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>
This commit is contained in:
Jinwoo Hong
2026-10-11 16:03:26 -04:00
committed by GitHub
co-authored by Neil
parent 37c2b43876
commit e22fd9c901
25 changed files with 474 additions and 56 deletions
+8 -1
View File
@@ -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'
+14 -7
View File
@@ -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": {
@@ -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 }
@@ -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 })
}
})
@@ -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`.
@@ -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', () => {
@@ -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,
@@ -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<DeveloperPermissionStatus>>(),
current: { getFullDiskAccessStatus: vi.fn<() => Promise<DeveloperPermissionStatus>>() },
legacy: { getFullDiskAccessStatus: vi.fn<() => Promise<DeveloperPermissionStatus>>() }
}))
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)
})
@@ -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<DeveloperPermissionStatus> {
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'
}
+19
View File
@@ -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'
}
@@ -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()
})
+19
View File
@@ -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<DeveloperPermissionStatus> {
if (!this.client.isConnected()) {
return 'unknown'
}
try {
const result = await this.client.request<unknown>('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()
+3
View File
@@ -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() }
+7
View File
@@ -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()
+7 -15
View File
@@ -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
+20 -1
View File
@@ -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()
+8 -1
View File
@@ -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<DeveloperP
case 'accessibility':
return { id, status: getAccessibilityStatus() }
case 'full-disk-access':
return { id, status: await getMacosFullDiskAccessStatus() }
return {
id,
status: await getMacosFullDiskAccessStatus(),
...(process.platform === 'darwin'
? { terminalHostStatus: await getTerminalHostsFullDiskAccessStatus() }
: {})
}
// Why 'unknown' and not a probe: macOS reports no per-app Files-and-Folders grant, and the
// only caller opens the pane rather than reading a status.
case 'files-and-folders':
+27 -2
View File
@@ -1,5 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
const { readMock, closeMock, openMock } = vi.hoisted(() => ({
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({
+9 -5
View File
@@ -5,20 +5,24 @@ import type { DeveloperPermissionStatus } from '../shared/developer-permissions-
type ReadProbe = (filePath: string) => Promise<void>
async function openForRead(filePath: string): Promise<void> {
async function readProbeByte(filePath: string): Promise<void> {
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
@@ -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(<DeveloperPermissionsPane />))
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' }])
@@ -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({
<div className="divide-y divide-border/60 rounded-lg border border-border/60">
{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)}
</span>
</div>
<p className="text-xs text-muted-foreground">{permission.description}</p>
{permission.id === 'full-disk-access' && state?.terminalHostStatus && (
<p className="text-xs text-muted-foreground">
{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.'
)}
</>
)}
</p>
)}
</div>
</div>
<DeveloperPermissionActions
+4 -1
View File
@@ -7423,7 +7423,10 @@
"connectionTestHost": "Host",
"connectionTestPort": "Port",
"connectionTestRunning": "Testing...",
"connectionTestAction": "Test Connection"
"connectionTestAction": "Test Connection",
"appStatus": "App: ",
"terminalHostStatus": "Terminal hosts: ",
"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."
},
"ExperimentalPane": {
"9762364929": "Uses APFS clone-copy on macOS when possible, otherwise symlinks configured folders or files into created worktrees.",
+39 -9
View File
@@ -13,30 +13,50 @@ describe('classifyDaemonSpawnerPath', () => {
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)
})
})
+8 -2
View File
@@ -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'
}
@@ -24,6 +24,7 @@ export type DeveloperPermissionStatus =
export type DeveloperPermissionState = {
id: DeveloperPermissionId
status: DeveloperPermissionStatus
terminalHostStatus?: DeveloperPermissionStatus
}
export type DeveloperPermissionRequestResult = {