Skip to content

fix: sudo password prompt broken in setup command#65

Merged
Peyton-Spencer merged 6 commits intomainfrom
fix/sudo-terminal-io
Mar 25, 2026
Merged

fix: sudo password prompt broken in setup command#65
Peyton-Spencer merged 6 commits intomainfrom
fix/sudo-terminal-io

Conversation

@Peyton-Spencer
Copy link
Copy Markdown
Contributor

@Peyton-Spencer Peyton-Spencer commented Mar 20, 2026

Summary

• Fix sudo: unable to read password: Input/output error in mac-runner setup and mac-runner setup --teardown
• Replace Foundation Process with C system() for the sudo -v pre-auth step
• Foundation's posix_spawn doesn't set the child as the terminal foreground process group, so sudo can't interact with /dev/tty for the password prompt

Root Cause

sudo -v reads the password directly from /dev/tty (not stdin). It needs the calling process to be in the terminal's foreground process group to do tcsetattr/tcgetattr for echo suppression and secure password reading. Foundation's Process class uses posix_spawn which doesn't set up terminal control properly, causing the I/O error.

Darwin.system() (C's system()) uses fork/exec with correct process group management, signal handling, and terminal control inheritance.

Test plan

  • Run mac-runner setup — password prompt should work
  • Run mac-runner setup --teardown — password prompt should work
  • Ctrl+C during password prompt should cancel cleanly
  • Existing unit test for makeAdministratorAuthenticationProcess still passes (factory kept for test coverage)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • More reliable detection of elevated/root execution and correct identification of the invoking user during setup/teardown.
    • Removed interactive administrator prompt; operations now fail fast with clear, actionable guidance when not run as root.
    • Ownership of config files is restored when commands run under sudo; sudoers installation is skipped when already root.
  • Documentation

    • CLI help and on-screen guidance now consistently show running setup/teardown with sudo.
  • Tests

    • Added unit tests for invoking-user detection and application support directory resolution under sudo.

Foundation's Process class spawns child processes via posix_spawn
without setting the child as the terminal foreground process group.
When sudo -v tries to read the password from /dev/tty, it fails
with "unable to read password: Input/output error" because the
child process doesn't have proper terminal control.

Replace with Darwin.system() which uses fork/exec with correct
process group management, terminal control, and signal handling
(SIGINT/SIGQUIT properly ignored in parent during wait).

Co-Authored-By: Zest <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 20, 2026

Caution

Review failed

Pull request was closed or merged during review

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Requires running setup/teardown as root: CLI help now shows sudo; SetupWizard exits with a message when not run as root and reads SUDO_USER to decide sudoers installation; ConfigService resolves Application Support for SUDO_USER when running as root and restores ownership after writes.

Changes

Cohort / File(s) Summary
CLI Help Text
Sources/Services/CLIHandler.swift
Prepended sudo to mac-runner setup and mac-runner setup --teardown usage/help text.
Setup & Teardown Flow
Sources/Services/SetupWizard.swift
Added geteuid() == 0 guards at start of runSetup() and runTeardown() (prints sudo guidance and returns if not root); removed isolationService.authenticateAdministratorAccess() calls and related prompts; derive mainUser from SUDO_USER (fallback NSUserName()), skip sudoers install if mainUser == "root", error if mainUser doesn't exist, otherwise install sudoers entry for that user; updated messaging to reference sudo mac-runner setup --teardown.
Config directory & ownership
Sources/Services/ConfigService.swift
When effective UID is root and SUDO_USER is set, resolve Application Support to /Users/{SUDO_USER}/Library/Application Support; add invokingUser helper and restoreOwnershipIfNeeded() to chown the config directory back to the invoking user after initialization and after saving config.json.
Tests
Tests/MacRunnerTests/MacRunnerTests.swift
Added tests verifying ConfigService behavior for applicationSupportDirectory and invokingUser under root vs non-root and presence/absence of SUDO_USER.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as "CLI (mac-runner)"
    participant Setup as "SetupWizard"
    participant OS as "OS (geteuid / SUDO_USER / userdb)"
    participant Config as "ConfigService"

    CLI->>Setup: invoke "setup" / "setup --teardown"
    Setup->>OS: check geteuid()
    alt not root
        Setup-->>CLI: print "Run with sudo mac-runner setup..." and exit
    else root
        Setup->>OS: read SUDO_USER
        Setup->>Config: initialize (resolve App Support for SUDO_USER if present)
        Config->>OS: (may) chown config dir to invokingUser
        Setup->>OS: resolve mainUser (SUDO_USER or NSUserName)
        alt mainUser == "root"
            Setup-->>CLI: skip sudoers installation
        else mainUser missing
            Setup-->>CLI: print error and exit
        else mainUser valid
            Setup->>OS: install sudoers entry for mainUser
            Setup-->>CLI: continue setup/teardown steps
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped to the terminal, nose all aglow,

"Are you root?" I whispered, "or sudo you'll go."
If not, wear a crown — type sudo with grace,
Then configs will settle back into place.
A nibble of code, a tidy small hop 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: sudo password prompt broken in setup command' directly addresses the main objective: fixing the sudo password prompt issue in the setup command. It accurately reflects the primary change in the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sudo-terminal-io

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Swift marks Darwin.system() as unavailable, so add a minimal C
helper module (CHelpers) that wraps system() for use from Swift.

system() properly manages terminal foreground process groups via
fork/exec, which is required for sudo's interactive password prompt.
Foundation's Process uses posix_spawn without terminal group setup,
causing "unable to read password: Input/output error".

Co-Authored-By: Zest <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
Sources/CHelpers/chelpers.c (1)

4-5: This function could be narrowed to reflect its single dedicated purpose.

The current wrapper (run_system_command) serves only one call site with a constant string ("sudo -v"). Rather than expose a generic command-execution API, consider a dedicated helper like run_sudo_validate() for clarity and to prevent accidental misuse.

♻️ Suggested refactor (dedicated helper)
- int run_system_command(const char *command) {
-     return system(command);
- }
+int run_sudo_validate(void) {
+    return system("/usr/bin/sudo -v");
+}

And update the header + Swift call accordingly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CHelpers/chelpers.c` around lines 4 - 5, Replace the generic wrapper
run_system_command(const char *command) with a dedicated helper
run_sudo_validate() that calls system("sudo -v") internally; update the C header
to expose run_sudo_validate instead of run_system_command and update the Swift
FFI/import site to call run_sudo_validate(), removing the ability to pass
arbitrary commands and clarifying the function's sole purpose.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Services/UserIsolationService.swift`:
- Around line 285-286: The call to run_system_command("sudo -v") in
UserIsolationService.swift should use an absolute path to avoid PATH-based
privilege escalation; update the invocation in the authentication flow (the
run_system_command call used in the UserIsolationService authentication block)
to call "/usr/bin/sudo -v" instead of "sudo -v" so the system() call executes
the fixed binary path.

---

Nitpick comments:
In `@Sources/CHelpers/chelpers.c`:
- Around line 4-5: Replace the generic wrapper run_system_command(const char
*command) with a dedicated helper run_sudo_validate() that calls system("sudo
-v") internally; update the C header to expose run_sudo_validate instead of
run_system_command and update the Swift FFI/import site to call
run_sudo_validate(), removing the ability to pass arbitrary commands and
clarifying the function's sole purpose.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 228a568a-3926-424b-b623-64c3736a03c7

📥 Commits

Reviewing files that changed from the base of the PR and between 750ed14 and 44d7fb4.

📒 Files selected for processing (4)
  • Package.swift
  • Sources/CHelpers/chelpers.c
  • Sources/CHelpers/include/chelpers.h
  • Sources/Services/UserIsolationService.swift

Comment thread Sources/Services/UserIsolationService.swift Outdated
Replace the `authenticateAdministratorAccess()` flow (which used
Foundation's Process to spawn `sudo -v` — broken due to terminal
I/O issues) with a simple root check at the start of setup/teardown.

Users now run `sudo mac-runner setup` instead of entering their
password mid-flow. This is cleaner, more secure, and matches how
Homebrew handles admin-required operations.

Also fixes SUDO_USER detection for the sudoers entry — NSUserName()
returns "root" under sudo, so we read SUDO_USER from the environment.

Co-Authored-By: Zest <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
Sources/Services/SetupWizard.swift (1)

104-106: Consider handling the edge case where the resolved user is "root".

The SUDO_USER lookup is correct for the normal sudo mac-runner setup flow. However, if someone logs in directly as root (not via sudo), SUDO_USER won't be set and NSUserName() returns "root", resulting in a sudoers entry for the root user—which is redundant.

Consider adding a guard or warning:

💡 Optional: Add a sanity check for root user
             // When running via sudo, NSUserName() returns "root".
             // SUDO_USER contains the actual invoking user.
             let mainUser = ProcessInfo.processInfo.environment["SUDO_USER"] ?? NSUserName()
+            if mainUser == "root" {
+                print("Warning: Could not determine invoking user. Sudoers entry will be created for 'root'.")
+            }
             try isolationService.installSudoersEntry(mainUsername: mainUser, serviceUsername: username)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Services/SetupWizard.swift` around lines 104 - 106, The resolved main
user assigned to mainUser via ProcessInfo.processInfo.environment["SUDO_USER"]
?? NSUserName() can legitimately be "root" when someone is logged in as root;
add a check after computing mainUser (the variable in SetupWizard.swift) that
detects if mainUser == "root" and handle it by either skipping creation of the
redundant sudoers entry (or emitting a clear warning and aborting the sudoers
modification flow), so the code that writes the sudoers entry for mainUser is
not executed for "root".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@Sources/Services/SetupWizard.swift`:
- Around line 104-106: The resolved main user assigned to mainUser via
ProcessInfo.processInfo.environment["SUDO_USER"] ?? NSUserName() can
legitimately be "root" when someone is logged in as root; add a check after
computing mainUser (the variable in SetupWizard.swift) that detects if mainUser
== "root" and handle it by either skipping creation of the redundant sudoers
entry (or emitting a clear warning and aborting the sudoers modification flow),
so the code that writes the sudoers entry for mainUser is not executed for
"root".

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5b7b5870-7890-4cbb-a02a-318ea8a0b489

📥 Commits

Reviewing files that changed from the base of the PR and between 44d7fb4 and 5e1b931.

📒 Files selected for processing (2)
  • Sources/Services/CLIHandler.swift
  • Sources/Services/SetupWizard.swift
✅ Files skipped from review due to trivial changes (1)
  • Sources/Services/CLIHandler.swift

Root already has full privileges — writing a sudoers entry for "root"
is redundant. This handles the edge case where someone runs setup
while logged in as root directly (no SUDO_USER set).

Co-Authored-By: Zest <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Services/SetupWizard.swift`:
- Around line 104-110: Resolve and validate the invoking account before passing
it into installSudoersEntry: prefer deriving mainUser from SUDO_UID by looking
up the local account for that UID (fallback to SUDO_USER only if no SUDO_UID),
then verify the resolved name is a canonical local short username (single token,
matches POSIX short name rules and exists in the local user DB); if validation
fails, log/print an error and skip calling
isolationService.installSudoersEntry(mainUsername:..., serviceUsername:
username). Update the code paths that currently set mainUser from SUDO_USER and
the call site of installSudoersEntry so they use the validated/lookup result or
bail out on invalid input.
- Line 48: The success footer in SetupWizard still prints the teardown
instruction without sudo; update the print statement that emits "Run 'mac-runner
setup --teardown' to disable." to include sudo (i.e. "Run 'sudo mac-runner setup
--teardown' to disable.") — locate the print in the SetupWizard success/finish
path (the footer print) and change its string, and search for any other
occurrences of the same message in SetupWizard to keep them consistent.
- Around line 27-34: SetupWizard.runSetup() and runTeardown() elevate to root,
causing ConfigService's .applicationSupportDirectory to resolve under root's
home; capture the invoking user's identity (from the SUDO_USER env var) or
resolve and store the invoking user's Application Support path before checking
geteuid(), then pass that original-user home/path into ConfigService
initialization or its path resolution so
ConfigService.applicationSupportDirectory uses the invoking user's home rather
than /var/root; update SetupWizard to supply that stored path or user to
ConfigService constructors or to a new init parameter so all config I/O targets
the original user.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 02fbd75d-a08e-4f81-ae0f-09d6682eb0be

📥 Commits

Reviewing files that changed from the base of the PR and between 5e1b931 and 509355b.

📒 Files selected for processing (1)
  • Sources/Services/SetupWizard.swift

Comment on lines +27 to +34
// Require root — all setup operations need administrator privileges.
// Running with sudo ensures clean terminal I/O (no mid-flow password
// prompts) and a cached credential for all child sudo -n calls.
guard geteuid() == 0 else {
print("Error: setup requires administrator privileges.")
print("Run: sudo mac-runner setup")
return
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

config_file=$(fd 'ConfigService\.swift$' Sources | head -n1)
if [[ -z "${config_file:-}" ]]; then
  echo "ConfigService.swift not found"
  exit 1
fi

echo "== ConfigService symbols =="
rg -n -C3 --type=swift '\b(class|struct|actor)\s+ConfigService\b|\bfunc\s+(loadConfig|saveConfig)\b' Sources

echo
echo "== ConfigService implementation =="
sed -n '1,260p' "$config_file"

echo
echo "== Path / ownership signals =="
rg -n -C2 --type=swift \
  'NSHomeDirectory\(|homeDirectoryForCurrentUser|applicationSupportDirectory|documentDirectory|libraryDirectory|SUDO_(USER|UID|HOME)|createFile|write\(|replaceItemAt|setAttributes|chown' \
  "$config_file"

Repository: omniaura/mac-runner

Length of output: 5856


Config I/O will target root instead of the invoking user.

When SetupWizard.runSetup() and runTeardown() force geteuid() == 0, ConfigService will resolve .applicationSupportDirectory under the root user's home (/var/root/Library/Application Support), not the invoking user's directory. This means setup writes configuration to the wrong location—the invoking user's config remains untouched, and later runs without sudo will read from an uninitialized config directory.

Pass the original user's identity (from SUDO_USER environment variable) to ConfigService so it can resolve paths under the correct home directory, or preserve the original user's Application Support directory path before forcing root privileges.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Services/SetupWizard.swift` around lines 27 - 34,
SetupWizard.runSetup() and runTeardown() elevate to root, causing
ConfigService's .applicationSupportDirectory to resolve under root's home;
capture the invoking user's identity (from the SUDO_USER env var) or resolve and
store the invoking user's Application Support path before checking geteuid(),
then pass that original-user home/path into ConfigService initialization or its
path resolution so ConfigService.applicationSupportDirectory uses the invoking
user's home rather than /var/root; update SetupWizard to supply that stored path
or user to ConfigService constructors or to a new init parameter so all config
I/O targets the original user.

Comment thread Sources/Services/SetupWizard.swift
Comment thread Sources/Services/SetupWizard.swift
- ConfigService: resolve invoking user's Application Support dir when
  running under sudo (was writing to /var/root/ instead of user's home)
- SetupWizard: add missing `sudo` prefix to teardown instruction footer
- SetupWizard: validate SUDO_USER is a real local account via dscl
  before writing it into the sudoers template

Co-Authored-By: Zest <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Services/ConfigService.swift`:
- Around line 8-18: ConfigService currently points appSupport at the invoking
user's Application Support when running under sudo (see the appSupport
assignment) but still performs directory creation and config writes as root in
init() and save operations, leaving MacRunner/ and config.json root-owned;
update init() (and any saveConfig/save methods) to perform I/O as the invoking
user or to chown the created directory/files back to SUDO_USER: detect SUDO_USER
from ProcessInfo, determine that user's uid/gid, and either (a) perform
directory creation and file writes using that uid (e.g., temporarily set
effective UID for the operations) or (b) after creating/writing, call chown on
the created MacRunner directory and config.json to the invoking user's uid/gid
so subsequent non-sudo runs can update the config.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b6e7b9c5-1f56-4ea8-b6d1-5bc54705c135

📥 Commits

Reviewing files that changed from the base of the PR and between 509355b and abb16dc.

📒 Files selected for processing (2)
  • Sources/Services/ConfigService.swift
  • Sources/Services/SetupWizard.swift

Comment thread Sources/Services/ConfigService.swift Outdated
Chown MacRunner's Application Support directory back to the invoking user after sudo-based setup so later non-sudo runs can still read and update config. Add focused tests for the sudo user path resolution helpers that support the ownership fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Peyton-Spencer Peyton-Spencer merged commit c708907 into main Mar 25, 2026
1 of 2 checks passed
@Peyton-Spencer Peyton-Spencer deleted the fix/sudo-terminal-io branch March 25, 2026 20:23
@github-actions
Copy link
Copy Markdown

🎉 This PR is included in version 1.13.1 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant