Files
NetMesh/electron/terminalWorker/terminalDataPipeline.cjs
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

417 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
};