[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View 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,
};

View 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 }),
);
});

View 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,
};

View 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);
});

View 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 };

View 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);
});

View 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 };

View 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 };

View 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"],
]);
});

View 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,
};

View 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" }),
);
});

View 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 };

File diff suppressed because it is too large Load Diff

View 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,
};

View 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");
});

View 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,
};

View 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 }));
});

View 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 };

View 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 };

View 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,
};

View 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);
});

File diff suppressed because it is too large Load Diff

View 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);
});

View 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 };

View 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);
});

View 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,
};

View 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);
});

View 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,
};

View 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);
});

View 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,
};

View 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);
});

View 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,
};

View 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");
});

View 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 };

View 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");
});

View 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 };

View 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");
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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");
});

View 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,
};

View 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;
}
});

View 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 };

View 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, []);
});

View File

@@ -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,
};

View File

@@ -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);
});

View 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,
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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);
});

View 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,
};