[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:
295
electron/bridges/aiBridge/agentAuthProbes.cjs
Normal file
295
electron/bridges/aiBridge/agentAuthProbes.cjs
Normal file
@@ -0,0 +1,295 @@
|
||||
"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,
|
||||
};
|
||||
334
electron/bridges/aiBridge/agentAuthProbes.test.cjs
Normal file
334
electron/bridges/aiBridge/agentAuthProbes.test.cjs
Normal file
@@ -0,0 +1,334 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
probeClaudeAuth, probeCopilotAuth, probeCodexAuth, probeCodebuddyAuth, probeCursorCliAuth, probeGrokAuth,
|
||||
parseCursorStatusJson, resolveCursorCliSpawnSpec,
|
||||
} = require("./agentAuthProbes.cjs");
|
||||
const { prepareCommandForSpawn } = require("../ai/shellUtils.cjs");
|
||||
const { resolveCursorCliSpawnSpec: resolveSharedCursorCliSpawnSpec } = require("./cursorCliSpawn.cjs");
|
||||
|
||||
test("probeClaudeAuth: env ANTHROPIC_API_KEY -> authenticated env", () => {
|
||||
const r = probeClaudeAuth({
|
||||
env: { ANTHROPIC_API_KEY: "sk-x" },
|
||||
platform: "darwin",
|
||||
runSecurity: () => { throw new Error("should not be called"); },
|
||||
fileExists: () => false,
|
||||
});
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "env");
|
||||
});
|
||||
|
||||
test("probeClaudeAuth: macOS keychain hit -> authenticated keychain", () => {
|
||||
const r = probeClaudeAuth({
|
||||
env: {},
|
||||
platform: "darwin",
|
||||
runSecurity: () => ({ exitCode: 0, stdout: '{"claudeAiOauth":{}}' }),
|
||||
fileExists: () => false,
|
||||
});
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "keychain");
|
||||
});
|
||||
|
||||
test("probeClaudeAuth: linux credentials file -> authenticated credentials-file", () => {
|
||||
const r = probeClaudeAuth({
|
||||
env: {},
|
||||
platform: "linux",
|
||||
runSecurity: () => { throw new Error("no keychain on linux"); },
|
||||
fileExists: (p) => p.endsWith(".credentials.json"),
|
||||
});
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "credentials-file");
|
||||
});
|
||||
|
||||
test("probeClaudeAuth: nothing -> not authenticated", () => {
|
||||
const r = probeClaudeAuth({
|
||||
env: {}, platform: "darwin",
|
||||
runSecurity: () => ({ exitCode: 44, stdout: "" }),
|
||||
fileExists: () => false,
|
||||
});
|
||||
assert.equal(r.authenticated, false);
|
||||
assert.equal(r.authSource, null);
|
||||
});
|
||||
|
||||
test("probeCopilotAuth: gh auth status exit 0 -> authenticated gh", () => {
|
||||
const r = probeCopilotAuth({ runGhAuthStatus: () => ({ exitCode: 0, stderr: "Logged in to github.com" }) });
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "gh");
|
||||
});
|
||||
|
||||
test("probeCopilotAuth: gh auth status non-zero -> not authenticated", () => {
|
||||
const r = probeCopilotAuth({ runGhAuthStatus: () => ({ exitCode: 1, stderr: "not logged in" }) });
|
||||
assert.equal(r.authenticated, false);
|
||||
});
|
||||
|
||||
test("probeCodexAuth: 'Logged in using ChatGPT' -> authenticated chatgpt", () => {
|
||||
const r = probeCodexAuth({
|
||||
runLoginStatus: () => ({ exitCode: 0, stdout: "Logged in using ChatGPT" }),
|
||||
fileExists: () => false,
|
||||
});
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "chatgpt");
|
||||
});
|
||||
|
||||
test("probeCodexAuth: auth.json fallback -> authenticated auth-file", () => {
|
||||
const r = probeCodexAuth({
|
||||
runLoginStatus: () => ({ exitCode: 1, stdout: "not logged in" }),
|
||||
fileExists: (p) => p.endsWith("auth.json"),
|
||||
});
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "auth-file");
|
||||
});
|
||||
|
||||
// ── CodeBuddy ──
|
||||
test("probeCodebuddyAuth: CODEBUDDY_API_KEY env -> authenticated api-key", () => {
|
||||
const r = probeCodebuddyAuth({
|
||||
env: { CODEBUDDY_API_KEY: "cb-key-123" },
|
||||
readFile: () => null,
|
||||
});
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "api-key");
|
||||
});
|
||||
|
||||
test("probeCodebuddyAuth: CODEBUDDY_AUTH_TOKEN env -> authenticated auth-token", () => {
|
||||
const r = probeCodebuddyAuth({
|
||||
env: { CODEBUDDY_AUTH_TOKEN: "oauth-token-xyz" },
|
||||
readFile: () => null,
|
||||
});
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "auth-token");
|
||||
});
|
||||
|
||||
test("probeCodebuddyAuth: settings.json with authToken -> authenticated settings-file", () => {
|
||||
const r = probeCodebuddyAuth({
|
||||
env: {},
|
||||
readFile: () => '{"authToken":"real-token"}',
|
||||
});
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "settings-file");
|
||||
});
|
||||
|
||||
test("probeCodebuddyAuth: settings.json with apiKeyHelper -> authenticated settings-file", () => {
|
||||
const r = probeCodebuddyAuth({
|
||||
env: {},
|
||||
readFile: () => '{"apiKeyHelper":"/usr/local/bin/helper"}',
|
||||
});
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "settings-file");
|
||||
});
|
||||
|
||||
test("probeCodebuddyAuth: empty settings.json -> not authenticated", () => {
|
||||
const r = probeCodebuddyAuth({
|
||||
env: {},
|
||||
readFile: () => "",
|
||||
});
|
||||
assert.equal(r.authenticated, false);
|
||||
assert.equal(r.authSource, null);
|
||||
});
|
||||
|
||||
test("probeCodebuddyAuth: malformed JSON in settings.json -> not authenticated", () => {
|
||||
const r = probeCodebuddyAuth({
|
||||
env: {},
|
||||
readFile: () => "{not valid json",
|
||||
});
|
||||
assert.equal(r.authenticated, false);
|
||||
assert.equal(r.authSource, null);
|
||||
});
|
||||
|
||||
test("probeCodebuddyAuth: settings.json without auth fields -> not authenticated", () => {
|
||||
const r = probeCodebuddyAuth({
|
||||
env: {},
|
||||
readFile: () => '{"theme":"dark","language":"en"}',
|
||||
});
|
||||
assert.equal(r.authenticated, false);
|
||||
assert.equal(r.authSource, null);
|
||||
});
|
||||
|
||||
test("probeCodebuddyAuth: no env, no settings file -> not authenticated", () => {
|
||||
const r = probeCodebuddyAuth({
|
||||
env: {},
|
||||
readFile: () => null,
|
||||
});
|
||||
assert.equal(r.authenticated, false);
|
||||
assert.equal(r.authSource, null);
|
||||
});
|
||||
|
||||
test("probeCodebuddyAuth: CODEBUDDY_API_KEY takes precedence over settings.json", () => {
|
||||
const r = probeCodebuddyAuth({
|
||||
env: { CODEBUDDY_API_KEY: "cb-key" },
|
||||
readFile: () => '{"authToken":"token"}',
|
||||
});
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "api-key");
|
||||
});
|
||||
|
||||
// ── Cursor CLI login ──
|
||||
test("probeCursorCliAuth: prefers cursor-agent and parses authenticated JSON", () => {
|
||||
const calls = [];
|
||||
const r = probeCursorCliAuth({
|
||||
env: { CURSOR_API_KEY: "should-be-stripped" },
|
||||
resolveBinary: (name) => {
|
||||
calls.push(name);
|
||||
return name === "cursor-agent" ? "/bin/cursor-agent" : null;
|
||||
},
|
||||
runStatus: (bin) => {
|
||||
assert.equal(bin, "/bin/cursor-agent");
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({
|
||||
status: "authenticated",
|
||||
isAuthenticated: true,
|
||||
userInfo: { email: "user@example.com" },
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.deepEqual(calls, ["cursor-agent"]);
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "cli-login");
|
||||
assert.equal(r.email, "user@example.com");
|
||||
assert.equal(r.binPath, "/bin/cursor-agent");
|
||||
});
|
||||
|
||||
test("probeCursorCliAuth: does not fall back to bare agent binary", () => {
|
||||
const statusCalls = [];
|
||||
const resolveCalls = [];
|
||||
const r = probeCursorCliAuth({
|
||||
resolveBinary: (name) => {
|
||||
resolveCalls.push(name);
|
||||
return name === "agent" ? "/bin/agent" : null;
|
||||
},
|
||||
runStatus: (bin) => {
|
||||
statusCalls.push(bin);
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({ isAuthenticated: true, userInfo: { email: "a@b.c" } }),
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.deepEqual(resolveCalls, ["cursor-agent"]);
|
||||
assert.deepEqual(statusCalls, []);
|
||||
assert.equal(r.authenticated, false);
|
||||
assert.equal(r.binPath, null);
|
||||
});
|
||||
|
||||
// ── Grok Build ──
|
||||
test("probeGrokAuth: env XAI_API_KEY -> authenticated env", () => {
|
||||
const r = probeGrokAuth({
|
||||
env: { XAI_API_KEY: "xai-test" },
|
||||
fileExists: () => false,
|
||||
homeDir: "/home/user",
|
||||
});
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "env");
|
||||
});
|
||||
|
||||
test("probeGrokAuth: auth.json fallback -> authenticated auth-file", () => {
|
||||
const path = require("node:path");
|
||||
const homeDir = path.join("home", "user");
|
||||
const authPath = path.join(homeDir, ".grok", "auth.json");
|
||||
const r = probeGrokAuth({
|
||||
env: {},
|
||||
homeDir,
|
||||
fileExists: (p) => p === authPath,
|
||||
});
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "auth-file");
|
||||
});
|
||||
|
||||
test("probeGrokAuth: nothing -> not authenticated", () => {
|
||||
const r = probeGrokAuth({
|
||||
env: {},
|
||||
homeDir: "/home/user",
|
||||
fileExists: () => false,
|
||||
});
|
||||
assert.equal(r.authenticated, false);
|
||||
assert.equal(r.authSource, null);
|
||||
});
|
||||
|
||||
test("probeCursorCliAuth: unauthenticated JSON -> not authenticated but keeps binPath", () => {
|
||||
const r = probeCursorCliAuth({
|
||||
resolveBinary: (name) => (name === "cursor-agent" ? "/bin/cursor-agent" : null),
|
||||
runStatus: () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({ isAuthenticated: false }),
|
||||
}),
|
||||
});
|
||||
assert.equal(r.authenticated, false);
|
||||
assert.equal(r.authSource, null);
|
||||
assert.equal(r.binPath, "/bin/cursor-agent");
|
||||
});
|
||||
|
||||
test("probeCursorCliAuth: missing binary -> not authenticated", () => {
|
||||
const r = probeCursorCliAuth({
|
||||
resolveBinary: () => null,
|
||||
runStatus: () => { throw new Error("should not run"); },
|
||||
});
|
||||
assert.equal(r.authenticated, false);
|
||||
assert.equal(r.binPath, null);
|
||||
});
|
||||
|
||||
test("probeCursorCliAuth: status command failure -> not authenticated", () => {
|
||||
const r = probeCursorCliAuth({
|
||||
resolveBinary: () => "/bin/cursor-agent",
|
||||
runStatus: () => ({ exitCode: 1, stdout: "", stderr: "boom" }),
|
||||
});
|
||||
assert.equal(r.authenticated, false);
|
||||
assert.equal(r.binPath, "/bin/cursor-agent");
|
||||
});
|
||||
|
||||
test("probeCursorCliAuth: unrecognized status stdout keeps resolved binPath", () => {
|
||||
const r = probeCursorCliAuth({
|
||||
resolveBinary: () => "/bin/cursor-agent",
|
||||
runStatus: () => ({ exitCode: 0, stdout: "cursor-agent 1.2.3\nnot json" }),
|
||||
});
|
||||
assert.equal(r.authenticated, false);
|
||||
assert.equal(r.authSource, null);
|
||||
assert.equal(r.binPath, "/bin/cursor-agent");
|
||||
});
|
||||
|
||||
test("probeCursorCliAuth: thrown status probe keeps resolved binPath", () => {
|
||||
const r = probeCursorCliAuth({
|
||||
resolveBinary: () => "/bin/cursor-agent",
|
||||
runStatus: () => { throw new Error("timeout"); },
|
||||
});
|
||||
assert.equal(r.authenticated, false);
|
||||
assert.equal(r.authSource, null);
|
||||
assert.equal(r.binPath, "/bin/cursor-agent");
|
||||
});
|
||||
|
||||
test("probeCursorCliAuth: extracts authenticated JSON wrapped in cmd noise", () => {
|
||||
const r = probeCursorCliAuth({
|
||||
resolveBinary: () => "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.cmd",
|
||||
runStatus: () => ({
|
||||
exitCode: 0,
|
||||
stdout: "Starting...\r\n{\"status\":\"authenticated\",\"isAuthenticated\":true,\"userInfo\":{\"email\":\"user@example.com\"}}\r\n",
|
||||
}),
|
||||
});
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "cli-login");
|
||||
assert.equal(r.email, "user@example.com");
|
||||
});
|
||||
|
||||
test("parseCursorStatusJson accepts BOM and surrounding text", () => {
|
||||
const parsed = parseCursorStatusJson(
|
||||
"\uFEFFnoise\n{\"isAuthenticated\":true,\"status\":\"authenticated\"}\n",
|
||||
);
|
||||
assert.equal(parsed.isAuthenticated, true);
|
||||
assert.equal(parsed.status, "authenticated");
|
||||
});
|
||||
|
||||
test("resolveCursorCliSpawnSpec re-exports the shared native launch helper", () => {
|
||||
const shim = "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.cmd";
|
||||
const args = ["status", "--format", "json"];
|
||||
assert.deepEqual(
|
||||
resolveCursorCliSpawnSpec(shim, args),
|
||||
resolveSharedCursorCliSpawnSpec(shim, args),
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveSharedCursorCliSpawnSpec(shim, args, {
|
||||
exists: () => false,
|
||||
readFile: () => { throw new Error("missing"); },
|
||||
}),
|
||||
prepareCommandForSpawn(shim, args, { unwrapNativeExe: false }),
|
||||
);
|
||||
});
|
||||
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,
|
||||
};
|
||||
237
electron/bridges/aiBridge/agentCliHelpers.test.cjs
Normal file
237
electron/bridges/aiBridge/agentCliHelpers.test.cjs
Normal file
@@ -0,0 +1,237 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { PassThrough } = require("node:stream");
|
||||
|
||||
const {
|
||||
createAgentCliHelpers,
|
||||
CODEX_AUTH_VALIDATION_TIMEOUT_MS,
|
||||
DEFAULT_CODEX_CLI_TIMEOUT_MS,
|
||||
MAX_AGENT_CLI_BUFFER_CHARS,
|
||||
} = require("./agentCliHelpers.cjs");
|
||||
|
||||
function createHungChild() {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kills = [];
|
||||
child.kill = (signal) => {
|
||||
child.kills.push(signal);
|
||||
return true;
|
||||
};
|
||||
return child;
|
||||
}
|
||||
|
||||
test("runCodexCli applies a default timeout to short status commands", async () => {
|
||||
const child = createHungChild();
|
||||
const scheduled = [];
|
||||
const helpers = createAgentCliHelpers({
|
||||
prepareCommandForSpawn: (command, args) => ({ command, args, shell: false }),
|
||||
spawn: () => child,
|
||||
stripAnsi: (value) => value,
|
||||
getShellEnv: async () => ({}),
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
resolveCliFromPathAsync: async () => "/fake/codex",
|
||||
setTimeout: (callback, delay) => {
|
||||
scheduled.push(delay);
|
||||
queueMicrotask(callback);
|
||||
return { unref() {} };
|
||||
},
|
||||
clearTimeout() {},
|
||||
});
|
||||
const safetyTimer = globalThis.setTimeout(() => child.emit("close", 0), 25);
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
helpers.runCodexCli(["login", "status"], {}),
|
||||
(error) => error?.code === "ETIMEDOUT",
|
||||
);
|
||||
} finally {
|
||||
globalThis.clearTimeout(safetyTimer);
|
||||
}
|
||||
assert.equal(scheduled[0], DEFAULT_CODEX_CLI_TIMEOUT_MS);
|
||||
assert.deepEqual(child.kills, ["SIGTERM", "SIGKILL"]);
|
||||
});
|
||||
|
||||
test("runCodexCli slices a single oversized output chunk to the hard limit", async () => {
|
||||
const child = createHungChild();
|
||||
const helpers = createAgentCliHelpers({
|
||||
prepareCommandForSpawn: (command, args) => ({ command, args, shell: false }),
|
||||
spawn: () => {
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit("data", Buffer.from("x".repeat(MAX_AGENT_CLI_BUFFER_CHARS + 257)));
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
},
|
||||
stripAnsi: (value) => value,
|
||||
getShellEnv: async () => ({}),
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
resolveCliFromPathAsync: async () => "/fake/codex",
|
||||
});
|
||||
|
||||
const result = await helpers.runCodexCli(["--version"], {});
|
||||
assert.equal(result.stdout.length, MAX_AGENT_CLI_BUFFER_CHARS);
|
||||
});
|
||||
|
||||
test("runCodexCli preserves split UTF-8 independently on stdout and stderr", async () => {
|
||||
const child = createHungChild();
|
||||
const helpers = createAgentCliHelpers({
|
||||
prepareCommandForSpawn: (command, args) => ({ command, args, shell: false }),
|
||||
spawn: () => {
|
||||
queueMicrotask(() => {
|
||||
const stdout = Buffer.from("中文", "utf8");
|
||||
const stderr = Buffer.from("错误", "utf8");
|
||||
child.stdout.emit("data", stdout.subarray(0, 2));
|
||||
child.stderr.emit("data", stderr.subarray(0, 1));
|
||||
child.stdout.emit("data", stdout.subarray(2));
|
||||
child.stderr.emit("data", stderr.subarray(1));
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
},
|
||||
stripAnsi: (value) => value,
|
||||
getShellEnv: async () => ({}),
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
resolveCliFromPathAsync: async () => "/fake/codex",
|
||||
});
|
||||
|
||||
const result = await helpers.runCodexCli(["--version"], {});
|
||||
assert.deepEqual(result, { stdout: "中文", stderr: "错误", exitCode: 0 });
|
||||
});
|
||||
|
||||
test("runCodexCli omits an incomplete UTF-8 suffix at its byte limit", async () => {
|
||||
const child = createHungChild();
|
||||
const helpers = createAgentCliHelpers({
|
||||
prepareCommandForSpawn: (command, args) => ({ command, args, shell: false }),
|
||||
spawn: () => {
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit("data", Buffer.from("x".repeat(MAX_AGENT_CLI_BUFFER_CHARS - 1)));
|
||||
child.stdout.emit("data", Buffer.from("中", "utf8"));
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
},
|
||||
stripAnsi: (value) => value,
|
||||
getShellEnv: async () => ({}),
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
resolveCliFromPathAsync: async () => "/fake/codex",
|
||||
});
|
||||
|
||||
const result = await helpers.runCodexCli(["--version"], {});
|
||||
assert.equal(result.stdout.length, MAX_AGENT_CLI_BUFFER_CHARS - 1);
|
||||
assert.doesNotMatch(result.stdout, /<2F>/u);
|
||||
});
|
||||
|
||||
function createValidationHelpers({ loadCodexSdk, setTimeout, clearTimeout }) {
|
||||
return createAgentCliHelpers({
|
||||
getCodexValidationCache: () => null,
|
||||
setCodexValidationCache() {},
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
getShellEnv: async () => ({}),
|
||||
resolveSdkBinPathAsync: async () => "/fake/codex",
|
||||
resolveCodexExecutableForSdk: (value) => value,
|
||||
addCodexExecutableEnvForSdk: (env) => env,
|
||||
extractCodexError: (error) => ({ message: error?.message || String(error) }),
|
||||
loadCodexSdk,
|
||||
...(setTimeout ? { setTimeout } : {}),
|
||||
...(clearTimeout ? { clearTimeout } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
test("ChatGPT auth validation coalesces concurrent probes and cleans the stream", async () => {
|
||||
let runCount = 0;
|
||||
let returnCount = 0;
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const helpers = createValidationHelpers({
|
||||
loadCodexSdk: async () => ({
|
||||
Codex: class {
|
||||
startThread() {
|
||||
return {
|
||||
async runStreamed(_prompt, options) {
|
||||
runCount += 1;
|
||||
assert.equal(options.signal.aborted, false);
|
||||
const iterator = {
|
||||
async next() {
|
||||
await gate;
|
||||
return { done: false, value: { type: "item.completed" } };
|
||||
},
|
||||
async return() {
|
||||
returnCount += 1;
|
||||
return { done: true };
|
||||
},
|
||||
};
|
||||
return { events: { [Symbol.asyncIterator]: () => iterator } };
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const first = helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex" });
|
||||
const second = helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex" });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(runCount, 1);
|
||||
release();
|
||||
|
||||
assert.deepEqual(await Promise.all([first, second]), [
|
||||
{ ok: true, checkedAt: (await first).checkedAt, codexPath: "/fake/codex", error: null },
|
||||
{ ok: true, checkedAt: (await second).checkedAt, codexPath: "/fake/codex", error: null },
|
||||
]);
|
||||
assert.equal(returnCount, 1);
|
||||
});
|
||||
|
||||
test("ChatGPT auth validation aborts and settles when the SDK stream hangs", async () => {
|
||||
let observedSignal;
|
||||
let returnCount = 0;
|
||||
const scheduled = [];
|
||||
let releaseSafety;
|
||||
const safetyGate = new Promise((resolve) => { releaseSafety = resolve; });
|
||||
const helpers = createValidationHelpers({
|
||||
loadCodexSdk: async () => ({
|
||||
Codex: class {
|
||||
startThread() {
|
||||
return {
|
||||
async runStreamed(_prompt, options) {
|
||||
observedSignal = options.signal;
|
||||
const iterator = {
|
||||
async next() {
|
||||
await safetyGate;
|
||||
return { done: false, value: { type: "item.completed" } };
|
||||
},
|
||||
async return() {
|
||||
returnCount += 1;
|
||||
return { done: true };
|
||||
},
|
||||
};
|
||||
return { events: { [Symbol.asyncIterator]: () => iterator } };
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
}),
|
||||
setTimeout: (callback, delay) => {
|
||||
scheduled.push(delay);
|
||||
queueMicrotask(callback);
|
||||
return { unref() {} };
|
||||
},
|
||||
clearTimeout() {},
|
||||
});
|
||||
const safetyTimer = globalThis.setTimeout(releaseSafety, 25);
|
||||
|
||||
try {
|
||||
const result = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex" });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /timed out/i);
|
||||
} finally {
|
||||
globalThis.clearTimeout(safetyTimer);
|
||||
releaseSafety();
|
||||
}
|
||||
assert.equal(scheduled[0], CODEX_AUTH_VALIDATION_TIMEOUT_MS);
|
||||
assert.equal(observedSignal.aborted, true);
|
||||
assert.equal(returnCount, 1);
|
||||
});
|
||||
487
electron/bridges/aiBridge/agentDiscoveryHandlers.cjs
Normal file
487
electron/bridges/aiBridge/agentDiscoveryHandlers.cjs
Normal file
@@ -0,0 +1,487 @@
|
||||
/* eslint-disable no-undef */
|
||||
function getCursorPlatformPackageName(platform = process.platform, arch = process.arch) {
|
||||
if (platform === "darwin" && (arch === "arm64" || arch === "x64")) return `@cursor/sdk-darwin-${arch}`;
|
||||
if (platform === "linux" && (arch === "arm64" || arch === "x64")) return `@cursor/sdk-linux-${arch}`;
|
||||
if (platform === "win32" && arch === "x64") return "@cursor/sdk-win32-x64";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bundled @cursor/sdk is importable in every Netcatty build. "installed" is the
|
||||
// user's Cursor Agent CLI, not that bundled package.
|
||||
function computeCursorInstallState({ sdkInstalled, cliBinPath, cliLoginOk } = {}) {
|
||||
return {
|
||||
sdkInstalled: Boolean(sdkInstalled),
|
||||
installed: Boolean(cliBinPath) || Boolean(cliLoginOk),
|
||||
};
|
||||
}
|
||||
|
||||
async function probeCursorSdkAvailability(shellEnv, options = {}) {
|
||||
const platformPackageName = getCursorPlatformPackageName();
|
||||
|
||||
let sdkInstalled = false;
|
||||
if (platformPackageName) {
|
||||
try {
|
||||
await import("@cursor/sdk");
|
||||
require.resolve(`${platformPackageName}/package.json`);
|
||||
sdkInstalled = true;
|
||||
} catch {
|
||||
sdkInstalled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const hasEnvApiKey = Boolean(shellEnv?.CURSOR_API_KEY);
|
||||
const hasSettingsApiKey = Boolean(options?.apiKeyPresent);
|
||||
const apiKeyOk = hasSettingsApiKey || hasEnvApiKey;
|
||||
const probeCli = typeof options?.probeCursorCliAuth === "function"
|
||||
? options.probeCursorCliAuth
|
||||
: null;
|
||||
let cliAuth = { authenticated: false, authSource: null, email: null, binPath: null };
|
||||
try {
|
||||
if (probeCli) {
|
||||
cliAuth = probeCli({ env: shellEnv }) || cliAuth;
|
||||
}
|
||||
} catch {
|
||||
cliAuth = { authenticated: false, authSource: null, email: null, binPath: null };
|
||||
}
|
||||
|
||||
const cliLoginOk = Boolean(cliAuth.authenticated);
|
||||
const authenticated = apiKeyOk || cliLoginOk;
|
||||
// authSource describes the primary credential for display priority; CLI UI
|
||||
// must use cliLoginOk, not this field alone.
|
||||
let authSource = null;
|
||||
if (hasSettingsApiKey) authSource = "settings";
|
||||
else if (hasEnvApiKey) authSource = "CURSOR_API_KEY";
|
||||
else if (cliLoginOk) authSource = "cli-login";
|
||||
|
||||
const installState = computeCursorInstallState({
|
||||
sdkInstalled,
|
||||
cliBinPath: cliAuth.binPath,
|
||||
cliLoginOk,
|
||||
});
|
||||
sdkInstalled = installState.sdkInstalled;
|
||||
// Available if either mode can run a turn (API key + SDK, or CLI login).
|
||||
const available = (apiKeyOk && sdkInstalled) || cliLoginOk;
|
||||
const installed = installState.installed;
|
||||
return {
|
||||
installed,
|
||||
sdkInstalled,
|
||||
available,
|
||||
authenticated,
|
||||
authSource,
|
||||
apiKeyOk,
|
||||
cliLoginOk,
|
||||
version: sdkInstalled ? "Cursor SDK" : (cliLoginOk || cliAuth.binPath ? "Cursor Agent CLI" : null),
|
||||
cliBinPath: cliAuth.binPath || null,
|
||||
cliEmail: cliAuth.email || null,
|
||||
};
|
||||
}
|
||||
|
||||
function registerAgentDiscoveryHandlers(ctx) {
|
||||
with (ctx) {
|
||||
ipcMain.handle("netcatty:ai:agents:discover", async (event, options = {}) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
if (options?.refreshShellEnv) {
|
||||
invalidateShellEnvCache();
|
||||
}
|
||||
const agents = [];
|
||||
const knownAgents = [
|
||||
{ command: "claude", name: "Claude Code", icon: "claude",
|
||||
description: "Anthropic's agentic coding assistant", sdkBackend: "claude", args: [] },
|
||||
{ command: "codex", name: "Codex CLI", icon: "openai",
|
||||
description: "OpenAI's coding agent", sdkBackend: "codex", args: [] },
|
||||
{ command: "copilot", name: "GitHub Copilot CLI", icon: "copilot",
|
||||
description: "GitHub's coding agent CLI", sdkBackend: "copilot", args: [] },
|
||||
{ command: "cursor", name: "Cursor", icon: "cursor",
|
||||
description: "Cursor's coding agent via Cursor SDK", sdkBackend: "cursor", args: [] },
|
||||
{ command: "codebuddy", name: "CodeBuddy Code", icon: "codebuddy",
|
||||
description: "Tencent's coding agent CLI (Agent SDK)", sdkBackend: "codebuddy", args: [] },
|
||||
{ command: "opencode", name: "OpenCode", icon: "opencode",
|
||||
description: "Open source coding agent via the official OpenCode SDK", sdkBackend: "opencode", args: [] },
|
||||
{ command: "grok", name: "Grok Build", icon: "grok",
|
||||
description: "xAI's Grok Build coding agent CLI", sdkBackend: "grok", args: [] },
|
||||
];
|
||||
|
||||
const shellEnv = await getShellEnv();
|
||||
const seenPaths = new Set();
|
||||
|
||||
for (const agent of knownAgents) {
|
||||
let cursorSdkStatus = null;
|
||||
if (agent.command === "cursor") {
|
||||
cursorSdkStatus = await probeCursorSdkAvailability(shellEnv, {
|
||||
apiKeyPresent: Boolean(options?.apiKeyPresent),
|
||||
probeCursorCliAuth,
|
||||
});
|
||||
if (!cursorSdkStatus.available) continue;
|
||||
}
|
||||
|
||||
const resolvedPath = agent.command === "cursor"
|
||||
? (cursorSdkStatus.cliLoginOk
|
||||
? (cursorSdkStatus.cliBinPath || "cursor")
|
||||
: (cursorSdkStatus.sdkInstalled ? "cursor" : (cursorSdkStatus.cliBinPath || "cursor")))
|
||||
: await resolveCliFromPathAsync(agent.command, shellEnv); // Layer-1: locate
|
||||
if (!resolvedPath || seenPaths.has(resolvedPath)) continue;
|
||||
|
||||
const probe = agent.command === "cursor"
|
||||
? { exitCode: 0, version: cursorSdkStatus.version }
|
||||
: await probeCliVersion(resolvedPath, ["--version"], shellEnv); // Layer-2: version
|
||||
const hasPlausibleVersion = agent.command === "cursor"
|
||||
? probe.exitCode === 0
|
||||
: probe.exitCode === 0 && isPlausibleCliVersionOutput(probe.version);
|
||||
if (!hasPlausibleVersion) continue;
|
||||
|
||||
// Layer-3: authentication (best-effort; never blocks discovery).
|
||||
let auth = { authenticated: false, authSource: null };
|
||||
try {
|
||||
if (agent.command === "claude") {
|
||||
auth = probeClaudeAuth({ env: shellEnv });
|
||||
} else if (agent.command === "copilot") {
|
||||
auth = probeCopilotAuth({});
|
||||
} else if (agent.command === "codex") {
|
||||
auth = { authenticated: false, authSource: null };
|
||||
} else if (agent.command === "cursor") {
|
||||
auth = {
|
||||
authenticated: cursorSdkStatus.authenticated,
|
||||
authSource: cursorSdkStatus.authSource,
|
||||
};
|
||||
} else if (agent.command === "codebuddy") {
|
||||
auth = probeCodebuddyAuth({ env: shellEnv });
|
||||
} else if (agent.command === "opencode") {
|
||||
auth = { authenticated: true, authSource: "opencode-config" };
|
||||
} else if (agent.command === "grok") {
|
||||
auth = probeGrokAuth({ env: shellEnv });
|
||||
}
|
||||
} catch { /* auth probe is best-effort */ }
|
||||
|
||||
agents.push({
|
||||
command: agent.command,
|
||||
name: agent.name,
|
||||
icon: agent.icon,
|
||||
description: agent.description,
|
||||
sdkBackend: agent.sdkBackend,
|
||||
args: agent.args,
|
||||
path: resolvedPath,
|
||||
binPath: resolvedPath,
|
||||
version: probe.version,
|
||||
installed: agent.command === "cursor" ? Boolean(cursorSdkStatus.installed) : true,
|
||||
available: true,
|
||||
authenticated: auth.authenticated,
|
||||
authSource: auth.authSource,
|
||||
...(agent.command === "cursor" ? {
|
||||
cliEmail: cursorSdkStatus.cliEmail || null,
|
||||
cliBinPath: cursorSdkStatus.cliBinPath || null,
|
||||
cliLoginOk: Boolean(cursorSdkStatus.cliLoginOk),
|
||||
apiKeyOk: Boolean(cursorSdkStatus.apiKeyOk),
|
||||
sdkInstalled: Boolean(cursorSdkStatus.sdkInstalled),
|
||||
} : {}),
|
||||
});
|
||||
seenPaths.add(resolvedPath);
|
||||
}
|
||||
|
||||
return agents;
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:shell-env:prewarm", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
await getShellEnv();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve a CLI binary path (auto-detect or validate custom path)
|
||||
ipcMain.handle("netcatty:ai:resolve-cli", async (event, { command, customPath, refreshShellEnv, apiKeyPresent }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
if (refreshShellEnv) {
|
||||
invalidateShellEnvCache();
|
||||
}
|
||||
const shellEnv = await getShellEnv();
|
||||
const hasCustomPath = command !== "cursor" && Boolean(String(customPath || "").trim());
|
||||
|
||||
let resolvedPath;
|
||||
if (hasCustomPath) {
|
||||
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
|
||||
// A user-supplied path must be validated as-is; falling back to PATH would
|
||||
// make Settings appear to accept one binary while actually using another.
|
||||
resolvedPath = normalizeCliPathForPlatform(customPath);
|
||||
} else {
|
||||
resolvedPath = await resolveCliFromPathAsync(command, shellEnv);
|
||||
}
|
||||
|
||||
if (command === "cursor") {
|
||||
const cursorSdkStatus = await probeCursorSdkAvailability(shellEnv, {
|
||||
apiKeyPresent: Boolean(apiKeyPresent),
|
||||
probeCursorCliAuth,
|
||||
});
|
||||
// Prefer CLI bin only when CLI login is proven. Otherwise do not use a
|
||||
// PATH `agent` binary (generic name) for API-key/SDK path identity.
|
||||
const resolvedSdkPath = await resolveCliFromPathAsync(command, shellEnv);
|
||||
const cursorPath = cursorSdkStatus.cliLoginOk
|
||||
? (cursorSdkStatus.cliBinPath || resolvedSdkPath || "cursor")
|
||||
: (resolvedSdkPath || "cursor");
|
||||
// Keep the SDK sentinel path when the bundled SDK is importable so
|
||||
// API-key mode still has an identity without Cursor.app / Agent CLI.
|
||||
const hasCursorPath = cursorSdkStatus.sdkInstalled
|
||||
|| cursorSdkStatus.installed
|
||||
|| cursorSdkStatus.available;
|
||||
return {
|
||||
path: hasCursorPath ? cursorPath : null,
|
||||
binPath: hasCursorPath ? cursorPath : null,
|
||||
version: cursorSdkStatus.version,
|
||||
available: cursorSdkStatus.available,
|
||||
installed: cursorSdkStatus.installed,
|
||||
authenticated: cursorSdkStatus.authenticated,
|
||||
authSource: cursorSdkStatus.authSource,
|
||||
cliEmail: cursorSdkStatus.cliEmail || null,
|
||||
cliBinPath: cursorSdkStatus.cliBinPath || null,
|
||||
cliLoginOk: Boolean(cursorSdkStatus.cliLoginOk),
|
||||
apiKeyOk: Boolean(cursorSdkStatus.apiKeyOk),
|
||||
sdkInstalled: Boolean(cursorSdkStatus.sdkInstalled),
|
||||
};
|
||||
}
|
||||
|
||||
if (!resolvedPath) {
|
||||
return { path: null, binPath: null, version: null, available: false, installed: false };
|
||||
}
|
||||
|
||||
const probe = await probeCliVersion(resolvedPath, ["--version"], shellEnv);
|
||||
const hasPlausibleVersion = command === "cursor"
|
||||
? probe.exitCode === 0
|
||||
: probe.exitCode === 0 && isPlausibleCliVersionOutput(probe.version);
|
||||
if (!hasPlausibleVersion) {
|
||||
return { path: resolvedPath, binPath: resolvedPath, version: null, available: false, installed: true };
|
||||
}
|
||||
|
||||
return { path: resolvedPath, binPath: resolvedPath, version: probe.version, available: true, installed: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:get-integration", async (event, options) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
// When the user clicks "Refresh Status" in Settings we also want to
|
||||
// rescan the shell env — otherwise a newly-exported variable in
|
||||
// .zshrc stays invisible until they restart netcatty entirely.
|
||||
if (options && options.refreshShellEnv) {
|
||||
invalidateShellEnvCache();
|
||||
}
|
||||
try {
|
||||
const codexCliOptions = { codexPath: options?.codexPath };
|
||||
const result = await runCodexCli(["login", "status"], codexCliOptions);
|
||||
const rawOutput = [result.stdout, result.stderr]
|
||||
.filter((chunk) => chunk.trim().length > 0)
|
||||
.join("\n")
|
||||
.trim();
|
||||
let state = normalizeCodexIntegrationState(rawOutput);
|
||||
let effectiveRawOutput = rawOutput;
|
||||
|
||||
if (state === "connected_chatgpt" && options?.validateChatGptAuth === true) {
|
||||
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000, codexPath: options?.codexPath });
|
||||
if (!validation.ok) {
|
||||
if (isCodexAuthError(validation)) {
|
||||
try {
|
||||
await runCodexCli(["logout"], codexCliOptions);
|
||||
} catch {
|
||||
// Ignore logout failures; we still want to surface the invalid state.
|
||||
}
|
||||
invalidateCodexValidationCache();
|
||||
state = "not_logged_in";
|
||||
}
|
||||
|
||||
effectiveRawOutput = appendCodexChatGptValidationFailure(
|
||||
rawOutput,
|
||||
validation.error || "Unknown validation error",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// `codex login status` only reflects ~/.codex/auth.json. A user who
|
||||
// configured a custom provider directly in ~/.codex/config.toml is
|
||||
// functional from the CLI but would look "not_logged_in" here. Probe
|
||||
// config.toml so we can surface that as a valid ready state instead of
|
||||
// pushing the user into the ChatGPT login flow.
|
||||
let customConfig = null;
|
||||
if (state !== "connected_chatgpt" && state !== "connected_api_key") {
|
||||
try {
|
||||
const shellEnv = await getShellEnv();
|
||||
customConfig = readCodexCustomProviderConfig(shellEnv);
|
||||
if (customConfig) {
|
||||
state = "connected_custom_config";
|
||||
}
|
||||
} catch {
|
||||
customConfig = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
isConnected:
|
||||
state === "connected_chatgpt" ||
|
||||
state === "connected_api_key" ||
|
||||
state === "connected_custom_config",
|
||||
rawOutput: effectiveRawOutput,
|
||||
exitCode: result.exitCode,
|
||||
customConfig,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
state: "unknown",
|
||||
isConnected: false,
|
||||
rawOutput: err?.message || String(err),
|
||||
exitCode: null,
|
||||
customConfig: null,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:start-login", async (event, options = {}) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const requestedPath = String(options?.codexPath || "").trim();
|
||||
const requestedCodexPath = requestedPath ? normalizeCliPathForPlatform?.(requestedPath) : null;
|
||||
if (requestedPath && !requestedCodexPath) {
|
||||
return { ok: false, error: `Codex CLI path not found: ${requestedPath}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const shellEnv = await getShellEnv();
|
||||
const codexCliPath = requestedCodexPath
|
||||
|| await resolveCliFromPathAsync("codex", shellEnv)
|
||||
|| "codex";
|
||||
const existingSession = getActiveCodexLoginSession();
|
||||
if (existingSession) {
|
||||
const existingPath = existingSession.codexPath || null;
|
||||
if (existingPath && codexCliPath !== existingPath) {
|
||||
return { ok: false, error: "A Codex login is already running for a different CLI path." };
|
||||
}
|
||||
return { ok: true, session: toCodexLoginSessionResponse(existingSession) };
|
||||
}
|
||||
|
||||
const sessionId = `codex_login_${randomUUID()}`;
|
||||
const spawnSpec = prepareCommandForSpawn(codexCliPath, ["login"]);
|
||||
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: shellEnv,
|
||||
shell: spawnSpec.shell,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const session = {
|
||||
id: sessionId,
|
||||
process: child,
|
||||
state: "running",
|
||||
output: "",
|
||||
url: null,
|
||||
error: null,
|
||||
exitCode: null,
|
||||
codexPath: codexCliPath,
|
||||
};
|
||||
|
||||
const stdoutDecoder = createCodexLoginOutputDecoder(session);
|
||||
const stderrDecoder = createCodexLoginOutputDecoder(session);
|
||||
let outputEnded = false;
|
||||
const endOutput = () => {
|
||||
if (outputEnded) return;
|
||||
outputEnded = true;
|
||||
stdoutDecoder.end();
|
||||
stderrDecoder.end();
|
||||
};
|
||||
|
||||
child.stdout.on("data", (chunk) => stdoutDecoder.write(chunk));
|
||||
child.stderr.on("data", (chunk) => stderrDecoder.write(chunk));
|
||||
|
||||
child.once("error", (error) => {
|
||||
endOutput();
|
||||
clearCodexLoginKillTimer(session);
|
||||
session.state = "error";
|
||||
session.error = `[codex] Failed to start login flow: ${error.message}`;
|
||||
session.process = null;
|
||||
recordCodexLoginSession(session);
|
||||
});
|
||||
|
||||
child.once("close", (exitCode) => {
|
||||
endOutput();
|
||||
clearCodexLoginKillTimer(session);
|
||||
session.exitCode = exitCode;
|
||||
session.process = null;
|
||||
|
||||
if (session.state === "cancelled") {
|
||||
recordCodexLoginSession(session);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exitCode === 0) {
|
||||
session.state = "success";
|
||||
session.error = null;
|
||||
} else {
|
||||
session.state = "error";
|
||||
session.error = session.error || `Codex login exited with code ${exitCode ?? "unknown"}`;
|
||||
}
|
||||
recordCodexLoginSession(session);
|
||||
});
|
||||
|
||||
recordCodexLoginSession(session);
|
||||
invalidateCodexValidationCache();
|
||||
return { ok: true, session: toCodexLoginSessionResponse(session) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:get-login-session", async (event, { sessionId }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const session = codexLoginSessions.get(sessionId);
|
||||
if (!session) {
|
||||
return { ok: false, error: "Codex login session not found" };
|
||||
}
|
||||
return { ok: true, session: toCodexLoginSessionResponse(session) };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:cancel-login", async (event, { sessionId }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const session = codexLoginSessions.get(sessionId);
|
||||
if (!session) {
|
||||
return { ok: true, found: false };
|
||||
}
|
||||
|
||||
session.state = "cancelled";
|
||||
session.error = null;
|
||||
stopCodexLoginProcess(session);
|
||||
recordCodexLoginSession(session);
|
||||
|
||||
invalidateCodexValidationCache();
|
||||
return { ok: true, found: true, session: toCodexLoginSessionResponse(session) };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:logout", async (event, options = {}) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const codexCliOptions = { codexPath: options?.codexPath };
|
||||
const logoutResult = await runCodexCli(["logout"], codexCliOptions);
|
||||
invalidateCodexValidationCache();
|
||||
const statusResult = await runCodexCli(["login", "status"], codexCliOptions);
|
||||
const rawOutput = [statusResult.stdout, statusResult.stderr]
|
||||
.filter((chunk) => chunk.trim().length > 0)
|
||||
.join("\n")
|
||||
.trim();
|
||||
const state = normalizeCodexIntegrationState(rawOutput);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
isConnected:
|
||||
state === "connected_chatgpt" ||
|
||||
state === "connected_api_key" ||
|
||||
state === "connected_custom_config",
|
||||
rawOutput,
|
||||
logoutOutput: [logoutResult.stdout, logoutResult.stderr]
|
||||
.filter((chunk) => chunk.trim().length > 0)
|
||||
.join("\n")
|
||||
.trim(),
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { registerAgentDiscoveryHandlers, computeCursorInstallState };
|
||||
45
electron/bridges/aiBridge/agentDiscoveryHandlers.test.cjs
Normal file
45
electron/bridges/aiBridge/agentDiscoveryHandlers.test.cjs
Normal file
@@ -0,0 +1,45 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { computeCursorInstallState } = require("./agentDiscoveryHandlers.cjs");
|
||||
|
||||
test("computeCursorInstallState: bundled SDK is not a user Cursor install", () => {
|
||||
const state = computeCursorInstallState({
|
||||
sdkInstalled: true,
|
||||
cliBinPath: null,
|
||||
cliLoginOk: false,
|
||||
});
|
||||
assert.equal(state.sdkInstalled, true);
|
||||
assert.equal(state.installed, false);
|
||||
});
|
||||
|
||||
test("computeCursorInstallState: Agent CLI on PATH is a user Cursor install", () => {
|
||||
const state = computeCursorInstallState({
|
||||
sdkInstalled: true,
|
||||
cliBinPath: "/usr/local/bin/cursor-agent",
|
||||
cliLoginOk: false,
|
||||
});
|
||||
assert.equal(state.sdkInstalled, true);
|
||||
assert.equal(state.installed, true);
|
||||
});
|
||||
|
||||
test("computeCursorInstallState: logged-out CLI path is installed without cliLoginOk", () => {
|
||||
const state = computeCursorInstallState({
|
||||
sdkInstalled: true,
|
||||
cliBinPath: "/bin/cursor-agent",
|
||||
cliLoginOk: false,
|
||||
});
|
||||
assert.equal(state.installed, true);
|
||||
assert.equal(state.sdkInstalled, true);
|
||||
});
|
||||
|
||||
test("computeCursorInstallState: proven CLI login is a user Cursor install", () => {
|
||||
const state = computeCursorInstallState({
|
||||
sdkInstalled: false,
|
||||
cliBinPath: null,
|
||||
cliLoginOk: true,
|
||||
});
|
||||
assert.equal(state.sdkInstalled, false);
|
||||
assert.equal(state.installed, true);
|
||||
});
|
||||
154
electron/bridges/aiBridge/agentProcessHandlers.cjs
Normal file
154
electron/bridges/aiBridge/agentProcessHandlers.cjs
Normal file
@@ -0,0 +1,154 @@
|
||||
/* eslint-disable no-undef */
|
||||
function registerAgentProcessHandlers(ctx) {
|
||||
with (ctx) {
|
||||
const maxCommandTimeoutSeconds = 24 * 60 * 60;
|
||||
// ── MCP Server session metadata ──
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:update-sessions", async (event, { sessions: sessionList, chatSessionId }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const list = Array.isArray(sessionList) ? sessionList : [];
|
||||
const externalId = mcpServerBridge.EXTERNAL_MCP_CHAT_SESSION_ID;
|
||||
if (chatSessionId === externalId) {
|
||||
// App-wide External MCP scope is owned by the main-window full-session sync.
|
||||
// Reject writes while disabled so in-flight renderer pushes cannot resurrect
|
||||
// metadata after stopActiveRuntime cleared the scope.
|
||||
try {
|
||||
const external = typeof getExternalMcpController === "function"
|
||||
? getExternalMcpController()
|
||||
: null;
|
||||
if (!external?.isEnabled?.()) {
|
||||
return { ok: false, error: "External MCP is disabled" };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, error: "External MCP is unavailable" };
|
||||
}
|
||||
}
|
||||
mcpServerBridge.updateSessionMetadata(list, chatSessionId);
|
||||
return { ok: true, count: list.length };
|
||||
});
|
||||
|
||||
// App-owned live session state is independent of the optional External MCP
|
||||
// surface. It lets host_open-owned chat scopes observe connection changes
|
||||
// even when the opened terminal never mounts its own AI side panel.
|
||||
ipcMain.handle("netcatty:ai:mcp:update-live-sessions", async (event, { sessions: sessionList }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
return mcpServerBridge.updateLiveSessionMetadata(
|
||||
Array.isArray(sessionList) ? sessionList : [],
|
||||
);
|
||||
});
|
||||
|
||||
// Merge (do not replace) session metadata into a chat scope. Used when agents
|
||||
// open a host mid-turn so terminal tools can target the new sessionId
|
||||
// without waiting for the next full scope push.
|
||||
ipcMain.handle("netcatty:ai:mcp:merge-sessions", async (event, { sessions: sessionList, chatSessionId }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
if (!chatSessionId || typeof chatSessionId !== "string") {
|
||||
return { ok: false, error: "chatSessionId is required" };
|
||||
}
|
||||
const list = Array.isArray(sessionList) ? sessionList : [];
|
||||
const externalId = mcpServerBridge.EXTERNAL_MCP_CHAT_SESSION_ID;
|
||||
if (chatSessionId === externalId) {
|
||||
try {
|
||||
const external = typeof getExternalMcpController === "function"
|
||||
? getExternalMcpController()
|
||||
: null;
|
||||
if (!external?.isEnabled?.()) {
|
||||
return { ok: false, error: "External MCP is disabled" };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, error: "External MCP is unavailable" };
|
||||
}
|
||||
}
|
||||
return mcpServerBridge.mergeSessionMetadata(list, chatSessionId);
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:update-attachments", async (event, { attachments, chatSessionId }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
mcpServerBridge.updateAttachmentMetadata(attachments || [], chatSessionId);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:set-command-blocklist", async (event, { blocklist }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
// Validate: must be an array of strings, each a valid regex pattern
|
||||
if (!Array.isArray(blocklist)) {
|
||||
return { ok: false, error: "blocklist must be an array" };
|
||||
}
|
||||
const validPatterns = [];
|
||||
for (const pattern of blocklist) {
|
||||
if (typeof pattern !== "string") continue;
|
||||
try {
|
||||
new RegExp(pattern, "i"); // Validate regex
|
||||
validPatterns.push(pattern);
|
||||
} catch {
|
||||
// Skip invalid regex patterns silently
|
||||
}
|
||||
}
|
||||
mcpServerBridge.setCommandBlocklist(validPatterns);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:set-command-timeout", async (event, { timeout }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const value = Number(timeout);
|
||||
if (!Number.isFinite(value) || value < 1 || value > maxCommandTimeoutSeconds) {
|
||||
return { ok: false, error: `timeout must be a number between 1 and ${maxCommandTimeoutSeconds}` };
|
||||
}
|
||||
mcpServerBridge.setCommandTimeout(value);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:set-max-iterations", async (event, { maxIterations }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const value = Number(maxIterations);
|
||||
if (!Number.isFinite(value) || value < 1 || value > 100) {
|
||||
return { ok: false, error: "maxIterations must be a number between 1 and 100" };
|
||||
}
|
||||
mcpServerBridge.setMaxIterations(value);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:set-permission-mode", async (event, { mode }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const validModes = ["observer", "confirm", "auto"];
|
||||
if (!validModes.includes(mode)) {
|
||||
return { ok: false, error: `mode must be one of: ${validModes.join(", ")}` };
|
||||
}
|
||||
mcpServerBridge.setPermissionMode(mode);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:set-tool-integration-mode", async (event, { mode }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const validModes = ["mcp", "skills"];
|
||||
if (!validModes.includes(mode)) {
|
||||
return { ok: false, error: `mode must be one of: ${validModes.join(", ")}` };
|
||||
}
|
||||
setToolIntegrationMode(mode);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:sync-permission-grants", async (event, { grants }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
mcpServerBridge.setPermissionGrants(grants);
|
||||
return { ok: true, count: mcpServerBridge.getPermissionGrants().length };
|
||||
});
|
||||
|
||||
// ── MCP Approval response (renderer → main) ──
|
||||
ipcMain.handle("netcatty:ai:mcp:approval-response", async (event, { approvalId, approved }) => {
|
||||
// Settings window also hosts External MCP approval cards.
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
mcpServerBridge.resolveApprovalFromRenderer(approvalId, approved);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Cancel MCP approval auto-deny after the user starts reviewing the card.
|
||||
ipcMain.handle("netcatty:ai:mcp:approval-cancel-timeout", async (event, { approvalId }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const cancelled = mcpServerBridge.cancelApprovalTimeoutFromRenderer?.(approvalId) === true;
|
||||
return { ok: true, cancelled };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { registerAgentProcessHandlers };
|
||||
309
electron/bridges/aiBridge/cattyExecHandlers.cjs
Normal file
309
electron/bridges/aiBridge/cattyExecHandlers.cjs
Normal file
@@ -0,0 +1,309 @@
|
||||
/* eslint-disable no-undef */
|
||||
// Module-level require on purpose: code inside registerCattyExecHandlers
|
||||
// runs under `with (ctx)` where bare `require` resolves to ctx.require
|
||||
// (based in electron/bridges/). Requiring here keeps the path unambiguous.
|
||||
const { formatSyntheticEcho } = require("../ai/shellUtils.cjs");
|
||||
const { ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
|
||||
|
||||
function getWorkerExecutionMeta(mcpServerBridge, sessionId, chatSessionId) {
|
||||
return mcpServerBridge.getSessionMeta?.(sessionId, chatSessionId) || {};
|
||||
}
|
||||
|
||||
function isNetworkDeviceLike(meta) {
|
||||
const protocol = meta?.protocol || "";
|
||||
const isSshOrSerial = protocol === "ssh" || protocol === "serial";
|
||||
return (meta?.deviceType === "network" && isSshOrSerial) || protocol === "serial";
|
||||
}
|
||||
|
||||
async function proxyCattyExecToWorker({
|
||||
event,
|
||||
terminalWorkerManager,
|
||||
mcpServerBridge,
|
||||
sessionId,
|
||||
command,
|
||||
chatSessionId,
|
||||
}) {
|
||||
if (!terminalWorkerManager?.request) {
|
||||
return { ok: false, error: "Session not found" };
|
||||
}
|
||||
|
||||
const busyErr = mcpServerBridge.getSessionBusyError?.(sessionId);
|
||||
if (busyErr) return busyErr;
|
||||
|
||||
const meta = getWorkerExecutionMeta(mcpServerBridge, sessionId, chatSessionId);
|
||||
if (!isNetworkDeviceLike(meta)) {
|
||||
// No live session here: settings additions plus common defaults only; the
|
||||
// terminal worker re-runs the shell-selected defaults on the live session.
|
||||
const safety = meta.shellType
|
||||
? mcpServerBridge.checkCommandSafetyForShell(command, meta.shellType)
|
||||
: mcpServerBridge.checkCommandSafetyCommonOnly(command);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
}
|
||||
|
||||
const reservation = mcpServerBridge.reserveSessionExecution?.(sessionId, "exec");
|
||||
if (reservation && !reservation.ok) return reservation;
|
||||
const sessionToken = reservation?.token;
|
||||
const releaseLock = () => {
|
||||
if (sessionToken) {
|
||||
try { mcpServerBridge.releaseSessionExecution?.(sessionId, sessionToken); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
return await terminalWorkerManager.request("netcatty:ai:exec", {
|
||||
sessionId,
|
||||
command,
|
||||
chatSessionId,
|
||||
commandTimeoutMs: mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000,
|
||||
sessionMeta: meta,
|
||||
commandBlocklist: mcpServerBridge.getCommandBlocklist?.(),
|
||||
}, {
|
||||
webContentsId: event?.sender?.id,
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function registerCattyExecHandlers(ctx) {
|
||||
with (ctx) {
|
||||
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
// Block execution in observer mode (Issue #11)
|
||||
if (mcpServerBridge.getPermissionMode() === "observer") {
|
||||
return { ok: false, error: "Execution blocked: permission mode is 'observer'" };
|
||||
}
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session) {
|
||||
return proxyCattyExecToWorker({
|
||||
event,
|
||||
terminalWorkerManager,
|
||||
mcpServerBridge,
|
||||
sessionId,
|
||||
command,
|
||||
chatSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Honor the per-session execution lock so this IPC path does not race with
|
||||
// long-running background jobs started via terminal_start.
|
||||
const busyErr = mcpServerBridge.getSessionBusyError?.(sessionId);
|
||||
if (busyErr) return busyErr;
|
||||
const reservation = mcpServerBridge.reserveSessionExecution?.(sessionId, "exec");
|
||||
if (reservation && !reservation.ok) return reservation;
|
||||
const sessionToken = reservation?.token;
|
||||
const releaseLock = () => {
|
||||
if (sessionToken) {
|
||||
try { mcpServerBridge.releaseSessionExecution?.(sessionId, sessionToken); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
// Look up device type from metadata (set by renderer from Host.deviceType).
|
||||
// Mosh sessions use a shell-backed PTY, so network device mode only applies to SSH/serial.
|
||||
// Prefer session.protocol (runtime truth) over meta.protocol (renderer hint)
|
||||
// because Mosh tabs report as protocol:"ssh" in metadata but "mosh" in session.
|
||||
const meta = mcpServerBridge.getSessionMeta(sessionId, chatSessionId) || {};
|
||||
const sessionProtocol = session.protocol || session.type || meta.protocol || "";
|
||||
const isSshOrSerial = sessionProtocol === "ssh" || sessionProtocol === "serial";
|
||||
const isNetworkDevice = (meta.deviceType === "network" && isSshOrSerial) || sessionProtocol === "serial";
|
||||
|
||||
// Helper: ensure the session lock is released once the promise settles
|
||||
// (or immediately on a synchronous error/early return).
|
||||
const withLockRelease = (factory) => {
|
||||
try {
|
||||
const result = factory();
|
||||
return Promise.resolve(result).finally(releaseLock);
|
||||
} catch (err) {
|
||||
releaseLock();
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
|
||||
releaseLock();
|
||||
return {
|
||||
ok: false,
|
||||
error: "AI execution is not supported for this local shell executable. Configure the local terminal to use bash/zsh/sh, fish, PowerShell/pwsh, or cmd.exe.",
|
||||
};
|
||||
}
|
||||
|
||||
const ptyStream = session.stream || session.pty || session.proc;
|
||||
|
||||
// Network devices (switches/routers) connected via SSH: use raw execution.
|
||||
// Their vendor CLIs don't run a POSIX shell, so shell-wrapped commands fail.
|
||||
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
|
||||
const { execViaRawPty } = require("./ai/ptyExec.cjs");
|
||||
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return withLockRelease(() => execViaRawPty(ptyStream, command, {
|
||||
timeoutMs,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
encoding: "utf8", // SSH PTY streams use UTF-8, not latin1
|
||||
}));
|
||||
}
|
||||
|
||||
// Prefer PTY stream (visible in terminal)
|
||||
if (ptyStream && typeof ptyStream.write === "function") {
|
||||
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
// Remote sessions historically left shellKind unset → posix wrapper
|
||||
// was typed into fish login shells (issue #1854). Probe once first,
|
||||
// cancellably so Stop during the probe window does not still type
|
||||
// the command after the probe resolves (Codex P2 on #2061).
|
||||
return withLockRelease(async () => {
|
||||
const probed = await ensureSessionShellKindForExec(session, {
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
const safety = mcpServerBridge.checkCommandSafetyForShell(
|
||||
command,
|
||||
mcpServerBridge.resolveSessionBlocklistShellKind(session),
|
||||
);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
return execViaPty(ptyStream, command, {
|
||||
stripMarkers: true,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => {
|
||||
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
|
||||
safeSend(contents, "netcatty:data", { sessionId, data: `${marker}_R\n` });
|
||||
},
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => {
|
||||
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
|
||||
safeSend(contents, "netcatty:data", {
|
||||
sessionId,
|
||||
data: formatSyntheticEcho(rawCommand),
|
||||
syntheticEcho: true,
|
||||
});
|
||||
},
|
||||
// Catty Agent has no terminal_start fallback for long-running
|
||||
// commands, so do NOT enforce a hard wall-clock timeout here.
|
||||
// The inactivity timeout still applies, so genuinely hung
|
||||
// processes are still terminated.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Network devices require an interactive PTY for raw command execution.
|
||||
if (isNetworkDevice) {
|
||||
releaseLock();
|
||||
return { ok: false, error: "Network device session has no writable PTY stream for command execution" };
|
||||
}
|
||||
|
||||
// Fallback: SSH exec channel (invisible to terminal)
|
||||
const sshClient = session.sshClient || session.conn;
|
||||
if (sshClient && typeof sshClient.exec === "function") {
|
||||
const { execViaChannel } = require("./ai/ptyExec.cjs");
|
||||
const channelTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return withLockRelease(async () => {
|
||||
const probed = await ensureSessionShellKindForExec(session, {
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
const safety = mcpServerBridge.checkCommandSafetyForShell(
|
||||
command,
|
||||
mcpServerBridge.resolveSessionBlocklistShellKind(session),
|
||||
);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
return execViaChannel(sshClient, command, {
|
||||
timeoutMs: channelTimeoutMs,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Serial port: raw command execution (no shell wrapping)
|
||||
if (session.protocol === "serial" && session.serialPort && typeof session.serialPort.write === "function") {
|
||||
if (session.ymodemActive || session.zmodemSentry?.isActive?.()) {
|
||||
releaseLock();
|
||||
return { ok: false, error: "Serial file transfer is already in progress" };
|
||||
}
|
||||
const { execViaRawPty } = require("./ai/ptyExec.cjs");
|
||||
const serialTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return withLockRelease(() => execViaRawPty(session.serialPort, command, {
|
||||
timeoutMs: serialTimeoutMs,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
encoding: session.serialEncoding || "utf8",
|
||||
}));
|
||||
}
|
||||
|
||||
releaseLock();
|
||||
return { ok: false, error: "No terminal stream or SSH client available for this session" };
|
||||
} catch (err) {
|
||||
releaseLock();
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel in-flight Catty Agent command executions for a chat session
|
||||
ipcMain.handle("netcatty:ai:catty:cancel", async (event, { chatSessionId }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
|
||||
void mcpServerBridge.cancelSftpOpsForSession?.(chatSessionId);
|
||||
if (typeof mcpServerBridge.cancelWorkerBackgroundJobsForSession === "function") {
|
||||
mcpServerBridge.cancelWorkerBackgroundJobsForSession(chatSessionId);
|
||||
} else {
|
||||
try {
|
||||
terminalWorkerManager?.send?.("netcatty:ai:catty:cancel", { chatSessionId }, {
|
||||
webContentsId: event?.sender?.id,
|
||||
});
|
||||
} catch {
|
||||
// Worker may already be gone while cancelling a torn-down terminal.
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:chat-session:set-cancelled", async (event, { chatSessionId, cancelled }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
if (!chatSessionId || typeof chatSessionId !== "string") {
|
||||
return { ok: false, error: "chatSessionId is required" };
|
||||
}
|
||||
try {
|
||||
return await mcpServerBridge.applyChatSessionCancelled(chatSessionId, cancelled !== false);
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:capability", async (event, { rpcMethod, params, chatSessionId }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
if (!rpcMethod || typeof rpcMethod !== "string") {
|
||||
return { ok: false, error: "rpcMethod is required" };
|
||||
}
|
||||
return mcpServerBridge.dispatchBuiltinRpc(rpcMethod, {
|
||||
...(params || {}),
|
||||
chatSessionId,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { registerCattyExecHandlers };
|
||||
96
electron/bridges/aiBridge/cattyExecHandlers.worker.test.cjs
Normal file
96
electron/bridges/aiBridge/cattyExecHandlers.worker.test.cjs
Normal file
@@ -0,0 +1,96 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { registerCattyExecHandlers } = require("./cattyExecHandlers.cjs");
|
||||
|
||||
function createFakeIpcMain() {
|
||||
return {
|
||||
handlers: new Map(),
|
||||
handle(channel, handler) {
|
||||
this.handlers.set(channel, handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("catty AI exec proxies to the terminal worker when the real session lives in the worker", async () => {
|
||||
const ipcMain = createFakeIpcMain();
|
||||
const requests = [];
|
||||
const terminalWorkerManager = {
|
||||
request(channel, payload, options) {
|
||||
requests.push({ channel, payload, options });
|
||||
return Promise.resolve({ ok: true, stdout: "ok\n" });
|
||||
},
|
||||
};
|
||||
const locks = [];
|
||||
const mcpServerBridge = {
|
||||
getPermissionMode: () => "auto",
|
||||
getSessionBusyError: () => null,
|
||||
reserveSessionExecution(sessionId, kind) {
|
||||
locks.push(["reserve", sessionId, kind]);
|
||||
return { ok: true, token: "token-1" };
|
||||
},
|
||||
releaseSessionExecution(sessionId, token) {
|
||||
locks.push(["release", sessionId, token]);
|
||||
},
|
||||
getSessionMeta() {
|
||||
return { protocol: "ssh", deviceType: "", hostname: "host.example" };
|
||||
},
|
||||
checkCommandSafetyForShell() {
|
||||
return { blocked: false };
|
||||
},
|
||||
checkCommandSafetyCommonOnly() {
|
||||
return { blocked: false };
|
||||
},
|
||||
resolveSessionBlocklistShellKind() {
|
||||
return "";
|
||||
},
|
||||
getCommandTimeoutMs() {
|
||||
return 12345;
|
||||
},
|
||||
getCommandBlocklist() {
|
||||
return [];
|
||||
},
|
||||
activePtyExecs: new Map(),
|
||||
};
|
||||
|
||||
registerCattyExecHandlers({
|
||||
ipcMain,
|
||||
validateSender: () => true,
|
||||
sessions: new Map(),
|
||||
terminalWorkerManager,
|
||||
mcpServerBridge,
|
||||
electronModule: {},
|
||||
safeSend() {},
|
||||
execViaPty() {
|
||||
throw new Error("main process should not execute without a real session");
|
||||
},
|
||||
getFreshIdlePrompt() {
|
||||
return "";
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ipcMain.handlers.get("netcatty:ai:exec")(
|
||||
{ sender: { id: 7 } },
|
||||
{ sessionId: "ssh-1", command: "pwd", chatSessionId: "chat-1" },
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { ok: true, stdout: "ok\n" });
|
||||
assert.deepEqual(requests, [
|
||||
{
|
||||
channel: "netcatty:ai:exec",
|
||||
payload: {
|
||||
sessionId: "ssh-1",
|
||||
command: "pwd",
|
||||
chatSessionId: "chat-1",
|
||||
commandTimeoutMs: 12345,
|
||||
sessionMeta: { protocol: "ssh", deviceType: "", hostname: "host.example" },
|
||||
commandBlocklist: [],
|
||||
},
|
||||
options: { webContentsId: 7 },
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(locks, [
|
||||
["reserve", "ssh-1", "exec"],
|
||||
["release", "ssh-1", "token-1"],
|
||||
]);
|
||||
});
|
||||
350
electron/bridges/aiBridge/codexAppServer/connection.cjs
Normal file
350
electron/bridges/aiBridge/codexAppServer/connection.cjs
Normal file
@@ -0,0 +1,350 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { createHash } = require("node:crypto");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const INITIALIZE_TIMEOUT_MS = 10_000;
|
||||
const MAX_STDERR_CHARS = 32_000;
|
||||
const MAX_JSONL_LINE_BYTES = 16 * 1024 * 1024;
|
||||
const CLOSE_KILL_GRACE_MS = 750;
|
||||
|
||||
function createBoundedLineReader(stream, onLine, onError, maxLineBytes) {
|
||||
const decoder = new StringDecoder("utf8");
|
||||
let buffer = "";
|
||||
let bufferedBytes = 0;
|
||||
let closed = false;
|
||||
|
||||
const fail = () => {
|
||||
buffer = "";
|
||||
bufferedBytes = 0;
|
||||
onError(new Error(`Codex App Server message exceeded ${maxLineBytes} bytes`));
|
||||
};
|
||||
const onData = (chunk) => {
|
||||
if (closed) return;
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ""));
|
||||
bufferedBytes += bytes.length;
|
||||
buffer += decoder.write(bytes);
|
||||
let index;
|
||||
let consumedLine = false;
|
||||
while ((index = buffer.indexOf("\n")) >= 0) {
|
||||
const line = buffer.slice(0, index).trim();
|
||||
buffer = buffer.slice(index + 1);
|
||||
consumedLine = true;
|
||||
if (line) onLine(line);
|
||||
if (closed) return;
|
||||
}
|
||||
if (consumedLine) bufferedBytes = Buffer.byteLength(buffer, "utf8") + decoder.lastNeed;
|
||||
if (bufferedBytes > maxLineBytes) fail();
|
||||
};
|
||||
const onEnd = () => {
|
||||
if (closed) return;
|
||||
buffer += decoder.end();
|
||||
const line = buffer.trim();
|
||||
buffer = "";
|
||||
bufferedBytes = 0;
|
||||
if (line) onLine(line);
|
||||
};
|
||||
|
||||
stream?.on?.("data", onData);
|
||||
stream?.once?.("end", onEnd);
|
||||
return {
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
buffer = "";
|
||||
bufferedBytes = 0;
|
||||
stream?.removeListener?.("data", onData);
|
||||
stream?.removeListener?.("end", onEnd);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodexAppServerLaunch(binPath, args = ["app-server", "--stdio"], {
|
||||
nodePath = process.execPath,
|
||||
} = {}) {
|
||||
const executable = String(binPath || "").trim();
|
||||
if (!executable) {
|
||||
throw new Error("Codex binary not found. Configure Codex in Settings -> AI.");
|
||||
}
|
||||
const extension = path.extname(executable).toLowerCase();
|
||||
if (extension === ".js" || extension === ".cjs" || extension === ".mjs") {
|
||||
return {
|
||||
command: nodePath,
|
||||
args: [executable, ...args],
|
||||
env: { ELECTRON_RUN_AS_NODE: "1" },
|
||||
};
|
||||
}
|
||||
if (extension === ".cmd" || extension === ".bat" || extension === ".ps1") {
|
||||
throw new Error(
|
||||
`Codex App Server cannot launch the shell shim ${executable}. ` +
|
||||
"Configure the native Codex executable or reinstall the Codex CLI.",
|
||||
);
|
||||
}
|
||||
return { command: executable, args };
|
||||
}
|
||||
|
||||
function buildCodexAppServerKey(binPath, env) {
|
||||
const fingerprint = createHash("sha256")
|
||||
.update(JSON.stringify(
|
||||
Object.entries(env || {})
|
||||
.map(([key, value]) => [key, String(value)])
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
))
|
||||
.digest("hex");
|
||||
return `${String(binPath || "")}\u0000${fingerprint}`;
|
||||
}
|
||||
|
||||
class CodexAppServerConnection {
|
||||
constructor({
|
||||
binPath,
|
||||
env,
|
||||
appVersion = "0.0.0",
|
||||
spawnImpl = spawn,
|
||||
onNotification,
|
||||
onServerRequest,
|
||||
onFatal,
|
||||
closeKillGraceMs = CLOSE_KILL_GRACE_MS,
|
||||
maxJsonlLineBytes = MAX_JSONL_LINE_BYTES,
|
||||
}) {
|
||||
this.binPath = binPath;
|
||||
this.env = env || {};
|
||||
this.appVersion = appVersion;
|
||||
this.spawnImpl = spawnImpl;
|
||||
this.onNotification = onNotification;
|
||||
this.onServerRequest = onServerRequest;
|
||||
this.onFatal = onFatal;
|
||||
this.closeKillGraceMs = closeKillGraceMs;
|
||||
this.maxJsonlLineBytes = maxJsonlLineBytes;
|
||||
this.process = null;
|
||||
this.closingProcesses = new Map();
|
||||
this.readline = null;
|
||||
this.nextRequestId = 1;
|
||||
this.pending = new Map();
|
||||
this.startPromise = null;
|
||||
this.initialized = false;
|
||||
this.closing = false;
|
||||
this.stderr = "";
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.initialized && this.process && !this.process.killed) return this;
|
||||
if (this.startPromise) return this.startPromise;
|
||||
this.startPromise = this.#startInternal().finally(() => {
|
||||
this.startPromise = null;
|
||||
});
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
async #startInternal() {
|
||||
this.closing = false;
|
||||
this.stderr = "";
|
||||
const launch = buildCodexAppServerLaunch(this.binPath);
|
||||
const child = this.spawnImpl(launch.command, launch.args, {
|
||||
cwd: process.cwd(),
|
||||
env: { ...this.env, ...(launch.env || {}) },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
shell: false,
|
||||
});
|
||||
this.process = child;
|
||||
|
||||
child.stderr?.setEncoding?.("utf8");
|
||||
child.stderr?.on?.("data", (chunk) => {
|
||||
this.stderr = `${this.stderr}${String(chunk || "")}`.slice(-MAX_STDERR_CHARS);
|
||||
});
|
||||
|
||||
this.readline = createBoundedLineReader(
|
||||
child.stdout,
|
||||
(line) => this.#handleLine(line),
|
||||
(error) => this.#handleFatal(error),
|
||||
this.maxJsonlLineBytes,
|
||||
);
|
||||
|
||||
child.once("error", (error) => {
|
||||
if (this.closingProcesses.has(child) || this.process !== child) return;
|
||||
this.#handleFatal(error);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
const wasClosing = this.closingProcesses.has(child);
|
||||
if (wasClosing) this.#releaseClosingProcess(child);
|
||||
if (wasClosing || this.process !== child) return;
|
||||
const detail = this.stderr.trim();
|
||||
const suffix = detail ? `\n${detail}` : "";
|
||||
this.#handleFatal(new Error(
|
||||
`Codex App Server exited unexpectedly (code ${code ?? "null"}, signal ${signal ?? "none"}).${suffix}`,
|
||||
));
|
||||
});
|
||||
|
||||
try {
|
||||
await this.request("initialize", {
|
||||
clientInfo: {
|
||||
name: "netcatty",
|
||||
title: "Netcatty",
|
||||
version: this.appVersion,
|
||||
},
|
||||
capabilities: {
|
||||
experimentalApi: true,
|
||||
requestAttestation: false,
|
||||
mcpServerOpenaiFormElicitation: false,
|
||||
},
|
||||
}, INITIALIZE_TIMEOUT_MS, { skipStart: true });
|
||||
this.notify("initialized", {});
|
||||
this.initialized = true;
|
||||
return this;
|
||||
} catch (error) {
|
||||
this.close();
|
||||
const detail = this.stderr.trim();
|
||||
if (detail && !String(error?.message || error).includes(detail)) {
|
||||
throw new Error(`${error?.message || error}\n${detail}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async request(method, params = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, options = {}) {
|
||||
if (!options.skipStart) await this.start();
|
||||
const id = this.nextRequestId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Codex App Server request timed out: ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { method, resolve, reject, timer });
|
||||
try {
|
||||
this.#write({ id, method, params });
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(id);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
notify(method, params = {}) {
|
||||
this.#write({ method, params });
|
||||
}
|
||||
|
||||
respond(id, result) {
|
||||
this.#write({ id, result });
|
||||
}
|
||||
|
||||
respondError(id, code, message, data) {
|
||||
const error = { code, message };
|
||||
if (data !== undefined) error.data = data;
|
||||
this.#write({ id, error });
|
||||
}
|
||||
|
||||
#write(message) {
|
||||
const stdin = this.process?.stdin;
|
||||
if (!stdin || stdin.destroyed || !stdin.writable) {
|
||||
throw new Error("Codex App Server stdin is unavailable");
|
||||
}
|
||||
stdin.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
#handleLine(rawLine) {
|
||||
const line = String(rawLine || "").trim();
|
||||
if (!line) return;
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
this.#handleFatal(new Error(`Codex App Server emitted invalid JSON: ${line.slice(0, 500)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(message, "id") && !message.method) {
|
||||
const entry = this.pending.get(message.id);
|
||||
if (!entry) return;
|
||||
this.pending.delete(message.id);
|
||||
clearTimeout(entry.timer);
|
||||
if (message.error) {
|
||||
const error = new Error(message.error.message || `Codex App Server ${entry.method} failed`);
|
||||
error.code = message.error.code;
|
||||
error.data = message.error.data;
|
||||
entry.reject(error);
|
||||
} else {
|
||||
entry.resolve(message.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method && Object.prototype.hasOwnProperty.call(message, "id")) {
|
||||
Promise.resolve(this.onServerRequest?.(message, this)).catch((error) => {
|
||||
try {
|
||||
this.respondError(message.id, -32603, error?.message || String(error));
|
||||
} catch {}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method) {
|
||||
try {
|
||||
this.onNotification?.(message, this);
|
||||
} catch (error) {
|
||||
this.#handleFatal(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#handleFatal(error) {
|
||||
if (this.closing) return;
|
||||
this.initialized = false;
|
||||
const fatal = error instanceof Error ? error : new Error(String(error));
|
||||
for (const [, entry] of this.pending) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.reject(fatal);
|
||||
}
|
||||
this.pending.clear();
|
||||
try { this.onFatal?.(fatal, this); } catch {}
|
||||
this.close();
|
||||
}
|
||||
|
||||
#releaseClosingProcess(child) {
|
||||
if (!this.closingProcesses.has(child)) return;
|
||||
clearTimeout(this.closingProcesses.get(child));
|
||||
this.closingProcesses.delete(child);
|
||||
}
|
||||
|
||||
getClosingProcessCountForTests() {
|
||||
return this.closingProcesses.size;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closing = true;
|
||||
this.initialized = false;
|
||||
try { this.readline?.close?.(); } catch {}
|
||||
this.readline = null;
|
||||
for (const [, entry] of this.pending) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.reject(new Error("Codex App Server connection closed"));
|
||||
}
|
||||
this.pending.clear();
|
||||
const child = this.process;
|
||||
this.process = null;
|
||||
if (!child) return;
|
||||
try { child.stdin?.end?.(); } catch {}
|
||||
this.closingProcesses.set(child, null);
|
||||
const killTimer = setTimeout(() => {
|
||||
if (!this.closingProcesses.has(child)) return;
|
||||
try { child.kill?.("SIGKILL"); } catch {}
|
||||
this.#releaseClosingProcess(child);
|
||||
}, this.closeKillGraceMs);
|
||||
killTimer.unref?.();
|
||||
this.closingProcesses.set(child, killTimer);
|
||||
try { child.kill?.("SIGTERM"); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CodexAppServerConnection,
|
||||
buildCodexAppServerKey,
|
||||
buildCodexAppServerLaunch,
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
INITIALIZE_TIMEOUT_MS,
|
||||
CLOSE_KILL_GRACE_MS,
|
||||
MAX_JSONL_LINE_BYTES,
|
||||
};
|
||||
200
electron/bridges/aiBridge/codexAppServer/connection.test.cjs
Normal file
200
electron/bridges/aiBridge/codexAppServer/connection.test.cjs
Normal file
@@ -0,0 +1,200 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter, once } = require("node:events");
|
||||
const { PassThrough } = require("node:stream");
|
||||
const {
|
||||
CodexAppServerConnection,
|
||||
buildCodexAppServerKey,
|
||||
buildCodexAppServerLaunch,
|
||||
} = require("./connection.cjs");
|
||||
|
||||
function createFakeChild() {
|
||||
const child = new EventEmitter();
|
||||
child.stdin = new PassThrough();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.killed = false;
|
||||
child.kill = () => { child.killed = true; };
|
||||
return child;
|
||||
}
|
||||
|
||||
async function readJsonLine(stream) {
|
||||
const [chunk] = await once(stream, "data");
|
||||
return JSON.parse(String(chunk).trim());
|
||||
}
|
||||
|
||||
test("buildCodexAppServerLaunch runs JS entries through Node without a shell", () => {
|
||||
assert.deepEqual(
|
||||
buildCodexAppServerLaunch("/opt/codex/bin/codex.js", ["app-server", "--help"], { nodePath: "/usr/bin/node" }),
|
||||
{
|
||||
command: "/usr/bin/node",
|
||||
args: ["/opt/codex/bin/codex.js", "app-server", "--help"],
|
||||
env: { ELECTRON_RUN_AS_NODE: "1" },
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildCodexAppServerLaunch("/usr/local/bin/codex"),
|
||||
{ command: "/usr/local/bin/codex", args: ["app-server", "--stdio"] },
|
||||
);
|
||||
assert.throws(() => buildCodexAppServerLaunch("C:\\npm\\codex.cmd"), /shell shim/);
|
||||
});
|
||||
|
||||
test("App Server connection initializes once and correlates JSONL requests", async () => {
|
||||
const child = createFakeChild();
|
||||
const notifications = [];
|
||||
const connection = new CodexAppServerConnection({
|
||||
binPath: "/usr/bin/codex",
|
||||
env: { HOME: "/tmp/home" },
|
||||
appVersion: "1.2.3",
|
||||
spawnImpl: () => child,
|
||||
onNotification: (message) => notifications.push(message),
|
||||
});
|
||||
|
||||
const startPromise = connection.start();
|
||||
const initialize = await readJsonLine(child.stdin);
|
||||
assert.equal(initialize.method, "initialize");
|
||||
assert.equal(initialize.params.clientInfo.name, "netcatty");
|
||||
assert.equal(initialize.params.capabilities.experimentalApi, true);
|
||||
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: { userAgent: "codex" } })}\n`);
|
||||
await startPromise;
|
||||
const initialized = await readJsonLine(child.stdin);
|
||||
assert.equal(initialized.method, "initialized");
|
||||
|
||||
const requestPromise = connection.request("model/list", { limit: 100 });
|
||||
const request = await readJsonLine(child.stdin);
|
||||
assert.equal(request.method, "model/list");
|
||||
child.stdout.write(`${JSON.stringify({ id: request.id, result: { data: [], nextCursor: null } })}\n`);
|
||||
assert.deepEqual(await requestPromise, { data: [], nextCursor: null });
|
||||
|
||||
child.stdout.write(`${JSON.stringify({ method: "warning", params: { message: "heads up" } })}\n`);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(notifications[0].method, "warning");
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("App Server connection preserves a Chinese response split across UTF-8 chunks", async () => {
|
||||
const child = createFakeChild();
|
||||
const connection = new CodexAppServerConnection({
|
||||
binPath: "/usr/bin/codex",
|
||||
env: {},
|
||||
spawnImpl: () => child,
|
||||
});
|
||||
|
||||
const startPromise = connection.start();
|
||||
const initialize = await readJsonLine(child.stdin);
|
||||
const response = Buffer.from(`${JSON.stringify({
|
||||
id: initialize.id,
|
||||
result: { message: "中文" },
|
||||
})}\n`, "utf8");
|
||||
const split = response.indexOf(Buffer.from("中", "utf8")) + 1;
|
||||
child.stdout.write(response.subarray(0, split));
|
||||
child.stdout.write(response.subarray(split));
|
||||
await startPromise;
|
||||
await readJsonLine(child.stdin);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("App Server connection rejects an unterminated oversized JSONL message", async () => {
|
||||
const child = createFakeChild();
|
||||
let fatal;
|
||||
const connection = new CodexAppServerConnection({
|
||||
binPath: "/usr/bin/codex",
|
||||
env: {},
|
||||
maxJsonlLineBytes: 8,
|
||||
spawnImpl: () => child,
|
||||
onFatal: (error) => { fatal = error; },
|
||||
});
|
||||
|
||||
const startPromise = connection.start();
|
||||
await readJsonLine(child.stdin);
|
||||
child.stdout.write("123456789");
|
||||
|
||||
await assert.rejects(startPromise, /message exceeded 8 bytes/);
|
||||
assert.match(fatal.message, /message exceeded 8 bytes/);
|
||||
assert.equal(child.killed, true);
|
||||
});
|
||||
|
||||
test("App Server connection rejects pending RPCs when the process exits", async () => {
|
||||
const child = createFakeChild();
|
||||
let fatal;
|
||||
const connection = new CodexAppServerConnection({
|
||||
binPath: "/usr/bin/codex",
|
||||
env: {},
|
||||
spawnImpl: () => child,
|
||||
onFatal: (error) => { fatal = error; },
|
||||
});
|
||||
const startPromise = connection.start();
|
||||
const initialize = await readJsonLine(child.stdin);
|
||||
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: {} })}\n`);
|
||||
await startPromise;
|
||||
await readJsonLine(child.stdin); // initialized notification
|
||||
|
||||
const request = connection.request("thread/start", {});
|
||||
await readJsonLine(child.stdin);
|
||||
child.emit("exit", 1, null);
|
||||
await assert.rejects(request, /exited unexpectedly/);
|
||||
assert.match(fatal.message, /code 1/);
|
||||
});
|
||||
|
||||
test("App Server close force-kills a child that ignores SIGTERM", async () => {
|
||||
const child = createFakeChild();
|
||||
const signals = [];
|
||||
child.kill = (signal) => {
|
||||
signals.push(signal);
|
||||
return true;
|
||||
};
|
||||
const connection = new CodexAppServerConnection({
|
||||
binPath: "/usr/bin/codex",
|
||||
env: {},
|
||||
closeKillGraceMs: 5,
|
||||
spawnImpl: () => child,
|
||||
});
|
||||
const startPromise = connection.start();
|
||||
const initialize = await readJsonLine(child.stdin);
|
||||
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: {} })}\n`);
|
||||
await startPromise;
|
||||
await readJsonLine(child.stdin);
|
||||
|
||||
connection.close();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
|
||||
});
|
||||
|
||||
test("App Server close does not retain or re-kill a child that exits synchronously on SIGTERM", async () => {
|
||||
const child = createFakeChild();
|
||||
const signals = [];
|
||||
child.kill = (signal) => {
|
||||
signals.push(signal);
|
||||
if (signal === "SIGTERM") child.emit("exit", 0, "SIGTERM");
|
||||
return true;
|
||||
};
|
||||
const connection = new CodexAppServerConnection({
|
||||
binPath: "/usr/bin/codex",
|
||||
env: {},
|
||||
closeKillGraceMs: 5,
|
||||
spawnImpl: () => child,
|
||||
});
|
||||
const startPromise = connection.start();
|
||||
const initialize = await readJsonLine(child.stdin);
|
||||
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: {} })}\n`);
|
||||
await startPromise;
|
||||
await readJsonLine(child.stdin);
|
||||
|
||||
connection.close();
|
||||
assert.equal(connection.getClosingProcessCountForTests(), 0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
assert.deepEqual(signals, ["SIGTERM"]);
|
||||
});
|
||||
|
||||
test("App Server process keys include executable and environment identity", () => {
|
||||
assert.notEqual(
|
||||
buildCodexAppServerKey("/a/codex", { HOME: "/a" }),
|
||||
buildCodexAppServerKey("/b/codex", { HOME: "/a" }),
|
||||
);
|
||||
assert.notEqual(
|
||||
buildCodexAppServerKey("/a/codex", { HOME: "/a" }),
|
||||
buildCodexAppServerKey("/a/codex", { HOME: "/b" }),
|
||||
);
|
||||
});
|
||||
44
electron/bridges/aiBridge/codexAppServer/probe.cjs
Normal file
44
electron/bridges/aiBridge/codexAppServer/probe.cjs
Normal file
@@ -0,0 +1,44 @@
|
||||
"use strict";
|
||||
|
||||
const { execFile } = require("node:child_process");
|
||||
const { buildCodexAppServerLaunch } = require("./connection.cjs");
|
||||
|
||||
function execFileText(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, options, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
error.stdout = stdout;
|
||||
error.stderr = stderr;
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout: String(stdout || ""), stderr: String(stderr || "") });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function probeCodexAppServer({ binPath, env, execFileImpl = execFileText }) {
|
||||
try {
|
||||
const launch = buildCodexAppServerLaunch(binPath, ["app-server", "--help"]);
|
||||
const result = await execFileImpl(launch.command, launch.args, {
|
||||
env: { ...(env || {}), ...(launch.env || {}) },
|
||||
encoding: "utf8",
|
||||
timeout: 5_000,
|
||||
windowsHide: true,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const output = `${result.stdout || ""}\n${result.stderr || ""}`;
|
||||
const available = /Run the app server|--listen|--stdio/i.test(output);
|
||||
return available
|
||||
? { available: true }
|
||||
: { available: false, error: "This Codex CLI does not advertise App Server support." };
|
||||
} catch (error) {
|
||||
const detail = String(error?.stderr || error?.message || error || "").trim();
|
||||
return {
|
||||
available: false,
|
||||
error: detail || "Failed to probe Codex App Server support.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { execFileText, probeCodexAppServer };
|
||||
23981
electron/bridges/aiBridge/codexAppServer/protocol.schema.json
Normal file
23981
electron/bridges/aiBridge/codexAppServer/protocol.schema.json
Normal file
File diff suppressed because it is too large
Load Diff
988
electron/bridges/aiBridge/codexAppServer/runtime.cjs
Normal file
988
electron/bridges/aiBridge/codexAppServer/runtime.cjs
Normal file
@@ -0,0 +1,988 @@
|
||||
"use strict";
|
||||
|
||||
const {
|
||||
CodexAppServerConnection,
|
||||
buildCodexAppServerKey,
|
||||
} = require("./connection.cjs");
|
||||
const {
|
||||
parseCodexModelSelection,
|
||||
toCodexMcpConfig,
|
||||
} = require("../sdk/codexDriver.cjs");
|
||||
|
||||
const INTERACTION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const INTERRUPT_REQUEST_TIMEOUT_MS = 5_000;
|
||||
const INTERRUPT_GRACE_MS = 2_000;
|
||||
const MAX_STREAMED_PREFIX_CHARS = 256 * 1024;
|
||||
const MAX_TOOL_OUTPUT_CHARS = 1024 * 1024;
|
||||
|
||||
function appendStreamState(map, itemId, delta, maxPrefixChars = MAX_STREAMED_PREFIX_CHARS) {
|
||||
const text = String(delta || "");
|
||||
const previous = map.get(itemId) || { prefix: "", length: 0, truncated: false };
|
||||
const remaining = Math.max(0, maxPrefixChars - previous.prefix.length);
|
||||
const next = {
|
||||
prefix: remaining > 0 ? previous.prefix + text.slice(0, remaining) : previous.prefix,
|
||||
length: previous.length + text.length,
|
||||
truncated: previous.truncated || text.length > remaining,
|
||||
};
|
||||
map.set(itemId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function appendToolOutputState(map, itemId, delta) {
|
||||
const text = String(delta || "");
|
||||
const previous = map.get(itemId) || { text: "", totalLength: 0, truncated: false };
|
||||
const remaining = Math.max(0, MAX_TOOL_OUTPUT_CHARS - previous.text.length);
|
||||
const next = {
|
||||
text: remaining > 0 ? previous.text + text.slice(0, remaining) : previous.text,
|
||||
totalLength: previous.totalLength + text.length,
|
||||
truncated: previous.truncated || text.length > remaining,
|
||||
};
|
||||
map.set(itemId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function formatBoundedToolOutput(value, totalLength = String(value || "").length) {
|
||||
const text = String(value || "");
|
||||
if (text.length <= MAX_TOOL_OUTPUT_CHARS && totalLength <= MAX_TOOL_OUTPUT_CHARS) return text;
|
||||
const kept = text.slice(0, MAX_TOOL_OUTPUT_CHARS);
|
||||
return `${kept}\n[output truncated: ${Math.max(totalLength, text.length)} characters total]`;
|
||||
}
|
||||
|
||||
function resolveCodexPermissionConfig(permissionMode) {
|
||||
if (permissionMode === "observer") {
|
||||
return {
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "read-only",
|
||||
sandboxPolicy: { type: "readOnly", networkAccess: false },
|
||||
};
|
||||
}
|
||||
if (permissionMode === "auto") {
|
||||
return {
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "danger-full-access",
|
||||
sandboxPolicy: { type: "dangerFullAccess" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "read-only",
|
||||
sandboxPolicy: { type: "readOnly", networkAccess: false },
|
||||
};
|
||||
}
|
||||
|
||||
function buildThreadConfig(injectedMcpServers) {
|
||||
return {
|
||||
// Netcatty already applies its Observer/Confirm/Auto policy inside the MCP
|
||||
// bridge. Tell Codex not to add a second MCP approval prompt: App Server
|
||||
// otherwise routes the stable MCP elicitation request back to this client,
|
||||
// and rejecting/omitting that duplicate prompt surfaces as
|
||||
// "user rejected MCP tool call" before Netcatty's own gate can run.
|
||||
mcp_servers: toCodexMcpConfig(injectedMcpServers, {
|
||||
defaultToolsApprovalMode: "approve",
|
||||
}),
|
||||
model_reasoning_summary: "concise",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFileChanges(changes) {
|
||||
if (!Array.isArray(changes)) return [];
|
||||
return changes
|
||||
.filter((change) => change && typeof change.path === "string")
|
||||
.map((change) => ({
|
||||
path: change.path,
|
||||
kind: change.kind?.type === "add"
|
||||
? "add"
|
||||
: change.kind?.type === "delete"
|
||||
? "delete"
|
||||
: "update",
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeGrantedPermissions(requested) {
|
||||
const granted = {};
|
||||
if (requested?.network != null) granted.network = requested.network;
|
||||
if (requested?.fileSystem != null) granted.fileSystem = requested.fileSystem;
|
||||
return granted;
|
||||
}
|
||||
|
||||
function stringifyMcpContent(result) {
|
||||
if (!result) return "";
|
||||
const content = Array.isArray(result.content) ? result.content : [];
|
||||
let text = "";
|
||||
let totalLength = 0;
|
||||
for (const item of content) {
|
||||
const rawPart = item && typeof item === "object" && typeof item.text === "string"
|
||||
? item.text
|
||||
: typeof item === "string" ? item : JSON.stringify(item);
|
||||
const part = typeof rawPart === "string" ? rawPart : "";
|
||||
totalLength += part.length;
|
||||
if (text.length < MAX_TOOL_OUTPUT_CHARS) {
|
||||
text += part.slice(0, MAX_TOOL_OUTPUT_CHARS - text.length);
|
||||
}
|
||||
}
|
||||
if (text || totalLength > 0) return formatBoundedToolOutput(text, totalLength);
|
||||
if (result.structuredContent == null) return "";
|
||||
return formatBoundedToolOutput(JSON.stringify(result.structuredContent));
|
||||
}
|
||||
|
||||
function buildTurnInput(prompt, attachments) {
|
||||
const input = [{ type: "text", text: String(prompt || ""), text_elements: [] }];
|
||||
for (const attachment of attachments || []) {
|
||||
if (!attachment?.filePath) continue;
|
||||
if (!String(attachment.mediaType || "").toLowerCase().startsWith("image/")) continue;
|
||||
input.push({ type: "localImage", path: attachment.filePath });
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function getActiveTurnNotSteerableKind(error) {
|
||||
const turnKind = error?.data?.activeTurnNotSteerable?.turnKind
|
||||
?? error?.data?.codexErrorInfo?.activeTurnNotSteerable?.turnKind;
|
||||
return turnKind === "review" || turnKind === "compact" ? turnKind : null;
|
||||
}
|
||||
|
||||
function mapAppServerModels(rawModels) {
|
||||
return (Array.isArray(rawModels) ? rawModels : [])
|
||||
.filter((model) => model && model.id && !model.hidden)
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.displayName || model.id,
|
||||
description: model.description || undefined,
|
||||
thinkingLevels: Array.isArray(model.supportedReasoningEfforts)
|
||||
? model.supportedReasoningEfforts
|
||||
.map((option) => option?.reasoningEffort)
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
defaultThinkingLevel: model.defaultReasoningEffort || undefined,
|
||||
isDefault: model.isDefault === true,
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveAppServerModelSelection(model) {
|
||||
if (!model) return null;
|
||||
const defaultThinkingLevel = model.defaultThinkingLevel;
|
||||
if (
|
||||
defaultThinkingLevel
|
||||
&& Array.isArray(model.thinkingLevels)
|
||||
&& model.thinkingLevels.includes(defaultThinkingLevel)
|
||||
) {
|
||||
return `${model.id}/${defaultThinkingLevel}`;
|
||||
}
|
||||
return model.id;
|
||||
}
|
||||
|
||||
class CodexAppServerRuntime {
|
||||
constructor({
|
||||
appVersion = "0.0.0",
|
||||
connectionFactory,
|
||||
sendInteractionRequest,
|
||||
sendInteractionCleared,
|
||||
interruptRequestTimeoutMs = INTERRUPT_REQUEST_TIMEOUT_MS,
|
||||
interruptGraceMs = INTERRUPT_GRACE_MS,
|
||||
} = {}) {
|
||||
this.appVersion = appVersion;
|
||||
this.connectionFactory = connectionFactory;
|
||||
this.sendInteractionRequest = sendInteractionRequest;
|
||||
this.sendInteractionCleared = sendInteractionCleared;
|
||||
this.interruptRequestTimeoutMs = interruptRequestTimeoutMs;
|
||||
this.interruptGraceMs = interruptGraceMs;
|
||||
this.connections = new Map();
|
||||
this.preferredConnectionKey = null;
|
||||
this.activeByRequest = new Map();
|
||||
this.activeByThread = new Map();
|
||||
this.activeByTurn = new Map();
|
||||
this.pendingInteractions = new Map();
|
||||
this.interactionCounter = 0;
|
||||
this.eventCounter = 0;
|
||||
}
|
||||
|
||||
#scopedKey(connectionKey, id) {
|
||||
return `${connectionKey}\u0000${String(id || "")}`;
|
||||
}
|
||||
|
||||
#getConnection(binPath, env) {
|
||||
const connectionKey = buildCodexAppServerKey(binPath, env);
|
||||
this.preferredConnectionKey = connectionKey;
|
||||
const existing = this.connections.get(connectionKey);
|
||||
if (existing) {
|
||||
this.#closeIdleConnections(connectionKey);
|
||||
return { connection: existing, connectionKey };
|
||||
}
|
||||
this.#closeIdleConnections(connectionKey);
|
||||
const factory = this.connectionFactory || ((options) => new CodexAppServerConnection(options));
|
||||
const connection = factory({
|
||||
binPath,
|
||||
env,
|
||||
appVersion: this.appVersion,
|
||||
onNotification: (message) => this.#handleNotification(connectionKey, message),
|
||||
onServerRequest: (message, source) => this.#handleServerRequest(connectionKey, source, message),
|
||||
onFatal: (error) => this.#handleConnectionFatal(connectionKey, error),
|
||||
});
|
||||
this.connections.set(connectionKey, connection);
|
||||
return { connection, connectionKey };
|
||||
}
|
||||
|
||||
#closeIdleConnections(keepKey = this.preferredConnectionKey) {
|
||||
const activeConnectionKeys = new Set(
|
||||
Array.from(this.activeByRequest.values(), (context) => context.connectionKey),
|
||||
);
|
||||
for (const [connectionKey, connection] of this.connections) {
|
||||
if (connectionKey === keepKey || activeConnectionKeys.has(connectionKey)) continue;
|
||||
this.connections.delete(connectionKey);
|
||||
try { connection.close(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
#refreshPreferredConnectionKey() {
|
||||
if (this.preferredConnectionKey && this.connections.has(this.preferredConnectionKey)) return;
|
||||
const connectionKeys = Array.from(this.connections.keys());
|
||||
this.preferredConnectionKey = connectionKeys.at(-1) || null;
|
||||
}
|
||||
|
||||
async runTurn({
|
||||
requestId,
|
||||
chatSessionId,
|
||||
prompt,
|
||||
attachments,
|
||||
cwd,
|
||||
model,
|
||||
permissionMode,
|
||||
env,
|
||||
binPath,
|
||||
injectedMcpServers,
|
||||
resumeThreadId,
|
||||
emitter,
|
||||
signal,
|
||||
sender,
|
||||
}) {
|
||||
const throwIfAborted = () => {
|
||||
if (!signal?.aborted) return;
|
||||
const error = new Error("Codex App Server turn was interrupted before it started");
|
||||
error.name = "AbortError";
|
||||
throw error;
|
||||
};
|
||||
throwIfAborted();
|
||||
const { connection, connectionKey } = this.#getConnection(binPath, env);
|
||||
await connection.start();
|
||||
throwIfAborted();
|
||||
const permission = resolveCodexPermissionConfig(permissionMode);
|
||||
const selection = parseCodexModelSelection(model);
|
||||
const threadParams = {
|
||||
model: selection.model || null,
|
||||
cwd: cwd || process.cwd(),
|
||||
approvalPolicy: permission.approvalPolicy,
|
||||
approvalsReviewer: permission.approvalsReviewer,
|
||||
sandbox: permission.sandbox,
|
||||
config: buildThreadConfig(injectedMcpServers),
|
||||
};
|
||||
|
||||
const threadResult = resumeThreadId
|
||||
? await connection.request("thread/resume", { threadId: resumeThreadId, ...threadParams })
|
||||
: await connection.request("thread/start", threadParams);
|
||||
throwIfAborted();
|
||||
const threadId = threadResult?.thread?.id || resumeThreadId;
|
||||
if (!threadId) throw new Error("Codex App Server did not return a thread id");
|
||||
emitter.sessionId(threadId);
|
||||
|
||||
const context = {
|
||||
requestId,
|
||||
chatSessionId,
|
||||
connection,
|
||||
connectionKey,
|
||||
threadId,
|
||||
turnId: null,
|
||||
emitter,
|
||||
signal,
|
||||
sender,
|
||||
lastError: null,
|
||||
settled: false,
|
||||
cancelRequested: false,
|
||||
interruptPromise: null,
|
||||
steerPromise: null,
|
||||
reasoningOpen: false,
|
||||
streamedTextByItem: new Map(),
|
||||
streamedReasoningByItem: new Map(),
|
||||
commandOutputByItem: new Map(),
|
||||
emittedToolCalls: new Set(),
|
||||
emittedToolResults: new Set(),
|
||||
forceCancelTimer: null,
|
||||
abortListener: null,
|
||||
};
|
||||
this.activeByRequest.set(requestId, context);
|
||||
this.activeByThread.set(this.#scopedKey(connectionKey, threadId), context);
|
||||
|
||||
const completion = new Promise((resolve, reject) => {
|
||||
context.resolve = resolve;
|
||||
context.reject = reject;
|
||||
});
|
||||
if (signal) {
|
||||
context.abortListener = () => { void this.cancelTurn(requestId); };
|
||||
signal.addEventListener("abort", context.abortListener, { once: true });
|
||||
if (signal.aborted) context.abortListener();
|
||||
}
|
||||
|
||||
try {
|
||||
const turnResult = await connection.request("turn/start", {
|
||||
threadId,
|
||||
input: buildTurnInput(prompt, attachments),
|
||||
cwd: cwd || process.cwd(),
|
||||
approvalPolicy: permission.approvalPolicy,
|
||||
approvalsReviewer: permission.approvalsReviewer,
|
||||
sandboxPolicy: permission.sandboxPolicy,
|
||||
model: selection.model || null,
|
||||
effort: selection.effort || null,
|
||||
summary: "concise",
|
||||
});
|
||||
const turnId = turnResult?.turn?.id;
|
||||
if (turnId) this.#assignTurnId(context, turnId);
|
||||
await completion;
|
||||
return { threadId, turnId: context.turnId };
|
||||
} finally {
|
||||
this.#removeContext(context);
|
||||
}
|
||||
}
|
||||
|
||||
async listModels({ binPath, env }) {
|
||||
const { connection } = this.#getConnection(binPath, env);
|
||||
await connection.start();
|
||||
const all = [];
|
||||
let cursor = null;
|
||||
do {
|
||||
const response = await connection.request("model/list", {
|
||||
cursor,
|
||||
limit: 100,
|
||||
}, 10_000);
|
||||
all.push(...(response?.data || []));
|
||||
cursor = response?.nextCursor || null;
|
||||
} while (cursor);
|
||||
const models = mapAppServerModels(all);
|
||||
const defaultModel = models.find((model) => model.isDefault);
|
||||
return {
|
||||
currentModelId: resolveAppServerModelSelection(defaultModel),
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
async steerTurn(requestId, {
|
||||
chatSessionId,
|
||||
prompt,
|
||||
attachments,
|
||||
clientUserMessageId,
|
||||
} = {}) {
|
||||
const context = this.activeByRequest.get(requestId);
|
||||
if (!context || context.settled || context.chatSessionId !== chatSessionId) {
|
||||
return { status: "inactive" };
|
||||
}
|
||||
if (context.cancelRequested || context.signal?.aborted) {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
if (!context.turnId) {
|
||||
return { status: "busy", message: "Codex turn is still starting" };
|
||||
}
|
||||
if (context.steerPromise) {
|
||||
return { status: "busy", message: "A Codex instruction is already being sent" };
|
||||
}
|
||||
|
||||
const steerPromise = (async () => {
|
||||
try {
|
||||
const response = await context.connection.request("turn/steer", {
|
||||
threadId: context.threadId,
|
||||
expectedTurnId: context.turnId,
|
||||
input: buildTurnInput(prompt, attachments),
|
||||
clientUserMessageId: clientUserMessageId || null,
|
||||
});
|
||||
if (context.cancelRequested || context.signal?.aborted || context.settled) {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
if (response?.turnId && response.turnId !== context.turnId) {
|
||||
return {
|
||||
status: "failed",
|
||||
message: "Codex App Server returned a different turn id while steering",
|
||||
};
|
||||
}
|
||||
return { status: "accepted" };
|
||||
} catch (error) {
|
||||
const turnKind = getActiveTurnNotSteerableKind(error);
|
||||
if (turnKind) {
|
||||
return {
|
||||
status: "not-steerable",
|
||||
turnKind,
|
||||
message: error?.message || "The active Codex turn cannot be steered",
|
||||
};
|
||||
}
|
||||
if (context.cancelRequested || context.signal?.aborted || context.settled) {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
return {
|
||||
status: "failed",
|
||||
message: error?.message || String(error),
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
context.steerPromise = steerPromise;
|
||||
try {
|
||||
return await steerPromise;
|
||||
} finally {
|
||||
if (context.steerPromise === steerPromise) context.steerPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
#assignTurnId(context, turnId) {
|
||||
if (!turnId || context.turnId === turnId) return;
|
||||
if (context.turnId) {
|
||||
this.activeByTurn.delete(this.#scopedKey(context.connectionKey, context.turnId));
|
||||
}
|
||||
context.turnId = turnId;
|
||||
this.activeByTurn.set(this.#scopedKey(context.connectionKey, turnId), context);
|
||||
if (context.cancelRequested) void this.#interruptAndSchedule(context);
|
||||
}
|
||||
|
||||
#interruptContext(context) {
|
||||
if (!context.turnId) return Promise.resolve(false);
|
||||
if (context.interruptPromise) return context.interruptPromise;
|
||||
let timeout;
|
||||
const request = Promise.resolve().then(() => context.connection.request("turn/interrupt", {
|
||||
threadId: context.threadId,
|
||||
turnId: context.turnId,
|
||||
}, this.interruptRequestTimeoutMs)).then(() => true).catch(() => false);
|
||||
const deadline = new Promise((resolve) => {
|
||||
timeout = setTimeout(() => resolve(false), this.interruptRequestTimeoutMs);
|
||||
timeout.unref?.();
|
||||
});
|
||||
context.interruptPromise = Promise.race([request, deadline])
|
||||
.finally(() => clearTimeout(timeout));
|
||||
return context.interruptPromise;
|
||||
}
|
||||
|
||||
#scheduleForcedCancellation(context, delayMs) {
|
||||
if (context.settled) return;
|
||||
clearTimeout(context.forceCancelTimer);
|
||||
context.forceCancelTimer = setTimeout(() => {
|
||||
this.#forceCancelContext(context, "Codex App Server did not complete the interrupted turn");
|
||||
}, Math.max(0, delayMs));
|
||||
context.forceCancelTimer.unref?.();
|
||||
}
|
||||
|
||||
async #interruptAndSchedule(context) {
|
||||
if (context.settled) return;
|
||||
if (!context.turnId) {
|
||||
this.#scheduleForcedCancellation(
|
||||
context,
|
||||
this.interruptRequestTimeoutMs + this.interruptGraceMs,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const interrupted = await this.#interruptContext(context);
|
||||
if (context.settled) return;
|
||||
if (!interrupted) {
|
||||
this.#forceCancelContext(context, "Codex App Server could not interrupt the turn");
|
||||
return;
|
||||
}
|
||||
this.#scheduleForcedCancellation(context, this.interruptGraceMs);
|
||||
}
|
||||
|
||||
#forceCancelContext(context, reason) {
|
||||
if (context.settled) return;
|
||||
context.settled = true;
|
||||
clearTimeout(context.forceCancelTimer);
|
||||
context.forceCancelTimer = null;
|
||||
this.#closeReasoning(context);
|
||||
this.#clearInteractionsForContext(context, "cancel");
|
||||
context.emitter.emitDone();
|
||||
context.resolve();
|
||||
|
||||
const connection = this.connections.get(context.connectionKey);
|
||||
if (connection === context.connection) {
|
||||
this.connections.delete(context.connectionKey);
|
||||
try { connection.close(); } catch {}
|
||||
this.#refreshPreferredConnectionKey();
|
||||
}
|
||||
const error = new Error(reason);
|
||||
for (const candidate of this.activeByRequest.values()) {
|
||||
if (candidate === context || candidate.connectionKey !== context.connectionKey || candidate.settled) continue;
|
||||
candidate.settled = true;
|
||||
clearTimeout(candidate.forceCancelTimer);
|
||||
candidate.forceCancelTimer = null;
|
||||
this.#clearInteractionsForContext(candidate, "cancel");
|
||||
candidate.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
#findContext(connectionKey, params) {
|
||||
if (params?.turnId) {
|
||||
const byTurn = this.activeByTurn.get(this.#scopedKey(connectionKey, params.turnId));
|
||||
if (byTurn) return byTurn;
|
||||
}
|
||||
if (params?.threadId) {
|
||||
return this.activeByThread.get(this.#scopedKey(connectionKey, params.threadId)) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
#handleNotification(connectionKey, message) {
|
||||
const params = message.params || {};
|
||||
const context = this.#findContext(connectionKey, params);
|
||||
if (!context) {
|
||||
if (message.method === "warning") {
|
||||
const contexts = Array.from(this.activeByRequest.values())
|
||||
.filter((candidate) => candidate.connectionKey === connectionKey);
|
||||
for (const candidate of contexts) {
|
||||
candidate.emitter.warning(
|
||||
`codex-warning:connection:${++this.eventCounter}`,
|
||||
params.message || "Codex warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const emitter = context.emitter;
|
||||
|
||||
switch (message.method) {
|
||||
case "turn/started":
|
||||
this.#assignTurnId(context, params.turn?.id);
|
||||
return;
|
||||
case "item/agentMessage/delta": {
|
||||
appendStreamState(context.streamedTextByItem, params.itemId, params.delta);
|
||||
emitter.text(params.delta || "");
|
||||
return;
|
||||
}
|
||||
case "item/reasoning/summaryTextDelta": {
|
||||
appendStreamState(context.streamedReasoningByItem, params.itemId, params.delta);
|
||||
emitter.reasoning(params.delta || "");
|
||||
context.reasoningOpen = true;
|
||||
return;
|
||||
}
|
||||
case "item/commandExecution/outputDelta": {
|
||||
appendToolOutputState(context.commandOutputByItem, params.itemId, params.delta);
|
||||
return;
|
||||
}
|
||||
case "item/started":
|
||||
this.#handleItem(context, params.item, false);
|
||||
return;
|
||||
case "item/completed":
|
||||
this.#handleItem(context, params.item, true);
|
||||
return;
|
||||
case "turn/plan/updated":
|
||||
emitter.planUpdate(
|
||||
`codex-plan:${params.turnId}`,
|
||||
(params.plan || []).map((item) => ({
|
||||
text: item.step || "",
|
||||
completed: item.status === "completed",
|
||||
})),
|
||||
(params.plan || []).every((item) => item.status === "completed") ? "completed" : "running",
|
||||
);
|
||||
return;
|
||||
case "thread/tokenUsage/updated": {
|
||||
const usage = params.tokenUsage?.last;
|
||||
if (usage) {
|
||||
emitter.usage({
|
||||
inputTokens: Number(usage.inputTokens) || 0,
|
||||
cachedInputTokens: Number(usage.cachedInputTokens) || 0,
|
||||
outputTokens: Number(usage.outputTokens) || 0,
|
||||
reasoningTokens: Number(usage.reasoningOutputTokens) || 0,
|
||||
totalTokens: Number(usage.totalTokens) || 0,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "warning":
|
||||
emitter.warning(
|
||||
`codex-warning:${params.turnId || context.turnId}:${++this.eventCounter}`,
|
||||
params.message || "Codex warning",
|
||||
);
|
||||
return;
|
||||
case "error":
|
||||
context.lastError = params.error?.message || "Codex App Server error";
|
||||
emitter.warning(
|
||||
`codex-error:${params.turnId || context.turnId}:${++this.eventCounter}`,
|
||||
params.willRetry ? `${context.lastError} (retrying)` : context.lastError,
|
||||
);
|
||||
return;
|
||||
case "turn/completed":
|
||||
this.#completeTurn(context, params.turn);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#closeReasoning(context) {
|
||||
if (!context.reasoningOpen) return;
|
||||
context.emitter.reasoningEnd();
|
||||
context.reasoningOpen = false;
|
||||
}
|
||||
|
||||
#emitToolCallOnce(context, item, name, args) {
|
||||
if (!item?.id || context.emittedToolCalls.has(item.id)) return;
|
||||
context.emittedToolCalls.add(item.id);
|
||||
this.#closeReasoning(context);
|
||||
context.emitter.toolCall(name, args || {}, item.id);
|
||||
}
|
||||
|
||||
#emitToolResultOnce(context, item, output, name) {
|
||||
if (!item?.id || context.emittedToolResults.has(item.id)) return;
|
||||
context.emittedToolResults.add(item.id);
|
||||
context.emitter.toolResult(item.id, output || "", name);
|
||||
}
|
||||
|
||||
#handleItem(context, item, completed) {
|
||||
if (!item || typeof item !== "object") return;
|
||||
const emitter = context.emitter;
|
||||
switch (item.type) {
|
||||
case "agentMessage": {
|
||||
if (!completed) return;
|
||||
this.#closeReasoning(context);
|
||||
const streamed = context.streamedTextByItem.get(item.id);
|
||||
context.streamedTextByItem.delete(item.id);
|
||||
if (item.text && streamed && item.text.startsWith(streamed.prefix)) {
|
||||
if (item.text.length > streamed.length) emitter.text(item.text.slice(streamed.length));
|
||||
} else if (item.text && !streamed) emitter.text(item.text);
|
||||
return;
|
||||
}
|
||||
case "reasoning": {
|
||||
if (!completed) return;
|
||||
const finalText = Array.isArray(item.summary) ? item.summary.join("\n") : "";
|
||||
const streamed = context.streamedReasoningByItem.get(item.id);
|
||||
context.streamedReasoningByItem.delete(item.id);
|
||||
if (finalText && streamed && finalText.startsWith(streamed.prefix)) {
|
||||
if (finalText.length > streamed.length) emitter.reasoning(finalText.slice(streamed.length));
|
||||
} else if (finalText && !streamed) emitter.reasoning(finalText);
|
||||
context.reasoningOpen = true;
|
||||
this.#closeReasoning(context);
|
||||
return;
|
||||
}
|
||||
case "commandExecution": {
|
||||
const toolName = "codex.command";
|
||||
this.#emitToolCallOnce(context, item, toolName, { command: item.command, cwd: item.cwd });
|
||||
if (completed) {
|
||||
const streamedOutput = context.commandOutputByItem.get(item.id);
|
||||
context.commandOutputByItem.delete(item.id);
|
||||
const output = item.aggregatedOutput == null
|
||||
? formatBoundedToolOutput(
|
||||
streamedOutput?.text || "",
|
||||
streamedOutput?.totalLength || 0,
|
||||
)
|
||||
: formatBoundedToolOutput(item.aggregatedOutput);
|
||||
const suffix = item.exitCode == null ? "" : `\n[exit code: ${item.exitCode}]`;
|
||||
this.#emitToolResultOnce(context, item, `${output}${suffix}`, toolName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "mcpToolCall": {
|
||||
const toolName = `${item.server || "mcp"}.${item.tool || "tool"}`;
|
||||
this.#emitToolCallOnce(context, item, toolName, item.arguments || {});
|
||||
if (completed) {
|
||||
const output = item.error?.message || stringifyMcpContent(item.result);
|
||||
this.#emitToolResultOnce(context, item, output, toolName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "fileChange":
|
||||
if (completed) {
|
||||
emitter.fileChange(
|
||||
item.id,
|
||||
normalizeFileChanges(item.changes),
|
||||
item.status === "completed" ? "completed" : "failed",
|
||||
);
|
||||
}
|
||||
return;
|
||||
case "webSearch":
|
||||
emitter.webSearch(item.id, item.query || "", completed ? "completed" : "running");
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#completeTurn(context, turn) {
|
||||
if (context.settled) return;
|
||||
context.settled = true;
|
||||
clearTimeout(context.forceCancelTimer);
|
||||
context.forceCancelTimer = null;
|
||||
this.#closeReasoning(context);
|
||||
this.#clearInteractionsForContext(context, "cancel");
|
||||
if (turn?.status === "failed") {
|
||||
context.reject(new Error(turn.error?.message || context.lastError || "Codex turn failed"));
|
||||
return;
|
||||
}
|
||||
context.emitter.emitDone();
|
||||
context.resolve();
|
||||
}
|
||||
|
||||
async #handleServerRequest(connectionKey, connection, message) {
|
||||
const params = message.params || {};
|
||||
const context = this.#findContext(connectionKey, params);
|
||||
const supported = new Map([
|
||||
["item/commandExecution/requestApproval", "command"],
|
||||
["item/fileChange/requestApproval", "file-change"],
|
||||
["item/permissions/requestApproval", "permissions"],
|
||||
["item/tool/requestUserInput", "user-input"],
|
||||
]);
|
||||
const kind = supported.get(message.method);
|
||||
if (!kind) {
|
||||
connection.respondError(message.id, -32601, `Unsupported Codex App Server request: ${message.method}`);
|
||||
context?.emitter.warning(
|
||||
`codex-unsupported-request:${++this.eventCounter}`,
|
||||
`Unsupported Codex request: ${message.method}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!context) {
|
||||
connection.respond(message.id, this.#safeInteractionResponse(kind, params, "reject"));
|
||||
return;
|
||||
}
|
||||
|
||||
const interactionId = `codex_interaction_${++this.interactionCounter}_${Date.now()}`;
|
||||
const timeoutMs = kind === "user-input" && Number(params.autoResolutionMs) > 0
|
||||
? Number(params.autoResolutionMs)
|
||||
: INTERACTION_TIMEOUT_MS;
|
||||
// Hard ceiling from creation — review can cancel the idle timer but must
|
||||
// re-arm the absolute remainder (Catty/MCP pattern; never unbounded).
|
||||
const absoluteExpiresAt = Date.now() + timeoutMs;
|
||||
const armTimer = (ms) => {
|
||||
const pending = this.pendingInteractions.get(interactionId);
|
||||
if (!pending) return;
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.timer = null;
|
||||
}
|
||||
if (ms <= 0) {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
pending.timer = setTimeout(() => {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
}, ms);
|
||||
};
|
||||
this.pendingInteractions.set(interactionId, {
|
||||
interactionId,
|
||||
connection,
|
||||
rpcId: message.id,
|
||||
kind,
|
||||
params,
|
||||
context,
|
||||
timer: null,
|
||||
absoluteExpiresAt,
|
||||
idleCancelled: false,
|
||||
});
|
||||
armTimer(timeoutMs);
|
||||
|
||||
const payload = {
|
||||
interactionId,
|
||||
source: "codex-app-server",
|
||||
kind,
|
||||
requestId: context.requestId,
|
||||
chatSessionId: context.chatSessionId,
|
||||
itemId: params.itemId,
|
||||
toolName: kind === "command"
|
||||
? "codex.command"
|
||||
: kind === "file-change"
|
||||
? "codex.file_change"
|
||||
: kind === "permissions"
|
||||
? "codex.permissions"
|
||||
: undefined,
|
||||
args: kind === "command"
|
||||
? {
|
||||
command: params.command,
|
||||
cwd: params.cwd,
|
||||
reason: params.reason,
|
||||
commandActions: params.commandActions,
|
||||
}
|
||||
: kind === "file-change"
|
||||
? { reason: params.reason, grantRoot: params.grantRoot, itemId: params.itemId }
|
||||
: kind === "permissions"
|
||||
? { cwd: params.cwd, reason: params.reason, permissions: params.permissions }
|
||||
: undefined,
|
||||
availableDecisions: kind === "command" && Array.isArray(params.availableDecisions)
|
||||
? params.availableDecisions
|
||||
: undefined,
|
||||
questions: kind === "user-input" ? params.questions || [] : undefined,
|
||||
autoResolutionMs: kind === "user-input" ? params.autoResolutionMs : undefined,
|
||||
};
|
||||
let delivered = false;
|
||||
try {
|
||||
delivered = typeof this.sendInteractionRequest === "function"
|
||||
&& this.sendInteractionRequest(payload, context) !== false;
|
||||
} catch {
|
||||
delivered = false;
|
||||
}
|
||||
if (!delivered) {
|
||||
this.#resolveInteraction(interactionId, kind === "user-input" ? { answers: {} } : { decision: "reject" });
|
||||
}
|
||||
}
|
||||
|
||||
#safeInteractionResponse(kind, params, decision) {
|
||||
if (kind === "user-input") return { answers: {} };
|
||||
if (kind === "permissions") {
|
||||
const granted = decision === "once" || decision === "session"
|
||||
? normalizeGrantedPermissions(params.permissions)
|
||||
: {};
|
||||
return { permissions: granted, scope: decision === "session" ? "session" : "turn" };
|
||||
}
|
||||
const mapped = decision === "once"
|
||||
? "accept"
|
||||
: decision === "session"
|
||||
? "acceptForSession"
|
||||
: decision === "cancel"
|
||||
? "cancel"
|
||||
: "decline";
|
||||
return { decision: mapped };
|
||||
}
|
||||
|
||||
#resolveInteraction(interactionId, response) {
|
||||
const pending = this.pendingInteractions.get(interactionId);
|
||||
if (!pending) return false;
|
||||
this.pendingInteractions.delete(interactionId);
|
||||
clearTimeout(pending.timer);
|
||||
try {
|
||||
const result = pending.kind === "user-input"
|
||||
? { answers: response?.answers || {} }
|
||||
: this.#safeInteractionResponse(pending.kind, pending.params, response?.decision || "reject");
|
||||
try { pending.connection.respond(pending.rpcId, result); } catch {}
|
||||
} finally {
|
||||
this.sendInteractionCleared?.({
|
||||
interactionIds: [interactionId],
|
||||
chatSessionId: pending.context.chatSessionId,
|
||||
}, pending.context);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
respondInteraction(interactionId, response, sender) {
|
||||
const pending = this.pendingInteractions.get(interactionId);
|
||||
if (sender && pending?.context?.sender && pending.context.sender !== sender) return false;
|
||||
return this.#resolveInteraction(interactionId, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the idle auto-reject timer after the user starts reviewing an approval card.
|
||||
* Re-arms the absolute creation deadline so a late approve cannot outlive the
|
||||
* original timeout window (matches Catty/MCP approval cancel semantics).
|
||||
*/
|
||||
cancelInteractionTimeout(interactionId, sender) {
|
||||
const pending = this.pendingInteractions.get(interactionId);
|
||||
if (!pending || pending.idleCancelled) return false;
|
||||
if (sender && pending.context?.sender && pending.context.sender !== sender) return false;
|
||||
pending.idleCancelled = true;
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.timer = null;
|
||||
}
|
||||
const remainingMs = Math.max(0, (pending.absoluteExpiresAt ?? 0) - Date.now());
|
||||
if (remainingMs <= 0) {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
pending.kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
pending.timer = setTimeout(() => {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
pending.kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
}, remainingMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
#clearInteractionsForContext(context, decision) {
|
||||
for (const [interactionId, pending] of Array.from(this.pendingInteractions)) {
|
||||
if (pending.context === context) {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
pending.kind === "user-input" ? { answers: {} } : { decision },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async cancelTurn(requestId) {
|
||||
const context = this.activeByRequest.get(requestId);
|
||||
if (!context) return false;
|
||||
context.cancelRequested = true;
|
||||
this.#clearInteractionsForContext(context, "cancel");
|
||||
await this.#interruptAndSchedule(context);
|
||||
return true;
|
||||
}
|
||||
|
||||
async cleanupChatSession(chatSessionId) {
|
||||
const contexts = Array.from(this.activeByRequest.values())
|
||||
.filter((context) => context.chatSessionId === chatSessionId);
|
||||
await Promise.all(contexts.map((context) => this.cancelTurn(context.requestId)));
|
||||
}
|
||||
|
||||
#handleConnectionFatal(connectionKey, error) {
|
||||
const connection = this.connections.get(connectionKey);
|
||||
if (connection) {
|
||||
this.connections.delete(connectionKey);
|
||||
this.#refreshPreferredConnectionKey();
|
||||
}
|
||||
const contexts = Array.from(this.activeByRequest.values())
|
||||
.filter((context) => context.connectionKey === connectionKey);
|
||||
for (const context of contexts) {
|
||||
if (context.settled) continue;
|
||||
context.settled = true;
|
||||
clearTimeout(context.forceCancelTimer);
|
||||
context.forceCancelTimer = null;
|
||||
this.#clearInteractionsForContext(context, "cancel");
|
||||
context.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
#removeContext(context) {
|
||||
clearTimeout(context.forceCancelTimer);
|
||||
context.forceCancelTimer = null;
|
||||
if (context.abortListener && context.signal) {
|
||||
context.signal.removeEventListener("abort", context.abortListener);
|
||||
context.abortListener = null;
|
||||
}
|
||||
this.activeByRequest.delete(context.requestId);
|
||||
this.activeByThread.delete(this.#scopedKey(context.connectionKey, context.threadId));
|
||||
if (context.turnId) this.activeByTurn.delete(this.#scopedKey(context.connectionKey, context.turnId));
|
||||
this.#closeIdleConnections();
|
||||
}
|
||||
|
||||
close() {
|
||||
for (const interactionId of Array.from(this.pendingInteractions.keys())) {
|
||||
this.#resolveInteraction(interactionId, { decision: "cancel", answers: {} });
|
||||
}
|
||||
for (const [, connection] of this.connections) connection.close();
|
||||
this.connections.clear();
|
||||
this.preferredConnectionKey = null;
|
||||
for (const context of this.activeByRequest.values()) {
|
||||
if (!context.settled) {
|
||||
context.settled = true;
|
||||
clearTimeout(context.forceCancelTimer);
|
||||
context.forceCancelTimer = null;
|
||||
context.reject(new Error("Codex App Server shut down"));
|
||||
}
|
||||
}
|
||||
this.activeByRequest.clear();
|
||||
this.activeByThread.clear();
|
||||
this.activeByTurn.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CodexAppServerRuntime,
|
||||
INTERACTION_TIMEOUT_MS,
|
||||
buildThreadConfig,
|
||||
buildTurnInput,
|
||||
getActiveTurnNotSteerableKind,
|
||||
mapAppServerModels,
|
||||
normalizeFileChanges,
|
||||
normalizeGrantedPermissions,
|
||||
resolveAppServerModelSelection,
|
||||
resolveCodexPermissionConfig,
|
||||
stringifyMcpContent,
|
||||
};
|
||||
850
electron/bridges/aiBridge/codexAppServer/runtime.test.cjs
Normal file
850
electron/bridges/aiBridge/codexAppServer/runtime.test.cjs
Normal file
@@ -0,0 +1,850 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
CodexAppServerRuntime,
|
||||
buildTurnInput,
|
||||
mapAppServerModels,
|
||||
normalizeFileChanges,
|
||||
resolveCodexPermissionConfig,
|
||||
} = require("./runtime.cjs");
|
||||
|
||||
class FakeConnection {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.requests = [];
|
||||
this.responses = [];
|
||||
this.threadId = "thread-1";
|
||||
this.turnId = "turn-1";
|
||||
this.closed = false;
|
||||
}
|
||||
async start() { return this; }
|
||||
async request(method, params) {
|
||||
this.requests.push({ method, params });
|
||||
if (method === "thread/start" || method === "thread/resume") {
|
||||
return { thread: { id: this.threadId } };
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
if (this.turnStartGate) await this.turnStartGate;
|
||||
return { turn: { id: this.turnId } };
|
||||
}
|
||||
if (method === "turn/steer") {
|
||||
if (this.turnSteerGate) await this.turnSteerGate;
|
||||
if (this.turnSteerError) throw this.turnSteerError;
|
||||
return { turnId: this.turnId };
|
||||
}
|
||||
if (method === "turn/interrupt") {
|
||||
if (this.turnInterruptGate) await this.turnInterruptGate;
|
||||
if (this.turnInterruptError) throw this.turnInterruptError;
|
||||
return {};
|
||||
}
|
||||
if (method === "model/list") {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: "gpt-first",
|
||||
displayName: "GPT First",
|
||||
description: "First model in the catalog",
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [{ reasoningEffort: "low" }],
|
||||
defaultReasoningEffort: "low",
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-test",
|
||||
displayName: "GPT Test",
|
||||
description: "Server default model",
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [{ reasoningEffort: "low" }, { reasoningEffort: "high" }],
|
||||
defaultReasoningEffort: "high",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
respond(id, result) { this.responses.push({ id, result }); }
|
||||
respondError(id, code, message) { this.responses.push({ id, error: { code, message } }); }
|
||||
notify(message) { this.options.onNotification(message); }
|
||||
serverRequest(message) { return this.options.onServerRequest(message, this); }
|
||||
close() { this.closed = true; }
|
||||
}
|
||||
|
||||
function createEmitter() {
|
||||
const events = [];
|
||||
return {
|
||||
events,
|
||||
emitDone: () => events.push(["done"]),
|
||||
sessionId: (id) => events.push(["session", id]),
|
||||
text: (text) => events.push(["text", text]),
|
||||
reasoning: (text) => events.push(["reasoning", text]),
|
||||
reasoningEnd: () => events.push(["reasoning-end"]),
|
||||
toolCall: (name, args, id) => events.push(["tool-call", name, args, id]),
|
||||
toolResult: (id, output, name) => events.push(["tool-result", id, output, name]),
|
||||
fileChange: (id, changes, status) => events.push(["file-change", id, changes, status]),
|
||||
webSearch: (id, query, status) => events.push(["web-search", id, query, status]),
|
||||
planUpdate: (id, items, status) => events.push(["plan", id, items, status]),
|
||||
warning: (id, message) => events.push(["warning", id, message]),
|
||||
usage: (usage) => events.push(["usage", usage]),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitFor(predicate) {
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
throw new Error("condition not reached");
|
||||
}
|
||||
|
||||
test("permission modes map to fail-closed Codex policies", () => {
|
||||
assert.deepEqual(resolveCodexPermissionConfig("observer"), {
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "read-only",
|
||||
sandboxPolicy: { type: "readOnly", networkAccess: false },
|
||||
});
|
||||
assert.equal(resolveCodexPermissionConfig("confirm").approvalPolicy, "on-request");
|
||||
assert.equal(resolveCodexPermissionConfig("confirm").sandbox, "read-only");
|
||||
assert.equal(resolveCodexPermissionConfig("auto").sandbox, "danger-full-access");
|
||||
});
|
||||
|
||||
test("turn input uses text plus local images only", () => {
|
||||
assert.deepEqual(buildTurnInput("hello", [
|
||||
{ filePath: "/tmp/a.png", mediaType: "image/png" },
|
||||
{ filePath: "/tmp/a.txt", mediaType: "text/plain" },
|
||||
]), [
|
||||
{ type: "text", text: "hello", text_elements: [] },
|
||||
{ type: "localImage", path: "/tmp/a.png" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runtime maps lifecycle, activities, usage, and retry warnings", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
});
|
||||
const emitter = createEmitter();
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-1",
|
||||
chatSessionId: "chat-1",
|
||||
prompt: "hello",
|
||||
cwd: "/repo",
|
||||
model: "gpt-test/high",
|
||||
permissionMode: "confirm",
|
||||
env: { HOME: "/home" },
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
connection.notify({ method: "item/agentMessage/delta", params: { threadId: "thread-1", turnId: "turn-1", itemId: "msg-1", delta: "Hi" } });
|
||||
connection.notify({ method: "turn/plan/updated", params: { threadId: "thread-1", turnId: "turn-1", plan: [{ step: "Inspect", status: "completed" }] } });
|
||||
connection.notify({ method: "item/started", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "webSearch", id: "search-1", query: "Netcatty" } } });
|
||||
connection.notify({ method: "item/completed", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "fileChange", id: "file-1", status: "completed", changes: [{ path: "a.ts", kind: { type: "add" } }] } } });
|
||||
connection.notify({ method: "thread/tokenUsage/updated", params: { threadId: "thread-1", turnId: "turn-1", tokenUsage: { last: { inputTokens: 10, cachedInputTokens: 2, outputTokens: 3, reasoningOutputTokens: 1, totalTokens: 13 } } } });
|
||||
connection.notify({ method: "error", params: { threadId: "thread-1", turnId: "turn-1", willRetry: true, error: { message: "network" } } });
|
||||
connection.notify({ method: "warning", params: { message: "global warning" } });
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
|
||||
assert.ok(emitter.events.some((event) => event[0] === "text" && event[1] === "Hi"));
|
||||
assert.ok(emitter.events.some((event) => event[0] === "plan"));
|
||||
assert.ok(emitter.events.some((event) => event[0] === "web-search" && event[3] === "running"));
|
||||
assert.ok(emitter.events.some((event) => event[0] === "file-change" && event[3] === "completed"));
|
||||
assert.ok(emitter.events.some((event) => event[0] === "usage" && event[1].cachedInputTokens === 2));
|
||||
assert.ok(emitter.events.some((event) => event[0] === "warning" && /retrying/.test(event[2])));
|
||||
assert.ok(emitter.events.some((event) => event[0] === "warning" && event[2] === "global warning"));
|
||||
assert.ok(emitter.events.some((event) => event[0] === "done"));
|
||||
});
|
||||
|
||||
test("runtime bounds command output and avoids replaying streamed message prefixes", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
});
|
||||
const emitter = createEmitter();
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-bounded-output",
|
||||
chatSessionId: "chat-bounded-output",
|
||||
prompt: "run",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
connection.notify({ method: "item/agentMessage/delta", params: {
|
||||
threadId: "thread-1", turnId: "turn-1", itemId: "msg-bounded", delta: "Hello",
|
||||
} });
|
||||
connection.notify({ method: "item/completed", params: {
|
||||
threadId: "thread-1", turnId: "turn-1",
|
||||
item: { type: "agentMessage", id: "msg-bounded", text: "Hello world" },
|
||||
} });
|
||||
connection.notify({ method: "item/commandExecution/outputDelta", params: {
|
||||
threadId: "thread-1", turnId: "turn-1", itemId: "cmd-bounded",
|
||||
delta: "x".repeat(1024 * 1024 + 1024),
|
||||
} });
|
||||
connection.notify({ method: "item/completed", params: {
|
||||
threadId: "thread-1", turnId: "turn-1",
|
||||
item: { type: "commandExecution", id: "cmd-bounded", command: "large", exitCode: 0 },
|
||||
} });
|
||||
connection.notify({ method: "turn/completed", params: {
|
||||
threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null },
|
||||
} });
|
||||
await run;
|
||||
|
||||
assert.deepEqual(
|
||||
emitter.events.filter((event) => event[0] === "text"),
|
||||
[["text", "Hello"], ["text", " world"]],
|
||||
);
|
||||
const toolResult = emitter.events.find((event) => event[0] === "tool-result");
|
||||
assert.ok(toolResult);
|
||||
assert.ok(toolResult[2].length < 1024 * 1024 + 200);
|
||||
assert.match(toolResult[2], /output truncated: 1049600 characters total/);
|
||||
assert.match(toolResult[2], /\[exit code: 0\]$/);
|
||||
});
|
||||
|
||||
test("runtime delegates injected MCP approvals to Netcatty's policy gate", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-mcp-policy",
|
||||
chatSessionId: "chat-mcp-policy",
|
||||
prompt: "inspect the terminal",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [{
|
||||
name: "netcatty-remote-hosts",
|
||||
command: "/abs/electron",
|
||||
args: ["/abs/server.cjs"],
|
||||
env: [{ name: "NETCATTY_MCP_PERMISSION_MODE", value: "confirm" }],
|
||||
}],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
const threadStart = connection.requests.find((request) => request.method === "thread/start");
|
||||
assert.deepEqual(threadStart.params.config.mcp_servers["netcatty-remote-hosts"], {
|
||||
command: "/abs/electron",
|
||||
args: ["/abs/server.cjs"],
|
||||
env: { NETCATTY_MCP_PERMISSION_MODE: "confirm" },
|
||||
default_tools_approval_mode: "approve",
|
||||
});
|
||||
|
||||
connection.notify({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } },
|
||||
});
|
||||
await run;
|
||||
});
|
||||
|
||||
test("runtime routes native approvals and request_user_input responses", async () => {
|
||||
let connection;
|
||||
let interaction;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
sendInteractionRequest: (payload) => { interaction = payload; return true; },
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-2",
|
||||
chatSessionId: "chat-2",
|
||||
prompt: "change it",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
await connection.serverRequest({
|
||||
id: 70,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-1",
|
||||
command: "npm test",
|
||||
cwd: "/repo",
|
||||
availableDecisions: ["accept", "acceptForSession", "decline", "cancel"],
|
||||
},
|
||||
});
|
||||
assert.equal(interaction.kind, "command");
|
||||
assert.deepEqual(interaction.availableDecisions, ["accept", "acceptForSession", "decline", "cancel"]);
|
||||
runtime.respondInteraction(interaction.interactionId, { decision: "session" });
|
||||
assert.deepEqual(connection.responses.at(-1), { id: 70, result: { decision: "acceptForSession" } });
|
||||
|
||||
await connection.serverRequest({
|
||||
id: 72,
|
||||
method: "item/permissions/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "permissions-1",
|
||||
permissions: { network: { enabled: true }, fileSystem: null },
|
||||
cwd: "/repo",
|
||||
},
|
||||
});
|
||||
runtime.respondInteraction(interaction.interactionId, { decision: "once" });
|
||||
assert.deepEqual(connection.responses.at(-1), {
|
||||
id: 72,
|
||||
result: { permissions: { network: { enabled: true } }, scope: "turn" },
|
||||
});
|
||||
|
||||
await connection.serverRequest({
|
||||
id: 71,
|
||||
method: "item/tool/requestUserInput",
|
||||
params: { threadId: "thread-1", turnId: "turn-1", itemId: "question-1", questions: [{ id: "choice", question: "Choose", header: "Mode", isOther: true, isSecret: false, options: null }] },
|
||||
});
|
||||
runtime.respondInteraction(interaction.interactionId, { answers: { choice: { answers: ["safe"] } } });
|
||||
assert.deepEqual(connection.responses.at(-1), { id: 71, result: { answers: { choice: { answers: ["safe"] } } } });
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
});
|
||||
|
||||
test("cancelInteractionTimeout re-arms the absolute approval deadline (Catty/MCP style)", async () => {
|
||||
let connection;
|
||||
let interaction;
|
||||
const realNow = Date.now;
|
||||
let now = 5_000_000;
|
||||
Date.now = () => now;
|
||||
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
sendInteractionRequest: (payload) => { interaction = payload; return true; },
|
||||
});
|
||||
try {
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-timeout-cancel",
|
||||
chatSessionId: "chat-timeout-cancel",
|
||||
prompt: "ask",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
await connection.serverRequest({
|
||||
id: 90,
|
||||
method: "item/tool/requestUserInput",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "question-timeout",
|
||||
questions: [{ id: "choice", question: "Choose", header: "Mode", isOther: true, isSecret: false, options: null }],
|
||||
autoResolutionMs: 100,
|
||||
},
|
||||
});
|
||||
assert.ok(interaction?.interactionId);
|
||||
|
||||
// Jump close to the absolute ceiling, then cancel idle. Remaining absolute ~30ms.
|
||||
now += 70;
|
||||
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), true);
|
||||
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), false);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
assert.equal(
|
||||
connection.responses.some((response) => response.id === 90),
|
||||
false,
|
||||
"must stay pending before absolute expiry",
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
assert.equal(
|
||||
connection.responses.some((response) => response.id === 90),
|
||||
true,
|
||||
"absolute deadline must auto-reject after remaining time elapses",
|
||||
);
|
||||
assert.deepEqual(connection.responses.at(-1), {
|
||||
id: 90,
|
||||
result: { answers: {} },
|
||||
});
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
|
||||
test("cancelInteractionTimeout still allows explicit approve before absolute expiry", async () => {
|
||||
let connection;
|
||||
let interaction;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
sendInteractionRequest: (payload) => { interaction = payload; return true; },
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-timeout-approve",
|
||||
chatSessionId: "chat-timeout-approve",
|
||||
prompt: "ask",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
await connection.serverRequest({
|
||||
id: 91,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-approve",
|
||||
command: "echo ok",
|
||||
cwd: "/tmp",
|
||||
reason: "demo",
|
||||
},
|
||||
});
|
||||
assert.ok(interaction?.interactionId);
|
||||
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), true);
|
||||
assert.equal(runtime.respondInteraction(interaction.interactionId, { decision: "once" }), true);
|
||||
assert.deepEqual(connection.responses.at(-1), {
|
||||
id: 91,
|
||||
result: { decision: "accept" },
|
||||
});
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
});
|
||||
|
||||
test("runtime steers the active turn with text, local images, and a stable user message id", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-steer",
|
||||
chatSessionId: "chat-steer",
|
||||
prompt: "initial",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
const result = await runtime.steerTurn("request-steer", {
|
||||
chatSessionId: "chat-steer",
|
||||
prompt: "use this image",
|
||||
attachments: [
|
||||
{ filePath: "/tmp/image.png", mediaType: "image/png" },
|
||||
{ filePath: "/tmp/notes.txt", mediaType: "text/plain" },
|
||||
],
|
||||
clientUserMessageId: "user-steer-1",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { status: "accepted" });
|
||||
assert.deepEqual(connection.requests.find((request) => request.method === "turn/steer"), {
|
||||
method: "turn/steer",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-1",
|
||||
input: [
|
||||
{ type: "text", text: "use this image", text_elements: [] },
|
||||
{ type: "localImage", path: "/tmp/image.png" },
|
||||
],
|
||||
clientUserMessageId: "user-steer-1",
|
||||
},
|
||||
});
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
});
|
||||
|
||||
test("runtime serializes steering and classifies non-steerable turns", async () => {
|
||||
let connection;
|
||||
let releaseSteer;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => {
|
||||
connection = new FakeConnection(options);
|
||||
connection.turnSteerGate = new Promise((resolve) => { releaseSteer = resolve; });
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-steer-busy",
|
||||
chatSessionId: "chat-steer-busy",
|
||||
prompt: "initial",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
const first = runtime.steerTurn("request-steer-busy", {
|
||||
chatSessionId: "chat-steer-busy",
|
||||
prompt: "first",
|
||||
clientUserMessageId: "user-first",
|
||||
});
|
||||
await waitFor(() => connection.requests.some((request) => request.method === "turn/steer"));
|
||||
assert.equal((await runtime.steerTurn("request-steer-busy", {
|
||||
chatSessionId: "chat-steer-busy",
|
||||
prompt: "second",
|
||||
clientUserMessageId: "user-second",
|
||||
})).status, "busy");
|
||||
releaseSteer();
|
||||
assert.equal((await first).status, "accepted");
|
||||
|
||||
const error = new Error("active turn cannot be steered");
|
||||
error.data = { activeTurnNotSteerable: { turnKind: "review" } };
|
||||
connection.turnSteerError = error;
|
||||
const rejected = await runtime.steerTurn("request-steer-busy", {
|
||||
chatSessionId: "chat-steer-busy",
|
||||
prompt: "review change",
|
||||
clientUserMessageId: "user-review",
|
||||
});
|
||||
assert.deepEqual(rejected, {
|
||||
status: "not-steerable",
|
||||
turnKind: "review",
|
||||
message: "active turn cannot be steered",
|
||||
});
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
});
|
||||
|
||||
test("stop during steering cancels the UI result without creating a replacement turn", async () => {
|
||||
let connection;
|
||||
let releaseSteer;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => {
|
||||
connection = new FakeConnection(options);
|
||||
connection.turnSteerGate = new Promise((resolve) => { releaseSteer = resolve; });
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-steer-stop",
|
||||
chatSessionId: "chat-steer-stop",
|
||||
prompt: "initial",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
const steer = runtime.steerTurn("request-steer-stop", {
|
||||
chatSessionId: "chat-steer-stop",
|
||||
prompt: "too late",
|
||||
clientUserMessageId: "user-steer-stop",
|
||||
});
|
||||
await waitFor(() => connection.requests.some((request) => request.method === "turn/steer"));
|
||||
assert.equal(await runtime.cancelTurn("request-steer-stop"), true);
|
||||
releaseSteer();
|
||||
assert.equal((await steer).status, "cancelled");
|
||||
assert.equal(connection.requests.filter((request) => request.method === "turn/start").length, 1);
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "interrupted", error: null } } });
|
||||
await run;
|
||||
});
|
||||
|
||||
test("stop requested while turn/start is pending interrupts the assigned turn", async () => {
|
||||
let connection;
|
||||
let releaseTurnStart;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => {
|
||||
connection = new FakeConnection(options);
|
||||
connection.turnStartGate = new Promise((resolve) => { releaseTurnStart = resolve; });
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const emitter = createEmitter();
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-stop",
|
||||
chatSessionId: "chat-stop",
|
||||
prompt: "wait",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
assert.equal(await runtime.cancelTurn("request-stop"), true);
|
||||
assert.equal(connection.requests.some((request) => request.method === "turn/interrupt"), false);
|
||||
|
||||
releaseTurnStart();
|
||||
await waitFor(() => connection.requests.some((request) => request.method === "turn/interrupt"));
|
||||
connection.notify({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-1", turn: { id: "turn-1", status: "interrupted", error: null } },
|
||||
});
|
||||
await run;
|
||||
assert.equal(connection.requests.filter((request) => request.method === "turn/interrupt").length, 1);
|
||||
assert.ok(emitter.events.some((event) => event[0] === "done"));
|
||||
});
|
||||
|
||||
test("failed interrupt force-settles the turn and releases its connection", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
interruptGraceMs: 5,
|
||||
connectionFactory: (options) => {
|
||||
connection = new FakeConnection(options);
|
||||
connection.turnInterruptError = new Error("interrupt unavailable");
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-interrupt-failure",
|
||||
chatSessionId: "chat-interrupt-failure",
|
||||
prompt: "wait",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
assert.equal(await runtime.cancelTurn("request-interrupt-failure"), true);
|
||||
await Promise.race([
|
||||
run,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("turn did not settle")), 50)),
|
||||
]);
|
||||
|
||||
assert.equal(connection.closed, true);
|
||||
assert.equal(runtime.activeByRequest.size, 0);
|
||||
assert.equal(runtime.activeByThread.size, 0);
|
||||
assert.equal(runtime.activeByTurn.size, 0);
|
||||
});
|
||||
|
||||
test("hung interrupt request times out and force-settles the turn", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
interruptRequestTimeoutMs: 5,
|
||||
interruptGraceMs: 5,
|
||||
connectionFactory: (options) => {
|
||||
connection = new FakeConnection(options);
|
||||
connection.turnInterruptGate = new Promise(() => {});
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-interrupt-hung",
|
||||
chatSessionId: "chat-interrupt-hung",
|
||||
prompt: "wait",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
assert.equal(await runtime.cancelTurn("request-interrupt-hung"), true);
|
||||
await Promise.race([
|
||||
run,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("turn did not settle")), 50)),
|
||||
]);
|
||||
|
||||
assert.equal(connection.closed, true);
|
||||
assert.equal(runtime.activeByRequest.size, 0);
|
||||
});
|
||||
|
||||
test("acknowledged interrupt force-settles when completion never arrives", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
interruptGraceMs: 5,
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-interrupt-no-completion",
|
||||
chatSessionId: "chat-interrupt-no-completion",
|
||||
prompt: "wait",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
assert.equal(await runtime.cancelTurn("request-interrupt-no-completion"), true);
|
||||
await Promise.race([
|
||||
run,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("turn did not settle")), 50)),
|
||||
]);
|
||||
|
||||
assert.equal(connection.closed, true);
|
||||
assert.equal(runtime.activeByRequest.size, 0);
|
||||
});
|
||||
|
||||
test("idle superseded app-server connections close when their active turn finishes", async () => {
|
||||
const connections = [];
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => {
|
||||
const connection = new FakeConnection(options);
|
||||
connection.threadId = `thread-${connections.length + 1}`;
|
||||
connection.turnId = `turn-${connections.length + 1}`;
|
||||
connections.push(connection);
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const firstRun = runtime.runTurn({
|
||||
requestId: "request-config-a",
|
||||
chatSessionId: "chat-config-a",
|
||||
prompt: "first",
|
||||
permissionMode: "confirm",
|
||||
env: { PROFILE: "a" },
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connections[0]?.requests.some((request) => request.method === "turn/start"));
|
||||
const secondRun = runtime.runTurn({
|
||||
requestId: "request-config-b",
|
||||
chatSessionId: "chat-config-b",
|
||||
prompt: "second",
|
||||
permissionMode: "confirm",
|
||||
env: { PROFILE: "b" },
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connections[1]?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
connections[0].notify({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } },
|
||||
});
|
||||
await firstRun;
|
||||
assert.equal(connections[0].closed, true);
|
||||
assert.equal(connections[1].closed, false);
|
||||
|
||||
connections[1].notify({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-2", turn: { id: "turn-2", status: "completed", error: null } },
|
||||
});
|
||||
await secondRun;
|
||||
assert.equal(connections[1].closed, false);
|
||||
runtime.close();
|
||||
});
|
||||
|
||||
test("force-cancelling the preferred connection keeps another active connection reusable", async () => {
|
||||
const connections = [];
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
interruptGraceMs: 5,
|
||||
connectionFactory: (options) => {
|
||||
const connection = new FakeConnection(options);
|
||||
connection.threadId = `thread-reuse-${connections.length + 1}`;
|
||||
connection.turnId = `turn-reuse-${connections.length + 1}`;
|
||||
connections.push(connection);
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const firstRun = runtime.runTurn({
|
||||
requestId: "request-reuse-a",
|
||||
chatSessionId: "chat-reuse-a",
|
||||
prompt: "first",
|
||||
permissionMode: "confirm",
|
||||
env: { PROFILE: "reuse-a" },
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connections[0]?.requests.some((request) => request.method === "turn/start"));
|
||||
const secondRun = runtime.runTurn({
|
||||
requestId: "request-reuse-b",
|
||||
chatSessionId: "chat-reuse-b",
|
||||
prompt: "second",
|
||||
permissionMode: "confirm",
|
||||
env: { PROFILE: "reuse-b" },
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connections[1]?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
assert.equal(await runtime.cancelTurn("request-reuse-b"), true);
|
||||
await secondRun;
|
||||
connections[0].notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-reuse-1",
|
||||
turn: { id: "turn-reuse-1", status: "completed", error: null },
|
||||
},
|
||||
});
|
||||
await firstRun;
|
||||
|
||||
assert.equal(connections[0].closed, false);
|
||||
runtime.close();
|
||||
});
|
||||
|
||||
test("unsupported requests fail immediately and warn without hanging", async () => {
|
||||
let connection;
|
||||
const emitter = createEmitter();
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-unsupported",
|
||||
chatSessionId: "chat-unsupported",
|
||||
prompt: "hello",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
await connection.serverRequest({
|
||||
id: 99,
|
||||
method: "item/unknown/requestApproval",
|
||||
params: { threadId: "thread-1", turnId: "turn-1" },
|
||||
});
|
||||
assert.deepEqual(connection.responses.at(-1), {
|
||||
id: 99,
|
||||
error: { code: -32601, message: "Unsupported Codex App Server request: item/unknown/requestApproval" },
|
||||
});
|
||||
assert.ok(emitter.events.some((event) => event[0] === "warning"));
|
||||
await connection.serverRequest({
|
||||
id: 100,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: { threadId: "thread-1", turnId: "turn-1", itemId: "cmd-no-renderer", command: "rm -rf /" },
|
||||
});
|
||||
assert.deepEqual(connection.responses.at(-1), { id: 100, result: { decision: "decline" } });
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
});
|
||||
|
||||
test("model and file-change normalization preserve UI contract", async () => {
|
||||
assert.deepEqual(normalizeFileChanges([
|
||||
{ path: "a", kind: { type: "add" } },
|
||||
{ path: "b", kind: { type: "delete" } },
|
||||
{ path: "c", kind: { type: "update", move_path: null } },
|
||||
]), [
|
||||
{ path: "a", kind: "add" },
|
||||
{ path: "b", kind: "delete" },
|
||||
{ path: "c", kind: "update" },
|
||||
]);
|
||||
assert.equal(mapAppServerModels([{ id: "hidden", hidden: true }]).length, 0);
|
||||
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
});
|
||||
const catalog = await runtime.listModels({ binPath: "/bin/codex", env: {} });
|
||||
assert.equal(connection.requests[0].method, "model/list");
|
||||
assert.equal(catalog.currentModelId, "gpt-test/high");
|
||||
assert.equal(catalog.models[0].id, "gpt-first");
|
||||
assert.deepEqual(catalog.models[1].thinkingLevels, ["low", "high"]);
|
||||
assert.equal(catalog.models[1].defaultThinkingLevel, "high");
|
||||
});
|
||||
124
electron/bridges/aiBridge/cursorCliSpawn.cjs
Normal file
124
electron/bridges/aiBridge/cursorCliSpawn.cjs
Normal file
@@ -0,0 +1,124 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Windows launch helper for the Cursor Agent installer shim.
|
||||
*
|
||||
* `%LOCALAPPDATA%\cursor-agent\cursor-agent.cmd` is a batch file that runs
|
||||
* `versions\<id>\node.exe` + `versions\<id>\index.js`. Node cannot spawn .cmd
|
||||
* directly (EINVAL). Routing the full turn prompt through cmd.exe is also
|
||||
* unsafe: 8191-char limit, %VAR% expansion, and broken quote escaping.
|
||||
*
|
||||
* Prefer the native node+script argv so prompts stay out of a shell.
|
||||
*/
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { prepareCommandForSpawn } = require("../ai/shellUtils.cjs");
|
||||
|
||||
function defaultExists(filePath) {
|
||||
try { return fs.existsSync(filePath); } catch { return false; }
|
||||
}
|
||||
|
||||
function defaultReadFile(filePath) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function defaultReaddir(dirPath) {
|
||||
return fs.readdirSync(dirPath);
|
||||
}
|
||||
|
||||
function defaultStat(filePath) {
|
||||
return fs.statSync(filePath);
|
||||
}
|
||||
|
||||
function expandWindowsShimPath(raw, shimDir) {
|
||||
const dp0 = /[\\/]$/.test(shimDir) ? shimDir : `${shimDir}${path.sep}`;
|
||||
let resolved = String(raw || "").replace(/%~dp0/gi, dp0);
|
||||
// Installer shims use Windows separators; normalize so existsSync works
|
||||
// when this helper is unit-tested on POSIX.
|
||||
resolved = resolved.replace(/\\/g, path.sep);
|
||||
if (!path.isAbsolute(resolved) && !path.win32.isAbsolute(resolved)) {
|
||||
resolved = path.resolve(shimDir, resolved);
|
||||
}
|
||||
return path.normalize(resolved);
|
||||
}
|
||||
|
||||
function parseCursorAgentCmdLaunch(shimPath, { exists, readFile } = {}) {
|
||||
const existsFn = exists || defaultExists;
|
||||
const readFn = readFile || defaultReadFile;
|
||||
let contents;
|
||||
try {
|
||||
contents = readFn(shimPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const match = String(contents || "").match(/"([^"\r\n]*node\.exe)"\s+"([^"\r\n]*index\.js)"/i);
|
||||
if (!match) return null;
|
||||
const shimDir = path.dirname(shimPath);
|
||||
const nodeExe = expandWindowsShimPath(match[1], shimDir);
|
||||
const script = expandWindowsShimPath(match[2], shimDir);
|
||||
if (!existsFn(nodeExe) || !existsFn(script)) return null;
|
||||
return { nodeExe, script };
|
||||
}
|
||||
|
||||
function resolveCursorAgentVersionsLaunch(installDir, { exists, readdir, stat } = {}) {
|
||||
const existsFn = exists || defaultExists;
|
||||
const readdirFn = readdir || defaultReaddir;
|
||||
const statFn = stat || defaultStat;
|
||||
const versionsDir = path.join(installDir, "versions");
|
||||
if (!existsFn(versionsDir)) return null;
|
||||
let names;
|
||||
try {
|
||||
names = readdirFn(versionsDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const candidates = [];
|
||||
for (const name of names) {
|
||||
const dir = path.join(versionsDir, name);
|
||||
const nodeExe = path.join(dir, "node.exe");
|
||||
const script = path.join(dir, "index.js");
|
||||
if (!existsFn(nodeExe) || !existsFn(script)) continue;
|
||||
let mtime = 0;
|
||||
try { mtime = Number(statFn(dir)?.mtimeMs) || 0; } catch { /* ignore */ }
|
||||
candidates.push({ name: String(name), nodeExe, script, mtime });
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
candidates.sort((a, b) => b.mtime - a.mtime || b.name.localeCompare(a.name));
|
||||
return { nodeExe: candidates[0].nodeExe, script: candidates[0].script };
|
||||
}
|
||||
|
||||
function resolveCursorAgentNativeLaunch(binPath, io = {}) {
|
||||
const normalized = String(binPath || "").trim();
|
||||
if (!normalized) return null;
|
||||
const ext = path.extname(normalized).toLowerCase();
|
||||
if (ext !== ".cmd" && ext !== ".bat") return null;
|
||||
|
||||
const fromShim = parseCursorAgentCmdLaunch(normalized, io);
|
||||
if (fromShim) return fromShim;
|
||||
|
||||
return resolveCursorAgentVersionsLaunch(path.dirname(normalized), io);
|
||||
}
|
||||
|
||||
function resolveCursorCliSpawnSpec(binPath, args, io = {}) {
|
||||
const command = String(binPath || "").trim();
|
||||
const spawnArgs = Array.isArray(args) ? args : [];
|
||||
const native = resolveCursorAgentNativeLaunch(command, io);
|
||||
if (native) {
|
||||
return {
|
||||
command: native.nodeExe,
|
||||
args: [native.script, ...spawnArgs],
|
||||
shell: false,
|
||||
};
|
||||
}
|
||||
// Last resort for unknown shims / short probes (status, models). Turns with
|
||||
// a long prompt should have resolved the official versions/ layout above.
|
||||
return prepareCommandForSpawn(command, spawnArgs, { unwrapNativeExe: false });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
expandWindowsShimPath,
|
||||
parseCursorAgentCmdLaunch,
|
||||
resolveCursorAgentNativeLaunch,
|
||||
resolveCursorAgentVersionsLaunch,
|
||||
resolveCursorCliSpawnSpec,
|
||||
};
|
||||
107
electron/bridges/aiBridge/cursorCliSpawn.test.cjs
Normal file
107
electron/bridges/aiBridge/cursorCliSpawn.test.cjs
Normal file
@@ -0,0 +1,107 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { prepareCommandForSpawn } = require("../ai/shellUtils.cjs");
|
||||
const {
|
||||
expandWindowsShimPath,
|
||||
parseCursorAgentCmdLaunch,
|
||||
resolveCursorAgentNativeLaunch,
|
||||
resolveCursorAgentVersionsLaunch,
|
||||
resolveCursorCliSpawnSpec,
|
||||
} = require("./cursorCliSpawn.cjs");
|
||||
|
||||
function writeCursorAgentInstall(root, version = "2026.06.01-abc") {
|
||||
const versionDir = path.join(root, "versions", version);
|
||||
fs.mkdirSync(versionDir, { recursive: true });
|
||||
const nodeExe = path.join(versionDir, "node.exe");
|
||||
const script = path.join(versionDir, "index.js");
|
||||
fs.writeFileSync(nodeExe, "", "utf8");
|
||||
fs.writeFileSync(script, "", "utf8");
|
||||
const shimPath = path.join(root, "cursor-agent.cmd");
|
||||
fs.writeFileSync(
|
||||
shimPath,
|
||||
`@ECHO off\r\n"%~dp0\\versions\\${version}\\node.exe" "%~dp0\\versions\\${version}\\index.js" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
return { shimPath, nodeExe, script, versionDir };
|
||||
}
|
||||
|
||||
test("expandWindowsShimPath expands %~dp0 relative to the shim directory", () => {
|
||||
const shimDir = path.join("C:", "Users", "me", "AppData", "Local", "cursor-agent");
|
||||
const resolved = expandWindowsShimPath("%~dp0\\versions\\2026.06.01-abc\\node.exe", shimDir);
|
||||
assert.equal(
|
||||
path.normalize(resolved),
|
||||
path.normalize(path.join(shimDir, "versions", "2026.06.01-abc", "node.exe")),
|
||||
);
|
||||
});
|
||||
|
||||
test("parseCursorAgentCmdLaunch reads node.exe + index.js from the installer shim", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-shim-"));
|
||||
try {
|
||||
const { shimPath, nodeExe, script } = writeCursorAgentInstall(tmp);
|
||||
const launch = parseCursorAgentCmdLaunch(shimPath);
|
||||
assert.deepEqual(launch, { nodeExe, script });
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCursorAgentVersionsLaunch picks the newest version directory", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-versions-"));
|
||||
try {
|
||||
const older = writeCursorAgentInstall(tmp, "2026.01.01-old");
|
||||
const newer = writeCursorAgentInstall(tmp, "2026.08.01-new");
|
||||
const olderTime = new Date("2026-01-01T00:00:00Z");
|
||||
const newerTime = new Date("2026-08-01T00:00:00Z");
|
||||
fs.utimesSync(older.versionDir, olderTime, olderTime);
|
||||
fs.utimesSync(newer.versionDir, newerTime, newerTime);
|
||||
|
||||
const launch = resolveCursorAgentVersionsLaunch(tmp);
|
||||
assert.deepEqual(launch, { nodeExe: newer.nodeExe, script: newer.script });
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCursorAgentNativeLaunch prefers the shim's node+script over a lone exe unwrap", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-native-"));
|
||||
try {
|
||||
const { shimPath, nodeExe, script } = writeCursorAgentInstall(tmp);
|
||||
const launch = resolveCursorAgentNativeLaunch(shimPath);
|
||||
assert.deepEqual(launch, { nodeExe, script });
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCursorCliSpawnSpec puts the prompt on argv, not a cmd.exe line", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-spawn-"));
|
||||
try {
|
||||
const { shimPath, nodeExe, script } = writeCursorAgentInstall(tmp);
|
||||
const prompt = 'do "%USERPROFILE%" and `whoami` then say hello';
|
||||
const spec = resolveCursorCliSpawnSpec(shimPath, ["--print", "--trust", prompt]);
|
||||
assert.deepEqual(spec, {
|
||||
command: nodeExe,
|
||||
args: [script, "--print", "--trust", prompt],
|
||||
shell: false,
|
||||
});
|
||||
assert.equal(spec.command.includes("cmd.exe"), false);
|
||||
assert.equal(spec.args.includes(prompt), true);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCursorCliSpawnSpec falls back to the cmd shim when versions/ is missing", () => {
|
||||
const shim = "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.cmd";
|
||||
const args = ["status", "--format", "json"];
|
||||
const spec = resolveCursorCliSpawnSpec(shim, args, {
|
||||
exists: () => false,
|
||||
readFile: () => { throw new Error("missing"); },
|
||||
});
|
||||
assert.deepEqual(spec, prepareCommandForSpawn(shim, args, { unwrapNativeExe: false }));
|
||||
});
|
||||
514
electron/bridges/aiBridge/providerHandlers.cjs
Normal file
514
electron/bridges/aiBridge/providerHandlers.cjs
Normal file
@@ -0,0 +1,514 @@
|
||||
/* eslint-disable no-undef */
|
||||
const { existsSync } = require("node:fs");
|
||||
|
||||
function registerProviderHandlers(ctx) {
|
||||
with (ctx) {
|
||||
ipcMain.handle("netcatty:ai:user-skills:status", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const status = await scanUserSkills(electronModule?.app);
|
||||
return { ok: true, ...toPublicUserSkillsStatus(status) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:user-skills:open", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const status = await scanUserSkills(electronModule?.app);
|
||||
const openResult = await electronModule?.shell?.openPath?.(status.directoryPath);
|
||||
return {
|
||||
ok: !openResult,
|
||||
error: openResult || undefined,
|
||||
...toPublicUserSkillsStatus(status),
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:user-skills:build-context", async (event, { prompt, selectedSkillSlugs }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const { context, status } = await buildUserSkillsContext(electronModule?.app, prompt, selectedSkillSlugs);
|
||||
return { ok: true, context, status: toPublicUserSkillsStatus(status) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:skills-cli:invocation", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const invocation = getSkillsCliInvocation();
|
||||
return {
|
||||
ok: true,
|
||||
skillPath: existsSync(NETCATTY_TOOL_SKILL_PATH) ? NETCATTY_TOOL_SKILL_PATH : null,
|
||||
commandPrefix: invocation.commandPrefix,
|
||||
launcherPath: invocation.launcherPath,
|
||||
usesLauncher: invocation.usesLauncher,
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
// ── Provider config sync (renderer → main, keys stay encrypted) ──
|
||||
ipcMain.handle("netcatty:ai:sync-providers", async (event, { providers }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false };
|
||||
if (Array.isArray(providers)) {
|
||||
providerConfigs = providers;
|
||||
rebuildProviderFetchHosts();
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// ── Web search config sync (renderer → main, for fetch allowlist + key decryption) ──
|
||||
ipcMain.handle("netcatty:ai:sync-web-search", async (event, { apiHost, apiKey }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false };
|
||||
webSearchApiHost = typeof apiHost === "string" ? apiHost : null;
|
||||
webSearchApiKeyEncrypted = typeof apiKey === "string" ? apiKey : null;
|
||||
rebuildProviderFetchHosts();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/**
|
||||
* Inject the decrypted web search API key into request headers.
|
||||
* Replaces __WEB_SEARCH_KEY__ placeholder, similar to __IPC_SECURED__ for providers.
|
||||
*/
|
||||
function injectWebSearchKeyIntoHeaders(headers) {
|
||||
if (!webSearchApiKeyEncrypted || !headers) return headers;
|
||||
const realKey = decryptApiKeyValue(webSearchApiKeyEncrypted);
|
||||
if (!realKey) return headers;
|
||||
const patched = {};
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
patched[k] = typeof v === "string" ? v.replace(WEB_SEARCH_KEY_PLACEHOLDER, realKey) : v;
|
||||
}
|
||||
return patched;
|
||||
}
|
||||
|
||||
// Temporarily add a host to the fetch allowlist (used by settings model listing).
|
||||
// Entries are auto-removed after 30 seconds unless they belong to a synced provider.
|
||||
const TEMP_ALLOWLIST_TTL = 30_000;
|
||||
// Track temporarily added entries so cleanup can distinguish them from synced ones
|
||||
const tempAllowedHosts = new Set();
|
||||
const tempAllowedPorts = new Set();
|
||||
// Track temporarily added HTTP hosts (for rebuild restoration)
|
||||
const tempHttpHosts = new Set();
|
||||
// Track active expiry timers per host to avoid duplicate/premature expiry
|
||||
const hostExpiryTimers = new Map();
|
||||
|
||||
/** Check if a host is owned by a currently synced provider config */
|
||||
function isHostInProviderConfigs(host) {
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try { if (new URL(config.baseURL).hostname === host) return true; } catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Check if a host is owned by a provider config that uses http:// */
|
||||
function isHttpHostInProviderConfigs(host) {
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try {
|
||||
const p = new URL(config.baseURL);
|
||||
if (p.hostname === host && p.protocol === "http:") return true;
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Check if a localhost port is owned by a currently synced provider config */
|
||||
function isPortInProviderConfigs(port) {
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try {
|
||||
const p = new URL(config.baseURL);
|
||||
if ((p.hostname === "localhost" || p.hostname === "127.0.0.1") &&
|
||||
Number(p.port || (p.protocol === "https:" ? 443 : 80)) === port) return true;
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ipcMain.handle("netcatty:ai:allowlist:add-host", async (event, { baseURL }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
if (typeof baseURL !== "string") return { ok: false, error: "baseURL must be a string" };
|
||||
try {
|
||||
const parsed = new URL(baseURL);
|
||||
const host = parsed.hostname;
|
||||
if (host === "localhost" || host === "127.0.0.1") {
|
||||
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
|
||||
if (!ALLOWED_LOCALHOST_PORTS.has(port)) {
|
||||
ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
tempAllowedPorts.add(port);
|
||||
setTimeout(() => {
|
||||
// Only remove if still temporary (not built-in and not synced by a provider)
|
||||
if (!BUILTIN_LOCALHOST_PORTS.includes(port) && !isPortInProviderConfigs(port)) {
|
||||
ALLOWED_LOCALHOST_PORTS.delete(port);
|
||||
}
|
||||
tempAllowedPorts.delete(port);
|
||||
}, TEMP_ALLOWLIST_TTL);
|
||||
}
|
||||
} else {
|
||||
const isNewHost = !providerFetchHosts.has(host);
|
||||
if (isNewHost) {
|
||||
providerFetchHosts.add(host);
|
||||
}
|
||||
// Always track in tempAllowedHosts so rebuild can restore to providerFetchHosts
|
||||
// even if the original persistent source (e.g. HTTPS provider) is removed mid-TTL
|
||||
tempAllowedHosts.add(host);
|
||||
if (parsed.protocol === "http:") {
|
||||
providerHttpHosts.add(host);
|
||||
if (!isHttpHostInProviderConfigs(host)) tempHttpHosts.add(host);
|
||||
}
|
||||
// Always (re-)schedule expiry timer to clean up temp entries
|
||||
const existing = hostExpiryTimers.get(host);
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => {
|
||||
hostExpiryTimers.delete(host);
|
||||
// Check if host is still needed by a provider config or web search
|
||||
const isWebSearchHost = webSearchApiHost && (() => {
|
||||
try { return new URL(webSearchApiHost).hostname === host; } catch { return false; }
|
||||
})();
|
||||
if (!isHostInProviderConfigs(host) && !isWebSearchHost) {
|
||||
providerFetchHosts.delete(host);
|
||||
providerHttpHosts.delete(host);
|
||||
} else if (!isHttpHostInProviderConfigs(host)) {
|
||||
providerHttpHosts.delete(host);
|
||||
}
|
||||
tempAllowedHosts.delete(host);
|
||||
tempHttpHosts.delete(host);
|
||||
}, TEMP_ALLOWLIST_TTL);
|
||||
hostExpiryTimers.set(host, timer);
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: "Invalid URL" };
|
||||
}
|
||||
});
|
||||
|
||||
// URL allowlist: only permit requests to known AI provider domains + HTTPS
|
||||
const BUILTIN_FETCH_HOSTS = new Set([
|
||||
"api.openai.com",
|
||||
"api.anthropic.com",
|
||||
"generativelanguage.googleapis.com",
|
||||
"openrouter.ai",
|
||||
// Web search providers
|
||||
"api.tavily.com",
|
||||
"api.exa.ai",
|
||||
"api.bochaai.com",
|
||||
"open.bigmodel.cn",
|
||||
]);
|
||||
// Dynamically populated from configured provider baseURLs
|
||||
const providerFetchHosts = new Set();
|
||||
// Subset of providerFetchHosts where the provider baseURL explicitly uses http://
|
||||
const providerHttpHosts = new Set();
|
||||
|
||||
/**
|
||||
* Rebuild the dynamic host allowlist from the current providerConfigs.
|
||||
* Called whenever providers are synced from the renderer.
|
||||
*/
|
||||
function rebuildProviderFetchHosts() {
|
||||
providerFetchHosts.clear();
|
||||
providerHttpHosts.clear();
|
||||
// Reset localhost ports to built-in defaults, then add provider-configured ones
|
||||
ALLOWED_LOCALHOST_PORTS.clear();
|
||||
for (const port of BUILTIN_LOCALHOST_PORTS) ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
// Re-add any still-active temporary entries so a sync doesn't wipe them
|
||||
for (const host of tempAllowedHosts) providerFetchHosts.add(host);
|
||||
for (const host of tempHttpHosts) providerHttpHosts.add(host);
|
||||
for (const port of tempAllowedPorts) ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try {
|
||||
const parsed = new URL(config.baseURL);
|
||||
const host = parsed.hostname;
|
||||
// Skip localhost — handled separately via port allowlist
|
||||
if (host === "localhost" || host === "127.0.0.1") {
|
||||
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
|
||||
ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
} else {
|
||||
providerFetchHosts.add(host);
|
||||
if (parsed.protocol === "http:") providerHttpHosts.add(host);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL in config — skip
|
||||
}
|
||||
}
|
||||
// Add web search apiHost if configured (e.g. SearXNG self-hosted instance)
|
||||
if (webSearchApiHost) {
|
||||
try {
|
||||
const parsed = new URL(webSearchApiHost);
|
||||
const host = parsed.hostname;
|
||||
if (host === "localhost" || host === "127.0.0.1") {
|
||||
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
|
||||
ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
} else {
|
||||
providerFetchHosts.add(host);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed localhost ports to prevent SSRF (Issue #9)
|
||||
const BUILTIN_LOCALHOST_PORTS = [
|
||||
11434, // Ollama default
|
||||
1234, // LM Studio default
|
||||
3000, // Common local dev
|
||||
3001, // Common local dev
|
||||
5000, // Common local dev
|
||||
5001, // Common local dev
|
||||
8000, // Common local dev
|
||||
8080, // Common local dev
|
||||
8888, // Common local dev
|
||||
];
|
||||
const ALLOWED_LOCALHOST_PORTS = new Set(BUILTIN_LOCALHOST_PORTS);
|
||||
// RFC1918 / link-local / loopback / IPv6 private ranges — used by SSRF guard
|
||||
function isPrivateIp(ip) {
|
||||
if (!ip) return false;
|
||||
// Strip IPv6 brackets that URL.hostname may include
|
||||
const cleaned = ip.replace(/^\[|\]$/g, "");
|
||||
if (cleaned === "::1" || cleaned === "0.0.0.0" || cleaned === "::") return true;
|
||||
// IPv6 private ranges: fc00::/7 (unique local), fe80::/10 (link-local), ::ffff:127.x (mapped loopback)
|
||||
const lower = cleaned.toLowerCase();
|
||||
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // fc00::/7
|
||||
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true; // fe80::/10
|
||||
if (lower.startsWith("::ffff:")) {
|
||||
// IPv4-mapped IPv6 — extract IPv4 portion and check
|
||||
const v4 = lower.slice(7);
|
||||
return isPrivateIp(v4);
|
||||
}
|
||||
// IPv4
|
||||
const parts = cleaned.split(".");
|
||||
if (parts.length === 4 && parts.every(p => /^\d+$/.test(p))) {
|
||||
const [a, b] = parts.map(Number);
|
||||
if (a === 10) return true; // 10.0.0.0/8
|
||||
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
|
||||
if (a === 192 && b === 168) return true; // 192.168.0.0/16
|
||||
if (a === 127) return true; // 127.0.0.0/8
|
||||
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
|
||||
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT (Tailscale etc.)
|
||||
if (a === 0) return true; // 0.0.0.0/8
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateHost(hostname) {
|
||||
if (hostname === "localhost") return true;
|
||||
// metadata endpoints (AWS, GCP, Azure)
|
||||
if (hostname === "metadata.google.internal") return true;
|
||||
return isPrivateIp(hostname);
|
||||
}
|
||||
|
||||
function isAllowedFetchUrl(urlString, skipHostCheck) {
|
||||
try {
|
||||
const parsed = new URL(urlString);
|
||||
// Always block private/internal hosts when skipHostCheck is set (SSRF protection)
|
||||
if (skipHostCheck) {
|
||||
if (isPrivateHost(parsed.hostname)) return false;
|
||||
// Require HTTPS for skipHostCheck requests
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
return true;
|
||||
}
|
||||
// Allow localhost/127.0.0.1 only on known ports (e.g. Ollama) — normal fetch path only
|
||||
if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") {
|
||||
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
|
||||
return ALLOWED_LOCALHOST_PORTS.has(port);
|
||||
}
|
||||
// Only allow http: and https: schemes for remote hosts
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
|
||||
// For HTTP, only allow providers explicitly configured with http:// or the web search apiHost
|
||||
if (parsed.protocol === "http:") {
|
||||
const isProviderHost = providerHttpHosts.has(parsed.hostname);
|
||||
let isWebSearchHost = false;
|
||||
if (webSearchApiHost) {
|
||||
try { isWebSearchHost = new URL(webSearchApiHost).hostname === parsed.hostname; } catch { }
|
||||
}
|
||||
if (!isProviderHost && !isWebSearchHost) return false;
|
||||
}
|
||||
// Check built-in + provider-configured host allowlist
|
||||
if (BUILTIN_FETCH_HOSTS.has(parsed.hostname)) return true;
|
||||
if (providerFetchHosts.has(parsed.hostname)) return true;
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Start a streaming chat request (proxied through main process)
|
||||
ipcMain.handle("netcatty:ai:chat:stream", async (event, {
|
||||
requestId,
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
providerId,
|
||||
idleTimeoutMs,
|
||||
}) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
try {
|
||||
// Inject real API key if providerId is given (replaces placeholder in headers/URL)
|
||||
const patched = injectApiKeyIntoRequest(url, headers, providerId);
|
||||
const resolvedUrl = patched.url;
|
||||
const resolvedHeaders = patched.headers;
|
||||
|
||||
// Validate URL: only allow HTTP(S) schemes
|
||||
try {
|
||||
const parsed = new URL(resolvedUrl);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return { ok: false, error: "Only HTTP(S) URLs are allowed" };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, error: "Invalid URL" };
|
||||
}
|
||||
|
||||
// Check URL against allowed hosts (same as netcatty:ai:fetch)
|
||||
if (!isAllowedFetchUrl(resolvedUrl)) {
|
||||
return { ok: false, error: "URL host is not in the allowed list" };
|
||||
}
|
||||
|
||||
const skipTLS = shouldSkipTLSVerify(providerId);
|
||||
const { statusCode, statusText } = await streamRequest(
|
||||
resolvedUrl,
|
||||
{ method: "POST", headers: resolvedHeaders, body, idleTimeoutMs },
|
||||
event,
|
||||
requestId,
|
||||
skipTLS,
|
||||
);
|
||||
return { ok: true, statusCode, statusText };
|
||||
} catch (err) {
|
||||
if (err?.name === "AbortError") {
|
||||
return { ok: false, aborted: true, error: "Aborted" };
|
||||
}
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel an active stream
|
||||
ipcMain.handle("netcatty:ai:chat:cancel", async (event, { requestId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const controller = activeStreams.get(requestId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
activeStreams.delete(requestId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Non-streaming request (for model listing, validation, etc.)
|
||||
ipcMain.handle("netcatty:ai:fetch", async (event, { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify }) => {
|
||||
// Validate IPC sender — settings window needs this for model listing
|
||||
if (!validateSenderOrSettings(event)) {
|
||||
return { ok: false, status: 0, data: "", error: "Unauthorized IPC sender" };
|
||||
}
|
||||
|
||||
// Inject real API key if providerId is given (replaces placeholder in headers/URL)
|
||||
const patched = injectApiKeyIntoRequest(url, headers, providerId);
|
||||
const resolvedUrl = patched.url;
|
||||
// Also inject web search API key if placeholder is present
|
||||
const resolvedHeaders = injectWebSearchKeyIntoHeaders(patched.headers);
|
||||
|
||||
// Validate URL: block non-HTTP(S) schemes and internal network access
|
||||
try {
|
||||
const parsed = new URL(resolvedUrl);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return { ok: false, status: 0, data: "", error: "Only HTTP(S) URLs are allowed" };
|
||||
}
|
||||
// Block file:// and other dangerous schemes (already covered above)
|
||||
} catch {
|
||||
return { ok: false, status: 0, data: "", error: "Invalid URL" };
|
||||
}
|
||||
|
||||
// Check URL against allowed hosts; skipHostCheck allows public HTTPS but still blocks private/internal
|
||||
if (!isAllowedFetchUrl(resolvedUrl, !!skipHostCheck)) {
|
||||
return { ok: false, status: 0, data: "", error: "URL host is not in the allowed list" };
|
||||
}
|
||||
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB safety limit
|
||||
const MAX_REDIRECTS = followRedirects ? 5 : 0;
|
||||
|
||||
async function doFetch(fetchUrl, redirectsLeft) {
|
||||
// ctx.require is bound from aiBridge.cjs, so this path is relative to that file.
|
||||
const { resolveOutboundHttpAgent } = require("./httpNetworkProxyAgent.cjs");
|
||||
const skipTLS = Boolean(skipTLSVerify || shouldSkipTLSVerify(providerId));
|
||||
let proxyAgent;
|
||||
try {
|
||||
proxyAgent = await resolveOutboundHttpAgent(fetchUrl, {
|
||||
session: electronModule?.session?.defaultSession,
|
||||
rejectUnauthorized: skipTLS ? false : undefined,
|
||||
});
|
||||
} catch {
|
||||
proxyAgent = undefined;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const parsedUrl = new URL(fetchUrl);
|
||||
const isHttps = parsedUrl.protocol === "https:";
|
||||
const lib = isHttps ? https : http;
|
||||
|
||||
const fetchOpts = {
|
||||
method: method || "GET",
|
||||
headers: withContentLength(resolvedHeaders || {}, body),
|
||||
timeout: 30000,
|
||||
};
|
||||
if (skipTLS && isHttps) fetchOpts.rejectUnauthorized = false;
|
||||
if (proxyAgent) fetchOpts.agent = proxyAgent;
|
||||
const req = lib.request(parsedUrl, fetchOpts,
|
||||
(res) => {
|
||||
// Handle redirects
|
||||
if (redirectsLeft > 0 && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
const location = new URL(res.headers.location, fetchUrl).href;
|
||||
res.resume(); // drain the response
|
||||
// Revalidate the redirect target hostname (blocks localhost/metadata etc.)
|
||||
if (!isAllowedFetchUrl(location, !!skipHostCheck)) {
|
||||
resolve({ ok: false, status: 0, data: "", error: "Redirect target is not allowed" });
|
||||
return;
|
||||
}
|
||||
resolve(doFetch(location, redirectsLeft - 1));
|
||||
return;
|
||||
}
|
||||
let data = "";
|
||||
let totalSize = 0;
|
||||
res.on("data", (chunk) => {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_RESPONSE_SIZE) {
|
||||
req.destroy();
|
||||
resolve({ ok: false, status: 0, data: "", error: "Response body exceeded maximum size (10MB)" });
|
||||
return;
|
||||
}
|
||||
data += chunk.toString();
|
||||
});
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
status: res.statusCode,
|
||||
data,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on("error", (err) => {
|
||||
resolve({ ok: false, status: 0, data: "", error: err.message });
|
||||
});
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
resolve({ ok: false, status: 0, data: "", error: "Request timeout" });
|
||||
});
|
||||
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
return doFetch(resolvedUrl, MAX_REDIRECTS);
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { registerProviderHandlers };
|
||||
74
electron/bridges/aiBridge/sdk/claudeConfig.cjs
Normal file
74
electron/bridges/aiBridge/sdk/claudeConfig.cjs
Normal file
@@ -0,0 +1,74 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Repair ~/.claude.json before the claude-agent-sdk subprocess reads it.
|
||||
* 1:1 port of craft options.ts ensureClaudeConfig(): a missing/empty/BOM-
|
||||
* prefixed/corrupted config (or a stale .backup / .corrupted.* sibling) makes
|
||||
* the Claude Code binary write plain-text recovery messages to stdout, which
|
||||
* the SDK transport rejects as "CLI output was not valid JSON".
|
||||
*/
|
||||
const { join } = require("node:path");
|
||||
const { homedir } = require("node:os");
|
||||
const { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } = require("node:fs");
|
||||
|
||||
const UTF8_BOM = "";
|
||||
let claudeConfigChecked = false;
|
||||
|
||||
function writeConfigSafe(configPath, content) {
|
||||
try {
|
||||
writeFileSync(configPath, content, "utf-8");
|
||||
} catch (err) {
|
||||
const code = err && err.code;
|
||||
if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM")) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 100) { /* brief busy wait, runs once at startup */ }
|
||||
try { writeFileSync(configPath, content, "utf-8"); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureClaudeConfig() {
|
||||
if (claudeConfigChecked) return;
|
||||
claudeConfigChecked = true;
|
||||
|
||||
const configPath = join(homedir(), ".claude.json");
|
||||
|
||||
const backupPath = `${configPath}.backup`;
|
||||
if (existsSync(backupPath)) {
|
||||
try { unlinkSync(backupPath); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
try {
|
||||
const homeDir = homedir();
|
||||
for (const file of readdirSync(homeDir)) {
|
||||
if (file.startsWith(".claude.json.corrupted.")) {
|
||||
try { unlinkSync(join(homeDir, file)); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
} catch { /* ignore — main repair below still runs */ }
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
writeConfigSafe(configPath, "{}");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(configPath, "utf-8");
|
||||
const content = raw.startsWith(UTF8_BOM) ? raw.slice(1) : raw;
|
||||
const hasBom = raw !== content;
|
||||
if (content.trim().length === 0) {
|
||||
writeConfigSafe(configPath, "{}");
|
||||
return;
|
||||
}
|
||||
JSON.parse(content);
|
||||
if (hasBom) writeConfigSafe(configPath, content);
|
||||
} catch {
|
||||
writeConfigSafe(configPath, "{}");
|
||||
}
|
||||
}
|
||||
|
||||
function resetClaudeConfigCheck() {
|
||||
claudeConfigChecked = false;
|
||||
}
|
||||
|
||||
module.exports = { ensureClaudeConfig, resetClaudeConfigCheck };
|
||||
389
electron/bridges/aiBridge/sdk/claudeDriver.cjs
Normal file
389
electron/bridges/aiBridge/sdk/claudeDriver.cjs
Normal file
@@ -0,0 +1,389 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Claude backend driver — wraps @anthropic-ai/claude-agent-sdk query().
|
||||
*
|
||||
* - Spawns the user's system `claude` binary via an ABSOLUTE pathToClaudeCodeExecutable
|
||||
* (SDK existsSync-checks it; PATH is not resolved — issue #205).
|
||||
* - Repairs ~/.claude.json before spawn (ensureClaudeConfig).
|
||||
* - Bypasses the SDK's built-in permission system and BLOCKS built-in
|
||||
* side-effect tools so the agent can only act through the injected netcatty
|
||||
* MCP server (approval/scope/blocklist enforced there).
|
||||
* - Translates SDK messages into the canonical renderer event protocol.
|
||||
*/
|
||||
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
|
||||
const { ensureClaudeConfig } = require("./claudeConfig.cjs");
|
||||
|
||||
// Built-in tools that need interactive UI netcatty doesn't provide - they would
|
||||
// hang the turn waiting for a response, so they are blocked in BOTH modes.
|
||||
const UI_DISALLOWED_TOOLS = ["EnterPlanMode", "ExitPlanMode", "AskUserQuestion"];
|
||||
|
||||
// Whitelist Claude built-ins instead of trying to track every local-capable
|
||||
// built-in tool the CLI may add over time. MCP tools remain available through
|
||||
// mcpServers; this only controls Claude Code's own local-machine tools.
|
||||
const MCP_MODE_TOOLS = [];
|
||||
const SKILLS_MODE_TOOLS = ["Bash", "Skill"];
|
||||
const CLAUDE_IMAGE_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
||||
|
||||
function isClaudeImageAttachment(attachment) {
|
||||
return Boolean(
|
||||
attachment &&
|
||||
CLAUDE_IMAGE_MEDIA_TYPES.has(String(attachment.mediaType || "").toLowerCase()) &&
|
||||
attachment.base64Data,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve built-in tools for the active tool-integration mode.
|
||||
* - "skills": only Bash + Skill so the Netcatty CLI skill can run.
|
||||
* - "mcp" (default): no Claude built-in local tools, forcing remote actions
|
||||
* through netcatty MCP.
|
||||
*/
|
||||
function claudeBuiltinTools(toolIntegrationMode) {
|
||||
return toolIntegrationMode === "skills"
|
||||
? [...SKILLS_MODE_TOOLS]
|
||||
: [...MCP_MODE_TOOLS];
|
||||
}
|
||||
|
||||
/** Convert neutral injectMcp configs into the SDK's keyed mcpServers map. */
|
||||
function toSdkMcpServers(injectedMcpServers) {
|
||||
const map = {};
|
||||
for (const cfg of injectedMcpServers || []) {
|
||||
if (!cfg || !cfg.name) continue;
|
||||
map[cfg.name] = {
|
||||
type: "stdio",
|
||||
command: cfg.command,
|
||||
args: cfg.args || [],
|
||||
env: mcpEnvPairsToObject(cfg.env),
|
||||
};
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the user-supplied claude `settings` value: a settings.json path
|
||||
* (string) or inline JSON ("{...}" -> object). Returns undefined when empty.
|
||||
* This is INDEPENDENT of CLAUDE_CONFIG_DIR (which supplies credentials + the
|
||||
* base settings layer) — `settings` is an additional override the SDK merges on
|
||||
* top, so the two coexist.
|
||||
*/
|
||||
function parseClaudeSettings(settings) {
|
||||
if (settings == null) return undefined;
|
||||
if (typeof settings === "object") return settings;
|
||||
const str = String(settings).trim();
|
||||
if (!str) return undefined;
|
||||
if (str.startsWith("{")) {
|
||||
try { return JSON.parse(str); } catch { return str; }
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
const CLAUDE_REASONING_LEVELS = new Set(["low", "medium", "high", "max"]);
|
||||
|
||||
function splitClaudeModelSelection(model) {
|
||||
if (typeof model !== "string" || !model) {
|
||||
return { model: undefined, effort: undefined };
|
||||
}
|
||||
const slash = model.lastIndexOf("/");
|
||||
if (slash <= 0) return { model, effort: undefined };
|
||||
const effort = model.slice(slash + 1);
|
||||
if (!CLAUDE_REASONING_LEVELS.has(effort)) return { model, effort: undefined };
|
||||
return { model: model.slice(0, slash), effort };
|
||||
}
|
||||
|
||||
function mergeClaudeEffortSettings(settings, effort) {
|
||||
if (!effort) return settings;
|
||||
if (settings == null) return { effort };
|
||||
if (typeof settings === "object") return { ...settings, effort };
|
||||
return settings;
|
||||
}
|
||||
|
||||
function buildClaudeQueryOptions({
|
||||
cwd, model, env, pathToClaudeCodeExecutable, abortController, injectedMcpServers, settings, resume,
|
||||
toolIntegrationMode,
|
||||
}) {
|
||||
const { model: resolvedModel, effort } = splitClaudeModelSelection(model);
|
||||
const options = {
|
||||
cwd,
|
||||
includePartialMessages: true,
|
||||
permissionMode: "bypassPermissions",
|
||||
// Required companion to permissionMode:'bypassPermissions' (the SDK rejects
|
||||
// the bypass without it). Netcatty blocks Claude's direct local read/write
|
||||
// tools and routes remote-session actions through MCP or Skills+CLI, where
|
||||
// Netcatty enforces approval/scope.
|
||||
allowDangerouslySkipPermissions: true,
|
||||
tools: claudeBuiltinTools(toolIntegrationMode),
|
||||
disallowedTools: [...UI_DISALLOWED_TOOLS],
|
||||
mcpServers: toSdkMcpServers(injectedMcpServers),
|
||||
env,
|
||||
abortController,
|
||||
};
|
||||
if (resolvedModel) options.model = resolvedModel;
|
||||
if (effort) options.effort = effort;
|
||||
// Resume the prior session so context carries ACROSS turns. Without this the
|
||||
// SDK starts a fresh session every turn (full amnesia). The session id is
|
||||
// emitted on system-init (before any turn work), so a mid-turn Stop can't lose
|
||||
// it and the next turn resumes correctly. undefined => fresh session.
|
||||
if (resume) options.resume = resume;
|
||||
// ABSOLUTE path only (SDK does not resolve PATH). undefined => SDK auto-discovery.
|
||||
if (pathToClaudeCodeExecutable) {
|
||||
options.pathToClaudeCodeExecutable = pathToClaudeCodeExecutable;
|
||||
}
|
||||
// Optional settings.json path / inline object — additive to CLAUDE_CONFIG_DIR.
|
||||
const parsedSettings = mergeClaudeEffortSettings(parseClaudeSettings(settings), effort);
|
||||
if (parsedSettings !== undefined) options.settings = parsedSettings;
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one SDK message into emitter calls.
|
||||
* NOTE: with includePartialMessages, streamed text arrives via stream_event;
|
||||
* the consolidated assistant TEXT block is skipped to avoid duplication, but
|
||||
* assistant TOOL_USE blocks are the authoritative source for tool calls.
|
||||
*/
|
||||
function translateClaudeMessage(message, emitter) {
|
||||
if (!message || typeof message !== "object") return;
|
||||
const type = message.type;
|
||||
|
||||
if (type === "system" && message.subtype === "init" && message.session_id) {
|
||||
emitter.sessionId(message.session_id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "stream_event" && message.event) {
|
||||
const ev = message.event;
|
||||
if (ev.type === "content_block_delta" && ev.delta) {
|
||||
if (ev.delta.type === "text_delta" && ev.delta.text) {
|
||||
emitter.text(ev.delta.text);
|
||||
} else if (ev.delta.type === "thinking_delta" && ev.delta.thinking) {
|
||||
emitter.reasoning(ev.delta.thinking);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "assistant" && message.message && Array.isArray(message.message.content)) {
|
||||
for (const block of message.message.content) {
|
||||
if (block?.type === "tool_use") {
|
||||
emitter.toolCall(block.name, block.input || {}, block.id);
|
||||
}
|
||||
// text blocks intentionally skipped (already streamed via stream_event)
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "user" && message.message && Array.isArray(message.message.content)) {
|
||||
for (const block of message.message.content) {
|
||||
if (block?.type === "tool_result") {
|
||||
const out = typeof block.content === "string"
|
||||
? block.content
|
||||
: JSON.stringify(block.content);
|
||||
emitter.toolResult(block.tool_use_id, out, undefined);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 'result' carries final usage/cost — handled by the run loop, no per-event emit.
|
||||
}
|
||||
|
||||
/** Classify a spawn failure. SDK wraps spawn ENOENT as a message string. */
|
||||
function classifyClaudeSpawnError(error) {
|
||||
const code = error && error.code;
|
||||
const msg = String((error && error.message) || error || "");
|
||||
const isSpawnEnoent =
|
||||
code === "ENOENT" ||
|
||||
/native binary not found/i.test(msg) ||
|
||||
/ENOENT/i.test(msg);
|
||||
return { isSpawnEnoent, message: msg };
|
||||
}
|
||||
|
||||
function buildClaudePromptInput(prompt, attachments) {
|
||||
const imageAttachments = Array.isArray(attachments)
|
||||
? attachments.filter(isClaudeImageAttachment)
|
||||
: [];
|
||||
if (imageAttachments.length === 0) return String(prompt || "");
|
||||
|
||||
const content = [{ type: "text", text: String(prompt || "") }];
|
||||
for (const attachment of imageAttachments) {
|
||||
content.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: String(attachment.mediaType).toLowerCase(),
|
||||
data: attachment.base64Data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (async function* claudePromptInput() {
|
||||
yield {
|
||||
type: "user",
|
||||
message: { role: "user", content },
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
}());
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a Claude turn. Streams events via `emitter`, resolves with { sessionId }.
|
||||
* @param {object} args
|
||||
* @param {string} args.prompt
|
||||
* @param {Array<object>} [args.attachments]
|
||||
* @param {object} args.options result of buildClaudeQueryOptions
|
||||
* @param {object} args.emitter createStreamEmitter(...)
|
||||
* @param {Function} [args.queryFn] inject @anthropic-ai/claude-agent-sdk query (for tests)
|
||||
*/
|
||||
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn }) {
|
||||
ensureClaudeConfig();
|
||||
let query = queryFn;
|
||||
if (!query) {
|
||||
let sdk;
|
||||
try { sdk = await import("@anthropic-ai/claude-agent-sdk"); } catch { emitter.emitError("Claude Agent SDK not installed. Run: npm install @anthropic-ai/claude-agent-sdk"); return { sessionId: null }; }
|
||||
query = sdk.query;
|
||||
}
|
||||
const promptInput = buildClaudePromptInput(prompt, attachments);
|
||||
|
||||
let sessionId = null;
|
||||
let hasContent = false;
|
||||
try {
|
||||
const stream = query({ prompt: promptInput, options });
|
||||
for await (const message of stream) {
|
||||
if (options.abortController?.signal?.aborted) break;
|
||||
if (message?.session_id && message.session_id !== sessionId) {
|
||||
sessionId = message.session_id;
|
||||
}
|
||||
if (
|
||||
message?.type === "stream_event" ||
|
||||
(message?.type === "assistant" && Array.isArray(message?.message?.content) && message.message.content.length > 0)
|
||||
) {
|
||||
hasContent = true;
|
||||
}
|
||||
translateClaudeMessage(message, emitter);
|
||||
}
|
||||
if (!hasContent && !options.abortController?.signal?.aborted) {
|
||||
emitter.emitError(
|
||||
"Claude returned an empty response. Run `claude` in a terminal to log in, " +
|
||||
"or set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN.",
|
||||
);
|
||||
return { sessionId };
|
||||
}
|
||||
emitter.emitDone();
|
||||
return { sessionId };
|
||||
} catch (error) {
|
||||
const classified = classifyClaudeSpawnError(error);
|
||||
if (classified.isSpawnEnoent) {
|
||||
emitter.emitError(
|
||||
`Claude Code binary not found or not runnable (${options.pathToClaudeCodeExecutable || "auto-discovery"}). ` +
|
||||
"Install with `npm i -g @anthropic-ai/claude-code` and ensure it's on PATH.",
|
||||
);
|
||||
} else {
|
||||
emitter.emitError(classified.message || "Claude turn failed");
|
||||
}
|
||||
return { sessionId };
|
||||
}
|
||||
}
|
||||
|
||||
/** Map claude-agent-sdk ModelInfo[] -> renderer preset shape {id,name,description}. */
|
||||
function mapClaudeModels(models) {
|
||||
if (!Array.isArray(models)) return [];
|
||||
return models
|
||||
.filter((m) => m && m.value)
|
||||
.map((m) => ({
|
||||
id: m.value,
|
||||
name: m.displayName || m.value,
|
||||
description: m.description,
|
||||
thinkingLevels: ["low", "medium", "high", "max"],
|
||||
defaultThinkingLevel: "medium",
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available Claude models via the SDK control channel. Opens a streaming
|
||||
* (idle) session so no turn is billed, asks supportedModels(), then tears down.
|
||||
* Returns [] on failure (the caller falls back to the UI's curated presets).
|
||||
* @param {object} args
|
||||
* @param {string} [args.pathToClaudeCodeExecutable]
|
||||
* @param {object} [args.env]
|
||||
* @param {Function} [args.queryFn] inject query() for tests
|
||||
*/
|
||||
async function listClaudeModels({
|
||||
pathToClaudeCodeExecutable,
|
||||
env,
|
||||
queryFn,
|
||||
abortController,
|
||||
signal,
|
||||
}) {
|
||||
ensureClaudeConfig();
|
||||
const externalSignal = signal || abortController?.signal;
|
||||
if (externalSignal?.aborted) return [];
|
||||
let query = queryFn;
|
||||
if (!query) {
|
||||
let sdk;
|
||||
try { sdk = await import("@anthropic-ai/claude-agent-sdk"); } catch { return []; }
|
||||
query = sdk.query;
|
||||
}
|
||||
const queryAbortController = new AbortController();
|
||||
const forwardAbort = () => {
|
||||
try { queryAbortController.abort(externalSignal?.reason); } catch {}
|
||||
};
|
||||
if (externalSignal) {
|
||||
externalSignal.addEventListener("abort", forwardAbort, { once: true });
|
||||
if (externalSignal.aborted) forwardAbort();
|
||||
}
|
||||
// Idle streaming input: keeps the session open (init handshake completes)
|
||||
// without sending a turn, so supportedModels() resolves; then we abort.
|
||||
async function* idleInput() {
|
||||
await new Promise((resolve) => {
|
||||
if (queryAbortController.signal.aborted) return resolve();
|
||||
queryAbortController.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
let q;
|
||||
try {
|
||||
q = query({
|
||||
prompt: idleInput(),
|
||||
options: {
|
||||
pathToClaudeCodeExecutable,
|
||||
env,
|
||||
abortController: queryAbortController,
|
||||
includePartialMessages: false,
|
||||
},
|
||||
});
|
||||
const result = await Promise.race([
|
||||
Promise.resolve(q.supportedModels()).then((models) => ({ type: "models", models })),
|
||||
new Promise((resolve) => {
|
||||
if (queryAbortController.signal.aborted) return resolve({ type: "aborted" });
|
||||
queryAbortController.signal.addEventListener(
|
||||
"abort",
|
||||
() => resolve({ type: "aborted" }),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
]);
|
||||
return result.type === "models" ? mapClaudeModels(result.models) : [];
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
if (externalSignal) externalSignal.removeEventListener("abort", forwardAbort);
|
||||
queryAbortController.abort();
|
||||
try { void Promise.resolve(q?.return?.(undefined)).catch(() => {}); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildClaudeQueryOptions,
|
||||
parseClaudeSettings,
|
||||
splitClaudeModelSelection,
|
||||
mergeClaudeEffortSettings,
|
||||
translateClaudeMessage,
|
||||
classifyClaudeSpawnError,
|
||||
buildClaudePromptInput,
|
||||
runClaudeTurn,
|
||||
listClaudeModels,
|
||||
mapClaudeModels,
|
||||
claudeBuiltinTools,
|
||||
UI_DISALLOWED_TOOLS,
|
||||
MCP_MODE_TOOLS,
|
||||
SKILLS_MODE_TOOLS,
|
||||
toSdkMcpServers,
|
||||
};
|
||||
256
electron/bridges/aiBridge/sdk/claudeDriver.test.cjs
Normal file
256
electron/bridges/aiBridge/sdk/claudeDriver.test.cjs
Normal file
@@ -0,0 +1,256 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { translateClaudeMessage, buildClaudeQueryOptions, buildClaudePromptInput, classifyClaudeSpawnError, listClaudeModels, mapClaudeModels, parseClaudeSettings, splitClaudeModelSelection } = require("./claudeDriver.cjs");
|
||||
|
||||
function collector() {
|
||||
const events = [];
|
||||
const emitter = {
|
||||
text: (t) => events.push({ k: "text", t }),
|
||||
reasoning: (d) => events.push({ k: "reasoning", d }),
|
||||
reasoningEnd: () => events.push({ k: "reasoningEnd" }),
|
||||
toolCall: (name, args, id) => events.push({ k: "toolCall", name, args, id }),
|
||||
toolResult: (id, out, name) => events.push({ k: "toolResult", id, out, name }),
|
||||
status: (m) => events.push({ k: "status", m }),
|
||||
sessionId: (s) => events.push({ k: "sessionId", s }),
|
||||
};
|
||||
return { events, emitter };
|
||||
}
|
||||
|
||||
test("init system message -> sessionId event", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateClaudeMessage({ type: "system", subtype: "init", session_id: "sess-1" }, emitter);
|
||||
assert.deepEqual(events, [{ k: "sessionId", s: "sess-1" }]);
|
||||
});
|
||||
|
||||
test("stream_event text_delta -> text event", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateClaudeMessage(
|
||||
{ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "hello" } } },
|
||||
emitter,
|
||||
);
|
||||
assert.deepEqual(events, [{ k: "text", t: "hello" }]);
|
||||
});
|
||||
|
||||
test("assistant tool_use block -> toolCall event", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateClaudeMessage(
|
||||
{
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "tool_use", id: "tu-1", name: "mcp__netcatty-remote-hosts__terminal_execute", input: { command: "ls" } }] },
|
||||
},
|
||||
emitter,
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "toolCall", name: "mcp__netcatty-remote-hosts__terminal_execute", args: { command: "ls" }, id: "tu-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("assistant text block (non-partial) is NOT double-emitted when partials enabled", () => {
|
||||
// With includePartialMessages, text arrives via stream_event; the assistant
|
||||
// message text block is the consolidated copy and must be skipped to avoid dupes.
|
||||
const { events, emitter } = collector();
|
||||
translateClaudeMessage(
|
||||
{ type: "assistant", message: { content: [{ type: "text", text: "consolidated" }] } },
|
||||
emitter,
|
||||
);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test("user tool_result block -> toolResult event", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateClaudeMessage(
|
||||
{ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "tu-1", content: "output text" }] } },
|
||||
emitter,
|
||||
);
|
||||
assert.deepEqual(events, [{ k: "toolResult", id: "tu-1", out: "output text", name: undefined }]);
|
||||
});
|
||||
|
||||
test("buildClaudeQueryOptions sets bypassPermissions, built-in tools, mcp stdio, abort", () => {
|
||||
const ac = new AbortController();
|
||||
const opts = buildClaudeQueryOptions({
|
||||
cwd: "/tmp",
|
||||
model: "claude-opus-4-6",
|
||||
env: { PATH: "/usr/bin" },
|
||||
pathToClaudeCodeExecutable: "/abs/claude",
|
||||
abortController: ac,
|
||||
injectedMcpServers: [{
|
||||
name: "netcatty-remote-hosts", type: "stdio",
|
||||
command: "/abs/electron", args: ["/abs/server.cjs"],
|
||||
env: [{ name: "NETCATTY_MCP_PORT", value: "1" }],
|
||||
}],
|
||||
});
|
||||
assert.equal(opts.permissionMode, "bypassPermissions");
|
||||
// required companion to bypassPermissions (SDK rejects the bypass without it)
|
||||
assert.equal(opts.allowDangerouslySkipPermissions, true);
|
||||
assert.equal(opts.includePartialMessages, true);
|
||||
assert.equal(opts.pathToClaudeCodeExecutable, "/abs/claude");
|
||||
assert.equal(opts.abortController, ac);
|
||||
// MCP mode disables Claude Code built-ins entirely; injected MCP tools remain wired below.
|
||||
assert.deepEqual(opts.tools, []);
|
||||
for (const t of ["EnterPlanMode", "ExitPlanMode", "AskUserQuestion"]) {
|
||||
assert.ok(opts.disallowedTools.includes(t), `expected ${t} disallowed`);
|
||||
}
|
||||
// netcatty MCP wired as keyed stdio with env object (not pair array)
|
||||
assert.equal(opts.mcpServers["netcatty-remote-hosts"].type, "stdio");
|
||||
assert.deepEqual(opts.mcpServers["netcatty-remote-hosts"].env, { NETCATTY_MCP_PORT: "1" });
|
||||
});
|
||||
|
||||
test("built-in tools are mode-aware: Skills+CLI allows only Bash/Skill, MCP blocks all built-ins", () => {
|
||||
const skills = buildClaudeQueryOptions({ env: {}, toolIntegrationMode: "skills" });
|
||||
// Bash + Skill are the only Claude Code built-ins exposed so the agent can
|
||||
// drive the netcatty CLI skill without direct file/search/web/local tools.
|
||||
assert.deepEqual(skills.tools, ["Bash", "Skill"]);
|
||||
for (const t of ["Read", "Edit", "Write", "MultiEdit", "Glob", "Grep", "WebFetch", "WebSearch", "Task", "Agent", "REPL", "Workflow"]) {
|
||||
assert.ok(!skills.tools.includes(t), `expected ${t} absent from skills mode tool whitelist`);
|
||||
}
|
||||
// UI-coupled tools still blocked in BOTH modes as defense-in-depth.
|
||||
for (const t of ["EnterPlanMode", "ExitPlanMode", "AskUserQuestion"]) {
|
||||
assert.ok(skills.disallowedTools.includes(t), `expected ${t} blocked in skills mode`);
|
||||
}
|
||||
// MCP mode (and the undefined default) disables all Claude Code built-ins.
|
||||
assert.deepEqual(buildClaudeQueryOptions({ env: {}, toolIntegrationMode: "mcp" }).tools, []);
|
||||
assert.deepEqual(buildClaudeQueryOptions({ env: {} }).tools, []);
|
||||
});
|
||||
|
||||
test("classifyClaudeSpawnError detects ENOENT 'native binary not found'", () => {
|
||||
const r = classifyClaudeSpawnError(new Error("Claude Code native binary not found at /abs/claude"));
|
||||
assert.equal(r.isSpawnEnoent, true);
|
||||
});
|
||||
|
||||
test("classifyClaudeSpawnError detects code:ENOENT", () => {
|
||||
const e = new Error("spawn failed"); e.code = "ENOENT"; e.syscall = "spawn";
|
||||
assert.equal(classifyClaudeSpawnError(e).isSpawnEnoent, true);
|
||||
});
|
||||
|
||||
test("mapClaudeModels maps {value,displayName,description} -> {id,name,description} and drops value-less", () => {
|
||||
const out = mapClaudeModels([
|
||||
{ value: "claude-opus-4-6", displayName: "Opus 4.6", description: "Recommended" },
|
||||
{ value: "claude-sonnet-4-6", displayName: "Sonnet 4.6" },
|
||||
{ displayName: "no value -> dropped" },
|
||||
]);
|
||||
assert.deepEqual(out, [
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Opus 4.6",
|
||||
description: "Recommended",
|
||||
thinkingLevels: ["low", "medium", "high", "max"],
|
||||
defaultThinkingLevel: "medium",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Sonnet 4.6",
|
||||
description: undefined,
|
||||
thinkingLevels: ["low", "medium", "high", "max"],
|
||||
defaultThinkingLevel: "medium",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(mapClaudeModels(null), []);
|
||||
});
|
||||
|
||||
test("splitClaudeModelSelection only treats known trailing effort as thinking", () => {
|
||||
assert.deepEqual(splitClaudeModelSelection("sonnet/high"), { model: "sonnet", effort: "high" });
|
||||
assert.deepEqual(splitClaudeModelSelection("claude-opus-4-6"), {
|
||||
model: "claude-opus-4-6",
|
||||
effort: undefined,
|
||||
});
|
||||
assert.deepEqual(splitClaudeModelSelection("org/custom-model"), {
|
||||
model: "org/custom-model",
|
||||
effort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("buildClaudeQueryOptions splits model/effort into model + settings.effort", () => {
|
||||
const opts = buildClaudeQueryOptions({
|
||||
cwd: "/tmp",
|
||||
model: "sonnet/high",
|
||||
env: {},
|
||||
settings: { model: "sonnet" },
|
||||
});
|
||||
assert.equal(opts.model, "sonnet");
|
||||
assert.equal(opts.effort, "high");
|
||||
assert.deepEqual(opts.settings, { model: "sonnet", effort: "high" });
|
||||
});
|
||||
|
||||
test("parseClaudeSettings: path string, inline JSON object, empty, and bad JSON", () => {
|
||||
assert.equal(parseClaudeSettings("/path/to/settings.json"), "/path/to/settings.json");
|
||||
assert.deepEqual(parseClaudeSettings('{"model":"sonnet"}'), { model: "sonnet" });
|
||||
assert.deepEqual(parseClaudeSettings({ model: "opus" }), { model: "opus" });
|
||||
assert.equal(parseClaudeSettings(""), undefined);
|
||||
assert.equal(parseClaudeSettings(null), undefined);
|
||||
assert.equal(parseClaudeSettings("{bad json"), "{bad json"); // invalid JSON -> treated as a path
|
||||
});
|
||||
|
||||
test("buildClaudeQueryOptions wires settings (additive to CLAUDE_CONFIG_DIR) and omits when absent", () => {
|
||||
const withS = buildClaudeQueryOptions({ env: {}, settings: "/abs/settings.json" });
|
||||
assert.equal(withS.settings, "/abs/settings.json");
|
||||
const without = buildClaudeQueryOptions({ env: {} });
|
||||
assert.equal("settings" in without, false);
|
||||
});
|
||||
|
||||
test("buildClaudeQueryOptions wires resume so context carries across turns; omits when absent", () => {
|
||||
// Without options.resume the SDK starts a fresh session every turn (amnesia).
|
||||
assert.equal(buildClaudeQueryOptions({ env: {}, resume: "sess-1" }).resume, "sess-1");
|
||||
assert.equal("resume" in buildClaudeQueryOptions({ env: {} }), false);
|
||||
});
|
||||
|
||||
test("buildClaudePromptInput sends supported images as native image blocks", async () => {
|
||||
const input = buildClaudePromptInput("describe this", [
|
||||
{ filename: "shot.png", mediaType: "image/png", filePath: "/tmp/shot.png", base64Data: "abc" },
|
||||
{ filename: "bad.svg", mediaType: "image/svg+xml", filePath: "/tmp/bad.svg", base64Data: "def" },
|
||||
]);
|
||||
const messages = [];
|
||||
for await (const message of input) messages.push(message);
|
||||
assert.deepEqual(messages, [{
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "describe this" },
|
||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } },
|
||||
],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
}]);
|
||||
});
|
||||
|
||||
test("buildClaudePromptInput keeps plain text when there are no supported images", () => {
|
||||
assert.equal(
|
||||
buildClaudePromptInput("hello", [{ filename: "note.txt", mediaType: "text/plain", base64Data: "abc" }]),
|
||||
"hello",
|
||||
);
|
||||
});
|
||||
|
||||
test("listClaudeModels aborts a hung SDK query and returns it for cleanup", async () => {
|
||||
const abortController = new AbortController();
|
||||
let queryAbortSignal;
|
||||
let returnCount = 0;
|
||||
let releaseModels;
|
||||
const pendingModels = new Promise((resolve) => { releaseModels = resolve; });
|
||||
const queryFn = ({ options }) => {
|
||||
queryAbortSignal = options.abortController.signal;
|
||||
return {
|
||||
supportedModels: () => pendingModels,
|
||||
async return() {
|
||||
returnCount += 1;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const modelsPromise = listClaudeModels({
|
||||
pathToClaudeCodeExecutable: "/bin/claude",
|
||||
env: {},
|
||||
queryFn,
|
||||
abortController,
|
||||
});
|
||||
abortController.abort();
|
||||
|
||||
const outcome = await Promise.race([
|
||||
modelsPromise.then(() => "settled"),
|
||||
new Promise((resolve) => setTimeout(() => resolve("hung"), 20)),
|
||||
]);
|
||||
if (outcome === "hung") releaseModels([]);
|
||||
assert.equal(outcome, "settled");
|
||||
assert.deepEqual(await modelsPromise, []);
|
||||
assert.equal(queryAbortSignal.aborted, true);
|
||||
assert.equal(returnCount, 1);
|
||||
});
|
||||
1058
electron/bridges/aiBridge/sdk/codebuddyDriver.cjs
Normal file
1058
electron/bridges/aiBridge/sdk/codebuddyDriver.cjs
Normal file
File diff suppressed because it is too large
Load Diff
881
electron/bridges/aiBridge/sdk/codebuddyDriver.test.cjs
Normal file
881
electron/bridges/aiBridge/sdk/codebuddyDriver.test.cjs
Normal file
@@ -0,0 +1,881 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { getEventListeners } = require("node:events");
|
||||
const {
|
||||
buildCodebuddyQueryOptions,
|
||||
buildCodebuddyCanUseTool,
|
||||
buildCodebuddyPromptInput,
|
||||
codebuddyBuiltinTools,
|
||||
mapCodebuddyModels,
|
||||
runCodebuddyTurn,
|
||||
translateCodebuddyMessage,
|
||||
buildCodebuddyHooks,
|
||||
buildCodebuddyElicitation,
|
||||
toSdkMcpServers,
|
||||
} = require("./codebuddyDriver.cjs");
|
||||
|
||||
function collector() {
|
||||
const events = [];
|
||||
const emitter = {
|
||||
text: (t) => events.push({ k: "text", t }),
|
||||
reasoning: (d) => events.push({ k: "reasoning", d }),
|
||||
toolCall: (name, args, id) => events.push({ k: "toolCall", name, args, id }),
|
||||
toolResult: (id, out, name) => events.push({ k: "toolResult", id, out, name }),
|
||||
usage: (usage) => events.push({ k: "usage", usage }),
|
||||
status: (m) => events.push({ k: "status", m }),
|
||||
sessionId: (s) => events.push({ k: "sessionId", s }),
|
||||
emitDone: () => events.push({ k: "done" }),
|
||||
emitError: (m) => events.push({ k: "error", m }),
|
||||
};
|
||||
return { events, emitter };
|
||||
}
|
||||
|
||||
test("buildCodebuddyQueryOptions wires SDK options in isolated mode", () => {
|
||||
const ac = new AbortController();
|
||||
const opts = buildCodebuddyQueryOptions({
|
||||
cwd: "/tmp",
|
||||
model: "codebuddy-1",
|
||||
env: { PATH: "/usr/bin", CODEBUDDY_INTERNET_ENVIRONMENT: "ioa" },
|
||||
pathToCodebuddyCode: "/opt/codebuddy/bin/codebuddy",
|
||||
abortController: ac,
|
||||
resume: "sess-1",
|
||||
injectedMcpServers: [{
|
||||
name: "netcatty-remote-hosts",
|
||||
command: "/abs/electron",
|
||||
args: ["/abs/server.cjs"],
|
||||
env: [{ name: "NETCATTY_MCP_PORT", value: "1" }],
|
||||
}],
|
||||
});
|
||||
|
||||
assert.equal(opts.cwd, "/tmp");
|
||||
assert.equal(opts.model, "codebuddy-1");
|
||||
assert.equal(opts.includePartialMessages, true);
|
||||
assert.equal(opts.permissionMode, "bypassPermissions");
|
||||
assert.equal(opts.allowDangerouslySkipPermissions, true);
|
||||
assert.deepEqual(opts.extraArgs, { "dangerously-skip-permissions": null });
|
||||
assert.deepEqual(opts.settingSources, []);
|
||||
assert.equal(opts.env.CODEBUDDY_INTERNET_ENVIRONMENT, "ioa");
|
||||
assert.equal(opts.pathToCodebuddyCode, "/opt/codebuddy/bin/codebuddy");
|
||||
assert.equal(opts.abortController, ac);
|
||||
assert.equal(opts.resume, "sess-1");
|
||||
assert.deepEqual(opts.tools, []);
|
||||
// allowedTools must stay unset in mcp mode: tools:[] disables built-ins, while
|
||||
// allowedTools:[] would prevent injected Netcatty MCP tools from running.
|
||||
assert.ok(!("allowedTools" in opts));
|
||||
assert.ok(opts.disallowedTools.includes("AskUserQuestion"));
|
||||
assert.equal(opts.mcpServers["netcatty-remote-hosts"].type, "stdio");
|
||||
assert.deepEqual(opts.mcpServers["netcatty-remote-hosts"].env, { NETCATTY_MCP_PORT: "1" });
|
||||
});
|
||||
|
||||
test("built-in tools are mode-aware", () => {
|
||||
assert.deepEqual(codebuddyBuiltinTools("mcp"), []);
|
||||
assert.deepEqual(codebuddyBuiltinTools(undefined), []);
|
||||
assert.deepEqual(codebuddyBuiltinTools("skills"), ["Bash"]);
|
||||
});
|
||||
|
||||
test("translateCodebuddyMessage emits assistant text fallback", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateCodebuddyMessage(
|
||||
{ type: "assistant", message: { content: [{ type: "text", text: "hello" }] } },
|
||||
emitter,
|
||||
);
|
||||
assert.deepEqual(events, [{ k: "text", t: "hello" }]);
|
||||
});
|
||||
|
||||
test("translateCodebuddyMessage can skip consolidated assistant text after stream deltas", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateCodebuddyMessage(
|
||||
{ type: "assistant", message: { content: [{ type: "text", text: "consolidated" }] } },
|
||||
emitter,
|
||||
{ skipAssistantText: true },
|
||||
);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test("translateCodebuddyMessage preserves consolidated reasoning without deltas", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateCodebuddyMessage(
|
||||
{
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "thinking", thinking: "check the fallback" }] },
|
||||
},
|
||||
emitter,
|
||||
);
|
||||
assert.deepEqual(events, [{ k: "reasoning", d: "check the fallback" }]);
|
||||
});
|
||||
|
||||
test("translateCodebuddyMessage maps stream deltas, tool calls, and tool results", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateCodebuddyMessage(
|
||||
{ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "hi" } } },
|
||||
emitter,
|
||||
);
|
||||
translateCodebuddyMessage(
|
||||
{ type: "stream_event", event: { type: "content_block_delta", delta: { type: "thinking_delta", thinking: "why" } } },
|
||||
emitter,
|
||||
);
|
||||
translateCodebuddyMessage(
|
||||
{ type: "assistant", message: { content: [{ type: "tool_use", id: "tu-1", name: "Bash", input: { command: "ls" } }] } },
|
||||
emitter,
|
||||
);
|
||||
translateCodebuddyMessage(
|
||||
{ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "tu-1", content: "ok" }] } },
|
||||
emitter,
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "text", t: "hi" },
|
||||
{ k: "reasoning", d: "why" },
|
||||
{ k: "toolCall", name: "Bash", args: { command: "ls" }, id: "tu-1" },
|
||||
{ k: "toolResult", id: "tu-1", out: "ok", name: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test("translateCodebuddyMessage emits system session id and status text", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateCodebuddyMessage(
|
||||
{ type: "system", session_id: "sess-1", message: "initializing" },
|
||||
emitter,
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "sessionId", s: "sess-1" },
|
||||
{ k: "status", m: "initializing" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCodebuddyTurn preserves explicit SDK error messages", async () => {
|
||||
const { events, emitter } = collector();
|
||||
async function* fakeQuery() {
|
||||
yield {
|
||||
type: "error",
|
||||
session_id: "sess-error",
|
||||
error: "Provider quota exceeded",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await runCodebuddyTurn({
|
||||
prompt: "hello",
|
||||
options: { abortController: new AbortController() },
|
||||
emitter,
|
||||
queryFn: fakeQuery,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { sessionId: "sess-error" });
|
||||
assert.deepEqual(events, [{ k: "error", m: "Provider quota exceeded" }]);
|
||||
});
|
||||
|
||||
test("translateCodebuddyMessage emits actual result usage", () => {
|
||||
const { events, emitter } = collector();
|
||||
const result = translateCodebuddyMessage({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
is_error: false,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 321,
|
||||
output_tokens: 45,
|
||||
cache_read_input_tokens: 100,
|
||||
cache_creation_input_tokens: 20,
|
||||
},
|
||||
}, emitter);
|
||||
|
||||
assert.deepEqual(result, { terminalError: false });
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
k: "usage",
|
||||
usage: {
|
||||
inputTokens: 441,
|
||||
cachedInputTokens: 100,
|
||||
outputTokens: 45,
|
||||
totalTokens: 486,
|
||||
},
|
||||
},
|
||||
{ k: "status", m: "CodeBuddy: 1 turns" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCodebuddyTurn reports terminal result subtypes instead of an auth error", async () => {
|
||||
const { events, emitter } = collector();
|
||||
async function* fakeQuery() {
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "error_max_budget_usd",
|
||||
is_error: true,
|
||||
num_turns: 2,
|
||||
total_cost_usd: 1,
|
||||
usage: { input_tokens: 10, output_tokens: 2 },
|
||||
permission_denials: [],
|
||||
};
|
||||
}
|
||||
|
||||
await runCodebuddyTurn({
|
||||
prompt: "spend",
|
||||
options: { abortController: new AbortController() },
|
||||
emitter,
|
||||
queryFn: () => fakeQuery(),
|
||||
});
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
k: "usage",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 2,
|
||||
totalTokens: 12,
|
||||
},
|
||||
},
|
||||
{ k: "status", m: "CodeBuddy: 2 turns, $1.0000" },
|
||||
{ k: "error", m: "CodeBuddy stopped after reaching the configured budget limit." },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCodebuddyTurn renders a successful result fallback when no text delta arrives", async () => {
|
||||
const { events, emitter } = collector();
|
||||
async function* fakeQuery() {
|
||||
yield {
|
||||
type: "stream_event",
|
||||
event: { type: "message_start", message: { content: [] } },
|
||||
};
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
is_error: false,
|
||||
num_turns: 1,
|
||||
result: "fallback answer",
|
||||
total_cost_usd: 0,
|
||||
usage: { input_tokens: 4, output_tokens: 2 },
|
||||
permission_denials: [],
|
||||
};
|
||||
}
|
||||
|
||||
await runCodebuddyTurn({
|
||||
prompt: "answer",
|
||||
options: { abortController: new AbortController() },
|
||||
emitter,
|
||||
queryFn: () => fakeQuery(),
|
||||
});
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
k: "usage",
|
||||
usage: {
|
||||
inputTokens: 4,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 2,
|
||||
totalTokens: 6,
|
||||
},
|
||||
},
|
||||
{ k: "status", m: "CodeBuddy: 1 turns" },
|
||||
{ k: "text", t: "fallback answer" },
|
||||
{ k: "done" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCodebuddyTurn does not duplicate assistant text after streamed text", async () => {
|
||||
const { events, emitter } = collector();
|
||||
async function* fakeQuery() {
|
||||
yield { type: "system", session_id: "sess-1" };
|
||||
yield { type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "hello" } } };
|
||||
yield { type: "assistant", message: { content: [{ type: "text", text: "hello" }] } };
|
||||
}
|
||||
|
||||
const result = await runCodebuddyTurn({
|
||||
prompt: "say hi",
|
||||
options: { abortController: new AbortController() },
|
||||
emitter,
|
||||
queryFn: () => fakeQuery(),
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { sessionId: "sess-1" });
|
||||
assert.deepEqual(events, [
|
||||
{ k: "sessionId", s: "sess-1" },
|
||||
{ k: "text", t: "hello" },
|
||||
{ k: "done" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCodebuddyTurn interrupts the SDK query as soon as abort is signaled", async () => {
|
||||
const events = [];
|
||||
let sawSession;
|
||||
const sessionSeen = new Promise((resolve) => { sawSession = resolve; });
|
||||
const emitter = {
|
||||
text: (t) => events.push({ k: "text", t }),
|
||||
reasoning: (d) => events.push({ k: "reasoning", d }),
|
||||
toolCall: (name, args, id) => events.push({ k: "toolCall", name, args, id }),
|
||||
toolResult: (id, out, name) => events.push({ k: "toolResult", id, out, name }),
|
||||
status: (m) => events.push({ k: "status", m }),
|
||||
sessionId: (s) => { events.push({ k: "sessionId", s }); sawSession(); },
|
||||
emitDone: () => events.push({ k: "done" }),
|
||||
emitError: (m) => events.push({ k: "error", m }),
|
||||
};
|
||||
const ac = new AbortController();
|
||||
let interruptCount = 0;
|
||||
let release;
|
||||
|
||||
const fakeQuery = () => ({
|
||||
interrupt: async () => { interruptCount += 1; release?.(); },
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { type: "system", session_id: "sess-1" };
|
||||
await new Promise((resolve) => { release = resolve; });
|
||||
},
|
||||
});
|
||||
|
||||
const turn = runCodebuddyTurn({
|
||||
prompt: "wait",
|
||||
options: { abortController: ac },
|
||||
emitter,
|
||||
queryFn: fakeQuery,
|
||||
});
|
||||
|
||||
await sessionSeen;
|
||||
ac.abort();
|
||||
const result = await turn;
|
||||
|
||||
assert.deepEqual(result, { sessionId: "sess-1" });
|
||||
assert.ok(interruptCount >= 1);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "sessionId", s: "sess-1" },
|
||||
{ k: "done" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCodebuddyTurn treats an abort rejection as normal completion", async () => {
|
||||
const ac = new AbortController();
|
||||
let rejectStream;
|
||||
const fakeQuery = () => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { type: "system", session_id: "sess-abort" };
|
||||
await new Promise((_resolve, reject) => {
|
||||
rejectStream = reject;
|
||||
});
|
||||
},
|
||||
async interrupt() {
|
||||
rejectStream?.(new Error("interrupted"));
|
||||
},
|
||||
});
|
||||
const { events, emitter } = collector();
|
||||
|
||||
const turn = runCodebuddyTurn({
|
||||
prompt: "wait",
|
||||
options: { abortController: ac },
|
||||
emitter,
|
||||
queryFn: fakeQuery,
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
ac.abort();
|
||||
|
||||
assert.deepEqual(await turn, { sessionId: "sess-abort" });
|
||||
assert.deepEqual(events, [
|
||||
{ k: "sessionId", s: "sess-abort" },
|
||||
{ k: "done" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCodebuddyTurn does not start the legacy CLI after an early abort", async () => {
|
||||
const { events, emitter } = collector();
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
let queryCalls = 0;
|
||||
|
||||
const result = await runCodebuddyTurn({
|
||||
prompt: "hello",
|
||||
attachments: [],
|
||||
options: { abortController },
|
||||
emitter,
|
||||
queryFn() {
|
||||
queryCalls += 1;
|
||||
throw new Error("must not start");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(queryCalls, 0);
|
||||
assert.deepEqual(result, { sessionId: null });
|
||||
assert.deepEqual(events, [{ k: "done" }]);
|
||||
});
|
||||
|
||||
test("buildCodebuddyPromptInput sends supported images as native image blocks", async () => {
|
||||
const input = buildCodebuddyPromptInput("describe this", [
|
||||
{ filename: "shot.png", mediaType: "image/png", filePath: "/tmp/shot.png", base64Data: "abc" },
|
||||
{ filename: "bad.svg", mediaType: "image/svg+xml", filePath: "/tmp/bad.svg", base64Data: "def" },
|
||||
]);
|
||||
const messages = [];
|
||||
for await (const message of input) messages.push(message);
|
||||
assert.deepEqual(messages, [{
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "describe this" },
|
||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } },
|
||||
],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
}]);
|
||||
});
|
||||
|
||||
test("mapCodebuddyModels maps model ids and drops invalid entries", () => {
|
||||
assert.deepEqual(mapCodebuddyModels([
|
||||
// Real CLI wire shape ({id,name}) — must NOT be dropped.
|
||||
{ id: "glm-5.1", name: "GLM-5.1" },
|
||||
{ modelId: "cb-1", name: "CodeBuddy 1", description: "default" },
|
||||
{ value: "cb-2", displayName: "CodeBuddy 2" },
|
||||
{ name: "missing id" },
|
||||
]), [
|
||||
{
|
||||
id: "glm-5.1",
|
||||
name: "GLM-5.1",
|
||||
description: undefined,
|
||||
thinkingLevels: ["low", "medium", "high", "xhigh"],
|
||||
defaultThinkingLevel: "medium",
|
||||
encodeDefaultThinking: false,
|
||||
},
|
||||
{
|
||||
id: "cb-1",
|
||||
name: "CodeBuddy 1",
|
||||
description: "default",
|
||||
thinkingLevels: ["low", "medium", "high", "xhigh"],
|
||||
defaultThinkingLevel: "medium",
|
||||
encodeDefaultThinking: false,
|
||||
},
|
||||
{
|
||||
id: "cb-2",
|
||||
name: "CodeBuddy 2",
|
||||
description: undefined,
|
||||
thinkingLevels: ["low", "medium", "high", "xhigh"],
|
||||
defaultThinkingLevel: "medium",
|
||||
encodeDefaultThinking: false,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(mapCodebuddyModels(null), []);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SDK 0.3.230 options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("buildCodebuddyQueryOptions passes SDK 0.3.230 options", () => {
|
||||
const opts = buildCodebuddyQueryOptions({
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
systemPrompt: "You are a server admin assistant.",
|
||||
effort: "high",
|
||||
maxTurns: 10,
|
||||
maxBudgetUsd: 0.5,
|
||||
fallbackModel: "glm-4",
|
||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
||||
agents: { auditor: { description: "Security auditor", prompt: "Audit", tools: ["Bash"] } },
|
||||
outputFormat: { type: "json_schema", schema: { type: "object" } },
|
||||
enableFileCheckpointing: true,
|
||||
traceId: "trace-123",
|
||||
parentSpanId: "span-456",
|
||||
persistSession: false,
|
||||
sessionId: "custom-sess",
|
||||
});
|
||||
|
||||
assert.deepEqual(opts.systemPrompt, { append: "You are a server admin assistant." });
|
||||
assert.equal(opts.effort, "high");
|
||||
assert.equal(opts.maxTurns, 10);
|
||||
assert.equal(opts.maxBudgetUsd, 0.5);
|
||||
assert.equal(opts.fallbackModel, "glm-4");
|
||||
assert.deepEqual(opts.sandbox, { enabled: true, autoAllowBashIfSandboxed: true });
|
||||
assert.deepEqual(opts.agents, { auditor: { description: "Security auditor", prompt: "Audit", tools: ["Bash"] } });
|
||||
assert.deepEqual(opts.outputFormat, { type: "json_schema", schema: { type: "object" } });
|
||||
assert.equal(opts.enableFileCheckpointing, true);
|
||||
assert.equal(opts.traceId, "trace-123");
|
||||
assert.equal(opts.parentSpanId, "span-456");
|
||||
assert.equal(opts.persistSession, false);
|
||||
assert.equal(opts.sessionId, "custom-sess");
|
||||
});
|
||||
|
||||
test("buildCodebuddyQueryOptions does not set maxThinkingTokens (deprecated removed)", () => {
|
||||
const opts = buildCodebuddyQueryOptions({
|
||||
cwd: "/tmp",
|
||||
env: { NETCATTY_CODEBUDDY_THINKING: "enabled:8000" },
|
||||
});
|
||||
assert.deepEqual(opts.thinking, { type: "enabled", budgetTokens: 8000 });
|
||||
assert.ok(!("maxThinkingTokens" in opts));
|
||||
});
|
||||
|
||||
test("buildCodebuddyQueryOptions splits model/effort and prefers it over settings effort", () => {
|
||||
const fromModel = buildCodebuddyQueryOptions({
|
||||
cwd: "/tmp",
|
||||
model: "glm-5.1/high",
|
||||
effort: "low",
|
||||
});
|
||||
assert.equal(fromModel.model, "glm-5.1");
|
||||
assert.equal(fromModel.effort, "high");
|
||||
|
||||
const fromSettings = buildCodebuddyQueryOptions({
|
||||
cwd: "/tmp",
|
||||
model: "glm-5.1",
|
||||
effort: "low",
|
||||
});
|
||||
assert.equal(fromSettings.model, "glm-5.1");
|
||||
assert.equal(fromSettings.effort, "low");
|
||||
});
|
||||
|
||||
test("buildCodebuddyQueryOptions drops invalid numeric guardrails", () => {
|
||||
const fractionalTurns = buildCodebuddyQueryOptions({
|
||||
maxTurns: 1.5,
|
||||
maxBudgetUsd: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
assert.equal(fractionalTurns.maxTurns, undefined);
|
||||
assert.equal(fractionalTurns.maxBudgetUsd, undefined);
|
||||
|
||||
const valid = buildCodebuddyQueryOptions({
|
||||
maxTurns: 2,
|
||||
maxBudgetUsd: 0.25,
|
||||
});
|
||||
assert.equal(valid.maxTurns, 2);
|
||||
assert.equal(valid.maxBudgetUsd, 0.25);
|
||||
});
|
||||
|
||||
test("buildCodebuddyQueryOptions drops disabled or malformed advanced options", () => {
|
||||
const opts = buildCodebuddyQueryOptions({
|
||||
cwd: "/tmp",
|
||||
effort: "ultra",
|
||||
fallbackModel: { id: "fallback" },
|
||||
sandbox: { enabled: false },
|
||||
enableFileCheckpointing: false,
|
||||
});
|
||||
|
||||
assert.equal(opts.effort, undefined);
|
||||
assert.equal(opts.fallbackModel, undefined);
|
||||
assert.equal(opts.sandbox, undefined);
|
||||
assert.equal(opts.enableFileCheckpointing, undefined);
|
||||
});
|
||||
|
||||
test("buildCodebuddyQueryOptions accepts object systemPrompt directly", () => {
|
||||
const opts = buildCodebuddyQueryOptions({
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
systemPrompt: { append: "custom append" },
|
||||
});
|
||||
assert.deepEqual(opts.systemPrompt, { append: "custom append" });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("buildCodebuddyHooks returns hook matchers that emit events", async () => {
|
||||
const { events, emitter } = collector();
|
||||
emitter.emitEvent = (ev) => events.push({ k: "event", ev });
|
||||
const hooks = buildCodebuddyHooks(emitter);
|
||||
|
||||
assert.ok(Array.isArray(hooks.PreToolUse));
|
||||
assert.ok(Array.isArray(hooks.PostToolUse));
|
||||
assert.ok(Array.isArray(hooks.PostToolUseFailure));
|
||||
assert.ok(Array.isArray(hooks.SessionEnd));
|
||||
assert.ok(Array.isArray(hooks.Notification));
|
||||
|
||||
// Invoke PreToolUse hook callback
|
||||
const preHook = hooks.PreToolUse[0].hooks[0];
|
||||
const result = await preHook(
|
||||
{ tool_name: "Bash", tool_input: { command: "ls" }, tool_use_id: "tu-1" },
|
||||
"tu-1",
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
assert.deepEqual(result, { continue: true });
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].ev.hookEvent, "PreToolUse");
|
||||
assert.equal(events[0].ev.toolName, "Bash");
|
||||
});
|
||||
|
||||
test("buildCodebuddyHooks blocks non-Netcatty Bash commands in skills mode", async () => {
|
||||
const { emitter } = collector();
|
||||
emitter.emitEvent = () => {};
|
||||
const hooks = buildCodebuddyHooks(emitter, {
|
||||
toolIntegrationMode: "skills",
|
||||
allowedCliCommandPrefix: "netcatty-tool-cli",
|
||||
});
|
||||
const preHook = hooks.PreToolUse[0].hooks[0];
|
||||
|
||||
assert.deepEqual(
|
||||
await preHook(
|
||||
{ tool_name: "Bash", tool_input: { command: "ls -la" }, tool_use_id: "tu-local" },
|
||||
"tu-local",
|
||||
{ signal: new AbortController().signal },
|
||||
),
|
||||
{
|
||||
continue: true,
|
||||
decision: "block",
|
||||
reason:
|
||||
"Only Netcatty CLI commands are allowed in Skills mode. " +
|
||||
"Use the netcatty-tool-cli command prefix provided by the host.",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
await preHook(
|
||||
{
|
||||
tool_name: "Bash",
|
||||
tool_input: {
|
||||
command: "netcatty-tool-cli session --session s1 --chat-session c1 --json",
|
||||
},
|
||||
tool_use_id: "tu-cli",
|
||||
},
|
||||
"tu-cli",
|
||||
{ signal: new AbortController().signal },
|
||||
),
|
||||
{ continue: true },
|
||||
);
|
||||
assert.equal(
|
||||
(await preHook(
|
||||
{
|
||||
tool_name: "Bash",
|
||||
tool_input: {
|
||||
command: "/tmp/netcatty-tool-cli status --json",
|
||||
},
|
||||
tool_use_id: "tu-impostor",
|
||||
},
|
||||
"tu-impostor",
|
||||
{ signal: new AbortController().signal },
|
||||
)).decision,
|
||||
"block",
|
||||
);
|
||||
assert.equal(
|
||||
(await preHook(
|
||||
{
|
||||
tool_name: "Bash",
|
||||
tool_input: {
|
||||
command: "netcatty-tool-cli status --json",
|
||||
run_in_background: true,
|
||||
},
|
||||
tool_use_id: "tu-background",
|
||||
},
|
||||
"tu-background",
|
||||
{ signal: new AbortController().signal },
|
||||
)).decision,
|
||||
"block",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCodebuddyHooks retains caller-provided lifecycle hooks", () => {
|
||||
const { emitter } = collector();
|
||||
emitter.emitEvent = () => {};
|
||||
const custom = { hooks: [async () => ({ continue: true })] };
|
||||
const hooks = buildCodebuddyHooks(emitter, {
|
||||
toolIntegrationMode: "skills",
|
||||
additionalHooks: { PreToolUse: [custom] },
|
||||
});
|
||||
|
||||
assert.equal(hooks.PreToolUse.length, 2);
|
||||
assert.equal(hooks.PreToolUse[1], custom);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Elicitation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("buildCodebuddyElicitation forwards create and resolves on response", async () => {
|
||||
const { events, emitter } = collector();
|
||||
emitter.emitEvent = (ev) => events.push({ k: "event", ev });
|
||||
const pendingMap = new Map();
|
||||
const handler = buildCodebuddyElicitation(emitter, pendingMap);
|
||||
const controller = new AbortController();
|
||||
|
||||
const createPromise = handler.create(
|
||||
{ _meta: { "codebuddy.ai": { elicitationId: "el-1" } }, message: "Confirm?" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
// Should have emitted elicitation-create event
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].ev.type, "elicitation-create");
|
||||
assert.equal(events[0].ev.elicitationId, "el-1");
|
||||
|
||||
// Resolve the pending elicitation
|
||||
assert.ok(pendingMap.has("el-1"));
|
||||
pendingMap.get("el-1").resolve({ action: "accept", content: { confirmed: true } });
|
||||
const response = await createPromise;
|
||||
assert.deepEqual(response, { action: "accept", content: { confirmed: true } });
|
||||
assert.equal(pendingMap.size, 0);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
});
|
||||
|
||||
test("buildCodebuddyElicitation cancels immediately for an aborted signal", async () => {
|
||||
const { events, emitter } = collector();
|
||||
emitter.emitEvent = (ev) => events.push({ k: "event", ev });
|
||||
const pendingMap = new Map();
|
||||
const handler = buildCodebuddyElicitation(emitter, pendingMap);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const response = await handler.create(
|
||||
{ _meta: { "codebuddy.ai": { elicitationId: "el-aborted" } } },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
assert.deepEqual(response, { action: "cancel" });
|
||||
assert.equal(pendingMap.size, 0);
|
||||
assert.equal(events.length, 0);
|
||||
});
|
||||
|
||||
test("buildCodebuddyElicitation tags pendings with chatSessionId and uses UUID fallback ids", async () => {
|
||||
const { events, emitter } = collector();
|
||||
emitter.emitEvent = (ev) => events.push({ k: "event", ev });
|
||||
const pendingMap = new Map();
|
||||
const handler = buildCodebuddyElicitation(emitter, pendingMap, { chatSessionId: "chat-1" });
|
||||
|
||||
// No _meta id — the fallback must be a UUID, distinct across creates so a
|
||||
// same-millisecond collision cannot cancel the earlier pending.
|
||||
const first = handler.create({ message: "one" }, {});
|
||||
const second = handler.create({ message: "two" }, {});
|
||||
const ids = [...pendingMap.keys()];
|
||||
assert.equal(ids.length, 2);
|
||||
assert.notEqual(ids[0], ids[1]);
|
||||
for (const id of ids) {
|
||||
assert.match(
|
||||
id,
|
||||
/^codebuddy:chat-1:elicitation_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
assert.equal(pendingMap.get(id).chatSessionId, "chat-1");
|
||||
}
|
||||
|
||||
pendingMap.get(ids[0]).resolve({ action: "accept" });
|
||||
pendingMap.get(ids[1]).resolve({ action: "cancel" });
|
||||
assert.deepEqual(await first, { action: "accept" });
|
||||
assert.deepEqual(await second, { action: "cancel" });
|
||||
assert.equal(pendingMap.size, 0);
|
||||
});
|
||||
|
||||
test("buildCodebuddyElicitation complete cancels and removes a pending create", async () => {
|
||||
const { events, emitter } = collector();
|
||||
emitter.emitEvent = (ev) => events.push({ k: "event", ev });
|
||||
const pendingMap = new Map();
|
||||
const handler = buildCodebuddyElicitation(emitter, pendingMap);
|
||||
const controller = new AbortController();
|
||||
|
||||
const createPromise = handler.create(
|
||||
{ _meta: { "codebuddy.ai": { elicitationId: "el-complete" } } },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
handler.complete({ elicitationId: "el-complete" });
|
||||
|
||||
assert.deepEqual(await createPromise, { action: "cancel" });
|
||||
assert.equal(pendingMap.size, 0);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
assert.deepEqual(events.map(({ ev }) => ev.type), [
|
||||
"elicitation-create",
|
||||
"elicitation-complete",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildCodebuddyElicitation scopes identical protocol ids to their chat", async () => {
|
||||
const pendingMap = new Map();
|
||||
const firstEvents = [];
|
||||
const secondEvents = [];
|
||||
const firstHandler = buildCodebuddyElicitation(
|
||||
{ emitEvent: (event) => firstEvents.push(event) },
|
||||
pendingMap,
|
||||
{ chatSessionId: "chat/one" },
|
||||
);
|
||||
const secondHandler = buildCodebuddyElicitation(
|
||||
{ emitEvent: (event) => secondEvents.push(event) },
|
||||
pendingMap,
|
||||
{ chatSessionId: "chat/two" },
|
||||
);
|
||||
|
||||
const first = firstHandler.create({
|
||||
_meta: { "codebuddy.ai": { elicitationId: "confirm:1" } },
|
||||
});
|
||||
const second = secondHandler.create({
|
||||
_meta: { "codebuddy.ai": { elicitationId: "confirm:1" } },
|
||||
});
|
||||
const firstId = firstEvents[0].elicitationId;
|
||||
const secondId = secondEvents[0].elicitationId;
|
||||
|
||||
assert.equal(firstId, "codebuddy:chat%2Fone:confirm%3A1");
|
||||
assert.equal(secondId, "codebuddy:chat%2Ftwo:confirm%3A1");
|
||||
assert.equal(pendingMap.size, 2);
|
||||
pendingMap.get(firstId).resolve({ action: "accept", content: { chat: "one" } });
|
||||
pendingMap.get(secondId).resolve({ action: "decline" });
|
||||
|
||||
assert.deepEqual(await first, { action: "accept", content: { chat: "one" } });
|
||||
assert.deepEqual(await second, { action: "decline" });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP SSE/HTTP support
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("toSdkMcpServers supports sse, http, and sdk transport types", () => {
|
||||
const fakeInstance = { __brand: "sdk-mcp" };
|
||||
const map = toSdkMcpServers([
|
||||
{ name: "stdio-server", command: "/bin/server", args: ["--port", "0"], env: [] },
|
||||
{ name: "sse-server", type: "sse", url: "http://localhost:3000/sse", headers: { Authorization: "Bearer x" } },
|
||||
{ name: "http-server", type: "http", url: "http://localhost:4000/mcp" },
|
||||
{ name: "sdk-server", type: "sdk", instance: fakeInstance },
|
||||
]);
|
||||
assert.equal(map["stdio-server"].type, "stdio");
|
||||
assert.equal(map["stdio-server"].command, "/bin/server");
|
||||
assert.equal(map["sse-server"].type, "sse");
|
||||
assert.equal(map["sse-server"].url, "http://localhost:3000/sse");
|
||||
assert.deepEqual(map["sse-server"].headers, { Authorization: "Bearer x" });
|
||||
assert.equal(map["http-server"].type, "http");
|
||||
assert.equal(map["http-server"].url, "http://localhost:4000/mcp");
|
||||
assert.equal(map["sdk-server"].type, "sdk");
|
||||
assert.equal(map["sdk-server"].name, "sdk-server");
|
||||
assert.equal(map["sdk-server"].instance, fakeInstance);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permission handler (canUseTool)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("buildCodebuddyCanUseTool auto mode allows without prompting", async () => {
|
||||
const handler = buildCodebuddyCanUseTool({ permissionMode: "auto" });
|
||||
const result = await handler("Bash", { command: "rm -rf /tmp/x" }, {});
|
||||
assert.deepEqual(result, { behavior: "allow" });
|
||||
});
|
||||
|
||||
test("buildCodebuddyCanUseTool observer mode denies with message", async () => {
|
||||
const handler = buildCodebuddyCanUseTool({ permissionMode: "observer" });
|
||||
const result = await handler("Bash", { command: "ls" }, {});
|
||||
assert.equal(result.behavior, "deny");
|
||||
assert.ok(result.message.includes("Observer mode"));
|
||||
});
|
||||
|
||||
test("buildCodebuddyCanUseTool confirm mode forwards to approval UI and allows on approve", async () => {
|
||||
const calls = [];
|
||||
const requestApproval = async (toolName, args, chatSessionId) => {
|
||||
calls.push({ toolName, args, chatSessionId });
|
||||
return true;
|
||||
};
|
||||
const handler = buildCodebuddyCanUseTool({
|
||||
permissionMode: "confirm",
|
||||
chatSessionId: "chat-1",
|
||||
requestApproval,
|
||||
});
|
||||
const result = await handler("Bash", { command: "apt install nginx" }, {});
|
||||
assert.deepEqual(result, { behavior: "allow" });
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].toolName, "Bash");
|
||||
assert.deepEqual(calls[0].args, { command: "apt install nginx" });
|
||||
assert.equal(calls[0].chatSessionId, "chat-1");
|
||||
});
|
||||
|
||||
test("buildCodebuddyCanUseTool confirm mode denies on user rejection", async () => {
|
||||
const handler = buildCodebuddyCanUseTool({
|
||||
permissionMode: "confirm",
|
||||
chatSessionId: "chat-1",
|
||||
requestApproval: async () => false,
|
||||
});
|
||||
const result = await handler("Bash", { command: "reboot" }, {});
|
||||
assert.equal(result.behavior, "deny");
|
||||
assert.ok(result.message.includes("User denied"));
|
||||
});
|
||||
|
||||
test("buildCodebuddyCanUseTool confirm mode denies when no approval channel", async () => {
|
||||
const handler = buildCodebuddyCanUseTool({ permissionMode: "confirm" });
|
||||
const result = await handler("Bash", {}, {});
|
||||
assert.equal(result.behavior, "deny");
|
||||
assert.ok(result.message.includes("no approval channel"));
|
||||
});
|
||||
|
||||
test("buildCodebuddyQueryOptions attaches canUseTool handler", () => {
|
||||
const handler = async () => ({ behavior: "allow" });
|
||||
const opts = buildCodebuddyQueryOptions({ cwd: "/tmp", env: {}, canUseTool: handler });
|
||||
assert.equal(opts.canUseTool, handler);
|
||||
});
|
||||
430
electron/bridges/aiBridge/sdk/codebuddySessionManager.cjs
Normal file
430
electron/bridges/aiBridge/sdk/codebuddySessionManager.cjs
Normal file
@@ -0,0 +1,430 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* CodeBuddy V2 Session Manager — @experimental
|
||||
*
|
||||
* Manages persistent multi-turn sessions using the SDK's unstable_v2 Session
|
||||
* API (createSession / resumeSession). Falls back to the legacy query() path
|
||||
* when the V2 API is unavailable.
|
||||
*
|
||||
* Benefits over query()-per-turn:
|
||||
* - CLI process stays warm across turns (faster subsequent responses)
|
||||
* - True multi-turn context without replaying history
|
||||
* - Supports steer (mid-turn追加消息) via session.send()
|
||||
*/
|
||||
|
||||
const {
|
||||
buildCodebuddyQueryOptions,
|
||||
buildCodebuddyPromptInput,
|
||||
buildCodebuddyHooks,
|
||||
buildCodebuddyElicitation,
|
||||
translateCodebuddyMessage,
|
||||
inspectCodebuddyMessageContent,
|
||||
codebuddyResultFallbackText,
|
||||
classifyCodebuddySpawnError,
|
||||
} = require("./codebuddyDriver.cjs");
|
||||
|
||||
/**
|
||||
* Compute a stable fingerprint from option-affecting fields so we can detect
|
||||
* when the user changes model, env, permission mode, tools, etc. between turns.
|
||||
* Only JSON-serializable fields are included; function-valued fields (hooks,
|
||||
* canUseTool, elicitation) are excluded since they are rebuilt every turn.
|
||||
*/
|
||||
function computeOptionsFingerprint(sessionOptions) {
|
||||
const relevant = {
|
||||
cwd: sessionOptions.cwd,
|
||||
model: sessionOptions.model,
|
||||
env: sessionOptions.env,
|
||||
pathToCodebuddyCode: sessionOptions.pathToCodebuddyCode,
|
||||
mcpServers: sessionOptions.mcpServers,
|
||||
permissionMode: sessionOptions.permissionMode,
|
||||
extraArgs: sessionOptions.extraArgs,
|
||||
systemPrompt: sessionOptions.systemPrompt,
|
||||
tools: sessionOptions.tools,
|
||||
disallowedTools: sessionOptions.disallowedTools,
|
||||
settingSources: sessionOptions.settingSources,
|
||||
maxTurns: sessionOptions.maxTurns,
|
||||
agents: sessionOptions.agents,
|
||||
thinking: sessionOptions.thinking,
|
||||
effort: sessionOptions.effort,
|
||||
hasHooks: Boolean(sessionOptions.hooks),
|
||||
hasCanUseTool: typeof sessionOptions.canUseTool === "function",
|
||||
hasElicitation: Boolean(sessionOptions.elicitation),
|
||||
};
|
||||
try {
|
||||
return JSON.stringify(relevant);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionCallbackState(sessionOptions) {
|
||||
const state = {
|
||||
elicitation: sessionOptions.elicitation,
|
||||
elicitationDelegate: null,
|
||||
};
|
||||
if (state.elicitation) {
|
||||
state.elicitationDelegate = {
|
||||
create(request, options) {
|
||||
const handler = state.elicitation;
|
||||
return handler?.create
|
||||
? handler.create(request, options)
|
||||
: Promise.resolve({ action: "cancel" });
|
||||
},
|
||||
complete(notification) {
|
||||
return state.elicitation?.complete?.(notification);
|
||||
},
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function refreshSessionCallbacks(entry, sessionOptions) {
|
||||
if (sessionOptions.hooks) {
|
||||
if (typeof entry.session.setHooks !== "function") return false;
|
||||
entry.session.setHooks(sessionOptions.hooks);
|
||||
}
|
||||
if (typeof sessionOptions.canUseTool === "function") {
|
||||
if (typeof entry.session.setCanUseTool !== "function") return false;
|
||||
entry.session.setCanUseTool(sessionOptions.canUseTool);
|
||||
}
|
||||
if (sessionOptions.elicitation) {
|
||||
if (!entry.callbackState?.elicitationDelegate) return false;
|
||||
entry.callbackState.elicitation = sessionOptions.elicitation;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class CodebuddySessionManager {
|
||||
constructor({ loadSdk } = {}) {
|
||||
/** @type {Map<string, {
|
||||
* session: object,
|
||||
* fingerprint: string|null,
|
||||
* callbackState?: ReturnType<typeof createSessionCallbackState>,
|
||||
* }>} */
|
||||
this.sessions = new Map();
|
||||
/** @type {Map<string, { resolve: Function, reject: Function }>} */
|
||||
this.elicitationPending = new Map();
|
||||
this.loadSdk = loadSdk || (() => import("@tencent-ai/agent-sdk"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an existing session or create/resume one.
|
||||
* If the session exists but its option-affecting fields have changed,
|
||||
* the stale session is closed and a fresh one is created.
|
||||
* @param {object} args
|
||||
* @param {string} args.sessionKey unique key (chatSessionId + backend + binPath)
|
||||
* @param {object} args.sessionOptions SDK SessionOptions
|
||||
* @param {string} [args.resumeSessionId] resume an existing session by ID
|
||||
* @returns {Promise<object|null>} session instance or null if V2 unavailable
|
||||
*/
|
||||
async getOrCreateSession({ sessionKey, sessionOptions, resumeSessionId }) {
|
||||
const fingerprint = computeOptionsFingerprint(sessionOptions);
|
||||
const existing = this.sessions.get(sessionKey);
|
||||
if (existing) {
|
||||
// Reuse only when serialized options still match, but always refresh
|
||||
// turn-scoped callbacks so events target the current request emitter.
|
||||
if (fingerprint !== null && existing.fingerprint === fingerprint) {
|
||||
try {
|
||||
if (refreshSessionCallbacks(existing, sessionOptions)) {
|
||||
return existing.session;
|
||||
}
|
||||
} catch {
|
||||
// Recreate below if the installed SDK cannot refresh callbacks.
|
||||
}
|
||||
}
|
||||
// Options changed — close the stale session and create a fresh one.
|
||||
try { existing.session.close(); } catch { /* best effort */ }
|
||||
this.sessions.delete(sessionKey);
|
||||
}
|
||||
|
||||
let sdk;
|
||||
try {
|
||||
sdk = await this.loadSdk();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createSession = sdk.unstable_v2_createSession;
|
||||
const resumeSession = sdk.unstable_v2_resumeSession;
|
||||
if (!createSession || !resumeSession) return null;
|
||||
|
||||
let session;
|
||||
try {
|
||||
const callbackState = createSessionCallbackState(sessionOptions);
|
||||
const sdkSessionOptions = callbackState.elicitationDelegate
|
||||
? { ...sessionOptions, elicitation: callbackState.elicitationDelegate }
|
||||
: sessionOptions;
|
||||
if (resumeSessionId) {
|
||||
session = resumeSession(resumeSessionId, sdkSessionOptions);
|
||||
} else {
|
||||
session = createSession(sdkSessionOptions);
|
||||
}
|
||||
// Do not connect before the first send. In resume mode, send() marks the
|
||||
// initialization as having a prompt so the SDK does not replay historical
|
||||
// messages into the new turn's stream.
|
||||
this.sessions.set(sessionKey, { session, fingerprint, callbackState });
|
||||
return session;
|
||||
} catch {
|
||||
// A factory failure can still leave a partially constructed session.
|
||||
try { session?.close(); } catch { /* best effort */ }
|
||||
// V2 session creation failed — caller should fall back to query().
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a turn using the V2 Session API.
|
||||
* Returns { sessionId, usedV2: true } on success, or null to signal fallback.
|
||||
*/
|
||||
async runTurn({
|
||||
sessionKey, prompt, attachments, options, emitter,
|
||||
sessionOptions, resumeSessionId,
|
||||
}) {
|
||||
const signal = options.abortController?.signal;
|
||||
if (signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
return { sessionId: null, usedV2: true };
|
||||
}
|
||||
|
||||
const session = await this.getOrCreateSession({
|
||||
sessionKey, sessionOptions, resumeSessionId,
|
||||
});
|
||||
if (!session) {
|
||||
if (signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
return { sessionId: null, usedV2: true };
|
||||
}
|
||||
return null; // signal caller to use query() fallback
|
||||
}
|
||||
|
||||
const promptInput = buildCodebuddyPromptInput(prompt, attachments);
|
||||
let sessionId = session.sessionId || null;
|
||||
let hasContent = false;
|
||||
let hasAssistantText = false;
|
||||
let hasStreamedText = false;
|
||||
let hasStreamedReasoning = false;
|
||||
let hasTerminalError = false;
|
||||
let resultFallbackText = "";
|
||||
let emittedSessionId = null;
|
||||
let removeAbortListener = null;
|
||||
|
||||
try {
|
||||
// Register before sending so cancellation during connection or send
|
||||
// cannot start a prompt without also interrupting the SDK session.
|
||||
const interruptSession = () => {
|
||||
if (typeof session.interrupt === "function") {
|
||||
void Promise.resolve(session.interrupt()).catch((err) => {
|
||||
console.debug("[CodeBuddy SDK] session interrupt failed:", err?.message || err);
|
||||
});
|
||||
}
|
||||
};
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", interruptSession, { once: true });
|
||||
removeAbortListener = () => signal.removeEventListener("abort", interruptSession);
|
||||
if (signal.aborted) {
|
||||
interruptSession();
|
||||
emitter.emitDone();
|
||||
return { sessionId, usedV2: true };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Send before the initial connection so resumed sessions suppress
|
||||
// historical replay and stream only the response to this prompt.
|
||||
if (typeof promptInput === "string") {
|
||||
await session.send(promptInput);
|
||||
} else {
|
||||
// Async iterable of UserMessage — send first message.
|
||||
for await (const msg of promptInput) {
|
||||
await session.send(msg);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Initial transport setup happens inside send(). Release any acquired
|
||||
// session lock/process before the caller falls back to legacy query().
|
||||
this.closeSession(sessionKey);
|
||||
if (signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
return { sessionId, usedV2: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
return { sessionId, usedV2: true };
|
||||
}
|
||||
if (sessionId) {
|
||||
emitter.sessionId(sessionId);
|
||||
emittedSessionId = sessionId;
|
||||
}
|
||||
|
||||
// Stream responses.
|
||||
for await (const message of session.stream()) {
|
||||
if (options.abortController?.signal?.aborted) {
|
||||
try { await session.interrupt(); } catch (err) {
|
||||
// Best effort — surface for diagnostics without failing the turn.
|
||||
console.debug("[CodeBuddy SDK] session interrupt failed:", err?.message || err);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (message?.session_id && message.session_id !== sessionId) {
|
||||
sessionId = message.session_id;
|
||||
}
|
||||
if (sessionId && sessionId !== emittedSessionId) {
|
||||
emitter.sessionId(sessionId);
|
||||
emittedSessionId = sessionId;
|
||||
}
|
||||
const contentState = inspectCodebuddyMessageContent(message);
|
||||
if (contentState.hasContent) hasContent = true;
|
||||
if (contentState.hasText) hasAssistantText = true;
|
||||
resultFallbackText ||= codebuddyResultFallbackText(message);
|
||||
const translation = translateCodebuddyMessage(
|
||||
message,
|
||||
emitter,
|
||||
{
|
||||
skipAssistantText: hasStreamedText,
|
||||
skipAssistantReasoning: hasStreamedReasoning,
|
||||
skipSessionId: true,
|
||||
},
|
||||
);
|
||||
if (translation?.terminalError) hasTerminalError = true;
|
||||
if (contentState.streamedText) hasStreamedText = true;
|
||||
if (contentState.streamedReasoning) hasStreamedReasoning = true;
|
||||
}
|
||||
|
||||
if (hasTerminalError) {
|
||||
return { sessionId, usedV2: true };
|
||||
}
|
||||
if (!hasAssistantText && resultFallbackText) {
|
||||
emitter.text(resultFallbackText);
|
||||
hasContent = true;
|
||||
}
|
||||
if (!hasContent && !options.abortController?.signal?.aborted) {
|
||||
emitter.emitError(
|
||||
"CodeBuddy returned an empty response. Run `codebuddy` in a terminal to log in, " +
|
||||
"or set CODEBUDDY_API_KEY / CODEBUDDY_AUTH_TOKEN.",
|
||||
);
|
||||
return { sessionId, usedV2: true };
|
||||
}
|
||||
emitter.emitDone();
|
||||
return { sessionId, usedV2: true };
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
return { sessionId, usedV2: true };
|
||||
}
|
||||
// A stream failure means the transport is no longer safe to reuse. Close
|
||||
// it now so the next turn can create/resume a fresh V2 session.
|
||||
this.closeSession(sessionKey);
|
||||
const classified = classifyCodebuddySpawnError(error);
|
||||
if (classified.isSpawnEnoent) {
|
||||
emitter.emitError(
|
||||
"CodeBuddy CLI not found or not runnable. " +
|
||||
"Install codebuddy and ensure it's on PATH, or set CODEBUDDY_CODE_PATH.",
|
||||
);
|
||||
} else {
|
||||
emitter.emitError(classified.message || "CodeBuddy turn failed");
|
||||
}
|
||||
return { sessionId, usedV2: true };
|
||||
} finally {
|
||||
removeAbortListener?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report mid-turn steer as unsupported for the current V2 Session API.
|
||||
*/
|
||||
async steer() {
|
||||
// SDK 0.3.230 Session.send() starts a new turn by resetting the shared
|
||||
// message iterator and discarding pending messages. Calling it while
|
||||
// runTurn() owns session.stream() can strand that active consumer.
|
||||
// Keep this disabled until the SDK exposes a dedicated mid-turn steer API.
|
||||
return { status: "unsupported" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set model at runtime without rebuilding the session.
|
||||
*/
|
||||
async setModel(sessionKey, model) {
|
||||
const entry = this.sessions.get(sessionKey);
|
||||
if (!entry) return false;
|
||||
try {
|
||||
await entry.session.setModel(model);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a specific session.
|
||||
*/
|
||||
closeSession(sessionKey) {
|
||||
const entry = this.sessions.get(sessionKey);
|
||||
if (entry) {
|
||||
try { entry.session.close(); } catch { /* best effort */ }
|
||||
this.sessions.delete(sessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all sessions for a given chat session prefix.
|
||||
* Also cancels pending elicitations scoped to the chat so main-process
|
||||
* promises cannot leak when the renderer never responds (chat closed).
|
||||
*/
|
||||
closeForChat(chatSessionId) {
|
||||
const prefix = `${String(chatSessionId || "")}\u0000`;
|
||||
for (const key of this.sessions.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
this.closeSession(key);
|
||||
}
|
||||
}
|
||||
this.cancelElicitationsForChat(chatSessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all sessions (app shutdown).
|
||||
*/
|
||||
closeAll() {
|
||||
for (const key of [...this.sessions.keys()]) {
|
||||
this.closeSession(key);
|
||||
}
|
||||
for (const [elicitationId, pending] of [...this.elicitationPending]) {
|
||||
this.elicitationPending.delete(elicitationId);
|
||||
try { pending.resolve({ action: "cancel" }); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending elicitations belonging to a chat session, resolving each
|
||||
* as { action: "cancel" } so waiting create() promises settle.
|
||||
*/
|
||||
cancelElicitationsForChat(chatSessionId) {
|
||||
const target = String(chatSessionId || "");
|
||||
for (const [elicitationId, pending] of [...this.elicitationPending]) {
|
||||
if (String(pending?.chatSessionId || "") !== target) continue;
|
||||
this.elicitationPending.delete(elicitationId);
|
||||
try { pending.resolve({ action: "cancel" }); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pending elicitation response from the renderer.
|
||||
*/
|
||||
resolveElicitation(elicitationId, response) {
|
||||
const pending = this.elicitationPending.get(elicitationId);
|
||||
if (pending) {
|
||||
this.elicitationPending.delete(elicitationId);
|
||||
pending.resolve(response);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance shared across the app lifecycle.
|
||||
const codebuddySessionManager = new CodebuddySessionManager();
|
||||
|
||||
module.exports = { CodebuddySessionManager, codebuddySessionManager, computeOptionsFingerprint };
|
||||
607
electron/bridges/aiBridge/sdk/codebuddySessionManager.test.cjs
Normal file
607
electron/bridges/aiBridge/sdk/codebuddySessionManager.test.cjs
Normal file
@@ -0,0 +1,607 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { CodebuddySessionManager, computeOptionsFingerprint } = require("./codebuddySessionManager.cjs");
|
||||
|
||||
function collector() {
|
||||
const events = [];
|
||||
const emitter = {
|
||||
text: (t) => events.push({ k: "text", t }),
|
||||
reasoning: (d) => events.push({ k: "reasoning", d }),
|
||||
toolCall: (name, args, id) => events.push({ k: "toolCall", name, args, id }),
|
||||
toolResult: (id, out, name) => events.push({ k: "toolResult", id, out, name }),
|
||||
usage: (usage) => events.push({ k: "usage", usage }),
|
||||
status: (m) => events.push({ k: "status", m }),
|
||||
sessionId: (s) => events.push({ k: "sessionId", s }),
|
||||
emitDone: () => events.push({ k: "done" }),
|
||||
emitError: (m) => events.push({ k: "error", m }),
|
||||
emitEvent: (ev) => events.push({ k: "event", ev }),
|
||||
};
|
||||
return { events, emitter };
|
||||
}
|
||||
|
||||
/** Create a fake V2 session that yields predefined messages. */
|
||||
function fakeSession(messages, opts = {}) {
|
||||
let sentMessages = [];
|
||||
let closed = false;
|
||||
let interruptCalls = 0;
|
||||
return {
|
||||
sessionId: opts.sessionId || "fake-sess-1",
|
||||
sentMessages,
|
||||
get closed() { return closed; },
|
||||
get interruptCalls() { return interruptCalls; },
|
||||
async connect() {},
|
||||
async send(msg) { sentMessages.push(msg); },
|
||||
async *stream() { for (const m of messages) yield m; },
|
||||
async interrupt() { interruptCalls += 1; },
|
||||
async setModel(model) { this._model = model; },
|
||||
setHooks(hooks) { this._hooks = hooks; },
|
||||
setCanUseTool(handler) { this._canUseTool = handler; },
|
||||
close() { closed = true; },
|
||||
};
|
||||
}
|
||||
|
||||
test("getOrCreateSession reuses existing session when options match", async () => {
|
||||
const mgr = new CodebuddySessionManager();
|
||||
const session = fakeSession([], { sessionId: "existing-sess" });
|
||||
const opts = { cwd: "/tmp", model: "glm-5" };
|
||||
mgr.sessions.set("reuse-key", { session, fingerprint: computeOptionsFingerprint(opts) });
|
||||
|
||||
const result = await mgr.getOrCreateSession({
|
||||
sessionKey: "reuse-key",
|
||||
sessionOptions: opts,
|
||||
});
|
||||
assert.equal(result, session);
|
||||
});
|
||||
|
||||
test("getOrCreateSession refreshes turn-scoped callbacks on a reused session", async () => {
|
||||
let createdOptions;
|
||||
const session = fakeSession([], { sessionId: "callback-session" });
|
||||
const mgr = new CodebuddySessionManager({
|
||||
loadSdk: async () => ({
|
||||
unstable_v2_createSession: (options) => {
|
||||
createdOptions = options;
|
||||
return session;
|
||||
},
|
||||
unstable_v2_resumeSession: () => session,
|
||||
}),
|
||||
});
|
||||
const firstEvents = [];
|
||||
const secondEvents = [];
|
||||
const firstOptions = {
|
||||
cwd: "/tmp",
|
||||
hooks: { Notification: [{ hooks: [() => firstEvents.push("hook")] }] },
|
||||
canUseTool: async () => ({ behavior: "allow", updatedInput: {} }),
|
||||
elicitation: {
|
||||
create: async () => {
|
||||
firstEvents.push("elicitation");
|
||||
return { action: "accept" };
|
||||
},
|
||||
},
|
||||
};
|
||||
const secondOptions = {
|
||||
cwd: "/tmp",
|
||||
hooks: { Notification: [{ hooks: [() => secondEvents.push("hook")] }] },
|
||||
canUseTool: async () => ({ behavior: "deny", message: "second turn" }),
|
||||
elicitation: {
|
||||
create: async () => {
|
||||
secondEvents.push("elicitation");
|
||||
return { action: "decline" };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const first = await mgr.getOrCreateSession({
|
||||
sessionKey: "callback-key",
|
||||
sessionOptions: firstOptions,
|
||||
});
|
||||
const second = await mgr.getOrCreateSession({
|
||||
sessionKey: "callback-key",
|
||||
sessionOptions: secondOptions,
|
||||
});
|
||||
|
||||
assert.equal(first, session);
|
||||
assert.equal(second, session);
|
||||
assert.equal(session._hooks, secondOptions.hooks);
|
||||
assert.equal(session._canUseTool, secondOptions.canUseTool);
|
||||
assert.notEqual(createdOptions.elicitation, firstOptions.elicitation);
|
||||
await session._hooks.Notification[0].hooks[0]();
|
||||
assert.deepEqual(await session._canUseTool(), {
|
||||
behavior: "deny",
|
||||
message: "second turn",
|
||||
});
|
||||
assert.deepEqual(
|
||||
await createdOptions.elicitation.create({}, { signal: new AbortController().signal }),
|
||||
{ action: "decline" },
|
||||
);
|
||||
assert.deepEqual(firstEvents, []);
|
||||
assert.deepEqual(secondEvents, ["hook", "elicitation"]);
|
||||
});
|
||||
|
||||
test("getOrCreateSession closes stale session when options change", async () => {
|
||||
const oldSession = fakeSession([], { sessionId: "old-sess" });
|
||||
const replacementSession = fakeSession([], { sessionId: "new-sess" });
|
||||
const mgr = new CodebuddySessionManager({
|
||||
loadSdk: async () => ({
|
||||
unstable_v2_createSession: () => replacementSession,
|
||||
unstable_v2_resumeSession: () => replacementSession,
|
||||
}),
|
||||
});
|
||||
const oldOpts = { cwd: "/tmp", model: "glm-4" };
|
||||
const newOpts = { cwd: "/tmp", model: "glm-5" };
|
||||
mgr.sessions.set("stale-key", {
|
||||
session: oldSession,
|
||||
fingerprint: computeOptionsFingerprint(oldOpts),
|
||||
});
|
||||
|
||||
const result = await mgr.getOrCreateSession({
|
||||
sessionKey: "stale-key",
|
||||
sessionOptions: newOpts,
|
||||
});
|
||||
|
||||
assert.ok(oldSession.closed);
|
||||
assert.equal(result, replacementSession);
|
||||
assert.equal(mgr.sessions.get("stale-key").session, replacementSession);
|
||||
assert.equal(
|
||||
mgr.sessions.get("stale-key").fingerprint,
|
||||
computeOptionsFingerprint(newOpts),
|
||||
);
|
||||
});
|
||||
|
||||
test("runTurn closes a session when initial send fails before fallback", async () => {
|
||||
const session = fakeSession([], { sessionId: "failed-connect-session" });
|
||||
session.send = async () => {
|
||||
throw new Error("connect failed");
|
||||
};
|
||||
const mgr = new CodebuddySessionManager({
|
||||
loadSdk: async () => ({
|
||||
unstable_v2_createSession: () => session,
|
||||
unstable_v2_resumeSession: () => session,
|
||||
}),
|
||||
});
|
||||
|
||||
const { events, emitter } = collector();
|
||||
const result = await mgr.runTurn({
|
||||
sessionKey: "failed-connect-key",
|
||||
prompt: "hello",
|
||||
attachments: [],
|
||||
options: { abortController: new AbortController() },
|
||||
emitter,
|
||||
sessionOptions: {},
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.equal(session.closed, true);
|
||||
assert.equal(mgr.sessions.has("failed-connect-key"), false);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test("runTurn closes and evicts a session when response streaming fails", async () => {
|
||||
const session = fakeSession([], { sessionId: "failed-stream-session" });
|
||||
session.stream = async function* stream() {
|
||||
throw new Error("transport died");
|
||||
};
|
||||
const mgr = new CodebuddySessionManager({
|
||||
loadSdk: async () => ({
|
||||
unstable_v2_createSession: () => session,
|
||||
unstable_v2_resumeSession: () => session,
|
||||
}),
|
||||
});
|
||||
|
||||
const { events, emitter } = collector();
|
||||
const result = await mgr.runTurn({
|
||||
sessionKey: "failed-stream-key",
|
||||
prompt: "hello",
|
||||
attachments: [],
|
||||
options: { abortController: new AbortController() },
|
||||
emitter,
|
||||
sessionOptions: {},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
sessionId: "failed-stream-session",
|
||||
usedV2: true,
|
||||
});
|
||||
assert.equal(session.closed, true);
|
||||
assert.equal(mgr.sessions.has("failed-stream-key"), false);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "sessionId", s: "failed-stream-session" },
|
||||
{ k: "error", m: "transport died" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("computeOptionsFingerprint detects option changes", () => {
|
||||
const base = {
|
||||
cwd: "/tmp",
|
||||
model: "glm-5",
|
||||
maxTurns: 10,
|
||||
effort: "high",
|
||||
extraArgs: { "dangerously-skip-permissions": null },
|
||||
};
|
||||
const same = {
|
||||
cwd: "/tmp",
|
||||
model: "glm-5",
|
||||
maxTurns: 10,
|
||||
effort: "high",
|
||||
extraArgs: { "dangerously-skip-permissions": null },
|
||||
};
|
||||
const diffModel = { cwd: "/tmp", model: "glm-4", maxTurns: 10, effort: "high" };
|
||||
const diffMaxTurns = { cwd: "/tmp", model: "glm-5", maxTurns: 20, effort: "high" };
|
||||
const diffEffort = { cwd: "/tmp", model: "glm-5", maxTurns: 10, effort: "low" };
|
||||
const diffExtraArgs = {
|
||||
...base,
|
||||
extraArgs: { "dangerously-skip-permissions": "false" },
|
||||
};
|
||||
assert.equal(computeOptionsFingerprint(base), computeOptionsFingerprint(same));
|
||||
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffModel));
|
||||
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffMaxTurns));
|
||||
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffEffort));
|
||||
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffExtraArgs));
|
||||
});
|
||||
|
||||
test("getOrCreateSession never reuses sessions with unserializable option fingerprints", async () => {
|
||||
const circular = {};
|
||||
circular.self = circular;
|
||||
const oldSession = fakeSession([], { sessionId: "circular-old" });
|
||||
const replacementSession = fakeSession([], { sessionId: "circular-new" });
|
||||
const mgr = new CodebuddySessionManager({
|
||||
loadSdk: async () => ({
|
||||
unstable_v2_createSession: () => replacementSession,
|
||||
unstable_v2_resumeSession: () => replacementSession,
|
||||
}),
|
||||
});
|
||||
mgr.sessions.set("circular-key", {
|
||||
session: oldSession,
|
||||
fingerprint: computeOptionsFingerprint({ mcpServers: circular }),
|
||||
});
|
||||
|
||||
const result = await mgr.getOrCreateSession({
|
||||
sessionKey: "circular-key",
|
||||
sessionOptions: { mcpServers: circular },
|
||||
});
|
||||
|
||||
assert.equal(result, replacementSession);
|
||||
assert.equal(oldSession.closed, true);
|
||||
assert.equal(mgr.sessions.get("circular-key").session, replacementSession);
|
||||
});
|
||||
|
||||
test("runTurn streams messages via V2 session when available", async () => {
|
||||
const mgr = new CodebuddySessionManager();
|
||||
const messages = [
|
||||
{ type: "system", session_id: "sess-v2" },
|
||||
{ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "hi from v2" } } },
|
||||
];
|
||||
const session = fakeSession(messages, { sessionId: "sess-v2" });
|
||||
// Pre-populate the session map to bypass SDK import.
|
||||
mgr.sessions.set("preloaded-key", { session, fingerprint: computeOptionsFingerprint({}) });
|
||||
|
||||
const { events, emitter } = collector();
|
||||
const result = await mgr.runTurn({
|
||||
sessionKey: "preloaded-key",
|
||||
prompt: "say hi",
|
||||
attachments: [],
|
||||
options: { abortController: new AbortController() },
|
||||
emitter,
|
||||
sessionOptions: {},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { sessionId: "sess-v2", usedV2: true });
|
||||
assert.ok(events.some((e) => e.k === "text" && e.t === "hi from v2"));
|
||||
assert.ok(events.some((e) => e.k === "done"));
|
||||
assert.deepEqual(
|
||||
events.filter((event) => event.k === "sessionId"),
|
||||
[{ k: "sessionId", s: "sess-v2" }],
|
||||
);
|
||||
assert.ok(session.sentMessages.includes("say hi"));
|
||||
});
|
||||
|
||||
test("runTurn sends before connecting a resumed session and skips replayed history", async () => {
|
||||
let explicitlyConnected = false;
|
||||
const session = fakeSession([], { sessionId: "resumed-session" });
|
||||
session.connect = async () => {
|
||||
explicitlyConnected = true;
|
||||
};
|
||||
session.send = async (message) => {
|
||||
session.sentMessages.push(message);
|
||||
};
|
||||
session.stream = async function* stream() {
|
||||
if (explicitlyConnected) {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "text", text: "old response" }] },
|
||||
};
|
||||
}
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "text", text: "new response" }] },
|
||||
};
|
||||
};
|
||||
const mgr = new CodebuddySessionManager({
|
||||
loadSdk: async () => ({
|
||||
unstable_v2_createSession: () => session,
|
||||
unstable_v2_resumeSession: () => session,
|
||||
}),
|
||||
});
|
||||
const { events, emitter } = collector();
|
||||
|
||||
const result = await mgr.runTurn({
|
||||
sessionKey: "resumed-key",
|
||||
prompt: "new question",
|
||||
attachments: [],
|
||||
options: { abortController: new AbortController() },
|
||||
emitter,
|
||||
sessionOptions: {},
|
||||
resumeSessionId: "resumed-session",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { sessionId: "resumed-session", usedV2: true });
|
||||
assert.equal(explicitlyConnected, false);
|
||||
assert.deepEqual(session.sentMessages, ["new question"]);
|
||||
assert.deepEqual(
|
||||
events.filter((event) => event.k === "text").map((event) => event.t),
|
||||
["new response"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
events.filter((event) => event.k === "sessionId"),
|
||||
[{ k: "sessionId", s: "resumed-session" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("runTurn does not connect or send when already aborted", async () => {
|
||||
let loadSdkCalls = 0;
|
||||
const mgr = new CodebuddySessionManager({
|
||||
loadSdk: async () => {
|
||||
loadSdkCalls += 1;
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const { events, emitter } = collector();
|
||||
|
||||
const result = await mgr.runTurn({
|
||||
sessionKey: "pre-aborted-key",
|
||||
prompt: "must not run",
|
||||
attachments: [],
|
||||
options: { abortController: controller },
|
||||
emitter,
|
||||
sessionOptions: {},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { sessionId: null, usedV2: true });
|
||||
assert.equal(loadSdkCalls, 0);
|
||||
assert.deepEqual(events, [{ k: "done" }]);
|
||||
});
|
||||
|
||||
test("runTurn does not stream when aborted while the initial send connects", async () => {
|
||||
let releaseSend;
|
||||
const session = fakeSession([], { sessionId: "slow-connect-session" });
|
||||
session.send = (message) => new Promise((resolve) => {
|
||||
session.sentMessages.push(message);
|
||||
releaseSend = resolve;
|
||||
});
|
||||
const mgr = new CodebuddySessionManager({
|
||||
loadSdk: async () => ({
|
||||
unstable_v2_createSession: () => session,
|
||||
unstable_v2_resumeSession: () => session,
|
||||
}),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const { events, emitter } = collector();
|
||||
|
||||
const runPromise = mgr.runTurn({
|
||||
sessionKey: "slow-connect-key",
|
||||
prompt: "must not run",
|
||||
attachments: [],
|
||||
options: { abortController: controller },
|
||||
emitter,
|
||||
sessionOptions: {},
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(typeof releaseSend, "function");
|
||||
|
||||
controller.abort();
|
||||
releaseSend();
|
||||
const result = await runPromise;
|
||||
|
||||
assert.deepEqual(result, {
|
||||
sessionId: "slow-connect-session",
|
||||
usedV2: true,
|
||||
});
|
||||
assert.deepEqual(session.sentMessages, ["must not run"]);
|
||||
assert.equal(session.interruptCalls, 1);
|
||||
assert.deepEqual(events, [{ k: "done" }]);
|
||||
});
|
||||
|
||||
test("runTurn treats an abort rejection while streaming as normal completion", async () => {
|
||||
let rejectStream;
|
||||
const session = fakeSession([], { sessionId: "stream-abort-session" });
|
||||
session.stream = async function* stream() {
|
||||
await new Promise((_resolve, reject) => {
|
||||
rejectStream = reject;
|
||||
});
|
||||
};
|
||||
session.interrupt = async () => {
|
||||
rejectStream?.(new Error("interrupted"));
|
||||
};
|
||||
const mgr = new CodebuddySessionManager({
|
||||
loadSdk: async () => ({
|
||||
unstable_v2_createSession: () => session,
|
||||
unstable_v2_resumeSession: () => session,
|
||||
}),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const { events, emitter } = collector();
|
||||
|
||||
const runPromise = mgr.runTurn({
|
||||
sessionKey: "stream-abort-key",
|
||||
prompt: "wait",
|
||||
attachments: [],
|
||||
options: { abortController: controller },
|
||||
emitter,
|
||||
sessionOptions: {},
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
controller.abort();
|
||||
|
||||
assert.deepEqual(await runPromise, {
|
||||
sessionId: "stream-abort-session",
|
||||
usedV2: true,
|
||||
});
|
||||
assert.ok(mgr.sessions.has("stream-abort-key"));
|
||||
assert.equal(session.closed, false);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "sessionId", s: "stream-abort-session" },
|
||||
{ k: "done" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("steer returns unsupported when no session exists", async () => {
|
||||
const mgr = new CodebuddySessionManager();
|
||||
const { emitter } = collector();
|
||||
const result = await mgr.steer({
|
||||
sessionKey: "nonexistent",
|
||||
prompt: "follow up",
|
||||
attachments: [],
|
||||
emitter,
|
||||
});
|
||||
assert.deepEqual(result, { status: "unsupported" });
|
||||
});
|
||||
|
||||
test("steer stays unsupported because Session.send resets the active SDK stream", async () => {
|
||||
const mgr = new CodebuddySessionManager();
|
||||
const session = fakeSession([]);
|
||||
mgr.sessions.set("steer-key", {
|
||||
session,
|
||||
fingerprint: computeOptionsFingerprint({}),
|
||||
});
|
||||
|
||||
const result = await mgr.steer({
|
||||
sessionKey: "steer-key",
|
||||
prompt: "now do this",
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { status: "unsupported" });
|
||||
assert.deepEqual(session.sentMessages, []);
|
||||
});
|
||||
|
||||
test("closeSession removes and closes the session", () => {
|
||||
const mgr = new CodebuddySessionManager();
|
||||
const session = fakeSession([]);
|
||||
mgr.sessions.set("close-key", { session, fingerprint: null });
|
||||
|
||||
mgr.closeSession("close-key");
|
||||
assert.ok(!mgr.sessions.has("close-key"));
|
||||
assert.ok(session.closed);
|
||||
});
|
||||
|
||||
test("closeForChat closes all sessions matching the chat prefix", () => {
|
||||
const mgr = new CodebuddySessionManager();
|
||||
const s1 = fakeSession([]);
|
||||
const s2 = fakeSession([]);
|
||||
const s3 = fakeSession([]);
|
||||
mgr.sessions.set("chat1\u0000codebuddy\u0000/bin/cb\u0000sdk", { session: s1, fingerprint: null });
|
||||
mgr.sessions.set("chat1\u0000codebuddy\u0000/other/cb\u0000sdk", { session: s2, fingerprint: null });
|
||||
mgr.sessions.set("chat2\u0000codebuddy\u0000/bin/cb\u0000sdk", { session: s3, fingerprint: null });
|
||||
|
||||
mgr.closeForChat("chat1");
|
||||
assert.ok(!mgr.sessions.has("chat1\u0000codebuddy\u0000/bin/cb\u0000sdk"));
|
||||
assert.ok(!mgr.sessions.has("chat1\u0000codebuddy\u0000/other/cb\u0000sdk"));
|
||||
assert.ok(mgr.sessions.has("chat2\u0000codebuddy\u0000/bin/cb\u0000sdk"));
|
||||
assert.ok(s1.closed);
|
||||
assert.ok(s2.closed);
|
||||
assert.ok(!s3.closed);
|
||||
});
|
||||
|
||||
test("closeForChat cancels pending elicitations scoped to the chat", () => {
|
||||
const mgr = new CodebuddySessionManager();
|
||||
const resolved = [];
|
||||
mgr.elicitationPending.set("el-chat1", {
|
||||
resolve: (v) => resolved.push(["el-chat1", v]),
|
||||
reject: () => {},
|
||||
chatSessionId: "chat1",
|
||||
});
|
||||
mgr.elicitationPending.set("el-chat2", {
|
||||
resolve: (v) => resolved.push(["el-chat2", v]),
|
||||
reject: () => {},
|
||||
chatSessionId: "chat2",
|
||||
});
|
||||
|
||||
mgr.closeForChat("chat1");
|
||||
|
||||
assert.deepEqual(resolved, [["el-chat1", { action: "cancel" }]]);
|
||||
assert.ok(!mgr.elicitationPending.has("el-chat1"));
|
||||
assert.ok(mgr.elicitationPending.has("el-chat2"));
|
||||
});
|
||||
|
||||
test("closeAll closes every session", () => {
|
||||
const mgr = new CodebuddySessionManager();
|
||||
const s1 = fakeSession([]);
|
||||
const s2 = fakeSession([]);
|
||||
mgr.sessions.set("a", { session: s1, fingerprint: null });
|
||||
mgr.sessions.set("b", { session: s2, fingerprint: null });
|
||||
|
||||
mgr.closeAll();
|
||||
assert.equal(mgr.sessions.size, 0);
|
||||
assert.ok(s1.closed);
|
||||
assert.ok(s2.closed);
|
||||
});
|
||||
|
||||
test("closeAll cancels every pending elicitation", () => {
|
||||
const mgr = new CodebuddySessionManager();
|
||||
const resolved = [];
|
||||
mgr.elicitationPending.set("el-a", {
|
||||
resolve: (v) => resolved.push(["el-a", v]),
|
||||
reject: () => {},
|
||||
chatSessionId: "chat1",
|
||||
});
|
||||
mgr.elicitationPending.set("el-b", {
|
||||
resolve: (v) => resolved.push(["el-b", v]),
|
||||
reject: () => {},
|
||||
chatSessionId: "chat2",
|
||||
});
|
||||
|
||||
mgr.closeAll();
|
||||
|
||||
assert.equal(mgr.elicitationPending.size, 0);
|
||||
assert.deepEqual(resolved, [
|
||||
["el-a", { action: "cancel" }],
|
||||
["el-b", { action: "cancel" }],
|
||||
]);
|
||||
});
|
||||
|
||||
test("setModel returns false when session does not exist", async () => {
|
||||
const mgr = new CodebuddySessionManager();
|
||||
const result = await mgr.setModel("missing", "new-model");
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
test("setModel delegates to the session", async () => {
|
||||
const mgr = new CodebuddySessionManager();
|
||||
const session = fakeSession([]);
|
||||
mgr.sessions.set("model-key", { session, fingerprint: null });
|
||||
|
||||
const result = await mgr.setModel("model-key", "glm-5");
|
||||
assert.equal(result, true);
|
||||
assert.equal(session._model, "glm-5");
|
||||
});
|
||||
|
||||
test("resolveElicitation resolves pending and returns true", () => {
|
||||
const mgr = new CodebuddySessionManager();
|
||||
let resolved;
|
||||
mgr.elicitationPending.set("el-1", {
|
||||
resolve: (v) => { resolved = v; },
|
||||
reject: () => {},
|
||||
});
|
||||
|
||||
const ok = mgr.resolveElicitation("el-1", { action: "accept" });
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(resolved, { action: "accept" });
|
||||
assert.ok(!mgr.elicitationPending.has("el-1"));
|
||||
});
|
||||
|
||||
test("resolveElicitation returns false for unknown id", () => {
|
||||
const mgr = new CodebuddySessionManager();
|
||||
const ok = mgr.resolveElicitation("unknown", { action: "cancel" });
|
||||
assert.equal(ok, false);
|
||||
});
|
||||
418
electron/bridges/aiBridge/sdk/codexDriver.cjs
Normal file
418
electron/bridges/aiBridge/sdk/codexDriver.cjs
Normal file
@@ -0,0 +1,418 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Codex backend driver — wraps @openai/codex-sdk.
|
||||
*
|
||||
* new Codex({ codexPathOverride, env, apiKey, config }).startThread({...}).runStreamed(...)
|
||||
* - sandbox:'read-only' blocks local writes; side effects must go through the
|
||||
* injected netcatty MCP server (config.mcp_servers).
|
||||
* - thread.id is the resumable session id; codex.resumeThread(id) continues it.
|
||||
*
|
||||
* Constructor/event field names are calibrated against @openai/codex-sdk's type
|
||||
* defs (CodexOptions.codexPathOverride; AgentMessageItem / CommandExecutionItem /
|
||||
* McpToolCallItem). `env` is also passed so the binary resolves on PATH. Live
|
||||
* smoke confirms end-to-end behavior.
|
||||
*/
|
||||
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
|
||||
|
||||
function isImageAttachment(attachment) {
|
||||
return Boolean(
|
||||
attachment &&
|
||||
typeof attachment.filePath === "string" &&
|
||||
attachment.filePath.length > 0 &&
|
||||
String(attachment.mediaType || "").toLowerCase().startsWith("image/"),
|
||||
);
|
||||
}
|
||||
|
||||
function buildCodexPromptInput(prompt, attachments) {
|
||||
const imageAttachments = Array.isArray(attachments)
|
||||
? attachments.filter(isImageAttachment)
|
||||
: [];
|
||||
if (imageAttachments.length === 0) return String(prompt || "");
|
||||
|
||||
return [
|
||||
{ type: "text", text: String(prompt || "") },
|
||||
...imageAttachments.map((attachment) => ({
|
||||
type: "local_image",
|
||||
path: attachment.filePath,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function toCodexMcpConfig(injectedMcpServers, { defaultToolsApprovalMode } = {}) {
|
||||
const mcp_servers = {};
|
||||
for (const cfg of injectedMcpServers || []) {
|
||||
if (!cfg || !cfg.name) continue;
|
||||
mcp_servers[cfg.name] = {
|
||||
command: cfg.command,
|
||||
args: cfg.args || [],
|
||||
env: mcpEnvPairsToObject(cfg.env),
|
||||
...(defaultToolsApprovalMode
|
||||
? { default_tools_approval_mode: defaultToolsApprovalMode }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return mcp_servers;
|
||||
}
|
||||
|
||||
function buildCodexConstructorOptions({ codexPath, env, apiKey, injectedMcpServers, baseUrl }) {
|
||||
const options = {
|
||||
env,
|
||||
config: {
|
||||
mcp_servers: toCodexMcpConfig(injectedMcpServers),
|
||||
// Force codex to emit reasoning SUMMARY items in the JSON stream. The
|
||||
// default ("auto") emits nothing in non-interactive `codex exec` (measured:
|
||||
// 0 summaries across runs), so the thinking panel went empty after the SDK
|
||||
// migration. "concise" restores visible step-by-step reasoning reliably
|
||||
// (measured: a summary on every reasoning turn) at the right altitude for a
|
||||
// terminal assistant — "detailed" is richer but noisier and less reliable.
|
||||
model_reasoning_summary: "concise",
|
||||
},
|
||||
};
|
||||
if (codexPath) options.codexPathOverride = codexPath; // 🔬 SMOKE-CALIBRATE [codex-path]
|
||||
if (apiKey) options.apiKey = apiKey;
|
||||
if (baseUrl) options.baseUrl = baseUrl;
|
||||
return options;
|
||||
}
|
||||
|
||||
// codex-sdk reasoning-effort levels (GPT-5.6 also advertises max/ultra).
|
||||
const CODEX_REASONING_EFFORTS = new Set([
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"ultra",
|
||||
]);
|
||||
|
||||
function parseCodexModelSelection(model) {
|
||||
const value = String(model || "");
|
||||
const slash = value.lastIndexOf("/");
|
||||
const effort = slash > 0 ? value.slice(slash + 1) : "";
|
||||
if (slash > 0 && CODEX_REASONING_EFFORTS.has(effort)) {
|
||||
return { model: value.slice(0, slash), effort };
|
||||
}
|
||||
return { model: value || undefined, effort: undefined };
|
||||
}
|
||||
|
||||
function buildCodexThreadOptions({ cwd, model }) {
|
||||
// model + sandboxMode + workingDirectory belong to ThreadOptions (startThread).
|
||||
// runStreamed's TurnOptions only accepts { outputSchema, signal }, so passing
|
||||
// them there (the previous behavior) silently dropped both model selection and
|
||||
// the read-only sandbox.
|
||||
//
|
||||
// Non-interactive `codex exec` CANCELS every MCP tool call ("user cancelled
|
||||
// MCP tool call", failing in 0ns before the server is even invoked) unless
|
||||
// approvals are fully bypassed. Empirically (tested across all sandbox ×
|
||||
// approval combos) the ONLY combo that lets injected netcatty MCP tools run is
|
||||
// sandbox "danger-full-access" + approvalPolicy "never" — i.e. codex's
|
||||
// `--dangerously-bypass-approvals-and-sandbox`. read-only and workspace-write
|
||||
// both cancel under every approval policy, because codex wants an interactive
|
||||
// approver for MCP calls and exec has no channel to answer one.
|
||||
//
|
||||
// Safe for netcatty's model: the REAL guardrails (approval prompts, command
|
||||
// blocklist, observer/confirm permission modes, session scope) are enforced by
|
||||
// the injected netcatty MCP server on every remote-host action — NOT by codex's
|
||||
// local sandbox. claude blocks its built-in side-effect tools via
|
||||
// disallowedTools and copilot is MCP-only; codex-sdk exposes no tool-disable
|
||||
// switch, so the sandbox is the only lever and it has to be fully open for the
|
||||
// MCP path to work at all.
|
||||
const opts = { sandboxMode: "danger-full-access", approvalPolicy: "never", skipGitRepoCheck: true };
|
||||
if (cwd) opts.workingDirectory = cwd;
|
||||
if (model) {
|
||||
// The renderer encodes codex reasoning effort as "<modelId>/<effort>"
|
||||
// (e.g. "gpt-5.5/high"). codex-sdk wants them as separate ThreadOptions.
|
||||
// Only split when the trailing segment is a real effort — custom/OpenRouter
|
||||
// model ids may legitimately contain "/".
|
||||
const selection = parseCodexModelSelection(model);
|
||||
opts.model = selection.model;
|
||||
if (selection.effort) opts.modelReasoningEffort = selection.effort;
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a display string from a Codex mcp_tool_call item.
|
||||
* Calibrated against @openai/codex-sdk McpToolCallItem: successful calls carry
|
||||
* `result.content` as an MCP ContentBlock[] (text blocks); failures carry
|
||||
* `error.message`.
|
||||
*/
|
||||
function extractMcpResultText(item) {
|
||||
if (item.error && item.error.message) return String(item.error.message);
|
||||
const content = item.result && item.result.content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((b) => (b && typeof b.text === "string" ? b.text : (b == null ? "" : JSON.stringify(b))))
|
||||
.join("");
|
||||
}
|
||||
if (item.result != null) return JSON.stringify(item.result);
|
||||
return "";
|
||||
}
|
||||
|
||||
function ensureStateSet(state, key) {
|
||||
if (!state[key]) state[key] = new Set();
|
||||
return state[key];
|
||||
}
|
||||
|
||||
function ensureStateMap(state, key) {
|
||||
if (!state[key]) state[key] = new Map();
|
||||
return state[key];
|
||||
}
|
||||
|
||||
function emitCodexReasoning(item, emitter, state) {
|
||||
if (!item || typeof item.text !== "string" || !item.text) return;
|
||||
const textById = ensureStateMap(state, "reasoningTextById");
|
||||
const itemId = item.id || "__default_reasoning";
|
||||
const previous = textById.get(itemId) || "";
|
||||
const delta = item.text.startsWith(previous) ? item.text.slice(previous.length) : item.text;
|
||||
textById.set(itemId, item.text);
|
||||
if (delta) {
|
||||
emitter.reasoning(delta);
|
||||
state.reasoningOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
function emitCodexToolCallOnce(item, emitter, state, toolName, args) {
|
||||
if (!item || !item.id) return false;
|
||||
const emittedToolCalls = ensureStateSet(state, "emittedToolCalls");
|
||||
if (emittedToolCalls.has(item.id)) return false;
|
||||
emittedToolCalls.add(item.id);
|
||||
emitter.toolCall(toolName, args || {}, item.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
function emitCodexToolResultOnce(item, emitter, state, output, toolName) {
|
||||
if (!item || !item.id) return false;
|
||||
const emittedToolResults = ensureStateSet(state, "emittedToolResults");
|
||||
if (emittedToolResults.has(item.id)) return false;
|
||||
emittedToolResults.add(item.id);
|
||||
emitter.toolResult(item.id, output || "", toolName);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex emits mid-turn `type:"error"` JSONL events while it reconnects after a
|
||||
* dropped SSE/response body (`Reconnecting...`, `retrying N/M`). Those are
|
||||
* recoverable — the same turn keeps producing items afterward. Treating them
|
||||
* as fatal settles the Netcatty sidebar turn and stops UI refresh while the CLI
|
||||
* process continues (issue #2456).
|
||||
*
|
||||
* Explicit `willRetry: false` / `will_retry: false` means Codex exhausted its
|
||||
* retry budget — always fatal, even when the message still mentions stream /
|
||||
* transport wording. Truly terminal failures also arrive as `turn.failed`.
|
||||
*/
|
||||
function isCodexRetryableStreamError(event) {
|
||||
if (!event || typeof event !== "object") return false;
|
||||
if (event.willRetry === false || event.will_retry === false) return false;
|
||||
if (event.willRetry === true || event.will_retry === true) return true;
|
||||
const message = String(event.message || "").toLowerCase();
|
||||
if (!message) return false;
|
||||
return /\breconnecting\b/.test(message) || /\bretrying\b/.test(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one Codex ThreadEvent into emitter calls.
|
||||
* `state` ({ reasoningOpen }) is threaded across events so reasoning summary
|
||||
* items render as a single collapsible thinking panel that closes when the first
|
||||
* non-reasoning content (assistant message / tool call) arrives.
|
||||
*/
|
||||
function translateCodexEvent(event, emitter, state) {
|
||||
if (!event || typeof event !== "object") return;
|
||||
const st = state || {};
|
||||
const closeReasoning = () => {
|
||||
if (st.reasoningOpen) { emitter.reasoningEnd(); st.reasoningOpen = false; }
|
||||
};
|
||||
|
||||
if (event.type === "turn.failed") {
|
||||
closeReasoning();
|
||||
st.fatalError = true;
|
||||
emitter.emitError(event.error?.message || "Codex turn failed");
|
||||
return;
|
||||
}
|
||||
if (event.type === "error") {
|
||||
const message = event.message || "Codex stream failed";
|
||||
if (isCodexRetryableStreamError(event)) {
|
||||
// Keep reasoning open — the turn is still in progress after Codex retries.
|
||||
const warningCount = (st.streamWarningCount = (st.streamWarningCount || 0) + 1);
|
||||
emitter.warning(`codex-stream-error:${warningCount}`, message);
|
||||
return;
|
||||
}
|
||||
closeReasoning();
|
||||
st.fatalError = true;
|
||||
emitter.emitError(message);
|
||||
return;
|
||||
}
|
||||
if (event.type === "turn.completed") {
|
||||
const usage = event.usage;
|
||||
const hasUsage = usage && [
|
||||
usage.input_tokens,
|
||||
usage.cached_input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.reasoning_output_tokens,
|
||||
].some((value) => Number.isFinite(value));
|
||||
if (!hasUsage) return;
|
||||
const inputTokens = Number(usage.input_tokens) || 0;
|
||||
const outputTokens = Number(usage.output_tokens) || 0;
|
||||
emitter.usage({
|
||||
inputTokens,
|
||||
cachedInputTokens: Number(usage.cached_input_tokens) || 0,
|
||||
outputTokens,
|
||||
reasoningTokens: Number(usage.reasoning_output_tokens) || 0,
|
||||
totalTokens: inputTokens + outputTokens,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!["item.started", "item.updated", "item.completed"].includes(event.type) || !event.item) return;
|
||||
|
||||
const item = event.item;
|
||||
|
||||
// Reasoning summary items feed the thinking panel. Codex may update the same
|
||||
// item with cumulative text before completion, so emit only the new suffix.
|
||||
if (item.type === "reasoning") {
|
||||
emitCodexReasoning(item, emitter, st);
|
||||
return;
|
||||
}
|
||||
|
||||
closeReasoning();
|
||||
|
||||
switch (item.type) {
|
||||
case "agent_message":
|
||||
if (event.type === "item.completed" && item.text) emitter.text(item.text);
|
||||
return;
|
||||
case "command_execution": {
|
||||
// Calibrated against @openai/codex-sdk CommandExecutionItem (command +
|
||||
// aggregated_output).
|
||||
emitCodexToolCallOnce(item, emitter, st, "shell", { command: item.command || "" });
|
||||
if (event.type === "item.completed" && item.aggregated_output) {
|
||||
emitCodexToolResultOnce(item, emitter, st, item.aggregated_output, "shell");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "mcp_tool_call": {
|
||||
// Calibrated against @openai/codex-sdk McpToolCallItem (tool + arguments;
|
||||
// result.content is an MCP ContentBlock[], errors carry .message).
|
||||
const toolName = item.tool || "mcp_tool";
|
||||
emitCodexToolCallOnce(item, emitter, st, toolName, item.arguments || {});
|
||||
if (event.type === "item.completed") {
|
||||
emitCodexToolResultOnce(item, emitter, st, extractMcpResultText(item), toolName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "file_change":
|
||||
if (event.type === "item.completed") {
|
||||
emitter.fileChange(
|
||||
item.id,
|
||||
Array.isArray(item.changes) ? item.changes : [],
|
||||
item.status === "failed" ? "failed" : "completed",
|
||||
);
|
||||
}
|
||||
return;
|
||||
case "web_search":
|
||||
emitter.webSearch(
|
||||
item.id,
|
||||
item.query || "",
|
||||
event.type === "item.completed" ? "completed" : "running",
|
||||
);
|
||||
return;
|
||||
case "todo_list":
|
||||
emitter.planUpdate(
|
||||
item.id,
|
||||
Array.isArray(item.items) ? item.items : [],
|
||||
event.type === "item.completed" ? "completed" : "running",
|
||||
);
|
||||
return;
|
||||
case "error":
|
||||
if (event.type === "item.completed") {
|
||||
emitter.warning(item.id, item.message || "Codex reported a recoverable error");
|
||||
}
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a Codex turn.
|
||||
* @param {object} args
|
||||
* @param {string} args.prompt
|
||||
* @param {Array<object>} [args.attachments]
|
||||
* @param {object} args.constructorOptions buildCodexConstructorOptions(...)
|
||||
* @param {object} args.threadOptions buildCodexThreadOptions(...) — model / sandboxMode / workingDirectory
|
||||
* @param {string} [args.resumeThreadId]
|
||||
* @param {object} args.emitter
|
||||
* @param {AbortSignal} [args.signal]
|
||||
* @param {Function} [args.CodexCtor] inject Codex class (for tests)
|
||||
*/
|
||||
async function runCodexTurn({
|
||||
prompt, attachments, constructorOptions, threadOptions, resumeThreadId, emitter, signal, CodexCtor,
|
||||
}) {
|
||||
const Codex = CodexCtor || (await import("@openai/codex-sdk")).Codex;
|
||||
const promptInput = buildCodexPromptInput(prompt, attachments);
|
||||
let threadId = null;
|
||||
try {
|
||||
const codex = new Codex(constructorOptions);
|
||||
// ThreadOptions (model + read-only sandbox + cwd) must be applied on resume too.
|
||||
const thread = resumeThreadId
|
||||
? codex.resumeThread(resumeThreadId, threadOptions)
|
||||
: codex.startThread(threadOptions);
|
||||
|
||||
const { events } = await thread.runStreamed(promptInput, signal ? { signal } : undefined);
|
||||
let hasContent = false;
|
||||
const state = { reasoningOpen: false };
|
||||
for await (const event of events) {
|
||||
// Capture + emit the resumable thread id as EARLY as possible — it exists
|
||||
// the moment `thread.started` arrives (the first event). Emitting it only at
|
||||
// the END of the turn (the old behavior) meant a mid-turn Stop never
|
||||
// persisted it, so the NEXT turn opened a fresh thread and the whole session
|
||||
// lost its memory. Verified: codex resume survives an aborted turn, so
|
||||
// preserving the id is enough to keep context across a Stop.
|
||||
if (!threadId) {
|
||||
const tid = thread.id || (event && event.type === "thread.started" ? event.thread_id : null);
|
||||
if (tid) { threadId = tid; emitter.sessionId(threadId); }
|
||||
}
|
||||
if (signal?.aborted) break;
|
||||
if (event?.type === "item.completed") hasContent = true;
|
||||
translateCodexEvent(event, emitter, state);
|
||||
if (state.fatalError) break;
|
||||
}
|
||||
if (state.reasoningOpen) emitter.reasoningEnd();
|
||||
if (!threadId) {
|
||||
threadId = thread.id || resumeThreadId || null;
|
||||
if (threadId) emitter.sessionId(threadId);
|
||||
}
|
||||
if (state.fatalError) {
|
||||
return { threadId };
|
||||
}
|
||||
if (!hasContent && !signal?.aborted) {
|
||||
emitter.emitError(
|
||||
"Codex returned an empty response. Reconnect Codex in Settings -> AI (codex login), " +
|
||||
"or configure a provider in ~/.codex/config.toml.",
|
||||
);
|
||||
return { threadId };
|
||||
}
|
||||
emitter.emitDone();
|
||||
return { threadId };
|
||||
} catch (error) {
|
||||
const code = error && error.code;
|
||||
const msg = String((error && error.message) || error || "");
|
||||
if (code === "ENOENT" || /ENOENT/i.test(msg)) {
|
||||
emitter.emitError(
|
||||
"Codex binary not found. Install with `npm i -g @openai/codex` (or `brew install --cask codex`).",
|
||||
);
|
||||
} else {
|
||||
emitter.emitError(msg || "Codex turn failed");
|
||||
}
|
||||
return { threadId };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildCodexConstructorOptions,
|
||||
buildCodexThreadOptions,
|
||||
buildCodexPromptInput,
|
||||
parseCodexModelSelection,
|
||||
translateCodexEvent,
|
||||
runCodexTurn,
|
||||
toCodexMcpConfig,
|
||||
};
|
||||
535
electron/bridges/aiBridge/sdk/codexDriver.test.cjs
Normal file
535
electron/bridges/aiBridge/sdk/codexDriver.test.cjs
Normal file
@@ -0,0 +1,535 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
translateCodexEvent,
|
||||
buildCodexConstructorOptions,
|
||||
buildCodexThreadOptions,
|
||||
buildCodexPromptInput,
|
||||
runCodexTurn,
|
||||
toCodexMcpConfig,
|
||||
} = require("./codexDriver.cjs");
|
||||
|
||||
function collector() {
|
||||
const events = [];
|
||||
return {
|
||||
events,
|
||||
emitter: {
|
||||
text: (t) => events.push({ k: "text", t }),
|
||||
reasoning: (d) => events.push({ k: "reasoning", d }),
|
||||
reasoningEnd: () => events.push({ k: "reasoningEnd" }),
|
||||
toolCall: (n, a, id) => events.push({ k: "toolCall", n, a, id }),
|
||||
toolResult: (id, o, n) => events.push({ k: "toolResult", id, o, n }),
|
||||
fileChange: (id, changes, status) => events.push({ k: "fileChange", id, changes, status }),
|
||||
webSearch: (id, query, status) => events.push({ k: "webSearch", id, query, status }),
|
||||
planUpdate: (id, items, status) => events.push({ k: "planUpdate", id, items, status }),
|
||||
warning: (id, message) => events.push({ k: "warning", id, message }),
|
||||
usage: (usage) => events.push({ k: "usage", usage }),
|
||||
status: (m) => events.push({ k: "status", m }),
|
||||
sessionId: (s) => events.push({ k: "sessionId", s }),
|
||||
emitError: (e) => events.push({ k: "error", e }),
|
||||
emitDone: () => events.push({ k: "done" }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("agent_message item -> text event", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateCodexEvent({ type: "item.completed", item: { type: "agent_message", text: "answer" } }, emitter);
|
||||
assert.deepEqual(events, [{ k: "text", t: "answer" }]);
|
||||
});
|
||||
|
||||
test("reasoning item -> reasoning event (thinking panel), not plain text", () => {
|
||||
const { events, emitter } = collector();
|
||||
const state = { reasoningOpen: false };
|
||||
translateCodexEvent({ type: "item.completed", item: { type: "reasoning", text: "**Plan**" } }, emitter, state);
|
||||
assert.deepEqual(events, [{ k: "reasoning", d: "**Plan**" }]);
|
||||
assert.equal(state.reasoningOpen, true);
|
||||
});
|
||||
|
||||
test("reasoning then agent_message -> reasoning, reasoningEnd, text (block closes on content)", () => {
|
||||
const { events, emitter } = collector();
|
||||
const state = { reasoningOpen: false };
|
||||
translateCodexEvent({ type: "item.completed", item: { type: "reasoning", text: "step 1" } }, emitter, state);
|
||||
translateCodexEvent({ type: "item.completed", item: { type: "reasoning", text: "step 2" } }, emitter, state);
|
||||
translateCodexEvent({ type: "item.completed", item: { type: "agent_message", text: "done" } }, emitter, state);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "reasoning", d: "step 1" },
|
||||
{ k: "reasoning", d: "step 2" },
|
||||
{ k: "reasoningEnd" },
|
||||
{ k: "text", t: "done" },
|
||||
]);
|
||||
assert.equal(state.reasoningOpen, false);
|
||||
});
|
||||
|
||||
test("reasoning item updates stream only new thinking text", () => {
|
||||
const { events, emitter } = collector();
|
||||
const state = { reasoningOpen: false };
|
||||
const item = { id: "r-1", type: "reasoning" };
|
||||
translateCodexEvent({ type: "item.started", item: { ...item, text: "step 1" } }, emitter, state);
|
||||
translateCodexEvent({ type: "item.updated", item: { ...item, text: "step 1\nstep 2" } }, emitter, state);
|
||||
translateCodexEvent({ type: "item.completed", item: { ...item, text: "step 1\nstep 2" } }, emitter, state);
|
||||
translateCodexEvent({ type: "item.completed", item: { type: "agent_message", text: "done" } }, emitter, state);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "reasoning", d: "step 1" },
|
||||
{ k: "reasoning", d: "\nstep 2" },
|
||||
{ k: "reasoningEnd" },
|
||||
{ k: "text", t: "done" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("mcp_tool_call item -> toolCall + toolResult events (extracts content text)", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateCodexEvent(
|
||||
{
|
||||
type: "item.completed",
|
||||
item: {
|
||||
type: "mcp_tool_call", id: "i-1",
|
||||
server: "netcatty-remote-hosts", tool: "terminal_execute",
|
||||
arguments: { command: "ls" },
|
||||
result: { content: [{ type: "text", text: "files" }] },
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
emitter,
|
||||
);
|
||||
assert.deepEqual(events.map((e) => e.k), ["toolCall", "toolResult"]);
|
||||
assert.equal(events[0].id, "i-1");
|
||||
assert.equal(events[0].n, "terminal_execute");
|
||||
assert.equal(events[1].o, "files");
|
||||
});
|
||||
|
||||
test("mcp_tool_call streams start early and completes without duplicate tool cards", () => {
|
||||
const { events, emitter } = collector();
|
||||
const state = { reasoningOpen: false };
|
||||
const item = {
|
||||
type: "mcp_tool_call", id: "i-live",
|
||||
server: "netcatty-remote-hosts", tool: "terminal_execute",
|
||||
arguments: { command: "uptime" },
|
||||
};
|
||||
translateCodexEvent({ type: "item.started", item: { ...item, status: "in_progress" } }, emitter, state);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "toolCall", n: "terminal_execute", a: { command: "uptime" }, id: "i-live" },
|
||||
]);
|
||||
translateCodexEvent({ type: "item.updated", item: { ...item, status: "in_progress" } }, emitter, state);
|
||||
translateCodexEvent(
|
||||
{
|
||||
type: "item.completed",
|
||||
item: {
|
||||
...item,
|
||||
result: { content: [{ type: "text", text: "up 1 day" }] },
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
emitter,
|
||||
state,
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "toolCall", n: "terminal_execute", a: { command: "uptime" }, id: "i-live" },
|
||||
{ k: "toolResult", id: "i-live", o: "up 1 day", n: "terminal_execute" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("command_execution streams start early and completes without duplicate tool cards", () => {
|
||||
const { events, emitter } = collector();
|
||||
const state = { reasoningOpen: false };
|
||||
const item = { type: "command_execution", id: "cmd-live", command: "pwd" };
|
||||
translateCodexEvent({ type: "item.started", item: { ...item, status: "in_progress", aggregated_output: "" } }, emitter, state);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "toolCall", n: "shell", a: { command: "pwd" }, id: "cmd-live" },
|
||||
]);
|
||||
translateCodexEvent({ type: "item.updated", item: { ...item, status: "in_progress", aggregated_output: "/tmp" } }, emitter, state);
|
||||
translateCodexEvent({ type: "item.completed", item: { ...item, status: "completed", aggregated_output: "/tmp\n" } }, emitter, state);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "toolCall", n: "shell", a: { command: "pwd" }, id: "cmd-live" },
|
||||
{ k: "toolResult", id: "cmd-live", o: "/tmp\n", n: "shell" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("mcp_tool_call failure -> toolResult carries the error message", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateCodexEvent(
|
||||
{
|
||||
type: "item.completed",
|
||||
item: {
|
||||
type: "mcp_tool_call", id: "i-2",
|
||||
server: "netcatty-remote-hosts", tool: "terminal_execute",
|
||||
arguments: {}, error: { message: "denied by observer" }, status: "failed",
|
||||
},
|
||||
},
|
||||
emitter,
|
||||
);
|
||||
assert.equal(events[1].o, "denied by observer");
|
||||
});
|
||||
|
||||
test("turn.failed -> error event", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateCodexEvent({ type: "turn.failed", error: { message: "stale login" } }, emitter);
|
||||
assert.deepEqual(events, [{ k: "error", e: "stale login" }]);
|
||||
});
|
||||
|
||||
test("turn.completed emits actual token usage", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateCodexEvent({
|
||||
type: "turn.completed",
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
cached_input_tokens: 40,
|
||||
output_tokens: 25,
|
||||
reasoning_output_tokens: 10,
|
||||
},
|
||||
}, emitter);
|
||||
assert.deepEqual(events, [{
|
||||
k: "usage",
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
cachedInputTokens: 40,
|
||||
outputTokens: 25,
|
||||
reasoningTokens: 10,
|
||||
totalTokens: 125,
|
||||
},
|
||||
}]);
|
||||
});
|
||||
|
||||
test("turn.completed without usage preserves the estimated fallback", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateCodexEvent({ type: "turn.completed", usage: {} }, emitter);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test("file changes emit once on completion", () => {
|
||||
const { events, emitter } = collector();
|
||||
const item = {
|
||||
id: "patch-1",
|
||||
type: "file_change",
|
||||
changes: [{ path: "src/app.ts", kind: "update" }],
|
||||
status: "completed",
|
||||
};
|
||||
translateCodexEvent({ type: "item.started", item }, emitter);
|
||||
translateCodexEvent({ type: "item.completed", item }, emitter);
|
||||
assert.deepEqual(events, [{
|
||||
k: "fileChange",
|
||||
id: "patch-1",
|
||||
changes: item.changes,
|
||||
status: "completed",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("web search and todo list updates keep stable item ids", () => {
|
||||
const { events, emitter } = collector();
|
||||
translateCodexEvent({
|
||||
type: "item.started",
|
||||
item: { id: "search-1", type: "web_search", query: "Codex SDK events" },
|
||||
}, emitter);
|
||||
translateCodexEvent({
|
||||
type: "item.completed",
|
||||
item: { id: "search-1", type: "web_search", query: "Codex SDK events" },
|
||||
}, emitter);
|
||||
translateCodexEvent({
|
||||
type: "item.updated",
|
||||
item: {
|
||||
id: "plan-1",
|
||||
type: "todo_list",
|
||||
items: [{ text: "Map events", completed: false }],
|
||||
},
|
||||
}, emitter);
|
||||
translateCodexEvent({
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "plan-1",
|
||||
type: "todo_list",
|
||||
items: [{ text: "Map events", completed: true }],
|
||||
},
|
||||
}, emitter);
|
||||
assert.deepEqual(events.map((event) => [event.k, event.id, event.status]), [
|
||||
["webSearch", "search-1", "running"],
|
||||
["webSearch", "search-1", "completed"],
|
||||
["planUpdate", "plan-1", "running"],
|
||||
["planUpdate", "plan-1", "completed"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("item errors and reconnectable stream errors are warnings; other stream errors stay fatal", () => {
|
||||
const { events, emitter } = collector();
|
||||
const state = {};
|
||||
translateCodexEvent({
|
||||
type: "item.completed",
|
||||
item: { id: "warning-1", type: "error", message: "Search result was unavailable" },
|
||||
}, emitter, state);
|
||||
translateCodexEvent({
|
||||
type: "error",
|
||||
message: "Reconnecting... 1/5 (stream disconnected before completion: Transport error: network error: error decoding response body)",
|
||||
}, emitter, state);
|
||||
translateCodexEvent({
|
||||
type: "error",
|
||||
message: "stream disconnected before completion: Transport error: error decoding response body; retrying 2/5 in 361ms…",
|
||||
}, emitter, state);
|
||||
translateCodexEvent({ type: "error", message: "stream disconnected", willRetry: true }, emitter, state);
|
||||
translateCodexEvent({ type: "error", message: "transport error", will_retry: true }, emitter, state);
|
||||
translateCodexEvent({ type: "error", message: "stream disconnected" }, emitter, state);
|
||||
translateCodexEvent({ type: "error", message: "error decoding response body" }, emitter, state);
|
||||
translateCodexEvent({ type: "error", message: "transport error" }, emitter, state);
|
||||
translateCodexEvent({
|
||||
type: "error",
|
||||
message: "Reconnecting... 5/5 (stream disconnected before completion: Transport error)",
|
||||
willRetry: false,
|
||||
}, emitter, state);
|
||||
translateCodexEvent({
|
||||
type: "error",
|
||||
message: "transport error; retrying 5/5 after retries exhausted",
|
||||
will_retry: false,
|
||||
}, emitter, state);
|
||||
translateCodexEvent({ type: "error", message: "not authenticated" }, emitter, state);
|
||||
assert.equal(events.filter((event) => event.k === "warning").length, 5);
|
||||
assert.deepEqual(events.filter((event) => event.k === "error"), [
|
||||
{ k: "error", e: "stream disconnected" },
|
||||
{ k: "error", e: "error decoding response body" },
|
||||
{ k: "error", e: "transport error" },
|
||||
{ k: "error", e: "Reconnecting... 5/5 (stream disconnected before completion: Transport error)" },
|
||||
{ k: "error", e: "transport error; retrying 5/5 after retries exhausted" },
|
||||
{ k: "error", e: "not authenticated" },
|
||||
]);
|
||||
assert.match(events[1].message, /Reconnecting|error decoding response body/);
|
||||
});
|
||||
|
||||
test("explicit non-retryable stream disconnect fails the turn even after partial content", async () => {
|
||||
const { events, emitter } = collector();
|
||||
class FakeCodex {
|
||||
startThread() {
|
||||
return {
|
||||
id: "thr-exhausted",
|
||||
async runStreamed() {
|
||||
return {
|
||||
events: (async function* () {
|
||||
yield { type: "thread.started", thread_id: "thr-exhausted" };
|
||||
yield {
|
||||
type: "item.completed",
|
||||
item: { type: "agent_message", text: "partial answer" },
|
||||
};
|
||||
yield {
|
||||
type: "error",
|
||||
message: "Reconnecting... 5/5 (stream disconnected before completion: Transport error)",
|
||||
willRetry: false,
|
||||
};
|
||||
})(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
resumeThread() { return this.startThread(); }
|
||||
}
|
||||
await runCodexTurn({
|
||||
prompt: "hi", constructorOptions: {}, threadOptions: {}, emitter, CodexCtor: FakeCodex,
|
||||
});
|
||||
assert.deepEqual(events.filter((event) => event.k === "text"), [{ k: "text", t: "partial answer" }]);
|
||||
assert.deepEqual(events.filter((event) => event.k === "error"), [
|
||||
{ k: "error", e: "Reconnecting... 5/5 (stream disconnected before completion: Transport error)" },
|
||||
]);
|
||||
assert.equal(events.some((event) => event.k === "done"), false);
|
||||
});
|
||||
|
||||
test("message-only transport failure fails the turn even after partial content", async () => {
|
||||
const { events, emitter } = collector();
|
||||
class FakeCodex {
|
||||
startThread() {
|
||||
return {
|
||||
id: "thr-disconnected",
|
||||
async runStreamed() {
|
||||
return {
|
||||
events: (async function* () {
|
||||
yield { type: "thread.started", thread_id: "thr-disconnected" };
|
||||
yield {
|
||||
type: "item.completed",
|
||||
item: { type: "agent_message", text: "partial answer" },
|
||||
};
|
||||
yield {
|
||||
type: "error",
|
||||
message: "stream disconnected before completion: Transport error",
|
||||
};
|
||||
})(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
resumeThread() { return this.startThread(); }
|
||||
}
|
||||
await runCodexTurn({
|
||||
prompt: "hi", constructorOptions: {}, threadOptions: {}, emitter, CodexCtor: FakeCodex,
|
||||
});
|
||||
assert.deepEqual(events.filter((event) => event.k === "text"), [{ k: "text", t: "partial answer" }]);
|
||||
assert.deepEqual(events.filter((event) => event.k === "error"), [
|
||||
{ k: "error", e: "stream disconnected before completion: Transport error" },
|
||||
]);
|
||||
assert.equal(events.some((event) => event.k === "done"), false);
|
||||
});
|
||||
|
||||
test("reconnectable Codex stream errors keep the turn open for later output", async () => {
|
||||
const { events, emitter } = collector();
|
||||
class FakeCodex {
|
||||
startThread() {
|
||||
return {
|
||||
id: "thr-reconnect",
|
||||
async runStreamed() {
|
||||
return {
|
||||
events: (async function* () {
|
||||
yield { type: "thread.started", thread_id: "thr-reconnect" };
|
||||
yield {
|
||||
type: "error",
|
||||
message: "Reconnecting... 1/5 (stream disconnected before completion: error decoding response body)",
|
||||
};
|
||||
yield {
|
||||
type: "item.completed",
|
||||
item: { type: "agent_message", text: "recovered answer" },
|
||||
};
|
||||
})(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
resumeThread() { return this.startThread(); }
|
||||
}
|
||||
await runCodexTurn({
|
||||
prompt: "hi", constructorOptions: {}, threadOptions: {}, emitter, CodexCtor: FakeCodex,
|
||||
});
|
||||
assert.ok(events.some((event) => event.k === "warning" && /decoding response body|Reconnecting/.test(event.message)));
|
||||
assert.deepEqual(events.filter((event) => event.k === "text"), [{ k: "text", t: "recovered answer" }]);
|
||||
assert.ok(events.some((event) => event.k === "done"));
|
||||
assert.equal(events.some((event) => event.k === "error"), false);
|
||||
});
|
||||
|
||||
test("runCodexTurn captures+emits the thread id early so an aborted turn still resumes", async () => {
|
||||
// Simulate a Stop that kills the stream mid-turn: thread.started arrives, then
|
||||
// the event stream throws. The id must already be emitted (renderer) and
|
||||
// returned (handler) so the NEXT turn resumes this thread instead of starting
|
||||
// fresh (which is what made the whole session lose its memory after a Stop).
|
||||
const { events, emitter } = collector();
|
||||
class FakeCodex {
|
||||
startThread() {
|
||||
return {
|
||||
id: "thr-abc",
|
||||
async runStreamed() {
|
||||
return {
|
||||
events: (async function* () {
|
||||
yield { type: "thread.started", thread_id: "thr-abc" };
|
||||
throw new Error("stream aborted mid-turn");
|
||||
})(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
resumeThread() { return this.startThread(); }
|
||||
}
|
||||
const result = await runCodexTurn({
|
||||
prompt: "hi", constructorOptions: {}, threadOptions: {}, emitter, CodexCtor: FakeCodex,
|
||||
});
|
||||
assert.deepEqual(events.filter((e) => e.k === "sessionId"), [{ k: "sessionId", s: "thr-abc" }]);
|
||||
assert.equal(result.threadId, "thr-abc");
|
||||
});
|
||||
|
||||
test("buildCodexPromptInput sends image attachments as native local_image inputs", () => {
|
||||
const input = buildCodexPromptInput("describe this", [
|
||||
{ filename: "shot.png", mediaType: "image/png", filePath: "/tmp/shot.png", base64Data: "abc" },
|
||||
{ filename: "note.txt", mediaType: "text/plain", filePath: "/tmp/note.txt", base64Data: "def" },
|
||||
]);
|
||||
assert.deepEqual(input, [
|
||||
{ type: "text", text: "describe this" },
|
||||
{ type: "local_image", path: "/tmp/shot.png" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCodexTurn passes native image input to the SDK", async () => {
|
||||
const { emitter } = collector();
|
||||
let capturedInput = null;
|
||||
class FakeCodex {
|
||||
startThread() {
|
||||
return {
|
||||
id: "thr-img",
|
||||
async runStreamed(input) {
|
||||
capturedInput = input;
|
||||
return {
|
||||
events: (async function* () {
|
||||
yield { type: "thread.started", thread_id: "thr-img" };
|
||||
yield { type: "item.completed", item: { type: "agent_message", text: "ok" } };
|
||||
})(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
await runCodexTurn({
|
||||
prompt: "what is in this image",
|
||||
attachments: [{ mediaType: "image/png", filePath: "/tmp/a.png", base64Data: "abc" }],
|
||||
constructorOptions: {},
|
||||
threadOptions: {},
|
||||
emitter,
|
||||
CodexCtor: FakeCodex,
|
||||
});
|
||||
assert.deepEqual(capturedInput, [
|
||||
{ type: "text", text: "what is in this image" },
|
||||
{ type: "local_image", path: "/tmp/a.png" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildCodexConstructorOptions sets path override + env + mcp config table", () => {
|
||||
const opts = buildCodexConstructorOptions({
|
||||
codexPath: "/abs/codex",
|
||||
env: { PATH: "/usr/bin" },
|
||||
apiKey: undefined,
|
||||
injectedMcpServers: [{
|
||||
name: "netcatty-remote-hosts", command: "/abs/electron",
|
||||
args: ["/abs/server.cjs"], env: [{ name: "NETCATTY_MCP_PORT", value: "1" }],
|
||||
}],
|
||||
});
|
||||
assert.equal(opts.codexPathOverride, "/abs/codex");
|
||||
assert.equal(opts.env.PATH, "/usr/bin");
|
||||
assert.deepEqual(opts.config.mcp_servers["netcatty-remote-hosts"], {
|
||||
command: "/abs/electron", args: ["/abs/server.cjs"], env: { NETCATTY_MCP_PORT: "1" },
|
||||
});
|
||||
// request visible reasoning summaries (default "auto" emits none in exec mode)
|
||||
assert.equal(opts.config.model_reasoning_summary, "concise");
|
||||
});
|
||||
|
||||
test("toCodexMcpConfig can delegate MCP approval to the embedding client", () => {
|
||||
const config = toCodexMcpConfig([{
|
||||
name: "netcatty-remote-hosts",
|
||||
command: "/abs/electron",
|
||||
args: ["/abs/server.cjs"],
|
||||
env: [],
|
||||
}], { defaultToolsApprovalMode: "approve" });
|
||||
|
||||
assert.equal(
|
||||
config["netcatty-remote-hosts"].default_tools_approval_mode,
|
||||
"approve",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCodexThreadOptions enables MCP via danger-full-access + approvalPolicy never", () => {
|
||||
// codex-sdk: model/sandboxMode/workingDirectory are ThreadOptions (startThread),
|
||||
// not runStreamed TurnOptions. Non-interactive `codex exec` cancels MCP tool
|
||||
// calls under read-only/workspace-write (any approval policy); only the full
|
||||
// bypass (danger-full-access + never) lets injected netcatty MCP tools run.
|
||||
// Real guardrails live in the netcatty MCP server, not codex's local sandbox.
|
||||
const t = buildCodexThreadOptions({ cwd: "/tmp", model: "gpt-5.5" });
|
||||
assert.equal(t.sandboxMode, "danger-full-access");
|
||||
assert.equal(t.approvalPolicy, "never");
|
||||
assert.equal(t.workingDirectory, "/tmp");
|
||||
assert.equal(t.model, "gpt-5.5");
|
||||
assert.equal(t.modelReasoningEffort, undefined);
|
||||
assert.equal(t.skipGitRepoCheck, true);
|
||||
});
|
||||
|
||||
test("buildCodexThreadOptions splits <model>/<effort> into model + modelReasoningEffort", () => {
|
||||
const t = buildCodexThreadOptions({ model: "gpt-5.5/high" });
|
||||
assert.equal(t.model, "gpt-5.5");
|
||||
assert.equal(t.modelReasoningEffort, "high");
|
||||
// GPT-5.6 advertises max/ultra reasoning efforts in the Codex catalog.
|
||||
const solMax = buildCodexThreadOptions({ model: "gpt-5.6-sol/max" });
|
||||
assert.equal(solMax.model, "gpt-5.6-sol");
|
||||
assert.equal(solMax.modelReasoningEffort, "max");
|
||||
const solUltra = buildCodexThreadOptions({ model: "gpt-5.6-sol/ultra" });
|
||||
assert.equal(solUltra.model, "gpt-5.6-sol");
|
||||
assert.equal(solUltra.modelReasoningEffort, "ultra");
|
||||
// a trailing segment that isn't a valid effort (custom/OpenRouter id) is kept whole
|
||||
const c = buildCodexThreadOptions({ model: "openrouter/some-model" });
|
||||
assert.equal(c.model, "openrouter/some-model");
|
||||
assert.equal(c.modelReasoningEffort, undefined);
|
||||
});
|
||||
565
electron/bridges/aiBridge/sdk/copilotDriver.cjs
Normal file
565
electron/bridges/aiBridge/sdk/copilotDriver.cjs
Normal file
@@ -0,0 +1,565 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Copilot backend driver — wraps @github/copilot-sdk.
|
||||
*
|
||||
* new CopilotClient({ connection: RuntimeConnection.forStdio({ path }), useLoggedInUser })
|
||||
* .createSession({ model, streaming, onPermissionRequest: approveAll, mcpServers })
|
||||
* .sendAndWait({ prompt }) -> response.data.content
|
||||
*
|
||||
* - The bundled copilot runtime (@github/copilot) is excluded from packaging
|
||||
* (bring-your-own-CLI), so we MUST point `connection` at the user's system
|
||||
* `copilot` binary via RuntimeConnection.forStdio({ path }) — otherwise the SDK
|
||||
* falls back to the (absent) bundled runtime in the shipped app.
|
||||
* - MCP mode: side effects route through the injected netcatty MCP server
|
||||
* (stdio). The permission handler rejects local Copilot tools and allows
|
||||
* only MCP requests; netcatty MCP then enforces approval/scope/blocklist.
|
||||
* - Skills mode: only builtin bash is exposed (CLI instructions are injected via
|
||||
* the host prompt; the skill builtin is omitted because its read/custom-tool
|
||||
* permission kinds are not shell-safe to auto-approve). Shell permission
|
||||
* requests are approved only for Netcatty CLI invocations; discovery env is
|
||||
* passed to the Copilot runtime so `netcatty-tool-cli` can reach the host.
|
||||
*
|
||||
* 🔬 SMOKE-CALIBRATE [copilot-stream]: sendAndWait returns only the final
|
||||
* assistant text. A follow-up can subscribe via session.on(handler) to stream
|
||||
* text + per-tool-call events (assistant.message / tool execution events).
|
||||
*/
|
||||
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
|
||||
|
||||
// Neutral client options. The real CopilotClient options (with RuntimeConnection)
|
||||
// are assembled in runCopilotTurn, because RuntimeConnection comes from the SDK
|
||||
// module which is loaded via dynamic import().
|
||||
function buildCopilotClientOptions({ cliPath, gitHubToken }) {
|
||||
const options = {};
|
||||
if (cliPath) options.cliPath = cliPath;
|
||||
if (gitHubToken) options.gitHubToken = gitHubToken;
|
||||
return options;
|
||||
}
|
||||
|
||||
function toCopilotMcpServers(injectedMcpServers) {
|
||||
const map = {};
|
||||
for (const cfg of injectedMcpServers || []) {
|
||||
if (!cfg || !cfg.name) continue;
|
||||
map[cfg.name] = {
|
||||
// Local subprocess MCP server (MCPStdioServerConfig). 'stdio' is the
|
||||
// SDK's canonical value for local/subprocess servers.
|
||||
type: "stdio",
|
||||
command: cfg.command,
|
||||
args: cfg.args || [],
|
||||
env: mcpEnvPairsToObject(cfg.env),
|
||||
tools: ["*"],
|
||||
};
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const COPILOT_SKILLS_AVAILABLE_TOOLS = ["builtin:bash"];
|
||||
|
||||
function copilotBuiltinTools(toolIntegrationMode) {
|
||||
return toolIntegrationMode === "skills" ? [...COPILOT_SKILLS_AVAILABLE_TOOLS] : null;
|
||||
}
|
||||
|
||||
function buildCopilotSessionOptions({ model, injectedMcpServers, toolIntegrationMode }) {
|
||||
// onPermissionRequest is wired in runCopilotTurn (it needs the SDK's approveAll).
|
||||
const options = {
|
||||
mcpServers: toCopilotMcpServers(injectedMcpServers),
|
||||
// Copilot SDK enables assistant.message_delta / assistant.reasoning_delta
|
||||
// from SessionConfig.streaming, not from MessageOptions. Without this the
|
||||
// renderer only receives final assistant.message and the thinking panel never
|
||||
// has live reasoning to render.
|
||||
streaming: true,
|
||||
};
|
||||
const availableTools = copilotBuiltinTools(toolIntegrationMode);
|
||||
if (availableTools) options.availableTools = availableTools;
|
||||
if (model) options.model = model;
|
||||
return options;
|
||||
}
|
||||
|
||||
// Shell chaining/redirection in the local Netcatty CLI prefix (not after exec `--`).
|
||||
const LOCAL_SHELL_METACHAR_PATTERN = /(?:[;&|`]|&&|\|\||\$\(|\$\{|<<?|>{1,2}|\r?\n)/;
|
||||
const LOCAL_SHELL_WRAPPER_PATTERN = /^(?:\/[^\s]+\/)?(?:ba|z|fi)?sh(?:\.exe)?\s+-c\b/i;
|
||||
const NETCATTY_CLI_TOKEN = String.raw`netcatty-tool-cli(?:\.(?:cjs|cmd))?`;
|
||||
const NETCATTY_CLI_PATH_SUFFIX = String.raw`(?:[\\/]|^)${NETCATTY_CLI_TOKEN}`;
|
||||
|
||||
/** Find the last exec/job-start payload separator outside shell quotes. */
|
||||
function findExecPayloadSeparatorIndex(command) {
|
||||
const text = String(command || "");
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let escape = false;
|
||||
let lastIndex = -1;
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\" && (inSingle || inDouble)) {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (!inDouble && ch === "'") {
|
||||
inSingle = !inSingle;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && ch === '"') {
|
||||
inDouble = !inDouble;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && !inDouble && text.startsWith(" -- ", i)) {
|
||||
lastIndex = i;
|
||||
i += 3;
|
||||
}
|
||||
}
|
||||
return lastIndex;
|
||||
}
|
||||
|
||||
function matchesShellMetacharAt(text, index) {
|
||||
const match = LOCAL_SHELL_METACHAR_PATTERN.exec(String(text || "").slice(index));
|
||||
return Boolean(match && match.index === 0);
|
||||
}
|
||||
|
||||
function containsUnsafeShellMetachar(text) {
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let escape = false;
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\" && (inSingle || inDouble)) {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (!inDouble && ch === "'") {
|
||||
inSingle = !inSingle;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && ch === '"') {
|
||||
inDouble = !inDouble;
|
||||
continue;
|
||||
}
|
||||
if (inSingle) continue;
|
||||
if (inDouble) {
|
||||
if (text.startsWith("$(", i) || ch === "`") return true;
|
||||
continue;
|
||||
}
|
||||
if (matchesShellMetacharAt(text, i)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Split before the final exec/job-start remote payload (` -- cmd`), not flag values. */
|
||||
function getLocalNetcattyCliPrefix(fullCommandText) {
|
||||
const command = String(fullCommandText || "").trim();
|
||||
const splitAt = findExecPayloadSeparatorIndex(command);
|
||||
if (splitAt >= 0) {
|
||||
return command.slice(0, splitAt).trim();
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
function isNetcattyCliInvocationPrefix(localPart) {
|
||||
const text = String(localPart || "").trim();
|
||||
if (!text) return false;
|
||||
const pathPrefix = String.raw`(?:\.\./|\./|/|[A-Za-z]:[\\/])[\w. \\-]*[\\/]`;
|
||||
const invocation = new RegExp(
|
||||
String.raw`^(?:(?:[A-Za-z_][\w.-]*=[^\s]+\s+)*)?(?:` +
|
||||
String.raw`"[^"]*${NETCATTY_CLI_PATH_SUFFIX}"|` +
|
||||
String.raw `'[^']*${NETCATTY_CLI_PATH_SUFFIX}'|` +
|
||||
String.raw `${NETCATTY_CLI_TOKEN}(?=\s|$)|` +
|
||||
String.raw `${pathPrefix}${NETCATTY_CLI_TOKEN}(?=\s|$)|` +
|
||||
String.raw `node\s+(?:${NETCATTY_CLI_TOKEN}(?=\s|$)|${pathPrefix}${NETCATTY_CLI_TOKEN}(?=\s|$)|` +
|
||||
String.raw `(?:[\w.-]+(?:[\\/][\w.-]+)*[\\/])?${NETCATTY_CLI_TOKEN}(?=\s|$)|` +
|
||||
String.raw `"[^"]*${NETCATTY_CLI_PATH_SUFFIX}"|'[^']*${NETCATTY_CLI_PATH_SUFFIX}'))`,
|
||||
"i",
|
||||
);
|
||||
return invocation.test(text);
|
||||
}
|
||||
|
||||
function hasExecPayloadSubcommand(localPart) {
|
||||
return /\b(?:exec|job-start)\b/i.test(String(localPart || ""));
|
||||
}
|
||||
|
||||
function isLikelyNetcattyCliShellCommand(fullCommandText) {
|
||||
const command = String(fullCommandText || "").trim();
|
||||
if (!command) return false;
|
||||
|
||||
const splitAt = findExecPayloadSeparatorIndex(command);
|
||||
const localPart = splitAt >= 0 ? command.slice(0, splitAt).trim() : command;
|
||||
const remotePayload = splitAt >= 0 ? command.slice(splitAt + 4).trim() : "";
|
||||
|
||||
if (!localPart || LOCAL_SHELL_WRAPPER_PATTERN.test(localPart)) return false;
|
||||
if (!isNetcattyCliInvocationPrefix(localPart)) return false;
|
||||
|
||||
if (remotePayload) {
|
||||
if (!hasExecPayloadSubcommand(localPart)) return false;
|
||||
if (containsUnsafeShellMetachar(localPart)) return false;
|
||||
// The runtime executes fullCommandText in a local shell; scan all of it so
|
||||
// tokens after `--` cannot chain additional local commands unless quoted.
|
||||
if (containsUnsafeShellMetachar(command)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return !containsUnsafeShellMetachar(command);
|
||||
}
|
||||
|
||||
function approveNetcattyMcpOnly(request) {
|
||||
if (request?.kind === "mcp" && request?.toolName) {
|
||||
return { kind: "approve-once" };
|
||||
}
|
||||
return {
|
||||
kind: "reject",
|
||||
feedback: "Only Netcatty MCP tools are allowed from this integration.",
|
||||
};
|
||||
}
|
||||
|
||||
function approveNetcattyCliShellOnly(request) {
|
||||
if (request?.kind === "shell") {
|
||||
const fullCommandText = request.fullCommandText || "";
|
||||
if (isLikelyNetcattyCliShellCommand(fullCommandText)) {
|
||||
return { kind: "approve-once" };
|
||||
}
|
||||
return {
|
||||
kind: "reject",
|
||||
feedback:
|
||||
"Only Netcatty CLI shell commands are allowed. Invoke the netcatty-tool-cli launcher or script prefix provided in the host context, and include --chat-session on every call.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "reject",
|
||||
feedback: "Only Netcatty CLI shell commands are allowed from this integration.",
|
||||
};
|
||||
}
|
||||
|
||||
function buildCopilotPermissionHandler(toolIntegrationMode) {
|
||||
return toolIntegrationMode === "skills" ? approveNetcattyCliShellOnly : approveNetcattyMcpOnly;
|
||||
}
|
||||
|
||||
function extractCopilotContent(response) {
|
||||
return (response && response.data && response.data.content) || "";
|
||||
}
|
||||
|
||||
function buildCopilotMessageOptions({ prompt, attachments }) {
|
||||
const options = { prompt: String(prompt || "") };
|
||||
const nativeAttachments = [];
|
||||
for (const attachment of Array.isArray(attachments) ? attachments : []) {
|
||||
if (!attachment) continue;
|
||||
const displayName = attachment.filename || undefined;
|
||||
if (attachment.base64Data && attachment.mediaType) {
|
||||
nativeAttachments.push({
|
||||
type: "blob",
|
||||
data: attachment.base64Data,
|
||||
mimeType: attachment.mediaType,
|
||||
displayName,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (attachment.filePath) {
|
||||
nativeAttachments.push({
|
||||
type: "file",
|
||||
path: attachment.filePath,
|
||||
displayName,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (nativeAttachments.length > 0) options.attachments = nativeAttachments;
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Extract a display string from a tool.execution_complete event's data. */
|
||||
function extractCopilotResultText(data) {
|
||||
if (!data) return "";
|
||||
if (data.error && data.error.message) return String(data.error.message);
|
||||
const result = data.result;
|
||||
if (result == null) return "";
|
||||
if (typeof result === "string") return result;
|
||||
const content = result.content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((b) => (b && typeof b.text === "string" ? b.text : (b == null ? "" : JSON.stringify(b))))
|
||||
.join("");
|
||||
}
|
||||
return typeof result === "object" ? JSON.stringify(result) : String(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one copilot SessionEvent into emitter calls — gives copilot the same
|
||||
* live tool-card + thinking-panel UX as codex/claude (it previously showed only
|
||||
* the final text). `state` ({ reasoningOpen, streamedText, streamedReasoning })
|
||||
* threads the thinking block and records whether any delta streamed, so
|
||||
* runCopilotTurn can fall back to final consolidated events when needed.
|
||||
* Event shapes calibrated against @github/copilot-sdk generated session-events.
|
||||
*/
|
||||
function translateCopilotEvent(event, emitter, state) {
|
||||
if (!event || typeof event !== "object") return;
|
||||
const st = state || {};
|
||||
const data = event.data || {};
|
||||
const closeReasoning = () => {
|
||||
if (st.reasoningOpen) { emitter.reasoningEnd(); st.reasoningOpen = false; }
|
||||
};
|
||||
switch (event.type) {
|
||||
case "assistant.reasoning_delta":
|
||||
if (data.deltaContent) {
|
||||
emitter.reasoning(data.deltaContent);
|
||||
st.reasoningOpen = true;
|
||||
st.streamedReasoning = true;
|
||||
}
|
||||
return;
|
||||
case "assistant.reasoning":
|
||||
if (data.content && !st.streamedReasoning) {
|
||||
emitter.reasoning(data.content);
|
||||
st.reasoningOpen = true;
|
||||
closeReasoning();
|
||||
}
|
||||
return;
|
||||
case "assistant.message_delta":
|
||||
if (data.deltaContent) { closeReasoning(); emitter.text(data.deltaContent); st.streamedText = true; }
|
||||
return;
|
||||
case "tool.execution_start":
|
||||
closeReasoning();
|
||||
emitter.toolCall(data.toolName || data.mcpToolName || "tool", data.arguments || {}, data.toolCallId);
|
||||
return;
|
||||
case "tool.execution_complete":
|
||||
emitter.toolResult(data.toolCallId, extractCopilotResultText(data), undefined);
|
||||
return;
|
||||
default:
|
||||
// assistant.message (final consolidated text) is intentionally ignored —
|
||||
// text arrives via message_delta (or the runCopilotTurn fallback). Other
|
||||
// events (turn start/end, usage, state changes) have no UI mapping.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a Copilot turn (保底同步形态 via sendAndWait).
|
||||
* @param {object} args
|
||||
* @param {string} args.prompt
|
||||
* @param {Array<object>} [args.attachments]
|
||||
* @param {object} args.clientOptions buildCopilotClientOptions(...) (neutral: {cliPath, gitHubToken})
|
||||
* @param {object} args.sessionOptions buildCopilotSessionOptions(...) ({model, mcpServers})
|
||||
* @param {object} args.emitter
|
||||
* @param {AbortSignal} [args.signal]
|
||||
* @param {object} [args.sdkModule] inject the @github/copilot-sdk module (for tests)
|
||||
*/
|
||||
async function runCopilotTurn({
|
||||
prompt,
|
||||
attachments,
|
||||
clientOptions,
|
||||
sessionOptions,
|
||||
resumeSessionId,
|
||||
toolIntegrationMode,
|
||||
runtimeEnv,
|
||||
emitter,
|
||||
signal,
|
||||
sdkModule,
|
||||
}) {
|
||||
let resolvedModule = sdkModule;
|
||||
if (!resolvedModule) {
|
||||
try { resolvedModule = await import("@github/copilot-sdk"); } catch { emitter.emitError("GitHub Copilot SDK not installed. Run: npm install @github/copilot-sdk"); return { sessionId: null }; }
|
||||
}
|
||||
const sdk = resolvedModule;
|
||||
const { CopilotClient, RuntimeConnection } = sdk;
|
||||
|
||||
// Assemble the real CopilotClient options: point at the user's system CLI
|
||||
// (the bundled runtime is excluded from packaging) and authenticate as the
|
||||
// logged-in user (gh CLI / stored OAuth).
|
||||
const realClientOptions = { useLoggedInUser: true };
|
||||
if (runtimeEnv && typeof runtimeEnv === "object") {
|
||||
realClientOptions.env = runtimeEnv;
|
||||
}
|
||||
if (clientOptions?.cliPath && RuntimeConnection?.forStdio) {
|
||||
realClientOptions.connection = RuntimeConnection.forStdio({ path: clientOptions.cliPath });
|
||||
}
|
||||
if (clientOptions?.gitHubToken) realClientOptions.gitHubToken = clientOptions.gitHubToken;
|
||||
|
||||
let client = null;
|
||||
let sessionId = resumeSessionId || null;
|
||||
try {
|
||||
client = new CopilotClient(realClientOptions);
|
||||
const sessionConfig = {
|
||||
...sessionOptions,
|
||||
streaming: true,
|
||||
// MCP mode: only netcatty MCP. Skills mode: only Netcatty CLI shell commands.
|
||||
onPermissionRequest: buildCopilotPermissionHandler(toolIntegrationMode),
|
||||
};
|
||||
// Resume the prior conversation so context carries ACROSS turns (incl. after
|
||||
// a Stop). Always (re)apply sessionConfig so the FRESH netcatty MCP server
|
||||
// config — its current port/token/chat-session id — is used, not the stale
|
||||
// one from the resumed session. Fall back to a fresh session if there's no id
|
||||
// yet or the resume fails (session expired/deleted).
|
||||
let session;
|
||||
if (resumeSessionId && typeof client.resumeSession === "function") {
|
||||
try {
|
||||
session = await client.resumeSession(resumeSessionId, sessionConfig);
|
||||
} catch {
|
||||
session = await client.createSession(sessionConfig);
|
||||
}
|
||||
} else {
|
||||
session = await client.createSession(sessionConfig);
|
||||
}
|
||||
// Emit the resumable session id IMMEDIATELY — before the blocking sendAndWait
|
||||
// — so a mid-turn Stop can't lose it; the next turn resumes this conversation.
|
||||
sessionId = session.sessionId || sessionId;
|
||||
if (sessionId) emitter.sessionId(sessionId);
|
||||
if (signal?.aborted) return { sessionId };
|
||||
|
||||
// Stream tool calls + text/reasoning deltas in real time (parity with
|
||||
// codex/claude — copilot previously showed only the final text). on() gets
|
||||
// every SessionEvent; SessionConfig.streaming enables assistant.message_delta
|
||||
// / assistant.reasoning_delta; tool.execution_* events arrive regardless.
|
||||
const state = { reasoningOpen: false, streamedText: false, streamedReasoning: false };
|
||||
let unsubscribe = () => {};
|
||||
if (typeof session.on === "function") {
|
||||
unsubscribe = session.on((ev) => translateCopilotEvent(ev, emitter, state));
|
||||
}
|
||||
let abortRequested = false;
|
||||
let removeAbortListener = () => {};
|
||||
if (signal) {
|
||||
const onAbort = () => {
|
||||
abortRequested = true;
|
||||
if (typeof session.abort === "function") {
|
||||
void session.abort().catch(() => {});
|
||||
}
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
let final;
|
||||
try {
|
||||
final = await session.sendAndWait(buildCopilotMessageOptions({ prompt, attachments }));
|
||||
} finally {
|
||||
try { unsubscribe(); } catch { /* best effort */ }
|
||||
removeAbortListener();
|
||||
}
|
||||
if (state.reasoningOpen) emitter.reasoningEnd();
|
||||
if (abortRequested || signal?.aborted) {
|
||||
return { sessionId };
|
||||
}
|
||||
|
||||
// Fallback: if nothing streamed (older runtime / streamDeltas unsupported),
|
||||
// emit the final consolidated text so the turn isn't silent.
|
||||
if (!state.streamedText) {
|
||||
const content = extractCopilotContent(final);
|
||||
if (content) emitter.text(content);
|
||||
if (!content && !signal?.aborted) {
|
||||
emitter.emitError(
|
||||
"Copilot returned an empty response. Run `copilot` once to log in, or `gh auth login`.",
|
||||
);
|
||||
return { sessionId };
|
||||
}
|
||||
}
|
||||
emitter.emitDone();
|
||||
return { sessionId };
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
return { sessionId };
|
||||
}
|
||||
const code = error && error.code;
|
||||
const msg = String((error && error.message) || error || "");
|
||||
if (code === "ENOENT" || /ENOENT/i.test(msg)) {
|
||||
emitter.emitError(
|
||||
"Copilot CLI not found. Install with `npm i -g @github/copilot` and run `gh auth login`.",
|
||||
);
|
||||
} else {
|
||||
emitter.emitError(msg || "Copilot turn failed");
|
||||
}
|
||||
return { sessionId };
|
||||
} finally {
|
||||
try { await client?.stop?.(); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Map copilot-sdk ModelInfo[] -> renderer preset shape {id,name}. */
|
||||
function mapCopilotModels(models) {
|
||||
if (!Array.isArray(models)) return [];
|
||||
return models
|
||||
.filter((m) => m && m.id)
|
||||
.map((m) => ({ id: m.id, name: m.name || m.id }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available Copilot models via client.start() + client.listModels().
|
||||
* Returns [] on failure (the caller falls back to the UI's curated presets).
|
||||
* @param {object} args
|
||||
* @param {string} [args.cliPath]
|
||||
* @param {object} [args.sdkModule] inject the @github/copilot-sdk module (for tests)
|
||||
*/
|
||||
async function listCopilotModels({ cliPath, sdkModule, abortController, signal }) {
|
||||
const externalSignal = signal || abortController?.signal;
|
||||
if (externalSignal?.aborted) return [];
|
||||
let resolvedModule = sdkModule;
|
||||
if (!resolvedModule) {
|
||||
try { resolvedModule = await import("@github/copilot-sdk"); } catch { return []; }
|
||||
}
|
||||
const sdk = resolvedModule;
|
||||
const { CopilotClient, RuntimeConnection } = sdk;
|
||||
const clientOptions = { useLoggedInUser: true };
|
||||
if (cliPath && RuntimeConnection?.forStdio) {
|
||||
clientOptions.connection = RuntimeConnection.forStdio({ path: cliPath });
|
||||
}
|
||||
const client = new CopilotClient(clientOptions);
|
||||
let stopPromise;
|
||||
const stopClient = () => {
|
||||
if (!stopPromise) {
|
||||
try { stopPromise = Promise.resolve(client.stop()).catch(() => {}); } catch { stopPromise = Promise.resolve(); }
|
||||
}
|
||||
return stopPromise;
|
||||
};
|
||||
let resolveAbort;
|
||||
const aborted = new Promise((resolve) => { resolveAbort = resolve; });
|
||||
const onAbort = () => {
|
||||
resolveAbort({ type: "aborted" });
|
||||
void stopClient();
|
||||
};
|
||||
externalSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (externalSignal?.aborted) onAbort();
|
||||
try {
|
||||
const started = await Promise.race([
|
||||
Promise.resolve(client.start()).then(() => ({ type: "started" })),
|
||||
aborted,
|
||||
]);
|
||||
if (started.type === "aborted") return [];
|
||||
const result = await Promise.race([
|
||||
Promise.resolve(client.listModels()).then((models) => ({ type: "models", models })),
|
||||
aborted,
|
||||
]);
|
||||
return result.type === "models" ? mapCopilotModels(result.models) : [];
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
externalSignal?.removeEventListener("abort", onAbort);
|
||||
void stopClient();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildCopilotClientOptions,
|
||||
buildCopilotSessionOptions,
|
||||
buildCopilotMessageOptions,
|
||||
buildCopilotPermissionHandler,
|
||||
approveNetcattyMcpOnly,
|
||||
approveNetcattyCliShellOnly,
|
||||
isLikelyNetcattyCliShellCommand,
|
||||
getLocalNetcattyCliPrefix,
|
||||
findExecPayloadSeparatorIndex,
|
||||
containsUnsafeShellMetachar,
|
||||
matchesShellMetacharAt,
|
||||
hasExecPayloadSubcommand,
|
||||
copilotBuiltinTools,
|
||||
toCopilotMcpServers,
|
||||
extractCopilotContent,
|
||||
extractCopilotResultText,
|
||||
translateCopilotEvent,
|
||||
runCopilotTurn,
|
||||
listCopilotModels,
|
||||
mapCopilotModels,
|
||||
};
|
||||
357
electron/bridges/aiBridge/sdk/copilotDriver.test.cjs
Normal file
357
electron/bridges/aiBridge/sdk/copilotDriver.test.cjs
Normal file
@@ -0,0 +1,357 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { approveNetcattyMcpOnly, approveNetcattyCliShellOnly, buildCopilotClientOptions, buildCopilotPermissionHandler, buildCopilotSessionOptions, buildCopilotMessageOptions, copilotBuiltinTools, extractCopilotContent, isLikelyNetcattyCliShellCommand, mapCopilotModels, runCopilotTurn, translateCopilotEvent } = require("./copilotDriver.cjs");
|
||||
|
||||
function collector() {
|
||||
const events = [];
|
||||
return {
|
||||
events,
|
||||
emitter: {
|
||||
text: (t) => events.push({ k: "text", t }),
|
||||
reasoning: (d) => events.push({ k: "reasoning", d }),
|
||||
reasoningEnd: () => events.push({ k: "reasoningEnd" }),
|
||||
toolCall: (n, a, id) => events.push({ k: "toolCall", n, a, id }),
|
||||
toolResult: (id, o, n) => events.push({ k: "toolResult", id, o, n }),
|
||||
sessionId: (s) => events.push({ k: "sessionId", s }),
|
||||
emitError: (e) => events.push({ k: "error", e }),
|
||||
emitDone: () => events.push({ k: "done" }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal @github/copilot-sdk mock; records create vs resume + returns a session. */
|
||||
function makeSdk(captured) {
|
||||
const makeSession = (sessionId) => ({
|
||||
sessionId,
|
||||
async sendAndWait({ prompt }) { captured.prompt = prompt; return { data: { content: "reply:" + sessionId } }; },
|
||||
});
|
||||
class CopilotClient {
|
||||
constructor(options) { captured.clientOptions = options; }
|
||||
async createSession(cfg) { captured.created = cfg; return makeSession("sess-new"); }
|
||||
async resumeSession(id, cfg) { captured.resumed = { id, cfg }; return makeSession(id); }
|
||||
async stop() {}
|
||||
}
|
||||
return { CopilotClient, RuntimeConnection: { forStdio: () => ({}) }, approveAll: () => {} };
|
||||
}
|
||||
|
||||
test("buildCopilotClientOptions pins cliPath", () => {
|
||||
const o = buildCopilotClientOptions({ cliPath: "/abs/copilot" });
|
||||
assert.equal(o.cliPath, "/abs/copilot");
|
||||
});
|
||||
|
||||
test("buildCopilotSessionOptions maps injected MCP to local stdio servers", () => {
|
||||
const o = buildCopilotSessionOptions({
|
||||
model: "claude-sonnet-4.5",
|
||||
injectedMcpServers: [{
|
||||
name: "netcatty-remote-hosts", command: "/abs/electron",
|
||||
args: ["/abs/server.cjs"], env: [{ name: "NETCATTY_MCP_PORT", value: "1" }],
|
||||
}],
|
||||
});
|
||||
assert.equal(o.model, "claude-sonnet-4.5");
|
||||
assert.equal(o.streaming, true);
|
||||
const srv = o.mcpServers["netcatty-remote-hosts"];
|
||||
assert.equal(srv.type, "stdio");
|
||||
assert.equal(srv.command, "/abs/electron");
|
||||
assert.deepEqual(srv.env, { NETCATTY_MCP_PORT: "1" });
|
||||
assert.deepEqual(srv.tools, ["*"]);
|
||||
// onPermissionRequest is wired in runCopilotTurn via the SDK's approveAll,
|
||||
// not in buildCopilotSessionOptions.
|
||||
});
|
||||
|
||||
test("approveNetcattyMcpOnly approves MCP permission requests and rejects local tools", () => {
|
||||
assert.deepEqual(
|
||||
approveNetcattyMcpOnly({ kind: "mcp", toolName: "terminal_execute" }),
|
||||
{ kind: "approve-once" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
approveNetcattyMcpOnly({ kind: "shell", fullCommandText: "rm -rf /tmp/x" }),
|
||||
{ kind: "reject", feedback: "Only Netcatty MCP tools are allowed from this integration." },
|
||||
);
|
||||
assert.deepEqual(
|
||||
approveNetcattyMcpOnly({ kind: "read", fileName: "/etc/passwd" }),
|
||||
{ kind: "reject", feedback: "Only Netcatty MCP tools are allowed from this integration." },
|
||||
);
|
||||
});
|
||||
|
||||
test("extractCopilotContent reads response data.content", () => {
|
||||
assert.equal(extractCopilotContent({ data: { content: "hi" } }), "hi");
|
||||
assert.equal(extractCopilotContent(null), "");
|
||||
assert.equal(extractCopilotContent({ data: {} }), "");
|
||||
});
|
||||
|
||||
test("buildCopilotMessageOptions sends pasted images/files as native attachments", () => {
|
||||
const opts = buildCopilotMessageOptions({
|
||||
prompt: "inspect these",
|
||||
attachments: [
|
||||
{ filename: "shot.png", mediaType: "image/png", filePath: "/tmp/shot.png", base64Data: "abc" },
|
||||
{ filename: "note.txt", mediaType: "text/plain", filePath: "/tmp/note.txt" },
|
||||
],
|
||||
});
|
||||
assert.equal(opts.prompt, "inspect these");
|
||||
assert.equal("streamDeltas" in opts, false);
|
||||
assert.deepEqual(opts.attachments, [
|
||||
{ type: "blob", data: "abc", mimeType: "image/png", displayName: "shot.png" },
|
||||
{ type: "file", path: "/tmp/note.txt", displayName: "note.txt" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("mapCopilotModels maps {id,name} and drops entries without id", () => {
|
||||
const out = mapCopilotModels([
|
||||
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
|
||||
{ id: "gpt-5" },
|
||||
{ name: "no id -> dropped" },
|
||||
]);
|
||||
assert.deepEqual(out, [
|
||||
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
|
||||
{ id: "gpt-5", name: "gpt-5" },
|
||||
]);
|
||||
assert.deepEqual(mapCopilotModels(undefined), []);
|
||||
});
|
||||
|
||||
test("runCopilotTurn (fresh) creates a session, emits its id early, returns it for resume", async () => {
|
||||
const { events, emitter } = collector();
|
||||
const captured = {};
|
||||
const result = await runCopilotTurn({
|
||||
prompt: "hi", clientOptions: { cliPath: "/c" }, sessionOptions: { model: "m" },
|
||||
emitter, sdkModule: makeSdk(captured),
|
||||
});
|
||||
assert.ok(captured.created, "used createSession when there's no resume id");
|
||||
assert.equal(captured.created.model, "m");
|
||||
assert.deepEqual(events.filter((e) => e.k === "sessionId"), [{ k: "sessionId", s: "sess-new" }]);
|
||||
assert.equal(result.sessionId, "sess-new");
|
||||
});
|
||||
|
||||
test("runCopilotTurn resumes the prior session (carry context) and re-applies fresh config", async () => {
|
||||
const { events, emitter } = collector();
|
||||
const captured = {};
|
||||
const result = await runCopilotTurn({
|
||||
prompt: "what did we say", clientOptions: {}, sessionOptions: { model: "m" },
|
||||
resumeSessionId: "sess-existing", emitter, sdkModule: makeSdk(captured),
|
||||
});
|
||||
assert.equal(captured.resumed.id, "sess-existing", "used resumeSession, not createSession");
|
||||
assert.equal(captured.created, undefined);
|
||||
// fresh netcatty MCP/session config re-applied on resume (not the stale one)
|
||||
assert.equal(captured.resumed.cfg.model, "m");
|
||||
assert.equal(result.sessionId, "sess-existing");
|
||||
assert.ok(events.some((e) => e.k === "sessionId" && e.s === "sess-existing"));
|
||||
});
|
||||
|
||||
test("translateCopilotEvent: deltas -> text/reasoning, tool start/complete -> tool card", () => {
|
||||
const { events, emitter } = collector();
|
||||
const state = { reasoningOpen: false, streamedText: false };
|
||||
translateCopilotEvent({ type: "assistant.reasoning_delta", data: { deltaContent: "thinking" } }, emitter, state);
|
||||
translateCopilotEvent({ type: "assistant.message_delta", data: { deltaContent: "hello" } }, emitter, state);
|
||||
translateCopilotEvent({ type: "tool.execution_start", data: { toolName: "shell", arguments: { command: "ls" }, toolCallId: "t1" } }, emitter, state);
|
||||
translateCopilotEvent({ type: "tool.execution_complete", data: { toolCallId: "t1", result: { content: [{ type: "text", text: "files" }] } } }, emitter, state);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "reasoning", d: "thinking" },
|
||||
{ k: "reasoningEnd" }, // message_delta closes the thinking block
|
||||
{ k: "text", t: "hello" },
|
||||
{ k: "toolCall", n: "shell", a: { command: "ls" }, id: "t1" },
|
||||
{ k: "toolResult", id: "t1", o: "files", n: undefined },
|
||||
]);
|
||||
assert.equal(state.streamedText, true);
|
||||
});
|
||||
|
||||
test("translateCopilotEvent: final reasoning is shown when no reasoning deltas streamed", () => {
|
||||
const { events, emitter } = collector();
|
||||
const state = { reasoningOpen: false, streamedText: false, streamedReasoning: false };
|
||||
translateCopilotEvent({ type: "assistant.reasoning", data: { content: "complete thinking" } }, emitter, state);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "reasoning", d: "complete thinking" },
|
||||
{ k: "reasoningEnd" },
|
||||
]);
|
||||
assert.equal(state.reasoningOpen, false);
|
||||
});
|
||||
|
||||
test("translateCopilotEvent: final reasoning is ignored after streamed reasoning deltas", () => {
|
||||
const { events, emitter } = collector();
|
||||
const state = { reasoningOpen: false, streamedText: false, streamedReasoning: false };
|
||||
translateCopilotEvent({ type: "assistant.reasoning_delta", data: { deltaContent: "thinking" } }, emitter, state);
|
||||
translateCopilotEvent({ type: "assistant.reasoning", data: { content: "thinking" } }, emitter, state);
|
||||
translateCopilotEvent({ type: "assistant.message_delta", data: { deltaContent: "hello" } }, emitter, state);
|
||||
assert.deepEqual(events, [
|
||||
{ k: "reasoning", d: "thinking" },
|
||||
{ k: "reasoningEnd" },
|
||||
{ k: "text", t: "hello" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCopilotTurn streams tool calls + deltas via session.on (no final-text dup)", async () => {
|
||||
const { events, emitter } = collector();
|
||||
const captured = {};
|
||||
let handler = null;
|
||||
const sdkModule = {
|
||||
RuntimeConnection: { forStdio: () => ({}) },
|
||||
approveAll: () => {},
|
||||
CopilotClient: class {
|
||||
async createSession(cfg) {
|
||||
captured.created = cfg;
|
||||
return {
|
||||
sessionId: "sess-x",
|
||||
on(h) { handler = h; return () => { handler = null; }; },
|
||||
async sendAndWait(opts) {
|
||||
captured.opts = opts;
|
||||
handler({ type: "assistant.message_delta", data: { deltaContent: "hi " } });
|
||||
handler({ type: "tool.execution_start", data: { toolName: "shell", arguments: {}, toolCallId: "t1" } });
|
||||
handler({ type: "tool.execution_complete", data: { toolCallId: "t1", result: { content: [{ type: "text", text: "ok" }] } } });
|
||||
handler({ type: "assistant.message_delta", data: { deltaContent: "there" } });
|
||||
return { data: { content: "hi there" } };
|
||||
},
|
||||
async stop() {},
|
||||
};
|
||||
}
|
||||
async stop() {}
|
||||
},
|
||||
};
|
||||
const result = await runCopilotTurn({
|
||||
prompt: "go",
|
||||
attachments: [{ filename: "shot.png", mediaType: "image/png", filePath: "/tmp/shot.png", base64Data: "abc" }],
|
||||
clientOptions: {},
|
||||
sessionOptions: {},
|
||||
emitter,
|
||||
sdkModule,
|
||||
});
|
||||
assert.equal(captured.created.streaming, true, "requested session streaming");
|
||||
assert.equal("streamDeltas" in captured.opts, false, "does not send unsupported message streaming flag");
|
||||
assert.deepEqual(captured.opts.attachments, [
|
||||
{ type: "blob", data: "abc", mimeType: "image/png", displayName: "shot.png" },
|
||||
]);
|
||||
// streamed deltas shown, NOT the duplicated final consolidated text
|
||||
assert.deepEqual(events.filter((e) => e.k === "text"), [{ k: "text", t: "hi " }, { k: "text", t: "there" }]);
|
||||
assert.ok(events.some((e) => e.k === "toolCall" && e.id === "t1"), "tool card streamed");
|
||||
assert.ok(events.some((e) => e.k === "toolResult" && e.o === "ok"), "tool result streamed");
|
||||
assert.equal(result.sessionId, "sess-x");
|
||||
});
|
||||
|
||||
test("runCopilotTurn aborts the active Copilot session when the signal aborts", async () => {
|
||||
const { events, emitter } = collector();
|
||||
const controller = new AbortController();
|
||||
let abortCalled = false;
|
||||
const sdkModule = {
|
||||
RuntimeConnection: { forStdio: () => ({}) },
|
||||
approveAll: () => {},
|
||||
CopilotClient: class {
|
||||
async createSession() {
|
||||
return {
|
||||
sessionId: "sess-abort",
|
||||
on() { return () => {}; },
|
||||
async sendAndWait() {
|
||||
controller.abort();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return { data: { content: "late text" } };
|
||||
},
|
||||
async abort() { abortCalled = true; },
|
||||
};
|
||||
}
|
||||
async stop() {}
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runCopilotTurn({
|
||||
prompt: "stop me",
|
||||
clientOptions: {},
|
||||
sessionOptions: {},
|
||||
emitter,
|
||||
signal: controller.signal,
|
||||
sdkModule,
|
||||
});
|
||||
|
||||
assert.equal(abortCalled, true);
|
||||
assert.equal(result.sessionId, "sess-abort");
|
||||
assert.equal(events.some((event) => event.k === "text" && event.t === "late text"), false);
|
||||
assert.equal(events.some((event) => event.k === "done"), false);
|
||||
});
|
||||
|
||||
test("copilotBuiltinTools exposes bash only in skills mode", () => {
|
||||
assert.equal(copilotBuiltinTools("mcp"), null);
|
||||
assert.deepEqual(copilotBuiltinTools("skills"), ["builtin:bash"]);
|
||||
});
|
||||
|
||||
test("buildCopilotSessionOptions whitelists bash in skills mode", () => {
|
||||
const skills = buildCopilotSessionOptions({
|
||||
model: "gpt-5",
|
||||
injectedMcpServers: [],
|
||||
toolIntegrationMode: "skills",
|
||||
});
|
||||
assert.deepEqual(skills.availableTools, ["builtin:bash"]);
|
||||
assert.deepEqual(skills.mcpServers, {});
|
||||
});
|
||||
|
||||
test("approveNetcattyCliShellOnly allows Netcatty CLI shell commands only", () => {
|
||||
assert.deepEqual(
|
||||
approveNetcattyCliShellOnly({
|
||||
kind: "shell",
|
||||
fullCommandText: 'node "/Applications/Netcatty.app/netcatty-tool-cli.cjs" env --chat-session abc --json',
|
||||
}),
|
||||
{ kind: "approve-once" },
|
||||
);
|
||||
assert.equal(
|
||||
approveNetcattyCliShellOnly({ kind: "shell", fullCommandText: "pwd" }).kind,
|
||||
"reject",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCopilotPermissionHandler selects MCP vs skills gate", () => {
|
||||
assert.equal(buildCopilotPermissionHandler("mcp"), approveNetcattyMcpOnly);
|
||||
assert.equal(buildCopilotPermissionHandler("skills"), approveNetcattyCliShellOnly);
|
||||
});
|
||||
|
||||
test("isLikelyNetcattyCliShellCommand matches launcher and script invocations", () => {
|
||||
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli status --json"), true);
|
||||
assert.equal(isLikelyNetcattyCliShellCommand("node electron/cli/netcatty-tool-cli.cjs env --json"), true);
|
||||
assert.equal(isLikelyNetcattyCliShellCommand("ls -la"), false);
|
||||
});
|
||||
|
||||
test("isLikelyNetcattyCliShellCommand rejects chained or wrapped local commands", () => {
|
||||
assert.equal(isLikelyNetcattyCliShellCommand("rm -rf /; netcatty-tool-cli status --json"), false);
|
||||
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli status --json && curl evil"), false);
|
||||
assert.equal(isLikelyNetcattyCliShellCommand('bash -c "netcatty-tool-cli status --json"'), false);
|
||||
assert.equal(isLikelyNetcattyCliShellCommand("malicious netcatty-tool-cli status --json"), false);
|
||||
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli status `id` --json"), false);
|
||||
});
|
||||
|
||||
test("isLikelyNetcattyCliShellCommand allows quoted remote exec payloads after --", () => {
|
||||
assert.equal(
|
||||
isLikelyNetcattyCliShellCommand('netcatty-tool-cli exec --session s1 --chat-session c1 --json -- "hostname && whoami"'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isLikelyNetcattyCliShellCommand("netcatty-tool-cli exec --session s1 --chat-session c1 --json -- hostname && whoami"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("isLikelyNetcattyCliShellCommand rejects impostor binaries and quoted -- bypasses", () => {
|
||||
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli-backup status --json"), false);
|
||||
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli.evil status --json"), false);
|
||||
assert.equal(
|
||||
isLikelyNetcattyCliShellCommand('netcatty-tool-cli sftp read --remote-path "a -- b" ; rm -rf /'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isLikelyNetcattyCliShellCommand('netcatty-tool-cli sftp read --remote-path "a -- b" --session s1 --json'),
|
||||
true,
|
||||
);
|
||||
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli status --json -- ; rm -rf /"), false);
|
||||
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli status --json > /tmp/out"), false);
|
||||
assert.equal(isLikelyNetcattyCliShellCommand('"C:\\Apps\\Netcatty\\netcatty-tool-cli.cmd" status --json'), true);
|
||||
assert.equal(isLikelyNetcattyCliShellCommand("attacker/netcatty-tool-cli status --json"), false);
|
||||
assert.equal(isLikelyNetcattyCliShellCommand('netcatty-tool-cli status "$(id)" --json'), false);
|
||||
});
|
||||
|
||||
test("runCopilotTurn passes runtime env and skills permission handler", async () => {
|
||||
const { emitter } = collector();
|
||||
const captured = {};
|
||||
await runCopilotTurn({
|
||||
prompt: "hi",
|
||||
clientOptions: { cliPath: "/c" },
|
||||
sessionOptions: { model: "m" },
|
||||
toolIntegrationMode: "skills",
|
||||
runtimeEnv: { NETCATTY_TOOL_CLI_DISCOVERY_FILE: "/tmp/discovery.json" },
|
||||
emitter,
|
||||
sdkModule: makeSdk(captured),
|
||||
});
|
||||
assert.deepEqual(captured.clientOptions.env, { NETCATTY_TOOL_CLI_DISCOVERY_FILE: "/tmp/discovery.json" });
|
||||
assert.equal(captured.created.onPermissionRequest, approveNetcattyCliShellOnly);
|
||||
});
|
||||
789
electron/bridges/aiBridge/sdk/cursorCliDriver.cjs
Normal file
789
electron/bridges/aiBridge/sdk/cursorCliDriver.cjs
Normal file
@@ -0,0 +1,789 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Cursor Agent CLI turn runner — subscription / login session path.
|
||||
*
|
||||
* Spawns `cursor-agent` in print/stream-json mode so Catty can use the local
|
||||
* CLI login quota without CURSOR_API_KEY.
|
||||
*/
|
||||
const { spawn } = require("node:child_process");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { resolveCursorCliSpawnSpec } = require("../cursorCliSpawn.cjs");
|
||||
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
|
||||
const { encodeCursorCliModel } = require("./cursorDriver.cjs");
|
||||
|
||||
const DEFAULT_CURSOR_CLI_MODEL = "auto";
|
||||
const NETCATTY_MCP_NAME = "netcatty-remote-hosts";
|
||||
const CURSOR_CLI_ABORT_GRACE_MS = 1_500;
|
||||
const MAX_CURSOR_CLI_STDERR_CHARS = 64 * 1024;
|
||||
const MAX_CURSOR_CLI_MODEL_STDOUT_CHARS = 1024 * 1024;
|
||||
const MAX_CURSOR_CLI_LINE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function signalCursorCliProcessTree(child, signal, forceKillImpl) {
|
||||
if (!child) return;
|
||||
if (typeof forceKillImpl === "function") {
|
||||
try { forceKillImpl(child, signal); } catch {}
|
||||
return;
|
||||
}
|
||||
if (process.platform === "win32" && signal === "SIGKILL" && child.pid) {
|
||||
try {
|
||||
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
killer.on("error", () => {});
|
||||
killer.unref?.();
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to ChildProcess.kill below.
|
||||
}
|
||||
}
|
||||
if (process.platform !== "win32" && child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// The child may not be a process-group leader (for injected tests or an
|
||||
// older runtime). Fall back to killing the direct child.
|
||||
}
|
||||
}
|
||||
try { child.kill(signal); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function stripCursorApiKeyFromEnv(env) {
|
||||
const out = { ...(env || {}) };
|
||||
delete out.CURSOR_API_KEY;
|
||||
return out;
|
||||
}
|
||||
|
||||
function spawnCursorCliProcess(spawnImpl, cliPath, args, options = {}) {
|
||||
const spawnFn = spawnImpl || spawn;
|
||||
const spawnSpec = resolveCursorCliSpawnSpec(cliPath, args);
|
||||
return spawnFn(spawnSpec.command, spawnSpec.args, {
|
||||
...options,
|
||||
shell: spawnSpec.shell,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCursorCliModel(model) {
|
||||
const encoded = encodeCursorCliModel(model);
|
||||
return encoded || DEFAULT_CURSOR_CLI_MODEL;
|
||||
}
|
||||
|
||||
/** Map Netcatty permission mode → Cursor CLI execution class. */
|
||||
function resolveCursorCliExecMode(permissionMode) {
|
||||
return String(permissionMode || "confirm").toLowerCase() === "observer" ? "ask" : "agent";
|
||||
}
|
||||
|
||||
function buildCursorCliArgs({
|
||||
model,
|
||||
resumeSessionId,
|
||||
permissionMode,
|
||||
cwd,
|
||||
prompt,
|
||||
}) {
|
||||
const args = [
|
||||
"--print",
|
||||
"--trust",
|
||||
"--approve-mcps",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--stream-partial-output",
|
||||
"--model",
|
||||
resolveCursorCliModel(model),
|
||||
];
|
||||
|
||||
if (cwd) {
|
||||
args.push("--workspace", cwd);
|
||||
}
|
||||
|
||||
if (resumeSessionId) {
|
||||
args.push("--resume", String(resumeSessionId));
|
||||
}
|
||||
|
||||
if (resolveCursorCliExecMode(permissionMode) === "ask") {
|
||||
// Read-only ask mode; no shell write approvals expected.
|
||||
args.push("--mode", "ask");
|
||||
} else {
|
||||
// confirm/auto (and any other agent mode): stdin is ignored for the child, so
|
||||
// interactive y/n command approval cannot work. Cursor docs require --force
|
||||
// (--yolo) to auto-allow shell/tools in non-interactive runs.
|
||||
args.push("--force");
|
||||
}
|
||||
|
||||
args.push(String(prompt || ""));
|
||||
return args;
|
||||
}
|
||||
|
||||
function mcpConfigToCursorMcpJsonEntry(cfg) {
|
||||
if (!cfg || !cfg.name || !cfg.command) return null;
|
||||
const entry = {
|
||||
type: "stdio",
|
||||
command: cfg.command,
|
||||
args: Array.isArray(cfg.args) ? cfg.args : [],
|
||||
};
|
||||
const env = mcpEnvPairsToObject(cfg.env);
|
||||
if (env && Object.keys(env).length > 0) entry.env = env;
|
||||
return { name: cfg.name, entry };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor CLI discovers MCP via `{cwd}/.cursor/mcp.json`. Packaged Netcatty
|
||||
* launched from Finder/Dock often has `process.cwd() === "/"`, which cannot
|
||||
* host that file. Always prefer a writable Netcatty temp workspace.
|
||||
*/
|
||||
function resolveCursorCliWorkspaceCwd({
|
||||
preferredCwd,
|
||||
chatSessionId,
|
||||
getTempDir,
|
||||
mkdirSync,
|
||||
} = {}) {
|
||||
const mkdir = mkdirSync || fs.mkdirSync;
|
||||
const resolveTempRoot = typeof getTempDir === "function"
|
||||
? getTempDir
|
||||
: () => {
|
||||
try {
|
||||
return require("../../tempDirBridge.cjs").getTempDir();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const tempRoot = String(resolveTempRoot?.() || "").trim();
|
||||
if (tempRoot) {
|
||||
const safeId = String(chatSessionId || "default")
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.slice(0, 80) || "default";
|
||||
const dir = path.join(tempRoot, "cursor-cli-mcp", safeId);
|
||||
mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
const fallback = String(preferredCwd || process.cwd() || "").trim() || process.cwd();
|
||||
try {
|
||||
mkdir(path.join(fallback, ".cursor"), { recursive: true });
|
||||
} catch {
|
||||
/* caller / merge may still fail loudly */
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Per-path refcount so concurrent CLI turns share one original snapshot and only
|
||||
// the last restorer writes the pre-merge file back (avoids last-writer-wins races).
|
||||
const mcpMergeRefcounts = new Map();
|
||||
|
||||
function mergeWorkspaceMcpJson(cwd, injectedMcpServers, { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } = {}) {
|
||||
const read = readFileSync || fs.readFileSync;
|
||||
const write = writeFileSync || fs.writeFileSync;
|
||||
const mkdir = mkdirSync || fs.mkdirSync;
|
||||
const exists = existsSync || fs.existsSync;
|
||||
const unlink = unlinkSync || ((p) => fs.unlinkSync(p));
|
||||
|
||||
const cursorDir = path.join(cwd || process.cwd(), ".cursor");
|
||||
const mcpPath = path.join(cursorDir, "mcp.json");
|
||||
|
||||
let state = mcpMergeRefcounts.get(mcpPath);
|
||||
if (!state) {
|
||||
let previousRaw = null;
|
||||
let previousExisted = false;
|
||||
if (exists(mcpPath)) {
|
||||
previousExisted = true;
|
||||
previousRaw = read(mcpPath, "utf8");
|
||||
}
|
||||
state = { refCount: 0, previousRaw, previousExisted };
|
||||
mcpMergeRefcounts.set(mcpPath, state);
|
||||
}
|
||||
state.refCount += 1;
|
||||
|
||||
let doc = { mcpServers: {} };
|
||||
if (exists(mcpPath)) {
|
||||
try {
|
||||
const parsed = JSON.parse(read(mcpPath, "utf8"));
|
||||
if (parsed && typeof parsed === "object") {
|
||||
doc = parsed;
|
||||
if (!doc.mcpServers || typeof doc.mcpServers !== "object") doc.mcpServers = {};
|
||||
}
|
||||
} catch {
|
||||
doc = { mcpServers: {} };
|
||||
}
|
||||
} else if (state.previousExisted && state.previousRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(state.previousRaw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
doc = parsed;
|
||||
if (!doc.mcpServers || typeof doc.mcpServers !== "object") doc.mcpServers = {};
|
||||
}
|
||||
} catch {
|
||||
doc = { mcpServers: {} };
|
||||
}
|
||||
}
|
||||
|
||||
for (const cfg of injectedMcpServers || []) {
|
||||
const mapped = mcpConfigToCursorMcpJsonEntry(cfg);
|
||||
if (!mapped) continue;
|
||||
doc.mcpServers[mapped.name] = mapped.entry;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!exists(cursorDir)) {
|
||||
mkdir(cursorDir, { recursive: true });
|
||||
}
|
||||
write(mcpPath, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
|
||||
} catch (err) {
|
||||
// Roll back refcount so a failed write does not pin the lock forever.
|
||||
state.refCount = Math.max(0, state.refCount - 1);
|
||||
if (state.refCount === 0) mcpMergeRefcounts.delete(mcpPath);
|
||||
throw err;
|
||||
}
|
||||
|
||||
let restored = false;
|
||||
return {
|
||||
mcpPath,
|
||||
restore() {
|
||||
if (restored) return;
|
||||
restored = true;
|
||||
const current = mcpMergeRefcounts.get(mcpPath);
|
||||
if (!current) return;
|
||||
current.refCount = Math.max(0, current.refCount - 1);
|
||||
if (current.refCount > 0) return;
|
||||
mcpMergeRefcounts.delete(mcpPath);
|
||||
try {
|
||||
if (current.previousExisted) write(mcpPath, current.previousRaw, "utf8");
|
||||
else if (exists(mcpPath)) unlink(mcpPath);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Test helper: clear MCP merge refcount state between unit tests. */
|
||||
function resetMcpMergeRefcountsForTests() {
|
||||
mcpMergeRefcounts.clear();
|
||||
}
|
||||
|
||||
function resultToText(result) {
|
||||
if (result == null) return "";
|
||||
if (typeof result === "string") return result;
|
||||
if (typeof result === "number" || typeof result === "boolean") return String(result);
|
||||
if (typeof result === "object") {
|
||||
if (typeof result.content === "string") return result.content;
|
||||
if (result.success && typeof result.success.content === "string") return result.success.content;
|
||||
try { return JSON.stringify(result); } catch { return String(result); }
|
||||
}
|
||||
return String(result);
|
||||
}
|
||||
|
||||
function extractCliToolCall(event) {
|
||||
const callId = event?.call_id || event?.toolCallId || null;
|
||||
const toolCall = event?.tool_call || event?.toolCall || null;
|
||||
if (!toolCall || typeof toolCall !== "object") {
|
||||
return { id: callId, name: event?.name || "tool", args: event?.args || {}, result: event?.result };
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(toolCall)) {
|
||||
if (!key.endsWith("ToolCall") || !value || typeof value !== "object") continue;
|
||||
const name = key.replace(/ToolCall$/, "");
|
||||
const args = value.args && typeof value.args === "object" ? value.args : {};
|
||||
const result = value.result != null ? value.result : undefined;
|
||||
return { id: callId || value.toolCallId || null, name, args, result };
|
||||
}
|
||||
|
||||
return {
|
||||
id: callId,
|
||||
name: event?.name || "tool",
|
||||
args: toolCall.args || {},
|
||||
result: toolCall.result,
|
||||
};
|
||||
}
|
||||
|
||||
function closeReasoning(state, emitter) {
|
||||
if (state?.reasoningOpen) {
|
||||
emitter.reasoningEnd();
|
||||
state.reasoningOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function translateCursorCliEvent(event, emitter, state = {}) {
|
||||
if (!event || typeof event !== "object") return false;
|
||||
|
||||
switch (event.type) {
|
||||
case "system":
|
||||
if (event.session_id) {
|
||||
state.sessionId = event.session_id;
|
||||
emitter.sessionId?.(event.session_id);
|
||||
}
|
||||
return false;
|
||||
|
||||
case "thinking":
|
||||
if (event.subtype === "completed") {
|
||||
closeReasoning(state, emitter);
|
||||
return false;
|
||||
}
|
||||
if (event.text) {
|
||||
emitter.reasoning(String(event.text));
|
||||
state.reasoningOpen = true;
|
||||
}
|
||||
return false;
|
||||
|
||||
case "assistant": {
|
||||
closeReasoning(state, emitter);
|
||||
// With --stream-partial-output, Cursor emits three assistant shapes:
|
||||
// timestamp_ms only → streaming delta (use)
|
||||
// timestamp_ms + model_call_id → buffered flush before tool (skip)
|
||||
// neither → final flush (skip if already streamed)
|
||||
// See https://cursor.com/docs/cli/reference/output-format.md#stream-json-format
|
||||
if (event.model_call_id) return false;
|
||||
const isPartial = Boolean(event.timestamp_ms);
|
||||
const content = event.message?.content;
|
||||
if (!Array.isArray(content)) return false;
|
||||
let text = "";
|
||||
for (const block of content) {
|
||||
if (block?.type === "text" && block.text) text += String(block.text);
|
||||
}
|
||||
if (!text) return false;
|
||||
if (!isPartial) {
|
||||
if (state.streamedAssistantText) return false;
|
||||
emitter.text(text);
|
||||
state.streamedAssistantText = true;
|
||||
return false;
|
||||
}
|
||||
emitter.text(text);
|
||||
state.streamedAssistantText = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
case "tool_call": {
|
||||
closeReasoning(state, emitter);
|
||||
const { id, name, args, result } = extractCliToolCall(event);
|
||||
if (!id) return false;
|
||||
if (!state.emittedToolCalls) state.emittedToolCalls = new Set();
|
||||
if (!state.emittedToolResults) state.emittedToolResults = new Set();
|
||||
|
||||
const subtype = String(event.subtype || "");
|
||||
if (subtype === "started" || subtype === "running" || !subtype) {
|
||||
if (!state.emittedToolCalls.has(id)) {
|
||||
state.emittedToolCalls.add(id);
|
||||
emitter.toolCall(name || "tool", args && typeof args === "object" ? args : {}, id);
|
||||
}
|
||||
}
|
||||
if (subtype === "completed" || subtype === "error") {
|
||||
if (!state.emittedToolCalls.has(id)) {
|
||||
state.emittedToolCalls.add(id);
|
||||
emitter.toolCall(name || "tool", args && typeof args === "object" ? args : {}, id);
|
||||
}
|
||||
if (!state.emittedToolResults.has(id)) {
|
||||
state.emittedToolResults.add(id);
|
||||
emitter.toolResult(id, resultToText(result || event.error || ""), name || "tool");
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
case "result":
|
||||
closeReasoning(state, emitter);
|
||||
if (event.session_id) {
|
||||
state.sessionId = event.session_id;
|
||||
emitter.sessionId?.(event.session_id);
|
||||
}
|
||||
if (event.is_error || event.subtype === "error") {
|
||||
state.failed = true;
|
||||
const message = String(event.result || event.error || event.message || "Cursor CLI turn failed");
|
||||
emitter.emitError(formatCursorCliErrorForUser(message));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
case "error":
|
||||
closeReasoning(state, emitter);
|
||||
state.failed = true;
|
||||
emitter.emitError(formatCursorCliErrorForUser(event.message || event.error || "Cursor CLI turn failed"));
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCursorCliErrorForUser(message) {
|
||||
const text = String(message || "").trim();
|
||||
if (
|
||||
/not authenticated|not logged in|please run .*login|unauthenticated|unauthorized/i.test(text)
|
||||
|| /(?:^|\b)(?:agent|cursor-agent)\s+login\b/i.test(text)
|
||||
) {
|
||||
return "Cursor CLI is not logged in. Run `cursor-agent login` in a terminal, then retry.";
|
||||
}
|
||||
if (/\bapi[_\s-]?key\b/i.test(text) && /invalid|missing|required|auth/i.test(text)) {
|
||||
return "Cursor CLI authentication failed. Run `cursor-agent login` or switch Cursor to API Key mode in Settings → AI.";
|
||||
}
|
||||
return text || "Cursor CLI turn failed";
|
||||
}
|
||||
|
||||
function createLineBuffer(onLine, maxBufferBytes = MAX_CURSOR_CLI_LINE_BYTES) {
|
||||
let buffer = "";
|
||||
let bufferedBytes = 0;
|
||||
let overflowed = false;
|
||||
const decoder = new StringDecoder("utf8");
|
||||
return {
|
||||
push(chunk) {
|
||||
if (overflowed) return;
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ""));
|
||||
bufferedBytes += bytes.length;
|
||||
buffer += decoder.write(bytes);
|
||||
let idx;
|
||||
let consumedLine = false;
|
||||
while ((idx = buffer.indexOf("\n")) >= 0) {
|
||||
const line = buffer.slice(0, idx).trim();
|
||||
buffer = buffer.slice(idx + 1);
|
||||
consumedLine = true;
|
||||
if (line) onLine(line);
|
||||
}
|
||||
if (consumedLine) bufferedBytes = Buffer.byteLength(buffer, "utf8") + decoder.lastNeed;
|
||||
if (bufferedBytes > maxBufferBytes) {
|
||||
overflowed = true;
|
||||
buffer = "";
|
||||
const error = new Error(`Cursor CLI message exceeded ${maxBufferBytes} bytes`);
|
||||
error.code = "CURSOR_CLI_LINE_LIMIT";
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
flush() {
|
||||
if (overflowed) return;
|
||||
buffer += decoder.end();
|
||||
const line = buffer.trim();
|
||||
buffer = "";
|
||||
if (line) onLine(line);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runCursorCliTurn({
|
||||
prompt,
|
||||
binPath,
|
||||
cwd,
|
||||
chatSessionId,
|
||||
getTempDir,
|
||||
model,
|
||||
env,
|
||||
permissionMode,
|
||||
resumeSessionId,
|
||||
injectedMcpServers,
|
||||
emitter,
|
||||
signal,
|
||||
spawnImpl,
|
||||
mergeMcp,
|
||||
workspaceCwd,
|
||||
abortGraceMs = CURSOR_CLI_ABORT_GRACE_MS,
|
||||
forceKillImpl,
|
||||
}) {
|
||||
const cliPath = String(binPath || "").trim();
|
||||
if (!cliPath) {
|
||||
emitter.emitError("Cursor Agent CLI not found. Install the Cursor CLI (`cursor-agent`) and ensure it is on PATH.");
|
||||
return { sessionId: resumeSessionId || null };
|
||||
}
|
||||
|
||||
let effectiveCwd;
|
||||
try {
|
||||
effectiveCwd = workspaceCwd || resolveCursorCliWorkspaceCwd({
|
||||
preferredCwd: cwd,
|
||||
chatSessionId,
|
||||
getTempDir,
|
||||
});
|
||||
} catch (err) {
|
||||
emitter.emitError(
|
||||
"Failed to prepare Netcatty MCP for Cursor CLI "
|
||||
+ `(cannot create workspace: ${err?.message || err}). `
|
||||
+ "Terminal tools will be unavailable.",
|
||||
);
|
||||
return { sessionId: resumeSessionId || null };
|
||||
}
|
||||
|
||||
const childEnv = stripCursorApiKeyFromEnv(env || process.env);
|
||||
const args = buildCursorCliArgs({
|
||||
model,
|
||||
resumeSessionId,
|
||||
permissionMode,
|
||||
cwd: effectiveCwd,
|
||||
prompt,
|
||||
});
|
||||
|
||||
const doMerge = mergeMcp || mergeWorkspaceMcpJson;
|
||||
let mcpHandle = null;
|
||||
if (Array.isArray(injectedMcpServers) && injectedMcpServers.length > 0) {
|
||||
try {
|
||||
mcpHandle = doMerge(effectiveCwd, injectedMcpServers);
|
||||
} catch (err) {
|
||||
emitter.emitError(
|
||||
"Failed to prepare Netcatty MCP for Cursor CLI "
|
||||
+ `(cannot write workspace MCP config: ${err?.message || err}). `
|
||||
+ "Terminal tools will be unavailable.",
|
||||
);
|
||||
return { sessionId: resumeSessionId || null };
|
||||
}
|
||||
}
|
||||
|
||||
const state = {
|
||||
sessionId: resumeSessionId || null,
|
||||
reasoningOpen: false,
|
||||
streamedAssistantText: false,
|
||||
failed: false,
|
||||
};
|
||||
|
||||
let child = null;
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
try { mcpHandle?.restore?.(); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
try {
|
||||
child = spawnCursorCliProcess(spawnImpl, cliPath, args, {
|
||||
cwd: effectiveCwd,
|
||||
env: childEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
emitter.emitError(formatCursorCliErrorForUser(err?.message || String(err)));
|
||||
return { sessionId: state.sessionId };
|
||||
}
|
||||
|
||||
const handleLine = (line) => {
|
||||
// Soft-cancel: ignore late stream-json after Stop (result/error would emitError).
|
||||
if (signal?.aborted) return;
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const stop = translateCursorCliEvent(event, emitter, state);
|
||||
if (stop && !signal?.aborted) state.failed = true;
|
||||
};
|
||||
|
||||
const stdoutBuffer = createLineBuffer(handleLine);
|
||||
let stderrText = "";
|
||||
let stderrBytes = 0;
|
||||
let stderrTruncated = false;
|
||||
let stderrEnded = false;
|
||||
const stderrDecoder = new StringDecoder("utf8");
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
if (signal?.aborted) return;
|
||||
try {
|
||||
stdoutBuffer.push(chunk);
|
||||
} catch (error) {
|
||||
if (!state.failed) {
|
||||
state.failed = true;
|
||||
emitter.emitError(formatCursorCliErrorForUser(error?.message || String(error)));
|
||||
}
|
||||
signalCursorCliProcessTree(child, "SIGKILL", forceKillImpl);
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
if (signal?.aborted) return;
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||||
const remaining = Math.max(0, MAX_CURSOR_CLI_STDERR_CHARS - stderrBytes);
|
||||
const accepted = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
|
||||
if (accepted.length > 0) stderrText += stderrDecoder.write(accepted);
|
||||
stderrBytes += accepted.length;
|
||||
if (accepted.length < buffer.length) stderrTruncated = true;
|
||||
});
|
||||
|
||||
let abortHandler = null;
|
||||
let forceKillTimer = null;
|
||||
await new Promise((resolve) => {
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(forceKillTimer);
|
||||
// Only flush remaining lines if not aborted — late error/result after
|
||||
// Stop must not surface as a failed turn.
|
||||
if (!signal?.aborted) stdoutBuffer.flush();
|
||||
resolve();
|
||||
};
|
||||
child.on("error", (err) => {
|
||||
// Soft-cancel: do not surface spawn errors after user Stop.
|
||||
if (!state.failed && !signal?.aborted) {
|
||||
state.failed = true;
|
||||
emitter.emitError(formatCursorCliErrorForUser(err?.message || String(err)));
|
||||
}
|
||||
finish();
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (!stderrEnded) {
|
||||
stderrEnded = true;
|
||||
if (!stderrTruncated || stderrDecoder.lastNeed === 0) stderrText += stderrDecoder.end();
|
||||
}
|
||||
// Soft-cancel: SIGTERM/kill after abort is not a turn failure.
|
||||
if (!state.failed && !signal?.aborted && code && code !== 0 && !state.streamedAssistantText) {
|
||||
const stderr = stderrText.trim();
|
||||
const message = stderr || `Cursor CLI exited with code ${code}`;
|
||||
state.failed = true;
|
||||
emitter.emitError(formatCursorCliErrorForUser(message));
|
||||
}
|
||||
finish();
|
||||
});
|
||||
|
||||
let terminationStarted = false;
|
||||
abortHandler = () => {
|
||||
if (settled || terminationStarted) return;
|
||||
terminationStarted = true;
|
||||
forceKillTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
signalCursorCliProcessTree(child, "SIGKILL", forceKillImpl);
|
||||
// Process APIs do not guarantee a close event when process-tree
|
||||
// termination itself fails. Stop must still release MCP config and the
|
||||
// renderer request within a fixed deadline.
|
||||
finish();
|
||||
}, Math.max(0, abortGraceMs));
|
||||
forceKillTimer.unref?.();
|
||||
signalCursorCliProcessTree(child, "SIGTERM");
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) abortHandler();
|
||||
else signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
});
|
||||
|
||||
if (signal) signal.removeEventListener("abort", abortHandler);
|
||||
cleanup();
|
||||
closeReasoning(state, emitter);
|
||||
|
||||
// Match cursorDriver: aborted turns must not report as successful done.
|
||||
if (!state.failed && !signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
}
|
||||
|
||||
return { sessionId: state.sessionId };
|
||||
}
|
||||
|
||||
async function listCursorCliModels({
|
||||
binPath,
|
||||
env,
|
||||
spawnImpl,
|
||||
abortController,
|
||||
signal,
|
||||
abortGraceMs = CURSOR_CLI_ABORT_GRACE_MS,
|
||||
forceKillImpl,
|
||||
} = {}) {
|
||||
const cliPath = String(binPath || "").trim();
|
||||
if (!cliPath) return { currentModelId: null, models: [] };
|
||||
const abortSignal = signal || abortController?.signal;
|
||||
if (abortSignal?.aborted) return { currentModelId: null, models: [] };
|
||||
|
||||
const childEnv = stripCursorApiKeyFromEnv(env || process.env);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
let stdout = "";
|
||||
let stdoutBytes = 0;
|
||||
let stdoutTruncated = false;
|
||||
let stdoutEnded = false;
|
||||
const stdoutDecoder = new StringDecoder("utf8");
|
||||
let settled = false;
|
||||
let abortHandler = null;
|
||||
let forceKillTimer = null;
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(forceKillTimer);
|
||||
if (abortSignal && abortHandler) {
|
||||
abortSignal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
let child;
|
||||
try {
|
||||
child = spawnCursorCliProcess(spawnImpl, cliPath, ["models"], {
|
||||
env: childEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
} catch {
|
||||
finish({ currentModelId: null, models: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
if (abortSignal?.aborted) return;
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||||
const remaining = Math.max(0, MAX_CURSOR_CLI_MODEL_STDOUT_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.on("error", () => finish({ currentModelId: null, models: [] }));
|
||||
child.on("close", () => {
|
||||
if (!stdoutEnded) {
|
||||
stdoutEnded = true;
|
||||
if (!stdoutTruncated || stdoutDecoder.lastNeed === 0) stdout += stdoutDecoder.end();
|
||||
}
|
||||
const models = [];
|
||||
const seen = new Set();
|
||||
let currentModelId = null;
|
||||
for (const line of String(stdout).split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || /^available models$/i.test(trimmed)) continue;
|
||||
const match = trimmed.match(/^([a-z0-9][a-z0-9._-]*)\s+-\s+(.+)$/i);
|
||||
if (!match) continue;
|
||||
const id = match[1];
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const rawName = match[2].trim();
|
||||
const isCurrent = /\(\s*current(?:\s*,\s*default)?\s*\)/i.test(rawName);
|
||||
if (isCurrent) currentModelId = id;
|
||||
const name = rawName
|
||||
.replace(/\s*\(\s*current(?:\s*,\s*default)?\s*\)\s*/ig, " ")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim() || id;
|
||||
models.push({ id, name });
|
||||
}
|
||||
if (!currentModelId && models.some((model) => model.id === "auto")) {
|
||||
currentModelId = "auto";
|
||||
}
|
||||
finish({ currentModelId, models });
|
||||
});
|
||||
|
||||
abortHandler = () => {
|
||||
if (settled) return;
|
||||
forceKillTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
signalCursorCliProcessTree(child, "SIGKILL", forceKillImpl);
|
||||
finish({ currentModelId: null, models: [] });
|
||||
}, Math.max(0, abortGraceMs));
|
||||
forceKillTimer.unref?.();
|
||||
signalCursorCliProcessTree(child, "SIGTERM", forceKillImpl);
|
||||
};
|
||||
if (abortSignal) {
|
||||
if (abortSignal.aborted) abortHandler();
|
||||
else abortSignal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CURSOR_CLI_MODEL,
|
||||
MAX_CURSOR_CLI_LINE_BYTES,
|
||||
NETCATTY_MCP_NAME,
|
||||
buildCursorCliArgs,
|
||||
createLineBuffer,
|
||||
formatCursorCliErrorForUser,
|
||||
listCursorCliModels,
|
||||
mergeWorkspaceMcpJson,
|
||||
resetMcpMergeRefcountsForTests,
|
||||
resolveCursorCliExecMode,
|
||||
resolveCursorCliModel,
|
||||
resolveCursorCliSpawnSpec,
|
||||
resolveCursorCliWorkspaceCwd,
|
||||
runCursorCliTurn,
|
||||
spawnCursorCliProcess,
|
||||
stripCursorApiKeyFromEnv,
|
||||
translateCursorCliEvent,
|
||||
};
|
||||
868
electron/bridges/aiBridge/sdk/cursorCliDriver.test.cjs
Normal file
868
electron/bridges/aiBridge/sdk/cursorCliDriver.test.cjs
Normal file
@@ -0,0 +1,868 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
buildCursorCliArgs,
|
||||
createLineBuffer,
|
||||
formatCursorCliErrorForUser,
|
||||
listCursorCliModels,
|
||||
mergeWorkspaceMcpJson,
|
||||
resetMcpMergeRefcountsForTests,
|
||||
resolveCursorCliExecMode,
|
||||
resolveCursorCliModel,
|
||||
resolveCursorCliSpawnSpec,
|
||||
resolveCursorCliWorkspaceCwd,
|
||||
runCursorCliTurn,
|
||||
spawnCursorCliProcess,
|
||||
stripCursorApiKeyFromEnv,
|
||||
translateCursorCliEvent,
|
||||
} = require("./cursorCliDriver.cjs");
|
||||
|
||||
function makeEmitter() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
text: (value) => calls.push(["text", value]),
|
||||
reasoning: (value) => calls.push(["reasoning", value]),
|
||||
reasoningEnd: () => calls.push(["reasoningEnd"]),
|
||||
toolCall: (name, args, id) => calls.push(["toolCall", name, args, id]),
|
||||
toolResult: (id, result, name) => calls.push(["toolResult", id, result, name]),
|
||||
sessionId: (id) => calls.push(["sessionId", id]),
|
||||
emitDone: () => calls.push(["done"]),
|
||||
emitError: (message) => calls.push(["error", message]),
|
||||
};
|
||||
}
|
||||
|
||||
test("resolveCursorCliModel defaults to auto", () => {
|
||||
assert.equal(resolveCursorCliModel(undefined), "auto");
|
||||
assert.equal(resolveCursorCliModel(""), "auto");
|
||||
assert.equal(resolveCursorCliModel("composer-2.5"), "composer-2.5");
|
||||
assert.equal(resolveCursorCliModel("gpt-5/high"), "gpt-5?effort=high");
|
||||
});
|
||||
|
||||
test("stripCursorApiKeyFromEnv removes CURSOR_API_KEY", () => {
|
||||
assert.deepEqual(
|
||||
stripCursorApiKeyFromEnv({ CURSOR_API_KEY: "secret", PATH: "/bin" }),
|
||||
{ PATH: "/bin" },
|
||||
);
|
||||
});
|
||||
|
||||
test("createLineBuffer rejects and releases an unterminated oversized message", () => {
|
||||
const lines = [];
|
||||
const lineBuffer = createLineBuffer((line) => lines.push(line), 8);
|
||||
lineBuffer.push(Buffer.from("12345678"));
|
||||
assert.throws(
|
||||
() => lineBuffer.push(Buffer.from("9")),
|
||||
(error) => error?.code === "CURSOR_CLI_LINE_LIMIT",
|
||||
);
|
||||
lineBuffer.flush();
|
||||
assert.deepEqual(lines, []);
|
||||
});
|
||||
|
||||
test("buildCursorCliArgs maps permission modes and resume", () => {
|
||||
assert.deepEqual(
|
||||
buildCursorCliArgs({
|
||||
model: "",
|
||||
permissionMode: "observer",
|
||||
resumeSessionId: "sess-1",
|
||||
cwd: "/repo",
|
||||
prompt: "hi",
|
||||
}),
|
||||
[
|
||||
"--print",
|
||||
"--trust",
|
||||
"--approve-mcps",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--stream-partial-output",
|
||||
"--model",
|
||||
"auto",
|
||||
"--workspace",
|
||||
"/repo",
|
||||
"--resume",
|
||||
"sess-1",
|
||||
"--mode",
|
||||
"ask",
|
||||
"hi",
|
||||
],
|
||||
);
|
||||
|
||||
const autoArgs = buildCursorCliArgs({
|
||||
model: "auto",
|
||||
permissionMode: "auto",
|
||||
cwd: "/repo",
|
||||
prompt: "go",
|
||||
});
|
||||
assert.ok(autoArgs.includes("--force"));
|
||||
assert.ok(!autoArgs.includes("--mode"));
|
||||
|
||||
// confirm must pass --force: stdin is ignored and Cursor asks y/n for shell tools.
|
||||
const confirmArgs = buildCursorCliArgs({
|
||||
model: "auto",
|
||||
permissionMode: "confirm",
|
||||
cwd: "/repo",
|
||||
prompt: "go",
|
||||
});
|
||||
assert.ok(confirmArgs.includes("--force"));
|
||||
assert.ok(!confirmArgs.includes("--mode"));
|
||||
});
|
||||
|
||||
test("formatCursorCliErrorForUser does not over-match bare login strings", () => {
|
||||
assert.match(
|
||||
formatCursorCliErrorForUser("Not authenticated"),
|
||||
/not logged in/i,
|
||||
);
|
||||
assert.equal(
|
||||
formatCursorCliErrorForUser("Failed to run login form validation"),
|
||||
"Failed to run login form validation",
|
||||
);
|
||||
});
|
||||
|
||||
test("translateCursorCliEvent streams thinking, text, and tools", () => {
|
||||
const emitter = makeEmitter();
|
||||
const state = {};
|
||||
translateCursorCliEvent({ type: "system", subtype: "init", session_id: "s1" }, emitter, state);
|
||||
translateCursorCliEvent({ type: "thinking", subtype: "delta", text: "plan" }, emitter, state);
|
||||
translateCursorCliEvent({ type: "thinking", subtype: "completed" }, emitter, state);
|
||||
translateCursorCliEvent({
|
||||
type: "assistant",
|
||||
timestamp_ms: 1,
|
||||
message: { content: [{ type: "text", text: "Hi" }] },
|
||||
}, emitter, state);
|
||||
translateCursorCliEvent({
|
||||
type: "assistant",
|
||||
timestamp_ms: 2,
|
||||
model_call_id: "call-dup",
|
||||
message: { content: [{ type: "text", text: "Hi" }] },
|
||||
}, emitter, state);
|
||||
translateCursorCliEvent({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "text", text: "Hi" }] },
|
||||
}, emitter, state);
|
||||
translateCursorCliEvent({
|
||||
type: "tool_call",
|
||||
subtype: "started",
|
||||
call_id: "c1",
|
||||
tool_call: { getMcpToolsToolCall: { args: { a: 1 } } },
|
||||
}, emitter, state);
|
||||
translateCursorCliEvent({
|
||||
type: "tool_call",
|
||||
subtype: "completed",
|
||||
call_id: "c1",
|
||||
tool_call: { getMcpToolsToolCall: { args: { a: 1 }, result: { success: { content: "ok" } } } },
|
||||
}, emitter, state);
|
||||
|
||||
assert.deepEqual(emitter.calls, [
|
||||
["sessionId", "s1"],
|
||||
["reasoning", "plan"],
|
||||
["reasoningEnd"],
|
||||
["text", "Hi"],
|
||||
["toolCall", "getMcpTools", { a: 1 }, "c1"],
|
||||
["toolResult", "c1", "ok", "getMcpTools"],
|
||||
]);
|
||||
assert.equal(state.sessionId, "s1");
|
||||
});
|
||||
|
||||
test("resolveCursorCliExecMode maps observer to ask and others to agent", () => {
|
||||
assert.equal(resolveCursorCliExecMode("observer"), "ask");
|
||||
assert.equal(resolveCursorCliExecMode("confirm"), "agent");
|
||||
assert.equal(resolveCursorCliExecMode("auto"), "agent");
|
||||
});
|
||||
|
||||
test("mergeWorkspaceMcpJson upserts netcatty without dropping others", () => {
|
||||
resetMcpMergeRefcountsForTests();
|
||||
const files = new Map();
|
||||
files.set("/repo/.cursor/mcp.json", JSON.stringify({
|
||||
mcpServers: { other: { command: "echo" } },
|
||||
}, null, 2));
|
||||
|
||||
const handle = mergeWorkspaceMcpJson("/repo", [{
|
||||
name: "netcatty-remote-hosts",
|
||||
command: "node",
|
||||
args: ["mcp.cjs"],
|
||||
env: [{ name: "TOKEN", value: "x" }],
|
||||
}], {
|
||||
existsSync: (p) => files.has(p) || p === "/repo/.cursor",
|
||||
readFileSync: (p) => files.get(p),
|
||||
writeFileSync: (p, data) => { files.set(p, data); },
|
||||
mkdirSync: () => {},
|
||||
});
|
||||
|
||||
const written = JSON.parse(files.get("/repo/.cursor/mcp.json"));
|
||||
assert.equal(written.mcpServers.other.command, "echo");
|
||||
assert.equal(written.mcpServers["netcatty-remote-hosts"].command, "node");
|
||||
assert.equal(written.mcpServers["netcatty-remote-hosts"].type, "stdio");
|
||||
assert.equal(written.mcpServers["netcatty-remote-hosts"].env.TOKEN, "x");
|
||||
|
||||
handle.restore();
|
||||
assert.ok(files.get("/repo/.cursor/mcp.json").includes('"other"'));
|
||||
});
|
||||
|
||||
test("mergeWorkspaceMcpJson concurrent turns restore original only after last", () => {
|
||||
resetMcpMergeRefcountsForTests();
|
||||
const files = new Map();
|
||||
const original = JSON.stringify({ mcpServers: { other: { command: "echo" } } }, null, 2);
|
||||
files.set("/repo/.cursor/mcp.json", original);
|
||||
const fsApi = {
|
||||
existsSync: (p) => files.has(p) || p === "/repo/.cursor",
|
||||
readFileSync: (p) => files.get(p),
|
||||
writeFileSync: (p, data) => { files.set(p, data); },
|
||||
mkdirSync: () => {},
|
||||
};
|
||||
|
||||
const a = mergeWorkspaceMcpJson("/repo", [{
|
||||
name: "netcatty-remote-hosts",
|
||||
command: "node",
|
||||
args: ["a.cjs"],
|
||||
}], fsApi);
|
||||
const b = mergeWorkspaceMcpJson("/repo", [{
|
||||
name: "netcatty-remote-hosts",
|
||||
command: "node",
|
||||
args: ["b.cjs"],
|
||||
}], fsApi);
|
||||
|
||||
a.restore();
|
||||
// First restore must keep the merged file while another turn is in flight.
|
||||
assert.ok(files.get("/repo/.cursor/mcp.json").includes("netcatty-remote-hosts"));
|
||||
b.restore();
|
||||
assert.equal(files.get("/repo/.cursor/mcp.json"), original);
|
||||
});
|
||||
|
||||
test("runCursorCliTurn strips API key, parses stream, emits done", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const observed = { env: null, args: null };
|
||||
|
||||
const fakeChild = new EventEmitter();
|
||||
fakeChild.stdout = new EventEmitter();
|
||||
fakeChild.stderr = new EventEmitter();
|
||||
fakeChild.killed = false;
|
||||
fakeChild.kill = () => { fakeChild.killed = true; };
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
runCursorCliTurn({
|
||||
prompt: "hi",
|
||||
binPath: "/bin/agent",
|
||||
cwd: "/repo",
|
||||
model: "",
|
||||
env: { CURSOR_API_KEY: "secret", PATH: "/bin" },
|
||||
permissionMode: "confirm",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
spawnImpl: (cmd, args, opts) => {
|
||||
observed.env = opts.env;
|
||||
observed.args = args;
|
||||
queueMicrotask(() => {
|
||||
fakeChild.stdout.emit("data", `${JSON.stringify({
|
||||
type: "system", subtype: "init", session_id: "sess-cli", apiKeySource: "login",
|
||||
})}\n`);
|
||||
fakeChild.stdout.emit("data", `${JSON.stringify({
|
||||
type: "assistant", timestamp_ms: 1, message: { content: [{ type: "text", text: "PONG" }] },
|
||||
})}\n`);
|
||||
fakeChild.stdout.emit("data", `${JSON.stringify({
|
||||
type: "result", subtype: "success", session_id: "sess-cli", result: "PONG",
|
||||
})}\n`);
|
||||
fakeChild.emit("close", 0);
|
||||
});
|
||||
return fakeChild;
|
||||
},
|
||||
mergeMcp: () => ({ restore() {} }),
|
||||
}).then(resolve, reject);
|
||||
});
|
||||
|
||||
assert.equal(observed.env.CURSOR_API_KEY, undefined);
|
||||
assert.equal(observed.env.PATH, "/bin");
|
||||
assert.ok(observed.args.includes("auto"));
|
||||
assert.ok(observed.args.includes("--force"));
|
||||
assert.equal(result.sessionId, "sess-cli");
|
||||
assert.deepEqual(emitter.calls, [
|
||||
["sessionId", "sess-cli"],
|
||||
["text", "PONG"],
|
||||
["sessionId", "sess-cli"],
|
||||
["done"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCursorCliTurn preserves a Chinese JSON event split across UTF-8 chunks", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const fakeChild = new EventEmitter();
|
||||
fakeChild.stdout = new EventEmitter();
|
||||
fakeChild.stderr = new EventEmitter();
|
||||
fakeChild.kill = () => {};
|
||||
|
||||
await runCursorCliTurn({
|
||||
prompt: "hi",
|
||||
binPath: "/bin/agent",
|
||||
cwd: "/repo",
|
||||
env: {},
|
||||
permissionMode: "confirm",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
spawnImpl: () => {
|
||||
queueMicrotask(() => {
|
||||
const line = Buffer.from(`${JSON.stringify({
|
||||
type: "assistant",
|
||||
timestamp_ms: 1,
|
||||
message: { content: [{ type: "text", text: "中文回复" }] },
|
||||
})}\n`, "utf8");
|
||||
const split = line.indexOf(Buffer.from("中", "utf8")) + 2;
|
||||
fakeChild.stdout.emit("data", line.subarray(0, split));
|
||||
fakeChild.stdout.emit("data", line.subarray(split));
|
||||
fakeChild.emit("close", 0);
|
||||
});
|
||||
return fakeChild;
|
||||
},
|
||||
mergeMcp: () => ({ restore() {} }),
|
||||
});
|
||||
|
||||
assert.ok(emitter.calls.some((call) => call[0] === "text" && call[1] === "中文回复"));
|
||||
});
|
||||
|
||||
test("runCursorCliTurn preserves Chinese stderr split across UTF-8 chunks", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const fakeChild = new EventEmitter();
|
||||
fakeChild.stdout = new EventEmitter();
|
||||
fakeChild.stderr = new EventEmitter();
|
||||
fakeChild.kill = () => {};
|
||||
|
||||
await runCursorCliTurn({
|
||||
prompt: "hi",
|
||||
binPath: "/bin/agent",
|
||||
cwd: "/repo",
|
||||
env: {},
|
||||
permissionMode: "confirm",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
spawnImpl: () => {
|
||||
queueMicrotask(() => {
|
||||
const bytes = Buffer.from("中文错误", "utf8");
|
||||
fakeChild.stderr.emit("data", bytes.subarray(0, 2));
|
||||
fakeChild.stderr.emit("data", bytes.subarray(2));
|
||||
fakeChild.emit("close", 1);
|
||||
});
|
||||
return fakeChild;
|
||||
},
|
||||
mergeMcp: () => ({ restore() {} }),
|
||||
});
|
||||
|
||||
assert.ok(emitter.calls.some((call) => call[0] === "error" && call[1] === "中文错误"));
|
||||
});
|
||||
|
||||
test("runCursorCliTurn abort after text does not emit done", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const ac = new AbortController();
|
||||
const fakeChild = new EventEmitter();
|
||||
fakeChild.stdout = new EventEmitter();
|
||||
fakeChild.stderr = new EventEmitter();
|
||||
fakeChild.killed = false;
|
||||
fakeChild.kill = () => {
|
||||
fakeChild.killed = true;
|
||||
queueMicrotask(() => fakeChild.emit("close", 143));
|
||||
};
|
||||
|
||||
const turnPromise = runCursorCliTurn({
|
||||
prompt: "hi",
|
||||
binPath: "/bin/agent",
|
||||
cwd: "/repo",
|
||||
model: "auto",
|
||||
env: {},
|
||||
permissionMode: "confirm",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
signal: ac.signal,
|
||||
spawnImpl: () => {
|
||||
queueMicrotask(() => {
|
||||
fakeChild.stdout.emit("data", `${JSON.stringify({
|
||||
type: "assistant", timestamp_ms: 1, message: { content: [{ type: "text", text: "partial" }] },
|
||||
})}\n`);
|
||||
ac.abort();
|
||||
});
|
||||
return fakeChild;
|
||||
},
|
||||
mergeMcp: () => ({ restore() {} }),
|
||||
});
|
||||
|
||||
await turnPromise;
|
||||
assert.ok(fakeChild.killed);
|
||||
assert.deepEqual(emitter.calls, [
|
||||
["text", "partial"],
|
||||
]);
|
||||
assert.ok(!emitter.calls.some((c) => c[0] === "done"));
|
||||
assert.ok(!emitter.calls.some((c) => c[0] === "error"));
|
||||
});
|
||||
|
||||
test("runCursorCliTurn abort before any text is soft cancel (no error/done)", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const ac = new AbortController();
|
||||
const fakeChild = new EventEmitter();
|
||||
fakeChild.stdout = new EventEmitter();
|
||||
fakeChild.stderr = new EventEmitter();
|
||||
fakeChild.killed = false;
|
||||
fakeChild.kill = () => {
|
||||
fakeChild.killed = true;
|
||||
queueMicrotask(() => fakeChild.emit("close", 143));
|
||||
};
|
||||
|
||||
await runCursorCliTurn({
|
||||
prompt: "hi",
|
||||
binPath: "/bin/agent",
|
||||
cwd: "/repo",
|
||||
model: "auto",
|
||||
env: {},
|
||||
permissionMode: "confirm",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
signal: ac.signal,
|
||||
spawnImpl: () => {
|
||||
queueMicrotask(() => ac.abort());
|
||||
return fakeChild;
|
||||
},
|
||||
mergeMcp: () => ({ restore() {} }),
|
||||
});
|
||||
|
||||
assert.ok(fakeChild.killed);
|
||||
assert.deepEqual(emitter.calls, []);
|
||||
});
|
||||
|
||||
test("runCursorCliTurn force-kills and settles when the CLI ignores SIGTERM", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const ac = new AbortController();
|
||||
const fakeChild = new EventEmitter();
|
||||
fakeChild.stdout = new EventEmitter();
|
||||
fakeChild.stderr = new EventEmitter();
|
||||
fakeChild.killed = false;
|
||||
const signals = [];
|
||||
fakeChild.kill = (signal) => {
|
||||
signals.push(signal);
|
||||
return true;
|
||||
};
|
||||
let restored = false;
|
||||
|
||||
const turn = runCursorCliTurn({
|
||||
prompt: "hi",
|
||||
binPath: "/bin/agent",
|
||||
cwd: "/repo",
|
||||
model: "auto",
|
||||
env: {},
|
||||
permissionMode: "confirm",
|
||||
injectedMcpServers: [{ name: "netcatty", command: "node", args: [] }],
|
||||
emitter,
|
||||
signal: ac.signal,
|
||||
abortGraceMs: 5,
|
||||
forceKillImpl: (child) => child.kill("SIGKILL"),
|
||||
spawnImpl: () => fakeChild,
|
||||
mergeMcp: () => ({ restore() { restored = true; } }),
|
||||
});
|
||||
ac.abort();
|
||||
|
||||
await Promise.race([
|
||||
turn,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("aborted Cursor CLI did not settle")), 50)),
|
||||
]);
|
||||
|
||||
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
|
||||
assert.equal(restored, true);
|
||||
assert.deepEqual(emitter.calls, []);
|
||||
});
|
||||
|
||||
test("runCursorCliTurn ignores late error events after abort (before text)", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const ac = new AbortController();
|
||||
const fakeChild = new EventEmitter();
|
||||
fakeChild.stdout = new EventEmitter();
|
||||
fakeChild.stderr = new EventEmitter();
|
||||
fakeChild.killed = false;
|
||||
fakeChild.kill = () => {
|
||||
fakeChild.killed = true;
|
||||
};
|
||||
|
||||
await runCursorCliTurn({
|
||||
prompt: "hi",
|
||||
binPath: "/bin/agent",
|
||||
cwd: "/repo",
|
||||
model: "auto",
|
||||
env: {},
|
||||
permissionMode: "confirm",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
signal: ac.signal,
|
||||
spawnImpl: () => {
|
||||
queueMicrotask(() => {
|
||||
ac.abort();
|
||||
// Late stream after Stop — must not surface as emitError.
|
||||
fakeChild.stdout.emit("data", `${JSON.stringify({
|
||||
type: "error", message: "not authenticated",
|
||||
})}\n`);
|
||||
fakeChild.stdout.emit("data", `${JSON.stringify({
|
||||
type: "result", subtype: "error", is_error: true, result: "boom",
|
||||
})}\n`);
|
||||
fakeChild.emit("close", 1);
|
||||
});
|
||||
return fakeChild;
|
||||
},
|
||||
mergeMcp: () => ({ restore() {} }),
|
||||
});
|
||||
|
||||
assert.deepEqual(emitter.calls, []);
|
||||
assert.ok(!emitter.calls.some((c) => c[0] === "error"));
|
||||
assert.ok(!emitter.calls.some((c) => c[0] === "done"));
|
||||
});
|
||||
|
||||
test("runCursorCliTurn ignores late error after abort following partial text", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const ac = new AbortController();
|
||||
const fakeChild = new EventEmitter();
|
||||
fakeChild.stdout = new EventEmitter();
|
||||
fakeChild.stderr = new EventEmitter();
|
||||
fakeChild.killed = false;
|
||||
fakeChild.kill = () => {
|
||||
fakeChild.killed = true;
|
||||
};
|
||||
|
||||
await runCursorCliTurn({
|
||||
prompt: "hi",
|
||||
binPath: "/bin/agent",
|
||||
cwd: "/repo",
|
||||
model: "auto",
|
||||
env: {},
|
||||
permissionMode: "auto",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
signal: ac.signal,
|
||||
spawnImpl: () => {
|
||||
queueMicrotask(() => {
|
||||
fakeChild.stdout.emit("data", `${JSON.stringify({
|
||||
type: "assistant", timestamp_ms: 1, message: { content: [{ type: "text", text: "hi" }] },
|
||||
})}\n`);
|
||||
ac.abort();
|
||||
fakeChild.stdout.emit("data", `${JSON.stringify({
|
||||
type: "result", subtype: "error", is_error: true, result: "killed",
|
||||
})}\n`);
|
||||
fakeChild.emit("close", 143);
|
||||
});
|
||||
return fakeChild;
|
||||
},
|
||||
mergeMcp: () => ({ restore() {} }),
|
||||
});
|
||||
|
||||
assert.deepEqual(emitter.calls, [
|
||||
["text", "hi"],
|
||||
]);
|
||||
assert.ok(!emitter.calls.some((c) => c[0] === "error"));
|
||||
assert.ok(!emitter.calls.some((c) => c[0] === "done"));
|
||||
});
|
||||
|
||||
test("runCursorCliTurn closes open reasoning before done", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const fakeChild = new EventEmitter();
|
||||
fakeChild.stdout = new EventEmitter();
|
||||
fakeChild.stderr = new EventEmitter();
|
||||
fakeChild.killed = false;
|
||||
fakeChild.kill = () => { fakeChild.killed = true; };
|
||||
|
||||
await runCursorCliTurn({
|
||||
prompt: "hi",
|
||||
binPath: "/bin/agent",
|
||||
cwd: "/repo",
|
||||
model: "auto",
|
||||
env: {},
|
||||
permissionMode: "auto",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
spawnImpl: () => {
|
||||
queueMicrotask(() => {
|
||||
fakeChild.stdout.emit("data", `${JSON.stringify({
|
||||
type: "thinking", subtype: "delta", text: "hmm",
|
||||
})}\n`);
|
||||
fakeChild.emit("close", 0);
|
||||
});
|
||||
return fakeChild;
|
||||
},
|
||||
mergeMcp: () => ({ restore() {} }),
|
||||
});
|
||||
|
||||
assert.deepEqual(emitter.calls, [
|
||||
["reasoning", "hmm"],
|
||||
["reasoningEnd"],
|
||||
["done"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("resolveCursorCliSpawnSpec keeps a native exe on argv without a shell", () => {
|
||||
const exePath = process.platform === "win32"
|
||||
? "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.exe"
|
||||
: "/usr/local/bin/cursor-agent";
|
||||
const args = ["--print", "--trust"];
|
||||
const exe = resolveCursorCliSpawnSpec(exePath, args);
|
||||
assert.equal(exe.shell, false);
|
||||
assert.equal(exe.command, exePath);
|
||||
assert.deepEqual(exe.args, args);
|
||||
});
|
||||
|
||||
test("spawnCursorCliProcess launches the installer node+script with the prompt on argv", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-turn-spawn-"));
|
||||
try {
|
||||
const versionDir = path.join(tmp, "versions", "2026.06.01-abc");
|
||||
fs.mkdirSync(versionDir, { recursive: true });
|
||||
const nodeExe = path.join(versionDir, "node.exe");
|
||||
const script = path.join(versionDir, "index.js");
|
||||
fs.writeFileSync(nodeExe, "", "utf8");
|
||||
fs.writeFileSync(script, "", "utf8");
|
||||
const shimPath = path.join(tmp, "cursor-agent.cmd");
|
||||
fs.writeFileSync(
|
||||
shimPath,
|
||||
`@ECHO off\r\n"%~dp0\\versions\\2026.06.01-abc\\node.exe" "%~dp0\\versions\\2026.06.01-abc\\index.js" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const prompt = 'review "%TEMP%" then run whoami';
|
||||
const calls = [];
|
||||
spawnCursorCliProcess(
|
||||
(command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return { stdout: { on() {} }, stderr: { on() {} }, on() {}, kill() {} };
|
||||
},
|
||||
shimPath,
|
||||
["--print", "--trust", prompt],
|
||||
{ windowsHide: true },
|
||||
);
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].command, nodeExe);
|
||||
assert.deepEqual(calls[0].args, [script, "--print", "--trust", prompt]);
|
||||
assert.equal(calls[0].options.shell, false);
|
||||
assert.equal(String(calls[0].command).includes("cmd.exe"), false);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("spawnCursorCliProcess forwards shell from resolveCursorCliSpawnSpec", () => {
|
||||
const calls = [];
|
||||
const fakeChild = {
|
||||
stdout: { on() {} },
|
||||
stderr: { on() {} },
|
||||
stdin: null,
|
||||
on() {},
|
||||
kill() {},
|
||||
};
|
||||
const cliPath = "/usr/local/bin/cursor-agent";
|
||||
const child = spawnCursorCliProcess(
|
||||
(command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return fakeChild;
|
||||
},
|
||||
cliPath,
|
||||
["models"],
|
||||
{ cwd: "/repo", windowsHide: true },
|
||||
);
|
||||
assert.equal(child, fakeChild);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].command, cliPath);
|
||||
assert.deepEqual(calls[0].args, ["models"]);
|
||||
assert.equal(calls[0].options.cwd, "/repo");
|
||||
assert.equal(calls[0].options.windowsHide, true);
|
||||
assert.equal(calls[0].options.shell, false);
|
||||
});
|
||||
|
||||
test("listCursorCliModels parses agent models output and prefers auto", async () => {
|
||||
const fakeChild = new EventEmitter();
|
||||
fakeChild.stdout = new EventEmitter();
|
||||
fakeChild.stderr = new EventEmitter();
|
||||
|
||||
const catalog = await listCursorCliModels({
|
||||
binPath: "/bin/agent",
|
||||
env: { CURSOR_API_KEY: "secret" },
|
||||
spawnImpl: (cmd, args, opts) => {
|
||||
assert.equal(cmd, "/bin/agent");
|
||||
assert.deepEqual(args, ["models"]);
|
||||
assert.equal(opts.env.CURSOR_API_KEY, undefined);
|
||||
queueMicrotask(() => {
|
||||
fakeChild.stdout.emit("data", [
|
||||
"Available models",
|
||||
"",
|
||||
"auto - Auto (current, default)",
|
||||
"composer-2.5 - Composer 2.5",
|
||||
"gpt-5.2 - GPT-5.2",
|
||||
"",
|
||||
].join("\n"));
|
||||
fakeChild.emit("close", 0);
|
||||
});
|
||||
return fakeChild;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(catalog, {
|
||||
currentModelId: "auto",
|
||||
models: [
|
||||
{ id: "auto", name: "Auto" },
|
||||
{ id: "composer-2.5", name: "Composer 2.5" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("listCursorCliModels preserves Chinese model names split across UTF-8 chunks", async () => {
|
||||
const fakeChild = new EventEmitter();
|
||||
fakeChild.stdout = new EventEmitter();
|
||||
fakeChild.stderr = new EventEmitter();
|
||||
fakeChild.kill = () => {};
|
||||
|
||||
const catalogPromise = listCursorCliModels({
|
||||
binPath: "/bin/agent",
|
||||
env: {},
|
||||
spawnImpl: () => {
|
||||
queueMicrotask(() => {
|
||||
const bytes = Buffer.from("model-cn - 中文模型\n", "utf8");
|
||||
const split = bytes.indexOf(Buffer.from("中", "utf8")) + 1;
|
||||
fakeChild.stdout.emit("data", bytes.subarray(0, split));
|
||||
fakeChild.stdout.emit("data", bytes.subarray(split));
|
||||
fakeChild.emit("close", 0);
|
||||
});
|
||||
return fakeChild;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await catalogPromise, {
|
||||
currentModelId: null,
|
||||
models: [{ id: "model-cn", name: "中文模型" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("listCursorCliModels aborts a hung CLI and settles after forced cleanup", async () => {
|
||||
const fakeChild = new EventEmitter();
|
||||
fakeChild.stdout = new EventEmitter();
|
||||
fakeChild.stderr = new EventEmitter();
|
||||
fakeChild.pid = 4242;
|
||||
const signals = [];
|
||||
const abortController = new AbortController();
|
||||
|
||||
const catalogPromise = listCursorCliModels({
|
||||
binPath: "/bin/agent",
|
||||
env: {},
|
||||
abortController,
|
||||
abortGraceMs: 0,
|
||||
forceKillImpl: (_child, signal) => signals.push(signal),
|
||||
spawnImpl: () => fakeChild,
|
||||
});
|
||||
abortController.abort();
|
||||
|
||||
const outcome = await Promise.race([
|
||||
catalogPromise.then(() => "settled"),
|
||||
new Promise((resolve) => setTimeout(() => resolve("hung"), 20)),
|
||||
]);
|
||||
if (outcome === "hung") fakeChild.emit("close", 0);
|
||||
assert.equal(outcome, "settled");
|
||||
assert.deepEqual(await catalogPromise, { currentModelId: null, models: [] });
|
||||
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
|
||||
});
|
||||
|
||||
test("resolveCursorCliWorkspaceCwd prefers Netcatty temp over unwritable preferred cwd", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-ws-"));
|
||||
const resolved = resolveCursorCliWorkspaceCwd({
|
||||
preferredCwd: "/",
|
||||
chatSessionId: "ai_chat_1",
|
||||
getTempDir: () => tempRoot,
|
||||
});
|
||||
assert.equal(resolved, path.join(tempRoot, "cursor-cli-mcp", "ai_chat_1"));
|
||||
assert.ok(fs.statSync(resolved).isDirectory());
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("runCursorCliTurn uses temp workspace for MCP merge and --workspace when cwd is /", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-ws-"));
|
||||
const observed = { spawnCwd: null, args: null, mergeCwd: null };
|
||||
const fakeChild = new EventEmitter();
|
||||
fakeChild.stdout = new EventEmitter();
|
||||
fakeChild.stderr = new EventEmitter();
|
||||
fakeChild.killed = false;
|
||||
fakeChild.kill = () => { fakeChild.killed = true; };
|
||||
|
||||
await runCursorCliTurn({
|
||||
prompt: "hi",
|
||||
binPath: "/bin/agent",
|
||||
cwd: "/",
|
||||
chatSessionId: "chat-packaged",
|
||||
getTempDir: () => tempRoot,
|
||||
model: "auto",
|
||||
env: {},
|
||||
permissionMode: "confirm",
|
||||
injectedMcpServers: [{
|
||||
name: "netcatty-remote-hosts",
|
||||
command: "node",
|
||||
args: ["server.cjs"],
|
||||
env: [{ name: "NETCATTY_MCP_PORT", value: "1" }],
|
||||
}],
|
||||
emitter,
|
||||
spawnImpl: (_cmd, args, opts) => {
|
||||
observed.spawnCwd = opts.cwd;
|
||||
observed.args = args;
|
||||
queueMicrotask(() => {
|
||||
fakeChild.stdout.emit("data", `${JSON.stringify({
|
||||
type: "assistant", timestamp_ms: 1, message: { content: [{ type: "text", text: "ok" }] },
|
||||
})}\n`);
|
||||
fakeChild.stdout.emit("data", `${JSON.stringify({
|
||||
type: "result", subtype: "success", result: "ok",
|
||||
})}\n`);
|
||||
fakeChild.emit("close", 0);
|
||||
});
|
||||
return fakeChild;
|
||||
},
|
||||
mergeMcp: (mergeCwd) => {
|
||||
observed.mergeCwd = mergeCwd;
|
||||
return { restore() {} };
|
||||
},
|
||||
});
|
||||
|
||||
const expected = path.join(tempRoot, "cursor-cli-mcp", "chat-packaged");
|
||||
assert.equal(observed.mergeCwd, expected);
|
||||
assert.equal(observed.spawnCwd, expected);
|
||||
assert.ok(observed.args.includes("--workspace"));
|
||||
assert.equal(observed.args[observed.args.indexOf("--workspace") + 1], expected);
|
||||
assert.ok(!emitter.calls.some((c) => c[0] === "error"));
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("runCursorCliTurn surfaces MCP merge failure instead of continuing without tools", async () => {
|
||||
const emitter = makeEmitter();
|
||||
let spawned = false;
|
||||
|
||||
await runCursorCliTurn({
|
||||
prompt: "hi",
|
||||
binPath: "/bin/agent",
|
||||
cwd: "/",
|
||||
chatSessionId: "chat-fail",
|
||||
getTempDir: () => "/definitely-not-writable-root-only",
|
||||
model: "auto",
|
||||
env: {},
|
||||
permissionMode: "confirm",
|
||||
injectedMcpServers: [{
|
||||
name: "netcatty-remote-hosts",
|
||||
command: "node",
|
||||
args: ["server.cjs"],
|
||||
}],
|
||||
emitter,
|
||||
spawnImpl: () => {
|
||||
spawned = true;
|
||||
const fakeChild = new EventEmitter();
|
||||
fakeChild.stdout = new EventEmitter();
|
||||
fakeChild.stderr = new EventEmitter();
|
||||
fakeChild.killed = false;
|
||||
fakeChild.kill = () => {};
|
||||
return fakeChild;
|
||||
},
|
||||
mergeMcp: () => {
|
||||
const err = new Error("ENOENT: mkdir '/.cursor'");
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(spawned, false);
|
||||
assert.equal(emitter.calls.length, 1);
|
||||
assert.equal(emitter.calls[0][0], "error");
|
||||
assert.match(emitter.calls[0][1], /Failed to prepare Netcatty MCP for Cursor CLI/i);
|
||||
});
|
||||
559
electron/bridges/aiBridge/sdk/cursorDriver.cjs
Normal file
559
electron/bridges/aiBridge/sdk/cursorDriver.cjs
Normal file
@@ -0,0 +1,559 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Cursor backend driver — wraps @cursor/sdk.
|
||||
*
|
||||
* Cursor SDK local agents use Agent.create({ apiKey, model, local:{cwd},
|
||||
* mcpServers }) and stream SDKMessage events from run.stream().
|
||||
*/
|
||||
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
|
||||
|
||||
const DEFAULT_CURSOR_MODEL = "composer-2.5";
|
||||
|
||||
function toCursorMcpServers(injectedMcpServers) {
|
||||
const servers = {};
|
||||
for (const cfg of injectedMcpServers || []) {
|
||||
if (!cfg || !cfg.name || !cfg.command) continue;
|
||||
servers[cfg.name] = {
|
||||
type: "stdio",
|
||||
command: cfg.command,
|
||||
args: cfg.args || [],
|
||||
env: mcpEnvPairsToObject(cfg.env),
|
||||
};
|
||||
}
|
||||
return servers;
|
||||
}
|
||||
|
||||
const CURSOR_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh"]);
|
||||
const CURSOR_FALLBACK_THINKING = {
|
||||
"gpt-5.5": ["low", "medium", "high"],
|
||||
"gpt-5.2": ["low", "medium", "high"],
|
||||
"gpt-5.1": ["low", "medium", "high"],
|
||||
"gpt-5": ["low", "medium", "high"],
|
||||
"claude-opus-4.6": ["low", "medium", "high"],
|
||||
"claude-sonnet-4.6": ["low", "medium", "high"],
|
||||
};
|
||||
|
||||
function parseCursorModelSelection(model) {
|
||||
const raw = String(model || DEFAULT_CURSOR_MODEL).trim() || DEFAULT_CURSOR_MODEL;
|
||||
const queryIndex = raw.indexOf("?");
|
||||
if (queryIndex >= 0) {
|
||||
const id = raw.slice(0, queryIndex);
|
||||
const search = new URLSearchParams(raw.slice(queryIndex + 1));
|
||||
const params = [];
|
||||
for (const [paramId, value] of search.entries()) {
|
||||
if (paramId && value) params.push({ id: paramId, value });
|
||||
}
|
||||
return params.length > 0 ? { id, params } : { id };
|
||||
}
|
||||
const slash = raw.lastIndexOf("/");
|
||||
if (slash > 0) {
|
||||
const effort = raw.slice(slash + 1).toLowerCase();
|
||||
if (CURSOR_REASONING_EFFORTS.has(effort)) {
|
||||
return { id: raw.slice(0, slash), params: [{ id: "effort", value: effort }] };
|
||||
}
|
||||
}
|
||||
return { id: raw };
|
||||
}
|
||||
|
||||
function encodeCursorCliModel(model) {
|
||||
const raw = String(model || "").trim();
|
||||
if (!raw) return "";
|
||||
const selection = parseCursorModelSelection(raw);
|
||||
if (!selection.params?.length) return selection.id || "";
|
||||
const search = new URLSearchParams();
|
||||
for (const param of selection.params) {
|
||||
if (param?.id && param?.value) search.set(param.id, param.value);
|
||||
}
|
||||
const qs = search.toString();
|
||||
return qs ? `${selection.id}?${qs}` : (selection.id || "");
|
||||
}
|
||||
|
||||
function buildCursorAgentOptions({ apiKey, env, model, cwd, injectedMcpServers }) {
|
||||
const effectiveApiKey = apiKey || env?.CURSOR_API_KEY || process.env.CURSOR_API_KEY;
|
||||
const options = {
|
||||
apiKey: effectiveApiKey,
|
||||
model: parseCursorModelSelection(model),
|
||||
local: {
|
||||
cwd: cwd || process.cwd(),
|
||||
autoReview: false,
|
||||
},
|
||||
};
|
||||
const mcpServers = toCursorMcpServers(injectedMcpServers);
|
||||
if (Object.keys(mcpServers).length > 0) options.mcpServers = mcpServers;
|
||||
return options;
|
||||
}
|
||||
|
||||
function applyTemporaryProcessEnv(env) {
|
||||
if (!env || typeof env !== "object") return () => {};
|
||||
const previous = new Map();
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (typeof value !== "string") continue;
|
||||
previous.set(key, Object.prototype.hasOwnProperty.call(process.env, key) ? process.env[key] : undefined);
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const [key, value] of previous.entries()) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function withTemporaryProcessEnv(env, fn) {
|
||||
const restore = applyTemporaryProcessEnv(env);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
|
||||
function buildCursorSendMessage(prompt, attachments) {
|
||||
const images = [];
|
||||
for (const attachment of Array.isArray(attachments) ? attachments : []) {
|
||||
if (!attachment?.base64Data || !attachment?.mediaType) continue;
|
||||
if (!String(attachment.mediaType).toLowerCase().startsWith("image/")) continue;
|
||||
images.push({ data: attachment.base64Data, mimeType: attachment.mediaType });
|
||||
}
|
||||
if (images.length === 0) return String(prompt || "");
|
||||
return { text: String(prompt || ""), images };
|
||||
}
|
||||
|
||||
function resultToText(result) {
|
||||
if (result == null) return "";
|
||||
if (typeof result === "string") return result;
|
||||
if (typeof result === "number" || typeof result === "boolean") return String(result);
|
||||
const content = result.content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((block) => {
|
||||
if (!block) return "";
|
||||
if (typeof block.text === "string") return block.text;
|
||||
if (block.type === "image") return "[image]";
|
||||
return JSON.stringify(block);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
function redactCursorSecret(value) {
|
||||
return String(value || "")
|
||||
.replace(/crsr[_-]?[A-Za-z0-9_-]{8,}/g, "[redacted-cursor-key]")
|
||||
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [redacted-token]");
|
||||
}
|
||||
|
||||
function cursorErrorDiagnostics(error) {
|
||||
if (!error || typeof error !== "object") {
|
||||
return { message: redactCursorSecret(error) };
|
||||
}
|
||||
return {
|
||||
name: error.name || null,
|
||||
message: redactCursorSecret(error.message || String(error)),
|
||||
code: error.code || null,
|
||||
status: error.status || null,
|
||||
operation: error.operation || null,
|
||||
endpoint: error.endpoint || null,
|
||||
requestId: error.requestId || null,
|
||||
isRetryable: typeof error.isRetryable === "boolean" ? error.isRetryable : null,
|
||||
cause: error.cause && typeof error.cause === "object"
|
||||
? {
|
||||
name: error.cause.name || null,
|
||||
message: redactCursorSecret(error.cause.message || String(error.cause)),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function isCursorAuthMessage(message) {
|
||||
return /api.?key|auth|unauthorized|unauthenticated/i.test(String(message || ""));
|
||||
}
|
||||
|
||||
async function logCursorApiKeyValidation(resolvedModule, apiKey) {
|
||||
if (!apiKey || typeof resolvedModule?.Cursor?.me !== "function") return;
|
||||
try {
|
||||
const user = await resolvedModule.Cursor.me({ apiKey });
|
||||
console.info("[Cursor SDK] API key validation ok", {
|
||||
hasUserId: user?.userId != null,
|
||||
hasEmail: Boolean(user?.email),
|
||||
createdAt: user?.createdAt || null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[Cursor SDK] API key validation failed", cursorErrorDiagnostics(error));
|
||||
}
|
||||
}
|
||||
|
||||
function closeReasoning(state, emitter) {
|
||||
if (state?.reasoningOpen) {
|
||||
emitter.reasoningEnd();
|
||||
state.reasoningOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function emitCursorToolCallOnce(event, emitter, state, toolName, args, id) {
|
||||
if (!id) return false;
|
||||
if (!state.emittedToolCalls) state.emittedToolCalls = new Set();
|
||||
if (state.emittedToolCalls.has(id)) return false;
|
||||
state.emittedToolCalls.add(id);
|
||||
emitter.toolCall(toolName || "tool", args && typeof args === "object" ? args : {}, id);
|
||||
return true;
|
||||
}
|
||||
|
||||
function emitCursorToolResultOnce(event, emitter, state, id, result, toolName) {
|
||||
if (!id) return false;
|
||||
if (!state.emittedToolResults) state.emittedToolResults = new Set();
|
||||
if (state.emittedToolResults.has(id)) return false;
|
||||
state.emittedToolResults.add(id);
|
||||
emitter.toolResult(id, resultToText(result), toolName);
|
||||
return true;
|
||||
}
|
||||
|
||||
function getCursorDisplayToolName(rawName, args) {
|
||||
const name = String(rawName || "").trim();
|
||||
const input = args && typeof args === "object" ? args : {};
|
||||
const nestedToolName = typeof input.toolName === "string" ? input.toolName.trim() : "";
|
||||
if ((name === "mcp" || name === "tool" || !name) && nestedToolName) {
|
||||
return nestedToolName;
|
||||
}
|
||||
return name || nestedToolName || "tool";
|
||||
}
|
||||
|
||||
function formatCursorErrorForUser(message) {
|
||||
const text = String(message || "").trim();
|
||||
if (/api.?key|auth|unauthorized/i.test(text)) {
|
||||
return "Cursor authentication failed. Update the Cursor API Key in Settings -> AI.";
|
||||
}
|
||||
return text || "Cursor turn failed";
|
||||
}
|
||||
|
||||
function isCursorAgentNotFoundError(error) {
|
||||
const message = String(error?.message || error || "");
|
||||
return /\bAgent\b.+\bnot found\b/i.test(message);
|
||||
}
|
||||
|
||||
function translateCursorEvent(event, emitter, state = {}) {
|
||||
if (!event || typeof event !== "object") return;
|
||||
|
||||
switch (event.type) {
|
||||
case "thinking":
|
||||
if (event.text) {
|
||||
emitter.reasoning(String(event.text));
|
||||
state.reasoningOpen = true;
|
||||
}
|
||||
return;
|
||||
case "assistant": {
|
||||
closeReasoning(state, emitter);
|
||||
const content = event.message?.content;
|
||||
if (!Array.isArray(content)) return;
|
||||
for (const block of content) {
|
||||
if (!block) continue;
|
||||
if (block.type === "text" && block.text) {
|
||||
emitter.text(String(block.text));
|
||||
} else if (block.type === "tool_use") {
|
||||
emitCursorToolCallOnce(
|
||||
event,
|
||||
emitter,
|
||||
state,
|
||||
getCursorDisplayToolName(block.name, block.input),
|
||||
block.input,
|
||||
block.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "tool_call": {
|
||||
closeReasoning(state, emitter);
|
||||
const id = event.call_id;
|
||||
const name = getCursorDisplayToolName(event.name, event.args);
|
||||
if (event.status === "running") {
|
||||
emitCursorToolCallOnce(event, emitter, state, name, event.args, id);
|
||||
} else if (event.status === "completed" || event.status === "error") {
|
||||
emitCursorToolCallOnce(event, emitter, state, name, event.args, id);
|
||||
emitCursorToolResultOnce(event, emitter, state, id, event.result || event.error || "", name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "status":
|
||||
if (event.status === "ERROR") {
|
||||
closeReasoning(state, emitter);
|
||||
state.failed = true;
|
||||
state.errorMessage = String(event.message || "");
|
||||
console.warn("[Cursor SDK] status error", {
|
||||
message: redactCursorSecret(event.message || ""),
|
||||
});
|
||||
emitter.emitError(formatCursorErrorForUser(event.message));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class CursorTurnAbortError extends Error {
|
||||
constructor() {
|
||||
super("Cursor turn aborted");
|
||||
this.name = "CursorTurnAbortError";
|
||||
}
|
||||
}
|
||||
|
||||
function isCursorTurnAbortError(error) {
|
||||
return error instanceof CursorTurnAbortError || error?.name === "CursorTurnAbortError";
|
||||
}
|
||||
|
||||
async function abortable(promise, signal, onLateResolve) {
|
||||
if (!signal) return promise;
|
||||
if (signal.aborted) {
|
||||
promise.then((value) => onLateResolve?.(value)).catch(() => {});
|
||||
throw new CursorTurnAbortError();
|
||||
}
|
||||
|
||||
let aborted = false;
|
||||
let removeAbortListener = () => {};
|
||||
const abortPromise = new Promise((_, reject) => {
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
reject(new CursorTurnAbortError());
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, abortPromise]);
|
||||
} finally {
|
||||
removeAbortListener();
|
||||
if (aborted) {
|
||||
promise.then((value) => onLateResolve?.(value)).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runCursorTurn({
|
||||
prompt, attachments, agentOptions, runtimeEnv, resumeSessionId, emitter, signal, sdkModule,
|
||||
}) {
|
||||
let resolvedModule = sdkModule;
|
||||
if (!resolvedModule) {
|
||||
try {
|
||||
resolvedModule = await import("@cursor/sdk");
|
||||
} catch {
|
||||
emitter.emitError("Cursor SDK not installed. Run: npm install @cursor/sdk");
|
||||
return { sessionId: resumeSessionId || null };
|
||||
}
|
||||
}
|
||||
|
||||
const { Agent } = resolvedModule;
|
||||
let agent = null;
|
||||
let run = null;
|
||||
let sessionId = resumeSessionId || null;
|
||||
try {
|
||||
const restoreCreateEnv = applyTemporaryProcessEnv(runtimeEnv);
|
||||
try {
|
||||
const createAgent = () => Agent.create(agentOptions);
|
||||
let agentPromise;
|
||||
if (resumeSessionId && typeof Agent.resume === "function") {
|
||||
agentPromise = Agent.resume(resumeSessionId, agentOptions).catch((error) => {
|
||||
// Stale Cursor agent IDs (expired local store, or a CLI session UUID
|
||||
// resumed on the SDK path) should start a fresh agent instead of
|
||||
// failing the whole turn with "Agent … not found".
|
||||
if (!isCursorAgentNotFoundError(error)) throw error;
|
||||
console.warn("[Cursor SDK] resume missed; creating a new agent", {
|
||||
resumeSessionId,
|
||||
message: error?.message || String(error),
|
||||
});
|
||||
sessionId = null;
|
||||
return createAgent();
|
||||
});
|
||||
} else {
|
||||
agentPromise = createAgent();
|
||||
}
|
||||
agent = await abortable(agentPromise, signal, (lateAgent) => {
|
||||
try { lateAgent?.close?.(); } catch { /* best effort */ }
|
||||
});
|
||||
} finally {
|
||||
restoreCreateEnv();
|
||||
}
|
||||
sessionId = agent.agentId || sessionId;
|
||||
if (sessionId) emitter.sessionId(sessionId);
|
||||
if (signal?.aborted) return { sessionId };
|
||||
|
||||
const sendMessage = buildCursorSendMessage(prompt, attachments);
|
||||
const restoreSendEnv = applyTemporaryProcessEnv(runtimeEnv);
|
||||
try {
|
||||
run = await abortable(agent.send(sendMessage), signal, (lateRun) => {
|
||||
if (lateRun && typeof lateRun.cancel === "function") {
|
||||
void lateRun.cancel().catch(() => {});
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
restoreSendEnv();
|
||||
}
|
||||
const state = { reasoningOpen: false };
|
||||
let hasContent = false;
|
||||
let failed = false;
|
||||
const onAbort = () => {
|
||||
if (run && typeof run.cancel === "function") {
|
||||
void run.cancel().catch(() => {});
|
||||
}
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
try {
|
||||
for await (const event of run.stream()) {
|
||||
if (signal?.aborted) break;
|
||||
if (event?.type === "assistant" || event?.type === "tool_call") hasContent = true;
|
||||
const streamFailed = translateCursorEvent(event, emitter, state);
|
||||
if (streamFailed || state.failed) {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
closeReasoning(state, emitter);
|
||||
if (failed) {
|
||||
if (isCursorAuthMessage(state.errorMessage)) {
|
||||
await logCursorApiKeyValidation(resolvedModule, agentOptions?.apiKey);
|
||||
}
|
||||
return { sessionId };
|
||||
}
|
||||
if (!hasContent && !signal?.aborted) {
|
||||
emitter.emitError("Cursor returned an empty response. Check the Cursor API Key in Settings -> AI.");
|
||||
return { sessionId };
|
||||
}
|
||||
if (!signal?.aborted) emitter.emitDone();
|
||||
return { sessionId };
|
||||
} catch (error) {
|
||||
if (isCursorTurnAbortError(error) || signal?.aborted) {
|
||||
return { sessionId };
|
||||
}
|
||||
{
|
||||
const message = error?.message || String(error);
|
||||
console.warn("[Cursor SDK] run error", cursorErrorDiagnostics(error));
|
||||
if (isCursorAuthMessage(message)) {
|
||||
await logCursorApiKeyValidation(resolvedModule, agentOptions?.apiKey);
|
||||
}
|
||||
emitter.emitError(formatCursorErrorForUser(message));
|
||||
}
|
||||
return { sessionId };
|
||||
} finally {
|
||||
try { await agent?.close?.(); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
function modelVariantId(modelId, params) {
|
||||
const search = new URLSearchParams();
|
||||
for (const param of params || []) {
|
||||
if (param?.id && param?.value) search.set(param.id, param.value);
|
||||
}
|
||||
const qs = search.toString();
|
||||
return qs ? `${modelId}?${qs}` : modelId;
|
||||
}
|
||||
|
||||
function collectCursorEffortLevels(model) {
|
||||
const levels = [];
|
||||
const add = (raw) => {
|
||||
const level = String(raw || "").toLowerCase();
|
||||
if (CURSOR_REASONING_EFFORTS.has(level) && !levels.includes(level)) levels.push(level);
|
||||
};
|
||||
const effortParam = (model.parameters || []).find((param) => param?.id === "effort");
|
||||
if (effortParam && Array.isArray(effortParam.values) && effortParam.values.length > 0) {
|
||||
for (const item of effortParam.values) add(item?.value);
|
||||
return levels;
|
||||
}
|
||||
for (const level of CURSOR_FALLBACK_THINKING[model.id] || []) add(level);
|
||||
if (levels.length > 0) return levels;
|
||||
for (const variant of model.variants || []) {
|
||||
const params = Array.isArray(variant.params) ? variant.params : [];
|
||||
const effortOnly = params.length === 1 && params[0]?.id === "effort" && params[0]?.value;
|
||||
if (effortOnly) add(params[0].value);
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
function mapCursorModels(models) {
|
||||
const out = [];
|
||||
if (!Array.isArray(models)) return out;
|
||||
for (const model of models) {
|
||||
if (!model?.id) continue;
|
||||
const name = model.displayName || model.name || model.id;
|
||||
const extraVariants = [];
|
||||
for (const variant of model.variants || []) {
|
||||
const params = Array.isArray(variant.params) ? variant.params : [];
|
||||
const effortOnly = params.length === 1 && params[0]?.id === "effort" && params[0]?.value;
|
||||
if (!effortOnly) extraVariants.push(variant);
|
||||
}
|
||||
const thinkingLevels = collectCursorEffortLevels(model);
|
||||
out.push({
|
||||
id: model.id,
|
||||
name,
|
||||
...(model.description ? { description: model.description } : {}),
|
||||
...(thinkingLevels.length > 0 ? {
|
||||
thinkingLevels,
|
||||
defaultThinkingLevel: thinkingLevels.includes("medium") ? "medium" : thinkingLevels[0],
|
||||
} : {}),
|
||||
});
|
||||
for (const variant of extraVariants) {
|
||||
const id = modelVariantId(model.id, variant.params || []);
|
||||
if (id === model.id) continue;
|
||||
out.push({
|
||||
id,
|
||||
name: `${name} - ${variant.displayName || id}`,
|
||||
...(variant.description ? { description: variant.description } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function listCursorModels({ apiKey, env, sdkModule, abortController, signal } = {}) {
|
||||
const externalSignal = signal || abortController?.signal;
|
||||
if (externalSignal?.aborted) return [];
|
||||
let resolvedModule = sdkModule;
|
||||
if (!resolvedModule) {
|
||||
try { resolvedModule = await import("@cursor/sdk"); } catch { return []; }
|
||||
}
|
||||
const effectiveApiKey = apiKey || env?.CURSOR_API_KEY || process.env.CURSOR_API_KEY;
|
||||
if (!effectiveApiKey) return [];
|
||||
let abortHandler;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
Promise.resolve(resolvedModule.Cursor.models.list({
|
||||
apiKey: effectiveApiKey,
|
||||
signal: externalSignal,
|
||||
})).then((models) => ({ type: "models", models })),
|
||||
new Promise((resolve) => {
|
||||
if (externalSignal?.aborted) return resolve({ type: "aborted" });
|
||||
abortHandler = () => resolve({ type: "aborted" });
|
||||
externalSignal?.addEventListener("abort", abortHandler, { once: true });
|
||||
}),
|
||||
]);
|
||||
return result.type === "models" ? mapCursorModels(result.models) : [];
|
||||
} finally {
|
||||
if (abortHandler) externalSignal?.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CURSOR_MODEL,
|
||||
abortable,
|
||||
applyTemporaryProcessEnv,
|
||||
buildCursorAgentOptions,
|
||||
buildCursorSendMessage,
|
||||
formatCursorErrorForUser,
|
||||
isCursorAgentNotFoundError,
|
||||
listCursorModels,
|
||||
mapCursorModels,
|
||||
parseCursorModelSelection,
|
||||
encodeCursorCliModel,
|
||||
runCursorTurn,
|
||||
toCursorMcpServers,
|
||||
translateCursorEvent,
|
||||
withTemporaryProcessEnv,
|
||||
};
|
||||
548
electron/bridges/aiBridge/sdk/cursorDriver.test.cjs
Normal file
548
electron/bridges/aiBridge/sdk/cursorDriver.test.cjs
Normal file
@@ -0,0 +1,548 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
buildCursorAgentOptions,
|
||||
buildCursorSendMessage,
|
||||
formatCursorErrorForUser,
|
||||
isCursorAgentNotFoundError,
|
||||
mapCursorModels,
|
||||
runCursorTurn,
|
||||
toCursorMcpServers,
|
||||
translateCursorEvent,
|
||||
withTemporaryProcessEnv,
|
||||
} = require("./cursorDriver.cjs");
|
||||
|
||||
function makeEmitter() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
text: (value) => calls.push(["text", value]),
|
||||
reasoning: (value) => calls.push(["reasoning", value]),
|
||||
reasoningEnd: () => calls.push(["reasoningEnd"]),
|
||||
toolCall: (name, args, id) => calls.push(["toolCall", name, args, id]),
|
||||
toolResult: (id, result, name) => calls.push(["toolResult", id, result, name]),
|
||||
sessionId: (id) => calls.push(["sessionId", id]),
|
||||
emitDone: () => calls.push(["done"]),
|
||||
emitError: (message) => calls.push(["error", message]),
|
||||
};
|
||||
}
|
||||
|
||||
test("buildCursorAgentOptions uses api key, model, cwd, and injected MCP servers", () => {
|
||||
const options = buildCursorAgentOptions({
|
||||
apiKey: "cur-key",
|
||||
model: "composer-2",
|
||||
cwd: "/repo",
|
||||
injectedMcpServers: [
|
||||
{
|
||||
name: "netcatty",
|
||||
command: "node",
|
||||
args: ["server.cjs"],
|
||||
env: [{ name: "TOKEN", value: "abc" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(options, {
|
||||
apiKey: "cur-key",
|
||||
model: { id: "composer-2" },
|
||||
local: { cwd: "/repo", autoReview: false },
|
||||
mcpServers: {
|
||||
netcatty: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["server.cjs"],
|
||||
env: { TOKEN: "abc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("buildCursorAgentOptions falls back to CURSOR_API_KEY and composer-2.5", () => {
|
||||
const options = buildCursorAgentOptions({
|
||||
env: { CURSOR_API_KEY: "env-key" },
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
assert.equal(options.apiKey, "env-key");
|
||||
assert.deepEqual(options.model, { id: "composer-2.5" });
|
||||
});
|
||||
|
||||
test("toCursorMcpServers drops invalid server configs", () => {
|
||||
assert.deepEqual(
|
||||
toCursorMcpServers([
|
||||
null,
|
||||
{ name: "", command: "node" },
|
||||
{ name: "ok", command: "node", args: [] },
|
||||
]),
|
||||
{ ok: { type: "stdio", command: "node", args: [], env: {} } },
|
||||
);
|
||||
});
|
||||
|
||||
test("withTemporaryProcessEnv restores env after async work", async () => {
|
||||
const original = process.env.NETCATTY_CURSOR_TEST_ENV;
|
||||
delete process.env.NETCATTY_CURSOR_TEST_ENV;
|
||||
|
||||
const value = await withTemporaryProcessEnv(
|
||||
{ NETCATTY_CURSOR_TEST_ENV: "present" },
|
||||
async () => process.env.NETCATTY_CURSOR_TEST_ENV,
|
||||
);
|
||||
|
||||
assert.equal(value, "present");
|
||||
assert.equal(process.env.NETCATTY_CURSOR_TEST_ENV, undefined);
|
||||
if (original !== undefined) process.env.NETCATTY_CURSOR_TEST_ENV = original;
|
||||
});
|
||||
|
||||
test("runCursorTurn exposes runtime env while creating and sending", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const observed = [];
|
||||
const sdkModule = {
|
||||
Agent: {
|
||||
async create() {
|
||||
observed.push(["create", process.env.NETCATTY_TOOL_CLI_DISCOVERY_FILE]);
|
||||
return {
|
||||
agentId: "agent-env",
|
||||
async send() {
|
||||
observed.push(["send", process.env.NETCATTY_TOOL_CLI_DISCOVERY_FILE]);
|
||||
return {
|
||||
async *stream() {
|
||||
yield { type: "assistant", message: { content: [{ type: "text", text: "ok" }] } };
|
||||
},
|
||||
};
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await runCursorTurn({
|
||||
prompt: "hi",
|
||||
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
|
||||
runtimeEnv: { NETCATTY_TOOL_CLI_DISCOVERY_FILE: "/tmp/discovery.json" },
|
||||
emitter,
|
||||
sdkModule,
|
||||
});
|
||||
|
||||
assert.deepEqual(observed, [
|
||||
["create", "/tmp/discovery.json"],
|
||||
["send", "/tmp/discovery.json"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("translateCursorEvent maps assistant, thinking, and tool events", () => {
|
||||
const emitter = makeEmitter();
|
||||
const state = {};
|
||||
|
||||
translateCursorEvent({ type: "thinking", text: "checking" }, emitter, state);
|
||||
translateCursorEvent({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "README.md" } },
|
||||
],
|
||||
},
|
||||
}, emitter, state);
|
||||
translateCursorEvent({
|
||||
type: "tool_call",
|
||||
call_id: "tool-1",
|
||||
name: "read_file",
|
||||
status: "completed",
|
||||
result: { content: [{ type: "text", text: "contents" }] },
|
||||
}, emitter, state);
|
||||
|
||||
assert.deepEqual(emitter.calls, [
|
||||
["reasoning", "checking"],
|
||||
["reasoningEnd"],
|
||||
["text", "hello"],
|
||||
["toolCall", "read_file", { path: "README.md" }, "tool-1"],
|
||||
["toolResult", "tool-1", "contents", "read_file"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("translateCursorEvent uses nested Cursor MCP toolName for display", () => {
|
||||
const emitter = makeEmitter();
|
||||
const state = {};
|
||||
const args = {
|
||||
providerIdentifier: "netcatty-remote-hosts",
|
||||
toolName: "terminal_execute",
|
||||
args: { command: "uname -a" },
|
||||
};
|
||||
|
||||
translateCursorEvent({
|
||||
type: "tool_call",
|
||||
call_id: "mcp-1",
|
||||
name: "mcp",
|
||||
status: "completed",
|
||||
args,
|
||||
result: { content: [{ type: "text", text: "Linux" }] },
|
||||
}, emitter, state);
|
||||
|
||||
assert.deepEqual(emitter.calls, [
|
||||
["toolCall", "terminal_execute", args, "mcp-1"],
|
||||
["toolResult", "mcp-1", "Linux", "terminal_execute"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("translateCursorEvent marks error status as failed", () => {
|
||||
const emitter = makeEmitter();
|
||||
const state = {};
|
||||
|
||||
const failed = translateCursorEvent({ type: "status", status: "ERROR", message: "bad key" }, emitter, state);
|
||||
|
||||
assert.equal(failed, true);
|
||||
assert.equal(state.failed, true);
|
||||
assert.deepEqual(emitter.calls, [["error", "bad key"]]);
|
||||
});
|
||||
|
||||
test("translateCursorEvent rewrites Cursor authentication errors", () => {
|
||||
const emitter = makeEmitter();
|
||||
const state = {};
|
||||
|
||||
const failed = translateCursorEvent({ type: "status", status: "ERROR", message: "bad API key" }, emitter, state);
|
||||
|
||||
assert.equal(failed, true);
|
||||
assert.equal(state.failed, true);
|
||||
assert.deepEqual(emitter.calls, [[
|
||||
"error",
|
||||
"Cursor authentication failed. Update the Cursor API Key in Settings -> AI.",
|
||||
]]);
|
||||
});
|
||||
|
||||
test("formatCursorErrorForUser points users to the settings API key", () => {
|
||||
assert.equal(
|
||||
formatCursorErrorForUser("unauthorized"),
|
||||
"Cursor authentication failed. Update the Cursor API Key in Settings -> AI.",
|
||||
);
|
||||
});
|
||||
|
||||
test("isCursorAgentNotFoundError detects stale resume ids", () => {
|
||||
assert.equal(isCursorAgentNotFoundError(new Error("Agent 61668441-bfcb-4795-a575-c46d70ad01fe not found")), true);
|
||||
assert.equal(isCursorAgentNotFoundError(new Error("unauthorized")), false);
|
||||
});
|
||||
|
||||
test("runCursorTurn falls back to create when resume agent is missing", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const observed = [];
|
||||
const sdkModule = {
|
||||
Agent: {
|
||||
async resume(id) {
|
||||
observed.push(["resume", id]);
|
||||
throw new Error(`Agent ${id} not found`);
|
||||
},
|
||||
async create() {
|
||||
observed.push(["create"]);
|
||||
return {
|
||||
agentId: "agent-fresh",
|
||||
async send() {
|
||||
return {
|
||||
async *stream() {
|
||||
yield { type: "assistant", message: { content: [{ type: "text", text: "ok" }] } };
|
||||
},
|
||||
};
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runCursorTurn({
|
||||
prompt: "hi",
|
||||
resumeSessionId: "61668441-bfcb-4795-a575-c46d70ad01fe",
|
||||
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
|
||||
emitter,
|
||||
sdkModule,
|
||||
});
|
||||
|
||||
assert.deepEqual(observed, [
|
||||
["resume", "61668441-bfcb-4795-a575-c46d70ad01fe"],
|
||||
["create"],
|
||||
]);
|
||||
assert.equal(result.sessionId, "agent-fresh");
|
||||
assert.deepEqual(emitter.calls, [
|
||||
["sessionId", "agent-fresh"],
|
||||
["text", "ok"],
|
||||
["done"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCursorTurn creates or resumes an agent, streams events, and emits done", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const captured = {};
|
||||
const sdkModule = {
|
||||
Agent: {
|
||||
async create(options) {
|
||||
captured.createOptions = options;
|
||||
return {
|
||||
agentId: "agent-new",
|
||||
async send(message) {
|
||||
captured.message = message;
|
||||
return {
|
||||
id: "run-1",
|
||||
agentId: "agent-new",
|
||||
async *stream() {
|
||||
yield { type: "assistant", message: { content: [{ type: "text", text: "done" }] } };
|
||||
},
|
||||
};
|
||||
},
|
||||
async close() {
|
||||
captured.closed = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runCursorTurn({
|
||||
prompt: "hi",
|
||||
attachments: [{ mediaType: "image/png", base64Data: "abc", filename: "a.png" }],
|
||||
agentOptions: { apiKey: "key", model: { id: "composer-2" }, local: { cwd: "/repo" } },
|
||||
emitter,
|
||||
sdkModule,
|
||||
});
|
||||
|
||||
assert.equal(result.sessionId, "agent-new");
|
||||
assert.deepEqual(captured.message, {
|
||||
text: "hi",
|
||||
images: [{ data: "abc", mimeType: "image/png" }],
|
||||
});
|
||||
assert.deepEqual(emitter.calls, [
|
||||
["sessionId", "agent-new"],
|
||||
["text", "done"],
|
||||
["done"],
|
||||
]);
|
||||
assert.equal(captured.closed, true);
|
||||
});
|
||||
|
||||
test("runCursorTurn does not emit done after a Cursor error status", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const sdkModule = {
|
||||
Agent: {
|
||||
async create() {
|
||||
return {
|
||||
agentId: "agent-error",
|
||||
async send() {
|
||||
return {
|
||||
async *stream() {
|
||||
yield { type: "status", status: "ERROR", message: "bad key" };
|
||||
yield { type: "assistant", message: { content: [{ type: "text", text: "late" }] } };
|
||||
},
|
||||
};
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runCursorTurn({
|
||||
prompt: "hi",
|
||||
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
|
||||
emitter,
|
||||
sdkModule,
|
||||
});
|
||||
|
||||
assert.equal(result.sessionId, "agent-error");
|
||||
assert.deepEqual(emitter.calls, [
|
||||
["sessionId", "agent-error"],
|
||||
["error", "bad key"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("runCursorTurn returns when aborted while creating an agent", async () => {
|
||||
const emitter = makeEmitter();
|
||||
let resolveCreate;
|
||||
const createPromise = new Promise((resolve) => {
|
||||
resolveCreate = resolve;
|
||||
});
|
||||
const sdkModule = {
|
||||
Agent: {
|
||||
create() {
|
||||
return createPromise;
|
||||
},
|
||||
},
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const turnPromise = runCursorTurn({
|
||||
prompt: "hi",
|
||||
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
|
||||
emitter,
|
||||
signal: controller.signal,
|
||||
sdkModule,
|
||||
});
|
||||
|
||||
controller.abort();
|
||||
const result = await turnPromise;
|
||||
assert.deepEqual(result, { sessionId: null });
|
||||
assert.deepEqual(emitter.calls, []);
|
||||
|
||||
let closed = false;
|
||||
resolveCreate({ agentId: "late", close: () => { closed = true; } });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(closed, true);
|
||||
});
|
||||
|
||||
test("runCursorTurn restores runtime env when aborted while creating an agent", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const original = process.env.NETCATTY_CURSOR_ABORT_ENV;
|
||||
delete process.env.NETCATTY_CURSOR_ABORT_ENV;
|
||||
const sdkModule = {
|
||||
Agent: {
|
||||
create() {
|
||||
return new Promise(() => {});
|
||||
},
|
||||
},
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const turnPromise = runCursorTurn({
|
||||
prompt: "hi",
|
||||
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
|
||||
runtimeEnv: { NETCATTY_CURSOR_ABORT_ENV: "present" },
|
||||
emitter,
|
||||
signal: controller.signal,
|
||||
sdkModule,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(process.env.NETCATTY_CURSOR_ABORT_ENV, "present");
|
||||
controller.abort();
|
||||
await turnPromise;
|
||||
assert.equal(process.env.NETCATTY_CURSOR_ABORT_ENV, undefined);
|
||||
if (original !== undefined) process.env.NETCATTY_CURSOR_ABORT_ENV = original;
|
||||
});
|
||||
|
||||
test("runCursorTurn cancels a late Cursor run when aborted while sending", async () => {
|
||||
const emitter = makeEmitter();
|
||||
let resolveSend;
|
||||
let cancelled = false;
|
||||
const sendPromise = new Promise((resolve) => {
|
||||
resolveSend = resolve;
|
||||
});
|
||||
const sdkModule = {
|
||||
Agent: {
|
||||
async create() {
|
||||
return {
|
||||
agentId: "agent-send-abort",
|
||||
send() {
|
||||
return sendPromise;
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const turnPromise = runCursorTurn({
|
||||
prompt: "hi",
|
||||
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
|
||||
emitter,
|
||||
signal: controller.signal,
|
||||
sdkModule,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
controller.abort();
|
||||
const result = await turnPromise;
|
||||
assert.deepEqual(result, { sessionId: "agent-send-abort" });
|
||||
assert.deepEqual(emitter.calls, [["sessionId", "agent-send-abort"]]);
|
||||
|
||||
resolveSend({ cancel: async () => { cancelled = true; }, stream: async function* stream() {} });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
test("mapCursorModels prefers advertised effort parameter values over fallbacks", () => {
|
||||
assert.deepEqual(
|
||||
mapCursorModels([
|
||||
{
|
||||
id: "custom-reasoner",
|
||||
displayName: "Custom Reasoner",
|
||||
parameters: [
|
||||
{ id: "effort", values: [{ value: "low" }, { value: "xhigh" }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gpt-5",
|
||||
displayName: "GPT-5",
|
||||
parameters: [
|
||||
{ id: "effort", values: [{ value: "low" }, { value: "high" }] },
|
||||
],
|
||||
},
|
||||
]),
|
||||
[
|
||||
{
|
||||
id: "custom-reasoner",
|
||||
name: "Custom Reasoner",
|
||||
thinkingLevels: ["low", "xhigh"],
|
||||
defaultThinkingLevel: "low",
|
||||
},
|
||||
{
|
||||
id: "gpt-5",
|
||||
name: "GPT-5",
|
||||
thinkingLevels: ["low", "high"],
|
||||
defaultThinkingLevel: "low",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("mapCursorModels maps display names and effort variants into thinkingLevels", () => {
|
||||
assert.deepEqual(
|
||||
mapCursorModels([
|
||||
{ id: "composer-2.5", displayName: "Composer 2.5", description: "Default" },
|
||||
{ id: "gpt-5", displayName: "GPT-5", variants: [{ displayName: "Fast", params: [{ id: "effort", value: "low" }] }] },
|
||||
]),
|
||||
[
|
||||
{ id: "composer-2.5", name: "Composer 2.5", description: "Default" },
|
||||
{
|
||||
id: "gpt-5",
|
||||
name: "GPT-5",
|
||||
thinkingLevels: ["low", "medium", "high"],
|
||||
defaultThinkingLevel: "medium",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("mapCursorModels keeps extra-param variants as separate models", () => {
|
||||
const mapped = mapCursorModels([
|
||||
{
|
||||
id: "gpt-5",
|
||||
displayName: "GPT-5",
|
||||
variants: [
|
||||
{ displayName: "Fast", params: [{ id: "effort", value: "low" }] },
|
||||
{
|
||||
displayName: "Fast custom",
|
||||
params: [{ id: "effort", value: "low" }, { id: "mode", value: "fast" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(mapped, [
|
||||
{
|
||||
id: "gpt-5",
|
||||
name: "GPT-5",
|
||||
thinkingLevels: ["low", "medium", "high"],
|
||||
defaultThinkingLevel: "medium",
|
||||
},
|
||||
{
|
||||
id: "gpt-5?effort=low&mode=fast",
|
||||
name: "GPT-5 - Fast custom",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseCursorModelSelection accepts query and slash effort encodings", () => {
|
||||
const { parseCursorModelSelection, encodeCursorCliModel } = require("./cursorDriver.cjs");
|
||||
assert.deepEqual(parseCursorModelSelection("gpt-5/high"), {
|
||||
id: "gpt-5",
|
||||
params: [{ id: "effort", value: "high" }],
|
||||
});
|
||||
assert.deepEqual(parseCursorModelSelection("gpt-5?effort=low"), {
|
||||
id: "gpt-5",
|
||||
params: [{ id: "effort", value: "low" }],
|
||||
});
|
||||
assert.equal(encodeCursorCliModel("gpt-5/high"), "gpt-5?effort=high");
|
||||
});
|
||||
73
electron/bridges/aiBridge/sdk/emit.cjs
Normal file
73
electron/bridges/aiBridge/sdk/emit.cjs
Normal file
@@ -0,0 +1,73 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Stream emitter: forwards translated SDK events to the renderer over the
|
||||
* SDK agent IPC channels consumed by sdkAgentAdapter.ts.
|
||||
*
|
||||
* Canonical event shapes consumed by sdkAgentAdapter.handleStreamEvent:
|
||||
* { type: 'text-delta', textDelta }
|
||||
* { type: 'reasoning-delta', delta }
|
||||
* { type: 'reasoning-end' }
|
||||
* { type: 'tool-call', toolName, args, toolCallId }
|
||||
* { type: 'tool-result', toolCallId, output, toolName }
|
||||
* { type: 'file-change', itemId, changes, status }
|
||||
* { type: 'web-search', itemId, query, status }
|
||||
* { type: 'plan-update', itemId, items, status }
|
||||
* { type: 'warning', itemId, message }
|
||||
* { type: 'usage', inputTokens, cachedInputTokens, outputTokens, reasoningTokens, totalTokens }
|
||||
* { type: 'status', message }
|
||||
* { type: 'session-id', sessionId }
|
||||
* { type: 'error', error }
|
||||
*/
|
||||
function createStreamEmitter({ safeSend, sender, requestId }) {
|
||||
const emitEvent = (event) => {
|
||||
safeSend(sender, "netcatty:ai:sdk-agent:event", { requestId, event });
|
||||
};
|
||||
return {
|
||||
emitEvent,
|
||||
emitDone() {
|
||||
safeSend(sender, "netcatty:ai:sdk-agent:done", { requestId });
|
||||
},
|
||||
emitError(error) {
|
||||
safeSend(sender, "netcatty:ai:sdk-agent:error", { requestId, error });
|
||||
},
|
||||
text(textDelta) {
|
||||
if (textDelta) emitEvent({ type: "text-delta", textDelta });
|
||||
},
|
||||
reasoning(delta) {
|
||||
if (delta) emitEvent({ type: "reasoning-delta", delta });
|
||||
},
|
||||
reasoningEnd() {
|
||||
emitEvent({ type: "reasoning-end" });
|
||||
},
|
||||
toolCall(toolName, args, toolCallId) {
|
||||
emitEvent({ type: "tool-call", toolName: toolName || "unknown", args: args || {}, toolCallId });
|
||||
},
|
||||
toolResult(toolCallId, output, toolName) {
|
||||
emitEvent({ type: "tool-result", toolCallId: toolCallId || "", output, toolName });
|
||||
},
|
||||
fileChange(itemId, changes, status) {
|
||||
emitEvent({ type: "file-change", itemId: itemId || "", changes: changes || [], status });
|
||||
},
|
||||
webSearch(itemId, query, status) {
|
||||
emitEvent({ type: "web-search", itemId: itemId || "", query: query || "", status });
|
||||
},
|
||||
planUpdate(itemId, items, status) {
|
||||
emitEvent({ type: "plan-update", itemId: itemId || "", items: items || [], status });
|
||||
},
|
||||
warning(itemId, message) {
|
||||
if (message) emitEvent({ type: "warning", itemId: itemId || "", message });
|
||||
},
|
||||
usage(usage) {
|
||||
if (usage) emitEvent({ type: "usage", ...usage });
|
||||
},
|
||||
status(message) {
|
||||
if (message) emitEvent({ type: "status", message });
|
||||
},
|
||||
sessionId(sessionId) {
|
||||
if (sessionId) emitEvent({ type: "session-id", sessionId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createStreamEmitter };
|
||||
60
electron/bridges/aiBridge/sdk/emit.test.cjs
Normal file
60
electron/bridges/aiBridge/sdk/emit.test.cjs
Normal file
@@ -0,0 +1,60 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { createStreamEmitter } = require("./emit.cjs");
|
||||
|
||||
function recordingSend() {
|
||||
const calls = [];
|
||||
const safeSend = (sender, channel, payload) => calls.push({ channel, payload });
|
||||
return { calls, safeSend };
|
||||
}
|
||||
|
||||
test("emitEvent sends on netcatty:ai:sdk-agent:event with requestId+event", () => {
|
||||
const { calls, safeSend } = recordingSend();
|
||||
const e = createStreamEmitter({ safeSend, sender: {}, requestId: "req-1" });
|
||||
e.emitEvent({ type: "text-delta", textDelta: "hi" });
|
||||
assert.deepEqual(calls[0], {
|
||||
channel: "netcatty:ai:sdk-agent:event",
|
||||
payload: { requestId: "req-1", event: { type: "text-delta", textDelta: "hi" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("emitDone sends on netcatty:ai:sdk-agent:done", () => {
|
||||
const { calls, safeSend } = recordingSend();
|
||||
const e = createStreamEmitter({ safeSend, sender: {}, requestId: "req-2" });
|
||||
e.emitDone();
|
||||
assert.deepEqual(calls[0], { channel: "netcatty:ai:sdk-agent:done", payload: { requestId: "req-2" } });
|
||||
});
|
||||
|
||||
test("emitError sends on netcatty:ai:sdk-agent:error with message", () => {
|
||||
const { calls, safeSend } = recordingSend();
|
||||
const e = createStreamEmitter({ safeSend, sender: {}, requestId: "req-3" });
|
||||
e.emitError("boom");
|
||||
assert.deepEqual(calls[0], { channel: "netcatty:ai:sdk-agent:error", payload: { requestId: "req-3", error: "boom" } });
|
||||
});
|
||||
|
||||
test("convenience helpers emit the canonical event shapes", () => {
|
||||
const { calls, safeSend } = recordingSend();
|
||||
const e = createStreamEmitter({ safeSend, sender: {}, requestId: "r" });
|
||||
e.text("abc");
|
||||
e.toolCall("terminal_execute", { command: "ls" }, "tc-1");
|
||||
e.toolResult("tc-1", "out", "terminal_execute");
|
||||
e.fileChange("patch-1", [{ path: "src/app.ts", kind: "update" }], "completed");
|
||||
e.webSearch("search-1", "Codex events", "running");
|
||||
e.planUpdate("plan-1", [{ text: "Map events", completed: false }], "running");
|
||||
e.warning("warning-1", "Search unavailable");
|
||||
e.usage({ inputTokens: 10, outputTokens: 5, totalTokens: 15 });
|
||||
e.status("Working...");
|
||||
e.sessionId("sess-9");
|
||||
assert.deepEqual(calls.map((c) => c.payload.event.type),
|
||||
[
|
||||
"text-delta", "tool-call", "tool-result", "file-change", "web-search",
|
||||
"plan-update", "warning", "usage", "status", "session-id",
|
||||
]);
|
||||
assert.equal(calls[1].payload.event.toolName, "terminal_execute");
|
||||
assert.equal(calls[1].payload.event.toolCallId, "tc-1");
|
||||
assert.deepEqual(calls[1].payload.event.args, { command: "ls" });
|
||||
assert.equal(calls[2].payload.event.output, "out");
|
||||
assert.equal(calls[3].payload.event.itemId, "patch-1");
|
||||
assert.equal(calls[7].payload.event.totalTokens, 15);
|
||||
assert.equal(calls[9].payload.event.sessionId, "sess-9");
|
||||
});
|
||||
73
electron/bridges/aiBridge/sdk/env.cjs
Normal file
73
electron/bridges/aiBridge/sdk/env.cjs
Normal file
@@ -0,0 +1,73 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Env construction for SDK agent subprocesses.
|
||||
*
|
||||
* Consolidates the env hardening that previously lived in
|
||||
* the removed raw-process handler (DANGEROUS_ENV_KEYS) and the per-spawn merge
|
||||
* helpers used by SDK agent launches.
|
||||
* Callers inject the netcatty helpers so this module stays pure/testable.
|
||||
*/
|
||||
|
||||
// Env var names that can be used for code injection into a child process.
|
||||
// Mirror of the set in the (now-removed) raw agent spawn handler.
|
||||
const DANGEROUS_ENV_KEYS = new Set([
|
||||
"LD_PRELOAD", "LD_LIBRARY_PATH",
|
||||
"DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH",
|
||||
"NODE_OPTIONS", "ELECTRON_RUN_AS_NODE",
|
||||
"PYTHONPATH", "RUBYLIB", "PERL5LIB",
|
||||
"BASH_ENV", "ENV", "CDPATH", "PROMPT_COMMAND",
|
||||
]);
|
||||
|
||||
function isDangerousEnvKey(key) {
|
||||
const normalized = String(key || "").toUpperCase();
|
||||
return DANGEROUS_ENV_KEYS.has(normalized) || normalized.startsWith("BASH_FUNC_");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the env handed to an SDK agent subprocess.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {Record<string,string>} args.shellEnv Resolved shell env (PATH-augmented).
|
||||
* @param {Record<string,string>} [args.requestedAgentEnv] Per-agent env from the UI (filtered).
|
||||
* @param {(e:Record<string,string>)=>Record<string,string>} [args.withCliDiscoveryEnv]
|
||||
* netcatty helper that injects the tool-CLI discovery file path.
|
||||
* @param {(e:Record<string,string>)=>Record<string,string>} [args.normalizeClaudeCodeExecutableEnv]
|
||||
* netcatty helper that rewrites CLAUDE_CODE_EXECUTABLE to a runnable path (claude only).
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
function buildSdkAgentEnv({
|
||||
shellEnv,
|
||||
requestedAgentEnv,
|
||||
withCliDiscoveryEnv,
|
||||
normalizeClaudeCodeExecutableEnv,
|
||||
}) {
|
||||
const filteredShellEnv = {};
|
||||
if (shellEnv && typeof shellEnv === "object") {
|
||||
for (const [k, v] of Object.entries(shellEnv)) {
|
||||
if (typeof v === "string" && !isDangerousEnvKey(k)) {
|
||||
filteredShellEnv[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRequested = {};
|
||||
if (requestedAgentEnv && typeof requestedAgentEnv === "object") {
|
||||
for (const [k, v] of Object.entries(requestedAgentEnv)) {
|
||||
if (typeof v === "string" && !isDangerousEnvKey(k)) {
|
||||
filteredRequested[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let env = { ...filteredShellEnv, ...filteredRequested };
|
||||
if (typeof withCliDiscoveryEnv === "function") {
|
||||
env = withCliDiscoveryEnv(env);
|
||||
}
|
||||
if (typeof normalizeClaudeCodeExecutableEnv === "function") {
|
||||
env = normalizeClaudeCodeExecutableEnv(env);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
module.exports = { buildSdkAgentEnv, DANGEROUS_ENV_KEYS, isDangerousEnvKey };
|
||||
62
electron/bridges/aiBridge/sdk/env.test.cjs
Normal file
62
electron/bridges/aiBridge/sdk/env.test.cjs
Normal file
@@ -0,0 +1,62 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { buildSdkAgentEnv, DANGEROUS_ENV_KEYS, isDangerousEnvKey } = require("./env.cjs");
|
||||
|
||||
test("merges shellEnv + requestedAgentEnv (requested wins)", () => {
|
||||
const env = buildSdkAgentEnv({
|
||||
shellEnv: { PATH: "/usr/bin", FOO: "shell" },
|
||||
requestedAgentEnv: { FOO: "req", BAR: "req" },
|
||||
});
|
||||
assert.equal(env.PATH, "/usr/bin");
|
||||
assert.equal(env.FOO, "req");
|
||||
assert.equal(env.BAR, "req");
|
||||
});
|
||||
|
||||
test("filters dangerous env keys from requestedAgentEnv", () => {
|
||||
const env = buildSdkAgentEnv({
|
||||
shellEnv: { PATH: "/usr/bin" },
|
||||
requestedAgentEnv: { LD_PRELOAD: "/evil.so", NODE_OPTIONS: "--x", BASH_FUNC_foo: "y", SAFE: "ok" },
|
||||
});
|
||||
assert.equal(env.LD_PRELOAD, undefined);
|
||||
assert.equal(env.NODE_OPTIONS, undefined);
|
||||
assert.equal(env.BASH_FUNC_foo, undefined);
|
||||
assert.equal(env.SAFE, "ok");
|
||||
});
|
||||
|
||||
test("filters dangerous env keys from shellEnv", () => {
|
||||
const env = buildSdkAgentEnv({
|
||||
shellEnv: { PATH: "/usr/bin", NODE_OPTIONS: "--require /evil.js", BASH_FUNC_x: "() { :; }", SAFE: "ok" },
|
||||
requestedAgentEnv: {},
|
||||
});
|
||||
assert.equal(env.PATH, "/usr/bin");
|
||||
assert.equal(env.NODE_OPTIONS, undefined);
|
||||
assert.equal(env.BASH_FUNC_x, undefined);
|
||||
assert.equal(env.SAFE, "ok");
|
||||
});
|
||||
|
||||
test("isDangerousEnvKey flags blocklist and BASH_FUNC_ prefix", () => {
|
||||
assert.equal(isDangerousEnvKey("DYLD_INSERT_LIBRARIES"), true);
|
||||
assert.equal(isDangerousEnvKey("dyld_insert_libraries"), true);
|
||||
assert.equal(isDangerousEnvKey("node_options"), true);
|
||||
assert.equal(isDangerousEnvKey("BASH_FUNC_x%%"), true);
|
||||
assert.equal(isDangerousEnvKey("bash_func_x%%"), true);
|
||||
assert.equal(isDangerousEnvKey("PATH"), false);
|
||||
});
|
||||
|
||||
test("applies withCliDiscoveryEnv hook", () => {
|
||||
const env = buildSdkAgentEnv({
|
||||
shellEnv: { PATH: "/usr/bin" },
|
||||
requestedAgentEnv: {},
|
||||
withCliDiscoveryEnv: (e) => ({ ...e, NETCATTY_TOOL_CLI_DISCOVERY: "/tmp/x.json" }),
|
||||
});
|
||||
assert.equal(env.NETCATTY_TOOL_CLI_DISCOVERY, "/tmp/x.json");
|
||||
});
|
||||
|
||||
test("normalizes CLAUDE_CODE_EXECUTABLE via injected normalizer", () => {
|
||||
const env = buildSdkAgentEnv({
|
||||
shellEnv: { PATH: "/usr/bin" },
|
||||
requestedAgentEnv: { CLAUDE_CODE_EXECUTABLE: "/old/claude" },
|
||||
normalizeClaudeCodeExecutableEnv: (e) => ({ ...e, CLAUDE_CODE_EXECUTABLE: "/new/claude" }),
|
||||
});
|
||||
assert.equal(env.CLAUDE_CODE_EXECUTABLE, "/new/claude");
|
||||
});
|
||||
1290
electron/bridges/aiBridge/sdk/grokAcpDriver.cjs
Normal file
1290
electron/bridges/aiBridge/sdk/grokAcpDriver.cjs
Normal file
File diff suppressed because it is too large
Load Diff
1897
electron/bridges/aiBridge/sdk/grokAcpDriver.test.cjs
Normal file
1897
electron/bridges/aiBridge/sdk/grokAcpDriver.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
1092
electron/bridges/aiBridge/sdk/grokDriver.cjs
Normal file
1092
electron/bridges/aiBridge/sdk/grokDriver.cjs
Normal file
File diff suppressed because it is too large
Load Diff
766
electron/bridges/aiBridge/sdk/grokDriver.test.cjs
Normal file
766
electron/bridges/aiBridge/sdk/grokDriver.test.cjs
Normal file
@@ -0,0 +1,766 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const {
|
||||
GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS,
|
||||
buildGrokCliArgs,
|
||||
buildGrokMcpServerTomlSection,
|
||||
createLineBuffer,
|
||||
formatGrokErrorForUser,
|
||||
listGrokModels,
|
||||
mergeWorkspaceGrokMcpToml,
|
||||
parseGrokModelsOutput,
|
||||
resetGrokMcpMergeRefcountsForTests,
|
||||
resolveGrokPermissionFlags,
|
||||
resolveGrokSpawnSpec,
|
||||
resolveGrokToolIntegrationFlags,
|
||||
resolveGrokTurnPrompt,
|
||||
extractGrokAcpPromptUsage,
|
||||
emitGrokUsage,
|
||||
normalizeGrokPlanUpdate,
|
||||
parseGrokModelSelection,
|
||||
shouldReportGrokProcessExitFailure,
|
||||
runGrokTurn,
|
||||
spawnGrokProcess,
|
||||
stripGrokMcpServerSection,
|
||||
translateGrokStreamEvent,
|
||||
} = require("./grokDriver.cjs");
|
||||
|
||||
function makeEmitter() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
text: (value) => calls.push(["text", value]),
|
||||
reasoning: (value) => calls.push(["reasoning", value]),
|
||||
reasoningEnd: () => calls.push(["reasoningEnd"]),
|
||||
toolCall: (name, args, id) => calls.push(["toolCall", name, args, id]),
|
||||
toolResult: (id, result, name) => calls.push(["toolResult", id, result, name]),
|
||||
sessionId: (id) => calls.push(["sessionId", id]),
|
||||
planUpdate: (itemId, items, status) => calls.push(["planUpdate", itemId, items, status]),
|
||||
usage: (usage) => calls.push(["usage", usage]),
|
||||
emitDone: () => calls.push(["done"]),
|
||||
emitError: (message) => calls.push(["error", message]),
|
||||
};
|
||||
}
|
||||
|
||||
test("resolveGrokPermissionFlags maps observer to plan and others to always-approve", () => {
|
||||
assert.deepEqual(resolveGrokPermissionFlags("observer"), ["--permission-mode", "plan"]);
|
||||
assert.deepEqual(resolveGrokPermissionFlags("confirm"), ["--always-approve"]);
|
||||
assert.deepEqual(resolveGrokPermissionFlags("auto"), ["--always-approve"]);
|
||||
});
|
||||
|
||||
test("buildGrokCliArgs uses streaming-json and optional model/resume/cwd", () => {
|
||||
assert.deepEqual(
|
||||
buildGrokCliArgs({
|
||||
prompt: "hi",
|
||||
model: "grok-4.5",
|
||||
cwd: "/repo",
|
||||
resumeSessionId: "sess-1",
|
||||
permissionMode: "observer",
|
||||
toolIntegrationMode: "skills",
|
||||
}),
|
||||
[
|
||||
"--no-auto-update",
|
||||
"-p",
|
||||
"hi",
|
||||
"--output-format",
|
||||
"streaming-json",
|
||||
"-m",
|
||||
"grok-4.5",
|
||||
"--cwd",
|
||||
"/repo",
|
||||
"-r",
|
||||
"sess-1",
|
||||
"--permission-mode",
|
||||
"plan",
|
||||
],
|
||||
);
|
||||
|
||||
const autoArgs = buildGrokCliArgs({
|
||||
prompt: "go",
|
||||
permissionMode: "auto",
|
||||
toolIntegrationMode: "skills",
|
||||
});
|
||||
assert.ok(autoArgs.includes("--always-approve"));
|
||||
assert.ok(autoArgs.includes("--no-auto-update"));
|
||||
assert.ok(!autoArgs.includes("-m"));
|
||||
});
|
||||
|
||||
test("buildGrokCliArgs passes a selected reasoning effort separately from the model", () => {
|
||||
assert.deepEqual(parseGrokModelSelection("grok-4.6/xhigh"), {
|
||||
model: "grok-4.6",
|
||||
effort: "xhigh",
|
||||
});
|
||||
assert.deepEqual(parseGrokModelSelection("provider/model"), {
|
||||
model: "provider/model",
|
||||
effort: undefined,
|
||||
});
|
||||
|
||||
const args = buildGrokCliArgs({
|
||||
prompt: "hi",
|
||||
model: "grok-4.6/xhigh",
|
||||
permissionMode: "auto",
|
||||
toolIntegrationMode: "skills",
|
||||
});
|
||||
const modelIdx = args.indexOf("-m");
|
||||
const effortIdx = args.indexOf("--reasoning-effort");
|
||||
assert.equal(args[modelIdx + 1], "grok-4.6");
|
||||
assert.equal(args[effortIdx + 1], "xhigh");
|
||||
});
|
||||
|
||||
test("resolveGrokToolIntegrationFlags locks local side-effect tools only in MCP mode", () => {
|
||||
assert.deepEqual(resolveGrokToolIntegrationFlags("skills"), []);
|
||||
assert.deepEqual(resolveGrokToolIntegrationFlags("mcp"), [
|
||||
"--disallowed-tools",
|
||||
GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS.join(","),
|
||||
]);
|
||||
// Default/unknown → MCP lockdown (align with Claude MCP-mode empty local tools).
|
||||
assert.deepEqual(resolveGrokToolIntegrationFlags(undefined), [
|
||||
"--disallowed-tools",
|
||||
GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS.join(","),
|
||||
]);
|
||||
assert.ok(GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS.includes("run_terminal_command"));
|
||||
assert.ok(GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS.includes("search_replace"));
|
||||
assert.ok(GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS.includes("write"));
|
||||
});
|
||||
|
||||
test("buildGrokCliArgs applies MCP-mode local-tool lockdown via real builder", () => {
|
||||
const mcpArgs = buildGrokCliArgs({
|
||||
prompt: "list sessions",
|
||||
permissionMode: "auto",
|
||||
toolIntegrationMode: "mcp",
|
||||
});
|
||||
const denyIdx = mcpArgs.indexOf("--disallowed-tools");
|
||||
assert.ok(denyIdx >= 0, "MCP mode must pass --disallowed-tools");
|
||||
const denied = String(mcpArgs[denyIdx + 1] || "");
|
||||
assert.match(denied, /run_terminal_command/);
|
||||
assert.match(denied, /search_replace/);
|
||||
assert.match(denied, /write/);
|
||||
// MCP meta-tools must not appear in the deny list (Netcatty remote path).
|
||||
assert.doesNotMatch(denied, /mcp|netcatty/i);
|
||||
|
||||
const skillsArgs = buildGrokCliArgs({
|
||||
prompt: "list sessions",
|
||||
permissionMode: "auto",
|
||||
toolIntegrationMode: "skills",
|
||||
});
|
||||
assert.ok(!skillsArgs.includes("--disallowed-tools"), "skills mode must not apply MCP lockdown");
|
||||
});
|
||||
|
||||
test("createLineBuffer rejects and releases an unterminated oversized message", () => {
|
||||
const lines = [];
|
||||
const lineBuffer = createLineBuffer((line) => lines.push(line), 8);
|
||||
lineBuffer.push(Buffer.from("12345678"));
|
||||
assert.throws(
|
||||
() => lineBuffer.push(Buffer.from("9")),
|
||||
(error) => error?.code === "GROK_LINE_LIMIT",
|
||||
);
|
||||
lineBuffer.flush();
|
||||
assert.deepEqual(lines, []);
|
||||
});
|
||||
|
||||
test("formatGrokErrorForUser maps auth failures without over-matching bare login strings", () => {
|
||||
assert.match(
|
||||
formatGrokErrorForUser("Not authenticated"),
|
||||
/not logged in/i,
|
||||
);
|
||||
assert.equal(
|
||||
formatGrokErrorForUser("Failed to run login form validation"),
|
||||
"Failed to run login form validation",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveGrokSpawnSpec matches prepareCommandForSpawn for cmd shims and exes", () => {
|
||||
const { prepareCommandForSpawn } = require("../../ai/shellUtils.cjs");
|
||||
// On win32, .cmd needs shell (or native-exe rewrite). Elsewhere shell stays false.
|
||||
const shim = "C:\\Users\\me\\AppData\\Roaming\\npm\\grok.cmd";
|
||||
const expected = prepareCommandForSpawn(shim, ["agent", "stdio"]);
|
||||
const actual = resolveGrokSpawnSpec(shim, ["agent", "stdio"]);
|
||||
assert.deepEqual(actual, expected);
|
||||
if (process.platform === "win32") {
|
||||
assert.equal(actual.shell, true);
|
||||
assert.equal(actual.args.length, 0);
|
||||
} else {
|
||||
assert.equal(actual.shell, false);
|
||||
}
|
||||
|
||||
const exePath = process.platform === "win32" ? "C:\\Tools\\grok.exe" : "/usr/bin/grok";
|
||||
const exe = resolveGrokSpawnSpec(exePath, ["-p", "hi"]);
|
||||
assert.equal(exe.shell, false);
|
||||
assert.equal(exe.command, exePath);
|
||||
assert.deepEqual(exe.args, ["-p", "hi"]);
|
||||
});
|
||||
|
||||
test("spawnGrokProcess forwards shell from prepareCommandForSpawn into spawnImpl", () => {
|
||||
const calls = [];
|
||||
const fakeChild = {
|
||||
stdout: { on() {} },
|
||||
stderr: { on() {} },
|
||||
stdin: null,
|
||||
on() {},
|
||||
kill() {},
|
||||
};
|
||||
const shim = "C:\\Users\\me\\AppData\\Roaming\\npm\\grok.cmd";
|
||||
const child = spawnGrokProcess(
|
||||
(command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return fakeChild;
|
||||
},
|
||||
shim,
|
||||
["agent", "stdio"],
|
||||
{ cwd: "D:\\repo", windowsHide: true },
|
||||
);
|
||||
assert.equal(child, fakeChild);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].options.cwd, "D:\\repo");
|
||||
assert.equal(calls[0].options.windowsHide, true);
|
||||
assert.equal(calls[0].options.shell, process.platform === "win32");
|
||||
if (process.platform === "win32") {
|
||||
assert.match(String(calls[0].command), /grok\.cmd/i);
|
||||
assert.deepEqual(calls[0].args, []);
|
||||
} else {
|
||||
assert.equal(calls[0].command, shim);
|
||||
assert.deepEqual(calls[0].args, ["agent", "stdio"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("extractGrokAcpPromptUsage maps live Grok _meta.usage and cachedReadTokens", () => {
|
||||
const promptResult = {
|
||||
stopReason: "end_turn",
|
||||
_meta: {
|
||||
inputTokens: 27144,
|
||||
outputTokens: 29,
|
||||
totalTokens: 27174,
|
||||
cachedReadTokens: 2560,
|
||||
reasoningTokens: 24,
|
||||
usage: {
|
||||
inputTokens: 27144,
|
||||
outputTokens: 29,
|
||||
totalTokens: 27173,
|
||||
cachedReadTokens: 2560,
|
||||
reasoningTokens: 24,
|
||||
},
|
||||
},
|
||||
};
|
||||
const extracted = extractGrokAcpPromptUsage(promptResult);
|
||||
assert.equal(extracted.cachedReadTokens, 2560);
|
||||
const calls = [];
|
||||
emitGrokUsage({ usage: (u) => calls.push(u) }, extracted);
|
||||
assert.deepEqual(calls[0], {
|
||||
inputTokens: 27144,
|
||||
cachedInputTokens: 2560,
|
||||
outputTokens: 29,
|
||||
reasoningTokens: 24,
|
||||
totalTokens: 27173,
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveGrokTurnPrompt seeds history only when resume falls back to session/new", () => {
|
||||
const seed = "[Conversation context replay]\nUSER: earlier";
|
||||
const turn = "latest question";
|
||||
assert.equal(
|
||||
resolveGrokTurnPrompt({
|
||||
turnPrompt: turn,
|
||||
historySeed: seed,
|
||||
resumeSessionId: "old-sess",
|
||||
establishMethod: "new",
|
||||
}),
|
||||
`${seed}\n\n${turn}`,
|
||||
);
|
||||
// Successful resume/load must not inject seed (avoids stacked prior replies).
|
||||
assert.equal(
|
||||
resolveGrokTurnPrompt({
|
||||
turnPrompt: turn,
|
||||
historySeed: seed,
|
||||
resumeSessionId: "old-sess",
|
||||
establishMethod: "resume",
|
||||
}),
|
||||
turn,
|
||||
);
|
||||
assert.equal(
|
||||
resolveGrokTurnPrompt({
|
||||
turnPrompt: turn,
|
||||
historySeed: seed,
|
||||
resumeSessionId: "old-sess",
|
||||
establishMethod: "load",
|
||||
}),
|
||||
turn,
|
||||
);
|
||||
// No resume attempt → never seed (first-turn replay is handled upstream).
|
||||
assert.equal(
|
||||
resolveGrokTurnPrompt({
|
||||
turnPrompt: turn,
|
||||
historySeed: seed,
|
||||
resumeSessionId: undefined,
|
||||
establishMethod: "new",
|
||||
}),
|
||||
turn,
|
||||
);
|
||||
assert.equal(
|
||||
resolveGrokTurnPrompt({
|
||||
turnPrompt: turn,
|
||||
historySeed: "",
|
||||
resumeSessionId: "old-sess",
|
||||
establishMethod: "new",
|
||||
}),
|
||||
turn,
|
||||
);
|
||||
});
|
||||
|
||||
test("translateGrokStreamEvent maps thought, text, tools, usage, end", () => {
|
||||
const emitter = makeEmitter();
|
||||
const state = {};
|
||||
|
||||
translateGrokStreamEvent({ type: "thought", data: "plan" }, emitter, state);
|
||||
translateGrokStreamEvent({ type: "text", data: "Hi" }, emitter, state);
|
||||
translateGrokStreamEvent({
|
||||
type: "tool_call",
|
||||
toolCallId: "c1",
|
||||
toolName: "read_file",
|
||||
status: "in_progress",
|
||||
rawInput: { path: "a.ts" },
|
||||
}, emitter, state);
|
||||
translateGrokStreamEvent({
|
||||
type: "tool_call_update",
|
||||
toolCallId: "c1",
|
||||
status: "completed",
|
||||
rawOutput: { lines: 2 },
|
||||
}, emitter, state);
|
||||
translateGrokStreamEvent({
|
||||
type: "usage",
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 3,
|
||||
cache_read_input_tokens: 1,
|
||||
reasoning_tokens: 2,
|
||||
total_tokens: 16,
|
||||
},
|
||||
}, emitter, state);
|
||||
translateGrokStreamEvent({
|
||||
type: "end",
|
||||
stopReason: "end_turn",
|
||||
sessionId: "s1",
|
||||
usage: { input_tokens: 10, output_tokens: 3, total_tokens: 13 },
|
||||
}, emitter, state);
|
||||
|
||||
assert.deepEqual(emitter.calls, [
|
||||
["reasoning", "plan"],
|
||||
["reasoningEnd"],
|
||||
["text", "Hi"],
|
||||
["toolCall", "read_file", { path: "a.ts" }, "c1"],
|
||||
["toolResult", "c1", "{\"lines\":2}", "read_file"],
|
||||
["usage", {
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 1,
|
||||
outputTokens: 3,
|
||||
reasoningTokens: 2,
|
||||
totalTokens: 16,
|
||||
}],
|
||||
["sessionId", "s1"],
|
||||
["usage", {
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 3,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 13,
|
||||
}],
|
||||
]);
|
||||
assert.equal(state.sessionId, "s1");
|
||||
assert.equal(state.streamedAssistantText, true);
|
||||
});
|
||||
|
||||
test("translateGrokStreamEvent maps error events to emitError and stop", () => {
|
||||
const emitter = makeEmitter();
|
||||
const state = {};
|
||||
const stop = translateGrokStreamEvent(
|
||||
{ type: "error", message: "Couldn't start session" },
|
||||
emitter,
|
||||
state,
|
||||
);
|
||||
assert.equal(stop, true);
|
||||
assert.equal(state.failed, true);
|
||||
assert.deepEqual(emitter.calls, [["error", "Couldn't start session"]]);
|
||||
});
|
||||
|
||||
test("buildGrokMcpServerTomlSection escapes paths and env", () => {
|
||||
const section = buildGrokMcpServerTomlSection({
|
||||
name: "netcatty-remote-hosts",
|
||||
command: "C:\\Program Files\\node.exe",
|
||||
args: ["mcp.cjs", "--flag"],
|
||||
env: [{ name: "TOKEN", value: 'a"b' }],
|
||||
});
|
||||
assert.match(section, /\[mcp_servers\.netcatty-remote-hosts\]/);
|
||||
assert.match(section, /command = "C:\\\\Program Files\\\\node\.exe"/);
|
||||
assert.match(section, /args = \["mcp\.cjs", "--flag"\]/);
|
||||
assert.match(section, /TOKEN = "a\\"b"/);
|
||||
assert.match(section, /enabled = true/);
|
||||
});
|
||||
|
||||
test("stripGrokMcpServerSection removes only the named server block", () => {
|
||||
const input = [
|
||||
"[ui]",
|
||||
"compact_mode = true",
|
||||
"",
|
||||
"[mcp_servers.other]",
|
||||
'command = "echo"',
|
||||
"",
|
||||
"[mcp_servers.netcatty-remote-hosts]",
|
||||
'command = "node"',
|
||||
"enabled = true",
|
||||
"",
|
||||
"[mcp_servers.other.nested]",
|
||||
"x = 1",
|
||||
].join("\n");
|
||||
|
||||
const stripped = stripGrokMcpServerSection(input, "netcatty-remote-hosts");
|
||||
assert.match(stripped, /\[mcp_servers\.other\]/);
|
||||
assert.match(stripped, /\[ui\]/);
|
||||
assert.doesNotMatch(stripped, /netcatty-remote-hosts/);
|
||||
});
|
||||
|
||||
test("mergeWorkspaceGrokMcpToml upserts netcatty without dropping other servers", () => {
|
||||
resetGrokMcpMergeRefcountsForTests();
|
||||
const path = require("node:path");
|
||||
const repo = path.join("repo-fixture");
|
||||
const grokDir = path.join(repo, ".grok");
|
||||
const configPath = path.join(grokDir, "config.toml");
|
||||
const original = [
|
||||
"[mcp_servers.other]",
|
||||
'command = "echo"',
|
||||
"enabled = true",
|
||||
"",
|
||||
].join("\n");
|
||||
const files = new Map();
|
||||
files.set(configPath, original);
|
||||
|
||||
const handle = mergeWorkspaceGrokMcpToml(repo, [{
|
||||
name: "netcatty-remote-hosts",
|
||||
command: "node",
|
||||
args: ["mcp.cjs"],
|
||||
env: [{ name: "TOKEN", value: "x" }],
|
||||
}], {
|
||||
existsSync: (p) => files.has(p) || p === grokDir,
|
||||
readFileSync: (p) => files.get(p),
|
||||
writeFileSync: (p, data) => { files.set(p, data); },
|
||||
mkdirSync: () => {},
|
||||
unlinkSync: (p) => { files.delete(p); },
|
||||
});
|
||||
|
||||
const written = files.get(configPath);
|
||||
assert.match(written, /\[mcp_servers\.other\]/);
|
||||
assert.match(written, /\[mcp_servers\.netcatty-remote-hosts\]/);
|
||||
assert.match(written, /TOKEN = "x"/);
|
||||
|
||||
handle.restore();
|
||||
assert.equal(files.get(configPath), original);
|
||||
});
|
||||
|
||||
test("parseGrokModelsOutput reads default and bullet list", () => {
|
||||
const parsed = parseGrokModelsOutput([
|
||||
"You are logged in with grok.com.",
|
||||
"",
|
||||
"Default model: grok-4.5",
|
||||
"",
|
||||
"Available models:",
|
||||
" * grok-4.5 (default)",
|
||||
" * grok-code-fast",
|
||||
].join("\n"));
|
||||
assert.equal(parsed.currentModelId, "grok-4.5");
|
||||
assert.deepEqual(parsed.models, [
|
||||
{
|
||||
id: "grok-4.5",
|
||||
name: "grok-4.5",
|
||||
thinkingLevels: ["high", "medium", "low"],
|
||||
defaultThinkingLevel: "high",
|
||||
},
|
||||
{ id: "grok-code-fast", name: "grok-code-fast" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("runGrokTurn streams fixture lines and emits done", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.pid = 4242;
|
||||
child.kill = () => {};
|
||||
|
||||
const spawnImpl = (bin, args) => {
|
||||
assert.equal(bin, "/usr/bin/grok");
|
||||
assert.ok(args.includes("streaming-json"));
|
||||
assert.ok(args.includes("--always-approve"));
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit("data", Buffer.from(
|
||||
[
|
||||
'{"type":"thought","data":"thinking"}',
|
||||
'{"type":"text","data":"hello"}',
|
||||
'{"type":"end","sessionId":"sess-xyz","stopReason":"end_turn"}',
|
||||
"",
|
||||
].join("\n"),
|
||||
));
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
};
|
||||
|
||||
const result = await runGrokTurn({
|
||||
prompt: "hi",
|
||||
binPath: "/usr/bin/grok",
|
||||
cwd: "/repo",
|
||||
permissionMode: "auto",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
spawnImpl,
|
||||
mergeMcp: () => ({ restore() {} }),
|
||||
});
|
||||
|
||||
assert.equal(result.sessionId, "sess-xyz");
|
||||
assert.ok(emitter.calls.some((c) => c[0] === "text" && c[1] === "hello"));
|
||||
assert.ok(emitter.calls.some((c) => c[0] === "done"));
|
||||
assert.ok(emitter.calls.some((c) => c[0] === "sessionId" && c[1] === "sess-xyz"));
|
||||
});
|
||||
|
||||
test("runGrokTurn reports error when process dies after partial text without end", async () => {
|
||||
// Mid-response crash: text already streamed, no end → must not emitDone.
|
||||
const emitter = makeEmitter();
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.pid = 99;
|
||||
child.kill = () => {};
|
||||
|
||||
const spawnImpl = () => {
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit("data", Buffer.from('{"type":"text","data":"partial…"}\n'));
|
||||
child.emit("close", 1);
|
||||
});
|
||||
return child;
|
||||
};
|
||||
|
||||
await runGrokTurn({
|
||||
prompt: "write a lot",
|
||||
binPath: "/usr/bin/grok",
|
||||
permissionMode: "auto",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
spawnImpl,
|
||||
mergeMcp: () => ({ restore() {} }),
|
||||
});
|
||||
|
||||
assert.ok(emitter.calls.some((c) => c[0] === "text" && c[1] === "partial…"));
|
||||
assert.ok(emitter.calls.some((c) => c[0] === "error"), "partial stream + exit 1 must emitError");
|
||||
assert.ok(!emitter.calls.some((c) => c[0] === "done"), "must not emitDone on mid-turn crash");
|
||||
});
|
||||
|
||||
test("runGrokTurn fails when process is signal-killed mid-turn (code=null)", async () => {
|
||||
// Node close(null, "SIGTERM") — previously skipped because code was not a nonzero number.
|
||||
const emitter = makeEmitter();
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.pid = 98;
|
||||
child.kill = () => {};
|
||||
|
||||
const spawnImpl = () => {
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit("data", Buffer.from('{"type":"text","data":"partial…"}\n'));
|
||||
child.emit("close", null, "SIGTERM");
|
||||
});
|
||||
return child;
|
||||
};
|
||||
|
||||
await runGrokTurn({
|
||||
prompt: "write a lot",
|
||||
binPath: "/usr/bin/grok",
|
||||
permissionMode: "auto",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
spawnImpl,
|
||||
mergeMcp: () => ({ restore() {} }),
|
||||
});
|
||||
|
||||
assert.ok(emitter.calls.some((c) => c[0] === "text" && c[1] === "partial…"));
|
||||
const err = emitter.calls.find((c) => c[0] === "error");
|
||||
assert.ok(err, "signal kill mid-turn must emitError");
|
||||
assert.match(String(err[1]), /SIGTERM|signal/i);
|
||||
assert.ok(!emitter.calls.some((c) => c[0] === "done"));
|
||||
});
|
||||
|
||||
test("runGrokTurn fails when process exits 0 after partial text without end", async () => {
|
||||
// Quiet CLI death must not look like a successful turn.
|
||||
const emitter = makeEmitter();
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.pid = 97;
|
||||
child.kill = () => {};
|
||||
|
||||
const spawnImpl = () => {
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit("data", Buffer.from('{"type":"text","data":"partial…"}\n'));
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
};
|
||||
|
||||
await runGrokTurn({
|
||||
prompt: "write a lot",
|
||||
binPath: "/usr/bin/grok",
|
||||
permissionMode: "auto",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
spawnImpl,
|
||||
mergeMcp: () => ({ restore() {} }),
|
||||
});
|
||||
|
||||
assert.ok(emitter.calls.some((c) => c[0] === "text" && c[1] === "partial…"));
|
||||
assert.ok(emitter.calls.some((c) => c[0] === "error"), "exit 0 without end must emitError");
|
||||
assert.ok(!emitter.calls.some((c) => c[0] === "done"));
|
||||
});
|
||||
|
||||
test("translateGrokStreamEvent emits toolResult when rawOutput present without status", () => {
|
||||
const emitter = makeEmitter();
|
||||
const state = {};
|
||||
translateGrokStreamEvent({
|
||||
type: "tool_call_update",
|
||||
toolCallId: "t1",
|
||||
toolName: "read",
|
||||
rawOutput: { content: "file body" },
|
||||
}, emitter, state);
|
||||
assert.ok(emitter.calls.some((c) => c[0] === "toolCall" && c[3] === "t1"));
|
||||
assert.ok(emitter.calls.some((c) => c[0] === "toolResult" && c[1] === "t1"));
|
||||
});
|
||||
|
||||
test("normalizeGrokPlanUpdate maps to shared { text, completed } activity shape", () => {
|
||||
assert.deepEqual(
|
||||
normalizeGrokPlanUpdate([
|
||||
{ content: "Explore", status: "completed" },
|
||||
{ text: "Edit", status: "pending" },
|
||||
"Ship it",
|
||||
]),
|
||||
{
|
||||
items: [
|
||||
{ text: "Explore", completed: true },
|
||||
{ text: "Edit", completed: false },
|
||||
{ text: "Ship it", completed: false },
|
||||
],
|
||||
status: "running",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeGrokPlanUpdate([
|
||||
{ content: "A", status: "done" },
|
||||
{ content: "B", completed: true },
|
||||
]),
|
||||
{
|
||||
items: [
|
||||
{ text: "A", completed: true },
|
||||
{ text: "B", completed: true },
|
||||
],
|
||||
status: "completed",
|
||||
},
|
||||
);
|
||||
assert.equal(normalizeGrokPlanUpdate([]), null);
|
||||
});
|
||||
|
||||
test("translateGrokStreamEvent plan uses text/completed and running|completed status", () => {
|
||||
const emitter = makeEmitter();
|
||||
translateGrokStreamEvent({
|
||||
type: "plan",
|
||||
entries: [
|
||||
{ content: "Step one", status: "completed" },
|
||||
{ content: "Step two", status: "in_progress" },
|
||||
],
|
||||
}, emitter, {});
|
||||
const planCall = emitter.calls.find((c) => c[0] === "planUpdate");
|
||||
assert.ok(planCall);
|
||||
assert.equal(planCall[1], "grok-plan");
|
||||
assert.deepEqual(planCall[2], [
|
||||
{ text: "Step one", completed: true },
|
||||
{ text: "Step two", completed: false },
|
||||
]);
|
||||
assert.equal(planCall[3], "running");
|
||||
assert.notEqual(planCall[3], "updated");
|
||||
});
|
||||
|
||||
test("shouldReportGrokProcessExitFailure fails any incomplete close (incl exit 0)", () => {
|
||||
assert.equal(shouldReportGrokProcessExitFailure({ turnCompleted: false }, null, null, "SIGTERM"), true);
|
||||
assert.equal(shouldReportGrokProcessExitFailure({ turnCompleted: false }, null, 143, null), true);
|
||||
// Exit 0 without protocol completion is still a failure (CLI can die quietly).
|
||||
assert.equal(shouldReportGrokProcessExitFailure({ turnCompleted: false }, null, 0, null), true);
|
||||
assert.equal(shouldReportGrokProcessExitFailure({ turnCompleted: true }, null, null, "SIGTERM"), false);
|
||||
assert.equal(shouldReportGrokProcessExitFailure({ turnCompleted: true }, null, 1, null), false);
|
||||
assert.equal(shouldReportGrokProcessExitFailure({ turnCompleted: false }, { aborted: true }, null, "SIGKILL"), false);
|
||||
assert.equal(shouldReportGrokProcessExitFailure({ turnCompleted: false, failed: true }, null, 1, null), false);
|
||||
});
|
||||
|
||||
test("runGrokTurn ignores exit code 1 after end event (Windows teardown)", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.pid = 100;
|
||||
child.kill = () => {};
|
||||
|
||||
const spawnImpl = () => {
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit("data", Buffer.from(
|
||||
[
|
||||
'{"type":"text","data":"done"}',
|
||||
'{"type":"end","sessionId":"s-end","stopReason":"end_turn"}',
|
||||
"",
|
||||
].join("\n"),
|
||||
));
|
||||
child.emit("close", 1);
|
||||
});
|
||||
return child;
|
||||
};
|
||||
|
||||
await runGrokTurn({
|
||||
prompt: "hi",
|
||||
binPath: "/usr/bin/grok",
|
||||
permissionMode: "auto",
|
||||
injectedMcpServers: [],
|
||||
emitter,
|
||||
spawnImpl,
|
||||
mergeMcp: () => ({ restore() {} }),
|
||||
});
|
||||
|
||||
assert.ok(emitter.calls.some((c) => c[0] === "done"));
|
||||
assert.ok(!emitter.calls.some((c) => c[0] === "error"));
|
||||
});
|
||||
|
||||
test("runGrokTurn reports missing CLI clearly", async () => {
|
||||
const emitter = makeEmitter();
|
||||
const result = await runGrokTurn({
|
||||
prompt: "hi",
|
||||
binPath: "",
|
||||
emitter,
|
||||
});
|
||||
assert.equal(result.sessionId, null);
|
||||
assert.match(String(emitter.calls[0]?.[1] || ""), /not found/i);
|
||||
});
|
||||
|
||||
test("listGrokModels parses spawn stdout", async () => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.pid = 1;
|
||||
child.kill = () => {};
|
||||
|
||||
const spawnImpl = (_bin, args) => {
|
||||
assert.deepEqual(args, ["--no-auto-update", "models"]);
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit("data", Buffer.from("Default model: grok-4.5\n* grok-4.5 (default)\n"));
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
};
|
||||
|
||||
const result = await listGrokModels({
|
||||
binPath: "/usr/bin/grok",
|
||||
spawnImpl,
|
||||
});
|
||||
assert.equal(result.currentModelId, "grok-4.5");
|
||||
assert.equal(result.models[0].id, "grok-4.5");
|
||||
});
|
||||
404
electron/bridges/aiBridge/sdk/index.cjs
Normal file
404
electron/bridges/aiBridge/sdk/index.cjs
Normal file
@@ -0,0 +1,404 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* SDK driver registry. Mirrors craft backend/factory.ts DRIVER_REGISTRY.
|
||||
* Each driver exposes a uniform runTurn(ctx) that builds its SDK options from
|
||||
* the neutral context and streams events through ctx.emitter.
|
||||
*
|
||||
* ctx shape (built by sdkStreamHandlers.cjs):
|
||||
* { prompt, attachments, cwd, model, env, binPath, injectedMcpServers, emitter,
|
||||
* signal, resumeSessionId, apiKey, baseUrl }
|
||||
*/
|
||||
const claude = require("./claudeDriver.cjs");
|
||||
const codex = require("./codexDriver.cjs");
|
||||
const copilot = require("./copilotDriver.cjs");
|
||||
const cursor = require("./cursorDriver.cjs");
|
||||
const cursorCli = require("./cursorCliDriver.cjs");
|
||||
const codebuddy = require("./codebuddyDriver.cjs");
|
||||
const opencode = require("./opencodeDriver.cjs");
|
||||
const grok = require("./grokDriver.cjs");
|
||||
const grokAcp = require("./grokAcpDriver.cjs");
|
||||
const { codebuddySessionManager } = require("./codebuddySessionManager.cjs");
|
||||
|
||||
function hasCodebuddyQueryOnlyOptions(options) {
|
||||
return Boolean(
|
||||
options.maxBudgetUsd ||
|
||||
options.sandbox?.enabled === true ||
|
||||
options.fallbackModel ||
|
||||
options.enableFileCheckpointing === true ||
|
||||
options.outputFormat,
|
||||
);
|
||||
}
|
||||
|
||||
const DRIVER_REGISTRY = {
|
||||
claude: {
|
||||
async runTurn(ctx) {
|
||||
const options = claude.buildClaudeQueryOptions({
|
||||
cwd: ctx.cwd,
|
||||
model: ctx.model,
|
||||
env: ctx.env,
|
||||
pathToClaudeCodeExecutable: ctx.binPath,
|
||||
abortController: ctx.abortController,
|
||||
injectedMcpServers: ctx.injectedMcpServers,
|
||||
settings: ctx.claudeSettings,
|
||||
resume: ctx.resumeSessionId,
|
||||
toolIntegrationMode: ctx.toolIntegrationMode,
|
||||
});
|
||||
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter });
|
||||
},
|
||||
async listModels(ctx) {
|
||||
return claude.listClaudeModels({
|
||||
pathToClaudeCodeExecutable: ctx.binPath,
|
||||
env: ctx.env,
|
||||
abortController: ctx.abortController,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
async runTurn(ctx) {
|
||||
const constructorOptions = codex.buildCodexConstructorOptions({
|
||||
codexPath: ctx.binPath,
|
||||
env: ctx.env,
|
||||
apiKey: ctx.apiKey,
|
||||
baseUrl: ctx.baseUrl,
|
||||
injectedMcpServers: ctx.injectedMcpServers,
|
||||
});
|
||||
const threadOptions = codex.buildCodexThreadOptions({ cwd: ctx.cwd, model: ctx.model });
|
||||
return codex.runCodexTurn({
|
||||
prompt: ctx.prompt,
|
||||
attachments: ctx.attachments,
|
||||
constructorOptions,
|
||||
threadOptions,
|
||||
resumeThreadId: ctx.resumeSessionId,
|
||||
emitter: ctx.emitter,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
},
|
||||
// codex-sdk exposes no model catalog; the UI falls back to curated presets.
|
||||
async listModels() { return []; },
|
||||
},
|
||||
copilot: {
|
||||
async runTurn(ctx) {
|
||||
const clientOptions = copilot.buildCopilotClientOptions({ cliPath: ctx.binPath });
|
||||
const sessionOptions = copilot.buildCopilotSessionOptions({
|
||||
model: ctx.model,
|
||||
injectedMcpServers: ctx.injectedMcpServers,
|
||||
toolIntegrationMode: ctx.toolIntegrationMode,
|
||||
});
|
||||
return copilot.runCopilotTurn({
|
||||
prompt: ctx.prompt,
|
||||
attachments: ctx.attachments,
|
||||
clientOptions,
|
||||
sessionOptions,
|
||||
resumeSessionId: ctx.resumeSessionId,
|
||||
toolIntegrationMode: ctx.toolIntegrationMode,
|
||||
runtimeEnv: ctx.env,
|
||||
emitter: ctx.emitter,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
},
|
||||
async listModels(ctx) {
|
||||
return copilot.listCopilotModels({
|
||||
cliPath: ctx.binPath,
|
||||
abortController: ctx.abortController,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
},
|
||||
},
|
||||
cursor: {
|
||||
async runTurn(ctx) {
|
||||
const authMode = ctx.cursorAuthMode === "cli-login" ? "cli-login" : "api-key";
|
||||
if (authMode === "cli-login") {
|
||||
return cursorCli.runCursorCliTurn({
|
||||
prompt: ctx.prompt,
|
||||
binPath: ctx.cursorCliBinPath || ctx.binPath,
|
||||
cwd: ctx.cwd,
|
||||
chatSessionId: ctx.chatSessionId,
|
||||
getTempDir: ctx.getTempDir,
|
||||
model: ctx.model,
|
||||
env: ctx.env,
|
||||
permissionMode: ctx.permissionMode,
|
||||
resumeSessionId: ctx.resumeSessionId,
|
||||
injectedMcpServers: ctx.injectedMcpServers,
|
||||
emitter: ctx.emitter,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
}
|
||||
const agentOptions = cursor.buildCursorAgentOptions({
|
||||
apiKey: ctx.apiKey,
|
||||
env: ctx.env,
|
||||
model: ctx.model,
|
||||
cwd: ctx.cwd,
|
||||
injectedMcpServers: ctx.injectedMcpServers,
|
||||
});
|
||||
return cursor.runCursorTurn({
|
||||
prompt: ctx.prompt,
|
||||
attachments: ctx.attachments,
|
||||
agentOptions,
|
||||
runtimeEnv: ctx.env,
|
||||
resumeSessionId: ctx.resumeSessionId,
|
||||
emitter: ctx.emitter,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
},
|
||||
async listModels(ctx) {
|
||||
if (ctx.cursorAuthMode === "cli-login") {
|
||||
return cursorCli.listCursorCliModels({
|
||||
binPath: ctx.cursorCliBinPath || ctx.binPath,
|
||||
env: ctx.env,
|
||||
abortController: ctx.abortController,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
}
|
||||
return cursor.listCursorModels({
|
||||
env: ctx.env,
|
||||
abortController: ctx.abortController,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
},
|
||||
},
|
||||
codebuddy: {
|
||||
async runTurn(ctx) {
|
||||
// Build the permission handler: when the CLI hits a security restriction,
|
||||
// auto-confirm (auto mode) or prompt the user (confirm mode) instead of
|
||||
// throwing an error.
|
||||
const canUseTool = codebuddy.buildCodebuddyCanUseTool({
|
||||
permissionMode: ctx.permissionMode,
|
||||
chatSessionId: ctx.chatSessionId,
|
||||
requestApproval: ctx.requestApprovalFromRenderer,
|
||||
});
|
||||
// Build the elicitation handler: forwards create/complete events to the
|
||||
// renderer and waits for the user's decision via the session manager's
|
||||
// pending-response map (resolved by the elicitation-response IPC).
|
||||
// chatSessionId lets closeForChat cancel pendings when the chat closes.
|
||||
const elicitation = codebuddy.buildCodebuddyElicitation(
|
||||
ctx.emitter,
|
||||
codebuddySessionManager.elicitationPending,
|
||||
{ chatSessionId: ctx.chatSessionId },
|
||||
);
|
||||
const options = codebuddy.buildCodebuddyQueryOptions({
|
||||
cwd: ctx.cwd,
|
||||
model: ctx.model,
|
||||
env: ctx.env,
|
||||
injectedMcpServers: ctx.injectedMcpServers,
|
||||
abortController: ctx.abortController,
|
||||
resume: ctx.resumeSessionId,
|
||||
pathToCodebuddyCode: ctx.binPath,
|
||||
toolIntegrationMode: ctx.toolIntegrationMode,
|
||||
// SDK 0.3.230 options
|
||||
systemPrompt: ctx.systemPrompt,
|
||||
effort: ctx.effort,
|
||||
maxTurns: ctx.maxTurns,
|
||||
maxBudgetUsd: ctx.maxBudgetUsd,
|
||||
fallbackModel: ctx.fallbackModel,
|
||||
sandbox: ctx.sandbox,
|
||||
agents: ctx.agents,
|
||||
outputFormat: ctx.outputFormat,
|
||||
enableFileCheckpointing: ctx.enableFileCheckpointing,
|
||||
traceId: ctx.traceId,
|
||||
parentSpanId: ctx.parentSpanId,
|
||||
hooks: codebuddy.buildCodebuddyHooks(ctx.emitter, {
|
||||
toolIntegrationMode: ctx.toolIntegrationMode,
|
||||
additionalHooks: ctx.hooks,
|
||||
allowedCliCommandPrefix: ctx.skillsCliCommandPrefix,
|
||||
}),
|
||||
elicitation,
|
||||
canUseTool,
|
||||
});
|
||||
|
||||
const sessionKey = [
|
||||
String(ctx.chatSessionId || ""),
|
||||
"codebuddy",
|
||||
String(ctx.binPath || ""),
|
||||
"sdk",
|
||||
].join("\u0000");
|
||||
|
||||
// Try V2 Session API first (persistent multi-turn), falling back to
|
||||
// query() only for fields that SessionOptions does not support.
|
||||
const hasQueryOnlyOptions = hasCodebuddyQueryOnlyOptions(options);
|
||||
|
||||
if (!hasQueryOnlyOptions) {
|
||||
const sessionOptions = {
|
||||
cwd: options.cwd,
|
||||
model: options.model,
|
||||
env: options.env,
|
||||
pathToCodebuddyCode: options.pathToCodebuddyCode,
|
||||
mcpServers: options.mcpServers,
|
||||
permissionMode: options.permissionMode,
|
||||
extraArgs: options.extraArgs,
|
||||
systemPrompt: options.systemPrompt,
|
||||
hooks: options.hooks,
|
||||
elicitation: options.elicitation,
|
||||
canUseTool: options.canUseTool,
|
||||
includePartialMessages: true,
|
||||
tools: options.tools,
|
||||
disallowedTools: options.disallowedTools,
|
||||
settingSources: options.settingSources,
|
||||
maxTurns: options.maxTurns,
|
||||
agents: options.agents,
|
||||
thinking: options.thinking,
|
||||
effort: options.effort,
|
||||
};
|
||||
const v2Result = await codebuddySessionManager.runTurn({
|
||||
sessionKey,
|
||||
prompt: ctx.prompt,
|
||||
attachments: ctx.attachments,
|
||||
options,
|
||||
emitter: ctx.emitter,
|
||||
sessionOptions,
|
||||
resumeSessionId: ctx.resumeSessionId,
|
||||
});
|
||||
if (v2Result) return v2Result;
|
||||
} else {
|
||||
// Do not leave a warm V2 process with stale context while query() is
|
||||
// resuming and advancing the same persisted conversation.
|
||||
codebuddySessionManager.closeSession(sessionKey);
|
||||
}
|
||||
|
||||
// Fallback: legacy query() per-turn (supports all Options fields).
|
||||
return codebuddy.runCodebuddyTurn({
|
||||
prompt: ctx.prompt,
|
||||
attachments: ctx.attachments,
|
||||
options,
|
||||
emitter: ctx.emitter,
|
||||
});
|
||||
},
|
||||
async steerTurn(ctx) {
|
||||
const sessionKey = [
|
||||
String(ctx.chatSessionId || ""),
|
||||
"codebuddy",
|
||||
String(ctx.binPath || ""),
|
||||
"sdk",
|
||||
].join("\u0000");
|
||||
return codebuddySessionManager.steer({
|
||||
sessionKey,
|
||||
prompt: ctx.prompt,
|
||||
attachments: ctx.attachments,
|
||||
});
|
||||
},
|
||||
async listModels(ctx) {
|
||||
return codebuddy.listCodebuddyModels({
|
||||
pathToCodebuddyCode: ctx.binPath,
|
||||
env: ctx.env,
|
||||
abortController: ctx.abortController,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
},
|
||||
},
|
||||
opencode: {
|
||||
async runTurn(ctx) {
|
||||
return opencode.runOpenCodeTurn({
|
||||
prompt: ctx.prompt,
|
||||
systemPrompt: ctx.systemPrompt,
|
||||
attachments: ctx.attachments,
|
||||
cwd: ctx.cwd,
|
||||
model: ctx.model,
|
||||
env: ctx.env,
|
||||
binPath: ctx.binPath,
|
||||
injectedMcpServers: ctx.injectedMcpServers,
|
||||
toolIntegrationMode: ctx.toolIntegrationMode,
|
||||
skillsPathAllowlist: ctx.skillsPathAllowlist,
|
||||
resumeSessionId: ctx.resumeSessionId,
|
||||
emitter: ctx.emitter,
|
||||
abortController: ctx.abortController,
|
||||
});
|
||||
},
|
||||
async listModels(ctx) {
|
||||
return opencode.listOpenCodeModels({
|
||||
env: ctx.env,
|
||||
binPath: ctx.binPath,
|
||||
abortController: ctx.abortController,
|
||||
signal: ctx.abortController?.signal || ctx.signal,
|
||||
});
|
||||
},
|
||||
},
|
||||
grok: {
|
||||
async runTurn(ctx) {
|
||||
// Default: ACP (`grok agent stdio`) with session-level mcpServers.
|
||||
// Explicit fallback: NETCATTY_GROK_RUNTIME=streaming-json or ctx.grokRuntime.
|
||||
const runtime = String(
|
||||
ctx.grokRuntime
|
||||
|| ctx.env?.NETCATTY_GROK_RUNTIME
|
||||
|| process.env.NETCATTY_GROK_RUNTIME
|
||||
|| "acp",
|
||||
).toLowerCase();
|
||||
if (runtime === "streaming-json" || runtime === "cli" || runtime === "headless") {
|
||||
// Headless cannot know if -r restored history before the prompt is sent.
|
||||
// Prefer native -r without seed (common success path). Stale-id fallback
|
||||
// is handled on ACP (default runtime) via historySeed + session/new.
|
||||
return grok.runGrokTurn({
|
||||
prompt: ctx.prompt,
|
||||
binPath: ctx.binPath,
|
||||
cwd: ctx.cwd,
|
||||
model: ctx.model,
|
||||
env: ctx.env,
|
||||
permissionMode: ctx.permissionMode,
|
||||
toolIntegrationMode: ctx.toolIntegrationMode,
|
||||
resumeSessionId: ctx.resumeSessionId,
|
||||
injectedMcpServers: ctx.injectedMcpServers,
|
||||
emitter: ctx.emitter,
|
||||
signal: ctx.signal || ctx.abortController?.signal,
|
||||
});
|
||||
}
|
||||
return grokAcp.runGrokAcpTurn({
|
||||
prompt: ctx.prompt,
|
||||
systemPrompt: ctx.systemPrompt,
|
||||
binPath: ctx.binPath,
|
||||
cwd: ctx.cwd,
|
||||
model: ctx.model,
|
||||
env: ctx.env,
|
||||
permissionMode: ctx.permissionMode,
|
||||
toolIntegrationMode: ctx.toolIntegrationMode,
|
||||
resumeSessionId: ctx.resumeSessionId,
|
||||
historySeed: ctx.historySeed,
|
||||
injectedMcpServers: ctx.injectedMcpServers,
|
||||
emitter: ctx.emitter,
|
||||
signal: ctx.signal || ctx.abortController?.signal,
|
||||
});
|
||||
},
|
||||
async listModels(ctx) {
|
||||
const acpCatalog = await grokAcp.listGrokAcpModels({
|
||||
binPath: ctx.binPath,
|
||||
env: ctx.env,
|
||||
abortController: ctx.abortController,
|
||||
signal: ctx.signal || ctx.abortController?.signal,
|
||||
});
|
||||
if (acpCatalog.models.length > 0) {
|
||||
return acpCatalog;
|
||||
}
|
||||
const fallbackCatalog = await grok.listGrokModels({
|
||||
binPath: ctx.binPath,
|
||||
env: ctx.env,
|
||||
abortController: ctx.abortController,
|
||||
signal: ctx.signal || ctx.abortController?.signal,
|
||||
});
|
||||
const currentModelId = acpCatalog.currentModelId || fallbackCatalog.currentModelId;
|
||||
const models = fallbackCatalog.models.length > 0
|
||||
? fallbackCatalog.models
|
||||
: (currentModelId
|
||||
? [grok.applyGrokReasoningFallback({ id: currentModelId, name: currentModelId })]
|
||||
: []);
|
||||
return {
|
||||
currentModelId: grok.resolveGrokCatalogCurrentModelId(models, currentModelId),
|
||||
models,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
function getDriver(backend) {
|
||||
const driver = DRIVER_REGISTRY[backend];
|
||||
if (!driver) throw new Error(`No SDK driver registered for backend: ${backend}`);
|
||||
return driver;
|
||||
}
|
||||
|
||||
function listBackends() {
|
||||
return Object.keys(DRIVER_REGISTRY);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DRIVER_REGISTRY,
|
||||
getDriver,
|
||||
listBackends,
|
||||
hasCodebuddyQueryOnlyOptions,
|
||||
};
|
||||
76
electron/bridges/aiBridge/sdk/index.test.cjs
Normal file
76
electron/bridges/aiBridge/sdk/index.test.cjs
Normal file
@@ -0,0 +1,76 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
getDriver,
|
||||
listBackends,
|
||||
hasCodebuddyQueryOnlyOptions,
|
||||
} = require("./index.cjs");
|
||||
const { codebuddySessionManager } = require("./codebuddySessionManager.cjs");
|
||||
|
||||
test("registry exposes SDK backends", () => {
|
||||
assert.deepEqual(listBackends().sort(), ["claude", "codebuddy", "codex", "copilot", "cursor", "grok", "opencode"]);
|
||||
});
|
||||
|
||||
test("getDriver returns a driver with runTurn", () => {
|
||||
for (const key of ["claude", "codebuddy", "codex", "copilot", "cursor", "grok", "opencode"]) {
|
||||
const d = getDriver(key);
|
||||
assert.equal(typeof d.runTurn, "function", `${key} must expose runTurn`);
|
||||
}
|
||||
});
|
||||
|
||||
test("getDriver throws on unknown backend", () => {
|
||||
assert.throws(() => getDriver("gemini"), /No SDK driver registered for backend: gemini/);
|
||||
});
|
||||
|
||||
test("SDK drivers expose listModels; codex returns [] (no catalog)", async () => {
|
||||
for (const key of ["claude", "codebuddy", "codex", "copilot", "cursor", "grok", "opencode"]) {
|
||||
assert.equal(typeof getDriver(key).listModels, "function", `${key} must expose listModels`);
|
||||
}
|
||||
assert.deepEqual(await getDriver("codex").listModels({}), []);
|
||||
});
|
||||
|
||||
test("CodeBuddy keeps V2 for SessionOptions fields and falls back for query-only fields", () => {
|
||||
assert.equal(hasCodebuddyQueryOnlyOptions({
|
||||
agents: { reviewer: { description: "Reviews changes", prompt: "Review" } },
|
||||
thinking: { type: "adaptive" },
|
||||
effort: "high",
|
||||
}), false);
|
||||
assert.equal(hasCodebuddyQueryOnlyOptions({ maxBudgetUsd: 1 }), true);
|
||||
assert.equal(hasCodebuddyQueryOnlyOptions({ sandbox: { enabled: true } }), true);
|
||||
assert.equal(hasCodebuddyQueryOnlyOptions({ sandbox: { enabled: false } }), false);
|
||||
assert.equal(hasCodebuddyQueryOnlyOptions({ fallbackModel: "fallback" }), true);
|
||||
assert.equal(hasCodebuddyQueryOnlyOptions({ enableFileCheckpointing: false }), false);
|
||||
assert.equal(hasCodebuddyQueryOnlyOptions({ outputFormat: { type: "json_schema" } }), true);
|
||||
});
|
||||
|
||||
test("CodeBuddy forwards the explicit bypass opt-in to V2 sessions", async () => {
|
||||
const originalRunTurn = codebuddySessionManager.runTurn;
|
||||
let capturedSessionOptions;
|
||||
codebuddySessionManager.runTurn = async ({ sessionOptions }) => {
|
||||
capturedSessionOptions = sessionOptions;
|
||||
return { sessionId: "v2-session", usedV2: true };
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await getDriver("codebuddy").runTurn({
|
||||
chatSessionId: "chat-1",
|
||||
prompt: "hello",
|
||||
attachments: [],
|
||||
cwd: "/tmp",
|
||||
env: {},
|
||||
injectedMcpServers: [],
|
||||
permissionMode: "auto",
|
||||
toolIntegrationMode: "mcp",
|
||||
emitter: {},
|
||||
});
|
||||
|
||||
assert.deepEqual(capturedSessionOptions.extraArgs, {
|
||||
"dangerously-skip-permissions": null,
|
||||
});
|
||||
assert.equal(capturedSessionOptions.permissionMode, "bypassPermissions");
|
||||
assert.deepEqual(capturedSessionOptions.settingSources, []);
|
||||
assert.deepEqual(result, { sessionId: "v2-session", usedV2: true });
|
||||
} finally {
|
||||
codebuddySessionManager.runTurn = originalRunTurn;
|
||||
}
|
||||
});
|
||||
57
electron/bridges/aiBridge/sdk/injectMcp.cjs
Normal file
57
electron/bridges/aiBridge/sdk/injectMcp.cjs
Normal file
@@ -0,0 +1,57 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Build the netcatty-mcp-server config to inject into an SDK agent as an
|
||||
* EXTERNAL MCP server. Reuses mcpServerBridge.buildMcpServerConfig (unchanged)
|
||||
* so the approval/scope/blocklist layer is identical across integrations.
|
||||
*
|
||||
* Returns an array of netcatty MCP server configs (0 or 1 entry):
|
||||
* { name, type:'stdio', command, args, env:[{name,value}, ...] }
|
||||
* Each driver converts this neutral shape into its SDK's MCP format.
|
||||
*/
|
||||
async function buildInjectedMcpServers({
|
||||
mcpServerBridge,
|
||||
chatSessionId,
|
||||
toolIntegrationMode,
|
||||
}) {
|
||||
try {
|
||||
// Start the netcatty control host for BOTH modes. getOrCreateHost binds the
|
||||
// TCP server and writes the netcatty-tool-cli discovery file on bind:
|
||||
// - mcp mode: the host is injected below as an MCP server.
|
||||
// - skills mode: the agent reaches the host through that discovery file via
|
||||
// the netcatty CLI. Skipping this in skills mode left no host for the CLI
|
||||
// to find, so every `netcatty-tool-cli` call failed with APP_NOT_RUNNING.
|
||||
const mcpPort = await mcpServerBridge.getOrCreateHost();
|
||||
// Skills mode drives the netcatty CLI, not an injected MCP server.
|
||||
if (toolIntegrationMode !== "mcp") return [];
|
||||
const scopedIds = mcpServerBridge.getScopedSessionIds(chatSessionId);
|
||||
const netcattyMcpConfig = mcpServerBridge.buildMcpServerConfig(
|
||||
mcpPort,
|
||||
scopedIds,
|
||||
chatSessionId,
|
||||
);
|
||||
return [netcattyMcpConfig];
|
||||
} catch (err) {
|
||||
console.error("[sdk] Failed to ensure netcatty host / inject MCP server:", err?.message || err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the neutral env-pair array ([{name,value}]) used by
|
||||
* buildMcpServerConfig into a plain {KEY:VALUE} object, which is what the
|
||||
* claude/codex/copilot SDKs expect for an MCP server's env field.
|
||||
*/
|
||||
function mcpEnvPairsToObject(envPairs) {
|
||||
const out = {};
|
||||
if (Array.isArray(envPairs)) {
|
||||
for (const pair of envPairs) {
|
||||
if (pair && typeof pair.name === "string" && typeof pair.value === "string") {
|
||||
out[pair.name] = pair.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { buildInjectedMcpServers, mcpEnvPairsToObject };
|
||||
61
electron/bridges/aiBridge/sdk/injectMcp.test.cjs
Normal file
61
electron/bridges/aiBridge/sdk/injectMcp.test.cjs
Normal file
@@ -0,0 +1,61 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { buildInjectedMcpServers } = require("./injectMcp.cjs");
|
||||
|
||||
function fakeMcpBridge() {
|
||||
let hostStartCount = 0;
|
||||
return {
|
||||
get hostStartCount() { return hostStartCount; },
|
||||
getOrCreateHost: async () => {
|
||||
hostStartCount += 1;
|
||||
return 54321;
|
||||
},
|
||||
getScopedSessionIds: (chatId) => (chatId === "chat-1" ? ["s1", "s2"] : []),
|
||||
buildMcpServerConfig: (port, ids, chatId) => ({
|
||||
name: "netcatty-remote-hosts",
|
||||
type: "stdio",
|
||||
command: "/path/electron",
|
||||
args: ["/path/netcatty-mcp-server.cjs"],
|
||||
env: [
|
||||
{ name: "NETCATTY_MCP_PORT", value: String(port) },
|
||||
{ name: "NETCATTY_MCP_CHAT_SESSION_ID", value: chatId },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test("mcp mode returns netcatty MCP stdio config", async () => {
|
||||
const res = await buildInjectedMcpServers({
|
||||
mcpServerBridge: fakeMcpBridge(),
|
||||
chatSessionId: "chat-1",
|
||||
toolIntegrationMode: "mcp",
|
||||
});
|
||||
assert.equal(res.length, 1);
|
||||
assert.equal(res[0].name, "netcatty-remote-hosts");
|
||||
assert.equal(res[0].type, "stdio");
|
||||
assert.equal(res[0].command, "/path/electron");
|
||||
const portPair = res[0].env.find((p) => p.name === "NETCATTY_MCP_PORT");
|
||||
assert.equal(portPair.value, "54321");
|
||||
});
|
||||
|
||||
test("skills mode starts the CLI host and returns no injected MCP config", async () => {
|
||||
const bridge = fakeMcpBridge();
|
||||
const res = await buildInjectedMcpServers({
|
||||
mcpServerBridge: bridge,
|
||||
chatSessionId: "chat-1",
|
||||
toolIntegrationMode: "skills",
|
||||
});
|
||||
assert.deepEqual(res, []);
|
||||
assert.equal(bridge.hostStartCount, 1);
|
||||
});
|
||||
|
||||
test("getOrCreateHost failure degrades to empty, not throw", async () => {
|
||||
const bridge = fakeMcpBridge();
|
||||
bridge.getOrCreateHost = async () => { throw new Error("port boom"); };
|
||||
const res = await buildInjectedMcpServers({
|
||||
mcpServerBridge: bridge,
|
||||
chatSessionId: "chat-1",
|
||||
toolIntegrationMode: "mcp",
|
||||
});
|
||||
assert.deepEqual(res, []);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
function normalizeOpenCodePath(targetPath, platform = process.platform) {
|
||||
return platform === "win32"
|
||||
? targetPath.replace(/\\/g, "/")
|
||||
: targetPath;
|
||||
}
|
||||
|
||||
function appendOpenCodePathPattern(baseDir, suffix) {
|
||||
const trimmedSuffix = suffix.replace(/^\//, "");
|
||||
return baseDir.endsWith("/")
|
||||
? `${baseDir}${trimmedSuffix}`
|
||||
: `${baseDir}/${trimmedSuffix}`;
|
||||
}
|
||||
|
||||
function toOpenCodeDirectoryBase(dirPath, options = {}) {
|
||||
if (!dirPath || typeof dirPath !== "string") return null;
|
||||
const pathModule = options.pathModule || path;
|
||||
const platform = options.platform || process.platform;
|
||||
try {
|
||||
const resolved = pathModule.resolve(dirPath);
|
||||
let baseDir = resolved;
|
||||
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
|
||||
baseDir = pathModule.dirname(resolved);
|
||||
}
|
||||
return normalizeOpenCodePath(baseDir, platform);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toOpenCodeDirectoryGlob(dirPath, options = {}) {
|
||||
const baseDir = toOpenCodeDirectoryBase(dirPath, options);
|
||||
return baseDir ? appendOpenCodePathPattern(baseDir, "**") : null;
|
||||
}
|
||||
|
||||
function toOpenCodeDirectoryPermissionPatterns(dirPath, options = {}) {
|
||||
const baseDir = toOpenCodeDirectoryBase(dirPath, options);
|
||||
return baseDir
|
||||
? [
|
||||
baseDir,
|
||||
appendOpenCodePathPattern(baseDir, "*"),
|
||||
appendOpenCodePathPattern(baseDir, "**"),
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
function toOpenCodeFileParentGlob(filePath, options = {}) {
|
||||
if (!filePath || typeof filePath !== "string") return null;
|
||||
const pathModule = options.pathModule || path;
|
||||
try {
|
||||
return toOpenCodeDirectoryGlob(pathModule.dirname(pathModule.resolve(filePath)), options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toOpenCodeFileParentPermissionPatterns(filePath, options = {}) {
|
||||
if (!filePath || typeof filePath !== "string") return [];
|
||||
const pathModule = options.pathModule || path;
|
||||
try {
|
||||
return toOpenCodeDirectoryPermissionPatterns(pathModule.dirname(pathModule.resolve(filePath)), options);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function dedupePatterns(patterns) {
|
||||
return [...new Set(patterns.filter(Boolean))];
|
||||
}
|
||||
|
||||
// OpenCode discovers native agent skills from these well-known directories:
|
||||
// its global config dirs (~/.opencode and ~/.config/opencode, both "skill"
|
||||
// and "skills" spellings), Claude/agents-compatible dirs, project-level
|
||||
// .opencode/.claude/.agents dirs, and the remote-skill download cache.
|
||||
// Reads inside them must stay allowed even though Netcatty otherwise locks
|
||||
// external directory access down, or loading a skill's reference files fails
|
||||
// with an OpenCode permission error (issue #1939).
|
||||
const OPENCODE_NATIVE_SKILL_DIR_SUFFIXES = [
|
||||
".opencode/skill",
|
||||
".opencode/skills",
|
||||
".config/opencode/skill",
|
||||
".config/opencode/skills",
|
||||
".claude/skills",
|
||||
".agents/skills",
|
||||
".cache/opencode/skills",
|
||||
];
|
||||
|
||||
// OpenCode's `read` permission checks match worktree-relative paths (e.g.
|
||||
// "../../.opencode/skills/foo/references/doc.md") while `external_directory`
|
||||
// checks match absolute directory globs ("C:/Users/me/.opencode/skills/foo/*").
|
||||
// Anchoring each well-known suffix behind a leading wildcard covers both
|
||||
// forms on every platform (OpenCode normalizes "\\" to "/" before matching).
|
||||
function buildOpenCodeNativeSkillPermissionPatterns() {
|
||||
return OPENCODE_NATIVE_SKILL_DIR_SUFFIXES.flatMap((suffix) => [
|
||||
`*${suffix}`,
|
||||
`*${suffix}/*`,
|
||||
`*${suffix}/**`,
|
||||
]);
|
||||
}
|
||||
|
||||
// OpenCode's default rules gate `.env` secret files behind approval. The
|
||||
// broad skill-directory read allows above would win over those defaults
|
||||
// (last matching rule wins), so re-deny dot-env files inside skill dirs
|
||||
// after the allow entries to keep secret-file protection intact.
|
||||
function buildOpenCodeNativeSkillEnvDenyPatterns() {
|
||||
return OPENCODE_NATIVE_SKILL_DIR_SUFFIXES.flatMap((suffix) => [
|
||||
`*${suffix}/**.env`,
|
||||
`*${suffix}/**.env.*`,
|
||||
]);
|
||||
}
|
||||
|
||||
// Base rules shared by every tool-integration mode so OpenCode's native
|
||||
// skills keep working: allow loading skills and reading their files while
|
||||
// still denying all other external directory access.
|
||||
function buildOpenCodeNativeSkillsPermissionRules() {
|
||||
const external_directory = { "*": "deny" };
|
||||
const read = {};
|
||||
for (const pattern of buildOpenCodeNativeSkillPermissionPatterns()) {
|
||||
external_directory[pattern] = "allow";
|
||||
read[pattern] = "allow";
|
||||
}
|
||||
for (const pattern of buildOpenCodeNativeSkillEnvDenyPatterns()) {
|
||||
read[pattern] = "deny";
|
||||
}
|
||||
return {
|
||||
skill: "allow",
|
||||
read,
|
||||
external_directory,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNetcattySkillsOpenCodePathAllowlist({
|
||||
launcherPath,
|
||||
cliScriptPath,
|
||||
skillPath,
|
||||
discoveryFilePath,
|
||||
cliStateDir,
|
||||
runtimeBinaryPath,
|
||||
tempDir,
|
||||
extraFilePaths,
|
||||
} = {}, options = {}) {
|
||||
const filePaths = [
|
||||
launcherPath,
|
||||
cliScriptPath,
|
||||
skillPath,
|
||||
discoveryFilePath,
|
||||
runtimeBinaryPath,
|
||||
...(Array.isArray(extraFilePaths) ? extraFilePaths : []),
|
||||
];
|
||||
return dedupePatterns([
|
||||
...filePaths.flatMap((filePath) => toOpenCodeFileParentPermissionPatterns(filePath, options)),
|
||||
...(cliStateDir ? toOpenCodeDirectoryPermissionPatterns(cliStateDir, options) : []),
|
||||
...(tempDir ? toOpenCodeDirectoryPermissionPatterns(tempDir, options) : []),
|
||||
]);
|
||||
}
|
||||
|
||||
function buildOpenCodeSkillsPermissionRules(pathAllowlist = []) {
|
||||
const { read, external_directory } = buildOpenCodeNativeSkillsPermissionRules();
|
||||
for (const pattern of pathAllowlist) {
|
||||
external_directory[pattern] = "allow";
|
||||
read[pattern] = "allow";
|
||||
}
|
||||
|
||||
return {
|
||||
bash: "allow",
|
||||
read,
|
||||
list: "deny",
|
||||
glob: "deny",
|
||||
grep: "deny",
|
||||
skill: "allow",
|
||||
external_directory,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildNetcattySkillsOpenCodePathAllowlist,
|
||||
buildOpenCodeNativeSkillEnvDenyPatterns,
|
||||
buildOpenCodeNativeSkillPermissionPatterns,
|
||||
buildOpenCodeNativeSkillsPermissionRules,
|
||||
buildOpenCodeSkillsPermissionRules,
|
||||
toOpenCodeDirectoryPermissionPatterns,
|
||||
toOpenCodeDirectoryGlob,
|
||||
toOpenCodeFileParentPermissionPatterns,
|
||||
toOpenCodeFileParentGlob,
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
buildNetcattySkillsOpenCodePathAllowlist,
|
||||
buildOpenCodeNativeSkillEnvDenyPatterns,
|
||||
buildOpenCodeNativeSkillPermissionPatterns,
|
||||
buildOpenCodeNativeSkillsPermissionRules,
|
||||
buildOpenCodeSkillsPermissionRules,
|
||||
toOpenCodeDirectoryPermissionPatterns,
|
||||
toOpenCodeDirectoryGlob,
|
||||
toOpenCodeFileParentPermissionPatterns,
|
||||
toOpenCodeFileParentGlob,
|
||||
} = require("./netcattySkillsOpenCodePermissions.cjs");
|
||||
|
||||
// Mirrors OpenCode's Wildcard.match (packages/core/src/util/wildcard.ts):
|
||||
// inputs and patterns are normalized to forward slashes, "*" matches any
|
||||
// run of characters, and matching is anchored to the whole string.
|
||||
function openCodeWildcardMatch(input, pattern) {
|
||||
const normalized = input.replaceAll("\\", "/");
|
||||
const escaped = pattern
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
return new RegExp(`^${escaped}$`, "s").test(normalized);
|
||||
}
|
||||
|
||||
function matchesAnyPattern(input, patterns) {
|
||||
return patterns.some((pattern) => openCodeWildcardMatch(input, pattern));
|
||||
}
|
||||
|
||||
// Mirrors OpenCode's Permission.evaluate: rules come from Object.entries of
|
||||
// the config map in insertion order, and the last matching rule wins.
|
||||
function evaluateOpenCodeRuleMap(input, ruleMap) {
|
||||
let action;
|
||||
for (const [pattern, ruleAction] of Object.entries(ruleMap)) {
|
||||
if (openCodeWildcardMatch(input, pattern)) action = ruleAction;
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
test("toOpenCodeFileParentGlob maps files to parent directory globs", () => {
|
||||
assert.equal(
|
||||
toOpenCodeFileParentGlob("/Applications/Netcatty.app/Contents/MacOS/netcatty-tool-cli"),
|
||||
"/Applications/Netcatty.app/Contents/MacOS/**",
|
||||
);
|
||||
assert.equal(
|
||||
toOpenCodeFileParentGlob("/tmp/netcatty/skills/netcatty-tool-cli/SKILL.md"),
|
||||
"/tmp/netcatty/skills/netcatty-tool-cli/**",
|
||||
);
|
||||
});
|
||||
|
||||
test("toOpenCodeDirectoryGlob keeps directory roots stable when missing on disk", () => {
|
||||
assert.equal(
|
||||
toOpenCodeDirectoryGlob("/Users/me/Library/Application Support/netcatty/netcatty-tool-cli"),
|
||||
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**",
|
||||
);
|
||||
});
|
||||
|
||||
test("toOpenCodeDirectoryPermissionPatterns includes exact and wildcard forms", () => {
|
||||
assert.deepEqual(
|
||||
toOpenCodeDirectoryPermissionPatterns("/Users/me/Library/Application Support/netcatty/netcatty-tool-cli"),
|
||||
[
|
||||
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli",
|
||||
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/*",
|
||||
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("toOpenCodeFileParentPermissionPatterns normalizes Windows paths", () => {
|
||||
assert.deepEqual(
|
||||
toOpenCodeFileParentPermissionPatterns(
|
||||
"C:\\Users\\me\\AppData\\Local\\Programs\\Netcatty\\resources\\app.asar.unpacked\\electron\\cli\\netcatty-tool-cli.cmd",
|
||||
{ platform: "win32", pathModule: path.win32 },
|
||||
),
|
||||
[
|
||||
"C:/Users/me/AppData/Local/Programs/Netcatty/resources/app.asar.unpacked/electron/cli",
|
||||
"C:/Users/me/AppData/Local/Programs/Netcatty/resources/app.asar.unpacked/electron/cli/*",
|
||||
"C:/Users/me/AppData/Local/Programs/Netcatty/resources/app.asar.unpacked/electron/cli/**",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildNetcattySkillsOpenCodePathAllowlist dedupes launcher and script roots", () => {
|
||||
const launcher = "/Applications/Netcatty.app/Contents/MacOS/netcatty-tool-cli";
|
||||
const script = "/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/electron/cli/netcatty-tool-cli.cjs";
|
||||
const skill = "/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/skills/netcatty-tool-cli/SKILL.md";
|
||||
const patterns = buildNetcattySkillsOpenCodePathAllowlist({
|
||||
launcherPath: launcher,
|
||||
cliScriptPath: script,
|
||||
skillPath: skill,
|
||||
discoveryFilePath: "/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/discovery.json",
|
||||
cliStateDir: "/Users/me/Library/Application Support/netcatty/netcatty-tool-cli",
|
||||
});
|
||||
|
||||
assert.deepEqual(patterns, [
|
||||
"/Applications/Netcatty.app/Contents/MacOS",
|
||||
"/Applications/Netcatty.app/Contents/MacOS/*",
|
||||
"/Applications/Netcatty.app/Contents/MacOS/**",
|
||||
"/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/electron/cli",
|
||||
"/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/electron/cli/*",
|
||||
"/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/electron/cli/**",
|
||||
"/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/skills/netcatty-tool-cli",
|
||||
"/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/skills/netcatty-tool-cli/*",
|
||||
"/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/skills/netcatty-tool-cli/**",
|
||||
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli",
|
||||
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/*",
|
||||
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildNetcattySkillsOpenCodePathAllowlist includes temp dir and extra attachment paths", () => {
|
||||
const patterns = buildNetcattySkillsOpenCodePathAllowlist({
|
||||
discoveryFilePath: "/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/discovery.json",
|
||||
tempDir: "/var/folders/tmp/Netcatty",
|
||||
extraFilePaths: ["/var/folders/tmp/Netcatty/ai-attachment-1.png"],
|
||||
});
|
||||
|
||||
assert.deepEqual(patterns, [
|
||||
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli",
|
||||
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/*",
|
||||
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**",
|
||||
"/var/folders/tmp/Netcatty",
|
||||
"/var/folders/tmp/Netcatty/*",
|
||||
"/var/folders/tmp/Netcatty/**",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildNetcattySkillsOpenCodePathAllowlist includes OpenCode-compatible Windows directory resources", () => {
|
||||
const patterns = buildNetcattySkillsOpenCodePathAllowlist({
|
||||
launcherPath: "C:\\Users\\me\\AppData\\Local\\Programs\\Netcatty\\resources\\app.asar.unpacked\\electron\\cli\\netcatty-tool-cli.cmd",
|
||||
cliScriptPath: "C:\\Users\\me\\AppData\\Local\\Programs\\Netcatty\\resources\\app.asar.unpacked\\electron\\cli\\netcatty-tool-cli.cjs",
|
||||
skillPath: "C:\\Users\\me\\AppData\\Local\\Programs\\Netcatty\\resources\\app.asar.unpacked\\skills\\netcatty-tool-cli\\SKILL.md",
|
||||
discoveryFilePath: "C:\\Users\\me\\AppData\\Roaming\\netcatty\\netcatty-tool-cli\\discovery.json",
|
||||
runtimeBinaryPath: "C:\\Users\\me\\AppData\\Local\\Programs\\Netcatty\\Netcatty.exe",
|
||||
tempDir: "C:\\Users\\me\\AppData\\Local\\Temp\\Netcatty",
|
||||
extraFilePaths: ["C:\\Users\\me\\AppData\\Local\\Temp\\Netcatty\\attachment.png"],
|
||||
}, { platform: "win32", pathModule: path.win32 });
|
||||
|
||||
assert.equal(patterns.includes("C:/Users/me/AppData/Local/Programs/Netcatty/resources/app.asar.unpacked/electron/cli/*"), true);
|
||||
assert.equal(patterns.includes("C:/Users/me/AppData/Roaming/netcatty/netcatty-tool-cli/*"), true);
|
||||
assert.equal(patterns.includes("C:/Users/me/AppData/Local/Temp/Netcatty/*"), true);
|
||||
assert.equal(patterns.includes("C:/Users/me/AppData/Local/Programs/Netcatty/*"), true);
|
||||
});
|
||||
|
||||
test("buildOpenCodeSkillsPermissionRules allowlists Netcatty CLI paths and denies other external access", () => {
|
||||
const rules = buildOpenCodeSkillsPermissionRules([
|
||||
"/Applications/Netcatty.app/Contents/MacOS/**",
|
||||
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**",
|
||||
]);
|
||||
|
||||
assert.equal(rules.bash, "allow");
|
||||
assert.equal(rules.skill, "allow");
|
||||
assert.equal(rules.list, "deny");
|
||||
assert.equal(rules.external_directory["*"], "deny");
|
||||
assert.equal(rules.external_directory["/Applications/Netcatty.app/Contents/MacOS/**"], "allow");
|
||||
assert.equal(rules.external_directory["/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**"], "allow");
|
||||
assert.equal(rules.read["/Applications/Netcatty.app/Contents/MacOS/**"], "allow");
|
||||
assert.equal(rules.read["/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**"], "allow");
|
||||
assert.equal(rules.read["*"], undefined);
|
||||
// Allowlist entries must come after the catch-all deny so OpenCode's
|
||||
// last-matching-rule-wins evaluation keeps them effective.
|
||||
assert.equal(Object.keys(rules.external_directory)[0], "*");
|
||||
});
|
||||
|
||||
test("buildOpenCodeNativeSkillsPermissionRules keeps OpenCode native skill dirs readable", () => {
|
||||
const rules = buildOpenCodeNativeSkillsPermissionRules();
|
||||
assert.equal(rules.skill, "allow");
|
||||
assert.equal(rules.external_directory["*"], "deny");
|
||||
for (const pattern of buildOpenCodeNativeSkillPermissionPatterns()) {
|
||||
assert.equal(rules.external_directory[pattern], "allow");
|
||||
assert.equal(rules.read[pattern], "allow");
|
||||
}
|
||||
for (const pattern of buildOpenCodeNativeSkillEnvDenyPatterns()) {
|
||||
assert.equal(rules.read[pattern], "deny");
|
||||
}
|
||||
});
|
||||
|
||||
test("native skill read rules re-deny dot-env files inside skill dirs (last match wins)", () => {
|
||||
const { read } = buildOpenCodeNativeSkillsPermissionRules();
|
||||
|
||||
// Regular skill files stay allowed.
|
||||
assert.equal(evaluateOpenCodeRuleMap("../../.opencode/skills/foo/references/doc.md", read), "allow");
|
||||
assert.equal(evaluateOpenCodeRuleMap("C:/Users/me/.config/opencode/skills/foo/SKILL.md", read), "allow");
|
||||
|
||||
// Dot-env secret files under skill dirs must not be silently readable.
|
||||
assert.equal(evaluateOpenCodeRuleMap("../../.opencode/skills/foo/.env", read), "deny");
|
||||
assert.equal(evaluateOpenCodeRuleMap("C:/Users/me/.config/opencode/skills/foo/.env", read), "deny");
|
||||
assert.equal(evaluateOpenCodeRuleMap("/home/me/.claude/skills/foo/.env.local", read), "deny");
|
||||
assert.equal(evaluateOpenCodeRuleMap("..\\..\\.agents\\skills\\foo\\references\\prod.env", read), "deny");
|
||||
});
|
||||
|
||||
test("native skill patterns match OpenCode permission requests for skill files (issue #1939)", () => {
|
||||
const patterns = buildOpenCodeNativeSkillPermissionPatterns();
|
||||
|
||||
// external_directory asks with an absolute parent-directory glob
|
||||
// (forward slashes on Windows after FSUtil.normalizePathPattern).
|
||||
assert.equal(matchesAnyPattern("C:/Users/me/.opencode/skills/my-skill/references/*", patterns), true);
|
||||
assert.equal(matchesAnyPattern("/home/me/.config/opencode/skills/my-skill/*", patterns), true);
|
||||
assert.equal(matchesAnyPattern("/Users/me/.claude/skills/my-skill/references/*", patterns), true);
|
||||
assert.equal(matchesAnyPattern("/Users/me/.agents/skills/my-skill/*", patterns), true);
|
||||
assert.equal(matchesAnyPattern("/Users/me/.cache/opencode/skills/abc123/my-skill/*", patterns), true);
|
||||
|
||||
// read asks with a worktree-relative path (Windows backslashes included).
|
||||
assert.equal(matchesAnyPattern("..\\..\\.opencode\\skills\\my-skill\\references\\doc.md", patterns), true);
|
||||
assert.equal(matchesAnyPattern("../.config/opencode/skills/my-skill/SKILL.md", patterns), true);
|
||||
assert.equal(matchesAnyPattern(".opencode/skills/my-skill/references/doc.md", patterns), true);
|
||||
|
||||
// unrelated external paths stay denied
|
||||
assert.equal(matchesAnyPattern("C:/Users/me/Documents/secret.txt/*", patterns), false);
|
||||
assert.equal(matchesAnyPattern("../../etc/passwd", patterns), false);
|
||||
assert.equal(matchesAnyPattern("C:/Users/me/.ssh/id_rsa", patterns), false);
|
||||
});
|
||||
946
electron/bridges/aiBridge/sdk/opencodeDriver.cjs
Normal file
946
electron/bridges/aiBridge/sdk/opencodeDriver.cjs
Normal file
@@ -0,0 +1,946 @@
|
||||
"use strict";
|
||||
|
||||
const net = require("node:net");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
|
||||
const {
|
||||
buildOpenCodeNativeSkillsPermissionRules,
|
||||
buildOpenCodeSkillsPermissionRules,
|
||||
} = require("./netcattySkillsOpenCodePermissions.cjs");
|
||||
|
||||
const OPENCODE_IMAGE_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
||||
const DEFAULT_OPENCODE_PORT = 4096;
|
||||
|
||||
function resolveUsableOpenCodeBinPath(binPath, env) {
|
||||
const candidates = [];
|
||||
if (binPath) candidates.push(String(binPath));
|
||||
if (env?.OPENCODE_BIN) candidates.push(String(env.OPENCODE_BIN));
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isOpenCodeImageAttachment(attachment) {
|
||||
return Boolean(
|
||||
attachment &&
|
||||
OPENCODE_IMAGE_MEDIA_TYPES.has(String(attachment.mediaType || "").toLowerCase()) &&
|
||||
attachment.filePath,
|
||||
);
|
||||
}
|
||||
|
||||
function parseOpenCodeModel(model) {
|
||||
const raw = String(model || "").trim();
|
||||
const slash = raw.indexOf("/");
|
||||
if (slash <= 0 || slash === raw.length - 1) return undefined;
|
||||
return {
|
||||
providerID: raw.slice(0, slash),
|
||||
modelID: raw.slice(slash + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function toOpenCodeMcpConfig(injectedMcpServers) {
|
||||
const mcp = {};
|
||||
for (const cfg of injectedMcpServers || []) {
|
||||
if (!cfg || !cfg.name) continue;
|
||||
mcp[cfg.name] = {
|
||||
type: "local",
|
||||
command: [cfg.command, ...(cfg.args || [])],
|
||||
environment: mcpEnvPairsToObject(cfg.env),
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
return mcp;
|
||||
}
|
||||
|
||||
function buildOpenCodeConfig({ model, injectedMcpServers, toolIntegrationMode, skillsPathAllowlist } = {}) {
|
||||
const allowBash = toolIntegrationMode === "skills";
|
||||
const permission = {
|
||||
edit: "deny",
|
||||
bash: allowBash ? "allow" : "deny",
|
||||
webfetch: "deny",
|
||||
// Netcatty does not yet bridge OpenCode's question reply API to the UI.
|
||||
// Leaving it enabled creates a tool call that can never be completed.
|
||||
question: "deny",
|
||||
// Keep external access locked down, but let OpenCode's native skills
|
||||
// (e.g. ~/.opencode/skills, ~/.config/opencode/skills) read their own
|
||||
// reference files in every mode (issue #1939).
|
||||
...buildOpenCodeNativeSkillsPermissionRules(),
|
||||
};
|
||||
if (allowBash && Array.isArray(skillsPathAllowlist) && skillsPathAllowlist.length > 0) {
|
||||
Object.assign(permission, buildOpenCodeSkillsPermissionRules(skillsPathAllowlist));
|
||||
}
|
||||
const config = {
|
||||
share: "disabled",
|
||||
autoupdate: false,
|
||||
permission,
|
||||
mcp: toOpenCodeMcpConfig(injectedMcpServers),
|
||||
};
|
||||
if (model) config.model = model;
|
||||
return config;
|
||||
}
|
||||
|
||||
function buildOpenCodePromptParts(prompt, attachments) {
|
||||
const parts = [{ type: "text", text: String(prompt || "") }];
|
||||
for (const attachment of Array.isArray(attachments) ? attachments : []) {
|
||||
if (!isOpenCodeImageAttachment(attachment)) continue;
|
||||
parts.push({
|
||||
type: "file",
|
||||
mime: String(attachment.mediaType).toLowerCase(),
|
||||
filename: attachment.filename,
|
||||
url: pathToFileURL(attachment.filePath).href,
|
||||
});
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function extractOpenCodeErrorMessage(error) {
|
||||
if (!error) return "";
|
||||
if (typeof error === "string") return error;
|
||||
return String(
|
||||
error.data?.message ||
|
||||
error.message ||
|
||||
error.name ||
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
function getOpenCodeResultError(result) {
|
||||
if (!result || typeof result !== "object") return null;
|
||||
return result.error || null;
|
||||
}
|
||||
|
||||
function getOpenCodeEventPayload(event) {
|
||||
if (event?.payload && typeof event.payload === "object") return event.payload;
|
||||
if (event?.type && event?.properties) return event;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOpenCodeSessionIdFromEvent(event) {
|
||||
const properties = getOpenCodeEventPayload(event)?.properties;
|
||||
return properties?.sessionID
|
||||
|| properties?.sessionId
|
||||
|| properties?.part?.sessionID
|
||||
|| properties?.part?.sessionId
|
||||
|| properties?.info?.sessionID
|
||||
|| properties?.info?.sessionId
|
||||
|| properties?.info?.id
|
||||
|| null;
|
||||
}
|
||||
|
||||
function getOpenCodePartId(part) {
|
||||
return part?.id || part?.partID || part?.partId || null;
|
||||
}
|
||||
|
||||
function rememberOpenCodePartType(state, part) {
|
||||
const partId = getOpenCodePartId(part);
|
||||
if (!partId || !part?.type) return;
|
||||
state.partTypes = state.partTypes || new Map();
|
||||
state.partTypes.set(partId, part.type);
|
||||
}
|
||||
|
||||
function rememberOpenCodeMessageRole(state, info) {
|
||||
if (!info || typeof info !== "object") return;
|
||||
const messageId = info.id;
|
||||
const role = info.role;
|
||||
if (!messageId || !role) return;
|
||||
state.messageRoles = state.messageRoles || new Map();
|
||||
state.messageRoles.set(messageId, role);
|
||||
}
|
||||
|
||||
function getOpenCodeMessageId(source) {
|
||||
if (!source || typeof source !== "object") return null;
|
||||
return source.messageID
|
||||
|| source.messageId
|
||||
|| source.part?.messageID
|
||||
|| source.part?.messageId
|
||||
|| null;
|
||||
}
|
||||
|
||||
function shouldEmitOpenCodeAssistantPart(state, source) {
|
||||
const messageId = getOpenCodeMessageId(source);
|
||||
if (!messageId) return true;
|
||||
const role = state.messageRoles?.get(messageId);
|
||||
if (!role) return true;
|
||||
return role === "assistant";
|
||||
}
|
||||
|
||||
function forgetOpenCodeMessageRole(state, messageId) {
|
||||
if (!messageId) return;
|
||||
state.messageRoles?.delete(messageId);
|
||||
}
|
||||
|
||||
function getOpenCodeDeltaKind(properties, state) {
|
||||
const partId = properties?.partID || properties?.partId || null;
|
||||
const knownType = partId && state.partTypes?.get(partId);
|
||||
if (knownType === "reasoning" || knownType === "text") return knownType;
|
||||
const field = String(properties?.field || "").toLowerCase();
|
||||
if (field.includes("reason") || field.includes("thinking")) return "reasoning";
|
||||
if (field === "text" || field === "content" || field.endsWith(".text") || field.endsWith(".content")) return "text";
|
||||
return null;
|
||||
}
|
||||
|
||||
function emitOpenCodePartChunk({ emitter, state, partId, kind, text, isDelta }) {
|
||||
if (typeof text !== "string" || text.length === 0) return false;
|
||||
let chunk = text;
|
||||
if (partId) {
|
||||
state.partOffsets = state.partOffsets || new Map();
|
||||
const emittedLength = state.partOffsets.get(partId) || 0;
|
||||
if (isDelta) {
|
||||
state.partOffsets.set(partId, emittedLength + text.length);
|
||||
} else {
|
||||
chunk = text.slice(emittedLength);
|
||||
state.partOffsets.set(partId, Math.max(emittedLength, text.length));
|
||||
}
|
||||
}
|
||||
if (!chunk) return false;
|
||||
if (kind === "reasoning") {
|
||||
emitter.reasoning(chunk);
|
||||
state.reasoningOpen = true;
|
||||
} else {
|
||||
emitter.text(chunk);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function translateOpenCodeEvent(event, emitter, state = {}) {
|
||||
const payload = getOpenCodeEventPayload(event);
|
||||
if (!payload || typeof payload !== "object") return { idle: false, error: false, content: false };
|
||||
|
||||
if (payload.type === "message.updated") {
|
||||
rememberOpenCodeMessageRole(state, payload.properties?.info);
|
||||
return { idle: false, error: false, content: false };
|
||||
}
|
||||
|
||||
if (payload.type === "message.removed") {
|
||||
forgetOpenCodeMessageRole(state, payload.properties?.messageID || payload.properties?.messageId);
|
||||
return { idle: false, error: false, content: false };
|
||||
}
|
||||
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties?.part;
|
||||
if (!part || typeof part !== "object") return { idle: false, error: false, content: false };
|
||||
if (!shouldEmitOpenCodeAssistantPart(state, part)) {
|
||||
return { idle: false, error: false, content: false };
|
||||
}
|
||||
rememberOpenCodePartType(state, part);
|
||||
|
||||
if (part.type === "text") {
|
||||
const delta = payload.properties?.delta;
|
||||
if (emitOpenCodePartChunk({
|
||||
emitter,
|
||||
state,
|
||||
partId: getOpenCodePartId(part),
|
||||
kind: "text",
|
||||
text: typeof delta === "string" ? delta : part.text,
|
||||
isDelta: typeof delta === "string",
|
||||
})) {
|
||||
return { idle: false, error: false, content: true };
|
||||
}
|
||||
return { idle: false, error: false, content: false };
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
const delta = payload.properties?.delta;
|
||||
if (emitOpenCodePartChunk({
|
||||
emitter,
|
||||
state,
|
||||
partId: getOpenCodePartId(part),
|
||||
kind: "reasoning",
|
||||
text: typeof delta === "string" ? delta : part.text,
|
||||
isDelta: typeof delta === "string",
|
||||
})) {
|
||||
return { idle: false, error: false, content: true };
|
||||
}
|
||||
return { idle: false, error: false, content: false };
|
||||
}
|
||||
|
||||
if (part.type === "tool") {
|
||||
if (state.reasoningOpen) {
|
||||
emitter.reasoningEnd?.();
|
||||
state.reasoningOpen = false;
|
||||
}
|
||||
const callId = part.callID || part.id || "";
|
||||
const toolName = part.tool || "tool";
|
||||
const input = part.state?.input || {};
|
||||
if (part.state?.status === "running" || part.state?.status === "pending") {
|
||||
state.toolCalls = state.toolCalls || new Set();
|
||||
if (!state.toolCalls.has(callId)) {
|
||||
state.toolCalls.add(callId);
|
||||
emitter.toolCall(toolName, input, callId);
|
||||
}
|
||||
} else if (part.state?.status === "completed") {
|
||||
state.toolCalls = state.toolCalls || new Set();
|
||||
if (!state.toolCalls.has(callId)) {
|
||||
state.toolCalls.add(callId);
|
||||
emitter.toolCall(toolName, input, callId);
|
||||
}
|
||||
state.toolResults = state.toolResults || new Set();
|
||||
if (!state.toolResults.has(callId)) {
|
||||
state.toolResults.add(callId);
|
||||
emitter.toolResult(callId, part.state.output || "", toolName);
|
||||
}
|
||||
} else if (part.state?.status === "error") {
|
||||
// Tool-level failures must not abort the whole OpenCode turn. Other
|
||||
// drivers (Cursor / Codex / Grok) surface tool errors as tool results
|
||||
// so the model can adapt and continue multi-step work (issue #2718).
|
||||
state.toolCalls = state.toolCalls || new Set();
|
||||
if (!state.toolCalls.has(callId)) {
|
||||
state.toolCalls.add(callId);
|
||||
emitter.toolCall(toolName, input, callId);
|
||||
}
|
||||
state.toolResults = state.toolResults || new Set();
|
||||
if (!state.toolResults.has(callId)) {
|
||||
state.toolResults.add(callId);
|
||||
// Prefer non-empty error, then output, then a stable default (blank
|
||||
// string error must not hide a useful output payload).
|
||||
const rawError = part.state.error || part.state.output || "OpenCode tool failed";
|
||||
const errorText = typeof rawError === "string"
|
||||
? rawError
|
||||
: (extractOpenCodeErrorMessage(rawError) || "OpenCode tool failed");
|
||||
emitter.toolResult(callId, errorText, toolName);
|
||||
}
|
||||
return { idle: false, error: false, content: true };
|
||||
}
|
||||
}
|
||||
return { idle: false, error: false, content: part.type === "tool" };
|
||||
}
|
||||
|
||||
if (payload.type === "message.part.delta") {
|
||||
const properties = payload.properties || {};
|
||||
if (!shouldEmitOpenCodeAssistantPart(state, properties)) {
|
||||
return { idle: false, error: false, content: false };
|
||||
}
|
||||
const delta = typeof properties.delta === "string" ? properties.delta : "";
|
||||
const kind = getOpenCodeDeltaKind(properties, state);
|
||||
if (!delta || !kind) return { idle: false, error: false, content: false };
|
||||
if (emitOpenCodePartChunk({
|
||||
emitter,
|
||||
state,
|
||||
partId: properties.partID || properties.partId || null,
|
||||
kind,
|
||||
text: delta,
|
||||
isDelta: true,
|
||||
})) {
|
||||
return { idle: false, error: false, content: true };
|
||||
}
|
||||
return { idle: false, error: false, content: false };
|
||||
}
|
||||
|
||||
if (payload.type === "session.error") {
|
||||
emitter.emitError(extractOpenCodeErrorMessage(payload.properties?.error) || "OpenCode session failed");
|
||||
return { idle: false, error: true, content: false };
|
||||
}
|
||||
|
||||
if (payload.type === "session.idle") {
|
||||
if (state.reasoningOpen) {
|
||||
emitter.reasoningEnd?.();
|
||||
state.reasoningOpen = false;
|
||||
}
|
||||
emitter.status("OpenCode session idle");
|
||||
return { idle: true, error: false, content: false };
|
||||
}
|
||||
|
||||
if (payload.type === "session.status" && payload.properties?.status?.type) {
|
||||
emitter.status(`OpenCode session ${payload.properties.status.type}`);
|
||||
}
|
||||
|
||||
return { idle: false, error: false, content: false };
|
||||
}
|
||||
|
||||
function classifyOpenCodeSpawnError(error) {
|
||||
const code = error && error.code;
|
||||
const msg = String((error && error.message) || error || "");
|
||||
return {
|
||||
isSpawnEnoent: code === "ENOENT" || /ENOENT/i.test(msg) || /not found/i.test(msg),
|
||||
message: msg,
|
||||
};
|
||||
}
|
||||
|
||||
function shellQuotePosix(value) {
|
||||
return `"${String(value).replace(/(["\\$`])/g, "\\$1")}"`;
|
||||
}
|
||||
|
||||
function createOpenCodeShim(binPath, options = {}) {
|
||||
if (!binPath) return null;
|
||||
const platform = options.platform || process.platform;
|
||||
const tempDirBridge = options.tempDirBridge || require("../../tempDirBridge.cjs");
|
||||
const getTempFilePath = options.getTempFilePath || tempDirBridge.getTempFilePath;
|
||||
const shimParent = getTempFilePath("opencode-sdk-shim");
|
||||
const uniqueId = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const shimRoot = path.join(shimParent, uniqueId);
|
||||
fs.mkdirSync(shimRoot, { recursive: true });
|
||||
const shimName = platform === "win32" ? "opencode.cmd" : "opencode";
|
||||
const shimPath = path.join(shimRoot, shimName);
|
||||
if (platform === "win32") {
|
||||
fs.writeFileSync(shimPath, `@echo off\r\n"${binPath}" %*\r\n`);
|
||||
} else {
|
||||
fs.writeFileSync(shimPath, `#!/bin/sh\nexec ${shellQuotePosix(binPath)} "$@"\n`);
|
||||
fs.chmodSync(shimPath, 0o755);
|
||||
}
|
||||
return {
|
||||
dir: shimRoot,
|
||||
path: shimPath,
|
||||
cleanup() {
|
||||
try { fs.rmSync(shimRoot, { recursive: true, force: true }); } catch {}
|
||||
try { fs.rmdirSync(shimParent); } catch {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createOpenCodeProcessEnv(env, binPath, options = {}) {
|
||||
const next = { ...(env || {}) };
|
||||
let shim = null;
|
||||
const explicitBinPath = binPath ? resolveUsableOpenCodeBinPath(binPath, null) : undefined;
|
||||
const envBinPath = explicitBinPath ? undefined : resolveUsableOpenCodeBinPath(null, next);
|
||||
|
||||
if (explicitBinPath) {
|
||||
shim = createOpenCodeShim(explicitBinPath, options);
|
||||
next.OPENCODE_BIN = explicitBinPath;
|
||||
next.PATH = [shim?.dir || path.dirname(explicitBinPath), next.PATH || process.env.PATH || ""]
|
||||
.filter(Boolean)
|
||||
.join(path.delimiter);
|
||||
} else if (envBinPath) {
|
||||
next.OPENCODE_BIN = envBinPath;
|
||||
} else if (binPath || next.OPENCODE_BIN) {
|
||||
delete next.OPENCODE_BIN;
|
||||
}
|
||||
|
||||
return {
|
||||
env: next,
|
||||
cleanup() {
|
||||
shim?.cleanup?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withOpenCodeProcessEnv(env, binPath, fn) {
|
||||
const previous = {};
|
||||
const { env: next, cleanup } = createOpenCodeProcessEnv(env, binPath);
|
||||
const restore = () => {
|
||||
for (const key of Object.keys(next)) {
|
||||
if (previous[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = previous[key];
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
previous[key] = process.env[key];
|
||||
process.env[key] = String(value);
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
|
||||
function getAvailablePort(host = "127.0.0.1") {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, host, () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(port === DEFAULT_OPENCODE_PORT ? getAvailablePort(host) : port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function withOpenCodeServerPort(options = {}) {
|
||||
if (options.port != null) return options;
|
||||
return { ...options, port: await getAvailablePort(options.hostname || "127.0.0.1") };
|
||||
}
|
||||
|
||||
function closeOpenCodeInstance(opencode) {
|
||||
try { opencode?.server?.close?.(); } catch {}
|
||||
}
|
||||
|
||||
async function createDefaultOpenCode(options, env, binPath) {
|
||||
let sdk;
|
||||
try { sdk = await import("@opencode-ai/sdk"); } catch {
|
||||
throw new Error("OpenCode SDK not installed. Run: npm install @opencode-ai/sdk");
|
||||
}
|
||||
|
||||
const { env: nextEnv, cleanup: cleanupShim } = createOpenCodeProcessEnv(env, binPath);
|
||||
const previous = {};
|
||||
for (const [key, value] of Object.entries(nextEnv)) {
|
||||
previous[key] = process.env[key];
|
||||
process.env[key] = String(value);
|
||||
}
|
||||
|
||||
// Restore the Electron main-process environment as soon as the child has been
|
||||
// spawned. Keeping PATH/OPENCODE_BIN pointed at a temporary shim for the
|
||||
// server lifetime (or list-models idle window) can leak into later turns and
|
||||
// other spawns; see #2184 review. The on-disk shim stays until close() so a
|
||||
// still-running child that re-resolves helpers does not race a deleted path.
|
||||
const restoreProcessEnv = () => {
|
||||
if (restoreProcessEnv.done) return;
|
||||
restoreProcessEnv.done = true;
|
||||
for (const key of Object.keys(nextEnv)) {
|
||||
if (previous[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = previous[key];
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (cleanup.done) return;
|
||||
cleanup.done = true;
|
||||
restoreProcessEnv();
|
||||
cleanupShim();
|
||||
};
|
||||
|
||||
try {
|
||||
const opencode = await sdk.createOpencode(options);
|
||||
restoreProcessEnv();
|
||||
const originalClose = opencode.server?.close?.bind(opencode.server);
|
||||
if (typeof originalClose === "function") {
|
||||
opencode.server.close = () => {
|
||||
try { originalClose(); } catch {}
|
||||
cleanup();
|
||||
};
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
return opencode;
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function createAbortWait(signal) {
|
||||
if (!signal) return { promise: new Promise(() => {}), dispose() {} };
|
||||
if (signal.aborted) return { promise: Promise.resolve(), dispose() {} };
|
||||
let resolveAbort;
|
||||
const promise = new Promise((resolve) => { resolveAbort = resolve; });
|
||||
const onAbort = () => resolveAbort();
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
return {
|
||||
promise,
|
||||
dispose() {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createStopWait() {
|
||||
let stopped = false;
|
||||
let resolveStop;
|
||||
const promise = new Promise((resolve) => { resolveStop = resolve; });
|
||||
return {
|
||||
promise,
|
||||
get stopped() { return stopped; },
|
||||
stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
resolveStop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runOpenCodeTurn({
|
||||
prompt, systemPrompt, attachments, cwd, model, injectedMcpServers, toolIntegrationMode,
|
||||
skillsPathAllowlist, resumeSessionId, env, binPath, emitter, abortController, openCodeFactory,
|
||||
}) {
|
||||
const config = buildOpenCodeConfig({ model, injectedMcpServers, toolIntegrationMode, skillsPathAllowlist });
|
||||
let opencode = null;
|
||||
let sessionId = resumeSessionId || null;
|
||||
let hasContent = false;
|
||||
let failed = false;
|
||||
let abortSent = false;
|
||||
let removeAbortListener = null;
|
||||
const state = { reasoningOpen: false };
|
||||
const directoryQuery = cwd ? { directory: cwd } : undefined;
|
||||
|
||||
try {
|
||||
const factory = openCodeFactory || ((options) => createDefaultOpenCode(options, env, binPath));
|
||||
opencode = await factory(await withOpenCodeServerPort({ config, signal: abortController?.signal }));
|
||||
const { client } = opencode;
|
||||
const abortOpenCode = async () => {
|
||||
if (abortSent) return;
|
||||
abortSent = true;
|
||||
if (sessionId) {
|
||||
try { await client.session.abort({ path: { id: sessionId }, query: directoryQuery }); } catch {}
|
||||
}
|
||||
try { opencode?.server?.close?.(); } catch {}
|
||||
};
|
||||
if (abortController?.signal) {
|
||||
const onAbort = () => { void abortOpenCode(); };
|
||||
abortController.signal.addEventListener("abort", onAbort, { once: true });
|
||||
removeAbortListener = () => abortController.signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
const events = await client.global.event({ signal: abortController?.signal });
|
||||
|
||||
if (!sessionId) {
|
||||
const created = await client.session.create({
|
||||
body: { title: "Netcatty OpenCode" },
|
||||
query: directoryQuery,
|
||||
});
|
||||
sessionId = created?.data?.id || created?.id || null;
|
||||
}
|
||||
if (!sessionId) throw new Error("OpenCode did not create a session");
|
||||
emitter.sessionId(sessionId);
|
||||
|
||||
const stopEventLoopWait = createStopWait();
|
||||
const eventLoop = (async () => {
|
||||
const iterator = events.stream?.[Symbol.asyncIterator]?.();
|
||||
if (!iterator) return;
|
||||
const abortWait = createAbortWait(abortController?.signal);
|
||||
try {
|
||||
while (true) {
|
||||
const nextEvent = iterator.next();
|
||||
const raced = await Promise.race([
|
||||
nextEvent.then(
|
||||
(value) => ({ type: "event", value }),
|
||||
(error) => ({ type: "error", error }),
|
||||
),
|
||||
abortWait.promise.then(() => ({ type: "abort" })),
|
||||
stopEventLoopWait.promise.then(() => ({ type: "stop" })),
|
||||
]);
|
||||
if (raced.type === "abort") break;
|
||||
if (raced.type === "stop") break;
|
||||
if (raced.type === "error") throw raced.error;
|
||||
const { value: event, done } = raced.value;
|
||||
if (done) break;
|
||||
if (abortController?.signal?.aborted) break;
|
||||
const eventSessionId = getOpenCodeSessionIdFromEvent(event);
|
||||
if (eventSessionId && eventSessionId !== sessionId) continue;
|
||||
const result = translateOpenCodeEvent(event, emitter, state);
|
||||
if (result.content) hasContent = true;
|
||||
if (result.error) {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
if (result.idle) break;
|
||||
}
|
||||
} finally {
|
||||
abortWait.dispose();
|
||||
if (abortController?.signal?.aborted || stopEventLoopWait.stopped) {
|
||||
try { void iterator.return?.(); } catch {}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const body = {
|
||||
parts: buildOpenCodePromptParts(prompt, attachments),
|
||||
};
|
||||
if (systemPrompt) body.system = String(systemPrompt);
|
||||
const parsedModel = parseOpenCodeModel(model);
|
||||
if (parsedModel) body.model = parsedModel;
|
||||
|
||||
const promptAbortWait = createAbortWait(abortController?.signal);
|
||||
const promptResult = await Promise.race([
|
||||
client.session.promptAsync({
|
||||
path: { id: sessionId },
|
||||
query: directoryQuery,
|
||||
body,
|
||||
signal: abortController?.signal,
|
||||
throwOnError: true,
|
||||
}).then(
|
||||
(result) => {
|
||||
const error = getOpenCodeResultError(result);
|
||||
return error ? { type: "error", error } : { type: "prompt" };
|
||||
},
|
||||
(error) => ({ type: "error", error }),
|
||||
),
|
||||
promptAbortWait.promise.then(() => ({ type: "abort" })),
|
||||
]);
|
||||
promptAbortWait.dispose();
|
||||
if (promptResult.type === "error") {
|
||||
failed = true;
|
||||
await abortOpenCode();
|
||||
stopEventLoopWait.stop();
|
||||
await eventLoop.catch(() => {});
|
||||
throw promptResult.error;
|
||||
}
|
||||
|
||||
if (promptResult.type === "abort") {
|
||||
await abortOpenCode();
|
||||
} else {
|
||||
await eventLoop;
|
||||
}
|
||||
|
||||
if (abortController?.signal?.aborted) {
|
||||
await abortOpenCode();
|
||||
}
|
||||
|
||||
if (!hasContent && !failed && !abortController?.signal?.aborted) {
|
||||
emitter.emitError("OpenCode returned an empty response. Run `opencode` in a terminal to configure authentication and models.");
|
||||
return { sessionId };
|
||||
}
|
||||
if (!failed && !abortController?.signal?.aborted) emitter.emitDone();
|
||||
return { sessionId };
|
||||
} catch (error) {
|
||||
const classified = classifyOpenCodeSpawnError(error);
|
||||
if (classified.isSpawnEnoent) {
|
||||
emitter.emitError("OpenCode CLI not found or not runnable. Install OpenCode and ensure `opencode` is on PATH, or set a custom path in Settings.");
|
||||
} else {
|
||||
emitter.emitError(extractOpenCodeErrorMessage(error) || classified.message || "OpenCode turn failed");
|
||||
}
|
||||
return { sessionId };
|
||||
} finally {
|
||||
removeAbortListener?.();
|
||||
closeOpenCodeInstance(opencode);
|
||||
}
|
||||
}
|
||||
|
||||
function mapOpenCodeModels(response) {
|
||||
const providers = Array.isArray(response?.providers) ? response.providers : [];
|
||||
const models = [];
|
||||
for (const provider of providers) {
|
||||
const providerId = provider?.id || provider?.providerID;
|
||||
if (!providerId || !provider?.models || typeof provider.models !== "object") continue;
|
||||
for (const [modelId, info] of Object.entries(provider.models)) {
|
||||
models.push({
|
||||
id: `${providerId}/${modelId}`,
|
||||
name: `${provider.name || providerId} ${info?.name || modelId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function getOpenCodeDefaultModelId(response) {
|
||||
const value = response?.default;
|
||||
if (!value) return null;
|
||||
if (typeof value === "string") return value.includes("/") ? value : null;
|
||||
if (typeof value !== "object") return null;
|
||||
if (typeof value.model === "string" && value.model.includes("/")) return value.model;
|
||||
if (typeof value.providerID === "string" && typeof value.modelID === "string") {
|
||||
return `${value.providerID}/${value.modelID}`;
|
||||
}
|
||||
if (typeof value.provider === "string" && typeof value.model === "string") {
|
||||
return `${value.provider}/${value.model}`;
|
||||
}
|
||||
for (const [providerId, modelId] of Object.entries(value)) {
|
||||
if (typeof modelId === "string" && providerId && modelId) {
|
||||
return modelId.includes("/") ? modelId : `${providerId}/${modelId}`;
|
||||
}
|
||||
if (modelId && typeof modelId === "object" && typeof modelId.modelID === "string") {
|
||||
const nestedProvider = typeof modelId.providerID === "string" ? modelId.providerID : providerId;
|
||||
return `${nestedProvider}/${modelId.modelID}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function emptyOpenCodeModelCatalog() {
|
||||
return { currentModelId: null, models: [] };
|
||||
}
|
||||
|
||||
function abortError(signal) {
|
||||
return signal?.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error(String(signal?.reason || "aborted"));
|
||||
}
|
||||
|
||||
function whenAborted(signal) {
|
||||
if (!signal) return new Promise(() => {});
|
||||
if (signal.aborted) return Promise.reject(abortError(signal));
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener("abort", () => reject(abortError(signal)), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Env vars that can change which OpenCode config / provider catalog is visible.
|
||||
const OPENCODE_CATALOG_ENV_KEYS = [
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"XDG_CONFIG_HOME",
|
||||
"OPENCODE_BIN",
|
||||
"OPENCODE_CONFIG",
|
||||
"OPENCODE_CONFIG_DIR",
|
||||
"OPENCODE_CONFIG_CONTENT",
|
||||
];
|
||||
|
||||
function buildOpenCodeCatalogEnvFingerprint(env) {
|
||||
return OPENCODE_CATALOG_ENV_KEYS
|
||||
.map((key) => `${key}=${env?.[key] == null ? "" : String(env[key])}`)
|
||||
.join("\u0000");
|
||||
}
|
||||
|
||||
function buildOpenCodeListServerKey(binPath, env) {
|
||||
const resolvedBin = String(
|
||||
resolveUsableOpenCodeBinPath(binPath, env)
|
||||
|| binPath
|
||||
|| env?.OPENCODE_BIN
|
||||
|| "default",
|
||||
);
|
||||
// Same binary + different HOME/XDG/OpenCode config must not share a catalog
|
||||
// server or cache entry (multi-agent / multi-profile setups).
|
||||
return `${resolvedBin}\u0000${buildOpenCodeCatalogEnvFingerprint(env)}`;
|
||||
}
|
||||
|
||||
// Shared list-models servers: coalesce concurrent catalog loads for the same
|
||||
// binary, then tear down after a short idle so idle Netcatty does not keep
|
||||
// opencode processes around (issue #2184).
|
||||
const OPENCODE_LIST_SERVER_IDLE_MS = 1500;
|
||||
const openCodeListServers = new Map();
|
||||
|
||||
function clearOpenCodeListServerIdle(entry) {
|
||||
if (!entry?.idleTimer) return;
|
||||
clearTimeout(entry.idleTimer);
|
||||
entry.idleTimer = null;
|
||||
}
|
||||
|
||||
function disposeOpenCodeListServer(key, entry) {
|
||||
const current = openCodeListServers.get(key);
|
||||
if (current && current !== entry) return;
|
||||
openCodeListServers.delete(key);
|
||||
clearOpenCodeListServerIdle(entry);
|
||||
try { entry?.createAbort?.abort?.(); } catch {}
|
||||
closeOpenCodeInstance(entry?.opencode);
|
||||
entry.opencode = null;
|
||||
}
|
||||
|
||||
function releaseOpenCodeListServer(key) {
|
||||
const entry = openCodeListServers.get(key);
|
||||
if (!entry) return;
|
||||
entry.refs = Math.max(0, (entry.refs || 0) - 1);
|
||||
if (entry.refs > 0) return;
|
||||
|
||||
// Create still in flight with no waiters: abort so the SDK kills the child.
|
||||
if (!entry.opencode && entry.createAbort && !entry.createAbort.signal.aborted) {
|
||||
try { entry.createAbort.abort(); } catch {}
|
||||
disposeOpenCodeListServer(key, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
clearOpenCodeListServerIdle(entry);
|
||||
entry.idleTimer = setTimeout(() => {
|
||||
const current = openCodeListServers.get(key);
|
||||
if (!current || current !== entry || current.refs > 0) return;
|
||||
disposeOpenCodeListServer(key, entry);
|
||||
}, OPENCODE_LIST_SERVER_IDLE_MS);
|
||||
if (typeof entry.idleTimer.unref === "function") entry.idleTimer.unref();
|
||||
}
|
||||
|
||||
async function acquireOpenCodeListServer({ env, binPath, openCodeFactory, signal } = {}) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
|
||||
const key = buildOpenCodeListServerKey(binPath, env);
|
||||
let entry = openCodeListServers.get(key);
|
||||
|
||||
if (entry) {
|
||||
clearOpenCodeListServerIdle(entry);
|
||||
} else {
|
||||
const createAbort = new AbortController();
|
||||
entry = {
|
||||
key,
|
||||
refs: 0,
|
||||
opencode: null,
|
||||
ready: null,
|
||||
idleTimer: null,
|
||||
createAbort,
|
||||
};
|
||||
const factory = openCodeFactory || ((options) => createDefaultOpenCode(options, env, binPath));
|
||||
entry.ready = (async () => {
|
||||
const options = await withOpenCodeServerPort({
|
||||
config: { autoupdate: false },
|
||||
timeout: 10000,
|
||||
signal: createAbort.signal,
|
||||
});
|
||||
const opencode = await factory(options);
|
||||
// If the last waiter cancelled while create was finishing, kill immediately
|
||||
// so the process cannot leak outside the pool map.
|
||||
if (createAbort.signal.aborted) {
|
||||
closeOpenCodeInstance(opencode);
|
||||
throw abortError(createAbort.signal);
|
||||
}
|
||||
entry.opencode = opencode;
|
||||
return opencode;
|
||||
})().catch((error) => {
|
||||
// Drop a failed create immediately so the next list-models can retry.
|
||||
disposeOpenCodeListServer(key, entry);
|
||||
throw error;
|
||||
});
|
||||
openCodeListServers.set(key, entry);
|
||||
}
|
||||
|
||||
entry.refs += 1;
|
||||
try {
|
||||
const opencode = await Promise.race([
|
||||
entry.ready,
|
||||
whenAborted(signal),
|
||||
]);
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
return { key, opencode };
|
||||
} catch (error) {
|
||||
entry.refs = Math.max(0, entry.refs - 1);
|
||||
if (entry.refs <= 0) {
|
||||
// Last waiter left before ready: abort spawn so the SDK child is killed.
|
||||
try { entry.createAbort?.abort?.(); } catch {}
|
||||
disposeOpenCodeListServer(key, entry);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function resetOpenCodeListServerPool() {
|
||||
for (const [key, entry] of openCodeListServers.entries()) {
|
||||
disposeOpenCodeListServer(key, entry);
|
||||
}
|
||||
openCodeListServers.clear();
|
||||
}
|
||||
|
||||
async function listOpenCodeModels({ env, binPath, openCodeFactory, abortController, signal } = {}) {
|
||||
const effectiveSignal = signal || abortController?.signal;
|
||||
let acquired = null;
|
||||
try {
|
||||
if (effectiveSignal?.aborted) return emptyOpenCodeModelCatalog();
|
||||
acquired = await acquireOpenCodeListServer({
|
||||
env,
|
||||
binPath,
|
||||
openCodeFactory,
|
||||
signal: effectiveSignal,
|
||||
});
|
||||
if (effectiveSignal?.aborted) return emptyOpenCodeModelCatalog();
|
||||
const response = await Promise.race([
|
||||
acquired.opencode.client.config.providers(),
|
||||
whenAborted(effectiveSignal),
|
||||
]);
|
||||
if (response?.error) {
|
||||
throw new Error(extractOpenCodeErrorMessage(response.error) || "OpenCode providers unavailable");
|
||||
}
|
||||
const data = response?.data || response;
|
||||
return {
|
||||
currentModelId: getOpenCodeDefaultModelId(data),
|
||||
models: mapOpenCodeModels(data),
|
||||
};
|
||||
} catch {
|
||||
return emptyOpenCodeModelCatalog();
|
||||
} finally {
|
||||
if (acquired) releaseOpenCodeListServer(acquired.key);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildOpenCodeConfig,
|
||||
buildOpenCodePromptParts,
|
||||
classifyOpenCodeSpawnError,
|
||||
closeOpenCodeInstance,
|
||||
createOpenCodeProcessEnv,
|
||||
withOpenCodeProcessEnv,
|
||||
listOpenCodeModels,
|
||||
mapOpenCodeModels,
|
||||
parseOpenCodeModel,
|
||||
resolveUsableOpenCodeBinPath,
|
||||
resetOpenCodeListServerPool,
|
||||
runOpenCodeTurn,
|
||||
toOpenCodeMcpConfig,
|
||||
translateOpenCodeEvent,
|
||||
OPENCODE_LIST_SERVER_IDLE_MS,
|
||||
};
|
||||
1014
electron/bridges/aiBridge/sdk/opencodeDriver.test.cjs
Normal file
1014
electron/bridges/aiBridge/sdk/opencodeDriver.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
1236
electron/bridges/aiBridge/sdk/sdkStreamHandlers.cjs
Normal file
1236
electron/bridges/aiBridge/sdk/sdkStreamHandlers.cjs
Normal file
File diff suppressed because it is too large
Load Diff
874
electron/bridges/aiBridge/sdk/sdkStreamHandlers.test.cjs
Normal file
874
electron/bridges/aiBridge/sdk/sdkStreamHandlers.test.cjs
Normal file
@@ -0,0 +1,874 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
registerSdkStreamHandlers,
|
||||
buildSdkTurnPrompt,
|
||||
formatSdkHistoryReplaySection,
|
||||
buildSdkModelCacheKey,
|
||||
getSdkModelCacheEntry,
|
||||
setSdkModelCacheEntry,
|
||||
buildSdkSessionKey,
|
||||
normalizeSdkListModelsResult,
|
||||
resolveSdkPromptPlacement,
|
||||
resolveSdkResumeSessionId,
|
||||
shouldReplaySdkHistory,
|
||||
expireSiblingCursorCliModeSessions,
|
||||
expireSiblingGrokRuntimeSessions,
|
||||
resolveBackendKey,
|
||||
resolveSdkBackendBinPath,
|
||||
shouldCacheSdkRuntimeModels,
|
||||
} = require("./sdkStreamHandlers.cjs");
|
||||
|
||||
/**
|
||||
* Register the real IPC handlers against a stubbed ctx so lifecycle handlers
|
||||
* (cleanup) can be invoked directly. registerSdkStreamHandlers exposes its
|
||||
* request-scoped maps on ctx for exactly this kind of test.
|
||||
*/
|
||||
function registerWithStubbedCtx() {
|
||||
const handlers = new Map();
|
||||
const ctx = {
|
||||
ipcMain: { handle: (channel, fn) => handlers.set(channel, fn) },
|
||||
electronModule: undefined,
|
||||
validateSender: () => true,
|
||||
mcpServerBridge: {
|
||||
setChatSessionCancelled: () => {},
|
||||
cancelPtyExecsForSession: () => {},
|
||||
cancelWorkerBackgroundJobsForSession: () => {},
|
||||
cleanupScopedMetadata: async () => {},
|
||||
},
|
||||
};
|
||||
registerSdkStreamHandlers(ctx);
|
||||
return { handlers, ctx };
|
||||
}
|
||||
|
||||
test("sdk-agent:cleanup aborts and removes request entries for the target chat only", async () => {
|
||||
const { handlers, ctx } = registerWithStubbedCtx();
|
||||
|
||||
const targetController = new AbortController();
|
||||
const otherController = new AbortController();
|
||||
ctx.sdkActiveStreams.set("req-1", targetController);
|
||||
ctx.sdkRequestSessions.set("req-1", "chat-1");
|
||||
ctx.sdkRequestRuntimes.set("req-1", { backendKey: "codebuddy", codexRuntime: "sdk", binPath: "/bin/cb" });
|
||||
ctx.sdkActiveStreams.set("req-2", otherController);
|
||||
ctx.sdkRequestSessions.set("req-2", "chat-2");
|
||||
ctx.sdkRequestRuntimes.set("req-2", { backendKey: "codex", codexRuntime: "sdk", binPath: "/bin/codex" });
|
||||
|
||||
const cleanup = handlers.get("netcatty:ai:sdk-agent:cleanup");
|
||||
assert.equal(typeof cleanup, "function");
|
||||
const result = await cleanup({ sender: {} }, { chatSessionId: "chat-1" });
|
||||
assert.deepEqual(result, { ok: true });
|
||||
|
||||
// Target chat: controller aborted and every request-scoped entry removed.
|
||||
assert.ok(targetController.signal.aborted);
|
||||
assert.ok(!ctx.sdkActiveStreams.has("req-1"));
|
||||
assert.ok(!ctx.sdkRequestSessions.has("req-1"));
|
||||
assert.ok(!ctx.sdkRequestRuntimes.has("req-1"));
|
||||
|
||||
// Other chat: untouched.
|
||||
assert.ok(!otherController.signal.aborted);
|
||||
assert.equal(ctx.sdkActiveStreams.get("req-2"), otherController);
|
||||
assert.equal(ctx.sdkRequestSessions.get("req-2"), "chat-2");
|
||||
assert.deepEqual(ctx.sdkRequestRuntimes.get("req-2"), {
|
||||
backendKey: "codex",
|
||||
codexRuntime: "sdk",
|
||||
binPath: "/bin/codex",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveBackendKey maps backend command/value to registry key", () => {
|
||||
assert.equal(resolveBackendKey("claude"), "claude");
|
||||
assert.equal(resolveBackendKey("codex"), "codex");
|
||||
assert.equal(resolveBackendKey("copilot"), "copilot");
|
||||
assert.equal(resolveBackendKey("codebuddy"), "codebuddy");
|
||||
assert.equal(resolveBackendKey("opencode"), "opencode");
|
||||
});
|
||||
|
||||
test("resolveBackendKey returns null for unknown", () => {
|
||||
assert.equal(resolveBackendKey("claude-agent-acp"), null);
|
||||
assert.equal(resolveBackendKey(""), null);
|
||||
assert.equal(resolveBackendKey(undefined), null);
|
||||
});
|
||||
|
||||
test("SDK session keys include backend and resolved CLI path", () => {
|
||||
assert.notEqual(
|
||||
buildSdkSessionKey("chat-1", "codex", "/usr/local/bin/codex"),
|
||||
buildSdkSessionKey("chat-1", "codex", "/opt/homebrew/bin/codex"),
|
||||
);
|
||||
assert.notEqual(
|
||||
buildSdkSessionKey("chat-1", "codex", "/usr/local/bin/codex"),
|
||||
buildSdkSessionKey("chat-1", "claude", "/usr/local/bin/codex"),
|
||||
);
|
||||
});
|
||||
|
||||
test("Cursor session keys isolate CLI login from API key auth modes", () => {
|
||||
assert.notEqual(
|
||||
buildSdkSessionKey("chat-1", "cursor", "/usr/bin/agent", "sdk", "cli-login"),
|
||||
buildSdkSessionKey("chat-1", "cursor", "cursor", "sdk", "api-key"),
|
||||
);
|
||||
});
|
||||
|
||||
test("SDK model cache keys include resolved CLI path", () => {
|
||||
assert.notEqual(
|
||||
buildSdkModelCacheKey("claude", "/usr/local/bin/claude"),
|
||||
buildSdkModelCacheKey("claude", "/opt/homebrew/bin/claude"),
|
||||
);
|
||||
});
|
||||
|
||||
test("SDK model cache keys include catalog-affecting agent environment", () => {
|
||||
assert.notEqual(
|
||||
buildSdkModelCacheKey("opencode", "/usr/bin/opencode", { HOME: "/Users/a", OPENCODE_CONFIG_DIR: "/a/config" }),
|
||||
buildSdkModelCacheKey("opencode", "/usr/bin/opencode", { HOME: "/Users/b", OPENCODE_CONFIG_DIR: "/b/config" }),
|
||||
);
|
||||
assert.equal(
|
||||
buildSdkModelCacheKey("opencode", "/usr/bin/opencode", { HOME: "/Users/a" }),
|
||||
buildSdkModelCacheKey("opencode", "/usr/bin/opencode", { HOME: "/Users/a" }),
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
buildSdkModelCacheKey("cursor", "/usr/bin/cursor", { CURSOR_API_KEY: "very-secret-key" }),
|
||||
/very-secret-key/,
|
||||
);
|
||||
});
|
||||
|
||||
test("SDK model cache removes expired entries instead of retaining tombstones", () => {
|
||||
const cache = new Map([
|
||||
["expired", { at: 1, currentModelId: null, models: [{ id: "old" }] }],
|
||||
["fresh", { at: 95, currentModelId: null, models: [{ id: "new" }] }],
|
||||
]);
|
||||
|
||||
assert.equal(getSdkModelCacheEntry(cache, "expired", { now: 100, ttlMs: 10, maxEntries: 8 }), null);
|
||||
assert.equal(cache.has("expired"), false);
|
||||
assert.equal(getSdkModelCacheEntry(cache, "fresh", { now: 100, ttlMs: 10, maxEntries: 8 }).models[0].id, "new");
|
||||
});
|
||||
|
||||
test("SDK model cache evicts the least recently used catalog at its hard limit", () => {
|
||||
const cache = new Map();
|
||||
setSdkModelCacheEntry(cache, "a", { at: 1, models: [{ id: "a" }] }, { now: 1, ttlMs: 100, maxEntries: 2 });
|
||||
setSdkModelCacheEntry(cache, "b", { at: 2, models: [{ id: "b" }] }, { now: 2, ttlMs: 100, maxEntries: 2 });
|
||||
assert.ok(getSdkModelCacheEntry(cache, "a", { now: 3, ttlMs: 100, maxEntries: 2 }));
|
||||
setSdkModelCacheEntry(cache, "c", { at: 3, models: [{ id: "c" }] }, { now: 3, ttlMs: 100, maxEntries: 2 });
|
||||
|
||||
assert.deepEqual(Array.from(cache.keys()), ["a", "c"]);
|
||||
});
|
||||
|
||||
test("normalizeSdkListModelsResult preserves current model ids from object results", () => {
|
||||
assert.deepEqual(normalizeSdkListModelsResult({
|
||||
currentModelId: "openai/gpt-5.1",
|
||||
models: [{ id: "openai/gpt-5.1" }, null, { name: "missing-id" }],
|
||||
}), {
|
||||
currentModelId: "openai/gpt-5.1",
|
||||
models: [{ id: "openai/gpt-5.1" }],
|
||||
});
|
||||
assert.deepEqual(normalizeSdkListModelsResult([{ id: "claude-sonnet" }]), {
|
||||
currentModelId: null,
|
||||
models: [{ id: "claude-sonnet" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("CodeBuddy and OpenCode keep Netcatty context in the system prompt only", () => {
|
||||
const input = {
|
||||
turnPrompt: "user request",
|
||||
contextualPrompt: "netcatty context\n\nuser request",
|
||||
systemContext: "netcatty context",
|
||||
};
|
||||
assert.deepEqual(resolveSdkPromptPlacement({
|
||||
...input,
|
||||
backendKey: "codebuddy",
|
||||
}), {
|
||||
prompt: "user request",
|
||||
systemPrompt: "netcatty context",
|
||||
});
|
||||
assert.deepEqual(resolveSdkPromptPlacement({
|
||||
...input,
|
||||
backendKey: "opencode",
|
||||
}), {
|
||||
prompt: "user request",
|
||||
systemPrompt: "netcatty context",
|
||||
});
|
||||
assert.deepEqual(resolveSdkPromptPlacement({
|
||||
...input,
|
||||
backendKey: "claude",
|
||||
}), {
|
||||
prompt: "netcatty context\n\nuser request",
|
||||
systemPrompt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("shouldCacheSdkRuntimeModels caches all SDK backends including OpenCode", () => {
|
||||
// OpenCode used to skip the cache, which re-spawned opencode servers on every
|
||||
// model-catalog probe (#2184). TTL still bounds staleness.
|
||||
assert.equal(shouldCacheSdkRuntimeModels("opencode"), true);
|
||||
assert.equal(shouldCacheSdkRuntimeModels("claude"), true);
|
||||
assert.equal(shouldCacheSdkRuntimeModels("codebuddy"), true);
|
||||
assert.equal(shouldCacheSdkRuntimeModels("copilot"), true);
|
||||
});
|
||||
|
||||
test("SDK resume only uses the current backend/path session key", () => {
|
||||
const sessions = new Map([
|
||||
[buildSdkSessionKey("chat-1", "codex", "/old/codex"), "old-session"],
|
||||
]);
|
||||
|
||||
assert.equal(
|
||||
resolveSdkResumeSessionId({
|
||||
sdkSessionIds: sessions,
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/new/codex"),
|
||||
backendKey: "codex",
|
||||
binPath: "/new/codex",
|
||||
hasConfiguredCommand: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
sessions.set(buildSdkSessionKey("chat-1", "codex", "/new/codex"), "new-session");
|
||||
assert.equal(
|
||||
resolveSdkResumeSessionId({
|
||||
sdkSessionIds: sessions,
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/new/codex"),
|
||||
backendKey: "codex",
|
||||
binPath: "/new/codex",
|
||||
hasConfiguredCommand: true,
|
||||
}),
|
||||
"new-session",
|
||||
);
|
||||
});
|
||||
|
||||
test("SDK resume uses persisted session identity only when backend and path match", () => {
|
||||
const persisted = `netcatty-sdk-session:${encodeURIComponent(JSON.stringify({
|
||||
v: 1,
|
||||
id: "persisted-session",
|
||||
backend: "codex",
|
||||
binPath: "/opt/homebrew/bin/codex",
|
||||
}))}`;
|
||||
|
||||
assert.equal(
|
||||
resolveSdkResumeSessionId({
|
||||
sdkSessionIds: new Map(),
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/opt/homebrew/bin/codex"),
|
||||
existingSessionId: persisted,
|
||||
backendKey: "codex",
|
||||
binPath: "/opt/homebrew/bin/codex",
|
||||
hasConfiguredCommand: true,
|
||||
}),
|
||||
"persisted-session",
|
||||
);
|
||||
assert.equal(
|
||||
resolveSdkResumeSessionId({
|
||||
sdkSessionIds: new Map(),
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/other/codex"),
|
||||
existingSessionId: persisted,
|
||||
backendKey: "codex",
|
||||
binPath: "/other/codex",
|
||||
hasConfiguredCommand: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("Codex sessions never resume across SDK and App Server runtimes", () => {
|
||||
const sdkIdentity = `netcatty-sdk-session:${encodeURIComponent(JSON.stringify({
|
||||
v: 1,
|
||||
id: "sdk-thread",
|
||||
backend: "codex",
|
||||
binPath: "/usr/bin/codex",
|
||||
runtime: "sdk",
|
||||
}))}`;
|
||||
assert.equal(resolveSdkResumeSessionId({
|
||||
sdkSessionIds: new Map(),
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/usr/bin/codex", "app-server"),
|
||||
existingSessionId: sdkIdentity,
|
||||
backendKey: "codex",
|
||||
binPath: "/usr/bin/codex",
|
||||
runtime: "app-server",
|
||||
hasConfiguredCommand: false,
|
||||
}), undefined);
|
||||
assert.equal(resolveSdkResumeSessionId({
|
||||
sdkSessionIds: new Map(),
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/usr/bin/codex", "app-server"),
|
||||
existingSessionId: "legacy-thread",
|
||||
backendKey: "codex",
|
||||
binPath: "/usr/bin/codex",
|
||||
runtime: "app-server",
|
||||
hasConfiguredCommand: false,
|
||||
}), undefined);
|
||||
});
|
||||
|
||||
test("SDK resume keeps legacy session ids only when no manual command is configured", () => {
|
||||
assert.equal(
|
||||
resolveSdkResumeSessionId({
|
||||
sdkSessionIds: new Map(),
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/usr/bin/codex"),
|
||||
existingSessionId: "legacy-session",
|
||||
backendKey: "codex",
|
||||
binPath: "/usr/bin/codex",
|
||||
hasConfiguredCommand: false,
|
||||
}),
|
||||
"legacy-session",
|
||||
);
|
||||
assert.equal(
|
||||
resolveSdkResumeSessionId({
|
||||
sdkSessionIds: new Map(),
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/manual/codex"),
|
||||
existingSessionId: "legacy-session",
|
||||
backendKey: "codex",
|
||||
binPath: "/manual/codex",
|
||||
hasConfiguredCommand: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("Cursor CLI login sessions do not resume on the API key SDK path", () => {
|
||||
const cliIdentity = `netcatty-sdk-session:${encodeURIComponent(JSON.stringify({
|
||||
v: 1,
|
||||
id: "61668441-bfcb-4795-a575-c46d70ad01fe",
|
||||
backend: "cursor",
|
||||
binPath: "/usr/bin/agent",
|
||||
runtime: "sdk",
|
||||
authMode: "cli-login",
|
||||
cliMode: "agent",
|
||||
}))}`;
|
||||
|
||||
assert.equal(
|
||||
resolveSdkResumeSessionId({
|
||||
sdkSessionIds: new Map(),
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "cursor", "cursor", "sdk", "api-key"),
|
||||
existingSessionId: cliIdentity,
|
||||
backendKey: "cursor",
|
||||
binPath: "cursor",
|
||||
runtime: "sdk",
|
||||
authMode: "api-key",
|
||||
hasConfiguredCommand: false,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSdkResumeSessionId({
|
||||
sdkSessionIds: new Map(),
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "cursor", "/usr/bin/agent", "sdk", "cli-login", "agent"),
|
||||
existingSessionId: cliIdentity,
|
||||
backendKey: "cursor",
|
||||
binPath: "/usr/bin/agent",
|
||||
runtime: "sdk",
|
||||
authMode: "cli-login",
|
||||
cliMode: "agent",
|
||||
hasConfiguredCommand: false,
|
||||
}),
|
||||
"61668441-bfcb-4795-a575-c46d70ad01fe",
|
||||
);
|
||||
assert.equal(
|
||||
resolveSdkResumeSessionId({
|
||||
sdkSessionIds: new Map(),
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "cursor", "/usr/bin/agent", "sdk", "cli-login", "ask"),
|
||||
existingSessionId: cliIdentity,
|
||||
backendKey: "cursor",
|
||||
binPath: "/usr/bin/agent",
|
||||
runtime: "sdk",
|
||||
authMode: "cli-login",
|
||||
cliMode: "ask",
|
||||
hasConfiguredCommand: false,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSdkResumeSessionId({
|
||||
sdkSessionIds: new Map(),
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "cursor", "cursor", "sdk", "cli-login"),
|
||||
existingSessionId: "61668441-bfcb-4795-a575-c46d70ad01fe",
|
||||
backendKey: "cursor",
|
||||
binPath: "cursor",
|
||||
runtime: "sdk",
|
||||
authMode: "cli-login",
|
||||
hasConfiguredCommand: false,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("expireSiblingCursorCliModeSessions drops the inactive Cursor CLI mode", () => {
|
||||
const askKey = buildSdkSessionKey("chat-1", "cursor", "/bin/cursor-agent", "sdk", "cli-login", "ask");
|
||||
const agentKey = buildSdkSessionKey("chat-1", "cursor", "/bin/cursor-agent", "sdk", "cli-login", "agent");
|
||||
const otherChatAskKey = buildSdkSessionKey("chat-2", "cursor", "/bin/cursor-agent", "sdk", "cli-login", "ask");
|
||||
const sessions = new Map([
|
||||
[askKey, "ask-session"],
|
||||
[agentKey, "agent-session"],
|
||||
[otherChatAskKey, "other-ask"],
|
||||
]);
|
||||
|
||||
// Observer → Confirm: expire Ask so a later switch-back cannot revive it.
|
||||
assert.equal(
|
||||
expireSiblingCursorCliModeSessions(sessions, {
|
||||
chatSessionId: "chat-1",
|
||||
backendKey: "cursor",
|
||||
binPath: "/bin/cursor-agent",
|
||||
runtime: "sdk",
|
||||
authMode: "cli-login",
|
||||
cliMode: "agent",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(sessions.has(askKey), false);
|
||||
assert.equal(sessions.get(agentKey), "agent-session");
|
||||
assert.equal(sessions.get(otherChatAskKey), "other-ask");
|
||||
|
||||
// Confirm → Observer: expire agent; Ask was already gone, so resume is fresh.
|
||||
sessions.set(agentKey, "agent-session-2");
|
||||
assert.equal(
|
||||
expireSiblingCursorCliModeSessions(sessions, {
|
||||
chatSessionId: "chat-1",
|
||||
backendKey: "cursor",
|
||||
binPath: "/bin/cursor-agent",
|
||||
runtime: "sdk",
|
||||
authMode: "cli-login",
|
||||
cliMode: "ask",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(sessions.has(agentKey), false);
|
||||
assert.equal(
|
||||
resolveSdkResumeSessionId({
|
||||
sdkSessionIds: sessions,
|
||||
sdkSessionKey: askKey,
|
||||
existingSessionId: `netcatty-sdk-session:${encodeURIComponent(JSON.stringify({
|
||||
v: 1,
|
||||
id: "agent-session-2",
|
||||
backend: "cursor",
|
||||
binPath: "/bin/cursor-agent",
|
||||
runtime: "sdk",
|
||||
authMode: "cli-login",
|
||||
cliMode: "agent",
|
||||
}))}`,
|
||||
backendKey: "cursor",
|
||||
binPath: "/bin/cursor-agent",
|
||||
runtime: "sdk",
|
||||
authMode: "cli-login",
|
||||
cliMode: "ask",
|
||||
hasConfiguredCommand: false,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSdkTurnPrompt replays history only when requested", () => {
|
||||
const prompt = buildSdkTurnPrompt({
|
||||
prompt: "latest question",
|
||||
replayHistory: true,
|
||||
historyMessages: [
|
||||
{ role: "user", content: "previous question" },
|
||||
{ role: "assistant", content: "previous answer" },
|
||||
],
|
||||
});
|
||||
|
||||
assert.match(prompt, /Conversation context replay/);
|
||||
assert.match(prompt, /USER: previous question/);
|
||||
assert.match(prompt, /ASSISTANT: previous answer/);
|
||||
assert.match(prompt, /latest question$/);
|
||||
|
||||
const steadyStatePrompt = buildSdkTurnPrompt({
|
||||
prompt: "latest question",
|
||||
replayHistory: false,
|
||||
historyMessages: [{ role: "user", content: "previous question" }],
|
||||
});
|
||||
assert.equal(steadyStatePrompt, "latest question");
|
||||
});
|
||||
|
||||
test("formatSdkHistoryReplaySection matches buildSdkTurnPrompt history wording", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "previous question" },
|
||||
{ role: "assistant", content: "previous answer" },
|
||||
];
|
||||
const section = formatSdkHistoryReplaySection(messages);
|
||||
assert.match(section, /Conversation context replay/);
|
||||
assert.match(section, /USER: previous question/);
|
||||
assert.match(section, /ASSISTANT: previous answer/);
|
||||
// Same section is embedded when replayHistory is true.
|
||||
const full = buildSdkTurnPrompt({
|
||||
prompt: "latest",
|
||||
replayHistory: true,
|
||||
historyMessages: messages,
|
||||
});
|
||||
assert.ok(full.startsWith(section));
|
||||
assert.equal(formatSdkHistoryReplaySection([]), "");
|
||||
assert.equal(formatSdkHistoryReplaySection(undefined), "");
|
||||
});
|
||||
|
||||
test("CodeBuddy does not replay renderer history when a persisted session can resume", () => {
|
||||
assert.equal(shouldReplaySdkHistory({
|
||||
backendKey: "codebuddy",
|
||||
codexRuntime: "sdk",
|
||||
resumeSessionId: "resumed-codebuddy",
|
||||
hasInMemorySession: false,
|
||||
}), false);
|
||||
assert.equal(shouldReplaySdkHistory({
|
||||
backendKey: "codebuddy",
|
||||
codexRuntime: "sdk",
|
||||
resumeSessionId: undefined,
|
||||
hasInMemorySession: false,
|
||||
}), true);
|
||||
assert.equal(shouldReplaySdkHistory({
|
||||
backendKey: "claude",
|
||||
codexRuntime: "sdk",
|
||||
resumeSessionId: "resumed-claude",
|
||||
hasInMemorySession: false,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("Grok does not replay renderer history when an ACP session can resume", () => {
|
||||
// Mirrors CodeBuddy: session/load / resume already restores Grok transcript.
|
||||
// Applies to both ACP and streaming-json once a resume id is present.
|
||||
assert.equal(shouldReplaySdkHistory({
|
||||
backendKey: "grok",
|
||||
codexRuntime: "sdk",
|
||||
resumeSessionId: "resumed-grok",
|
||||
hasInMemorySession: false,
|
||||
}), false);
|
||||
assert.equal(shouldReplaySdkHistory({
|
||||
backendKey: "grok",
|
||||
codexRuntime: "sdk",
|
||||
resumeSessionId: undefined,
|
||||
hasInMemorySession: false,
|
||||
}), true);
|
||||
// Even with an in-memory map miss, resume id alone must suppress replay.
|
||||
assert.equal(shouldReplaySdkHistory({
|
||||
backendKey: "grok",
|
||||
codexRuntime: "sdk",
|
||||
resumeSessionId: "s1",
|
||||
hasInMemorySession: true,
|
||||
}), false);
|
||||
// First turn (no resume) still seeds context even if in-memory key exists.
|
||||
assert.equal(shouldReplaySdkHistory({
|
||||
backendKey: "grok",
|
||||
codexRuntime: "sdk",
|
||||
resumeSessionId: undefined,
|
||||
hasInMemorySession: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("expireSiblingGrokRuntimeSessions drops the inactive Grok runtime", () => {
|
||||
const acpKey = buildSdkSessionKey("chat-1", "grok", "/usr/bin/grok", "acp");
|
||||
const headlessKey = buildSdkSessionKey("chat-1", "grok", "/usr/bin/grok", "streaming-json");
|
||||
const otherChatAcpKey = buildSdkSessionKey("chat-2", "grok", "/usr/bin/grok", "acp");
|
||||
const sessions = new Map([
|
||||
[acpKey, "acp-session"],
|
||||
[headlessKey, "json-session"],
|
||||
[otherChatAcpKey, "other-acp"],
|
||||
]);
|
||||
|
||||
// Switch to streaming-json: expire ACP so switch-back cannot revive it.
|
||||
assert.equal(
|
||||
expireSiblingGrokRuntimeSessions(sessions, {
|
||||
chatSessionId: "chat-1",
|
||||
backendKey: "grok",
|
||||
binPath: "/usr/bin/grok",
|
||||
runtime: "streaming-json",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(sessions.has(acpKey), false);
|
||||
assert.equal(sessions.get(headlessKey), "json-session");
|
||||
assert.equal(sessions.get(otherChatAcpKey), "other-acp");
|
||||
|
||||
// Switch back to ACP: expire headless; ACP was already gone → fresh resume.
|
||||
sessions.set(headlessKey, "json-session-2");
|
||||
assert.equal(
|
||||
expireSiblingGrokRuntimeSessions(sessions, {
|
||||
chatSessionId: "chat-1",
|
||||
backendKey: "grok",
|
||||
binPath: "/usr/bin/grok",
|
||||
runtime: "acp",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(sessions.has(headlessKey), false);
|
||||
assert.equal(
|
||||
resolveSdkResumeSessionId({
|
||||
sdkSessionIds: sessions,
|
||||
sdkSessionKey: acpKey,
|
||||
existingSessionId: `netcatty-sdk-session:${encodeURIComponent(JSON.stringify({
|
||||
v: 1,
|
||||
id: "json-session-2",
|
||||
backend: "grok",
|
||||
binPath: "/usr/bin/grok",
|
||||
runtime: "streaming-json",
|
||||
}))}`,
|
||||
backendKey: "grok",
|
||||
binPath: "/usr/bin/grok",
|
||||
runtime: "acp",
|
||||
hasConfiguredCommand: false,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
// Non-grok backends no-op.
|
||||
assert.equal(
|
||||
expireSiblingGrokRuntimeSessions(sessions, {
|
||||
chatSessionId: "chat-1",
|
||||
backendKey: "claude",
|
||||
binPath: "/usr/bin/claude",
|
||||
runtime: "sdk",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Grok ACP and streaming-json session identities never cross-resume", () => {
|
||||
const acpIdentity = `netcatty-sdk-session:${encodeURIComponent(JSON.stringify({
|
||||
v: 1,
|
||||
id: "grok-acp-thread",
|
||||
backend: "grok",
|
||||
binPath: "/usr/bin/grok",
|
||||
runtime: "acp",
|
||||
}))}`;
|
||||
const headlessIdentity = `netcatty-sdk-session:${encodeURIComponent(JSON.stringify({
|
||||
v: 1,
|
||||
id: "grok-headless-thread",
|
||||
backend: "grok",
|
||||
binPath: "/usr/bin/grok",
|
||||
runtime: "streaming-json",
|
||||
}))}`;
|
||||
|
||||
// ACP identity must not resume onto streaming-json runtime.
|
||||
assert.equal(resolveSdkResumeSessionId({
|
||||
sdkSessionIds: new Map(),
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "grok", "/usr/bin/grok", "streaming-json"),
|
||||
existingSessionId: acpIdentity,
|
||||
backendKey: "grok",
|
||||
binPath: "/usr/bin/grok",
|
||||
runtime: "streaming-json",
|
||||
hasConfiguredCommand: false,
|
||||
}), undefined);
|
||||
|
||||
// streaming-json identity must not resume onto ACP runtime.
|
||||
assert.equal(resolveSdkResumeSessionId({
|
||||
sdkSessionIds: new Map(),
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "grok", "/usr/bin/grok", "acp"),
|
||||
existingSessionId: headlessIdentity,
|
||||
backendKey: "grok",
|
||||
binPath: "/usr/bin/grok",
|
||||
runtime: "acp",
|
||||
hasConfiguredCommand: false,
|
||||
}), undefined);
|
||||
|
||||
// Matching runtime resumes.
|
||||
assert.equal(resolveSdkResumeSessionId({
|
||||
sdkSessionIds: new Map(),
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "grok", "/usr/bin/grok", "acp"),
|
||||
existingSessionId: acpIdentity,
|
||||
backendKey: "grok",
|
||||
binPath: "/usr/bin/grok",
|
||||
runtime: "acp",
|
||||
hasConfiguredCommand: false,
|
||||
}), "grok-acp-thread");
|
||||
assert.equal(resolveSdkResumeSessionId({
|
||||
sdkSessionIds: new Map(),
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "grok", "/usr/bin/grok", "streaming-json"),
|
||||
existingSessionId: headlessIdentity,
|
||||
backendKey: "grok",
|
||||
binPath: "/usr/bin/grok",
|
||||
runtime: "streaming-json",
|
||||
hasConfiguredCommand: false,
|
||||
}), "grok-headless-thread");
|
||||
|
||||
// Bare legacy ids are only safe for runtime "sdk" — not Grok dual runtimes.
|
||||
assert.equal(resolveSdkResumeSessionId({
|
||||
sdkSessionIds: new Map(),
|
||||
sdkSessionKey: buildSdkSessionKey("chat-1", "grok", "/usr/bin/grok", "acp"),
|
||||
existingSessionId: "legacy-bare-id",
|
||||
backendKey: "grok",
|
||||
binPath: "/usr/bin/grok",
|
||||
runtime: "acp",
|
||||
hasConfiguredCommand: false,
|
||||
}), undefined);
|
||||
});
|
||||
|
||||
test("buildSdkTurnPrompt stages attachments as local file hints", () => {
|
||||
const staged = [];
|
||||
const prompt = buildSdkTurnPrompt({
|
||||
prompt: "describe it",
|
||||
attachments: [
|
||||
{ base64Data: Buffer.from("img").toString("base64"), mediaType: "image/png", filename: "screen.png" },
|
||||
],
|
||||
writeAttachmentToTemp: (attachment) => `/tmp/${attachment.filename}`,
|
||||
onStagedAttachment: (attachment) => staged.push(attachment),
|
||||
});
|
||||
|
||||
assert.match(prompt, /Attached files/);
|
||||
assert.match(prompt, /read_attachment/);
|
||||
assert.match(prompt, /"screen\.png" \(image\/png\)/);
|
||||
assert.match(prompt, /\/tmp\/screen\.png/);
|
||||
assert.match(prompt, /describe it$/);
|
||||
assert.deepEqual(staged, [{
|
||||
filename: "screen.png",
|
||||
mediaType: "image/png",
|
||||
filePath: "/tmp/screen.png",
|
||||
base64Data: Buffer.from("img").toString("base64"),
|
||||
}]);
|
||||
});
|
||||
|
||||
test("buildSdkTurnPrompt directs Skills-mode attachments to the controlled CLI", () => {
|
||||
const prompt = buildSdkTurnPrompt({
|
||||
prompt: "read it",
|
||||
toolIntegrationMode: "skills",
|
||||
attachments: [
|
||||
{ base64Data: "ZGF0YQ==", mediaType: "text/plain", filename: "notes.txt" },
|
||||
],
|
||||
writeAttachmentToTemp: (attachment) => `/tmp/${attachment.filename}`,
|
||||
});
|
||||
|
||||
assert.match(prompt, /attachment list\/read CLI commands/);
|
||||
assert.doesNotMatch(prompt, /list_attachments|read_attachment/);
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath prefers configured CodeBuddy path", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codebuddy",
|
||||
shellEnv: { PATH: "/usr/bin" },
|
||||
env: { CODEBUDDY_CODE_PATH: "/shim/bin/codebuddy" },
|
||||
resolveCliFromPath: () => "/usr/bin/codebuddy",
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
realpath: () => "/opt/codebuddy/bin/codebuddy",
|
||||
});
|
||||
assert.equal(out, "/opt/codebuddy/bin/codebuddy");
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath prefers the renderer-configured command path", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codex",
|
||||
configuredCommand: "/opt/homebrew/bin/codex",
|
||||
shellEnv: { PATH: "/usr/bin" },
|
||||
env: {},
|
||||
resolveCliFromPath: () => "/usr/bin/codex",
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
resolveSdkBinPath: () => "/usr/bin/codex",
|
||||
realpath: () => "/opt/homebrew/bin/codex",
|
||||
});
|
||||
assert.equal(out, "/opt/homebrew/bin/codex");
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath rejects invalid renderer-configured command paths", () => {
|
||||
assert.throws(
|
||||
() => resolveSdkBackendBinPath({
|
||||
backendKey: "codex",
|
||||
configuredCommand: "/missing/codex",
|
||||
shellEnv: { PATH: "/usr/bin" },
|
||||
env: {},
|
||||
resolveCliFromPath: () => "/usr/bin/codex",
|
||||
normalizeCliPathForPlatform: () => null,
|
||||
resolveSdkBinPath: () => "/usr/bin/codex",
|
||||
}),
|
||||
/Agent CLI path not found: \/missing\/codex/,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath applies Codex SDK normalization to configured command paths", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codex",
|
||||
configuredCommand: "C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd",
|
||||
shellEnv: { Path: "C:\\Windows\\System32" },
|
||||
env: {},
|
||||
resolveCliFromPath: () => "C:\\Windows\\System32\\codex.cmd",
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
resolveCodexExecutableForSdk: (p) =>
|
||||
p.endsWith("codex.cmd")
|
||||
? "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex-win32-x64\\vendor\\x86_64-pc-windows-msvc\\bin\\codex.exe"
|
||||
: p,
|
||||
realpath: (p) => p,
|
||||
});
|
||||
assert.equal(
|
||||
out,
|
||||
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex-win32-x64\\vendor\\x86_64-pc-windows-msvc\\bin\\codex.exe",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath applies CodeBuddy SDK normalization to configured command paths", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codebuddy",
|
||||
configuredCommand: "C:\\Users\\me\\AppData\\Roaming\\npm\\codebuddy.cmd",
|
||||
shellEnv: { Path: "C:\\Windows\\System32" },
|
||||
env: {},
|
||||
resolveCliFromPath: () => "C:\\Windows\\System32\\codebuddy.cmd",
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
resolveCodebuddyExecutableForSdk: (p) =>
|
||||
p.endsWith("codebuddy.cmd")
|
||||
? "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy"
|
||||
: p,
|
||||
realpath: (p) => p,
|
||||
});
|
||||
assert.equal(
|
||||
out,
|
||||
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath falls back to PATH when CodeBuddy path is invalid", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codebuddy",
|
||||
shellEnv: { PATH: "/usr/bin" },
|
||||
env: { CODEBUDDY_CODE_PATH: "/missing/codebuddy" },
|
||||
resolveCliFromPath: () => "/usr/bin/codebuddy",
|
||||
normalizeCliPathForPlatform: () => null,
|
||||
});
|
||||
assert.equal(out, "/usr/bin/codebuddy");
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath realpaths CodeBuddy PATH discovery fallback", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codebuddy",
|
||||
shellEnv: { PATH: "/usr/bin" },
|
||||
env: {},
|
||||
resolveCliFromPath: () => "/shim/bin/codebuddy",
|
||||
normalizeCliPathForPlatform: () => null,
|
||||
realpath: () => "/opt/codebuddy/bin/codebuddy",
|
||||
});
|
||||
assert.equal(out, "/opt/codebuddy/bin/codebuddy");
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath resolves Windows CodeBuddy shim to the package JS entry", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codebuddy",
|
||||
shellEnv: { Path: "C:\\Users\\me\\AppData\\Roaming\\npm" },
|
||||
env: {},
|
||||
resolveCliFromPath: () => "C:\\Users\\me\\AppData\\Roaming\\npm\\codebuddy.cmd",
|
||||
normalizeCliPathForPlatform: () => null,
|
||||
realpath: (p) => p,
|
||||
resolveCodebuddyExecutableForSdk: (p) =>
|
||||
p.endsWith("codebuddy.cmd")
|
||||
? "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy"
|
||||
: p,
|
||||
});
|
||||
assert.equal(
|
||||
out,
|
||||
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath falls back to bundled CLI when Windows CodeBuddy shim is unresolvable", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codebuddy",
|
||||
shellEnv: { Path: "C:\\Users\\me\\AppData\\Roaming\\npm" },
|
||||
env: {},
|
||||
resolveCliFromPath: () => "C:\\Users\\me\\AppData\\Roaming\\npm\\codebuddy.cmd",
|
||||
normalizeCliPathForPlatform: () => null,
|
||||
realpath: (p) => p,
|
||||
resolveCodebuddyExecutableForSdk: () => null,
|
||||
});
|
||||
assert.equal(out, undefined);
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath keeps non-CodeBuddy SDK path normalization", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codex",
|
||||
shellEnv: { PATH: "C:\\Users\\me\\AppData\\Roaming\\npm" },
|
||||
env: {},
|
||||
resolveCliFromPath: () => "C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd",
|
||||
resolveSdkBinPath: () => "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js",
|
||||
});
|
||||
assert.equal(out, "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js");
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath does not fall back to Windows shell shims for non-CodeBuddy", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codex",
|
||||
shellEnv: { PATH: "C:\\Users\\me\\AppData\\Roaming\\npm" },
|
||||
env: {},
|
||||
resolveCliFromPath: () => "C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd",
|
||||
resolveSdkBinPath: () => null,
|
||||
});
|
||||
assert.equal(out, undefined);
|
||||
});
|
||||
75
electron/bridges/aiBridge/vaultAgentBridge.cjs
Normal file
75
electron/bridges/aiBridge/vaultAgentBridge.cjs
Normal file
@@ -0,0 +1,75 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const VAULT_AGENT_TIMEOUT_MS = 15_000;
|
||||
const pendingVaultRequests = new Map();
|
||||
|
||||
function createVaultAgentBridge({ getMainWindowFn, validateSender }) {
|
||||
function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:ai:vault-agent:response", (event, { requestId, result }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
if (!requestId || typeof requestId !== "string") {
|
||||
return { ok: false, error: "requestId is required" };
|
||||
}
|
||||
const entry = pendingVaultRequests.get(requestId);
|
||||
if (!entry) {
|
||||
return { ok: false, error: "Unknown or expired vault agent request." };
|
||||
}
|
||||
clearTimeout(entry.timer);
|
||||
pendingVaultRequests.delete(requestId);
|
||||
entry.resolve(result);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
async function invokeVaultAgent(op, params = {}, options = {}) {
|
||||
const mainWin = typeof getMainWindowFn === "function" ? getMainWindowFn() : null;
|
||||
if (!mainWin || mainWin.isDestroyed()) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "No active Netcatty window is available for vault access.",
|
||||
};
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (!pendingVaultRequests.has(requestId)) return;
|
||||
pendingVaultRequests.delete(requestId);
|
||||
resolve({
|
||||
ok: false,
|
||||
error: "Vault agent bridge timed out waiting for renderer.",
|
||||
});
|
||||
}, options.timeoutMs ?? VAULT_AGENT_TIMEOUT_MS);
|
||||
|
||||
pendingVaultRequests.set(requestId, { resolve, timer });
|
||||
try {
|
||||
mainWin.webContents.send("netcatty:ai:vault-agent:request", {
|
||||
requestId,
|
||||
op,
|
||||
params,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
pendingVaultRequests.delete(requestId);
|
||||
resolve({
|
||||
ok: false,
|
||||
error: err?.message || String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
registerHandlers,
|
||||
invokeVaultAgent,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createVaultAgentBridge,
|
||||
VAULT_AGENT_TIMEOUT_MS,
|
||||
};
|
||||
Reference in New Issue
Block a user