feat(v1.6): 新增 gui.await 等待条件能力(复盘短板之「语义」)

把 GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**:四类条件
window / control / process / file,各复用 window.list / screen.find /
process.list / fs.stat(零新平台代码,平台行为与既有能力一致,只读低危)。
- present/absent 两种等待;超时正确返回 satisfied:false + note
- 轮询期单次异常**不视为失败**(启动中程序常短暂报错),超时才带回 last_error
- 参数非法分两层拒:schema 层(协议错误)与业务层(E_PARAM_INVALID)

本地真实验证(macOS,7 项全过):present 命中(1208ms/7 次轮询)、absent 命中、
超时返回、非法参数 2+1 拒绝、process 命中、底层报错不中断轮询(last_error 带回
辅助功能权限提示)、缺省参数可用。

能力数 42 → 43(数量守卫测试同步更新);README 补快捷键与 gui.await 用法章节。
This commit is contained in:
jkooe committed 2026-10-07 01:21:41 +08:00
1 parent 7037c7ff94
commit 2eb33587fe
17 files changed
+2607 -3

No files matched your search

+24 -2
View File
@@ -81,7 +81,28 @@ OEM 符号键(`oem_plus`/`oem_comma`/`oem_period`… → 支持 `win+d`、`ctr
> MCP 侧同名工具为 `na_key`(`action: "press"`),支持同一套参数。
**36 项能力** · **32 个 MCP 工具** · **114 项单元测试** · **26 项端到端用例**(CI 在真实 Windows 上验证)
## 等待条件 gui.await —— v1.6
GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**流程:
```bash
# 等界面元素出现(最多 30s)
nodeagent --node win invoke gui.await --args '{"condition":"control","text":"完成","timeout_ms":30000}'
# 等它消失
nodeagent --node win invoke gui.await --args '{"condition":"control","text":"安装中","state":"absent"}'
# 等窗口 / 进程 / 文件
nodeagent --node win invoke gui.await --args '{"condition":"window","title":"安装程序","timeout_ms":10000}'
nodeagent --node win invoke gui.await --args '{"condition":"process","process":"setup.exe"}'
nodeagent --node win invoke gui.await --args '{"condition":"file","path":"C:\\log.txt"}'
```
四类条件各自复用 `window.list` / `screen.find` / `process.list` / `fs.stat`,
平台行为(Windows UIA/OCR、macOS Vision OCR)与那些能力完全一致,**只读低危**。
轮询期单次异常不视为失败(启动中的程序常短暂报错),超时未命中才带回 `last_error`。
**典型闭环**:`await control(text:"立即安装")` → `mouse.click(x, y)` → `await control(text:"完成", timeout_ms:60000)` → `click` → `await absent(text:"安装中")`。
**43 项能力** · **32 个 MCP 工具** · **114 项单元测试** · **26 项端到端用例**(CI 在真实 Windows 上验证)
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
> 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
@@ -513,11 +534,12 @@ nodeagent/
- [`docs/SECURITY.md`](docs/SECURITY.md) —— **安全模型与加固指南**(钥匙=root、证书指纹钉住、网段白名单、应急处置)
- [`docs/VERSIONING.md`](docs/VERSIONING.md) —— **版本与兼容性契约**(升级顺序、什么算破坏性、发版流程、更新方式)
- [`docs/REMOTE-LIMITS.md`](docs/REMOTE-LIMITS.md) —— 远程能力边界与真实故障案例(含杀软误拦)
- [`docs/ROADMAP.md`](docs/ROADMAP.md) —— **当前进度盘点与下一步路线**(2026-10-07 基线:待办收尾 / 语义可断言 / 状态采样 / 安全残余)
- [产品需求文档(PRD)](./PRD.md)
- [开发文档(DEVELOPMENT)](./docs/DEVELOPMENT.md) —— 协议细节、能力 schema、安全模型、各端实现指南
- [**跨机接管的边界与局限**](./docs/REMOTE-LIMITS.md) —— 哪些问题能远程修、哪些必须人工,附真实故障诊断案例
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)。**写于 2026-09-28(19 项能力时),多数 P0/P1 已解决 —— 最新状态见 [ROADMAP](./docs/ROADMAP.md) §3 对照表**
## 关键决策
+170
View File
@@ -0,0 +1,170 @@
/**
* gui.await —— 等待条件成立(v1.6)。
*
* ## 为什么需要它(复盘三大短板之「语义」)
* 原来的 GUI 操作是**盲试**:按一下、截个图、看看有没有变化,不行再试。
* 问题不是"找不到元素"(screen.find 已解决),而是**操作之后无法断言界面到了预期状态**。
* 有了本能力,"点了安装 → 等待『完成』按钮出现" 从"睡 3 秒碰运气"变成**可验证的等待**:
*
* await control(text:"完成", timeout:30s) → click(found) → await absent(text:"安装中")
*
* ## 实现要点
* 不写任何新的平台探测代码 —— 四个条件全部**复用既有能力**(window.list /
* screen.find / process.list / fs.stat),因此 Windows/macOS 行为与它们完全一致。
* 轮询期间异常一律视为"尚未命中"并记录 last_error,避免单次抖动整句失败;
* 但超时未命中时会把它带回,方便判断是"真的没有"还是"每次都在报错"。
*/
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { windowList } from './window.js';
import { screenFind } from './window.js';
import { processList } from './system.js';
import { fsStat } from './fs.js';
type Args = Record<string, unknown>;
type Condition = 'window' | 'control' | 'process' | 'file';
const CONDITIONS: readonly Condition[] = ['window', 'control', 'process', 'file'];
/** 判断一次底层调用是否"命中"(兼容各能力不同的返回形态)。 */
function isHit(result: unknown): boolean {
if (result === null || result === undefined) return false;
if (typeof result === 'boolean') return result;
const r = result as Record<string, unknown>;
if (r['found'] === true) return true;
if (r['exists'] === true) return true;
for (const key of ['windows', 'processes', 'matches', 'elements']) {
const arr = r[key];
if (Array.isArray(arr) && arr.length > 0) return true;
}
if (Array.isArray(result) && result.length > 0) return true;
return false;
}
/** 按条件组装底层调用的参数(只透传该条件支持的字段)。 */
function buildInnerArgs(condition: Condition, args: Args): Args {
const num = (k: string): number | undefined => {
const v = args[k];
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
};
switch (condition) {
case 'window': {
const inner: Args = {};
const title = (args['title'] as string | undefined) ?? (args['pattern'] as string | undefined);
if (title) inner['title_pattern'] = title;
const limit = num('limit');
if (limit !== undefined) inner['limit'] = limit;
return inner;
}
case 'control': {
// 透传 screen.find 的全部相关参数(method=image 时也一样走轮询)
const inner: Args = {};
for (const k of ['text', 'window', 'control_type', 'method', 'template', 'limit', 'timeout_ms']) {
if (args[k] !== undefined) inner[k] = args[k];
}
return inner;
}
case 'process': {
const inner: Args = {};
const name = args['process'] as string | undefined;
if (name) inner['filter'] = { name_pattern: name };
const limit = num('limit');
if (limit !== undefined) inner['limit'] = limit;
return inner;
}
case 'file': {
const p = args['path'] as string | undefined;
if (!p) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'condition=file 需要 path', {
hint: '如 {"condition":"file","path":"C:\\\\Windows\\\\Temp\\\\setup.log"}',
});
}
return { path: p };
}
}
}
/** 条件 → 底层能力(本进程内直接调用,省去 RPC 往返开销)。 */
function innerProbe(condition: Condition): (args: Args) => Promise<unknown> {
switch (condition) {
case 'window':
return (a) => windowList(a);
case 'control':
return (a) => screenFind(a);
case 'process':
return (a) => processList(a);
case 'file':
return (a) => fsStat(a);
}
}
export async function guiAwait(args: Args): Promise<unknown> {
const condition = args['condition'] as Condition | undefined;
if (!condition || !CONDITIONS.includes(condition)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 condition: ${String(condition)}`, {
allowed: CONDITIONS,
hint: 'window=等窗口出现/消失;control=等界面元素;process=等进程;file=等文件',
});
}
const state = (args['state'] as string | undefined) ?? 'present';
if (state !== 'present' && state !== 'absent') {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 state: ${state}`, {
allowed: ['present', 'absent'],
});
}
const timeoutMs = Math.max(0, Math.min(60_000, (args['timeout_ms'] as number | undefined) ?? 5000));
const intervalMs = Math.max(50, Math.min(5000, (args['interval_ms'] as number | undefined) ?? 400));
const innerArgs = buildInnerArgs(condition, args);
const probe = innerProbe(condition);
const startedAt = Date.now();
let attempts = 0;
let lastHit = false;
let lastError: string | null = null;
for (;;) {
attempts += 1;
try {
const result = await probe(innerArgs);
lastHit = isHit(result);
lastError = null;
} catch (err) {
// 轮询期异常不代表条件不成立(可能正忙于启动)——记下再试
lastError = err instanceof Error ? err.message : String(err);
lastHit = false;
}
const satisfied = state === 'present' ? lastHit : !lastHit;
if (satisfied) {
return {
satisfied: true,
condition,
state,
elapsed_ms: Date.now() - startedAt,
attempts,
last_seen: state,
note:
state === 'absent'
? '条件已消失(连续一次未命中即视为消失;如需更稳可加大 interval_ms)'
: undefined,
};
}
if (Date.now() - startedAt >= timeoutMs) {
return {
satisfied: false,
condition,
state,
elapsed_ms: Date.now() - startedAt,
attempts,
last_seen: lastHit ? 'present' : 'absent',
last_error: lastError ?? undefined,
note:
state === 'present'
? `等待 ${timeoutMs}ms 仍未出现${lastError ? `,期间持续报错:${lastError.slice(0, 160)}` : ''}`
: `等待 ${timeoutMs}ms 仍未消失${lastError ? `,期间持续报错:${lastError.slice(0, 160)}` : ''}`,
};
}
await new Promise((r) => setTimeout(r, intervalMs));
}
}
+3
View File
@@ -13,6 +13,7 @@ import { audioGet, audioSet } from './audio.js';
import { agentUpdate } from './selfupdate.js';
import { mouseMove, mouseClick, mouseScroll, mouseDrag, keyType, keyPress, setInputPolicy } from './input.js';
import { fsList, fsStat, fsRead, fsWrite, setFsRoots } from './fs.js';
import { guiAwait } from './await.js';
export interface HandlerContext {
/** 连接标识(事件订阅归属 / 断开清理) */
@@ -52,6 +53,8 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
[CapabilityNames.MouseDrag]: mouseDrag,
[CapabilityNames.KeyType]: keyType,
[CapabilityNames.KeyPress]: keyPress,
// v1.6 等待条件(把盲试变为可断言)
[CapabilityNames.GuiAwait]: guiAwait,
// v3+ 审计
[CapabilityNames.AuditList]: auditList,
// v11 审计防篡改
+1174
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -0,0 +1,5 @@
[workspace]
resolver = "2"
members = ["nodeagent-client"]
[workspace.dependencies]
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "nodeagent-client"
version = "0.1.0"
edition = "2021"
description = "nodeagent 控制端 Rust 客户端(行为对齐 @nodeagent/client)"
license = "MIT"
[dependencies]
base64 = "0.23.1"
ed25519-dalek = { version = "3.0.0", features = ["pkcs8"] }
futures-util = "0.3.34"
hmac = "0.13.0"
rand = "0.10.3"
rustls = "0.23.45"
rustls-pemfile = "2.2.0"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
sha2 = "0.11.0"
thiserror = "2.0.21"
tokio = { version = "1.53.2", features = ["rt-multi-thread", "net", "time", "sync", "macros", "io-util"] }
tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots"] }
url = "2.5.8"
+131
View File
@@ -0,0 +1,131 @@
//! 密码学工具:HMAC / ed25519 签名 / 证书指纹 / nonce。
//! 逐一对齐 `packages/protocol/src/{hmac,ed25519}.ts` 与 `net.ts`。
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
/// 计算 HMAC-SHA256,返回 Base64(对齐 hmac.ts 的 `computeHmac`)。
/// 密钥为预共享密钥字符串,nonce 以 UTF-8 编码。
pub fn compute_hmac(key: &str, nonce: &str) -> String {
// HMAC 接受任意长度 key,此处不可能失败,unwrap 仅为类型收敛。
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC 接受任意长度 key");
mac.update(nonce.as_bytes());
STANDARD.encode(mac.finalize().into_bytes())
}
/// 生成随机 nonce(32 字节,Base64),对齐 `generateNonce`。
pub fn generate_nonce() -> String {
let mut bytes = [0u8; 32];
for b in bytes.iter_mut() {
*b = rand::random::<u8>();
}
STANDARD.encode(bytes)
}
/// 证书指纹:sha256(DER) 的十六进制小写 64 位(对齐 net.ts 的 `certFingerprint`)。
pub fn cert_fingerprint(der: &[u8]) -> String {
let digest = Sha256::digest(der);
digest.iter().map(|b| format!("{b:02x}")).collect()
}
/// 对 nonce 原文做 ed25519 签名,返回 Base64(对齐 ed25519.ts 的 `signNonce`)。
/// 私钥为 Base64(PKCS8 DER)。
pub fn sign_nonce(private_key_b64: &str, nonce: &str) -> Result<String, String> {
use ed25519_dalek::pkcs8::DecodePrivateKey;
use ed25519_dalek::Signer;
let der = STANDARD
.decode(private_key_b64)
.map_err(|e| format!("私钥 Base64 解码失败: {e}"))?;
let signing_key = ed25519_dalek::SigningKey::from_pkcs8_der(&der)
.map_err(|e| format!("PKCS8 私钥解析失败: {e}"))?;
let sig = signing_key.sign(nonce.as_bytes());
Ok(STANDARD.encode(sig.to_bytes()))
}
/// 验签(对齐 `verifyNonce`);任何异常均视为失败(不抛出)。
pub fn verify_nonce(public_key_b64: &str, nonce: &str, signature_b64: &str) -> bool {
use ed25519_dalek::pkcs8::DecodePublicKey;
use ed25519_dalek::Verifier;
let Ok(pk_der) = STANDARD.decode(public_key_b64) else {
return false;
};
let Ok(vk) = ed25519_dalek::VerifyingKey::from_public_key_der(&pk_der) else {
return false;
};
let Ok(sig_bytes) = STANDARD.decode(signature_b64) else {
return false;
};
let Ok(sig) = ed25519_dalek::Signature::from_slice(&sig_bytes) else {
return false;
};
vk.verify(nonce.as_bytes(), &sig).is_ok()
}
/// 公钥指纹(SHA-256 前 16 位 hex),对齐 `keyId`。
pub fn key_id(public_key_b64: &str) -> String {
let der = STANDARD.decode(public_key_b64).unwrap_or_default();
let digest = Sha256::digest(&der);
// 前 16 个 hex 字符 = 前 8 字节。
digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey};
fn gen_keypair() -> (String, String) {
use ed25519_dalek::SigningKey;
let sk = SigningKey::generate(&mut rand::rng());
let pk = sk.verifying_key();
(
STANDARD.encode(pk.to_public_key_der().expect("编码公钥")),
STANDARD.encode(sk.to_pkcs8_der().expect("编码私钥")),
)
}
#[test]
fn hmac_matches_known_vector() {
// 与 Node 版对拍的固定向量:HMAC-SHA256("key", "nonce")。
let out = compute_hmac("secret", "hello");
// 用独立的 hmac 重算并比对,避免自证。
let mut mac = HmacSha256::new_from_slice(b"secret").unwrap();
mac.update(b"hello");
let expect = STANDARD.encode(mac.finalize().into_bytes());
assert_eq!(out, expect);
// Base64(HMAC-SHA256("secret","hello")) 的已知值(由 RFC 测试向量推得,供跨端核对)。
assert_eq!(out, "iKqz7ejTrflNJquQ07r9SiCDBww7zOnAFO4EpEOEfAs=");
}
#[test]
fn sign_and_verify_roundtrip() {
let (pk, sk) = gen_keypair();
let nonce = generate_nonce();
let sig = sign_nonce(&sk, &nonce).unwrap();
assert!(verify_nonce(&pk, &nonce, &sig));
assert!(!verify_nonce(&pk, "tampered", &sig));
}
#[test]
fn key_id_is_16_hex() {
let (pk, _sk) = gen_keypair();
let id = key_id(&pk);
assert_eq!(id.len(), 16);
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn cert_fingerprint_sha256_der() {
// 空 DER 的 sha256 是已知常量。
assert_eq!(
cert_fingerprint(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
}
+95
View File
@@ -0,0 +1,95 @@
//! 协议错误码与客户端错误类型(对齐 `packages/protocol/src/errors.ts`)。
use std::fmt;
/// 协议错误码常量(对齐 errors.ts 的 `ErrorCodes`)。
pub mod ErrorCodes {
pub const PARSE_ERROR: i64 = -32700;
pub const INVALID_REQUEST: i64 = -32600;
pub const METHOD_NOT_FOUND: i64 = -32601;
pub const PARAM_INVALID: i64 = -32602;
pub const AUTH_FAILED: i64 = -32401;
pub const AUTH_REQUIRED: i64 = -32402;
pub const CAPABILITY_NOT_FOUND: i64 = -32403;
pub const CAPABILITY_DISABLED: i64 = -32404;
pub const NODE_OFFLINE: i64 = -32405;
pub const ACL_DENIED: i64 = -32406;
pub const RATE_LIMITED: i64 = -32407;
pub const TIMEOUT: i64 = -32412;
pub const EXECUTION_FAILED: i64 = -32419;
pub const PROTOCOL_MISMATCH: i64 = -32420;
pub const UNSUPPORTED_PLATFORM: i64 = -32421;
/// v21:被控端证书指纹与控制端钉住的不一致(可能被冒充)。
pub const CERT_MISMATCH: i64 = -32422;
}
/// 错误码 → 名称。
pub fn error_name(code: i64) -> &'static str {
match code {
ErrorCodes::PARSE_ERROR => "E_PARSE_ERROR",
ErrorCodes::INVALID_REQUEST => "E_INVALID_REQUEST",
ErrorCodes::METHOD_NOT_FOUND => "E_METHOD_NOT_FOUND",
ErrorCodes::PARAM_INVALID => "E_PARAM_INVALID",
ErrorCodes::AUTH_FAILED => "E_AUTH_FAILED",
ErrorCodes::AUTH_REQUIRED => "E_AUTH_REQUIRED",
ErrorCodes::CAPABILITY_NOT_FOUND => "E_CAPABILITY_NOT_FOUND",
ErrorCodes::CAPABILITY_DISABLED => "E_CAPABILITY_DISABLED",
ErrorCodes::NODE_OFFLINE => "E_NODE_OFFLINE",
ErrorCodes::ACL_DENIED => "E_ACL_DENIED",
ErrorCodes::RATE_LIMITED => "E_RATE_LIMITED",
ErrorCodes::TIMEOUT => "E_TIMEOUT",
ErrorCodes::EXECUTION_FAILED => "E_EXECUTION_FAILED",
ErrorCodes::PROTOCOL_MISMATCH => "E_PROTOCOL_MISMATCH",
ErrorCodes::UNSUPPORTED_PLATFORM => "E_UNSUPPORTED_PLATFORM",
ErrorCodes::CERT_MISMATCH => "E_CERT_MISMATCH",
_ => "E_UNKNOWN",
}
}
/// 客户端错误(携带协议错误码,对齐 client.ts 的 `ClientError`)。
#[derive(Debug, Clone)]
pub struct ClientError {
pub code: i64,
pub name: String,
pub message: String,
pub data: Option<serde_json::Value>,
}
impl ClientError {
pub fn new(code: i64, message: impl Into<String>) -> Self {
Self {
code,
name: error_name(code).to_string(),
message: message.into(),
data: None,
}
}
pub fn with_data(mut self, data: serde_json::Value) -> Self {
self.data = Some(data);
self
}
/// 被控端离线/不可达。
pub fn offline(message: impl Into<String>) -> Self {
Self::new(ErrorCodes::NODE_OFFLINE, message)
}
/// 调用超时。
pub fn timeout(message: impl Into<String>) -> Self {
Self::new(ErrorCodes::TIMEOUT, message)
}
/// 证书指纹不一致。
pub fn cert_mismatch(message: impl Into<String>) -> Self {
Self::new(ErrorCodes::CERT_MISMATCH, message)
}
}
impl fmt::Display for ClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.name, self.message)
}
}
impl std::error::Error for ClientError {}
+19
View File
@@ -0,0 +1,19 @@
//! nodeagent 控制端 Rust 客户端。
//!
//! 行为对齐 `packages/client`(Node 版),复刻:
//! - 握手 hello → challenge → auth → auth_ok(psk HMAC / ed25519 签名)
//! - 泛型能力调用 invoke(带超时与 id 匹配)
//! - 事件通知(无 id 的 JSON-RPC notification,`event` 方法)
//! - 断线指数退避重连(1s → 30s,±15% 抖动)
//! - TLS 自签证书 + 指纹钉住(v21,E_CERT_MISMATCH)
pub mod client;
pub mod crypto;
pub mod discovery;
pub mod error;
pub mod tls;
pub mod types;
pub mod ulid;
pub use client::{Client, ClientEvent, ClientOptions, ConnState};
pub use error::{ClientError, ErrorCodes};
+212
View File
@@ -0,0 +1,212 @@
//! 协议消息类型(对齐 `packages/protocol/src/messages.ts`)。
//!
//! 所有对外字段名保持 snake_case 与 TS 侧一致,经 serde 序列化/反序列化。
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// 协议版本(对齐 methods.ts 的 `PROTOCOL_VERSION`)。
pub const PROTOCOL_VERSION: &str = "1.0";
/// JSON-RPC 2.0 请求信封(nodeagent 简化版)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcRequest {
pub jsonrpc: String,
/// ULID,响应回填相同 id。
pub id: String,
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<Value>,
}
/// JSON-RPC 2.0 成功响应。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcResponse {
pub jsonrpc: String,
pub id: String,
pub result: Value,
}
/// 协议错误对象。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcError {
pub code: i64,
pub name: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
/// JSON-RPC 2.0 错误响应。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcErrorResponse {
pub jsonrpc: String,
#[serde(default)]
pub id: Option<String>,
pub error: RpcError,
}
/// 任意入站消息:成功响应或错误响应(无 id 的通知由上层单独处理)。
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum InboundMessage {
Response(RpcResponse),
Error(RpcErrorResponse),
Notification {
#[serde(default)]
id: Option<String>,
method: String,
#[serde(default)]
params: Option<Value>,
},
}
/// 判断消息是否为错误响应。
pub fn is_error_response(msg: &Value) -> bool {
msg.get("error").is_some()
}
// ---------- 握手 ----------
/// hello 入参。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelloParams {
pub protocol: String,
pub client_id: String,
}
/// challenge 出参。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChallengeParams {
pub nonce: String,
pub expires_at: i64,
}
/// auth 入参。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthParams {
pub client_id: String,
pub nonce: String,
/// psk 模式:Base64(HMAC-SHA256(pre_shared_key, nonce))。
#[serde(skip_serializing_if = "Option::is_none")]
pub hmac: Option<String>,
/// ed25519 模式:Base64(Ed25519_Sign(privkey, nonce))。
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
/// auth_ok 出参。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthOkParams {
pub capabilities: Vec<CapabilityDescriptor>,
/// ed25519 模式:本次实际被授权的能力(全量子集)。
#[serde(skip_serializing_if = "Option::is_none")]
pub authorized: Option<Vec<String>>,
/// 本次连接采用的认证模式。
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_mode: Option<String>,
/// v20:被控端语义化版本。
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_version: Option<String>,
/// v20:被控端构建信息。
#[serde(skip_serializing_if = "Option::is_none")]
pub build: Option<BuildInfo>,
/// v20:被控端支持的协议版本。
#[serde(skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>,
}
/// 被控端构建信息(v20)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub built_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cert_sha256: Option<String>,
}
// ---------- 调用 ----------
/// invoke 入参。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvokeParams {
pub capability: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<Value>,
}
/// 能力执行结果(业务层)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvokeResult {
pub status: String, // "ok" | "failed"
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<InvokeError>,
}
impl InvokeResult {
pub fn is_ok(&self) -> bool {
self.status == "ok"
}
}
/// 能力执行失败的错误信息。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvokeError {
pub name: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
/// 能力描述(能力清单条目)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityDescriptor {
pub name: String,
pub version: String,
pub description: String,
pub risk: String, // "low" | "medium" | "high"
pub params_schema: JsonSchema,
#[serde(skip_serializing_if = "Option::is_none")]
pub returns_schema: Option<JsonSchema>,
}
/// JSON Schema 子集(见 DEVELOPMENT.md 4.3)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonSchema {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub schema_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<std::collections::BTreeMap<String, JsonSchema>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<Vec<String>>,
#[serde(rename = "additionalProperties", skip_serializing_if = "Option::is_none")]
pub additional_properties: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub r#enum: Option<Vec<Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub minimum: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub maximum: Option<f64>,
#[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
pub min_length: Option<u64>,
#[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
pub max_length: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Box<JsonSchema>>,
#[serde(rename = "minItems", skip_serializing_if = "Option::is_none")]
pub min_items: Option<u64>,
#[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")]
pub max_items: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
+72
View File
@@ -0,0 +1,72 @@
//! ULID 生成器(Crockford Base32,26 字符:10 位时间戳 + 16 位随机)。
//! 对齐 `packages/protocol/src/ulid.ts`(自实现,避免额外依赖)。
use std::time::{SystemTime, UNIX_EPOCH};
/// Crockford Base32 字母表(与 ulid.ts 完全一致)。
const ENCODING: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// 生成一个 ULID(单调递增:同一毫秒内保证字典序递增)。
pub fn ulid() -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
ulid_at(now)
}
/// 按指定时间戳生成 ULID(供测试注入确定性时间)。
pub fn ulid_at(now: u64) -> String {
// 时间戳部分:10 字符,低位在前地填充(不足 10 位高位补 '0')。
let mut time_part = [b'0'; 10];
let mut t = now;
for i in (0..10).rev() {
time_part[i] = ENCODING[(t % 32) as usize];
t /= 32;
}
// 随机部分:16 字符。256 % 32 == 0,取模无偏差。
let mut rand_part = [b'0'; 16];
for slot in rand_part.iter_mut() {
*slot = ENCODING[(rand::random::<u8>() % 32) as usize];
}
let mut out = String::with_capacity(26);
out.push_str(std::str::from_utf8(&time_part).unwrap());
out.push_str(std::str::from_utf8(&rand_part).unwrap());
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn length_is_26() {
assert_eq!(ulid().len(), 26);
}
#[test]
fn only_valid_charset() {
let s = ulid();
let set: Vec<char> = ENCODING.iter().map(|&b| b as char).collect();
assert!(s.chars().all(|c| set.contains(&c)));
}
#[test]
fn monotonic_same_millis_lexicographic() {
// 同一毫秒内,两个 ULID 的时间前缀必须相同(保证字典序可比较)。
let a = ulid_at(1_700_000_000_000);
let b = ulid_at(1_700_000_000_000);
assert_eq!(&a[..10], &b[..10]);
}
#[test]
fn distinct() {
let mut seen = std::collections::HashSet::new();
for _ in 0..1000 {
seen.insert(ulid());
}
assert_eq!(seen.len(), 1000);
}
}
+4
View File
@@ -1,5 +1,9 @@
# nodeagent 实战复盘:短板、瓶颈与改进优先级
> ⚠️ **本文写于 2026-09-28(当时 19 项能力),多数结论已过时。**
> 2026-10-07 逐项核查:P0 全部解决、P1 仅「定时采样」未做、P2 已补大半。
> **当前状态与下一步路线以 [`ROADMAP.md`](./ROADMAP.md) 为准**,本文保留作为「问题从哪来」的历史记录。
> 基于 2026-09-27 ~ 09-28 的真实使用(Windows 真机部署 + v2rayN 故障排查 + 重装前信息采集)
> 所有问题均来自**实际踩坑**,非理论推演。
+359
View File
@@ -0,0 +1,359 @@
# nodeagent 进度盘点与下一步路线
> 盘点日期:2026-10-07(**当日第二次更新**,据工作区最新推进刷新)
> 盘点方式:只读核查仓库(`git fetch` + 源码 grep + 本地跑单测)
> 基线:远端 main `0d62cb1`(tag **v1.5.0**)|本地 HEAD `7ad820c`(1 个未推送 CI 提交)
> 工作区:有**未提交的业务改动** —— `gui.await`(v1.6,语义方向一的首个落地)
> 适用范围:本文档随仓库公开,**不含任何真机地址与账户信息**(见 §6 脱敏约定)
---
## 一、结论先行
功能层面已基本收口。**43 项能力**覆盖了「命令接管 → 图形接管 → 零信任 → 文件传输 → Hub 中转 → 自更新」六条主线,安全加固已发版两批。
**方向一(语义可断言)已开工**:`gui.await` 在工作区完成实现(170 行 + 57 行 manifest + 注册),把 GUI 操作从「盲试」推进到「可等待、可断言」。**但尚未收尾** —— 详见 §4。
当前瓶颈:
| 性质 | 问题 | 影响 |
|---|---|---|
| **在建未收尾** | `gui.await` 已实现但**未提交、未发版、单测未覆盖、CI 未绿** | 能力数断言失败(见下),远端拿不到 |
| **收尾缺口** | 本地 1 个 CI 提交未推送;`ci/workflow-unit-tests` 分支贡献已并入但分支未清理 | CI 护栏未生效于远端 |
| **语义深度** | `gui.await` 解决了「等得到」,但 `screen.find` 仍不返回 UIA **属性**(value/enabled/selected) | 能等「元素出现」,还不能等「元素变成某状态」 |
| **状态留存** | 事件订阅已通,但无采样留存与回看 | 间歇性问题(代理不通、杀软拦截)仍只能靠巧遇 |
> ⚠️ **当前工作区单测是红的**:195 例中 **194 通过、1 失败** —— `tests/unit/capabilities.test.mjs:66` 硬编码 `assert.equal(CAPABILITY_MANIFEST.length, 42)`,而 `gui.await` 使能力数变 43。**修断言前不要提交**,否则 CI 必红。
优先级:**`gui.await` 收尾 > 语义属性 > 状态采样 > 安全残余**。
---
## 二、现状快照
### 2.1 版本与仓库
| 项 | 值 |
|---|---|
| `package.json` version | `1.5.0`(root 及 6 个子包一致)—— **未随 `gui.await` 上调** |
| 本地 HEAD | `7ad820c` ci: 补全 Windows 真机验证 |
| 远端 main | `0d62cb1`(tag **`v1.5.0`**) |
| 本地领先远端 | **1 个提交**(仅 `.github/workflows/windows-e2e.yml`,未推送) |
| **未提交业务改动** | `gui.await` 全套:`apps/agent/src/capabilities/await.ts`(新增 170 行)/`capabilities/index.ts`(+3 注册)/`packages/protocol/.../names.ts`(+1 能力名)/`manifest/graphics.ts`(+57 行契约) |
| 未合并分支 | `ci/workflow-unit-tests`(其贡献**已手工并入** `7ad820c`,分支本身基于 09-29 老 main,直接 merge 会回退上万行,故**只取内容不 merge**,分支可清理) |
| 未跟踪文件 | `docs/ROADMAP.md`(本文档)、`docs/console-plan.md`(大前端方案) |
| Release 资产 | `nodeagent-win-x64.zip`(≈32.9MB,含 Node 运行时)/ `agent.mjs` / `latest.json` / `SHA256SUMS` |
**近期提交脉络**:
```
0d62cb1 (tag v1.5.0) chore(release): v1.5.0
1c836e0 feat(v1.5): 快捷键控制范围扩展(字符串热键/预设别名/长按/后台定向投递)
0ee8231 feat(v1.2): 媒体键 WM_APPCOMMAND 通道 + 定向投递
42ec556 (tag v1.4.3) chore(release): v1.4.3
7ad820c (本地 HEAD) ci: 补全 Windows 真机验证 ← 未推送
[工作区未提交] feat(v1.6): gui.await 等待条件 ← 在建
```
### 2.2 代码与测试
| 项 | 数值 |
|---|---|
| `apps/` 代码量 | 12,201 行 TS(agent / cli / hub / mcp)+ `await.ts` 170 行(在建) |
| `packages/` 代码量 | 3,620 行 TS(client / protocol)+ manifest 57 行(在建) |
| 合计 | **约 1.58 万行** |
| 单测 | 18 个文件 / **195 例:194 通过、1 失败**(本地实跑) |
| e2e | 2 个套件(`run.mjs` 通用 29 例 / `windows.mjs` Windows 专属 13 例) |
| **`gui.await` 覆盖** | **0** —— 单测、e2e、CLI、MCP 均无 `gui.await`/`GuiAwait` 命中 |
> ⚠️ 单测数从 162 增至 195(新增 33 例,来自 v1.5 快捷键的按键表覆盖断言),但**红了一例**:`tests/unit/capabilities.test.mjs:66` 的 `assert.equal(CAPABILITY_MANIFEST.length, 42)`,实际 43。**修法**:改为 43,或更稳妥地改成「断言每个 `CapabilityNames` 键都在 manifest 中」以免每加一能力都要改数。
**最大的三个文件**(后续拆分候选):
| 文件 | 行数 | 说明 |
|---|---|---|
| `apps/mcp/src/index.ts` | 1000 | MCP 工具定义全集中,协议前缀 `na_` |
| `apps/agent/src/capabilities/window.ts` | 963 | UIA 枚举 + 模板匹配 + OCR 三条路径 |
| `apps/agent/src/capabilities/network.ts` | 747 | 两阶段提交网络变更 |
### 2.3 能力清单(43 项,以 `packages/protocol/src/capabilities/names.ts` 为准)
| 域 | 能力 |
|---|---|
| **系统与命令**(5) | `system.info` `system.status` `system.process.list` `system.service.list` `system.shell.exec` |
| **软件**(2) | `app.list` `app.install`(winget 静默优先) |
| **屏幕与录制**(4) | `screen.info` `screen.capture` `screen.record` `screen.find` |
| **输入**(6) | `input.mouse.move` `input.mouse.click` `input.mouse.scroll` `input.mouse.drag` `input.key.type` `input.key.press` |
| **窗口**(2) | `window.list` `window.focus` |
| **GUI 等待**(1) | **`gui.await`** 🆕 v1.6 在建未提交 —— 等条件成立再返回 |
| **文件**(4) | `fs.list` `fs.stat` `fs.read` `fs.write`(分块 RPC + 原子写) |
| **剪贴板**(2) | `clip.get` `clip.set` |
| **异步任务**(3) | `system.task.list` `system.task.get` `system.task.kill` |
| **审计与指标**(3) | `system.audit.list` `system.audit.verify`(哈希链) `system.metrics` |
| **事件订阅**(4) | `event.watch` `event.unwatch` `event.list` `event.poll` |
| **自持与更新**(2) | `system.agent.restart`(走计划任务) `system.agent.update`(拉取式,校验 sha256) |
| **音频**(2) | `system.audio.get` `system.audio.set`(Core Audio COM) |
| **网络变更**(3) | `system.net.apply` `system.net.confirm` `system.net.status` |
**`gui.await` 设计要点**(工作区在建,v1.6):
| 项 | 内容 |
|---|---|
| 四类条件 | `window` / `control` / `process` / `file`,各自复用既有 `window.list` / `screen.find` / `process.list` / `fs.stat` —— **零新增平台探测代码**,Windows/macOS 行为与底层能力完全一致 |
| 两个状态 | `state=present`(默认,等出现)/`absent`(等消失) |
| 参数 | `timeout_ms`(0~60000,默认 5000)/`interval_ms`(50~5000,默认 400) |
| 返回 | `{satisfied, condition, state, elapsed_ms, attempts, last_seen, last_error?, note?}` |
| **超时是正常返回** | 返 `satisfied:false` + `last_seen` + `note`,**不抛异常** —— 避免上层反复重试。`last_error` 带回末次异常,用于区分「真的没有」与「每次都在报错」 |
| 轮询期异常 | 视为「尚未命中」并记录,不让单次抖动整句失败(启动中的程序常短暂报错) |
| 风控 | `risk: 'low'`(纯只读),`additionalProperties: false` |
典型编排:
```
await gui.await(condition:"control", text:"完成", timeout_ms:30000)
→ input.mouse.click(命中坐标)
→ gui.await(condition:"control", text:"安装中", state:"absent")
```
> v1.5.0 未新增能力名,而是**扩展了 `input.key.press` 的参数面**:字符串热键 `hotkey: "ctrl+shift+esc"`、约 50 个预设别名(`copy` / `show_desktop` / `task_manager` / `volume_mute_toggle`…)、`hold_ms` 长按、`route=post` + `target_pid` 后台定向投递;按键表补齐左右侧修饰键、小键盘、OEM 符号键(解锁 `win+d` / `ctrl++` / `win+.`)、IME 键。**这些是同一能力内的行为扩展,不改协议结构**。
安全机制(不计入能力数):挑战-应答握手、nonce 一次性、能力级 ACL(deny → allow → 默认拒绝)、调用限速、审计链、证书指纹钉住(TOFU)、来源网段白名单 `allow_from`、每客户端独立 PSK、发现广播最小化 + HMAC 签名、键鼠默认禁用。
---
## 三、与复盘文档的对照
`docs/RETROSPECTIVE.md` 写于 2026-09-28(当时 19 项能力)。逐项核对,**多数 P0/P1 已解决**,文档本身已过期:
| 复盘当时的判断 | 现状 | 落点 |
|---|---|---|
| **P0** UI 元素定位缺失 | ✅ 已解决 | `screen.find`(UIA → 模板匹配 → OCR 三级降级,返回 `engine`) |
| **P0** 受控重启缺失 | ✅ 已解决 | `system.agent.restart`(借计划任务,任务名带时间戳) |
| **P0** 编码未统一 UTF-8 | ✅ 已解决 | v7 UTF-8 全链路;`.ps1` 带 BOM / `.cmd` 纯 ASCII+CRLF / `.json` 无 BOM,三套规则有单测守卫 |
| **P0** exec 输出污染 | ✅ 已解决 | 结构化 `{stdout, stderr, exit_code, duration_ms}` |
| **P1** 长任务异步化 | ✅ 已解决 | `system.task.list/get/kill` |
| **P1** 剪贴板 | ✅ 已解决 | `clip.get` / `clip.set`(软失败需重试 + 读回校验) |
| **P1** 窗口管理 | ✅ 已解决 | `window.list` / `window.focus`(`window.rect` 无独立能力,rect 由 list 返回) |
| **P1** CLI 无连接复用 | ✅ 已解决 | `apps/cli/src/daemon.ts` + `nodeagent daemon start` |
| **P1** 定时采样 / 监控 | ❌ **未做** | 无 `monitor.*` 能力(grep 命中 0) |
| **P1** GUI 操作需可断言 | 🚧 **在建** | `gui.await` 已实现未提交(等 window/control/process/file 出现或消失);属性条件与组合未做 |
| **P2** 文件监控 `fs.watch` | ❌ **未做** | grep 命中 0 |
| **P2** 日志聚合 `log.query` | ❌ **未做** | grep 命中 0 |
| **P2** 录屏 | ✅ 已解决 | `screen.record` |
| **P2** 键盘宏 / 鼠标拖拽 | ✅ 已解决 | `packages/client/src/macro.ts` + `input.mouse.drag` |
| **P2** 并发 RPC | ⚠️ 部分 | WS 单连接仍串行批量;daemon 缓解了重连成本,未解决单连接并发 |
| **P2** 多设备并行 | ❌ 未做 | 仍需 `--node` 串行切换 |
**结论**:复盘 §十的三个短板,「自持」已彻底解决,「语义」解决了一半(能找到,读不懂),「状态」只补了事件通知,采样留存仍是空白。
---
## 四、待办一:收尾
### 4.0 ⚠️ 推送前必须先脱敏(优先级高于一切)
第九轮隐私审查发现:**`0ee8231` 与 `1c836e0` 两个已推送提交都带真机痕迹**,其中设备软件清单已在公开面:
| 位置 | 内容性质 | 状态 |
|---|---|---|
| `0ee8231` 提交信息 | 真机内网地址 + 具体软件名(详见该提交) | **已推送** |
| `1c836e0` → `apps/agent/src/capabilities/input.ts:145,146,163,410` | 注释含具体播放器名与游戏名 | **已在 origin/main** |
| `1c836e0` → `apps/agent/src/capabilities/input-script.ts:29,32` | 同上 | **已在 origin/main** |
这构成**设备软件清单 + 使用习惯**的公开暴露(哪台机器装了哪些软件、常在什么场景下远程控它)。比单纯的 IP 泄露更难辩解 —— IP 会换,软件清单是稳定画像。
处置建议(按代价排序):
| 方案 | 代价 | 效果 |
|---|---|---|
| **A. 只清未来**(最低) | 0 | 新增代码与提交信息一律脱敏,历史不动。仓库公开仍有痕迹 |
| **B. 清注释 + 清未来**(臣建议) | 半小时 | 源码注释泛化为「主流播放器」「全屏游戏」,不动历史 |
| **C. B + 改写历史** | 需 `git filter-repo` + force push | 连历史一并清,但**旧 blob 已被匿名抓取过,改写不等于收回** |
> ⚠️ 关键认知:**已推送到公开仓库并被匿名抓取过的内容,改写历史无法收回**。旧 blob 只能靠 GitHub Support GC,而 Support GC 本身也非保证。因此现实目标应是「**止血 + 不再恶化**」,即方案 B。
**具体要改的注释**(脱敏后按此句式重写,勿照抄原词):
| 原文式(不复述) | 改为 |
|---|---|
| 「远程控制某播放器 / 某云音乐」 | 「远程控制主流媒体播放器」 |
| 「某音乐会建 30+ 辅助窗口」 | 「部分播放器会创建大量辅助窗口」 |
| 「某游戏全屏遮挡时」 | 「全屏独占应用遮挡时」 |
### 4.1 推送 CI 提交
- **现状**:本地 `7ad820c` 领先远端 1 个提交,仅改 `.github/workflows/windows-e2e.yml`(+61 行)。
- **内容**(三段护栏,全部来自 10-04 真机事故复盘):
1. **单测步骤** —— 把 `pnpm test:unit` 并入 Windows E2E 工作流。该内容取自 `ci/workflow-unit-tests` 分支(`4d73d6e`);因该分支基于 09-29 老 main,直接 merge 会**回退上万行**,故只取提交内容手工并入。
2. **5.1 BOM 解析守卫** —— 刻意用 `cmd` 调 `powershell`(5.1)而非 `pwsh`。5.1 读无 BOM 的 `.ps1` 会按 ANSI/GBK 解析,中文注释破坏字符串边界 → 整份脚本 ParseError。此前全用 pwsh(7+ 认无 BOM UTF-8),所以**CI 绿、真机炸**。这道守卫只问一件事:5.1 能否读懂 —— 而这就是真机的核心约束。
3. **install.cmd / control.cmd 双击链路冒烟** —— 这两个脚本是用户唯一上手入口,编码或语法坏了只有真机 cmd.exe 测得出。
- **收尾**:推送后可清理 `ci/workflow-unit-tests` 分支(贡献已并入,留着只会误导)。
### 4.2 v1.5.0 真机验证尚未做(v1.5 提交信息自陈)
`1c836e0` 的提交信息里写明:
> ⚠️ **真机未验证**:Windows 被控端当前离线(连接超时)。待上线后需验……
即v1.5.0 是**未经真机验证就发的版**。待办:
```
node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
```
重点验四项(提交信息已列):字符串热键 `hotkey`、预设别名、长按 `hold_ms`、后台定向投递 `route=post`。其中后台定向投递需复验「窗口被遮挡时是否真有效」—— 这条机制此前只在媒体键通道验证过,等价推断需实测坐实。
### 4.3 大前端方案(`docs/console-plan.md`,已出文档待评审)
盘点期间新增了一份前端方案文档,臣不在此展开(详见该文档),仅提示它引入了**一个新包 `crates/nodeagent-client`(Rust)**,与现有 TypeScript 的 `@nodeagent/client` 并存 —— 意味着 client 层有两套实现。这本身是个需要皇上拍板的架构决策(详见该文档的待确认项)。
---
## 五、待办二:三个开发方向
### 方向一:语义 —— 从「能找到」到「可断言」
**判断**:`screen.find` 的能力边界已经够用,缺的是**返回值的信息量**与**操作后的状态断言**。原来的链条是「点 → 截图 → 目测 → 再点」。
**当前进度:第一步已落地(在建),第二步未做。**
| # | 改动 | 状态 | 说明 |
|---|---|---|---|
| 1 | **新增 `gui.await` 等待条件** | ✅ **已实现,未提交** | 四类条件(window/control/process/file)× 两态(present/absent);复用既有能力,零新增平台探测代码;超时**正常返回**不抛异常 |
| 2 | `screen.find` 返回 UIA 属性 | ⬜ 未做 | 补 `name` / `value` / `enabled` / `selected` / `rect` / `automation_id` |
| 3 | 条件组合(`and` / `or` / `not`) | ⬜ 未做 | `gui.await` 目前是单条件;组合需先有属性支持 |
#### `gui.await` 收尾清单(**提交前必做**)
| # | 事项 | 说明 |
|---|---|---|
| 1 | **修能力数断言** | `tests/unit/capabilities.test.mjs:66` 硬编码 42 → 实际 43,**当前单测是红的**。建议改成「断言每个 `CapabilityNames` 键都在 manifest 中」,避免每加一能力都要改数 |
| 2 | **补单测** | 当前 `gui.await` 覆盖为 0。至少覆盖:参数校验(非法 condition/state)、`timeout_ms` 上限裁剪、`isHit` 对不同返回形态的判定(`found` / `exists` / 数组 / 布尔)、超时返 `satisfied:false`、轮询期异常不中断 |
| 3 | **补 CLI / MCP 入口** | grep 显示 `apps/cli/` 与 `apps/mcp/` **均无 `GuiAwait` 命中**。若 CLI 走通用 invoke 通道则无需改;若逐能力映射则必须补 |
| 4 | **版本号上调** | `package.json` 仍 `1.5.0`,root + 6 子包。新增能力名 → MINOR → **v1.6.0** |
| 5 | 真机验证 | 至少验一次「等窗口出现 → 点击 → 等元素消失」完整编排。`gui.await` 是**阻塞调用**,需确认 `timeout_ms` 上限(60s)不会撞上 WS 心跳/超时机制 |
| 6 | 发版 | `node scripts/release.mjs` → v1.6.0 |
**验收标准**:
- 「等某窗口出现某控件」在设定 timeout 内命中,返回 `attempts` 与 `elapsed_ms`;
- 条件永不满足时返回 `satisfied:false` + `last_seen`,**不抛异常**;
- 条件本就「不存在」时立即返回(`attempts:1`),不等满 timeout。
#### 第二步:`screen.find` 属性化(后续)
这是方向一的**真正深水区**。`gui.await` 解决「等得到出现/消失」,但等不到「等它变成某状态」:
| 改动 | 内容 | 验收标准 |
|---|---|---|
| `screen.find` 返回 UIA 属性 | 在现有坐标之外补 `name` / `value` / `enabled` / `selected` / `rect` / `automation_id` | 对某个真实界面能读出 enabled 状态随播放状态变化 |
| `gui.await` 支持属性条件 | predicate 可比较 `enabled` / `selected` / `value` | 能表达「按钮出现 **且** enabled」 |
| 条件组合 | `and` / `or` / `not` | 能表达「弹窗出现 且 其中某按钮 enabled」 |
**为什么仍排第一**:GUI 接管是 v2 的核心价值,也是当前唯一还「不好用」的域。`gui.await` 只是把「盲试」变成「有耐心的盲试」—— 补上属性比较后,才真正变成可断言。
**风险**:`gui.await` 是**阻塞式调用**,必须有 `timeout_ms` 上限(已设 60s 上限 ✓);超时属正常返回(已实现 ✓)。第二个风险:`screen.find` 的 UIA 属性读取在部分应用上可能拿到空值(Electron / 游戏 UI 常见),需能优雅降级为「仅坐标 + found」。
### 方向二:状态 —— 治间歇性问题
**判断**:复盘 §四那条判断仍成立 —— 代理不通、杀软拦截都是**间歇性**的,而臣只能看当下快照。`event.watch` 解决「有事件时通知」,没解决「持续记录并回看」。
| 能力 | 设计 | 验收标准 |
|---|---|---|
| `monitor.start` | `{ interval_ms, command \| metric, duration_ms }` → 落盘,返回 `id` | 对某连接端口跑 5 分钟 / 每 2 秒采样,事后能拿到完整时间序列 |
| `monitor.report` | `{ id }` → 返回采样序列 + 摘要(min/max/均值/异常点) | 能直接回答「5 分钟里断过几次、最长断了多久」 |
| `log.query` | `{ path, since, level, pattern, limit }` —— **被控端侧过滤** | 10 万行日志中按 pattern 查 20 条,不整份拉回 |
| `fs.watch` | `{ path, recursive, filter }` → 走事件订阅通道推送 | 监听配置文件变更,变更事件 ≤2s 内到达 |
**关键点**:`log.query` 必须过滤在 Windows 侧。当前 `fs.read` 虽有 8MB 上限,但拉大日志既慢又占带宽,治标不治本。
### 方向三:安全 —— 剩余四项
`docs/SECURITY.md` §5 自列的未做项:
| 项 | 现状 | 建议 |
|---|---|---|
| **握手失败封禁** | 只计数 + 记审计,无按 IP 拉黑 | 加指数退避封禁(如 5 分钟内失败 10 次 → 临时拉黑 1 小时),可配置白名单豁免 |
| **会话上限与空闲断开** | 无最大连接数、无空闲超时 | 上限防资源耗尽;空闲断开(如 30 分钟无调用)释放资源 |
| **安装期密钥传递** | `install.cmd` 用命令行 `-Key` 传参,**同机其他用户 `ps` 可见** | 改走 stdin 或临时文件(600 权限,读后即删)。这是唯一还没堵住的密钥暴露面 |
| **审计链外部锚定** | 哈希链可被「有 root 的攻击者」整链重写 | 定期把链头哈希同步到独立位置(如 Hub 或另一台机器),重写即暴露 |
**臣另建议一项**:**ed25519 默认化**。`SECURITY.md` §2 已写明 PSK 模式的固有限制(`client_id` 自报 → ACL 身份维度形同虚设)。v22 的每客户端独立 PSK 只是缓解 —— 持有共享 key 的人仍可冒充未登记的 client_id。新装默认应走 ed25519,PSK 降级为「兼容存量」并在安装时明确告警。
---
## 六、待办三:隐私收敛(已连续多轮 P1,该决断)
### 6.1 已暴露且无法收回
| 项 | 状态 | 处置 |
|---|---|---|
| **设备软件清单已在公开面** | `input.ts` / `input-script.ts` 注释含具体播放器与游戏名(见 §4.0) | 泛化注释(方案 B);历史不追 |
| **2 个含PII 的 blob 匿名可直取** | 旧提交中的 blob 用 40 位完整 SHA 即可匿名读取,已连续 6 轮未消;内容含本机用户名与真实目录名 | 报 GitHub Support 申请 GC,或删库重建。**这是唯一的「真泄露」** |
| **提交信息含真机内网地址** | 5 条 commit message 含内网 IP 与端口 | 公开且被搜索引擎收录。后续禁止在 message 写真机地址 |
| **Windows 账户名** | 硬编码在 `network.ts:96` | 改为从配置或环境变量读取 |
| **网卡名「Ethernet」+ 网段常量** | 测试与源码中多处 | 同上,注入式配置 |
### 6.2 尚未暴露,立即处置
| 项 | 处置 |
|---|---|
| `.gitignore` 缺 `keys/`、`*.key`、`*.pem` | 立即补(当前已有 `**/PSK.txt`、`../win-backup/`) |
| `input.ts:324` 附近含 3 个 U+FFFD 字面量 | 编码缺陷,顺手修 |
### 6.3 脱敏约定(本文档已遵守,后续继续遵守)
本文档不出现任何真机地址、端口、账户名、密钥,一律用 `<地址>` / `<密钥>` 占位。代码与测试中的真实值应逐步改为从配置或环境变量读取。
**新增约定**:提交信息与源码注释**不得出现具体软件名、设备名、真实地址**。举例子:「实测某播放器被某游戏全屏压住」→「实测主流播放器被全屏游戏遮挡」;真实内网地址→「控制端内网地址」。
> **教训**:本轮暴露的根源不是泄露了密钥(那类已按等式法核验并修复),而是**注释里写「为什么这么做」时顺手写了真实环境**。真实环境信息对理解原理无帮助,却直接画像了使用者。以后写注释,理由归理由,实况归实况。
---
## 七、建议排期
| 序 | 事项 | 预估 | 依赖 |
|---|---|---|---|
| 0 | **修 `gui.await` 能力数断言**(42→43,当前单测红) | 5 分钟 | 无(**提交前必做,否则 CI 必红**) |
| 0.5 | **脱敏**:泛化 `input.ts` / `input-script.ts` 注释里的软件名 | 20 分钟 | 无(**先于一切推送**) |
| 1 | `gui.await` 收尾:补单测 + CLI/MCP 入口 + 版本号 v1.6.0 + 真机验证 + 发版 | 1 天 | 序 0 |
| 2 | 推送 CI 提交 + 清理 `ci/workflow-unit-tests` 分支 | 10 分钟 | 序 1 |
| 3 | **v1.5.0 真机验证**(该版未经真机就发了) | 半天 | 被控端在线 |
| 4 | 方向一第二步:`screen.find` 属性化 + `gui.await` 支持属性条件 + 条件组合 | 2~3 天 | 序 1 |
| 5 | 方向三:`install.cmd` 密钥改 stdin + 握手封禁 + 会话上限 | 1~2 天 | 无(安全项建议提前插队) |
| 6 | 方向二:`monitor.*` + `log.query` + `fs.watch` | 2~3 天 | 无 |
| 7 | `.gitignore` 补密钥规则 + 源码真机常量注入化 | 分散 | 无 |
| 8 | PII blob 报 Support GC / 决定是否重建仓库 | — | **需皇上决策** |
| 9 | `ed25519` 默认化 | 1~2 天 | 需确认向后兼容策略 |
| 10 | `console-plan.md` 评审:Rust client 与 TS client 双实现取舍 | — | **需皇上拍板** |
**臣的建议**:
- **序 0 立刻做**。5 分钟的事,但不做则单测红、CI 必红。注意建议顺手把断言改成「每个 `CapabilityNames` 键都在 manifest 中」——否则每加一能力都要改数字,这类「硬编码计数」会反复咬人。
- **序 0.5 紧接着做**。只改注释,20 分钟,零风险,但能阻止「设备软件清单」继续扩散。`gui.await` 也正好是新代码,此时立脱敏规矩最自然。
- **序 1 是当前主线**。`gui.await` 实现已完成(170 行 + 57 行契约 + 注册),设计也站得住(超时正常返回、轮询期异常不中断、复用既有能力零新增探测代码)。差的只是收尾四件事:单测、CLI/MCP 入口、版本号、真机验证。**建议一次做完再提交,不要分批**。
- **序 3 优先级高于新功能**。v1.5.0 是**未经真机验证就发的版**,其中「后台定向投递」只在媒体键通道验证过、等价推断未经实测。
- **序 5 可提前**。`install.cmd` 密钥经命令行传参(同机 `ps` 可见)是当下唯一未堵住的密钥暴露路径,改动局限在一个脚本内。
- **序 8、10 需皇上决策**:前者关系到公开历史能否部分收回,后者关系到是否长期维护两套 client 实现。
> **一条经验**:`gui.await` 一次加能力就让单测红,原因是能力数被硬编码成 `42`。这类「计数断言」在持续加能力的项目里必然反复失效。**改为集合断言**(每个能力名都在 manifest 中)比每次改数字更省事,也更不容易漏检。
---
## 八、附:本文档的核查方法
| 结论 | 核查方式 |
|---|---|
| 版本与提交差距 | `git fetch` 后比 `git log origin/main..HEAD`(**必须先 fetch** —— 盘点期间远端已推进到 v1.5.0,本地缓存的 origin 是过期的) |
| 能力清单 | 读 `packages/protocol/src/capabilities/names.ts` 全量枚举 |
| 遗留能力未实现 | 对 `fs.watch` / `log.query` / `monitor.` / `serve` / `daemon` 逐个 grep 源码,命中 0 即未实现 |
| 单测状态 | 本地 `node --test "tests/unit/**/*.test.mjs"` → **195 例:194 pass / 1 fail**(`capabilities.test.mjs:66` 能力数断言 42≠43) |
| 复盘对照 | 逐条读 `docs/RETROSPECTIVE.md` 的 P0/P1/P2 表,与能力清单交叉 |
| `gui.await` 完成度 | 分别 grep `tests/` `apps/cli/` `apps/mcp/` 找 `gui.await`/`GuiAwait`/`guiAwait` —— 三处均 0 命中 → 单测与两端入口都还没接|
| 安全残余 | 读 `docs/SECURITY.md` §5「还没做的(后续批次)」 |
| 公开面暴露 | `git fetch` 后 `git grep <关键词> origin/main` —— **必须先 fetch**(否则查的是过期快照),且**关键词要带空格变体**(`XX音乐` / `XX 音乐`),否则会漏 |
> **两条教训**:
> 1. 跨平台差异(编码、文件重命名、进程语义)**必须在真机验证** —— macOS 通过不代表 Windows 通过。文档层面的盘点只能发现「没写」,发现不了「写错了但没人跑」。
> 2. **`git grep` 不 fetch 会查旧快照**。本次差点把「未推送」误判成「未暴露」,实际远端已含该内容。凡涉及「公开面是否暴露」的结论,必须 `git fetch` 后再查,且关键词要带空格变体。
+258
View File
@@ -0,0 +1,258 @@
# nodeagent 大前端开发方案(桌面操作台)
> 版本 v1.0 | 日期 2026-10-07 | 状态:待皇上评审
> 定位:为「Mac 控 Windows」补上**人肉可视化操作台**——CLI 是脚本底座、MCP 是 AI 落点、本方案是**皇上自己点按钮**的桌面界面。
---
## 1. 一句话结论
用 **Tauri 2 + Vue 3 + TypeScript + Vite** 造一套**跨端桌面应用**,同一份代码编译出 **Mac 版(控制端操作台)**与 **Windows 版(被控端本地面板)**;Rust 后端复刻 `@nodeagent/client` 的握手/RPC/事件逻辑,经 Tauri command 暴露给 Vue 前端。首期覆盖 **核心管控 + 文件管理 + 审计**。
---
## 2. 技术栈选型(含取舍)
| 层 | 选型 | 版本 | 选型理由 |
|---|---|---|---|
| 桌面壳 | **Tauri 2** | 2.x | 跨端(Mac/Win)+ 性能好 + 体积小;Rust 后端契合皇上「Rust 加速」技术栈 |
| 前端框架 | **Vue 3** | 3.5+ | 皇上既有 Vue2/Vue3 经验(reader、FinDataHub),迁移成本最低 |
| 语言 | **TypeScript** | 5.x | 与 monorepo 既有 TS 生态对齐,复用协议类型定义 |
| 构建 | **Vite** | 6.x | Tauri 官方模板默认,HMR 快 |
| 状态管理 | **Pinia** | 2.x | Vue3 官方推荐,模块化 store 天然契合多面板 |
| 路由 | **Vue Router** | 4.x | 多页面(总览/终端/文件/审计…)需要 |
| 组件库 | **Naive UI** | 2.x | 暗色主题友好、TS 类型完整、可自定义涨红跌绿 |
| 图表 | **ECharts** | 5.x | 资源趋势/磁盘/指标可视化,涨红跌绿可配 |
| Rust 客户端 | tokio + tokio-tungstenite + rustls | — | 复刻 client.ts 的 WebSocket 握手与 RPC |
| Rust 密码学 | hmac + sha2 + ed25519-dalek + x509-parser | — | HMAC 挑战应答、ed25519 签名、证书指纹钉住 |
### 为什么 Tauri 而不是 Electron / 纯 Web
| 维度 | Tauri 2 | Electron | 纯 Web(浏览器) |
|---|---|---|---|
| 跨端 Mac+Win | ✅ | ✅ | ✅(但无桌面壳) |
| 打包体积 | ~10MB 级 | ~100MB+ | 无 |
| 内存占用 | 低(复用系统 WebView) | 高(自建 Chromium) | 视浏览器 |
| 性能 | 好(Rust 后端) | 中 | 受浏览器沙箱限制 |
| 直接复用 Node SDK | ❌(需 Rust 版 client) | ✅(主进程可 import) | ❌(需浏览器版) |
**取舍**:Tauri 需要新写一份 Rust 版 client(协议本身是 JSON-RPC over WebSocket,语言无关,逻辑对齐已跑通的 client.ts 即可),换取体积、内存、性能上的长期优势——与皇上「性能好 + 跨端」的要求一致。
---
## 3. 整体架构
```
┌─────────────────────────── apps/console(桌面应用,同一份代码两端编译) ───────────────────────────┐
│ │
│ Vue 3 前端(src/) Tauri Rust 壳(src-tauri/) │
│ ┌─────────────────────┐ IPC invoke ┌──────────────────────────────┐ │
│ │ 视图 Views │ ────────────► │ commands/*.rs(薄命令层) │ │
│ │ 组件 Components │ ◄──────────── │ │ │ │
│ │ Pinia stores │ event 推送 │ ▼ │ │
│ │ api 调用层 │ ◄──────────── │ state.rs(连接管理器) │ │
│ └─────────────────────┘ │ │ │ │
│ │ ▼ │ │
│ │ crates/nodeagent-client │ ── WebSocket ──► 被控端 │
│ │ (Rust 版 client:握手/RPC/ │ (wss://IP:8765) Windows │
│ │ 事件/重连/证书钉住/ed25519) │ │
│ └──────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
```
关键设计:
1. **薄壳厚前端**:Rust 壳只做「连接管理 + 协议桥接」,业务展示逻辑全在 Vue 前端。
2. **事件推送走 Tauri event**:被控端主动事件(v12 `event.*`)、连接状态变化,由 Rust 后端转发为 Tauri event,前端 `listen()` 订阅,避免轮询。
3. **能力调用统一 `invoke`**:所有 42 项能力都走一个泛型 `invoke(capability, args)` 通道,前端按能力清单动态渲染参数表单,**不逐能力手写 command**。
---
## 4. 功能模块与页面清单(首期 = 核心管控 + 文件 + 审计)
| # | 页面/模块 | 路由 | 支撑能力 | 功能点 |
|---|---|---|---|---|
| 1 | **设备管理** | `/nodes` | `discover`、`nodes` 配置、`connect` | 局域网发现、设备列表、连接/断开、增删改节点、设默认设备、证书指纹展示与重钉 |
| 2 | **状态总览 Dashboard** | `/overview` | `system.info` / `system.status` / `system.metrics` / `system.audio.get` | 连接状态、主机名/OS/CPU/内存/开机时长、磁盘用量环图、网络适配器、PRD 成功指标(闭环率/P95/拦截率) |
| 3 | **命令执行(终端)** | `/terminal` | `system.shell.exec`、`system.task.*` | 交互式 PowerShell 终端、退出码/耗时、超时可配、长命令后台化 + 任务列表/续读/终止 |
| 4 | **进程与服务** | `/processes` | `system.process.list` / `system.service.list` | 进程/服务列表,按 CPU/内存/PID/名称排序,按名筛选,按状态筛选 |
| 5 | **软件管理** | `/software` | `app.list` / `app.install` | 已装软件列表与版本、winget 静默安装(输入包名/ID,返回结果+版本) |
| 6 | **文件管理** | `/files` | `fs.list` / `fs.stat` / `fs.read` / `fs.write` | 目录树浏览、文件预览(分块读)、上传/下载(分块 + 进度)、新建/覆盖/追加写入、元信息 |
| 7 | **审计日志** | `/audit` | `system.audit.list` / `system.audit.verify` | 审计列表分页、按类型/调用方/时间筛选、完整性校验(verify)、导出 JSONL |
> **Windows 版侧重**(待确认):同一套代码,Windows 版默认聚焦**被控端本地面板**——本机连接状态、授权(ACL/`allow_input` 输入开关)、本机资源、日志、自更新入口。因协议点对点对称,Windows 版同样可作控制端。
---
## 5. 数据接口与状态管理
### 5.1 Rust 版 client(`crates/nodeagent-client`)对外 API
| 方法 | 说明 | 对齐 client.ts |
|---|---|---|
| `Client::connect(profile) -> NodeInfo` | 握手:hello → challenge → auth → auth_ok | `connect()` |
| `Client::invoke(capability, args, timeout) -> InvokeResult` | 泛型能力调用 | `invoke()` |
| `Client::list_capabilities()` / `list_authorized()` | 能力/授权清单 | `listCapabilities()` |
| `Client::set_event_handler(fn)` | 订阅被控端事件(无 id 通知) | `onEvent` |
| `Client::on_state_change(fn)` | 连接/重连/关闭状态回调 | `onStateChange` |
| `Client::close()` | 主动断开(停自动重连) | `close()` |
| `Client::peer_cert_fingerprint()` | 对端证书指纹(TOFU 钉住) | `getPeerCertFingerprint()` |
复刻要点(已跑通,勿重蹈):指数退避重连 1s→30s ±15% 抖动;证书指纹 sha256 比对(`E_CERT_MISMATCH`);ed25519 模式需私钥;Hub 配对透传。
### 5.2 Tauri command(Rust 壳 → 前端 IPC)
| Command | 入参 | 返回 | 说明 |
|---|---|---|---|
| `discover` | `wait_ms` | `Node[]` | UDP 8766 发现 |
| `connect_node` | `node_id` | `NodeInfo` | 连接并握手 |
| `disconnect` | — | `()` | 断开 |
| `invoke` | `capability, args, timeout_ms?` | `InvokeResult` | 泛型能力调用 |
| `list_nodes` / `add_node` / `remove_node` / `set_current` | profile 结构 | 配置 | 设备 CRUD(写 `~/.nodeagent/config.json`) |
| `get_conn_state` | — | `ConnState` | 当前连接状态 |
| `get_capabilities` | — | `Capability[]` | 能力/授权清单 |
### 5.3 事件推送(Tauri event,Rust → 前端)
| 事件名 | 载荷 | 触发 |
|---|---|---|
| `node-event` | 被控端事件(`kind/action/target/detail`) | 被控端 `event.*` 主动推送 |
| `conn-state` | `connected/reconnecting/closed` | 连接状态变化 |
| `task-progress` | `task_id, offset, output` | 长任务增量输出 |
### 5.4 Pinia store 设计
| Store | 职责 | 关键 state |
|---|---|---|
| `useConnStore` | 连接状态、当前节点、能力/授权清单、证书指纹 | `state, nodeId, capabilities, authorized, certSha256` |
| `useNodeStore` | 设备配置列表、发现结果、分组 | `nodes, current, groups, discovered` |
| `useSysStore` | 系统信息/状态/指标缓存(可手动刷新) | `info, status, metrics` |
| `useFileStore` | 当前目录、文件列表、选中项、上传下载进度 | `cwd, entries, selection, transfers` |
| `useAuditStore` | 审计分页与筛选 | `items, page, filter` |
| `useEventStore` | 事件流(订阅 + 游标续拉) | `watches, events, cursor` |
| `useTaskStore` | 后台长命令任务 | `tasks` |
**数据流**:组件 → Pinia store → `api/`(封装 `invoke`)→ Tauri command → Rust client → 被控端;反向经 Tauri event → store 更新 → 响应式视图。高频指标(status/metrics)由 store 内定时器轮询,可配置周期;事件类数据走推送,不轮询。
---
## 6. 代码结构(monorepo 新增)
```
nodeagent/
├── apps/
│ └── console/ # 桌面应用(新增)
│ ├── package.json
│ ├── vite.config.ts
│ ├── index.html
│ ├── src/ # Vue 3 前端
│ │ ├── main.ts / App.vue
│ │ ├── router/index.ts # 7 条路由
│ │ ├── views/ # Nodes / Overview / Terminal / Processes / Software / Files / Audit
│ │ ├── components/ # 可复用组件(设备卡片、参数表单、状态徽章、进度条…)
│ │ ├── stores/ # Pinia(5.4 节 7 个 store)
│ │ ├── api/ # invoke 封装 + 类型(对齐 Rust command)
│ │ ├── types/ # 能力清单、InvokeResult、NodeProfile 等类型镜像
│ │ └── utils/ # 格式化、涨红跌绿、分块编排
│ └── src-tauri/ # Rust 壳
│ ├── Cargo.toml
│ ├── tauri.conf.json
│ └── src/
│ ├── main.rs / lib.rs
│ ├── state.rs # 连接管理器(持有 client)
│ └── commands/ # 5.2 节 command 实现
├── crates/
│ ├── nodeagent-client/ # Rust 版 client(新增,5.1 节)
│ │ ├── Cargo.toml
│ │ └── src/{lib.rs, client.rs, handshake.rs, tls.rs, events.rs}
│ └── nodeagent-protocol/ # (可选)协议类型/能力清单的 Rust 镜像
└── packages/… apps/{agent,cli,hub,mcp} # 现有,不动
```
约束:Rust client 与 Node client(`packages/client`)**行为一致**,两端共享 `~/.nodeagent/config.json` 与 `keys/`,不新造存储位置;能力清单以 `packages/protocol/src/capabilities/manifest` 为唯一事实源,Rust 侧做类型镜像并对齐。
---
## 7. 交付范围
### In scope(首期 v1)
| # | 交付物 | 说明 |
|---|---|---|
| 1 | `crates/nodeagent-client` | Rust 版 client,对齐 client.ts 全部行为,含单元测试 |
| 2 | `apps/console` 桌面应用 | Tauri + Vue3,7 个页面,Mac/Win 双端可编译 |
| 3 | 核心管控 | 设备管理、总览、终端、进程/服务、软件安装 |
| 4 | 文件管理 | 浏览/预览/上传/下载(分块)/写入 |
| 5 | 审计 | 列表分页筛选、完整性校验、导出 |
| 6 | 打包脚本 | Mac `.app` + Windows `.msi/.exe`(复用现有 `pack.mjs` 风格) |
### Out of scope(后续版本)
| # | 内容 | 归属 |
|---|---|---|
| 1 | 图形接管 UI(实时截图/键鼠/窗口定位/宏回放) | v2 |
| 2 | 网络两阶段变更、音频控制、自更新面板 | v2 |
| 3 | 事件流订阅的可视化(watch/list/poll 面板) | v2 |
| 4 | 应用签名/公证/自动更新 | 独立发布项 |
| 5 | 多设备批量下发(fanout/分组)面板 | v2 |
---
## 8. 验收标准
### 8.1 功能验收(Given/When/Then)
| # | 验收项 | 标准 |
|---|---|---|
| A1 | 设备连接 | Given 已配置被控端,When 点击连接,Then 3s 内返回能力清单并进入总览;密钥错误返回 `E_AUTH_FAILED` 且界面明确提示 |
| A2 | 状态总览 | Given 已连接,When 打开总览,Then 正确展示 CPU/内存/磁盘/网络与 PRD 指标,且数据带时间戳 |
| A3 | 命令执行 | Given 已连接,When 输入合法 PowerShell,Then 返回 `exit_code/stdout/stderr/duration_ms`;超时命令返回 `E_TIMEOUT` 且无残留进程 |
| A4 | 软件安装 | Given winget 可用,When 输入包名,Then 返回安装结果+版本;不存在时给出明确失败原因 |
| A5 | 文件上传/下载 | Given 已连接,When 上传大文件(>8MB),Then 分块写入成功且校验 sha256 一致;下载支持断点续读 |
| A6 | 审计 | Given 已连接,When 打开审计页,Then 分页展示并可筛选;verify 返回完整性结论 |
| A7 | 断线重连 | Given 已连接,When 拔网再恢复,Then 指数退避自动重连并回到 connected,界面状态同步 |
### 8.2 指标验收
| 指标 | 口径 | 目标 |
|---|---|---|
| 命令闭环成功率 | 成功返回 ÷ 发起 | ≥ 95% |
| 内网调用 P95 时延 | 前端点击到结果渲染 | < 3s |
| 应用内存占用 | Mac 版空闲常驻 RSS | < 150MB(远低于 Electron) |
| 打包体积 | 单端安装包 | Mac `.app` < 30MB / Win 安装包 < 40MB |
| 双端编译 | `pnpm build` 一次产出两端 | 100% |
---
## 9. 里程碑拆分
| 阶段 | 内容 | 退出标准 |
|---|---|---|
| M1 | Rust client crate + 握手/RPC/事件/重连 + 单测 | 与现有 `test:e2e` 协议用例对齐通过 |
| M2 | Tauri 壳 + command 层 + 设备管理/总览/终端三页 | 真机连接成功、能跑命令、总览数据正确 |
| M3 | 文件管理 + 审计 + 软件/进程服务页 | A5/A6/A4 验收通过 |
| M4 | Mac/Win 双端打包脚本 + 文档 | A 全部通过、指标达标 |
---
## 10. 风险与对策
| # | 风险 | 概率 | 影响 | 对策 |
|---|---|---|---|---|
| R1 | Rust 版 client 与 Node 版行为漂移 | 中 | 高 | 以 `packages/protocol` 能力清单为唯一事实源;协议用例双端共跑 |
| R2 | Tauri 下自签证书/TLS 处理差异 | 中 | 中 | 复刻 client.ts 的 `insecure` + 指纹钉住逻辑,单测覆盖 `E_CERT_MISMATCH` |
| R3 | Windows 端定位歧义 | 中 | 中 | 见第 4 节「待确认」,评审时定死再做 |
| R4 | 大文件分块上传稳定性 | 中 | 中 | 复用已有分块能力;前端做进度+断点,M3 真机压测 |
---
## 11. 待确认项(请皇上评审时一并拍板)
| # | 事项 | 臣的建议 |
|---|---|---|
| C1 | Windows 端桌面应用的定位 | 被控端本地面板(默认侧重)+ 对称可作控制端 |
| C2 | 组件库 | Naive UI(暗色友好、TS 完整);若皇上惯用 Element Plus 可换 |
| C3 | 图表库 | ECharts(资源趋势/磁盘);确认后纳入 M2 |
| C4 | 是否同步做图形接管 UI | 建议 v2 再做,首期聚焦闭环 |
> 仅供参考,本方案为技术规划,不构成任何投资建议。
@@ -488,4 +488,61 @@ export const GRAPHICS_CAPABILITIES: CapabilityDescriptor[] = [
},
},
},
{
name: CapabilityNames.GuiAwait,
version: '1.0',
description:
'等待一个条件成立再返回(v1.6,**只读低危**)。四类条件:' +
'window=等窗口出现/消失;control=等界面元素出现/消失(复用 screen.find);' +
'process=等进程出现/消失;file=等文件出现/消失。' +
'用途:把 GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**的流程,例如:' +
'await control(text:"完成", timeout_ms:30000) → mouse.click(found.x, found.y) → await state:absent(text:"安装中")。' +
'state=present(默认)等到出现,absent 等到消失。' +
'四个条件各自复用 window.list / screen.find / process.list / fs.stat,' +
'平台行为(Windows UIA/OCR、macOS Vision OCR)与那些能力完全一致。' +
'轮询期单次异常不视为失败(启动中的程序常短暂报错),只在超时未命中时带回 last_error。',
risk: 'low',
params_schema: {
type: 'object',
properties: {
condition: {
type: 'string',
enum: ['window', 'control', 'process', 'file'],
description: '等待哪类条件',
},
state: {
type: 'string',
enum: ['present', 'absent'],
default: 'present',
description: '等到出现(默认)还是等到消失',
},
timeout_ms: { type: 'integer', minimum: 0, maximum: 60000, default: 5000, description: '总超时' },
interval_ms: { type: 'integer', minimum: 50, maximum: 5000, default: 400, description: '轮询间隔' },
title: { type: 'string', description: 'window 条件:窗口标题(正则)' },
pattern: { type: 'string', description: 'window 条件的 title 别名' },
text: { type: 'string', description: 'control 条件:元素文本' },
window: { type: 'string', description: 'control 条件:限定窗口标题(正则)' },
control_type: { type: 'string', description: 'control 条件:限定控件类型(仅 UIA)' },
method: { type: 'string', enum: ['auto', 'uia', 'ocr', 'image'], description: 'control 条件:查找方式' },
template: { type: 'string', description: 'control 条件 + method=image 时的模板' },
process: { type: 'string', description: 'process 条件:进程名(正则)' },
path: { type: 'string', description: 'file 条件:文件路径' },
limit: { type: 'integer', description: '底层 list 类条件的返回条数' },
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
satisfied: { type: 'boolean' },
condition: { type: 'string' },
state: { type: 'string' },
elapsed_ms: { type: 'integer' },
attempts: { type: 'integer', description: '实际轮询次数' },
last_seen: { type: 'string' },
last_error: { type: 'string', description: '超时未命中时带回的末次异常(若有)' },
note: { type: 'string' },
},
},
},
];
@@ -18,6 +18,7 @@ export const CapabilityNames = {
MouseDrag: 'input.mouse.drag',
KeyType: 'input.key.type',
KeyPress: 'input.key.press',
GuiAwait: 'gui.await',
// v3+ 审计
AuditList: 'system.audit.list',
// v5 文件传输
+1 -1
View File
@@ -63,7 +63,7 @@ test('清单:v7~v11 新能力均已登记', () => {
for (const name of expected) {
assert.ok(findCapability(name), `缺少 ${name}`);
}
assert.equal(CAPABILITY_MANIFEST.length, 42, '当前应有 42 项能力');
assert.equal(CAPABILITY_MANIFEST.length, 43, '当前应有 43 项能力');
});
// ---------- 参数校验(新能力) ----------