Skip to content

Commit fcfd47d

Browse files
committed
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 <user> -i setsid -w <cmd>' 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 <user> -i /usr/bin/python3 -I -c <shim> <cmd>') 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 <apcho@amazon.com>
1 parent f0bfb98 commit fcfd47d

7 files changed

Lines changed: 412 additions & 3 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,15 @@ with passwordless `sudo` by, for example, adding a rule like follows to your
234234
host ALL=(actions) NOPASSWD: ALL
235235
```
236236

237+
On MacOS, the impersonated command is launched under a small Python shim because macOS
238+
lacks the `setsid(1)` utility. The shim runs with the base interpreter behind the Python
239+
that is running this library (for a virtual environment, the interpreter the venv was
240+
created from) provided that interpreter is reachable and executable by other users;
241+
otherwise it falls back to the operating system's `/usr/bin/python3`, which resolves to a
242+
working interpreter only when the Xcode Command Line Tools (or Xcode) are present
243+
(`xcode-select --install`). No separate Python installation is required when the base
244+
interpreter is usable.
245+
237246
#### Impersonating a User: Windows Systems
238247

239248
To run an impersonated Session on Windows Systems modify the "Running a Session" example

src/openjd/sessions/_linux/_sudo.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ def find_child_process_id_pgrep(
143143
stdin=DEVNULL,
144144
text=True,
145145
)
146+
# pgrep exit codes: 0 = one or more processes matched; 1 = no processes matched;
147+
# >1 = an actual error (syntax/operational). Exit 1 is NOT an error here -- it just
148+
# means sudo has not spawned its child yet, so we return None to let the caller's
149+
# retry loop poll again.
150+
if pgrep_result.returncode == 1:
151+
return None
146152
if pgrep_result.returncode != 0:
147153
raise FindSignalTargetError("Unable to query child processes of sudo process")
148154
results = pgrep_result.stdout.splitlines()

src/openjd/sessions/_os_checker.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ def is_windows() -> bool:
2121
return os.name == WINDOWS
2222

2323

24+
def is_macos() -> bool:
25+
return sys.platform == MACOS
26+
27+
2428
def check_os() -> None:
2529
if not (is_posix() or is_windows()):
2630
raise NotImplementedError(

src/openjd/sessions/_subprocess.py

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import shlex
55
import signal
6+
import stat
67
import sys
78
import time
89
from contextlib import nullcontext
@@ -16,7 +17,7 @@
1617
from ._linux._capabilities import try_use_cap_kill
1718
from ._linux._sudo import find_sudo_child_process_group_id
1819
from ._logging import LoggerAdapter, LogContent, LogExtraInfo
19-
from ._os_checker import is_linux, is_posix, is_windows
20+
from ._os_checker import is_linux, is_macos, is_posix, is_windows
2021
from ._session_user import PosixSessionUser, WindowsSessionUser, SessionUser
2122
from ._action_filter import redact_openjd_redacted_env_requests
2223

@@ -28,6 +29,84 @@
2829

2930
__all__ = ("LoggingSubprocess",)
3031

32+
# macOS has no `setsid(1)` binary (it is a Linux/util-linux tool), yet the new-session
33+
# behavior it provides is still required: `sudo -u <user> -i <cmd>` places the workload in
34+
# sudo's own (root-owned) process group, which the jobRunAsUser cannot signal and which
35+
# openjd must not signal (it would hit the root sudo process). We reproduce `setsid` with a
36+
# tiny pure-Python shim, run as the workload, that makes the workload a new session/process-
37+
# group leader and then exec's the real command.
38+
#
39+
# Details:
40+
# * `os.getpgrp() == os.getpid() or os.setsid()` calls setsid() only when the process is
41+
# NOT already a group leader; os.setsid() raises EPERM if the caller already leads a
42+
# group, so the short-circuit avoids that. Either way the workload ends up in a process
43+
# group distinct from sudo's, which find_sudo_child_process_group_id() then discovers.
44+
# * Single line (no newlines) so it passes cleanly through `sudo -i` argv without any
45+
# shell-quoting fragility.
46+
# * The interpreter that runs the shim is the base interpreter behind the one running this
47+
# process (see _macos_shim_interpreter()), falling back to /usr/bin/python3. sys.executable
48+
# itself is not used directly because it may live inside a virtual environment that the
49+
# jobRunAsUser has no traverse/read permission on.
50+
# * `-I` (isolated mode) drops the current working directory from sys.path and ignores
51+
# PYTHON* environment variables, so a file such as os.py in the session working directory
52+
# cannot be imported ahead of the standard library before os.execvp() runs.
53+
#
54+
# Signal-target discovery (find_sudo_child_process_group_id) locates the workload by walking
55+
# sudo's single child and comparing process groups. This relies on `sudo -i` exec'ing the
56+
# command into a single child rather than leaving extra long-lived processes in between; the
57+
# same assumption already holds for the Linux `setsid -w` path.
58+
_MACOS_SETSID_SHIM = (
59+
"import os,sys;os.getpgrp()==os.getpid() or os.setsid();os.execvp(sys.argv[1],sys.argv[1:])"
60+
)
61+
_MACOS_FALLBACK_SHIM_INTERPRETER = "/usr/bin/python3"
62+
63+
64+
def _other_users_can_execute(path: str) -> bool:
65+
"""Returns whether an arbitrary other user can execute the file at the given path based
66+
on the world (other) permission bits: the file itself must be o+x and every directory on
67+
the path must be o+x (traversable). A world-executable file under e.g. a 0o750 home
68+
directory is still unreachable, so both checks are required.
69+
70+
This is a conservative approximation: it ignores group permissions and ACLs that might
71+
also grant access, so it can return False for a path some specific user could execute.
72+
"""
73+
try:
74+
mode = os.stat(path).st_mode
75+
if not (stat.S_ISREG(mode) and mode & stat.S_IXOTH):
76+
return False
77+
parent = os.path.dirname(path)
78+
while True:
79+
if not os.stat(parent).st_mode & stat.S_IXOTH:
80+
return False
81+
next_parent = os.path.dirname(parent)
82+
if next_parent == parent: # reached the filesystem root
83+
return True
84+
parent = next_parent
85+
except OSError:
86+
return False
87+
88+
89+
def _macos_shim_interpreter() -> str:
90+
"""Returns the path of the Python interpreter used to run _MACOS_SETSID_SHIM as the
91+
jobRunAsUser.
92+
93+
Prefers the base interpreter behind the one running this process (sys._base_executable;
94+
for a virtual environment this is the interpreter the venv was created from, in a system
95+
location such as /usr/bin, /opt/homebrew, or a python.org framework install) so that no
96+
separate Python installation is required on the host. The venv's own sys.executable is
97+
not suitable: the jobRunAsUser typically has no traverse/read permission on the agent's
98+
venv directory.
99+
100+
Falls back to /usr/bin/python3 (the Command Line Tools shim; requires the Command Line
101+
Tools or Xcode to be installed) when the base interpreter cannot be determined or is not
102+
reachable and executable by other users.
103+
"""
104+
base = os.path.realpath(getattr(sys, "_base_executable", None) or sys.executable)
105+
if _other_users_can_execute(base):
106+
return base
107+
return _MACOS_FALLBACK_SHIM_INTERPRETER
108+
109+
31110
# ========================================================================
32111
# ========================================================================
33112
# DEVELOPER NOTE:
@@ -257,7 +336,29 @@ def _start_subprocess(self) -> Optional[Popen]:
257336
# same process group as the `sudo` command. If that happens, then
258337
# we're stuck: 1/ Our user cannot kill processes by the self._user; and
259338
# 2/ The self._user cannot kill the root-owned sudo process group.
260-
command.extend(["sudo", "-u", user.user, "-i", "setsid", "-w"])
339+
if is_macos():
340+
# macOS has no setsid(1); use a pure-Python setsid shim (see
341+
# _MACOS_SETSID_SHIM) run as the workload to get the same
342+
# new-session behavior that `setsid -w` provides on Linux.
343+
shim_interpreter = _macos_shim_interpreter()
344+
self._logger.info(
345+
f"Using {shim_interpreter} to run the setsid shim",
346+
extra=LogExtraInfo(openjd_log_content=LogContent.PROCESS_CONTROL),
347+
)
348+
command.extend(
349+
[
350+
"sudo",
351+
"-u",
352+
user.user,
353+
"-i",
354+
shim_interpreter,
355+
"-I",
356+
"-c",
357+
_MACOS_SETSID_SHIM,
358+
]
359+
)
360+
else:
361+
command.extend(["sudo", "-u", user.user, "-i", "setsid", "-w"])
261362
elif is_windows():
262363
user = cast(WindowsSessionUser, self._user) # type: ignore
263364

test/openjd/sessions_v0/test_os_checker.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import unittest
44
from enum import Enum
55
from unittest.mock import patch
6-
from openjd.sessions._os_checker import is_posix, is_windows, check_os
6+
from openjd.sessions._os_checker import is_macos, is_posix, is_windows, check_os
77

88

99
class OSName(str, Enum):
@@ -32,6 +32,16 @@ def test_is_not_windows(self, mock_os):
3232
mock_os.name = OSName.POSIX
3333
self.assertFalse(is_windows())
3434

35+
@patch("openjd.sessions._os_checker.sys")
36+
def test_is_macos(self, mock_sys):
37+
mock_sys.platform = "darwin"
38+
self.assertTrue(is_macos())
39+
40+
@patch("openjd.sessions._os_checker.sys")
41+
def test_is_not_macos(self, mock_sys):
42+
mock_sys.platform = "linux"
43+
self.assertFalse(is_macos())
44+
3545
@patch("openjd.sessions._os_checker.os")
3646
def test_check_os_posix(self, mock_os):
3747
mock_os.name = OSName.POSIX

0 commit comments

Comments
 (0)