Files
NetMesh/scripts/verify-cursor-cli-mcp-cwd.cjs
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

344 lines
10 KiB
JavaScript

#!/usr/bin/env node
"use strict";
/**
* Local verification: packaged Cursor CLI MCP injection depends on a writable cwd.
*
* Proves:
* 1) mergeWorkspaceMcpJson fails when cwd is "/" (typical Dock/Finder launch)
* 2) mergeWorkspaceMcpJson succeeds in a writable directory (dev / terminal launch)
* 3) Packaged Netcatty process cwd differs: launch from "/" vs writable dir
* 4) Packaged MCP server script path exists under app.asar.unpacked
*
* Usage:
* node scripts/verify-cursor-cli-mcp-cwd.cjs
* node scripts/verify-cursor-cli-mcp-cwd.cjs --skip-app-launch
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawn, spawnSync } = require("node:child_process");
const {
mergeWorkspaceMcpJson,
resetMcpMergeRefcountsForTests,
resolveCursorCliWorkspaceCwd,
} = require("../electron/bridges/aiBridge/sdk/cursorCliDriver.cjs");
const SKIP_APP = process.argv.includes("--skip-app-launch");
const APP_BIN = "/Applications/Netcatty.app/Contents/MacOS/Netcatty";
const APP_MCP = "/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/electron/mcp/netcatty-mcp-server.cjs";
const TEST_DIR = path.join(os.homedir(), "netcatty-cli-cwd-test");
const FAKE_MCP = [{
name: "netcatty-remote-hosts",
type: "stdio",
command: APP_BIN,
args: [APP_MCP],
env: [
{ name: "ELECTRON_RUN_AS_NODE", value: "1" },
{ name: "NETCATTY_MCP_PORT", value: "1" },
{ name: "NETCATTY_MCP_TOKEN", value: "verify-token" },
{ name: "NETCATTY_MCP_CHAT_SESSION_ID", value: "verify-chat" },
],
}];
const results = [];
function pass(name, detail) {
results.push({ ok: true, name, detail });
console.log(`PASS ${name}${detail ? `${detail}` : ""}`);
}
function fail(name, detail) {
results.push({ ok: false, name, detail });
console.error(`FAIL ${name}${detail ? `${detail}` : ""}`);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function readProcessCwd(pid) {
const out = spawnSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
encoding: "utf8",
});
if (out.status !== 0) return null;
const line = String(out.stdout || "").split("\n").find((l) => l.startsWith("n"));
return line ? line.slice(1) : null;
}
async function launchAndReadCwd(launchCwd, label) {
if (!fs.existsSync(APP_BIN)) {
fail(`app cwd (${label})`, `missing ${APP_BIN}`);
return null;
}
const child = spawn(APP_BIN, [], {
cwd: launchCwd,
stdio: "ignore",
detached: true,
env: process.env,
});
const pid = child.pid;
child.unref();
let cwd = null;
for (let i = 0; i < 20; i++) {
await sleep(250);
cwd = readProcessCwd(pid);
if (cwd) break;
try {
process.kill(pid, 0);
} catch {
break;
}
}
try {
process.kill(pid, "SIGTERM");
} catch { /* already gone */ }
await sleep(300);
try {
process.kill(pid, "SIGKILL");
} catch { /* ignore */ }
return cwd;
}
function testMergeRootFails() {
resetMcpMergeRefcountsForTests();
try {
mergeWorkspaceMcpJson("/", FAKE_MCP);
fail("merge at cwd=/", "expected throw, but merge succeeded");
try {
fs.unlinkSync("/.cursor/mcp.json");
} catch { /* ignore */ }
} catch (err) {
pass("merge at cwd=/", `${err.code || "ERR"}: ${err.message}`);
}
}
function testMergeWritableSucceeds() {
resetMcpMergeRefcountsForTests();
fs.mkdirSync(TEST_DIR, { recursive: true });
const cursorDir = path.join(TEST_DIR, ".cursor");
const mcpPath = path.join(cursorDir, "mcp.json");
try {
fs.rmSync(cursorDir, { recursive: true, force: true });
} catch { /* ignore */ }
let handle;
try {
handle = mergeWorkspaceMcpJson(TEST_DIR, FAKE_MCP);
} catch (err) {
fail("merge in writable cwd", err.message);
return;
}
if (!fs.existsSync(mcpPath)) {
fail("merge in writable cwd", `missing ${mcpPath}`);
handle?.restore?.();
return;
}
let doc;
try {
doc = JSON.parse(fs.readFileSync(mcpPath, "utf8"));
} catch (err) {
fail("merge in writable cwd", `invalid json: ${err.message}`);
handle?.restore?.();
return;
}
const entry = doc?.mcpServers?.["netcatty-remote-hosts"];
if (!entry?.command) {
fail("merge in writable cwd", "netcatty-remote-hosts missing from mcp.json");
} else {
pass(
"merge in writable cwd",
`wrote ${mcpPath}; command=${entry.command}`,
);
}
handle?.restore?.();
// After restore of a previously non-existent file, mcp.json should be removed.
if (fs.existsSync(mcpPath)) {
fail("restore cleans mcp.json", `${mcpPath} still present after restore`);
} else {
pass("restore cleans mcp.json", "removed after turn-end restore");
}
}
function testMergeFailureIsLoud() {
const { runCursorCliTurn } = require("../electron/bridges/aiBridge/sdk/cursorCliDriver.cjs");
const calls = [];
const emitter = {
emitError: (message) => calls.push(message),
emitDone: () => {},
text: () => {},
reasoning: () => {},
reasoningEnd: () => {},
toolCall: () => {},
toolResult: () => {},
sessionId: () => {},
};
return runCursorCliTurn({
prompt: "hi",
binPath: "/bin/agent",
cwd: "/",
chatSessionId: "loud-fail",
getTempDir: () => path.join(os.tmpdir(), "netcatty-cli-loud-ok"),
model: "auto",
env: {},
permissionMode: "confirm",
injectedMcpServers: [{ name: "netcatty-remote-hosts", command: "node", args: ["x"] }],
emitter,
spawnImpl: () => {
throw new Error("spawn should not run after MCP merge failure");
},
mergeMcp: () => {
throw new Error("forced merge failure");
},
}).then(() => {
if (calls.length === 1 && /Failed to prepare Netcatty MCP for Cursor CLI/i.test(calls[0])) {
pass("merge failure is user-visible", calls[0]);
} else {
fail("merge failure is user-visible", JSON.stringify(calls));
}
});
}
function testPackagedMcpPath() {
if (fs.existsSync(APP_MCP)) {
pass("packaged MCP script exists", APP_MCP);
} else {
fail("packaged MCP script exists", `missing ${APP_MCP}`);
}
// Smoke: Electron binary can at least start the script as node long enough to print env requirement.
if (!fs.existsSync(APP_BIN)) {
fail("packaged MCP spawn smoke", `missing ${APP_BIN}`);
return;
}
const smoke = spawnSync(
APP_BIN,
[APP_MCP],
{
env: {
...process.env,
ELECTRON_RUN_AS_NODE: "1",
// Intentionally omit NETCATTY_MCP_PORT so server exits quickly with a known message.
},
encoding: "utf8",
timeout: 5000,
},
);
const errText = `${smoke.stderr || ""}${smoke.stdout || ""}`;
if (/NETCATTY_MCP_PORT not set/i.test(errText)) {
pass("packaged MCP spawn smoke", "Electron+ELECTRON_RUN_AS_NODE runs mcp server bootstrap");
} else {
fail(
"packaged MCP spawn smoke",
`unexpected exit=${smoke.status} signal=${smoke.signal} out=${errText.slice(0, 300)}`,
);
}
}
async function testAppLaunchCwds() {
if (SKIP_APP) {
console.log("SKIP packaged app cwd probes (--skip-app-launch)");
return;
}
// Ensure no leftover instance steals single-instance lock from a previous Dock launch.
spawnSync("pkill", ["-x", "Netcatty"], { encoding: "utf8" });
await sleep(800);
const fromRoot = await launchAndReadCwd("/", "from /");
if (fromRoot === "/" || fromRoot === "/System/Volumes/Data") {
pass("packaged cwd when launched from /", fromRoot);
} else if (fromRoot == null) {
fail("packaged cwd when launched from /", "could not read process cwd (app exited early?)");
} else {
// Still useful: report actual cwd
fail("packaged cwd when launched from /", `expected / , got ${fromRoot}`);
}
spawnSync("pkill", ["-x", "Netcatty"], { encoding: "utf8" });
await sleep(800);
fs.mkdirSync(TEST_DIR, { recursive: true });
const fromWritable = await launchAndReadCwd(TEST_DIR, "from writable");
if (fromWritable === TEST_DIR || fromWritable === path.resolve(TEST_DIR)) {
pass("packaged cwd when launched from writable dir", fromWritable);
} else if (fromWritable == null) {
fail("packaged cwd when launched from writable dir", "could not read process cwd");
} else {
fail(
"packaged cwd when launched from writable dir",
`expected ${TEST_DIR}, got ${fromWritable}`,
);
}
spawnSync("pkill", ["-x", "Netcatty"], { encoding: "utf8" });
}
function testResolveUsesTempOverRoot() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-fix-"));
try {
const resolved = resolveCursorCliWorkspaceCwd({
preferredCwd: "/",
chatSessionId: "verify-chat",
getTempDir: () => tempRoot,
});
const expected = path.join(tempRoot, "cursor-cli-mcp", "verify-chat");
if (resolved !== expected) {
fail("resolveCursorCliWorkspaceCwd", `expected ${expected}, got ${resolved}`);
return;
}
// Merge into the resolved workspace must succeed even when preferred cwd is /.
resetMcpMergeRefcountsForTests();
const handle = mergeWorkspaceMcpJson(resolved, FAKE_MCP);
const mcpPath = path.join(resolved, ".cursor", "mcp.json");
if (!fs.existsSync(mcpPath)) {
fail("resolveCursorCliWorkspaceCwd", `missing ${mcpPath}`);
handle?.restore?.();
return;
}
handle?.restore?.();
pass("resolveCursorCliWorkspaceCwd", `uses ${resolved} instead of /`);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
async function main() {
console.log("=== Cursor CLI MCP cwd verification ===\n");
testMergeRootFails();
testMergeWritableSucceeds();
await testMergeFailureIsLoud();
testResolveUsesTempOverRoot();
testPackagedMcpPath();
await testAppLaunchCwds();
const failed = results.filter((r) => !r.ok);
console.log(`\n=== Summary: ${results.length - failed.length}/${results.length} passed ===`);
if (failed.length) {
console.error("Failed checks:");
for (const f of failed) console.error(` - ${f.name}: ${f.detail}`);
process.exitCode = 1;
return;
}
console.log("\nConclusion:");
console.log("- Finder/Dock cwd=/ cannot host .cursor/mcp.json.");
console.log("- Fix: Cursor CLI turns use Netcatty temp cursor-cli-mcp/<chatId> workspace.");
console.log("- Merge failures now error out instead of silently dropping MCP.");
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});