Run userland scripts as the console user and bound the login wait - #28
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
#15 —
userscriptitems run as rootprocessUserScriptnow dispatches throughScriptManager.runAsUserinstead ofrunScript. 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 thatwaitForUserSessionhad inline is nowSessionManager.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'sHOMEand root's user defaults domain, i.e. the exact buguserscriptitems run as root, so per-user work goes to the wrong place #15 describes, just with a Mach namespace attached. It now runslaunchctl asuser <uid> /usr/bin/sudo -u <username> <script>, which is why the signature gained ausernameparameter (it had zero callers, so this is not a break).Pipethat was never read, andwaitUntilExit()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 beforewaitUntilExit(), and logs throughLogger.outputthe wayrunSyncScriptdoes — so user script output actually reaches the log.donotwaitpath attached those same undrained pipes; it now attaches none.PATHenvironment handling thatrunSyncScriptalready had, for parity.#23 —
--userscriptalways exits 0runUserScriptOnlycollects per-script results, logs a one-line summary naming the failures, and returns aBool; the CLI exits1when 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 asudothat would prompt.#24 — the userland login wait has no timeout
New
userlandLoginTimeoutmanaged preference, read inloadPreferencesFromDomainexactly the way the other Int key (networkTimeout) is, and consumed inwaitForUserSession— 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
Skippedwith a reason, the status plist is written and the run summary is posted, thenrunUserlandStagereturns 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(gainedusername),runUserScriptOnly(now returnsBool,@discardableResult),waitForUserSession(now returnsBool) and one newBootstrapMateConfigfield with a default, all of whose call sites are updated in this branch.What to check first
BootstrapMateConfigmemberwise init change does not break the app target's settings view model.launchctl asuser <uid> sudo -u <user>invocation on a real machine — that it drops to the user, that~anddefaultswrites land in the user's domain, and that a non-zero script exit propagates.Skippedin the status plist, summary posted, daemon cleans itself up.Merge note
Another branch is concurrently editing
IAOrchestrator.swift(the preflight loop and theskipIfhelper). These edits are confined torunUserlandStage,processUserScriptandwaitForUserSession, so the two should merge cleanly, butshouldSkipForArchitecturesits between two of my hunks and is worth a look if git complains.https://claude.ai/code/session_01LQxgdJHaS4UhjJnJueqhcs