Tokimo 存储系统重构：从分布式自治到集中式管控
本文记录 Tokimo（Web Desktop OS）blob 存储系统的完整重构过程——从各 app 独立管理存储到主进程统一管控、app 通过 RPC 读写的架构演进。涵盖重构动因、两阶段实施路径及最终架构设计。 背景与问题 Tokimo 采用 pnpm monorepo + Rust sidecar 架构，系统中包含多个独立 app（音乐、视频、照片等），每个 app 均需持久化 blob 数据（专辑封面、艺术家头像、视频海报、缩略图等）。 重构前的存储架构 ┌─────────────────────────────────────────────────────────────────┐ │ Tokimo 主进程 │ │ │ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ │ Music App │ │ Video App │ │ Photo App │ ← 各 sidecar │ │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ │ Storage │ │ Storage │ │ Storage │ ← 各自持有 │ │ │ Provider │ │ Provider │ │ Provider │ StorageProvider │ │ │ (OpenDAL) │ │ (OpenDAL) │ │ (OpenDAL) │ + 独立实例 │ │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │ │ │ │ │ │ └──────────────┼──────────────┘ │ │ ▼ │ │ ┌─────────────────┐ │ │ │ .data/storage/ │ ← 共享物理目录 │ │ │ music/ │ │ │ │ video/ │ │ │ │ tmdb-images/ │ │ │ └─────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ 每个 app 持有独立的 StorageProvider 实例（基于 OpenDAL），均指向同一物理目录 {DATA_LOCAL_PATH}/storage。从功能层面看，该设计能够正常工作——音乐 app 将封面写入 music/ 前缀，视频 app 将海报写入 video/ 前缀，各自维护独立的文件命名空间。 然而，该架构在 sidecar 拆分后暴露了三个结构性缺陷： 问题一：Sidecar 进程间的存储路径不一致 当音乐 app 从主进程内嵌模块拆分为独立 sidecar 进程后，DATA_LOCAL_PATH 环境变量在进程间传递出现偏差。Sidecar 通过 tokimo-app.toml 声明并由主进程 spawn，其工作目录与主进程不同，导致 DATA_LOCAL_PATH 解析结果不一致： 主进程: DATA_LOCAL_PATH = /home/lab/.data/tokimo Sidecar: DATA_LOCAL_PATH = /home/lab/.data/tokimo/apps/music ← 解析错误 结果是专辑封面被写入 sidecar 的 cwd 而非主进程的存储目录，前端请求 GET /storage/music/ab/cd/uuid/cover.jpg 时无法定位文件，所有图片资源返回 404。 问题二：Key 格式泄露内部 app 结构 原有的 key 格式为 {app_id}/{path}/{filename}： music/library-images/music/abc123/cover.jpg video/tmdb-images/poster/550.jpg 该 key 直接暴露在 URL 中（GET /storage/music/library-images/...）。若后续接入 CDN 或启用公开分享，URL 将泄露内部 app 的组织结构和文件命名规则。 问题三：缺乏写隔离，存在跨 app 数据篡改风险 所有 app 共享同一存储目录，且 key 格式未做强制校验。恶意或有缺陷的 app 可以构造其他 app 命名空间下的 key 来删除或覆盖文件，系统缺乏基于 app 维度的访问控制。 Phase 1：Storage Bus Service 识别出上述问题后，核心重构目标明确：将存储访问从 app 自治模式转为主进程集中管控模式。 设计目标 Before: App → 自有 StorageProvider → 本地文件系统 After: App → bus client → bus → 主进程 StorageProvider → 本地文件系统 选择 bus RPC 而非共享文件系统的原因：sidecar 作为独立进程，其 DATA_LOCAL_PATH 可能与主进程不同，且未来可能运行在独立容器中。基于 Unix Domain Socket 的 bus RPC 是进程间唯一可靠的通信通道。 Service 接口定义 主进程通过 bus local service 将存储能力暴露给各 sidecar app，底层依赖开源 crate tokimo-package-storage（后文详述）： ┌──────────────────────────────────────────────────────────────┐ │ storage bus service │ ├──────────────┬──────────┬────────────────────────────────────┤ │ method │ HTTP │ 说明 │ ├──────────────┼──────────┼────────────────────────────────────┤ │ upload │ POST │ 上传文件，返回生成的 key │ │ download │ POST │ 下载文件，返回 base64 编码的 bytes │ │ delete │ POST │ 删除文件 │ │ exists │ POST │ 检查文件是否存在 │ │ head │ POST │ 获取文件元数据（key + size） │ │ list │ POST │ 列出 app 下的文件 │ │ local_path │ POST │ 获取本地绝对路径（仅本地后端） │ └──────────────┴──────────┴────────────────────────────────────┘ 传输协议选择： Bus 协议 payload 为 Vec<u8>，文件数据以 base64 编码传输。该方案适用于 artwork/thumbnail 等小文件（通常 < 5MB）。视频等大文件通过已有的 VFS 流式传输机制处理，不经过 storage service。 认证模型： Bus 基于 Unix Domain Socket，调用方在建立连接时已完成身份验证。CallerCtx 中携带 caller_app_id，用于后续的写隔离鉴权。 Phase 2：Opaque Key 与 App 写隔离 Bus service 上线后，key 格式仍为 {app_id}/{path}/{filename}，前文识别的 URL 泄露和写隔离问题尚未解决。 Opaque Key 生成策略 新 key 格式： {hex[0:2]}/{hex[2:4]}/{uuid}.{ext} fn generate_key(_app_id: &str, filename: &str) -> String { let uuid = Uuid::new_v4(); let hex = uuid.as_simple().to_string(); let ext = filename.rsplit('.').next().unwrap_or("bin"); format!("{}/{}/{uuid}.{ext}", &hex[0..2], &hex[2..4]) } Before: music/library-images/music/abc123/cover.jpg After: a3/b8/550e8400-e29b-41d4-a716-446655440000.jpg 设计决策： 决策点 方案 理由 key 是否包含 app_id 不包含 URL 不泄露任何 app 信息 是否保留原始文件名 不保留 key 完全 opaque，无语义信息 前两级目录划分 UUID hex 前 4 位分两级 避免单目录文件数过多（ext4 目录项超 10K 后性能下降） 文件扩展名 保留原始扩展名 便于 Content-Type 推断 storage_objects 元数据表 Opaque key 使 URL 不再包含业务语义，但 app 仍需追踪自己上传的文件。为此引入 storage_objects 表，作为 key 与 app 归属关系的映射层： CREATE TABLE storage_objects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), key TEXT NOT NULL UNIQUE, -- opaque storage key app_id TEXT NOT NULL, -- 上传方 app 标识 filename TEXT NOT NULL, -- 原始文件名（调试/管理用途） content_type TEXT, -- MIME type size BIGINT DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_storage_objects_app_id ON storage_objects(app_id); 上传流程： App 调用 storage.upload({ appId: "music", filename: "cover.jpg", dataBase64: "..." }) │ ▼ 主进程 storage service: │ ├─ 1. generate_key("music", "cover.jpg") │ → "a3/b8/550e8400-...-0000.jpg" │ ├─ 2. StorageProvider::upload("a3/b8/...jpg", data) │ → 写入物理存储 │ ├─ 3. INSERT INTO storage_objects (key, app_id, filename, ...) │ → 记录归属元数据 │ └─ 4. 返回 { key: "a3/b8/550e8400-...-0000.jpg" } → App 将 key 存入业务表 删除流程（含写隔离鉴权）： App 调用 storage.delete({ key: "a3/b8/..." }) │ ▼ 主进程 storage service: │ ├─ 1. SELECT app_id FROM storage_objects WHERE key = ? │ → owner = "music" │ ├─ 2. caller.caller_app_id == owner ? │ ├─ YES → 继续执行删除 │ └─ NO → 返回 Unauthorized │ ├─ 3. StorageProvider::delete(key) │ → 删除物理文件 │ └─ 4. DELETE FROM storage_objects WHERE key = ? → 清理元数据记录 访问控制模型： 操作 鉴权策略 设计理由 upload bus 连接级认证 仅已注册的 app 可调用 bus service download 公开读 图片/媒体场景下任意 app 均需读取资源 delete 写隔离（app_id 校验） 防止 app 间数据误删或恶意篡改 list 按 app_id 过滤 各 app 仅可见自身文件列表 最终架构 ┌─────────────────────────────────────────────────────────────────────────┐ │ tokimo-package-storage (开源 crate) │ │ ┌───────────────────────────────────────────┐ │ │ │ StorageProvider trait │ │ │ │ OpendalStorageProvider (local filesystem) │ │ │ └───────────────────────────────────────────┘ │ │ ▲ ▲ ▲ │ │ │ dep │ dep │ dep │ ├─────────┼────────────────────┼────────────────────┼──────────────────────┤ │ │ Tokimo 主进程 │ │ │ │ │ │ │ ┌──────┴──────┐ ┌───────────┐ ┌───────────┐ │ │ │ │ Bus Broker │ │ storage │ │ storage │ │ │ │ │ (tokimo-bus)│ │ local svc │ │ objects │ │ │ │ │ │ │ │ │ (DB 表) │ │ │ │ └──────┬──────┘ └─────┬─────┘ └───────────┘ │ │ │ │ │ │ │ │ ┌──────┴───────────────┴──────────────────────┐ │ │ │ │ bus RPC (Unix Domain Socket) │ │ │ │ └──┬──────────────┬──────────────┬────────────┘ │ │ │ │ │ │ │ │ │ ┌──┴──────┐ ┌────┴─────┐ ┌────┴─────┐ │ │ │ │ Music │ │ Video │ │ Photo │ │ │ │ │ App │ │ App │ │ App │ │ │ │ │(sidecar)│ │(sidecar) │ │(sidecar) │ │ │ │ └─────────┘ └──────────┘ └──────────┘ │ │ │ │ │ │ ┌───────────────────────────────────────────┐ │ │ │ │ StorageProvider (主进程持有的唯一实例) │◄───┘ │ │ └─────────────────┬─────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────┐ │ │ │ .data/storage/ │ │ │ │ │ │ │ │ a3/b8/uuid1.jpg │ ← opaque key │ │ │ f1/2c/uuid2.png │ │ │ │ 7d/e9/uuid3.jpg │ │ │ └─────────────────┘ │ │ │ │ ┌──────────────────────────────────────────────┐ │ │ │ PostgreSQL │ │ │ │ │ │ │ │ storage_objects: │ │ │ │ ┌──────┬───────────┬─────────┬────────────┐ │ │ │ │ │ key │ app_id │ filename│ size │ │ │ │ │ ├──────┼───────────┼─────────┼────────────┤ │ │ │ │ │ a3/..│ music │ cover │ 45KB │ │ │ │ │ │ f1/..│ video │ poster │ 120KB │ │ │ │ │ │ 7d/..│ music │ avatar │ 22KB │ │ │ │ │ └──────┴───────────┴─────────┴────────────┘ │ │ │ └──────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ 前端通过 GET /storage/{key} 访问文件，该接口为公开读（无需鉴权）。Key 本身为 opaque 格式，不包含任何可推断的业务信息。 设计权衡 集中管控 vs App 自治 Sidecar 为独立进程，拥有各自的 DATA_LOCAL_PATH 和工作目录。若由各 app 自行管理存储路径，每个 app 均需感知"存储根目录"这一全局配置，sidecar 拆分后该配置的传递和一致性维护成本过高。集中管控模式下，app 仅需知道 bus service 的地址，无需关心底层存储细节。 Bus Payload vs S3 预签名 URL 当前阶段文件体积较小（artwork/thumbnail 通常 < 5MB），bus payload 的 Vec<u8> 完全适用。视频等大文件通过已有的 VFS 流式传输机制处理。若后续需支持大文件上传，可扩展 storage.request_upload_url 方法返回预签名 URL，app 直接 PUT 至 S3，主进程仅负责签名生成。 公开读 + 写隔离 这是图片/媒体场景的典型访问模式。封面、海报、头像等资源需要被任意 app 读取（如全局搜索结果页展示跨类型的媒体封面），但写入（尤其是删除）操作必须严格隔离，避免单一 app 的缺陷影响其他 app 的数据完整性。 存储抽象层：tokimo-package-storage 整个重构的基础设施层是开源 crate tokimo-package-storage。它定义了存储系统的统一抽象，使主进程和各 sidecar app 基于同一 trait 编程，后端实现可独立演进。 StorageProvider Trait #[async_trait] pub trait StorageProvider: Send + Sync { async fn upload(&self, key: &str, body: Bytes, options: Option<UploadOptions>) -> Result<(), String>; async fn upload_opaque(&self, key: &str, body: Bytes, options: Option<UploadOptions>) -> Result<String, String>; async fn download(&self, key: &str) -> Result<Bytes, String>; async fn delete(&self, key: &str) -> Result<(), String>; async fn exists(&self, key: &str) -> Result<bool, String>; async fn head(&self, key: &str) -> Result<Option<StorageObject>, String>; async fn list(&self, prefix: Option<&str>) -> Result<Vec<StorageObject>, String>; fn local_absolute_path(&self, key: &str) -> Option<PathBuf> { None } } 关键设计点： 方法 设计意图 upload 传统上传，caller 提供 key upload_opaque 服务端生成 key 并返回，bus storage 的核心接口 head 元数据查询（不下载内容），返回 None 表示不存在 local_absolute_path 仅本地后端实现，将 virtual key 映射为物理路径 upload_opaque 的默认实现直接委托给 upload 并返回传入的 key。但在 bus storage 场景下，主进程 override 该方法：忽略 caller 提供的 key，由服务端生成 opaque UUID 路径后返回。这一设计使 app 端代码无需区分"本地直写"和"bus RPC 代理"两种模式。 基于 OpenDAL 的可插拔后端 OpendalStorageProvider 是当前的默认实现，基于 Apache OpenDAL（Open Data Access Layer）： StorageProvider trait │ ▼ OpendalStorageProvider │ ▼ OpenDAL Operator │ ┌────┴────┬────────┬────────┬────────┐ ▼ ▼ ▼ ▼ ▼ Local S3/MinIO FTP/SFTP WebDAV ... (Fs) OpenDAL 屏蔽了底层存储后端的差异（API 签名、错误码、路径规则），OpendalStorageProvider 只需处理业务逻辑（Content-Type 推断、目录递归过滤、NotFound 错误归一化）。切换存储后端只需修改 Operator::new() 的 service 配置，上层代码零改动。 为何在 OpenDAL 之上再封装一层？ OpenDAL 面向"通用数据访问"场景，Tokimo 的存储系统有三个 OpenDAL 不覆盖的业务需求： Opaque key 生成 — upload_opaque 需要在上传时由服务端决定最终 key，OpenDAL 的 write_with 只接受 caller 提供的 key App 归属追踪 — storage_objects 表的写入逻辑与存储操作耦合，OpenDAL 不感知"谁上传了什么" 写隔离鉴权 — delete 操作需要先查 storage_objects 验证 app_id，这是业务层逻辑 tokimo-package-storage 在 OpenDAL 之上封装了这些业务语义，同时保持 trait 的通用性——未来替换为 S3 直连或自定义分布式存储时，只需实现 StorageProvider trait。 总结 本次重构将 Tokimo 的存储系统从"各 app 自治"演进为"主进程集中管控"，核心变化： ┌──────────────────────────────────────────────────────────────┐ │ 重构前 → 重构后 │ ├──────────────────────┬───────────────────────────────────────┤ │ 存储实例 │ N 个独立实例 → 1 个主进程实例 │ │ Key 生成 │ App 自行构造 → 服务端 opaque UUID │ │ 访问控制 │ 无 → 基于 storage_objects 的写隔离 │ │ 存储抽象 │ 各 app 重复实现 → tokimo-package-storage│ │ │ (基于 OpenDAL 的统一封装) │ │ 进程模型 │ 内嵌模块 → sidecar 通过 bus RPC 调用 │ └──────────────────────┴───────────────────────────────────────┘ 关键架构决策： 集中管控：解决 sidecar 拆分后的路径一致性问题，消除 app 对存储路径的感知 Opaque key：URL 不泄露 app 结构和文件语义，适配 CDN 和公开分享场景 公开读 + 写隔离：匹配媒体资源的典型访问模式，兼顾可用性和安全性 Trait 抽象：tokimo-package-storage 基于 OpenDAL 封装业务语义，将存储接口与实现解耦，后端可独立演进
把一个 OS 子系统拆成独立进程：video app 的解耦实录
TL;DR 把一个浏览器桌面 OS 里最复杂的子系统从主进程里拆出来跑成独立进程，听起来像"挪个目录"，实际要重新回答四个问题：谁负责接 HTTP，谁负责排任务，DB 怎么分家，前端怎么跨 bundle 借能力。这篇讲我们怎么回答这四个问题，以及拆离过程踩到的三个真问题。 目录 TL;DR 目录 Part 1 · video 从内置域模型迁移为独立 app §1.1 项目定位与职责边界变化 §1.2 video 的职责归属冲突 §1.3 video 是最关键的一次抽离 Part 2 · 设计约束 Part 3 · 四条核心架构边界 §3.1 Bus 通信架构 §3.2 Job 系统架构 §3.3 Player 能力注册架构 §3.4 数据库边界：从隐式漂移到 multiSchema Part 4 · 抽离后的目标架构与可观测收益 Part 5 · 验收方法论 Part 1 · video 从内置域模型迁移为独立 app §1.1 项目定位与职责边界变化 tokimo 立项时更像媒体管理系统：视频库、刮削、转码、字幕、播放状态，是产品的主轴。随后系统逐步加入文件管理、SSH、AI、dashcam、docs、自动化、数据库会话等能力，形态从“一个媒体产品”变成“浏览器里的桌面 OS”。在这个语境里，OS 的职责不再是理解每一种内容业务，而是提供窗口、身份、任务、文件、播放、通知、事件这些可复用能力，让 app 在上面运行。 身份漂移的关键不是功能数量变多，而是“谁为谁服务”的方向反了。媒体系统里，文件系统、任务队列、播放器都是为了 video 服务；webOS 里，video 只是众多 app 之一，反过来消费 OS 提供的通用能力。只要主 server 仍然把 TMDB、剧集、字幕当作内置领域，其他 app 就只能围着 video 的历史结构让路，OS 的抽象也会不断被媒体业务拉偏。 flowchart LR A[媒体管理系统] --> B[文件 / SSH / AI] B --> C[dashcam / docs / automation] C --> D[浏览器桌面 OS] D --> E[30+ apps] A -.早期 core.-> V[video] D -.运行于 OS 之上的 app.-> V 维度 媒体管理系统语境 webOS 语境 video 的角色 核心域模型 第三方 app 能力 OS 应该知道什么 TMDB、字幕、剧集、刮削 streamUrl、窗口、任务、权限 失败影响面 产品核心失败 单 app 降级 发布节奏 跟主 server 一起发 可独立迭代 §1.2 video 的职责归属冲突 video 又早又重：它是项目最早、最复杂的子系统之一，占主仓 Rust 代码显著比例，并留下约 101 处 raw SQL。这样的复杂度在“媒体系统”里合理，因为它就是中心；但在“webOS”里会变成反向依赖：OS 需要硬编码 TMDB 刮削、NFO、HLS、字幕、人物关系、剧集季集等业务概念。更直接的问题是，任何 video 崩溃都可能污染主 server 的心智模型：故障归因无法区分 OS 故障与 app 故障。如果回答不清，抽离边界就还没建立。 尴尬还体现在 API 归属上。/api/apps/video/* 从 URL 看已经像 app，但实现却长期散落在主仓 handler、queue handler、前端 app registry、播放器上下文里。URL 先 app 化，进程却仍 monolith，这会制造一种危险错觉：调用者以为边界已经存在，维护者却仍能在主仓里随手 import video 内部类型。抽离 sidecar 后，URL 语义与运行时边界保持一致。 Before monorepo server ├─ auth / window / vfs / jobs ├─ video: tmdb / hls / subtitle / scrape / raw sql └─ other apps After main server = OS capabilities video sidecar = business domain + own router + own workers 判断题 留在 OS 放入 video sidecar 用户身份、会话、窗口容器 ✅ ❌ TMDB lookup / NFO parse ❌ ✅ HLS 字节流反代能力 ✅ 数据面 ✅ 业务 handler 字幕选择器 UI ❌ ✅ Media Session / controls ✅ ❌ §1.3 video 是最关键的一次抽离 dashcam-archive、docs 等 app 已经证明“sidecar 运行模型可用”。但 video 的价值不在首创，而在复杂度：它横跨 Rust handler、队列、DB schema、前端 bundle、播放器扩展、VFS、ffmpeg 路径和崩溃 UX。它触及的是之前简单 app 抽离尚未验证的边界：父子 job、流式数据面、Player 扩展、跨 schema 访问、大量 raw SQL 和长时间后台任务。 关键决策不是“为什么优先拆 video”，而是“为什么不绕过 video，继续抽别的简单 app”。答案是：只要 video 还留在主进程，OS 的 jobs/player/schema 抽象就会继续被媒体业务拉偏。抽完 video，sidecar 平台才不只是能承载轻量 app，而是能承载最复杂的 OS 子系统级业务。 quadrantChart title sidecar 抽离的复杂度与平台定型价值 x-axis 简单 --> 复杂 y-axis 局部收益 --> 平台契约定型 quadrant-1 已抽离：常规 app quadrant-2 关键抽离：平台基准 quadrant-3 低优先级 quadrant-4 复杂但收益不足 dashcam: [0.42, 0.58] docs: [0.32, 0.52] music: [0.58, 0.68] video: [0.9, 0.95] 方案 优点 缺点 决策 继续抽简单 app 风险低，流程熟 避开 jobs/player/schema 真边界 不选 抽离 video 一次验证最复杂承载能力 迁移面最大 选择 不抽离，保留 feature flag 短期稳定 OS 与媒体业务继续耦合 不选 Part 2 · 设计约束 拆离不是“移动目录”，而是重新划定 OS 与 app 的职责。约束必须先固定，否则后续局部优化会破坏职责边界。这里的约束分两类：硬约束决定系统不变量，不做的事限制迁移范围。前者防止抽离后退化成“另一个进程里的 monolith”，后者防止一次迁移同时引入数据库拆分、协议迁移和 URL 破坏，导致风险无法归因。 因此，抽离策略刻意保持外部形状稳定：URL 不变、DB 实例不变、播放器入口不变；只改变内部运行时归属。这样验收可以集中在进程隔离、协议边界、ownership 和 UX 上，而不是把所有变量混在一起。 flowchart TD C[设计约束] --> A[崩溃隔离] C --> B[零 API 破坏] C --> D[双轨过渡] C --> E[身份字段不污染业务字段] C --> F[崩溃 UX 明确] C --> N1[不拆 DB] C --> N2[不上 gRPC] C --> N3[不改外部 URL] 类型 约束 含义 硬约束 崩溃隔离 sidecar 死亡不能拖垮主 server、桌面 shell、其他 app 硬约束 零 API 破坏 浏览器外部仍访问 /api/apps/video/* 硬约束 双轨过渡 video-builtin feature 可作为回退，默认走 sidecar 硬约束 身份字段不污染业务字段 user_id/app_id 属于调用上下文，不属于业务 payload 硬约束 崩溃 UX 明确 502 表示上游不可达，504 表示上游超时 不做 不拆 DB 先用 Postgres multi-schema，不引入独立数据库 不做 不上 gRPC 避免工具链和 schema 迁移成本 不做 不改外部 URL 前端和外部调用者不感知进程位置 Part 3 · 四条核心架构边界 §3.1 Bus 通信架构 Bus 是这次抽离的进程边界，但它不是所有 /api/apps/video/* 请求的唯一入口。目标架构里有三条路径，必须分开理解：第一，浏览器访问 /api/apps/video/* 时，main server 的 wildcard 路由直接走 data-plane reverse proxy，把请求转发到 video sidecar 自己的 Axum router；这是绝大多数 video REST API、HLS、图片代理、字幕等浏览器路径。第二，main server 的 queue / handler 在需要把任务派给 video 时，走 bus control plane 调用 sidecar 暴露的 dispatch_* 方法。第三，sidecar 需要 OS 能力时，反向调用 bus local services，例如 jobs、vfs、playback_state。 这个拆分避免把视频流、Range 请求、大文件 body 塞进控制帧，也避免把内部 RPC 暴露成浏览器通用入口。控制面适合“命令”：job dispatch、能力查询、状态写入；数据面适合“搬运字节”：HLS 切片、Range 请求、图片代理、字幕文件。把两者混在一个 RPC 层里，结果要么 RPC 变成半个 HTTP server，要么视频流量使 broker 成为性能瓶颈。 BusClient 长连接复用，sidecar 启动时注册 service、版本和 data-plane socket。dispatch_app_call + MethodDecl 主要服务 bus local services 的 typed route 和内部控制面能力；浏览器 REST API 主路径则由 data-plane wildcard 交给 sidecar router 自己处理。错误码仍然是边界语言：连接失败、握手失败、连接关闭映射为 502；上游响应超时映射为 504；sidecar 内部业务错误保持自己的响应。 错误码区分直接决定故障定位语义。502 表示“OS 找不到或连不上 app”，处理方向是拉起、重启、检查注册表；504 表示“app 存在但没有及时响应”，处理方向是超时、负载、死锁、慢查询。若都返回 500，Shell 和用户只能看到“服务器错误”，进程隔离的可观测性就丢了。 flowchart TD Browser[Browser /api/apps/video/*] --> DP[main server data-plane wildcard] DP --> Router[video sidecar Axum router] Router --> VideoDB[(video schema)] Queue[main server queue / handler] --> CP[bus control plane rmp-serde + UDS/TCP] CP --> Dispatch[sidecar dispatch_* methods] Sidecar[video sidecar services] --> Local[bus local services] Local --> Jobs[OS jobs] Local --> Vfs[OS vfs] Local --> Playback[OS playback_state] 协议 协议成本 类型化 跨进程 工具链 tokimo bus 中：自有 broker + DTO 强，Rust/TS 可生成 原生支持 UDS/TCP 项目内可控 gRPC 高：proto、生成、gateway 强 原生支持 引入新栈 HTTP REST 低 中，靠约定 支持 简单但易漂移 直接 DB 低 弱 “跨进程”但无协议 破坏边界 let stream = BusStream::connect(socket) .await .map_err(|e| AppError::BadGateway(format!("app sidecar unreachable: {e}")))?; let upstream_resp = match timeout(Duration::from_secs(30), sender.send_request(upstream_req)).await { Ok(Ok(resp)) => resp, Ok(Err(e)) => return Err(AppError::BadGateway(format!("app sidecar request failed: {e}"))), Err(_) => return Err(AppError::GatewayTimeout("app sidecar response timeout after 30s".into())), }; 除可达性与超时语义外，bus 还必须承载跨进程调用身份。单进程时代可以依赖请求上下文、thread-local 或 handler 参数一路传递 CallerCtx。跨进程后，身份必须显式进入协议。这里有三种选择：让业务 payload 自带身份、放到 bus 协议头、或者做 TLS/证书级鉴权。最终选择协议头，因为它既能防止业务字段伪造，又不会让每个 DTO 都多出身份字段。 协议头还有一个组织收益：所有 local service 都可以用同一种 caller 结构做鉴权，jobs、vfs、task_queue、media_tools 不需要各自发明“当前 app 是谁”的字段。只要 broker 负责盖章，service 负责校验，业务代码就不必在每个请求里重复解析身份。 flowchart LR HTTP[session cookie] --> Bridge[HTTP bridge] Bridge --> Ctx[CallerCtx\nuser_id + caller_app_id] Ctx --> Header[bus protocol header] Header --> Sidecar[sidecar method] Sidecar -.业务 payload 不带身份.-> Payload[domain payload] 方案 实现复杂度 防 spoof 业务侵入 性能 payload 自带 userId/appId 低 弱，易伪造 高 好 bus 协议头 中 强，broker 注入 低 好 TLS / mTLS 高 强 低 中 协议头方案带来一个副作用：appId/userId 成为保留字段。若业务 payload 也有 appId，系统必须决定怎么处理。选择不是“静默覆盖”，而是 fail-fast：一旦业务 payload 里的保留字段与 caller 注入值不一致，立即报错。 fn ensure_reserved_field_matches(value: Option<&JsonValue>, field: &str, expected: &str) -> Result<(), BusError> { let Some(value) = value else { return Ok(()); }; if value.as_str() == Some(expected) { return Ok(()); } Err(BusError::BadRequest(format!( "payload contains reserved field '{field}' that conflicts with caller-injected value '{expected}'" ))) } 反直觉决策 看起来的好处 实际问题 结论 静默覆盖 + warning 兼容旧 payload 业务字段被改写后仍可能显示成功 不选 自动 prefix 业务字段 减少报错 协议层猜业务语义 不选 fail-fast 拒绝 迁移期更吵 在 dev/CI 暴露约定冲突 选择 让违反约定的请求直接失败，比让迁移者改字段名更可靠。字段命名是局部成本，身份污染是平台级成本。 §3.2 Job 系统架构 job 是 OS 能力，不是 video 私有表。原因是：持久化队列、状态机、retry、取消、父子任务、WebSocket 事件，都应该被多个 app 共享。video 定义 library_scan、item_scrape、tmdb_lookup 这样的业务 job 类型，但不能绕过 OS 直接写 public.jobs。因此 sidecar 只能通过 bus 调用 jobs.create / update_status / batch_children / query / cancel。 这个边界也避免了“表共享即接口共享”的误解。public.jobs 的列名、索引、状态枚举、重试策略都可能随 OS 演进；sidecar 真正需要的是“创建一个属于当前用户和当前 app 的任务”“更新它的状态”“查询自己的任务”。bus endpoint 把需求压缩成窄接口，repo 细节继续留在主 server。 Ownership 模型把 jobs 表的"谁拥有"收敛到当前用户和当前 app。sidecar 创建 job 时，bus service 从 caller 派生 owner = (appId, userId)。query、cancel、update_status、batch_children 各自在 bus service / repo 层按 owner 过滤或校验，业务 payload 不需要自带身份字段。这样 jobs 边界保持单一职责：sidecar 定义业务 job 类型，OS 保留队列能力与 ownership 判定。 sequenceDiagram participant UI as video UI participant V as video sidecar participant B as bus jobs service participant J as public.jobs UI->>V: start library_scan V->>B: jobs.create(type=library_scan) B->>J: insert owner = current user + video V->>B: jobs.batch_children(item_scrape[]) B->>J: insert children(parent_job_id) V->>B: jobs.update_status(tmdb_lookup) UI->>B: jobs.query(type/status) B->>J: filter by caller user + app endpoint 关键校验 防止的问题 数据路径 jobs.create 从 caller 派生 owner 伪造 owner bus → repo insert jobs.update_status 当前 job 属于 caller 修改他人 job bus → repo update jobs.query SQL 层 owner 过滤 + 分页 全表扫 / N+1 bus → list_jobs_for_owner jobs.cancel parent job owner 匹配 取消他人任务 bus → cancel + children jobs.batch_children parent owner 匹配，children 继承 owner 借父任务混入子任务 bus → batch insert fn model_owned_by(model: &jobs::Model, user_id: Uuid, app_id: &str) -> bool { model.user_id == Some(user_id) && model.payload.get("appId").and_then(JsonValue::as_str) == Some(app_id) } let result = JobRepo::list_jobs_for_owner( &state.db, user_id, &app_id, req.job_type.as_deref(), req.status.as_deref(), &PageInput { page, page_size }, ).await?; 父子 job 是 video 抽离里最能暴露边界的场景：library_scan 只是根任务，真正的数据变化发生在大量 item_scrape、tmdb_person_scrape、image_upload 子任务中。若 sidecar 直写 public.jobs，性能可能少一跳，但 OS 将失去 ownership、retry、取消和 WS 统一语义。选择 bus 是为了让“任务系统”继续属于 OS。 §3.3 Player 能力注册架构 播放器最容易被误判成“video 私有能力”。实际需要拆成两层：Shell 保留跨 app 能力，video 注册业务扩展。Shell 层负责 streamUrl、controls、窗口容器、Media Session、当前播放项快照；video bundle 只负责 episode menu、library nav、字幕选择器、下一集推荐等业务 UI。 这条边界直接决定后续 app 能不能复用播放器。music 需要队列和歌词，dashcam 需要时间轴和事件标记，docs 也可能需要嵌入式媒体预览。如果 Player 被 video 私有化，其他 app 会复制一套播放器；如果 Player 完全留在 OS，OS 又会硬编码 video 的剧集概念。注册式扩展让 Shell 拥有“播放能力”，app 拥有“播放语义”。 OS Shell layer ├─ PlayerProvider / Media Session / controls ├─ Window content container └─ PlayerExtensionRegistry ├─ global extensions ├─ video namespace: episode picker / next item / taskbar actions ├─ music namespace: queue / lyrics └─ dashcam namespace: timeline / event markers 能力 OS Shell video bundle 说明 控制与 Media Session ✅ ❌ 跨 app 共享 streamUrl 解析入口 ✅ ✅ 提供业务源 Shell 调扩展 剧集菜单 ❌ ✅ video 专属 字幕选择器 ❌ ✅ video 专属 WindowPortal / container ✅ ✅ 使用 容器归 OS，内容归 app PlayerExtensionRegistry 的核心是 register/unregister/list，并用 app_id namespace 防止多个 app 互相污染。这里有一个 SDK contract 硬约束：所有跨 app 播放源必须在 sourceMetadata 中携带 appId，缺失会退化为对所有 app namespace 的全量 extension 广播，必须视为 SDK contract violation 而非"兼容路径"。bundle unmount 时必须调用 unregister，否则扩展函数会在 app 不存在时继续被播放器调用，形成隐性状态泄漏。 registerExtension = (...args: RegisterArgs): (() => void) => { const [appId, ext] = typeof args[0] === "string" ? [args[0], args[1]] : [null, args[0]]; let appExtensions = this.extensionsByAppId.get(appId ?? ""); if (!appExtensions) this.extensionsByAppId.set(appId ?? "", (appExtensions = new Set())); appExtensions.add(ext); return () => { appExtensions.delete(ext); if (appExtensions.size === 0) this.extensionsByAppId.delete(appId ?? ""); }; }; 在 namespace 与 unregister 已能表达扩展生命周期后，问题就不是引入 Module Federation。tokimo 已经有 manifest 注册、bundle 加载、WindowPortal、SDK shim，真正缺的是运行时能力注册与卸载生命周期，不是"如何加载远程 bundle"。Module Federation 解决的是运行时 remote 模块加载与 shared singleton 依赖协商，与 Player Extension 的 mount/register/unregister 生命周期正交；引入它还会增加 host/remote 版本耦合，包括 webpack runtime、shared singletons 和版本协商。 方案 解决的问题 生命周期能力 版本耦合 决策 注册式 API 运行时能力挂载 强：register/unregister 显式 低：只依赖 SDK contract 选择 直接 import 复用 host 内部代码 弱：host 特判 app 高：路径即 API 不选 Module Federation 运行时 remote 加载 + shared singleton 协商 正交：仍需自建注册生命周期 高：runtime + shared singleton 协商 不选 video bundle 不只是页面，它需要 host 能力：Player、WindowPortal、i18n、Bridge、theme、message、typed API client。若直接 import 主仓内部模块，sidecar UI 就会变成“搬出去的源码目录”。选择注册式 API + namespace：host 暴露能力，bundle 在 mount 时注册扩展，在 unmount 时注销。 UI 边界比后端边界更容易被忽略，因为 TypeScript import 的耦合成本容易被低估。但一旦 remote bundle 直接 import host 内部路径，host 的文件结构就成了 public API，重构成本会比 HTTP API 更高。SDK contract 的价值，是把“可以依赖什么”写成显式对象，而不是让 bundler 解析路径来决定边界。 sequenceDiagram participant Host as OS Shell participant Loader as loadBundle participant Video as video bundle participant Reg as PlayerExtensionRegistry Host->>Loader: load tokimo-app-video bundle Loader->>Video: mount({ shell, i18n, bridge }) Video->>Reg: register("video", extension) Host->>Reg: getExtensions(appId from sourceMetadata) Video-->>Reg: unregister on unmount 这里的关键不是“保证 video bundle 可运行”，而是让多个 bundle 能同时扩展 OS。注册式 API 把能力契约固定在 SDK 层，避免一个 app 的 UI 结构反向塑造 host。 §3.4 数据库边界：从隐式漂移到 multiSchema 通信、任务和播放器边界固定后，剩下的核心问题是数据归属。DB 没有被拆成独立实例，而是用 Postgres multi-schema：video 私有表进入 video.*，OS 公共表留在 public.*。sidecar 直接访问 public.jobs、public.vfs 可减少一次调用链路，但会把 schema 变成隐式 API，任何主仓表结构变化都会破坏 sidecar。最终边界是：sidecar 可以直读写自己的 video.*；跨到 public.* 必须走 bus。 不拆 DB 是务实选择：一次性引入独立数据库会让迁移、事务、备份、开发环境和权限模型同时变化。multi-schema 则把“数据归属”先表达出来，同时保留同一个 Postgres 运维面。终态依赖显式边界：video 表在 entity 上标注 #[sea_orm(schema_name = "video")]，raw SQL 使用明确 schema 前缀；跨到 OS 公共能力时只走 bus。过渡期保护栏可以存在，但不能成为设计本身。 flowchart TD subgraph Sidecar[video sidecar] VCode[handlers / services / workers] end subgraph DB[Postgres] VS[(video.*)] PS[(public.jobs / public.vfs / users)] end subgraph OS[main server] Bus[bus local services] end VCode -->|合法：私有业务表| VS VCode -->|非法：直连公共表| PS VCode -->|合法：跨边界| Bus --> PS 表 / 能力 sidecar 可直读写 必须走 bus 原因 video.videos / video.video_items ✅ ❌ 业务私有表 video.subtitles / video.playback_sessions ✅ ❌ video 域模型 public.jobs ❌ ✅ OS 队列与状态机 public.vfs driver config ❌ ✅ 含凭据和可见性策略 public.users ❌ ✅ 身份归 OS Part 4 · 抽离后的目标架构与可观测收益 bus、jobs、player 与 schema 四条边界收敛后，目标形态可以落到进程、仓库和运行时三层结构。 tokimo/ ├─ packages/rust-server/ # OS server: bus, jobs, vfs, shell API ├─ packages/web/ # desktop shell + PlayerProvider ├─ packages/ui/ # shared UI primitives └─ apps/ └─ tokimo-app-video/ # independent sidecar app ├─ src/main.rs # clap + AppCtx + bus registration ├─ src/handlers/ # browse / hls / subtitle / sync ├─ src/bus_clients/ # jobs / vfs / playback_state ├─ src/db/entities/ # video schema entities └─ ui/src/ # bundle UI + player extension flowchart LR U[用户打开 video 库] --> Shell[Desktop Shell] Shell --> Bundle[loadBundle tokimo-app-video] Bundle --> API[fetch /api/apps/video/*] API --> DP[main server data-plane reverse proxy] DP --> Sidecar[video sidecar Axum router] Sidecar --> DB[(video.*)] Sidecar --> Bus[bus local services for public.*] 指标 抽离前 抽离后 结果 主 server 启动 编译/初始化 video 模块 默认 video-builtin OFF 启动路径变轻 编译影响面 video 改动牵动主仓 sidecar 独立 binary/UI 影响面收缩 崩溃影响 容易被误判为 OS 问题 502/504 限定到 app 隔离更清楚 发布节奏 跟主仓一起 submodule + sidecar 可独立演进 sidecar binary 是 clap CLI + AppCtx 的组合：无命令且存在 TOKIMO_BUS_SOCKET 时进入服务模式，初始化 DB pool，启动 app Axum server，注册 bus service 和 data-plane socket；显式命令则可执行 version、standalone 等开发入口。 这种入口设计让同一个 binary 同时满足生产和开发：生产由主 server supervisor 拉起并注入 bus 环境；开发可以用 standalone HTTP server 单独调试 handler；CLI 子命令则用于低成本健康检查和运维动作。sidecar 不需要知道自己被哪个窗口打开，只需要在 bus 上声明“当前服务是 video、可用 method 列表以及数据面 socket 位置”。 let db = db::init_pool().await?; let ctx = Arc::new(AppCtx::new(db, Arc::clone(&client_slot)).await?); let app_socket = app_server::spawn("video", Arc::clone(&ctx)).await?; let client = bus_services::video_jobs::register( BusClient::builder(cfg).service("video", env!("CARGO_PKG_VERSION")).data_plane(app_socket), Arc::clone(&ctx), ).build().await?; 故障场景 sidecar 死 拉起失败 假死 bundle 404 / runtime throw 主 server 仍健康 skip 单 app 504 仍健康 桌面 shell 单窗错误 单 app 缺失 窗口卡住/超时 fallback 占位 其他 app 不受影响 不受影响 不受影响 不受影响 video 自己 502 manifest 找不到 504 加载失败 用户观察 app 不可用 “Library not found” 类提示 等待后超时 “Failed to load” Part 5 · 验收方法论 这类拆分的验收不能仅以页面可访问作为验收标准。更有效的方法是把系统按风险切成 workstream：后端 bus、性能热路径、前端 parity、Shell SDK contract、submodule 完整性、崩溃隔离，再加主 agent 自查。每条线独立找 P0/P1/P2，再用 rubber-duck 复审找报告盲区。再用威胁模型倒推优先级：若 sidecar 都是可信内置 app，一些 bus 能力可按 P1 处理；若允许第三方 sidecar，同样的问题会升级为 P0。 这种验收方式的核心是避免“局部通过”。例如前端 parity 通过，不代表 bus ownership 安全；HLS 流畅，不代表 job 分页正确；sidecar 能启动，不代表 bundle runtime throw 时有可理解 UX。把问题按威胁模型重新分级，才能知道哪些缺陷只是低风险质量问题，哪些会破坏 sidecar 平台本身。 mindmap root((验收)) WS1 后端 bus WS2 性能热路径 WS3 前端 parity WS4 Shell SDK contract WS5 submodule 终态 WS6 崩溃隔离 WS7 主 agent grep rubber-duck 复审 threat model -> P 级 workstream 关注点 典型输出 后端 bus auth、ownership、协议保留字段 P0/P1 列表 性能热路径 HLS、Range、batch children 是否走数据面 前端 parity 残留 import、URL、fallback 删除/迁移清单 SDK contract Player、WindowPortal、i18n API 边界 崩溃隔离 502/504、单窗错误 隔离矩阵 项目开源地址：tokimo-lab
浏览器里的 AI agent 工具调用：从前端按钮到 microVM PTY 的完整链路
浏览器里的 AI agent 工具调用：从前端按钮到 microVM PTY 的完整链路 目录 为什么一次「看目录」不只是一次命令执行 链路全景：从 React 到 guest-agent 前端：按钮、SSE、WS 与 typed client 的边界 后端：Axum 把聊天流和工具执行拆开 sandbox：为什么是 vsock，不是 TCP PTY：为什么不是直接 exec 错误与回流：stdout、exit、error 都是协议 业界正在做什么 一个可复用的工程框架 为什么一次「看目录」不只是一次命令执行 用户在浏览器 chat 输入「帮我看一下当前目录」，表面上只是 pwd && ls。但在 agent 产品里，它同时穿过四个边界：浏览器进程、Rust server、sandbox runtime、microVM guest。每一层都可能丢事件、阻塞、错认工作目录，或者把错误包装成一段模型看不懂的文本。 真正难的不是执行命令，而是让一次工具调用具备这些性质：用户能看到流式反馈；模型拿到结构化结果；命令运行在隔离环境；刷新页面后状态还能恢复；超时、退出码、stderr 都能被解释；交互式 shell 和一次性命令不能互相污染。 这里还有一个容易被忽略的产品问题：用户以为自己在和「一个助手」对话，但系统实际由很多异步参与者组成。LLM 可能还在思考，SSE 已经断线；命令可能已经退出，消息缓存还没 flush；sandbox 可能刚被重建，旧的 PTY tab 还在尝试写 stdin；用户切换 agent 后，工作目录、技能开关、模型参数都要跟着换。如果这些边界没有清晰协议，最后出现的 bug 往往不是「命令跑错了」，而是「结果跑对了，但出现在错误的会话、错误的窗口、错误的工作目录」。 难点 表面现象 工程解法 事件先后 POST 成功但前端没收到首个 token 先建 SSE 订阅，再触发 send 执行边界 模型命令误跑在 host executor 显式携带 sandbox handle 工作目录 多轮 Bash cwd 漂移 执行后回读 pwd -P 并更新状态 页面刷新 terminal 输出丢失 PTY registry 保存 history 并支持 attach 错误表达 stderr 被当普通文本 exit/error/stderr 分帧再映射 ToolOutput ┌──────────────┐ POST /chat/send ┌──────────────┐ │ Browser Chat │ ───────────────────▶ │ Axum AI API │ │ React + SSE │ ◀── data: AgentEvent │ ChatService │ └──────┬───────┘ └──────┬───────┘ │ │ tool: Bash │ WS: app events / terminal ▼ │ ┌──────────────┐ │ │ AgentSandbox │ │ │ per-agent cwd│ │ └──────┬───────┘ │ │ spawn_shell │ ▼ │ ┌──────────────┐ └────────────────────────────▶ │ microVM guest│ │ vsock + PTY │ └──────────────┘ Agent 工程的本质，是把「模型想做一件事」变成跨进程、跨协议、跨权限边界的可恢复事务。 链路全景：从 React 到 guest-agent 当前代码里，AI chat 的入口主要在 packages/web/src/apps/ai-chat/。AiChatContext.tsx 维护 agent、conversation、panel 状态；useSendMessage.ts 把编辑器内容、附件和模型选择组装成发送输入；useChatSSE.ts 负责先连 /api/apps/ai/chat/{conversationId}/events，再 POST /api/apps/ai/chat/send，避免服务端广播时前端还没订阅。 服务端路由集中在 packages/rust-server/src/apps/ai/router.rs：stream::chat_events 提供 SSE，stream::chat_send 触发 ChatService::stream_completion。工具层落在 packages/rust-server/src/apps/ai/tools/，其中 Bash 工具最终会走 LocalToolExecutor::exec_bash_streaming；正常情况下 sandbox always-on，executor 进入 AgentSandbox::exec，由 tokimo-package-sandbox 的 Auto backend 决定 Linux CH / bwrap 等实际路径。只有当 sandbox boot 进入 cached Failed state 时，chat 才按 6432e4ab 的策略退回 host execution，避免每次请求都卡 multi-second timeout；/api/apps/ai/sandbox/restart 用于清掉 failed runtime 并重试。 这篇文章按「理想的一次看目录工具调用」来讲链路，但会保留当前代码的真实边界：chat send 不是 generated client 的 mutation，而是 authFetch 手写；packages/web/src/lib/api/ 这个旧路径不存在，真实 typed client 在 generated/rust-api/；packages/tokimo-guest-agent/ 也不是独立 package，而是 sandbox 包里的 binary。写技术随笔最怕把架构图画得很顺，代码却不是这么跑的，所以这里宁可把这些不那么优雅的过渡态写出来。 graph TD A[ChatInput / Send button] --> B[useSendMessage] B --> C[useChatSSE] C --> D[authFetch /api/apps/ai/chat/send] C --> E[SSE /api/apps/ai/chat/:id/events] D --> F[Axum stream::chat_send] E --> G[React Query message cache] F --> H[ChatService::stream_completion] H --> I[BashTool] I --> J[LocalToolExecutor] J --> K{sandbox runtime healthy?} K -->|yes| L[AgentSandbox::exec] K -->|boot failed / Failed cached| M[host process fallback] L --> N[tokimo-package-sandbox Auto backend] N --> O[tokimo-sandbox-init / guest bash] R[/api/apps/ai/sandbox/restart] --> K 一次调用的时序条带 Browser Axum Agent runtime Sandbox host Guest | | | | | |--SSE open-->| chat_events | subscribe bus | | |<--initial---| | | | |--POST------>| chat_send | stream_completion | | | |------------------->| model/tool loop | | | | | BashTool | | | | |------------------->| spawn_shell | | | | |--vsock JSON--->| | | | |<--frames-------| |<--SSE data--|<-------------------| AgentEvent | | 这条链路不是单向 RPC，而是「触发通道 + 事件通道 + 执行通道」三条线并行协作。 前端：按钮、SSE、WS 与 typed client 的边界 前端有两类网络通道。第一类是 AI chat 自己的 SSE：useChatSSE.ts 用 authFetch(rustUrl(...)) 手动读取 response body，解析 data: 行，再用 applySSEEvent patch React Query cache。这里没有直接使用浏览器原生 EventSource，因为代码需要精确控制「先订阅、再发送」以及 AbortController 生命周期。 第二类是全局 WS：packages/web/src/system/events/ws.tsx 定义 JSON envelope：{ type, data, reqId, error }；useJobEvents.ts 明确把 app-wide 的 job_update、person_scraped、download:progress 放到 WS 上，并替代旧 SSE。也就是说，chat token 和工具事件偏 SSE，系统级广播偏 WS。 typed client 的真实位置不是旧路径 packages/web/src/lib/api/，而是生成目录 packages/web/src/generated/rust-api/。例如 ai.ts 里有 api.ai.agents.*、api.ai.conversations.*、api.ai.messages.*、sandbox status、terminal URL 等。chat send 当前仍是手写 authFetch，但 agent/conversation/message 的 CRUD 已经由 generated client 承担。 这个边界值得保留：不是所有请求都应该强行塞进同一个 client 抽象。普通 CRUD 追求类型、缓存键和失效策略一致；chat stream 追求连接时序、增量解析和取消语义可控。把二者分开，反而能让「控制面」和「数据面」各自简单，也方便后续把 stream 协议单独演进，不牵动普通查询接口，并保持可测试、可回放、可追踪。 通道 当前用途 代码位置 为什么这样分 POST 触发一次用户消息 useChatSSE.ts → /chat/send 请求有明确成功/失败 SSE assistant/message/tool 增量 chat_events + ConversationJournal 单向流、易恢复、适合文本增量 WS 全局事件、terminal、job system/events/ws.tsx、terminal_ws.rs 双向、可复用、适合交互 generated API CRUD / 查询 generated/rust-api/ai.ts 类型稳定、React Query 集成 graph LR subgraph React Input[ChatInput] Hook[useSendMessage] SSE[useChatSSE] Cache[React Query Cache] WS[WsProvider] end Input --> Hook --> SSE SSE -->|POST trigger| Send[/chat/send/] SSE -->|read stream| Events[/chat/:id/events/] Events --> Cache WS --> Jobs[job_update etc] API[generated rust-api] --> Cache 前端不是「发一个请求等结果」，而是在维护一个可断线重连的 agent 运行投影。 后端：Axum 把聊天流和工具执行拆开 router.rs 里有三组关键路由：/api/apps/ai/chat/send 触发运行；/api/apps/ai/chat/{conversationId}/events 输出 SSE；/api/apps/ai/agents/{id}/terminal/ws 给用户打开同一个 sandbox 内的交互式终端。stream.rs 的 chat_send 把 user、conversation、provider、model、attachments 和 sandbox_runtime 一起传进 ChatService::stream_completion。 工具执行上下文由 ServerToolExt 描述。它把 workspace 级资源（DB、HTTP、ConversationJournal、MCP、sandbox manager）和 per-agent 资源（agent id、cwd、模型参数、tool whitelist、sandbox handle）拆开。这样 DispatchAgent、Skill、session memory extractor 都能继承同一个逻辑 sandbox，而不是复制一个可能已经死掉的 Arc。 sequenceDiagram participant U as Browser participant A as Axum stream.rs participant C as ChatService participant T as BashTool participant E as LocalToolExecutor participant S as AgentSandbox participant G as sandbox-init / guest bash U->>A: POST /api/apps/ai/chat/send A->>C: stream_completion(input, sandbox_runtime, bus) C-->>U: SSE AgentEvent conversation:start C->>T: model requests Bash T->>E: exec_bash(args) E->>S: exec(cmd, timeout) S->>G: spawn argv=[/bin/bash,-lc,cmd] G-->>S: stdout/stderr/exit/error frames S-->>E: ExecResult E-->>T: ToolOutput C-->>U: SSE message/tool_result/end Axum 层只负责把运行接上轨道，真正的复杂度在「事件 journal」和「工具 executor」之间。 sandbox：为什么是 vsock，不是 TCP tokimo-package-sandbox 是跨平台 sandbox 包，公开 Sandbox、ShellOpts、Event、JobId 等 API。Linux 现在不应只写 Cloud Hypervisor：默认是 Auto backend，先探测 CH，失败时回退到 bubblewrap。CH 分支仍通过 Cloud Hypervisor hybrid-vsock UDS 连接 guest；bwrap fallback 则用 namespaces + socketpair 承载同一套 init / RPC / netstack 语义。对 Bash tool 来说，关键变化是 guest 里应有真 /bin/bash，同时有大量 busybox applet symlinks 补齐常用命令。 Guest 侧并没有独立的 packages/tokimo-guest-agent/，而是 packages/tokimo-package-sandbox 里的 binary：src/bin/tokimo-guest-agent/。main.rs 默认在 1024 开 one-shot RPC，在 1025 开 PTY；server.rs 读一行 JSON request，返回多行 JSON frames；exec.rs 定义 Spawn/Ping/QueryMount；pty.rs 用 forkpty() 处理交互式会话。 维度 vsock TCP 地址模型 host/guest CID + port IP + port 暴露面 不进入普通网络栈 容易被路由、防火墙、代理影响 microVM 语义 天然 host↔guest 通道 需要额外网络配置 安全边界 不需要给 guest 开公网/局域网入口 需要认真处理监听地址和 ACL 当前代码 ch/rpc.rs hybrid-vsock UDS + CONNECT 不是主路径 graph TD Host[Sandbox host process] --> UDS[Cloud Hypervisor vsock UDS] UDS -->|CONNECT 1024| RPC[guest-agent RPC listener] UDS -->|CONNECT 1025| PTY[guest-agent PTY listener] RPC --> Spawn[Command::output] PTY --> Forkpty[forkpty + exec] Spawn --> Frames[stdout/stderr/exit/error] Forkpty --> PtyFrames[stdin/resize/stdout/exit] vsock 的价值不是更快，而是把「只允许宿主机和 guest 说话」变成默认拓扑。 但这句话只适用于 VM backend：Linux CH、macOS VZ、Windows Hyper-V 分别对应 AF_VSOCK / virtio-vsock / HvSocket。Linux bwrap fallback 不走 vsock，而是用 socketpair 承载同一类 frame / RPC 语义；文中后续提到“vsock”时，应理解为 VM backend 的 transport，而不是所有 backend 的唯一传输。 PTY：为什么不是直接 exec 一次性工具调用和交互式终端是两种协议。GuestRpc::spawn_command_with_options 每次连接只发一个 spawn request，guest 用 Command::output() 等命令结束后再返回 stdout/stderr/exit。这适合「帮我看目录」这种短命令，因为模型需要的是完整结果。 但用户打开 terminal 或 agent 运行交互式程序时，直接 exec 不够。TTY 程序会检查是否有终端、需要窗口尺寸、需要 Ctrl-C、需要 resize、会输出 ANSI 控制序列。AgentSandbox::open_pty 传 ShellOpts { pty: Some((rows, cols)), argv, env, cwd }；terminal_ws.rs 再把浏览器 WS 的 binary/text 输入转成 write_stdin 和 resize_shell，把 sandbox Event::Stdout 广播给所有 attach 的浏览器 tab，并保留 scrollback。 维度 one-shot exec PTY 生命周期 请求结束即结束 长连接，直到 shell 退出 stdin 基本不交互 持续双向输入 输出 stdout/stderr/exit 结果 ANSI 字节流 + exit 窗口尺寸 无意义 rows/cols + SIGWINCH 适合 pwd && ls、测试命令 shell、REPL、top、vim 类程序 当前实现 GuestRpc::spawn_command_with_options open_pty_with_options + forkpty() PTY 不是「exec 多一个参数」，而是一条活的终端电路： Browser xterm │ key bytes / resize ▼ terminal_ws.rs │ PtyInput::Data / Resize ▼ Sandbox::write_stdin / resize_shell │ VM: vsock JSON frame / bwrap: socketpair frame ▼ sandbox-init pty.rs │ forkpty master fd ▼ /bin/bash -l（Bash tool 语义）或 ShellOpts 指定的交互式 shell ❓ exec 给模型结果，PTY 给用户现场；把两者混成一个接口，最后会两边都难用。 错误与回流：stdout、exit、error 都是协议 错误不能只靠字符串。当前链路里至少有三种错误：请求错误（POST 失败，前端 onError）、运行错误（AgentEvent 里的 conversation/message/turn error）、sandbox 错误（Event::Error { fatal, message } 或 guest Response::Error { msg }）。这里需要删除 caller-owned JobId 的叙述：该 race fix 已被 b797c2975 revert，exec() 回到 spawn-then-subscribe。项目组判断原始竞态并不存在，因为事件发布路径要经过 guest fork+exec / vsock / HCS pump，远晚于 host 侧 synchronous insert；反而是只在 ChBackend 实现 spawn_shell_with_id、其他 backend 走 trait 默认 fallback，导致 Windows/macOS/bwrap 丢弃 job_id，最终让 Bash tool 在 Windows 卡 120s。 这个 revert 的教训比 race 本身更重要：跨平台 sandbox 的 API 不能只在一个 backend 上实现，然后依赖 trait 默认实现“看起来编译通过”。如果 API 改动影响 job id、事件订阅、stdin/stdout 语义或 host-exec response，就必须让 CH、bwrap、macOS、Windows 全部显式实现或显式报错；静默 fallback 会把 bug 推迟到最难调的平台上爆炸。 输出回流也分层：guest-agent 把 stdout/stderr/exit/error 变成 JSON lines；ch/backend.rs 映射成 tokimo_package_sandbox::Event；AgentSandbox::exec 收集成 ExecResult；Bash tool 格式化成 ToolOutput；ChatService 再投影成前端可消费的 AgentEvent。对于 chat，前端以 SSE patch message cache；对于 terminal，WS 直接推原始字节给浏览器。 对比项 SSE WS 方向 server → browser 双向 chat 增量 很适合，浏览器只读 也能做，但需要自定义重连语义 terminal 不适合 stdin/resize 适合二进制输入和输出 当前代码 useChatSSE.ts、stream::chat_events WsProvider、terminal_ws.rs 失败恢复 重新连接并通过初始状态补齐 需要业务自己处理 session/history 好的 agent 运行时不会隐藏错误，而是让每一层都能保留自己的错误类型和恢复策略。 业界正在做什么 OpenAI Code Interpreter 的路线是「托管沙箱 + 文件挂载 + 结果工件」，用户不关心底层 VM，但工具调用被严格关在会话环境里。Cursor agent 更像「IDE 内本地代理」，强调 workspace 上下文、diff、终端和用户确认。Claude Code 则把 terminal、文件系统、任务子代理和工具权限做成 CLI 里的长期会话，重点是把工程项目当运行现场，而不是把 prompt 当唯一上下文。 Tokimo 这条链路更接近「浏览器桌面 OS 里的 agent runtime」：前端是窗口和消息投影，后端是 agent orchestrator，sandbox 是可共享但 per-agent 隔离的执行层。它不是复制某一家产品，而是在浏览器、Rust server、microVM 三者之间明确切边界。 系统 用户界面 执行位置 强项 OpenAI Code Interpreter Chat 托管沙箱 工件和数据分析闭环 Cursor agent IDE 本地 workspace/终端 代码上下文和编辑体验 Claude Code CLI 本机工程目录 长任务、工具权限、子任务 Tokimo Browser desktop Rust server + microVM sandbox Web UI、窗口系统、隔离执行统一 业界共识正在形成：强 agent 不是一个模型接口，而是一套带 UI、状态、沙箱和权限的运行时。 一个可复用的工程框架 如果把这条链路抽象出来，我们会得到一个四层框架：UI projection、run orchestration、tool execution、isolation transport。UI projection 负责把运行状态变成用户可理解的消息、spinner、terminal；run orchestration 负责模型循环、工具调度、取消和恢复；tool execution 负责 cwd、env、timeout、输出裁剪；isolation transport 负责把命令送进受控环境。 graph TB L1[UI Projection\nReact Query / SSE / WS] --> L2[Run Orchestration\nChatService / ConversationJournal] L2 --> L3[Tool Execution\nBashTool / LocalToolExecutor] L3 --> L4[Isolation Transport\nAgentSandbox / Sandbox / vsock] L4 --> L5[Guest Runtime\nguest-agent / exec / PTY] L5 --> L4 L4 --> L3 L3 --> L2 L2 --> L1 这也是为什么「帮我看一下当前目录」值得被认真拆解。它不是 demo，而是 agent 产品最小但完整的工程切片：一次用户意图，被模型转成工具调用，被 server 约束成执行计划，优先被 sandbox backend 执行，再被事件流还原成用户能看懂的状态。只有当 sandbox boot 已进入 cached Failed state 时，chat 才退回 host execution；这是一条可用性 fallback，不应被写成常规执行路径。 Framework-level 结论：浏览器里的 AI agent 工具调用，应该被设计成事件驱动的分层运行时，而不是从按钮一路硬连到 shell 的远程命令。
为什么我们给 AI 沙箱手写了一套跨平台用户态 TCP/IP 栈
为什么我们给 AI 沙箱手写了一套跨平台用户态 TCP/IP 栈 目录 问题为什么难 为什么不是容器 / userns 业界正在做什么 网络方案怎么选 现在的架构 性能目标不是越高越好 我们诚实承认的取舍 AI sandbox 的网络问题，表面上只是"让 guest 能访问外网"。但我担心的不是能不能 ping 通，而是：未知代码会在高频网络路径里做什么，以及我们能不能在事后解释它为什么能做。 AI agent 跑的是用户给的仓库、脚本、二进制和临时命令。它可能 curl | sh，可能跑包管理器，也可能启动一个开发服务器。网络是高频路径：DNS、TLS、包管理器、Git、模型下载、webhook、local service probe，全部都会从这里经过。如果这些流量直接进宿主内核网络栈，我们很难在跨 Linux / macOS / Windows 的语义上稳定地做审计、DLP、egress allowlist，甚至很难解释一次连接到底从哪里来、为什么被允许。 所以 Tokimo 最后选择了一条看起来更重、但边界更清楚的路：guest 里只放 tokimo-tun-pump 搬运 Ethernet frame，host 侧自己跑基于 smoltcp 的 userspace netstack。guest 可以像平常一样发包，但真正决定 flow 怎么建、怎么路由、怎么记账、怎么被策略拦截的地方，回到了 Tokimo 自己的 Rust 代码里。 问题为什么难 AI sandbox 的网络需求和传统 VM 网络不同，面临三个同时成立的约束： ┌──────────────────────────────────────────────────────────┐ │ 约束 1: 未知代码 │ │ Guest 运行任意用户仓库 / 脚本 / 二进制 │ │ 网络行为不可预测，必须从外部可解释 │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ 约束 2: 三平台一致性 │ │ Linux Auto (CH → bwrap) / macOS (VZ) / Windows (Hyper-V) │ │ 策略语义必须统一，不能每 backend 各写一套 │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ 约束 3: 可审计 / 可解释 │ │ 每次连接需能追溯到 session、task、进程 │ │ flow log 要产品侧可控，而非依赖宿主平台状态 │ └──────────────────────────────────────────────────────────┘ 这三个约束加在一起，就排除了大部分"接通即可"的常规方案。传统的平台原生网络（nftables / pf / WFP）能解决单平台的某个约束，但无法同时满足三个。 AI sandbox 网络的真正难题不是吞吐，而是同时满足跨平台一致性和完整的流量可解释性。 为什么不是容器 / userns 容器路线我们不是没想过。userns、Landlock、seccomp、cgroup 全部拼起来，看起来像一个便宜的 sandbox，而且启动快、镜像生态成熟、调试体验也好。问题在于它们共享宿主内核，爆炸半径不够小。CVE-2022-0185、CVE-2024-1086 这种例子不需要过度展开；结论就是，只要未知代码还能打到同一个内核，AI shell 的风险就没有被切开。 对传统 CI 来说，容器可能是足够好的折中；对一个会让用户把任意仓库、任意命令、任意 agent loop 丢进去的产品来说，我们不想把"请相信 namespace 组合得足够好"当成核心安全边界。尤其网络审计还要和进程、文件、会话身份绑定时，共享内核会让很多边界变成策略约定，而不是故障域隔离。 于是主路线切到 VM：Linux 优先 Cloud Hypervisor，macOS 用 Virtualization.framework，Windows 用 Hyper-V。VM 至少给了一个硬边界：guest 用户态和宿主内核不是同一个故障域。但 Linux 现在还有一个必须诚实写出的可用性兜底：当 CH 不可用时，Auto backend 会退到 bubblewrap。bwrap 是 namespaces-based 容器，不提供 VM 级 kernel boundary；它的价值是让无 KVM / 无 vhost-vsock 环境仍能跑同一套 sandbox API、FUSE 和用户态 netstack，而不是把它包装成等价强隔离。 flowchart LR A[容器 / userns] --> A1[共享宿主内核] A1 --> A2[syscall / namespace / eBPF 风险面] B[强隔离主路线] --> B1[Linux: Cloud Hypervisor] B --> B2[macOS: Virtualization.framework] B --> B3[Windows: Hyper-V] F[Linux fallback] --> F1[bubblewrap namespaces] B1 --> C[统一 sandbox API] B2 --> C B3 --> C F1 --> C C --> D[用户态网络栈] 共享内核让"合规的设计"和"不出问题的实现"永远不在同一个故障域里。 业界正在做什么 用户态 TCP/IP 并不是 Tokimo 独自发明的方向，业界已有多个先行者验证了可行性： passt（Linux 近年推出的用户态网络方案）：在无特权 namespace 下提供完整 IPv4/IPv6 转发，不依赖 tun 设备，专为 VM 和容器的 rootless 网络设计，代表了 Linux 内核社区对"用户态网络可以够好"的判断。 cowork（逆向分析结论）：其 Windows 后端跳过了 Hyper-V vSwitch，而是走用户态网络栈。这个选择几乎坐实了一个判断：想在跨平台 AI sandbox 里保持策略一致性，回避 vSwitch 是务实选择，不是偏执。 gVisor netstack：Google 的 gVisor 把完整的 TCP/IP 实现搬进用户态（Go），用于 runsc 容器的网络层。其 netstack 模块已被多个项目独立复用，说明用户态 L3/L4 在生产级沙箱里是被验证过的架构。 这条路不是孤注一掷，而是多个团队在不同场景里独立走到相同结论的收敛点。 网络方案怎么选 VM 以后，网络仍然有很多选择。我们试过或评估过 vSwitch / kernel stack 方向，最后丢掉了。原因不是它不能工作，而是策略落点太散：Linux、macOS、Windows 的网络对象、权限模型、packet hook 能力都不一样。你可以在某个平台上写 nftables，在另一个平台上写 pf 或系统过滤接口，再在 Windows 上补一套 Hyper-V / WFP 逻辑；但这样得到的不是一个产品级网络策略模型，而是三套相似但不等价的实现。 后来逆向看 cowork 的实现，发现他们也没有走 Hyper-V network，而是用了用户态网络栈，这个判断基本坐实：如果目标是跨平台 AI sandbox，真正难的不是"把网接通"，而是把每个 flow 的语义收束到同一个地方。 vhost-user-net 很快，尤其 Linux + DPDK 场景很好看。但 Tokimo 的目标不是 10G+ NFV，也不是只跑 Linux。我们要的是一个三平台一致、可审计、可演进的接口。所以选择了 smoltcp：性能上限不如 vhost，CPU 也会更贵，但它把 L3/L4 放回了我们能读、能改、能插 hook 的 Rust 代码里。 flowchart LR A[Tokimo AI sandbox 网络选择] --> B[vSwitch / kernel stack] A --> C[vhost-user-net / DPDK] A --> D[smoltcp userspace netstack] B --> B1[平台原生但策略分裂] C --> C1[吞吐最高但偏 Linux / 特权部署] D --> D1[跨平台一致 / Rust hook 可控] 方案 吞吐 CPU 平台覆盖 可审计 维护成本 我们选不选 vSwitch / kernel stack 高 低 部分采用 否（三平台语义不一致） 中 否 vhost-user-net + DPDK 极高 低 部分采用（偏 Linux） 部分 高（特权 / DPDK 运维） 否 passt 高 低 部分采用（Linux/CH 阶段资产） 部分 中 保留历史验证价值，但不作为统一策略落点 bubblewrap native netns only 中 低 Linux fallback 否（只能说明隔离，不提供三平台 flow 模型） 低 否，bwrap 仍接入 Tokimo netstack gVisor netstack 中 中 部分采用 是 高（Go 依赖） 否 wireguard-only relay 低 低 是 否（relay 语义弱） 低 否 smoltcp userspace netstack 中（目标/内部口径，正式 bench 待补） 高 是（CH、bwrap、VZ、Hyper-V 统一） 是 中（需维护 fork/patch） 是 跨平台策略一致性是选型的第一约束，吞吐是选型的最后约束。 现在的架构 flowchart TD subgraph Guest[Guest sandbox] App[AI shell / unknown code] Tk0[tk0 network interface] Pump[tokimo-tun-pump] App --> Tk0 Tk0 <-->|Ethernet frames| Pump end Pump <-->|CH: vsock| CH[Linux Cloud Hypervisor transport] Pump <-->|bwrap: socketpair| BW[Linux bubblewrap transport] Pump <-->|VZ: virtio-vsock| VZ[macOS transport] Pump <-->|Hyper-V: HvSocket| HV[Windows transport] subgraph Host[Host] Netstack[smoltcp netstack] Route[flow route / NAT / proxy] Hooks[packet hooks / flow hooks] Policy[egress allowlist / audit log / DLP] end CH --> Netstack BW --> Netstack VZ --> Netstack HV --> Netstack Netstack --> Route --> Hooks --> Policy --> Internet[(Internet / local services)] 数据在各层之间的流动： ┌──────────────────────────────────────────┐ │ 应用层 (AI shell / unknown code) │ │ POSIX socket API │ └──────────────────────────────────────────┘ │ v TCP/UDP segments ┌──────────────────────────────────────────┐ │ Guest socket / tk0 虚拟网卡 │ │ Ethernet frames (ARP + IP) │ └──────────────────────────────────────────┘ │ v raw frames over vsock / HvSocket ┌──────────────────────────────────────────┐ │ tokimo-tun-pump (guest 侧) │ │ 无策略，只负责帧的搬运 │ └──────────────────────────────────────────┘ │ v byte stream ┌──────────────────────────────────────────┐ │ smoltcp netstack (host 侧) │ │ L3/L4 解析 -> flow 创建 -> hook 拦截 │ │ egress allowlist / audit / DLP │ └──────────────────────────────────────────┘ │ v upstream TCP/UDP socket ┌──────────────────────────────────────────┐ │ 宿主 OS 网络栈 -> Internet │ └──────────────────────────────────────────┘ guest 侧的 tokimo-tun-pump 很克制：它不做策略，只负责把 tk0 上的 frame 搬出来。host 侧的 netstack 才是核心：smoltcp 处理 L3/L4，Tokimo 在 flow 创建、路由、关闭，以及 packet 进出点上预留审计位置。transport 可以是 Linux CH 的 vsock、Linux bwrap 的 socketpair、macOS virtio-vsock、Windows HvSocket，但上层看到的是同一类 frame / flow。 guest 不需要知道策略，它只需要相信自己有一张网卡，像普通 Linux 一样发 ARP、IP、TCP、UDP。host 侧则把 frame 还原成可理解的连接，把"这个 TCP flow 属于哪个 sandbox session、目标是什么、是否命中 allowlist、是否需要 DLP、关闭时传了多少数据"记录下来。策略不再散在 guest 里，也不依赖某个宿主平台的私有网络对象。 flowchart LR P1[packet in: guest -> host] --> H1[packet hook] H1 --> F1[smoltcp parse] F1 --> F2[flow create / route] F2 --> H2[flow hook: allow / deny / log] H2 --> U[upstream socket] U --> H3[flow hook: response accounting] H3 --> F3[smoltcp emit] F3 --> H4[packet hook] H4 --> P2[packet out: host -> guest] hook 分成 packet 和 flow 两层，因为它们回答的问题不同。packet hook 适合底层观测、调试、计数；flow hook 才适合做产品策略——egress allowlist、按任务记录审计日志、在连接建立时拒绝某个目的地、在响应阶段做流量记账。这样避免了把高层策略塞进每个 packet，也避免在 flow 层丢掉必要的底层可见性。 策略的位置决定策略的可维护性；把 flow hook 放在 host userspace，是让网络审计成为产品能力而非平台副作用的关键一步。 性能目标不是越高越好 目前保守口径约 ~1 Gbps，会议目标是 4.5 Gbps（该数字为内部目标，正式 bench 数据待补充）。这个目标对 AI agent 已经足够：拉依赖、访问 GitHub、跑包管理器、下载模型分片，瓶颈通常不在本机 guest↔host 网络。我们没有必要为了 10G+ 把 DPDK、vhost、平台特权和复杂运维全部拖进来。 更重要的是，网络栈可以独立升级。guest 发行版换不换、kernel config 怎么裁剪、Windows 后端怎么包装，都不应该影响 egress allowlist 和审计语义。只要 tokimo-tun-pump 和 host netstack 的协议稳定，网络策略就是 Tokimo 自己的产品能力，而不是某个平台的副作用。 高性能网络方案往往会把一部分控制权交回内核、驱动、vSwitch 或平台服务；而 AI sandbox 的第一优先级不是跑满网卡，而是让未知代码的网络行为可解释、可限制、可复盘。4.5 Gbps 目标足够支撑当前 agent 工作负载，约 ~1 Gbps 的保守口径已能覆盖大量开发场景。剩下的优化可以在同一架构里迭代，而不是为了一个极限吞吐数字推翻安全边界。 吞吐是可以迭代的工程数字；安全边界一旦妥协，架构级别的代价需要重写才能偿还。 我们诚实承认的取舍 得到的是三个东西： 跨 backend 接口一致：Linux Auto backend 下的 CH / bwrap、macOS VZ、Windows Hyper-V 可以用不同 transport，但 flow 语义、日志字段、allowlist 判断不需要写四遍。平台和 Linux fallback 差异被压在 transport 层，上层策略保持同一套模型。 安全产品能力落点明确：DLP、审计日志、egress allowlist、按会话网络开关，都可以落在 host netstack，而不是散在 nftables、pf、Windows Filtering Platform 或 Hyper-V vSwitch 里。以后要解释一次连接，看的是 Tokimo 的 flow log，而不是三个平台各自的系统状态。 网络升级可以独立于 distro / kernel：CH initrd 可以从 0.1.0 升到 0.1.1，rootfs 可以换，guest kernel 可以裁剪；只要 pump 还在，host 策略就不跟着漂。 代价： 吞吐上限低于 vhost-user-net CPU 成本更高 smoltcp fork / patch 需要长期维护 调试路径更长：包从 guest -> host -> userspace stack -> upstream socket，中间任何环节出问题都需要额外工具 对 AI sandbox 来说，可解释、可限制、可复盘是第一阶段的核心产品指标；网络栈必须是我们能装刹车和行车记录仪的那个位置。 用户态 TCP/IP 是把网络治理能力从"依赖三个平台各自的私有对象"变成"Tokimo 代码库里一个可读、可改、可审计的模块"的必要条件——不是工程上的偏执，而是架构层面的职责划分。
在浏览器里给 AI 跑命令：Tokimo sandbox 三平台 backend 的现状切片
在浏览器里给 AI 跑命令：Tokimo sandbox 三平台 backend 的现状切片 目录 问题为什么难 一张总图：三平台不是三套产品 Linux / Auto backend：Cloud Hypervisor 优先，bubblewrap 自动兜底 macOS / Virtualization.framework：原生 VM，协议尽量不特殊化 Windows / Hyper-V：service、镜像打包和诊断链路正在持续推进 共同基础：tun-pump、vsock 抽象与 guest-agent 协议 为什么不直接 Docker / WSL2 业界做法 我们诚实承认的缺口 问题为什么难 在浏览器里让 AI agent 跑命令，难点不在“把一串 argv 丢给 shell”。难点是：这串命令可能来自模型、用户、网页和历史上下文的混合产物，我们无法提前审查它会不会 curl | sh、跑包管理器、开端口、扫内网、读工作区外的文件，或者把一台开发机拖进 fork bomb。 传统 Web 应用把不可信输入关在进程边界里；AI coding agent 则反过来，主动要求我们给它一个“像真机器一样”的 shell。它需要 PTY、文件系统、网络、DNS、包管理器、长进程、信号和退出码。这里的工程悖论是：越像真机器，隔离越难；越像沙箱，工具越不像真机器。 Tokimo sandbox 当前的答案不是押一个平台原语，而是把 Linux、macOS、Windows 各自最靠谱的 VM / namespace 能力收敛到同一组语义：启动 sandbox、打开 shell、转发 stdio/PTY、挂载工作区、给网络加策略、最后销毁会话状态。 金句：AI shell 的安全边界不是一条 syscall 黑名单，而是一台可预测、可销毁、可观测的小机器。 一张总图：三平台不是三套产品 flowchart TD Browser[Browser / AI tool call] --> Server[tokimo-server] Server --> API[tokimo-package-sandbox API] API --> Auto[Linux: Auto backend] Auto -->|preferred| CH[Cloud Hypervisor microVM] Auto -->|fallback| BW[bubblewrap namespaces] API --> VZ[macOS: Virtualization.framework] API --> HV[Windows: Hyper-V HCS + service] CH --> GA[tokimo-guest-agent / sandbox-init] BW --> GA VZ --> GA HV --> GA GA --> Shell[exec / PTY / shell] GA --> FS[workspace mount] GA --> Pump[tokimo-tun-pump] Pump --> Netstack[host smoltcp netstack] Netstack --> Policy[policy / audit hooks] Policy --> Internet[(network)] 跨平台一致性视图 ┌─────────────── public API ───────────────┐ │ configure / create / start / shell / stop│ └───────────────┬───────────────┬──────────┘ │ │ ┌───────▼────────┐ ┌────▼──────┐ ┌────────▼────────┐ │ Linux Auto │ │ macOS VZ │ │ Windows Hyper-V │ │ ch → bwrap │ │ virtio-vsock │ HvSocket + pipe │ └───────┬────────┘ └────┬──────┘ └────────┬────────┘ └───────────────▼─────────────────┘ guest-agent protocol + netstack 最近的主仓和 sandbox 子包 commit 说明这条线已经发生了两类重要变化。第一，Linux 产品路径从“只讲 CH”变成 Auto：优先 Cloud Hypervisor，遇到无 KVM、无 vhost-vsock、缺 CH binary 等条件时自动降级到 bubblewrap（886048c5、c2b4979）。第二，Windows 不是停在概念图里：sandbox 子包已经补了 static-pie guest binaries、WSL + docker 的 mkvhdx 本地打包、COM2 named pipe boot log drainer，并由主仓 aec2253c2 bump 到 be9314e。同时，bash tool 的修正方向也从“降级到 sh”回到“在 initrd 里安装真 bash，并展开 busybox applet symlinks”。 这篇文章刻意叫“现状切片”，不是“最终架构宣言”。原因是 sandbox 这类系统最容易被单点成功误导：一个 backend 的 smoke test 绿了，不代表资源回收、并发会话、网络策略、文件共享、host-exec、审计日志也都达到了产品级；一个平台能跑 shell，也不代表另外两个平台能暴露同样的错误模型。因此我们更关心三类不变量：第一，guest 内的控制协议是否一致；第二，host 侧是否有统一的网络决策点；第三，产品层是否能把 fallback、降级和 not implemented 变成可解释状态，而不是神秘失败。 金句：平台 backend 可以不同，但 agent 看到的机器语义必须只有一套。 Linux / Auto backend：Cloud Hypervisor 优先，bubblewrap 自动兜底 Linux 线最值得注意的变化不是“从 chroot-ish 方案切到 CH”这么单线，而是现在默认走 Auto backend：先探测 Cloud Hypervisor，能用就走 KVM microVM；CH 不可用时，自动回退到 bubblewrap。这个 fallback 面向的是开发机、CI、嵌套虚拟化等常见环境：没有 /dev/kvm、没有 /dev/vhost-vsock、缺 cloud-hypervisor / virtiofsd binary 时，仍然可以给 agent 一个受约束的 Linux 执行环境。 两条 Linux 路径的隔离强度不同，不能混写成同一种东西。CH 是完整 microVM，拥有独立 kernel 边界；bwrap 是 bubblewrap 提供的 namespaces-based 容器，不等价于 VM。但它们都被压进同一个 Sandbox API、同一个 tokimo-sandbox-init 协议和同一套 smoltcp 用户态网络栈：CH 通过 vsock/TAP，bwrap 通过 socketpair/TAP 和新的 netns。 因此 Linux 的产品口径应从“CH 已经进入产品路径”改成“CH 是首选强隔离路径，bwrap 是可用性兜底路径”。这也解释了为什么 rust-server 侧不应该暴露 backend selection：项目组已经移除用户可见 sandbox 设置，sandbox always-on，server 只是 sandbox 包的 consumer，由包内 Auto 策略决定实际 backend。 Linux Auto backend 结构 CH path (preferred) ┌──────────── vmlinuz ────────────┐ │ virtio-net / virtio-fs / vsock │ └─────────────────┬───────────────┘ ▼ ┌──────────────────────────────────────────────────────┐ │ initrd: read-only boot base │ │ /init → tokimo-sandbox-init / true bash / busybox applets │ │ busybox applet symlinks expanded for bash tool parity │ └──────────────────┬───────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────┐ │ rootfs lower: packaged userland │ │ overlay upper: per-session writable layer │ │ virtio-fs / FUSE: host workspace, not the whole home │ └──────────────────────────────────────────────────────┘ bwrap fallback ┌──────────────────────────────────────────────────────┐ │ bubblewrap user/pid/net/ipc/uts namespaces │ │ same sandbox-init protocol, TAP tk0 + socketpair net │ │ weaker than VM isolation, but keeps tool semantics │ └──────────────────────────────────────────────────────┘ 金句：Linux 线的成熟标志不是能 boot，而是 AI bash 和 terminal_ws 已经不用知道自己站在 microVM 里。 macOS / Virtualization.framework：原生 VM，协议尽量不特殊化 macOS backend 走 Apple Virtualization.framework，通过 arcbox-vz 启 Linux microVM。它的工程约束很 Apple：调用进程需要 com.apple.security.virtualization entitlement，集成测试要 codesign，且 VZ 的 VM start 调度不适合在同一进程里并发乱跑，所以代码里对 build/start 做了串行化。 和 Linux CH 相比，macOS 没有直接复用 CH 的 VMM 进程模型，而是让 VZ 管 VM 生命周期；但 guest 里仍尽量复用同一套 tokimo-sandbox-init、FUSE-over-vsock、tokimo-tun-pump 和 host-side smoltcp netstack。VZ 里根文件系统通过 virtio-fs tag 暴露，用户动态 mount 则已经从早期 NFS / APFS clone 方向收敛到 FUSE-over-vsock。 flowchart LR subgraph macOSHost[macOS host] App[Tokimo process] VZ[VZVirtualMachine] Net[smoltcp netstack] Fuse[FuseHost] end subgraph Guest[Linux guest] Init[tokimo-sandbox-init] TUN[tokimo-tun-pump tk0] FUSE[tokimo-sandbox-fuse] end App --> VZ App <-->|init RPC 2222| Init TUN <-->|virtio-vsock 4444| Net FUSE <-->|virtio-vsock 5555| Fuse 因此 macOS 线的现状不是“落后于 Linux 的实验品”，而是另一种 native VMM 绑定：启动时延通常比 CH 更重，签名和 entitlement 更麻烦，但它不要求 root，也不引入 Docker daemon。它真正需要追的是 artifact 模型、持续 parity 验证和与 Linux 产品路径的行为一致性。 金句：macOS backend 的价值，是把 Apple 的 VM 特权模型藏在同一条 guest-agent 协议背后。 Windows / Hyper-V：service、镜像打包和诊断链路正在持续推进 Windows backend 最不像另外两个平台，因为 Hyper-V / HCS 操作天然要求更高权限。Tokimo 的做法仍然是把 VM 生命周期放进 tokimo-sandbox-svc.exe SYSTEM service，普通用户进程通过 named pipe 发 JSON-RPC；service 再用 HCS 创建 LinuxKernelDirect microVM，分配 per-session HvSocket service GUID，使用本地打包出的 VHDX/rootfs artifact，桥接 guest init 与 host API。 sequenceDiagram participant C as client library participant S as tokimo-sandbox-svc participant B as VM artifact build participant H as Hyper-V HCS participant G as guest init participant L as COM2 log pipe B-->>S: VHDX/rootfs artifact from WSL + docker mkvhdx C->>S: startVm / spawnShell (named pipe) S->>H: create + start compute system H-->>L: guest boot logs via COM2 named pipe G->>S: connect HvSocket(per-session GUID) S-->>C: events stdout/stderr/exit C->>S: host-exec / guest response path ❓ 这里必须讲清一个取舍：我们已经放弃继续深挖 Hyper-V vSwitch。roadmap 里写得很直白：逆向 cowork 沙箱实现后发现，对方也没有走 Hyper-V 网络层，而是采用用户态网络栈。继续把时间花在 vSwitch、NAT、Windows 网络策略和不可控审计点上，ROI 太低。Tokimo 因此把 Windows 网络也拉回 tokimo-tun-pump + host netstack 的统一方向。 方案 优点 代价 Tokimo 选择 Hyper-V vSwitch 原生、吞吐潜力高、符合 Windows 管理模型 权限重，策略分散，packet/flow 审计落点不统一，调试成本高 放弃作为主线 用户态 tun-pump + smoltcp 三平台一致，策略在 host 用户态收口，便于审计与测试 需要自维护网络栈，正式 bench 待补 主线 这里不能再写成“卡在最后一段回包语义”。近期 sandbox 子包已经补了三件关键工程件：static-pie guest binaries（让 guest binary 更适合塞进 VM artifact）、Windows local VM 通过 WSL + docker mkvhdx 打包、COM2 named pipe drainer 用于收集 guest boot logs；主仓也已经 bump sandbox pointer 到 be9314e。这些改动不等于 Windows 已经产品级完成，但说明它处在镜像、启动诊断和本地验证链路持续推进中。send_guest_response / host-exec 双向闭环是否已经完全解决需要以最新代码再核对，不能在文中继续把它写成唯一且静止的 blocker。❓ 金句：Windows 线真正的胜利不是驯服 vSwitch，而是让 Hyper-V 只负责隔离，策略仍回到 Tokimo 自己手里。 共同基础：tun-pump、vsock 抽象与 guest-agent 协议 三平台能粘成一致体验，靠的不是 VMM 一样，而是传输层以上尽量一样。 flowchart TD subgraph Guest[Guest] Proc[process / shell] Init[guest-agent protocol] Tk0[tk0 TAP] Pump[tokimo-tun-pump] Proc --> Init Tk0 <-->|Ethernet frame| Pump end subgraph Transport[Backend transport] Linux[AF_VSOCK / Unix socketpair] Mac[virtio-vsock] Win[HvSocket] end subgraph Host[Host] RPC[backend client] Net[smoltcp netstack] Audit[future packet / flow audit] end Init <-->|JSON frames| RPC Pump <-->|u16 length + frame| Linux Pump <-->|u16 length + frame| Mac Pump <-->|u16 length + frame| Win Linux --> Net Mac --> Net Win --> Net Net --> Audit tokimo-tun-pump 在 guest 内创建或打开 tk0 TAP，把 Ethernet frame 用 u16-be length || frame 搬到 host。host 的 src/netstack/ 用 smoltcp 实现 L3/L4，TCP/UDP/ICMP 分别转成宿主 socket 或系统 helper。这样 NetworkPolicy::Blocked 与 AllowAll 不必依赖平台防火墙语义；未来 DNS、SNI、flow audit 也能挂在同一个地方。性能上我们只能保守写：内部测试约 ~4 Gbps 量级（待 benches/ 补正式 bench），不能把会议口头数据写成产品承诺。 后端 成熟度 启动时延 网络方案 passt 替代 当前缺口 我们的下一步 Linux / Auto (ch → bwrap) CH 是首选产品路径；bwrap 是 CH 不可用时的 namespaces fallback CH 仍按 microVM 冷启动口径；bwrap README 口径约 ~50 ms，非 SLA CH: TAP + vsock；bwrap: TAP + socketpair；二者都走 host smoltcp CH 线历史上接过 passt；当前统一方向是 tun-pump/netstack，bwrap 不走 passt CH artifact/真机矩阵仍需固化；bwrap 隔离强度弱于 VM，需在文案中说清 固化 Auto 探测、错误解释和 release artifact；不要让产品层关心 backend macOS / VZ backend 可用，VZ/FUSE/vsock/netstack 已成形 冷启动更重，受 VZ 和签名约束 virtio-vsock 承载 tun-pump，host smoltcp gating 不走 passt，走 VZ + vsock transport artifact parity、并发启动约束、持续测试矩阵 对齐 Linux artifact 与 API 行为 Windows / Hyper-V service/HCS/HvSocket 架构成形；static-pie guest、mkvhdx 本地打包、COM2 日志诊断已推进 文档旧口径约数百 ms，不作承诺；本地打包链路仍在演进 HvSocket 承载 tun-pump；放弃 vSwitch 主线 用户态 netstack 替代 vSwitch host-exec / guest response 最新闭环状态需复核 ❓；本地 VM artifact 与诊断仍在补强 继续验证 boot、日志、guest binary、host-exec 双向链路，避免把 Windows 写成停滞 协议层还有一个容易被忽略的点：vsock 在这里不是“网络”，而是本机 VM 控制总线。Linux CH 用 AF_VSOCK 找 guest CID，macOS VZ 用 virtio-vsock listener，Windows 用 HvSocket service GUID；三者的 socket API、连接方向、权限模型都不同，但上层都只看到 length-prefixed JSON frame、stdout/stderr/exit event、PTY resize/signal/write 这些动作。只要这一层稳定，未来换 VMM、换 rootfs、换网络实现，都不会逼产品层重写一次 terminal。 我们也把网络和控制面分开：控制面负责“让 guest 做什么”，数据面负责“guest 想连哪里”。这能避免一个常见错误：为了给文件共享、host-exec 或调试通道开后门，顺手把 NetworkPolicy::Blocked 打穿。FUSE-over-vsock、host-exec bridge、guest-agent RPC 都应该是显式 local service，而不是伪装成普通互联网 egress。 金句：Tokimo 的网络边界不是 tap、vsock 或 HvSocket，而是 host 侧那一个可审计的 flow 决策点。 为什么不直接 Docker / WSL2 方案 看起来省事的地方 卡住我们的地方 Docker / Podman 镜像生态成熟，文件挂载和网络现成 共享宿主内核；daemon / OCI 栈过重；跨桌面平台语义不一致；agent shell 仍会撞 namespace/seccomp 细节 WSL2 Windows 上像 Linux，开发者熟悉 生命周期和网络由 Microsoft VM 管；packet/flow 审计不稳定；发行版状态难控；不适合作为三平台统一安全边界 gVisor syscall 面比普通容器小 仍不是我们想要的 VM 边界；Sentry/Go runtime 变成新 TCB；文件共享和 syscall 税不适合热路径 Kata Containers VM 隔离成熟，常和 CH 搭配 假设 containerd/CRI/Kubernetes；Tokimo 自己就是编排器，引入 Kata 等于为不用的栈付维护税 microVM / native VM 边界清晰，会话可销毁，资源墙明确 artifact、启动、网络、guest 协议都要自己做 Tokimo 主线 Docker 和 WSL2 最大的问题，是它们解决的是“把应用交付到一个 Linux-ish 环境”，而 Tokimo 要解决的是“把不可信 agent 行为关进可观测、可销毁、跨平台一致的执行框架”。这两个问题相似，但安全边界完全不同。需要特别说明的是：Windows 本地 VM 打包链路里出现 WSL + docker mkvhdx，是 build-time artifact 制作手段，不是把 agent runtime 交给 WSL2 或 Docker daemon。 金句：我们不是缺一个能跑命令的 Linux，而是缺一个能解释每次命令影响面的隔离层。 业界做法 Firecracker 代表 serverless microVM：极小设备模型、强启动性能、适合 Lambda / Fargate 这类密度优先场景。gVisor 代表用户态内核：用 Sentry 接管 syscall，牺牲一部分兼容与性能换更小内核攻击面。Kata Containers 代表“容器接口 + VM 隔离”：对 Kubernetes / containerd 用户友好，但对 Tokimo 这种自带 agent runtime 的系统偏重。OpenAI Code Interpreter 这类产品不会公开所有实现细节，但从外部行为看，核心也是短生命周期、文件隔离、网络受限、执行结果结构化回传。 Tokimo 的位置更靠近“开发者桌面里的 microVM sandbox”：我们要保留 shell 真实感，又要把网络、文件和控制面抽象到框架层。于是我们不直接照搬某个业界栈，而是吸收它们的边界：Firecracker 的 microVM 思维、Kata/CH 的 virtio 设备模型、gVisor 的可观测诉求，以及 Code Interpreter 的产品语义。 这里还有一个产品差异：云端 code sandbox 往往可以牺牲本地文件系统，只给一次性上传目录；Tokimo 面向的是浏览器桌面 OS，用户期待 agent 能在真实 workspace 里持续工作、打开 terminal、让多个窗口看到同一份状态。这迫使我们把“会话持久性”和“会话销毁性”同时做出来：同一会话内 shell 状态要稳定，不同会话之间状态又不能串；工作区要可写，但 host home 不能被整包暴露；网络要能打开，但默认路径必须能被记录和解释。 金句：业界给了我们隔离原语，Tokimo 要补的是 agent runtime 的一致语义。 我们诚实承认的缺口 Linux 需要把 Auto 探测、CH 失败原因、bwrap fallback 触发条件和最终 active backend 都变成可观测、可解释状态。 CH 线仍需要把真机验证固化成可重复命令、机器条件、artifact 版本和 CI / release 流程。 bwrap fallback 提升了可用性，但它不是 VM 隔离；博客必须明确它的安全边界弱于 CH / VZ / Hyper-V。 vmlinuz / initrd / rootfs 三件套和 Windows VHDX/rootfs artifact 仍需要继续自动化与校验。 tokimo-tun-pump 的高吞吐口径还缺正式 bench；当前只能写内部测试量级，不能写成 SLA。 Windows host-exec / guest response 最新闭环状态需要以代码复核后再写死 ❓；但 boot artifact、static guest binary、COM2 日志诊断已经在推进，不能写成“卡死”。 egress allowlist、packet/flow audit hook、cgroup/balloon、warm pool 仍在下一阶段。 macOS 和 Windows 的集成测试矩阵天然依赖真机、签名、系统功能开关，不能假装普通 CI 就能覆盖完。 金句：一个 sandbox 项目最危险的时刻，是把能跑 demo 误认为边界已经成立。 Framework-level 一句话：Tokimo sandbox 的目标不是发明第四种虚拟化，而是把三种平台虚拟化折叠成一个可测试、可审计、可被 AI runtime 依赖的执行框架。
manifest 驱动的桌面应用：在浏览器里「装」一个跨 4 种窗口类型的应用是什么体验
manifest 驱动的桌面应用：在浏览器里「装」一个跨 4 种窗口类型的应用是什么体验 浏览器给了我们 DOM，但没有给我们桌面；React 给了我们组件，但没有给我们应用生命周期。 目录 为什么这件事难：窗口不是路由，应用也不是组件 四种窗口类型：page / system / app / popup manifest 如何把 app 注册进桌面 文件窗口和 app 窗口如何共存 跨窗口通信：bridge 不是全局事件总线 业界正在做什么 从 0 加一个 Hello World app framework-level 的一句话 为什么这件事难：窗口不是路由，应用也不是组件 在传统 Web 里，我们习惯把「一个功能」想成一条 route：URL 变化，组件树变化，状态跟着 React 生命周期走。但 tokimo 要做的是浏览器里的桌面 OS：窗口可以拖拽、缩放、最小化、恢复、层叠；同一个 app 可以同时开多个实例；一个文件可以被不同 viewer 打开；一个设置页可能只是系统窗口；一个传输进度条又不应该被持久化成长期任务。 难点不在「渲染一个窗口」，而在三件事同时成立： shell 层不能 import 每个业务 app，否则系统层会被应用层反向污染； 新 app 不能改一堆 switch/case，否则桌面会变成手工路由表； 窗口状态要能持久化，但显示名、组件、默认尺寸又要跟 manifest 更新。 ┌──────────────────────────────────────────┐ │ Browser Desktop Shell │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ Window A │ │ Window B │ │ │ │ type=system │ │ type=text │ │ │ └──────────────┘ └──────────────┘ │ │ ▲ ▲ │ │ │ │ │ │ WINDOW_REGISTRY manifest / metadata │ └──────────────────────────────────────────┘ 所以 tokimo 的答案不是「写一个 App 组件」，而是把 app 变成一份 AppManifest，再由框架把它注册成 Launchpad 项、窗口类型、系统页、运行时、菜单栏、设置项。真实入口在 packages/web/src/apps/_framework/app-registry.ts，类型定义在 packages/web/src/system/window/manifest-types.ts。 金句：桌面应用的抽象边界，不是 React component，而是可被 shell 理解的 manifest。 四种窗口类型：page / system / app / popup Tokimo 的 manifest 把 app 分成四类：page、system、app、popup。它们不是视觉风格分类，而是生命周期、路由方式和持久化语义的分类。 category 典型窗口 type 注册入口 适合场景 关键字段 page type: "page" TYPE_TO_MANIFEST DB 驱动的用户实例，如媒体库、相册库 supportedTypes, component, views system type: "system" SYSTEM_TO_MANIFEST / SYSTEM_COMPONENT Finder、Search、Settings、Video 这类内置系统应用 id, pageIds, component, pages app 自定义，如 text / terminal APP_WINDOW_TYPE_REGISTRY + WINDOW_REGISTRY 自治窗口、viewer、第三方 app windowType, windowComponent popup 自定义，如 transfer WINDOW_REGISTRY 进度条、临时任务、不可 resize 的小窗口 windowType, singletonKey, noResize 真实案例非常直观：packages/web/src/apps/terminal/manifest.ts 是 system + 独立 windowType 的双形态应用，管理页走 system，真实终端会话走 terminal windowType。 export const manifest: AppManifest = { id: "terminal", category: "system", defaultSize: { width: 1100, height: 700 }, appName: "dashboard.menu.terminalManagement", component: () => import("./pages/TerminalAppPage"), windowType: "terminal", windowComponent: () => import("./components/TerminalContent").then((m) => ({ default: m.default, })), fullBleed: true, }; 四类窗口最终都会回到同一个 dispatcher：WINDOW_REGISTRY。不同点是 page / system 先进入元 dispatcher，再二次查 manifest；app / popup 直接把自己的 windowType 注入 registry。 graph TD O[openWindow params.type] --> R[WINDOW_REGISTRY] R -->|page| P[PageContent] P --> T[TYPE_TO_MANIFEST] R -->|system| S[SystemContent] S --> M[SYSTEM_COMPONENT] R -->|custom app type| A[manifest.windowComponent] R -->|popup type| U[popup component] 金句：window type 是 shell 的调度协议，category 是 app 对自己生命周期的声明。 manifest 如何把 app 注册进桌面 app-registry.ts 做了一件很关键的事：用 import.meta.glob eager 扫描 packages/web/src/apps/**/manifest.ts，然后把同一份 manifest 投影到多个运行时索引里。 flowchart LR G[import.meta.glob manifests] --> A[registerAppManifest] G --> B{supportedTypes?} B -->|yes| C[TYPE_TO_MANIFEST] G --> D{system pageIds?} D -->|yes| E[SYSTEM_TO_MANIFEST + SYSTEM_COMPONENT] G --> F{windowType?} F -->|yes| H[APP_WINDOW_TYPE_REGISTRY] F -->|yes| I[WINDOW_REGISTRY custom type] G --> J{icon/color/appName?} J -->|yes| K[Launchpad] 这让 shell 层保持干净：packages/web/src/system/window/window-registry.ts 一开始只是一个空对象，连 page 和 system 两个元类型也是由 app framework 注入。系统层只知道「type → lazy component」，不知道 VideoApp、FinderContent、TerminalContent 的存在。 更有意思的是 appName。WindowState.appName 存的是完整 i18n key，比如 dashboard.menu.video，不是翻译后的「视频」。渲染时统一走 resolveWindowAppName(win, t)：如果 t(appName) 找到翻译，就显示当前语言；如果找不到，就把它当字面量返回。文件窗口因此可以直接显示 notes.txt，而带 manifest 的 app 又能随语言切换。 server task restore │ ├─ 不信任持久化 appName ▼ manifest / registry 重新派生 appName │ ├─ i18n key: dashboard.menu.video → t() └─ literal : notes.txt → 原样显示 这避免了一个常见坑：如果把翻译后的字符串持久化，用户切换语言后，任务栏、Dock、菜单栏会同时出现旧语言和新语言。Tokimo 在 packages/web/src/system/window/window-sync.ts 里明确不从 server restore appName，而是从 manifest / registry 重新推导。 金句：持久化应该存身份，不应该存会随语言、版本和主题变化的显示结果。 文件窗口和 app 窗口如何共存 文件窗口看起来像「没有 manifest 的例外」，其实它们也走同一套 registry。比如 packages/web/src/apps/viewers/text/manifest.ts 把 windowType: "text" 注册成一个 category: "app" 的 viewer。打开文件时，OpenWindowParams 里携带 fileName、filePath、fileSystemId，WindowManager 会把 title 设为文件名；如果 manifest 没有 appName，最终 appName 也会落到 title。 flowchart TD F[open file: README.md] --> W[openWindow type=text] W --> V[WINDOW_REGISTRY.text] V --> C[TextViewer] W --> N{appName source} N -->|explicit param| E[params.appName] N -->|manifest has appName| M[manifest.appName] N -->|fallback| L[fileName/title] 这就是文件窗口和 app 窗口能共存的原因：shell 不问「你是不是应用」，只问「你的 type 有没有 registry entry」。Video、PDF、Image、Text 是 viewer；Terminal、Database 是自治工具；Transfer 是 popup；它们都只是不同 metadata shape 下的窗口。 Desktop Window Stack z=42 ┌──────────────────────────────┐ │ transfer: job progress │ popup / singletonKey z=31 ├──────────────────────────────┤ │ terminal: ssh session │ app custom windowType z=18 ├──────────────────────────────┤ │ system: finder pageId=finder │ system dispatcher z=07 ├──────────────────────────────┤ │ text: /docs/README.md │ file viewer └──────────────────────────────┘ 这里的统一不是「所有东西都继承同一个基类」，而是「所有窗口都遵守同一个调度协议」。 金句：桌面 shell 不需要理解业务，它只需要稳定地理解窗口协议。 跨窗口通信：bridge 不是全局事件总线 窗口一多，通信就会变难。最容易犯的错是把所有事情塞进全局事件总线：picker 选中文件、modal 取消、子窗口回传配置、后台任务更新，全都广播。短期快，长期灾难。 Tokimo 的 packages/web/src/system/window-bridge/ 选择了更窄的抽象：bridge 是进程内、一次性、按 id 定向的事件 registry。父窗口创建 bridge，把 bridgeId 放进 modal metadata；子窗口只向这个 bridge emit；窗口关闭时，WindowManager 自动发 system:close。 sequenceDiagram participant Parent participant Bridge participant WM as WindowManager participant Picker Parent->>Bridge: createBridge() Parent->>Bridge: subscribe("pick") / subscribe("system:close") Parent->>WM: openModalWindow(metadata.bridgeId) WM->>Picker: mount with win.metadata Picker->>Bridge: emitPick(win, selectedFile) Picker->>WM: closeWindow(win.id) WM->>Bridge: emit("system:close") Bridge-->>Parent: Promise resolve before cleanup 真实调用在 packages/web/src/apps/docs/pages/useDocsPage.ts：文档编辑器用 pickWithBridge<VfsFileSelection> 打开 VfsFilePickerWindow；子窗口在 packages/web/src/apps/docs/components/VfsFilePickerWindow.tsx 里 emitPick(win, file) 后关闭窗口。 Picker event flow Parent component ├─ pickWithBridge(openModalWindow, params) │ ├─ create bridge id │ ├─ subscribe pick │ └─ subscribe system:close │ └─ modal window metadata.bridgeId │ ▼ Picker window ├─ emitPick(win, value) ───────► resolve(value) └─ closeWindow(win.id) ────────► system:close fallback 机制 生命周期 适合数据 不适合什么 bridge 父子窗口之间，通常一次性 picker 返回值、modal 确认/取消 全局状态、跨刷新恢复 全局事件总线 进程内长期存在 主题切换、菜单栏局部广播 点对点请求/响应，容易误收 WebSocket 服务端事件流，可跨窗口同步 job_update、下载进度、窗口状态同步 单次 picker 选择，太重 这个边界很重要：bridge 解决的是「打开一个窗口，等它给我一个值」；WebSocket 解决的是「服务端状态变化，所有相关窗口都要知道」。两者不混用，系统复杂度会低很多。 金句：越临时的通信，越应该局部；越持久的状态，越应该有服务端来源。 业界正在做什么 VS Code Webview 把扩展 UI 放在隔离 webview 里，扩展贡献点决定它出现在哪里；Tokimo 的 manifest 更像桌面 shell 的贡献点，只是目标是窗口而不是编辑器面板。 Tauri 多窗口把窗口创建交给 Rust/系统 WebView，适合 native shell；Tokimo 反过来，在单浏览器进程里模拟窗口管理，所以 registry 和 metadata 比 OS window handle 更重要。 Electron 的 BrowserWindow 是进程级窗口抽象，强大但重；Tokimo 的 FloatingWindow 是 React 内的窗口抽象，轻，但必须自己处理 z-index、modal blocking、持久化和恢复。 Chrome Extension 用 manifest.json 声明权限、入口和 background/content scripts；Tokimo 的 AppManifest 也是声明式入口，不过它声明的是桌面 app 如何被 shell 调度。 金句：业界都在用 manifest 降低宿主和插件的耦合，只是宿主不同，manifest 的语义边界也不同。 从 0 加一个 Hello World app 仓库里已经有真实第三方样板：apps/tokimo-app-helloworld/。它不是前端假 demo，而是多进程 app：Rust binary 通过 UDS 暴露服务，主 server 反代 /api/apps/helloworld/*，UI bundle 通过 assets 加载。它的 tokimo-app.toml 是给主 server / shell 读的安装 manifest。 [app] id = "helloworld" display = "Hello World" version = "0.1.0" [ui] entry = "assets/index.js" window_type = "helloworld" category = "app" icon = "Sparkles" width = 720 height = 560 从 0 加一个 app，大致是这棵树： apps/tokimo-app-helloworld/ ├─ tokimo-app.toml # server/shell 安装描述 ├─ src/ # axum app server + handlers + db └─ ui/ ├─ src/index.tsx # default export mount(container, ctx) └─ dist/index.js # 被主 shell dynamic import 注册流程如下： flowchart TD T[tokimo-app.toml] --> S[tokimo-server installed-apps API] S --> D[InstalledAppDescriptor] D --> L[load-third-party.tsx] L --> M[synthetic AppManifest] M --> R[registerAppManifest] M --> W[registerWindowTypeManifest] M --> P[WINDOW_REGISTRY.helloworld] P --> A[Adapter lazy-imports assets/index.js] A --> U[third-party mount(container, ctx)] 注意这里有一个桥接动作：第三方 descriptor 的 category: "app" 在 load-third-party.tsx 里会被包装成 shell 可展示的 manifest，并注册到 Launchpad；真正打开窗口时用的是 windowType，不是 system pageId。这让第三方 app 可以像原生 app 一样出现在桌面，又不需要进入 packages/web/src/apps/ 的源码扫描。 如果是内置 Hello World，则更简单：在 packages/web/src/apps/hello/manifest.ts 写一个 AppManifest，给 id、category、windowType、component、defaultSize、icon/color/appName，app-registry.ts 会自动发现并注入 registry。差别只是：内置 app 编译进 shell；第三方 app 通过 installed-apps API 和 dynamic import 接入 shell。 金句：从框架视角看，内置 app 和第三方 app 的区别，不是能不能打开窗口，而是谁负责生成 manifest。 framework-level 的一句话 Tokimo 的 manifest 桌面模型，本质上是在浏览器里给「应用」补上宿主协议：声明身份、声明窗口、声明运行时，再让 shell 用 registry 把它们调度成可持久、可通信、可组合的桌面体验。