Problem
Every new atm-daemon binary path triggers a fresh macOS "access Documents folder" consent prompt for the LaunchAgent: seven prompts between 2026-09-10 and 2026-09-16 (~/.atm-builds/v1.5.20/bin/atm-daemon, /opt/homebrew/Cellar/atm/1.6.0/bin/atm-daemon, ...). tccd keys the grant by real path for our dev-signed binary (identifier_type=Path), and /opt/homebrew/bin/atm-daemon is a symlink, so every release or daemon-switch is a new identity.
Those prompts are not just an operator nuisance:
Rand's ruling (2026-09-16): moving repos out of ~/Documents, restricting the daemon to ~/.atm, and "toggle it in System Settings" are not solutions. Requested: a design that gives the managed daemon a stable TCC identity so it is granted once and never re-prompts.
Where the Documents access comes from today
~/Library/LaunchAgents/com.atm.daemon.crosshost-smoke.plist sets WorkingDirectory to /Users/randlee/Documents/github/atm-core. launchd chdirs there before exec, and atm-daemon-bootstrap reads std::env::current_dir() at startup (crates/atm-daemon-bootstrap/src/lib.rs:215). That is a Documents access on every daemon start, before it has done any work.
ProgramArguments runs /usr/bin/env -u ATM_TEAM -u ATM_IDENTITY -u ATM_ENVIRONMENT /opt/homebrew/bin/atm-daemon. The symlink resolves to the versioned Cellar path; daemon-switch swaps this for ~/.atm-builds/<sel>/bin/atm-daemon. Each distinct real path is a new TCC subject.
- Runtime reads of roster
home_dirs and worktrees that live under ~/Documents (still true for ~/Documents/github/atm-core-worktrees/* and the LaunchAgent cwd even after the primary checkouts moved to ~/github).
Proposal: atm-supervisor
A tiny, rarely-changing binary at a fixed real path (not a symlink), e.g. ~/.atm/bin/atm-supervisor, becomes the LaunchAgent's program. It spawns the currently selected atm-daemon as a child. On macOS, TCC attributes a child's file access to its responsible process, which is inherited from the parent unless the parent explicitly disclaims it. The supervisor never disclaims, so the one Documents grant given to atm-supervisor covers every daemon version it launches. The daemon binary can change freely; the subject macOS sees does not.
Supervisor contract
- Selection: reads the active pair from the existing
daemon-switch selector (~/.atm-builds/selectors/<os>, falling back to the Homebrew pair). No new config format.
- Lifecycle:
posix_spawn the daemon with the scrubbed environment (the env -u ATM_TEAM/ATM_IDENTITY/ATM_ENVIRONMENT step moves into the supervisor), inherit stdout/stderr (launchd log paths keep working), forward SIGTERM/SIGINT/SIGHUP to the child, wait, exit with the child's status. The supervisor exits when the child exits so launchd KeepAlive restarts the pair; it does not implement its own restart loop or back-off.
- Responsibility: must NOT call
responsibility_spawnattrs_setdisclaim; must not be a shell script (the interpreter would be the subject). One static Rust binary, no dependencies on the atm-core crates so it changes only when the contract changes.
- Working directory: the LaunchAgent
WorkingDirectory becomes ~/.atm (outside every TCC folder); the supervisor sets the child's cwd the same way. The daemon must not depend on cwd (today it reads current_dir() only for diagnostics; confirm).
- Non-macOS: not shipped;
daemon-switch keeps its current Linux/Windows paths.
daemon-switch changes
switch writes the selector and runs launchctl kickstart -k <label>; it never rewrites ProgramArguments again. The plist is written once by an install-supervisor step (idempotent) and the pre-switch probe checks the plist points at the supervisor.
- The stale
~/.config/atm/daemon-switch.json restore defaults (1.3.2-beta.1, written by setdefault in release_resolution.py's save_default_pair) stay harmless but should be cleaned up in the same change.
atm doctor gains one finding: LaunchAgent program is not the supervisor, or supervisor path is a symlink.
Why not only Developer ID signing (#1126)
A notarized Developer ID build gives tccd a code-requirement identity that survives version changes, which is the right long-term answer for released binaries. It is gated on paid enrollment and does nothing for dev builds from ~/.atm-builds, which is where most of the seven prompts came from. The supervisor works today, for every build source, and still composes with signing later (sign the supervisor too and the grant survives supervisor updates).
Rejected
- Repos out of
~/Documents / daemon confined to ~/.atm / manual System Settings toggles: rejected by Rand as non-solutions.
- Making
/opt/homebrew/bin/atm-daemon a hard link or copy: still changes per Homebrew upgrade; the supervisor is the only file that must stay put.
Acceptance
- Install the supervisor, grant Documents once. Then switch daemon builds three times (
--release latest, a ~/.atm-builds prerelease, back): /usr/bin/log show --predicate 'process == "tccd" AND eventMessage CONTAINS "AUTHREQ_PROMPTING"' shows no prompt for any atm-daemon path; atm doctor --json healthy after each switch.
launchctl kickstart -k restarts the pair; SIGTERM to the supervisor stops the daemon cleanly (socket removed, no orphan).
- Kernel log shows no
deny(1) lines for the supervisor's pid across the switches.
- The
daemon-switch skill tests cover: selector-only switch, plist-drift detection, supervisor-is-symlink refusal.
Scope
One sprint: supervisor crate (crates/atm-supervisor, no workspace-internal deps), daemon-switch script + plist template, doctor finding, docs (docs/user/… daemon section). No change to the daemon's own code beyond confirming cwd independence.
Problem
Every new
atm-daemonbinary path triggers a fresh macOS "access Documents folder" consent prompt for the LaunchAgent: seven prompts between 2026-09-10 and 2026-09-16 (~/.atm-builds/v1.5.20/bin/atm-daemon,/opt/homebrew/Cellar/atm/1.6.0/bin/atm-daemon, ...). tccd keys the grant by real path for our dev-signed binary (identifier_type=Path), and/opt/homebrew/bin/atm-daemonis a symlink, so every release ordaemon-switchis a new identity.Those prompts are not just an operator nuisance:
herdrprocess tree offline for 100 minutes on 2026-09-16 until a reboot (Apple Feedback draft: see the companion issue "sandboxd caches a transient tccd attribution failure"). We cannot fix that Apple bug; we can stop triggering it.Rand's ruling (2026-09-16): moving repos out of
~/Documents, restricting the daemon to~/.atm, and "toggle it in System Settings" are not solutions. Requested: a design that gives the managed daemon a stable TCC identity so it is granted once and never re-prompts.Where the Documents access comes from today
~/Library/LaunchAgents/com.atm.daemon.crosshost-smoke.plistsetsWorkingDirectoryto/Users/randlee/Documents/github/atm-core. launchdchdirs there before exec, andatm-daemon-bootstrapreadsstd::env::current_dir()at startup (crates/atm-daemon-bootstrap/src/lib.rs:215). That is a Documents access on every daemon start, before it has done any work.ProgramArgumentsruns/usr/bin/env -u ATM_TEAM -u ATM_IDENTITY -u ATM_ENVIRONMENT /opt/homebrew/bin/atm-daemon. The symlink resolves to the versioned Cellar path;daemon-switchswaps this for~/.atm-builds/<sel>/bin/atm-daemon. Each distinct real path is a new TCC subject.home_dirs and worktrees that live under~/Documents(still true for~/Documents/github/atm-core-worktrees/*and the LaunchAgent cwd even after the primary checkouts moved to~/github).Proposal:
atm-supervisorA tiny, rarely-changing binary at a fixed real path (not a symlink), e.g.
~/.atm/bin/atm-supervisor, becomes the LaunchAgent's program. It spawns the currently selectedatm-daemonas a child. On macOS, TCC attributes a child's file access to its responsible process, which is inherited from the parent unless the parent explicitly disclaims it. The supervisor never disclaims, so the one Documents grant given toatm-supervisorcovers every daemon version it launches. The daemon binary can change freely; the subject macOS sees does not.Supervisor contract
daemon-switchselector (~/.atm-builds/selectors/<os>, falling back to the Homebrew pair). No new config format.posix_spawnthe daemon with the scrubbed environment (theenv -u ATM_TEAM/ATM_IDENTITY/ATM_ENVIRONMENTstep moves into the supervisor), inherit stdout/stderr (launchd log paths keep working), forwardSIGTERM/SIGINT/SIGHUPto the child, wait, exit with the child's status. The supervisor exits when the child exits so launchdKeepAliverestarts the pair; it does not implement its own restart loop or back-off.responsibility_spawnattrs_setdisclaim; must not be a shell script (the interpreter would be the subject). One static Rust binary, no dependencies on the atm-core crates so it changes only when the contract changes.WorkingDirectorybecomes~/.atm(outside every TCC folder); the supervisor sets the child's cwd the same way. The daemon must not depend on cwd (today it readscurrent_dir()only for diagnostics; confirm).daemon-switchkeeps its current Linux/Windows paths.daemon-switchchangesswitchwrites the selector and runslaunchctl kickstart -k <label>; it never rewritesProgramArgumentsagain. The plist is written once by aninstall-supervisorstep (idempotent) and the pre-switch probe checks the plist points at the supervisor.~/.config/atm/daemon-switch.jsonrestore defaults (1.3.2-beta.1, written bysetdefaultinrelease_resolution.py'ssave_default_pair) stay harmless but should be cleaned up in the same change.atm doctorgains one finding: LaunchAgent program is not the supervisor, or supervisor path is a symlink.Why not only Developer ID signing (#1126)
A notarized Developer ID build gives tccd a code-requirement identity that survives version changes, which is the right long-term answer for released binaries. It is gated on paid enrollment and does nothing for dev builds from
~/.atm-builds, which is where most of the seven prompts came from. The supervisor works today, for every build source, and still composes with signing later (sign the supervisor too and the grant survives supervisor updates).Rejected
~/Documents/ daemon confined to~/.atm/ manual System Settings toggles: rejected by Rand as non-solutions./opt/homebrew/bin/atm-daemona hard link or copy: still changes per Homebrew upgrade; the supervisor is the only file that must stay put.Acceptance
--release latest, a~/.atm-buildsprerelease, back):/usr/bin/log show --predicate 'process == "tccd" AND eventMessage CONTAINS "AUTHREQ_PROMPTING"'shows no prompt for anyatm-daemonpath;atm doctor --jsonhealthy after each switch.launchctl kickstart -krestarts the pair; SIGTERM to the supervisor stops the daemon cleanly (socket removed, no orphan).deny(1)lines for the supervisor's pid across the switches.daemon-switchskill tests cover: selector-only switch, plist-drift detection, supervisor-is-symlink refusal.Scope
One sprint: supervisor crate (
crates/atm-supervisor, no workspace-internal deps),daemon-switchscript + plist template, doctor finding, docs (docs/user/…daemon section). No change to the daemon's own code beyond confirming cwd independence.