Prepare the Mac mini
A checklist for the host, in order. Each step says why. Steps marked unverified have not been tried on a real setup yet. The design decisions behind this page are D28 (software) and D29 (reachability) in the design.
The quick way: whr setup (provisional)
Two things do most of this page: the install of whr (below) and whr setup, which picks its part by the account that runs it: as your administrator it sets up the host and the workharbor account’s base configuration, and as workharbor, in its desktop session, it does the rest. The steps below stay the reference for what they do. whr setup checks a step first, show the exact commands of its fix, run them only after you answer y, and check again; a step whose check passes does nothing. They need a terminal, and --dry-run runs the read-only checks for real and prints every fix without running any. --only <step> and --from <step> pick steps (the shell completes their names), and the steps marked optional run only when named.
Install whr first, as the administrator, from the release archive: download
whr_<version>_darwin_arm64.tar.gzandchecksums.txt, verify them, unpack,sudo ./install.sh <tag>. No clone, noghand no Command Line Tools are needed; the commands are in Install, upgrade and release and in the release notes (step 13.1 below).make install-release VERSION=<tag>is the route from a clone withgh. The wizard accepts awhrwherever it lies (alpha policy, issue #493): in a Git working tree, in/usr/local/bin, in a directory you can write, or owned by the account that runs it. It refusesroot, and a file that is not an executable regular file after symbolic links.whr doctorreports wherewhrruns from, and who owns or can write the prefix, as onewarn: it does not change the exit code, and a missing/opt/whris part of that warn whenwhrruns from elsewhere. The guest helpers are looked for next to the running binary (<dir>/../libexec/whr), becausewhr servelooks there. The installers follow the same policy (issue #504):install.shwarns about a prefix that the account or others can write, andmake installwarns about a dirty tree or aHEADthat is not the localmain, instead of refusing. The administrator-owned prefix stays the recommended install; the stricter rule is revisited at beta. Not run on a real host unverified.As your administrator account, not
root; and notworkharborunless it is an administrator itself (D49):/opt/whr/bin/whr setup(that path is not on the administrator’sPATH; the next lines writewhr setupfor short). It runsconfig-first(issue #394, #471): the same base-configuration questions asconfig-base(repository, the numbered volume choice below, account), becauseworkspace-volumeandspotlightread the workspace roots fromconfig.json. For a shared account (--user, for examplewhr setup --user "$USER") it writes$HOME/.config/whr/config.jsonof the account that runs setup (or the path of--config/WHR_CONFIG) after a diff and youry. For a separateworkharboraccount,config-firstruns after the account exists and initializes that account’s base configuration for you (issue #471): after the confirmation,sudo -u workharbor /bin/mkdir -p -m 0700 ~/.config/whrmakes the folders as the account (an existing folder keeps its mode), a copy of the file owned byworkharbor(mode0600) is staged under the root-owned/etc/whr, andsudo -u workharbor /bin/cp -ncopies it into place and then the stage is removed; the system config/etc/whr/config.jsonis installed withsudo installonly when it is missing. All commands are shown first; the file is written from a private temporary file. Root never creates, chowns or writes insideworkharbor’s home, so a symbolic link the account plants there can only reach what the account can write anyway, andcp -nnever replaces a file that appeared meanwhile. What is covered: a link or a foreign-owned.configis also refused before the commands. What is not: that check is not atomic with the commands, andsudo -uruns the system’smkdirandcpas the account, which is what any administrator can already do unverified. It never overwrites an existingconfig.json, never reads it, runs nothing fromworkharbor’s home and installs no service; ifworkharbor’s home is not there yet, the step is reported as not reachable (noFAIL) and a secondwhr setupdoes it. What it cannot do from the administrator’s session is listed at step 3. Whether the administrator can see intoworkharbor’s home, the owner and group arguments ofinstallfor the intermediate folders, and the whole step on a real host are unverified; only the unit tests ran. A step whose precondition is missing is always reported this way, ordered before any password prompt. Afterconfig-first, it covers steps 2 to 5 and 8: the standard userworkharbor, power settings, the firewall, SSH with keys only, FileVault (guided, because enabling it prints a recovery key whr must not see), no automatic log-out (guided:autologoutreadscom.apple.autologout.AutoLogOutDelay, an unset key, an empty answer or 0 is fine; any value or message the check does not know isnot_verified), the workspace volumes (workspace-volume: a root under/Volumesmust be encrypted, which is guided because it needs your passphrase, and must honour ownership, which whr turns on withsudo diskutil enableOwnership; both readdiskutil infotext, and the key and the output format are unverified on macOS 26), Homebrew (downloaded, shown and run only after your confirmation, see step 5) and the Brewfile withbrew pin container, and the/opt/whrprefix. Every privileged command runs as its ownsudocommand, shown first; it asks for your password once (sudo -v) and never keeps it alive. A file that must be root’s is written to a private temporary file and installed withsudo install. Screen Sharing is guided and optional. Tailscale (issue #408, optional, named with--only tailscale) is installed when missing withbrew install --cask tailscale-app, after the usual confirmation (--yesskips the prompt, a dry run only prints it); it never runs as root or withsudo. Signing in, and approving a system or network extension if macOS asks, stay yours (unverified). The Brewfile names the casktailscale-app. Measured read-only on one host on 2026-10-08:brew info --cask tailscale-appprintstailscale-app (Tailscale): 1.102.4, andbrew info --cask tailscaleresolves to the same cask (an alias or rename there); older brew versions and other machines are unverified.--only tailscale-servenames the serve step, as--only tailscalenames the install step. Which account runs the serve step, and whether the administrator’stailscalereaches the serve configuration of the app whileworkharboris signed in, is not measured unverified. Thetailscale-servestep then readstailscale serve statusand offerstailscale serve --bg <port>with the port oflisten.An account that cannot
sudo(issue #507, alpha policy, issue #493: warn instead of refuse). At the start,whr setupasksdseditgroup -o checkmemberwhether the running account is in theadmingroup. If it is not, you get one warning and the run goes on: the read-only checks run, every step whose fix needssudois reported as an ACTION with its exact command to hand to an administrator, nothing is asked and nothing is run for it, and the run ends with a non-zero exit code while such steps are open, so a script notices. If the group answer cannot be read, there is no warning andsudoitself decides.rootstays refused, and so does the standardworkharboraccount (D49); an administratorworkharbor(single-account install) runs as before. Not run on a real host, and thedseditgroupanswer on macOS 26 is unverified.As
workharbor, in its desktop session (Terminal on the Mac, or over Screen Sharing), not over SSH:whr setup. The base configuration is already written by step 1; what stays here needs that session or the account’s own secrets, andwhrdoes not run it from the administrator’s account (that would be a new privilege boundary, D49). It covers steps 6, 12 and 13: the container kernel and system, the standard-user check, the private~/.config/whr, the API token (generated, never shown), an optional API key (typed without echo),whr github app create, the configuration’s GitHub App keys (shown as a diff and written after ay, keeping every key it does not know), the tool store andwhr service install.
The volume choice (issue #387). When whr setup creates config.json it lists the volumes diskutil list -plist mounts, each read with diskutil info -plist, and asks which one holds the workspaces. It only chooses a folder; it never erases, formats or adds a volume. Read on one Mac with macOS 26 verified: the keys VolumeName, MountPoint, FilesystemType, TotalSize, Internal, Writable, SystemImage and APFSContainerFree; on APFS FreeSpace is 0, so the free space shown is the container’s. Not read yet unverified: an external disk (Internal false, FreeSpace of exFAT, FAT or HFS+ volumes), locked or unmounted volumes, and Macs with many disks.
Which volumes can be chosen, and the workspace folders (issue #395). Only APFS volumes can be chosen. exFAT, FAT, NTFS and HFS+ volumes are listed with not usable and the reason (no Unix owners or modes, and macOS ignores ownership on external volumes by default, so every account could write the workspaces); choosing one is refused, --yes included. Each volume shows encryption: yes, no or unknown, read from the FileVault key of diskutil info -plist; whr warns, and does not block, on an unencrypted external APFS volume (section 3 says how to encrypt it) and when ownership is ignored on the volume (GlobalPermissionsEnabled false). Keys read with diskutil info -plist / on the developer’s Mac, macOS 26 verified: FileVault (true), GlobalPermissionsEnabled (true), FilesystemType (apfs), Internal, Locked; Encryption (true) and EncryptionThisVolumeProper (false) also exist but are not used, because what they mean for an external volume is not known. No external disk was attached, so the keys’ values on an external APFS volume (encrypted, unencrypted, locked, ownership off) are unverified; a missing or non-boolean key shows unknown and warns about nothing. The host step workspace-folders then makes each workspace root: sudo mkdir -p <root>, sudo chown -h workharbor <root>, sudo chmod -h 0700 <root> (-h so a link in the last place is never followed; checked to be accepted on macOS), one argument vector each. --dry-run describes them, and sudo -v is asked once, only after the real commands are known, so not when everything is right or you decline. Mode 0700 is the mode whr gives its own folders; the design names no other for the workspaces. It refuses a root under a symbolic link, one on another volume than its path says, and a /Volumes/<name> path whose disk is not mounted (it would land on the boot disk), and runs the commands on the resolved path, checked again after your y and, for a changed owner, again after your answer to the owner question. --yes never answers that owner question, and --unattended stops at it. A folder that exists with the right owner and mode is left alone; a different owner is reported and chown is only offered, default no. The check is ok, fail or not_verified (no configuration yet, or df/stat did not answer). The container-storage folder the issue mentions has no configuration key in this version, so only roots.workspaces is handled. With a separate administrator account, config-base would write a root outside the home folder into workharbor’s configuration, which the administrator’s run cannot read; so the administrator’s config-first (issue #471) installs /etc/whr/config.json with those roots when it initializes the account, and the roots steps read them from there. For an account that is already configured (an upgrade), nothing the administrator runs can read its file: pass --config with a copy of workharbor’s config.json that the administrator can read, and system-config writes /etc/whr/config.json from it; without either, the roots steps stay not_verified, and you can create the roots by hand with the sudo mkdir -p, sudo chown -h and sudo chmod -h 0700 commands above, for workharbor. Where one account runs both parts, or --user names the account, the lookup-order paragraph of the system config below applies (issue #510). Real-disk behaviour of these commands on an external volume is unverified.
The system config (issue #431, provisional). /etc/whr/config.json is the system config, the non-secret, machine-wide record for the administrator. It is a different file from the full user config of the whr account at $HOME/.config/whr/config.json; it has no secrets, and whr never reads it back as its own configuration. The full configuration stays in the whr account’s home, $HOME/.config/whr/config.json. The host step system-config additionally publishes a small file for the administrator at a fixed place, /etc/whr/config.json, whatever the install prefix is and wherever whr lies (alpha policy, issue #493): version, user (the whr account), workspaces (the workspace roots) and dashboard_port (the port of listen). Nothing else is copied: the system config is built from these fields by name, so no token path, key file or other configuration key can reach it, and no secret is ever in it. whr setup installs it like the sshd drop-in, as two sudo install commands shown first: the directory /etc/whr (root:wheel, 0755) and the file (root:wheel, 0644) from a private temporary file; /etc is a link to /private/etc on macOS and install follows it. The check compares the file with what the configuration would give, so a second run changes nothing; it is never a FAIL: a missing or stale system config is a warn that says writing it needs root, because it is a convenience, not a security property. On a single-account install without sudo the step therefore only warns and the system config stays unwritten. --dry-run prints the system config in the plan. Like workspace-folders, it reads config.json of the account that runs whr setup, so with a separate administrator account it is still reported as not reachable: unlike the roots steps it needs the full user config, so it must run where that is readable (issue #510 does not change this). Without the file the check is not_verified and names the same remedy as workspace-folders (whr setup --only config-base, run as the whr account). The location, the modes and ownership on a real host, the link handling of /etc, and a run without sudo are unverified; only the unit tests ran. Lookup order for the workspace roots (issue #510, provisional): workspace-folders, workspace-volume and spotlight read roots.workspaces from (1) the user config of the whr account, $HOME/.config/whr/config.json; (2) if the running account cannot read it (a separate administrator cannot read workharbor’s home), from workspaces in /etc/whr/config.json, only for the roots; (3) if neither can be read, the step is not_verified with a message that names both files and the remedy (never a FAIL, alpha policy, issue #493). A user config that does not exist yet does not fall back when the whr account runs the step: config-first is the remedy. In an administrator run (--user names another account) the default path is the administrator’s own $HOME/.config/whr/config.json, which never holds the whr account’s config, so a missing file falls back to the system config too, and the steps are reachable when it names roots. The system config stays informational and is not read back for anything else. It holds no volume name: the steps derive the volume from the roots with df. The prefix step reads no roots. The administrator still needs the system config written once by a run that can read the user config. With a separate standard workharbor account no step the administrator can run does that, so the fallback in (2) has nothing to read and stays open until the design lane decides (see the volume paragraph above); reading it on a real host is unverified; only the unit tests ran.
whr doctor runs every one of those checks, read-only: it never fixes, asks or runs sudo. It prints the host steps, the user steps and the shared checks, and on each failing or not_verified line it names the command that fixes it, for example → whr setup --only power; the same command is the fourth tab-separated column on stdout (fix with --json). The user steps describe the account whr runs as, so run as any other account (--user, default workharbor) they say not_verified (“this check describes the account whr doctor as workharbor to read them; --skip leaves out any check or step by name. This is unverified on the reference Mac mini until issue #73.
What the output looks like (issue #320). whr setup and whr doctor print for a person on stderr, with three kinds of text that always look the same. A report says what whr checked: a symbol, a status word (ok, FAIL, ? for not verified, WARN, skip) and one reason line, so the same fact is never printed as status, heading and detail. An ACTION is anything you must do or answer: a bar and the label ACTION. A command sits under it after a $, indented, ready to copy. Each setup step starts with a header line, ── Step n of N ─ title ── (-- Step n of N - title -- in plain ASCII); a legend comes first, and the end has a one-line summary and a numbered list, “What you need to do now”. Colour and non-ASCII symbols appear only on a terminal, with TERM not dumb, without NO_COLOR, --no-color and --plain; FORCE_COLOR or --color=always force colour on explicitly; the words and symbols are always there, so nothing depends on colour. The raw text of the tools whr ran (for example defaults or dscl errors) is shown only with --verbose, and what a fix command prints is indented apart under “tool output”. On a terminal, whr doctor leaves the tab-separated data lines off stdout, because the report says the same; piped, they are unchanged, and --json is byte-for-byte the same either way. Questions read [Y/n/q] (Enter is yes) for steps that can be undone, and [y/N/q] (Enter is no) for steps that cannot; q stops with the command that resumes and exit code 8, which is neither success nor failure. Ctrl-C, SIGTERM or a deadline stops the run with exit code 9 (interrupted), at once while a check or a command runs; at a [Y/n/q] question the terminal waits for Enter first, and the run stops after it; no later step starts, and the resume command names the step that was cut short (its check or its fix), so it is checked again, or the next step when none had started. This rendering has not been tried in the author’s own terminal (fsh) unverified.
Closing summary, backups and --yes (issue #381, provisional). whr setup and whr offboard host --delete end with what changed, the ACTION lines for what you still must do, and the path of the run log. Before whr replaces the configuration, it saves the old file as config.json.bak (0600, an older backup is replaced) and prints that path. whr setup --yes answers [Y/n/q] questions about steps that can be undone with yes; it still asks every [y/N/q] question and “Done with this step?”, and sudo still asks for its password. whr offboard has no --yes, and the account is removed only with --delete and the typed word. unverified
The automatic log-out step. The step reports ok when defaults read for com.apple.autologout.AutoLogOutDelay fails with exactly one of two messages, each as the whole error text: “The domain/default pair of (/Library/Preferences/.GlobalPreferences, com.apple.autologout.AutoLogOutDelay) does not exist”, or “Could not find key ‘com.apple.autologout.AutoLogOutDelay’ in domain ‘kCFPreferencesAnyApplication’”. Either may follow an optional defaults[pid:tid] timestamp header line, which defaults prints first (observed on one Mac running macOS 26.6.2), and may start with Error: and end with a full stop, as defaults prints it on the reference Mac (exit status 1, verified there). An absent key means automatic log-out is off, so the manual step needs nothing more. A value of plain digits is ok when zero and a failure otherwise; a value that is not plain digits and any other error or extra text stay not_verified. That the default is off on every macOS 26 is unverified.
Secrets are only ever generated or typed without echo, written 0600 with an exclusive create, never overwritten and never printed. What the wizard cannot do (enabling FileVault, the App’s confirm click and installation, the ruleset check, signing in to Tailscale) it says, opens the right System Settings pane or link where it can, and checks afterwards. All of the wizard’s commands and the output formats it reads are unverified until it has set up the reference Mac mini (issue #73).
Answers and the setup protocol (issue #337, provisional). whr setup (the user part only) can keep your answers and replay them. --save-answers FILE writes the run or skip you gave to each step that a file may decide; --answers FILE reads such a file, so those steps do not ask the outer “Ready to run this?” question again. Both take only a path you name: there is no default location, and ~/.config/whr/ is a good place. --save-answers checks the path before the run and never overwrites: an existing file stops the run at the start, so remove it first. The file is 0600, must be yours and not writable by others (a readable one loads with a warning), lies outside any git working tree and holds decisions only: per step the id, the sha256 of the exact command and run or skip, bound to the whr build and the account that saved it. A changed command, another build, another account or a build without a commit identity (a dirty source build neither saves nor uses a file) means the step is asked again. A password, token or key is never stored: a fix that asks for one still asks. Host steps (whr setup takes none of these flags), steps that use sudo, guided steps and irreversible ones are never answered from a file, and a sudo command that a step builds at run time is asked first even when the file says run. Questions inside a fix and “Open it now?” stay interactive. --dry-run --answers FILE shows, per step, whether the file would run or skip it or the question stays open and why. --unattended (user part, needs --answers, no terminal needed) asks nothing: it runs what the file decides, leaves every other step for you and exits with code 6 when any is left. A file in your account is an index of decisions, not proof: anything that can write it can also run whr.
Every whr setup run that is not a dry run appends to the setup protocol, ~/.local/state/whr/setup-protocol.jsonl of the account that runs it (the administrator for whr setup, workharbor for whr setup), one JSON line per event: the run start with the resume flags (home shown as ~), per step what was decided (interactive or answers, with the digest of the file) before its fix runs, and its outcome with the digest of the commands that ran and the exit status, then the run end. It is created 0600, never rewritten or rotated, and holds no command text, output, password, token or key. Each line carries the digest of the one before, so an edit in the middle shows; but whoever runs as the same user can rewrite the whole file, so it records what a run decided and did and proves nothing against that user. If a line cannot be written, the run stops before it changes anything more. whr setup history will read it later (not built yet).
The run log (issue #379). Every whr setup, whr offboard host and whr doctor run writes a text log (the default log is not written when you run as root or when HOME is not an absolute path; give --log-file then) of the commands it ran: per command the argv, the exit code and the output, and per step one line step <name>: ok|fail|unknown <reason>. The default is a new file per run, ~/.local/state/whr/logs/<command>-<UTC time>.log (for example setup-20261007T101500Z.log), in a 0700 directory and 0600 itself; --log-file <path> writes there instead (appending if it exists) and --verbose also streams the records to the terminal. A failed step shows its cause, the next command, the last lines of its output and the log path; the path is also the last line of every run. The log never holds a password: whr reads it itself and hands it to the tool on stdin, a tool’s prompt for a secret is dropped, and a secret value a tool echoes is masked. A --log-file must be a plain file with one name: a symlink, a hard link, a FIFO or a device is refused (exit 2), and as root a file owned by someone else is refused too. As root the parent directory must also be owned by root and not writable by group or others, so a --log-file in /tmp or in a directory owned by another user is refused (exit 2); use a directory root owns, such as /var/root. Following a run from a second terminal (or a tmux pane) is one line: whr setup --log-file ~/whr-setup.log in the first, tail -f ~/whr-setup.log in the second. Untried on the reference Mac mini unverified.
1. Hardware and macOS
An Apple-silicon Mac mini. Recommended: M6 with 32 GB memory and 512 GB storage, for about ten concurrent agent environments. On a budget: 16 GB, for about four, with 512 GB or with 256 GB plus an external SSD. 24 GB / 512 GB sits in between, for about six (D32, design §8).
M6 configuration US price, 1 October 2026 Environments (estimate) 16 GB / 256 GB, budget with an external SSD $899 about 4 16 GB / 512 GB, budget $1,099 about 4 24 GB / 512 GB $1,299 about 6 32 GB / 512 GB $1,499 about 10 Each memory step costs $200 and adds about four environments; prices change, so check the Apple Store for your country (Germany: from €1,049).
Spend on memory before storage: memory cannot be upgraded later, storage can be added externally. An external SSD holds repositories, workspaces and backups well; whether Apple Container’s own storage (images, volumes with the agents’ build caches) can live there is unverified (issue #54).
Use the macOS version you are running (unverified): whr’s commands and their output formats were not checked against a specific macOS version, and the Apple Container measurements were made on macOS 26.6.2.
Keep automatic security updates on, but install macOS updates that restart the Mac yourself: a restart stops every running agent until the supervisor resumes them.
2. Users
- Use your own administrator account for setup only: Homebrew,
sudo, system settings. Each step below says which account runs it. - Pick the account WorkHarbor runs as (D49; any account but
root;whr doctor’saccountcheck reports which of these you have, andwhr setupasks once whether the account isdedicatedorshared, which it writes asaccountin the configuration):- A dedicated standard user
workharbor(recommended). Agents never run next to your own home directory, keychain or SSH keys, and nothing running asworkharborcan install software or usesudo. This is the only account thatwhr doctorpasses withok. - A dedicated administrator. Setup is easier, because one account runs
whr setupfor the administrator part and thenwhr setup --from config-dirfor the account’s own steps; the risk is that an escape from the supervisor, or whoever drives it, holdssudoand writes to/Applicationsand the Homebrew prefix.whr doctorsayswarn, andfailwhen the configuration also makes whr reachable from other devices (apublic_url, the console’s SSH, or the Mac’s own Remote Login or Screen Sharing being on). After setup, the last optional stepdrop-adminremoves the account from theadmingroup (sudo dseditgroup -o edit -d <user> -t user admin, thensudo -k); it refuses when no other administrator exists, so you are never locked out; log out and back in or restart afterwards, because processes that already run keep the group until then (unverified). - Your own account on a dual-use Mac (
account: shared). No setup at all, at the price of everything above plus your keychain, SSH keys and forge logins sitting next to whr’s secrets and the supervisor’s token and socket, which every process you run can reach.whr doctorsayswarn(orfailwith remote access),whr setupasks one explicitythat names the risk when the check would fail, anddrop-adminis never offered. Threat T18 has the reasons.
- A dedicated standard user
whrneeds a desktop login session, not only SSH. Apple Container registers its services in the logged-in user’s GUI launchd domain (gui/<uid>), and its state lives in that user’s~/Library/Application Support/com.apple.container: a shell from SSH,sudo -u workharbororsu workharborcannot start or reach them. So log in asworkharboron the Mac (or over Screen Sharing), start what step 6 and step 13 start from a Terminal in that session, and leave the session logged in; use fast user switching to reach your own account. Commands that do not touch containers (files, the configuration,whr tools build) also work fromsudo -iu workharbor.- Keep that session logged in. Turn off automatic log-out (System Settings → Privacy & Security → Advanced → Log out automatically after inactivity): a log-out ends Apple Container’s services and every agent with them. A locked screen is fine; the session and its services keep running. Automatic login is not an option, because it needs FileVault off (step 3).
whr doctorreads this setting from the system preferences; a delay that a managed (MDM) profile sets is not read, and a read that fails is reported as not verified, never as passing unverified. - Whether Apple Container runs for a standard (non-administrator) user is unverified: the services are per user, but nobody has tried it on a fresh standard account yet. Check it once in step 6; if it fails, tell us in issue #38 before you make
workharboran administrator (D49 allows it).
As the administrator; you are asked for a password:
sudo sysadminctl -addUser workharbor -fullName "WorkHarbor" -password -The record name (the short name) stays lowercase workharbor; only the full name shown at the login window is set as WorkHarbor (unverified on macOS 26).
The default service account is workharbor. The host account step is named
workharbor-user; its title, account-creation command and login guidance use
the account selected with --user. Login guidance and doctor repair commands
retain --user for a nondefault account. The old whr-user step name remains an
input alias for whr setup --only, --from and whr doctor --skip; output and
completion use workharbor-user. Existing installations can keep their
current nonroot account, including whr, by passing --user <account> to
whr setup and whr doctor (for example, whr doctor --user whr, where whr is the legacy account name from before D49). When the workharbor-user check runs for the default account, finds no workharbor but does find a whr account, it says so and names whr setup --user whr and whr doctor --user whr as the next step instead of offering sysadminctl -addUser for a second account (an account you name with --user is never redirected); it only counts whr as missing on a real “not found” answer from dscl, and any other answer stays not verified (unverified on macOS 26). The selected
account uses its actual host home directory. This change does not rename an
account, move a home directory, change ownership or UID/GID, or migrate volumes,
credentials or keys. Plan any existing-installation migration separately.
The host step login-picture gives the account the WorkHarbor logo as its login picture (whr setup --only login-picture). It installs a bundled 256x256 PNG, embedded in the binary and never downloaded, as /Library/User Pictures/Workharbor/logo.png (owner root, so the account cannot replace it) and sets the account’s Picture attribute to that path with sudo dscl . -create /Users/workharbor Picture <path>; it does nothing when the attribute and the file already match, and the step is skipped (never creates a record) until the account exists. Verified on a Mac, read-only: Picture holds a file path and JPEGPhoto is empty on an account with the default picture. unverified: that dscl -create Picture is accepted without a GUI and that the login window then shows the image (one real-host run is needed); the JPEGPhoto binary attribute is not used.
To delete the account again, see Remove the WorkHarbor account.
3. FileVault and restarts
Keep FileVault on. That rules out automatic login, which is the right trade-off for a machine that holds agent logins.
- An external SSD is not covered by FileVault. Encrypt it on its own, as the administrator: erase it as APFS (Encrypted) in Disk Utility, or encrypt an existing APFS volume with
diskutil apfs encryptVolume /Volumes/<ssd> -user disk(it asks for a passphrase; keep it in your password manager). Whenworkharborfirst unlocks it, tick Remember this password in my keychain, so it mounts whenworkharborlogs in; before that login it stays locked, which matches FileVault’s behaviour after a restart. macOS ignores file ownership on external volumes by default, which would let every account write to the workspaces: turn it on withsudo diskutil enableOwnership /Volumes/<ssd>and check Get Info → Ignore ownership on this volume is off. The keychain mount at login and the ownership default are unverified on macOS 26. - Planned restarts:
sudo fdesetup authrestartrestarts once without the unlock prompt. - After a power cut: the Mac stops at the FileVault unlock screen. Nobody is logged in yet, so the Tailscale app (step 7) is not running and Screen Sharing is not available: unlock it with a keyboard and display, then log in as
workharborso its services start. Whether macOS 26 accepts a remote unlock over SSH at that screen is unverified. A small UPS makes this rare. - Tailscale and a reboot: the cask app is a login item, so it starts only after someone logs in. With FileVault on and no automatic login, the host is unreachable through Tailscale after a reboot until someone logs in unverified. The Homebrew formula
tailscalecan runtailscaledas a system daemon that starts at boot instead; that is an alternative (it replaces the app) and whr does not install or check it unverified. - WorkHarbor runs as a LaunchAgent of
workharbor, never a LaunchDaemon (issue #38): Apple Container’s services are inworkharbor’s GUI launchd domain, so a daemon cannot reach them.whr service install(step 13) loads it, and it starts again at every login ofworkharbor. So after a power cut, one login asworkharborbrings the supervisor, Apple Container and the agents back. Loading and restarting it was measured on the developer’s own account (verified, 2 October 2026, macOS withcontainer1.5.0); that it also works for the standard userworkharborand after a real reboot is unverified (issue #73).
4. Power
A sleeping Mac pauses every agent.
sudo pmset -a sleep 0 disksleep 0 autorestart 1 womp 1 powernap 0autorestart 1 starts the Mac after a power cut; womp 1 lets it wake on a Wake-on-LAN magic packet; powernap 0 turns Power Nap off, which on a headless Mac only wakes it for background work. That Power Nap is behind spikes on a headless Mac is unverified; the power step of whr setup and whr doctor checks and sets all five values in one sudo pmset -a command.
A headless Mac: Apple’s background analysis
Werner’s headless Mac was measured with mediaanalysisd (Apple’s media analysis) at 222 % CPU (21:35 of CPU time, load about 4) while it ran agent environments (Werner’s own observation, with no committed measurement: unverified, as are its cause and the remedies below). Two steps look at it, and neither kills, deletes or disables anything of Apple’s:
media-analysisreadsmediaanalysisd’s CPU use and CPU time (ps) and the size of its cache under theworkharboruser’s~/Library/Caches(du). It fails at 50 % of a core or more, a threshold picked from that one measurement, and passes when the process is not running. Its fix is guided: turn off Apple Intelligence and Siri (System Settings, the Siri pane the step opens), and keep Photos’ analysis from running. Whether that stops the process on macOS 26 is unverified.spotlightreadsmdutil -sfor each workspace root that is on a volume of its own and fails while indexing is on; its fix, shown first and run after youry, issudo mdutil -i off <volume>. A root on the internal disk cannot be handled that way, and Spotlight’s privacy list cannot be read, so the step stays not verified and you add the root yourself under System Settings, Spotlight, Search Privacy. That this stops the background work is unverified.
Two stopgaps are manual only, because the OS undoes them and they are not idempotent: deleting ~/Library/Caches/com.apple.mediaanalysisd, and killall mediaanalysisd (launchd starts it again). whr never runs either. Their effect is unverified until the verifier has measured mediaanalysisd’s CPU and the load before and after the steps (issue #150).
5. Software (Homebrew)
As the administrator. Homebrew’s prefix /opt/homebrew belongs to the account that installed it, so whr cannot install, upgrade or pin anything; it only runs what is installed. whr setup --only homebrew (as the administrator, spelled /opt/whr/bin/whr setup --only homebrew until /opt/whr/bin is on its PATH) installs Homebrew when /opt/homebrew/bin/brew is missing (issue #505): it downloads Homebrew’s install.sh from https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh to a private file (mode 0600 in a private temporary directory, removed afterwards), prints the file’s path, the URL and the script’s SHA-256, then asks you to confirm before it runs the script as you (/usr/bin/env NONINTERACTIVE=1 /bin/bash <file>, after one sudo -v; the script’s own sudo calls use that ticket and are not shown one by one). The hash is informational, not a pin: the script is trusted by TLS alone, so read it first (less <file>). The confirmation is always asked: --yes does not answer it and --unattended leaves the step for you. A failed download is reported and nothing runs. The step works for a single account that is its own administrator (the script itself refuses root). The Command Line Tools stay guided and are not needed to install whr from the release archive: install them yourself (xcode-select --install, for git and make, for example a clone and make install-release; it opens a dialog on the Mac’s screen). Then brew-packages installs the host packages from a Brewfile. On a Mac with no Command Line Tools, Homebrew or gh, whether the script finishes headless, which Command Line Tools prompt appears, and who owns /opt/homebrew afterwards are unverified; so is the real download. Keep Homebrew from upgrading anything you did not ask for: HOMEBREW_NO_AUTO_UPDATE stops the automatic index refresh, HOMEBREW_NO_INSTALL_UPGRADE stops brew install from upgrading what is installed, and brew pin (step 6) holds a version through brew upgrade.
echo 'export HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_UPGRADE=1' >> ~/.zprofile# Brewfile: the whole host software for workharbor (D28)
brew "container" # Apple Container; workharbor was measured with 1.5.0
brew "git"
brew "gh" # not needed to install or run whr; the brew-packages step still expects it
cask "tailscale-app" # the Tailscale app of step 7
# tap "wstein/tap"; brew "whr" # from v0.1.0 (issue #62); until then step 13brew bundle --file=Brewfile
brew pin containerbrew pin container holds the version you tested through brew upgrade. The Brewfile above is the one the brew-packages step of whr setup embeds (DefaultBrewfile), and it always lists tailscale-app: the step installs the Tailscale app even if you pick option B or C in step 7, although Tailscale itself is optional. Remove the line from your own Brewfile if you do not want the app (the embedded default is not changed by this page). gh is not needed to install or run whr (the release archive route uses only what stock macOS ships), but the brew-packages check fails while gh is not installed, so the Brewfile keeps it.
whr finds these through its PATH: as workharbor, add eval "$(/opt/homebrew/bin/brew shellenv)" to ~/.zprofile.
Do not install Claude Code, Codex CLI or other agent CLIs on the host for WorkHarbor. WorkHarbor fetches them into its own verified tool store and mounts them into each environment read-only (D19).
6. Apple Container
As workharbor, in a Terminal of its desktop session (step 2), start the container system, install the Linux kernel the containers boot once, and check it:
container system start --disable-kernel-install
container system kernel set --recommended
container system status
launchctl print gui/$(id -u) | grep com.apple.container
container list --allThe launchctl line shows that the services run in this session; container list --all must answer without an error.
--disable-kernel-install skips the interactive kernel prompt, which is why the kernel is installed in its own step; container system kernel set needs the running system (it fails with XPC connection error before the start), and without a kernel no container starts. whr setup runs the steps in this order and says at its end when no kernel is installed. The last two lines are the standard-user check of step 2: if container system start or container list fails with a permission or bootstrap error, note the message in issue #38. After a restart the system does not start by itself; the WorkHarbor LaunchAgent (step 13) runs container system start --disable-kernel-install in workharbor’s session before it starts the supervisor, which then resumes the agents.
Over SSH
Over SSH, use the whr CLI; never run container directly. whr ls, whr show <task> and whr logs are clients of the API and need no session check. whr doctor runs its checks locally, apart from one probe of the API (the supervisor-token check). whr service status and whr service install refuse over SSH (they check that the shell is in the graphical login session): run them in the desktop session (Screen Sharing). container commands and whr setup need workharbor’s desktop session, because Apple Container’s services live in that user’s GUI launchd domain and an SSH login is in another one, where container system status fails with XPC connection error: Connection invalid even while the services run unverified (upstream apple/container#205; to be measured in #155). Over SSH, whr doctor therefore reports the three checks that call container (the kernel, the container system and the standard-user check) as not_verified; run them in the desktop session (Screen Sharing).
7. Reach it from your phone
The web UI of WorkHarbor listens on loopback by default ("listen": "127.0.0.1:8787"), and the JSON API is not on the network at all: it is served on a unix socket, api.sock in the state directory (~/.local/state/whr, a 0700 directory), which only workharbor’s own account can open, so the whr commands work in workharbor’s session and a leaked API token is no use from the phone (D29). Sign-in to the web UI is by passkey once one is enrolled. A guest container reaches anything on the Mac’s LAN address or on all interfaces, even from an isolated network, but not loopback (verified in the host-reachability spike, issue #69). Your phone reaches WorkHarbor through a forwarder, or the explicitly configured WireGuard interface in option C. Pick one option.
Option A: Tailscale (default)
The quickest option. The Mac gets its own VPN interface and a stable name, and Tailscale can issue HTTPS certificates for that name, which the phone app (PWA) needs.
- Install the Tailscale app on the Mac (step 5, as the administrator) and on the phone. Sign in on the Mac in
workharbor’s session, the one that stays logged in; whether the app keeps the Mac on the VPN while no one or another user is logged in is unverified. - As
workharbor, forward the Mac’s Tailscale name to WorkHarbor on loopback with HTTPS:tailscale serve --bg <port>(check the exact syntax withtailscale serve --help). WorkHarbor itself stays on loopback. - Optional: limit the phone to that port with a Tailscale access rule.
- Whether a guest container can reach the Mac’s Tailscale address is unverified (issue #69); web sign-in guards the web UI either way. The JSON API is never forwarded.
Tailscale’s coordination server is a third party. Headscale replaces it with a self-hosted one.
Option B: WireGuard on your router (FRITZ!Box)
No VPN software on the Mac and no third party. A FRITZ!Box offers WireGuard from FRITZ!OS 7.50 (unverified: check your version).
- On the FRITZ!Box: Internet → Permit Access → VPN (WireGuard), add a connection for your phone, and import it into the WireGuard app with the QR code.
- The phone then reaches the Mac at its LAN address. There is no VPN interface on the Mac, so the address alone does not tell your phone from any other device on the LAN.
- So WorkHarbor stays on loopback, and a small HTTPS proxy on the Mac’s LAN address forwards to it. Guest containers can reach that proxy too, so a
pfpacket-filter rule admits only the addresses the FRITZ!Box gives VPN clients to its port, and web sign-in guards the web UI. The JSON API stays on its private unix socket. The macOS firewall in System Settings cannot do this: it filters by app, not by address. Thepfrule and how the FRITZ!Box numbers VPN clients are unverified (issue #69): check the address your phone gets. - The phone app needs HTTPS: use your own certificate authority (installed on the phone) or a certificate for a domain you own.
A line without a public IPv4 address (DS-Lite, carrier-grade NAT) may not accept inbound WireGuard (unverified); Tailscale works there.
Option C: WireGuard on the Mac
A VPN interface like Tailscale’s, without a third party, but you forward a UDP port on the router to the Mac and manage keys yourself. Choose it only if you need both properties.
To bind the web UI directly to that interface, set "listen": "10.44.0.1:8787" in config.json, substituting the Mac’s own WireGuard IP and chosen port (IPv6 example: "[fd42::1]:8787"), then restart whr serve. This existing setting selects one literal unicast IP; it does not add a second loopback listener. Empty addresses, wildcard addresses (0.0.0.0, ::), host names, multicast, broadcast, link-local and scoped-zone addresses are refused. Keep the loopback setting for Tailscale’s tailscale serve option above. whr doctor reports the configured address and refuses a wildcard.
The operator must verify that the chosen IP belongs to the WireGuard interface and restrict host firewall access appropriately; an accepted address proves neither VPN isolation nor protection from guest containers. Before switching away from loopback, set the effective HTTPS public_url (or board.public_url) and enrol a passkey through the host CLI. Without both, the non-loopback listener refuses to start; restart on loopback for enrolment or recovery. The remote listener never accepts the API token for web sign-in, even after its last passkey is revoked, and its session cookies always use Secure and the __Host- prefix. For remote passkeys and the phone app, provide trusted HTTPS at the configured public_url, with TLS termination reaching this web listener; WireGuard does not replace HTTPS or D45’s fresh assertion for sensitive answers. The JSON API stays on its private unix socket, and no /v1 route is served on the TCP listener, even with an API token. A direct non-loopback listener counts as remote access for the account check (D49): use a dedicated standard account. GitHub App creation keeps its separate loopback-only setup listener; if listen already names the VPN IP, use whr github app create --listen 127.0.0.1:8787 --local in a browser on this Mac while whr serve is stopped. Binding and reachability on the reference host’s actual WireGuard setup are unverified (issue #539); the address-validation, listener and route-boundary tests do not measure that setup.
8. Firewall and SSH
- macOS firewall on, in stealth mode: System Settings → Network → Firewall.
- Every guest container can reach the Mac’s services that listen on all interfaces, even from an isolated network (issue #69). Turn off what you do not need in System Settings → General → Sharing (File Sharing, Screen Sharing, AirPlay Receiver, Media Sharing).
- Remote Login (SSH) only for your administrator account (Allow access for), with keys only: set
PasswordAuthentication noandKbdInteractiveAuthentication noin a file under/etc/ssh/sshd_config.d/. Otherwise an agent could guess passwords. The short-lived SSH certificates of WorkHarbor are for the console only; they do not replace this, and SSH to the host stays key-only. - A
pfrule that blocks the container subnets (192.168.64.0/24for the default network, and the--internalnetworks’) from the Mac’s own addresses closes this for every service; it is unverified and comes with issue #69. - Screen Sharing over the VPN works once a user is logged in; it does not reach the FileVault unlock screen (step 3). It is how you reach
workharbor’s desktop session from afar (step 2); if you keep it on, allow it only for your administrator andworkharbor, and it stays reachable from guests until thepfrule above exists. - Headless is fine. After setup the Mac runs without a display, keyboard or mouse, and you reach it over SSH and Screen Sharing. Without a display attached, Screen Sharing may offer only a low resolution; an HDMI dummy plug fixes that (unverified on macOS 26). Keep a display and keyboard at hand for the FileVault unlock after a power cut (step 3).
- From SSH as the administrator,
sudo -iu workharborgives aworkharborshell for files and builds, but not forcontainerorwhr serve(step 2). - Only the administrator logs in to the host, and only for administration (macOS and
whrupgrades, recovery). Your daily shell work, in the workspaces and across them, goes through the console instead:whr consoleopens a shell in a VM with git, zsh, fish,ripgrep,tmux,makeand an editor, every workspace mounted read-only, behind the same egress allowlist as the agents (the package registries andgithub.comfor reads, never the model API), with none of your credentials or the agents’.whr console <workspace>starts in that workspace;whr console <workspace> --writemounts that one workspace read-write, which you want when you edit, and needs the console closed first if it was opened with other writable workspaces (whr console --close; its home, with your dotfiles and history, is kept).gitin the console ignores the hooks, filters, aliases and other commands that a workspace’s configuration sets, because agents write that configuration. The shell comes throughwhrand the token on the private socket of the supervisor; it is not reachable from the phone or through the web UI. The first console builds its image, which takes a while. - SSH into the console (issue #32).
whr sshopens an SSH session in the console, never on the host, andwhr ssh --configprints aHost whr-consoleblock for~/.ssh/configso that VS Code Remote-SSH and JetBrains can use it. Turn it on once: runwhr setup(its optionalssh-castep makes the authority key, a0600file in the configuration directory) and add"console": {"ssh_ca_key_file": "<that path>"}to the configuration. Your key stays on the machine you runwhron; each connection gets a certificate for it that lasts minutes (a certificate that still has four minutes left is reused, so a connection does not sign twice), for the userworkharboronly, and the console’s host key is pinned fromwhr, so nothing is trusted on first sight and nothing needs renewing by hand. The console listens on no port: SSH is carried over the same API aswhr console, so it is reachable exactly as far as that is, and thesshclient must be installed where you runwhr ssh. Port forwarding, which the editors’ remote modes need, is off unless you pass--forward, and then reaches only the console’s own loopback. Losing the authority key means anyone holding it can sign a certificate, so it is a secret like the API token: back it up the same way, and if it leaks, delete the file, run the step again and restartwhr serve.
9. Backups
- Follow the one procedure in Back up, upgrade and restore: it names the configuration directory (
~/.config/whr), the state directory (the database with its-waland-shmfiles), the secret files (encrypted backup only) and the workspace folders, and says to stopwhr servefirst. - Exclude the container volumes and images from Time Machine and every other backup. The agent-home volumes hold the agents’ sessions and subscription logins, which no
whrprocedure copies (D40); a restore means signing in again. Images are rebuilt, and volumes grow sparsely (issue #54).
10. Phone notifications (optional)
WorkHarbor does not create the topic: you do, as the workharbor user, and tell the configuration where it is (design §9.4). On ntfy.sh the topic is the only thing that keeps your channel private, so make it long and random:
umask 077
mkdir -p ~/.config/whr && chmod 700 ~/.config/whr
openssl rand -hex 16 > ~/.config/whr/ntfy.topicThe topic is 32 characters long.
Each secret file must be 0600, owned by you, absolute, a regular file with one link, not empty, and outside every workspace and root WorkHarbor trusts. An optional token file (for a server that needs an access token) follows the same rules. Add the block to the configuration (step 13):
"ntfy": {
"server": "https://ntfy.sh",
"topic_file": "/Users/workharbor/.config/whr/ntfy.topic",
"token_file": "/Users/workharbor/.config/whr/ntfy.token"
}serveris optional and defaults tohttps://ntfy.sh. It must be anhttpsURL, orhttpon127.0.0.1orlocalhostfor a server on the same host, and it carries no username, password, query or fragment (keep the token in the token file).topic_fileis required. The topic in it needs at least 20 characters and no/,?,#or whitespace (surrounding whitespace is trimmed).token_fileis optional.- A notification links to the task, so
public_url(orboard.public_url) must be set to anhttpsURL, as in step 13.
whr serve refuses to start when any of this is wrong, and says which key. Then install the ntfy app and subscribe to that topic on your server. A notification carries only a task ID, an event kind and a link; the link needs the VPN from step 7. Pushes are limited per task: an identical message (same task, kind, decision and run) sent within an hour is dropped, and a task sends at most 5 pushes an hour. What is dropped is still in the inbox. That the configuration is validated and the throttle works is tested; delivery to a real ntfy server and the app on a phone have not been measured (unverified).
11. The GitHub App
WorkHarbor talks to GitHub as an App of your own, never with your personal token (D15, D31): its tokens last about an hour, cover one repository and only the permissions below, and the App cannot merge, tag or release. On github.com, as the repository’s owner:
The quick way: whr github app create (provisional name). It creates the App from a manifest, so there is no form to fill in and no .pem to download. You need the listen and api_token_file lines of the configuration (step 13) and the HTTPS name your forwarder gives WorkHarbor (step 7), which belongs in the configuration as public_url: whr setup asks for it (the public-url step) and writes it after a y. Type the host name only, for example whr.example.ts.net: https:// is added when it is missing, the host is lower-cased and a trailing slash is dropped. An explicit http://, a path, a user name, a query, a space or a port outside 1 to 65535 is refused, because GitHub sends the browser to this name; whr does not guess it, since reading a Tailscale name would need the network. whr serve must not be running, because the supervisor needs the App this creates.
whr github app create--public-url <name> overrides public_url for one run. To try the flow on the Mac itself, without any forwarder, run whr github app create --local and open the link, http://127.0.0.1:8787/..., in a browser on this Mac; GitHub redirecting a browser back to a loopback address is unverified on a real host.
If the link times out. The command listens on loopback only (listen, default 127.0.0.1:8787), and the forwarder of step 7 must map your HTTPS name to that port (tailscale serve). The macOS application firewall (/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate, which needs no sudo) does not filter a connection to loopback, so it cannot be why http://127.0.0.1:8787 fails on this Mac; a link that times out points at the name or the forwarder: a name the device cannot resolve (MagicDNS or .local names need the VPN or the same network), or a forwarder that is not serving. whr doctor reports public-url: it checks the configuration and the listener and reads the firewall state, and says plainly that it does not check the forwarder (unverified on a real host).
- It prints a link and waits on the configuration’s
listenaddress. Open the link on any device that reaches that name (the Mac or the phone, over the VPN of step 7) and press Continue to GitHub. GitHub shows the App’s name and asks you to confirm: Create GitHub App. For an organization’s App, add--org <name>; you must own the organization. - GitHub sends the browser back to WorkHarbor with a one-time code. Only a link this command just made is accepted, once, and for ten minutes (
--ttl); anything else is refused.whrexchanges the code for the App and writes the private key to~/.config/whr/github-app-<id>.pem(mode0600, never overwriting a file). The key, the client secret and the webhook secret are never printed or logged; the two secrets are dropped. - The command prints the two lines to add to the configuration,
"github": {"app_id": <id>, "key_file": "<path>"}: it does not edit the file. It also prints the link to install the App. Install it as in step 3 below, and check the ruleset as in step 4. The App is private, has no active webhook and only the permissions below. whr doctorthen checks, from GitHub, that the App is installed on every configured repository with exactly those permissions.
Whether GitHub accepts the manifest as workharbor sends it (its inactive webhook, a private App) is unverified until the first run against github.com. If it does not work, or you prefer to click, create the App by hand with the steps below:
Create the App. Your account’s Settings → Developer settings → GitHub Apps → New GitHub App.
- Name: for example
workharbor-<your-name>(it must be unique on GitHub). Homepage URL: the repository’s URL. - Webhook: untick Active.
whrasks GitHub when it needs something; webhooks would need a public address, which WorkHarbor does not have (D29). - Repository permissions: Contents Read and write, Issues Read and write, Pull requests Read and write; Metadata stays Read-only. Nothing else: without the Workflows permission the App cannot change
.github/workflows, and without Administration it cannot change rulesets. - Where can this GitHub App be installed: Only on this account.
- Create GitHub App, then note the App ID on its General page.
- Name: for example
Generate a private key on the same page (Private keys → Generate a private key). The browser downloads a
.pemfile: that file is the secret, see step 12.Install it (Install App) on your account with Only select repositories and pick the repositories WorkHarbor works on.
whrfinds the installation by itself. One App serves all your repositories: to add one later, add it to the installation’s repository list and torepositoriesin the configuration; each tokenwhrmints still covers one repository only. Repositories of an organization need the App installed there too, which takes Any account in step 1, or a second App owned by the organization.Check the ruleset of
main(the repository’s Settings → Rules → Rulesets): changes need a pull request with a human review, force pushes are blocked, and the App is not in the bypass list (D15). The supervisor pushes onlyagent/*branches and opens pull requests; merging stays with you.Which ruleset each workflow needs (D47; set per repository in the configuration as
"workflow", defaultintegration;whr doctorchecks it asforge-workflow):Workflow Where approved commits go The ruleset to set up prototypethe supervisor fast-forwards the integration branch ( integration_branch, required, and never the default branch) to the approved commit; no pull requesttwo rulesets. On the integration branch: force pushes blocked, and only the App and you may write to it (unverified: whether a ruleset can name the App as the only other writer). On the default branch: a ruleset that targets ~DEFAULT_BRANCH(not the branch’s name, and with no excluded refs, so it follows a change of default) and restricts updates or requires a pull request, with the App not a bypass actor; you promote through a bypass of your own or a pull request (whr doctorreports a missing rule or an App bypass as a failure, and a ruleset that excludes refs, or whose bypass list or targets GitHub does not show this App, as “not verified”: unverified whether it shows them)integration(default)a pull request into integration_branch(defaultdevelop) that you merge and promote tomainthat branch protected, a pull request required publisheda pull request into the default branch; the agent asks for every tool ( manual)the default branch requires a pull request with a review and status checks and signed commits, and has no bypass actor Whether the App’s token can read these rulesets (and a ruleset’s bypass list) is unverified: where it cannot,
whr doctorsays “not verified”, never “ok”. Changing a repository’s workflow in the configuration is a policy change:whr serverefuses to start until you confirm it with--accept-workflow-change, the change is recorded, and tasks already started keep the workflow they started under.
12. Secrets
Every secret is a file with mode 0600, owned by the workharbor user, outside the workspace roots and the tool store, and the configuration names only its path. Never paste a secret into a chat, an agent session, a command line, a script or an issue: an agent never needs the value, only the path. whr serve refuses a secret file that is not 0600, belongs to another user, is a link or has a second hard link, or lies inside a workspace root or the tool store.
As the workharbor user:
mkdir -p ~/.config/whr && chmod 700 ~/.config/whr
umask 077 && openssl rand -base64 32 > ~/.config/whr/api.tokenThe API token is what the local whr CLI uses: generated, never typed. The GitHub App’s private key comes from step 11: whr github app create has already written it to ~/.config/whr/github-app-<id>.pem, and only a key you downloaded needs moving. Set app to the name of the downloaded file’s App (the file is <app-name>.<date>.private-key.pem in ~/Downloads):
app=my-app-name
mv ~/Downloads/"$app".*.private-key.pem ~/.config/whr/github-app.pem
chmod 600 ~/.config/whr/github-app.pemThe agent’s login:
A subscription (Claude Pro or Max) is the default and has no file: you sign in inside the environment with Claude Code’s own login, and
whrnever sees it (design D40, agent vendor terms). How that works from the console is being measured (issue #82); the first sign-in needs a Terminal inworkharbor’s desktop session (step 2).An API key is optional, and it must be an API key. Create one in the vendor’s console, not with
claude setup-tokenand not a login token:whr setup,whr doctorand the configuration refuse a variable named like a subscription credential (CLAUDE_CODE_OAUTH_TOKEN,ANTHROPIC_AUTH_TOKEN, any name containingOAUTH,SESSION,ACCESS_TOKEN,REFRESH_TOKEN,AUTH_TOKEN,SETUP_TOKENorLOGIN_TOKEN), an empty or blank value, and a value shaped like a credentials file (D40, issue #348). Detecting a setup-token by its value prefix is unverified: the prefix has not been measured, and the measured OAuth tokens also start withsk-ant-(API key prefix not recorded; unverified), so no prefix is refused; only names, empty values and the credentials-file shape are. Codex and Antigravity are covered by name only. Type the key so that it is neither echoed nor kept in the shell history nor visible in the process list (read -sdoes not echo, andprintfis a shell builtin):umask 077; read -rs KEY; printf 'ANTHROPIC_API_KEY=%s\n' "$KEY" > ~/.config/whr/agent.env; unset KEY
Keys for your own experiments (a spike, a test) live in a password manager such as pass or 1Password, and a script reads them from a 0600 env file named by an environment variable, never inline: the repository’s hooks refuse a literal key. If a secret leaks anyway, follow the runbook in SECURITY.md.
13. Install and configure whr (dogfood)
Until v0.1.0 the host runs a dogfood pre-release: a signed prerelease tag v0.1.0-alpha.N on main that CI built and attested and that is published as a pre-release (design D24, D34). Nothing is built on the host. Only pre-releases exist so far. From v0.1.0 on, brew install wstein/tap/whr replaces the install in step 1.
Install, as the administrator, from the release archive: follow Install from the release archive (the release notes of the tag carry the same commands). You download
whr_<version>_darwin_arm64.tar.gzandchecksums.txt, check the archive against the checksums, unpack it and runsudo ./install.sh <tag>. It needs only what stock macOS ships (no clone, nogh, no Command Line Tools) and never runs inworkharbor’s account. The prefix/opt/whris then root’s, so nothing running asworkharbor, an agent that escaped included, can replace the supervisor. Ifworkharboritself is the administrator that runs this, the prefix belongs torootas well, andwhr doctorwarns, no longer fails, about a prefix,binorbin/whrowned by the account the supervisor runs as (D49, alpha policy #493; revisited at beta). That single-account install (the administrator account is also the whr account, no extra macOS user) is supported and not recommended unverified end to end.The second route needs a clone,
git,make(Command Line Tools) and an administrator-owned prefix; withghsigned in it also checks the attestation, and it is how you verify a draft release. Settagto the release tag in the same shell first, then:sudo install -d -o "$(id -un)" -g admin -m 755 /opt/whr git clone https://github.com/wstein/workharbor.git cd workharbor make install-release VERSION="$tag"The prefix
/opt/whris the default of both routes; the tag is a release afterv0.1.0-alpha.4. Both installwhrin/opt/whr/binand the guest binarieswhr-shimandwhr-proxyin/opt/whr/libexec/whr, and install nothing if a check fails.gh release downloadfinds a draft by its tag (verified with the first draft,v0.1.0-alpha.1, on an Apple-silicon Mac; issue #103). Upgrade the same way with the next tag. Forv0.1.0-alpha.4and older, use that tag’s own script (git show $tag:scripts/install-release.sh). The administrator reaches the installedwhrby its full path,/opt/whr/bin/whr version(the directory is not on itsPATH); the next command is/opt/whr/bin/whr setup. Then, asworkharbor, addexport PATH=/opt/whr/bin:$PATHto~/.zprofile:whr versionshows the tag, andwhr completion zsh(orbash,fish) prints the shell completion.
The remaining steps run as the workharbor user: steps 2 and 3 from any workharbor shell, step 4 from a Terminal of its desktop session (step 2).
Fill the tool store. With
store=/Users/workharbor/tools(theroots.tool_storeof step 3),whr tools build -store "$store" -shim /opt/whr/libexec/whr/whr-shim-linux-arm64downloads Claude Code at the version pinned in the repository, checks it against the pin and the vendor’s manifest, stores it read-only and adds the launcher. The build holds Claude Code only;-tools claude,antigravityadds Antigravity, which is checked against its pin alone (archive sha512 and extracted sha256). A checksum mismatch stops it with nothing stored. On a host that already has aclaude-<version>profile,-tools claude,antigravitymakes a second glibc profile,claude-<version>-antigravity-<version>, andwhr servethen refuses to start (“set tool_profile”) untiltool_profilenames one of them or you remove the old profile.Write the configuration file,
~/.config/whr/config.json. Secrets are paths (step 12), never values:{ "listen": "127.0.0.1:8787", "public_url": "https://<your-forwarded-name>", "repositories": [{ "name": "<owner>/<repository>", "integration_branch": "develop" }], "roots": { "workspaces": ["/Volumes/<ssd>/workspaces"], "tool_store": "/Users/workharbor/tools" }, "github": { "app_id": 123456, "key_file": "/Users/workharbor/.config/whr/github-app.pem" }, "api_token_file": "/Users/workharbor/.config/whr/api.token", "agent_allowed_tools": ["Read", "Edit", "Write", "Bash(git status:*)", "Bash(make check:*)"] }More than one API caller (
api_clients, optional).api_token_filestays the clientdefault. To tell further callers apart in the audit, add"api_clients": [{"name": "ci-nightly", "token_file": "/Users/workharbor/.config/whr/ci-nightly.token"}]: one file per client, one token per file, each read like every secret file (absolute path, no link, mode0600, owned by this account, at most 64 KiB). A name is lowercase letters, digits and-, starts with a letter, is at most 32 characters, is notdefaultand is not twelve hex digits. A name used twice, or a token shared by two clients, is refused. An action by a named client carries the actorapi:<name>;defaultkeeps its derived id.whr doctorchecks all of it asapi-clientsand names the client of a failing file. The CLI keeps usingdefault.integration_branchis where approved commits go (the default workflowintegrationusesdevelopwhen it is not set, so the line only makes that visible); the branch must exist in the repository. A workspace rebases onto the same branch:whr ws addtakes it from the configuration (forpublished, the default branch GitHub reports now) and refuses a--branchthat differs, naming both, so a first workspace and its tasks meet on one branch.To mirror task state on a GitHub project board (provisional, issue #70, unverified until it runs with the real App), add
"board": {"owner": "<organization>", "organization": true, "number": <project number>, "public_url": "https://<your-forwarded-name>"}: the project needs a single-selectStatusfield with the optionsNeeds you,In progressandDone(whr doctorchecks these three; a cancelled task writesTodo, which the board may have as a column but the check does not require), and may have aSessionfield and a text fieldTaskthat holds the link. The App then needs the organization’s Projects permission (whr github app create --boardasks for it;whr doctorchecks it). To sign in to the web UI and answer reviews from the phone with a passkey (provisional, issue #101, unverified until it runs on a real phone), set"public_url": "https://<your-forwarded-name>", restart, and runwhr passkey add phoneon the host: open the link it prints on the phone within 5 minutes. From then on the web UI signs in with the passkey only;whr passkey lsandwhr passkey rm <id>manage them, and only on the host. What the agent and the console may reach (environment.egress_allow,console.egress_allow). The agent environment reachesapi.anthropic.comand nothing else by default; the console reachesgithub.com,proxy.golang.org,sum.golang.org,registry.npmjs.org,pypi.organdfiles.pythonhosted.org. An entry is one exact host:github.comadmitsgithub.comand notapi.github.com, so a host you rely on is listed by name. A wildcard*.example.com(every subdomain, never the bare name) is allowed here, in your own configuration, and nowhere else: a repository’s request for a host (itsdevcontainer.json, a lockfile) is one exact host that you answer as a Decision, and a request containing*is refused. To sign in to Claude Code with a subscription inside the environment (D40), the spike measured these hosts as the allowlist the flow needs:claude.com,platform.claude.com,api.anthropic.com,auth.anthropic.comandstatsig.anthropic.com(verified in the sign-in spike); add them toenvironment.egress_allowbefore you sign in, and the same for the OpenAI hosts of Codex when that adapter exists. They are not in the default list, so the default allows no more than before. Add"agent_api_key_env_file": "/Users/workharbor/.config/whr/agent.env"only for an API key.agent_allowed_toolsis required in the defaultdontAskmode: only the tools listed there run, and the list above is an example to adapt. To approve each tool use yourself instead, set"agent_permission_mode": "manual"and remove the list: every prompt then arrives inwhr inboxas an approval (whr approve <id>orwhr reject <id>) and the agent waits for you for ten minutes before it is told no.Start it and create a workspace. In
workharbor’s desktop session, because the supervisor talks to Apple Container’s per-session services (step 2):whr servechecks the whole configuration at start and lists every problem. The web UI is onlisten(http://127.0.0.1:8787/, or your forwarded HTTPS name) and thewhrcommands use the API socket in the state directory (keepstate_dirshort: a unix socket path is at most 100 bytes): sign in with the API token, then use the dashboard, the inbox and the task pages from a browser or the phone (design §9.3). In another terminal:whr ws add <name> --path <empty folder below a workspace root> --repo <owner>/<repository> --role <role>creates the workspace, seeds its agent clone and starts its environment;whr agent add <workspace> <role>adds an agent. Thenwhr run <issue-url> --agent <workspace>/<role>. To keep it running, let launchd do it (provisional command):whr service installwrites~/Library/LaunchAgents/io.github.wstein.workharbor.plist(mode 0644, yours), loads it intogui/<your uid>, and from then on the job runscontainer system start --disable-kernel-install, thenwhr serve, and starts it again if it exits, at most every 30 seconds. It refuses to run outside the graphical session (a shell from SSH orsudois in another domain), with awhrthat is not an executable regular file, and with a configuration thatwhr servewould reject. Output goes to~/Library/Logs/whr/whr.out.logandwhr.err.log, which launchd does not rotate.whr service statusshows whether it is loaded and its pid, andwhr service uninstallunloads it and removes the plist.
Remove the WorkHarbor account
whr offboard host (provisional, unverified) does part of step 1 and steps 4 and 5 below for the default account workharbor. It is a dry run by default: it inspects read-only and prints what it would remove, including the home folder’s size (home-size, read with du and without sudo) and the volumes mounted in it (home-volumes, then one home-volume line per mount point, or none). Each prints unknown, with a note, when it cannot tell: du exiting non-zero (common without sudo, when it cannot read a subfolder) discards its total, and a mount table that is empty, has a line that does not parse or lacks the root mount gives unknown alone, with no home-volume lines. A mount point that contains on / is matched at every split of its line, so it may be listed with a longer name than its real one. A mounted volume is only shown, never a refusal, and what sysadminctl does with it is unverified; a volume mounted after the plan makes the final recheck refuse and name it. whr offboard host --delete asks you to type workharbor, never takes an answer from --answers or --unattended, inspects again, runs /usr/bin/sudo /usr/sbin/sysadminctl -deleteUser workharbor -adminUser <you> -adminPassword - and then verifies. sudo asks for its own password with echo off; whr reads the administrator password for sysadminctl itself, without echo, and writes it to sysadminctl’s standard input, never to its command line, environment, the protocol or any report, and it drops any password prompt of the tool from the relayed tool output:. Verified: sysadminctl’s usage text allows -adminUser/-adminPassword and says - requests a prompt. unverified: that - reads standard input rather than the terminal, and a real deletion with this form (there is no man page for sysadminctl on the reference Mac). It refuses an account whose record has more than one name (an alias), a user ID below 500, the account you run as or that is logged in at the console (or whose console owner it cannot read), an administrator unless you add --allow-admin, and a home folder that is not a plain directory /Users/workharbor owned by the account. Of step 1 it reads only the record, the groups, the administrator membership, the console owner and the home folder; it does not check launchctl jobs, /Library/LaunchDaemons, sudoers files, FileVault users or automatic login (steps 1 and 3), the running processes (step 2), or backups (the paragraph below). Do those by hand with the commands below, as well as volumes (step 6) and any other account. After confirmation and the final account recheck, it records the deletion step in the administrator’s setup-protocol.jsonl in the default state directory, before running sudo, then records the verification outcome and a digest of the attempted commands. If the protocol cannot be opened or written beforehand, nothing runs. A protocol failure after execution exits with an error and does not undo deletion. Dry runs and refused confirmations write no protocol. The typed word protects against a mistake, not against an adversary: a program in an administrator’s session could feed it through a pseudo-terminal, and sudo’s own authentication is the real barrier.
After the account is verified gone, whr offboard host --delete also removes the login picture directory /Library/User Pictures/Workharbor of the login-picture step with sudo /bin/rm -rf -- <dir> (a constant path; a failure is only a note), because it lives outside the home; the dry run prints that command too. The Picture attribute goes with the account record.
Use this to delete the macOS user of WorkHarbor (workharbor by default, written <user> below; its record name is lowercase and its full name WorkHarbor) that step 2 created with sysadminctl -addUser: the user, its home folder and its access. Deleting a user is irreversible, and the home folder goes with it by default: back up first (the repositories, ~/.config/whr, the state in ~/.local/state/whr, the workspaces’ work) and check that the backup opens. whr setup changed host-wide settings that stay after the account is gone: the power settings (step 4), the log-out setting, the firewall and SSH settings (step 8) and what the Brewfile installed (step 5). Undo those by hand if you want them back. None of the commands below was run on macOS 26: every one is unverified, so read each one before you run it, as the administrator, never as workharbor.
Inspect first. Nothing here changes anything. Do not assume the name: the commands below write
<user>for the account’s realRecordName, which you use in every later command, and its home is/Users/<user>. The default name isworkharbor(WhrUserininternal/doctor/host.go, decision D49); an account created before D49 may be namedwhr(home/Users/whr, the legacy name), andwhr setup --user <name>selects a non-default account (read, not run). A WorkHarbor account is meant to be a standard user (theworkharbor-usercheck passes only for that and warns for an administrator).dscl . -list /Users UniqueID | sort -k2 -nlists the names and IDs that exist unverified.dscl . -read /Users/<user> RecordName UniqueID PrimaryGroupIDconfirms the account unverified.sudo ls -la /Users/<user>lists what the home folder holds unverified.id <user>shows the user and its groups; the groups to remove in step 3 come from this output unverified.sudo launchctl print user/$(id -u <user>)lists what runs in its launchd domain unverified.ls /Library/LaunchDaemons /Library/LaunchAgents | grep -i <user>finds a job of the account outside its home unverified.ls /etc/sudoers.dshows a sudoers file that names it unverified.sudo fdesetup listshows whether it is a FileVault user unverified.diskutil apfs listshows the workspace volumes of step 3 unverified.
Stop the sessions and processes. First stop the containers and the service: in the account’s session,
whr service uninstallunverified andcontainer system stopunverified. Then, as the administrator:ps -u <user> -o pid,commshows what still runs for the account unverified.- Optional:
sudo launchctl bootout user/$(id -u <user>)unloads its launchd domain unverified; in one run by the user it printed nothing. - Optional: log the account out at the login window or over Screen Sharing unverified.
sudo pkill -u <user>is not needed: in the user’s run,sysadminctl -deleteUserreported “Killing all processes for UID 502” itself, while an earlierpkillhad reported “Operation not permitted” for six pids (observed once, cause not verified). Deleting the account while session processes still run may leave a remnant; that is unverified too, so check with step 5.Note the access, and any automatic login. Delete normally removes the group memberships itself (step 5 shows what one run printed), so nothing has to be removed by hand first. Write down the groups that
id <user>printed (for examplecom.apple.access_ssh,com.apple.access_screensharing,com.apple.access_ftp,com.apple.access_remote_ae,admin);dseditgroup -o checkmember -m <user> adminreads whether it is an administrator unverified.sudo defaults read /Library/Preferences/com.apple.loginwindow autoLoginUsershows whether automatic login names it; if it does,sudo defaults delete /Library/Preferences/com.apple.loginwindow autoLoginUserremoves the setting unverified. Step 3 keeps automatic login off, so there is normally nothing to remove.
Delete the user.
sudo sysadminctl -deleteUser <user>deletes the account and, by default, its home folder;-keepHomekeeps the folder unverified. This is the irreversible step. Output observed once by the user for an account namedwhr(macOS version not recorded, not verified):No clear text password or interactive option was specified (adduser, change/reset password will not allow user to use FDE) ! Killing all processes for UID 502 Removing whr's home at /Users/whr Deleting Public share point for whr Deleting record for whrThe first line is a warning seen in that run; its meaning is not verified.
Verify.
dscl . -read /Users/<user>must fail with exit status 56 or an error namingeDSRecordNotFoundas a whole word (the text “does not exist” alone is not enough and staysnot_verified), for the account name and for any alias record name unverified.id <user>must fail with “no such user” unverified.ls /Usersandls "/Users/Deleted Users"show what is left of the home folder unverified. Remove a leftover only aftersudo ls -laon it shows what it holds unverified.dscl . -read /Groups/<group> GroupMembershipfor each group of step 3 must no longer list the name unverified. Only if a group still does, remove the name withsudo dseditgroup -o edit -d <user> -t user <group>unverified.
Observed once by the user after deleting
whr(macOS version not recorded, not verified):dscl . -read /Users/whr eDSRecordNotFound (-14136) dscl . -read /Users/workharbor eDSRecordNotFound (-14136) id whr no such user id workharbor no such user ls /Users Shared werner dscl . -read /Groups/com.apple.access_ssh GroupMembership No such key: GroupMembership (the same for access_screensharing, access_ftp, access_remote_ae) dscl . -read /Groups/admin GroupMembership root werner _mbsetupuserWorkspace volumes, only if their data should go too. A volume of step 3 lives outside the home folder, so step 4 leaves it, and its files stay owned by a user that no longer exists, which shows as a bare number. Keep it if you plan to create
workharboragain (a new account may get another user ID, and then ownership still does not match). To delete it:diskutil apfs listfinds its identifier, thensudo diskutil apfs deleteVolume <id>erases it for good unverified.
After the account is gone, whr setup and whr doctor report that there is no user <user> and name the sysadminctl -addUser command of step 2 as the fix; they never delete one. Today, with the user present, the workharbor-user check passes (workharbor exists and is a standard user, or a warning for an administrator) and offers no addUser fix; only a missing user, or an answer from dscl that is not clear, shows it. That is how the code reads (internal/doctor/host.go, issue #319); it was not run against a Mac (unverified).