Skip to content

fix(desktop): make the macOS dev TCC grant durable, behind an opt-in - #2032

Merged
Astro-Han merged 3 commits into
mainfrom
fix/dev-app-tcc-opt-in
Aug 3, 2026
Merged

fix(desktop): make the macOS dev TCC grant durable, behind an opt-in#2032
Astro-Han merged 3 commits into
mainfrom
fix/dev-app-tcc-opt-in

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

#1920 established the mechanism macOS TCC requires: an ad-hoc-signed Maka Dev.app launched through LaunchServices. That stays. This PR removes the session protocol built around it, scopes the grant per worktree, and puts the whole workflow behind MAKA_DEV_TCC.

The session protocol was unnecessary. open returns at the LaunchServices handoff and the system can relaunch the app at any time, so launch-time context was pushed through 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 build-time constants instead, and publish the environment as ownerless 0600 data, and a relaunch with no arguments and no environment reproduces a correct app by itself. main.ts returns 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.dev meant each worktree silently overwrote the others' row. The identifier is now com.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 loads dist/main/main.js from 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 /tmp that 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 ordinary npm run dev, which is a marker cache hit.

npm run dev and npm start were the same logic twice, and the copies had drifted into different bugs rather than different behaviour. npm start never followed the log, so under MAKA_DEV_TCC it 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 live npm run dev silently killed that app and brought up a replacement on the stale prebuilt renderer. Both now go through one startDevelopmentApp.

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:

Claim Reality
An identifier requirement is not weaker here Refuted by experiment; see above. Reverted to the cdhash default.
LaunchServices does not inherit the shell environment open forwards the whole parent environment. The env file earns its place for Dock/Spotlight/"Quit & Reopen", which have no parent shell.
Resources/app would flip app.isPackaged to true isPackaged is basename(execPath) !== 'electron'. Only the executable's name keeps the dev gates on.
ditto drops quarantine; xattr -r is too new man ditto: --qtn is the default. man xattr: -r is in the first synopsis line.

Excluding TERM from the recorded environment turned out to matter more than "it goes stale": shell-env.ts short-circuits when TERM is present, so a recorded one would leave a Dock launch on launchd's minimal PATH.

Also fixed: pkill -f/pgrep -f take 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.log is created 0600; a failed rebuild no longer leaves ~250 MB of staging behind; the stale ElectronAsarIntegrity record is removed; and .maka-dev-session/ stays gitignored because the pre-opt-in launcher ran unconditionally and its leftover session.json holds forwarded API keys.

Refs #1920

Verification

On-device, macOS 25.5.0 / Electron 43.1.1:

  • Identity and requirementIdentifier=com.maka.dev.5b9c7a5a7a17, designated => cdhash H"12ce55…", helpers keep com.github.Electron.helper, --verify --deep --strict passes. 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).
  • Cache — a second prepare:dev-app leaves the cdhash unchanged, so ordinary development does not churn the bundle the grant is anchored to.
  • Lifecycle — Ctrl-C on both npm run dev and npm start: exit 0, no leftover app, no leftover tail, Vite port released. Quitting the app mid-session ends the dev session cleanly (Maka Dev.app quit) instead of hanging.
  • npm start fixes — terminal now shows [startup] app ready; the published Vite URL survives a reclaiming launch instead of being dropped.
  • Modesapp.log and dev-env.json both 0600.
  • Default path — without MAKA_DEV_TCC it runs node .bin/electron <desktopDir> and never touches the bundle.
  • npm run test:scripts 78/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 main by 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 matches main and 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 keep com.github.Electron.helper — which main's --deep --identifier had been overwriting.

Known limitation

dev-env.json outlives the session, so a Dock launch long after npm run dev stopped 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 in main-window.ts's load-failure path and is out of scope. Documented in the README.

…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
Astro-Han force-pushed the fix/dev-app-tcc-opt-in branch from eb1c5c5 to 022b8ad Compare August 3, 2026 15:57
@Astro-Han Astro-Han changed the title refactor(desktop): make the macOS TCC dev bundle opt-in refactor(desktop): drop dev session supervision, make the TCC bundle opt-in Aug 3, 2026
…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.
@Astro-Han Astro-Han changed the title refactor(desktop): drop dev session supervision, make the TCC bundle opt-in fix(desktop): make the macOS dev TCC grant durable, behind an opt-in Aug 3, 2026
…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.
@Astro-Han
Astro-Han marked this pull request as ready for review August 3, 2026 17:41
@Astro-Han
Astro-Han merged commit a555d6f into main Aug 3, 2026
11 checks passed
@Astro-Han
Astro-Han deleted the fix/dev-app-tcc-opt-in branch August 3, 2026 17:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant