Files
dsh_ai1net_server/交付物/棒0-网关端口发现-20260928.mjs
T
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

289 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
}