fix(daemon): detect severed macOS TCC attribution and surface daemon-restart remedy (STA-3491)

macOS pins the detached PTY daemon's TCC responsible process 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/System Events
fails with -25211 no matter what the user grants.

- record spawnerExecPath in the daemon pid file at fork
- adoption checks it: severed + 0 live sessions -> replace the daemon
  (reason severed_tcc_attribution); live sessions are preserved
- Settings (Developer Permissions + Manage Sessions) show a visible
  banner pointing at Manage Sessions -> Restart while severed
This commit is contained in:
Brennan Benson
2026-08-05 19:42:16 -07:00
parent 79896cb9a6
commit 8a2b66802f
16 changed files with 622 additions and 21 deletions
+43 -2
View File
@@ -48,6 +48,7 @@ export type ParsedDaemonPid = {
launchNonce: string | null
linuxStartTicks: string | null
bootId: string | null
spawnerExecPath: string | null
}
function canConnectSocket(socketPath: string): Promise<boolean> {
@@ -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<MacDaemonTccAttributionHealth> {
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,
+77 -2
View File
@@ -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<void> }>
getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed')
forkMock.mockImplementationOnce(() => {
const handlers: Record<string, ((arg?: unknown) => 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<void> }>
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' })
})
+44 -4
View File
@@ -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<MacDaemonTccAttributionHealth> {
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<string[] | null> {
if (!adapter) {
+2
View File
@@ -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 = {
@@ -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<number | null> {
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<string, unknown>) => void) => Promise<void>
): Promise<void> {
const child = spawnDaemonLikeProcess(socketPath, tokenPath)
try {
const startedAtMs = await getStartedAtMs(child.pid)
if (startedAtMs === null || !child.pid) {
return
}
const writePidFile = (extra: Record<string, unknown>): 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'
)
})
})
+47 -9
View File
@@ -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 })
+19 -1
View File
@@ -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',
+5
View File
@@ -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 = {
+2 -1
View File
@@ -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')
}
},
@@ -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 (
<div className="space-y-5">
<TerminalTccAttributionNotice />
<div className="flex items-start justify-between gap-4 rounded-lg border border-border/60 bg-muted/25 px-4 py-3">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
@@ -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}
>
<TerminalTccAttributionNotice showManageSessionsButton={false} />
<ManageSessionsTable
sessions={sessions}
hasLoadedOnce={hasLoadedOnce}
@@ -0,0 +1,107 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import {
MANAGE_SESSIONS_SECTION_ID,
TerminalTccAttributionNotice
} from './TerminalTccAttributionNotice'
const openSettingsTarget = vi.fn()
const openSettingsPage = vi.fn()
const setSettingsSearchQuery = vi.fn()
vi.mock('../../store', () => ({
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(<TerminalTccAttributionNotice />)
})
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(<TerminalTccAttributionNotice key="fresh" />)
})
expect(container.querySelector('[role="alert"]')).toBeNull()
})
it('navigates to Manage Sessions from the banner action', async () => {
stubAttributionHealth('severed')
await act(async () => {
root.render(<TerminalTccAttributionNotice />)
})
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(<TerminalTccAttributionNotice showManageSessionsButton={false} />)
})
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(<TerminalTccAttributionNotice />)
})
expect(container.querySelector('[role="alert"]')).toBeNull()
})
@@ -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<void> => {
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 (
<div
role="alert"
className="flex items-start justify-between gap-4 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-amber-700 dark:text-amber-300"
>
<div className="flex min-w-0 items-start gap-2.5">
<TriangleAlert className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium">
{translate(
'auto.components.settings.TerminalTccAttributionNotice.title',
'macOS permission grants aren’t reaching terminals'
)}
</p>
<p className="text-xs leading-snug">
{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.'
)}
</p>
</div>
</div>
{props.showManageSessionsButton !== false && (
<Button variant="outline" size="sm" className="shrink-0" onClick={openManageSessions}>
{translate(
'auto.components.settings.TerminalTccAttributionNotice.openManageSessions',
'Open Manage Sessions'
)}
</Button>
)}
</div>
)
}
+5
View File
@@ -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": {
+3 -1
View File
@@ -3224,7 +3224,9 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['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 })
}
}
}
+2 -1
View File
@@ -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]