diff --git a/docs/developers/_meta.ts b/docs/developers/_meta.ts index 240b767e3e..1bc5a7cb32 100644 --- a/docs/developers/_meta.ts +++ b/docs/developers/_meta.ts @@ -21,6 +21,7 @@ export default { 'channel-plugins': 'Channel Plugin Guide', tools: 'Tools', 'qwen-serve-protocol': 'qwen serve HTTP protocol', + daemon: 'Daemon 模式 · 开发者深度指南', examples: { display: 'hidden', diff --git a/docs/developers/daemon/00-index.md b/docs/developers/daemon/00-index.md new file mode 100644 index 0000000000..14ee778e81 --- /dev/null +++ b/docs/developers/daemon/00-index.md @@ -0,0 +1,149 @@ +# Daemon 开发者文档 +这是 **qwen-code daemon 模式**面向开发者的技术文档集 —— 涵盖 `qwen serve` HTTP daemon、底层的 `acp-bridge` 包、工作区粒度的 MCP transport 池、多客户端权限协调器、Typed Daemon Event Schema v1、TypeScript SDK daemon 客户端,以及所有上层适配器(CLI TUI、IM 渠道机器人、VSCode IDE 等)。 + +它是对现有文档的补充,而不是替代: + +| 现有文档 | 受众 | 仍是该主题的事实来源 | +| ------------------------------------------------------------------------------------ | ------------------ | ---------------------------------------------------------------------- | +| [`../../users/qwen-serve.md`](../../users/qwen-serve.md) | 运维 / 使用者 | 启动方式、命令行参数、威胁模型 | +| [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md) | 协议实现者 | HTTP 路由清单、请求/响应结构、错误码 | +| [`../examples/daemon-client-quickstart.md`](../examples/daemon-client-quickstart.md) | SDK 使用者 | TS 端到端示例 | +| [`../daemon-client-adapters/`](../daemon-client-adapters/) | 适配器作者(草案) | 每种客户端的设计草案 | +| [`../../design/f2-mcp-transport-pool.md`](../../design/f2-mcp-transport-pool.md) | F2 维护者 | 工作区共享 MCP transport 池设计 v2.2(32 条 review fold-in changelog) | + +如果你想 **快速把 daemon 跑起来 + 验证它工作**,直接看 [`20-quickstart-operations.md`](./20-quickstart-operations.md);如果你想 **基于 wire 协议构建一个客户端**,先看 `qwen-serve-protocol.md`;如果你想 **理解 daemon 内部如何工作、扩展它或调试它**,就读本文档集 01–19。 + +## 阅读顺序 + +按目标挑路径: + +- **想先跑起来再看原理** — 直接 `20 → 17 → 19`(快速上手 + 配置 + 调试),有问题再回来看 01 + 02。 +- **新贡献者** — 依次:`01 → 02 → 03 → 08 → 09 → 10 → 11 → 12`,覆盖系统、运行时、bridge、wire 侧基础。`20` 任意时候作为「跑起来怎么验」的副本。 +- **新增客户端适配器** — `01 → 09 → 10 → 13 → (14 / 15 / 16)`:架构、事件模式、SSE bus、SDK,再看与你最接近的适配器。 +- **修改 MCP 池 / 预算** — `01 → 03 → 05 → 06`。 +- **修改权限相关代码** — `01 → 03 → 04 → 12`。 +- **线上排查问题** — `19 → 18 → 17 → 20`。 + +## 文档清单 + +### 基础 + +- [`01-architecture.md`](./01-architecture.md) — 系统架构、进程拓扑、包关系、6 张顶层时序图。 + +### 服务端核心 + +- [`02-serve-runtime.md`](./02-serve-runtime.md) — `runQwenServe` 引导、Express 应用、中间件链、优雅退出。 +- [`03-acp-bridge.md`](./03-acp-bridge.md) — `@qwen-code/acp-bridge` 包内部、会话多路复用、channel 工厂、ACP 子进程拉起。 +- [`04-permission-mediation.md`](./04-permission-mediation.md) — `MultiClientPermissionMediator` 四种策略、N1 超时不变式、取消哨兵。 +- [`05-mcp-transport-pool.md`](./05-mcp-transport-pool.md) — F2 引入的 `McpTransportPool`、池条目、反向索引、重启、drain。 +- [`06-mcp-budget-guardrails.md`](./06-mcp-budget-guardrails.md) — `WorkspaceMcpBudget`、模式(off/warn/enforce)、滞回阈值、批量拒绝合并。 +- [`07-workspace-filesystem.md`](./07-workspace-filesystem.md) — `WorkspaceFileSystem` 沙箱、路径策略、审计、`BridgeFileSystem` 契约。 +- [`08-session-lifecycle.md`](./08-session-lifecycle.md) — 创建 / 附加 / 载入 / 恢复、`X-Qwen-Client-Id`、心跳、剔除、元数据。 +- [`09-event-schema.md`](./09-event-schema.md) — Typed Event Schema v1:29 种已知事件、payload、reducer、向前兼容。 +- [`10-event-bus.md`](./10-event-bus.md) — `EventBus`、单调 ID、环形缓冲重放、`Last-Event-ID`、慢消费者反压、`client_evicted`。 +- [`11-capabilities-versioning.md`](./11-capabilities-versioning.md) — 能力注册表、协议版本、Schema 版本、条件广播。 +- [`12-auth-security.md`](./12-auth-security.md) — Bearer 中间件、Host 白名单、CORS 拒绝、Mutation Gate、`--require-auth`、`/health` 豁免、Device Flow。 + +### 客户端 + +- [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md) — TS SDK:`DaemonClient`、`DaemonSessionClient`、`DaemonAuthFlow`、SSE 解析器、事件 reducer,以及新的 `ui/*` 子包。 +- [`14-cli-tui-adapter.md`](./14-cli-tui-adapter.md) — **共享 UI Transcript 层**(SDK `ui/*`)。原 `DaemonTuiAdapter.ts` 已在 #4328 中删除;本篇覆盖新的 transcript 归一 / reduce / selector 原语与 webui `DaemonSessionProvider` 消费方。 +- [`15-channel-adapters.md`](./15-channel-adapters.md) — `DaemonChannelBridge` 共享基座 + 钉钉、微信、Telegram 适配器。 +- [`16-vscode-ide-adapter.md`](./16-vscode-ide-adapter.md) — `DaemonIdeConnection`、Loopback 强制、Webview 桥接。 + +### 参考附录 + +- [`17-configuration.md`](./17-configuration.md) — 影响 daemon 的环境变量、命令行参数、`settings.json` 键。 +- [`18-error-taxonomy.md`](./18-error-taxonomy.md) — 各层的 typed error 与修复建议。 +- [`19-observability.md`](./19-observability.md) — `QWEN_SERVE_DEBUG`、调试套路、Telemetry 现状缺口。 + +### 快速上手 / 运维向 + +- [`20-quickstart-operations.md`](./20-quickstart-operations.md) — 9 种启动姿势、全部 CLI 参数 / env / `settings.json` 速查表、boot 拒启动场景、`curl` 验证清单、`/demo` 用法、`qwen serve` → listening server 的完整调用链、嵌入式调用示例、优雅退出 vs 强退。**想先跑起来再看原理的话从这篇开始。** + +## 术语表 + +- **ACP** — Agent Client Protocol,daemon bridge 与 ACP 子进程之间通过 stdio 跑的 JSON-RPC;不要和客户端用来访问 daemon 的 HTTP 协议混淆。 +- **ACP 子进程** — daemon 拉起的子进程(`qwen --acp`),里面跑真正的 agent 运行时;daemon 的 bridge 把一个 ACP 子进程多路复用给多个连进来的客户端。 +- **acp-bridge** — `@qwen-code/acp-bridge` 包(`packages/acp-bridge/`),负责会话多路复用、权限协调器、事件总线、channel 工厂。 +- **BridgeClient** — `packages/acp-bridge/src/bridgeClient.ts`,封装一条 ACP `ClientSideConnection`,处理 `requestPermission` / `sendPrompt` / `cancelSession`。 +- **Channel 工厂** — 可插拔策略,决定 bridge 如何拉起 / 附加 ACP 子进程:默认 `spawnChannel` 把 `qwen --acp` 跑成子进程;`inMemoryChannel` 在进程内跑用于测试。 +- **DaemonClient** — `packages/sdk-typescript/src/daemon/DaemonClient.ts`,TS SDK 对 daemon 的 HTTP 门面。 +- **DaemonSessionClient** — `packages/sdk-typescript/src/daemon/DaemonSessionClient.ts`,会话级封装,自动跟踪 `lastSeenEventId` 用于 SSE 重放。 +- **EventBus** — `packages/acp-bridge/src/eventBus.ts`,按会话维度的内存 pub/sub:单调 ID、环形缓冲、每订阅者反压。 +- **F1 / F2 / F3 / F4** — [#4175](https://github.com/QwenLM/qwen-code/issues/4175) 的里程碑:F1 bridge 抽取 + `BridgeFileSystem`;F2 工作区共享 MCP transport 池;F3 多客户端权限协调;F4 协议补齐 + `qwen --serve` 同进程托管(进行中)。 +- **MCP** — Model Context Protocol,MCP server 暴露 tool / resource / prompt,daemon 的 ACP 子进程连这些 server。 +- **McpTransportPool** — `packages/core/src/tools/mcp-transport-pool.ts`,F2 的工作区共享池,按 (server 名 + 配置指纹) 复用一个 MCP transport。 +- **Mediator policy** — `first-responder` / `designated` / `consensus` / `local-only` 之一,决定多客户端权限投票如何裁决。 +- **Originator client id** — 触发当前权限请求的那次 prompt 所用的 `X-Qwen-Client-Id`,`designated` 策略只接受这个 id 的投票。 +- **PoolEntry** — `packages/core/src/tools/mcp-pool-entry.ts`,`McpTransportPool` 里的一条记录:一条 MCP transport、引用此条目的会话引用计数、空闲 drain 定时器。 +- **Session scope** — `single`(所有客户端共享一个 ACP 会话)或 `per-client`(每客户端一个会话),默认 `single`。 +- **SSE** — Server-Sent Events,daemon 的出站事件通道(`GET /session/:id/events`)。 +- **Workspace** — daemon 启动时绑定的目录(`--workspace` 或 `cwd`),一个 daemon 进程 = 一个 workspace。 + +## 本文档集**不**覆盖的内容 + +- **Java / Python SDK 的 daemon 客户端** — 目前只有 TS SDK 有 daemon 客户端,第 13 篇只覆盖 TS。 +- **Web UI 详细产品形态** — 自 [#4328](https://github.com/QwenLM/qwen-code/pull/4328) 起 `packages/webui/src/daemon/` 已经是真正的 daemon 前端(React `DaemonSessionProvider` + transcriptAdapter,消费 SDK `ui/*` 子包)。架构走法和 selectors 在 [`14-cli-tui-adapter.md`](./14-cli-tui-adapter.md) 一并讲;webui 自身的产品形态(设计、布局、复用到哪里)参考 [`../daemon-client-adapters/web-ui.md`](../daemon-client-adapters/web-ui.md) 与 [`../daemon-ui/README.md`](../daemon-ui/README.md)。 +- **Zed extension (`packages/zed-extension/`)** — 直接用 stdio ACP 拉起 `qwen --acp`,不走 daemon,不需要 daemon 章节。 +- **F4(进行中)** — 协议补齐和 `qwen --serve` 同进程托管。写文档时该 surface 还不稳定,等落地后再补章。 + +## 当前 daemon mode 覆盖的功能 + +下表列出本文档集覆盖的所有功能 surface,按域归类。每条都是 daemon mode 完整产品的一部分,不是「增量」或「PR 合入清单」。 + +### 服务端核心 + +| Surface | 实现位置 | 文档落点 | +|---|---|---| +| `qwen serve` 引导与 Express 装配 | `packages/cli/src/serve/runQwenServe.ts`、`server.ts` | [`02-serve-runtime.md`](./02-serve-runtime.md) | +| ACP bridge 与会话多路复用 | `packages/acp-bridge/src/bridge.ts` 等 | [`03-acp-bridge.md`](./03-acp-bridge.md) | +| 多客户端权限协调(four-policy mediator + N1 timeout invariant + cancel sentinel) | `packages/acp-bridge/src/permissionMediator.ts` | [`04-permission-mediation.md`](./04-permission-mediation.md) | +| 工作区共享 MCP transport 池(含 fingerprint / OAuth 凭证隔离、子进程 descendant 清理、IDE-close drain、`/mcp refresh` pool gate、reconnect 期 `MCPCallInterruptedError`、`MAX_IDLE_MS` 孤儿回收) | `packages/core/src/tools/mcp-transport-pool.ts`、`mcp-pool-entry.ts`、`mcp-pool-key.ts`、`pid-descendants.ts`、`session-mcp-view.ts` | [`05-mcp-transport-pool.md`](./05-mcp-transport-pool.md) | +| MCP workspace budget guardrails | `packages/core/src/tools/mcp-workspace-budget.ts` | [`06-mcp-budget-guardrails.md`](./06-mcp-budget-guardrails.md) | +| Workspace FS 沙箱、TOCTOU / symlink / trust gate / atomic write / FsError-over-ACP-wire | `packages/cli/src/serve/fs/`、`packages/acp-bridge/src/bridgeClient.ts` | [`07-workspace-filesystem.md`](./07-workspace-filesystem.md) | +| Session 生命周期:create / attach / load / resume / heartbeat / eviction / `X-Qwen-Client-Id` 身份 | `packages/acp-bridge/src/bridge.ts`、`bridgeTypes.ts` | [`08-session-lifecycle.md`](./08-session-lifecycle.md) | + +### Wire 协议 + +| Surface | 实现位置 | 文档落点 | +|---|---|---| +| Typed event schema v1(29 种已知 event type,含 `state_resync_required` 同步恢复帧、SDK reducer `awaitingResync` 状态机、`RESYNC_PASSTHROUGH_TYPES` 终态白名单) | `packages/sdk-typescript/src/daemon/events.ts` | [`09-event-schema.md`](./09-event-schema.md) | +| Envelope 级元数据:每帧 `_meta.serverTimestamp`(多客户端时钟一致性)、`tool_call.provenance` + `serverId`(在 `data._meta`) | `packages/cli/src/serve/server.ts` 的 `formatSseFrame`、`packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts` | [`09-event-schema.md`](./09-event-schema.md) | +| SSE event bus:单调 ID、环形缓冲重放、`Last-Event-ID`、慢消费者反压、环驱逐 → `state_resync_required` 恢复路径 | `packages/acp-bridge/src/eventBus.ts` | [`10-event-bus.md`](./10-event-bus.md) | +| 能力协商:注册表、协议版本、条件广播 | `packages/cli/src/serve/capabilities.ts` | [`11-capabilities-versioning.md`](./11-capabilities-versioning.md) | +| 认证与安全模型:bearer + host allowlist + CORS deny + mutation gate + `--require-auth` + `/health` 豁免 + device-flow OAuth | `packages/cli/src/serve/auth.ts`、`packages/cli/src/serve/auth/deviceFlow.ts` | [`12-auth-security.md`](./12-auth-security.md) | + +### 客户端 / SDK + +| Surface | 实现位置 | 文档落点 | +|---|---|---| +| TS SDK daemon client(HTTP/SSE 门面、session 封装、SSE replay、device-flow helper、330s `MCP_RESTART_DEFAULT_TIMEOUT_MS`) | `packages/sdk-typescript/src/daemon/{DaemonClient,DaemonSessionClient,DaemonAuthFlow,sse,events,types}.ts` | [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md) | +| 共享 UI Transcript 层(`DaemonUiEventType` 29 种 UI 友好事件、reducer + selectors、HTML / terminal / tool preview / conformance 渲染原语,给任何 UI 宿主复用) | `packages/sdk-typescript/src/daemon/ui/{types,normalizer,transcript,store,render,terminal,toolPreview,conformance,utils}.ts` | [`14-cli-tui-adapter.md`](./14-cli-tui-adapter.md) | +| Web UI daemon 前端(React `DaemonSessionProvider` + transcriptAdapter,第一个共享 UI 层消费方) | `packages/webui/src/daemon/` | [`14-cli-tui-adapter.md`](./14-cli-tui-adapter.md) 「消费方」段 | +| IM channel 适配器(钉钉 / 微信 / Telegram,共享 `DaemonChannelBridge` 基座) | `packages/channels/` | [`15-channel-adapters.md`](./15-channel-adapters.md) | +| VSCode IDE daemon 适配器(loopback 强制、webview postMessage 桥接) | `packages/vscode-ide-companion/src/services/daemonIdeConnection.ts` | [`16-vscode-ide-adapter.md`](./16-vscode-ide-adapter.md) | + +### 参考与运维 + +| Surface | 文档落点 | +|---|---| +| 全部 env / CLI 参数 / `settings.json` 速查 | [`17-configuration.md`](./17-configuration.md) | +| 各层 typed error 与修复建议 | [`18-error-taxonomy.md`](./18-error-taxonomy.md) | +| `QWEN_SERVE_DEBUG`、调试套路、telemetry 现状缺口 | [`19-observability.md`](./19-observability.md) | +| 启动姿势、`curl` 验证清单、`/demo`、调用链 | [`20-quickstart-operations.md`](./20-quickstart-operations.md) | + +### 历史 / 已弃用 surface + +- **`packages/cli/src/ui/daemon/DaemonTuiAdapter.ts`** 与整个 `packages/cli/src/ui/daemon/` 目录已删除,由共享 UI Transcript 层(第 14 篇)取代。CLI TUI、channel base、VSCode IDE 三条产品路径会陆续迁过去,迁移指南见 [`../daemon-ui/MIGRATION.md`](../daemon-ui/MIGRATION.md)。 +- **`docs/developers/daemon-client-adapters/tui.md`** 草案已删除,新的 [`../daemon-client-adapters/web-ui.md`](../daemon-client-adapters/web-ui.md) 是替代品。 + +### 向前兼容 + +- Event schema 是加法协议:未知 type 自动 fallback 到 `narrowDaemonEvent → kind: 'unknown'`,SDK 消费方不会因为新增 event type 而崩。 +- `mcp_server_restart_refused.reason` 是封闭枚举(`MCP_RESTART_REFUSED_REASONS.has` 闸),新加的枚举值在老 SDK 上会被静默丢弃 —— 新 reason 必须配新 SDK 一起发。 +- envelope 上的 `_meta` 走宽松 spread merge,未来加新元数据字段不会破老解析器。 + +### 版本溯源 + +这套文档对齐到 `daemon_mode_b_main` 当前 HEAD。覆盖到的源 PR 时间线见 [`#4175`](https://github.com/QwenLM/qwen-code/issues/4175) 的 F 系列里程碑(F1 acp-bridge 抽取 / F2 MCP transport 池 / F3 多客户端权限协调 / F4 协议补齐)。如果要追某条具体功能的提交历史,从对应专题文档底部「参考」节的 PR # 进去比较快。 diff --git a/docs/developers/daemon/01-architecture.md b/docs/developers/daemon/01-architecture.md new file mode 100644 index 0000000000..02cff32cd5 --- /dev/null +++ b/docs/developers/daemon/01-architecture.md @@ -0,0 +1,369 @@ +# Daemon 架构 +## 概览 + +一个 `qwen serve` 进程坚持 **一 daemon = 一 workspace** 的不变式。它内嵌一个 Express HTTP 服务、持有一个 `acp-bridge` 实例、拉起一个 ACP 子进程(`qwen --acp`)来跑真正的 agent 运行时。多个客户端(CLI TUI、IDE companion、IM channel 机器人、Web BFF、自定义脚本)通过 HTTP + SSE 连进来,要么共享同一个 ACP session(`sessionScope: 'single'`,默认),要么每个客户端各拿一个(`per-client`)。 + +在 ACP 子进程内部,MCP server 通过 `McpTransportPool`(F2)实现工作区内共享:一对 (server name + 配置指纹) 对应一条 MCP transport,不管被几个 session 发现都只起一份。Bridge 的 `MultiClientPermissionMediator`(F3)在四种策略之一下协调多客户端的权限投票。 + +本文给出 **系统级全景**,本文档集其余 18 篇文档都挂在它下面。每条主干流程都给一张 Mermaid 时序图,单个组件的实现细节请看对应的专题文档。 + +## 进程拓扑 + +```mermaid +flowchart LR + subgraph clients["Clients"] + WUI["Web UI
(packages/webui/src/daemon)"] + TUI["CLI TUI
(待迁移到 SDK ui/*)"] + IDE["VSCode IDE
(packages/vscode-ide-companion)"] + CH["Channel bots
(DingTalk / WeChat / Telegram)"] + SDK["Any SDK consumer
(packages/sdk-typescript/src/daemon)"] + end + + subgraph daemon["qwen serve process (one workspace)"] + EXP["Express app
(packages/cli/src/serve/server.ts)"] + BR["AcpBridge
(packages/acp-bridge/src/bridge.ts)"] + MED["MultiClientPermissionMediator
(F3)"] + EB["EventBus per session
(eventBus.ts)"] + FS["WorkspaceFileSystem
(cli/src/serve/fs/)"] + end + + subgraph child["ACP child process (qwen --acp)"] + AGT["QwenAgent runtime"] + POOL["McpTransportPool
(F2, core/src/tools)"] + BDG["WorkspaceMcpBudget"] + end + + subgraph external["External"] + MCP1["MCP server A
(stdio)"] + MCP2["MCP server B
(websocket)"] + end + + WUI -- "HTTP+SSE" --> EXP + TUI -- "HTTP+SSE" --> EXP + IDE -- "HTTP+SSE (loopback)" --> EXP + CH -- "HTTP+SSE" --> EXP + SDK -- "HTTP+SSE" --> EXP + + EXP --> BR + BR --> MED + BR --> EB + EXP --> FS + + BR -- "ACP NDJSON over stdio" --> AGT + AGT --> POOL + POOL --> BDG + POOL -- "shared transport" --> MCP1 + POOL -- "shared transport" --> MCP2 +``` + +要点: + +- daemon 进程与 ACP 子进程通过 `AcpChannel` 连接,默认是真实的子进程 + 一对管道;`inMemoryChannel` 用于测试。 +- 所有架构都被这条「daemon ↔ child」缝隙塑造:HTTP / SSE 在 daemon 终止,agent 决策与工具调用在子进程发生,bridge 是中转。 + +## 包关系 + +```mermaid +flowchart TB + subgraph serve["packages/cli/src/serve"] + RQS["runQwenServe.ts
(bootstrap)"] + SRV["server.ts (Express)"] + CAP["capabilities.ts"] + AUTH["auth.ts"] + FSM["fs/ (sandbox)"] + DSP["daemonStatusProvider.ts"] + end + + subgraph br["packages/acp-bridge"] + BR2["bridge.ts"] + BC2["bridgeClient.ts"] + EB2["eventBus.ts"] + MED2["permissionMediator.ts"] + ST2["status.ts"] + CH2["channel.ts / spawnChannel.ts"] + end + + subgraph core["packages/core/src/tools"] + POOL2["mcp-transport-pool.ts"] + ENT["mcp-pool-entry.ts"] + WBG["mcp-workspace-budget.ts"] + SMV["session-mcp-view.ts"] + end + + subgraph sdk["packages/sdk-typescript/src/daemon"] + DC["DaemonClient.ts"] + DSC["DaemonSessionClient.ts"] + EVT["events.ts"] + SSE["sse.ts"] + AUTHF["DaemonAuthFlow.ts"] + UI["ui/* (#4328 + #4353)
normalizer / transcript / store / render"] + end + + subgraph adapters["Adapters"] + WUIP["webui/src/daemon/
DaemonSessionProvider.tsx"] + CHB["channels/base/
DaemonChannelBridge.ts"] + DT["channels/dingtalk"] + WX["channels/weixin"] + TG["channels/telegram"] + IDEA["vscode-ide-companion/
daemonIdeConnection.ts"] + end + + RQS --> SRV + RQS --> CAP + RQS --> AUTH + RQS --> FSM + RQS --> BR2 + + BR2 --> BC2 + BR2 --> EB2 + BR2 --> MED2 + BR2 --> CH2 + + BR2 -.spawns.-> core + POOL2 --> ENT + POOL2 --> WBG + POOL2 --> SMV + + WUIP --> DSC + WUIP --> UI + CHB --> DSC + DT --> CHB + WX --> CHB + TG --> CHB + IDEA --> DSC + + DSC --> DC + DC --> EVT + DC --> SSE + DC --> AUTHF + UI --> EVT +``` + +记住三条信任边界: + +1. HTTP 入口边界:`serve/auth.ts` 中间件链。 +2. bridge ↔ ACP 子进程边界:stdio 上的 NDJSON,没有认证 —— 子进程默认信任 bridge。 +3. agent ↔ MCP server 边界:agent 可能触发涉及宿主资源的工具调用。 + +## 流程 1:HTTP 请求生命周期 + +```mermaid +sequenceDiagram + autonumber + participant C as Client (SDK) + participant MW as Middleware
(bearer→host→CORS→mutationGate) + participant R as Route handler + participant BR as AcpBridge + participant BC as BridgeClient + participant CH as ACP child + + C->>MW: POST /session/:id/prompt
Authorization: Bearer …
X-Qwen-Client-Id: … + MW->>MW: bearerAuth (constant-time compare) + MW->>MW: hostAllowlist (DNS rebinding guard) + MW->>MW: denyBrowserOriginCors + MW->>MW: mutationGate (strict on mutating routes) + MW->>R: req validated + R->>BR: bridge.sendPrompt(sessionId, body, clientId) + BR->>BC: client.sendPrompt(sessionId, …) + BC->>CH: ACP JSON-RPC over stdin + CH-->>BC: ACP response / notifications + BC-->>BR: result + BR-->>R: result + R-->>C: 200 JSON +``` + +非流式路由(prompt、cancel、model 切换、metadata、workspace CRUD)以一次 JSON 响应结束。流式输出**不是**在该 HTTP 连接上以分块方式返回,而是走 SSE 通道;见流程 2。 + +## 流程 2:SSE 事件投递与重放 + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant SR as GET /session/:id/events + participant EB as EventBus
(per session) + participant BC as BridgeClient + participant CH as ACP child + + C->>SR: GET …/events
Last-Event-ID: 42 (optional) + SR->>EB: subscribe(lastSeenId=42, maxQueued=N) + EB-->>SR: replay frames 43..currentTail
(from ring buffer) + SR-->>C: NDJSON: id=43, type=session_update, … + CH-->>BC: ACP notification (e.g. agent_message_chunk) + BC->>EB: publish({type, data}) + EB-->>SR: enqueue id=N + SR-->>C: id=N, type=…, data=… + Note over EB,SR: If subscriber queue >= maxQueued,
EventBus emits client_evicted terminal frame
and closes subscriber. +``` + +要点: + +- 环形缓冲有上限(`eventRingSize`,默认 1024)。 +- 重连客户端如果 `Last-Event-ID` 已经落出环外,会收到合成 catch-up 信号,必须用 `loadSession` / `resumeSession` 重建更深层状态。 +- 慢消费者在队列 75% 触发 `slow_client_warning`,达到上限时收到 `client_evicted`(终态)后被关掉。 + +## 流程 3:多客户端权限协调 + +```mermaid +sequenceDiagram + autonumber + participant CH as ACP child (agent) + participant BC as BridgeClient.requestPermission + participant MED as Mediator (policy) + participant EB as EventBus + participant C1 as Client A
(originator) + participant C2 as Client B + + CH->>BC: ACP requestPermission(requestId, options) + BC->>MED: request({requestId, sessionId, originatorClientId, allowedOptionIds}, timeoutMs) + MED->>EB: publish permission_request
(broadcast to subscribers) + EB-->>C1: SSE permission_request + EB-->>C2: SSE permission_request + + alt first-responder + C2->>MED: POST /permission/:requestId optionId=allow + MED-->>BC: resolved + BC-->>CH: ACP response + MED->>EB: permission_resolved + C1->>MED: POST /permission/:requestId (late vote) + MED-->>C1: 409 permission_already_resolved + else designated + C2->>MED: vote (clientId != originatorClientId) + MED-->>C2: 403 permission_forbidden + C1->>MED: vote (matches originator) + MED-->>BC: resolved + else consensus (N-of-M) + C1->>MED: vote + MED->>EB: permission_partial_vote (1/N) + C2->>MED: vote + MED->>EB: permission_partial_vote (2/N) + Note over MED: when tally reaches quorum on one option, resolve + else local-only + C2->>MED: vote (remote) + MED-->>C2: 403 permission_forbidden (remote_not_allowed) + Note over MED,CH: blocks until a loopback voter resolves it + end +``` + +跨策略「逃生口」:任何客户端都可以投 `CANCEL_VOTE_SENTINEL` 把请求短路成 `cancelled / agent_cancelled`。bridge 防止 wire 端通过普通 `optionId` 字段偷偷塞这个哨兵(`InvalidPermissionOptionError`)。 + +四种策略一句话: + +- `first-responder` — 第一个有效投票获胜(默认,保留 live 协作 UX)。 +- `designated` — 只有 `originatorClientId` 能投,其他客户端收 `permission_forbidden`。 +- `consensus` — N-of-M 法定人数,过程中发 `permission_partial_vote` 让 UI 渲染进度。 +- `local-only` — 拒绝任何 HTTP 投票,只接受 loopback。 + +## 流程 4:MCP transport 池的 acquire / release / restart + +```mermaid +sequenceDiagram + autonumber + participant S as Session in ACP child + participant P as McpTransportPool + participant SIF as spawnInFlight (dedup) + participant E as PoolEntry + participant BDG as WorkspaceMcpBudget + participant SRV as MCP server + + S->>P: acquire(name, cfg, sessionId) + P->>SIF: check inflight for (name+fingerprint) + alt cached inflight + SIF-->>P: existing promise + else cold start + P->>BDG: tryReserve(name) + BDG-->>P: ok / refused + alt refused + P-->>S: BudgetExhaustedError + else ok + P->>E: new PoolEntry(...) + E->>SRV: connect transport + SRV-->>E: ready + E-->>P: connected + end + end + P->>P: sessionToEntries.add(sessionId, id) + P-->>S: PooledConnection + + Note over S,P: Session uses entry, then… + + S->>P: release(id, sessionId) + P->>E: detach session + E->>E: arm drain timer (default 30s) + Note over E: refs==0 → drain timer fires → close transport
(MAX_IDLE_MS 5min hard cap survives flap) + + Note over S,P: Operator restart flow… + S->>P: restartByName(name, opts?) + P->>E: drain + close + P->>E: spawn replacement + E->>SRV: reconnect + P->>EB: publish mcp_server_restarted +``` + +要点: + +- `releaseSession(sessionId)` 借助 `sessionToEntries` 反向索引,以 O(refs) 释放该 session 持有的所有条目。 +- daemon 关停时 `drainAll()` 置 `draining` 标志(拒绝新的 acquire),并以可配置超时等待所有条目关闭。 +- `restartByName` 可以接 `entryIndex` 来精确重启某条;池里同名多条目时返回 `{entries: RestartResult[]}` 形状。 + +## 流程 5:生命周期 —— 启动与优雅退出 + +```mermaid +sequenceDiagram + autonumber + participant Op as Operator (signal) + participant RQS as runQwenServe + participant APP as Express app + participant BR as AcpBridge + participant CH as ACP child + + Op->>RQS: qwen serve --workspace … --token … + RQS->>RQS: validate flags + canonicalize workspace + RQS->>RQS: allocate PermissionAuditRing + RQS->>BR: createHttpAcpBridge(options) + RQS->>APP: createServeApp(bridge, …) + RQS->>APP: listen(host, port) + RQS->>RQS: arm SIGINT / SIGTERM handlers + + Op->>RQS: SIGTERM + RQS->>BR: dispose device-flow registry + RQS->>BR: bridge.shutdown() + BR->>CH: send graceful close (10s deadline) + CH-->>BR: exit + RQS->>APP: server.close() (5s force-close timer) + APP->>APP: closeAllConnections() (+2s secondary) + Note over Op,RQS: Second SIGTERM during shutdown →
bridge.killAllSync() + process.exit(1) (orphan prevention) +``` + +为什么要分两阶段: + +- 还在飞的 HTTP 请求、还连着的 SSE 订阅者、子进程里还在跑的工具调用都需要有上限的退出窗口。 +- 任何一条卡过窗口,force-close 路径会接管,避免子进程把 daemon 进程拖住。 +- 第二次 SIGTERM 直接走 `bridge.killAllSync()` + `process.exit(1)`,防孤儿。 + +## 关键文件 + +| 关注点 | 文件 | +| ------------------ | -------------------------------------------------------------------- | +| Bootstrap | `packages/cli/src/serve/runQwenServe.ts` (308-994) | +| Express 应用 | `packages/cli/src/serve/server.ts` (261-339) | +| 能力注册表 | `packages/cli/src/serve/capabilities.ts` (37-215) | +| Auth 中间件 | `packages/cli/src/serve/auth.ts` (1-60) | +| Bridge | `packages/acp-bridge/src/bridge.ts` | +| BridgeClient | `packages/acp-bridge/src/bridgeClient.ts` | +| 权限协调器 | `packages/acp-bridge/src/permissionMediator.ts` (1-1292) | +| EventBus | `packages/acp-bridge/src/eventBus.ts` | +| MCP transport 池 | `packages/core/src/tools/mcp-transport-pool.ts` (104+) | +| Workspace MCP 预算 | `packages/core/src/tools/mcp-workspace-budget.ts` | +| Workspace 文件系统 | `packages/cli/src/serve/fs/` | +| SDK DaemonClient | `packages/sdk-typescript/src/daemon/DaemonClient.ts` (209-1506) | +| SDK SessionClient | `packages/sdk-typescript/src/daemon/DaemonSessionClient.ts` (61-385) | +| 事件 schema | `packages/sdk-typescript/src/daemon/events.ts` (13-63) | + +## 参考 + +- 设计 issue:[#3803](https://github.com/QwenLM/qwen-code/issues/3803)(daemon 总体设计)、[#4175](https://github.com/QwenLM/qwen-code/issues/4175)(F 系列里程碑)。 +- 用户使用文档:[`../../users/qwen-serve.md`](../../users/qwen-serve.md)。 +- Wire 协议参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。 +- F2 设计文档(v2.2,含 32 条 review fold-in):[`../../design/f2-mcp-transport-pool.md`](../../design/f2-mcp-transport-pool.md)。 +- F2 设计笔记:issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) commit 4-6。 diff --git a/docs/developers/daemon/02-serve-runtime.md b/docs/developers/daemon/02-serve-runtime.md new file mode 100644 index 0000000000..80f058873a --- /dev/null +++ b/docs/developers/daemon/02-serve-runtime.md @@ -0,0 +1,138 @@ +# Serve 运行时 +## 概览 + +`packages/cli/src/serve/` 是 `qwen serve` 的引导层,负责:把 CLI 参数翻译成 `ServeOptions`、启动期校验、构造 Express 应用、装配中间件链、注册路由、暴露 daemon-host 的 preflight/status provider、维护权限审计环、以及两阶段优雅退出序列。所有 HTTP 形态的东西都在这一层;所有 ACP 形态的东西在下一层 `@qwen-code/acp-bridge`(见 [`03-acp-bridge.md`](./03-acp-bridge.md))。 + +## 职责 + +- 解析与校验 `ServeOptions`(`hostname`、`port`、`token`、`requireAuth`、`workspace`、`maxSessions`、`maxConnections`、`eventRingSize`、`mcpClientBudget`、`mcpBudgetMode`、`mcpPoolActive`)。 +- 一次性 **canonicalize** 绑定的 workspace(同一份规范形式同时供 `/capabilities`、`POST /session` 兜底和 bridge 使用)。 +- 拒绝以不安全的姿势启动:非 loopback 绑定无 token;`--require-auth` 无 token;`mcpBudgetMode='enforce'` 无正整数 `mcpClientBudget`;`--workspace` 不存在或不是目录。 +- 构造 `WorkspaceFileSystem` 工厂、权限审计 publisher、`DaemonStatusProvider`、`acp-bridge`。 +- 构造 Express 应用、装配中间件链(`bearerAuth` → `hostAllowlist` → `denyBrowserOriginCors` → 每路由 `mutationGate`)、挂载路由(session、workspace CRUD、文件、Device Flow auth、权限投票)。 +- 绑定监听端口并注册信号 handler。 +- 收到 SIGINT/SIGTERM 时两阶段退出;二次信号强退。 + +## 架构 + +**入口**:`runQwenServe(opts, deps)`,文件 `packages/cli/src/serve/runQwenServe.ts:308-994`,返回 `RunHandle`(`{ url, port, close, ... }`)。 + +**应用工厂**:`createServeApp(opts, getPort, deps)`,文件 `packages/cli/src/serve/server.ts:261-339`,构建 Express `Application`。直接嵌入和测试不走 bootstrap,直接调它。 + +**能力注册表**:`SERVE_CAPABILITY_REGISTRY`,文件 `packages/cli/src/serve/capabilities.ts:37-215`。每个 tag 带 `since` 版本和可选 `modes`,条件 tag(`require_auth`、`mcp_workspace_pool`、`mcp_pool_restart`)在开关关掉时不广播。详见 [`11-capabilities-versioning.md`](./11-capabilities-versioning.md)。 + +**中间件** `packages/cli/src/serve/auth.ts`: + +| 中间件 | 作用 | 说明 | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `denyBrowserOriginCors` | 拒绝任何带 `Origin` 头的请求 | CLI/SDK 永远不发 `Origin`,这是 CSRF 防护。 | +| `hostAllowlist(bind, getPort)` | Loopback 下校验 `Host` 头属于 `localhost`、`127.0.0.1`、`[::1]`、`host.docker.internal` 加端口的集合 | 防 DNS rebinding,按端口缓存,比较时大小写不敏感。 | +| `bearerAuth(token)` | 用 SHA-256 + `timingSafeEqual` 常量时间比较 | 无 token(loopback dev 默认)就 open passthrough,`Bearer` 大小写不敏感。 | +| `createMutationGate({tokenConfigured, requireAuth})` | 路由级 opt-in 闸门,对 Wave 4 修改类路由即便在 loopback 也强制 token | 返回 `401 { code: 'token_required' }`,区别于一般 `Unauthorized`。`/workspace/memory`、`/workspace/agents/*`、`/file/write`、`/workspace/tools/:name/enable`、`/workspace/mcp/:server/restart`、`/workspace/auth/device-flow`、`/workspace/init` 都调 `mutate({strict: true})`。 | + +**子系统**: + +| 路径 | 作用 | +| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `serve/fs/` | `WorkspaceFileSystem` 工厂 + `policy.ts`(大小/信任/二进制检查)+ `paths.ts`(canonicalize、resolveWithin、拒绝 symlink)+ `audit.ts` + `errors.ts`(typed `FsError`) | +| `serve/routes/workspaceFileRead.ts`、`workspaceFileWrite.ts` | `GET /file`、`GET /file/bytes`、`POST /file/write`、`POST /file/edit` 的 HTTP handler | +| `serve/workspaceMemory.ts` | `GET/POST /workspace/memory`(QWEN.md CRUD) | +| `serve/workspaceAgents.ts` | `GET/POST/DELETE /workspace/agents`(子 agent CRUD) | +| `serve/daemonStatusProvider.ts:41-287` | env 快照 + daemon-host preflight cell(Node 版本、CLI 入口、workspace stat、ripgrep、git、npm) | +| `serve/permissionAudit.ts:1-60` | `PermissionAuditRing`(FIFO 512 条)+ `createPermissionAuditPublisher` | +| `serve/auth/deviceFlow.ts`、`qwenDeviceFlowProvider.ts` | Device Flow OAuth 路由(见 [`12-auth-security.md`](./12-auth-security.md)) | +| `serve/demo.ts` | `GET /demo` 的自包含内联 HTML —— 一个浏览器可访问的调试控制台(聊天 UI + 事件日志 + workspace 检视器)。loopback 且不带 `--require-auth` 时注册在 `bearerAuth` **之前**,开发不带 token 就能从浏览器打开;非 loopback 或带 `--require-auth` 时注册在 `bearerAuth` **之后**,未认证探测不能枚举接口。Strict CSP(`default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'`)+ `X-Frame-Options: DENY`。 | + +**Re-export shim**(为兼容 F1 前的 import 路径): + +- `serve/eventBus.ts` → `@qwen-code/acp-bridge/eventBus` +- `serve/status.ts` → `@qwen-code/acp-bridge/status` +- `serve/httpAcpBridge.ts` → `@qwen-code/acp-bridge` + +## 流程 + +### 启动序列 + +1. **取并 trim token**:`opts.token` || `QWEN_SERVER_TOKEN`(启动时 trim 一次,防止 `cat token.txt` 把换行带进来导致永远比对不上)。 +2. **hostname 错配兜底**:`--hostname localhost:4170` 直接报错并提示用 `--port`。 +3. **auth 预检**:非 loopback 无 token → 拒绝;`--require-auth` 无 token → 拒绝。 +4. **workspace 校验**:必须绝对路径、必须存在、必须是目录;`EACCES`/`EPERM` 包装成指向参数本身的错误。 +5. **canonicalize workspace**:`canonicalizeWorkspace(rawWorkspace)` 走 `realpathSync.native` 一次,给 `/capabilities`、`POST /session` 兜底、bridge 共用,保证在 symlink / 大小写不敏感 FS 上不分叉。 +6. **MCP 预算校验**:必须正整数;`enforce` 必须配 budget。 +7. **MCP pool 开关推断**:父进程 env 里 `QWEN_SERVE_NO_MCP_POOL=1` 时,`mcpPoolActive` 默认 `false`,capabilities 也会诚实地不广播 `mcp_workspace_pool` + `mcp_pool_restart`。 +8. **per-handle `childEnvOverrides`**:把 `QWEN_SERVE_MCP_CLIENT_BUDGET` 和 `QWEN_SERVE_MCP_BUDGET_MODE` 通过 `BridgeOptions.childEnvOverrides` 传给 ACP 子进程,**不**改 `process.env`(同进程跑两个 daemon 会出 race)。 +9. **boot 一次 `settings.json`**:取 `context.fileName`、`policy.permissionStrategy`、`policy.consensusQuorum`;损坏文件 try/catch 走默认值。之后 **`validatePolicyConfig()`**(`runQwenServe.ts:89+`)解析 `policy.*`,未知 strategy(按 `SERVE_CAPABILITY_REGISTRY.permission_mediation.modes` 单一事实源校验)或非正整数 `consensusQuorum` 时抛 `InvalidPolicyConfigError`。`consensusQuorum` 设了但策略非 `consensus` 时打 stderr 警告(默认会被静默忽略,浮出来防 operator 误以为它生效)。settings 读 I/O 失败回退默认;`InvalidPolicyConfigError` 重抛让 boot 显式失败。 +10. **分配 `PermissionAuditRing`**(512 条)。 +11. **建 `fsFactory`**:`runQwenServe` 路径默认 `trusted: true`;`createServeApp` 直接调时默认 `trusted: false` 并发警告一次。 +12. **`createHttpAcpBridge`**,见 [`03-acp-bridge.md`](./03-acp-bridge.md)。 +13. **`createServeApp`** 装配 Express。 +14. **`server.listen(port, hostname)`**,resolve 后取真实 `getPort()` 给 host allowlist。 +15. **注册 SIGINT / SIGTERM handler**,驱动优雅退出。 + +### 优雅退出(两阶段) + +1. **第一阶段 —— bridge 收尾**(首次信号): + - dispose Device Flow registry(取消所有 pending flow)。 + - `bridge.shutdown()`:所有 channel 置 `isDying = true`;向每个 ACP 子进程 stdin 发 graceful close;每个 channel 等 `KILL_HARD_DEADLINE_MS`(10s);不退就 `channel.kill()`。 +2. **第二阶段 —— HTTP 收尾**: + - `server.close()`(停止接收新连接,等飞行中请求收尾)。 + - 起 `SHUTDOWN_FORCE_CLOSE_MS`(5s)定时器,到点 `server.closeAllConnections()` 强切 socket。 + - 起二次 2s deadline,到点继续升级。 +3. **退出中再来一次信号**: + - `bridge.killAllSync()` + `process.exit(1)`。防孤儿 —— 子进程卡死也不能拖死 daemon 进程。 + +## 状态与生命周期 + +`RunHandle` 暴露: + +- `url`:实际监听 URL(ephemeral 端口取 `getPort()` 之后)。 +- `port`:实际端口(`0` 解析后的真实值)。 +- `close({ timeoutMs? })`:给嵌入方 / 测试用的程序化关闭。 + +`createServeApp` 直接调时只返回 `Application`,不持有生命周期;嵌入方自己写 `listen` 和 shutdown。 + +## 依赖 + +| 上游(`serve/` 用了什么) | 下游(谁用了 `serve/`) | +| ---------------------------------------------------------------------------------------------- | ------------------------------------ | +| `@qwen-code/acp-bridge`:bridge、event bus、status 类型 | `qwen` CLI 的 `serve` 子命令处理函数 | +| `packages/core`:`loadSettings`、`getCurrentGeminiMdFilename`、`Config`、`WorkspaceContext` | 任何直接嵌入方(测试、程序化调用) | +| ACP SDK(`@agentclientprotocol/sdk`):`PROTOCOL_VERSION`、`ClientSideConnection`(经 bridge) | | +| Express + body-parser、`node:crypto`、`node:fs`、`node:path` | | + +## 配置 + +| 来源 | Key | 效果 | +| --------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------- | +| Env | `QWEN_SERVER_TOKEN` | Bearer token(trim 后)。 | +| Env | `QWEN_SERVE_NO_MCP_POOL=1` | 强制 `mcpPoolActive=false`。 | +| Env | `QWEN_SERVE_MCP_CLIENT_BUDGET` / `QWEN_SERVE_MCP_BUDGET_MODE` | 通过 `childEnvOverrides` 传给 ACP 子进程。 | +| Env | `QWEN_SERVE_DEBUG=1` | 详细 stderr 日志(见 [`19-observability.md`](./19-observability.md))。 | +| 参数 | `--hostname`、`--port` | 监听绑定。 | +| 参数 | `--token` | Bearer token(覆盖 env)。 | +| 参数 | `--require-auth` | 把 bearer 强制到 loopback;无 token 直接拒启动。 | +| 参数 | `--workspace` | 覆盖 `process.cwd()`。 | +| 参数 | `--max-sessions`、`--max-connections`、`--event-ring-size` | bridge / Express 上限。 | +| 参数 | `--mcp-client-budget=N`、`--mcp-budget-mode={off,warn,enforce}` | 传给 ACP 子进程。 | +| `settings.json` | `policy.permissionStrategy`、`policy.consensusQuorum` | `MultiClientPermissionMediator` 的策略与法定人数。 | +| `settings.json` | `context.fileName` | bridge 的 `getCurrentGeminiMdFilename` 覆盖。 | + +合并参考见 [`17-configuration.md`](./17-configuration.md)。 + +## 注意 & 已知局限 + +- `createServeApp` 没传 `deps.fsFactory` 或 `deps.bridge` 时默认 `trusted: false`,agent 侧 ACP `writeTextFile` 会拒为 `untrusted_workspace`。提示只打一次。 +- `denyBrowserOriginCors` 拒绝**所有**带 `Origin` 的请求;demo 页能跑是因为另一个中间件先把匹配本机 origin 的剥掉了。 +- body-parser 顺序:`mutateGate({strict: true})` 的 401 在 `express.json()` 之后才触发;strict 路径最坏放大成 `--max-connections × express.json({limit: '10mb'})` ≈ 2.5 GB 瞬时(loopback only,刻意接受)。 +- 同进程跑两个 daemon 时必须用 per-handle `childEnvOverrides`;改 `process.env` 会 race(`defaultSpawnChannelFactory` 在 spawn 时刻快照 env)。 + +## 参考 + +- `packages/cli/src/serve/runQwenServe.ts:308-994` +- `packages/cli/src/serve/server.ts:261-339` +- `packages/cli/src/serve/auth.ts:1-294` +- `packages/cli/src/serve/capabilities.ts:1-220` +- `packages/cli/src/serve/types.ts:37-155`(`ServeOptions`、`CapabilitiesEnvelope`) +- `packages/cli/src/serve/daemonStatusProvider.ts:41-287` +- `packages/cli/src/serve/permissionAudit.ts:1-60` +- Issue:[#3803](https://github.com/QwenLM/qwen-code/issues/3803)、[#4175](https://github.com/QwenLM/qwen-code/issues/4175)。 diff --git a/docs/developers/daemon/03-acp-bridge.md b/docs/developers/daemon/03-acp-bridge.md new file mode 100644 index 0000000000..e379c19c66 --- /dev/null +++ b/docs/developers/daemon/03-acp-bridge.md @@ -0,0 +1,231 @@ +# ACP Bridge +## 概览 + +`packages/acp-bridge/` 包是 daemon HTTP 层与 ACP 子进程之间的缝隙拥有者。它被 `packages/cli/src/serve/`(`qwen serve` daemon)消费;在 #4175 F1 step 3 中被抽取出来,让以后的消费方(`channels/base/AcpBridge.ts`、VSCode IDE companion)可以直接复用 bridge 内核而不必反向依赖 cli 包。 + +bridge 提供:一个 `HttpAcpBridge` 实例、一条 `AcpChannel` 连到 ACP 子进程、在这条 channel 上多路复用的 session、每个 session 的 `EventBus`、一个 `MultiClientPermissionMediator`、一个 `BridgeFileSystem` adapter,外加 ACP 形状的辅助方法(`spawnOrAttach`、`loadSession`、`resumeSession`、`sendPrompt`、`cancelSession`、`respondToPermission`,以及供 workspace 级状态与 MCP 重启用的 extMethod RPC)。 + +## 职责 + +- 用可插拔的 `ChannelFactory` spawn 或 attach 到 ACP 子进程。默认 `defaultSpawnChannelFactory`(子进程 `qwen --acp`),测试用 `inMemoryChannel`。 +- 维护 `aliveChannels`(channel 注册表)和 `byId`(session 注册表)。 +- 用 `connection.newSession()` 在一条 ACP child 上多路复用 N 个 HTTP-side session。 +- 用 `promptQueue` 把同一 session 的 prompt 串行化(ACP 强制 「一个 session 同一时刻只能有一个 prompt 在跑」)。 +- 用 `modelChangeQueue` 串行化 `setSessionModel`,防止并发 attach + 不同 model 把 agent 带进非确定状态。 +- 每个 session 一个 `EventBus`,驱动 `GET /session/:id/events`(详见 [`10-event-bus.md`](./10-event-bus.md))。 +- 权限流:`BridgeClient.requestPermission` → `MultiClientPermissionMediator.request` → 扇出 → 收票 → 回 ACP(详见 [`04-permission-mediation.md`](./04-permission-mediation.md))。 +- 文件 IO:通过 `BridgeFileSystem` adapter 处理 ACP 的 `readTextFile` / `writeTextFile`(详见 [`07-workspace-filesystem.md`](./07-workspace-filesystem.md))。 +- workspace 级状态的 extMethod RPC(`/workspace/mcp`、`/workspace/skills`、`/workspace/providers`)和 MCP 重启。 +- 生命周期:`shutdown()` 每个 channel 等 `KILL_HARD_DEADLINE_MS`(10s);二次信号 `killAllSync()` 同步强杀。 + +## 架构 + +**公开入口**:`createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge`,文件 `packages/acp-bridge/src/bridge.ts:350+`。 + +**关键类型**: + +| 类型 | 文件 | 作用 | +| ------------------------------- | ------------------------------ | -------------------------------------------------------------------------------- | +| `HttpAcpBridge` | `bridgeTypes.ts:30-180+` | 对外接口,全部方法都在这里 | +| `BridgeSession` | `bridgeTypes.ts:49+` | `{ sessionId, workspaceCwd, attached, clientId?, createdAt? }` | +| `BridgeOptions` | `bridgeOptions.ts:88-323` | 构造时配置(见 [配置](#配置)) | +| `AcpChannel` | `channel.ts:21-50` | `{ stream, kill(), killSync(), exited }` 一条 ACP NDJSON channel | +| `ChannelFactory` | `channel.ts:57-60` | `(workspaceCwd, childEnvOverrides?) => Promise` | +| `BridgeClient` | `bridgeClient.ts:1-150+` | 封装一条 ACP `ClientSideConnection`,实现 ACP `Client` | +| `EventBus` | `eventBus.ts` | 每 session 内存 pub/sub,见 [`10-event-bus.md`](./10-event-bus.md) | +| `MultiClientPermissionMediator` | `permissionMediator.ts:1-1292` | 四策略 mediator,见 [`04-permission-mediation.md`](./04-permission-mediation.md) | + +**内部状态**(由 `createHttpAcpBridge` 闭包持有): + +| 状态 | 形态 | 用途 | +| --------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `aliveChannels` | `Map` | channel 注册表;每条 `ChannelInfo` 包括 `channel`、`connection`、`client`(每 channel 一个 `BridgeClient`)、`sessionIds: Set`、`pendingRestoreIds`、`statusClosedReject?`、`isDying: boolean` | +| `byId` | `Map` | session 注册表;每个 `SessionEntry` 包括 `channel`、`connection`、`events: EventBus`、`promptQueue`、`modelChangeQueue`、`pendingPermissionIds: Set`、`clientIds: Map`、`activePromptOriginatorClientId?`、`attachCount`、`spawnOwnerWantedKill`、`restoreState?`、`sessionLastSeenAt?`、`clientLastSeenAt` | +| `defaultEntry` | `SessionEntry \| null` | `sessionScope: 'single'` 下共享的那个 session | +| `defaultPolicy` | `PermissionPolicy` | 由 `BridgeOptions.permissionPolicy` 决定 | +| `mediator` | `MultiClientPermissionMediator` | 每 bridge 一个 | +| 常量 | — | `DEFAULT_INIT_TIMEOUT_MS = 10_000`、`MCP_RESTART_TIMEOUT_MS = 300_000`、`DEFAULT_MAX_SESSIONS = 20`、`MAX_EVENT_RING_SIZE = 1_000_000`、`DEFAULT_PERMISSION_TIMEOUT_MS = 5min`、`DEFAULT_MAX_PENDING_PER_SESSION = 64` | + +**`isDying` 不变式**:任何 teardown 路径在 await `channel.kill()` 之前必须**同步**置 `ChannelInfo.isDying = true`。`ensureChannel` 把 dying channel 视作不存在,会重新 spawn 一条。否则一个并发 `spawnOrAttach` 在 SIGTERM 宽限窗口(最长 10s)中到来时会 attach 到马上要关掉的 transport,调用方拿到的 sessionId 之后每次请求都 404。**设置位点**(必须同步保持):`ensureChannel`(initialize 失败 + 晚到 shutdown 重检)、`doSpawn`(empty channel 上 newSession 失败)、`killSession`(最后一个 session 离开)、`shutdown`(批量)。 + +**`BkUyD` 不变式**:置 `isDying = true` 时**不要**清除 `channelInfo`。`killAllSync` 在 SIGTERM 宽限窗口仍需要找到 channel 触发 SIGKILL;`aliveChannels` 持有 dying 项直到 `channel.exited` 触发。 + +**BridgeClient 早到事件缓冲**:当 ACP `extNotification` 在 `connection.newSession` 响应返回之前(但其内部 MCP discovery 已经触发 budget 事件)到达 `BridgeClient`,事件按 `MAX_EARLY_EVENT_SESSIONS = 64` × `MAX_EARLY_EVENTS_PER_SESSION = 32` × `EARLY_EVENT_TTL_MS = 60_000` 三重上限缓冲,最坏 ~400 KB。否则新 session SSE 重放环的第一个 slot 会丢掉创建期发生的事件。 + +## 流程 + +### `spawnOrAttach`(最常用入口) + +```mermaid +sequenceDiagram + autonumber + participant R as Route handler + participant B as createHttpAcpBridge closure + participant CF as ChannelFactory + participant CH as AcpChannel + participant ACP as ACP child + participant M as Mediator + + R->>B: spawnOrAttach({cwd?, sessionScope?, clientId?}) + B->>B: validate cwd vs boundWorkspace
(WorkspaceMismatchError) + alt sessionScope=single and defaultEntry exists + B->>B: bump attachCount
register clientId + B-->>R: {sessionId, attached: true, restoreState?} + else cold path + B->>CF: factory(workspaceCwd, childEnvOverrides) + CF->>ACP: spawn qwen --acp + pipes + CF-->>B: AcpChannel + B->>ACP: ACP initialize (timeout=DEFAULT_INIT_TIMEOUT_MS) + ACP-->>B: initialize response + B->>ACP: connection.newSession({cwd}) + ACP-->>B: {sessionId} + B->>B: build SessionEntry
register in byId / defaultEntry + B-->>R: {sessionId, attached: false} + end +``` + +要点: + +- 校验 cwd vs `boundWorkspace`,不一致抛 `WorkspaceMismatchError`。 +- `sessionScope='single'` 且 `defaultEntry` 已存在 → 只 bump `attachCount` 并登记 `clientId`,返回 `attached: true`。 +- 冷路径 → 走 ChannelFactory 拉子进程 → ACP `initialize`(`DEFAULT_INIT_TIMEOUT_MS=10s`)→ `connection.newSession({cwd})` → 构造 `SessionEntry` 注册到 `byId` / `defaultEntry`。 +- `byId.size >= maxSessions` 抛 `SessionLimitExceededError`。 +- `X-Qwen-Client-Id` 不在 `[A-Za-z0-9._:-]{1,128}` 范围 → `InvalidClientIdError`。 +- `server.ts` 的 disconnect-reaper 通过 `attachCount` / `spawnOwnerWantedKill` 跟踪 spawn 拥有者,避免在 spawn 拥有者掉线但其他客户端已经 attach 的情况下把 session 拆掉(review #3889 BQ9tV)。 + +### Prompt 串行化 + +```mermaid +sequenceDiagram + autonumber + participant R as Route + participant E as SessionEntry + participant Q as promptQueue (FIFO) + participant BC as BridgeClient + participant ACP as ACP child + + R->>E: sendPrompt(sessionId, body, clientId) + E->>E: set activePromptOriginatorClientId = clientId + E->>Q: chain off resolved tail + Q->>BC: client.sendPrompt(sessionId, body) + BC->>ACP: ACP prompt JSON-RPC + ACP-->>BC: response (after potentially multiple requestPermission roundtrips) + BC-->>E: result + E->>E: clear activePromptOriginatorClientId + E-->>R: result +``` + +要点: + +- 队列尾部失败被**吞**掉,避免前一次失败毒害后续 prompt;调用方仍可在自己的 promise 上拿到 rejection。 +- session 上缓存的 `transportClosedReject` 把 prompt promise 与 `channel.exited` race,子进程崩了立刻浮出来而不是 hang。 + +### 权限流(高层) + +```mermaid +sequenceDiagram + autonumber + participant ACP as ACP child (agent) + participant BC as BridgeClient.requestPermission + participant E as SessionEntry + participant M as Mediator + participant EB as EventBus + + ACP->>BC: requestPermission(requestId, options) + BC->>E: record requestId in pendingPermissionIds + BC->>M: request({requestId, sessionId, originatorClientId, allowedOptionIds}, timeoutMs) + M->>EB: publish permission_request (fan-out to subscribers) + Note over M: waits for vote / timeout / cancel + M-->>BC: PermissionResolution + BC-->>ACP: RequestPermissionResponse (selected or cancelled) + BC->>E: clear requestId +``` + +要点: + +- wire 端通过普通 `optionId` 偷塞 `CANCEL_VOTE_SENTINEL` → bridge 在到 mediator 之前抛 `InvalidPermissionOptionError`,这个哨兵只能由 bridge 内部使用来把请求短路成 `cancelled / agent_cancelled`。 +- 详见 [`04-permission-mediation.md`](./04-permission-mediation.md)。 + +### 退出 + +```mermaid +sequenceDiagram + autonumber + participant Op as runQwenServe + participant B as Bridge + participant CHs as Channels + participant M as Mediator + + Op->>B: shutdown() + B->>CHs: mark every ChannelInfo isDying = true (bulk) + B->>M: forgetSession for every sessionId (pending → cancelled/session_closed) + par per channel + B->>CHs: channel.kill() (await up to KILL_HARD_DEADLINE_MS = 10s) + CHs-->>B: exited + end + B-->>Op: done + Note over Op,B: Second signal → killAllSync()
(fire SIGKILL on every alive child synchronously) +``` + +## Channel 工厂 + +`AcpChannel`(`channel.ts:21-50`)是 bridge 的传输抽象。生产用 `defaultSpawnChannelFactory`(`spawnChannel.ts`),把 `qwen --acp` 跑成子进程加一对 stdio 管道;测试用 `inMemoryChannel`,agent 在进程内跑。bridge 不在乎下面是什么机制,只要给 `{ stream, kill, killSync, exited }` 就行。 + +`ChannelFactory` 接受 `childEnvOverrides`,每个 daemon handle 可以传自己那份 MCP-budget env(`QWEN_SERVE_MCP_CLIENT_BUDGET`、`QWEN_SERVE_MCP_BUDGET_MODE`),不去改 `process.env`(同进程两个 daemon 会 race)。 + +## 状态与生命周期 + +- bridge 构造同步完成;首次 `spawnOrAttach` 冷启动 ACP 子进程。 +- `sessionScope: 'single'` 下 `defaultEntry` 与 bridge 同生命周期;channel 在 `sessionIds.size === 0` 且 `isDying = true` 后被回收。 +- `MAX_EVENT_RING_SIZE = 1_000_000` 是 `BridgeOptions.eventRingSize` 的软上限,挡操作者打错值导致 ~500 MB 一个 session OOM。 +- `DEFAULT_PERMISSION_TIMEOUT_MS = 5 * 60 * 1000` 防止一个 wedged 权限请求把 session 的 `promptQueue` 永久 hang。 +- `DEFAULT_MAX_PENDING_PER_SESSION = 64` 对话多的 agent 反压;超出的 `requestPermission` 直接解析为 cancelled 并打 stderr 警告。 + +## 依赖 + +| 上游 | 下游 | +| ------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| `@agentclientprotocol/sdk`:`ClientSideConnection`、`PROTOCOL_VERSION`、ACP 类型 | `packages/cli/src/serve/`(daemon) | +| `@qwen-code/qwen-code-core`:`ApprovalMode`、`TrustGateError`、`getCurrentGeminiMdFilename` | `packages/channels/base/`(规划中,F4) | +| `node:crypto`、`node:fs`、`node:path` | `packages/vscode-ide-companion/`(规划中,F4) | + +## 配置 + +`BridgeOptions`(`bridgeOptions.ts:88-323`): + +| 键 | 默认 | 作用 | +| --------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------ | +| `boundWorkspace` | (必填) | bridge 强制的规范 workspace 路径 | +| `sessionScope` | `'single'` | `'single'` 所有客户端共享一个 session;`'per-client'` 每客户端一个 | +| `channelFactory` | `defaultSpawnChannelFactory` | 可插拔 ACP child 工厂 | +| `initializeTimeoutMs` | `10_000` | ACP `initialize` 握手超时 | +| `maxSessions` | `20` | `byId.size` 上限;`0`/`Infinity` = 不限;NaN/负值抛错 | +| `eventRingSize` | `DEFAULT_RING_SIZE` | 每 session 事件环;软上限 `1_000_000` | +| `permissionResponseTimeoutMs` | `5 min` | mediator 每请求 wallclock | +| `maxPendingPermissionsPerSession` | `64` | 反压 | +| `childEnvOverrides` | `{}` | 每 handle 给 ACP child 的 env 增量 / scrub | +| `persistApprovalMode`、`persistDisabledTools` | — | Wave 4 修改路由的 settings 写钩子 | +| `contextFilename` | 从 `settings.json` 的 `context.fileName` | 覆盖 `getCurrentGeminiMdFilename` | +| `statusProvider` | (无) | daemon-host preflight cells | +| `fileSystem` | (无) | `BridgeFileSystem` adapter | +| `permissionPolicy` | 从 `settings.json` 的 `policy.permissionStrategy` | 四策略之一 | +| `permissionConsensusQuorum` | 从 `settings.json` | consensus 策略的 N | +| `permissionAudit` | `createNoOpPermissionAuditPublisher()` | 接到 `PermissionAuditRing` | + +## 注意 & 已知局限 + +- `MCP_RESTART_TIMEOUT_MS = 300_000`(5 min)—— bridge race deadline 故意设这么长,因为 `McpClientManager.MAX_DISCOVERY_TIMEOUT_MS` 对 stdio MCP 最长 5 min。设短了会在 ACP child 还在后台重连时假超时。 +- `BridgeOptions.eventRingSize > 1_000_000` 构造时抛错。 +- `connection.unstable_resumeSession` 通过 `unstable_session_resume` 能力 tag 暴露并保留 `unstable_` 前缀;ACP 方法形状还可能变,客户端必须 feature-detect。 +- bridge 包是 `@qwen-code/acp-bridge`,通过 `serve/eventBus.ts`、`serve/status.ts`、`serve/httpAcpBridge.ts` 三个 re-export shim 兼容 F1 前的 import 路径。新代码应该直接 import 包。 + +## 参考 + +- `packages/acp-bridge/src/bridge.ts`(重点 `createHttpAcpBridge` line 350+) +- `packages/acp-bridge/src/bridgeClient.ts` +- `packages/acp-bridge/src/bridgeTypes.ts:30-180+` +- `packages/acp-bridge/src/bridgeOptions.ts:88-323` +- `packages/acp-bridge/src/channel.ts:1-60` +- `packages/acp-bridge/src/spawnChannel.ts` +- `packages/acp-bridge/src/bridgeErrors.ts` +- Issue:[#3803](https://github.com/QwenLM/qwen-code/issues/3803)、[#4175](https://github.com/QwenLM/qwen-code/issues/4175)。 diff --git a/docs/developers/daemon/04-permission-mediation.md b/docs/developers/daemon/04-permission-mediation.md new file mode 100644 index 0000000000..d96b60b0ae --- /dev/null +++ b/docs/developers/daemon/04-permission-mediation.md @@ -0,0 +1,229 @@ +# 多客户端权限协调 +## 概览 + +ACP 子进程的 agent 调 `requestPermission` 时,daemon 并不会只转给某一个客户端 —— `sessionScope: 'single'` 下每个连上来的客户端都看得到这个请求,谁回复都行。没有协调器就乱套:迟到的投票无处去、两个客户端 race 同一个请求、一个流氓客户端能盖过 originator 等等。 + +`MultiClientPermissionMediator`(`packages/acp-bridge/src/permissionMediator.ts:1-1292`)实现了 `PermissionMediator` 契约(`packages/acp-bridge/src/permission.ts`),bridge 的所有 pending + resolved 权限状态都归它管。它按 `PermissionPolicy` 四选一分派投票: + +| 策略 | 裁决规则 | 用例 | +| ----------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------- | +| `first-responder` | 第一个有效票获胜;后来的拿 `permission_already_resolved` | 实时跨客户端协作 UX(默认) | +| `designated` | 只允许 prompt 的 `originatorClientId` 裁决;其他人收 `permission_forbidden{designated_mismatch}` | per-tenant SaaS,UI surface 自己拥有审批 | +| `consensus` | N-of-M 法定人数(pair-token 认证),过程中 `permission_partial_vote` 让 UI 渲进度 | 企业变更评审,两名操作员需达成一致 | +| `local-only` | 拒绝任何非 loopback 投票,阻塞直到 loopback 客户端裁决 | 工作站,远程控制绝不能授予提权 | + +## 职责 + +- 跟踪每个 pending 请求(`request → vote → resolved` 生命周期)。 +- 给每个请求装上 wallclock 超时(**N1 不变式**:超时必须在 `request()` **同步**装上,不然立刻 cancel 的 session 会把闭包永远 pending 漏掉)。 +- 按 `request()` 时刻捕获的策略派发投票(中途改 daemon 全局策略不影响飞行中请求)。 +- 维护有界 FIFO(`MAX_RESOLVED_PERMISSION_RECORDS = 512`),新近 resolved 的请求重复投票拿结构化 `already_resolved` 而不是 `unknown_request`。 +- 在 per-session EventBus 上发 `permission_partial_vote`(consensus)和 `permission_forbidden`(designated / consensus / local-only)。 +- 在 session teardown 时 `forgetSession(sessionId)` 把 pending 解析为 `{kind: 'cancelled', reason: 'session_closed'}`。 +- 拒绝恶意 / 误注入 `CANCEL_VOTE_SENTINEL`:wire 端 `InvalidPermissionOptionError`,agent 端 `CancelSentinelCollisionError`。 + +## 架构 + +### 公开 surface + +```ts +interface PermissionMediator { + readonly policy: PermissionPolicy; + request( + record: PermissionRequestRecord, + timeoutMs: number, + ): Promise; + vote(vote: PermissionVote): PermissionVoteOutcome; + forgetSession(sessionId: string): void; +} +``` + +`MultiClientPermissionMediator` 还有 `peekSessionFor(requestId)`、`pendingCount(sessionId)`、内部 audit publisher 等。`BridgeClient` 只依赖 `request()` 那一半(结构化 sub-typing,见 `bridgeClient.ts:30`)。 + +### `PermissionPolicy` 与 `PermissionVoteOutcome` + +```ts +type PermissionPolicy = + | 'first-responder' + | 'designated' + | 'consensus' + | 'local-only'; + +type PermissionVoteOutcome = + | { kind: 'resolved'; resolvedOptionId: string } + | { kind: 'recorded'; votesNeeded: number } // consensus 局部 + | { kind: 'already_resolved'; resolvedOptionId: string } + | { kind: 'forbidden'; reason: 'designated_mismatch' | 'remote_not_allowed' } + | { kind: 'unknown_request' }; + +type PermissionResolution = + | { kind: 'option'; optionId: string } + | { + kind: 'cancelled'; + reason: 'timeout' | 'session_closed' | 'agent_cancelled'; + }; +``` + +### Cancel 哨兵 + +`CANCEL_VOTE_SENTINEL = '__cancelled__'`。bridge 把 voter `{outcome:'cancelled'}` 映射成这个哨兵后再调 `mediator.vote`。mediator 在策略派发**之前**就处理哨兵 —— voter-cancel 在任何策略下都能用,跟 `clientId` / loopback / membership 无关。两道护栏: + +1. **`bridge.ts`** 拒掉 wire 端 `optionId === CANCEL_VOTE_SENTINEL` 的投票,抛 `InvalidPermissionOptionError`(恶意 wire 客户端不能靠假报 `optionId` 注入 cancel)。 +2. **`mediator.request`** 拒掉 `allowedOptionIds` 包含哨兵的记录,抛 `CancelSentinelCollisionError`(agent 合法发布 `'__cancelled__'` 选项标签也不能伪装成 cancel)。 + +这种刻意跨策略 escape 在 `permissionMediator.ts:50-57` 有文档说明,免得未来 maintainer 把它「修掉」。 + +### Pending 状态 + +每个 pending 按 `requestId` 索引,包含: + +- `policy` —— `request()` 时捕获。 +- `record: PermissionRequestRecord`(requestId、sessionId、originatorClientId、allowedOptionIds、issuedAtMs)。 +- `resolve` / `reject` 闭包。 +- `votesAtIssue`(仅 consensus)—— 发起时 session 上已登记的 `clientIds` 快照;后到的投票必须在这个集合里。 +- `tally`(仅 consensus)—— `Map>` 按 option 计票。 +- `timeoutHandle` —— `request()` 内同步装上的 Node timeout(N1 不变式)。 +- `auditTrail[]` —— 每票审计记录。 + +### Resolved FIFO + +`MAX_RESOLVED_PERMISSION_RECORDS = 512`,FIFO 通过 `resolvedOrder.shift()`(DeepSeek review #4335 / 3271627446,对齐 `PermissionAuditRing`)。只存 `{requestId, sessionId, outcome}`,512 条在正常 UI 重连 / race 窗口下 < 100 KB。 + +## 流程 + +### `request()`(N1 不变式) + +```mermaid +flowchart TD + A["BridgeClient.requestPermission(record, timeoutMs)"] --> B{"allowedOptionIds.has(SENTINEL)?"} + B -->|yes| C["throw CancelSentinelCollisionError"] + B -->|no| D["capture policy, snapshot votersAtIssue (consensus)"] + D --> E["new Promise: store resolve/reject"] + E --> F["arm setTimeout(timeoutMs) → resolve {cancelled, timeout}"] + F --> G["pending.set(requestId, entry)"] + G --> H["emit audit 'permission.requested'"] + H --> I["return Promise to bridge"] +``` + +定时器在 entry 对外可见**之前**就装上。否则 `forgetSession` 在 `pending.set` 与 `setTimeout` 之间到来,entry 就成了「pending 但无超时」 —— bridge 的 per-session `promptQueue` 永远 hang。 + +### `vote()` 派发 + +```mermaid +flowchart TD + V["vote({requestId, sessionId, clientId?, optionId, receivedAtMs, fromLoopback})"] --> E{"pending entry exists?"} + E -->|no| RD{"in resolved FIFO?"} + RD -->|yes| AR["return {already_resolved, resolvedOptionId}"] + RD -->|no| UR["return {unknown_request}"] + E -->|yes| SENT{"optionId == SENTINEL?"} + SENT -->|yes| CX["resolve {cancelled, agent_cancelled}; clear pending"] + SENT -->|no| POL{"policy"} + POL -->|first-responder| FR["resolve {option, optionId}; remember"] + POL -->|designated| DG{"clientId == originatorClientId?"} + DG -->|no| FOR["emit permission_forbidden{designated_mismatch}; return forbidden"] + DG -->|yes| FRR["resolve {option, optionId}; remember"] + POL -->|consensus| CN{"clientId in votersAtIssue?"} + CN -->|no| FORC["emit permission_forbidden{designated_mismatch}; return forbidden"] + CN -->|yes| TAL["tally[option].add(clientId)"] + TAL --> Q{"max(tally[*]) >= quorum?"} + Q -->|yes| RES["resolve {option, optionId}; remember"] + Q -->|no| PV["emit permission_partial_vote; return recorded"] + POL -->|local-only| LO{"fromLoopback?"} + LO -->|no| FORL["emit permission_forbidden{remote_not_allowed}; return forbidden"] + LO -->|yes| RESL["resolve {option, optionId}; remember"] +``` + +### `forgetSession()` + +session close / 剔除 / bridge shutdown 时调用。对每个 `record.sessionId === sessionId` 的 pending entry: + +1. 取消超时。 +2. 用 `{kind: 'cancelled', reason: 'session_closed'}` resolve Promise。 +3. 写一条 audit。 +4. 从 `pending` 删除。 + +bridge 的 session-teardown 路径永远在 channel-kill 窗口**之前**调 `forgetSession`,pending 不会比 session 活得久。 + +## 状态与生命周期 + +- `policy` per-request 捕获。改 daemon 全局策略不影响飞行中请求。 +- `votesAtIssue`(consensus)`request()` 时捕获;request 后到来的客户端可以投票,但 `clientId` 不在那时的快照中 → 拒为 `designated_mismatch`。和 `designated` 的 mismatch 原因刻意重载以保持契约封闭;未来版本如果 SDK 需要区分可以拆。 +- Resolved entry 在 FIFO 里活最多 `MAX_RESOLVED_PERMISSION_RECORDS`(512);evict 后对同 `requestId` 的重复投票返回 `{unknown_request}`。 +- `permission_partial_vote` 只在 `consensus` 下发,别人那不要依赖。 +- `permission_forbidden` 在 `designated` / `consensus` / `local-only` 下发,**不在** `first-responder` 下发。 + +## 依赖 + +- [`03-acp-bridge.md`](./03-acp-bridge.md) — bridge 怎么把 `BridgeClient.requestPermission` 接到 `mediator.request`。 +- [`10-event-bus.md`](./10-event-bus.md) — partial-vote / forbidden 帧怎么到客户端。 +- [`09-event-schema.md`](./09-event-schema.md) — `permission_*` 事件的 payload 契约。 +- [`08-session-lifecycle.md`](./08-session-lifecycle.md) — 每次 session 终态都会 `forgetSession()`。 +- [`02-serve-runtime.md`](./02-serve-runtime.md) — `PermissionAuditRing`(512 条 FIFO 审计)。 + +## 配置 + +| 来源 | 旋钮 | 效果 | +| --------------- | --------------------------------------------------------------------------------------------------- | -------------------- | +| `settings.json` | `policy.permissionStrategy` | 激活 mediator 策略 | +| `settings.json` | `policy.consensusQuorum` | consensus 的 N | +| `BridgeOptions` | `permissionPolicy`、`permissionConsensusQuorum`、`permissionAudit` | 程序化覆盖 | +| 能力 tag | `permission_mediation`(恒;`modes: ['first-responder', 'designated', 'consensus', 'local-only']`) | 构建期支持集 | +| 能力 envelope | `policy.permission` | 当前 daemon 跑的策略 | + +## Consensus 法定人数:默认公式与 M=2 边界 + +`consensus` 策略激活且 `policy.consensusQuorum` 没显式配置时,mediator 按 **N = floor(M/2) + 1** 算 quorum(`permissionMediator.ts:1030`,`Math.max(1, Math.floor(m / 2) + 1)`)。具体: + +| M(`votersAtIssue.size`) | 默认 N | 行为 | +| ------------------------- | ------ | ------------------------------------------ | +| 1 | 1 | 单投票者立即裁决 | +| 2 | 2 | **要求一致同意**,两个客户端必须选同一选项 | +| 3 | 2 | 多数 | +| 4 | 3 | 超过半数 | +| 5 | 3 | 多数 | +| 6 | 4 | 超过半数 | + +**M = 2** 时分票(A 选 X,B 选 Y)**只能靠 per-permission 超时**裁决 —— 哪个选项都到不了一致同意,请求挂到 `permissionResponseTimeoutMs`(默认 5 min)触发,解析为 `{cancelled, timeout}`。mediator 在 `permissionMediator.ts:486-495` 打 stderr 提示这层「一致同意 → 分票走超时」语义,operator 在日志里能看到。 + +operator 想要 M = 2 时严格多数(不要一致同意)可以显式 `policy.consensusQuorum: 1`,行为塌陷为「第一票即胜」。更宽松配置(比如 M = 4 也强制一致)也通过同字段调。 + +## Boot 时策略校验 + +`runQwenServe.validatePolicyConfig(policyConfig)`(`packages/cli/src/serve/runQwenServe.ts:89+`)在 boot 时解析合并后的 settings `policy.*` 段,operator 配错时抛 `InvalidPolicyConfigError`: + +- `policy.permissionStrategy` 设了但不在四值集合内。合法集合**运行时派生**自 `SERVE_CAPABILITY_REGISTRY.permission_mediation.modes`(单一事实源,将来加第五种策略时校验器和能力广播一起更新)。 +- `policy.consensusQuorum` 设了但不是正整数。 + +外加一条**软警告**(stderr):`consensusQuorum` 设了但 `permissionStrategy !== 'consensus'` —— override 在非 consensus 策略下会被静默丢掉,警告浮出来,operator 不会以为它生效。 + +`InvalidPolicyConfigError` 导出供测试 `instanceof`;`runQwenServe` 的 boot catch 用它区分 operator 错配(rethrow → 显式 boot 失败)和 settings 读 I/O 失败(fallback 默认)。 + +## 安全注意:v1 的 client 身份是自报 + +`X-Qwen-Client-Id` 由 HTTP 客户端**自报**,daemon 在 v1 **不做** proof-of-possession 检查。daemon 校验格式(`[A-Za-z0-9._:-]{1,128}`),按 session 跟踪 attach 的 client id 进 `clientIds`,但任何客户端只要观察到 SSE 帧里的 `originatorClientId`,就能用同 id 注册并在后续请求里冒充 originator。 + +每个策略的影响: + +- **`first-responder`** —— 不受影响,策略不依赖身份。 +- **`designated`** —— 远端客户端可以伪装 `originatorClientId`,对本应只让 prompt 发起人投票的请求投票。**`settings.json` 的 `policy.permissionStrategy` 描述里有显式标注。** +- **`consensus`** —— 投票按 issue-time `votersAtIssue` 快照闸;快照里如果已经有伪装 id(冒充者在 request 时就 attach 了),它就能投。 +- **`local-only`** —— `fromLoopback: boolean` 由 daemon 按连接的 remote address 盖戳,**不**取自客户端,所以这个策略对 id 伪装免疫,闸是按连接而非按 id。 + +「pair-token」机制(daemon 在 `POST /session` 发一个 per-session secret,`designated` / `consensus` 投票时必须带)将来 PR 落地,v1 没有。今天想加固 designated 策略的部署应当绑 loopback(`local-only` 天然 robust),或挂在做认证的反代后面。 + +## 注意 & 已知局限 + +- **Cancel 哨兵在策略派发之前路由**是刻意的 —— `local-only` 和 `consensus` 都能被任何投 `{outcome: 'cancelled'}` 的客户端取消。这是 agent 侧 abort 路径,文档在 `permissionMediator.ts:50-57`。**`local-only` 特别注意**:远端客户端**不能 RESOLVE**,但**能 ABORT** pending permission。F3 v1 把 cancel 跨策略统一是出于一致性考虑。需要严格 cancel-too(远端调用方完全不能影响 pending)的部署必须跑专用 loopback-bound daemon —— 当下没有 per-policy cancel 闸。 +- **`designated` 与 `consensus` 都用 `designated_mismatch`** 在 `PermissionVoteOutcome` 里重载;mediator 写不同 audit,但 wire 形状一致。未来协议版本可能拆。 +- **匿名投票者(无 `X-Qwen-Client-Id`)** 只在 `first-responder` 和 `local-only`(loopback)下被接受;`designated` / `consensus` 拒。 +- **跨策略 escape** 意味着 cancel 无法被策略 gate。如果部署需要 policy-gated cancel,那是未来契约变化,不要用路由级 check paper-over。 +- **`votesAtIssue` 快照语义**意味着客户端集合在变动中的 consensus 部署会拒掉合法客户端(连入晚于 request 发起)。operator 应当在发起 change-review prompt 之前预先注册协作者的 client id。 + +## 参考 + +- `packages/acp-bridge/src/permission.ts:1-177`(冻结契约) +- `packages/acp-bridge/src/permissionMediator.ts:1-1292`(实现,F3 commit 6+7) +- `packages/acp-bridge/src/bridgeClient.ts:30`(对 `PermissionMediator` 用结构化 sub-typing) +- `packages/acp-bridge/src/bridgeErrors.ts`(`CancelSentinelCollisionError`、`InvalidPermissionOptionError`、`PermissionForbiddenError`) +- `packages/cli/src/serve/permissionAudit.ts:1-60`(audit ring + publisher) +- Issue:[#4175](https://github.com/QwenLM/qwen-code/issues/4175) F3 系列。 diff --git a/docs/developers/daemon/05-mcp-transport-pool.md b/docs/developers/daemon/05-mcp-transport-pool.md new file mode 100644 index 0000000000..f4ac4fe2f4 --- /dev/null +++ b/docs/developers/daemon/05-mcp-transport-pool.md @@ -0,0 +1,396 @@ +# Workspace MCP Transport 池 +## 概览 + +`McpTransportPool`(`packages/core/src/tools/mcp-transport-pool.ts:104+`)是 F2(#4175 commit 5)的工作区级共享池:一个 daemon 上的 N 个 ACP session 共享每个唯一 `(serverName + configFingerprint)` 元组对应的一条 transport,不再各 spawn 一份 MCP 子进程。池**在 ACP 子进程里**(`QwenAgent.mcpPool`),用 daemon bootstrap `Config` 构造一次,活过 session 生命周期 —— 条目按 session attach 引用计数,refs 归零后在可配宽限期 drain 回 closed。 + +它是多 session daemon 不至于把每个 MCP server fork N 份的最大原因。 + +## 职责 + +- 每 `(name + fingerprint)` acquire 或 spawn 一条 transport,并发 cold acquire 通过 `spawnInFlight` 去重。 +- 释放 per-session 引用;最后一个引用脱离时 arm drain 定时器。 +- 用硬性 `MAX_IDLE_MS` 上限挡住 ref-count 抖动客户端无限保活。 +- 用反向索引 `sessionToEntries` 让 `releaseSession(sessionId)` 是 O(refs) 而不是 O(entries)。 +- 按需重启条目(`restartByName`):单条目返回 `{restarted, durationMs}`,多条目返回 `{entries: RestartResult[]}`(F2 multi-entry 契约)。 +- daemon shutdown 时 `drainAll` 用可配置超时排空全池;drain 期间拒绝新 acquire。 +- 与 `WorkspaceMcpBudget`(见 [`06-mcp-budget-guardrays.md`](./06-mcp-budget-guardrails.md))联动在 `acquire` 上做 per-name 预留上限;条目 close 且同名无其他 entry 时释放 slot。 +- 通过 `SessionMcpView` 给每 session 一个过滤过的 tool / prompt 快照,免得一个 session 的 discovery 把 tool 注册到其他 session。 + +## 架构 + +### 公开 surface + +```ts +class McpTransportPool { + constructor(cliConfig: Config, options: McpTransportPoolOptions); + acquire( + serverName, + cfg, + sessionId, + sessionToolRegistry, + sessionPromptRegistry, + ): Promise; + release(id, sessionId): void; + releaseSession(sessionId): void; + restartByName( + name, + opts?, + ): Promise; + drainAll(opts?): Promise; + getBudget(): WorkspaceMcpBudget | undefined; + getSnapshot(): McpPoolSnapshot; +} +``` + +`McpTransportPoolOptions`: + +- `workspaceContext: WorkspaceContext`(必填)。 +- `debugMode: boolean`。 +- `sendSdkMcpMessage?` —— per-session 回调(池绕过 SDK MCP)。 +- `pooledTransports?: ReadonlySet` —— 默认 `{stdio, websocket}`。HTTP/SSE 故意不入池(header 可能带 session 特定 OAuth state,入池会跨 session 泄漏凭证)。 +- `drainDelayMs?` —— 默认 `30_000`。 +- `entryOptions?: (transport) => PoolEntryOptions`。 +- `budget?: WorkspaceMcpBudget`。 + +### 内部状态 + +| 状态 | 类型 | 用途 | +| ------------------ | --------------------------------------- | ------------------------------------------------------------------------ | +| `entries` | `Map` | live 条目,key 为 `connectionIdOf(name, fingerprint)` | +| `unpooledIds` | `Set` | HTTP/SSE 那种非可入池 transport 的条目 | +| `spawnInFlight` | `Map>` | 并发 cold acquire 去重 | +| `sessionToEntries` | `Map>` | V21-2 反向索引,让 `releaseSession` 是 O(refs) | +| `draining` | `boolean` | Wenshao C5 drain 锁;一旦置位所有 `acquire` 都拒 | +| `nextIndexByName` | `Map` | V21-7 per server 单调 `entryIndex`(dashboard 不会因为新条目出现而抖动) | + +### `PoolEntry`(每条目结构体,`mcp-pool-entry.ts`) + +状态机:`spawning → active ⇄ (active ↔ reconnect) → (active → draining on last detach, draining → active on attach OR draining → closed on timer)`。 + +| 字段 | 用途 | +| ------------------------------------------------------ | ------------------------------------------------------------- | +| `localStatus: MCPServerStatus` | 由 `MCPServerStatus` 生命周期驱动 | +| `state: PoolEntryState` | `spawning`/`active`/`draining`/`closed`/`failed` | +| `generation: number` | 每次 restart bump,订阅者比较探测 reconnect 周期 | +| `refs: Set` | 当前 attach 的 session id 集合 | +| `subscribers: Map` | per-session 过滤视图 | +| `subscriberHandles: Map` | `acquire` 返回的 handle | +| `toolsSnapshot[]`、`promptsSnapshot[]` | 池级 canonical 快照;`toolsChanged` / `promptsChanged` 时重发 | +| `drainTimer?` | `refs.size === 0` 时装上,默认 30s;attach 时重置 | +| `maxIdleTimer?` | **首次** idle 时装上,acquire/release 抖动不重置;默认 5 min | +| `firstIdleAt?` | 硬性最大空闲的水位线 | +| `restartInFlight?` | `restart()` 的互斥 | + +### `PoolEntryOptions` + +```ts +interface PoolEntryOptions { + drainDelayMs: number; // 默认 30_000 + maxIdleMs: number; // 默认 5 * 60_000 + maxReconnectAttempts: number; // 默认 3(stdio/ws)或 5(http/sse) + reconnectStrategy: + | { kind: 'fixed'; delayMs: number } + | { kind: 'exponential'; baseMs: number; capMs: number }; +} +``` + +`defaultPoolEntryOptions(transport)`(`mcp-pool-entry.ts:58-70`):stdio/ws → `{fixed 5s, 3 次}`;http/sse → `{exponential 1s → 16s, 5 次}`。remote transport 给更长重试预算,因为它们的失败更多是 transient。 + +## 流程 + +### `acquire` + +```mermaid +sequenceDiagram + autonumber + participant S as Session + participant P as Pool + participant SIF as spawnInFlight + participant E as PoolEntry + participant BDG as WorkspaceMcpBudget + participant SRV as MCP server + + S->>P: acquire(name, cfg, sessionId, sessionToolRegistry, sessionPromptRegistry) + P->>P: refuse if draining + P->>P: connectionId = connectionIdOf(name, fingerprint) + P->>P: if !isPoolable(cfg) → mark unpooled + alt entry in entries (warm) + E-->>P: existing PoolEntry + else inflight cold spawn + SIF-->>P: existing Promise + else cold start + P->>BDG: tryReserve(name) (if budget set + poolable) + BDG-->>P: 'reserved' | 'already_held' | 'refused' + alt refused + P->>BDG: recordRefusal(name, transport) + P-->>S: BudgetExhaustedError + else ok + P->>E: spawnEntry(name, cfg) + E->>SRV: connect transport + SRV-->>E: ready + P->>P: entries.set(id, E); nextIndexByName++ + E-->>P: connected + end + end + P->>E: addSubscriber(sessionId, sessionToolRegistry, sessionPromptRegistry) + P->>P: sessionToEntries.add(sessionId, id) + P->>P: cancel drain timer (refs>0) + P-->>S: PooledConnection { id, serverName, entryIndex, client, toolsSnapshot, promptsSnapshot, on, off, release } +``` + +### `release` + drain + +```mermaid +sequenceDiagram + autonumber + participant S as Session + participant P as Pool + participant E as PoolEntry + participant BDG as WorkspaceMcpBudget + + S->>P: release(id, sessionId) + P->>E: removeSubscriber(sessionId) + P->>P: sessionToEntries.delete(sessionId, id) + alt refs > 0 + E-->>P: ok + else refs == 0 + E->>E: firstIdleAt = now (if unset) + E->>E: arm drainTimer(drainDelayMs) + E->>E: arm maxIdleTimer(maxIdleMs - elapsed) + end + Note over E: drainTimer fires → + E->>SRV: disconnect transport + E->>P: emit 'closed' + P->>P: entries.delete(id) + P->>P: if !hasNameSibling(name) → BDG.release(name) +``` + +`hasNameSibling(name)`(`mcp-transport-pool.ts:181+`)同时遍历 `entries.values()` 和 `spawnInFlight.keys()`;后者要用 `parseConnectionId` 解析(MCP server 名可以合法包含 `::`,`startsWith` 会在 sibling 名以 `${name}::` 开头时假阳性)。 + +`releaseSession(sessionId)` 从 `sessionToEntries` 读,O(refs) 释放该 session 引用的所有条目然后清索引。bridge 的 session-close 路径用它,不必遍历整个 entry map。 + +### `restartByName` + +```mermaid +sequenceDiagram + autonumber + participant Op as POST /workspace/mcp/:server/restart + participant P as Pool + participant E as PoolEntry + participant SRV as MCP server + + Op->>P: restartByName(name, opts?) + alt opts.entryIndex specified + P->>E: find entry by (name, entryIndex) + else + P->>P: gather all entries with matching name + end + par per entry + P->>E: restart() (mutex via restartInFlight) + E->>SRV: disconnect + E->>SRV: reconnect + E->>E: bump generation, re-emit snapshots + end + alt single entry + P-->>Op: {restarted: true, durationMs} + else multi-entry + P-->>Op: {entries: [{restarted, durationMs, entryIndex}, ...]} + end +``` + +daemon HTTP 层的预检(Wave-4 PR 17):目标 slot 没有被预留,且重启会让 live count 超 `enforce` 预算时,返回 `{restarted:false, skipped:true, reason:'budget_would_exceed'}`。 + +### `drainAll` + +```mermaid +sequenceDiagram + autonumber + participant D as Daemon shutdown + participant P as Pool + participant E as PoolEntries + + D->>P: drainAll({timeoutMs?}) + P->>P: draining = true (refuse new acquires) + par for each entry + P->>E: trigger drain (close transport, clear timers) + E-->>P: closed + end + P-->>D: done (or timeout reached, force close) +``` + +## 状态与生命周期 + +- 池构造同步;首次 `acquire` 冷启动 transport。 +- `drainDelayMs`(默认 30s)attach 时取消。 +- `maxIdleMs`(默认 5 min)attach/detach 抖动**不**重置;从**首次** idle 起跳,到点或在 deadline 前 attach 才停。挡 thrashing 客户端。 +- `nextIndexByName` 单调。新条目出现后老条目保留原 index,dashboard 读 `entryIndex` 不抖。 +- Spawn 失败释放预留的 budget slot(V21-4,否则 cold spawn 在 connect 中途崩会永远漏 reservation)。 + +## 依赖 + +- `packages/core/src/tools/mcp-client.ts`:`McpClient`、status 枚举、`SendSdkMcpMessage`。 +- `packages/core/src/tools/mcp-pool-entry.ts`:`PoolEntry`、`PoolEntryOptions`、`defaultPoolEntryOptions`。 +- `packages/core/src/tools/mcp-pool-key.ts`:`connectionIdOf`、`parseConnectionId`、`isPoolable`、`mcpTransportOf`、`POOLED_TRANSPORTS_DEFAULT`。 +- `packages/core/src/tools/mcp-pool-events.ts`:`ConnectionId`、`PoolEntryState`、`PoolEvent`。 +- `packages/core/src/tools/session-mcp-view.ts`:per-session 过滤视图。 +- `packages/core/src/tools/mcp-workspace-budget.ts`:`WorkspaceMcpBudget`(见 [`06-mcp-budget-guardrails.md`](./06-mcp-budget-guardrails.md))。 +- `packages/core/src/tools/mcp-discovery-timeout.ts`:`discoveryTimeoutFor`、`runWithTimeout`。 + +## 配置 + +| 来源 | 旋钮 | 效果 | +| ---------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Env | `QWEN_SERVE_NO_MCP_POOL=1` | 杀手锏 —— `QwenAgent.mcpPool` 保持 undefined,回退到 per-session `McpClientManager`(pre-F2 路径) | +| 参数 | `--mcp-client-budget=N`、`--mcp-budget-mode={off,warn,enforce}` | 通过 `childEnvOverrides` 传 ACP 子进程;子进程构造 `WorkspaceMcpBudget` 喂给池 | +| 能力 tag(条件) | `mcp_workspace_pool`、`mcp_pool_restart` | 池开启时一起广播。SDK 都 pre-flight 才能依赖 pool-aware 响应形状 | + +### 非入池条目(HTTP / SSE / SDK-MCP) + +`pooledTransports` 之外的 transport(HTTP、SSE、SDK-MCP)走另一条路:`createUnpooledConnection(name, cfg, sessionId, ...)`(`mcp-transport-pool.ts:1142`)按 session 起一条 entry,id 形如 `${name}::unpooled-${entryIndex}`。与入池条目的差异: + +- 同时存到 `entries` 和 `unpooledIds: Set`,`release` / `releaseSession` 能快速走 detach-即关 的路径(refs 永远最多 1)。 +- 直接调 `McpClient.discover()`,不走池的重放;`applyTools` / `applyPrompts` 都是 no-op,因为 session 的 registry 自己已经持有刚注册的内容(W77 / `attach()` 里 `skipReplay: true`)。 +- workspace 预算照样闸 —— F2 commit 6 关掉了之前 unpooled 绕过 `tryReserve` 的口子;不管入不入池,同一个 `WorkspaceMcpBudget` slot 都被预留,entry close 时释放。 + +W77 竞态(`cb206da36`):`createUnpooledConnection` 在 await `client.connect()` / `client.discover()` 之前就把 entry 放进 `this.entries`,但只在 `attach()` 成功之后才往 `sessionToEntries[sessionId]` 索引。connect/discover 窗口里并发到来的 `closeStoredSession()` / `releaseSession(sessionId)` 看到空索引,让 unpooled spawn 跑完,`attach()` 接着把 tool/prompt 注册到一个已经关闭的 session。修复: + +- `mcp-pool-entry.ts:520`:公开 `isTerminated(): boolean` 探针(`state === 'closed' || state === 'failed'`)。 +- `mcp-pool-entry.ts:529`:`markActive()` 在 `isTerminated()` 时短路,已拆掉的 entry 不能被复活到 `'active'`。 +- 调用方(池的 unpooled 路径)在 await 之间探 `isTerminated()`,父 session 没了就放弃 attach。 + +这条 race 今天**潜在**(W61/W71 的 per-session `releaseSession` hook 在 F4 才落),但那个 hook 一到这条 race 就变 live —— F2 线上先把它修了。 + +## `GET /workspace/mcp` 的 pool-aware 快照字段 + +池激活时,`ServeWorkspaceMcpStatus` 每个 server cell(`packages/acp-bridge/src/status.ts:171-200`,类型主定义在 242)多三个字段: + +| 字段 | 类型 | 用途 | +| ---------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `disabledReason` | `'config' \| 'budget'` | 区分 operator 禁用(`disabled: true` 来自 `disabledMcpServers` 配置)和预算拒绝(`status: 'error', errorKind: 'budget_exhausted'`)。operator 在 dashboard 上不必交叉查 `errors[]` 或 `budgets[]` 才能渲染单 server 行 | +| `entryCount` | `number`(≥1) | 池模式工作区上同名可有多条 `PoolEntry`(session 注入不同 fingerprint,如 per-session OAuth header)。`QWEN_SERVE_NO_MCP_POOL=1` 关闭池时该字段不存在。新客户端按 `entryCount > 1` 渲「N 条 entry」徽章 | +| `entrySummary` | `ReadonlyArray<{entryIndex, refs, status}>` | per-entry 分解。`entryIndex` 是 entry 创建时分配的**稳定不透明整数** —— **不是**原始 fingerprint,否则会通过快照 diff 泄漏 OAuth/env 轮换时机。`refs` 是当前 attach 的 session 数。`status` 是 per-entry 运行时状态,dashboard 在聚合 `mcpStatus` 已经 `connected` 但某条 entry 还在重连时仍能显示分项健康 | + +`(entryCount, entrySummary)` **广播时永远成对**出现 —— `mcp_workspace_pool` 能力 tag 蕴含两者。老 SDK 客户端按加法协议契约忽略它们。 + +池快照里还有一个 `subprocessCount` 计数:**只数 `'stdio'` 家族**。websocket / HTTP / SSE 是拨远端 server,本地无 child 进程;早期版本错把 websocket 计入,会让本地资源仪表板虚高。 + +## 关闭路径:drain 在两条入口都触发 + +池 drain 不只跑 SIGTERM handler —— IDE 发起的正常关闭路径(`await connection.closed`)也调 `drainAll`。两条路径互为镜像(`packages/cli/src/acp-integration/acpAgent.ts:231-332`,`drainPoolBeforeExit` 助手定义在 231,分别在 306(signal)和 332(ide_close)调用),无论 daemon 是被信号杀掉还是 IDE 干净挂断 connection,pool 都会进 `draining` 状态、拒绝新 acquire、并等所有 entry 关闭。 + +## `/mcp refresh` 与 boot 期发现走同一池路径 + +`discoverAllMcpTools`(boot 期发现)和 `discoverAllMcpToolsIncremental`(`/mcp refresh` / 热加载)在池模式下都先查池(`packages/core/src/tools/mcp-client-manager.ts:1977`)。两条 discovery 路径共用同一 gate,避免热加载意外起 per-session client、双算 budget、留下孤儿 transport。 + +## 重连期间 in-flight 工具调用(`MCPCallInterruptedError`) + +底层 MCP transport 静默掉线(连接从 `'active'` / `'draining'` 直接进 `localStatus === DISCONNECTED`,没有显式关闭)时,池把 entry 转 `'failed'`、从 `pool.entries` 驱逐、在 detach 订阅者视图**之前**先 emit `failed` 事件(`mcp-pool-entry.ts:358-440`,emit 在 387)。emit-先于-detach 的顺序重要:订阅者及时收到 `failed` 事件能把 pending `callTool` promise 路由到 `MCPCallInterruptedError`,卡住的 `await client.callTool(...)` 干净 reject 而不是 hang。`forceShutdown` 走的也是同样 emit→detach 顺序(`mcp-pool-entry.ts:720+`,`forceShutdown` 入口 720、emit 在 757)。 + +## Fingerprint 与 `canonicalOAuth` 归一 + +池 key 由 `fingerprint(cfg)`(`mcp-pool-key.ts:128+`)计算。哈希字段覆盖所有 transport 定义性的: + +> `transport, command, args, cwd, env, url, httpUrl, tcp, headers, timeout, oauth` + +per-session 过滤 / 元数据字段(`includeTools`、`excludeTools`、`trust`、`description`、`extensionName`、`discoveryTimeoutMs`)**被排除**,不同 session 用不同过滤共享同一 entry。 + +OAuth 这一格,`canonicalOAuth(o)`(`mcp-pool-key.ts:75-110`)哈希**每一个** `MCPOAuthConfig` 字段 —— `clientId`、`clientSecret`、`scopes`(排序后)、`audiences`(排序后)、`authorizationUrl`、`tokenUrl`、`redirectUri`、`tokenParamName`、`registrationUrl`。**这是凭证隔离的关键**:仅在 `clientSecret` / `audiences` / `redirectUri` 等字段上有差异的两个 session config 会被正确视作不同 fingerprint,不会共享一条 entry。confidential client(带 `clientSecret`)和 multi-audience token 部署最依赖这条契约。 + +scope 数组和 audience 数组排序,callsite 顺序不会改 fingerprint;显式 `null` 默认让 undefined 字段哈希等于显式 null。key 里没有 `discoveryTimeoutMs` —— 同 key 不同 timeout 并发 acquire 是「first wins」(对齐 pre-F2 per-session manager 行为)。 + +`PoolEntry` 持有的 `cfg: MCPServerConfig` 字段是**私有**的,外部代码读 transport 家族要走 `entry.transportKind` getter。这是防止 env / header auth / OAuth 等敏感字段被外部消费方意外读到。 + +## Extension 卸载:孤儿 entry 由 MAX_IDLE_MS 自然回收 + +设计上**不**为运行中卸载 MCP extension 加主动回收路径。孤儿 entry(extension 的 `MCPServerConfig` 已不在工作区合并设置里但池里还有 entry)由最后一个订阅者 detach 后的 `MAX_IDLE_MS`(默认 5 min)硬上限自然回收。同步的卸载-回收路径会为 operator 罕见的边缘场景加复杂度,硬上限把孤儿进程超过卸载点的最坏寿命限到 5 分钟。 + +operator 想要更快的孤儿清理可以重启 daemon 或对已不再配置的 name 触发 `POST /workspace/mcp/:server/restart` —— 会走 disabled-server 路径把 entry 拆掉。 + +## 自愈观测:transport 错误捕获 + sweep 结果 + +池底层 self-heal 路径有两块结构化诊断输出: + +**`McpClient.lastTransportError: Error | undefined`**(`packages/core/src/tools/mcp-client.ts:106-161, 336-346`)—— `McpClient.onerror` 把最近一次 transport 异常落到私有字段,`connect()` 入口处清零。`PoolEntry` 的「silent transport drop → 'failed'」分支(见上节)通过 `client.getLastTransportError()` 把上游错误透到 `emit({kind:'failed', lastError})` 里,subscriber / dashboard 不必再去 grep stderr 推因。 + +**`SweepResult`**(内部 interface,**不导出**;`packages/core/src/tools/mcp-pool-entry.ts:130+`)—— `sweepAndDisconnect(reason)` 返 `Promise`: + +```ts +interface SweepResult { + pidSweepError?: Error; // listDescendantPids 自身抛了 + descendantsFound?: number; // 找到的子孙 pid 数 + descendantsSignaled?: number; // 成功 SIGTERM 的数(可能 < found) +} +``` + +消费方只有 `statusChangeListener` 里的 silent-drop 块。它通过 `descendantsFound` / `descendantsSignaled` 判断 **partial-signal**(信号数少于发现数,子进程在 `listDescendantPids` 与 `sigtermPids` 之间退了或 EPERM)以及 **sweep 本身报错**,结构化打 warn 日志。`forceShutdown` / `doRestart` 路径忽略这个返回 —— 自带 catch 路径已经有更丰富的错误信号。 + +## 子进程清理:`pid-descendants` 的快照路径 + +`McpTransportPool` 关停 stdio 子进程时要枚举它们的子孙进程(npx 包装、shell wrapper 等多层 fork 都要被回收)。`packages/core/src/tools/pid-descendants.ts` 暴露 `listDescendantPids(rootPid) → Promise` + `sigtermPids(pids)` 两个原语,给 `sweepAndDisconnect` 用。 + +### Linux / macOS 主路径 + +单次 `ps -A -o pid=,ppid=` 快照把整张进程表读出来 → 解析成 `Map` → `walkDescendants(tree, root)` 做 BFS 拿出整棵子树。任何深度都只 fork 一次 `ps`。 + +`walkDescendants` 维护 `visited: Set`(`root` 也进 visited)防 PID-reuse 循环 —— 快进程 churn 下 `ps -A` 启动到读完之间可能发生 wraparound,理论上能在快照里看到 A→B / B→A 环,没 visited 防御会把 `MAX_DESCENDANTS` 配额填满假数据,挤掉真正的子孙。 + +### Windows 主路径 + +单次 `Get-CimInstance Win32_Process | ConvertTo-Csv -Delimiter ","` 快照所有 `(ProcessId, ParentProcessId)` 行,同样落到 `Map` 后走 `walkDescendants`。 + +`-Delimiter ","` 是显式的,**不能省**。PowerShell 5.1(Windows 自带的版本)`ConvertTo-Csv` 默认遵守系统 locale 的列表分隔符;DE / FR / NL / IT 等 locale 用 `;`,pre-fix 正则 `^"(\d+)","(\d+)"$` 永远不匹配,每次 daemon shutdown 都会回退到 per-pid CIM filter 路径,每个子进程多 ~0.5-1s PowerShell 启动开销。 + +### Fallback 路径 + +BusyBox `` BFS,Windows 用 `Get-CimInstance -Filter "ParentProcessId=$p"`(`$p` 是 PowerShell 变量绑定,不是字符串拼接 —— 入口的 `Number.isInteger` 守护今天就够,绑定是 defense-in-depth)。 + +### 共同约束 + +两条路径都受 `MAX_DESCENDANTS = 256` / `MAX_DEPTH = 8` 上限保护,防止恶意或退化的进程树把 sweep 拖垮。 + +snapshot 路径 `maxBuffer: 8MB` 覆盖 ~250k 进程的病态主机;默认 1MB 会在 ~30k 进程时截断 child-process 输出。 + +性能侧只是**轻度收益**(典型 200-500 进程的开发机解析 < 10ms,相比 per-pid pgrep ~2× 改进);主要收益是 **fork hygiene + 快照一致性**:BFS 一次性看到完整子树,而 pre-fix 的「逐 pid 询问」会在两次询问之间漏掉新 fork 的孙进程。 + +## 嵌入方注意:`McpClientManager` 构造签名 + +`McpClientManager` 的构造签名是 `(config, toolRegistry, options?: McpClientManagerOptions)`。直接 import 该类的嵌入方传: + +```ts +new McpClientManager(config, toolRegistry, { + eventEmitter, + sendSdkMcpMessage, + healthConfig, + budgetConfig, + pool, +}); +``` + +测试侧推荐用 `mkManager(overrides?)` factory 把只关心一两个字段的 case 写成单行。 + +## 实现笔记(内部 helper / 优化,不影响 API) + +下游不直接使用但 grep 源码会撞到的内部结构: + +- `McpTransportPool.acquire()` 内部两个 helper `attachPooledSession` 与 `rollbackReservationOnSpawnFailure` 把 fast-path attach / post-spawn attach / pooled spawn-in-flight catch 三处共用代码集中(行为不变;race-window 不变式仍由调用点描述)。 +- `SessionMcpView.applyTools` / `applyPrompts` 用 `compileNameFilter(cfg)` 一次性把 `includeTools` / `excludeTools` 编译成 Set,per-tool 命中走 `compiledFilterAccepts(compiled, name)`。导出 `passesSessionFilter` / `passesSessionPromptFilter` 仍然走同一编译路径(单一事实来源)。`excludeTools` 直接等值;`includeTools` 剥首个 `(...)` 后缀让 `toolName(args)` 匹配 `toolName`。 + +设计文档:[`../../design/f2-mcp-transport-pool.md`](../../design/f2-mcp-transport-pool.md) §6 全章覆盖 transport 池的状态机、reconnect、drain、descendant sweep。 + +## 注意 & 已知局限 + +- **HTTP / SSE transport 不入池** —— 每次 acquire 新起一条只活 session 那么久。原因:header 可能带 session 特定 OAuth state,入池会跨 session 泄漏凭证。 +- **`maxIdleMs` 是抗抖动硬上限**。5 分钟硬空闲意味着即使激进 attach/detach 也不能让 idle transport 钉超 5 分钟。想要长期常驻 transport 的 operator 应该调大 `maxIdleMs` 或者把 server 跑在池外面。 +- **per-server-name 预算 slot** 意味着同名不同 fingerprint 的两条入池条目共占 ONE slot 而不是两个。子进程账面分开通过 `pool.getSnapshot().subprocessCount` 暴露。 +- **`startsWith` 回归** 在 `hasNameSibling` 里被规避,因为 MCP server 名可以合法含 `::`(`mcp-pool-key.test.ts:258`);永远用 `parseConnectionId` 的 `lastIndexOf('::')` 切,不要用字符串前缀匹配。 +- **池 drain 是单向**:`drainAll` 永久置 `draining = true`;要再 work 必须新池。 + +## 参考 + +- `packages/core/src/tools/mcp-transport-pool.ts`(整文件;关键行 104+、181+、208+) +- `packages/core/src/tools/mcp-pool-entry.ts:1-120+`(entry 生命周期) +- `packages/core/src/tools/mcp-pool-key.ts`(`connectionIdOf`、`parseConnectionId`) +- `packages/core/src/tools/mcp-pool-events.ts`(事件类型) +- `packages/core/src/tools/session-mcp-view.ts`(per-session 过滤视图) +- F2 设计文档(v2.2,含 32 条 review fold-in):[`../../design/f2-mcp-transport-pool.md`](../../design/f2-mcp-transport-pool.md)。实现契约的事实源;本篇是它的开发者深度阅读。 +- F2 设计笔记:issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175)(F2 系列 commit 4-6)。 diff --git a/docs/developers/daemon/06-mcp-budget-guardrails.md b/docs/developers/daemon/06-mcp-budget-guardrails.md new file mode 100644 index 0000000000..f7386e0c0f --- /dev/null +++ b/docs/developers/daemon/06-mcp-budget-guardrails.md @@ -0,0 +1,152 @@ +# MCP 工作区预算护栏 +## 概览 + +`WorkspaceMcpBudget`(`packages/core/src/tools/mcp-workspace-budget.ts:55+`)是 F2(#4175 commit 6)的工作区级 MCP client 预算控制器。它持有的状态机和 `McpClientManager` inline 的完全一样(slot 预留、75% 滞回警告、跨 `discoverAllMcpTools*` 一遍 pass 合并 refused-batch),但**一 workspace 一份**住在 `McpTransportPool` 里,而不是每个 ACP child 的 manager 里 N 份。池把 `acquire` / `release` 委托给它,于是上限是**工作区**级上限不是每 session 级。 + +老的 `McpClientManager` 预算机器保留给独立 qwen 和 SDK MCP server(commit 4 的修复让它们绕过池)。池模式 → `WorkspaceMcpBudget` 强制;standalone / SDK MCP → manager inline 机器强制。不会双数:池模式 discovery 永不调 manager 的 `tryReserveSlot`。 + +## 职责 + +- 跟踪 `reservedSlots: Set`(当前持有的 server NAME,slot key per-NAME,对齐 PR 14 v1)。 +- `tryReserve(name) → 'reserved' | 'already_held' | 'refused'` —— 原子同步,并发 `Promise.all` acquire 不能在 await 边界偷过上限。 +- `release(name) → boolean` —— 幂等(`Set.delete` 语义)。 +- `reservedSlots.size / clientBudget` 上升越过 75% 时发一次 `mcp_budget_warning`;下降越过 37.5% 才重新装填。 +- 在 bulk discovery pass 内合并 per-server 拒绝 —— `beginBulkPass()` / `endBulkPass()` 包围期间所有拒绝累成一次 `mcp_child_refused_batch` 事件。 +- 维护 `lastRefusedServerNames` 给快照消费者(`GET /workspace/mcp`)—— 下一个 bulk pass **开始时**才清掉,不是 emit 时;夹在两 pass 之间的快照还能看到上一批拒绝。 + +## 架构 + +### 配置 + +```ts +new WorkspaceMcpBudget({ + clientBudget?: number, // undefined = 不限 + mode: 'off' | 'warn' | 'enforce', + onEvent?: (event: McpBudgetEvent) => void, +}); +``` + +`mode`: + +- `off` —— 所有方法 no-op;`tryReserve` 无条件返回 `'reserved'`;无事件。 +- `warn` —— 跟踪 slot 并在 75% 发 `mcp_budget_warning`,但 `tryReserve` 永不拒绝。 +- `enforce` —— `tryReserve` 超 `clientBudget` 时拒绝;`recordRefusal` 排队 per-server 拒绝;`endBulkPass` 发 `mcp_child_refused_batch`。 + +### 来自 `mcp-client-manager.ts` 的常量 + +- `MCP_BUDGET_WARN_FRACTION = 0.75`。 +- `MCP_BUDGET_REARM_FRACTION = 0.375`。 +- `McpBudgetMode = 'off' | 'warn' | 'enforce'`。 + +### 内部状态 + +| 状态 | 用途 | +| -------------------------------------------------- | ----------------------------------------------------------------------------- | +| `reservedSlots: Set` | 权威预留集合;滞回评估 `size / clientBudget` | +| `pendingRefusalNames: Set` | 当前 `beginBulkPass` / `endBulkPass` 窗口内累积的拒绝名;`endBulkPass` 时排空 | +| `pendingRefusalTransports: Map` | 给 emit 的 batch 带每个拒绝 server 的 transport | +| `lastRefusedServerNames: readonly string[]` | 上一个完成 pass 的拒绝列表,快照可见;下一个 pass 开始才清 | +| `warnArmed: boolean` | 滞回状态 —— true = 准备好发,false = 已发未 37.5% 下回 | +| `bulkPassDepth: number` | 嵌套 bulk pass 计数(嵌套时不能双发) | + +## 流程 + +### `tryReserve` + +```mermaid +flowchart TD + A["tryReserve(serverName)"] --> B{"reservedSlots.has(name)?"} + B -->|yes| AH["return 'already_held'"] + B -->|no| C{"budget undefined OR mode == 'off'?"} + C -->|yes| R["return 'reserved'"] + C -->|no| D{"mode == 'enforce' AND size >= budget?"} + D -->|yes| RF["return 'refused'"] + D -->|no| ADD["reservedSlots.add(name)"] + ADD --> EV["evaluateState() (hysteresis check)"] + EV --> R2["return 'reserved'"] +``` + +`tryReserve` 是**同步**的。池的 `acquire` 是 async,但 reservation 在任何 `await` 之前完成,两个并发 `Promise.all` acquire 不同名的请求不可能都挤过上限。 + +### 滞回 + +```mermaid +flowchart TD + EV["evaluateState() called after every mutation"] --> R["ratio = reservedSlots.size / clientBudget"] + R --> U{"warnArmed && ratio >= 0.75?"} + U -->|yes| FIRE["fire mcp_budget_warning; warnArmed = false"] + U -->|no| D{"!warnArmed && ratio <= 0.375?"} + D -->|yes| ARM["warnArmed = true"] + D -->|no| NOOP[no-op] +``` + +滞回防 75% 上下抖时的 spam。首次越过发一次;不再下到 37.5% 时后续越过不发。 + +### 拒绝-批合并 + +```mermaid +sequenceDiagram + autonumber + participant POOL as pool.discoverAllMcpToolsViaPool + participant BDG as WorkspaceMcpBudget + participant EB as EventBus + + POOL->>BDG: beginBulkPass() + BDG->>BDG: bulkPassDepth++
clear lastRefusedServerNames if outermost + loop per server in pass + POOL->>BDG: tryReserve(name) + alt refused + POOL->>BDG: recordRefusal(name, transport) + BDG->>BDG: pendingRefusalNames.add; pendingRefusalTransports.set + Note over BDG: NO event yet (coalesce) + end + end + POOL->>BDG: endBulkPass() + BDG->>BDG: bulkPassDepth-- + alt outermost (depth == 0) AND pending non-empty + BDG->>EB: emit mcp_child_refused_batch
{refusedServers, budget, liveCount, reservedCount, mode: 'enforce', scope?: 'workspace'} + BDG->>BDG: lastRefusedServerNames = drain pendingRefusalNames + end +``` + +pass 之外的拒绝(比如 lazy `readResource` spawn 完全绕过 bulk pass)inline 发 length-1 batch 保持形状一致。嵌套 pass(`bulkPassDepth > 0`)不发;只有最外层 end-of-pass 才发合并的 batch。 + +## 状态与生命周期 + +- 预算控制器在池初始化时一 workspace 一份构造。 +- `clientBudget` 构造后不可变;运行时改动要重建池。 +- `mode` 也不可变(`mode === 'off'` 时 `onEvent` 被 stash 为 `undefined`,defense in depth)。 +- `warnArmed` 初始 true;37.5% 下回时 reset 为 true。 +- `lastRefusedServerNames` 在 `endBulkPass` emit 时**不**清;只在下个 bulk pass 开始时清。这让两 pass 之间的快照路由还能报告上一批拒绝集合(否则 refused-batch 事件刚送达 dashboard 就空了)。 + +## 依赖 + +- `packages/core/src/tools/mcp-client-manager.ts` —— 复用 `McpBudgetEvent`、`McpBudgetMode`、`McpRefusedServer`、`MCP_BUDGET_WARN_FRACTION`、`MCP_BUDGET_REARM_FRACTION`、`BudgetExhaustedError`(refused 时由池的 `acquire` 抛)。 +- `packages/core/src/tools/mcp-transport-pool.ts` —— 消费 budget;通过池的 `onEvent` 把事件喂到 daemon EventBus。 +- daemon 快照路由 `GET /workspace/mcp` —— 读 `getReservedSlots()`、`getRefusedServerNames()`、`getReservedCount()`、`getBudget()`、`getMode()`。 + +## 配置 + +| 来源 | 旋钮 | 效果 | +| -------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| 参数 | `--mcp-client-budget=N` | 设 `clientBudget` | +| 参数 | `--mcp-budget-mode={off,warn,enforce}` | 设 `mode`;`enforce` 要求正整数 `clientBudget`(否则 boot-loud 拒) | +| Env | `QWEN_SERVE_MCP_CLIENT_BUDGET`、`QWEN_SERVE_MCP_BUDGET_MODE` | 通过 `childEnvOverrides` 传 ACP 子进程,子进程的 `readBudgetFromEnv()` 接 | +| 能力 tag | `mcp_guardrails`(恒;`modes: ['warn', 'enforce']`)、`mcp_guardrail_events`(恒) | 见 [`11-capabilities-versioning.md`](./11-capabilities-versioning.md) | + +## 注意 & 已知局限 + +- **预留 key 是 per-NAME**。同名不同 fingerprint(session 注入不同 OAuth header)的两条池条目共占 ONE slot。子进程账面通过池快照的 `subprocessCount` 单独暴露。operator 应当把预算理解为「配置 server slot 数」而不是「子进程数」。 +- **滞回基于预留数不是 live(CONNECTED)数**。reservation 包括 in-flight connect 且 survive 短暂 disconnect,所以滞回在重连周期里稳定。live count 也在事件 payload 的 `liveCount` 里暴露给想看那个 lens 的 SDK。 +- **`warn` 模式永不拒绝**。仍然跟踪并发 `mcp_budget_warning`,但 `tryReserve` 总返 `'reserved'`。拒绝语义只有 `enforce`。 +- **工作区级 budget 事件带 `scope: 'workspace'`** 同时扇出给所有 attach 的 session;SDK reducer 的 `mcpBudgetWarningCount` / `mcpChildRefusedBatchCount` 在同一 connection 上的 session 之间齐步增长。`McpClientManager` 的 per-session 老事件无 `scope`(语义默认 `'session'`)。 +- **杀手锏 `QWEN_SERVE_NO_MCP_POOL=1`** 完全禁池;workspace budget 也禁,回到 per-session `McpClientManager` budget。capabilities envelope 诚实地不广播 `mcp_workspace_pool` / `mcp_pool_restart`。 +- **`ServeMcpBudgetStatusCell.scope`** 是向前兼容的**列表**形状(`budgets[]`)而不是单一 `budget?` 字段。PR 14 v1 发一条 `scope: 'session'`(每个 ACP session 通过 `acpAgent.newSessionConfig()` 创建自己的 `Config` / `McpClientManager`)。`'pool'` scope 是**预留**给 Wave 5 PR 23(与 session-scoped cell 并列的 pool-scoped cell)—— 消费方**必须**容忍未知 `scope` 值的额外条目(丢掉而不是失败),让未来扩展不破 schema。 + +## 参考 + +- `packages/core/src/tools/mcp-workspace-budget.ts:1-200+`(整 class) +- `packages/core/src/tools/mcp-client-manager.ts`(`BudgetExhaustedError`、`McpBudgetEvent`、滞回常量) +- `packages/core/src/tools/mcp-transport-pool.ts:208+`(池 `acquire` 调 `tryReserve` 的站点) +- F2 设计文档(v2.2):[`../../design/f2-mcp-transport-pool.md`](../../design/f2-mcp-transport-pool.md) §11(workspace 级 budget)以及 v2.2 changelog 中 W21 / W77 / W88 / W121 / W122 / R3 关于预算与 fingerprint 的 fold-in。 +- F2 设计笔记:issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) commit 6。 diff --git a/docs/developers/daemon/07-workspace-filesystem.md b/docs/developers/daemon/07-workspace-filesystem.md new file mode 100644 index 0000000000..f68bb69443 --- /dev/null +++ b/docs/developers/daemon/07-workspace-filesystem.md @@ -0,0 +1,230 @@ +# Workspace 文件系统边界 +## 概览 + +daemon 不让 HTTP 路由或 ACP 侧 agent 直接碰宿主文件系统。所有 read、write、list、glob、stat 都过 `WorkspaceFileSystem` 边界(`packages/cli/src/serve/fs/`): + +- **路径解析** —— canonicalize + 拒绝任何越出 bound workspace 的路径(包括通过 symlink)。 +- **信任 gate** —— workspace 不被信任时拒写(`untrusted_workspace`)。 +- **大小 & 内容策略** —— 读上限(`MAX_READ_BYTES = 256 KiB`)、写上限(`MAX_WRITE_BYTES = 5 MiB`)、二进制检测。 +- **原子性** —— write-then-rename,保留目标 mode,新建文件默认 `0o600`。 +- **审计** —— 每次 access / denial 发结构化事件给 `PermissionAuditRing` / 监控。 +- **typed error** —— 封闭 `FsErrorKind` 联合 ↔ HTTP 状态码。 + +HTTP 文件路由(`GET /file`、`GET /file/bytes`、`POST /file/write`、`POST /file/edit`、`GET /list`、`GET /glob`、`GET /stat`)和 ACP 侧 `BridgeFileSystem` 适配器(agent 触发的 `readTextFile` / `writeTextFile` 也拿到同样的护栏)都过这个边界。 + +## 职责 + +- 把用户传入的路径解析成 branded `ResolvedPath`,下游安全使用。 +- 拒绝 workspace 外的路径(`path_outside_workspace`)和 target 是 symlink 的路径(`symlink_escape`)。 +- 拒绝读超 `MAX_READ_BYTES` / 写超 `MAX_WRITE_BYTES` / 二进制文件(`binary_file`)。 +- workspace 不被信任时拒写 / edit(`untrusted_workspace`) —— 由 `assertTrustedForIntent(intent)` 闸。 +- 遵循 `.gitignore` / `.qwenignore` 模式(`shouldIgnore`)。 +- 原子 write-then-rename,保留目标 mode;新建文件默认 `0o600`。 +- 每次操作发 `fs.access` / `fs.denied` 审计事件。 +- 每次失败都映射到 `FsError`(kind + HTTP 状态),路由 handler 统一序列化。 + +## 架构 + +### 模块布局 + +| 文件 | 用途 | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `paths.ts` | `canonicalizeWorkspace`、`resolveWithinWorkspace`、`hasSuspiciousPathPattern`、branded `ResolvedPath`、`Intent`(`read \| write \| list \| stat \| glob`) | +| `policy.ts` | `MAX_READ_BYTES`、`MAX_WRITE_BYTES`、`BINARY_PROBE_BYTES`、`assertTrustedForIntent`、`detectBinary`、`enforceReadBytesSize`、`enforceReadSize`、`enforceWriteSize`、`shouldIgnore` | +| `audit.ts` | `FS_ACCESS_EVENT_TYPE`、`FS_DENIED_EVENT_TYPE`、`createAuditPublisher`、audit payload 类型 | +| `errors.ts` | `FsError` 类、`isFsError`、`FsErrorKind`(13 种)、`FsErrorStatus`(`400 / 403 / 404 / 409 / 413 / 422 / 500 / 503`) | +| `workspaceFileSystem.ts` | `createWorkspaceFileSystemFactory`、`WorkspaceFileSystem`、`WriteMode`、`ContentHash`、`FsEntry`、`FsStat`、`ListOptions`、`GlobOptions`、`ReadTextOptions`、`ReadBytesOptions`、`WriteTextAtomicOptions` | + +### `FsErrorKind` 分类 + +| Kind | 默认 HTTP | 含义 | +| ------------------------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `path_outside_workspace` | 400 | 解析后的路径在 workspace 外 | +| `symlink_escape` | 400 | target 是 symlink(PR 18 + PR 20 的保守姿态) | +| `path_not_found` | 404 | `ENOENT` | +| `binary_file` | 422 | text 路由上 sniff 到二进制 | +| `file_too_large` | 413 | 超 `MAX_READ_BYTES` 或 `MAX_WRITE_BYTES` | +| `hash_mismatch` | 409 | 乐观并发 `expectedSha256` 不匹配 | +| `file_already_exists` | 409 | `mode: 'create'` 而文件已存在 | +| `text_not_found` | 422 | `POST /file/edit` 的 search 字符串不在文件里 | +| `ambiguous_text_match` | 422 | 需要唯一匹配但匹配到多处 | +| `untrusted_workspace` | 403 | 写在不被信任的 workspace | +| `permission_denied` | 403 | OS 级 `EACCES` / `EPERM` | +| `io_error` | 503 | `ENOSPC` / `EIO` / `EBUSY` / `ETXTBSY` / `ENAMETOOLONG` / `EMFILE` / `ENFILE`。**与 `permission_denied` 严格区分**,否则监控按 errorKind 告警会把「磁盘满」错挂到安全 oncall | +| `internal_error` | 500 | 非 errno 的边界 error(`TypeError`、bug) | +| `parse_error` | 400 / 422 | 请求体解析 error(400)或服务级不变式破坏(422) | + +### `BridgeFileSystem`(ACP 侧适配器) + +`packages/acp-bridge/src/bridgeFileSystem.ts:39-97`: + +```ts +interface BridgeFileSystem { + readText(params: ReadTextFileRequest): Promise; + writeText(params: WriteTextFileRequest): Promise; +} +``` + +这是 ACP `readTextFile` / `writeTextFile` 的注入接口。bridge 测试 + Mode A 嵌入方可以在 `BridgeOptions` 上不传它;`BridgeClient` 回退到 inline `fs.readFile` / `fs.writeFile` proxy(保留 F1 前行为)。生产 `qwen serve` 通过 `createBridgeFileSystemAdapter(fsFactory)`(`packages/cli/src/serve/bridgeFileSystemAdapter.ts`)把它接上,agent 侧 ACP 写得到与 HTTP 路由一致的 TOCTOU + symlink + 信任闸 + 审计护栏。 + +适配器**必须**复刻 inline proxy 的两道护栏(注入适配器后 inline 路径完全 bypass): + +1. **拒绝非常规文件** —— socket / pipe / char device / procfs / sysfs 即使 `stats.size === 0` 也能流无界数据。inline 路径抛错时带 `describeStatKind(stats)`。 +2. **缓冲大小上限** `READ_FILE_SIZE_CAP = 100 MiB`。否则一个针对 500 MB 日志的 `{ line: 1, limit: 10 }` 请求要花 500 MB RSS 才能返 10 行。 + +适配器还更进一步:用 `WorkspaceFileSystem.writeTextOverwrite`(PR 18 原语)做 atomic tmp+rename、保留 mode、新建默认 `0o600`、symlink reject,整段在 per-path 锁内。这是**与 F1 前 inline proxy 的偏离** —— 老 proxy 解析 symlink 并写穿 target;现在的 agent 如果之前依赖通过 symlink 写 dotfile,要直接寻址解析后的路径。 + +### FsError 在 ACP wire 上的保留 + +`BridgeFileSystem` 适配器抛 `FsError`(`kind: 'untrusted_workspace'` / `'symlink_escape'` / `'file_too_large'` 等)时,ACP SDK 默认 RPC error 序列化只把 `error.message` 当作通用 `-32603 "Internal error"` —— `kind` / `status` / `hint` 在线上被剥掉。下游 agent 的 RPC client 想做 typed UI(auth 重试 vs 文件选择 vs 代理提示)就只能 regex-match 人类可读消息。 + +`BridgeClient.writeTextFile` 与 `BridgeClient.readTextFile` 装了一道薄护栏(`packages/acp-bridge/src/bridgeClient.ts:40-100+`),捕获 FsError 形状的异常重抛为 ACP `RequestError`: + +```ts +function isFsErrorShape(err: unknown): err is FsErrorShape { + return ( + err instanceof Error && + err.name === 'FsError' && + typeof (err as { kind?: unknown }).kind === 'string' + ); +} + +function preserveFsErrorOverAcp(err: unknown): never { + if (isFsErrorShape(err)) { + throw new RequestError(-32603, err.message, { + errorKind: err.kind, + ...(err.hint !== undefined ? { hint: err.hint } : {}), + ...(err.status !== undefined ? { status: err.status } : {}), + }); + } + throw err; +} +``` + +agent 的 RPC client 现在拿到 `data.errorKind`(封闭 `FsErrorKind` 值)外加可选 `data.hint`、`data.status`,SDK 消费方按 typed 枚举 dispatch 而不是 regex 消息。 + +两条设计说明: + +- **鸭子类型而非 import** —— `FsError` 住在 `packages/cli/src/serve/fs/errors.ts`,`BridgeClient` 住在 `packages/acp-bridge`,直接 `import { FsError }` 会反向依赖。鸭子检查(`name === 'FsError'` + `kind: string`)与 `mapDomainErrorToErrorKind`(`status.ts`)对 `TrustGateError` / `SkillError` 用的同样思路,跨包打包同问题。 +- **JSON-RPC code 保持 -32603** —— bridge 没法把 `FsError.kind` 可靠映射到 JSON-RPC error code 形状,所以语义信息走结构化 `data` 字段。wire 上状态码(`-32603` "internal error")不变,客户端按 `data.errorKind` 路由。 + +### 信任 gate + +`assertTrustedForIntent(intent)` 查 `Config.isTrustedFolder()`。read / list / stat / glob 总是允许(信任只对写起作用)。在不被信任的 workspace 上 write 意图抛 `FsError('untrusted_workspace', ..., status: 403)`。trust 信号通过 `WorkspaceFileSystemFactoryDeps.trusted: boolean` 注入 —— `runQwenServe` 传 `true`(operator 自己启动 daemon 即默认信任那 workspace);`createServeApp` 直接嵌入默认 `false` 并 process 内告警一次(详见 [`02-serve-runtime.md`](./02-serve-runtime.md))。 + +## 流程 + +### 读 + +```mermaid +sequenceDiagram + autonumber + participant R as HTTP route OR BridgeFileSystem.readText + participant FS as WorkspaceFileSystem + participant POL as policy.ts + participant FSP as node:fs + + R->>FS: readText(ctx, path, opts) + FS->>FS: resolveWithinWorkspace(path) → ResolvedPath OR throw + FS->>FS: shouldIgnore? → throw / skip + FS->>FSP: stat(path) + FSP-->>FS: stats + FS->>FS: reject if not regular file (describeStatKind) + FS->>POL: enforceReadSize(stats.size, opts.maxBytes?)
→ throw file_too_large OR slice plan + FS->>FSP: readFile(path) + FSP-->>FS: buffer + FS->>POL: detectBinary(buffer) + POL-->>FS: isBinary? + FS->>FS: reject if binary; sha256 hash; truncate to line window + FS->>FS: audit fs.access + FS-->>R: { content, sha256, truncated?, meta } +``` + +### 写 + +```mermaid +sequenceDiagram + autonumber + participant R as POST /file/write OR ACP writeText + participant FS as WorkspaceFileSystem + participant POL as policy.ts + participant FSP as node:fs + + R->>FS: writeTextAtomic(ctx, path, content, opts) + FS->>FS: assertTrustedForIntent('write') → throw untrusted_workspace OR ok + FS->>FS: resolveWithinWorkspace(path) + FS->>POL: enforceWriteSize(content) → throw file_too_large OR ok + FS->>FSP: lstat(path) → reject symlink + FS->>FS: acquire per-path lock + FS->>FSP: stat(existing?) → capture target mode (default 0o600) + FS->>FSP: writeFile(tmpPath, content, {mode}) + FS->>FSP: rename(tmpPath, path) (atomic) + FS->>FS: audit fs.access (write) + FS-->>R: { sha256, mode, bytesWritten } +``` + +atomic write-then-rename 确保 SIGKILL / OOM 写到一半也不会让 target 被截断。`mode: 'create'` 在 lstat 时遇文件已存在中止(`file_already_exists`);`mode: 'overwrite'` 继续;`expectedSha256` 装乐观并发(不匹配 → `hash_mismatch`)。 + +### `POST /file/edit`(单段文本替换) + +在 write 之上加两种失败: + +- `text_not_found`(422)—— search 字符串不在文件里。 +- `ambiguous_text_match`(422)—— 需要唯一匹配但匹配到多处(路由契约)。 + +### 审计扇出 + +```mermaid +flowchart LR + A["WorkspaceFileSystem op succeeds OR fails"] --> P["createAuditPublisher → emit FS_ACCESS_EVENT_TYPE / FS_DENIED_EVENT_TYPE"] + P --> AR["PermissionAuditRing (512 entries, FIFO)"] + P --> MON["future: external monitoring sink"] +``` + +`FS_ACCESS_EVENT_TYPE` / `FS_DENIED_EVENT_TYPE` 带 ctx、path、intent、outcome、errorKind?、bytesRead/written、sha256?。 + +## 状态与生命周期 + +- 工厂在 daemon boot 一次(`runQwenServe` → `resolveBridgeFsFactory` → 适配器)。 +- 每请求构造一个 `RequestContext` 并调工厂 orchestrator 处理那次;不持久 per-file 状态。 +- per-path 锁只活在写操作期间(无跨调用锁;同路径并发写在锁上 race 串行)。 +- 审计环属于 `runQwenServe`,与 permission audit publisher 共享。 + +## 依赖 + +- `@qwen-code/qwen-code-core` —— `Ignore`、`isBinaryFile`、`Config.isTrustedFolder()`。 +- `node:fs`、`node:path`、`node:crypto`。 +- `@qwen-code/acp-bridge` —— ACP 侧 `BridgeFileSystem` 契约。 +- HTTP 路由:`packages/cli/src/serve/routes/workspaceFileRead.ts`、`workspaceFileWrite.ts`。 + +## 配置 + +| 来源 | 旋钮 | 效果 | +| ------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `WorkspaceFileSystemFactoryDeps.trusted: boolean` | 构造入参 | 是否允许写;`runQwenServe` 默认 `true`,`createServeApp` 默认 `false`(带告警) | +| 常量 | `MAX_READ_BYTES = 256 KiB` | 读上限;超过 → `file_too_large` | +| 常量 | `MAX_WRITE_BYTES = 5 MiB` | 写上限;低于 `express.json({ limit: '10mb' })` | +| 常量 | `BINARY_PROBE_BYTES = 4096` | 二进制检测采样大小 | +| 能力 tag | `workspace_file_read`、`workspace_file_bytes`、`workspace_file_write` | 见 [`11-capabilities-versioning.md`](./11-capabilities-versioning.md) | +| workspace 文件 | `.gitignore`、`.qwenignore` | 被忽略路径在 `shouldIgnore` 上 `ignored: true` | + +## 注意 & 已知局限 + +- **symlink 直接拒,不跟随**。与 F1 前 inline `BridgeClient.writeTextFile` proxy 的行为偏离。通过 symlink 写 dotfile 的 agent 要改成直接寻址解析后的路径。 +- **`io_error` 与 `permission_denied` 严格区分**。不要混。监控按 errorKind 告警 —— 把 ENOSPC 折进 permission_denied 会让 `df -h` 问题误把安全 oncall 叫起来。 +- **新建文件默认 `0o600`,不是 umask 默认**。write 系统调用的 `mode` 参数绕过 umask。要写公开文件的 agent 必须显式覆盖 mode。 +- **`createServeApp` 默认 `trusted: false`** 嵌入方没注入 `fsFactory` 或 `bridge` 时静默拒 ACP 写为 `untrusted_workspace`。首次告警 stderr 打印一次,之后无提示。详见 [`02-serve-runtime.md`](./02-serve-runtime.md)。 +- **读上限是在解码前强制**。一个 `MAX_READ_BYTES + 1` 的文件即使只要 10 行也会被拒,因为底层 `readFileWithLineAndLimit` 先把整文件读进内存才切行。 +- **`BridgeFileSystem` 适配器必须复刻 inline-proxy 两道护栏**(非常规文件 refusal + 缓冲大小上限)。注入适配器后 inline 路径完全 bypass。 + +## 参考 + +- `packages/cli/src/serve/fs/index.ts`(barrel) +- `packages/cli/src/serve/fs/paths.ts` +- `packages/cli/src/serve/fs/policy.ts:1-100+` +- `packages/cli/src/serve/fs/errors.ts:1-80+` +- `packages/cli/src/serve/fs/audit.ts` +- `packages/cli/src/serve/fs/workspaceFileSystem.ts` +- `packages/cli/src/serve/bridgeFileSystemAdapter.ts:1-60` +- `packages/acp-bridge/src/bridgeFileSystem.ts:39-97` +- HTTP 路由参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。 diff --git a/docs/developers/daemon/08-session-lifecycle.md b/docs/developers/daemon/08-session-lifecycle.md new file mode 100644 index 0000000000..e8ee84e233 --- /dev/null +++ b/docs/developers/daemon/08-session-lifecycle.md @@ -0,0 +1,181 @@ +# Session 生命周期与身份 +## 概览 + +daemon **session** 是一段绑定到一个 ACP `sessionId` 的逻辑对话。bridge 为每个 session 维护一个 `SessionEntry`(见 [`03-acp-bridge.md`](./03-acp-bridge.md)),把 ACP child connection 与 HTTP 侧的簿记捆在一起:prompt FIFO、model-change FIFO、event bus、pending permission、attach 的客户端、心跳、restore 状态、终态 tombstone。 + +daemon **客户端**由 `X-Qwen-Client-Id` 标识 —— 一段不透明、由 daemon 校验的字符串,调用方自行在请求里盖。daemon 自己不会替调用方生成 id;客户端自取并复用,daemon 据此归属投票、审计事件、识别重连。 + +本文讲清每一次 session 状态迁移(create / attach / load / resume / close / die / evict)以及 daemon 暴露的每个身份相关 surface。 + +## 职责 + +- 创建、attach、restore、回收 session。 +- 校验 `X-Qwen-Client-Id`,错的格式直接拒。 +- 跟踪 session 上多个 attach 的客户端(`clientIds: Map`、`attachCount`)。 +- 给出站事件盖 `originatorClientId`。 +- 跑心跳,让 dashboard 知道谁还在连着。 +- 提供 `displayName`,operator 通过 `PATCH /session/:id/metadata` 设置。 +- 推送终态帧(`session_died`、`session_closed`、`client_evicted`、`stream_error`)。 + +## 架构 + +| 关注点 | 源 | 说明 | +| ------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `SessionEntry` | `packages/acp-bridge/src/bridge.ts:183-285` | 每 session 结构体,字段列表见 [`03-acp-bridge.md`](./03-acp-bridge.md) | +| `BridgeSession`(对外) | `packages/acp-bridge/src/bridgeTypes.ts:49+` | `{ sessionId, workspaceCwd, attached, clientId?, createdAt? }` 回给 HTTP handler | +| `BridgeSessionState` | `packages/acp-bridge/src/bridgeTypes.ts:73+` | `LoadSessionResponse \| ResumeSessionResponse`,缓存为 `restoreState` | +| `DaemonSession`(SDK) | `packages/sdk-typescript/src/daemon/types.ts:113` | `{ sessionId, workspaceCwd, attached, clientId?, createdAt? }` | +| ClientId 校验 | `packages/acp-bridge/src/bridge.ts`(`spawnOrAttach` 附近) | 正则 `[A-Za-z0-9._:-]{1,128}`,违法抛 `InvalidClientIdError` | +| Session disconnect-reaper | `packages/cli/src/serve/server.ts` | 用 `attachCount` + `spawnOwnerWantedKill` 跟踪 spawn 拥有者断连 | + +### 状态机 + +```mermaid +stateDiagram-v2 + [*] --> SpawnInProgress: POST /session + SpawnInProgress --> Live: newSession success + SpawnInProgress --> [*]: initialize failure / spawn error + Live --> Live: attach (sessionScope=single, bump attachCount) + Live --> Live: detach (decrement attachCount) + Live --> RestoreInProgress: POST /session/:id/load or /resume + RestoreInProgress --> Live: restoreState cached on entry + RestoreInProgress --> Live: RestoreInProgressError (coalesce waiters) + Live --> Closed: DELETE /session/:id (last client) + Live --> Died: ACP child exit / channel.exited fired + Closed --> [*]: session_closed terminal frame + Died --> [*]: session_died terminal frame +``` + +### Attach 与 Spawn + +`sessionScope: 'single'`(默认)下,bridge 的 `defaultEntry` 被所有连进来的客户端共享。`POST /session` 到来时 `defaultEntry` 已存在 → 不 spawn 新 ACP child,直接返回 `attached: true`。bridge 同步 bump `attachCount` 并把调用方的 `X-Qwen-Client-Id` 登记到 `clientIds`。 + +`sessionScope: 'per-client'`:每次 `POST /session` 新建一个 session。仍然受 `maxSessions` 约束。 + +### 身份 + +`X-Qwen-Client-Id` **可选**但**强烈建议**带。daemon 不会替调用方生成;客户端自己挑、在所有请求里复用,daemon 才能归属投票、审计事件、识别重连。 + +校验: + +- 字符集 `[A-Za-z0-9._:-]`。 +- 长度 1–128。 +- 不合规 → `InvalidClientIdError`(`400`)。 + +daemon 在以下条件全部满足时给出站 SSE 事件盖 `originatorClientId`: + +1. 触发该事件的请求带了 `X-Qwen-Client-Id`,且 +2. 该 id 已登记在 session 的 `clientIds` 集合里,且 +3. session 当前有 `activePromptOriginatorClientId`(在跑的 prompt 的内联 `sessionUpdate` 和 `permission_request` 继承该 originator)。 + +匿名调用(不带 `X-Qwen-Client-Id`)在 `first-responder` 下可用;`designated` 会拒它的投票为 `permission_forbidden{ reason: 'designated_mismatch' }`;`consensus` 同样拒为 `forbidden`(不在发起时 `votersAtIssue` 快照中);`local-only` 是唯一接受匿名 loopback 投票者的策略。 + +## 流程 + +### Create or attach + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant R as POST /session + participant B as Bridge.spawnOrAttach + participant CH as ACP child + + C->>R: POST /session
X-Qwen-Client-Id: alice
{cwd, sessionScope?} + R->>R: validate clientId pattern + R->>B: spawnOrAttach({cwd, sessionScope, clientId}) + alt single scope + defaultEntry exists + B->>B: bump attachCount; register clientId + B-->>R: {sessionId, attached: true, restoreState?} + else cold + B->>CH: spawn + ACP initialize + newSession + CH-->>B: sessionId + B->>B: build SessionEntry; register in byId + B-->>R: {sessionId, attached: false} + end + R-->>C: 200 { sessionId, attached, ... } +``` + +### Load / Resume + +- `POST /session/:id/load` — 重放完整 ACP 历史(`session/load` 通知先于响应返回)。 +- `POST /session/:id/resume` — 不重放(`connection.unstable_resumeSession`,由 `unstable_session_resume` 能力暴露)。 + +两者都: + +1. 在 channel 的 `pendingRestoreIds` 集合里登记,让并发 restore 合并(`RestoreInProgressError`)。 +2. 把 `restoreState` 缓存到 entry,让晚到的 attacher 看到与原始 restore 调用一致的 payload。 + +### 心跳 + +`POST /session/:id/heartbeat` 不管带不带 `clientId` 都会更新 `sessionLastSeenAt`。如果请求带了已登记的 `X-Qwen-Client-Id`,`clientLastSeenAt.set(clientId, Date.now())` 也会 bump。v1 **没有** per-client 剔除;revocation 是 F 系列 Wave 5。当前心跳的价值是给 dashboard / 给将来的 PR 24 撤权策略提供观测。 + +### Metadata + +`PATCH /session/:id/metadata` 接受 `{displayName?}`。校验: + +- 最长 `MAX_DISPLAY_NAME_LENGTH = 256`。 +- 不能含控制字符(`hasControlCharacter` 拒绝码点 ≤ 0x1f 或 == 0x7f)。 +- 违反 → `InvalidSessionMetadataError`(`400`)。 + +成功后向所有订阅者广播 `session_metadata_updated`。 + +### 终态 + +| 终态帧 | 触发 | +| ---------------- | --------------------------------------------------------------------------------------------------------------- | +| `session_closed` | `DELETE /session/:id`(client_close)或程序化关闭 | +| `session_died` | `channel.exited` 触发(崩溃、被 kill);OS exit 路径下带 `exitCode?` + `signalCode?` | +| `client_evicted` | EventBus 每订阅者队列溢出(见 [`10-event-bus.md`](./10-event-bus.md)),**非** session 级终态,仅关掉当前订阅者 | +| `stream_error` | `SubscriberLimitExceededError` 或其他路由流错误 | + +每个终态路径都会 `mediator.forgetSession(sessionId)`,把所有 pending permission 解析为 `{kind:'cancelled', reason:'session_closed'}`。 + +### Disconnect-reaper 守护 + +spawn 拥有者的 HTTP 响应写不出去时(TCP 在握手中途 reset),路由会 `killSession({ requireZeroAttaches: true })`。如果其他客户端已经 attach 了(`attachCount > 0`),bail 短路、session 继续活着,但 `spawnOwnerWantedKill = true` 留作 tombstone;之后某次 `detachClient()` 把 `attachCount` 拉回 0 时完成延迟回收。没有这个守护,spawn 拥有者快速断连会每隔一次重连就拆掉一个健康 session。 + +## 状态与生命周期 + +`SessionEntry` 中和生命周期最密切的字段: + +| 字段 | 类型 | 含义 | +| -------------------------------- | --------------------- | --------------------------------------------------------------- | +| `clientIds` | `Map` | 已登记 clientId → 引用计数 | +| `attachCount` | `number` | `spawnOrAttach` 对该 entry 返回 `attached: true` 的次数 | +| `activePromptOriginatorClientId` | `string?` | 当前在跑的 prompt 的 originator | +| `restoreState` | `BridgeSessionState?` | load/resume 响应缓存,让晚到 attacher 看到一致 payload | +| `spawnOwnerWantedKill` | `boolean` | 延迟回收 tombstone | +| `sessionLastSeenAt` | `number?` | 任何客户端最近一次心跳(epoch ms) | +| `clientLastSeenAt` | `Map` | per-client 心跳 | +| `pendingPermissionIds` | `Set` | 当前 pending 的 ACP requestId — cancel/close 时解析为 cancelled | + +## 依赖 + +- ACP 层:`connection.newSession`、`connection.unstable_resumeSession`、`connection.loadSession`。 +- [`03-acp-bridge.md`](./03-acp-bridge.md) — 周围的 bridge 架构。 +- [`04-permission-mediation.md`](./04-permission-mediation.md) — originator + identity 如何驱动策略。 +- [`10-event-bus.md`](./10-event-bus.md) — 终态帧投递。 + +## 配置 + +- `BridgeOptions.maxSessions`(默认 20)。 +- `BridgeOptions.sessionScope`(默认 `'single'`)。 +- `BridgeOptions.initializeTimeoutMs`(默认 10s)。 +- 能力 tag:`session_create`、`session_scope_override`、`session_load`、`unstable_session_resume`、`session_list`、`session_close`、`session_metadata`、`session_set_model`、`client_identity`、`client_heartbeat`。 + +## 注意 & 已知局限 + +- `connection.unstable_resumeSession` 不稳定;ACP 方法形状还可能变。能力 tag 故意带 `unstable_` 前缀,让客户端 feature-detect 而不是硬绑 v1。 +- v1 **没有** per-client 剔除,只有 per-session 与 per-subscriber 终态。撤权策略是 F 系列 Wave 5 / PR 24。 +- `client_evicted` 是 per-subscriber 不是 per-session;订阅者被剔除的客户端可以重连。 +- 匿名客户端在 `designated` / `consensus` 策略下不能投票。 + +## 参考 + +- `packages/acp-bridge/src/bridge.ts:183-285`(SessionEntry 定义) +- `packages/acp-bridge/src/bridgeTypes.ts:30-180+`(`HttpAcpBridge`、`BridgeSession`、`BridgeSessionState`) +- `packages/sdk-typescript/src/daemon/types.ts:113+`(`DaemonSession`) +- `packages/sdk-typescript/src/daemon/DaemonSessionClient.ts:61-385` +- Wire 参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。 diff --git a/docs/developers/daemon/09-event-schema.md b/docs/developers/daemon/09-event-schema.md new file mode 100644 index 0000000000..94ca6d822d --- /dev/null +++ b/docs/developers/daemon/09-event-schema.md @@ -0,0 +1,238 @@ +# Typed Daemon Event Schema v1 +## 概览 + +daemon 在 `GET /session/:id/events` 上发的每一帧 SSE 都形如 `{ id, v, type, data, originatorClientId?, _meta? }`,`v: 1` 是当前 `EVENT_SCHEMA_VERSION`。`type` 取自一个封闭的、版本固定的集合 —— `DAEMON_KNOWN_EVENT_TYPE_VALUES`(`packages/sdk-typescript/src/daemon/events.ts:13-63`)共 **29 种**。envelope 的 `_meta` 字段在 SSE 写边界(`server.ts` 的 `formatSseFrame()`)盖上 —— 详见下文 [Envelope 级元数据](#envelope-级元数据)。 + +SDK 暴露 `narrowDaemonEvent(evt)`,对已知 type 返回一个判别式 `KnownDaemonEvent`,对其他 type 返回 `{ kind: 'unknown' }` —— SDK 消费方无需固定 SDK 版本就能处理向前兼容(更新的 daemon 加了新 type 也不会崩)。 + +wire 格式见 [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md),本文是每个事件的 payload 契约。 + +## 职责 + +- 提供事件词汇表的唯一事实来源(line 13 那个常量数组)。 +- 提供每种 type 的 typed envelope(`DaemonEventEnvelope`)。 +- 提供纯 reducer(`reduceDaemonSessionEvent`、`reduceDaemonAuthEvent`),把事件流投影成 SDK view-state。 +- 通过 `typed_event_schema` 能力 tag 广播(信息性 —— 不广播时 `narrowDaemonEvent` 仍 fallback 到 `unknown`)。 + +## 事件词汇表(29 种) + +按域分组。 + +### Core session + +| Type | 方向 | 触发 | Payload 关键字段 | +| -------------------------- | ------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------- | +| `session_update` | S→C | 任意 ACP `sessionUpdate` 通知(agent text / thought / tool call / plan) | `sessionUpdate: string, content?: ...`(不透明 ACP shape) | +| `session_metadata_updated` | S→C | `PATCH /session/:id/metadata` | `sessionId, displayName?` | +| `session_died` | S→C **终态** | `channel.exited` 触发 | `sessionId, reason, exitCode? \| null, signalCode? \| null` | +| `session_closed` | S→C **终态** | `DELETE /session/:id` 或程序化关闭 | `sessionId, reason: 'client_close' \| string, closedBy?` | + +### Subscriber 级合成帧 + +| Type | 触发 | 备注 | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `client_evicted` | EventBus 每订阅者队列溢出。**无 `id`** | `reason: string, droppedAfter?: number`;只对当前订阅者终态,session 还活着 | +| `slow_client_warning` | 队列 ≥ 75%(force-push,**无 `id`**) | `queueSize, maxQueued, lastEventId`;37.5% 滞回 re-arm | +| `stream_error` | `SubscriberLimitExceededError` 或其他路由流错 | `error: string`;订阅终态 | +| `state_resync_required` | `subscribe({lastEventId})` 时 daemon 环里已不再持有 `[lastEventId+1, earliestInRing-1]` 这段间隙。在剩余 replay 帧**之前**强推。**无 `id`** | `reason: string`(当前恒为 `'ring_evicted'`)、`lastDeliveredId: number`、`earliestAvailableId: number`。**面向恢复,非终态** —— SSE 流保持打开,replay + live 帧继续;SDK reducer 翻转 `awaitingResync = true`,自动跳过 delta,直到调用方调 `loadSession` 重置。daemon 端实现见 `eventBus.ts:359-402`,SDK 端见 `events.ts:870-905` | + +### Permissions(F3 + base) + +| Type | 方向 | 触发 | Payload 关键字段 | +| ----------------------------- | ---- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `permission_request` | S→C | agent 调 `requestPermission` | `requestId, sessionId, toolCall, options[]`;envelope 盖 `originatorClientId`(= prompt originator,F3 N3) | +| `permission_resolved` | S→C | mediator 已裁决 | `requestId, outcome`(ACP `PermissionOutcome`) | +| `permission_already_resolved` | S→C | 已裁决后投票才到 | `requestId, sessionId, outcome` | +| `permission_partial_vote` | S→C | `consensus` 策略记录了一次不裁决的投票 | `requestId, sessionId, votesReceived, votesNeeded (≥1), quorum, optionTallies: Record, originatorClientId?` | +| `permission_forbidden` | S→C | 投票被策略拒绝 | `requestId, sessionId, clientId?, reason: 'designated_mismatch' \| 'remote_not_allowed', originatorClientId?`;匿名投票者无 `clientId` | + +### Models + +| Type | 方向 | Payload | +| --------------------- | ---- | -------------------------------------------- | +| `model_switched` | S→C | `sessionId, modelId` | +| `model_switch_failed` | S→C | `sessionId, requestedModelId, error: string` | + +### MCP guardrails(PR 14b + F2) + +| Type | 方向 | Payload | +| ---------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mcp_budget_warning` | S→C | `liveCount, reservedCount, budget, thresholdRatio: 0.75, mode: 'warn' \| 'enforce', scope?: 'workspace' \| 'session'` | +| `mcp_child_refused_batch` | S→C | `refusedServers: [{name, transport, reason: 'budget_exhausted'}], budget, liveCount, reservedCount, mode: 'enforce', scope?: 'workspace' \| 'session'` | +| `mcp_server_restarted` | S→C | `serverName, durationMs, entryIndex?`(F2 多 entry) | +| `mcp_server_restart_refused` | S→C | `serverName, reason: 'budget_would_exceed' \| 'in_flight' \| 'disabled' \| 'restart_failed', entryIndex?, details?`。第 4 个值 `'restart_failed'`(F2 commit 5)携带底层硬失败,`details` 是自由格式字符串,给池模式多 entry restart 用。**封闭集判别**:`MCP_RESTART_REFUSED_REASONS` 拒绝未知 reason,老 SDK reducer 看到加法新值会**默默丢弃**事件(`parseDaemonEvent` 返回 `undefined`)。新 reason 值必须与认识它的 SDK 版本一起发 | + +### Mutation control(Wave 4 PR 16+17) + +| Type | 方向 | Payload | +| ----------------------- | ---- | ------------------------------------------------------------------------------------- | +| `memory_changed` | S→C | `scope: 'workspace' \| 'global', filePath, mode: 'append' \| 'replace', bytesWritten` | +| `agent_changed` | S→C | `change: 'created' \| 'updated' \| 'deleted', name, level: 'project' \| 'user'` | +| `approval_mode_changed` | S→C | `sessionId, previous, next, persisted: boolean` | +| `tool_toggled` | S→C | `toolName, enabled`(下次 ACP child spawn 才生效,不会回溯改动已在跑的 session) | +| `workspace_initialized` | S→C | `path, action: 'created' \| 'overwritten'` | + +### Auth device flow(PR 21) + +这些是 workspace-keyed 不是 session-keyed。session reducer 对它们 no-op;`reduceDaemonAuthEvent` 投到 workspace-level state。 + +| Type | 方向 | Payload | +| ----------------------------- | ---- | ----------------------------------------------------- | +| `auth_device_flow_started` | S→C | `deviceFlowId, providerId, expiresAt` | +| `auth_device_flow_throttled` | S→C | `deviceFlowId, intervalMs` | +| `auth_device_flow_authorized` | S→C | `deviceFlowId, providerId, expiresAt?, accountAlias?` | +| `auth_device_flow_failed` | S→C | `deviceFlowId, errorKind, hint?` | +| `auth_device_flow_cancelled` | S→C | `deviceFlowId` | + +## 架构 + +| 关注点 | 文件:行 | 说明 | +| -------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------ | +| `EVENT_SCHEMA_VERSION = 1` | `packages/acp-bridge/src/eventBus.ts:22` | 每帧带 | +| `DAEMON_KNOWN_EVENT_TYPE_VALUES` | `packages/sdk-typescript/src/daemon/events.ts:13-63` | 封闭列表(长 28) | +| `DaemonEventEnvelope` | `events.ts:74-78` | 泛型 envelope | +| `DaemonKnownEventType` | `events.ts:71-72` | `typeof DAEMON_KNOWN_EVENT_TYPE_VALUES[number]` | +| 各事件 payload 类型 | `events.ts:80+` | 每种 type 一个 `DaemonXxxData` interface | +| `narrowDaemonEvent(evt)` | `events.ts` | 返回 `KnownDaemonEvent \| { kind: 'unknown', value: DaemonEvent }` | +| `reduceDaemonSessionEvent(state, evt)` | `events.ts` | 投到 `DaemonSessionViewState` | +| `reduceDaemonAuthEvent(state, evt)` | `events.ts` | 投到 `DaemonAuthState` | +| `isWorkspaceScopedBudgetEvent(evt)` | `events.ts` | 判别 F2 `scope: 'workspace'` | + +### `DaemonSessionViewState` + +`reduceDaemonSessionEvent` 填充,CLI TUI adapter、`DaemonChannelBridge`、VSCode IDE 都消费。关键字段: + +- `messages: HistoryItem[]` — 由 `session_update` 派生。 +- `pendingPermissionRequests: PermissionRequestData[]` — 当前打开的请求;`permission_resolved` / `permission_already_resolved` / 对自身的 `permission_forbidden` / cancel 时清掉。 +- `latestPermissionResolution?: PermissionOutcome`。 +- `currentModelId?: string` — 由 `model_switched`。 +- `lastModelSwitchError?: string` — 由 `model_switch_failed`。 +- `mcpBudgetWarningCount`、`lastMcpBudgetWarning?` — 由 `mcp_budget_warning`。 +- `mcpChildRefusedBatchCount`、`lastMcpChildRefusedBatch?` — 由 `mcp_child_refused_batch`。 +- `mcpRestartHistory[]` — 由 `mcp_server_restarted` / `mcp_server_restart_refused`。 +- `terminal?: { kind, reason, ... }` — 任何终态帧。 + +### `DaemonAuthState` + +按 `providerId` 一项,由 `auth_device_flow_*` 驱动。每个 flow 暴露 `{deviceFlowId, status, providerId, expiresAt?, lastThrottleIntervalMs?, lastError?}`。 + +## 流程 + +### Producer 端 + +```mermaid +flowchart LR + A["ACP child notification"] --> B["BridgeClient.sessionUpdate /
BridgeClient.extNotification"] + B --> C{"Mapped to event type?"} + C -->|yes| D["EventBus.publish({type, data, originatorClientId?})"] + C -->|no| E["No emit (drop or log)"] + D --> F["Assigns id + v=1, pushes to ring"] + F --> G["Fans to all subscribers"] +``` + +### Consumer 端(SDK) + +```mermaid +flowchart LR + A["SSE bytes"] --> B["parseSseStream → DaemonEvent[]"] + B --> C["narrowDaemonEvent(evt)"] + C -->|"kind: 'session_update' | ..."| D["reduceDaemonSessionEvent(state, evt)"] + C -->|"kind: 'auth_device_flow_*'"| E["reduceDaemonAuthEvent(state, evt)"] + C -->|"kind: 'unknown'"| F["pass-through (forward-compat)"] +``` + +## Envelope 级元数据 + +除了每事件的 `data` payload,daemon 还在 envelope 上盖两个字段: + +### `_meta.serverTimestamp` —— daemon 时钟 + +在 `formatSseFrame()`(`packages/cli/src/serve/server.ts:2602+`)的 SSE 写边界盖,**不**在 `EventBus.publish`。这样内存里的 `BridgeEvent` 类型不变,内部 daemon 消费方看不到 `_meta`,只有 wire 上的 SSE 帧带。 + +```jsonc +// 盖完之后 wire 上的一帧 +{ + "id": 47, + "v": 1, + "type": "session_update", + "data": { ... }, + "_meta": { "serverTimestamp": 1716287345123 } +} +``` + +merge 保留任何已有 `_meta` 键(`{...existingMeta, serverTimestamp: Date.now()}`)。**当前 daemon 没有任何生产者写 envelope 级 `_meta`** —— wenshao #4360 review 已确认 `ToolCallEmitter` 的元数据嵌在 `event.data._meta`(ACP `session/update` payload 自己的 `_meta`),不是 envelope。顶层 merge 是向前兼容逃生口。 + +**为什么重要**:多客户端 UI 渲「X 分钟前」或按 emit 时间排序 transcript 块时,老路径用各自本地时钟,跨浏览器 / 标签 / 手机漂几十秒到几分钟。服务端盖戳之后,所有客户端排序一致。 + +**SDK 访问**:3 处探针(`event.serverTimestamp` / `event._meta.serverTimestamp` / `event.data._meta.serverTimestamp`)在 chiga0 的 PR #4353 规划中。在那合入之前,SDK 消费方可以直接通过 `as any` cast 读 `event._meta?.serverTimestamp` —— wire 上字段已经在了。 + +### `originatorClientId` + +上文事件表已经标注。带了已注册 `X-Qwen-Client-Id` 的请求触发的事件才有(规则见 [`08-session-lifecycle.md`](./08-session-lifecycle.md))。 + +## Tool-call `_meta`(provenance / serverId) + +跟上面 envelope 级 `_meta` 不是同一个:ACP `session/update` payload 自己也带 `_meta`,在 `event.data._meta`。`ToolCallEmitter`(`packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts`)在 `emitStart` / `emitResult` / `emitError` 上盖两个字段: + +| 字段 | 类型 | 解析规则(`ToolCallEmitter.resolveToolProvenance`) | +| ------------ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| `provenance` | `'builtin' \| 'mcp' \| 'subagent'` | 有 `subagentMeta` → `subagent`(最高优先级);tool 名匹配 `mcp____` → `mcp`;其它 → `builtin` | +| `serverId` | `string`(仅 `provenance === 'mcp'` 时设) | 从 `mcp____` 命名启发提取 | + +加上原本就有的 `_meta.toolName`(显示名)。 + +UI 据此渲染 builtin / MCP server badge / subagent 归属的 tool call,不必再去解析 tool 名字。 + +## SDK reducer 行为 + +`reduceDaemonSessionEvent(state, evt)`(`packages/sdk-typescript/src/daemon/events.ts:1100+`)把事件流投到 `DaemonSessionViewState`。三个 resync 相关字段: + +- **`awaitingResync: boolean`** —— `state_resync_required` 时置 `true`;调用方代码自己清(典型路径:调 `POST /session/:id/load` 重置 view state)。 +- **`resyncRequiredCount: number`** —— 观测帧计数(病态客户端可能不止一次 resync)。 +- **`lastResyncRequired?: DaemonStateResyncRequiredData`** —— 最近一次 payload。 + +`awaitingResync = true` 期间 reducer **自动跳过** delta 应用,**只放行**封闭的 `RESYNC_PASSTHROUGH_TYPES` 集合(`packages/sdk-typescript/src/daemon/events.ts:896-902`): + +| 放行 type | 为什么 resync 期间也要应用 | +| ----------------------- | ---------------------------------------------------------------------------- | +| `state_resync_required` | 二次 resync(少见但可能)要更新 `lastResyncRequired` / `resyncRequiredCount` | +| `session_died` | 流终态信号即便在 resync limbo 也必须可见 | +| `session_closed` | 同上 | +| `client_evicted` | 同上 | +| `stream_error` | 同上 | + +`lastEventId` 在 resync limbo 期间仍然通过 `advanceLastEventId(base)` 单调推进,调用方重置并清掉 `awaitingResync` 后,后续 delta 对齐到正确游标。 + +## 状态与向前兼容 + +- 新增已知 type → append 到 `DAEMON_KNOWN_EVENT_TYPE_VALUES`。老 SDK 看到 `kind: 'unknown'` 直接忽略;新 SDK 依赖判别式联合类型。 +- 给已有 payload 加可选字段 → 安全(`{ [key: string]: unknown }` 是开的)。 +- 改已有 payload 的**形状** → break;必须 bump `EVENT_SCHEMA_VERSION` 并依赖 `caps.features.typed_event_schema_v2` 之类的能力 tag 兼容。 +- `id` 是每 session 单调,合成终态帧(`client_evicted`、`slow_client_warning`、`stream_error`)刻意无 id,防止其他订阅者看到序号断档。 +- `originatorClientId` 在 envelope 而非 `data`。F3 的 partial-vote / forbidden payload 同时也把它盖到 `data`(`mergeOriginator`),view-state 消费方就不必保留 envelope。 + +## 依赖 + +- [`10-event-bus.md`](./10-event-bus.md) — 投递通道。 +- [`11-capabilities-versioning.md`](./11-capabilities-versioning.md) — SDK 怎么 pre-flight `typed_event_schema`、`mcp_guardrail_events`、`permission_mediation` tag。 +- [`04-permission-mediation.md`](./04-permission-mediation.md) — 权限事件怎么产出。 +- [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md) — `narrowDaemonEvent`、reducer、view-state 形状。 + +## 配置 + +- 默认广播:`typed_event_schema`(恒)、`mcp_guardrail_events`(恒)、`permission_mediation`(恒,`modes` 列出支持策略)。 +- 没有 env / 参数直接控制 schema 本身;杀手锏 `QWEN_SERVE_NO_MCP_POOL=1` 会让 MCP 事件的 `scope` 字段从 `'workspace'` 变成 缺失 / `'session'`。 + +## 注意 & 已知局限 + +- 三种合成帧故意无 `id`,SDK 代码不能假设每个事件都有 id。 +- `permission_partial_vote` 只在 `consensus` 下出现;`permission_forbidden` 在 `designated` / `consensus` / `local-only` 下出现,**不在** `first-responder` 下出现。 +- `mcp_child_refused_batch` 只在 `mode: 'enforce'` 下出现,`warn` 模式从不拒绝。 +- `auth_device_flow_*` 事件不是 session-keyed;通过 `DaemonSessionClient` 消费时必须走 `reduceDaemonAuthEvent`,不要走 session reducer。 + +## 参考 + +- `packages/sdk-typescript/src/daemon/events.ts`(整文件) +- `packages/acp-bridge/src/eventBus.ts:22`(`EVENT_SCHEMA_VERSION`) +- `packages/cli/src/serve/capabilities.ts:60`(`typed_event_schema`)、`:110`(`mcp_guardrail_events`)、`:211-214`(`permission_mediation`)。 +- wire 参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。 diff --git a/docs/developers/daemon/10-event-bus.md b/docs/developers/daemon/10-event-bus.md new file mode 100644 index 0000000000..fecdea516e --- /dev/null +++ b/docs/developers/daemon/10-event-bus.md @@ -0,0 +1,199 @@ +# SSE 事件总线与反压 +## 概览 + +`EventBus`(`packages/acp-bridge/src/eventBus.ts`)是每 session 一份的内存 pub/sub,喂给 daemon 的 `GET /session/:id/events` SSE 路由。它给每个事件分配单调 id、用有界环形缓冲缓存最近事件给 `Last-Event-ID` 重放、把 publish 扇出到所有订阅者、对订阅者实施反压(队列 75% 满时发警告、达到上限时驱逐),还会合成两种终态帧(`client_evicted`、`slow_client_warning`),SDK 把它们当一等事件,但 bus 故意**不**给它们分配 `id`,防止它们占掉本 session 的序列号让其他订阅者看到断档。 + +`EventBus` 目前是 `acp-bridge` 包内部的实现,bridge 工厂为每 session 闭包持有一份。未来 refactor(文件 line 150–159 提到)会把它升到顶层组件,channels、dual-output 以及未来 WebSocket 传输都能通过同一 bus 订阅,而不必各跑一条并行流。 + +## 职责 + +- 给每 session 分配单调事件 id(从 1 起)。 +- 在环形缓冲缓存最近 `ringSize` 个事件,供 `lastEventId` 重放。 +- 把 publish 扇出到至多 `maxSubscribers` 个订阅者。 +- 每订阅者用有界队列;超过上限的订阅者收一个合成终态帧 `client_evicted` 后被关掉。 +- 队列 75% 满时合成 `slow_client_warning` —— 每个 overflow episode 只发一次,37.5% 滞回重新装填。 +- `AbortSignal.abort()` 触发后及时拆订阅。 +- bus close 时(session 拆除)干净地关闭所有订阅者。 +- `publish` 永远不抛(合约:调 `publish` 永远安全)。 + +## 架构 + +| 常量 | 值 | 用途 | +| --------------------------------------- | ----------- | ---------------------------------------------------------- | +| `EVENT_SCHEMA_VERSION` | `1` | 每帧 `v`;frame 形状破坏性改动时 bump | +| `DEFAULT_RING_SIZE` | `8000` | per-session 重放环;operator 通过 `--event-ring-size` 覆盖 | +| `DEFAULT_MAX_QUEUED` | `256` | per-subscriber 队列上限 | +| `DEFAULT_MAX_SUBSCRIBERS` | `64` | per-session 订阅者上限 | +| `WARN_THRESHOLD_RATIO` | `0.75` | 触发 `slow_client_warning` 的占比 | +| `WARN_RESET_RATIO` | `0.375` | 滞回重置占比 | +| `MAX_EVENT_RING_SIZE`(在 `bridge.ts`) | `1_000_000` | `BridgeOptions.eventRingSize` 软上限,挡打错值 OOM | + +### `BridgeEvent` + +```ts +interface BridgeEvent { + id?: number; // per session 单调;合成终态帧无 id + v: 1; // EVENT_SCHEMA_VERSION + type: string; // 29 已知 type 之一或未来扩展 + data: unknown; // payload,SDK 按 type typed(详见 09) + originatorClientId?: string; // 由带 clientId 的请求派生 +} +``` + +### `SubscribeOptions` + +```ts +interface SubscribeOptions { + lastEventId?: number; // 从该 id 之后重放(Last-Event-ID 重连) + signal?: AbortSignal; // 及时拆订阅 + maxQueued?: number; // per-subscriber 队列上限;默认 256 +} +``` + +`subscribe()` 返回 `AsyncIterable`。SSE 路由用 `for await` 消费。注册是**同步**的 —— `subscribe()` 返回时订阅者已经挂上,所以与消费者第一次 `next()` race 的 `publish()` 仍会被投递。 + +### `BoundedAsyncQueue` + +每订阅者的队列,两个关键行为: + +- **上限只算 LIVE 项**。`forcePush()` 进的项每条带 `forced: true` 标签,不计入 `maxSize`。这让 `Last-Event-ID` 重放可以强推数百历史帧到新订阅者而不会立刻撞到 live 上限把刚 resume 的订阅者驱逐。 +- **`liveCount` 是字段**,不是由 `forcedInBuf` 位置推导的。之前位置推导在 `slow_client_warning` 开始 mid-stream 强推(推到队尾,不是像 replay 那样推到队头)后就坏了;每条 `forced` 标签位置无关。 + +`push(value)` 在 LIVE 上限时返回 `false`(既不阻塞也不抛),bus 据此驱逐订阅者。`forcePush(value)` 绕过上限。`close({drain?: boolean})` 默认 drain 已有项;abort 路径用 `drain: false` 直接丢弃。 + +## 流程 + +### Publish + +```mermaid +flowchart TD + P["publish({type, data, originatorClientId?})"] --> C{"bus closed?"} + C -->|yes| RU["return undefined"] + C -->|no| AID["assign id = nextId++, v = 1"] + AID --> PR["push to ring (shift if > ringSize)"] + PR --> FAN["snapshot subscribers, for each sub:"] + FAN --> EVCK{"sub.evicted?"} + EVCK -->|yes| NEXT[next subscriber] + EVCK -->|no| PUSH["sub.queue.push(event)"] + PUSH --> OK{"accepted?"} + OK -->|no| EVICT["mark evicted; force-push client_evicted; queue.close; sub.dispose"] + OK -->|yes| WARN{"!warned && liveSize >= warnThreshold?"} + WARN -->|yes| FW["force-push slow_client_warning; warned = true"] + WARN -->|no| RES{"warned && liveSize <= warnResetThreshold?"} + RES -->|yes| RA["warned = false (hysteresis re-arm)"] + RES -->|no| NEXT +``` + +`publish` 永远不抛。关闭 bus 之中 publish(shutdown 路径在 await `channel.kill()` 前关每个 session 的 bus)返回 `undefined` 而不是抛,因为 agent 在 bus close 与 channel kill 之间的窗口里还可能发 `sessionUpdate` 通知。 + +### Subscribe + replay(带环驱逐检测) + +```mermaid +sequenceDiagram + autonumber + participant SR as SSE route + participant EB as EventBus + participant Q as BoundedAsyncQueue + + SR->>EB: subscribe({lastEventId: 42, maxQueued: 256, signal}) + EB->>EB: refuse if subs.size >= maxSubscribers
(throws SubscriberLimitExceededError) + EB->>Q: new BoundedAsyncQueue(256) + EB->>EB: subs.add(sub) + EB->>EB: earliestInRing = ring[0]?.id + alt earliestInRing > lastEventId + 1 (gap evicted) + EB->>Q: forcePush state_resync_required
{ reason: 'ring_evicted', lastDeliveredId: 42, earliestAvailableId: earliestInRing } + Note over EB,Q: id-less synthetic, frame goes BEFORE replay.
Stream stays open; SDK reducer flips awaitingResync. + end + loop ring scan + EB->>EB: for e in ring where e.id > 42 + EB->>Q: forcePush(e) + end + EB->>EB: attach AbortSignal listener
(onAbort → queue.close({drain:false}); dispose) + EB-->>SR: AsyncIterable + SR->>Q: next() in for-await loop +``` + +subscribe 时 `subs.size >= maxSubscribers` 抛 `SubscriberLimitExceededError`,SSE 路由捕获并给被拒客户端序列化一个 `stream_error` 合成帧,免得他们看到一片空。返回空 iterable 会让 oncall 在高负载下分不清「有的客户端收到了,有的没收到」。 + +### 环驱逐 → `state_resync_required`(恢复流) + +当消费方带 `Last-Event-ID: N` 重连,但环里最早留存事件的 `id > N + 1`,说明 `[N+1, earliestInRing-1]` 这段在重连前被 evict 了。朴素重放会默默成功但拿到一个非连续后缀,SDK reducer 当作连续流继续 apply delta,状态就与 daemon 真相分叉 —— 全程没有终态信号。 + +实现在 `packages/acp-bridge/src/eventBus.ts:359-402`: + +1. 算 `earliestInRing = this.ring[0]?.id`。 +2. 若 `earliestInRing > opts.lastEventId + 1`,在重放帧**之前**强推一帧合成: + ```jsonc + { + "v": 1, + "type": "state_resync_required", + "data": { + "reason": "ring_evicted", + "lastDeliveredId": , + "earliestAvailableId": + } + } + ``` +3. 之后照常做重放循环。 + +关键契约(以及 wenshao #4360 review 修正过的几点): + +- **无 `id`** —— 与 `client_evicted` 同样的「不占位」模式,不会占掉 per-session 单调序列号让其他订阅者看到断档。 +- **流保持打开** —— 不同于 `client_evicted`(真终态),`state_resync_required` 面向恢复。重放和 live 帧继续。 +- **reducer 自动跳过 delta** —— SDK 端 `awaitingResync = true`,只放行 `state_resync_required` 本身加四个终态帧(`session_died`、`session_closed`、`client_evicted`、`stream_error`),直到调用方调 `loadSession` 清掉标志。详见 [`09-event-schema.md`](./09-event-schema.md) 的 `RESYNC_PASSTHROUGH_TYPES`。 +- **省网络** —— 帧仍然走线,SDK 之后可以计算「漏了什么」的 diff,不需要额外重连一次。 + +### 驱逐终态 + +订阅者 LIVE 队列已到 `maxQueued`,再来一次 `push()` 返回 `false`: + +1. 标 `sub.evicted = true`。 +2. 构造 `client_evicted` 帧,**无 `id`** —— `{ v: 1, type: 'client_evicted', data: { reason: 'queue_overflow', droppedAfter: <最后投递的 id> } }`。 +3. `queue.forcePush(evictionFrame)` 让消费者 iterator 看到一个终态帧。 +4. `queue.close()` 让 iterator 在终态帧后 unwind。 +5. `sub.dispose()` —— 从 `subs` 移除**并且**解绑 `AbortSignal` listener(**BmJT1 修复**:不这么做时,卡住的消费者闭包会一直存活到 `AbortSignal` 自己 GC)。 + +### Abort 流 + +`AbortSignal.abort()` → `onAbort()`: + +1. `queue.close({drain: false})` —— 丢弃已缓冲项,免得 SSE 路由继续往没人看的 socket 序列化事件。 +2. `dispose()` —— 通过 `disposed` 标志幂等。 + +subscribe 时已 abort 的 signal 会在返回 iterator 前同步调一次 `onAbort()`。 + +## 状态与生命周期 + +- `nextId` 从 1 起只增不减;`lastEventId` getter 返回 `nextId - 1`。 +- `ring` 有界;满了之后 `shift` 是 O(n)。`ringSize=8000` 在聊天密集 session 上每次 publish 几毫秒,远低于 per-frame 延迟预算。circular-buffer refactor 推迟到 profiling 真的标出它,或 operator 把 `--event-ring-size` 提一个数量级时再做。 +- `close()` 翻转 `closed`、关掉所有订阅者队列、清空 `subs`。之后 `publish()` / `subscribe()` 都是 no-op(`publish` 返 undefined,`subscribe` 返 `emptyAsyncIterable`)。 +- 每 session 一个 `EventBus`。bus close 发生在 `channel.kill()` 之前,shutdown 中飞行的 publish 返 undefined 而不抛。 + +## 依赖 + +- 被 `packages/acp-bridge/src/bridge.ts` 消费(`BridgeClient.sessionUpdate` / `extNotification` → `events.publish(...)`)。 +- 被 `packages/cli/src/serve/server.ts` 消费(SSE 路由 → `events.subscribe(...)`,再把 `BridgeEvent` 序列化为 SSE wire)。 +- re-export shim:`packages/cli/src/serve/eventBus.ts` → `@qwen-code/acp-bridge/eventBus`。 +- SDK 消费方:`packages/sdk-typescript/src/daemon/sse.ts`(`parseSseStream`),之后接 `narrowDaemonEvent`(详见 [`09-event-schema.md`](./09-event-schema.md)、[`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md))。 + +## 配置 + +- `--event-ring-size ` — per-session 环深度,软上限 `MAX_EVENT_RING_SIZE = 1_000_000`。 +- `GET /session/:id/events` 上的 `?maxQueued=N` query 参数,范围 `[16, 2048]`,SDK 在 opt-in 前 pre-flight `caps.features.slow_client_warning`。 +- `BridgeOptions.eventRingSize`(嵌入用例覆盖 daemon 默认)。 +- 能力 tag:`session_events`、`slow_client_warning`、`typed_event_schema`。 + +## 注意 & 已知局限 + +- **合成帧无 `id`**。SDK 用 `Last-Event-ID` 重连时不能假设序号连续 —— live 流里看到 「事件 3、5、6,缺 4」是正常的(如果 4 是当时强推给该订阅者的 `slow_client_warning` 或 `client_evicted`,那帧是私有的)。 +- `client_evicted` 是 **per-subscriber** 不是 per-session,同一客户端可以重连。 +- `BoundedAsyncQueue` iterator **不支持并发驱动** —— 两次同时 `.next()` 会 race 同一事件。生产环境是顺序消费(SSE 路由的 `for await`),安全。 +- bus 目前包私有;channels 和 webui 想订阅必须走 daemon HTTP SSE 路由,不能直接 reach 进 bus。Stage 1.5 会把它升到顶层。 + +## 参考 + +- `packages/acp-bridge/src/eventBus.ts`(整文件) +- `packages/acp-bridge/src/bridge.ts`(publish 站点,特别是 `BridgeClient.sessionUpdate` 和 F3 权限事件) +- `packages/cli/src/serve/server.ts`(SSE 路由 handler — 把 `BridgeEvent` 序列化为 wire SSE) +- `packages/sdk-typescript/src/daemon/sse.ts`(客户端 SSE wire 解析器) +- wire 参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)(`Last-Event-ID` 重连合约)。 diff --git a/docs/developers/daemon/11-capabilities-versioning.md b/docs/developers/daemon/11-capabilities-versioning.md new file mode 100644 index 0000000000..4bf0e92f21 --- /dev/null +++ b/docs/developers/daemon/11-capabilities-versioning.md @@ -0,0 +1,165 @@ +# 能力协商与协议版本 +## 概览 + +`GET /capabilities` 是 daemon 的 pre-flight 出口。每个 SDK 客户端应该在任何其他路由之前先读它,了解 daemon 说哪个协议版本、开了哪些 feature tag、绑定到哪个 workspace。合约: + +- **只有一个协议版本 `v1`。** `SERVE_PROTOCOL_VERSION = 'v1'`、`SUPPORTED_SERVE_PROTOCOL_VERSIONS = ['v1']`。v1 内部纯加法;frame 形态破坏性改动留给 v2。 +- **每 tag 一个 `since` 版本**,未来 v2 可以同时广播 v1 与 v2 tag。 +- **条件广播**。三个 tag(`require_auth`、`mcp_workspace_pool`、`mcp_pool_restart`)只在对应部署开关打开时才广播;tag 存在 = 行为存在。 +- **Capability tag = 行为契约**。在已有 tag 下加新行为会悄悄破坏已有的 pre-flight 检查;**新行为对应新 tag**。 + +完整注册表在 `packages/cli/src/serve/capabilities.ts:37-215`。 + +## 职责 + +- 声明 daemon 可能广播的每个 feature。 +- 按协议版本 + 部署开关过滤实际广播的 feature。 +- 暴露 `getRegisteredServeFeatures()`(全 key、不过滤)、`getAdvertisedServeFeatures(version, toggles)`(过滤后)、`getServeProtocolVersions()`(envelope:`{current, supported}`)。 +- 守住「tag 存在 = 行为存在」的不变式 —— `server.test.ts` 的「every conditional tag advertises when its toggle is on」测试遍历 `CONDITIONAL_SERVE_FEATURES` 的 key,没写 predicate 的新 tag 直接挂测。 + +## 架构 + +### Capability envelope + +`/capabilities` 返回: + +```ts +{ + v: 1, // CAPABILITIES_SCHEMA_VERSION + mode: 'http-bridge', + features: ServeFeature[], + workspaceCwd: string, + protocol?: { current: 'v1', supported: ['v1'] }, + policy?: { permission: PermissionPolicy }, +} +``` + +`workspaceCwd` 是 daemon 绑定的规范化 workspace(详见 [`02-serve-runtime.md`](./02-serve-runtime.md))。`policy.permission` 是当前激活的 mediator 策略。 + +### `ServeCapabilityDescriptor` + +```ts +interface ServeCapabilityDescriptor { + since: ServeProtocolVersion; // current = 'v1' + modes?: readonly string[]; // 多种操作模式时列出 +} +``` + +v1 用到 `modes` 的两个 tag: + +- `mcp_guardrails: { since: 'v1', modes: ['warn', 'enforce'] }` —— 客户端依赖 refusal 行为前 pre-flight `'enforce'`。 +- `permission_mediation: { since: 'v1', modes: ['first-responder', 'designated', 'consensus', 'local-only'] }` —— 客户端在这里看到**构建期支持集**;daemon 的**激活策略**在 envelope 的 `policy.permission`。 + +### 条件 tag + +```ts +export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< + ServeFeature, + (toggles: AdvertiseFeatureToggles) => boolean +> = new Map([ + ['require_auth', (t) => t.requireAuth === true], + ['mcp_workspace_pool', (t) => t.mcpPoolActive === true], + ['mcp_pool_restart', (t) => t.mcpPoolActive === true], +]); +``` + +`Map` 形状把「predicate 判断」和「集合成员」收成一条记录。加一个新条件 tag 要**两处协调修改**: + +1. 在 `SERVE_CAPABILITY_REGISTRY` 注册 tag 及其 `since`。 +2. 在 `CONDITIONAL_SERVE_FEATURES` 加 predicate。 + +基线 tag(Map 里没有)无条件广播 —— 这个决定是**用「不写」表达**的,不需要专门维护一个 Set。 + +### 38 个 tag(v1,按域) + +Foundation:`health`、`capabilities`。 + +Sessions:`session_create`、`session_scope_override`、`session_load`、`unstable_session_resume`、`session_list`、`session_prompt`、`session_cancel`、`session_events`、`session_set_model`、`session_close`、`session_metadata`、`session_context`、`session_supported_commands`、`session_approval_mode_control`。 + +Streaming:`slow_client_warning`、`typed_event_schema`。 + +Identity & heartbeat:`client_identity`、`client_heartbeat`。 + +Permissions:`session_permission_vote`、`permission_vote`、**`permission_mediation`**(`modes: ['first-responder', 'designated', 'consensus', 'local-only']`)。 + +Workspace 只读快照:`workspace_mcp`、`workspace_skills`、`workspace_providers`、`workspace_env`、`workspace_preflight`。 + +Workspace 修改(Wave 4):`workspace_memory`、`workspace_agents`、`workspace_tool_toggle`、`workspace_init`、`workspace_mcp_restart`、`workspace_file_read`、`workspace_file_bytes`、`workspace_file_write`。 + +MCP guardrails:**`mcp_guardrails`**(`modes: ['warn', 'enforce']`)、`mcp_guardrail_events`、**`mcp_workspace_pool`**(条件)、**`mcp_pool_restart`**(条件)。 + +Auth:`auth_device_flow`、**`require_auth`**(条件)。 + +(粗体 = 带 `modes` 或条件。) + +## 流程 + +### Daemon 端:装 envelope + +```mermaid +flowchart LR + A["GET /capabilities"] --> B["getAdvertisedServeFeatures(version, toggles)"] + B --> C["filter by isFeatureAvailableInProtocol"] + C --> D["for each, check CONDITIONAL_SERVE_FEATURES"] + D --> E["yes: predicate(toggles) ? include : drop"] + D --> F["no: include unconditionally"] + E --> G["return ServeFeature[]"] + F --> G + G --> H["wrap in envelope:
{ v: 1, mode, features, workspaceCwd, protocol, policy }"] +``` + +### 客户端:feature pre-flight + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant D as GET /capabilities + participant R as Route + + C->>D: GET /capabilities + D-->>C: { v, mode, features, workspaceCwd, protocol, policy } + C->>C: features.includes('mcp_workspace_pool')? + alt yes + C->>R: rely on pool-aware response shapes
(e.g. entries[] from /workspace/mcp/:server/restart) + else no + C->>R: legacy single-entry response shape + end +``` + +## 状态与生命周期 + +- `CAPABILITIES_SCHEMA_VERSION` 是 wire envelope 的形状版本(当前 `1`)。bump 它是对 envelope 本身的 break。 +- `SERVE_PROTOCOL_VERSION = 'v1'` 是协议-feature 版本。v1 内加 feature 是加法;老客户端不 pre-flight 新 tag 就看不到,**移除** feature 才是 v2 break。 +- `EVENT_SCHEMA_VERSION = 1` 是 SSE frame 的 `v` 字段(见 [`09-event-schema.md`](./09-event-schema.md)),独立版本轴;bump 事件 schema 不必 bump 协议版本,反之亦然。 +- `unstable_session_resume` 故意带 `unstable_` 前缀,因为 ACP 的 `connection.unstable_resumeSession` 还可能变形状;客户端应 feature-detect 而不是固定 v1。 + +## 依赖 + +- 被 `packages/cli/src/serve/server.ts` 读来装 `/capabilities` 响应。 +- Toggle 输入:`runQwenServe` 构造 `{ requireAuth: opts.requireAuth, mcpPoolActive: opts.mcpPoolActive }` 透传到 envelope。 +- envelope 中激活的 `permission` 策略来自 `BridgeOptions.permissionPolicy`(其本身读 `settings.json` 的 `policy.permissionStrategy`)。 + +## 配置 + +| 来源 | 旋钮 | 对 capabilities 的影响 | +| --------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| 参数 | `--require-auth` | `require_auth` tag 出现 | +| Env | `QWEN_SERVE_NO_MCP_POOL=1` | `mcp_workspace_pool` + `mcp_pool_restart` 消失;MCP 事件不再盖 `scope: 'workspace'` | +| 参数 | `--mcp-client-budget=N`、`--mcp-budget-mode={off,warn,enforce}` | 不改 tag 集合(`mcp_guardrails` 永远广播),但改 per-server 预留 + refusal 行为 | +| `settings.json` | `policy.permissionStrategy` | 设 envelope 的 `policy.permission` | + +## 注意 & 已知局限 + +- **`--require-auth` 遮蔽 pre-flight**。开 `--require-auth` 时所有路由(包括 `/capabilities`)都要 bearer。未认证客户端无法 pre-flight `caps.features.require_auth` 来发现需要认证;这种情形下**401 响应体**就是发现 surface(详见 [`12-auth-security.md`](./12-auth-security.md))。`require_auth` tag 是**认证后确认**,给加固部署的审计 UI 用。 +- **Tag 存在 = 行为存在**。如果未来贡献者在已有 tag 下加行为且没 bump `since`,pre-flight 旧 tag 的 SDK 会默默拿到新行为。约定:**新行为对应新 tag**。 +- **`unstable_*` tag 可能在版本之间变形状**且不 bump 协议版本。依赖时硬绑 SDK 版本。 +- 路由清单在 [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md),本文刻意不重复。 + +## 参考 + +- `packages/cli/src/serve/capabilities.ts:1-330`(整文件) +- `packages/cli/src/serve/types.ts:37-155`(`ServeOptions`、`CapabilitiesEnvelope`) +- `packages/cli/src/serve/server.ts`(envelope 装配) +- `packages/acp-bridge/src/eventBus.ts:22`(`EVENT_SCHEMA_VERSION`) +- wire 参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。 diff --git a/docs/developers/daemon/12-auth-security.md b/docs/developers/daemon/12-auth-security.md new file mode 100644 index 0000000000..8f3f28377d --- /dev/null +++ b/docs/developers/daemon/12-auth-security.md @@ -0,0 +1,246 @@ +# 认证与安全模型 +## 概览 + +`qwen serve` 默认是本地 daemon,配错就是暴露面。安全模型**分层**,错配时 fail-closed: + +1. **绑定** — 非 loopback 绑定无 bearer token **拒启动**。 +2. **Bearer auth** — `bearerAuth` 中间件,常量时间 SHA-256 比较,覆盖除 loopback 上 `/health` 之外的每条路由(`require_auth` 把它扩展到 loopback 与 `/health`)。 +3. **Host 白名单** — loopback 上只接受 `localhost`、`127.0.0.1`、`[::1]`、`host.docker.internal`(带端口),防 DNS rebinding。 +4. **Origin 拒绝** — 任何带 `Origin` 头的请求 `403`。CLI / SDK 永远不发 `Origin`,只有浏览器发。 +5. **每路由 mutation gate** — Wave 4 修改类路由 opt-in,「即便 loopback 无 token 也 401」并带专有 `code: 'token_required'`。 +6. **Device-flow auth** — Provider OAuth 流的独立 surface(`POST /workspace/auth/device-flow` + GET/DELETE on `/:id`)。 + +本文讲清每一层和 boot 路径强制的每个不变式。 + +## 职责 + +- 不安全配置直接拒启动。 +- 通过 bearer(配了的话)+ host(loopback)+ origin 检查闸所有 HTTP 请求。 +- 为 Wave 4 路由提供 per-route mutation gate。 +- 托管 device-flow registry 驱动 provider OAuth 流并通过 SSE 事件可见。 + +## 架构 + +### 启动期 refuse 规则 + +`runQwenServe.ts`: + +```ts +if (!isLoopbackBind(opts.hostname) && !token) { + throw new Error('Refusing to bind : without a bearer token. ...'); +} +if (opts.requireAuth && !token) { + throw new Error( + 'Refusing to start with --require-auth set but no bearer token configured. ...', + ); +} +``` + +两个拒绝都是 boot-loud(stderr / 抛给嵌入方),从不静默。#3803 的威胁模型明文禁止 daemon 默默裸跑到 loopback 之外。 + +### 中间件链(HTTP 请求顺序) + +```mermaid +flowchart LR + REQ[Request] --> SO["strip same-origin Origin
(demo page support)"] + SO --> CORS["denyBrowserOriginCors"] + CORS --> HA["hostAllowlist"] + HA --> BA["bearerAuth"] + BA --> ROUTE["route handler"] + ROUTE --> MG["mutationGate (per-route opt-in)"] + MG --> BODY["body parsing + handler"] +``` + +(`mutationGate` 是 per-route 中间件,可以选择性 `strict: true`,详见 `packages/cli/src/serve/auth.ts:1-294`。) + +### `bearerAuth` + +- **没配 token** → 中间件是 no-op(loopback dev 默认)。 +- **配了 token** → 构造时把 token SHA-256 一次;每请求把 candidate 哈希再 `timingSafeEqual` 比较;不走字符串等值短路,不漏时序信息。 +- **scheme 解析**:`Bearer` 大小写不敏感(RFC 7235 §2.1),scheme 与凭证之间允许 `SP\tHTAB` BWS(RFC 7230 §3.2.6),但纯 HTAB 作分隔被拒。 +- **CodeQL 加固**:手写 `indexOf` 解析而不是带 `\s+` / `.+` 重叠的正则,避免多项式正则风险。 + +### `hostAllowlist` + +仅 loopback。按端口缓存 `Set`。允许的 Host: + +- `localhost:`、`127.0.0.1:`、`[::1]:`、`host.docker.internal:`。 +- 加上无端口形式(`localhost`、`127.0.0.1`、`[::1]`、`host.docker.internal`),**仅**当绑端口 80 时(RFC 7230 §5.4 默认端口省略)。 + +Host 比较**大小写不敏感** —— Express 规范 header 名但不规范值,Docker 代理大写 Host(`Localhost:4170`、`HOST.docker.internal`)严格比对会 403。 + +非 loopback 绑定跳过该中间件(operator 选了暴露面,bearer 顶上挡 Host 伪造)。 + +### `denyBrowserOriginCors` + +任何带 `Origin` 的请求直接 `403 { error: 'Request denied by CORS policy' }`。CLI/SDK 永不发 `Origin`,只有浏览器发。返回确定性 403 而不是 `cors` 包错误回调的 500 HTML。 + +例外:demo 页的同源 XHR 由 `server.ts` 里另一个中间件先把匹配本机地址的 `Origin` 剥掉。 + +### `createMutationGate` + +per-route opt-in 闸门。行为矩阵: + +| daemon 配置 | route opts | 结果 | +| ------------------------ | --------------- | -------------------------------- | +| `requireAuth=true` | 任意 | passthrough¹ | +| 配了 `token` | 任意 | passthrough² | +| 无 token(loopback dev) | `strict: false` | passthrough | +| 无 token(loopback dev) | `strict: true` | `401 { code: 'token_required' }` | + +¹ `--require-auth` 只在配了 token 时启动,全局 `bearerAuth` 已经 401 过未认证调用。 +² 配了 token 的任何路径,全局 `bearerAuth` 都强制 bearer;这里冗余但无害。 + +`code: 'token_required'` 与 `bearerAuth` 普通 `Unauthorized` 不同形状,SDK 据此渲染「请用 --token / --require-auth 启动 daemon」提示而不是泛 401。 + +**Wave 4 strict 路由**:`/workspace/memory`、`/workspace/agents/*`、`/file/write`、`/file/edit`、`/workspace/tools/:name/enable`、`/workspace/mcp/:server/restart`、`/workspace/auth/device-flow`、`/workspace/init`、`/session/:id/approval-mode`。 + +### `/health` 豁免 + +loopback 绑定上,`/health` 注册在 bearer 中间件**之前**,pod 内部 liveness 探针不必带 token。非 loopback 绑定下 `/health` 也走 bearer。`--require-auth` 撤销豁免:loopback 上 `/health` 也要 `Authorization: Bearer `。 + +### v1 的 client 身份 (`X-Qwen-Client-Id`) 是自报 + +daemon 只校验 `X-Qwen-Client-Id` 的格式(`[A-Za-z0-9._:-]{1,128}`)并按 session 跟踪 attach 的 client id;当下**不做** proof-of-possession 检查。客户端只要观察到 SSE 帧里的 `originatorClientId` 就能用同 id 重新注册,在后续请求里冒充 originator。 + +影响范围:**`designated`** 策略(远端可以伪装 originator 给本应只属于 prompt 发起人的请求投票);**`consensus`** 策略(如果 `votersAtIssue` 快照里已经有伪装 id,它能投)。**不**影响 `local-only`(按 `fromLoopback` 闸,daemon 按连接 remote address 盖戳),**不**影响 `first-responder`(与身份无关)。 + +「pair-token」机制(`POST /session` 时 daemon 发一个 per-session secret,`designated` / `consensus` 投票必须带)将来 PR 落地。当下需要加固 designated 策略的部署应当绑 loopback 或挂在做认证的反代后面。详见 [`04-permission-mediation.md`](./04-permission-mediation.md) 各策略的具体影响。 + +### Device-flow auth + +provider 认证(Qwen OAuth 等)的独立 OAuth surface: + +- `POST /workspace/auth/device-flow` — 启动一个流;返回 `{deviceFlowId, providerId, expiresAt, verificationUrl, userCode}`。 +- `GET /workspace/auth/device-flow/:id` — 轮询状态。 +- `DELETE /workspace/auth/device-flow/:id` — 取消。 +- `GET /workspace/auth/status` — 当前账号 / provider 快照。 + +SSE 事件 `auth_device_flow_{started, throttled, authorized, failed, cancelled}` 把流状态扇出给所有订阅者,多客户端 UI 同步。见 [`09-event-schema.md`](./09-event-schema.md)。 + +实现:`packages/cli/src/serve/auth/deviceFlow.ts` + `qwenDeviceFlowProvider.ts`。 + +**日志注入 / Trojan-Source 防御**:`sanitizeForStderr(value)`(`deviceFlow.ts:47-72`)剥掉 ASCII C0 / DEL / C1 控制字符**外加** Unicode 同形字符 —— 恶意 IdP 可能用它们伪造日志行或隐藏 payload: + +| 范围 | 为什么剥 | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `\x00–\x1f`、`\x7f`、`\x80–\x9f` | ASCII C0 / DEL / C1,日志行伪造、终端控制序列 | +| U+200B–U+200F | 零宽字符 + LRM / RLM,隐形但能改终端渲染 | +| U+2028–U+2029 | LINE / PARAGRAPH SEPARATOR,许多 Unicode-aware 终端把它当换行,最直接的日志伪造向量 | +| U+202A–U+202E | 双向 EMBEDDING / OVERRIDE 控制 | +| U+2066–U+2069 | 双向 ISOLATE 控制(LRI / RLI / FSI / PDI),[CVE-2021-42574 "Trojan Source"](https://trojansource.codes/) 主攻击向量。恶意 IdP 用 U+2066 (LRI) 替换 U+202D (LRO) 会绕过 EMBEDDING/OVERRIDE 范围却达到同样视觉重排 | +| U+FEFF | BOM / 零宽不折断空格 | + +长度保持(每个被剥码点替换为 `?` 而不是消失),operator 在那索引处仍能看出有东西曾经在。两层都用:`qwenDeviceFlowProvider` 净化 IdP 的 `oauthError`,registry 的 late-poll 观察者净化插值进 audit hint 的 provider 可控值(`latePollResult.kind` / `lateErr.name`)。 + +**日志注入 / Trojan-Source 防御**:`sanitizeForStderr(value)`(`deviceFlow.ts:47-72`)剥掉 ASCII C0 / DEL / C1 控制字符**外加** Unicode 同形字符 —— 恶意 IdP 可能用它们伪造日志行或隐藏 payload: + +| 范围 | 为什么剥 | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `\x00–\x1f`、`\x7f`、`\x80–\x9f` | ASCII C0 / DEL / C1,日志行伪造、终端控制序列 | +| `​–‏` | Zero-width 字符 + LRM/RLM,隐形但能改终端渲染 | +| `–` | LINE / PARAGRAPH SEPARATOR,许多 Unicode-aware 终端把它当换行,最直接的日志伪造向量 | +| `‪–‮` | 双向 EMBEDDING / OVERRIDE 控制 | +| `⁦–⁩` | 双向 ISOLATE 控制(LRI / RLI / FSI / PDI),[CVE-2021-42574 "Trojan Source"](https://trojansource.codes/) 主攻击向量。恶意 IdP 用 U+2066 (LRI) 替换 U+202D (LRO) 会绕过 EMBEDDING/OVERRIDE 范围却达到同样视觉重排 | +| `` | BOM / 零宽不折断空格 | + +长度保持(每个被剥码点替换为 `?` 而不是消失),operator 在那索引处仍能看出有东西曾经在。两层都用:`qwenDeviceFlowProvider` 净化 IdP 的 `oauthError`,registry 的 late-poll 观察者净化插值进 audit hint 的 provider 可控值(`latePollResult.kind` / `lateErr.name`)。 + +`auth_device_flow` 能力 tag **无条件**广播;路由本身在 daemon 不支持指定 provider 时返 `400 unsupported_provider`。支持的 provider 列表在 `/workspace/auth/status` 而不是 `/capabilities`,保持 descriptor 形状统一。 + +## 流程 + +### Bearer auth 正路 + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant BA as bearerAuth + participant R as Route + + C->>BA: Authorization: Bearer abc... + BA->>BA: parse scheme (case-insensitive), strip BWS + BA->>BA: SHA-256(candidate) + BA->>BA: timingSafeEqual(candidate, expected) + BA->>R: next() + R-->>C: 200 ... +``` + +### Bearer auth 失败模式 + +都返 `401 { error: 'Unauthorized' }`(`missing header` / `wrong scheme` / `wrong token` 一致),探测者无法区分。 + +### `--require-auth` 阴影 + +```mermaid +sequenceDiagram + autonumber + participant C as Unauth client + participant CAPS as GET /capabilities + participant BA as bearerAuth + + C->>CAPS: GET /capabilities (no Authorization) + CAPS->>BA: pass through middleware + BA-->>C: 401 Unauthorized + Note over C,BA: client cannot pre-flight require_auth tag
before authenticating. Discovery surface is the 401 body. +``` + +认证成功后 `caps.features.includes('require_auth')` 确认部署是 hardened。 + +### Wave-4 mutation gate 在无 token loopback 上 + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant BA as bearerAuth (no-op, no token) + participant MG as mutationGate({strict: true}) + participant R as Handler + + C->>BA: POST /workspace/memory (no Authorization) + BA->>MG: passthrough + MG-->>C: 401 { code: 'token_required', error: '...' } +``` + +## 状态与生命周期 + +- Bearer token 在 boot 读取并 trim(防 `cat token.txt` 带尾换行默默永不匹配)。 +- 允许 Host 集合按端口缓存;端口变(ephemeral `0` → `listen` 后的真实端口)才重建。 +- mutation gate 在应用构造时构造 `passthrough` 和 `strictDenier` 一次;每路由调用返回缓存闭包(无 per-request 分配)。 +- device-flow registry 在 `shutdown()` 第一阶段释放,pending flow 在 HTTP 收尾前解析为 `cancelled`。 + +## 依赖 + +- `node:crypto` —— `createHash`、`timingSafeEqual`。 +- `packages/cli/src/serve/loopbackBinds.ts` —— `isLoopbackBind`。 +- `packages/cli/src/serve/auth/deviceFlow.ts` —— device-flow 状态机。 +- `@qwen-code/acp-bridge` —— 把 device-flow 事件吐到 per-session SSE bus。 + +## 配置 + +| 来源 | 旋钮 | 效果 | +| -------- | ------------------------------------------------ | --------------------------------------------------------------------- | +| Env | `QWEN_SERVER_TOKEN` | Bearer token(trim 后) | +| 参数 | `--token` | Bearer token(覆盖 env) | +| 参数 | `--require-auth` | Bearer 扩展到 loopback + `/health`。仅在配了 token 时启动 | +| 参数 | `--hostname` | 非 loopback 绑定要求 `--token`(或 env) | +| 能力 tag | `require_auth`(条件)、`auth_device_flow`(恒) | 见 [`11-capabilities-versioning.md`](./11-capabilities-versioning.md) | + +## 注意 & 已知局限 + +- **`--require-auth` 遮蔽 feature pre-flight**。未认证客户端无法发现 `require_auth` tag;它们的发现 surface 是 401 响应体。 +- **mutation gate body-parser 顺序**:strict 路径的 401 在 `express.json()` 之后才发;满载 loopback 监听器最坏 `--max-connections × express.json({limit: '10mb'})` ≈ 2.5 GB 瞬时。loopback only,刻意接受。 +- **同源 Origin 剥离**发生在 `denyBrowserOriginCors` **之前**;如果未来 refactor 把剥离挪走,demo 页会坏。 +- **Token 比较是对 SHA-256 摘要**而不是原始 token,把变长比较收成定长比较,时序泄漏更难做。 +- daemon 当前**没有** mTLS / 请求签名 / per-client 限流,那些是 F 系列 Wave 5+ 项目。 + +## 参考 + +- `packages/cli/src/serve/auth.ts:1-294`(整文件) +- `packages/cli/src/serve/runQwenServe.ts:341-360`(refuse 规则) +- `packages/cli/src/serve/loopbackBinds.ts` +- `packages/cli/src/serve/auth/deviceFlow.ts` +- `packages/cli/src/serve/auth/qwenDeviceFlowProvider.ts` +- 用户威胁模型:[`../../users/qwen-serve.md`](../../users/qwen-serve.md)。 +- wire 参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。 diff --git a/docs/developers/daemon/13-sdk-daemon-client.md b/docs/developers/daemon/13-sdk-daemon-client.md new file mode 100644 index 0000000000..6ce6ce82bd --- /dev/null +++ b/docs/developers/daemon/13-sdk-daemon-client.md @@ -0,0 +1,262 @@ +# TypeScript SDK Daemon 客户端 +## 概览 + +`packages/sdk-typescript/src/daemon/` 是 **TypeScript SDK 的 daemon 客户端**。任何 TypeScript / JavaScript 宿主想跟在跑的 `qwen serve` 通话都走它(CLI 自己的 TUI 适配器、channel 机器人后端、VSCode IDE companion、自定义脚本、服务端 Web BFF)。所有其他适配器都依赖它。 + +包布局有意保持紧凑: + +| 文件 | 暴露 | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| `index.ts` | 公开 barrel(`DaemonClient`、`DaemonSessionClient`、`DaemonAuthFlow`、`parseSseStream`、event reducers、types) | +| `DaemonClient.ts` | 低层 HTTP/SSE 门面 —— 每条 `qwen-serve-protocol.md` 路由一个方法 | +| `DaemonSessionClient.ts` | session 级封装,自动跟踪 SSE 重放 | +| `DaemonAuthFlow.ts` | 高层 OAuth Device Flow 助手 | +| `sse.ts` | `parseSseStream`(NDJSON / SSE 框架解析) | +| `events.ts` | `narrowDaemonEvent`、`reduceDaemonSessionEvent`、`reduceDaemonAuthEvent`(见 [`09-event-schema.md`](./09-event-schema.md)) | +| `types.ts` | `DaemonCapabilities`、`DaemonSession`、`DaemonEvent`、`PermissionResponse`、`PromptResult`、MCP / agent / memory / auth 类型 | + +走查示例在 [`../examples/daemon-client-quickstart.md`](../examples/daemon-client-quickstart.md);本文是架构/契约参考。 + +## 职责 + +- 每条 daemon HTTP 路由提供一个 TS 方法。 +- 给每请求正确盖 bearer token 和 `X-Qwen-Client-Id`。 +- 把 per-call 超时与调用方传入的 `AbortSignal` 组合(不杀长 SSE)。 +- 把 SSE 流解析成 typed `DaemonEvent`。 +- 每 session 跟踪 `lastSeenEventId`,重连正确重放。 +- 暴露 device-flow auth surface 按 daemon 给出的间隔轮询。 + +## 架构 + +### `DaemonClient`(`DaemonClient.ts:209-1506`) + +构造: + +```ts +new DaemonClient({ + baseUrl: string, // 默认 'http://127.0.0.1:4170' + token?: string, + fetch?: typeof globalThis.fetch, // 测试可注入 + fetchTimeoutMs?: number, // 0 = 禁用;默认 DEFAULT_FETCH_TIMEOUT_MS +}); +``` + +方法分组(每个方法可选 `clientId` 用于盖 `X-Qwen-Client-Id`): + +| 组 | 方法 | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Plumbing | `health()`、`capabilities()`、`auth`(lazy `DaemonAuthFlow` accessor) | +| Sessions | `createOrAttachSession`、`loadSession`、`resumeSession`、`listSessions`、`closeSession`、`setSessionMetadata`、`getSessionContext`、`getSessionSupportedCommands`、`setSessionApprovalMode`、`setSessionModel` | +| Prompting | `prompt`、`cancel`、`heartbeat` | +| Events | `subscribeEvents`(SSE 生成器)、`subscribeEventsStream`(原始 response) | +| Permissions | `respondToPermission`、`respondToSessionPermission` | +| Workspace 快照 | `getWorkspaceMcp`、`getWorkspaceSkills`、`getWorkspaceProviders`、`getWorkspaceEnv`、`getWorkspacePreflight` | +| Workspace 修改 | `writeWorkspaceMemory`、`readWorkspaceMemory`、`listWorkspaceAgents`、`getWorkspaceAgent`、`createWorkspaceAgent`、`updateWorkspaceAgent`、`deleteWorkspaceAgent`、`toggleWorkspaceTool`、`restartMcpServer`、`initializeWorkspace` | +| Files | `readFile`、`readFileBytes`、`writeFile`、`editFile`、`listDirectory`、`globPaths`、`statPath` | +| Auth | `startDeviceFlow`、`pollDeviceFlow`、`cancelDeviceFlow`、`getAuthStatus` | + +### `fetchWithTimeout`(BRN1o 行为) + +每个请求都过 `fetchWithTimeout`。关键细节: + +- **body 读取在定时器作用域内**。之前实现 header 一到就清定时器;代理在 body 中途卡住时 `await res.json()` 会超过 `fetchTimeoutMs` 仍然 hang。当前形态把读 body 的代码作为 callback 传入,定时器覆盖 header 到 body 全程。 +- **`perCallTimeoutMs`** 允许单次调用覆盖 client 级默认。最显眼的使用方是 `restartMcpServer`,SDK 用 `MCP_RESTART_DEFAULT_TIMEOUT_MS = 330_000`(5 分 30 秒)。daemon 自己的 `MCP_RESTART_TIMEOUT_MS` 上限正好是 300 秒 —— client 与之精确相等会与 daemon 响应 race:接近 300 秒完成或失败的重启可能在 daemon 把结构化响应序列化 + 上线 + 编码完之前 client 的 `AbortSignal` 先 fire,给出一个假阳性 `TimeoutError` 而 daemon 其实还在自己预算之内。多出的 30 秒覆盖序列化 + 在线传输 + 两端解码。想要更紧的调用方自己传 `timeoutMs`;传 `0` 完全关闭超时。 +- **`AbortSignal.any`** 把调用方信号与 per-call 定时器信号组合,调用方取消和 per-call 超时都干净 abort。 +- **`AbortController` + 可取消 `setTimeout`** 而不是 `AbortSignal.timeout()`;快速完成的请求不会在 event loop 上留 pending 定时器。`finally` 里 `clearTimeout`。 +- **流式端点(`subscribeEvents`)绕过超时** —— 长 SSE 不能被它杀。 + +### `DaemonSessionClient`(`DaemonSessionClient.ts:61-385`) + +绑一个 session 并自动跟踪 `lastSeenEventId`,SSE 重连重放开箱即用。 + +```ts +class DaemonSessionClient { + readonly client: DaemonClient; + readonly session: DaemonSession; + readonly state: DaemonSessionState; + private lastSeenEventId: number | undefined; + + static createOrAttach(client, req?): Promise; + static load(client, sessionId, req?): Promise; + static resume(client, sessionId, req?): Promise; + + events(opts?: DaemonSessionSubscribeOptions): AsyncIterable; + prompt(req: PromptRequest): Promise; + cancel(): Promise; + respondToPermission(...): Promise; + setModel(modelServiceId): Promise; + heartbeat(): Promise; + setMetadata(metadata): Promise; + close(): Promise; +} +``` + +`events()` 默认 `resume: true` 代理 `client.subscribeEvents`,把跟踪的 `lastSeenEventId` 传过去,重连从上次停的地方重放。每条 yield 出去的事件 bump `lastSeenEventId`。 + +### `DaemonAuthFlow`(`DaemonAuthFlow.ts:102-340`) + +```ts +class DaemonAuthFlow { + start(opts: { providerId, ... }): Promise; +} +interface DaemonAuthFlowHandle { + deviceFlowId: string; + providerId: string; + expiresAt: string; + verificationUrl: string; + userCode: string; + awaitCompletion(opts?): Promise; + cancel(): Promise; +} +``` + +`awaitCompletion()` 按 daemon 给出的 `intervalMs` 轮询 `GET /workspace/auth/device-flow/:id` 直到 `authorized` / `failed` / `cancelled`。通过 `client.auth` 懒构造,从不碰 auth 的客户端不付分配开销。 + +### `parseSseStream`(`sse.ts:70-295`) + +把 `Response.body`(`ReadableStream`)转成 `AsyncIterable`。处理: + +- LF 与 CRLF 帧。 +- 缓冲溢出上限(16 MiB),防 daemon 发单个荒谬大帧的防御性边界。 +- AbortSignal 接线 —— abort 关掉流和 iterator。 +- 仅注释帧与未知 event 类型(透传为 `DaemonEvent`,SDK 消费方通过 `narrowDaemonEvent` 下游 narrow)。 + +### 类型(`types.ts`) + +主要导出:`DaemonCapabilities`、`DaemonSession`(`{ sessionId, workspaceCwd, attached, clientId?, createdAt? }`)、`DaemonEvent`、`DaemonSessionState`、`DaemonSessionContextStatus`、`DaemonSessionSupportedCommandsStatus`、`PermissionResponse`、`PromptResult`、`HeartbeatResult`、`SetModelResult`、`SessionMetadataResult`,以及 MCP / agent / memory / auth 结果类型。 + +## 流程 + +### Create-or-attach 与首次 prompt + +```mermaid +sequenceDiagram + autonumber + participant App as App code + participant SC as DaemonSessionClient + participant DC as DaemonClient + participant D as Daemon + + App->>SC: DaemonSessionClient.createOrAttach(client, {clientId: 'alice'}) + SC->>DC: client.createOrAttachSession({}, 'alice') + DC->>D: POST /session
Authorization: Bearer ...
X-Qwen-Client-Id: alice + D-->>DC: {sessionId, attached, clientId} + DC-->>SC: DaemonSession + SC-->>App: DaemonSessionClient + + App->>SC: prompt({...}) + SC->>DC: client.prompt(sessionId, req, 'alice') + DC->>D: POST /session/:id/prompt + D-->>DC: {result} + DC-->>SC: PromptResult +``` + +### 带重放的订阅 + +```mermaid +sequenceDiagram + autonumber + participant App as App code + participant SC as DaemonSessionClient + participant DC as DaemonClient + participant D as Daemon + participant P as parseSseStream + + App->>SC: for await (e of session.events()) + SC->>DC: client.subscribeEvents(sessionId, {lastEventId: }, 'alice') + DC->>D: GET /session/:id/events
Last-Event-ID: 42 + D-->>DC: SSE bytes (replay then live) + DC->>P: parseSseStream(res.body, signal) + loop per frame + P-->>SC: DaemonEvent + SC->>SC: bump lastSeenEventId + SC-->>App: DaemonEvent + App->>App: narrowDaemonEvent + reduce + end +``` + +### Device Flow 认证 + +```mermaid +sequenceDiagram + autonumber + participant App as App + participant AF as DaemonAuthFlow + participant DC as DaemonClient + participant D as Daemon + + App->>AF: start({providerId: 'qwen-oauth'}) + AF->>DC: client.startDeviceFlow(...) + DC->>D: POST /workspace/auth/device-flow + D-->>DC: {deviceFlowId, verificationUrl, userCode, intervalMs, expiresAt} + DC-->>AF: handle + AF-->>App: handle (with awaitCompletion()) + App->>AF: handle.awaitCompletion() + loop until done + AF->>D: GET /workspace/auth/device-flow/:id + D-->>AF: {status: 'pending' | 'authorized' | ...} + AF->>AF: setTimeout(intervalMs) + end + AF-->>App: final state +``` + +## 状态与生命周期 + +- `DaemonClient` 无连接;构造时什么都没发生。每次方法新起一次 `fetch`。 +- `DaemonSessionClient` 跨 `events()` 调用保留 `lastSeenEventId`,重连从最后看到的重放。 +- `DaemonAuthFlow` 懒 —— `client.auth` 首次访问才构造。 +- SSE iterator 关闭条件:(a) daemon 结束流;(b) `AbortSignal.abort()`;(c) 消费方 break `for await`;(d) 缓冲溢出 16 MiB 上限被撞。 + +## 依赖 + +- `globalThis.fetch`(Node 18+ 内置,浏览器,undici 等),`DaemonClient` 可注入测试。 +- 原生 `AbortController` / `AbortSignal.any` / `setTimeout`。 +- 不传递依赖 `@qwen-code/qwen-code-core` 或 `@qwen-code/acp-bridge`,SDK 包完全解耦,外部消费方不会被拉进 daemon 内部。 + +## `ui/*` 子包([#4328](https://github.com/QwenLM/qwen-code/pull/4328) + [#4353](https://github.com/QwenLM/qwen-code/pull/4353)) + +SDK 还导出 `packages/sdk-typescript/src/daemon/ui/`,一套面向任何 UI 宿主的「daemon 事件 → transcript blocks」原语: + +- `normalizeDaemonEvent(evt)` 把 wire 上 29 种 typed event 映射成 29 种 UI 友好的 `DaemonUiEventType`。 +- `createDaemonTranscriptState()` + `reduceDaemonTranscriptEvents(state, events)` 把 UI 事件流投到 `DaemonTranscriptBlock[]`。 +- `createDaemonTranscriptStore()` 提供 subscribe / dispatch 包装。 +- `render.ts` / `terminal.ts` 给 HTML 与终端基线渲染;`toolPreview.ts` 给 tool call 摘要。 +- selectors:`selectTranscriptBlocksOrderedByEventId`、`selectPendingPermissionBlocks`、`selectCurrentTool`、`selectApprovalMode`、`selectToolProgress`、`selectSubagentChildBlocks`、`formatMissedRange`、`formatBlockTimestamp` 等。 +- `DAEMON_PLAN_TOOL_CALL_ID` 等公开常量。 +- `conformance.ts` 跨宿主一致性测试套件。 + +第一个真实消费方是 `packages/webui/src/daemon/`(React `DaemonSessionProvider`)。详细架构、词汇表、selector 全表、与老 `DaemonTuiAdapter` 对比见 [`14-cli-tui-adapter.md`](./14-cli-tui-adapter.md)(标题虽然还叫 CLI TUI,但内容已替换为共享 UI Transcript 层)。 + +子包从 `@qwen-code/sdk/daemon` 子路径独立导出,老代码继续 `import { DaemonClient }` 不受影响。 + +## 配置 + +| 旋钮 | 位置 | 效果 | +| ------------------ | ------------------------------- | -------------------------------------------------------------------------------------- | +| `baseUrl` | `DaemonClient` 构造 | daemon URL,尾 slash strip | +| `token` | `DaemonClient` 构造 | 盖 `Authorization: Bearer` | +| `fetch` | `DaemonClient` 构造 | 测试注入点 | +| `fetchTimeoutMs` | `DaemonClient` 构造 | per-call 超时,`0` = 禁用 | +| `clientId` | 方法可选参数 | `X-Qwen-Client-Id` header(见 [`08-session-lifecycle.md`](./08-session-lifecycle.md)) | +| `lastEventId` | `DaemonSessionClient` 构造 | 重放游标种子 | +| `maxQueued` | 每订阅 option | SSE 路由 `?maxQueued=N`;先 pre-flight `caps.features.slow_client_warning` | +| `perCallTimeoutMs` | 每方法(如 `restartMcpServer`) | 覆盖 client 级超时 | + +## 注意 & 已知局限 + +- **`fetchTimeoutMs` 是 per-call 不是连接级**。长 body 读共享定时器。流式响应必须 per-call 覆盖或把超时设 `0`。 +- **SSE 是超时绕过** —— 长 SSE 不被 `fetchTimeoutMs` 杀;用 `AbortSignal` 做调用方控制。 +- **`parseSseStream` 缓冲上限 16 MiB**,单帧大于此 iterator 中断(daemon 不会合法发那么大的帧)。 +- **`narrowDaemonEvent` 对未来事件 type 返 `kind: 'unknown'`**。SDK 消费方必须处理这条分支而不是假设联合穷举 —— 这就是向前兼容契约。 +- **`client_evicted`、`slow_client_warning`、`stream_error` 不在重放环里**。eviction 后重连从 daemon 的环重放,不会再看到 eviction 帧。 +- **`DaemonClient` 不自动重试**。网络失败以 rejection 浮上来;重连 / 重放策略是调用方的责任(`DaemonSessionClient.events()` 让重放容易,但重连仍要调用方做)。 + +## 参考 + +- `packages/sdk-typescript/src/daemon/DaemonClient.ts:209-1506` +- `packages/sdk-typescript/src/daemon/DaemonSessionClient.ts:61-385` +- `packages/sdk-typescript/src/daemon/DaemonAuthFlow.ts:102-340` +- `packages/sdk-typescript/src/daemon/sse.ts:70-295` +- `packages/sdk-typescript/src/daemon/events.ts:1-2101` +- `packages/sdk-typescript/src/daemon/types.ts:1-942` +- 端到端示例:[`../examples/daemon-client-quickstart.md`](../examples/daemon-client-quickstart.md)。 diff --git a/docs/developers/daemon/14-cli-tui-adapter.md b/docs/developers/daemon/14-cli-tui-adapter.md new file mode 100644 index 0000000000..cc7509fc10 --- /dev/null +++ b/docs/developers/daemon/14-cli-tui-adapter.md @@ -0,0 +1,182 @@ +# 共享 UI Transcript 层 + +> **历史变更**:本文原标题「CLI TUI Daemon 适配器」,描述 `packages/cli/src/ui/daemon/DaemonTuiAdapter.ts` 的实验性 CLI ↔ daemon 渲染胶水。该文件已在 [#4328](https://github.com/QwenLM/qwen-code/pull/4328) 中**删除**(包括整个 `packages/cli/src/ui/daemon/` 目录),替换成本文介绍的「共享 UI Transcript 层」—— 一套住在 SDK 里、任何 UI 宿主(Web、TUI、IDE、IM 渠道)都可消费的 daemon 事件归一与转录原语。CLI TUI、channel、VSCode IDE 的迁移会在后续 PR 落地。 + +## 概览 + +`packages/sdk-typescript/src/daemon/ui/` 是 SDK 新增的 `ui/*` 子包,把「daemon SSE 事件 → UI 可渲染 transcript blocks」这条变换链做成可复用原语: + +- **归一化层** (`normalizer.ts`):把 daemon wire 上 29 种 typed event(详见 [`09-event-schema.md`](./09-event-schema.md))映射成 UI 友好的 `DaemonUiEventType`(29 种语义事件,命名风格 `assistant.text.delta` / `tool.update` / `session.metadata.changed`)。 +- **状态机** (`transcript.ts`, `store.ts`):纯函数 reducer + 可订阅 store,把 UI 事件流投到一个有序的 `DaemonTranscriptBlock[]`。 +- **渲染器** (`render.ts`, `terminal.ts`, `toolPreview.ts`):transcript blocks → HTML / 终端字符 / tool preview 字符串。宿主可挑用。 +- **conformance** (`conformance.ts`):跨宿主一致性测试套件,channel / TUI / IDE 迁移到这套时用来确保渲染等价。 + +第一个真实消费方是 **`packages/webui/src/daemon/`**([#4328](https://github.com/QwenLM/qwen-code/pull/4328))—— React `DaemonSessionProvider` + transcriptAdapter,把 webui 从「只渲染 host postMessage」升级成可以直接接 daemon HTTP+SSE 的前端。CLI TUI、channel base、VSCode IDE 后续会复用这套([`../daemon-ui/MIGRATION.md`](../daemon-ui/MIGRATION.md) 列出了 v2 增量适配指南)。 + +## 职责 + +- 把 29 种 daemon wire event 归一成稳定 UI 词汇(`DaemonUiEventType`),让 renderer 不再去读 `rawEvent.data`。 +- 维护 daemon-monotonic SSE 游标(`eventId`)作为**主排序键**,多端 transcript 同序。 +- 用纯 reducer 投到 transcript block 列表(带 selectors 拿 pending permission / current tool / approval mode / tool progress 等)。 +- 提供 HTML 与终端两种基线渲染(宿主可自定义)。 +- 暴露 `DAEMON_PLAN_TOOL_CALL_ID` 等公开常量供宿主拼计划面板。 +- 与 wire 层保持加法语义:未知 type → 归一为 `debug` 事件,永不丢。 + +## 架构 + +### 包结构 + +| 文件 | 暴露 | 用途 | +|---|---|---| +| `packages/sdk-typescript/src/daemon/ui/index.ts` | 子包 barrel | 唯一公开入口 | +| `ui/types.ts` | `DaemonUiEventType`、`DaemonUiEvent*`(按 type 一类一 interface)、`DaemonTranscriptBlock`、`DaemonTranscriptState`、`DaemonUiToolProvenance`、`DAEMON_PLAN_TOOL_CALL_ID` | 全部类型 | +| `ui/normalizer.ts` | `normalizeDaemonEvent(evt) → DaemonUiEvent`、`getSessionUpdatePayload(evt)` | wire → UI 词汇映射 | +| `ui/transcript.ts` | `createDaemonTranscriptState()`、`appendLocalUserTranscriptMessage()`、`reduceDaemonTranscriptEvents()`、`rebuildDaemonTranscriptBlockIndex()`、selectors(见下) | 状态机 + 选择器 | +| `ui/store.ts` | `createDaemonTranscriptStore(initial?)` | 可订阅 store 封装 reducer | +| `ui/toolPreview.ts` | `createDaemonToolPreview(toolEvent)` | tool call summary 文案 | +| `ui/render.ts` | `DaemonHtmlRenderOptions`、`DaemonRenderOptions` 加渲染函数 | HTML / 通用渲染 | +| `ui/terminal.ts` | terminal 专用渲染 | 给 TUI 准备 | +| `ui/conformance.ts` | 跨宿主一致性测试套件 | 迁移老 adapter 时用 | +| `ui/utils.ts` | `DaemonUiContentPart` 等辅助 | 内部公用 | + +### `DaemonUiEventType` 词汇(29 种) + +来自 `ui/types.ts:17-50`。按域分组: + +**Chat-stream(Stage 1)** +- `user.text.delta`、`assistant.text.delta`、`assistant.done`、`thought.text.delta` +- `tool.update`、`shell.output` +- `permission.request`、`permission.resolved` +- `model.changed`、`status`、`error`、`debug` + +**Session-meta** +- `session.metadata.changed`、`session.approval_mode.changed` +- `session.available_commands`、`session.state_resync_required` + +**Workspace(Wave 3-4)** +- `workspace.memory.changed`、`workspace.agent.changed` +- `workspace.tool.toggled`、`workspace.initialized` +- `workspace.mcp.budget_warning`、`workspace.mcp.child_refused` +- `workspace.mcp.server_restarted`、`workspace.mcp.server_restart_refused` + +**Auth flow(Wave 4 OAuth)** +- `auth.device_flow.started`、`auth.device_flow.throttled`、`auth.device_flow.authorized` +- `auth.device_flow.failed`、`auth.device_flow.cancelled` + +`normalizeDaemonEvent` 把 daemon wire 上的 29 种 typed event(见 [`09-event-schema.md`](./09-event-schema.md))映射进来;未知 type 归一为 `debug`,保留 `rawEvent` 给宿主诊断。 + +### Reducer / selectors + +```ts +// 创建初态 +const state = createDaemonTranscriptState(); + +// 应用 SSE 事件序列 +const next = reduceDaemonTranscriptEvents(state, daemonUiEvents); + +// selectors +selectTranscriptBlocks(state); // 全部 blocks +selectTranscriptBlocksOrderedByEventId(state); // 按 eventId 排序(推荐主键) +selectPendingPermissionBlocks(state); +selectCurrentTool(state); +selectApprovalMode(state); +selectToolProgress(state, toolCallId); +selectSubagentChildBlocks(state, parentBlockId); +isSubagentChildBlock(block); +formatBlockTimestamp(block.clientReceivedAt); +formatMissedRange(state); // state_resync_required 后的 "you missed X" 文案 +``` + +### Store + +`createDaemonTranscriptStore()` 提供订阅 / 派发: + +```ts +const store = createDaemonTranscriptStore(); +store.subscribe(() => render(store.getState())); +store.dispatch(uiEvents); // 内部走 reducer +``` + +webui 的 `DaemonSessionProvider` 就是基于它实现 React Context(详见下面「消费方」一节)。 + +## 流程 + +### 单条 SSE 事件的端到端 + +```mermaid +flowchart LR + A["daemon SSE wire frame
type=session_update / permission_request / ..."] + A --> B["DaemonClient.subscribeEvents
parseSseStream"] + B --> C["narrowDaemonEvent
(09-event-schema.md)"] + C --> D["normalizeDaemonEvent
ui/normalizer.ts"] + D --> E["DaemonUiEvent
(29 UI-friendly types)"] + E --> F["reduceDaemonTranscriptEvents
ui/transcript.ts"] + F --> G["DaemonTranscriptState +
DaemonTranscriptBlock[]"] + G --> H["renderer
(render.ts HTML / terminal.ts / 宿主自渲)"] + G --> I["selectors
selectCurrentTool / selectApprovalMode / ..."] +``` + +宿主可以选在 `(E)` 落地(自己写 reducer),也可以接 `(G)` 用现成 selectors。webui 走完整 `(B)→(H)`,TUI 迁移后可能在 `(G)` 接自己的 Ink renderer。 + +### 与 `state_resync_required` 的配合 + +`session.state_resync_required` 在 reducer 里被映射成 transcript 的 "missed range" 标记,UI 用 `formatMissedRange(state)` 拿到 "missed events X–Y" 文案。reducer 之后**继续 apply 后续事件**,但会标 block 为 `resyncRecovery: true`,渲染层可加视觉提示。具体语义见 [`10-event-bus.md`](./10-event-bus.md) 的「环驱逐 → state_resync_required」一节。 + +## 消费方 + +### `packages/webui/src/daemon/`([#4328](https://github.com/QwenLM/qwen-code/pull/4328) 一起落地) + +| 文件 | 暴露 | +|---|---| +| `DaemonSessionProvider.tsx` | React `` Provider;`useDaemonSession()`、`useDaemonTranscriptStore()`、`useDaemonTranscriptState()`、`useDaemonTranscriptBlocks()`、`useDaemonPendingPermissions()`、`useDaemonActions()`、`useDaemonConnection()` hooks;`DaemonConnectionStatus` / `DaemonConnectionState` / `DaemonSessionContextValue` 类型 | +| `transcriptAdapter.ts` | 把 SDK 的 `DaemonTranscriptBlock` 适配成 webui 的 `UnifiedMessage`,包括 markdown 流式 chunk 合并、tool call 摘要等 | +| `index.ts` | 子包 barrel | + +webui 现在能直接连 daemon HTTP+SSE 跑 transcript,不再仅依赖宿主 postMessage 传 ACP 消息(老 `ACPAdapter` 路径仍保留)。 + +### 后续待迁移 + +[`../daemon-ui/MIGRATION.md`](../daemon-ui/MIGRATION.md) 给「web chat 和 web terminal 适配器」写了 v2 增量指南。MIGRATION.md 明文说 **CLI TUI、channel base、VSCode IDE 这三条默认产品路径本 PR 没迁**,会在各自后续 PR 落地(共用 conformance 套件确保渲染等价)。 + +## 与老 `DaemonTuiAdapter.ts` 的对比 + +| 维度 | 老 DaemonTuiAdapter(已删) | 新共享 transcript 层 | +|---|---|---| +| 所在包 | `packages/cli/src/ui/daemon/` | `packages/sdk-typescript/src/daemon/ui/` | +| 公开 surface | `DaemonTuiAdapter`、`DaemonTuiUpdate`、`DaemonTuiSessionClient` 接口 | `DaemonUiEventType`、`reduceDaemonTranscriptEvents` + 一组 selectors | +| 适用范围 | 仅 CLI Ink TUI | Web / TUI / IDE / IM 任一 UI | +| 状态形态 | TUI 内部 update union | 纯 transcript block 列表 + state 字段 | +| 排序 | 用 `createdAt` | 用 `eventId`(daemon-monotonic,多端同序) | +| 未知 type | 在 `reduceDaemonEventToTuiUpdates` 里被丢 | 归一为 `debug` 事件保留 | +| 测试 | 单包内单测 | 全局 conformance 套件确保跨宿主等价 | + +## 依赖 + +- 上游 wire 类型:`packages/sdk-typescript/src/daemon/events.ts`(详见 [`09-event-schema.md`](./09-event-schema.md))。 +- 下游真实消费方:`packages/webui/src/daemon/`(在用);`packages/cli/src/ui/` 的 TUI、`packages/channels/base/`、`packages/vscode-ide-companion/src/services/daemonIdeConnection.ts` 后续迁移。 +- 平行参考:`docs/developers/daemon-ui/README.md`(upstream 写的子包总览)、`docs/developers/daemon-ui/MIGRATION.md`(v2 迁移指南)、`docs/developers/daemon-client-adapters/web-ui.md`(webui 适配器草案,替代了原 `tui.md`)。 + +## 配置 + +- 无运行时配置 —— 全部 reducer / selectors 是纯函数。 +- 宿主自选渲染层:HTML(`render.ts`)/ 终端(`terminal.ts`)/ 自实现。 +- 调试用:`render.ts` 的选项支持 `includeRawEvent: true` 把原始 wire frame 一起放进渲染输出。 + +## 注意 & 已知局限 + +- **`DaemonTuiAdapter.ts` 整目录已删** —— 老 `14-cli-tui-adapter.md` 内容里的所有 file:line 引用全部失效。`reduceDaemonEventToTuiUpdates` / `DaemonTuiUpdate` 等类型不再存在;外部代码引用需要改成 `reduceDaemonTranscriptEvents` + `DaemonTranscriptBlock`。 +- **CLI TUI / channel base / VSCode IDE 还没迁过来** —— 它们当前各自仍维护渲染胶水。`docs/developers/daemon-client-adapters/` 下还剩 `ide.md` 和 `channel-web.md` 草案;老 `tui.md` 已被 #4328 删,新 `web-ui.md` 是替代品。 +- **`eventId` 是主排序键** —— `createdAt` 仍保留为 `@deprecated` 别名(`clientReceivedAt`),新代码必须用 `selectTranscriptBlocksOrderedByEventId(state)`。MIGRATION.md 详细给出从 `createdAt` 排序切到 `eventId` 排序的代码差异。 +- **未知 wire type 归一为 `debug`** —— 不再像老 adapter 那样直接丢,保留 `rawEvent`,但 renderer 默认不渲染 `debug`,宿主需要主动 opt-in 才看得到。 +- **包大小**:`ui/*` 子包以 ESM 子路径独立导出(`@qwen-code/sdk/daemon`),不引入额外 React / DOM 依赖;webui 端用 `DaemonSessionProvider` 时才把 React glue 拉进来。 + +## 参考 + +- `packages/sdk-typescript/src/daemon/ui/types.ts:17-50`(`DaemonUiEventType` 词汇) +- `packages/sdk-typescript/src/daemon/ui/transcript.ts`(reducer + selectors,完整列表见上) +- `packages/sdk-typescript/src/daemon/ui/normalizer.ts`(wire → UI 映射) +- `packages/sdk-typescript/src/daemon/ui/store.ts`、`render.ts`、`terminal.ts`、`toolPreview.ts`、`conformance.ts` +- `packages/sdk-typescript/src/daemon/index.ts` 中 `ui/*` 的 re-export 段(line 47–148) +- `packages/webui/src/daemon/DaemonSessionProvider.tsx`、`transcriptAdapter.ts` +- Upstream 文档:[`../daemon-ui/README.md`](../daemon-ui/README.md)、[`../daemon-ui/MIGRATION.md`](../daemon-ui/MIGRATION.md)、[`../daemon-client-adapters/web-ui.md`](../daemon-client-adapters/web-ui.md) +- 上下文 PR:[#4328](https://github.com/QwenLM/qwen-code/pull/4328)(v1 transcript layer + webui Provider)、[#4353](https://github.com/QwenLM/qwen-code/pull/4353)(v2 unified completeness follow-up:扩到 29 类型 + `render.ts` + conformance) diff --git a/docs/developers/daemon/15-channel-adapters.md b/docs/developers/daemon/15-channel-adapters.md new file mode 100644 index 0000000000..c320fdf2ab --- /dev/null +++ b/docs/developers/daemon/15-channel-adapters.md @@ -0,0 +1,187 @@ +# Channel 适配器 +## 概览 + +`packages/channels/` 是 **IM 渠道适配器**,把聊天平台的入站消息翻成 daemon prompt,把 daemon 的出站事件翻回平台消息。现已落地三个具体渠道:钉钉、微信(Weixin)、Telegram。它们共享 `packages/channels/base/` 基座加 `DaemonChannelBridge` —— 后者做 session 多路复用 + SSE 消费。 + +每个渠道按可配的 `SessionScope`(`per-sender` / `per-group` 等)把一段会话(或一群)映射到一个 daemon session。适配器委托给 `DaemonChannelBridge`,bridge 委托给 SDK 的 `DaemonSessionClient`(见 [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md))。 + +## 职责 + +- 从渠道原生传输(钉钉 WebSocket 流、微信 HTTP 长轮询、Telegram Bot 长轮询)收入站消息。 +- 通过 `DaemonChannelSessionFactory` 把 `(senderId, groupId?)` 解析成 daemon session。 +- 把用户消息转成 daemon prompt 并把响应流式回写为出站消息,必要时切块。 +- 渠道原生交互式 prompt 渲染权限请求;非交互时按 `ChannelConfig.approvalMode` 自动批准。 +- 应用 sender / group gating(白/黑名单)与内容规范化(markdown / HTML,按渠道)。 + +## 架构 + +### `DaemonChannelBridge`(共享基座,`packages/channels/base/src/DaemonChannelBridge.ts:1-179+`) + +```ts +class DaemonChannelBridge extends EventEmitter { + constructor(opts: { + sessionFactory: DaemonChannelSessionFactory; + config: ChannelConfig; + }); + handleInbound(envelope: Envelope): Promise; + shutdown(): Promise; +} +``` + +持有 `Map`,key 是渠道的 chat id(sender / group)。每条记录包括: + +- `DaemonChannelSessionClient`(去掉渠道无关方法的 `DaemonSessionClient`)。 +- 一条 live SSE 消费 pump。 +- debounce 的 prompt 组装器(适配把用户输入拆成多条入站消息的平台)。 +- 每请求的自动批准策略。 + +发的事件:`permission_request`、`permission_resolved`、`outbound_message`、`stream_error`、`session_died`。渠道适配器把它们接到平台原生 API。 + +### `ChannelBase`(`packages/channels/base/src/ChannelBase.ts`) + +每个适配器继承的抽象基: + +```ts +abstract class ChannelBase { + abstract start(): Promise; + abstract sendOutbound(target, payload): Promise; + handleInbound(envelope: Envelope): Promise; // → bridge.handleInbound + shutdown(): Promise; +} +``` + +承担共性:sender / group gating、块流式发送(块大小、节流)、入站去抖。 + +### 各渠道适配器 + +| 适配器 | 文件 | 传输 | 备注 | +| -------------- | ---------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------- | +| 钉钉 | `packages/channels/dingtalk/src/DingtalkAdapter.ts:79-586` | DingTalk Stream SDK WebSocket | 通过 `sessionWebhook` POST 出站;媒体图片走 DT API 下载,base64 进 envelope | +| 微信(Weixin) | `packages/channels/weixin/src/WeixinAdapter.ts:33-309` | iLink Bot HTTP 长轮询 | 通过专有 `sendText` / `sendImage` 出站;带打字指示 | +| Telegram | `packages/channels/telegram/src/TelegramAdapter.ts:19-308` | Telegram Bot API 长轮询(grammy) | 通过 `sendMessage` 发 HTML 块 | + +每个适配器实现: + +1. 入站传输(订阅 / 轮询消息)。 +2. 构造 envelope(`{ senderId, groupId?, text, media?, raw }`)。 +3. sender / group gating(委托给 `ChannelBase`)。 +4. 出站序列化(markdown → HTML / WeChat 原生 / DingTalk 原生)。 +5. 生命周期(start / shutdown)。 + +### 适配器矩阵 + +| 适配器 | 传输 | 身份 | 权限 UX | 自动批准 | +| ------------ | -------------- | ------------------------------------------ | ------------------------- | ------------------------------------------------- | +| **钉钉** | WebSocket 流 | `senderStaffId`(群里 + `conversationId`) | 通过 DT markdown 内联按钮 | `ChannelConfig.approvalMode = 'auto' \| 'prompt'` | +| **微信** | HTTP 长轮询 | `senderWxid`(群里 + `groupWxid`) | 纯文本提示 + 回复 token | 同上 | +| **Telegram** | Bot API 长轮询 | `from.id`(群里 + `chat.id`) | inline keyboard 按钮 | 同上 | + +## 流程 + +### 入站 prompt + +```mermaid +sequenceDiagram + autonumber + participant CH as Channel platform + participant AD as Channel adapter + participant CB as ChannelBase + participant BR as DaemonChannelBridge + participant SC as DaemonChannelSessionClient + participant D as Daemon + + CH-->>AD: inbound message + AD->>AD: build Envelope { senderId, groupId?, text, media? } + AD->>CB: handleInbound(envelope) + CB->>CB: sender / group gating + CB->>BR: handleInbound(envelope) + BR->>BR: resolve chatId → ActiveSession (create-or-attach via factory) + BR->>SC: session.prompt({...}) + SC->>D: POST /session/:id/prompt +``` + +### SSE 驱动出站 + +```mermaid +sequenceDiagram + autonumber + participant D as Daemon + participant SC as DaemonChannelSessionClient + participant BR as DaemonChannelBridge + participant AD as Channel adapter + participant CH as Channel platform + + D-->>SC: SSE: session_update (agent_message_chunk) + SC-->>BR: DaemonEvent + BR->>BR: reduce → outbound chunks (block streaming) + BR-->>AD: emit 'outbound_message' + AD->>CH: sendText / sendMessage / sendChunk +``` + +### 权限自动批准 + +```mermaid +sequenceDiagram + autonumber + participant D as Daemon + participant SC as DaemonChannelSessionClient + participant BR as DaemonChannelBridge + participant AD as Channel adapter + + D-->>SC: SSE: permission_request + SC-->>BR: DaemonEvent + alt config.approvalMode == 'auto' + BR->>SC: session.respondToPermission({...}) + else 'prompt' + BR-->>AD: emit 'permission_request' (renders chat-native UI) + AD->>BR: user picks option → respondToPermission + end +``` + +## 状态与生命周期 + +- `DaemonChannelBridge` 与渠道适配器同生命周期;里面的 session 按 chat 维度活。 +- 每个 chat session 在 SSE 掉的时候自动重连 —— `DaemonSessionClient.events()` 跟踪 `lastSeenEventId`,重放正确。 +- `shutdown()` 关掉所有活 session 和底层传输(渠道的 WebSocket / 长轮询)。 +- 钉钉 WebSocket 流支持 server-push;微信长轮询空响应需 backoff;Telegram 长轮询自带 `timeout` 参数。 + +## 依赖 + +- `packages/channels/base/` —— `ChannelBase`、`DaemonChannelBridge`、`types.ts`(`ChannelConfig`、`Envelope`、`SessionScope`、`ChannelPlugin`)。 +- `packages/sdk-typescript/src/daemon/` —— `DaemonSessionClient` 等。 +- 各渠道 SDK:`@dingtalk/stream`(钉钉)、专有 iLink Bot HTTP(微信)、`grammy`(Telegram)。 + +## 配置 + +`ChannelConfig`(`packages/channels/base/src/types.ts:1-121`): + +| 旋钮 | 效果 | +| ---------------------------------------- | --------------------------------------------------------- | +| `sessionScope` | `'per-sender'`、`'per-group'`、`'per-thread'`(渠道定义) | +| `approvalMode` | `'auto'`(自动应答) / `'prompt'`(渲染 UI) | +| `allowlist?: string[]` | 允许的 sender id,缺省 = 开放 | +| `denylist?: string[]` | 拒绝的 sender id | +| `chunkSize`、`chunkIntervalMs` | 出站块流参数 | +| `daemon: { baseUrl, token?, clientId? }` | 传给 `DaemonChannelSessionFactory` | + +每渠道还有自己的 key(钉钉:`streamCredentials`;微信:`ilinkUrl`、`botId`;Telegram:`botToken`)。 + +## 注意 & 已知局限 + +- **渠道**不直接** import `@qwen-code/sdk`**。走 `ChannelBase` → `DaemonChannelBridge` → `DaemonChannelSessionClient`(bridge 从 SDK 构造)。这层间接让 bridge 可以换实现(如测试 stub),渠道无感。 +- **权限 UX 各渠道不同**。钉钉用 markdown 按钮;微信纯文本;Telegram 用 inline keyboard。还没共享的「交互式权限组件」抽象。 +- **自动批准是部署侧决策**,不是 daemon 侧。daemon 的 `permission_mediation` 策略仍然生效;自动批准只是渠道不问人而已。不要把 `auto` 与 `enforce` 级工作流叠加。 +- **每渠道限流 / 单消息大小**是适配器的责任。`DaemonChannelBridge` 只切块;微信单消息大小、Telegram flood 限制需要适配器处理。 +- **无钉钉 / 微信 / Telegram 反向调用** —— 渠道是单向(chat → daemon → chat)。IM 原生 push(如 DT 卡片回调)还没接到 bridge。 + +## 参考 + +- `packages/channels/base/src/DaemonChannelBridge.ts:1-179+` +- `packages/channels/base/src/ChannelBase.ts` +- `packages/channels/base/src/types.ts:1-121` +- `packages/channels/dingtalk/src/DingtalkAdapter.ts:79-586` +- `packages/channels/weixin/src/WeixinAdapter.ts:33-309` +- `packages/channels/telegram/src/TelegramAdapter.ts:19-308` +- `packages/channels/plugin-example/`(reference 插件骨架) +- 渠道插件指南:[`../channel-plugins.md`](../channel-plugins.md)。 +- SDK 参考:[`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)。 diff --git a/docs/developers/daemon/16-vscode-ide-adapter.md b/docs/developers/daemon/16-vscode-ide-adapter.md new file mode 100644 index 0000000000..200f38f556 --- /dev/null +++ b/docs/developers/daemon/16-vscode-ide-adapter.md @@ -0,0 +1,197 @@ +# VSCode IDE Daemon 适配器 +## 概览 + +`packages/vscode-ide-companion/src/services/daemonIdeConnection.ts` 是 **VSCode 扩展的 daemon 适配器**。它让 IDE companion 通过 HTTP + SSE 跟在跑的 `qwen serve` daemon 通话,而不是启动一个进程内 `qwen --acp` stdio 子进程(老 `AcpConnectionState` 路径)。它是 VSCode 宿主侧 [`14-cli-tui-adapter.md`](./14-cli-tui-adapter.md) 的同级传输等价物。 + +IDE 的 chat webview 通过本适配器消费 daemon 事件;权限请求以 VSCode 原生 quick-pick 弹窗呈现。 + +## 职责 + +- 从 loopback 校验过的 `baseUrl` 构造 `DaemonClient` + `DaemonSessionClient`。 +- 把 session client 的 SSE 事件按回调派发(`onSessionUpdate`、`onPermissionRequest`、`onAskUserQuestion`、`onEndTurn`、`onDisconnected`)。 +- 构造时强制 **loopback only**(IDE 应当只与同主机 daemon 通话)。 +- 把 daemon 事件桥接到 webview 的 `postMessage`,chat 面板保持同步。 +- 通过 VSCode 原生 quick-pick UI 呈现权限请求。 +- 把 `connect()` 串行化,避免宿主快速 double-call 时 race。 + +## 架构 + +### 公开 surface + +```ts +class DaemonIdeConnection { + constructor(opts: DaemonIdeConnectionOptions); + connect(): Promise; + disconnect(): Promise; + prompt(req): Promise; + cancel(): Promise; + respondToPermission(req): Promise; + setModel(modelServiceId): Promise; + + onSessionUpdate(cb: (update) => void): Disposable; + onPermissionRequest(cb: (req) => void): Disposable; + onAskUserQuestion(cb: (q) => void): Disposable; + onEndTurn(cb: () => void): Disposable; + onDisconnected(cb: (reason) => void): Disposable; +} + +interface DaemonIdeConnectionOptions { + baseUrl: string; // 必须 loopback(127.0.0.1 / localhost / [::1]) + token?: string; + workspaceCwd: string; + modelServiceId?: string; + lastEventId?: number; +} +``` + +### Loopback 校验 + +构造时(`daemonIdeConnection.ts:161-628`): + +```ts +const parsed = new URL(opts.baseUrl); +if (!isLoopbackHost(parsed.hostname)) { + throw new Error('DaemonIdeConnection: baseUrl must be loopback (...)'); +} +``` + +这是 **客户端硬约束**,与 daemon 自己的 `hostAllowlist`(见 [`12-auth-security.md`](./12-auth-security.md))不同。IDE companion 永远不连远程 daemon —— 即便 operator 配了远程。理由:VSCode 的威胁模型假设 workspace 与 daemon 共享同一宿主(文件系统信任等)。 + +### `createSdkDaemonSessionFactory()` + +`daemonIdeConnection.ts:144-159` 的工厂函数:从 `@qwen-code/sdk` 构造 `DaemonClient` 并调 `DaemonSessionClient.createOrAttach()`。connection 类持有工厂而不是直接实例化,方便测试注入 fake。 + +### 事件派发 + +connection 跑一个 SSE 消费者(`for await` over `session.events()`),按 type 路由: + +| daemon event | IDE 回调 | +| ------------------------------------------------------------------ | ---------------------------------------- | +| `session_update`(多数子类型) | `onSessionUpdate` | +| `session_update`(ask-user-question 变体) | `onAskUserQuestion` | +| `session_update`(end-turn 标记) | `onEndTurn` | +| `permission_request` | `onPermissionRequest` | +| `session_died`、`session_closed`、`client_evicted`、`stream_error` | `onDisconnected`(终态) | +| 其他(model、MCP、mutation、auth) | 目前 no-op 或仅记日志,未来 webview 暴露 | + +### Webview 桥接 + +connection 类**只做传输**。真正的 VSCode 集成住在 `packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.ts` 等。Provider 订阅 connection 的回调并翻成 webview 的 `postMessage`。webview 自身用 `packages/webui/` 组件库渲染 —— 见 [`01-architecture.md`](./01-architecture.md) 的适配器矩阵。 + +### Connect 串行化 + +`connect()` 内部用队列,宿主快速 double-call(用户在握手中打开 panel 两次)不会 race。第二次 await 第一次;connection 最终落在一个确定状态。 + +## 流程 + +### 初次连接 + +```mermaid +sequenceDiagram + autonumber + participant H as VSCode host + participant C as DaemonIdeConnection + participant F as createSdkDaemonSessionFactory + participant SDK as DaemonSessionClient + participant D as Daemon + + H->>C: new DaemonIdeConnection({baseUrl, token, workspaceCwd}) + C->>C: validate loopback host + H->>C: connect() + C->>F: factory({baseUrl, token, workspaceCwd, lastEventId}) + F->>SDK: DaemonClient + DaemonSessionClient.createOrAttach + SDK->>D: POST /session + D-->>SDK: DaemonSession + F-->>C: DaemonSessionClient + C->>SDK: session.events() + par event pump + SDK->>D: GET /session/:id/events + loop per frame + D-->>SDK: DaemonEvent + SDK-->>C: DaemonEvent + C->>C: dispatch by type + C->>H: onSessionUpdate / onPermissionRequest / ... + end + end +``` + +### Quick-pick 权限 + +```mermaid +sequenceDiagram + autonumber + participant D as Daemon + participant SDK as DaemonSessionClient + participant C as DaemonIdeConnection + participant P as Webview/QuickPick provider + participant U as User + + D-->>SDK: permission_request event + SDK-->>C: DaemonEvent + C-->>P: onPermissionRequest(req) + P->>U: vscode.window.showQuickPick(options) + U->>P: choose option + P->>C: respondToPermission({optionId}) + C->>SDK: session.respondToPermission(...) + SDK->>D: POST /permission/:requestId + D-->>SDK: 200 (or 409 already_resolved) +``` + +### 断开 / 恢复 + +```mermaid +sequenceDiagram + autonumber + participant D as Daemon + participant SDK as DaemonSessionClient + participant C as DaemonIdeConnection + participant H as Host + + D-->>SDK: session_died (or other terminal) + SDK-->>C: DaemonEvent + C->>C: shut down pump + C-->>H: onDisconnected(reason) + H->>C: connect() (user-driven retry; resume lastEventId) +``` + +## 状态与生命周期 + +- 构造同步;**无网络 IO**,要等 `connect()`。 +- `connect()` 通过内部队列幂等;二次调串行化。 +- `disconnect()` 通过 `AbortController` 中止 SSE iterator 并清回调。 +- `lastEventId` 在 disconnect 时从 SDK `DaemonSessionClient` 抓出来,下次 `connect()` 可再传以重放。 + +## 依赖 + +- `packages/sdk-typescript/src/daemon/` —— `DaemonClient`、`DaemonSessionClient`(真正的传输)。 +- VSCode 扩展 API(`vscode.*`)—— 宿主 API、quick-pick、webview。 +- `packages/webui/src/adapters/ACPAdapter.ts` —— webview 通过 `postMessage` 拿到 ACP 形态消息后渲染。 + +## 配置 + +| 旋钮 | 位置 | 效果 | +| -------------------------------------------- | ------------------- | ------------------------------------------------------- | +| `baseUrl` | 构造 | daemon URL;必须 loopback | +| `token` | 构造 | Bearer token(通过 SDK 盖) | +| `workspaceCwd` | 构造 | `POST /session` 用;必须与 daemon 绑定的 workspace 一致 | +| `modelServiceId` | 构造 / `setModel()` | 初始 model | +| `lastEventId` | 构造 | 恢复游标(一般从宿主状态恢复) | +| VSCode 设置 `qwen.ide.daemonUrl`(或等价键) | 工作区设置 | operator 配的 daemon URL | + +## 注意 & 已知局限 + +- **Loopback only —— 构造时硬拒**。想让 IDE 指向远程 daemon 的 operator 需要 SSH port-forward / 本地代理;适配器永远不连非 loopback URL。 +- **老 `AcpConnectionState` 路径仍是 IDE companion 的主路径**(stdio child)。本适配器是 Mode-B 迁移的同级传输;迁移阻塞项与计划中的 `BridgeFileSystem` 一致工作见 [`../daemon-client-adapters/ide.md`](../daemon-client-adapters/ide.md)。 +- **HTTP 上暂无反向 RPC / 编辑器原生能力 surface**。需要 agent 回调 IDE 的功能(只读 buffer 访问、diff 预览集成)目前只在 stdio 路径有。 +- **Webview ↔ connection 耦合由宿主拥有**,不在本适配器。不要把 webview 专属逻辑塞进 `DaemonIdeConnection`。 +- **`workspaceCwd` 与 daemon 绑定不一致** → `400 workspace_mismatch`,应当作清晰的配置错误暴露,不要重试。 + +## 参考 + +- `packages/vscode-ide-companion/src/services/daemonIdeConnection.ts:161-628` +- `packages/vscode-ide-companion/src/services/daemonIdeConnection.ts:144-159`(`createSdkDaemonSessionFactory`) +- `packages/vscode-ide-companion/src/types/connectionTypes.ts:1-42`(老 `AcpConnectionState`) +- `packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.ts`(webview bridge) +- `packages/webui/src/adapters/ACPAdapter.ts`(webview ACP-message 适配器) +- 草案设计:[`../daemon-client-adapters/ide.md`](../daemon-client-adapters/ide.md) +- SDK 参考:[`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md) diff --git a/docs/developers/daemon/17-configuration.md b/docs/developers/daemon/17-configuration.md new file mode 100644 index 0000000000..0bf8aa2c68 --- /dev/null +++ b/docs/developers/daemon/17-configuration.md @@ -0,0 +1,115 @@ +# 配置参考 +## 概览 + +把所有会影响 `qwen serve` daemon 与适配器的旋钮(env、CLI 参数、`settings.json` 键)汇总到一页。跨切面参考,单 feature 文档链接到此。 + +## CLI 参数(`qwen serve`) + +| 参数 | 类型 | 默认 | 效果 | +| ------------------------- | ---------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--hostname ` | string | `127.0.0.1` | 监听绑定。loopback 值:`127.0.0.1`、`localhost`、`::1`、`[::1]`。非 loopback 要求 boot 时有 bearer token。错配兜底 `host:port` 形(用 `--port`) | +| `--port ` | int | `4170` | 监听端口;`0` = ephemeral | +| `--token ` | string | (env) | Bearer token,覆盖 `QWEN_SERVER_TOKEN`,boot 时 trim | +| `--require-auth` | flag | off | bearer 扩展到 loopback + `/health`,无 token 拒启动 | +| `--workspace ` | 绝对路径 | `process.cwd()` | 绑定 workspace。必须绝对且为目录;boot 时 canonicalize 一次 | +| `--max-sessions ` | int | `20`(`DEFAULT_MAX_SESSIONS`) | 活动 session 上限。`0` / `Infinity` = 不限;`NaN`/负值抛错 | +| `--max-connections ` | int | (server 默认) | HTTP 监听器的 `server.maxConnections` | +| `--event-ring-size ` | int | `8000`(`DEFAULT_RING_SIZE`) | per-session SSE 重放环;软上限 `1_000_000` | +| `--mcp-client-budget ` | 正整数 | (未设) | 设 `WorkspaceMcpBudget.clientBudget`,通过 `childEnvOverrides` 传 ACP child | +| `--mcp-budget-mode ` | `off`/`warn`/`enforce` | (未设) | 设 `WorkspaceMcpBudget.mode`;`enforce` 需 `--mcp-client-budget` | +| (无 flag) | — | — | env `QWEN_SERVE_NO_MCP_POOL=1` 完全禁池 | + +## 环境变量 + +### `runQwenServe` / Express 中间件读 + +| Env | 作用 | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `QWEN_SERVER_TOKEN` | Bearer token,boot 时 trim | +| `QWEN_SERVE_DEBUG` | `1` / `true` / `on` / `yes`(不区分大小写)开启详细 stderr(见 [`19-observability.md`](./19-observability.md)) | +| `QWEN_SERVE_NO_MCP_POOL` | `1` 禁 workspace MCP transport 池(回到 per-session `McpClientManager`;capabilities 不再广播 `mcp_workspace_pool` / `mcp_pool_restart`) | + +### 通过 `BridgeOptions.childEnvOverrides` 转发给 ACP child + +`runQwenServe` per-handle 构造,防止同进程两个 daemon 在 `process.env` 上 race: + +| Env | 作用 | +| ------------------------------ | ----------------------------------------------------- | +| `QWEN_SERVE_MCP_CLIENT_BUDGET` | 正整数字符串;ACP child 的 `readBudgetFromEnv()` 消费 | +| `QWEN_SERVE_MCP_BUDGET_MODE` | `off` / `warn` / `enforce` | + +### SDK / 适配器读 + +| Env | 作用 | +| ----------------------- | ---------------------------------------------------------- | +| `QWEN_DAEMON_URL` | daemon base URL(CLI TUI 适配器、channels、IDE companion) | +| `QWEN_DAEMON_TOKEN` | Bearer token | +| `QWEN_DAEMON_WORKSPACE` | 覆盖 `POST /session` 的 `cwd` | + +## `settings.json` 键 + +daemon boot 时读一次(`runQwenServe.ts:496+`):`loadSettings(boundWorkspace)`。损坏 try/catch 回退默认。 + +| 键 | 类型 | 效果 | +| --------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `policy.permissionStrategy` | `'first-responder' \| 'designated' \| 'consensus' \| 'local-only'` | 设 `BridgeOptions.permissionPolicy`;激活值出现在 `/capabilities` 的 `policy.permission`。**boot 校验**通过 `validatePolicyConfig()`,对照 `SERVE_CAPABILITY_REGISTRY.permission_mediation.modes`;未知字面量抛 `InvalidPolicyConfigError`,boot 显式失败 | +| `policy.consensusQuorum` | 正整数 | `consensus` 策略的 N。**默认**:`votersAtIssue.size` 的 `floor(M/2) + 1`(M=2 一致同意;更大偶数 M 超过半数)。非 `consensus` 策略下设它会被静默忽略,boot 会打 stderr 警告。非正整数抛 `InvalidPolicyConfigError`。详见 [`04-permission-mediation.md`](./04-permission-mediation.md) | +| `context.fileName` | string | 覆盖 `getCurrentGeminiMdFilename()`;走 `BridgeOptions.contextFilename` | +| `tools.disabled` | string[] | 下次 ACP child spawn 时被禁的 tool;通过 `normalizeDisabledToolList()`(`packages/cli/src/config/normalizeDisabledTools.ts`)归一化:非数组 → `[]`;非字符串项跳过;trim 空白;trim 后空串丢弃;去重(保留首次出现顺序)。boot 路径与 `restartMcpServer` settings 刷新都过这函数,`ToolRegistry.has(name)` 精确匹配才一致。**不**做大小写折叠 —— Stage 1 工具名在 registry 全程大小写敏感。`POST /workspace/tools/:name/enable` 与 `tool_toggled` 事件改这里 | +| `tools.approvalMode` | `'default' \| 'auto' \| ...` | session 默认 approval mode;`POST /session/:id/approval-mode`(带 `persist: true`)写这里 | + +## `ServeOptions`(程序化嵌入) + +`packages/cli/src/serve/types.ts:37-155` 的 typed options 对象,`runQwenServe` 和 `createServeApp` 都接受。镜像上面 CLI 参数,外加: + +| 字段 | 效果 | +| --------------- | -------------------------------------------------- | +| `eventRingSize` | 覆盖默认 per-session 环大小 | +| `mcpPoolActive` | 程序化开关(默认从 `QWEN_SERVE_NO_MCP_POOL` 推断) | + +## `BridgeOptions`(程序化 bridge 嵌入) + +`packages/acp-bridge/src/bridgeOptions.ts:88-323`,完整表见 [`03-acp-bridge.md`](./03-acp-bridge.md)。要点: + +| 字段 | 效果 | +| ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `boundWorkspace` | 必填 canonical workspace | +| `sessionScope` | `'single'`(默认)vs `'per-client'` | +| `initializeTimeoutMs`、`maxSessions`、`eventRingSize`、`permissionResponseTimeoutMs`、`maxPendingPermissionsPerSession` | 有界资源 caps | +| `channelFactory` | 可插拔 ACP child 工厂,默认 `defaultSpawnChannelFactory` | +| `fileSystem` | `BridgeFileSystem` adapter(见 [`07-workspace-filesystem.md`](./07-workspace-filesystem.md)) | +| `permissionPolicy`、`permissionConsensusQuorum`、`permissionAudit` | mediator 接线 | +| `statusProvider` | daemon-host preflight cells | +| `childEnvOverrides` | per-handle env 增量 / scrub | +| `contextFilename` | 覆盖 `getCurrentGeminiMdFilename()` | + +## 重要默认 + +| 常量 | 文件 | 值 | 意义 | +| --------------------------------- | -------------------------- | ----------------- | -------------------------------------------------------- | +| `DEFAULT_MAX_SESSIONS` | `bridge.ts` | `20` | 每 daemon 抛 `SessionLimitExceededError` 前的上限 | +| `MAX_EVENT_RING_SIZE` | `bridge.ts` | `1_000_000` | `BridgeOptions.eventRingSize` 软上限(错字防御) | +| `DEFAULT_RING_SIZE` | `eventBus.ts:76` | `8000` | per-session SSE 重放环深度 | +| `DEFAULT_MAX_QUEUED` | `eventBus.ts:63` | `256` | per-subscriber 队列上限 | +| `DEFAULT_MAX_SUBSCRIBERS` | `eventBus.ts:97` | `64` | per-bus 订阅者上限 | +| `WARN_THRESHOLD_RATIO` | `eventBus.ts:85` | `0.75` | `slow_client_warning` 触发 | +| `WARN_RESET_RATIO` | `eventBus.ts:87` | `0.375` | 滞回 re-arm | +| `DEFAULT_INIT_TIMEOUT_MS` | `bridge.ts` | `10_000` | ACP `initialize` 握手超时 | +| `MCP_RESTART_TIMEOUT_MS` | `bridge.ts` | `300_000` | `/workspace/mcp/:server/restart` 的 bridge race deadline | +| `DEFAULT_PERMISSION_TIMEOUT_MS` | `bridge.ts` | `5 * 60_000` | 每权限请求 wallclock | +| `DEFAULT_MAX_PENDING_PER_SESSION` | `bridge.ts` | `64` | 对齐 `DEFAULT_MAX_SUBSCRIBERS` | +| `MAX_RESOLVED_PERMISSION_RECORDS` | `permissionMediator.ts:77` | `512` | 近期已 resolved 权限的 FIFO | +| `KILL_HARD_DEADLINE_MS` | `bridge.ts` | `10_000` | per-channel graceful 关闭窗口 | +| `SHUTDOWN_FORCE_CLOSE_MS` | `runQwenServe.ts` | `5_000` | HTTP server 强关定时器 | +| `MAX_READ_BYTES` | `fs/policy.ts:33` | `256 * 1024` | 读上限 | +| `MAX_WRITE_BYTES` | `fs/policy.ts:42` | `5 * 1024 * 1024` | 写上限 | +| `MAX_DISPLAY_NAME_LENGTH` | `bridge.ts:298` | `256` | session displayName 上限 | + +## 交叉参考 + +- Auth 旋钮:[`12-auth-security.md`](./12-auth-security.md)。 +- 能力和协议版本:[`11-capabilities-versioning.md`](./11-capabilities-versioning.md)。 +- 事件环 / 反压调优:[`10-event-bus.md`](./10-event-bus.md)。 +- MCP 池 / 预算:[`05-mcp-transport-pool.md`](./05-mcp-transport-pool.md) 与 [`06-mcp-budget-guardrails.md`](./06-mcp-budget-guardrails.md)。 +- 权限策略:[`04-permission-mediation.md`](./04-permission-mediation.md)。 +- 用户运维指南:[`../../users/qwen-serve.md`](../../users/qwen-serve.md)。 diff --git a/docs/developers/daemon/18-error-taxonomy.md b/docs/developers/daemon/18-error-taxonomy.md new file mode 100644 index 0000000000..c3fe5274da --- /dev/null +++ b/docs/developers/daemon/18-error-taxonomy.md @@ -0,0 +1,152 @@ +# 错误分类与修复 +## 概览 + +daemon 的失败模式刻意做成封闭联合,SDK 消费方可以穷举 switch、路由 handler 给出一致 HTTP 响应。本文按三层列每个 typed 错误: + +1. **`packages/cli/src/serve/`** —— HTTP 边界(auth、workspace 文件系统、daemon-host preflight)。 +2. **`packages/acp-bridge/`** —— bridge / mediator(daemon ↔ ACP child 缝隙)。 +3. **`packages/sdk-typescript/src/daemon/`** —— SDK 侧包装与结构化错误字段。 + +Wire 错误形状在 [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md);本文加 cause-and-remediation 视角。 + +## 文件系统边界(`packages/cli/src/serve/fs/errors.ts`) + +`FsError` 带 `{ kind, message, status, cause? }`。`FsErrorKind` 联合(13 种,默认 HTTP 状态): + +| Kind | HTTP | 原因 | 修复 | +| ------------------------ | --------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `path_outside_workspace` | 400 | 解析后越出 workspace | 用 `workspaceCwd` 内的路径;查 `/capabilities` | +| `symlink_escape` | 400 | 目标是 symlink | 直接寻址解析后的路径;symlink 设计上被拒 | +| `path_not_found` | 404 | `ENOENT` | 确认存在;Linux 注意大小写敏感 | +| `binary_file` | 422 | 文本路由 sniff 到二进制 | 用 `GET /file/bytes`;文本路由拒二进制 | +| `file_too_large` | 413 | 超 `MAX_READ_BYTES`(256 KiB)或 `MAX_WRITE_BYTES`(5 MiB) | byte-range 读;切分写 | +| `hash_mismatch` | 409 | 乐观并发 `expectedSha256` 不匹配 | 重读文件用新 hash 重试 | +| `file_already_exists` | 409 | `mode: 'create'` 而文件已存在 | 用 `mode: 'overwrite'` 或换路径 | +| `text_not_found` | 422 | `POST /file/edit` search 字符串不在文件 | 复核 search;空白/编码不一致最常见 | +| `ambiguous_text_match` | 422 | 需要唯一匹配但匹到多处 | 在 search 字符串前后加更多上下文使其唯一 | +| `untrusted_workspace` | 403 | 不被信任的 workspace 上写 | 把 workspace 标信任(`Config.isTrustedFolder()`),或用 `runQwenServe` 而不是 `createServeApp` 直嵌 | +| `permission_denied` | 403 | OS 级 `EACCES` / `EPERM` | 调整文件 ACL;**不是**安全告警 | +| `io_error` | 503 | `ENOSPC` / `EIO` / `EBUSY` / `ETXTBSY` / `ENAMETOOLONG` / `EMFILE` / `ENFILE` | 宿主级运维问题(磁盘满、fd 耗尽),叫 ops 而不是安全 | +| `internal_error` | 500 | 非 errno 错误到达边界 | 报 daemon bug | +| `parse_error` | 400 / 422 | 请求体解析(400)或服务级不变式破坏(422) | 校验请求体;查 SDK 版本 | + +`io_error` 与 `permission_denied` 严格区分是刻意的,监控按 errorKind 路由 —— 把 ENOSPC 折进 `permission_denied` 会让 `df -h` 问题误叫安全 oncall。 + +## Bridge 错误(`packages/acp-bridge/src/bridgeErrors.ts`) + +bridge / mediator 抛的 typed class,多数路由 handler 通过 switch 给出 HTTP 状态。 + +| 类 | HTTP | 原因 | 修复 | +| ------------------------------------- | ---- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SessionNotFoundError` | 404 | sessionId 不在 `byId` | 重建或附加;可能被回收 | +| `WorkspaceMismatchError` | 400 | `POST /session` `cwd` ≠ daemon `boundWorkspace` | 省略 `cwd`(走 bound)或路由到绑定该 `cwd` 的 daemon | +| `SessionLimitExceededError` | 503 | `byId.size >= maxSessions` | 关旧 session;调 `--max-sessions` | +| `InvalidClientIdError` | 400 | `X-Qwen-Client-Id` 不在 `[A-Za-z0-9._:-]{1,128}` | 清洗 clientId | +| `InvalidSessionMetadataError` | 400 | `displayName` > 256 或含控制字符 | trim / 清洗 | +| `InvalidSessionScopeError` | 400 | 未知 `sessionScope` | `'single'` 或 `'per-client'` | +| `RestoreInProgressError` | 409 | 并发 `loadSession` / `resumeSession` | 等待重试 | +| `WorkspaceInitConflictError` | 409 | `POST /workspace/init` 文件已存在且无 `force` | 传 `force: true` 或换路径 | +| `WorkspaceInitPathEscapeError` | 400 | init 路径越出 workspace | 用 `workspaceCwd` 内路径 | +| `WorkspaceInitSymlinkError` | 400 | init 路径是 symlink | 直接寻址解析后路径 | +| `WorkspaceInitRaceError` | 409 | init 上 TOCTOU 竞态 | 重试 | +| `McpServerNotFoundError` | 404 | 未知 server 的 restart | 在 `/workspace/mcp` 核对名字 | +| `McpServerRestartFailedError` | 500 | ACP child 内部 restart 失败 | 查 ACP child 日志;可能 MCP server 坏了 | +| `InvalidPermissionOptionError` | 400 | wire 投票通过 `optionId` 注入 `CANCEL_VOTE_SENTINEL` | 改用 `{outcome: 'cancelled'}` 投票而不是 `optionId` | +| `PermissionForbiddenError` | 403 | 策略拒了投票者(`designated_mismatch` / `remote_not_allowed`) | designated → 用 originator clientId;consensus → 预先注册 voter;local-only → 从 loopback 投票(详见 [`04-permission-mediation.md`](./04-permission-mediation.md)) | +| `CancelSentinelCollisionError` | 500 | agent 发布 `'__cancelled__'` 作为合法 option 标签 | agent bug —— 改 option 标签 | +| `PermissionPolicyNotImplementedError` | 500 | 请求的策略未在本 daemon 构建 | 升级 daemon 或改 `policy.permissionStrategy` | +| `BridgeChannelClosedError` | 503 | ACP child channel 在调用中关闭 | 重连 / 重试;查 `session_died` 找原因 | +| `BridgeTimeoutError` | 504 | bridge 级 wallclock 超 | 重试;排查底层慢 | + +## Boot 时配置错误(`packages/cli/src/serve/runQwenServe.ts`) + +| 类 | 何时 | 修复 | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `InvalidPolicyConfigError` | `validatePolicyConfig()` 拒了合并后的 settings:未知的 `policy.permissionStrategy`(按 `SERVE_CAPABILITY_REGISTRY.permission_mediation.modes` 单一事实源校验)**或** `policy.consensusQuorum` 不是正整数。boot 显式失败 | 改 `settings.json` 里的违规字段。该类支持 `instanceof` 测试;`runQwenServe` 的 boot catch 用它区分配置错配与 settings 读 I/O 失败(后者静默回退默认) | + +## Device Flow auth(`packages/cli/src/serve/auth/deviceFlow.ts`) + +| 类 | 何时 | 注意 | +| ---------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `UpstreamDeviceFlowError` | 上游 IdP 在 device-flow 轮询时返了结构化错误 | `oauthError` 字段在插值进 stderr / audit hint 之前过 `sanitizeForStderr` 净化(CVE-2021-42574 / Trojan-Source 防御,见 [`12-auth-security.md`](./12-auth-security.md)) | +| `DeviceFlowPollTimeoutError` | registry 的 race 定时器在 provider 返回前就触发了 | **provider 代码不能抛此类型**。导出该类只是因为测试需要,但 registry 用运行时品牌 `_isRegistryTimeout: boolean`(**不是** `instanceof`)来闸 `pollTimedOut`。provider 自己 import + 抛 `new DeviceFlowPollTimeoutError(ms)` 仍走 generic provider-throw 审计路径(因为 `_isRegistryTimeout` 默认 `false`),品牌只在内部工厂 `makeRegistryPollTimeoutError(ms)`(race 定时器调用点)设 | + +## Daemon-host 错误 kind(`packages/cli/src/serve/status.ts`) + +`DaemonErrorKind` 枚举,给 `GET /workspace/preflight` 单元在 daemon-host check 失败时用: + +| Kind | 含义 | +| ---------------- | ----------------------------------- | +| `missing_binary` | `ripgrep` / `git` / `npm` 不在 PATH | +| `blocked_egress` | 出站网络探测失败 | +| `auth_env_error` | auth 相关 env 错 | +| `init_timeout` | daemon 侧 init 步骤超 wallclock | +| `protocol_error` | ACP / HTTP 协议不匹配 | +| `missing_file` | 需要的本地文件缺失 | +| `parse_error` | 本地文件解析错 | + +通过 preflight cell 的 `errorKind` 暴露,让客户端 UI 渲染结构化修复(而不是裸 stack trace)。 + +## Auth 错误形状 + +| 状态 | Body | 何时 | +| ----- | -------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `401` | `{ error: 'Unauthorized' }` | 缺失 / 错 token / 无 scheme。`missing header` / `wrong scheme` / `wrong token` 一致防探测 | +| `401` | `{ error: '...', code: 'token_required' }` | 无 token loopback daemon 上的 mutation-gate strict 路由。SDK 渲染「请配 --token / --require-auth」 | +| `403` | `{ error: 'Request denied by CORS policy' }` | `denyBrowserOriginCors` 拒带 `Origin` 的请求 | +| `403` | `{ error: 'Invalid Host header' }` | `hostAllowlist` 拒 `Host` 头(防 DNS rebinding) | + +完整 auth 模型见 [`12-auth-security.md`](./12-auth-security.md)。 + +## 权限结果(wire-vs-audit 重载) + +`PermissionResolution` 两种终态: + +- `{kind: 'option', optionId}` — 投票胜。 +- `{kind: 'cancelled', reason: 'timeout' \| 'session_closed' \| 'agent_cancelled'}` — 被取消。wire 形状是单一 `{outcome: 'cancelled'}`;审计日志通过 `decisionReason.type` 区分 timeout / session_closed / voter-cancelled / agent-cancelled。这种重载是为了不破坏 `permission.ts` 冻结契约而刻意保留。 + +## SDK 侧错误包装 + +`DaemonClient` 把 HTTP 错误转成 rejected Promise,rejection value 是解析后的 body。命中 `404` unknown session 的方法 reject `{error, sessionId}`;SDK 当下没把它们包成 typed class(不鼓励调用方 `instanceof Error` + `.message.includes(...)`,改成 switch body 的 `err.code` / `err.kind`)。 + +`parseSseStream` 16 MiB 缓冲溢出时中断 iterator(防御性边界)。 + +## 流程 + +### 把错误浮给用户 + +```mermaid +flowchart LR + A[HTTP 4xx/5xx body] --> B["switch on body.code OR errorKind"] + B --> C["Render remediation per this doc's table"] + B --> D["fallback: render body.error as toast"] +``` + +### 区分 auth 失败 + +```mermaid +flowchart TD + A["401 received"] --> B{"body.code == 'token_required'?"} + B -->|yes| C["mutation-gate strict — guide user to --token / --require-auth"] + B -->|no| D["plain Unauthorized — generic 'check token' UI"] +``` + +## 依赖 + +- 所有错误类从各自包导出;SDK 消费方在同一 Node 进程里可以对 `bridgeErrors.ts` 类型用 `instanceof`。跨 wire 改用 `body.code` / `body.kind` / `body.errorKind` 路由。 + +## 注意 & 已知局限 + +- **`io_error` 与 `permission_denied`** 严格区分是刻意的,不要混。 +- **`PermissionForbiddenError` 的 reason(`designated_mismatch` / `remote_not_allowed`)** 在 `designated` 和 `consensus` 之间重载;审计精确区分,wire 不区分。 +- **`CancelSentinelCollisionError` 指示 agent 侧 bug**,不是安全事件 —— bridge 拒掉请求而不是让哨兵默默匹到真实 option。 +- **SDK 侧 typed error 仍在演进**。调用方应当 route on body 字段,而不是依赖 wire 上的 JS 类身份。 +- **`internal_error` 必须查**。它表示 `FsError` 构造时用了为非 errno 路径预留的 kind(程序员错),响应 body 的 `cause` 字段可能带原 throw。 + +## 参考 + +- `packages/cli/src/serve/fs/errors.ts:1-80+`(`FsErrorKind`、`FsErrorStatus`) +- `packages/acp-bridge/src/bridgeErrors.ts`(所有 typed class) +- `packages/cli/src/serve/status.ts`(`DaemonErrorKind`) +- `packages/cli/src/serve/auth.ts:101-294`(auth body) +- wire 参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。 diff --git a/docs/developers/daemon/19-observability.md b/docs/developers/daemon/19-observability.md new file mode 100644 index 0000000000..3ded7e2506 --- /dev/null +++ b/docs/developers/daemon/19-observability.md @@ -0,0 +1,148 @@ +# 可观测性与调试 +## 概览 + +`qwen serve` **当下**带 debug 日志、结构化 preflight cell、内存权限审计环。**没有**当下提供 OpenTelemetry span、Prometheus 指标、结构化日志格式 —— 这些落在 Stage 1.5+。本文是一份针对当前 surface 的实用指南,外加排查时应当意识到的现状缺口。 + +## 当下有什么 + +| Surface | 位置 | 用途 | +| ------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `QWEN_SERVE_DEBUG` stderr 日志 | `bridge.ts:287-295` 及调用点 | env 设 `1` / `true` / `on` / `yes`(不区分大小写),stderr 出现 `qwen serve debug: ...` 行 | +| `/health` | `server.ts` 路由 | Liveness 探针;`?deep=1` 返回扩展信息 | +| `/capabilities` | `server.ts` 路由 | pre-flight feature(见 [`11-capabilities-versioning.md`](./11-capabilities-versioning.md)) | +| `/workspace/preflight` | 路由 → `DaemonStatusProvider` | 结构化 readiness cell(Node 版本、CLI 入口、ripgrep、git、npm,子进程活着后多出 ACP 级 cell) | +| `/workspace/env` | 路由 → `DaemonStatusProvider` | daemon 进程 env 快照(机密 env 只报存在性、剥去 proxy URL 凭证) | +| `/workspace/mcp` | 路由 → bridge extMethod | 池 / 预算 / 拒绝快照 | +| `/workspace/skills`、`/workspace/providers` | 路由 | ACP 侧实时快照(无 session 时返回空 idle) | +| per-session SSE | `GET /session/:id/events` | 实时事件流 | +| `/demo` 调试控制台 | `GET /demo`(`packages/cli/src/serve/demo.ts`) | 浏览器可访问的单页控制台(聊天 + 事件日志 + workspace 检视 + 权限 UX)。loopback 上 `http://127.0.0.1:4170/demo` 直接开 —— 不写 SDK 就能端到端把 daemon 跑起来的最快方式。loopback-vs-auth 注册规则见 [`02-serve-runtime.md`](./02-serve-runtime.md) | +| `PermissionAuditRing` | `permissionAudit.ts:1-60` | 内存 FIFO(512 条)权限决策 | +| mediator 的 `decisionReason` 审计 | `permissionMediator.ts:80-100+` | 内部结构化「为什么这样裁决」记录 | + +## 当下**没有**什么 + +- **没有 OpenTelemetry span / trace**。`docs/developers/development/telemetry.md` 只提到一个 daemon 相关字段(`mcp_servers`)。 +- **没有 Prometheus / metrics 端点**。没有 `process_cpu_seconds_total`、`http_requests_total`、`event_bus_queue_depth` 等。 +- **stderr 日志非结构化**。行带 `qwen serve debug:` / `qwen serve:` 前缀但是纯字符串,不是 JSON。 +- **没有 per-request `requestId` 关联**。一次 HTTP 请求的多行 debug 输出不易整组。 +- **`PermissionAuditRing` 无外部 audit sink 接线** —— 环存在,但向 SIEM / 外部存储扇出的钩子还没。 + +Stage 1.5+ 会闭合这些缺口(issue [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §08 Roadmap)。 + +## 调试套路 + +### 1. daemon 还活着吗? + +```bash +curl -s http://127.0.0.1:4170/health +# {"status":"ok"} + +curl -s 'http://127.0.0.1:4170/health?deep=1' | jq +# {"status":"ok","workspaceCwd":"/path","sessions":N,...} +``` + +loopback 上 401 → 看 `--require-auth` 是否开(或 `QWEN_SERVE_DEBUG=1` 看启动日志)。 + +### 2. daemon 广播了哪些 feature? + +```bash +curl -s http://127.0.0.1:4170/capabilities | jq +``` + +看:`mcp_workspace_pool`(F2 开?)、`require_auth`(加固?)、`permission_mediation.modes`(支持哪些策略?)、`policy.permission`(激活哪一条?)。 + +### 3. daemon-host readiness 如何? + +```bash +curl -s http://127.0.0.1:4170/workspace/preflight | jq +``` + +`status: 'not_started'` 是 ACP 级;首次 session attach 后才填。`status: 'fail'` 带封闭 `errorKind`(见 [`18-error-taxonomy.md`](./18-error-taxonomy.md)),渲染结构化修复。 + +### 4. 终端里 tail 一个 session 的 SSE + +```bash +curl -N -H 'Accept: text/event-stream' \ + -H 'Authorization: Bearer XYZ' \ + -H 'X-Qwen-Client-Id: debug-tail' \ + 'http://127.0.0.1:4170/session//events?lastEventId=0' +``` + +`-N` 关 curl 输出 buffer。`lastEventId=0` 从头重放。 + +### 5. 这次权限为什么这么 resolve? + +`PermissionAuditRing` 是内存的;今天没 HTTP surface 暴露。开 `QWEN_SERVE_DEBUG=1` 重跑;mediator 每次投票 / 裁决在 stderr 出结构化行,带 `decisionReason.type`。后续 PR 会通过 HTTP 路由暴露 ring。 + +### 6. 慢消费者在哪? + +`slow_client_warning` 每个 overflow episode 在队列 75% 满时发一次。订阅 session SSE 看合成帧;payload 带 `queueSize`、`maxQueued`、`lastEventId`。重复警告 = 一个粘住的慢消费者;查 SDK 消费方的 `for await` 循环。 + +### 7. 为什么某 MCP server 被拒? + +`/workspace/mcp` 快照的 per-cell `disabledReason: 'budget'` + `refusedServerNames` 列表 + `mcp_child_refused_batch` SSE 事件合起来告诉你这一 pass 拒了什么。对照 `/capabilities` 的 `mcp_guardrails.modes`(`enforce` 是否激活?)与 live `--mcp-client-budget`(在 `getReservedSlots()` 可见)。 + +### 8. daemon 关不掉 + +第一信号触发优雅退出(见 [`02-serve-runtime.md`](./02-serve-runtime.md))。卡过 10s 时看: + +- 卡住的 ACP 子进程不响应 graceful close。 +- 长 SSE 把 HTTP `server.close()` 挂过 `SHUTDOWN_FORCE_CLOSE_MS`(5s)。 + +**第二个** SIGTERM/SIGINT 触发 `bridge.killAllSync()` + `process.exit(1)`,刻意用。 + +## 流程 + +### 典型 triage 流 + +```mermaid +flowchart TD + A[User reports issue] --> B{daemon alive?} + B -->|no| BD[check process; check boot logs] + B -->|yes| C{capabilities match expectations?} + C -->|no| CD["check --require-auth, QWEN_SERVE_NO_MCP_POOL, settings.json"] + C -->|yes| D{preflight all green?} + D -->|no| DD["fix the errorKind cell"] + D -->|yes| E{issue is session-specific?} + E -->|yes| ES["tail SSE for that session;
QWEN_SERVE_DEBUG=1 + reproduce"] + E -->|no| EW["check /workspace/mcp,
/workspace/env"] +``` + +## 状态与生命周期 + +- `QWEN_SERVE_DEBUG` 每次检查时读(`isServeDebugLoggingEnabled()`),切换不需重启 —— 但 daemon 已启动后启动日志就没了,除非启动时就配上。 +- `PermissionAuditRing` 有界(512 条,FIFO),老记录静默丢。 +- `DaemonStatusProvider` 每请求重建 cell(无缓存),preflight 不便宜,别没必要狂轮询。 + +## 依赖 + +- `process.stderr.write`(无外部日志框架)。 +- `node:process` 看 env / 信号。 +- 当下无 OTel SDK、无 Prometheus client、无外部 sink。 + +## 配置 + +| 旋钮 | 效果 | +| -------------------------- | ------------------------------------------------------------------- | +| `QWEN_SERVE_DEBUG` | 开 stderr 详细(见 [`17-configuration.md`](./17-configuration.md)) | +| `PermissionAuditRing` size | 硬编码 512,当下不可配 | +| `slow_client_warning` 阈值 | `0.75` / `0.375` 硬编码在 `eventBus.ts` | + +## 注意 & 已知局限 + +- **非结构化日志**。纯文本;程序解析脆弱。别用 `stderr` grep 建 dashboard。 +- **没有 correlation id**。把「permission denied」stderr 行与触发 HTTP 请求关联是肉眼活;结构化日志在 Stage 1.5+。 +- **`/workspace/preflight` 的 ACP 级 cell 需要 session 活着**。idle daemon 上 auth / MCP / skills / providers 都 `status: 'not_started'`,是预期不是失败。 +- **`/workspace/env` 对机密只报存在不报值**;响应不要扔到对不可信受众暴露存在性也敏感的位置。 +- **审计环是进程局部**,daemon 重启历史丢。 +- **没有压测套路**。性能 baseline 在 `test/perf-daemon-baseline` 分支;本文不是合适的地方。 + +## 参考 + +- `packages/cli/src/serve/daemonStatusProvider.ts:41-287` +- `packages/cli/src/serve/permissionAudit.ts:1-60` +- `packages/acp-bridge/src/bridge.ts:287-295`(`isServeDebugLoggingEnabled`、`writeServeDebugLine`) +- `packages/acp-bridge/src/permissionMediator.ts:80-100+`(`PermissionDecisionReason`) +- 配置:[`17-configuration.md`](./17-configuration.md)。 +- 错误分类:[`18-error-taxonomy.md`](./18-error-taxonomy.md)。 +- 用户运维指南:[`../../users/qwen-serve.md`](../../users/qwen-serve.md)。 diff --git a/docs/developers/daemon/20-quickstart-operations.md b/docs/developers/daemon/20-quickstart-operations.md new file mode 100644 index 0000000000..65cf5d42e4 --- /dev/null +++ b/docs/developers/daemon/20-quickstart-operations.md @@ -0,0 +1,323 @@ +# 快速上手与运维手册 + +本篇集中讲「**怎么把 `qwen serve` 跑起来 + 怎么验证它真的能工作 + 内部从 `qwen serve` 到 listening server 的调用链长什么样**」。架构 / 组件 / wire 协议看其他 19 篇专题文档。 + +## 1. 最短路径 + +```bash +qwen serve +``` + +输出: + +``` +qwen serve listening on http://127.0.0.1:4170 (mode=http-bridge, workspace=/your/cwd) +qwen serve: bound to workspace "/your/cwd" +qwen serve: bearer auth disabled (loopback default). Set QWEN_SERVER_TOKEN to enable. +``` + +浏览器开 `http://127.0.0.1:4170/demo` 就能看到调试控制台(聊天 UI + 事件流 + workspace 检视)。loopback dev 默认下 `/demo` 注册在 `bearerAuth` **之前**(`packages/cli/src/serve/server.ts:611-612`),无需 token。 + +## 2. 启动姿势速查 + +```bash +# 1. 本地 dev 默认(loopback 无 token) +qwen serve + +# 2. 指定工作区 + ephemeral 端口 +qwen serve --workspace /path/to/repo --port 0 + +# 3. 加固 loopback dev(loopback 上也强制 bearer) +QWEN_SERVER_TOKEN=$(openssl rand -hex 32) qwen serve --require-auth + +# 4. 暴露给 LAN(非 loopback 必须配 token) +QWEN_SERVER_TOKEN=$(openssl rand -hex 32) \ + qwen serve --hostname 0.0.0.0 --port 4170 + +# 5. 调多 session + 大重放环 +qwen serve --max-sessions 0 --event-ring-size 32000 + +# 6. 多客户端协作 + 严格预算 +QWEN_SERVER_TOKEN=secret \ + qwen serve --require-auth \ + --mcp-client-budget 10 \ + --mcp-budget-mode enforce + +# 7. settings.json 配 consensus 策略后启动 +# settings.json: { "policy": { "permissionStrategy": "consensus", "consensusQuorum": 2 } } +qwen serve + +# 8. 排查问题用 +QWEN_SERVE_DEBUG=1 qwen serve + +# 9. 关闭 F2 池(fallback per-session) +QWEN_SERVE_NO_MCP_POOL=1 qwen serve +``` + +加固 loopback 的姿势(3)下 `/demo` 会移到 `bearerAuth` 之后(`server.ts:625-626`),浏览器开就要带 token 头才能用了 —— 通常配脚本或 curl 而不是浏览器。 + +## 3. 全部启动参数 + +CLI 定义在 **`packages/cli/src/commands/serve.ts:50-147`**: + +| 参数 | 类型 | 默认 | 必填条件 | 作用 | +|---|---|---|---|---| +| `--port ` | number | `4170` | — | TCP 端口;`0` = OS 分配 ephemeral | +| `--hostname ` | string | `127.0.0.1` | 非 loopback 必须配 token | bind 地址。loopback 集合:`127.0.0.1` `localhost` `::1` `[::1]`。`[::1]` 风格自动剥括号;`host:port` 写法直接报错让你改 `--port` | +| `--token ` | string | env / 无 | 非 loopback 必填;`--require-auth` 必填 | bearer token;trim 一次。**会出现在 `/proc//cmdline`,推荐改用 `QWEN_SERVER_TOKEN`**(boot 时 stderr 也会提示) | +| `--max-sessions ` | number | `20` | — | 活动 session 上限,超额 spawn 返回 503;`0` = 不限。`NaN` / 负值 throws | +| `--workspace ` | string | `process.cwd()` | — | 绑定工作区。**必须绝对路径、必须存在、必须是目录**。boot 时 `canonicalizeWorkspace` 一次。`POST /session` 带不一致 `cwd` 时 `400 workspace_mismatch` | +| `--max-connections ` | number | `256` | — | 监听级 `server.maxConnections`。`0` / `Infinity` 不限。NaN/负值 boot 失败(防 fail-OPEN) | +| `--require-auth` | boolean | `false` | 必须配 token | bearer 扩展到 loopback **以及** `/health`。无 token 启动直接拒 | +| `--event-ring-size ` | number | `8000` | — | per-session SSE 重放环深度。软上限 `MAX_EVENT_RING_SIZE = 1_000_000`;越界 boot 抛 | +| `--http-bridge` | boolean | `true` | — | Stage 1 桥模式(一个 `qwen --acp` 子进程多路复用)。Stage 2 进程内模式还没实现,传 `--no-http-bridge` 会回退并打 stderr | +| `--mcp-client-budget ` | number | 无 | `mcp-budget-mode=enforce` 时必填 | 工作区 MCP client 上限(PR 14)。必须正整数 | +| `--mcp-budget-mode ` | `'enforce' \| 'warn' \| 'off'` | budget 设了默认 `warn`,否则 `off` | `enforce` 必须配 `--mcp-client-budget` | `enforce` 拒;`warn` 仅在 75% 报警;`off` 纯观测 | + +## 4. 环境变量 + +| Env | 等效参数 / 作用 | +|---|---| +| `QWEN_SERVER_TOKEN` | 等价 `--token`;`--token` 优先。boot 时 trim 一次(防 `cat token.txt` 留尾换行) | +| `QWEN_SERVE_DEBUG` | `1` / `true` / `on` / `yes`(不区分大小写)开 stderr 详细日志 | +| `QWEN_SERVE_NO_MCP_POOL` | `1` 完全禁工作区 MCP 池(回到 per-session `McpClientManager`,capabilities 不再广播 `mcp_workspace_pool` / `mcp_pool_restart`) | +| `QWEN_SERVE_MCP_CLIENT_BUDGET` | 等价 `--mcp-client-budget`,daemon 通过 `BridgeOptions.childEnvOverrides` 透传给 ACP 子进程 | +| `QWEN_SERVE_MCP_BUDGET_MODE` | 等价 `--mcp-budget-mode`,同样透传 | + +per-handle env override 是刻意的 —— 同进程跑两个 daemon 不会在 `process.env` 上 race(`defaultSpawnChannelFactory` 在 spawn 时刻快照 env)。 + +## 5. `settings.json` 也会被读 + +boot 时一次性 `loadSettings(boundWorkspace)`: + +| 键 | 类型 | 行为 | +|---|---|---| +| `policy.permissionStrategy` | `'first-responder' \| 'designated' \| 'consensus' \| 'local-only'` | 设 `BridgeOptions.permissionPolicy`。**boot 时 `validatePolicyConfig` 校验**,未知值抛 `InvalidPolicyConfigError`(boot 显式失败,而不是回退默认) | +| `policy.consensusQuorum` | 正整数 | consensus 策略的 N。默认 `floor(M/2)+1`。非 `consensus` 策略下设了会被静默忽略 + boot 打 stderr 警告 | +| `context.fileName` | string | 覆盖 `getCurrentGeminiMdFilename()`,影响 `POST /workspace/init` 写哪个文件 | +| `tools.disabled` | string[] | 经 `normalizeDisabledToolList()` 归一化(trim、丢空、去重)后影响下次 ACP child spawn | +| `tools.approvalMode` | string | session 默认 approval mode | + +settings 读 I/O 失败(损坏 JSON 等)回退默认;`InvalidPolicyConfigError` 例外 —— 配错就直接 boot 失败。 + +## 6. boot 拒启动场景(fail-loud) + +`runQwenServe.ts` 故意在这些场景直接抛错而不是 fallback: + +| 场景 | 错误信息开头 | +|---|---| +| 非 loopback 没 token | `Refusing to bind … without a bearer token` | +| `--require-auth` 没 token | `Refusing to start with --require-auth set but no bearer token` | +| `--workspace` 不存在 / 不是目录 / 不绝对 | `Invalid --workspace ...` | +| `--workspace` 没权限 stat | `Invalid --workspace ...: permission denied` | +| `--mcp-client-budget` 非正整数 | `Must be a positive integer` | +| `--mcp-budget-mode=enforce` 无 budget | `requires a positive mcpClientBudget` | +| `--hostname` 写成 `localhost:4170` | `looks like a "host:port" combination. Use --port` | +| `--hostname [::1]:8080` | `Invalid --hostname … brackets indicate an IPv6 literal but the value isn't a clean [addr] form` | +| `--max-connections` NaN / 负值 | `Must be >= 0` | +| `--event-ring-size > 1_000_000` | bridge 构造时抛 | +| `policy.permissionStrategy` 未知值 / `policy.consensusQuorum` 非正整数 | `InvalidPolicyConfigError` | + +## 7. 跑起来之后的 curl 验证清单 + +```bash +# 1. liveness +curl http://127.0.0.1:4170/health +# → {"status":"ok"} + +# 1.1 deep health +curl -s 'http://127.0.0.1:4170/health?deep=1' | jq + +# 2. capabilities(看广播了哪些 feature tag) +curl -s http://127.0.0.1:4170/capabilities | jq + +# 3. preflight 看是否就绪 +curl -s http://127.0.0.1:4170/workspace/preflight | jq + +# 4. env 快照(机密只报存在性) +curl -s http://127.0.0.1:4170/workspace/env | jq + +# 5. MCP 池 / 预算快照 +curl -s http://127.0.0.1:4170/workspace/mcp | jq + +# 6. 创建 session +curl -s -X POST http://127.0.0.1:4170/session \ + -H 'Content-Type: application/json' \ + -H 'X-Qwen-Client-Id: curl-debug' \ + -d '{}' | jq + +# 7. tail SSE(替换 ) +curl -N \ + -H 'Accept: text/event-stream' \ + -H 'X-Qwen-Client-Id: curl-debug' \ + 'http://127.0.0.1:4170/session//events?lastEventId=0' + +# 8. demo 页(浏览器) +open http://127.0.0.1:4170/demo +``` + +带 token 的姿势:所有请求加 `-H "Authorization: Bearer $QWEN_SERVER_TOKEN"`。 + +## 8. demo 页能不能用 + +**能。** 实现在 `packages/cli/src/serve/demo.ts:8-12` —— 自包含 HTML,无外部依赖,由 `getDemoHtml(port)` 返回。 + +| 启动姿势 | `/demo` 注册位置 | 浏览器直接打 | +|---|---|---| +| loopback + 无 `--require-auth` | `server.ts:611-612`,在 `bearerAuth` **之前** | ✓ 不要 token | +| loopback + `--require-auth` | `server.ts:625-626`,在 `bearerAuth` **之后** | ✗ 浏览器很难带 Auth 头,用 curl 或 SDK | +| 非 loopback bind | `server.ts:625-626`,在 `bearerAuth` **之后** | ✗ 同上 | + +CSP:`default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'`;加 `X-Frame-Options: DENY` 防被嵌入 iframe。所以页面只能 fetch `'self'`(同 daemon),不能拉外部脚本 / 样式。 + +## 9. 从 `qwen serve` 到 listening server 的调用链 + +``` +qwen serve + │ + ▼ (process) +packages/cli/index.ts:87 main() + │ + ▼ +gemini.tsx:392 main() — parseArguments() + │ + ▼ (yargs 装配) +config/config.ts:62 import { serveCommand } ... +config/config.ts:1009 .command(serveCommand) +config/config.ts:1020 await yargsInstance.parse() + │ + ▼ (handler 触发) +commands/serve.ts:148 handler(argv) — boot pre-checks +commands/serve.ts:208 const { runQwenServe } = await import('../serve/index.js') # lazy load +commands/serve.ts:210 await runQwenServe({...}) + │ + ▼ +serve/runQwenServe.ts:308 runQwenServe(opts, deps) + │ ├─ 312-323 trim token + │ ├─ 326-339 hostname 错配兜底 + │ ├─ 341-360 auth 预检 + │ ├─ 374-419 workspace 校验 + canonicalize + │ ├─ 443-484 MCP budget 校验 + childEnvOverrides + │ ├─ 496-530 loadSettings + validatePolicyConfig + │ ├─ 542-545 PermissionAuditRing + publisher + │ ├─ 555-561 resolveBridgeFsFactory + │ └─ 563-678 createHttpAcpBridge({...}) + │ + ▼ +serve/runQwenServe.ts:665 const app = createServeApp(opts, () => actualPort, {...}) + │ + ▼ +serve/server.ts:262 createServeApp() — 构造 Express app(**不监听**) + │ ├─ 中间件链(515-617) + │ ├─ 路由挂载(641 / 675 / 706 / 753 / 962 / 1785 ...) + │ └─ return app + │ + ▼ +serve/runQwenServe.ts:735 server = app.listen(port, hostname, cb) + │ ├─ 758 server.maxConnections = cap + │ ├─ 762 actualPort = server.address().port + │ ├─ 764 写 "qwen serve listening on ..." + │ ├─ 805 注册 SIGINT / SIGTERM (onSignal) + │ └─ resolve(handle: RunHandle) + │ + ▼ +commands/serve.ts:229 await blockForever() // 永久阻塞,等信号 +``` + +关键事实: + +- **`createServeApp` 只构造,不监听。** 它返回的是 `express()` 实例加挂好中间件 + 路由,调用方自己 `app.listen()`。`server.test.ts` 的 ~25 个 case 就是这样用,所以工厂特意不持有生命周期。 +- **`() => actualPort` 是惰性闭包。** `actualPort` 在 `app.listen` 回调里才赋值(line 762),`hostAllowlist` 中间件查询时按需读,所以 ephemeral 端口(`--port 0`)也能正确闸 `Host` 头。 +- **`await blockForever()` 不是 bug**:yargs `parse()` 如果 resolve,CLI 顶层会 fall-through 进交互式 TUI 入口(gemini.tsx)。SIGINT / SIGTERM 在 `runQwenServe` 里走 `onSignal` 路径,是唯一退出方式。 + +## 10. HTTP 路由分散在哪些文件 + +主装配在 `server.ts` 的 `createServeApp()`,对四个模块化路由文件做外挂: + +| 路由 | 文件 | 关键行 | +|---|---|---| +| `/health`、`/demo`、`/capabilities`、所有 session 路由、device-flow、permission 投票、SSE、单服务器 MCP restart 等 | `packages/cli/src/serve/server.ts` | `611 / 641 / 962 / 1208 / 1785 / 1707 / 1631 …` | +| `/workspace/memory`(GET/POST) | `packages/cli/src/serve/workspaceMemory.ts` | `86 / 112`;在 `server.ts:706` 挂载 | +| `/workspace/agents` 全套 CRUD | `packages/cli/src/serve/workspaceAgents.ts` | `107 / 155 / 288 / 315 / 464`;在 `server.ts:713` 挂载 | +| `GET /file`、`/file/bytes`、`/list`、`/glob`、`/stat` | `packages/cli/src/serve/routes/workspaceFileRead.ts` | `519-523`;在 `server.ts:753` 挂载 | +| `POST /file/write`、`/file/edit` | `packages/cli/src/serve/routes/workspaceFileWrite.ts` | `286 / 289`;在 `server.ts:756` 挂载 | + +完整路由 + wire 协议看 [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md);架构看 [`01-architecture.md`](./01-architecture.md)。 + +## 11. 优雅退出 vs 强退 + +- **第一次 SIGINT / SIGTERM** → 走 `onSignal`(`runQwenServe.ts:805`) → 两阶段 graceful: + 1. `bridge.shutdown()`:每个 channel 等 `KILL_HARD_DEADLINE_MS`(10s),然后 `channel.kill()`。 + 2. `server.close()`:等飞行中请求收尾,5s `SHUTDOWN_FORCE_CLOSE_MS` 到点 `closeAllConnections()`,再 2s 二次 deadline。 +- **第二次 SIGINT / SIGTERM** 在退出中再来 → `bridge.killAllSync()` 同步 SIGKILL 所有 ACP child + `process.exit(1)`(防孤儿)。 + +`runQwenServe` 返回的 `RunHandle.close()` 是程序化等价物,给嵌入方 / 测试用。 + +## 12. 嵌入式调用(绕过 CLI) + +```ts +import { runQwenServe } from '@qwen-code/qwen-code/serve'; + +const handle = await runQwenServe({ + port: 0, // ephemeral + hostname: '127.0.0.1', + mode: 'http-bridge', + maxSessions: 20, + workspace: '/abs/path/to/repo', +}); +console.log(`Daemon at ${handle.url}`); +// ... 用 handle.bridge 直接调或访问 handle.server +await handle.close(); // 程序化关 +``` + +或者直接拿 Express app(自己 listen): + +```ts +import { createServeApp } from '@qwen-code/qwen-code/serve'; + +const app = createServeApp({ + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + maxSessions: 20, +}, () => 0, { /* deps: bridge, fsFactory, ... */ }); + +const server = app.listen(0, '127.0.0.1', () => { + console.log('listening on', server.address()); +}); +``` + +注意:直接调 `createServeApp` 时默认 `fsFactory.trusted = false`,agent 侧 ACP `writeTextFile` 会拒为 `untrusted_workspace`,且首次会打一次 stderr 警告(`server.ts:328-335`)。要么注入 `deps.fsFactory`(带显式 trust),要么注入 `deps.bridge`,要么接受这个 trust-gate-default 姿势。 + +## 13. 调试套路 + +详见 [`19-observability.md`](./19-observability.md) 的「调试套路」一节。最常用: + +```bash +# 看 daemon 是否还活着 +curl http://127.0.0.1:4170/health + +# 看广播了哪些 capability +curl -s http://127.0.0.1:4170/capabilities | jq + +# 看 daemon-host readiness +curl -s http://127.0.0.1:4170/workspace/preflight | jq + +# tail SSE 看实时事件 +curl -N -H 'Accept: text/event-stream' \ + 'http://127.0.0.1:4170/session//events?lastEventId=0' + +# 详细日志 +QWEN_SERVE_DEBUG=1 qwen serve +``` + +## 参考 + +- CLI 入口:`packages/cli/src/commands/serve.ts:46-232` +- bootstrap:`packages/cli/src/serve/runQwenServe.ts:308-940` +- Express 工厂:`packages/cli/src/serve/server.ts:262-1900` +- 中间件:`packages/cli/src/serve/auth.ts:1-294` +- bridge 工厂:`packages/acp-bridge/src/bridge.ts:350+` +- demo 页 HTML:`packages/cli/src/serve/demo.ts:8+` +- 用户文档:[`../../users/qwen-serve.md`](../../users/qwen-serve.md) +- wire 协议:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md) diff --git a/docs/developers/daemon/_meta.ts b/docs/developers/daemon/_meta.ts new file mode 100644 index 0000000000..2171bc5c83 --- /dev/null +++ b/docs/developers/daemon/_meta.ts @@ -0,0 +1,23 @@ +export default { + '00-index': '索引 / 总览', + '01-architecture': '01 · 系统架构', + '02-serve-runtime': '02 · Serve 运行时', + '03-acp-bridge': '03 · ACP Bridge', + '04-permission-mediation': '04 · 多客户端权限协调', + '05-mcp-transport-pool': '05 · Workspace MCP Transport 池', + '06-mcp-budget-guardrails': '06 · MCP 工作区预算护栏', + '07-workspace-filesystem': '07 · Workspace 文件系统边界', + '08-session-lifecycle': '08 · Session 生命周期与身份', + '09-event-schema': '09 · Typed Event Schema v1', + '10-event-bus': '10 · SSE 事件总线与反压', + '11-capabilities-versioning': '11 · 能力协商与协议版本', + '12-auth-security': '12 · 认证与安全模型', + '13-sdk-daemon-client': '13 · TypeScript SDK Daemon 客户端', + '14-cli-tui-adapter': '14 · 共享 UI Transcript 层', + '15-channel-adapters': '15 · Channel 适配器', + '16-vscode-ide-adapter': '16 · VSCode IDE Daemon 适配器', + '17-configuration': '17 · 配置参考', + '18-error-taxonomy': '18 · 错误分类与修复', + '19-observability': '19 · 可观测性与调试', + '20-quickstart-operations': '20 · 快速上手与运维手册', +};