UNPKG

@nataliapc/mcp-openmsx

Version:

Model context protocol server for openMSX automation and control

317 lines (316 loc) 14.1 kB
/** * Windows control-channel connector for openMSX. * * On Windows, openMSX is a /SUBSYSTEM:WINDOWS GUI app whose stdin/stdout cannot * be piped, and its TCP control socket requires SSPI (Negotiate/NTLM) auth since * openMSX 0.7.1. This module owns ALL Windows-specific transport details (mode * selection, socket-file polling, TCP connect, SSPI handshake, proxy launch) so * that `openmsx.ts` keeps only platform-agnostic orchestration: it consumes the * returned {@link WindowsControlConnection} and attaches the same generic XML * stream handlers it uses on Linux/macOS. * * Modes (env var `OPENMSX_WINDOWS_CONTROL`): * - `stdio-proxy` (default): launch the bundled .NET helper * `bin/win-x64/mcp-openmsx-sspi-proxy.exe <port>` which does SSPI and exposes * a clean XML stdio channel, exactly like `openmsx -control stdio` on Linux. * - `direct-sspi`: Node opens the TCP socket and authenticates with * `node-expose-sspi`. Fallback path. * - `socket`: alias of `direct-sspi`. * - `pipe`: reserved/not implemented. * * @author Natalia Pujol Cremades (@nataliapc) * @license GPL2 */ import fsSync from 'fs'; import net from 'net'; import os from 'os'; import path from 'path'; import { spawn } from 'child_process'; import { fileURLToPath } from 'url'; import { sleep } from './utils.js'; /** Directory of this module (works under both `src/` (tsx) and `dist/`). */ const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); /** Resolve the Windows temp directory openMSX writes its socket file into. */ function windowsTempDir(env) { return env.TEMP ?? env.TMP ?? path.join(os.homedir(), 'AppData', 'Local', 'Temp'); } export class OpenMsxWindowsConnector { options; constructor(options) { this.options = options; } get env() { return this.options.env ?? process.env; } /** * Resolve the requested Windows control mode from `OPENMSX_WINDOWS_CONTROL`. * Defaults to `stdio-proxy`. `socket` is a legacy alias of `direct-sspi`. * Throws on an unrecognised value. */ static getControlMode(env = process.env) { const raw = env.OPENMSX_WINDOWS_CONTROL?.trim().toLowerCase() || 'stdio-proxy'; if (raw === 'socket') return 'direct-sspi'; if (raw === 'stdio-proxy' || raw === 'direct-sspi' || raw === 'pipe') return raw; throw new Error(`Invalid OPENMSX_WINDOWS_CONTROL="${raw}". ` + `Supported values: stdio-proxy, direct-sspi, socket, pipe.`); } /** * Resolve the path to the SSPI proxy executable. * Honours `OPENMSX_WINDOWS_PROXY_EXECUTABLE` for development overrides, * otherwise points at the bundled `bin/win-x64/mcp-openmsx-sspi-proxy.exe` * relative to this module (i.e. `../bin/win-x64/...` from `dist/`). */ static resolveProxyExecutable(env = process.env) { const override = env.OPENMSX_WINDOWS_PROXY_EXECUTABLE?.trim(); if (override) return override; return path.resolve(MODULE_DIR, '..', 'bin', 'win-x64', 'mcp-openmsx-sspi-proxy.exe'); } /** * Poll for openMSX's socket file and return the TCP control port it contains. * The file appears at `%TEMP%\openmsx-default\socket.<pid>` and holds a port * number (9938-9958). */ async waitForWindowsSocketPort(socketFile, maxWaitMs = 8000, pollMs = 200) { let elapsed = 0; // eslint-disable-next-line no-constant-condition while (true) { if (fsSync.existsSync(socketFile)) { let raw; try { raw = fsSync.readFileSync(socketFile, 'utf8').trim(); } catch (e) { throw new Error(`Cannot read openMSX socket file: ${e instanceof Error ? e.message : e}`); } const port = parseInt(raw, 10); if (!port || isNaN(port)) { throw new Error(`Invalid port in openMSX socket file: "${raw}"`); } return port; } elapsed += pollMs; if (elapsed >= maxWaitMs) { throw new Error(`openMSX socket file not found after ${maxWaitMs}ms: ${socketFile}`); } await sleep(pollMs); } } /** * Establish the Windows control channel for the configured mode and return a * uniform connection. SSPI is fully completed before returning: for * `direct-sspi` here in Node; for `stdio-proxy` inside the .NET helper. The * caller only has to attach generic XML stream handlers and send * `<openmsx-control>`. */ async connect() { const mode = OpenMsxWindowsConnector.getControlMode(this.env); if (mode === 'pipe') { throw new Error('OPENMSX_WINDOWS_CONTROL=pipe is reserved but not implemented yet'); } const pid = this.options.openmsxProcess.pid; if (!pid) throw new Error('openMSX process has no pid'); const socketFile = path.join(windowsTempDir(this.env), 'openmsx-default', `socket.${pid}`); this.options.diag(`waiting for openMSX socket file: ${socketFile}`); const port = await this.waitForWindowsSocketPort(socketFile); this.options.diag(`openMSX control port: ${port}`); return mode === 'stdio-proxy' ? this.connectStdioProxy(port) : this.connectDirectSspi(port); } /** * `stdio-proxy`: launch the bundled .NET helper (it connects to openMSX, does * SSPI and pipes raw XML over its stdio). SSPI never touches Node. */ connectStdioProxy(port) { const proxyExe = OpenMsxWindowsConnector.resolveProxyExecutable(this.env); if (!fsSync.existsSync(proxyExe)) { throw new Error(`SSPI proxy executable not found: ${proxyExe}. ` + `Build it with: pnpm build:proxy:win-x64:docker ` + `(or set OPENMSX_WINDOWS_PROXY_EXECUTABLE to a built proxy).`); } this.options.diag(`launching SSPI proxy: "${proxyExe}" ${port}`); const proxy = spawn(proxyExe, [String(port)], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, }); if (!proxy.stdin || !proxy.stdout || !proxy.stderr) { throw new Error('Failed to create SSPI proxy stdio pipes'); } return { mode: 'stdio-proxy', controlProcess: proxy, input: proxy.stdin, output: proxy.stdout, errorOutput: proxy.stderr, close: () => { try { proxy.stdin?.end(); } catch { /* ignore */ } }, forceClose: () => { try { if (!proxy.killed) proxy.kill('SIGKILL'); } catch { /* ignore */ } }, }; } /** * `direct-sspi`: open the TCP socket and authenticate from Node with * `node-expose-sspi`. The returned socket is a Duplex used for both XML * directions. */ async connectDirectSspi(port) { const socket = await this.tcpConnect(port); this.options.diag('starting SSPI authentication...'); await this.performSspiAuth(socket); this.options.diag('SSPI authentication successful'); return { mode: 'direct-sspi', controlProcess: null, tcpSocket: socket, input: socket, output: socket, close: () => { try { socket.end(); } catch { /* ignore */ } }, forceClose: () => { try { socket.destroy(); } catch { /* ignore */ } }, }; } /** Open a TCP connection to openMSX's loopback control port. */ tcpConnect(port) { return new Promise((resolve, reject) => { const socket = net.createConnection(port, '127.0.0.1'); socket.once('connect', () => resolve(socket)); socket.once('error', err => { socket.destroy(); reject(new Error(`TCP connect to ${port} failed: ${err.message}`)); }); }); } /** * SSPI (Negotiate/NTLM) authentication handshake — Windows only. * Required since openMSX 0.7.1 for TCP socket connections. * Uses the optional `node-expose-sspi` v0.1.x package. * * Reference C++ implementation: openMSX debugger SspiNegotiateClient.cpp * Protocol: loop until SEC_E_OK — each round: * client → [4-byte BE length][SSPI token] * server → [4-byte BE length][SSPI response] (if SEC_I_CONTINUE_NEEDED) * After SEC_E_OK: no server read — the XML protocol begins. * * The socket is paused before returning so any data openMSX sends after auth * buffers until the caller attaches its own `data` handler (no data is lost). */ async performSspiAuth(socket) { let nes; try { const { createRequire } = await import('module'); const req = createRequire(import.meta.url); nes = req('node-expose-sspi'); } catch (e) { throw new Error(`node-expose-sspi not available (${e instanceof Error ? e.message : e}). ` + `Install with: npm install node-expose-sspi`); } // Accumulate TCP data for length-prefixed reads during SSPI phase. // Pattern: onSspiData appends to buffer and NOTIFIES (does not clear). // readLengthPrefixed checks the buffer size after each notification. let sspiBuffer = Buffer.alloc(0); let sspiNotify = null; const onSspiData = (chunk) => { sspiBuffer = Buffer.concat([sspiBuffer, chunk]); if (sspiNotify) { const notify = sspiNotify; sspiNotify = null; notify(); // just signal — buffer stays intact } }; socket.on('data', onSspiData); // Wait until new data arrives (doesn't clear the buffer) const waitMore = () => new Promise(resolve => { sspiNotify = resolve; }); const readLengthPrefixed = async () => { // Wait until we have at least the 4-byte length prefix while (sspiBuffer.length < 4) await waitMore(); const len = sspiBuffer.readUInt32BE(0); const total = 4 + len; // Wait until the full payload has arrived while (sspiBuffer.length < total) await waitMore(); const result = sspiBuffer.subarray(4, total); sspiBuffer = sspiBuffer.subarray(total); // consume only what we read return result; }; const sendToken = (token) => { const buf = Buffer.from(token); const lenBuf = Buffer.alloc(4); lenBuf.writeUInt32BE(buf.length, 0); socket.write(lenBuf); socket.write(buf); }; try { // node-expose-sspi v0.1.x API: // AcquireCredentialsHandle → CredentialWithExpiry { credential, tsExpiry } // InitializeSecurityContextInput.credential = CredHandle (the .credential property) // InitializeSecurityContextInput.SecBufferDesc = server's token (NOT serverSecurityContext) // InitializeSecurityContextInput.contextReq = string[] of ISC_REQ_* flags // Flags from openMSX C++ ref: ISC_REQ_ALLOCATE_MEMORY | ISC_REQ_CONNECTION | ISC_REQ_STREAM const credWithExpiry = nes.sspi.AcquireCredentialsHandle({ packageName: 'Negotiate', credentialUse: 'SECPKG_CRED_OUTBOUND', }); const credential = credWithExpiry.credential; const packageInfo = nes.sspi.QuerySecurityPackageInfo('Negotiate'); const ISC_FLAGS = ['ISC_REQ_ALLOCATE_MEMORY', 'ISC_REQ_CONNECTION', 'ISC_REQ_STREAM']; let contextHandle = undefined; let serverSecBufDesc = undefined; // Loop until SEC_E_OK (mirrors C++ reference implementation) while (true) { const ctxInput = { credential, targetName: '', cbMaxToken: packageInfo.cbMaxToken, contextReq: ISC_FLAGS, targetDataRep: 'SECURITY_NETWORK_DREP', }; if (contextHandle !== undefined) ctxInput.contextHandle = contextHandle; if (serverSecBufDesc !== undefined) ctxInput.SecBufferDesc = serverSecBufDesc; const clientCtx = nes.sspi.InitializeSecurityContext(ctxInput); contextHandle = clientCtx.contextHandle; // Send our token to the server (if non-empty) const tokenBuf = clientCtx.SecBufferDesc?.buffers?.[0]; if (tokenBuf && tokenBuf.byteLength > 0) { sendToken(tokenBuf); } if (clientCtx.SECURITY_STATUS === 'SEC_E_OK') { // Auth complete — no final read from server, proceed to XML break; } if (clientCtx.SECURITY_STATUS !== 'SEC_I_CONTINUE_NEEDED') { throw new Error(`SSPI error: ${clientCtx.SECURITY_STATUS}`); } // Read server's response token const response = await readLengthPrefixed(); const responseAB = response.buffer.slice(response.byteOffset, response.byteOffset + response.byteLength); serverSecBufDesc = { ulVersion: 0, buffers: [responseAB] }; } } finally { // Remove the SSPI data listener and pause the socket so any post-auth // bytes buffer until the caller attaches its own 'data' handler. socket.removeListener('data', onSspiData); socket.pause(); } } }