diff --git a/README.md b/README.md index 3d929c3..b7fd0fc 100644 --- a/README.md +++ b/README.md @@ -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 对照表** ## 关键决策 diff --git a/apps/agent/src/capabilities/await.ts b/apps/agent/src/capabilities/await.ts new file mode 100644 index 0000000..9e57353 --- /dev/null +++ b/apps/agent/src/capabilities/await.ts @@ -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; + +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; + 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 { + 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 { + 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)); + } +} diff --git a/apps/agent/src/capabilities/index.ts b/apps/agent/src/capabilities/index.ts index 862f4e1..a52e47c 100644 --- a/apps/agent/src/capabilities/index.ts +++ b/apps/agent/src/capabilities/index.ts @@ -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; + +/// 计算 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::(); + } + 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 { + 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" + ); + } +} diff --git a/crates/nodeagent-client/src/error.rs b/crates/nodeagent-client/src/error.rs new file mode 100644 index 0000000..23a9f57 --- /dev/null +++ b/crates/nodeagent-client/src/error.rs @@ -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, +} + +impl ClientError { + pub fn new(code: i64, message: impl Into) -> 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) -> Self { + Self::new(ErrorCodes::NODE_OFFLINE, message) + } + + /// 调用超时。 + pub fn timeout(message: impl Into) -> Self { + Self::new(ErrorCodes::TIMEOUT, message) + } + + /// 证书指纹不一致。 + pub fn cert_mismatch(message: impl Into) -> 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 {} diff --git a/crates/nodeagent-client/src/lib.rs b/crates/nodeagent-client/src/lib.rs new file mode 100644 index 0000000..72a53a5 --- /dev/null +++ b/crates/nodeagent-client/src/lib.rs @@ -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}; diff --git a/crates/nodeagent-client/src/types.rs b/crates/nodeagent-client/src/types.rs new file mode 100644 index 0000000..7ad634f --- /dev/null +++ b/crates/nodeagent-client/src/types.rs @@ -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, +} + +/// 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, +} + +/// JSON-RPC 2.0 错误响应。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcErrorResponse { + pub jsonrpc: String, + #[serde(default)] + pub id: Option, + pub error: RpcError, +} + +/// 任意入站消息:成功响应或错误响应(无 id 的通知由上层单独处理)。 +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum InboundMessage { + Response(RpcResponse), + Error(RpcErrorResponse), + Notification { + #[serde(default)] + id: Option, + method: String, + #[serde(default)] + params: Option, + }, +} + +/// 判断消息是否为错误响应。 +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, + /// ed25519 模式:Base64(Ed25519_Sign(privkey, nonce))。 + #[serde(skip_serializing_if = "Option::is_none")] + pub signature: Option, +} + +/// auth_ok 出参。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthOkParams { + pub capabilities: Vec, + /// ed25519 模式:本次实际被授权的能力(全量子集)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub authorized: Option>, + /// 本次连接采用的认证模式。 + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_mode: Option, + /// v20:被控端语义化版本。 + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_version: Option, + /// v20:被控端构建信息。 + #[serde(skip_serializing_if = "Option::is_none")] + pub build: Option, + /// v20:被控端支持的协议版本。 + #[serde(skip_serializing_if = "Option::is_none")] + pub protocol: Option, +} + +/// 被控端构建信息(v20)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub commit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub built_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cert_sha256: Option, +} + +// ---------- 调用 ---------- + +/// invoke 入参。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InvokeParams { + pub capability: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub args: Option, +} + +/// 能力执行结果(业务层)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InvokeResult { + pub status: String, // "ok" | "failed" + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +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, +} + +/// 能力描述(能力清单条目)。 +#[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, +} + +/// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub properties: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option>, + #[serde(rename = "additionalProperties", skip_serializing_if = "Option::is_none")] + pub additional_properties: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#enum: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub minimum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub maximum: Option, + #[serde(rename = "minLength", skip_serializing_if = "Option::is_none")] + pub min_length: Option, + #[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")] + pub max_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pattern: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub items: Option>, + #[serde(rename = "minItems", skip_serializing_if = "Option::is_none")] + pub min_items: Option, + #[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")] + pub max_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} diff --git a/crates/nodeagent-client/src/ulid.rs b/crates/nodeagent-client/src/ulid.rs new file mode 100644 index 0000000..da318c2 --- /dev/null +++ b/crates/nodeagent-client/src/ulid.rs @@ -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::() % 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 = 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); + } +} diff --git a/docs/RETROSPECTIVE.md b/docs/RETROSPECTIVE.md index b37a105..c39c35d 100644 --- a/docs/RETROSPECTIVE.md +++ b/docs/RETROSPECTIVE.md @@ -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 故障排查 + 重装前信息采集) > 所有问题均来自**实际踩坑**,非理论推演。 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..2de1b02 --- /dev/null +++ b/docs/ROADMAP.md @@ -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` 后再查,且关键词要带空格变体。 \ No newline at end of file diff --git a/docs/console-plan.md b/docs/console-plan.md new file mode 100644 index 0000000..8452ce5 --- /dev/null +++ b/docs/console-plan.md @@ -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 再做,首期聚焦闭环 | + +> 仅供参考,本方案为技术规划,不构成任何投资建议。 diff --git a/packages/protocol/src/capabilities/manifest/graphics.ts b/packages/protocol/src/capabilities/manifest/graphics.ts index 091666c..85e3256 100644 --- a/packages/protocol/src/capabilities/manifest/graphics.ts +++ b/packages/protocol/src/capabilities/manifest/graphics.ts @@ -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' }, + }, + }, + }, ]; diff --git a/packages/protocol/src/capabilities/names.ts b/packages/protocol/src/capabilities/names.ts index 6c318d7..40e835a 100644 --- a/packages/protocol/src/capabilities/names.ts +++ b/packages/protocol/src/capabilities/names.ts @@ -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 文件传输 diff --git a/tests/unit/capabilities.test.mjs b/tests/unit/capabilities.test.mjs index 9c86aeb..7b325f1 100644 --- a/tests/unit/capabilities.test.mjs +++ b/tests/unit/capabilities.test.mjs @@ -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 项能力'); }); // ---------- 参数校验(新能力) ----------