# OnionPress Project Memory ## Meta - **This file holds rules a change must respect**, each with the test that holds it. Not history: what broke, in which release and why goes in the commit message or PR. `docs/DESIGN-NOTES.md` has the story behind every rule here, under the same heading (a 2026-10-26 snapshot, not maintained). - A new rule: a bullet or two under the section it belongs to, ending in its test. If a rule needs a paragraph of explanation, put that in docs/. - Run `/mission` to evaluate a change against OnionPress's three author commitments: safety, ease of publishing, and content durability. ## Naming Rules (IMPORTANT) - **OnionPress** (one word, capital O and P), never "Onion.Press", "onion.press" or "onion-press". Data dir `~/.onionpress/`. Repo `brewsterkahle/onionpress`. - **"onion service"**, never "hidden service", in all user-facing text (external identifiers like `/var/lib/tor/hidden_service/` excepted). ## Key Architecture - macOS menubar app (py2app, `src/menubar.py`) is a view of the daemon's state file; `onionpress daemon` (`src/onionpress/daemon.py`, a launchd agent) runs every loop ("The Mac Daemon"). - Launcher `app/MacOS/onionpress` (Mac) and `linux/onionpress` (Linux); Docker containers (tor, wordpress, mariadb, onionheaven…) run inside a Colima VM on macOS. - **A function that differs by platform takes `os_name`** (`"macos"` / `"linux"`, `detect_os().value`; read as `OS(os_name)`, so any other name raises), never a `mac=` or `linux=` flag. `TestPlatformName` in `tests/test_first_run_key_seed.py` (a census). - Logs in `~/.onionpress/` (`logs/`, `onionpress.log`, `launcher.log`); user-visible content in `~/OnionPress/` (backups, Creations). ## Repo Layout - `src/menubar.py` — py2app entry point, the only flat module. - `src/onionpress/` — the shared package (everything else). A new submodule needs one line in `setup.py`'s `includes`. - `app/` — the .app bundle source (`Info.plist`, `MacOS/`, `Resources/`: docker configs, plugins, icons, templates). `linux/` — the Linux side. - `OnionPress.app/` — **gitignored build output**; never commit it. - py2app because Macs ship no usable Python; users must never need Xcode tools. ## Build & Release Process - **After editing any `src/` file the app uses, rebuild the MenubarApp** (`build/rebuild-menubar.sh`): the bundle holds compiled `.pyc`s, and the daemon and every `onionpress.cli` command run the bundle's package. Hot-patching: only `MenubarApp/Contents/Resources/menubar.py` (and its pyc) is the live entry point. - **Releases: `build/release.sh` only, on `brewsterkahle/onionpress`, never a fork.** It passes `-R`, refuses unless HEAD is upstream `main`'s tip, builds both `.dmg` and `.deb` (Mac side first; Linux only attaches a `.deb` to a release that has the `.dmg`), writes `SHA256SUMS`, publishes from draft only after reading it back. Hand-run `gh release` needs `-R brewsterkahle/onionpress`. No Internet Archive uploads. `tests/test_release_sha256sums.py`. - **Every release adds its notes to `RELEASE_HISTORY.md`** before it is cut: a `## X.Y.Z — YYYY-MM-DD` section at the top, saying what changed for the people who run OnionPress (what to do, what to know; hub/OnionHome steps under their own heading). Write it in the bump PR. `release.sh` puts the section above GitHub's generated PR list and refuses a version without one; the tree's version must have one. `TestReleaseHistory` in `tests/test_release_sha256sums.py`. - **`onionpress update` is one updater on both platforms: `cli.cmd_update` over `updater.py`** (`find_release`/`check_release`: the latest, or `--version X.Y.Z`; `--pre` allows a pre-release; a draft, a tag that is not `v` + a release version, or an answer for another tag is refused; `version_newer` compares numbers, `--force` reinstalls or downgrades). Both launchers' `update` cases delegate; the Linux launcher keeps only the install leaves the Python calls back, `update-install-deb` and `update-from-checkout`. No second lookup, comparator or `SHA256SUMS` parser anywhere. `tests/test_linux_release_update.py`, `tests/test_cli_dispatch.py`. - On Linux `updater.fetch_deb` installs only a release `.deb` matching its `SHA256SUMS` line, the onionpress package of the tag's version, newer than installed (`/opt/onionpress/VERSION`), downloaded from the release's own URL into a 0700 folder; then `sudo apt-get install -y --no-remove` in the leaf and `update_finish` (`pull --policy missing`). Off a terminal it needs `--yes` (the launcher's `-y` filter passes it on). `tests/test_linux_release_update.py`. - The Settings page's update only checks, on both platforms (anything in WordPress can request it): `handle_action._update` runs `update --check --json`, keeps the answer five minutes in `update-check.json` (GitHub allows 60 lookups an hour) and writes `update-result.json` with `installable` and how to install. `TestUpdate` in `tests/test_handle_action.py`. - The Mac update mounts a DMG only if it matches the release's `SHA256SUMS`. `DmgChecksumTest` in `tests/test_updater_staged_swap.py`. - Every curl in `updater.py` is `_curl_argv` (`--fail`, HTTPS only, `--cacert /etc/ssl/cert.pem` only where it exists); `--check --json` always exits 0 and reports `error`, which UIs show as failure. `tests/test_update_check_ca_bundle.py`. - **What goes into `/opt/onionpress` is one list: `linux/install-tree.sh`** (the .deb, `install.sh`, `update --from-checkout`). It refuses links and special files; `--from-checkout` takes only a clean, non-world-writable checkout owned by the user or root, fast-forward only. `tests/test_linux_install_tree.py`. - `linux/install.sh`: never `rm -rf` a folder named by the environment (`tests/test_install_sh_clone_cleanup.py`); installs the newest release tag, not main's HEAD; body is `main() {…}; main "$@"` so a cut-off `curl | bash` runs nothing; Docker's apt key is checked by fingerprint. `tests/test_install_sh_release.py`. - The .deb postinst restarts the units and tray on upgrade and never fails the install (`tests/test_deb_upgrade_restarts.py`); a first install sets up rootless Docker, judging success by `docker info`, using `user_systemctl` (not `--machine`) and `--no-block` (`tests/test_deb_first_install_docker.py`). - The rootless setup tool is looked for on PATH, then in docker.io's contrib, then in the package's own copy of Docker's two scripts (`linux/docker-rootless/`, pinned by commit and sha256, Apache-2.0 in `linux/copyright`; Ubuntu's docker.io ships none). A RootlessKit without `--detach-netns` (Ubuntu 24.04's 1.1.1) gets the user's `docker.service.d/onionpress-rootlesskit.conf` drop-in turning it off. A skipped or failed attempt says why at the terminal and in `~/.onionpress/docker-rootless-setup.log`. **No Compose v1 in `Depends`**: every Compose alternative is v2 (`docker-compose-plugin | docker-compose-v2 | docker-compose (>= 2)`), and the launcher's start checks `docker compose version`. **The .deb Recommends nothing**: torbrowser-launcher is a Suggests, since as a Recommends it brought the system `tor` on to 9050 (Ubuntu installs Recommends by default) and the site moved to 18080. `tests/test_docker_rootless_vendored.py`, `tests/test_deb_first_install_docker.py`. - **Docker Engine 20.10+ and Compose 2.22+ are the floors** (`docker_versions.py`, `onionpress check-docker`): the Linux launcher's `start` refuses older ones with what was found and how to upgrade, the postinst warns at install time (never fails). Moving a floor means new evidence in that module's docstring. `tests/test_docker_versions.py`. - **Every Mac build input is pinned by hash**: downloads through `fetch_checked` against the `*_SHA256_*` constants; Python from the `build/*-macos.lock` files with `--require-hashes` (`build/refresh-python-locks.sh`); mkp224o at `MKP224O_COMMIT`. Bumping a version means its new hash. `tests/test_dmg_build_pins.py`. - **A release's Mac app is universal** (`ONIONPRESS_RELEASE_BUILD=1` refuses single-arch Python; every Mach-O must have arm64 + x86_64). The `.deb`'s files are root's, with no AppleDouble `._` members and no PAX headers (dpkg cannot unpack them; the Mac's bsdtar writes GNU tar without xattrs, `build/check-deb-tar.py` checks; run `build/check-deb.sh` on any package before handing it out). `tests/test_build_release_hygiene.py`. - CI rebuilds, lintian-checks and attests the `.deb` on every published release and rewrites its `SHA256SUMS` line; write permission only in the release job. `tests/test_linux_package_workflow.py`. - **Every workflow action is pinned to a commit SHA** with a `# vX.Y.Z` comment; dependabot bumps both. `tests/test_build_release_hygiene.py`. - **Version bump: `build/bump-version.sh X.Y.Z`.** Canonical sources: `src/menubar.py` (`self.version`) and `app/Info.plist`. - The build lock keeps `setuptools<81` (py2app #557) until a build has tried 81+; see the `.in` file's comment before dropping it. ## Local Build Path - **`docs/BUILDING.md` is the map**; `make doctor` reports missing tools. `make images` / `make dev-up` build and run local images. - Every `docker pull` is gated on `onionpress.containers.using_local_images` (the launchers delegate to it). `tests/test_local_image_mode.py`. - The tor image is built on Onimages' C Tor image; its Dockerfile must `USER root` and `CMD []`, and the entrypoint's per-start `chown -R /var/lib/tor` stays. **C Tor only**: `TOR_IMPL` must not come back (the control port is load-bearing). - **The key volume is every install's identity**: `onionpress-onion-keys`, PEM at `/ks_hs_id.ed25519_expanded_private`. `migrate_key_volume` (from the old `onionpress-arti-state`) runs before anything starts tor and before first-run detection, fails loudly rather than copy into an empty volume, and is resumable via `.key-volume-migration-pending`. Every identity wipe drops that marker and keeps the old volume name in its wipe list. `ContainerManager.start_tor` refuses while a migration is due. A failed first-run key seed removes its volume and stops (`start.KeySeed`; one seed, `containers.run_key_seed`, for start and restore). `tests/test_install_invariants.py`, `tests/test_first_run_key_seed.py`. - **Linux's first run pulls the tor image before `generate-vanity`** (#519: the postinst's first start had pulled nothing, "no image" read as "no mkp224o", Tor minted a key that never reached the key volume as a PEM, and every backup of that site was unrestorable). **A backup holds the PEM or says it has no key** (`backup._stage_onion_key`, both platforms): the key is read out of the tor container by `key_manager.extract_keys` (the delivered PEM, else C Tor's files, the public key from `hostname` when tor wrote none); no key readable is a WARNING and `excludes_onion_key` + `onion_key_unreadable` in metadata (the restore then keeps the install's key, as for the page's backup; `unchecked_notes` says so with the finished backup), never an archive no restore accepts. `TestLinux` in `tests/test_first_run_key_seed.py`, `tests/test_linux_backup_key_pem.py`. - **All Dockerfile inputs are pinned** as `ARG` defaults (images by multi-arch index digest, mkp224o by commit, wp-cli by sha256); `MKP224O_VERSION` matches `build/build-dmg-simple.sh`. Re-pin the Onimages base right before every image publish (its registry drops old digests). `build/base-image-digest.sh` resolves tags. - `make build` runs `build-dmg-simple.sh` (`build-dmg.sh` is deleted and stays deleted). Firefox's extension = `extension/` overlaid with `extension-firefox/`; keep only differing files there. - `docker-publish.yml` needs nothing outside GitHub and publishes to the repo owner's namespace. On `development` `IMAGE_PREFIX: dev-`; reset to `""` when merging to main. `tests/test_publish_workflow.py`. ## Container Image Pins - **`build/image-pins.env` is the single source of truth** for every image the stack runs. **Never hand-edit a digest**: `build/refresh-image-digests.sh` (`--propagate` without a daemon; resolves from the registry, `--local` only on request). Pins are multi-arch index digests; **mariadb moves forward only**. `tests/test_image_pins.py`. - Starts pull `--policy missing` only (`start.start_core`). **Images change only with a release**: no refresh toggle, no `update-images`, no image pass in Check for Updates (with every image pinned they re-pulled the same digests); Linux's `onionpress update` pulls its release's pins. `tests/test_no_image_refresh.py`, `tests/test_image_pins.py`. - **The start removes what a moved pin superseded** (`start.prune_superseded_images`, last in post-up): per image the stack runs (`compose config --images`), only once its pinned image is here, only untagged images of that repository and of a mirror's former one (`FORMER_REPOSITORIES`, held equal to `build/image-mirrors.txt`; OnionPress pulls by digest; a user's tagged image is never touched), plain `image rm`, never forced (an image a container uses stays); nothing for local images. `TestPruneSupersededImages` in `tests/test_start_post_up.py`. - **A release builds no images**: it only points `:latest`/`:` at the pinned digests. `tests/test_publish_workflow.py`. - `ONIONPRESS_TOR_IMAGE` / `ONIONPRESS_WORDPRESS_IMAGE` repoint the stack at local images. - **Image pins move with the launcher**; changes to files in the tor or wordpress image reach users only after an image publish + re-pin. ## A Damaged Key Never Becomes a New Address (issue #311) - The delivered PEM is the identity; C Tor's files are a copy re-derived at every start unless they match exactly (old files set aside, never deleted). No usable PEM and no secret key: refuse to start. - The watchdog mints (`ADD_ONION NEW:BEST`) only with no trace of an address at all, keeps a minted key in memory, saves it O_EXCL. `tests/test_onion_key_never_reminted.py`. ## Onion Service Recovery (issue #266) - The app's dual probe (`health.py`, fetching `/.onionpress-health`) is the only measure of reachability. `RepublishTrigger` (rate-based: ≥ 50 % of 10 min failing; 5 min for `desc-missing`/`desc-stale`) asks the watchdog for DEL_ONION + ADD_ONION via `launcher_ops.request_watchdog_republish`. **The daemon's `check_status` runs it on both platforms** (#407): one `RecoveryWindow`, no `reachability.json`, no second driver. `tests/test_linux_recovery_loop.py`, `tests/test_mac_daemon.py`. - Rate limits: 15 min host, 15 min watchdog, never within 5 min of an upload. `REPUBLISH_ON_UNREACHABLE=no` makes it log-only. The one wake handler, `Daemon.handle_wake`, resets the window (test-enforced), run by IOKit on a Mac and by logind's PrepareForSleep on Linux (`power.LogindPowerHandler`). - Locked together by tests: `PROBE_PATH`/`PROBE_BODY_MARKER` and the image's `onionpress-health.conf`; the request path literal in `launcher_ops.py` and `tor-watchdog.py`. Every Python entry point in `linux/` is installed by both `install.sh` and `build-linux.sh`. `tests/test_linux_recovery_loop.py`. - **The daemon on Linux reaches the Docker daemon the launcher uses** (#503): `Daemon._setup_environment` puts `resolve_paths`' socket into DOCKER_HOST before the first `docker` call (a preset one kept; the unit sets no DOCKER_HOST, and must not: a system-Docker install has none). `TestPlatformSeams` in `tests/test_linux_daemon.py`. - The watchdog is started only through entrypoint.sh's `supervise_watchdog` (a function, not a `*tor-watchdog*` script: hosts signal `pgrep -f '[t]or-watchdog'`). `tests/test_tor_watchdog_supervised.py`. ## License Declarations - **AGPL-3.0-or-later** (that SPDX form), "Copyright (C) 2026 Internet Archive and OnionPress contributors": LICENSE, README, Info.plist, setup.py, `linux/copyright`. Entry scripts carry the GNU short notice; plugin headers and the theme's style.css carry `License:` + `License URI:`. - The DMG's `THIRD-PARTY-NOTICES.txt` covers bundled binaries and the bundle's Python distributions (`build/python-notices.py`); a missing text fails the build. A new dependency whose wheel has no license text needs a `LICENSE_URLS` row. `tests/test_license_declarations.py`. ## Test suite - `make test-unit` (`unittest discover`, uv, Python 3.14, PyNaCl). **No pytest**: every test file holds `unittest.TestCase` classes. - **On a Mac running OnionPress, set `DOCKER_HOST=unix:///var/empty/no-docker-for-tests.sock`** so docker-backed tests skip instead of touching the live site. - `make coverage` enforces floors (`build/coverage-floors.sh`); raise a floor when tests catch up, never lower one to pass. `tests/test_coverage_gate.py`. - **The launchers' `main` is run, not just read**: `tests/launcher_harness.py` runs a copy of each launcher with docker, the CLI and named helpers stubbed. A launcher verb's options, exit status and the CLI argv it produces go in `tests/test_launcher_dispatch_{mac,linux}.py`; the tray runs only verbs the Linux `main` has (same file). - **A new `onionpress` subcommand needs a row in `tests/test_cli_dispatch.py`** (the census fails otherwise; grouping commands go in `PARENTS`), and its handler a case in `tests/test_cli_commands.py` / `test_cli_edges.py`. - **e2e tiers after T0 start from `ready_node`** (`e2e/harness/scenario. ensure_ready`: install, start, setup, each only if T0 has not), live in `e2e/test_t__*.py` (run.sh's glob, name order: read-only modules first), and keep their logic in `e2e/harness/` where the unit suite reaches it; T1 (a)'s capability check imports `tests/test_container_capabilities.py`'s census, never a copy. `ScenarioTest` / `HardeningTest` in `tests/test_e2e_harness.py`. - The e2e suite's platforms are OS families, `macos` and `linux` (`e2e/harness/platforms.py`); the distro is a node fact. Linux-host gates use `HostFacts.debian_based` (`ID` or `ID_LIKE`), never `distro == "debian"`; a `linux-vm` guest is one of `LINUX_GUESTS` (Debian 13 the default). `tests/test_e2e_harness.py`. ## Security - Database passwords are random per install (`ensure_secrets`, `~/.onionpress/secrets`); never defaults, never committed or logged. - **Crypto comes from established libraries (libsodium via PyNaCl), never constructions of our own.** - Secrets never go on argv: stdin or the environment. `tests/test_secrets_off_argv.py`. - **A backup password given as an argument is refused** (#504; exit 2 from the launchers' `read_backup_password`, identical in both, and `cli.read_backup_password` / `scrub`): `backup`, `restore`, `scrub` and `setup --from-backup` take it from `ONIONPRESS_BACKUP_PASSWORD`, stdin or a prompt only. The old positional forms are still parsed so they get the refusal, never a mis-read argument. `tests/test_backup_password_handoff.py`, `tests/test_terminal_setup.py`. ## WordPress Branch Moves With the Image - `WORDPRESS_BRANCH` in the wordpress Dockerfile is the branch the audit moves sites up to; never past it, never down. Raise it only after testing mu-plugins and pinned plugins on it (docs/BUILDING.md). `tests/test_wordpress_branch_upgrade.py`. ## A Core Package Is Checked Before It Is Installed (issue #395) - Core packages come only from `https://downloads.wordpress.org` and every zip member is held to WordPress.org's md5 list before install; a refused package is removed. `tests/test_core_update_verified.py`. ## Settings Page Config Writes Are Allowlisted - Anything in WordPress can write the request files in `/var/lib/onionpress/`. Config changes go only through `config.apply_settings_updates` (`SETTINGS_PAGE_KEYS`, every value checked; the rest dropped, logged by key name only), applied by `handle_action.apply_config` on every `handle-action` run and by `onionpress apply-config`, on both platforms (each launcher's case delegates; no bash twin). `tests/test_settings_page_updates.py`. - **Never add to `SETTINGS_PAGE_KEYS`** image overrides, `ONIONHEAVEN_ADDRESS`, `ONIONHEAVEN_ACCEPT_REGISTRATIONS`, `ADDRESS_PREFIX`, ports, paths or secrets other than `CLOUDFLARE_TUNNEL_TOKEN` (write-only field). Log changes via `describe_change`/`loggable_value`. Every page key reaches a reader (`tests/test_settings_page_keys_are_read.py`). - **The page never sets `CLOUDFLARE_TUNNEL_TOKEN` unattended** (#463; its tunnel's owner sees the host's IP): `PAGE_HELD_KEYS` values are held, asked on a Mac ("clearnet-tunnel"), refused on Linux; removal is applied. `TestTunnelToken` in `tests/test_handle_action.py`. - **Page requests are handled only by `onionpress handle-action`** (`handle_action.py`, both platforms): fixed verbs (`ACTIONS`), each run through the launcher; `update` only checks on both platforms; on a Mac an address change stops for the person at the Mac (`pending-action/`). `tests/test_handle_action.py`. - Every result carries the request's id (`checked_request_id`); `tests/test_settings_page_results.py`. Reads are size-capped (`READ_LIMITS`), actions throttled (`MIN_INTERVAL`), unknown request text never logged (`TestBoundedReads`, `TestThrottle`). - Every `wp_ajax_` handler with a side effect `check_ajax_referer`s (`tests/test_settings_page_nonces.py`, a census). - Page Stop/Restart go through the launcher's cases (Linux asks the daemon for `/offline` first). `tests/test_linux_settings_page_stop.py`. Share Now is handle-action's `upload-analytics` (`analytics_sharing.request_upload`'s data-dir marker, refused while sharing is off; nothing in the volume, no per-cycle `docker exec`). `tests/test_settings_page_share_now.py`. - **The page does none of the host's work** (#528): its release line is `status.json`'s `release` (`status_writer.release_status`, the cache of `handle_action.check_release`; its button is the `update` action), never a request of its own; `config-current.json` has one writer, the status writer (the form shows unapplied `config-updates.json` on top); the status page writes no `requested-action`. Every PHP copy of a host constant (fields, token pattern, restart keys, result files, actions, request id, the onion shape) is held to its source. `tests/test_settings_page_host_constants.py`. - **The page's archive.org login is the host's** (#528 item 3): handle-action's `archive-login` over `multisite.fetch_archive_s3_keys` (archive.org's onion over Tor) and `save_archive_s3_keys`; no login code in WordPress. The account and password cross the volume only in the `archive-login` request file (0600, written only when `status.json`'s `page_actions` lists the verb), read once and removed before the login or with a throttled request (`INPUT_FILES`); neither is logged or in the result. `TestArchiveLogin` in `tests/test_handle_action.py`, `TestArchiveLoginIsTheHosts` in `tests/test_settings_page_host_constants.py`. - **`onionpress config get|set|unset|list` is the apps' one writer of `~/.onionpress/config`** (`config.CLI_CONFIG_KEYS`). A new setting gets its `config set` entry first (check, `CONFIG_KEY_HELP`, `NEEDS_RESTART` / `CODE_DEFAULTS`); a UI only calls the command. `ONIONPRESS_*` and `ONIONHEAVEN_ACCEPT_REGISTRATIONS` are hand-edit only. Secrets only as `config set KEY -`. `tests/test_cli_config.py`. - **One rule reads the config** (#404): the first `KEY=` line wins, the value is all after its first `=`, trimmed: `config.read_value` / `read_config` and the launchers' `config_value` (identical in both; never `grep | cut`). Writes are a temp file + rename under `.config.lock`, one line per key. `tests/test_config_one_reader.py`. - The page's backup leaves out host secrets (`include_host_secrets=False`, config cut to `SAFE_CONFIG_KEYS`); no restore takes the token from a backup. `tests/test_backup_host_secrets_off_wordpress.py`. ## WordPress Host Controls Are the Owner's - `manage_options` is per-site in the multisite: **gate everything that reaches the host or every site on `onionpress_settings_owner_allowed()`** (super admin), in every handler, AJAX endpoint and REST `permission_callback`. Same for onboarding, Wayback, blog snapshots and My Creations upload; social importers stay per-site. `tests/test_settings_owner_only.py`. - The backup download sends/deletes only an `OnionPress-<≤8>-.zip` regular file; changing the name in `handle_action.py` means changing the pattern. `tests/test_backup_download.py`. - **A key import never loses the current key**: every import is `onionpress import-key` (`key_import.replace_key`, both platforms; the launchers' `import_key` is a delegation, and the one ADDRESS_PREFIX rule is `config.prefix_for_address`): check, stage, copy old keys to `~/.onionpress/replaced-keys/`, then release hubs and swap. On a Mac the page's import needs the person at the Mac (native dialog, Cancel default, 5 min = Cancel); Linux refuses it from the page. The CLI speaks the check/confirm protocol on both platforms (`address-change`, and `hub-holds-key` when a hub may still keep the key). `tests/test_onion_key_replacement.py`. - An import Docker was not up for (`.import-key-pending`) is finished by the next start, first: `ContainerManager.apply_pending_key_import` wipes the old key, migration-era and C Tor volumes, each confirmed absent; anything else keeps the marker and stops the start. `tests/test_pending_key_import.py`. - **Code in WordPress never reads or replaces the site's onion key without the owner at the host**: the page's backup excludes it on both platforms (`cmd_settings_page_backup`, `include_onion_key=False`; every `create_backup` call says which); a keyless backup never mints an address on any path; a page restore of another address asks (Mac) or is refused (Linux: `cmd_settings_page_restore`, `ADDRESS_CHANGE_REFUSED_MSG`); a hub/OnionHome node, or one that cannot say, restores nothing from the page. `tests/test_settings_page_onion_key.py`. ## The Daemon (issue #367, CLI-first phase 3; Linux since #407) - **`onionpress daemon` runs every loop on both platforms; the menubar and the tray are views** (menu, dialogs, notifications, setup windows, starting the daemon). The menubar runs no loop naming docker, the hub or Tor, no timer but the splash tips. `tests/test_mac_daemon.py`. - **Every platform difference in the daemon is a branch on `self.os_name`** (`detect_os()`, overridable for tests), never a second module: sleep and wake (`mac_power.MacPowerHandler` / `power.LogindPowerHandler`, one interface: `register`, `run_loop_once`, `stop`, `OFFLINE_MAX_TIME`; the sleep and wake policy is `handle_sleep` / `handle_wake` once, the `/offline` within the handler's budget: IOKit 5 s, logind 3 s of its 5 s delay), the sleep inhibitor (`CaffeineManager` / `SystemdInhibitor`), the Docker environment, `_runtime_ready` (Colima's `.initialized` only on a Mac), the start of Tor after the first run's setup (`start-tor`, Mac only: Linux's start brings Tor up), the hub gate (`hub_registration_allowed`, below), the rootless Docker namespace watch (`_watch_rootless_netns` → `wordpress-egress`, Linux), and what only a Mac has: thumbnails, the clearnet dmesg capture, the extension's proxy. `tests/test_linux_daemon.py` (the Linux half of the harness), `tests/test_mac_daemon_paths.py`. - **Linux runs it as the `onionpress-daemon.service` user unit** (`linux/onionpress-daemon.service`: `Before=onionpress.service`, so the site's `ExecStop` asks it for `/offline` first; `Restart=always`), and `onionpress.service` is a oneshot that stays active (`RemainAfterExit`): the launcher's `start` brings the containers up, runs `daemon start` (`daemon_client`'s systemd half: `systemd_unit(data_dir)`, derived per data dir like the launchd label; `--no-block`) and returns. `stop` and `restart` run `daemon offline` before `stop_containers`; an update stops the daemon `--no-offline` and starts it again. No heartbeat client, no bash status loop, no watcher timer, no system-sleep hook, no `write-status` one-shot: the .deb, `install.sh` and the launcher's uninstall retire the old units (`onionpress-heartbeat.service`, `onionpress-watcher.timer`) and lift their runtime mask. `tests/test_linux_daemon.py`, `tests/test_deb_upgrade_restarts.py`, `tests/test_launcher_dispatch_linux.py`, `tests/test_linux_install_tree.py`. - **The menubar performs no operation itself**: every action is an `onionpress …` subcommand run through the launcher; it imports none of `backup`, `hub_backup`, `updater`. A new menu action gets its subcommand first. `tests/test_menubar_actions.py`. - **Neither UI runs a program of its own** (#547) beyond `UI_SUBPROCESS_ALLOWED` (desktop openers, the menubar's pid-file boot time and relaunch, the tray's uninstall terminal): the bundle's admin-group sharing is `updater.share_bundle_with_admins` (the staged copy, and once per Mac daemon start), the host cloudflared check the daemon's on both platforms (`host_cloudflared` in its state, shown via `ui_client.host_cloudflared_notice`), the tray's Quit the Linux launcher's `quit --leave-tray`. `TestTheMenubarRunsNoOperation` in `tests/test_menubar_actions.py`, `TestTheTrayRunsNoOperation` and `TestQuit` in `tests/test_launcher_dispatch_linux.py`, `tests/test_host_cloudflared.py`. - **The menubar and the Linux tray are widgets of one client** (#526, `src/onionpress/ui_client.py`: stdlib + `confirm` + `launcher_ops`, so the menubar's docker-free import closure holds): both call the launcher only through `ui_client.Launcher` (secrets in the environment, stdin /dev/null) and read its answers only through `run_json` / `run_confirmed` (the `--check --json` → ask → `--confirmed` loop), `plan_backup` / `run_backup`, `plan_restore` / `run_restore` / `restore_summary` (the dialogs an object the UI supplies: `BackupUI`, `RestoreUI`) and `update_outcome`; no `subprocess` argv in either names the launcher. The tray imports the package hard (the .deb installs it beside the tray): no degraded fallbacks. `tests/test_ui_client.py`. - **Both UIs paint `ui_client.status_view(state, now)`**: category, title, icon and the actions it enables from daemon-state.json; a state older than 90 s or from a dead pid is "reconnecting", never its last word (the tray recomputes it every tick). The tray's log gets the menubar's redaction (home as `~`, `redact.make_scrub_fn` on rolled files). `TestStatusView` in `tests/test_ui_client.py`, `tests/test_linux_tray_protocol.py`. - **The daemon's onionname retry is `onionpress onionname set`** (run through `run_cli`, no registration of its own), and there is one name rule, `onionnames_client.validate_name` (`setup_logic.validate_onionname` and config's ONIONNAME check are it). `TestOnionnameRetry` in `tests/test_mac_daemon_paths.py`, `tests/test_onionname_as_data.py`. - launchd agent `org.onionpress.daemon` (`KeepAlive` on failure only, `AbandonProcessGroup`); `onionpress daemon start|stop|status|offline`. The launcher's `stop` runs `daemon offline` before stopping containers; `quit` runs `daemon stop` first. - **Labels are per data dir** (#505: launchd's gui domain is per user, and a second install on the account took the real daemon for its own): `daemon_client.data_dir_label` keeps `org.onionpress.daemon` / `com.onionpress.launcher` for the account's own `~/.onionpress` (`default_data_dir`, the passwd home, never `$HOME`) and derives `