Files
HomeAgent/cmd/gui/endpoint-align.test.mjs
JianFeeeee 53d9af446c feat(gui): 星图接上 /memory/graph/pulse + 前端依赖本地化 + 端点对齐门禁
GUI 与服务端 WebUI 插件共享同一套 REST 接口,但两边独立演进、没有任何
强制手段。这次核实发现三处真实漂移(服务端 42 路由 / GUI 只用 29)。

1) 星图接上服务端专为它造的轻量活动端点
   服务端 handleMemoryGraphPulse 的注释写了动机:生产实例全量图谱
   408KB / 1151 节点,为了「知道哪些节点是新的」而每 N 秒拉全量是把
   带宽和 JSON.parse 全花在重复数据上;pulse 只回 id+name+type+
   mention_count+updated_at,几百字节 ~ 几 KB,差两个数量级。
   WebUI dashboard 接了它,GUI 此前只在初始化拉一次全量且完全不轮询
   ⇒ 星图停在打开那一刻的快照,agent 后来学的东西它永远看不到。
   现接入:/runtime 3s + /memory/graph/pulse 10s,两路轮询幂等、
   切连接时停;新实体标「生长」;SSE 的 tool_call/stage/agent_output
   也会触发脉冲(用工具名与回复前 60 字当 hint,让点亮落到本轮相关实体)。
   pulse 检出「全量图里没有的新实体」时置脏,由下一次 renderChatStarmap
   惰性重拉全量 —— 不在脉冲回调里直接拉,否则会把轻量活动源变成每 10s
   拉一次 408KB 全量,正好是 pulse 端点要消除的浪费。

2) 前端依赖本地化,顺带修掉一处安全边界
   四个库从 internal/plugins/webui/static/ 复制到 renderer/vendor/
   (字节一致,由门禁钉住),index.html 不再引用任何公网 CDN。
   与服务端同一理由(见 webui/starmap_vendor_test.go 的注释):
   HomeAgent 支持离线/内网部署,换 CDN 只是把同一个赌注重下一遍。
   ★ renderMd() 的净化器缺失分支不再回退到手写正则(剥 <script>/on*=/
   javascript:)—— 那不是完备的 HTML sanitizer,漏 <iframe srcdoc>、
   SVG 内联事件、data: URI 等,而它渲染的是模型输出与记忆文本,
   都算不可信输入。现在直接退纯文本:库已本地化,走不到该分支。

3) 新增 endpoint-align.test.mjs(进 npm test / CI)
   扫描 app.js 的 api("...") 与 handler.go 的 mux.HandleFunc 对账,
   7 条判据。与既有判据同一路子:读真实源码,不抄逻辑重写。
   反向差集只报告不门禁(管理面端点接不接是产品决策),
   只硬钉「服务端已明确为其设计」的 pulse —— 即本判据的由来。
   已做变异测试:改错 pulse 路径、改回 CDN 均能判红。
   protocol-align.test.mjs 是真浏览器判据(需 Electron+Xvfb+真后端),
   CI 明确不跑,所以这类漂移此前无人发现。

顺带:connectSSE 由隐式全局赋值改为 function 声明(加 "use strict"
会在文件靠后处 ReferenceError,报错点离调用点很远)。

判据:cmd/gui 下 npm test 全绿(4 个 .mjs / 25 条)。
2026-10-01 18:23:20 +08:00

232 lines
9.5 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.

// GUI ↔ WebUI 端点对齐门禁。
//
// ## 要判的是什么
//
// GUI(cmd/gui/renderer/app.js)与服务端 WebUI 插件(internal/plugins/webui)
// 是两个独立演进的客户端,它们共享同一套 REST 接口。这个共享关系**没有任何
// 强制手段**:服务端加端点不必通知 GUI,GUI 改端点也不必通知服务端。
//
// 真实漂移实例(2026-10-01 核实):服务端 handler.go 里有 39 个路径,
// GUI 只用 28 个。差集里最刺眼的是 `/memory/graph/pulse` ——
// 服务端**专门为星图「跟随 agent 动」**造这个端点,handler 注释写了动机
// (全量图谱生产实例 408KB / 1151 节点,pulse 只有几 KB,差两个数量级),
// WebUI dashboard 接了它(dashboard.js 里 5 处),GUI 却只在初始化时拉一次
// 全量且完全不轮询 ⇒ GUI 星图停在打开那一刻的快照。
//
// 之前没人发现,是因为 protocol-align.test.mjs 是**真浏览器**判据
// (要 Electron + Xvfb + 真后端),CI 明确不跑。
//
// ## 为什么这个判据必须是「文本扫描」而不是运行时探测
//
// 两个已知端点集合(GUI 的 api("...") 与服务端 mux.HandleFunc)都是**源码文本**
// 里的东西,扫源码就能拿到完整集合,不需要起服务、不需要鉴权。
// 而运行时探测(真连一个后端)在这两个维度上不可用:
// - 要真后端 + 有效凭据(本机实测 admin/admin 已失效);
// - 只能看到「已部署版本」的端点,看不到源码里的 —— 而漂移恰恰
// 发生在「源码已加、客户端还没接」的窗口里,那正是要抓的。
//
// 与既有判据(sse-backoff / retry-guard)同一路子:从**真实源码**提取,
// 不抄一份逻辑重写(抄的那份会和真实代码漂移,而漂移正是本判据要防的)。
//
// 运行:node endpoint-align.test.mjs
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, "..", "..");
const APP = join(here, "renderer", "app.js");
const HANDLER_GO = join(repoRoot, "internal", "plugins", "webui", "handler.go");
let failures = 0;
const check = (name, ok, detail) => {
if (ok) {
console.log(` ✓ ${name}`);
} else {
failures++;
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
}
};
// ── 1) 读源码 ───────────────────────────────────────────────────────
if (!existsSync(HANDLER_GO)) {
console.error(`找不到服务端路由表:${HANDLER_GO}`);
process.exit(1);
}
const appSrc = readFileSync(APP, "utf8");
const handlerSrc = readFileSync(HANDLER_GO, "utf8");
// ── 2) 抽端点集合 ───────────────────────────────────────────────────
// 服务端:mux.HandleFunc("...", ...) 里的第一个参数。
// 只取 /api/v1 与 /v1 前缀的(/files/ /uploads/ /static/ / 是页面与静态资源,
// 不是 GUI 的 api() 目标;带上它们只会制造噪音)。
function serverRoutes() {
const out = new Set();
const re = /mux\.HandleFunc\(\s*"([^"]+)"/g;
let m;
while ((m = re.exec(handlerSrc)) !== null) {
const p = m[1];
if (p.startsWith("/api/v1/") || p.startsWith("/v1/")) out.add(p);
}
return out;
}
// GUI:api("...") 的第一个参数。带 query 串的按 '?' 截断
// (如 "/chat/history?limit=" → "/chat/history")。
//
// ★ 正则必须允许字符串后面跟表达式(+ 拼接):星图 pulse 写成
// api("/memory/graph/pulse?since=" + since),只匹配到右引号为止会漏掉它 ——
// 而本判据的由来恰恰就是这个端点,用一个会漏掉它的正则去防它漏接是自相矛盾。
// (?<![\w.]) 前视是为了避开 cliMap() 里的字符串比较(path === "/status"),
// 那不是 api() 调用。
function guiCalls() {
const out = new Set();
const re = /(?<![\w.])api\(\s*"([^"]*)"/g;
let m;
while ((m = re.exec(appSrc)) !== null) {
out.add(m[1].split("?")[0]);
}
return out;
}
const routes = serverRoutes();
const calls = guiCalls();
check(
"能抽到服务端路由",
routes.size >= 30,
`只抽到 ${routes.size} 条 —— 正则可能已与 handler.go 漂移`,
);
check(
"能抽到 GUI 调用",
calls.size >= 20,
`只抽到 ${calls.size} 条 —— 正则可能已与 app.js 漂移`,
);
// ── 3) GUI 调用的每个端点都必须在服务端存在 ──────────────────────────
//
// 这是**硬门禁**:GUI 调了一个服务端没有的端点 ⇒ 运行时必然 404,
// 属于真缺陷。反向(服务端有、GUI 没接)是能力差距,按需评估。
const missing = [...calls].filter((p) => {
if (routes.has(p)) return false;
// 前缀通配:服务端有 "/api/v1/adapters/" 而 GUI 调 "/adapters"
// → api() 会拼成 /api/v1/adapters,靠 Go 1.22 ServeMux 最长前缀匹配命中。
const withPrefix = "/api/v1" + p;
if (routes.has(withPrefix)) return false;
for (const r of routes) {
if (r.endsWith("/") && withPrefix.startsWith(r)) return false;
}
return true;
});
check(
"GUI 调用的端点服务端都存在",
missing.length === 0,
missing.length
? "服务端无此路由(运行时必然 404):\n - " + missing.join("\n - ")
: "",
);
// ── 4) 关键能力端点必须被 GUI 用上 ──────────────────────────────────
//
// 只判「服务端有 + GUI 必须有」的一小撮**能力面**端点,不是全部差集。
// 理由:差集里的管理面端点(/login /logout /tracker/ /knowledge/tree/
// /memory/text ...)该不该接取决于产品决策,逐条人工判;把这几类硬编码
// 进判据会让人为了让门禁变绿而随手接端点。
//
// 这里只钉**服务端已明确为其设计、且 GUI 已经在用同类能力**的端点:
// 星图活动(pulse)是本判据的由来——服务端专门造它、WebUI 用了、GUI 没接。
// 若将来 GUI 确实不再需要星图,请连同下面这条判据一起删,并在注释里
// 写明理由;不要静默留着一条红的门禁。
const mustUse = [
{
path: "/api/v1/memory/graph/pulse",
why: "星图活动源:服务端为「不重复拉 408KB 全量」专门造的轻量端点",
},
];
const unused = mustUse.filter((m) => {
// mustUse 写的是服务端全路径(/api/v1/…),GUI 侧 api() 传的是
// 去掉 /api/v1 前缀的短路径(api() 内部会拼)。两边都比一次。
const short = m.path.replace(/^\/api\/v1/, "");
return !calls.has(m.path) && !calls.has(short);
}).map((m) => m.why);
check(
"能力面端点未被 GUI 漏接",
unused.length === 0,
unused.length ? unused.join(";") : "",
);
// ── 5) 页面不得再引用公网 CDN ───────────────────────────────────────
//
// 与服务端 starmap_vendor_test.go 同一判据(那里钉 WebUI,这里钉 GUI)。
// HomeAgent 支持离线/内网部署,而前端有 4 个硬依赖在公网上时,
// 断网/出口受限环境下星图必坏、且用户无从修复。
const htmlSrc = readFileSync(join(here, "renderer", "index.html"), "utf8");
const cdnRe = /(?:src|href)\s*=\s*["'](https?:\/\/[^"']+)["']/g;
const cdnUrls = [...htmlSrc.matchAll(cdnRe)].map((m) => m[1]);
check(
"index.html 不引用外部 CDN",
cdnUrls.length === 0,
cdnUrls.length ? "仍引用:" + cdnUrls.join(", ") : "",
);
// 本地 vendor 文件必须真的存在(embed/打包漏文件 ⇒ 静默失效)。
const vendorFiles = [
"three.min.js",
"OrbitControls.js",
"marked.min.js",
"purify.min.js",
];
const missingVendor = vendorFiles.filter(
(f) => !existsSync(join(here, "renderer", "vendor", f)),
);
check(
"vendor 第三方库文件都在",
missingVendor.length === 0,
missingVendor.length ? "缺少:" + missingVendor.join(", ") : "",
);
// 两端 vendor 版本必须一致(否则星图/markdown 行为会在两端分叉)。
const serverStatic = join(repoRoot, "internal", "plugins", "webui", "static");
const drift = vendorFiles.filter((f) => {
const a = join(here, "renderer", "vendor", f);
const b = join(serverStatic, f);
if (!existsSync(b)) return true;
return readFileSync(a, "utf8") !== readFileSync(b, "utf8");
});
check(
"GUI 与 WebUI 的 vendor 版本一致",
drift.length === 0,
drift.length
? "内容与 internal/plugins/webui/static 不一致:" + drift.join(", ")
: "",
);
// ── 汇总 ────────────────────────────────────────────────────────────
console.log("");
console.log(` 服务端路由 ${routes.size} 条 / GUI 调用 ${calls.size} 条`);
if (missing.length === 0) {
console.log(" 服务端有、GUI 未使用的端点(差集,供人工评估,非门禁):");
const notUsed = [...routes].filter((r) => {
const short = r.replace(/^\/api\/v1\//, "/").replace(/^\/v1\//, "/");
return !calls.has(r) && !calls.has(short);
});
notUsed.slice(0, 14).forEach((p) => console.log(` · ${p}`));
console.log(` (共 ${notUsed.length} 条未使用)`);
}
console.log("");
if (failures > 0) {
console.log(`全部失败:${failures} 条`);
process.exit(1);
}
console.log("全部通过");