From fcfd47dc84001f4326d5da2cfd72df56f2300eca Mon Sep 17 00:00:00 2001 From: Andy Choquette Date: Thu, 16 Jul 2026 16:25:18 -0700 Subject: [PATCH 1/2] feat: support running Sessions as a jobRunAsUser on macOS openjd-sessions could not run a Session's actions as a separate user (jobRunAsUser) on macOS. Three problems in the POSIX cross-user path: - setsid(1) does not exist on macOS, so the cross-user command 'sudo -u -i setsid -w ' failed at spawn (exit 127), breaking all jobRunAsUser execution on macOS. - find_child_process_id_pgrep treated pgrep's exit code 1 (no match yet) as a fatal error, which broke out of the caller's retry loop. Since Linux uses procfs and only other POSIX platforms use pgrep, signal-target discovery never actually retried off Linux. - There was no is_macos() helper (the MACOS constant was unused). Changes: - _os_checker.py: add is_macos(). - _subprocess.py: on darwin, launch the workload via a pure-Python setsid shim ('sudo -u -i /usr/bin/python3 -I -c ') that makes the workload a new session/process-group leader before exec, reproducing the new-session behavior 'setsid -w' provides on Linux. Runs under /usr/bin/python3 (so the job user needs no venv access) with -I (isolated mode, so the working directory is not on sys.path). - _linux/_sudo.py: treat pgrep exit code 1 as "no match yet -> return None" so the retry loop polls as intended; exit >1 is still fatal. Fixes discovery and cancellation on all non-Linux POSIX hosts. - Tests for is_macos(), the pgrep exit-code handling, and the macOS command construction (plus a POSIX check that the shim creates a new process group). - README: document the macOS Command Line Tools prerequisite for impersonation. Linux and Windows behavior is unchanged. Validated end-to-end on macOS 26.5 (arm64) via the AWS Deadline Cloud worker agent against a live customer-managed fleet: running an action as a jobRunAsUser and cancellation reaping the workload's process group with no orphans. Signed-off-by: Andy Choquette --- README.md | 9 + src/openjd/sessions/_linux/_sudo.py | 6 + src/openjd/sessions/_os_checker.py | 4 + src/openjd/sessions/_subprocess.py | 105 ++++++++++- test/openjd/sessions_v0/test_os_checker.py | 12 +- test/openjd/sessions_v0/test_subprocess.py | 208 +++++++++++++++++++++ test/openjd/sessions_v0/test_sudo.py | 71 +++++++ 7 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 test/openjd/sessions_v0/test_sudo.py diff --git a/README.md b/README.md index ec53f71d..03a7a83d 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,15 @@ with passwordless `sudo` by, for example, adding a rule like follows to your host ALL=(actions) NOPASSWD: ALL ``` +On MacOS, the impersonated command is launched under a small Python shim because macOS +lacks the `setsid(1)` utility. The shim runs with the base interpreter behind the Python +that is running this library (for a virtual environment, the interpreter the venv was +created from) provided that interpreter is reachable and executable by other users; +otherwise it falls back to the operating system's `/usr/bin/python3`, which resolves to a +working interpreter only when the Xcode Command Line Tools (or Xcode) are present +(`xcode-select --install`). No separate Python installation is required when the base +interpreter is usable. + #### Impersonating a User: Windows Systems To run an impersonated Session on Windows Systems modify the "Running a Session" example diff --git a/src/openjd/sessions/_linux/_sudo.py b/src/openjd/sessions/_linux/_sudo.py index 43a1fa8c..9fd5ec4b 100644 --- a/src/openjd/sessions/_linux/_sudo.py +++ b/src/openjd/sessions/_linux/_sudo.py @@ -143,6 +143,12 @@ def find_child_process_id_pgrep( stdin=DEVNULL, text=True, ) + # pgrep exit codes: 0 = one or more processes matched; 1 = no processes matched; + # >1 = an actual error (syntax/operational). Exit 1 is NOT an error here -- it just + # means sudo has not spawned its child yet, so we return None to let the caller's + # retry loop poll again. + if pgrep_result.returncode == 1: + return None if pgrep_result.returncode != 0: raise FindSignalTargetError("Unable to query child processes of sudo process") results = pgrep_result.stdout.splitlines() diff --git a/src/openjd/sessions/_os_checker.py b/src/openjd/sessions/_os_checker.py index c42c2dda..d87dd403 100644 --- a/src/openjd/sessions/_os_checker.py +++ b/src/openjd/sessions/_os_checker.py @@ -21,6 +21,10 @@ def is_windows() -> bool: return os.name == WINDOWS +def is_macos() -> bool: + return sys.platform == MACOS + + def check_os() -> None: if not (is_posix() or is_windows()): raise NotImplementedError( diff --git a/src/openjd/sessions/_subprocess.py b/src/openjd/sessions/_subprocess.py index 9b21a4ef..fefc2b86 100644 --- a/src/openjd/sessions/_subprocess.py +++ b/src/openjd/sessions/_subprocess.py @@ -3,6 +3,7 @@ import os import shlex import signal +import stat import sys import time from contextlib import nullcontext @@ -16,7 +17,7 @@ from ._linux._capabilities import try_use_cap_kill from ._linux._sudo import find_sudo_child_process_group_id from ._logging import LoggerAdapter, LogContent, LogExtraInfo -from ._os_checker import is_linux, is_posix, is_windows +from ._os_checker import is_linux, is_macos, is_posix, is_windows from ._session_user import PosixSessionUser, WindowsSessionUser, SessionUser from ._action_filter import redact_openjd_redacted_env_requests @@ -28,6 +29,84 @@ __all__ = ("LoggingSubprocess",) +# macOS has no `setsid(1)` binary (it is a Linux/util-linux tool), yet the new-session +# behavior it provides is still required: `sudo -u -i ` places the workload in +# sudo's own (root-owned) process group, which the jobRunAsUser cannot signal and which +# openjd must not signal (it would hit the root sudo process). We reproduce `setsid` with a +# tiny pure-Python shim, run as the workload, that makes the workload a new session/process- +# group leader and then exec's the real command. +# +# Details: +# * `os.getpgrp() == os.getpid() or os.setsid()` calls setsid() only when the process is +# NOT already a group leader; os.setsid() raises EPERM if the caller already leads a +# group, so the short-circuit avoids that. Either way the workload ends up in a process +# group distinct from sudo's, which find_sudo_child_process_group_id() then discovers. +# * Single line (no newlines) so it passes cleanly through `sudo -i` argv without any +# shell-quoting fragility. +# * The interpreter that runs the shim is the base interpreter behind the one running this +# process (see _macos_shim_interpreter()), falling back to /usr/bin/python3. sys.executable +# itself is not used directly because it may live inside a virtual environment that the +# jobRunAsUser has no traverse/read permission on. +# * `-I` (isolated mode) drops the current working directory from sys.path and ignores +# PYTHON* environment variables, so a file such as os.py in the session working directory +# cannot be imported ahead of the standard library before os.execvp() runs. +# +# Signal-target discovery (find_sudo_child_process_group_id) locates the workload by walking +# sudo's single child and comparing process groups. This relies on `sudo -i` exec'ing the +# command into a single child rather than leaving extra long-lived processes in between; the +# same assumption already holds for the Linux `setsid -w` path. +_MACOS_SETSID_SHIM = ( + "import os,sys;os.getpgrp()==os.getpid() or os.setsid();os.execvp(sys.argv[1],sys.argv[1:])" +) +_MACOS_FALLBACK_SHIM_INTERPRETER = "/usr/bin/python3" + + +def _other_users_can_execute(path: str) -> bool: + """Returns whether an arbitrary other user can execute the file at the given path based + on the world (other) permission bits: the file itself must be o+x and every directory on + the path must be o+x (traversable). A world-executable file under e.g. a 0o750 home + directory is still unreachable, so both checks are required. + + This is a conservative approximation: it ignores group permissions and ACLs that might + also grant access, so it can return False for a path some specific user could execute. + """ + try: + mode = os.stat(path).st_mode + if not (stat.S_ISREG(mode) and mode & stat.S_IXOTH): + return False + parent = os.path.dirname(path) + while True: + if not os.stat(parent).st_mode & stat.S_IXOTH: + return False + next_parent = os.path.dirname(parent) + if next_parent == parent: # reached the filesystem root + return True + parent = next_parent + except OSError: + return False + + +def _macos_shim_interpreter() -> str: + """Returns the path of the Python interpreter used to run _MACOS_SETSID_SHIM as the + jobRunAsUser. + + Prefers the base interpreter behind the one running this process (sys._base_executable; + for a virtual environment this is the interpreter the venv was created from, in a system + location such as /usr/bin, /opt/homebrew, or a python.org framework install) so that no + separate Python installation is required on the host. The venv's own sys.executable is + not suitable: the jobRunAsUser typically has no traverse/read permission on the agent's + venv directory. + + Falls back to /usr/bin/python3 (the Command Line Tools shim; requires the Command Line + Tools or Xcode to be installed) when the base interpreter cannot be determined or is not + reachable and executable by other users. + """ + base = os.path.realpath(getattr(sys, "_base_executable", None) or sys.executable) + if _other_users_can_execute(base): + return base + return _MACOS_FALLBACK_SHIM_INTERPRETER + + # ======================================================================== # ======================================================================== # DEVELOPER NOTE: @@ -257,7 +336,29 @@ def _start_subprocess(self) -> Optional[Popen]: # same process group as the `sudo` command. If that happens, then # we're stuck: 1/ Our user cannot kill processes by the self._user; and # 2/ The self._user cannot kill the root-owned sudo process group. - command.extend(["sudo", "-u", user.user, "-i", "setsid", "-w"]) + if is_macos(): + # macOS has no setsid(1); use a pure-Python setsid shim (see + # _MACOS_SETSID_SHIM) run as the workload to get the same + # new-session behavior that `setsid -w` provides on Linux. + shim_interpreter = _macos_shim_interpreter() + self._logger.info( + f"Using {shim_interpreter} to run the setsid shim", + extra=LogExtraInfo(openjd_log_content=LogContent.PROCESS_CONTROL), + ) + command.extend( + [ + "sudo", + "-u", + user.user, + "-i", + shim_interpreter, + "-I", + "-c", + _MACOS_SETSID_SHIM, + ] + ) + else: + command.extend(["sudo", "-u", user.user, "-i", "setsid", "-w"]) elif is_windows(): user = cast(WindowsSessionUser, self._user) # type: ignore diff --git a/test/openjd/sessions_v0/test_os_checker.py b/test/openjd/sessions_v0/test_os_checker.py index 28efcd66..b8389994 100644 --- a/test/openjd/sessions_v0/test_os_checker.py +++ b/test/openjd/sessions_v0/test_os_checker.py @@ -3,7 +3,7 @@ import unittest from enum import Enum from unittest.mock import patch -from openjd.sessions._os_checker import is_posix, is_windows, check_os +from openjd.sessions._os_checker import is_macos, is_posix, is_windows, check_os class OSName(str, Enum): @@ -32,6 +32,16 @@ def test_is_not_windows(self, mock_os): mock_os.name = OSName.POSIX self.assertFalse(is_windows()) + @patch("openjd.sessions._os_checker.sys") + def test_is_macos(self, mock_sys): + mock_sys.platform = "darwin" + self.assertTrue(is_macos()) + + @patch("openjd.sessions._os_checker.sys") + def test_is_not_macos(self, mock_sys): + mock_sys.platform = "linux" + self.assertFalse(is_macos()) + @patch("openjd.sessions._os_checker.os") def test_check_os_posix(self, mock_os): mock_os.name = OSName.POSIX diff --git a/test/openjd/sessions_v0/test_subprocess.py b/test/openjd/sessions_v0/test_subprocess.py index 6c014546..03ea7abf 100644 --- a/test/openjd/sessions_v0/test_subprocess.py +++ b/test/openjd/sessions_v0/test_subprocess.py @@ -1069,3 +1069,211 @@ def end_proc(): if num_children_running == 0: break assert num_children_running == 0 + + +@pytest.mark.usefixtures("message_queue", "queue_handler") +class TestLoggingSubprocessMacOSSetsid: + """Tests for the macOS-specific cross-user command construction. + + macOS has no setsid(1), so on darwin the workload is launched under a small + pure-Python shim (run via a system-location Python interpreter with -I) that + becomes a new session/process-group leader before exec'ing the real command. + """ + + @pytest.mark.skipif( + is_windows(), reason="Constructs a PosixSessionUser, which is rejected on Windows hosts" + ) + def test_builds_setsid_shim_command_on_macos(self, queue_handler: QueueHandler) -> None: + # GIVEN + from openjd.sessions import _subprocess as subprocess_mod + + logger = build_logger(queue_handler) + target_user = MagicMock(spec=PosixSessionUser) + target_user.user = "job-user" + target_user.is_process_user.return_value = False + subproc = LoggingSubprocess( + logger=logger, + args=["/path/to/workload.sh"], + user=target_user, + ) + + # WHEN + with ( + patch.object(subprocess_mod, "is_macos", return_value=True), + patch.object(subprocess_mod, "is_posix", return_value=True), + patch.object(subprocess_mod, "is_windows", return_value=False), + patch.object( + subprocess_mod, "_macos_shim_interpreter", return_value="/usr/local/bin/python3" + ), + patch.object(subprocess_mod, "Popen") as mock_popen, + ): + subproc._start_subprocess() + + # THEN + built_command = mock_popen.call_args.kwargs["args"] + assert built_command == [ + "sudo", + "-u", + "job-user", + "-i", + "/usr/local/bin/python3", + "-I", + "-c", + subprocess_mod._MACOS_SETSID_SHIM, + "/path/to/workload.sh", + ] + + @pytest.mark.skipif(not is_posix(), reason="posix-specific test") + def test_setsid_shim_creates_new_process_group(self) -> None: + # GIVEN the shim string that macOS uses in place of setsid(1). + from subprocess import PIPE, run + + from openjd.sessions import _subprocess as subprocess_mod + + # WHEN we run it (as the current user; no sudo) to report the workload's + # process-group id alongside the launching python's own pid. + result = run( + [ + sys.executable, + "-I", + "-c", + subprocess_mod._MACOS_SETSID_SHIM, + "/bin/sh", + "-c", + "echo $$ $(ps -o pgid= -p $$)", + ], + stdout=PIPE, + text=True, + check=True, + ) + + # THEN the workload is the leader of its own process group (pgid == its pid). + workload_pid, workload_pgid = (int(x) for x in result.stdout.split()) + assert workload_pid == workload_pgid + + +class TestMacOSShimInterpreter: + """Tests for _macos_shim_interpreter(), which selects the Python interpreter that runs + the setsid shim as the jobRunAsUser, and for the _other_users_can_execute() permission + check that backs it.""" + + def test_prefers_base_executable(self, tmp_path: Path) -> None: + # GIVEN a reachable interpreter behind sys._base_executable + from openjd.sessions import _subprocess as subprocess_mod + + interpreter = tmp_path / "python3" + interpreter.touch() + + # WHEN + with ( + patch.object(subprocess_mod.sys, "_base_executable", str(interpreter), create=True), + patch.object(subprocess_mod, "_other_users_can_execute", return_value=True), + ): + result = subprocess_mod._macos_shim_interpreter() + + # THEN + assert result == str(interpreter.resolve()) + + def test_resolves_symlink_to_base_interpreter(self, tmp_path: Path) -> None: + # GIVEN _base_executable is a symlink (e.g. a framework/Homebrew shim) + from openjd.sessions import _subprocess as subprocess_mod + + real_interpreter = tmp_path / "python3.11" + real_interpreter.touch() + link = tmp_path / "python3" + link.symlink_to(real_interpreter) + + # WHEN + with ( + patch.object(subprocess_mod.sys, "_base_executable", str(link), create=True), + patch.object(subprocess_mod, "_other_users_can_execute", return_value=True), + ): + result = subprocess_mod._macos_shim_interpreter() + + # THEN the symlink is resolved to the real interpreter + assert result == str(real_interpreter.resolve()) + + def test_uses_sys_executable_when_base_executable_unset(self, tmp_path: Path) -> None: + # GIVEN _base_executable is None (not a venv); sys.executable is used instead + from openjd.sessions import _subprocess as subprocess_mod + + interpreter = tmp_path / "python3" + interpreter.touch() + + # WHEN + with ( + patch.object(subprocess_mod.sys, "_base_executable", None, create=True), + patch.object(subprocess_mod.sys, "executable", str(interpreter)), + patch.object(subprocess_mod, "_other_users_can_execute", return_value=True), + ): + result = subprocess_mod._macos_shim_interpreter() + + # THEN + assert result == str(interpreter.resolve()) + + def test_falls_back_when_base_not_executable_by_others(self, tmp_path: Path) -> None: + # GIVEN the base interpreter is not reachable/executable by other users + from openjd.sessions import _subprocess as subprocess_mod + + interpreter = tmp_path / "python3" + interpreter.touch() + + # WHEN + with ( + patch.object(subprocess_mod.sys, "_base_executable", str(interpreter), create=True), + patch.object(subprocess_mod, "_other_users_can_execute", return_value=False), + ): + result = subprocess_mod._macos_shim_interpreter() + + # THEN + assert result == subprocess_mod._MACOS_FALLBACK_SHIM_INTERPRETER + + @pytest.mark.skipif(not is_posix(), reason="POSIX permission-bit semantics") + def test_other_users_can_execute_system_binary(self) -> None: + # GIVEN a system binary that is world-executable with world-traversable parents + from openjd.sessions import _subprocess as subprocess_mod + + # THEN + assert subprocess_mod._other_users_can_execute("/bin/sh") + + @pytest.mark.skipif(is_windows(), reason="POSIX permission bits are not honored on Windows") + def test_other_users_cannot_execute_without_o_x_bit(self, tmp_path: Path) -> None: + # GIVEN a file that other users cannot execute (no o+x bit) + from openjd.sessions import _subprocess as subprocess_mod + + interpreter = tmp_path / "python3" + interpreter.touch() + interpreter.chmod(0o750) + + # THEN + assert not subprocess_mod._other_users_can_execute(str(interpreter)) + + @pytest.mark.skipif(is_windows(), reason="POSIX permission bits are not honored on Windows") + def test_other_users_cannot_execute_behind_private_dir(self, tmp_path: Path) -> None: + # GIVEN a world-executable file inside a directory that other users cannot + # traverse (e.g. a Python install under a 0o750 home directory) + from openjd.sessions import _subprocess as subprocess_mod + + private_dir = tmp_path / "private" + private_dir.mkdir() + interpreter = private_dir / "python3" + interpreter.touch() + interpreter.chmod(0o755) + private_dir.chmod(0o750) + + # WHEN + try: + result = subprocess_mod._other_users_can_execute(str(interpreter)) + finally: + # Restore so pytest can clean up tmp_path + private_dir.chmod(0o755) + + # THEN + assert not result + + def test_other_users_cannot_execute_missing_path(self, tmp_path: Path) -> None: + # GIVEN a path that does not exist + from openjd.sessions import _subprocess as subprocess_mod + + # THEN + assert not subprocess_mod._other_users_can_execute(str(tmp_path / "no-such-python")) diff --git a/test/openjd/sessions_v0/test_sudo.py b/test/openjd/sessions_v0/test_sudo.py new file mode 100644 index 00000000..eba97843 --- /dev/null +++ b/test/openjd/sessions_v0/test_sudo.py @@ -0,0 +1,71 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +from subprocess import CompletedProcess +from unittest.mock import MagicMock, patch + +import pytest + +from openjd.sessions._linux._sudo import ( + FindSignalTargetError, + find_child_process_id_pgrep, +) + + +def _pgrep_result(returncode: int, stdout: str = "") -> CompletedProcess: + return CompletedProcess(args=["pgrep"], returncode=returncode, stdout=stdout) + + +class TestFindChildProcessIdPgrep: + """Tests for the pgrep-based signal-target discovery used on non-Linux POSIX hosts.""" + + @patch("openjd.sessions._linux._sudo.run") + def test_returns_child_pid_on_match(self, mock_run: MagicMock) -> None: + # GIVEN pgrep matches a single child process + mock_run.return_value = _pgrep_result(returncode=0, stdout="4321\n") + + # WHEN + result = find_child_process_id_pgrep(sudo_pid=1234) + + # THEN + assert result == 4321 + + @patch("openjd.sessions._linux._sudo.run") + def test_returns_none_when_no_match_yet(self, mock_run: MagicMock) -> None: + # GIVEN pgrep finds no matching processes (exit code 1) -- e.g. sudo has not + # spawned its child yet. This must return None (so the caller retries), NOT raise. + mock_run.return_value = _pgrep_result(returncode=1, stdout="") + + # WHEN + result = find_child_process_id_pgrep(sudo_pid=1234) + + # THEN + assert result is None + + @patch("openjd.sessions._linux._sudo.run") + def test_raises_on_pgrep_error(self, mock_run: MagicMock) -> None: + # GIVEN pgrep reports an actual error (exit code > 1) + mock_run.return_value = _pgrep_result(returncode=2, stdout="") + + # WHEN / THEN + with pytest.raises(FindSignalTargetError): + find_child_process_id_pgrep(sudo_pid=1234) + + @patch("openjd.sessions._linux._sudo.run") + def test_raises_on_multiple_children(self, mock_run: MagicMock) -> None: + # GIVEN pgrep matches more than one child, violating the single-child assumption + mock_run.return_value = _pgrep_result(returncode=0, stdout="4321\n4322\n") + + # WHEN / THEN + with pytest.raises(FindSignalTargetError): + find_child_process_id_pgrep(sudo_pid=1234) + + @patch("openjd.sessions._linux._sudo.run") + def test_returns_none_on_empty_stdout_with_success(self, mock_run: MagicMock) -> None: + # GIVEN pgrep exits 0 but with no pids in stdout (defensive: treat as no match) + mock_run.return_value = _pgrep_result(returncode=0, stdout="") + + # WHEN + result = find_child_process_id_pgrep(sudo_pid=1234) + + # THEN + assert result is None From ce537718e7412ddd8c814617139dca40ecb2615d Mon Sep 17 00:00:00 2001 From: Andy Choquette Date: Fri, 17 Jul 2026 10:07:45 -0700 Subject: [PATCH 2/2] test: run POSIX cross-user impersonation tests on macOS in CI The impersonation tests already exist but xfail everywhere the OPENJD_TEST_SUDO_* environment variables are unset; on Linux they run inside a purpose-built Docker container, and nothing runs them on macOS. macOS runners have passwordless sudo, so this workflow provisions the same user/group layout with Directory Services (sysadminctl/dseditgroup) and runs the existing tests for real, covering the macOS setsid-shim launch, signalling, and process-tree termination paths end to end. A guard step fails the job if the tests regress to xfail (e.g. the provisioning breaks), instead of silently passing an empty run. Signed-off-by: Andy Choquette --- .github/workflows/macos_cross_user_test.yml | 169 ++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 .github/workflows/macos_cross_user_test.yml diff --git a/.github/workflows/macos_cross_user_test.yml b/.github/workflows/macos_cross_user_test.yml new file mode 100644 index 00000000..68e3611d --- /dev/null +++ b/.github/workflows/macos_cross_user_test.yml @@ -0,0 +1,169 @@ +name: macOS Cross-User Tests + +# Runs the POSIX user-impersonation tests (which xfail when the OPENJD_TEST_SUDO_* +# environment variables are unset) on a macOS runner. This exercises the real +# `sudo -u -i -I -c ` cross-user path end to end: +# process launch as another user, new-process-group creation, signalling, and +# process-tree termination. +# +# The runner's passwordless sudo is used to provision the same user/group layout +# that testing_containers/localuser_sudo_environment/Dockerfile creates for Linux: +# runner -- runs the pytests (member of the shared group) +# openjd-target -- the impersonated user (member of the shared group) +# openjd-disjoint -- a user with no group in common (temp-dir permission tests) + +on: + workflow_dispatch: + pull_request: + branches: [ mainline, release ] + paths: + - 'src/openjd/sessions/_subprocess.py' + - 'src/openjd/sessions/_linux/_sudo.py' + - 'src/openjd/sessions/_os_checker.py' + - 'src/openjd/sessions/_tempdir.py' + - '.github/workflows/macos_cross_user_test.yml' + +env: + OPENJD_TEST_SUDO_TARGET_USER: openjd-target + OPENJD_TEST_SUDO_SHARED_GROUP: openjd-shared + OPENJD_TEST_SUDO_DISJOINT_USER: openjd-disjoint + OPENJD_TEST_SUDO_DISJOINT_GROUP: openjd-disjointgrp + # Hatch's default data dir is under ~/Library, which other users cannot traverse. + # The impersonation tests execute the hatch venv's python as the target user, so + # the venv must live somewhere world-traversable. + HATCH_DATA_DIR: /opt/hatch + # macOS's default per-user temp dir (/var/folders//T, mode 700) is not + # traversable by the impersonated user, and /var is a symlink to /private/var + # (which TempDir resolves but gettempdir() does not). Use a dedicated + # world-writable, already-resolved temp root instead, which matches the /tmp + # semantics the impersonation tests get on Linux. Created during provisioning + # owned by runner:staff because BSD filesystems give new files the GROUP OF THE + # PARENT DIRECTORY (not the creator's gid), and the same-user TempDir test + # asserts the created directory has the creating process's gid. + TMPDIR: /private/tmp/openjd-tests + +jobs: + macos-cross-user: + name: Python ${{ matrix.python-version }} + runs-on: macos-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + python-version: ['3.11', '3.13'] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Provision test users and groups + run: | + set -euxo pipefail + + # Groups + sudo dseditgroup -o create "${OPENJD_TEST_SUDO_SHARED_GROUP}" + sudo dseditgroup -o create "${OPENJD_TEST_SUDO_DISJOINT_GROUP}" + + # Target user: impersonated by the tests; shares a group with runner + sudo sysadminctl -addUser "${OPENJD_TEST_SUDO_TARGET_USER}" \ + -fullName "OpenJD Test Target" -password "OpenJD-ci-test-1!" -shell /bin/zsh + sudo createhomedir -c -u "${OPENJD_TEST_SUDO_TARGET_USER}" > /dev/null + sudo dseditgroup -o edit -a "${OPENJD_TEST_SUDO_TARGET_USER}" -t user "${OPENJD_TEST_SUDO_SHARED_GROUP}" + # Linux useradd gives every user a self-named group, and + # test_cleanup_posix_user chowns to "user:user"; macOS does not, so + # create the self-named group explicitly. runner must NOT be a member. + sudo dseditgroup -o create "${OPENJD_TEST_SUDO_TARGET_USER}" + sudo dseditgroup -o edit -a "${OPENJD_TEST_SUDO_TARGET_USER}" -t user "${OPENJD_TEST_SUDO_TARGET_USER}" + + # Disjoint user: NO group in common with runner + sudo sysadminctl -addUser "${OPENJD_TEST_SUDO_DISJOINT_USER}" \ + -fullName "OpenJD Test Disjoint" -password "OpenJD-ci-test-1!" -shell /bin/zsh + sudo createhomedir -c -u "${OPENJD_TEST_SUDO_DISJOINT_USER}" > /dev/null + sudo dseditgroup -o edit -a "${OPENJD_TEST_SUDO_DISJOINT_USER}" -t user "${OPENJD_TEST_SUDO_DISJOINT_GROUP}" + + # The test-running user joins the shared group (matches the Docker layout) + sudo dseditgroup -o edit -a runner -t user "${OPENJD_TEST_SUDO_SHARED_GROUP}" + + # Passwordless sudo from runner to the target user (and itself), mirroring + # the hostuser rule in the Linux test container + echo "runner ALL=(${OPENJD_TEST_SUDO_TARGET_USER},runner) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/openjd-cross-user-tests + sudo chmod 440 /etc/sudoers.d/openjd-cross-user-tests + sudo visudo -cf /etc/sudoers.d/openjd-cross-user-tests + + # test_basic_operation runs a bare `python` as the target user via + # `sudo -i`; macOS ships python3 only, so provide the alias. + sudo mkdir -p /usr/local/bin + sudo ln -sf /usr/bin/python3 /usr/local/bin/python + + # Flush Directory Services caches so the new users/groups resolve + sudo dscacheutil -flushcache + + # World-writable temp root for the tests (see TMPDIR at the top of the file) + sudo mkdir -p "${TMPDIR}" + sudo chown runner:staff "${TMPDIR}" + sudo chmod 1777 "${TMPDIR}" + + - name: Verify provisioning + run: | + set -euxo pipefail + id "${OPENJD_TEST_SUDO_TARGET_USER}" + id "${OPENJD_TEST_SUDO_DISJOINT_USER}" + id runner + # The isolation invariant the tests rely on: runner and the target user + # share OPENJD_TEST_SUDO_SHARED_GROUP; the disjoint user shares nothing. + id -Gn runner | tr ' ' '\n' | grep -qx "${OPENJD_TEST_SUDO_SHARED_GROUP}" + id -Gn "${OPENJD_TEST_SUDO_TARGET_USER}" | tr ' ' '\n' | grep -qx "${OPENJD_TEST_SUDO_SHARED_GROUP}" + if id -Gn "${OPENJD_TEST_SUDO_DISJOINT_USER}" | tr ' ' '\n' | grep -qx "${OPENJD_TEST_SUDO_SHARED_GROUP}"; then + echo "disjoint user must not be in the shared group" && exit 1 + fi + # Cross-user execution works at all + sudo -u "${OPENJD_TEST_SUDO_TARGET_USER}" -i /usr/bin/true + sudo -u "${OPENJD_TEST_SUDO_TARGET_USER}" -i python -c 'import getpass; print("bare python runs as", getpass.getuser())' + + - name: Install hatch + run: | + sudo mkdir -p "${HATCH_DATA_DIR}" + sudo chown runner "${HATCH_DATA_DIR}" + pip install hatch + + - name: Create test environment + run: | + set -euxo pipefail + hatch env create + # The target user executes the venv python and reads test support files; + # make the venv and the workspace world-readable/traversable. + chmod -R o+rX "${HATCH_DATA_DIR}" "${GITHUB_WORKSPACE}" + + - name: Report which interpreter the setsid shim resolves to + # NOTE: no braces in this inline script -- hatch run applies its own + # {...} template substitution to the arguments it receives. + run: | + hatch run python -c " + from openjd.sessions._subprocess import _macos_shim_interpreter, _MACOS_FALLBACK_SHIM_INTERPRETER + picked = _macos_shim_interpreter() + branch = 'FALLBACK' if picked == _MACOS_FALLBACK_SHIM_INTERPRETER else 'BASE-INTERPRETER' + print('shim interpreter:', picked, '(' + branch + ' branch)') + " + + - name: Run cross-user impersonation tests + run: | + set -euxo pipefail + # -rxX lists (x)failed and (X)passed-unexpectedly tests in the summary so the + # next step can assert nothing silently xfailed back to a no-op. + hatch run test -- test/openjd/sessions_v0/test_subprocess.py test/openjd/sessions_v0/test_tempdir.py \ + --no-cov -rxX 2>&1 | tee pytest-cross-user.log + + - name: Assert impersonation tests actually ran + run: | + set -euxo pipefail + # If the OPENJD_TEST_SUDO_* wiring regresses, the impersonation tests xfail + # with this message instead of failing the job -- catch that here. + if grep -q "Must define environment vars OPENJD_TEST_SUDO" pytest-cross-user.log; then + echo "Impersonation tests were skipped (env vars not picked up); provisioning is broken." + exit 1 + fi + + - name: Run remaining tests + run: hatch run test -- --ignore test/openjd/sessions_v0/test_subprocess.py --ignore test/openjd/sessions_v0/test_tempdir.py --no-cov