[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:
309
electron/bridges/aiBridge/cattyExecHandlers.cjs
Normal file
309
electron/bridges/aiBridge/cattyExecHandlers.cjs
Normal file
@@ -0,0 +1,309 @@
|
||||
/* eslint-disable no-undef */
|
||||
// Module-level require on purpose: code inside registerCattyExecHandlers
|
||||
// runs under `with (ctx)` where bare `require` resolves to ctx.require
|
||||
// (based in electron/bridges/). Requiring here keeps the path unambiguous.
|
||||
const { formatSyntheticEcho } = require("../ai/shellUtils.cjs");
|
||||
const { ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
|
||||
|
||||
function getWorkerExecutionMeta(mcpServerBridge, sessionId, chatSessionId) {
|
||||
return mcpServerBridge.getSessionMeta?.(sessionId, chatSessionId) || {};
|
||||
}
|
||||
|
||||
function isNetworkDeviceLike(meta) {
|
||||
const protocol = meta?.protocol || "";
|
||||
const isSshOrSerial = protocol === "ssh" || protocol === "serial";
|
||||
return (meta?.deviceType === "network" && isSshOrSerial) || protocol === "serial";
|
||||
}
|
||||
|
||||
async function proxyCattyExecToWorker({
|
||||
event,
|
||||
terminalWorkerManager,
|
||||
mcpServerBridge,
|
||||
sessionId,
|
||||
command,
|
||||
chatSessionId,
|
||||
}) {
|
||||
if (!terminalWorkerManager?.request) {
|
||||
return { ok: false, error: "Session not found" };
|
||||
}
|
||||
|
||||
const busyErr = mcpServerBridge.getSessionBusyError?.(sessionId);
|
||||
if (busyErr) return busyErr;
|
||||
|
||||
const meta = getWorkerExecutionMeta(mcpServerBridge, sessionId, chatSessionId);
|
||||
if (!isNetworkDeviceLike(meta)) {
|
||||
// No live session here: settings additions plus common defaults only; the
|
||||
// terminal worker re-runs the shell-selected defaults on the live session.
|
||||
const safety = meta.shellType
|
||||
? mcpServerBridge.checkCommandSafetyForShell(command, meta.shellType)
|
||||
: mcpServerBridge.checkCommandSafetyCommonOnly(command);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
}
|
||||
|
||||
const reservation = mcpServerBridge.reserveSessionExecution?.(sessionId, "exec");
|
||||
if (reservation && !reservation.ok) return reservation;
|
||||
const sessionToken = reservation?.token;
|
||||
const releaseLock = () => {
|
||||
if (sessionToken) {
|
||||
try { mcpServerBridge.releaseSessionExecution?.(sessionId, sessionToken); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
return await terminalWorkerManager.request("netcatty:ai:exec", {
|
||||
sessionId,
|
||||
command,
|
||||
chatSessionId,
|
||||
commandTimeoutMs: mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000,
|
||||
sessionMeta: meta,
|
||||
commandBlocklist: mcpServerBridge.getCommandBlocklist?.(),
|
||||
}, {
|
||||
webContentsId: event?.sender?.id,
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function registerCattyExecHandlers(ctx) {
|
||||
with (ctx) {
|
||||
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
// Block execution in observer mode (Issue #11)
|
||||
if (mcpServerBridge.getPermissionMode() === "observer") {
|
||||
return { ok: false, error: "Execution blocked: permission mode is 'observer'" };
|
||||
}
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session) {
|
||||
return proxyCattyExecToWorker({
|
||||
event,
|
||||
terminalWorkerManager,
|
||||
mcpServerBridge,
|
||||
sessionId,
|
||||
command,
|
||||
chatSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Honor the per-session execution lock so this IPC path does not race with
|
||||
// long-running background jobs started via terminal_start.
|
||||
const busyErr = mcpServerBridge.getSessionBusyError?.(sessionId);
|
||||
if (busyErr) return busyErr;
|
||||
const reservation = mcpServerBridge.reserveSessionExecution?.(sessionId, "exec");
|
||||
if (reservation && !reservation.ok) return reservation;
|
||||
const sessionToken = reservation?.token;
|
||||
const releaseLock = () => {
|
||||
if (sessionToken) {
|
||||
try { mcpServerBridge.releaseSessionExecution?.(sessionId, sessionToken); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
// Look up device type from metadata (set by renderer from Host.deviceType).
|
||||
// Mosh sessions use a shell-backed PTY, so network device mode only applies to SSH/serial.
|
||||
// Prefer session.protocol (runtime truth) over meta.protocol (renderer hint)
|
||||
// because Mosh tabs report as protocol:"ssh" in metadata but "mosh" in session.
|
||||
const meta = mcpServerBridge.getSessionMeta(sessionId, chatSessionId) || {};
|
||||
const sessionProtocol = session.protocol || session.type || meta.protocol || "";
|
||||
const isSshOrSerial = sessionProtocol === "ssh" || sessionProtocol === "serial";
|
||||
const isNetworkDevice = (meta.deviceType === "network" && isSshOrSerial) || sessionProtocol === "serial";
|
||||
|
||||
// Helper: ensure the session lock is released once the promise settles
|
||||
// (or immediately on a synchronous error/early return).
|
||||
const withLockRelease = (factory) => {
|
||||
try {
|
||||
const result = factory();
|
||||
return Promise.resolve(result).finally(releaseLock);
|
||||
} catch (err) {
|
||||
releaseLock();
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
|
||||
releaseLock();
|
||||
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 ptyStream = session.stream || session.pty || session.proc;
|
||||
|
||||
// Network devices (switches/routers) connected via SSH: use raw execution.
|
||||
// Their vendor CLIs don't run a POSIX shell, so shell-wrapped commands fail.
|
||||
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
|
||||
const { execViaRawPty } = require("./ai/ptyExec.cjs");
|
||||
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return withLockRelease(() => execViaRawPty(ptyStream, command, {
|
||||
timeoutMs,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
encoding: "utf8", // SSH PTY streams use UTF-8, not latin1
|
||||
}));
|
||||
}
|
||||
|
||||
// Prefer PTY stream (visible in terminal)
|
||||
if (ptyStream && typeof ptyStream.write === "function") {
|
||||
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
// Remote sessions historically left shellKind unset → posix wrapper
|
||||
// was typed into fish login shells (issue #1854). Probe once first,
|
||||
// cancellably so Stop during the probe window does not still type
|
||||
// the command after the probe resolves (Codex P2 on #2061).
|
||||
return withLockRelease(async () => {
|
||||
const probed = await ensureSessionShellKindForExec(session, {
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
const safety = mcpServerBridge.checkCommandSafetyForShell(
|
||||
command,
|
||||
mcpServerBridge.resolveSessionBlocklistShellKind(session),
|
||||
);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
return execViaPty(ptyStream, command, {
|
||||
stripMarkers: true,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => {
|
||||
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
|
||||
safeSend(contents, "netcatty:data", { sessionId, data: `${marker}_R\n` });
|
||||
},
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => {
|
||||
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
|
||||
safeSend(contents, "netcatty:data", {
|
||||
sessionId,
|
||||
data: formatSyntheticEcho(rawCommand),
|
||||
syntheticEcho: true,
|
||||
});
|
||||
},
|
||||
// Catty Agent has no terminal_start fallback for long-running
|
||||
// commands, so do NOT enforce a hard wall-clock timeout here.
|
||||
// The inactivity timeout still applies, so genuinely hung
|
||||
// processes are still terminated.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Network devices require an interactive PTY for raw command execution.
|
||||
if (isNetworkDevice) {
|
||||
releaseLock();
|
||||
return { ok: false, error: "Network device session has no writable PTY stream for command execution" };
|
||||
}
|
||||
|
||||
// Fallback: SSH exec channel (invisible to terminal)
|
||||
const sshClient = session.sshClient || session.conn;
|
||||
if (sshClient && typeof sshClient.exec === "function") {
|
||||
const { execViaChannel } = require("./ai/ptyExec.cjs");
|
||||
const channelTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return withLockRelease(async () => {
|
||||
const probed = await ensureSessionShellKindForExec(session, {
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
const safety = mcpServerBridge.checkCommandSafetyForShell(
|
||||
command,
|
||||
mcpServerBridge.resolveSessionBlocklistShellKind(session),
|
||||
);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
return execViaChannel(sshClient, command, {
|
||||
timeoutMs: channelTimeoutMs,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
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?.()) {
|
||||
releaseLock();
|
||||
return { ok: false, error: "Serial file transfer is already in progress" };
|
||||
}
|
||||
const { execViaRawPty } = require("./ai/ptyExec.cjs");
|
||||
const serialTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return withLockRelease(() => execViaRawPty(session.serialPort, command, {
|
||||
timeoutMs: serialTimeoutMs,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
encoding: session.serialEncoding || "utf8",
|
||||
}));
|
||||
}
|
||||
|
||||
releaseLock();
|
||||
return { ok: false, error: "No terminal stream or SSH client available for this session" };
|
||||
} catch (err) {
|
||||
releaseLock();
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel in-flight Catty Agent command executions for a chat session
|
||||
ipcMain.handle("netcatty:ai:catty:cancel", async (event, { chatSessionId }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
|
||||
void mcpServerBridge.cancelSftpOpsForSession?.(chatSessionId);
|
||||
if (typeof mcpServerBridge.cancelWorkerBackgroundJobsForSession === "function") {
|
||||
mcpServerBridge.cancelWorkerBackgroundJobsForSession(chatSessionId);
|
||||
} else {
|
||||
try {
|
||||
terminalWorkerManager?.send?.("netcatty:ai:catty:cancel", { chatSessionId }, {
|
||||
webContentsId: event?.sender?.id,
|
||||
});
|
||||
} catch {
|
||||
// Worker may already be gone while cancelling a torn-down terminal.
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:chat-session:set-cancelled", async (event, { chatSessionId, cancelled }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
if (!chatSessionId || typeof chatSessionId !== "string") {
|
||||
return { ok: false, error: "chatSessionId is required" };
|
||||
}
|
||||
try {
|
||||
return await mcpServerBridge.applyChatSessionCancelled(chatSessionId, cancelled !== false);
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:capability", async (event, { rpcMethod, params, chatSessionId }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
if (!rpcMethod || typeof rpcMethod !== "string") {
|
||||
return { ok: false, error: "rpcMethod is required" };
|
||||
}
|
||||
return mcpServerBridge.dispatchBuiltinRpc(rpcMethod, {
|
||||
...(params || {}),
|
||||
chatSessionId,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { registerCattyExecHandlers };
|
||||
Reference in New Issue
Block a user