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