[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:
638
electron/terminalWorker/aiExec.cjs
Normal file
638
electron/terminalWorker/aiExec.cjs
Normal file
@@ -0,0 +1,638 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const {
|
||||
execViaPty,
|
||||
startPtyJob,
|
||||
execViaChannel,
|
||||
execViaRawPty,
|
||||
} = require("../bridges/ai/ptyExec.cjs");
|
||||
const { getFreshIdlePrompt, formatSyntheticEcho } = require("../bridges/ai/shellUtils.cjs");
|
||||
const {
|
||||
ensureSessionShellKind,
|
||||
ensureSessionShellKindForExec,
|
||||
} = require("../bridges/ai/sessionShellKind.cjs");
|
||||
const {
|
||||
checkBlocklistForShell,
|
||||
resolveSessionBlocklistShellKind,
|
||||
} = require("../bridges/ai/commandSafety.cjs");
|
||||
|
||||
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;
|
||||
|
||||
function cancelPtyExecsForSession(activePtyExecs, 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 cancellation races while the worker session is shutting down.
|
||||
}
|
||||
activePtyExecs.delete(marker);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelWorkerBackgroundJobsForSession(backgroundJobs, chatSessionId) {
|
||||
if (!chatSessionId) return;
|
||||
for (const [, job] of backgroundJobs) {
|
||||
if (job.chatSessionId !== chatSessionId) continue;
|
||||
if (job.status !== "running") continue;
|
||||
try {
|
||||
job.handle?.cancel?.();
|
||||
job.status = "stopping";
|
||||
job.error = "Cancellation requested";
|
||||
job.updatedAt = Date.now();
|
||||
} catch {
|
||||
// Ignore cancellation races while the worker session is shutting down.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createWorkerBackgroundJobId() {
|
||||
return `job_${Date.now().toString(36)}_${crypto.randomBytes(6).toString("hex")}`;
|
||||
}
|
||||
|
||||
function readWorkerJobSnapshot(job) {
|
||||
if (!job) {
|
||||
return {
|
||||
stdout: "",
|
||||
outputBaseOffset: 0,
|
||||
totalOutputChars: 0,
|
||||
outputTruncated: false,
|
||||
};
|
||||
}
|
||||
if (job.status === "running" || job.status === "stopping") {
|
||||
const snapshot = job.handle?.getSnapshot?.();
|
||||
if (snapshot) {
|
||||
const stdout = String(snapshot.stdout || "");
|
||||
const outputBaseOffset = Math.max(0, Number(snapshot.outputBaseOffset) || 0);
|
||||
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, Number(snapshot.totalOutputChars) || 0);
|
||||
return {
|
||||
stdout,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: Boolean(snapshot.outputTruncated),
|
||||
};
|
||||
}
|
||||
}
|
||||
const stdout = String(job.stdout || "");
|
||||
const outputBaseOffset = Math.max(0, Number(job.outputBaseOffset) || 0);
|
||||
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, Number(job.totalOutputChars) || 0);
|
||||
return {
|
||||
stdout,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: Boolean(job.outputTruncated),
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkerOutputWindow(stdout) {
|
||||
const fullText = String(stdout || "");
|
||||
const totalOutputChars = fullText.length;
|
||||
const outputBaseOffset = Math.max(0, totalOutputChars - MAX_BACKGROUND_JOB_OUTPUT_CHARS);
|
||||
return {
|
||||
stdout: outputBaseOffset > 0 ? fullText.slice(outputBaseOffset) : fullText,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: outputBaseOffset > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function refreshRunningWorkerJobSnapshot(job) {
|
||||
if (!job || (job.status !== "running" && job.status !== "stopping")) return;
|
||||
const snapshot = readWorkerJobSnapshot(job);
|
||||
job.stdout = snapshot.stdout;
|
||||
job.outputBaseOffset = snapshot.outputBaseOffset;
|
||||
job.totalOutputChars = snapshot.totalOutputChars;
|
||||
job.outputTruncated = snapshot.outputTruncated;
|
||||
}
|
||||
|
||||
function storeCompletedWorkerJobOutput(job, stdout, metadata = null) {
|
||||
if (metadata && typeof metadata === "object") {
|
||||
const normalizedStdout = String(metadata.stdout ?? stdout ?? "");
|
||||
const outputBaseOffset = Math.max(0, Number(metadata.outputBaseOffset) || 0);
|
||||
const totalOutputChars = Math.max(outputBaseOffset + normalizedStdout.length, Number(metadata.totalOutputChars) || 0);
|
||||
job.stdout = normalizedStdout;
|
||||
job.outputBaseOffset = outputBaseOffset;
|
||||
job.totalOutputChars = totalOutputChars;
|
||||
job.outputTruncated = Boolean(metadata.outputTruncated);
|
||||
job.handle = null;
|
||||
return;
|
||||
}
|
||||
const window = createWorkerOutputWindow(stdout);
|
||||
job.stdout = window.stdout;
|
||||
job.outputBaseOffset = window.outputBaseOffset;
|
||||
job.totalOutputChars = window.totalOutputChars;
|
||||
job.outputTruncated = window.outputTruncated;
|
||||
job.handle = null;
|
||||
}
|
||||
|
||||
function pruneCompletedWorkerJobs(backgroundJobs, now = Date.now()) {
|
||||
for (const [jobId, job] of backgroundJobs) {
|
||||
if (job.status === "running" || job.status === "stopping") continue;
|
||||
const updatedAt = Number(job.updatedAt) || 0;
|
||||
if (updatedAt > 0 && now - updatedAt > BACKGROUND_JOB_RETENTION_MS) {
|
||||
backgroundJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collapseCarriageReturns(text) {
|
||||
if (!text || text.indexOf("\r") === -1) return text;
|
||||
let result = "";
|
||||
let crPending = false;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (ch === "\r") {
|
||||
crPending = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\n") {
|
||||
crPending = false;
|
||||
result += ch;
|
||||
continue;
|
||||
}
|
||||
if (crPending) {
|
||||
const lastNl = result.lastIndexOf("\n");
|
||||
result = lastNl >= 0 ? result.slice(0, lastNl + 1) : "";
|
||||
crPending = false;
|
||||
}
|
||||
result += ch;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function serializeWorkerJob(job, offset = 0) {
|
||||
if (job.status === "running" || job.status === "stopping") {
|
||||
refreshRunningWorkerJobSnapshot(job);
|
||||
}
|
||||
const stdout = job.stdout || "";
|
||||
const outputBaseOffset = job.outputBaseOffset || 0;
|
||||
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, job.totalOutputChars || 0);
|
||||
const numericOffset = Math.max(0, Number(offset) || 0);
|
||||
const relativeOffset = numericOffset <= outputBaseOffset
|
||||
? 0
|
||||
: Math.min(numericOffset - outputBaseOffset, stdout.length);
|
||||
return {
|
||||
ok: true,
|
||||
jobId: job.id,
|
||||
sessionId: job.sessionId,
|
||||
command: job.command,
|
||||
status: job.status,
|
||||
completed: job.status !== "running" && job.status !== "stopping",
|
||||
exitCode: job.exitCode,
|
||||
error: job.error,
|
||||
startedAt: job.startedAt,
|
||||
updatedAt: job.updatedAt,
|
||||
output: collapseCarriageReturns(stdout.slice(relativeOffset)),
|
||||
nextOffset: totalOutputChars,
|
||||
totalOutputChars,
|
||||
outputBaseOffset,
|
||||
outputTruncated: Boolean(job.outputTruncated),
|
||||
recommendedPollIntervalMs: DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS,
|
||||
};
|
||||
}
|
||||
|
||||
function getScopedWorkerJob(backgroundJobs, jobId, chatSessionId) {
|
||||
const job = backgroundJobs.get(jobId);
|
||||
if (!job) return null;
|
||||
if (job.chatSessionId) {
|
||||
if (!chatSessionId || job.chatSessionId !== chatSessionId) return null;
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
function getActiveWorkerSessionJobError(activeSessionJobs, sessionId) {
|
||||
if (!activeSessionJobs?.has(sessionId)) return null;
|
||||
return {
|
||||
ok: false,
|
||||
error: "Session already has a long-running command in progress. Wait for it to finish or stop it before starting another command.",
|
||||
};
|
||||
}
|
||||
|
||||
function isNetworkDeviceSession(session, sessionMeta = {}) {
|
||||
const sessionProtocol = session.protocol || session.type || sessionMeta.protocol || "";
|
||||
const isSshOrSerial = sessionProtocol === "ssh" || sessionProtocol === "serial";
|
||||
return {
|
||||
sessionProtocol,
|
||||
isNetworkDevice: (sessionMeta.deviceType === "network" && isSshOrSerial) || sessionProtocol === "serial",
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkerAiExecHandler({
|
||||
sessions,
|
||||
activePtyExecs = new Map(),
|
||||
activeSessionJobs = new Map(),
|
||||
}) {
|
||||
return async function handleWorkerAiExec(event, payload = {}) {
|
||||
const {
|
||||
sessionId,
|
||||
command,
|
||||
chatSessionId,
|
||||
commandTimeoutMs,
|
||||
sessionMeta,
|
||||
enforceWallTimeout,
|
||||
commandBlocklist,
|
||||
} = payload;
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session) {
|
||||
return { ok: false, error: "Session not found" };
|
||||
}
|
||||
const busy = getActiveWorkerSessionJobError(activeSessionJobs, sessionId);
|
||||
if (busy) return busy;
|
||||
|
||||
const meta = sessionMeta || {};
|
||||
const { sessionProtocol, isNetworkDevice } = isNetworkDeviceSession(session, meta);
|
||||
const timeoutMs = Number.isFinite(commandTimeoutMs) ? commandTimeoutMs : 60000;
|
||||
|
||||
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 ptyStream = session.stream || session.pty || session.proc;
|
||||
|
||||
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
|
||||
return execViaRawPty(ptyStream, command, {
|
||||
timeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId,
|
||||
encoding: sessionProtocol === "serial" ? (session.serialEncoding || "utf8") : "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
if (ptyStream && typeof ptyStream.write === "function") {
|
||||
// Remote sessions may not set shellKind at connect time; probe once so
|
||||
// fish login shells get the fish wrapper (issue #1854). Cancellable so
|
||||
// Stop during the probe window does not still type the command.
|
||||
const probed = await ensureSessionShellKindForExec(session, {
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
const safety = checkBlocklistForShell(
|
||||
command,
|
||||
resolveSessionBlocklistShellKind(session),
|
||||
commandBlocklist,
|
||||
);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
return execViaPty(ptyStream, command, {
|
||||
stripMarkers: true,
|
||||
trackForCancellation: activePtyExecs,
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
sessionId,
|
||||
data: `${marker}_R\n`,
|
||||
});
|
||||
},
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
sessionId,
|
||||
data: formatSyntheticEcho(rawCommand),
|
||||
syntheticEcho: true,
|
||||
});
|
||||
},
|
||||
enforceWallTimeout: enforceWallTimeout === true,
|
||||
});
|
||||
}
|
||||
|
||||
if (isNetworkDevice) {
|
||||
return { ok: false, error: "Network device session has no writable PTY stream for command execution" };
|
||||
}
|
||||
|
||||
const sshClient = session.sshClient || session.conn;
|
||||
if (sshClient && typeof sshClient.exec === "function") {
|
||||
const probed = await ensureSessionShellKindForExec(session, {
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
const safety = checkBlocklistForShell(
|
||||
command,
|
||||
resolveSessionBlocklistShellKind(session),
|
||||
commandBlocklist,
|
||||
);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
return execViaChannel(sshClient, command, {
|
||||
timeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
if (session.protocol === "serial" && session.serialPort && typeof session.serialPort.write === "function") {
|
||||
if (session.ymodemActive || session.zmodemSentry?.isActive?.()) {
|
||||
return { ok: false, error: "Serial file transfer is already in progress" };
|
||||
}
|
||||
return execViaRawPty(session.serialPort, command, {
|
||||
timeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId,
|
||||
encoding: session.serialEncoding || "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: false, error: "No terminal stream or SSH client available for this session" };
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkerAiJobStartHandler({
|
||||
sessions,
|
||||
backgroundJobs = new Map(),
|
||||
activeSessionJobs = new Map(),
|
||||
}) {
|
||||
return async function handleWorkerAiJobStart(event, payload = {}) {
|
||||
const {
|
||||
sessionId,
|
||||
command,
|
||||
chatSessionId,
|
||||
commandTimeoutMs,
|
||||
sessionMeta,
|
||||
commandBlocklist,
|
||||
} = payload;
|
||||
if (!sessionId || !command) {
|
||||
return { ok: false, error: "sessionId and command are required" };
|
||||
}
|
||||
if (typeof command !== "string" || !command.trim()) {
|
||||
return { ok: false, error: "Invalid command", exitCode: 1 };
|
||||
}
|
||||
pruneCompletedWorkerJobs(backgroundJobs);
|
||||
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session) {
|
||||
return { ok: false, error: "Session not found" };
|
||||
}
|
||||
const busy = getActiveWorkerSessionJobError(activeSessionJobs, sessionId);
|
||||
if (busy) return busy;
|
||||
|
||||
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 meta = sessionMeta || {};
|
||||
const { sessionProtocol, isNetworkDevice } = isNetworkDeviceSession(session, meta);
|
||||
if (isNetworkDevice || sessionProtocol === "serial") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Background execution currently supports shell-backed PTY sessions only.",
|
||||
};
|
||||
}
|
||||
|
||||
const ptyStream = session.stream || session.pty || session.proc;
|
||||
if (!ptyStream || typeof ptyStream.write !== "function") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Background execution requires a writable PTY-backed terminal session.",
|
||||
};
|
||||
}
|
||||
|
||||
const jobId = createWorkerBackgroundJobId();
|
||||
const startedAt = Date.now();
|
||||
activeSessionJobs.set(sessionId, jobId);
|
||||
|
||||
// Insert into backgroundJobs *before* the shell-kind probe so
|
||||
// netcatty:ai:catty:cancel / cancelWorkerBackgroundJobsForSession can
|
||||
// latch cancellation while we await. Without this, the first job on an
|
||||
// unprobed remote session has no map entry during the probe and still
|
||||
// writes to the PTY after chat cancel (Codex P2 on #2061).
|
||||
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);
|
||||
|
||||
// Same shellKind probe as foreground exec so background jobs on fish
|
||||
// remote shells are not wrapped as posix (issue #1854). Session is
|
||||
// reserved above so concurrent starts cannot pass the busy check.
|
||||
try {
|
||||
await ensureSessionShellKind(session);
|
||||
} catch (err) {
|
||||
job.status = "failed";
|
||||
job.error = err?.message || String(err);
|
||||
job.updatedAt = Date.now();
|
||||
job.pendingShellProbe = false;
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
|
||||
if (probeCancelRequested || job.status === "stopping") {
|
||||
job.status = "cancelled";
|
||||
job.error = "Cancelled";
|
||||
job.updatedAt = Date.now();
|
||||
job.pendingShellProbe = false;
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
return {
|
||||
ok: false,
|
||||
error: "Cancelled",
|
||||
jobId,
|
||||
sessionId,
|
||||
status: "cancelled",
|
||||
};
|
||||
}
|
||||
|
||||
const safety = checkBlocklistForShell(
|
||||
command,
|
||||
resolveSessionBlocklistShellKind(session),
|
||||
commandBlocklist,
|
||||
);
|
||||
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);
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
return { ok: false, error: job.error };
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(
|
||||
Number.isFinite(commandTimeoutMs) ? commandTimeoutMs : 60000,
|
||||
DEFAULT_BACKGROUND_JOB_TIMEOUT_MS,
|
||||
);
|
||||
let handle;
|
||||
try {
|
||||
handle = startPtyJob(ptyStream, command, {
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
sessionId,
|
||||
data: `${marker}_R\n`,
|
||||
});
|
||||
},
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
sessionId,
|
||||
data: formatSyntheticEcho(rawCommand),
|
||||
syntheticEcho: true,
|
||||
});
|
||||
},
|
||||
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;
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
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;
|
||||
storeCompletedWorkerJobOutput(job, result.stdout || "", result);
|
||||
const isForcedCancel = typeof result.error === "string" && result.error.includes("forced");
|
||||
if (result.error === "Cancelled" || isForcedCancel) {
|
||||
job.status = "cancelled";
|
||||
job.error = result.error;
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
return;
|
||||
}
|
||||
if (result.error) {
|
||||
job.status = "failed";
|
||||
job.error = result.error;
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
return;
|
||||
}
|
||||
if (typeof result.exitCode === "number" && result.exitCode !== 0) {
|
||||
job.status = "failed";
|
||||
job.error = `Command exited with code ${result.exitCode}`;
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
return;
|
||||
}
|
||||
job.status = "completed";
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
}).catch((err) => {
|
||||
job.updatedAt = Date.now();
|
||||
job.status = "failed";
|
||||
job.error = err?.message || String(err);
|
||||
storeCompletedWorkerJobOutput(job, job.stdout || "");
|
||||
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
jobId,
|
||||
sessionId,
|
||||
command,
|
||||
status: "running",
|
||||
startedAt,
|
||||
outputMode: "foreground-mirrored",
|
||||
recommendedPollIntervalMs: DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkerAiJobPollHandler({ backgroundJobs = new Map() }) {
|
||||
return function handleWorkerAiJobPoll(_event, payload = {}) {
|
||||
const { jobId, offset = 0, chatSessionId } = payload || {};
|
||||
if (!jobId) return { ok: false, error: "jobId is required" };
|
||||
const job = getScopedWorkerJob(backgroundJobs, jobId, chatSessionId || null);
|
||||
if (!job) return { ok: false, error: "Background job not found" };
|
||||
return serializeWorkerJob(job, offset);
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkerAiJobStopHandler({ backgroundJobs = new Map() }) {
|
||||
return function handleWorkerAiJobStop(_event, payload = {}) {
|
||||
const { jobId, chatSessionId } = payload || {};
|
||||
if (!jobId) return { ok: false, error: "jobId is required" };
|
||||
const job = getScopedWorkerJob(backgroundJobs, jobId, chatSessionId || null);
|
||||
if (!job) return { ok: false, error: "Background job not found" };
|
||||
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 serializeWorkerJob(job, 0);
|
||||
};
|
||||
}
|
||||
|
||||
function registerWorkerAiExecHandlers(ipcMain, { sessions }) {
|
||||
const activePtyExecs = new Map();
|
||||
const backgroundJobs = new Map();
|
||||
const activeSessionJobs = new Map();
|
||||
ipcMain.handle("netcatty:ai:exec", createWorkerAiExecHandler({
|
||||
sessions,
|
||||
activePtyExecs,
|
||||
activeSessionJobs,
|
||||
}));
|
||||
ipcMain.handle("netcatty:ai:jobStart", createWorkerAiJobStartHandler({
|
||||
sessions,
|
||||
backgroundJobs,
|
||||
activeSessionJobs,
|
||||
}));
|
||||
ipcMain.handle("netcatty:ai:jobPoll", createWorkerAiJobPollHandler({
|
||||
backgroundJobs,
|
||||
}));
|
||||
ipcMain.handle("netcatty:ai:jobStop", createWorkerAiJobStopHandler({
|
||||
backgroundJobs,
|
||||
}));
|
||||
ipcMain.on("netcatty:ai:catty:cancel", (_event, payload = {}) => {
|
||||
cancelPtyExecsForSession(activePtyExecs, payload.chatSessionId);
|
||||
cancelWorkerBackgroundJobsForSession(backgroundJobs, payload.chatSessionId);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cancelWorkerBackgroundJobsForSession,
|
||||
cancelPtyExecsForSession,
|
||||
createWorkerAiExecHandler,
|
||||
createWorkerAiJobStartHandler,
|
||||
createWorkerAiJobPollHandler,
|
||||
createWorkerAiJobStopHandler,
|
||||
registerWorkerAiExecHandlers,
|
||||
};
|
||||
493
electron/terminalWorker/aiExec.test.cjs
Normal file
493
electron/terminalWorker/aiExec.test.cjs
Normal file
@@ -0,0 +1,493 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
createWorkerAiJobStartHandler,
|
||||
registerWorkerAiExecHandlers,
|
||||
} = require("./aiExec.cjs");
|
||||
const { PROBE_OUTPUT_MARKER } = require("../bridges/ai/sessionShellKind.cjs");
|
||||
|
||||
class FakePty extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.writes = [];
|
||||
}
|
||||
|
||||
write(data) {
|
||||
this.writes.push(String(data));
|
||||
if (String(data).includes("command sh -c")) {
|
||||
const marker = String(data).match(/(__NCMCP_[A-Za-z0-9_]+__)/)[1];
|
||||
queueMicrotask(() => this.emit("data", `${marker}_P:\n${marker}_Q`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createFakeIpcMain() {
|
||||
const handlers = new Map();
|
||||
const listeners = new Map();
|
||||
return {
|
||||
handlers,
|
||||
listeners,
|
||||
handle(channel, handler) {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
on(channel, listener) {
|
||||
listeners.set(channel, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeEvent() {
|
||||
const rendererMessages = [];
|
||||
return {
|
||||
rendererMessages,
|
||||
sender: {
|
||||
send(channel, payload) {
|
||||
rendererMessages.push({ channel, payload });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractMarker(writes) {
|
||||
const wrapper = writes.find((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c"));
|
||||
assert.ok(wrapper, "expected wrapped command to be written to the PTY");
|
||||
const match = wrapper.match(/(__NCMCP_[A-Za-z0-9_]+__)/);
|
||||
assert.ok(match, "expected command wrapper to contain an MCP marker");
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function nextTick() {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
function createShellProbeConn(stdout = `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`) {
|
||||
const conn = {
|
||||
exec(_command, callback) {
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.close = () => stream.emit("close");
|
||||
queueMicrotask(() => {
|
||||
callback(null, stream);
|
||||
queueMicrotask(() => {
|
||||
stream.emit("data", Buffer.from(stdout));
|
||||
stream.emit("close");
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
return conn;
|
||||
}
|
||||
|
||||
function createDeferredShellProbeConn(stdout = `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`) {
|
||||
let execCallback;
|
||||
const conn = {
|
||||
exec(_command, callback) {
|
||||
execCallback = callback;
|
||||
},
|
||||
};
|
||||
return {
|
||||
conn,
|
||||
release() {
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.close = () => stream.emit("close");
|
||||
execCallback(null, stream);
|
||||
queueMicrotask(() => {
|
||||
stream.emit("data", Buffer.from(stdout));
|
||||
stream.emit("close");
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("worker AI background jobs start, poll, stop, and block overlapping exec", async () => {
|
||||
const pty = new FakePty();
|
||||
const sessions = new Map([
|
||||
["ssh-1", {
|
||||
protocol: "ssh",
|
||||
stream: pty,
|
||||
shellKind: "posix",
|
||||
}],
|
||||
]);
|
||||
const ipcMain = createFakeIpcMain();
|
||||
registerWorkerAiExecHandlers(ipcMain, { sessions });
|
||||
|
||||
assert.equal(typeof ipcMain.handlers.get("netcatty:ai:jobStart"), "function");
|
||||
assert.equal(typeof ipcMain.handlers.get("netcatty:ai:jobPoll"), "function");
|
||||
assert.equal(typeof ipcMain.handlers.get("netcatty:ai:jobStop"), "function");
|
||||
|
||||
const event = createFakeEvent();
|
||||
const started = await ipcMain.handlers.get("netcatty:ai:jobStart")(event, {
|
||||
sessionId: "ssh-1",
|
||||
command: "npm test",
|
||||
chatSessionId: "chat-1",
|
||||
commandTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
assert.equal(started.ok, true);
|
||||
assert.equal(started.sessionId, "ssh-1");
|
||||
assert.equal(started.command, "npm test");
|
||||
assert.equal(started.status, "running");
|
||||
assert.equal(started.outputMode, "foreground-mirrored");
|
||||
assert.deepEqual(event.rendererMessages, [
|
||||
{
|
||||
channel: "netcatty:data",
|
||||
payload: {
|
||||
sessionId: "ssh-1",
|
||||
data: "npm test\r\n",
|
||||
syntheticEcho: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const marker = extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\nready\r\n`);
|
||||
await nextTick();
|
||||
|
||||
const polled = await ipcMain.handlers.get("netcatty:ai:jobPoll")(event, {
|
||||
jobId: started.jobId,
|
||||
offset: 0,
|
||||
chatSessionId: "chat-1",
|
||||
});
|
||||
|
||||
assert.equal(polled.ok, true);
|
||||
assert.equal(polled.completed, false);
|
||||
assert.equal(polled.output, "ready\n");
|
||||
assert.equal(polled.nextOffset, "ready\n".length);
|
||||
|
||||
const busy = await ipcMain.handlers.get("netcatty:ai:exec")(event, {
|
||||
sessionId: "ssh-1",
|
||||
command: "pwd",
|
||||
chatSessionId: "chat-1",
|
||||
});
|
||||
assert.equal(busy.ok, false);
|
||||
assert.match(busy.error, /already has a long-running command in progress/);
|
||||
|
||||
const stopped = await ipcMain.handlers.get("netcatty:ai:jobStop")(event, {
|
||||
jobId: started.jobId,
|
||||
chatSessionId: "chat-1",
|
||||
});
|
||||
assert.equal(stopped.ok, true);
|
||||
assert.equal(stopped.status, "stopping");
|
||||
assert.ok(pty.writes.includes("\x03"), "expected stop to send Ctrl+C to the PTY");
|
||||
|
||||
pty.emit("data", `${marker}_E:130\r\n`);
|
||||
await nextTick();
|
||||
|
||||
const cancelled = await ipcMain.handlers.get("netcatty:ai:jobPoll")(event, {
|
||||
jobId: started.jobId,
|
||||
offset: 0,
|
||||
chatSessionId: "chat-1",
|
||||
});
|
||||
assert.equal(cancelled.status, "cancelled");
|
||||
assert.equal(cancelled.completed, true);
|
||||
assert.equal(cancelled.error, "Cancelled");
|
||||
});
|
||||
|
||||
test("worker chat cancellation stops matching background jobs", async () => {
|
||||
const pty = new FakePty();
|
||||
const sessions = new Map([
|
||||
["ssh-1", {
|
||||
protocol: "ssh",
|
||||
stream: pty,
|
||||
shellKind: "posix",
|
||||
}],
|
||||
]);
|
||||
const ipcMain = createFakeIpcMain();
|
||||
registerWorkerAiExecHandlers(ipcMain, { sessions });
|
||||
|
||||
const event = createFakeEvent();
|
||||
const started = await ipcMain.handlers.get("netcatty:ai:jobStart")(event, {
|
||||
sessionId: "ssh-1",
|
||||
command: "sleep 30",
|
||||
chatSessionId: "chat-1",
|
||||
commandTimeoutMs: 5000,
|
||||
});
|
||||
const marker = extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\nrunning\r\n`);
|
||||
await nextTick();
|
||||
|
||||
ipcMain.listeners.get("netcatty:ai:catty:cancel")(event, {
|
||||
chatSessionId: "chat-1",
|
||||
});
|
||||
|
||||
assert.ok(pty.writes.includes("\x03"), "expected chat cancellation to send Ctrl+C to the background job");
|
||||
|
||||
const stopping = await ipcMain.handlers.get("netcatty:ai:jobPoll")(event, {
|
||||
jobId: started.jobId,
|
||||
offset: 0,
|
||||
chatSessionId: "chat-1",
|
||||
});
|
||||
assert.equal(stopping.status, "stopping");
|
||||
assert.equal(stopping.error, "Cancellation requested");
|
||||
|
||||
pty.emit("data", `${marker}_E:130\r\n`);
|
||||
await nextTick();
|
||||
|
||||
const cancelled = await ipcMain.handlers.get("netcatty:ai:jobPoll")(event, {
|
||||
jobId: started.jobId,
|
||||
offset: 0,
|
||||
chatSessionId: "chat-1",
|
||||
});
|
||||
assert.equal(cancelled.status, "cancelled");
|
||||
assert.equal(cancelled.completed, true);
|
||||
});
|
||||
|
||||
test("worker background job probes unset remote shellKind before wrapping", async () => {
|
||||
const pty = new FakePty();
|
||||
const sessions = new Map([
|
||||
["ssh-fish", {
|
||||
protocol: "ssh",
|
||||
stream: pty,
|
||||
conn: createShellProbeConn(),
|
||||
}],
|
||||
]);
|
||||
const ipcMain = createFakeIpcMain();
|
||||
registerWorkerAiExecHandlers(ipcMain, { sessions });
|
||||
|
||||
const event = createFakeEvent();
|
||||
const started = await ipcMain.handlers.get("netcatty:ai:jobStart")(event, {
|
||||
sessionId: "ssh-fish",
|
||||
command: "echo fish",
|
||||
chatSessionId: "chat-1",
|
||||
commandTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
assert.equal(started.ok, true);
|
||||
// Login fish is a soft hint (not pinned); wrapper should be fish-native.
|
||||
assert.equal(sessions.get("ssh-fish").shellKind, undefined);
|
||||
assert.equal(sessions.get("ssh-fish")._loginShellKind, "fish");
|
||||
const wrapper = pty.writes.find((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c"));
|
||||
assert.match(wrapper, /set -l __NCMCP_.*_cmd/);
|
||||
assert.doesNotMatch(wrapper, / sh -c '/);
|
||||
|
||||
const marker = extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\n${marker}_E:0\r\n`);
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
test("worker background job reserves the session while shellKind probe is pending", async () => {
|
||||
const pty = new FakePty();
|
||||
const deferred = createDeferredShellProbeConn();
|
||||
const sessions = new Map([
|
||||
["ssh-fish", {
|
||||
protocol: "ssh",
|
||||
stream: pty,
|
||||
conn: deferred.conn,
|
||||
}],
|
||||
]);
|
||||
const ipcMain = createFakeIpcMain();
|
||||
registerWorkerAiExecHandlers(ipcMain, { sessions });
|
||||
|
||||
const event = createFakeEvent();
|
||||
const firstStart = ipcMain.handlers.get("netcatty:ai:jobStart")(event, {
|
||||
sessionId: "ssh-fish",
|
||||
command: "sleep 1",
|
||||
chatSessionId: "chat-1",
|
||||
commandTimeoutMs: 5000,
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
const second = await ipcMain.handlers.get("netcatty:ai:jobStart")(event, {
|
||||
sessionId: "ssh-fish",
|
||||
command: "pwd",
|
||||
chatSessionId: "chat-1",
|
||||
commandTimeoutMs: 5000,
|
||||
});
|
||||
assert.equal(second.ok, false);
|
||||
assert.match(second.error, /already has a long-running command in progress/);
|
||||
|
||||
deferred.release();
|
||||
const first = await firstStart;
|
||||
assert.equal(first.ok, true);
|
||||
|
||||
const marker = extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\n${marker}_E:0\r\n`);
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
test("worker chat cancel during shellKind probe aborts job start before PTY write", async () => {
|
||||
// Codex P2 on #2061: first jobStart on an unprobed remote session awaits
|
||||
// ensureSessionShellKind with no backgroundJobs entry historically, so
|
||||
// catty:cancel missed it and the command was still typed after the probe.
|
||||
const pty = new FakePty();
|
||||
const deferred = createDeferredShellProbeConn();
|
||||
const sessions = new Map([
|
||||
["ssh-fish", {
|
||||
protocol: "ssh",
|
||||
stream: pty,
|
||||
conn: deferred.conn,
|
||||
}],
|
||||
]);
|
||||
const ipcMain = createFakeIpcMain();
|
||||
registerWorkerAiExecHandlers(ipcMain, { sessions });
|
||||
|
||||
const event = createFakeEvent();
|
||||
const pendingStart = ipcMain.handlers.get("netcatty:ai:jobStart")(event, {
|
||||
sessionId: "ssh-fish",
|
||||
command: "sleep 999",
|
||||
chatSessionId: "chat-cancel-probe",
|
||||
commandTimeoutMs: 5000,
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// Cancel while the shell probe is still in-flight.
|
||||
ipcMain.listeners.get("netcatty:ai:catty:cancel")(event, {
|
||||
chatSessionId: "chat-cancel-probe",
|
||||
});
|
||||
|
||||
deferred.release();
|
||||
const started = await pendingStart;
|
||||
|
||||
assert.equal(started.ok, false);
|
||||
assert.equal(started.error, "Cancelled");
|
||||
assert.equal(
|
||||
pty.writes.filter((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c")).length,
|
||||
0,
|
||||
"cancelled pending start must not type a wrapper into the PTY",
|
||||
);
|
||||
});
|
||||
|
||||
test("worker exec keeps shell-selected defaults: powershell frees $(), dangerous PS commands blocked", async () => {
|
||||
const pty = new FakePty();
|
||||
const sessions = new Map([
|
||||
["ps-1", {
|
||||
protocol: "ssh",
|
||||
stream: pty,
|
||||
shellKind: "",
|
||||
remoteSshVersion: "OpenSSH_for_Windows_9.5",
|
||||
lastIdlePrompt: "custom% ",
|
||||
_promptTrackTail: "custom prompt\r\ncustom% ",
|
||||
_shellKindExecProbe: async () => (
|
||||
"DefaultShell REG_SZ C:\\Program Files\\PowerShell\\7\\pwsh.exe\r\n"
|
||||
),
|
||||
}],
|
||||
]);
|
||||
const ipcMain = createFakeIpcMain();
|
||||
registerWorkerAiExecHandlers(ipcMain, { sessions });
|
||||
const event = createFakeEvent();
|
||||
const exec = ipcMain.handlers.get("netcatty:ai:exec");
|
||||
|
||||
// PowerShell subexpression syntax on a PowerShell session is legal: the
|
||||
// command must pass the worker blocklist and reach the PTY wrapper.
|
||||
const allowedPromise = exec(event, {
|
||||
sessionId: "ps-1",
|
||||
command: 'Write-Host "now: $(Get-Date)"',
|
||||
chatSessionId: "chat-ps",
|
||||
commandTimeoutMs: 300,
|
||||
});
|
||||
await nextTick();
|
||||
assert.ok(
|
||||
pty.writes.some((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c")),
|
||||
"expected the unblocked command to reach the PTY",
|
||||
);
|
||||
await allowedPromise.catch(() => {});
|
||||
|
||||
// PowerShell-native destructive commands are blocked by the new group.
|
||||
const blocked = await exec(event, {
|
||||
sessionId: "ps-1",
|
||||
command: "Remove-Item -Recurse -Force C:\\important",
|
||||
chatSessionId: "chat-ps",
|
||||
commandTimeoutMs: 5000,
|
||||
});
|
||||
assert.equal(blocked.ok, false);
|
||||
assert.match(blocked.error, /Command blocked by safety policy/);
|
||||
assert.equal(blocked.error.includes("Remove-Item"), true);
|
||||
});
|
||||
|
||||
test("worker exec honors an explicitly empty configured blocklist", async () => {
|
||||
const pty = new FakePty();
|
||||
const sessions = new Map([
|
||||
["posix-no-blocklist", {
|
||||
protocol: "ssh",
|
||||
stream: pty,
|
||||
shellKind: "posix",
|
||||
}],
|
||||
]);
|
||||
const ipcMain = createFakeIpcMain();
|
||||
registerWorkerAiExecHandlers(ipcMain, { sessions });
|
||||
|
||||
const execution = ipcMain.handlers.get("netcatty:ai:exec")(createFakeEvent(), {
|
||||
sessionId: "posix-no-blocklist",
|
||||
command: "rm -rf /tmp/test-only",
|
||||
chatSessionId: "chat-no-blocklist",
|
||||
commandTimeoutMs: 300,
|
||||
commandBlocklist: [],
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
assert.ok(
|
||||
pty.writes.some((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c")),
|
||||
"disabled defaults must allow the command to reach the PTY wrapper",
|
||||
);
|
||||
await execution.catch(() => {});
|
||||
});
|
||||
|
||||
test("worker background job probes before blocking a first PowerShell command", async () => {
|
||||
const pty = new FakePty();
|
||||
const sessions = new Map([
|
||||
["ps-danger-first", {
|
||||
protocol: "ssh",
|
||||
stream: pty,
|
||||
shellKind: "",
|
||||
remoteSshVersion: "OpenSSH_for_Windows_9.5",
|
||||
lastIdlePrompt: "custom% ",
|
||||
_promptTrackTail: "custom prompt\r\ncustom% ",
|
||||
_shellKindExecProbe: async () => (
|
||||
"DefaultShell REG_SZ C:\\Program Files\\PowerShell\\7\\pwsh.exe\r\n"
|
||||
),
|
||||
}],
|
||||
]);
|
||||
const backgroundJobs = new Map();
|
||||
const activeSessionJobs = new Map();
|
||||
const start = createWorkerAiJobStartHandler({
|
||||
sessions,
|
||||
backgroundJobs,
|
||||
activeSessionJobs,
|
||||
});
|
||||
|
||||
const result = await start(createFakeEvent(), {
|
||||
sessionId: "ps-danger-first",
|
||||
command: "Remove-Item -Recurse -Force C:\\important",
|
||||
chatSessionId: "chat-ps-danger",
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /Command blocked by safety policy/);
|
||||
assert.equal(pty.writes.length, 0);
|
||||
assert.equal(backgroundJobs.size, 0);
|
||||
assert.equal(activeSessionJobs.size, 0);
|
||||
});
|
||||
|
||||
test("worker exec on an unclassified posix session still blocks command substitution", async () => {
|
||||
const pty = new FakePty();
|
||||
const sessions = new Map([
|
||||
["ssh-posix", {
|
||||
protocol: "ssh",
|
||||
stream: pty,
|
||||
shellKind: "posix",
|
||||
}],
|
||||
]);
|
||||
const ipcMain = createFakeIpcMain();
|
||||
registerWorkerAiExecHandlers(ipcMain, { sessions });
|
||||
const event = createFakeEvent();
|
||||
|
||||
const blocked = await ipcMain.handlers.get("netcatty:ai:exec")(event, {
|
||||
sessionId: "ssh-posix",
|
||||
command: "echo $(whoami)",
|
||||
chatSessionId: "chat-posix",
|
||||
commandTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
assert.equal(blocked.ok, false);
|
||||
assert.match(blocked.error, /Command blocked by safety policy/);
|
||||
assert.equal(
|
||||
pty.writes.filter((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c")).length,
|
||||
0,
|
||||
"blocked commands must not reach the PTY",
|
||||
);
|
||||
});
|
||||
207
electron/terminalWorker/portForwardingSharedPool.test.cjs
Normal file
207
electron/terminalWorker/portForwardingSharedPool.test.cjs
Normal file
@@ -0,0 +1,207 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const EventEmitter = require("node:events");
|
||||
const Module = require("node:module");
|
||||
const test = require("node:test");
|
||||
|
||||
let physicalDialCount = 0;
|
||||
|
||||
class MockSshClient extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this._sock = { destroyed: false, writable: true, setTimeout() {} };
|
||||
}
|
||||
|
||||
connect() {
|
||||
physicalDialCount += 1;
|
||||
setImmediate(() => {
|
||||
this.emit("connect");
|
||||
this.emit("ready");
|
||||
});
|
||||
}
|
||||
|
||||
end() {
|
||||
if (this._sock.destroyed) return;
|
||||
this._sock.destroyed = true;
|
||||
this._sock.writable = false;
|
||||
setImmediate(() => this.emit("close"));
|
||||
}
|
||||
}
|
||||
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function mockSsh2(request, parent, isMain) {
|
||||
if (request === "ssh2") return { Client: MockSshClient };
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
const { createTerminalWorkerRuntime } = require("./runtime.cjs");
|
||||
const { registerPortForwardingWorkerBridge } = require("./process.cjs");
|
||||
// Load the production worker bridge while ssh2 is replaced by the deterministic
|
||||
// transport used by this integration test. The registration helper then uses
|
||||
// this exact shared module instance.
|
||||
require("../bridges/portForwardingBridge.cjs");
|
||||
const {
|
||||
LEASE_KINDS,
|
||||
borrowTransport,
|
||||
createTransport,
|
||||
findTransportByEndpoint,
|
||||
resetSshTransportRegistryForTests,
|
||||
returnTransport,
|
||||
} = require("../bridges/sshConnectionPool.cjs");
|
||||
Module._load = originalLoad;
|
||||
|
||||
const endpoint = {
|
||||
hostId: "worker-host",
|
||||
hostname: "worker-host.test",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
jumpHosts: [],
|
||||
proxy: null,
|
||||
authType: "password",
|
||||
keyId: "",
|
||||
certificate: "",
|
||||
requiresMfa: false,
|
||||
verifyHostKeys: true,
|
||||
knownHosts: [{
|
||||
id: "kh-worker-host",
|
||||
hostname: "worker-host.test",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: "SHA256:worker-host",
|
||||
publicKey: "ssh-ed25519 WORKER_HOST_PUBLIC_KEY",
|
||||
}],
|
||||
useSshAgent: false,
|
||||
agentForwarding: false,
|
||||
password: "worker-password",
|
||||
};
|
||||
|
||||
function createParentPort() {
|
||||
const listeners = new Map();
|
||||
const waiters = new Map();
|
||||
return {
|
||||
messages: [],
|
||||
on(channel, listener) {
|
||||
listeners.set(channel, listener);
|
||||
},
|
||||
postMessage(message) {
|
||||
this.messages.push(message);
|
||||
if (message.kind === "response") {
|
||||
const waiter = waiters.get(message.requestId);
|
||||
if (waiter) {
|
||||
waiters.delete(message.requestId);
|
||||
message.error ? waiter.reject(new Error(message.error)) : waiter.resolve(message.result);
|
||||
}
|
||||
}
|
||||
},
|
||||
request(channel, payload, webContentsId = 7) {
|
||||
const requestId = `${channel}:${Math.random()}`;
|
||||
const promise = new Promise((resolve, reject) => waiters.set(requestId, { resolve, reject }));
|
||||
listeners.get("message")?.({ kind: "request", requestId, channel, payload, webContentsId });
|
||||
return promise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const parentPort = createParentPort();
|
||||
const siblingHolders = [];
|
||||
const runtime = createTerminalWorkerRuntime({
|
||||
parentPort,
|
||||
registerBridges(ipcMain) {
|
||||
registerPortForwardingWorkerBridge(ipcMain);
|
||||
for (const [channel, kind] of [
|
||||
["netcatty:test:terminal-open", LEASE_KINDS.shell],
|
||||
["netcatty:test:sftp-open", LEASE_KINDS.sftp],
|
||||
]) {
|
||||
ipcMain.handle(channel, async (_event, payload) => {
|
||||
let transport = findTransportByEndpoint(endpoint, { kind: "channel" });
|
||||
if (!transport) {
|
||||
physicalDialCount += 1;
|
||||
transport = createTransport({ conn: new MockSshClient(), chainConnections: [], endpoint });
|
||||
}
|
||||
const holder = { id: payload.id };
|
||||
borrowTransport(transport, {
|
||||
kind,
|
||||
holder,
|
||||
leaseId: `${kind}:${payload.id}`,
|
||||
});
|
||||
siblingHolders.push(holder);
|
||||
return { id: payload.id };
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
runtime.start();
|
||||
return { parentPort, siblingHolders };
|
||||
}
|
||||
|
||||
function portForwardPayload(tunnelId, overrides = {}) {
|
||||
return {
|
||||
tunnelId,
|
||||
ruleId: `rule-${tunnelId}`,
|
||||
type: "local",
|
||||
localPort: 0,
|
||||
bindAddress: "127.0.0.1",
|
||||
remoteHost: "127.0.0.1",
|
||||
remotePort: 3306,
|
||||
...endpoint,
|
||||
authMethod: endpoint.authType,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanupHarness(parentPort, siblingHolders, tunnelId) {
|
||||
if (tunnelId) {
|
||||
await parentPort.request("netcatty:portforward:stop", { tunnelId }).catch(() => {});
|
||||
}
|
||||
for (const holder of siblingHolders) returnTransport(holder);
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
}
|
||||
|
||||
for (const [label, channel] of [
|
||||
["terminal", "netcatty:test:terminal-open"],
|
||||
["SFTP", "netcatty:test:sftp-open"],
|
||||
]) {
|
||||
test(`worker ${label} opened after port forwarding reuses one physical SSH dial`, async (t) => {
|
||||
physicalDialCount = 0;
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const { parentPort, siblingHolders } = createHarness();
|
||||
t.after(() => cleanupHarness(parentPort, siblingHolders, "pf-first"));
|
||||
|
||||
assert.equal((await parentPort.request(
|
||||
"netcatty:portforward:start",
|
||||
portForwardPayload("pf-first"),
|
||||
)).success, true);
|
||||
await parentPort.request(channel, { id: `${label}-after` });
|
||||
|
||||
assert.equal(physicalDialCount, 1);
|
||||
});
|
||||
|
||||
test(`worker port forwarding opened after ${label} reuses one physical SSH dial`, async (t) => {
|
||||
physicalDialCount = 0;
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const { parentPort, siblingHolders } = createHarness();
|
||||
t.after(() => cleanupHarness(parentPort, siblingHolders, "pf-after"));
|
||||
|
||||
await parentPort.request(channel, { id: `${label}-first` });
|
||||
assert.equal((await parentPort.request(
|
||||
"netcatty:portforward:start",
|
||||
portForwardPayload("pf-after"),
|
||||
)).success, true);
|
||||
|
||||
assert.equal(physicalDialCount, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test("worker port forwarding with reuseTransport false stays physically independent", async (t) => {
|
||||
physicalDialCount = 0;
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const { parentPort, siblingHolders } = createHarness();
|
||||
t.after(() => cleanupHarness(parentPort, siblingHolders, "pf-dedicated"));
|
||||
|
||||
await parentPort.request("netcatty:test:sftp-open", { id: "sftp-first" });
|
||||
assert.equal((await parentPort.request(
|
||||
"netcatty:portforward:start",
|
||||
portForwardPayload("pf-dedicated", { reuseTransport: false }),
|
||||
)).success, true);
|
||||
|
||||
assert.equal(physicalDialCount, 2);
|
||||
});
|
||||
425
electron/terminalWorker/process.cjs
Normal file
425
electron/terminalWorker/process.cjs
Normal file
@@ -0,0 +1,425 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { createTerminalWorkerRuntime } = require("./runtime.cjs");
|
||||
const tempDirBridge = require("../bridges/tempDirBridge.cjs");
|
||||
|
||||
// The worker owns SSH sessions in the default runtime path. Install the same
|
||||
// DH compatibility shim as the main process before loading ssh2-backed bridges.
|
||||
require("../bridges/boringSslDhCompat.cjs").installBoringSslDhCompat();
|
||||
|
||||
function createWorkerSender(parentPort, webContentsId) {
|
||||
return {
|
||||
id: webContentsId,
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
send(channel, payload) {
|
||||
if (channel === "netcatty:data") {
|
||||
const message = {
|
||||
kind: "output",
|
||||
sessionId: payload?.sessionId,
|
||||
data: payload?.data,
|
||||
};
|
||||
if (payload?.meta) message.meta = payload.meta;
|
||||
parentPort.postMessage(message);
|
||||
return;
|
||||
}
|
||||
parentPort.postMessage({
|
||||
kind: "renderer-event",
|
||||
webContentsId,
|
||||
channel,
|
||||
payload,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeParentPortMessage(eventOrMessage) {
|
||||
if (eventOrMessage && typeof eventOrMessage === "object" && "data" in eventOrMessage) {
|
||||
return eventOrMessage.data;
|
||||
}
|
||||
return eventOrMessage;
|
||||
}
|
||||
|
||||
function createZmodemUploadFileSelector(parentPort, options = {}) {
|
||||
const randomUUIDFn = options.randomUUID || randomUUID;
|
||||
const pendingRequests = new Map();
|
||||
|
||||
parentPort.on("message", (eventOrMessage) => {
|
||||
const message = normalizeParentPortMessage(eventOrMessage);
|
||||
if (message?.kind !== "zmodem-upload-dialog-result") return;
|
||||
const pending = pendingRequests.get(message.requestId);
|
||||
if (!pending) return;
|
||||
pendingRequests.delete(message.requestId);
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error));
|
||||
} else {
|
||||
pending.resolve(message.result || { canceled: true, filePaths: [] });
|
||||
}
|
||||
});
|
||||
|
||||
return function selectZmodemUploadFiles(webContentsId, sessionId) {
|
||||
const requestId = randomUUIDFn();
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
pendingRequests.set(requestId, { resolve, reject });
|
||||
});
|
||||
parentPort.postMessage({
|
||||
kind: "zmodem-upload-dialog",
|
||||
requestId,
|
||||
webContentsId,
|
||||
sessionId,
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
|
||||
function createZmodemDownloadDirectorySelector(parentPort, options = {}) {
|
||||
const randomUUIDFn = options.randomUUID || randomUUID;
|
||||
const pendingRequests = new Map();
|
||||
|
||||
parentPort.on("message", (eventOrMessage) => {
|
||||
const message = normalizeParentPortMessage(eventOrMessage);
|
||||
if (message?.kind !== "zmodem-download-dialog-result") return;
|
||||
const pending = pendingRequests.get(message.requestId);
|
||||
if (!pending) return;
|
||||
pendingRequests.delete(message.requestId);
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error));
|
||||
} else {
|
||||
pending.resolve(message.result || { canceled: true, filePaths: [] });
|
||||
}
|
||||
});
|
||||
|
||||
return function selectZmodemDownloadDirectory(webContentsId, sessionId) {
|
||||
const requestId = randomUUIDFn();
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
pendingRequests.set(requestId, { resolve, reject });
|
||||
});
|
||||
parentPort.postMessage({
|
||||
kind: "zmodem-download-dialog",
|
||||
requestId,
|
||||
webContentsId,
|
||||
sessionId,
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
|
||||
function registerExternalSessionHandlers(ipcMain, options) {
|
||||
const {
|
||||
sessions,
|
||||
parentPort,
|
||||
sessionLogStreamManager,
|
||||
} = options;
|
||||
|
||||
const stopSessionLog = async (sessionId, session) => {
|
||||
const token = session?.sessionLogToken;
|
||||
if (!token) return;
|
||||
session.sessionLogToken = null;
|
||||
await sessionLogStreamManager.stopStream(sessionId, token);
|
||||
};
|
||||
|
||||
ipcMain.handle("netcatty:external:start", async (event, payload) => {
|
||||
const sessionId = payload?.sessionId;
|
||||
if (typeof sessionId !== "string" || sessionId.length < 1 || sessionId.length > 128) {
|
||||
throw new TypeError("External terminal session ID is invalid");
|
||||
}
|
||||
if (sessions.has(sessionId)) throw new Error("Terminal session already exists");
|
||||
const columns = Number(payload?.columns);
|
||||
const rows = Number(payload?.rows);
|
||||
if (!Number.isInteger(columns) || columns < 1 || columns > 16_384
|
||||
|| !Number.isInteger(rows) || rows < 1 || rows > 16_384) {
|
||||
throw new TypeError("External terminal dimensions are invalid");
|
||||
}
|
||||
const postEvent = (message) => parentPort.postMessage({
|
||||
kind: "external-session-event",
|
||||
sessionId,
|
||||
...message,
|
||||
});
|
||||
let session;
|
||||
const stream = {
|
||||
write(data) {
|
||||
postEvent({ event: "input", data });
|
||||
return true;
|
||||
},
|
||||
setWindow(nextRows, nextColumns) {
|
||||
postEvent({ event: "resize", columns: nextColumns, rows: nextRows });
|
||||
},
|
||||
pause() {
|
||||
postEvent({ event: "flow", paused: true });
|
||||
},
|
||||
resume() {
|
||||
postEvent({ event: "flow", paused: false });
|
||||
},
|
||||
close() {
|
||||
void stopSessionLog(sessionId, session).catch(() => {});
|
||||
postEvent({ event: "close", reason: "closed" });
|
||||
},
|
||||
};
|
||||
let sessionLogToken = null;
|
||||
if (payload?.sessionLog?.enabled && payload.sessionLog.directory) {
|
||||
sessionLogToken = sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: payload?.hostLabel || payload?.hostname || payload?.protocol || "Plugin",
|
||||
hostname: payload?.hostname || payload?.protocol || "plugin",
|
||||
directory: payload.sessionLog.directory,
|
||||
format: payload.sessionLog.format || "txt",
|
||||
timestampsEnabled: Boolean(payload.sessionLog.timestampsEnabled),
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
session = {
|
||||
type: "plugin",
|
||||
protocol: typeof payload?.protocol === "string" ? payload.protocol : "plugin",
|
||||
stream,
|
||||
cols: columns,
|
||||
rows,
|
||||
webContentsId: event.sender.id,
|
||||
closed: false,
|
||||
sessionLogToken,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
return { sessionId };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:external:output", async (event, payload) => {
|
||||
const session = sessions.get(payload?.sessionId);
|
||||
if (!session || session.type !== "plugin" || session.closed) {
|
||||
throw new Error("External terminal session is unavailable");
|
||||
}
|
||||
if (typeof payload?.data !== "string") {
|
||||
throw new TypeError("External terminal output is invalid");
|
||||
}
|
||||
if (payload.data.length > 0) sessionLogStreamManager.appendData(payload.sessionId, payload.data);
|
||||
await event.sender.send("netcatty:data", {
|
||||
sessionId: payload.sessionId,
|
||||
data: payload.data,
|
||||
...(payload.meta === undefined ? {} : { meta: payload.meta }),
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:external:finish", async (event, payload) => {
|
||||
const session = sessions.get(payload?.sessionId);
|
||||
if (!session || session.type !== "plugin") return null;
|
||||
session.closed = true;
|
||||
sessions.delete(payload.sessionId);
|
||||
await stopSessionLog(payload.sessionId, session);
|
||||
event.sender.send("netcatty:exit", {
|
||||
sessionId: payload.sessionId,
|
||||
exitCode: payload?.reason === "error" ? 1 : 0,
|
||||
reason: payload?.reason || "closed",
|
||||
...(payload?.error ? { error: payload.error } : {}),
|
||||
...(Array.isArray(payload?.diagnostics) ? { diagnostics: payload.diagnostics } : {}),
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function registerPortForwardingWorkerBridge(ipcMain) {
|
||||
const portForwardingBridge = require("../bridges/portForwardingBridge.cjs");
|
||||
portForwardingBridge.registerHandlers(ipcMain);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const parentPort = process.parentPort;
|
||||
if (!parentPort) {
|
||||
throw new Error("Terminal worker requires process.parentPort");
|
||||
}
|
||||
|
||||
// Every session lives in this shared worker, so a single stray async error
|
||||
// must never hit Node's default exit-with-code-1 behavior. Install the same
|
||||
// guards as the main process; without them one unhandled error disconnects
|
||||
// all sessions simultaneously ("Terminal worker exited with code 1").
|
||||
// Errors are reported to main so they still land in the crash log.
|
||||
// Startup errors (before the bridges load and runtime.start() completes)
|
||||
// are not suppressed: the guards re-throw those so the worker exits and the
|
||||
// manager can reject or replace it instead of hanging pending requests.
|
||||
const { installTerminalWorkerErrorGuards } = require("./workerProcessGuards.cjs");
|
||||
|
||||
const reportWorkerError = (origin, err, reason) => {
|
||||
try {
|
||||
parentPort.postMessage({
|
||||
kind: "worker-error",
|
||||
origin,
|
||||
reason,
|
||||
message: err?.message || String(err),
|
||||
...(err?.stack ? { stack: err.stack } : {}),
|
||||
...(err?.code ? { code: err.code } : {}),
|
||||
...(err?.level ? { level: err.level } : {}),
|
||||
});
|
||||
} catch {
|
||||
// Reporting must never be able to escalate into a worker crash.
|
||||
}
|
||||
};
|
||||
|
||||
let startupComplete = false;
|
||||
installTerminalWorkerErrorGuards({
|
||||
isRuntimeStarted: () => startupComplete,
|
||||
report(origin, err, decision) {
|
||||
reportWorkerError(origin, err, decision?.reason);
|
||||
},
|
||||
});
|
||||
|
||||
const sessions = new Map();
|
||||
const sftpClients = new Map();
|
||||
const { createTerminalDataPipeline } = require("./terminalDataPipeline.cjs");
|
||||
const terminalDataPipeline = createTerminalDataPipeline({
|
||||
onWarning: (warning) => parentPort.postMessage({
|
||||
kind: "terminal-interceptor-warning",
|
||||
warning,
|
||||
}),
|
||||
});
|
||||
let runtime = null;
|
||||
const electronModule = {
|
||||
webContents: {
|
||||
fromId(webContentsId) {
|
||||
if (runtime?.createSender) {
|
||||
return runtime.createSender(webContentsId);
|
||||
}
|
||||
return createWorkerSender(parentPort, webContentsId);
|
||||
},
|
||||
},
|
||||
};
|
||||
const selectZmodemUploadFiles = createZmodemUploadFileSelector(parentPort);
|
||||
const selectZmodemDownloadDirectory = createZmodemDownloadDirectorySelector(parentPort);
|
||||
|
||||
const terminalBridge = require("../bridges/terminalBridge.cjs");
|
||||
const sshBridge = require("../bridges/sshBridge.cjs");
|
||||
const sftpBridge = require("../bridges/sftpBridge.cjs");
|
||||
const transferBridge = require("../bridges/transferBridge.cjs");
|
||||
const fileWatcherBridge = require("../bridges/fileWatcherBridge.cjs");
|
||||
const compressUploadBridge = require("../bridges/compressUploadBridge.cjs");
|
||||
const sessionLogStreamManager = require("../bridges/sessionLogStreamManager.cjs");
|
||||
const { registerWorkerAiExecHandlers } = require("./aiExec.cjs");
|
||||
const deps = {
|
||||
sessions,
|
||||
sftpClients,
|
||||
electronModule,
|
||||
selectZmodemUploadFiles,
|
||||
selectZmodemDownloadDirectory,
|
||||
terminalDataPipeline,
|
||||
};
|
||||
|
||||
runtime = createTerminalWorkerRuntime({
|
||||
parentPort,
|
||||
terminalDataPipeline,
|
||||
reportSuppressedError(origin, err, reason) {
|
||||
reportWorkerError(origin, err, reason);
|
||||
},
|
||||
registerBridges(ipcMain) {
|
||||
sshBridge.init(deps);
|
||||
terminalBridge.init(deps);
|
||||
sftpBridge.init(deps);
|
||||
transferBridge.init(deps);
|
||||
fileWatcherBridge.init({
|
||||
...deps,
|
||||
transferBridge,
|
||||
});
|
||||
compressUploadBridge.init({
|
||||
...deps,
|
||||
transferBridge,
|
||||
});
|
||||
sshBridge.registerHandlers(ipcMain);
|
||||
terminalBridge.registerHandlers(ipcMain);
|
||||
sftpBridge.registerHandlers(ipcMain);
|
||||
registerPortForwardingWorkerBridge(ipcMain);
|
||||
transferBridge.registerHandlers(ipcMain);
|
||||
fileWatcherBridge.registerHandlers(ipcMain);
|
||||
compressUploadBridge.registerHandlers(ipcMain);
|
||||
registerWorkerAiExecHandlers(ipcMain, { sessions });
|
||||
registerExternalSessionHandlers(ipcMain, {
|
||||
sessions,
|
||||
parentPort,
|
||||
sessionLogStreamManager,
|
||||
});
|
||||
// Expose worker-owned active log paths so main-process clear-all can
|
||||
// skip live auto-save streams (separate module instance from main).
|
||||
require("../bridges/sessionLogsBridge.cjs").registerWorkerHandlers(ipcMain);
|
||||
const { createSystemManagerBridge } = require("../bridges/systemManagerBridge.cjs");
|
||||
createSystemManagerBridge({
|
||||
getSessions: () => sessions,
|
||||
execOnEtSession: (...args) => terminalBridge.execOnEtSession(...args),
|
||||
ensureMoshStatsConnection: (...args) => sshBridge.ensureMoshStatsConnection(...args),
|
||||
process,
|
||||
}).registerHandlers(ipcMain);
|
||||
ipcMain.on("netcatty:zmodem:cancel", (_event, payload) => {
|
||||
sessions.get(payload?.sessionId)?.zmodemSentry?.cancel(payload?.options);
|
||||
});
|
||||
ipcMain.handle("netcatty:zmodem:drag-drop-upload", async (_event, payload) => {
|
||||
const { sessionId, files, uploadCommand } = payload || {};
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session?.zmodemSentry?.queueDragDropUpload) {
|
||||
return { success: false, error: "ZMODEM upload is not available for this session" };
|
||||
}
|
||||
if (session.zmodemSentry.isActive?.()) {
|
||||
return { success: false, error: "ZMODEM transfer already in progress" };
|
||||
}
|
||||
|
||||
const filePaths = [];
|
||||
const remoteNames = [];
|
||||
const tempPaths = [];
|
||||
|
||||
for (const file of files || []) {
|
||||
if (!file?.name) continue;
|
||||
let localPath = file.path;
|
||||
if (!localPath && file.data) {
|
||||
localPath = tempDirBridge.getTempFilePath(file.name);
|
||||
await fs.promises.writeFile(localPath, Buffer.from(file.data));
|
||||
tempPaths.push(localPath);
|
||||
}
|
||||
if (!localPath) continue;
|
||||
try {
|
||||
await fs.promises.access(localPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
filePaths.push(localPath);
|
||||
remoteNames.push(file.remoteName || path.basename(localPath));
|
||||
}
|
||||
|
||||
if (!filePaths.length) {
|
||||
for (const tempPath of tempPaths) {
|
||||
try { await fs.promises.unlink(tempPath); } catch { /* ignore */ }
|
||||
}
|
||||
return { success: false, error: "No readable files to upload" };
|
||||
}
|
||||
|
||||
try {
|
||||
session.zmodemSentry.queueDragDropUpload({
|
||||
filePaths,
|
||||
remoteNames,
|
||||
uploadCommand: uploadCommand || "rz -y\r",
|
||||
tempPaths,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
for (const tempPath of tempPaths) {
|
||||
try { await fs.promises.unlink(tempPath); } catch { /* ignore */ }
|
||||
}
|
||||
return { success: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
runtime.start();
|
||||
// Only after the message listener is installed and all bridges registered
|
||||
// is it safe to suppress process-level errors.
|
||||
startupComplete = true;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createWorkerSender,
|
||||
createZmodemDownloadDirectorySelector,
|
||||
createZmodemUploadFileSelector,
|
||||
normalizeParentPortMessage,
|
||||
registerExternalSessionHandlers,
|
||||
registerPortForwardingWorkerBridge,
|
||||
main,
|
||||
};
|
||||
219
electron/terminalWorker/process.test.cjs
Normal file
219
electron/terminalWorker/process.test.cjs
Normal file
@@ -0,0 +1,219 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
createZmodemDownloadDirectorySelector,
|
||||
createZmodemUploadFileSelector,
|
||||
normalizeParentPortMessage,
|
||||
registerExternalSessionHandlers,
|
||||
} = require("./process.cjs");
|
||||
|
||||
function createParentPort() {
|
||||
const messages = [];
|
||||
const listeners = new Map();
|
||||
return {
|
||||
messages,
|
||||
on(channel, callback) {
|
||||
listeners.set(channel, callback);
|
||||
},
|
||||
postMessage(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
emitMessage(message) {
|
||||
listeners.get("message")?.(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("normalizeParentPortMessage unwraps Electron utility process MessageEvent data", () => {
|
||||
assert.deepEqual(
|
||||
normalizeParentPortMessage({ data: { kind: "zmodem-upload-dialog-result" } }),
|
||||
{ kind: "zmodem-upload-dialog-result" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeParentPortMessage({ kind: "request" }),
|
||||
{ kind: "request" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal worker installs DH compatibility before SSH bridges load", () => {
|
||||
assert.equal(crypto.createDiffieHellmanGroup.__boringSslDhCompat, true);
|
||||
});
|
||||
|
||||
test("ZMODEM upload selector resolves dialog results delivered as MessageEvent data", async () => {
|
||||
const parentPort = createParentPort();
|
||||
const selectUploadFiles = createZmodemUploadFileSelector(parentPort, {
|
||||
randomUUID: () => "dialog-1",
|
||||
});
|
||||
|
||||
const promise = selectUploadFiles(7, "session-1");
|
||||
assert.deepEqual(parentPort.messages, [{
|
||||
kind: "zmodem-upload-dialog",
|
||||
requestId: "dialog-1",
|
||||
webContentsId: 7,
|
||||
sessionId: "session-1",
|
||||
}]);
|
||||
|
||||
parentPort.emitMessage({
|
||||
data: {
|
||||
kind: "zmodem-upload-dialog-result",
|
||||
requestId: "dialog-1",
|
||||
result: { canceled: false, filePaths: ["/tmp/upload.txt"] },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await promise, {
|
||||
canceled: false,
|
||||
filePaths: ["/tmp/upload.txt"],
|
||||
});
|
||||
});
|
||||
|
||||
test("ZMODEM download selector resolves directory dialog results delivered as MessageEvent data", async () => {
|
||||
const parentPort = createParentPort();
|
||||
const selectDownloadDirectory = createZmodemDownloadDirectorySelector(parentPort, {
|
||||
randomUUID: () => "download-dialog-1",
|
||||
});
|
||||
|
||||
const promise = selectDownloadDirectory(7, "session-1");
|
||||
assert.deepEqual(parentPort.messages, [{
|
||||
kind: "zmodem-download-dialog",
|
||||
requestId: "download-dialog-1",
|
||||
webContentsId: 7,
|
||||
sessionId: "session-1",
|
||||
}]);
|
||||
|
||||
parentPort.emitMessage({
|
||||
data: {
|
||||
kind: "zmodem-download-dialog-result",
|
||||
requestId: "download-dialog-1",
|
||||
result: { canceled: false, filePaths: ["/tmp/downloads"] },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await promise, {
|
||||
canceled: false,
|
||||
filePaths: ["/tmp/downloads"],
|
||||
});
|
||||
});
|
||||
|
||||
test("external plugin sessions stream auto-save logs through output and lifecycle cleanup", async () => {
|
||||
const handlers = new Map();
|
||||
const sessions = new Map();
|
||||
const parentPort = createParentPort();
|
||||
const observed = [];
|
||||
const token = Symbol("plugin-session-log");
|
||||
const sessionLogStreamManager = {
|
||||
startStream(sessionId, options) {
|
||||
observed.push(["start-log", sessionId, options]);
|
||||
return token;
|
||||
},
|
||||
appendData(sessionId, data) {
|
||||
observed.push(["append-log", sessionId, data]);
|
||||
},
|
||||
async stopStream(sessionId, expectedToken) {
|
||||
observed.push(["stop-log", sessionId, expectedToken]);
|
||||
},
|
||||
};
|
||||
registerExternalSessionHandlers({
|
||||
handle(channel, handler) {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
}, {
|
||||
sessions,
|
||||
parentPort,
|
||||
sessionLogStreamManager,
|
||||
});
|
||||
const sender = {
|
||||
id: 7,
|
||||
send(channel, payload) {
|
||||
observed.push(["send", channel, payload]);
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(await handlers.get("netcatty:external:start")({ sender }, {
|
||||
sessionId: "plugin-log-1",
|
||||
protocol: "plugin:com.example.transport.connection",
|
||||
hostLabel: "Example transport",
|
||||
hostname: "example.test",
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
sessionLog: {
|
||||
enabled: true,
|
||||
directory: "/logs",
|
||||
format: "html",
|
||||
timestampsEnabled: true,
|
||||
},
|
||||
}), { sessionId: "plugin-log-1" });
|
||||
assert.equal(observed[0][0], "start-log");
|
||||
assert.deepEqual(observed[0].slice(1, 3), [
|
||||
"plugin-log-1",
|
||||
{
|
||||
hostLabel: "Example transport",
|
||||
hostname: "example.test",
|
||||
directory: "/logs",
|
||||
format: "html",
|
||||
timestampsEnabled: true,
|
||||
startTime: observed[0][2].startTime,
|
||||
},
|
||||
]);
|
||||
|
||||
await handlers.get("netcatty:external:output")({ sender }, {
|
||||
sessionId: "plugin-log-1",
|
||||
data: "provider output",
|
||||
});
|
||||
assert.deepEqual(observed.slice(1, 3), [
|
||||
["append-log", "plugin-log-1", "provider output"],
|
||||
["send", "netcatty:data", {
|
||||
sessionId: "plugin-log-1",
|
||||
data: "provider output",
|
||||
}],
|
||||
]);
|
||||
|
||||
await assert.rejects(
|
||||
handlers.get("netcatty:external:output")({ sender }, {
|
||||
sessionId: "plugin-log-1",
|
||||
data: Buffer.from("not a decoded provider string"),
|
||||
}),
|
||||
/output is invalid/,
|
||||
);
|
||||
assert.equal(observed.length, 3);
|
||||
|
||||
await handlers.get("netcatty:external:finish")({ sender }, {
|
||||
sessionId: "plugin-log-1",
|
||||
reason: "closed",
|
||||
diagnostics: [{ severity: "warning", message: "Provider closed after idle timeout" }],
|
||||
});
|
||||
assert.deepEqual(observed.slice(3), [
|
||||
["stop-log", "plugin-log-1", token],
|
||||
["send", "netcatty:exit", {
|
||||
sessionId: "plugin-log-1",
|
||||
exitCode: 0,
|
||||
reason: "closed",
|
||||
diagnostics: [{ severity: "warning", message: "Provider closed after idle timeout" }],
|
||||
}],
|
||||
]);
|
||||
assert.equal(sessions.has("plugin-log-1"), false);
|
||||
|
||||
await handlers.get("netcatty:external:start")({ sender }, {
|
||||
sessionId: "plugin-log-2",
|
||||
protocol: "plugin:com.example.transport.connection",
|
||||
hostLabel: "Example transport",
|
||||
hostname: "example.test",
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
sessionLog: {
|
||||
enabled: true,
|
||||
directory: "/logs",
|
||||
format: "txt",
|
||||
},
|
||||
});
|
||||
sessions.get("plugin-log-2").stream.close();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(
|
||||
observed.some((entry) => entry[0] === "stop-log"
|
||||
&& entry[1] === "plugin-log-2"
|
||||
&& entry[2] === token),
|
||||
true,
|
||||
);
|
||||
});
|
||||
854
electron/terminalWorker/runtime.cjs
Normal file
854
electron/terminalWorker/runtime.cjs
Normal file
@@ -0,0 +1,854 @@
|
||||
"use strict";
|
||||
|
||||
const {
|
||||
logTerminalInterruptDebug,
|
||||
normalizeTrace,
|
||||
} = require("../bridges/terminalInterruptDiagnostics.cjs");
|
||||
const {
|
||||
clearTerminalSessionPerformanceState,
|
||||
} = require("../bridges/emitTerminalSessionData.cjs");
|
||||
|
||||
const DEFAULT_SESSION_LIFECYCLE_TOMBSTONE_TTL_MS = 60_000;
|
||||
const DEFAULT_MAX_SESSION_LIFECYCLE_TOMBSTONES = 2_048;
|
||||
|
||||
const SESSION_START_CHANNELS = new Set([
|
||||
"netcatty:start",
|
||||
"netcatty:local:start",
|
||||
"netcatty:telnet:start",
|
||||
"netcatty:mosh:start",
|
||||
"netcatty:et:start",
|
||||
"netcatty:serial:start",
|
||||
"netcatty:local:reconnect",
|
||||
"netcatty:external:start",
|
||||
]);
|
||||
|
||||
function createIpcMainHarness() {
|
||||
const handlers = new Map();
|
||||
const listeners = new Map();
|
||||
return {
|
||||
handlers,
|
||||
listeners,
|
||||
handle(channel, handler) {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
on(channel, listener) {
|
||||
listeners.set(channel, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMessageEvent(eventOrMessage) {
|
||||
if (eventOrMessage && typeof eventOrMessage === "object" && "data" in eventOrMessage) {
|
||||
return {
|
||||
message: eventOrMessage.data,
|
||||
ports: eventOrMessage.ports || [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: eventOrMessage,
|
||||
ports: eventOrMessage?.ports || [],
|
||||
};
|
||||
}
|
||||
|
||||
function createOutputPortRegistry() {
|
||||
const outputPorts = new Map();
|
||||
|
||||
function closeSession(sessionId) {
|
||||
const port = outputPorts.get(sessionId);
|
||||
if (!port) return;
|
||||
outputPorts.delete(sessionId);
|
||||
try {
|
||||
port.close?.();
|
||||
} catch {
|
||||
// Ignore close races while tearing down a worker-owned output port.
|
||||
}
|
||||
}
|
||||
|
||||
function post(sessionId, data, meta) {
|
||||
const port = outputPorts.get(sessionId);
|
||||
if (!port) return false;
|
||||
try {
|
||||
port.postMessage(meta ? { sessionId, data, meta } : { sessionId, data });
|
||||
return true;
|
||||
} catch {
|
||||
closeSession(sessionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function postControl(sessionId, message) {
|
||||
const port = outputPorts.get(sessionId);
|
||||
if (!port) return false;
|
||||
try {
|
||||
port.postMessage({ ...message, sessionId });
|
||||
return true;
|
||||
} catch {
|
||||
closeSession(sessionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function open(sessionId, port) {
|
||||
if (!sessionId || !port) return;
|
||||
closeSession(sessionId);
|
||||
outputPorts.set(sessionId, port);
|
||||
try {
|
||||
port.start?.();
|
||||
} catch {
|
||||
// Some Electron MessagePort implementations do not require start().
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
post,
|
||||
postControl,
|
||||
closeSession,
|
||||
};
|
||||
}
|
||||
|
||||
function addPortMessageListener(port, callback) {
|
||||
if (typeof port?.on === "function") {
|
||||
port.on("message", callback);
|
||||
return;
|
||||
}
|
||||
if (port) {
|
||||
port.onmessage = callback;
|
||||
}
|
||||
}
|
||||
|
||||
function createUrgentInputPortRegistry(dispatch) {
|
||||
const ports = new Map();
|
||||
|
||||
function close(webContentsId) {
|
||||
const port = ports.get(webContentsId);
|
||||
if (!port) return;
|
||||
ports.delete(webContentsId);
|
||||
try {
|
||||
port.close?.();
|
||||
} catch {
|
||||
// Ignore stale urgent input port close races.
|
||||
}
|
||||
}
|
||||
|
||||
function open(webContentsId, port) {
|
||||
if (!webContentsId || !port) return;
|
||||
close(webContentsId);
|
||||
ports.set(webContentsId, port);
|
||||
addPortMessageListener(port, (eventOrMessage) => {
|
||||
const { message } = normalizeMessageEvent(eventOrMessage);
|
||||
dispatch(webContentsId, message);
|
||||
});
|
||||
try {
|
||||
port.start?.();
|
||||
} catch {
|
||||
// Some Electron MessagePort implementations do not require start().
|
||||
}
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
for (const webContentsId of Array.from(ports.keys())) {
|
||||
close(webContentsId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
closeAll,
|
||||
};
|
||||
}
|
||||
|
||||
function createSender(
|
||||
parentPort,
|
||||
webContentsId,
|
||||
outputPorts,
|
||||
terminalDataPipeline,
|
||||
pendingOutputBySession = new Map(),
|
||||
sessionOutputGenerations = new Map(),
|
||||
sessionRequestIds = new Map(),
|
||||
fixedOriginRequestId = null,
|
||||
onCurrentSessionExit = null,
|
||||
) {
|
||||
const ownedSessionGenerations = new Map();
|
||||
const getOwnedSessionGeneration = (sessionId) => {
|
||||
if (!ownedSessionGenerations.has(sessionId)) {
|
||||
ownedSessionGenerations.set(sessionId, sessionOutputGenerations.get(sessionId) ?? 0);
|
||||
}
|
||||
return ownedSessionGenerations.get(sessionId);
|
||||
};
|
||||
const trackPendingOutput = (sessionId, pending) => {
|
||||
pendingOutputBySession.set(sessionId, pending);
|
||||
const clearPending = () => {
|
||||
if (pendingOutputBySession.get(sessionId) === pending) {
|
||||
pendingOutputBySession.delete(sessionId);
|
||||
}
|
||||
};
|
||||
void pending.then(clearPending, clearPending);
|
||||
};
|
||||
const getOriginRequestId = (sessionId) => (
|
||||
fixedOriginRequestId || sessionRequestIds.get(sessionId) || null
|
||||
);
|
||||
const postRendererEvent = (channel, payload) => {
|
||||
const explicitGeneration = payload?._terminalSessionGeneration;
|
||||
const sessionGeneration = Number.isSafeInteger(explicitGeneration)
|
||||
? explicitGeneration
|
||||
: payload?.sessionId
|
||||
? getOwnedSessionGeneration(payload.sessionId)
|
||||
: undefined;
|
||||
const rendererPayload = explicitGeneration === undefined
|
||||
? payload
|
||||
: Object.freeze(Object.fromEntries(
|
||||
Object.entries(payload).filter(([key]) => key !== "_terminalSessionGeneration"),
|
||||
));
|
||||
const originRequestId = getOriginRequestId(payload?.sessionId);
|
||||
if (channel === "netcatty:exit" && payload?.sessionId) {
|
||||
if ((sessionOutputGenerations.get(payload.sessionId) ?? 0) === sessionGeneration) {
|
||||
sessionOutputGenerations.set(payload.sessionId, sessionGeneration + 1);
|
||||
pendingOutputBySession.delete(payload.sessionId);
|
||||
outputPorts?.closeSession?.(payload.sessionId);
|
||||
terminalDataPipeline?.detach?.(payload.sessionId, undefined, "session-closed");
|
||||
onCurrentSessionExit?.(payload.sessionId, sessionGeneration);
|
||||
}
|
||||
}
|
||||
parentPort.postMessage({
|
||||
kind: "renderer-event",
|
||||
webContentsId,
|
||||
channel,
|
||||
payload: rendererPayload,
|
||||
...(sessionGeneration === undefined ? {} : { sessionGeneration }),
|
||||
...(originRequestId ? { originRequestId } : {}),
|
||||
});
|
||||
};
|
||||
const deliverTerminalData = (payload) => {
|
||||
const sessionId = payload?.sessionId;
|
||||
const explicitGeneration = payload?._terminalSessionGeneration;
|
||||
const outputGeneration = Number.isSafeInteger(explicitGeneration)
|
||||
? explicitGeneration
|
||||
: getOwnedSessionGeneration(sessionId);
|
||||
const originRequestId = getOriginRequestId(sessionId);
|
||||
if ((sessionOutputGenerations.get(sessionId) ?? 0) !== outputGeneration) return;
|
||||
const tapMessage = {
|
||||
kind: "output-tap",
|
||||
sessionId: payload?.sessionId,
|
||||
data: payload?.data,
|
||||
sessionGeneration: outputGeneration,
|
||||
...(originRequestId ? { originRequestId } : {}),
|
||||
};
|
||||
if (payload?.meta) tapMessage.meta = payload.meta;
|
||||
if (payload?.tapped !== true) parentPort.postMessage(tapMessage);
|
||||
const pipelineProcessed = payload?.pipelineProcessed === true;
|
||||
const pipelineMode = terminalDataPipeline?.getOutputMode?.(payload?.sessionId) ?? 0;
|
||||
let sensitiveInputState;
|
||||
if (!pipelineProcessed && pipelineMode !== 0) {
|
||||
sensitiveInputState = terminalDataPipeline.observeOutput?.(
|
||||
payload?.sessionId,
|
||||
payload?.data,
|
||||
) === true;
|
||||
}
|
||||
const deliver = (data, transformed = false) => {
|
||||
if ((sessionOutputGenerations.get(sessionId) ?? 0) !== outputGeneration) return;
|
||||
const inheritedIngressBytes = payload?.meta?.pluginPipelineIngressBytes;
|
||||
const replayedRawIngressBytes = !transformed
|
||||
&& !pipelineProcessed
|
||||
&& Number.isFinite(inheritedIngressBytes)
|
||||
? Math.max(0, Number(inheritedIngressBytes)) + String(payload?.data ?? "").length
|
||||
: null;
|
||||
const pipelineMeta = {
|
||||
...(payload?.meta ?? {}),
|
||||
...(replayedRawIngressBytes == null
|
||||
? {}
|
||||
: { pluginPipelineIngressBytes: replayedRawIngressBytes }),
|
||||
...(transformed
|
||||
? {
|
||||
pluginPipelineIngressBytes:
|
||||
Number(payload?.meta?.pluginPipelineIngressBytes ?? 0)
|
||||
+ String(payload?.data ?? "").length,
|
||||
pluginPipelineProcessed: true,
|
||||
}
|
||||
: {}),
|
||||
...(sensitiveInputState === undefined
|
||||
? {}
|
||||
: { pluginPipelineSensitiveInput: sensitiveInputState }),
|
||||
};
|
||||
const meta = Object.keys(pipelineMeta).length > 0 ? pipelineMeta : undefined;
|
||||
if (outputPorts?.post?.(payload?.sessionId, data, meta)) return;
|
||||
const outputMessage = {
|
||||
kind: "output",
|
||||
sessionId: payload?.sessionId,
|
||||
data,
|
||||
tapped: true,
|
||||
sessionGeneration: outputGeneration,
|
||||
...(originRequestId ? { originRequestId } : {}),
|
||||
};
|
||||
if (meta) outputMessage.meta = meta;
|
||||
parentPort.postMessage(outputMessage);
|
||||
};
|
||||
const previous = pendingOutputBySession.get(sessionId);
|
||||
if (pipelineProcessed || !terminalDataPipeline?.interceptOutput || (pipelineMode & 2) === 0) {
|
||||
if (!previous) {
|
||||
deliver(payload?.data, false);
|
||||
return Promise.resolve();
|
||||
}
|
||||
const pending = previous.then(
|
||||
() => deliver(payload?.data, false),
|
||||
() => deliver(payload?.data, false),
|
||||
);
|
||||
trackPendingOutput(sessionId, pending);
|
||||
return pending;
|
||||
}
|
||||
const interceptAndDeliver = () => {
|
||||
try {
|
||||
return Promise.resolve(terminalDataPipeline.interceptOutput(sessionId, payload?.data)).then(
|
||||
(data) => deliver(data, true),
|
||||
() => deliver(payload?.data, false),
|
||||
);
|
||||
} catch {
|
||||
deliver(payload?.data, false);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
// Invoke the pipeline immediately so its bounded byte window and monotonic
|
||||
// deadline cover time spent waiting behind earlier transforms. The
|
||||
// pipeline owns per-session transform ordering; this outer registry only
|
||||
// retains the latest barrier for direct fail-open output and session exit.
|
||||
const pending = Promise.resolve(interceptAndDeliver());
|
||||
trackPendingOutput(sessionId, pending);
|
||||
return pending;
|
||||
};
|
||||
return {
|
||||
id: webContentsId,
|
||||
claimSessionGeneration(sessionId) {
|
||||
return getOwnedSessionGeneration(sessionId);
|
||||
},
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
send(channel, payload) {
|
||||
if (channel === "netcatty:data") {
|
||||
return deliverTerminalData(payload);
|
||||
}
|
||||
if (channel === "netcatty:exit" && payload?.sessionId) {
|
||||
const pending = pendingOutputBySession.get(payload.sessionId);
|
||||
if (pending) {
|
||||
void pending.then(
|
||||
() => postRendererEvent(channel, payload),
|
||||
() => postRendererEvent(channel, payload),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
postRendererEvent(channel, payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTerminalWorkerRuntime(options = {}) {
|
||||
const {
|
||||
parentPort,
|
||||
registerBridges,
|
||||
terminalDataPipeline,
|
||||
} = options;
|
||||
// Optional hook for suppressed-but-diagnostic failures so they still reach
|
||||
// the main process's persistent crash log via the "worker-error" channel.
|
||||
const reportSuppressedError = typeof options.reportSuppressedError === "function"
|
||||
? options.reportSuppressedError
|
||||
: null;
|
||||
const ipcMain = createIpcMainHarness();
|
||||
let started = false;
|
||||
const outputPorts = createOutputPortRegistry();
|
||||
const pendingOutputBySession = new Map();
|
||||
const sessionOutputGenerations = new Map();
|
||||
const sessionRequestIds = new Map();
|
||||
const sessionOperationTails = new Map();
|
||||
const sessionOperationKinds = new Map();
|
||||
const sessionCloseEpochs = new Map();
|
||||
const sessionLifecycleTombstoneTimes = new Map();
|
||||
const sessionStartMarkers = new Set();
|
||||
const pendingSessionStartBootEpochs = new Map();
|
||||
let urgentInputPorts = null;
|
||||
const now = typeof options.now === "function" ? options.now : Date.now;
|
||||
const sessionLifecycleTombstoneTtlMs = Number.isFinite(options.sessionLifecycleTombstoneTtlMs)
|
||||
? Math.max(0, Number(options.sessionLifecycleTombstoneTtlMs))
|
||||
: DEFAULT_SESSION_LIFECYCLE_TOMBSTONE_TTL_MS;
|
||||
const maxSessionLifecycleTombstones = Number.isFinite(options.maxSessionLifecycleTombstones)
|
||||
? Math.max(1, Math.floor(Number(options.maxSessionLifecycleTombstones)))
|
||||
: DEFAULT_MAX_SESSION_LIFECYCLE_TOMBSTONES;
|
||||
const setDefaultTransportIdleTtlMs = typeof options.setDefaultTransportIdleTtlMs === "function"
|
||||
? options.setDefaultTransportIdleTtlMs
|
||||
: (value) => require("../bridges/sshConnectionPool.cjs").setDefaultTransportIdleTtlMs(value);
|
||||
|
||||
function canPruneSessionLifecycleTombstone(sessionId) {
|
||||
return !sessionOperationTails.has(sessionId)
|
||||
&& !sessionStartMarkers.has(sessionId)
|
||||
&& !pendingOutputBySession.has(sessionId);
|
||||
}
|
||||
|
||||
function deleteSessionLifecycleTombstone(sessionId) {
|
||||
sessionLifecycleTombstoneTimes.delete(sessionId);
|
||||
sessionOutputGenerations.delete(sessionId);
|
||||
sessionCloseEpochs.delete(sessionId);
|
||||
pendingSessionStartBootEpochs.delete(sessionId);
|
||||
}
|
||||
|
||||
function normalizeBootEpoch(bootEpoch) {
|
||||
if (!Number.isFinite(bootEpoch)) return undefined;
|
||||
return Number(bootEpoch);
|
||||
}
|
||||
|
||||
function rememberPendingStartBootEpoch(sessionId, bootEpoch) {
|
||||
const normalized = normalizeBootEpoch(bootEpoch);
|
||||
if (!sessionId || normalized === undefined) return;
|
||||
pendingSessionStartBootEpochs.set(sessionId, normalized);
|
||||
}
|
||||
|
||||
function shouldSkipStaleEpochClose(sessionId, bootEpoch) {
|
||||
const closeEpoch = normalizeBootEpoch(bootEpoch);
|
||||
if (closeEpoch === undefined || !sessionId) return false;
|
||||
const ownerEpoch = pendingSessionStartBootEpochs.get(sessionId);
|
||||
return ownerEpoch !== undefined && ownerEpoch > closeEpoch;
|
||||
}
|
||||
|
||||
function pruneSessionLifecycleTombstones() {
|
||||
const currentTime = now();
|
||||
for (const [sessionId, closedAt] of sessionLifecycleTombstoneTimes) {
|
||||
if (currentTime - closedAt < sessionLifecycleTombstoneTtlMs) continue;
|
||||
if (!canPruneSessionLifecycleTombstone(sessionId)) continue;
|
||||
deleteSessionLifecycleTombstone(sessionId);
|
||||
}
|
||||
if (sessionLifecycleTombstoneTimes.size <= maxSessionLifecycleTombstones) return;
|
||||
for (const sessionId of [...sessionLifecycleTombstoneTimes.keys()]) {
|
||||
if (sessionLifecycleTombstoneTimes.size <= maxSessionLifecycleTombstones) break;
|
||||
if (!canPruneSessionLifecycleTombstone(sessionId)) continue;
|
||||
deleteSessionLifecycleTombstone(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function touchSessionLifecycleTombstone(sessionId) {
|
||||
if (!sessionId) return;
|
||||
sessionLifecycleTombstoneTimes.delete(sessionId);
|
||||
sessionLifecycleTombstoneTimes.set(sessionId, now());
|
||||
}
|
||||
|
||||
function finalizeNaturalSessionExit(sessionId) {
|
||||
if (!sessionId) return;
|
||||
sessionStartMarkers.delete(sessionId);
|
||||
sessionRequestIds.delete(sessionId);
|
||||
clearTerminalSessionPerformanceState(sessionId);
|
||||
touchSessionLifecycleTombstone(sessionId);
|
||||
pruneSessionLifecycleTombstones();
|
||||
}
|
||||
|
||||
const createWorkerOutputSender = () => createSender(
|
||||
parentPort,
|
||||
0,
|
||||
outputPorts,
|
||||
terminalDataPipeline,
|
||||
pendingOutputBySession,
|
||||
sessionOutputGenerations,
|
||||
sessionRequestIds,
|
||||
null,
|
||||
finalizeNaturalSessionExit,
|
||||
);
|
||||
|
||||
function replayWorkerOutput(sessionId, chunks) {
|
||||
const sender = createWorkerOutputSender();
|
||||
for (const chunk of chunks || []) {
|
||||
const data = chunk && typeof chunk === "object" && "data" in chunk ? chunk.data : chunk;
|
||||
const meta = chunk && typeof chunk === "object" ? chunk.meta : undefined;
|
||||
const pipelineProcessed = meta?.pluginPipelineProcessed === true;
|
||||
sender.send("netcatty:data", {
|
||||
sessionId,
|
||||
data,
|
||||
meta,
|
||||
tapped: true,
|
||||
pipelineProcessed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateSessionOutput(sessionId) {
|
||||
if (!sessionId) return;
|
||||
sessionOutputGenerations.set(
|
||||
sessionId,
|
||||
(sessionOutputGenerations.get(sessionId) ?? 0) + 1,
|
||||
);
|
||||
pendingOutputBySession.delete(sessionId);
|
||||
outputPorts.closeSession(sessionId);
|
||||
terminalDataPipeline?.detach?.(sessionId, undefined, "session-closed");
|
||||
sessionRequestIds.delete(sessionId);
|
||||
clearTerminalSessionPerformanceState(sessionId);
|
||||
touchSessionLifecycleTombstone(sessionId);
|
||||
pruneSessionLifecycleTombstones();
|
||||
}
|
||||
|
||||
async function handleRequest(message) {
|
||||
const handler = ipcMain.handlers.get(message.channel);
|
||||
if (!handler) {
|
||||
parentPort.postMessage({
|
||||
kind: "response",
|
||||
requestId: message.requestId,
|
||||
error: `No terminal worker handler registered for ${message.channel}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const isSessionStart = SESSION_START_CHANNELS.has(message.channel);
|
||||
const requestedSessionId = isSessionStart ? message.payload?.sessionId : null;
|
||||
const naturalExitGenerations = new Map();
|
||||
const requestedSessionGeneration = requestedSessionId
|
||||
? (sessionOutputGenerations.get(requestedSessionId) ?? 0)
|
||||
: null;
|
||||
if (requestedSessionId) {
|
||||
sessionRequestIds.set(requestedSessionId, message.requestId);
|
||||
sessionLifecycleTombstoneTimes.delete(requestedSessionId);
|
||||
}
|
||||
const result = await handler({
|
||||
sender: createSender(
|
||||
parentPort,
|
||||
message.webContentsId,
|
||||
outputPorts,
|
||||
terminalDataPipeline,
|
||||
pendingOutputBySession,
|
||||
sessionOutputGenerations,
|
||||
sessionRequestIds,
|
||||
isSessionStart ? message.requestId : null,
|
||||
(sessionId, generation) => {
|
||||
naturalExitGenerations.set(sessionId, generation);
|
||||
finalizeNaturalSessionExit(sessionId);
|
||||
},
|
||||
),
|
||||
}, message.payload);
|
||||
const sessionId = result?.sessionId;
|
||||
if (isSessionStart && sessionId) {
|
||||
const currentGeneration = sessionOutputGenerations.get(sessionId) ?? 0;
|
||||
const exitedGeneration = naturalExitGenerations.get(sessionId);
|
||||
const exitedDuringRequest = exitedGeneration !== undefined
|
||||
&& currentGeneration > exitedGeneration;
|
||||
const requestedGenerationStillCurrent = requestedSessionGeneration === null
|
||||
|| currentGeneration === requestedSessionGeneration;
|
||||
if (!exitedDuringRequest && requestedGenerationStillCurrent) {
|
||||
sessionRequestIds.set(sessionId, message.requestId);
|
||||
sessionStartMarkers.add(sessionId);
|
||||
sessionLifecycleTombstoneTimes.delete(sessionId);
|
||||
} else {
|
||||
sessionRequestIds.delete(sessionId);
|
||||
sessionStartMarkers.delete(sessionId);
|
||||
}
|
||||
}
|
||||
parentPort.postMessage({
|
||||
kind: "response",
|
||||
requestId: message.requestId,
|
||||
result,
|
||||
...(typeof sessionId === "string"
|
||||
? { sessionGeneration: sessionOutputGenerations.get(sessionId) ?? 0 }
|
||||
: {}),
|
||||
});
|
||||
} catch (err) {
|
||||
parentPort.postMessage({
|
||||
kind: "response",
|
||||
requestId: message.requestId,
|
||||
error: err?.message || String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function closeSupersededSessionStart(message) {
|
||||
const sessionId = message.payload?.sessionId;
|
||||
if (!sessionId) return;
|
||||
parentPort.postMessage({
|
||||
kind: "session-superseding",
|
||||
sessionId,
|
||||
sessionGeneration: sessionOutputGenerations.get(sessionId) ?? 0,
|
||||
replacementRequestId: message.requestId,
|
||||
});
|
||||
invalidateSessionOutput(sessionId);
|
||||
const closeHandler = ipcMain.handlers.get("netcatty:close:await");
|
||||
if (closeHandler) {
|
||||
await closeHandler({
|
||||
sender: createSender(
|
||||
parentPort,
|
||||
message.webContentsId,
|
||||
outputPorts,
|
||||
terminalDataPipeline,
|
||||
pendingOutputBySession,
|
||||
sessionOutputGenerations,
|
||||
sessionRequestIds,
|
||||
null,
|
||||
finalizeNaturalSessionExit,
|
||||
),
|
||||
}, { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
function postRequestError(message, error) {
|
||||
parentPort.postMessage({
|
||||
kind: "response",
|
||||
requestId: message.requestId,
|
||||
error: error?.message || String(error),
|
||||
});
|
||||
}
|
||||
|
||||
function trackSessionOperation(sessionId, operation, kind) {
|
||||
sessionOperationTails.set(sessionId, operation);
|
||||
sessionOperationKinds.set(sessionId, kind);
|
||||
void operation.finally(() => {
|
||||
if (sessionOperationTails.get(sessionId) === operation) {
|
||||
sessionOperationTails.delete(sessionId);
|
||||
sessionOperationKinds.delete(sessionId);
|
||||
pruneSessionLifecycleTombstones();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchRequest(message) {
|
||||
pruneSessionLifecycleTombstones();
|
||||
const sessionId = SESSION_START_CHANNELS.has(message.channel)
|
||||
? message.payload?.sessionId
|
||||
: null;
|
||||
if (message.channel === "netcatty:close:await" && message.payload?.sessionId) {
|
||||
dispatchSessionClose(message, true);
|
||||
return;
|
||||
}
|
||||
if (!sessionId) {
|
||||
void handleRequest(message);
|
||||
return;
|
||||
}
|
||||
rememberPendingStartBootEpoch(sessionId, message.payload?.bootEpoch);
|
||||
const closeEpoch = sessionCloseEpochs.get(sessionId) ?? 0;
|
||||
const previous = sessionOperationTails.get(sessionId);
|
||||
const previousKind = sessionOperationKinds.get(sessionId);
|
||||
const shouldClosePreviousStart = previousKind === "start" || sessionStartMarkers.has(sessionId);
|
||||
const current = (previous || Promise.resolve()).catch(() => {}).then(async () => {
|
||||
if ((sessionCloseEpochs.get(sessionId) ?? 0) !== closeEpoch) {
|
||||
postRequestError(message, new Error("Terminal session start was cancelled by close"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (shouldClosePreviousStart) await closeSupersededSessionStart(message);
|
||||
if ((sessionCloseEpochs.get(sessionId) ?? 0) !== closeEpoch) {
|
||||
postRequestError(message, new Error("Terminal session start was cancelled by close"));
|
||||
return;
|
||||
}
|
||||
sessionStartMarkers.add(sessionId);
|
||||
await handleRequest(message);
|
||||
} catch (error) {
|
||||
postRequestError(message, error);
|
||||
}
|
||||
});
|
||||
trackSessionOperation(sessionId, current, "start");
|
||||
}
|
||||
|
||||
function dispatchSessionClose(message, expectsResponse) {
|
||||
const sessionId = message.payload?.sessionId;
|
||||
if (!sessionId) {
|
||||
if (expectsResponse) void handleRequest(message);
|
||||
else handleSend(message);
|
||||
return;
|
||||
}
|
||||
if (shouldSkipStaleEpochClose(sessionId, message.payload?.bootEpoch)) {
|
||||
if (expectsResponse) {
|
||||
parentPort.postMessage({
|
||||
kind: "response",
|
||||
requestId: message.requestId,
|
||||
result: { skipped: true, reason: "boot-epoch-mismatch" },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
sessionCloseEpochs.set(sessionId, (sessionCloseEpochs.get(sessionId) ?? 0) + 1);
|
||||
pendingSessionStartBootEpochs.delete(sessionId);
|
||||
touchSessionLifecycleTombstone(sessionId);
|
||||
const previous = sessionOperationTails.get(sessionId);
|
||||
const current = (previous || Promise.resolve()).catch(() => {}).then(async () => {
|
||||
try {
|
||||
if (expectsResponse) {
|
||||
invalidateSessionOutput(sessionId);
|
||||
await handleRequest(message);
|
||||
} else {
|
||||
handleSend(message);
|
||||
}
|
||||
sessionStartMarkers.delete(sessionId);
|
||||
} catch (error) {
|
||||
if (expectsResponse) postRequestError(message, error);
|
||||
}
|
||||
});
|
||||
trackSessionOperation(sessionId, current, "close");
|
||||
pruneSessionLifecycleTombstones();
|
||||
}
|
||||
|
||||
function handleSend(message) {
|
||||
const listener = ipcMain.listeners.get(message.channel);
|
||||
if (!listener) return;
|
||||
if (message.channel === "netcatty:interrupt") {
|
||||
terminalDataPipeline?.clearSensitiveInput?.(message.payload?.sessionId);
|
||||
const trace = normalizeTrace(message.payload);
|
||||
logTerminalInterruptDebug("worker-received-send", {
|
||||
channel: message.channel,
|
||||
webContentsId: message.webContentsId,
|
||||
}, trace);
|
||||
}
|
||||
if (message.channel === "netcatty:close" && message.payload?.sessionId) {
|
||||
invalidateSessionOutput(message.payload.sessionId);
|
||||
}
|
||||
try {
|
||||
listener({
|
||||
sender: createSender(
|
||||
parentPort,
|
||||
message.webContentsId,
|
||||
outputPorts,
|
||||
terminalDataPipeline,
|
||||
pendingOutputBySession,
|
||||
sessionOutputGenerations,
|
||||
sessionRequestIds,
|
||||
null,
|
||||
finalizeNaturalSessionExit,
|
||||
),
|
||||
}, message.payload);
|
||||
} catch (err) {
|
||||
// Send listeners (write/resize/flow/close) are fire-and-forget; a throw
|
||||
// here is synchronous and would otherwise escape as an uncaught
|
||||
// exception on the worker's message loop.
|
||||
console.error(`[TerminalWorker] send listener failed for ${message.channel}:`, err);
|
||||
if (reportSuppressedError) {
|
||||
try {
|
||||
reportSuppressedError("send-listener", err, `send listener failed for ${message.channel}`);
|
||||
} catch {
|
||||
// Reporting must never be able to escalate into a worker crash.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleUrgentInput(webContentsId, message) {
|
||||
if (message?.kind !== "interrupt" || !message.sessionId) return;
|
||||
handleSend({
|
||||
channel: "netcatty:interrupt",
|
||||
payload: {
|
||||
sessionId: message.sessionId,
|
||||
trace: message.trace,
|
||||
urgentInputPort: true,
|
||||
},
|
||||
webContentsId,
|
||||
});
|
||||
}
|
||||
|
||||
function handleMessage(eventOrMessage) {
|
||||
const { message, ports } = normalizeMessageEvent(eventOrMessage);
|
||||
if (message?.kind === "set-ssh-transport-idle-ttl") {
|
||||
setDefaultTransportIdleTtlMs(message.value);
|
||||
return;
|
||||
}
|
||||
if (message?.kind === "urgent-input-port") {
|
||||
urgentInputPorts?.open(message.webContentsId, ports?.[0]);
|
||||
return;
|
||||
}
|
||||
if (message?.kind === "close-urgent-input-port") {
|
||||
urgentInputPorts?.close(message.webContentsId);
|
||||
return;
|
||||
}
|
||||
if (message?.kind === "output-port") {
|
||||
const sessionGeneration = sessionOutputGenerations.get(message.sessionId) ?? 0;
|
||||
if (Number.isSafeInteger(message.sessionGeneration)
|
||||
&& message.sessionGeneration !== sessionGeneration) {
|
||||
try { ports?.[0]?.close?.(); } catch {}
|
||||
return;
|
||||
}
|
||||
outputPorts.open(message.sessionId, ports?.[0]);
|
||||
replayWorkerOutput(message.sessionId, message.bufferedOutput);
|
||||
const pending = pendingOutputBySession.get(message.sessionId);
|
||||
const notifyReady = () => parentPort.postMessage({
|
||||
kind: "output-port-ready",
|
||||
sessionId: message.sessionId,
|
||||
...(Number.isSafeInteger(message.sessionGeneration) ? { sessionGeneration } : {}),
|
||||
...(message.outputPortRequestId
|
||||
? { outputPortRequestId: message.outputPortRequestId }
|
||||
: {}),
|
||||
});
|
||||
if (pending) {
|
||||
void pending.then(
|
||||
notifyReady,
|
||||
notifyReady,
|
||||
);
|
||||
} else {
|
||||
notifyReady();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message?.kind === "terminal-interceptor-port") {
|
||||
terminalDataPipeline?.attach?.(message, ports?.[0]);
|
||||
return;
|
||||
}
|
||||
if (message?.kind === "terminal-interceptor-detach") {
|
||||
terminalDataPipeline?.detach?.(message.sessionId, message.direction, "detached");
|
||||
return;
|
||||
}
|
||||
if (message?.kind === "output-flush") {
|
||||
replayWorkerOutput(message.sessionId, message.chunks);
|
||||
return;
|
||||
}
|
||||
if (message?.kind === "close-output-port") {
|
||||
invalidateSessionOutput(message.sessionId);
|
||||
return;
|
||||
}
|
||||
if (message?.kind === "output-drain") {
|
||||
outputPorts.postControl(message.sessionId, {
|
||||
kind: "drain",
|
||||
requestId: message.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message?.kind === "request") {
|
||||
dispatchRequest(message);
|
||||
return;
|
||||
}
|
||||
if (message?.kind === "send") {
|
||||
if (message.channel === "netcatty:close" && message.payload?.sessionId) {
|
||||
dispatchSessionClose(message, false);
|
||||
return;
|
||||
}
|
||||
handleSend(message);
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
urgentInputPorts = createUrgentInputPortRegistry(handleUrgentInput);
|
||||
registerBridges?.(ipcMain);
|
||||
parentPort.on("message", handleMessage);
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
ipcMain,
|
||||
createSender(webContentsId) {
|
||||
return createSender(
|
||||
parentPort,
|
||||
webContentsId,
|
||||
outputPorts,
|
||||
terminalDataPipeline,
|
||||
pendingOutputBySession,
|
||||
sessionOutputGenerations,
|
||||
sessionRequestIds,
|
||||
null,
|
||||
finalizeNaturalSessionExit,
|
||||
);
|
||||
},
|
||||
closeUrgentInputPortsForTest() {
|
||||
urgentInputPorts?.closeAll();
|
||||
},
|
||||
_getSessionLifecycleStateCountsForTests() {
|
||||
pruneSessionLifecycleTombstones();
|
||||
return {
|
||||
outputGenerations: sessionOutputGenerations.size,
|
||||
closeEpochs: sessionCloseEpochs.size,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createTerminalWorkerRuntime,
|
||||
createOutputPortRegistry,
|
||||
};
|
||||
1467
electron/terminalWorker/runtime.test.cjs
Normal file
1467
electron/terminalWorker/runtime.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
416
electron/terminalWorker/terminalDataPipeline.cjs
Normal file
416
electron/terminalWorker/terminalDataPipeline.cjs
Normal file
@@ -0,0 +1,416 @@
|
||||
"use strict";
|
||||
|
||||
const { TextDecoder, TextEncoder } = require("node:util");
|
||||
const { performance } = require("node:perf_hooks");
|
||||
const {
|
||||
TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES,
|
||||
TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES,
|
||||
createTerminalInterceptorEnvelope,
|
||||
} = require("../plugins/terminalInterceptorTransport.cjs");
|
||||
|
||||
const INPUT_DEADLINE_MS = 4;
|
||||
const OUTPUT_DEADLINE_MS = 50;
|
||||
const MAX_CHUNK_BYTES = TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES;
|
||||
const OUTPUT_WINDOW_BYTES = TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES;
|
||||
const PROMPT_TAIL_CHARS = 2_048;
|
||||
const SENSITIVE_LABELS = [
|
||||
"pass(?:word|phrase|code)", "passwd", "one[\\s-]?time", "otp", "verification",
|
||||
"authentication", "security[\\s-]+(?:code|token|passcode|pin)", "\\bpin\\b",
|
||||
"\\btoken\\b", "2fa", "two[\\s-]?factor", "multi[\\s-]?factor", "\\bmfa\\b",
|
||||
"second[\\s-]+factor", "secondary", "re[\\s-]?enter", "confirm", "\\bedr\\b",
|
||||
"\\bduo\\b", "密码", "密碼", "口令", "动态", "動態", "一次性", "验证码",
|
||||
"驗證碼", "验证信息", "驗證資訊", "令牌", "双因素", "雙因素", "多因素",
|
||||
"短信验证", "簡訊驗證", "手机验证", "手機驗證", "二次", "安全密码", "安全密碼",
|
||||
"挑战码", "挑戰碼", "парол",
|
||||
].join("|");
|
||||
const SENSITIVE_PROMPT = new RegExp(
|
||||
`(?:${SENSITIVE_LABELS})[^\\r\\n]{0,160}[::?>›»]?\\s*$`,
|
||||
"iu",
|
||||
);
|
||||
const CONFIRMED_SHELL_PROMPT = /^(?:(?:~)?[#$%]|(?:[^\r\n]{1,120}@[^\r\n]{1,120}:[^\r\n]{0,120}[#$%])|(?:PS(?:\s+\S.*)?>))\s*$/u;
|
||||
|
||||
function messageData(value) {
|
||||
return value && typeof value === "object" && "data" in value ? value.data : value;
|
||||
}
|
||||
|
||||
function addPortListener(port, listener) {
|
||||
if (typeof port.addEventListener === "function") {
|
||||
port.addEventListener("message", listener);
|
||||
port.start?.();
|
||||
return () => port.removeEventListener?.("message", listener);
|
||||
}
|
||||
port.on?.("message", listener);
|
||||
port.start?.();
|
||||
return () => port.off?.("message", listener) ?? port.removeListener?.("message", listener);
|
||||
}
|
||||
|
||||
function toTransferBuffer(bytes) {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return copy.buffer;
|
||||
}
|
||||
|
||||
function nextUtf8ChunkEnd(bytes, offset, maxChunkBytes = MAX_CHUNK_BYTES) {
|
||||
let end = Math.min(bytes.byteLength, offset + maxChunkBytes);
|
||||
if (end >= bytes.byteLength) return end;
|
||||
while (end > offset && (bytes[end] & 0xc0) === 0x80) end -= 1;
|
||||
if (end > offset) return end;
|
||||
// TextEncoder output is valid UTF-8 and a code point is at most four bytes,
|
||||
// so this branch is only reachable with an artificially tiny test limit.
|
||||
end = Math.min(bytes.byteLength, offset + maxChunkBytes);
|
||||
while (end < bytes.byteLength && (bytes[end] & 0xc0) === 0x80) end += 1;
|
||||
return end;
|
||||
}
|
||||
|
||||
function visibleTerminalTail(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/gu, "")
|
||||
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/gu, "")
|
||||
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
|
||||
.slice(-PROMPT_TAIL_CHARS);
|
||||
}
|
||||
|
||||
function createTerminalDataPipeline(options = {}) {
|
||||
const encoder = options.encoder ?? new TextEncoder();
|
||||
const decoder = options.decoder ?? new TextDecoder("utf-8", { fatal: true });
|
||||
const now = options.now ?? (() => performance.now());
|
||||
const onWarning = options.onWarning ?? (() => {});
|
||||
const inputDeadlineMs = options.inputDeadlineMs ?? INPUT_DEADLINE_MS;
|
||||
const outputDeadlineMs = options.outputDeadlineMs ?? OUTPUT_DEADLINE_MS;
|
||||
const outputWindowBytes = options.outputWindowBytes ?? OUTPUT_WINDOW_BYTES;
|
||||
const bindings = new Map();
|
||||
const outputModes = new Map();
|
||||
const outputRawTails = new Map();
|
||||
const sensitiveInputSessions = new Set();
|
||||
|
||||
const keyOf = (sessionId, direction) => `${sessionId}\0${direction}`;
|
||||
|
||||
function refreshOutputMode(sessionId) {
|
||||
const input = bindings.has(keyOf(sessionId, "input"));
|
||||
const output = bindings.has(keyOf(sessionId, "output"));
|
||||
const mode = (input ? 1 : 0) | (output ? 2 : 0);
|
||||
if (mode) outputModes.set(sessionId, mode);
|
||||
else outputModes.delete(sessionId);
|
||||
if (!mode) {
|
||||
outputRawTails.delete(sessionId);
|
||||
sensitiveInputSessions.delete(sessionId);
|
||||
} else if (!input) {
|
||||
sensitiveInputSessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function warn(binding, code, message) {
|
||||
try {
|
||||
onWarning(Object.freeze({
|
||||
sessionId: binding.sessionId,
|
||||
direction: binding.direction,
|
||||
providerId: binding.providerId,
|
||||
pluginId: binding.pluginId,
|
||||
pluginVersion: binding.pluginVersion,
|
||||
runtimeId: binding.runtimeId,
|
||||
runtimeKind: binding.runtimeKind,
|
||||
securityPrincipal: binding.securityPrincipal,
|
||||
code,
|
||||
message,
|
||||
}));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function disable(binding, code, message) {
|
||||
if (!binding.active) return;
|
||||
binding.active = false;
|
||||
bindings.delete(keyOf(binding.sessionId, binding.direction));
|
||||
refreshOutputMode(binding.sessionId);
|
||||
binding.removeListener?.();
|
||||
for (const pending of binding.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(null);
|
||||
}
|
||||
binding.pending.clear();
|
||||
try { binding.port.close?.(); } catch {}
|
||||
if (!["detached", "replaced", "shutdown", "session-closed"].includes(code)) {
|
||||
warn(binding, code, message);
|
||||
}
|
||||
}
|
||||
|
||||
function attach(descriptor, port) {
|
||||
const sessionId = typeof descriptor?.sessionId === "string" ? descriptor.sessionId : "";
|
||||
const direction = descriptor?.direction;
|
||||
const identityFields = [
|
||||
descriptor?.pluginId,
|
||||
descriptor?.pluginVersion,
|
||||
descriptor?.runtimeId,
|
||||
descriptor?.securityPrincipal,
|
||||
descriptor?.providerId,
|
||||
];
|
||||
if (!sessionId || sessionId.length > 256
|
||||
|| (direction !== "input" && direction !== "output")
|
||||
|| descriptor?.runtimeKind !== "utility"
|
||||
|| identityFields.some((value) => typeof value !== "string" || value.length < 1 || value.length > 512)
|
||||
|| !port?.postMessage) {
|
||||
throw new TypeError("Terminal interceptor attachment is invalid");
|
||||
}
|
||||
const key = keyOf(sessionId, direction);
|
||||
const previous = bindings.get(key);
|
||||
const previousRawTail = outputRawTails.get(sessionId);
|
||||
const wasSensitive = sensitiveInputSessions.has(sessionId);
|
||||
if (previous) disable(previous, "replaced", "Terminal interceptor was replaced for this session");
|
||||
const binding = {
|
||||
sessionId,
|
||||
direction,
|
||||
providerId: String(descriptor.providerId ?? ""),
|
||||
pluginId: String(descriptor.pluginId ?? ""),
|
||||
pluginVersion: descriptor.pluginVersion,
|
||||
runtimeId: descriptor.runtimeId,
|
||||
runtimeKind: descriptor.runtimeKind,
|
||||
securityPrincipal: descriptor.securityPrincipal,
|
||||
port,
|
||||
active: true,
|
||||
nextSequence: 1,
|
||||
pending: new Map(),
|
||||
queuedBytes: 0,
|
||||
outputExpansionCreditBytes: direction === "output" ? outputWindowBytes : 0,
|
||||
queue: Promise.resolve(),
|
||||
removeListener: null,
|
||||
};
|
||||
binding.removeListener = addPortListener(port, (event) => {
|
||||
let envelope;
|
||||
try {
|
||||
const message = messageData(event);
|
||||
envelope = createTerminalInterceptorEnvelope(message?.frame, message?.transfer);
|
||||
} catch {
|
||||
disable(binding, "protocol", "Terminal interceptor returned an invalid response and was disabled");
|
||||
return;
|
||||
}
|
||||
const message = envelope.frame;
|
||||
if (message.type !== "netcatty:terminal-interceptor:result") {
|
||||
disable(binding, "protocol", "Terminal interceptor returned an invalid response and was disabled");
|
||||
return;
|
||||
}
|
||||
const pending = binding.pending.get(message.sequence);
|
||||
if (!pending) {
|
||||
disable(binding, "protocol", "Terminal interceptor returned an unsolicited or duplicate response and was disabled");
|
||||
return;
|
||||
}
|
||||
binding.pending.delete(message.sequence);
|
||||
clearTimeout(pending.timer);
|
||||
if (now() >= pending.deadlineAt) {
|
||||
pending.resolve(null);
|
||||
disable(binding, "timeout", `Terminal ${binding.direction} interceptor exceeded its ${pending.deadlineMs} ms budget`);
|
||||
return;
|
||||
}
|
||||
if (message.status !== "ok" || message.creditBytes !== pending.sentBytes) {
|
||||
pending.resolve(null);
|
||||
disable(binding, "protocol", "Terminal interceptor returned an invalid response and was disabled");
|
||||
return;
|
||||
}
|
||||
pending.resolve(new Uint8Array(envelope.transfer));
|
||||
});
|
||||
port.on?.("close", () => disable(binding, "closed", "Terminal interceptor stopped and was disabled"));
|
||||
port.postMessage(createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:ready",
|
||||
sessionId,
|
||||
direction,
|
||||
windowBytes: direction === "output" ? outputWindowBytes : MAX_CHUNK_BYTES,
|
||||
}));
|
||||
bindings.set(key, binding);
|
||||
refreshOutputMode(sessionId);
|
||||
if (previousRawTail !== undefined) outputRawTails.set(sessionId, previousRawTail);
|
||||
if (direction === "input") {
|
||||
const lastVisibleLine = previousRawTail === undefined
|
||||
? ""
|
||||
: (visibleTerminalTail(previousRawTail).split(/[\r\n]/u).at(-1) ?? "");
|
||||
if (wasSensitive || SENSITIVE_PROMPT.test(lastVisibleLine)) {
|
||||
sensitiveInputSessions.add(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function detach(sessionId, direction, reason = "detached") {
|
||||
const directions = direction ? [direction] : ["input", "output"];
|
||||
for (const item of directions) {
|
||||
const binding = bindings.get(keyOf(sessionId, item));
|
||||
if (binding) disable(binding, reason, "Terminal interceptor was detached from this session");
|
||||
}
|
||||
}
|
||||
|
||||
function requestChunk(binding, bytes, deadlineAt, deadlineMs) {
|
||||
if (!binding.active) return Promise.resolve(null);
|
||||
const remainingMs = deadlineAt - now();
|
||||
if (remainingMs <= 0) {
|
||||
disable(binding, "timeout", `Terminal ${binding.direction} interceptor exceeded its ${deadlineMs} ms budget`);
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const sequence = binding.nextSequence++;
|
||||
const data = toTransferBuffer(bytes);
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
binding.pending.delete(sequence);
|
||||
resolve(null);
|
||||
disable(binding, "timeout", `Terminal ${binding.direction} interceptor exceeded its ${deadlineMs} ms budget`);
|
||||
}, remainingMs);
|
||||
binding.pending.set(sequence, {
|
||||
resolve,
|
||||
timer,
|
||||
deadlineAt,
|
||||
deadlineMs,
|
||||
sentBytes: bytes.byteLength,
|
||||
});
|
||||
try {
|
||||
const envelope = createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:chunk",
|
||||
sequence,
|
||||
direction: binding.direction,
|
||||
creditBytes: binding.direction === "output"
|
||||
? Math.max(0, outputWindowBytes - binding.queuedBytes)
|
||||
: MAX_CHUNK_BYTES,
|
||||
byteLength: data.byteLength,
|
||||
}, data);
|
||||
binding.port.postMessage(envelope, [data]);
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
binding.pending.delete(sequence);
|
||||
resolve(null);
|
||||
disable(binding, "closed", "Terminal interceptor transport failed and was disabled");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function observeOutput(sessionId, data) {
|
||||
const mode = outputModes.get(sessionId) ?? 0;
|
||||
let sensitivePrompt = false;
|
||||
if (mode !== 0) {
|
||||
// Retain bounded raw output so an ANSI sequence split across chunks can
|
||||
// be stripped only after its terminating byte arrives. Persisting the
|
||||
// already-stripped tail would turn an incomplete CSI into visible text
|
||||
// and could split a password label such as "Pass\x1b[0" + "mword:".
|
||||
const rawTail = `${outputRawTails.get(sessionId) ?? ""}${data}`
|
||||
.slice(-(PROMPT_TAIL_CHARS * 2));
|
||||
outputRawTails.set(sessionId, rawTail);
|
||||
const tail = visibleTerminalTail(rawTail);
|
||||
const lastLine = tail.split(/[\r\n]/u).at(-1) ?? "";
|
||||
sensitivePrompt = SENSITIVE_PROMPT.test(lastLine);
|
||||
if ((mode & 1) !== 0) {
|
||||
if (sensitivePrompt) sensitiveInputSessions.add(sessionId);
|
||||
else if (CONFIRMED_SHELL_PROMPT.test(lastLine)) sensitiveInputSessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
return (mode & 1) !== 0 ? sensitiveInputSessions.has(sessionId) : sensitivePrompt;
|
||||
}
|
||||
|
||||
async function transform(sessionId, direction, data, options = {}) {
|
||||
const binding = bindings.get(keyOf(sessionId, direction));
|
||||
if (!binding?.active) {
|
||||
return data;
|
||||
}
|
||||
const hostSensitive = direction === "input" && sensitiveInputSessions.has(sessionId);
|
||||
if (options.bypass === true || (direction === "input" && options.sensitive === true) || hostSensitive) {
|
||||
const finishPassthrough = () => {
|
||||
if (hostSensitive && /[\r\n]/u.test(String(data))) {
|
||||
sensitiveInputSessions.delete(sessionId);
|
||||
outputRawTails.delete(sessionId);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
const passthrough = binding.queue.then(finishPassthrough, finishPassthrough);
|
||||
binding.queue = passthrough.then(() => undefined, () => undefined);
|
||||
return passthrough;
|
||||
}
|
||||
const bytes = encoder.encode(String(data));
|
||||
if (bytes.byteLength === 0) return data;
|
||||
const deadlineMs = direction === "input" ? inputDeadlineMs : outputDeadlineMs;
|
||||
const deadlineAt = now() + deadlineMs;
|
||||
if (direction === "output" && binding.queuedBytes + bytes.byteLength > outputWindowBytes) {
|
||||
// Chain the fail-open chunk behind all earlier work before disabling the
|
||||
// binding. disable() releases pending requests, and this queue barrier
|
||||
// ensures callers cannot deliver the newer chunk first.
|
||||
const passthrough = binding.queue.then(() => data, () => data);
|
||||
binding.queue = passthrough.then(() => undefined, () => undefined);
|
||||
disable(binding, "backpressure", "Terminal output interceptor exceeded its bounded credit window");
|
||||
return passthrough;
|
||||
}
|
||||
binding.queuedBytes += bytes.byteLength;
|
||||
const run = async () => {
|
||||
const output = [];
|
||||
for (let offset = 0; offset < bytes.byteLength;) {
|
||||
if (!binding.active) return data;
|
||||
const end = nextUtf8ChunkEnd(bytes, offset);
|
||||
const chunk = bytes.subarray(offset, end);
|
||||
offset = end;
|
||||
const result = await requestChunk(
|
||||
binding,
|
||||
chunk,
|
||||
deadlineAt,
|
||||
deadlineMs,
|
||||
);
|
||||
if (!result) return data;
|
||||
output.push(result);
|
||||
offset = end;
|
||||
}
|
||||
try {
|
||||
const total = output.reduce((sum, item) => sum + item.byteLength, 0);
|
||||
if (direction === "output") {
|
||||
// Each original byte replenishes one byte of expansion credit. This
|
||||
// permits a bounded 2x steady-state rewrite plus one initial window
|
||||
// burst, while repeated tiny-to-64-KiB expansion trips fail-open
|
||||
// before it can outrun the renderer's display-byte queue.
|
||||
const replenishedCredit = Math.min(
|
||||
outputWindowBytes,
|
||||
binding.outputExpansionCreditBytes + bytes.byteLength,
|
||||
);
|
||||
const expansionBytes = Math.max(0, total - bytes.byteLength);
|
||||
if (total > outputWindowBytes || expansionBytes > replenishedCredit) {
|
||||
disable(
|
||||
binding,
|
||||
"backpressure",
|
||||
"Terminal output interceptor exceeded its bounded expansion credit",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
binding.outputExpansionCreditBytes = replenishedCredit - expansionBytes;
|
||||
}
|
||||
const combined = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const item of output) {
|
||||
combined.set(item, offset);
|
||||
offset += item.byteLength;
|
||||
}
|
||||
return decoder.decode(combined);
|
||||
} catch {
|
||||
disable(binding, "encoding", "Terminal interceptor returned invalid UTF-8 and was disabled");
|
||||
return data;
|
||||
}
|
||||
};
|
||||
const result = binding.queue.then(run, run);
|
||||
binding.queue = result.then(() => undefined, () => undefined);
|
||||
try { return await result; }
|
||||
finally { binding.queuedBytes = Math.max(0, binding.queuedBytes - bytes.byteLength); }
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
attach,
|
||||
detach,
|
||||
interceptInput: (sessionId, data, options) => transform(sessionId, "input", data, options),
|
||||
interceptOutput: (sessionId, data, options) => transform(sessionId, "output", data, options),
|
||||
has: (sessionId, direction) => bindings.has(keyOf(sessionId, direction)),
|
||||
getOutputMode: (sessionId) => outputModes.get(sessionId) ?? 0,
|
||||
observeOutput,
|
||||
clearSensitiveInput(sessionId) {
|
||||
sensitiveInputSessions.delete(sessionId);
|
||||
outputRawTails.delete(sessionId);
|
||||
},
|
||||
shutdown() {
|
||||
for (const binding of [...bindings.values()]) disable(binding, "shutdown", "Terminal interceptor stopped");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
INPUT_DEADLINE_MS,
|
||||
MAX_CHUNK_BYTES,
|
||||
OUTPUT_DEADLINE_MS,
|
||||
OUTPUT_WINDOW_BYTES,
|
||||
nextUtf8ChunkEnd,
|
||||
visibleTerminalTail,
|
||||
createTerminalDataPipeline,
|
||||
};
|
||||
459
electron/terminalWorker/terminalDataPipeline.test.cjs
Normal file
459
electron/terminalWorker/terminalDataPipeline.test.cjs
Normal file
@@ -0,0 +1,459 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { MessageChannel } = require("node:worker_threads");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
createTerminalDataPipeline,
|
||||
} = require("./terminalDataPipeline.cjs");
|
||||
const {
|
||||
createTerminalInterceptorEnvelope,
|
||||
} = require("../plugins/terminalInterceptorTransport.cjs");
|
||||
|
||||
function listen(port, listener) {
|
||||
port.on("message", listener);
|
||||
port.start?.();
|
||||
}
|
||||
|
||||
function readFrame(message) {
|
||||
return createTerminalInterceptorEnvelope(message?.frame, message?.transfer);
|
||||
}
|
||||
|
||||
function postResult(port, sequence, creditBytes, data) {
|
||||
const envelope = createTerminalInterceptorEnvelope({
|
||||
type: "netcatty:terminal-interceptor:result",
|
||||
sequence,
|
||||
status: "ok",
|
||||
creditBytes,
|
||||
byteLength: data.byteLength,
|
||||
}, data);
|
||||
port.postMessage(envelope, [data]);
|
||||
}
|
||||
|
||||
function attachTransform(pipeline, options = {}) {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.unref?.();
|
||||
channel.port2.unref?.();
|
||||
const seen = [];
|
||||
listen(channel.port2, (message) => {
|
||||
const envelope = readFrame(message);
|
||||
const frame = envelope.frame;
|
||||
if (frame.type !== "netcatty:terminal-interceptor:chunk") return;
|
||||
seen.push({ sequence: frame.sequence, data: Buffer.from(envelope.transfer).toString("utf8") });
|
||||
if (options.hold) return;
|
||||
const transformed = Buffer.from(options.transform?.(Buffer.from(envelope.transfer).toString("utf8"))
|
||||
?? Buffer.from(envelope.transfer).toString("utf8").toUpperCase());
|
||||
const data = Uint8Array.from(transformed).buffer;
|
||||
postResult(channel.port2, frame.sequence, frame.byteLength, data);
|
||||
});
|
||||
pipeline.attach({
|
||||
sessionId: options.sessionId ?? "session-1",
|
||||
direction: options.direction ?? "input",
|
||||
providerId: "com.example.interceptor",
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "utility",
|
||||
securityPrincipal: "principal-1",
|
||||
}, channel.port1);
|
||||
return { channel, seen };
|
||||
}
|
||||
|
||||
test("terminal input interception transfers bounded UTF-8 chunks and preserves ordering", async () => {
|
||||
const pipeline = createTerminalDataPipeline({ inputDeadlineMs: 100 });
|
||||
const { seen } = attachTransform(pipeline);
|
||||
assert.equal(await pipeline.interceptInput("session-1", "hello"), "HELLO");
|
||||
assert.equal(await pipeline.interceptInput("session-1", "world"), "WORLD");
|
||||
assert.deepEqual(seen, [
|
||||
{ sequence: 1, data: "hello" },
|
||||
{ sequence: 2, data: "world" },
|
||||
]);
|
||||
pipeline.shutdown();
|
||||
});
|
||||
|
||||
test("terminal interception keeps multi-byte UTF-8 characters whole at the chunk boundary", async () => {
|
||||
for (const direction of ["input", "output"]) {
|
||||
const pipeline = createTerminalDataPipeline({ inputDeadlineMs: 100, outputDeadlineMs: 100 });
|
||||
const { seen } = attachTransform(pipeline, { direction, transform: (value) => value });
|
||||
const value = `${"a".repeat(65535)}你b`;
|
||||
const result = direction === "input"
|
||||
? await pipeline.interceptInput("session-1", value)
|
||||
: await pipeline.interceptOutput("session-1", value);
|
||||
assert.equal(result, value);
|
||||
assert.deepEqual(seen.map((entry) => Buffer.byteLength(entry.data)), [65535, 4]);
|
||||
assert.equal(seen.map((entry) => entry.data).join(""), value);
|
||||
pipeline.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("sensitive input bypasses the third-party port unconditionally", async () => {
|
||||
const pipeline = createTerminalDataPipeline({ inputDeadlineMs: 100 });
|
||||
const { seen } = attachTransform(pipeline);
|
||||
assert.equal(await pipeline.interceptInput("session-1", "password\r", { sensitive: true }), "password\r");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(seen, []);
|
||||
assert.equal(pipeline.has("session-1", "input"), true);
|
||||
pipeline.shutdown();
|
||||
});
|
||||
|
||||
test("sensitive passthrough stays ordered behind earlier intercepted input", async () => {
|
||||
const pipeline = createTerminalDataPipeline({ inputDeadlineMs: 100 });
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.unref?.();
|
||||
channel.port2.unref?.();
|
||||
let firstChunk;
|
||||
listen(channel.port2, (message) => {
|
||||
const envelope = readFrame(message);
|
||||
if (envelope.frame.type === "netcatty:terminal-interceptor:chunk") firstChunk = envelope.frame;
|
||||
});
|
||||
pipeline.attach({
|
||||
sessionId: "session-1",
|
||||
direction: "input",
|
||||
providerId: "com.example.interceptor",
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "utility",
|
||||
securityPrincipal: "principal-1",
|
||||
}, channel.port1);
|
||||
const order = [];
|
||||
const ordinary = pipeline.interceptInput("session-1", "a").then((value) => order.push(value));
|
||||
const sensitive = pipeline.interceptInput("session-1", "secret", { sensitive: true })
|
||||
.then((value) => order.push(value));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.ok(firstChunk);
|
||||
assert.deepEqual(order, []);
|
||||
const result = Uint8Array.from(Buffer.from("A")).buffer;
|
||||
postResult(channel.port2, firstChunk.sequence, 1, result);
|
||||
await Promise.all([ordinary, sensitive]);
|
||||
assert.deepEqual(order, ["A", "secret"]);
|
||||
pipeline.shutdown();
|
||||
});
|
||||
|
||||
test("replacing an input interceptor preserves host-detected sensitive state", async () => {
|
||||
const pipeline = createTerminalDataPipeline({ inputDeadlineMs: 100 });
|
||||
const first = attachTransform(pipeline, { transform: (data) => data });
|
||||
pipeline.observeOutput("session-1", "Password:");
|
||||
assert.equal(await pipeline.interceptInput("session-1", "first-secret"), "first-secret");
|
||||
|
||||
const second = attachTransform(pipeline, { transform: (data) => data });
|
||||
assert.equal(await pipeline.interceptInput("session-1", "second-secret"), "second-secret");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(second.seen.length, 0);
|
||||
|
||||
pipeline.shutdown();
|
||||
first.channel.port2.close();
|
||||
second.channel.port2.close();
|
||||
});
|
||||
|
||||
test("original output protects password input even when a plugin could hide the prompt", async () => {
|
||||
const pipeline = createTerminalDataPipeline({ inputDeadlineMs: 100 });
|
||||
const { seen } = attachTransform(pipeline);
|
||||
assert.equal(pipeline.getOutputMode("session-1"), 1);
|
||||
pipeline.observeOutput("session-1", "\u001b[31mPass");
|
||||
pipeline.observeOutput("session-1", "word:\u001b[0m ");
|
||||
assert.equal(await pipeline.interceptInput("session-1", "hunter2"), "hunter2");
|
||||
assert.equal(await pipeline.interceptInput("session-1", "\r"), "\r");
|
||||
assert.deepEqual(seen, []);
|
||||
assert.equal(await pipeline.interceptInput("session-1", "next"), "NEXT");
|
||||
assert.deepEqual(seen, [{ sequence: 1, data: "next" }]);
|
||||
pipeline.observeOutput("session-1", "Pass\u001b[0");
|
||||
assert.equal(pipeline.observeOutput("session-1", "mword: "), true);
|
||||
assert.equal(await pipeline.interceptInput("session-1", "split-secret\r"), "split-secret\r");
|
||||
pipeline.observeOutput("session-1", "Custom authentication> ");
|
||||
assert.equal(await pipeline.interceptInput("session-1", "opaque\r"), "opaque\r");
|
||||
pipeline.observeOutput("session-1", "请输入验证码:");
|
||||
assert.equal(await pipeline.interceptInput("session-1", "123456\r"), "123456\r");
|
||||
assert.deepEqual(seen, [{ sequence: 1, data: "next" }]);
|
||||
pipeline.shutdown();
|
||||
});
|
||||
|
||||
test("a confirmed shell prompt clears sensitive mode when authentication is abandoned", async () => {
|
||||
const pipeline = createTerminalDataPipeline({ inputDeadlineMs: 100 });
|
||||
const { seen } = attachTransform(pipeline);
|
||||
|
||||
assert.equal(pipeline.observeOutput("session-1", "Password: "), true);
|
||||
assert.equal(await pipeline.interceptInput("session-1", "secret"), "secret");
|
||||
assert.deepEqual(seen, []);
|
||||
|
||||
assert.equal(pipeline.observeOutput("session-1", "\r\nAccess denied\r\n$ "), false);
|
||||
assert.equal(await pipeline.interceptInput("session-1", "next"), "NEXT");
|
||||
assert.deepEqual(seen, [{ sequence: 1, data: "next" }]);
|
||||
pipeline.shutdown();
|
||||
});
|
||||
|
||||
test("output-only interception classifies sensitive prompts without retaining stale input state", () => {
|
||||
const pipeline = createTerminalDataPipeline();
|
||||
attachTransform(pipeline, { direction: "output" });
|
||||
|
||||
assert.equal(pipeline.observeOutput("session-1", "Pass"), false);
|
||||
assert.equal(pipeline.observeOutput("session-1", "word: "), true);
|
||||
assert.equal(pipeline.observeOutput("session-1", "\r\nordinary output"), false);
|
||||
pipeline.shutdown();
|
||||
});
|
||||
|
||||
test("an input interceptor attached after a visible password prompt starts in sensitive mode", async () => {
|
||||
const pipeline = createTerminalDataPipeline({ inputDeadlineMs: 100 });
|
||||
attachTransform(pipeline, { direction: "output", transform: (data) => data });
|
||||
assert.equal(pipeline.observeOutput("session-1", "Password: "), true);
|
||||
|
||||
const { seen } = attachTransform(pipeline, { direction: "input" });
|
||||
assert.equal(await pipeline.interceptInput("session-1", "secret\r"), "secret\r");
|
||||
assert.deepEqual(seen, []);
|
||||
pipeline.shutdown();
|
||||
});
|
||||
|
||||
test("clearing sensitive input on interrupt restores ordinary interception", async () => {
|
||||
const pipeline = createTerminalDataPipeline({ inputDeadlineMs: 100 });
|
||||
const { seen } = attachTransform(pipeline);
|
||||
attachTransform(pipeline, { direction: "output" });
|
||||
|
||||
assert.equal(pipeline.observeOutput("session-1", "Password: "), true);
|
||||
assert.equal(await pipeline.interceptInput("session-1", "secret"), "secret");
|
||||
assert.deepEqual(seen, []);
|
||||
|
||||
pipeline.clearSensitiveInput("session-1");
|
||||
assert.equal(await pipeline.interceptInput("session-1", "next"), "NEXT");
|
||||
assert.deepEqual(seen, [{ sequence: 1, data: "next" }]);
|
||||
pipeline.shutdown();
|
||||
});
|
||||
|
||||
test("an input deadline failure fails open, disables the session binding, and warns once", async () => {
|
||||
const warnings = [];
|
||||
const pipeline = createTerminalDataPipeline({ inputDeadlineMs: 5, onWarning: (value) => warnings.push(value) });
|
||||
attachTransform(pipeline, { hold: true });
|
||||
assert.equal(await pipeline.interceptInput("session-1", "slow"), "slow");
|
||||
assert.equal(pipeline.has("session-1", "input"), false);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.equal(warnings[0].code, "timeout");
|
||||
assert.equal(await pipeline.interceptInput("session-1", "later"), "later");
|
||||
assert.equal(warnings.length, 1);
|
||||
});
|
||||
|
||||
test("an elapsed deadline rejects a late response before its delayed timer callback runs", async () => {
|
||||
const warnings = [];
|
||||
let now = 1_000;
|
||||
const pipeline = createTerminalDataPipeline({
|
||||
inputDeadlineMs: 100,
|
||||
now: () => now,
|
||||
onWarning: (value) => warnings.push(value),
|
||||
});
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.unref?.();
|
||||
channel.port2.unref?.();
|
||||
listen(channel.port2, (message) => {
|
||||
const envelope = readFrame(message);
|
||||
if (envelope.frame.type !== "netcatty:terminal-interceptor:chunk") return;
|
||||
now += 100;
|
||||
const data = Uint8Array.from(Buffer.from("LATE")).buffer;
|
||||
postResult(channel.port2, envelope.frame.sequence, envelope.frame.byteLength, data);
|
||||
});
|
||||
pipeline.attach({
|
||||
sessionId: "session-1",
|
||||
direction: "input",
|
||||
providerId: "com.example.interceptor",
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "utility",
|
||||
securityPrincipal: "principal-1",
|
||||
}, channel.port1);
|
||||
|
||||
assert.equal(await pipeline.interceptInput("session-1", "original"), "original");
|
||||
assert.equal(pipeline.has("session-1", "input"), false);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.equal(warnings[0].code, "timeout");
|
||||
});
|
||||
|
||||
test("an unsolicited interceptor result trips the protocol circuit breaker", async () => {
|
||||
const warnings = [];
|
||||
const pipeline = createTerminalDataPipeline({
|
||||
inputDeadlineMs: 100,
|
||||
onWarning: (value) => warnings.push(value),
|
||||
});
|
||||
const { channel } = attachTransform(pipeline, { hold: true });
|
||||
const data = Uint8Array.from(Buffer.from("UNSOLICITED")).buffer;
|
||||
postResult(channel.port2, 999, data.byteLength, data);
|
||||
for (let attempt = 0; attempt < 10 && pipeline.has("session-1", "input"); attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(pipeline.has("session-1", "input"), false);
|
||||
assert.deepEqual(warnings.map((warning) => warning.code), ["protocol"]);
|
||||
});
|
||||
|
||||
test("a duplicate interceptor result trips the protocol circuit breaker", async () => {
|
||||
const warnings = [];
|
||||
const pipeline = createTerminalDataPipeline({
|
||||
inputDeadlineMs: 100,
|
||||
onWarning: (value) => warnings.push(value),
|
||||
});
|
||||
const { channel, seen } = attachTransform(pipeline, { hold: true });
|
||||
const transformed = pipeline.interceptInput("session-1", "a");
|
||||
for (let attempt = 0; attempt < 10 && seen.length === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(seen.length, 1);
|
||||
const first = Uint8Array.from(Buffer.from("A")).buffer;
|
||||
const duplicate = Uint8Array.from(Buffer.from("A")).buffer;
|
||||
postResult(channel.port2, seen[0].sequence, 1, first);
|
||||
postResult(channel.port2, seen[0].sequence, 1, duplicate);
|
||||
assert.equal(await transformed, "A");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(pipeline.has("session-1", "input"), false);
|
||||
assert.deepEqual(warnings.map((warning) => warning.code), ["protocol"]);
|
||||
});
|
||||
|
||||
test("output interception is credit bounded and fails open under backpressure", async () => {
|
||||
const warnings = [];
|
||||
const pipeline = createTerminalDataPipeline({
|
||||
outputDeadlineMs: 100,
|
||||
outputWindowBytes: 5,
|
||||
onWarning: (value) => warnings.push(value),
|
||||
});
|
||||
attachTransform(pipeline, { direction: "output", hold: true });
|
||||
const order = [];
|
||||
const first = pipeline.interceptOutput("session-1", "1234")
|
||||
.then((value) => { order.push(value); return value; });
|
||||
const second = pipeline.interceptOutput("session-1", "5678")
|
||||
.then((value) => { order.push(value); return value; });
|
||||
assert.deepEqual(await Promise.all([first, second]), ["1234", "5678"]);
|
||||
assert.deepEqual(order, ["1234", "5678"]);
|
||||
assert.equal(warnings[0].code, "backpressure");
|
||||
assert.equal(pipeline.has("session-1", "output"), false);
|
||||
});
|
||||
|
||||
test("output expansion consumes bounded credit before transformed data is delivered", async () => {
|
||||
const warnings = [];
|
||||
const pipeline = createTerminalDataPipeline({
|
||||
outputDeadlineMs: 100,
|
||||
outputWindowBytes: 5,
|
||||
onWarning: (value) => warnings.push(value),
|
||||
});
|
||||
attachTransform(pipeline, {
|
||||
direction: "output",
|
||||
transform: () => "12345",
|
||||
});
|
||||
|
||||
assert.equal(await pipeline.interceptOutput("session-1", "a"), "12345");
|
||||
assert.equal(await pipeline.interceptOutput("session-1", "b"), "b");
|
||||
assert.equal(pipeline.has("session-1", "output"), false);
|
||||
assert.equal(warnings.at(-1).code, "backpressure");
|
||||
});
|
||||
|
||||
test("queued output keeps the deadline from its arrival time", async () => {
|
||||
let now = 0;
|
||||
const warnings = [];
|
||||
const pipeline = createTerminalDataPipeline({
|
||||
now: () => now,
|
||||
outputDeadlineMs: 100,
|
||||
onWarning: (value) => warnings.push(value),
|
||||
});
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.unref?.();
|
||||
channel.port2.unref?.();
|
||||
const chunks = [];
|
||||
listen(channel.port2, (message) => {
|
||||
const envelope = readFrame(message);
|
||||
if (envelope.frame.type === "netcatty:terminal-interceptor:chunk") chunks.push(envelope.frame);
|
||||
});
|
||||
pipeline.attach({
|
||||
sessionId: "session-1",
|
||||
direction: "output",
|
||||
providerId: "com.example.interceptor",
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "utility",
|
||||
securityPrincipal: "principal-1",
|
||||
}, channel.port1);
|
||||
|
||||
const first = pipeline.interceptOutput("session-1", "first");
|
||||
const second = pipeline.interceptOutput("session-1", "second");
|
||||
for (let attempt = 0; attempt < 10 && chunks.length < 1; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(chunks.length, 1);
|
||||
now = 90;
|
||||
const firstData = Uint8Array.from(Buffer.from("FIRST")).buffer;
|
||||
postResult(channel.port2, chunks[0].sequence, 5, firstData);
|
||||
for (let attempt = 0; attempt < 10 && chunks.length < 2; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(chunks.length, 2);
|
||||
now = 101;
|
||||
const secondData = Uint8Array.from(Buffer.from("SECOND")).buffer;
|
||||
postResult(channel.port2, chunks[1].sequence, 6, secondData);
|
||||
|
||||
assert.deepEqual(await Promise.all([first, second]), ["FIRST", "second"]);
|
||||
assert.equal(warnings.at(-1).code, "timeout");
|
||||
assert.equal(pipeline.has("session-1", "output"), false);
|
||||
});
|
||||
|
||||
test("invalid interceptor UTF-8 fails open and permanently trips the circuit breaker", async () => {
|
||||
const warnings = [];
|
||||
const pipeline = createTerminalDataPipeline({ inputDeadlineMs: 100, onWarning: (value) => warnings.push(value) });
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.unref?.();
|
||||
channel.port2.unref?.();
|
||||
listen(channel.port2, (message) => {
|
||||
const envelope = readFrame(message);
|
||||
if (envelope.frame.type !== "netcatty:terminal-interceptor:chunk") return;
|
||||
const data = Uint8Array.from([0xff]).buffer;
|
||||
postResult(channel.port2, envelope.frame.sequence, 4, data);
|
||||
});
|
||||
pipeline.attach({
|
||||
sessionId: "session-1",
|
||||
direction: "input",
|
||||
providerId: "com.example.interceptor",
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "utility",
|
||||
securityPrincipal: "principal-1",
|
||||
}, channel.port1);
|
||||
assert.equal(await pipeline.interceptInput("session-1", "safe"), "safe");
|
||||
assert.equal(warnings[0].code, "encoding");
|
||||
assert.equal(pipeline.has("session-1", "input"), false);
|
||||
});
|
||||
|
||||
test("the worker rejects a result that bypasses the canonical terminal frame schema", async () => {
|
||||
const warnings = [];
|
||||
const pipeline = createTerminalDataPipeline({
|
||||
inputDeadlineMs: 100,
|
||||
onWarning: (value) => warnings.push(value),
|
||||
});
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.unref?.();
|
||||
channel.port2.unref?.();
|
||||
listen(channel.port2, (message) => {
|
||||
const envelope = readFrame(message);
|
||||
if (envelope.frame.type !== "netcatty:terminal-interceptor:chunk") return;
|
||||
const data = Uint8Array.from(Buffer.from("UNSAFE")).buffer;
|
||||
channel.port2.postMessage({
|
||||
frame: {
|
||||
type: "netcatty:terminal-interceptor:result",
|
||||
sequence: envelope.frame.sequence,
|
||||
status: "ok",
|
||||
creditBytes: envelope.frame.byteLength,
|
||||
byteLength: data.byteLength,
|
||||
extra: true,
|
||||
},
|
||||
transfer: data,
|
||||
}, [data]);
|
||||
});
|
||||
pipeline.attach({
|
||||
sessionId: "session-1",
|
||||
direction: "input",
|
||||
providerId: "com.example.interceptor",
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "utility",
|
||||
securityPrincipal: "principal-1",
|
||||
}, channel.port1);
|
||||
|
||||
assert.equal(await pipeline.interceptInput("session-1", "safe"), "safe");
|
||||
assert.equal(pipeline.has("session-1", "input"), false);
|
||||
assert.equal(warnings.at(-1).code, "protocol");
|
||||
});
|
||||
97
electron/terminalWorker/workerProcessGuards.cjs
Normal file
97
electron/terminalWorker/workerProcessGuards.cjs
Normal file
@@ -0,0 +1,97 @@
|
||||
"use strict";
|
||||
|
||||
const {
|
||||
classifyProcessError,
|
||||
} = require("../bridges/processErrorGuards.cjs");
|
||||
|
||||
/**
|
||||
* Terminal worker process error guards.
|
||||
*
|
||||
* Every terminal, SSH, SFTP, and port-forwarding session shares this
|
||||
* utilityProcess, so a single stray async error must never let Node's default
|
||||
* `uncaughtException` behavior exit the worker with code 1 — that would
|
||||
* disconnect every session at once. This mirrors the main-process guards
|
||||
* (`bridges/processErrorGuards.cjs`), but worker policy is stricter: once the
|
||||
* worker is running, every process-level error is suppressed. The error is
|
||||
* still reported (see `report`) so the main process can record it in the
|
||||
* crash log for later diagnosis.
|
||||
*
|
||||
* Startup errors are NOT suppressed: until `options.isRuntimeStarted()`
|
||||
* returns true, every error is fatal and re-thrown so the worker exits.
|
||||
* An IPC-retained utilityProcess that swallowed a startup failure
|
||||
* would otherwise stay alive without a message listener, leaving every
|
||||
* manager request pending forever instead of rejecting/replacing it.
|
||||
*/
|
||||
function installTerminalWorkerErrorGuards(options = {}) {
|
||||
const processObject = options.processObject || process;
|
||||
if (!processObject?.on || !processObject?.removeListener) {
|
||||
throw new Error("A process-like EventEmitter is required");
|
||||
}
|
||||
const report = typeof options.report === "function" ? options.report : () => {};
|
||||
const logError = typeof options.logError === "function"
|
||||
? options.logError
|
||||
: (...args) => console.error(...args);
|
||||
// Default to runtime semantics only when a caller provides no startup
|
||||
// signal; process.cjs always passes one.
|
||||
const isRuntimeStarted = typeof options.isRuntimeStarted === "function"
|
||||
? options.isRuntimeStarted
|
||||
: () => true;
|
||||
|
||||
const labelFor = (origin) => (
|
||||
origin === "unhandledRejection" ? "unhandled rejection" : "uncaught exception"
|
||||
);
|
||||
|
||||
const makeHandler = (origin) => (err) => {
|
||||
// An error already marked fatal (e.g. a startup unhandled rejection that
|
||||
// threw into the uncaughtException path) must exit, not be re-classified.
|
||||
if (err?.__terminalWorkerFatalStartupError) {
|
||||
throw err;
|
||||
}
|
||||
// The shared classifier ignores some stream/network errors regardless of
|
||||
// startup state. Those are recoverable only after this worker can receive
|
||||
// requests; swallowing them during initialization leaves a live dead end.
|
||||
const decision = isRuntimeStarted()
|
||||
? classifyProcessError(err, { runtimeStarted: true, origin })
|
||||
: { action: "fatal", reason: "startup error before worker became usable" };
|
||||
if (decision.action === "fatal") {
|
||||
logError(
|
||||
`Terminal worker ${labelFor(origin)} (${decision.reason}); exiting:`,
|
||||
err,
|
||||
);
|
||||
try {
|
||||
report(origin, err, decision);
|
||||
} catch {
|
||||
// Error reporting must never be able to escalate into a worker crash.
|
||||
}
|
||||
// Re-throw so Node's default behavior terminates the worker; the
|
||||
// manager observes the exit and can reject or replace the worker.
|
||||
const fatal = err instanceof Error ? err : new Error(String(err));
|
||||
fatal.__terminalWorkerFatalStartupError = true;
|
||||
throw fatal;
|
||||
}
|
||||
logError(
|
||||
`Suppressed terminal worker ${labelFor(origin)} (${decision.reason}):`,
|
||||
err,
|
||||
);
|
||||
try {
|
||||
report(origin, err, decision);
|
||||
} catch {
|
||||
// Error reporting must never be able to escalate into a worker crash.
|
||||
}
|
||||
};
|
||||
|
||||
const handleUncaughtException = makeHandler("uncaughtException");
|
||||
const handleUnhandledRejection = makeHandler("unhandledRejection");
|
||||
|
||||
processObject.on("uncaughtException", handleUncaughtException);
|
||||
processObject.on("unhandledRejection", handleUnhandledRejection);
|
||||
|
||||
return () => {
|
||||
processObject.removeListener("uncaughtException", handleUncaughtException);
|
||||
processObject.removeListener("unhandledRejection", handleUnhandledRejection);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
installTerminalWorkerErrorGuards,
|
||||
};
|
||||
185
electron/terminalWorker/workerProcessGuards.test.cjs
Normal file
185
electron/terminalWorker/workerProcessGuards.test.cjs
Normal file
@@ -0,0 +1,185 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const test = require("node:test");
|
||||
|
||||
const { installTerminalWorkerErrorGuards } = require("./workerProcessGuards.cjs");
|
||||
|
||||
function createFakeProcess() {
|
||||
const listeners = new Map();
|
||||
return {
|
||||
on(name, callback) {
|
||||
listeners.set(name, callback);
|
||||
},
|
||||
removeListener(name, callback) {
|
||||
if (listeners.get(name) === callback) listeners.delete(name);
|
||||
},
|
||||
emit(name, err) {
|
||||
const callback = listeners.get(name);
|
||||
if (!callback) throw new Error(`no listener for ${name}`);
|
||||
callback(err);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("worker guards suppress uncaught exceptions and report them", () => {
|
||||
const fakeProcess = createFakeProcess();
|
||||
const reports = [];
|
||||
const logs = [];
|
||||
installTerminalWorkerErrorGuards({
|
||||
processObject: fakeProcess,
|
||||
report: (origin, err, decision) => reports.push({ origin, err, reason: decision.reason }),
|
||||
logError: (...args) => logs.push(args),
|
||||
});
|
||||
|
||||
const err = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" });
|
||||
fakeProcess.emit("uncaughtException", err);
|
||||
|
||||
assert.equal(reports.length, 1);
|
||||
assert.equal(reports[0].origin, "uncaughtException");
|
||||
assert.equal(reports[0].err, err);
|
||||
assert.equal(reports[0].reason, "non-fatal network error");
|
||||
assert.equal(logs.length, 1);
|
||||
});
|
||||
|
||||
test("worker guards suppress unhandled rejections with non-Error reasons", () => {
|
||||
const fakeProcess = createFakeProcess();
|
||||
const reports = [];
|
||||
installTerminalWorkerErrorGuards({
|
||||
processObject: fakeProcess,
|
||||
report: (origin, err) => reports.push({ origin, err }),
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => fakeProcess.emit("unhandledRejection", "plain string reason"));
|
||||
assert.equal(reports.length, 1);
|
||||
assert.equal(reports[0].origin, "unhandledRejection");
|
||||
assert.equal(reports[0].err, "plain string reason");
|
||||
});
|
||||
|
||||
test("worker guards absorb report failures instead of rethrowing", () => {
|
||||
const fakeProcess = createFakeProcess();
|
||||
installTerminalWorkerErrorGuards({
|
||||
processObject: fakeProcess,
|
||||
report: () => {
|
||||
throw new Error("report route is dead");
|
||||
},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => fakeProcess.emit("uncaughtException", new Error("boom")));
|
||||
});
|
||||
|
||||
test("uninstall removes the installed handlers", () => {
|
||||
const fakeProcess = createFakeProcess();
|
||||
const uninstall = installTerminalWorkerErrorGuards({
|
||||
processObject: fakeProcess,
|
||||
logError: () => {},
|
||||
});
|
||||
assert.doesNotThrow(() => fakeProcess.emit("uncaughtException", new Error("before")));
|
||||
uninstall();
|
||||
assert.throws(
|
||||
() => fakeProcess.emit("uncaughtException", new Error("after")),
|
||||
/no listener for uncaughtException/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("guards require a process-like EventEmitter", () => {
|
||||
assert.throws(
|
||||
() => installTerminalWorkerErrorGuards({ processObject: {} }),
|
||||
/process-like EventEmitter/u,
|
||||
);
|
||||
});
|
||||
|
||||
const startupErrors = [
|
||||
{ label: "generic", properties: {} },
|
||||
{ label: "network", properties: { code: "ECONNRESET" } },
|
||||
{ label: "permissions", properties: { code: "EPERM" } },
|
||||
{ label: "broken pipe", properties: { code: "EPIPE" } },
|
||||
{ label: "destroyed stream", properties: { code: "ERR_STREAM_DESTROYED" } },
|
||||
{ label: "SSH", properties: { level: "client-timeout" } },
|
||||
];
|
||||
|
||||
test("every startup error is fatal, including normally recoverable errors", () => {
|
||||
for (const origin of ["uncaughtException", "unhandledRejection"]) {
|
||||
for (const { label, properties } of startupErrors) {
|
||||
const fakeProcess = createFakeProcess();
|
||||
const reports = [];
|
||||
installTerminalWorkerErrorGuards({
|
||||
processObject: fakeProcess,
|
||||
isRuntimeStarted: () => false,
|
||||
logError() {},
|
||||
report: (_origin, err, decision) => reports.push({ err, decision }),
|
||||
});
|
||||
const err = Object.assign(new Error(`startup ${label}`), properties);
|
||||
assert.throws(() => fakeProcess.emit(origin, err), err, `${origin}: ${label}`);
|
||||
assert.equal(reports.length, 1);
|
||||
assert.equal(reports[0].decision.action, "fatal");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("protection becomes active only after successful startup", () => {
|
||||
const fakeProcess = createFakeProcess();
|
||||
const reports = [];
|
||||
let started = false;
|
||||
installTerminalWorkerErrorGuards({
|
||||
processObject: fakeProcess,
|
||||
isRuntimeStarted: () => started,
|
||||
logError() {},
|
||||
report: (_origin, _err, decision) => reports.push(decision.action),
|
||||
});
|
||||
assert.throws(() => fakeProcess.emit("uncaughtException", new Error("startup")));
|
||||
started = true;
|
||||
for (const { properties } of startupErrors) {
|
||||
assert.doesNotThrow(() => fakeProcess.emit(
|
||||
"uncaughtException",
|
||||
Object.assign(new Error("runtime"), properties),
|
||||
));
|
||||
}
|
||||
assert.equal(reports[0], "fatal");
|
||||
assert.ok(reports.slice(1).every((action) => action !== "fatal"));
|
||||
});
|
||||
|
||||
test("a failed bridge load exits the worker instead of leaving requests hanging", () => {
|
||||
for (const { label, properties } of startupErrors) {
|
||||
const child = spawnSync(process.execPath, ["-e", `
|
||||
const { EventEmitter } = require("node:events");
|
||||
const Module = require("node:module");
|
||||
const worker = require(${JSON.stringify(require.resolve("./process.cjs"))});
|
||||
process.parentPort = new EventEmitter();
|
||||
process.parentPort.postMessage = (message) => {
|
||||
if (message.kind === "worker-error") {
|
||||
process.stdout.write(JSON.stringify(message) + "\\n");
|
||||
}
|
||||
};
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function(request, ...args) {
|
||||
if (request === "./terminalDataPipeline.cjs") {
|
||||
throw Object.assign(new Error("injected bridge startup failure"), ${JSON.stringify(properties)});
|
||||
}
|
||||
return originalLoad.call(this, request, ...args);
|
||||
};
|
||||
// Retain the event loop, as the Electron parent port does in production.
|
||||
setTimeout(() => process.exit(0), 200);
|
||||
setImmediate(() => worker.main());
|
||||
`], { encoding: "utf8", timeout: 5_000 });
|
||||
assert.ifError(child.error);
|
||||
assert.notEqual(child.status, 0, `${label}: an unusable worker must not survive startup`);
|
||||
const report = JSON.parse(child.stdout.trim());
|
||||
assert.equal(report.kind, "worker-error");
|
||||
assert.match(report.message, /injected bridge startup failure/);
|
||||
assert.match(report.reason, /startup/);
|
||||
}
|
||||
});
|
||||
|
||||
test("startup rejection terminates a real process even for benign stream errors", () => {
|
||||
const child = spawnSync(process.execPath, ["-e", `
|
||||
const { installTerminalWorkerErrorGuards } = require(${JSON.stringify(require.resolve("./workerProcessGuards.cjs"))});
|
||||
installTerminalWorkerErrorGuards({ isRuntimeStarted: () => false, logError() {} });
|
||||
setTimeout(() => process.exit(0), 200);
|
||||
Promise.reject(Object.assign(new Error("injected startup rejection"), { code: "EPIPE" }));
|
||||
`], { encoding: "utf8", timeout: 5_000 });
|
||||
assert.ifError(child.error);
|
||||
assert.notEqual(child.status, 0);
|
||||
assert.match(child.stderr, /injected startup rejection/);
|
||||
});
|
||||
Reference in New Issue
Block a user