fix(macos): tell the user when Orca's terminal service can't read their folder, and walk them through the fix (#21923)

* fix(macos): tell the user when Orca's terminal service can't read their folder

On macOS, a terminal daemon that survived an app update can be refused access to
a workspace under Documents, Desktop, or Downloads while the Orca app itself can
still read it. Terminals opened there die with "Operation not permitted" and
nothing on screen explains why. The daemon has reported `cwdReadableByDaemon` on
every create since #18043 and main has emitted `daemon_pty_cwd_denied` on proven
divergence since then; the field data says 1,438 users hit it in 21 days. What
was missing was the notice.

The verdict itself moves off `access()`. A grant-less probe on an affected
machine showed a TCC mode where `access(R_OK|X_OK)` passes on `~/Documents` and
`opendir` still fails, so the check now does what a shell listing its cwd does:
`opendirSync`, one `readSync`, `closeSync`. Only EPERM/EACCES reads as denial —
a missing path, a non-directory, or an unexpected error still reads as readable,
so a non-permission failure can never masquerade as one. The same probe is what
the app side compares with, through one oracle shared by the telemetry emitter
and the notice, so the spawn path reads the directory once.

Proven divergence now also records evidence in main: one entry, keyed by the
daemon's pid, start time and launch nonce, carrying an opaque digest of that
identity and the folder class. No path leaves main. The existing focus-time
`macTccAttribution` poll carries it to the renderer, which raises a second toast
latched per daemon scope: dismissed stays dismissed, and a restart mints a new
identity so the poll returns null and the toast clears with no post-restart
probe. If the replacement daemon is denied too, about 31% of cases, the next
spawn re-records under the new scope and the notice returns, now with the
re-allow sentence doing the work.

No new IPC channel, no daemon protocol field, no polling change, and nothing new
on the spawn path beyond one `opendir`. `daemon_folder_access_notice` counts
shown, dismissed and open_manage_sessions against `daemon_pty_cwd_denied` as the
denominator; `shown` is emitted from main the first time a scope leaves the IPC
handler, so the renderer carries no telemetry plumbing for it.

* fix(macos): clear folder-access evidence only when the same folder class reads back

A readable spawn in ~/code said nothing about a Documents denial but was
hiding the notice; retire the evidence only when the daemon reads a folder
of the class it was denied on.

* fix(macos): say what a terminal-service restart actually does

The Manage Sessions restart confirmation still described the product as it was
before agents resumed themselves: it promised panes showing "Process exited"
that the user reopens by hand, and mentioned legacy-protocol sessions nobody
outside the daemon code can act on. Open terminals and agents come back on
their own now, so the old copy made a routine remedy sound like data loss.

It also called the thing a "daemon". The same restart is about to be offered
from a user-facing fix dialog, so both surfaces now say "terminal service", and
the confirm button is just "Restart".

The new body adds the one fact the old one never stated: terminals on remote
hosts are not affected. Translations of the two changed strings are dropped so
the five non-English locales fall back to English rather than keep showing copy
that is now wrong.

* feat(macos): give the denied-folder notice a fix the user can follow

The folder-access toast told the user their terminal service could not read
Documents and then handed them a paragraph: restart from Manage Sessions, and
if that does not work, re-allow Orca in System Settings. Both halves were
guesses. Roughly a third of restarts do not fix it, and the user had no way to
know which case they were in before spending every open terminal on finding
out.

Main can now answer that. `daemon-folder-access-probe.ts` forks a short-lived
child of the app binary the same way the daemon itself is forked, runs one
opendir/readdir/closedir against the denied path, and prints a single JSON
line. macOS attributes a TCC grant to the process that forked the child, so a
child of the app running now answers exactly the question the running daemon
cannot: would a replacement daemon get in? The child goes through the shared
child-process wrapper, never a shell, with a 3s deadline, a 1KB output cap and
an environment scrubbed to PATH/HOME/TMPDIR. Every failure — timeout, bad
output, spawn error — reads as `unknown`, never as a verdict.

That answer rides out as `restartWillHelp` on the evidence the existing
focus-time poll already carries, and the toast becomes a title and two buttons:
Fix… and Not now. Fix opens a dialog with the two real steps. When the grant is
already in place, step one is shown as done and Restart is live. When it is
not, step one is open and Restart is disabled until it completes — which it
does by itself, because the poll re-probes while the answer is still no, and
returning from System Settings is the moment that lands. An unanswered probe
never accuses the user of a missing grant; it leaves both steps open.

Restart calls the management API directly rather than stacking the Manage
Sessions confirmation on top, since the dialog already states the consequence.
Success replaces the steps with a done line and takes the toast down; failure
says so inline and leaves the button usable.

System Settings opens through the existing developer-permissions pane opener,
which takes an id rather than a URL, with Files and Folders added to it. The
event's action enum now also counts fix_opened, settings_opened,
restart_clicked and — emitted from main when a replacement daemon's first spawn
lands in the folder class the previous one was denied on — whether the restart
actually worked.

* fix(macos): let the folder-access notice return after a poll that read no daemon

A daemon identity reads as null during any reconnect blip, and the poll reports that as
"no mismatch". The notice dismissed itself and then never showed again for that daemon,
because the once-per-daemon latch still held its scope. Only "Not now" should latch.

* fix(macos): say what the folder-access notice costs the user

One line read like a stray warning. The toast now says who is blocked and what fails,
and still leaves the steps to the fix dialog.

* fix(macos): give the folder-access toast one action and the X, like every other toast

"Fix" is the only button; the X dismisses. Sonner fires onDismiss for programmatic
dismissals too, so the post-restart takedown now goes through the store and the hook,
and only a user's X is counted as dismissed.

* fix(macos): keep the fix dialog's steps a checklist and put the one action in the footer

Buttons inside each step made the list look like a form, and a footer Close duplicated
the X. The footer now carries the active step's action, with a ghost Cancel; a probe
that could not answer says so under step 1 instead of showing a check.

* fix(macos): let the checklist show the fix landed instead of saying so

A hedged sentence addressed to the user read like chat. On success both steps check
off and the footer offers Done; the unanswered-probe helper is a status, not advice.

* chore(i18n): drop the fix dialog's unused close key

* Revert "chore(i18n): drop the fix dialog's unused close key"

This reverts commit 365915df48.

* chore(i18n): drop the fix dialog's unused close key

* fix(macos): tell step 1 what to do when the folder toggle is already on

Users who need step 1 usually find Orca already allowed in System Settings; the grant
is recorded but not honoured for the daemon. Re-toggling re-records it.

* fix(macos): drop the unverified toggle instruction from step 1

Nothing has been confirmed to fix a grant that is already on, so the step says only
what the probe knows.

* refactor(macos): share the tccutil reset and bundle-id read behind one module

Clearing a macOS TCC row is about to have a second caller: the daemon
folder-access fix (STA-7948) needs the exact `tccutil reset` the computer-use
helper already issues. Extract both it and the PlistBuddy bundle-id read into
src/main/macos-tcc-reset.ts so the two remedies cannot drift apart.

The extracted calls go through runProcessSync rather than a fresh
node:child_process import: the spawn chokepoint's ratchet holds the direct
importer count at a pin, and a new module with its own spawnSync would raise it.
Behaviour is unchanged except that both calls now carry a 10s bound, and the
computer-use test asserts the same argv against the chokepoint's options.

* feat(macos): offer a permission reset when restarting the terminal service cannot help

About a third of the users who see the folder-access notice are still denied by
a freshly forked daemon even though Orca itself is allowed under Files and
Folders, so the restart the dialog offers cannot fix anything for them. That
state previously had one action: open System Settings, where the toggle they
would look for is already on.

The denied state now offers "Reset permission". Main clears Orca's TCC row for
that folder class with tccutil, then reads the folder from the app itself so
macOS raises its prompt against Orca rather than the daemon, then forces a
fresh-daemon re-probe that bypasses the poll's reuse interval. The dialog
re-renders from that verdict: allowed turns step one green and offers Restart,
still denied says so, and a refused reset points back at System Settings.

Nobody has confirmed this remedy on an affected machine, which is why main emits
the re-probe's verdict as reset_outcome_allowed/still_denied/unknown. Those
three, plus reset_clicked, are the evidence that decides whether the feature
stays.

* fix(macos): say what the permission reset does, and keep System Settings as the fallback

Step 1 was labelled like a Settings task while the button did something else, with two routes
in the footer for one step. The denied state now names the step for what the reset does,
explains it under the step, and shows System Settings only after a reset fails or leaves
things blocked.

* fix(macos): count a folder-access restart only against evidence that survived

The stored denial is the prior denial, so a second copy of it outlived the
one event that retires it: a daemon that read its own folder back cleared the
entry but left the copy, and the next daemon's first denial was then reported
as a restart that had never happened.

Track the outcome on the entry itself, drop the spawn-path probe (ten denied
terminals forked ten probe children the focus-time poll re-runs anyway), and
stop emitting `shown` from a getter the reset path calls for data. The
renderer's toast latch is what decides a scope is shown, so it emits it.

Both accessors now read one identity-matched entry.

* refactor(macos): name the folder-access verdict instead of encoding it as a tri-state

`restartWillHelp: boolean | null` re-encoded a verdict the probe already
returns as a named union, so every reader had to remember that `false` meant
"Orca itself must be re-allowed" and `null` meant "no answer".

`freshDaemonAccess: 'allowed' | 'denied' | 'unknown'` says it, end to end
through main, the IPC payload, the preload mirror and the dialog. The reset's
outcome event becomes a lookup. No user-visible string changes.

* refactor(macos): give the folder-access notice one latch instead of three

Two refs in the hook and a field in the store tracked the same fact, and the
dialog reached the hook through a store field plus an effect just to take its
own toast down before sonner echoed the dismissal back.

The store now holds the visible scope and the scopes the user closed, and
exposes the three things that happen to a notice: it is shown, someone else
retires it, or the user dismisses it. The dialog calls retire directly and the
effect is gone. `settingsIsFallback` loses an argument that was always true at
its only call site, so it becomes the local it always was.

* refactor(macos): stop blocking main on the tccutil reset

Two spawnSync calls with a ten-second timeout sat inside an async IPC handler,
so clearing a TCC row held main's event loop for as long as either binary took.

Both now run through runProcess. The computer-use caller that shared them was
already async, so it awaits them.

* test(macos): run the folder-access probe script against real paths

Every other test mocks the spawn away, so the minified child script — the one
piece that duplicates enumerateDirectoryOnce's errno mapping — had no oracle.
It now runs against a temp directory, an absent path, a file, and a directory
whose mode withholds it, which is skipped for root and on Windows.

* refactor(macos): read the folder-access entry through one identity match

All four callers that ask "is this evidence still this daemon's?" now go
through the same private accessor, so the rule the canonical path depends on
lives in one place.

* fix(macos): keep folder evidence through a failed health read, and make a forced re-probe always probe

A rejected attribution-health read nulled the folder evidence on the same poll, which the
renderer read as "cleared". A forced refresh after a reset returned early on an older
settled verdict. The dialog also closes when a reset finds the evidence gone, and stops
showing the unverified helper once the restart is done.

* fix(macos): name the folder in the access-notice scope

One daemon denied two protected folders kept one scope, so the toast, the
fix dialog, and the tccutil reset could each be about a different folder.

* refactor(macos): derive the folder-access dialog from the latest verdict

The store held an `open` flag and a mismatch frozen at the moment the toast
was raised, so the dialog could open on a stale verdict and its remedy state
could survive a close. It now keeps the latest verdict and the scope the user
opened, and the dialog is shown only while the two agree.

* fix(macos): offer the permission reset only where there is a row to reset

A workspace symlinked out of Documents or on an external volume can be denied
too, and the dialog offered a reset that main refuses. One shared list of the
TCC-backed folder classes now decides both.

* fix(macos): give the permission prompt's read a deadline

An unanswered macOS sheet blocks the app's folder read for as long as the user
ignores it, and the fix dialog is modal and busy until that read returns. The
wait now ends after a minute and reports an unknown outcome rather than
probing under the sheet.

* fix(macos): count the folder-access notice once per scope

A reconnect blip reports no daemon, which takes the toast down and lets the
same scope raise it again. Both raises counted as separate notices, inflating
the denominator behind the affected-user rate. The two latches are now one
map from scope to phase, and the count follows first insertion.

* fix(macos): drop the restart warning once the restart is done

Step two ticked green while its helper still warned that open terminals and
agents would restart, which had already happened.

* fix(macos): keep the folder-access toast up when the fix dialog opens

Sonner deletes a toast after its action button runs unless the handler
prevents the event, and it does so without calling onDismiss. Clicking Fix
therefore took the notice off screen while the scope stayed latched as
visible, so cancelling the dialog left no way back to it.

* refactor(preload): reuse the shared daemon cwd class instead of copying it

The five folder classes were hand-mirrored in preload behind a comment saying
preload cannot depend on main-only modules. The enum lives in src/shared,
which preload already imports from elsewhere, so the copy could drift.

* refactor(macos): close the fix dialog when its evidence disappears

A null verdict left the opened scope set, so the same scope coming back
remounted a checklist nobody had opened. Clearing it on a null verdict also
makes the dialog's scope key redundant, so it goes.

* fix(macos): let each fix-dialog button report its own work

The footer swaps the reset for a restart as soon as a poll says the grant
landed, which can happen while the reset is still running. Both buttons read
their label off the dialog being busy at all, so the restart button appeared
spinning as "Restarting…" for a restart nobody had started.

* fix(macos): clear the reset failure once the permission is granted

"Couldn't reset the permission" stayed on screen after the user granted it in
System Settings and the probe read allowed, contradicting the ticked step
above it. Its sibling line was already gated on the same verdict.

* fix(macos): end the folder-access remedy with the evidence it is about

Two ways out were missing. A reset that cleared the evidence closed the dialog
but left the toast on screen, because only the poll retired it; the store now
retires the notice whenever a verdict comes back null, so both callers get it
and the hook's own branch goes. And the opened scope survived a verdict for a
different scope, so the original one returning later reopened the dialog with
nobody having asked for it.

* fix(macos): keep the folder prompt off main's spawn path

The app-side readability check moved from accessSync to opendir when the
notice was added. TCC lets accessSync through but gates opendir, so on a
machine that has never granted Orca the folder, spawning a terminal there
raised the macOS sheet and froze main until the user answered it. The read is
async now and the spawn no longer waits for it. The blocking variant keeps a
name that says so, and the reset module's own copy of the read is gone.

* fix(macos): only say a folder is still blocked when something re-read it

Two paths reached "Still blocked after the reset." with no verdict behind it:
an unanswered prompt, where the reset returns the verdict stored before it
ran, and a re-probe that could not answer. The reset now returns the same
access it reports to telemetry, and the line waits for a real denial.

* refactor(macos): let the folder-access refresh decide when to skip itself

The poll handler re-implemented the refresh's own two guards, a null entry
and a settled allowed verdict, so each had to be kept in step by hand.

* fix(macos): stop the daemon blocking on its own folder read

The daemon reads the requested cwd before forking a shell to report whether
it can list it. That read is the one macOS gates, so on a folder the daemon
is refused it could hold the daemon's event loop behind a prompt. It is
awaited now, which leaves the blocking enumerator with no callers.
This commit is contained in:
Jinwoo Hong
2026-09-22 01:09:26 -04:00
committed by GitHub
parent b9643365ba
commit 7650abe224
45 changed files with 4038 additions and 233 deletions
@@ -13,23 +13,57 @@ const permissionStatusTempDir = '/tmp/orca-computer-use-permissions-test'
const helperAppPath = '/Applications/Orca Computer Use.app'
const helperInfoPlistPath = join(helperAppPath, 'Contents', 'Info.plist')
// The tccutil reset and the bundle-id read now run through `runProcess`, so the fake child has to
// be one that promise settles on: stdout, then `close`.
const plistBuddyStdout = vi.hoisted(() => ({ value: 'com.example.orca.computer-use\n' }))
function fakeChild(stdout: string): Record<string, unknown> {
const stdoutData: ((chunk: Buffer) => void)[] = []
const finish = (callback: (status: number, signal: null) => void): void => {
queueMicrotask(() => {
for (const onData of stdoutData) {
onData(Buffer.from(stdout))
}
callback(0, null)
})
}
const child: Record<string, unknown> = {
pid: 4242,
stdin: { end: vi.fn(), on: vi.fn() },
stdout: {
on: vi.fn((event: string, callback: (chunk: Buffer) => void) => {
if (event === 'data') {
stdoutData.push(callback)
}
}),
off: vi.fn(),
setEncoding: vi.fn()
},
stderr: { on: vi.fn(), off: vi.fn(), setEncoding: vi.fn() },
on: vi.fn((event: string, callback: (status: number, signal: null) => void) => {
if (event === 'close') {
finish(callback)
}
return child
}),
once: vi.fn((event: string, callback: (status: number, signal: null) => void) => {
if (event === 'close') {
finish(callback)
}
return child
}),
off: vi.fn(() => child),
kill: vi.fn(),
unref: vi.fn()
}
return child
}
vi.mock('child_process', () => ({
execFileSync: vi.fn(),
spawn: vi.fn(() => {
const child = {
stdout: { off: vi.fn(), on: vi.fn(), setEncoding: vi.fn() },
stderr: { off: vi.fn(), on: vi.fn(), setEncoding: vi.fn() },
on: vi.fn((event: string, callback: (status: number) => void) => {
if (event === 'close') {
queueMicrotask(() => callback(0))
}
return child
}),
off: vi.fn(() => child),
unref: vi.fn()
}
return child
}),
spawn: vi.fn((file: string) =>
fakeChild(file === '/usr/libexec/PlistBuddy' ? plistBuddyStdout.value : '')
),
spawnSync: vi.fn()
}))
@@ -213,7 +247,6 @@ describe('openComputerUsePermissions', () => {
vi.mocked(readFile)
.mockResolvedValueOnce('{"accessibility":"granted","screenshots":"granted"}')
.mockResolvedValueOnce('{"accessibility":"not-granted","screenshots":"not-granted"}')
vi.mocked(execFileSync).mockReturnValueOnce('com.example.orca.computer-use\n')
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType<typeof spawnSync>)
await expect(resetComputerUsePermissions()).resolves.toEqual({
@@ -226,20 +259,29 @@ describe('openComputerUsePermissions', () => {
{ id: 'screenshots', status: 'not-granted' }
]
})
expect(execFileSync).toHaveBeenCalledWith(
// Argv is asserted exactly; the options belong to the shared spawn chokepoint these now run
// through, which owns and tests them.
const throughChokepoint = expect.objectContaining({ shell: false, windowsHide: true })
expect(spawn).toHaveBeenCalledWith(
'/usr/libexec/PlistBuddy',
['-c', 'Print :CFBundleIdentifier', helperInfoPlistPath],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
throughChokepoint
)
expect(spawnSync).toHaveBeenCalledWith(
expect(spawn).toHaveBeenCalledWith(
'/usr/bin/tccutil',
['reset', 'Accessibility', 'com.example.orca.computer-use'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
throughChokepoint
)
expect(spawnSync).toHaveBeenCalledWith(
expect(spawn).toHaveBeenCalledWith(
'/usr/bin/tccutil',
['reset', 'ScreenCapture', 'com.example.orca.computer-use'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
throughChokepoint
)
// Why: a sync reset would hold main's event loop for both children.
expect(spawnSync).not.toHaveBeenCalledWith(
'/usr/bin/tccutil',
expect.anything(),
expect.anything()
)
})
})
@@ -1,6 +1,6 @@
import { execFileSync, spawn, spawnSync } from 'node:child_process'
import { join } from 'node:path'
import { spawn, spawnSync } from 'node:child_process'
import { RuntimeClientError } from './runtime-client-error'
import { readMacosBundleId, resetMacosTccPermission } from '../macos-tcc-reset'
import { resolveMacOSComputerUseAppPath } from './macos-native-provider-paths'
import { getComputerUsePermissionStatus } from './macos-computer-use-permission-status'
import type {
@@ -107,10 +107,10 @@ async function resetComputerUsePermissionsAsync(): Promise<ComputerUsePermission
throw new RuntimeClientError('accessibility_error', status.helperUnavailableReason)
}
const bundleId = readComputerUseBundleId(helperAppPath)
const bundleId = (await readMacosBundleId(helperAppPath)) ?? DEFAULT_COMPUTER_USE_BUNDLE_ID
closeExistingPermissionHelpers()
resetTccPermission('Accessibility', bundleId)
resetTccPermission('ScreenCapture', bundleId)
await resetTccPermission('Accessibility', bundleId)
await resetTccPermission('ScreenCapture', bundleId)
return {
...(await getComputerUsePermissionStatus()),
@@ -132,36 +132,16 @@ function closeExistingPermissionHelpers(): void {
}
}
function readComputerUseBundleId(helperAppPath: string): string {
const infoPlistPath = join(helperAppPath, 'Contents', 'Info.plist')
try {
const bundleId = execFileSync(
'/usr/libexec/PlistBuddy',
['-c', 'Print :CFBundleIdentifier', infoPlistPath],
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}
).trim()
return bundleId || DEFAULT_COMPUTER_USE_BUNDLE_ID
} catch {
return DEFAULT_COMPUTER_USE_BUNDLE_ID
}
}
function resetTccPermission(service: string, bundleId: string): void {
async function resetTccPermission(service: string, bundleId: string): Promise<void> {
// Why: macOS keeps TCC rows after uninstall; users need an explicit way to
// clear stale grants or denials for the helper's stable bundle identity.
const result = spawnSync('/usr/bin/tccutil', ['reset', service, bundleId], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
})
if (result.status === 0) {
return
const result = await resetMacosTccPermission(service, bundleId)
if (!result.ok) {
throw new RuntimeClientError(
'accessibility_error',
`Could not reset ${service}: ${result.detail}`
)
}
const detail =
result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status ?? 'unknown'}`
throw new RuntimeClientError('accessibility_error', `Could not reset ${service}: ${detail}`)
}
function nextPermissionStep(
@@ -2,10 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ParsedDaemonPid } from './daemon-pid-file-parse'
import { validate } from '../telemetry/validator'
const { trackMock, accessSyncMock, existsSyncMock, readFileSyncMock, getVersionMock } = vi.hoisted(
const { trackMock, opendirMock, existsSyncMock, readFileSyncMock, getVersionMock } = vi.hoisted(
() => ({
trackMock: vi.fn(),
accessSyncMock: vi.fn(),
opendirMock: vi.fn(),
existsSyncMock: vi.fn(() => true),
readFileSyncMock: vi.fn(),
getVersionMock: vi.fn(() => '1.4.191')
@@ -14,10 +14,14 @@ const { trackMock, accessSyncMock, existsSyncMock, readFileSyncMock, getVersionM
vi.mock('../telemetry/client', () => ({ track: trackMock }))
vi.mock('node:fs', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
accessSync: accessSyncMock,
existsSync: existsSyncMock,
readFileSync: readFileSyncMock
}))
// The app-side read is async on purpose: it can sit on an unanswered macOS folder prompt.
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
opendir: opendirMock
}))
vi.mock('node:os', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
homedir: () => '/Users/alice'
@@ -28,9 +32,28 @@ vi.mock('../../shared/app-environment', () => ({
import {
classifyDaemonAdoptionOrigin,
hasDaemonPtyCwdDenialDiverged,
reportDaemonPtyCwdVerdict,
trackDaemonAdopted,
trackDaemonPtyCwdDeniedIfDiverged
trackDaemonPtyCwdDenied
} from './daemon-adoption-telemetry-event'
import {
getDaemonFolderAccessMismatch,
resetDaemonFolderAccessMismatchForTests
} from './daemon-folder-access-mismatch'
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
const DAEMON: DaemonEndpointIdentity = { pid: 1530, startedAtMs: 1_700_000, launchNonce: 'n1' }
const DENIED_CWD = '/Users/alice/Documents/repo'
function readableDir(): { read: () => Promise<{ name: string }>; close: () => Promise<void> } {
return { read: async () => ({ name: 'entry' }), close: async () => {} }
}
function failWith(code: string): never {
throw Object.assign(new Error(code), { code })
}
const stalePidRecord: ParsedDaemonPid = {
pid: 1530,
@@ -49,7 +72,8 @@ const PID_PATH = '/fake/daemon.pid'
beforeEach(() => {
trackMock.mockReset()
accessSyncMock.mockReset()
resetDaemonFolderAccessMismatchForTests()
opendirMock.mockReset().mockReturnValue(readableDir())
existsSyncMock.mockReset().mockReturnValue(true)
readFileSyncMock.mockReset().mockReturnValue(JSON.stringify(stalePidRecord))
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
@@ -95,10 +119,41 @@ describe('trackDaemonAdopted', () => {
})
})
describe('trackDaemonPtyCwdDeniedIfDiverged', () => {
it('emits only when the daemon was denied and the app can read the same cwd', () => {
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
expect(accessSyncMock).toHaveBeenCalledWith('/Users/alice/Documents/repo', expect.any(Number))
describe('hasDaemonPtyCwdDenialDiverged', () => {
it('is true only when the daemon was denied and this process can enumerate the same cwd', async () => {
expect(await hasDaemonPtyCwdDenialDiverged(DENIED_CWD, false)).toBe(true)
expect(opendirMock).toHaveBeenCalledWith(DENIED_CWD)
})
// False positives would drown the signal this event exists to measure, so every
// non-divergent shape must stay silent — including daemons too old to report.
it('is false when the daemon could read the cwd or did not report one', async () => {
expect(await hasDaemonPtyCwdDenialDiverged(DENIED_CWD, true)).toBe(false)
expect(await hasDaemonPtyCwdDenialDiverged(DENIED_CWD, undefined)).toBe(false)
expect(await hasDaemonPtyCwdDenialDiverged(undefined, false)).toBe(false)
expect(opendirMock).not.toHaveBeenCalled()
})
it('is false when this process cannot enumerate it either (no divergence)', async () => {
opendirMock.mockImplementation(() => failWith('EACCES'))
expect(await hasDaemonPtyCwdDenialDiverged(DENIED_CWD, false)).toBe(false)
})
it('is false when the cwd is gone rather than refused', async () => {
opendirMock.mockImplementation(() => failWith('ENOENT'))
expect(await hasDaemonPtyCwdDenialDiverged(DENIED_CWD, false)).toBe(false)
})
it('is false off macOS', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
expect(await hasDaemonPtyCwdDenialDiverged('/home/alice/Documents/repo', false)).toBe(false)
expect(opendirMock).not.toHaveBeenCalled()
})
})
describe('trackDaemonPtyCwdDenied', () => {
it('emits a validator-accepted payload', () => {
trackDaemonPtyCwdDenied(DENIED_CWD, PID_PATH)
expect(trackMock).toHaveBeenCalledTimes(1)
const [name, props] = trackMock.mock.calls[0]
expect(name).toBe('daemon_pty_cwd_denied')
@@ -106,24 +161,6 @@ describe('trackDaemonPtyCwdDeniedIfDiverged', () => {
expect(validate('daemon_pty_cwd_denied', props).ok).toBe(true)
})
// False positives would drown the signal this event exists to measure, so every
// non-divergent shape must stay silent.
it('stays silent when the daemon could read the cwd or did not report', () => {
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', true, PID_PATH)
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', undefined, PID_PATH)
trackDaemonPtyCwdDeniedIfDiverged(undefined, false, PID_PATH)
expect(accessSyncMock).not.toHaveBeenCalled()
expect(trackMock).not.toHaveBeenCalled()
})
it('stays silent when the app cannot read the cwd either (no divergence)', () => {
accessSyncMock.mockImplementation(() => {
throw Object.assign(new Error('EACCES'), { code: 'EACCES' })
})
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
expect(trackMock).not.toHaveBeenCalled()
})
it('attributes the denial to the daemon recorded right now, not a startup snapshot', () => {
readFileSyncMock.mockReturnValue(
JSON.stringify({
@@ -132,7 +169,7 @@ describe('trackDaemonPtyCwdDeniedIfDiverged', () => {
spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca'
})
)
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
trackDaemonPtyCwdDenied(DENIED_CWD, PID_PATH)
expect(readFileSyncMock).toHaveBeenCalledWith(PID_PATH, 'utf8')
expect(trackMock.mock.calls[0][1]).toEqual({
cwd_class: 'documents',
@@ -145,16 +182,7 @@ describe('trackDaemonPtyCwdDeniedIfDiverged', () => {
getVersionMock.mockImplementationOnce(() => {
throw new Error('AppEnvironment not initialized')
})
expect(() =>
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
).not.toThrow()
expect(trackMock).not.toHaveBeenCalled()
})
it('stays silent off macOS', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
trackDaemonPtyCwdDeniedIfDiverged('/home/alice/Documents/repo', false, PID_PATH)
expect(accessSyncMock).not.toHaveBeenCalled()
expect(() => trackDaemonPtyCwdDenied(DENIED_CWD, PID_PATH)).not.toThrow()
expect(trackMock).not.toHaveBeenCalled()
})
@@ -162,8 +190,102 @@ describe('trackDaemonPtyCwdDeniedIfDiverged', () => {
trackMock.mockImplementationOnce(() => {
throw new Error('posthog exploded')
})
expect(() =>
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
).not.toThrow()
expect(() => trackDaemonPtyCwdDenied(DENIED_CWD, PID_PATH)).not.toThrow()
})
})
describe('reportDaemonPtyCwdVerdict', () => {
it('emits the event and records the notice evidence on one directory read', async () => {
await reportDaemonPtyCwdVerdict({
cwd: DENIED_CWD,
cwdReadableByDaemon: false,
pidPath: PID_PATH,
daemonIdentity: DAEMON
})
expect(opendirMock).toHaveBeenCalledTimes(1)
expect(trackMock.mock.calls[0][0]).toBe('daemon_pty_cwd_denied')
expect(getDaemonFolderAccessMismatch(DAEMON)?.cwdClass).toBe('documents')
})
it('retires the evidence when the same daemon later reads a cwd it owns', async () => {
await reportDaemonPtyCwdVerdict({
cwd: DENIED_CWD,
cwdReadableByDaemon: false,
pidPath: PID_PATH,
daemonIdentity: DAEMON
})
await reportDaemonPtyCwdVerdict({
cwd: DENIED_CWD,
cwdReadableByDaemon: true,
pidPath: PID_PATH,
daemonIdentity: DAEMON
})
expect(getDaemonFolderAccessMismatch(DAEMON)).toBeNull()
})
it('does nothing for a daemon that never reported a verdict', async () => {
await reportDaemonPtyCwdVerdict({
cwd: DENIED_CWD,
cwdReadableByDaemon: undefined,
pidPath: PID_PATH,
daemonIdentity: DAEMON
})
expect(trackMock).not.toHaveBeenCalled()
expect(getDaemonFolderAccessMismatch(DAEMON)).toBeNull()
})
it('records nothing when the daemon identity is unknown, and never rejects', async () => {
await expect(
reportDaemonPtyCwdVerdict({
cwd: DENIED_CWD,
cwdReadableByDaemon: false,
pidPath: PID_PATH,
daemonIdentity: null
})
).resolves.toBeUndefined()
expect(getDaemonFolderAccessMismatch(DAEMON)).toBeNull()
})
it('swallows a throwing telemetry client instead of failing the spawn', async () => {
trackMock.mockImplementationOnce(() => {
throw new Error('posthog exploded')
})
await expect(
reportDaemonPtyCwdVerdict({
cwd: DENIED_CWD,
cwdReadableByDaemon: false,
pidPath: PID_PATH,
daemonIdentity: DAEMON
})
).resolves.toBeUndefined()
})
// The read behind this can sit on an unanswered macOS folder prompt, and a spawn that waited
// for it would hold main's event loop for as long as the user leaves the sheet up.
it('records nothing until the app-side read resolves, and the spawn need not wait', async () => {
let release: (dir: ReturnType<typeof readableDir>) => void = () => {}
opendirMock.mockReturnValue(
new Promise<ReturnType<typeof readableDir>>((resolve) => {
release = resolve
})
)
const pending = reportDaemonPtyCwdVerdict({
cwd: DENIED_CWD,
cwdReadableByDaemon: false,
pidPath: PID_PATH,
daemonIdentity: DAEMON
})
expect(trackMock).not.toHaveBeenCalled()
expect(getDaemonFolderAccessMismatch(DAEMON)).toBeNull()
release(readableDir())
await pending
expect(getDaemonFolderAccessMismatch(DAEMON)?.cwdClass).toBe('documents')
})
})
@@ -1,7 +1,7 @@
// App-side emitters for `daemon_adopted` and `daemon_pty_cwd_denied` (#17696). Both sit on the
// daemon launch / PTY spawn path, so every failure dies here — telemetry can never cost a terminal.
import { accessSync, constants as fsConstants, existsSync } from 'node:fs'
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { getAppEnvironment } from '../../shared/app-environment'
import {
@@ -14,8 +14,14 @@ import { bucketDaemonLiveSessionCount } from '../../shared/daemon-lifecycle-tele
import type { EventProps } from '../../shared/telemetry-events'
import { track } from '../telemetry/client'
import { readDaemonPidRecord } from './daemon-endpoint-incarnation'
import { enumerateDirectoryOnce } from './directory-enumeration-probe'
import type { ParsedDaemonPid } from './daemon-pid-file-parse'
import type { MacDaemonTccAttributionHealth } from './daemon-tcc-attribution'
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
import {
clearDaemonFolderAccessMismatch,
recordDaemonFolderAccessMismatch
} from './daemon-folder-access-mismatch'
export type DaemonAdoptionOrigin = Pick<
EventProps<'daemon_pty_cwd_denied'>,
@@ -56,19 +62,27 @@ export function trackDaemonAdopted(
}
/**
* Emits only on proven divergence: the daemon reported the cwd unreadable AND this process can
* read it. A cwd neither can read (chmod, ENOENT, unmounted volume) is not the #17696 shape.
* Proven divergence: the daemon reported the cwd unreadable AND this process can enumerate it.
* A cwd neither can read (chmod, ENOENT, unmounted volume) is not the #17696 shape. Single oracle
* for both the event below and the user-facing notice, so the app-side read happens once.
*/
export function trackDaemonPtyCwdDeniedIfDiverged(
export async function hasDaemonPtyCwdDenialDiverged(
cwd: string | undefined,
cwdReadableByDaemon: boolean | undefined,
pidPath: string | null
): void {
cwdReadableByDaemon: boolean | undefined
): Promise<boolean> {
try {
if (process.platform !== 'darwin' || !cwd || cwdReadableByDaemon !== false) {
return
return false
}
accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK)
return (await enumerateDirectoryOnce(cwd)) === 'ok'
} catch {
return false
}
}
/** Emits `daemon_pty_cwd_denied` for a cwd `hasDaemonPtyCwdDenialDiverged` already proved diverged. */
export function trackDaemonPtyCwdDenied(cwd: string, pidPath: string | null): void {
try {
// Why read now, not the adapter's startup snapshot: a respawn swaps the daemon under a
// long-lived adapter, and the denial must be attributed to the daemon that just spawned.
track('daemon_pty_cwd_denied', {
@@ -76,6 +90,39 @@ export function trackDaemonPtyCwdDeniedIfDiverged(
...classifyDaemonAdoptionOrigin(readDaemonPidRecord(pidPath))
})
} catch {
// Either the app cannot read it (no divergence) or telemetry failed; neither may reach the caller.
// Telemetry is best-effort; a dropped event must not reach the caller.
}
}
/**
* The spawn path's single reader of the daemon's cwd verdict: one directory read feeds both the
* event and the user-facing notice. Local current-protocol daemons only — one that omits the
* verdict reports nothing. Every failure dies here; neither may ever cost a terminal.
*
* Never rejects, and the caller must not wait for it: the app-side read is what raises the macOS
* folder prompt, which holds the syscall for as long as the user leaves the sheet up.
*/
export async function reportDaemonPtyCwdVerdict(args: {
cwd: string | undefined
cwdReadableByDaemon: boolean | undefined
pidPath: string | null
daemonIdentity: DaemonEndpointIdentity | null
}): Promise<void> {
try {
const { cwd } = args
if (!cwd) {
return
}
if (args.cwdReadableByDaemon === true) {
clearDaemonFolderAccessMismatch(args.daemonIdentity, cwd)
return
}
if (!(await hasDaemonPtyCwdDenialDiverged(cwd, args.cwdReadableByDaemon))) {
return
}
trackDaemonPtyCwdDenied(cwd, args.pidPath)
recordDaemonFolderAccessMismatch(args.daemonIdentity, cwd)
} catch {
// Best-effort evidence; a spawn must not fail because the notice could not be recorded.
}
}
@@ -0,0 +1,433 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { validate } from '../telemetry/validator'
const { trackMock, probeMock } = vi.hoisted(() => ({ trackMock: vi.fn(), probeMock: vi.fn() }))
vi.mock('../telemetry/client', () => ({ track: trackMock }))
vi.mock('./daemon-folder-access-probe', () => ({
probeFolderAccessForFreshDaemon: probeMock
}))
vi.mock('node:os', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
homedir: () => '/Users/alice'
}))
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
import {
clearDaemonFolderAccessMismatch,
getDaemonFolderAccessMismatch,
getDaemonFolderAccessTarget,
recordDaemonFolderAccessMismatch,
refreshDaemonFolderAccessProbe,
resetDaemonFolderAccessMismatchForTests
} from './daemon-folder-access-mismatch'
const DAEMON: DaemonEndpointIdentity = { pid: 1530, startedAtMs: 1_700_000, launchNonce: 'n1' }
const RESTARTED: DaemonEndpointIdentity = { pid: 1610, startedAtMs: 1_700_900, launchNonce: 'n2' }
const DOCUMENTS = '/Users/alice/Documents/repo'
beforeEach(() => {
resetDaemonFolderAccessMismatchForTests()
trackMock.mockReset()
probeMock.mockReset().mockResolvedValue('unknown')
vi.useRealTimers()
})
/** Where an unawaited probe's result lands. */
async function settleProbe(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
}
/** The spawn path records without probing, so every verdict here comes from a refresh. */
async function recordAndProbe(
identity: DaemonEndpointIdentity,
cwd: string = DOCUMENTS
): Promise<void> {
recordDaemonFolderAccessMismatch(identity, cwd)
await refreshDaemonFolderAccessProbe(identity)
}
describe('daemon folder access mismatch evidence', () => {
it('has nothing until a spawn records one', () => {
expect(getDaemonFolderAccessMismatch(DAEMON)).toBeNull()
})
it('classifies the recorded cwd and keeps only the latest entry', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
expect(getDaemonFolderAccessMismatch(DAEMON)?.cwdClass).toBe('documents')
recordDaemonFolderAccessMismatch(DAEMON, '/Users/alice/Desktop/other')
expect(getDaemonFolderAccessMismatch(DAEMON)?.cwdClass).toBe('desktop')
})
it('clears when the same daemon later reads a cwd of the same folder class', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
clearDaemonFolderAccessMismatch(DAEMON, '/Users/alice/Documents/other-repo')
expect(getDaemonFolderAccessMismatch(DAEMON)).toBeNull()
})
it('keeps the evidence when the same daemon reads a folder of another class', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
clearDaemonFolderAccessMismatch(DAEMON, '/Users/alice/code/repo')
expect(getDaemonFolderAccessMismatch(DAEMON)).not.toBeNull()
})
it('ignores a clear from a different daemon', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
clearDaemonFolderAccessMismatch(RESTARTED, DOCUMENTS)
expect(getDaemonFolderAccessMismatch(DAEMON)).not.toBeNull()
})
// This is the whole restart remedy: a new daemon has a new identity, so the poll goes quiet
// without anyone probing the folder again.
it('returns null once the daemon that earned it has been replaced', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
expect(getDaemonFolderAccessMismatch(RESTARTED)).toBeNull()
})
it('returns null when there is no current daemon identity', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
expect(getDaemonFolderAccessMismatch(null)).toBeNull()
})
it('records nothing for a daemon that has no identity yet', () => {
recordDaemonFolderAccessMismatch(null, DOCUMENTS)
expect(getDaemonFolderAccessMismatch(DAEMON)).toBeNull()
})
it('gives one daemon a stable scope and two daemons different scopes', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
const first = getDaemonFolderAccessMismatch(DAEMON)?.daemonScope
expect(getDaemonFolderAccessMismatch(DAEMON)?.daemonScope).toBe(first)
recordDaemonFolderAccessMismatch(RESTARTED, DOCUMENTS)
expect(getDaemonFolderAccessMismatch(RESTARTED)?.daemonScope).not.toBe(first)
})
// The notice names a folder, so a second class under one daemon is a new notice, not the same
// one with a new word in it.
it('mints a new scope when the same daemon is denied a second folder class', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
const documents = getDaemonFolderAccessMismatch(DAEMON)?.daemonScope
recordDaemonFolderAccessMismatch(DAEMON, '/Users/alice/Desktop/other')
expect(getDaemonFolderAccessMismatch(DAEMON)?.daemonScope).not.toBe(documents)
})
it('gives one daemon the same scope for every cwd of one folder class', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
const first = getDaemonFolderAccessMismatch(DAEMON)?.daemonScope
recordDaemonFolderAccessMismatch(DAEMON, '/Users/alice/Documents/other-repo')
expect(getDaemonFolderAccessMismatch(DAEMON)?.daemonScope).toBe(first)
})
it('keeps every path fragment and the folder class out of the scope', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
const scope = getDaemonFolderAccessMismatch(DAEMON)?.daemonScope ?? ''
expect(scope).toMatch(/^[0-9a-f]{16}$/)
for (const fragment of ['alice', 'Documents', 'documents', 'repo', 'Users']) {
expect(scope).not.toContain(fragment)
}
})
})
describe('freshDaemonAccess', () => {
it('starts unanswered, and the spawn path forks no child to answer it', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
expect(getDaemonFolderAccessMismatch(DAEMON)?.freshDaemonAccess).toBe('unknown')
expect(probeMock).not.toHaveBeenCalled()
})
it('probes the folder the spawn was denied on', async () => {
await recordAndProbe(DAEMON)
expect(probeMock).toHaveBeenCalledWith(DOCUMENTS)
})
it.each([
['ok', 'allowed'],
['denied', 'denied'],
['missing', 'unknown'],
['other', 'unknown'],
['unknown', 'unknown']
])('maps a %s probe to %s', async (outcome, expected) => {
probeMock.mockResolvedValue(outcome)
await recordAndProbe(DAEMON)
expect(getDaemonFolderAccessMismatch(DAEMON)?.freshDaemonAccess).toBe(expected)
})
it('drops a probe whose entry was replaced while the child ran', async () => {
let release: (value: string) => void = () => {}
probeMock.mockReturnValueOnce(
new Promise<string>((resolve) => {
release = resolve
})
)
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
void refreshDaemonFolderAccessProbe(DAEMON)
probeMock.mockResolvedValue('denied')
recordDaemonFolderAccessMismatch(DAEMON, '/Users/alice/Desktop/other')
const forced = refreshDaemonFolderAccessProbe(DAEMON, { force: true })
release('ok')
await forced
const notice = getDaemonFolderAccessMismatch(DAEMON)
expect(notice?.cwdClass).toBe('desktop')
expect(notice?.freshDaemonAccess).toBe('denied')
})
it('survives a probe that rejects', async () => {
probeMock.mockRejectedValue(new Error('spawn failed'))
await recordAndProbe(DAEMON)
expect(getDaemonFolderAccessMismatch(DAEMON)?.freshDaemonAccess).toBe('unknown')
})
})
describe('refreshDaemonFolderAccessProbe', () => {
it('re-probes a denial so step one can complete itself', async () => {
probeMock.mockResolvedValue('denied')
await recordAndProbe(DAEMON)
expect(getDaemonFolderAccessMismatch(DAEMON)?.freshDaemonAccess).toBe('denied')
vi.setSystemTime(Date.now() + 6_000)
probeMock.mockResolvedValue('ok')
await refreshDaemonFolderAccessProbe(DAEMON)
expect(getDaemonFolderAccessMismatch(DAEMON)?.freshDaemonAccess).toBe('allowed')
})
it('reuses a probe younger than the refresh interval', async () => {
probeMock.mockResolvedValue('denied')
await recordAndProbe(DAEMON)
expect(probeMock).toHaveBeenCalledTimes(1)
await refreshDaemonFolderAccessProbe(DAEMON)
expect(probeMock).toHaveBeenCalledTimes(1)
})
it('treats a settled true as final', async () => {
probeMock.mockResolvedValue('ok')
await recordAndProbe(DAEMON)
vi.setSystemTime(Date.now() + 60_000)
await refreshDaemonFolderAccessProbe(DAEMON)
expect(probeMock).toHaveBeenCalledTimes(1)
})
it('probes again on a forced refresh even after a settled allowed', async () => {
probeMock.mockResolvedValue('ok')
await recordAndProbe(DAEMON)
await refreshDaemonFolderAccessProbe(DAEMON, { force: true })
expect(probeMock).toHaveBeenCalledTimes(2)
})
it('re-probes an unanswered entry once the interval has passed', async () => {
probeMock.mockResolvedValue('other')
await recordAndProbe(DAEMON)
vi.setSystemTime(Date.now() + 6_000)
await refreshDaemonFolderAccessProbe(DAEMON)
expect(probeMock).toHaveBeenCalledTimes(2)
})
it('does nothing for a daemon the evidence does not belong to', async () => {
probeMock.mockResolvedValue('denied')
await recordAndProbe(DAEMON)
vi.setSystemTime(Date.now() + 6_000)
await refreshDaemonFolderAccessProbe(RESTARTED)
await refreshDaemonFolderAccessProbe(null)
expect(probeMock).toHaveBeenCalledTimes(1)
})
it('joins an in-flight probe instead of starting a second child', async () => {
let release: (value: string) => void = () => {}
probeMock.mockReturnValue(
new Promise<string>((resolve) => {
release = resolve
})
)
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
const first = refreshDaemonFolderAccessProbe(DAEMON)
const joined = refreshDaemonFolderAccessProbe(DAEMON)
release('ok')
await Promise.all([first, joined])
expect(probeMock).toHaveBeenCalledTimes(1)
expect(getDaemonFolderAccessMismatch(DAEMON)?.freshDaemonAccess).toBe('allowed')
})
// The reset's caller needs a verdict from after the reset, and the interval is what would
// otherwise hand it the pre-reset one.
it('probes again inside the refresh interval when forced', async () => {
probeMock.mockResolvedValue('denied')
await recordAndProbe(DAEMON)
probeMock.mockResolvedValue('ok')
await refreshDaemonFolderAccessProbe(DAEMON, { force: true })
expect(probeMock).toHaveBeenCalledTimes(2)
expect(getDaemonFolderAccessMismatch(DAEMON)?.freshDaemonAccess).toBe('allowed')
})
// A probe that started before the reset would otherwise win the race and discard the forced
// one's write, reporting the state the reset was meant to change.
it('waits for an older in-flight probe and still lands its own verdict', async () => {
const releases: ((value: string) => void)[] = []
probeMock.mockImplementation(
() =>
new Promise<string>((resolve) => {
releases.push(resolve)
})
)
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
void refreshDaemonFolderAccessProbe(DAEMON)
const forced = refreshDaemonFolderAccessProbe(DAEMON, { force: true })
releases[0]('denied')
await settleProbe()
releases[1]('ok')
await forced
expect(probeMock).toHaveBeenCalledTimes(2)
expect(getDaemonFolderAccessMismatch(DAEMON)?.freshDaemonAccess).toBe('allowed')
})
})
describe('getDaemonFolderAccessTarget', () => {
it('hands the remedy the folder the evidence is about', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
expect(getDaemonFolderAccessTarget(DAEMON)).toEqual({
canonicalPath: DOCUMENTS,
cwdClass: 'documents'
})
})
it('has no target for another daemon, no daemon, or no evidence', () => {
expect(getDaemonFolderAccessTarget(DAEMON)).toBeNull()
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
expect(getDaemonFolderAccessTarget(RESTARTED)).toBeNull()
expect(getDaemonFolderAccessTarget(null)).toBeNull()
})
// Reading evidence is not showing it: the renderer owns the `shown` event.
it('emits nothing, and neither does reading the notice', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
trackMock.mockReset()
getDaemonFolderAccessTarget(DAEMON)
getDaemonFolderAccessMismatch(DAEMON)
getDaemonFolderAccessMismatch(DAEMON)
expect(trackMock).not.toHaveBeenCalled()
})
})
describe('restart outcome', () => {
it('counts a replacement daemon that can read the folder as fixed', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
trackMock.mockReset()
clearDaemonFolderAccessMismatch(RESTARTED, '/Users/alice/Documents/other')
expect(trackMock).toHaveBeenCalledWith('daemon_folder_access_notice', {
action: 'restart_outcome_fixed',
cwd_class: 'documents'
})
expect(validate('daemon_folder_access_notice', trackMock.mock.calls[0][1]).ok).toBe(true)
})
it('counts a replacement daemon denied the same folder as still denied', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
trackMock.mockReset()
recordDaemonFolderAccessMismatch(RESTARTED, DOCUMENTS)
expect(trackMock).toHaveBeenCalledWith('daemon_folder_access_notice', {
action: 'restart_outcome_still_denied',
cwd_class: 'documents'
})
expect(validate('daemon_folder_access_notice', trackMock.mock.calls[0][1]).ok).toBe(true)
})
it('counts one outcome per restart, not one per spawn', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
trackMock.mockReset()
clearDaemonFolderAccessMismatch(RESTARTED, DOCUMENTS)
clearDaemonFolderAccessMismatch(RESTARTED, DOCUMENTS)
const outcomes = trackMock.mock.calls.filter(([, props]) =>
String(props.action).startsWith('restart_outcome_')
)
expect(outcomes).toHaveLength(1)
})
// The same daemon reading back is a TCC grant landing mid-session, not a restart's verdict.
it('says nothing when the daemon that was denied reads the folder itself', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
trackMock.mockReset()
clearDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
expect(trackMock).not.toHaveBeenCalled()
})
// A readable ~/code after a Documents denial says nothing about Documents.
it('says nothing for a spawn in another folder class', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
trackMock.mockReset()
clearDaemonFolderAccessMismatch(RESTARTED, '/Users/alice/code/repo')
recordDaemonFolderAccessMismatch(RESTARTED, '/Users/alice/Desktop/x')
const outcomes = trackMock.mock.calls.filter(([, props]) =>
String(props.action).startsWith('restart_outcome_')
)
expect(outcomes).toHaveLength(0)
})
it('says nothing when no denial preceded the spawn', () => {
clearDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
expect(trackMock).not.toHaveBeenCalled()
})
// The denial resolved itself before any restart, so the next daemon's denial is its own story.
it('says nothing about a denial the same daemon had already read back', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
clearDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
trackMock.mockReset()
recordDaemonFolderAccessMismatch(RESTARTED, DOCUMENTS)
expect(trackMock).not.toHaveBeenCalled()
})
it('still records the replacement denial when the telemetry client throws', () => {
recordDaemonFolderAccessMismatch(DAEMON, DOCUMENTS)
trackMock.mockImplementationOnce(() => {
throw new Error('posthog exploded')
})
recordDaemonFolderAccessMismatch(RESTARTED, DOCUMENTS)
expect(getDaemonFolderAccessMismatch(RESTARTED)?.cwdClass).toBe('documents')
})
})
@@ -0,0 +1,223 @@
// Evidence behind the macOS folder-access notice (STA-7948). Main-process only, at most one entry,
// keyed by the daemon that produced it: a restart mints a new identity, so the next read returns
// null and the notice clears without probing anything.
import { createHash } from 'node:crypto'
import { homedir } from 'node:os'
import {
classifyDaemonPtyCwd,
type DaemonPtyCwdClass
} from '../../shared/daemon-adoption-telemetry'
import type { EventProps } from '../../shared/telemetry-events'
import { track } from '../telemetry/client'
import {
probeFolderAccessForFreshDaemon,
type FreshDaemonFolderAccess
} from './daemon-folder-access-probe'
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
/** Long enough that a focus-time poll cannot spin up a child per poll, short enough to feel live. */
const PROBE_REFRESH_INTERVAL_MS = 5_000
/** What a daemon forked by this app right now would get, or `unknown` if the probe could not say. */
export type FreshDaemonAccess = 'allowed' | 'denied' | 'unknown'
/** What the renderer is allowed to see: an opaque per-daemon scope, the folder class, a verdict. */
export type DaemonFolderAccessMismatchNotice = {
daemonScope: string
cwdClass: DaemonPtyCwdClass
freshDaemonAccess: FreshDaemonAccess
}
type StoredMismatch = DaemonFolderAccessMismatchNotice & {
daemonKey: string
canonicalPath: string
probedAtMs: number | null
/** Latched once this denial has served as a restart's before-picture, so it counts one outcome. */
outcomeReported: boolean
}
let stored: StoredMismatch | null = null
let probeInFlight: Promise<void> | null = null
function emit(
action: EventProps<'daemon_folder_access_notice'>['action'],
cwdClass: DaemonPtyCwdClass
): void {
try {
track('daemon_folder_access_notice', { action, cwd_class: cwdClass })
} catch {
// Telemetry is best-effort; a dropped event must never withhold or delay the notice.
}
}
function daemonKeyOf(identity: DaemonEndpointIdentity): string {
return `${identity.pid}:${identity.startedAtMs}:${identity.launchNonce}`
}
/**
* Digest, never a path. The folder class is in it because the notice names a folder: one daemon
* denied a second class is a different remedy, and must not inherit the first one's latches.
*/
function daemonScopeOf(daemonKey: string, cwdClass: DaemonPtyCwdClass): string {
return createHash('sha256').update(`${daemonKey}:${cwdClass}`).digest('hex').slice(0, 16)
}
/** The entry, but only while it still belongs to the daemon asking for it. */
function entryFor(identity: DaemonEndpointIdentity | null): StoredMismatch | null {
if (!identity || !stored || stored.daemonKey !== daemonKeyOf(identity)) {
return null
}
return stored
}
/** Only `ok` proves a fresh daemon would get in; a non-verdict stays `unknown`, never `denied`. */
function freshDaemonAccessFrom(outcome: FreshDaemonFolderAccess): FreshDaemonAccess {
if (outcome === 'ok') {
return 'allowed'
}
return outcome === 'denied' ? 'denied' : 'unknown'
}
async function probeStoredEntry(entry: StoredMismatch): Promise<void> {
const outcome = await probeFolderAccessForFreshDaemon(entry.canonicalPath)
// Why the identity compare: a later spawn may have replaced the entry while the child ran.
if (stored !== entry) {
return
}
stored = { ...entry, freshDaemonAccess: freshDaemonAccessFrom(outcome), probedAtMs: Date.now() }
}
function startProbe(entry: StoredMismatch): Promise<void> {
const run = probeStoredEntry(entry).catch(() => {})
probeInFlight = run
void run.then(() => {
if (probeInFlight === run) {
probeInFlight = null
}
})
return run
}
/**
* The restart's verdict: the first spawn by a *different* daemon into the folder class the stored
* denial is about. An entry the same daemon already read back is gone, so it reports nothing.
*/
function reportOutcomeIfReplacementDaemon(
daemonKey: string,
cwdClass: DaemonPtyCwdClass,
fixed: boolean
): void {
const prior = stored
if (
!prior ||
prior.outcomeReported ||
prior.daemonKey === daemonKey ||
prior.cwdClass !== cwdClass
) {
return
}
prior.outcomeReported = true
emit(fixed ? 'restart_outcome_fixed' : 'restart_outcome_still_denied', cwdClass)
}
export function recordDaemonFolderAccessMismatch(
identity: DaemonEndpointIdentity | null,
cwd: string
): void {
if (!identity) {
return
}
const daemonKey = daemonKeyOf(identity)
const cwdClass = classifyDaemonPtyCwd(cwd, homedir())
reportOutcomeIfReplacementDaemon(daemonKey, cwdClass, false)
// Why no probe here: this is the PTY spawn path, and the focus-time poll probes before it answers.
stored = {
daemonKey,
daemonScope: daemonScopeOf(daemonKey, cwdClass),
cwdClass,
canonicalPath: cwd,
freshDaemonAccess: 'unknown',
probedAtMs: null,
outcomeReported: false
}
}
/**
* A later spawn this daemon could read retires its own evidence, but only for the same folder
* class: TCC denies Documents as a whole, so a readable `~/code` says nothing about it.
*/
export function clearDaemonFolderAccessMismatch(
identity: DaemonEndpointIdentity | null,
cwd: string
): void {
if (!identity) {
return
}
const cwdClass = classifyDaemonPtyCwd(cwd, homedir())
reportOutcomeIfReplacementDaemon(daemonKeyOf(identity), cwdClass, true)
if (entryFor(identity)?.cwdClass === cwdClass) {
stored = null
}
}
/**
* Re-runs the probe so step 1 of the fix dialog can complete itself: the user allows Orca in System
* Settings, returns to the app, and the focus-time poll is the only thing that can notice. A
* settled `allowed` is final, and a probe younger than the interval is reused.
*/
export async function refreshDaemonFolderAccessProbe(
identity: DaemonEndpointIdentity | null,
options?: { force?: boolean }
): Promise<void> {
const force = options?.force === true
// A probe started before the remedy ran cannot see its effect, and its late write would be
// discarded anyway; let it land, then probe whatever entry it leaves behind.
if (force && probeInFlight) {
await probeInFlight
}
const entry = entryFor(identity)
// Why force skips the settled shortcut: a reset must be judged by a probe that ran after it.
if (!entry || (!force && entry.freshDaemonAccess === 'allowed')) {
return
}
if (
!force &&
entry.probedAtMs !== null &&
Date.now() - entry.probedAtMs < PROBE_REFRESH_INTERVAL_MS
) {
return
}
await (force ? startProbe(entry) : (probeInFlight ?? startProbe(entry)))
}
/**
* The folder the stored evidence is about, for remedies that must act on it. Deliberately narrow:
* the canonical path is the one field the notice itself must never carry off the main process.
*/
export function getDaemonFolderAccessTarget(
identity: DaemonEndpointIdentity | null
): { canonicalPath: string; cwdClass: DaemonPtyCwdClass } | null {
const entry = entryFor(identity)
return entry ? { canonicalPath: entry.canonicalPath, cwdClass: entry.cwdClass } : null
}
/** Returns evidence only while it still belongs to the daemon in use. */
export function getDaemonFolderAccessMismatch(
currentIdentity: DaemonEndpointIdentity | null
): DaemonFolderAccessMismatchNotice | null {
const entry = entryFor(currentIdentity)
if (!entry) {
return null
}
return {
daemonScope: entry.daemonScope,
cwdClass: entry.cwdClass,
freshDaemonAccess: entry.freshDaemonAccess
}
}
export function resetDaemonFolderAccessMismatchForTests(): void {
stored = null
probeInFlight = null
}
@@ -0,0 +1,51 @@
// The child script the probe runs is a minified copy of enumerateDirectoryOnce's errno mapping,
// inlined because the child can load nothing from the app bundle. Every other test mocks the
// spawn away, so this is the only place the script itself is executed.
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { probeFolderAccessForFreshDaemon } from './daemon-folder-access-probe'
// chmod cannot lock root out of a directory, and does not withhold reads on Windows.
const CAN_MAKE_A_DIRECTORY_UNREADABLE = process.platform !== 'win32' && process.getuid?.() !== 0
let root: string
beforeAll(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-folder-access-probe-'))
})
afterAll(async () => {
await chmod(join(root, 'unreadable'), 0o700).catch(() => {})
await rm(root, { recursive: true, force: true })
})
describe('the probe child, run against real paths', () => {
it('reads a directory it can list as ok', async () => {
await expect(probeFolderAccessForFreshDaemon(root)).resolves.toBe('ok')
})
it('reads a path that is not there as missing', async () => {
await expect(probeFolderAccessForFreshDaemon(join(root, 'absent'))).resolves.toBe('missing')
})
it('reads a file as missing rather than as a denial', async () => {
const file = join(root, 'file.txt')
await writeFile(file, 'contents')
await expect(probeFolderAccessForFreshDaemon(file)).resolves.toBe('missing')
})
it.runIf(CAN_MAKE_A_DIRECTORY_UNREADABLE)(
'reads a directory it may not open as denied',
async () => {
const unreadable = join(root, 'unreadable')
await mkdir(unreadable)
await chmod(unreadable, 0o000)
await expect(probeFolderAccessForFreshDaemon(unreadable)).resolves.toBe('denied')
}
)
})
@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() }))
vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock }))
import { probeFolderAccessForFreshDaemon } from './daemon-folder-access-probe'
type RunProcessSpec = {
program: string
args: string[]
env: NodeJS.ProcessEnv
timeoutMs: number
maxOutputBytes: number
}
function settled(stdout: string, overrides: Record<string, unknown> = {}): void {
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout,
stderr: '',
timedOut: false,
...overrides
})
}
function lastSpec(): RunProcessSpec {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the probe is the only caller of this mock and always passes a full ProcessSpec.
return runProcessMock.mock.calls.at(-1)?.[0] as RunProcessSpec
}
const DOCUMENTS = '/Users/alice/Documents/repo'
beforeEach(() => {
runProcessMock.mockReset()
})
describe('probeFolderAccessForFreshDaemon', () => {
it('reports each outcome the child prints', async () => {
for (const outcome of ['ok', 'denied', 'missing', 'other'] as const) {
settled(`${JSON.stringify({ outcome })}\n`)
await expect(probeFolderAccessForFreshDaemon(DOCUMENTS)).resolves.toBe(outcome)
}
})
it('runs the app binary as plain Node with the path as its only argument', async () => {
settled('{"outcome":"ok"}\n')
await probeFolderAccessForFreshDaemon(DOCUMENTS)
const spec = lastSpec()
expect(spec.program).toBe(process.execPath)
expect(spec.args[0]).toBe('-e')
expect(spec.args.at(-1)).toBe(DOCUMENTS)
expect(spec.args).toHaveLength(3)
expect(spec.env.ELECTRON_RUN_AS_NODE).toBe('1')
})
it('scrubs the environment down to the childs own needs', async () => {
settled('{"outcome":"ok"}\n')
vi.stubEnv('ORCA_SECRET_TOKEN', 'do-not-leak')
await probeFolderAccessForFreshDaemon(DOCUMENTS)
vi.unstubAllEnvs()
const names = Object.keys(lastSpec().env).sort()
expect(
names.every((name) => ['ELECTRON_RUN_AS_NODE', 'PATH', 'HOME', 'TMPDIR'].includes(name))
).toBe(true)
expect(names).not.toContain('ORCA_SECRET_TOKEN')
})
it('bounds the child by a deadline and an output cap', async () => {
settled('{"outcome":"ok"}\n')
await probeFolderAccessForFreshDaemon(DOCUMENTS)
expect(lastSpec().timeoutMs).toBe(3_000)
expect(lastSpec().maxOutputBytes).toBe(1024)
})
it('never passes the path through a shell', async () => {
settled('{"outcome":"ok"}\n')
await probeFolderAccessForFreshDaemon('/Users/alice/Documents/a b; rm -rf /')
expect(lastSpec().args.at(-1)).toBe('/Users/alice/Documents/a b; rm -rf /')
})
// Why: the path is the child's sole argv entry, so Node reads a leading dash as its own option.
it('refuses a relative path instead of handing it to Node as a flag', async () => {
await expect(probeFolderAccessForFreshDaemon('-e')).resolves.toBe('unknown')
expect(runProcessMock).not.toHaveBeenCalled()
})
it('reads a timeout as unknown, never as a denial', async () => {
settled('', { timedOut: true, code: null })
await expect(probeFolderAccessForFreshDaemon(DOCUMENTS)).resolves.toBe('unknown')
})
it('reads a non-zero exit as unknown', async () => {
settled('{"outcome":"denied"}\n', { code: 1 })
await expect(probeFolderAccessForFreshDaemon(DOCUMENTS)).resolves.toBe('unknown')
})
it('reads truncated output as unknown', async () => {
settled('{"outcome":"ok"}\n', { outputTruncated: true })
await expect(probeFolderAccessForFreshDaemon(DOCUMENTS)).resolves.toBe('unknown')
})
it.each([
['empty output', ''],
['not JSON', 'denied\n'],
['JSON that is not an object', '"denied"\n'],
['an object without the field', '{"result":"denied"}\n'],
['a value outside the enum', '{"outcome":"maybe"}\n']
])('reads %s as unknown', async (_label, stdout) => {
settled(stdout)
await expect(probeFolderAccessForFreshDaemon(DOCUMENTS)).resolves.toBe('unknown')
})
it('reads a spawn failure as unknown', async () => {
runProcessMock.mockRejectedValue(new Error('ENOENT'))
await expect(probeFolderAccessForFreshDaemon(DOCUMENTS)).resolves.toBe('unknown')
})
})
@@ -0,0 +1,87 @@
// Answers the one question the running daemon cannot (STA-7948): would a daemon forked by THIS
// app, right now, be able to list this folder? macOS attributes a TCC grant to the process that
// forked the child, so only a fresh child of the current app binary can tell the user whether
// restarting the terminal service is the remedy or whether they must re-allow Orca first.
import { isAbsolute } from 'node:path'
import { runProcess } from '../../shared/child-process/run-process'
import type { DirectoryEnumerationOutcome } from './directory-enumeration-probe'
/** `unknown` keeps "the probe could not answer" apart from every verdict it could have returned. */
export type FreshDaemonFolderAccess = DirectoryEnumerationOutcome | 'unknown'
const PROBE_DEADLINE_MS = 3_000
const PROBE_MAX_OUTPUT_BYTES = 1024
/** Everything the child needs; a scrubbed env keeps app-only state out of the probe's TCC context. */
const INHERITED_ENV_NAMES = ['PATH', 'HOME', 'TMPDIR'] as const
// Mirrors enumerateDirectoryOnce's errno mapping. Inlined rather than imported because the child
// runs as plain Node against argv only — it can load nothing from the app bundle.
const PROBE_SCRIPT = `const fs=require('node:fs');let d;let o;try{d=fs.opendirSync(process.argv[1]);d.readSync();o='ok'}catch(e){const c=e&&e.code;o=c==='EPERM'||c==='EACCES'?'denied':c==='ENOENT'||c==='ENOTDIR'?'missing':'other'}finally{try{if(d)d.closeSync()}catch(_){}}process.stdout.write(JSON.stringify({outcome:o})+'\\n')`
function probeEnvironment(): NodeJS.ProcessEnv {
// Why ELECTRON_RUN_AS_NODE: the app binary is Electron; the daemon is forked the same way.
const env: NodeJS.ProcessEnv = { ELECTRON_RUN_AS_NODE: '1' }
for (const name of INHERITED_ENV_NAMES) {
const value = process.env[name]
if (value !== undefined) {
env[name] = value
}
}
return env
}
function parseProbeOutcome(stdout: string): FreshDaemonFolderAccess {
const line = stdout.trim()
if (line.length === 0) {
return 'unknown'
}
let parsed: unknown
try {
parsed = JSON.parse(line)
} catch {
return 'unknown'
}
if (typeof parsed !== 'object' || parsed === null || !('outcome' in parsed)) {
return 'unknown'
}
const { outcome } = parsed
switch (outcome) {
case 'ok':
case 'denied':
case 'missing':
case 'other':
return outcome
default:
return 'unknown'
}
}
/**
* Never throws and never outlives its deadline: this runs off the spawn path, and a folder whose
* readability we cannot establish must read as `unknown` rather than as either verdict.
*/
export async function probeFolderAccessForFreshDaemon(
path: string
): Promise<FreshDaemonFolderAccess> {
// Why absolute-only: the path is the child's sole argv entry, and Node parses a leading-dash
// argument as one of its own options.
if (!isAbsolute(path)) {
return 'unknown'
}
try {
const result = await runProcess({
program: process.execPath,
args: ['-e', PROBE_SCRIPT, path],
env: probeEnvironment(),
timeoutMs: PROBE_DEADLINE_MS,
maxOutputBytes: PROBE_MAX_OUTPUT_BYTES
})
if (result.timedOut || result.code !== 0 || result.outputTruncated === true) {
return 'unknown'
}
return parseProbeOutcome(result.stdout)
} catch {
return 'unknown'
}
}
@@ -0,0 +1,268 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { validate } from '../telemetry/validator'
const {
trackMock,
getPathMock,
opendirMock,
readMacosBundleIdMock,
resetMacosTccPermissionMock,
getTargetMock,
getMismatchMock,
refreshProbeMock
} = vi.hoisted(() => ({
trackMock: vi.fn(),
getPathMock: vi.fn(() => '/Applications/Orca.app/Contents/MacOS/Orca'),
opendirMock: vi.fn(),
readMacosBundleIdMock: vi.fn<() => Promise<string | null>>(async () => 'com.stablyai.orca'),
resetMacosTccPermissionMock: vi.fn<() => Promise<{ ok: boolean; detail?: string }>>(async () => ({
ok: true
})),
getTargetMock: vi.fn<() => { canonicalPath: string; cwdClass: string } | null>(() => null),
getMismatchMock: vi.fn<
() => { daemonScope: string; cwdClass: string; freshDaemonAccess: string } | null
>(() => null),
refreshProbeMock: vi.fn(async () => {})
}))
vi.mock('electron', () => ({ app: { getPath: getPathMock } }))
vi.mock('node:fs/promises', () => ({ opendir: opendirMock }))
vi.mock('../telemetry/client', () => ({ track: trackMock }))
vi.mock('../macos-tcc-reset', () => ({
readMacosBundleId: readMacosBundleIdMock,
resetMacosTccPermission: resetMacosTccPermissionMock
}))
vi.mock('./daemon-folder-access-mismatch', () => ({
getDaemonFolderAccessTarget: getTargetMock,
getDaemonFolderAccessMismatch: getMismatchMock,
refreshDaemonFolderAccessProbe: refreshProbeMock
}))
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
import { resetFolderAccessForDaemon } from './daemon-folder-access-reset'
const DAEMON: DaemonEndpointIdentity = { pid: 1530, startedAtMs: 1_700_000, launchNonce: 'n1' }
const originalPlatform = process.platform
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
}
/** The folder handle the app opens to provoke the prompt; `read` then `close`, both awaited. */
function fakeDir(): { read: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> } {
return { read: vi.fn(async () => null), close: vi.fn(async () => {}) }
}
beforeEach(() => {
setPlatform('darwin')
trackMock.mockReset()
getPathMock.mockReset().mockReturnValue('/Applications/Orca.app/Contents/MacOS/Orca')
opendirMock.mockReset().mockResolvedValue(fakeDir())
readMacosBundleIdMock.mockReset().mockResolvedValue('com.stablyai.orca')
resetMacosTccPermissionMock.mockReset().mockResolvedValue({ ok: true })
getTargetMock
.mockReset()
.mockReturnValue({ canonicalPath: '/Users/alice/Documents/repo', cwdClass: 'documents' })
getMismatchMock.mockReset().mockReturnValue(null)
refreshProbeMock.mockReset().mockResolvedValue(undefined)
})
afterEach(() => {
setPlatform(originalPlatform)
})
describe('resetFolderAccessForDaemon rejects cases it cannot remedy', () => {
it('is unsupported off macOS, where there is no TCC row to clear', async () => {
setPlatform('win32')
expect(await resetFolderAccessForDaemon(DAEMON)).toEqual({ outcome: 'unsupported' })
expect(resetMacosTccPermissionMock).not.toHaveBeenCalled()
})
it('is unsupported when no evidence belongs to this daemon', async () => {
getTargetMock.mockReturnValue(null)
expect(await resetFolderAccessForDaemon(DAEMON)).toEqual({ outcome: 'unsupported' })
expect(resetMacosTccPermissionMock).not.toHaveBeenCalled()
})
// Only Documents/Desktop/Downloads have a per-app TCC row; the rest have nothing to reset.
it.each([['other-home'], ['outside-home']])(
'is unsupported for the %s folder class',
async (cwdClass) => {
getTargetMock.mockReturnValue({ canonicalPath: '/Users/alice/code', cwdClass })
expect(await resetFolderAccessForDaemon(DAEMON)).toEqual({ outcome: 'unsupported' })
expect(resetMacosTccPermissionMock).not.toHaveBeenCalled()
}
)
it('is unsupported when the running bundle has no readable identifier', async () => {
readMacosBundleIdMock.mockResolvedValue(null)
expect(await resetFolderAccessForDaemon(DAEMON)).toEqual({ outcome: 'unsupported' })
expect(resetMacosTccPermissionMock).not.toHaveBeenCalled()
})
it('reports a refused tccutil without touching the folder', async () => {
resetMacosTccPermissionMock.mockResolvedValue({ ok: false, detail: 'exit 64' })
expect(await resetFolderAccessForDaemon(DAEMON)).toEqual({ outcome: 'reset_failed' })
expect(opendirMock).not.toHaveBeenCalled()
expect(refreshProbeMock).not.toHaveBeenCalled()
})
})
describe('resetFolderAccessForDaemon runs the remedy', () => {
it.each([
['documents', 'SystemPolicyDocumentsFolder'],
['desktop', 'SystemPolicyDesktopFolder'],
['downloads', 'SystemPolicyDownloadsFolder']
])('clears the %s row against the running app bundle', async (cwdClass, service) => {
getTargetMock.mockReturnValue({ canonicalPath: '/Users/alice/Documents/repo', cwdClass })
await resetFolderAccessForDaemon(DAEMON)
expect(readMacosBundleIdMock).toHaveBeenCalledWith('/Applications/Orca.app')
expect(resetMacosTccPermissionMock).toHaveBeenCalledWith(service, 'com.stablyai.orca')
})
// The prompt is attributed to whoever makes the syscall, so the app has to be what reads it.
it('reads the folder from the app, then forces a fresh-daemon re-probe', async () => {
const dir = fakeDir()
opendirMock.mockResolvedValue(dir)
await resetFolderAccessForDaemon(DAEMON)
expect(opendirMock).toHaveBeenCalledWith('/Users/alice/Documents/repo')
expect(dir.read).toHaveBeenCalledTimes(1)
expect(dir.close).toHaveBeenCalledTimes(1)
expect(refreshProbeMock).toHaveBeenCalledWith(DAEMON, { force: true })
})
it('still re-probes when the folder read is itself denied', async () => {
opendirMock.mockRejectedValue(Object.assign(new Error('denied'), { code: 'EPERM' }))
getMismatchMock.mockReturnValue({
daemonScope: 'aaaa111122223333',
cwdClass: 'documents',
freshDaemonAccess: 'denied'
})
expect(await resetFolderAccessForDaemon(DAEMON)).toEqual({
outcome: 'probed',
mismatch: {
daemonScope: 'aaaa111122223333',
cwdClass: 'documents',
freshDaemonAccess: 'denied'
}
})
expect(refreshProbeMock).toHaveBeenCalledWith(DAEMON, { force: true })
})
// An unanswered TCC sheet blocks the read for as long as the user ignores it, and the dialog is
// modal and busy the whole time.
it('stops waiting on an unanswered prompt, and does not probe under the sheet', async () => {
const denied = {
daemonScope: 'aaaa111122223333',
cwdClass: 'documents',
freshDaemonAccess: 'denied'
}
opendirMock.mockReturnValue(new Promise<never>(() => {}))
getMismatchMock.mockReturnValue(denied)
vi.useFakeTimers()
try {
const pending = resetFolderAccessForDaemon(DAEMON)
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(60_000)
// The stored verdict predates the reset, so it is not the reset's answer.
expect(await pending).toEqual({
outcome: 'probed',
mismatch: { ...denied, freshDaemonAccess: 'unknown' }
})
expect(refreshProbeMock).not.toHaveBeenCalled()
// Nothing probed the folder after the reset, so the outcome is not a verdict.
expect(trackMock).toHaveBeenCalledWith('daemon_folder_access_notice', {
action: 'reset_outcome_unknown',
cwd_class: 'documents'
})
} finally {
vi.useRealTimers()
}
})
it('keeps waiting inside the deadline and probes once the prompt is answered', async () => {
let answer: () => void = () => {}
opendirMock.mockReturnValue(
new Promise((resolve) => {
answer = () => resolve(fakeDir())
})
)
vi.useFakeTimers()
try {
const pending = resetFolderAccessForDaemon(DAEMON)
await vi.advanceTimersByTimeAsync(59_000)
expect(refreshProbeMock).not.toHaveBeenCalled()
answer()
await vi.advanceTimersByTimeAsync(0)
await pending
expect(refreshProbeMock).toHaveBeenCalledWith(DAEMON, { force: true })
} finally {
vi.useRealTimers()
}
})
it('closes the handle even when the read throws', async () => {
const dir = fakeDir()
dir.read.mockRejectedValue(new Error('EPERM'))
opendirMock.mockResolvedValue(dir)
await resetFolderAccessForDaemon(DAEMON)
expect(dir.close).toHaveBeenCalledTimes(1)
})
})
// Nobody has verified this remedy on an affected machine, so the re-probe's verdict is the
// feature's only evidence. It must leave main as a valid event every time.
describe('resetFolderAccessForDaemon reports the outcome', () => {
it.each([
['allowed', 'reset_outcome_allowed'],
['denied', 'reset_outcome_still_denied'],
['unknown', 'reset_outcome_unknown']
])('emits %s as %s', async (freshDaemonAccess, action) => {
getMismatchMock.mockReturnValue({
daemonScope: 'aaaa111122223333',
cwdClass: 'documents',
freshDaemonAccess
})
await resetFolderAccessForDaemon(DAEMON)
expect(trackMock).toHaveBeenCalledWith('daemon_folder_access_notice', {
action,
cwd_class: 'documents'
})
expect(validate('daemon_folder_access_notice', trackMock.mock.calls[0][1]).ok).toBe(true)
})
it('treats a retired entry as an unknown outcome', async () => {
getMismatchMock.mockReturnValue(null)
expect(await resetFolderAccessForDaemon(DAEMON)).toEqual({ outcome: 'probed', mismatch: null })
expect(trackMock).toHaveBeenCalledWith('daemon_folder_access_notice', {
action: 'reset_outcome_unknown',
cwd_class: 'documents'
})
})
it('completes the reset even when telemetry throws', async () => {
trackMock.mockImplementation(() => {
throw new Error('no transport')
})
expect(await resetFolderAccessForDaemon(DAEMON)).toEqual({ outcome: 'probed', mismatch: null })
})
})
@@ -0,0 +1,123 @@
// The remedy for the third of affected users whom a freshly forked daemon is still denied
// (STA-7948) even though Orca itself is allowed: clear Orca's TCC row for that folder class so
// macOS asks again, have the app touch the folder so the prompt names Orca, then re-probe.
import { app } from 'electron'
import { dirname, resolve } from 'node:path'
import {
isMacTccFolderClass,
type DaemonPtyCwdClass,
type MacTccFolderClass
} from '../../shared/daemon-adoption-telemetry'
import type { EventProps } from '../../shared/telemetry-events'
import { readMacosBundleId, resetMacosTccPermission } from '../macos-tcc-reset'
import { enumerateDirectoryOnce } from './directory-enumeration-probe'
import { track } from '../telemetry/client'
import {
getDaemonFolderAccessMismatch,
getDaemonFolderAccessTarget,
refreshDaemonFolderAccessProbe,
type DaemonFolderAccessMismatchNotice,
type FreshDaemonAccess
} from './daemon-folder-access-mismatch'
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
/**
* `unsupported` covers every reason the remedy does not apply — no stored evidence, a folder class
* TCC has no service for, another platform, or a bundle id we cannot read — because the dialog
* says the same thing to the user for all of them.
*/
export type DaemonFolderAccessResetResult =
| { outcome: 'unsupported' }
| { outcome: 'reset_failed' }
| { outcome: 'probed'; mismatch: DaemonFolderAccessMismatchNotice | null }
const TCC_SERVICE_BY_CWD_CLASS: Record<MacTccFolderClass, string> = {
documents: 'SystemPolicyDocumentsFolder',
desktop: 'SystemPolicyDesktopFolder',
downloads: 'SystemPolicyDownloadsFolder'
}
/** `Orca.app/Contents/MacOS/Orca` → `Orca.app`, the bundle whose id owns every TCC row. */
function runningAppBundlePath(): string {
return resolve(dirname(app.getPath('exe')), '..', '..')
}
/** An unanswered macOS sheet must not keep the fix dialog busy for the rest of the session. */
const PROMPT_DEADLINE_MS = 60_000
/**
* Why the app reads the folder itself: TCC raises its prompt against the process that made the
* syscall, so a daemon-side read would put the daemon on screen, or nothing at all. Async
* throughout — the prompt blocks the calling syscall until the user answers it, and the sync
* variant would take main's event loop down with it for the whole time the dialog is up.
*
* Returns false once the deadline passes with the read still blocked, which means the sheet is up
* and unanswered. The read itself cannot be cancelled; it is simply no longer awaited.
*/
async function promptByReadingFolder(path: string): Promise<boolean> {
let deadline: NodeJS.Timeout | undefined
try {
return await Promise.race([
// The outcome is the re-probe's job; this read exists only to raise the prompt.
enumerateDirectoryOnce(path).then(() => true),
new Promise<false>((resolve) => {
deadline = setTimeout(() => resolve(false), PROMPT_DEADLINE_MS)
})
])
} finally {
clearTimeout(deadline)
}
}
const RESET_OUTCOME_ACTION = {
allowed: 'reset_outcome_allowed',
denied: 'reset_outcome_still_denied',
unknown: 'reset_outcome_unknown'
} as const satisfies Record<FreshDaemonAccess, EventProps<'daemon_folder_access_notice'>['action']>
/**
* Emitted from main, not the renderer: nobody has verified this remedy on an affected machine, so
* the verdict the re-probe returns is the only evidence the feature works.
*/
function emitResetOutcome(cwdClass: DaemonPtyCwdClass, access: FreshDaemonAccess): void {
try {
track('daemon_folder_access_notice', {
action: RESET_OUTCOME_ACTION[access],
cwd_class: cwdClass
})
} catch {
// Best-effort: a dropped event must not turn a completed reset into a failure.
}
}
export async function resetFolderAccessForDaemon(
identity: DaemonEndpointIdentity | null
): Promise<DaemonFolderAccessResetResult> {
if (process.platform !== 'darwin') {
return { outcome: 'unsupported' }
}
const target = getDaemonFolderAccessTarget(identity)
if (!target || !isMacTccFolderClass(target.cwdClass)) {
return { outcome: 'unsupported' }
}
const bundleId = await readMacosBundleId(runningAppBundlePath())
if (bundleId === null) {
return { outcome: 'unsupported' }
}
if (!(await resetMacosTccPermission(TCC_SERVICE_BY_CWD_CLASS[target.cwdClass], bundleId)).ok) {
return { outcome: 'reset_failed' }
}
const prompted = await promptByReadingFolder(target.canonicalPath)
// Why no probe once the deadline passes: the sheet is still up, and a probe under it would read
// as denied — a verdict about the unanswered prompt, not about the permission.
if (prompted) {
await refreshDaemonFolderAccessProbe(identity, { force: true })
}
const mismatch = getDaemonFolderAccessMismatch(identity)
// One access for the event and the dialog: with the prompt unanswered the stored verdict predates
// the reset, so reporting it as the reset's would claim a denial nothing has re-read.
const access = prompted ? (mismatch?.freshDaemonAccess ?? 'unknown') : 'unknown'
emitResetOutcome(target.cwdClass, access)
return { outcome: 'probed', mismatch: mismatch && { ...mismatch, freshDaemonAccess: access } }
}
+6
View File
@@ -1,9 +1,15 @@
import { emitPtyListeners, createPtyExitPayload } from './daemon-pty-listener-emission'
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'
export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyProvider {
/** Identity of the daemon behind this adapter; null until hello completes or after a disconnect. */
getDaemonIdentity(): DaemonEndpointIdentity | null {
return this.client.getDaemonIdentity()
}
protected setupEventRouting(): void {
if (this.removeEventListener) {
return
+8 -2
View File
@@ -5,7 +5,7 @@ import type {
HistoryRecoveryContext,
PendingDaemonSpawnOperation
} from './daemon-pty-runtime-state'
import { trackDaemonPtyCwdDeniedIfDiverged } from './daemon-adoption-telemetry-event'
import { reportDaemonPtyCwdVerdict } from './daemon-adoption-telemetry-event'
import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version'
import { TerminalKilledError } from './daemon-pty-lifecycle-errors'
import { DaemonPtySpawnResult } from './daemon-pty-spawn-result'
@@ -253,7 +253,13 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
activeSpawnContext = context
const result = await this.createOrAttachSpawn(context, context.historySeedSegments)
if (result.isNew && !attachOnly) {
trackDaemonPtyCwdDeniedIfDiverged(effectiveCwd, result.cwdReadableByDaemon, this.pidPath)
// Not awaited: the app-side read behind it can sit on an unanswered macOS folder prompt.
void reportDaemonPtyCwdVerdict({
cwd: effectiveCwd,
cwdReadableByDaemon: result.cwdReadableByDaemon,
pidPath: this.pidPath,
daemonIdentity: this.client.getDaemonIdentity()
})
}
return this.finishSpawn(context, result)
}
@@ -0,0 +1,41 @@
import type { Dir } from 'node:fs'
import { opendir } from 'node:fs/promises'
/** `denied` is the only outcome that proves a permission refusal; `other` keeps unknown errors apart. */
export type DirectoryEnumerationOutcome = 'ok' | 'denied' | 'missing' | 'other'
function errorCode(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null || !('code' in error)) {
return undefined
}
const { code } = error
return typeof code === 'string' ? code : undefined
}
function outcomeForError(error: unknown): DirectoryEnumerationOutcome {
const code = errorCode(error)
if (code === 'EPERM' || code === 'EACCES') {
return 'denied'
}
return code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'other'
}
/**
* Why enumeration and not `access()`: macOS TCC can let `access(R_OK|X_OK)` succeed on a protected
* folder while `opendir` still fails, which is exactly what a shell listing its cwd hits. One entry
* is enough — the refusal lands on `opendir` or the first read, never later.
*/
export async function enumerateDirectoryOnce(path: string): Promise<DirectoryEnumerationOutcome> {
let dir: Dir | undefined
try {
dir = await opendir(path)
await dir.read()
return 'ok'
} catch (error) {
return outcomeForError(error)
} finally {
await dir?.close().catch(() => {
// A handle we cannot close says nothing about readability.
})
}
}
@@ -2,8 +2,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SubprocessHandle } from './session-subprocess-handle'
import { TerminalHost, type TerminalHostOptions } from './terminal-host'
const { opendirMock } = vi.hoisted(() => ({ opendirMock: vi.fn() }))
// Async on purpose: on macOS this read is what raises the TCC prompt, which holds the syscall for
// as long as the user leaves the sheet up.
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
opendir: opendirMock
}))
vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() }))
const close = vi.fn(async () => {})
function dirReading(read: () => unknown): { read: () => unknown; close: () => Promise<void> } {
return { read, close }
}
function failWith(code: string): never {
throw Object.assign(new Error(code), { code })
}
function createMockSubprocess(): SubprocessHandle {
let onExitCb: ((code: number) => void) | null = null
return {
@@ -25,13 +43,15 @@ function createMockSubprocess(): SubprocessHandle {
}
}
// #17696: only the daemon process can say whether TCC lets it read the cwd, so its verdict
// #17696: only the daemon process can say whether TCC lets it enumerate the cwd, so its verdict
// rides on the create result. A non-permission failure must never read as denial.
describe('TerminalHost cwd readability verdict', () => {
let host: TerminalHost
let platformDescriptor: PropertyDescriptor | undefined
beforeEach(() => {
close.mockReset()
opendirMock.mockReset().mockReturnValue(dirReading(() => ({ name: 'entry' })))
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
const spawnSubprocess: TerminalHostOptions['spawnSubprocess'] = () => createMockSubprocess()
@@ -54,21 +74,54 @@ describe('TerminalHost cwd readability verdict', () => {
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
it('reports a readable cwd as readable', async () => {
expect((await create('readable', process.cwd())).cwdReadableByDaemon).toBe(true)
it('reports an enumerable cwd as readable, and closes the handle', async () => {
expect((await create('readable', '/work/repo')).cwdReadableByDaemon).toBe(true)
expect(opendirMock).toHaveBeenCalledWith('/work/repo')
expect(close).toHaveBeenCalled()
})
it('reports an empty directory as readable', async () => {
opendirMock.mockReturnValue(dirReading(() => null))
expect((await create('empty', '/work/empty')).cwdReadableByDaemon).toBe(true)
})
// The #17696 shape: TCC refuses the daemon, and only a refusal may read as denial.
it('reports EPERM on open as denied', async () => {
opendirMock.mockImplementation(() => failWith('EPERM'))
expect((await create('eperm', '/Users/alice/Documents/repo')).cwdReadableByDaemon).toBe(false)
})
it('reports EACCES on the first read as denied, and still closes the handle', async () => {
opendirMock.mockReturnValue(dirReading(() => failWith('EACCES')))
expect((await create('eacces', '/Users/alice/Desktop/repo')).cwdReadableByDaemon).toBe(false)
expect(close).toHaveBeenCalled()
})
it('reports a missing cwd as readable — absence is not a permission denial', async () => {
expect((await create('missing', '/definitely/not/a/real/dir')).cwdReadableByDaemon).toBe(true)
opendirMock.mockImplementation(() => failWith('ENOENT'))
expect((await create('enoent', '/definitely/not/a/real/dir')).cwdReadableByDaemon).toBe(true)
})
it('reports a non-directory cwd as readable', async () => {
opendirMock.mockImplementation(() => failWith('ENOTDIR'))
expect((await create('enotdir', '/work/repo/file.txt')).cwdReadableByDaemon).toBe(true)
})
it('reports an unexpected failure as readable — it must not masquerade as denial', async () => {
opendirMock.mockImplementation(() => {
throw new TypeError('opendir is not a function')
})
expect((await create('unexpected', '/work/repo')).cwdReadableByDaemon).toBe(true)
})
it('omits the verdict when no cwd was requested', async () => {
expect((await create('no-cwd')).cwdReadableByDaemon).toBeUndefined()
expect(opendirMock).not.toHaveBeenCalled()
})
it('omits the verdict on attach to an existing session', async () => {
await create('attach', process.cwd())
const attached = await create('attach', process.cwd())
await create('attach', '/work/repo')
const attached = await create('attach', '/work/repo')
expect(attached.isNew).toBe(false)
expect(attached.cwdReadableByDaemon).toBeUndefined()
})
@@ -1,7 +1,7 @@
import { accessSync, constants as fsConstants } from 'node:fs'
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend'
import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result'
import { enumerateDirectoryOnce } from './directory-enumeration-probe'
import { normalizePtySize } from './daemon-pty-size'
import { Session } from './session'
import { shellPathSupportsPtyStartupBarrier } from './shell-ready'
@@ -110,7 +110,8 @@ async function spawnAndPublishSession(
): Promise<CreateOrAttachResult> {
const { size, wslDistro } = ctx
// Why before the fork: the shell's own cwd may already have fallen back, so probe the requested path.
const cwdReadableByDaemon = opts.cwd && !wslDistro ? isCwdReadableByThisProcess(opts.cwd) : null
const cwdReadableByDaemon =
opts.cwd && !wslDistro ? await isCwdReadableByThisProcess(opts.cwd) : null
const subprocess = await deps.spawnSubprocess({
sessionId: opts.sessionId,
cols: size.cols,
@@ -225,15 +226,9 @@ function createSessionExitHandler(
return () => onSessionExit(sessionId, generation)
}
// Why R_OK|X_OK: listing a directory needs read, and entering it needs search — both are what
// TCC withholds. A non-permission failure (ENOENT, ENOTDIR) reads as readable so it can never
// masquerade as a permission denial.
function isCwdReadableByThisProcess(cwd: string): boolean {
try {
accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK)
return true
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
return code !== 'EACCES' && code !== 'EPERM'
}
// Why enumeration: a shell's cwd listing is what TCC withholds, and it can withhold it while
// `access()` still passes. Only a proven permission refusal reads as denial — a missing path or an
// unexpected error reads as readable so it can never masquerade as one.
async function isCwdReadableByThisProcess(cwd: string): Promise<boolean> {
return (await enumerateDirectoryOnce(cwd)) !== 'denied'
}
+5
View File
@@ -16,6 +16,8 @@ const PRIVACY_PANE_URLS: Partial<Record<DeveloperPermissionId, string>> = {
screen: 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
accessibility: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility',
'full-disk-access': 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles',
'files-and-folders':
'x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders',
automation: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Automation',
'local-network':
'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_LocalNetwork',
@@ -152,6 +154,9 @@ async function getPermissionState(id: DeveloperPermissionId): Promise<DeveloperP
return { id, status: getAccessibilityStatus() }
case 'full-disk-access':
return { id, status: await getMacosFullDiskAccessStatus() }
// 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':
case 'automation':
case 'local-network':
return { id, status: unsupportedOffMac() ?? 'unknown' }
+214 -18
View File
@@ -1,24 +1,50 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DaemonSessionInfo } from '../daemon/types'
// Mirrors DaemonFolderAccessResetResult; declared here so the mock is not typed by the module it
// replaces.
type FolderAccessResetResult = {
outcome: string
mismatch?: { daemonScope: string; cwdClass: string; freshDaemonAccess: string } | null
}
const {
handleMock,
removeHandlerMock,
getDaemonProviderMock,
restartDaemonMock,
getCurrentDaemonMacTccAttributionHealthMock
getCurrentDaemonMacTccAttributionHealthMock,
getDaemonFolderAccessMismatchMock,
refreshDaemonFolderAccessProbeMock,
resetFolderAccessForDaemonMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
getDaemonProviderMock: vi.fn(),
restartDaemonMock: vi.fn(),
getCurrentDaemonMacTccAttributionHealthMock: vi.fn(async () => 'unknown')
getCurrentDaemonMacTccAttributionHealthMock: vi.fn(async () => 'unknown'),
getDaemonFolderAccessMismatchMock: vi.fn<
() => { daemonScope: string; cwdClass: string; freshDaemonAccess: string } | null
>(() => null),
refreshDaemonFolderAccessProbeMock: vi.fn(async () => {}),
resetFolderAccessForDaemonMock: vi.fn<() => Promise<FolderAccessResetResult>>(async () => ({
outcome: 'unsupported'
}))
}))
vi.mock('electron', () => ({
ipcMain: { handle: handleMock, removeHandler: removeHandlerMock }
}))
vi.mock('../daemon/daemon-folder-access-mismatch', () => ({
getDaemonFolderAccessMismatch: getDaemonFolderAccessMismatchMock,
refreshDaemonFolderAccessProbe: refreshDaemonFolderAccessProbeMock
}))
vi.mock('../daemon/daemon-folder-access-reset', () => ({
resetFolderAccessForDaemon: resetFolderAccessForDaemonMock
}))
vi.mock('../daemon/daemon-init', () => ({
getDaemonProvider: getDaemonProviderMock,
restartDaemon: restartDaemonMock,
@@ -35,12 +61,21 @@ vi.mock('../daemon/daemon-init', () => ({
vi.mock('../daemon/daemon-pty-router', () => {
class DaemonPtyRouter {
private allAdapters: unknown[]
private current: unknown
constructor(opts: { current: unknown; legacy: unknown[] }) {
this.current = opts.current
this.allAdapters = [opts.current, ...opts.legacy]
}
getAllAdapters() {
return this.allAdapters
}
// Why: the folder-access read asks the *current* adapter for the daemon identity.
getCurrentAdapter() {
return this.current
}
getLegacyAdapters() {
return this.allAdapters.slice(1)
}
}
return { DaemonPtyRouter }
})
@@ -51,10 +86,18 @@ vi.mock('../daemon/daemon-pty-router', () => {
vi.mock('../daemon/degraded-daemon-pty-provider', () => {
class DegradedDaemonPtyProvider {
private allAdapters: unknown[]
private current: unknown
private routesFreshToFallback = true
constructor(opts: { current: unknown; legacy: unknown[] }) {
this.current = opts.current
this.allAdapters = [opts.current, ...opts.legacy]
}
getCurrentAdapter() {
return this.current
}
getLegacyAdapters() {
return this.allAdapters.slice(1)
}
get routesFreshSpawnsToLocalProvider(): true | undefined {
return this.routesFreshToFallback ? true : undefined
}
@@ -103,6 +146,7 @@ type MockAdapter = {
protocolVersion: number
listSessions: ReturnType<typeof vi.fn>
shutdown: ReturnType<typeof vi.fn>
getDaemonIdentity: ReturnType<typeof vi.fn>
}
function makeAdapter(
@@ -117,7 +161,8 @@ function makeAdapter(
return {
protocolVersion,
listSessions: vi.fn(async () => sessions.map(({ protocolVersion: _pv, ...rest }) => rest)),
shutdown: vi.fn(shutdownImpl ?? (async () => {}))
shutdown: vi.fn(shutdownImpl ?? (async () => {})),
getDaemonIdentity: vi.fn(() => ({ pid: 1530, startedAtMs: 1_700_000, launchNonce: 'n1' }))
}
}
@@ -148,6 +193,9 @@ describe('pty:management IPC handlers', () => {
restartDaemonMock.mockReset()
getCurrentDaemonMacTccAttributionHealthMock.mockReset()
getCurrentDaemonMacTccAttributionHealthMock.mockResolvedValue('unknown')
getDaemonFolderAccessMismatchMock.mockReset().mockReturnValue(null)
refreshDaemonFolderAccessProbeMock.mockReset().mockResolvedValue(undefined)
resetFolderAccessForDaemonMock.mockReset().mockResolvedValue({ outcome: 'unsupported' })
})
afterEach(() => {
@@ -465,32 +513,128 @@ describe('pty:management IPC handlers', () => {
})
describe('macTccAttribution', () => {
type AttributionResult = {
health: string
folderAccessMismatch: {
daemonScope: string
cwdClass: string
freshDaemonAccess: string
} | null
}
async function readAttribution(): Promise<AttributionResult> {
const { registerDaemonManagementHandlers } = await importFresh()
registerDaemonManagementHandlers()
const handlers = buildHandlerMap()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler map is untyped by construction; this channel's handler is the one registered above.
return (await handlers['pty:management:macTccAttribution']({})) as AttributionResult
}
it('reports the daemon attribution health', async () => {
getCurrentDaemonMacTccAttributionHealthMock.mockResolvedValue('severed')
const { registerDaemonManagementHandlers } = await importFresh()
registerDaemonManagementHandlers()
const handlers = buildHandlerMap()
const result = (await handlers['pty:management:macTccAttribution']({})) as {
health: string
}
const result = await readAttribution()
expect(result.health).toBe('severed')
expect(result.folderAccessMismatch).toBeNull()
})
it('fails open to unknown when the probe throws', async () => {
it('fails open to unknown when the probe throws, keeping the folder evidence', async () => {
getCurrentDaemonMacTccAttributionHealthMock.mockRejectedValue(new Error('no pid record'))
const current = makeAdapter(5, [])
getDaemonProviderMock.mockReturnValue(await makeRouter(current, []))
getDaemonFolderAccessMismatchMock.mockReturnValue(evidence('denied'))
const { registerDaemonManagementHandlers } = await importFresh()
registerDaemonManagementHandlers()
const handlers = buildHandlerMap()
const result = (await handlers['pty:management:macTccAttribution']({})) as {
health: string
}
const result = await readAttribution()
expect(result.health).toBe('unknown')
expect(result.folderAccessMismatch).toEqual(evidence('denied'))
})
it('carries folder-access evidence for the current daemon', async () => {
const current = makeAdapter(5, [])
getDaemonProviderMock.mockReturnValue(await makeRouter(current, [makeAdapter(4, [])]))
getDaemonFolderAccessMismatchMock.mockReturnValue({
daemonScope: 'abc123def4567890',
cwdClass: 'documents',
freshDaemonAccess: 'allowed'
})
const result = await readAttribution()
expect(result.folderAccessMismatch).toEqual({
daemonScope: 'abc123def4567890',
cwdClass: 'documents',
freshDaemonAccess: 'allowed'
})
// Why: evidence belongs to the daemon spawning terminals now, never a legacy adapter's.
expect(getDaemonFolderAccessMismatchMock).toHaveBeenCalledWith({
pid: 1530,
startedAtMs: 1_700_000,
launchNonce: 'n1'
})
expect(current.getDaemonIdentity).toHaveBeenCalled()
})
function evidence(freshDaemonAccess: string): {
daemonScope: string
cwdClass: string
freshDaemonAccess: string
} {
return { daemonScope: 'abc123def4567890', cwdClass: 'documents', freshDaemonAccess }
}
// Why re-probe on the poll: the fix dialog's first step completes in System Settings, and
// this is the only moment anything can notice that it landed.
it.each([['denied'], ['unknown']])(
'reports the verdict the re-probe leaves behind, not the %s one it started from',
async (initial) => {
getDaemonFolderAccessMismatchMock.mockReturnValue(evidence(initial))
refreshDaemonFolderAccessProbeMock.mockImplementation(async () => {
getDaemonFolderAccessMismatchMock.mockReturnValue(evidence('allowed'))
})
const result = await readAttribution()
expect(refreshDaemonFolderAccessProbeMock).toHaveBeenCalledTimes(1)
expect(result.folderAccessMismatch?.freshDaemonAccess).toBe('allowed')
}
)
// Whether a refresh is worth running is the refresh's own decision; the handler just reports
// whatever evidence is there afterwards.
it('reports a settled allowed verdict unchanged', async () => {
getDaemonFolderAccessMismatchMock.mockReturnValue(evidence('allowed'))
const result = await readAttribution()
expect(result.folderAccessMismatch).toEqual(evidence('allowed'))
})
it('reports no evidence at all as no mismatch', async () => {
getDaemonFolderAccessMismatchMock.mockReturnValue(null)
const result = await readAttribution()
expect(result.folderAccessMismatch).toBeNull()
})
it('keeps the folder evidence when the refresh throws', async () => {
getDaemonFolderAccessMismatchMock.mockReturnValue(evidence('denied'))
refreshDaemonFolderAccessProbeMock.mockRejectedValue(new Error('probe exploded'))
const result = await readAttribution()
expect(result.folderAccessMismatch).toEqual(evidence('denied'))
})
it('reads a null identity when no daemon provider exists', async () => {
getDaemonProviderMock.mockReturnValue(null)
const result = await readAttribution()
expect(getDaemonFolderAccessMismatchMock).toHaveBeenCalledWith(null)
expect(result.folderAccessMismatch).toBeNull()
})
})
@@ -522,4 +666,56 @@ describe('pty:management IPC handlers', () => {
expect(result.success).toBe(false)
})
})
describe('resetFolderAccess', () => {
async function runReset(): Promise<FolderAccessResetResult> {
const { registerDaemonManagementHandlers } = await importFresh()
registerDaemonManagementHandlers()
const handlers = buildHandlerMap()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler map is untyped by construction; this channel's handler is the one registered above.
return (await handlers['pty:management:resetFolderAccess']({})) as FolderAccessResetResult
}
it('hands the current daemon to the reset and returns its verdict', async () => {
const current = makeAdapter(5, [])
getDaemonProviderMock.mockReturnValue(await makeRouter(current, [makeAdapter(4, [])]))
resetFolderAccessForDaemonMock.mockResolvedValue({
outcome: 'probed',
mismatch: {
daemonScope: 'abc123def4567890',
cwdClass: 'documents',
freshDaemonAccess: 'allowed'
}
})
expect(await runReset()).toEqual({
outcome: 'probed',
mismatch: {
daemonScope: 'abc123def4567890',
cwdClass: 'documents',
freshDaemonAccess: 'allowed'
}
})
// Why the current adapter: a legacy daemon's denial is not the one the user is looking at.
expect(resetFolderAccessForDaemonMock).toHaveBeenCalledWith({
pid: 1530,
startedAtMs: 1_700_000,
launchNonce: 'n1'
})
})
it('reports unsupported rather than rejecting when the reset throws', async () => {
resetFolderAccessForDaemonMock.mockRejectedValue(new Error('no app bundle'))
expect(await runReset()).toEqual({ outcome: 'unsupported' })
})
it('registers the channel exactly once per registration', async () => {
const { registerDaemonManagementHandlers } = await importFresh()
registerDaemonManagementHandlers()
expect(removeHandlerMock).toHaveBeenCalledWith('pty:management:resetFolderAccess')
expect(buildHandlerMap()['pty:management:resetFolderAccess']).toBeTypeOf('function')
})
})
})
+45 -4
View File
@@ -7,7 +7,18 @@ import {
getDaemonProvider,
restartDaemon
} from '../daemon/daemon-init'
import { getCurrentDaemonAdapter } from '../daemon/daemon-provider-routing'
import {
getDaemonFolderAccessMismatch,
refreshDaemonFolderAccessProbe,
type DaemonFolderAccessMismatchNotice
} from '../daemon/daemon-folder-access-mismatch'
import {
resetFolderAccessForDaemon,
type DaemonFolderAccessResetResult
} from '../daemon/daemon-folder-access-reset'
import type { MacDaemonTccAttributionHealth } from '../daemon/daemon-tcc-attribution'
import type { DaemonEndpointIdentity } from '../daemon/daemon-hello-protocol'
import type { DaemonSessionInfo } from '../daemon/types'
// Why: poll past the daemon's 5s SIGTERM→SIGKILL ladder (KILL_TIMEOUT_MS in session.ts), else slow-exiting shells falsely look "refused".
@@ -38,6 +49,13 @@ function isDaemonDegraded(): boolean {
)
}
// Why the current adapter only: evidence is keyed to the daemon now spawning terminals, so a
// legacy adapter's daemon must never satisfy the identity match that keeps the notice up.
function readCurrentDaemonIdentity(): DaemonEndpointIdentity | null {
const provider = getDaemonProvider()
return provider ? getCurrentDaemonAdapter(provider).getDaemonIdentity() : null
}
async function collectSessions(adapters: DaemonPtyAdapter[]): Promise<DaemonSessionInfo[]> {
const results = await Promise.allSettled(
adapters.map(async (adapter) => {
@@ -57,15 +75,38 @@ export function registerDaemonManagementHandlers(): void {
ipcMain.removeHandler('pty:management:killOne')
ipcMain.removeHandler('pty:management:restart')
ipcMain.removeHandler('pty:management:macTccAttribution')
ipcMain.removeHandler('pty:management:resetFolderAccess')
// Why: lets Settings warn that macOS privacy grants no longer reach daemon terminals (STA-3491).
// Why: lets Settings warn that macOS privacy grants no longer reach daemon terminals (STA-3491),
// and carries the folder-access evidence the notice needs (STA-7948) on the same focus-time poll.
ipcMain.handle(
'pty:management:macTccAttribution',
async (): Promise<{ health: MacDaemonTccAttributionHealth }> => {
async (): Promise<{
health: MacDaemonTccAttributionHealth
folderAccessMismatch: DaemonFolderAccessMismatchNotice | null
}> => {
// Why two guards: the two answers are independent evidence, and a failed health read must
// not present as "the folder evidence is gone".
const health = await getCurrentDaemonMacTccAttributionHealth().catch(
(): MacDaemonTccAttributionHealth => 'unknown'
)
const identity = readCurrentDaemonIdentity()
// Why re-probe on the poll: the fix dialog's first step completes in System Settings, and
// returning to Orca is the only moment anything can notice. The refresh owns when to skip.
await refreshDaemonFolderAccessProbe(identity).catch(() => {})
return { health, folderAccessMismatch: getDaemonFolderAccessMismatch(identity) }
}
)
// Why a separate channel from the poll: this one has a side effect — it clears Orca's TCC row and
// makes the app touch the folder so macOS re-prompts — and only a user click may trigger it.
ipcMain.handle(
'pty:management:resetFolderAccess',
async (): Promise<DaemonFolderAccessResetResult> => {
try {
return { health: await getCurrentDaemonMacTccAttributionHealth() }
return await resetFolderAccessForDaemon(readCurrentDaemonIdentity())
} catch {
return { health: 'unknown' }
return { outcome: 'unsupported' }
}
}
)
+109
View File
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProcessResult } from '../shared/child-process/run-process'
const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() }))
vi.mock('../shared/child-process/run-process', () => ({ runProcess: runProcessMock }))
import { readMacosBundleId, resetMacosTccPermission } from './macos-tcc-reset'
function processResult(overrides: Partial<ProcessResult>): ProcessResult {
return {
code: 0,
signal: null,
stdout: '',
stderr: '',
timedOut: false,
outputTruncated: false,
...overrides
}
}
beforeEach(() => {
runProcessMock.mockReset()
})
describe('readMacosBundleId', () => {
it('reads CFBundleIdentifier out of the bundles Info.plist', async () => {
runProcessMock.mockResolvedValue(processResult({ stdout: 'com.stablyai.orca\n' }))
await expect(readMacosBundleId('/Applications/Orca.app')).resolves.toBe('com.stablyai.orca')
expect(runProcessMock).toHaveBeenCalledWith(
expect.objectContaining({
program: '/usr/libexec/PlistBuddy',
args: ['-c', 'Print :CFBundleIdentifier', '/Applications/Orca.app/Contents/Info.plist']
})
)
})
it.each([
['a non-zero exit', processResult({ code: 1, stderr: 'Print: Entry, Does Not Exist' })],
['empty output', processResult({ stdout: ' \n' })]
])('returns null on %s', async (_label, result) => {
runProcessMock.mockResolvedValue(result)
await expect(readMacosBundleId('/Applications/Orca.app')).resolves.toBeNull()
})
it('returns null rather than throwing when PlistBuddy cannot be started', async () => {
runProcessMock.mockRejectedValue(new Error('ENOENT'))
await expect(readMacosBundleId('/Applications/Orca.app')).resolves.toBeNull()
})
})
describe('resetMacosTccPermission', () => {
it('clears the services row for the bundle id', async () => {
runProcessMock.mockResolvedValue(processResult({}))
await expect(
resetMacosTccPermission('SystemPolicyDocumentsFolder', 'com.stablyai.orca')
).resolves.toEqual({ ok: true })
expect(runProcessMock).toHaveBeenCalledWith(
expect.objectContaining({
program: '/usr/bin/tccutil',
args: ['reset', 'SystemPolicyDocumentsFolder', 'com.stablyai.orca']
})
)
})
// The observed shape on macOS 15: exit 64, everything on stderr, nothing on stdout.
it('reports the unknown-bundle-id failure tccutil writes to stderr', async () => {
runProcessMock.mockResolvedValue(
processResult({
code: 64,
stderr: 'tccutil: No such bundle identifier "com.example.absent"\n'
})
)
await expect(
resetMacosTccPermission('SystemPolicyDesktopFolder', 'com.example.absent')
).resolves.toEqual({
ok: false,
detail: 'tccutil: No such bundle identifier "com.example.absent"'
})
})
it.each([
['stdout when stderr is empty', processResult({ code: 1, stdout: 'refused\n' }), 'refused'],
['the exit code when both are empty', processResult({ code: 70 }), 'exit 70'],
[
'an unknown exit when the process was signalled',
processResult({ code: null }),
'exit unknown'
]
])('falls back to %s', async (_label, result, detail) => {
runProcessMock.mockResolvedValue(result)
await expect(
resetMacosTccPermission('SystemPolicyDownloadsFolder', 'com.stablyai.orca')
).resolves.toEqual({ ok: false, detail })
})
it('reports a failure to start as a failed reset', async () => {
runProcessMock.mockRejectedValue(new Error('EACCES'))
await expect(
resetMacosTccPermission('SystemPolicyDocumentsFolder', 'com.stablyai.orca')
).resolves.toEqual({ ok: false, detail: 'EACCES' })
})
})
+59
View File
@@ -0,0 +1,59 @@
// Clearing a macOS TCC row, plus the bundle id every row is keyed by. Shared because two remedies
// must issue the identical `tccutil reset`: the computer-use helper's stale-grant reset and the
// daemon folder-access fix (STA-7948), where clearing the row is what makes macOS ask again.
import { join } from 'node:path'
import { runProcess, type ProcessResult } from '../shared/child-process/run-process'
/** Bounded so a wedged helper cannot hold a caller: neither binary prompts, so neither lingers. */
const TCC_COMMAND_TIMEOUT_MS = 10_000
export type MacosTccResetResult = { ok: true } | { ok: false; detail: string }
/**
* The bundle's `CFBundleIdentifier`, or null when it cannot be read — Info.plist is usually a
* binary plist, so PlistBuddy is the only reader that works on both encodings.
*/
export async function readMacosBundleId(appBundlePath: string): Promise<string | null> {
try {
const result = await runProcess({
program: '/usr/libexec/PlistBuddy',
args: ['-c', 'Print :CFBundleIdentifier', join(appBundlePath, 'Contents', 'Info.plist')],
timeoutMs: TCC_COMMAND_TIMEOUT_MS
})
if (result.code !== 0) {
return null
}
return result.stdout.trim() || null
} catch {
return null
}
}
/**
* Clears the TCC row for one service and bundle id, so the next access re-prompts.
*
* Failure is data, not an exception: `tccutil` exits 64 and explains itself on stderr when
* LaunchServices does not know the bundle id, which is the ordinary outcome for an app running
* from an unregistered location.
*/
export async function resetMacosTccPermission(
service: string,
bundleId: string
): Promise<MacosTccResetResult> {
let result: ProcessResult
try {
result = await runProcess({
program: '/usr/bin/tccutil',
args: ['reset', service, bundleId],
timeoutMs: TCC_COMMAND_TIMEOUT_MS
})
} catch (error) {
return { ok: false, detail: error instanceof Error ? error.message : 'tccutil failed to start' }
}
if (result.code === 0) {
return { ok: true }
}
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code ?? 'unknown'}`
return { ok: false, detail }
}
+2
View File
@@ -186,6 +186,8 @@ export type {
} from './api/preflight-api'
export type {
PtyManagementApi,
PtyManagementDaemonCwdClass,
PtyManagementFolderAccessMismatch,
PtyManagementMacTccAttributionHealth,
PtyManagementSession
} from './api/pty-management-api'
@@ -143,6 +143,7 @@ export const ptyStreamAndSerializationApi = {
killAll: () => ipcRenderer.invoke('pty:management:killAll'),
killOne: (args: { sessionId: string }) => ipcRenderer.invoke('pty:management:killOne', args),
restart: () => ipcRenderer.invoke('pty:management:restart'),
macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution')
macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution'),
resetFolderAccess: () => ipcRenderer.invoke('pty:management:resetFolderAccess')
}
} satisfies Partial<PreloadApi['pty']>
+29 -1
View File
@@ -1,3 +1,5 @@
import type { DaemonPtyCwdClass } from '../../shared/daemon-adoption-telemetry'
// Mirror of daemon's `DaemonSessionInfo` (src/main/daemon/types.ts); not imported — preload can't depend on main-only protocol types.
export type PtyManagementSession = {
sessionId: string
@@ -16,6 +18,28 @@ export type PtyManagementSession = {
// Automation grants silently stop applying until the daemon is restarted (STA-3491).
export type PtyManagementMacTccAttributionHealth = 'intact' | 'severed' | 'unknown'
export type PtyManagementDaemonCwdClass = DaemonPtyCwdClass
// The daemon spawned a terminal into a folder it can't read while Orca can (STA-7948).
// `daemonScope` is an opaque per-daemon digest, never a path — it only latches the notice.
// `freshDaemonAccess` is what a daemon forked now would get: 'allowed' means restarting is the
// whole remedy, 'denied' means Orca must be re-allowed first, 'unknown' means main could not tell.
export type PtyManagementFreshDaemonAccess = 'allowed' | 'denied' | 'unknown'
export type PtyManagementFolderAccessMismatch = {
daemonScope: string
cwdClass: PtyManagementDaemonCwdClass
freshDaemonAccess: PtyManagementFreshDaemonAccess
}
// Mirrors DaemonFolderAccessResetResult in src/main/daemon/daemon-folder-access-reset.ts.
// 'unsupported': nothing to reset, or the platform/app bundle cannot support one.
// 'reset_failed': tccutil refused. 'probed': the reset ran and `mismatch` is the fresh verdict.
export type PtyManagementFolderAccessResetResult =
| { outcome: 'unsupported' }
| { outcome: 'reset_failed' }
| { outcome: 'probed'; mismatch: PtyManagementFolderAccessMismatch | null }
export type PtyManagementApi = {
// `degraded`: daemon is alive but can't spawn fresh PTYs, so new terminals run locally without daemon persistence.
listSessions: () => Promise<{ sessions: PtyManagementSession[]; degraded: boolean }>
@@ -26,5 +50,9 @@ export type PtyManagementApi = {
}>
killOne: (args: { sessionId: string }) => Promise<{ success: boolean }>
restart: () => Promise<{ success: boolean }>
macTccAttribution: () => Promise<{ health: PtyManagementMacTccAttributionHealth }>
macTccAttribution: () => Promise<{
health: PtyManagementMacTccAttributionHealth
folderAccessMismatch: PtyManagementFolderAccessMismatch | null
}>
resetFolderAccess: () => Promise<PtyManagementFolderAccessResetResult>
}
@@ -0,0 +1,557 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
const { trackTelemetry, restart, openSettings, resetFolderAccess, dismissToast } = vi.hoisted(
() => ({
trackTelemetry: vi.fn(),
restart: vi.fn(async () => ({ success: true })),
openSettings: vi.fn(async () => {}),
resetFolderAccess: vi.fn(),
dismissToast: vi.fn()
})
)
vi.mock('sonner', () => ({ toast: { dismiss: dismissToast } }))
vi.mock('@/lib/telemetry', () => ({ track: trackTelemetry }))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, options?: Record<string, string>) =>
fallback.replace(/\{\{(\w+)\}\}/g, (match, name: string) => options?.[name] ?? match)
}))
import { MacFolderAccessFixDialog } from './MacFolderAccessFixDialog'
import {
useMacFolderAccessFixStore,
type FolderAccessNoticePhase
} from '@/store/mac-folder-access-fix'
const SCOPE = 'aaaa111122223333'
function noticePhase(): FolderAccessNoticePhase | undefined {
return useMacFolderAccessFixStore.getState().noticePhaseByScope.get(SCOPE)
}
function verdict(
freshDaemonAccess: 'allowed' | 'denied' | 'unknown',
daemonScope: string = SCOPE
): void {
act(() => {
useMacFolderAccessFixStore
.getState()
.applyVerdict({ daemonScope, cwdClass: 'documents', freshDaemonAccess })
})
}
function openWith(
freshDaemonAccess: 'allowed' | 'denied' | 'unknown',
cwdClass: 'documents' | 'other-home' | 'outside-home' = 'documents'
): void {
useMacFolderAccessFixStore.setState({
mismatch: { daemonScope: SCOPE, cwdClass, freshDaemonAccess },
openScope: SCOPE,
noticePhaseByScope: new Map<string, FolderAccessNoticePhase>([[SCOPE, 'visible']])
})
}
function dialogShown(): boolean {
return screen.queryByRole('dialog') !== null
}
function restartButton(): HTMLElement {
return screen.getByRole('button', { name: /^Restart/ })
}
function resetButton(): HTMLElement {
return screen.getByRole('button', { name: /^Reset/ })
}
/** The verdict a forced re-probe returned after the reset ran. */
function probed(freshDaemonAccess: 'allowed' | 'denied' | 'unknown'): void {
resetFolderAccess.mockResolvedValue({
outcome: 'probed',
mismatch: { daemonScope: 'aaaa111122223333', cwdClass: 'documents', freshDaemonAccess }
})
}
function footerButton(name: string): HTMLElement {
const footer = screen.getByRole('dialog').querySelector('[data-slot="dialog-footer"]')
if (!(footer instanceof HTMLElement)) {
throw new Error('dialog footer did not render')
}
return within(footer).getByRole('button', { name })
}
beforeEach(() => {
trackTelemetry.mockReset()
restart.mockReset().mockResolvedValue({ success: true })
openSettings.mockReset().mockResolvedValue(undefined)
resetFolderAccess.mockReset()
dismissToast.mockReset()
probed('denied')
useMacFolderAccessFixStore.setState({
mismatch: null,
openScope: null,
noticePhaseByScope: new Map<string, FolderAccessNoticePhase>()
})
Object.defineProperty(window, 'api', {
configurable: true,
value: {
pty: { management: { restart, resetFolderAccess } },
developerPermissions: { openSettings }
}
})
})
afterEach(() => {
cleanup()
})
describe('MacFolderAccessFixDialog', () => {
it('renders nothing until the toast raises it', () => {
render(<MacFolderAccessFixDialog />)
expect(screen.queryByRole('dialog')).toBeNull()
})
it('names the denied folder and leads with the cause', () => {
openWith('allowed')
render(<MacFolderAccessFixDialog />)
expect(screen.getByText('Fix access to your Documents folder')).toBeTruthy()
expect(
screen.getByText('macOS is blocking Orcas terminal service from this folder.')
).toBeTruthy()
expect(screen.getByText('Open terminals and agents will restart.')).toBeTruthy()
})
// 'allowed' means a daemon forked now could already read the folder.
it('hides step one and enables Restart when the grant is already in place', () => {
openWith('allowed')
render(<MacFolderAccessFixDialog />)
expect(screen.queryByRole('button', { name: 'Open System Settings' })).toBeNull()
expect(restartButton().hasAttribute('disabled')).toBe(false)
})
it('offers only the reset when a fresh daemon is still denied, and says what it does', () => {
openWith('denied')
render(<MacFolderAccessFixDialog />)
expect(footerButton('Cancel')).toBeTruthy()
expect(footerButton('Reset permission')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Open System Settings' })).toBeNull()
expect(screen.queryByRole('button', { name: /^Restart/ })).toBeNull()
expect(screen.getByText('Re-allow Orca for your Documents folder')).toBeTruthy()
expect(screen.getByText(/Reset asks macOS for the permission again/)).toBeTruthy()
})
// Only Documents, Desktop and Downloads have a TCC row, so a reset elsewhere is a button that
// cannot work. A workspace symlinked out of Documents, or one on an external volume, lands here.
it.each([['other-home'], ['outside-home']] as const)(
'points a denied %s workspace at System Settings instead of a reset',
(cwdClass) => {
openWith('denied', cwdClass)
render(<MacFolderAccessFixDialog />)
expect(screen.queryByRole('button', { name: /^Reset/ })).toBeNull()
expect(screen.queryByRole('button', { name: /^Restart/ })).toBeNull()
expect(footerButton('Cancel')).toBeTruthy()
expect(footerButton('Open System Settings')).toBeTruthy()
}
)
// Nothing here has been verified for a class with no row, so step one promises nothing.
it('drops the reset explanation when there is no permission to reset', () => {
openWith('denied', 'other-home')
render(<MacFolderAccessFixDialog />)
expect(screen.getByText('Allow Orca under Files and Folders')).toBeTruthy()
expect(screen.queryByText(/Reset asks macOS for the permission again/)).toBeNull()
})
// An unanswered probe must not accuse the user of a missing grant, but the pane stays reachable.
it('keeps both actions and says so when the probe could not answer', () => {
openWith('unknown')
render(<MacFolderAccessFixDialog />)
expect(footerButton('Open System Settings')).toBeTruthy()
expect(restartButton().hasAttribute('disabled')).toBe(false)
expect(screen.getByText('Couldnt verify. Skip if already allowed.')).toBeTruthy()
})
it('flips step one to done when a later poll reports the grant landed', async () => {
openWith('denied')
render(<MacFolderAccessFixDialog />)
expect(screen.queryByRole('button', { name: /^Restart/ })).toBeNull()
act(() => {
useMacFolderAccessFixStore.getState().applyVerdict({
daemonScope: 'aaaa111122223333',
cwdClass: 'documents',
freshDaemonAccess: 'allowed'
})
})
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Open System Settings' })).toBeNull()
})
expect(restartButton().hasAttribute('disabled')).toBe(false)
})
it('opens the Files and Folders pane through the permission opener', async () => {
openWith('unknown')
render(<MacFolderAccessFixDialog />)
await userEvent.click(screen.getByRole('button', { name: 'Open System Settings' }))
expect(openSettings).toHaveBeenCalledWith({ id: 'files-and-folders' })
expect(trackTelemetry).toHaveBeenCalledWith('daemon_folder_access_notice', {
action: 'settings_opened',
cwd_class: 'documents'
})
})
it('restarts the terminal service without a second confirmation', async () => {
openWith('allowed')
render(<MacFolderAccessFixDialog />)
await userEvent.click(restartButton())
expect(restart).toHaveBeenCalledTimes(1)
expect(trackTelemetry).toHaveBeenCalledWith('daemon_folder_access_notice', {
action: 'restart_clicked',
cwd_class: 'documents'
})
})
it('shows a busy state while the restart runs', async () => {
let release: (value: { success: boolean }) => void = () => {}
restart.mockReturnValue(
new Promise<{ success: boolean }>((resolve) => {
release = resolve
})
)
openWith('allowed')
render(<MacFolderAccessFixDialog />)
await userEvent.click(restartButton())
expect(screen.getByRole('button', { name: /Restarting/ }).hasAttribute('disabled')).toBe(true)
await act(async () => {
release({ success: true })
})
})
it('checks off both steps, offers Done, and hands the toast to the notice hook', async () => {
openWith('allowed')
render(<MacFolderAccessFixDialog />)
await userEvent.click(restartButton())
await waitFor(() => {
expect(footerButton('Done')).toBeTruthy()
})
expect(screen.queryByRole('button', { name: /^Restart/ })).toBeNull()
expect(screen.getByRole('dialog').querySelectorAll('.text-status-success')).toHaveLength(2)
// A ticked step must not still warn about what it was going to cost.
expect(screen.queryByText('Open terminals and agents will restart.')).toBeNull()
// The daemon that earned the notice is gone, so its toast goes without counting a dismissal.
expect(noticePhase()).toBe('retired')
})
it('reports a refused restart inline and leaves the button usable', async () => {
restart.mockResolvedValue({ success: false })
openWith('allowed')
render(<MacFolderAccessFixDialog />)
await userEvent.click(restartButton())
await waitFor(() => {
expect(
screen.getByText('Restart failed. Try again from Settings → Terminal → Manage Sessions.')
).toBeTruthy()
})
expect(restartButton().hasAttribute('disabled')).toBe(false)
expect(noticePhase()).toBe('visible')
})
it('reports a rejected restart the same way', async () => {
restart.mockRejectedValue(new Error('ipc gone'))
openWith('allowed')
render(<MacFolderAccessFixDialog />)
await userEvent.click(restartButton())
await waitFor(() => {
expect(
screen.getByText('Restart failed. Try again from Settings → Terminal → Manage Sessions.')
).toBeTruthy()
})
expect(restartButton().hasAttribute('disabled')).toBe(false)
})
it('reports the reset click and flips to Restart once the re-probe allows it', async () => {
probed('allowed')
openWith('denied')
render(<MacFolderAccessFixDialog />)
await userEvent.click(resetButton())
await waitFor(() => {
expect(restartButton()).toBeTruthy()
})
expect(resetFolderAccess).toHaveBeenCalledTimes(1)
expect(trackTelemetry).toHaveBeenCalledWith('daemon_folder_access_notice', {
action: 'reset_clicked',
cwd_class: 'documents'
})
expect(screen.queryByText('Still blocked after the reset.')).toBeNull()
})
it('says so when the re-probe still reports a denial', async () => {
probed('denied')
openWith('denied')
render(<MacFolderAccessFixDialog />)
await userEvent.click(resetButton())
await waitFor(() => {
expect(screen.getByText('Still blocked after the reset.')).toBeTruthy()
})
expect(footerButton('Reset permission').hasAttribute('disabled')).toBe(false)
expect(footerButton('Open System Settings')).toBeTruthy()
})
// Evidence gone mid-reset means the daemon was replaced; nothing is left to fix here.
it('closes when the reset finds the evidence gone', async () => {
resetFolderAccess.mockResolvedValue({ outcome: 'probed', mismatch: null })
openWith('denied')
render(<MacFolderAccessFixDialog />)
await userEvent.click(resetButton())
await waitFor(() => {
expect(dialogShown()).toBe(false)
})
expect(useMacFolderAccessFixStore.getState().mismatch).toBeNull()
// The toast outlives the dialog unless something retires it, and only the store can.
expect(noticePhase()).toBe('retired')
expect(dismissToast).toHaveBeenCalledWith('mac-daemon-folder-access-mismatch')
})
// The footer flips to the restart branch the moment the grant lands, which can happen while the
// reset is still running. A button must report its own work, never the dialog's.
it('never labels the restart button with the reset that is running', async () => {
let release: (value: { outcome: string }) => void = () => {}
resetFolderAccess.mockReturnValue(
new Promise<{ outcome: string }>((resolve) => {
release = resolve
})
)
openWith('denied')
render(<MacFolderAccessFixDialog />)
await userEvent.click(resetButton())
verdict('allowed')
expect(restartButton().textContent).toBe('Restart')
expect(restartButton().hasAttribute('disabled')).toBe(true)
await act(async () => {
release({ outcome: 'unsupported' })
})
})
// An unanswered probe is not evidence the reset failed, so the dialog says what it knows.
it('does not claim a block the re-probe never confirmed', async () => {
probed('unknown')
openWith('denied')
render(<MacFolderAccessFixDialog />)
await userEvent.click(resetButton())
await waitFor(() => {
expect(screen.getByText('Couldnt verify. Skip if already allowed.')).toBeTruthy()
})
expect(screen.queryByText('Still blocked after the reset.')).toBeNull()
})
// Reset failed, then the user granted it in System Settings: the failure is no longer true.
it('drops the reset failure once the grant lands', async () => {
const failure = 'Couldnt reset the permission. Use System Settings instead.'
resetFolderAccess.mockResolvedValue({ outcome: 'reset_failed' })
openWith('denied')
render(<MacFolderAccessFixDialog />)
await userEvent.click(resetButton())
await waitFor(() => {
expect(screen.getByText(failure)).toBeTruthy()
})
verdict('allowed')
expect(screen.queryByText(failure)).toBeNull()
expect(screen.getByRole('dialog').querySelectorAll('.text-status-success')).toHaveLength(1)
})
// Closing is the end of the remedy, so the scope returning later must not pop the dialog again.
it('does not reopen itself when the original scope comes back', () => {
openWith('denied')
render(<MacFolderAccessFixDialog />)
verdict('denied', 'bbbb444455556666')
verdict('denied')
expect(useMacFolderAccessFixStore.getState().openScope).toBeNull()
expect(dialogShown()).toBe(false)
})
// The remedy belongs to one folder on one daemon, so evidence that moves is a different remedy.
it('closes itself when the evidence moves to another scope', async () => {
openWith('denied')
render(<MacFolderAccessFixDialog />)
act(() => {
useMacFolderAccessFixStore.getState().applyVerdict({
daemonScope: 'bbbb444455556666',
cwdClass: 'desktop',
freshDaemonAccess: 'denied'
})
})
expect(dialogShown()).toBe(false)
})
it('drops the unverified helper once the restart is done', async () => {
openWith('unknown')
render(<MacFolderAccessFixDialog />)
expect(screen.getByText('Couldnt verify. Skip if already allowed.')).toBeTruthy()
await userEvent.click(restartButton())
await waitFor(() => {
expect(footerButton('Done')).toBeTruthy()
})
expect(screen.queryByText('Couldnt verify. Skip if already allowed.')).toBeNull()
})
it.each([['reset_failed'], ['unsupported']])(
'points at System Settings when the reset comes back %s',
async (outcome) => {
resetFolderAccess.mockResolvedValue({ outcome })
openWith('denied')
render(<MacFolderAccessFixDialog />)
await userEvent.click(resetButton())
await waitFor(() => {
expect(
screen.getByText('Couldnt reset the permission. Use System Settings instead.')
).toBeTruthy()
})
expect(screen.queryByText('Still blocked after the reset.')).toBeNull()
expect(footerButton('Open System Settings')).toBeTruthy()
}
)
it('reports a rejected reset the same way', async () => {
resetFolderAccess.mockRejectedValue(new Error('ipc gone'))
openWith('denied')
render(<MacFolderAccessFixDialog />)
await userEvent.click(resetButton())
await waitFor(() => {
expect(
screen.getByText('Couldnt reset the permission. Use System Settings instead.')
).toBeTruthy()
})
})
it('blocks every way out while the reset runs', async () => {
let release: (value: { outcome: string }) => void = () => {}
resetFolderAccess.mockReturnValue(
new Promise<{ outcome: string }>((resolve) => {
release = resolve
})
)
openWith('denied')
render(<MacFolderAccessFixDialog />)
await userEvent.click(resetButton())
expect(screen.getByRole('button', { name: /Resetting/ }).hasAttribute('disabled')).toBe(true)
expect(footerButton('Cancel').hasAttribute('disabled')).toBe(true)
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
await userEvent.keyboard('{Escape}')
expect(dialogShown()).toBe(true)
await act(async () => {
release({ outcome: 'unsupported' })
})
})
// With no evidence there is nothing open, so a scope that comes back opens a fresh remedy.
it('forgets what was open once the evidence is gone', () => {
openWith('denied')
render(<MacFolderAccessFixDialog />)
act(() => {
useMacFolderAccessFixStore.getState().applyVerdict(null)
})
expect(dialogShown()).toBe(false)
expect(useMacFolderAccessFixStore.getState().openScope).toBeNull()
})
it('starts a replacement daemons remedy from scratch', async () => {
openWith('allowed')
render(<MacFolderAccessFixDialog />)
await userEvent.click(restartButton())
await waitFor(() => {
expect(footerButton('Done')).toBeTruthy()
})
await userEvent.click(footerButton('Done'))
act(() => {
useMacFolderAccessFixStore.getState().applyVerdict({
daemonScope: 'bbbb444455556666',
cwdClass: 'documents',
freshDaemonAccess: 'denied'
})
useMacFolderAccessFixStore.getState().openFix()
})
expect(screen.getByRole('dialog').querySelectorAll('.text-status-success')).toHaveLength(0)
expect(footerButton('Reset permission')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Done' })).toBeNull()
})
// Closing unmounts the remedy, so no phase of it can be waiting when the same scope reopens.
it('reopens the same scope with an unticked checklist', async () => {
openWith('allowed')
render(<MacFolderAccessFixDialog />)
await userEvent.click(restartButton())
await waitFor(() => {
expect(footerButton('Done')).toBeTruthy()
})
await userEvent.click(footerButton('Done'))
act(() => {
useMacFolderAccessFixStore.getState().openFix()
})
// One tick, from the verdict's own step; two would mean the finished restart outlived its close.
expect(screen.getByRole('dialog').querySelectorAll('.text-status-success')).toHaveLength(1)
expect(restartButton()).toBeTruthy()
})
it('closes on Cancel', async () => {
openWith('allowed')
render(<MacFolderAccessFixDialog />)
await userEvent.click(footerButton('Cancel'))
expect(dialogShown()).toBe(false)
})
})
@@ -0,0 +1,384 @@
import React, { useCallback, useState } from 'react'
import { CircleCheck, CircleDashed, LoaderCircle } from 'lucide-react'
import type { PtyManagementFolderAccessMismatch } from '../../../../preload/api-types'
import { isMacTccFolderClass } from '../../../../shared/daemon-adoption-telemetry'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { track } from '@/lib/telemetry'
import { useMacFolderAccessFixStore } from '@/store/mac-folder-access-fix'
import { Button } from '../ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
import { macFolderAccessFolderName } from './mac-folder-access-folder-name'
const FILES_AND_FOLDERS_PANE = { id: 'files-and-folders' } as const
type RestartState = 'idle' | 'busy' | 'done' | 'failed'
/** 'probed': the reset ran and a fresh probe answered; the verdict itself is on the mismatch. */
type ResetState = 'idle' | 'busy' | 'probed' | 'failed'
function Step({
done,
label,
helper
}: {
done: boolean
label: string
helper?: string
}): React.JSX.Element {
return (
<li className="flex items-start gap-2">
{done ? (
<CircleCheck className="mt-0.5 size-4 shrink-0 text-status-success" aria-hidden="true" />
) : (
<CircleDashed className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
)}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm text-foreground">{label}</span>
{helper ? <span className="text-xs text-muted-foreground">{helper}</span> : null}
</div>
</li>
)
}
/**
* Whether the denial is one `tccutil reset` can act on: only Documents, Desktop and Downloads have
* a per-app TCC row, so offering the button for any other folder promises a remedy that cannot run.
*/
function canResetPermission(mismatch: PtyManagementFolderAccessMismatch): boolean {
return mismatch.freshDaemonAccess === 'denied' && isMacTccFolderClass(mismatch.cwdClass)
}
function allowStepHelper(mismatch: PtyManagementFolderAccessMismatch): string | undefined {
// A probe that could not answer must not accuse the user of a missing grant.
if (mismatch.freshDaemonAccess === 'unknown') {
return translate(
'auto.components.shared.MacFolderAccessFixDialog.stepAllowUnknown',
'Couldnt verify. Skip if already allowed.'
)
}
// The toggle is already on for everyone who sees this, so the step has to say what the reset
// does instead of pointing at a switch (STA-7948). Without a reset there is nothing to promise.
if (canResetPermission(mismatch)) {
return translate(
'auto.components.shared.MacFolderAccessFixDialog.stepAllowDenied',
'Orca is already allowed, but macOS isnt applying it to the terminal service. Reset asks macOS for the permission again. Click Allow when it prompts.'
)
}
return undefined
}
function FixSteps({
mismatch,
restartState,
resetState,
folder
}: {
mismatch: PtyManagementFolderAccessMismatch
restartState: RestartState
resetState: ResetState
folder: string
}): React.JSX.Element {
return (
<>
<ol className="flex flex-col gap-3">
<Step
done={mismatch.freshDaemonAccess === 'allowed' || restartState === 'done'}
label={
canResetPermission(mismatch)
? translate(
'auto.components.shared.MacFolderAccessFixDialog.stepReallow',
'Re-allow Orca for your {{folder}}',
{ folder }
)
: translate(
'auto.components.shared.MacFolderAccessFixDialog.stepAllow',
'Allow Orca under Files and Folders'
)
}
helper={restartState === 'done' ? undefined : allowStepHelper(mismatch)}
/>
<Step
done={restartState === 'done'}
label={translate(
'auto.components.shared.MacFolderAccessFixDialog.stepRestart',
'Restart Orcas terminal service'
)}
helper={
restartState === 'done'
? undefined
: translate(
'auto.components.shared.MacFolderAccessFixDialog.restartConsequence',
'Open terminals and agents will restart.'
)
}
/>
</ol>
{restartState === 'failed' ? (
<p className="text-sm text-destructive">
{translate(
'auto.components.shared.MacFolderAccessFixDialog.restartFailed',
'Restart failed. Try again from Settings → Terminal → Manage Sessions.'
)}
</p>
) : null}
{resetState === 'failed' && mismatch.freshDaemonAccess !== 'allowed' ? (
<p className="text-sm text-destructive">
{translate(
'auto.components.shared.MacFolderAccessFixDialog.resetFailed',
'Couldnt reset the permission. Use System Settings instead.'
)}
</p>
) : null}
{resetState === 'probed' && mismatch.freshDaemonAccess === 'denied' ? (
<p className="text-sm text-muted-foreground">
{translate(
'auto.components.shared.MacFolderAccessFixDialog.resetStillBlocked',
'Still blocked after the reset.'
)}
</p>
) : null}
</>
)
}
/** The footer carries the active step's one action, so the steps stay a checklist. */
function FixFooter({
mismatch,
restartState,
resetState,
onCancel,
onOpenSettings,
onReset,
onRestart
}: {
mismatch: PtyManagementFolderAccessMismatch
restartState: RestartState
resetState: ResetState
onCancel: () => void
onOpenSettings: () => void
onReset: () => void
onRestart: () => void
}): React.JSX.Element {
const busy = restartState === 'busy' || resetState === 'busy'
const openSettingsLabel = translate(
'auto.components.shared.MacFolderAccessFixDialog.openSystemSettings',
'Open System Settings'
)
if (restartState === 'done') {
return (
<Button size="sm" onClick={onCancel}>
{translate('auto.components.shared.MacFolderAccessFixDialog.done', 'Done')}
</Button>
)
}
// Restarting cannot help while a fresh daemon is denied, so the reset takes the primary slot.
// Two routes for one step would read as a choice the user cannot make.
if (canResetPermission(mismatch)) {
// System Settings is the fallback: it appears only once the reset has settled without helping.
const resetSettled = resetState === 'probed' || resetState === 'failed'
return (
<>
{resetSettled ? (
<Button variant="ghost" size="sm" onClick={onOpenSettings} disabled={busy}>
{openSettingsLabel}
</Button>
) : (
<Button variant="ghost" size="sm" onClick={onCancel} disabled={busy}>
{translate('auto.components.shared.MacFolderAccessFixDialog.cancel', 'Cancel')}
</Button>
)}
<Button size="sm" onClick={onReset} disabled={busy}>
{resetState === 'busy' ? <LoaderCircle className="size-4 animate-spin" /> : null}
{resetState === 'busy'
? translate('auto.components.shared.MacFolderAccessFixDialog.resetting', 'Resetting…')
: translate(
'auto.components.shared.MacFolderAccessFixDialog.reset',
'Reset permission'
)}
</Button>
</>
)
}
// A denial with no TCC row to reset leaves System Settings as the only route, so it takes the
// primary slot: a restart cannot help while a fresh daemon is denied.
if (mismatch.freshDaemonAccess === 'denied') {
return (
<>
<Button variant="ghost" size="sm" onClick={onCancel} disabled={busy}>
{translate('auto.components.shared.MacFolderAccessFixDialog.cancel', 'Cancel')}
</Button>
<Button size="sm" onClick={onOpenSettings} disabled={busy}>
{openSettingsLabel}
</Button>
</>
)
}
return (
<>
{mismatch.freshDaemonAccess === 'unknown' ? (
<Button variant="ghost" size="sm" onClick={onOpenSettings} disabled={busy}>
{openSettingsLabel}
</Button>
) : (
<Button variant="ghost" size="sm" onClick={onCancel} disabled={busy}>
{translate('auto.components.shared.MacFolderAccessFixDialog.cancel', 'Cancel')}
</Button>
)}
<Button size="sm" onClick={onRestart} disabled={busy}>
{restartState === 'busy' ? <LoaderCircle className="size-4 animate-spin" /> : null}
{restartState === 'busy'
? translate('auto.components.shared.MacFolderAccessFixDialog.restarting', 'Restarting…')
: translate('auto.components.shared.MacFolderAccessFixDialog.restart', 'Restart')}
</Button>
</>
)
}
/**
* The remedy for a daemon macOS refuses a folder to (STA-7948), raised from the folder-access
* toast. Two steps, because a restart alone only works once Orca itself is allowed again — which
* step 1 does, and the focus-time poll behind `freshDaemonAccess` is what notices it landed.
*/
function FolderAccessFix({
mismatch
}: {
mismatch: PtyManagementFolderAccessMismatch
}): React.JSX.Element {
const close = useMacFolderAccessFixStore((s) => s.close)
const applyVerdict = useMacFolderAccessFixStore((s) => s.applyVerdict)
const retireNotice = useMacFolderAccessFixStore((s) => s.retireNotice)
const [restartState, setRestartState] = useState<RestartState>('idle')
const [resetState, setResetState] = useState<ResetState>('idle')
const mountedRef = useMountedRef()
const { cwdClass, daemonScope } = mismatch
const onOpenSettings = useCallback((): void => {
track('daemon_folder_access_notice', { action: 'settings_opened', cwd_class: cwdClass })
void window.api?.developerPermissions?.openSettings(FILES_AND_FOLDERS_PANE)
}, [cwdClass])
const onReset = useCallback(async (): Promise<void> => {
track('daemon_folder_access_notice', { action: 'reset_clicked', cwd_class: cwdClass })
setResetState('busy')
try {
const result = await window.api.pty.management.resetFolderAccess()
if (!mountedRef.current) {
return
}
if (result.outcome !== 'probed') {
setResetState('failed')
return
}
setResetState('probed')
// A null verdict means the evidence is gone (daemon replaced mid-reset), which unmounts this
// dialog: there is nothing left for it to fix.
applyVerdict(result.mismatch)
} catch {
if (mountedRef.current) {
setResetState('failed')
}
}
}, [applyVerdict, cwdClass, mountedRef])
const onRestart = useCallback(async (): Promise<void> => {
track('daemon_folder_access_notice', { action: 'restart_clicked', cwd_class: cwdClass })
setRestartState('busy')
try {
const { success } = await window.api.pty.management.restart()
if (!mountedRef.current) {
return
}
setRestartState(success ? 'done' : 'failed')
if (success) {
// Why here: the replaced daemon's identity is gone, so the poll that raised the toast will
// never mention it again, and a takedown the user did not ask for is not a dismissal.
retireNotice(daemonScope)
}
} catch {
if (mountedRef.current) {
setRestartState('failed')
}
}
}, [cwdClass, daemonScope, mountedRef, retireNotice])
const folder = macFolderAccessFolderName(cwdClass)
const busy = restartState === 'busy' || resetState === 'busy'
return (
<Dialog
open
onOpenChange={(next) => {
if (!next && !busy) {
close()
}
}}
>
<DialogContent
className="max-w-md"
showCloseButton={!busy}
onPointerDownOutside={(event) => {
if (busy) {
event.preventDefault()
}
}}
onEscapeKeyDown={(event) => {
if (busy) {
event.preventDefault()
}
}}
>
<DialogHeader>
<DialogTitle>
{translate(
'auto.components.shared.MacFolderAccessFixDialog.title',
'Fix access to your {{folder}}',
{ folder }
)}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.shared.MacFolderAccessFixDialog.lead',
'macOS is blocking Orcas terminal service from this folder.'
)}
</DialogDescription>
</DialogHeader>
<FixSteps
mismatch={mismatch}
restartState={restartState}
resetState={resetState}
folder={folder}
/>
<DialogFooter>
<FixFooter
mismatch={mismatch}
restartState={restartState}
resetState={resetState}
onCancel={close}
onOpenSettings={onOpenSettings}
onReset={() => void onReset()}
onRestart={() => void onRestart()}
/>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/**
* Shown only while the scope the user opened is still the one the evidence is about, so no remedy
* phase can outlive its evidence and nothing here has to be closed by hand.
*/
export function MacFolderAccessFixDialog(): React.JSX.Element | null {
const mismatch = useMacFolderAccessFixStore((s) => s.mismatch)
const openScope = useMacFolderAccessFixStore((s) => s.openScope)
if (!mismatch || !openScope) {
return null
}
return <FolderAccessFix mismatch={mismatch} />
}
@@ -0,0 +1,29 @@
import type { PtyManagementDaemonCwdClass } from '../../../../preload/api-types'
import { translate } from '@/i18n/i18n'
/**
* The folder phrase the toast and the fix dialog both drop into "your {{folder}}", so the two read
* as one notice. Only the three protected classes have a name macOS itself uses.
*/
export function macFolderAccessFolderName(cwdClass: PtyManagementDaemonCwdClass): string {
switch (cwdClass) {
case 'documents':
return translate(
'auto.components.shared.macFolderAccessFolderName.documents',
'Documents folder'
)
case 'desktop':
return translate('auto.components.shared.macFolderAccessFolderName.desktop', 'Desktop folder')
case 'downloads':
return translate(
'auto.components.shared.macFolderAccessFolderName.downloads',
'Downloads folder'
)
case 'other-home':
case 'outside-home':
return translate(
'auto.components.shared.macFolderAccessFolderName.workspace',
'workspace folder'
)
}
}
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, renderHook, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { runCleanupMock, snapshotMock, toastErrorMock, toastInfoMock, toastSuccessMock } =
vi.hoisted(() => ({
@@ -30,7 +30,7 @@ vi.mock('@/i18n/i18n', () => ({
}))
import type { KillAllTerminalSurfacesSummary } from './kill-all-terminal-surfaces'
import { useDaemonActions } from './useDaemonActions'
import { DaemonActionDialog, useDaemonActions, type DaemonActionsApi } from './useDaemonActions'
function rejectedSummary(): KillAllTerminalSurfacesSummary {
return {
@@ -150,3 +150,42 @@ describe('useDaemonActions kill-all cleanup', () => {
expect(toastInfoMock).not.toHaveBeenCalled()
})
})
describe('DaemonActionDialog restart copy', () => {
function pendingRestartApi(): DaemonActionsApi {
return {
pending: 'restart',
setPending: vi.fn(),
busyKind: null,
isBusy: false,
runRestart: vi.fn(async () => {}),
runKillAll: vi.fn(async () => {}),
runConfirmed: vi.fn()
}
}
afterEach(() => {
cleanup()
})
// The old copy promised panes showing "Process exited" that the user reopens by hand; agents
// resume themselves now, so it described a product that no longer exists.
it('names the terminal service and states only what actually happens', () => {
render(<DaemonActionDialog api={pendingRestartApi()} />)
expect(screen.getByText('Restart the terminal service?')).toBeTruthy()
expect(
screen.getByText(
'Open terminals and agents will restart. Terminals on remote hosts are not affected.'
)
).toBeTruthy()
expect(screen.getByRole('button', { name: 'Restart' })).toBeTruthy()
})
it('no longer promises reopenable "Process exited" panes', () => {
render(<DaemonActionDialog api={pendingRestartApi()} />)
expect(screen.queryByText(/Process exited/)).toBeNull()
expect(screen.queryByText(/Legacy-protocol sessions/)).toBeNull()
})
})
@@ -235,17 +235,17 @@ function getCopy(kind: DaemonActionKind): DaemonActionCopy {
return {
title: translate(
'auto.components.shared.useDaemonActions.922548bc66',
'Restart the terminal daemon?'
'Restart the terminal service?'
),
description: (
<>
{translate(
'auto.components.shared.useDaemonActions.01d6b7c64e',
'Kills every running terminal pane and restarts the daemon process. Panes show "Process exited" and can be reopened immediately. Legacy-protocol sessions from a previous app version are preserved. This can\'t be undone.'
'Open terminals and agents will restart. Terminals on remote hosts are not affected.'
)}
</>
),
confirmLabel: 'Restart daemon',
confirmLabel: 'Restart',
busyLabel: 'Restarting…'
}
}
@@ -1,9 +1,12 @@
import React from 'react'
import { MacFolderAccessFixDialog } from '@/components/shared/MacFolderAccessFixDialog'
import { useMacosTccPromptNotice } from './useMacosTccPromptNotice'
import { useMacTccAttributionSeveredNotice } from './useMacTccAttributionSeveredNotice'
export function MacosTccPromptNoticeHost(): null {
export function MacosTccPromptNoticeHost(): React.JSX.Element {
useMacosTccPromptNotice()
// Why: severed daemon attribution only showed in Settings (#13594); toast the remedy at launch/focus.
useMacTccAttributionSeveredNotice()
return null
// Why here: the folder-access toast raises this dialog, and both must outlive any one screen.
return <MacFolderAccessFixDialog />
}
@@ -4,19 +4,46 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { toast } from 'sonner'
import { MacosTccPromptNoticeHost } from './MacosTccPromptNoticeHost'
import {
useMacFolderAccessFixStore,
type FolderAccessNoticePhase
} from '@/store/mac-folder-access-fix'
type FolderAccessMismatch = {
daemonScope: string
cwdClass: string
freshDaemonAccess: string
} | null
type AttributionResult = {
health: 'intact' | 'severed' | 'unknown'
folderAccessMismatch: FolderAccessMismatch
}
const macTccAttribution = vi.hoisted(() =>
vi.fn(async (): Promise<{ health: 'intact' | 'severed' | 'unknown' }> => ({ health: 'intact' }))
vi.fn(async (): Promise<AttributionResult> => ({ health: 'intact', folderAccessMismatch: null }))
)
const trackTelemetry = vi.hoisted(() => vi.fn())
const openSettingsPage = vi.hoisted(() => vi.fn())
const openSettingsTarget = vi.hoisted(() => vi.fn())
const setSettingsSearchQuery = vi.hoisted(() => vi.fn())
const platform = vi.hoisted(() => ({ value: 'darwin' as NodeJS.Platform }))
// Sonner routes a programmatic dismissal through the toast's own onDismiss, which is the only
// reason the hook guards that callback at all.
const onDismissById = vi.hoisted(() => new Map<string, () => void>())
vi.mock('sonner', () => ({
toast: {
warning: vi.fn(),
dismiss: vi.fn()
warning: vi.fn((_title: string, options?: { id?: string; onDismiss?: () => void }) => {
if (options?.id !== undefined && options.onDismiss) {
onDismissById.set(options.id, options.onDismiss)
}
}),
dismiss: vi.fn((id?: string) => {
if (id !== undefined) {
onDismissById.get(id)?.()
}
})
}
}))
@@ -45,9 +72,12 @@ vi.mock('@/store/plugin-language-packs', () => ({
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
translate: (_key: string, fallback: string, options?: Record<string, string>) =>
fallback.replace(/\{\{(\w+)\}\}/g, (match, name: string) => options?.[name] ?? match)
}))
vi.mock('@/lib/telemetry', () => ({ track: trackTelemetry }))
vi.mock('./useMacosTccPromptNotice', () => ({
useMacosTccPromptNotice: vi.fn()
}))
@@ -55,13 +85,15 @@ vi.mock('./useMacosTccPromptNotice', () => ({
describe('useMacTccAttributionSeveredNotice', () => {
beforeEach(() => {
macTccAttribution.mockReset()
macTccAttribution.mockResolvedValue({ health: 'intact' })
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: null })
trackTelemetry.mockReset()
openSettingsPage.mockReset()
openSettingsTarget.mockReset()
setSettingsSearchQuery.mockReset()
platform.value = 'darwin'
vi.mocked(toast.warning).mockReset()
vi.mocked(toast.dismiss).mockReset()
vi.mocked(toast.warning).mockClear()
vi.mocked(toast.dismiss).mockClear()
onDismissById.clear()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
@@ -101,7 +133,7 @@ describe('useMacTccAttributionSeveredNotice', () => {
})
it('toasts Manage Sessions remedy once when attribution is severed', async () => {
macTccAttribution.mockResolvedValue({ health: 'severed' })
macTccAttribution.mockResolvedValue({ health: 'severed', folderAccessMismatch: null })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(toast.warning).toHaveBeenCalledTimes(1)
@@ -124,7 +156,7 @@ describe('useMacTccAttributionSeveredNotice', () => {
})
it('does not toast again after the first severed notice this session', async () => {
macTccAttribution.mockResolvedValue({ health: 'severed' })
macTccAttribution.mockResolvedValue({ health: 'severed', folderAccessMismatch: null })
const { rerender } = render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(toast.warning).toHaveBeenCalledTimes(1)
@@ -140,12 +172,12 @@ describe('useMacTccAttributionSeveredNotice', () => {
})
it('dismisses the warning after attribution recovers', async () => {
macTccAttribution.mockResolvedValueOnce({ health: 'severed' })
macTccAttribution.mockResolvedValueOnce({ health: 'severed', folderAccessMismatch: null })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(toast.warning).toHaveBeenCalledTimes(1)
})
macTccAttribution.mockResolvedValue({ health: 'intact' })
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: null })
act(() => {
window.dispatchEvent(new Event('focus'))
@@ -158,8 +190,8 @@ describe('useMacTccAttributionSeveredNotice', () => {
})
it('coalesces overlapping mount/focus checks into one IPC call and one toast', async () => {
let resolveHealth!: (value: { health: 'severed' }) => void
const pending = new Promise<{ health: 'severed' }>((resolve) => {
let resolveHealth!: (value: AttributionResult) => void
const pending = new Promise<AttributionResult>((resolve) => {
resolveHealth = resolve
})
macTccAttribution.mockImplementation(() => pending)
@@ -175,7 +207,7 @@ describe('useMacTccAttributionSeveredNotice', () => {
expect(toast.warning).not.toHaveBeenCalled()
await act(async () => {
resolveHealth({ health: 'severed' })
resolveHealth({ health: 'severed', folderAccessMismatch: null })
await pending
})
await waitFor(() => {
@@ -187,7 +219,7 @@ describe('useMacTccAttributionSeveredNotice', () => {
it('clears the in-flight guard on rejection so a later focus can retry', async () => {
macTccAttribution
.mockRejectedValueOnce(new Error('probe failed'))
.mockResolvedValueOnce({ health: 'severed' })
.mockResolvedValueOnce({ health: 'severed', folderAccessMismatch: null })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
@@ -204,3 +236,430 @@ describe('useMacTccAttributionSeveredNotice', () => {
})
})
})
describe('useMacTccAttributionSeveredNotice folder-access notice', () => {
const SCOPE_A = {
daemonScope: 'aaaa111122223333',
cwdClass: 'documents',
freshDaemonAccess: 'allowed'
}
const SCOPE_B = {
daemonScope: 'bbbb444455556666',
cwdClass: 'desktop',
freshDaemonAccess: 'denied'
}
type ToastOptions = {
id?: string
description?: string
duration?: number
action?: { label?: string; onClick?: (event: { preventDefault: () => void }) => void }
cancel?: { label?: string; onClick?: () => void }
onDismiss?: () => void
}
function dismissedEvents(): Record<string, unknown>[] {
return trackTelemetry.mock.calls
.filter(
([name, props]) => name === 'daemon_folder_access_notice' && props.action === 'dismissed'
)
.map(([, props]) => props)
}
function noticePhase(daemonScope: string): FolderAccessNoticePhase | undefined {
return useMacFolderAccessFixStore.getState().noticePhaseByScope.get(daemonScope)
}
function shownEvents(): Record<string, unknown>[] {
return trackTelemetry.mock.calls
.filter(([name, props]) => name === 'daemon_folder_access_notice' && props.action === 'shown')
.map(([, props]) => props)
}
/** Sonner hands the action a real event and deletes the toast unless the handler prevents it. */
function clickFix(index = 0): { preventDefault: ReturnType<typeof vi.fn> } {
const event = { preventDefault: vi.fn() }
act(() => {
folderNoticeCalls()[index].options.action?.onClick?.(event)
})
return event
}
function folderNoticeCalls(): { title: string; options: ToastOptions }[] {
return vi
.mocked(toast.warning)
.mock.calls.map((call) => ({
title: String(call[0] ?? ''),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook is the only caller and always passes this options object.
options: (call[1] ?? {}) as ToastOptions
}))
.filter(({ options }) => options.id === 'mac-daemon-folder-access-mismatch')
}
beforeEach(() => {
macTccAttribution.mockReset()
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: null })
trackTelemetry.mockReset()
openSettingsPage.mockReset()
openSettingsTarget.mockReset()
setSettingsSearchQuery.mockReset()
platform.value = 'darwin'
vi.mocked(toast.warning).mockClear()
vi.mocked(toast.dismiss).mockClear()
useMacFolderAccessFixStore.setState({
mismatch: null,
openScope: null,
noticePhaseByScope: new Map<string, FolderAccessNoticePhase>()
})
onDismissById.clear()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
platform: { get: () => ({ platform: platform.value }) },
pty: { management: { macTccAttribution } }
}
})
})
afterEach(() => {
cleanup()
})
it('does not toast when there is no mismatch', async () => {
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(macTccAttribution).toHaveBeenCalled()
})
expect(folderNoticeCalls()).toHaveLength(0)
})
it('names the denied folder and the cost, and leaves the steps to the dialog', async () => {
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: SCOPE_A })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(1)
})
const notice = folderNoticeCalls()[0]
expect(notice.title).toMatch(/Terminals cant read your Documents folder/i)
// The dialog carries the steps; the toast says what is blocked and what that costs.
expect(notice.options.description).toMatch(/may fail until its fixed/)
expect(notice.options.description).not.toMatch(/Manage Sessions|System Settings/)
expect(notice.options.duration).toBe(Infinity)
expect(notice.options.action?.label).toBe('Fix')
expect(notice.options.cancel).toBeUndefined()
})
it('opens the fix dialog rather than Manage Sessions', async () => {
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: SCOPE_A })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(1)
})
clickFix()
expect(useMacFolderAccessFixStore.getState().openScope).toBe(SCOPE_A.daemonScope)
expect(useMacFolderAccessFixStore.getState().mismatch).toEqual(SCOPE_A)
expect(openSettingsPage).not.toHaveBeenCalled()
expect(trackTelemetry).toHaveBeenCalledWith('daemon_folder_access_notice', {
action: 'fix_opened',
cwd_class: 'documents'
})
})
it('carries a later polls verdict into the open dialog', async () => {
macTccAttribution.mockResolvedValueOnce({ health: 'intact', folderAccessMismatch: SCOPE_A })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(1)
})
clickFix()
macTccAttribution.mockResolvedValue({
health: 'intact',
folderAccessMismatch: { ...SCOPE_A, freshDaemonAccess: 'denied' }
})
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(useMacFolderAccessFixStore.getState().mismatch?.freshDaemonAccess).toBe('denied')
})
})
// Sonner deletes a toast after its action button runs unless the handler prevents the event, and
// it does that silently — no onDismiss — so the scope would stay latched with nothing on screen.
it('keeps the toast up when the user opens the dialog', async () => {
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: SCOPE_A })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(1)
})
const event = clickFix()
expect(event.preventDefault).toHaveBeenCalledTimes(1)
expect(noticePhase(SCOPE_A.daemonScope)).toBe('visible')
// The toast sonner kept is the one still on screen, so a later poll must not raise a second.
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(macTccAttribution).toHaveBeenCalledTimes(2)
})
expect(folderNoticeCalls()).toHaveLength(1)
expect(dismissedEvents()).toHaveLength(0)
})
// The open remedy belongs to one scope, so evidence that moves closes it rather than retargeting
// the title, the checklist, and the reset onto a folder the user never asked about.
it('closes the open dialog when the evidence moves to another scope', async () => {
macTccAttribution.mockResolvedValueOnce({ health: 'intact', folderAccessMismatch: SCOPE_A })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(1)
})
clickFix()
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: SCOPE_B })
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(useMacFolderAccessFixStore.getState().mismatch).toEqual(SCOPE_B)
})
// Ended, not parked: the scope coming back later must not pop the dialog on its own.
expect(useMacFolderAccessFixStore.getState().openScope).toBeNull()
})
// The toast outlives the poll that raised it, and a restart offered against a stale `unknown`
// would kill every terminal for a daemon that is provably denied.
it('opens the dialog on the latest verdict, not the one that raised the toast', async () => {
const unanswered = { ...SCOPE_A, freshDaemonAccess: 'unknown' }
macTccAttribution.mockResolvedValueOnce({ health: 'intact', folderAccessMismatch: unanswered })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(1)
})
const denied = { ...SCOPE_A, freshDaemonAccess: 'denied' }
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: denied })
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(macTccAttribution).toHaveBeenCalledTimes(2)
})
clickFix()
expect(folderNoticeCalls()).toHaveLength(1)
expect(useMacFolderAccessFixStore.getState().mismatch).toEqual(denied)
})
it('substitutes the folder word for each protected class', async () => {
for (const [cwdClass, expected] of [
['desktop', 'Desktop folder'],
['downloads', 'Downloads folder'],
['other-home', 'workspace folder'],
['outside-home', 'workspace folder']
]) {
vi.mocked(toast.warning).mockClear()
macTccAttribution.mockResolvedValue({
health: 'intact',
folderAccessMismatch: {
daemonScope: `scope-${cwdClass}`,
cwdClass,
freshDaemonAccess: 'allowed'
}
})
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(1)
})
expect(folderNoticeCalls()[0].title).toContain(expected)
cleanup()
}
})
it('shows once per daemon scope, not once per poll', async () => {
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: SCOPE_A })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(1)
})
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(macTccAttribution).toHaveBeenCalledTimes(2)
})
expect(folderNoticeCalls()).toHaveLength(1)
expect(shownEvents()).toHaveLength(1)
})
// The notice is shown by the renderer, so the renderer is what can count it.
it('counts the notice as shown when it raises one, and not when it withholds one', async () => {
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: SCOPE_A })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(1)
})
expect(shownEvents()).toEqual([{ action: 'shown', cwd_class: 'documents' }])
})
it('never re-shows a scope the user dismissed this session', async () => {
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: SCOPE_A })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(1)
})
folderNoticeCalls()[0].options.onDismiss?.()
expect(trackTelemetry).toHaveBeenCalledWith('daemon_folder_access_notice', {
action: 'dismissed',
cwd_class: 'documents'
})
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(macTccAttribution).toHaveBeenCalledTimes(2)
})
expect(folderNoticeCalls()).toHaveLength(1)
})
// The restart remedy: a replacement daemon mints a new identity, so the poll goes quiet.
it('dismisses the notice once the poll stops reporting a mismatch', async () => {
macTccAttribution.mockResolvedValueOnce({ health: 'intact', folderAccessMismatch: SCOPE_A })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(1)
})
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: null })
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(toast.dismiss).toHaveBeenCalledWith('mac-daemon-folder-access-mismatch')
})
})
// A reconnect blip reports no daemon and takes the toast down, so the same notice comes back.
// Counting that raise would inflate the denominator the affected-user rate is read against.
it('re-shows the same daemon after a poll that briefly reported nothing, counting it once', async () => {
macTccAttribution.mockResolvedValueOnce({ health: 'intact', folderAccessMismatch: SCOPE_A })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(1)
})
macTccAttribution.mockResolvedValueOnce({ health: 'intact', folderAccessMismatch: null })
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(toast.dismiss).toHaveBeenCalledWith('mac-daemon-folder-access-mismatch')
})
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: SCOPE_A })
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(2)
})
expect(shownEvents()).toHaveLength(1)
expect(noticePhase(SCOPE_A.daemonScope)).toBe('visible')
})
it('shows again when a replacement daemon is denied too', async () => {
macTccAttribution.mockResolvedValueOnce({ health: 'intact', folderAccessMismatch: SCOPE_A })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(1)
})
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: SCOPE_B })
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(2)
})
expect(folderNoticeCalls()[1].title).toContain('Desktop folder')
// A second scope is a second affected notice, so it does count.
expect(shownEvents()).toHaveLength(2)
})
// A second scope — a replacement daemon, or one daemon denied a second folder class — reuses the
// toast id, so the replaced toast's onDismiss may still fire. It must latch neither scope.
it('does not read a replaced toasts dismissal as the users', async () => {
macTccAttribution.mockResolvedValueOnce({ health: 'intact', folderAccessMismatch: SCOPE_A })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(1)
})
const replaced = folderNoticeCalls()[0].options.onDismiss
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: SCOPE_B })
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(2)
})
act(() => {
replaced?.()
})
expect(dismissedEvents()).toHaveLength(0)
expect(noticePhase(SCOPE_A.daemonScope)).toBe('retired')
expect(noticePhase(SCOPE_B.daemonScope)).toBe('visible')
})
// A takedown the user did not ask for reaches the same callback, and must not read as their X.
it('counts only the users own close as a dismissal', async () => {
macTccAttribution.mockResolvedValueOnce({ health: 'intact', folderAccessMismatch: SCOPE_A })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(1)
})
macTccAttribution.mockResolvedValueOnce({ health: 'intact', folderAccessMismatch: null })
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(toast.dismiss).toHaveBeenCalledWith('mac-daemon-folder-access-mismatch')
})
expect(dismissedEvents()).toHaveLength(0)
macTccAttribution.mockResolvedValue({ health: 'intact', folderAccessMismatch: SCOPE_A })
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(folderNoticeCalls()).toHaveLength(2)
})
act(() => {
folderNoticeCalls()[1].options.onDismiss?.()
})
expect(dismissedEvents()).toHaveLength(1)
})
it('raises both notices when attribution is severed and a folder is denied', async () => {
macTccAttribution.mockResolvedValue({ health: 'severed', folderAccessMismatch: SCOPE_A })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(toast.warning).toHaveBeenCalledTimes(2)
})
expect(folderNoticeCalls()).toHaveLength(1)
})
})
@@ -1,16 +1,26 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import type {
PtyManagementFolderAccessMismatch,
PtyManagementMacTccAttributionHealth
} from '../../../preload/api-types'
import { isPluginUiLanguage } from '../../../shared/ui-language'
import { useAppStore } from '@/store'
import { usePluginLanguagePackStore } from '@/store/plugin-language-packs'
import { translate } from '@/i18n/i18n'
import { track } from '@/lib/telemetry'
import { resolveUiLocale } from '@/i18n/supported-languages'
import { MANAGE_SESSIONS_SECTION_ID } from '@/components/settings/TerminalTccAttributionNotice'
import { macFolderAccessFolderName } from '@/components/shared/mac-folder-access-folder-name'
import {
FOLDER_ACCESS_MISMATCH_NOTICE_ID,
useMacFolderAccessFixStore
} from '@/store/mac-folder-access-fix'
const SEVERED_TCC_NOTICE_ID = 'mac-tcc-attribution-severed'
/** Surface the existing restart remedy once when daemon TCC attribution is severed. */
/** Surface the existing restart remedy when daemon TCC attribution is severed or a folder is denied. */
export function useMacTccAttributionSeveredNotice(): void {
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
@@ -46,56 +56,122 @@ export function useMacTccAttributionSeveredNotice(): void {
return
}
const openManageSessions = (): void => {
setSettingsSearchQuery('')
openSettingsTarget({
pane: 'terminal',
repoId: null,
sectionId: MANAGE_SESSIONS_SECTION_ID
})
openSettingsPage()
}
const applySeveredNotice = (health: PtyManagementMacTccAttributionHealth): void => {
if (health !== 'severed') {
if (toastedThisSession.current) {
toast.dismiss(SEVERED_TCC_NOTICE_ID)
}
return
}
if (toastedThisSession.current) {
return
}
toastedThisSession.current = true
toast.warning(
translate(
'auto.hooks.useMacTccAttributionSeveredNotice.title',
'macOS permissions may not reach Orca terminals'
),
{
id: SEVERED_TCC_NOTICE_ID,
description: translate(
'auto.hooks.useMacTccAttributionSeveredNotice.description',
'Running Orca terminals are hosted by a daemon started by a previous Orca installation. macOS may not apply Orcas Accessibility, Automation, or protected-file permissions to them. Restart the daemon from Manage Sessions to restore access. This will close all running Orca terminals.'
),
duration: Infinity,
action: {
label: translate(
'auto.hooks.useMacTccAttributionSeveredNotice.openManageSessions',
'Open Manage Sessions'
),
onClick: openManageSessions
},
cancel: {
label: translate('auto.hooks.useMacTccAttributionSeveredNotice.dismiss', 'Dismiss'),
onClick: () => {}
}
}
)
}
const applyFolderAccessNotice = (mismatch: PtyManagementFolderAccessMismatch | null): void => {
const { noticePhaseByScope, applyVerdict, showNotice, openFix } =
useMacFolderAccessFixStore.getState()
// Why unconditionally: this is the evidence the dialog renders, and an open one completes
// its first step only when a later poll says the grant landed. A null verdict retires the
// notice from in there, so the same daemon can raise it again after a reconnect blip.
applyVerdict(mismatch)
if (!mismatch) {
return
}
const { daemonScope, cwdClass } = mismatch
const phase = noticePhaseByScope.get(daemonScope)
if (phase === 'visible' || phase === 'dismissed') {
return
}
showNotice(daemonScope)
// Counted once per scope, not once per raise: a retired scope re-shows after a reconnect
// blip, and that second toast is the same notice, not a second affected user.
if (phase === undefined) {
track('daemon_folder_access_notice', { action: 'shown', cwd_class: cwdClass })
}
toast.warning(
translate(
'auto.hooks.useMacTccAttributionSeveredNotice.folderAccessTitle',
'Terminals cant read your {{folder}}',
{ folder: macFolderAccessFolderName(cwdClass) }
),
{
id: FOLDER_ACCESS_MISMATCH_NOTICE_ID,
description: translate(
'auto.hooks.useMacTccAttributionSeveredNotice.folderAccessDescription',
'macOS is blocking Orcas terminal service from this folder, so commands run there may fail until its fixed.'
),
duration: Infinity,
action: {
label: translate('auto.hooks.useMacTccAttributionSeveredNotice.folderAccessFix', 'Fix'),
onClick: (event) => {
// Sonner deletes the toast after an action click, silently: the evidence is still
// true until a restart, so the toast has to survive the dialog being cancelled.
event.preventDefault()
track('daemon_folder_access_notice', { action: 'fix_opened', cwd_class: cwdClass })
// No captured verdict: the dialog opens on whatever the latest poll reported.
openFix()
}
},
// Why onDismiss, no cancel button: every other toast dismisses through the X alone.
// Sonner fires it for a programmatic takedown too, which has already cleared the scope.
onDismiss: () => {
const store = useMacFolderAccessFixStore.getState()
if (store.noticePhaseByScope.get(daemonScope) !== 'visible') {
return
}
store.dismissNotice(daemonScope)
track('daemon_folder_access_notice', { action: 'dismissed', cwd_class: cwdClass })
}
}
)
}
const maybeToast = async (): Promise<void> => {
if (checkInFlight.current) {
return
}
checkInFlight.current = true
try {
const { health } = await macTccAttribution()
if (health !== 'severed') {
if (toastedThisSession.current) {
toast.dismiss(SEVERED_TCC_NOTICE_ID)
}
return
}
if (toastedThisSession.current) {
return
}
toastedThisSession.current = true
toast.warning(
translate(
'auto.hooks.useMacTccAttributionSeveredNotice.title',
'macOS permissions may not reach Orca terminals'
),
{
id: SEVERED_TCC_NOTICE_ID,
description: translate(
'auto.hooks.useMacTccAttributionSeveredNotice.description',
'Running Orca terminals are hosted by a daemon started by a previous Orca installation. macOS may not apply Orcas Accessibility, Automation, or protected-file permissions to them. Restart the daemon from Manage Sessions to restore access. This will close all running Orca terminals.'
),
duration: Infinity,
action: {
label: translate(
'auto.hooks.useMacTccAttributionSeveredNotice.openManageSessions',
'Open Manage Sessions'
),
onClick: () => {
setSettingsSearchQuery('')
openSettingsTarget({
pane: 'terminal',
repoId: null,
sectionId: MANAGE_SESSIONS_SECTION_ID
})
openSettingsPage()
}
},
cancel: {
label: translate('auto.hooks.useMacTccAttributionSeveredNotice.dismiss', 'Dismiss'),
onClick: () => {}
}
}
)
const { health, folderAccessMismatch } = await macTccAttribution()
applySeveredNotice(health)
applyFolderAccessNotice(folderAccessMismatch ?? null)
} catch {
// Rejection clears the guard so a later focus can retry.
} finally {
+32 -3
View File
@@ -1170,7 +1170,10 @@
"title": "macOS permissions may not reach Orca terminals",
"description": "Running Orca terminals are hosted by a daemon started by a previous Orca installation. macOS may not apply Orcas Accessibility, Automation, or protected-file permissions to them. Restart the daemon from Manage Sessions to restore access. This will close all running Orca terminals.",
"openManageSessions": "Open Manage Sessions",
"dismiss": "Dismiss"
"dismiss": "Dismiss",
"folderAccessTitle": "Terminals cant read your {{folder}}",
"folderAccessDescription": "macOS is blocking Orcas terminal service from this folder, so commands run there may fail until its fixed.",
"folderAccessFix": "Fix"
},
"ipc": {
"events": {
@@ -6368,8 +6371,8 @@
"01af244097": "Cancel",
"28c8e53176": "This force-quits every running terminal pane across all workspaces. Any unsaved work in those sessions is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone.",
"1bbea41a77": "Kill all terminal sessions?",
"01d6b7c64e": "Kills every running terminal pane and restarts the daemon process. Panes show \"Process exited\" and can be reopened immediately. Legacy-protocol sessions from a previous app version are preserved. This can't be undone.",
"922548bc66": "Restart the terminal daemon?",
"01d6b7c64e": "Open terminals and agents will restart. Terminals on remote hosts are not affected.",
"922548bc66": "Restart the terminal service?",
"2b4efdc162": "Couldnt kill sessions.",
"d18f3005c2": "{{value0}} session{{value1}} refused to exit.",
"baad8cd651": "No sessions running.",
@@ -6392,6 +6395,32 @@
"d9657ac204": "Terminal session shutdown requested.",
"e8f25bd903": "Couldnt finish terminal cleanup.",
"a702d4196e": "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone."
},
"MacFolderAccessFixDialog": {
"stepAllow": "Allow Orca under Files and Folders",
"openSystemSettings": "Open System Settings",
"stepRestart": "Restart Orcas terminal service",
"restartConsequence": "Open terminals and agents will restart.",
"restarting": "Restarting…",
"restart": "Restart",
"restartFailed": "Restart failed. Try again from Settings → Terminal → Manage Sessions.",
"title": "Fix access to your {{folder}}",
"lead": "macOS is blocking Orcas terminal service from this folder.",
"done": "Done",
"cancel": "Cancel",
"stepAllowUnknown": "Couldnt verify. Skip if already allowed.",
"stepAllowDenied": "Orca is already allowed, but macOS isnt applying it to the terminal service. Reset asks macOS for the permission again. Click Allow when it prompts.",
"stepReallow": "Re-allow Orca for your {{folder}}",
"reset": "Reset permission",
"resetting": "Resetting…",
"resetFailed": "Couldnt reset the permission. Use System Settings instead.",
"resetStillBlocked": "Still blocked after the reset."
},
"macFolderAccessFolderName": {
"documents": "Documents folder",
"desktop": "Desktop folder",
"downloads": "Downloads folder",
"workspace": "workspace folder"
}
},
"setup": {
-2
View File
@@ -5285,8 +5285,6 @@
"01af244097": "Cancelar",
"28c8e53176": "Esto fuerza el cierre de todos los paneles de terminal en ejecución en todos los espacios de trabajo. Cualquier trabajo sin guardar en esas sesiones se perderá. El servicio sigue ejecutándose y se pueden abrir nuevos terminales inmediatamente. Esto no se puede deshacer.",
"1bbea41a77": "¿Terminar todas las sesiones de terminal?",
"01d6b7c64e": "Termina todos los paneles de terminal en ejecución y reinicia el proceso del servicio. Los paneles muestran \"Process exited\" y se pueden volver a abrir inmediatamente. Se conservan las sesiones de protocolo heredado de una versión anterior de la app. Esto no se puede deshacer.",
"922548bc66": "¿Reiniciar el servicio del terminal?",
"2b4efdc162": "No se pudieron finalizar las sesiones.",
"d18f3005c2": "{{value0}} sesión{{value1}} se negó a salir.",
"baad8cd651": "No hay sesiones en ejecución.",
-2
View File
@@ -6019,8 +6019,6 @@
"01af244097": "Annuler",
"28c8e53176": "Force l'arrêt de tous les volets de terminal en cours d'exécution dans tous les espaces de travail. Tout travail non enregistré de ces sessions est perdu. Le daemon continue de tourner et de nouveaux terminaux peuvent être ouverts immédiatement. Irréversible.",
"1bbea41a77": "Forcer l'arrêt de toutes les sessions de terminal ?",
"01d6b7c64e": "Tue tous les volets de terminal en cours d'exécution et redémarre le processus daemon. Les volets affichent \"Process exited\" et peuvent être rouverts immédiatement. Les sessions au protocole hérité d'une version précédente de l'application sont préservées. Irréversible.",
"922548bc66": "Redémarrer le daemon de terminal ?",
"2b4efdc162": "Impossible de tuer les sessions.",
"d18f3005c2": "Fermeture refusée pour {{value0}} session{{value1}}.",
"baad8cd651": "Aucune session en cours.",
-2
View File
@@ -5285,8 +5285,6 @@
"01af244097": "キャンセル",
"28c8e53176": "これにより、すべてのワークスペースで実行中のすべてのターミナルペインが強制終了されます。これらのセッションで保存されていない作業内容は失われます。デーモン自体は実行を継続し、新規ターミナルをすぐに開くことができます。これを元に戻すことはできません。",
"1bbea41a77": "すべてのターミナルセッションを強制終了しますか?",
"01d6b7c64e": "実行中のすべてのターミナルペインを強制終了し、デーモンプロセスを再起動します。ペインには「プロセスが終了しました」と表示され、すぐに再度開くことができます。以前のアプリバージョンのレガシープロトコルセッションは保持されます。これを元に戻すことはできません。",
"922548bc66": "ターミナルデーモンを再起動しますか?",
"2b4efdc162": "セッションを強制終了できませんでした。",
"d18f3005c2": "{{value0}} セッション{{value1}} は終了を拒否しました。",
"baad8cd651": "実行中のセッションはありません。",
-2
View File
@@ -5290,8 +5290,6 @@
"01af244097": "취소",
"28c8e53176": "그러면 모든 워크스페이스에서 실행 중인 모든 terminals 패널이 강제 종료됩니다. 해당 세션에서 저장하지 않은 작업은 모두 손실됩니다. 데몬 자체는 계속 실행되며 새 terminals을 즉시 열 수 있습니다. 이 작업은 취소할 수 없습니다.",
"1bbea41a77": "모든 terminal 세션을 종료하시겠습니까?",
"01d6b7c64e": "실행 중인 모든 terminal 패널을 종료하고 데몬 프로세스를 다시 시작합니다. 패널에는 \"프로세스 종료됨\"이 표시되며 즉시 다시 열 수 있습니다. 이전 앱 버전의 레거시 프로토콜 세션은 보존됩니다. 이 작업은 취소할 수 없습니다.",
"922548bc66": "terminal 데몬을 다시 시작하시겠습니까?",
"2b4efdc162": "세션을 종료할 수 없습니다.",
"d18f3005c2": "세션 {{value0}}개{{value1}}이(가) 종료를 거부했습니다.",
"baad8cd651": "실행 중인 세션이 없습니다.",
-2
View File
@@ -5333,8 +5333,6 @@
"01af244097": "取消",
"28c8e53176": "这会强制退出所有工作区中每个正在运行的终端窗格。这些会话中所有未保存的工作都会丢失。守护进程本身保持运行,并且可以立即打开新终端。这无法撤销。",
"1bbea41a77": "终止所有终端会话?",
"01d6b7c64e": "终止每个正在运行的终端窗格并重新启动守护进程。窗格显示“进程已退出”并且可以立即重新打开。先前应用程序版本的旧协议会话将被保留。这无法撤销。",
"922548bc66": "重新启动终端守护进程?",
"2b4efdc162": "无法终止会话。",
"d18f3005c2": "{{value0}} 会话{{value1}} 拒绝退出。",
"baad8cd651": "没有正在运行的会话。",
@@ -0,0 +1,97 @@
// Shared state between the folder-access toast (which raises it) and the fix dialog (which renders
// it), so neither has to own the other. STA-7948.
import { toast } from 'sonner'
import { create } from 'zustand'
import type { PtyManagementFolderAccessMismatch } from '../../../preload/api-types'
export const FOLDER_ACCESS_MISMATCH_NOTICE_ID = 'mac-daemon-folder-access-mismatch'
/**
* Where a scope's notice stands. `retired` is a takedown nobody asked for — a restart, or a poll
* that read no daemon through a reconnect blip — so the scope may raise again; `dismissed` is the
* user's own close and is final for the session. A scope absent from the map has never been shown.
*/
export type FolderAccessNoticePhase = 'visible' | 'retired' | 'dismissed'
/** At most one, because sonner keeps a single toast under the notice's id. */
export function visibleNoticeScope(
noticePhaseByScope: ReadonlyMap<string, FolderAccessNoticePhase>
): string | null {
for (const [daemonScope, phase] of noticePhaseByScope) {
if (phase === 'visible') {
return daemonScope
}
}
return null
}
type MacFolderAccessFixState = {
/** The latest verdict main reported, whatever scope it is about. The dialog renders this one. */
mismatch: PtyManagementFolderAccessMismatch | null
/**
* The scope the user asked to fix. The dialog shows only while it still matches the evidence, so
* evidence that moves to another scope closes it rather than retargeting it mid-remedy.
*/
openScope: string | null
/** Every scope that has ever raised a notice, and where each one stands now. */
noticePhaseByScope: ReadonlyMap<string, FolderAccessNoticePhase>
openFix: () => void
close: () => void
/**
* Every verdict main produces — a poll or a reset's forced re-probe — lands here unconditionally,
* and a null one retires the notice as well, so no caller has to remember to.
*/
applyVerdict: (mismatch: PtyManagementFolderAccessMismatch | null) => void
showNotice: (daemonScope: string) => void
retireNotice: (daemonScope: string) => void
dismissNotice: (daemonScope: string) => void
}
export const useMacFolderAccessFixStore = create<MacFolderAccessFixState>()((set, get) => ({
mismatch: null,
openScope: null,
noticePhaseByScope: new Map<string, FolderAccessNoticePhase>(),
openFix: () => set((state) => ({ openScope: state.mismatch?.daemonScope ?? null })),
close: () => set({ openScope: null }),
applyVerdict: (mismatch) => {
// The open remedy belongs to one scope; any other verdict ends it.
set((state) => ({
mismatch,
openScope: mismatch && mismatch.daemonScope === state.openScope ? state.openScope : null
}))
if (mismatch) {
return
}
// No evidence left, so the toast goes too — whether a poll or a reset is what found that out.
const visible = visibleNoticeScope(get().noticePhaseByScope)
if (visible) {
get().retireNotice(visible)
}
},
showNotice: (daemonScope) =>
set((state) => {
const next = new Map(state.noticePhaseByScope)
for (const [scope, phase] of next) {
// One toast id, so raising this scope is what takes the previous one off screen.
if (phase === 'visible' && scope !== daemonScope) {
next.set(scope, 'retired')
}
}
return { noticePhaseByScope: next.set(daemonScope, 'visible') }
}),
retireNotice: (daemonScope) => {
const { noticePhaseByScope } = get()
if (noticePhaseByScope.get(daemonScope) !== 'visible') {
return
}
// Why retire first: sonner reports a programmatic dismissal through `onDismiss` too, and only
// a still-visible scope there is the user's doing.
set({ noticePhaseByScope: new Map(noticePhaseByScope).set(daemonScope, 'retired') })
toast.dismiss(FOLDER_ACCESS_MISMATCH_NOTICE_ID)
},
dismissNotice: (daemonScope) =>
set((state) => ({
noticePhaseByScope: new Map(state.noticePhaseByScope).set(daemonScope, 'dismissed')
}))
}))
@@ -95,7 +95,10 @@ export function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
killOne: () => Promise.resolve({ success: false }),
restart: () => Promise.resolve({ success: false }),
// Why: web clients can't inspect the host daemon's pid record; 'unknown' keeps the banner hidden.
macTccAttribution: () => Promise.resolve({ health: 'unknown' as const })
macTccAttribution: () =>
Promise.resolve({ health: 'unknown' as const, folderAccessMismatch: null }),
// Why: the TCC row belongs to the host's app bundle, which a web client cannot reach.
resetFolderAccess: () => Promise.resolve({ outcome: 'unsupported' as const })
}
}
}
+62 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest'
import { classifyDaemonPtyCwd, classifyDaemonSpawnerPath } from './daemon-adoption-telemetry'
import {
classifyDaemonPtyCwd,
classifyDaemonSpawnerPath,
DAEMON_PTY_CWD_CLASSES,
isMacTccFolderClass,
MAC_TCC_FOLDER_CLASSES
} from './daemon-adoption-telemetry'
import { eventSchemas } from './telemetry-event-registry'
describe('classifyDaemonSpawnerPath', () => {
@@ -47,6 +53,16 @@ describe('classifyDaemonPtyCwd', () => {
})
})
// The reset remedy is offered for exactly these classes, so main and the fix dialog must agree.
describe('isMacTccFolderClass', () => {
it('admits the three folders with a per-app TCC row and no others', () => {
for (const cwdClass of DAEMON_PTY_CWD_CLASSES) {
expect(isMacTccFolderClass(cwdClass)).toBe(MAC_TCC_FOLDER_CLASSES.some((c) => c === cwdClass))
}
expect([...MAC_TCC_FOLDER_CLASSES]).toEqual(['documents', 'desktop', 'downloads'])
})
})
// Privacy invariant: enum-only. A raw path, version, or exact count must be rejected by .strict().
describe('daemon_adopted / daemon_pty_cwd_denied schemas', () => {
const adopted = {
@@ -87,3 +103,48 @@ describe('daemon_adopted / daemon_pty_cwd_denied schemas', () => {
).toBe(false)
})
})
// The notice is read against `daemon_pty_cwd_denied`, so it carries the same enum-only budget:
// no daemon scope, no path, no folder name.
describe('daemon_folder_access_notice schema', () => {
const shown = { action: 'shown', cwd_class: 'documents' }
it('accepts each action against a protected folder class', () => {
for (const action of [
'shown',
'fix_opened',
'settings_opened',
'restart_clicked',
'dismissed',
'restart_outcome_fixed',
'restart_outcome_still_denied',
'reset_clicked',
'reset_outcome_allowed',
'reset_outcome_still_denied',
'reset_outcome_unknown'
]) {
expect(eventSchemas.daemon_folder_access_notice.safeParse({ ...shown, action }).success).toBe(
true
)
}
for (const cwdClass of DAEMON_PTY_CWD_CLASSES) {
expect(
eventSchemas.daemon_folder_access_notice.safeParse({ ...shown, cwd_class: cwdClass })
.success
).toBe(true)
}
})
it('rejects an unknown action, an unknown class, and any extra field', () => {
for (const bad of [
{ action: 'open_manage_sessions' },
{ cwd_class: 'Documents' },
{ daemon_scope: 'aaaa111122223333' },
{ cwd: '/Users/alice/Documents' }
]) {
expect(eventSchemas.daemon_folder_access_notice.safeParse({ ...shown, ...bad }).success).toBe(
false
)
}
})
})
+11
View File
@@ -32,6 +32,17 @@ export const DAEMON_PTY_CWD_CLASSES = [
] as const
export type DaemonPtyCwdClass = (typeof DAEMON_PTY_CWD_CLASSES)[number]
/**
* The classes macOS gates behind a per-app TCC row, which is what `tccutil reset` acts on. The
* other two are denied through something else, so there is no row to clear and no reset to offer.
*/
export const MAC_TCC_FOLDER_CLASSES = ['documents', 'desktop', 'downloads'] as const
export type MacTccFolderClass = (typeof MAC_TCC_FOLDER_CLASSES)[number]
export function isMacTccFolderClass(cwdClass: DaemonPtyCwdClass): cwdClass is MacTccFolderClass {
return MAC_TCC_FOLDER_CLASSES.some((name) => name === cwdClass)
}
export function classifyDaemonSpawnerPath(
spawnerExecPath: string | null,
exists: (path: string) => boolean
@@ -4,6 +4,9 @@ export type DeveloperPermissionId =
| 'screen'
| 'accessibility'
| 'full-disk-access'
// Not in DEVELOPER_PERMISSION_IDS: macOS exposes no API to read this grant, so it is
// open-the-pane only (STA-7948).
| 'files-and-folders'
| 'automation'
| 'local-network'
| 'usb'
@@ -77,6 +77,28 @@ export const daemonPtyCwdDeniedSchema = z
})
.strict()
// Why: STA-7948 — `daemon_pty_cwd_denied` counts the failure; this counts how often a user is
// actually told about it, what they do next, and whether the restart they were offered worked, so
// the notice can be read against that denominator.
export const daemonFolderAccessNoticeSchema = z
.object({
action: z.enum([
'shown',
'fix_opened',
'settings_opened',
'restart_clicked',
'dismissed',
'restart_outcome_fixed',
'restart_outcome_still_denied',
'reset_clicked',
'reset_outcome_allowed',
'reset_outcome_still_denied',
'reset_outcome_unknown'
]),
cwd_class: z.enum(DAEMON_PTY_CWD_CLASSES)
})
.strict()
// Why: daemon replace/retire lifecycle signal — issue #7936 was undiagnosable without asking a user for daemon.log.
// Enum-only + bucketed session count so no paths, raw versions, or exact counts reach the wire.
// The union keeps each reason pinned to its transition, so a death can't be reported as a replace.
+2
View File
@@ -16,6 +16,7 @@ import {
codexTrustGrantSchema,
daemonAdoptedSchema,
daemonAuditEligibilitySchema,
daemonFolderAccessNoticeSchema,
daemonLifecycleSchema,
daemonPtyCwdDeniedSchema,
daemonStartFailedSchema,
@@ -126,6 +127,7 @@ export const eventSchemas = {
daemon_lifecycle: daemonLifecycleSchema,
daemon_adopted: daemonAdoptedSchema,
daemon_pty_cwd_denied: daemonPtyCwdDeniedSchema,
daemon_folder_access_notice: daemonFolderAccessNoticeSchema,
daemon_audit_eligibility: daemonAuditEligibilitySchema,
runtime_rpc_start_failed: runtimeRpcStartFailedSchema,
remote_outbound_budget_close: remoteOutboundBudgetCloseSchema,