Files
NetMesh/electron/bridges/mcpServerBridge.cjs

2277 lines
80 KiB
JavaScript
Raw Normal View History

/**
* MCP Server Bridge TCP host in Electron main process
*
* Starts a local TCP server that the netcatty-mcp-server.cjs child process
* connects to. Handles JSON-RPC calls by dispatching to real terminal sessions.
*/
"use strict";
const net = require("node:net");
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { existsSync } = require("node:fs");
const { toUnpackedAsarPath, getFreshIdlePrompt, formatSyntheticEcho } = require("./ai/shellUtils.cjs");
const { appendVaultAgentGuidance } = require("../shared/vaultAgentGuidance.cjs");
const {
checkBlocklistForShell,
checkBlocklistCommonOnly,
resolveSessionBlocklistShellKind,
} = require("./ai/commandSafety.cjs");
const { execViaPty, startPtyJob, execViaChannel, execViaRawPty } = require("./ai/ptyExec.cjs");
const { safeSend } = require("./ipcUtils.cjs");
const { getCliDiscoveryFilePath } = require("../cli/discoveryPath.cjs");
const { EXTERNAL_MCP_CHAT_SESSION_ID } = require("../cli/externalMcpDiscoveryPath.cjs");
const sftpBridge = require("./sftpBridge.cjs");
const portForwardingBridge = require("./portForwardingBridge.cjs");
const DEBUG_MCP = process.env.NETCATTY_MCP_DEBUG === "1";
/** Optional external-MCP activity / host-ready hooks (set by externalMcpController). */
let externalMcpActivityHook = null;
let externalMcpHostReadyHook = null;
function debugLog(...args) {
if (!DEBUG_MCP) return;
console.error("[MCP Bridge:debug]", ...args);
}
let sessions = null; // Map<sessionId, { sshClient, stream, pty, proc, conn, ... }>
let terminalWorkerManager = null;
let fileTransferBridge = null;
let tcpServer = null;
let tcpPort = null;
let authToken = null; // Random token generated when TCP server starts
// Dedicated token for External MCP discovery. Rotated on enable/disable so a
// stale discovery file cannot keep writing after External MCP is turned off.
let externalAuthToken = null;
let pendingHostStart = null; // { promise, server, cancel }
let electronModule = null;
let cliDiscoveryFilePath = getCliDiscoveryFilePath();
// Track which sockets have completed authentication
const authenticatedSockets = new WeakSet();
// Sockets authenticated with the External MCP token (or that used the reserved scope).
const externalMcpSockets = new Set();
function markExternalMcpSocket(socket) {
if (!socket || socket.destroyed) return;
externalMcpSockets.add(socket);
if (!socket.__netcattyExternalMcpCleanupBound) {
socket.__netcattyExternalMcpCleanupBound = true;
const cleanup = () => {
externalMcpSockets.delete(socket);
};
socket.once("close", cleanup);
socket.once("end", cleanup);
socket.once("error", cleanup);
}
}
function issueExternalMcpAuthToken() {
externalAuthToken = crypto.randomBytes(32).toString("hex");
return externalAuthToken;
}
function revokeExternalMcpAuthToken() {
externalAuthToken = null;
}
function getExternalMcpAuthToken() {
return externalAuthToken;
}
function disconnectExternalMcpClients() {
// Prefer soft revoke: keep long-lived stdio MCP TCP sockets alive so
// re-enable can resume without restarting Codex/Claude/Grok. Hard destroy
// is reserved for process shutdown paths that call this intentionally.
for (const socket of Array.from(externalMcpSockets)) {
externalMcpSockets.delete(socket);
try {
if (!socket.destroyed) socket.destroy();
} catch {
// Ignore destroy failures while revoking external clients.
}
}
}
// Per-scope metadata: chatSessionId → { sessionIds: string[], metadata: Map<sessionId, meta> }
// Each chat session only sees the hosts registered for its scope.
const scopedMetadata = new Map();
let liveSessionMetadata = new Map();
let sessionMetadataRevision = 0;
let terminalSessionCloseRevision = 0;
const closedTerminalSessionRevisions = new Map();
const MAX_CLOSED_TERMINAL_SESSION_REVISIONS = 2048;
const scopedAttachments = new Map(); // chatSessionId -> Map<filePath, attachment>
const { createSessionOwnershipRegistry } = require("./mcpServerBridge/sessionOwnership.cjs");
const { retainOwnedSessions, mergeRetentionMeta } = require("./mcpServerBridge/retainOwnedSessions.cjs");
const {
createSessionIdleManager,
normalizeSessionIdleTimeoutMinutes,
} = require("./mcpServerBridge/sessionIdleManager.cjs");
const openedSessionOwnership = createSessionOwnershipRegistry();
// Command safety checking (see ./ai/commandSafety.cjs)
let commandBlocklist = [];
// Command timeout in milliseconds (default 60s, synced from user settings)
const MAX_COMMAND_TIMEOUT_SECONDS = 24 * 60 * 60;
let commandTimeoutMs = 60000;
// Max iterations for AI agent loops (default 20, synced from user settings)
let maxIterations = 20;
// Permission mode: 'observer' | 'confirm' | 'auto' (synced from user settings)
let permissionMode = "confirm";
// Cached permission grants synced from renderer (confirm-mode memory table)
let permissionGrantsSnapshot = [];
// Track active PTY executions for cancellation
const activePtyExecs = new Map(); // marker → { ptyStream, cleanup }
const cancelledChatSessions = new Set();
const activeExecChatSessions = new Map(); // chatSessionId -> { sessionId, command, startedAt }
const backgroundJobs = new Map(); // jobId -> job metadata
const workerBackgroundJobs = new Map(); // jobId -> { chatSessionId, sessionId }
const pendingWorkerJobStarts = new Map(); // sessionId -> Set<{ chatSessionId, cancelled }>
const activeSessionExecutions = new Map(); // sessionId -> { kind, startedAt, token }
const activeSessionSftpOps = new Map(); // opId -> { chatSessionId, sessionId, cancel }
const closingTerminalSessions = new Map(); // sessionId -> overlapping close request count
const pendingSessionWriteApprovals = new Map(); // sessionId -> method
const DEFAULT_BACKGROUND_JOB_TIMEOUT_MS = 60 * 60 * 1000;
const DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS = 30 * 1000;
const BACKGROUND_JOB_RETENTION_MS = 10 * 60 * 1000;
const MAX_BACKGROUND_JOB_OUTPUT_CHARS = 256 * 1024;
const SESSION_CLOSE_CLEANUP_TIMEOUT_MS = 5000;
let activeSftpOpSeq = 0;
// ── Approval gate (for confirm mode with SDK/MCP agents) ──
let getMainWindowFn = null; // () => BrowserWindow | null
const pendingApprovals = new Map(); // approvalId → { resolve, chatSessionId }
let approvalIdCounter = 0;
function setMainWindowGetter(fn) {
getMainWindowFn = fn;
debugLog("setMainWindowGetter", { hasGetter: typeof fn === "function" });
}
/**
* Request approval from the renderer process.
* Sends an IPC event and returns a Promise<boolean> that resolves
* when the user approves/rejects in the UI, or auto-denies after timeout.
*/
// External SDK agents (for example Codex) may give up on MCP tool calls after
// about 120 seconds; see openai/codex#6127 ("timed out awaiting tools/call
// after 120s"). Keep the Netcatty-side approval window below that with a small
// buffer so a stale approval cannot still be accepted after the agent has
// already timed out and abandoned the call.
const { MCP_APPROVAL_TIMEOUT_MS } = require("../shared/approvalConstants.cjs");
const APPROVAL_TIMEOUT_MS = MCP_APPROVAL_TIMEOUT_MS;
function listApprovalTargetWindows() {
const windows = [];
const seen = new Set();
const push = (win) => {
if (!win || win.isDestroyed?.()) return;
const id = win.webContents?.id;
if (id != null && seen.has(id)) return;
if (id != null) seen.add(id);
windows.push(win);
};
try {
if (typeof getMainWindowFn === "function") push(getMainWindowFn());
} catch { /* ignore */ }
try {
const windowManager = require("./windowManager.cjs");
push(windowManager.getSettingsWindow?.());
} catch { /* ignore */ }
return windows;
}
function broadcastApprovalEvent(channel, payload) {
for (const win of listApprovalTargetWindows()) {
try {
win.webContents.send(channel, payload);
} catch { /* ignore */ }
}
}
function requestApprovalFromRenderer(toolName, args, chatSessionId) {
return new Promise((resolve) => {
debugLog("requestApprovalFromRenderer", { toolName, args, chatSessionId });
const targets = listApprovalTargetWindows();
if (targets.length === 0) {
// No renderer available — deny to preserve confirm mode safety guarantee
resolve(false);
return;
}
const approvalId = `mcp_approval_${++approvalIdCounter}_${Date.now()}`;
// Hard ceiling from creation — must stay below external SDK tool-call
// timeouts (~120s). Review can cancel the idle timer but not this bound.
const absoluteExpiresAt = Date.now() + APPROVAL_TIMEOUT_MS;
let timerId = null;
const denyTimedOut = () => {
if (!pendingApprovals.has(approvalId)) return;
pendingApprovals.delete(approvalId);
resolve(false);
// Notify renderer(s) to remove the stale approval card
broadcastApprovalEvent('netcatty:ai:mcp:approval-cleared', { approvalIds: [approvalId] });
};
const clearTimer = () => {
if (timerId != null) {
clearTimeout(timerId);
timerId = null;
}
};
const armTimer = (ms) => {
clearTimer();
if (ms <= 0) {
denyTimedOut();
return;
}
timerId = setTimeout(denyTimedOut, ms);
};
// Auto-deny after timeout so SDK/MCP tool calls don't hang indefinitely.
// Idle engagement cancels this arm and re-arms the absolute remainder
// (never auto-approves; never extends past absoluteExpiresAt).
armTimer(APPROVAL_TIMEOUT_MS);
pendingApprovals.set(approvalId, {
resolve: (approved) => {
clearTimer();
resolve(approved);
},
clearTimer,
armAbsoluteTimeout: () => armTimer(absoluteExpiresAt - Date.now()),
absoluteExpiresAt,
idleCancelled: false,
chatSessionId: chatSessionId || null,
});
broadcastApprovalEvent('netcatty:ai:mcp:approval-request', {
approvalId,
toolName,
args,
chatSessionId: chatSessionId || undefined,
});
});
}
function resolveApprovalFromRenderer(approvalId, approved) {
debugLog("resolveApprovalFromRenderer", { approvalId, approved });
const entry = pendingApprovals.get(approvalId);
if (entry) {
pendingApprovals.delete(approvalId);
entry.resolve(approved);
// Main + settings both receive approval requests; clear the sibling card.
notifyRendererApprovalCleared([approvalId]);
}
}
/**
* Drop the idle auto-deny timer after the user starts reviewing an approval card.
* Re-arms the absolute creation deadline so a late approve cannot outlive the
* external agent's tool-call timeout window.
*/
function cancelApprovalTimeoutFromRenderer(approvalId) {
const entry = pendingApprovals.get(approvalId);
if (!entry || entry.idleCancelled) return false;
entry.idleCancelled = true;
entry.clearTimer?.();
entry.armAbsoluteTimeout?.();
return true;
}
function notifyRendererApprovalCleared(approvalIds) {
if (!Array.isArray(approvalIds) || approvalIds.length === 0) return;
broadcastApprovalEvent("netcatty:ai:mcp:approval-cleared", { approvalIds });
}
/**
* Clear pending MCP approvals, optionally scoped to a specific chatSessionId.
* Resolves matched entries with false (denied) to unblock hanging promises.
*/
function clearPendingApprovals(chatSessionId) {
const clearedIds = [];
if (!chatSessionId) {
for (const [id, entry] of pendingApprovals) {
entry.resolve(false);
clearedIds.push(id);
}
pendingApprovals.clear();
notifyRendererApprovalCleared(clearedIds);
return;
}
for (const [id, entry] of pendingApprovals) {
if (entry.chatSessionId === chatSessionId) {
pendingApprovals.delete(id);
entry.resolve(false);
clearedIds.push(id);
}
}
notifyRendererApprovalCleared(clearedIds);
}
function cancelAllPtyExecs() {
for (const [marker, entry] of activePtyExecs) {
try {
if (typeof entry.cancel === "function") entry.cancel();
else entry.cleanup();
} catch { /* ignore */ }
activePtyExecs.delete(marker);
}
activePtyExecs.clear();
}
/**
* Cancel PTY executions scoped to a specific chat session.
* Only affects entries whose chatSessionId matches.
*/
function cancelPtyExecsForSession(chatSessionId) {
if (!chatSessionId) return;
for (const [marker, entry] of activePtyExecs) {
if (entry.chatSessionId !== chatSessionId) continue;
try {
if (typeof entry.cancel === "function") entry.cancel();
else entry.cleanup();
} catch { /* ignore */ }
activePtyExecs.delete(marker);
}
}
const { createBackgroundJobApi } = require("./mcpServerBridge/backgroundJobs.cjs");
const backgroundJobApi = createBackgroundJobApi({
get activeSftpOpSeq() { return activeSftpOpSeq; },
set activeSftpOpSeq(value) { activeSftpOpSeq = value; },
backgroundJobs, activeSessionSftpOps, activeSessionExecutions, closingTerminalSessions, crypto,
BACKGROUND_JOB_RETENTION_MS, DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS, MAX_BACKGROUND_JOB_OUTPUT_CHARS,
SESSION_CLOSE_CLEANUP_TIMEOUT_MS,
debugLog, sftpBridge,
});
const {
createBackgroundJobId,
cancelBackgroundJobsForSession,
cancelBackgroundJobsForTerminalSession,
settleBackgroundJobsForTerminalSession,
registerSftpOp,
cancelSftpOpsForSession,
cancelSftpOpsForTerminalSession,
beginTerminalSessionClose,
endTerminalSessionClose,
cancelAllSftpOps,
readBackgroundJobSnapshot,
createOutputWindow,
refreshRunningJobSnapshot,
storeCompletedJobOutput,
pruneCompletedBackgroundJobs,
collapseCarriageReturns,
serializeBackgroundJob,
describeActiveSessionExecution,
getSessionBusyError,
reserveSessionExecution,
releaseSessionExecution,
} = backgroundJobApi;
let disposeWorkerSessionClosed = null;
function init(deps) {
sessions = deps.sessions;
terminalWorkerManager = deps.terminalWorkerManager || null;
fileTransferBridge = deps.transferBridge || null;
electronModule = deps.electronModule || null;
cliDiscoveryFilePath = deps.cliDiscoveryFilePath || getCliDiscoveryFilePath();
debugLog("init", { hasSessions: Boolean(sessions), hasElectron: Boolean(electronModule) });
if (deps.commandBlocklist) {
commandBlocklist = deps.commandBlocklist;
}
try { disposeWorkerSessionClosed?.dispose?.(); } catch { /* ignore */ }
disposeWorkerSessionClosed = null;
// Only explicit UI/tab closes drop host_open ownership. Shell "exited"
// events (including missing/nonzero exitCode) and recoverable exits
// (error / worker-exit / superseded / quiet transport "closed") keep
// ownership so a disconnected tab can reconnect with the same session id.
// Auto-close clean exits still forget ownership when the renderer closes
// the tab and the worker emits explicit:true.
if (typeof terminalWorkerManager?.onSessionClosed === "function") {
disposeWorkerSessionClosed = terminalWorkerManager.onSessionClosed((event) => {
const sessionId = event?.sessionId;
if (!sessionId) return;
if (event?.explicit === true) {
forgetClosedTerminalSession(sessionId);
}
});
}
}
async function listActivePortForwards() {
if (terminalWorkerManager?.request) {
const tunnels = await terminalWorkerManager.request(
"netcatty:portforward:list",
{},
{},
);
return Array.isArray(tunnels) ? tunnels : [];
}
const tunnels = await portForwardingBridge.listPortForwards();
return Array.isArray(tunnels) ? tunnels : [];
}
function writeCliDiscoveryFile() {
if (!tcpPort || !authToken || !cliDiscoveryFilePath) return;
const payload = {
port: tcpPort,
token: authToken,
pid: process.pid,
permissionMode,
updatedAt: new Date().toISOString(),
};
try {
fs.mkdirSync(path.dirname(cliDiscoveryFilePath), { recursive: true });
fs.writeFileSync(cliDiscoveryFilePath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
} catch (err) {
console.error("[MCP Bridge] Failed to write AI CLI discovery file:", err?.message || err);
}
}
function removeCliDiscoveryFile() {
if (!cliDiscoveryFilePath) return;
try {
fs.rmSync(cliDiscoveryFilePath, { force: true });
} catch (err) {
console.error("[MCP Bridge] Failed to remove AI CLI discovery file:", err?.message || err);
}
}
function shutdownHost({ preserveScopedMetadata = false } = {}) {
removeCliDiscoveryFile();
authToken = null;
if (pendingHostStart?.server && pendingHostStart.server !== tcpServer) {
const inFlightStart = pendingHostStart;
pendingHostStart = null;
inFlightStart.cancel?.();
}
if (tcpServer) {
tcpServer.close();
tcpServer = null;
tcpPort = null;
}
clearPendingApprovals();
cancelAllPtyExecs();
void cancelAllSftpOps();
cancelledChatSessions.clear();
activeExecChatSessions.clear();
pendingSessionWriteApprovals.clear();
if (!preserveScopedMetadata) {
scopedMetadata.clear();
scopedAttachments.clear();
liveSessionMetadata.clear();
}
for (const [, job] of backgroundJobs) {
try {
job.handle?.cancel?.();
} catch {
// Ignore cancellation failures during cleanup
}
}
backgroundJobs.clear();
pendingWorkerJobStarts.clear();
activeSessionExecutions.clear();
sessionIdleManager.clearAll();
}
function echoCommandToSession(session, sessionId, command, { syntheticEcho = true } = {}) {
if (!electronModule || !session?.webContentsId || !command) return;
const contents = electronModule.webContents?.fromId?.(session.webContentsId);
safeSend(contents, "netcatty:data", {
sessionId,
data: formatSyntheticEcho(command),
syntheticEcho,
});
}
function setCommandBlocklist(list) {
// Stored raw; checkCommandSafetyForShell / checkCommandSafetyCommonOnly
// split settings additions from the shell-grouped default table.
commandBlocklist = list || [];
}
function getCommandBlocklist() {
return [...commandBlocklist];
}
function setCommandTimeout(seconds) {
commandTimeoutMs = Math.max(1, Math.min(MAX_COMMAND_TIMEOUT_SECONDS, seconds || 60)) * 1000;
}
function getCommandTimeoutMs() {
return commandTimeoutMs;
}
function setSessionIdleTimeoutMinutes(minutes) {
return sessionIdleManager.setTimeoutMinutes(
normalizeSessionIdleTimeoutMinutes(minutes),
);
}
function getSessionIdleTimeoutMinutes() {
return sessionIdleManager.getTimeoutMinutes();
}
function setMaxIterations(value) {
maxIterations = Math.max(1, Math.min(100, value || 20));
}
function getMaxIterations() {
return maxIterations;
}
function setPermissionMode(mode) {
if (mode === "observer" || mode === "confirm" || mode === "auto") {
permissionMode = mode;
writeCliDiscoveryFile();
try {
if (typeof externalMcpActivityHook?.onPermissionModeChanged === "function") {
externalMcpActivityHook.onPermissionModeChanged();
}
} catch {
// External MCP permission sync is best-effort.
}
}
}
function setExternalMcpHooks(hooks = null) {
externalMcpActivityHook = hooks && typeof hooks === "object" ? hooks : null;
externalMcpHostReadyHook = typeof hooks?.onBridgeHostReady === "function"
? hooks.onBridgeHostReady.bind(hooks)
: null;
}
function notifyExternalMcpActivity(method, params) {
try {
const chatSessionId = params?.chatSessionId || null;
if (chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID) {
externalMcpActivityHook?.recordActivity?.({ method, chatSessionId });
}
} catch {
// Ignore activity hook failures.
}
}
/**
* Build MCP session metadata from the live main-process session map and
* register it under the reserved external MCP chat scope.
*/
function syncLiveSessionsToExternalScope(chatSessionId = EXTERNAL_MCP_CHAT_SESSION_ID) {
const existingScoped = scopedMetadata.get(chatSessionId);
const existingMeta = existingScoped?.metadata || new Map();
const sessionList = [];
if (sessions && typeof sessions.entries === "function") {
for (const [sessionId, session] of sessions.entries()) {
if (!session || typeof session !== "object") continue;
const previous = existingMeta.get(sessionId) || findSessionMetaAcrossScopes(sessionId) || {};
sessionList.push({
sessionId,
hostId: session.hostId || previous.hostId || "",
hostname: session.hostname || session.host || previous.hostname || "",
label: session.label || session.hostname || previous.label || sessionId,
os: session.os || previous.os || "",
username: session.username || previous.username || "",
protocol: session.protocol || session.type || previous.protocol || "",
shellType: session.shellKind || session.shellType || previous.shellType || "",
deviceType: session.deviceType || previous.deviceType || "",
connected: session.connected !== false,
hostChain: Array.isArray(session.hostChain)
? session.hostChain
: (Array.isArray(previous.hostChain) ? previous.hostChain : []),
activePortForwards: Array.isArray(session.activePortForwards)
? session.activePortForwards
: (Array.isArray(previous.activePortForwards) ? previous.activePortForwards : []),
});
}
}
// Terminal-worker mode keeps live sessions off the main-process map. Do not
// wipe renderer-pushed external scope metadata with an empty live snapshot.
if (sessionList.length === 0) {
if (existingScoped?.sessionIds?.length) {
return {
ok: true,
count: existingScoped.sessionIds.length,
chatSessionId,
preserved: true,
};
}
// Only seed when the external scope key has never been written. An explicit
// empty updateSessionMetadata([]) must stay empty (authoritative clear).
if (!scopedMetadata.has(chatSessionId)) {
const seeded = seedExternalScopeFromOtherScopes(chatSessionId);
if (seeded) {
return {
ok: true,
count: seeded,
chatSessionId,
seeded: true,
};
}
}
return { ok: true, count: 0, chatSessionId };
}
updateSessionMetadata(sessionList, chatSessionId);
return { ok: true, count: sessionList.length, chatSessionId };
}
function findSessionMetaAcrossScopes(sessionId, excludeChatSessionId = null) {
let latest = liveSessionMetadata.get(sessionId) || null;
let latestRevision = Number.isSafeInteger(latest?._revision) ? latest._revision : -1;
for (const [scopeId, scoped] of scopedMetadata.entries()) {
if (excludeChatSessionId && scopeId === excludeChatSessionId) continue;
const meta = scoped?.metadata?.get?.(sessionId);
if (!meta) continue;
const revision = Number.isSafeInteger(meta._revision) ? meta._revision : 0;
if (revision > latestRevision) {
latest = meta;
latestRevision = revision;
}
}
return latest;
}
/**
* App-owned live session snapshot. Unlike the External MCP scope, this is
* always refreshed by the main renderer and is never exposed as a scope of
* its own. It only supplies current state for session ids a chat already owns.
*/
function updateLiveSessionMetadata(sessionList) {
const incoming = Array.isArray(sessionList) ? sessionList : [];
const updateRevision = ++sessionMetadataRevision;
const next = new Map();
for (const entry of incoming) {
if (!entry || typeof entry !== "object" || !entry.sessionId) continue;
next.set(entry.sessionId, {
hostname: entry.hostname || "",
label: entry.label || "",
os: entry.os || "",
username: entry.username || "",
protocol: entry.protocol || "",
shellType: entry.shellType || "",
deviceType: entry.deviceType || "",
connected: entry.connected !== false,
hostId: entry.hostId || "",
hostChain: Array.isArray(entry.hostChain) ? entry.hostChain : [],
activePortForwards: Array.isArray(entry.activePortForwards) ? entry.activePortForwards : [],
_revision: updateRevision,
});
}
liveSessionMetadata = next;
return { ok: true, count: next.size };
}
function seedExternalScopeFromOtherScopes(chatSessionId) {
const byId = new Map();
for (const [scopeId, scoped] of scopedMetadata.entries()) {
if (scopeId === chatSessionId || !scoped?.metadata) continue;
for (const [sessionId, meta] of scoped.metadata.entries()) {
if (!sessionId || !meta) continue;
const existing = byId.get(sessionId);
const existingRevision = Number.isSafeInteger(existing?._revision) ? existing._revision : 0;
const revision = Number.isSafeInteger(meta._revision) ? meta._revision : 0;
if (!existing || revision > existingRevision) {
byId.set(sessionId, { sessionId, ...meta });
}
}
}
if (byId.size === 0) return 0;
updateSessionMetadata(Array.from(byId.values()), chatSessionId);
return byId.size;
}
function getPermissionMode() {
return permissionMode;
}
function setChatSessionCancelled(chatSessionId, cancelled) {
if (!chatSessionId) return;
if (cancelled) {
cancelledChatSessions.add(chatSessionId);
} else {
cancelledChatSessions.delete(chatSessionId);
}
}
function isChatSessionCancelled(chatSessionId) {
return Boolean(chatSessionId && cancelledChatSessions.has(chatSessionId));
}
function getActiveChatExecution(chatSessionId) {
if (!chatSessionId) return null;
return activeExecChatSessions.get(chatSessionId) || null;
}
function beginChatExecution(chatSessionId, sessionId, command) {
if (!chatSessionId) return { ok: true, release: () => {} };
const active = getActiveChatExecution(chatSessionId);
if (active) {
return {
ok: false,
active,
};
}
activeExecChatSessions.set(chatSessionId, {
sessionId,
command,
startedAt: Date.now(),
});
let released = false;
return {
ok: true,
release: () => {
if (released) return;
released = true;
const current = activeExecChatSessions.get(chatSessionId);
if (current && current.sessionId === sessionId && current.command === command) {
activeExecChatSessions.delete(chatSessionId);
}
},
};
}
/**
* Register metadata for terminal sessions (called from renderer via IPC).
* Metadata is stored per-scope (chatSessionId) so different AI chat sessions
* only see their own hosts.
* @param {Array<{sessionId, hostname, label, os, username, connected, protocol?, shellType?}>} sessionList
* @param {string} [chatSessionId] - AI chat session ID for per-scope isolation
*/
function updateSessionMetadata(sessionList, chatSessionId) {
const incoming = Array.isArray(sessionList) ? sessionList : [];
const updateRevision = ++sessionMetadataRevision;
// Authoritative empty replace: clear retention ownership for this scope so a
// later non-empty sync cannot resurrect sessions via cross-scope fallback.
if (chatSessionId && incoming.length === 0) {
openedSessionOwnership.releaseScopeOwnership(chatSessionId);
}
// host_open merges the new session into this chat scope, but the AI side
// panel later pushes a full replace of only the currently focused tab —
// which would drop mid-turn opened sessions. Retain ownership-tracked ids.
const effectiveList = chatSessionId
? retainOwnedSessions({
incomingSessions: incoming,
ownedSessionIds: openedSessionOwnership.listOwned(chatSessionId),
previousById: scopedMetadata.get(chatSessionId)?.metadata || null,
// Skip the current scope so a stale connected:false snapshot cannot
// shadow a fresher copy from External MCP / another chat tab.
findFallbackMeta: (sessionId) => findSessionMetaAcrossScopes(sessionId, chatSessionId),
})
: incoming;
debugLog("updateSessionMetadata", {
chatSessionId,
count: effectiveList.length,
sessionIds: effectiveList.map(s => s.sessionId),
});
const ids = effectiveList.map(s => s.sessionId);
const metaMap = new Map();
for (const s of effectiveList) {
metaMap.set(s.sessionId, {
hostname: s.hostname || "",
label: s.label || "",
os: s.os || "",
username: s.username || "",
protocol: s.protocol || "",
shellType: s.shellType || "",
deviceType: s.deviceType || "",
connected: s.connected !== false,
hostId: s.hostId || "",
hostChain: Array.isArray(s.hostChain) ? s.hostChain : [],
activePortForwards: Array.isArray(s.activePortForwards) ? s.activePortForwards : [],
_revision: Number.isSafeInteger(s._revision) ? s._revision : updateRevision,
});
}
// Store per-scope metadata when chatSessionId is provided
if (chatSessionId) {
scopedMetadata.set(chatSessionId, { sessionIds: ids, metadata: metaMap });
}
}
/**
* Merge session metadata into an existing chat scope without dropping
* previously known sessions. Used for the app-wide External MCP scope so a
* single Catty sidebar push cannot shrink the exposed host set.
*/
function mergeSessionMetadata(sessionList, chatSessionId) {
if (!chatSessionId || !Array.isArray(sessionList)) return { ok: false, count: 0 };
const existing = scopedMetadata.get(chatSessionId);
const byId = new Map();
if (existing?.metadata) {
for (const [sessionId, meta] of existing.metadata.entries()) {
byId.set(sessionId, { sessionId, ...meta });
}
}
for (const entry of sessionList) {
if (!entry || typeof entry !== "object" || !entry.sessionId) continue;
const previous = byId.get(entry.sessionId) || {};
byId.set(entry.sessionId, {
sessionId: entry.sessionId,
hostname: entry.hostname || previous.hostname || "",
label: entry.label || previous.label || "",
os: entry.os || previous.os || "",
username: entry.username || previous.username || "",
protocol: entry.protocol || previous.protocol || "",
shellType: entry.shellType || previous.shellType || "",
deviceType: entry.deviceType || previous.deviceType || "",
connected: entry.connected !== undefined ? entry.connected !== false : previous.connected !== false,
hostId: entry.hostId || previous.hostId || "",
hostChain: Array.isArray(entry.hostChain)
? entry.hostChain
: (Array.isArray(previous.hostChain) ? previous.hostChain : []),
activePortForwards: Array.isArray(entry.activePortForwards)
? entry.activePortForwards
: (Array.isArray(previous.activePortForwards) ? previous.activePortForwards : []),
});
}
updateSessionMetadata(Array.from(byId.values()), chatSessionId);
return { ok: true, count: byId.size };
}
function normalizeAttachmentEntry(attachment) {
if (!attachment || typeof attachment !== "object") return null;
const filename = String(attachment.filename || "attachment").trim() || "attachment";
const mediaType = String(attachment.mediaType || "application/octet-stream").trim() || "application/octet-stream";
const base64Data = typeof attachment.base64Data === "string" ? attachment.base64Data : "";
const filePathValue = typeof attachment.filePath === "string" ? attachment.filePath.trim() : "";
if (!base64Data && !filePathValue) return null;
let resolvedPath = filePathValue;
if (filePathValue) {
try {
resolvedPath = path.resolve(filePathValue);
} catch {
resolvedPath = filePathValue;
}
}
const sizeBytes = base64Data ? Buffer.byteLength(base64Data, "base64") : undefined;
return {
filename,
mediaType,
filePath: resolvedPath,
base64Data,
sizeBytes,
};
}
function updateAttachmentMetadata(attachments, chatSessionId) {
if (!chatSessionId || !Array.isArray(attachments)) return;
const existing = scopedAttachments.get(chatSessionId) || new Map();
for (const attachment of attachments) {
const normalized = normalizeAttachmentEntry(attachment);
if (!normalized) continue;
const key = normalized.filePath || normalized.filename;
existing.set(key, normalized);
}
scopedAttachments.set(chatSessionId, existing);
}
function getScopedAttachments(chatSessionId) {
if (!chatSessionId) return [];
return Array.from(scopedAttachments.get(chatSessionId)?.values() || []);
}
function attachmentSummary(attachment) {
return {
filename: attachment.filename,
mediaType: attachment.mediaType,
filePath: attachment.filePath || undefined,
sizeBytes: attachment.sizeBytes,
};
}
function handleListAttachments(params) {
const chatSessionId = params?.chatSessionId;
if (!chatSessionId || typeof chatSessionId !== "string") {
return { ok: false, error: "chatSessionId is required." };
}
return {
ok: true,
attachments: getScopedAttachments(chatSessionId).map(attachmentSummary),
};
}
const TEXT_ATTACHMENT_EXTENSIONS = new Set([
".txt", ".md", ".markdown", ".json", ".jsonc", ".jsonl", ".yaml", ".yml",
".toml", ".ini", ".csv", ".tsv", ".xml", ".html", ".css", ".js", ".jsx",
".ts", ".tsx", ".mjs", ".cjs", ".py", ".sh", ".bash", ".zsh", ".fish",
".rs", ".go", ".java", ".kt", ".rb", ".php", ".sql", ".log", ".conf",
".cfg", ".env", ".gitignore",
]);
function isLikelyTextAttachment(mediaType, filename) {
return /^text\//i.test(mediaType)
|| /^(application\/(json|xml|javascript|x-javascript|typescript|yaml|x-yaml|toml|csv|x-ndjson|ndjson))$/i.test(mediaType)
|| /\+(json|xml)$/i.test(mediaType)
|| TEXT_ATTACHMENT_EXTENSIONS.has(path.extname(filename || "").toLowerCase());
}
function findRegisteredAttachment(params) {
const chatSessionId = params?.chatSessionId;
if (!chatSessionId || typeof chatSessionId !== "string") {
return { error: "chatSessionId is required." };
}
const attachments = getScopedAttachments(chatSessionId);
const requestedPath = typeof params.filePath === "string" && params.filePath.trim()
? path.resolve(params.filePath.trim())
: "";
const requestedName = typeof params.filename === "string" ? params.filename.trim() : "";
if (!requestedPath && !requestedName) {
return { error: "filePath or filename is required." };
}
const attachment = attachments.find((entry) => (
(requestedPath && entry.filePath === requestedPath)
|| (requestedName && entry.filename === requestedName)
));
if (!attachment) {
return { error: "Attachment is not registered for this chat session." };
}
return { attachment };
}
function handleReadAttachment(params) {
const found = findRegisteredAttachment(params);
if (found.error) return { ok: false, error: found.error };
const attachment = found.attachment;
let base64Data = attachment.base64Data;
if (!base64Data && attachment.filePath) {
try {
base64Data = fs.readFileSync(attachment.filePath).toString("base64");
} catch (err) {
return { ok: false, error: err?.message || "Failed to read attachment." };
}
}
if (!base64Data) return { ok: false, error: "Attachment content is unavailable." };
const buffer = Buffer.from(base64Data, "base64");
const result = {
ok: true,
filename: attachment.filename,
mediaType: attachment.mediaType,
filePath: attachment.filePath || undefined,
sizeBytes: buffer.length,
base64Data,
};
if (isLikelyTextAttachment(attachment.mediaType, attachment.filename)) {
result.text = buffer.toString("utf8");
}
return result;
}
/**
* Get scoped session IDs for a specific chat session.
*/
function getScopedSessionIds(chatSessionId) {
if (!chatSessionId) return [];
const scoped = scopedMetadata.get(chatSessionId);
return scoped?.sessionIds || [];
}
/**
* Resolve the effective session scope for a request.
* Explicit per-call scopedSessionIds may only narrow the chat scope, never widen it.
*
* Returns:
* - `null` when no scope context was provided at all
* - `[]` when the effective scope is intentionally empty
* - a concrete array of allowed session IDs otherwise
*/
function resolveScopedSessionIds(chatSessionId, explicitScopedIds = null) {
const hasExplicitScope = Array.isArray(explicitScopedIds);
const hasChatScope = typeof chatSessionId === "string" && chatSessionId.length > 0;
if (!hasExplicitScope && !hasChatScope) {
return null;
}
if (!hasChatScope) {
return explicitScopedIds;
}
const chatScopedIds = getScopedSessionIds(chatSessionId);
if (!hasExplicitScope) {
return chatScopedIds;
}
const chatScopedSet = new Set(chatScopedIds);
return explicitScopedIds.filter((sessionId) => chatScopedSet.has(sessionId));
}
/**
* Look up metadata for a sessionId, scoped to a specific chat session.
* Falls back to session object properties if no scoped metadata is found.
* When this scope still has a stale connected:false snapshot (common after
* host_open + tab switch), refresh connected from a fresher cross-scope copy.
* Owned host_open sessions also refresh while still connected so another
* scope's empty activePortForwards reaches getContext without a later sidebar
* replace.
*/
function getSessionMeta(sessionId, chatSessionId) {
if (!chatSessionId) return null;
const scoped = scopedMetadata.get(chatSessionId);
const meta = scoped?.metadata?.get(sessionId) || null;
if (!meta) return null;
const owned = openedSessionOwnership.validate(chatSessionId, sessionId).ok;
// Live non-owned snapshots stay local. Owned sessions (and stale
// connected:false copies) consult other scopes.
if (!owned && meta.connected !== false) return toPublicSessionMeta(meta);
const fresher = findSessionMetaAcrossScopes(sessionId, chatSessionId);
if (!fresher) return toPublicSessionMeta(meta);
if (!owned && fresher.connected === false) return toPublicSessionMeta(meta);
const refreshed = mergeRetentionMeta(meta, fresher);
if (!refreshed) return toPublicSessionMeta(meta);
scoped.metadata.set(sessionId, refreshed);
return toPublicSessionMeta(refreshed);
}
function toPublicSessionMeta(meta) {
if (!meta || typeof meta !== "object") return meta;
const { _revision, ...publicMeta } = meta;
return publicMeta;
}
function buildOpenedSessionMeta(result, sessionId) {
const host = result?.host && typeof result.host === "object" ? result.host : {};
return {
sessionId,
hostname: host.hostname || "",
label: host.label || host.hostname || sessionId,
os: host.os || "",
username: host.username || "",
protocol: result?.protocol || host.protocol || "",
shellType: "",
deviceType: host.deviceType || "",
connected: result?.status === "connected",
hostId: result?.hostId || host.id || "",
hostChain: [],
activePortForwards: [],
};
}
function ensureOpenedSessionMetadata(chatSessionId, sessionId, result) {
if (!chatSessionId || !sessionId) return;
if (scopedMetadata.get(chatSessionId)?.metadata?.has(sessionId)) return;
const latest = findSessionMetaAcrossScopes(sessionId, chatSessionId);
mergeSessionMetadata([
latest ? { sessionId, ...latest } : buildOpenedSessionMeta(result, sessionId),
], chatSessionId);
}
/**
* Drop host_open ownership and scoped metadata after a terminal session is
* actually closed (explicit UI tab close or agent session_close).
*/
function forgetClosedTerminalSession(sessionId) {
if (!sessionId) return;
terminalSessionCloseRevision += 1;
closedTerminalSessionRevisions.delete(sessionId);
closedTerminalSessionRevisions.set(sessionId, terminalSessionCloseRevision);
while (closedTerminalSessionRevisions.size > MAX_CLOSED_TERMINAL_SESSION_REVISIONS) {
closedTerminalSessionRevisions.delete(closedTerminalSessionRevisions.keys().next().value);
}
sessionIdleManager.forgetSession(sessionId);
openedSessionOwnership.forgetSession(sessionId);
liveSessionMetadata.delete(sessionId);
for (const scoped of scopedMetadata.values()) {
scoped.sessionIds = scoped.sessionIds.filter((id) => id !== sessionId);
scoped.metadata.delete(sessionId);
}
}
function forgetUnownedTerminalSessionMetadata(sessionId) {
if (!sessionId) return;
openedSessionOwnership.forgetSession(sessionId);
liveSessionMetadata.delete(sessionId);
for (const scoped of scopedMetadata.values()) {
scoped.sessionIds = scoped.sessionIds.filter((id) => id !== sessionId);
scoped.metadata.delete(sessionId);
}
}
/**
* Run an array of async task factories with a concurrency limit.
*/
/**
* Shell-aware blocklist check. `shellKind` selects the default pattern groups;
* settings entries that are not default patterns always apply. Unknown kinds
* keep the strict full default table.
*/
function checkCommandSafetyForShell(command, shellKind) {
return checkBlocklistForShell(command, shellKind, commandBlocklist);
}
/**
* Settings additions plus shell-independent (common) defaults. For
* metadata-only call sites with no live session: the terminal worker re-runs
* the shell-selected defaults on the live session before execution.
*/
function checkCommandSafetyCommonOnly(command) {
return checkBlocklistCommonOnly(command, commandBlocklist);
}
// ── TCP Server ──
function getOrCreateHost() {
if (tcpServer && tcpPort) return Promise.resolve(tcpPort);
if (pendingHostStart?.promise) return pendingHostStart.promise;
// Generate a random auth token for this server instance
authToken = crypto.randomBytes(32).toString("hex");
const server = net.createServer((socket) => {
debugLog("TCP client connected");
handleConnection(socket);
});
const startState = {
promise: null,
server,
cancel: null,
};
const startPromise = new Promise((resolve, reject) => {
let settled = false;
const finishReject = (err) => {
if (settled) return;
settled = true;
if (pendingHostStart === startState) {
pendingHostStart = null;
}
if (tcpServer !== server) {
authToken = null;
}
reject(err);
};
const finishResolve = (value) => {
if (settled) return;
settled = true;
if (pendingHostStart === startState) {
pendingHostStart = null;
}
resolve(value);
};
startState.cancel = () => {
try {
server.close();
} catch {
// Ignore close failures while aborting a host startup.
}
finishReject(new Error("TCP bridge startup cancelled"));
};
server.listen(0, "127.0.0.1", () => {
if (settled) {
try {
server.close();
} catch {
// Ignore close failures for a host that was already cancelled.
}
return;
}
tcpPort = server.address().port;
tcpServer = server;
debugLog("TCP server listening", { port: tcpPort });
writeCliDiscoveryFile();
try {
externalMcpHostReadyHook?.({ port: tcpPort, token: authToken });
} catch {
// External MCP host-ready sync is best-effort.
}
finishResolve(tcpPort);
});
server.on("error", (err) => {
console.error("[MCP Bridge] TCP server error:", err.message);
finishReject(err);
});
});
startState.promise = startPromise;
pendingHostStart = startState;
return startPromise;
}
const MAX_TCP_BUFFER = 10 * 1024 * 1024; // 10MB
function handleConnection(socket) {
let buffer = "";
socket.setEncoding("utf-8");
socket.on("data", (chunk) => {
if (buffer.length + chunk.length > MAX_TCP_BUFFER) {
console.error("[MCP Bridge] TCP buffer exceeded max size, dropping connection");
socket.destroy();
return;
}
const previousBufferLength = buffer.length;
buffer += chunk;
let consumedUntil = 0;
let searchFrom = previousBufferLength;
let newlineIdx;
while ((newlineIdx = buffer.indexOf("\n", searchFrom)) !== -1) {
const line = buffer.slice(consumedUntil, newlineIdx);
consumedUntil = newlineIdx + 1;
searchFrom = consumedUntil;
if (!line.trim()) continue;
debugLog("Incoming line", line);
handleMessage(socket, line);
}
if (consumedUntil > 0) buffer = buffer.slice(consumedUntil);
});
socket.on("error", () => {
// Client disconnected — nothing to do
});
}
async function handleMessage(socket, line) {
let msg;
try {
msg = JSON.parse(line);
} catch {
return;
}
const { id, method, params } = msg;
debugLog("handleMessage", { id, method, params });
if (id == null || !method) return;
// ── Authentication gate ──
// The first message from any connection MUST be auth/verify with the correct token.
// All other methods are rejected until the socket is authenticated.
if (!authenticatedSockets.has(socket)) {
const presentedToken = typeof params?.token === "string" ? params.token : "";
const isCattyToken = Boolean(presentedToken && authToken && presentedToken === authToken);
const isExternalToken = Boolean(
presentedToken && externalAuthToken && presentedToken === externalAuthToken,
);
if (method === "auth/verify" && (isCattyToken || isExternalToken)) {
if (isExternalToken && !externalMcpActivityHook?.isEnabled?.()) {
const response = JSON.stringify({
jsonrpc: "2.0",
id,
error: {
code: -32001,
message: "External MCP is disabled. Re-enable it in Netcatty Settings → AI.",
},
}) + "\n";
if (!socket.destroyed) {
socket.write(response);
socket.destroy();
}
return;
}
debugLog("auth/verify success", { external: isExternalToken });
authenticatedSockets.add(socket);
if (isExternalToken) {
markExternalMcpSocket(socket);
}
const response = JSON.stringify({ jsonrpc: "2.0", id, result: { ok: true } }) + "\n";
if (!socket.destroyed) socket.write(response);
return;
}
console.warn("[MCP Bridge] auth/verify failed or unexpected first method", method);
// Wrong token or wrong method — reject and close
const response = JSON.stringify({
jsonrpc: "2.0",
id,
error: { code: -32001, message: "Authentication required. Send auth/verify with valid token first." },
}) + "\n";
if (!socket.destroyed) {
socket.write(response);
socket.destroy();
}
return;
}
try {
const callParams = { ...(params || {}) };
// External-token sockets always operate under the reserved app-wide scope so
// callers cannot omit chatSessionId and widen access via scopedSessionIds.
if (externalMcpSockets.has(socket)) {
callParams.chatSessionId = EXTERNAL_MCP_CHAT_SESSION_ID;
}
const isExternalScope = callParams.chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID;
if (isExternalScope) {
markExternalMcpSocket(socket);
}
// External MCP clients keep an authenticated TCP socket after disable unless
// we reject reserved-scope RPCs (and previously marked external sockets).
if (
(isExternalScope || externalMcpSockets.has(socket))
&& !externalMcpActivityHook?.isEnabled?.()
) {
throw new Error(
"External MCP is disabled. Re-enable it in Netcatty Settings → AI.",
);
}
notifyExternalMcpActivity(method, callParams);
const result = await dispatch(method, callParams);
const response = JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n";
if (!socket.destroyed) socket.write(response);
} catch (err) {
const response = JSON.stringify({
jsonrpc: "2.0",
id,
error: { code: -32000, message: err?.message || String(err) },
}) + "\n";
if (!socket.destroyed) socket.write(response);
}
}
// ── RPC Dispatch ──
const {
evaluateRpcPermission,
evaluatePermissionWithGrants,
USER_DENIED_MESSAGE,
} = require("../capabilities/policy.cjs");
const { CAPABILITY_SURFACES } = require("../capabilities/constants.cjs");
const { getCapabilityByRpcMethod } = require("../capabilities/registry.cjs");
const { listMcpTools } = require("../capabilities/codegen/toolSurfaces.cjs");
const {
createCapabilityRpcDispatcher,
UNROUTED,
} = require("./mcpServerBridge/capabilityRpcDispatch.cjs");
const { buildBuiltinRpcHandlerRegistry } = require("./mcpServerBridge/builtinRpcHandlers.cjs");
const { createSessionService } = require("../capabilities/services/sessionService.cjs");
let invokeVaultAgentFn = null;
function setVaultAgentInvoker(fn) {
invokeVaultAgentFn = typeof fn === "function" ? fn : null;
}
let sessionService = null;
const sessionIdleManager = createSessionIdleManager({
onIdle: async ({ chatSessionId, sessionId }, activityVersion) => {
const hasWorkerJob = await hasActiveWorkerJobForTerminalSession(sessionId);
if (!sessionIdleManager.isIdleCheckCurrent(sessionId, activityVersion)) {
return;
}
if (activeSessionExecutions.has(sessionId) || hasWorkerJob) {
sessionIdleManager.resume(sessionId);
return;
}
if (!sessionIdleManager.beginIdleClose(sessionId, activityVersion)) {
return;
}
await sessionService?.closeTracked({ chatSessionId, sessionId });
},
});
function reportOpenedSessionActivity(event = {}) {
const sessionId = event?.sessionId;
if (!sessionId) return false;
if (event.phase === "closed") {
forgetClosedTerminalSession(sessionId);
return true;
}
if (event.phase === "begin") {
return sessionIdleManager.beginActivity(null, sessionId);
}
if (event.phase === "end") {
return sessionIdleManager.endActivity(null, sessionId);
}
return sessionIdleManager.touch(null, sessionId);
}
sessionService = createSessionService({
invokeSessionAgent: (...args) => {
if (typeof invokeVaultAgentFn !== "function") {
return Promise.resolve({ ok: false, error: "Vault agent bridge is unavailable." });
}
return invokeVaultAgentFn(...args);
},
validateClose: (params = {}) => {
const scopeErr = validateSessionScope(
params.sessionId,
params.chatSessionId,
params.scopedSessionIds,
);
if (scopeErr) return { ok: false, error: scopeErr };
if (sessionIdleManager.isClosing(params.sessionId)) {
return { ok: false, error: `Session "${params.sessionId}" is closing.` };
}
return openedSessionOwnership.validate(params.chatSessionId, params.sessionId);
},
beforeClose: async (params = {}) => {
sessionIdleManager.beginClose(params.sessionId);
beginTerminalSessionClose(params.sessionId);
cancelBackgroundJobsForTerminalSession(params.sessionId);
await cancelWorkerBackgroundJobsForTerminalSession(params.sessionId);
await cancelSftpOpsForTerminalSession(params.sessionId);
},
afterClose: (params = {}, outcome = {}) => {
endTerminalSessionClose(params.sessionId);
if (outcome.closed) return;
if (outcome.notFound) {
forgetClosedTerminalSession(params.sessionId);
return;
}
sessionIdleManager.resume(params.sessionId);
},
onClosed: async (sessionId) => {
await settleBackgroundJobsForTerminalSession(sessionId);
forgetClosedTerminalSession(sessionId);
},
});
const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
invokeVaultAgent: (...args) => {
if (typeof invokeVaultAgentFn !== "function") {
return Promise.resolve({ ok: false, error: "Vault agent bridge is unavailable." });
}
return invokeVaultAgentFn(...args);
},
evaluatePermissionWithGrants,
get permissionMode() {
return permissionMode;
},
get permissionGrantsSnapshot() {
return permissionGrantsSnapshot;
},
isChatSessionCancelled,
requestApprovalFromRenderer,
USER_DENIED_MESSAGE,
listPortForwards: () => listActivePortForwards(),
sessionService,
captureHostOpenScope: (chatSessionId) => ({
ownershipGeneration: openedSessionOwnership.captureGeneration(chatSessionId),
closeRevision: terminalSessionCloseRevision,
}),
onHostOpened: async (chatSessionId, sessionId, generation, result) => {
// The renderer creates the tab before replying to host_open. If the user
// explicitly closes it while that reply is still in flight, the eventual
// result must not revive ownership or scope metadata for the closed tab.
if ((closedTerminalSessionRevisions.get(sessionId) || 0) > generation?.closeRevision) return;
if (!openedSessionOwnership.register(
chatSessionId,
sessionId,
generation?.ownershipGeneration ?? generation,
)) {
// The chat may have been deleted while the renderer was creating the
// terminal. The generation revoke correctly rejects ownership, but the
// newly created backend session must also be reclaimed or it becomes an
// unowned session that no agent can close.
// host_open mints a fresh tab/session id. A reconnect with that id is the
// same logical tab and must also be reclaimed after its initiating chat
// was deleted; unrelated sessions do not reuse the minted id.
sessionIdleManager.track(chatSessionId, sessionId);
await sessionService.closeTracked({ chatSessionId, sessionId });
// A failed close stays idle-tracked so the normal bounded retry can try
// again. Remove only scope/live metadata here so a deleted chat cannot be
// recreated by the renderer's late merge.
forgetUnownedTerminalSessionMetadata(sessionId);
return;
}
closedTerminalSessionRevisions.delete(sessionId);
// A full empty scope replace can land after the renderer's best-effort
// host_open merge but before this async operation completes. Once the open
// succeeds, restore the returned session atomically with its ownership.
ensureOpenedSessionMetadata(chatSessionId, sessionId, result);
sessionIdleManager.track(chatSessionId, sessionId);
},
});
/**
* Validate that a sessionId is allowed in the current scope.
* Explicit per-call scopedSessionIds can only narrow the effective scope;
* they are intersected with per-chatSession scoped metadata when both exist.
*
* An explicit empty array (`[]`) means "no access" not "fall through to
* global scope" matching the documented behavior in handleGetContext.
*/
function validateSessionScope(sessionId, chatSessionId, explicitScopedIds = null) {
if (!sessionId) return null; // will fail at handler level
const resolvedScopedIds = resolveScopedSessionIds(chatSessionId, explicitScopedIds);
if (resolvedScopedIds === null) {
return "chatSessionId or scopedSessionIds is required.";
}
debugLog("validateSessionScope", {
sessionId,
chatSessionId,
explicitScopedIds,
resolvedScopedIds,
});
if (!resolvedScopedIds.includes(sessionId)) {
return `Session "${sessionId}" is not in the current scope.`;
}
return null;
}
function isNetworkDeviceLikeMeta(meta) {
const protocol = meta?.protocol || "";
const isSshOrSerial = protocol === "ssh" || protocol === "serial";
return (meta?.deviceType === "network" && isSshOrSerial) || protocol === "serial";
}
function buildHostFromMetadata(sessionId, meta) {
return {
sessionId,
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 : [],
};
}
function pickToolHints(toolMap, hints) {
return Object.fromEntries(
Object.entries(hints)
.map(([key, capabilityId]) => [key, toolMap.get(capabilityId)])
.filter(([, toolName]) => Boolean(toolName)),
);
}
function buildMcpToolHints() {
const toolMap = new Map(listMcpTools().map((tool) => [tool.capabilityId, tool.mcpTool]));
return {
environment: toolMap.get("session.environment"),
terminal: pickToolHints(toolMap, {
execute: "terminal.execute",
start: "terminal.start",
poll: "terminal.poll",
stop: "terminal.stop",
}),
attachments: pickToolHints(toolMap, {
list: "attachment.list",
read: "attachment.read",
}),
sftp: pickToolHints(toolMap, {
list: "sftp.list",
read: "sftp.read",
write: "sftp.write",
download: "sftp.download",
upload: "sftp.upload",
}),
};
}
function buildTerminalToolGuidance(toolHints) {
const terminal = toolHints?.terminal || {};
if (terminal.execute && terminal.start && terminal.poll && terminal.stop) {
return `For terminal commands, use \`${terminal.execute}\` for short commands and \`${terminal.start}\`, \`${terminal.poll}\`, and \`${terminal.stop}\` for long-running commands. `;
}
if (Object.keys(terminal).length > 0) {
return "For terminal commands, use the terminal tools listed in tools.terminal. ";
}
return "";
}
async function handleWorkerTerminalExec(params = {}) {
const { sessionId, command } = params;
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 };
}
if (!terminalWorkerManager?.request) {
return { ok: false, error: "Session not found" };
}
const chatSessionId = params?.chatSessionId || null;
const meta = getSessionMeta(sessionId, chatSessionId) || {};
if (!isNetworkDeviceLikeMeta(meta)) {
// meta.shellType is not reported for remote sessions yet; until it is,
// defer the shell-selected default patterns to the terminal worker, which
// resolves the shell kind from the live session and idle prompt.
const safety = meta.shellType
? checkCommandSafetyForShell(command, meta.shellType)
: checkCommandSafetyCommonOnly(command);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
}
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,
};
}
try {
return await terminalWorkerManager.request("netcatty:ai:exec", {
sessionId,
command,
chatSessionId,
commandTimeoutMs,
sessionMeta: meta,
enforceWallTimeout: true,
commandBlocklist,
}, {});
} catch (err) {
return { ok: false, error: err?.message || String(err) };
} finally {
releaseSessionExecution(sessionId, sessionToken);
executionLock.release();
}
}
async function handleWorkerJobStart(params = {}) {
const { sessionId, command } = params;
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 };
}
if (!terminalWorkerManager?.request) {
return { ok: false, error: "Session not found" };
}
if (closingTerminalSessions.has(sessionId)) {
return { ok: false, error: `Session "${sessionId}" is closing.` };
}
const chatSessionId = params?.chatSessionId || null;
const meta = getSessionMeta(sessionId, chatSessionId) || {};
if (!isNetworkDeviceLikeMeta(meta)) {
// Same shell-aware deferral as handleWorkerTerminalExec: the terminal
// worker re-runs the shell-selected defaults on the live session.
const safety = meta.shellType
? checkCommandSafetyForShell(command, meta.shellType)
: checkCommandSafetyCommonOnly(command);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
}
const pendingStart = { chatSessionId, cancelled: false };
let pendingForSession = pendingWorkerJobStarts.get(sessionId);
if (!pendingForSession) {
pendingForSession = new Set();
pendingWorkerJobStarts.set(sessionId, pendingForSession);
}
pendingForSession.add(pendingStart);
try {
const result = await terminalWorkerManager.request("netcatty:ai:jobStart", {
sessionId,
command,
chatSessionId,
commandTimeoutMs,
sessionMeta: meta,
commandBlocklist,
}, {});
if (result?.ok && result.jobId) {
if (pendingStart.cancelled || closingTerminalSessions.has(sessionId)) {
await waitForWorkerCleanup(terminalWorkerManager.request("netcatty:ai:jobStop", {
jobId: result.jobId,
sessionId,
chatSessionId,
}, {}));
return { ok: false, error: "Session is closing.", jobId: result.jobId, status: "cancelled" };
}
workerBackgroundJobs.set(result.jobId, {
chatSessionId: chatSessionId || null,
sessionId,
});
}
return result;
} catch (err) {
return { ok: false, error: err?.message || String(err) };
} finally {
pendingForSession.delete(pendingStart);
if (pendingForSession.size === 0) pendingWorkerJobStarts.delete(sessionId);
}
}
function getWorkerJob(jobId, chatSessionId) {
const job = workerBackgroundJobs.get(jobId);
if (!job) return null;
if (job.chatSessionId && (!chatSessionId || chatSessionId !== job.chatSessionId)) {
return null;
}
return job;
}
async function handleWorkerJobPoll(params = {}) {
const { jobId, chatSessionId, scopedSessionIds } = params || {};
if (!jobId) throw new Error("jobId is required");
const job = getWorkerJob(jobId, chatSessionId || null);
if (!job || !terminalWorkerManager?.request) {
return { ok: false, error: "Background job not found" };
}
if (job.sessionId) {
const scopeErr = validateSessionScope(job.sessionId, chatSessionId || null, scopedSessionIds);
if (scopeErr) return { ok: false, error: scopeErr };
}
const result = await terminalWorkerManager.request("netcatty:ai:jobPoll", params, {});
if (result?.completed) {
workerBackgroundJobs.delete(jobId);
}
return result;
}
async function handleWorkerJobStop(params = {}) {
const { jobId, chatSessionId, scopedSessionIds } = params || {};
if (!jobId) throw new Error("jobId is required");
const job = getWorkerJob(jobId, chatSessionId || null);
if (!job || !terminalWorkerManager?.request) {
return { ok: false, error: "Background job not found" };
}
if (Array.isArray(scopedSessionIds) && job.sessionId && !scopedSessionIds.includes(job.sessionId)) {
return { ok: false, error: `Session "${job.sessionId}" is not in the current scope.` };
}
const result = await terminalWorkerManager.request("netcatty:ai:jobStop", params, {});
if (result?.completed) {
workerBackgroundJobs.delete(jobId);
}
return result;
}
async function hasActiveWorkerJobForTerminalSession(sessionId) {
const matchingJobs = Array.from(workerBackgroundJobs.entries())
.filter(([, job]) => job?.sessionId === sessionId);
for (const [jobId, job] of matchingJobs) {
if (!terminalWorkerManager?.request) return true;
try {
const result = await terminalWorkerManager.request("netcatty:ai:jobPoll", {
jobId,
sessionId,
chatSessionId: job.chatSessionId || null,
offset: 0,
}, {});
if (result?.completed || (result?.ok === false && /not found/i.test(result?.error || ""))) {
workerBackgroundJobs.delete(jobId);
continue;
}
return true;
} catch {
// A transient worker failure should not close a possibly active session.
return true;
}
}
return false;
}
function cancelWorkerBackgroundJobsForSession(chatSessionId) {
if (!chatSessionId) return;
for (const pendingStarts of pendingWorkerJobStarts.values()) {
for (const pendingStart of pendingStarts) {
if (pendingStart.chatSessionId === chatSessionId) pendingStart.cancelled = true;
}
}
for (const [jobId, job] of workerBackgroundJobs) {
if (job.chatSessionId === chatSessionId) {
workerBackgroundJobs.delete(jobId);
}
}
try {
terminalWorkerManager?.send?.("netcatty:ai:catty:cancel", { chatSessionId }, {});
} catch {
// Worker may already be gone while cancelling a torn-down chat/session.
}
}
function waitForWorkerCleanup(requestPromise) {
const timeoutMs = Math.max(1, Math.min(commandTimeoutMs, 5000));
let timer = null;
const timeoutPromise = new Promise((resolve) => {
timer = setTimeout(resolve, timeoutMs);
});
return Promise.race([requestPromise, timeoutPromise]).finally(() => {
if (timer) clearTimeout(timer);
});
}
async function cancelWorkerBackgroundJobsForTerminalSession(sessionId) {
if (!sessionId) return;
for (const pendingStart of pendingWorkerJobStarts.get(sessionId) || []) {
pendingStart.cancelled = true;
}
const matchingJobs = [];
const pending = [];
for (const [jobId, job] of workerBackgroundJobs) {
if (job.sessionId !== sessionId) continue;
matchingJobs.push(jobId);
if (terminalWorkerManager?.request) {
pending.push(waitForWorkerCleanup(terminalWorkerManager.request("netcatty:ai:jobStop", {
jobId,
sessionId,
chatSessionId: job.chatSessionId || null,
}, {})));
}
}
if (pending.length) await Promise.allSettled(pending);
for (const jobId of matchingJobs) workerBackgroundJobs.delete(jobId);
}
let builtinRpcHandlerRegistry = null;
function getBuiltinRpcHandlerRegistry() {
if (!builtinRpcHandlerRegistry) {
builtinRpcHandlerRegistry = buildBuiltinRpcHandlerRegistry({
"session.environment": handleGetContext,
"meta.status": handleGetStatus,
"attachment.list": handleListAttachments,
"attachment.read": handleReadAttachment,
"terminal.execute": handleExec,
"sftp.list": handleSftpList,
"sftp.read": handleSftpRead,
"sftp.write": handleSftpWrite,
"sftp.download": handleSftpDownload,
"sftp.upload": handleSftpUpload,
"sftp.mkdir": handleSftpMkdir,
"sftp.delete": handleSftpDelete,
"sftp.rename": handleSftpRename,
"sftp.stat": handleSftpStat,
"sftp.chmod": handleSftpChmod,
"sftp.home": handleSftpHome,
"session.cancel": handleSetCancelled,
"terminal.start": handleJobStart,
"terminal.poll": handleJobPoll,
"terminal.stop": handleJobStop,
});
}
return builtinRpcHandlerRegistry;
}
async function dispatch(method, params) {
debugLog("dispatch", { method, params, permissionMode });
if (!method.startsWith("netcatty/")) {
const capabilityResult = await dispatchCapabilityRpc(method, params || {});
if (capabilityResult !== UNROUTED) {
return capabilityResult;
}
}
const capability = getCapabilityByRpcMethod(method, CAPABILITY_SURFACES.BUILTIN);
const sessionWriteLockId = (capability?.id === "terminal.execute" || capability?.id === "terminal.start")
? params?.sessionId
: null;
pruneCompletedBackgroundJobs();
const permission = evaluatePermissionWithGrants({
rpcMethod: method,
surface: CAPABILITY_SURFACES.BUILTIN,
permissionMode,
params,
context: {
chatSessionCancelled: isChatSessionCancelled(params?.chatSessionId),
},
}, permissionGrantsSnapshot);
if (!permission.allowed) {
return { ok: false, error: permission.error };
}
// Validate session scope *first* so out-of-scope callers cannot infer the
// existence or activity of foreign sessions through busy-state error
// messages, and so requests fail fast without blocking the write lock.
if (method !== "netcatty/getContext" && params?.sessionId) {
const scopeErr = validateSessionScope(params.sessionId, params?.chatSessionId, params?.scopedSessionIds);
if (scopeErr) return { ok: false, error: scopeErr };
}
if (
(capability?.id === "terminal.execute" || capability?.id === "terminal.start")
&& params?.sessionId
&& closingTerminalSessions.has(params.sessionId)
) {
return { ok: false, error: `Session "${params.sessionId}" is closing.` };
}
if ((capability?.id === "terminal.execute" || capability?.id === "terminal.start") && params?.sessionId) {
const busy = getSessionBusyError(params.sessionId);
if (busy) return busy;
}
if (sessionWriteLockId) {
const pendingMethod = pendingSessionWriteApprovals.get(sessionWriteLockId);
if (pendingMethod) {
return {
ok: false,
error: "Session already has another command request awaiting approval or startup. Wait for it to finish before starting a new command.",
};
}
pendingSessionWriteApprovals.set(sessionWriteLockId, method);
}
const tracksSessionActivity = Boolean(
params?.sessionId
&& (
capability?.id === "terminal.execute"
|| capability?.id === "terminal.start"
|| capability?.id?.startsWith("sftp.")
)
);
const activityStarted = tracksSessionActivity
? sessionIdleManager.beginActivity(params?.chatSessionId, params.sessionId)
: false;
try {
// Confirm mode: request user approval for write operations.
// netcatty/jobStop bypasses approval — it's a stop/cancel action that
// must remain available even if the renderer is unavailable; otherwise
// a runaway terminal_start job could not be interrupted at all.
if (permission.requiresApproval) {
const { chatSessionId, ...toolArgs } = params || {};
const approved = await requestApprovalFromRenderer(method, toolArgs, chatSessionId);
if (!approved) {
return { ok: false, error: USER_DENIED_MESSAGE };
}
}
if (
(capability?.id === "terminal.execute" || capability?.id === "terminal.start")
&& params?.sessionId
) {
const scopeErr = validateSessionScope(
params.sessionId,
params?.chatSessionId,
params?.scopedSessionIds,
);
if (scopeErr) return { ok: false, error: scopeErr };
if (closingTerminalSessions.has(params.sessionId)) {
return { ok: false, error: `Session "${params.sessionId}" is closing.` };
}
}
const handler = getBuiltinRpcHandlerRegistry().get(method);
if (!handler) {
throw new Error(`Unknown method: ${method}`);
}
if (capability?.id === "terminal.execute" && params?.sessionId && !sessions?.get?.(params.sessionId)) {
return await handleWorkerTerminalExec(params);
}
if (capability?.id === "terminal.start" && params?.sessionId && !sessions?.get?.(params.sessionId)) {
return await handleWorkerJobStart(params);
}
if (capability?.id === "terminal.poll" && workerBackgroundJobs.has(params?.jobId)) {
return await handleWorkerJobPoll(params);
}
if (capability?.id === "terminal.stop" && workerBackgroundJobs.has(params?.jobId)) {
return await handleWorkerJobStop(params);
}
return await handler(params);
} finally {
if (activityStarted) {
sessionIdleManager.endActivity(params?.chatSessionId, params.sessionId);
}
if (sessionWriteLockId) {
pendingSessionWriteApprovals.delete(sessionWriteLockId);
}
}
}
// ── Handler: getContext ──
async function handleGetContext(params) {
debugLog("handleGetContext:start", { params, sessionCount: sessions?.size || 0 });
const toolHints = buildMcpToolHints();
if (!sessions) {
return {
hosts: [],
instructions: "No sessions available.",
tools: toolHints,
};
}
// chatSessionId may be passed via env for per-scope metadata lookup
const chatSessionId = params?.chatSessionId || null;
// External MCP clients use the reserved app-wide scope; refresh from the live
// session map so newly opened terminals appear without waiting for a renderer push.
// Only sync while External MCP is enabled — otherwise a stale client could
// rebuild the cleared scope after disable/idle timeout.
if (chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID) {
if (!externalMcpActivityHook?.isEnabled?.()) {
return {
environment: "netcatty-terminal",
description: "External MCP is disabled.",
hosts: [],
hostCount: 0,
tools: toolHints,
};
}
syncLiveSessionsToExternalScope(chatSessionId);
}
const explicitScopedIds = Array.isArray(params?.scopedSessionIds)
? params.scopedSessionIds
: null;
const resolvedScopedIds = resolveScopedSessionIds(chatSessionId, explicitScopedIds);
if (resolvedScopedIds === null) {
throw new Error("chatSessionId or scopedSessionIds is required.");
}
const hasScopedContext = true;
const scopedIds = resolvedScopedIds ? new Set(resolvedScopedIds) : null;
const hosts = [];
const addedHostIds = new Set();
// When a scoped context exists but currently resolves to zero sessions, treat
// it as "no access" rather than falling back to all sessions.
if (hasScopedContext && (!resolvedScopedIds || resolvedScopedIds.length === 0)) {
return {
environment: "netcatty-terminal",
description: "No hosts are available in the current scope.",
hosts: [],
hostCount: 0,
tools: toolHints,
};
}
for (const [sessionId, session] of sessions.entries()) {
if (scopedIds && !scopedIds.has(sessionId)) continue;
const ptyStream = session.stream || session.pty || session.proc;
const sshClient = session.conn || session.sshClient;
const hasCommandablePty = ptyStream && typeof ptyStream.write === "function";
const hasSshExec = sshClient && typeof sshClient.exec === "function";
const hasSerialPort = session.serialPort && typeof session.serialPort.write === "function";
if (!hasCommandablePty && !hasSshExec && !hasSerialPort) continue;
// Look up metadata scoped to this chat session
const meta = getSessionMeta(sessionId, chatSessionId) || {};
hosts.push({
sessionId,
hostname: meta.hostname || session.hostname || "",
label: meta.label || session.label || "",
os: meta.os || "",
username: meta.username || session.username || "",
protocol: meta.protocol || session.protocol || session.type || "",
shellType: meta.shellType || session.shellKind || "",
deviceType: meta.deviceType || "",
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn || ptyStream || session.serialPort),
hostId: meta.hostId || "",
hostChain: meta.hostChain || [],
activePortForwards: meta.activePortForwards || [],
});
addedHostIds.add(sessionId);
}
if (resolvedScopedIds?.length) {
for (const sessionId of resolvedScopedIds) {
if (addedHostIds.has(sessionId)) continue;
const meta = getSessionMeta(sessionId, chatSessionId);
if (!meta || meta.connected === false) continue;
hosts.push(buildHostFromMetadata(sessionId, meta));
addedHostIds.add(sessionId);
}
}
let activePortForwardTunnels = [];
try {
activePortForwardTunnels = await listActivePortForwards();
} catch {
activePortForwardTunnels = [];
}
return {
environment: "netcatty-terminal",
description: appendVaultAgentGuidance(
"You are operating inside Netcatty, a multi-session terminal manager. " +
"The available sessions may be remote hosts, local terminals, Mosh-backed shells, or serial port connections (network devices, embedded systems). " +
"Use the provided tools to execute commands through the sessions exposed by Netcatty. " +
buildTerminalToolGuidance(toolHints) +
"Serial sessions (protocol: serial, shellType: raw) do not run a standard shell — commands are sent as-is. " +
"Network device sessions (deviceType: network) use vendor CLIs (Huawei VRP, Cisco IOS, etc.) — commands are sent as-is without shell wrapping, and exit codes are unavailable. " +
"Vault snippets, port forwarding rules/tunnels, and SFTP read/write tools are available when exposed in the tool list. " +
"Always prefer these tools over suggesting the user to do things manually.",
),
hosts,
hostCount: hosts.length,
activePortForwardTunnels,
tools: toolHints,
};
}
function handleGetStatus() {
return {
ok: true,
environment: "netcatty-terminal",
permissionMode,
approvalTimeoutMs: APPROVAL_TIMEOUT_MS,
commandTimeoutMs,
maxIterations,
tcpPort,
sessionCount: sessions?.size || 0,
scopedContextCount: scopedMetadata.size,
activeExecutionCount: activePtyExecs.size,
activeSftpOperationCount: activeSessionSftpOps.size,
activeChatExecutionCount: activeExecChatSessions.size,
pendingApprovalCount: pendingApprovals.size,
discoveryFilePath: cliDiscoveryFilePath || null,
discoveryFilePresent: Boolean(cliDiscoveryFilePath && existsSync(cliDiscoveryFilePath)),
};
}
function setPermissionGrants(grants) {
const { sanitizePermissionGrants } = require("../shared/permissionGrants.cjs");
permissionGrantsSnapshot = sanitizePermissionGrants(grants);
}
function getPermissionGrants() {
return permissionGrantsSnapshot;
}
function applyChatSessionCancelled(chatSessionId, cancelled) {
if (!chatSessionId || typeof chatSessionId !== "string") {
throw new Error("chatSessionId is required");
}
if (cancelled) {
setChatSessionCancelled(chatSessionId, true);
cancelPtyExecsForSession(chatSessionId);
cancelBackgroundJobsForSession(chatSessionId);
cancelWorkerBackgroundJobsForSession(chatSessionId);
clearPendingApprovals(chatSessionId);
void cancelSftpOpsForSession(chatSessionId);
} else {
setChatSessionCancelled(chatSessionId, false);
}
return {
ok: true,
chatSessionId,
cancelled: !!cancelled,
};
}
async function handleSetCancelled(params) {
const chatSessionId = params?.chatSessionId;
const cancelled = params?.cancelled !== false;
return applyChatSessionCancelled(chatSessionId, cancelled);
}
const { createSftpHandlerApi } = require("./mcpServerBridge/sftpHandlers.cjs");
const sftpHandlerApi = createSftpHandlerApi({
get commandTimeoutMs() { return commandTimeoutMs; },
get sessions() { return sessions; },
get terminalWorkerManager() { return terminalWorkerManager; },
sftpBridge, registerSftpOp, setTimeout, clearTimeout, AbortController, Promise, Error,
createTransferId: () => require("node:crypto").randomUUID(),
reportTransferEvent: (payload) => broadcastApprovalEvent("netcatty:sftp:global-transfer", payload),
get transferBridge() { return fileTransferBridge; },
});
const {
getSessionSftpEncodingStateKey,
withSessionBackedSftp,
handleSftpList,
handleSftpRead,
handleSftpWrite,
handleSftpDownload,
handleSftpUpload,
handleSftpMkdir,
handleSftpDelete,
handleSftpRename,
handleSftpStat,
handleSftpChmod,
handleSftpHome,
} = sftpHandlerApi;
// ── Handler: exec ──
const { createExecHandlerApi } = require("./mcpServerBridge/execHandlers.cjs");
const execHandlerApi = createExecHandlerApi({
get sessions() { return sessions; },
get commandTimeoutMs() { return commandTimeoutMs; },
DEFAULT_BACKGROUND_JOB_TIMEOUT_MS, DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS, MAX_BACKGROUND_JOB_OUTPUT_CHARS,
backgroundJobs, activePtyExecs,
debugLog, getSessionMeta, checkCommandSafetyForShell, resolveSessionBlocklistShellKind, reserveSessionExecution, releaseSessionExecution,
beginChatExecution, execViaRawPty, execViaPty, execViaChannel, startPtyJob,
getFreshIdlePrompt, echoCommandToSession, createBackgroundJobId, storeCompletedJobOutput,
serializeBackgroundJob, validateSessionScope, Date, Error,
});
const {
resolveExecContext,
handleExec,
handleJobStart,
getScopedJob,
handleJobPoll,
handleJobStop,
} = execHandlerApi;
const { createConfigAndCleanupApi } = require("./mcpServerBridge/configAndCleanup.cjs");
const configAndCleanupApi = createConfigAndCleanupApi({
get authToken() { return authToken; },
get permissionMode() { return permissionMode; },
process, existsSync, path, __dirname, toUnpackedAsarPath, DEBUG_MCP,
getScopedSessionIds, scopedMetadata, scopedAttachments, cancelledChatSessions, cancelBackgroundJobsForSession,
cancelWorkerBackgroundJobsForSession,
cancelWorkerBackgroundJobsForTerminalSession,
clearPendingApprovals, cancelSftpOpsForSession, sftpBridge,
preserveIdleSessionCleanup: sessionIdleManager.scopeCleared,
clearOpenedSessionScope: openedSessionOwnership.clearScope,
});
const { resolveMcpServerRuntimeCommand, buildMcpServerConfig, cleanupScopedMetadata } = configAndCleanupApi;
function cleanup() {
try { disposeWorkerSessionClosed?.dispose?.(); } catch { /* ignore */ }
disposeWorkerSessionClosed = null;
shutdownHost();
}
module.exports = {
init,
setCommandBlocklist,
getCommandBlocklist,
setCommandTimeout,
getCommandTimeoutMs,
setSessionIdleTimeoutMinutes,
getSessionIdleTimeoutMinutes,
reportOpenedSessionActivity,
setMaxIterations,
getMaxIterations,
setPermissionMode,
getPermissionMode,
setPermissionGrants,
getPermissionGrants,
setChatSessionCancelled,
applyChatSessionCancelled,
checkCommandSafetyForShell,
checkCommandSafetyCommonOnly,
resolveSessionBlocklistShellKind,
updateSessionMetadata,
updateLiveSessionMetadata,
mergeSessionMetadata,
updateAttachmentMetadata,
handleListAttachments,
handleReadAttachment,
getScopedSessionIds,
getOrCreateHost,
buildMcpServerConfig,
activePtyExecs,
cancelBackgroundJobsForSession,
cancelAllPtyExecs,
cancelPtyExecsForSession,
cancelWorkerBackgroundJobsForSession,
cancelWorkerBackgroundJobsForTerminalSession,
hasActiveWorkerJobForTerminalSession,
cancelSftpOpsForSession,
getSessionMeta,
forgetClosedTerminalSession,
cleanupScopedMetadata,
cleanup,
shutdownHost,
setMainWindowGetter,
setVaultAgentInvoker,
setExternalMcpHooks,
disconnectExternalMcpClients,
issueExternalMcpAuthToken,
revokeExternalMcpAuthToken,
getExternalMcpAuthToken,
syncLiveSessionsToExternalScope,
requestApprovalFromRenderer,
resolveApprovalFromRenderer,
cancelApprovalTimeoutFromRenderer,
clearPendingApprovals,
reserveSessionExecution,
releaseSessionExecution,
getSessionBusyError,
dispatchBuiltinRpc: dispatch,
EXTERNAL_MCP_CHAT_SESSION_ID,
};