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