1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
+ ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
149 lines
13 KiB
Markdown
149 lines
13 KiB
Markdown
# 42-实例共享 Python 运行时与会话取证复核(2026-09-11)
|
||
|
||
> **TL;DR**|**结论**:会话取证发现 `python3` 不存在 —— 实为**档案 39 收窄 `/etc` 引入的回归**;本轮改为实例侧使用**共享 Python 运行时**(宿主装一次、全员可用)。
|
||
> **关键**:共享路径走宿主 `/usr`(已 ro-bind),不逐用户安装;同时复核会话取证结论。
|
||
> **状态**:✅ 已修复
|
||
|
||
## 背景与动机
|
||
|
||
1. 用户要求**分析 guest「MCN短视频创作」工作区的最新对话记录**(dsh 服务侧,非 WorkBuddy 侧)。
|
||
2. 会话取证暴露「`python3` 不存在」→ 复核发现是**档案 39 我引入的回归**。
|
||
3. 用户提问:**能不能给 dsh 用户单独装一个 python,不用系统的版本?** 并要求**方便服务器迁移时整体打包**。
|
||
4. 用户补充:**python3.6 不必对用户开放,避免模型迷惑**。
|
||
|
||
## 一、会话取证(8 个会话目录的身份甄别)
|
||
|
||
| 会话 | 创建 | depth | 性质 |
|
||
|---|---|---|---|
|
||
| `session-b83ae69b` | 09-10 21:29 | **0** | **最新主会话**(末次活动今天 16:58;标题「看看有哪些功能和权限尝试所」→「代码库功能与权限探索」)|
|
||
| `session-bd54c7cc` | 09-10 04:12 | 0 | 上午取证过的另一条主会话(12 turns / 4191 行)|
|
||
| `b11581f3` `6ac89f97` `71e06296` `75a54b50` `e7f270f0` `43a2f3cb` | 09-11 16:48–16:51 | **1** | 主会话派发的**子代理**(**非我的验证所致**)|
|
||
|
||
用户一句「看看有哪些功能和权限尝试所有方式」→ agent 系统性实测 **26 个工具** + 沙箱 + 文件策略 + 网络 + 子代理/workflow/ralph,产出 `能力与权限探测报告.md`(7.5KB) + `RALPH_PROBE.md`。
|
||
|
||
**暴露的平台问题**:
|
||
1. **存量会话仍 `workspace-write` + `ask`**(本会话创建于 09-10 21:29,**早于档案 33 的 09-11 14:33 修复 17 小时**)→ 每次 bash 报 `no sandbox backend is usable on this host` → **逐条审批(本次 5 次,全部 `allowed-once`,不持久)**;用户最后一句正是「没办法执行 bash命令了吗」。
|
||
2. **子代理固定 `approval:"never"` → 永久不可用 shell** → 多代理并行实际废掉。
|
||
3. `subagent_fork` 看不到父代理 **in-flight turn**(只继承已完成 turn)→ 两个子代理都回「无继承证据」。
|
||
4. 成果落 `/tmp`(`dy_hot.json` 64KB、`board_*.json`、`c.html` 等)**不在工作区**,有丢失风险。
|
||
5. agent 一条**错误建议**「装 bubblewrap 或换 Landlock 内核」(用户侧装不上;平台已在容器层用 bwrap)。
|
||
|
||
**技术结论修正**:不是「沙箱完全不可用」,而是「**bash 执行沙箱后端缺失;文件工具的 policy 层正常**」(`write DSH_HOME` 被拒 = `[sandbox: file access denied under workspace-write mode]`;`write /tmp` 允许)。
|
||
|
||
## 二、P0 回归修复:`/etc` 白名单漏 `/etc/alternatives`(commit `e32d1bf`)
|
||
|
||
`/usr/bin/python3` 是**两跳软链**(`→ /etc/alternatives/python3 → /usr/bin/python3.6`),中间跳在 `/etc` → 沙箱内断链 → **21 个命令静默 `command not found`**:
|
||
`python3` `python` `pip3` `pip-3` `pydoc3` `pydoc-3` `python3-config` `pyvenv-3` `easy_install-3` `unversioned-python` `ld`(→ld.bfd,node-gyp 用) `pax` `print-*`(lp/lpr/lpq/lprm/lpstat/cancel) `ifup` `ifdown` `lpc`
|
||
|
||
**修法**:白名单加 `/etc/alternatives`(33 项全部指向 `/usr`/`/lib64`,无凭据与平台情报 → 不扩大实质可见面)。
|
||
实测:21 命令全恢复;`/etc` 可见项 14、可读文件 27、平台情报仍 0/0/0/0、其他用户目录仍 1 项、DB 不可读。
|
||
|
||
**通用教训**:`/etc` 白名单化**必须枚举「整条软链链条会穿过 /etc」的条目**(探测器已入 skill);这类回归**不报错、只静默失效**,验收只能靠"工具清单前后对比"或会话取证。
|
||
|
||
## 三、可移植 Python 3.12(已装)
|
||
|
||
- **为什么不用 `dnf install python3.11`**:用户要求「**方便服务器迁移时一起打包迁移,避免环境出问题**」。rpm 包分散在 `/usr` 各处、依赖系统库 → 迁移易出问题。选 **python-build-standalone(install_only, stripped)**:自包含单目录、解压即用。
|
||
- **落地**:`scripts/install-python-runtime.sh`(幂等 / 可离线 / 支持 `--tarball`)→ `/usr/local/dsh-runtime/python-3.12.14/`(109MB)+ `/usr/local/bin/{python3,python,pip3}` 软链。
|
||
- **零代码生效**:`/usr` 已 ro-bind + `/usr/local/bin` 在实例 PATH 首位 → 实例内 `python3` = **3.12.14**、`pip3` = 26.2.1,**无需重启实例**。
|
||
- **迁移打包单元 = `/usr/local/dsh-runtime/` 一个目录**:`tar czf dsh-python-runtime.tar.gz -C /usr/local dsh-runtime` → 目标机解压回原位 + 重跑脚本(只重建软链)。
|
||
- **宿主零影响**:`dnf`(4.7.0) shebang 为绝对路径 `/usr/libexec/platform-python`;BT-Panel 用自带 pyenv 绝对路径。已核实宿主**无任何东西依赖裸 `python3`**。
|
||
- **pip 落点**:默认装 runtime 的 site-packages(**只读被挡** ✅ 天然护栏);`--user` → `$HOME/.local`;**推荐 `--target <ws>/.pylibs` + `PYTHONPATH`**(实测成功,requests 2.34.2)。
|
||
|
||
## 四、平台自身 bug:root 属主污染用户工作区(存量已修,治本待做)
|
||
|
||
- 现象:guest `ws/.local`、`.local/share/pnpm` **属主 root** → 用户 `pip install --user` 报 `Permission denied`(**在自己的目录里装不了包**)。
|
||
- 根因:**平台以 root 跑 `pnpm`,且 `HOME=<userRoot>/ws`**(`src/web/routes/business-plugins.ts:260` 的 `pnpmEnv`、`scripts/ensure-biz-plugins.cjs:154`)。
|
||
- **不能简单改 HOME**:代码注释写明需与 dsh 实例共用 pnpm store,否则 `ERR_PNPM_UNEXPECTED_STORE`(已踩过)。
|
||
- 存量已修:全量扫描 + `chown -R` 回对应用户(guest **49 项 → 0**;admin 本就干净)。
|
||
- **治本待做**:pnpm 流程后把 `ws` 下 root 属主项 chown 回用户,或加入 `ws-cleanup.cjs`(cron 04:10)自愈 pass。
|
||
|
||
## 五、P0 自伤并已回滚:无法遮蔽 `/usr` 内文件
|
||
|
||
用户要求「`python3.6` 不必对用户开放,避免模型迷惑」。尝试用 `--ro-bind /dev/null <path>` 遮蔽 8 项(`platform-python*` + `python2.7`)。
|
||
|
||
**结果**:`bwrap: Can't create file at /usr/libexec/platform-python3.6m-config: Permission denied` → **实例无法启动**。
|
||
|
||
- **根因**:bwrap 需要**在 DEST 创建挂载点**,而 `/usr` 是 **ro-bind(只读)** → 创建失败。
|
||
`/etc` 之所以能自由白名单,是因为它先 `--tmpfs`(可写)再逐项 `--ro-bind-try`。
|
||
- **判据(已入 skill)**:**只有「先 tmpfs 再白名单」的目录才可自由增删**;ro-bind 的目录只能整目录决策。
|
||
- **处置**:立即用 `.bak-*` 回滚 → `grep -c platform-python` = 0、lib 同步、门户 200。
|
||
**失败窗口期零实例启动请求**(journalctl 窗口内 0 条 spawn;0 个 scope)→ **零用户影响**。
|
||
- **可行变体(未采用)**:`--tmpfs /usr/libexec` + rebind 必需子项(`git-core`/`getconf`/`gawk`/`awk`/`coreutils`…)→ 收益低(只是"看不见旧解释器")、风险中(同类静默回归)。
|
||
|
||
## 验证记录(Python 运行时)
|
||
|
||
| 检查 | 结果 |
|
||
|---|---|
|
||
| 实例内 `python3` | `/usr/local/bin/python3` = **3.12.14**(OpenSSL 3.5.8)|
|
||
| 实例内 `pip3` | pip **26.2.1** |
|
||
| 模块自检 | ssl / sqlite3 / lzma / zlib / ctypes / json / urllib **全可用** |
|
||
| `/usr/local/dsh-runtime` | 可读、**只读**(`touch` = Read-only file system)|
|
||
| `pip install`(默认) | 拒绝(runtime 只读)→ 天然护栏 |
|
||
| `pip install --target <ws>/.pylibs` + `PYTHONPATH` | **成功**(requests 2.34.2)|
|
||
| 宿主 `dnf` | 4.7.0 正常 |
|
||
| 宿主 `python3.6` | 3.6.8 仍可用(未受影响)|
|
||
| `/etc` 白名单(补 alternatives 后) | 可见项 14、平台情报 0/0/0/0 |
|
||
|
||
## 回滚 / 注意
|
||
|
||
- **Python**:删 `/usr/local/bin/{python3,python,pip3}` 软链即恢复旧行为;`/usr/local/dsh-runtime/` 可保留(迁移资产)。
|
||
- **未做(待用户决策)**:① 遮蔽 `python3.6` 的可行变体;② pnpm 污染治本;③ 子代理 `approval` 播种放开(否则多代理并行永远无 shell)。
|
||
- **存档的迁移资产**:`/opt/dsh/artifacts/cpython-3.12.14.tar.gz`(34MB)。
|
||
|
||
---
|
||
|
||
## 修正(2026-09-12):③ 子代理 `approval` 的根因与可行路径
|
||
|
||
**用户裁定**:判据 = 「**不扩大用户访问边界即可**」。
|
||
|
||
**源码级取证**(服务器 `/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-subagent/lib/types/child-agent.js`,官方包,只读):
|
||
|
||
| 事实 | 证据(官方注释原文) |
|
||
|---|---|
|
||
| 子代理沙箱只继承父会话的**显式 override** | `sandboxMode: parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session)`;注释:*"Only the parent session's explicit sandbox override is captured — **never deployment defaults** or one-shot grants"* |
|
||
| approval 被**钉死** `'never'` | `approvalPolicy: parent.ctx.get('approval') === undefined ? undefined : 'never'`;注释:*"the approval policy is **pinned to `'never'` regardless of the parent's own policy**"* |
|
||
| `'never'` 的语义 = **需要审批的操作被自动拒绝**(不是放行) | `SUBAGENT_DELEGATION_CONTEXT`:*"operations that require approval are **rejected automatically**"*(并刻意声明 *"cannot be widened from inside this session"*) |
|
||
|
||
**闭环解释**:平台的 `DSH_PERMISSION_MODE=danger-full-access` 属**部署默认值** → 恰好被官方规则**排除** → 子代理回落内置默认 sandbox(需审批)→ 而 approval 钉死 `never` = 自动拒绝 ⇒ **子代理永远拿不到 shell**。这正解释了 §一 的实测现象(当时父会话还是 `workspace-write`)。
|
||
|
||
**路径裁决**:
|
||
|
||
| 路径 | 判定 |
|
||
|---|---|
|
||
| 用 env / 部署默认值让子代理继承完全权限 | ❌ 不可能 —— 官方**明确排除** deployment defaults |
|
||
| 走 profile patch 改预设 | ❌ 无效 —— 改的仍是"默认值"通道;approval 是硬编码常量而非配置项 |
|
||
| 改写会话种子事件造"显式 override" | ❌ 档案 45 已否决同源做法(R5 扩大 + 风险收益不成比例) |
|
||
| 改官方包 | ❌ 红线 R2 |
|
||
| **用户在会话内自行切档位(产生显式 override)** | ⚠️ 唯一合规路径;但**当前平台默认已是 `danger-full-access`,切同名档位是否产生 override 未验证** |
|
||
|
||
**边界结论(回答用户判据)**:子代理与父会话**同实例、同 uid、同 cgroup、同 bwrap 沙箱** → 让它能执行 shell **不新增任何用户可访问的路径或资源** ⇒ **「不扩大用户访问边界」成立**。它触碰的是 **dsh 官方刻意的"子代理降权"设计**,而不是平台的安全边界。
|
||
|
||
**待验证(下一步)**:在实例内新开会话派发子代理跑一次 `bash` —— 可用则本项自动闭环;不可用再评估平台侧方案。
|
||
|
||
### ✅ 追加结论(2026-09-12 源码复核):**用户自己设置权限 = 支持**
|
||
|
||
用户追问「`danger-full-access` 用户自己设置权限是不是就支持了」→ 复核 `overrideOf` 实现,答案**是**:
|
||
|
||
```js
|
||
// dsh-sandbox-policy/lib/index.js
|
||
/** Read the session override **without applying the deployment default**. */
|
||
overrideOf(session) { return this.ctx.sessionProjections.stateOf(session, "sandboxMode") ?? void 0 }
|
||
// dsh-user-approval/lib/index.js —— 倒序取会话日志里最后一条 approval/policy 事件
|
||
overrideOf(session) { for (let seq = session.seq - 1; seq >= 0; seq -= 1) { const e = session.eventAt(SessionSeq(seq)); if (e?.type === "approval/policy") return e.data.policy } }
|
||
```
|
||
|
||
`overrideOf` 读的是**会话日志里最后一条 `sandbox/mode` 事件**,**不与默认值做比较** —— 所以只要日志里存在这条事件(= 用户在会话内切过一次档位),`captureDelegatedPolicyOverrides` 就会捕获它并 append 给子代理(`source: 'delegation'`)。子代理因此拿到 `danger-full-access`,而该模式下**不存在需要审批的操作**,被钉死的 `never` 不再构成阻碍 ⇒ **子代理可用 shell**。
|
||
|
||
⇒ **平台侧零改动,完全走官方机制**;边界上子代理继承的是**用户自己选的档位**,不新增任何可见面(与用户的判据一致)。
|
||
|
||
**操作(顺序重要)**:① 新开会话(档位是会话级播种,旧会话不跟随);② 会话内执行 `/permission workspace-write` 再 `/permission danger-full-access` —— **先切走再切回**,确保日志真的产生事件;③ 派子代理跑 `bash -c 'echo ok'` 验证。
|
||
**残留不确定**:设置 UI 在"选到同一档位"时是否仍写事件未验证 → 用"先切走再切回"规避。
|
||
|
||
### 🧪 实测结果(2026-09-12,用户执行):**子代理 shell 可用**
|
||
|
||
用户在其实例的会话中派子代理执行 `bash -c 'echo subagent-ok'` → **原样返回 `subagent-ok`**。
|
||
|
||
⇒ **本项(③ 子代理 approval)关闭:平台侧零改动、无需放权评估**,与用户判据(不扩大用户访问边界)一致。
|
||
|
||
> 未记录本次是否包含上文②的"先切档位"动作;两种情形结论一致 —— 平台侧都不需要改任何东西。若属"直接派发即成功",则新会话开箱即用、② 可省。
|