Skip to content

Run userland scripts as the console user and bound the login wait - #28

Merged
rodchristiansen merged 2 commits into
mainfrom
fix/userland-stage-execution
Sep 5, 2026
Merged

Run userland scripts as the console user and bound the login wait#28
rodchristiansen merged 2 commits into
mainfrom
fix/userland-stage-execution

Conversation

@rodchristiansen

Copy link
Copy Markdown
Contributor

Fixes #15, #23 and #24 — three defects in the userland stage, all of which let a run report clean while doing nothing useful. They are one PR because they share the same code path and the same console-user lookup.

#15userscript items run as root

processUserScript now dispatches through ScriptManager.runAsUser instead of runScript. When no console user is logged in the item fails with "No console user" rather than silently falling back to root. The system-account filter that waitForUserSession had inline is now SessionManager.getValidConsoleUser(), shared by all three call sites.

What I found reading runAsUser, given it had never executed. It was not correct as written:

  • launchctl asuser <uid> <script> only moves the process into the target user's GUI bootstrap namespace — it does not drop privileges. The script would still have run as root with root's HOME and root's user defaults domain, i.e. the exact bug userscript items run as root, so per-user work goes to the wrong place #15 describes, just with a Mach namespace attached. It now runs launchctl asuser <uid> /usr/bin/sudo -u <username> <script>, which is why the signature gained a username parameter (it had zero callers, so this is not a break).
  • Both stdout and stderr were wired to one Pipe that was never read, and waitUntilExit() was called with it still attached. A script writing more than the pipe buffer holds would have blocked on a full pipe and hung the daemon. It now uses separate pipes, drains them before waitUntilExit(), and logs through Logger.output the way runSyncScript does — so user script output actually reaches the log.
  • The donotwait path attached those same undrained pipes; it now attaches none.
  • Added the working directory and PATH environment handling that runSyncScript already had, for parity.

#23--userscript always exits 0

runUserScriptOnly collects per-script results, logs a one-line summary naming the failures, and returns a Bool; the CLI exits 1 when any script failed. Running as root the scripts are dispatched to the console user (#15 applies here too); invoked as the user already — from a LaunchAgent — geteuid() != 0, so they run directly rather than through a sudo that would prompt.

#24 — the userland login wait has no timeout

New userlandLoginTimeout managed preference, read in loadPreferencesFromDomain exactly the way the other Int key (networkTimeout) is, and consumed in waitForUserSession — not one of the read-but-unused keys #18 covers, and I did not touch those.

Default: 3600 seconds. It has to be long enough that a real user who completes Setup Assistant and then walks away for lunch is still picked up on their return, and short enough that a machine nobody will log into reports the same working day instead of never. An hour clears the first comfortably. A value of 0 or less restores the old unbounded wait for anyone who wants it.

On expiry the stage is recorded as Skipped with a reason, the status plist is written and the run summary is posted, then runUserlandStage returns false. Returning false (rather than treating skipped as success) is deliberate: a successful-completion plist on a machine whose per-user provisioning never ran would tell the fleet the device is finished when it is not. The run now ends and reports honestly instead of blocking forever.

Not compiled or tested locally

There is no Swift toolchain on the machine this was written on, so nothing here was built or run — it relies entirely on CI. Changes were kept minimal and matched to surrounding style for that reason. The API changes are runAsUser (gained username), runUserScriptOnly (now returns Bool, @discardableResult), waitForUserSession (now returns Bool) and one new BootstrapMateConfig field with a default, all of whose call sites are updated in this branch.

What to check first

  1. That it compiles, and that the BootstrapMateConfig memberwise init change does not break the app target's settings view model.
  2. The launchctl asuser <uid> sudo -u <user> invocation on a real machine — that it drops to the user, that ~ and defaults writes land in the user's domain, and that a non-zero script exit propagates.
  3. The timeout path end to end on a Mac left at the login window: Skipped in the status plist, summary posted, daemon cleans itself up.

Merge note

Another branch is concurrently editing IAOrchestrator.swift (the preflight loop and the skipIf helper). These edits are confined to runUserlandStage, processUserScript and waitForUserSession, so the two should merge cleanly, but shouldSkipForArchitecture sits between two of my hunks and is worth a look if git complains.

https://claude.ai/code/session_01LQxgdJHaS4UhjJnJueqhcs

Three defects in the userland stage, all of which let a run report clean
while doing nothing useful.

Dispatch userscript items through ScriptManager.runAsUser instead of
runScript, so per-user work lands in the console user's context rather
than root's. runAsUser had never been executed and needed correcting on
the way in: launchctl asuser only moves a process into the target user's
GUI bootstrap namespace and does not drop privileges, so the script is
now handed to sudo -u as well; the single undrained pipe (which could
deadlock waitUntilExit on a chatty script) is replaced with separate
stdout/stderr pipes drained before the wait and logged via Logger.output;
donotwait launches attach no pipes at all. When no console user is logged
in the item now fails with a clear message rather than silently running
as root.

Accumulate per-script results in runUserScriptOnly and propagate them to
the --userscript exit code, with a one-line summary naming the failures.
Anything checking $? now learns whether the scripts actually worked.
Running as root the scripts are dispatched to the console user; invoked
as the user already (from a LaunchAgent) they run directly.

Bound the userland login wait with a new userlandLoginTimeout managed
preference, defaulting to 3600 seconds. On expiry the stage is recorded
as skipped, the status is written and the run summary is sent, so a Mac
nobody logs into finishes and reports instead of sitting at "Waiting for
user to log in..." forever and looking like a device that never enrolled.
A value of 0 or less restores the unbounded wait.

Fixes #15
Fixes #23
Fixes #24

Claude-Session: https://claude.ai/code/session_01LQxgdJHaS4UhjJnJueqhcs
Both branches changed IAOrchestrator. #27 replaced the private
shouldSkipForArchitecture and getCurrentArchitecture helpers with the shared
ArchitectureSkip type; this branch rewrote waitForUserSession to be bounded
by the userlandLoginTimeout preference.

Resolved by keeping the bounded waitForUserSession and dropping both
architecture helpers, which the shared type supersedes.

Claude-Session: https://claude.ai/code/session_01LQxgdJHaS4UhjJnJueqhcs
@rodchristiansen
rodchristiansen merged commit 395d12c into main Sep 5, 2026
2 checks passed
@rodchristiansen
rodchristiansen deleted the fix/userland-stage-execution branch September 5, 2026 17:15
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.

userscript items run as root, so per-user work goes to the wrong place

1 participant