chief runs as its own system user, with its own Apple ID (#286) - #288
Conversation
`imessage.mode` picks which Apple ID chief speaks as. `self` (default) is today's install, untouched. `dedicated` is chief on its own Apple ID in its own user session, and turns off the four self-DM compensations together: * the self-chat query scope — by polling with an EMPTY scope, never by repointing it at the owner's handle, which is now chief's real conversation with them and would poll chief's own replies back as owner input; * the BOT_PREFIX stamp on replies and its inbound filter; * the twin-row dedup (a self-DM artefact of one account writing both rows); * the out-of-band imsg/osascript send guard. `owner_handles` keeps its meaning in both modes. A typo in `mode` is refused at boot rather than read as `self`, which would leave the echo machinery on for a chief that has its own Apple ID. The central mechanism per the PRD: the adapter in dedicated mode driven through the real poll query against a real SQLite store, round-tripping an owner message into a reply that does not re-enter. Docs updated (CONFIG, LIFECYCLE, config.default.yaml). Prose trimmed in imessage.py to stay under the 200-line cap; the detail lives in LIFECYCLE.md and SECURITY.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
…286) Pure, pinned plan: description + exact argv + whose authority each step needs. Covers both platforms (sysadminctl/dseditgroup, useradd/groupadd/ usermod/loginctl), the shared-group + setgid tree, the secrets carve-out, chief's git identity and the owner's safe.directory trust. The password never enters an argv — pinned commands get printed, logged and diffed. It rides a separate stdin field instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
fdesetup parse decides the mechanism. Unencrypted Mac → the supported sysadminctl auto-login path (password via stdin, never argv). Encrypted, or an unreadable state → screen-sharing enablement plus the documented reconnect-after-reboot step; unknown takes the encrypted branch because auto-login on an encrypted disk silently does nothing. Linux needs none. password_conflict() covers macOS refusing auto-login when the login and Apple ID passwords match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
…286) macOS auto-login breaks silently after an OS update, so the state is shown rather than inferred from chief's silence. read_posture() probes fdesetup, autoLoginUser and the gui/<uid> domain and names the failure modes; on Linux it probes nothing. Surfaced in both required places: `chief status` prints it, and the web statusbar polls /posture beside /monitors. Both routes moved into web/status_routes.py — app.py was one line under the cap. account.py split: the Step primitive and the OS-divergent account/group commands now live in install/account_steps.py (249 lines → 140 + 123). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
`chief account` (new subcommand) offers the three cases — create / use an existing account / decline — asks the two file-grant questions (default none, home root refused), prints the whole plan, and runs it under sudo, stopping at the first failure. A non-interactive run refuses outright rather than adding a system user unattended; declining anywhere lands on exactly today's single-user install. Taking the account flips imessage.mode to `dedicated` in config.yaml and writes a key=value report install.sh branches on. install.sh: --single-user, the account offer after the wizard (so wizard-written secrets are inside the chown), and in dedicated mode the service definition is written into chief's home unstarted — launchd cannot bootstrap into a session that does not exist yet — with the remaining human steps printed instead of a health wait. uninstall now asks about the system account, with --remove-account / --keep-account as the scripted answers. Its home holds chief's message store, so keeping is the default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
Dedicated mode polls both, so the owner's existing monitors keep working after chief moves to its own Apple ID. Two config keys: imessage.owner_db_path and imessage.self_handles. The trap this closes: chief's reply lands in the OWNER's store as an ordinary is_from_me = 0 row from chief's handle, and dedicated mode has already turned off BOT_PREFIX — so polling both stores unfiltered re-creates the echo loop by another route. Rows from chief's own handles are dropped from the owner's store, mutation-checked. Rowids are per-store, so each store carries its own cursor (a shared one swallows everything below the higher rowid across a restart — also mutation-checked). To fit the 200-line cap: the per-thread FIFO machinery moved to adapters/imessage_fifo.py, and the cursor + per-store position to adapters/imessage_cursor.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
…#286) Documentation is part of the change, not a follow-up — every doc describing a single-user install was stale the moment the account landed. - OPERATIONS: the account flow (interactive-only, three cases, sudo on the terminal), why the install ends without starting the daemon, the boot check, the new `chief account` command and the uninstall account flags, and the full migration checklist with its explicit abort path. - SECURITY: a new "account boundary" section — what it buys, and the two things it deliberately does not cover (no escalation ever; read access to the owner's message store, stated plainly). Three invariants added. - CONFIG / LIFECYCLE: the dual-store keys and the echo trap they close; the poll walk over stores, ThreadFifo and Store module moves. - README / CLAUDE / ARCHITECTURE: the posture is now the headline of what chief runs as. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
…ount` The launcher's dispatch list and USAGE, install.sh's --help range (the header grew), install.sh's own description, and bootstrap.sh's flag passthrough all needed to know about the account step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
#288) The dedicated install path was never executed end-to-end: the e2e workflow runs --non-interactive, which skips it entirely. Four of these would abort or silently mis-install on real hardware. - The account plan chowns the tree to chief while the installer is still writing into it, and group membership does not reach an already-running process. config.yaml and the report install.sh branches on now land before the first permission step; a failed step aborts the installer, which never reads either file. - The launchd/systemd definition is written BY chief, under `sudo -u`: chief's home is not owner-writable (macOS ~/Library is 0700) and the compensating chown ran after the write it was meant to enable. - Guard the traversal the tree and launcher need from chief's side — they sit under the owner's home, which is 0700 on distros honouring HOME_MODE. - `grep -qE`: BSD grep does not understand BRE alternation, and a silent no-match installed the service into the owner's account instead. - `chief status` probes chief's account, not whoever typed it — the owner is the only one who ever runs it, so it reported their session as chief's. - Reject `owner_db_path` set without `self_handles`: that handle list is the only thing stopping chief from reading its own replies back out of the owner's store, with BOT_PREFIX already off. - An unreadable owner store costs chief that store, not the daemon and the web UI with it; the migration checklist now names the two grants it needs. - An existing account keeps its real home instead of a guessed /Users/<user>. - read_posture off the event loop: three subprocesses every 10s per tab. - `problems()` treats unknown encryption as encrypted, like session_plan. - The mode flip anchors on the `imessage:` block, not the file's first `mode:` key; it moved to config/write.py next to the other config writer. - SECURITY.md invariant 12 says plainly that the boundary is accident-scoped: the launcher and git run chief-authored code as the owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
|
Review pass ( Fixed on this branch (11):
Finding 3 (high) — decided, not deferred. The launcher runs chief-authored code from a chief-writable tree as the owner, which the previous invariant-12 wording did not admit. Filed, not fixed: #289 ( Mock-drift audit: clean, no Done-check green: 785 passed, ruff clean, mypy clean, shellcheck clean. New regression tests pin the write-ordering (asserts |
start() leaves the poll loop running, so the explicit poll_once() that follows raced its first tick — both fetched the same row before either advanced the cursor, and the message was delivered twice. Green locally, red on CI. Stop the adapter before polling by hand, which is what the other tests in this file already do. Test-only: nothing calls poll_once() concurrently in production, where _poll_loop is the sole caller and runs serially. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
Builds PRD #286 end to end. Seven commits, done-check green throughout
(777 passed, ruff clean, mypy clean).
What's here
79d920eimessage.modeswitch —self(default, untouched) /dedicated, turning off all four self-DM compensations5709768install/account.py+account_steps.py— the account plan as pure data, every argv pinned byte-for-byteba2bb65install/session.py—fdesetup→ auto-login or the documented screen-share reconnectc701ab6install/posture.py— the boot check, surfaced bychief statusand the web statusbar06913d8install/dedicated.py+dedicated_ask.py,chief account, install.sh integration, uninstall account flags143b2cbfeb0b388670117Worth a close look
The dual-store echo trap. Chief's own reply lands in the owner's store as
an ordinary
is_from_me = 0row from chief's handle — and dedicated mode hasalready turned off the
BOT_PREFIXthat used to catch exactly this. Pollingboth stores unfiltered rebuilds the echo loop by another route.
imessage.self_handlesdrops those rows from that store only. Each store alsocarries its own cursor, because rowids are per-store and a shared one swallows
everything below the higher rowid across a restart. Both guards are
mutation-checked — removing either fails its test.
The tree does not move.
install.shpasses the actual repo dir as--tree;/opt/chiefstays the documented default. Relocating a live tree isa migration step run by a human, not something the installer should do
underneath itself while running from inside it. The PRD reads as though the
installer places the tree — flagging the deviation rather than burying it.
Secrets carve-out ordering.
chmod -R go-rwx secrets/runs after thegroup sweep. Reversed, the sweep re-opens what it just closed. There is a test
pinning the order for that reason.
Line-cap pressure shaped the diff.
commands.py,wiring.py,account.pyand
app.pyall sit a few lines under the CI-enforced 200, so several commitshad to shed or split to fit:
web/status_routes.py,install/account_steps.py,adapters/imessage_fifo.pyandadapters/imessage_cursor.pyare all extractions,not new concepts.
Testing
Per the PRD's decision: the central mechanism (dedicated-mode round trip through
the real poll query against a real SQLite store, proving the reply does not
re-enter) plus every generated installer artifact pinned byte-for-byte. The
dual-store guards, the home-root grant refusal and the failed-step abort are
each mutation-checked.
Not done — the real-hardware gate
Step 7 of the PRD is a human-run checklist, not CI, and it is stated as an
acceptance gate rather than pretended into a suite. The feature is not done
until the mini is migrated and six things pass by hand: web UI reachable,
inbound from the owner's phone to chief's new address, outbound reply, a
self-edit commit under chief's identity, a scheduled task firing, and survival
of one full reboot.
docs/OPERATIONS.mdhas the checklist and the abort path.Two things will complicate that run: #285 is merged to main but not yet
deployed to the mini (deploying it trips the boot sweep that disables its
existing unscoped classifier monitors — intended, but read the log and rescope
them), and the mini's iMessage send path is pre-existing broken
(
osascript -1700), which will muddy verification of the outbound reply.Closes #286 once that gate passes.
🤖 Generated with Claude Code
https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc