Browse Source

发布 v0.2.27:统一 HTTP+SSE 实时接口

LSamien 2 months ago
parent
commit
5bc9e9a30c

+ 8 - 5
README.md

@@ -9,7 +9,8 @@
 所有 8800 用户共享本机 9119 的 Bots、历史、团队和文件;普通用户不能管理用户、
 节点、插件或系统升级。
 
-iOS 默认连接 `http://主机:8800` 并使用 8800 用户登录;已有 9119 账号继续兼容。
+iOS/Web 连接 `http://主机:8800` 并使用 8800 用户登录,客户端实时接口仅支持 HTTP+SSE。
+从 v0.2.27 起,旧客户端 WebSocket、票据和租约接口已移除;iOS 必须同步更新到 HTTP+SSE 版本。
 9119 和 8800 都可管理由 8800 签发配对码的直接子节点,节点不会递归暴露孙节点。
 已登录 Web 用户还可在“手机登录与节点”中生成两分钟有效的一次性登录二维码;iOS
 登录页扫描后会把 8800 保存为普通服务器账号。`yaoyao://login` 登录码与
@@ -21,7 +22,7 @@ iOS 默认连接 `http://主机:8800` 并使用 8800 用户登录;已有 9119
 
 ### iOS 后台消息推送
 
-iOS 在线时继续使用 8800 的 WebSocket 实时接收消息;App 被挂起或未运行时,8800
+iOS 在线时使用 8800 的 SSE 实时接收消息;App 被挂起或未运行时,8800
 可作为 APNs Provider 发送系统通知。推送设备与当前 8800 用户绑定,普通聊天只提醒
 该用户经 8800 发起的任务;团队在用户首次发言后自动订阅,也可在团队页关闭。
 
@@ -39,7 +40,7 @@ export HERMES_YAOYAO_APNS_TOPIC=cn.samien.yaoyao.hermes
 管理员可在“系统管理 → iOS 消息推送”查看配置、注册设备和待发送数量,页面与日志均
 不会显示 `.p8` 内容或 device token。Docker 部署需要把 `.p8` 只读挂载到容器,并让
 `HERMES_YAOYAO_APNS_KEY_FILE` 指向容器内绝对路径。APNs 只要求 8800 能主动访问
-Apple,不需要把 8800 暴露到公网;手机访问 8800 仍应优先使用 HTTPS/WSS 或 Tailscale。
+Apple,不需要把 8800 暴露到公网;手机访问 8800 仍应优先使用 HTTPS 或 Tailscale。
 
 未设置任何 `HERMES_YAOYAO_APNS_*` 环境变量时,管理员也可以在“系统管理 → iOS
 消息推送”填写 **8800 所在机器上的 `.p8` 绝对路径**、Key ID、Team ID、Topic 及启用
@@ -78,7 +79,9 @@ ID 和包名。浏览器只提交路径和公开元数据;8800 本地校验 RS
 插件安装接口安装。默认源为
 `https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao`。
 
-当前发布版本:**Git `v0.2.26` / 夭夭 Web `0.2.26` / Hermes Dashboard 插件 `1.7.3`**。版本组合由仓库根目录的 `release.json` 唯一声明,并由 `npm run release:verify` 校验。
+当前发布版本:**Git `v0.2.27` / 夭夭 Web `0.2.27` / Hermes Dashboard 插件 `1.7.3`**。版本组合由仓库根目录的 `release.json` 唯一声明,并由 `npm run release:verify` 校验。
+
+本版将普通聊天和团队事件统一为 HTTP+SSE,增加服务端连接管理、近期事件补收、提交回执去重与权限隔离。8800 到 9119 的上游接口及 Python 插件保持不变。详见 [HTTP+SSE 接口与升级边界](docs/http-sse-realtime.md)。
 
 需要由自动化 Agent 部署或升级时,请直接使用 [Agent 安装手册](docs/agent-install.md)。其中包含固定版本校验、备份、同步、单一 Dashboard 重载与验证步骤。
 
@@ -236,7 +239,7 @@ npm run dev
 
 原生群聊由房主 Hermes 保存权威房间、话题和消息。扫码时,iOS 会把远端节点以加密凭据注册到房主插件;房主通过远端 `nodeWorker` 执行 Session,并转发流式事件、审批、澄清、中断和受限群聊附件。房主工作目录不会作为远端绝对路径使用,远端 Agent 默认采用自己的工作区。
 
-免密配对和跨节点执行应使用 HTTPS/WSS 或 Tailscale。受管安装会显式设置 `HERMES_YAOYAO_ALLOW_INSECURE_LAN=1`,因此默认 HTTP 监听只适合可信局域网;不要把二维码或配对凭据发送给不受信任的人。
+免密配对和跨节点执行应使用 HTTPS 或 Tailscale。受管安装会显式设置 `HERMES_YAOYAO_ALLOW_INSECURE_LAN=1`,因此默认 HTTP 监听只适合可信局域网;不要把二维码或配对凭据发送给不受信任的人。
 
 ## 网络暴露:9119 与 8800 分别控制
 

+ 7 - 5
docs/agent-install.md

@@ -6,12 +6,14 @@
 
 | 项目 | 版本 | 校验位置 |
 | --- | --- | --- |
-| Git 发布标签 | `v0.2.26` | `release.json` 的 `gitTag` 与 `git describe --tags --exact-match HEAD` |
-| 夭夭 Web | `0.2.26` | `release.json` 与 `package.json` 的 `version` |
+| Git 发布标签 | `v0.2.27` | `release.json` 的 `gitTag` 与 `git describe --tags --exact-match HEAD` |
+| 夭夭 Web | `0.2.27` | `release.json` 与 `package.json` 的 `version` |
 | Hermes Dashboard 插件 | `1.7.3` | `hermes-plugins/yaoyao/dashboard/manifest.json` 的 `version` |
 
 以下步骤以仓库根目录为工作目录。不要使用浮动分支替代发布标签。
 
+本版客户端只支持 HTTP+SSE,旧 WebSocket、票据和租约入口返回 410。升级服务端时必须同步更新 iOS 客户端;Web 静态资源随本版发布。上游 9119 与 Python 插件未迁移,插件版本仍为 1.7.3。
+
 ## 网络默认值与局域网策略
 
 通过 `service install` 安装的受管服务默认监听可信局域网,两个端口一起开放:
@@ -78,12 +80,12 @@ curl --fail --silent --show-error http://127.0.0.1:9119/api/auth/providers
 ## 1. 获取并校验指定发布版本
 
 ```bash
-RELEASE_VERSION=v0.2.26
+RELEASE_VERSION=v0.2.27
 git fetch --tags
 git checkout "$RELEASE_VERSION"
 
 test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_VERSION"
-test "$(node -p \"require('./package.json').version\")" = "0.2.26"
+test "$(node -p \"require('./package.json').version\")" = "0.2.27"
 test "$(node -e \"console.log(require('./hermes-plugins/yaoyao/dashboard/manifest.json').version)\")" = "1.7.3"
 npm run release:verify
 ```
@@ -247,7 +249,7 @@ Git、发布目录、命令输出或日志。配置后检查系统管理中的 A
 
 ## 7. 通过受管服务进行配套升级
 
-首次按第 6 步安装 Web `0.2.26` 后,可在左下角 Agent 菜单打开“系统更新”。
+首次按第 6 步安装 Web `0.2.27` 后,可在左下角 Agent 菜单打开“系统更新”。
 检查更新只读取固定的 `HERMES_YAOYAO_RELEASE_SOURCE`,并锁定最新 `vX.Y.Z`
 标签解析到的 Git 提交;执行升级前仍会检查插件持久目录是否安全。
 

+ 45 - 0
docs/http-sse-realtime.md

@@ -0,0 +1,45 @@
+# HTTP + SSE realtime bridge
+
+8800 remains the only client entrypoint. It owns authenticated upstream WebSocket connections to 9119; it does not run a second Agent engine or move Python group data.
+
+## Contract (version 1)
+
+All endpoints are relative to `/api/realtime` (also supported beneath an authorized `/node/:deviceID` base URL).
+
+| Method | Path | Behavior |
+| --- | --- | --- |
+| GET | `/capabilities` | `protocolVersion`, `channels`, `brokerEpoch`; Cookie callers also receive a CSRF token. |
+| POST | `/channels` | `{channel:"chat"}` or `{channel:"groups",epoch,cursor}`; returns `{id,brokerEpoch}`. |
+| GET | `/channels/:id/events` | SSE `ready`, `frame`, `reset`; `frame.data` is the existing RPC/group envelope. |
+| POST | `/channels/:id/commands` | Whitelisted RPC `{method,params}` plus a stable `Idempotency-Key`. |
+| GET | `/commands/:requestId` | Auth-scoped command receipt. |
+| DELETE | `/channels/:id` | Detach the subscriber; does not stop a running Agent. |
+
+Cookie mutations require Origin and X-CSRF-Token. Paired Bearer callers retain their device scopes. SSE credentials are headers/Cookies, never long-lived query credentials. Each channel belongs to its authenticated principal; opening a session explicitly registers that route. Existing shared-history access is unchanged, but events are not broadcast to unrelated subscribers.
+
+Chat frames retain their upstream seq separately from the opaque SSE `id`. Send `Last-Event-ID` only to the same channel. `409 reset_required`, SSE `reset`, or a missing channel requires session resume and authoritative REST history reconciliation. A mere downstream network interruption reconnects the same channel without rebuilding runtimes. Group envelopes retain their original epoch/cursor validation.
+
+## Receipts and failure semantics
+
+`state` is `confirmed`, `rejected`, `pending`, or `unknown`. Only an actual upstream RPC acknowledgement is confirmed; receiving HTTP 200 alone is not a submission acknowledgement. Identical keys reuse the original operation; different payloads under one key conflict. Network retry must retain the key and original encoded command, not rerun attachment preparation. The iOS Outbox ID is the prompt key.
+
+`realtime-receipts.sqlite3` records write-ahead fingerprints and small control acknowledgements, not prompts, attachments, transcript content or full event streams. Terminal records retain 24 hours. Pending operations become unknown on process restart. Unknown receipts are never automatically re-executed or evicted to permit retries; capacity exhaustion rejects new admissions. Full RPC results have a separate bounded in-memory cache; after eviction/restart only the small retained control receipt remains, so clients must refresh resource data rather than assume a history snapshot was persisted.
+
+## Resource limits and HTTP-only clients
+
+- Recent events: 10 minutes, 8 MiB or 10,000 events per principal, 128 MiB globally.
+- At most 8 live subscribers per principal. Up to 32 detached/reconnectable handles; oldest detached handles may be evicted under pressure and then require REST recovery.
+- Slow output: 1 MiB backlog / 15 seconds; detach only the slow downstream.
+- Idle upstreams close after five minutes with no channels, work or pending commands. Running/waiting Agent sessions are retained.
+- Dedicated RPC parser retains 36 MiB frame / 25 MiB attachment bounds. The global JSON parser remains 2 MiB.
+- Old client WS endpoints, WS tickets and realtime leases are removed. Requests receive `410 http_sse_required`; only server-to-9119 WebSockets remain. HTTP RPC mutations use CSRF or paired Bearer authorization.
+- Ordinary chat push recovery uses broker transports. Group push retains its independent durable journal cursor.
+- Different credential scopes are not silently pooled. If they target the same already-owned runtime, the bridge fails closed with `session_scope_conflict` instead of stealing its upstream event receiver.
+
+This does not guarantee Agent survival after 8800/9119 restarts or prolonged upstream loss. Upstream recovery uses `session.events.since` when available; missing/truncated replay falls back to REST history, not to an old client transport. Web and shipping iOS clients require HTTP+SSE capability version 1. Missing capabilities, authentication errors and network errors never downgrade to WebSocket.
+
+## Verification
+
+Run `npm run typecheck`, `npm test`, `npm run build`, and `npx playwright test`. Realtime tests cover shared ownership, route isolation, CSRF, durable unknown receipts, reply loss, replay, cache pressure and a real 21-second downstream outage. Fixture latency samples are not model inference benchmarks.
+
+The iOS `HTTPRealtimeAcceptanceUITests` is opt-in (`HERMES_SSE_ACCEPTANCE=1` in the test runner environment). Start the isolated 19119 fake Hermes and 18801 e2e server and initialize the fixture admin password as in Playwright before running it. It uses an ephemeral cookie jar, not saved user accounts.

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.2.26",
+  "version": "0.2.27",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.2.26",
+      "version": "0.2.27",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.2.26",
+  "version": "0.2.27",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,7 +1,7 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.2.26",
-  "webVersion": "0.2.26",
+  "releaseVersion": "0.2.27",
+  "webVersion": "0.2.27",
   "pluginVersion": "1.7.3",
-  "gitTag": "v0.2.26"
+  "gitTag": "v0.2.27"
 }

+ 1 - 1
src/client/api/client.ts

@@ -73,7 +73,7 @@ function errorMessage(payload: unknown, fallback: string): string {
 }
 
 export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
-  if (!path.startsWith('/api/app/')) throw new ApiError('客户端仅允许访问 /api/app 接口', 0, 'INVALID_PATH')
+  if (!path.startsWith('/api/app/') && !path.startsWith('/api/realtime/')) throw new ApiError('客户端仅允许访问应用接口', 0, 'INVALID_PATH')
 
   const method = (options.method ?? 'GET').toUpperCase()
   const headers = new Headers(options.headers)

+ 101 - 0
src/client/api/httpRealtime.ts

@@ -0,0 +1,101 @@
+import type { JsonValue } from '@shared/types'
+import { SSEParser } from '@shared/sse'
+import { apiRequest, ApiError, setApiCsrfToken } from './client'
+import { createId } from '@/utils/id'
+
+export class HTTPRPCError extends Error {
+  constructor(message: string, readonly code?: string | number, readonly data?: JsonValue) { super(message) }
+}
+
+export class HTTPRealtimeChannel {
+  private id = ''
+  private cursor = ''
+  private stopped = false
+  private abort?: AbortController
+  private ready = false
+  constructor(readonly onFrame: (frame: string) => Promise<unknown>, readonly onFailure: (error: Error) => void) {}
+  static async requireSupport(): Promise<void> {
+    let value: { protocolVersion: number; csrfToken?: string }
+    try { value = await apiRequest('/api/realtime/capabilities') }
+    catch (e) {
+      if (e instanceof ApiError && [404, 405, 410].includes(e.status)) throw new ApiError('此服务端不支持 HTTP+SSE,请升级 8800 服务', 409, 'HTTP_SSE_REQUIRED')
+      throw e
+    }
+    if (value.protocolVersion !== 1) throw new ApiError('不支持的实时协议版本', 409, 'UNSUPPORTED_REALTIME_PROTOCOL')
+    if (value.csrfToken) setApiCsrfToken(value.csrfToken)
+  }
+  async open(channel: 'chat' | 'groups', anchor?: { epoch: string; cursor: number }): Promise<void> {
+    const created = await apiRequest<{ id: string }>('/api/realtime/channels', { method: 'POST', body: { channel, ...anchor } })
+    this.id = created.id
+    if (this.stopped) { this.close(); return }
+    await new Promise<void>((resolve, reject) => {
+      const timer = window.setTimeout(() => { this.abort?.abort(); reject(new ApiError('实时事件握手超时', 0, 'REALTIME_READY_TIMEOUT')) }, 20_000)
+      void this.readLoop(() => { window.clearTimeout(timer); this.ready = true; resolve() }).catch(error => {
+        window.clearTimeout(timer)
+        if (!this.ready) reject(error)
+        if (!this.stopped) this.onFailure(error instanceof Error ? error : new Error(String(error)))
+      })
+    })
+  }
+  private async readLoop(onReady: () => void): Promise<void> {
+    let attempt = 0
+    while (!this.stopped) {
+      this.abort = new AbortController()
+      let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
+      let watchdog: number | undefined
+      try {
+        const headers: Record<string, string> = { Accept: 'text/event-stream' }
+        if (this.cursor) headers['Last-Event-ID'] = this.cursor
+        const response = await fetch(`/api/realtime/channels/${this.id}/events`, { credentials: 'same-origin', headers, signal: this.abort.signal, cache: 'no-store' })
+        if ([401, 403, 404, 409, 428].includes(response.status)) throw new ApiError('实时连接需要重新同步', response.status, 'REALTIME_RESET_REQUIRED')
+        if (!response.ok) throw new Error(`SSE HTTP ${response.status}`)
+        if (!response.headers.get('content-type')?.startsWith('text/event-stream') || !response.body) throw new ApiError('无效的事件流响应', 502, 'INVALID_EVENT_STREAM')
+        reader = response.body.getReader()
+        const parser = new SSEParser(), decoder = new TextDecoder()
+        while (!this.stopped) {
+          window.clearTimeout(watchdog)
+          watchdog = window.setTimeout(() => this.abort?.abort(), 45_000)
+          const { value, done } = await reader.read()
+          if (done) throw new Error('SSE disconnected')
+          attempt = 0
+          for (const entry of parser.feed(decoder.decode(value, { stream: true }))) {
+            if (entry.event === 'reset') throw new ApiError('事件需要重新同步', 409, 'REALTIME_RESET_REQUIRED')
+            if (entry.event === 'ready') continue
+            if (entry.event !== 'frame') continue
+            if (entry.id && entry.id === this.cursor) continue
+            await this.onFrame(entry.data)
+            if (entry.id) this.cursor = entry.id
+            const frame = JSON.parse(entry.data)
+            if (frame.type === 'group.ready' || frame.params?.type === 'gateway.ready') onReady()
+          }
+        }
+      } catch (error) {
+        if (this.stopped) return
+        if (error instanceof ApiError) throw error
+      } finally { window.clearTimeout(watchdog); await reader?.cancel().catch(() => {}); reader?.releaseLock() }
+      await new Promise(r => window.setTimeout(r, Math.min(15_000, 500 * 2 ** Math.min(attempt++, 5))))
+    }
+  }
+  async request(method: string, params: Record<string, JsonValue>, timeoutMs: number, requestId = createId('http-rpc')): Promise<JsonValue> {
+    if (!this.ready || this.stopped) throw new ApiError('事件通道尚未就绪', 0, 'REALTIME_NOT_READY')
+    type Receipt = { state: string; response?: { result?: JsonValue; error?: { message?: string; code?: string | number; data?: JsonValue } } }
+    let receipt: Receipt
+    try {
+      receipt = await apiRequest(`/api/realtime/channels/${this.id}/commands`, {
+        method: 'POST', headers: { 'Idempotency-Key': requestId }, body: { jsonrpc: '2.0', method, params }, timeoutMs,
+      })
+    } catch (error) {
+      if (error instanceof ApiError && error.status >= 400 && error.status < 500) throw error
+      try { receipt = await apiRequest(`/api/realtime/commands/${encodeURIComponent(requestId)}`) }
+      catch { throw new ApiError('提交结果未知,请同步历史确认', 0, 'SUBMISSION_UNCERTAIN') }
+    }
+    if (receipt.state === 'unknown' || receipt.state === 'pending') throw new ApiError('提交结果未知,请同步历史确认', 0, 'SUBMISSION_UNCERTAIN')
+    if (receipt.response?.error) throw new HTTPRPCError(receipt.response.error.message ?? '上游拒绝请求', receipt.response.error.code, receipt.response.error.data)
+    if (receipt.state !== 'confirmed') throw new ApiError('无效的命令回执', 502, 'INVALID_RECEIPT')
+    return receipt.response?.result ?? null
+  }
+  close(): void {
+    this.stopped = true; this.abort?.abort()
+    if (this.id) { const id = this.id; this.id = ''; void apiRequest(`/api/realtime/channels/${id}`, { method: 'DELETE' }).catch(() => {}) }
+  }
+}

+ 56 - 200
src/client/api/realtime.ts

@@ -1,197 +1,81 @@
-import type { GroupSocketEnvelope, JsonValue, RealtimeConnectionState, RpcEventFrame, RpcRequestFrame } from '@shared/types'
-import { apiRequest, ApiError, unwrapData } from './client'
-import { createId } from '@/utils/id'
+import type { GroupSocketEnvelope, JsonValue, RealtimeConnectionState, RpcEventFrame } from '@shared/types'
+import { ApiError } from './client'
 import { number, record, string } from '@/utils/normalize'
+import { HTTPRealtimeChannel, HTTPRPCError } from './httpRealtime'
 
 export class RpcError extends Error {
-  readonly code?: number | string
-  readonly data?: JsonValue
-
-  constructor(message: string, code?: number | string, data?: JsonValue) {
-    super(message)
-    this.name = 'RpcError'
-    this.code = code
-    this.data = data
+  constructor(message: string, readonly code?: number | string, readonly data?: JsonValue) {
+    super(message); this.name = 'RpcError'
   }
 }
 
-interface LeaseResponse { lease?: string; token?: string; id?: string; expiresAt?: string }
-
-export async function createRealtimeLease(channel: 'chat' | 'groups', anchor?: { epoch: string; cursor: number }): Promise<string> {
-  const payload = unwrapData(await apiRequest<LeaseResponse>('/api/app/realtime-leases', {
-    method: 'POST', body: { channel, ...(anchor ?? {}) }, timeoutMs: 15_000,
-  }))
-  const lease = payload.lease ?? payload.token ?? payload.id
-  if (!lease?.trim()) throw new ApiError('实时连接租约无效', 0, 'INVALID_LEASE')
-  return lease.trim()
-}
-
-function webSocketURL(path: string, query: Record<string, string | number | undefined>): string {
-  const url = new URL(path, window.location.origin)
-  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
-  for (const [key, value] of Object.entries(query)) if (value !== undefined) url.searchParams.set(key, String(value))
-  return url.toString()
-}
-
 type StateListener = (state: RealtimeConnectionState, reason?: string) => void
 type RpcEventListener = (event: RpcEventFrame['params']) => void
 
-interface PendingRequest {
-  resolve(value: JsonValue): void
-  reject(reason: unknown): void
-  timer: number
-  method: string
-}
-
+/** Historical facade name retained for store injection; transport is HTTP+SSE only. */
 export class ChatRpcSocket {
-  private socket?: WebSocket
+  private http?: HTTPRealtimeChannel
   private generation = 0
-  private pending = new Map<string, PendingRequest>()
+  private openingRequests = 0
+  private earlyFrames: string[] = []
   private eventListeners = new Set<RpcEventListener>()
   private stateListeners = new Set<StateListener>()
-  private manuallyClosed = false
   state: RealtimeConnectionState = 'idle'
 
-  onEvent(listener: RpcEventListener): () => void {
-    this.eventListeners.add(listener)
-    return () => this.eventListeners.delete(listener)
-  }
-
-  onState(listener: StateListener): () => void {
-    this.stateListeners.add(listener)
-    return () => this.stateListeners.delete(listener)
-  }
-
+  onEvent(listener: RpcEventListener): () => void { this.eventListeners.add(listener); return () => this.eventListeners.delete(listener) }
+  onState(listener: StateListener): () => void { this.stateListeners.add(listener); return () => this.stateListeners.delete(listener) }
   async connect(): Promise<void> {
     this.close()
-    this.manuallyClosed = false
     const generation = ++this.generation
-    this.publishState('leasing')
-    const lease = await createRealtimeLease('chat')
-    if (generation !== this.generation || this.manuallyClosed) return
     this.publishState('connecting')
-    await new Promise<void>((resolve, reject) => {
-      const socket = new WebSocket(webSocketURL('/ws/chat', { lease }))
-      this.socket = socket
-      let settled = false
-      const readyTimer = window.setTimeout(() => {
-        if (settled) return
-        settled = true
-        socket.close(1011, 'gateway ready timeout')
-        reject(new ApiError('等待 Hermes 实时握手超时', 0, 'GATEWAY_READY_TIMEOUT'))
-      }, 15_000)
-      socket.addEventListener('open', () => {
-        if (generation !== this.generation) return socket.close(1000)
-        this.publishState('connected')
-      }, { once: true })
-      socket.addEventListener('message', event => {
-        void this.handleMessage(event.data, generation).then(ready => {
-          if (!ready || settled) return
-          settled = true
-          window.clearTimeout(readyTimer)
-          resolve()
-        })
-      })
-      socket.addEventListener('error', () => {
-        if (!settled) {
-          settled = true
-          window.clearTimeout(readyTimer)
-          reject(new ApiError('聊天实时连接失败', 0, 'WEBSOCKET_ERROR'))
-        }
-      })
-      socket.addEventListener('close', event => {
-        if (!settled) {
-          settled = true
-          window.clearTimeout(readyTimer)
-          reject(new ApiError(event.reason || '聊天实时连接在握手前关闭', 0, 'WEBSOCKET_CLOSED'))
-        }
-        this.handleClose(event, generation)
-      })
+    await HTTPRealtimeChannel.requireSupport()
+    if (generation !== this.generation) return
+    this.http = new HTTPRealtimeChannel(frame => this.handleMessage(frame, generation), error => {
+      if (generation === this.generation) this.publishState('failed', error.message)
     })
+    await this.http.open('chat')
   }
-
   close(): void {
-    this.manuallyClosed = true
-    this.generation += 1
-    this.socket?.close(1000, 'client close')
-    this.socket = undefined
-    this.rejectAll(new ApiError('实时连接已关闭', 0, 'WEBSOCKET_CLOSED'))
+    this.generation++; this.http?.close(); this.http = undefined
+    this.earlyFrames = []; this.openingRequests = 0
     this.publishState('disconnected')
   }
-
-  async request(method: string, params: Record<string, JsonValue> = {}, timeoutMs = 120_000): Promise<JsonValue> {
-    const socket = this.socket
-    if (!socket || socket.readyState !== WebSocket.OPEN) throw new ApiError('聊天实时连接尚未就绪', 0, 'WEBSOCKET_NOT_READY')
-    const id = createId('rpc')
-    const frame: RpcRequestFrame = { jsonrpc: '2.0', id, method, params }
-    return new Promise<JsonValue>((resolve, reject) => {
-      const timer = window.setTimeout(() => {
-        this.pending.delete(id)
-        reject(new ApiError(`实时请求超时:${method}`, 0, 'RPC_TIMEOUT'))
-        // A timed-out request has an unknown receipt and the transport may be half-open.
-        this.socket?.close(1011, 'rpc timeout')
-      }, timeoutMs)
-      this.pending.set(id, { resolve, reject, timer, method })
-      try {
-        socket.send(JSON.stringify(frame))
-      } catch (error) {
-        window.clearTimeout(timer)
-        this.pending.delete(id)
-        reject(error)
+  async request(method: string, params: Record<string, JsonValue> = {}, timeoutMs = 120_000, requestId?: string): Promise<JsonValue> {
+    if (!this.http) throw new ApiError('HTTP 事件通道尚未就绪', 0, 'REALTIME_NOT_READY')
+    const generation = this.generation
+    const opening = ['session.create', 'session.resume', 'session.branch'].includes(method)
+    if (opening) this.openingRequests++
+    try { return await this.http.request(method, params, timeoutMs, requestId) }
+    catch (error) { if (error instanceof HTTPRPCError) throw new RpcError(error.message, error.code, error.data); throw error }
+    finally {
+      if (opening && generation === this.generation) {
+        this.openingRequests--
+        window.setTimeout(() => {
+          if (generation !== this.generation || this.openingRequests) return
+          const frames = this.earlyFrames; this.earlyFrames = []
+          for (const frame of frames) void this.handleMessage(frame, generation)
+        }, 0)
       }
-    })
+    }
   }
-
-  private async handleMessage(data: unknown, generation: number): Promise<boolean> {
-    if (generation !== this.generation) return false
-    let text: string
-    if (typeof data === 'string') text = data
-    else if (data instanceof Blob) text = await data.text()
-    else if (data instanceof ArrayBuffer) text = new TextDecoder().decode(data)
-    else return false
-    let frame: unknown
-    try { frame = JSON.parse(text) } catch { return false }
-    const source = record(frame)
-    const id = typeof source.id === 'string' || typeof source.id === 'number' ? String(source.id) : ''
-    if (id && this.pending.has(id)) {
-      const pending = this.pending.get(id)!
-      this.pending.delete(id)
-      window.clearTimeout(pending.timer)
-      const error = record(source.error)
-      if (source.error) pending.reject(new RpcError(string(error.message, 'Hermes JSON-RPC 请求失败'), error.code as number | string, error.data as JsonValue))
-      else pending.resolve((source.result ?? null) as JsonValue)
-      return false
+  private async handleMessage(text: string, generation: number): Promise<void> {
+    if (generation !== this.generation) return
+    const frame = record(JSON.parse(text))
+    if (frame.method !== 'event') return
+    const params = record(frame.params), type = string(params.type)
+    if (!type) return
+    if (this.openingRequests && type !== 'gateway.ready') {
+      if (this.earlyFrames.length >= 512) { this.earlyFrames = []; this.publishState('failed', '事件缓存已满,需要重新同步') }
+      else this.earlyFrames.push(text)
+      return
     }
-    if (source.method !== 'event') return false
-    const params = record(source.params)
-    const type = string(params.type)
-    if (!type) return false
+    if (type === 'gateway.ready') this.publishState('ready')
     const event: RpcEventFrame['params'] = {
-      type,
-      session_id: string(params.session_id) || undefined,
-      profile: string(params.profile) || undefined,
-      payload: (params.payload ?? null) as JsonValue,
+      type, session_id: string(params.session_id) || undefined,
+      profile: string(params.profile) || undefined, payload: (params.payload ?? null) as JsonValue,
     }
-    if (type === 'gateway.ready') this.publishState('ready')
     for (const listener of this.eventListeners) listener(event)
-    return type === 'gateway.ready'
-  }
-
-  private handleClose(event: CloseEvent, generation: number): void {
-    if (generation !== this.generation) return
-    this.socket = undefined
-    this.rejectAll(new ApiError('聊天实时连接已断开', 0, 'WEBSOCKET_CLOSED'))
-    this.publishState(this.manuallyClosed ? 'disconnected' : 'failed', event.reason || `连接关闭(${event.code})`)
   }
-
-  private rejectAll(error: Error): void {
-    for (const pending of this.pending.values()) {
-      window.clearTimeout(pending.timer)
-      pending.reject(error)
-    }
-    this.pending.clear()
-  }
-
   private publishState(state: RealtimeConnectionState, reason?: string): void {
     this.state = state
     for (const listener of this.stateListeners) listener(state, reason)
@@ -207,55 +91,27 @@ export interface GroupEventSocketOptions {
 }
 
 export class GroupEventSocket {
-  private socket?: WebSocket
+  private http?: HTTPRealtimeChannel
   private generation = 0
-  private manuallyClosed = false
   private epoch = ''
   private cursor = 0
   state: RealtimeConnectionState = 'idle'
-
   async connect(options: GroupEventSocketOptions): Promise<void> {
     this.close()
-    this.manuallyClosed = false
-    this.epoch = options.epoch
-    this.cursor = options.cursor
+    this.epoch = options.epoch; this.cursor = options.cursor
     const generation = ++this.generation
-    this.publish(options, 'leasing')
-    const lease = await createRealtimeLease('groups', { epoch: this.epoch, cursor: this.cursor })
-    if (generation !== this.generation || this.manuallyClosed) return
     this.publish(options, 'connecting')
-    await new Promise<void>((resolve, reject) => {
-      const socket = new WebSocket(webSocketURL('/ws/groups', { lease }))
-      this.socket = socket
-      let settled = false
-      socket.addEventListener('open', () => {
-        if (generation !== this.generation) return socket.close(1000)
-        settled = true
-        resolve()
-      }, { once: true })
-      socket.addEventListener('message', event => { void this.handleMessage(event.data, generation, options) })
-      socket.addEventListener('error', () => {
-        if (!settled) {
-          settled = true
-          reject(new ApiError('团队事件连接失败', 0, 'GROUP_WEBSOCKET_ERROR'))
-        }
-      })
-      socket.addEventListener('close', event => {
-        if (generation !== this.generation) return
-        this.socket = undefined
-        this.publish(options, this.manuallyClosed ? 'disconnected' : 'failed', event.reason || `连接关闭(${event.code})`)
-      })
+    await HTTPRealtimeChannel.requireSupport()
+    if (generation !== this.generation) return
+    this.http = new HTTPRealtimeChannel(frame => this.handleMessage(frame, generation, options), error => {
+      if (generation === this.generation) this.needsReset(options, error.message)
     })
+    await this.http.open('groups', { epoch: this.epoch, cursor: this.cursor })
   }
-
   close(): void {
-    this.manuallyClosed = true
-    this.generation += 1
-    this.socket?.close(1000, 'client close')
-    this.socket = undefined
+    this.generation++; this.http?.close(); this.http = undefined
     this.state = 'disconnected'
   }
-
   private async handleMessage(data: unknown, generation: number, options: GroupEventSocketOptions): Promise<void> {
     if (generation !== this.generation) return
     let text: string
@@ -309,8 +165,8 @@ export class GroupEventSocket {
   }
 
   private needsReset(options: GroupEventSocketOptions, reason: string): void {
+    this.http?.close(); this.http = undefined
     this.publish(options, 'needs-reset', reason)
-    this.socket?.close(1000, 'reset required')
     options.onReset(reason)
   }
 

+ 1 - 1
src/client/stores/chat.ts

@@ -719,7 +719,7 @@ export const useChatStore = defineStore('chat', () => {
         session_id: runtimeId,
         text: parts.join('\n\n'),
         ...(effectiveMode === 'queue' ? { queued: true } : {}),
-      }))
+      }, 120_000, `web:prompt:${clientMessageId}`))
       const status = string(result.status, 'accepted').toLowerCase()
       if (effectiveMode === 'steer' && !['accepted', 'queued', 'steered', 'streaming'].includes(status)) {
         throw new RpcError(string(result.error ?? result.message, 'Hermes 拒绝了 Steer 请求'), status)

+ 14 - 25
src/server/app.ts

@@ -5,10 +5,10 @@ import { createServer as createHttpsServer } from 'node:https'
 import { join } from 'node:path'
 import { bodyParser } from '@koa/bodyparser'
 import Koa from 'koa'
+import { RealtimeAPI } from './realtimeApi.js'
 import type { ServerConfig } from './config.js'
 import { loadServerConfig } from './config.js'
 import { errorMessage, HttpError } from './errors.js'
-import { RealtimeLeaseStore } from './leases.js'
 import { NodePairingStore } from './pairing.js'
 import { LocalAuthStore, UpstreamServiceSession } from './localAuth.js'
 import { AccountLoginPairingStore } from './accountPairing.js'
@@ -23,7 +23,6 @@ import {
 import { UpstreamHttpError, UpstreamClient } from './upstream.js'
 import { UploadStore } from './uploads.js'
 import { SystemUpdateManager } from './updateManager.js'
-import { installWebSocketRelay } from './websocket.js'
 import { PushCoordinator } from './pushCoordinator.js'
 import {
   APNsConfigurationManager,
@@ -49,7 +48,6 @@ export interface ApplicationOptions {
   config?: ServerConfig
   fetchImpl?: typeof fetch
   csrfSecret?: Buffer
-  leases?: RealtimeLeaseStore
   pairings?: NodePairingStore
   uploads?: UploadStore
   updates?: SystemUpdateManager
@@ -64,10 +62,10 @@ export interface ApplicationOptions {
 }
 
 export interface ApplicationRuntime {
+  realtime: RealtimeAPI
   app: Koa
   config: ServerConfig
   csrf: CsrfProtection
-  leases: RealtimeLeaseStore
   pairings: NodePairingStore
   upstream: UpstreamClient
   uploads: UploadStore
@@ -120,7 +118,6 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const app = new Koa()
   app.proxy = false
   const csrf = new CsrfProtection(persistentCsrfSecret(config.home, options.csrfSecret), Boolean(config.tlsCert))
-  const leases = options.leases ?? new RealtimeLeaseStore()
   const pairings = options.pairings ?? new NodePairingStore(config.home)
   const upstream = new UpstreamClient(config.upstream, options.fetchImpl, Boolean(config.tlsCert))
   const uploads = options.uploads ?? new UploadStore(config.home)
@@ -191,6 +188,11 @@ export function createApplication(options: ApplicationOptions = {}): Application
     new HermesGroupEventSource(config, upstreamSession),
     pushEventCoordinator,
   )
+  const realtime = new RealtimeAPI(config, auth, csrf, pairings, upstream, upstreamSession, {
+    coordinator: pushEventCoordinator,
+    resolver: new HermesChatNotificationResolver(upstreamSession, (user, prompt) => push.promptDigest(user, prompt)),
+  })
+  chatPushJobs.setTransportFactory(realtime.recoveryTransport, job => realtime.ownsPushJob(job.id))
   try {
     uploads.cleanupUncommitted()
   } catch {
@@ -242,13 +244,14 @@ export function createApplication(options: ApplicationOptions = {}): Application
     }
     await next()
   })
+  app.use(realtime.middleware())
   app.use(bodyParser({
     encoding: 'utf-8',
     enableTypes: ['json'],
     parsedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
     jsonLimit: '2mb',
-    onError: (_error, ctx) => {
-      throw new HttpError(ctx.status === 413 ? 413 : 400, 'Invalid JSON request body', 'invalid_json_body')
+    onError: (error, ctx) => {
+      throw new HttpError(ctx.status === 413 || (error as { status?: number }).status === 413 ? 413 : 400, 'Invalid JSON request body', 'invalid_json_body')
     },
   }))
   app.use(async (ctx, next) => {
@@ -296,7 +299,6 @@ export function createApplication(options: ApplicationOptions = {}): Application
     config,
     csrf,
     upstream,
-    leases,
     pairings,
     uploads,
     updates,
@@ -322,10 +324,10 @@ export function createApplication(options: ApplicationOptions = {}): Application
   })
 
   return {
+    realtime,
     app,
     config,
     csrf,
-    leases,
     pairings,
     upstream,
     uploads,
@@ -342,6 +344,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     chatPushJobs,
     groupPushEvents,
     close: () => {
+      realtime.close()
       chatPushJobs.stop()
       groupPushEvents.stop()
       push.close()
@@ -374,27 +377,13 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
   }
   synchronizePushObservers()
   const removePushConfigurationListener = runtime.push.onEnabledChange(synchronizePushObservers)
-  const removeWebSockets = installWebSocketRelay(
-    server,
-    config,
-    runtime.leases,
-    runtime.pairings,
-    {
-      coordinator: runtime.pushEventCoordinator,
-      resolveGatewayUser: request => runtime.auth.currentFromCookieHeader(
-        typeof request.headers.cookie === 'string' ? request.headers.cookie : undefined,
-      )?.id,
-      notificationResolver: new HermesChatNotificationResolver(
-        runtime.upstreamSession,
-        (localUserID, prompt) => runtime.push.promptDigest(localUserID, prompt),
-      ),
-    },
-  )
+  const removeWebSockets = runtime.realtime.rejectLegacyUpgrades(server)
   return {
     server,
     close: async () => {
       removePushConfigurationListener()
       removeWebSockets()
+      runtime.realtime.close()
       await new Promise<void>((resolve, reject) => {
         if (!server.listening) {
           resolve()

+ 0 - 105
src/server/leases.ts

@@ -1,105 +0,0 @@
-import { randomBytes } from 'node:crypto'
-import { HttpError } from './errors.js'
-
-export type RealtimeChannel = 'chat' | 'groups'
-export type UpstreamCredential = { name: 'ticket' | 'token'; value: string }
-
-export interface RealtimeLease {
-  id: string
-  channel: RealtimeChannel
-  credential: UpstreamCredential
-  origin: string
-  accountKeys: ReadonlySet<string>
-  localUserID?: string
-  epoch?: string
-  cursor?: number
-  expiresAt: number
-}
-
-export interface IssueLeaseInput {
-  channel: RealtimeChannel
-  credential: UpstreamCredential
-  origin: string
-  accountKeys: Iterable<string>
-  localUserID?: string
-  epoch?: string
-  cursor?: number
-}
-
-export class RealtimeLeaseStore {
-  readonly #leases = new Map<string, RealtimeLease>()
-
-  constructor(
-    readonly ttlMs = 20_000,
-    readonly now: () => number = Date.now,
-  ) {}
-
-  issue(input: IssueLeaseInput): RealtimeLease {
-    this.prune()
-    const accountKeys = new Set([...input.accountKeys].filter(Boolean))
-    if (accountKeys.size === 0) throw new HttpError(401, 'Missing account scope', 'missing_account')
-    const activeForAccount = [...this.#leases.values()].filter((lease) =>
-      [...accountKeys].some((key) => lease.accountKeys.has(key)),
-    ).length
-    if (activeForAccount >= 16) {
-      throw new HttpError(429, 'Too many pending realtime leases', 'lease_limit')
-    }
-    const id = randomBytes(32).toString('base64url')
-    const lease: RealtimeLease = {
-      id,
-      channel: input.channel,
-      credential: input.credential,
-      origin: input.origin,
-      accountKeys,
-      localUserID: input.localUserID,
-      epoch: input.epoch,
-      cursor: input.cursor,
-      expiresAt: this.now() + this.ttlMs,
-    }
-    this.#leases.set(id, lease)
-    return lease
-  }
-
-  consume(
-    id: string,
-    channel: RealtimeChannel,
-    origin: string,
-    accountKey: string,
-  ): RealtimeLease {
-    this.prune()
-    const lease = this.#leases.get(id)
-    if (!lease || lease.channel !== channel || lease.origin !== origin || !lease.accountKeys.has(accountKey)) {
-      throw new HttpError(401, 'Realtime lease is invalid or expired', 'invalid_lease')
-    }
-    this.#leases.delete(id)
-    return lease
-  }
-
-  prune(): void {
-    const now = this.now()
-    for (const [id, lease] of this.#leases) {
-      if (lease.expiresAt <= now) this.#leases.delete(id)
-    }
-  }
-
-  get size(): number {
-    this.prune()
-    return this.#leases.size
-  }
-}
-
-export function canonicalEpoch(value: unknown): string {
-  if (typeof value !== 'string'
-    || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)) {
-    throw new HttpError(400, 'epoch must be a canonical lowercase UUID', 'invalid_epoch')
-  }
-  return value
-}
-
-export function groupCursor(value: unknown): number {
-  const cursor = typeof value === 'number' ? value : Number(value ?? 0)
-  if (!Number.isSafeInteger(cursor) || cursor < 0) {
-    throw new HttpError(400, 'cursor must be a non-negative integer', 'invalid_cursor')
-  }
-  return cursor
-}

+ 33 - 2
src/server/pushEvents.ts

@@ -373,6 +373,10 @@ export class ChatPushRelayObserver {
     })
   }
 
+  ownsJob(jobID: string): boolean {
+    return !this.#closed && [...this.#jobsByRuntimeSession.values()].some(jobs => jobs.some(job => job.id === jobID))
+  }
+
   observeUpstreamFrame(data: RawData, isBinary: boolean): void {
     if (this.#closed || isBinary) return
     let frame: JsonRecord
@@ -1127,13 +1131,20 @@ async function chatTerminalCandidate(
  * Server-owned recovery connection for one persisted chat job. The caller
  * controls the number of concurrently started watchers.
  */
+export interface ChatPushRecoveryTransport {
+  start(): void
+  send(data: string): void
+  close(code?: number, reason?: string): void
+}
+export type ChatPushTransportFactory = (job: ChatPushJob, onFrame: (frame: string) => void, onClose: () => void) => Promise<ChatPushRecoveryTransport>
+
 export class HermesChatPushJobWatcher {
   #job: ChatPushJob
   #running = false
   #generation = 0
   #retryAttempt = 0
   #retryTimer?: ReturnType<typeof setTimeout>
-  #socket?: WebSocket
+  #socket?: WebSocket | ChatPushRecoveryTransport
   #resumeRequestID = ''
   #frames = Promise.resolve()
   #finished = false
@@ -1146,6 +1157,7 @@ export class HermesChatPushJobWatcher {
     readonly resolver: ChatNotificationResolver = new HermesChatNotificationResolver(upstreamSession),
     readonly now: () => number = Date.now,
     readonly onFinished?: (jobID: string) => void,
+    readonly transportFactory?: ChatPushTransportFactory,
   ) {
     this.#job = { ...job }
   }
@@ -1174,6 +1186,15 @@ export class HermesChatPushJobWatcher {
       return
     }
     try {
+      if (this.transportFactory) {
+        const transport = await this.transportFactory(this.#job,
+          frame => this.#queueFrame(generation, Buffer.from(frame), false),
+          () => this.#scheduleReconnect(generation))
+        if (!this.#running || generation !== this.#generation) { transport.close(); return }
+        this.#socket = transport
+        transport.start()
+        return
+      }
       const ticketResponse = await this.upstreamSession.request('/api/auth/ws-ticket', { method: 'POST' })
       if (ticketResponse.status < 200 || ticketResponse.status >= 300) {
         throw new Error(`Hermes WebSocket ticket failed (${ticketResponse.status})`)
@@ -1376,6 +1397,7 @@ export class HermesChatPushJobWatcher {
 }
 
 export interface ChatPushJobManagerOptions {
+  transportFactory?: ChatPushTransportFactory
   maximumConcurrent?: number
   reconcileMilliseconds?: number
   now?: () => number
@@ -1384,6 +1406,8 @@ export interface ChatPushJobManagerOptions {
 
 /** Starts and bounds the server-owned recovery sockets for persisted jobs. */
 export class ChatPushJobManager {
+  private transportFactory?: ChatPushTransportFactory
+  private brokerOwnsJob?: (job: ChatPushJob) => boolean
   readonly #watchers = new Map<string, HermesChatPushJobWatcher>()
   readonly #maximumConcurrent: number
   readonly #reconcileMilliseconds: number
@@ -1403,6 +1427,11 @@ export class ChatPushJobManager {
     this.#reconcileMilliseconds = Math.max(250, Math.trunc(options.reconcileMilliseconds ?? 1_000))
     this.#now = options.now ?? Date.now
     this.#resolver = options.resolver ?? new HermesChatNotificationResolver(upstreamSession)
+    this.transportFactory = options.transportFactory
+  }
+
+  setTransportFactory(factory: ChatPushTransportFactory, ownsJob?: (job: ChatPushJob) => boolean): void {
+    this.transportFactory = factory; this.brokerOwnsJob = ownsJob
   }
 
   start(): void {
@@ -1444,10 +1473,11 @@ export class ChatPushJobManager {
     for (const job of pending) {
       const structurallyEligible = job.expiresAt > now
         && job.metadata?.baselineCaptured === true
-        && Number.isFinite(Number(job.metadata?.disconnectedAt))
+        && (Boolean(this.transportFactory) || Number.isFinite(Number(job.metadata?.disconnectedAt)))
         && Boolean(string(job.metadata?.runID) || string(job.metadata?.promptDigest))
         && (job.phase === 'submitted' || job.phase === 'accepted' || job.phase === 'watching')
       if (!structurallyEligible) continue
+      if (this.brokerOwnsJob?.(job)) continue
       if (this.coordinator.canRecoverChatJob
         && !await this.coordinator.canRecoverChatJob(job)) continue
       jobs.push(job)
@@ -1474,6 +1504,7 @@ export class ChatPushJobManager {
           this.#watchers.delete(jobID)
           if (this.#running) void this.reconcile()
         },
+        this.transportFactory,
       )
       this.#watchers.set(job.id, watcher)
       watcher.start()

+ 214 - 0
src/server/realtimeApi.ts

@@ -0,0 +1,214 @@
+import { randomBytes } from 'node:crypto'
+import type { Server, IncomingMessage } from 'node:http'
+import type { Duplex } from 'node:stream'
+import type Koa from 'koa'
+import { RealtimeBroker, type RealtimePrincipal, type RealtimeChannel } from './realtimeBroker.js'
+import type { ServerConfig } from './config.js'
+import { LocalAuthStore, UpstreamServiceSession } from './localAuth.js'
+import { NodePairingStore } from './pairing.js'
+import { CookieJar, UpstreamClient } from './upstream.js'
+import { CsrfProtection, isAllowedHostHeader, isExactOrigin } from './security.js'
+import { HttpError } from './errors.js'
+import { canonicalEpoch, groupCursor } from './realtimeProtocol.js'
+import { CHAT_MAX_PAYLOAD } from './realtimeProtocol.js'
+import { ChatPushRelayObserver, type ChatNotificationResolver, type PushEventCoordinator, type ChatPushTransportFactory } from './pushEvents.js'
+
+export class RealtimeAPI {
+  readonly broker: RealtimeBroker
+  private observers = new Map<string, ChatPushRelayObserver>()
+  constructor(readonly config: ServerConfig, readonly auth: LocalAuthStore, readonly csrf: CsrfProtection,
+    readonly pairings: NodePairingStore, readonly upstream: UpstreamClient, readonly session: UpstreamServiceSession,
+    readonly push?: { coordinator: PushEventCoordinator; resolver: ChatNotificationResolver }) {
+    this.broker = new RealtimeBroker(config.home)
+  }
+  private principal(ctx: Koa.Context, device?: string): RealtimePrincipal {
+    let key: string
+    let valid: () => boolean
+    let authorize: RealtimePrincipal['authorize']
+    let jar: CookieJar | undefined
+    let observer: ChatPushRelayObserver | undefined
+    if (device) {
+      const bearer = ctx.get('authorization').match(/^Bearer\s+(.+)$/i)?.[1] ?? ''
+      jar = new CookieJar(this.pairings.authorize(device, bearer))
+      key = `device:${device}`
+      valid = () => this.pairings.hasDevice(device)
+      authorize = (kind, method) => {
+        this.pairings.authorize(device, bearer, kind === 'groups' ? 'groups.read'
+          : method === 'profiles.list' || method === 'profiles.get_asset' ? 'agents.read' : 'sessions.execute')
+      }
+    } else {
+      const user = this.auth.require(ctx)
+      const cookie = ctx.get('cookie')
+      key = `user:${user.id}:${this.auth.pushAuthorizationVersion(user.id)}`
+      valid = () => { try { return this.auth.currentFromCookieHeader(cookie)?.id === user.id } catch { return false } }
+      if (this.push) {
+        observer = this.observers.get(key)
+        if (!observer) {
+          observer = new ChatPushRelayObserver(this.push.coordinator, { localUserID: user.id, source: 'web' }, Date.now, this.push.resolver)
+          this.observers.set(key, observer)
+        }
+      }
+    }
+    return {
+      key, instanceKey: this.config.upstream.href, upstreamKey: `${this.config.upstream.href}:${device ? `device:${device}` : 'service'}`, paired: Boolean(device), valid, authorize,
+      observeCommand: f => observer?.observeClientFrame(f),
+      observeEvent: f => observer?.observeUpstreamFrame(Buffer.from(f), false),
+      url: async (kind, anchor) => {
+        if (device && !valid()) throw new HttpError(401, 'Device revoked', 'authentication_required')
+        const response = jar
+          ? await this.upstream.request('/api/auth/ws-ticket', jar, { method: 'POST' })
+          : await this.session.request('/api/auth/ws-ticket', { method: 'POST' })
+        if (response.status !== 200) throw new HttpError(502, 'Upstream authentication failed', 'upstream_auth_failed')
+        const ticket = JSON.parse(response.body.toString()).ticket
+        if (typeof ticket !== 'string' || !ticket) throw new HttpError(502, 'Invalid upstream ticket', 'invalid_ticket')
+        const url = new URL(this.config.upstream)
+        url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
+        url.pathname = `${url.pathname.replace(/\/$/, '')}/api/${kind === 'chat' ? 'ws' : 'plugins/yaoyao/v1/events'}`
+        url.search = ''; url.searchParams.set('ticket', ticket)
+        if (anchor) { url.searchParams.set('epoch', anchor.epoch); url.searchParams.set('cursor', String(anchor.cursor)) }
+        return url
+      },
+    }
+  }
+  recoveryTransport: ChatPushTransportFactory = async (job, onFrame, onClose) => {
+    const principal: RealtimePrincipal = {
+      key: `push:${job.localUserID}:${this.auth.pushAuthorizationVersion(job.localUserID)}`,
+      upstreamKey: `${this.config.upstream.href}:service`, paired: false,
+      instanceKey: this.config.upstream.href,
+      valid: () => this.auth.isUserActive(job.localUserID),
+      url: async () => {
+        const response = await this.session.request('/api/auth/ws-ticket', { method: 'POST' })
+        const ticket = JSON.parse(response.body.toString()).ticket
+        if (response.status !== 200 || !ticket) throw new Error('Upstream authentication failed')
+        const url = new URL(this.config.upstream)
+        url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
+        url.pathname = `${url.pathname.replace(/\/$/, '')}/api/ws`
+        url.search = ''; url.searchParams.set('ticket', ticket)
+        return url
+      },
+    }
+    const channel = await this.broker.create(principal, 'chat')
+    let off = () => {}, closed = false
+    return {
+      start: () => { off = this.broker.subscribe(channel, undefined, entry => { if (entry.event === 'reset') onClose(); else onFrame(entry.data) }) },
+      send: raw => {
+        const frame = JSON.parse(raw)
+        void Promise.resolve().then(() => this.broker.command(channel, randomBytes(24).toString('hex'), frame)).then(receipt => {
+          if (!closed) onFrame(JSON.stringify({ id: frame.id, ...(receipt.response ?? { error: { message: 'Submission uncertain' } }) }))
+        }).catch(onClose)
+      },
+      close: () => { closed = true; off(); this.broker.release(channel) },
+    }
+  }
+  ownsPushJob(jobID: string): boolean { return [...this.observers.values()].some(observer => observer.ownsJob(jobID)) }
+  private checkMutation(ctx: Koa.Context, paired: boolean): void {
+    if (paired || ['GET', 'HEAD'].includes(ctx.method)) return
+    if (!isExactOrigin(ctx.get('origin'), ctx.get('host'), ctx.secure || Boolean(this.config.tlsCert), this.config.allowedHosts)
+      || !this.csrf.verify(ctx.get('cookie'), ctx.get('x-csrf-token'))) {
+      throw new HttpError(403, 'Origin or CSRF token is invalid', 'invalid_csrf')
+    }
+  }
+  private async body(ctx: Koa.Context, limit = 2 * 1024 * 1024): Promise<Record<string, any>> {
+    if (!ctx.is('application/json')) throw new HttpError(415, 'JSON body required', 'invalid_content_type')
+    let bytes = 0
+    const chunks: Buffer[] = []
+    const timeout = setTimeout(() => ctx.req.destroy(), 30_000)
+    timeout.unref()
+    try {
+      for await (const chunk of ctx.req) {
+        bytes += Buffer.byteLength(chunk)
+        if (bytes > limit) throw new HttpError(413, 'Request body too large', 'body_too_large')
+        chunks.push(Buffer.from(chunk))
+      }
+      const value: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
+      if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not object')
+      return value as Record<string, any>
+    } catch (e) { if (e instanceof HttpError) throw e; throw new HttpError(400, 'Invalid JSON', 'invalid_json_body') }
+    finally { clearTimeout(timeout) }
+  }
+  middleware(): Koa.Middleware {
+    return async (ctx, next) => {
+      const match = /^(?:\/node\/([0-9a-f-]{36}))?\/api\/realtime(\/.*)$/.exec(ctx.path)
+      if (/^(?:\/node\/[^/]+)?\/api\/(?:auth\/ws-ticket|ws|plugins\/yaoyao\/v1\/events)$/.test(ctx.path)
+        || ctx.path === '/api/app/realtime-leases' || /^\/ws\/(?:chat|groups)$/.test(ctx.path)) {
+        throw new HttpError(410, 'Legacy realtime endpoints were removed; use HTTP+SSE', 'http_sse_required')
+      }
+      if (!match) return next()
+      const device = match[1]
+      const principal = this.principal(ctx, device)
+      ctx.set('Cache-Control', 'no-store')
+      const path = match![2]!
+      if (ctx.method === 'GET' && path === '/capabilities') {
+        ctx.body = { protocolVersion: 1, channels: ['chat', 'groups'], brokerEpoch: this.broker.epoch,
+          ...(device ? {} : { csrfToken: this.csrf.issue(ctx) }) }
+        return
+      }
+      this.checkMutation(ctx, Boolean(device))
+      if (ctx.method === 'POST' && path === '/channels') {
+        const body = await this.body(ctx)
+        if (body.channel !== 'chat' && body.channel !== 'groups') throw new HttpError(400, 'Invalid channel', 'invalid_channel')
+        const c = await this.broker.create(principal, body.channel, body.channel === 'groups'
+          ? { epoch: canonicalEpoch(body.epoch), cursor: groupCursor(body.cursor) } : undefined)
+        ctx.status = 201; ctx.body = { id: c.id, brokerEpoch: this.broker.epoch }; return
+      }
+      const receipt = /^\/commands\/([A-Za-z0-9:_-]{1,200})$/.exec(path)
+      if (receipt && ctx.method === 'GET') { ctx.body = this.broker.receipt(principal, receipt[1]!); return }
+      const channel = /^\/channels\/([0-9a-f-]{36})(?:\/(commands|events))?$/.exec(path)
+      if (!channel) throw new HttpError(404, 'Realtime route not found', 'not_found')
+      const c = this.broker.get(principal, channel[1]!)
+      if (ctx.method === 'DELETE' && !channel[2]) { this.broker.detach(principal, c.id); ctx.status = 204; return }
+      if (ctx.method === 'POST' && channel[2] === 'commands') {
+        const body = await this.body(ctx, CHAT_MAX_PAYLOAD)
+        ctx.body = await this.broker.command(c, ctx.get('idempotency-key'), body)
+        return
+      }
+      if (ctx.method === 'GET' && channel[2] === 'events') { this.stream(ctx, c); return }
+      throw new HttpError(405, 'Method not allowed', 'method_not_allowed')
+    }
+  }
+  private stream(ctx: Koa.Context, c: RealtimeChannel): void {
+    const res = ctx.res
+    const initial: string[] = []
+    let started = false
+    let closed = false
+    let congested: ReturnType<typeof setTimeout> | undefined
+    let heartbeat: ReturnType<typeof setInterval> | undefined
+    let off = () => {}
+    const close = () => { if (closed) return; closed = true; clearInterval(heartbeat); clearTimeout(congested); off(); if (!res.writableEnded) res.end() }
+    const write = (text: string) => {
+      if (!started) { initial.push(text); return }
+      if (res.destroyed || res.writableEnded) return close()
+      if (res.writableLength + Buffer.byteLength(text) > 1024 * 1024) return close()
+      if (!res.write(text) && !congested) { congested = setTimeout(close, 15_000); congested.unref() }
+    }
+    off = this.broker.subscribe(c, ctx.get('last-event-id') || undefined, entry => {
+      write(`id: ${entry.id}\nevent: ${entry.event}\ndata: ${entry.data}\n\n`)
+      if (entry.event === 'reset' && started) close()
+    })
+    ctx.respond = false
+    res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' })
+    res.flushHeaders(); started = true
+    write(`event: ready\ndata: ${JSON.stringify({ channelId: c.id, brokerEpoch: this.broker.epoch })}\n\n`)
+    for (const text of initial) write(text)
+    heartbeat = setInterval(() => { if (!c.principal.valid()) close(); else write(': heartbeat\n\n') }, 15_000)
+    heartbeat.unref()
+    res.on('drain', () => { clearTimeout(congested); congested = undefined })
+    res.once('close', close)
+    res.once('error', close)
+  }
+
+  rejectLegacyUpgrades(server: Server): () => void {
+    const upgrade = (request: IncomingMessage, socket: Duplex) => {
+      const path = (request.url ?? '/').split('?')[0]!
+      if (!this.config.production && !path.startsWith('/api/') && !path.startsWith('/node/') && !path.startsWith('/ws/')) return
+      const body = JSON.stringify({ code: 'http_sse_required', error: 'Client WebSockets are no longer supported; use HTTP+SSE' })
+      socket.end('HTTP/1.1 410 Gone\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: ' + Buffer.byteLength(body) + '\r\n\r\n' + body)
+    }
+    server.on('upgrade', upgrade)
+    return () => server.off('upgrade', upgrade)
+  }
+  close(): void {
+    for (const observer of this.observers.values()) observer.disconnected()
+    this.observers.clear(); this.broker.close()
+  }
+}

+ 450 - 0
src/server/realtimeBroker.ts

@@ -0,0 +1,450 @@
+import { randomUUID } from 'node:crypto'
+import WebSocket from 'ws'
+import { HttpError } from './errors.js'
+import { checkedChatFrame, CHAT_MAX_PAYLOAD, GroupFrameValidator } from './realtimeProtocol.js'
+import { RealtimeReceipts, type CommandReceipt } from './realtimeReceipts.js'
+
+type Frame = Record<string, any>
+export interface RealtimePrincipal {
+  key: string
+  upstreamKey: string
+  instanceKey?: string
+  paired: boolean
+  valid(): boolean
+  authorize?(kind: 'chat' | 'groups', method?: string): void
+  url(channel: 'chat' | 'groups', anchor?: { epoch: string; cursor: number }): Promise<URL>
+  observeCommand?(frame: string): void
+  observeEvent?(frame: string): void
+}
+export interface StreamEntry { id: string; event: 'frame' | 'reset'; data: string; at: number; bytes: number }
+export interface RealtimeChannel {
+  id: string; kind: 'chat' | 'groups'; principal: RealtimePrincipal
+  seq: number; droppedThrough: number; entries: StreamEntry[]; routes: Set<string>
+  listeners: Set<(entry: StreamEntry) => void>; touched: number; bytes: number
+  group?: { epoch: string; cursor: number }; socket?: WebSocket
+}
+interface Route { profile: string; stored: string; runtime: string; active: boolean; seq: number; observers: Map<string, RealtimePrincipal> }
+interface Pending { resolve(frame: Frame): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout> }
+interface Upstream {
+  principal: RealtimePrincipal; socket?: WebSocket; connecting?: Promise<void>; ready?: Frame
+  pending: Map<string, Pending>; routes: Map<string, Route>; byRuntime: Map<string, Route>
+  tails: Map<string, Promise<unknown>>; early: Map<string, Frame[]>; earlyBytes: number
+  retry?: ReturnType<typeof setTimeout>; attempts: number; epoch?: string; recovering: boolean
+  lastUsed: number; ping?: ReturnType<typeof setInterval>
+  deferred: Frame[]
+  recovery?: Promise<void>
+}
+
+/** Server-owned WS transports. Downstream detach never tears down an active upstream. */
+export class RealtimeBroker {
+  readonly epoch = randomUUID()
+  readonly channels = new Map<string, RealtimeChannel>()
+  private upstreams = new Map<string, Upstream>()
+  private commands = new Map<string, Promise<CommandReceipt>>()
+  private completed = new Map<string, { receipt: CommandReceipt; at: number; bytes: number }>()
+  private receipts: RealtimeReceipts
+  private timer: ReturnType<typeof setInterval>
+  private closed = false
+  constructor(home: string, readonly now = Date.now) {
+    this.receipts = new RealtimeReceipts(home)
+    this.timer = setInterval(() => this.sweep(), 5_000)
+    this.timer.unref()
+  }
+
+  async create(principal: RealtimePrincipal, kind: 'chat' | 'groups', group?: { epoch: string; cursor: number }): Promise<RealtimeChannel> {
+    if (this.closed) throw new HttpError(503, 'Realtime broker is stopping', 'broker_stopping')
+    principal.authorize?.(kind)
+    if (!principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
+    // Closed pages leave resumable channels behind. They must not consume the live-stream
+    // quota; evict oldest detached handles under pressure, never a running upstream.
+    let own = [...this.channels.values()].filter(c => c.principal.key === principal.key)
+    while (own.length >= 32) {
+      const stale = own.filter(c => !c.listeners.size).sort((a, b) => a.touched - b.touched)[0]
+      if (!stale) break
+      this.remove(stale); own = own.filter(c => c !== stale)
+    }
+    if (own.length >= 32 || this.channels.size >= 512) {
+      throw new HttpError(429, 'Too many realtime channels', 'channel_limit')
+    }
+    const c: RealtimeChannel = { id: randomUUID(), kind, principal, seq: 0, droppedThrough: 0,
+      entries: [], routes: new Set(), listeners: new Set(), touched: this.now(), bytes: 0, group }
+    this.channels.set(c.id, c)
+    try {
+      if (kind === 'groups') {
+        if (!group) throw new HttpError(400, 'Group anchor is required', 'invalid_anchor')
+        await this.connectGroup(c)
+      } else {
+        const u = this.upstream(principal)
+        await this.connect(u)
+        this.emit(c, 'frame', u.ready!)
+      }
+      return c
+    } catch (e) { this.remove(c); throw e }
+  }
+
+  get(principal: RealtimePrincipal, id: string): RealtimeChannel {
+    const c = this.channels.get(id)
+    if (!c || c.principal.key !== principal.key) throw new HttpError(404, 'Channel not found', 'channel_not_found')
+    if (!principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
+    c.touched = this.now()
+    return c
+  }
+  detach(principal: RealtimePrincipal, id: string): void { this.remove(this.get(principal, id)) }
+  release(c: RealtimeChannel): void { if (this.channels.get(c.id) === c) this.remove(c) }
+  private remove(c: RealtimeChannel): void {
+    this.channels.delete(c.id)
+    c.socket?.close()
+    for (const listener of c.listeners) {
+      try { listener({ id: '', event: 'reset', data: '{"reason":"channel_closed"}', at: this.now(), bytes: 0 }) } catch { /* a closed consumer must not affect the upstream */ }
+    }
+    c.listeners.clear()
+  }
+  subscribe(c: RealtimeChannel, cursor: string | undefined, listener: (entry: StreamEntry) => void): () => void {
+    if ([...this.channels.values()].filter(v => v.principal.key === c.principal.key).reduce((n, v) => n + v.listeners.size, 0) >= 8) {
+      throw new HttpError(429, 'Too many active event streams', 'stream_limit')
+    }
+    this.trim(c)
+    let seq = 0
+    if (cursor) {
+      const prefix = `${this.epoch}:`
+      if (!cursor.startsWith(prefix) || !/^\d+$/.test(cursor.slice(prefix.length))) {
+        throw new HttpError(409, 'Stream epoch changed', 'reset_required')
+      }
+      seq = Number(cursor.slice(prefix.length))
+    }
+    if (!Number.isSafeInteger(seq) || seq < c.droppedThrough || seq > c.seq) {
+      throw new HttpError(409, 'Replay window expired', 'reset_required')
+    }
+    c.listeners.add(listener)
+    for (const entry of c.entries) if (Number(entry.id.split(':').at(-1)) > seq) listener(entry)
+    return () => { c.listeners.delete(listener); c.touched = this.now() }
+  }
+  receipt(p: RealtimePrincipal, id: string): CommandReceipt {
+    if (!p.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
+    const receipt = this.completed.get(`${p.key}:${id}`)?.receipt ?? this.receipts.lookup(p.key, id)
+    if (!receipt) throw new HttpError(404, 'Receipt not found; do not automatically resubmit an attempted command', 'receipt_not_found')
+    return receipt
+  }
+
+  command(c: RealtimeChannel, requestId: string, input: Frame): Promise<CommandReceipt> {
+    if (this.closed) throw new HttpError(503, 'Realtime broker is stopping', 'broker_stopping')
+    if (c.kind !== 'chat') throw new HttpError(403, 'Group event channel is read-only', 'read_only')
+    const normalized = checkedChatFrame(Buffer.from(JSON.stringify(input)), false, c.principal.paired)
+    const frame = JSON.parse(normalized) as Frame
+    c.principal.authorize?.('chat', String(frame.method))
+    delete frame.id
+    const fingerprint = JSON.stringify(frame)
+    if (!this.receipts.lookup(c.principal.key, requestId) && (this.commands.size >= 1024
+      || [...this.commands.keys()].filter(k => k.startsWith(`${c.principal.key}:`)).length >= 32)) {
+      throw new HttpError(429, 'Too many pending commands', 'command_limit')
+    }
+    const existing = this.receipts.reserve(c.principal.key, requestId, fingerprint)
+    const key = `${c.principal.key}:${requestId}`
+    if (existing) return this.commands.get(key) ?? Promise.resolve(this.completed.get(key)?.receipt ?? existing)
+    const work = this.perform(c, frame).then(response => {
+      const state = response.error ? 'rejected' : 'confirmed'
+      this.receipts.finish(c.principal.key, requestId, state, response)
+      return { requestId, state, response } as CommandReceipt
+    }, (error: unknown) => {
+      if (error instanceof HttpError) {
+        const response = { error: { code: error.code, message: error.message } }
+        this.receipts.finish(c.principal.key, requestId, 'rejected', response)
+        return { requestId, state: 'rejected', response } as CommandReceipt
+      }
+      this.receipts.finish(c.principal.key, requestId, 'unknown')
+      return { requestId, state: 'unknown' } as CommandReceipt
+    })
+    this.commands.set(key, work)
+    void work.then(receipt => {
+      this.commands.delete(key)
+      this.completed.set(key, { receipt, at: this.now(), bytes: Buffer.byteLength(JSON.stringify(receipt)) })
+      while (this.completed.size > 10_000 || [...this.completed.values()].reduce((n, x) => n + x.bytes, 0) > 32 * 1024 * 1024) this.completed.delete(this.completed.keys().next().value!)
+    })
+    return work
+  }
+
+  private async perform(c: RealtimeChannel, frame: Frame): Promise<Frame> {
+    const u = this.upstream(c.principal)
+    const p = frame.params as Frame
+    const method = String(frame.method)
+    const opening = method === 'session.create' || method === 'session.resume'
+    if (opening && (c.routes.size >= 128 || u.routes.size >= 512)) throw new HttpError(429, 'Route capacity reached', 'route_limit')
+    let route = p.session_id ? u.byRuntime.get(p.session_id) : undefined
+    if (!opening && !method.startsWith('profiles.')) {
+      if (!route && (method === 'approval.respond' || method === 'clarify.respond')) {
+        // These RPCs use request_id rather than session_id; resolve only a pending interaction seen by this channel.
+        const match = [...c.routes].map(k => u.routes.get(k)).find(r => r && this.interactions.get(`${u.principal.upstreamKey}:${p.request_id}`) === r.runtime)
+        route = match
+      }
+      if (!route || !c.routes.has(this.routeKey(route.profile, route.stored))) throw new HttpError(403, 'Session is not subscribed', 'route_forbidden')
+      if (['approval.respond', 'clarify.respond'].includes(method)
+        && this.interactions.get(`${u.principal.upstreamKey}:${p.request_id}`) !== route.runtime) {
+        throw new HttpError(403, 'Interaction does not belong to this session', 'interaction_forbidden')
+      }
+    }
+    const routeKey = opening ? this.routeKey(String(p.profile), String(p.session_id ?? c.id))
+      : route ? this.routeKey(route.profile, route.stored) : 'profiles'
+    const execute = async () => {
+      if (!c.principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
+      if (method === 'session.resume') {
+        const ownerKey = JSON.stringify([c.principal.instanceKey ?? c.principal.upstreamKey, p.profile, p.session_id])
+        const owner = this.routeOwners.get(ownerKey)
+        if (owner && owner !== c.principal.upstreamKey) throw new HttpError(409, 'Session is attached through another credential scope', 'session_scope_conflict')
+        this.routeOwners.set(ownerKey, c.principal.upstreamKey)
+      }
+      await this.connect(u)
+      await u.recovery
+      if (!c.principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
+      const previouslyActive = route?.active ?? false
+      if (route && (method === 'prompt.submit' || method === 'session.steer')) route.active = true
+      const response = await this.rpc(u, method, p, f => c.principal.observeEvent?.(JSON.stringify(f)), f => c.principal.observeCommand?.(JSON.stringify(f)))
+      if (response.error && route && (method === 'prompt.submit' || method === 'session.steer')) route.active = previouslyActive
+      if (!response.error && route && (method === 'prompt.submit' || method === 'session.steer')) route.active = true
+      if ((opening || method === 'session.branch') && response.result) {
+        const result = response.result as Frame
+        const info = result.info ?? {}
+        const runtime = String(result.session_id ?? '')
+        const stored = String(result.stored_session_id ?? result.session_key ?? info.stored_session_id ?? p.session_id ?? runtime)
+        if (!runtime || !stored) throw new Error('Invalid upstream session identity')
+        const profile = String(p.profile ?? route?.profile ?? 'default')
+        const key = this.routeKey(profile, stored)
+        this.routeOwners.set(JSON.stringify([c.principal.instanceKey ?? c.principal.upstreamKey, profile, stored]), c.principal.upstreamKey)
+        const r: Route = u.routes.get(key) ?? { profile, stored, runtime, active: false, seq: 0, observers: new Map() }
+        u.byRuntime.delete(r.runtime)
+        r.runtime = runtime
+        r.active = result.running === true || info.running === true
+        u.routes.set(key, r); u.byRuntime.set(runtime, r); c.routes.add(key)
+        r.observers.set(c.principal.key, c.principal)
+        for (const pending of [result.pending_approval, result.pending_clarify, result.inflight?.pending_approval, result.inflight?.pending_clarify]) {
+          if (pending?.request_id) this.interactions.set(`${u.principal.upstreamKey}:${pending.request_id}`, runtime)
+        }
+        const early = u.early.get(runtime) ?? []
+        u.early.delete(runtime)
+        for (const e of early) { u.earlyBytes -= Buffer.byteLength(JSON.stringify(e)); this.event(u, e) }
+      }
+      if (method === 'session.close' && route && !response.error) {
+        u.byRuntime.delete(route.runtime); u.routes.delete(this.routeKey(route.profile, route.stored))
+        this.routeOwners.delete(JSON.stringify([c.principal.instanceKey ?? c.principal.upstreamKey, route.profile, route.stored]))
+      }
+      u.lastUsed = this.now()
+      return response
+    }
+    if (['session.interrupt', 'approval.respond', 'clarify.respond', 'session.steer'].includes(method)) return execute()
+    const work = (u.tails.get(routeKey) ?? Promise.resolve()).catch(() => {}).then(execute)
+    u.tails.set(routeKey, work)
+    void work.finally(() => { if (u.tails.get(routeKey) === work) u.tails.delete(routeKey) }).catch(() => {})
+    return work
+  }
+
+  private interactions = new Map<string, string>()
+  private routeOwners = new Map<string, string>()
+  private routeKey(profile: string, stored: string): string { return JSON.stringify([profile, stored]) }
+  private upstream(p: RealtimePrincipal): Upstream {
+    let u = this.upstreams.get(p.upstreamKey)
+    if (!u) {
+      if (this.upstreams.size >= 64) throw new HttpError(429, 'Upstream capacity reached', 'upstream_limit')
+      u = { principal: p, pending: new Map(), routes: new Map(), byRuntime: new Map(), tails: new Map(),
+        early: new Map(), earlyBytes: 0, attempts: 0, recovering: false, deferred: [], lastUsed: this.now() }
+      this.upstreams.set(p.upstreamKey, u)
+    }
+    return u
+  }
+  private connect(u: Upstream): Promise<void> {
+    if (u.connecting) return u.connecting
+    if (u.socket?.readyState === WebSocket.OPEN && u.ready) return Promise.resolve()
+    u.connecting = (async () => {
+      const url = await u.principal.url('chat')
+      if (this.closed) throw new Error('Broker closed')
+      await new Promise<void>((resolve, reject) => {
+        const ws = new WebSocket(url, { headers: { Origin: `${url.protocol === 'wss:' ? 'https:' : 'http:'}//${url.host}` }, maxPayload: CHAT_MAX_PAYLOAD, handshakeTimeout: 15_000 })
+        u.socket = ws
+        const timeout = setTimeout(() => { reject(new Error('Upstream ready timeout')); ws.terminate() }, 20_000)
+        let ready = false
+        ws.on('message', (data, binary) => {
+          if (u.socket !== ws || binary) return
+          let f: Frame
+          try { f = JSON.parse(data.toString()) } catch { ws.terminate(); return }
+          if (f.method === 'event' && f.params?.type === 'gateway.ready') {
+            clearTimeout(timeout); ready = true; u.ready = f; u.attempts = 0
+            const epoch = f.params.payload?.replay_epoch
+            const changed = u.epoch !== undefined && epoch !== u.epoch
+            u.epoch = epoch
+            if (u.routes.size) u.recovery = this.recover(u, changed).finally(() => { u.recovery = undefined })
+            resolve()
+          } else if (f.id !== undefined) {
+            const pending = u.pending.get(String(f.id))
+            if (pending) { clearTimeout(pending.timer); u.pending.delete(String(f.id)); pending.resolve(f) }
+          } else if (f.method === 'event') this.event(u, f)
+        })
+        let alive = true
+        ws.on('pong', () => { alive = true })
+        u.ping = setInterval(() => { if (!alive) ws.terminate(); else if (ws.readyState === WebSocket.OPEN) { alive = false; ws.ping() } }, 25_000)
+        u.ping.unref()
+        ws.on('error', () => { if (!ready) reject(new Error('Upstream connection failed')) })
+        ws.on('close', () => {
+          clearTimeout(timeout); clearInterval(u.ping)
+          if (u.socket !== ws) return
+          u.socket = undefined; u.ready = undefined
+          if (!ready) reject(new Error('Upstream disconnected'))
+          for (const pending of u.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error('Upstream disconnected; result unknown')) }
+          u.pending.clear()
+          if (!this.closed && this.upstreams.get(u.principal.upstreamKey) === u) {
+            u.retry = setTimeout(() => { u.retry = undefined; void this.connect(u).catch(() => {}) }, Math.min(15_000, 500 * 2 ** Math.min(u.attempts++, 5)))
+            u.retry.unref()
+          }
+        })
+      })
+    })().finally(() => { u.connecting = undefined })
+    return u.connecting
+  }
+  private rpc(u: Upstream, method: string, params: Frame, observed?: (frame: Frame) => void, sent?: (frame: Frame) => void): Promise<Frame> {
+    const id = randomUUID()
+    if (u.socket?.readyState !== WebSocket.OPEN || u.socket.bufferedAmount > 8 * 1024 * 1024 || u.pending.size >= 128) throw new Error('Upstream unavailable or congested')
+    const frame = { jsonrpc: '2.0', id, method, params }
+    sent?.(frame)
+    return new Promise((resolve, reject) => {
+      const timer = setTimeout(() => { u.pending.delete(id); reject(new Error('Upstream command result unknown')) }, 120_000)
+      timer.unref()
+      u.pending.set(id, { timer, reject, resolve: response => { observed?.(response); resolve(response) } })
+      u.socket!.send(JSON.stringify(frame), error => { if (error) { clearTimeout(timer); u.pending.delete(id); reject(error) } })
+    })
+  }
+  private event(u: Upstream, f: Frame, replay = false): void {
+    if (this.closed) return
+    if (u.recovering && !replay) {
+      if (u.deferred.length >= 512 || Buffer.byteLength(JSON.stringify([...u.deferred, f])) > 1024 * 1024) { u.deferred = []; this.reset(u, 'recovery_overflow') }
+      else u.deferred.push(f)
+      return
+    }
+    const p = f.params
+    if (!p || typeof p.type !== 'string') return
+    const sid = String(p.session_id ?? '')
+    const r = u.byRuntime.get(sid)
+    if (sid && !r) {
+      const size = Buffer.byteLength(JSON.stringify(f))
+      if (u.earlyBytes + size > 1024 * 1024) { u.early.clear(); u.earlyBytes = 0; this.reset(u, 'unmapped_event_overflow'); return }
+      const list = u.early.get(sid) ?? []; list.push(f); u.early.set(sid, list); u.earlyBytes += size
+      return
+    }
+    if (r) {
+      if (Number.isSafeInteger(p.seq)) {
+        if (p.seq <= r.seq) return
+        if (r.seq > 0 && p.seq > r.seq + 1) this.reset(u, 'upstream_sequence_gap')
+        r.seq = p.seq
+      }
+      if (['message.start', 'approval.request', 'clarify.request'].includes(p.type)) r.active = true
+      if (p.type === 'message.complete') r.active = false
+      if (p.payload?.request_id) this.interactions.set(`${u.principal.upstreamKey}:${p.payload.request_id}`, sid)
+      while (this.interactions.size > 10_000) this.interactions.delete(this.interactions.keys().next().value!)
+    }
+    if (r) for (const principal of r.observers.values()) {
+      if (principal.valid()) principal.observeEvent?.(JSON.stringify(f))
+    }
+    for (const c of this.channels.values()) {
+      if (c.kind !== 'chat' || c.principal.upstreamKey !== u.principal.upstreamKey || !c.principal.valid()) continue
+      if (r && !c.routes.has(this.routeKey(r.profile, r.stored))) continue
+      if (!r && !['profiles.changed', 'sessions.changed', 'models.changed', 'pet.changed'].includes(p.type)) continue
+      this.emit(c, 'frame', f)
+    }
+  }
+  private async recover(u: Upstream, changed: boolean): Promise<void> {
+    if (u.recovering) return
+    u.recovering = true
+    try {
+      for (const r of u.routes.values()) {
+        const lastSeen = r.seq
+        const resumed = await this.rpc(u, 'session.resume', { session_id: r.stored, profile: r.profile, close_on_disconnect: false, omit_messages: true })
+        if (typeof resumed.result?.running === 'boolean') r.active = resumed.result.running
+        if (!resumed.result || resumed.result.session_id !== r.runtime || changed) {
+          if (resumed.result?.session_id) { u.byRuntime.delete(r.runtime); r.runtime = resumed.result.session_id; u.byRuntime.set(r.runtime, r) }
+          r.seq = 0; this.reset(u, 'upstream_reset'); continue
+        }
+        const replay = await this.rpc(u, 'session.events.since', { session_id: r.runtime, last_seen: lastSeen })
+        if (replay.error || replay.result?.truncated || replay.result?.epoch !== u.epoch) this.reset(u, 'upstream_replay_unavailable')
+        else for (const p of replay.result?.events ?? []) this.event(u, { jsonrpc: '2.0', method: 'event', params: p }, true)
+      }
+    } catch { this.reset(u, 'upstream_recovery_failed') }
+    finally {
+      u.recovering = false
+      const deferred = u.deferred; u.deferred = []
+      for (const frame of deferred) this.event(u, frame)
+    }
+  }
+  private reset(u: Upstream, reason: string): void {
+    for (const c of this.channels.values()) if (c.kind === 'chat' && c.principal.upstreamKey === u.principal.upstreamKey) this.emit(c, 'reset', { reason })
+  }
+  private async connectGroup(c: RealtimeChannel): Promise<void> {
+    const url = await c.principal.url('groups', c.group)
+    const validator = new GroupFrameValidator(c.group!.epoch, c.group!.cursor)
+    await new Promise<void>((resolve, reject) => {
+      const ws = new WebSocket(url, { headers: { Origin: `${url.protocol === 'wss:' ? 'https:' : 'http:'}//${url.host}` }, maxPayload: 2 * 1024 * 1024, handshakeTimeout: 15_000 })
+      c.socket = ws
+      const timer = setTimeout(() => { ws.terminate(); reject(new Error('Group ready timeout')) }, 20_000)
+      ws.on('message', (data, binary) => {
+        try {
+          if (!validator.accept(data, binary)) return
+          const frame = JSON.parse(data.toString()) as Frame
+          if (frame.type === 'group.ready') { clearTimeout(timer); resolve() }
+          if (frame.type === 'group.event') c.group!.cursor = frame.cursor
+          this.emit(c, 'frame', frame)
+        } catch { this.emit(c, 'reset', { reason: 'group_gap' }); ws.close() }
+      })
+      ws.on('error', () => { clearTimeout(timer); reject(new Error('Group connection failed')) })
+      ws.on('close', () => { clearTimeout(timer); reject(new Error('Group disconnected')); if (this.channels.has(c.id)) this.emit(c, 'reset', { reason: 'group_disconnected' }) })
+    })
+  }
+  private emit(c: RealtimeChannel, event: StreamEntry['event'], value: Frame): void {
+    const data = JSON.stringify(value)
+    const entry: StreamEntry = { id: `${this.epoch}:${++c.seq}`, event, data, at: this.now(), bytes: Buffer.byteLength(data) }
+    c.entries.push(entry); c.bytes += entry.bytes
+    this.trim(c)
+    for (const listener of [...c.listeners]) {
+      try { listener(entry) } catch { c.listeners.delete(listener) }
+    }
+  }
+  private trim(c: RealtimeChannel): void {
+    const own = [...this.channels.values()].filter(v => v.principal.key === c.principal.key)
+    while (own.reduce((n, v) => n + v.bytes, 0) > 8 * 1024 * 1024 || own.reduce((n, v) => n + v.entries.length, 0) > 10_000) {
+      const oldest = own.filter(v => v.entries.length).sort((a, b) => a.entries[0]!.at - b.entries[0]!.at)[0]
+      if (!oldest) break
+      this.evict(oldest)
+    }
+    while (c.entries[0] && c.entries[0].at < this.now() - 600_000) this.evict(c)
+    while ([...this.channels.values()].reduce((n, v) => n + v.bytes, 0) > 128 * 1024 * 1024) {
+      const oldest = [...this.channels.values()].filter(v => v.entries.length).sort((a, b) => a.entries[0]!.at - b.entries[0]!.at)[0]
+      if (!oldest) break
+      this.evict(oldest)
+    }
+  }
+  private evict(c: RealtimeChannel): void {
+    const entry = c.entries.shift()!
+    c.bytes -= entry.bytes; c.droppedThrough = Number(entry.id.split(':').at(-1))
+  }
+  private sweep(): void {
+    for (const [key, item] of this.completed) if (item.at < this.now() - 600_000) this.completed.delete(key)
+    for (const c of this.channels.values()) {
+      if (!c.principal.valid() || (!c.listeners.size && this.now() - c.touched > 600_000)) this.remove(c)
+      else this.trim(c)
+    }
+    for (const [key, u] of this.upstreams) {
+      if (u.pending.size || u.tails.size || [...u.routes.values()].some(r => r.active)) { u.lastUsed = this.now(); continue }
+      if ([...this.channels.values()].some(c => c.principal.upstreamKey === key)) { u.lastUsed = this.now(); continue }
+      if (this.now() - u.lastUsed > 300_000) {
+        this.upstreams.delete(key); clearTimeout(u.retry); clearInterval(u.ping); u.socket?.close()
+        for (const [route, owner] of this.routeOwners) if (owner === key) this.routeOwners.delete(route)
+      }
+    }
+  }
+  close(): void {
+    if (this.closed) return
+    this.closed = true; clearInterval(this.timer)
+    for (const c of [...this.channels.values()]) this.remove(c)
+    for (const u of this.upstreams.values()) {
+      clearTimeout(u.retry); clearInterval(u.ping)
+      for (const p of u.pending.values()) { clearTimeout(p.timer); p.reject(new Error('Broker stopped')) }
+      u.pending.clear(); u.socket?.terminate()
+    }
+    // Pending command continuations may still update their receipts in the next microtask.
+    void Promise.allSettled(this.commands.values()).then(() => this.receipts.close())
+  }
+}

+ 348 - 0
src/server/realtimeProtocol.ts

@@ -0,0 +1,348 @@
+import type { RawData } from 'ws'
+import { HttpError } from './errors.js'
+
+export const CHAT_MAX_PAYLOAD = 36 * 1_024 * 1_024
+const CHAT_METHODS = new Set([
+  'approval.respond',
+  'clarify.respond',
+  'config.set',
+  'file.attach',
+  'image.attach_bytes',
+  'pdf.attach',
+  'profiles.configure',
+  'profiles.get_asset',
+  'profiles.list',
+  'profiles.set_asset',
+  'prompt.submit',
+  'session.branch',
+  'session.close',
+  'session.context_breakdown',
+  'session.create',
+  'session.interrupt',
+  'session.resume',
+  'session.steer',
+  'session.usage',
+])
+const SESSION_ID_REQUIRED_METHODS = new Set([
+  'config.set',
+  'file.attach',
+  'image.attach_bytes',
+  'pdf.attach',
+  'prompt.submit',
+  'session.branch',
+  'session.close',
+  'session.context_breakdown',
+  'session.interrupt',
+  'session.resume',
+  'session.steer',
+  'session.usage',
+])
+
+function safeString(value: unknown, label: string, required = false): string | undefined {
+  if (value === undefined && !required) return undefined
+  if (typeof value !== 'string') throw new HttpError(400, `${label} must be a string`)
+  const normalized = value.trim()
+  if ((!normalized && required) || normalized.length > 256 || /[\u0000-\u001f\u007f]/.test(normalized)) {
+    throw new HttpError(400, `${label} is invalid`)
+  }
+  return normalized
+}
+
+function normalizedConfigParams(params: Record<string, unknown>): Record<string, unknown> {
+  const sessionID = safeString(params.session_id, 'session_id', true)!
+  const key = safeString(params.key, 'config key', true)
+  if (key === 'model') {
+    const value = typeof params.value === 'string' ? params.value.trim() : ''
+    if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,511} --provider [A-Za-z0-9][A-Za-z0-9._:-]{0,127}( --session)?$/.test(value)) {
+      throw new HttpError(400, 'Model selection is invalid')
+    }
+    return {
+      session_id: sessionID,
+      key,
+      value,
+      ...(params.confirm_expensive_model === true ? { confirm_expensive_model: true } : {}),
+    }
+  }
+  if (key === 'reasoning') {
+    const value = safeString(params.value, 'reasoning value', true)!
+    if (!new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']).has(value)) {
+      throw new HttpError(400, 'Reasoning value is invalid')
+    }
+    return { session_id: sessionID, key, value, scope: 'session' }
+  }
+  if (key === 'fast') {
+    const value = safeString(params.value, 'fast value', true)!
+    if (value !== 'fast' && value !== 'normal') throw new HttpError(400, 'Fast value is invalid')
+    return { session_id: sessionID, key, value, scope: 'session' }
+  }
+  throw new HttpError(403, 'Config key is not allowed')
+}
+
+function normalizedSessionOpenParams(
+  method: 'session.create' | 'session.resume',
+  params: Record<string, unknown>,
+): Record<string, unknown> {
+  const profile = safeString(params.profile, 'profile', true)!
+  const columns = typeof params.cols === 'number' && Number.isInteger(params.cols)
+    ? Math.min(500, Math.max(20, params.cols))
+    : 80
+  if (method === 'session.resume') {
+    return {
+      session_id: safeString(params.session_id, 'session_id', true)!,
+      profile,
+      source: 'web',
+      close_on_disconnect: false,
+      omit_messages: params.omit_messages === true,
+      cols: columns,
+    }
+  }
+  const title = safeString(params.title, 'title')
+  const reasoning = safeString(params.reasoning_effort, 'reasoning effort')
+  const allowedReasoning = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
+  if (reasoning && !allowedReasoning.has(reasoning)) throw new HttpError(400, 'Reasoning effort is invalid')
+  return {
+    profile,
+    source: 'web',
+    close_on_disconnect: false,
+    cols: columns,
+    ...(title ? { title } : {}),
+    ...(reasoning ? { reasoning_effort: reasoning } : {}),
+    ...(typeof params.fast === 'boolean' ? { fast: params.fast } : {}),
+  }
+}
+
+function normalizedPairedSessionOpenParams(
+  method: 'session.create' | 'session.resume',
+  params: Record<string, unknown>,
+): Record<string, unknown> {
+  const profile = safeString(params.profile, 'profile', true)!
+  const source = safeString(params.source, 'source') ?? 'mobile'
+  const columns = typeof params.cols === 'number' && Number.isInteger(params.cols)
+    ? Math.min(500, Math.max(20, params.cols))
+    : 80
+  if (method === 'session.resume') {
+    return {
+      session_id: safeString(params.session_id, 'session_id', true)!,
+      profile,
+      source,
+      close_on_disconnect: false,
+      omit_messages: params.omit_messages === true,
+      cols: columns,
+    }
+  }
+  const title = safeString(params.title, 'title')
+  const cwd = typeof params.cwd === 'string' && params.cwd.length <= 4_096 ? params.cwd : ''
+  const reasoning = safeString(params.reasoning_effort, 'reasoning effort')
+  const allowedReasoning = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'])
+  if (reasoning && !allowedReasoning.has(reasoning)) throw new HttpError(400, 'Reasoning effort is invalid')
+  const model = safeString(params.model, 'model')
+  const provider = safeString(params.provider, 'provider')
+  if ((model === undefined) !== (provider === undefined)) {
+    throw new HttpError(400, 'Model and provider must be set together')
+  }
+  const messages = params.messages
+  if (messages !== undefined && (!Array.isArray(messages) || messages.length > 256)) {
+    throw new HttpError(400, 'Seed messages are invalid')
+  }
+  return {
+    profile,
+    source,
+    close_on_disconnect: false,
+    cols: columns,
+    cwd,
+    hidden: params.hidden === true,
+    ...(title ? { title } : {}),
+    ...(reasoning ? { reasoning_effort: reasoning } : {}),
+    ...(model && provider ? { model, provider } : {}),
+    ...(typeof params.fast === 'boolean' ? { fast: params.fast } : {}),
+    ...(messages !== undefined ? { messages } : {}),
+  }
+}
+
+export function checkedChatFrame(data: RawData, isBinary: boolean, paired = false): string {
+  if (isBinary) throw new HttpError(400, 'Binary chat frames are not accepted')
+  const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer)
+  if (bytes.byteLength > CHAT_MAX_PAYLOAD) throw new HttpError(413, 'Chat frame is too large')
+  let value: unknown
+  try {
+    value = JSON.parse(bytes.toString('utf8'))
+  } catch {
+    throw new HttpError(400, 'Chat frame must be JSON')
+  }
+  if (!value || typeof value !== 'object' || Array.isArray(value)) {
+    throw new HttpError(400, 'Chat frame must be a JSON-RPC object')
+  }
+  const request = value as Record<string, unknown>
+  if (typeof request.method !== 'string' || !CHAT_METHODS.has(request.method)) {
+    throw new HttpError(403, 'Chat RPC method is not allowed')
+  }
+  if (request.id !== undefined && typeof request.id !== 'string' && typeof request.id !== 'number') {
+    throw new HttpError(400, 'Chat RPC id is invalid')
+  }
+  if (!request.params || typeof request.params !== 'object' || Array.isArray(request.params)) {
+    throw new HttpError(400, 'Chat RPC params must be an object')
+  }
+  let params = { ...(request.params as Record<string, unknown>) }
+  safeString(params.session_id, 'session_id', SESSION_ID_REQUIRED_METHODS.has(request.method))
+  const profileRequired = request.method === 'session.create' || request.method === 'session.resume'
+  safeString(params.profile, 'profile', profileRequired)
+  if (request.method === 'session.create' || request.method === 'session.resume') {
+    params = paired
+      ? normalizedPairedSessionOpenParams(request.method, params)
+      : normalizedSessionOpenParams(request.method, params)
+  }
+  if (request.method === 'profiles.list') {
+    const preferred = params.preferred_session_ids
+    if (preferred !== undefined && (!preferred || typeof preferred !== 'object' || Array.isArray(preferred))) {
+      throw new HttpError(400, 'preferred_session_ids must be an object')
+    }
+    params = {
+      ...(typeof params.include_sessions === 'boolean' ? { include_sessions: params.include_sessions } : {}),
+      ...(preferred !== undefined ? { preferred_session_ids: preferred } : {}),
+    }
+  }
+  if (request.method === 'profiles.get_asset') {
+    params = {
+      name: safeString(params.name, 'profile name', true)!,
+      asset: safeString(params.asset, 'profile asset', true)!,
+    }
+  }
+  if (request.method === 'profiles.set_asset') {
+    const clear = params.clear === true
+    const data = typeof params.data === 'string' ? params.data.trim() : ''
+    if (!clear && !data) throw new HttpError(400, 'profile asset data is required')
+    if (data.length > 2_800_000) throw new HttpError(413, 'profile asset exceeds 2 MiB')
+    params = {
+      name: safeString(params.name, 'profile name', true)!,
+      asset: safeString(params.asset, 'profile asset', true)!,
+      ...(clear ? { clear: true } : { data }),
+    }
+  }
+  if (request.method === 'profiles.configure') {
+    if (!params.ui_meta || typeof params.ui_meta !== 'object' || Array.isArray(params.ui_meta)) {
+      throw new HttpError(400, 'ui_meta must be an object')
+    }
+    if (params.ui_meta_expected_revisions !== undefined
+      && (!params.ui_meta_expected_revisions
+        || typeof params.ui_meta_expected_revisions !== 'object'
+        || Array.isArray(params.ui_meta_expected_revisions))) {
+      throw new HttpError(400, 'ui_meta_expected_revisions must be an object')
+    }
+    params = {
+      name: safeString(params.name, 'profile name', true)!,
+      ui_meta: params.ui_meta,
+      ...(params.ui_meta_expected_revisions !== undefined
+        ? { ui_meta_expected_revisions: params.ui_meta_expected_revisions }
+        : {}),
+    }
+  }
+  if (request.method === 'config.set') params = normalizedConfigParams(params)
+  if (request.method === 'prompt.submit' || request.method === 'session.steer') {
+    const text = typeof params.text === 'string' ? params.text.trim() : ''
+    if (!text || text.length > 200_000) throw new HttpError(400, 'Prompt text is invalid')
+    params = {
+      session_id: safeString(params.session_id, 'session_id', true)!,
+      text,
+      ...(request.method === 'prompt.submit' && params.queued === true ? { queued: true } : {}),
+    }
+  }
+
+  if (request.method === 'image.attach_bytes' || request.method === 'pdf.attach') {
+    const encoded = typeof params.content_base64 === 'string' ? params.content_base64 : ''
+    const estimated = Math.floor(encoded.length * 0.75)
+    if (!encoded || estimated > 25 * 1_024 * 1_024) {
+      throw new HttpError(413, 'Attachment exceeds 25 MiB')
+    }
+  }
+  if (request.method === 'file.attach') {
+    const dataURL = typeof params.data_url === 'string' ? params.data_url : ''
+    const marker = dataURL.indexOf(',')
+    if (marker < 0 || Math.floor((dataURL.length - marker - 1) * 0.75) > 25 * 1_024 * 1_024) {
+      throw new HttpError(413, 'Attachment exceeds 25 MiB')
+    }
+  }
+  return JSON.stringify({ ...request, params })
+}
+
+export class GroupFrameValidator {
+  #ready = false
+  #cursor: number
+
+  constructor(readonly epoch: string, cursor: number) {
+    this.#cursor = cursor
+  }
+
+  accept(data: RawData, isBinary: boolean): boolean {
+    if (isBinary) throw new HttpError(400, 'Binary group frames are not accepted')
+    let envelope: Record<string, unknown>
+    try {
+      const value = JSON.parse(Buffer.from(data as Uint8Array).toString('utf8')) as unknown
+      if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not an object')
+      envelope = value as Record<string, unknown>
+    } catch {
+      throw new HttpError(400, 'Malformed group event frame')
+    }
+    const type = envelope.type
+    const cursor = Number(envelope.cursor)
+    if (type === 'group.ready') {
+      const heartbeat = Number(envelope.heartbeatSeconds)
+      if (this.#ready || envelope.epoch !== this.epoch || !Number.isSafeInteger(cursor)
+        || cursor !== this.#cursor || !Number.isFinite(heartbeat) || heartbeat <= 0) {
+        throw new HttpError(409, 'Invalid group.ready frame')
+      }
+      this.#ready = true
+      return true
+    }
+    if (type === 'group.reset_required') {
+      if (typeof envelope.epoch !== 'string' || !Number.isSafeInteger(cursor)
+        || typeof envelope.reason !== 'string') {
+        throw new HttpError(400, 'Invalid group.reset_required frame')
+      }
+      return true
+    }
+    if (!this.#ready) throw new HttpError(409, 'Group event arrived before group.ready')
+    if (type === 'group.event') {
+      if (envelope.epoch !== this.epoch || !Number.isSafeInteger(cursor) || cursor < 0) {
+        throw new HttpError(409, 'Group event epoch or cursor is invalid')
+      }
+      if (cursor <= this.#cursor) return false
+      if (cursor !== this.#cursor + 1) throw new HttpError(409, 'Group event cursor gap')
+      this.#cursor = cursor
+      return true
+    }
+    if (type === 'group.heartbeat') {
+      if ((envelope.epoch !== undefined && envelope.epoch !== this.epoch)
+        || !Number.isSafeInteger(cursor) || cursor !== this.#cursor) {
+        throw new HttpError(409, 'Group heartbeat cursor mismatch')
+      }
+      return true
+    }
+    throw new HttpError(400, 'Unknown group event frame')
+  }
+
+  resetFrame(reason: string): string {
+    return JSON.stringify({
+      type: 'group.reset_required',
+      epoch: this.epoch,
+      cursor: this.#cursor,
+      reason,
+    })
+  }
+}
+
+export function canonicalEpoch(value: unknown): string {
+  if (typeof value !== 'string'
+    || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)) {
+    throw new HttpError(400, 'epoch must be a canonical lowercase UUID', 'invalid_epoch')
+  }
+  return value
+}
+
+export function groupCursor(value: unknown): number {
+  const cursor = typeof value === 'number' ? value : Number(value ?? 0)
+  if (!Number.isSafeInteger(cursor) || cursor < 0) {
+    throw new HttpError(400, 'cursor must be a non-negative integer', 'invalid_cursor')
+  }
+  return cursor
+}
+

+ 63 - 0
src/server/realtimeReceipts.ts

@@ -0,0 +1,63 @@
+import { createHash } from 'node:crypto'
+import { chmodSync, mkdirSync } from 'node:fs'
+import { join } from 'node:path'
+import { DatabaseSync } from 'node:sqlite'
+import { HttpError } from './errors.js'
+
+export interface CommandReceipt {
+  requestId: string
+  state: 'pending' | 'confirmed' | 'rejected' | 'unknown'
+  response?: Record<string, unknown>
+}
+
+/** Write-ahead admission, not an event/transcript store. Never stores command bodies. */
+export class RealtimeReceipts {
+  private db: DatabaseSync
+  constructor(home: string) {
+    mkdirSync(home, { recursive: true, mode: 0o700 })
+    const path = join(home, 'realtime-receipts.sqlite3')
+    this.db = new DatabaseSync(path)
+    chmodSync(path, 0o600)
+    this.db.exec(`PRAGMA journal_mode=WAL;
+      CREATE TABLE IF NOT EXISTS receipts (
+        owner TEXT NOT NULL, id TEXT NOT NULL, fingerprint TEXT NOT NULL,
+        state TEXT NOT NULL, response TEXT, updated INTEGER NOT NULL,
+        PRIMARY KEY(owner,id));
+      CREATE INDEX IF NOT EXISTS receipt_retention ON receipts(state,updated);
+      UPDATE receipts SET state='unknown' WHERE state='pending';`)
+  }
+  lookup(owner: string, id: string): CommandReceipt | undefined {
+    const row = this.db.prepare('SELECT state,response FROM receipts WHERE owner=? AND id=?').get(owner, id)
+    if (!row) return undefined
+    return { requestId: id, state: row.state as CommandReceipt['state'],
+      ...(row.response ? { response: JSON.parse(String(row.response)) as Record<string, unknown> } : {}) }
+  }
+  reserve(owner: string, id: string, frame: string): CommandReceipt | undefined {
+    if (!/^[A-Za-z0-9:_-]{1,200}$/.test(id)) throw new HttpError(400, 'Invalid request ID', 'invalid_request_id')
+    const fingerprint = createHash('sha256').update(frame).digest('hex')
+    const row = this.db.prepare('SELECT fingerprint FROM receipts WHERE owner=? AND id=?').get(owner, id)
+    if (row) {
+      if (row.fingerprint !== fingerprint) throw new HttpError(409, 'Request ID payload conflict', 'idempotency_conflict')
+      return this.lookup(owner, id)
+    }
+    this.db.prepare("DELETE FROM receipts WHERE updated<? AND state IN ('confirmed','rejected')").run(Date.now() - 86_400_000)
+    if (Number(this.db.prepare('SELECT count(*) AS n FROM receipts').get()!.n) >= 100_000) {
+      throw new HttpError(429, 'Command receipt capacity reached', 'receipt_capacity')
+    }
+    this.db.prepare('INSERT INTO receipts VALUES(?,?,?,?,NULL,?)').run(owner, id, fingerprint, 'pending', Date.now())
+    return undefined
+  }
+  finish(owner: string, id: string, state: CommandReceipt['state'], response?: Record<string, unknown>): void {
+    // Persist only control-plane acknowledgements, never prompt, attachment or history content.
+    const result = response?.result as Record<string, unknown> | undefined
+    const safe: Record<string, unknown> = {}
+    for (const key of ['status', 'session_id', 'stored_session_id', 'session_key', 'queued', 'running', 'ok']) {
+      if (result && ['string', 'number', 'boolean'].includes(typeof result[key])) safe[key] = result[key]
+    }
+    const saved = response?.error ? { error: { message: 'Upstream rejected command' } }
+      : response ? { result: safe } : undefined
+    this.db.prepare('UPDATE receipts SET state=?,response=?,updated=? WHERE owner=? AND id=?')
+      .run(state, saved ? JSON.stringify(saved) : null, Date.now(), owner, id)
+  }
+  close(): void { this.db.close() }
+}

+ 1 - 67
src/server/routes.ts

@@ -8,7 +8,6 @@ import { isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL
 import type { ServerConfig } from './config.js'
 import { DEFAULT_YAOYAO_PLUGIN_SOURCE, isLoopbackHost, isLoopbackUpstream, isPrivateHost } from './config.js'
 import { HttpError } from './errors.js'
-import { canonicalEpoch, groupCursor, type RealtimeChannel, RealtimeLeaseStore } from './leases.js'
 import { compareReleaseVersions } from './releases.js'
 import {
   bearerToken,
@@ -17,7 +16,7 @@ import {
   type NodeScope,
   NodePairingStore,
 } from './pairing.js'
-import { acceptedRequestOrigin, accountKeyFromCookieHeader, CsrfProtection, requestAccountKey } from './security.js'
+import { CsrfProtection, requestAccountKey } from './security.js'
 import {
   applyUpstreamCookies,
   CookieJar,
@@ -52,7 +51,6 @@ export interface RouteDependencies {
   config: ServerConfig
   csrf: CsrfProtection
   upstream: UpstreamClient
-  leases: RealtimeLeaseStore
   pairings: NodePairingStore
   uploads: UploadStore
   updates: SystemUpdateManager
@@ -633,7 +631,6 @@ function pairedProxyPath(rawPath: string): string {
   }
   const allowed = path === '/status'
     || path === '/auth/me'
-    || path === '/auth/ws-ticket'
     || path === '/profiles'
     || path.startsWith('/profiles/')
     || path === '/sessions'
@@ -1065,66 +1062,6 @@ async function requireGatewayAuthentication(
   requireSuccess(identity)
 }
 
-async function issueLease(
-  ctx: Koa.Context,
-  dependencies: RouteDependencies,
-  jar: CookieJar,
-): Promise<void> {
-  const request = body(ctx)
-  const channel = request.channel
-  if (channel !== 'chat' && channel !== 'groups') {
-    throw new HttpError(400, 'channel must be chat or groups', 'invalid_channel')
-  }
-  const status = requireSuccess(await dependencies.upstream.request('/api/status', jar))
-  if (channel === 'groups') {
-    const capabilities = requireSuccess(await dependencies.upstream.request(
-      '/api/plugins/yaoyao/v1/capabilities',
-      jar,
-    ))
-    const protocolVersion = Number(capabilities.protocolVersion ?? capabilities.protocol_version)
-    if (!isSupportedGroupProtocolVersion(protocolVersion)) {
-      throw new HttpError(409, `Hermes group chat protocol ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL} is required`, 'unsupported_group_protocol')
-    }
-  }
-  let credential: { name: 'ticket' | 'token'; value: string }
-  if (authRequired(status)) {
-    const ticketResponse = requireSuccess(await dependencies.upstream.request('/api/auth/ws-ticket', jar, {
-      method: 'POST',
-    }))
-    if (typeof ticketResponse.ticket !== 'string' || !ticketResponse.ticket.trim()) {
-      throw new HttpError(502, 'Hermes returned an empty WebSocket ticket', 'invalid_ticket')
-    }
-    credential = { name: 'ticket', value: ticketResponse.ticket.trim() }
-  } else {
-    const root = await dependencies.upstream.request('/', jar, { maxResponseBytes: 2 * 1_024 * 1_024 })
-    const match = root.body.toString('utf8').match(/window\.__HERMES_SESSION_TOKEN__="([^"]+)"/)
-    if (!match?.[1]) throw new HttpError(401, 'Hermes did not provide a local session token', 'missing_session_token')
-    credential = { name: 'token', value: match[1] }
-  }
-
-  const origin = acceptedRequestOrigin(
-    ctx.get('origin'),
-    ctx.get('host'),
-    ctx.secure || Boolean(dependencies.config.tlsCert),
-    dependencies.config.allowedHosts,
-  )
-  if (!origin) throw new HttpError(400, 'Invalid request host', 'invalid_host')
-  const accountKeys = new Set([
-    requestAccountKey(ctx.req),
-    accountKeyFromCookieHeader(jar.header, ctx.req.socket.remoteAddress),
-  ])
-  const lease = dependencies.leases.issue({
-    channel: channel as RealtimeChannel,
-    credential,
-    origin,
-    accountKeys,
-    localUserID: (ctx.state.localUser as LocalUser | undefined)?.id,
-    epoch: channel === 'groups' ? canonicalEpoch(request.epoch) : undefined,
-    cursor: channel === 'groups' ? groupCursor(request.cursor) : undefined,
-  })
-  json(ctx, 201, { lease: lease.id, channel: lease.channel, expiresAt: lease.expiresAt })
-}
-
 async function searchSessions(ctx: Koa.Context, dependencies: RouteDependencies): Promise<void> {
   await withJar(ctx, async (jar) => {
     const query = searchFrom(ctx, ['q', 'limit', 'source', 'profile'])
@@ -2170,9 +2107,6 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       })
     })
   })
-  router.post('/api/app/realtime-leases', async (ctx) => {
-    await withJar(ctx, (jar) => issueLease(ctx, dependencies, jar))
-  })
 
   router.get('/api/app/profiles', async (ctx) => {
     await withJar(ctx, async (jar) => {

+ 0 - 806
src/server/websocket.ts

@@ -1,806 +0,0 @@
-import type { Server as HttpServer } from 'node:http'
-import type { Duplex } from 'node:stream'
-import WebSocket, { WebSocketServer, type RawData } from 'ws'
-import type { ServerConfig } from './config.js'
-import { HttpError } from './errors.js'
-import type { RealtimeChannel, RealtimeLeaseStore } from './leases.js'
-import type { NodePairingStore } from './pairing.js'
-import {
-  ChatPushRelayObserver,
-  type ChatNotificationResolver,
-  type PushEventCoordinator,
-} from './pushEvents.js'
-import { isAllowedHostHeader, isExactOrigin, requestAccountKey } from './security.js'
-
-type UpgradeRequest = Parameters<NonNullable<HttpServer['on']>>[1] extends never ? never : any
-
-export interface WebSocketPushDependencies {
-  coordinator: PushEventCoordinator
-  resolveGatewayUser?(request: UpgradeRequest): string | undefined
-  notificationResolver?: ChatNotificationResolver
-}
-
-const CHAT_MAX_PAYLOAD = 36 * 1_024 * 1_024
-const GROUP_MAX_PAYLOAD = 2 * 1_024 * 1_024
-const MAX_BUFFERED_BYTES = 8 * 1_024 * 1_024
-const CHAT_METHODS = new Set([
-  'approval.respond',
-  'clarify.respond',
-  'config.set',
-  'file.attach',
-  'image.attach_bytes',
-  'pdf.attach',
-  'profiles.configure',
-  'profiles.get_asset',
-  'profiles.list',
-  'profiles.set_asset',
-  'prompt.submit',
-  'session.branch',
-  'session.close',
-  'session.context_breakdown',
-  'session.create',
-  'session.interrupt',
-  'session.resume',
-  'session.steer',
-  'session.usage',
-])
-const SESSION_ID_REQUIRED_METHODS = new Set([
-  'config.set',
-  'file.attach',
-  'image.attach_bytes',
-  'pdf.attach',
-  'prompt.submit',
-  'session.branch',
-  'session.close',
-  'session.context_breakdown',
-  'session.interrupt',
-  'session.resume',
-  'session.steer',
-  'session.usage',
-])
-
-function rejectUpgrade(socket: Duplex, status: number, message: string): void {
-  if (socket.destroyed) return
-  const safe = message.replace(/[\r\n]/g, ' ').slice(0, 120)
-  socket.end(
-    `HTTP/1.1 ${status} ${status === 401 ? 'Unauthorized' : 'Bad Request'}\r\n`
-      + 'Connection: close\r\n'
-      + 'Content-Type: text/plain; charset=utf-8\r\n'
-      + `Content-Length: ${Buffer.byteLength(safe)}\r\n\r\n${safe}`,
-  )
-}
-
-function safeCloseCode(code: number): number {
-  if ((code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code))
-    || (code >= 3000 && code <= 4999)) return code
-  return 1011
-}
-
-function safeCloseReason(value: string): string {
-  let reason = value
-  while (Buffer.byteLength(reason, 'utf8') > 123) reason = reason.slice(0, -1)
-  return reason
-}
-
-function safeString(value: unknown, label: string, required = false): string | undefined {
-  if (value === undefined && !required) return undefined
-  if (typeof value !== 'string') throw new HttpError(400, `${label} must be a string`)
-  const normalized = value.trim()
-  if ((!normalized && required) || normalized.length > 256 || /[\u0000-\u001f\u007f]/.test(normalized)) {
-    throw new HttpError(400, `${label} is invalid`)
-  }
-  return normalized
-}
-
-function normalizedConfigParams(params: Record<string, unknown>): Record<string, unknown> {
-  const sessionID = safeString(params.session_id, 'session_id', true)!
-  const key = safeString(params.key, 'config key', true)
-  if (key === 'model') {
-    const value = typeof params.value === 'string' ? params.value.trim() : ''
-    if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,511} --provider [A-Za-z0-9][A-Za-z0-9._:-]{0,127}( --session)?$/.test(value)) {
-      throw new HttpError(400, 'Model selection is invalid')
-    }
-    return {
-      session_id: sessionID,
-      key,
-      value,
-      ...(params.confirm_expensive_model === true ? { confirm_expensive_model: true } : {}),
-    }
-  }
-  if (key === 'reasoning') {
-    const value = safeString(params.value, 'reasoning value', true)!
-    if (!new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']).has(value)) {
-      throw new HttpError(400, 'Reasoning value is invalid')
-    }
-    return { session_id: sessionID, key, value, scope: 'session' }
-  }
-  if (key === 'fast') {
-    const value = safeString(params.value, 'fast value', true)!
-    if (value !== 'fast' && value !== 'normal') throw new HttpError(400, 'Fast value is invalid')
-    return { session_id: sessionID, key, value, scope: 'session' }
-  }
-  throw new HttpError(403, 'Config key is not allowed')
-}
-
-function normalizedSessionOpenParams(
-  method: 'session.create' | 'session.resume',
-  params: Record<string, unknown>,
-): Record<string, unknown> {
-  const profile = safeString(params.profile, 'profile', true)!
-  const columns = typeof params.cols === 'number' && Number.isInteger(params.cols)
-    ? Math.min(500, Math.max(20, params.cols))
-    : 80
-  if (method === 'session.resume') {
-    return {
-      session_id: safeString(params.session_id, 'session_id', true)!,
-      profile,
-      source: 'web',
-      close_on_disconnect: false,
-      omit_messages: params.omit_messages === true,
-      cols: columns,
-    }
-  }
-  const title = safeString(params.title, 'title')
-  const reasoning = safeString(params.reasoning_effort, 'reasoning effort')
-  const allowedReasoning = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
-  if (reasoning && !allowedReasoning.has(reasoning)) throw new HttpError(400, 'Reasoning effort is invalid')
-  return {
-    profile,
-    source: 'web',
-    close_on_disconnect: false,
-    cols: columns,
-    ...(title ? { title } : {}),
-    ...(reasoning ? { reasoning_effort: reasoning } : {}),
-    ...(typeof params.fast === 'boolean' ? { fast: params.fast } : {}),
-  }
-}
-
-function normalizedPairedSessionOpenParams(
-  method: 'session.create' | 'session.resume',
-  params: Record<string, unknown>,
-): Record<string, unknown> {
-  const profile = safeString(params.profile, 'profile', true)!
-  const source = safeString(params.source, 'source') ?? 'mobile'
-  const columns = typeof params.cols === 'number' && Number.isInteger(params.cols)
-    ? Math.min(500, Math.max(20, params.cols))
-    : 80
-  if (method === 'session.resume') {
-    return {
-      session_id: safeString(params.session_id, 'session_id', true)!,
-      profile,
-      source,
-      close_on_disconnect: false,
-      omit_messages: params.omit_messages === true,
-      cols: columns,
-    }
-  }
-  const title = safeString(params.title, 'title')
-  const cwd = typeof params.cwd === 'string' && params.cwd.length <= 4_096 ? params.cwd : ''
-  const reasoning = safeString(params.reasoning_effort, 'reasoning effort')
-  const allowedReasoning = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'])
-  if (reasoning && !allowedReasoning.has(reasoning)) throw new HttpError(400, 'Reasoning effort is invalid')
-  const model = safeString(params.model, 'model')
-  const provider = safeString(params.provider, 'provider')
-  if ((model === undefined) !== (provider === undefined)) {
-    throw new HttpError(400, 'Model and provider must be set together')
-  }
-  const messages = params.messages
-  if (messages !== undefined && (!Array.isArray(messages) || messages.length > 256)) {
-    throw new HttpError(400, 'Seed messages are invalid')
-  }
-  return {
-    profile,
-    source,
-    close_on_disconnect: false,
-    cols: columns,
-    cwd,
-    hidden: params.hidden === true,
-    ...(title ? { title } : {}),
-    ...(reasoning ? { reasoning_effort: reasoning } : {}),
-    ...(model && provider ? { model, provider } : {}),
-    ...(typeof params.fast === 'boolean' ? { fast: params.fast } : {}),
-    ...(messages !== undefined ? { messages } : {}),
-  }
-}
-
-function checkedChatFrame(data: RawData, isBinary: boolean, paired = false): string {
-  if (isBinary) throw new HttpError(400, 'Binary chat frames are not accepted')
-  const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer)
-  if (bytes.byteLength > CHAT_MAX_PAYLOAD) throw new HttpError(413, 'Chat frame is too large')
-  let value: unknown
-  try {
-    value = JSON.parse(bytes.toString('utf8'))
-  } catch {
-    throw new HttpError(400, 'Chat frame must be JSON')
-  }
-  if (!value || typeof value !== 'object' || Array.isArray(value)) {
-    throw new HttpError(400, 'Chat frame must be a JSON-RPC object')
-  }
-  const request = value as Record<string, unknown>
-  if (typeof request.method !== 'string' || !CHAT_METHODS.has(request.method)) {
-    throw new HttpError(403, 'Chat RPC method is not allowed')
-  }
-  if (request.id !== undefined && typeof request.id !== 'string' && typeof request.id !== 'number') {
-    throw new HttpError(400, 'Chat RPC id is invalid')
-  }
-  if (!request.params || typeof request.params !== 'object' || Array.isArray(request.params)) {
-    throw new HttpError(400, 'Chat RPC params must be an object')
-  }
-  let params = { ...(request.params as Record<string, unknown>) }
-  safeString(params.session_id, 'session_id', SESSION_ID_REQUIRED_METHODS.has(request.method))
-  const profileRequired = request.method === 'session.create' || request.method === 'session.resume'
-  safeString(params.profile, 'profile', profileRequired)
-  if (request.method === 'session.create' || request.method === 'session.resume') {
-    params = paired
-      ? normalizedPairedSessionOpenParams(request.method, params)
-      : normalizedSessionOpenParams(request.method, params)
-  }
-  if (request.method === 'profiles.list') {
-    const preferred = params.preferred_session_ids
-    if (preferred !== undefined && (!preferred || typeof preferred !== 'object' || Array.isArray(preferred))) {
-      throw new HttpError(400, 'preferred_session_ids must be an object')
-    }
-    params = {
-      ...(typeof params.include_sessions === 'boolean' ? { include_sessions: params.include_sessions } : {}),
-      ...(preferred !== undefined ? { preferred_session_ids: preferred } : {}),
-    }
-  }
-  if (request.method === 'profiles.get_asset') {
-    params = {
-      name: safeString(params.name, 'profile name', true)!,
-      asset: safeString(params.asset, 'profile asset', true)!,
-    }
-  }
-  if (request.method === 'profiles.set_asset') {
-    const clear = params.clear === true
-    const data = typeof params.data === 'string' ? params.data.trim() : ''
-    if (!clear && !data) throw new HttpError(400, 'profile asset data is required')
-    if (data.length > 2_800_000) throw new HttpError(413, 'profile asset exceeds 2 MiB')
-    params = {
-      name: safeString(params.name, 'profile name', true)!,
-      asset: safeString(params.asset, 'profile asset', true)!,
-      ...(clear ? { clear: true } : { data }),
-    }
-  }
-  if (request.method === 'profiles.configure') {
-    if (!params.ui_meta || typeof params.ui_meta !== 'object' || Array.isArray(params.ui_meta)) {
-      throw new HttpError(400, 'ui_meta must be an object')
-    }
-    if (params.ui_meta_expected_revisions !== undefined
-      && (!params.ui_meta_expected_revisions
-        || typeof params.ui_meta_expected_revisions !== 'object'
-        || Array.isArray(params.ui_meta_expected_revisions))) {
-      throw new HttpError(400, 'ui_meta_expected_revisions must be an object')
-    }
-    params = {
-      name: safeString(params.name, 'profile name', true)!,
-      ui_meta: params.ui_meta,
-      ...(params.ui_meta_expected_revisions !== undefined
-        ? { ui_meta_expected_revisions: params.ui_meta_expected_revisions }
-        : {}),
-    }
-  }
-  if (request.method === 'config.set') params = normalizedConfigParams(params)
-  if (request.method === 'prompt.submit' || request.method === 'session.steer') {
-    const text = typeof params.text === 'string' ? params.text.trim() : ''
-    if (!text || text.length > 200_000) throw new HttpError(400, 'Prompt text is invalid')
-    params = {
-      session_id: safeString(params.session_id, 'session_id', true)!,
-      text,
-      ...(request.method === 'prompt.submit' && params.queued === true ? { queued: true } : {}),
-    }
-  }
-
-  if (request.method === 'image.attach_bytes' || request.method === 'pdf.attach') {
-    const encoded = typeof params.content_base64 === 'string' ? params.content_base64 : ''
-    const estimated = Math.floor(encoded.length * 0.75)
-    if (!encoded || estimated > 25 * 1_024 * 1_024) {
-      throw new HttpError(413, 'Attachment exceeds 25 MiB')
-    }
-  }
-  if (request.method === 'file.attach') {
-    const dataURL = typeof params.data_url === 'string' ? params.data_url : ''
-    const marker = dataURL.indexOf(',')
-    if (marker < 0 || Math.floor((dataURL.length - marker - 1) * 0.75) > 25 * 1_024 * 1_024) {
-      throw new HttpError(413, 'Attachment exceeds 25 MiB')
-    }
-  }
-  return JSON.stringify({ ...request, params })
-}
-
-function upstreamWebSocketURL(config: ServerConfig, channel: RealtimeChannel, lease: {
-  credential: { name: 'ticket' | 'token'; value: string }
-  epoch?: string
-  cursor?: number
-}): URL {
-  const url = new URL(config.upstream)
-  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
-  const prefix = config.upstream.pathname === '/' ? '' : config.upstream.pathname.replace(/\/$/, '')
-  url.pathname = channel === 'chat'
-    ? `${prefix}/api/ws`
-    : `${prefix}/api/plugins/yaoyao/v1/events`
-  url.search = ''
-  url.searchParams.set(lease.credential.name, lease.credential.value)
-  if (channel === 'groups') {
-    url.searchParams.set('epoch', lease.epoch!)
-    url.searchParams.set('cursor', String(lease.cursor ?? 0))
-  }
-  return url
-}
-
-function pairedUpstreamWebSocketURL(
-  config: ServerConfig,
-  channel: RealtimeChannel,
-  ticket: string,
-  epoch?: string,
-  cursor?: number,
-): URL {
-  const url = new URL(config.upstream)
-  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
-  const prefix = config.upstream.pathname === '/' ? '' : config.upstream.pathname.replace(/\/$/, '')
-  url.pathname = channel === 'chat'
-    ? `${prefix}/api/ws`
-    : `${prefix}/api/plugins/yaoyao/v1/events`
-  url.search = ''
-  url.searchParams.set('ticket', ticket)
-  if (channel === 'groups') {
-    url.searchParams.set('epoch', epoch!)
-    url.searchParams.set('cursor', String(cursor ?? 0))
-  }
-  return url
-}
-
-function closePair(client: WebSocket, upstream: WebSocket, code = 1000, reason = ''): void {
-  const safeReason = safeCloseReason(reason)
-  const safeCode = safeCloseCode(code)
-  if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) {
-    client.close(safeCode, safeReason)
-  }
-  if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
-    upstream.close(safeCode, safeReason)
-  }
-}
-
-class GroupFrameValidator {
-  #ready = false
-  #cursor: number
-
-  constructor(readonly epoch: string, cursor: number) {
-    this.#cursor = cursor
-  }
-
-  accept(data: RawData, isBinary: boolean): boolean {
-    if (isBinary) throw new HttpError(400, 'Binary group frames are not accepted')
-    let envelope: Record<string, unknown>
-    try {
-      const value = JSON.parse(Buffer.from(data as Uint8Array).toString('utf8')) as unknown
-      if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not an object')
-      envelope = value as Record<string, unknown>
-    } catch {
-      throw new HttpError(400, 'Malformed group event frame')
-    }
-    const type = envelope.type
-    const cursor = Number(envelope.cursor)
-    if (type === 'group.ready') {
-      const heartbeat = Number(envelope.heartbeatSeconds)
-      if (this.#ready || envelope.epoch !== this.epoch || !Number.isSafeInteger(cursor)
-        || cursor !== this.#cursor || !Number.isFinite(heartbeat) || heartbeat <= 0) {
-        throw new HttpError(409, 'Invalid group.ready frame')
-      }
-      this.#ready = true
-      return true
-    }
-    if (type === 'group.reset_required') {
-      if (typeof envelope.epoch !== 'string' || !Number.isSafeInteger(cursor)
-        || typeof envelope.reason !== 'string') {
-        throw new HttpError(400, 'Invalid group.reset_required frame')
-      }
-      return true
-    }
-    if (!this.#ready) throw new HttpError(409, 'Group event arrived before group.ready')
-    if (type === 'group.event') {
-      if (envelope.epoch !== this.epoch || !Number.isSafeInteger(cursor) || cursor < 0) {
-        throw new HttpError(409, 'Group event epoch or cursor is invalid')
-      }
-      if (cursor <= this.#cursor) return false
-      if (cursor !== this.#cursor + 1) throw new HttpError(409, 'Group event cursor gap')
-      this.#cursor = cursor
-      return true
-    }
-    if (type === 'group.heartbeat') {
-      if ((envelope.epoch !== undefined && envelope.epoch !== this.epoch)
-        || !Number.isSafeInteger(cursor) || cursor !== this.#cursor) {
-        throw new HttpError(409, 'Group heartbeat cursor mismatch')
-      }
-      return true
-    }
-    throw new HttpError(400, 'Unknown group event frame')
-  }
-
-  resetFrame(reason: string): string {
-    return JSON.stringify({
-      type: 'group.reset_required',
-      epoch: this.epoch,
-      cursor: this.#cursor,
-      reason,
-    })
-  }
-}
-
-function relaySocket(
-  client: WebSocket,
-  upstream: WebSocket,
-  channel: RealtimeChannel,
-  onFinished: () => void,
-  groupAnchor?: { epoch: string; cursor: number },
-  paired = false,
-  chatObserver?: ChatPushRelayObserver,
-): void {
-  const groupValidator = groupAnchor
-    ? new GroupFrameValidator(groupAnchor.epoch, groupAnchor.cursor)
-    : undefined
-  let clientAlive = true
-  let upstreamAlive = true
-  let finished = false
-  let clientFrames = Promise.resolve()
-  const finish = () => {
-    if (finished) return
-    finished = true
-    clearInterval(heartbeat)
-    chatObserver?.disconnected()
-    onFinished()
-  }
-  const heartbeat = setInterval(() => {
-    if (!clientAlive || !upstreamAlive) {
-      client.terminate()
-      upstream.terminate()
-      finish()
-      return
-    }
-    clientAlive = false
-    upstreamAlive = false
-    if (client.readyState === WebSocket.OPEN) client.ping()
-    if (upstream.readyState === WebSocket.OPEN) upstream.ping()
-  }, 25_000)
-  heartbeat.unref()
-  client.on('pong', () => { clientAlive = true })
-  upstream.on('pong', () => { upstreamAlive = true })
-
-  client.on('message', (data, isBinary) => {
-    clientFrames = clientFrames.then(() => {
-      if (channel === 'groups') throw new HttpError(403, 'Group event stream is read-only')
-      if (upstream.readyState !== WebSocket.OPEN) throw new HttpError(409, 'Hermes socket is not ready')
-      if (upstream.bufferedAmount > MAX_BUFFERED_BYTES) throw new HttpError(429, 'Hermes socket is congested')
-      const frame = checkedChatFrame(data, isBinary, paired)
-      chatObserver?.observeClientFrame(frame)
-      if (upstream.readyState !== WebSocket.OPEN) throw new HttpError(409, 'Hermes socket is not ready')
-      upstream.send(frame)
-    }).catch(error => {
-      const reason = error instanceof Error ? error.message : 'Invalid frame'
-      closePair(client, upstream, 1008, reason)
-    })
-  })
-  upstream.on('message', (data, isBinary) => {
-    const length = typeof data === 'string' ? Buffer.byteLength(data) : Buffer.byteLength(data as Uint8Array)
-    if ((channel === 'groups' && length > GROUP_MAX_PAYLOAD) || client.bufferedAmount > MAX_BUFFERED_BYTES) {
-      closePair(client, upstream, 1009, 'Upstream frame or buffer limit exceeded')
-      return
-    }
-    if (groupValidator) {
-      try {
-        if (!groupValidator.accept(data, isBinary)) return
-      } catch (error) {
-        const reason = error instanceof Error ? error.message : 'invalid_group_frame'
-        if (client.readyState === WebSocket.OPEN) client.send(groupValidator.resetFrame(reason))
-        closePair(client, upstream, 1008, 'Group event stream requires reset')
-        return
-      }
-    }
-    if (channel === 'chat') chatObserver?.observeUpstreamFrame(data, isBinary)
-    if (client.readyState === WebSocket.OPEN) client.send(data, { binary: isBinary })
-  })
-  client.on('close', (code, reason) => {
-    if (upstream.readyState === WebSocket.OPEN) {
-      upstream.close(safeCloseCode(code), safeCloseReason(reason.toString()))
-    }
-    finish()
-  })
-  upstream.on('close', (code, reason) => {
-    if (client.readyState === WebSocket.OPEN) {
-      client.close(safeCloseCode(code), safeCloseReason(reason.toString()))
-    }
-    finish()
-  })
-  client.on('error', () => {
-    upstream.terminate()
-    finish()
-  })
-  upstream.on('error', () => {
-    if (client.readyState === WebSocket.OPEN) client.close(1011, 'Hermes WebSocket failed')
-    finish()
-  })
-}
-
-export function installWebSocketRelay(
-  server: HttpServer,
-  config: ServerConfig,
-  leases: RealtimeLeaseStore,
-  pairings: NodePairingStore,
-  push?: WebSocketPushDependencies,
-): () => void {
-  const socketServer = new WebSocketServer({ noServer: true, maxPayload: CHAT_MAX_PAYLOAD })
-  const active = new Map<string, number>()
-  const onUpgrade = (request: UpgradeRequest, socket: Duplex, head: Buffer) => {
-    let url: URL
-    try {
-      url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'invalid'}`)
-    } catch {
-      rejectUpgrade(socket, 400, 'Invalid WebSocket URL')
-      return
-    }
-    const gatewayChannel: RealtimeChannel | undefined = url.pathname === '/api/ws'
-      ? 'chat'
-      : url.pathname === '/api/plugins/yaoyao/v1/events' ? 'groups' : undefined
-    if (gatewayChannel) {
-      const host = request.headers.host
-      const origin = typeof request.headers.origin === 'string' ? request.headers.origin : undefined
-      const secure = Boolean(config.tlsCert)
-      if (!isAllowedHostHeader(host, config)
-        || (origin !== undefined && !isExactOrigin(origin, host, secure, config.allowedHosts))) {
-        rejectUpgrade(socket, 403, 'Host or Origin rejected')
-        return
-      }
-      const allowed = gatewayChannel === 'groups'
-        ? new Set(['ticket', 'epoch', 'cursor']) : new Set(['ticket'])
-      if ([...url.searchParams.keys()].some(name => !allowed.has(name))) {
-        rejectUpgrade(socket, 400, 'Unexpected WebSocket query parameter')
-        return
-      }
-      const ticket = url.searchParams.get('ticket')
-      const epoch = gatewayChannel === 'groups' ? url.searchParams.get('epoch') : undefined
-      const cursor = Number(url.searchParams.get('cursor') ?? '0')
-      if (!ticket || ticket.length > 4_096
-        || (gatewayChannel === 'groups' && (!epoch || !Number.isSafeInteger(cursor) || cursor < 0))) {
-        rejectUpgrade(socket, 401, 'Missing or invalid Gateway ticket')
-        return
-      }
-      const connectionKey = `gateway:${request.socket.remoteAddress ?? 'unknown'}:${gatewayChannel}`
-      if ((active.get(connectionKey) ?? 0) >= (gatewayChannel === 'chat' ? 4 : 2)) {
-        rejectUpgrade(socket, 429, 'Too many realtime connections')
-        return
-      }
-      active.set(connectionKey, (active.get(connectionKey) ?? 0) + 1)
-      let gatewayUserID: string | undefined
-      if (gatewayChannel === 'chat' && push?.resolveGatewayUser) {
-        try { gatewayUserID = push.resolveGatewayUser(request) } catch { /* unowned relays are not push-tracked */ }
-      }
-      socketServer.handleUpgrade(request, socket, head, (client) => {
-        const upstream = new WebSocket(pairedUpstreamWebSocketURL(
-          config, gatewayChannel, ticket, epoch ?? undefined, cursor,
-        ), {
-          headers: { Origin: config.upstream.origin },
-          maxPayload: gatewayChannel === 'chat' ? CHAT_MAX_PAYLOAD : GROUP_MAX_PAYLOAD,
-          handshakeTimeout: 15_000,
-        })
-        let released = false
-        const release = () => {
-          if (released) return
-          released = true
-          const count = (active.get(connectionKey) ?? 1) - 1
-          if (count <= 0) active.delete(connectionKey)
-          else active.set(connectionKey, count)
-        }
-        upstream.once('open', () => relaySocket(
-          client, upstream, gatewayChannel, release,
-          gatewayChannel === 'groups' ? { epoch: epoch!, cursor } : undefined,
-          false,
-          gatewayUserID && push
-            ? new ChatPushRelayObserver(
-                push.coordinator,
-                { localUserID: gatewayUserID, source: 'gateway' },
-                Date.now,
-                push.notificationResolver,
-              )
-            : undefined,
-        ))
-        upstream.once('unexpected-response', release)
-        upstream.once('error', () => {
-          if (client.readyState === WebSocket.OPEN) client.close(1011, 'Unable to connect to Hermes')
-          release()
-        })
-        client.once('close', () => {
-          if (upstream.readyState === WebSocket.CONNECTING) upstream.terminate()
-          release()
-        })
-      })
-      return
-    }
-    const pairedMatch = url.pathname.match(
-      /^\/node\/([0-9a-f-]{36})\/api\/(ws|plugins\/yaoyao\/v1\/events)$/,
-    )
-    if (pairedMatch) {
-      const deviceID = pairedMatch[1]!
-      const channel: RealtimeChannel = pairedMatch[2] === 'ws' ? 'chat' : 'groups'
-      const host = request.headers.host
-      const origin = typeof request.headers.origin === 'string' ? request.headers.origin : undefined
-      const secure = Boolean(config.tlsCert)
-      if (!isAllowedHostHeader(host, config)
-        || (origin !== undefined && !isExactOrigin(origin, host, secure, config.allowedHosts))) {
-        rejectUpgrade(socket, 403, 'Host or Origin rejected')
-        return
-      }
-      if (!pairings.hasDevice(deviceID)) {
-        rejectUpgrade(socket, 401, 'Paired device was revoked')
-        return
-      }
-      const allowedQuery = channel === 'groups'
-        ? new Set(['ticket', 'epoch', 'cursor'])
-        : new Set(['ticket'])
-      if ([...url.searchParams.keys()].some((name) => !allowedQuery.has(name))) {
-        rejectUpgrade(socket, 400, 'Unexpected WebSocket query parameter')
-        return
-      }
-      const ticket = url.searchParams.get('ticket')
-      if (!ticket || ticket.length > 4_096) {
-        rejectUpgrade(socket, 401, 'Missing paired node ticket')
-        return
-      }
-      const epoch = channel === 'groups' ? url.searchParams.get('epoch') : undefined
-      const cursorValue = channel === 'groups' ? Number(url.searchParams.get('cursor') ?? '0') : 0
-      if (channel === 'groups' && (!epoch
-        || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(epoch)
-        || !Number.isSafeInteger(cursorValue) || cursorValue < 0)) {
-        rejectUpgrade(socket, 400, 'Invalid paired group cursor')
-        return
-      }
-      const connectionKey = `paired:${deviceID}:${channel}`
-      const maximum = channel === 'chat' ? 4 : 2
-      if ((active.get(connectionKey) ?? 0) >= maximum) {
-        rejectUpgrade(socket, 429, 'Too many realtime connections')
-        return
-      }
-      active.set(connectionKey, (active.get(connectionKey) ?? 0) + 1)
-      socketServer.handleUpgrade(request, socket, head, (client) => {
-        const upstream = new WebSocket(
-          pairedUpstreamWebSocketURL(
-            config,
-            channel,
-            ticket,
-            epoch ?? undefined,
-            cursorValue,
-          ),
-          {
-            headers: { Origin: config.upstream.origin },
-            maxPayload: channel === 'chat' ? CHAT_MAX_PAYLOAD : GROUP_MAX_PAYLOAD,
-            handshakeTimeout: 15_000,
-          },
-        )
-        let released = false
-        const release = () => {
-          if (released) return
-          released = true
-          const count = (active.get(connectionKey) ?? 1) - 1
-          if (count <= 0) active.delete(connectionKey)
-          else active.set(connectionKey, count)
-        }
-        upstream.once('open', () => relaySocket(
-          client,
-          upstream,
-          channel,
-          release,
-          channel === 'groups' ? { epoch: epoch!, cursor: cursorValue } : undefined,
-          true,
-        ))
-        upstream.once('unexpected-response', () => {
-          if (client.readyState === WebSocket.OPEN) client.close(1011, 'Hermes rejected WebSocket')
-          release()
-        })
-        upstream.once('error', () => {
-          if (upstream.readyState !== WebSocket.OPEN && client.readyState === WebSocket.OPEN) {
-            client.close(1011, 'Unable to connect to Hermes')
-            release()
-          }
-        })
-        client.once('close', () => {
-          if (upstream.readyState === WebSocket.CONNECTING) upstream.terminate()
-          release()
-        })
-      })
-      return
-    }
-    if (!url.pathname.startsWith('/ws/')) {
-      if (config.production) rejectUpgrade(socket, 404, 'Unknown WebSocket endpoint')
-      return
-    }
-    const channel: RealtimeChannel | undefined = url.pathname === '/ws/chat'
-      ? 'chat'
-      : url.pathname === '/ws/groups' ? 'groups' : undefined
-    if (!channel) {
-      rejectUpgrade(socket, 404, 'Unknown WebSocket endpoint')
-      return
-    }
-    const host = request.headers.host
-    const origin = typeof request.headers.origin === 'string' ? request.headers.origin : undefined
-    const secure = Boolean(config.tlsCert)
-    if (!isAllowedHostHeader(host, config) || !isExactOrigin(origin, host, secure, config.allowedHosts)) {
-      rejectUpgrade(socket, 403, 'Host or Origin rejected')
-      return
-    }
-    if ([...url.searchParams.keys()].some((name) => name !== 'lease')) {
-      rejectUpgrade(socket, 400, 'Unexpected WebSocket query parameter')
-      return
-    }
-    const leaseID = url.searchParams.get('lease')
-    if (!leaseID || leaseID.length > 128) {
-      rejectUpgrade(socket, 401, 'Missing realtime lease')
-      return
-    }
-    let lease
-    const accountKey = requestAccountKey(request)
-    try {
-      lease = leases.consume(leaseID, channel, origin!, accountKey)
-    } catch {
-      rejectUpgrade(socket, 401, 'Realtime lease is invalid or expired')
-      return
-    }
-    const connectionKey = `${channel}:${accountKey}`
-    const maximum = channel === 'chat' ? 4 : 2
-    if ((active.get(connectionKey) ?? 0) >= maximum) {
-      rejectUpgrade(socket, 429, 'Too many realtime connections')
-      return
-    }
-    active.set(connectionKey, (active.get(connectionKey) ?? 0) + 1)
-
-    socketServer.handleUpgrade(request, socket, head, (client) => {
-      const upstream = new WebSocket(upstreamWebSocketURL(config, channel, lease), {
-        headers: { Origin: config.upstream.origin },
-        maxPayload: channel === 'chat' ? CHAT_MAX_PAYLOAD : GROUP_MAX_PAYLOAD,
-        handshakeTimeout: 15_000,
-      })
-      let released = false
-      const release = () => {
-        if (released) return
-        released = true
-        const count = (active.get(connectionKey) ?? 1) - 1
-        if (count <= 0) active.delete(connectionKey)
-        else active.set(connectionKey, count)
-      }
-      upstream.once('open', () => relaySocket(
-        client,
-        upstream,
-        channel,
-        release,
-        channel === 'groups' ? { epoch: lease.epoch!, cursor: lease.cursor ?? 0 } : undefined,
-        false,
-        channel === 'chat' && lease.localUserID && push
-          ? new ChatPushRelayObserver(
-              push.coordinator,
-              { localUserID: lease.localUserID, accountKey, source: 'web' },
-              Date.now,
-              push.notificationResolver,
-            )
-          : undefined,
-      ))
-      upstream.once('unexpected-response', () => {
-        if (client.readyState === WebSocket.OPEN) client.close(1011, 'Hermes rejected WebSocket')
-        release()
-      })
-      upstream.once('error', () => {
-        if (upstream.readyState !== WebSocket.OPEN && client.readyState === WebSocket.OPEN) {
-          client.close(1011, 'Unable to connect to Hermes')
-          release()
-        }
-      })
-      client.once('close', () => {
-        if (upstream.readyState === WebSocket.CONNECTING) upstream.terminate()
-        release()
-      })
-    })
-  }
-  server.on('upgrade', onUpgrade)
-  return () => {
-    server.off('upgrade', onUpgrade)
-    for (const client of socketServer.clients) client.close(1001, 'Server shutting down')
-    socketServer.close()
-  }
-}

+ 35 - 0
src/shared/sse.ts

@@ -0,0 +1,35 @@
+export interface SSEMessage { event: string; data: string; id?: string }
+
+/** Incremental SSE framing; callers supply a streaming UTF-8 decoder. */
+export class SSEParser {
+  private buffer = ''
+  private data: string[] = []
+  private event = ''
+  private id: string | undefined
+  private size = 0
+  private first = true
+  feed(chunk: string): SSEMessage[] {
+    if (this.first && chunk) { chunk = chunk.replace(/^\uFEFF/, ''); this.first = false }
+    this.buffer += chunk
+    if (this.buffer.length + this.size > 36 * 1024 * 1024) throw new Error('SSE frame exceeds limit')
+    const result: SSEMessage[] = []
+    while (true) {
+      const i = this.buffer.search(/[\r\n]/)
+      if (i < 0 || (this.buffer[i] === '\r' && i === this.buffer.length - 1)) break
+      const line = this.buffer.slice(0, i)
+      this.buffer = this.buffer.slice(i + (this.buffer[i] === '\r' && this.buffer[i + 1] === '\n' ? 2 : 1))
+      if (!line) {
+        if (this.data.length) result.push({ event: this.event || 'message', data: this.data.join('\n'), ...(this.id !== undefined ? { id: this.id } : {}) })
+        this.data = []; this.event = ''; this.size = 0
+      } else if (!line.startsWith(':')) {
+        const colon = line.indexOf(':')
+        const field = colon < 0 ? line : line.slice(0, colon)
+        const value = colon < 0 ? '' : line.slice(colon + 1).replace(/^ /, '')
+        if (field === 'data') { this.data.push(value); this.size += value.length }
+        else if (field === 'event') this.event = value
+        else if (field === 'id' && !value.includes('\0')) this.id = value
+      }
+    }
+    return result
+  }
+}

+ 51 - 80
tests/client/chatProtocol.test.ts

@@ -2,94 +2,65 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { ChatRpcSocket, GroupEventSocket } from '@/api/realtime'
 import { setApiCsrfToken } from '@/api/client'
 
-class FakeWebSocket extends EventTarget {
-  static readonly CONNECTING = 0
-  static readonly OPEN = 1
-  static readonly CLOSING = 2
-  static readonly CLOSED = 3
-  static instances: FakeWebSocket[] = []
+beforeEach(() => { setApiCsrfToken('csrf-test'); vi.stubGlobal('WebSocket', vi.fn()) })
+afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
 
-  readonly url: string
-  readyState = FakeWebSocket.CONNECTING
-  sent: string[] = []
-
-  constructor(url: string) {
-    super()
-    this.url = url
-    FakeWebSocket.instances.push(this)
-    queueMicrotask(() => {
-      this.readyState = FakeWebSocket.OPEN
-      this.dispatchEvent(new Event('open'))
-    })
-  }
-
-  send(data: string): void { this.sent.push(data) }
-  close(code = 1000, reason = ''): void {
-    this.readyState = FakeWebSocket.CLOSED
-    this.dispatchEvent(new CloseEvent('close', { code, reason }))
-  }
-  message(value: unknown): void { this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(value) })) }
-}
-
-beforeEach(() => {
-  FakeWebSocket.instances = []
-  setApiCsrfToken('csrf-test')
-  vi.stubGlobal('WebSocket', FakeWebSocket)
-})
-
-afterEach(() => {
-  vi.unstubAllGlobals()
-  vi.restoreAllMocks()
-})
-
-function leaseFetch() {
-  const mock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({ lease: 'lease-1' }), {
-    status: 201, headers: { 'content-type': 'application/json' },
-  }))
-  vi.stubGlobal('fetch', mock)
-  return mock
+function httpFixture() {
+  let kind = 'chat', epoch = '', cursor = 0
+  let stream: ReadableStreamDefaultController<Uint8Array> | undefined
+  const send = (frame: object) => stream!.enqueue(new TextEncoder().encode('event: frame\ndata: ' + JSON.stringify(frame) + '\n\n'))
+  const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+    const path = String(input)
+    if (path.endsWith('/capabilities')) return Response.json({ protocolVersion: 1, csrfToken: 'csrf-test' })
+    if (init?.method === 'DELETE') return new Response(null, { status: 204 })
+    if (path.endsWith('/channels')) {
+      const body = JSON.parse(String(init?.body)); kind = body.channel; epoch = body.epoch; cursor = body.cursor
+      return Response.json({ id: 'channel-1' }, { status: 201 })
+    }
+    if (path.endsWith('/events')) {
+      const body = new ReadableStream<Uint8Array>({ start(controller) {
+        stream = controller
+        send(kind === 'chat'
+          ? { method: 'event', params: { type: 'gateway.ready', payload: {} } }
+          : { type: 'group.ready', epoch, cursor, heartbeatSeconds: 20 })
+        init?.signal?.addEventListener('abort', () => { try { controller.close() } catch {} }, { once: true })
+      } })
+      return new Response(body, { headers: { 'content-type': 'text/event-stream' } })
+    }
+    return Response.json({ state: 'confirmed', response: { result: { total_tokens: 42 } } })
+  })
+  vi.stubGlobal('fetch', fetchMock)
+  return { fetchMock, send }
 }
 
-describe('raw realtime protocols', () => {
-  it('waits for gateway.ready and correlates JSON-RPC responses', async () => {
-    leaseFetch()
-    const socket = new ChatRpcSocket()
-    const events: string[] = []
+describe('HTTP-only realtime protocols', () => {
+  it('waits for SSE ready and submits commands through HTTP without opening a WS', async () => {
+    const f = httpFixture(), socket = new ChatRpcSocket(), events: string[] = []
     socket.onEvent(event => events.push(event.type))
-    const connecting = socket.connect()
-    await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1))
-    const transport = FakeWebSocket.instances[0]
-    await Promise.resolve()
-    expect(socket.state).toBe('connected')
-    transport.message({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: {} } })
-    await connecting
+    await socket.connect()
     expect(socket.state).toBe('ready')
-
-    const response = socket.request('session.usage', { session_id: 'runtime-1' })
-    const frame = JSON.parse(transport.sent[0])
-    expect(frame).toMatchObject({ jsonrpc: '2.0', method: 'session.usage', params: { session_id: 'runtime-1' } })
-    transport.message({ jsonrpc: '2.0', id: frame.id, result: { total_tokens: 42 } })
-    await expect(response).resolves.toEqual({ total_tokens: 42 })
+    const result = await socket.request('session.usage', { session_id: 'runtime-1' }, 5000, 'stable-command')
+    expect(result).toEqual({ total_tokens: 42 })
+    const sent = f.fetchMock.mock.calls.find(([path]) => String(path).endsWith('/commands'))![1]!
+    expect(new Headers(sent.headers).get('Idempotency-Key')).toBe('stable-command')
+    expect(JSON.parse(String(sent.body))).toMatchObject({ method: 'session.usage', params: { session_id: 'runtime-1' } })
     expect(events).toEqual(['gateway.ready'])
+    expect(WebSocket).not.toHaveBeenCalled()
     socket.close()
   })
-
-  it('binds a group lease to epoch/cursor but exposes only the lease in WS query', async () => {
-    const fetchMock = leaseFetch()
-    const socket = new GroupEventSocket()
-    await socket.connect({
-      epoch: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
-      cursor: 12,
-      onEnvelope: () => undefined,
-      onReset: () => undefined,
-    })
-    const request = fetchMock.mock.calls[0]?.[1]
-    expect(request).toBeDefined()
-    expect(JSON.parse(String(request!.body))).toEqual({
-      channel: 'groups', epoch: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', cursor: 12,
-    })
-    const url = new URL(FakeWebSocket.instances[0].url)
-    expect([...url.searchParams.keys()]).toEqual(['lease'])
+  it('preserves the group epoch/cursor on the HTTP channel', async () => {
+    const f = httpFixture(), socket = new GroupEventSocket()
+    await socket.connect({ epoch: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', cursor: 12, onEnvelope: () => {}, onReset: () => {} })
+    const sent = f.fetchMock.mock.calls.find(([path]) => String(path).endsWith('/channels'))![1]!
+    expect(JSON.parse(String(sent.body))).toEqual({ channel: 'groups', epoch: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', cursor: 12 })
+    expect(WebSocket).not.toHaveBeenCalled()
+    socket.close()
+  })
+  it.each([401, 404, 405, 410])('does not downgrade after HTTP %s', async status => {
+    vi.stubGlobal('fetch', vi.fn(async () => Response.json({ error: 'unsupported or unauthorized' }, { status })))
+    const socket = new ChatRpcSocket()
+    await expect(socket.connect()).rejects.toThrow()
+    expect(WebSocket).not.toHaveBeenCalled()
     socket.close()
   })
 })

+ 15 - 0
tests/client/sseParser.test.ts

@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest'
+import { SSEParser } from '../../src/shared/sse'
+describe('SSE framing', () => {
+  it('handles UTF8 and CRLF across arbitrary byte boundaries', () => {
+    const bytes = new TextEncoder().encode('\uFEFF: heartbeat\r\nid: e:1\r\nevent: frame\r\ndata: 你好\r\ndata: 世界\r\n\r\n')
+    const decoder = new TextDecoder(), parser = new SSEParser(), messages = []
+    for (const byte of bytes) messages.push(...parser.feed(decoder.decode(new Uint8Array([byte]), { stream: true })))
+    expect(messages).toEqual([{ id: 'e:1', event: 'frame', data: '你好\n世界' }])
+  })
+  it('ignores comments and does not emit an incomplete frame', () => {
+    const parser = new SSEParser()
+    expect(parser.feed(': heartbeat\n\ndata: partial')).toEqual([])
+    expect(parser.feed('\n\n')).toEqual([{ event: 'message', data: 'partial' }])
+  })
+})

+ 18 - 0
tests/e2e/workspace.spec.ts

@@ -817,6 +817,24 @@ test('shows a thinking animation after submit until output starts', async ({ pag
   await expect(page.locator('.thinking-indicator')).toHaveCount(0)
 })
 
+test('uses HTTP commands and SSE for chat and team events without opening client WebSockets', async ({ page }, testInfo) => {
+  const sockets: string[] = [], paths: string[] = []
+  page.on('websocket', socket => sockets.push(socket.url()))
+  page.on('request', request => paths.push(new URL(request.url()).pathname))
+  await page.goto('/chat/session-demo?profile=yaoyao')
+  await page.locator('.composer-textarea').fill('验证 HTTP SSE 入口')
+  await page.getByRole('button', { name: '发送消息' }).click()
+  await expect(page.getByText('这是来自假 Gateway 的流式回复。', { exact: true })).toBeVisible()
+  expect(paths.some(path => /^\/api\/realtime\/channels\/[^/]+\/commands$/.test(path))).toBe(true)
+  expect(paths.some(path => /^\/api\/realtime\/channels\/[^/]+\/events$/.test(path))).toBe(true)
+  await page.screenshot({ path: testInfo.outputPath('http-sse-chat.png'), fullPage: true })
+  await page.getByRole('button', { name: '团队', exact: true }).click()
+  await page.getByRole('option').filter({ hasText: '设计验收' }).click()
+  await expect(page.getByRole('heading', { name: '设计验收' })).toBeVisible()
+  expect(sockets).toEqual([])
+  await page.screenshot({ path: testInfo.outputPath('http-sse-team.png'), fullPage: true })
+})
+
 test('keeps streamed text below sealed interim commentary and its tool trace', async ({ page }) => {
   await page.goto('/chat/session-demo?profile=yaoyao')
   await page.locator('.composer-textarea').fill('验证流式分段')

+ 2 - 0
tests/server/authenticatedApplication.ts

@@ -25,12 +25,14 @@ const TEST_USER: LocalUser = {
 }
 
 class AuthenticatedTestStore extends LocalAuthStore {
+  override currentFromCookieHeader(_cookie: string | undefined): LocalUser { return TEST_ADMIN }
   override current(_ctx: Koa.Context): LocalUser { return TEST_ADMIN }
   override require(_ctx: Koa.Context, _allowPasswordChange = false): LocalUser { return TEST_ADMIN }
   override requireAdmin(_ctx: Koa.Context): LocalUser { return TEST_ADMIN }
 }
 
 class UserAuthenticatedTestStore extends LocalAuthStore {
+  override currentFromCookieHeader(_cookie: string | undefined): LocalUser { return TEST_USER }
   override current(_ctx: Koa.Context): LocalUser { return TEST_USER }
   override require(_ctx: Koa.Context, _allowPasswordChange = false): LocalUser { return TEST_USER }
 }

+ 0 - 50
tests/server/leases.test.ts

@@ -1,50 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { RealtimeLeaseStore } from '../../src/server/leases.js'
-
-describe('RealtimeLeaseStore', () => {
-  it('binds a lease to channel, origin, account and one use', () => {
-    let now = 1_000
-    const store = new RealtimeLeaseStore(20_000, () => now)
-    const lease = store.issue({
-      channel: 'chat',
-      credential: { name: 'ticket', value: 'upstream-secret' },
-      origin: 'http://127.0.0.1:8800',
-      accountKeys: ['account-a'],
-      localUserID: 'local-user-a',
-    })
-    expect(() => store.consume(
-      lease.id,
-      'groups',
-      'http://127.0.0.1:8800',
-      'account-a',
-    )).toThrow(/invalid or expired/)
-    const consumed = store.consume(
-      lease.id,
-      'chat',
-      'http://127.0.0.1:8800',
-      'account-a',
-    )
-    expect(consumed.credential.value).toBe('upstream-secret')
-    expect(consumed.localUserID).toBe('local-user-a')
-    expect(() => store.consume(
-      lease.id,
-      'chat',
-      'http://127.0.0.1:8800',
-      'account-a',
-    )).toThrow(/invalid or expired/)
-
-    const expiring = store.issue({
-      channel: 'chat',
-      credential: { name: 'ticket', value: 'another-secret' },
-      origin: 'http://127.0.0.1:8800',
-      accountKeys: ['account-a'],
-    })
-    now += 20_001
-    expect(() => store.consume(
-      expiring.id,
-      'chat',
-      'http://127.0.0.1:8800',
-      'account-a',
-    )).toThrow(/invalid or expired/)
-  })
-})

+ 2 - 2
tests/server/localAuthRoutes.test.ts

@@ -104,8 +104,8 @@ describe('8800 local authentication routes', () => {
     expect(nativeProfiles.body.profiles[0]).toMatchObject({
       name: 'default', description: '丫头', display_name: '丫头', agentName: '丫头',
     })
-    await native.post('/api/auth/ws-ticket').set('Host', '127.0.0.1:8800').send({}).expect(200, /upstream-ticket/)
-    expect(wsTicketForwardedFor).toBeNull()
+    await native.post('/api/auth/ws-ticket').set('Host', '127.0.0.1:8800').send({}).expect(410)
+    expect(wsTicketForwardedFor).toBeUndefined()
   })
 
   it('lets one 8800 claim another 8800 as a direct child node', async () => {

+ 152 - 0
tests/server/realtimeApi.test.ts

@@ -0,0 +1,152 @@
+// @vitest-environment node
+import { createServer } from 'node:http'
+import { once } from 'node:events'
+import { mkdtempSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import type { AddressInfo } from 'node:net'
+import { WebSocketServer, type WebSocket } from 'ws'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createAuthenticatedApplication } from './authenticatedApplication.js'
+import { createNodeServer } from '../../src/server/app.js'
+import { SSEParser } from '../../src/shared/sse.js'
+import type { ServerConfig } from '../../src/server/config.js'
+
+const closers: Array<() => Promise<void>> = []
+afterEach(async () => { for (const close of closers.splice(0).reverse()) await close() })
+
+async function setup() {
+  const server = createServer()
+  const wss = new WebSocketServer({ server })
+  const peers = new Set<WebSocket>(), commands: any[] = []
+  let connectionCount = 0, sequence = 0
+  wss.on('connection', peer => {
+    connectionCount++; peers.add(peer); peer.on('close', () => peers.delete(peer))
+    peer.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: { replay_epoch: 'fixture' } } }))
+    peer.on('message', raw => {
+      const command = JSON.parse(raw.toString()); commands.push(command)
+      peer.send(JSON.stringify({ id: command.id, result: command.method === 'session.resume'
+        ? { session_id: 'runtime', stored_session_id: 'stored', running: false } : { status: 'streaming' } }))
+    })
+  })
+  server.listen(0, '127.0.0.1'); await once(server, 'listening')
+  closers.push(async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise<void>(r => server.close(() => r())) })
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-http-sse-api-'))
+  const config: ServerConfig = { host: '127.0.0.1', port: 8800,
+    upstream: new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`), allowedHosts: new Set(),
+    home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home, mediaOwner: 'test',
+    allowInsecureLan: false, insecureLan: false, production: false }
+  const runtime = createAuthenticatedApplication({ config, fetchImpl: (async input => {
+    const path = new URL(String(input)).pathname
+    if (path === '/api/auth/ws-ticket') return Response.json({ ticket: 'upstream-secret' })
+    if (path === '/api/status') return Response.json({ auth_required: false })
+    return Response.json({ user_id: 'service', profiles: [] })
+  }) as typeof fetch })
+  const node = createNodeServer(runtime)
+  node.server.listen(0, '127.0.0.1'); await once(node.server, 'listening')
+  config.port = (node.server.address() as AddressInfo).port
+  closers.push(() => node.close())
+  const origin = `http://127.0.0.1:${config.port}`
+  const caps = await fetch(`${origin}/api/realtime/capabilities`)
+  const token = await caps.json() as { csrfToken: string }
+  const cookie = caps.headers.getSetCookie().map(c => c.split(';')[0]).join('; ')
+  const headers = { Cookie: cookie, Origin: origin, 'X-CSRF-Token': token.csrfToken, 'Content-Type': 'application/json' }
+  const create = async () => {
+    const response = await fetch(`${origin}/api/realtime/channels`, { method: 'POST', headers, body: '{"channel":"chat"}' })
+    expect(response.status).toBe(201)
+    return (await response.json() as { id: string }).id
+  }
+  const command = async (channel: string, id: string, method: string, params: object) => {
+    const response = await fetch(`${origin}/api/realtime/channels/${channel}/commands`, {
+      method: 'POST', headers: { ...headers, 'Idempotency-Key': id }, body: JSON.stringify({ method, params }),
+    })
+    return { response, receipt: await response.json() as any }
+  }
+  const emit = (text: string) => {
+    const payload = { text, emitted: performance.now() }
+    for (const peer of peers) peer.send(JSON.stringify({ method: 'event', params: { type: 'message.delta', session_id: 'runtime', seq: ++sequence, payload } }))
+  }
+  return { origin, headers, create, command, emit, commands, config, count: () => connectionCount }
+}
+
+describe('HTTP/SSE realtime API', () => {
+  it('enforces CSRF and returns upstream-confirmed receipts', async () => {
+    const f = await setup()
+    const invalid = await fetch(`${f.origin}/api/realtime/channels`, { method: 'POST', headers: { ...f.headers, 'X-CSRF-Token': 'bad' }, body: '{"channel":"chat"}' })
+    expect(invalid.status).toBe(403)
+    const c = await f.create()
+    const result = await f.command(c, 'resume', 'session.resume', { session_id: 'stored', profile: 'default' })
+    expect(result.receipt).toMatchObject({ state: 'confirmed', response: { result: { session_id: 'runtime' } } })
+    const forbidden = await f.command(c, 'forbidden', 'terminal.resize', { session_id: 'runtime' })
+    expect(forbidden.response.status).toBe(403)
+    expect(f.commands).toHaveLength(1)
+  })
+  it('allows existing attachment sizes only on the dedicated command endpoint', async () => {
+    const f = await setup(), c = await f.create()
+    await f.command(c, 'resume', 'session.resume', { session_id: 'stored', profile: 'default' })
+    const attached = await f.command(c, 'attachment', 'image.attach_bytes', { session_id: 'runtime', content_base64: 'A'.repeat(3 * 1024 * 1024), filename: 'fixture.png' })
+    expect(attached.response.status).toBe(200)
+    expect(attached.receipt.state).toBe('confirmed')
+    const other = await fetch(`${f.origin}/api/app/sessions`, { method: 'POST', headers: f.headers, body: JSON.stringify({ data: 'A'.repeat(3 * 1024 * 1024) }) })
+    expect(other.status).toBe(413)
+  })
+
+  it('shares Web/iOS subscriptions, replays after detach, and measures stream latency', async () => {
+    const f = await setup(), a = await f.create(), b = await f.create()
+    await f.command(a, 'resume-a', 'session.resume', { session_id: 'stored', profile: 'default' })
+    await f.command(b, 'resume-b', 'session.resume', { session_id: 'stored', profile: 'default' })
+    const controller = new AbortController()
+    const response = await fetch(`${f.origin}/api/realtime/channels/${a}/events`, { headers: f.headers, signal: controller.signal })
+    expect(response.headers.get('content-type')).toContain('text/event-stream')
+    expect(response.headers.get('x-accel-buffering')).toBe('no')
+    const reader = response.body!.getReader(), decoder = new TextDecoder(), parser = new SSEParser()
+    const initial = []
+    while (!initial.some(x => x.event === 'frame')) initial.push(...parser.feed(decoder.decode((await reader.read()).value, { stream: true })))
+    const cursor = initial.find(x => x.event === 'frame')!.id!
+    controller.abort(); await reader.cancel().catch(() => {})
+    f.emit('while-offline')
+    await new Promise(r => setTimeout(r, 30))
+    const resumedController = new AbortController()
+    const resumed = await fetch(`${f.origin}/api/realtime/channels/${a}/events`, { headers: { ...f.headers, 'Last-Event-ID': cursor }, signal: resumedController.signal })
+    const resumedReader = resumed.body!.getReader(), resumedParser = new SSEParser(), resumedDecoder = new TextDecoder()
+    const entries = []
+    while (!entries.some(x => x.event === 'frame')) entries.push(...resumedParser.feed(resumedDecoder.decode((await resumedReader.read()).value, { stream: true })))
+    expect(entries.filter(e => e.event === 'frame').map(e => JSON.parse(e.data).params.payload.text)).toEqual(['while-offline'])
+    expect(f.count()).toBe(1)
+    const latencies: number[] = []
+    const before = process.memoryUsage().rss, cpu = process.cpuUsage()
+    const consume = (async () => {
+      while (latencies.length < 200) {
+        const chunk = await resumedReader.read()
+        for (const e of resumedParser.feed(resumedDecoder.decode(chunk.value, { stream: true }))) {
+          if (e.event === 'frame') latencies.push(performance.now() - JSON.parse(e.data).params.payload.emitted)
+        }
+      }
+    })()
+    for (let i = 0; i < 200; i++) { f.emit(`token-${i}`); await new Promise<void>(r => setImmediate(r)) }
+    await consume
+    latencies.sort((x, y) => x - y)
+    console.info('SSE fixture performance', JSON.stringify({ samples: latencies.length, p95Ms: latencies[189], p99Ms: latencies[197], rssDelta: process.memoryUsage().rss - before, cpuMicros: process.cpuUsage(cpu) }))
+    expect(latencies).toHaveLength(200)
+    resumedController.abort(); await resumedReader.cancel().catch(() => {})
+  })
+  it('keeps an admitted run connected after the only SSE client is offline for over twenty seconds', async () => {
+    const f = await setup(), c = await f.create()
+    await f.command(c, 'open', 'session.resume', { session_id: 'stored', profile: 'default' })
+    await f.command(c, 'send', 'prompt.submit', { session_id: 'runtime', text: 'long-running fixture' })
+    const controller = new AbortController()
+    const response = await fetch(`${f.origin}/api/realtime/channels/${c}/events`, { headers: f.headers, signal: controller.signal })
+    const reader = response.body!.getReader()
+    await reader.read(); controller.abort(); await reader.cancel().catch(() => {})
+    await new Promise(r => setTimeout(r, 21_000))
+    expect(f.count()).toBe(1)
+    f.emit('finished while offline')
+    const reconnect = new AbortController()
+    const again = await fetch(`${f.origin}/api/realtime/channels/${c}/events`, { headers: f.headers, signal: reconnect.signal })
+    const input = again.body!.getReader(), decoder = new TextDecoder()
+    let text = ''
+    while (!text.includes('finished while offline')) text += decoder.decode((await input.read()).value, { stream: true })
+    expect(text).toContain('finished while offline')
+    reconnect.abort(); await input.cancel().catch(() => {})
+  }, 30_000)
+})

+ 164 - 0
tests/server/realtimeBroker.test.ts

@@ -0,0 +1,164 @@
+import { mkdtempSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { once } from 'node:events'
+import { WebSocketServer, type WebSocket } from 'ws'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { RealtimeBroker, type RealtimePrincipal, type StreamEntry } from '../../src/server/realtimeBroker.js'
+import { RealtimeReceipts } from '../../src/server/realtimeReceipts.js'
+
+const cleanup: Array<() => void> = []
+afterEach(() => cleanup.splice(0).reverse().forEach(f => f()))
+async function fixture() {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-realtime-test-'))
+  const ws = new WebSocketServer({ port: 0, host: '127.0.0.1' })
+  await once(ws, 'listening')
+  cleanup.push(() => { for (const c of ws.clients) c.terminate(); ws.close() })
+  const commands: any[] = []
+  let count = 0
+  let seq = 0, truncate = false, dropPrompt = false
+  const history: any[] = []
+  let peer: WebSocket
+  ws.on('connection', socket => {
+    peer = socket; count++
+    socket.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: { replay_epoch: 'test' } } }))
+    socket.on('message', raw => {
+      const f = JSON.parse(raw.toString()); commands.push(f)
+      if (f.method === 'prompt.submit' && dropPrompt) { socket.terminate(); return }
+      if (f.method === 'session.events.since') {
+        socket.send(JSON.stringify({ id: f.id, result: { epoch: 'test', truncated: truncate,
+          events: history.filter(e => e.seq > f.params.last_seen) } }))
+      } else if (f.method === 'session.branch') {
+        socket.send(JSON.stringify({ id: f.id, result: { session_id: 'runtime-branch', stored_session_id: 'stored-branch' } }))
+      } else if (f.method === 'session.create' || f.method === 'session.resume') {
+        socket.send(JSON.stringify({ id: f.id, result: { session_id: 'runtime-1', stored_session_id: 'stored-1', running: false } }))
+      } else {
+        setTimeout(() => socket.send(JSON.stringify({ id: f.id, result: { status: 'submitted' } })), 20)
+      }
+    })
+  })
+  let now = Date.now()
+  const broker = new RealtimeBroker(home, () => now)
+  cleanup.push(() => broker.close())
+  const principal = (key: string): RealtimePrincipal => ({ key, upstreamKey: 'one-service', paired: false,
+    valid: () => true, url: async () => new URL(`ws://127.0.0.1:${(ws.address() as any).port}`) })
+  return { home, broker, principal, commands, count: () => count, advance: (n: number) => { now += n },
+    disconnect: () => peer!.terminate(), truncate: () => { truncate = true }, dropPrompt: () => { dropPrompt = true },
+    emit: (type: string, payload: unknown) => {
+      const params = { type, session_id: 'runtime-1', seq: ++seq, payload }; history.push(params)
+      if (peer!.readyState === 1) peer!.send(JSON.stringify({ method: 'event', params }))
+    } }
+}
+const resume = { method: 'session.resume', params: { session_id: 'stored-1', profile: 'default' } }
+describe('realtime broker', () => {
+  it('shares upstream across subscribers and isolates unregistered routes', async () => {
+    const f = await fixture()
+    const a = await f.broker.create(f.principal('alice'), 'chat')
+    const b = await f.broker.create(f.principal('bob'), 'chat')
+    const c = await f.broker.create(f.principal('alice'), 'chat')
+    await f.broker.command(a, 'resume-a', resume)
+    await f.broker.command(c, 'resume-c', resume)
+    const seenA: StreamEntry[] = [], seenB: StreamEntry[] = [], seenC: StreamEntry[] = []
+    f.broker.subscribe(a, undefined, x => seenA.push(x)); f.broker.subscribe(b, undefined, x => seenB.push(x)); f.broker.subscribe(c, undefined, x => seenC.push(x))
+    f.emit('message.delta', { text: '你好' })
+    await new Promise(r => setTimeout(r, 30))
+    expect(f.count()).toBe(1)
+    expect(seenA.some(x => x.data.includes('你好'))).toBe(true)
+    expect(seenC.some(x => x.data.includes('你好'))).toBe(true)
+    expect(seenB.some(x => x.data.includes('你好'))).toBe(false)
+    expect(() => f.broker.get(f.principal('bob'), a.id)).toThrow('Channel not found')
+  })
+  it('deduplicates simultaneous commands and rejects a changed payload', async () => {
+    const f = await fixture(), p = f.principal('alice')
+    const c = await f.broker.create(p, 'chat')
+    await f.broker.command(c, 'open', resume)
+    const prompt = { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'secret prompt' } }
+    const [one, two] = await Promise.all([f.broker.command(c, 'outbox-1', prompt), f.broker.command(c, 'outbox-1', prompt)])
+    expect(one).toEqual(two); expect(one.state).toBe('confirmed')
+    expect(f.commands.filter(x => x.method === 'prompt.submit')).toHaveLength(1)
+    expect(() => f.broker.command(c, 'outbox-1', { ...prompt, params: { ...prompt.params, text: 'different' } })).toThrow('conflict')
+    expect(() => f.broker.receipt(f.principal('bob'), 'outbox-1')).toThrow('Receipt not found')
+    for (const file of ['realtime-receipts.sqlite3', 'realtime-receipts.sqlite3-wal']) expect(readFileSync(join(f.home, file)).includes(Buffer.from('secret prompt'))).toBe(false)
+  })
+  it('keeps upstream alive after downstream detach and replays exact cursor', async () => {
+    const f = await fixture(), p = f.principal('alice')
+    const c = await f.broker.create(p, 'chat')
+    await f.broker.command(c, 'open', resume)
+    const entries: StreamEntry[] = []
+    const off = f.broker.subscribe(c, undefined, x => entries.push(x))
+    const cursor = entries.at(-1)!.id
+    off(); f.emit('message.start', {}); f.emit('message.delta', { text: 'background' })
+    await new Promise(r => setTimeout(r, 30))
+    const replay: StreamEntry[] = []
+    f.broker.subscribe(c, cursor, x => replay.push(x))
+    expect(replay).toHaveLength(2); expect(f.count()).toBe(1)
+    f.advance(600_001)
+    expect(() => f.broker.subscribe(c, cursor, () => {})).toThrow('Replay window expired')
+  })
+  it('does not execute runtime commands without a route subscription', async () => {
+    const f = await fixture()
+    const c = await f.broker.create(f.principal('alice'), 'chat')
+    const result = await f.broker.command(c, 'forbidden', { method: 'session.interrupt', params: { session_id: 'guessed' } })
+    expect(result.state).toBe('rejected'); expect(f.commands).toHaveLength(0)
+  })
+  it('marks write-ahead admissions unknown after a process restart', () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-receipts-test-'))
+    const a = new RealtimeReceipts(home)
+    a.reserve('alice', 'submitted', '{"text":"private"}'); a.close()
+    const b = new RealtimeReceipts(home)
+    expect(b.reserve('alice', 'submitted', '{"text":"private"}')).toMatchObject({ state: 'unknown' })
+    b.close()
+  })
+  it('registers branch identities without losing the original subscription', async () => {
+    const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
+    await f.broker.command(c, 'open', resume)
+    const branch = await f.broker.command(c, 'branch', { method: 'session.branch', params: { session_id: 'runtime-1' } })
+    expect(branch.state).toBe('confirmed')
+    const sent = await f.broker.command(c, 'branched-prompt', { method: 'prompt.submit', params: { session_id: 'runtime-branch', text: 'branch' } })
+    expect(sent.state).toBe('confirmed')
+    expect(c.routes.size).toBe(2)
+  })
+  it('does not let guessed approval IDs escape a subscribed session', async () => {
+    const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
+    await f.broker.command(c, 'open', resume)
+    const result = await f.broker.command(c, 'approval', { method: 'approval.respond', params: { session_id: 'runtime-1', request_id: 'another-users-approval', approved: true } })
+    expect(result).toMatchObject({ state: 'rejected', response: { error: { code: 'interaction_forbidden' } } })
+    expect(f.commands.filter(x => x.method === 'approval.respond')).toHaveLength(0)
+  })
+  it('page reloads cannot exhaust live-stream capacity with detached handles', async () => {
+    const f = await fixture(), p = f.principal('alice')
+    for (let i = 0; i < 40; i++) await f.broker.create(p, 'chat')
+    expect(f.broker.channels.size).toBe(32)
+    expect(f.count()).toBe(1)
+  })
+  it('recovers the upstream replay ring without losing offline deltas', async () => {
+    const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
+    await f.broker.command(c, 'open', resume)
+    const received: StreamEntry[] = []
+    f.broker.subscribe(c, undefined, e => received.push(e))
+    f.emit('message.delta', { text: 'before' })
+    await vi.waitFor(() => expect(received.some(e => e.data.includes('before'))).toBe(true))
+    f.disconnect(); f.emit('message.delta', { text: 'offline' })
+    await vi.waitFor(() => expect(received.filter(e => e.data.includes('offline'))).toHaveLength(1), { timeout: 2500 })
+    expect(f.count()).toBe(2)
+    expect(received.filter(e => e.event === 'reset')).toHaveLength(0)
+  })
+  it('reports reset when upstream history was truncated', async () => {
+    const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
+    await f.broker.command(c, 'open', resume)
+    const received: StreamEntry[] = []
+    f.broker.subscribe(c, undefined, e => received.push(e))
+    f.truncate(); f.disconnect()
+    await vi.waitFor(() => expect(received.some(e => e.event === 'reset')).toBe(true), { timeout: 2500 })
+  })
+  it('never repeats a prompt when the upstream acceptance reply is lost', async () => {
+    const f = await fixture(), c = await f.broker.create(f.principal('alice'), 'chat')
+    await f.broker.command(c, 'open', resume)
+    f.dropPrompt()
+    const prompt = { method: 'prompt.submit', params: { session_id: 'runtime-1', text: 'do once' } }
+    const result = await f.broker.command(c, 'lost-ack', prompt)
+    expect(result.state).toBe('unknown')
+    expect((await f.broker.command(c, 'lost-ack', prompt)).state).toBe('unknown')
+    expect(f.commands.filter(f => f.method === 'prompt.submit')).toHaveLength(1)
+  })
+})

+ 216 - 0
tests/server/realtimeMigration.test.ts

@@ -0,0 +1,216 @@
+import { createServer, type Server } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import WebSocket, { WebSocketServer } from 'ws'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createNodeServer, type NodeServerRuntime } from '../../src/server/app.js'
+import type { ServerConfig } from '../../src/server/config.js'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { createAuthenticatedApplication as createApplication } from './authenticatedApplication.js'
+
+const closers: Array<() => Promise<void>> = []
+const homes: string[] = []
+
+afterEach(async () => {
+  for (const close of closers.splice(0).reverse()) await close()
+  for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
+})
+
+function listen(server: Server): Promise<number> {
+  return new Promise((resolve, reject) => {
+    server.once('error', reject)
+    server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
+  })
+}
+
+function closeServer(server: Server): Promise<void> {
+  return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
+}
+
+function cookies(response: Response): string {
+  return response.headers.getSetCookie().map((value) => value.split(';', 1)[0]).join('; ')
+}
+
+function closeRuntime(runtime: NodeServerRuntime): () => Promise<void> {
+  return () => runtime.close()
+}
+
+describe('HTTP+SSE migration', () => {
+  it('lets a QR-paired iOS node use authenticated HTTP+SSE and rejects old tickets', async () => {
+    let pairedCookie = ''
+    const pairedFrames: Array<Record<string, unknown>> = []
+    const upstream = createServer((request, response) => {
+      response.setHeader('Content-Type', 'application/json')
+      if (request.url === '/api/status') {
+        pairedCookie = request.headers.cookie ?? pairedCookie
+        response.setHeader('Set-Cookie', 'hermes_session_at=paired-user; Path=/; HttpOnly')
+        response.end(JSON.stringify({ auth_required: true }))
+      } else if (request.url === '/api/auth/me') {
+        pairedCookie = request.headers.cookie ?? pairedCookie
+        response.end(JSON.stringify({ user_id: 'paired-user', display_name: 'Paired User' }))
+      } else if (request.url === '/api/profiles') {
+        pairedCookie = request.headers.cookie ?? pairedCookie
+        response.end(JSON.stringify({ profiles: [{ name: 'default', is_default: true }] }))
+      } else if (request.url === '/api/plugins/yaoyao/profiles') {
+        response.end(JSON.stringify({ profiles: [{ name: 'default', botName: '竹儿', agentName: '旧插件名称' }] }))
+      } else if (request.url === '/api/auth/providers') {
+        response.end(JSON.stringify({ providers: [{ name: 'basic', supports_password: true }] }))
+      } else if (request.url === '/auth/password-login' && request.method === 'POST') {
+        response.setHeader('Set-Cookie', 'hermes_session_rt=paired-refresh; Path=/; HttpOnly')
+        response.end(JSON.stringify({ ok: true }))
+      } else if (request.url === '/api/auth/ws-ticket' && request.method === 'POST') {
+        pairedCookie = request.headers.cookie ?? pairedCookie
+        response.end(JSON.stringify({ ticket: 'paired-upstream-ticket' }))
+      } else {
+        response.statusCode = 404
+        response.end(JSON.stringify({ error: 'not found' }))
+      }
+    })
+    const upstreamSockets = new WebSocketServer({ noServer: true })
+    upstream.on('upgrade', (request, socket, head) => {
+      const url = new URL(request.url ?? '/', 'http://127.0.0.1')
+      if (url.pathname !== '/api/ws' || url.searchParams.get('ticket') !== 'paired-upstream-ticket') {
+        socket.destroy()
+        return
+      }
+      upstreamSockets.handleUpgrade(request, socket, head, client => {
+        client.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: { paired: true } } }))
+        client.on('message', data => {
+          const frame = JSON.parse(data.toString()) as Record<string, unknown>
+          pairedFrames.push(frame)
+          client.send(JSON.stringify({ id: frame.id, result: frame.method === 'session.create' ? { session_id: 'runtime-test', stored_session_id: 'stored-test' } : {} }))
+        })
+      })
+    })
+    const upstreamPort = await listen(upstream)
+    closers.push(async () => {
+      for (const client of upstreamSockets.clients) client.terminate()
+      upstreamSockets.close()
+      await closeServer(upstream)
+    })
+
+    const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-paired-ws-'))
+    homes.push(home)
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800,
+      upstream: new URL(`http://127.0.0.1:${upstreamPort}`),
+      allowedHosts: new Set(), home,
+      mediaRoot: join(home, 'media'), attachmentsRoot: join(home, 'attachments'), imagesRoot: join(home, 'images'), mediaOwner: 'tester',
+      allowInsecureLan: false, insecureLan: false, production: false,
+    }
+    const application = createApplication({ config })
+    const node = createNodeServer(application)
+    await listen(node.server)
+    closers.push(closeRuntime(node))
+    const port = (node.server.address() as AddressInfo).port
+    config.port = port
+    const origin = `http://127.0.0.1:${port}`
+
+    const bootstrap = await fetch(`${origin}/api/app/bootstrap`)
+    const bootstrapBody = await bootstrap.json() as { csrfToken: string }
+    const cookie = cookies(bootstrap)
+    const pairingResponse = await fetch(`${origin}/api/app/pairings`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json', Cookie: cookie, Origin: origin,
+        'X-CSRF-Token': bootstrapBody.csrfToken,
+      },
+      body: JSON.stringify({
+        username: 'paired-user', password: 'paired-password',
+      }),
+    })
+    expect(pairingResponse.status).toBe(201)
+    const pairing = await pairingResponse.json() as { pairingId: string; qrPayload: string }
+    const qr = new URL(pairing.qrPayload)
+    const claimResponse = await fetch(`${origin}/api/pair/v1/claim`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        pairingId: pairing.pairingId,
+        secret: qr.searchParams.get('secret'),
+        deviceName: 'Test iPhone',
+      }),
+    })
+    expect(claimResponse.status).toBe(201)
+    const claim = await claimResponse.json() as { serverUrl: string; token: string; deviceId: string }
+    const authorization = `Bearer ${claim.token}`
+
+    const status = await fetch(`${claim.serverUrl}/api/status`, {
+      headers: { Authorization: authorization },
+    })
+    expect(status.status).toBe(200)
+    expect((await status.json() as { auth_required: boolean }).auth_required).toBe(true)
+    const retired = await fetch(`${claim.serverUrl}/api/auth/ws-ticket`, {
+      method: 'POST', headers: { Authorization: authorization },
+    })
+    expect(retired.status).toBe(410)
+    const capabilities = await fetch(`${claim.serverUrl}/api/realtime/capabilities`, { headers: { Authorization: authorization } })
+    expect(await capabilities.json()).toMatchObject({ protocolVersion: 1 })
+    const opened = await fetch(`${claim.serverUrl}/api/realtime/channels`, {
+      method: 'POST', headers: { Authorization: authorization, 'Content-Type': 'application/json' },
+      body: JSON.stringify({ channel: 'chat' }),
+    })
+    expect(opened.status).toBe(201)
+    const channel = await opened.json() as { id: string }
+    const stream = await fetch(`${claim.serverUrl}/api/realtime/channels/${channel.id}/events`, {
+      headers: { Authorization: authorization, Accept: 'text/event-stream' },
+    })
+    expect(stream.headers.get('content-type')).toContain('text/event-stream')
+    await stream.body!.cancel()
+    const send = async (id: string, method: string, params: object) => {
+      const response = await fetch(`${claim.serverUrl}/api/realtime/channels/${channel.id}/commands`, {
+        method: 'POST', headers: { Authorization: authorization, 'Content-Type': 'application/json', 'Idempotency-Key': id },
+        body: JSON.stringify({ method, params }),
+      })
+      expect(response.status).toBe(200)
+      expect(await response.json()).toMatchObject({ state: 'confirmed' })
+    }
+    await send('profiles', 'profiles.list', { include_sessions: true })
+    await send('profile-meta', 'profiles.configure', { name: 'default', ui_meta: { 'hermes-bots': { chat: 'stored-session' } } })
+    await send('profile-avatar', 'profiles.set_asset', { name: 'default', asset: 'avatar', data: 'data:image/png;base64,AA==' })
+    await send('hidden-bot', 'session.create', { profile: 'default', title: 'Bot Chat', source: 'ios_bot_group', hidden: true })
+    expect(pairedFrames.map(frame => frame.method)).toEqual(['profiles.list', 'profiles.configure', 'profiles.set_asset', 'session.create'])
+    expect(pairedFrames[3]).toMatchObject({ params: { profile: 'default', source: 'ios_bot_group', hidden: true } })
+    const revoke = await fetch(`${origin}/api/pair/v1/devices/${claim.deviceId}`, {
+      method: 'DELETE', headers: { Authorization: authorization },
+    })
+    expect(revoke.status).toBe(200)
+    expect(await fetch(`${claim.serverUrl}/api/status`, {
+      headers: { Authorization: authorization },
+    })).toHaveProperty('status', 401)
+  })
+
+  it('rejects client WebSocket upgrades instead of keeping legacy compatibility', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-ws-'))
+    homes.push(home)
+    const config: ServerConfig = {
+      host: '127.0.0.1',
+      port: 8800,
+      upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(),
+      home,
+      allowInsecureLan: false,
+      insecureLan: false,
+      production: true,
+    }
+    const application = createApplication({ config })
+    const node = createNodeServer(application)
+    await listen(node.server)
+    closers.push(closeRuntime(node))
+    const port = (node.server.address() as AddressInfo).port
+    config.port = port
+    const status = await new Promise<number>((resolve, reject) => {
+      const socket = new WebSocket(`ws://127.0.0.1:${port}/vite-hmr`, {
+        origin: `http://127.0.0.1:${port}`,
+      })
+      socket.once('unexpected-response', (_request, response) => {
+        resolve(response.statusCode ?? 0)
+        response.resume()
+      })
+      socket.once('error', reject)
+    })
+    expect(status).toBe(410)
+  })
+})
+

+ 0 - 404
tests/server/websocket.test.ts

@@ -1,404 +0,0 @@
-import { createServer, type Server } from 'node:http'
-import type { AddressInfo } from 'node:net'
-import WebSocket, { WebSocketServer } from 'ws'
-import { afterEach, describe, expect, it } from 'vitest'
-import { createNodeServer, type NodeServerRuntime } from '../../src/server/app.js'
-import type { ServerConfig } from '../../src/server/config.js'
-import { mkdtempSync, rmSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { createAuthenticatedApplication as createApplication } from './authenticatedApplication.js'
-
-const closers: Array<() => Promise<void>> = []
-const homes: string[] = []
-
-afterEach(async () => {
-  for (const close of closers.splice(0).reverse()) await close()
-  for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
-})
-
-function listen(server: Server): Promise<number> {
-  return new Promise((resolve, reject) => {
-    server.once('error', reject)
-    server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
-  })
-}
-
-function closeServer(server: Server): Promise<void> {
-  return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
-}
-
-function cookies(response: Response): string {
-  return response.headers.getSetCookie().map((value) => value.split(';', 1)[0]).join('; ')
-}
-
-function wsOpen(url: string, origin: string, cookie: string): Promise<WebSocket> {
-  return new Promise((resolve, reject) => {
-    const socket = new WebSocket(url, { origin, headers: { Cookie: cookie } })
-    socket.once('open', () => resolve(socket))
-    socket.once('error', reject)
-  })
-}
-
-function wsMessage(socket: WebSocket): Promise<Record<string, unknown>> {
-  return new Promise((resolve, reject) => {
-    socket.once('message', (data) => {
-      try {
-        resolve(JSON.parse(data.toString()) as Record<string, unknown>)
-      } catch (error) {
-        reject(error)
-      }
-    })
-    socket.once('error', reject)
-  })
-}
-
-function closeRuntime(runtime: NodeServerRuntime): () => Promise<void> {
-  return () => runtime.close()
-}
-
-describe('WebSocket relay', () => {
-  it('lets a QR-paired iOS node use Hermes-compatible REST and WebSocket paths', async () => {
-    let pairedCookie = ''
-    const pairedFrames: Array<Record<string, unknown>> = []
-    const upstream = createServer((request, response) => {
-      response.setHeader('Content-Type', 'application/json')
-      if (request.url === '/api/status') {
-        pairedCookie = request.headers.cookie ?? pairedCookie
-        response.setHeader('Set-Cookie', 'hermes_session_at=paired-user; Path=/; HttpOnly')
-        response.end(JSON.stringify({ auth_required: true }))
-      } else if (request.url === '/api/auth/me') {
-        pairedCookie = request.headers.cookie ?? pairedCookie
-        response.end(JSON.stringify({ user_id: 'paired-user', display_name: 'Paired User' }))
-      } else if (request.url === '/api/profiles') {
-        pairedCookie = request.headers.cookie ?? pairedCookie
-        response.end(JSON.stringify({ profiles: [{ name: 'default', is_default: true }] }))
-      } else if (request.url === '/api/plugins/yaoyao/profiles') {
-        response.end(JSON.stringify({ profiles: [{ name: 'default', botName: '竹儿', agentName: '旧插件名称' }] }))
-      } else if (request.url === '/api/auth/providers') {
-        response.end(JSON.stringify({ providers: [{ name: 'basic', supports_password: true }] }))
-      } else if (request.url === '/auth/password-login' && request.method === 'POST') {
-        response.setHeader('Set-Cookie', 'hermes_session_rt=paired-refresh; Path=/; HttpOnly')
-        response.end(JSON.stringify({ ok: true }))
-      } else if (request.url === '/api/auth/ws-ticket' && request.method === 'POST') {
-        pairedCookie = request.headers.cookie ?? pairedCookie
-        response.end(JSON.stringify({ ticket: 'paired-upstream-ticket' }))
-      } else {
-        response.statusCode = 404
-        response.end(JSON.stringify({ error: 'not found' }))
-      }
-    })
-    const upstreamSockets = new WebSocketServer({ noServer: true })
-    upstream.on('upgrade', (request, socket, head) => {
-      const url = new URL(request.url ?? '/', 'http://127.0.0.1')
-      if (url.pathname !== '/api/ws' || url.searchParams.get('ticket') !== 'paired-upstream-ticket') {
-        socket.destroy()
-        return
-      }
-      upstreamSockets.handleUpgrade(request, socket, head, client => {
-        client.send(JSON.stringify({ method: 'gateway.ready', params: { paired: true } }))
-        client.on('message', data => {
-          const frame = JSON.parse(data.toString()) as Record<string, unknown>
-          pairedFrames.push(frame)
-          client.send(JSON.stringify({ id: frame.id, result: {} }))
-        })
-      })
-    })
-    const upstreamPort = await listen(upstream)
-    closers.push(async () => {
-      for (const client of upstreamSockets.clients) client.terminate()
-      upstreamSockets.close()
-      await closeServer(upstream)
-    })
-
-    const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-paired-ws-'))
-    homes.push(home)
-    const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800,
-      upstream: new URL(`http://127.0.0.1:${upstreamPort}`),
-      allowedHosts: new Set(), home,
-      mediaRoot: join(home, 'media'), attachmentsRoot: join(home, 'attachments'), imagesRoot: join(home, 'images'), mediaOwner: 'tester',
-      allowInsecureLan: false, insecureLan: false, production: false,
-    }
-    const application = createApplication({ config })
-    const node = createNodeServer(application)
-    await listen(node.server)
-    closers.push(closeRuntime(node))
-    const port = (node.server.address() as AddressInfo).port
-    config.port = port
-    const origin = `http://127.0.0.1:${port}`
-
-    const bootstrap = await fetch(`${origin}/api/app/bootstrap`)
-    const bootstrapBody = await bootstrap.json() as { csrfToken: string }
-    const cookie = cookies(bootstrap)
-    const pairingResponse = await fetch(`${origin}/api/app/pairings`, {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json', Cookie: cookie, Origin: origin,
-        'X-CSRF-Token': bootstrapBody.csrfToken,
-      },
-      body: JSON.stringify({
-        username: 'paired-user', password: 'paired-password',
-      }),
-    })
-    expect(pairingResponse.status).toBe(201)
-    const pairing = await pairingResponse.json() as { pairingId: string; qrPayload: string }
-    const qr = new URL(pairing.qrPayload)
-    const claimResponse = await fetch(`${origin}/api/pair/v1/claim`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        pairingId: pairing.pairingId,
-        secret: qr.searchParams.get('secret'),
-        deviceName: 'Test iPhone',
-      }),
-    })
-    expect(claimResponse.status).toBe(201)
-    const claim = await claimResponse.json() as { serverUrl: string; token: string; deviceId: string }
-    const authorization = `Bearer ${claim.token}`
-
-    const status = await fetch(`${claim.serverUrl}/api/status`, {
-      headers: { Authorization: authorization },
-    })
-    expect(status.status).toBe(200)
-    expect((await status.json() as { auth_required: boolean }).auth_required).toBe(true)
-    const ticketResponse = await fetch(`${claim.serverUrl}/api/auth/ws-ticket`, {
-      method: 'POST', headers: { Authorization: authorization },
-    })
-    expect(ticketResponse.status).toBe(200)
-    const ticket = (await ticketResponse.json() as { ticket: string }).ticket
-    expect(pairedCookie).toContain('hermes_session_at=paired-user')
-
-    const socket = await wsOpen(
-      `${claim.serverUrl.replace(/^http/, 'ws')}/api/ws?ticket=${encodeURIComponent(ticket)}`,
-      origin,
-      '',
-    )
-    expect(await wsMessage(socket)).toMatchObject({
-      method: 'gateway.ready', params: { paired: true },
-    })
-    socket.send(JSON.stringify({
-      id: 'profiles', method: 'profiles.list',
-      params: { include_sessions: true },
-    }))
-    await wsMessage(socket)
-    socket.send(JSON.stringify({
-      id: 'profile-meta', method: 'profiles.configure',
-      params: {
-        name: 'default',
-        ui_meta: { 'hermes-bots': { chat: 'stored-session' } },
-        ui_meta_expected_revisions: { 'hermes-bots': 2 },
-      },
-    }))
-    await wsMessage(socket)
-    socket.send(JSON.stringify({
-      id: 'profile-avatar', method: 'profiles.set_asset',
-      params: { name: 'default', asset: 'avatar', data: 'data:image/png;base64,AA==' },
-    }))
-    await wsMessage(socket)
-    socket.send(JSON.stringify({
-      id: 'hidden-bot', method: 'session.create',
-      params: {
-        profile: 'default', title: 'Bot Chat', source: 'ios_bot_group',
-        hidden: true, close_on_disconnect: false,
-      },
-    }))
-    await wsMessage(socket)
-    expect(pairedFrames[0]).toEqual({
-      id: 'profiles', method: 'profiles.list', params: { include_sessions: true },
-    })
-    expect(pairedFrames[1]).toMatchObject({
-      id: 'profile-meta', method: 'profiles.configure',
-      params: { name: 'default' },
-    })
-    expect(pairedFrames[2]).toEqual({
-      id: 'profile-avatar', method: 'profiles.set_asset',
-      params: { name: 'default', asset: 'avatar', data: 'data:image/png;base64,AA==' },
-    })
-    expect(pairedFrames[3]).toMatchObject({
-      id: 'hidden-bot', method: 'session.create',
-      params: { profile: 'default', source: 'ios_bot_group', hidden: true },
-    })
-    socket.close()
-
-    const revoke = await fetch(`${origin}/api/pair/v1/devices/${claim.deviceId}`, {
-      method: 'DELETE', headers: { Authorization: authorization },
-    })
-    expect(revoke.status).toBe(200)
-    expect(await fetch(`${claim.serverUrl}/api/status`, {
-      headers: { Authorization: authorization },
-    })).toHaveProperty('status', 401)
-  })
-
-  it('keeps the upstream ticket server-side and filters chat RPC methods', async () => {
-    const received: Array<Record<string, unknown>> = []
-    const upstream = createServer((request, response) => {
-      response.setHeader('Content-Type', 'application/json')
-      if (request.url === '/api/status') {
-        response.setHeader('Set-Cookie', 'hermes_session_at=socket-user; Path=/; HttpOnly')
-        response.end(JSON.stringify({ auth_required: true }))
-      } else if (request.url === '/api/auth/me') {
-        response.end(JSON.stringify({ user_id: 'socket-user', display_name: 'Socket User' }))
-      } else if (request.url === '/api/profiles') {
-        response.end(JSON.stringify({ profiles: [{ name: 'default', is_default: true }] }))
-      } else if (request.url === '/api/auth/ws-ticket' && request.method === 'POST') {
-        response.end(JSON.stringify({ ticket: 'one-time-upstream-secret' }))
-      } else {
-        response.statusCode = 404
-        response.end(JSON.stringify({ error: 'not found' }))
-      }
-    })
-    const upstreamSockets = new WebSocketServer({ noServer: true })
-    upstream.on('upgrade', (request, socket, head) => {
-      const url = new URL(request.url ?? '/', 'http://127.0.0.1')
-      if (url.pathname !== '/api/ws' || url.searchParams.get('ticket') !== 'one-time-upstream-secret') {
-        socket.destroy()
-        return
-      }
-      upstreamSockets.handleUpgrade(request, socket, head, (client) => {
-        client.send(JSON.stringify({ method: 'gateway.ready', params: {} }))
-        client.on('message', (data) => {
-          received.push(JSON.parse(data.toString()) as Record<string, unknown>)
-          client.send(JSON.stringify({ id: 'ok', result: {} }))
-        })
-      })
-    })
-    const upstreamPort = await listen(upstream)
-    closers.push(async () => {
-      for (const client of upstreamSockets.clients) client.terminate()
-      upstreamSockets.close()
-      await closeServer(upstream)
-    })
-
-    const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-ws-'))
-    homes.push(home)
-    const config: ServerConfig = {
-      host: '127.0.0.1',
-      port: 8800,
-      upstream: new URL(`http://127.0.0.1:${upstreamPort}`),
-      allowedHosts: new Set(),
-      home,
-      allowInsecureLan: false,
-      insecureLan: false,
-      production: false,
-    }
-    const application = createApplication({ config })
-    const node = createNodeServer(application)
-    await listen(node.server)
-    closers.push(closeRuntime(node))
-    const port = (node.server.address() as AddressInfo).port
-    config.port = port
-    const origin = `http://127.0.0.1:${port}`
-
-    const bootstrap = await fetch(`${origin}/api/app/bootstrap`)
-    expect(bootstrap.status).toBe(200)
-    const bootstrapBody = await bootstrap.json() as { csrfToken: string }
-    const cookie = cookies(bootstrap)
-    const leaseResponse = await fetch(`${origin}/api/app/realtime-leases`, {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-        Cookie: cookie,
-        Origin: origin,
-        'X-CSRF-Token': bootstrapBody.csrfToken,
-      },
-      body: JSON.stringify({ channel: 'chat' }),
-    })
-    expect(leaseResponse.status).toBe(201)
-    const leaseBody = await leaseResponse.json() as { lease: string }
-    expect(JSON.stringify(leaseBody)).not.toContain('upstream-secret')
-    const rotatedCookie = [cookie, cookies(leaseResponse)].filter(Boolean).join('; ')
-
-    const socket = await wsOpen(
-      `ws://127.0.0.1:${port}/ws/chat?lease=${encodeURIComponent(leaseBody.lease)}`,
-      origin,
-      rotatedCookie,
-    )
-    expect(await wsMessage(socket)).toMatchObject({ method: 'gateway.ready' })
-    socket.send(JSON.stringify({
-      id: 'resume-1',
-      method: 'session.resume',
-      params: { session_id: 'stored-session', profile: 'default', source: 'ios' },
-    }))
-    await wsMessage(socket)
-    expect(received[0]).toMatchObject({
-      method: 'session.resume',
-      params: { session_id: 'stored-session', profile: 'default', source: 'web' },
-    })
-
-    socket.send(JSON.stringify({
-      id: 'clarify-1',
-      method: 'clarify.respond',
-      params: { request_id: 'question-1', answer: '继续', },
-    }))
-    await wsMessage(socket)
-    expect(received[1]).toMatchObject({
-      method: 'clarify.respond',
-      params: { request_id: 'question-1', answer: '继续' },
-    })
-
-    socket.send(JSON.stringify({
-      id: 'queue-1',
-      method: 'prompt.submit',
-      params: { session_id: 'stored-session', text: '下一条', queued: true, ignored: 'drop-me' },
-    }))
-    await wsMessage(socket)
-    expect(received[2]).toEqual({
-      id: 'queue-1',
-      method: 'prompt.submit',
-      params: { session_id: 'stored-session', text: '下一条', queued: true },
-    })
-
-    socket.send(JSON.stringify({
-      id: 'model-with-scoped-provider',
-      method: 'config.set',
-      params: { session_id: 'stored-session', key: 'model', value: 'omni --provider Custom:Tingly --session' },
-    }))
-    await wsMessage(socket)
-    expect(received[3]).toEqual({
-      id: 'model-with-scoped-provider',
-      method: 'config.set',
-      params: { session_id: 'stored-session', key: 'model', value: 'omni --provider Custom:Tingly --session' },
-    })
-
-    socket.send(JSON.stringify({
-      id: 'forbidden',
-      method: 'config.set',
-      params: { session_id: 'stored-session', key: 'yolo', value: 'true' },
-    }))
-    const closeCode = await new Promise<number>((resolve) => socket.once('close', resolve))
-    expect(closeCode).toBe(1008)
-  })
-
-  it('rejects unknown production Upgrade requests instead of leaking a hanging socket', async () => {
-    const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-ws-'))
-    homes.push(home)
-    const config: ServerConfig = {
-      host: '127.0.0.1',
-      port: 8800,
-      upstream: new URL('http://127.0.0.1:9119'),
-      allowedHosts: new Set(),
-      home,
-      allowInsecureLan: false,
-      insecureLan: false,
-      production: true,
-    }
-    const application = createApplication({ config })
-    const node = createNodeServer(application)
-    await listen(node.server)
-    closers.push(closeRuntime(node))
-    const port = (node.server.address() as AddressInfo).port
-    config.port = port
-    const status = await new Promise<number>((resolve, reject) => {
-      const socket = new WebSocket(`ws://127.0.0.1:${port}/vite-hmr`, {
-        origin: `http://127.0.0.1:${port}`,
-      })
-      socket.once('unexpected-response', (_request, response) => {
-        resolve(response.statusCode ?? 0)
-        response.resume()
-      })
-      socket.once('error', reject)
-    })
-    expect(status).toBe(404)
-  })
-})

+ 3 - 3
tests/setup.ts

@@ -1,7 +1,7 @@
 import { afterEach } from 'vitest'
 
 afterEach(() => {
-  document.body.innerHTML = ''
-  localStorage.clear()
-  sessionStorage.clear()
+  if (typeof document !== 'undefined') document.body.innerHTML = ''
+  if (typeof localStorage !== 'undefined') localStorage.clear()
+  if (typeof sessionStorage !== 'undefined') sessionStorage.clear()
 })