Files
dsh_ai1net_server/交付物/棒0-网关端口发现-20260928.mjs
T

288 lines
11 KiB
JavaScript
Raw Normal View History

#!/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 <netstat -ano 的落盘文本> \
* --tasklist-file <tasklist /FO CSV /NH 的落盘文本> \
* --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<pid, imageName>;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<pid, ppid>;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);
}