03. 运行时拓扑与 RPC
Craft Agents 有三种主要运行形态:完整 Electron、本地/远程 headless server、薄客户端 Electron。它们没有各自实现会话逻辑,而是复用相同 server-core,并通过统一 RPC 协议改变部署位置。
3.1 三种拓扑
Section titled “3.1 三种拓扑”模式 A:完整桌面
Section titled “模式 A:完整桌面”flowchart LR R["Renderer"] --> P["Preload API"] P --> LC["Local RPC client"] LC --> LS["Electron main 内的 local server"] LS --> SM["SessionManager"] SM --> A["Agent backend"] SM --> BP["BrowserPaneManager"]UI、后端、文件系统和浏览器都在同一台机器,但 renderer 仍走 RPC 风格接口,不直接拿 SessionManager 对象。这为远程模式保留同一调用形态。
模式 B:Headless + WebUI/CLI
Section titled “模式 B:Headless + WebUI/CLI”flowchart LR CLI["CLI"] -->|"ws/wss"| WS["WsRpcServer"] WEB["WebUI"] -->|"HTTP 静态资源 + WS"| WS WS --> SM["SessionManager"] SM --> FS["远程文件系统"] SM --> A["远程 Agent/工具"]WebUI 静态资源和 WebSocket 可共用一个 HTTP(S) 端口。服务端必须有 token 或 cookie session 验证;对非本机明文 WS 有显式保护。
模式 C:Electron 薄客户端
Section titled “模式 C:Electron 薄客户端”flowchart LR R["Renderer"] --> P["Preload"] P -->|"wss"| RS["Remote Server"] RS --> SM["Remote SessionManager"] SM -. "invoke browser capability" .-> P P --> BPM["Local BrowserPaneManager"]会话、模型、文件工具在远端,窗口与内置浏览器留在本机。这是双向 RPC,不是普通 REST 客户端。
3.2 统一启动骨架
Section titled “3.2 统一启动骨架”bootstrapServer() 接收依赖注入选项,顺序很有意义:
1. 读取并校验 server token2. 建 PlatformServices3. 初始化 config 目录和全局 config4. 获取单实例 lock5. 创建 model refresh service 和 SessionManager6. 创建 WsRpcServer 并 listen7. 把 RPC server 绑定给 SessionManager8. 创建 OAuthFlowStore 与 handler deps9. 注册所有 RPC handlers10. 设置 SessionManager event sink = wsServer.push11. 初始化已有 sessions12. 启动 model refresh为什么先 listen() 再初始化 session?启动期间服务端已经有 transport,但 handlers/event sink 会在 session 初始化前完成,避免初始化产生事件时没有出口。另一方面真正可用性仍需由 init gate/health 表达,不能仅以端口监听判断所有数据已加载。
3.3 启动安全
Section titled “3.3 启动安全”服务端要求 token,长度至少 16,拒绝单字符重复,并对低字符多样性警告。默认生成 24 随机字节,编码成 48 位 hex,即 192-bit 熵。
~/.craft-agent/.server.lock(或 CRAFT_CONFIG_DIR 下)记录 pid + startedAt。判断逻辑不仅检查 PID 是否存活,还比较系统 boot time,防止重启后 PID 被别的进程复用。Docker 中 PID 1 跨容器生命周期复用也有特殊处理。
这不是分布式锁;它保护同一配置目录不被两个 server 同时写。并行开发需要不同 CRAFT_CONFIG_DIR。
传入证书时同一个 WsRpcServer 切到 HTTPS/WSS。根 server 入口还会阻止“非 loopback host + 无 TLS”的默认暴露,除非显式允许不安全模式。
3.4 RPC 信封
Section titled “3.4 RPC 信封”协议由 packages/shared/src/protocol 定义,传输由 server-core/src/transport 实现。逻辑消息包含:
- handshake / handshake_ack;
- request / response / error;
- push event;
- ack / replay sequence;
- ping/pong heartbeat;
- server → client invoke 与 invoke result。
传输层不理解“创建 session”的业务,只按 channel 找 handler。业务 DTO 留在 shared protocol,使 CLI、preload 与 server 编译时共享。
3.5 握手与 client identity
Section titled “3.5 握手与 client identity”WsRpcServer 为握手完成的连接维护:
interface ClientConnection { id: string workspaceId: string | null webContentsId: number | null capabilities: Set<string> missedPongs: number eventBuffer: BufferedEvent[] lastAckedSeq: number lastSentSeq: number}身份字段各有用途:
clientId:连接/重连、反向调用和资源清理;workspaceId:只向同 workspace 客户端推送;webContentsId:Electron 多窗口定位;capabilities:声明本客户端能执行哪些宿主动作。
握手还交换 protocol/app version。版本信息用于兼容性提示,protocol version 用于拒绝不兼容帧。
3.6 Push 路由
Section titled “3.6 Push 路由”push(channel, target, ...args) 支持按目标广播,而不是每个 handler 自己遍历 socket:
- 所有客户端;
- 指定 client;
- 指定 workspace;
- 其他协议定义的 target 组合。
服务端 SessionManager 的 event sink 被设成 wsServer.push.bind(wsServer)。因此核心只声明“向哪个目标发什么 session event”,不依赖 WebSocket 实现。
3.7 断线重放
Section titled “3.7 断线重放”每个 client 有单调 seq 和 event ring buffer。断线后连接状态会在 TTL 内保留;重连携带已确认序号,服务端可重放未 ack 事件。buffer 同时有大小与时间上限,防止无限占内存。
重要语义:这解决的是短暂断线期间的增量事件,不替代 session 持久化。若断线太久或 buffer 丢失,客户端应重新拉权威 session/message snapshot。
sequenceDiagram participant C as Client participant S as WsRpcServer participant SM as SessionManager
C->>S: handshake(lastAckedSeq=41) S-->>C: handshake_ack(clientId) SM->>S: push session_event S-->>C: event(seq=42) Note over C,S: 网络断开,seq 42 未 ack SM->>S: push event(seq=43),进入断线 buffer C->>S: reconnect(clientId, lastAckedSeq=41) S-->>C: replay 42, 43 C->>S: ack(43)3.8 Heartbeat 与清理
Section titled “3.8 Heartbeat 与清理”服务器按固定间隔 ping。连续漏掉阈值 pong 后断开;断开回调做两类清理:
- transport 自己保留有限重连状态;
- 注入的
cleanupClientResources(clientId)释放客户端资源; - SessionManager 收到
onClientDisconnected,丢弃 browser host pin。
最后一点避免 remote session 永远指向一台已经离线的 Electron。
3.9 反向 RPC:capability
Section titled “3.9 反向 RPC:capability”invokeClient(clientId, channel, ...args) 先检查:
- client 是否在线;
- 握手时是否声明此 capability;
- pending invoke 是否在 timeout 内返回。
失败分别给出 CLIENT_DISCONNECTED、CAPABILITY_UNAVAILABLE 等可识别 code。调用结果通过 correlation id 匹配 pending promise。
这使服务端可以请求客户端执行:
- 浏览器 pane 操作;
- 打开本地 URL/路径;
- 某些必须出现在客户端的 OAuth/系统 UI。
capability 不是安全授权本身。服务端仍要决定调用哪个 client、哪些操作允许远程发起;Electron dispatcher 也应验证参数。
3.10 Channel routing:本地与远程的分水岭
Section titled “3.10 Channel routing:本地与远程的分水岭”protocol/routing.ts 维护两类显式表:
Local-only
Section titled “Local-only”窗口管理、native dialog、shell/terminal 集成、主题、更新、browser pane 等。它们依赖当前机器或当前 WebContents。
Remote-eligible
Section titled “Remote-eligible”sessions、tasks、files、LLM connections、sources、OAuth、projects、skills、automations、messaging、workspace 等。这些属于 workspace server 的事实源。
RoutedClient 根据 channel 选择 local 或 remote client。使用白名单而非默认猜测,能让新增 channel 在 code review 时暴露部署语义。
一个典型错误是把 files:read 一律当本地:在薄客户端里,聊天使用的是远程 workspace 文件,读取应在远程;而“在 Finder 中显示”必须本地且可能根本不适用于远程路径。协议需要分别建模,而不是用一个模糊 file API。
3.11 RPC handler 的职责
Section titled “3.11 RPC handler 的职责”registerAllRpcHandlers() 聚合 sessions/tasks/files/sources/oauth/settings 等模块。单个 handler 应做:
- 解出 request context(client/workspace);
- 校验输入与 workspace ownership;
- 调用 SessionManager 或 shared domain service;
- 返回 DTO;
- 让领域层负责持久化与事件。
它不应把 renderer-specific 状态塞进后端,也不应绕过 SessionManager 直接改同一个 session 文件。
3.12 优雅关闭
Section titled “3.12 优雅关闭”stop() 是幂等的,顺序如下:
- push
server:shuttingDown,给客户端 2 秒 grace; - stop model refresh;
- cleanup SessionManager(flush、destroy agents/资源);
- close WS;
- dispose OAuthFlowStore;
- release lock。
先通知再关 socket,让客户端停止自动重连并展示明确状态;先 cleanup session 再断 OAuth/锁,降低半写文件与悬挂子进程。
3.13 WebUI 同端口
Section titled “3.13 WebUI 同端口”headless entry 可把 WebUI HTTP handler 注入 WsRpcServer 底层 HTTP(S) server:
GET /... → 静态资源 / OAuth callback / session authUpgrade: ws → RPC WebSocket这样反向代理只需暴露一个端口,TLS、cookie 和 origin 策略也更集中。WebUI 可用 cookie session 作为 bearer token 的替代握手认证,但二者由同一 server options 显式配置。
3.14 失败场景
Section titled “3.14 失败场景”| 场景 | 保护机制 | 恢复方式 |
|---|---|---|
| 弱 token | 启动前拒绝 | 换随机 token |
| 重复 server | pid + boot-aware lock | 关闭旧实例或换 config dir |
| 客户端短断线 | seq + replay buffer | 原 client id 重连 |
| 客户端长断线 | buffer TTL/size 到期 | 重新拉 session snapshot |
| capability host 离线 | pin 清理 + error code | 重新选择在线 host |
| server shutdown | 先 push grace event | 客户端停止重连/稍后连接 |
| 远程无 TLS | 入口保护 | WSS 或反代 TLS |
| handler 抛错 | typed error envelope | 客户端按 code 呈现/重试 |
3.15 可迁移设计
Section titled “3.15 可迁移设计”如果要给自己的 Agent 产品增加远程模式,最值得照搬的不是 WebSocket 语法,而是这三层分离:
业务接口(typed channels/DTO) ↓路由策略(local-only / remote-eligible / capability) ↓传输可靠性(auth / heartbeat / seq / replay / shutdown)业务代码不知道 socket,传输层不知道 session,部署差异由 routing/capability 表达。
3.16 本章小结
Section titled “3.16 本章小结”同一 SessionManager 能服务多种客户端,是因为系统把后端变成一个可独立运行的应用服务,而不是 Electron main 中不可分割的一坨代码。RPC 还承担了第二层职责:用 capability 把本地 GUI 能力反向借给远程 Agent。
下一章进入领域模型和磁盘布局,解释 RPC 操作的那些 workspace/session/source/task 最终如何成为可搬运的文件。