#!/usr/bin/env node /** * 棒 0 · WorkBuddy gateway 端口发现(架构定稿 v2 §5.2) * ------------------------------------------------------------------ * 为什么需要:WorkBuddy gateway 的端口 **每次重启都变**(实测 * 50753 → 59914 → 60473 → 58717 → 52954)⇒ 任何写死端口的做法必炸。 * * 三判据(必须**同时**成立,缺一不可 —— 防"随手抓一个像的"): * ① 监听者是 `WorkBuddy.exe`(或它的后代进程) * ② `GET /` ⇒ 200 且正文含 `CodeBuddy Gateway` / `CodeBuddy Remote Control` * ③ `GET /api/v1/health` ⇒ 401(路由在、需鉴权) * * fail-closed:找不到 ⇒ 返回 null + 具名错误码,⛔ **绝不沿用上次端口**。 * * 零第三方依赖(只用 node:net / node:http / node:child_process), * 与 device-shim「零依赖、不引 schemastery」的既有约定一致。 * * 用法: * node 棒0-网关端口发现-20260928.mjs # 人读输出 * node 棒0-网关端口发现-20260928.mjs --json # 机读输出(供垫片 import/调用) * node 棒0-网关端口发现-20260928.mjs \ * --netstat-file \ * --tasklist-file \ * --parents-file <"pid ppid" 每行一条的落盘文本> * ⇧ 受限环境逃生口:某些被托管的运行时**不许本进程 spawn 系统命令** * (实测:在 WorkBuddy 的 AI 沙箱里 Node spawn netstat/tasklist 恒 `EBUSY`, * 而同一沙箱里 Python `subprocess.run(["netstat","-ano"])` 正常)⇒ * 那种环境下由外层抓一次落文件,再喂进来。三个都不给 = 脚本自己 spawn。 * 退出码:找到 = 0 ;找不到 = 3(具名失败,便于 fail-closed 分支) * ------------------------------------------------------------------ */ import http from 'node:http'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; const GATEWAY_HOST = '127.0.0.1'; const ROOT_HOSTNAME = 'WorkBuddy.exe'; const BODY_MARKERS = ['CodeBuddy Gateway', 'CodeBuddy Remote Control']; const PROBE_TIMEOUT_MS = 1200; /** 具名错误码 —— ⛔ 不要用笼统的 "not found" 把三类失败混成一种 */ export const DISCOVERY_ERRORS = { NO_LOOPBACK_LISTENER: 'no-loopback-listener', // netstat 里一个候选都没有 NOT_WORKBUDDY: 'not-workbuddy-process', // 有候选但监听者不是 WorkBuddy 系 FINGERPRINT_MISMATCH: 'fingerprint-mismatch', // 进程对了但首页/health 指纹不符 ENUMERATION_FAILED: 'enumeration-failed', // netstat / tasklist 本身跑不起来 }; /* ------------------------------------------------------------------ */ /* ① 列举回环监听 + 进程名 + 父链 */ /* ------------------------------------------------------------------ */ function run(cmd, args) { return execFileSync(cmd, args, { encoding: 'utf8', timeout: 10_000, windowsHide: true, maxBuffer: 8 * 1024 * 1024, }); } /** netstat -ano ⇒ [{ port, pid }],只取 127.0.0.1 上的 LISTENING */ function listLoopbackListeners({ netstatText = null } = {}) { let out; if (netstatText != null) { out = netstatText; // 逃生口:外层已抓好的文本 } else { try { out = run('netstat', ['-ano']); } catch (e) { throw Object.assign(new Error(DISCOVERY_ERRORS.ENUMERATION_FAILED), { code: DISCOVERY_ERRORS.ENUMERATION_FAILED, cause: e, }); } } const seen = new Set(); const rows = []; for (const line of out.split(/\r?\n/)) { if (!/LISTENING/i.test(line)) continue; const m = line.match(/^\s*TCP\s+(\S+):(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/i); if (!m) continue; const [, addr, port, pid] = m; if (addr !== GATEWAY_HOST) continue; // ⛔ 只要回环,别把可路由地址当候选 const key = `${port}/${pid}`; if (seen.has(key)) continue; seen.add(key); rows.push({ port: Number(port), pid: Number(pid) }); } return rows; } /** tasklist ⇒ Map;tasklistText 非空 ⇒ 直接解析喂入的文本 */ function processNames(tasklistText = null) { const map = new Map(); let out; if (tasklistText != null) { out = tasklistText; } else { try { out = run('tasklist', ['/FO', 'CSV', '/NH']); } catch { return map; // 退化:名字查不到 ⇒ 判据① 走 strict,见 isWorkBuddyTree() } } for (const line of out.split(/\r?\n/)) { const m = line.match(/^"([^"]+)","(\d+)"/); if (m) map.set(Number(m[2]), m[1]); } return map; } /** 父链 ⇒ Map;parentsText 非空 ⇒ 直接解析喂入的 "pid ppid" 行 */ function parentMap(parentsText = null) { const map = new Map(); let out; if (parentsText != null) { out = parentsText; } else { try { out = run('powershell', [ '-NoProfile', '-NonInteractive', '-Command', 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }', ]); } catch { return map; // 拿不到就当只有严格匹配 —— 宁可窄,不可宽 } } for (const line of out.split(/\r?\n/)) { const m = line.trim().match(/^(\d+)\s+(\d+)$/); if (m) map.set(Number(m[1]), Number(m[2])); } return map; } /** * 判据①:监听者是不是 WorkBuddy 进程树里的。 * 严格命中 = 自身 `WorkBuddy.exe`;放宽命中 = 祖先链上出现 `WorkBuddy.exe`。 * ⚠️ 父链拿不到时**只认严格命中**(fail-closed 方向的偏差)。 */ function isWorkBuddyTree(pid, names, parents, rootName) { if (names.get(pid) === rootName) return { ok: true, how: 'self' }; let cur = pid; const guard = new Set(); while (parents.has(cur) && !guard.has(cur)) { guard.add(cur); const up = parents.get(cur); if (!up || up === 0) break; if (names.get(up) === rootName) return { ok: true, how: `ancestor:${up}` }; cur = up; } return { ok: false, how: names.get(pid) ?? 'unknown' }; } /* ------------------------------------------------------------------ */ /* ②③ HTTP 指纹 */ /* ------------------------------------------------------------------ */ function httpProbe(port, path) { return new Promise((resolve) => { const req = http.request( { host: GATEWAY_HOST, port, path, method: 'GET', timeout: PROBE_TIMEOUT_MS }, (res) => { let body = ''; res.setEncoding('utf8'); res.on('data', (c) => { if (body.length < 8192) body += c; }); res.on('end', () => resolve({ status: res.statusCode, body })); res.on('error', () => resolve({ status: res.statusCode, body })); }, ); req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); }); req.on('error', () => resolve({ status: 0, body: '' })); req.end(); }); } /* ------------------------------------------------------------------ */ /* 主流程 */ /* ------------------------------------------------------------------ */ export async function discoverGateway({ rootName = ROOT_HOSTNAME, netstatText = null, tasklistText = null, parentsText = null } = {}) { const attempts = []; let listeners; try { listeners = listLoopbackListeners({ netstatText }); } catch (e) { return { ok: false, error: DISCOVERY_ERRORS.ENUMERATION_FAILED, detail: String(e.message ?? e), attempts }; } if (listeners.length === 0) { return { ok: false, error: DISCOVERY_ERRORS.NO_LOOPBACK_LISTENER, detail: 'netstat 无 127.0.0.1 上的 LISTENING', attempts }; } const names = processNames(tasklistText); const parents = parentMap(parentsText); const parentChainAvailable = parents.size > 0; let sawWorkBuddy = false; for (const { port, pid } of listeners) { const tree = isWorkBuddyTree(pid, names, parents, rootName); if (!tree.ok) { attempts.push({ port, pid, image: tree.how, verdict: 'skip:not-workbuddy' }); continue; } sawWorkBuddy = true; const root = await httpProbe(port, '/'); const health = await httpProbe(port, '/api/v1/health'); const fingerprintOk = root.status === 200 && BODY_MARKERS.some((m) => root.body.includes(m)); const healthOk = health.status === 401; if (fingerprintOk && healthOk) { return { ok: true, port, pid, matchedBy: tree.how, evidence: { listenerImage: names.get(pid) ?? '(unknown)', rootStatus: root.status, rootMarkerHit: BODY_MARKERS.find((m) => root.body.includes(m)), healthStatus: health.status, }, parentChainAvailable, attempts, }; } attempts.push({ port, pid, image: names.get(pid) ?? '(unknown)', verdict: 'skip:fingerprint', rootStatus: root.status, healthStatus: health.status, }); } return { ok: false, error: sawWorkBuddy ? DISCOVERY_ERRORS.FINGERPRINT_MISMATCH : DISCOVERY_ERRORS.NOT_WORKBUDDY, detail: sawWorkBuddy ? '有 WorkBuddy 系监听者,但首页正文 / health=401 指纹不符' : `回环上有 ${listeners.length} 个监听者,但都不是 ${rootName}(或其后代)`, parentChainAvailable, attempts, }; } /* ------------------------------------------------------------------ */ // ⚠️ 本文件是中文文件名 ⇒ `import.meta.url` 会被百分号编码, // 直接跟 argv[1] 比字符串会**永远对不上**(曾导致"跑起来零输出")。 // 必须经 fileURLToPath 解码后比 basename。 const invokedDirectly = (() => { if (!process.argv[1]) return false; try { return path.basename(fileURLToPath(import.meta.url)) === path.basename(process.argv[1]); } catch { return false; } })(); if (invokedDirectly) { const asJson = process.argv.includes('--json'); const { readFileSync } = await import('node:fs'); const opt = (flag) => { const i = process.argv.indexOf(flag); return i !== -1 && process.argv[i + 1] ? readFileSync(process.argv[i + 1], 'utf8') : null; }; const r = await discoverGateway({ netstatText: opt('--netstat-file'), tasklistText: opt('--tasklist-file'), parentsText: opt('--parents-file'), }); if (asJson) { process.stdout.write(JSON.stringify(r) + '\n'); } else if (r.ok) { console.log('✅ gateway 已定位'); console.log(` 端口 = ${r.port}`); console.log(` 宿主 pid = ${r.pid} (${r.evidence.listenerImage})`); console.log(` 命中方式 = ${r.matchedBy}`); console.log(` GET / = ${r.evidence.rootStatus} · 正文标记 «${r.evidence.rootMarkerHit}»`); console.log(` /api/v1/health = ${r.evidence.healthStatus}(401 = 路由在、需鉴权)`); console.log(` 父链可用 = ${r.parentChainAvailable}`); } else { console.error(`❌ 未发现 gateway —— error=${r.error}`); console.error(` ${r.detail}`); for (const a of r.attempts) console.error(` · ${JSON.stringify(a)}`); console.error(' ⇒ fail-closed:不启垫片、⛔ 不沿用上次端口'); } process.exit(r.ok ? 0 : 3); }