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
211 lines
7.5 KiB
JavaScript
211 lines
7.5 KiB
JavaScript
"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);
|
|
});
|
|
});
|