From 4ff1b4fd9021755d7ed060da5baadc5618dcbc3d Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:25:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(v5):=20=E5=A4=9A=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=AE=A1=E7=90=86=20+=20=E6=96=87=E4=BB=B6=E4=BC=A0=E8=BE=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 方向 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 一致、白名单越界被拒 --- README.md | 19 +- apps/agent/src/capabilities/fs.ts | 220 ++++++++++++++++++ apps/agent/src/capabilities/index.ts | 7 + apps/agent/src/config.ts | 6 + apps/cli/src/index.ts | 318 ++++++++++++++++++++++++-- apps/mcp/src/index.ts | 81 ++++++- docs/DEVELOPMENT.md | 49 ++++ packages/client/src/config.ts | 90 ++++++-- packages/protocol/src/capabilities.ts | 121 ++++++++++ tests/e2e/run.mjs | 132 +++++++++++ 10 files changed, 999 insertions(+), 44 deletions(-) create mode 100644 apps/agent/src/capabilities/fs.ts diff --git a/README.md b/README.md index 12d41ee..fd1e404 100644 --- a/README.md +++ b/README.md @@ -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 --name win_a # 添加并命名 +node apps/cli/dist/index.js connect 192.168.1.101 --key --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` 安装。 diff --git a/apps/agent/src/capabilities/fs.ts b/apps/agent/src/capabilities/fs.ts new file mode 100644 index 0000000..1d0b43c --- /dev/null +++ b/apps/agent/src/capabilities/fs.ts @@ -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; + +/** 小文件读取时才计算 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 { + 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 { + 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 { + 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 = { + 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 { + 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 }; +} diff --git a/apps/agent/src/capabilities/index.ts b/apps/agent/src/capabilities/index.ts index 3aac12d..d411206 100644 --- a/apps/agent/src/capabilities/index.ts +++ b/apps/agent/src/capabilities/index.ts @@ -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) => Promise; @@ -13,6 +14,7 @@ export type CapabilityHandler = (args: Record) => Promise { 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 切换当前默认设备 + nodeagent remove <设备名> 移除设备 nodeagent audit [--limit 20] [--type invoke|auth|acl|agent] [--client-id X] [--since ] 查询被控端审计日志 (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(fn: (client: NodeAgentClient) => Promise): Promise { +/** 本次命令的临时目标设备(来自全局 --node)。 */ +let currentNodeOverride: string | undefined; + +async function withClient(fn: (client: NodeAgentClient) => Promise, nodeName?: string): Promise { 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 { 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 { 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 { } } +// ---------- v5 多设备管理 ---------- + +async function cmdNodes(opts: Options): Promise { + const cfg = loadConfig(); + if (!cfg || Object.keys(cfg.nodes).length === 0) { + console.log('尚未配置任何设备。用 nodeagent connect --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 { + if (!name) fail('用法: nodeagent use <设备名>'); + const cfg = loadConfig(); + if (!cfg) fail('尚未配置任何设备。请先运行: nodeagent connect --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 { + 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 { const clientId = opts.id ?? 'mac_01'; @@ -535,6 +628,148 @@ async function cmdDiscover(opts: Options): Promise { 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 { + if (!pathArg) fail('用法: nodeagent ls <远端路径> [--recursive] [--pattern "*.log"]'); + const args: Record = { 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 { + 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 { + 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 { + 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 { + 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 { - 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 { 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; diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index 10920fd..8f83719 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -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 { if (!cfg) { throw new Error('尚未配置被控端。请先在终端运行: nodeagent connect --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 = { 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 } | { error: string }; diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2dec60d..748f512 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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 --key --name win_a` | 连接测试通过后**才**写入设备表,避免脏配置 | +| `nodeagent nodes` | 列出设备(● 标记当前设备) | +| `nodeagent use ` | 切换默认设备 | +| `nodeagent remove ` | 移除设备(若移除的是当前设备,自动切到第一个) | +| `nodeagent --node ` | 单次命令临时指定目标,不改动 `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 章 各端实现指南 diff --git a/packages/client/src/config.ts b/packages/client/src/config.ts index e53b08f..96eb971 100644 --- a/packages/client/src/config.ts +++ b/packages/client/src/config.ts @@ -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; +} + +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): 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; + 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): 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: {} }; } diff --git a/packages/protocol/src/capabilities.ts b/packages/protocol/src/capabilities.ts index 7e5c246..808aeb2 100644 --- a/packages/protocol/src/capabilities.ts +++ b/packages/protocol/src/capabilities.ts @@ -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' }, + }, + }, + }, ]; /** 按名称查找能力描述。 */ diff --git a/tests/e2e/run.mjs b/tests/e2e/run.mjs index f2589ef..4e11008 100644 --- a/tests/e2e/run.mjs +++ b/tests/e2e/run.mjs @@ -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));