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:
1 parent
0bf9a2f918
commit
daa39e67f4
5 files changed
+158
-21
No files matched your search
+37
-14
@@ -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 };
|
||||
}
|
||||
@@ -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 ----------------
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user