Files
nodeagent/apps/agent/src/events.ts
T
jkooe 0a47c5a92d 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 全绿
2026-09-29 14:19:09 +08:00

275 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}