Files
HomeAgent/cmd/gui/device-align.test.mjs
JianFeeeee 90880b135e fix(remotedevice): 补 omniparse 能力 + 设备桥对齐门禁
## 要判的是什么

GUI(cmd/gui/main.js)作为**设备端**被 agent 驱动,走的是 WS 上行/下行的
op 消息,与服务端 remotedevice 插件共享同一套能力契约。这条通道此前
**没有任何强制手段** —— endpoint-align.test.mjs 只守 REST 面
(api("...") 对齐 webui 的 mux.HandleFunc),明确不碰设备桥。

两套实现(Go client / GUI JS / 鸿蒙 ETS)全靠注释人工对齐
(main.js:1367「与鸿蒙端 DeviceBridge.ets:209 对齐」)。

## 真实漂移(2026-10-05 核实)

GUI caps 含 "omniparse",executeHomeagentCmd 有完整 case(调 PowerShell
+ ha_omniparse_*.ps1)。但 capabilityTools 里没有这个键,整个
remotedevice 包 grep omniparse 命中 0 ⇒ deviceSupportsTool 恒 false
⇒ agent 永远不会下发 ⇒ 那段实现是死代码。

修复:capabilityTools 补键,使其可被下发。

## 删 devicebridge_dll.js

三条都查证过:
  1. 无人引用 —— 全仓 grep DeviceBridgeDLL 命中 0;
  2. 必然抛 —— start() 无条件 throw 'koffi not fully implemented',
     且 loadFFI() 的 ffi-napi 备选分支只 return true 不返回 lib;
  3. 方向已废弃 —— GUI 走 main.js:999 手工构造的原生 WebSocket
     (Sec-WebSocket-Key/Version 握手),不经 FFI。

⚠ koffi **依赖保留**:main.js:1873 用 koffi.load("user32.dll") 做
computeruse 的鼠标键盘注入,那是真实在用的,与本文件无关。

## 撤回两个误判

写门禁初版时判错两处,均已在判据注释里记录理由:

  - 「GUI 声明 cmdrun/cmdresult 会绕过能力矩阵」是错的。device_ctl_cmdrun
    (device.go:270)是所有能力的**统一下发通道**:agent 调
    computeruse/screensue 最终都由它下发 homeagent-*,SupportsTool 查的是
    具体能力名而非 cmdrun。声明 cmdrun 是对的。
  - 「status/deviceinfo 服务端不认」也是错的。二者是元信息通道而非设备
    工具:status 是心跳上报(registry.go:831),deviceinfo 的实现
    (device.go:338)直接回显 meta.Caps 原始值、不经 deviceSupportsTool。
    判据改为区分 capabilityTools(可裁剪能力)与 metaCaps(元信息通道)。

## 门禁(cmd/gui/device-align.test.mjs)

从 registry.go / protocol.go / main.js 三方**源码文本**提取,不抄逻辑
重写 —— 同 endpoint-align 路子。9 条判据覆盖:cap 双向识别、capability-
> 实现的一致性、protocol.go 消息类型落点、多处 hello 的 caps 一致性、
DLL 桩守卫。

变异测试验证非假红:撤掉服务端 omniparse ⇒ 第 3、5 条立刻转红;
只改第二处 caps ⇒ 第 9 条报「第 2 处起有 1 处不一致」。

## 验证

- node device-align.test.mjs ⇒ 11 条全绿
- gofmt -l registry.go ⇒ 干净
- go vet ./internal/plugins/remotedevice/ ⇒ 本包无告警

⚠ go test ./internal/plugins/remotedevice/ 未能跑通:internal/agent/api 的
cgo 构建失败(codec_cgo.go 静态断言 + C 符号解析)。已确认该失败在**未
改动的 main 上同样存在**,需先构建 csrc 静态库,是既有环境前置条件,
与本次改动无关。

顺带同步 cmd/gui/package-lock.json 版本号 1.0.0 → 1.4.0,与
cmd/gui/package.json 对齐。
2026-10-05 10:27:13 +08:00

413 lines
17 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.

// DeviceBridge 能力对齐门禁:GUI ↔ remotedevice 插件 ↔ devicebridge 客户端库。
//
// ## 为什么需要这个门禁(endpoint-align.test.mjs 覆盖不到的那一半)
//
// endpoint-align.test.mjs 守的是 **REST 面**:GUI 的 api("...") 对齐 webui 的
// mux.HandleFunc。它明确不碰设备桥 —— 而设备桥是 GUI 作为**设备端**被
// agent 驱动的唯一通道,走的是 WS 上行/下行的 op 消息,两端没有任何
// 共享契约文件,全靠注释人工对齐。
//
// 真实漂移实例(2026-10-05 核实):
//
// 1. GUI main.js 声明 caps 含 "omniparse",且 executeHomeagentCmd 有完整
// case "omniparse"(调 PowerShell + ha_omniparse_*.ps1)。但服务端
// registry.go 的 capabilityTools 里**没有 omniparse 这个键**,整个
// remotedevice 包 grep omniparse 命中 0 次 ⇒ deviceSupportsTool 对它
// 永远返回 false ⇒ agent 永远不会下发 omniparse ⇒ 那段实现是死代码。
//
// 2. cmd/gui/devicebridge_dll.js 声明了 12 个 devicebridge_* 导出,但
// start() 无条件 `throw new Error('koffi not fully implemented')`,
// 且 loadFFI() 的 ffi-napi 备选分支只 return true 不返回 lib。
// 且全仓无人引用它 —— GUI 实际走 main.js:999 手工构造的原生 WebSocket。
// 已于 fix/devicebridge-capability-align 删除该文件。
// (koffi 依赖保留:main.js:1873 用它 load user32.dll 做 computeruse 注入。)
//
// 3. GUI caps 里同时声明了 cmdrun/cmdresult(compatFullCaps,历史全能力
// 标记)与完整的 computeruse/screensue/... 精确能力。初判为「精确声明
// 被绕过」,**实为误判**:device_ctl_cmdrun 是所有能力的统一下发通道,
// agent 调 computeruse/screensue 最终都由它下发 homeagent-*,
// SupportsTool 查的是具体能力名而非 cmdrun。声明 cmdrun 是对的。
//
// 4. GUI caps 里的 status / deviceinfo 不在 capabilityTools 矩阵里。初判为
// 「服务端不认识的漂移」,**实为误判**:二者是元信息通道而非设备工具 ——
// status 是心跳上报(registry.go:831),deviceinfo 的实现(device.go:338)
// 直接回显 meta.Caps 原始值、不经 deviceSupportsTool。
//
// 这些都不报错、不告警,GUI 照常运行,只是某些能力永远不会被触发。
//
// ## 与既有判据同路子
//
// endpoint-align / sse-backoff / retry-guard 都是:从**真实源码**提取,
// 不抄一份逻辑重写(抄的那份会和真实代码漂移,而漂移正是要防的)。
// 运行时探测在这里同样不可用:omniparse 是否可达取决于 agent 何时调用
// 哪个工具(LLM 决策),不是能构造出来的固定条件。
//
// 运行:node device-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 MAIN_JS = join(here, "main.js");
const REGISTRY_GO = join(repoRoot, "internal", "plugins", "remotedevice", "registry.go");
const PROTOCOL_GO = join(
repoRoot,
"internal",
"devicebridge",
"client",
"protocol.go",
);
const DLL_JS = join(here, "devicebridge_dll.js");
let failures = 0;
const check = (name, ok, detail) => {
if (ok) {
console.log(` ✓ ${name}`);
} else {
failures++;
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
}
};
// ── 1) 读源码 ───────────────────────────────────────────────────────
for (const [label, p] of [
["GUI 主进程", MAIN_JS],
["服务端能力矩阵", REGISTRY_GO],
["客户端协议", PROTOCOL_GO],
]) {
if (!existsSync(p)) {
console.error(`找不到${label}:${p}`);
process.exit(1);
}
}
const mainSrc = readFileSync(MAIN_JS, "utf8");
const registrySrc = readFileSync(REGISTRY_GO, "utf8");
const protocolSrc = readFileSync(PROTOCOL_GO, "utf8");
// ── 2) 抽能力集合 ───────────────────────────────────────────────────
// 服务端 capabilityTools 的键。形如:
//
// var capabilityTools = map[string][]string{
// "screen": {"screensue", "screensee"},
// ...
// }
//
// 只在 map 字面量的键位置上匹配(行首 tab + 引号 + 冒号),
// 不去匹配值位置的字符串,否则 tools 列表会被当成 cap。
function serverCaps() {
const start = registrySrc.indexOf("var capabilityTools");
if (start < 0) return null;
// 找 map 字面量结尾 "\n}",范围收窄以免吃到后面的 map
const end = registrySrc.indexOf("\n}", start);
if (end < 0) return null;
const body = registrySrc.slice(start, end);
const out = new Set();
const re = /^\t"([a-z]+)":/gm;
let m;
while ((m = re.exec(body)) !== null) out.add(m[1]);
return out;
}
// compatFullCaps:声明了这些的设备视为全能力,不参与能力裁剪。
function compatCaps() {
const start = registrySrc.indexOf("var compatFullCaps");
if (start < 0) return new Set();
const end = registrySrc.indexOf("\n}", start);
const body = registrySrc.slice(start, end < 0 ? undefined : end);
const out = new Set();
const re = /"([a-z]+)":\s*true/g;
let m;
while ((m = re.exec(body)) !== null) out.add(m[1]);
return out;
}
// metaCaps:服务端消费但不参与 deviceSupportsTool 裁剪的 cap。
//
// 这类 cap 不是设备工具,而是**元信息通道**,拿 capabilityTools 去比对必然误报:
//
// - status:registry.go 收设备的 {"op":"status"} 上报刷 LastSeen(心跳),
// 不在工具面。
// - deviceinfo:是 agent 工具名(device.go:168),但 deviceinfo 的实现
// (device.go:338 info())直接读 meta.Caps 原始值回显,**不经过**
// deviceSupportsTool —— 它要的就是「完整 caps」,正是能力裁剪的反面。
//
// 所以「服务端能不能识别一个 cap」有两个不同的问题:能被裁剪矩阵识别(capabilityTools),
// 与被别处消费(metaCaps)。只查前者会把这两个误判成漂移。
const metaCaps = new Set(["status", "deviceinfo"]);
// GUI 声明的 caps。main.js 里有**两处** hello 消息(首次连接 + set authorized
// 后重发),两处必须一致 —— 不一致会让服务端在不同时机看到不同的能力集。
function guiCaps() {
const out = new Set();
const re = /caps:\s*\[([\s\S]*?)\]/g;
let m;
while ((m = re.exec(mainSrc)) !== null) {
const body = m[1];
const item = /"([a-z]+)"/g;
let c;
while ((c = item.exec(body)) !== null) out.add(c[1]);
}
return out;
}
const caps = serverCaps();
const compat = compatCaps();
const gui = guiCaps();
check(
"能抽到服务端能力矩阵",
caps && caps.size >= 8,
caps ? `只抽到 ${caps.size} 条 —— 正则可能已与 registry.go 漂移` : "找不到 capabilityTools",
);
check(
"能抽到 GUI caps",
gui.size >= 8,
`只抽到 ${gui.size} 条 —— 正则可能已与 main.js 漂移`,
);
// ── 3) GUI 声明的每个 cap 服务端都要认识 ────────────────────────────
//
// 这是**硬门禁**:GUI 声明一个服务端不认识的 cap,服务端不会报错(未知 cap
// 只是匹配不到任何 tool),但依赖它的能力永远不会被下发 ⇒ 静默失效。
//
// 注意语义:命中 compatFullCaps 的 cap(cmd/cmdrun/cmdresult)在服务端
// 眼里是「全能力」,本身不算漂移 —— 第 4) 条单独盯它。
const unknown = [...gui].filter(
(c) => !caps.has(c) && !compat.has(c) && !metaCaps.has(c),
);
check(
"GUI 声明的 cap 服务端都认识",
unknown.length === 0,
unknown.length
? "服务端无此能力(对应实现永远不会被下发):\n - " + unknown.join("\n - ")
: "",
);
// ── 4) GUI 的能力声明必须真的起作用(不得整体退化为全能力)──────────
//
// ★ 早期版本这条判据说「GUI 不该声明 cmdrun/cmdresult」是**错的**,已撤回。
// 错在哪:device_ctl_cmdrun(device.go:270)是所有设备能力的**统一下发通道**
// ——agent 对 computeruse/screensue/clipboardsue 的每次调用,最终都是
// cmdrun 下发一条 homeagent-* 命令。SupportsTool 查的 tool 名是
// computeruse 这类具体能力,不是 cmdrun。所以 GUI 声明 cmdrun 是对的。
//
// 真正的问题是反向的失效:GUI 若**只**声明 compatFullCaps
// (cmd/cmdrun/cmdresult)而漏掉具体能力,那 deviceSupportsTool 首循环
// 就 return true,精确声明形同虚设;但只要 GUI 声明了具体能力,服务端
// 就已经在按矩阵裁剪了,compat 项并不改变这一点(首个 return true 只在
// 没有任何已知能力时才会「意外」发生,而 GUI 明确声明了 12 个能力,
// 其中多数命中 capabilityTools)。
//
// 所以这条判据改为检查**真正会静默失效的形态**:声明了具体能力却同时
// 带着 compat 标记,使矩阵裁剪与实际能力不一致 —— 即 GUI 少声明了某个
// 它已实现的能力,但 compat 标记让服务端误以为它有。
//
// 若将来 GUI 要作为「只能跑 shell、无屏幕能力」的设备存在(即真的只有
// compat 能力),请连同下面这条判据一起删,并在注释里写明理由。
const guiHasConcrete = [...gui].filter((c) => caps.has(c));
const guiHasCompat = [...gui].filter((c) => compat.has(c));
check(
"GUI 能力声明不退化为 compat-only",
!(guiHasCompat.length > 0 && guiHasConcrete.length === 0),
guiHasCompat.length > 0 && guiHasConcrete.length === 0
? `只声明了 ${guiHasCompat.join(", ")} ⇒ deviceSupportsTool 首循环 return true,` +
"服务端无法按能力裁剪"
: "",
);
// ── 5) GUI 实现的每个命令都要对应一个服务端能力 ────────────────────
//
// executeHomeagentCmd 的 case 分支名就是设备桥命令名。服务端只会下发
// capabilityTools 值里的工具名(经 device_ctl_* 工具面),所以 GUI
// 实现了一个服务端没有对应工具的命令 ⇒ 永远收不到。
function guiCommandCases() {
const start = mainSrc.indexOf("function executeHomeagentCmd");
if (start < 0) return null;
// 函数体到下一个顶层 function 声明为止
const rest = mainSrc.slice(start);
const end = rest.indexOf("\nfunction ");
const body = end < 0 ? rest : rest.slice(0, end);
const out = new Set();
// 只取 switch 里的 case(缩进 4 空格),避免抓到嵌套 switch(computeruse
// 内部还有一层 switch,case move/click/... 是动作名不是命令名)
const re = /^ {4}case "([a-z]+)":/gm;
let m;
while ((m = re.exec(body)) !== null) out.add(m[1]);
return out;
}
// 服务端可下发的工具名 = capabilityTools 所有 value 的并集。
function serverTools() {
const start = registrySrc.indexOf("var capabilityTools");
if (start < 0) return new Set();
const end = registrySrc.indexOf("\n}", start);
const body = registrySrc.slice(start, end);
const out = new Set();
const re = /"([a-z]+)"/g;
let m;
while ((m = re.exec(body)) !== null) out.add(m[1]);
return out;
}
const cmdCases = guiCommandCases();
const tools = serverTools();
check(
"能抽到 GUI 命令 case",
cmdCases && cmdCases.size >= 6,
cmdCases ? `只抽到 ${cmdCases.size} 条 —— 正则可能已与 main.js 漂移` : "找不到 executeHomeagentCmd",
);
const orphanCmds = cmdCases ? [...cmdCases].filter((c) => !tools.has(c)) : [];
check(
"GUI 实现的命令服务端都有对应工具",
orphanCmds.length === 0,
orphanCmds.length
? "服务端无对应工具(下发不了):\n - " + orphanCmds.join("\n - ")
: "",
);
// ── 6) 声明了能力就必须实现它 ───────────────────────────────────────
//
// 反向:GUI 在 caps 里声明了 X,executeHomeagentCmd 却没有 case "X"
// ⇒ 服务端会下发 X(因为 capabilityTools 里 X 是合法工具),GUI 落到
// default 分支。这是**运行时空转**:req_id 收不到回执,网关侧超时。
const declaredButNotImplemented = [...gui]
.filter((c) => tools.has(c) && !compat.has(c) && cmdCases && !cmdCases.has(c));
check(
"声明的能力都已实现",
declaredButNotImplemented.length === 0,
declaredButNotImplemented.length
? "声明了但无 case 实现:\n - " + declaredButNotImplemented.join("\n - ")
: "",
);
// ── 7) devicebridge_dll.js 不得回来 ──────────────────────────────────
//
// ★ 这个文件已于 fix/devicebridge-capability-align 删除。删除理由(三条都查证过):
//
// 1. 无人引用:全仓(除本判据外)grep DeviceBridgeDLL 命中 0。
// 2. 必然抛:start() 无条件 throw 'koffi not fully implemented'。
// 3. 方向已废弃:GUI 走的是 main.js:999 手工构造的原生 WebSocket
// (Sec-WebSocket-Key/Version 握手),不经 FFI。
//
// ⚠ 但 koffi **依赖本身要留着**:main.js:1873 用它 koffi.load("user32.dll")
// 做 computeruse 的鼠标键盘注入。那是真实在用的,与本文件无关。
// 删文件不等于删依赖。
//
// 判据保留为反向守卫:文件若被重新引入,必须是完整实现而非抛异常的桩。
if (!existsSync(DLL_JS)) {
console.log(" ✓ devicebridge_dll.js 已删除(GUI 走原生 WebSocket,不经 FFI)");
} else {
const dllSrc = readFileSync(DLL_JS, "utf8");
const stubs = [
/throw new Error\(["']koffi not fully implemented["']\)/,
/TODO:\s*实现 koffi 调用/,
/TODO:.*FFI/,
].filter((re) => re.test(dllSrc));
check(
"devicebridge_dll.js 不是未实现桩",
stubs.length === 0,
stubs.length
? "存在未实现桩(require 即抛)。走 FFI 前补全,或删除该文件改用已实现的路径"
: "",
);
}
// ── 8) protocol.go 的消息类型要在 GUI 侧有落点 ──────────────────────
//
// DataStart/DataEnd/SpeechStart/SpeechEnd/EventMsg/StatusMsg 是设备桥
// 上行的消息契约。GUI 若不实现其中任何一个,对应能力(媒体回传、TTS
// 播放、主动事件上报)就是哑的 —— 而 GUI 的 caps 里声明了 camerasue,
// camerasue 正是靠 sendDeviceDataChunked 走 DataStart/DataEnd。
//
// 只判「caps 声明了该能力 ⇒ 相关消息类型必须有实现」。
const protocolTypes = new Set();
{
const re = /^type\s+([A-Z]\w+)\s+struct/mg;
let m;
while ((m = re.exec(protocolSrc)) !== null) protocolTypes.add(m[1]);
}
check(
"能抽到 protocol.go 消息类型",
protocolTypes.size >= 6,
`只抽到 ${protocolTypes.size} 条 —— 正则可能已与 protocol.go 漂移`,
);
// 媒体回传能力 → 必须有 DataStart/DataEnd 实现
if (gui.has("camerasue")) {
const hasDataStart = /DataStart|data_start|dataStart/i.test(mainSrc);
const hasDataEnd = /DataEnd|data_end|dataEnd/i.test(mainSrc);
check(
"声明 camerasue ⇒ 已实现媒体分块回传",
hasDataStart && hasDataEnd,
!hasDataStart || !hasDataEnd
? "camerasue 依赖 cmd_data_start/cmd_data_end 分块回传,但 GUI 侧找不到实现"
: "",
);
} else {
console.log(" · GUI 未声明 camerasue,跳过媒体回传判据");
}
// ── 9) 两处 hello 的 caps 必须一致 ──────────────────────────────────
//
// main.js 有两处 caps 数组(首次连接 / set authorized 后重发)。不一致
// ⇒ 服务端在不同时机看到不同的能力集,表现为「改授权后能力变了」这类
// 难复现的偶发问题。
const capsBlocks = [];
{
const re = /caps:\s*\[([\s\S]*?)\]/g;
let m;
while ((m = re.exec(mainSrc)) !== null) {
const set = new Set();
const item = /"([a-z]+)"/g;
let c;
while ((c = item.exec(m[1])) !== null) set.add(c[1]);
capsBlocks.push(set);
}
}
if (capsBlocks.length < 2) {
console.log(` · 只找到 ${capsBlocks.length} 处 caps 声明,跳过一致性判据`);
} else {
const [first, ...rest] = capsBlocks;
const inconsistent = rest.filter(
(b) => b.size !== first.size || [...b].some((v) => !first.has(v)),
);
check(
"多处 hello 的 caps 声明一致",
inconsistent.length === 0,
inconsistent.length
? `第 2 处起有 ${inconsistent.length} 处与第 1 处不一致`
: "",
);
}
// ── 汇总 ────────────────────────────────────────────────────────────
console.log("");
console.log(
` 服务端能力 ${caps ? caps.size : 0} 个 / compat ${compat.size} 个 / ` +
`可下发工具 ${tools.size} 个 / GUI caps ${gui.size} 个 / GUI 实现 ${cmdCases ? cmdCases.size : 0} 个`,
);
console.log("");
if (failures > 0) {
console.log(`失败 ${failures} 条`);
process.exit(1);
}
console.log("全部通过");