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:
1 parent
3d0eb1c696
commit
4ff1b4fd90
10 files changed
+999
-44
No files matched your search
@@ -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` 安装。
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -46,6 +46,12 @@ export interface AgentConfig {
|
||||
/** 广播间隔毫秒(默认 5000) */
|
||||
interval_ms?: number;
|
||||
};
|
||||
/**
|
||||
* v5:文件访问白名单(根目录列表)。
|
||||
* 为空或缺省 = 不限制(访问控制完全交给 v3 的能力级 ACL);
|
||||
* 非空 = 只允许读写这些目录之下的路径。
|
||||
*/
|
||||
fs_roots?: string[];
|
||||
}
|
||||
|
||||
/** 数据目录:优先 NODEAGENT_HOME(跨平台一致),否则 ~/.nodeagent。 */
|
||||
|
||||
+297
-21
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 章 各端实现指南
|
||||
|
||||
@@ -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: {} };
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** 按名称查找能力描述。 */
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user