From 553fd1cfcf59e4c501c54fc81bc083ffd2ca007b Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 16 May 2026 21:00:42 +0200 Subject: [PATCH] test: handed tmp_path and staged workspaces to slot uid in Linux root tests --- tests/conftest.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_proxy_server.py | 8 ++++++++ tests/test_sandbox.py | 36 +++++++++++++++++++++++++++++++----- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8efe12382..ffe86bffc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,6 +41,40 @@ def _ensure_dashboard_bundle() -> None: 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-/`` 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]: return { # Orchestrator-mode: no PAT in this container; talk to gh-proxy instead. diff --git a/tests/test_proxy_server.py b/tests/test_proxy_server.py index 36a65e0b7..094193dbb 100644 --- a/tests/test_proxy_server.py +++ b/tests/test_proxy_server.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import platform import subprocess import time from collections.abc import Callable @@ -762,6 +763,13 @@ async def test_git_push_passes_slot_uid_to_git_push( branch = "farm/abc/slot" 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] = {} def fake_git_push(path: Path, **kwargs: object) -> PushResult: diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 504345304..1d5641618 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import platform import signal import stat import subprocess @@ -148,6 +149,13 @@ def test_rename_workspace_branch_refreshes_shared_metadata(tmp_path: Path, monke repo_full_name="octo/widget", 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]] = [] monkeypatch.setattr( "robomp.sandbox._share_git_metadata_with_slots", @@ -688,12 +696,20 @@ def test_ensure_workspace_refreshes_permissions_for_retry_slot_and_session( ) -> None: chowns: 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))) - monkeypatch.setattr( - "robomp.sandbox._share_git_metadata_with_slots", - lambda repo_dir, slot_uid: shared.append((repo_dir, slot_uid)), - ) + def record_chown(root: Path, slot_uid: int | None) -> None: + chowns.append((root, slot_uid)) + # Delegate so subsequent slot-identity git ops can stat the tree. + 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") ws1 = mgr.ensure_workspace( @@ -826,9 +842,14 @@ def test_ensure_workspace_invokes_slot_chown( tmp_path: Path, upstream_repo: Path, monkeypatch: pytest.MonkeyPatch ) -> None: calls: list[tuple[Path, int | None]] = [] + real_chown = _chown_workspace def record_chown(ws_root: Path, slot_uid: int | None) -> None: 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) mgr = SandboxManager(tmp_path / "workspaces") @@ -852,6 +873,7 @@ def test_ensure_workspace_provisions_and_slot_owns_runtime_dirs( ) -> None: owned: dict[Path, tuple[int, int]] = {} runtime_paths: list[Path] = [] + real_chown = _chown_workspace def record_chown(ws_root: Path, slot_uid: int | None) -> 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: assert path.is_dir() 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) mgr = SandboxManager(tmp_path / "workspaces")