diff --git a/src/main/computer/macos-computer-use-permissions.test.ts b/src/main/computer/macos-computer-use-permissions.test.ts index a8b6442b719..5ac2da2d2c1 100644 --- a/src/main/computer/macos-computer-use-permissions.test.ts +++ b/src/main/computer/macos-computer-use-permissions.test.ts @@ -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 { + 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 = { + 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) 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() ) }) }) diff --git a/src/main/computer/macos-computer-use-permissions.ts b/src/main/computer/macos-computer-use-permissions.ts index 36d7cedf59d..bebd36b3924 100644 --- a/src/main/computer/macos-computer-use-permissions.ts +++ b/src/main/computer/macos-computer-use-permissions.ts @@ -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 { // 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( diff --git a/src/main/daemon/daemon-adoption-telemetry-event.test.ts b/src/main/daemon/daemon-adoption-telemetry-event.test.ts index 4525ea3aa03..9f19706b111 100644 --- a/src/main/daemon/daemon-adoption-telemetry-event.test.ts +++ b/src/main/daemon/daemon-adoption-telemetry-event.test.ts @@ -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>()), - 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>()), + opendir: opendirMock +})) vi.mock('node:os', async (importOriginal) => ({ ...(await importOriginal>()), 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 } { + 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) => void = () => {} + opendirMock.mockReturnValue( + new Promise>((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') }) }) diff --git a/src/main/daemon/daemon-adoption-telemetry-event.ts b/src/main/daemon/daemon-adoption-telemetry-event.ts index 47f554bf9bb..c92c7086c44 100644 --- a/src/main/daemon/daemon-adoption-telemetry-event.ts +++ b/src/main/daemon/daemon-adoption-telemetry-event.ts @@ -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 { 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 { + 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. } } diff --git a/src/main/daemon/daemon-folder-access-mismatch.test.ts b/src/main/daemon/daemon-folder-access-mismatch.test.ts new file mode 100644 index 00000000000..e8f56efad67 --- /dev/null +++ b/src/main/daemon/daemon-folder-access-mismatch.test.ts @@ -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>()), + 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 { + 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 { + 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((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((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((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') + }) +}) diff --git a/src/main/daemon/daemon-folder-access-mismatch.ts b/src/main/daemon/daemon-folder-access-mismatch.ts new file mode 100644 index 00000000000..43aac9486ce --- /dev/null +++ b/src/main/daemon/daemon-folder-access-mismatch.ts @@ -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 | 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 { + 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 { + 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 { + 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 +} diff --git a/src/main/daemon/daemon-folder-access-probe-script.test.ts b/src/main/daemon/daemon-folder-access-probe-script.test.ts new file mode 100644 index 00000000000..936e28ee0e2 --- /dev/null +++ b/src/main/daemon/daemon-folder-access-probe-script.test.ts @@ -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') + } + ) +}) diff --git a/src/main/daemon/daemon-folder-access-probe.test.ts b/src/main/daemon/daemon-folder-access-probe.test.ts new file mode 100644 index 00000000000..b7f18f7c216 --- /dev/null +++ b/src/main/daemon/daemon-folder-access-probe.test.ts @@ -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 = {}): 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 child’s 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') + }) +}) diff --git a/src/main/daemon/daemon-folder-access-probe.ts b/src/main/daemon/daemon-folder-access-probe.ts new file mode 100644 index 00000000000..a65b33a2932 --- /dev/null +++ b/src/main/daemon/daemon-folder-access-probe.ts @@ -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 { + // 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' + } +} diff --git a/src/main/daemon/daemon-folder-access-reset.test.ts b/src/main/daemon/daemon-folder-access-reset.test.ts new file mode 100644 index 00000000000..885b10238b0 --- /dev/null +++ b/src/main/daemon/daemon-folder-access-reset.test.ts @@ -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>(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; close: ReturnType } { + 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(() => {})) + 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 }) + }) +}) diff --git a/src/main/daemon/daemon-folder-access-reset.ts b/src/main/daemon/daemon-folder-access-reset.ts new file mode 100644 index 00000000000..86d8761b909 --- /dev/null +++ b/src/main/daemon/daemon-folder-access-reset.ts @@ -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 = { + 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 { + 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((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['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 { + 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 } } +} diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 9624edc4aca..dfe694fbcad 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -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 diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index bbb899b64a3..388919dd2e3 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -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) } diff --git a/src/main/daemon/directory-enumeration-probe.ts b/src/main/daemon/directory-enumeration-probe.ts new file mode 100644 index 00000000000..3ba06b84710 --- /dev/null +++ b/src/main/daemon/directory-enumeration-probe.ts @@ -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 { + 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. + }) + } +} diff --git a/src/main/daemon/terminal-host-cwd-readability.test.ts b/src/main/daemon/terminal-host-cwd-readability.test.ts index aa9e08379d7..81f93b7b35d 100644 --- a/src/main/daemon/terminal-host-cwd-readability.test.ts +++ b/src/main/daemon/terminal-host-cwd-readability.test.ts @@ -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>()), + opendir: opendirMock +})) + vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() })) +const close = vi.fn(async () => {}) + +function dirReading(read: () => unknown): { read: () => unknown; close: () => Promise } { + 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() }) diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index 10c5b949108..7a8dbe379cc 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -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 { 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 { + return (await enumerateDirectoryOnce(cwd)) !== 'denied' } diff --git a/src/main/ipc/developer-permissions.ts b/src/main/ipc/developer-permissions.ts index cfc10e61aef..1ce5e228324 100644 --- a/src/main/ipc/developer-permissions.ts +++ b/src/main/ipc/developer-permissions.ts @@ -16,6 +16,8 @@ const PRIVACY_PANE_URLS: Partial> = { 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 ({ 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>(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 shutdown: ReturnType + getDaemonIdentity: ReturnType } 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 { + 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 { + 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') + }) + }) }) diff --git a/src/main/ipc/pty-management.ts b/src/main/ipc/pty-management.ts index 6260b8454af..34f990ac161 100644 --- a/src/main/ipc/pty-management.ts +++ b/src/main/ipc/pty-management.ts @@ -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 { 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 => { try { - return { health: await getCurrentDaemonMacTccAttributionHealth() } + return await resetFolderAccessForDaemon(readCurrentDaemonIdentity()) } catch { - return { health: 'unknown' } + return { outcome: 'unsupported' } } } ) diff --git a/src/main/macos-tcc-reset.test.ts b/src/main/macos-tcc-reset.test.ts new file mode 100644 index 00000000000..76cc5ff5144 --- /dev/null +++ b/src/main/macos-tcc-reset.test.ts @@ -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 { + return { + code: 0, + signal: null, + stdout: '', + stderr: '', + timedOut: false, + outputTruncated: false, + ...overrides + } +} + +beforeEach(() => { + runProcessMock.mockReset() +}) + +describe('readMacosBundleId', () => { + it('reads CFBundleIdentifier out of the bundle’s 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 service’s 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' }) + }) +}) diff --git a/src/main/macos-tcc-reset.ts b/src/main/macos-tcc-reset.ts new file mode 100644 index 00000000000..34c627f5a5e --- /dev/null +++ b/src/main/macos-tcc-reset.ts @@ -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 { + 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 { + 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 } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 9a592ff264c..c942e6cb548 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -186,6 +186,8 @@ export type { } from './api/preflight-api' export type { PtyManagementApi, + PtyManagementDaemonCwdClass, + PtyManagementFolderAccessMismatch, PtyManagementMacTccAttributionHealth, PtyManagementSession } from './api/pty-management-api' diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index 0847291ba7e..65612b7e3d7 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -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 diff --git a/src/preload/api/pty-management-api.ts b/src/preload/api/pty-management-api.ts index 10fef2d2303..ddc94c77f2c 100644 --- a/src/preload/api/pty-management-api.ts +++ b/src/preload/api/pty-management-api.ts @@ -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 } diff --git a/src/renderer/src/components/shared/MacFolderAccessFixDialog.test.tsx b/src/renderer/src/components/shared/MacFolderAccessFixDialog.test.tsx new file mode 100644 index 00000000000..8bc9a086e97 --- /dev/null +++ b/src/renderer/src/components/shared/MacFolderAccessFixDialog.test.tsx @@ -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) => + 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([[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() + }) + 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() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('names the denied folder and leads with the cause', () => { + openWith('allowed') + render() + + expect(screen.getByText('Fix access to your Documents folder')).toBeTruthy() + expect( + screen.getByText('macOS is blocking Orca’s 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() + + 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() + + 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() + + 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() + + 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() + + expect(footerButton('Open System Settings')).toBeTruthy() + expect(restartButton().hasAttribute('disabled')).toBe(false) + expect(screen.getByText('Couldn’t verify. Skip if already allowed.')).toBeTruthy() + }) + + it('flips step one to done when a later poll reports the grant landed', async () => { + openWith('denied') + render() + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + 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() + + await userEvent.click(resetButton()) + + await waitFor(() => { + expect(screen.getByText('Couldn’t 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 = 'Couldn’t reset the permission. Use System Settings instead.' + resetFolderAccess.mockResolvedValue({ outcome: 'reset_failed' }) + openWith('denied') + render() + 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() + + 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() + + 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() + expect(screen.getByText('Couldn’t verify. Skip if already allowed.')).toBeTruthy() + + await userEvent.click(restartButton()) + + await waitFor(() => { + expect(footerButton('Done')).toBeTruthy() + }) + expect(screen.queryByText('Couldn’t 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() + + await userEvent.click(resetButton()) + + await waitFor(() => { + expect( + screen.getByText('Couldn’t 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() + + await userEvent.click(resetButton()) + + await waitFor(() => { + expect( + screen.getByText('Couldn’t 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() + + 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() + + act(() => { + useMacFolderAccessFixStore.getState().applyVerdict(null) + }) + + expect(dialogShown()).toBe(false) + expect(useMacFolderAccessFixStore.getState().openScope).toBeNull() + }) + + it('starts a replacement daemon’s remedy from scratch', async () => { + openWith('allowed') + render() + 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() + 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() + + await userEvent.click(footerButton('Cancel')) + + expect(dialogShown()).toBe(false) + }) +}) diff --git a/src/renderer/src/components/shared/MacFolderAccessFixDialog.tsx b/src/renderer/src/components/shared/MacFolderAccessFixDialog.tsx new file mode 100644 index 00000000000..884a28b546e --- /dev/null +++ b/src/renderer/src/components/shared/MacFolderAccessFixDialog.tsx @@ -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 ( +
  • + {done ? ( +
  • + ) +} + +/** + * 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', + 'Couldn’t 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 isn’t 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 ( + <> +
      + + +
    + {restartState === 'failed' ? ( +

    + {translate( + 'auto.components.shared.MacFolderAccessFixDialog.restartFailed', + 'Restart failed. Try again from Settings → Terminal → Manage Sessions.' + )} +

    + ) : null} + {resetState === 'failed' && mismatch.freshDaemonAccess !== 'allowed' ? ( +

    + {translate( + 'auto.components.shared.MacFolderAccessFixDialog.resetFailed', + 'Couldn’t reset the permission. Use System Settings instead.' + )} +

    + ) : null} + {resetState === 'probed' && mismatch.freshDaemonAccess === 'denied' ? ( +

    + {translate( + 'auto.components.shared.MacFolderAccessFixDialog.resetStillBlocked', + 'Still blocked after the reset.' + )} +

    + ) : 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 ( + + ) + } + // 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 ? ( + + ) : ( + + )} + + + ) + } + // 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 ( + <> + + + + ) + } + return ( + <> + {mismatch.freshDaemonAccess === 'unknown' ? ( + + ) : ( + + )} + + + ) +} + +/** + * 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('idle') + const [resetState, setResetState] = useState('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 => { + 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 => { + 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 ( + { + if (!next && !busy) { + close() + } + }} + > + { + if (busy) { + event.preventDefault() + } + }} + onEscapeKeyDown={(event) => { + if (busy) { + event.preventDefault() + } + }} + > + + + {translate( + 'auto.components.shared.MacFolderAccessFixDialog.title', + 'Fix access to your {{folder}}', + { folder } + )} + + + {translate( + 'auto.components.shared.MacFolderAccessFixDialog.lead', + 'macOS is blocking Orca’s terminal service from this folder.' + )} + + + + + void onReset()} + onRestart={() => void onRestart()} + /> + + + + ) +} + +/** + * 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 +} diff --git a/src/renderer/src/components/shared/mac-folder-access-folder-name.ts b/src/renderer/src/components/shared/mac-folder-access-folder-name.ts new file mode 100644 index 00000000000..c28d134c5d4 --- /dev/null +++ b/src/renderer/src/components/shared/mac-folder-access-folder-name.ts @@ -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' + ) + } +} diff --git a/src/renderer/src/components/shared/useDaemonActions.test.tsx b/src/renderer/src/components/shared/useDaemonActions.test.tsx index 6c9e00b8f73..792238662bb 100644 --- a/src/renderer/src/components/shared/useDaemonActions.test.tsx +++ b/src/renderer/src/components/shared/useDaemonActions.test.tsx @@ -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() + + 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() + + expect(screen.queryByText(/Process exited/)).toBeNull() + expect(screen.queryByText(/Legacy-protocol sessions/)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/shared/useDaemonActions.tsx b/src/renderer/src/components/shared/useDaemonActions.tsx index 93c4477cf66..b9ad6f64143 100644 --- a/src/renderer/src/components/shared/useDaemonActions.tsx +++ b/src/renderer/src/components/shared/useDaemonActions.tsx @@ -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…' } } diff --git a/src/renderer/src/hooks/MacosTccPromptNoticeHost.tsx b/src/renderer/src/hooks/MacosTccPromptNoticeHost.tsx index d4d4c1d5541..7bc7d29b0a5 100644 --- a/src/renderer/src/hooks/MacosTccPromptNoticeHost.tsx +++ b/src/renderer/src/hooks/MacosTccPromptNoticeHost.tsx @@ -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 } diff --git a/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx index 1d15ddde948..162e2ab8d23 100644 --- a/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx +++ b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx @@ -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 => ({ 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 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) => + 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() 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() 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() 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((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() 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[] { + 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[] { + 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 } { + 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() + }) + 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() + 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() + await waitFor(() => { + expect(folderNoticeCalls()).toHaveLength(1) + }) + + const notice = folderNoticeCalls()[0] + expect(notice.title).toMatch(/Terminals can’t 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 it’s 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() + 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 poll’s verdict into the open dialog', async () => { + macTccAttribution.mockResolvedValueOnce({ health: 'intact', folderAccessMismatch: SCOPE_A }) + render() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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 toast’s dismissal as the user’s', async () => { + macTccAttribution.mockResolvedValueOnce({ health: 'intact', folderAccessMismatch: SCOPE_A }) + render() + 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 user’s own close as a dismissal', async () => { + macTccAttribution.mockResolvedValueOnce({ health: 'intact', folderAccessMismatch: SCOPE_A }) + render() + 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() + await waitFor(() => { + expect(toast.warning).toHaveBeenCalledTimes(2) + }) + expect(folderNoticeCalls()).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts index 448c47f10b6..f278616a5e4 100644 --- a/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts +++ b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts @@ -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 Orca’s 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 can’t read your {{folder}}', + { folder: macFolderAccessFolderName(cwdClass) } + ), + { + id: FOLDER_ACCESS_MISMATCH_NOTICE_ID, + description: translate( + 'auto.hooks.useMacTccAttributionSeveredNotice.folderAccessDescription', + 'macOS is blocking Orca’s terminal service from this folder, so commands run there may fail until it’s 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 => { 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 Orca’s 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 { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 583da8d98d9..eecf630c0e3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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 Orca’s 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 can’t read your {{folder}}", + "folderAccessDescription": "macOS is blocking Orca’s terminal service from this folder, so commands run there may fail until it’s 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": "Couldn’t kill sessions.", "d18f3005c2": "{{value0}} session{{value1}} refused to exit.", "baad8cd651": "No sessions running.", @@ -6392,6 +6395,32 @@ "d9657ac204": "Terminal session shutdown requested.", "e8f25bd903": "Couldn’t 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 Orca’s 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 Orca’s terminal service from this folder.", + "done": "Done", + "cancel": "Cancel", + "stepAllowUnknown": "Couldn’t verify. Skip if already allowed.", + "stepAllowDenied": "Orca is already allowed, but macOS isn’t 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": "Couldn’t 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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a6b3d602dc2..95fa7e7bd90 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index ee8dd10aff1..2b318d17535 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index bec4a51c057..4529b4f4d38 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5285,8 +5285,6 @@ "01af244097": "キャンセル", "28c8e53176": "これにより、すべてのワークスペースで実行中のすべてのターミナルペインが強制終了されます。これらのセッションで保存されていない作業内容は失われます。デーモン自体は実行を継続し、新規ターミナルをすぐに開くことができます。これを元に戻すことはできません。", "1bbea41a77": "すべてのターミナルセッションを強制終了しますか?", - "01d6b7c64e": "実行中のすべてのターミナルペインを強制終了し、デーモンプロセスを再起動します。ペインには「プロセスが終了しました」と表示され、すぐに再度開くことができます。以前のアプリバージョンのレガシープロトコルセッションは保持されます。これを元に戻すことはできません。", - "922548bc66": "ターミナルデーモンを再起動しますか?", "2b4efdc162": "セッションを強制終了できませんでした。", "d18f3005c2": "{{value0}} セッション{{value1}} は終了を拒否しました。", "baad8cd651": "実行中のセッションはありません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 81b478c22cf..c858e070444 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5290,8 +5290,6 @@ "01af244097": "취소", "28c8e53176": "그러면 모든 워크스페이스에서 실행 중인 모든 terminals 패널이 강제 종료됩니다. 해당 세션에서 저장하지 않은 작업은 모두 손실됩니다. 데몬 자체는 계속 실행되며 새 terminals을 즉시 열 수 있습니다. 이 작업은 취소할 수 없습니다.", "1bbea41a77": "모든 terminal 세션을 종료하시겠습니까?", - "01d6b7c64e": "실행 중인 모든 terminal 패널을 종료하고 데몬 프로세스를 다시 시작합니다. 패널에는 \"프로세스 종료됨\"이 표시되며 즉시 다시 열 수 있습니다. 이전 앱 버전의 레거시 프로토콜 세션은 보존됩니다. 이 작업은 취소할 수 없습니다.", - "922548bc66": "terminal 데몬을 다시 시작하시겠습니까?", "2b4efdc162": "세션을 종료할 수 없습니다.", "d18f3005c2": "세션 {{value0}}개{{value1}}이(가) 종료를 거부했습니다.", "baad8cd651": "실행 중인 세션이 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a179f84e3e2..51ae4677518 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5333,8 +5333,6 @@ "01af244097": "取消", "28c8e53176": "这会强制退出所有工作区中每个正在运行的终端窗格。这些会话中所有未保存的工作都会丢失。守护进程本身保持运行,并且可以立即打开新终端。这无法撤销。", "1bbea41a77": "终止所有终端会话?", - "01d6b7c64e": "终止每个正在运行的终端窗格并重新启动守护进程。窗格显示“进程已退出”并且可以立即重新打开。先前应用程序版本的旧协议会话将被保留。这无法撤销。", - "922548bc66": "重新启动终端守护进程?", "2b4efdc162": "无法终止会话。", "d18f3005c2": "{{value0}} 会话{{value1}} 拒绝退出。", "baad8cd651": "没有正在运行的会话。", diff --git a/src/renderer/src/store/mac-folder-access-fix.ts b/src/renderer/src/store/mac-folder-access-fix.ts new file mode 100644 index 00000000000..98b7f9e4c34 --- /dev/null +++ b/src/renderer/src/store/mac-folder-access-fix.ts @@ -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 | 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 + 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()((set, get) => ({ + mismatch: null, + openScope: null, + noticePhaseByScope: new Map(), + 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') + })) +})) diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts index a3e4964cb8f..535c3454431 100644 --- a/src/renderer/src/web/preload-api/web-terminal-api.ts +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -95,7 +95,10 @@ export function createPtyApi(): NonNullable['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 }) } } } diff --git a/src/shared/daemon-adoption-telemetry.test.ts b/src/shared/daemon-adoption-telemetry.test.ts index f02aa431506..93a79f94a67 100644 --- a/src/shared/daemon-adoption-telemetry.test.ts +++ b/src/shared/daemon-adoption-telemetry.test.ts @@ -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 + ) + } + }) +}) diff --git a/src/shared/daemon-adoption-telemetry.ts b/src/shared/daemon-adoption-telemetry.ts index 72c21647cd3..9cdc31299bc 100644 --- a/src/shared/daemon-adoption-telemetry.ts +++ b/src/shared/daemon-adoption-telemetry.ts @@ -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 diff --git a/src/shared/developer-permissions-types.ts b/src/shared/developer-permissions-types.ts index c97762df028..6b541e62fef 100644 --- a/src/shared/developer-permissions-types.ts +++ b/src/shared/developer-permissions-types.ts @@ -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' diff --git a/src/shared/telemetry-daemon-event-schemas.ts b/src/shared/telemetry-daemon-event-schemas.ts index c6b2795a333..5ed87e15d1a 100644 --- a/src/shared/telemetry-daemon-event-schemas.ts +++ b/src/shared/telemetry-daemon-event-schemas.ts @@ -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. diff --git a/src/shared/telemetry-event-registry.ts b/src/shared/telemetry-event-registry.ts index 5a91640359a..068efb6f38d 100644 --- a/src/shared/telemetry-event-registry.ts +++ b/src/shared/telemetry-event-registry.ts @@ -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,