-
Notifications
You must be signed in to change notification settings - Fork 0
Troubleshooting and Gotchas
Symptom first. Each entry names what you observed, what causes it, and what to look at. The last section lists behaviours that are defective or inert in the current code, so you do not spend an afternoon proving one of them to yourself.
Before anything else, know where the evidence is:
/Library/Managed Bootstrap/logs/YYYY-MM-DD/HHMMSS/bootstrap.log
/Library/Managed Bootstrap/logs/YYYY-MM-DD/HHMMSS/events.jsonl
/Library/Managed Bootstrap/logs/YYYY-MM-DD/HHMMSS/session.json
/Library/Managed Bootstrap/status.json
/Library/Preferences/com.github.bootstrapmate.plist
/tmp/bootstrapmate-postinstall.log
/var/tmp/dialog.log
One session directory per run, 30-day retention on day directories and a cap of the newest
100 session directories. bootstrap.log is the human log, events.jsonl the same records as
JSON, and session.json the run's outcome, duration and error count — read that first to see
whether the run finished. status.json carries per-phase stage, exit code and last error;
/tmp/bootstrapmate-postinstall.log has the package install and launchd load evidence; and
/var/tmp/dialog.log is the SwiftDialog command stream. Logging and
Reporting describes all three run files.
Nothing ran at all — no log directory, no session directory, no dialog.
Either the package never installed, or the daemon never loaded, or a run already completed and tore the daemon down.
Check /tmp/bootstrapmate-postinstall.log first. It records directory creation, the symlink,
the version check, Loading LaunchDaemon..., and the result of launchctl load -w. If that
file does not exist, the package did not install — look at your MDM's command status.
pkgutil --pkg-info com.github.bootstrapmate confirms the receipt.
If the postinstall ran, check whether the job is loaded:
launchctl print system/com.github.bootstrapmate
An absent /Library/LaunchDaemons/com.github.bootstrapmate.plist is normal after any
completed run — the daemon is one-shot and deletes itself. It is not evidence of failure.
Reinstall the package to run again, or invoke the CLI directly. See
Deployment.
If the logs root itself could not be created, the CLI writes
bootstrapmate: failed to create log directory <dir>: <err> to stderr and carries on with no
log file. There is no fallback location outside the logs root, so on that path stderr is all
you get. A run that reached the logs root but could not create its session directory leaves a
flat yyyy-MM-dd-HHmmss.log at the root instead, with no events.jsonl or session.json.
The run started but no packages installed.
Usually one of three things.
Exit 1 with No manifest URL configured. Use --jsonurl or configure via management profile.
means the configuration profile carrying the com.github.bootstrapmate domain never arrived.
The run waits 300 seconds for it, logging Still waiting for management config... (Ns elapsed)
every 30 seconds, then Management configuration not received within 300s. Confirm the
profile landed with defaults read /Library/Managed\ Preferences/com.github.bootstrapmate.
The daemon passes no arguments, so with no profile there is nothing to install.
Preflight script exited 0 means your preflight script decided the device was already
configured. That is a deliberate skip: status goes to Completed, the daemon is removed, and
the CLI exits 0. Check what your script decided; examples/preflight.sh writes its own
reasoning to /var/log/bootstrapmate/preflight.log.
Preflight script failed with exit code -1 means the preflight script could not be downloaded
or could not be launched at all. That fails the phase, and both later phases are skipped.
A preceding Could not set executable permission: … warning is often the reason.
An item reported success but did nothing.
Two mechanisms deliberately skip work, and both look like a no-op.
A package with both packageid and version set is skipped when pkgutil reports an
installed receipt at or above that version. If your version string does not compare the way
you expect — the comparison is numeric-dot — the item is skipped silently. Drop version to
force the install.
A payload already on disk with a matching SHA-256 is not re-downloaded:
Already have valid file: <path>. Skipping re-download. If you replaced the file on the
server without changing the hash field, the stale local copy wins.
Separately: a userscript runs as root, not as the console user (see Known issues). A
script that writes user defaults with no explicit target will report success and have changed
nothing the user can see.
The manifest failed to download.
Failed to load manifest from <url>, optionally preceded by
Failed to decode BootstrapManifest: …, then exit 1. The fetch has a 60-second timeout.
Check the URL is reachable from the device's network at Setup Assistant time, and that any
required Authorization value is set as headers in the profile. Then check the document
itself. Format detection is by URL path extension: .yaml/.yml is parsed as YAML, .json
as JSON, and anything else is tried as JSON then YAML. Query parameters do not confuse it.
The most common decode failure is key spelling. Item keys are matched to the Swift property
names exactly, so it is skipIf, followRedirects, expectedTeamID — camelCase. skip_if
does not work. file, hash, url and type are all required on every item.
Unknown item type: <type> is a typo in type, which accepts only package, rootscript
and userscript.
A hash or signature check failed.
Hash mismatch or download failed for <path>. Retrying in Ns... repeated, then
All retries failed for <path>. The default is 3 attempts, 5 seconds apart, with the hash
re-verified after each.
HTTP status codes are never inspected on downloads. A 404 error page is written to the destination path and then fails the hash check — so a "hash mismatch" on a URL you just changed usually means the URL is wrong, not the hash. Fetch it yourself and compare:
shasum -a 256 /Library/Application\ Support/BootstrapMate/<file>
The hash must be lowercase hex SHA-256.
Refusing to install <pkg>: untrusted or missing signature (…) means the package failed
pkgutil --check-signature and allowUnsigned is not set. Verify it yourself with
pkgutil --check-signature <pkg>.
Refusing to install <pkg>: Team ID mismatch — found X, expected Y is never overridable.
allowUnsigned does not bypass a Team ID requirement. Fix expectedTeamID — globally, or on
the item — or re-sign the package.
Scripts get no signature check at all; only the hash.
The run was cut short by a reboot.
There is no resume and no state machine. Every run starts from scratch with a fresh runId,
and the daemon has already been removed if the run got as far as cleanup — so after a reboot,
nothing re-runs by itself.
What makes a repeat run cheap rather than destructive is the SHA-256 check (no re-download) and the pkgutil receipt check (no reinstall). Re-lay the LaunchDaemon plist or reinstall the package to run again; see Deployment.
If you use --reboot, note it only fires when the run succeeded, five seconds after
Triggering reboot in 5 seconds. A failing run never reboots itself.
A user-context item did not run in the user's context.
userscript items are executed as root inside the daemon, not as the logged-in user. This is
a defect, listed below. Until it changes, a userland script must find the console user itself
and re-target its own work — do not rely on the item type to do it.
The run hangs with "Waiting for user to log in..." on screen.
The userland stage blocks in a two-second poll until SCDynamicStoreCopyConsoleUser returns
a user that is not loginwindow, _mbsetupuser or root, and does not begin with an
underscore. There is no timeout on this wait. On a Mac nobody logs into — a lab machine, a
headless build node — the run sits there indefinitely.
In the log, look for Waiting for user session with no matching session-detected line after
it.
The GUI shows "No connection to helper".
The helper refuses the connection. In the published source the client code requirement is a placeholder Team ID, so any genuinely signed GUI fails validation — see Known issues. The CLI is unaffected; use it directly.
Nothing appears on screen during a run.
BootstrapMate probes for SwiftDialog at /usr/local/bin/dialog once at process start. If it
is not there it logs
SwiftDialog not found at /usr/local/bin/dialog - running in headless mode and every dialog
call becomes a no-op. Installing SwiftDialog later in the same run does not help — the probe
already ran.
The dialog is also disabled by --no-dialog or --silent. It cannot be enabled or disabled
by profile.
Each of these is present in the current code. The observable consequence is stated; no fix or timeline is implied.
userscript items run as root. The item type is dispatched to the ordinary root script
runner. The code path that would run a script as the console user exists but is never called.
Consequence: userland scripts execute in the daemon's root context, so per-user work — user
defaults, Dock changes, anything writing to ~ — silently affects root or nothing at all,
while the item reports success.
The per-phase status plist is destroyed on success. After a successful run,
/Library/Preferences/com.github.bootstrapmate.plist is overwritten with only
LastRunVersion, LastUpdated and Architecture. Consequence: per-phase Stage,
ExitCode and LastError are unavailable after a success — you have status.json and the
run's session directory instead. A failed run leaves the full per-phase detail in place.
A successful run reports empty phases. The plist overwrite above happens before the
reporting POST, and the report reads its phase data from the plist. Consequence: your
reporting endpoint receives phases: {} for successful runs and full per-phase detail for
failures — the opposite of what most dashboards expect.
The privileged helper validates clients against a placeholder Team ID. The helper's code
requirement in the published source names the literal Team ID TEAMID0000, so a GUI signed
with any real identity fails validation and the XPC connection is refused. Consequence: the
GUI reports that the helper is not registered or that there is no connection to it, and cannot
start a run or write system preferences. The CLI and the LaunchDaemon-driven run are
unaffected. The requirement must be edited for your own signing identity before the GUI works.
The version string is generated at runtime, not at build time. The constant that feeds
--version, the log header's Version: line, the User-Agent and the reported version
field is formatted from the current date and time on first access. Consequence: all four
report the wall-clock time you looked, not the build you deployed. The only reliable version
is the app bundle's:
defaults read /Applications/Utilities/BootstrapMate.app/Contents/Info CFBundleShortVersionString
The helper's version-reporting method is affected the same way, so it cannot be used to detect a version mismatch.
--follow-redirects and the per-item followRedirects do nothing in either direction.
The value is threaded through the download API but no redirect-controlling delegate is ever
installed, and URLSession follows redirects by default. Consequence: redirects are always
followed, whether or not the flag is set, and setting it to false does not prevent them.
Preference keys that are read but have no effect. These can be set by profile, and they will be parsed, and nothing will change:
| Key | Consequence |
|---|---|
networkTimeout |
The network wait always uses the CLI --network-timeout value (default 120). The profile value is ignored. |
enableDialog |
Cannot turn the dialog on or off. Dialog state comes only from --no-dialog and --silent. |
dialogIcon |
The icon is never taken from the profile; the default SF Symbol is used. |
blurScreen |
The screen is never blurred. |
dialogTitle, dialogMessage
|
Only --dialog-title and --dialog-message reach the dialog. The built-in defaults are used otherwise. |
customInstallPath / installPath / iapath
|
No effect on where anything lands. Download destinations come from each manifest item's file. |
daemonIdentifier / ldidentifier
|
Cleanup always targets the compiled-in label com.github.bootstrapmate. A custom label would not be torn down. |
agentIdentifier / laidentifier
|
No LaunchAgent is ever registered. |
retainCache |
Appears in the example mobileconfig; no code reads it. Downloaded payloads are never cleaned up regardless. |
verboseMode and silentMode from a profile do not change console output. They are
honoured as configuration, but the logger is initialised from the CLI flags before those
preferences are read. Consequence: console verbosity is settable only on the command line.
File logging is unaffected.
Managed keys do not all show as managed in the GUI. The GUI's lock indicators cover only the first nine canonical keys. Consequence: dialog, reporting and signature-verification keys set by profile are still in force but are not shown as managed.
Downloads are held entirely in memory. Payloads are fetched into memory and written non-atomically, deliberately, because the system temp directory is read-only during Setup Assistant. Consequence: a large package is fully resident in RAM while it downloads.
--userscript always exits 0. In userscript-only mode, per-script failures are ignored
and the process exits 0 regardless. Consequence: the exit code cannot be used to gate anything
in that mode; read the log.
Documented retry default is inconsistent. The example YAML manifest states a retrywait
default of 10 seconds. The code's default is 5.
Documented minimum OS is inconsistent. The bundle and the README state macOS 13.0; the package manifest requires macOS 15.
No preflight item beyond the first rootscript is used. The preflight stage honours
exactly one item — the first of type rootscript. Consequence: any further preflight items
are ignored without a message naming them.