系统架构:知识工作区、代码工作区与受控执行
TJUClaw 从跨端客户端、Go API、知识库、Forgejo 代码工作区到加密附件与隔离沙箱的整体架构说明。
系统架构:知识工作区、代码工作区与受控执行
TJUClaw 的核心不是一个把所有东西都塞进聊天框的机器人。
用户在这里整理笔记、保存资料、配置智能体;智能体在需要时读取被授权的内容,进入隔离环境执行代码,再把可检查的结果交回来。不同类型的数据走不同的路径,权限也在每一层重新确认。
这件事听起来没有那么热闹,却很重要:正文、文件、代码历史、搜索索引和执行环境不能互相冒充。
一张图看懂
┌──────────────────────────────────────────────────────────────┐
│ Web / Desktop / Mobile │
│ React + Vite + Tauri │
│ │
│ 知识树 · Markdown 笔记 · Agent 会话 · 文件入口 · 设置 │
└──────────────────────────────┬───────────────────────────────┘
│ 同源 HTTPS /api/*
▼
┌──────────────────────────────────────────────────────────────┐
│ TJUClaw API │
│ Go stdlib net/http │
│ │
│ 身份校验 · 所有权 · 知识库 · 会话 · 模型网关 · Run 编排 │
└───────────────┬──────────────────┬───────────────────────────┘
│ │
│ ├──────────────► 模型服务
│ │ 公网 HTTPS / 受控网关
│ │
│ ├──────────────► WeKnora
│ │ 派生检索索引
│ │
│ └──────────────► Sandbox Controller
│ 临时密钥 · Agent · 工具
│
├──────────────► PostgreSQL
│ 账户归属 · 条目树 · 会话 · 发布关系
│
├──────────────► R2
│ 附件密文 · Git LFS 对象 · 交付物
│
└──────────────► Forgejo
代码工作区 · commit · checkpoint边缘网络只负责把客户端和 API 接到正确的入口。它不拥有用户身份,也不替 API 做资源授权。
先把三类数据分开
知识工作区
知识工作区是用户每天打开的产品界面。它有一棵条目树,笔记、智能体、工作环境说明和文件入口都可以放在同一棵树里。
| 条目 | 用途 | 权威数据 |
|---|---|---|
| 笔记 | Markdown 正文、标题和层级关系 | 产品 PostgreSQL |
| 智能体 | 人设、工具范围、知识范围和会话入口 | 条目记录与会话记录 |
| 工作环境 | 远程主机或执行环境的说明书 | 条目元数据 |
| 文件 | 图片、课件、代码包等二进制资料 | 条目元数据 + R2 对象 |
条目的 parent_id 决定用户看到的路径。对象存储的 object key、数据库行号和内部 blob ID 都不应该变成用户界面里的路径。
笔记正文保持为普通 Markdown。图片和二进制资料不写成 data URI,也不把对象存储地址硬编码进正文;正文只保留稳定的引用关系。
代码工作区
代码工作区是给真实 shell 和 Agent 使用的文件空间。它以 Forgejo Git 仓库为权威,而不是以某个沙箱实例的本地磁盘为权威。
一次执行至少绑定以下信息:
- 用户和知识库归属;
- 服务器选定的仓库;
- 完整的 commit SHA;
- 目标分支;
- 是否允许写回;
- Agent Profile、资源预算和截止时间。
沙箱可以拥有工作树,但不能自行选择仓库 URL、分支或能力。所有写回都通过受控的 commit-and-push 路径完成。
派生索引
全文索引、模糊搜索索引、Embedding、摘要和缩略图都是派生数据。它们可以被重建、替换和删除,不能反过来成为正文或权限的来源。
这条规则也适用于 WeKnora:
WeKnora 负责召回,不负责产品身份、知识库 ACL 或正文保存。
客户端:一个工作区,多种入口
客户端使用 React 组件和共享状态逻辑构建 Web、桌面与移动端。Tauri 负责原生宿主,Web 端和原生端复用同一套产品界面。
客户端有几条明确的边界:
- 浏览器只请求同源
/api/*,不直接连接数据库、Forgejo、R2 私有管理接口或 WeKnora。 - 认证凭据由浏览器自动携带 HttpOnly Cookie,JavaScript 不读取会话 Token。
- 条目树、笔记编辑器和会话页面只展示服务端授权后的投影。
- 外观、侧栏排序、标签页和设备偏好属于客户端状态,不冒充账号级业务数据。
- 编辑器只修改原始 Markdown,不为了预览而重写用户正文。
工作区的主流程是:
登录
↓
读取当前身份可以看到的知识库
↓
展开同一棵条目树
↓
打开笔记,或进入某个智能体条目的会话
↓
保存正文 / 发送消息 / 请求一次受控执行智能体不是第二套文件树。它看到的条目范围、能否写入,以及是否可以进入代码工作区,都由服务端授权结果决定。
API:身份和所有权的唯一裁判
Go API 使用标准库 net/http 组织路由。它不是模型推理服务,也不应该成为用户代码的执行主机。
API 主要负责五件事:
- 验证浏览器会话;
- 根据认证源返回的 identity 判定资源归属;
- 读写知识库、条目、会话、发布快照和模型配置;
- 把受控请求交给模型网关、检索服务或沙箱控制面;
- 记录 Run 状态、产物引用和清理结果。
同源认证
Kratos 是浏览器身份的权威来源。API 通过受保护的同源接口完成邮箱验证码、会话读取和退出;浏览器不会保存可读的 Bearer Token。
所有受保护请求都经过同一条检查链:
Cookie
↓
同源与 Origin 校验
↓
向身份服务确认 Session
↓
检查会话有效期与邮箱验证状态
↓
使用 identity.id 作为 owner_id
↓
读取或修改资源客户端传来的 user_id、owner_id 或类似字段不能改变归属。访问别人的私有资源时,API 使用与“资源不存在”一致的响应,避免通过状态码差异枚举数据。
存储分层
产品 PostgreSQL 保存需要事务和关系约束的数据:
- 用户可见的知识库和条目;
- 条目树的父子关系;
- Agent 会话与消息;
- 模型配置和产品额度;
- 发布快照、撤回状态和只读接入关系;
- 文件对象的元数据。
文件字节不应因为“方便”就塞进每一次 API 响应。小规模本地开发可以使用 FileStore;生产环境使用 PostgreSQL 保存元数据,并由对象存储承载大文件。
存储驱动可以更换,但业务契约不能跟着环境变化。FileStore 和 PostgreSQL 都必须遵守相同的归属、只读快照、路径和大小限制。
稳定的错误边界
API 向客户端返回稳定的机器错误码,例如:
{
"error": {
"id": "library_read_only"
}
}上游模型、数据库、Git、对象存储和 Kubernetes 的内部错误不直接穿过浏览器。日志也不记录 Cookie、授权头、验证码、模型 Key、完整 Prompt 或附件正文。
模型网关:让模型能工作,但不能变成任意代理
用户可以配置一个 OpenAI 兼容的公网 HTTPS 上游,也可以使用产品提供的模型额度。
API 在保存和使用自定义模型时都要验证:
- 只接受 HTTPS;
- 拒绝回环、RFC1918、链路本地、云元数据和内部域名;
- 校验 DNS 解析结果,防止域名解析到受保护网段;
- 限制请求体、响应体、超时和重定向;
- 不把 API Key 返回客户端,也不写入条目、会话或模型上下文。
这个网关只支持产品需要的模型协议。它不是一个让调用者自由拼接目标地址的 HTTP 代理。
Agent 工具调用同样经过服务端定义的工具面。第一层工具可以只操作当前知识库的条目:列出树、创建条目、改标题、改 Markdown、移动和删除。工具结果回到会话历史,但不会把内部凭据或对象 key 交给模型。
代码执行:Forgejo 是历史,沙箱是一次工作树
当一次会话需要真实 shell、编译器或大量文件时,API 不在自己的进程里执行命令,而是把已经授权的请求交给沙箱控制面。
启动顺序
API 根据身份和会话确定 workspace
↓
解析固定 Forgejo 仓库与最新允许 commit
↓
创建 Run,绑定 Profile、资源、期限和工具范围
↓
沙箱创建隔离执行边界
↓
拉取指定 commit,校验 origin 与 HEAD
↓
注入必要的短期能力
↓
启动 Pi 和真实 shell
↓
收集 stdout、stderr、产物清单和资源用量
↓
可写工作区按步骤 checkpoint commit 并 push
↓
清理沙箱和临时凭据沙箱工作区使用固定布局:
/workspace/
├── repo/ # 指定 Forgejo commit 的代码工作树
├── inputs/ # 任务输入,只读
├── scratch/ # 依赖和中间文件,执行结束丢弃
└── outputs/ # 可收集交付物,其他路径不进入产物/workspace/repo 可以被 Agent 修改,但每一步有意义的变更都应该尽快形成可追踪的 checkpoint。不能把未推送的 .git 历史交给一个可能随时被回收的沙箱实例。
权限和凭据
沙箱不接收:
- Kratos Cookie;
- API Key;
- 数据库凭据;
- 长期校园会话;
- 集群管理员凭据;
- 未限定范围的工具 Token。
Git 访问通过短期 askpass 或同等受控通道完成。工具调用使用绑定到 Run 的短期 grant,服务端代理每次检查 Run、scope 和过期时间。
控制面和执行面也要分开:控制面可以创建、观察和清理工作负载;用户代码不能借此取得 Kubernetes API 或宿主机文件系统权限。
加密工作区:先分清浏览器私密笔记与沙箱工作树
当前「Git 笔记」有两个互不自动迁移的入口。普通 Markdown 写入 Forgejo 工作树, Agent 在沙箱内可以读取,尚未实现全仓库端到端加密;旧知识库条目仍存在 PostgreSQL 中,也不能因设置了工作区口令就视作已加密。工作区口令验证访问 并不等于对这些旧数据加密。
「私密笔记」使用另一组 Forgejo 密文对象:标题清单和正文在浏览器用工作区口令
加密后,通过同源 /api/vault/objects/{id} 保存。明文不发往沙箱,也不进入
服务端搜索或 Agent 上下文。此模式目前只覆盖纯文本私密笔记,不加密
旧笔记的原件、附件和整个 Git 仓库。用户可手动把单篇旧 Markdown 复制到
密文对象并回读核对,原件仍留在原资料库;目录、富文本、附件与会话不会迁移。
Forgejo 仍可观察对象数量、大小和更新时间;
浏览器运行环境及所加载的脚本必须可信,服务端若能替换客户端代码,也可能
窃取用户在页面中输入的口令。不要把这个能力等同于防范服务运营方的完全零知识。
目标架构:沙箱可读的加密代码仓库
如果代码仓库本身也属于敏感资料,传输层 HTTPS 和 Forgejo 的磁盘加密还不够。目标架构采用应用层加密,让 Forgejo 和普通对象存储只接触密文。
用户授权一次 Run
↓
Key Broker 根据用户、仓库、commit、Run 和 TTL 签发短期解密能力
↓
沙箱拉取密文仓库
↓
在内存或受限临时目录中解包、解密指定 commit
↓
Agent、编译器和本地索引只访问明文工作树
↓
输出和 checkpoint 按同一策略加密后写回
↓
Run 结束,密钥、明文目录和索引一起销毁这个目标与浏览器私密笔记的信任边界不同:一旦把解密能力交给沙箱, 沙箱运营方及其执行的 Agent 就有机会接触明文,不能再承诺它们无法读取。 任何自动化读取都应取得用户明确授权,并单独限制有效期、范围和日志。
密钥注入不应使用普通环境变量、命令行参数或会被日志采集的配置文件。更合适的通道是受限 Unix socket、memfd、一次性文件描述符或权限为 0600 的短生命周期文件。
密钥至少要绑定以下上下文:
- 用户身份;
- 仓库和知识库;
- 允许的 commit;
- Run ID 和沙箱实例;
- Agent Profile;
- 过期时间和一次性 nonce。
这样,泄露一个 Run 的能力不会自然变成整个仓库或整个账号的长期能力。
为什么不在 Forgejo 上继续做明文模糊搜索
严格的端到端加密和服务器明文代码搜索不能同时成立。服务器没有明文,就不能构建普通的全文或模糊索引。
搜索因此移动到已经获授权的沙箱或专用可信执行面:
密文 commit
↓ 只在授权沙箱中解密
明文文件
↓ 首次建立 trigram / n-gram / 语言索引
本地搜索索引
↓ 后续查询只访问索引
模糊搜索结果索引性能的关键不在于“每次搜索都重新解密”,而在于把解密和索引建立变成一次性的物化过程:
- 同一个 commit 只建立一次索引;
- 保存已索引的 commit、文件摘要和索引版本;
- 只对
diff中新增或修改的文件增量更新; - 删除文件时删除对应文档或写入 tombstone;
- 查询阶段不重新解密整个仓库;
- 如果索引需要跨 Run 保存,就使用独立的索引密钥,不复用仓库主密钥。
这会把成本从“每次查询都付一次解密”变成“首次 materialize 和变更时付一次索引成本”。模糊搜索的热路径仍然是本地索引,而不是 Git、Forgejo 或 R2。
R2 附件:加速下载不等于明文存储
R2 适合承载附件、Git LFS 对象和任务产物,但默认的静态加密不等于应用层端到端加密。对于私有附件,目标是让 R2 成为高速的密文传输层,而不是权限裁判或明文处理器。
私有附件的路径
客户端或授权沙箱
├─ 生成随机 file DEK
├─ 按 chunk 使用 AEAD 加密
├─ 用用户/工作区公钥封装 DEK
└─ 通过短期授权上传密文到 R2
R2
└─ 保存密文对象和加密 manifest
下载端
├─ 获取短期 GET 授权
├─ 获取被授权的 wrapped DEK
├─ 通过 R2/CDN 下载密文 chunk
└─ 在客户端或授权沙箱中解密附件不宜把整份文件加密成一个不可分段的 blob。分块 AEAD 可以保留:
- Range 请求;
- 断点续传;
- 并发下载;
- 大文件的渐进加载;
- 视频、音频和 PDF 的局部读取。
每个文件使用独立的 file_dek,文件密钥轮换时只重新封装密钥,不重新加密整份文件。文件名、真实路径和权限信息放在 manifest 或产品元数据中;R2 object key 使用随机对象 ID,不直接暴露用户目录。
公开附件和私有附件
公开发布的资料可以选择普通 R2 对象,但公开的决定必须发生在产品发布流程里,不能因为“知道 URL”就自动公开。
私有附件需要:
- 短期下载授权;
- 对象级所有权检查;
- 不把密钥放进 URL;
- 不把原始文件名作为对象 key;
- 下载、解密和预览都记录在明确的权限范围内。
R2 和 CDN 不负责病毒扫描、缩略图、转码、PDF 提取或全文检索。需要看见明文的处理应在客户端或已经授权的执行面完成;一旦由边缘 Worker 解密,Worker 就属于明文信任边界。
检索:正文和索引各司其职
TJUClaw 的检索分成两条路径。
知识库检索
笔记正文和被授权的资料进入派生索引。检索引擎保存文档 ID、标题、片段和必要的向量或全文特征,但权限判断仍回到 Go API。
删除、撤回或取消接入时,检索索引必须同步删除或标记不可见。搜索结果不能绕过知识库的所有权和只读快照规则。
代码工作区检索
敏感代码不进入公共 WeKnora,也不由 Forgejo 构建服务器明文索引。它在授权沙箱中完成本地索引,索引生命周期与仓库 commit、Run 或受控工作区绑定。
两类检索都遵守同一个原则:
索引是为了更快地找到内容,不是为了扩大内容的可见范围。
发布与接入:分享的是快照,不是编辑权
知识库发布生成不可变快照。其他用户接入后获得只读范围,不获得原库的写权限,也不能借此访问所有者的会话、模型配置、工具 grant、私有附件或工作环境凭据。
公开快照应当是一次明确的投影:
- 包含所有者选择公开的条目;
- 保留必要的标题、正文、层级和公开附件引用;
- 去除会话、模型 Key、内部 object ID、执行记录和私有配置;
- 撤回后不再出现在市场和新的读取路径中。
发布前的检查不是装饰。对用户来说,真正需要确认的是:这份资料会被谁看到,文件是否真的应该公开,以及正文里有没有把凭据或私人信息带出去。
失败时,数据应该落在哪里
系统把“能不能重试”和“什么是权威”分开处理。
| 失败位置 | 可重试对象 | 权威数据 |
|---|---|---|
| 客户端请求超时 | API 请求或页面读取 | PostgreSQL / R2 |
| 模型上游失败 | 当前会话轮次 | 已保存的会话消息 |
| 沙箱创建失败 | Run 或会话确保操作 | API 的 Run 状态 |
| 沙箱被回收 | 从指定 commit 重新建立工作树 | Forgejo commit |
| 索引构建失败 | 派生索引任务 | 原始笔记或代码 commit |
| 附件下载中断 | 当前 chunk | R2 密文对象 |
| 产物收集失败 | 重新收集 allowlist 路径 | 沙箱 Run 记录与 checkpoint |
persist/restore、本地缓存、搜索索引和模型上下文都不能取代 PostgreSQL、Forgejo 或 R2 这些权威来源。
这套架构刻意不做什么
- 不让 API Pod 直接执行用户生成的任意代码。
- 不让浏览器直接拥有数据库、Forgejo 管理接口或 WeKnora Key。
- 不把 WeKnora 当成用户身份或知识库 ACL。
- 不把 R2 object key 当成用户路径。
- 不把模型网关做成任意 URL 转发器。
- 不把沙箱本地磁盘当成代码历史。
- 不用服务端明文搜索去抵消端到端加密。
- 不把用户文件、Agent 会话和公开采集库混成一份数据。
架构的目标不是让每一层都知道所有事情,而是让每一层只知道完成自己工作所需的那一小部分。
当用户打开一篇笔记时,系统应当足够快;当用户交给 Agent 一份代码时,沙箱应当足够自由;当数据离开用户授权范围时,每一层都应当知道自己不该再往前走。