news 2026/9/17 19:25:40

Yuxi 项目 Workdir 与 Sandbox Runtime 基础:从独立存储域到 UserWorkspace 的架构演进

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Yuxi 项目 Workdir 与 Sandbox Runtime 基础:从独立存储域到 UserWorkspace 的架构演进

Yuxi 项目 Workdir 与 Sandbox Runtime 基础:从独立存储域到 UserWorkspace 的架构演进

【免费下载链接】Yuxi可私有部署的多租户知识智能体平台:统一 RAG、知识图谱、多智能体、MCP/Skills、沙盒与权限管理。Self-hosted knowledge agent platform for RAG, knowledge graphs and multi-agent workflows.项目地址: https://gitcode.com/GitHub_Trending/yu/Yuxi

本文以仓库中已归档的技术决策记录 2026-08-18-project-workdir-runtime-foundation.md 为主体骨架,结合其后继落地决策与 backend、sandbox provider、sandbox provisioner 等源码实现,完整还原 Yuxi 平台"Project Workdir 与 Sandbox Runtime 身份解耦"这一关键架构演进:先提出独立ProjectWorkdir存储域,再被"Workdir 归属 UserWorkspace"的简化方案完全取代。读者将理解 Workdir、runtime scope、Skill 投影三者的职责边界,以及沙盒挂载、generation 契约与权限校验的底层实现。

一、文档历史定位:一项被取代的架构决策

该文档在仓库中处于archived(已归档)状态,属于"simplification(简化)"类型决策。文档开头明确声明:它已被 Workdir 归属 UserWorkspace 并取消独立 Project 存储域完全取代,仅保留为历史背景

这意味着阅读本文时需要把握两层信息:

  1. 历史方案(本决策记录的主体):引入独立的ProjectWorkdirPostgreSQL 模型、runtime_scope_id执行树分组键、Sandbox generation 契约与按 uid 汇总的只读 Skill 投影;
  2. 演进结果(后继决策):放弃独立 Project 存储域,让 Workdir 变成 UserWorkspace 下的一个相对路径,Sandbox 挂载收敛为两个逻辑域。

同时,文档也记录了语义 Owner 的划分原则:

  • ProjectWorkdir与 Conversation/AgentRun 绑定由PostgreSQL model、repository 和 schema migration拥有;
  • Sandbox identity、generation 和挂载校验由agents/backends/sandbox/provider.pydocker/sandbox_provisioner/app.py拥有;
  • 用户 Skill 投影由agents/skills/service.py拥有。

这套"谁拥有什么"的划分,正是后续所有实现落地的组织原则。

二、问题:旧 Sandbox identity 的多重耦合

决策记录指出的核心问题是旧 Sandbox identity 同时混入了三类来源

  • 文件 thread:历史文件所属的线程 ID 被当作运行环境标识;
  • Skills thread:按线程复制出来的 Skill 文件来源;
  • 每个 Run instance:每次运行实例的临时环境。

其后果有三:

  1. 文件授权、Agent 选择与运行环境生命周期互相耦合——文件属于哪个线程本应是文件系统的授权问题,却被错误地提升为"运行环境隔离边界";
  2. 用户 workspace 只有隐式路径,没有可供未来多个 Conversation 共享的持久工作目录身份;
  3. provisioner 缺少 generation 契约——无法防止旧的观察结果误删新创建的实例(典型的 ABA 问题)。

此外,Skills 文件按 thread 复制存在双重缺陷:既不能表达"用户授权全集"(一个用户的 Skill 权限是全量的,而不是某个线程的子集),也会在多 worker 同步共享来源时产生竞争与越界风险

三、决策:分离文件身份与运行环境身份

针对上述问题,该决策提出了一套完整的身份模型,其核心思想是让"文件身份"(Workdir)与"运行环境身份"(runtime scope)彻底分离

3.1 ProjectWorkdir:持久化的文件身份

PostgreSQL 使用ProjectWorkdir保存四类信息:opaque ID、所属 uid、存储键(storage key)与物化状态(materialization state)

  • 顶层 Conversation 创建时默认创建 Workdir
  • 子 Conversation 通过SubagentThread继承根 Conversation 的workdir_id
  • 跨用户绑定被拒绝——Project 与 uid 的归属关系是硬约束。

这为"未来多个 Conversation 共享同一持久工作目录"提供了身份基础,而不需要再次改变文件协议。

3.2 runtime_scope_id:执行树的运行环境身份

AgentRun.runtime_scope_id持久保存根 Conversation 的 runtime scope。在此之后:

  • Sandboxhash、cache key、wire、Docker label、Kubernetes annotation 和工具连接一律不再使用file_thread_idskills_thread_id
  • 当前 identity 由uid + runtime thread + 可选 instance组成;
  • Workdir 作为不可漂移的挂载约束——它约束"这个运行环境挂载哪个目录",但不参与身份哈希。

这一设计直接回应了"文件授权被错误提升为环境隔离边界"的问题:两个顶层 Conversation 即使绑定同一个 Workdir,也只是"共享文件",绝不"共享运行环境"。

3.3 generation 契约:防止旧观察误删新实例

provisioner 为每次 runtime incarnation(运行实体)返回 generation。所有涉及实例生命周期的操作——发现(discover)、缓存、删除、idle reaper、Kubernetes 409 恢复——都复核 identity/generation,从而保证:

旧观察不能删除或接管新的同名实例。

这是并发安全的基石。若删除了 generation 复核,旧 worker 的延迟删除请求就可能误删新创建的 Sandbox。

3.4 挂载契约:Project/User/Skills 三个可选挂载

Docker 与 Kubernetes 支持可选的 Project/User/Skills mount contract:

  • Project Workdir 在 Sandbox 内使用/home/gem/projects/project-<opaque-id>,并作为显式 Workdir fixture 的默认目录;
  • Kubernetes contract 要求RWX PVC/subPath
  • 文档明确注明:当前 shipping 文件主链路尚未切换到该可选 contract(该切换属于后续决策的范围)。

3.5 Skills 授权投影:按 uid 汇总的只读全集

  • /home/gem/skills是按 uid 汇总的共享/内置授权全集只读投影
  • 个人 Skill 直接保留在 UserWorkspace,不进入共享投影;
  • Agent 配置只控制 Prompt 和工具激活,不改变 Sandbox identity 或文件可见集合——这是权限模型的关键边界;
  • 投影刷新在 PostgreSQLuid advisory lock内重读最新授权,再以共享卷flock 串行替换
  • 授权上下文缺失时 fail-closed(默认拒绝),绝不"无授权也放行"。

3.6 投影的受限安全复制:fd-relative + O_NOFOLLOW

共享 Skill 投影刷新使用从文件系统根逐组件O_NOFOLLOW的 fd-relative 快照,只复制普通文件和真实目录。以下特殊项会导致删除旧 slug 投影并阻止本次刷新

  • symlink 竞态;
  • Unix socket;
  • FIFO;
  • 设备等特殊项。

这套机制杜绝了经典 TOCTOU 攻击路径——symlink 指向文件系统根时,任何基于字符串路径的复制都会越权,而 fd-relative 打开(每级目录都以O_NOFOLLOW打开后持有 fd 再进入下一级)让攻击者无法在复制中途替换目录。

文档同时指出:personal Skill 的持久源与单一路径已由 Skill 持久源与只读投影收敛 接管,本决策只保留授权投影的并发与安全基础

四、被拒绝的替代方案

决策记录明确列出了四条被拒绝的路线及理由,这些理由本身就是理解设计取舍的注脚:

方案拒绝理由
继续让file_thread_idskills_thread_id参与 Sandbox identity把历史文件 Owner 和 Agent 选择错误地提升为运行环境隔离边界
在 4R-A 直接切换 uploads/outputs/Viewer历史数据尚未完成全量物化、缺少维护 fence 和 activation gate,局部切换会产生按 Conversation 混跑与空目录假成功
用 MinIO 或 s3fs 模拟实时 POSIX Workdir对象存储不拥有 rename、partial write、锁和多进程可见性所需的完整文件系统语义
把 personal Skill 复制进共享授权投影个人目录由 UserWorkspace 直接提供,投影只承载共享与内置 Skill

五、后果与验证记录

5.1 后果

  • Project 文件身份与 Sandbox runtime identity 已分离,未来 Project 只需让多个顶层 Conversation 指向同一workdir_id,无需再次改变文件协议;
  • 同 uid 的父子 Agent看到相同共享 Skill 投影与 UserWorkspace 个人 Skill,但各自Prompt/工具仍保持选择隔离
  • 实时文件行为由后续 owning decision 负责,本记录只保留 Workdir/runtime identity 与 Skills 投影基础;
  • Skills 投影刷新会对共享来源执行受限安全复制跨 worker 串行化,换取授权一致性;
  • 真实 Kubernetes RWX 行为仍需目标集群 smoke,Compose 和 Pod spec 测试不能替代该证据

5.2 验证数据(决策当时的测试记录)

该决策在落地时拥有成体系的测试证据:

  • backend non-slow unit:1377 passed, 26 skipped;Skills 定向 unit:60 passed
  • 真实 PostgreSQL Workdir/schema/runtime scope 与 advisory-lock 撤权 integration:5 passed
  • 真实 Docker 双 Sandbox:同 Workdir 文件互见且/tmp隔离Skills 跨 Sandbox 共享、跨 uid 隔离、只读写拒绝2 passed
  • output revision 旧链路兼容 integration:5 passed
  • symlink 交错、特殊文件、执行位、确定性两进程 flock、缺失授权上下文和 generation/ABA均有负向测试;
  • 工程契约48 passed,Ruff check/format、git diff --check与 docs build 通过;Darwin Unix socket 负控真实运行通过;
  • 真实 Kubernetes RWX smoke:Not run(未执行)。

决策还明确了"旧能力不存在"清单:Sandbox identity/wire/mount/tool 链路不再接受file_thread_idskills_thread_id;Skills 不再按 thread/Agent 选择创建文件投影;personal Skill 投影不再使用会跟随 symlink 的复制路径。同时给出重新引入条件:只有新的产品边界明确要求不同文件 thread 或 Skill 选择拥有独立运行环境,并提供对应生命周期、授权和真实并发证据时,才可重新引入。

六、演进:Workdir 归属 UserWorkspace 并取消独立 Project 存储域

6.1 为什么放弃独立存储域

后继决策 Workdir 归属 UserWorkspace 并取消独立 Project 存储域 指出,把 Workdir 建模为独立的ProjectWorkdir存储域,会为同一用户文件能力引入一整套平行设施:单独的数据库表、storage key、物化状态、宿主机目录、容器挂载和 Kubernetes PVC,与 UserWorkspace 形成两套根目录。同时,新的 Thread 创建需求允许指定 UserWorkspace 内的目录,独立 Project 根目录与该需求的真实权限边界不一致——"选择一个 workspace 目录"不应变成"切换一个存储域和挂载域"。

6.2 新模型:Workdir 是 UserWorkspace 下的相对路径

最终落地模型是:Workdir 保留为对话的逻辑工作目录,但不再是独立存储域;轻量业务 Project 保存一个相对于当前用户workspace的路径:

workdir_path = projects/<opaque-workdir-id>

宿主机和 Sandbox 的映射由 UserWorkspace Owner 统一完成:

宿主机:user-data/shared/<uid>/workspace/<workdir_path> 容器内:/home/gem/user-data/<workdir_path>

/home/gem/user-data直接表示当前用户的 UserWorkspace 根,不再在容器内重复一层workspace。默认 Workdir 的容器路径是/home/gem/user-data/projects/<opaque-workdir-id>,个人 Skill 的容器路径是/home/gem/user-data/agents/skills/<slug>

Workdir 在该设计中是Conversation 的 cwd 和 Thread 文件 API 的默认视图,不是同一用户不同 Thread 之间的文件授权边界。Sandbox 可以访问当前 uid 的整个 UserWorkspace,uid 挂载负责跨用户隔离;Thread 文件 API 仍只暴露绑定的workdir_path。因此Project A 读取 Project B 的文件属于设计范围,可用于引用同一用户的其他项目资料。

系统 Prompt 向 Agent 提供当前workdir_path,并明确默认写入约束(来自 Prompt 实现):

整个 UserWorkspace 对当前 Sandbox 可见。可以读取其他目录作为参考;未经用户明确要求, 不得在当前 Workdir 之外创建、修改、移动或删除文件。

该约束定义的是默认模型行为,不构成安全或授权边界;后端仍只强制 uid 隔离、路径不越界和具体工具拥有的权限。

6.3 runtime_scope_id 与 Workdir 彻底分离

在最终实现中,runtime_scope_id是持久化在AgentRun上的执行树分组键,当前取根 Conversation 的 thread ID:

  • 根 Conversation 的 Run 使用自己的conversation_thread_id
  • SubAgent Run 拥有自己的conversation_thread_id,但继承创建者 Run 的runtime_scope_id
  • 同一runtime_scope_id的父子 Run复用一个 Sandbox runtime,并共享进程、/tmp、运行时依赖和环境;
  • 根执行树终态时,worker 用该值确认没有仍活跃的子 Run;仍在执行的子 Run 先保留cancel_requested与 owner/lease,确认停止后再通过同一清理栅栏销毁 Sandbox;
  • 两个顶层 Conversation 即使显式绑定同一个workdir_path,也具有不同的runtime_scope_id,因此只共享文件,不共享运行环境或生命周期

在源码层面,Sandbox provider 的_sandbox_key(uid, runtime_thread_id)返回f"{uid}::{runtime_thread_id}"作为连接缓存键,sandbox_id_for_threadf"{uid}:{thread_id}"的 SHA-256 摘要前 12 位作为 Sandbox ID——整个 identity 不包含任何 Workdir 信息。同时,_touch_if_needed在每次探活时会复核record.workdir_path != connection.workdir_path,若同一 runtime scope 内 Workdir 发生变化则抛出SandboxIdentityMismatchError,这就是"Workdir 作为不可漂移的挂载约束"的运行时实现。

6.4 挂载边界收敛为两个逻辑域

Sandbox 的持久文件挂载最终收敛为两个逻辑域:

当前用户 UserWorkspace -> /home/gem/user-data rw 共享 Skill projection -> /home/gem/skills ro

Sandbox 的 cwd 设置为/home/gem/user-data/<workdir_path>不再存在:容器内的/home/gem/user-data/workspace中间层、独立的/home/gem/projects根目录、Project bind mount、DOCKER_PROJECTS_HOST_PATHPROJECT_DATA_PVC。每个 Thread 只改变 cwd,不改变挂载配置。

uploads/outputs/是当前 Workdir 下的目录约定:/home/gem/user-data/<workdir_path>/uploads/home/gem/user-data/<workdir_path>/outputs两者都按首次使用创建;Sandbox provisioner 只验证挂载与 cwd,不预建业务目录,也不递归修改整个 UserWorkspace 权限。

6.5 旧数据迁移与运行身份

  • storage-migrator收敛为一次性旧布局迁移 Owner:升级基线是 v0.7.1,每个历史顶层 Conversation 获得 implicit Project 和确定性派生的 canonicalprojects/<uuid>;v0.7.1 threaduploads/outputs导入对应 Workdir,持久化的/home/gem/user-data/workspace/...改写为/home/gem/user-data/...;未发布的ProjectWorkdirFileStorageMaterializationworkdir_id中间 schema明确拒绝,不执行兼容导入;新安装直接使用 UserWorkspace 布局;
  • 统一运行身份(见 统一 Workspace 运行身份并删除权限补丁):API、worker 与 Sandbox 数据面统一使用数值身份1000:1000访问同一 UserWorkspace;新目录0o700、新文件0o600;旧数据由 root storage migrator 在运行时启动前一次性收敛所有权与权限,provisioner 不再承担运行时权限修复。

七、源码佐证:从决策到实现的落地链路

7.1 Workdir 授权服务

workdir_service.py 是当前 Workdir 语义的 Owner。核心数据结构WorkdirBinding携带conversation_id / thread_id / uid / project_id / workdir_path / directory_mode,其中directory_mode仅有managedlinked两个合法值(见workdir_binding_from_project的校验),materialize_managed属性决定事务提交后是否物化目录。

ensure_conversation_workdir_available实现了文档要求的"提交后物化"顺序:managed模式调用ensure_bound_user_workdir物化目录,linked模式调用Workdir.open_existing打开已有目录。resolve_authorized_conversation_workdir则对 Conversation 做uidstatus == "deleted"的双重校验后返回AuthorizedWorkdir——这与决策中"跨用户绑定被拒绝"的约束一一对应。

7.2 Workspace 路径与 no-follow 安全

workspace/paths.py 实现了决策中的路径安全契约:

  • normalize_workdir_path拒绝绝对路径、\://以及空/./..组件;
  • ensure_bound_user_workdir通过_open_user_workspace_fd从配置根逐层open_directory_fd打开,拒绝中间 symlinkELOOP/ENOTDIR被翻译为"包含符号链接或非目录组件");
  • workspace_uid_dirname对含:等不安全字符的 OIDC subject 使用 SHA-256 摘要生成uid-<hex>目录名;
  • managed Workdir 命名兼容两种形式:projects/<uuid>projects/<timestamp>_<project-id前8位>[-N](见normalize_managed_workdir_path)。

workdir.py 中的Workdir类把浏览 scope 固定到持久化目录:resolve_path拒绝..\://,所有文件操作(list/read/write/stat/copy/delete)都通过Workspace*_authorized_*方法执行,并携带root=self.root_path防止越界。

7.3 Sandbox runtime 路径契约

agents/backends/paths.py 定义了SANDBOX_VIRTUAL_PATH_PREFIX(默认/home/gem/user-data,可用环境变量SANDBOX_VIRTUAL_PATH_PREFIX覆盖)与/home/gem/skills两个 runtime 根。runtime_workdir_path把持久化 Workdir 标识映射为/home/gem/user-data/<workdir_path>workdir_runtime_paths返回 Workdir 内的outputs/large_tool_resultsoutputs/conversation_history目录;is_runtime_path判断路径是否属于 runtime 命名空间。这些函数构成了"宿主机路径、容器虚拟路径、Workdir scope"三层转换的闭环。

7.4 Provisioner 与挂载实现

docker/sandbox_provisioner/app.py 是决策中"provisioner 拥有 Sandbox identity、generation 与挂载校验"的落地。值得注意的细节:

  • PERSISTENT_SANDBOX_MOUNT_ROOTS兼容性保留/home/gem/skills/home/gem/user-data/home/gem/projects三个历史挂载根;
  • MemoryProvisionerBackendcreate时若existing.workdir_path != normalized_workdir_path直接抛错,delete校验expected_generation否则抛SandboxGenerationMismatchError——这就是 generation/ABA 防护的 reference 实现;
  • LocalContainerProvisionerBackend使用DOCKER_USER_DATA_HOST_PATHDOCKER_SKILL_PROJECTIONS_HOST_PATH两个 host bind 路径,容器内路径分别为/app/user-data/app/skill-projections
  • kubernetes_storage_init_script生成 K8s PVC 子树的一次性身份迁移脚本,以O_NOFOLLOW逐级打开、fchown/fchmod归一化到1000:1000,并带 marker 目录.v072-runtime-identity防重入;
  • SandboxOperationPins让删除等待已开始的 proxy 请求排空,SandboxQuiescenceGate在存储迁移停机后拒绝创建新的 Sandbox generation——与"维护 fence 和 activation gate"的决策一致。

docker-compose.yml 中可以看到:API/worker/storage-migrator 都 bind 同一YUXI_USER_DATA_DIR: /app/user-data,provisioner 服务声明USER_DATA_PVCSKILLS_PVC环境变量,SANDBOX_VIRTUAL_PATH_PREFIX默认/home/gem/user-data——与决策中的单根语义完全吻合。

7.5 Skills 投影的并发实现

agents/skills/service.py 实现了决策中的并发与安全契约:refresh_user_skill_projection_async先执行pg_advisory_xact_lock(PostgreSQL 事务级 advisory lock)重读最新授权,再通过fcntl.flock(LOCK_EX)在共享卷的.locks目录上串行替换投影——"advisory lock 内重读 + flock 串行替换"的双重串行化正是文档描述的实现形态。

八、总结:设计取舍与适用边界

回顾整个演进,可以提炼出三条贯穿始终的设计原则:

  1. 身份分离:文件身份(Workdir/Project)与运行环境身份(runtime_scope_id/Sandbox)严格分离。文件属于谁、Agent 选择谁、运行环境是什么——三件事互不干扰;
  2. 单一根目录:UserWorkspace 是唯一的 POSIX 字节与 uid 隔离边界,Workdir 只是其中的相对路径,任何"第二个根"(独立 Project 根、/home/gem/projects挂载、workdir-files-<id>文件桥接)都被删除;
  3. 并发安全显式化:generation 契约防止 ABA,advisory lock + flock 串行化投影刷新,fd-relativeO_NOFOLLOW阻断 symlink 竞态,fail-closed 保证授权上下文缺失时默认拒绝。

同时要明确其边界:同一用户不同 Thread 之间的文件互不可见不属于当前设计范围(需要重新引入更窄的挂载或等价的强制访问控制);Kubernetes RWX 行为仍需目标集群 smoke 验证;Prompt 的默认写入约束不能阻止被注入的 Agent 跨 Project 写文件——这些是文档明示的接受风险,而非缺陷。

对开发者而言,若要为 Yuxi 增加新的文件或运行环境能力,应遵循当前 Owner 划分:文件视图与授权看 workdir_service.py 与 workspace;Sandbox 生命周期看 provider.py 与 provisioner;Skill 投影看 service.py。

【免费下载链接】Yuxi可私有部署的多租户知识智能体平台:统一 RAG、知识图谱、多智能体、MCP/Skills、沙盒与权限管理。Self-hosted knowledge agent platform for RAG, knowledge graphs and multi-agent workflows.项目地址: https://gitcode.com/GitHub_Trending/yu/Yuxi

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 19:18:01

VS Code 操作 MySQL:连接、SQL 管理与执行计划实战

写业务代码的时候最烦的不是逻辑绕&#xff0c;而是为了确认一条数据&#xff0c;得从 VS Code 切到 MySQL 图形客户端&#xff0c;查完再切回来&#xff0c;思路刚断了一截&#xff0c;回来还得重新把上下文捡起来。我统计过自己一天的窗口切换次数&#xff0c;密集的时候一小…

作者头像 李华
网站建设 2026/9/17 19:16:15

单因素与两因素方差分析:原理、Python实操与常见坑

去年有回&#xff0c;运营同学抱着一份数据来找我&#xff1a;三个落地页版本&#xff0c;各跑了小半个月&#xff0c;回收了每版 300 条左右的用户评分&#xff0c;开门见山就问“到底哪个版本该上线”。我的第一反应不是去看均值谁高谁低&#xff0c;而是先问了自己一句&…

作者头像 李华
网站建设 2026/9/17 19:14:42

Windows电池健康度精准检测与深度校准指南

1. 为什么“电池健康度”不是个虚概念&#xff0c;而是能精准量化的硬件状态指标很多人以为笔记本电池健康度只是厂商宣传话术里的一个模糊词汇&#xff0c;就像手机里“剩余寿命85%”这种提示&#xff0c;点开就看个数字&#xff0c;关掉就忘。但其实Windows系统从Vista时代起…

作者头像 李华