[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:
352
electron/bridges/mcpServerBridge/backgroundJobs.cjs
Normal file
352
electron/bridges/mcpServerBridge/backgroundJobs.cjs
Normal file
@@ -0,0 +1,352 @@
|
||||
/* eslint-disable no-undef */
|
||||
function createBackgroundJobApi(ctx) {
|
||||
with (ctx) {
|
||||
async function waitForSessionCloseCleanup(pending) {
|
||||
if (!pending.length) return;
|
||||
const allSettled = Promise.allSettled(pending);
|
||||
const timeoutMs = Number.isFinite(SESSION_CLOSE_CLEANUP_TIMEOUT_MS)
|
||||
? Math.max(1, SESSION_CLOSE_CLEANUP_TIMEOUT_MS)
|
||||
: 5000;
|
||||
let timer = null;
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, timeoutMs);
|
||||
});
|
||||
await Promise.race([allSettled, timeout]);
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
|
||||
function createBackgroundJobId() {
|
||||
return `job_${Date.now().toString(36)}_${crypto.randomBytes(6).toString("hex")}`;
|
||||
}
|
||||
|
||||
function cancelBackgroundJobsForSession(chatSessionId) {
|
||||
if (!chatSessionId) return;
|
||||
for (const [, job] of backgroundJobs) {
|
||||
if (job.chatSessionId !== chatSessionId) continue;
|
||||
if (job.status !== "running") continue;
|
||||
try {
|
||||
job.handle?.cancel?.();
|
||||
job.status = "stopping";
|
||||
job.error = "Cancellation requested";
|
||||
job.updatedAt = Date.now();
|
||||
} catch {
|
||||
// Ignore cancellation failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelBackgroundJobsForTerminalSession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
for (const [, job] of backgroundJobs) {
|
||||
if (job.sessionId !== sessionId) continue;
|
||||
if (job.status !== "running" && job.status !== "stopping") continue;
|
||||
try {
|
||||
job.handle?.cancel?.();
|
||||
} catch {
|
||||
// The terminal close below will still tear down the underlying stream.
|
||||
}
|
||||
job.status = "stopping";
|
||||
job.error = "Cancellation requested";
|
||||
job.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
async function settleBackgroundJobsForTerminalSession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
const matchingJobs = [];
|
||||
const pending = [];
|
||||
for (const [jobId, job] of backgroundJobs) {
|
||||
if (job.sessionId !== sessionId) continue;
|
||||
matchingJobs.push([jobId, job]);
|
||||
if (job.handle?.resultPromise) pending.push(job.handle.resultPromise);
|
||||
}
|
||||
await waitForSessionCloseCleanup(pending);
|
||||
for (const [jobId] of matchingJobs) backgroundJobs.delete(jobId);
|
||||
activeSessionExecutions.delete(sessionId);
|
||||
}
|
||||
|
||||
function registerSftpOp(chatSessionId, sessionId, cancel) {
|
||||
if (!chatSessionId || typeof cancel !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
if (closingTerminalSessions?.has(sessionId)) {
|
||||
try {
|
||||
void Promise.resolve(cancel()).catch(() => {});
|
||||
} catch {
|
||||
// The session is already closing; a failed redundant cancellation is harmless.
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
const opId = `sftp_${Date.now().toString(36)}_${(++activeSftpOpSeq).toString(36)}`;
|
||||
activeSessionSftpOps.set(opId, { chatSessionId, sessionId, cancel });
|
||||
return () => {
|
||||
activeSessionSftpOps.delete(opId);
|
||||
};
|
||||
}
|
||||
|
||||
async function cancelSftpOpsForSession(chatSessionId) {
|
||||
if (!chatSessionId) return;
|
||||
const pending = [];
|
||||
for (const [opId, entry] of activeSessionSftpOps) {
|
||||
if (entry.chatSessionId !== chatSessionId) continue;
|
||||
activeSessionSftpOps.delete(opId);
|
||||
try {
|
||||
pending.push(Promise.resolve(entry.cancel()));
|
||||
} catch {
|
||||
// Ignore cancellation failures for already-closed SFTP handles.
|
||||
}
|
||||
}
|
||||
if (pending.length) {
|
||||
await Promise.allSettled(pending);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelSftpOpsForTerminalSession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
const pending = [];
|
||||
for (const [opId, entry] of activeSessionSftpOps) {
|
||||
if (entry.sessionId !== sessionId) continue;
|
||||
activeSessionSftpOps.delete(opId);
|
||||
try {
|
||||
pending.push(Promise.resolve(entry.cancel()));
|
||||
} catch {
|
||||
// Ignore cancellation failures for already-closed SFTP handles.
|
||||
}
|
||||
}
|
||||
await waitForSessionCloseCleanup(pending);
|
||||
}
|
||||
|
||||
function beginTerminalSessionClose(sessionId) {
|
||||
if (!sessionId) return;
|
||||
const current = closingTerminalSessions?.get(sessionId) || 0;
|
||||
closingTerminalSessions?.set(sessionId, current + 1);
|
||||
}
|
||||
|
||||
function endTerminalSessionClose(sessionId) {
|
||||
if (!sessionId) return;
|
||||
const current = closingTerminalSessions?.get(sessionId) || 0;
|
||||
if (current <= 1) closingTerminalSessions?.delete(sessionId);
|
||||
else closingTerminalSessions?.set(sessionId, current - 1);
|
||||
}
|
||||
|
||||
function cancelAllSftpOps() {
|
||||
const pending = [];
|
||||
for (const [opId, entry] of activeSessionSftpOps) {
|
||||
activeSessionSftpOps.delete(opId);
|
||||
try {
|
||||
pending.push(Promise.resolve(entry.cancel()));
|
||||
} catch {
|
||||
// Ignore cancellation failures during global cleanup.
|
||||
}
|
||||
}
|
||||
return pending.length ? Promise.allSettled(pending) : Promise.resolve([]);
|
||||
}
|
||||
|
||||
function readBackgroundJobSnapshot(job) {
|
||||
if (!job) {
|
||||
return {
|
||||
stdout: "",
|
||||
outputBaseOffset: 0,
|
||||
totalOutputChars: 0,
|
||||
outputTruncated: false,
|
||||
};
|
||||
}
|
||||
if (job.status === "running" || job.status === "stopping") {
|
||||
const snapshot = job.handle?.getSnapshot?.();
|
||||
if (snapshot) {
|
||||
const stdout = String(snapshot.stdout || "");
|
||||
const outputBaseOffset = Math.max(0, Number(snapshot.outputBaseOffset) || 0);
|
||||
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, Number(snapshot.totalOutputChars) || 0);
|
||||
return {
|
||||
stdout,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: Boolean(snapshot.outputTruncated),
|
||||
};
|
||||
}
|
||||
}
|
||||
const stdout = String(job.stdout || "");
|
||||
const outputBaseOffset = Math.max(0, Number(job.outputBaseOffset) || 0);
|
||||
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, Number(job.totalOutputChars) || 0);
|
||||
return {
|
||||
stdout,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: Boolean(job.outputTruncated),
|
||||
};
|
||||
}
|
||||
|
||||
function createOutputWindow(stdout) {
|
||||
const fullText = String(stdout || "");
|
||||
const totalOutputChars = fullText.length;
|
||||
const outputBaseOffset = Math.max(0, totalOutputChars - MAX_BACKGROUND_JOB_OUTPUT_CHARS);
|
||||
return {
|
||||
stdout: outputBaseOffset > 0 ? fullText.slice(outputBaseOffset) : fullText,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: outputBaseOffset > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function refreshRunningJobSnapshot(job) {
|
||||
if (!job || (job.status !== "running" && job.status !== "stopping")) return;
|
||||
const snapshot = readBackgroundJobSnapshot(job);
|
||||
job.stdout = snapshot.stdout;
|
||||
job.outputBaseOffset = snapshot.outputBaseOffset;
|
||||
job.totalOutputChars = snapshot.totalOutputChars;
|
||||
job.outputTruncated = snapshot.outputTruncated;
|
||||
}
|
||||
|
||||
function storeCompletedJobOutput(job, stdout, metadata = null) {
|
||||
if (metadata && typeof metadata === "object") {
|
||||
const normalizedStdout = String(metadata.stdout ?? stdout ?? "");
|
||||
const outputBaseOffset = Math.max(0, Number(metadata.outputBaseOffset) || 0);
|
||||
const totalOutputChars = Math.max(outputBaseOffset + normalizedStdout.length, Number(metadata.totalOutputChars) || 0);
|
||||
job.stdout = normalizedStdout;
|
||||
job.outputBaseOffset = outputBaseOffset;
|
||||
job.totalOutputChars = totalOutputChars;
|
||||
job.outputTruncated = Boolean(metadata.outputTruncated);
|
||||
job.handle = null;
|
||||
return;
|
||||
}
|
||||
const window = createOutputWindow(stdout);
|
||||
job.stdout = window.stdout;
|
||||
job.outputBaseOffset = window.outputBaseOffset;
|
||||
job.totalOutputChars = window.totalOutputChars;
|
||||
job.outputTruncated = window.outputTruncated;
|
||||
job.handle = null;
|
||||
}
|
||||
|
||||
function pruneCompletedBackgroundJobs(now = Date.now()) {
|
||||
for (const [jobId, job] of backgroundJobs) {
|
||||
if (job.status === "running" || job.status === "stopping") continue;
|
||||
const updatedAt = Number(job.updatedAt) || 0;
|
||||
if (updatedAt > 0 && now - updatedAt > BACKGROUND_JOB_RETENTION_MS) {
|
||||
backgroundJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse carriage-return progress redraws to the latest frame.
|
||||
// Each \r resets the cursor to the start of the current line; the next
|
||||
// non-\r character overwrites the existing line content. A trailing \r
|
||||
// (with no following content) leaves the existing line intact, so a
|
||||
// snapshot taken between redraws still shows the latest visible frame.
|
||||
// Used at serialize time so the stored buffer can keep raw monotonic
|
||||
// offsets while polled output shows the latest frame.
|
||||
function collapseCarriageReturns(text) {
|
||||
if (!text || text.indexOf("\r") === -1) return text;
|
||||
let result = "";
|
||||
let crPending = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === "\r") {
|
||||
crPending = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\n") {
|
||||
crPending = false;
|
||||
result += ch;
|
||||
continue;
|
||||
}
|
||||
if (crPending) {
|
||||
const lastNl = result.lastIndexOf("\n");
|
||||
result = lastNl >= 0 ? result.slice(0, lastNl + 1) : "";
|
||||
crPending = false;
|
||||
}
|
||||
result += ch;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function serializeBackgroundJob(job, offset = 0) {
|
||||
if (job.status === "running" || job.status === "stopping") {
|
||||
refreshRunningJobSnapshot(job);
|
||||
}
|
||||
const stdout = job.stdout || "";
|
||||
const outputBaseOffset = job.outputBaseOffset || 0;
|
||||
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, job.totalOutputChars || 0);
|
||||
const numericOffset = Math.max(0, Number(offset) || 0);
|
||||
const relativeOffset = numericOffset <= outputBaseOffset
|
||||
? 0
|
||||
: Math.min(numericOffset - outputBaseOffset, stdout.length);
|
||||
return {
|
||||
ok: true,
|
||||
jobId: job.id,
|
||||
sessionId: job.sessionId,
|
||||
command: job.command,
|
||||
status: job.status,
|
||||
completed: job.status !== "running" && job.status !== "stopping",
|
||||
exitCode: job.exitCode,
|
||||
error: job.error,
|
||||
startedAt: job.startedAt,
|
||||
updatedAt: job.updatedAt,
|
||||
output: collapseCarriageReturns(stdout.slice(relativeOffset)),
|
||||
nextOffset: totalOutputChars,
|
||||
totalOutputChars,
|
||||
outputBaseOffset,
|
||||
outputTruncated: Boolean(job.outputTruncated),
|
||||
recommendedPollIntervalMs: DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS,
|
||||
};
|
||||
}
|
||||
|
||||
function describeActiveSessionExecution(entry) {
|
||||
if (!entry) return "another command";
|
||||
return entry.kind === "job" ? "a long-running command" : "another command";
|
||||
}
|
||||
|
||||
function getSessionBusyError(sessionId) {
|
||||
const active = activeSessionExecutions.get(sessionId);
|
||||
if (!active) return null;
|
||||
return {
|
||||
ok: false,
|
||||
error: `Session already has ${describeActiveSessionExecution(active)} in progress. Wait for it to finish or stop it before starting another command.`,
|
||||
};
|
||||
}
|
||||
|
||||
function reserveSessionExecution(sessionId, kind) {
|
||||
const existing = getSessionBusyError(sessionId);
|
||||
if (existing) return existing;
|
||||
const token = `${kind}_${Date.now().toString(36)}_${crypto.randomBytes(6).toString("hex")}`;
|
||||
activeSessionExecutions.set(sessionId, {
|
||||
kind,
|
||||
startedAt: Date.now(),
|
||||
token,
|
||||
});
|
||||
return { ok: true, token };
|
||||
}
|
||||
|
||||
function releaseSessionExecution(sessionId, token) {
|
||||
const active = activeSessionExecutions.get(sessionId);
|
||||
if (!active) return;
|
||||
if (token && active.token !== token) return;
|
||||
activeSessionExecutions.delete(sessionId);
|
||||
}
|
||||
|
||||
return {
|
||||
createBackgroundJobId,
|
||||
cancelBackgroundJobsForSession,
|
||||
cancelBackgroundJobsForTerminalSession,
|
||||
settleBackgroundJobsForTerminalSession,
|
||||
registerSftpOp,
|
||||
cancelSftpOpsForSession,
|
||||
cancelSftpOpsForTerminalSession,
|
||||
beginTerminalSessionClose,
|
||||
endTerminalSessionClose,
|
||||
cancelAllSftpOps,
|
||||
readBackgroundJobSnapshot,
|
||||
createOutputWindow,
|
||||
refreshRunningJobSnapshot,
|
||||
storeCompletedJobOutput,
|
||||
pruneCompletedBackgroundJobs,
|
||||
collapseCarriageReturns,
|
||||
serializeBackgroundJob,
|
||||
describeActiveSessionExecution,
|
||||
getSessionBusyError,
|
||||
reserveSessionExecution,
|
||||
releaseSessionExecution,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createBackgroundJobApi };
|
||||
35
electron/bridges/mcpServerBridge/builtinRpcHandlers.cjs
Normal file
35
electron/bridges/mcpServerBridge/builtinRpcHandlers.cjs
Normal file
@@ -0,0 +1,35 @@
|
||||
"use strict";
|
||||
|
||||
const { getCapabilityById } = require("../../capabilities/registry.cjs");
|
||||
|
||||
/**
|
||||
* Build rpcMethod → handler map from capability-id-keyed handlers.
|
||||
* Keeps dispatch aligned with catalog builtin RPC names.
|
||||
*
|
||||
* @param {Record<string, (params: object) => Promise<unknown> | unknown>} handlersByCapabilityId
|
||||
* @returns {{ get: (rpcMethod: string) => ((params: object) => unknown) | null, has: (rpcMethod: string) => boolean }}
|
||||
*/
|
||||
function buildBuiltinRpcHandlerRegistry(handlersByCapabilityId) {
|
||||
const byRpcMethod = new Map();
|
||||
|
||||
for (const [capabilityId, handler] of Object.entries(handlersByCapabilityId)) {
|
||||
if (typeof handler !== "function") continue;
|
||||
const capability = getCapabilityById(capabilityId);
|
||||
const rpcMethod = capability?.surfaces?.builtin?.rpcMethod;
|
||||
if (!rpcMethod) continue;
|
||||
byRpcMethod.set(rpcMethod, handler);
|
||||
}
|
||||
|
||||
return {
|
||||
get(rpcMethod) {
|
||||
return byRpcMethod.get(rpcMethod) || null;
|
||||
},
|
||||
has(rpcMethod) {
|
||||
return byRpcMethod.has(rpcMethod);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildBuiltinRpcHandlerRegistry,
|
||||
};
|
||||
67
electron/bridges/mcpServerBridge/builtinRpcHandlers.test.cjs
Normal file
67
electron/bridges/mcpServerBridge/builtinRpcHandlers.test.cjs
Normal file
@@ -0,0 +1,67 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { CAPABILITY_STATUS } = require("../../capabilities/constants.cjs");
|
||||
const { ALL_CAPABILITIES } = require("../../capabilities/catalog/index.cjs");
|
||||
const { buildBuiltinRpcHandlerRegistry } = require("./builtinRpcHandlers.cjs");
|
||||
|
||||
/** Capability ids wired in mcpServerBridge.getBuiltinRpcHandlerRegistry(). */
|
||||
const MCP_BRIDGE_BUILTIN_CAPABILITY_IDS = [
|
||||
"session.environment",
|
||||
"meta.status",
|
||||
"attachment.list",
|
||||
"attachment.read",
|
||||
"terminal.execute",
|
||||
"sftp.list",
|
||||
"sftp.read",
|
||||
"sftp.write",
|
||||
"sftp.download",
|
||||
"sftp.upload",
|
||||
"sftp.mkdir",
|
||||
"sftp.delete",
|
||||
"sftp.rename",
|
||||
"sftp.stat",
|
||||
"sftp.chmod",
|
||||
"sftp.home",
|
||||
"session.cancel",
|
||||
"terminal.start",
|
||||
"terminal.poll",
|
||||
"terminal.stop",
|
||||
];
|
||||
|
||||
test("buildBuiltinRpcHandlerRegistry maps catalog builtin rpcMethod to handlers", () => {
|
||||
const handlersByCapabilityId = Object.fromEntries(
|
||||
MCP_BRIDGE_BUILTIN_CAPABILITY_IDS.map((id) => [id, async () => ({ ok: true, id })]),
|
||||
);
|
||||
const registry = buildBuiltinRpcHandlerRegistry(handlersByCapabilityId);
|
||||
|
||||
for (const capabilityId of MCP_BRIDGE_BUILTIN_CAPABILITY_IDS) {
|
||||
const capability = ALL_CAPABILITIES.find((entry) => entry.id === capabilityId);
|
||||
assert.ok(capability, `missing catalog entry for ${capabilityId}`);
|
||||
const rpcMethod = capability.surfaces?.builtin?.rpcMethod;
|
||||
assert.ok(rpcMethod, `missing builtin rpcMethod for ${capabilityId}`);
|
||||
assert.equal(typeof registry.get(rpcMethod), "function", rpcMethod);
|
||||
}
|
||||
});
|
||||
|
||||
test("every implemented netcatty/* builtin rpc has a bridge handler", () => {
|
||||
const handlersByCapabilityId = Object.fromEntries(
|
||||
MCP_BRIDGE_BUILTIN_CAPABILITY_IDS.map((id) => [id, async () => ({ ok: true })]),
|
||||
);
|
||||
const registry = buildBuiltinRpcHandlerRegistry(handlersByCapabilityId);
|
||||
|
||||
const implementedBuiltinRpcMethods = ALL_CAPABILITIES
|
||||
.filter((capability) => capability.status === CAPABILITY_STATUS.IMPLEMENTED)
|
||||
.map((capability) => capability.surfaces?.builtin?.rpcMethod)
|
||||
.filter((rpcMethod) => typeof rpcMethod === "string" && rpcMethod.startsWith("netcatty/"));
|
||||
|
||||
const uniqueRpcMethods = [...new Set(implementedBuiltinRpcMethods)];
|
||||
for (const rpcMethod of uniqueRpcMethods) {
|
||||
assert.equal(
|
||||
registry.has(rpcMethod),
|
||||
true,
|
||||
`no handler registered for ${rpcMethod}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
179
electron/bridges/mcpServerBridge/capabilityRpcDispatch.cjs
Normal file
179
electron/bridges/mcpServerBridge/capabilityRpcDispatch.cjs
Normal file
@@ -0,0 +1,179 @@
|
||||
"use strict";
|
||||
|
||||
const { CAPABILITY_STATUS, CAPABILITY_SURFACES } = require("../../capabilities/constants.cjs");
|
||||
const { getCapabilityByRpcMethod } = require("../../capabilities/registry.cjs");
|
||||
const { getMcpToolNameForRpcMethod } = require("../../capabilities/adapters/mcpAdapter.cjs");
|
||||
const { createVaultService } = require("../../capabilities/services/vaultService.cjs");
|
||||
const { createPortForwardService } = require("../../capabilities/services/portforwardService.cjs");
|
||||
const { createSessionService } = require("../../capabilities/services/sessionService.cjs");
|
||||
|
||||
const UNROUTED = Symbol("capability-rpc-unrouted");
|
||||
|
||||
const SERVICE_BINDINGS = Object.freeze({
|
||||
"vault.host.get": { domain: "vault", method: "getHost" },
|
||||
"vault.host.list": { domain: "vault", method: "listHosts" },
|
||||
"vault.host.open": { domain: "vault", method: "openHost" },
|
||||
"vault.hosts.create": { domain: "vault", method: "createHosts" },
|
||||
"vault.host.update": { domain: "vault", method: "updateHost" },
|
||||
"vault.host.delete": { domain: "vault", method: "deleteHost" },
|
||||
"vault.host.import": { domain: "vault", method: "importHosts" },
|
||||
"vault.host.notes.get": { domain: "vault", method: "getHostNotes" },
|
||||
"vault.host.notes.set": { domain: "vault", method: "setHostNotes" },
|
||||
"vault.note.list": { domain: "vault", method: "listNotes" },
|
||||
"vault.note.get": { domain: "vault", method: "getNote" },
|
||||
"vault.note.create": { domain: "vault", method: "createNote" },
|
||||
"vault.note.update": { domain: "vault", method: "updateNote" },
|
||||
"vault.note.delete": { domain: "vault", method: "deleteNote" },
|
||||
"vault.identity.list": { domain: "vault", method: "listIdentities" },
|
||||
"vault.proxyProfile.list": { domain: "vault", method: "listProxyProfiles" },
|
||||
"vault.group.list": { domain: "vault", method: "listGroups" },
|
||||
"vault.group.create": { domain: "vault", method: "createGroup" },
|
||||
"vault.group.update": { domain: "vault", method: "updateGroup" },
|
||||
"vault.group.delete": { domain: "vault", method: "deleteGroup" },
|
||||
"vault.snippets.list": { domain: "vault", method: "listSnippets" },
|
||||
"vault.snippets.get": { domain: "vault", method: "getSnippet" },
|
||||
"vault.snippets.run": { domain: "vault", method: "runSnippet" },
|
||||
"vault.snippets.create": { domain: "vault", method: "createSnippet" },
|
||||
"vault.snippets.update": { domain: "vault", method: "updateSnippet" },
|
||||
"vault.snippets.delete": { domain: "vault", method: "deleteSnippet" },
|
||||
"vault.scripts.list": { domain: "vault", method: "listScripts" },
|
||||
"vault.scripts.get": { domain: "vault", method: "getScript" },
|
||||
"vault.scripts.create": { domain: "vault", method: "createScript" },
|
||||
"vault.scripts.update": { domain: "vault", method: "updateScript" },
|
||||
"vault.scripts.delete": { domain: "vault", method: "deleteScript" },
|
||||
"vault.scripts.run": { domain: "vault", method: "runScript" },
|
||||
"vault.scripts.reference": { domain: "vault", method: "getScriptReference" },
|
||||
"vault.scripts.runs.list": { domain: "vault", method: "listScriptRuns" },
|
||||
"vault.scripts.run.stop": { domain: "vault", method: "stopScriptRun" },
|
||||
"vault.scripts.run.pause": { domain: "vault", method: "pauseScriptRun" },
|
||||
"vault.scripts.run.resume": { domain: "vault", method: "resumeScriptRun" },
|
||||
"vault.scripts.targets.set": { domain: "vault", method: "setScriptTargets" },
|
||||
"vault.host.connectScripts.list": { domain: "vault", method: "listHostConnectScripts" },
|
||||
"vault.host.connectScripts.set": { domain: "vault", method: "setHostConnectScripts" },
|
||||
"portforward.rules.list": { domain: "portforward", method: "listRules" },
|
||||
"portforward.rules.create": { domain: "portforward", method: "createRule" },
|
||||
"portforward.rules.update": { domain: "portforward", method: "updateRule" },
|
||||
"portforward.rules.duplicate": { domain: "portforward", method: "duplicateRule" },
|
||||
"portforward.rules.delete": { domain: "portforward", method: "deleteRule" },
|
||||
"portforward.tunnels.list": { domain: "portforward", method: "listTunnels" },
|
||||
"portforward.start": { domain: "portforward", method: "start" },
|
||||
"portforward.stop": { domain: "portforward", method: "stop" },
|
||||
"session.close": { domain: "session", method: "close" },
|
||||
});
|
||||
|
||||
function resolveCapabilitySurface(rpcMethod) {
|
||||
for (const surface of [
|
||||
CAPABILITY_SURFACES.PUBLIC,
|
||||
CAPABILITY_SURFACES.GLOBAL,
|
||||
CAPABILITY_SURFACES.BUILTIN,
|
||||
]) {
|
||||
if (rpcMethod.startsWith("netcatty/") && surface !== CAPABILITY_SURFACES.BUILTIN) {
|
||||
continue;
|
||||
}
|
||||
const capability = getCapabilityByRpcMethod(rpcMethod, surface);
|
||||
if (capability) {
|
||||
return { capability, surface };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createCapabilityRpcDispatcher(deps) {
|
||||
const {
|
||||
invokeVaultAgent,
|
||||
evaluatePermissionWithGrants,
|
||||
isChatSessionCancelled,
|
||||
requestApprovalFromRenderer,
|
||||
USER_DENIED_MESSAGE,
|
||||
} = deps;
|
||||
|
||||
const vaultService = createVaultService({ invokeVaultAgent });
|
||||
const portforwardService = createPortForwardService({
|
||||
invokeVaultAgent,
|
||||
listPortForwards: deps.listPortForwards,
|
||||
});
|
||||
const sessionService = deps.sessionService || createSessionService({
|
||||
invokeSessionAgent: invokeVaultAgent,
|
||||
validateClose: deps.validateSessionClose,
|
||||
beforeClose: deps.beforeSessionClose,
|
||||
afterClose: deps.afterSessionClose,
|
||||
onClosed: deps.onSessionClosed,
|
||||
});
|
||||
const services = {
|
||||
vault: vaultService,
|
||||
portforward: portforwardService,
|
||||
session: sessionService,
|
||||
};
|
||||
|
||||
return async function dispatchCapabilityRpc(rpcMethod, params = {}) {
|
||||
if (typeof rpcMethod !== "string" || rpcMethod.startsWith("netcatty/")) {
|
||||
return UNROUTED;
|
||||
}
|
||||
|
||||
const resolved = resolveCapabilitySurface(rpcMethod);
|
||||
if (!resolved) {
|
||||
return UNROUTED;
|
||||
}
|
||||
|
||||
const { capability, surface } = resolved;
|
||||
if (capability.status !== CAPABILITY_STATUS.IMPLEMENTED) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "CAPABILITY_NOT_IMPLEMENTED",
|
||||
error: `Capability "${capability.id}" is not implemented yet.`,
|
||||
};
|
||||
}
|
||||
|
||||
const binding = SERVICE_BINDINGS[capability.id];
|
||||
if (!binding) {
|
||||
return UNROUTED;
|
||||
}
|
||||
|
||||
const permission = evaluatePermissionWithGrants({
|
||||
rpcMethod,
|
||||
surface,
|
||||
permissionMode: deps.permissionMode,
|
||||
params,
|
||||
context: {
|
||||
chatSessionCancelled: isChatSessionCancelled(params?.chatSessionId),
|
||||
},
|
||||
}, deps.permissionGrantsSnapshot);
|
||||
|
||||
if (!permission.allowed) {
|
||||
return { ok: false, error: permission.error };
|
||||
}
|
||||
|
||||
if (permission.requiresApproval) {
|
||||
const { chatSessionId, ...toolArgs } = params || {};
|
||||
const toolName = getMcpToolNameForRpcMethod(rpcMethod, surface) || capability.id;
|
||||
const approved = await requestApprovalFromRenderer(toolName, toolArgs, chatSessionId);
|
||||
if (!approved) {
|
||||
return { ok: false, error: USER_DENIED_MESSAGE };
|
||||
}
|
||||
}
|
||||
|
||||
const service = services[binding.domain];
|
||||
const handler = service?.[binding.method];
|
||||
if (typeof handler !== "function") {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Capability handler "${capability.id}" is unavailable.`,
|
||||
};
|
||||
}
|
||||
|
||||
const hostOpenGeneration = capability.id === "vault.host.open"
|
||||
? deps.captureHostOpenScope?.(params?.chatSessionId)
|
||||
: null;
|
||||
const result = await handler(params);
|
||||
if (capability.id === "vault.host.open" && result?.ok !== false && result?.sessionId) {
|
||||
await deps.onHostOpened?.(params?.chatSessionId, result.sessionId, hostOpenGeneration, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UNROUTED,
|
||||
createCapabilityRpcDispatcher,
|
||||
SERVICE_BINDINGS,
|
||||
};
|
||||
321
electron/bridges/mcpServerBridge/capabilityRpcDispatch.test.cjs
Normal file
321
electron/bridges/mcpServerBridge/capabilityRpcDispatch.test.cjs
Normal file
@@ -0,0 +1,321 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { createCapabilityRpcDispatcher, UNROUTED } = require("./capabilityRpcDispatch.cjs");
|
||||
const { CAPABILITY_SURFACES, PERMISSION_MODES } = require("../../capabilities/constants.cjs");
|
||||
|
||||
function createTestDispatcher(overrides = {}) {
|
||||
const invokeVaultAgent = overrides.invokeVaultAgent || (async (op, params) => ({
|
||||
ok: true,
|
||||
op,
|
||||
params,
|
||||
}));
|
||||
const requestApprovalFromRenderer = overrides.requestApprovalFromRenderer
|
||||
|| (async () => true);
|
||||
|
||||
return createCapabilityRpcDispatcher({
|
||||
invokeVaultAgent,
|
||||
evaluatePermissionWithGrants: overrides.evaluatePermissionWithGrants || ((input, grants) => ({
|
||||
allowed: true,
|
||||
requiresApproval: false,
|
||||
grants,
|
||||
...input,
|
||||
})),
|
||||
permissionMode: overrides.permissionMode || PERMISSION_MODES.CONFIRM,
|
||||
permissionGrantsSnapshot: [],
|
||||
isChatSessionCancelled: () => false,
|
||||
requestApprovalFromRenderer,
|
||||
USER_DENIED_MESSAGE: "User denied the operation.",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test("dispatchCapabilityRpc returns UNROUTED for netcatty builtin methods", async () => {
|
||||
const dispatch = createTestDispatcher();
|
||||
const result = await dispatch("netcatty/exec", { chatSessionId: "chat-1" });
|
||||
assert.equal(result, UNROUTED);
|
||||
});
|
||||
|
||||
test("dispatchCapabilityRpc routes vault host notes get to vault service", async () => {
|
||||
let invokedOp = null;
|
||||
const dispatch = createTestDispatcher({
|
||||
invokeVaultAgent: async (op, params) => {
|
||||
invokedOp = op;
|
||||
return { ok: true, hostId: params.hostId, notes: "notes" };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dispatch("vault/host/notes/get", { hostId: "host-1" });
|
||||
assert.equal(invokedOp, "host.notes.get");
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.notes, "notes");
|
||||
});
|
||||
|
||||
test("dispatchCapabilityRpc routes public vault host notes set through approval", async () => {
|
||||
const approvalCalls = [];
|
||||
const dispatch = createTestDispatcher({
|
||||
evaluatePermissionWithGrants: () => ({
|
||||
allowed: true,
|
||||
requiresApproval: true,
|
||||
}),
|
||||
requestApprovalFromRenderer: async (toolName, args, chatSessionId) => {
|
||||
approvalCalls.push({ toolName, args, chatSessionId });
|
||||
return true;
|
||||
},
|
||||
invokeVaultAgent: async (op, params) => ({
|
||||
ok: true,
|
||||
op,
|
||||
hostId: params.hostId,
|
||||
notes: params.notes,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await dispatch("public/vault/hostNotes/set", {
|
||||
chatSessionId: "chat-1",
|
||||
hostId: "host-1",
|
||||
notes: "updated",
|
||||
});
|
||||
|
||||
assert.equal(approvalCalls.length, 1);
|
||||
assert.equal(approvalCalls[0].toolName, "host_notes_set");
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.notes, "updated");
|
||||
});
|
||||
|
||||
test("dispatchCapabilityRpc denies public vault host notes set when approval rejected", async () => {
|
||||
const dispatch = createTestDispatcher({
|
||||
evaluatePermissionWithGrants: () => ({
|
||||
allowed: true,
|
||||
requiresApproval: true,
|
||||
}),
|
||||
requestApprovalFromRenderer: async () => false,
|
||||
});
|
||||
|
||||
const result = await dispatch("public/vault/hostNotes/set", {
|
||||
chatSessionId: "chat-1",
|
||||
hostId: "host-1",
|
||||
notes: "updated",
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /denied/i);
|
||||
});
|
||||
|
||||
test("dispatchCapabilityRpc closes an owned session through the renderer bridge", async () => {
|
||||
const calls = [];
|
||||
const closed = [];
|
||||
const lifecycle = [];
|
||||
const dispatch = createTestDispatcher({
|
||||
validateSessionClose: (params) => {
|
||||
assert.equal(params.chatSessionId, "chat-1");
|
||||
assert.equal(params.sessionId, "session-1");
|
||||
return { ok: true };
|
||||
},
|
||||
beforeSessionClose: async () => {
|
||||
await Promise.resolve();
|
||||
lifecycle.push("sftp-clean");
|
||||
},
|
||||
afterSessionClose: async () => {
|
||||
lifecycle.push("close-finished");
|
||||
},
|
||||
onSessionClosed: async (sessionId) => {
|
||||
await Promise.resolve();
|
||||
lifecycle.push("session-jobs-settled");
|
||||
closed.push(sessionId);
|
||||
},
|
||||
invokeVaultAgent: async (op, params) => {
|
||||
lifecycle.push("session-close");
|
||||
calls.push({ op, params });
|
||||
return { ok: true, sessionId: params.sessionId, status: "closed" };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dispatch("public/session/close", {
|
||||
chatSessionId: "chat-1",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, [{ op: "session.close", params: { sessionId: "session-1" } }]);
|
||||
assert.deepEqual(closed, ["session-1"]);
|
||||
assert.deepEqual(lifecycle, ["sftp-clean", "session-close", "session-jobs-settled", "close-finished"]);
|
||||
});
|
||||
|
||||
test("dispatchCapabilityRpc refuses to close a session outside ownership", async () => {
|
||||
let invoked = false;
|
||||
const dispatch = createTestDispatcher({
|
||||
validateSessionClose: () => ({ ok: false, error: "not owned" }),
|
||||
invokeVaultAgent: async () => {
|
||||
invoked = true;
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dispatch("public/session/close", {
|
||||
chatSessionId: "chat-1",
|
||||
sessionId: "session-2",
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(invoked, false);
|
||||
});
|
||||
|
||||
test("dispatchCapabilityRpc preserves the host-open scope generation across the async bridge", async () => {
|
||||
const registrations = [];
|
||||
const dispatch = createTestDispatcher({
|
||||
captureHostOpenScope: (chatSessionId) => {
|
||||
assert.equal(chatSessionId, "chat-1");
|
||||
return 7;
|
||||
},
|
||||
onHostOpened: (chatSessionId, sessionId, generation, result) => {
|
||||
registrations.push({ chatSessionId, sessionId, generation, result });
|
||||
},
|
||||
invokeVaultAgent: async () => ({ ok: true, sessionId: "session-1" }),
|
||||
});
|
||||
|
||||
await dispatch("public/vault/hosts/open", { chatSessionId: "chat-1", hostId: "host-1" });
|
||||
|
||||
assert.deepEqual(registrations, [{
|
||||
chatSessionId: "chat-1",
|
||||
sessionId: "session-1",
|
||||
generation: 7,
|
||||
result: { ok: true, sessionId: "session-1" },
|
||||
}]);
|
||||
});
|
||||
|
||||
test("dispatchCapabilityRpc routes vault hosts create to vault service", async () => {
|
||||
let invokedOp = null;
|
||||
const dispatch = createTestDispatcher({
|
||||
invokeVaultAgent: async (op, params) => {
|
||||
invokedOp = op;
|
||||
return { ok: true, addedCount: 1, previewHosts: [] , params };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dispatch("vault/hosts/create", {
|
||||
hosts: JSON.stringify([{ hostname: "10.2.0.209", username: "root" }]),
|
||||
dryRun: "true",
|
||||
});
|
||||
assert.equal(invokedOp, "hosts.create");
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
test("dispatchCapabilityRpc routes vault host update to vault service", async () => {
|
||||
let invokedOp = null;
|
||||
let invokedParams = null;
|
||||
const dispatch = createTestDispatcher({
|
||||
invokeVaultAgent: async (op, params) => {
|
||||
invokedOp = op;
|
||||
invokedParams = params;
|
||||
return { ok: true, hostId: params.hostId };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dispatch("vault/hosts/update", {
|
||||
hostId: "host-1",
|
||||
label: "updated",
|
||||
});
|
||||
assert.equal(invokedOp, "host.update");
|
||||
assert.equal(invokedParams.label, "updated");
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
test("dispatchCapabilityRpc routes vault host delete to vault service", async () => {
|
||||
let invokedOp = null;
|
||||
const dispatch = createTestDispatcher({
|
||||
invokeVaultAgent: async (op, params) => {
|
||||
invokedOp = op;
|
||||
return { ok: true, hostId: params.hostId };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dispatch("vault/hosts/delete", { hostId: "host-1" });
|
||||
assert.equal(invokedOp, "host.delete");
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
test("dispatchCapabilityRpc routes vault hosts import to vault service", async () => {
|
||||
let invokedOp = null;
|
||||
const dispatch = createTestDispatcher({
|
||||
invokeVaultAgent: async (op) => {
|
||||
invokedOp = op;
|
||||
return { ok: true, addedCount: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dispatch("vault/hosts/import", {
|
||||
format: "csv",
|
||||
text: "hostname,username\n10.0.0.1,root\n",
|
||||
dryRun: "true",
|
||||
});
|
||||
assert.equal(invokedOp, "host.import");
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
test("dispatchCapabilityRpc routes portforward start to portforward service", async () => {
|
||||
let invokedOp = null;
|
||||
const dispatch = createTestDispatcher({
|
||||
invokeVaultAgent: async (op, params) => {
|
||||
invokedOp = op;
|
||||
return { ok: true, ruleId: params.ruleId, status: "active" };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dispatch("portforward/start", {
|
||||
chatSessionId: "chat-1",
|
||||
ruleId: "rule-1",
|
||||
});
|
||||
assert.equal(invokedOp, "portforward.start");
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.ruleId, "rule-1");
|
||||
});
|
||||
|
||||
test("dispatchCapabilityRpc lists port forwards from the configured runtime", async () => {
|
||||
const calls = [];
|
||||
const dispatch = createTestDispatcher({
|
||||
listPortForwards: async () => {
|
||||
calls.push("list");
|
||||
return [{ tunnelId: "worker-pf", status: "active" }];
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dispatch("portforward/tunnels/list", {});
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
tunnels: [{ tunnelId: "worker-pf", status: "active" }],
|
||||
});
|
||||
assert.deepEqual(calls, ["list"]);
|
||||
});
|
||||
|
||||
test("dispatchCapabilityRpc reads permissionMode from deps on each call", async () => {
|
||||
const seenModes = [];
|
||||
const mutableDeps = { permissionMode: PERMISSION_MODES.CONFIRM };
|
||||
const liveDispatch = createCapabilityRpcDispatcher({
|
||||
invokeVaultAgent: async () => ({ ok: true }),
|
||||
evaluatePermissionWithGrants: (input) => {
|
||||
seenModes.push(input.permissionMode);
|
||||
return { allowed: true, requiresApproval: false };
|
||||
},
|
||||
get permissionMode() {
|
||||
return mutableDeps.permissionMode;
|
||||
},
|
||||
permissionGrantsSnapshot: [],
|
||||
isChatSessionCancelled: () => false,
|
||||
requestApprovalFromRenderer: async () => true,
|
||||
USER_DENIED_MESSAGE: "User denied the operation.",
|
||||
});
|
||||
|
||||
await liveDispatch("vault/host/get", { hostId: "host-1" });
|
||||
mutableDeps.permissionMode = PERMISSION_MODES.AUTO;
|
||||
await liveDispatch("vault/host/get", { hostId: "host-2" });
|
||||
|
||||
assert.deepEqual(seenModes, [PERMISSION_MODES.CONFIRM, PERMISSION_MODES.AUTO]);
|
||||
});
|
||||
|
||||
test("implemented vault capabilities do not return CAPABILITY_NOT_IMPLEMENTED", async () => {
|
||||
const dispatch = createTestDispatcher();
|
||||
const result = await dispatch("vault/host/get", { hostId: "host-1" });
|
||||
assert.notEqual(result.code, "CAPABILITY_NOT_IMPLEMENTED");
|
||||
});
|
||||
91
electron/bridges/mcpServerBridge/configAndCleanup.cjs
Normal file
91
electron/bridges/mcpServerBridge/configAndCleanup.cjs
Normal file
@@ -0,0 +1,91 @@
|
||||
/* eslint-disable no-undef */
|
||||
function createConfigAndCleanupApi(ctx) {
|
||||
with (ctx) {
|
||||
function resolveMcpServerRuntimeCommand() {
|
||||
const runtimeCommand = process.execPath;
|
||||
const runtimeEnv = [];
|
||||
|
||||
if (runtimeCommand && existsSync(runtimeCommand)) {
|
||||
const basename = path.basename(runtimeCommand).toLowerCase();
|
||||
const isNodeBinary = basename === "node" || basename.startsWith("node.");
|
||||
if (!isNodeBinary) {
|
||||
runtimeEnv.push({ name: "ELECTRON_RUN_AS_NODE", value: "1" });
|
||||
}
|
||||
return { command: runtimeCommand, env: runtimeEnv };
|
||||
}
|
||||
|
||||
return { command: "node", env: runtimeEnv };
|
||||
}
|
||||
|
||||
function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
|
||||
// Use provided scoped IDs, or resolve them from chatSessionId.
|
||||
const effectiveIds = (scopedSessionIds && scopedSessionIds.length > 0)
|
||||
? scopedSessionIds
|
||||
: getScopedSessionIds(chatSessionId);
|
||||
|
||||
const runtimePath = toUnpackedAsarPath(
|
||||
path.join(__dirname, "..", "mcp", "netcatty-mcp-server.cjs"),
|
||||
);
|
||||
const runtime = resolveMcpServerRuntimeCommand();
|
||||
|
||||
const env = [
|
||||
...runtime.env,
|
||||
{ name: "NETCATTY_MCP_PORT", value: String(port) },
|
||||
];
|
||||
|
||||
if (authToken) {
|
||||
env.push({ name: "NETCATTY_MCP_TOKEN", value: authToken });
|
||||
}
|
||||
if (DEBUG_MCP) {
|
||||
env.push({ name: "NETCATTY_MCP_DEBUG", value: "1" });
|
||||
}
|
||||
|
||||
// When chatSessionId is present, the MCP subprocess resolves scope dynamically
|
||||
// through main-process metadata, so avoid freezing session IDs at spawn time.
|
||||
if (!chatSessionId && effectiveIds && effectiveIds.length > 0) {
|
||||
env.push({ name: "NETCATTY_MCP_SESSION_IDS", value: effectiveIds.join(",") });
|
||||
}
|
||||
|
||||
// Pass chatSessionId so MCP server can scope getContext responses
|
||||
if (chatSessionId) {
|
||||
env.push({ name: "NETCATTY_MCP_CHAT_SESSION_ID", value: chatSessionId });
|
||||
}
|
||||
|
||||
// Pass permission mode so MCP server can enforce it locally (defense-in-depth)
|
||||
env.push({ name: "NETCATTY_MCP_PERMISSION_MODE", value: permissionMode });
|
||||
|
||||
return {
|
||||
name: "netcatty-remote-hosts",
|
||||
type: "stdio",
|
||||
command: runtime.command,
|
||||
args: [runtimePath],
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Cleanup ──
|
||||
|
||||
async function cleanupScopedMetadata(chatSessionId) {
|
||||
if (chatSessionId) {
|
||||
scopedMetadata.delete(chatSessionId);
|
||||
scopedAttachments.delete(chatSessionId);
|
||||
preserveIdleSessionCleanup?.(chatSessionId);
|
||||
clearOpenedSessionScope?.(chatSessionId);
|
||||
cancelledChatSessions.delete(chatSessionId);
|
||||
cancelBackgroundJobsForSession(chatSessionId);
|
||||
cancelWorkerBackgroundJobsForSession(chatSessionId);
|
||||
// Resolve any in-flight approval requests so dispatch()'s finally block
|
||||
// releases its pendingSessionWriteApprovals entry. Without this, a chat
|
||||
// deleted while an approval was pending would leave the per-session
|
||||
// write lock held until the approval timeout expires.
|
||||
clearPendingApprovals(chatSessionId);
|
||||
await cancelSftpOpsForSession(chatSessionId);
|
||||
sftpBridge.clearSftpEncodingStateByPrefix?.(`chat:${chatSessionId}:session:`);
|
||||
}
|
||||
}
|
||||
|
||||
return { resolveMcpServerRuntimeCommand, buildMcpServerConfig, cleanupScopedMetadata };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createConfigAndCleanupApi };
|
||||
472
electron/bridges/mcpServerBridge/execHandlers.cjs
Normal file
472
electron/bridges/mcpServerBridge/execHandlers.cjs
Normal file
@@ -0,0 +1,472 @@
|
||||
/* eslint-disable no-undef */
|
||||
// Module-level require: code inside createExecHandlerApi runs under `with (ctx)`
|
||||
// where bare `require` resolves to ctx.require (based in electron/bridges/).
|
||||
const {
|
||||
ensureSessionShellKind,
|
||||
ensureSessionShellKindForExec,
|
||||
} = require("../ai/sessionShellKind.cjs");
|
||||
|
||||
function createExecHandlerApi(ctx) {
|
||||
with (ctx) {
|
||||
function resolveExecContext(params) {
|
||||
const { sessionId, command } = params;
|
||||
debugLog("handleExec:start", { sessionId, command, chatSessionId: params?.chatSessionId });
|
||||
if (!sessionId || !command) throw new Error("sessionId and command are required");
|
||||
if (typeof command !== 'string' || !command.trim()) {
|
||||
return { ok: false, error: 'Invalid command', exitCode: 1 };
|
||||
}
|
||||
|
||||
const session = sessions?.get(sessionId);
|
||||
debugLog("handleExec:sessionLookup", {
|
||||
sessionId,
|
||||
found: Boolean(session),
|
||||
protocol: session?.protocol || session?.type || null,
|
||||
shellKind: session?.shellKind || null,
|
||||
});
|
||||
if (!session) return { ok: false, error: "Session not found" };
|
||||
|
||||
// Look up device type from metadata (set by renderer from Host.deviceType).
|
||||
const chatSessionId = params?.chatSessionId || null;
|
||||
const meta = getSessionMeta(sessionId, chatSessionId) || {};
|
||||
// Mosh sessions use a shell-backed PTY and cannot connect to vendor CLIs,
|
||||
// so network device mode only applies to SSH and serial sessions.
|
||||
// Prefer session.protocol (runtime truth) over meta.protocol (renderer hint)
|
||||
// because Mosh tabs report as protocol:"ssh" in metadata but "mosh" in session.
|
||||
const sessionProtocol = session.protocol || session.type || meta.protocol || "";
|
||||
const isSshOrSerial = sessionProtocol === "ssh" || sessionProtocol === "serial";
|
||||
const isNetworkDevice = (meta.deviceType === "network" && isSshOrSerial) || sessionProtocol === "serial";
|
||||
|
||||
// The blocklist targets shell-specific patterns (rm -rf, eval, $(), etc.) that
|
||||
// are meaningless on network device CLIs. Serial sessions skip the check because
|
||||
// commands like "shutdown" (disable an interface) are routine on Cisco/Huawei.
|
||||
//
|
||||
// Design note: the serial protocol is explicitly chosen by the user in the UI
|
||||
// for network devices / embedded systems. While startSerialSession technically
|
||||
// supports PTY devices, users connecting to a Linux/BusyBox shell should use
|
||||
// the "local" protocol (which goes through the normal shell path with blocklist).
|
||||
// Additionally, execViaRawPty sends commands without shell wrapping, so shell
|
||||
// metacharacters in blocklist patterns (eval, $(), backticks, pipes) cannot
|
||||
// actually be interpreted even if sent to a serial-connected shell.
|
||||
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "AI execution is not supported for this local shell executable. Configure the local terminal to use bash/zsh/sh, fish, PowerShell/pwsh, or cmd.exe.",
|
||||
};
|
||||
}
|
||||
|
||||
const sshClient = session.conn || session.sshClient;
|
||||
const ptyStream = session.stream || session.pty || session.proc;
|
||||
return {
|
||||
ok: true,
|
||||
context: {
|
||||
sessionId,
|
||||
command,
|
||||
session,
|
||||
chatSessionId,
|
||||
sessionProtocol,
|
||||
isNetworkDevice,
|
||||
sshClient,
|
||||
ptyStream,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function handleExec(params) {
|
||||
const resolved = resolveExecContext(params);
|
||||
if (!resolved.ok) return resolved;
|
||||
const {
|
||||
sessionId,
|
||||
command,
|
||||
session,
|
||||
chatSessionId,
|
||||
sessionProtocol,
|
||||
isNetworkDevice,
|
||||
sshClient,
|
||||
ptyStream,
|
||||
} = resolved.context;
|
||||
const reservation = reserveSessionExecution(sessionId, "exec");
|
||||
if (!reservation.ok) return reservation;
|
||||
const sessionToken = reservation.token;
|
||||
const executionLock = beginChatExecution(chatSessionId, sessionId, command);
|
||||
if (!executionLock.ok) {
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
return {
|
||||
ok: false,
|
||||
code: "COMMAND_ALREADY_RUNNING",
|
||||
error: `Another Netcatty command is already running for chat session "${chatSessionId}". Wait for it to finish before starting a new exec.`,
|
||||
activeCommand: executionLock.active.command,
|
||||
activeSessionId: executionLock.active.sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
const runExecution = (factory) => {
|
||||
try {
|
||||
return Promise.resolve(factory()).finally(() => {
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
executionLock.release();
|
||||
});
|
||||
} catch (err) {
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
executionLock.release();
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
};
|
||||
|
||||
// Network devices (switches/routers) connected via SSH: use raw execution.
|
||||
// Their vendor CLIs (Huawei VRP, Cisco IOS, etc.) don't run a POSIX shell,
|
||||
// so shell-wrapped commands with markers would fail. Raw mode sends commands
|
||||
// as-is with idle-timeout completion detection — same as serial sessions.
|
||||
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
|
||||
return runExecution(() => execViaRawPty(ptyStream, command, {
|
||||
timeoutMs: commandTimeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId: params?.chatSessionId,
|
||||
encoding: "utf8", // SSH PTY streams use UTF-8, not latin1
|
||||
}));
|
||||
}
|
||||
|
||||
// Prefer the interactive PTY so the user sees command/output in-session.
|
||||
if (ptyStream && typeof ptyStream.write === "function") {
|
||||
// Probe remote login shell once when shellKind is unset so fish
|
||||
// sessions get the fish wrapper instead of the posix default (#1854).
|
||||
// Cancellable: Stop during the probe must not still type the command.
|
||||
return runExecution(async () => {
|
||||
const probed = await ensureSessionShellKindForExec(session, {
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session));
|
||||
if (safety.blocked) {
|
||||
debugLog("handleExec:blocklisted", { sessionId, matchedPattern: safety.matchedPattern });
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
return execViaPty(ptyStream, command, {
|
||||
trackForCancellation: activePtyExecs,
|
||||
timeoutMs: commandTimeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }),
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
|
||||
chatSessionId,
|
||||
// MCP callers have terminal_start as a fallback for long commands,
|
||||
// so enforce a hard wall-clock timeout here to match the MCP budget.
|
||||
enforceWallTimeout: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Network devices require an interactive PTY for raw command execution.
|
||||
// If we got here, ptyStream wasn't writable — there's no usable channel.
|
||||
if (isNetworkDevice) {
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
executionLock.release();
|
||||
return { ok: false, error: "Network device session has no writable PTY stream for command execution" };
|
||||
}
|
||||
|
||||
// Fallback: SSH exec channel (invisible to terminal).
|
||||
// At this point ptyStream is not writable (already returned above if it was).
|
||||
if (sshClient && typeof sshClient.exec === "function") {
|
||||
return runExecution(async () => {
|
||||
const probed = await ensureSessionShellKindForExec(session, {
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session));
|
||||
if (safety.blocked) {
|
||||
debugLog("handleExec:blocklisted", { sessionId, matchedPattern: safety.matchedPattern });
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
return execViaChannel(sshClient, command, {
|
||||
timeoutMs: commandTimeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
// Pass chatSessionId so cancelPtyExecsForSession can interrupt this
|
||||
// exec channel when the originating SDK agent run is stopped.
|
||||
chatSessionId: params?.chatSessionId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Serial port: raw command execution (no shell wrapping)
|
||||
if (session.protocol === "serial" && session.serialPort && typeof session.serialPort.write === "function") {
|
||||
if (session.ymodemActive || session.zmodemSentry?.isActive?.()) {
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
executionLock.release();
|
||||
return { ok: false, error: "Serial file transfer is already in progress" };
|
||||
}
|
||||
return runExecution(() => execViaRawPty(session.serialPort, command, {
|
||||
timeoutMs: commandTimeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId: params?.chatSessionId,
|
||||
encoding: session.serialEncoding || "utf8",
|
||||
}));
|
||||
}
|
||||
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
executionLock.release();
|
||||
return { ok: false, error: "Session does not support command execution" };
|
||||
}
|
||||
|
||||
function handleJobStart(params) {
|
||||
const resolved = resolveExecContext(params);
|
||||
if (!resolved.ok) return resolved;
|
||||
const {
|
||||
sessionId,
|
||||
command,
|
||||
session,
|
||||
chatSessionId,
|
||||
isNetworkDevice,
|
||||
sessionProtocol,
|
||||
ptyStream,
|
||||
} = resolved.context;
|
||||
|
||||
if (isNetworkDevice || sessionProtocol === "serial") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Background execution currently supports shell-backed PTY sessions only.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!ptyStream || typeof ptyStream.write !== "function") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Background execution requires a writable PTY-backed terminal session.",
|
||||
};
|
||||
}
|
||||
|
||||
const reservation = reserveSessionExecution(sessionId, "job");
|
||||
if (!reservation.ok) return reservation;
|
||||
const sessionToken = reservation.token;
|
||||
|
||||
const jobId = createBackgroundJobId();
|
||||
const timeoutMs = Math.max(commandTimeoutMs, DEFAULT_BACKGROUND_JOB_TIMEOUT_MS);
|
||||
const startedAt = Date.now();
|
||||
|
||||
// Register the job *before* the shell-kind probe so chat-delete /
|
||||
// cancelBackgroundJobsForSession can see it while we await. Without
|
||||
// this, the first terminal_start on an unprobed remote session has no
|
||||
// backgroundJobs entry during the probe and still starts the PTY job
|
||||
// after cancel (Codex P2 on #2061). Not registered in activePtyExecs —
|
||||
// terminal_start is designed to survive SDK Stop; only chat cancel
|
||||
// (which walks backgroundJobs) should abort a pending start.
|
||||
let probeCancelRequested = false;
|
||||
const job = {
|
||||
id: jobId,
|
||||
sessionId,
|
||||
chatSessionId: chatSessionId || null,
|
||||
command,
|
||||
status: "running",
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
exitCode: null,
|
||||
error: null,
|
||||
stdout: "",
|
||||
outputBaseOffset: 0,
|
||||
totalOutputChars: 0,
|
||||
outputTruncated: false,
|
||||
pendingShellProbe: true,
|
||||
handle: {
|
||||
cancel: () => {
|
||||
probeCancelRequested = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
backgroundJobs.set(jobId, job);
|
||||
|
||||
// Probe so fish login shells are not mis-wrapped as posix (#1854).
|
||||
return Promise.resolve(ensureSessionShellKind(session)).then(() => {
|
||||
if (probeCancelRequested || job.status === "stopping") {
|
||||
job.status = "cancelled";
|
||||
job.error = "Cancelled";
|
||||
job.updatedAt = Date.now();
|
||||
job.pendingShellProbe = false;
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
return {
|
||||
ok: false,
|
||||
error: "Cancelled",
|
||||
jobId,
|
||||
sessionId,
|
||||
status: "cancelled",
|
||||
};
|
||||
}
|
||||
|
||||
const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session));
|
||||
if (safety.blocked) {
|
||||
job.status = "failed";
|
||||
job.error = `Command blocked by safety policy. Pattern: ${safety.matchedPattern}`;
|
||||
job.updatedAt = Date.now();
|
||||
job.pendingShellProbe = false;
|
||||
backgroundJobs.delete(jobId);
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
return { ok: false, error: job.error };
|
||||
}
|
||||
|
||||
let handle;
|
||||
try {
|
||||
handle = startPtyJob(ptyStream, command, {
|
||||
// Intentionally do NOT register in activePtyExecs: terminal_start jobs
|
||||
// are designed to survive SDK agent "Stop" so the model can stop polling
|
||||
// without aborting a long-running build/scan/log stream. The job is
|
||||
// managed via terminal_stop and the per-session execution lock.
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }),
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
|
||||
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
|
||||
normalizeFinalOutput: false,
|
||||
});
|
||||
} catch (err) {
|
||||
job.status = "failed";
|
||||
job.error = err?.message || String(err);
|
||||
job.updatedAt = Date.now();
|
||||
job.pendingShellProbe = false;
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
|
||||
job.handle = handle;
|
||||
job.pendingShellProbe = false;
|
||||
|
||||
handle.resultPromise.then((result) => {
|
||||
job.updatedAt = Date.now();
|
||||
job.exitCode = result.exitCode ?? null;
|
||||
storeCompletedJobOutput(job, result.stdout || "", result);
|
||||
const isForcedCancel = typeof result.error === "string" && result.error.includes("forced");
|
||||
if (result.error === "Cancelled" || isForcedCancel) {
|
||||
// Forced cancel means the process ignored SIGINT for the cancel
|
||||
// wall-clock window. We mark the job as cancelled and release the
|
||||
// lock so the session is reusable; the error message tells the
|
||||
// caller the process may still be running so subsequent commands
|
||||
// should be considered carefully. This is consistent: callers see
|
||||
// completed=true exactly when the lock is no longer held.
|
||||
job.status = "cancelled";
|
||||
job.error = result.error;
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
return;
|
||||
}
|
||||
if (result.error) {
|
||||
job.status = "failed";
|
||||
job.error = result.error;
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
return;
|
||||
}
|
||||
// A non-zero exit code without an error message still represents a
|
||||
// failed command (e.g. a build/test that returned 1). Mark it as failed
|
||||
// so callers don't have to special-case exitCode against status.
|
||||
if (typeof result.exitCode === "number" && result.exitCode !== 0) {
|
||||
job.status = "failed";
|
||||
job.error = `Command exited with code ${result.exitCode}`;
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
return;
|
||||
}
|
||||
job.status = "completed";
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
}).catch((err) => {
|
||||
job.updatedAt = Date.now();
|
||||
job.status = "failed";
|
||||
job.error = err?.message || String(err);
|
||||
storeCompletedJobOutput(job, job.stdout || "");
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
jobId,
|
||||
sessionId,
|
||||
command,
|
||||
status: "running",
|
||||
startedAt,
|
||||
outputMode: "foreground-mirrored",
|
||||
recommendedPollIntervalMs: DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS,
|
||||
};
|
||||
}).catch((err) => {
|
||||
// Probe (or unexpected rejection) must not leave the session lock held.
|
||||
job.status = "failed";
|
||||
job.error = err?.message || String(err);
|
||||
job.updatedAt = Date.now();
|
||||
job.pendingShellProbe = false;
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
});
|
||||
}
|
||||
|
||||
function getScopedJob(jobId, chatSessionId) {
|
||||
const job = backgroundJobs.get(jobId);
|
||||
if (!job) return null;
|
||||
// Per-chat isolation: a job started under a chat session can only be
|
||||
// accessed by callers presenting the same chatSessionId. Unscoped or
|
||||
// statically-scoped callers cannot reach into another chat's jobs.
|
||||
if (job.chatSessionId) {
|
||||
if (!chatSessionId || job.chatSessionId !== chatSessionId) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
function handleJobPoll(params) {
|
||||
const { jobId, offset = 0, chatSessionId, scopedSessionIds } = params || {};
|
||||
if (!jobId) throw new Error("jobId is required");
|
||||
const job = getScopedJob(jobId, chatSessionId || null);
|
||||
if (!job) return { ok: false, error: "Background job not found" };
|
||||
// Re-check session scope so a caller that lost access to the host
|
||||
// cannot continue reading output from jobs on that session.
|
||||
// Covers dynamic (chatSessionId) and static (scopedSessionIds) modes.
|
||||
if (job.sessionId) {
|
||||
const scopeErr = validateSessionScope(job.sessionId, chatSessionId || null, scopedSessionIds);
|
||||
if (scopeErr) return { ok: false, error: scopeErr };
|
||||
}
|
||||
return serializeBackgroundJob(job, offset);
|
||||
}
|
||||
|
||||
function handleJobStop(params) {
|
||||
const { jobId, chatSessionId, scopedSessionIds } = params || {};
|
||||
if (!jobId) throw new Error("jobId is required");
|
||||
const job = getScopedJob(jobId, chatSessionId || null);
|
||||
if (!job) return { ok: false, error: "Background job not found" };
|
||||
// For statically scoped MCP clients, validate that the job's session is
|
||||
// within the caller's static scope so a foreign jobId cannot cancel jobs
|
||||
// outside the caller's allowed sessions. Dynamic chat scope is already
|
||||
// enforced by getScopedJob (caller's chatSessionId must match the job's),
|
||||
// and we intentionally do NOT re-check dynamic scope here so jobs can
|
||||
// still be stopped after workspace membership changes — otherwise the
|
||||
// session lock would stay held forever.
|
||||
if (Array.isArray(scopedSessionIds) && job.sessionId) {
|
||||
if (!scopedSessionIds.includes(job.sessionId)) {
|
||||
return { ok: false, error: `Session "${job.sessionId}" is not in the current scope.` };
|
||||
}
|
||||
}
|
||||
if (job.status === "running") {
|
||||
try {
|
||||
job.handle?.cancel?.();
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
job.status = "stopping";
|
||||
job.error = "Cancellation requested";
|
||||
job.updatedAt = Date.now();
|
||||
}
|
||||
return serializeBackgroundJob(job, 0);
|
||||
}
|
||||
|
||||
return {
|
||||
resolveExecContext,
|
||||
handleExec,
|
||||
handleJobStart,
|
||||
getScopedJob,
|
||||
handleJobPoll,
|
||||
handleJobStop,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createExecHandlerApi };
|
||||
117
electron/bridges/mcpServerBridge/retainOwnedSessions.cjs
Normal file
117
electron/bridges/mcpServerBridge/retainOwnedSessions.cjs
Normal file
@@ -0,0 +1,117 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Merge two scoped snapshots without letting an older cross-scope copy
|
||||
* overwrite newer connection state. Metadata revisions are assigned by the
|
||||
* main-process bridge when a renderer update arrives.
|
||||
*
|
||||
* @param {Record<string, unknown> | null | undefined} previous
|
||||
* @param {Record<string, unknown> | null | undefined} fallback
|
||||
* @returns {Record<string, unknown> | null}
|
||||
*/
|
||||
function mergeRetentionMeta(previous, fallback) {
|
||||
if (!previous && !fallback) return null;
|
||||
if (!previous) return fallback && typeof fallback === "object" ? fallback : null;
|
||||
if (!fallback || typeof fallback !== "object") return previous;
|
||||
|
||||
const previousRevision = Number.isSafeInteger(previous._revision) ? previous._revision : 0;
|
||||
const fallbackRevision = Number.isSafeInteger(fallback._revision) ? fallback._revision : 0;
|
||||
// Unversioned direct helper inputs retain the historical fallback-wins
|
||||
// behavior. Bridge-owned metadata is always versioned.
|
||||
const fallbackIsNewer = fallbackRevision >= previousRevision;
|
||||
const newer = fallbackIsNewer ? fallback : previous;
|
||||
const older = fallbackIsNewer ? previous : fallback;
|
||||
const connected = Object.prototype.hasOwnProperty.call(newer, "connected")
|
||||
? newer.connected !== false
|
||||
: older.connected !== false;
|
||||
return {
|
||||
...older,
|
||||
...newer,
|
||||
hostname: newer.hostname || older.hostname,
|
||||
label: newer.label || older.label,
|
||||
os: newer.os || older.os,
|
||||
username: newer.username || older.username,
|
||||
protocol: newer.protocol || older.protocol,
|
||||
shellType: newer.shellType || older.shellType,
|
||||
deviceType: newer.deviceType || older.deviceType,
|
||||
hostId: newer.hostId || older.hostId,
|
||||
hostChain: Array.isArray(newer.hostChain) ? newer.hostChain : older.hostChain,
|
||||
// Explicit empty arrays in the newer snapshot clear stopped forwards.
|
||||
activePortForwards: Array.isArray(newer.activePortForwards)
|
||||
? newer.activePortForwards
|
||||
: older.activePortForwards,
|
||||
connected,
|
||||
...(Math.max(previousRevision, fallbackRevision) > 0
|
||||
? { _revision: Math.max(previousRevision, fallbackRevision) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep host_open-owned sessions in a chat scope when a full metadata replace
|
||||
* would otherwise drop them (e.g. AIChatSidePanel pushing only the current
|
||||
* terminal tab after a mid-turn host_open).
|
||||
*
|
||||
* Empty incoming lists are treated as authoritative clears and are not retained.
|
||||
*
|
||||
* @param {{
|
||||
* incomingSessions: Array<Record<string, unknown>>,
|
||||
* ownedSessionIds: string[],
|
||||
* previousById?: Map<string, Record<string, unknown>> | null,
|
||||
* findFallbackMeta?: ((sessionId: string) => Record<string, unknown> | null | undefined) | null,
|
||||
* }} args
|
||||
* @returns {Array<Record<string, unknown>>}
|
||||
*/
|
||||
function retainOwnedSessions({
|
||||
incomingSessions,
|
||||
ownedSessionIds,
|
||||
previousById = null,
|
||||
findFallbackMeta = null,
|
||||
}) {
|
||||
if (!Array.isArray(incomingSessions) || incomingSessions.length === 0) {
|
||||
return incomingSessions;
|
||||
}
|
||||
if (!Array.isArray(ownedSessionIds) || ownedSessionIds.length === 0) {
|
||||
return incomingSessions;
|
||||
}
|
||||
|
||||
const byId = new Map();
|
||||
for (const entry of incomingSessions) {
|
||||
if (!entry || typeof entry !== "object" || !entry.sessionId) continue;
|
||||
byId.set(String(entry.sessionId), entry);
|
||||
}
|
||||
|
||||
for (const ownedIdRaw of ownedSessionIds) {
|
||||
const ownedId = typeof ownedIdRaw === "string" ? ownedIdRaw.trim() : "";
|
||||
if (!ownedId || byId.has(ownedId)) continue;
|
||||
|
||||
const previous = previousById?.get?.(ownedId) || null;
|
||||
// Always consult fallback — a stale previous connected:false must not
|
||||
// block a fresher cross-scope snapshot (e.g. External MCP / other tab).
|
||||
const fallback = typeof findFallbackMeta === "function"
|
||||
? findFallbackMeta(ownedId)
|
||||
: null;
|
||||
const meta = mergeRetentionMeta(previous, fallback);
|
||||
if (!meta || typeof meta !== "object") continue;
|
||||
|
||||
byId.set(ownedId, {
|
||||
sessionId: ownedId,
|
||||
hostname: meta.hostname || "",
|
||||
label: meta.label || "",
|
||||
os: meta.os || "",
|
||||
username: meta.username || "",
|
||||
protocol: meta.protocol || "",
|
||||
shellType: meta.shellType || "",
|
||||
deviceType: meta.deviceType || "",
|
||||
connected: meta.connected !== false,
|
||||
hostId: meta.hostId || "",
|
||||
hostChain: Array.isArray(meta.hostChain) ? meta.hostChain : [],
|
||||
activePortForwards: Array.isArray(meta.activePortForwards) ? meta.activePortForwards : [],
|
||||
...(Number.isSafeInteger(meta._revision) ? { _revision: meta._revision } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
module.exports = { retainOwnedSessions, mergeRetentionMeta };
|
||||
267
electron/bridges/mcpServerBridge/retainOwnedSessions.test.cjs
Normal file
267
electron/bridges/mcpServerBridge/retainOwnedSessions.test.cjs
Normal file
@@ -0,0 +1,267 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { retainOwnedSessions, mergeRetentionMeta } = require("./retainOwnedSessions.cjs");
|
||||
|
||||
test("retainOwnedSessions keeps host_open-owned sessions dropped by a full scope replace", () => {
|
||||
const previousById = new Map([
|
||||
["sess-original", {
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
hostId: "host-a",
|
||||
}],
|
||||
["sess-opened", {
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
protocol: "ssh",
|
||||
connected: false,
|
||||
hostId: "host-b",
|
||||
}],
|
||||
]);
|
||||
|
||||
const retained = retainOwnedSessions({
|
||||
incomingSessions: [{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
hostId: "host-a",
|
||||
}],
|
||||
ownedSessionIds: ["sess-opened"],
|
||||
previousById,
|
||||
});
|
||||
|
||||
const ids = retained.map((entry) => entry.sessionId).sort();
|
||||
assert.deepEqual(ids, ["sess-opened", "sess-original"]);
|
||||
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
|
||||
assert.equal(opened.label, "server-b");
|
||||
assert.equal(opened.hostId, "host-b");
|
||||
assert.equal(opened.connected, false);
|
||||
});
|
||||
|
||||
test("retainOwnedSessions does not alter authoritative empty replaces", () => {
|
||||
const retained = retainOwnedSessions({
|
||||
incomingSessions: [],
|
||||
ownedSessionIds: ["sess-opened"],
|
||||
previousById: new Map([
|
||||
["sess-opened", { hostname: "10.0.0.2", label: "server-b" }],
|
||||
]),
|
||||
});
|
||||
assert.deepEqual(retained, []);
|
||||
});
|
||||
|
||||
test("retainOwnedSessions falls back to cross-scope metadata when needed", () => {
|
||||
const retained = retainOwnedSessions({
|
||||
incomingSessions: [{ sessionId: "sess-original", label: "server-a" }],
|
||||
ownedSessionIds: ["sess-opened"],
|
||||
previousById: new Map(),
|
||||
findFallbackMeta: (sessionId) => (
|
||||
sessionId === "sess-opened"
|
||||
? { hostname: "10.0.0.2", label: "server-b", hostId: "host-b" }
|
||||
: null
|
||||
),
|
||||
});
|
||||
assert.equal(retained.length, 2);
|
||||
assert.equal(
|
||||
retained.find((entry) => entry.sessionId === "sess-opened")?.label,
|
||||
"server-b",
|
||||
);
|
||||
});
|
||||
|
||||
test("retainOwnedSessions ignores owned ids with no recoverable metadata", () => {
|
||||
const retained = retainOwnedSessions({
|
||||
incomingSessions: [{ sessionId: "sess-original", label: "server-a" }],
|
||||
ownedSessionIds: ["sess-ghost"],
|
||||
previousById: new Map(),
|
||||
});
|
||||
assert.deepEqual(retained.map((entry) => entry.sessionId), ["sess-original"]);
|
||||
});
|
||||
|
||||
test("retainOwnedSessions refreshes connected from fallback even when previous exists", () => {
|
||||
const retained = retainOwnedSessions({
|
||||
incomingSessions: [{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
}],
|
||||
ownedSessionIds: ["sess-opened"],
|
||||
previousById: new Map([
|
||||
["sess-opened", {
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: false,
|
||||
hostId: "host-b",
|
||||
}],
|
||||
]),
|
||||
findFallbackMeta: (sessionId) => (
|
||||
sessionId === "sess-opened"
|
||||
? {
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
username: "root",
|
||||
}
|
||||
: null
|
||||
),
|
||||
});
|
||||
|
||||
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
|
||||
assert.ok(opened);
|
||||
assert.equal(opened.connected, true);
|
||||
assert.equal(opened.username, "root");
|
||||
assert.equal(opened.hostId, "host-b");
|
||||
});
|
||||
|
||||
test("mergeRetentionMeta lets a later disconnected fallback replace connected:true", () => {
|
||||
const merged = mergeRetentionMeta(
|
||||
{
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
},
|
||||
{
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: false,
|
||||
hostId: "host-b",
|
||||
},
|
||||
);
|
||||
assert.equal(merged.connected, false);
|
||||
});
|
||||
|
||||
test("retainOwnedSessions applies a later disconnect from fallback metadata", () => {
|
||||
const retained = retainOwnedSessions({
|
||||
incomingSessions: [{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
}],
|
||||
ownedSessionIds: ["sess-opened"],
|
||||
previousById: new Map([
|
||||
["sess-opened", {
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
}],
|
||||
]),
|
||||
findFallbackMeta: (sessionId) => (
|
||||
sessionId === "sess-opened"
|
||||
? {
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: false,
|
||||
hostId: "host-b",
|
||||
}
|
||||
: null
|
||||
),
|
||||
});
|
||||
|
||||
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
|
||||
assert.ok(opened);
|
||||
assert.equal(opened.connected, false);
|
||||
});
|
||||
|
||||
test("mergeRetentionMeta clears activePortForwards when fallback reports an empty array", () => {
|
||||
const merged = mergeRetentionMeta(
|
||||
{
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
activePortForwards: [{ ruleId: "fwd-1", localPort: 8080, status: "active" }],
|
||||
},
|
||||
{
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
activePortForwards: [],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(merged.activePortForwards, []);
|
||||
});
|
||||
|
||||
test("mergeRetentionMeta keeps prior activePortForwards when fallback omits them", () => {
|
||||
const prior = [{ ruleId: "fwd-1", localPort: 8080, status: "active" }];
|
||||
const merged = mergeRetentionMeta(
|
||||
{
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
activePortForwards: prior,
|
||||
},
|
||||
{
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(merged.activePortForwards, prior);
|
||||
});
|
||||
|
||||
test("retainOwnedSessions applies empty activePortForwards from fallback metadata", () => {
|
||||
const retained = retainOwnedSessions({
|
||||
incomingSessions: [{
|
||||
sessionId: "sess-original",
|
||||
hostname: "10.0.0.1",
|
||||
label: "server-a",
|
||||
connected: true,
|
||||
}],
|
||||
ownedSessionIds: ["sess-opened"],
|
||||
previousById: new Map([
|
||||
["sess-opened", {
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: false,
|
||||
hostId: "host-b",
|
||||
activePortForwards: [{ ruleId: "fwd-1", localPort: 8080, status: "active" }],
|
||||
}],
|
||||
]),
|
||||
findFallbackMeta: (sessionId) => (
|
||||
sessionId === "sess-opened"
|
||||
? {
|
||||
hostname: "10.0.0.2",
|
||||
label: "server-b",
|
||||
connected: true,
|
||||
hostId: "host-b",
|
||||
activePortForwards: [],
|
||||
}
|
||||
: null
|
||||
),
|
||||
});
|
||||
|
||||
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
|
||||
assert.ok(opened);
|
||||
assert.deepEqual(opened.activePortForwards, []);
|
||||
assert.equal(opened.connected, true);
|
||||
});
|
||||
|
||||
test("mergeRetentionMeta keeps the higher metadata revision in either argument", () => {
|
||||
const oldConnected = {
|
||||
connected: true,
|
||||
username: "old-user",
|
||||
activePortForwards: [{ ruleId: "old-forward" }],
|
||||
_revision: 4,
|
||||
};
|
||||
const newDisconnected = {
|
||||
connected: false,
|
||||
username: "new-user",
|
||||
activePortForwards: [],
|
||||
_revision: 7,
|
||||
};
|
||||
|
||||
for (const merged of [
|
||||
mergeRetentionMeta(oldConnected, newDisconnected),
|
||||
mergeRetentionMeta(newDisconnected, oldConnected),
|
||||
]) {
|
||||
assert.equal(merged.connected, false);
|
||||
assert.equal(merged.username, "new-user");
|
||||
assert.deepEqual(merged.activePortForwards, []);
|
||||
assert.equal(merged._revision, 7);
|
||||
}
|
||||
});
|
||||
195
electron/bridges/mcpServerBridge/sessionIdleManager.cjs
Normal file
195
electron/bridges/mcpServerBridge/sessionIdleManager.cjs
Normal file
@@ -0,0 +1,195 @@
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
|
||||
const MIN_SESSION_IDLE_TIMEOUT_MINUTES = 1;
|
||||
const MAX_SESSION_IDLE_TIMEOUT_MINUTES = 24 * 60;
|
||||
|
||||
function normalizeSessionIdleTimeoutMinutes(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES;
|
||||
return Math.min(
|
||||
MAX_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
Math.max(MIN_SESSION_IDLE_TIMEOUT_MINUTES, Math.round(parsed)),
|
||||
);
|
||||
}
|
||||
|
||||
function createSessionIdleManager(options = {}) {
|
||||
const DateImpl = options.Date || Date;
|
||||
const setTimeoutImpl = options.setTimeout || setTimeout;
|
||||
const clearTimeoutImpl = options.clearTimeout || clearTimeout;
|
||||
const onIdle = typeof options.onIdle === "function" ? options.onIdle : async () => {};
|
||||
const entries = new Map();
|
||||
let timeoutMinutes = normalizeSessionIdleTimeoutMinutes(options.timeoutMinutes);
|
||||
|
||||
function clearTimer(entry) {
|
||||
if (entry.timer == null) return;
|
||||
clearTimeoutImpl(entry.timer);
|
||||
entry.timer = null;
|
||||
}
|
||||
|
||||
function schedule(entry) {
|
||||
clearTimer(entry);
|
||||
if (entry.activeCount > 0 || entry.checking || entry.closing) return;
|
||||
const expiresAt = entry.lastActivityAt + timeoutMinutes * 60 * 1000;
|
||||
const delay = Math.max(0, expiresAt - DateImpl.now());
|
||||
entry.timer = setTimeoutImpl(() => {
|
||||
entry.timer = null;
|
||||
const current = entries.get(entry.sessionId);
|
||||
if (current !== entry || entry.activeCount > 0 || entry.checking || entry.closing) return;
|
||||
const latestExpiresAt = entry.lastActivityAt + timeoutMinutes * 60 * 1000;
|
||||
if (DateImpl.now() < latestExpiresAt) {
|
||||
schedule(entry);
|
||||
return;
|
||||
}
|
||||
entry.checking = true;
|
||||
const activityVersion = entry.activityVersion;
|
||||
Promise.resolve(onIdle({
|
||||
chatSessionId: entry.chatSessionId,
|
||||
sessionId: entry.sessionId,
|
||||
}, activityVersion)).catch(() => {
|
||||
resume(entry.sessionId);
|
||||
});
|
||||
}, delay);
|
||||
entry.timer?.unref?.();
|
||||
}
|
||||
|
||||
function track(chatSessionId, sessionId) {
|
||||
if (!chatSessionId || !sessionId) return false;
|
||||
const existing = entries.get(sessionId);
|
||||
if (existing) clearTimer(existing);
|
||||
const entry = {
|
||||
chatSessionId,
|
||||
sessionId,
|
||||
lastActivityAt: DateImpl.now(),
|
||||
activityVersion: 0,
|
||||
activeCount: 0,
|
||||
checking: false,
|
||||
closing: false,
|
||||
timer: null,
|
||||
};
|
||||
entries.set(sessionId, entry);
|
||||
schedule(entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
function touch(chatSessionId, sessionId) {
|
||||
const entry = entries.get(sessionId);
|
||||
if (!entry || entry.closing) return false;
|
||||
entry.checking = false;
|
||||
entry.activityVersion += 1;
|
||||
entry.lastActivityAt = DateImpl.now();
|
||||
// Keep an existing timer instead of recreating it for every output chunk.
|
||||
// Its callback rechecks lastActivityAt and extends the deadline as needed.
|
||||
if (entry.timer == null) schedule(entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
function beginActivity(chatSessionId, sessionId) {
|
||||
const entry = entries.get(sessionId);
|
||||
if (!entry || entry.closing) return false;
|
||||
entry.checking = false;
|
||||
entry.activityVersion += 1;
|
||||
entry.activeCount += 1;
|
||||
entry.lastActivityAt = DateImpl.now();
|
||||
clearTimer(entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
function endActivity(chatSessionId, sessionId) {
|
||||
const entry = entries.get(sessionId);
|
||||
if (!entry || entry.closing) return false;
|
||||
entry.activityVersion += 1;
|
||||
entry.activeCount = Math.max(0, entry.activeCount - 1);
|
||||
entry.lastActivityAt = DateImpl.now();
|
||||
schedule(entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
function beginClose(sessionId) {
|
||||
const entry = entries.get(sessionId);
|
||||
if (!entry || entry.closing) return false;
|
||||
entry.checking = false;
|
||||
entry.closing = true;
|
||||
clearTimer(entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
function beginIdleClose(sessionId, activityVersion) {
|
||||
if (!isIdleCheckCurrent(sessionId, activityVersion)) return false;
|
||||
return beginClose(sessionId);
|
||||
}
|
||||
|
||||
function isIdleCheckCurrent(sessionId, activityVersion) {
|
||||
const entry = entries.get(sessionId);
|
||||
return Boolean(
|
||||
entry
|
||||
&& entry.checking
|
||||
&& !entry.closing
|
||||
&& entry.activeCount === 0
|
||||
&& entry.activityVersion === activityVersion
|
||||
);
|
||||
}
|
||||
|
||||
function resume(sessionId) {
|
||||
const entry = entries.get(sessionId);
|
||||
if (!entry) return false;
|
||||
entry.closing = false;
|
||||
entry.checking = false;
|
||||
entry.activityVersion += 1;
|
||||
entry.activeCount = 0;
|
||||
entry.lastActivityAt = DateImpl.now();
|
||||
schedule(entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
function forgetSession(sessionId) {
|
||||
const entry = entries.get(sessionId);
|
||||
if (!entry) return false;
|
||||
clearTimer(entry);
|
||||
entries.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function setTimeoutMinutes(value) {
|
||||
timeoutMinutes = normalizeSessionIdleTimeoutMinutes(value);
|
||||
for (const entry of entries.values()) schedule(entry);
|
||||
return timeoutMinutes;
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
for (const entry of entries.values()) clearTimer(entry);
|
||||
entries.clear();
|
||||
}
|
||||
|
||||
function scopeCleared() {
|
||||
// Intentionally keep timers alive. A deleted/interrupted AI scope is one of
|
||||
// the cases where the idle fallback is needed most.
|
||||
}
|
||||
|
||||
return {
|
||||
track,
|
||||
touch,
|
||||
beginActivity,
|
||||
endActivity,
|
||||
beginClose,
|
||||
beginIdleClose,
|
||||
resume,
|
||||
forgetSession,
|
||||
isTracked: (sessionId) => entries.has(sessionId),
|
||||
hasActivity: (sessionId) => Boolean(entries.get(sessionId)?.activeCount),
|
||||
isIdleCheckCurrent,
|
||||
isClosing: (sessionId) => Boolean(entries.get(sessionId)?.closing),
|
||||
setTimeoutMinutes,
|
||||
getTimeoutMinutes: () => timeoutMinutes,
|
||||
clearAll,
|
||||
scopeCleared,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
MIN_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
MAX_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
normalizeSessionIdleTimeoutMinutes,
|
||||
createSessionIdleManager,
|
||||
};
|
||||
227
electron/bridges/mcpServerBridge/sessionIdleManager.test.cjs
Normal file
227
electron/bridges/mcpServerBridge/sessionIdleManager.test.cjs
Normal file
@@ -0,0 +1,227 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { createSessionIdleManager } = require("./sessionIdleManager.cjs");
|
||||
|
||||
function createClock() {
|
||||
let now = 0;
|
||||
let nextId = 1;
|
||||
const timers = new Map();
|
||||
|
||||
function runDueTimers() {
|
||||
while (true) {
|
||||
const due = Array.from(timers.entries())
|
||||
.filter(([, timer]) => timer.at <= now)
|
||||
.sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!due) break;
|
||||
timers.delete(due[0]);
|
||||
due[1].callback();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Date: { now: () => now },
|
||||
setTimeout(callback, delay) {
|
||||
const id = nextId++;
|
||||
timers.set(id, { callback, at: now + delay });
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
advance(ms) {
|
||||
now += ms;
|
||||
runDueTimers();
|
||||
},
|
||||
timerCount() {
|
||||
return timers.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("idle sessions close independently and activity renews only the matching session", async () => {
|
||||
const clock = createClock();
|
||||
const closed = [];
|
||||
const manager = createSessionIdleManager({
|
||||
...clock,
|
||||
timeoutMinutes: 1,
|
||||
onIdle: async (entry) => closed.push(entry),
|
||||
});
|
||||
|
||||
manager.track("chat-a", "session-1");
|
||||
manager.track("chat-a", "session-2");
|
||||
|
||||
clock.advance(30_000);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(closed, []);
|
||||
manager.touch("chat-a", "session-1");
|
||||
|
||||
clock.advance(30_000);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(closed, [{ chatSessionId: "chat-a", sessionId: "session-2" }]);
|
||||
|
||||
clock.advance(30_000);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(closed, [
|
||||
{ chatSessionId: "chat-a", sessionId: "session-2" },
|
||||
{ chatSessionId: "chat-a", sessionId: "session-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("an in-flight operation cannot time out and gets a fresh idle window when it ends", async () => {
|
||||
const clock = createClock();
|
||||
const closed = [];
|
||||
const manager = createSessionIdleManager({
|
||||
...clock,
|
||||
timeoutMinutes: 1,
|
||||
onIdle: async (entry) => closed.push(entry),
|
||||
});
|
||||
|
||||
manager.track("chat-a", "session-1");
|
||||
assert.equal(manager.beginActivity("chat-a", "session-1"), true);
|
||||
clock.advance(120_000);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(closed, []);
|
||||
assert.equal(clock.timerCount(), 0);
|
||||
|
||||
manager.endActivity("chat-a", "session-1");
|
||||
clock.advance(59_999);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(closed, []);
|
||||
clock.advance(1);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(closed, [{ chatSessionId: "chat-a", sessionId: "session-1" }]);
|
||||
});
|
||||
|
||||
test("activity from another authorized scope renews the same terminal session", async () => {
|
||||
const clock = createClock();
|
||||
const closed = [];
|
||||
const manager = createSessionIdleManager({
|
||||
...clock,
|
||||
timeoutMinutes: 1,
|
||||
onIdle: async (entry) => closed.push(entry),
|
||||
});
|
||||
|
||||
manager.track("chat-owner", "session-1");
|
||||
clock.advance(30_000);
|
||||
assert.equal(manager.beginActivity("chat-other", "session-1"), true);
|
||||
manager.endActivity("chat-other", "session-1");
|
||||
|
||||
clock.advance(59_999);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(closed, []);
|
||||
clock.advance(1);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(closed, [{ chatSessionId: "chat-owner", sessionId: "session-1" }]);
|
||||
});
|
||||
|
||||
test("activity starting while an idle check is pending prevents the close", async () => {
|
||||
const clock = createClock();
|
||||
const closed = [];
|
||||
let releaseIdleCheck;
|
||||
const idleCheck = new Promise((resolve) => {
|
||||
releaseIdleCheck = resolve;
|
||||
});
|
||||
let manager;
|
||||
manager = createSessionIdleManager({
|
||||
...clock,
|
||||
timeoutMinutes: 1,
|
||||
onIdle: async (entry) => {
|
||||
await idleCheck;
|
||||
if (!manager.hasActivity(entry.sessionId)) closed.push(entry);
|
||||
},
|
||||
});
|
||||
|
||||
manager.track("chat-owner", "session-1");
|
||||
clock.advance(60_000);
|
||||
assert.equal(manager.beginActivity("chat-other", "session-1"), true);
|
||||
releaseIdleCheck();
|
||||
await idleCheck;
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(closed, []);
|
||||
|
||||
manager.endActivity("chat-other", "session-1");
|
||||
clock.advance(60_000);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(closed, [{ chatSessionId: "chat-owner", sessionId: "session-1" }]);
|
||||
});
|
||||
|
||||
test("activity that starts and ends during an idle check invalidates the stale check", async () => {
|
||||
const clock = createClock();
|
||||
const closed = [];
|
||||
let releaseIdleCheck;
|
||||
const idleCheck = new Promise((resolve) => {
|
||||
releaseIdleCheck = resolve;
|
||||
});
|
||||
let manager;
|
||||
manager = createSessionIdleManager({
|
||||
...clock,
|
||||
timeoutMinutes: 1,
|
||||
onIdle: async (entry, activityVersion) => {
|
||||
await idleCheck;
|
||||
if (manager.beginIdleClose(entry.sessionId, activityVersion)) {
|
||||
closed.push(entry);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
manager.track("chat-owner", "session-1");
|
||||
clock.advance(60_000);
|
||||
assert.equal(manager.beginActivity("chat-other", "session-1"), true);
|
||||
assert.equal(manager.endActivity("chat-other", "session-1"), true);
|
||||
releaseIdleCheck();
|
||||
await idleCheck;
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(closed, []);
|
||||
|
||||
clock.advance(59_999);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(closed, []);
|
||||
clock.advance(1);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(closed, [{ chatSessionId: "chat-owner", sessionId: "session-1" }]);
|
||||
});
|
||||
|
||||
test("a failed close resumes tracking while a successful close can be forgotten", async () => {
|
||||
const clock = createClock();
|
||||
let attempts = 0;
|
||||
const manager = createSessionIdleManager({
|
||||
...clock,
|
||||
timeoutMinutes: 1,
|
||||
onIdle: async ({ sessionId }) => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) manager.resume(sessionId);
|
||||
else manager.forgetSession(sessionId);
|
||||
},
|
||||
});
|
||||
|
||||
manager.track("chat-a", "session-1");
|
||||
clock.advance(60_000);
|
||||
await Promise.resolve();
|
||||
assert.equal(attempts, 1);
|
||||
assert.equal(manager.isTracked("session-1"), true);
|
||||
|
||||
clock.advance(60_000);
|
||||
await Promise.resolve();
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(manager.isTracked("session-1"), false);
|
||||
assert.equal(clock.timerCount(), 0);
|
||||
});
|
||||
|
||||
test("clearing an AI scope does not discard its idle cleanup fallback", async () => {
|
||||
const clock = createClock();
|
||||
const closed = [];
|
||||
const manager = createSessionIdleManager({
|
||||
...clock,
|
||||
timeoutMinutes: 1,
|
||||
onIdle: async (entry) => closed.push(entry),
|
||||
});
|
||||
|
||||
manager.track("chat-a", "session-1");
|
||||
manager.scopeCleared("chat-a");
|
||||
clock.advance(60_000);
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(closed, [{ chatSessionId: "chat-a", sessionId: "session-1" }]);
|
||||
});
|
||||
92
electron/bridges/mcpServerBridge/sessionOwnership.cjs
Normal file
92
electron/bridges/mcpServerBridge/sessionOwnership.cjs
Normal file
@@ -0,0 +1,92 @@
|
||||
"use strict";
|
||||
|
||||
function createSessionOwnershipRegistry() {
|
||||
const ownedByScope = new Map();
|
||||
const scopeGenerations = new Map();
|
||||
|
||||
function captureGeneration(chatSessionId) {
|
||||
if (!chatSessionId) return null;
|
||||
let generation = scopeGenerations.get(chatSessionId);
|
||||
if (!generation) {
|
||||
generation = { chatSessionId, revoked: false };
|
||||
scopeGenerations.set(chatSessionId, generation);
|
||||
}
|
||||
return generation;
|
||||
}
|
||||
|
||||
function register(chatSessionId, sessionId, expectedGeneration = null) {
|
||||
if (!chatSessionId || !sessionId) return false;
|
||||
if (expectedGeneration !== null) {
|
||||
const currentGeneration = scopeGenerations.get(chatSessionId);
|
||||
if (
|
||||
expectedGeneration.revoked
|
||||
|| expectedGeneration.chatSessionId !== chatSessionId
|
||||
|| currentGeneration !== expectedGeneration
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const owned = ownedByScope.get(chatSessionId) || new Set();
|
||||
owned.add(sessionId);
|
||||
ownedByScope.set(chatSessionId, owned);
|
||||
return true;
|
||||
}
|
||||
|
||||
function validate(chatSessionId, sessionId) {
|
||||
if (!chatSessionId) return { ok: false, error: "chatSessionId is required." };
|
||||
if (!ownedByScope.get(chatSessionId)?.has(sessionId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Session "${sessionId}" was not opened by the current AI scope.`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function listOwned(chatSessionId) {
|
||||
if (!chatSessionId) return [];
|
||||
const owned = ownedByScope.get(chatSessionId);
|
||||
return owned ? Array.from(owned) : [];
|
||||
}
|
||||
|
||||
function forgetSession(sessionId) {
|
||||
for (const [scopeId, owned] of ownedByScope) {
|
||||
owned.delete(sessionId);
|
||||
if (owned.size === 0) ownedByScope.delete(scopeId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop retained ownership for a chat scope without revoking its host_open
|
||||
* generation. Used when the renderer pushes an authoritative empty scope
|
||||
* replace so a later non-empty sync cannot resurrect cleared sessions.
|
||||
*/
|
||||
function releaseScopeOwnership(chatSessionId) {
|
||||
if (!chatSessionId) return;
|
||||
ownedByScope.delete(chatSessionId);
|
||||
}
|
||||
|
||||
function clearScope(chatSessionId) {
|
||||
ownedByScope.delete(chatSessionId);
|
||||
const generation = scopeGenerations.get(chatSessionId);
|
||||
if (generation) generation.revoked = true;
|
||||
scopeGenerations.delete(chatSessionId);
|
||||
}
|
||||
|
||||
function getTrackedGenerationCountForTests() {
|
||||
return scopeGenerations.size;
|
||||
}
|
||||
|
||||
return {
|
||||
captureGeneration,
|
||||
register,
|
||||
validate,
|
||||
listOwned,
|
||||
forgetSession,
|
||||
releaseScopeOwnership,
|
||||
clearScope,
|
||||
getTrackedGenerationCountForTests,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSessionOwnershipRegistry };
|
||||
90
electron/bridges/mcpServerBridge/sessionOwnership.test.cjs
Normal file
90
electron/bridges/mcpServerBridge/sessionOwnership.test.cjs
Normal file
@@ -0,0 +1,90 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { createSessionOwnershipRegistry } = require("./sessionOwnership.cjs");
|
||||
|
||||
test("session ownership is isolated by AI scope", () => {
|
||||
const ownership = createSessionOwnershipRegistry();
|
||||
ownership.register("chat-a", "session-1");
|
||||
|
||||
assert.equal(ownership.validate("chat-a", "session-1").ok, true);
|
||||
assert.equal(ownership.validate("chat-b", "session-1").ok, false);
|
||||
assert.match(ownership.validate("chat-b", "session-1").error, /not opened/i);
|
||||
});
|
||||
|
||||
test("listOwned returns sessions registered for a chat scope", () => {
|
||||
const ownership = createSessionOwnershipRegistry();
|
||||
ownership.register("chat-a", "session-1");
|
||||
ownership.register("chat-a", "session-2");
|
||||
ownership.register("chat-b", "session-3");
|
||||
|
||||
assert.deepEqual(ownership.listOwned("chat-a").sort(), ["session-1", "session-2"]);
|
||||
assert.deepEqual(ownership.listOwned("chat-b"), ["session-3"]);
|
||||
assert.deepEqual(ownership.listOwned("missing"), []);
|
||||
assert.deepEqual(ownership.listOwned(""), []);
|
||||
});
|
||||
|
||||
test("forgetSession revokes ownership from every scope", () => {
|
||||
const ownership = createSessionOwnershipRegistry();
|
||||
ownership.register("chat-a", "session-1");
|
||||
ownership.register("chat-b", "session-1");
|
||||
ownership.forgetSession("session-1");
|
||||
|
||||
assert.equal(ownership.validate("chat-a", "session-1").ok, false);
|
||||
assert.equal(ownership.validate("chat-b", "session-1").ok, false);
|
||||
});
|
||||
|
||||
test("releaseScopeOwnership clears retained ids without revoking generations", () => {
|
||||
const ownership = createSessionOwnershipRegistry();
|
||||
const generation = ownership.captureGeneration("chat-a");
|
||||
ownership.register("chat-a", "session-1", generation);
|
||||
ownership.register("chat-b", "session-2");
|
||||
|
||||
ownership.releaseScopeOwnership("chat-a");
|
||||
|
||||
assert.deepEqual(ownership.listOwned("chat-a"), []);
|
||||
assert.equal(ownership.validate("chat-a", "session-1").ok, false);
|
||||
assert.equal(ownership.validate("chat-b", "session-2").ok, true);
|
||||
// Late host_open for the same generation must still be allowed.
|
||||
assert.equal(ownership.register("chat-a", "session-3", generation), true);
|
||||
assert.deepEqual(ownership.listOwned("chat-a"), ["session-3"]);
|
||||
});
|
||||
|
||||
test("clearScope only revokes the deleted chat scope", () => {
|
||||
const ownership = createSessionOwnershipRegistry();
|
||||
ownership.register("chat-a", "session-1");
|
||||
ownership.register("chat-b", "session-2");
|
||||
ownership.clearScope("chat-a");
|
||||
|
||||
assert.equal(ownership.validate("chat-a", "session-1").ok, false);
|
||||
assert.equal(ownership.validate("chat-b", "session-2").ok, true);
|
||||
});
|
||||
|
||||
test("a host open that finishes after scope cleanup cannot restore ownership", () => {
|
||||
const ownership = createSessionOwnershipRegistry();
|
||||
const generation = ownership.captureGeneration("chat-a");
|
||||
ownership.clearScope("chat-a");
|
||||
|
||||
assert.equal(ownership.register("chat-a", "session-1", generation), false);
|
||||
assert.equal(ownership.validate("chat-a", "session-1").ok, false);
|
||||
|
||||
const nextGeneration = ownership.captureGeneration("chat-a");
|
||||
assert.equal(ownership.register("chat-a", "session-2", nextGeneration), true);
|
||||
});
|
||||
|
||||
test("cleared scope generations are released without allowing a late host open", () => {
|
||||
const ownership = createSessionOwnershipRegistry();
|
||||
const staleGenerations = [];
|
||||
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
const scopeId = `chat-${index}`;
|
||||
staleGenerations.push([scopeId, ownership.captureGeneration(scopeId)]);
|
||||
ownership.clearScope(scopeId);
|
||||
}
|
||||
|
||||
assert.equal(ownership.getTrackedGenerationCountForTests(), 0);
|
||||
for (const [scopeId, generation] of staleGenerations) {
|
||||
assert.equal(ownership.register(scopeId, `late-${scopeId}`, generation), false);
|
||||
}
|
||||
});
|
||||
545
electron/bridges/mcpServerBridge/sftpHandlers.cjs
Normal file
545
electron/bridges/mcpServerBridge/sftpHandlers.cjs
Normal file
@@ -0,0 +1,545 @@
|
||||
/* eslint-disable no-undef */
|
||||
function createSftpHandlerApi(ctx) {
|
||||
with (ctx) {
|
||||
function getSessionSftpEncodingStateKey(chatSessionId, sessionId) {
|
||||
if (!chatSessionId || !sessionId) return null;
|
||||
return `chat:${chatSessionId}:session:${sessionId}`;
|
||||
}
|
||||
|
||||
function getWorkerManager() {
|
||||
return typeof terminalWorkerManager !== "undefined" ? terminalWorkerManager : null;
|
||||
}
|
||||
|
||||
function getMainSessions() {
|
||||
return typeof sessions !== "undefined" ? sessions : null;
|
||||
}
|
||||
|
||||
function getStableTransferHostId(params) {
|
||||
if (typeof params?.hostId === "string" && params.hostId) return params.hostId;
|
||||
const mainHostId = getMainSessions()?.get?.(params?.sessionId)?.hostId;
|
||||
if (typeof mainHostId === "string" && mainHostId) return mainHostId;
|
||||
const workerHostId = getWorkerManager()?.getSessionHostId?.(params?.sessionId);
|
||||
if (typeof workerHostId === "string" && workerHostId) return workerHostId;
|
||||
return typeof params?.sessionId === "string" && params.sessionId ? params.sessionId : undefined;
|
||||
}
|
||||
|
||||
function shouldProxySessionBackedSftpToWorker(params) {
|
||||
if (!params?.sessionId) return false;
|
||||
const manager = getWorkerManager();
|
||||
if (!manager?.request) return false;
|
||||
const mainSessions = getMainSessions();
|
||||
return !mainSessions?.get?.(params.sessionId);
|
||||
}
|
||||
|
||||
function waitForWorkerSftpRequest(requestPromise, options = {}) {
|
||||
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
|
||||
? options.timeoutMs
|
||||
: 0;
|
||||
if (!timeoutMs) return requestPromise;
|
||||
|
||||
let timer = null;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`${options.operationName || "SFTP operation"} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
return Promise.race([requestPromise, timeoutPromise]).finally(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
|
||||
function requestWorkerSftp(channel, payload, options = {}) {
|
||||
const manager = getWorkerManager();
|
||||
if (!manager?.request) {
|
||||
return Promise.reject(new Error("Terminal worker is unavailable"));
|
||||
}
|
||||
return waitForWorkerSftpRequest(manager.request(channel, payload, {}), options);
|
||||
}
|
||||
|
||||
async function withWorkerSessionBackedSftp(params, workerChannel, options = {}) {
|
||||
if (!workerChannel) throw new Error("Worker SFTP channel is required");
|
||||
const chatSessionId = typeof params?.chatSessionId === "string" && params.chatSessionId ? params.chatSessionId : null;
|
||||
const encodingStateKey = getSessionSftpEncodingStateKey(chatSessionId, params.sessionId);
|
||||
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 0;
|
||||
const operationName = options.operationName || "SFTP operation";
|
||||
let sftpId = null;
|
||||
let pendingOpenPromise = null;
|
||||
let boundedOpenPromise = null;
|
||||
let closePromise = null;
|
||||
let closeRequested = false;
|
||||
let cancellationError = null;
|
||||
|
||||
const closeKnownSftpHandle = () => {
|
||||
if (!sftpId) return Promise.resolve();
|
||||
if (!closePromise) {
|
||||
closePromise = requestWorkerSftp("netcatty:sftp:close", { sftpId, encodingStateKey });
|
||||
}
|
||||
return closePromise;
|
||||
};
|
||||
const closeSftpHandle = async () => {
|
||||
closeRequested = true;
|
||||
if (!sftpId && boundedOpenPromise) {
|
||||
try {
|
||||
const opened = await boundedOpenPromise;
|
||||
sftpId = opened?.sftpId || null;
|
||||
} catch {
|
||||
// Do not let an unresponsive worker open block cancellation. If it
|
||||
// eventually succeeds, the late-result handler below closes it.
|
||||
}
|
||||
}
|
||||
return closeKnownSftpHandle();
|
||||
};
|
||||
const unregisterSftpOp = registerSftpOp(chatSessionId, params.sessionId, () => {
|
||||
if (!cancellationError) {
|
||||
cancellationError = new Error("Cancelled");
|
||||
}
|
||||
return closeSftpHandle().catch(() => {
|
||||
// Ignore close failures while cancelling a worker-backed SFTP handle.
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const manager = getWorkerManager();
|
||||
pendingOpenPromise = manager.request("netcatty:sftp:openForSession", {
|
||||
sessionId: params.sessionId,
|
||||
encodingStateKey,
|
||||
timeoutMs,
|
||||
}, {});
|
||||
boundedOpenPromise = waitForWorkerSftpRequest(pendingOpenPromise, { timeoutMs, operationName });
|
||||
void pendingOpenPromise.then((lateOpened) => {
|
||||
if (!sftpId) sftpId = lateOpened?.sftpId || null;
|
||||
if (closeRequested) return closeKnownSftpHandle();
|
||||
return undefined;
|
||||
}).catch(() => {
|
||||
// The bounded request reports open failures to the active operation.
|
||||
});
|
||||
const opened = await boundedOpenPromise;
|
||||
sftpId = opened?.sftpId;
|
||||
if (!sftpId) throw new Error("Failed to open session-backed SFTP handle");
|
||||
if (cancellationError) throw cancellationError;
|
||||
|
||||
const { abortSignal: _abortSignal, ...workerParams } = params || {};
|
||||
const workerPayload = options.buildWorkerPayload
|
||||
? options.buildWorkerPayload(workerParams, sftpId)
|
||||
: { ...workerParams, sftpId, timeoutMs };
|
||||
const value = await requestWorkerSftp(workerChannel, workerPayload, { timeoutMs, operationName });
|
||||
if (cancellationError) throw cancellationError;
|
||||
return value;
|
||||
} finally {
|
||||
unregisterSftpOp();
|
||||
try {
|
||||
await closeSftpHandle();
|
||||
} catch {
|
||||
// Ignore close failures for one-off worker-backed SFTP handles.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function withSessionBackedSftp(params, action, options = {}) {
|
||||
if (!params?.sessionId) throw new Error("sessionId is required");
|
||||
if (shouldProxySessionBackedSftpToWorker(params) && options.workerChannel) {
|
||||
return withWorkerSessionBackedSftp(params, options.workerChannel, options);
|
||||
}
|
||||
const chatSessionId = typeof params?.chatSessionId === "string" && params.chatSessionId ? params.chatSessionId : null;
|
||||
const encodingStateKey = getSessionSftpEncodingStateKey(chatSessionId, params.sessionId);
|
||||
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 0;
|
||||
const cancelCleanupGraceMs = Number.isFinite(options.cancelCleanupGraceMs) && options.cancelCleanupGraceMs >= 0
|
||||
? options.cancelCleanupGraceMs
|
||||
: 1000;
|
||||
const operationName = options.operationName || "SFTP operation";
|
||||
const abortController = new AbortController();
|
||||
let sftpId = null;
|
||||
let timeoutId = null;
|
||||
let forceCloseTimer = null;
|
||||
let closeRequested = false;
|
||||
let closePromise = null;
|
||||
let cancellationError = null;
|
||||
let timeoutError = null;
|
||||
const closeSftpHandle = () => {
|
||||
if (!sftpId) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!closePromise) {
|
||||
closePromise = Promise.resolve().then(() => sftpBridge.closeSftp(null, { sftpId, encodingStateKey }));
|
||||
}
|
||||
return closePromise;
|
||||
};
|
||||
const closeSftpInBackground = () => {
|
||||
if (closeRequested) return;
|
||||
closeRequested = true;
|
||||
void closeSftpHandle().catch(() => {
|
||||
// Ignore close failures while cleaning up a cancelled or timed-out handle.
|
||||
});
|
||||
};
|
||||
const requestAbort = (err) => {
|
||||
if (!abortController.signal.aborted) {
|
||||
abortController.abort(err);
|
||||
}
|
||||
if (!forceCloseTimer && !closeRequested) {
|
||||
forceCloseTimer = setTimeout(() => {
|
||||
forceCloseTimer = null;
|
||||
closeSftpInBackground();
|
||||
}, cancelCleanupGraceMs);
|
||||
}
|
||||
};
|
||||
const unregisterSftpOp = registerSftpOp(chatSessionId, params.sessionId, () => {
|
||||
if (!cancellationError) {
|
||||
cancellationError = new Error("Cancelled");
|
||||
}
|
||||
requestAbort(cancellationError);
|
||||
closeRequested = true;
|
||||
return closeSftpHandle().catch(() => {
|
||||
// Ignore close failures while cancelling the SFTP operation.
|
||||
});
|
||||
});
|
||||
try {
|
||||
if (timeoutMs) {
|
||||
timeoutId = setTimeout(() => {
|
||||
if (!timeoutError) {
|
||||
timeoutError = new Error(`${operationName} timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
requestAbort(timeoutError);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
const opened = await sftpBridge.openSftpForSession(null, {
|
||||
sessionId: params.sessionId,
|
||||
encodingStateKey,
|
||||
abortSignal: abortController.signal,
|
||||
timeoutMs,
|
||||
});
|
||||
sftpId = opened?.sftpId;
|
||||
if (!sftpId) throw new Error("Failed to open session-backed SFTP handle");
|
||||
if (timeoutError) {
|
||||
throw timeoutError;
|
||||
}
|
||||
if (cancellationError) {
|
||||
throw cancellationError;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...params,
|
||||
sftpId,
|
||||
abortSignal: abortController.signal,
|
||||
timeoutMs,
|
||||
};
|
||||
const value = await Promise.resolve().then(() => action(payload));
|
||||
if (timeoutError) {
|
||||
throw timeoutError;
|
||||
}
|
||||
if (cancellationError) {
|
||||
throw cancellationError;
|
||||
}
|
||||
return value;
|
||||
} catch (err) {
|
||||
if (timeoutError) {
|
||||
throw timeoutError;
|
||||
}
|
||||
if (cancellationError) {
|
||||
throw cancellationError;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
unregisterSftpOp();
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (forceCloseTimer) {
|
||||
clearTimeout(forceCloseTimer);
|
||||
forceCloseTimer = null;
|
||||
}
|
||||
try {
|
||||
await closeSftpHandle();
|
||||
} catch {
|
||||
// Ignore close failures for one-off internal SFTP handles.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSftpList(params) {
|
||||
const entries = await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.listSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP list", workerChannel: "netcatty:sftp:list" },
|
||||
);
|
||||
return { ok: true, entries };
|
||||
}
|
||||
|
||||
async function handleSftpRead(params) {
|
||||
if (!params?.path) throw new Error("path is required");
|
||||
const content = await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.readSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP read", workerChannel: "netcatty:sftp:read" },
|
||||
);
|
||||
return { ok: true, path: params.path, content };
|
||||
}
|
||||
|
||||
async function handleSftpWrite(params) {
|
||||
if (!params?.path) throw new Error("path is required");
|
||||
if (typeof params?.content !== "string") throw new Error("content is required");
|
||||
await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.writeSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP write", workerChannel: "netcatty:sftp:write" },
|
||||
);
|
||||
return { ok: true, path: params.path };
|
||||
}
|
||||
|
||||
async function handleSftpDownload(params) {
|
||||
if (!params?.remotePath || !params?.localPath) {
|
||||
throw new Error("remotePath and localPath are required");
|
||||
}
|
||||
const transferId = createTransferId();
|
||||
const sourceHostId = getStableTransferHostId(params);
|
||||
reportTransferEvent({
|
||||
type: "queued", transferId, origin: "agent", background: true,
|
||||
direction: "download", sourcePath: params.remotePath, targetPath: params.localPath,
|
||||
sessionId: params.sessionId, startedAt: Date.now(),
|
||||
sourceHostId,
|
||||
});
|
||||
try {
|
||||
const sender = {
|
||||
send(channel, payload) {
|
||||
if (channel === "netcatty:transfer:progress") {
|
||||
reportTransferEvent({ type: "progress", ...payload });
|
||||
} else if (channel === "netcatty:transfer:started") {
|
||||
reportTransferEvent({ type: "started", ...payload });
|
||||
} else if (channel === "netcatty:transfer:queued") {
|
||||
reportTransferEvent({ type: "queued", ...payload });
|
||||
} else if (channel === "netcatty:transfer:paused") {
|
||||
reportTransferEvent({ type: "paused", ...payload });
|
||||
} else if (channel === "netcatty:transfer:cancelled") {
|
||||
reportTransferEvent({ type: "cancelled", ...payload, endedAt: Date.now() });
|
||||
}
|
||||
},
|
||||
};
|
||||
const useOuterAdmission = !!(
|
||||
shouldProxySessionBackedSftpToWorker(params) && transferBridge?.runAdmittedTransfer
|
||||
);
|
||||
const runDownload = (skipAdmission) => withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => transferBridge
|
||||
? transferBridge.startTransfer({ sender }, {
|
||||
transferId,
|
||||
sourcePath: params.remotePath,
|
||||
targetPath: params.localPath,
|
||||
sourceType: "sftp",
|
||||
targetType: "local",
|
||||
sourceSftpId: payload.sftpId,
|
||||
sourceHostId,
|
||||
resumable: true,
|
||||
globalConcurrency: transferBridge.getGlobalTransferConcurrency?.(),
|
||||
// Only skip when outer runAdmittedTransfer already owns the slot.
|
||||
skipAdmission: skipAdmission === true,
|
||||
})
|
||||
: sftpBridge.downloadSftpToLocal(null, payload),
|
||||
{
|
||||
timeoutMs: commandTimeoutMs,
|
||||
operationName: "SFTP download",
|
||||
workerChannel: transferBridge ? "netcatty:transfer:start" : "netcatty:sftp:downloadToLocal",
|
||||
buildWorkerPayload: transferBridge ? (_workerParams, sftpId) => ({
|
||||
transferId,
|
||||
sourcePath: params.remotePath,
|
||||
targetPath: params.localPath,
|
||||
sourceType: "sftp",
|
||||
targetType: "local",
|
||||
sourceSftpId: sftpId,
|
||||
sourceHostId,
|
||||
resumable: true,
|
||||
skipAdmission: true,
|
||||
}) : undefined,
|
||||
},
|
||||
);
|
||||
const result = await (
|
||||
useOuterAdmission
|
||||
? transferBridge.runAdmittedTransfer(
|
||||
{ sender },
|
||||
{ transferId, sourceHostId, globalConcurrency: transferBridge.getGlobalTransferConcurrency?.() },
|
||||
undefined,
|
||||
() => runDownload(true),
|
||||
)
|
||||
: runDownload(false)
|
||||
);
|
||||
if (result?.cancelled || result?.error === "Transfer cancelled") {
|
||||
reportTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
|
||||
return { ok: false, cancelled: true, transferId };
|
||||
}
|
||||
if (result?.error) throw new Error(result.error);
|
||||
reportTransferEvent({ type: "completed", transferId, endedAt: Date.now() });
|
||||
return { ok: true, transferId, ...result };
|
||||
} catch (error) {
|
||||
reportTransferEvent({ type: "failed", transferId, endedAt: Date.now(), error: error?.message || String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSftpUpload(params) {
|
||||
if (!params?.remotePath || !params?.localPath) {
|
||||
throw new Error("remotePath and localPath are required");
|
||||
}
|
||||
const transferId = createTransferId();
|
||||
const targetHostId = getStableTransferHostId(params);
|
||||
reportTransferEvent({
|
||||
type: "queued", transferId, origin: "agent", background: true,
|
||||
direction: "upload", sourcePath: params.localPath, targetPath: params.remotePath,
|
||||
sessionId: params.sessionId, startedAt: Date.now(),
|
||||
targetHostId,
|
||||
});
|
||||
try {
|
||||
const sender = {
|
||||
send(channel, payload) {
|
||||
if (channel === "netcatty:transfer:progress") {
|
||||
reportTransferEvent({ type: "progress", ...payload });
|
||||
} else if (channel === "netcatty:transfer:started") {
|
||||
reportTransferEvent({ type: "started", ...payload });
|
||||
} else if (channel === "netcatty:transfer:queued") {
|
||||
reportTransferEvent({ type: "queued", ...payload });
|
||||
} else if (channel === "netcatty:transfer:paused") {
|
||||
reportTransferEvent({ type: "paused", ...payload });
|
||||
} else if (channel === "netcatty:transfer:cancelled") {
|
||||
reportTransferEvent({ type: "cancelled", ...payload, endedAt: Date.now() });
|
||||
}
|
||||
},
|
||||
};
|
||||
const useOuterAdmissionUpload = !!(
|
||||
shouldProxySessionBackedSftpToWorker(params) && transferBridge?.runAdmittedTransfer
|
||||
);
|
||||
const runUpload = (skipAdmission) => withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => transferBridge
|
||||
? transferBridge.startTransfer({ sender }, {
|
||||
transferId,
|
||||
sourcePath: params.localPath,
|
||||
targetPath: params.remotePath,
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: payload.sftpId,
|
||||
targetHostId,
|
||||
resumable: true,
|
||||
globalConcurrency: transferBridge.getGlobalTransferConcurrency?.(),
|
||||
// Only skip when outer runAdmittedTransfer already owns the slot.
|
||||
skipAdmission: skipAdmission === true,
|
||||
})
|
||||
: sftpBridge.uploadLocalToSftp(null, payload),
|
||||
{
|
||||
timeoutMs: commandTimeoutMs,
|
||||
operationName: "SFTP upload",
|
||||
workerChannel: transferBridge ? "netcatty:transfer:start" : "netcatty:sftp:uploadLocal",
|
||||
buildWorkerPayload: transferBridge ? (_workerParams, sftpId) => ({
|
||||
transferId,
|
||||
sourcePath: params.localPath,
|
||||
targetPath: params.remotePath,
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: sftpId,
|
||||
targetHostId,
|
||||
resumable: true,
|
||||
skipAdmission: true,
|
||||
}) : undefined,
|
||||
},
|
||||
);
|
||||
const result = await (
|
||||
useOuterAdmissionUpload
|
||||
? transferBridge.runAdmittedTransfer(
|
||||
{ sender },
|
||||
{ transferId, targetHostId, globalConcurrency: transferBridge.getGlobalTransferConcurrency?.() },
|
||||
undefined,
|
||||
() => runUpload(true),
|
||||
)
|
||||
: runUpload(false)
|
||||
);
|
||||
if (result?.cancelled || result?.error === "Transfer cancelled") {
|
||||
reportTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
|
||||
return { ok: false, cancelled: true, transferId };
|
||||
}
|
||||
if (result?.error) throw new Error(result.error);
|
||||
reportTransferEvent({ type: "completed", transferId, endedAt: Date.now() });
|
||||
return { ok: true, transferId, ...result };
|
||||
} catch (error) {
|
||||
reportTransferEvent({ type: "failed", transferId, endedAt: Date.now(), error: error?.message || String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSftpMkdir(params) {
|
||||
if (!params?.path) throw new Error("path is required");
|
||||
await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.mkdirSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP mkdir", workerChannel: "netcatty:sftp:mkdir" },
|
||||
);
|
||||
return { ok: true, path: params.path };
|
||||
}
|
||||
|
||||
async function handleSftpDelete(params) {
|
||||
if (!params?.path) throw new Error("path is required");
|
||||
await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.deleteSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP delete", workerChannel: "netcatty:sftp:delete" },
|
||||
);
|
||||
return { ok: true, path: params.path };
|
||||
}
|
||||
|
||||
async function handleSftpRename(params) {
|
||||
if (!params?.oldPath || !params?.newPath) {
|
||||
throw new Error("oldPath and newPath are required");
|
||||
}
|
||||
await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.renameSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP rename", workerChannel: "netcatty:sftp:rename" },
|
||||
);
|
||||
return { ok: true, oldPath: params.oldPath, newPath: params.newPath };
|
||||
}
|
||||
|
||||
async function handleSftpStat(params) {
|
||||
if (!params?.path) throw new Error("path is required");
|
||||
const stat = await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.statSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP stat", workerChannel: "netcatty:sftp:stat" },
|
||||
);
|
||||
return { ok: true, stat };
|
||||
}
|
||||
|
||||
async function handleSftpChmod(params) {
|
||||
if (!params?.path || !params?.mode) throw new Error("path and mode are required");
|
||||
await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.chmodSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP chmod", workerChannel: "netcatty:sftp:chmod" },
|
||||
);
|
||||
return { ok: true, path: params.path, mode: params.mode };
|
||||
}
|
||||
|
||||
async function handleSftpHome(params) {
|
||||
const result = await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.getSftpHomeDir(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP home", workerChannel: "netcatty:sftp:homeDir" },
|
||||
);
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.error || "Could not determine home directory");
|
||||
}
|
||||
return { ok: true, homeDir: result.homeDir };
|
||||
}
|
||||
|
||||
return {
|
||||
getSessionSftpEncodingStateKey,
|
||||
withSessionBackedSftp,
|
||||
handleSftpList,
|
||||
handleSftpRead,
|
||||
handleSftpWrite,
|
||||
handleSftpDownload,
|
||||
handleSftpUpload,
|
||||
handleSftpMkdir,
|
||||
handleSftpDelete,
|
||||
handleSftpRename,
|
||||
handleSftpStat,
|
||||
handleSftpChmod,
|
||||
handleSftpHome,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createSftpHandlerApi };
|
||||
Reference in New Issue
Block a user