Files
EvoScientist-WebUI/docs/conversation-workspace-isolation.md
T
m4 fe982f7f95
CI / macos-latest / Node 20 (push) Has been cancelled
CI / ubuntu-latest / Node 20 (push) Has been cancelled
CI / windows-latest / Node 20 (push) Has been cancelled
feat: add workspace isolation and administration UI
2026-07-19 12:17:18 +08:00

1332 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 隔离保留扩展位置。