Skip to content

feat: support running Sessions as a jobRunAsUser on macOS#335

Open
andychoquette wants to merge 3 commits into
OpenJobDescription:mainlinefrom
andychoquette:macos-support
Open

feat: support running Sessions as a jobRunAsUser on macOS#335
andychoquette wants to merge 3 commits into
OpenJobDescription:mainlinefrom
andychoquette:macos-support

Conversation

@andychoquette

Copy link
Copy Markdown

What was the problem/requirement? (What/Why)

openjd-sessions could not run a Session's actions as a separate user (a
"jobRunAsUser") on macOS. Three problems in the POSIX cross-user path:

  1. setsid is missing on macOS. The cross-user command is
    sudo -u <user> -i setsid -w <cmd>, but setsid(1) is a Linux/util-linux
    tool that does not exist on macOS. Every impersonated action failed at spawn
    with command not found (exit 127), so running actions as a jobRunAsUser was
    completely broken on macOS.

  2. find_child_process_id_pgrep never retried. On non-Linux POSIX hosts,
    signal-target discovery uses pgrep -P <sudo_pid>. pgrep exits 1 when it
    finds no match yet (the child hasn't spawned), but the code treated any
    non-zero exit as a fatal FindSignalTargetError, which broke out of the
    caller's retry loop. Because Linux uses procfs and only other POSIX platforms
    use pgrep, this latent bug meant signal-target discovery never actually
    retried off Linux.

  3. No is_macos() helper. The MACOS = "darwin" constant existed in
    _os_checker.py but was unused, so there was no way to branch on macOS.

What was the solution? (How)

  1. Add is_macos() to _os_checker.py.

  2. Reproduce setsid behavior on macOS with a pure-Python shim. On darwin,
    the workload is launched via
    sudo -u <user> -i /usr/bin/python3 -I -c '<shim>' <cmd>, where the shim
    makes the process a new session/process-group leader and then execs the
    real command:

    import os,sys;os.getpgrp()==os.getpid() or os.setsid();os.execvp(sys.argv[1],sys.argv[1:])
    • os.getpgrp() == os.getpid() or os.setsid() calls setsid() only when the
      process is not already a group leader, avoiding the EPERM that os.setsid()
      raises for an existing group leader.
    • Single line so it passes cleanly through sudo -i argv without shell-quoting
      fragility.
    • /usr/bin/python3 (the OS interpreter) rather than sys.executable, so the
      jobRunAsUser can execute it without traverse/read permission on the agent's
      virtual environment.
    • -I (isolated mode) so the session working directory is not on sys.path,
      preventing a file such as os.py in that directory from being imported ahead
      of the standard library before os.execvp() runs.

    This produces the same topology setsid -w gives on Linux — the workload is
    sudo's direct child in a process group distinct from sudo's — so the
    existing find_sudo_child_process_group_id discovery works unchanged.

  3. Treat pgrep exit code 1 as "no match yet" (return None so the caller
    retries) rather than fatal. Exit >1 is still a genuine error. This fixes the
    discovery/cancellation path on all non-Linux POSIX hosts, not just macOS.

The Linux and Windows code paths are unchanged.

What is the impact of this change?

macOS hosts can now run Session actions as a jobRunAsUser via sudo, matching
the existing behavior on Linux and Windows. On Linux and Windows there is no
behavioral change. The pgrep fix additionally repairs signal-target discovery
(and therefore process cancellation/cleanup) for any non-Linux POSIX platform.

macOS prerequisite: because the shim runs via /usr/bin/python3 (the Command
Line Tools interpreter), impersonated Sessions on macOS require the Xcode Command
Line Tools (xcode-select --install). This is documented in the README.

How was this change tested?

  • Added unit tests: is_macos() (test_os_checker.py); the pgrep exit-code
    handling — exit 1 -> None/retry, exit >1 -> raise, single/multiple/empty
    matches (test_sudo.py, new); the macOS cross-user command construction and a
    POSIX check that the shim creates a new process group (test_subprocess.py).
  • Ran the full test/openjd/sessions_v0 unit suite: 550 passed, 33 skipped,
    16 xfailed
    , no failures.
  • Validated end-to-end on macOS 26.5 (arm64) with the AWS Deadline Cloud worker
    agent against a live customer-managed fleet: install -> worker registration ->
    running an action as a jobRunAsUser -> cancellation reaping the workload's
    process group with no orphaned processes.
  • Yes, unit tests were run.

Was this change documented?

  • Yes. Added a macOS note to the POSIX impersonation section of the README
    (Command Line Tools requirement) and expanded the code comments in
    _subprocess.py explaining the shim, the -I isolation, and the discovery
    assumption. The is_macos()/pgrep behavior is covered by the new tests.

Is this a breaking change?

No. No public interface changes; Linux and Windows behavior is unchanged.

Does this change impact security?

This touches the cross-user (jobRunAsUser) impersonation path, which is a
security boundary. The shim is constructed as an argv list (no shell, not
injectable), uses a fixed absolute interpreter path, and -I prevents importing
attacker-placed modules from the working directory before exec. All work after
sudo -u <user> runs as the target job user (same as the existing Linux path),
so control of the command only ever lets the job run as itself. Flagging with the
"security" label so maintainers can review the boundary; happy to work through a
threat-model discussion.

Cross-port to openjd-rs

  • A tracking issue has been filed in openjd-rs to port this change (link here):

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Comment thread src/openjd/sessions/_subprocess.py Outdated
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>
crowecawcaw
crowecawcaw previously approved these changes Jul 17, 2026
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 <apcho@amazon.com>

@leongdl leongdl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also port this to OpenJD-RS.

We're in the middle of migrating from Python to the Rust implementation.

Comment on lines +58 to +60
_MACOS_SETSID_SHIM = (
"import os,sys;os.getpgrp()==os.getpid() or os.setsid();os.execvp(sys.argv[1],sys.argv[1:])"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagging this line, but really I'd like to learn more in general process management in macOS and what research we've done to know this is the correct solution.

From what I understand, macOS makes sids/pgids pretty hidden. Like you've discovered they don't even provide a utility to assign those values even though there's a syscall. Even when inspecting processes they don't really expose these values via ps -o sess= though a getsid syscall may produce the session id?

Basically, I want to know we are using the right tools provided to us for the OS to properly signal, inspect, manage, isolate, and terminate processes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haven't forgotten about this comment. doing some additional research this week to answer whether these are the right os-provided tools.

@epmog
epmog disabled auto-merge July 20, 2026 18:55
@andychoquette

Copy link
Copy Markdown
Author

Please also port this to OpenJD-RS.

We're in the middle of migrating from Python to the Rust implementation.

Will do - I opened issue OpenJobDescription/openjd-rs#263 to track the work

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants