1332 lines
66 KiB
Markdown
1332 lines
66 KiB
Markdown
# WebUI 会话级工作目录隔离修改方案
|
||
|
||
> 版本:1.4 | 日期:2026-07-18 | 所属项目:EvoScientist-WebUI / EvoScientist
|
||
> 状态:实施中。WebUI BFF、会话 scope 解析和 cutover owner 对账已实现;切换 `required`
|
||
> 前仍须在目标部署执行全量 cutover 并验证运行环境。
|
||
|
||
## 1. 决策摘要
|
||
|
||
当前 WebUI 和 EvoScientist 后端共同使用部署级固定工作目录。文件上传、文件树、
|
||
Agent 文件工具和后台命令都可能在同一个物理目录中读写,因此不同对话之间没有
|
||
文件边界。
|
||
|
||
本方案将工作目录改为按主对话隔离:
|
||
|
||
```text
|
||
一个主 LangGraph thread
|
||
-> 一个不可变 workspace_scope_id
|
||
-> 一个独立、可写的物理工作目录
|
||
```
|
||
|
||
推荐目录结构:
|
||
|
||
```text
|
||
<deployment-workspace>/
|
||
.evoscientist/
|
||
conversations/
|
||
<workspace_scope_id>/
|
||
files/ # Agent 看到的虚拟根目录“/”
|
||
runtime/ # 后台任务日志等内部运行数据
|
||
tombstone.json # 仅在删除或回收过程中出现
|
||
trash/ # 已删除对话的延迟清理目录
|
||
```
|
||
|
||
核心行为:
|
||
|
||
- 对话 A 的上传文件和 Agent 生成文件只进入 A 的 `files/`。
|
||
- 对话 A 的 WebUI 文件接口和 Agent 工具不能列出、读取或修改对话 B 的文件。
|
||
- 同一对话刷新、重新打开、审批恢复和断线恢复后继续使用原目录。
|
||
- 同步子代理、异步子代理、Memory Worker、后台进程和定时任务继承主对话的
|
||
`workspace_scope_id`,不能按自己的内部 thread ID 新建目录。
|
||
- `/skills/` 和 `/memories/` 保持显式共享,不混入私有工作目录。
|
||
- 浏览器不传 scope 或物理目录;scope、运行配置和物理路径均由服务端计算。
|
||
- `required` 模式的 shell 和后台命令在容器化执行器中运行,只挂载当前 scope 的
|
||
`files/`;现有宿主机 `LocalShellBackend` 不构成严格隔离边界。
|
||
|
||
本方案解决的是“对话之间的文件隔离”。它不是多用户 ACL,也不会自动隔离共享
|
||
Memory、Skills、模型上下文或拥有独立文件系统权限的 MCP 工具。
|
||
|
||
## 2. 目标和非目标
|
||
|
||
### 2.1 目标
|
||
|
||
1. 上传文件、Agent 生成文件和后台任务输出按主对话隔离。
|
||
2. WebUI 文件树、预览、编辑、删除、下载和容量统计只操作当前对话目录。
|
||
3. 所有 run 和 resume run 使用同一个稳定 scope。
|
||
4. 主代理及其子代理共享当前对话文件,但不能访问其他对话文件。
|
||
5. 目录计算、路径校验、进程归属和删除操作可自动化测试。
|
||
6. 支持现有公共 workspace 平滑迁移,不把旧文件复制到所有对话。
|
||
7. 在并发执行多个对话时不依赖可变全局工作目录。
|
||
|
||
### 2.2 非目标
|
||
|
||
第一阶段不处理:
|
||
|
||
- 新增用户、组织、角色或多租户权限系统。
|
||
- 把 `threadId` 当作用户认证凭据。
|
||
- 自动判断公共 workspace 中每个历史文件属于哪个旧对话。
|
||
- 默认复制公共项目源码到每个新对话。
|
||
- 隔离 `/skills/`、`/memories/` 中的共享内容。
|
||
- 为允许任意真实文件系统访问的 `dangerous_mode` 提供隔离承诺。
|
||
- 在未审计的 MCP 文件工具外部强行建立可靠的文件边界。
|
||
|
||
## 3. 当前实现与问题
|
||
|
||
### 3.1 WebUI 使用部署级目录
|
||
|
||
`src/lib/server/workspace.ts` 的 `getWorkspaceDir()` 通过 sidecar、环境变量或默认值
|
||
解析一个部署级工作目录。它没有接收 thread 或 conversation scope。
|
||
|
||
现有 Workspace API 均直接使用这个目录:
|
||
|
||
```text
|
||
src/app/api/workspace/route.ts
|
||
src/app/api/workspace/file/route.ts
|
||
src/app/api/workspace/upload/route.ts
|
||
src/app/api/workspace/download/route.ts
|
||
```
|
||
|
||
例如上传接口把文件直接写入 `getWorkspaceDir()` 返回的根目录,文件树接口也会列出
|
||
同一个根目录。当前列表响应还返回真实 `dir`,隔离后必须删除这个字段。
|
||
|
||
### 3.2 上传发生时可能还没有 thread
|
||
|
||
`ChatInterface` 允许 New Chat 在发送第一条消息前上传文件,但 `useChat` 目前只在
|
||
创建第一个 run 时调用 `client.threads.create()`。因此单纯要求上传接口携带
|
||
`threadId` 会破坏 New Chat 上传流程。
|
||
|
||
本方案通过“首次上传或首次发送前预创建草稿 thread”解决,不使用临时上传目录和
|
||
后续搬迁。
|
||
|
||
### 3.3 Agent backend 在启动时固定根目录
|
||
|
||
`../EvoScientist/EvoScientist/EvoScientist.py` 的 `_get_default_backend()` 当前在 graph
|
||
构建时创建:
|
||
|
||
```python
|
||
CustomSandboxBackend(root_dir=WORKSPACE_ROOT)
|
||
```
|
||
|
||
graph 会跨请求复用。不能在每个 run 开始前修改全局 `WORKSPACE_ROOT` 或调用全局
|
||
`set_active_workspace()` 切换目录,否则对话 A、B 并发时会发生目录串用。
|
||
|
||
### 3.4 部分执行面绕过 Agent 文件 backend
|
||
|
||
`../EvoScientist/EvoScientist/middleware/background.py` 当前通过全局
|
||
`paths.resolve_virtual_path("/")` 计算后台命令 cwd。后台进程查询和停止也没有完整的
|
||
会话所有权校验。
|
||
|
||
异步子代理会创建独立的内部 LangGraph thread。若直接使用当前运行的 `thread_id`
|
||
作为工作目录,它会与父对话分离;正确行为是继承父对话的 `workspace_scope_id`。
|
||
|
||
定时任务同样可能在新的 scheduler thread 中执行,必须保存创建任务时的 scope。
|
||
|
||
## 4. 隔离边界和不变量
|
||
|
||
实现必须持续满足以下不变量:
|
||
|
||
1. 物理路径只能由可信部署根目录和服务端校验后的 scope 计算。
|
||
2. 主对话的 `workspace_scope_id` 创建后不可修改。
|
||
3. UI 选择其他对话只会切换当前 scope,不会合并目录。
|
||
4. 子代理、Memory Worker、Scheduler 的内部 thread ID 不改变文件 scope。
|
||
5. 对话 B 访问对话 A 的文件时返回 `404`,不暴露文件是否存在。
|
||
6. `..`、绝对路径、控制字符和符号链接均不能逃出当前 scope。
|
||
7. 缺少、冲突或由浏览器伪造的 scope 不能进入严格模式 run,也不能回落到公共根目录。
|
||
8. WebUI API 不返回部署物理路径,且文件系统根与 LangGraph API 必须属于同一 deployment。
|
||
9. 删除、下载、容量统计、Memory Worker、后台进程和定时任务使用与文件读写相同的 scope。
|
||
10. 任何请求路径不得调用全局 `paths.resolve_virtual_path()` 或可变的 `set_active_workspace()`
|
||
来解析当前对话文件。
|
||
11. 开启严格隔离时禁止 `dangerous_mode`。
|
||
|
||
这里的“其他对话不能查看”包含两个层次:
|
||
|
||
- Agent 边界:对话 A 中的模型和工具不能看到 B 的文件。
|
||
- WebUI 边界:当前打开 A 时,Workspace 面板和文件 API 只能访问 A。
|
||
|
||
当前项目是单用户可信部署。用户主动切换到对话 B 后仍可查看 B 自己的文件,这是
|
||
正常行为。若未来支持多用户,还必须增加 `user_id -> thread_id` 所有权校验,不能仅
|
||
依赖 thread ID。
|
||
|
||
## 5. 标识和数据模型
|
||
|
||
### 5.1 使用独立的 workspace_scope_id
|
||
|
||
新增运行字段:
|
||
|
||
```text
|
||
workspace_scope_id
|
||
```
|
||
|
||
每个主对话都在后端 Scope Registry 中有一条不可变映射:
|
||
|
||
```text
|
||
deployment_id + primary_thread_id -> workspace_scope_id
|
||
```
|
||
|
||
新对话由服务端生成 UUIDv7 scope。实现可以复用该 UUID 作为新建 thread ID,但不能把
|
||
两者相等视为安全条件;旧 thread 的 scope 由迁移流程生成,可能与原 thread ID 不同。
|
||
|
||
仍使用独立字段而不是到处直接读取 `thread_id`,原因是:
|
||
|
||
- 异步子代理拥有自己的 thread ID,但必须继承父目录。
|
||
- Scheduler thread 不等于创建定时任务的对话。
|
||
- 未来复制或分支对话时可以明确选择新建、复制或共享策略。
|
||
- 日志和测试可以区分“执行 thread”和“文件所有者”。
|
||
|
||
scope 仅由服务端生成,始终为标准 UUID。目录名不使用标题、模型输出、浏览器输入或
|
||
原始 thread ID;客户端传入的 thread ID 只用于查询 Registry 映射。
|
||
|
||
### 5.2 Thread metadata
|
||
|
||
主 thread metadata 增加:
|
||
|
||
```json
|
||
{
|
||
"workspace_schema_version": 1,
|
||
"workspace_scope_id": "018f...",
|
||
"workspace_status": "draft",
|
||
"assistant_id": "..."
|
||
}
|
||
```
|
||
|
||
状态定义:
|
||
|
||
| 状态 | 含义 |
|
||
| ---------- | ------------------------------------ |
|
||
| `draft` | 已为上传预创建,但尚未发送第一条消息 |
|
||
| `active` | 已发送消息,正常显示在历史列表 |
|
||
| `deleting` | 正在删除,拒绝新的文件和 run 操作 |
|
||
|
||
Thread metadata 是对 LangGraph 的可查询镜像;后端 Scope Registry 才是 scope 归属、
|
||
状态和派生任务所有权的权威来源。两者必须保持同一 `workspace_scope_id` 和状态;不
|
||
一致时严格模式失败关闭并由修复任务处理。
|
||
|
||
### 5.3 Scope Registry
|
||
|
||
v1 将 Registry 固定实现为 EvoScientist 进程可访问、Agent 永不挂载的 SQLite 数据库:
|
||
|
||
```text
|
||
<deployment-workspace>/.evoscientist/control/scope-registry.sqlite3
|
||
```
|
||
|
||
目录权限为 `0700`、数据库文件为 `0600`。复用项目已有的 `aiosqlite` 访问方式,启动时
|
||
设置 `foreign_keys=ON`、WAL journal 和 30 秒 busy timeout。v1 的 `required` 模式仅支持
|
||
单主机、单个 EvoScientist deployment;部署清单必须显式声明
|
||
`EVOSCIENTIST_SCOPE_REGISTRY_TOPOLOGY=single-host`,否则拒绝启动 `required`。多副本或
|
||
跨主机部署必须等待后续 PostgreSQL Registry adapter,不能把 SQLite 放在网络共享盘上。
|
||
|
||
WebUI 通过服务端凭据调用内部 API,浏览器不能直接读写 Registry。数据库迁移使用
|
||
`PRAGMA user_version`,由 `scope_registry.py` 在后端启动时顺序执行;迁移失败时后端不
|
||
提供 Workspace 或 run 服务。
|
||
|
||
```text
|
||
scopes
|
||
deployment_id, scope_id, primary_thread_id, state, revision,
|
||
created_at, updated_at, deleted_at
|
||
PK (deployment_id, scope_id)
|
||
UNIQUE (deployment_id, primary_thread_id)
|
||
|
||
scope_owners
|
||
deployment_id, owner_id, scope_id, owner_type, resource_id,
|
||
parent_owner_id, state, created_at, updated_at, terminal_at
|
||
PK (deployment_id, owner_id)
|
||
FK (deployment_id, scope_id) -> scopes
|
||
UNIQUE (deployment_id, owner_type, resource_id) WHERE resource_id IS NOT NULL
|
||
|
||
scope_run_requests
|
||
deployment_id, scope_id, run_request_id, turn_id, interrupt_key,
|
||
request_hash, run_owner_id, run_id, state, created_at, updated_at
|
||
PK (deployment_id, scope_id, run_request_id)
|
||
UNIQUE (deployment_id, scope_id, interrupt_key) WHERE interrupt_key IS NOT NULL
|
||
|
||
scope_operations
|
||
deployment_id, operation_id, scope_id, kind, expected_revision, state,
|
||
external_resource_id, result_sha256, last_error_code, created_at, updated_at
|
||
PK (deployment_id, operation_id)
|
||
INDEX (deployment_id, scope_id, state)
|
||
scope_id 仅 deployment 级 cutover operation 可为 NULL
|
||
|
||
deployment_locks
|
||
deployment_id, lock_name, operation_id, expires_at, created_at
|
||
PK (deployment_id, lock_name)
|
||
```
|
||
|
||
`owner_type` 至少覆盖 `primary_thread`、`async_thread`、`memory_worker`、`cron`、
|
||
`background_process` 和 `run`。所有派生任务在创建前先登记 owner;删除时通过该表
|
||
查询、取消并等待全部 owner 终态。owner 的状态只能为 `reserved`、`active`、`draining`、
|
||
`terminal`、`failed` 或 `quarantined`,且 `terminal`、`quarantined` 不能回到 active。Registry
|
||
scope 的状态迁移为:
|
||
|
||
```text
|
||
provisioning -> draft -> active -> deleting -> deleted
|
||
```
|
||
|
||
创建、迁移、进入 `deleting` 和登记 owner 均使用 `BEGIN IMMEDIATE` 事务。状态转换使用
|
||
`UPDATE ... WHERE revision = :expected_revision AND state IN (...)`,影响行数不是 1 时返回
|
||
冲突;成功时 revision 加 1。owner 先以 UUID `owner_id` 预登记,再写入外部 `resource_id`
|
||
(thread、run、cron 或 container ID);同一外部资源插入到其他 scope 时由唯一索引拒绝。
|
||
|
||
LangGraph、文件系统和 Registry 没有跨系统事务。`provision`、run 创建、cron 创建和删除
|
||
都以 `scope_operations` 的 UUID `operation_id` 记录,按“预留 -> 外部副作用 -> 提交/补偿”
|
||
执行。operation 状态只能按 `reserved -> applying -> completed` 或
|
||
`reserved/applying/failed -> compensating -> compensated` 转换;恢复任务只处理非终态
|
||
operation,并依据其 kind、external resource 和 last error 做幂等补偿。`turn_id` 表示一个
|
||
逻辑用户回合;每次 `runs.create` 使用新的 `run_request_id`。同一 `run_request_id` 的
|
||
`request_hash` 必须一致才返回既有 run,不一致返回 `409 idempotency_key_conflict`;同一
|
||
`turn_id` 的不同请求 ID 是合法的审批或追问恢复。带 `interrupt_key` 的 resume 对同一 scope
|
||
只能预留一次,重复决定返回 `409 interrupt_already_resolved`。后台修复任务可依据 thread
|
||
metadata、cron metadata 和运行记录补全已知 owner,但不能把未知 owner 当作可安全删除。
|
||
|
||
Registry 由 EvoScientist 提供仅服务端可调用的内部接口,使用独立服务凭据:
|
||
|
||
```text
|
||
POST /internal/workspace-scopes/provision
|
||
GET /internal/workspace-scopes/by-thread/<threadId>
|
||
POST /internal/workspace-scopes/<scopeId>/owners
|
||
PATCH /internal/workspace-scopes/<scopeId>/owners/<ownerId>
|
||
GET /internal/workspace-scopes/<scopeId>/owners/by-resource/<resourceId>
|
||
POST /internal/workspace-scopes/<scopeId>/runs/reserve
|
||
PATCH /internal/workspace-scopes/<scopeId>/runs/<runRequestId>
|
||
PATCH /internal/workspace-scopes/<scopeId>
|
||
```
|
||
|
||
每个接口只接受 loopback/private 网络上的 `Authorization: Bearer
|
||
EVOSCIENTIST_BACKEND_SERVICE_TOKEN`;`deployment_id` 从后端自身配置读取,不能由请求体、
|
||
header 或 WebUI 选择。所有写接口记录 `operation_id` 并校验预期 revision。
|
||
|
||
创建新对话时,BFF 先建立 `provisioning` Registry reservation,再创建携带 scope metadata
|
||
的 LangGraph thread 和 `files/` 目录,最后原子提交为 `draft`。任一步失败都删除已创建
|
||
资源或保留可重试的 provisioning record,绝不把半成品视为 active。
|
||
|
||
### 5.4 Run config 和 metadata
|
||
|
||
新 run、resume run 和异步子代理 run 都显式携带:
|
||
|
||
```json
|
||
{
|
||
"config": {
|
||
"configurable": {
|
||
"workspace_scope_id": "018f...",
|
||
"workspace_scope_owner_id": "primary-thread-or-internal-owner-id",
|
||
"workspace_scope_revision": 4
|
||
}
|
||
},
|
||
"metadata": {
|
||
"workspace_scope_id": "018f...",
|
||
"workspace_scope_owner_id": "primary-thread-or-internal-owner-id"
|
||
}
|
||
}
|
||
```
|
||
|
||
`configurable` 用于运行时 backend 解析,metadata 用于异步派生、恢复、审计和故障
|
||
排查。两者都存在时必须一致,否则拒绝运行。`workspace_scope_owner_id` 是 Registry
|
||
创建的内部 owner ID:主图使用 primary thread owner,异步线程和 Memory Worker 在创建
|
||
内部 thread 前登记,cron 在创建前使用预生成 owner ID。浏览器不能提供该字段。
|
||
|
||
浏览器不能自行决定该字段。严格模式中的 thread 创建、主 run 和 resume run 都经过
|
||
WebUI 服务端对话服务;服务端从已持久化的主 thread 记录读取 scope,再注入 run
|
||
config。浏览器只传白名单业务字段(消息、审批结果、上传文件、`turn_id`、
|
||
`run_request_id`、可选 `interrupt_key`、审核模式和模型选择),不传
|
||
`workspace_scope_id`、owner、metadata 或运行 config。
|
||
|
||
### 5.5 Scope 权威性
|
||
|
||
主图在后端必须再次校验,而不能只相信 WebUI:
|
||
|
||
```text
|
||
主图:Registry(scope_id).primary_thread_id == runtime 的实际 thread_id
|
||
派生图:Registry 中存在 active owner,且 config、metadata、owner 与 scope 一致
|
||
```
|
||
|
||
主图的实际 thread ID 由 LangGraph runtime 提供,不从浏览器请求体读取。异步子代理、
|
||
Memory Worker 和 Scheduler 可拥有不同内部 thread ID,但其 scope 只能由已登记的
|
||
父 owner 传播。严格模式中,缺少 Registry 记录、scope 正在 `deleting` 或出现不一致
|
||
时一律失败。
|
||
|
||
`require_scoped_runtime()` 在每次 graph 运行和每次直接文件工具调用时读取 Registry:
|
||
校验 deployment、scope、owner、runtime thread 与状态。运行配置中的 revision 只用于
|
||
诊断;Registry 当前状态才是最终判断,因而删除开始后持有旧 revision 的任务不能继续
|
||
访问目录。内存缓存只能保存已构造的 backend 以减少重复初始化,不能作为授权结果;每次
|
||
实际文件或命令操作仍须先通过 Registry 校验。
|
||
|
||
## 6. 目录解析设计
|
||
|
||
### 6.1 Deployment 绑定和服务端对话服务
|
||
|
||
浏览器不得选择 LangGraph deployment URL。WebUI 启动后会清理旧版 localStorage 配置;
|
||
所有对话、模型、健康检查和 Workspace 请求都从服务端 `ActiveDeployment` 解析同一
|
||
sidecar/服务端环境配置,严格隔离不会混用两个 deployment。
|
||
|
||
WebUI 服务端新增不可由浏览器覆盖的 `ActiveDeployment` 解析器:
|
||
|
||
```ts
|
||
interface ActiveDeployment {
|
||
deploymentId: string;
|
||
langgraphApiUrl: string;
|
||
threadClient: ServerOnlyLangGraphClient;
|
||
scopeRegistry: ServerOnlyScopeRegistryClient;
|
||
workspaceRoot: string;
|
||
}
|
||
```
|
||
|
||
部署启动器写入或更新 sidecar 时,同时记录 deployment ID、后端 API 地址和 workspace
|
||
根目录。WebUI 只信任该 sidecar 或显式服务端环境变量,并验证后端健康状态与
|
||
deployment ID 一致;浏览器永不接收或提交 deployment URL、上游 API key 或 assistant
|
||
选择,因而不能决定 Workspace API、thread 创建、run 创建或删除所使用的 deployment。
|
||
|
||
严格模式采用固定拓扑,不能让浏览器拥有 LangGraph 凭据:
|
||
|
||
```text
|
||
Browser
|
||
-> WebUI BFF(同源认证)
|
||
-> LangGraph / EvoScientist(私网或 loopback + 服务端凭据)
|
||
```
|
||
|
||
LangGraph API 必须绑定私网或 loopback,并要求仅存于 WebUI 与 EvoScientist 服务端环境
|
||
或同机 workspace 的 `.evoscientist/control/scope-service-token`(`0600`)中的服务凭据。
|
||
后者仅由 WebUI BFF 在本机读取,绝不进入 sidecar、响应、浏览器 localStorage 或日志。
|
||
`NEXT_PUBLIC_LANGSMITH_API_KEY`、浏览器 localStorage 中的 API key 和浏览器直连 `Client`
|
||
在 `required` 模式均禁止使用。sidecar 原子写入且权限为 `0600`,不包含服务凭据。
|
||
|
||
本方案仍是单用户文件隔离。`required` 模式下,WebUI 绑定非 loopback 地址时必须启用
|
||
登录认证;未认证的远程 BFF 启动失败。loopback 单用户部署可沿用本机可信前提。多用户
|
||
场景还需要在 BFF 中校验 `user_id -> primary_thread_id` 所有权,不属于本期范围。
|
||
|
||
新增服务端对话接口:
|
||
|
||
```text
|
||
GET /api/conversations # 历史列表
|
||
POST /api/conversations # 创建 draft thread
|
||
GET /api/conversations/<threadId> # 已脱敏的 thread 状态与历史
|
||
PATCH /api/conversations/<threadId> # title、pinned、model_override
|
||
PUT /api/conversations/<threadId>/file-state # 已上传文件的会话附件状态
|
||
GET /api/conversations/<threadId>/export # 下载已脱敏的完整对话 JSON
|
||
DELETE /api/conversations/<threadId> # 删除 thread 和 scope
|
||
POST /api/conversations/<threadId>/runs # 创建主 run 或 resume run
|
||
GET /api/conversations/<threadId>/runs # 列表、状态和恢复发现
|
||
GET /api/conversations/<threadId>/runs/<runId> # 单个主 run 状态
|
||
POST /api/conversations/<threadId>/runs/<runId>/cancel
|
||
GET /api/conversations/<threadId>/runs/<runId>/stream
|
||
# 服务端转发 SSE
|
||
|
||
GET /api/conversations/<threadId>/async-tasks
|
||
GET /api/conversations/<threadId>/async-tasks/<taskId>
|
||
POST /api/conversations/<threadId>/async-tasks/<taskId>/runs
|
||
|
||
GET /api/conversations/<threadId>/schedules
|
||
POST /api/conversations/<threadId>/schedules
|
||
PATCH /api/conversations/<threadId>/schedules/<scheduleId>
|
||
DELETE /api/conversations/<threadId>/schedules/<scheduleId>
|
||
POST /api/conversations/<threadId>/schedules/<scheduleId>/run
|
||
|
||
GET /api/deployment/assistant # 当前部署的只读 assistant 描述
|
||
```
|
||
|
||
`ensureThread()`、`sendMessage()`、`resumeInterrupt()`、线程列表、重命名、置顶、模型切换、
|
||
run 恢复、异步代理面板和定时任务面板都改为调用上述服务端接口。严格模式删除
|
||
`ClientProvider`、浏览器 `Client`、`NEXT_PUBLIC_LANGSMITH_API_KEY` 和 localStorage API key;
|
||
浏览器只使用同源 `fetch` 的 `ConversationApi`。服务端创建 thread 时生成 UUID、写入不可变
|
||
scope metadata,并以同一个可信 `ActiveDeployment` 创建 thread。
|
||
|
||
`PATCH /conversations/<threadId>` 只接受 `{ title?, pinned?, model_override? }`。服务端读取
|
||
现有 metadata 后合并允许字段,永不接受 scope、graph、assistant 或 deployment 字段。
|
||
`model_override` 先经服务端模型 allowlist 校验再持久化;后续 run 使用该值,从而在刷新和
|
||
重新打开后保持用户选择。`PUT /file-state` 只接受文件 descriptor 的允许字段,服务端忽略
|
||
客户端传入的物理路径、大小和 MIME 结论,并逐个验证虚拟路径存在于当前 scope,之后才更新
|
||
thread state,不能把该端点作为任意状态写入接口。`GET /export` 只导出当前主 thread 的
|
||
脱敏 history/state,且不能返回 scope、服务凭据、物理目录或派生 thread 的未授权状态。
|
||
|
||
`POST /runs` 的请求体只允许以下字段:`turn_id`、`run_request_id`、可选
|
||
`interrupt_key`、`input`、`command`、`review_mode`、`model_override`。它拒绝 `thread_id`、
|
||
`workspace_scope_id`、`metadata`、`config`、
|
||
`assistant_id` 和 deployment URL。若携带 `model_override`,它是持久化模型选择:服务端先
|
||
校验并写入该主 thread,再用同一值创建 run;不提供时读取已保存选择。服务端执行以下步骤:
|
||
|
||
1. 从 Registry 读取 active scope,并验证主 thread 归属。
|
||
2. 验证 `turn_id` 与 `run_request_id` 为 UUID,按 `scope_id + run_request_id` 查找已有
|
||
run,保证单次请求重试幂等;同一 `turn_id` 的 resume 使用新的 `run_request_id`。
|
||
3. 对 `review_mode` 和 `model_override` 按服务端允许列表校验。
|
||
4. 合并服务端生成的 thread、scope、模型与审核配置,写入 run config 和 metadata。
|
||
5. 登记 run owner 后创建 run;失败时回滚未启动的 owner 记录。
|
||
6. 返回固定 `{ threadId, runId, status, turnId, runRequestId }`,浏览器通过 BFF 查询或订阅。
|
||
|
||
SSE、thread 查询、run 查询、取消和历史列表也必须走 BFF,不能在严格模式回退到浏览器
|
||
SDK。BFF 转发时只转发已属于当前 `ActiveDeployment` 和当前 scope 的记录。
|
||
|
||
异步接口先从主 thread 的 `async_tasks` 找到 `taskId`,再验证该 child thread/run 在
|
||
Registry 中是当前 scope 的 active `async_thread`/`run` owner;浏览器提交的 child thread ID、
|
||
agent 名称和 scope 一律忽略。追问接口仅接收 `{ turn_id, input }`,继承该 child owner 的
|
||
scope config,且 child 已终态、deleting 或不属于当前主对话时返回 `409`/`404`。
|
||
|
||
定时任务页面改为“当前对话的定时任务”,没有 active thread 时禁用。所有 schedule API 以
|
||
主 thread 查询 scope;创建时先登记 cron owner,并在 cron config 和 metadata 写入 scope、
|
||
owner、revision。更新采用“创建新 cron 后删除旧 cron”的 saga,两个 cron 均由当前 scope
|
||
校验;立即执行同样从已登记 cron owner 派生 run owner,绝不创建匿名 scheduler thread。
|
||
列表、读取、更新、删除和立即执行均按 Registry owner 查询,不能调用全局 `crons.search()`。
|
||
创建/更新 body 仅允许 `{ name, prompt, schedule, idempotency_key }`,立即执行仅允许
|
||
`{ turn_id }`;服务端验证 cron 表达式、长度和幂等键,所有其他 cron config/metadata 均由
|
||
服务端生成。
|
||
|
||
审核不在新 run 创建时不可逆地决定。`review_mode` 只定义当前 UI 的默认策略;图产生
|
||
interrupt 后,BFF 返回已校验的 `interrupt_id` 和 action requests,浏览器在每次
|
||
`command.resume` 中提交该 interrupt 的逐项 `approve`、`reject` 或编辑后批准决定。每次
|
||
resume 复用逻辑 `turn_id`,但生成新的 `run_request_id`;同一网络重试复用该请求 ID。BFF
|
||
以 `interrupt_key` 拒绝同一 interrupt 的第二个决定,因此用户可在执行过程中改变批准选择,
|
||
而刷新、自动批准或多标签竞态不会创建第二个恢复 run。
|
||
|
||
#### 6.1.1 客户端状态和 SSE 迁移
|
||
|
||
`useChat` 必须移除 `useStream`、`UseStreamThread` 和 SDK client。它通过
|
||
`GET /api/conversations/<threadId>` 获取 `ThreadSnapshot`(messages、todos、interrupts、
|
||
pending task 和允许的 metadata),通过 BFF stream endpoint 接收并解析 SSE;断线后携带
|
||
`Last-Event-ID` 重连,无法续接时重新读取 `ThreadSnapshot`。BFF 只代理已校验主 run 的
|
||
事件,设置 `Cache-Control: no-store`,不把上游 URL 或凭据下发给浏览器。`ChatProvider` 和
|
||
`ConversationApi` 使用本地 TypeScript DTO,不再以 SDK runtime 类型作为客户端状态容器。
|
||
|
||
`GET /api/deployment/assistant` 由服务端从 `ActiveDeployment` 解析当前 assistant,返回仅供
|
||
展示的 `{ assistantId, graphId, name }`;`POST /runs` 始终由服务端选择该 assistant。前端不再
|
||
调用 `client.assistants.get/search`,也不把 deployment URL/API key 写入 `ConfigDialog` 或
|
||
localStorage。严格模式的 ConfigDialog 只展示服务端连接状态和允许的模型,不提供后端 URL、
|
||
assistant ID 或 API key 编辑。
|
||
|
||
严格模式增加静态门禁:脚本扫描 `src/` 中所有非 BFF 模块,必须不存在 `new Client(`、
|
||
`useClient()`、`useStream`、浏览器 deployment URL、公开 API key 或 LangGraph SDK 直连。
|
||
BFF route 和 `src/lib/server/**` 可以使用服务端 SDK,但必须导入 `server-only`;共享类型只能
|
||
使用 `import type`。任何新豁免必须附带安全测试和本方案更新。
|
||
|
||
### 6.2 WebUI 服务端目录解析器
|
||
|
||
保留 `getWorkspaceDir()` 作为部署根目录解析器,新增:
|
||
|
||
```ts
|
||
interface ConversationWorkspace {
|
||
deploymentRoot: string;
|
||
scopeId: string;
|
||
filesDir: string;
|
||
runtimeDir: string;
|
||
}
|
||
|
||
async function resolveConversationWorkspace(
|
||
request: NextRequest,
|
||
deployment: ActiveDeployment,
|
||
options: { create: boolean }
|
||
): Promise<ConversationWorkspace>;
|
||
```
|
||
|
||
解析步骤:
|
||
|
||
1. 从请求读取 `threadId`,按不透明 ID 格式和长度校验,绝不用于拼接路径。
|
||
2. 通过传入的服务端 `ActiveDeployment.threadClient` 查询主 thread。
|
||
3. 校验 thread 的 assistant/graph 和 deployment ID 都属于当前 deployment。
|
||
4. 向 Scope Registry 查询 `primary_thread_id -> active scope` 映射,并比对 metadata 镜像。
|
||
5. 使用固定根目录拼接 `.evoscientist/conversations/<scope UUID>/files`。
|
||
6. 对根目录执行 `realpath` 和包含关系检查。
|
||
7. 仅在 provision 操作中创建目录;普通读取和写入不隐式创建陌生 scope。
|
||
|
||
所有 API 使用这个单一解析器,不能各自拼接路径,也不能接收浏览器提供的 deployment
|
||
URL 或物理路径。
|
||
|
||
### 6.3 Agent 运行时解析器
|
||
|
||
EvoScientist 新增独立模块,例如:
|
||
|
||
```text
|
||
../EvoScientist/EvoScientist/workspace_scope.py
|
||
```
|
||
|
||
建议接口:
|
||
|
||
```python
|
||
def require_workspace_scope_id(runtime: ToolRuntime) -> str: ...
|
||
def conversation_workspace_dir(scope_id: str) -> Path: ...
|
||
def create_workspace_backend(runtime: ToolRuntime) -> BackendProtocol: ...
|
||
def require_scoped_runtime(runtime: ToolRuntime, *, kind: str) -> ScopeContext: ...
|
||
def register_scope_owner(context: ScopeContext, owner: ScopeOwner) -> None: ...
|
||
def drain_scope(scope_id: str) -> DrainResult: ...
|
||
```
|
||
|
||
`create_workspace_backend()` 根据 `runtime.config.configurable.workspace_scope_id` 返回
|
||
当前请求的 scoped backend handle;handle 在 Agent 事件循环中不得执行 Registry、路径或
|
||
文件系统 I/O。实际的 Registry 校验、`realpath` 检查和 `CompositeBackend` 构造在文件操作
|
||
的工作线程中完成:
|
||
|
||
```text
|
||
default -> 当前对话 files/,可读写
|
||
/skills/ -> 现有共享 Skills backend
|
||
/memories/ -> 现有共享 Memory backend
|
||
```
|
||
|
||
不能把 `.evoscientist/conversations` 或部署根目录作为 `/project/` 暴露给 Agent,否则
|
||
Agent 可以重新枚举其他 scope。未来若需要公共项目文件,应提供过滤掉内部目录的
|
||
独立只读 backend,并显式挂载为 `/project/`。
|
||
|
||
`workspace_scope.py` 是唯一允许把 scope 变为物理目录的 EvoScientist 模块。请求处理、
|
||
工具和 worker 不得调用 `paths.resolve_virtual_path()` 解析对话文件,也不得在运行期间
|
||
调用 `set_active_workspace()`;这两个全局 API 仅保留给 CLI 兼容路径。
|
||
|
||
### 6.4 Deepagents 兼容性
|
||
|
||
当前依赖为 `deepagents[quickjs]~=0.6.12`。该版本支持按 `ToolRuntime` 创建 backend,
|
||
但对应工厂接口已经标记为将在 0.7 移除。
|
||
|
||
实施要求:
|
||
|
||
- 第一阶段保持 `~=0.6.12`,不升级到 0.7。
|
||
- 把 deepagents 适配封装在 `create_workspace_backend()`,业务代码不直接依赖弃用接口。
|
||
- runtime backend factory 必须无阻塞:不得在 Agent 事件循环调用 SQLite、`Path.resolve()`、
|
||
`mkdir()` 或构造会执行这些操作的具体 backend。异步文件方法必须通过执行器完成上述工作。
|
||
- 增加启动测试,确认 `create_deep_agent` 能接收运行时 backend 工厂。
|
||
- 升级 deepagents 前,迁移到届时官方的请求级 storage/backend 机制或自定义
|
||
FilesystemMiddleware。
|
||
|
||
## 7. New Chat 和上传流程
|
||
|
||
### 7.1 统一 ensureThread
|
||
|
||
在 `useChat` 暴露或内部共享一个幂等方法:
|
||
|
||
```ts
|
||
ensureThread(): Promise<string>
|
||
```
|
||
|
||
行为:
|
||
|
||
1. 已有 `threadId` 时直接返回。
|
||
2. 没有 thread 时调用 `POST /api/conversations`;服务端生成 thread ID、scope 和 draft。
|
||
3. 更新 `threadIdRef`、React state 和路由。
|
||
4. 并发上传和发送共用同一个 Promise,避免创建两个 thread。
|
||
|
||
以下入口都必须先调用它:
|
||
|
||
- 第一次上传文件。
|
||
- 发送第一条消息。
|
||
- 在 New Chat 中打开 Workspace 面板并执行写操作。
|
||
|
||
### 7.2 上传时序
|
||
|
||
```text
|
||
用户在 New Chat 选择文件
|
||
-> ensureThread()
|
||
-> 创建 draft thread + workspace_scope_id
|
||
-> POST /api/workspace/upload?threadId=<id>
|
||
-> 服务端校验 thread
|
||
-> 写入 <scope>/files/
|
||
-> 返回虚拟路径 /filename.pdf
|
||
```
|
||
|
||
上传结果继续使用虚拟路径,例如 `/paper.pdf`。聊天消息只引用虚拟路径,不包含
|
||
`.evoscientist/conversations/...` 物理前缀。
|
||
|
||
### 7.3 草稿清理
|
||
|
||
- Thread 列表默认过滤 `workspace_status=draft`。
|
||
- 第一条消息创建 run 前把状态改为 `active`。
|
||
- 无消息、无活动 run 且超过 24 小时的 draft 由定期清理任务删除。
|
||
- 清理任务同时删除 thread 和 scope 目录,操作要求幂等。
|
||
- 上传失败后不立即删除 draft,以便用户重试;无内容草稿仍由 TTL 处理。
|
||
|
||
不推荐临时上传区方案。它需要在创建 thread 后移动文件,并额外处理崩溃、重名、
|
||
跨文件系统移动和遗留临时文件,复杂度高于预创建草稿 thread。
|
||
|
||
## 8. Workspace API 修改
|
||
|
||
### 8.1 请求约定
|
||
|
||
所有 Workspace API 必须携带 `threadId`:
|
||
|
||
```text
|
||
GET /api/workspace?threadId=<id>&path=...
|
||
GET /api/workspace?threadId=<id>&recursive=1
|
||
POST /api/workspace/upload?threadId=<id>
|
||
GET /api/workspace/file?threadId=<id>&path=...
|
||
PUT /api/workspace/file?threadId=<id>&path=...
|
||
DELETE /api/workspace/file?threadId=<id>&path=...
|
||
GET /api/workspace/download?threadId=<id>
|
||
```
|
||
|
||
查询参数适用于图片 `src`、下载链接和新标签页,不把 thread ID 当作秘密。未来多用户
|
||
版本仍必须通过登录态校验 thread 所有权。
|
||
|
||
### 8.2 响应和错误
|
||
|
||
| 场景 | 状态码 | 行为 |
|
||
| ------------------------------------ | ------ | --------------------- |
|
||
| 缺少或非法 thread ID | `400` | 不读取任何目录 |
|
||
| thread 不存在或不属于当前 deployment | `404` | 不透露其他 scope 信息 |
|
||
| thread 正在删除 | `409` | 禁止上传和写操作 |
|
||
| 文件不存在或试图跨 scope | `404` | 统一不可访问响应 |
|
||
| 超过单次上传限制 | `413` | 不留下部分文件 |
|
||
|
||
列表响应删除真实 `dir` 字段,只返回虚拟相对路径和可展示的 scope 信息。
|
||
|
||
### 8.3 前端调用方
|
||
|
||
以下组件必须使用当前 thread ID:
|
||
|
||
- `ChatInterface.tsx`:上传。
|
||
- `WorkspacePanel.tsx`:目录和类型视图、下载全部。
|
||
- `WorkspaceFileDialog.tsx`:预览、编辑、删除、单文件下载。
|
||
- `ResearchDashboard.tsx`:当前对话文件统计。
|
||
|
||
`workspaceFileUrl()` 的签名调整为:
|
||
|
||
```ts
|
||
workspaceFileUrl(threadId, path, download?)
|
||
```
|
||
|
||
组件在 `threadId` 不存在时不得发起全局 Workspace 请求。
|
||
|
||
## 9. Agent 和派生任务传播
|
||
|
||
### 9.1 主 run 和 resume run
|
||
|
||
`useChat.buildRunConfig()` 只构造模型和审核模式配置。服务端 run 接口从 Scope Registry
|
||
解析 scope 后写入 `configurable` 和 metadata。普通消息、审批恢复、`ask_user` 恢复、
|
||
自动批准和断线恢复发现的后续 run 都必须保持同一 scope。
|
||
|
||
创建 run 前校验:
|
||
|
||
```text
|
||
Registry(workspace_scope_id).primary_thread_id == 主 thread_id
|
||
```
|
||
|
||
加载历史 thread 时从 thread metadata 恢复 scope,而不是从当前 UI 状态猜测。
|
||
|
||
### 9.2 同步子代理
|
||
|
||
同步子代理与父 run 共用当前 `ToolRuntime` 和 backend 工厂。验收时必须验证它读取和
|
||
写入的是父对话目录,并发子代理写文件时仍受同一根目录限制。
|
||
|
||
### 9.3 异步子代理
|
||
|
||
异步子代理会创建自己的 LangGraph thread,因此它的传播规则是:
|
||
|
||
```text
|
||
child.thread_id != parent.thread_id
|
||
child.workspace_scope_id == parent.workspace_scope_id
|
||
```
|
||
|
||
`../EvoScientist/EvoScientist/llm/patches.py` 中创建异步 run 的包装逻辑应无条件复制
|
||
`workspace_scope_id` 到 child config 和 metadata。此传播不能依赖 Token 统计是否
|
||
启用。创建 child thread 前生成并登记 owner UUID;child thread ID 与 owner 关联成功后
|
||
才创建 run,失败时清理 reservation。
|
||
|
||
### 9.4 Memory Worker 和自动技能 Worker
|
||
|
||
EvoMemory 生命周期、Memory Worker、Observation Linker 和 AutoSkills 是独立 graph,
|
||
不能因为 `/memories/` 是共享路由而保留部署级 workspace。当前这些 graph 在启动时
|
||
把 `WORKSPACE_ROOT` 固化为 backend 根目录;严格模式中必须改为运行时 scope factory。
|
||
|
||
- Memory 生命周期从父 runtime 获取 scope,而不是在 middleware 构造时保存
|
||
`WORKSPACE_ROOT`。
|
||
- Worker launch payload 的 `config.configurable` 和 metadata 都携带 scope。
|
||
- Worker launch 前生成并登记 owner UUID;内部 thread 创建后将 thread ID 关联该 owner,
|
||
再允许 run 启动。
|
||
- Memory Worker 的默认文件 backend 对当前 `files/` 只读;它可以读当前对话文件,
|
||
不能枚举 `.evoscientist/conversations` 或其他 scope。
|
||
- Observation Linker、AutoSkills 和关联的 memory scheduler 继承创建它们的 scope。
|
||
- 若上述 graph 无法在本次改造为运行时 backend,`required` 模式必须禁用
|
||
`memory_workers_enabled`,不能保留部署根目录读权限。
|
||
|
||
Memory 内容仍可按产品策略共享;这只表示 Worker 不能直接读取其他对话的文件。若共享
|
||
Memory 中也不能出现对话 A 的摘要或文件内容,必须另行启用 Memory 数据隔离。
|
||
|
||
### 9.5 Skill Manager 和其他直接文件工具
|
||
|
||
严格模式把共享 Skills 视为管理员管理的只读依赖。Agent 的 `skill_manager` 只保留
|
||
`list`、`browse` 和 `info`;`install`、`uninstall` 及所有本地 source 一律拒绝。
|
||
|
||
因此 strict mode 中不存在通过 Skill 工具读取部署根目录、其他 scope 或宿主机路径的
|
||
入口,也不依赖现有 HITL 范围。管理员安装或更新共享 Skill 使用独立的管理接口,不在
|
||
对话 Agent 工具集中。未来若需要“从当前对话导入私有 Skill”,必须另立方案:接收
|
||
`ToolRuntime`、只读取当前 `files/`、写入 scope 私有目录,并为该操作新增显式 HITL。
|
||
|
||
对所有非 backend 文件工具执行同一审计;任何使用 `WORKSPACE_ROOT`、
|
||
`resolve_virtual_path()` 或固定 cwd 的请求路径必须改为 runtime scope 解析器,或在
|
||
严格模式禁用。
|
||
|
||
### 9.6 后台进程
|
||
|
||
修改 BackgroundExecutionMiddleware:
|
||
|
||
- `run_in_background` 从 `ToolRuntime` 解析 scope 并以当前 `files/` 为 cwd。
|
||
- `BgProcess` 保存 `workspace_scope_id`,不能只保存 origin thread ID。
|
||
- `check_process`、`stop_process` 和 `list_processes` 都接收 runtime 并校验 scope。
|
||
- 移除 `all_threads=true` 的跨 scope 列表能力。
|
||
- 日志存放位置属于当前 scope,其他 scope 返回 `not found`。
|
||
- 启动后台容器前生成并登记 Registry owner UUID,将其写入容器 label;container ID 返回
|
||
后关联 owner。停止、完成和异常退出都原子更新 owner 状态。
|
||
|
||
`stop_process` 当前没有 `ToolRuntime` 参数,是必须修复的隔离缺口。
|
||
|
||
### 9.7 定时任务
|
||
|
||
定时任务记录增加不可变 `workspace_scope_id`:
|
||
|
||
- `schedule_task`、`list_scheduled_tasks`、`cancel_scheduled_task` 都接收 ToolRuntime,
|
||
从中读取 scope。
|
||
- 创建 cron 时同时写入 `metadata.workspace_scope_id` 和
|
||
`config.configurable.workspace_scope_id`。后者是 cron 触发 run 时选择 backend 的依据,
|
||
不能只存 metadata。
|
||
- Scheduler graph 使用运行时 backend factory;cron 触发的内部 thread 不得成为目录名。
|
||
- `list_schedules(scope)` 使用 metadata 的 containment 查询;`cancel` 和 `run_now` 先
|
||
从 Registry 验证 cron owner,再操作。`run_now` 也必须传同一 config 与 metadata。
|
||
- `SchedulerMiddleware` 的动态提示改为按 runtime scope 查询,缓存键为
|
||
`scope_id + revision`,不得缓存或注入其他 scope 的任务名称、prompt 或 ID。
|
||
- 创建 cron 前生成并登记 owner UUID,将其写入 cron config、metadata 和 Registry;
|
||
返回 cron ID 后再把 cron ID 关联到该 owner。删除主对话时由 Registry 枚举、禁用并
|
||
删除该 scope 的 cron。
|
||
|
||
如果第一阶段不准备完成 Scheduler 隔离,应在严格模式下暂时禁止对话创建 schedule,
|
||
不能让它回落到公共 workspace。
|
||
|
||
## 10. 生命周期设计
|
||
|
||
### 10.1 打开和恢复
|
||
|
||
- 打开历史对话:BFF 查询 Registry 与 thread metadata;旧 thread 先 provision,再切换 scope。
|
||
- 浏览器刷新:URL thread ID 只用于 BFF 查询,Registry 是 scope 的恢复来源。
|
||
- 审批暂停后恢复:BFF 沿用已登记 run owner 的 scope,不读取 New Chat 状态。
|
||
- 运行重连:发现的 active run metadata、Registry owner 或 thread metadata 任一不一致时停止
|
||
恢复并报告错误。
|
||
|
||
### 10.2 重命名
|
||
|
||
对话标题变化不改变 scope,也不重命名物理目录。目录永远使用不可变 UUID。
|
||
|
||
### 10.3 删除
|
||
|
||
当前 `useThreads.deleteThread()` 只删除 LangGraph thread,隔离后应由统一服务端接口
|
||
编排对话和文件删除:
|
||
|
||
```text
|
||
DELETE /api/conversations/<threadId>
|
||
```
|
||
|
||
建议流程:
|
||
|
||
1. 将 thread 状态置为 `deleting`,拒绝新 run 和文件请求。
|
||
2. Registry 以 compare-and-set 将 scope 改为 `deleting`,递增 revision 并冻结 owner 创建。
|
||
3. 调用 `drain_scope(scope_id)`:按 owner registry 枚举主 run、async/memory thread、
|
||
cron 和后台进程,取消或停止后等待终态。
|
||
4. drain 超时、owner 未知或 Registry 与 thread metadata 不一致时保持 `deleting`,不移动
|
||
目录;修复任务完成对账后才能重试。
|
||
5. 将 scope 目录原子重命名到 `trash/`,再删除 LangGraph thread 和已终态 owner 记录。
|
||
6. 将 Registry 状态置为 `deleted`;失败时按阶段补偿,成功后异步清理回收目录。
|
||
|
||
文件系统和 LangGraph 存储之间没有跨系统事务,因此所有步骤必须幂等,并由孤儿
|
||
清理任务处理残留。运行中的目录不能进入 purge;回收目录默认保留 7 天,产品可在
|
||
配置中调整。
|
||
|
||
### 10.4 复制和分支
|
||
|
||
未来支持复制或分支对话时,默认创建新的 `workspace_scope_id`。文件策略必须显式
|
||
选择:
|
||
|
||
- `empty`:新对话使用空目录,推荐默认值。
|
||
- `copy`:复制源对话文件到新 scope。
|
||
|
||
禁止两个主对话永久共享同一个可写 scope,否则不再满足隔离定义。
|
||
|
||
## 11. 全量切换和旧 workspace 迁移
|
||
|
||
现有部署根目录中的文件没有可靠的 thread 所有权信息。不能根据聊天文本或文件时间自动
|
||
分配,更不能复制到每个对话,否则会扩大泄露。为保证“所有现有对话均已调整”,生产
|
||
`required` 切换不用“首次打开时懒迁移”作为主路径。
|
||
|
||
### 11.1 必经批量切换
|
||
|
||
在 `optional` 模式执行可恢复的 `workspace-cutover` 管理任务,进度写入 Registry 操作表:
|
||
|
||
1. 获取 Registry 中 `deployment_locks(deployment_id, "workspace-cutover")` 的排他租约。
|
||
锁存续期间 BFF 和所有内部 owner 创建入口拒绝新的 draft、run、resume、cron 和后台任务,
|
||
返回 `503 cutover-in-progress`;锁租约必须由 `operation_id` 持有并可续期。随后盘点全部
|
||
主 thread、draft、active run、async/memory thread、cron、后台容器和 memory scheduler。
|
||
cutover 的每次写入和续租都必须以 `operation_id` 与 `expires_at > now` 作为条件;条件失败
|
||
立即停止,避免失去租约的旧进程继续写入。
|
||
2. 为每个主 thread 创建或验证唯一 scope、`files/` 和 primary owner,并原子写入 Registry
|
||
与 thread metadata。未打开的历史对话同样必须处理。
|
||
3. 依据 parent metadata 和运行记录把可证明归属的派生资源登记到相同 scope。无 parent、
|
||
无 scope、owner 冲突或无法证明归属的 active run、cron、后台进程和 worker 必须取消、
|
||
禁用并记录为 quarantine;不得继续在公共根目录执行。
|
||
4. 旧根目录标记为只读 `Legacy shared workspace`,不再挂载给 Agent;历史文件不自动分配。
|
||
用户仅能通过“导入到当前对话”复制指定文件,导入记录源、目标 scope、时间和幂等键。
|
||
5. 将报告原子写入 `.evoscientist/control/cutover-reports/<operation-id>.json`(权限 `0600`),
|
||
在 operation 中保存其 SHA-256。报告包含盘点时间、主 thread/scope/primary owner 计数、
|
||
active/quarantined owner 清单摘要、legacy cron/process 数、浏览器 SDK 静态门禁结果和
|
||
Registry/metadata 对账结果。仅当计数相等、无未处理 legacy owner、无不一致且静态门禁
|
||
通过时报告为 `passed`;否则为 `failed`,禁止切到 `required`。
|
||
6. 通过的报告写入后才释放 cutover 锁。`required` 启动时验证最新通过报告的 hash、部署 ID
|
||
和所有 gate;验证失败则拒绝启动。锁异常过期时由恢复任务继续或将报告标记失败,不能
|
||
自动切换为 `required`。
|
||
|
||
`required` 模式不再为未知旧 thread 懒创建 scope,而是返回 `409 migration-required` 并由
|
||
管理员重新运行 cutover。`provision_legacy_scope(thread_id)` 仅保留给 `optional` 的修复任务,
|
||
必须使用同一 Registry 事务和审计记录,不能绕过上述盘点。
|
||
|
||
当前实现的管理入口为:
|
||
|
||
```bash
|
||
uv run python scripts/workspace_cutover.py \
|
||
--workspace /absolute/deployment-workspace \
|
||
--api-url http://127.0.0.1:6174 \
|
||
--webui-root /absolute/path/to/EvoScientist-WebUI
|
||
```
|
||
|
||
该命令在 Registry 中持有并续租 `workspace-cutover` 租约;运行期间 BFF 和运行时 scope
|
||
解析器拒绝新 draft、run、cron 和派生 owner。它分页盘点主 thread、检测仍在运行的旧 run,
|
||
并对每个带 `workspace_scope_id` 的派生 thread 或 cron 以 Registry 的
|
||
`resource_id -> owner -> scope` 映射对账。未登记、scope/owner/deployment 不一致或无效状态
|
||
的 thread 会中断活动 run、移除 scope metadata 并写入 `workspace_quarantine`;对应 cron 会
|
||
被禁用。没有 scope 的旧派生资源走相同 quarantine 流程。取消、标记或最终状态任一步失败
|
||
都会保留失败报告,不能切换为 `required`。报告保存为
|
||
`.evoscientist/control/cutover-reports/latest.json`,同时写入 `scope_operations` 的报告 SHA-256。
|
||
`required` 启动会校验二者、部署 ID、浏览器 SDK 静态门禁和 `passed` 状态。严格执行器还会
|
||
验证 Docker 与固定 digest 的 OCI 镜像已就绪,缺失时拒绝启动。
|
||
|
||
定期生命周期清理由部署管理员执行;建议每小时运行一次:
|
||
|
||
```bash
|
||
uv run python scripts/workspace_maintenance.py \
|
||
--workspace /absolute/deployment-workspace \
|
||
--api-url http://127.0.0.1:6174
|
||
```
|
||
|
||
它与 cutover 共享 Registry 生命周期租约;租约存续期间 BFF、Registry 与运行时拒绝新的
|
||
draft、run、cron 和派生 owner。它仅删除超过 `EVOSCIENTIST_DRAFT_WORKSPACE_TTL_HOURS`
|
||
且没有活动 run、没有非主 owner 的 draft;删除先进入 `trash/`。超过
|
||
`EVOSCIENTIST_WORKSPACE_TRASH_RETENTION_DAYS` 的非符号链接回收目录才会被物理清理。
|
||
|
||
### 11.2 旧文件与回滚
|
||
|
||
旧根目录在 cutover 后至少保留到管理员确认导入窗口结束;它不属于任何 scope。回滚至
|
||
`legacy` 只允许在维护窗口进行,并先停止 required 模式产生的容器、cron 和 run,避免两个
|
||
目录模型同时写入。完成全量导入或达到保留期后再归档旧目录。
|
||
|
||
若当前部署 workspace 本身是一个代码项目,第一阶段也不自动挂载。确实需要公共
|
||
项目源码时,再增加只读 `/project/` backend,并确保 `.evoscientist/`、Secrets、
|
||
配置凭据和其他对话目录永远不可见。
|
||
|
||
## 12. 安全要求
|
||
|
||
### 12.1 路径安全
|
||
|
||
在现有 `resolveInside()` 和 `safeResolve()` 基础上保留并扩展:
|
||
|
||
- 规范化路径分隔符。
|
||
- 拒绝控制字符、`..`、隐藏内部目录和绝对路径覆盖。
|
||
- 读取前 `realpath`,再次检查是否位于当前 `files/`。
|
||
- 创建新文件时检查父目录真实路径,防止通过已有符号链接逃逸。
|
||
- 上传使用排他创建或原子重命名,避免并发覆盖。
|
||
- 下载 zip 只在当前 `files/` 执行,不跟随符号链接。
|
||
|
||
### 12.2 dangerous_mode
|
||
|
||
当前 `dangerous_mode` 允许 Agent 接触真实文件系统,与严格对话隔离互斥。启用
|
||
`conversation workspace isolation = required` 时,后端应在启动阶段拒绝
|
||
`dangerous_mode=true`,而不是只在 UI 显示警告。
|
||
|
||
### 12.3 宿主机执行隔离
|
||
|
||
`CustomSandboxBackend` 的路径重写和命令正则校验只能降低误操作,不能限制运行在同一
|
||
Unix 用户下的解释器主动枚举父目录、HOME 或其他进程环境。因此它不能单独作为
|
||
conversation isolation 的安全边界。
|
||
|
||
`required` 模式固定使用容器化 `ScopedExecutor`:
|
||
|
||
```text
|
||
host <scope>/files/ -> container /workspace (read-write)
|
||
shared skills/memories -> 不默认挂载;仅通过受控工具访问
|
||
deployment workspace -> 不挂载
|
||
other conversation roots -> 不挂载
|
||
host HOME / Docker socket -> 不挂载
|
||
```
|
||
|
||
- `execute` 在短生命周期容器中运行,容器标签包含 scope 和 owner ID。
|
||
- `run_in_background` 创建同一策略的受管后台容器,Registry 保存 container ID;不再
|
||
直接使用宿主机 `subprocess.Popen`。
|
||
- 容器镜像、网络策略、CPU/内存/进程限制和允许的环境变量由服务端配置,Agent 不能
|
||
覆盖。默认网络关闭;需要联网的明确工具使用受控 egress 策略。
|
||
- Docker 或兼容 OCI runtime 不可用、镜像未验证或 daemon 不可达时,`required` 模式
|
||
启动失败;只能显式使用 `optional`/`legacy`,不得悄悄回退到宿主机执行。
|
||
- 文件读写工具仍只以当前 `files/` 为 root;容器执行器解决的是 shell、解释器和后台
|
||
进程绕过该 root 的问题。
|
||
|
||
### 12.4 MCP 工具
|
||
|
||
Agent backend 只能约束经过该 backend 的文件和 shell 工具。具有独立宿主机文件
|
||
权限的 MCP Server 可能绕过 scope。
|
||
|
||
严格模式采用拒绝优先策略。MCP 配置在启动时按能力分类,并写入审计日志:
|
||
|
||
| MCP 类型 | `required` 模式 |
|
||
| ------------------------------------------------- | ------------------------------------ |
|
||
| 仅远程业务 API、无本地文件或进程能力 | 允许,按显式 allowlist 加载 |
|
||
| 本地文件、shell、项目目录、代码解释器或可传入 cwd | 禁用 |
|
||
| 连接在进程启动时固定本地根目录的 MCP | 禁用,不能靠“启动时传当前 scope”复用 |
|
||
| 已实现每次调用 runtime scope 校验的 MCP adapter | 允许,但需独立安全测试 |
|
||
|
||
当前 MCP 工具缓存是进程级的,因此 v1 不实现每个 scope 启动独立 MCP 会话。任何不在
|
||
allowlist 的 MCP 都使 `required` 模式启动失败;管理员必须先移除或显式标记为纯远程
|
||
API。未来增加动态 MCP adapter 时,adapter 必须接收 `ScopeContext`,不得只在启动时
|
||
保存 root directory。
|
||
|
||
### 12.5 原生 Code Interpreter
|
||
|
||
项目的 `EvoCodeInterpreterMiddleware` 是原生 QuickJS middleware,不属于 MCP。它目前可
|
||
批量调用 `read_file`、`grep`、`glob`、`ls` 和 async task 工具,因此必须纳入 scope 传播,
|
||
不能仅依赖 MCP allowlist。
|
||
|
||
`required` 模式默认 `EVOSCIENTIST_STRICT_CODE_INTERPRETER=disabled`。只有满足以下条件才
|
||
允许配置为 `scoped`:QuickJS 的每个 PTC 工具调用均保留原始 `ToolRuntime`,所有被允许的
|
||
工具在执行前调用 `require_scoped_runtime()`,异步创建仍登记 child owner,并且启动自检与
|
||
验收测试通过。任一条件失败时拒绝 `scoped` 配置,不能退化为未校验执行。Code Interpreter
|
||
不得暴露 Node、Python、宿主机文件系统或任意网络模块;其可调用工具表在严格模式必须是
|
||
服务端固定 allowlist。
|
||
|
||
### 12.6 共享 Memory 和 Skills
|
||
|
||
`/memories/` 和 `/skills/` 是显式共享路由。它们可以让不同对话获得共同知识,但不
|
||
是私有 `files/` 的文件系统挂载。Memory Worker、Skill Manager 和其他直接文件工具
|
||
仍必须使用当前 scope;共享路由本身不能成为读取部署根目录的理由。
|
||
|
||
如果产品要求“任何信息都不能跨对话”,需要另立 Memory 隔离方案,包括 Memory
|
||
Worker、自动技能和摘要数据;本方案不应被描述为已经满足该更强目标。
|
||
|
||
### 12.7 Scope 来源和 deployment 一致性
|
||
|
||
- 浏览器不提交 `workspace_scope_id`,也不能通过 query/header 选择 deployment 根目录。
|
||
- WebUI 对话服务从服务端 `ActiveDeployment` 查询主 thread,再生成或恢复 scope。
|
||
- 主图后端验证 runtime 的实际 thread ID 属于 Registry 中该 scope 的 primary thread。
|
||
- 派生图只接受内部传播的 scope,并验证 config 与 metadata 完全一致。
|
||
- sidecar 的 deployment ID、LangGraph API 地址和 workspace 根目录不一致时,所有
|
||
Workspace API 和新 run 失败关闭。
|
||
|
||
## 13. 配置和发布策略
|
||
|
||
所有部署配置写入后端或 WebUI 的 `.env`;不需要通过 CLI 保存工作目录或隔离模式。
|
||
未设置 `EVOSCIENTIST_WORKSPACE_ISOLATION` 时默认值为 `optional`,因此新建的 WebUI
|
||
对话会自动拥有独立的 `files/` 和 `runtime/` 目录。`legacy` 仅用于显式回退,`required`
|
||
是需要额外运行时加固的严格模式。
|
||
|
||
后端 `.env` 的常用配置如下:
|
||
|
||
```text
|
||
EVOSCIENTIST_WORKSPACE_DIR=/absolute/path/to/workspace
|
||
EVOSCIENTIST_WORKSPACE_ISOLATION=optional
|
||
EVOSCIENTIST_SCOPE_REGISTRY_TOPOLOGY=single-host
|
||
EVOSCIENTIST_DRAFT_WORKSPACE_TTL_HOURS=24
|
||
EVOSCIENTIST_WORKSPACE_TRASH_RETENTION_DAYS=7
|
||
```
|
||
|
||
同机部署不需要手工填写 `EVOSCIENTIST_BACKEND_SERVICE_TOKEN`:`EvoSci deploy` 会在
|
||
`<workspace>/.evoscientist/control/` 生成仅服务端读取的令牌,并传给后端;独立 WebUI
|
||
也会从该位置读取。仅在 WebUI 与后端无法共享该目录时,才在两端 `.env` 中配置相同的
|
||
服务端密钥。严格模式另行在 `.env` 中设置:
|
||
|
||
```text
|
||
EVOSCIENTIST_WORKSPACE_ISOLATION=required
|
||
EVOSCIENTIST_STRICT_EXECUTOR=oci
|
||
EVOSCIENTIST_STRICT_EXECUTOR_IMAGE=<pinned-image-digest>
|
||
EVOSCIENTIST_STRICT_CODE_INTERPRETER=disabled|scoped
|
||
```
|
||
|
||
v1 保留现有单次上传限制,但不承诺“每 scope 总容量配额”。Agent shell、文件工具和
|
||
后台进程都可写文件,仅在上传 API 计数无法形成可靠配额。总容量配额需另行采用
|
||
文件系统 project quota 或覆盖所有写入面的统一计量后再发布。
|
||
|
||
模式定义:
|
||
|
||
| 模式 | 行为 |
|
||
| ---------- | -------------------------------------------------------------------------------------------------------------------- |
|
||
| `legacy` | 完全保持现有公共根目录,仅用于回滚 |
|
||
| `optional` | WebUI 默认模式:新对话必须创建 scope 并隔离;scope 服务、令牌或 Registry 不可用时拒绝创建线程、Run 和 Workspace 请求 |
|
||
| `required` | 只接受 BFF 和内部服务凭据;缺少、非法或 deleting scope 立即失败 |
|
||
|
||
发布顺序:
|
||
|
||
1. 后端和 API 先支持 `optional`,完成双路径自动化测试。
|
||
2. WebUI 改为仅使用 BFF,完成所有调用入口映射,并通过“无浏览器 LangGraph SDK”静态门禁。
|
||
3. 完成后台任务、Scheduler、Code Interpreter 和 MCP 审计;任何无法传播 scope 的能力在
|
||
`required` 禁用。
|
||
4. 执行全量 `workspace-cutover`,处理全部历史主 thread 和已存在 owner,生成通过的报告。
|
||
5. 通过日志确认没有 WebUI run 缺少 scope、没有 legacy owner、没有 Registry/metadata 不一致。
|
||
6. 切换为 `required`。
|
||
|
||
正式发布不能长期停留在 `optional`,因为遗漏传播时会静默落入公共目录。
|
||
|
||
## 14. 实施拆分
|
||
|
||
### 阶段 A:Scope 基础设施
|
||
|
||
- WebUI 增加 thread scope 类型、UUID 校验、`ActiveDeployment` 和服务端目录解析器。
|
||
- WebUI 增加服务端创建 thread/run/resume 的对话接口,严格模式移除浏览器直连的
|
||
LangGraph SDK、`useStream`、线程列表、run 查询、异步代理和定时任务路径。
|
||
- EvoScientist 增加 Scope Registry、内部服务凭据校验、`workspace_scope.py` 和运行时
|
||
backend 工厂。
|
||
- 验证 OCI runtime、固定执行镜像和最小挂载;缺失时拒绝启用 `required`。
|
||
- 增加配置开关和启动期 `dangerous_mode` 冲突校验。
|
||
- 保留 `/skills/`、`/memories/` 现有路由。
|
||
|
||
### 阶段 B:传播完整性
|
||
|
||
- 主 run、resume run 写入 config 和 metadata。
|
||
- 同步子代理验证继承。
|
||
- 异步子代理显式传播。
|
||
- Memory Worker、Observation Linker、AutoSkills 和 memory scheduler 显式传播,或在
|
||
`required` 模式禁用。
|
||
- `skill_manager` 在严格模式禁用安装与卸载;其他直接文件工具切换至 runtime scope
|
||
解析器或禁用。
|
||
- Code Interpreter 默认禁用;启用时验证其 PTC 工具保留 `ToolRuntime` 并覆盖同一 scope
|
||
测试矩阵。
|
||
- 后台进程 cwd、日志和所有权隔离。
|
||
- Scheduler 在 cron payload 的 config 和 metadata 中保存并继承 scope,系统提示缓存按
|
||
scope 隔离。
|
||
- MCP 在启动期按能力 allowlist 验证,拒绝本地文件和进程能力。
|
||
|
||
阶段 B 完成前不能对外宣称已实现严格隔离。
|
||
|
||
### 阶段 C:WebUI 文件体验
|
||
|
||
- 实现 `ensureThread()` 和 draft thread。
|
||
- 修改上传、列表、预览、编辑、删除和下载 API。
|
||
- 修改 WorkspacePanel、WorkspaceFileDialog 和 ResearchDashboard。
|
||
- 历史列表隐藏 draft,对话激活后正常显示。
|
||
|
||
### 阶段 D:生命周期和迁移
|
||
|
||
- 实现统一删除接口、active run 排空、trash 和孤儿目录清理。
|
||
- 实现 `workspace-cutover` 全量盘点、owner quarantine、报告和旧文件显式导入。
|
||
- 仅在 cutover 报告和浏览器 SDK 静态门禁均通过后切换生产配置为 `required`。
|
||
|
||
## 15. 预计修改文件
|
||
|
||
### EvoScientist-WebUI
|
||
|
||
新增:
|
||
|
||
```text
|
||
src/lib/workspaceScope.ts
|
||
src/lib/server/activeDeployment.ts
|
||
src/lib/server/conversationWorkspace.ts
|
||
src/lib/server/scopeRegistryClient.ts
|
||
src/lib/conversationApi.ts
|
||
src/lib/conversationTypes.ts
|
||
scripts/check-no-browser-langgraph-client.mjs
|
||
src/app/api/conversations/route.ts
|
||
src/app/api/conversations/[threadId]/route.ts
|
||
src/app/api/conversations/[threadId]/file-state/route.ts
|
||
src/app/api/conversations/[threadId]/export/route.ts
|
||
src/app/api/conversations/[threadId]/runs/route.ts
|
||
src/app/api/conversations/[threadId]/runs/[runId]/route.ts
|
||
src/app/api/conversations/[threadId]/runs/[runId]/cancel/route.ts
|
||
src/app/api/conversations/[threadId]/runs/[runId]/stream/route.ts
|
||
src/app/api/conversations/[threadId]/async-tasks/route.ts
|
||
src/app/api/conversations/[threadId]/async-tasks/[taskId]/route.ts
|
||
src/app/api/conversations/[threadId]/async-tasks/[taskId]/runs/route.ts
|
||
src/app/api/conversations/[threadId]/schedules/route.ts
|
||
src/app/api/conversations/[threadId]/schedules/[scheduleId]/route.ts
|
||
src/app/api/conversations/[threadId]/schedules/[scheduleId]/run/route.ts
|
||
src/app/api/deployment/assistant/route.ts
|
||
```
|
||
|
||
修改:
|
||
|
||
```text
|
||
src/lib/server/workspace.ts
|
||
src/app/api/workspace/route.ts
|
||
src/app/api/workspace/file/route.ts
|
||
src/app/api/workspace/upload/route.ts
|
||
src/app/api/workspace/download/route.ts
|
||
src/app/hooks/useChat.ts
|
||
src/app/hooks/useThreads.ts
|
||
src/app/hooks/useAsyncAgents.ts
|
||
src/app/hooks/useScheduledTasks.ts
|
||
src/lib/modelCommand.ts
|
||
src/lib/runRecovery.ts
|
||
src/app/components/ChatInterface.tsx
|
||
src/app/components/AgentsPanel.tsx
|
||
src/app/components/ScheduledTasksPanel.tsx
|
||
src/app/components/WorkspacePanel.tsx
|
||
src/app/components/WorkspaceFileDialog.tsx
|
||
src/app/components/ResearchDashboard.tsx
|
||
src/app/components/ThreadList.tsx
|
||
src/app/page.tsx
|
||
src/providers/ChatProvider.tsx
|
||
src/app/components/ConfigDialog.tsx
|
||
src/providers/ClientProvider.tsx(删除,替换为 ConversationApi)
|
||
```
|
||
|
||
### EvoScientist
|
||
|
||
新增:
|
||
|
||
```text
|
||
EvoScientist/workspace_scope.py
|
||
EvoScientist/scope_registry.py
|
||
EvoScientist/execution/scoped_executor.py
|
||
```
|
||
|
||
修改范围:
|
||
|
||
```text
|
||
EvoScientist/EvoScientist.py
|
||
EvoScientist/llm/patches.py
|
||
EvoScientist/config/settings.py
|
||
EvoScientist/gateway/server.py
|
||
EvoScientist/langgraph_dev/manager.py
|
||
EvoScientist/langgraph_dev/graphs.py
|
||
EvoScientist/backends.py
|
||
EvoScientist/mcp.py
|
||
EvoScientist/middleware/code_interpreter.py
|
||
EvoScientist/middleware/background.py
|
||
EvoScientist/background.py
|
||
EvoScientist/middleware/scheduler.py
|
||
EvoScientist/cron/schedule.py
|
||
EvoScientist/middleware/memory_lifecycle.py
|
||
EvoScientist/memory/launch.py
|
||
EvoScientist/memory/scheduler.py
|
||
EvoScientist/memory/agents/_factory.py
|
||
EvoScientist/memory/agents/memory_worker.py
|
||
EvoScientist/tools/skill_manager.py
|
||
EvoScientist/tools/skills_manager.py
|
||
EvoScientist/cli/workspace_cutover.py
|
||
EvoScientist/paths.py(仅保留部署根目录能力,不用于运行时切换)
|
||
```
|
||
|
||
实际实施前应再次搜索所有 `WORKSPACE_ROOT`、`getWorkspaceDir()`、
|
||
`resolve_virtual_path()`、`set_active_workspace()`、`threads.create()`、`runs.create()`、
|
||
`runs.wait()`、`runs.get()`、`runs.list()`、`threads.getState()`、`threads.updateState()`、
|
||
`crons.*()`、`assistants.get()`、`assistants.search()`、`new Client()`、`useClient()` 和
|
||
`useStream()` 调用,不能只修改上述已知入口。
|
||
|
||
## 16. 验收测试
|
||
|
||
### 16.1 路径和 API
|
||
|
||
1. A 上传 `result.txt`,A 可以列表、读取、编辑和下载。
|
||
2. B 上传同名 `result.txt`,A、B 内容互不覆盖。
|
||
3. 用 B 请求 A 的虚拟路径返回 `404`。
|
||
4. `../`、绝对路径、控制字符和符号链接逃逸全部失败。
|
||
5. 下载全部只包含当前 scope 文件。
|
||
6. API 响应和日志不向浏览器暴露物理根目录。
|
||
7. 浏览器传入其他 deployment URL 或伪造 scope 时,服务端拒绝而不访问本地文件。
|
||
8. 浏览器直连 LangGraph API、携带公开 API key 或直接提交 `config`/`metadata` 均失败;
|
||
同一 `run_request_id` 的 BFF 重试只创建一个 run,而同一 `turn_id` 的审批恢复可创建
|
||
独立 run。
|
||
9. 线程重命名、置顶、模型选择和文件附件状态均只能经过 BFF;伪造的 metadata、文件路径
|
||
或 model 不修改线程。重新打开后仍使用保存的 allowlist 模型。
|
||
10. 线程导出仅返回当前主 thread 的脱敏状态,不能通过 export 获取派生 thread 或物理目录。
|
||
11. 静态门禁在浏览器 bundle 中发现 LangGraph Client、`useStream`、公开 API key 或
|
||
deployment URL 时失败;服务端 BFF SDK 未被误判。
|
||
|
||
### 16.2 对话生命周期
|
||
|
||
1. New Chat 上传前只创建一个 draft thread。
|
||
2. 上传和发送同时发生时仍只使用一个 thread/scope。
|
||
3. 第一条消息后 draft 转为 active,历史列表只出现一次。
|
||
4. 刷新、重新打开和审批恢复后 scope 不变。
|
||
5. 删除时 active run、后台进程和 schedule 先终止;仍在运行时保持 `deleting`。
|
||
6. 删除对话后不能再读文件,trash 按策略清理。
|
||
7. 过期 draft 的 thread 和目录都被清理。
|
||
8. 删除使用 Registry 枚举并 drain async thread、Memory Worker、cron 与后台进程;未知
|
||
owner 时不删除目录。
|
||
9. cutover 为所有历史主 thread(包含从未打开的 thread)建立唯一 scope;报告中的 thread
|
||
数、scope 数和 primary owner 数一致。
|
||
10. 无法归属的 legacy run、cron、后台进程和 worker 均被 quarantine,`required` 切换被
|
||
拒绝直至报告无遗留项;未知旧 thread 在 `required` 返回 `migration-required`。
|
||
11. cutover 租约存续时 BFF 与内部创建入口均返回 `503 cutover-in-progress`;报告 hash、
|
||
deployment ID 和全部 gate 在 `required` 启动前重新验证。
|
||
12. 带 scope metadata 的派生 thread 和 cron 必须匹配 Registry 的 resource owner;不匹配的
|
||
资源被 quarantine/禁用,scope metadata 不得继续保留在可运行线程上。
|
||
|
||
### 16.3 Agent 执行
|
||
|
||
1. A、B 并发让 Agent 写 `/result.txt`,实际落入两个目录。
|
||
2. 主代理无法通过 `ls`、文件工具或 shell 看到其他 scope。
|
||
3. 同步子代理读写父 scope。
|
||
4. 异步子代理拥有不同 thread ID,但读写父 scope。
|
||
5. 后台进程以当前 scope 为 cwd;B 不能查询、停止或读取 A 的日志。
|
||
6. Memory Worker、Observation Linker 和 AutoSkills 只能读取父 scope,不能列举其他
|
||
conversation 目录。
|
||
7. 严格模式中 `skill_manager` 的安装和卸载一律失败,不能解析任何本地 source。
|
||
8. Scheduler 的 cron payload 同时包含 scope config 和 metadata,恢复执行后仍写入创建
|
||
它的 scope。
|
||
9. A 的 Scheduler 系统提示、列表和取消操作均不能看到或操作 B 的 cron。
|
||
10. 在 A 的 `execute` 与后台容器中使用 Python 的 `Path.home()`、父目录枚举、环境变量
|
||
和绝对宿主机路径均无法读取 B 或部署根目录。
|
||
11. 启用 scoped Code Interpreter 时,从 JS 调用 `ls`、`read_file`、`glob` 和
|
||
`start_async_task` 都只使用父 scope;运行时缺失或伪造 scope 时全部失败。禁用配置下
|
||
不注册 `code_interpreter` 工具。
|
||
|
||
### 16.4 安全和回归
|
||
|
||
1. `required` 模式缺少 scope 时 run 失败,不回落公共目录。
|
||
2. `required + dangerous_mode` 启动失败并给出明确错误。
|
||
3. 所有启用的 MCP 通过能力审计。
|
||
4. `/skills/`、`/memories/` 可按共享策略使用,但不允许任何工具跳转到 conversations
|
||
目录。
|
||
5. 浏览器不能直接创建带 scope 的主 run;严格模式中的 run 均由服务端对话接口创建。
|
||
6. `required` 模式拒绝本地文件/进程 MCP,并拒绝 Agent 的 Skill 安装和卸载。
|
||
7. OCI runtime、镜像摘要或容器最小挂载校验失败时,`required` 模式拒绝启动。
|
||
8. WebUI 绑定非 loopback 且未启用认证时,`required` 模式拒绝启动。
|
||
9. 模型选择、三种审核模式、Token 统计和断线恢复不因新增 scope 回归。
|
||
10. 对同一 interrupt 在执行过程中分别提交 approve、reject 和编辑后批准,BFF 只恢复
|
||
第一项对应 scope/run,后续提交返回 `interrupt_already_resolved`;初始 review_mode 不会
|
||
自动批准已发出的 interrupt。
|
||
11. Registry schema 迁移、revision CAS、owner 外部资源唯一性、run request 幂等冲突和崩溃后的
|
||
saga 补偿均有单元测试与断电恢复集成测试。
|
||
12. 历史加载、SSE 断线续接和无法续接后的 `ThreadSnapshot` 回读都只经过 BFF,且审批卡、
|
||
Token 统计和消息顺序与当前行为一致。
|
||
|
||
## 17. 可观测性
|
||
|
||
日志可以记录:
|
||
|
||
```text
|
||
thread_id
|
||
workspace_scope_id
|
||
run_id
|
||
operation
|
||
virtual_path
|
||
result
|
||
```
|
||
|
||
不得记录上传文件内容、Secret 或完整物理路径。建议增加以下指标:
|
||
|
||
- 缺少 scope 的 WebUI run 数量。
|
||
- scope/config/metadata 不一致数量。
|
||
- 按 scope 的文件数量和字节数。
|
||
- draft、trash 和孤儿目录数量。
|
||
- 被拒绝的跨目录、符号链接和后台进程访问次数。
|
||
|
||
## 18. 默认产品决策
|
||
|
||
若没有额外产品要求,实施时采用以下默认值:
|
||
|
||
- 私有范围:上传文件、Agent 生成文件、后台日志。
|
||
- 共享范围:Skills、Memories。
|
||
- 浏览器权限:只提交白名单业务字段,不创建带 scope 的主 run;严格模式由服务端对话
|
||
接口创建 draft、run、resume、异步代理查询和定时任务;浏览器不保有 LangGraph 凭据。
|
||
- 模型选择:服务端 allowlist 校验后以 `model_override` 存入当前主 thread,重开对话沿用。
|
||
- 审核选择:每个 interrupt 单独决定 approve、reject 或编辑后批准,不在对话开始时锁定。
|
||
- Memory Worker:只读当前 scope 的 `files/`;无法完成运行时改造时在严格模式禁用。
|
||
- Skills:严格模式下 Agent 只能浏览已安装的共享 Skills,不能安装或卸载。
|
||
- MCP:严格模式仅允许服务端 allowlist 中的纯远程 API MCP。
|
||
- 命令执行:严格模式要求 OCI 容器执行器,只挂载当前 scope 的 `files/`。
|
||
- New Chat:首次上传或发送时预创建 draft thread。
|
||
- 草稿 TTL:24 小时。
|
||
- 删除回收:7 天。
|
||
- 旧文件:只允许用户显式导入,不自动分配。
|
||
- 历史对话:切换 `required` 前批量创建 scope、登记或 quarantine 全部 owner;未知旧 thread
|
||
不可运行,不能按首次打开懒迁移。
|
||
- 对话复制:新建空目录,不共享可写目录。
|
||
- 公共项目目录:默认不挂载;需要时单独只读挂载。
|
||
- WebUI 默认:`EVOSCIENTIST_WORKSPACE_ISOLATION=optional`,新对话隔离;scope 服务、
|
||
令牌或 Registry 不可用时拒绝操作。需要公共目录兼容时,管理员必须显式设置 `legacy`。
|
||
- 高安全部署:在 `.env` 显式设置 `EVOSCIENTIST_WORKSPACE_ISOLATION=required`,并完成
|
||
全量 cutover、镜像摘要校验和严格执行器配置。
|
||
|
||
上述默认值能在不引入多用户权限系统的前提下,实现当前 WebUI 单用户部署中的
|
||
可靠会话文件隔离,并为后续用户级 ACL 和更严格的 Memory 隔离保留扩展位置。
|