Files
Agent-System-Documents/docs/proposals/2026-10-15-layered-python-env.md
T
weichenandCursor 945db355fc Record the layered Python environment proposal
Why: 8788 could not render docx because the sandbox hides host site-packages and there is no request path for extra packages. The proposal fixes the design before the implementation lands.
Evidence: SELF — proposal written from the cnv_7936c0a799ff transcript and the existing exec-deps-dir code path.
Impact: Documents only. Index lists the proposal as pending review, not approved.
Refs: docs/proposals/2026-10-15-layered-python-env.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 13:29:49 +00:00

8.6 KiB
Raw Blame History

分层 Python 环境方案 r1

状态:待评审(收录不是批准)。编制:2026-10-15。 触发:dev 8788 会话 cnv_7936c0a799ff 中,智能体无法把已有 Markdown 摘要渲染成 docx,并向用户报告「没有 Python 对应的包」。

1. 已核实的故障(不是「缺包」本身)

8788 的 run_python / run_shell 跑在 bwrap 沙箱里(只读根、--tmpfs /home、--unshare-net)。同一台宿主机的 ~/.local 里其实已经有 python-docx、lxml、openpyxl、python-pptx。失败来自四件事叠在一起:

  1. 依赖层没有接线。 代码已有 --exec-deps-dir(F3),但 scripts/svc.sh 与 8788 的启动参数都没有传入它。沙箱把 /home 遮掉之后,~/.local 不可见。
  2. 解释器解析到会被遮掉的路径。 服务进程的 PATH 里 linuxbrew 在 /usr/bin 之前,python3 被解析成 /home/linuxbrew/.linuxbrew/bin/python3。沙箱里这条路径不存在,run_python 得到 exit=127。
  3. 工具说明指向一条走不通的安装路径。 run_python 描述和 base.deliverables 都写「缺库先 pip install --user」。沙箱断网、根只读、家目录是空 tmpfs,这条命令必然失败。
  4. 没有向系统申请包的通道。 智能体只能探测、报错、让用户选择,再派一个后台任务去轮询一个永远不会出现的依赖。

同一次会话里还有多次模型网关 401 invalid_api_key。那是密钥问题,不在本方案范围内。

2. 目标

每个工作区和沙箱都能直接使用系统提供的 Python 能力(解释器 + 办公与数据处理基础包),不必各自新建虚拟环境。某个工作区需要额外的包时,向系统申请;系统决定装不装,并只把结果挂给这个工作区。

成功标准:

  • 沙箱内 import docx, openpyxl, pptx 成功,python3 -m housestyle.build 能产出通过结构自检的 docx。
  • 两个工作区要同一包的不同版本时,互不影响,基础层版本不变。
  • 沙箱仍然断网。安装只发生在宿主侧的构建器里,并且只接受带哈希的预编译 wheel。
  • 白名单内的包自动批准;白名单外、要改基础层版本、体积超限、用户上传的 wheel,弹审批卡。sdist 与黑名单直接拒绝。

3. 不采用的两条路

  • 每个工作区一个 venv。 贵的是重复下载、解析和安装,不是 venv 目录本身。同样的包会被装很多遍。
  • 所有包装进一个可变的系统环境。 一个工作区的需求会改变所有工作区的行为,版本冲突无法隔离,也无法复现「当时用的是哪一套包」。

4. 采用的结构

所有环境层不可变,按内容哈希命名,按会话挂载。系统只新增层,不改已经发出去的层。

<envroot>/                      默认 runtime/envs/,各服务实例共享
  python/cpython-<ver>/         系统托管的 CPython(只读)
  python/current -> 上述目录
  layers/base-<hash>/site-packages
  layers/x-<hash>/site-packages 扩展层:相对基础层的增量闭包
  layers/iso-<hash>/site-packages  完整隔离层:仅在审批通过「覆盖基础层版本」时使用
  wheelhouse/                   按 sha256 去重的 wheel
  locks/<hash>.json
  catalog.json                  白名单、黑名单、单包体积上限
  current -> layers/base-<hash>
  bin/pip                       垫片:提示改用 python_env,不以安装成功退出

层键 = 锁文件内容 + Python 标签 + 平台的哈希。同一份需求在任何工作区都命中同一层。

基础层由系统策划,首批为:python-docx、openpyxl、python-pptx、lxml、Pillow、numpy、matplotlib、pypdf、reportlab、markdown、pdfplumber,以及它们的传递依赖。名单可以在评审时增减;锁文件在第一次 env ensure 时生成并应入库,之后构建按锁复现。

解释器优先使用托管前缀(python-build-standalone 的 install_only 包)。下载失败时,退化为用系统 python3 -m venv 做一份唯一的托管前缀,并在 python/SOURCE 里写明这是退化,不是每个工作区各做一份。沙箱里的 python3 永远是这个前缀的绝对路径;找不到托管前缀时才用 /usr/bin/python3,不再走会落到 /home 下的 PATH 查找。

5. 一次执行如何看到这些包

沙箱参数在 --tmpfs /home 之后,只读挂载:托管解释器前缀(如果它位于会被 tmpfs 遮住的路径上)、基础层、该会话绑定的扩展层。PATH 固定为 <托管 bin>:<envroot>/bin:/usr/local/bin:/usr/bin:/bin,不再继承宿主 PATH。PYTHONPATH 是这些层再加上 house-style。PIP_NO_INDEX=1。

扩展层与基础层来自同一次以基础层为约束的解析,每个发行名只有一个版本,所以叠放顺序不影响导入。隔离层例外:它替换基础层,而不是叠在上面,用来满足「这个工作区必须要 numpy<2」这类与基础层冲突的需求。

6. 申请(工具 python_env)

动作:status、request、wait。

  • status 返回基础层包清单、本会话已绑定的层、未完成的申请。
  • request 带包规格或工作区内的 .whl。解析以基础层锁为约束(等价于带 constraints 的 pip install --dry-run --report)。分级:
    • 全部在白名单内、纯 wheel、未超体积:自动批准并构建。命中已有层时只是多挂一个目录。
    • 白名单外、要覆盖基础层已固定的版本、超体积、用户上传的 wheel:写入 exec_env_requests,发一张选择卡(批准 / 拒绝;覆盖基础层版本时多一个「建隔离层」)。系统管理员或该会话的属主可以批。决定写入 audit_events。
    • 只有 sdist、命中黑名单、解析失败、哈希不符:拒绝,并说明原因。
  • 构建在宿主侧进行:--only-binary=:all:,校验 sha256,只用解包 wheel,不执行 setup.py。这是唯一有网络的步骤,并且不在沙箱里。
  • 构建完成后把层 ID 写进 exec_env_bindings。下一次 run_python / run_shell 挂上这些层。wait 在短超时内轮询申请状态。

7. 三个顾虑如何被挡住

  • 成本。 基础层覆盖常见办公和数据场景。wheel 与层按哈希缓存,第二个工作区要同一套包时不再下载、不再解析。
  • 冲突。 不存在共享的可变环境。冲突需求要么被拒绝并说明卡在基础层的哪一个版本,要么经审批得到一份只属于该会话的隔离层。基础层哈希不变。
  • 包都堆进系统环境。 基础层只放策划过的包。扩展层按会话绑定,其他会话看不见。升级基础层生成新的 base-<hash> 并移动 current;已经绑定旧基础层的会话保持不动,直到显式 rebase。

8. 安全

  • 沙箱断网、根只读、家目录遮蔽,这些都不放宽。
  • 不在宿主上执行第三方构建脚本。
  • 只挂当前会话绑定的层。
  • 申请记录含会话、包、wheel 哈希、批准人。
  • 任务封存时应能读到基础层哈希与层 ID(绑定表就是这份清单)。

9. 实施切片

  • P0 解释器与 PATH 不再指向会被沙箱遮住的 linuxbrew;svc.sh 在环境根存在时传入 AGENT_SYSTEM_EXEC_ENV_ROOT 与基础层 AGENT_SYSTEM_EXEC_DEPS_DIR;工具说明与提示词改为 python_env,去掉 pip install --user;配置了依赖层时启动做真实导入探针。
  • P1 环境根、锁、层、wheelhouse、托管解释器,以及 agent-system env ensure|lock|gc|doctor|rebase|python。
  • P2 表 exec_env_requests、exec_env_bindings,工具 python_env,分级审批,pip 垫片,按会话挂载。
  • P3 管理区「Python 环境」页(层、申请、批准/拒绝、rebase、gc、上传 wheel)。

10. 验收

  • sandboxArgv 覆盖多层只读挂载、固定 PATH、PIP_NO_INDEX。
  • 离线解析夹具:冲突、白名单、sdist 拒绝、哈希不符。
  • 有 bwrap 时:沙箱内能导入层里的模块,pip 垫片给出申请指引,外网不可达。
  • 两个会话分别要 foo==1 与 foo==2,各得一层,基础层哈希不变。
  • 在基础层可用时,housestyle.build 产出的 docx 通过既有 OOXML 结构自检。

11. 已定与未定

已定(2026-10-15 对话):白名单内自动批准,其余弹审批卡;解释器由系统托管,而不是长期绑死宿主 /usr/bin/python3。

未定,留在评审:基础层最终包名单是否增减;审批人除系统管理员和会话属主外,是否还要接项目角色。本实施先只认系统管理员与会话属主,避免在基座里发明第二套项目角色判断。