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