From e22fd9c9016bbf0065dde62f06b3e23b4d37b0b7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:03:26 -0400 Subject: [PATCH] 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 a32018836b2 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 --- config/electron-builder.config.cjs | 9 ++- config/reliability-gates.jsonc | 21 ++++--- .../macos-folder-usage-descriptions.cjs | 28 ++++++++- .../macos-folder-usage-descriptions.test.mjs | 52 ++++++++++++++++- .../reference/macos-daemon-tcc-attribution.md | 11 ++-- .../daemon-adoption-telemetry-event.test.ts | 22 ++++++- .../daemon/daemon-adoption-telemetry-event.ts | 16 +++++- .../daemon-full-disk-access-status.test.ts | 57 +++++++++++++++++++ .../daemon/daemon-full-disk-access-status.ts | 25 ++++++++ src/main/daemon/daemon-health-requests.ts | 19 +++++++ ...aemon-pty-adapter-full-disk-access.test.ts | 37 ++++++++++++ src/main/daemon/daemon-pty-adapter.ts | 19 +++++++ src/main/daemon/daemon-request-router.ts | 3 + src/main/daemon/daemon-server.test.ts | 7 +++ src/main/daemon/types.ts | 22 +++---- src/main/ipc/developer-permissions.test.ts | 21 ++++++- src/main/ipc/developer-permissions.ts | 9 ++- .../macos-full-disk-access-status.test.ts | 29 +++++++++- src/main/macos-full-disk-access-status.ts | 14 +++-- .../DeveloperPermissionsPane.test.tsx | 17 ++++++ .../settings/DeveloperPermissionsPane.tsx | 28 ++++++++- src/renderer/src/i18n/locales/en.json | 5 +- src/shared/daemon-adoption-telemetry.test.ts | 48 +++++++++++++--- src/shared/daemon-adoption-telemetry.ts | 10 +++- src/shared/developer-permissions-types.ts | 1 + 25 files changed, 474 insertions(+), 56 deletions(-) create mode 100644 src/main/daemon/daemon-full-disk-access-status.test.ts create mode 100644 src/main/daemon/daemon-full-disk-access-status.ts create mode 100644 src/main/daemon/daemon-health-requests.ts create mode 100644 src/main/daemon/daemon-pty-adapter-full-disk-access.test.ts diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index b3c3a6cc102..f8fc9f36060 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -1,7 +1,10 @@ const { chmodSync, existsSync, readdirSync, readFileSync, writeFileSync } = require('node:fs') const { execFileSync } = require('node:child_process') const { join, resolve } = require('node:path') -const { MACOS_FOLDER_USAGE_DESCRIPTIONS } = require('./scripts/macos-folder-usage-descriptions.cjs') +const { + MACOS_FOLDER_USAGE_DESCRIPTIONS, + applyMacHelperFolderUsageDescriptions +} = require('./scripts/macos-folder-usage-descriptions.cjs') const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs') const { assertPackagedDaemonEntryExists, @@ -397,6 +400,10 @@ module.exports = { writeFileSync(join(resourcesDir, 'package-type'), 'AppImage') } if (context.electronPlatformName === 'darwin') { + applyMacHelperFolderUsageDescriptions( + join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`), + context.packager.appInfo.productFilename + ) const architecture = macSliceArch(context.arch, 'local-build compatibility') const version = context.packager.appInfo.version let commit = process.env.ORCA_BUILD_COMMIT || process.env.GITHUB_SHA || 'unknown' diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index eacb68bd994..1a636561482 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -20719,7 +20719,11 @@ "protection": "partial", "owner": "terminal-runtime", "layer": "daemon-native-signing-and-provider-contract", - "surfaces": ["packaged macOS daemon launch", "signed bundle retirement"], + "surfaces": [ + "packaged macOS daemon launch", + "signed bundle retirement", + "Full Disk Access status" + ], "platforms": ["macos", "linux", "windows"], "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime", "mobile"], "coveredPlatforms": ["macos"], @@ -20727,9 +20731,9 @@ "coverageNotes": "Signed arm64 macOS evidence; other platforms and hosts keep the existing launcher. See docs/reference/macos-daemon-tcc-attribution.md.", "motivatingLinks": ["https://github.com/stablyai/orca/issues/13921"], "invariant": "A packaged macOS daemon runs from the verified signed helper copy for its build, which the updater never touches, and never beside a job that may own the endpoint; the fork fallback still fits the startup gate. A copy is deleted only when lsof proves it unused.", - "oracle": "Deterministic launch, fallback-deadline and retirement tests, plus the signed launcher validation recorded in #25848.", + "oracle": "Deterministic launch, fallback-deadline, per-build copy, retirement and status tests, plus the signed launcher validation recorded in #25848.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/daemon/macos-daemon-bundle.test.ts src/main/daemon/macos-daemon-launchd.test.ts src/main/daemon/daemon-init-macos-stable-launch-fallback.test.ts src/main/daemon/macos-daemon-job-state.test.ts src/main/daemon/macos-daemon-bundle-retirement.test.ts config/scripts/macos-folder-usage-descriptions.test.mjs" + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/daemon/macos-daemon-bundle.test.ts src/main/daemon/macos-daemon-launchd.test.ts src/main/daemon/daemon-init-macos-stable-launch-fallback.test.ts src/main/daemon/macos-daemon-job-state.test.ts src/main/daemon/macos-daemon-bundle-retirement.test.ts src/main/daemon/daemon-full-disk-access-status.test.ts src/main/daemon/daemon-pty-adapter-full-disk-access.test.ts config/scripts/macos-folder-usage-descriptions.test.mjs src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx" ], "testFiles": [ "src/main/daemon/macos-daemon-bundle.test.ts", @@ -20737,7 +20741,10 @@ "src/main/daemon/daemon-init-macos-stable-launch-fallback.test.ts", "src/main/daemon/macos-daemon-job-state.test.ts", "src/main/daemon/macos-daemon-bundle-retirement.test.ts", - "config/scripts/macos-folder-usage-descriptions.test.mjs" + "src/main/daemon/daemon-full-disk-access-status.test.ts", + "src/main/daemon/daemon-pty-adapter-full-disk-access.test.ts", + "config/scripts/macos-folder-usage-descriptions.test.mjs", + "src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx" ], "assertionRefs": [ { @@ -20768,10 +20775,10 @@ "date": "2026-10-10", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/daemon/macos-daemon-bundle.test.ts src/main/daemon/macos-daemon-launchd.test.ts src/main/daemon/daemon-init-macos-stable-launch-fallback.test.ts src/main/daemon/macos-daemon-job-state.test.ts src/main/daemon/macos-daemon-bundle-retirement.test.ts config/scripts/macos-folder-usage-descriptions.test.mjs", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/daemon/macos-daemon-bundle.test.ts src/main/daemon/macos-daemon-launchd.test.ts src/main/daemon/daemon-init-macos-stable-launch-fallback.test.ts src/main/daemon/macos-daemon-job-state.test.ts src/main/daemon/macos-daemon-bundle-retirement.test.ts src/main/daemon/daemon-full-disk-access-status.test.ts src/main/daemon/daemon-pty-adapter-full-disk-access.test.ts config/scripts/macos-folder-usage-descriptions.test.mjs src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx", "result": "passed", - "durationSeconds": 1.53, - "summary": "All 64 focused launch, fallback-deadline, per-build copy, retirement and packaging tests passed." + "durationSeconds": 2.38, + "summary": "All 82 focused launch, fallback-deadline, per-build copy, retirement, status and packaging tests passed." } ], "runtimeBudget": { diff --git a/config/scripts/macos-folder-usage-descriptions.cjs b/config/scripts/macos-folder-usage-descriptions.cjs index 7be46e809f2..6dd3e2c0736 100644 --- a/config/scripts/macos-folder-usage-descriptions.cjs +++ b/config/scripts/macos-folder-usage-descriptions.cjs @@ -1,3 +1,7 @@ +const { readdirSync, writeFileSync } = require('node:fs') +const { join } = require('node:path') +const { execFileSync } = require('node:child_process') + const MACOS_FOLDER_USAGE_DESCRIPTIONS = { NSDesktopFolderUsageDescription: "Orca allows terminal tools to access the user's Desktop folder when requested.", @@ -7,4 +11,26 @@ const MACOS_FOLDER_USAGE_DESCRIPTIONS = { "Orca allows terminal tools to access the user's Downloads folder when requested." } -module.exports = { MACOS_FOLDER_USAGE_DESCRIPTIONS } +function applyMacHelperFolderUsageDescriptions(appPath, productFilename, run = execFileSync) { + const frameworks = join(appPath, 'Contents', 'Frameworks') + for (const entry of readdirSync(frameworks, { withFileTypes: true })) { + if ( + !entry.isDirectory() || + !entry.name.startsWith(`${productFilename} Helper`) || + !entry.name.endsWith('.app') + ) { + continue + } + const plist = join(frameworks, entry.name, 'Contents', 'Info.plist') + const info = JSON.parse( + run('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plist], { encoding: 'utf8' }) + ) + if (!info || typeof info !== 'object' || Array.isArray(info)) { + throw new Error('The packaged macOS Helper has an invalid Info.plist') + } + writeFileSync(plist, JSON.stringify({ ...info, ...MACOS_FOLDER_USAGE_DESCRIPTIONS })) + run('/usr/bin/plutil', ['-convert', 'xml1', plist], { encoding: 'utf8' }) + } +} + +module.exports = { MACOS_FOLDER_USAGE_DESCRIPTIONS, applyMacHelperFolderUsageDescriptions } diff --git a/config/scripts/macos-folder-usage-descriptions.test.mjs b/config/scripts/macos-folder-usage-descriptions.test.mjs index 71969fbc046..8d5d9287222 100644 --- a/config/scripts/macos-folder-usage-descriptions.test.mjs +++ b/config/scripts/macos-folder-usage-descriptions.test.mjs @@ -1,11 +1,57 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { createRequire } from 'node:module' -import { expect, it } from 'vitest' +import { expect, it, vi } from 'vitest' const require = createRequire(import.meta.url) -const { MACOS_FOLDER_USAGE_DESCRIPTIONS } = require('./macos-folder-usage-descriptions.cjs') +const { + MACOS_FOLDER_USAGE_DESCRIPTIONS, + applyMacHelperFolderUsageDescriptions +} = require('./macos-folder-usage-descriptions.cjs') const config = require('../electron-builder.config.cjs') -it('gives the app the same three folder descriptions as the terminal host helper', () => { +it('uses the same three folder descriptions for the app and every packaged Electron Helper', () => { expect(config.mac.extendInfo).toMatchObject(MACOS_FOLDER_USAGE_DESCRIPTIONS) expect(Object.keys(MACOS_FOLDER_USAGE_DESCRIPTIONS)).toHaveLength(3) + const root = mkdtempSync(join(tmpdir(), 'orca-helper-privacy-')) + try { + const names = [ + 'Orca Dev Helper.app', + 'Orca Dev Helper (GPU).app', + 'Orca Dev Helper (Renderer).app', + 'Orca Dev Helper (Plugin).app', + 'Orca Computer.app' + ] + for (const name of names) { + const contents = join(root, 'Contents', 'Frameworks', name, 'Contents') + mkdirSync(contents, { recursive: true }) + writeFileSync( + join(contents, 'Info.plist'), + JSON.stringify({ CFBundleIdentifier: name, preserved: true }) + ) + } + const run = vi.fn((_program, args) => + args.includes('json') ? readFileSync(args.at(-1), 'utf8') : '' + ) + applyMacHelperFolderUsageDescriptions(root, 'Orca Dev', run) + for (const name of names.slice(0, 4)) { + expect( + JSON.parse( + readFileSync(join(root, 'Contents', 'Frameworks', name, 'Contents', 'Info.plist'), 'utf8') + ) + ).toEqual({ CFBundleIdentifier: name, preserved: true, ...MACOS_FOLDER_USAGE_DESCRIPTIONS }) + } + expect(run).toHaveBeenCalledTimes(8) + expect( + JSON.parse( + readFileSync( + join(root, 'Contents', 'Frameworks', names[4], 'Contents', 'Info.plist'), + 'utf8' + ) + ) + ).not.toHaveProperty('NSDesktopFolderUsageDescription') + } finally { + rmSync(root, { recursive: true, force: true }) + } }) diff --git a/docs/reference/macos-daemon-tcc-attribution.md b/docs/reference/macos-daemon-tcc-attribution.md index 12e676f75bd..26772dda46f 100644 --- a/docs/reference/macos-daemon-tcc-attribution.md +++ b/docs/reference/macos-daemon-tcc-attribution.md @@ -39,7 +39,8 @@ So a packaged macOS GUI app starts its daemon from a private copy that the updat 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. +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 @@ -119,7 +120,9 @@ are still running are adopted as usual. Copies are only deleted by the collectio there (`retireOtherMacDaemonCopies(root, '')`) until no copy-launched daemon can remain; each copy keeps about 124 MiB. -## Existing sessions +## Existing sessions and status -Running old daemons keep their terminals; a denied terminal is recovered by closing it or restarting -the daemon. +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`. diff --git a/src/main/daemon/daemon-adoption-telemetry-event.test.ts b/src/main/daemon/daemon-adoption-telemetry-event.test.ts index fc493ea75ff..e4024f3f620 100644 --- a/src/main/daemon/daemon-adoption-telemetry-event.test.ts +++ b/src/main/daemon/daemon-adoption-telemetry-event.test.ts @@ -8,6 +8,7 @@ const { existsSyncMock, readFileSyncMock, getVersionMock, + getPathMock, codeIdentityMock } = vi.hoisted(() => ({ trackMock: vi.fn(), @@ -15,6 +16,7 @@ const { existsSyncMock: vi.fn(() => true), readFileSyncMock: vi.fn(), getVersionMock: vi.fn(() => '1.4.191'), + getPathMock: vi.fn((_name: string) => '/Users/alice/Library/Application Support/Orca'), codeIdentityMock: vi.fn(async () => 'parked') })) vi.mock('../telemetry/client', () => ({ track: trackMock })) @@ -35,7 +37,7 @@ vi.mock('node:os', async (importOriginal) => ({ homedir: () => '/Users/alice' })) vi.mock('../../shared/app-environment', () => ({ - getAppEnvironment: () => ({ getVersion: getVersionMock }) + getAppEnvironment: () => ({ getVersion: getVersionMock, getPath: getPathMock }) })) import { @@ -110,6 +112,24 @@ describe('classifyDaemonAdoptionOrigin', () => { }) expect(codeIdentityMock).toHaveBeenLastCalledWith(undefined) }) + + it('reports a daemon started from the private stable copy', async () => { + const copies = '/Users/alice/Library/Application Support/Orca/daemon-host/macos' + const stableCopy = { + ...stalePidRecord, + spawnerExecPath: `${copies}/terminal-host-0f3c/app.noindex/Orca Terminal Host.app/Contents/MacOS/orca-terminal-host` + } + expect(await classifyDaemonAdoptionOrigin(stableCopy)).toMatchObject({ + spawner_path_class: 'stable-copy' + }) + expect(getPathMock).toHaveBeenCalledWith('userData') + getPathMock.mockImplementationOnce(() => { + throw new Error('AppEnvironment not initialized') + }) + expect(await classifyDaemonAdoptionOrigin(stableCopy)).toMatchObject({ + spawner_path_class: 'other' + }) + }) }) describe('trackDaemonAdopted', () => { diff --git a/src/main/daemon/daemon-adoption-telemetry-event.ts b/src/main/daemon/daemon-adoption-telemetry-event.ts index e912abb1751..48be0060eaf 100644 --- a/src/main/daemon/daemon-adoption-telemetry-event.ts +++ b/src/main/daemon/daemon-adoption-telemetry-event.ts @@ -4,6 +4,7 @@ import { existsSync } from 'node:fs' import { homedir } from 'node:os' +import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import { classifyDaemonPtyCwd, @@ -17,6 +18,7 @@ import type { EventProps } from '../../shared/telemetry-events' import { track } from '../telemetry/client' import { readDaemonPidRecord } from './daemon-endpoint-incarnation' import { getDaemonMacCodeIdentity } from './daemon-mac-code-identity' +import { getMacDaemonBundleRoot, MAC_DAEMON_COPY_PREFIX } from './macos-daemon-bundle' import { enumerateDirectoryOnce } from './directory-enumeration-probe' import type { ParsedDaemonPid } from './daemon-pid-file-parse' import type { MacDaemonTccAttributionHealth } from './daemon-tcc-attribution' @@ -31,6 +33,17 @@ export type DaemonAdoptionOrigin = Pick< 'app_version_match' | 'code_identity' | 'spawner_path_class' > +function stableCopyPrefix(): string | null { + try { + return join( + getMacDaemonBundleRoot(getAppEnvironment().getPath('userData')), + MAC_DAEMON_COPY_PREFIX + ) + } catch { + return null + } +} + /** Classifies the adopted daemon's pid record against the running app; enum-only by construction. */ export async function classifyDaemonAdoptionOrigin( pidRecord: ParsedDaemonPid | null @@ -42,7 +55,8 @@ export async function classifyDaemonAdoptionOrigin( : 'different' const spawnerPathClass: DaemonSpawnerPathClass = classifyDaemonSpawnerPath( pidRecord?.spawnerExecPath ?? null, - existsSync + existsSync, + stableCopyPrefix() ) return { app_version_match: appVersionMatch, diff --git a/src/main/daemon/daemon-full-disk-access-status.test.ts b/src/main/daemon/daemon-full-disk-access-status.test.ts new file mode 100644 index 00000000000..b92c610a842 --- /dev/null +++ b/src/main/daemon/daemon-full-disk-access-status.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type { DeveloperPermissionStatus } from '../../shared/developer-permissions-types' + +const { state, current, legacy, DegradedProvider, localStatus } = vi.hoisted(() => ({ + state: { installed: true, degraded: false }, + DegradedProvider: class {}, + localStatus: vi.fn<() => Promise>(), + current: { getFullDiskAccessStatus: vi.fn<() => Promise>() }, + legacy: { getFullDiskAccessStatus: vi.fn<() => Promise>() } +})) +vi.mock('./daemon-provider-state', () => ({ + getDaemonProvider: () => (state.installed ? (state.degraded ? new DegradedProvider() : {}) : null) +})) +vi.mock('./degraded-daemon-pty-provider', () => ({ DegradedDaemonPtyProvider: DegradedProvider })) +vi.mock('../macos-full-disk-access-status', () => ({ getMacosFullDiskAccessStatus: localStatus })) +vi.mock('./daemon-provider-routing', () => ({ + getCurrentDaemonAdapter: () => current, + getLegacyDaemonAdapters: () => [legacy] +})) +import { getTerminalHostsFullDiskAccessStatus } from './daemon-full-disk-access-status' + +beforeEach(() => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + state.installed = true + state.degraded = false + localStatus.mockReset().mockResolvedValue('denied') + current.getFullDiskAccessStatus.mockReset().mockResolvedValue('granted') + legacy.getFullDiskAccessStatus.mockReset().mockResolvedValue('granted') +}) + +it.each(['granted', 'denied', 'unknown'] as const)( + 'includes the older generation when it reports %s', + async (status) => { + legacy.getFullDiskAccessStatus.mockResolvedValue(status) + expect(await getTerminalHostsFullDiskAccessStatus()).toBe(status) + expect(current.getFullDiskAccessStatus).toHaveBeenCalledTimes(1) + expect(legacy.getFullDiskAccessStatus).toHaveBeenCalledTimes(1) + } +) + +it('keeps an observed denial even if another generation cannot answer', async () => { + current.getFullDiskAccessStatus.mockResolvedValue('unknown') + legacy.getFullDiskAccessStatus.mockResolvedValue('denied') + expect(await getTerminalHostsFullDiskAccessStatus()).toBe('denied') +}) + +it('does not launch a daemon merely to check permissions', async () => { + state.installed = false + expect(await getTerminalHostsFullDiskAccessStatus()).toBe('unknown') + expect(current.getFullDiskAccessStatus).not.toHaveBeenCalled() +}) + +it('includes the in-process host used for fresh terminals in degraded mode', async () => { + state.degraded = true + expect(await getTerminalHostsFullDiskAccessStatus()).toBe('denied') + expect(localStatus).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/daemon/daemon-full-disk-access-status.ts b/src/main/daemon/daemon-full-disk-access-status.ts new file mode 100644 index 00000000000..a7fc132cb5f --- /dev/null +++ b/src/main/daemon/daemon-full-disk-access-status.ts @@ -0,0 +1,25 @@ +import type { DeveloperPermissionStatus } from '../../shared/developer-permissions-types' +import { getCurrentDaemonAdapter, getLegacyDaemonAdapters } from './daemon-provider-routing' +import { getDaemonProvider } from './daemon-provider-state' +import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' +import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status' + +/** macOS only; the caller gates it. A denied generation wins; granted needs every generation. */ +export async function getTerminalHostsFullDiskAccessStatus(): Promise { + const provider = getDaemonProvider() + if (!provider) { + return 'unknown' + } + const probes = [getCurrentDaemonAdapter(provider), ...getLegacyDaemonAdapters(provider)].map( + (adapter) => adapter.getFullDiskAccessStatus() + ) + if (provider instanceof DegradedDaemonPtyProvider) { + probes.push(getMacosFullDiskAccessStatus()) + } + const statuses = await Promise.all(probes) + return statuses.includes('denied') + ? 'denied' + : statuses.every((status) => status === 'granted') + ? 'granted' + : 'unknown' +} diff --git a/src/main/daemon/daemon-health-requests.ts b/src/main/daemon/daemon-health-requests.ts new file mode 100644 index 00000000000..60c7170adfa --- /dev/null +++ b/src/main/daemon/daemon-health-requests.ts @@ -0,0 +1,19 @@ +export type PingRequest = { + id: string + type: 'ping' +} + +export type SystemResolverHealthRequest = { + id: string + type: 'systemResolverHealth' +} + +export type PtySpawnHealthRequest = { + id: string + type: 'ptySpawnHealth' +} + +export type FullDiskAccessStatusRequest = { + id: string + type: 'fullDiskAccessStatus' +} diff --git a/src/main/daemon/daemon-pty-adapter-full-disk-access.test.ts b/src/main/daemon/daemon-pty-adapter-full-disk-access.test.ts new file mode 100644 index 00000000000..1f45be336a5 --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-full-disk-access.test.ts @@ -0,0 +1,37 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { DaemonClient } from './client' +import { DaemonPtyAdapter } from './daemon-pty-adapter' + +afterEach(() => vi.restoreAllMocks()) + +it.each([ + [{ status: 'granted' }, 'granted'], + [{ status: 'denied' }, 'denied'], + [{ status: 'future-status' }, 'unknown'], + [null, 'unknown'] +])('checks the owning connection and validates %j', async (response, expected) => { + vi.spyOn(DaemonClient.prototype, 'isConnected').mockReturnValue(true) + const request = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue(response) + const adapter = new DaemonPtyAdapter({ socketPath: '/unused', tokenPath: '/unused' }) + expect(await adapter.getFullDiskAccessStatus()).toBe(expected) + expect(request).toHaveBeenCalledWith('fullDiskAccessStatus', undefined, 3000) +}) + +it.each(['Unknown request type', 'Request timed out', 'Connection lost'])( + 'keeps old or unreachable hosts unknown: %s', + async (message) => { + vi.spyOn(DaemonClient.prototype, 'isConnected').mockReturnValue(true) + vi.spyOn(DaemonClient.prototype, 'request').mockRejectedValue(new Error(message)) + const adapter = new DaemonPtyAdapter({ socketPath: '/unused', tokenPath: '/unused' }) + expect(await adapter.getFullDiskAccessStatus()).toBe('unknown') + } +) + +it('does not reconnect or respawn a disconnected host to obtain a verdict', async () => { + const request = vi.spyOn(DaemonClient.prototype, 'request') + const connect = vi.spyOn(DaemonClient.prototype, 'ensureConnected') + const adapter = new DaemonPtyAdapter({ socketPath: '/unused', tokenPath: '/unused' }) + expect(await adapter.getFullDiskAccessStatus()).toBe('unknown') + expect(request).not.toHaveBeenCalled() + expect(connect).not.toHaveBeenCalled() +}) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 098142a3fe7..af08ffc3c7d 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -3,6 +3,7 @@ import { DaemonPtyDaemonRecovery } from './daemon-pty-daemon-recovery' import { supportsMode2031UnsubscribeFact, type DaemonEvent } from './types' import type { DaemonEndpointIdentity } from './daemon-hello-protocol' import type { IPtyProvider } from '../providers/types' +import type { DeveloperPermissionStatus } from '../../shared/developer-permissions-types' import { AUTOMATIC_WORK_RESUME_UNSUPPORTED, type AutomaticWorkResumeOutcome, @@ -13,6 +14,24 @@ import { isUnknownRequestTypeError } from './daemon-endpoint-errors' import type { AutomaticWorkRequestType } from './daemon-automatic-work-protocol' export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyProvider { + async getFullDiskAccessStatus(): Promise { + if (!this.client.isConnected()) { + return 'unknown' + } + try { + const result = await this.client.request('fullDiskAccessStatus', undefined, 3000) + if (result && typeof result === 'object' && 'status' in result) { + const { status } = result + if (status === 'granted' || status === 'denied' || status === 'unsupported') { + return status + } + } + } catch { + // Older daemons and lost host contact cannot establish a permission verdict. + } + return 'unknown' + } + /** Identity of the daemon behind this adapter; null until hello completes or after a disconnect. */ getDaemonIdentity(): DaemonEndpointIdentity | null { return this.client.getDaemonIdentity() diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index 1184517c6dc..28f4d073a0d 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -1,6 +1,7 @@ import { performance } from 'node:perf_hooks' import { setPtyOwnerHostColors } from '../../shared/pty-owner-color-query-colors' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' +import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status' import type { ConnectedDaemonClient, DaemonClientConnections } from './daemon-client-connections' import type { DaemonFileLog } from './daemon-file-log' import type { DaemonPtySpawnPreparations } from './daemon-pty-spawn-preparations' @@ -155,6 +156,8 @@ export class DaemonRequestRouter { return { pong: true } case 'systemResolverHealth': return { health: await readCurrentProcessMacSystemResolverHealth() } + case 'fullDiskAccessStatus': + return { status: await getMacosFullDiskAccessStatus() } case 'ptySpawnHealth': await this.options.ptySpawnHealthCheck() return { healthy: true, coverage: ptySpawnHealthPlatformCoverage() } diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 3479b4eea45..8d30326e857 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -437,6 +437,13 @@ describe('DaemonServer', () => { expect(['healthy', 'unhealthy', 'unknown']).toContain(result.health) }) + it('probes Full Disk Access in the owning daemon', async () => { + await startServer() + const c = await connectClient() + const result = await c.request<{ status: unknown }>('fullDiskAccessStatus', undefined) + expect(['granted', 'denied', 'unknown', 'unsupported']).toContain(result.status) + }) + it('handles resize', async () => { await startServer() const c = await connectClient() diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index ff7068fecf0..5819978ce26 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,3 +1,9 @@ +import type { + FullDiskAccessStatusRequest, + PingRequest, + PtySpawnHealthRequest, + SystemResolverHealthRequest +} from './daemon-health-requests' import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { ConfirmForegroundProcessRequest, @@ -221,21 +227,6 @@ export type ShutdownRequest = { } } -export type PingRequest = { - id: string - type: 'ping' -} - -export type SystemResolverHealthRequest = { - id: string - type: 'systemResolverHealth' -} - -export type PtySpawnHealthRequest = { - id: string - type: 'ptySpawnHealth' -} - export type GetSnapshotRequest = { id: string type: 'getSnapshot' @@ -325,6 +316,7 @@ export type DaemonRequest = | PingRequest | SystemResolverHealthRequest | PtySpawnHealthRequest + | FullDiskAccessStatusRequest | GetSnapshotRequest | GetSizeRequest | TakePendingOutputRequest diff --git a/src/main/ipc/developer-permissions.test.ts b/src/main/ipc/developer-permissions.test.ts index 5bece99b825..bfea16e6ad8 100644 --- a/src/main/ipc/developer-permissions.test.ts +++ b/src/main/ipc/developer-permissions.test.ts @@ -7,6 +7,7 @@ const { getMediaAccessStatusMock, isTrustedAccessibilityClientMock, getMacosFullDiskAccessStatusMock, + terminalHostsStatusMock, execFileMock, createSocketMock, socketMock, @@ -31,6 +32,7 @@ const { getMediaAccessStatusMock: vi.fn(), isTrustedAccessibilityClientMock: vi.fn(), getMacosFullDiskAccessStatusMock: vi.fn(), + terminalHostsStatusMock: vi.fn(async () => 'denied'), execFileMock: vi.fn(), createSocketMock: vi.fn(() => socketMock), socketMock, @@ -65,6 +67,9 @@ vi.mock('node:child_process', () => ({ vi.mock('../macos-full-disk-access-status', () => ({ getMacosFullDiskAccessStatus: getMacosFullDiskAccessStatusMock })) +vi.mock('../daemon/daemon-full-disk-access-status', () => ({ + getTerminalHostsFullDiskAccessStatus: terminalHostsStatusMock +})) import type { DeveloperPermissionState } from '../../shared/developer-permissions-types' import { registerDeveloperPermissionHandlers } from './developer-permissions' @@ -166,10 +171,24 @@ describe('registerDeveloperPermissionHandlers', () => { await expect(handler?.()).resolves.toContainEqual({ id: 'full-disk-access', - status: 'granted' + status: 'granted', + terminalHostStatus: 'denied' }) }) + it.each(['win32', 'linux'] as const)('does not probe terminal hosts on %s', async (platform) => { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + getMacosFullDiskAccessStatusMock.mockResolvedValue('unsupported') + terminalHostsStatusMock.mockClear() + registerDeveloperPermissionHandlers() + + await expect(getStatusHandler()()).resolves.toContainEqual({ + id: 'full-disk-access', + status: 'unsupported' + }) + expect(terminalHostsStatusMock).not.toHaveBeenCalled() + }) + it('keeps the local-network status unknown when UDP send settles without an error', async () => { registerDeveloperPermissionHandlers() diff --git a/src/main/ipc/developer-permissions.ts b/src/main/ipc/developer-permissions.ts index 1ce5e228324..8116af8f6b7 100644 --- a/src/main/ipc/developer-permissions.ts +++ b/src/main/ipc/developer-permissions.ts @@ -2,6 +2,7 @@ import { execFile } from 'node:child_process' import dgram from 'node:dgram' import { ipcMain, shell, systemPreferences } from 'electron' import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status' +import { getTerminalHostsFullDiskAccessStatus } from '../daemon/daemon-full-disk-access-status' import { testLocalNetworkConnection } from './local-network-connection-test' import type { DeveloperPermissionId, @@ -153,7 +154,13 @@ async function getPermissionState(id: DeveloperPermissionId): Promise ({ + readMock: vi.fn(), + closeMock: vi.fn(), + openMock: vi.fn() +})) +vi.mock('node:fs/promises', () => ({ open: openMock })) import { getMacosFullDiskAccessStatus, probeMacosFullDiskAccess @@ -22,15 +28,34 @@ function fileSystemError(code: string): NodeJS.ErrnoException { afterEach(() => { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) }) +beforeEach(() => { + readMock.mockReset().mockResolvedValue({ bytesRead: 1 }) + closeMock.mockReset().mockResolvedValue(undefined) + openMock.mockReset().mockResolvedValue({ read: readMock, close: closeMock }) +}) describe('probeMacosFullDiskAccess', () => { - it('reports granted only when the TCC database opens for reading', async () => { + it('reports granted only when the TCC database can be read', async () => { const readProbe = vi.fn().mockResolvedValue(undefined) await expect(probeMacosFullDiskAccess({ homeDirectory, readProbe })).resolves.toBe('granted') expect(readProbe).toHaveBeenCalledWith(databasePath) }) + it('reads only one discarded byte and closes the handle', async () => { + expect(await probeMacosFullDiskAccess({ homeDirectory })).toBe('granted') + expect(openMock).toHaveBeenCalledWith(databasePath, 'r') + expect(readMock).toHaveBeenCalledWith(expect.any(Buffer), 0, 1, 0) + expect(readMock.mock.calls[0]?.[0].length).toBe(1) + expect(closeMock).toHaveBeenCalledTimes(1) + }) + + it('does not mistake an allowed open for allowed contents, and closes after denial', async () => { + readMock.mockRejectedValueOnce(fileSystemError('EPERM')) + expect(await probeMacosFullDiskAccess({ homeDirectory })).toBe('denied') + expect(closeMock).toHaveBeenCalledTimes(1) + }) + it.each(['EACCES', 'EPERM'])('reports denied for %s', async (code) => { await expect( probeMacosFullDiskAccess({ diff --git a/src/main/macos-full-disk-access-status.ts b/src/main/macos-full-disk-access-status.ts index aea18b95677..44af4fe8d29 100644 --- a/src/main/macos-full-disk-access-status.ts +++ b/src/main/macos-full-disk-access-status.ts @@ -5,20 +5,24 @@ import type { DeveloperPermissionStatus } from '../shared/developer-permissions- type ReadProbe = (filePath: string) => Promise -async function openForRead(filePath: string): Promise { +async function readProbeByte(filePath: string): Promise { const handle = await open(filePath, 'r') - await handle.close() + try { + await handle.read(Buffer.alloc(1), 0, 1, 0) + } finally { + await handle.close() + } } function errorCode(error: unknown): string | undefined { - return error instanceof Error && 'code' in error - ? (error as NodeJS.ErrnoException).code + return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code : undefined } export async function probeMacosFullDiskAccess({ homeDirectory = homedir(), - readProbe = openForRead + readProbe = readProbeByte }: { homeDirectory?: string readProbe?: ReadProbe diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx index 6400e71795e..f33bc4a8b95 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx @@ -53,6 +53,23 @@ function setPermissionStates(states: DeveloperPermissionState[]): void { api.developerPermissions.getStatus = vi.fn(async () => states) } +it.each(['denied', 'unknown'] as const)( + 'distinguishes app access from a %s terminal host', + async (terminalHostStatus) => { + setPermissionStates([{ id: 'full-disk-access', status: 'granted', terminalHostStatus }]) + await act(async () => root.render()) + const row = container.querySelector( + '[data-settings-section="developer-permissions-full-disk-access"]' + ) + expect(row?.textContent).toContain('App: Granted') + expect(row?.textContent).toContain( + terminalHostStatus === 'denied' ? 'Terminal hosts: Denied' : 'Terminal hosts: Check manually' + ) + expect(row?.textContent).toContain('restarting closes its current terminals') + expect(window.api.developerPermissions.request).not.toHaveBeenCalled() + } +) + it('requests Local Network access without claiming a permission verdict', async () => { setPermissionStates([{ id: 'local-network', status: 'unknown' }]) diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx index 18b48aa918b..2abd9d7a9c5 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx @@ -231,7 +231,7 @@ export function DeveloperPermissionsPane({ const refreshSequenceRef = useRef(0) const stateById = useMemo( - () => new Map(states.map((state) => [state.id, state.status] as const)), + () => new Map(states.map((state) => [state.id, state] as const)), [states] ) @@ -341,7 +341,8 @@ export function DeveloperPermissionsPane({
{PERMISSIONS.map((permission) => { - const status = stateById.get(permission.id) + const state = stateById.get(permission.id) + const status = state?.status const pending = pendingId === permission.id const settingId = `developer-permissions-${permission.id}` @@ -362,10 +363,33 @@ export function DeveloperPermissionsPane({ status )}`} > + {permission.id === 'full-disk-access' && + translate( + 'auto.components.settings.DeveloperPermissionsPane.appStatus', + 'App: ' + )} {developerPermissionStatusLabel(permission.id, status)}

{permission.description}

+ {permission.id === 'full-disk-access' && state?.terminalHostStatus && ( +

+ {translate( + 'auto.components.settings.DeveloperPermissionsPane.terminalHostStatus', + 'Terminal hosts: ' + )} + {developerPermissionStatusLabel(permission.id, state.terminalHostStatus)} + {state.terminalHostStatus !== 'granted' && ( + <> + {' '} + {translate( + 'auto.components.settings.DeveloperPermissionsPane.terminalHostRecovery', + 'If a terminal cannot read protected files, save its work and close it in Terminal → Manage Sessions. Restart the daemon if new terminals are also affected; restarting closes its current terminals.' + )} + + )} +

+ )} { it('classifies the installed app, the ShipIt staging area, and everything else', () => { expect( - classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists) + classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists, null) ).toBe('applications') expect( - classifyDaemonSpawnerPath('/private/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists) + classifyDaemonSpawnerPath( + '/private/Applications/Orca.app/Contents/MacOS/Orca', + alwaysExists, + null + ) ).toBe('applications') expect( classifyDaemonSpawnerPath( '/Users/a/Library/Caches/com.stablyai.orca.ShipIt/update.abc/Orca.app/Contents/MacOS/Orca', - alwaysExists + alwaysExists, + null ) ).toBe('updater-cache') expect( - classifyDaemonSpawnerPath('/Users/a/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists) + classifyDaemonSpawnerPath( + '/Users/a/Applications/Orca.app/Contents/MacOS/Orca', + alwaysExists, + null + ) + ).toBe('other') + expect( + classifyDaemonSpawnerPath('/tmp/OrcaA.app/Contents/MacOS/Orca', alwaysExists, null) ).toBe('other') - expect(classifyDaemonSpawnerPath('/tmp/OrcaA.app/Contents/MacOS/Orca', alwaysExists)).toBe( - 'other' - ) }) it('reports a deleted spawner as missing and an unrecorded one as unknown', () => { expect( - classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', () => false) + classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', () => false, null) ).toBe('missing') - expect(classifyDaemonSpawnerPath(null, alwaysExists)).toBe('unknown') + expect(classifyDaemonSpawnerPath(null, alwaysExists, null)).toBe('unknown') + }) + + it("classifies Orca's private stable copy from the recorded spawner path", () => { + const prefix = '/Users/alice/Library/Application Support/Orca/daemon-host/macos/terminal-host-' + const copy = `${prefix}0f3c/app.noindex/Orca Terminal Host.app/Contents/MacOS/orca-terminal-host` + expect(classifyDaemonSpawnerPath(copy, alwaysExists, prefix)).toBe('stable-copy') + expect(classifyDaemonSpawnerPath(copy, () => false, prefix)).toBe('missing') + expect(classifyDaemonSpawnerPath(copy, alwaysExists, null)).toBe('other') + expect( + classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists, prefix) + ).toBe('applications') }) }) @@ -106,6 +126,16 @@ describe('daemon_adopted / daemon_pty_cwd_denied schemas', () => { expect( eventSchemas.daemon_adopted.safeParse({ ...adopted, code_identity: 'severed' }).success ).toBe(false) + expect( + eventSchemas.daemon_pty_cwd_readable.safeParse({ ...denied, launch_method: 'stable-copy' }) + .success + ).toBe(false) + expect( + eventSchemas.daemon_pty_cwd_readable.safeParse({ + ...denied, + spawner_path_class: 'stable-copy' + }).success + ).toBe(true) }) }) diff --git a/src/shared/daemon-adoption-telemetry.ts b/src/shared/daemon-adoption-telemetry.ts index 149dc4da910..3318f4f5e68 100644 --- a/src/shared/daemon-adoption-telemetry.ts +++ b/src/shared/daemon-adoption-telemetry.ts @@ -9,10 +9,12 @@ export type DaemonAdoptedAppVersionMatch = (typeof DAEMON_ADOPTED_APP_VERSION_MA /** * Where the binary that forked the adopted daemon lives now. `updater-cache` is the Squirrel - * ShipIt staging area — a daemon attributed there is the reported #17696 shape. + * ShipIt staging area — a daemon attributed there is the reported #17696 shape. `stable-copy` is + * Orca's private signed bundle copy, run by launchd (#25848). */ export const DAEMON_SPAWNER_PATH_CLASSES = [ 'applications', + 'stable-copy', 'updater-cache', 'other', 'missing', @@ -60,7 +62,8 @@ export function isMacTccFolderClass(cwdClass: DaemonPtyCwdClass): cwdClass is Ma export function classifyDaemonSpawnerPath( spawnerExecPath: string | null, - exists: (path: string) => boolean + exists: (path: string) => boolean, + stableCopyPrefix: string | null ): DaemonSpawnerPathClass { if (!spawnerExecPath) { return 'unknown' @@ -68,6 +71,9 @@ export function classifyDaemonSpawnerPath( if (!exists(spawnerExecPath)) { return 'missing' } + if (stableCopyPrefix && spawnerExecPath.startsWith(stableCopyPrefix)) { + return 'stable-copy' + } if (/\/Library\/Caches\/[^/]*ShipIt\//.test(spawnerExecPath)) { return 'updater-cache' } diff --git a/src/shared/developer-permissions-types.ts b/src/shared/developer-permissions-types.ts index 6b541e62fef..20255575257 100644 --- a/src/shared/developer-permissions-types.ts +++ b/src/shared/developer-permissions-types.ts @@ -24,6 +24,7 @@ export type DeveloperPermissionStatus = export type DeveloperPermissionState = { id: DeveloperPermissionId status: DeveloperPermissionStatus + terminalHostStatus?: DeveloperPermissionStatus } export type DeveloperPermissionRequestResult = {