Skip to content
Prepare the Mac mini

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.

  1. Install whr first, as the administrator, from the release archive: download whr_<version>_darwin_arm64.tar.gz and checksums.txt, verify them, unpack, sudo ./install.sh <tag>. No clone, no gh and 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 with gh. The wizard accepts a whr wherever 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 refuses root, and a file that is not an executable regular file after symbolic links. whr doctor reports where whr runs from, and who owns or can write the prefix, as one warn: it does not change the exit code, and a missing /opt/whr is part of that warn when whr runs from elsewhere. The guest helpers are looked for next to the running binary (<dir>/../libexec/whr), because whr serve looks there. The installers follow the same policy (issue #504): install.sh warns about a prefix that the account or others can write, and make install warns about a dirty tree or a HEAD that is not the local main, 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.

  2. As your administrator account, not root; and not workharbor unless it is an administrator itself (D49): /opt/whr/bin/whr setup (that path is not on the administrator’s PATH; the next lines write whr setup for short). It runs config-first (issue #394, #471): the same base-configuration questions as config-base (repository, the numbered volume choice below, account), because workspace-volume and spotlight read the workspace roots from config.json. For a shared account (--user, for example whr setup --user "$USER") it writes $HOME/.config/whr/config.json of the account that runs setup (or the path of --config / WHR_CONFIG) after a diff and your y. For a separate workharbor account, config-first runs 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/whr makes the folders as the account (an existing folder keeps its mode), a copy of the file owned by workharbor (mode 0600) is staged under the root-owned /etc/whr, and sudo -u workharbor /bin/cp -n copies it into place and then the stage is removed; the system config /etc/whr/config.json is installed with sudo install only when it is missing. All commands are shown first; the file is written from a private temporary file. Root never creates, chowns or writes inside workharbor’s home, so a symbolic link the account plants there can only reach what the account can write anyway, and cp -n never replaces a file that appeared meanwhile. What is covered: a link or a foreign-owned .config is also refused before the commands. What is not: that check is not atomic with the commands, and sudo -u runs the system’s mkdir and cp as the account, which is what any administrator can already do unverified. It never overwrites an existing config.json, never reads it, runs nothing from workharbor’s home and installs no service; if workharbor’s home is not there yet, the step is reported as not reachable (no FAIL) and a second whr setup does it. What it cannot do from the administrator’s session is listed at step 3. Whether the administrator can see into workharbor’s home, the owner and group arguments of install for 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. After config-first, it covers steps 2 to 5 and 8: the standard user workharbor, 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: autologout reads com.apple.autologout.AutoLogOutDelay, an unset key, an empty answer or 0 is fine; any value or message the check does not know is not_verified), the workspace volumes (workspace-volume: a root under /Volumes must be encrypted, which is guided because it needs your passphrase, and must honour ownership, which whr turns on with sudo diskutil enableOwnership; both read diskutil info text, 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 with brew pin container, and the /opt/whr prefix. Every privileged command runs as its own sudo command, 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 with sudo install. Screen Sharing is guided and optional. Tailscale (issue #408, optional, named with --only tailscale) is installed when missing with brew install --cask tailscale-app, after the usual confirmation (--yes skips the prompt, a dry run only prints it); it never runs as root or with sudo. Signing in, and approving a system or network extension if macOS asks, stay yours (unverified). The Brewfile names the cask tailscale-app. Measured read-only on one host on 2026-10-08: brew info --cask tailscale-app prints tailscale-app (Tailscale): 1.102.4, and brew info --cask tailscale resolves to the same cask (an alias or rename there); older brew versions and other machines are unverified. --only tailscale-serve names the serve step, as --only tailscale names the install step. Which account runs the serve step, and whether the administrator’s tailscale reaches the serve configuration of the app while workharbor is signed in, is not measured unverified. The tailscale-serve step then reads tailscale serve status and offers tailscale serve --bg <port> with the port of listen.

    An account that cannot sudo (issue #507, alpha policy, issue #493: warn instead of refuse). At the start, whr setup asks dseditgroup -o checkmember whether the running account is in the admin group. If it is not, you get one warning and the run goes on: the read-only checks run, every step whose fix needs sudo is 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 and sudo itself decides. root stays refused, and so does the standard workharbor account (D49); an administrator workharbor (single-account install) runs as before. Not run on a real host, and the dseditgroup answer on macOS 26 is unverified.

  3. 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, and whr does 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 a y, keeping every key it does not know), the tool store and whr 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 runs as: check it as ”), so run 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 configurationUS price, 1 October 2026Environments (estimate)
    16 GB / 256 GB, budget with an external SSD$899about 4
    16 GB / 512 GB, budget$1,099about 4
    24 GB / 512 GB$1,299about 6
    32 GB / 512 GB$1,499about 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’s account check reports which of these you have, and whr setup asks once whether the account is dedicated or shared, which it writes as account in the configuration):
    • A dedicated standard user workharbor (recommended). Agents never run next to your own home directory, keychain or SSH keys, and nothing running as workharbor can install software or use sudo. This is the only account that whr doctor passes with ok.
    • A dedicated administrator. Setup is easier, because one account runs whr setup for the administrator part and then whr setup --from config-dir for the account’s own steps; the risk is that an escape from the supervisor, or whoever drives it, holds sudo and writes to /Applications and the Homebrew prefix. whr doctor says warn, and fail when the configuration also makes whr reachable from other devices (a public_url, the console’s SSH, or the Mac’s own Remote Login or Screen Sharing being on). After setup, the last optional step drop-admin removes the account from the admin group (sudo dseditgroup -o edit -d <user> -t user admin, then sudo -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 doctor says warn (or fail with remote access), whr setup asks one explicit y that names the risk when the check would fail, and drop-admin is never offered. Threat T18 has the reasons.
  • whr needs 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 workharbor or su workharbor cannot start or reach them. So log in as workharbor on 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 from sudo -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 doctor reads 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 workharbor an 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). When workharbor first unlocks it, tick Remember this password in my keychain, so it mounts when workharbor logs 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 with sudo 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 authrestart restarts 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 workharbor so 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 tailscale can run tailscaled as 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 in workharbor’s GUI launchd domain, so a daemon cannot reach them. whr service install (step 13) loads it, and it starts again at every login of workharbor. So after a power cut, one login as workharbor brings 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 with container 1.5.0); that it also works for the standard user workharbor and 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 0

autorestart 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-analysis reads mediaanalysisd’s CPU use and CPU time (ps) and the size of its cache under the workharbor user’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.
  • spotlight reads mdutil -s for each workspace root that is on a volume of its own and fails while indexing is on; its fix, shown first and run after your y, is sudo 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 13
brew bundle --file=Brewfile
brew pin container

brew 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 --all

The 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.

  1. 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.
  2. As workharbor, forward the Mac’s Tailscale name to WorkHarbor on loopback with HTTPS: tailscale serve --bg <port> (check the exact syntax with tailscale serve --help). WorkHarbor itself stays on loopback.
  3. Optional: limit the phone to that port with a Tailscale access rule.
  4. 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).

  1. 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.
  2. 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.
  3. 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 pf packet-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. The pf rule and how the FRITZ!Box numbers VPN clients are unverified (issue #69): check the address your phone gets.
  4. 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 no and KbdInteractiveAuthentication no in 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 pf rule that blocks the container subnets (192.168.64.0/24 for the default network, and the --internal networks’) 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 and workharbor, and it stays reachable from guests until the pf rule 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 workharbor gives a workharbor shell for files and builds, but not for container or whr serve (step 2).
  • Only the administrator logs in to the host, and only for administration (macOS and whr upgrades, recovery). Your daily shell work, in the workspaces and across them, goes through the console instead: whr console opens a shell in a VM with git, zsh, fish, ripgrep, tmux, make and an editor, every workspace mounted read-only, behind the same egress allowlist as the agents (the package registries and github.com for reads, never the model API), with none of your credentials or the agents’. whr console <workspace> starts in that workspace; whr console <workspace> --write mounts 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). git in the console ignores the hooks, filters, aliases and other commands that a workspace’s configuration sets, because agents write that configuration. The shell comes through whr and 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 ssh opens an SSH session in the console, never on the host, and whr ssh --config prints a Host whr-console block for ~/.ssh/config so that VS Code Remote-SSH and JetBrains can use it. Turn it on once: run whr setup (its optional ssh-ca step makes the authority key, a 0600 file in the configuration directory) and add "console": {"ssh_ca_key_file": "<that path>"} to the configuration. Your key stays on the machine you run whr on; 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 user workharbor only, and the console’s host key is pinned from whr, 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 as whr console, so it is reachable exactly as far as that is, and the ssh client must be installed where you run whr 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 restart whr 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 -wal and -shm files), the secret files (encrypted backup only) and the workspace folders, and says to stop whr serve first.
  • 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 whr procedure 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.topic

The 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"
}
  • server is optional and defaults to https://ntfy.sh. It must be an https URL, or http on 127.0.0.1 or localhost for a server on the same host, and it carries no username, password, query or fragment (keep the token in the token file).
  • topic_file is required. The topic in it needs at least 20 characters and no /, ?, # or whitespace (surrounding whitespace is trimmed).
  • token_file is optional.
  • A notification links to the task, so public_url (or board.public_url) must be set to an https URL, 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).

  1. It prints a link and waits on the configuration’s listen address. 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.
  2. 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. whr exchanges the code for the App and writes the private key to ~/.config/whr/github-app-<id>.pem (mode 0600, never overwriting a file). The key, the client secret and the webhook secret are never printed or logged; the two secrets are dropped.
  3. 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.
  4. whr doctor then 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:

  1. 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. whr asks 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.
  2. Generate a private key on the same page (Private keys → Generate a private key). The browser downloads a .pem file: that file is the secret, see step 12.

  3. Install it (Install App) on your account with Only select repositories and pick the repositories WorkHarbor works on. whr finds the installation by itself. One App serves all your repositories: to add one later, add it to the installation’s repository list and to repositories in the configuration; each token whr mints 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.

  4. 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 only agent/* branches and opens pull requests; merging stays with you.

    Which ruleset each workflow needs (D47; set per repository in the configuration as "workflow", default integration; whr doctor checks it as forge-workflow):

    WorkflowWhere approved commits goThe 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 doctor reports 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 (default develop) that you merge and promote to mainthat 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 doctor says “not verified”, never “ok”. Changing a repository’s workflow in the configuration is a policy change: whr serve refuses 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.token

The 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.pem

The 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 whr never 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 in workharbor’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-token and not a login token: whr setup, whr doctor and the configuration refuse a variable named like a subscription credential (CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_AUTH_TOKEN, any name containing OAUTH, SESSION, ACCESS_TOKEN, REFRESH_TOKEN, AUTH_TOKEN, SETUP_TOKEN or LOGIN_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 with sk-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 -s does not echo, and printf is 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.

  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.gz and checksums.txt, check the archive against the checksums, unpack it and run sudo ./install.sh <tag>. It needs only what stock macOS ships (no clone, no gh, no Command Line Tools) and never runs in workharbor’s account. The prefix /opt/whr is then root’s, so nothing running as workharbor, an agent that escaped included, can replace the supervisor. If workharbor itself is the administrator that runs this, the prefix belongs to root as well, and whr doctor warns, no longer fails, about a prefix, bin or bin/whr owned 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; with gh signed in it also checks the attestation, and it is how you verify a draft release. Set tag to 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/whr is the default of both routes; the tag is a release after v0.1.0-alpha.4. Both install whr in /opt/whr/bin and the guest binaries whr-shim and whr-proxy in /opt/whr/libexec/whr, and install nothing if a check fails. gh release download finds 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. For v0.1.0-alpha.4 and older, use that tag’s own script (git show $tag:scripts/install-release.sh). The administrator reaches the installed whr by its full path, /opt/whr/bin/whr version (the directory is not on its PATH); the next command is /opt/whr/bin/whr setup. Then, as workharbor, add export PATH=/opt/whr/bin:$PATH to ~/.zprofile: whr version shows the tag, and whr completion zsh (or bash, 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).

  1. Fill the tool store. With store=/Users/workharbor/tools (the roots.tool_store of step 3), whr tools build -store "$store" -shim /opt/whr/libexec/whr/whr-shim-linux-arm64 downloads 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,antigravity adds 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 a claude-<version> profile, -tools claude,antigravity makes a second glibc profile, claude-<version>-antigravity-<version>, and whr serve then refuses to start (“set tool_profile”) until tool_profile names one of them or you remove the old profile.

  2. 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_file stays the client default. 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, mode 0600, 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 not default and 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 actor api:<name>; default keeps its derived id. whr doctor checks all of it as api-clients and names the client of a failing file. The CLI keeps using default.

    integration_branch is where approved commits go (the default workflow integration uses develop when 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 add takes it from the configuration (for published, the default branch GitHub reports now) and refuses a --branch that 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-select Status field with the options Needs you, In progress and Done (whr doctor checks these three; a cancelled task writes Todo, which the board may have as a column but the check does not require), and may have a Session field and a text field Task that holds the link. The App then needs the organization’s Projects permission (whr github app create --board asks for it; whr doctor checks 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 run whr passkey add phone on 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 ls and whr 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 reaches api.anthropic.com and nothing else by default; the console reaches github.com, proxy.golang.org, sum.golang.org, registry.npmjs.org, pypi.org and files.pythonhosted.org. An entry is one exact host: github.com admits github.com and not api.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 (its devcontainer.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.com and statsig.anthropic.com (verified in the sign-in spike); add them to environment.egress_allow before 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_tools is required in the default dontAsk mode: 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 in whr inbox as an approval (whr approve <id> or whr reject <id>) and the agent waits for you for ten minutes before it is told no.

  3. 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 serve checks the whole configuration at start and lists every problem. The web UI is on listen (http://127.0.0.1:8787/, or your forwarded HTTPS name) and the whr commands use the API socket in the state directory (keep state_dir short: 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. Then whr run <issue-url> --agent <workspace>/<role>. To keep it running, let launchd do it (provisional command): whr service install writes ~/Library/LaunchAgents/io.github.wstein.workharbor.plist (mode 0644, yours), loads it into gui/<your uid>, and from then on the job runs container system start --disable-kernel-install, then whr 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 or sudo is in another domain), with a whr that is not an executable regular file, and with a configuration that whr serve would reject. Output goes to ~/Library/Logs/whr/whr.out.log and whr.err.log, which launchd does not rotate. whr service status shows whether it is loaded and its pid, and whr service uninstall unloads 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.

  1. Inspect first. Nothing here changes anything. Do not assume the name: the commands below write <user> for the account’s real RecordName, which you use in every later command, and its home is /Users/<user>. The default name is workharbor (WhrUser in internal/doctor/host.go, decision D49); an account created before D49 may be named whr (home /Users/whr, the legacy name), and whr setup --user <name> selects a non-default account (read, not run). A WorkHarbor account is meant to be a standard user (the workharbor-user check passes only for that and warns for an administrator).

    • dscl . -list /Users UniqueID | sort -k2 -n lists the names and IDs that exist unverified.
    • dscl . -read /Users/<user> RecordName UniqueID PrimaryGroupID confirms 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.d shows a sudoers file that names it unverified.
    • sudo fdesetup list shows whether it is a FileVault user unverified.
    • diskutil apfs list shows the workspace volumes of step 3 unverified.
  2. Stop the sessions and processes. First stop the containers and the service: in the account’s session, whr service uninstall unverified and container system stop unverified. Then, as the administrator:

    • ps -u <user> -o pid,comm shows 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 -deleteUser reported “Killing all processes for UID 502” itself, while an earlier pkill had 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.

  3. 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 example com.apple.access_ssh, com.apple.access_screensharing, com.apple.access_ftp, com.apple.access_remote_ae, admin); dseditgroup -o checkmember -m <user> admin reads whether it is an administrator unverified.

    • sudo defaults read /Library/Preferences/com.apple.loginwindow autoLoginUser shows whether automatic login names it; if it does, sudo defaults delete /Library/Preferences/com.apple.loginwindow autoLoginUser removes the setting unverified. Step 3 keeps automatic login off, so there is normally nothing to remove.
  4. Delete the user. sudo sysadminctl -deleteUser <user> deletes the account and, by default, its home folder; -keepHome keeps the folder unverified. This is the irreversible step. Output observed once by the user for an account named whr (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 whr

    The first line is a warning seen in that run; its meaning is not verified.

  5. Verify.

    • dscl . -read /Users/<user> must fail with exit status 56 or an error naming eDSRecordNotFound as a whole word (the text “does not exist” alone is not enough and stays not_verified), for the account name and for any alias record name unverified.
    • id <user> must fail with “no such user” unverified.
    • ls /Users and ls "/Users/Deleted Users" show what is left of the home folder unverified. Remove a leftover only after sudo ls -la on it shows what it holds unverified.
    • dscl . -read /Groups/<group> GroupMembership for each group of step 3 must no longer list the name unverified. Only if a group still does, remove the name with sudo 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 _mbsetupuser
  6. Workspace 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 workharbor again (a new account may get another user ID, and then ownership still does not match). To delete it: diskutil apfs list finds its identifier, then sudo 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).