fix(macos): recover severed terminal TCC attribution after updates (#13992)

* fix(macos): recover severed terminal TCC attribution after updates

When a packaged update leaves the daemon healthy but TCC-severed (spawning
binary gone), surface a Manage Sessions toast and replace the daemon before a
new terminal only when zero live sessions remain. Does not broaden FDA or
auto-kill sessions. Addresses the Orca-specific path of #13594.

* fix(macos): coalesce severed-TCC toast checks and sync i18n keys

Add catalog entries for the Manage Sessions toast strings and guard overlapping
mount/focus probes with an in-flight latch so only one infinite toast can fire.

* test(macos): harden severed TCC recovery coverage

* fix(macos): clear recovered TCC warning

* fix(macos): clarify severed TCC warning copy

* fix(macos): bound TCC attribution health checks

* fix(macos): preserve bounded attribution checks after merge

* test(macos): use real execFile callback contract

* fix(macos): cover legacy daemon attribution

* fix(macos): clarify affected Orca terminals

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-08-13 16:30:44 -07:00
committed by GitHub
co-authored by Brennan Benson
parent 77b37d85e2
commit e1ee1b3ef3
10 changed files with 770 additions and 33 deletions
+108 -21
View File
@@ -29,6 +29,7 @@ import {
} from './types'
const HEALTH_CHECK_TIMEOUT_MS = 3_000
const PS_IDENTITY_TIMEOUT_MS = 2_000
const RESOLVER_HEALTH_CHECK_TIMEOUT_MS = 3_000
const KILL_WAIT_MS = 3_000
const KILL_POLL_MS = 100
@@ -438,19 +439,47 @@ type PsProcessIdentity = {
startedAtMs: number | null
}
function parsePsProcessIdentity(output: string): PsProcessIdentity {
// BSD ps formats lstart as a fixed-width 24-character timestamp.
const startedAtMs = Date.parse(output.slice(0, 24))
return {
commandLine: output.slice(24).trim(),
startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : null
}
}
function getPsProcessIdentity(pid: number): PsProcessIdentity | null {
try {
const output = execFileSync('ps', ['-p', String(pid), '-o', 'lstart=', '-o', 'command='], {
encoding: 'utf8',
timeout: 2_000
})
// BSD ps formats lstart as a fixed-width 24-character timestamp.
const startedAtMs = Date.parse(output.slice(0, 24))
const commandLine = output.slice(24).trim()
return {
commandLine,
startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : null
}
return parsePsProcessIdentity(output)
} catch {
return null
}
}
async function getPsProcessIdentityAsync(pid: number): Promise<PsProcessIdentity | null> {
try {
const stdout = await new Promise<string>((resolve, reject) => {
execFile(
'ps',
['-p', String(pid), '-o', 'lstart=', '-o', 'command='],
{
encoding: 'utf8',
timeout: PS_IDENTITY_TIMEOUT_MS
},
(error, output) => {
if (error) {
reject(error)
return
}
resolve(output)
}
)
})
return parsePsProcessIdentity(stdout)
} catch {
return null
}
@@ -560,7 +589,7 @@ async function inspectDaemonProcessIdentity(
commandLineMatchesDaemon(cmdline, socketPath, tokenPath) && startTimeMatches(pid, startedAtMs)
)
} catch {
const identity = getPsProcessIdentity(pid)
const identity = await getPsProcessIdentityAsync(pid)
if (!identity) {
return 'unknown'
}
@@ -579,7 +608,7 @@ async function getDaemonCommandLine(pid: number): Promise<string | null> {
try {
return readFileSync(`/proc/${pid}/cmdline`, 'utf8')
} catch {
return getPsProcessIdentity(pid)?.commandLine ?? null
return (await getPsProcessIdentityAsync(pid))?.commandLine ?? null
}
}
@@ -669,6 +698,38 @@ export async function isDaemonStaleForCurrentBundle(
// 'unknown' fails open: legacy pid files and probe failures must not trigger replacement.
export type MacDaemonTccAttributionHealth = 'intact' | 'severed' | 'unknown'
let cachedMacDaemonTccAttributionHealth: {
key: string
pending: Promise<MacDaemonTccAttributionHealth>
} | null = null
function getMacDaemonTccAttributionCacheKey(
runtimeDir: string,
socketPath: string,
tokenPath: string,
packagedAppVersion: string | null,
protocolVersion: number
): string | null {
try {
const pidRecord = readFileSync(getDaemonPidPath(runtimeDir, protocolVersion), 'utf8')
const parsedPid = parseDaemonPidFile(pidRecord)
if (!parsedPid) {
return null
}
const spawnerExists = parsedPid.spawnerExecPath ? existsSync(parsedPid.spawnerExecPath) : null
return JSON.stringify([
socketPath,
tokenPath,
packagedAppVersion,
protocolVersion,
pidRecord,
spawnerExists
])
} catch {
return null
}
}
/**
* 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
@@ -686,23 +747,49 @@ export async function getMacDaemonTccAttributionHealth(
if (process.platform !== 'darwin') {
return 'unknown'
}
const parsedPid = await readVerifiedDaemonPid(runtimeDir, socketPath, tokenPath, protocolVersion)
if (!parsedPid) {
const cacheKey = getMacDaemonTccAttributionCacheKey(
runtimeDir,
socketPath,
tokenPath,
packagedAppVersion,
protocolVersion
)
if (cacheKey && cachedMacDaemonTccAttributionHealth?.key === cacheKey) {
return await cachedMacDaemonTccAttributionHealth.pending
}
const pending = (async (): Promise<MacDaemonTccAttributionHealth> => {
const parsedPid = await readVerifiedDaemonPid(
runtimeDir,
socketPath,
tokenPath,
protocolVersion
)
if (!parsedPid) {
return 'unknown'
}
// Packaged updates can replace the bundle at the same path; missing version
// metadata also identifies a daemon from before the current packaged generation.
if (packagedAppVersion !== null && parsedPid.appVersion !== packagedAppVersion) {
return 'severed'
}
if (parsedPid.spawnerExecPath) {
return existsSync(parsedPid.spawnerExecPath) ? 'intact' : 'severed'
}
return 'unknown'
})()
if (cacheKey) {
cachedMacDaemonTccAttributionHealth = { key: cacheKey, pending }
}
// Packaged updates can replace the bundle at the same path, so path existence
// alone cannot prove the recorded spawning binary still backs this daemon.
const health = await pending
if (
packagedAppVersion !== null &&
parsedPid.appVersion !== null &&
parsedPid.appVersion !== packagedAppVersion
health === 'unknown' &&
cachedMacDaemonTccAttributionHealth?.key === cacheKey &&
cachedMacDaemonTccAttributionHealth.pending === pending
) {
return 'severed'
cachedMacDaemonTccAttributionHealth = null
}
if (parsedPid.spawnerExecPath) {
return existsSync(parsedPid.spawnerExecPath) ? 'intact' : 'severed'
}
return 'unknown'
return health
}
function isNoSuchProcessError(error: unknown): boolean {
+13 -5
View File
@@ -949,7 +949,9 @@ export async function initDaemonPtyProvider(
socketPath: info.socketPath,
tokenPath: info.tokenPath,
pidPath: getDaemonPidPath(runtimeDir),
profileScope: runtimeDir
profileScope: runtimeDir,
runtimeDir,
packagedAppVersion: app.isPackaged ? app.getVersion() : null
})
releaseDaemonAdoptionLease(newSpawner.getHandle())
await abortedStartupAdapter.disconnectOnly()
@@ -961,6 +963,8 @@ export async function initDaemonPtyProvider(
tokenPath: info.tokenPath,
pidPath: getDaemonPidPath(runtimeDir),
profileScope: runtimeDir,
runtimeDir,
packagedAppVersion: app.isPackaged ? app.getVersion() : null,
historyPath: getHistoryDir(),
// Why: on daemon death, ensureConnected() detects the dead socket and calls this to fork a replacement before retrying.
respawn: async (reason: DaemonRespawnReason) => {
@@ -975,9 +979,9 @@ export async function initDaemonPtyProvider(
if (!restartInFlight) {
trackDaemonRetired('died_respawn')
}
} else if (reason === 'unhealthy_resolver') {
} else if (reason === 'unhealthy_resolver' || reason === 'severed_tcc_attribution') {
// Must reach the launcher below without an await in between; see the consume site.
attributedReplaceReason = 'unhealthy_resolver'
attributedReplaceReason = reason
}
newSpawner.resetHandle()
await newSpawner.ensureRunning()
@@ -1196,6 +1200,8 @@ async function runRestartDaemon(): Promise<RestartDaemonResult> {
tokenPath: info.tokenPath,
pidPath: getDaemonPidPath(runtimeDir),
profileScope: runtimeDir,
runtimeDir,
packagedAppVersion: app.isPackaged ? app.getVersion() : null,
historyPath: getHistoryDir(),
respawn: async (reason: DaemonRespawnReason) => {
// Why: attribute rather than emit — the launcher below is the one that completes the
@@ -1209,9 +1215,9 @@ async function runRestartDaemon(): Promise<RestartDaemonResult> {
if (!restartInFlight) {
trackDaemonRetired('died_respawn')
}
} else if (reason === 'unhealthy_resolver') {
} else if (reason === 'unhealthy_resolver' || reason === 'severed_tcc_attribution') {
// Must reach the launcher below without an await in between; see the consume site.
attributedReplaceReason = 'unhealthy_resolver'
attributedReplaceReason = reason
}
currentSpawner.resetHandle()
await currentSpawner.ensureRunning()
@@ -1414,6 +1420,8 @@ export async function createLegacyDaemonAdapters(
tokenPath,
pidPath: getDaemonPidPath(runtimeDir, protocolVersion),
profileScope: runtimeDir,
runtimeDir,
packagedAppVersion: app.isPackaged ? app.getVersion() : null,
protocolVersion,
historyPath
})
+109 -4
View File
@@ -24,9 +24,16 @@ import { getDaemonSocketPath, serializeDaemonPidFile } from './daemon-spawner'
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks'
const { getMacDaemonSystemResolverHealthMock } = vi.hoisted(() => ({
getMacDaemonSystemResolverHealthMock: vi.fn(async () => 'unknown')
}))
const { getMacDaemonSystemResolverHealthMock, getMacDaemonTccAttributionHealthMock } = vi.hoisted(
() => ({
getMacDaemonSystemResolverHealthMock: vi.fn(
async (): Promise<'unknown' | 'unhealthy'> => 'unknown'
),
getMacDaemonTccAttributionHealthMock: vi.fn(
async (): Promise<'intact' | 'severed' | 'unknown'> => 'unknown'
)
})
)
const itOnPosix = process.platform === 'win32' ? it.skip : it
@@ -34,7 +41,8 @@ vi.mock('./daemon-health', async (importOriginal) => {
const actual = await importOriginal<typeof DaemonHealthModule>()
return {
...actual,
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock,
getMacDaemonTccAttributionHealth: getMacDaemonTccAttributionHealthMock
}
})
@@ -137,6 +145,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
lastSpawnOpts = null
getMacDaemonSystemResolverHealthMock.mockReset()
getMacDaemonSystemResolverHealthMock.mockResolvedValue('unknown')
getMacDaemonTccAttributionHealthMock.mockReset()
getMacDaemonTccAttributionHealthMock.mockResolvedValue('unknown')
})
it('reports whether its daemon protocol can participate in agent claims', () => {
@@ -3925,6 +3935,101 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
respawnAdapter.dispose()
})
it('preserves a severed-TCC daemon that still owns live sessions before a new spawn', async () => {
const respawnFn = vi.fn()
const respawnAdapter = new DaemonPtyAdapter({
socketPath,
tokenPath,
runtimeDir: dir,
packagedAppVersion: '1.4.178',
respawn: respawnFn
})
// One live session in this adapter — the zero-session gate must fail closed.
await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true })
getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed')
const next = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true })
expect(getMacDaemonTccAttributionHealthMock).toHaveBeenCalledWith(
dir,
socketPath,
tokenPath,
'1.4.178',
respawnAdapter.protocolVersion
)
expect(respawnFn).not.toHaveBeenCalled()
expect(next.id).toBeDefined()
respawnAdapter.dispose()
})
it('preserves a severed-TCC daemon when its live session inventory is unavailable', async () => {
const respawnFn = vi.fn()
const respawnAdapter = new DaemonPtyAdapter({
socketPath,
tokenPath,
runtimeDir: dir,
packagedAppVersion: '1.4.178',
respawn: respawnFn
})
const internals = respawnAdapter as unknown as {
client: { request: (type: string, payload?: unknown) => Promise<unknown> }
}
const originalRequest = internals.client.request.bind(internals.client)
vi.spyOn(internals.client, 'request').mockImplementation((type, payload) => {
if (type === 'listSessions') {
return Promise.reject(new Error('inventory unavailable'))
}
return originalRequest(type, payload)
})
getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed')
const next = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true })
expect(respawnFn).not.toHaveBeenCalled()
expect(next.id).toBeDefined()
respawnAdapter.dispose()
})
it('replaces a severed-TCC daemon before a fresh session when no sessions are active', async () => {
let respawnServer: DaemonServer | undefined
const respawnFn = vi.fn(async () => {
await server.shutdown()
rmSync(socketPath, { force: true })
respawnServer = new DaemonServer({
socketPath,
tokenPath,
spawnSubprocess: () => createMockSubprocess()
})
await respawnServer.start()
})
const respawnAdapter = new DaemonPtyAdapter({
socketPath,
tokenPath,
runtimeDir: dir,
packagedAppVersion: '1.4.178',
respawn: respawnFn
})
getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed')
const replacement = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true })
expect(getMacDaemonTccAttributionHealthMock).toHaveBeenCalledWith(
dir,
socketPath,
tokenPath,
'1.4.178',
respawnAdapter.protocolVersion
)
expect(respawnFn).toHaveBeenCalledTimes(1)
expect(respawnFn).toHaveBeenCalledWith('severed_tcc_attribution')
expect(replacement.id).toBeDefined()
respawnAdapter.dispose()
await respawnServer?.shutdown()
})
it('propagates respawn failure to the caller', async () => {
const respawnFn = vi.fn(async () => {
throw new Error('Daemon entry file missing')
+52 -1
View File
@@ -6,6 +6,7 @@ import { DaemonClient } from './client'
import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership'
import {
getMacDaemonSystemResolverHealth,
getMacDaemonTccAttributionHealth,
parseDaemonPidFile,
type ParsedDaemonPid
} from './daemon-health'
@@ -138,11 +139,15 @@ export type DaemonPtyAdapterOptions = {
protocolVersion?: number
/** Directory for disk-based terminal history; when set, raw PTY output is written to disk for cold restore on daemon crash. */
historyPath?: string
/** Runtime profile directory used to verify daemon TCC attribution. */
runtimeDir?: string
/** Current packaged version, or null for unpackaged builds. */
packagedAppVersion?: string | null
/** Forks a fresh daemon after endpoint death or a confirmed resolver-health replacement. */
respawn?: (reason: DaemonRespawnReason) => Promise<void | (() => void)>
}
export type DaemonRespawnReason = 'daemon_died' | 'unhealthy_resolver'
export type DaemonRespawnReason = 'daemon_died' | 'unhealthy_resolver' | 'severed_tcc_attribution'
export type DaemonIdentityChangeEvent = {
previous: DaemonEndpointIdentity
@@ -199,6 +204,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
private historyManager: HistoryManager | null
private historyReader: HistoryReader | null
private respawnFn: DaemonPtyAdapterOptions['respawn'] | null
private runtimeDir: string | null
private packagedAppVersion: string | null
private pendingRespawnAdoptionRelease: (() => void) | null = null
private respawnAdoptionClosed = false
// Why: concurrent spawn() calls hitting a dead daemon would each fork their own; this promise coalesces respawns so only the first forks and the rest await it.
@@ -311,6 +318,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null
this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null
this.respawnFn = opts.respawn ?? null
this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null
this.packagedAppVersion = opts.packagedAppVersion === undefined ? null : opts.packagedAppVersion
this.supportsCheckpoints = this.protocolVersion >= 4
this.supportsIncrementalCheckpoints = this.protocolVersion >= 13
this.supportsProducerFlowControl = this.protocolVersion >= 19
@@ -482,6 +491,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (opts.isNewSession) {
await this.replaceUnhealthyMacResolverDaemonBeforeNewPty()
await this.replaceSeveredMacTccDaemonBeforeNewPty()
}
await this.ensureConnected()
@@ -2497,6 +2507,47 @@ export class DaemonPtyAdapter implements IPtyProvider {
await this.respawnPromise
}
/** Replace a TCC-severed daemon only after its live sessions drain. */
private async replaceSeveredMacTccDaemonBeforeNewPty(): Promise<void> {
// Why no platform gate: getMacDaemonTccAttributionHealth returns 'unknown' off macOS.
if (!this.respawnFn || !this.runtimeDir) {
return
}
const health = await getMacDaemonTccAttributionHealth(
this.runtimeDir,
this.socketPath,
this.tokenPath,
this.packagedAppVersion,
this.protocolVersion
)
if (health !== 'severed') {
return
}
const daemonLiveSessionCount = await this.getDaemonLiveSessionCount()
const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0)
if (daemonLiveSessionCount === null || liveSessionCount > 0) {
console.warn(
daemonLiveSessionCount === null
? '[daemon] macOS TCC attribution severed - preserving daemon because live session state could not be verified'
: `[daemon] macOS TCC attribution severed - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}; restart from Manage Sessions when ready`
)
return
}
this.fanoutSyntheticExits(-1)
if (!this.respawnPromise) {
this.respawnPromise = this.doRespawn(
'[daemon] macOS TCC attribution severed - respawning daemon under the current app binary',
'severed_tcc_attribution'
).finally(() => {
this.respawnPromise = null
})
}
await this.respawnPromise
}
private async getDaemonLiveSessionCount(): Promise<number | null> {
try {
await this.client.ensureConnected()
@@ -0,0 +1,154 @@
import type * as ChildProcessModule from 'node:child_process'
import type * as FsModule from 'node:fs'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from './daemon-protocol-version'
const PS_START = 'Thu Aug 13 12:34:56 2026'
const PS_STARTED_AT_MS = Date.parse(PS_START)
const { execFileMock, execFileSyncMock, psCommandLine, psError } = vi.hoisted(() => ({
execFileMock: vi.fn(
(
_file: string,
_args: readonly string[],
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void
) => callback(psError.value, psError.value ? '' : `${PS_START} ${psCommandLine.value}\n`, '')
),
execFileSyncMock: vi.fn(() => ''),
psCommandLine: { value: '' },
psError: { value: null as Error | null }
}))
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal<typeof ChildProcessModule>()),
execFile: execFileMock,
execFileSync: execFileSyncMock
}))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof FsModule>()
return {
...actual,
readFileSync: ((path, options) => {
if (String(path) === `/proc/${process.pid}/cmdline`) {
throw new Error('procfs unavailable on macOS')
}
return actual.readFileSync(path, options)
}) as typeof actual.readFileSync
}
})
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
const { getMacDaemonTccAttributionHealth } = await import('./daemon-health')
describe('macOS daemon TCC attribution main-thread cost', () => {
let dir: string
let socketPath: string
let tokenPath: string
let spawnerExecPath: string
beforeAll(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
})
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-tcc-main-thread-test-'))
socketPath = join(dir, 'daemon.sock')
tokenPath = join(dir, 'daemon.token')
spawnerExecPath = join(dir, 'Orca')
writeFileSync(spawnerExecPath, '')
psCommandLine.value = `node daemon-entry --socket ${socketPath} --token ${tokenPath}`
psError.value = null
execFileMock.mockClear()
execFileSyncMock.mockClear()
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
afterAll(() => {
if (platformDescriptor) {
Object.defineProperty(process, 'platform', platformDescriptor)
}
})
function writePidRecord(appVersion?: string): void {
writeFileSync(
join(dir, `daemon-v${PROTOCOL_VERSION}.pid`),
JSON.stringify({
pid: process.pid,
startedAtMs: PS_STARTED_AT_MS,
launchNonce: 'launch-a',
...(appVersion === undefined ? {} : { appVersion }),
spawnerExecPath
})
)
}
it('deduplicates identity inspection by daemon generation without a synchronous spawn', async () => {
writePidRecord('1.2.2')
await expect(
Promise.all([
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3'),
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')
])
).resolves.toEqual(['severed', 'severed'])
await expect(
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')
).resolves.toBe('severed')
expect(execFileSyncMock).not.toHaveBeenCalled()
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(execFileMock).toHaveBeenCalledWith(
'ps',
['-p', String(process.pid), '-o', 'lstart=', '-o', 'command='],
expect.anything(),
expect.any(Function)
)
writePidRecord('1.2.3')
await expect(
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')
).resolves.toBe('intact')
expect(execFileMock).toHaveBeenCalledTimes(2)
rmSync(spawnerExecPath)
await expect(
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')
).resolves.toBe('severed')
expect(execFileMock).toHaveBeenCalledTimes(3)
})
it('retries an indeterminate identity inspection', async () => {
writePidRecord('1.2.2')
psError.value = new Error('ps unavailable')
await expect(
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')
).resolves.toBe('unknown')
psError.value = null
await expect(
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')
).resolves.toBe('severed')
expect(execFileSyncMock).not.toHaveBeenCalled()
expect(execFileMock).toHaveBeenCalledTimes(2)
})
it('treats a packaged legacy pid record as a previous app generation', async () => {
writePidRecord()
await expect(
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')
).resolves.toBe('severed')
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(execFileSyncMock).not.toHaveBeenCalled()
})
})
@@ -113,7 +113,7 @@ describe('macOS daemon TCC attribution health', () => {
await withDaemonLikeProcess(async (writePidFile) => {
const spawnerPath = join(dir, 'Orca')
writeFileSync(spawnerPath, '', 'utf8')
writePidFile({ spawnerExecPath: spawnerPath })
writePidFile({ spawnerExecPath: spawnerPath, appVersion: '1.2.3' })
expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe(
'intact'
)
@@ -134,11 +134,15 @@ describe('macOS daemon TCC attribution health', () => {
})
})
it('flags legacy records only on a packaged app-version change', async () => {
it('flags missing or changed app-version metadata only for packaged builds', async () => {
if (process.platform !== 'darwin') {
return
}
await withDaemonLikeProcess(async (writePidFile) => {
writePidFile({})
expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe(
'severed'
)
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(
@@ -1,6 +1,9 @@
import { useMacosTccPromptNotice } from './useMacosTccPromptNotice'
import { useMacTccAttributionSeveredNotice } from './useMacTccAttributionSeveredNotice'
export function MacosTccPromptNoticeHost(): null {
useMacosTccPromptNotice()
// Why: severed daemon attribution only showed in Settings (#13594); toast the remedy at launch/focus.
useMacTccAttributionSeveredNotice()
return null
}
@@ -0,0 +1,206 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { toast } from 'sonner'
import { MacosTccPromptNoticeHost } from './MacosTccPromptNoticeHost'
const macTccAttribution = vi.hoisted(() =>
vi.fn(async (): Promise<{ health: 'intact' | 'severed' | 'unknown' }> => ({ health: 'intact' }))
)
const openSettingsPage = vi.hoisted(() => vi.fn())
const openSettingsTarget = vi.hoisted(() => vi.fn())
const setSettingsSearchQuery = vi.hoisted(() => vi.fn())
const platform = vi.hoisted(() => ({ value: 'darwin' as NodeJS.Platform }))
vi.mock('sonner', () => ({
toast: {
warning: vi.fn(),
dismiss: vi.fn()
}
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
i18n: {
language: 'en',
hasResourceBundle: () => true
}
})
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (s: Record<string, unknown>) => unknown) =>
selector({
openSettingsPage,
openSettingsTarget,
setSettingsSearchQuery,
settings: { uiLanguage: 'en' }
})
}))
vi.mock('@/store/plugin-language-packs', () => ({
usePluginLanguagePackStore: (selector: (s: Record<string, unknown>) => unknown) =>
selector({ packs: [], loaded: true })
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('./useMacosTccPromptNotice', () => ({
useMacosTccPromptNotice: vi.fn()
}))
describe('useMacTccAttributionSeveredNotice', () => {
beforeEach(() => {
macTccAttribution.mockReset()
macTccAttribution.mockResolvedValue({ health: 'intact' })
openSettingsPage.mockReset()
openSettingsTarget.mockReset()
setSettingsSearchQuery.mockReset()
platform.value = 'darwin'
vi.mocked(toast.warning).mockReset()
vi.mocked(toast.dismiss).mockReset()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
platform: {
get: () => ({ platform: platform.value })
},
pty: {
management: {
macTccAttribution
}
}
}
})
})
afterEach(() => {
cleanup()
})
it('does not toast when attribution is intact', async () => {
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(macTccAttribution).toHaveBeenCalled()
})
expect(toast.warning).not.toHaveBeenCalled()
})
it('does not probe on non-macOS focus', async () => {
platform.value = 'win32'
render(<MacosTccPromptNoticeHost />)
act(() => {
window.dispatchEvent(new Event('focus'))
})
expect(macTccAttribution).not.toHaveBeenCalled()
})
it('toasts Manage Sessions remedy once when attribution is severed', async () => {
macTccAttribution.mockResolvedValue({ health: 'severed' })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(toast.warning).toHaveBeenCalledTimes(1)
})
const call = vi.mocked(toast.warning).mock.calls[0]
const title = String(call?.[0] ?? '')
const options = call?.[1] as
| { description?: string; action?: { onClick?: () => void } }
| undefined
expect(title).toMatch(/macOS permissions may not reach Orca terminals/i)
expect(String(options?.description ?? '')).toMatch(/Manage Sessions/i)
options?.action?.onClick?.()
expect(setSettingsSearchQuery).toHaveBeenCalledWith('')
expect(openSettingsTarget).toHaveBeenCalledWith({
pane: 'terminal',
repoId: null,
sectionId: 'terminal-manage-sessions'
})
expect(openSettingsPage).toHaveBeenCalled()
})
it('does not toast again after the first severed notice this session', async () => {
macTccAttribution.mockResolvedValue({ health: 'severed' })
const { rerender } = render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(toast.warning).toHaveBeenCalledTimes(1)
})
rerender(<MacosTccPromptNoticeHost />)
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(macTccAttribution).toHaveBeenCalledTimes(2)
expect(toast.warning).toHaveBeenCalledTimes(1)
})
})
it('dismisses the warning after attribution recovers', async () => {
macTccAttribution.mockResolvedValueOnce({ health: 'severed' })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(toast.warning).toHaveBeenCalledTimes(1)
})
macTccAttribution.mockResolvedValue({ health: 'intact' })
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(macTccAttribution).toHaveBeenCalledTimes(2)
expect(toast.dismiss).toHaveBeenCalledWith('mac-tcc-attribution-severed')
})
})
it('coalesces overlapping mount/focus checks into one IPC call and one toast', async () => {
let resolveHealth!: (value: { health: 'severed' }) => void
const pending = new Promise<{ health: 'severed' }>((resolve) => {
resolveHealth = resolve
})
macTccAttribution.mockImplementation(() => pending)
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(macTccAttribution).toHaveBeenCalledTimes(1)
})
act(() => {
window.dispatchEvent(new Event('focus'))
})
expect(macTccAttribution).toHaveBeenCalledTimes(1)
expect(toast.warning).not.toHaveBeenCalled()
await act(async () => {
resolveHealth({ health: 'severed' })
await pending
})
await waitFor(() => {
expect(macTccAttribution).toHaveBeenCalledTimes(1)
expect(toast.warning).toHaveBeenCalledTimes(1)
})
})
it('clears the in-flight guard on rejection so a later focus can retry', async () => {
macTccAttribution
.mockRejectedValueOnce(new Error('probe failed'))
.mockResolvedValueOnce({ health: 'severed' })
render(<MacosTccPromptNoticeHost />)
await waitFor(() => {
expect(macTccAttribution).toHaveBeenCalledTimes(1)
})
expect(toast.warning).not.toHaveBeenCalled()
act(() => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(macTccAttribution).toHaveBeenCalledTimes(2)
expect(toast.warning).toHaveBeenCalledTimes(1)
})
})
})
@@ -0,0 +1,113 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { isPluginUiLanguage } from '../../../shared/ui-language'
import { useAppStore } from '@/store'
import { usePluginLanguagePackStore } from '@/store/plugin-language-packs'
import { translate } from '@/i18n/i18n'
import { resolveUiLocale } from '@/i18n/supported-languages'
import { MANAGE_SESSIONS_SECTION_ID } from '@/components/settings/TerminalTccAttributionNotice'
const SEVERED_TCC_NOTICE_ID = 'mac-tcc-attribution-severed'
/** Surface the existing restart remedy once when daemon TCC attribution is severed. */
export function useMacTccAttributionSeveredNotice(): void {
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const setSettingsSearchQuery = useAppStore((s) => s.setSettingsSearchQuery)
const uiLanguage = useAppStore((s) => s.settings?.uiLanguage ?? null)
const pluginLanguagePacks = usePluginLanguagePackStore((s) => s.packs)
const pluginLanguagePacksLoaded = usePluginLanguagePackStore((s) => s.loaded)
const { i18n } = useTranslation()
const selectedPluginLanguage = pluginLanguagePacks.find((pack) => pack.id === uiLanguage)
const targetLocale =
uiLanguage === null || (isPluginUiLanguage(uiLanguage) && !pluginLanguagePacksLoaded)
? null
: (selectedPluginLanguage?.resourceLanguage ??
(isPluginUiLanguage(uiLanguage) ? 'en' : resolveUiLocale(uiLanguage)))
const localeReady =
targetLocale !== null &&
i18n.language === targetLocale &&
i18n.hasResourceBundle(targetLocale, 'translation')
const toastedThisSession = useRef(false)
// Why: toast was only marked after await; a focus/effect re-run mid-check could dual-toast.
const checkInFlight = useRef(false)
useEffect(() => {
if (
!localeReady ||
typeof window === 'undefined' ||
window.api?.platform?.get().platform !== 'darwin'
) {
return
}
const macTccAttribution = window.api?.pty?.management?.macTccAttribution
if (!macTccAttribution) {
return
}
const maybeToast = async (): Promise<void> => {
if (checkInFlight.current) {
return
}
checkInFlight.current = true
try {
const { health } = await macTccAttribution()
if (health !== 'severed') {
if (toastedThisSession.current) {
toast.dismiss(SEVERED_TCC_NOTICE_ID)
}
return
}
if (toastedThisSession.current) {
return
}
toastedThisSession.current = true
toast.warning(
translate(
'auto.hooks.useMacTccAttributionSeveredNotice.title',
'macOS permissions may not reach Orca terminals'
),
{
id: SEVERED_TCC_NOTICE_ID,
description: translate(
'auto.hooks.useMacTccAttributionSeveredNotice.description',
'Running Orca terminals are hosted by a daemon started by a previous Orca installation. macOS may not apply Orcas Accessibility, Automation, or protected-file permissions to them. Restart the daemon from Manage Sessions to restore access. This will close all running Orca terminals.'
),
duration: Infinity,
action: {
label: translate(
'auto.hooks.useMacTccAttributionSeveredNotice.openManageSessions',
'Open Manage Sessions'
),
onClick: () => {
setSettingsSearchQuery('')
openSettingsTarget({
pane: 'terminal',
repoId: null,
sectionId: MANAGE_SESSIONS_SECTION_ID
})
openSettingsPage()
}
},
cancel: {
label: translate('auto.hooks.useMacTccAttributionSeveredNotice.dismiss', 'Dismiss'),
onClick: () => {}
}
}
)
} catch {
// Rejection clears the guard so a later focus can retry.
} finally {
checkInFlight.current = false
}
}
void maybeToast()
const onFocus = (): void => {
void maybeToast()
}
window.addEventListener('focus', onFocus)
return () => window.removeEventListener('focus', onFocus)
}, [localeReady, openSettingsPage, openSettingsTarget, setSettingsSearchQuery])
}
+6
View File
@@ -939,6 +939,12 @@
"description": "Permission messages from macOS may appear when an agent or terminal tool running in Orca attempts to access protected files. Grant Full Disk Access in Settings to reduce these prompts.",
"openSettings": "Open Settings",
"dismiss": "Don't show again"
},
"useMacTccAttributionSeveredNotice": {
"title": "macOS permissions may not reach Orca terminals",
"description": "Running Orca terminals are hosted by a daemon started by a previous Orca installation. macOS may not apply Orcas Accessibility, Automation, or protected-file permissions to them. Restart the daemon from Manage Sessions to restore access. This will close all running Orca terminals.",
"openManageSessions": "Open Manage Sessions",
"dismiss": "Dismiss"
}
},
"components": {