|
3 | 3 | import os |
4 | 4 | import shlex |
5 | 5 | import signal |
| 6 | +import stat |
6 | 7 | import sys |
7 | 8 | import time |
8 | 9 | from contextlib import nullcontext |
|
16 | 17 | from ._linux._capabilities import try_use_cap_kill |
17 | 18 | from ._linux._sudo import find_sudo_child_process_group_id |
18 | 19 | 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 |
20 | 21 | from ._session_user import PosixSessionUser, WindowsSessionUser, SessionUser |
21 | 22 | from ._action_filter import redact_openjd_redacted_env_requests |
22 | 23 |
|
|
28 | 29 |
|
29 | 30 | __all__ = ("LoggingSubprocess",) |
30 | 31 |
|
| 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 | + |
31 | 110 | # ======================================================================== |
32 | 111 | # ======================================================================== |
33 | 112 | # DEVELOPER NOTE: |
@@ -257,7 +336,29 @@ def _start_subprocess(self) -> Optional[Popen]: |
257 | 336 | # same process group as the `sudo` command. If that happens, then |
258 | 337 | # we're stuck: 1/ Our user cannot kill processes by the self._user; and |
259 | 338 | # 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"]) |
261 | 362 | elif is_windows(): |
262 | 363 | user = cast(WindowsSessionUser, self._user) # type: ignore |
263 | 364 |
|
|
0 commit comments