feat(E1+E2): 事件订阅与监控 —— agent 主动推送(能力 31 -> 35)

## 协议
- 新增 Methods.Event = 'event':协议中唯一的**无 id 通知**(JSON-RPC notification),
  被控端 → 控制端主动推送,控制端不回复
- 4 项能力:event.watch / event.unwatch / event.list / event.poll
- 能力处理器签名扩展为 (args, ctx?):ctx 携带 owner(连接标识)与 emit(推送出口)

## 被控端(apps/agent/src/events.ts)
三类 watcher:
- file    —— fs.watch(支持 recursive + glob 过滤),报 created_or_removed / modified
- process —— 轮询 tasklist(win)/ps(posix) 做差分,报 process_started / process_exited
- net     —— 轮询 netstat 提取 LISTEN 端口做差分,报 port_opened / port_closed
两条出口并存:
1) **推送**:通过 event 通知实时发给创建订阅的连接
2) **环形缓冲 + 游标**:500 条上限、seq 游标增量拉取(适配无法收推送的 MCP)
其它:首次采样只建基线(避免开订阅就刷出几百条);连接断开自动清理该连接的订阅(防泄漏)

## 控制端
- NodeAgentClient 新增 onEvent 回调;handleMessage 把无 id 的 event 通知分发过去
- CLI: nodeagent events [--kind file|process|net] [--path/--pattern/--recursive/--seconds]
       无 --kind 则列出当前订阅
- MCP: na_event_watch / na_event_poll / na_event_list / na_event_unwatch

## 真机验证(三类全部实测)
- 文件:创建/修改/删除 t1.txt -> 3 个事件实时推送(14:16:33/34/35)
- 进程:启动并关闭 Notepad.exe -> process_started / process_exited
- 端口:开-关 8899 -> port_opened / port_closed(基线 45 个监听端口)
全部**零轮询**到达控制端(由被控端主动推)。

单测 61 -> 63(补 event.watch 枚举/边界与 event.poll 游标校验)、e2e 21/21 全绿
This commit is contained in:
jkooe committed 2026-09-29 14:19:09 +08:00
1 parent 7efe717cf8
commit 0a47c5a92d
9 files changed
+635 -5

No files matched your search

+18 -1
View File
@@ -7,10 +7,21 @@ import { windowList, windowFocus, screenFind } from './window.js';
import { screenRecord } from './record.js';
import { taskList, taskGet, taskKill } from './task.js';
import { clipGet, clipSet } from './clipboard.js';
import { eventWatch, eventUnwatch, eventList, eventPoll } from '../events.js';
import { mouseMove, mouseClick, mouseScroll, mouseDrag, keyType, keyPress, setInputPolicy } from './input.js';
import { fsList, fsStat, fsRead, fsWrite, setFsRoots } from './fs.js';
export type CapabilityHandler = (args: Record<string, unknown>) => Promise<unknown>;
export interface HandlerContext {
/** 连接标识(事件订阅归属 / 断开清理) */
owner: string;
/** 向该连接推送事件 */
emit: (evt: unknown) => void;
}
export type CapabilityHandler = (
args: Record<string, unknown>,
ctx?: HandlerContext,
) => Promise<unknown>;
/**
* 装配能力注册表。
@@ -60,5 +71,11 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
[CapabilityNames.TaskKill]: taskKill,
[CapabilityNames.ClipGet]: clipGet,
[CapabilityNames.ClipSet]: clipSet,
// v12 事件订阅
[CapabilityNames.EventWatch]: (args, ctx) =>
eventWatch(args, ctx ?? { owner: 'unknown', emit: () => undefined }),
[CapabilityNames.EventUnwatch]: eventUnwatch,
[CapabilityNames.EventList]: eventList,
[CapabilityNames.EventPoll]: eventPoll,
};
}
+274
View File
@@ -0,0 +1,274 @@
import { watch, type FSWatcher } from 'node:fs';
import { exec } from 'node:child_process';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
type Args = Record<string, unknown>;
/**
* 被控端事件订阅(v12 / E1+E2)。
*
* 三类 watcher:
* file —— fs.watch 文件变动(增/删/改),支持递归
* process —— 轮询进程列表做差分,报「启动/退出」
* net —— 轮询监听端口做差分,报「端口开/关」
*
* 两条出口(同时提供):
* 1) **推送**:通过 event 通知发给创建订阅的那条连接(低延迟、无需轮询)
* 2) **环形缓冲 + 游标**:供无法接收推送的调用方(MCP)拉取
*/
export interface WatchEvent {
seq: number;
watch_id: string;
kind: 'file' | 'process' | 'net';
ts: number;
action: string;
target: string;
detail?: string;
}
interface WatchRecord {
id: string;
kind: 'file' | 'process' | 'net';
owner: string; // 连接标识,断开时清理
description: string;
createdAt: number;
count: number;
/** 推送出口(由 server 注入) */
emit: (evt: WatchEvent) => void;
dispose: () => void;
}
const watches = new Map<string, WatchRecord>();
const buffer: WatchEvent[] = [];
const MAX_BUFFER = 500;
let seq = 0;
let dropped = 0;
function nextId(): string {
return `w_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
}
function push(rec: WatchRecord, action: string, target: string, detail?: string): void {
seq += 1;
const evt: WatchEvent = {
seq,
watch_id: rec.id,
kind: rec.kind,
ts: Date.now(),
action,
target,
...(detail ? { detail } : {}),
};
rec.count += 1;
buffer.push(evt);
if (buffer.length > MAX_BUFFER) {
const overflow = buffer.length - MAX_BUFFER;
buffer.splice(0, overflow);
dropped += overflow;
}
try {
rec.emit(evt);
} catch {
/* 推送失败不影响缓冲 */
}
}
/** 简易 glob(仅支持 * 与 ?),用于文件名/进程名过滤。 */
function globMatch(pattern: string, value: string): boolean {
const re = new RegExp(
`^${pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.')}$`,
'i',
);
return re.test(value);
}
// ---------------- 各类型 watcher ----------------
function startFileWatch(
rec: WatchRecord,
path: string,
recursive: boolean,
pattern?: string,
): () => void {
let watcher: FSWatcher;
try {
watcher = watch(path, { recursive, persistent: true });
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `无法监控路径: ${path}`, {
detail: String(err),
});
}
watcher.on('change', (eventType, filename) => {
const name = filename ? String(filename) : '';
if (pattern && name && !globMatch(pattern, name)) return;
push(rec, eventType === 'rename' ? 'created_or_removed' : 'modified', name || path);
});
watcher.on('error', (err) => {
push(rec, 'watch_error', path, String(err));
});
return () => watcher.close();
}
function startProcessWatch(rec: WatchRecord, intervalMs: number, pattern?: string): () => void {
let previous = new Set<string>();
let first = true;
const tick = (): void => {
// Windows: tasklist / POSIX: ps(避免依赖额外包)
const cmd =
process.platform === 'win32'
? 'tasklist /fo csv /nh'
: 'ps -eo comm= 2>/dev/null || ps -axo comm=';
exec(cmd, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
if (err) return;
const names = new Set<string>();
for (const line of stdout.split('\n')) {
const raw = process.platform === 'win32' ? (line.split(',')[0] ?? '').replace(/"/g, '') : line.trim();
const name = raw.trim();
if (!name || name.toLowerCase() === 'image name') continue;
if (pattern && !globMatch(pattern, name)) continue;
names.add(name);
}
if (first) {
previous = names;
first = false;
push(rec, 'baseline', `${names.size} 个进程`, '首次采样,仅建立基线不报差异');
return;
}
for (const n of names) if (!previous.has(n)) push(rec, 'process_started', n);
for (const n of previous) if (!names.has(n)) push(rec, 'process_exited', n);
previous = names;
});
};
tick();
const timer = setInterval(tick, intervalMs);
timer.unref?.();
return () => clearInterval(timer);
}
function startNetWatch(rec: WatchRecord, intervalMs: number): () => void {
let previous = new Set<string>();
let first = true;
const cmd =
process.platform === 'win32'
? 'netstat -an'
: 'netstat -an 2>/dev/null || ss -ltn';
const tick = (): void => {
exec(cmd, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
if (err) return;
const listening = new Set<string>();
for (const line of stdout.split('\n')) {
const m = /[:.](\d{2,5})\s+(LISTEN|LISTENING|ESTABLISHED)?/i.exec(line);
const isListen = /LISTEN/i.test(line);
if (m && isListen) listening.add(m[1]!);
}
if (first) {
previous = listening;
first = false;
push(rec, 'baseline', `${listening.size} 个监听端口`, '首次采样,仅建立基线');
return;
}
for (const p of listening) if (!previous.has(p)) push(rec, 'port_opened', `:${p}`);
for (const p of previous) if (!listening.has(p)) push(rec, 'port_closed', `:${p}`);
previous = listening;
});
};
tick();
const timer = setInterval(tick, intervalMs);
timer.unref?.();
return () => clearInterval(timer);
}
// ---------------- 对外能力 ----------------
export interface WatchContext {
/** 连接标识(用于断开时清理) */
owner: string;
/** 该连接的推送出口 */
emit: (evt: WatchEvent) => void;
}
export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown> {
const kind = args['kind'] as 'file' | 'process' | 'net';
if (!['file', 'process', 'net'].includes(kind)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 kind: ${String(kind)}`);
}
const intervalMs = Math.max(1000, Math.min(60_000, (args['interval_ms'] as number | undefined) ?? 5000));
const pattern = args['pattern'] as string | undefined;
const id = nextId();
const rec: WatchRecord = {
id,
kind,
owner: ctx.owner,
description: '',
createdAt: Date.now(),
count: 0,
emit: ctx.emit,
dispose: () => undefined,
};
if (kind === 'file') {
const path = args['path'] as string | undefined;
if (!path) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'file 类型需要 path');
const recursive = args['recursive'] === true;
rec.dispose = startFileWatch(rec, path, recursive, pattern);
rec.description = `文件变动 ${path}${recursive ? '(递归)' : ''}${pattern ? ` pattern=${pattern}` : ''}`;
} else if (kind === 'process') {
rec.dispose = startProcessWatch(rec, intervalMs, pattern);
rec.description = `进程启停${pattern ? ` pattern=${pattern}` : ''} 每 ${intervalMs}ms`;
} else {
rec.dispose = startNetWatch(rec, intervalMs);
rec.description = `监听端口开闭 每 ${intervalMs}ms`;
}
watches.set(id, rec);
return { watch_id: id, kind, description: rec.description };
}
export async function eventUnwatch(args: Args): Promise<unknown> {
const id = args['watch_id'] as string;
const rec = watches.get(id);
if (!rec) return { removed: false };
rec.dispose();
watches.delete(id);
return { removed: true };
}
export async function eventList(_args: Args): Promise<unknown> {
return {
watches: [...watches.values()].map((w) => ({
watch_id: w.id,
kind: w.kind,
description: w.description,
events: w.count,
created_at: w.createdAt,
})),
buffered: buffer.length,
};
}
export async function eventPoll(args: Args): Promise<unknown> {
const limit = Math.max(1, Math.min(500, (args['limit'] as number | undefined) ?? 50));
const since = (args['since'] as number | undefined) ?? 0;
const watchId = args['watch_id'] as string | undefined;
let list = buffer.filter((e) => e.seq > since);
if (watchId) list = list.filter((e) => e.watch_id === watchId);
const sliced = list.slice(0, limit);
const nextCursor = sliced.length > 0 ? sliced[sliced.length - 1]!.seq : since;
return { events: sliced, next_cursor: nextCursor, dropped };
}
/** 连接断开时清理该连接创建的所有订阅(避免 watcher 泄漏)。 */
export function disposeWatchesByOwner(owner: string): number {
let n = 0;
for (const [id, rec] of watches) {
if (rec.owner === owner) {
rec.dispose();
watches.delete(id);
n += 1;
}
}
return n;
}
+16 -2
View File
@@ -28,6 +28,7 @@ import {
} from '@nodeagent/protocol';
import { createCapabilityRegistry } from './capabilities/index.js';
import { audit, describeArgs } from './audit.js';
import { disposeWatchesByOwner } from './events.js';
import type { AgentConfig } from './config.js';
import type { TlsMaterial } from './certs.js';
@@ -42,6 +43,8 @@ interface ConnState {
authorized: string[] | null;
/** 来源地址(审计用) */
remote: string;
/** v12:连接标识,用于清理该连接创建的事件订阅 */
connId: string;
}
export interface AgentServer {
@@ -291,7 +294,11 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
const started = Date.now();
const argsInfo = describeArgs(finalArgs);
try {
const data = await handler(finalArgs);
// v12:把连接上下文交给能力处理器(事件订阅需要「谁订阅的」与「往哪推」)
const data = await handler(finalArgs, {
owner: state.connId,
emit: (evt: unknown) => send(ws, { jsonrpc: '2.0', method: 'event', params: evt }),
});
const duration = Date.now() - started;
log('info', `invoke ${name} → ok (${duration}ms)`);
audit({
@@ -373,6 +380,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
function attachConnection(ws: WebSocket, remote: string): void {
log('info', `新连接: ${remote}`);
const connId = `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
states.set(ws, {
authenticated: false,
nonce: null,
@@ -380,11 +388,17 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
clientId: null,
authorized: null,
remote,
connId,
});
ws.on('message', (raw: RawData) => {
void handleMessage(ws, raw.toString());
});
ws.on('close', () => log('info', `连接关闭: ${remote}`));
ws.on('close', () => {
log('info', `连接关闭: ${remote}`);
// v12:连接断开即释放其事件订阅,避免 watcher 泄漏
const n = disposeWatchesByOwner(connId);
if (n > 0) log('debug', `已清理 ${n} 个事件订阅(连接 ${connId})`);
});
ws.on('error', (err: Error) => log('warn', `连接错误: ${err.message}`));
}
+111
View File
@@ -66,6 +66,8 @@ const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
nodeagent task <id> [--offset N] [--kill] 读取/终止后台任务(支持增量续读)
nodeagent clip [--set "文本"] [--out <路径>] [--image-file <路径>]
读/写剪贴板文本或图片(--out 保存图片)(v11)
nodeagent events [--kind file|process|net] [--path <路径>] [--pattern G] [--seconds 15]
订阅事件并实时接收推送(无 --kind 则列出订阅)(v12)
nodeagent record [--duration 5000] [--fps 2] [--scale 0.5] [--region x,y,w,h]
录屏为帧序列(有 ffmpeg 则封装 mp4)(v11)
nodeagent deploy <agent.mjs> [--path <远端路径>]
@@ -146,6 +148,10 @@ interface Options {
nodes?: string;
/** v11 部署目标路径 */
path?: string;
/** v12 事件订阅 */
kind?: string;
seconds?: string;
intervalMs?: string;
}
function getClientConfig(): ClientConfig {
@@ -770,6 +776,102 @@ async function cmdDeploy(localFile: string, opts: Options): Promise<void> {
});
}
/**
* v12 / E1+E2:订阅被控端事件并实时接收推送。
* 与普通命令不同,它需要**保持连接**一段时间(长连接 + 推送),因此不走 withClient 的「用完即关」。
*/
async function cmdEvents(opts: Options): Promise<void> {
const cfg = getClientConfig();
let target: ResolvedTarget;
try {
target = resolveTarget(cfg, currentNodeOverride);
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
}
// 无 --kind 时列出当前订阅
if (!opts.kind) {
await withClient((c) =>
callAndPrint(c, CapabilityNames.EventList, {}, opts.json, (data) => {
const d = data as {
watches: Array<{ watch_id: string; kind: string; description: string; events: number }>;
buffered: number;
};
if (d.watches.length === 0) {
console.log(`当前没有事件订阅(缓冲 ${d.buffered} 条)`);
console.log('用法: nodeagent events --kind file --path <路径> [--seconds 20]');
return;
}
console.log(`${'订阅ID'.padEnd(24)} ${'类型'.padEnd(8)} 事件数 说明`);
for (const w of d.watches) {
console.log(`${w.watch_id.padEnd(24)} ${w.kind.padEnd(8)} ${String(w.events).padStart(6)} ${w.description}`);
}
}),
);
return;
}
const seconds = Math.max(1, Math.min(600, Number(opts.seconds ?? 15)));
const { profile, clientId } = target;
const keys = profile.auth_mode === 'ed25519' ? loadKeys() : null;
const received: Array<Record<string, unknown>> = [];
const client = new NodeAgentClient({
url: toWsUrl(profile),
key: profile.key ?? '',
clientId,
insecure: profile.insecure,
authMode: profile.auth_mode,
privateKey: keys?.privateKey,
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
onEvent: (evt) => {
received.push(evt);
const ts = new Date(Number(evt['ts'] ?? Date.now())).toLocaleTimeString('zh-CN');
const line = `${ts} ${String(evt['action'] ?? '').padEnd(20)} ${String(evt['target'] ?? '')}${evt['detail'] ? ` (${evt['detail']})` : ''}`;
if (opts.json) console.log(JSON.stringify(evt));
else console.log(` ${line}`);
},
});
try {
await client.connect();
const args: Record<string, unknown> = { kind: opts.kind };
if (opts.path) args['path'] = opts.path;
if (opts.pattern) args['pattern'] = opts.pattern;
if (opts.recursive) args['recursive'] = true;
if (opts.intervalMs) args['interval_ms'] = Number(opts.intervalMs);
const r = await client.invoke<{ watch_id: string; description: string }>(
CapabilityNames.EventWatch,
args,
);
if (r.status === 'failed') fail(`${r.error?.name}: ${r.error?.message}`);
const watchId = (r.data as { watch_id: string }).watch_id;
if (!opts.json) {
console.log(`已订阅:${(r.data as { description: string }).description}`);
console.log(`订阅 ID: ${watchId} 监听 ${seconds}s(Ctrl+C 可提前结束)\n`);
}
await new Promise<void>((resolve) => {
const t = setTimeout(resolve, seconds * 1000);
process.once('SIGINT', () => {
clearTimeout(t);
resolve();
});
});
await client.invoke(CapabilityNames.EventUnwatch, { watch_id: watchId });
if (!opts.json) {
console.log(`\n共收到 ${received.length} 个事件(订阅已取消)`);
}
} catch (err) {
if (err instanceof ClientError) fail(`${err.name}: ${err.message}`);
throw err;
} finally {
client.close();
}
}
async function cmdClip(opts: Options): Promise<void> {
// 图片上传:从本地文件读入(避免超长命令行参数)
if (opts.imageFile) {
@@ -1273,6 +1375,9 @@ function parseOptions(rest: string[]): { opts: Options; positionals: string[] }
fps: { type: 'string' },
nodes: { type: 'string' },
path: { type: 'string' },
kind: { type: 'string' },
seconds: { type: 'string' },
'interval-ms': { type: 'string' },
},
allowPositionals: true,
strict: false,
@@ -1309,6 +1414,9 @@ function parseOptions(rest: string[]): { opts: Options; positionals: string[] }
fps: values['fps'] as string | undefined,
nodes: values['nodes'] as string | undefined,
path: values['path'] as string | undefined,
kind: values['kind'] as string | undefined,
seconds: values['seconds'] as string | undefined,
intervalMs: values['interval-ms'] as string | undefined,
name: values['name'] as string | undefined,
note: values['note'] as string | undefined,
recursive: Boolean(values['recursive']),
@@ -1447,6 +1555,9 @@ async function main(): Promise<void> {
case 'record':
await cmdRecord(opts);
return;
case 'events':
await cmdEvents(opts);
return;
case 'fanout': {
const cap = positionals[0];
if (!cap) fail('用法: nodeagent fanout <能力名> [--nodes a,b] [--args JSON]');
+52
View File
@@ -370,6 +370,54 @@ const TOOLS = [
additionalProperties: false,
},
},
{
name: 'na_event_watch',
description:
'订阅被控端事件:file=文件变动、process=进程启停、net=监听端口开闭。' +
'MCP 无法接收推送,事件会进入服务端缓冲,用 na_event_poll 拉取。',
inputSchema: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['file', 'process', 'net'] },
path: { type: 'string', description: 'file 类型:监控的目录/文件路径' },
pattern: { type: 'string', description: '可选:文件名或进程名 glob(如 *.log、chrome*)' },
recursive: { type: 'boolean', description: 'file 类型:是否递归子目录' },
interval_ms: { type: 'integer', description: 'process/net 采样间隔(1000~60000,默认 5000)' },
},
required: ['kind'],
additionalProperties: false,
},
},
{
name: 'na_event_poll',
description:
'拉取已缓冲的被控端事件(增量:传上次返回的 next_cursor)。' +
'典型用法:先 na_event_watch 订阅,稍后 na_event_poll 查看期间发生了什么。',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'integer', description: '最多返回条数(默认 50)' },
since: { type: 'integer', description: '游标:上次返回的 next_cursor' },
watch_id: { type: 'string', description: '可选:只看某个订阅' },
},
additionalProperties: false,
},
},
{
name: 'na_event_list',
description: '列出当前生效的事件订阅(含各自已产生的事件数)与缓冲区大小。',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
},
{
name: 'na_event_unwatch',
description: '取消一个事件订阅(长时间不用的订阅应主动取消,避免浪费被控端资源)。',
inputSchema: {
type: 'object',
properties: { watch_id: { type: 'string' } },
required: ['watch_id'],
additionalProperties: false,
},
},
{
name: 'na_record',
description:
@@ -456,6 +504,10 @@ const TOOL_TO_CAPABILITY: Record<string, string> = {
na_fs_write: CapabilityNames.FsWrite,
na_fs_stat: CapabilityNames.FsStat,
na_restart: CapabilityNames.AgentRestart,
na_event_watch: CapabilityNames.EventWatch,
na_event_poll: CapabilityNames.EventPoll,
na_event_list: CapabilityNames.EventList,
na_event_unwatch: CapabilityNames.EventUnwatch,
na_window_list: CapabilityNames.WindowList,
na_window_focus: CapabilityNames.WindowFocus,
na_screen_find: CapabilityNames.ScreenFind,
+17 -1
View File
@@ -56,6 +56,8 @@ export interface ClientOptions {
hub?: { token: string; nodeId: string };
/** 日志回调 */
onLog?: (msg: string) => void;
/** v12:接收被控端主动推送的事件(无 id 的 JSON-RPC 通知) */
onEvent?: (event: Record<string, unknown>) => void;
}
interface Pending {
@@ -215,7 +217,21 @@ export class NodeAgentClient {
}
const id = (msg as { id?: string | null }).id;
if (!id) return;
if (!id) {
// v12:无 id = 通知(事件推送),交给 onEvent;控制端不应回复
const method = (msg as { method?: string }).method;
if (method === Methods.Event) {
const params = (msg as { params?: Record<string, unknown> }).params;
if (params) {
try {
this.opts.onEvent?.(params);
} catch (err) {
this.opts.onLog?.(`onEvent 回调异常: ${String(err)}`);
}
}
}
return;
}
const pending = this.pending.get(id);
if (!pending) return;
+120
View File
@@ -40,6 +40,11 @@ export const CapabilityNames = {
ClipSet: 'clip.set',
// v11 安全加固
AuditVerify: 'system.audit.verify',
// v12 事件订阅与监控
EventWatch: 'event.watch',
EventUnwatch: 'event.unwatch',
EventList: 'event.list',
EventPoll: 'event.poll',
} as const;
export type CapabilityName = (typeof CapabilityNames)[keyof typeof CapabilityNames];
@@ -985,6 +990,121 @@ export const CAPABILITY_MANIFEST: CapabilityDescriptor[] = [
},
},
},
{
name: CapabilityNames.EventWatch,
version: '1.0',
description:
'订阅被控端事件:file=文件变动(增删改)、process=进程启停、net=监听端口开闭。' +
'被控端通过 event 通知主动推送(无需轮询);事件同时进入环形缓冲,可用 event.poll 拉取。',
risk: 'low',
params_schema: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['file', 'process', 'net'] },
path: { type: 'string', description: 'file 类型:监控的目录/文件路径' },
pattern: { type: 'string', description: '可选:文件名 glob(如 *.log)或进程名子串' },
recursive: { type: 'boolean', default: false, description: 'file 类型:是否递归子目录' },
interval_ms: {
type: 'integer',
minimum: 1000,
maximum: 60000,
default: 5000,
description: 'process/net 类型的采样间隔',
},
},
required: ['kind'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
watch_id: { type: 'string' },
kind: { type: 'string' },
description: { type: 'string' },
},
},
},
{
name: CapabilityNames.EventUnwatch,
version: '1.0',
description: '取消一个事件订阅(连接断开时也会自动清理)。',
risk: 'low',
params_schema: {
type: 'object',
properties: { watch_id: { type: 'string' } },
required: ['watch_id'],
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: { removed: { type: 'boolean' } },
},
},
{
name: CapabilityNames.EventList,
version: '1.0',
description: '列出当前生效的事件订阅及其已产生事件数。',
risk: 'low',
params_schema: { type: 'object', properties: {}, additionalProperties: false },
returns_schema: {
type: 'object',
properties: {
watches: {
type: 'array',
items: {
type: 'object',
properties: {
watch_id: { type: 'string' },
kind: { type: 'string' },
description: { type: 'string' },
events: { type: 'integer' },
created_at: { type: 'integer' },
},
},
},
buffered: { type: 'integer', description: '环形缓冲中的事件总数' },
},
},
},
{
name: CapabilityNames.EventPoll,
version: '1.0',
description:
'拉取已缓冲的事件(适合无法接收推送的调用方,如 MCP:先 watch 再 poll)。' +
'since 传入上次返回的 next_cursor 可增量拉取。',
risk: 'low',
params_schema: {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 500, default: 50 },
since: { type: 'integer', description: '游标(上次返回的 next_cursor)' },
watch_id: { type: 'string', description: '可选:只看某订阅' },
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: {
events: {
type: 'array',
items: {
type: 'object',
properties: {
seq: { type: 'integer' },
watch_id: { type: 'string' },
kind: { type: 'string' },
ts: { type: 'integer' },
action: { type: 'string' },
target: { type: 'string' },
detail: { type: 'string' },
},
},
},
next_cursor: { type: 'integer' },
dropped: { type: 'integer', description: '因缓冲上限被丢弃的事件数' },
},
},
},
];
/** 按名称查找能力描述。 */
+5
View File
@@ -16,6 +16,11 @@ export const Methods = {
Ping: 'ping',
/** 双向:心跳响应 */
Pong: 'pong',
/**
* 被控端 → 控制端:主动推送事件(v12 事件订阅)。
* 这是协议中唯一的**无 id 通知**(JSON-RPC notification)——控制端不应回复。
*/
Event: 'event',
} as const;
export type MethodName = (typeof Methods)[keyof typeof Methods];
+22 -1
View File
@@ -47,11 +47,15 @@ test('清单:v7~v11 新能力均已登记', () => {
'system.audit.verify',
'input.mouse.drag',
'screen.record',
'event.watch',
'event.unwatch',
'event.list',
'event.poll',
];
for (const name of expected) {
assert.ok(findCapability(name), `缺少 ${name}`);
}
assert.equal(CAPABILITY_MANIFEST.length, 31, '当前应有 31 项能力');
assert.equal(CAPABILITY_MANIFEST.length, 35, '当前应有 35 项能力');
});
// ---------- 参数校验(新能力) ----------
@@ -130,6 +134,23 @@ test('system.shell.exec:async 与 wait_forever 为合法布尔', () => {
assert.ok(check('system.shell.exec', { command: 'x', async: 'yes' }).length > 0, '类型错误应被拒');
});
test('event.watch:kind 枚举受控,file 需 path,interval 有上下限', () => {
assert.equal(check('event.watch', { kind: 'file', path: 'C:\\tmp' }).length, 0);
assert.equal(check('event.watch', { kind: 'process', pattern: 'chrome*' }).length, 0);
assert.equal(check('event.watch', { kind: 'net', interval_ms: 2000 }).length, 0);
assert.ok(check('event.watch', { kind: 'disk' }).length > 0, '非法 kind 应被拒');
assert.ok(check('event.watch', { kind: 'net', interval_ms: 100 }).length > 0, 'interval 下限 1s');
assert.ok(check('event.watch', { kind: 'net', interval_ms: 99999 }).length > 0, 'interval 上限 60s');
});
test('event.poll:游标与条数可省略,且不接受未知参数', () => {
assert.equal(check('event.poll', {}).length, 0);
assert.equal(check('event.poll', { since: 12, limit: 100 }).length, 0);
assert.ok(check('event.poll', { limit: 0 }).length > 0, 'limit 下限 1');
assert.ok(check('event.poll', { limit: 999 }).length > 0, 'limit 上限 500');
assert.ok(check('event.poll', { bogus: 1 }).length > 0, '未知参数应被拒');
});
// ---------- 默认值 ----------
test('applyDefaults:新能力默认值正确填充', () => {