Files
EvoScientist-WebUI/docs/conversation-workspace-isolation.md
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

66 KiB
Raw Permalink Blame History

WebUI 会话级工作目录隔离修改方案

版本:1.4 | 日期:2026-07-18 | 所属项目:EvoScientist-WebUI / EvoScientist 状态:实施中。WebUI BFF、会话 scope 解析和 cutover owner 对账已实现;切换 required 前仍须在目标部署执行全量 cutover 并验证运行环境。

1. 决策摘要

当前 WebUI 和 EvoScientist 后端共同使用部署级固定工作目录。文件上传、文件树、 Agent 文件工具和后台命令都可能在同一个物理目录中读写,因此不同对话之间没有 文件边界。

本方案将工作目录改为按主对话隔离:

一个主 LangGraph thread
    -> 一个不可变 workspace_scope_id
    -> 一个独立、可写的物理工作目录

推荐目录结构:

<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 均直接使用这个目录:

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 构建时创建:

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

新增运行字段:

workspace_scope_id

每个主对话都在后端 Scope Registry 中有一条不可变映射:

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 增加:

{
  "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 数据库:

<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 服务。

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 的状态迁移为:

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 提供仅服务端可调用的内部接口,使用独立服务凭据:

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 都显式携带:

{
  "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:

主图: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 解析器:

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 凭据:

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 所有权,不属于本期范围。

新增服务端对话接口:

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() 作为部署根目录解析器,新增:

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 新增独立模块,例如:

../EvoScientist/EvoScientist/workspace_scope.py

建议接口:

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 构造在文件操作 的工作线程中完成:

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 暴露或内部共享一个幂等方法:

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 上传时序

用户在 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:

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() 的签名调整为:

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 前校验:

Registry(workspace_scope_id).primary_thread_id == 主 thread_id

加载历史 thread 时从 thread metadata 恢复 scope,而不是从当前 UI 状态猜测。

9.2 同步子代理

同步子代理与父 run 共用当前 ToolRuntime 和 backend 工厂。验收时必须验证它读取和 写入的是父对话目录,并发子代理写文件时仍受同一根目录限制。

9.3 异步子代理

异步子代理会创建自己的 LangGraph thread,因此它的传播规则是:

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,隔离后应由统一服务端接口 编排对话和文件删除:

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 事务和审计记录,不能绕过上述盘点。

当前实现的管理入口为:

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 镜像已就绪,缺失时拒绝启动。

定期生命周期清理由部署管理员执行;建议每小时运行一次:

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:

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 的常用配置如下:

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 中设置:

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

新增:

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

修改:

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

新增:

EvoScientist/workspace_scope.py
EvoScientist/scope_registry.py
EvoScientist/execution/scoped_executor.py

修改范围:

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. 可观测性

日志可以记录:

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 隔离保留扩展位置。