fix(workspace): scope _ensure_agent_owned walk to .git subtree only

Walking the entire workspace (incl node_modules) to chown+chmod every
entry cost 2.7-15.5s per git op. The agent only needs write ownership on
.git/ during git ops; working-tree files don't need chowning. Restrict
the walk to .git, and no-op when .git is absent.
This commit is contained in:
Renn F
2026-06-03 18:34:31 +02:00
parent e1c3f926f2
commit 61e80495c3
2 changed files with 117 additions and 18 deletions
+22 -18
View File
@@ -86,31 +86,35 @@ def _make_owner_and_group_rw(entry: str) -> None:
def _ensure_agent_owned(workspace: Path) -> None: def _ensure_agent_owned(workspace: Path) -> None:
"""Recursively chown + group-write a workspace for the agent user. """Chown + group-write the .git subtree for the agent user.
Orchestrator runs as root so anything it clones or writes is root-owned. Orchestrator runs as root so anything it clones or writes is root-owned.
Agent containers run as uid 1000 and must be able to create Agent containers run as uid 1000 and must be able to create
.git/index.lock, refs, packed-refs, and new source files — otherwise .git/index.lock, refs, packed-refs, and objects — otherwise every git
every git operation (and even plain file writes) fails with operation fails with "Permission denied". Called after clone and on every
"Permission denied". Called after clone and on every ensure_workspace ensure_workspace so legacy (pre-fix) workspaces get repaired.
so legacy (pre-fix) workspaces get repaired.
Two defenses (both cheap, both idempotent): The walk is scoped to ``.git`` only. Working-tree files (and especially a
1. chown every entry to (AGENT_UID, AGENT_GID). On setups where user multi-thousand-entry ``node_modules/``) don't need chowning for git to
namespaces silently remap or reject the chown (some NAS / rootless work, and walking them cost 2.7-15.5s per git op. If ``.git`` is absent
docker configs), we log the failure instead of swallowing it — so (never cloned), there is nothing to own and we no-op.
when writes still fail from the agent, we can actually see why.
2. chmod g+w on every file/dir. If chown doesn't take effect, having
the group writable (and with AGENT_GID) is enough for uid 1000 to
write, provided agent is in that group. Belt + suspenders.
The previous fast-path (skip walk if top-level stat already matches) Two defenses (both cheap, both idempotent), applied to every entry under
was unsafe: a root-owned file deep under an agent-owned top dir would ``.git``:
not get repaired, which is exactly how README.md ends up root:root 1. chown to (AGENT_UID, AGENT_GID). On setups where user namespaces
even after ensure_workspace runs. silently remap or reject the chown (some NAS / rootless docker
configs), we log the failure instead of swallowing it — so when writes
still fail from the agent, we can actually see why.
2. chmod g+w. If chown doesn't take effect, having the group writable
(and with AGENT_GID) is enough for uid 1000 to write, provided agent
is in that group. Belt + suspenders.
""" """
git_dir = workspace / ".git"
if not git_dir.exists():
return
failed_chowns = 0 failed_chowns = 0
for root, dirs, files in os.walk(workspace): for root, dirs, files in os.walk(git_dir):
entries = ( entries = (
root, root,
*[str(Path(root) / d) for d in dirs], *[str(Path(root) / d) for d in dirs],
@@ -0,0 +1,95 @@
"""Tests that _ensure_agent_owned only touches the .git subtree.
The agent only needs write ownership on .git/ (index.lock, refs, packed-refs,
objects) during git ops. Walking the entire working tree including a large
node_modules/ chown+chmod'ing every entry made every git op take seconds.
The walk must be scoped to .git only.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from roboco.services import workspace as workspace_module
from roboco.services.workspace import _ensure_agent_owned
def _build_workspace(root: Path) -> None:
"""Create a workspace with a .git dir and a large node_modules tree."""
git_dir = root / ".git"
(git_dir / "refs" / "heads").mkdir(parents=True)
(git_dir / "objects").mkdir(parents=True)
(git_dir / "config").write_text("[core]\n")
(git_dir / "HEAD").write_text("ref: refs/heads/main\n")
(git_dir / "refs" / "heads" / "main").write_text("abc123\n")
(git_dir / "packed-refs").write_text("# pack-refs\n")
# A large working tree with a deep node_modules/ that must NOT be walked.
src = root / "src"
src.mkdir(parents=True)
(src / "main.py").write_text("print('hi')\n")
node_modules = root / "node_modules"
for pkg in range(20):
pkg_dir = node_modules / f"pkg-{pkg}" / "dist"
pkg_dir.mkdir(parents=True)
(pkg_dir / "index.js").write_text("module.exports = {}\n")
@pytest.fixture
def _record_touched(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Record every path _ensure_agent_owned tries to chown/chmod."""
touched: list[str] = []
def fake_chown_entry(entry: str) -> bool:
touched.append(entry)
return True
def fake_make_rw(entry: str) -> None:
touched.append(entry)
monkeypatch.setattr(workspace_module, "_chown_entry", fake_chown_entry)
monkeypatch.setattr(workspace_module, "_make_owner_and_group_rw", fake_make_rw)
return touched
def test_ensure_agent_owned_scopes_to_git_only(
tmp_path: Path, _record_touched: list[str]
) -> None:
_build_workspace(tmp_path)
_ensure_agent_owned(tmp_path)
git_dir = tmp_path / ".git"
assert _record_touched, "expected .git entries to be touched"
# Every touched path must live inside .git/.
for entry in _record_touched:
resolved = Path(entry).resolve()
assert git_dir.resolve() in (resolved, *resolved.parents), (
f"{entry} is outside the .git subtree"
)
# No node_modules path may be touched.
assert not any("node_modules" in entry for entry in _record_touched)
# The git internals that need agent ownership were in fact visited.
expected = {
str(git_dir / "config"),
str(git_dir / "HEAD"),
str(git_dir / "packed-refs"),
str(git_dir / "refs" / "heads" / "main"),
}
assert expected.issubset(set(_record_touched))
def test_ensure_agent_owned_noop_when_git_absent(
tmp_path: Path, _record_touched: list[str]
) -> None:
# Working tree with no .git/ — nothing to own.
(tmp_path / "node_modules" / "pkg").mkdir(parents=True)
(tmp_path / "node_modules" / "pkg" / "index.js").write_text("x\n")
_ensure_agent_owned(tmp_path)
assert _record_touched == []