- 1.1(核对):registry 默认两布尔 false 已一致,零代码改动(设计文档已按裁决改写) - 1.3 + 10.4.2:新增迁移 0012_tool_policy(tool_policies 表,表名复数 + idx_tool_policies_name); 新增 registry.PolicyStore/SQLitePolicyStore,启动 Load 覆盖注册初值、SetPolicy 先落库后改内存 (落盘失败保持旧值并报错);面板保存路径不变但从此重启仍在 - 13.4.3:代审真接线 —— restrict.SensitiveCall 结构化判定(terminal / file write / http POST)、 restrict.Gate/Reviewer/Resolver(无代审者或代审报错一律 fail-closed 拦截), 组合根装进 loop.Options.Hooks 的 CallAdmit;gateway.RecordApproval 落 audit_logs (who=角色名 / what=approval.<工具> / result=granted|denied)并广播 approval_notice 帧 - 前端:代审小字「xxx 想要干什么,我审查后批准了」进聊天流顶部(帧→store→组件,文案单一来源 复用 audit.Approval.Explain()),附纯 node 逻辑测试 - 复核:迁移在 data/cyrene.db **副本**上 up/down/up 各一次通过;TestRegistry_PolicyStoreSurvivesRestart 与 TestAdminSetPolicySurvivesRestart 证明「重启后策略仍在」;terminal 触发代审 + 审计有行有端到端断言
206 lines
7.1 KiB
TypeScript
206 lines
7.1 KiB
TypeScript
import { useAppStore } from "../store/useAppStore";
|
|
import { useModeStore } from "../store/useModeStore";
|
|
import { getAuthToken, isTokenExpired, onAuthExpired, setAuthToken } from "./api";
|
|
import { toRecord } from "./wsParse";
|
|
import type { WsUpEvent } from "./types";
|
|
import { handleStreamEvent } from "./ws/handlers/stream";
|
|
import { handleThinkingEvent } from "./ws/handlers/thinking";
|
|
import { handleToolEvent } from "./ws/handlers/tool";
|
|
import { handleMessageEvent } from "./ws/handlers/message";
|
|
import { handleAgentEvent } from "./ws/handlers/agent";
|
|
import { handleSessionEvent } from "./ws/handlers/session";
|
|
import { handleDeviceEvent } from "./ws/handlers/device";
|
|
import { handleApprovalEvent } from "./ws/handlers/approval";
|
|
|
|
/**
|
|
* Realtime client for `/ws/chat`. Reconnects with exponential backoff and
|
|
* decodes the downstream event stream into the app store. All `data` payloads
|
|
* are `unknown` and narrowed here; nothing leaks `any` into components.
|
|
*/
|
|
|
|
let ws: WebSocket | null = null;
|
|
let shouldRun = false;
|
|
let reconnectTimer: number | null = null;
|
|
let attempt = 0;
|
|
|
|
const MAX_BACKOFF = 30_000;
|
|
const BASE_BACKOFF = 1_000;
|
|
|
|
function wsUrl(): string {
|
|
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
|
const token = getAuthToken() ?? "";
|
|
const qs = token ? `?token=${encodeURIComponent(token)}` : "";
|
|
return `${proto}://${window.location.host}/ws/chat${qs}`;
|
|
}
|
|
|
|
/** Route one decoded downstream frame into the store. Exported (with a `frame`
|
|
* shaped first argument) so the frame→store mapping can be exercised as a pure
|
|
* logic test without a browser: this project has no jsdom/test runner, so the
|
|
* socket lifecycle around it stays untested while the mapping does not have to.
|
|
* 帧按类型分组在 lib/ws/handlers/* 中处理:任一组命中(含组内主动丢弃)即结束,
|
|
* 全部未命中走下方未知帧兜底 —— 与拆分前的单个 switch 等价。 */
|
|
export function dispatchFrame(ev: { type: string; sessionId?: string; data: unknown }): void {
|
|
const s = useAppStore.getState();
|
|
const r = toRecord(ev.data);
|
|
|
|
if (handleStreamEvent(ev, r, s)) return;
|
|
if (handleThinkingEvent(ev, r, s)) return;
|
|
if (handleToolEvent(ev, r, s)) return;
|
|
if (handleMessageEvent(ev, r, s)) return;
|
|
if (handleAgentEvent(ev, r, s)) return;
|
|
if (handleSessionEvent(ev, r, s)) return;
|
|
if (handleDeviceEvent(ev, r, s)) return;
|
|
if (handleApprovalEvent(ev, r, s)) return;
|
|
|
|
// Unknown future event: keep it in the context log so no frame is lost.
|
|
const text = typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data);
|
|
s.addDeviceUpdate(`[${ev.type}] ${text}`);
|
|
}
|
|
|
|
function scheduleReconnect(): void {
|
|
if (!shouldRun) return;
|
|
if (reconnectTimer !== null) return;
|
|
// Only a live, non-expired credential may retry; otherwise this isn't a network
|
|
// blip but a dead session we should surface, not loop on.
|
|
const token = getAuthToken();
|
|
if (!token || isTokenExpired(token)) {
|
|
markAuthExpired();
|
|
return;
|
|
}
|
|
const delay = Math.min(BASE_BACKOFF * 2 ** attempt, MAX_BACKOFF);
|
|
attempt += 1;
|
|
useAppStore.getState().setWsStatus("reconnecting");
|
|
reconnectTimer = window.setTimeout(() => {
|
|
reconnectTimer = null;
|
|
openSocket();
|
|
}, delay);
|
|
}
|
|
|
|
function openSocket(): void {
|
|
const s = useAppStore.getState();
|
|
// A locally-expired token means the handshake will be rejected with 401 before
|
|
// it ever upgrades; don't even attempt it — flag the expiry instead.
|
|
const token = getAuthToken();
|
|
if (token && isTokenExpired(token)) {
|
|
markAuthExpired();
|
|
return;
|
|
}
|
|
try {
|
|
ws = new WebSocket(wsUrl());
|
|
} catch {
|
|
s.setWsStatus("disconnected");
|
|
scheduleReconnect();
|
|
return;
|
|
}
|
|
|
|
// Tracks whether this socket ever completed the handshake (reached OPEN). A
|
|
// browser surfaces a handshake 401 only as onerror+onclose (code 1006, no HTTP
|
|
// status), so the only way to tell "auth rejected" from "network blip" is to
|
|
// compare the staged close against the token's own expiry.
|
|
let opened = false;
|
|
s.setWsStatus(attempt === 0 ? "connecting" : "reconnecting");
|
|
|
|
ws.onopen = () => {
|
|
opened = true;
|
|
attempt = 0;
|
|
useAppStore.getState().setWsStatus("connected");
|
|
// Sync the current mode on every (re)connect so the agent's shell matches.
|
|
const mode = useModeStore.getState().mode;
|
|
sendUp({ type: "mode_switch", data: { mode } });
|
|
};
|
|
|
|
ws.onmessage = (msg) => {
|
|
try {
|
|
const parsed = JSON.parse(String(msg.data)) as unknown;
|
|
const r = toRecord(parsed);
|
|
if (!r) return;
|
|
dispatchFrame({
|
|
type: typeof r.type === "string" ? r.type : "",
|
|
// The frame envelope's sessionId is what scopes a payload that does not
|
|
// carry its own (see the `message_deleted` case in dispatch).
|
|
sessionId: typeof r.sessionId === "string" ? r.sessionId : undefined,
|
|
data: r.data,
|
|
});
|
|
} catch {
|
|
// ignore malformed frames
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
// Handshake/auth rejects land here first. An expired credential is not a
|
|
// transient outage, so stop the loop and prompt re-login rather than looping.
|
|
const cur = getAuthToken();
|
|
if (cur && isTokenExpired(cur)) {
|
|
markAuthExpired();
|
|
return;
|
|
}
|
|
useAppStore.getState().setWsStatus("disconnected");
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
ws = null;
|
|
const cur = getAuthToken();
|
|
if (!opened && cur && isTokenExpired(cur)) {
|
|
markAuthExpired();
|
|
return;
|
|
}
|
|
useAppStore.getState().setWsStatus("disconnected");
|
|
scheduleReconnect();
|
|
};
|
|
}
|
|
|
|
/** Open (or reopen) the realtime channel. Safe to call repeatedly. */
|
|
export function connectWs(): void {
|
|
if (shouldRun && (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
shouldRun = true;
|
|
openSocket();
|
|
}
|
|
|
|
/** Close the channel and stop reconnecting. */
|
|
export function disconnectWs(): void {
|
|
shouldRun = false;
|
|
if (reconnectTimer !== null) {
|
|
window.clearTimeout(reconnectTimer);
|
|
reconnectTimer = null;
|
|
}
|
|
const cur = ws;
|
|
ws = null;
|
|
if (cur) {
|
|
cur.onclose = null;
|
|
cur.close();
|
|
}
|
|
useAppStore.getState().setWsStatus("idle");
|
|
}
|
|
|
|
/** Terminal "auth expired" path: stop reconnecting, drop the stale token, and
|
|
* flag the UI so it prompts re-login instead of burning backoff on a dead
|
|
* credential. Fired from (a) a handshake reject on an expired token and (b) the
|
|
* REST 401 handler (via onAuthExpired). */
|
|
function markAuthExpired(): void {
|
|
setAuthToken(null);
|
|
disconnectWs();
|
|
useAppStore.getState().setWsStatus("expired");
|
|
}
|
|
|
|
/** Send an upstream frame; returns false when the socket is not open. */
|
|
export function sendUp(ev: WsUpEvent): boolean {
|
|
const cur = ws;
|
|
if (!cur || cur.readyState !== WebSocket.OPEN) return false;
|
|
cur.send(JSON.stringify(ev));
|
|
return true;
|
|
}
|
|
|
|
/** Is the realtime channel open? */
|
|
export function isWsOpen(): boolean {
|
|
return ws?.readyState === WebSocket.OPEN;
|
|
}
|
|
|
|
// When the REST layer hits a 401/unauthorized on a logged-in call it clears the
|
|
// token and fires this handler; mirror it into the same terminal expiry path so
|
|
// the reconnect loop stops and the UI shows "登录过期" for both WS and REST 401s.
|
|
onAuthExpired(() => {
|
|
markAuthExpired();
|
|
});
|