fix: 修复项目分析指出的 5 项问题

P0 · 真 bug:
1. MCP 无法走 Hub:ensureClient 漏传 hub 字段 → 补齐(与 CLI 对齐)
2. Hub 启动提示有误:打印 --hub 但 CLI 认 --hub-token/--hub-node → 更正文案

P1 · 功能完整性与安全缺口:
3. app.list 补商店/UWP 应用来源(Get-AppxPackage),不再漏装商店软件
4. psk 模式支持能力级 ACL(此前仅 ed25519 生效,持钥即全通)
   —— 配置 ACL 的 psk 设备现在按调用方授权;未配置 ACL 的行为不变
5. MCP 设备选择:新增 na_nodes / na_use(会话级切换,不影响 CLI 默认)+ 补 na_fs_stat
6. 审计查询支持轮转文件(audit.log.1/2/...),历史记录不再丢失

验证:构建 + 类型检查全绿;E2E 21/21;
冒烟确认 psk-ACL 授权/拒绝正确、MCP 工具列表含全部 18 个工具
This commit is contained in:
jkooe committed 2026-09-27 16:38:37 +08:00
1 parent 0bf9a2f918
commit daa39e67f4
5 files changed
+158 -21

No files matched your search

+37 -14
View File
@@ -134,31 +134,54 @@ export interface AuditQuery {
type?: string;
}
/** 读取审计记录(取最新 limit 条,按时间升序返回)。 */
export function readAudit(query: AuditQuery = {}): { entries: AuditEntry[]; total: number; file: string } {
const p = auditFilePath();
if (!existsSync(p)) return { entries: [], total: 0, file: p };
let entries: AuditEntry[] = [];
function readEntriesFromFile(file: string): AuditEntry[] {
if (!existsSync(file)) return [];
const out: AuditEntry[] = [];
try {
const lines = readFileSync(p, 'utf8').split('\n');
const lines = readFileSync(file, 'utf8').split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
entries.push(JSON.parse(line) as AuditEntry);
out.push(JSON.parse(line) as AuditEntry);
} catch {
/* 跳过损坏行 */
}
}
} catch {
return { entries: [], total: 0, file: p };
/* 单文件读取失败不影响整体 */
}
return out;
}
const total = entries.length;
if (query.since !== undefined) entries = entries.filter((e) => e.ts > query.since!);
if (query.clientId) entries = entries.filter((e) => e.client_id === query.clientId);
if (query.type) entries = entries.filter((e) => e.type.startsWith(query.type!));
/**
* 读取审计记录(取最新 limit 条,按时间升序返回)。
* 会同时读取轮转文件(audit.log.1/.2/…),避免历史记录被遗漏。
*/
export function readAudit(query: AuditQuery = {}): { entries: AuditEntry[]; total: number; file: string } {
const p = auditFilePath();
if (!existsSync(p)) return { entries: [], total: 0, file: p };
const limit = Math.max(1, Math.min(query.limit ?? 50, 1000));
return { entries: entries.slice(-limit), total, file: p };
// 收集候选文件:当前文件 + 轮转文件(从新到旧)
const files: string[] = [p];
for (let i = 1; i <= 20 && existsSync(`${p}.${i}`); i += 1) files.push(`${p}.${i}`);
const collected: AuditEntry[] = [];
let total = 0;
for (const f of files) {
const list = readEntriesFromFile(f);
total += list.length;
for (const e of list) {
if (query.since !== undefined && e.ts <= query.since) continue;
if (query.clientId && e.client_id !== query.clientId) continue;
if (query.type && !e.type.startsWith(query.type)) continue;
collected.push(e);
}
// 已凑够需要的条数,可提前停止(避免无谓地读完所有轮转文件)
if (collected.length >= limit) break;
}
collected.sort((a, b) => a.ts - b.ts);
return { entries: collected.slice(-limit), total, file: p };
}
+40 -3
View File
@@ -18,6 +18,32 @@ function requireWindows(capability: string): void {
// ---------------- app.list ----------------
interface AppEntry {
name: string;
version: string;
publisher: string;
source: string;
}
/** 商店 / UWP 应用(补充注册表卸载项之外的应用;失败不阻塞主清单)。 */
async function listUwpApps(): Promise<AppEntry[]> {
const script =
"Get-AppxPackage | Where-Object {$_.SignatureKind -ne 'System' -and $_.IsFramework -eq $false} | " +
'Select-Object Name,Version,Publisher | ConvertTo-Json -Compress';
const r = await execCommand({ command: script, timeoutMs: 60_000 }).catch(() => null);
if (!r || r.exit_code !== 0 || !r.stdout) return [];
try {
return toArray<{ Name?: string; Version?: string; Publisher?: string }>(JSON.parse(r.stdout)).map((a) => ({
name: a.Name ?? '',
version: a.Version ?? '',
publisher: a.Publisher ?? '',
source: 'store',
}));
} catch {
return [];
}
}
export async function appList(args: Args): Promise<unknown> {
requireWindows('app.list');
const pattern = (args['filter'] as { name_pattern?: string } | undefined)?.name_pattern;
@@ -32,7 +58,7 @@ export async function appList(args: Args): Promise<unknown> {
'$items | Sort-Object DisplayName -Unique | ConvertTo-Json -Compress';
const r = await execCommand({ command: script, timeoutMs: 60_000 });
let apps = toArray<{ DisplayName?: string; DisplayVersion?: string; Publisher?: string }>(
const apps: AppEntry[] = toArray<{ DisplayName?: string; DisplayVersion?: string; Publisher?: string }>(
r.stdout ? JSON.parse(r.stdout) : [],
).map((a) => ({
name: a.DisplayName ?? '',
@@ -41,11 +67,22 @@ export async function appList(args: Args): Promise<unknown> {
source: 'registry',
}));
// 合并商店 / UWP 应用:以注册表为准,补充其中没有的
const uwp = await listUwpApps();
const seen = new Set(apps.map((a) => a.name.toLowerCase()));
for (const item of uwp) {
if (item.name && !seen.has(item.name.toLowerCase())) {
seen.add(item.name.toLowerCase());
apps.push(item);
}
}
let result = apps;
if (pattern) {
const re = new RegExp(pattern, 'i');
apps = apps.filter((a) => re.test(a.name));
result = result.filter((a) => re.test(a.name));
}
return { apps };
return { apps: result, total: result.length, sources: ['registry', 'store'] };
}
// ---------------- app.install ----------------
+5 -2
View File
@@ -59,6 +59,8 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
/** v3:本次实例采用的认证模式(psk 向后兼容 / ed25519 零信任) */
const authMode: 'psk' | 'ed25519' = cfg.auth_mode === 'ed25519' ? 'ed25519' : 'psk';
const aclPolicy = cfg.acl ?? { default_effect: 'deny' as const, clients: [] };
/** 只要配置了 ACL 就启用能力级授权(无论 psk 还是 ed25519)。 */
const hasAcl = Boolean(cfg.acl?.clients?.length);
/** 按调用方的滑动窗口限速(每分钟)。 */
const rateBuckets = new Map<string, number[]>();
@@ -180,8 +182,9 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
const params = req.params as InvokeParams | undefined;
const name = params?.capability ?? '';
// v3:ed25519 模式下执行能力级 ACL 校验(deny 优先 → allow → 默认拒绝)
if (authMode === 'ed25519') {
// 能力级 ACL:只要被控端配置了 ACL 即执行(deny 优先 → allow → 默认拒绝)。
// 注:psk 模式下 client_id 由客户端自报,此为「配置级约束」;对抗恶意调用方仍需 ed25519 绑定身份。
if (hasAcl) {
const authz = authorize(aclPolicy, state.clientId ?? '', name);
if (!authz.allowed) {
log('warn', `ACL 拒绝 client_id=${state.clientId} capability=${name} — ${authz.reason}`);
+3 -1
View File
@@ -14,7 +14,9 @@ async function main(): Promise<void> {
console.log(` Hub 令牌 : ${config.token}`);
console.log('');
console.log(' 被控端接入 : 在 agent.json 配置 hub: { url, token } 后启动 agent');
console.log(` 控制端接入 : nodeagent connect <hub-主机> --port ${config.port} --hub --key <设备密钥>`);
console.log(
` 控制端接入 : nodeagent connect <hub-主机> --port ${config.port} --hub-token <Hub 令牌> --hub-node <设备ID> --key <设备密钥>`,
);
console.log('');
console.log(' 安全说明 : Hub 只透传字节流,不解析内容 ——');
console.log(' 控制端与被控端仍执行自有握手,Hub 无法解密或伪造。');
+73 -1
View File
@@ -20,6 +20,8 @@ function log(msg: string): void {
// ---------- 被控端连接(懒加载 + 断线重置) ----------
let client: NodeAgentClient | null = null;
/** 会话级目标设备(na_use 切换;不影响 CLI 的默认设备配置)。 */
let sessionNode: string | null = null;
async function ensureClient(): Promise<NodeAgentClient> {
if (client) return client;
@@ -29,7 +31,7 @@ async function ensureClient(): Promise<NodeAgentClient> {
}
let target;
try {
target = resolveTarget(cfg);
target = resolveTarget(cfg, sessionNode ?? undefined);
} catch (err) {
throw new Error(err instanceof Error ? err.message : String(err));
}
@@ -42,6 +44,7 @@ async function ensureClient(): Promise<NodeAgentClient> {
insecure: profile.insecure,
authMode: profile.auth_mode,
privateKey: keys?.privateKey,
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
});
await c.connect();
log(`已连接被控端 ${target.name} ${profile.host}:${profile.port}(${profile.auth_mode ?? 'psk'})`);
@@ -257,6 +260,35 @@ const TOOLS = [
additionalProperties: false,
},
},
{
name: 'na_fs_stat',
description: '查看被控端文件或目录的元信息(类型 / 大小 / 修改时间),用于读文件前先判断类型与大小。',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: '文件或目录绝对路径' },
},
required: ['path'],
additionalProperties: false,
},
},
{
name: 'na_nodes',
description: '列出已配置的 nodeagent 被控端设备(● 为当前默认设备)。多台 Windows 时使用。',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
},
{
name: 'na_use',
description: '切换本次会话的目标设备(不影响 CLI 的默认设备)。用户说"切换到某台机器"时使用。',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: '设备名(用 na_nodes 查看)' },
},
required: ['name'],
additionalProperties: false,
},
},
] as const;
/** MCP 工具入参 → 能力 args。 */
@@ -300,6 +332,7 @@ const TOOL_TO_CAPABILITY: Record<string, string> = {
na_fs_list: CapabilityNames.FsList,
na_fs_read: CapabilityNames.FsRead,
na_fs_write: CapabilityNames.FsWrite,
na_fs_stat: CapabilityNames.FsStat,
};
type Resolved = { capability: string; args: Record<string, unknown> } | { error: string };
@@ -378,6 +411,45 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const name = request.params.name;
const input = (request.params.arguments ?? {}) as Record<string, unknown>;
// na_nodes:列出已配置设备(不经过被控端连接)
if (name === 'na_nodes') {
const cfg = loadConfig();
if (!cfg || Object.keys(cfg.nodes).length === 0) {
return { content: [{ type: 'text', text: '尚未配置任何设备。请先用 nodeagent connect 添加。' }] };
}
const lines = Object.entries(cfg.nodes).map(([n, p]) => {
const mark = n === (sessionNode ?? cfg.current) ? '●' : ' ';
const via = p.hub ? `经 Hub ${p.host}:${p.port}` : `${p.host}:${p.port}`;
return `${mark} ${n} — ${via}(${p.auth_mode ?? 'psk'})${p.note ? ` ${p.note}` : ''}`;
});
return {
content: [{ type: 'text', text: `已配置 ${lines.length} 台设备(● = 当前目标):\n${lines.join('\n')}` }],
};
}
// na_use:会话级切换目标设备(下次调用生效,不影响 CLI 的默认设备)
if (name === 'na_use') {
const targetName = String(input['name'] ?? '');
const cfg = loadConfig();
if (!cfg?.nodes[targetName]) {
return {
isError: true,
content: [
{
type: 'text',
text: `未配置的设备: ${targetName};已配置:${Object.keys(cfg?.nodes ?? {}).join(', ') || '(空)'}`,
},
],
};
}
sessionNode = targetName;
client = null; // 下次调用时按新设备重连
const p = cfg.nodes[targetName]!;
return {
content: [{ type: 'text', text: `已切换目标设备为「${targetName}」→ ${p.host}:${p.port}` }],
};
}
// na_discover:本地 UDP 监听,不经过被控端连接
if (name === 'na_discover') {
const waitSec = Math.min(Math.max(Number(input['wait_seconds'] ?? 3), 1), 15);