feat(v5): 多设备管理 + 文件传输

方向 1 · 多设备管理:
- 配置模型升级为 nodes{} + current,自动迁移 v1/v2 单设备扁平配置(零破坏)
- 新增 resolveTarget():按名解析生效设备与控制端身份
- CLI: nodes(列表)/ use(切换)/ remove(移除)/ connect --name(命名)
- 全局 --node <设备名> 单次指定目标,前置(--node=x cmd)与后置(cmd --node x)均可

方向 2 · 文件传输(分块 RPC,协议零改动):
- fs.list(递归+glob)/ fs.stat / fs.read(offset+max_bytes 分块)/ fs.write(原子写+append)
- 小文件(<=8MB)首次读取附带 sha256;路径白名单 fs_roots,越界 -> E_ACL_DENIED
- CLI: ls / stat / cat / pull / push(自动分块;pull 与 push 均原子落盘)
- MCP: na_fs_list / na_fs_read / na_fs_write

能力总数 15 -> 19;MCP 工具 12 -> 15

验证:本地 E2E 20/20(新增文件传输全链路用例);构建 + 类型检查全绿;
冒烟确认 3MB 文件 pull 后 sha256 一致、白名单越界被拒
This commit is contained in:
jkooe committed 2026-09-27 13:25:38 +08:00
1 parent 3d0eb1c696
commit 4ff1b4fd90
10 files changed
+999 -44

No files matched your search

+17 -2
View File
@@ -25,8 +25,9 @@ nodeagent 让 Mac 上的 AI(WorkBuddy)通过统一协议接管局域网内
|---|---|
| 协议 | JSON-RPC 2.0 over WebSocket,握手 `hello → challenge → auth → auth_ok` |
| 鉴权 | 预共享密钥 + HMAC-SHA256 挑战-应答(防重放) |
| 能力 | `system.*`(6,含 `audit.list`) + `app.*`(2) + `screen.*`(2) + `input.*`(5,**默认禁用**) |
| 能力 | `system.*`(6) + `app.*`(2) + `screen.*`(2) + `fs.*`(4) + `input.*`(5,**默认禁用**) |
| 接入 | CLI(调试底座)+ MCP(AI 落点) |
| 多设备 | 一台 Mac 可接管多台 Windows(`nodes` 设备表 + `--node` 切换) |
## 快速开始
@@ -75,6 +76,19 @@ node apps/cli/dist/index.js audit --type invoke --client-id mac_01 # 按类别/
# 自动发现(v4,免手抄 IP)
node apps/cli/dist/index.js discover --wait 5 # 局域网内有哪些 Windows
# 多设备(v5)
node apps/cli/dist/index.js connect 192.168.1.100 --key <K> --name win_a # 添加并命名
node apps/cli/dist/index.js connect 192.168.1.101 --key <K> --name win_b
node apps/cli/dist/index.js nodes # 列出所有设备
node apps/cli/dist/index.js use win_a # 切换当前设备
node apps/cli/dist/index.js info --node win_b # 临时指定目标设备
# 文件传输(v5,自动分块,支持大文件)
node apps/cli/dist/index.js ls "C:\Users\me\Desktop" --recursive
node apps/cli/dist/index.js cat "C:\logs\app.log"
node apps/cli/dist/index.js pull "C:\big.iso" --out ./big.iso # 下载(原子落盘)
node apps/cli/dist/index.js push ./script.ps1 "C:\tools\script.ps1" --create-dirs
```
### 3. 接入 WorkBuddy(MCP)
@@ -93,7 +107,7 @@ node apps/cli/dist/index.js discover --wait 5 # 局域网
随后即可用自然语言调度,例如「Windows 上装个 VSCode」「看看 Windows 内存够不够」。
MCP 工具:`na_status` · `na_system_info` · `na_exec` · `na_install` · `na_app_list` · `na_process_list` · `na_service_list` · **`na_screenshot`**(直接返回图片)· **`na_mouse`** · **`na_key`** · **`na_audit`** · **`na_discover`**
MCP 工具:`na_status` · `na_system_info` · `na_exec` · `na_install` · `na_app_list` · `na_process_list` · `na_service_list` · **`na_screenshot`**(直接返回图片)· **`na_mouse`** · **`na_key`** · **`na_audit`** · **`na_discover`** · **`na_fs_list`** · **`na_fs_read`** · **`na_fs_write`**
## 开发
@@ -169,6 +183,7 @@ nodeagent/
| **v3** | 零信任:Ed25519 身份认证 + **能力级 ACL** | ✅ 已实现 |
| **v3+** | **审计日志**(谁在何时调了什么 + 查询能力)+ **限速** | ✅ 已实现 |
| **v4** | 无感体验:**局域网自动发现** + **断线自动重连** | ✅ 已实现 |
| **v5** | **多设备管理** + **文件传输**(分块 / 大文件 / 原子写) | ✅ 已实现 |
> **输入控制安全默认**:`input.*` 为高危能力,**默认禁用**。需在被控端 `agent.json` 设 `"allow_input": true` 重启后生效,或用 `install.ps1 -AllowInput` 安装。
+220
View File
@@ -0,0 +1,220 @@
import { createHash } from 'node:crypto';
import {
appendFileSync,
closeSync,
existsSync,
mkdirSync,
openSync,
readSync,
readdirSync,
renameSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, resolve, sep } from 'node:path';
import { CapabilityError, ErrorCodes, matchPattern } from '@nodeagent/protocol';
type Args = Record<string, unknown>;
/** 小文件读取时才计算 sha256,避免大文件额外扫一遍 */
const HASH_LIMIT_BYTES = 8 * 1024 * 1024;
/**
* 路径白名单(v5)。为空表示不限制(此时访问控制完全交给 v3 的能力级 ACL)。
* 非空时,只允许访问这些根目录之下的路径 —— 用于「只暴露某个工作目录」的场景。
*/
let allowedRoots: string[] = [];
export function setFsRoots(roots: string[] | undefined): void {
allowedRoots = (roots ?? []).map((r) => resolve(r));
}
function guardPath(input: string): string {
if (typeof input !== 'string' || input.length === 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'path 不能为空');
}
const abs = resolve(input);
if (allowedRoots.length === 0) return abs;
const ok = allowedRoots.some((root) => {
if (abs === root) return true;
const prefix = root.endsWith(sep) ? root : root + sep;
return abs.startsWith(prefix);
});
if (!ok) {
throw new CapabilityError(ErrorCodes.ACL_DENIED, `路径不在允许范围内: ${abs}`, {
allowed_roots: allowedRoots,
});
}
return abs;
}
// ---------------- fs.list ----------------
export async function fsList(args: Args): Promise<unknown> {
const dir = guardPath(args['path'] as string);
const recursive = args['recursive'] === true;
const pattern = args['pattern'] as string | undefined;
const maxEntries = (args['max_entries'] as number) ?? 500;
let rootStat;
try {
rootStat = statSync(dir);
} catch {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `无法访问目录: ${dir}`);
}
if (!rootStat.isDirectory()) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不是目录: ${dir}`);
}
const entries: Array<{ name: string; path: string; type: string; size: number; mtime: number }> = [];
// 多取一些以便判断是否截断;同时设硬上限,防止递归失控
const hardLimit = maxEntries * 3;
const walk = (d: string): void => {
if (entries.length >= hardLimit) return;
let items;
try {
items = readdirSync(d, { withFileTypes: true });
} catch {
return; // 单个子目录无权限时跳过,不影响整体
}
for (const item of items) {
if (entries.length >= hardLimit) return;
const full = join(d, item.name);
let st;
try {
st = statSync(full);
} catch {
continue;
}
const type = st.isDirectory() ? 'dir' : st.isFile() ? 'file' : 'other';
if (!pattern || matchPattern(pattern, item.name)) {
entries.push({ name: item.name, path: full, type, size: st.size, mtime: Math.floor(st.mtimeMs) });
}
if (recursive && type === 'dir') walk(full);
}
};
walk(dir);
return {
entries: entries.slice(0, maxEntries),
total: entries.length,
truncated: entries.length > maxEntries,
};
}
// ---------------- fs.stat ----------------
export async function fsStat(args: Args): Promise<unknown> {
const p = guardPath(args['path'] as string);
if (!existsSync(p)) {
return { path: p, type: 'other', size: 0, mtime: 0, exists: false };
}
const st = statSync(p);
return {
path: p,
type: st.isDirectory() ? 'dir' : st.isFile() ? 'file' : 'other',
size: st.size,
mtime: Math.floor(st.mtimeMs),
exists: true,
};
}
// ---------------- fs.read(分块) ----------------
export async function fsRead(args: Args): Promise<unknown> {
const p = guardPath(args['path'] as string);
const encoding: BufferEncoding = args['encoding'] === 'base64' ? 'base64' : 'utf8';
const offset = Math.max(0, (args['offset'] as number) ?? 0);
const maxBytes = (args['max_bytes'] as number) ?? 1024 * 1024;
let st;
try {
st = statSync(p);
} catch {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `无法访问文件: ${p}`);
}
if (st.isDirectory()) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `目标是目录而非文件: ${p}`);
}
const total = st.size;
const len = Math.max(0, Math.min(maxBytes, total - offset));
const buf = Buffer.alloc(len);
if (len > 0) {
const fd = openSync(p, 'r');
try {
readSync(fd, buf, 0, len, offset);
} finally {
closeSync(fd);
}
}
const result: Record<string, unknown> = {
data: buf.toString(encoding),
encoding: encoding === 'base64' ? 'base64' : 'utf8',
offset,
bytes: len,
total_bytes: total,
eof: offset + len >= total,
};
// 小文件首次读取时附带摘要,便于调用方校验完整性
if (offset === 0 && total <= HASH_LIMIT_BYTES) {
try {
const whole = Buffer.alloc(total);
const fd = openSync(p, 'r');
try {
readSync(fd, whole, 0, total, 0);
} finally {
closeSync(fd);
}
result['sha256'] = createHash('sha256').update(whole).digest('hex');
} catch {
/* 摘要失败不影响主返回 */
}
}
return result;
}
// ---------------- fs.write(原子写 / 追加) ----------------
export async function fsWrite(args: Args): Promise<unknown> {
const p = guardPath(args['path'] as string);
const data = args['data'] as string;
const encoding = args['encoding'] === 'base64' ? 'base64' : 'utf8';
const append = args['append'] === true;
const createDirs = args['create_dirs'] === true;
if (typeof data !== 'string') {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'data 必须是字符串');
}
const buf = Buffer.from(data, encoding);
const dir = dirname(p);
if (!existsSync(dir)) {
if (!createDirs) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `目录不存在: ${dir}`, {
hint: '设置 create_dirs: true 可自动创建',
});
}
mkdirSync(dir, { recursive: true });
}
try {
if (append) {
appendFileSync(p, buf);
} else {
// 原子写:先写临时文件再重命名,避免中途失败留下半截文件
const tmp = `${p}.nodeagent-tmp-${process.pid}`;
writeFileSync(tmp, buf);
renameSync(tmp, p);
}
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `写入失败: ${err instanceof Error ? err.message : String(err)}`);
}
return { written: buf.byteLength, total_bytes: statSync(p).size, path: p };
}
+7
View File
@@ -4,6 +4,7 @@ import { systemInfo, systemStatus, processList, serviceList, shellExec, auditLis
import { appList, appInstall } from './app.js';
import { screenInfo, screenCapture } from './screen.js';
import { mouseMove, mouseClick, mouseScroll, keyType, keyPress, setInputPolicy } from './input.js';
import { fsList, fsStat, fsRead, fsWrite, setFsRoots } from './fs.js';
export type CapabilityHandler = (args: Record<string, unknown>) => Promise<unknown>;
@@ -13,6 +14,7 @@ export type CapabilityHandler = (args: Record<string, unknown>) => Promise<unkno
*/
export function createCapabilityRegistry(cfg: AgentConfig): Record<string, CapabilityHandler> {
setInputPolicy({ allowInput: cfg.allow_input === true });
setFsRoots(cfg.fs_roots);
return {
// v1 命令级
[CapabilityNames.SystemInfo]: systemInfo,
@@ -33,5 +35,10 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
[CapabilityNames.KeyPress]: keyPress,
// v3+ 审计
[CapabilityNames.AuditList]: auditList,
// v5 文件传输
[CapabilityNames.FsList]: fsList,
[CapabilityNames.FsStat]: fsStat,
[CapabilityNames.FsRead]: fsRead,
[CapabilityNames.FsWrite]: fsWrite,
};
}
+6
View File
@@ -46,6 +46,12 @@ export interface AgentConfig {
/** 广播间隔毫秒(默认 5000) */
interval_ms?: number;
};
/**
* v5:文件访问白名单(根目录列表)。
* 为空或缺省 = 不限制(访问控制完全交给 v3 的能力级 ACL);
* 非空 = 只允许读写这些目录之下的路径。
*/
fs_roots?: string[];
}
/** 数据目录:优先 NODEAGENT_HOME(跨平台一致),否则 ~/.nodeagent。 */
+297 -21
View File
@@ -1,4 +1,4 @@
import { writeFileSync } from 'node:fs';
import { closeSync, openSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import { parseArgs } from 'node:util';
import {
NodeAgentClient,
@@ -11,7 +11,11 @@ import {
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
@@ -28,10 +32,20 @@ const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
配置并连接被控端(握手成功后打印能力清单)
nodeagent keygen [--id mac_01] 生成 Ed25519 密钥对并输出被控端 ACL 配置片段 (v3 零信任)
nodeagent nodes 列出已配置的被控端设备 (v5 多设备)
nodeagent use <设备名> 切换当前默认设备
nodeagent remove <设备名> 移除设备
nodeagent audit [--limit 20] [--type invoke|auth|acl|agent] [--client-id X] [--since <ms>]
查询被控端审计日志 (v3+)
nodeagent discover [--wait 5] 发现局域网内的被控端(UDP 广播,免手抄 IP)(v4)
文件传输 (v5):
nodeagent ls <远端路径> [--recursive] [--pattern "*.log"] 列目录
nodeagent stat <远端路径> 看元信息
nodeagent cat <远端路径> [--out 本地文件] 读文本(首块)
nodeagent pull <远端路径> [--out 本地文件] 下载(自动分块,支持大文件)
nodeagent push <本地文件> <远端路径> [--create-dirs] 上传(自动分块)
nodeagent info 查看系统信息 (system.info)
nodeagent status 查看资源状态 (system.status)
nodeagent ps [--limit 20] 查看进程 (system.process.list)
@@ -53,6 +67,8 @@ const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
nodeagent key press <键1> [键2] ... (组合键,如 ctrl c)
通用选项:
--node <设备名> 本次命令临时指定目标设备(不改变 current)
可放命令后(nodeagent info --node win_b)或前置(nodeagent --node=win_b info)
--json 以原始 JSON 输出
--config 显示当前配置路径
`;
@@ -83,6 +99,14 @@ interface Options {
/** v4 发现 */
wait?: string;
discoveryPort?: string;
/** v5 多设备 */
node?: string;
name?: string;
note?: string;
/** v5 文件 */
recursive?: boolean;
pattern?: string;
createDirs?: boolean;
}
function getClientConfig(): ClientConfig {
@@ -93,15 +117,25 @@ function getClientConfig(): ClientConfig {
return cfg;
}
async function withClient<T>(fn: (client: NodeAgentClient) => Promise<T>): Promise<T> {
/** 本次命令的临时目标设备(来自全局 --node)。 */
let currentNodeOverride: string | undefined;
async function withClient<T>(fn: (client: NodeAgentClient) => Promise<T>, nodeName?: string): Promise<T> {
const cfg = getClientConfig();
const keys = cfg.auth_mode === 'ed25519' ? loadKeys() : null;
let target: ResolvedTarget;
try {
target = resolveTarget(cfg, nodeName ?? currentNodeOverride);
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
}
const { profile, clientId } = target;
const keys = profile.auth_mode === 'ed25519' ? loadKeys() : null;
const client = new NodeAgentClient({
url: toWsUrl(cfg),
key: cfg.key,
clientId: cfg.client_id,
insecure: cfg.insecure,
authMode: cfg.auth_mode,
url: toWsUrl(profile),
key: profile.key ?? '',
clientId,
insecure: profile.insecure,
authMode: profile.auth_mode,
privateKey: keys?.privateKey,
});
try {
@@ -161,8 +195,9 @@ async function cmdConnect(host: string, opts: Options): Promise<void> {
fail('缺少 --key <预共享密钥>(零信任模式请用 --auth-mode ed25519)');
}
const cfg: ClientConfig = {
client_id: opts.id ?? 'mac_01',
const name = opts.name ?? host;
const clientId = opts.id ?? 'mac_01';
const profile: NodeProfile = {
host,
port,
tls: !process.argv.includes('--no-tls'),
@@ -170,31 +205,39 @@ async function cmdConnect(host: string, opts: Options): Promise<void> {
key: opts.key ?? '',
auth_mode: authMode,
};
if (opts.note) profile.note = opts.note;
const keys = authMode === 'ed25519' ? loadKeys() : null;
if (authMode === 'ed25519' && !keys) {
fail(`零信任模式需要本机密钥,请先运行: nodeagent keygen --id ${cfg.client_id}`);
fail(`零信任模式需要本机密钥,请先运行: nodeagent keygen --id ${clientId}`);
}
const client = new NodeAgentClient({
url: toWsUrl(cfg),
key: cfg.key,
clientId: cfg.client_id,
insecure: cfg.insecure,
url: toWsUrl(profile),
key: profile.key ?? '',
clientId,
insecure: profile.insecure,
authMode,
privateKey: keys?.privateKey,
});
try {
const caps = await client.connect();
const authorized = client.listAuthorized();
// 连接成功才落盘,避免把错配置写进设备表
const cfg = loadConfig() ?? emptyConfig(clientId);
cfg.client_id = clientId;
cfg.nodes[name] = profile;
cfg.current = name;
saveConfig(cfg);
console.log(
`✓ 已连接 ${cfg.host}:${cfg.port}(${cfg.tls ? 'wss' : 'ws'},${authMode}),配置已保存到 ${configPath()}`,
);
const isOk = (name: string): boolean => !authorized || authorized.some((p) => matchPattern(p, name));
console.log(
`\n被控端声明 ${caps.length} 项能力${authorized ? `,其中 ${authorized.length} 项已授权` : ''}:`,
`✓ 已连接并保存设备「${name}」${profile.host}:${profile.port}(${profile.tls ? 'wss' : 'ws'},${authMode})`,
);
console.log(` 配置: ${configPath()}(当前设备 ${name},共 ${Object.keys(cfg.nodes).length} 台)`);
const isOk = (n: string): boolean => !authorized || authorized.some((p) => matchPattern(p, n));
console.log(`\n被控端声明 ${caps.length} 项能力${authorized ? `,其中 ${authorized.length} 项已授权` : ''}:`);
for (const c of caps) {
console.log(` ${isOk(c.name) ? riskIcon(c.risk) : '🚫'} ${c.name.padEnd(24)} ${c.description}`);
}
@@ -206,6 +249,56 @@ async function cmdConnect(host: string, opts: Options): Promise<void> {
}
}
// ---------- v5 多设备管理 ----------
async function cmdNodes(opts: Options): Promise<void> {
const cfg = loadConfig();
if (!cfg || Object.keys(cfg.nodes).length === 0) {
console.log('尚未配置任何设备。用 nodeagent connect <host> --key <密钥> 添加,或 nodeagent discover 先发现。');
return;
}
const names = Object.keys(cfg.nodes);
if (opts.json) {
return printJson({ current: cfg.current, client_id: cfg.client_id, nodes: cfg.nodes });
}
console.log(`控制端身份: ${cfg.client_id} 共 ${names.length} 台设备\n`);
console.log(` ${'名称'.padEnd(16)} ${'地址'.padEnd(22)} ${'协议'.padEnd(6)} ${'认证'.padEnd(9)} 备注`);
for (const n of names) {
const p = cfg.nodes[n]!;
const mark = n === cfg.current ? '●' : ' ';
const auth = `${p.auth_mode ?? 'psk'}${p.insecure ? '*' : ''}`;
console.log(
`${mark} ${n.padEnd(16)} ${`${p.host}:${p.port}`.padEnd(22)} ${(p.tls ? 'wss' : 'ws').padEnd(6)} ${auth.padEnd(9)} ${p.note ?? ''}`,
);
}
console.log('\n(● = 当前设备;认证列 * = 跳过证书校验)');
}
async function cmdUse(name: string | undefined, opts: Options): Promise<void> {
if (!name) fail('用法: nodeagent use <设备名>');
const cfg = loadConfig();
if (!cfg) fail('尚未配置任何设备。请先运行: nodeagent connect <host> --key <密钥>');
if (!cfg.nodes[name]) {
fail(`未配置的设备: ${name};已配置:${Object.keys(cfg.nodes).join(', ') || '(空)'}`);
}
cfg.current = name;
saveConfig(cfg);
if (opts.json) return printJson({ current: cfg.current });
const p = cfg.nodes[name]!;
console.log(`✓ 当前设备已切换为「${name}」 → ${p.host}:${p.port}`);
}
async function cmdRemove(name: string | undefined, opts: Options): Promise<void> {
if (!name) fail('用法: nodeagent remove <设备名>');
const cfg = loadConfig();
if (!cfg || !cfg.nodes[name]) fail(`未配置的设备: ${name}`);
delete cfg.nodes[name];
if (cfg.current === name) cfg.current = Object.keys(cfg.nodes)[0] ?? '';
saveConfig(cfg);
if (opts.json) return printJson({ removed: name, current: cfg.current });
console.log(`✓ 已移除设备「${name}」${cfg.current ? `,当前设备: ${cfg.current}` : ''}`);
}
/** v3:生成 Ed25519 密钥对,并输出可直接粘贴的被控端 ACL 配置片段。 */
async function cmdKeygen(opts: Options): Promise<void> {
const clientId = opts.id ?? 'mac_01';
@@ -535,6 +628,148 @@ async function cmdDiscover(opts: Options): Promise<void> {
console.log(` nodeagent connect ${first.host} --port ${first.port} --key <密钥>${first.tls ? ' --insecure' : ''}`);
}
// ---------- v5 文件传输 ----------
/** 分块大小:1MB(与被控端 max_bytes 默认值配合) */
const CHUNK_BYTES = 1024 * 1024;
async function cmdLs(pathArg: string | undefined, opts: Options): Promise<void> {
if (!pathArg) fail('用法: nodeagent ls <远端路径> [--recursive] [--pattern "*.log"]');
const args: Record<string, unknown> = { path: pathArg };
if (opts.recursive) args['recursive'] = true;
if (opts.pattern) args['pattern'] = opts.pattern;
await withClient((c) =>
callAndPrint(c, CapabilityNames.FsList, args, opts.json, (data) => {
const d = data as {
entries: Array<{ name: string; type: string; size: number; mtime: number }>;
total: number;
truncated: boolean;
};
for (const e of d.entries) {
const icon = e.type === 'dir' ? '📁' : '📄';
const when = new Date(e.mtime).toLocaleString('zh-CN');
console.log(` ${icon} ${e.name.padEnd(34)} ${humanSize(e.size).padStart(10)} ${when}`);
}
console.log(`\n共 ${d.total} 项${d.truncated ? '(已截断,可加 --pattern 过滤)' : ''}`);
}),
);
}
async function cmdStat(pathArg: string | undefined, opts: Options): Promise<void> {
if (!pathArg) fail('用法: nodeagent stat <远端路径>');
await withClient((c) =>
callAndPrint(c, CapabilityNames.FsStat, { path: pathArg }, opts.json, (data) => {
const d = data as { path: string; type: string; size: number; mtime: number; exists: boolean };
if (!d.exists) {
console.log(`✗ 不存在: ${d.path}`);
return;
}
console.log(` 路径 : ${d.path}`);
console.log(` 类型 : ${d.type}`);
console.log(` 大小 : ${humanSize(d.size)}`);
console.log(` 修改 : ${new Date(d.mtime).toLocaleString('zh-CN')}`);
}),
);
}
async function cmdCat(pathArg: string | undefined, opts: Options): Promise<void> {
if (!pathArg) fail('用法: nodeagent cat <远端路径> [--out 本地文件]');
await withClient((c) =>
callAndPrint(c, CapabilityNames.FsRead, { path: pathArg, encoding: 'utf8' }, opts.json, (data) => {
const d = data as { data: string; bytes: number; total_bytes: number; eof: boolean };
if (opts.out) {
writeFileSync(opts.out, d.data, 'utf8');
console.log(`✓ 已保存 ${opts.out}(${d.bytes} 字节${d.eof ? '' : ',文件较大仅首块,请用 pull 下载完整文件'})`);
return;
}
process.stdout.write(d.data);
if (!d.eof) console.log(`\n\n… 仅显示首 ${humanSize(d.bytes)}(共 ${humanSize(d.total_bytes)}),完整下载请用 pull`);
}),
);
}
async function cmdPull(remote: string | undefined, opts: Options): Promise<void> {
if (!remote) fail('用法: nodeagent pull <远端路径> [--out 本地文件]');
const local = opts.out ?? remote.split(/[\\/]/).pop() ?? 'download.bin';
await withClient(async (c) => {
const statRes = await c.invoke(CapabilityNames.FsStat, { path: remote });
if (statRes.status !== 'ok') fail(`读取远端信息失败: ${statRes.error?.message}`);
const st = statRes.data as { exists: boolean; type: string; size: number };
if (!st.exists) fail(`远端文件不存在: ${remote}`);
if (st.type === 'dir') fail(`目标是目录,不是文件: ${remote}(先用 ls 查看)`);
const tmp = `${local}.nodeagent-part`;
const fd = openSync(tmp, 'w');
let offset = 0;
try {
for (;;) {
const r = await c.invoke(CapabilityNames.FsRead, {
path: remote,
encoding: 'base64',
offset,
max_bytes: CHUNK_BYTES,
});
if (r.status !== 'ok') fail(`下载失败: ${r.error?.name}: ${r.error?.message}`);
const d = r.data as { data: string; bytes: number; eof: boolean };
if (d.bytes > 0) writeSync(fd, Buffer.from(d.data, 'base64'));
offset += d.bytes;
if (d.eof || d.bytes === 0) break;
}
} finally {
closeSync(fd);
}
renameSync(tmp, local); // 原子落盘,避免半截文件
console.log(`✓ 已下载 ${remote} → ${local}(${humanSize(offset)})`);
});
}
async function cmdPush(local: string | undefined, remote: string | undefined, opts: Options): Promise<void> {
if (!local || !remote) fail('用法: nodeagent push <本地文件> <远端路径> [--create-dirs]');
let st;
try {
st = statSync(local);
} catch {
fail(`本地文件不存在: ${local}`);
}
if (!st.isFile()) fail(`不是文件: ${local}`);
await withClient(async (c) => {
const fd = openSync(local, 'r');
let offset = 0;
let first = true;
try {
while (offset < st.size) {
const len = Math.min(CHUNK_BYTES, st.size - offset);
const buf = Buffer.alloc(len);
readSync(fd, buf, 0, len, offset);
const r = await c.invoke(CapabilityNames.FsWrite, {
path: remote,
data: buf.toString('base64'),
encoding: 'base64',
append: !first,
create_dirs: first && opts.createDirs === true,
});
if (r.status !== 'ok') fail(`上传失败: ${r.error?.name}: ${r.error?.message}`);
offset += len;
first = false;
}
if (st.size === 0) {
const r = await c.invoke(CapabilityNames.FsWrite, {
path: remote,
data: '',
create_dirs: opts.createDirs === true,
});
if (r.status !== 'ok') fail(`上传失败: ${r.error?.message}`);
}
} finally {
closeSync(fd);
}
console.log(`✓ 已上传 ${local} → ${remote}(${humanSize(offset)})`);
});
}
function parseOptions(rest: string[]): { opts: Options; positionals: string[] } {
const { values, positionals } = parseArgs({
args: rest,
@@ -561,6 +796,12 @@ function parseOptions(rest: string[]): { opts: Options; positionals: string[] }
'client-id': { type: 'string' },
wait: { type: 'string' },
'discovery-port': { type: 'string' },
node: { type: 'string' },
name: { type: 'string' },
note: { type: 'string' },
recursive: { type: 'boolean', default: false },
pattern: { type: 'string' },
'create-dirs': { type: 'boolean', default: false },
},
allowPositionals: true,
strict: false,
@@ -587,14 +828,25 @@ function parseOptions(rest: string[]): { opts: Options; positionals: string[] }
clientId: values['client-id'] as string | undefined,
wait: values['wait'] as string | undefined,
discoveryPort: values['discovery-port'] as string | undefined,
node: values['node'] as string | undefined,
name: values['name'] as string | undefined,
note: values['note'] as string | undefined,
recursive: Boolean(values['recursive']),
pattern: values['pattern'] as string | undefined,
createDirs: Boolean(values['create-dirs']),
},
positionals,
};
}
async function main(): Promise<void> {
const [command, ...rest] = process.argv.slice(2);
const argv = process.argv.slice(2);
// 命令 = 第一个非选项 token,故全局选项可前置(--node=win_b info)
const cmdIdx = argv.findIndex((a) => !a.startsWith('-'));
const command = cmdIdx >= 0 ? argv[cmdIdx] : undefined;
const rest = cmdIdx >= 0 ? [...argv.slice(0, cmdIdx), ...argv.slice(cmdIdx + 1)] : argv;
const { opts, positionals } = parseOptions(rest);
currentNodeOverride = opts.node; // 全局 --node 生效于本次命令
if (rest.includes('--config')) {
console.log(configPath());
@@ -647,12 +899,36 @@ async function main(): Promise<void> {
case 'keygen':
await cmdKeygen(opts);
return;
case 'nodes':
await cmdNodes(opts);
return;
case 'use':
await cmdUse(positionals[0], opts);
return;
case 'remove':
await cmdRemove(positionals[0], opts);
return;
case 'audit':
await cmdAudit(opts);
return;
case 'discover':
await cmdDiscover(opts);
return;
case 'ls':
await cmdLs(positionals[0], opts);
return;
case 'stat':
await cmdStat(positionals[0], opts);
return;
case 'cat':
await cmdCat(positionals[0], opts);
return;
case 'pull':
await cmdPull(positionals[0], opts);
return;
case 'push':
await cmdPush(positionals[0], positionals[1], opts);
return;
case 'screen':
await cmdScreen(opts);
return;
+73 -8
View File
@@ -1,7 +1,15 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { NodeAgentClient, ClientError, loadConfig, loadKeys, toWsUrl, discoverOnce } from '@nodeagent/client';
import {
NodeAgentClient,
ClientError,
loadConfig,
loadKeys,
toWsUrl,
discoverOnce,
resolveTarget,
} from '@nodeagent/client';
import { CapabilityNames, DEFAULT_DISCOVERY_PORT, type InvokeResult } from '@nodeagent/protocol';
/** stderr 日志(stdout 被 MCP 协议占用,禁止打印)。 */
@@ -19,17 +27,24 @@ async function ensureClient(): Promise<NodeAgentClient> {
if (!cfg) {
throw new Error('尚未配置被控端。请先在终端运行: nodeagent connect <host> --port 8765 --key <密钥>');
}
const keys = cfg.auth_mode === 'ed25519' ? loadKeys() : null;
let target;
try {
target = resolveTarget(cfg);
} catch (err) {
throw new Error(err instanceof Error ? err.message : String(err));
}
const { profile, clientId } = target;
const keys = profile.auth_mode === 'ed25519' ? loadKeys() : null;
const c = new NodeAgentClient({
url: toWsUrl(cfg),
key: cfg.key,
clientId: cfg.client_id,
insecure: cfg.insecure,
authMode: cfg.auth_mode,
url: toWsUrl(profile),
key: profile.key ?? '',
clientId,
insecure: profile.insecure,
authMode: profile.auth_mode,
privateKey: keys?.privateKey,
});
await c.connect();
log(`已连接被控端 ${cfg.host}:${cfg.port}(${cfg.auth_mode ?? 'psk'})`);
log(`已连接被控端 ${target.name} ${profile.host}:${profile.port}(${profile.auth_mode ?? 'psk'})`);
client = c;
return c;
}
@@ -195,6 +210,53 @@ const TOOLS = [
additionalProperties: false,
},
},
{
name: 'na_fs_list',
description: '列出被控端某目录下的文件与子目录。用户问"Windows 上某目录有什么文件"时使用。',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: '目录绝对路径,如 C:\\\\Users\\\\me\\\\Desktop' },
pattern: { type: 'string', description: '可选 glob 过滤,如 "*.log"' },
recursive: { type: 'boolean', description: '是否递归子目录' },
},
required: ['path'],
additionalProperties: false,
},
},
{
name: 'na_fs_read',
description:
'读取被控端文件内容(默认 UTF-8)。文件较大时会分块返回,用 offset 续读、看 eof 判断结束。适合读取日志、配置、脚本。',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: '文件绝对路径' },
encoding: { type: 'string', enum: ['utf8', 'base64'], description: '默认 utf8;二进制文件用 base64' },
offset: { type: 'integer', description: '起始字节偏移,默认 0' },
max_bytes: { type: 'integer', description: '单次读取上限,默认 1MB,最大 8MB' },
},
required: ['path'],
additionalProperties: false,
},
},
{
name: 'na_fs_write',
description:
'向被控端写入文件(自动原子写,不会留半截文件)。已有文件可用 append: true 追加;目录不存在时设 create_dirs: true。',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: '文件绝对路径' },
data: { type: 'string', description: '写入内容' },
encoding: { type: 'string', enum: ['utf8', 'base64'], description: '默认 utf8' },
append: { type: 'boolean', description: '是否追加(默认覆盖)' },
create_dirs: { type: 'boolean', description: '目录不存在时自动创建' },
},
required: ['path', 'data'],
additionalProperties: false,
},
},
] as const;
/** MCP 工具入参 → 能力 args。 */
@@ -235,6 +297,9 @@ const TOOL_TO_CAPABILITY: Record<string, string> = {
na_service_list: CapabilityNames.ServiceList,
na_screenshot: CapabilityNames.ScreenCapture,
na_audit: CapabilityNames.AuditList,
na_fs_list: CapabilityNames.FsList,
na_fs_read: CapabilityNames.FsRead,
na_fs_write: CapabilityNames.FsWrite,
};
type Resolved = { capability: string; args: Record<string, unknown> } | { error: string };
+49
View File
@@ -303,6 +303,10 @@
| 13 | `input.key.type` | 🔴🔒 | 输入文本(Unicode 逐字符) | v2 |
| 14 | `input.key.press` | 🔴🔒 | 按下按键/组合键 | v2 |
| 15 | `system.audit.list` | 🟢 | 查询审计日志(谁在何时调了什么) | v3+ |
| 16 | `fs.list` | 🟢 | 列目录(glob / 递归) | v5 |
| 17 | `fs.stat` | 🟢 | 文件元信息 | v5 |
| 18 | `fs.read` | 🟡 | 读文件(分块续读) | v5 |
| 19 | `fs.write` | 🔴 | 写文件(分块 / 原子写) | v5 |
> 🔒 = 受 `allow_input` 开关管控,默认禁用。
>
@@ -551,6 +555,51 @@
> **实测**:E2E 覆盖两项 —— UDP 广播可被发现 / 被控端重启后客户端自动恢复调用。
### 6.7 多设备与文件传输(v5)
#### 多设备管理
控制端配置从「单设备扁平结构」升级为「设备表」:
```jsonc
// ~/.nodeagent/config.json
{
"client_id": "mac_01", // 控制端身份(多台设备共用)
"current": "win_a", // 当前默认设备
"nodes": {
"win_a": { "host": "192.168.1.100", "port": 8765, "tls": true, "insecure": true, "key": "...", "auth_mode": "psk" },
"win_b": { "host": "192.168.1.101", "port": 8765, "tls": true, "insecure": true, "auth_mode": "ed25519", "note": "工位机" }
}
}
```
| 命令 | 作用 |
|---|---|
| `nodeagent connect <host> --key <K> --name win_a` | 连接测试通过后**才**写入设备表,避免脏配置 |
| `nodeagent nodes` | 列出设备(● 标记当前设备) |
| `nodeagent use <name>` | 切换默认设备 |
| `nodeagent remove <name>` | 移除设备(若移除的是当前设备,自动切到第一个) |
| `nodeagent <cmd> --node <name>` | 单次命令临时指定目标,不改动 `current` |
> **零破坏迁移**:`loadConfig()` 会自动把 v1/v2 的扁平配置迁移为 `nodes.default`,老配置文件无需手工改写。
#### 文件传输
传输层用**分块 RPC**(而非新增二进制帧)—— 协议无改动、天然支持断点与进度、实现简单:
| 能力 | 关键设计 |
|---|---|
| `fs.read` | `offset` + `max_bytes` 分块;`eof` 标识结束;小文件(≤8MB)首次读取附带 `sha256` 便于校验 |
| `fs.write` | 覆盖写走**临时文件 + 原子重命名**(不会留半截文件);`append: true` 追加;`create_dirs` 自动建目录 |
| `fs.list` | 递归 + glob 过滤;递归设硬上限(`max_entries × 3`)防止失控 |
| `fs.stat` | 不存在时返回 `exists: false` 而非报错,便于探测 |
**路径白名单**:被控端可配 `fs_roots: ["C:\\work"]`,非空时只允许访问这些根目录之下的路径,越界返回 `E_ACL_DENIED`;为空则完全交给 v3 的能力级 ACL 管控。
CLI 便捷命令:`ls` / `stat` / `cat` / `pull` / `push`(pull 与 push 自动分块,支持任意大小文件;下载同样**原子落盘**)。
> **实测**:E2E 覆盖 —— 写入 → 读回 → 递归列表 → **1.5MB 分块重组 sha256 一致** → append 语义 → 白名单越界被拒。
---
## 第 7 章 各端实现指南
+77 -13
View File
@@ -2,18 +2,37 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'n
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
/** 控制端配置(存于 ~/.nodeagent/config.json)。 */
export interface ClientConfig {
client_id: string;
/** 单台被控端的连接配置。 */
export interface NodeProfile {
host: string;
port: number;
tls: boolean;
/** v1 简化:跳过自签证书校验(TLS 仍加密,另由 HMAC/签名鉴权兜底) */
/** 跳过自签证书校验(TLS 仍加密,另由 HMAC / 签名鉴权兜底) */
insecure?: boolean;
/** v1 简化:预共享密钥存于 600 权限文件;v2 升级 OS 密钥链 */
key: string;
/** v3:认证模式;`psk`(默认)或 `ed25519`(私钥存于 keys/ed25519.json) */
/** psk 模式的预共享密钥 */
key?: string;
/** 认证模式;默认 psk */
auth_mode?: 'psk' | 'ed25519';
/** 覆盖全局 client_id(同一控制端接入多台时可区分身份) */
client_id?: string;
/** 备注,便于识别 */
note?: string;
}
/** 控制端配置(存于 ~/.nodeagent/config.json),支持多设备。 */
export interface ClientConfig {
/** 控制端身份标识(对多台设备通用) */
client_id: string;
/** 当前默认设备名 */
current: string;
/** 设备表:设备名 → 连接配置 */
nodes: Record<string, NodeProfile>;
}
export interface ResolvedTarget {
name: string;
profile: NodeProfile;
clientId: string;
}
/** 数据目录:优先 NODEAGENT_HOME(跨平台一致),否则 ~/.nodeagent。 */
@@ -25,19 +44,49 @@ export function configPath(): string {
return join(configDir(), 'config.json');
}
/** 把 v1/v2 的单设备扁平配置迁移为多设备结构(幂等、零破坏)。 */
function migrate(raw: Record<string, unknown>): ClientConfig {
const nodes = raw['nodes'];
if (nodes && typeof nodes === 'object' && !Array.isArray(nodes)) {
const cfg = raw as unknown as ClientConfig;
if (!cfg.current || !cfg.nodes[cfg.current]) cfg.current = Object.keys(cfg.nodes)[0] ?? '';
if (!cfg.client_id) cfg.client_id = 'mac_01';
return cfg;
}
// 旧格式:{ client_id, host, port, tls, insecure, key, auth_mode }
const name = typeof raw['node_name'] === 'string' ? raw['node_name'] : 'default';
return {
client_id: typeof raw['client_id'] === 'string' ? raw['client_id'] : 'mac_01',
current: name,
nodes: {
[name]: {
host: typeof raw['host'] === 'string' ? raw['host'] : '',
port: typeof raw['port'] === 'number' ? raw['port'] : 8765,
tls: raw['tls'] !== false,
insecure: Boolean(raw['insecure']),
key: typeof raw['key'] === 'string' ? raw['key'] : '',
auth_mode: raw['auth_mode'] === 'ed25519' ? 'ed25519' : 'psk',
},
},
};
}
export function loadConfig(): ClientConfig | null {
const p = configPath();
if (!existsSync(p)) return null;
const cfg = JSON.parse(readFileSync(p, 'utf8')) as ClientConfig;
const raw = JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown>;
const cfg = migrate(raw);
const envKey = process.env['NODEAGENT_KEY'];
if (envKey) cfg.key = envKey;
const cur = cfg.nodes[cfg.current];
if (envKey && cur) cur.key = envKey;
return cfg;
}
export function saveConfig(cfg: ClientConfig): void {
const p = configPath();
mkdirSync(dirname(p), { recursive: true });
writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
writeFileSync(p, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
try {
chmodSync(p, 0o600);
} catch {
@@ -45,8 +94,23 @@ export function saveConfig(cfg: ClientConfig): void {
}
}
/** 解析生效目标(未指定则取 current)。 */
export function resolveTarget(cfg: ClientConfig, name?: string): ResolvedTarget {
const target = name ?? cfg.current;
const profile = cfg.nodes[target];
if (!profile) {
const known = Object.keys(cfg.nodes).join(', ') || '(空)';
throw new Error(`未配置的设备: ${target};已配置:${known}`);
}
return { name: target, profile, clientId: profile.client_id ?? cfg.client_id };
}
/** 解析连接目标为 WebSocket URL。 */
export function toWsUrl(cfg: Pick<ClientConfig, 'host' | 'port' | 'tls'>): string {
const scheme = cfg.tls ? 'wss' : 'ws';
return `${scheme}://${cfg.host}:${cfg.port}`;
export function toWsUrl(profile: { host: string; port: number; tls: boolean }): string {
const scheme = profile.tls ? 'wss' : 'ws';
return `${scheme}://${profile.host}:${profile.port}`;
}
export function emptyConfig(clientId = 'mac_01'): ClientConfig {
return { client_id: clientId, current: '', nodes: {} };
}
+121
View File
@@ -19,6 +19,11 @@ export const CapabilityNames = {
KeyPress: 'input.key.press',
// v3+ 审计
AuditList: 'system.audit.list',
// v5 文件传输
FsList: 'fs.list',
FsStat: 'fs.stat',
FsRead: 'fs.read',
FsWrite: 'fs.write',
} as const;
export type CapabilityName = (typeof CapabilityNames)[keyof typeof CapabilityNames];
@@ -466,6 +471,122 @@ export const CAPABILITY_MANIFEST: CapabilityDescriptor[] = [
},
},
},
// ---------- v5 文件传输 ----------
{
name: CapabilityNames.FsList,
version: '1.0',
description: '列出目录内容(支持 glob 过滤与递归)',
risk: 'low',
params_schema: {
type: 'object',
properties: {
path: { type: 'string', minLength: 1 },
pattern: { type: 'string', description: 'glob 过滤,如 "*.log"' },
recursive: { type: 'boolean', default: false },
max_entries: { type: 'integer', default: 500, minimum: 1, maximum: 5000 },
},
required: ['path'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
entries: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
path: { type: 'string' },
type: { type: 'string', enum: ['file', 'dir', 'other'] },
size: { type: 'integer' },
mtime: { type: 'integer' },
},
},
},
total: { type: 'integer' },
truncated: { type: 'boolean' },
},
},
},
{
name: CapabilityNames.FsStat,
version: '1.0',
description: '获取文件或目录的元信息',
risk: 'low',
params_schema: {
type: 'object',
properties: { path: { type: 'string', minLength: 1 } },
required: ['path'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
path: { type: 'string' },
type: { type: 'string', enum: ['file', 'dir', 'other'] },
size: { type: 'integer' },
mtime: { type: 'integer' },
exists: { type: 'boolean' },
},
},
},
{
name: CapabilityNames.FsRead,
version: '1.0',
description: '读取文件内容(分块:用 offset/max_bytes 续读,eof 标识结束)',
risk: 'medium',
params_schema: {
type: 'object',
properties: {
path: { type: 'string', minLength: 1 },
encoding: { type: 'string', enum: ['utf8', 'base64'], default: 'utf8' },
offset: { type: 'integer', default: 0, minimum: 0, description: '起始字节偏移' },
max_bytes: { type: 'integer', default: 1048576, minimum: 1, maximum: 8388608, description: '单次读取上限' },
},
required: ['path'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
data: { type: 'string' },
encoding: { type: 'string' },
offset: { type: 'integer' },
bytes: { type: 'integer', description: '本次实际读取字节数' },
total_bytes: { type: 'integer' },
eof: { type: 'boolean' },
sha256: { type: 'string', description: '整文件摘要(仅首次读取时返回)' },
},
},
},
{
name: CapabilityNames.FsWrite,
version: '1.0',
description: '写入文件(分块:append=true 追加;写入走临时文件 + 原子重命名)',
risk: 'high',
params_schema: {
type: 'object',
properties: {
path: { type: 'string', minLength: 1 },
data: { type: 'string' },
encoding: { type: 'string', enum: ['utf8', 'base64'], default: 'utf8' },
append: { type: 'boolean', default: false },
create_dirs: { type: 'boolean', default: false },
},
required: ['path', 'data'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
written: { type: 'integer', description: '本次写入字节数' },
total_bytes: { type: 'integer', description: '写入后的文件总大小' },
path: { type: 'string' },
},
},
},
];
/** 按名称查找能力描述。 */
+132
View File
@@ -8,6 +8,7 @@
*/
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -25,6 +26,8 @@ const DISCOVERY_PORT = 18796;
const DISCOVERY_AGENT_PORT = 18797;
const RECONNECT_PORT = 18798;
const RECONNECT_KEY = 'reconnect-key-0123456789abcdef0123456789';
const FS_PORT = 18799;
const FS_KEY = 'fs-key-0123456789abcdef0123456789abcdef';
const KEY = 'e2e-test-key-0123456789abcdef0123456789abcdef';
const URL = `ws://127.0.0.1:${PORT}`;
@@ -395,6 +398,132 @@ async function testAutoReconnect() {
}
}
/** v5 场景:文件传输(分块读写 + 列表 + append + 白名单)。 */
async function testFileTransfer() {
const dataDir = mkdtempSync(join(tmpdir(), 'nodeagent-fs-'));
const fsRoot = mkdtempSync(join(tmpdir(), 'nodeagent-fsroot-'));
writeFileSync(
join(dataDir, 'agent.json'),
JSON.stringify({
node_id: 'fs_node',
host: '127.0.0.1',
port: FS_PORT,
tls: false,
key: FS_KEY,
log_level: 'warn',
discovery: { enabled: false },
fs_roots: [fsRoot],
}),
);
const child = spawn(process.execPath, [join(root, 'apps/agent/dist/index.js')], {
env: { ...process.env, NODEAGENT_HOME: dataDir, HOME: dataDir, USERPROFILE: dataDir },
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stderr.on('data', (d) => process.stderr.write(`[fs-agent] ${d}`));
const url = `ws://127.0.0.1:${FS_PORT}`;
const mk = () => new NodeAgentClient({ url, key: FS_KEY, clientId: 'fs_test' });
try {
// 等待就绪
const deadline = Date.now() + 20_000;
let ready = false;
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`fs 被控端已退出 (code=${child.exitCode})`);
try {
const p = mk();
await p.connect();
p.close();
ready = true;
break;
} catch {
await new Promise((r) => setTimeout(r, 300));
}
}
if (!ready) throw new Error('fs 被控端未就绪');
const c = mk();
await c.connect();
// 1. 写入(自动建目录)
const nested = join(fsRoot, 'sub', 'hello.txt');
const w = await c.invoke('fs.write', { path: nested, data: '你好 nodeagent', create_dirs: true });
assert.equal(w.status, 'ok', `写入失败: ${JSON.stringify(w.error)}`);
assert.equal(w.data.written, Buffer.byteLength('你好 nodeagent'), 'written 应为 UTF-8 字节数');
// 2. 读回 + sha256
const r = await c.invoke('fs.read', { path: nested });
assert.equal(r.status, 'ok');
assert.equal(r.data.data, '你好 nodeagent');
assert.equal(r.data.eof, true, '小文件应一次读完');
assert.ok(typeof r.data.sha256 === 'string' && r.data.sha256.length === 64, '小文件应返回 sha256');
// 3. stat + 递归 list
const s = await c.invoke('fs.stat', { path: nested });
assert.equal(s.data.type, 'file');
assert.ok(s.data.size > 0, 'size 应大于 0');
const l = await c.invoke('fs.list', { path: fsRoot, recursive: true });
assert.equal(l.status, 'ok');
assert.ok(
l.data.entries.some((e) => e.name === 'hello.txt'),
'递归列表应包含刚写入的文件',
);
// 4. 大文件分块:写 1.5MB → 按 512KB 分块读 → 重组校验
const chunk = 512 * 1024;
const payload = Buffer.alloc(1_500_000);
for (let i = 0; i < payload.length; i += 1) payload[i] = i % 251;
const bigPath = join(fsRoot, 'big.bin');
const wBig = await c.invoke(
'fs.write',
{ path: bigPath, data: payload.toString('base64'), encoding: 'base64' },
60_000,
);
assert.equal(wBig.status, 'ok', `大文件写入失败: ${JSON.stringify(wBig.error)}`);
assert.equal(wBig.data.total_bytes, payload.length);
const parts = [];
let offset = 0;
for (;;) {
const rr = await c.invoke(
'fs.read',
{ path: bigPath, encoding: 'base64', offset, max_bytes: chunk },
60_000,
);
assert.equal(rr.status, 'ok');
parts.push(Buffer.from(rr.data.data, 'base64'));
offset += rr.data.bytes;
if (rr.data.eof || rr.data.bytes === 0) break;
}
const reassembled = Buffer.concat(parts);
assert.equal(reassembled.length, payload.length, `分块重组长度应一致 (${reassembled.length} vs ${payload.length})`);
assert.equal(
createHash('sha256').update(reassembled).digest('hex'),
createHash('sha256').update(payload).digest('hex'),
'分块重组内容应与原文件完全一致',
);
// 5. append 追加(而非覆盖)
const appendedPath = join(fsRoot, 'lines.txt');
await c.invoke('fs.write', { path: appendedPath, data: 'line1\n' });
await c.invoke('fs.write', { path: appendedPath, data: 'line2\n', append: true });
const appended = await c.invoke('fs.read', { path: appendedPath });
assert.equal(appended.data.data, 'line1\nline2\n', 'append 应追加');
// 6. 白名单越界 → E_ACL_DENIED
const outsidePath = process.platform === 'win32' ? 'C:\\Windows\\win.ini' : '/etc/hosts';
const outside = await c.invoke('fs.stat', { path: outsidePath });
assert.equal(outside.status, 'failed', '越界路径应被拒绝');
assert.equal(outside.error.name, 'E_ACL_DENIED', `期望 E_ACL_DENIED,实际 ${outside.error.name}`);
c.close();
} finally {
child.kill();
await new Promise((r) => setTimeout(r, 300));
}
}
async function main() {
console.log('\nnodeagent 端到端测试\n');
const agent = startAgent();
@@ -584,6 +713,9 @@ async function main() {
// ---------- v4 无感体验 ----------
await test('v4 局域网发现:UDP 广播可被发现', testDiscovery);
await test('v4 断线自动重连:被控端重启后自动恢复', testAutoReconnect);
// ---------- v5 文件传输 ----------
await test('v5 文件传输:写入→读回→列表→分块重组→append→白名单', testFileTransfer);
} finally {
agent.kill('SIGTERM');
await new Promise((r) => setTimeout(r, 300));