Files
nodeagent/apps/console/src/stores/conn.ts
T
jkooe 5a2c5f9c55 feat: 三端暴露 L2 系统级审计(MCP na_audit_l2 + 控制台 L2 分区 + agent.l2 类型)
决策「L2 同时暴露 MCP+控制台」。此前 system.audit.l2 只存在于
被控端实现里:MCP 无工具(模型看不到)、控制台无入口无类型
(用户点不到)、审计事件写成 agent.start(与进程生命周期混类筛不出)。

MCP(41 → 42 个工具):
- defs.ts 在 na_audit 后新增 na_audit_l2(action: status/enable/
  collect/verify + since/limit,additionalProperties: false)
- index.ts TOOL_TO_CAPABILITY 映射 CapabilityNames.AuditL2,
  分发走 default 直通(参数名与被控端一致,无需改写)
- mcp-tools 守卫同步:41→42、BASELINE_SEQ_SHA 重算、
  新增「na_audit_l2 必须紧随 na_audit」顺序断言

控制台:
- conn.ts WINDOWS_ONLY_CAPS += system.audit.l2(非 Windows 被控端
  上按钮禁用并明示原因,不搞「点了才报错」)
- types/index.ts 新增 AuditL2Result(四 action 属性并集,对齐
  manifest returns_schema)+ AuditType += agent.l2;
  AuditEntry 同步补 args_bytes/args_truncated(A-P0-4 新字段)
- AuditView.vue 新增 L2 卡片:查状态/拉取采集/校验 L2 链/启用策略
  (NPopconfirm 二次确认 + 管理员权限提示);信任边界如实说明
  (采集器与 agent 同权限,防篡改依赖转发出机,不夸大本地副本)

agent.l2 审计类型四端同源:
- protocol audit-types.ts AUDIT_TYPES(真源)+= agent.l2
- agent audit.ts AuditType 联合同步(AssertExact 编译期守卫兜底)
- audit-l2.ts 两处写入点 agent.start → agent.l2(L2 策略操作与
  进程生命周期无关,混类会污染两边统计与筛选)

守卫加强(console-contract 平台门控):
- audit-l2.ts 平台门控由裸 throw 改用统一 assertWindows
  (错误信息格式统一 + 可被守卫扫到)
- 守卫扫描范围 +audit-l2.ts、模式 +assertWindows —— 覆盖面扩大;
  否则把 system.audit.l2 加进 WINDOWS_ONLY_CAPS 反而报红
- mcp-tools 正则盲区修复:原 na_[a-z_]+ 匹配不了带数字的工具名,
  na_audit_l2 会整个漏扫(新增了工具两个守卫却无觉)→ 放至 [a-z0-9_]+

新增 tests/unit/console-l2.test.mjs 5 例:前端字段 ⊆ returns_schema /
契约关键字段 ⊇ 前端渲染 / MCP 工具存在且映射 / agent.l2 四端同源 /
界面纪律(信任边界文案 + enable 二次确认 + 平台门控)
故障注入实测:审计类型退回 agent.start、WINDOWS_ONLY_CAPS 删项 → 3 处红;恢复 → 绿

单测 451/451、e2e 33/33、typecheck 全绿
2026-10-26 00:28:44 +08:00

172 lines
5.5 KiB
TypeScript

/** 连接状态 store:连接/断开、能力清单、元信息、日志与被控端事件。 */
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { listen } from "@tauri-apps/api/event";
import * as api from "../api";
import type { ConnectInfo, InvokeResult, SystemInfo } from "../types";
export interface AgentEvent {
ts: number;
payload: unknown;
}
/**
* 仅在被控端为 Windows 时可用(其余平台返回 E_UNSUPPORTED_PLATFORM)。
* 前端据此**事前门控**,而不是发出注定失败的请求再解释错误。
*/
export const WINDOWS_ONLY_CAPS = ["app.list", "app.install", "system.service.list", "system.audit.l2"] as const;
export const useConnStore = defineStore("conn", () => {
const state = ref<string>("closed");
// 默认 wss:被控端默认 tls=true(明文 ws 连 TLS 端口必然握手失败,2026-10-17 真机踩过)
const url = ref(localStorage.getItem("na.url") || "wss://127.0.0.1:8765");
const key = ref(localStorage.getItem("na.key") || "");
const info = ref<ConnectInfo | null>(null);
const logs = ref<string[]>([]);
const events = ref<AgentEvent[]>([]);
const connecting = ref(false);
const bound = ref(false);
/** 被控端平台:'Windows' | 'macOS' | 'Linux' | ... (连接后自动探测) */
const nodeOs = ref<string | null>(null);
const connected = computed(() => state.value === "connected" && !!info.value);
const capabilities = computed(() => info.value?.capabilities ?? []);
/** 被控端能力名集合,便于按名判存在性。 */
const capabilityNames = computed(() => new Set(capabilities.value.map((c) => c.name)));
const isWindows = computed(() => nodeOs.value === "Windows");
/** 平台门控能力当前是否可用(未探测到平台时按「乐观可用」处理)。 */
const platformReady = computed(() => nodeOs.value === null || isWindows.value);
function pushLog(msg: string) {
logs.value.push(`[${new Date().toLocaleTimeString()}] ${msg}`);
if (logs.value.length > 500) logs.value.shift();
}
/** 绑定后端事件(应用启动时调用一次)。 */
async function bind() {
if (bound.value) return;
bound.value = true;
await listen<string>("na-log", (e) => pushLog(e.payload));
await listen<string>("na-state", (e) => {
state.value = e.payload;
pushLog(`状态 → ${e.payload}`);
});
await listen<unknown>("na-event", (e) => {
events.value.unshift({ ts: Date.now(), payload: e.payload });
if (events.value.length > 200) events.value.pop();
pushLog(`被控端事件:${JSON.stringify(e.payload)}`);
});
state.value = await api.getState();
}
/** 探测被控端平台(`system.info.os`)——决定 Windows 专属能力是否可用。 */
async function loadPlatform() {
try {
const i = await api.ok<SystemInfo>("system.info", { fields: ["os"] });
nodeOs.value = i.os ?? null;
pushLog(`被控端平台:${nodeOs.value ?? "未知"}`);
return nodeOs.value;
} catch (e) {
pushLog(`平台探测失败:${e}`);
return null;
}
}
async function connect(u = url.value, k = key.value) {
connecting.value = true;
url.value = u;
key.value = k;
pushLog(`正在连接 ${u} ...`);
try {
const i = await api.connect(u, k, "console");
info.value = i;
localStorage.setItem("na.url", u);
localStorage.setItem("na.key", k);
pushLog(`握手成功:${i.capabilities.length} 项能力,agent ${i.agent_version ?? "?"}`);
// 握手后立刻探测平台,供各页面事前门控。
await loadPlatform();
return i;
} catch (e) {
pushLog(`连接失败:${e}`);
throw e;
} finally {
connecting.value = false;
}
}
async function disconnect() {
await api.disconnect();
info.value = null;
nodeOs.value = null;
pushLog("已断开");
}
/** 泛型调用;连接断开时抛错。 */
async function call<T = unknown>(
capability: string,
args: Record<string, unknown> = {},
timeoutMs?: number,
): Promise<InvokeResult<T>> {
if (!connected.value) throw new api.CapabilityFailure(capability, "E_NODE_OFFLINE", "未连接");
const t0 = performance.now();
const res = await api.call<T>(capability, args, timeoutMs);
const ms = Math.round(performance.now() - t0);
if (res.status === "ok") {
pushLog(`${capability} → ok (${ms}ms)`);
} else {
pushLog(`${capability} → 失败:[${res.error?.name}] ${res.error?.message ?? "未知错误"}`);
}
return res;
}
/**
* 调用并要求成功,直接返回 data。
* 失败抛 `CapabilityFailure`(携带 `errorName`),便于页面按错误名分支
* ——如 `isUnsupportedPlatform(e)` 判平台不支持。
*/
async function ok<T = unknown>(
capability: string,
args: Record<string, unknown> = {},
timeoutMs?: number,
): Promise<T> {
const res = await call<T>(capability, args, timeoutMs);
if (res.status !== "ok") {
throw new api.CapabilityFailure(
capability,
res.error?.name ?? "E_UNKNOWN",
res.error?.message ?? `${capability} 执行失败`,
res.error?.data,
);
}
return res.data as T;
}
function clearLogs() {
logs.value = [];
}
return {
state,
url,
key,
info,
logs,
events,
connecting,
connected,
capabilities,
capabilityNames,
nodeOs,
isWindows,
platformReady,
bind,
loadPlatform,
connect,
disconnect,
call,
ok,
pushLog,
clearLogs,
};
});