mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user