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>
109 lines
8.6 KiB
Markdown
109 lines
8.6 KiB
Markdown
# 分层 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. 采用的结构
|
||
|
||
所有环境层不可变,按内容哈希命名,按会话挂载。系统只新增层,不改已经发出去的层。
|
||
|
||
```text
|
||
<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`。
|
||
|
||
未定,留在评审:基础层最终包名单是否增减;审批人除系统管理员和会话属主外,是否还要接项目角色。本实施先只认系统管理员与会话属主,避免在基座里发明第二套项目角色判断。
|