diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 71bc7f3279b..b11f3fa9ed2 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -48,6 +48,7 @@ export type ParsedDaemonPid = { launchNonce: string | null linuxStartTicks: string | null bootId: string | null + spawnerExecPath: string | null } function canConnectSocket(socketPath: string): Promise { @@ -327,6 +328,7 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { launchNonce?: unknown linuxStartTicks?: unknown bootId?: unknown + spawnerExecPath?: unknown } if (typeof parsed.pid === 'number' && Number.isFinite(parsed.pid)) { return { @@ -339,7 +341,8 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { appVersion: typeof parsed.appVersion === 'string' ? parsed.appVersion : null, launchNonce: typeof parsed.launchNonce === 'string' ? parsed.launchNonce : null, linuxStartTicks: typeof parsed.linuxStartTicks === 'string' ? parsed.linuxStartTicks : null, - bootId: typeof parsed.bootId === 'string' ? parsed.bootId : null + bootId: typeof parsed.bootId === 'string' ? parsed.bootId : null, + spawnerExecPath: typeof parsed.spawnerExecPath === 'string' ? parsed.spawnerExecPath : null } } } catch { @@ -355,7 +358,8 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { appVersion: null, launchNonce: null, linuxStartTicks: null, - bootId: null + bootId: null, + spawnerExecPath: null } : null } @@ -657,6 +661,43 @@ export async function isDaemonStaleForCurrentBundle( return true } +// 'severed': macOS can no longer resolve the daemon's TCC responsible process, so +// Accessibility/Automation grants on Orca silently stop covering its terminals (STA-3491). +// 'unknown' fails open: legacy pid files and probe failures must not trigger replacement. +export type MacDaemonTccAttributionHealth = 'intact' | 'severed' | 'unknown' + +/** + * macOS pins a process's TCC "responsible process" to the binary that forked it, + * by file reference. The detached daemon outlives that app instance, and once the + * spawning binary is deleted (every packaged update replaces the bundle) tccd + * can't resolve the grant subject — `osascript`/System Events from every terminal + * hosted by that daemon is silently denied (-25211) no matter what the user grants. + */ +export async function getMacDaemonTccAttributionHealth( + runtimeDir: string, + socketPath: string, + tokenPath: string, + packagedAppVersion: string | null, + protocolVersion = PROTOCOL_VERSION +): Promise { + if (process.platform !== 'darwin') { + return 'unknown' + } + const parsedPid = await readVerifiedDaemonPid(runtimeDir, socketPath, tokenPath, protocolVersion) + if (!parsedPid) { + return 'unknown' + } + if (parsedPid.spawnerExecPath) { + return existsSync(parsedPid.spawnerExecPath) ? 'intact' : 'severed' + } + // Legacy pid files carry no spawner path. A packaged version change still proves the + // spawning bundle was replaced (the updater deletes the old binary), so flag those. + if (packagedAppVersion !== null && parsedPid.appVersion !== null) { + return parsedPid.appVersion === packagedAppVersion ? 'unknown' : 'severed' + } + return 'unknown' +} + export async function killStaleDaemon( runtimeDir: string, socketPath: string, diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 6f7de545dc4..41f9af83f4f 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -24,6 +24,7 @@ const { checkDaemonHealthMock, healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, + getMacDaemonTccAttributionHealthMock, getDaemonLaunchIdentityMock, isDaemonStaleForCurrentBundleMock, killStaleDaemonMock, @@ -83,6 +84,7 @@ const { const checkDaemonHealthMock = vi.fn(async () => 'healthy') const healthCheckDaemonMock = vi.fn(async () => true) const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy') + const getMacDaemonTccAttributionHealthMock = vi.fn(async () => 'unknown') const getDaemonLaunchIdentityMock = vi.fn(() => 'match') const isDaemonStaleForCurrentBundleMock = vi.fn(() => false) const killStaleDaemonMock = vi.fn(async () => true) @@ -165,6 +167,7 @@ const { checkDaemonHealthMock, healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, + getMacDaemonTccAttributionHealthMock, getDaemonLaunchIdentityMock, isDaemonStaleForCurrentBundleMock, killStaleDaemonMock, @@ -252,6 +255,7 @@ vi.mock('./daemon-health', () => ({ checkDaemonHealth: checkDaemonHealthMock, getDaemonLaunchIdentity: getDaemonLaunchIdentityMock, getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock, + getMacDaemonTccAttributionHealth: getMacDaemonTccAttributionHealthMock, healthCheckDaemon: healthCheckDaemonMock, isDaemonStaleForCurrentBundle: isDaemonStaleForCurrentBundleMock, killStaleDaemon: killStaleDaemonMock, @@ -413,6 +417,8 @@ async function importFresh() { healthCheckDaemonMock.mockResolvedValue(true) getMacDaemonSystemResolverHealthMock.mockReset() getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy') + getMacDaemonTccAttributionHealthMock.mockReset() + getMacDaemonTccAttributionHealthMock.mockResolvedValue('unknown') getDaemonLaunchIdentityMock.mockClear() isDaemonStaleForCurrentBundleMock.mockReset() isDaemonStaleForCurrentBundleMock.mockReturnValue(false) @@ -1244,6 +1250,73 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(trackDaemonReplacedMock).toHaveBeenCalledWith('different_app_path', 0) }) + it('replaces a healthy daemon whose macOS TCC attribution is severed when it has no live sessions', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed') + forkMock.mockImplementationOnce(() => { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + return { + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + handlers[event]?.push(cb) + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) + } + return this + }, + off(event: string, cb: (arg?: unknown) => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + } + }) + + await launcher('/fake/socket', '/fake/token') + + expect(forkMock).toHaveBeenCalledTimes(1) + // STA-3491: attribution-severed replacement is billed to its own reason, exactly once. + expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1) + expect(trackDaemonReplacedMock).toHaveBeenCalledWith('severed_tcc_attribution', 0) + }) + + it('preserves a severed-attribution daemon that owns live sessions', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed') + // Why: live sessions must veto replacement — the Settings surface owns the remedy instead. + daemonClientMock.mockImplementation(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: vi.fn(async () => ({ sessions: [{ sessionId: 's1', isAlive: true }] })), + disconnect: vi.fn() + } + }) + + const handle = await launcher('/fake/socket', '/fake/token') + + expect(handle).toBeDefined() + expect(forkMock).not.toHaveBeenCalled() + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(trackDaemonReplacedMock).not.toHaveBeenCalled() + }) + it('holds a full adoption pair before a healthy launcher resolves', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1819,7 +1892,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { bootId: 'boot-a', entryPath: FAKE_DAEMON_ENTRY_PATH, appVersion: '1.2.3', - launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/) + launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/), + spawnerExecPath: process.execPath }) expect(pidOptions).toEqual({ mode: 0o600, flag: 'wx' }) const launchArgs = forkMock.mock.calls.at(-1)?.[1] as string[] @@ -2795,7 +2869,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { startedAtMs: 1_700_000_123_456, entryPath: FAKE_DAEMON_ENTRY_PATH, appVersion: '1.2.3', - launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/) + launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/), + spawnerExecPath: process.execPath }) expect(pidOptions).toEqual({ mode: 0o600, flag: 'wx' }) }) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index de040bb8efc..71a1cfcb181 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -27,11 +27,13 @@ import { } from './types' import { getMacDaemonSystemResolverHealth, + getMacDaemonTccAttributionHealth, getDaemonLaunchIdentity, checkDaemonHealth, isDaemonStaleForCurrentBundle, killStaleDaemon, - parseDaemonPidFile + parseDaemonPidFile, + type MacDaemonTccAttributionHealth } from './daemon-health' import { collectPinnedDaemonVersions, @@ -427,8 +429,31 @@ function createOutOfProcessLauncher( confirmedReplacement = (await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)) .cleaned } else { - // Why: healthy daemon from a previous session answered a protocol ping — safe to reuse. - return preserveDaemon() + const attributionHealth = await getMacDaemonTccAttributionHealth( + runtimeDir, + socketPath, + tokenPath, + app.isPackaged ? app.getVersion() : null + ) + if (attributionHealth === 'severed') { + // Why: replacing with live sessions would kill them; Settings → Developer + // Permissions surfaces the Manage Sessions → Restart remedy instead. + const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) + if (liveSessionCount === 0) { + console.warn( + '[daemon] Replacing daemon whose macOS TCC attribution is severed (spawning app binary no longer exists)' + ) + pendingReplacement = { reason: 'severed_tcc_attribution', liveSessionCount } + confirmedReplacement = ( + await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) + ).cleaned + } else { + return preserveDaemon() + } + } else { + // Why: healthy daemon from a previous session answered a protocol ping — safe to reuse. + return preserveDaemon() + } } } } else { @@ -615,7 +640,10 @@ function createOutOfProcessLauncher( ...readyIdentity, entryPath, appVersion: app.getVersion(), - launchNonce + launchNonce, + // Why: macOS pins the daemon's TCC responsible process to this binary; + // adoption re-checks it still exists so severed grants are detectable. + spawnerExecPath: process.execPath }), { mode: 0o600, flag: 'wx' } ) @@ -832,6 +860,18 @@ export function getDaemonProvider(): DaemonProvider | null { return adapter } +// Why: computed from the pid record on demand (not cached at adoption) so the Settings +// remedy surface always reflects the daemon actually serving terminals right now. +export async function getCurrentDaemonMacTccAttributionHealth(): Promise { + const runtimeDir = getRuntimeDir() + return getMacDaemonTccAttributionHealth( + runtimeDir, + getDaemonSocketPath(runtimeDir), + getDaemonTokenPath(runtimeDir), + app.isPackaged ? app.getVersion() : null + ) +} + /** Returns null unless every daemon generation supplied an authoritative inventory. */ export async function listLiveDaemonPtyIds(): Promise { if (!adapter) { diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index 68b2a5afce4..3a571784f85 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -16,6 +16,8 @@ export type DaemonPidFile = { launchNonce?: string linuxStartTicks?: string bootId?: string + /** Forking app's binary — macOS pins the daemon's TCC responsible process to it (STA-3491). */ + spawnerExecPath?: string } export type DaemonProcessHandle = { diff --git a/src/main/daemon/daemon-tcc-attribution.test.ts b/src/main/daemon/daemon-tcc-attribution.test.ts new file mode 100644 index 00000000000..57b30c6b3f6 --- /dev/null +++ b/src/main/daemon/daemon-tcc-attribution.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { spawn } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getDaemonPidPath, serializeDaemonPidFile } from './daemon-spawner' +import { + getMacDaemonTccAttributionHealth, + getProcessStartedAtMs, + parseDaemonPidFile +} from './daemon-health' + +// Real-process harness (same shape as daemon-bundle-staleness.test.ts): the health +// check only trusts a pid record whose process is verifiably the daemon, so these +// tests spawn a daemon-shaped child instead of mocking process identity. +function spawnDaemonLikeProcess(socketPath: string, tokenPath: string) { + return spawn( + process.execPath, + [ + '-e', + 'setTimeout(() => {}, 30000)', + 'daemon-entry', + '--socket', + socketPath, + '--token', + tokenPath + ], + { stdio: 'ignore' } + ) +} + +async function getStartedAtMs(pid: number | undefined): Promise { + if (!pid) { + return null + } + await new Promise((resolve) => setTimeout(resolve, 100)) + return getProcessStartedAtMs(pid) +} + +describe('parseDaemonPidFile spawnerExecPath', () => { + it('round-trips the spawner exec path', () => { + const parsed = parseDaemonPidFile( + serializeDaemonPidFile({ + pid: 123, + startedAtMs: 1, + spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca' + }) + ) + expect(parsed?.spawnerExecPath).toBe('/Applications/Orca.app/Contents/MacOS/Orca') + }) + + it('reads legacy records without a spawner exec path as null', () => { + expect( + parseDaemonPidFile(serializeDaemonPidFile({ pid: 123, startedAtMs: 1 }))?.spawnerExecPath + ).toBeNull() + expect(parseDaemonPidFile('123')?.spawnerExecPath).toBeNull() + }) +}) + +describe('macOS daemon TCC attribution health', () => { + let dir: string + let socketPath: string + let tokenPath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-tcc-attribution-test-')) + socketPath = join(dir, 'daemon.sock') + tokenPath = join(dir, 'daemon.token') + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + async function withDaemonLikeProcess( + run: (writePidFile: (extra: Record) => void) => Promise + ): Promise { + const child = spawnDaemonLikeProcess(socketPath, tokenPath) + try { + const startedAtMs = await getStartedAtMs(child.pid) + if (startedAtMs === null || !child.pid) { + return + } + const writePidFile = (extra: Record): void => { + writeFileSync( + getDaemonPidPath(dir), + JSON.stringify({ pid: child.pid, startedAtMs, ...extra }), + { mode: 0o600 } + ) + } + await run(writePidFile) + } finally { + child.kill('SIGKILL') + } + } + + it('reports severed when the recorded spawning binary no longer exists', async () => { + if (process.platform !== 'darwin') { + return + } + await withDaemonLikeProcess(async (writePidFile) => { + writePidFile({ spawnerExecPath: join(dir, 'deleted-bundle', 'Orca') }) + expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe( + 'severed' + ) + }) + }) + + it('reports intact when the recorded spawning binary still exists', async () => { + if (process.platform !== 'darwin') { + return + } + await withDaemonLikeProcess(async (writePidFile) => { + const spawnerPath = join(dir, 'Orca') + writeFileSync(spawnerPath, '', 'utf8') + writePidFile({ spawnerExecPath: spawnerPath }) + expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe( + 'intact' + ) + }) + }) + + it('flags legacy records only on a packaged app-version change', async () => { + if (process.platform !== 'darwin') { + return + } + await withDaemonLikeProcess(async (writePidFile) => { + writePidFile({ appVersion: '1.2.2' }) + // Updater replaced the bundle since this daemon was forked → attribution is gone. + expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe( + 'severed' + ) + writePidFile({ appVersion: '1.2.3' }) + expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe( + 'unknown' + ) + // Dev builds pass null — no version heuristic, fail open. + expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, null)).toBe( + 'unknown' + ) + }) + }) + + it('fails open when no verifiable pid record exists', async () => { + if (process.platform !== 'darwin') { + return + } + expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe( + 'unknown' + ) + }) + + it('reports unknown off macOS', async () => { + if (process.platform === 'darwin') { + return + } + expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe( + 'unknown' + ) + }) +}) diff --git a/src/main/ipc/pty-management.test.ts b/src/main/ipc/pty-management.test.ts index 1586c566ffb..d1c2689000b 100644 --- a/src/main/ipc/pty-management.test.ts +++ b/src/main/ipc/pty-management.test.ts @@ -1,14 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DaemonSessionInfo } from '../daemon/types' -const { handleMock, removeHandlerMock, getDaemonProviderMock, restartDaemonMock } = vi.hoisted( - () => ({ - handleMock: vi.fn(), - removeHandlerMock: vi.fn(), - getDaemonProviderMock: vi.fn(), - restartDaemonMock: vi.fn() - }) -) +const { + handleMock, + removeHandlerMock, + getDaemonProviderMock, + restartDaemonMock, + getCurrentDaemonMacTccAttributionHealthMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + removeHandlerMock: vi.fn(), + getDaemonProviderMock: vi.fn(), + restartDaemonMock: vi.fn(), + getCurrentDaemonMacTccAttributionHealthMock: vi.fn(async () => 'unknown') +})) vi.mock('electron', () => ({ ipcMain: { handle: handleMock, removeHandler: removeHandlerMock } @@ -16,7 +21,8 @@ vi.mock('electron', () => ({ vi.mock('../daemon/daemon-init', () => ({ getDaemonProvider: getDaemonProviderMock, - restartDaemon: restartDaemonMock + restartDaemon: restartDaemonMock, + getCurrentDaemonMacTccAttributionHealth: getCurrentDaemonMacTccAttributionHealthMock })) // Why: the handler uses `provider instanceof DaemonPtyRouter` to branch @@ -140,6 +146,8 @@ describe('pty:management IPC handlers', () => { beforeEach(() => { getDaemonProviderMock.mockReset() restartDaemonMock.mockReset() + getCurrentDaemonMacTccAttributionHealthMock.mockReset() + getCurrentDaemonMacTccAttributionHealthMock.mockResolvedValue('unknown') }) afterEach(() => { @@ -456,6 +464,36 @@ describe('pty:management IPC handlers', () => { }) }) + describe('macTccAttribution', () => { + 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 + } + + expect(result.health).toBe('severed') + }) + + it('fails open to unknown when the probe throws', async () => { + getCurrentDaemonMacTccAttributionHealthMock.mockRejectedValue(new Error('no pid record')) + + const { registerDaemonManagementHandlers } = await importFresh() + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = (await handlers['pty:management:macTccAttribution']({})) as { + health: string + } + + expect(result.health).toBe('unknown') + }) + }) + describe('restart', () => { it('delegates to restartDaemon and reports success', async () => { restartDaemonMock.mockResolvedValue({ killedCount: 2 }) diff --git a/src/main/ipc/pty-management.ts b/src/main/ipc/pty-management.ts index 6a3f455aeea..16e6cfb41ac 100644 --- a/src/main/ipc/pty-management.ts +++ b/src/main/ipc/pty-management.ts @@ -2,7 +2,12 @@ import { ipcMain } from 'electron' import { DaemonPtyRouter } from '../daemon/daemon-pty-router' import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider' import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' -import { getDaemonProvider, restartDaemon } from '../daemon/daemon-init' +import { + getCurrentDaemonMacTccAttributionHealth, + getDaemonProvider, + restartDaemon +} from '../daemon/daemon-init' +import type { MacDaemonTccAttributionHealth } from '../daemon/daemon-health' 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". @@ -51,6 +56,19 @@ export function registerDaemonManagementHandlers(): void { ipcMain.removeHandler('pty:management:killAll') ipcMain.removeHandler('pty:management:killOne') ipcMain.removeHandler('pty:management:restart') + ipcMain.removeHandler('pty:management:macTccAttribution') + + // Why: lets Settings warn that macOS privacy grants no longer reach daemon terminals (STA-3491). + ipcMain.handle( + 'pty:management:macTccAttribution', + async (): Promise<{ health: MacDaemonTccAttributionHealth }> => { + try { + return { health: await getCurrentDaemonMacTccAttributionHealth() } + } catch { + return { health: 'unknown' } + } + } + ) ipcMain.handle( 'pty:management:listSessions', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 53bdb0e7349..3a3137163cf 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -759,6 +759,10 @@ export type PtyManagementSession = { protocolVersion: number } +// 'severed': macOS can no longer attribute daemon terminals to Orca, so Accessibility/ +// Automation grants silently stop applying until the daemon is restarted (STA-3491). +export type PtyManagementMacTccAttributionHealth = 'intact' | 'severed' | 'unknown' + 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 }> @@ -769,6 +773,7 @@ export type PtyManagementApi = { }> killOne: (args: { sessionId: string }) => Promise<{ success: boolean }> restart: () => Promise<{ success: boolean }> + macTccAttribution: () => Promise<{ health: PtyManagementMacTccAttributionHealth }> } export type ExportApi = { diff --git a/src/preload/index.ts b/src/preload/index.ts index cf602b3f1cc..e543961a589 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1251,7 +1251,8 @@ const api = { listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), killAll: () => ipcRenderer.invoke('pty:management:killAll'), killOne: (args: { sessionId: string }) => ipcRenderer.invoke('pty:management:killOne', args), - restart: () => ipcRenderer.invoke('pty:management:restart') + restart: () => ipcRenderer.invoke('pty:management:restart'), + macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution') } }, diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx index 31576f0e1ce..1924d91a81e 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx @@ -24,6 +24,7 @@ import { developerPermissionStatusClass, developerPermissionStatusLabel } from './developer-permission-status' +import { TerminalTccAttributionNotice } from './TerminalTccAttributionNotice' export { getDeveloperPermissionsPaneSearchEntries } from './developer-permissions-search' type DeveloperPermissionsPaneProps = { @@ -329,6 +330,7 @@ export function DeveloperPermissionsPane({ return (
+
diff --git a/src/renderer/src/components/settings/ManageSessionsSection.tsx b/src/renderer/src/components/settings/ManageSessionsSection.tsx index d0f39bbeec6..ca6c8e39bf1 100644 --- a/src/renderer/src/components/settings/ManageSessionsSection.tsx +++ b/src/renderer/src/components/settings/ManageSessionsSection.tsx @@ -10,6 +10,10 @@ import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions import { ManageSessionKillDialog } from './ManageSessionKillDialog' import { ManageSessionsTable } from './ManageSessionsTable' import { notifyDaemonSessionInventoryInvalidated } from '../status-bar/daemon-session-inventory-invalidation' +import { + MANAGE_SESSIONS_SECTION_ID, + TerminalTccAttributionNotice +} from './TerminalTccAttributionNotice' import { translate } from '@/i18n/i18n' type ConfirmKind = 'killOne' @@ -206,7 +210,9 @@ export function ManageSessionsSection(): React.JSX.Element { description={getManageSessionsSearchEntries()[0].description} keywords={getManageSessionsSearchEntries()[0].keywords} className="space-y-3" + id={MANAGE_SESSIONS_SECTION_ID} > + ({ + useAppStore: ( + selector: (state: { + openSettingsTarget: typeof openSettingsTarget + openSettingsPage: typeof openSettingsPage + setSettingsSearchQuery: typeof setSettingsSearchQuery + }) => unknown + ) => selector({ openSettingsTarget, openSettingsPage, setSettingsSearchQuery }) +})) + +let container: HTMLDivElement +let root: Root + +function stubAttributionHealth(health: 'intact' | 'severed' | 'unknown'): void { + Object.assign(window, { + api: { + pty: { + management: { + macTccAttribution: vi.fn(async () => ({ health })) + } + } + } + }) +} + +beforeEach(() => { + openSettingsTarget.mockClear() + openSettingsPage.mockClear() + setSettingsSearchQuery.mockClear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + Reflect.deleteProperty(window, 'api') +}) + +it('renders the remedy banner only while attribution is severed', async () => { + stubAttributionHealth('severed') + await act(async () => { + root.render() + }) + const alert = container.querySelector('[role="alert"]') + expect(alert?.textContent).toContain('macOS permission grants aren’t reaching terminals') + expect(alert?.textContent).toContain('-25211') + + stubAttributionHealth('intact') + await act(async () => { + root.render() + }) + expect(container.querySelector('[role="alert"]')).toBeNull() +}) + +it('navigates to Manage Sessions from the banner action', async () => { + stubAttributionHealth('severed') + await act(async () => { + root.render() + }) + + const button = container.querySelector('button') + expect(button?.textContent).toContain('Open Manage Sessions') + await act(async () => { + button?.click() + }) + + expect(setSettingsSearchQuery).toHaveBeenCalledWith('') + expect(openSettingsTarget).toHaveBeenCalledWith({ + pane: 'terminal', + repoId: null, + sectionId: MANAGE_SESSIONS_SECTION_ID + }) + expect(openSettingsPage).toHaveBeenCalled() +}) + +it('hides the navigation button on the Manage Sessions surface itself', async () => { + stubAttributionHealth('severed') + await act(async () => { + root.render() + }) + expect(container.querySelector('[role="alert"]')).not.toBeNull() + expect(container.querySelector('button')).toBeNull() +}) + +it('fails closed when the attribution probe is unavailable', async () => { + Object.assign(window, { api: { pty: {} } }) + await act(async () => { + root.render() + }) + expect(container.querySelector('[role="alert"]')).toBeNull() +}) diff --git a/src/renderer/src/components/settings/TerminalTccAttributionNotice.tsx b/src/renderer/src/components/settings/TerminalTccAttributionNotice.tsx new file mode 100644 index 00000000000..581c79fa97e --- /dev/null +++ b/src/renderer/src/components/settings/TerminalTccAttributionNotice.tsx @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useState } from 'react' +import { TriangleAlert } from 'lucide-react' +import { Button } from '../ui/button' +import { useAppStore } from '../../store' +import { translate } from '@/i18n/i18n' + +export const MANAGE_SESSIONS_SECTION_ID = 'terminal-manage-sessions' + +/** + * Why this exists: macOS pins the TCC "responsible process" of the detached terminal + * daemon to the app binary that forked it. Once that binary is deleted (packaged + * updates replace the bundle), Accessibility/Automation grants on Orca silently stop + * covering every daemon-hosted terminal (osascript -25211) with no OS-side signal — + * so the remedy has to be surfaced here, next to the permissions it breaks (STA-3491). + */ +export function useMacTccAttributionSevered(): boolean { + const [severed, setSevered] = useState(false) + + const refresh = useCallback(async (): Promise => { + try { + const { health } = await window.api.pty.management.macTccAttribution() + setSevered(health === 'severed') + } catch { + setSevered(false) + } + }, []) + + useEffect(() => { + void refresh() + // Why: a daemon restart or drain changes the verdict without a pane remount. + const onFocus = (): void => { + void refresh() + } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [refresh]) + + return severed +} + +export function TerminalTccAttributionNotice(props: { + /** The Manage Sessions surface hosts the fix itself, so it hides the navigation button. */ + showManageSessionsButton?: boolean +}): React.JSX.Element | null { + const severed = useMacTccAttributionSevered() + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const setSettingsSearchQuery = useAppStore((s) => s.setSettingsSearchQuery) + + if (!severed) { + return null + } + + const openManageSessions = (): void => { + // Why: a stale Settings search would hide the Manage Sessions section this points at. + setSettingsSearchQuery('') + openSettingsTarget({ + pane: 'terminal', + repoId: null, + sectionId: MANAGE_SESSIONS_SECTION_ID + }) + openSettingsPage() + } + + return ( +
+
+ +
+

+ {translate( + 'auto.components.settings.TerminalTccAttributionNotice.title', + 'macOS permission grants aren’t reaching terminals' + )} +

+

+ {translate( + 'auto.components.settings.TerminalTccAttributionNotice.body', + 'The terminal daemon was started by an Orca install that no longer exists, so macOS can’t attribute its commands to Orca — Accessibility and Automation grants are silently ignored (osascript fails with error -25211). Restarting the daemon fixes this; running terminal sessions will close.' + )} +

+
+
+ {props.showManageSessionsButton !== false && ( + + )} +
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3f940076736..f93793d8855 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10288,6 +10288,11 @@ "description": "Input device used for voice dictation. System default follows the OS microphone setting.", "accessHint": "Allow microphone access to list input devices.", "allowAccess": "Allow access" + }, + "TerminalTccAttributionNotice": { + "body": "The terminal daemon was started by an Orca install that no longer exists, so macOS can’t attribute its commands to Orca — Accessibility and Automation grants are silently ignored (osascript fails with error -25211). Restarting the daemon fixes this; running terminal sessions will close.", + "openManageSessions": "Open Manage Sessions", + "title": "macOS permission grants aren’t reaching terminals" } }, "right": { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 64239245158..131be65a6df 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -3224,7 +3224,9 @@ function createPtyApi(): NonNullable['pty']> { listSessions: () => Promise.resolve({ sessions: [], degraded: false }), killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }), killOne: () => Promise.resolve({ success: false }), - restart: () => 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 }) } } } diff --git a/src/shared/daemon-lifecycle-telemetry.ts b/src/shared/daemon-lifecycle-telemetry.ts index 1ec13c985f6..61ecdf3f665 100644 --- a/src/shared/daemon-lifecycle-telemetry.ts +++ b/src/shared/daemon-lifecycle-telemetry.ts @@ -8,7 +8,8 @@ export const DAEMON_REPLACE_REASONS = [ 'unhealthy_resolver', 'stale_bundle', 'different_app_path', - 'failed_health_check' + 'failed_health_check', + 'severed_tcc_attribution' ] as const export type DaemonReplaceReason = (typeof DAEMON_REPLACE_REASONS)[number]