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
344 lines
10 KiB
JavaScript
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;
|
|
});
|