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
320 lines
9.6 KiB
JavaScript
320 lines
9.6 KiB
JavaScript
const net = require("node:net");
|
|
const fs = require("node:fs");
|
|
const { Transform } = require("node:stream");
|
|
const { execFileSync } = require("node:child_process");
|
|
|
|
const X11_PORT_BASE = 6000;
|
|
const MIT_MAGIC_COOKIE_PROTOCOL = "MIT-MAGIC-COOKIE-1";
|
|
|
|
function resolveX11DisplaySpec(spec, options = {}) {
|
|
const platform = options.platform || process.platform;
|
|
let raw = String(spec || options.envDisplay || process.env.DISPLAY || (platform === "win32" ? "localhost:0" : ":0")).trim();
|
|
|
|
if (!raw) {
|
|
return resolveX11DisplaySpec(undefined, { ...options, envDisplay: platform === "win32" ? "localhost:0" : ":0" });
|
|
}
|
|
if (raw === ":") {
|
|
raw = ":0";
|
|
}
|
|
|
|
if (raw.startsWith("/")) {
|
|
return { path: raw };
|
|
}
|
|
|
|
const match = raw.match(/^(.*):(\d+)(?:\.(\d+))?$/);
|
|
if (!match) {
|
|
return platform === "win32"
|
|
? { host: raw, port: X11_PORT_BASE }
|
|
: { path: raw };
|
|
}
|
|
|
|
const host = match[1] || "";
|
|
const display = Number.parseInt(match[2], 10);
|
|
const port = display >= 100 ? display : X11_PORT_BASE + display;
|
|
|
|
if (host.toLowerCase() === "unix" && platform !== "win32") {
|
|
return { path: `/tmp/.X11-unix/X${display}` };
|
|
}
|
|
|
|
if (!host) {
|
|
if (platform === "win32") {
|
|
return { host: "localhost", port };
|
|
}
|
|
return { path: `/tmp/.X11-unix/X${display}` };
|
|
}
|
|
|
|
if (host.startsWith("/")) {
|
|
return { path: host };
|
|
}
|
|
|
|
return { host, port };
|
|
}
|
|
|
|
function formatDisplayTarget(target) {
|
|
if (target.path) return target.path;
|
|
return `${target.host}:${target.port}`;
|
|
}
|
|
|
|
function platformHint(platform) {
|
|
if (platform === "win32") {
|
|
return "Install and start VcXsrv or Xming, then try again.";
|
|
}
|
|
if (platform === "darwin") {
|
|
return "Install and start XQuartz, then try again.";
|
|
}
|
|
return "Check DISPLAY and make sure Xorg, Xwayland, or your X server is running.";
|
|
}
|
|
|
|
function connectSocket(socket, target) {
|
|
if (target.path) {
|
|
return socket.connect(target.path);
|
|
}
|
|
return socket.connect(target.port, target.host);
|
|
}
|
|
|
|
function destroyStream(stream) {
|
|
try {
|
|
stream.destroy();
|
|
} catch {
|
|
// best effort cleanup
|
|
}
|
|
}
|
|
|
|
function pad4(n) {
|
|
return (n + 3) & ~3;
|
|
}
|
|
|
|
function readUInt16(buf, offset, littleEndian) {
|
|
return littleEndian ? buf.readUInt16LE(offset) : buf.readUInt16BE(offset);
|
|
}
|
|
|
|
function normalizeCookieBuffer(cookie) {
|
|
if (!cookie) return null;
|
|
if (Buffer.isBuffer(cookie)) return cookie;
|
|
const value = String(cookie).trim();
|
|
if (/^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0) {
|
|
return Buffer.from(value, "hex");
|
|
}
|
|
return Buffer.from(value, "binary");
|
|
}
|
|
|
|
function rewriteX11AuthSetupPacket(buffer, options = {}) {
|
|
const fakeCookie = normalizeCookieBuffer(options.fakeCookie);
|
|
const realCookie = normalizeCookieBuffer(options.realCookie);
|
|
if (!realCookie || buffer.length < 12) {
|
|
return { buffer, complete: buffer.length >= 12, rewritten: false };
|
|
}
|
|
|
|
const byteOrder = buffer[0];
|
|
const littleEndian = byteOrder === 0x6c; // 'l'
|
|
if (!littleEndian && byteOrder !== 0x42) { // 'B'
|
|
return { buffer, complete: true, rewritten: false };
|
|
}
|
|
|
|
const protocolLength = readUInt16(buffer, 6, littleEndian);
|
|
const dataLength = readUInt16(buffer, 8, littleEndian);
|
|
const protocolStart = 12;
|
|
const dataStart = protocolStart + pad4(protocolLength);
|
|
const totalLength = dataStart + pad4(dataLength);
|
|
|
|
if (buffer.length < totalLength) {
|
|
return { buffer, complete: false, rewritten: false };
|
|
}
|
|
|
|
const protocol = buffer.subarray(protocolStart, protocolStart + protocolLength).toString("ascii");
|
|
if (protocol !== MIT_MAGIC_COOKIE_PROTOCOL || dataLength !== realCookie.length) {
|
|
return { buffer, complete: true, rewritten: false };
|
|
}
|
|
|
|
const dataEnd = dataStart + dataLength;
|
|
const currentCookie = buffer.subarray(dataStart, dataEnd);
|
|
if (fakeCookie && currentCookie.length === fakeCookie.length && !currentCookie.equals(fakeCookie)) {
|
|
return { buffer, complete: true, rewritten: false };
|
|
}
|
|
|
|
const next = Buffer.from(buffer);
|
|
realCookie.copy(next, dataStart);
|
|
return { buffer: next, complete: true, rewritten: true };
|
|
}
|
|
|
|
function createX11AuthTransform(options = {}) {
|
|
let pending = Buffer.alloc(0);
|
|
let done = false;
|
|
|
|
return new Transform({
|
|
transform(chunk, _encoding, callback) {
|
|
if (done) {
|
|
callback(null, chunk);
|
|
return;
|
|
}
|
|
|
|
pending = Buffer.concat([pending, Buffer.from(chunk)]);
|
|
const result = rewriteX11AuthSetupPacket(pending, options);
|
|
if (!result.complete) {
|
|
callback();
|
|
return;
|
|
}
|
|
|
|
done = true;
|
|
callback(null, result.buffer);
|
|
},
|
|
flush(callback) {
|
|
if (!done && pending.length > 0) {
|
|
callback(null, pending);
|
|
return;
|
|
}
|
|
callback();
|
|
},
|
|
});
|
|
}
|
|
|
|
function resolveXauthCommand(platform) {
|
|
if (platform === "darwin" && fs.existsSync("/opt/X11/bin/xauth")) {
|
|
return "/opt/X11/bin/xauth";
|
|
}
|
|
return "xauth";
|
|
}
|
|
|
|
function getDisplayNumber(display) {
|
|
const value = String(display || process.env.DISPLAY || ":0").trim() || ":0";
|
|
const normalized = value === ":" ? ":0" : value;
|
|
const match = normalized.match(/:(\d+)(?:\.\d+)?$/) || normalized.match(/\/X(\d+)$/);
|
|
if (!match) return null;
|
|
|
|
const displayNumber = Number.parseInt(match[1], 10);
|
|
if (!Number.isFinite(displayNumber)) return null;
|
|
return displayNumber >= X11_PORT_BASE ? displayNumber - X11_PORT_BASE : displayNumber;
|
|
}
|
|
|
|
function parseXauthCookie(output, display) {
|
|
const requestedDisplay = getDisplayNumber(display);
|
|
const cookiePattern = new RegExp(`\\b${MIT_MAGIC_COOKIE_PROTOCOL}\\b\\s+([0-9a-fA-F]+)`);
|
|
|
|
for (const entry of String(output || "").split(/\r?\n/)) {
|
|
const match = entry.match(cookiePattern);
|
|
if (!match) continue;
|
|
|
|
const target = entry.trim().split(/\s+/, 1)[0];
|
|
if (requestedDisplay !== null && getDisplayNumber(target) !== requestedDisplay) {
|
|
continue;
|
|
}
|
|
|
|
return Buffer.from(match[1], "hex");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function readLocalX11AuthCookie(options = {}) {
|
|
const platform = options.platform || process.platform;
|
|
const command = options.xauthCommand || resolveXauthCommand(platform);
|
|
const display = String(options.display || process.env.DISPLAY || ":0").trim() || ":0";
|
|
try {
|
|
const normalizedDisplay = display === ":" ? ":0" : display;
|
|
const output = typeof options.readXauthOutput === "function"
|
|
? options.readXauthOutput({ command, display: normalizedDisplay })
|
|
: execFileSync(command, ["list"], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
DISPLAY: normalizedDisplay,
|
|
},
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
timeout: 2000,
|
|
});
|
|
return parseXauthCookie(output, normalizedDisplay);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function attachX11Forwarding(conn, options = {}) {
|
|
const createSocket = options.createSocket || (() => new net.Socket());
|
|
const sendMessage = typeof options.sendMessage === "function" ? options.sendMessage : () => {};
|
|
const logger = options.logger || console;
|
|
const platform = options.platform || process.platform;
|
|
const display = options.display;
|
|
const fakeCookie = options.fakeCookie;
|
|
const fixedLocalAuthCookie = normalizeCookieBuffer(options.localAuthCookie);
|
|
let localAuthCookie = fixedLocalAuthCookie;
|
|
let localAuthCookieResolved = Boolean(fixedLocalAuthCookie);
|
|
|
|
const resolveLocalAuthCookie = () => {
|
|
if (localAuthCookieResolved) return localAuthCookie;
|
|
localAuthCookieResolved = true;
|
|
const cookie = typeof options.readLocalAuthCookie === "function"
|
|
? options.readLocalAuthCookie({ display, platform })
|
|
: readLocalX11AuthCookie({ display, platform });
|
|
localAuthCookie = normalizeCookieBuffer(cookie);
|
|
return localAuthCookie;
|
|
};
|
|
|
|
const onX11 = (info, accept, reject) => {
|
|
const target = resolveX11DisplaySpec(display, { platform });
|
|
const localSocket = createSocket();
|
|
let acceptedChannel = null;
|
|
let settled = false;
|
|
|
|
const cleanup = () => {
|
|
if (acceptedChannel) destroyStream(acceptedChannel);
|
|
destroyStream(localSocket);
|
|
};
|
|
|
|
localSocket.once("connect", () => {
|
|
if (settled) return;
|
|
try {
|
|
acceptedChannel = accept();
|
|
settled = true;
|
|
} catch (err) {
|
|
logger.warn?.("[X11] Failed to accept forwarded channel", err);
|
|
cleanup();
|
|
return;
|
|
}
|
|
|
|
acceptedChannel.on("error", () => cleanup());
|
|
localSocket.on("error", () => cleanup());
|
|
acceptedChannel.on("close", () => destroyStream(localSocket));
|
|
localSocket.on("close", () => destroyStream(acceptedChannel));
|
|
const realCookie = resolveLocalAuthCookie();
|
|
if (realCookie && fakeCookie) {
|
|
acceptedChannel
|
|
.pipe(createX11AuthTransform({ fakeCookie, realCookie }))
|
|
.pipe(localSocket)
|
|
.pipe(acceptedChannel);
|
|
} else {
|
|
acceptedChannel.pipe(localSocket).pipe(acceptedChannel);
|
|
}
|
|
});
|
|
|
|
localSocket.once("error", (err) => {
|
|
if (!settled) {
|
|
settled = true;
|
|
try { reject(); } catch { /* ignore reject errors */ }
|
|
sendMessage(`\r\n[X11] Could not connect to the local X11 server: ${err?.message || err}\r\n`);
|
|
sendMessage(`[X11] Display target: ${formatDisplayTarget(target)}\r\n`);
|
|
sendMessage(`[X11] ${platformHint(platform)}\r\n`);
|
|
}
|
|
destroyStream(localSocket);
|
|
});
|
|
|
|
try {
|
|
connectSocket(localSocket, target);
|
|
} catch (err) {
|
|
localSocket.emit("error", err);
|
|
}
|
|
};
|
|
|
|
conn.on("x11", onX11);
|
|
return () => {
|
|
if (typeof conn.off === "function") conn.off("x11", onX11);
|
|
else conn.removeListener("x11", onX11);
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
attachX11Forwarding,
|
|
readLocalX11AuthCookie,
|
|
rewriteX11AuthSetupPacket,
|
|
resolveX11DisplaySpec,
|
|
};
|