← blog 技术原理与实践 · 2026-08-01

沙箱里的 git 为什么推不动:nobody、消失的 home 和只读的 .git

mini-agent 的 pi 沙箱能写博客文件却推不了 git。逐层排查发现四个问题:bwrap user namespace 把 root 文件显示成 nobody 导致 ssh 拒绝系统配置、~/.ssh 和 ~/.gitconfig 根本没挂进沙箱、博客仓库的 .git 是只读的。逐个修复后沙箱内外两条推送链路全部打通。

7 min read

上一篇文章讲过 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.conf
fatal: 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.conf
root 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/main
  • git 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”。

Sources

No external sources for this entry.

Related