Files
nodeagent/crates/nodeagent-client/examples/probe.rs
T
jkooe 8ab8f8ae80 feat(crates): 入库 Rust 控制端客户端 nodeagent-client
复刻 @nodeagent/client 的协议行为,供桌面控制台(Tauri 壳)经 Cargo path 依赖:
- 模块:error / types / ulid / crypto / tls / discovery / client
- 握手:hello → challenge → auth → auth_ok;psk(HMAC-SHA256) 与 ed25519 两种模式
- 证书指纹 sha256(DER) 钉住(E_CERT_MISMATCH);ULID(Crockford Base32)
- 断线指数退避重连 1s→30s(±15% 抖动)

Rust async Send 三层陷阱(本轮主要成本,解法已写入 client.rs 注释):
1. futures_util::lock::Mutex 无手写 Sync 实现 → 含它的类型整体 !Sync
2. tokio::sync::MutexGuard 非 Send
3. establish → spawn(read_loop) → on_disconnected → establish 构成**类型级递归**,
   编译器无法证明 Send。最终采用**监督者模式**:单个 supervise 任务串行
   「建立+握手 → 等读循环 Notify 断开 → 退避重连」,读循环只 notify 不回调
   establish,从而打破递归;发送半用专用写任务 + mpsc 解耦(同步 send,不跨 await 持锁)。

测试:9 单测(crypto/ulid/Send+Sync 断言)+ 2 E2E(psk 握手→invoke 全链路、连不上端口
返回 NODE_OFFLINE),`cargo clippy --all-targets -- -D warnings` 零告警。
另附 examples/connect.rs(连真实被控端)与 examples/probe.rs(字段契约探针,
输出顶层键+数组首元素键,并按 system.info.os 区分「平台不支持」与「真失败」)。

.gitignore:crates/ 转正后必须显式忽略 /crates/target/ —— 此前是靠整个 /crates/
被忽略才没让 4GB 级构建产物进库,取消那条保护后会裸奔。
2026-10-07 10:13:41 +08:00

189 lines
6.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! M3 能力探针:对真实被控端逐项调用「文件 / 审计 / 软件 / 进程服务」能力,核对契约。
//!
//! 输出**字段契约**而非数据预览——顶层键 + 数组首元素的键,
//! 便于与前端 `apps/console/src/types/index.ts` 的类型声明逐项比对。
//!
//! 用法:`cargo run --example probe -- <url> <key> [base_dir] [--full]`
//! --full 额外打印完整 JSON(长字段截断到 400 字符)
use nodeagent_client::{Client, ClientOptions};
use serde_json::{json, Value};
/// 一行对象 → `a, b, c` 形式的键清单。
fn keys_of(v: &Value) -> String {
match v {
Value::Object(m) => m.keys().cloned().collect::<Vec<_>>().join(", "),
other => format!("<{}>", type_name(other)),
}
}
fn type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
/// 打印「字段契约」:顶层键 + 各数组字段的首元素键。
fn contract(v: &Value, indent: &str) {
if let Value::Object(map) = v {
for (k, val) in map {
match val {
Value::Array(items) if !items.is_empty() => {
println!("{indent}{k}: array[{}] → {{ {} }}", items.len(), keys_of(&items[0]));
}
other if other.is_object() => {
println!("{indent}{k}: object → {{ {} }}", keys_of(other));
}
other => {
let shown = match other {
Value::String(s) if s.chars().count() > 60 => {
format!("string(\"{}…\")", s.chars().take(60).collect::<String>())
}
Value::Array(a) => format!("array[{}]", a.len()),
_ => format!("{}({other})", type_name(other)),
};
println!("{indent}{k}: {shown}");
}
}
}
} else {
println!("{indent}{}", type_name(v));
}
}
async fn call(client: &Client, cap: &str, args: Value, full: bool) -> bool {
match client.invoke(cap, args).await {
Ok(r) if r.status == "ok" => {
println!(" ✓ {cap}");
if let Some(d) = &r.data {
contract(d, " ");
if full {
let s = serde_json::to_string_pretty(d).unwrap_or_default();
let s: String = s.chars().take(400).collect();
println!(" ── full ── {s}");
}
}
true
}
Ok(r) => {
let e = r.error.unwrap_or_default();
println!(" ✗ {cap}: [{}] {}", e.name, e.message);
false
}
Err(e) => {
println!(" ✗ {cap}: {e}");
false
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<String> = std::env::args().skip(1).collect();
let full = argv.iter().any(|a| a == "--full");
let pos: Vec<String> = argv.into_iter().filter(|a| !a.starts_with("--")).collect();
let url = pos.first().cloned().unwrap_or_else(|| "ws://127.0.0.1:8765".to_string());
let key = pos.get(1).cloned().unwrap_or_default();
let base = pos.get(2).cloned().unwrap_or_else(|| "/tmp/nodeagent-e2e".to_string());
let client = Client::new(ClientOptions {
url,
key,
client_id: "probe".to_string(),
insecure: true,
..Default::default()
});
let caps = client.connect().await?;
println!("连接成功,{} 项能力", caps.len());
// 平台探测——决定 platform-only 能力是否预期可用。
let info = client.invoke("system.info", json!({})).await?;
let os = info
.data
.as_ref()
.and_then(|d| d.get("os"))
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
let is_windows = os == "Windows";
println!("被控端平台:{os}(platform-only 能力{}预期可用)\n", if is_windows { "" } else { "不" });
let mut pass = 0usize;
let mut fail = 0usize;
println!("【文件 fs.*】");
for (cap, args) in [
("fs.list", json!({ "path": base })),
("fs.stat", json!({ "path": format!("{base}/agent.json") })),
("fs.read", json!({ "path": format!("{base}/agent.json"), "max_bytes": 2048 })),
(
"fs.write",
json!({ "path": format!("{base}/probe.txt"), "data": "hello from rust probe", "append": false, "create_dirs": true }),
),
] {
if call(&client, cap, args, full).await {
pass += 1
} else {
fail += 1
}
}
println!("\n【审计 system.audit.*】");
for (cap, args) in [
("system.audit.list", json!({ "limit": 5 })),
("system.audit.verify", json!({})),
] {
if call(&client, cap, args, full).await {
pass += 1
} else {
fail += 1
}
}
println!("\n【软件 / 进程 / 服务】");
let platform_only = ["app.list", "app.install", "system.service.list"];
for (cap, args) in [
("app.list", json!({})),
("app.install", json!({ "package": "__nonexistent_probe__", "silent": true })),
("system.process.list", json!({ "limit": 5 })),
("system.service.list", json!({ "limit": 5 })),
] {
let ok = call(&client, cap, args, full).await;
// platform-only 能力在非 Windows 被控端上失败属**预期**,不计入失败。
if ok {
pass += 1;
} else if !is_windows && platform_only.contains(&cap) {
println!(" ↑ 非 Windows 被控端,属预期");
} else {
fail += 1;
}
}
println!("\n【其他页面依赖】");
for (cap, args) in [
("system.info", json!({})),
("system.status", json!({})),
("system.shell.exec", json!({ "command": "echo probe-ok" })),
] {
if call(&client, cap, args, full).await {
pass += 1
} else {
fail += 1
}
}
println!("\n═══ 通过 {pass} · 失败 {fail} ═══");
client.close();
if fail > 0 {
std::process::exit(1);
}
Ok(())
}