test: handed tmp_path and staged workspaces to slot uid in Linux root tests

This commit is contained in:
can1357
2026-05-16 21:00:42 +02:00
parent 7f544fc669
commit 553fd1cfcf
3 changed files with 73 additions and 5 deletions
+34
View File
@@ -41,6 +41,40 @@ def _ensure_dashboard_bundle() -> None:
reset_index_cache() reset_index_cache()
@pytest.fixture(autouse=True)
def _open_tmp_path_for_slot_traversal(tmp_path: Path) -> None:
"""Grant traverse (`+x`) on tmp_path's root-owned ancestors so slot
subprocesses can reach the workspace.
pytest's default ``tmp_path`` lives under ``/tmp/pytest-of-<user>/`` with
mode ``0700``. On macOS dev that's irrelevant (no slot subprocess ever
drops uid). On Linux+root the slot UID (e.g. 2001) is non-zero and
every directory between ``/`` and the workspace needs at least the
`o+x` bit or the slot's stat fails with EACCES. Adds `o+x` (NOT `o+r`)
so directory contents stay private; only path-traversal is allowed.
"""
import os
import platform
import stat
if platform.system() != "Linux" or os.geteuid() != 0:
return
cursor = tmp_path.resolve()
while cursor != cursor.parent:
try:
st = cursor.stat()
except FileNotFoundError:
break
if not stat.S_ISDIR(st.st_mode):
break
if not (st.st_mode & 0o001):
try:
cursor.chmod(st.st_mode | 0o001)
except PermissionError:
break
cursor = cursor.parent
def _baseline_env(tmp_path: Path) -> dict[str, str]: def _baseline_env(tmp_path: Path) -> dict[str, str]:
return { return {
# Orchestrator-mode: no PAT in this container; talk to gh-proxy instead. # Orchestrator-mode: no PAT in this container; talk to gh-proxy instead.
+8
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import platform
import subprocess import subprocess
import time import time
from collections.abc import Callable from collections.abc import Callable
@@ -762,6 +763,13 @@ async def test_git_push_passes_slot_uid_to_git_push(
branch = "farm/abc/slot" branch = "farm/abc/slot"
repo_dir, head = _stage_workspace(proxy_settings, upstream_repo, "octo/widget", 1, branch) repo_dir, head = _stage_workspace(proxy_settings, upstream_repo, "octo/widget", 1, branch)
# The push handler reads the origin URL as the slot uid. On Linux+root
# the staged workspace is root-owned; hand it to slot 2001 so the
# subprocess can stat it. On macOS dev this is a no-op (slot identity
# is never activated).
if platform.system() == "Linux" and os.geteuid() == 0:
for path in [repo_dir.parent, repo_dir, *repo_dir.rglob("*")]:
os.chown(path, 2001, 2001, follow_symlinks=False)
captured: dict[str, object] = {} captured: dict[str, object] = {}
def fake_git_push(path: Path, **kwargs: object) -> PushResult: def fake_git_push(path: Path, **kwargs: object) -> PushResult:
+31 -5
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import platform
import signal import signal
import stat import stat
import subprocess import subprocess
@@ -148,6 +149,13 @@ def test_rename_workspace_branch_refreshes_shared_metadata(tmp_path: Path, monke
repo_full_name="octo/widget", repo_full_name="octo/widget",
issue_number=1, issue_number=1,
) )
# On Linux+root the rename runs `git branch -m` as the slot uid (2004),
# so the worktree needs to be readable by that uid before the call.
# On macOS dev `_slot_permissions_active` returns False and this
# whole block is a no-op.
if platform.system() == "Linux" and os.geteuid() == 0:
for path in [root, repo_dir, *repo_dir.rglob("*")]:
os.chown(path, 2004, 2004, follow_symlinks=False)
calls: list[tuple[Path, int | None]] = [] calls: list[tuple[Path, int | None]] = []
monkeypatch.setattr( monkeypatch.setattr(
"robomp.sandbox._share_git_metadata_with_slots", "robomp.sandbox._share_git_metadata_with_slots",
@@ -688,12 +696,20 @@ def test_ensure_workspace_refreshes_permissions_for_retry_slot_and_session(
) -> None: ) -> None:
chowns: list[tuple[Path, int | None]] = [] chowns: list[tuple[Path, int | None]] = []
shared: list[tuple[Path, int | None]] = [] shared: list[tuple[Path, int | None]] = []
real_chown = _chown_workspace
real_share = _share_git_metadata_with_slots
monkeypatch.setattr("robomp.sandbox._chown_workspace", lambda root, slot_uid: chowns.append((root, slot_uid))) def record_chown(root: Path, slot_uid: int | None) -> None:
monkeypatch.setattr( chowns.append((root, slot_uid))
"robomp.sandbox._share_git_metadata_with_slots", # Delegate so subsequent slot-identity git ops can stat the tree.
lambda repo_dir, slot_uid: shared.append((repo_dir, slot_uid)), real_chown(root, slot_uid)
)
def record_share(repo_dir: Path, slot_uid: int | None) -> None:
shared.append((repo_dir, slot_uid))
real_share(repo_dir, slot_uid)
monkeypatch.setattr("robomp.sandbox._chown_workspace", record_chown)
monkeypatch.setattr("robomp.sandbox._share_git_metadata_with_slots", record_share)
mgr = SandboxManager(tmp_path / "workspaces") mgr = SandboxManager(tmp_path / "workspaces")
ws1 = mgr.ensure_workspace( ws1 = mgr.ensure_workspace(
@@ -826,9 +842,14 @@ def test_ensure_workspace_invokes_slot_chown(
tmp_path: Path, upstream_repo: Path, monkeypatch: pytest.MonkeyPatch tmp_path: Path, upstream_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
calls: list[tuple[Path, int | None]] = [] calls: list[tuple[Path, int | None]] = []
real_chown = _chown_workspace
def record_chown(ws_root: Path, slot_uid: int | None) -> None: def record_chown(ws_root: Path, slot_uid: int | None) -> None:
calls.append((ws_root, slot_uid)) calls.append((ws_root, slot_uid))
# Delegate to the real chown so the subsequent `git config` as the
# slot can stat the tree. On macOS dev (uid != 0) the real chown is
# itself a no-op; on Linux+root in CI it hands the tree to the slot.
real_chown(ws_root, slot_uid)
monkeypatch.setattr("robomp.sandbox._chown_workspace", record_chown) monkeypatch.setattr("robomp.sandbox._chown_workspace", record_chown)
mgr = SandboxManager(tmp_path / "workspaces") mgr = SandboxManager(tmp_path / "workspaces")
@@ -852,6 +873,7 @@ def test_ensure_workspace_provisions_and_slot_owns_runtime_dirs(
) -> None: ) -> None:
owned: dict[Path, tuple[int, int]] = {} owned: dict[Path, tuple[int, int]] = {}
runtime_paths: list[Path] = [] runtime_paths: list[Path] = []
real_chown = _chown_workspace
def record_chown(ws_root: Path, slot_uid: int | None) -> None: def record_chown(ws_root: Path, slot_uid: int | None) -> None:
assert slot_uid is not None assert slot_uid is not None
@@ -869,6 +891,10 @@ def test_ensure_workspace_provisions_and_slot_owns_runtime_dirs(
for path in paths: for path in paths:
assert path.is_dir() assert path.is_dir()
owned[path] = (slot_uid, slot_uid) owned[path] = (slot_uid, slot_uid)
# Same rationale as test_ensure_workspace_invokes_slot_chown: hand
# the tree to the slot so the subsequent `git config` works under
# real slot permissions in CI.
real_chown(ws_root, slot_uid)
monkeypatch.setattr("robomp.sandbox._chown_workspace", record_chown) monkeypatch.setattr("robomp.sandbox._chown_workspace", record_chown)
mgr = SandboxManager(tmp_path / "workspaces") mgr = SandboxManager(tmp_path / "workspaces")