- 变更规模:新增 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/ 知识文件,按口径入库)
289 lines
11 KiB
JavaScript
289 lines
11 KiB
JavaScript
#!/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);
|
||
}
|