* 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. * 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 --------- Co-authored-by: Neil <neil@stably.ai>
7.7 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 fromsrc/shared/node-runtime-pin.tsas its main executable, the daemon'sout/mainJS and that slice's node-pty.config/scripts/macos-terminal-host-bundle.cjsbuilds and signs it inafterPack, 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.tskeeps one copy per helper build, named by the helper's CDHash, atuserData/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 withcodesign --verify --strict -R "=<app designated requirement>", and published with onerename; losing that rename to another publisher is fine. An app without the helper falls back to the fork.macos-daemon-launchd.tsruns the copy's Node on the copy'sdaemon-entry.jsas 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-pathstays 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 setsAssociatedBundleIdentifierstocom.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.
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
Running old daemons keep their terminals; a denied terminal is recovered by closing it or restarting the daemon.