[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
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:
210
electron/bridges/externalMcpController.test.cjs
Normal file
210
electron/bridges/externalMcpController.test.cjs
Normal file
@@ -0,0 +1,210 @@
|
||||
"use strict";
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
createExternalMcpController,
|
||||
EXTERNAL_MCP_CHAT_SESSION_ID,
|
||||
} = require("./externalMcpController.cjs");
|
||||
|
||||
function createFakeBridge({ port = 45555, token = "tok-1" } = {}) {
|
||||
const scoped = new Map();
|
||||
const sessionIdleTimeouts = [];
|
||||
return {
|
||||
getOrCreateHost: async () => port,
|
||||
issueExternalMcpAuthToken: () => token,
|
||||
getExternalMcpAuthToken: () => token,
|
||||
revokeExternalMcpAuthToken: () => {},
|
||||
getPermissionMode: () => "confirm",
|
||||
getScopedSessionIds: (chatSessionId) => scoped.get(chatSessionId)?.sessionIds || [],
|
||||
updateSessionMetadata: (sessionList, chatSessionId) => {
|
||||
scoped.set(chatSessionId, {
|
||||
sessionIds: sessionList.map((s) => s.sessionId),
|
||||
});
|
||||
},
|
||||
syncLiveSessionsToExternalScope: (chatSessionId = EXTERNAL_MCP_CHAT_SESSION_ID) => {
|
||||
scoped.set(chatSessionId, { sessionIds: ["sess-1", "sess-2"] });
|
||||
return { ok: true, count: 2, chatSessionId };
|
||||
},
|
||||
cleanupScopedMetadata: async (chatSessionId) => {
|
||||
scoped.delete(chatSessionId);
|
||||
},
|
||||
cancelPtyExecsForSession: () => {},
|
||||
cancelBackgroundJobsForSession: () => {},
|
||||
cancelWorkerBackgroundJobsForSession: () => {},
|
||||
cancelSftpOpsForSession: async () => {},
|
||||
setChatSessionCancelled: () => {},
|
||||
clearPendingApprovals: () => {},
|
||||
disconnectExternalMcpClients: () => {},
|
||||
setSessionIdleTimeoutMinutes: (minutes) => sessionIdleTimeouts.push(minutes),
|
||||
_scoped: scoped,
|
||||
_sessionIdleTimeouts: sessionIdleTimeouts,
|
||||
};
|
||||
}
|
||||
|
||||
describe("externalMcpController", () => {
|
||||
let tmpDir;
|
||||
let discoveryPath;
|
||||
let written;
|
||||
let removed;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-ext-ctrl-"));
|
||||
discoveryPath = path.join(tmpDir, "discovery.json");
|
||||
written = [];
|
||||
removed = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createController(overrides = {}) {
|
||||
const bridge = overrides.mcpServerBridge || createFakeBridge();
|
||||
const controller = createExternalMcpController({
|
||||
mcpServerBridge: bridge,
|
||||
getDiscoveryFilePath: () => discoveryPath,
|
||||
getLauncherPath: () => "/fake/netcatty-external-mcp",
|
||||
writeDiscovery: (filePath, options) => {
|
||||
written.push({ filePath, options });
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(options));
|
||||
return options;
|
||||
},
|
||||
removeDiscovery: (filePath) => {
|
||||
removed.push(filePath);
|
||||
fs.rmSync(filePath, { force: true });
|
||||
},
|
||||
createCodexSetup: () => ({
|
||||
getStatus: async () => ({ ok: true, state: "not_configured", launcherPath: "/fake/netcatty-external-mcp" }),
|
||||
addToCodex: async () => ({ ok: true, state: "configured", launcherPath: "/fake/netcatty-external-mcp" }),
|
||||
}),
|
||||
createClaudeSetup: () => ({
|
||||
getStatus: async () => ({ ok: true, state: "not_configured", launcherPath: "/fake/netcatty-external-mcp" }),
|
||||
addToClaude: async () => ({ ok: true, state: "configured", launcherPath: "/fake/netcatty-external-mcp" }),
|
||||
}),
|
||||
setTimeout: overrides.setTimeout || ((fn) => {
|
||||
// Do not auto-fire idle timers in unit tests unless requested.
|
||||
return 0;
|
||||
}),
|
||||
clearTimeout: () => {},
|
||||
...overrides,
|
||||
});
|
||||
controller.init({ discoveryFilePath: discoveryPath, mcpServerBridge: bridge });
|
||||
controller.setSessionSyncHandler(async () => {
|
||||
bridge.syncLiveSessionsToExternalScope();
|
||||
});
|
||||
return { controller, bridge };
|
||||
}
|
||||
|
||||
it("enables external mode, writes discovery, and syncs sessions", async () => {
|
||||
const { controller, bridge } = createController();
|
||||
const status = await controller.setEnabled(true);
|
||||
assert.equal(status.enabled, true);
|
||||
assert.equal(status.state, "running");
|
||||
assert.equal(status.port, 45555);
|
||||
assert.equal(written.length, 1);
|
||||
assert.equal(written[0].options.chatSessionId, EXTERNAL_MCP_CHAT_SESSION_ID);
|
||||
assert.equal(written[0].options.token, "tok-1");
|
||||
assert.deepEqual(bridge.getScopedSessionIds(EXTERNAL_MCP_CHAT_SESSION_ID), ["sess-1", "sess-2"]);
|
||||
});
|
||||
|
||||
it("removes discovery and clears scope on disable", async () => {
|
||||
const { controller, bridge } = createController();
|
||||
await controller.setEnabled(true);
|
||||
const status = await controller.setEnabled(false);
|
||||
assert.equal(status.enabled, false);
|
||||
assert.equal(status.state, "disabled");
|
||||
assert.ok(removed.includes(discoveryPath));
|
||||
assert.deepEqual(bridge.getScopedSessionIds(EXTERNAL_MCP_CHAT_SESSION_ID), []);
|
||||
});
|
||||
|
||||
it("rotates discovery on init so stale files are cleared", () => {
|
||||
fs.writeFileSync(discoveryPath, JSON.stringify({ port: 1, token: "stale" }));
|
||||
const { controller } = createController();
|
||||
assert.ok(removed.includes(discoveryPath));
|
||||
assert.equal(controller.getStatus().state, "disabled");
|
||||
});
|
||||
|
||||
it("idle timeout in temporary mode disables the runtime", async () => {
|
||||
let idleFn = null;
|
||||
let idleDone = null;
|
||||
const idlePromise = new Promise((resolve) => {
|
||||
idleDone = resolve;
|
||||
});
|
||||
const { controller } = createController({
|
||||
setTimeout: (fn) => {
|
||||
idleFn = async () => {
|
||||
await fn();
|
||||
idleDone?.();
|
||||
};
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
await controller.setEnabled(true);
|
||||
assert.equal(typeof idleFn, "function");
|
||||
await idleFn();
|
||||
await idlePromise;
|
||||
// Allow setEnabled(false) from the idle callback to settle.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const status = controller.getStatus();
|
||||
assert.equal(status.enabled, false);
|
||||
assert.equal(status.state, "disabled");
|
||||
});
|
||||
|
||||
it("persistent mode does not schedule idle shutdown", async () => {
|
||||
let scheduled = 0;
|
||||
const { controller } = createController({
|
||||
setTimeout: (fn) => {
|
||||
scheduled += 1;
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
controller.setConfig({ mode: "persistent", idleTimeoutMinutes: 5 });
|
||||
await controller.setEnabled(true);
|
||||
assert.equal(scheduled, 0);
|
||||
assert.equal(controller.getStatus().mode, "persistent");
|
||||
});
|
||||
|
||||
it("syncs the opened-session idle timeout to the MCP bridge", () => {
|
||||
const { controller, bridge } = createController();
|
||||
const status = controller.setConfig({ sessionIdleTimeoutMinutes: 45 });
|
||||
|
||||
assert.equal(status.sessionIdleTimeoutMinutes, 45);
|
||||
assert.deepEqual(bridge._sessionIdleTimeouts, [45]);
|
||||
});
|
||||
|
||||
it("serializes overlapping enable/disable so final enable recovers", async () => {
|
||||
let releaseStart = null;
|
||||
let startPasses = 0;
|
||||
const { controller, bridge } = createController();
|
||||
controller.setSessionSyncHandler(async () => {
|
||||
startPasses += 1;
|
||||
if (startPasses === 1) {
|
||||
await new Promise((resolve) => {
|
||||
releaseStart = resolve;
|
||||
});
|
||||
}
|
||||
bridge.syncLiveSessionsToExternalScope();
|
||||
});
|
||||
|
||||
const firstEnable = controller.setEnabled(true);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(typeof releaseStart, "function");
|
||||
|
||||
const disableDuringStart = controller.setEnabled(false);
|
||||
const reenable = controller.setEnabled(true);
|
||||
releaseStart();
|
||||
|
||||
await firstEnable;
|
||||
await disableDuringStart;
|
||||
const status = await reenable;
|
||||
assert.equal(status.enabled, true);
|
||||
assert.equal(status.state, "running");
|
||||
assert.ok(written.length >= 2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user