[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,472 @@
/* eslint-disable no-undef */
// Module-level require: code inside createExecHandlerApi runs under `with (ctx)`
// where bare `require` resolves to ctx.require (based in electron/bridges/).
const {
ensureSessionShellKind,
ensureSessionShellKindForExec,
} = require("../ai/sessionShellKind.cjs");
function createExecHandlerApi(ctx) {
with (ctx) {
function resolveExecContext(params) {
const { sessionId, command } = params;
debugLog("handleExec:start", { sessionId, command, chatSessionId: params?.chatSessionId });
if (!sessionId || !command) throw new Error("sessionId and command are required");
if (typeof command !== 'string' || !command.trim()) {
return { ok: false, error: 'Invalid command', exitCode: 1 };
}
const session = sessions?.get(sessionId);
debugLog("handleExec:sessionLookup", {
sessionId,
found: Boolean(session),
protocol: session?.protocol || session?.type || null,
shellKind: session?.shellKind || null,
});
if (!session) return { ok: false, error: "Session not found" };
// Look up device type from metadata (set by renderer from Host.deviceType).
const chatSessionId = params?.chatSessionId || null;
const meta = getSessionMeta(sessionId, chatSessionId) || {};
// Mosh sessions use a shell-backed PTY and cannot connect to vendor CLIs,
// so network device mode only applies to SSH and serial sessions.
// Prefer session.protocol (runtime truth) over meta.protocol (renderer hint)
// because Mosh tabs report as protocol:"ssh" in metadata but "mosh" in session.
const sessionProtocol = session.protocol || session.type || meta.protocol || "";
const isSshOrSerial = sessionProtocol === "ssh" || sessionProtocol === "serial";
const isNetworkDevice = (meta.deviceType === "network" && isSshOrSerial) || sessionProtocol === "serial";
// The blocklist targets shell-specific patterns (rm -rf, eval, $(), etc.) that
// are meaningless on network device CLIs. Serial sessions skip the check because
// commands like "shutdown" (disable an interface) are routine on Cisco/Huawei.
//
// Design note: the serial protocol is explicitly chosen by the user in the UI
// for network devices / embedded systems. While startSerialSession technically
// supports PTY devices, users connecting to a Linux/BusyBox shell should use
// the "local" protocol (which goes through the normal shell path with blocklist).
// Additionally, execViaRawPty sends commands without shell wrapping, so shell
// metacharacters in blocklist patterns (eval, $(), backticks, pipes) cannot
// actually be interpreted even if sent to a serial-connected shell.
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
return {
ok: false,
error: "AI execution is not supported for this local shell executable. Configure the local terminal to use bash/zsh/sh, fish, PowerShell/pwsh, or cmd.exe.",
};
}
const sshClient = session.conn || session.sshClient;
const ptyStream = session.stream || session.pty || session.proc;
return {
ok: true,
context: {
sessionId,
command,
session,
chatSessionId,
sessionProtocol,
isNetworkDevice,
sshClient,
ptyStream,
},
};
}
function handleExec(params) {
const resolved = resolveExecContext(params);
if (!resolved.ok) return resolved;
const {
sessionId,
command,
session,
chatSessionId,
sessionProtocol,
isNetworkDevice,
sshClient,
ptyStream,
} = resolved.context;
const reservation = reserveSessionExecution(sessionId, "exec");
if (!reservation.ok) return reservation;
const sessionToken = reservation.token;
const executionLock = beginChatExecution(chatSessionId, sessionId, command);
if (!executionLock.ok) {
releaseSessionExecution(sessionId, sessionToken);
return {
ok: false,
code: "COMMAND_ALREADY_RUNNING",
error: `Another Netcatty command is already running for chat session "${chatSessionId}". Wait for it to finish before starting a new exec.`,
activeCommand: executionLock.active.command,
activeSessionId: executionLock.active.sessionId,
};
}
const runExecution = (factory) => {
try {
return Promise.resolve(factory()).finally(() => {
releaseSessionExecution(sessionId, sessionToken);
executionLock.release();
});
} catch (err) {
releaseSessionExecution(sessionId, sessionToken);
executionLock.release();
return { ok: false, error: err?.message || String(err) };
}
};
// Network devices (switches/routers) connected via SSH: use raw execution.
// Their vendor CLIs (Huawei VRP, Cisco IOS, etc.) don't run a POSIX shell,
// so shell-wrapped commands with markers would fail. Raw mode sends commands
// as-is with idle-timeout completion detection — same as serial sessions.
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
return runExecution(() => execViaRawPty(ptyStream, command, {
timeoutMs: commandTimeoutMs,
trackForCancellation: activePtyExecs,
chatSessionId: params?.chatSessionId,
encoding: "utf8", // SSH PTY streams use UTF-8, not latin1
}));
}
// Prefer the interactive PTY so the user sees command/output in-session.
if (ptyStream && typeof ptyStream.write === "function") {
// Probe remote login shell once when shellKind is unset so fish
// sessions get the fish wrapper instead of the posix default (#1854).
// Cancellable: Stop during the probe must not still type the command.
return runExecution(async () => {
const probed = await ensureSessionShellKindForExec(session, {
trackForCancellation: activePtyExecs,
chatSessionId,
});
if (!probed.ok) return probed;
const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session));
if (safety.blocked) {
debugLog("handleExec:blocklisted", { sessionId, matchedPattern: safety.matchedPattern });
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
return execViaPty(ptyStream, command, {
trackForCancellation: activePtyExecs,
timeoutMs: commandTimeoutMs,
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
probeLiveShell: true,
onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }),
expectedPrompt: getFreshIdlePrompt(session),
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
chatSessionId,
// MCP callers have terminal_start as a fallback for long commands,
// so enforce a hard wall-clock timeout here to match the MCP budget.
enforceWallTimeout: true,
});
});
}
// Network devices require an interactive PTY for raw command execution.
// If we got here, ptyStream wasn't writable — there's no usable channel.
if (isNetworkDevice) {
releaseSessionExecution(sessionId, sessionToken);
executionLock.release();
return { ok: false, error: "Network device session has no writable PTY stream for command execution" };
}
// Fallback: SSH exec channel (invisible to terminal).
// At this point ptyStream is not writable (already returned above if it was).
if (sshClient && typeof sshClient.exec === "function") {
return runExecution(async () => {
const probed = await ensureSessionShellKindForExec(session, {
trackForCancellation: activePtyExecs,
chatSessionId,
});
if (!probed.ok) return probed;
const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session));
if (safety.blocked) {
debugLog("handleExec:blocklisted", { sessionId, matchedPattern: safety.matchedPattern });
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
return execViaChannel(sshClient, command, {
timeoutMs: commandTimeoutMs,
trackForCancellation: activePtyExecs,
// Pass chatSessionId so cancelPtyExecsForSession can interrupt this
// exec channel when the originating SDK agent run is stopped.
chatSessionId: params?.chatSessionId,
});
});
}
// Serial port: raw command execution (no shell wrapping)
if (session.protocol === "serial" && session.serialPort && typeof session.serialPort.write === "function") {
if (session.ymodemActive || session.zmodemSentry?.isActive?.()) {
releaseSessionExecution(sessionId, sessionToken);
executionLock.release();
return { ok: false, error: "Serial file transfer is already in progress" };
}
return runExecution(() => execViaRawPty(session.serialPort, command, {
timeoutMs: commandTimeoutMs,
trackForCancellation: activePtyExecs,
chatSessionId: params?.chatSessionId,
encoding: session.serialEncoding || "utf8",
}));
}
releaseSessionExecution(sessionId, sessionToken);
executionLock.release();
return { ok: false, error: "Session does not support command execution" };
}
function handleJobStart(params) {
const resolved = resolveExecContext(params);
if (!resolved.ok) return resolved;
const {
sessionId,
command,
session,
chatSessionId,
isNetworkDevice,
sessionProtocol,
ptyStream,
} = resolved.context;
if (isNetworkDevice || sessionProtocol === "serial") {
return {
ok: false,
error: "Background execution currently supports shell-backed PTY sessions only.",
};
}
if (!ptyStream || typeof ptyStream.write !== "function") {
return {
ok: false,
error: "Background execution requires a writable PTY-backed terminal session.",
};
}
const reservation = reserveSessionExecution(sessionId, "job");
if (!reservation.ok) return reservation;
const sessionToken = reservation.token;
const jobId = createBackgroundJobId();
const timeoutMs = Math.max(commandTimeoutMs, DEFAULT_BACKGROUND_JOB_TIMEOUT_MS);
const startedAt = Date.now();
// Register the job *before* the shell-kind probe so chat-delete /
// cancelBackgroundJobsForSession can see it while we await. Without
// this, the first terminal_start on an unprobed remote session has no
// backgroundJobs entry during the probe and still starts the PTY job
// after cancel (Codex P2 on #2061). Not registered in activePtyExecs —
// terminal_start is designed to survive SDK Stop; only chat cancel
// (which walks backgroundJobs) should abort a pending start.
let probeCancelRequested = false;
const job = {
id: jobId,
sessionId,
chatSessionId: chatSessionId || null,
command,
status: "running",
startedAt,
updatedAt: startedAt,
exitCode: null,
error: null,
stdout: "",
outputBaseOffset: 0,
totalOutputChars: 0,
outputTruncated: false,
pendingShellProbe: true,
handle: {
cancel: () => {
probeCancelRequested = true;
},
},
};
backgroundJobs.set(jobId, job);
// Probe so fish login shells are not mis-wrapped as posix (#1854).
return Promise.resolve(ensureSessionShellKind(session)).then(() => {
if (probeCancelRequested || job.status === "stopping") {
job.status = "cancelled";
job.error = "Cancelled";
job.updatedAt = Date.now();
job.pendingShellProbe = false;
releaseSessionExecution(sessionId, sessionToken);
return {
ok: false,
error: "Cancelled",
jobId,
sessionId,
status: "cancelled",
};
}
const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session));
if (safety.blocked) {
job.status = "failed";
job.error = `Command blocked by safety policy. Pattern: ${safety.matchedPattern}`;
job.updatedAt = Date.now();
job.pendingShellProbe = false;
backgroundJobs.delete(jobId);
releaseSessionExecution(sessionId, sessionToken);
return { ok: false, error: job.error };
}
let handle;
try {
handle = startPtyJob(ptyStream, command, {
// Intentionally do NOT register in activePtyExecs: terminal_start jobs
// are designed to survive SDK agent "Stop" so the model can stop polling
// without aborting a long-running build/scan/log stream. The job is
// managed via terminal_stop and the per-session execution lock.
timeoutMs,
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
probeLiveShell: true,
onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }),
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
normalizeFinalOutput: false,
});
} catch (err) {
job.status = "failed";
job.error = err?.message || String(err);
job.updatedAt = Date.now();
job.pendingShellProbe = false;
releaseSessionExecution(sessionId, sessionToken);
return { ok: false, error: err?.message || String(err) };
}
job.handle = handle;
job.pendingShellProbe = false;
handle.resultPromise.then((result) => {
job.updatedAt = Date.now();
job.exitCode = result.exitCode ?? null;
storeCompletedJobOutput(job, result.stdout || "", result);
const isForcedCancel = typeof result.error === "string" && result.error.includes("forced");
if (result.error === "Cancelled" || isForcedCancel) {
// Forced cancel means the process ignored SIGINT for the cancel
// wall-clock window. We mark the job as cancelled and release the
// lock so the session is reusable; the error message tells the
// caller the process may still be running so subsequent commands
// should be considered carefully. This is consistent: callers see
// completed=true exactly when the lock is no longer held.
job.status = "cancelled";
job.error = result.error;
releaseSessionExecution(sessionId, sessionToken);
return;
}
if (result.error) {
job.status = "failed";
job.error = result.error;
releaseSessionExecution(sessionId, sessionToken);
return;
}
// A non-zero exit code without an error message still represents a
// failed command (e.g. a build/test that returned 1). Mark it as failed
// so callers don't have to special-case exitCode against status.
if (typeof result.exitCode === "number" && result.exitCode !== 0) {
job.status = "failed";
job.error = `Command exited with code ${result.exitCode}`;
releaseSessionExecution(sessionId, sessionToken);
return;
}
job.status = "completed";
releaseSessionExecution(sessionId, sessionToken);
}).catch((err) => {
job.updatedAt = Date.now();
job.status = "failed";
job.error = err?.message || String(err);
storeCompletedJobOutput(job, job.stdout || "");
releaseSessionExecution(sessionId, sessionToken);
});
return {
ok: true,
jobId,
sessionId,
command,
status: "running",
startedAt,
outputMode: "foreground-mirrored",
recommendedPollIntervalMs: DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS,
};
}).catch((err) => {
// Probe (or unexpected rejection) must not leave the session lock held.
job.status = "failed";
job.error = err?.message || String(err);
job.updatedAt = Date.now();
job.pendingShellProbe = false;
releaseSessionExecution(sessionId, sessionToken);
return { ok: false, error: err?.message || String(err) };
});
}
function getScopedJob(jobId, chatSessionId) {
const job = backgroundJobs.get(jobId);
if (!job) return null;
// Per-chat isolation: a job started under a chat session can only be
// accessed by callers presenting the same chatSessionId. Unscoped or
// statically-scoped callers cannot reach into another chat's jobs.
if (job.chatSessionId) {
if (!chatSessionId || job.chatSessionId !== chatSessionId) {
return null;
}
}
return job;
}
function handleJobPoll(params) {
const { jobId, offset = 0, chatSessionId, scopedSessionIds } = params || {};
if (!jobId) throw new Error("jobId is required");
const job = getScopedJob(jobId, chatSessionId || null);
if (!job) return { ok: false, error: "Background job not found" };
// Re-check session scope so a caller that lost access to the host
// cannot continue reading output from jobs on that session.
// Covers dynamic (chatSessionId) and static (scopedSessionIds) modes.
if (job.sessionId) {
const scopeErr = validateSessionScope(job.sessionId, chatSessionId || null, scopedSessionIds);
if (scopeErr) return { ok: false, error: scopeErr };
}
return serializeBackgroundJob(job, offset);
}
function handleJobStop(params) {
const { jobId, chatSessionId, scopedSessionIds } = params || {};
if (!jobId) throw new Error("jobId is required");
const job = getScopedJob(jobId, chatSessionId || null);
if (!job) return { ok: false, error: "Background job not found" };
// For statically scoped MCP clients, validate that the job's session is
// within the caller's static scope so a foreign jobId cannot cancel jobs
// outside the caller's allowed sessions. Dynamic chat scope is already
// enforced by getScopedJob (caller's chatSessionId must match the job's),
// and we intentionally do NOT re-check dynamic scope here so jobs can
// still be stopped after workspace membership changes — otherwise the
// session lock would stay held forever.
if (Array.isArray(scopedSessionIds) && job.sessionId) {
if (!scopedSessionIds.includes(job.sessionId)) {
return { ok: false, error: `Session "${job.sessionId}" is not in the current scope.` };
}
}
if (job.status === "running") {
try {
job.handle?.cancel?.();
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
job.status = "stopping";
job.error = "Cancellation requested";
job.updatedAt = Date.now();
}
return serializeBackgroundJob(job, 0);
}
return {
resolveExecContext,
handleExec,
handleJobStart,
getScopedJob,
handleJobPoll,
handleJobStop,
};
}
}
module.exports = { createExecHandlerApi };