diff --git a/apps/agent/src/audit.ts b/apps/agent/src/audit.ts index 03371bc..f3039fc 100644 --- a/apps/agent/src/audit.ts +++ b/apps/agent/src/audit.ts @@ -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 }; } diff --git a/apps/agent/src/capabilities/app.ts b/apps/agent/src/capabilities/app.ts index 3e2cb02..0569810 100644 --- a/apps/agent/src/capabilities/app.ts +++ b/apps/agent/src/capabilities/app.ts @@ -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 { + 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 { 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 { '$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 { 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 ---------------- diff --git a/apps/agent/src/server.ts b/apps/agent/src/server.ts index 7b5224c..0d7b3b7 100644 --- a/apps/agent/src/server.ts +++ b/apps/agent/src/server.ts @@ -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(); @@ -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}`); diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index fa04a5c..d5740b8 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -14,7 +14,9 @@ async function main(): Promise { console.log(` Hub 令牌 : ${config.token}`); console.log(''); console.log(' 被控端接入 : 在 agent.json 配置 hub: { url, token } 后启动 agent'); - console.log(` 控制端接入 : nodeagent connect --port ${config.port} --hub --key <设备密钥>`); + console.log( + ` 控制端接入 : nodeagent connect --port ${config.port} --hub-token --hub-node <设备ID> --key <设备密钥>`, + ); console.log(''); console.log(' 安全说明 : Hub 只透传字节流,不解析内容 ——'); console.log(' 控制端与被控端仍执行自有握手,Hub 无法解密或伪造。'); diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index 8f83719..2b40d75 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -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 { if (client) return client; @@ -29,7 +31,7 @@ async function ensureClient(): Promise { } 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 { 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 = { 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 } | { error: string }; @@ -378,6 +411,45 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const name = request.params.name; const input = (request.params.arguments ?? {}) as Record; + // 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);