From 79d920efda4ac4067548c9d054f3f0fbead09fab Mon Sep 17 00:00:00 2001 From: William Chastain Date: Tue, 28 Jul 2026 18:51:25 -0700 Subject: [PATCH 01/10] feat(imessage): dedicated-Apple-ID mode switch (#286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc --- config.default.yaml | 7 ++- docs/CONFIG.md | 9 ++++ docs/LIFECYCLE.md | 8 +++ src/chief/adapters/imessage.py | 79 ++++++++++++++-------------- src/chief/adapters/imessage_store.py | 9 ++-- src/chief/app.py | 2 +- src/chief/config/coerce.py | 17 ++++++ src/chief/config/load.py | 1 + src/chief/config/schema.py | 15 ++++++ src/chief/wiring.py | 2 +- tests/test_config.py | 18 +++++++ tests/test_imessage.py | 72 +++++++++++++++++++++++++ 12 files changed, 194 insertions(+), 45 deletions(-) diff --git a/config.default.yaml b/config.default.yaml index d06934e6..6ac9702f 100644 --- a/config.default.yaml +++ b/config.default.yaml @@ -92,12 +92,17 @@ hooks: disabled: [] # iMessage adapter (macOS only; configured by the build-imessage package). -# owner_handles: the self-chat handle(s) โ€” owner texts run turns, everyone +# owner_handles: the owner's handle(s) โ€” owner texts run turns, everyone # else is logged + published for monitors. +# mode: which Apple ID chief speaks as. self = the owner's own (today's +# install; chief is texted through the self-chat and compensates for it with +# the ๐Ÿค– prefix, twin dedup, self-chat scope and out-of-band send guard). +# dedicated = chief's own Apple ID in its own user session; all four are off. imessage: enabled: false owner_handles: [] poll_seconds: 2 + mode: self # Per-channel live stream policy. Every turn always emits its coarse activity # tick (and, for non-web threads, the rich inbound/final); this only governs diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 74e2e4d7..33a9f838 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -76,6 +76,7 @@ template line. | `imessage.owner_handles` | `imessage_owner_handles` | `tuple` | `()` | โ€” | | `imessage.db_path` | `imessage_db_path` | `Path` | `~/Library/Messages/chat.db` | โ€” | | `imessage.poll_seconds` | `imessage_poll_seconds` | `float` | `2.0` | โ€” | +| `imessage.mode` | `imessage_mode` | `str` | `self` | โ€” | | `compaction.ratio` | `compaction_ratio` | `float` | `0.95` | โ€” | | `compaction.keep_recent` | `compaction_keep_recent` | `int` | `20` | โ€” | | `compaction.default_window` | `compaction_default_window` | `int` | `60000` | โ€” | @@ -93,6 +94,14 @@ Notes on specific keys: closed and no listener is built. - **`gate.approved` accepts `"*"`** to approve every tool; `gate.never` still wins. - **`imessage.enabled` also requires `sys.platform == "darwin"`.** +- **`imessage.mode` picks which Apple ID chief speaks as** โ€” `self` (default, + today's install: the owner's own, chief texted through the self-chat) or + `dedicated` (chief's own Apple ID in its own user session). `dedicated` turns + off all four self-DM compensations at once: the self-chat query scope, the + ๐Ÿค– prefix on replies and its inbound filter, the twin-row dedup, and the + out-of-band `imsg`/`osascript` send guard. A typo is refused at boot, not read + as `self`. `owner_handles` keeps its meaning in both modes โ€” it is still who + chief answers as the owner. - **`quiet_hours` is `"HH:MM-HH:MM"`** and may span midnight; prompt-waking schedule fires inside the window defer to its end (command schedules run silently and are never deferred). diff --git a/docs/LIFECYCLE.md b/docs/LIFECYCLE.md index 142e2aca..0ade66e7 100644 --- a/docs/LIFECYCLE.md +++ b/docs/LIFECYCLE.md @@ -58,6 +58,14 @@ All converge on `Dispatcher.handle` (`dispatch.py`). `restart.fire_if_requested()` *after* the turn โ€” safe, because the cursor is already durable. +Steps 3, 5 and 6 above describe `imessage.mode: self` โ€” chief on the owner's +Apple ID. Under `mode: dedicated` (chief's own Apple ID, own user session) the +poll runs with an **empty** self-chat scope, so only `is_from_me = 0` rows +qualify; the `BOT_PREFIX` skip and `RecentDedup` are both bypassed, and +`send()` stamps no prefix. The scope is turned off, never repointed at the +owner's handle: that chat is chief's real conversation with the owner, so +scoping it would poll chief's own replies back as owner input. + ### Socket / CLI โ€” `SocketAdapter` (`adapters/socket.py`), name `cli` Unix socket at `config.socket_path`, newline-delimited JSON. In `{thread, text}`, diff --git a/src/chief/adapters/imessage.py b/src/chief/adapters/imessage.py index 0d096731..cf427e62 100644 --- a/src/chief/adapters/imessage.py +++ b/src/chief/adapters/imessage.py @@ -1,21 +1,21 @@ """iMessage adapter: a dumb pipe over the local Messages store, macOS-only. -Registration is guarded by ``sys.platform == "darwin"`` in app wiring; the -module itself is platform-neutral so tests drive it anywhere with a fake -chat.db and a fake send runner. - -Inbound is a poll loop over ``chat.db`` with a persisted rowid cursor advanced -at read: each row is dispatched onto its thread's FIFO worker so a slow turn on -one thread never stalls another, and turns run at most once (a hard crash -mid-turn drops that row rather than replaying it; graceful self-edit restarts -drain in-flight turns first). Same-account self-DM posture: chief runs on the -owner's own Apple ID, so self-chat texts carry ``is_from_me = 1``. A row is -delivered when it is a real inbound (``is_from_me = 0``) OR sits in the owner's -self-chat; self-chat rows map to sender ``owner``, strangers pass through -as-is. Replies to owner handles carry BOT_PREFIX: chief's own send re-enters as -an ``is_from_me = 1`` self-chat row, the prefix its sole echo filter; twin-row -dedup and the query scope live in ``imessage_store``, the JXA send path and the -out-of-band send guard in ``imessage_send``. +Registration is darwin-gated in app wiring; the module is platform-neutral so +tests drive it with a fake chat.db and send runner. Inbound is a poll loop with +a persisted rowid cursor advanced at read, each row onto its thread's FIFO +worker, turns at most once (docs/LIFECYCLE.md). + +Two postures, per config ``imessage.mode``. ``self`` (default): chief shares +the owner's Apple ID, so the owner's texts to it are ``is_from_me = 1`` rows in +the self-chat, and four mechanisms compensate โ€” the self-chat query scope and +twin-row dedup (``imessage_store``), the BOT_PREFIX stamped on replies and +filtered on the way back, the out-of-band send guard (``imessage_send``). +``dedicated``: chief holds its own Apple ID in its own user session, the owner +is an ordinary correspondent, and all four switch off (the guard, in +``app.py``; see :meth:`IMessageAdapter._map` for delivery). The scope is +turned *off*, never repointed at the owner's handle โ€” that chat is now chief's +real conversation with them, so scoping it would poll chief's own replies back +as owner input. """ import asyncio @@ -58,24 +58,24 @@ def __init__( run_jxa: RunJxa = run_jxa_subprocess, restart: RestartBoundary | None = None, resolve_approval: Callable[[Message], bool] | None = None, + dedicated: bool = False, ) -> None: self._on_message = on_message self._db_path = db_path self._cursor_store = RowCursor(cursor_path) self._owner_handles = frozenset(owner_handles) + self._dedicated = dedicated self._poll_seconds = poll_seconds self._run_jxa = run_jxa self._restart = restart - # Drain an approval answer at the poll stage, ahead of the per-thread - # FIFO worker: a gated turn suspends its worker awaiting the owner's - # answer, so that answer must bypass the worker or it deadlocks the - # whole thread until the card times out (fail-closed deny). + # Drained at poll stage, ahead of the FIFO worker: a gated turn suspends + # its worker awaiting the answer, so the answer must bypass the worker + # or it deadlocks that thread until the card times out. self._resolve_approval = resolve_approval self._cursor = 0 self._dedup = RecentDedup() self._task: asyncio.Task[None] | None = None - # One FIFO queue + worker per thread: a slow turn on one thread can't - # stall another's, while same-thread turns stay serialized in order. + # One FIFO queue + worker per thread: parallel across, ordered within. self._queues: dict[str, asyncio.Queue[Message]] = {} self._workers: dict[str, asyncio.Task[None]] = {} @@ -100,7 +100,7 @@ async def stop(self) -> None: self._queues.clear() async def send(self, thread_key: str, text: str) -> None: - if thread_key in self._owner_handles: + if not self._dedicated and thread_key in self._owner_handles: text = BOT_PREFIX + text await self._run_jxa(SEND_TEXT_SCRIPT, (thread_key, text)) @@ -116,28 +116,31 @@ async def poll_once(self) -> None: """One poll tick: enqueue new rows onto their thread's worker, never awaiting a turn โ€” so one slow/hung turn can't stall the poll loop. - The cursor advances and is persisted at READ (before the turn runs), so - the row can't be re-polled and answered twice (at-most-once): a hard - crash between enqueue and reply drops that row rather than replaying it; - graceful self-edit restarts still drain in-flight turns first.""" + The cursor is persisted at READ, before the turn runs: a crash drops + that row rather than answering it twice (at-most-once); graceful + self-edit restarts drain instead. Dedicated mode polls with an EMPTY + scope, so the self-chat clause matches nothing and only real inbound + rows qualify.""" + scope = frozenset() if self._dedicated else self._owner_handles rows = await asyncio.to_thread( - fetch_rows, self._db_path, self._owner_handles, self._cursor + fetch_rows, self._db_path, scope, self._cursor ) for rowid, sender, text, from_me, group_chat, in_self, date in rows: self._cursor = rowid self._cursor_store.save(rowid) message = self._map(sender, text, from_me, group_chat, in_self) - if message is None or self._dedup.is_duplicate( + if message is None: + continue + if not self._dedicated and self._dedup.is_duplicate( (message.thread_key, message.sender, message.text), date ): - continue + continue # twin rows are a self-DM artefact only if self._resolve_approval is not None and self._resolve_approval(message): continue # answered a pending card โ€” bypass the FIFO worker self._enqueue(message) def _enqueue(self, message: Message) -> None: - """Route a message to its thread's FIFO queue, spawning a worker the - first time that thread is seen.""" + """Route to the thread's FIFO queue; first sighting spawns its worker.""" queue = self._queues.get(message.thread_key) if queue is None: queue = asyncio.Queue() @@ -179,17 +182,15 @@ def _map( monitors, never answered. Which groups matter is monitor policy.""" if not text: return None # attachment-only row: attributedBody held no text - if text.startswith(BOT_PREFIX): + if text.startswith(BOT_PREFIX) and not self._dedicated: return None # chief's own reply echoing back through the store if from_me and not in_self: return None # owner->friend sent copy: not the self-chat if group_chat is not None: - # Raw sender, never "owner", even for an owner handle: sender - # "owner" takes the dispatcher's owner path, which runs a turn and - # replies to thread_key โ€” and a group thread_key is a chat the - # one-to-one send path cannot address. It also means nobody in a - # group can present as the owner; group text is uniformly - # untrusted, and owner instructions arrive only via the self-chat. + # Raw sender, never "owner", even for an owner handle: that would + # take the dispatcher's owner path, replying to a group thread_key + # the one-to-one send path cannot address โ€” and would let anyone in + # the group present as the owner (docs/SECURITY.md). return Message( channel=self.name, sender=sender, thread_key=group_chat, text=text ) diff --git a/src/chief/adapters/imessage_store.py b/src/chief/adapters/imessage_store.py index ce924015..136fc0e1 100644 --- a/src/chief/adapters/imessage_store.py +++ b/src/chief/adapters/imessage_store.py @@ -126,11 +126,14 @@ def text_of(text: object, body: object) -> str: def fetch_rows( - db_path: Path, owner_handles: frozenset[str], after: int + db_path: Path, scope_handles: frozenset[str], after: int ) -> list[PolledRow]: """Run :data:`POLL_QUERY` read-only and coerce the rows (sync; callers - thread it off the loop).""" - handles = tuple(owner_handles) + thread it off the loop). + + ``scope_handles`` are the self-chat identifiers; empty (dedicated mode) + turns that scope off, leaving only real inbound rows.""" + handles = tuple(scope_handles) scope = ",".join("?" for _ in handles) if handles else "NULL" query = POLL_QUERY.format(scope=scope) params: tuple[object, ...] = (*handles, after, POLL_BATCH_LIMIT) diff --git a/src/chief/app.py b/src/chief/app.py index 910c293d..7bbe9f58 100644 --- a/src/chief/app.py +++ b/src/chief/app.py @@ -146,7 +146,7 @@ async def build_app(config: Config, provider: Provider | None = None) -> App: output_limit=config.shell_output_limit, ) # Echo-loop seatbelt on BOTH shell paths: the tool and cron's runner. - shell_guards = (owner_send_guard(config.imessage_owner_handles),) + shell_guards = (owner_send_guard(config.echo_guarded_handles),) core = await _build_agent_core( config, provider, store, factory, gate, skills, shell_service, shell_guards ) diff --git a/src/chief/config/coerce.py b/src/chief/config/coerce.py index 5126d19e..6ebbfa41 100644 --- a/src/chief/config/coerce.py +++ b/src/chief/config/coerce.py @@ -14,6 +14,7 @@ from chief.policy import DEFAULT_CHANNEL_DEFAULTS, StreamPolicy AUTONOMY_VALUES = ("off", "clean-only", "full") +IMESSAGE_MODES = ("self", "dedicated") def read_secret(path: Path) -> str: @@ -71,6 +72,22 @@ def autonomy(value: Any) -> str: return parsed +def imessage_mode(value: Any) -> str: + """Coerce ``imessage.mode``; refuse anything outside the two postures. + + A typo silently reading as ``self`` would leave the echo machinery on for + a chief that has its own Apple ID โ€” replies stamped ๐Ÿค– and a scope that + can't see them โ€” so it fails the boot instead. + """ + parsed = str(value).strip() + if parsed not in IMESSAGE_MODES: + raise ConfigError( + f"imessage.mode must be {' or '.join(IMESSAGE_MODES)} โ€” " + f"got {parsed!r}" + ) + return parsed + + def backends(raw: dict[str, Any]) -> dict[str, BackendSpec]: return { name: BackendSpec(base_url=str(spec["base_url"]), api_key=_backend_key(spec)) diff --git a/src/chief/config/load.py b/src/chief/config/load.py index fa5b6b0d..acf590f6 100644 --- a/src/chief/config/load.py +++ b/src/chief/config/load.py @@ -112,6 +112,7 @@ def load_config(path: Path = Path("config.yaml")) -> Config: imessage.get("db_path") or Path.home() / "Library/Messages/chat.db" ), imessage_poll_seconds=float(imessage.get("poll_seconds", 2.0)), + imessage_mode=coerce.imessage_mode(imessage.get("mode", "self")), compaction_ratio=coerce.ratio(compaction.get("ratio", 0.95)), compaction_keep_recent=int(compaction.get("keep_recent", 20)), compaction_default_window=int(compaction.get("default_window", 60_000)), diff --git a/src/chief/config/schema.py b/src/chief/config/schema.py index 7a6c4ad5..a7c3f001 100644 --- a/src/chief/config/schema.py +++ b/src/chief/config/schema.py @@ -97,6 +97,11 @@ class Config: default_factory=lambda: Path.home() / "Library/Messages/chat.db" ) imessage_poll_seconds: float = 2.0 + # Which Apple ID chief speaks as. "self" (default, today's install): the + # owner's own, so chief compensates for the shared self-chat โ€” bot prefix, + # twin dedup, self-chat query scope, out-of-band send guard. "dedicated": + # chief's own Apple ID in its own user session; all four switch off. + imessage_mode: str = "self" # Context compaction: fold old history into a summary note when a thread's # transcript nears the model's context window. Threshold = ratio * window; # the window is the thread's *current* model's, resolved per turn (config @@ -125,3 +130,13 @@ class Config: @property def default_model(self) -> str: return self.models["default"] + + @property + def imessage_dedicated(self) -> bool: + return self.imessage_mode == "dedicated" + + @property + def echo_guarded_handles(self) -> tuple[str, ...]: + """Handles the out-of-band send guard covers โ€” none in dedicated mode, + where texting the owner is an ordinary send that never polls back.""" + return () if self.imessage_dedicated else self.imessage_owner_handles diff --git a/src/chief/wiring.py b/src/chief/wiring.py index 77175701..2aca1c92 100644 --- a/src/chief/wiring.py +++ b/src/chief/wiring.py @@ -180,7 +180,7 @@ def build_adapters( cursor_path=config.db_path.parent / "imessage_cursor", owner_handles=config.imessage_owner_handles, poll_seconds=config.imessage_poll_seconds, - restart=core.restart, + restart=core.restart, dedicated=config.imessage_dedicated, # Consume approvals at poll stage, ahead of the thread FIFO worker. resolve_approval=dispatcher.resolve_approval, ) diff --git a/tests/test_config.py b/tests/test_config.py index 314e94a9..476ba5b0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -327,6 +327,24 @@ def test_bare_off_survives_yamls_boolean_reading(tmp_path: Path) -> None: load_config(path) +def test_imessage_mode_defaults_to_todays_self_dm_posture(tmp_path: Path) -> None: + """Existing installs must be untouched by the dedicated-account work.""" + assert load_config(tmp_path / "none.yaml").imessage_mode == "self" + path = tmp_path / "config.yaml" + path.write_text("imessage:\n mode: dedicated\n") + assert load_config(path).imessage_mode == "dedicated" + + +def test_a_typo_in_imessage_mode_is_refused_not_coerced(tmp_path: Path) -> None: + """Reading a typo as `self` would leave the echo machinery on for a chief + that has its own Apple ID โ€” ๐Ÿค–-stamped replies and a scope for a chat that + isn't its own.""" + path = tmp_path / "config.yaml" + path.write_text("imessage:\n mode: dedicted\n") + with pytest.raises(ConfigError, match="imessage.mode"): + load_config(path) + + def test_a_typo_in_update_autonomy_is_refused_not_coerced(tmp_path: Path) -> None: """Neither "silently off" nor "silently on" is an acceptable reading of a typo in the key that governs unattended conflict resolution.""" diff --git a/tests/test_imessage.py b/tests/test_imessage.py index 2cb3c9d6..1fd90b90 100644 --- a/tests/test_imessage.py +++ b/tests/test_imessage.py @@ -119,6 +119,9 @@ def __init__(self, tmp_path: Path) -> None: self.on_deliver: Callable[[Message], Awaitable[None]] | None = None # Optional poll-stage approval resolver (dispatcher.resolve_approval). self.resolve_approval: Callable[[Message], bool] | None = None + # imessage.mode = dedicated: chief on its own Apple ID, so the four + # self-DM compensations are off. + self.dedicated = False def adapter(self) -> IMessageAdapter: async def on_message(message: Message) -> None: @@ -138,6 +141,7 @@ async def run_jxa(script: str, argv: tuple[str, ...]) -> str: run_jxa=run_jxa, restart=self.restart, resolve_approval=self.resolve_approval, + dedicated=self.dedicated, ) @@ -309,6 +313,74 @@ async def test_self_chat_scope_does_not_leak_other_conversations( ] +# --- dedicated mode: chief on its own Apple ID ---------------------------- + + +async def test_dedicated_mode_round_trip_reply_does_not_re_enter( + tmp_path: Path, +) -> None: + """The central mechanism. On its own Apple ID chief's chat with the owner + is an ordinary conversation whose chat_identifier IS the owner's handle: + the owner's texts arrive is_from_me=0, chief's replies are is_from_me=1 in + that same chat. So the self-chat scope must be OFF, not repointed at the + owner โ€” repointed, every reply below would poll straight back as owner + input and loop. Driven through the real query against a real store.""" + harness = Harness(tmp_path) + harness.dedicated = True + harness.store.add_message(OWNER, "hi chief", from_me=0, chat=OWNER) + adapter = harness.adapter() + await adapter.poll_once() + await adapter.drain() + assert [(m.sender, m.thread_key, m.text) for m in harness.delivered] == [ + ("owner", OWNER, "hi chief"), + ] + + await adapter.send(OWNER, "hello back") + assert harness.jxa_calls[0][1] == (OWNER, "hello back") # no BOT_PREFIX + + # Chief's own reply as its store records it, then a second poll. + harness.store.add_message(OWNER, "hello back", from_me=1, chat=OWNER) + await adapter.poll_once() + await adapter.drain() + assert [m.text for m in harness.delivered] == ["hi chief"] + + +async def test_dedicated_mode_drops_the_self_dm_compensations( + tmp_path: Path, +) -> None: + """Prefix filter and twin dedup are self-DM artefacts: in dedicated mode a + real owner message that happens to start with ๐Ÿค– must run a turn, and two + quick identical texts are two messages, not one row recorded twice.""" + harness = Harness(tmp_path) + harness.dedicated = True + harness.store.add_message(OWNER, BOT_PREFIX + "robot emoji", chat=OWNER) + harness.store.add_message(OWNER, "ok", chat=OWNER, date=100) + harness.store.add_message(OWNER, "ok", chat=OWNER, date=100) + adapter = harness.adapter() + await adapter.poll_once() + await adapter.drain() + assert [m.text for m in harness.delivered] == [ + BOT_PREFIX + "robot emoji", "ok", "ok", + ] + + +async def test_dedicated_mode_still_ignores_owner_sends_to_others( + tmp_path: Path, +) -> None: + """Chief reads the owner's store too (monitors), so its own poll sees the + owner's outbound copies. Those are not messages to chief.""" + harness = Harness(tmp_path) + harness.dedicated = True + harness.store.add_message( + "+15554443333", "hey friend", from_me=1, chat="+15554443333" + ) + harness.store.add_message("+15559998888", "yo from a stranger") + adapter = harness.adapter() + await adapter.poll_once() + await adapter.drain() + assert [m.text for m in harness.delivered] == ["yo from a stranger"] + + async def test_cursor_persists_across_restarts(tmp_path: Path) -> None: harness = Harness(tmp_path) harness.store.add_message(OWNER, "first") From 57097688f431f5f0db96e66ef2bc8d6922d20461 Mon Sep 17 00:00:00 2001 From: William Chastain Date: Tue, 28 Jul 2026 19:01:17 -0700 Subject: [PATCH 02/10] =?UTF-8?q?feat(install):=20account=20plan=20?= =?UTF-8?q?=E2=80=94=20every=20step=20to=20give=20chief=20its=20own=20user?= =?UTF-8?q?=20(#286)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc --- src/chief/install/account.py | 249 +++++++++++++++++++++++++++++++++++ tests/test_install.py | 89 +++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 src/chief/install/account.py diff --git a/src/chief/install/account.py b/src/chief/install/account.py new file mode 100644 index 00000000..26e9e8ef --- /dev/null +++ b/src/chief/install/account.py @@ -0,0 +1,249 @@ +"""What the installer must run to give chief its own system account. + +A pure plan: each step is a description plus the exact argv, so the tests pin +every generated command byte-for-byte and the installer can print the whole +plan before touching the machine. Passwords never appear in argv โ€” they travel +in ``stdin``, the one field the pinned commands do not carry. + +``run_as`` says whose authority a step needs: ``None`` is the owner running the +installer, ``"root"`` escalates, and chief's own name is used for the steps +that must land in chief's home (its git identity). +""" + +from dataclasses import dataclass +from pathlib import Path + +DEFAULT_USER = "chief" +DEFAULT_GROUP = "chief" +DEFAULT_TREE = Path("/opt/chief") +DEFAULT_EMAIL = "chief@localhost" +SECRETS_DIRNAME = "secrets" + +__all__ = [ + "DEFAULT_EMAIL", + "DEFAULT_GROUP", + "DEFAULT_TREE", + "DEFAULT_USER", + "AccountPlan", + "Step", + "account_plan", + "default_home", +] + + +@dataclass(frozen=True) +class Step: + """One command the installer runs, and whose authority it needs.""" + + description: str + argv: tuple[str, ...] + run_as: str | None = None + stdin: str | None = None + + @property + def privileged(self) -> bool: + return self.run_as == "root" + + def command(self) -> tuple[str, ...]: + """The argv as actually invoked, escalation prefix included.""" + if self.run_as is None: + return self.argv + if self.run_as == "root": + return ("sudo", *self.argv) + return ("sudo", "-u", self.run_as, *self.argv) + + +@dataclass(frozen=True) +class AccountPlan: + """The account chief will run as, and every step to get there.""" + + user: str + group: str + home: Path + tree: Path + steps: tuple[Step, ...] + + +def default_home(platform: str, user: str) -> Path: + return Path("/Users" if platform == "darwin" else "/home") / user + + +def _create_steps( + platform: str, user: str, home: Path, password: str +) -> tuple[Step, ...]: + if platform == "darwin": + return ( + Step( + # Deliberately not -admin: chief logs in graphically, and an + # admin chief would be root-equivalent via its shell tool. + f"create the {user} account (non-admin)", + ( + "sysadminctl", + "-addUser", + user, + "-fullName", + user, + "-home", + str(home), + "-shell", + "/bin/zsh", + "-password", + "-", + ), + run_as="root", + stdin=f"{password}\n", + ), + ) + return ( + Step( + f"create the {user} account", + ( + "useradd", + "--create-home", + "--home-dir", + str(home), + "--shell", + "/bin/bash", + user, + ), + run_as="root", + ), + Step( + f"set {user}'s login password", + ("chpasswd",), + run_as="root", + stdin=f"{user}:{password}\n", + ), + ) + + +def _group_steps( + platform: str, user: str, group: str, owner: str +) -> tuple[Step, ...]: + if platform == "darwin": + add = ( + Step( + f"add {member} to the shared group", + ("dseditgroup", "-o", "edit", "-a", member, "-t", "user", group), + run_as="root", + ) + for member in (user, owner) + ) + return ( + Step( + "create the shared group", + ("dseditgroup", "-o", "create", group), + run_as="root", + ), + *add, + ) + return ( + Step( + "create the shared group", + ("groupadd", "--force", group), + run_as="root", + ), + *( + Step( + f"add {member} to the shared group", + ("usermod", "-aG", group, member), + run_as="root", + ) + for member in (user, owner) + ), + ) + + +def _permission_steps(tree: Path, user: str, group: str) -> tuple[Step, ...]: + return ( + Step( + "give the tree to chief, shared with the group", + ("chown", "-R", f"{user}:{group}", str(tree)), + run_as="root", + ), + Step( + "let the group read and edit the tree", + ("chmod", "-R", "g+rwX", str(tree)), + run_as="root", + ), + Step( + "make new files inherit the shared group", + ("find", str(tree), "-type", "d", "-exec", "chmod", "g+s", "{}", "+"), + run_as="root", + ), + Step( + # After the sweep above, or it would re-open what it just closed. + "carve out secrets โ€” readable by chief only", + ("chmod", "-R", "go-rwx", str(tree / SECRETS_DIRNAME)), + run_as="root", + ), + ) + + +def _git_steps(tree: Path, user: str, email: str) -> tuple[Step, ...]: + return ( + Step( + "give chief a git identity (self-edit commits fail without one)", + ("git", "config", "--global", "user.name", user), + run_as=user, + ), + Step( + "give chief a git email", + ("git", "config", "--global", "user.email", email), + run_as=user, + ), + Step( + # git refuses to operate on a tree owned by another user. + "trust the tree in your own git config", + ("git", "config", "--global", "--add", "safe.directory", str(tree)), + ), + ) + + +def account_plan( + *, + platform: str, + owner: str, + create: bool = True, + password: str | None = None, + user: str = DEFAULT_USER, + group: str = DEFAULT_GROUP, + tree: Path = DEFAULT_TREE, + home: Path | None = None, + email: str = DEFAULT_EMAIL, +) -> AccountPlan: + """Every step to stand chief up as its own user, in order. + + ``create=False`` installs into an account that already exists: creation is + skipped, the rest still runs. Raises ``ValueError`` on an unsupported + platform or a creating plan with no password. + """ + if platform not in ("darwin", "linux"): + raise ValueError(f"unsupported platform: {platform!r}") + if create and not password: + raise ValueError("creating an account needs a password") + resolved_home = home or default_home(platform, user) + steps = ( + *( + _create_steps(platform, user, resolved_home, password or "") + if create + else () + ), + *_group_steps(platform, user, group, owner), + *_permission_steps(tree, user, group), + *_git_steps(tree, user, email), + *( + ( + Step( + "keep chief running without an interactive login", + ("loginctl", "enable-linger", user), + run_as="root", + ), + ) + if platform == "linux" + else () + ), + ) + return AccountPlan( + user=user, group=group, home=resolved_home, tree=tree, steps=steps + ) diff --git a/tests/test_install.py b/tests/test_install.py index d7c38ed1..9d462f96 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -18,6 +18,7 @@ from chief.hooks.context import TurnContext from chief.install import updatecheck, wizard_steps +from chief.install.account import account_plan from chief.install.commands import ensure_config, main from chief.install.lifecycle import uninstall from chief.install.service import ServiceManager @@ -456,3 +457,91 @@ def counting_refresh( assert await hook(_turn()) is None await _drain() assert len(refreshes) == 1 + + +# --- dedicated account plan (#286) + + +def test_darwin_account_plan_is_pinned() -> None: + plan = account_plan( + platform="darwin", owner="owner", password="hunter2", email="c@l" + ) + assert [list(step.command()) for step in plan.steps] == [ + ["sudo", "sysadminctl", "-addUser", "chief", "-fullName", "chief", + "-home", "/Users/chief", "-shell", "/bin/zsh", "-password", "-"], + ["sudo", "dseditgroup", "-o", "create", "chief"], + ["sudo", "dseditgroup", "-o", "edit", "-a", "chief", "-t", "user", + "chief"], + ["sudo", "dseditgroup", "-o", "edit", "-a", "owner", "-t", "user", + "chief"], + ["sudo", "chown", "-R", "chief:chief", "/opt/chief"], + ["sudo", "chmod", "-R", "g+rwX", "/opt/chief"], + ["sudo", "find", "/opt/chief", "-type", "d", "-exec", "chmod", "g+s", + "{}", "+"], + ["sudo", "chmod", "-R", "go-rwx", "/opt/chief/secrets"], + ["sudo", "-u", "chief", "git", "config", "--global", "user.name", + "chief"], + ["sudo", "-u", "chief", "git", "config", "--global", "user.email", + "c@l"], + ["git", "config", "--global", "--add", "safe.directory", "/opt/chief"], + ] + assert plan.home == Path("/Users/chief") + + +def test_linux_account_plan_is_pinned() -> None: + plan = account_plan( + platform="linux", owner="owner", password="hunter2", email="c@l" + ) + assert [list(step.command()) for step in plan.steps] == [ + ["sudo", "useradd", "--create-home", "--home-dir", "/home/chief", + "--shell", "/bin/bash", "chief"], + ["sudo", "chpasswd"], + ["sudo", "groupadd", "--force", "chief"], + ["sudo", "usermod", "-aG", "chief", "chief"], + ["sudo", "usermod", "-aG", "chief", "owner"], + ["sudo", "chown", "-R", "chief:chief", "/opt/chief"], + ["sudo", "chmod", "-R", "g+rwX", "/opt/chief"], + ["sudo", "find", "/opt/chief", "-type", "d", "-exec", "chmod", "g+s", + "{}", "+"], + ["sudo", "chmod", "-R", "go-rwx", "/opt/chief/secrets"], + ["sudo", "-u", "chief", "git", "config", "--global", "user.name", + "chief"], + ["sudo", "-u", "chief", "git", "config", "--global", "user.email", + "c@l"], + ["git", "config", "--global", "--add", "safe.directory", "/opt/chief"], + ["sudo", "loginctl", "enable-linger", "chief"], + ] + + +def test_the_account_password_never_reaches_an_argv() -> None: + """A pinned argv is printed, logged and diffed โ€” the password must not be + in one. It rides stdin instead.""" + for platform in ("darwin", "linux"): + plan = account_plan(platform=platform, owner="owner", password="hunter2") + assert not any("hunter2" in arg for s in plan.steps for arg in s.command()) + assert any(s.stdin and "hunter2" in s.stdin for s in plan.steps) + + +def test_using_an_existing_account_skips_creation_but_keeps_the_rest() -> None: + plan = account_plan(platform="linux", owner="owner", create=False) + joined = [" ".join(s.command()) for s in plan.steps] + assert not any("useradd" in c or "chpasswd" in c for c in joined) + assert "sudo usermod -aG chief owner" in joined + assert "sudo chmod -R go-rwx /opt/chief/secrets" in joined + assert "sudo loginctl enable-linger chief" in joined + + +def test_secrets_are_carved_out_after_the_group_sweep() -> None: + """Ordering is the whole point: a later g+rwX sweep would re-open them.""" + plan = account_plan(platform="linux", owner="owner", create=False) + joined = [" ".join(s.command()) for s in plan.steps] + assert joined.index("sudo chmod -R g+rwX /opt/chief") < joined.index( + "sudo chmod -R go-rwx /opt/chief/secrets" + ) + + +def test_account_plan_rejects_bad_input() -> None: + with pytest.raises(ValueError, match="unsupported platform"): + account_plan(platform="plan9", owner="owner", password="x") + with pytest.raises(ValueError, match="needs a password"): + account_plan(platform="linux", owner="owner") From ba2bb65dbc48e3e6e6e1ce295884e7854e721c72 Mon Sep 17 00:00:00 2001 From: William Chastain Date: Tue, 28 Jul 2026 19:02:41 -0700 Subject: [PATCH 03/10] feat(install): disk-encryption detection + login-session plan (#286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc --- src/chief/install/session.py | 150 +++++++++++++++++++++++++++++++++++ tests/test_install.py | 77 ++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 src/chief/install/session.py diff --git a/src/chief/install/session.py b/src/chief/install/session.py new file mode 100644 index 00000000..0ccbb734 --- /dev/null +++ b/src/chief/install/session.py @@ -0,0 +1,150 @@ +"""Which login-session mechanism this machine can support, and how to get it. + +Messages only delivers into a real graphical session, and research settled that +none can be manufactured headlessly from a root context. So the mechanism +follows the machine's disk-encryption state: an unencrypted Mac takes the +platform's supported automatic-login path, an encrypted one keeps a documented +human step after every reboot, and Linux needs no session at all โ€” lingering +already covers it. + +Pure like :mod:`.account`: the plan is data, pinned by tests, printed by the +installer before anything runs. +""" + +from dataclasses import dataclass + +from chief.install.account import Step +from chief.install.units import Runner, default_runner + +AUTO_LOGIN = "auto-login" +SCREEN_SHARING = "screen-sharing" +NO_SESSION = "none" + +_RECONNECT = ( + "after every reboot: unlock the disk at the console as yourself, then", + "connect Screen Sharing to vnc://127.0.0.1 and log in as {user} โ€” that", + "login is what creates the graphical session Messages delivers into", +) + +__all__ = [ + "AUTO_LOGIN", + "NO_SESSION", + "SCREEN_SHARING", + "SessionPlan", + "disk_encrypted", + "password_conflict", + "session_plan", +] + + +@dataclass(frozen=True) +class SessionPlan: + """How chief gets a graphical session, and what stays on the human.""" + + mechanism: str + steps: tuple[Step, ...] + manual: tuple[str, ...] + + +def disk_encrypted( + platform: str, runner: Runner = default_runner +) -> bool | None: + """macOS disk-encryption state from ``fdesetup status``. + + ``None`` means unknown โ€” a failed call, unparseable output, or a platform + where the question does not apply. Callers must treat it as encrypted: + automatic login on an encrypted disk silently does nothing. + """ + if platform != "darwin": + return None + result = runner(["fdesetup", "status"]) + if result.returncode != 0: + return None + if "FileVault is On" in result.stdout: + return True + if "FileVault is Off" in result.stdout: + return False + return None + + +def password_conflict(login_password: str, apple_id_password: str) -> str | None: + """macOS refuses automatic login when the two passwords match.""" + if login_password and login_password == apple_id_password: + return ( + "chief's login password must differ from its Apple ID password โ€” " + "macOS refuses automatic login when they match" + ) + return None + + +def session_plan( + *, + platform: str, + encrypted: bool | None, + user: str, + password: str | None = None, +) -> SessionPlan: + """The session mechanism for this machine, with its steps and human notes. + + Raises ``ValueError`` if the automatic-login branch is asked for without + chief's login password. + """ + if platform not in ("darwin", "linux"): + raise ValueError(f"unsupported platform: {platform!r}") + if platform == "linux": + return SessionPlan(NO_SESSION, (), ()) + if encrypted is False: + if not password: + raise ValueError("automatic login needs chief's login password") + return SessionPlan( + AUTO_LOGIN, + ( + Step( + f"log {user} in automatically at boot", + ( + "sysadminctl", + "-autologin", + "set", + "-userName", + user, + "-password", + "-", + ), + run_as="root", + stdin=f"{password}\n", + ), + ), + ( + "automatic login breaks silently after OS updates โ€” the boot", + "check reports it, and `chief status` shows the same state", + ), + ) + unknown = encrypted is None + return SessionPlan( + SCREEN_SHARING, + ( + Step( + "allow screen sharing so you can open chief's session", + ("launchctl", "enable", "system/com.apple.screensharing"), + run_as="root", + ), + Step( + "start the screen-sharing service", + ( + "launchctl", + "load", + "-w", + "/System/Library/LaunchDaemons/com.apple.screensharing.plist", + ), + run_as="root", + ), + ), + ( + *( + ("could not read the disk-encryption state โ€” assuming encrypted",) + if unknown + else () + ), + *(line.format(user=user) for line in _RECONNECT), + ), + ) diff --git a/tests/test_install.py b/tests/test_install.py index 9d462f96..55c633c1 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -22,6 +22,15 @@ from chief.install.commands import ensure_config, main from chief.install.lifecycle import uninstall from chief.install.service import ServiceManager +from chief.install.session import ( + AUTO_LOGIN, + NO_SESSION, + SCREEN_SHARING, + SessionPlan, + disk_encrypted, + password_conflict, + session_plan, +) from chief.install.units import default_path_env, launchd_plist, systemd_unit from chief.install.updatecheck import UpdateStatus from chief.install.wizard import WizardIO, run_wizard @@ -545,3 +554,71 @@ def test_account_plan_rejects_bad_input() -> None: account_plan(platform="plan9", owner="owner", password="x") with pytest.raises(ValueError, match="needs a password"): account_plan(platform="linux", owner="owner") + + +# --- login-session mechanism (#286) + + +def test_filevault_state_decides_the_session_mechanism() -> None: + on = FakeRunner(stdout={"fdesetup": "FileVault is On.\n"}) + off = FakeRunner(stdout={"fdesetup": "FileVault is Off.\n"}) + assert disk_encrypted("darwin", on) is True + assert disk_encrypted("darwin", off) is False + assert disk_encrypted("linux", on) is None + assert not on.calls[1:] # one probe, no retries + + +def test_unreadable_filevault_state_is_treated_as_encrypted() -> None: + """Automatic login on an encrypted disk silently does nothing, so an + unknown answer must take the branch with the human step, not the one that + looks like it worked.""" + broken = FakeRunner(fails={"fdesetup": "boom"}) + assert disk_encrypted("darwin", broken) is None + plan = session_plan( + platform="darwin", encrypted=None, user="chief", password="pw" + ) + assert plan.mechanism == SCREEN_SHARING + assert any("assuming encrypted" in line for line in plan.manual) + + +def test_unencrypted_mac_gets_pinned_auto_login() -> None: + plan = session_plan( + platform="darwin", encrypted=False, user="chief", password="hunter2" + ) + assert plan.mechanism == AUTO_LOGIN + assert [list(s.command()) for s in plan.steps] == [ + ["sudo", "sysadminctl", "-autologin", "set", "-userName", "chief", + "-password", "-"], + ] + assert not any("hunter2" in a for s in plan.steps for a in s.command()) + assert plan.steps[0].stdin == "hunter2\n" + + +def test_encrypted_mac_gets_pinned_screen_sharing_and_a_human_step() -> None: + plan = session_plan(platform="darwin", encrypted=True, user="chief") + assert plan.mechanism == SCREEN_SHARING + assert [list(s.command()) for s in plan.steps] == [ + ["sudo", "launchctl", "enable", "system/com.apple.screensharing"], + ["sudo", "launchctl", "load", "-w", + "/System/Library/LaunchDaemons/com.apple.screensharing.plist"], + ] + assert any("vnc://127.0.0.1" in line for line in plan.manual) + assert any("log in as chief" in line for line in plan.manual) + + +def test_linux_needs_no_session_mechanism() -> None: + plan = session_plan(platform="linux", encrypted=None, user="chief") + assert plan == SessionPlan(NO_SESSION, (), ()) + + +def test_session_plan_rejects_bad_input() -> None: + with pytest.raises(ValueError, match="unsupported platform"): + session_plan(platform="plan9", encrypted=False, user="chief") + with pytest.raises(ValueError, match="needs chief's login password"): + session_plan(platform="darwin", encrypted=False, user="chief") + + +def test_auto_login_refuses_a_password_matching_the_apple_id() -> None: + assert password_conflict("same", "same") is not None + assert password_conflict("login", "appleid") is None + assert password_conflict("", "") is None From c701ab6ddeec805101fe3902e8e763f0674a7d74 Mon Sep 17 00:00:00 2001 From: William Chastain Date: Tue, 28 Jul 2026 19:07:12 -0700 Subject: [PATCH 04/10] =?UTF-8?q?feat(install):=20boot=20check=20=E2=80=94?= =?UTF-8?q?=20encryption,=20auto-login,=20session=20presence=20(#286)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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 Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc --- src/chief/install/account.py | 119 ++-------------------------- src/chief/install/account_steps.py | 123 +++++++++++++++++++++++++++++ src/chief/install/commands.py | 8 ++ src/chief/install/posture.py | 74 +++++++++++++++++ src/chief/web/app.py | 13 +-- src/chief/web/pages.py | 1 + src/chief/web/script.py | 12 ++- src/chief/web/status_routes.py | 52 ++++++++++++ src/chief/web/styles.py | 4 +- tests/test_install.py | 65 +++++++++++++++ 10 files changed, 345 insertions(+), 126 deletions(-) create mode 100644 src/chief/install/account_steps.py create mode 100644 src/chief/install/posture.py create mode 100644 src/chief/web/status_routes.py diff --git a/src/chief/install/account.py b/src/chief/install/account.py index 26e9e8ef..7d296eda 100644 --- a/src/chief/install/account.py +++ b/src/chief/install/account.py @@ -5,14 +5,15 @@ plan before touching the machine. Passwords never appear in argv โ€” they travel in ``stdin``, the one field the pinned commands do not carry. -``run_as`` says whose authority a step needs: ``None`` is the owner running the -installer, ``"root"`` escalates, and chief's own name is used for the steps -that must land in chief's home (its git identity). +The ``Step`` primitive and the account/group commands that differ by OS live +in :mod:`.account_steps`; this module is the order they run in. """ from dataclasses import dataclass from pathlib import Path +from chief.install.account_steps import Step, create_steps, group_steps + DEFAULT_USER = "chief" DEFAULT_GROUP = "chief" DEFAULT_TREE = Path("/opt/chief") @@ -31,28 +32,6 @@ ] -@dataclass(frozen=True) -class Step: - """One command the installer runs, and whose authority it needs.""" - - description: str - argv: tuple[str, ...] - run_as: str | None = None - stdin: str | None = None - - @property - def privileged(self) -> bool: - return self.run_as == "root" - - def command(self) -> tuple[str, ...]: - """The argv as actually invoked, escalation prefix included.""" - if self.run_as is None: - return self.argv - if self.run_as == "root": - return ("sudo", *self.argv) - return ("sudo", "-u", self.run_as, *self.argv) - - @dataclass(frozen=True) class AccountPlan: """The account chief will run as, and every step to get there.""" @@ -68,92 +47,6 @@ def default_home(platform: str, user: str) -> Path: return Path("/Users" if platform == "darwin" else "/home") / user -def _create_steps( - platform: str, user: str, home: Path, password: str -) -> tuple[Step, ...]: - if platform == "darwin": - return ( - Step( - # Deliberately not -admin: chief logs in graphically, and an - # admin chief would be root-equivalent via its shell tool. - f"create the {user} account (non-admin)", - ( - "sysadminctl", - "-addUser", - user, - "-fullName", - user, - "-home", - str(home), - "-shell", - "/bin/zsh", - "-password", - "-", - ), - run_as="root", - stdin=f"{password}\n", - ), - ) - return ( - Step( - f"create the {user} account", - ( - "useradd", - "--create-home", - "--home-dir", - str(home), - "--shell", - "/bin/bash", - user, - ), - run_as="root", - ), - Step( - f"set {user}'s login password", - ("chpasswd",), - run_as="root", - stdin=f"{user}:{password}\n", - ), - ) - - -def _group_steps( - platform: str, user: str, group: str, owner: str -) -> tuple[Step, ...]: - if platform == "darwin": - add = ( - Step( - f"add {member} to the shared group", - ("dseditgroup", "-o", "edit", "-a", member, "-t", "user", group), - run_as="root", - ) - for member in (user, owner) - ) - return ( - Step( - "create the shared group", - ("dseditgroup", "-o", "create", group), - run_as="root", - ), - *add, - ) - return ( - Step( - "create the shared group", - ("groupadd", "--force", group), - run_as="root", - ), - *( - Step( - f"add {member} to the shared group", - ("usermod", "-aG", group, member), - run_as="root", - ) - for member in (user, owner) - ), - ) - - def _permission_steps(tree: Path, user: str, group: str) -> tuple[Step, ...]: return ( Step( @@ -225,11 +118,11 @@ def account_plan( resolved_home = home or default_home(platform, user) steps = ( *( - _create_steps(platform, user, resolved_home, password or "") + create_steps(platform, user, resolved_home, password or "") if create else () ), - *_group_steps(platform, user, group, owner), + *group_steps(platform, user, group, owner), *_permission_steps(tree, user, group), *_git_steps(tree, user, email), *( diff --git a/src/chief/install/account_steps.py b/src/chief/install/account_steps.py new file mode 100644 index 00000000..037bcb81 --- /dev/null +++ b/src/chief/install/account_steps.py @@ -0,0 +1,123 @@ +"""The ``Step`` primitive, and the account/group commands that differ by OS. + +Split from :mod:`.account` so that module stays under the file-length cap: the +plan (what runs, in what order) lives there, the platform-divergent argv lives +here. Both halves stay pure โ€” nothing in this file touches the machine. + +``run_as`` says whose authority a step needs: ``None`` is the owner running the +installer, ``"root"`` escalates, and chief's own name is used for the steps that +must land in chief's home (its git identity). +""" + +from dataclasses import dataclass +from pathlib import Path + +__all__ = ["Step", "create_steps", "group_steps"] + + +@dataclass(frozen=True) +class Step: + """One command the installer runs, and whose authority it needs.""" + + description: str + argv: tuple[str, ...] + run_as: str | None = None + stdin: str | None = None + + @property + def privileged(self) -> bool: + return self.run_as == "root" + + def command(self) -> tuple[str, ...]: + """The argv as actually invoked, escalation prefix included.""" + if self.run_as is None: + return self.argv + if self.run_as == "root": + return ("sudo", *self.argv) + return ("sudo", "-u", self.run_as, *self.argv) + + +def create_steps( + platform: str, user: str, home: Path, password: str +) -> tuple[Step, ...]: + if platform == "darwin": + return ( + Step( + # Deliberately not -admin: chief logs in graphically, and an + # admin chief would be root-equivalent via its shell tool. + f"create the {user} account (non-admin)", + ( + "sysadminctl", + "-addUser", + user, + "-fullName", + user, + "-home", + str(home), + "-shell", + "/bin/zsh", + "-password", + "-", + ), + run_as="root", + stdin=f"{password}\n", + ), + ) + return ( + Step( + f"create the {user} account", + ( + "useradd", + "--create-home", + "--home-dir", + str(home), + "--shell", + "/bin/bash", + user, + ), + run_as="root", + ), + Step( + f"set {user}'s login password", + ("chpasswd",), + run_as="root", + stdin=f"{user}:{password}\n", + ), + ) + + +def group_steps( + platform: str, user: str, group: str, owner: str +) -> tuple[Step, ...]: + if platform == "darwin": + add = ( + Step( + f"add {member} to the shared group", + ("dseditgroup", "-o", "edit", "-a", member, "-t", "user", group), + run_as="root", + ) + for member in (user, owner) + ) + return ( + Step( + "create the shared group", + ("dseditgroup", "-o", "create", group), + run_as="root", + ), + *add, + ) + return ( + Step( + "create the shared group", + ("groupadd", "--force", group), + run_as="root", + ), + *( + Step( + f"add {member} to the shared group", + ("usermod", "-aG", group, member), + run_as="root", + ) + for member in (user, owner) + ), + ) diff --git a/src/chief/install/commands.py b/src/chief/install/commands.py index d2b7775e..839b768e 100644 --- a/src/chief/install/commands.py +++ b/src/chief/install/commands.py @@ -5,6 +5,7 @@ import argparse import asyncio +import getpass import os import sys import webbrowser @@ -18,6 +19,7 @@ wait_for_health, web_url, ) +from chief.install.posture import read_posture from chief.install.release import cut_release from chief.install.service import ServiceManager from chief.install.update import abort_update, update @@ -84,6 +86,12 @@ def _dispatch(args: argparse.Namespace) -> int: # noqa: PLR0911 url = web_url(args.port) up = wait_for_health(url, timeout=2.0) print(f"web: {url} ({'responding' if up else 'not responding'})") + state = read_posture( + platform=service.platform, user=getpass.getuser(), uid=service.uid + ) + print(f"disk: encryption {state.encryption}") + print(f"login: auto {state.auto_login}, session {state.session}") + print(f"posture: {state.summary()}") return 0 if command == "update": if args.abort: diff --git a/src/chief/install/posture.py b/src/chief/install/posture.py new file mode 100644 index 00000000..8fb5314e --- /dev/null +++ b/src/chief/install/posture.py @@ -0,0 +1,74 @@ +"""The boot check: what chief's login posture actually is right now. + +macOS automatic login is documented to break silently after an OS update, and +a graphical session is the thing Messages delivers into โ€” so the state has to +be visible rather than inferred from chief going quiet. Read on demand (the +web UI polls it, ``chief status`` prints it) rather than kept by a background +loop: none of these probes is expensive and there is no state worth holding. +""" + +from dataclasses import dataclass + +from chief.install.session import disk_encrypted +from chief.install.units import Runner, default_runner + +LOGIN_WINDOW = "/Library/Preferences/com.apple.loginwindow" +NOT_APPLICABLE = "n/a" +UNKNOWN = "unknown" +OFF = "off" +ON = "on" +PRESENT = "present" +ABSENT = "absent" + +__all__ = ["Posture", "read_posture"] + + +@dataclass(frozen=True) +class Posture: + """Disk encryption, automatic login and session presence, as read.""" + + user: str + encryption: str + auto_login: str + session: str + + def problems(self) -> tuple[str, ...]: + """Everything about this posture that will cost chief its session.""" + found = [] + if self.session == ABSENT: + found.append( + f"no graphical session for {self.user} โ€” " + "Messages will not deliver" + ) + if self.encryption == OFF and self.auto_login != self.user: + found.append( + f"automatic login is not set to {self.user} โ€” a reboot leaves " + "chief with no session" + ) + if self.encryption == ON and self.auto_login == self.user: + found.append( + "automatic login is set but the disk is encrypted โ€” " + "macOS ignores it; use the screen-sharing reconnect step" + ) + return tuple(found) + + def summary(self) -> str: + return "; ".join(self.problems()) or "ok" + + +def read_posture( + *, platform: str, user: str, uid: int, runner: Runner = default_runner +) -> Posture: + """Probe the machine. Linux has no graphical session to lose โ€” all n/a.""" + if platform != "darwin": + return Posture(user, NOT_APPLICABLE, NOT_APPLICABLE, NOT_APPLICABLE) + encrypted = disk_encrypted(platform, runner) + auto = runner(["defaults", "read", LOGIN_WINDOW, "autoLoginUser"]) + # A GUI domain only exists once someone has actually logged in as that uid. + session = runner(["launchctl", "print", f"gui/{uid}"]) + return Posture( + user=user, + encryption=UNKNOWN if encrypted is None else (ON if encrypted else OFF), + auto_login=(auto.stdout.strip() if auto.returncode == 0 else "") or OFF, + session=PRESENT if session.returncode == 0 else ABSENT, + ) diff --git a/src/chief/web/app.py b/src/chief/web/app.py index 39126e16..67eebd94 100644 --- a/src/chief/web/app.py +++ b/src/chief/web/app.py @@ -29,6 +29,7 @@ from chief.web.pages import CHAT_PAGE, LOGIN_PAGE from chief.web.policy_routes import build_policy_routes from chief.web.script import SCRIPT +from chief.web.status_routes import build_status_routes from chief.web.styles import STYLES from chief.web.view import render_transcript, tool_call_response @@ -161,16 +162,6 @@ async def commands(request: Request) -> Response: return unauthorized() return JSONResponse(palette()) - async def monitor_list(request: Request) -> Response: - if not auth.is_authed(request): - return unauthorized() - rows = await monitors.list_monitors() - if not rows: - return PlainTextResponse("none") - return PlainTextResponse( - "; ".join(f"#{r.id} {r.description}" for r in rows) - ) - async def app_css(request: Request) -> Response: return Response(STYLES, media_type="text/css") @@ -191,7 +182,7 @@ async def app_js(request: Request) -> Response: *build_policy_routes( store, channel_defaults, auth.is_authed, unauthorized ), - Route("/monitors", monitor_list), + *build_status_routes(monitors, auth.is_authed, unauthorized), Route("/app.css", app_css), Route("/app.js", app_js), ] diff --git a/src/chief/web/pages.py b/src/chief/web/pages.py index d0d2e2a6..bb4d74f9 100644 --- a/src/chief/web/pages.py +++ b/src/chief/web/pages.py @@ -35,6 +35,7 @@ model — mon … +