沙箱里的 git 为什么推不动:nobody、消失的 home 和只读的 .git
上一篇文章讲过 mini-agent 的 pi 沙箱(bubblewrap + env 白名单)。当时把”写博客文件”这条路打通了——把博客 posts 目录加进 rw_paths,pi 在沙箱里写文章没问题。
但很快发现一个别扭的事:我在 Telegram 里让 agent “把博客目录的 commit 推掉”,pi 在沙箱里执行 git push,直接报错失败。写文件行,git 网络操作不行。这篇文章记录排查过程——最后挖出来四个独立的问题,少修一个都跑不通。
先复现,别猜
第一步永远是用最小成本复现。我直接把服务启动时构建的那条 bwrap 命令原样拿出来,把末尾的 pi 换成 git:
bwrap --die-with-parent --dev /dev \ --ro-bind /usr /usr --ro-bind /etc /etc \ --ro-bind /home/worker/Web/SGJki-s-blog /home/worker/Web/SGJki-s-blog \ --bind /home/worker/Web/SGJki-s-blog/src/content/posts ...(略) \ --clearenv --setenv HOME /home/worker ... \ -- git -C <博客仓库> push --dry-run稳定复现:
Bad owner or permissions on /etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conffatal: Could not read from remote repository.报错本身就很说明问题:ssh 在检查配置文件权限时翻脸了。但这个文件在宿主机上好好的,宿主机 git push 一切正常。那为什么沙箱里就 “Bad owner”?
问题一:root 的文件在沙箱里变成了 nobody
对比同一个文件在宿主机和沙箱里的 stat 结果:
# 宿主机$ stat -L -c '%U %a' /etc/ssh/ssh_config.d/20-systemd-ssh-proxy.confroot 644 # ssh 满意:owner 是 root
# bwrap 沙箱内$ bwrap ... -- stat -L -c '%U %a' /etc/ssh/...nobody 644 # ssh 翻脸:owner 既不是我也不是 root原因:非特权 bwrap 必须靠 user namespace 工作,而 userns 里只映射了当前用户一个 uid。所有 root 拥有的文件,在沙箱里看 owner 都是 nobody(内核的 overflowuid 机制)。平时这无所谓——系统文件本来就只读。但 ssh 有个洁癖:它读系统配置时会校验 owner 必须是 root 或当前用户,是 nobody 就拒绝加载,而且是 fatal。
文件还是那个文件,权限位也没变,纯粹是”看起来”的 owner 变了。宿主机上永远复现不了,只有进沙箱才能看到。
这个 ownership 问题在 userns 里没法修(你改不了内核的映射行为),只能绕:让 git 用的 ssh 别去读系统配置。
argv += ["--setenv", "GIT_SSH_COMMAND", "ssh -F /dev/null"]-F /dev/null 让 ssh 跳过 /etc/ssh/ssh_config 及其 include。用户级配置我本来就没有,什么都没损失。
问题二:沙箱里根本没有 home
绕过第一关后还有第二关。沙箱的 bind 是精确到路径的:~/.pi、博客 posts 目录、/tmp……注意,/home/worker 这个 home 目录本身并没有整体挂进去,只有被点名的几个子路径在沙箱里存在。
所以 ~/.ssh 在沙箱里不存在。没有私钥,就算 ssh 跑起来也是 Permission denied (publickey)。同理 ~/.gitconfig 也不存在,commit 会报 “Please tell me who you are”。
修法是把这两个加进 ro 路径(只读足够,ssh 只是读私钥、git 只是读身份):
paths = [ # ...原有的... Path.home() / ".ssh", Path.home() / ".gitconfig",]这里有个权衡值得说清楚:ro-bind ~/.ssh 意味着沙箱里的 pi 能读到 SSH 私钥。这是”让 pi 能 push”的固有代价——push 就需要 key,没有别的办法。mini-agent 自己的 API 凭据(CLAUDE_API_KEY 之类)依然通过 env 白名单隔离,不受影响。
问题三:.git 是只读的
还有第三关。之前为了保护博客仓库,沙箱把整个仓库根目录 ro-bind,只把 posts/ 子目录 rw-bind(后绑定的覆盖先绑定的)。写文章够了,但 git 不行:
git pull --rebase要写.git里的 refs、index、rebase 状态git push成功后也要回写本地的refs/remotes/origin/maingit commit要写 objects 和 index
.git 只读,这些全部失败。修法很直接——仓库根保持 ro,但把 .git 单独 rw-bind,利用”后绑定覆盖先绑定”的规则:
sandbox_rw_paths.append(str(blog_path.resolve())) # posts 目录git_dir = _blog_repo_root(blog_path.resolve()) / ".git"if git_dir.is_dir(): sandbox_rw_paths.append(str(git_dir)) # .git 可写效果:仓库里除 .git 和 posts/ 之外全部只读,git 又完全够用。
验证:四层修完一次通过
用服务实际构建的 bwrap argv(不是手搓的近似版),在沙箱里连跑三个命令:
== pull: rc=0 Already up to date.== empty-commit: rc=0 [main 24a5873] sandbox write test ← 写 .git 成功== push-dry: rc=0 To github.com:SGJki/SGJki-s-blog.git 5944257..24a5873全绿(测试 commit 已 reset 清理)。
顺手补的一个洞:BlogSync 的 “nothing to commit”
排查中还发现一个相关的设计缺口。BlogSyncTask 是沙箱外的兜底同步:watchdog 盯博客目录,防抖后自动 add → commit → pull --rebase → push。但原逻辑里 commit 报 “nothing to commit” 就直接 return 了——不检查是否有未推送的 commit。
这意味着:如果 commit 是别人创建的(pi 在沙箱里提交的,或者我手动提交的),BlogSync 醒来一看”没新东西可提交”就走了,那些 commit 永远躺在本地。我这次卡住就有这个因素。
修法:nothing to commit 时多问一句 git rev-list --count @{u}..HEAD,有未推送 commit 就继续走 pull/push,没有才跳过。从此不管 commit 是谁创建的,防抖窗口过后都会被推上去。
总结
这次故障表面是”沙箱没权限”,实际是四层叠加:
| 层 | 问题 | 修法 |
|---|---|---|
| ssh 配置 | userns 里 root 文件显示为 nobody,ssh 拒绝系统配置 | GIT_SSH_COMMAND=ssh -F /dev/null |
| 认证 | ~/.ssh 没挂进沙箱 | ro-bind |
| commit 身份 | ~/.gitconfig 没挂进沙箱 | ro-bind |
| git 写操作 | 仓库 ro-bind 导致 .git 只读 | 单独 rw-bind .git |
最有教育意义的是第一层:同一个文件、同样的权限位,宿主机和沙箱里 stat 出来的 owner 不一样。userns 的 uid 映射平时无感,一旦遇到像 ssh 这样校验 owner 的程序就会暴雷——而且错误信息只说 “Bad owner or permissions”,不会告诉你”在你看不见的维度里,root 变成了 nobody”。