← index2026-05-09 12:07 (Beirut)(backfill from DOCUMENTATION/)

Mac Access Controller v2 — Design

Mac Access Controller v2 — Design

Status: DRAFT — codex review integrated 2026-05-09
Last updated: 2026-05-09
Owner: Brian (autonomous agent on Hetzner)
Scope: All Hetzner→Mac access flows through one centralized controller with a single visible UI on Jonah's Mac.


Codex review delta (integrated 2026-05-09)

Codex's strongest critique: "the architecture centralizes intent, but not authority over the actual Mac-touching path. ProxyCommand only protects SSH connections that use that config path... A central controller is only real if all Mac access is forced through it at the network/account boundary."

Adopted changes:

  1. Daemon is the only shell client. mac_session.py and mac_run no longer invoke ssh directly. They send {action:"run", argv:[...], cwd, env, timeout} over the Unix socket; the daemon spawns the remote shell, owns PGIDs, logs, tokens, heartbeats, and kill semantics. The mac_gatekeeper ProxyCommand becomes belt-and-suspenders only — it refuses any direct shell that lacks a session, but is not the enforcement path.
  2. Process-group kills, not PID kills. Daemon spawns each remote-shell command in its own process group via os.setpgrp / start_new_session=True. Stop sends os.killpg(-pgid, SIGTERM) then SIGKILL. Also closes any SSH ControlMaster sockets to prevent multiplexed-session survival.
  3. HMAC on every Mac↔Hetzner JSON payload. Tailscale ACL is the transport boundary; HMAC-SHA256 with nonce + timestamp and a per-session shared secret is the app-layer auth. Reject replays (nonce LRU, ±30s window). Secret rotates per-session.
  4. Token model hardened. Tokens are short-lived (per-session), single-use, never logged, never put in env when avoidable. Where env is required (legacy mac_run callers), token is rotated on every step boundary so a leaked snapshot is stale.
  5. Heartbeat decoupled from operation duration. Daemon→Mac heartbeat every 1–2s. Mac watchdog: show "disconnected" badge after 10–15s, tear down + kill after 30s. Long screen recordings are fine because the daemon keeps heartbeating independently of the operation.
  6. Migration: legacy first, v2 second. Delete/rename legacy handlers and wrappers BEFORE enabling v2. Rollback tarball ready. Dual-mode is forbidden (it recreates the current bug class).

These changes are reflected throughout the architecture below. The original "ProxyCommand-as-enforcement + env-token push" design is rejected.


North star

Brian on Mac is maximally autonomous. Anything a human user could do on Jonah's Mac, Brian can do — files, screen, clicks, native apps, voice, clipboard, browser, contacts/calendar/notes/shortcuts, accessibility tree, OCR, vision, recording, IPC. And Jonah is never confused about what is happening on his machine. Every Brian-driven action paints the same single breathing-cyan UI, with one clickable pause/stop pill, one translucent status box, and the start/stop chimes (Glass / Submarine).


Why v2 (what's wrong with v1)

v1 (mac_access_gateway.py + brian_mac_gateway.lua, commit c81e8b8) shipped a context-manager + Lua module pair. Three bugs proved the architecture is wrong:

  1. Two cyan borders rendering simultaneously. Old init.lua canvas code (_brian_active_canvas, _brian_status_canvas, _brian_active_label_canvas) still runs alongside the new module. Neither side knows about the other.
  2. Status box shows URL-encoded %20 literals. Hammerspoon's urlevent.bind does not auto-decode URL params despite documentation implying otherwise.
  3. Old "Hammerspoon Command" status box fires on every SSH. The legacy mac-bin/ssh PATH wrapper intercepts every shell ssh call and pushes its own status URL.

Root cause: 5+ uncoordinated Mac-touching systems, no central controller. Anyone can paint the screen; no one has a global view of session state.


Architecture

Hetzner side

                +--------------------------+
                |  Caller (skill/script)   |
                |  with MacSession() as s: |
                |      s.step("...")       |
                |      s.run("...")        |
                +-----------+--------------+
                            |
                  Unix socket /run/brian-mac.sock
                  (request: run/click/screencap/...)
                            |
                +-----------v--------------+
                | brian-mac-controller     |    <-- systemd service
                | (Python daemon, SSOT)    |
                |                          |
                |  - active session state  |
                |  - pause/stop signals    |
                |  - PGID set (kill -PGID) |
                |  - SSH ControlMaster mgr |
                |  - HMAC signer/verifier  |
                |  - heartbeat to Mac      |
                +-----+-----------------+--+
                      |                 |
                      | spawns shell    | HTTPS POST (JSON + HMAC)
                      | in own pgrp     | over Tailscale
                      v                 v
              +---------------+   +------------------+
              | shell (PGID)  |   |   Mac httpserver |
              |  → Mac:22     |   |  (Hammerspoon)   |
              +---------------+   +------------------+

  Belt-and-suspenders (NOT enforcement path):
    ~/.ssh/config ProxyCommand → mac_gatekeeper → refuses if no session

Components:

  1. brian-mac-controller.service — systemd-managed Python daemon. The only process that opens a remote shell to the Mac. Single source of truth for:
    - active session: id, title, current_step, state (running / paused / stopping / idle), started_at, last_heartbeat
    - pending pause/stop signals coming back from the Mac
    - registry of running shell process groups (PGIDs), spawned with start_new_session=True so os.killpg(-pgid, SIGKILL) reaps grandchildren
    - SSH ControlMaster socket lifecycle (close on stop to kill multiplexed sessions)
    - HMAC signer/verifier for every Mac↔Hetzner payload
    - HTTPS client to the Mac's hs.httpserver for control POSTs (Tailscale-bound)
    - HTTP server endpoint for pause/stop callbacks from the Mac
    - Exposes Unix socket /run/brian-mac.sock for IPC from sibling processes

  2. mac_session.py — thin client library used inside Brian's Python code. Does not invoke ssh. Connects to /run/brian-mac.sock, registers a session, gets a session id (no token-in-env). Every s.run() / s.click() / s.screencap() is a JSON request to the daemon.

python from brian_mac import MacSession with MacSession(title="Re-linking LinkedIn") as s: s.step("Bringing the LinkedIn login window to front") s.run("osascript -e '...'") # daemon spawns, owns PGID s.click(800, 450) # daemon sends to Mac s.screencap("/tmp/after_click.png") # daemon orchestrates

Auth on the Unix socket: SO_PEERCRED + uid check (only root or the agent uid). No token push.

  1. mac_gatekeeper — small executable used as ~/.ssh/config ProxyCommand. Belt-and-suspenders only — not the enforcement path. Asks the daemon over /run/brian-mac.sock "is there an active session for this caller?". Pipes nc 100.79.172.24 22 if yes, refuses with EX_CONFIG if no. This catches escape attempts (third-party tools, manual SSH from a shell, copy-pasted snippets) and forces them to fail loudly instead of silently bypassing the controller.

# ~/.ssh/config Host jonahs-mac mac.tail51157c4.ts.net 100.79.172.24 ProxyCommand /opt/agent/bin/mac_gatekeeper %h %p

  1. mac_run — shell wrapper for non-Python callers. Also routes through the Unix socket, not env-token-shell:
    mac_run --title "X" --step "Y" -- <cmd>
    Spawns/attaches a session via the daemon, sends the command as a {action:"run", argv:[...]} request, streams stdout/stderr back, exits with the remote exit code. No env token.

  2. PreToolUse hook (existing, brian-mac-access-gateway-guard.py) stays as a third belt for the Bash tool path.

Mac side

  1. ~/.hammerspoon/brian_controller_ui.lua — single encapsulated module:
    - hs.httpserver.new() on Tailscale-bound port 8765 listening for JSON POSTs from the Hetzner daemon
    - Schema: {action, session_id, title, step, state} where actionstart | step | pause | resume | stop | heartbeat
    - Owns the ONE border canvas (4-layer breathing gradient cyan #00e5d4)
    - Owns the ONE clickable pill (chooser → pause/stop)
    - Owns the ONE translucent status box (alpha 0.60, see-through, text legible)
    - Plays Glass on start, Submarine on stop
    - Heartbeat watchdog: if no POST in N seconds → tear down UI + send "kill" back to Hetzner controller
    - Click pause/stop → POSTs back to Hetzner controller daemon's HTTP endpoint

  2. init.lua cleanup — DELETE every legacy symbol:
    - _brian_active_canvas
    - _brian_status_canvas
    - _brian_active_label_canvas
    - _showBrianActive()
    - _setBrianStatus()
    - the brian-active and brian-status URL handlers
    Keep one line: require("brian_controller_ui"). Backup init.lua.bak.20260509_pre_v2 before editing.


Decisions locked

Decision Value Rationale
Brand color cyan #00e5d4 Matches existing Webspot/Brian palette
Sounds Glass (start) / Submarine (stop) macOS system sounds, no asset shipping
Status box opacity alpha 0.60 Translucent, see-through, text legible
Migration hard cutover, no shims Shims would re-create the "5 uncoordinated systems" failure mode
Mac transport hs.httpserver JSON-POST Eliminates URL-encoding bugs forever
Auth enforcement ~/.ssh/config ProxyCommand Catches Python subprocess.run calls the PreToolUse hook can't see
Lua surface single module Old canvas code in init.lua MUST be deleted
Watchdog heartbeat tear-down If controller dies, UI clears and live SSH PIDs are killed
Pause between steps Halts before next s.step()
Stop immediate Aborts current shell, kills child PIDs
Codex partnership ongoing Adversarial review at lock-in, after each component, before final commit
Documentation publishable as-built Drive-synced folder so this can one day open-source

Capability surface (ambitious — v2 lands foundation + ~10, rest land incrementally)

Each capability gets:
- A method on MacSession
- A docs page under capabilities/<name>.md
- An end-to-end test under /opt/agent/tests/mac_controller/test_<name>.py

Categories (full list in handoff):
- Files & shellrun, scp_to_mac, scp_from_mac, read_file, write_file
- Screenscreencap (region/cursor), start_recording, stop_recording
- Mouse + keyboardclick, move, drag, scroll, type_text, keystroke, keychord
- Window mgmtlist_windows, front_app, activate, raise_window, close_window, move_window, resize_window
- Browser — Chrome tabs/activate/url/run_js across all profiles (Default, Brian, chrome-mac-cdp)
- Pasteboardclipboard_get/set/history
- Notifications + voicenotify, say, alert
- Mac data — Contacts, Calendar, Notes, Reminders, Shortcuts, iMessage
- Native app IPC — AppleScript + JXA escape hatches
- Accessibility — find/click/value by role+label (not coords)
- System state — battery, network, audio, brightness, lock state, idle
- Permissions — TCC check/request (live grants on sshd-keygen-wrapper)
- Process mgmt — list/kill/launch/quit
- OS shortcuts — open URL/file, reveal in Finder, Spotlight, mdfind
- Photos / Music / Continuity / Bluetooth / USB / Wi-Fi
- Camera / Mic — snapshot, record (for "what's around me right now")
- OCR + Vision — find text on screen, click text-by-content not coords
- Hammerspoon escape hatchhs.run(lua_code) for anything not yet wrapped


Migration order (hard cutover)

Build v2 in parallel with v1 still running. Verify v2 in isolation against the Mac. Then:

  1. Stop v1 service + remove init.lua require for the old module
  2. Enable v2 service + add init.lua require("brian_controller_ui")
  3. Migrate callers one at a time, end-to-end test per caller:
  4. relink_brian_linkedin.sh (and add the click-retry-on-URL-mismatch fix)
  5. chrome-mac skill
  6. claude-in-chrome skill (via MCP bridge)
  7. use-my-browser skill
  8. brian_alert.py (Mac notification path)
  9. brian_shared_watcher.py (Drive/dropbox watcher)
  10. mac_helper_client.py (queue-based IPC) → replace with daemon API calls
  11. After all callers migrated: tarball legacy as mac_legacy_v1.tar.gz in /opt/agent/data/archives/, then git rm:
    - mac-bin/ssh
    - mac_ssh_guard.sh
    - brian_mac_active.sh
    - brian_mac_status.sh

Security model (placeholder — fills out in 03_security_model.md)

Open: HMAC-signed JSON over the Tailscale-bound HTTP? Codex review pending.


Open items pending codex adversarial review

All resolved by the codex review delta at the top of this document. Outstanding for next codex pass (after Lua module + daemon land):


Implementation order (next session — locked)

  1. Re-consult codex on this design (in flight)
  2. Write this design doc (DONE — you're reading it)
  3. Build the Mac-side Lua module (brian_controller_ui.lua) FIRST — it's the SSOT for UI state. Test in isolation: POST JSON, verify border/pill/box/chime fire correctly, verify pause/stop POSTs back.
  4. Build the Hetzner daemon (brian-mac-controller) — Python, systemd unit, Unix socket, HTTP client to Mac, manages shell child PIDs.
  5. Build the gatekeeper (mac_gatekeeper) and wire ~/.ssh/config ProxyCommand. End-to-end smoke: with MacSession(): s.run("uname -a") → confirms gatekeeper ran, daemon authorized, UI showed, command ran, UI cleared.
  6. Cutover script (v1_to_v2_cutover.sh) — stops v1 service, edits init.lua to swap modules, restarts v2 service, runs smoke test.
  7. Migrate relink_brian_linkedin.sh as first caller. Add the click-retry-on-URL-mismatch fix.
  8. Migrate next 6 callers, end-to-end test per caller.
  9. Tarball + delete legacy systems.
  10. Final codex review of the complete system. Commit + handoff.
  11. Documentation pass — fill out capabilities/ for every capability landed.

References