fix(desktop): make the macOS dev TCC grant durable, behind an opt-in - #2032
Merged
Conversation
…opt-in The signed `Maka Dev.app` launched through LaunchServices is required for macOS TCC to keep Accessibility and Screen Recording grants across development. The session protocol built around it was not. `open` returns at the LaunchServices handoff and the system can relaunch the app at any time, so the launch-time context — Vite URL, environment, argv — was pushed through a supervised session: session.json, app.pid, launch-status.json, a runtime lock, stale-session recovery, a startup handshake, and a launch-record probe injected into main.ts. All of it existed to let a detached app pretend it was still a child process. Make the bootstrap depend on constants instead. Everything it needs is fixed when the bundle is built, and the environment it adopts is plain data in an ignored 0600 file with no owning process. A relaunch with no arguments and no environment then reproduces a correct app on its own, which is what the session machinery was for. Shutdown matches the worktree's own bundle path, so it stays precise across concurrent worktrees without tracking a pid. Two consequences worth naming. PATH is no longer forwarded: shell-env.ts already resolves the login-shell PATH for exactly this case, and forwarding TERM/COLORTERM alongside it made that module skip resolution. And the payload is now a plain directory at the default_app.asar path, which Node resolves identically, removing the @electron/asar dependency. The workflow is also now opt-in behind MAKA_DEV_TCC. LaunchServices detaches the app from the terminal, so main-process logs go to Console.app — a real cost for developers who are not working on OS permissions.
Astro-Han
force-pushed
the
fix/dev-app-tcc-opt-in
branch
from
August 3, 2026 15:57
eb1c5c5 to
022b8ad
Compare
…nces Four rounds of independent review found the previous revision correct in shape but wrong in several load-bearing details. This corrects them. The designated requirement was the important one. An ad-hoc signature designates a bare cdhash, and the generated bootstrap seals absolute paths into the bundle, so every rebuild trigger was also a cdhash change: no TCC grant could survive one, and concurrent worktrees silently overwrote each other's TCC row. Sign with an explicit `identifier` requirement instead. That is not a weaker boundary here — the bundle loads dist/main/main.js from outside the seal, so write access to the repository already confers the grant. Signing now runs inside-out, because a single --deep pass carrying the requirement stamps it onto the helper bundles, whose own identifier differs, and codesign then rejects them. Launch and rebuild now reclaim a leftover app first. Electron's single-instance lock is keyed on userData, so an app surviving a hard-killed session absorbed the new launch: the new process exited 0, the OLD window came forward against a dead Vite URL, and the liveness probe still reported success. Rebuilding also unlinked the bundle a running app was launched from. Corrections to claims that were simply false: - `open` DOES forward the parent environment; the env file earns its place for Dock/Spotlight/"Quit & Reopen" launches, which have no parent shell. - `app.isPackaged` is `basename(execPath) !== 'electron'`, not `!process.defaultApp`. Only the executable's name keeps the dev gates on. - `ditto` preserves quarantine by default and `xattr -r` is long-standing, so the clear is now recursive. - `pkill -f`/`pgrep -f` take an extended regex: an unescaped path under e.g. `~/Dropbox (Personal)` matched nothing, leaving the app unkillable, and a pattern error was read as "not running". Also: restore the .maka-dev-session ignore, since the pre-opt-in launcher ran unconditionally and its leftover session.json holds forwarded API keys; drop the stale ElectronAsarIntegrity record describing a payload we replace; stage rebuilds and publish by rename; stream the app's output back to the terminal via `open --stdout`. Tests now execute the generated bootstrap against a stub Electron rather than regex-matching its source, covering the no-env-file, published-env, and corrupt/wrong-schema paths, and pin the un-injected pkill/pgrep wiring.
…aunchers The dev bundle pinned its designated requirement to `identifier "com.maka.dev"` so a grant would survive rebuilds. That is forgeable: `codesign --sign -` is available to every unprivileged process, so any binary anywhere on the disk could claim the identifier and satisfy the requirement. Since TCC rows are keyed on the identifier and outlive the code they were granted to, the grant stayed redeemable even after the repository was deleted. Keep the ad-hoc default cdhash requirement instead, and scope the identifier per worktree so worktrees no longer overwrite each other's row. A rebuild now costs a re-grant, but a rebuild only happens on an Electron bump or a repository move, not on an ordinary `npm run dev`. `npm run dev` and `npm start` each carried their own copy of publish-launch- follow-shutdown, and the copies had drifted into different bugs rather than different behaviour: `npm start` never followed the log, so it was silent, and it republished the environment without the Vite URL, silently stealing a live dev session's app and dropping it to the prebuilt renderer. Both now go through one `startDevelopmentApp`. Also: - Replace the single 5s liveness check, which reported a false failure when the app was quit early, never noticed a quit after 5s, and could kill a slow first launch, with a monitor that waits for the app to appear and treats a later disappearance as an ordinary session end. - Register signal handlers before the launch await. A signal arriving during the codesign rebuild or the monitor took the default action, killing the launcher mid-teardown and orphaning the app (observed: exit 130, app and tail leaked). - Forward only the options each callee accepts, so the poll delay no longer doubles as the SIGTERM grace and a stubbed probe cannot fall through to a real pkill. - Create app.log 0600; it receives the app's whole stdout next to a 0600 env file. - Remove the staging bundle on a failed rebuild instead of leaving ~250MB behind. - Cover the three paths that had none: the opt-in gate where it is consulted, the payload installer, and the marker contract between writer and cache check.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
#1920 established the mechanism macOS TCC requires: an ad-hoc-signed
Maka Dev.applaunched through LaunchServices. That stays. This PR removes the session protocol built around it, scopes the grant per worktree, and puts the whole workflow behindMAKA_DEV_TCC.The session protocol was unnecessary.
openreturns at the LaunchServices handoff and the system can relaunch the app at any time, so launch-time context was pushed throughsession.json,app.pid,launch-status.json, a runtime lock, stale-session recovery, a startup handshake, and a launch-record probe injected intomain.ts. All of it existed to let a detached app pretend it was still a child process. Make the bootstrap depend on build-time constants instead, and publish the environment as ownerless0600data, and a relaunch with no arguments and no environment reproduces a correct app by itself.main.tsreturns to its pre-#1920 state.The grant is scoped per worktree, not pinned to a forgeable identity. TCC keys its rows on the bundle identifier, so the shared
com.maka.devmeant each worktree silently overwrote the others' row. The identifier is nowcom.maka.dev.<worktree-id>.An earlier revision of this PR also pinned
designated => identifier "com.maka.dev"so the grant would survive rebuilds, arguing that this was not weaker because the bundle loadsdist/main/main.jsfrom outside the signature seal. That argument was wrong and the change is reverted. It only shows that repository write access implies use of the grant; it does not show the converse.codesign --sign -is available to every unprivileged process, so any binary anywhere on the disk can claim the identifier and satisfy the requirement — demonstrated with a 15-line C program in/tmpthat satisfies the identifier requirement and fails a cdhash one. And because TCC rows outlive the code they were granted to, the token stays redeemable after this repository is deleted, at which point "anyone who can write the repo" describes nothing at all. The ad-hoc default cdhash requirement stands instead. A rebuild now costs a re-grant, but a rebuild only happens on an Electron bump or a repository move — not on an ordinarynpm run dev, which is a marker cache hit.npm run devandnpm startwere the same logic twice, and the copies had drifted into different bugs rather than different behaviour.npm startnever followed the log, so underMAKA_DEV_TCCit was completely silent — including bootstrap failures — while the README promised streamed logs. It also republished the environment with no Vite URL, so running it beside a livenpm run devsilently killed that app and brought up a replacement on the stale prebuilt renderer. Both now go through onestartDevelopmentApp.Launch and rebuild reclaim a leftover app. Electron's single-instance lock is keyed on userData, so an app surviving a hard-killed session absorbed the new launch: the new process exited 0, the OLD window came forward against a dead Vite URL, and the liveness probe still reported success.
Claims from earlier revisions that were wrong
Independent review plus direct experiment refuted several things earlier revisions asserted in comments:
openforwards the whole parent environment. The env file earns its place for Dock/Spotlight/"Quit & Reopen", which have no parent shell.Resources/appwould flipapp.isPackagedto trueisPackagedisbasename(execPath) !== 'electron'. Only the executable's name keeps the dev gates on.dittodrops quarantine;xattr -ris too newman ditto:--qtnis the default.man xattr:-ris in the first synopsis line.Excluding
TERMfrom the recorded environment turned out to matter more than "it goes stale":shell-env.tsshort-circuits whenTERMis present, so a recorded one would leave a Dock launch on launchd's minimalPATH.Also fixed:
pkill -f/pgrep -ftake an extended regex, so an unescaped path under e.g.~/Dropbox (Personal)matched nothing and left the app unkillable, while a pattern error was read as "not running"; the single 5s liveness check reported a false failure when the app was quit early, never noticed a quit after 5s (hanging the terminal), and could kill a slow first launch — it is now a monitor that waits for the app to appear and treats a later disappearance as an ordinary session end; signal handlers are registered before the launch await, since a signal arriving during the codesign rebuild took the default action and orphaned the app (observed: exit 130 with the app and its log tail leaked);app.logis created0600; a failed rebuild no longer leaves ~250 MB of staging behind; the staleElectronAsarIntegrityrecord is removed; and.maka-dev-session/stays gitignored because the pre-opt-in launcher ran unconditionally and its leftoversession.jsonholds forwarded API keys.Refs #1920
Verification
On-device, macOS 25.5.0 / Electron 43.1.1:
Identifier=com.maka.dev.5b9c7a5a7a17,designated => cdhash H"12ce55…", helpers keepcom.github.Electron.helper,--verify --deep --strictpasses. An ad-hoc-signed impostor claiming the same identifier is rejected (exit 3); against the previous revision's identifier requirement it was accepted (exit 0).prepare:dev-appleaves the cdhash unchanged, so ordinary development does not churn the bundle the grant is anchored to.npm run devandnpm start: exit 0, no leftover app, no leftovertail, Vite port released. Quitting the app mid-session ends the dev session cleanly (Maka Dev.app quit) instead of hanging.npm startfixes — terminal now shows[startup] app ready; the published Vite URL survives a reclaiming launch instead of being dropped.app.loganddev-env.jsonboth0600.MAKA_DEV_TCCit runsnode .bin/electron <desktopDir>and never touches the bundle.npm run test:scripts78/78 · typecheck · format:check · lint — all clean.Tests execute the generated bootstrap against a stub Electron module rather than regex-matching its source. Six mutations were used to check the suite can actually go red: inverting the opt-in gate, renaming a marker field, dropping the log redirection, moving the payload to
Resources/app, collapsing the forwarded option shapes, and misreporting a failed launch as a normal exit — all six are caught.On not testing the grant by hand
The TCC grant itself was not exercised, and does not need to be for this PR. In TCC terms this branch differs from
mainby one string:main(i.e. #1920) already signs ad hoc and already takes codesign's default cdhash designated requirement. An earlier revision of this PR deviated by pinning-r=identifier; that is now reverted, so the requirement matchesmainand the only remaining change is the per-worktree suffix on the identifier — strictly more isolation.Whether an ad-hoc-signed bundle can hold a grant at all is therefore a premise of #1920, already on
main, neither introduced nor worsened by this PR. The parts this PR does change were verified by experiment rather than by argument: an impostor claiming the identifier is rejected, a rebuild leaves the cdhash unchanged, strict verification passes, and the helper bundles keepcom.github.Electron.helper— whichmain's--deep --identifierhad been overwriting.Known limitation
dev-env.jsonoutlives the session, so a Dock launch long afternpm run devstopped points at a Vite URL that is no longer served and the window stays blank; if another worktree has since taken that port, it loads that renderer instead, which looks like it worked. Deleting the file on exit would break "Quit & Reopen", the capability this design exists to keep; a proper fix belongs inmain-window.ts's load-failure path and is out of scope. Documented in the README.