From abd44d3e0b6e4dca08be8d50f3154a81bfae5c1b Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 28 May 2026 15:23:34 -0700 Subject: [PATCH 1/6] Add cross-platform getOpenPortsForPid() to pty-core Discovers TCP listening ports opened by a shell process and all of its descendant subprocesses, mapped back to the owning PID. Three native backends, no new dependencies: - Linux: scan /proc//stat for the process tree, map /proc//fd socket inodes to LISTEN rows in /proc/net/tcp{,6}. - macOS: ps for the tree, lsof -iTCP -sTCP:LISTEN for the ports. - Windows: Get-CimInstance Win32_Process for the tree, Get-NetTCPConnection (with a netstat -ano fallback) for the ports. Returns { protocol, family, address, port, pid, processName } records, de-duplicated by (family, address, port) and sorted by port, so callers also learn which interface each socket is bound to. Wires up a getOpenPorts(id, requestId) manager method and adds a node --test script to the sidecar package so these (and the previously un-run sidecar) tests execute in CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- standalone/sidecar/package.json | 3 + standalone/sidecar/pty-core.js | 460 +++++++++++++++++++++++++++- standalone/sidecar/pty-core.test.js | 274 ++++++++++++++++- 3 files changed, 735 insertions(+), 2 deletions(-) diff --git a/standalone/sidecar/package.json b/standalone/sidecar/package.json index 3a7fa5a6..da28fb06 100644 --- a/standalone/sidecar/package.json +++ b/standalone/sidecar/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.1.0", "main": "main.js", + "scripts": { + "test": "node --test" + }, "dependencies": { "node-pty": "1.2.0-beta.13" } diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index 71749fd4..05a4c139 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -276,6 +276,455 @@ function getCwdForPid(pid, runtime = {}) { module.exports.getCwdForPid = getCwdForPid; +// ── Open-port discovery ────────────────────────────────────────────────────── +// +// `getOpenPortsForPid(rootPid)` answers "which TCP ports are listening, opened +// by this shell or any of its descendant processes?" It works in two steps that +// are each platform-specific but share the same shape: +// +// 1. getDescendantPids(rootPid) — walk the process tree to the full set of +// PIDs rooted at the shell (the shell itself plus every transitive child). +// 2. getListeningPortsForPids(pids) — enumerate TCP sockets in the LISTEN +// state owned by any PID in that set. +// +// No third-party dependencies: Linux reads /proc directly, macOS shells out to +// `ps` + `lsof` (already used for cwd), and Windows uses PowerShell cmdlets with +// a `netstat -ano` fallback. Only listening TCP sockets are reported — this is +// the "what server is this terminal running" signal, without the churn of +// ephemeral outbound connections. + +/** + * Build the set of descendant PIDs (including rootPid) from a flat list of + * [pid, ppid] pairs via breadth-first walk. Shared by every platform. + */ +function buildDescendantSet(pairs, rootPid) { + const children = new Map(); + for (const [pid, ppid] of pairs) { + if (!children.has(ppid)) children.set(ppid, []); + children.get(ppid).push(pid); + } + const result = new Set([rootPid]); + const queue = [rootPid]; + while (queue.length > 0) { + const current = queue.shift(); + for (const child of children.get(current) || []) { + if (!result.has(child)) { + result.add(child); + queue.push(child); + } + } + } + return result; +} + +module.exports.buildDescendantSet = buildDescendantSet; + +/** + * Extract the parent PID from the contents of a Linux /proc//stat file. + * The comm field (2nd) is wrapped in parens and may itself contain spaces and + * parens, so we anchor on the last ')': state follows it, then ppid. + */ +function parseProcStatPpid(content) { + const rparen = content.lastIndexOf(')'); + if (rparen < 0) return null; + const rest = content.slice(rparen + 1).trim().split(/\s+/); + // rest[0] = state, rest[1] = ppid + const ppid = Number(rest[1]); + return Number.isInteger(ppid) ? ppid : null; +} + +module.exports.parseProcStatPpid = parseProcStatPpid; + +/** Parse `ps -axo pid=,ppid=` output into [pid, ppid] pairs (macOS/Linux). */ +function parsePsPairs(output) { + const pairs = []; + for (const line of output.split(/\r?\n/)) { + const match = line.trim().match(/^(\d+)\s+(\d+)$/); + if (match) pairs.push([Number(match[1]), Number(match[2])]); + } + return pairs; +} + +module.exports.parsePsPairs = parsePsPairs; + +function getDescendantPids(rootPid, runtime = {}) { + const platform = runtime.platform || process.platform; + const fsModule = runtime.fsModule || fs; + const execFileSyncFn = runtime.execFileSync || execFileSync; + + if (platform === 'linux') { + try { + const pairs = []; + for (const entry of fsModule.readdirSync('/proc')) { + if (!/^\d+$/.test(entry)) continue; + try { + const stat = fsModule.readFileSync(`/proc/${entry}/stat`, 'utf-8'); + const ppid = parseProcStatPpid(stat); + if (ppid != null) pairs.push([Number(entry), ppid]); + } catch { /* process vanished mid-scan */ } + } + return [...buildDescendantSet(pairs, rootPid)]; + } catch { + return [rootPid]; + } + } + + // macOS (and any other POSIX): ps gives the whole pid/ppid table. + if (platform === 'darwin') { + try { + const out = execFileSyncFn('ps', ['-axo', 'pid=,ppid='], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 4000, + }); + return [...buildDescendantSet(parsePsPairs(out), rootPid)]; + } catch { + return [rootPid]; + } + } + + if (platform === 'win32') { + try { + const json = runPowerShell( + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId | ConvertTo-Json -Compress', + execFileSyncFn, + ); + const rows = normalizeJsonArray(JSON.parse(json)); + const pairs = rows + .map((r) => [Number(r.ProcessId), Number(r.ParentProcessId)]) + .filter(([pid, ppid]) => Number.isInteger(pid) && Number.isInteger(ppid)); + return [...buildDescendantSet(pairs, rootPid)]; + } catch { + return [rootPid]; + } + } + + return [rootPid]; +} + +module.exports.getDescendantPids = getDescendantPids; + +/** Format a Linux /proc/net hex IPv4 address (little-endian per byte). */ +function parseHexIpv4(hex) { + const octets = []; + for (let i = 0; i < 8; i += 2) { + octets.push(parseInt(hex.slice(i, i + 2), 16)); + } + return octets.reverse().join('.'); +} + +module.exports.parseHexIpv4 = parseHexIpv4; + +/** + * Format a Linux /proc/net hex IPv6 address. The kernel stores it as four + * 32-bit words in host byte order, so each 4-byte word is byte-reversed before + * the 16 bytes are grouped and zero-compressed into canonical form. + */ +function parseHexIpv6(hex) { + const bytes = []; + for (let w = 0; w < 4; w++) { + const word = hex.slice(w * 8, w * 8 + 8); + for (let b = 3; b >= 0; b--) bytes.push(word.slice(b * 2, b * 2 + 2)); + } + const groups = []; + for (let i = 0; i < 16; i += 2) { + groups.push(parseInt(bytes[i], 16) * 256 + parseInt(bytes[i + 1], 16)); + } + return compressIpv6(groups); +} + +module.exports.parseHexIpv6 = parseHexIpv6; + +/** Collapse the longest run of zero groups in an 8-group IPv6 address to "::". */ +function compressIpv6(groups) { + let bestStart = -1; + let bestLen = 0; + let runStart = -1; + let runLen = 0; + for (let i = 0; i < groups.length; i++) { + if (groups[i] === 0) { + if (runStart < 0) runStart = i; + runLen++; + if (runLen > bestLen) { bestLen = runLen; bestStart = runStart; } + } else { + runStart = -1; + runLen = 0; + } + } + const hex = groups.map((g) => g.toString(16)); + if (bestLen < 2) return hex.join(':'); + const head = hex.slice(0, bestStart).join(':'); + const tail = hex.slice(bestStart + bestLen).join(':'); + return `${head}::${tail}`; +} + +/** + * Parse a /proc/net/tcp or /proc/net/tcp6 table into listening-socket records, + * keeping only rows whose socket inode is owned by one of `inodeToPid`. + * State `0A` is TCP_LISTEN. + */ +function parseProcNetTcp(content, family, inodeToPid) { + const ports = []; + const lines = content.split(/\r?\n/); + for (let i = 1; i < lines.length; i++) { + const tokens = lines[i].trim().split(/\s+/); + if (tokens.length < 10) continue; + if (tokens[3] !== '0A') continue; // TCP_LISTEN + const inode = tokens[9]; + const pid = inodeToPid.get(inode); + if (pid === undefined) continue; + const [hexIp, hexPort] = tokens[1].split(':'); + if (!hexPort) continue; + ports.push({ + protocol: 'tcp', + family, + address: family === 'IPv6' ? parseHexIpv6(hexIp) : parseHexIpv4(hexIp), + port: parseInt(hexPort, 16), + pid, + }); + } + return ports; +} + +module.exports.parseProcNetTcp = parseProcNetTcp; + +function linuxListeningPorts(pids, runtime = {}) { + const fsModule = runtime.fsModule || fs; + const pidSet = new Set(pids); + + // Map socket inode -> owning pid by reading each pid's open fds. + const inodeToPid = new Map(); + for (const pid of pidSet) { + let fds; + try { fds = fsModule.readdirSync(`/proc/${pid}/fd`); } catch { continue; } + for (const fd of fds) { + try { + const link = fsModule.readlinkSync(`/proc/${pid}/fd/${fd}`); + const match = /^socket:\[(\d+)\]$/.exec(link); + if (match) inodeToPid.set(match[1], pid); + } catch { /* fd closed mid-scan */ } + } + } + + const ports = []; + for (const [file, family] of [['/proc/net/tcp', 'IPv4'], ['/proc/net/tcp6', 'IPv6']]) { + try { + ports.push(...parseProcNetTcp(fsModule.readFileSync(file, 'utf-8'), family, inodeToPid)); + } catch { /* file absent (e.g. IPv6 disabled) */ } + } + + // Attach process names from /proc//comm. + for (const entry of ports) { + try { + entry.processName = fsModule.readFileSync(`/proc/${entry.pid}/comm`, 'utf-8').trim(); + } catch { /* gone */ } + } + return ports; +} + +/** + * Parse `lsof -nP -iTCP -sTCP:LISTEN ... -Fpcnt` field output (macOS). Records + * are keyed by single-char field types: p=pid, c=command, t=type (IPv4/IPv6), + * n=name (addr:port). A listening name looks like `*:3000`, `127.0.0.1:3000`, + * or `[::1]:8080`. + */ +function parseLsofListening(output) { + const ports = []; + let pid; + let command; + let family = 'IPv4'; + for (const line of output.split(/\r?\n/)) { + if (!line) continue; + const tag = line[0]; + const value = line.slice(1); + if (tag === 'p') { pid = Number(value); continue; } + if (tag === 'c') { command = value; continue; } + if (tag === 't') { if (value === 'IPv4' || value === 'IPv6') family = value; continue; } + if (tag === 'n') { + const parsed = parseHostPort(value); + if (parsed) { + ports.push({ protocol: 'tcp', family, address: parsed.address, port: parsed.port, pid, processName: command }); + } + } + } + return ports; +} + +module.exports.parseLsofListening = parseLsofListening; + +/** Split an "address:port" token, handling `*`, IPv4, and bracketed IPv6. */ +function parseHostPort(token) { + let address; + let portStr; + if (token.startsWith('[')) { + const end = token.indexOf(']'); + if (end < 0) return null; + address = token.slice(1, end); + portStr = token.slice(end + 2); // skip "]:" + } else { + const colon = token.lastIndexOf(':'); + if (colon < 0) return null; + address = token.slice(0, colon); + portStr = token.slice(colon + 1); + } + if (address === '*') address = '0.0.0.0'; + const port = Number(portStr); + if (!Number.isInteger(port) || port <= 0) return null; + return { address, port }; +} + +function macListeningPorts(pids, runtime = {}) { + const execFileSyncFn = runtime.execFileSync || execFileSync; + if (pids.length === 0) return []; + try { + const out = execFileSyncFn( + 'lsof', + ['-nP', '-a', '-iTCP', '-sTCP:LISTEN', '-p', pids.join(','), '-Fpcnt'], + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 }, + ); + return parseLsofListening(out); + } catch { + // lsof exits non-zero when none of the pids have matching files. + return []; + } +} + +/** ConvertTo-Json emits a bare object (not an array) for a single row. */ +function normalizeJsonArray(parsed) { + if (Array.isArray(parsed)) return parsed; + if (parsed == null) return []; + return [parsed]; +} + +/** + * Parse `Get-NetTCPConnection -State Listen | Select LocalAddress,LocalPort, + * OwningProcess` JSON, keeping rows owned by a pid in `pidSet`. + */ +function parseNetTcpConnections(json, pidSet, nameByPid = new Map()) { + const rows = normalizeJsonArray(JSON.parse(json)); + const ports = []; + for (const row of rows) { + const pid = Number(row.OwningProcess); + if (!pidSet.has(pid)) continue; + const port = Number(row.LocalPort); + if (!Number.isInteger(port)) continue; + const address = String(row.LocalAddress); + ports.push({ + protocol: 'tcp', + family: address.includes(':') ? 'IPv6' : 'IPv4', + address, + port, + pid, + processName: nameByPid.get(pid), + }); + } + return ports; +} + +module.exports.parseNetTcpConnections = parseNetTcpConnections; + +/** Parse `netstat -ano` LISTENING TCP rows (Windows fallback for older hosts). */ +function parseNetstatListening(output, pidSet, nameByPid = new Map()) { + const ports = []; + for (const line of output.split(/\r?\n/)) { + const tokens = line.trim().split(/\s+/); + if (tokens.length < 5) continue; + if (!/^TCP$/i.test(tokens[0])) continue; + if (!/^LISTENING$/i.test(tokens[3])) continue; + const pid = Number(tokens[4]); + if (!pidSet.has(pid)) continue; + const parsed = parseHostPort(tokens[1]); + if (!parsed) continue; + ports.push({ + protocol: 'tcp', + family: parsed.address.includes(':') ? 'IPv6' : 'IPv4', + address: parsed.address, + port: parsed.port, + pid, + processName: nameByPid.get(pid), + }); + } + return ports; +} + +module.exports.parseNetstatListening = parseNetstatListening; + +function runPowerShell(script, execFileSyncFn) { + return execFileSyncFn( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', script], + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 6000 }, + ); +} + +function windowsListeningPorts(pids, runtime = {}) { + const execFileSyncFn = runtime.execFileSync || execFileSync; + const pidSet = new Set(pids); + + // Resolve pid -> process name once (best-effort; ports still returned without). + const nameByPid = new Map(); + try { + const json = runPowerShell( + 'Get-CimInstance Win32_Process | Select-Object ProcessId,Name | ConvertTo-Json -Compress', + execFileSyncFn, + ); + for (const row of normalizeJsonArray(JSON.parse(json))) { + nameByPid.set(Number(row.ProcessId), String(row.Name)); + } + } catch { /* names are optional */ } + + // Preferred: Get-NetTCPConnection (Windows 8+/Server 2012+). + try { + const json = runPowerShell( + 'Get-NetTCPConnection -State Listen | Select-Object LocalAddress,LocalPort,OwningProcess | ConvertTo-Json -Compress', + execFileSyncFn, + ); + return parseNetTcpConnections(json, pidSet, nameByPid); + } catch { /* fall through to netstat */ } + + // Fallback: netstat -ano. + try { + const out = execFileSyncFn('netstat', ['-ano', '-p', 'TCP'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 6000, + }); + return parseNetstatListening(out, pidSet, nameByPid); + } catch { + return []; + } +} + +function getListeningPortsForPids(pids, runtime = {}) { + const platform = runtime.platform || process.platform; + if (platform === 'linux') return linuxListeningPorts(pids, runtime); + if (platform === 'darwin') return macListeningPorts(pids, runtime); + if (platform === 'win32') return windowsListeningPorts(pids, runtime); + return []; +} + +module.exports.getListeningPortsForPids = getListeningPortsForPids; + +/** + * Listening TCP ports opened by `rootPid` or any of its descendant processes, + * de-duplicated by (family, address, port) and sorted by port. Returns [] on + * any platform-specific failure rather than throwing. + */ +function getOpenPortsForPid(rootPid, runtime = {}) { + if (!Number.isInteger(rootPid)) return []; + const pids = getDescendantPids(rootPid, runtime); + const ports = getListeningPortsForPids(pids, runtime); + + const seen = new Map(); + for (const entry of ports) { + const key = `${entry.family}|${entry.address}|${entry.port}`; + if (!seen.has(key)) seen.set(key, entry); + } + return [...seen.values()].sort((a, b) => a.port - b.port || a.address.localeCompare(b.address)); +} + +module.exports.getOpenPortsForPid = getOpenPortsForPid; + /** * Shared PTY manager — the single place where node-pty processes are managed. * @@ -287,6 +736,7 @@ module.exports.getCwdForPid = getCwdForPid; * send('exit', { id, exitCode, signal }) * send('error', { id, message }) * send('list', { ptys: [{ id, alive }] }) + * send('openPorts', { id, ports: [{ protocol, family, address, port, pid, processName }], requestId }) */ module.exports.create = function create(send, ptyModule) { @@ -395,6 +845,14 @@ module.exports.create = function create(send, ptyModule) { send('cwd', { id, cwd: getCwdForPid(p.pid), requestId }); } + function getOpenPorts(id, requestId) { + const p = ptys.get(id); + if (!p) { send('openPorts', { id, ports: [], requestId }); return; } + let ports = []; + try { ports = getOpenPortsForPid(p.pid); } catch { ports = []; } + send('openPorts', { id, ports, requestId }); + } + function getScrollback(id, requestId) { const entry = scrollback.get(id); send('scrollback', { @@ -417,5 +875,5 @@ module.exports.create = function create(send, ptyModule) { send('shells', { shells: detectAvailableShells(), requestId }); } - return { spawn, write, resize, kill, killAll, list, getCwd, getScrollback, gracefulKillAll, getShells }; + return { spawn, write, resize, kill, killAll, list, getCwd, getOpenPorts, getScrollback, gracefulKillAll, getShells }; }; diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 78c92ff3..82f2c58d 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -1,7 +1,25 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { create, getCwdForPid, parseCwdFromLsof, resolveSpawnConfig, detectAvailableShells } = require('./pty-core'); +const { + create, + getCwdForPid, + parseCwdFromLsof, + resolveSpawnConfig, + detectAvailableShells, + buildDescendantSet, + parseProcStatPpid, + parsePsPairs, + parseHexIpv4, + parseHexIpv6, + parseProcNetTcp, + parseLsofListening, + parseNetTcpConnections, + parseNetstatListening, + getDescendantPids, + getListeningPortsForPids, + getOpenPortsForPid, +} = require('./pty-core'); test('resolveSpawnConfig uses POSIX shell and home defaults', () => { const config = resolveSpawnConfig(undefined, { @@ -380,3 +398,257 @@ test('detectAvailableShells detects WSL distros on Windows', () => { const debian = shells.find((s) => s.name === 'Debian'); assert.ok(debian, 'Debian WSL should be detected'); }); + +// ── Open-port discovery ────────────────────────────────────────────────────── + +test('buildDescendantSet walks the process tree from the root', () => { + // 100 → 200 → 400, 100 → 300; 999 is unrelated. + const pairs = [ + [200, 100], + [300, 100], + [400, 200], + [999, 1], + ]; + const set = buildDescendantSet(pairs, 100); + assert.deepEqual([...set].sort((a, b) => a - b), [100, 200, 300, 400]); + assert.ok(!set.has(999)); +}); + +test('buildDescendantSet tolerates cycles without looping forever', () => { + const pairs = [[200, 100], [100, 200]]; + const set = buildDescendantSet(pairs, 100); + assert.deepEqual([...set].sort((a, b) => a - b), [100, 200]); +}); + +test('parseProcStatPpid handles comm containing spaces and parens', () => { + // comm = "(weird ) name)" — ppid is the second token after the final ')'. + const content = '4242 (weird ) name) S 4200 4242 4242 0 -1 4194304 100 0'; + assert.equal(parseProcStatPpid(content), 4200); +}); + +test('parseProcStatPpid returns null on garbage', () => { + assert.equal(parseProcStatPpid('no parens here'), null); +}); + +test('parsePsPairs parses pid/ppid columns', () => { + const out = ' 100 1\n 200 100\n 400 200\nheader junk\n'; + assert.deepEqual(parsePsPairs(out), [[100, 1], [200, 100], [400, 200]]); +}); + +test('parseHexIpv4 decodes little-endian /proc address', () => { + assert.equal(parseHexIpv4('0100007F'), '127.0.0.1'); // loopback + assert.equal(parseHexIpv4('00000000'), '0.0.0.0'); // all interfaces +}); + +test('parseHexIpv6 decodes and compresses', () => { + assert.equal(parseHexIpv6('00000000000000000000000000000000'), '::'); // any + assert.equal(parseHexIpv6('00000000000000000000000001000000'), '::1'); // loopback +}); + +test('parseProcNetTcp keeps only LISTEN rows owned by tracked inodes', () => { + const content = [ + ' sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode', + ' 0: 0100007F:1538 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 55501 1 ffff 100', + ' 1: 00000000:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 55502 1 ffff 100', + ' 2: 0100007F:E07A AB07007F:1538 01 00000000:00000000 00:00000000 00000000 1000 0 55503 1 ffff 100', + ].join('\n'); + const inodeToPid = new Map([['55501', 4242], ['55502', 4242], ['55503', 4242]]); + const ports = parseProcNetTcp(content, 'IPv4', inodeToPid); + // Row 2 is ESTABLISHED (st 01) so it is dropped; only the two LISTEN rows remain. + assert.deepEqual(ports, [ + { protocol: 'tcp', family: 'IPv4', address: '127.0.0.1', port: 5432, pid: 4242 }, + { protocol: 'tcp', family: 'IPv4', address: '0.0.0.0', port: 8080, pid: 4242 }, + ]); +}); + +test('parseProcNetTcp ignores rows whose inode is not tracked', () => { + const content = [ + 'header', + ' 0: 0100007F:1538 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 77777 1 ffff 100', + ].join('\n'); + assert.deepEqual(parseProcNetTcp(content, 'IPv4', new Map()), []); +}); + +test('parseLsofListening parses *, IPv4, and bracketed IPv6 names', () => { + const output = [ + 'p4242', + 'cnode', + 'tIPv4', + 'n*:3000', + 'tIPv4', + 'n127.0.0.1:5432', + 'p4300', + 'cpython3', + 'tIPv6', + 'n[::1]:8080', + ].join('\n'); + assert.deepEqual(parseLsofListening(output), [ + { protocol: 'tcp', family: 'IPv4', address: '0.0.0.0', port: 3000, pid: 4242, processName: 'node' }, + { protocol: 'tcp', family: 'IPv4', address: '127.0.0.1', port: 5432, pid: 4242, processName: 'node' }, + { protocol: 'tcp', family: 'IPv6', address: '::1', port: 8080, pid: 4300, processName: 'python3' }, + ]); +}); + +test('parseNetTcpConnections filters by owning pid and detects family', () => { + const json = JSON.stringify([ + { LocalAddress: '0.0.0.0', LocalPort: 3000, OwningProcess: 4242 }, + { LocalAddress: '::', LocalPort: 8080, OwningProcess: 4242 }, + { LocalAddress: '0.0.0.0', LocalPort: 9999, OwningProcess: 1 }, // not ours + ]); + const ports = parseNetTcpConnections(json, new Set([4242]), new Map([[4242, 'node.exe']])); + assert.deepEqual(ports, [ + { protocol: 'tcp', family: 'IPv4', address: '0.0.0.0', port: 3000, pid: 4242, processName: 'node.exe' }, + { protocol: 'tcp', family: 'IPv6', address: '::', port: 8080, pid: 4242, processName: 'node.exe' }, + ]); +}); + +test('parseNetTcpConnections accepts a single (non-array) JSON object', () => { + const json = JSON.stringify({ LocalAddress: '0.0.0.0', LocalPort: 3000, OwningProcess: 4242 }); + const ports = parseNetTcpConnections(json, new Set([4242])); + assert.equal(ports.length, 1); + assert.equal(ports[0].port, 3000); +}); + +test('parseNetstatListening parses LISTENING TCP rows for tracked pids', () => { + const output = [ + 'Active Connections', + ' Proto Local Address Foreign Address State PID', + ' TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 4242', + ' TCP [::]:8080 [::]:0 LISTENING 4242', + ' TCP 127.0.0.1:54000 127.0.0.1:5432 ESTABLISHED 4242', + ' TCP 0.0.0.0:9999 0.0.0.0:0 LISTENING 1', + ].join('\n'); + const ports = parseNetstatListening(output, new Set([4242])); + assert.deepEqual(ports, [ + { protocol: 'tcp', family: 'IPv4', address: '0.0.0.0', port: 3000, pid: 4242, processName: undefined }, + { protocol: 'tcp', family: 'IPv6', address: '::', port: 8080, pid: 4242, processName: undefined }, + ]); +}); + +test('getDescendantPids (linux) reads ppid from /proc//stat', () => { + const procStat = { + '100': '100 (zsh) S 1 100 100 0', + '200': '200 (node) S 100 200 200 0', + '400': '400 (esbuild) S 200 400 400 0', + '999': '999 (other) S 1 999 999 0', + }; + const fsModule = { + readdirSync(p) { + if (p === '/proc') return ['100', '200', '400', '999', 'cpuinfo']; + throw new Error('ENOENT'); + }, + readFileSync(p) { + const m = /^\/proc\/(\d+)\/stat$/.exec(p); + if (m && procStat[m[1]]) return procStat[m[1]]; + throw new Error('ENOENT'); + }, + }; + const pids = getDescendantPids(100, { platform: 'linux', fsModule }); + assert.deepEqual(pids.sort((a, b) => a - b), [100, 200, 400]); +}); + +test('getListeningPortsForPids (linux) maps fd inodes to /proc/net/tcp ports', () => { + const fdLinks = { + '/proc/200/fd/3': 'socket:[55501]', + '/proc/200/fd/4': '/dev/null', + '/proc/200/fd/5': 'socket:[55502]', + }; + const tcp = [ + 'header', + ' 0: 0100007F:1538 00000000:0000 0A 0 0 0 1000 0 55501 1 ffff 100', + ' 1: 00000000:1F90 00000000:0000 0A 0 0 0 1000 0 55502 1 ffff 100', + ].join('\n'); + const fsModule = { + readdirSync(p) { + if (p === '/proc/200/fd') return ['3', '4', '5']; + throw new Error('ENOENT'); + }, + readlinkSync(p) { + if (fdLinks[p]) return fdLinks[p]; + throw new Error('ENOENT'); + }, + readFileSync(p) { + if (p === '/proc/net/tcp') return tcp; + if (p === '/proc/200/comm') return 'node\n'; + throw new Error('ENOENT'); // no tcp6 + }, + }; + const ports = getListeningPortsForPids([200], { platform: 'linux', fsModule }); + assert.deepEqual(ports, [ + { protocol: 'tcp', family: 'IPv4', address: '127.0.0.1', port: 5432, pid: 200, processName: 'node' }, + { protocol: 'tcp', family: 'IPv4', address: '0.0.0.0', port: 8080, pid: 200, processName: 'node' }, + ]); +}); + +test('getListeningPortsForPids (darwin) runs lsof with the descendant pid list', () => { + let capturedArgs; + const execFileSync = (cmd, args) => { + assert.equal(cmd, 'lsof'); + capturedArgs = args; + return ['p4242', 'cnode', 'tIPv4', 'n*:3000'].join('\n'); + }; + const ports = getListeningPortsForPids([100, 200], { platform: 'darwin', execFileSync }); + assert.ok(capturedArgs.includes('-sTCP:LISTEN')); + assert.ok(capturedArgs.includes('100,200')); + assert.deepEqual(ports, [ + { protocol: 'tcp', family: 'IPv4', address: '0.0.0.0', port: 3000, pid: 4242, processName: 'node' }, + ]); +}); + +test('getListeningPortsForPids (win32) prefers Get-NetTCPConnection', () => { + const execFileSync = (cmd, args) => { + assert.equal(cmd, 'powershell.exe'); + const script = args[args.length - 1]; + if (script.includes('Win32_Process')) { + return JSON.stringify([{ ProcessId: 4242, Name: 'node.exe' }]); + } + if (script.includes('Get-NetTCPConnection')) { + return JSON.stringify([{ LocalAddress: '0.0.0.0', LocalPort: 3000, OwningProcess: 4242 }]); + } + throw new Error(`unexpected script: ${script}`); + }; + const ports = getListeningPortsForPids([4242], { platform: 'win32', execFileSync }); + assert.deepEqual(ports, [ + { protocol: 'tcp', family: 'IPv4', address: '0.0.0.0', port: 3000, pid: 4242, processName: 'node.exe' }, + ]); +}); + +test('getListeningPortsForPids (win32) falls back to netstat when the cmdlet fails', () => { + const execFileSync = (cmd, args) => { + if (cmd === 'powershell.exe') { + const script = args[args.length - 1]; + if (script.includes('Win32_Process')) return JSON.stringify([]); + throw new Error('Get-NetTCPConnection: not recognized'); + } + if (cmd === 'netstat') { + return ' TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 4242\n'; + } + throw new Error('unexpected'); + }; + const ports = getListeningPortsForPids([4242], { platform: 'win32', execFileSync }); + assert.deepEqual(ports, [ + { protocol: 'tcp', family: 'IPv4', address: '0.0.0.0', port: 3000, pid: 4242, processName: undefined }, + ]); +}); + +test('getOpenPortsForPid de-duplicates and sorts by port', () => { + // darwin path: lsof returns a duplicate (same family/addr/port) plus an + // out-of-order pair to exercise sorting. + const execFileSync = (cmd) => { + if (cmd === 'ps') return '100 1\n200 100\n'; + if (cmd === 'lsof') { + return [ + 'p200', 'cnode', 'tIPv4', 'n*:8080', + 'tIPv4', 'n*:3000', + 'tIPv4', 'n*:8080', // duplicate + ].join('\n'); + } + throw new Error('unexpected'); + }; + const ports = getOpenPortsForPid(100, { platform: 'darwin', execFileSync }); + assert.deepEqual(ports.map((p) => p.port), [3000, 8080]); +}); + +test('getOpenPortsForPid returns [] for a non-integer pid', () => { + assert.deepEqual(getOpenPortsForPid(undefined, { platform: 'linux' }), []); +}); From 518c9afb00a51968a2648dc6cabfb7d3e1d9c4e5 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 28 May 2026 15:29:49 -0700 Subject: [PATCH 2/6] Wire getOpenPorts() through all adapters and the PlatformAdapter API Exposes the pty-core port discovery as PlatformAdapter.getOpenPorts(id), threaded end-to-end through every backend the same way getCwd is: - types.ts: OpenPort type + getOpenPorts() on PlatformAdapter. - VS Code: pty:getOpenPorts/pty:openPorts message types, message-router case, pty-manager.getOpenPorts() (4s timeout), pty-host case. - Tauri: sidecar main.js case, pty_get_open_ports Rust command (8s timeout) + handler registration, tauri-adapter method. - Fake adapter: getOpenPorts() backed by a settable map for playground/tests. Documents the mechanism and invariants in docs/specs/transport.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/specs/transport.md | 17 ++++++++++++++++ lib/src/lib/platform/fake-adapter.ts | 13 +++++++++++- lib/src/lib/platform/types.ts | 16 +++++++++++++++ lib/src/lib/platform/vscode-adapter.ts | 13 +++++++++++- standalone/sidecar/main.js | 1 + standalone/src-tauri/src/lib.rs | 20 ++++++++++++++++++ standalone/src/tauri-adapter.ts | 8 +++++++- vscode-ext/src/message-router.ts | 5 +++++ vscode-ext/src/message-types.ts | 3 +++ vscode-ext/src/pty-host.js | 1 + vscode-ext/src/pty-manager.ts | 28 ++++++++++++++++++++++++++ 11 files changed, 122 insertions(+), 3 deletions(-) diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 1f74af28..2fa4cce3 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -78,12 +78,29 @@ Non-obvious message contracts: | Direction | Message | Source type | Contract | | --- | --- | --- | --- | | Webview → host | `dormouse:openExternal` | `WebviewMessage` | Request the host to open a user-confirmed external URI from an OSC 8 hyperlink. Hosts must revalidate and reject malformed, control-character-bearing, or blocked pseudo-scheme targets (`javascript:`, `data:`, `blob:`, `about:`). | +| Webview → host | `pty:getOpenPorts` | `WebviewMessage` | Request the TCP listening ports opened by a PTY's shell process **and all of its descendant subprocesses**. The host resolves them from the PTY's root pid (see "Open-port discovery" below) and replies with `pty:openPorts`. | +| Host → webview | `pty:openPorts` | `ExtensionMessage` | Reply to `pty:getOpenPorts`: `ports: OpenPort[]` (`{ protocol, family, address, port, pid, processName }`), de-duplicated by `(family, address, port)` and sorted by port. Empty array when the PTY is gone or enumeration fails. | | Host → webview | `pty:data` | `ExtensionMessage` | PTY output after state-driving supported OSC sequences have been parsed/stripped; `OSC 8` hyperlinks are preserved for xterm.js and routed only to the owning router. | | Host → webview | `pty:replay` | `ExtensionMessage` | Buffered raw output since spawn; the webview parses semantic OSCs during replay reconstruction without triggering alerts. | | Host → webview | `dormouse:newTerminal` | `ExtensionMessage` | Payload may include `shell`, `args`, display `name`, `replaceUntouched`, and `announce`; the webview replaces the selected untouched terminal in-place only when `replaceUntouched` is true, otherwise it spawns a new pane. | The OSC parsing/stripping rules that produce `pty:data` and `terminal:semanticEvents` are specified in `docs/specs/terminal-escapes.md`. +## Open-port discovery + +`getOpenPorts(id)` answers "which TCP ports is this terminal listening on?" — covering the shell process **and every descendant subprocess** (e.g. a dev server launched from the shell, or a server launched by a script launched by the shell). It works for any Dormouse pane on any platform because Dormouse always holds the root shell pid of the PTYs it spawns; no terminal-side cooperation is required. + +Source of truth: `getOpenPortsForPid(rootPid)` in `standalone/sidecar/pty-core.js` (the shared core; the VS Code extension loads it through the `lib/pty-core.cjs` shim). It runs in two platform-specific steps with no third-party dependencies: + +1. **Process tree** (`getDescendantPids`) — Linux scans `/proc//stat` for the pid→ppid table; macOS uses `ps -axo pid=,ppid=`; Windows uses `Get-CimInstance Win32_Process`. A shared BFS (`buildDescendantSet`) collects the root pid plus all transitive children. +2. **Listening sockets** (`getListeningPortsForPids`) — Linux maps each pid's `/proc//fd` socket inodes to `0A` (LISTEN) rows in `/proc/net/tcp{,6}`; macOS runs `lsof -nP -iTCP -sTCP:LISTEN -p `; Windows runs `Get-NetTCPConnection -State Listen` with a `netstat -ano` fallback for hosts lacking the cmdlet. + +Invariants: + +- **Listening TCP only.** Established/outbound connections and UDP are intentionally excluded — the signal is "what server is this terminal running," not raw connection churn. +- **Fail soft, never throw.** Any platform-specific failure (missing `/proc`, `lsof`/PowerShell error, timeout) yields `[]`, never an exception. The Tauri command uses an 8s timeout and the VS Code path a 4s timeout, both wider than the 1s cwd query because enumeration shells out on macOS/Windows. +- **`address` is the bind interface.** `0.0.0.0`/`::` mean all interfaces; `127.0.0.1`/`::1` mean loopback-only. Results are de-duplicated by `(family, address, port)` and sorted by port. + ## Persisted session types Source of truth: `lib/src/lib/session-types.ts` defines the persisted-session interfaces (`PersistedSession` v3, `PersistedPane`, `PersistedAlertState`, `PersistedDoor`) and their v1→v2→v3 migrations. diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index a8cfd5f3..372c4e4d 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -1,4 +1,4 @@ -import type { AlertStateDetail, PlatformAdapter, PtyInfo } from './types'; +import type { AlertStateDetail, OpenPort, PlatformAdapter, PtyInfo } from './types'; import { AlertManager, type SessionStatus } from '../alert-manager'; import { normalizeExternalUri } from '../external-links'; import { @@ -45,6 +45,7 @@ export class FakePtyAdapter implements PlatformAdapter { private scenarioMap = new Map(); private inputHandlers = new Map void>(); private protocolParsers = new Map(); + private openPortsMap = new Map(); private alertManager = new AlertManager(); constructor() { @@ -91,6 +92,7 @@ export class FakePtyAdapter implements PlatformAdapter { this.spawnHandlers.clear(); this.inputHandlers.clear(); this.protocolParsers.clear(); + this.openPortsMap.clear(); this.alertManager.dispose(); this.alertManager = new AlertManager(); this.alertManager.onStateChange((id, state) => { @@ -183,6 +185,15 @@ export class FakePtyAdapter implements PlatformAdapter { async getCwd(_id: string): Promise { return null; } async getScrollback(_id: string): Promise { return null; } + /** Ports the playground/tests want a given terminal to report. */ + setOpenPorts(id: string, ports: OpenPort[]): void { + this.openPortsMap.set(id, ports); + } + + async getOpenPorts(id: string): Promise { + return this.openPortsMap.get(id) ?? []; + } + getPtySize(id: string): FakePtySize { return this.terminalSizes.get(id) ?? DEFAULT_PTY_SIZE; } diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index ffa45d47..3d2e787b 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -6,6 +6,20 @@ export interface PtyInfo { exitCode?: number; } +/** + * A TCP socket in the LISTEN state opened by a terminal's shell process or any + * of its descendant subprocesses. `address` is the bind interface — `0.0.0.0` + * / `::` mean all interfaces, `127.0.0.1` / `::1` mean loopback-only. + */ +export interface OpenPort { + protocol: 'tcp'; + family: 'IPv4' | 'IPv6'; + address: string; + port: number; + pid: number; + processName?: string; +} + export type AlertStateDetail = { id: string } & AlertState; export interface PlatformAdapter { @@ -25,6 +39,8 @@ export interface PlatformAdapter { // PTY queries getCwd(id: string): Promise; getScrollback(id: string): Promise; + /** TCP listening ports opened by this terminal's process tree (shell + descendants). */ + getOpenPorts(id: string): Promise; // Clipboard support for file references and raw images. readClipboardFilePaths(): Promise; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 2bf4c65e..fb5218ff 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,4 +1,4 @@ -import type { AlertStateDetail, PlatformAdapter, PtyInfo } from './types'; +import type { AlertStateDetail, OpenPort, PlatformAdapter, PtyInfo } from './types'; import { setDefaultShellOpts } from '../shell-defaults'; import { collectTerminalSemanticEvents, @@ -161,6 +161,17 @@ export class VSCodeAdapter implements PlatformAdapter { return this.requestResponse('pty:getScrollback', 'pty:scrollback', { id }, (msg) => msg.data); } + async getOpenPorts(id: string): Promise { + // Port enumeration shells out (lsof / PowerShell) on macOS/Windows, so allow + // a longer ceiling than the default 1s cwd query. + const result = await this.requestResponse( + 'pty:getOpenPorts', 'pty:openPorts', { id }, + (msg) => msg.ports as OpenPort[], + 4000, + ); + return result ?? []; + } + readClipboardFilePaths(): Promise { return this.requestResponse( 'clipboard:readFiles', 'clipboard:files', {}, diff --git a/standalone/sidecar/main.js b/standalone/sidecar/main.js index 4ae2f106..ad23f996 100644 --- a/standalone/sidecar/main.js +++ b/standalone/sidecar/main.js @@ -40,6 +40,7 @@ rl.on('line', (line) => { case 'pty:kill': mgr.kill(data.id); break; case 'pty:requestInit': mgr.list(); break; case 'pty:getCwd': mgr.getCwd(data.id, data.requestId); break; + case 'pty:getOpenPorts': mgr.getOpenPorts(data.id, data.requestId); break; case 'pty:getScrollback': mgr.getScrollback(data.id, data.requestId); break; case 'pty:getShells': mgr.getShells(data.requestId); break; case 'pty:gracefulKillAll': mgr.gracefulKillAll(data.timeout); break; diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 582fc5b1..83338140 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -254,6 +254,25 @@ fn pty_get_cwd( .and_then(|cwd| cwd.as_str().map(String::from))) } +#[tauri::command] +fn pty_get_open_ports( + state: tauri::State<'_, SidecarState>, + id: String, +) -> Result { + // Enumeration shells out to lsof / PowerShell, so allow more headroom than + // the 1s default used by most sidecar queries. + let response = request_from_sidecar_timeout( + &state, + "pty:getOpenPorts", + serde_json::json!({ "id": id }), + Duration::from_secs(8), + )?; + Ok(response + .get("ports") + .cloned() + .unwrap_or_else(|| JsonValue::Array(Vec::new()))) +} + #[tauri::command] fn pty_get_scrollback( state: tauri::State<'_, SidecarState>, @@ -638,6 +657,7 @@ pub fn run() { pty_resize, pty_kill, pty_get_cwd, + pty_get_open_ports, pty_get_scrollback, pty_request_init, kill_sidecar_now, diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 6adc229a..823e72db 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -1,7 +1,7 @@ import { invoke as rawInvoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { open } from "@tauri-apps/plugin-shell"; -import type { AlertStateDetail, PlatformAdapter, PtyInfo } from "dormouse-lib/lib/platform/types"; +import type { AlertStateDetail, OpenPort, PlatformAdapter, PtyInfo } from "dormouse-lib/lib/platform/types"; import { AlertManager, type SessionStatus } from "dormouse-lib/lib/alert-manager"; import { normalizeExternalUri } from "dormouse-lib/lib/external-links"; import { @@ -163,6 +163,12 @@ export class TauriAdapter implements PlatformAdapter { } catch { return null; } } + async getOpenPorts(id: string): Promise { + try { + return await rawInvoke("pty_get_open_ports", { id }); + } catch { return []; } + } + async readClipboardFilePaths(): Promise { try { return await rawInvoke("read_clipboard_file_paths"); diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 28f29484..db171616 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -237,6 +237,11 @@ export function attachRouter( webview.postMessage({ type: 'pty:cwd', id: msg.id, cwd, requestId: msg.requestId } satisfies ExtensionMessage); }); break; + case 'pty:getOpenPorts': + ptyManager.getOpenPorts(msg.id).then((ports) => { + webview.postMessage({ type: 'pty:openPorts', id: msg.id, ports, requestId: msg.requestId } satisfies ExtensionMessage); + }); + break; case 'pty:getScrollback': webview.postMessage({ type: 'pty:scrollback', id: msg.id, diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 278f9d23..b675fa5e 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -1,5 +1,6 @@ import type { ActivityNotification, SessionStatus, TodoState } from '../../lib/src/lib/alert-manager'; import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state'; +import type { OpenPort } from '../../lib/src/lib/platform/types'; // Messages from webview → extension host export type WebviewMessage = @@ -8,6 +9,7 @@ export type WebviewMessage = | { type: 'pty:resize'; id: string; cols: number; rows: number } | { type: 'pty:kill'; id: string } | { type: 'pty:getCwd'; id: string; requestId?: string } + | { type: 'pty:getOpenPorts'; id: string; requestId?: string } | { type: 'pty:getScrollback'; id: string; requestId?: string } | { type: 'pty:getShells'; requestId?: string } | { type: 'clipboard:readFiles'; requestId: string } @@ -43,6 +45,7 @@ export type ExtensionMessage = | { type: 'pty:list'; ptys: PtyInfo[] } | { type: 'pty:replay'; id: string; data: string } | { type: 'pty:cwd'; id: string; cwd: string | null; requestId?: string } + | { type: 'pty:openPorts'; id: string; ports: OpenPort[]; requestId?: string } | { type: 'pty:scrollback'; id: string; data: string | null; requestId?: string } | { type: 'pty:shells'; shells: Array<{ name: string; path: string; args: string[] }>; requestId?: string } | { type: 'clipboard:files'; paths: string[] | null; requestId: string } diff --git a/vscode-ext/src/pty-host.js b/vscode-ext/src/pty-host.js index b07f88cc..ddc5e7d6 100644 --- a/vscode-ext/src/pty-host.js +++ b/vscode-ext/src/pty-host.js @@ -18,6 +18,7 @@ process.on('message', (msg) => { case 'killAll': mgr.killAll(); break; case 'gracefulKillAll': mgr.gracefulKillAll(msg.timeout); break; case 'getCwd': mgr.getCwd(msg.id); break; + case 'getOpenPorts': mgr.getOpenPorts(msg.id); break; case 'getShells': mgr.getShells(msg.requestId); break; } }); diff --git a/vscode-ext/src/pty-manager.ts b/vscode-ext/src/pty-manager.ts index c938c941..4e6bc65e 100644 --- a/vscode-ext/src/pty-manager.ts +++ b/vscode-ext/src/pty-manager.ts @@ -236,6 +236,34 @@ export function getCwd(id: string): Promise { }); } +export interface OpenPortEntry { + protocol: 'tcp'; + family: 'IPv4' | 'IPv6'; + address: string; + port: number; + pid: number; + processName?: string; +} + +export function getOpenPorts(id: string): Promise { + return new Promise((resolve) => { + sendToChild({ type: 'getOpenPorts', id }); + // Port enumeration shells out on macOS/Windows; allow more headroom than getCwd. + const timeout = setTimeout(() => { + child?.off('message', handler); + resolve([]); + }, 4000); + const handler = (msg: any) => { + if (msg.type === 'openPorts' && msg.id === id) { + clearTimeout(timeout); + child?.off('message', handler); + resolve(msg.ports || []); + } + }; + child?.on('message', handler); + }); +} + export function write(id: string, data: string): void { sendToChild({ type: 'input', id, data }); } From 23eb73ce18d0d24205524411d674a97e930a6db0 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 28 May 2026 17:07:40 -0700 Subject: [PATCH 3/6] Simplify port-discovery code after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pty-manager.ts: drop the OpenPortEntry interface and import the shared OpenPort type from lib (matches how message-types.ts sources it). - pty-core.js: add a runPowerShellJson() helper to collapse the repeated runPowerShell + JSON.parse + normalizeJsonArray dance at the two Win32_Process call sites. - pty-core.js: memoize /proc//comm reads so a pid with multiple listening ports is only read once. - pty-core.js: drop the redundant try/catch in the getOpenPorts manager method — getOpenPortsForPid is already fail-soft, and getCwd doesn't wrap its helper either. Co-Authored-By: Claude Opus 4.8 (1M context) --- standalone/sidecar/pty-core.js | 30 ++++++++++++++++++------------ vscode-ext/src/pty-manager.ts | 12 ++---------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index 05a4c139..dd730346 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -385,11 +385,10 @@ function getDescendantPids(rootPid, runtime = {}) { if (platform === 'win32') { try { - const json = runPowerShell( + const rows = runPowerShellJson( 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId | ConvertTo-Json -Compress', execFileSyncFn, ); - const rows = normalizeJsonArray(JSON.parse(json)); const pairs = rows .map((r) => [Number(r.ProcessId), Number(r.ParentProcessId)]) .filter(([pid, ppid]) => Number.isInteger(pid) && Number.isInteger(ppid)); @@ -513,11 +512,15 @@ function linuxListeningPorts(pids, runtime = {}) { } catch { /* file absent (e.g. IPv6 disabled) */ } } - // Attach process names from /proc//comm. + // Attach process names from /proc//comm, reading each pid at most once. + const nameByPid = new Map(); for (const entry of ports) { - try { - entry.processName = fsModule.readFileSync(`/proc/${entry.pid}/comm`, 'utf-8').trim(); - } catch { /* gone */ } + if (!nameByPid.has(entry.pid)) { + let name; + try { name = fsModule.readFileSync(`/proc/${entry.pid}/comm`, 'utf-8').trim(); } catch { /* gone */ } + nameByPid.set(entry.pid, name); + } + entry.processName = nameByPid.get(entry.pid); } return ports; } @@ -657,6 +660,11 @@ function runPowerShell(script, execFileSyncFn) { ); } +/** Run a `... | ConvertTo-Json` script and return its rows as an array. */ +function runPowerShellJson(script, execFileSyncFn) { + return normalizeJsonArray(JSON.parse(runPowerShell(script, execFileSyncFn))); +} + function windowsListeningPorts(pids, runtime = {}) { const execFileSyncFn = runtime.execFileSync || execFileSync; const pidSet = new Set(pids); @@ -664,11 +672,11 @@ function windowsListeningPorts(pids, runtime = {}) { // Resolve pid -> process name once (best-effort; ports still returned without). const nameByPid = new Map(); try { - const json = runPowerShell( + const rows = runPowerShellJson( 'Get-CimInstance Win32_Process | Select-Object ProcessId,Name | ConvertTo-Json -Compress', execFileSyncFn, ); - for (const row of normalizeJsonArray(JSON.parse(json))) { + for (const row of rows) { nameByPid.set(Number(row.ProcessId), String(row.Name)); } } catch { /* names are optional */ } @@ -847,10 +855,8 @@ module.exports.create = function create(send, ptyModule) { function getOpenPorts(id, requestId) { const p = ptys.get(id); - if (!p) { send('openPorts', { id, ports: [], requestId }); return; } - let ports = []; - try { ports = getOpenPortsForPid(p.pid); } catch { ports = []; } - send('openPorts', { id, ports, requestId }); + // getOpenPortsForPid is fail-soft (returns [] on any platform error). + send('openPorts', { id, ports: p ? getOpenPortsForPid(p.pid) : [], requestId }); } function getScrollback(id, requestId) { diff --git a/vscode-ext/src/pty-manager.ts b/vscode-ext/src/pty-manager.ts index 4e6bc65e..79dc6284 100644 --- a/vscode-ext/src/pty-manager.ts +++ b/vscode-ext/src/pty-manager.ts @@ -1,6 +1,7 @@ import { fork, ChildProcess } from 'child_process'; import * as path from 'path'; import { log } from './log'; +import type { OpenPort } from '../../lib/src/lib/platform/types'; export interface PtyCallbacks { onData(id: string, data: string): void; @@ -236,16 +237,7 @@ export function getCwd(id: string): Promise { }); } -export interface OpenPortEntry { - protocol: 'tcp'; - family: 'IPv4' | 'IPv6'; - address: string; - port: number; - pid: number; - processName?: string; -} - -export function getOpenPorts(id: string): Promise { +export function getOpenPorts(id: string): Promise { return new Promise((resolve) => { sendToChild({ type: 'getOpenPorts', id }); // Port enumeration shells out on macOS/Windows; allow more headroom than getCwd. From 287caa26fae819e61dfc660003262f1187d752e3 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 28 May 2026 17:29:23 -0700 Subject: [PATCH 4/6] Preserve IPv6 lsof wildcard ports --- standalone/sidecar/pty-core.js | 6 +++--- standalone/sidecar/pty-core.test.js | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index dd730346..a0312c4c 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -544,7 +544,7 @@ function parseLsofListening(output) { if (tag === 'c') { command = value; continue; } if (tag === 't') { if (value === 'IPv4' || value === 'IPv6') family = value; continue; } if (tag === 'n') { - const parsed = parseHostPort(value); + const parsed = parseHostPort(value, family); if (parsed) { ports.push({ protocol: 'tcp', family, address: parsed.address, port: parsed.port, pid, processName: command }); } @@ -556,7 +556,7 @@ function parseLsofListening(output) { module.exports.parseLsofListening = parseLsofListening; /** Split an "address:port" token, handling `*`, IPv4, and bracketed IPv6. */ -function parseHostPort(token) { +function parseHostPort(token, wildcardFamily = 'IPv4') { let address; let portStr; if (token.startsWith('[')) { @@ -570,7 +570,7 @@ function parseHostPort(token) { address = token.slice(0, colon); portStr = token.slice(colon + 1); } - if (address === '*') address = '0.0.0.0'; + if (address === '*') address = wildcardFamily === 'IPv6' ? '::' : '0.0.0.0'; const port = Number(portStr); if (!Number.isInteger(port) || port <= 0) return null; return { address, port }; diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 82f2c58d..4829b28e 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -481,11 +481,14 @@ test('parseLsofListening parses *, IPv4, and bracketed IPv6 names', () => { 'cpython3', 'tIPv6', 'n[::1]:8080', + 'tIPv6', + 'n*:5173', ].join('\n'); assert.deepEqual(parseLsofListening(output), [ { protocol: 'tcp', family: 'IPv4', address: '0.0.0.0', port: 3000, pid: 4242, processName: 'node' }, { protocol: 'tcp', family: 'IPv4', address: '127.0.0.1', port: 5432, pid: 4242, processName: 'node' }, { protocol: 'tcp', family: 'IPv6', address: '::1', port: 8080, pid: 4300, processName: 'python3' }, + { protocol: 'tcp', family: 'IPv6', address: '::', port: 5173, pid: 4300, processName: 'python3' }, ]); }); From db6badd6010482a0818e574be342ace90eaca55b Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 28 May 2026 17:30:19 -0700 Subject: [PATCH 5/6] Clear fake adapter ports on kill --- lib/src/lib/platform/fake-adapter.test.ts | 24 +++++++++++++++++++++++ lib/src/lib/platform/fake-adapter.ts | 2 ++ 2 files changed, 26 insertions(+) diff --git a/lib/src/lib/platform/fake-adapter.test.ts b/lib/src/lib/platform/fake-adapter.test.ts index c657dd10..7839ce75 100644 --- a/lib/src/lib/platform/fake-adapter.test.ts +++ b/lib/src/lib/platform/fake-adapter.test.ts @@ -239,6 +239,30 @@ describe('FakePtyAdapter', () => { expect(dataEvents).toEqual([{ id: 't1', data: 'after' }]); }); + it('clears configured open ports on kill', async () => { + const { adapter } = createAdapter(); + const ports = [ + { + protocol: 'tcp' as const, + family: 'IPv4' as const, + address: '127.0.0.1', + port: 5173, + pid: 1234, + processName: 'vite', + }, + ]; + adapter.spawnPty('t1'); + adapter.setOpenPorts('t1', ports); + + await expect(adapter.getOpenPorts('t1')).resolves.toEqual(ports); + + adapter.killPty('t1'); + await expect(adapter.getOpenPorts('t1')).resolves.toEqual([]); + + adapter.spawnPty('t1'); + await expect(adapter.getOpenPorts('t1')).resolves.toEqual([]); + }); + it('clears input handlers on reset', () => { const { adapter, dataEvents } = createAdapter(); const handled: string[] = []; diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 372c4e4d..05dad2d4 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -160,6 +160,7 @@ export class FakePtyAdapter implements PlatformAdapter { this.terminalSizes.delete(id); this.inputHandlers.delete(id); this.protocolParsers.delete(id); + this.openPortsMap.delete(id); this.alertManager.onExit(id, 0); for (const handler of this.exitHandlers) { handler({ id, exitCode: 0 }); @@ -191,6 +192,7 @@ export class FakePtyAdapter implements PlatformAdapter { } async getOpenPorts(id: string): Promise { + if (!this.terminals.has(id)) return []; return this.openPortsMap.get(id) ?? []; } From 65304c90eed9fe548368b130904af60709ab33ba Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Fri, 29 May 2026 02:56:18 +0000 Subject: [PATCH 6/6] Centralize OPEN_PORT_TIMEOUT_MS in lib at 3s, trim spec duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define a single OPEN_PORT_TIMEOUT_MS=3000 constant in lib and use it at every transport boundary (vscode-adapter, pty-manager, Tauri pty_get_open_ports) and for the per-subprocess execs inside pty-core (ps, lsof, PowerShell, netstat). Rust and pty-core.js mirror the value with cross-reference comments since lib's TS source isn't importable from those runtimes. Drop the "Open-port discovery" section in transport.md — its platform-step breakdown and invariants list duplicated the implementation. The message-protocol row keeps a Source-of-truth pointer. Co-Authored-By: Claude Opus 4.7 --- docs/specs/transport.md | 17 +---------------- lib/src/lib/platform/types.ts | 11 +++++++++++ lib/src/lib/platform/vscode-adapter.ts | 5 ++--- standalone/sidecar/pty-core.js | 13 +++++++++---- standalone/src-tauri/src/lib.rs | 7 ++++--- vscode-ext/src/pty-manager.ts | 4 ++-- 6 files changed, 29 insertions(+), 28 deletions(-) diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 2fa4cce3..cc1bcfbc 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -78,7 +78,7 @@ Non-obvious message contracts: | Direction | Message | Source type | Contract | | --- | --- | --- | --- | | Webview → host | `dormouse:openExternal` | `WebviewMessage` | Request the host to open a user-confirmed external URI from an OSC 8 hyperlink. Hosts must revalidate and reject malformed, control-character-bearing, or blocked pseudo-scheme targets (`javascript:`, `data:`, `blob:`, `about:`). | -| Webview → host | `pty:getOpenPorts` | `WebviewMessage` | Request the TCP listening ports opened by a PTY's shell process **and all of its descendant subprocesses**. The host resolves them from the PTY's root pid (see "Open-port discovery" below) and replies with `pty:openPorts`. | +| Webview → host | `pty:getOpenPorts` | `WebviewMessage` | Request the TCP listening ports opened by a PTY's shell process **and all of its descendant subprocesses**. The host resolves them from the PTY's root pid and replies with `pty:openPorts`. Source of truth: `getOpenPortsForPid()` in `standalone/sidecar/pty-core.js` (the VS Code extension loads it through the `lib/pty-core.cjs` shim). | | Host → webview | `pty:openPorts` | `ExtensionMessage` | Reply to `pty:getOpenPorts`: `ports: OpenPort[]` (`{ protocol, family, address, port, pid, processName }`), de-duplicated by `(family, address, port)` and sorted by port. Empty array when the PTY is gone or enumeration fails. | | Host → webview | `pty:data` | `ExtensionMessage` | PTY output after state-driving supported OSC sequences have been parsed/stripped; `OSC 8` hyperlinks are preserved for xterm.js and routed only to the owning router. | | Host → webview | `pty:replay` | `ExtensionMessage` | Buffered raw output since spawn; the webview parses semantic OSCs during replay reconstruction without triggering alerts. | @@ -86,21 +86,6 @@ Non-obvious message contracts: The OSC parsing/stripping rules that produce `pty:data` and `terminal:semanticEvents` are specified in `docs/specs/terminal-escapes.md`. -## Open-port discovery - -`getOpenPorts(id)` answers "which TCP ports is this terminal listening on?" — covering the shell process **and every descendant subprocess** (e.g. a dev server launched from the shell, or a server launched by a script launched by the shell). It works for any Dormouse pane on any platform because Dormouse always holds the root shell pid of the PTYs it spawns; no terminal-side cooperation is required. - -Source of truth: `getOpenPortsForPid(rootPid)` in `standalone/sidecar/pty-core.js` (the shared core; the VS Code extension loads it through the `lib/pty-core.cjs` shim). It runs in two platform-specific steps with no third-party dependencies: - -1. **Process tree** (`getDescendantPids`) — Linux scans `/proc//stat` for the pid→ppid table; macOS uses `ps -axo pid=,ppid=`; Windows uses `Get-CimInstance Win32_Process`. A shared BFS (`buildDescendantSet`) collects the root pid plus all transitive children. -2. **Listening sockets** (`getListeningPortsForPids`) — Linux maps each pid's `/proc//fd` socket inodes to `0A` (LISTEN) rows in `/proc/net/tcp{,6}`; macOS runs `lsof -nP -iTCP -sTCP:LISTEN -p `; Windows runs `Get-NetTCPConnection -State Listen` with a `netstat -ano` fallback for hosts lacking the cmdlet. - -Invariants: - -- **Listening TCP only.** Established/outbound connections and UDP are intentionally excluded — the signal is "what server is this terminal running," not raw connection churn. -- **Fail soft, never throw.** Any platform-specific failure (missing `/proc`, `lsof`/PowerShell error, timeout) yields `[]`, never an exception. The Tauri command uses an 8s timeout and the VS Code path a 4s timeout, both wider than the 1s cwd query because enumeration shells out on macOS/Windows. -- **`address` is the bind interface.** `0.0.0.0`/`::` mean all interfaces; `127.0.0.1`/`::1` mean loopback-only. Results are de-duplicated by `(family, address, port)` and sorted by port. - ## Persisted session types Source of truth: `lib/src/lib/session-types.ts` defines the persisted-session interfaces (`PersistedSession` v3, `PersistedPane`, `PersistedAlertState`, `PersistedDoor`) and their v1→v2→v3 migrations. diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 3d2e787b..477e0848 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -20,6 +20,17 @@ export interface OpenPort { processName?: string; } +/** + * End-to-end budget for `getOpenPorts()` at every transport boundary + * (webview → host adapter, host → pty-host child, Tauri command → sidecar) and + * for the per-subprocess execs inside `getOpenPortsForPid()` (lsof, PowerShell, + * `Get-NetTCPConnection`, `netstat`). Wider than the 1 s cwd query because + * enumeration shells out on macOS/Windows; tight enough to fail visibly rather + * than hang a pane header. Mirrored as `OPEN_PORT_TIMEOUT_MS` in + * `standalone/sidecar/pty-core.js` and `standalone/src-tauri/src/lib.rs`. + */ +export const OPEN_PORT_TIMEOUT_MS = 3000; + export type AlertStateDetail = { id: string } & AlertState; export interface PlatformAdapter { diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index fb5218ff..9ffd66e5 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,4 +1,5 @@ import type { AlertStateDetail, OpenPort, PlatformAdapter, PtyInfo } from './types'; +import { OPEN_PORT_TIMEOUT_MS } from './types'; import { setDefaultShellOpts } from '../shell-defaults'; import { collectTerminalSemanticEvents, @@ -162,12 +163,10 @@ export class VSCodeAdapter implements PlatformAdapter { } async getOpenPorts(id: string): Promise { - // Port enumeration shells out (lsof / PowerShell) on macOS/Windows, so allow - // a longer ceiling than the default 1s cwd query. const result = await this.requestResponse( 'pty:getOpenPorts', 'pty:openPorts', { id }, (msg) => msg.ports as OpenPort[], - 4000, + OPEN_PORT_TIMEOUT_MS, ); return result ?? []; } diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index a0312c4c..f8e3f275 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -293,6 +293,11 @@ module.exports.getCwdForPid = getCwdForPid; // the "what server is this terminal running" signal, without the churn of // ephemeral outbound connections. +// Mirrors `OPEN_PORT_TIMEOUT_MS` in `lib/src/lib/platform/types.ts` — keep in +// sync. Used as the per-subprocess timeout cap inside the open-port pipeline. +const OPEN_PORT_TIMEOUT_MS = 3000; +module.exports.OPEN_PORT_TIMEOUT_MS = OPEN_PORT_TIMEOUT_MS; + /** * Build the set of descendant PIDs (including rootPid) from a flat list of * [pid, ppid] pairs via breadth-first walk. Shared by every platform. @@ -375,7 +380,7 @@ function getDescendantPids(rootPid, runtime = {}) { const out = execFileSyncFn('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], - timeout: 4000, + timeout: OPEN_PORT_TIMEOUT_MS, }); return [...buildDescendantSet(parsePsPairs(out), rootPid)]; } catch { @@ -583,7 +588,7 @@ function macListeningPorts(pids, runtime = {}) { const out = execFileSyncFn( 'lsof', ['-nP', '-a', '-iTCP', '-sTCP:LISTEN', '-p', pids.join(','), '-Fpcnt'], - { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 }, + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: OPEN_PORT_TIMEOUT_MS }, ); return parseLsofListening(out); } catch { @@ -656,7 +661,7 @@ function runPowerShell(script, execFileSyncFn) { return execFileSyncFn( 'powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], - { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 6000 }, + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: OPEN_PORT_TIMEOUT_MS }, ); } @@ -695,7 +700,7 @@ function windowsListeningPorts(pids, runtime = {}) { const out = execFileSyncFn('netstat', ['-ano', '-p', 'TCP'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], - timeout: 6000, + timeout: OPEN_PORT_TIMEOUT_MS, }); return parseNetstatListening(out, pidSet, nameByPid); } catch { diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 83338140..61c96bc7 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -254,18 +254,19 @@ fn pty_get_cwd( .and_then(|cwd| cwd.as_str().map(String::from))) } +// Mirrors `OPEN_PORT_TIMEOUT_MS` in `lib/src/lib/platform/types.ts` — keep in sync. +const OPEN_PORT_TIMEOUT_MS: u64 = 3000; + #[tauri::command] fn pty_get_open_ports( state: tauri::State<'_, SidecarState>, id: String, ) -> Result { - // Enumeration shells out to lsof / PowerShell, so allow more headroom than - // the 1s default used by most sidecar queries. let response = request_from_sidecar_timeout( &state, "pty:getOpenPorts", serde_json::json!({ "id": id }), - Duration::from_secs(8), + Duration::from_millis(OPEN_PORT_TIMEOUT_MS), )?; Ok(response .get("ports") diff --git a/vscode-ext/src/pty-manager.ts b/vscode-ext/src/pty-manager.ts index 79dc6284..6011c246 100644 --- a/vscode-ext/src/pty-manager.ts +++ b/vscode-ext/src/pty-manager.ts @@ -2,6 +2,7 @@ import { fork, ChildProcess } from 'child_process'; import * as path from 'path'; import { log } from './log'; import type { OpenPort } from '../../lib/src/lib/platform/types'; +import { OPEN_PORT_TIMEOUT_MS } from '../../lib/src/lib/platform/types'; export interface PtyCallbacks { onData(id: string, data: string): void; @@ -240,11 +241,10 @@ export function getCwd(id: string): Promise { export function getOpenPorts(id: string): Promise { return new Promise((resolve) => { sendToChild({ type: 'getOpenPorts', id }); - // Port enumeration shells out on macOS/Windows; allow more headroom than getCwd. const timeout = setTimeout(() => { child?.off('message', handler); resolve([]); - }, 4000); + }, OPEN_PORT_TIMEOUT_MS); const handler = (msg: any) => { if (msg.type === 'openPorts' && msg.id === id) { clearTimeout(timeout);