# 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 / .evoscientist/ conversations/ / 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 /.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/ POST /internal/workspace-scopes//owners PATCH /internal/workspace-scopes//owners/ GET /internal/workspace-scopes//owners/by-resource/ POST /internal/workspace-scopes//runs/reserve PATCH /internal/workspace-scopes//runs/ PATCH /internal/workspace-scopes/ ``` 每个接口只接受 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/ # 已脱敏的 thread 状态与历史 PATCH /api/conversations/ # title、pinned、model_override PUT /api/conversations//file-state # 已上传文件的会话附件状态 GET /api/conversations//export # 下载已脱敏的完整对话 JSON DELETE /api/conversations/ # 删除 thread 和 scope POST /api/conversations//runs # 创建主 run 或 resume run GET /api/conversations//runs # 列表、状态和恢复发现 GET /api/conversations//runs/ # 单个主 run 状态 POST /api/conversations//runs//cancel GET /api/conversations//runs//stream # 服务端转发 SSE GET /api/conversations//async-tasks GET /api/conversations//async-tasks/ POST /api/conversations//async-tasks//runs GET /api/conversations//schedules POST /api/conversations//schedules PATCH /api/conversations//schedules/ DELETE /api/conversations//schedules/ POST /api/conversations//schedules//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/` 只接受 `{ 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/` 获取 `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; ``` 解析步骤: 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//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 ``` 行为: 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= -> 服务端校验 thread -> 写入 /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=&path=... GET /api/workspace?threadId=&recursive=1 POST /api/workspace/upload?threadId= GET /api/workspace/file?threadId=&path=... PUT /api/workspace/file?threadId=&path=... DELETE /api/workspace/file?threadId=&path=... GET /api/workspace/download?threadId= ``` 查询参数适用于图片 `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/ ``` 建议流程: 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/.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 /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` 会在 `/.evoscientist/control/` 生成仅服务端读取的令牌,并传给后端;独立 WebUI 也会从该位置读取。仅在 WebUI 与后端无法共享该目录时,才在两端 `.env` 中配置相同的 服务端密钥。严格模式另行在 `.env` 中设置: ```text EVOSCIENTIST_WORKSPACE_ISOLATION=required EVOSCIENTIST_STRICT_EXECUTOR=oci EVOSCIENTIST_STRICT_EXECUTOR_IMAGE= 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 隔离保留扩展位置。