[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

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

View File

@@ -0,0 +1,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,
};

View 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",
);
});

View 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);
});

View 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,
};

View 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,
);
});

View 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,
};

File diff suppressed because it is too large Load Diff

View 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,
};

View 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");
});

View 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,
};

View 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/);
});