Files
orca/docs/reference/macos-daemon-tcc-attribution.md
T
Jinwoo HongandNeil e22fd9c901 feat(macos): show terminal-host Full Disk Access, tag stable-copy daemons, and give Helpers folder strings (#27354)
* fix(macos): preserve terminal privacy identity across app updates

* test(macos): make launchd fixtures portable across host platforms

* fix(macos): probe protected contents and close handles on denial

* fix(i18n): register terminal permission status labels

* fix(macos): fork the daemon when no stable-bundle job can claim the endpoint

A copy, signature, plist or deadline failure before bootstrap, a bootstrap
launchd refused with the job absent, a job whose daemon exited before
answering, and a foreign endpoint all threw out of the launcher. The app then
lost its persistent daemon and fell back to local PTYs, where main forks one.
These now fall through to the existing fork path, which also adopts a foreign
occupant normally instead of as a degraded endpoint. A timed-out bootstrap or
a job still running/unreadable stays fatal, so no second daemon races it.

* feat(telemetry): split daemon origin events by stable-copy launch method

daemon_adopted, daemon_pty_cwd_denied and daemon_pty_cwd_readable gain a
closed-enum launch_method (stable-copy / app-fork / unknown), read from the
spawner path the daemon recorded, so the update fix is measurable per daemon
even when an old forked daemon is adopted across updates. The reliability
gate is a test manifest with no runtime switch, so there is no gate state to
report; spawner_path_class reports the private copy as 'other' alongside
other non-/Applications installs.

* refactor(telemetry): report the stable copy as a spawner path class

The separate launch_method property duplicated what the recorded spawner
path already says. spawner_path_class gains `stable-copy`, classified from
the same path under the private runtime root, and launch_method, its enum
and classifier are removed from all three .strict() daemon origin events.
A deleted copy still reports `missing`.

* refactor(settings): gate terminal-host Full Disk Access on macOS once

The permissions handler already asks for the terminal-host status only on
macOS, so the status function's own platform check and the pane's
'unsupported' branch could never fire. The off-macOS guarantee is now
tested at the handler.

* fix(macos): keep the fork fallback inside the startup PTY gate

The stable launch could run until 55 s after the launch started, so a late
"unavailable" verdict left the fork fallback's own 10 s readiness wait to
finish after the 60 s gate had already moved the app to local PTYs.

The launcher now gives the stable attempt one handoff deadline: the gate
minus the fork's readiness timeout and the 5 s lease/adapter allowance
(45 s). Copy, signature check and plist conversion share one abort signal
that ends early enough for bootstrap and the job's own readiness wait to
meet that deadline; every command otherwise keeps a fixed timeout. An
unavailable verdict past the deadline is reported instead of forking a
daemon that cannot arrive in time. The per-step budget module is gone (its
deletion landed in the previous commit by a staging slip).

The launch body is now two blocks: preparation (plist, plutil, deadline
check) fails as unavailable after removing the copy and job file, and
bootstrap runs in its own try/finally that always deletes the job file.

Retirement is simpler and claims more orphans: one in-flight collection per
process instead of a per-root map and rotating 20-record scan, and the job
record (written before copying) no longer carries a bundle name because
lsof +D scans the whole runtime directory (measured equal cost, same
result on a live copy). A never-submitted copy is retired once its producer
has exited and its job is missing, so a bootstrap timeout or a crash
mid-copy no longer leaves a copy forever; the open-file check still guards
deletion. Collection now starts from the launcher after the attempt.

* docs(macos): ship the daemon launch rules, not the investigation

The signed investigation harness left the tree in the previous telemetry
commit by a staging slip; its results and pinned link stay in the PR. This
trims the reference doc to why the copy and launchd exist, the fallback
states and the cleanup rule, trims the reliability gate to what its
validator needs, and points AGENTS.md at the reference doc next to the
Windows relocation entry.

* fix(macos): hand off to the fork as soon as the stable job exits

A copied daemon refused at exec (for example by Gatekeeper) never answers,
so every fresh start paid the full 10 s readiness wait before the fork
fallback. The readiness loop now reads the job state every 500 ms and
abandons as soon as the job is stopped or missing; a running or
unreadable job is still waited out and never forked beside.

Also drops the dead forkEntryPath the stable launch never read (its
options type now omits it) and un-exports the handoff constant.

* fix(macos): keep daemon copies out of Spotlight and unregister retired ones

Each runtime copy is a full Orca.app, so Spotlight indexed it as an app
and LaunchServices kept a record for it that outlived the copy. A
registered copy claims orca: links and Markdown/CSV files, and becomes
their default handler whenever /Applications/Orca.app is not registered.

New copies go under runtime-*/app.noindex/, which Spotlight skips and which
stops the record LaunchServices made while the bundle was being copied.
Retirement now runs lsregister -u on every bundle in the copy (both the
app.noindex layout and the earlier runtime-*/Orca.app layout) once lsof
shows it unused, then deletes it. A running copy stays registered,
because Local Network access may be resolved through that record.

Collection and stable-copy telemetry match the runtime-* directory, so
copies made before this change are still adopted, classified and retired.

* docs(macos): record the LaunchServices trade-off and the rollback plan

The reference doc now explains why copies sit in a .noindex folder, why
only retired copies are unregistered, and how to turn the copy launch off:
make launchMacDaemonFromStableBundle return null, and keep the retirement
code until no copy-launched daemon can remain, because the old code never
deletes copies.

A test pins that rollback: with the stable launcher returning null, the
launcher forks and still runs the copy collection.

* fix(macos): keep daemon copies registered so Local Network can resolve them

The `.noindex` folder kept copies out of Spotlight and LaunchServices, but
Local Network access is resolved from the signing identifier through
LaunchServices to the executable's Mach-O UUID. After an update that changes
Electron only the copy carries the running daemon's UUID, so an unregistered
copy risks the same Local Network loss main has today. Copies go back to
`runtime-*/Orca.app`; retirement still unregisters each copy before deleting
it, so deleted copies leave no LaunchServices entry. The remaining trade-off:
while `/Applications/Orca.app` is missing, a running registered copy can be
picked for `orca:` links and documents.

* fix(macos): hide daemon copies from Spotlight and tie the job to Orca

Spotlight listed each runtime copy as a second Orca app. New copies go
back under runtime-*/app.noindex/Orca.app, which Spotlight skips.
Retirement and stable-copy telemetry match the runtime-* directory, so
copies in the earlier runtime-*/Orca.app layout still retire and classify
the same way.

Local Network access identifies the daemon by its code signature and
main-executable UUID, not by a LaunchServices record, so an unregistered
copy keeps it. The launchd job now sets AssociatedBundleIdentifiers to
com.stablyai.orca, which is how a job not installed through SMAppService
names the app it belongs to. Copies are never registered explicitly;
retirement still runs lsregister -u before deleting each one.

* feat(macos): package a signed terminal host helper that runs the daemon on the pinned Node

Contents/Helpers/Orca Terminal Host.app carries the pinned Node, the daemon's
out/main JS and this slice's node-pty, signed inside-out as com.stablyai.orca.
afterPack boots it through one PTY round trip, then fails the build on layout,
Info.plist, macOS floor (NODE_RUNTIME_DARWIN_MINIMUM_OS vs Node's minos),
architecture or signature drift.

* ci(macos): cache and prefetch the pinned Node before packaging

Packaging now fetches the darwin Node from nodejs.org, whose download has no
retry. Each mac workflow caches it with the electron-builder downloads, keyed on
the pin, and warms both darwin targets inside the existing retry wrapper.

* feat(macos): launch the daemon from a copy of the terminal host helper, not the whole app

The stable launch now clones Contents/Helpers/Orca Terminal Host.app (~124 MiB
instead of ~628 MiB) and runs its Node on its daemon entry, without
ELECTRON_RUN_AS_NODE. The copy must still match the running app's designated
requirement. A missing helper falls back to the fork, and macOS below the
helper's Node floor forks without copying.

* docs(macos): describe the terminal host helper, its floor and its identity

* fix(macos): bound the terminal host boot check by its deadline and always clean up

A daemon that stayed silent hung packaging past the 20 s deadline; one that closed the
connection left the hello unsettled so Node exited 0 without cleanup; a malformed line threw
uncaught. Every connect, hello and PTY wait now races the deadline, close/error/parse failures
reject, and the daemon and scratch dir are removed on every path.

* ci(macos): cache the pinned Node on its own key with no restore-keys fallback

Sharing the electron-builder cache let old Node archives accumulate through restore-keys.
Also moves release-mac-build's Electron cache comment back onto its step.

* fix(macos): reject a universal slice for the terminal host and pin its name in one test

packageMacTerminalHost turned an unknown arch into darwin-undefined; it now throws through the
shared mac arch mapping. A test asserts the packager and the desktop agree on the helper bundle
and executable names, replacing a Linux/Windows test that could not fail.

* fix(macos): pass the launchd adoption lease its options object after the main merge

* test(macos): accept a reset as the daemon closing the boot-check connection; keep only passing gate commands

* fix(daemon): bump the terminal daemon protocol to v45 so new macOS tabs start in the terminal host helper

* fix(macos): ship the daemon's required packages in the terminal host and boot it outside the build tree

* style(macos): use a template literal in the terminal host resolution check

* fix(macos): derive the terminal host's packages from the daemon's own module graph

* fix(macos): treat any non-literal daemon load as computed, follow directory loads, and keep nested dependencies

* refactor(macos): move terminal-host telemetry, Settings status and Helper folder strings to a follow-up

Keeps this change to the update-safe terminal host itself. The follow-up restores:
- the `stable-copy` spawner path class in daemon adoption telemetry;
- the per-terminal-host Full Disk Access row in Settings and its daemon RPC;
- writing folder usage descriptions into the app's Electron Helpers for the fork fallback.

The helper still takes its Desktop, Documents and Downloads descriptions from
`macos-folder-usage-descriptions.cjs`, so that constant stays.

* feat(macos): report terminal-host Full Disk Access, classify stable-copy daemons, and give Helpers folder strings

Restores what a32018836b moved out of the terminal host change:
- daemon adoption telemetry classifies a daemon spawned from the private copy as `stable-copy`;
- Settings shows the terminal hosts' own Full Disk Access verdict beside the app's, through a
  new `fullDiskAccessStatus` daemon request that older daemons answer as unknown;
- packaging writes the Desktop, Documents and Downloads descriptions into the app's Helper
  bundles, which still host terminals when the fork fallback runs.

* fix(i18n): restore en.json to this branch's base, not newer main

The previous commit took the file from a newer main, which pulled unrelated strings into this PR.

* refactor(macos): publish one terminal-host copy per helper build under a fixed launchd label

Name the copy after the helper's CDHash, reuse it when present, and
publish a new one by cloning into a staging dir, verifying it once
against the app's designated requirement and renaming it into place.
One launchd label per profile and protocol replaces per-launch labels,
so job.json, producer pids and per-label collection go away. Retirement
deletes any other copy lsof shows unused. The stable launcher returns
null for every fork fallback and logs its reason.

* fix(packaging): copy terminal-host dependencies from where Node resolves them

A nested package's own dependencies were never walked, so a hoisted
dependency of a nested package was left out of the helper. Resolve each
dependency through the node_modules lookup Node performs from its parent
and copy it to the same relative place, unless a copied package already
carries it.

* fix(packaging): fail release and CI mac builds that cannot boot a terminal-host slice

An arm64 runner without Rosetta skipped the x64 helper's PTY boot check
and shipped the slice untested. Release channels and CI now fail there;
local cross-arch builds still skip with a log line.

* docs(macos): describe the per-build terminal-host copy and its fallback reasons

* docs(macos): keep the codesign command on one line

* test(macos): type the launchd test state without an assertion

* docs(macos): name what the login-session flag gates in the stable launcher

* fix(macos): wait for launchd to drop a booted-out terminal job before reusing its label

* refactor(daemon): keep the health request types in their own module

types.ts hit the line limit once the Full Disk Access status request joined the union.

---------

Co-authored-by: Neil <neil@stably.ai>
2026-10-11 16:03:26 -04:00

8.0 KiB

macOS terminal permissions across updates

Read this before changing how the packaged macOS terminal daemon is launched, copied or cleaned up, or before treating a missing executable as a permission denial. Background: issue #13921; the signed native evidence is in PR #25848.

Why a signed copy started by launchd

A daemon forked from the app runs as Orca Helper and macOS charges its privacy access to the app at its original path. While an update replaces that path, a daemon that first touches a protected file is denied, and the denial persists after the new bundle arrives even though System Settings still shows the grant. A missing executable alone does not cause it: a daemon that already read protected files keeps working after its old executable is deleted.

So a packaged macOS GUI app starts its daemon from a private copy that the updater never touches:

  • Each packaged macOS app ships a signed helper, Contents/Helpers/Orca Terminal Host.app (about 124 MiB): the pinned Node from src/shared/node-runtime-pin.ts as its main executable, the daemon's out/main JS and that slice's node-pty. config/scripts/macos-terminal-host-bundle.cjs builds and signs it in afterPack, boots it through one PTY round trip, and fails the build on any layout, Info.plist, OS floor, architecture or signature drift.
  • macos-daemon-bundle.ts keeps one copy per helper build, named by the helper's CDHash, at userData/daemon-host/macos/terminal-host-<cdhash>/app.noindex/Orca Terminal Host.app. The helper is read from the bundle the running main process executes from, never the whole app. A present copy is reused as is. Otherwise the helper is cloned (APFS clone, regular copy otherwise) into a .staging-<pid>-* sibling, verified once with codesign --verify --strict -R "=<app designated requirement>", and published with one rename; losing that rename to another publisher is fine. An app without the helper falls back to the fork.
  • macos-daemon-launchd.ts runs the copy's Node on the copy's daemon-entry.js as a non-persistent launchd job, so the daemon has Orca's own identity rather than the UI process's replaceable path. The label is fixed per profile and protocol (com.stablyai.orca.terminal.<profile hash>.v<protocol>). Before bootstrap a stopped job with that label is booted out; a running or unreadable one is fatal. --entry-path stays the installed app's entry, which the replacement preflight compares. The job file is 0600 and deleted right after bootstrap, because the environment may hold credentials. It sets AssociatedBundleIdentifiers to com.stablyai.orca, which is how a launchd job not installed through SMAppService names the app it belongs to for Local Network access. Readiness is the normal authenticated handshake, fenced by the launch nonce.

Node servers, SSH hosts, unpackaged builds, Linux and Windows keep the fork launcher. Adopting an existing daemon never copies. Telemetry reports a daemon started from a copy as spawner_path_class=stable-copy.

Only Developer ID builds exercise the helper path in practice. A local ad hoc build's designated requirement is a cdhash, which the helper (a different binary) cannot match, so it falls back to the fork.

Why the helper is com.stablyai.orca

TCC records grants against the designated requirement, which for Orca is the identifier plus team. Signing the helper with the app's own identifier lets it inherit every existing grant, including Full Disk Access, which a user can only add by hand. A distinct identifier would be a new client: new folder prompts naming it, and a new Full Disk Access row for every user.

macOS floor

The helper's Node declares LC_BUILD_VERSION minos 13.5, which is also the helper's LSMinimumSystemVersion and NODE_RUNTIME_DARWIN_MINIMUM_OS. Packaging fails if a Node pin bump changes it. Below 13.5 the launcher returns before copying and the fork runs, so those Macs keep the pre-#25848 behaviour; Orca itself still supports macOS 12.0.

First launch

macOS takes about 1 s the first time a binary runs from a new path, so the first daemon of each build starts that much slower; later daemons reuse the copy and pay nothing. During that first exec the daemon's /usr/bin/login preflight can time out, and that one shell is spawned directly.

Security

The helper is a signed "run any JS as Orca" binary: it honours NODE_OPTIONS and any script path, and Electron's fuses do not apply to it. Orca's own binary already allows the same, because its RunAsNode fuse is enabled, so the helper adds no new exposure.

Fallback states

launchMacDaemonFromStableBundle returns null, and the launcher forks, only when no job from the attempt can still claim the endpoint. It logs one [daemon] macOS terminal host fallback reason=<…> line with MacStableLaunchFallbackReason: prepare-failed (helper, copy, signature or job file), deadline, bootstrap-refused (launchd refused the job and it is absent), exited-before-ready (the job stopped before answering), or endpoint-taken (another daemon owns the endpoint, which the fork path then adopts normally). After bootstrap-refused or exited-before-ready the copy is deleted if unused, so the next launch re-copies it. A timed-out bootstrap, or a job still running or unreadable, throws: forking beside it could split the endpoint.

The stable attempt must hand off by MAC_STABLE_LAUNCH_HANDOFF_MS after the launch starts, so the fork's own readiness wait and adapter install still fit the 60 s startup PTY gate. Preparation shares one abort signal that ends early enough for bootstrap and the job's readiness wait to fit too. A fallback after the handoff deadline throws instead of forking a daemon that would arrive after the app has moved to local PTYs.

Cleanup rule

After publishing or reusing the current copy, each launch starts one background collection (one at a time per process). It deletes every other entry under daemon-host/macos/, older copies and crashed staging dirs, once lsof +D over it reports no process. lsof +D exits 1 even when it lists users, so only empty stdout counts as unused; any timeout, stderr, truncated output or other exit code keeps the copy. Nothing else can run a non-current copy: only this app launches daemons, and only from the current copy. Staging dirs named with this process's pid are skipped, since they may still be copying.

LaunchServices and Spotlight

The helper declares no links, documents or types, has a lower CFBundleVersion than the app and LSUIElement, and was not registered in the signed spike, nested or copied. Copies sit in a .noindex folder, which Spotlight skips, and Orca never registers them. Starting the daemon through launchd leaves no record, but another process running the copy's executable directly can make macOS register it, so retirement runs lsregister -u on the bundle before deleting it.

The helper's main-executable UUID is Node's, shared by every official Node 24.21.0 binary of that architecture, so it is not unique to Orca. Local Network access still passed in the signed spike: macOS matched the client by its code signature, and the job's AssociatedBundleIdentifiers ties it to Orca. Do not register copies explicitly (lsregister -f or LSRegisterURL).

Rollback

To turn this off, make launchMacDaemonFromStableBundle (macos-daemon-launchd.ts) return null right after its platform checks: every launch then takes the fork path. Copy-launched daemons that are still running are adopted as usual. Copies are only deleted by the collection, so keep it running there (retireOtherMacDaemonCopies(root, '')) until no copy-launched daemon can remain; each copy keeps about 124 MiB.

Existing sessions and status

Running old daemons keep their terminals; settings reports app and terminal-host Full Disk Access separately, and a denied terminal is recovered by closing it or restarting the daemon. The packaging hook also gives the main bundle and every Helper the Desktop, Documents and Downloads descriptions, because the fork fallback still hosts terminals in Orca Helper.