From e1ee1b3ef3966d0cebed7ffded4294e84048d74f Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 13 Aug 2026 16:30:44 -0700 Subject: [PATCH] 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> --- src/main/daemon/daemon-health.ts | 129 +++++++++-- src/main/daemon/daemon-init.ts | 18 +- src/main/daemon/daemon-pty-adapter.test.ts | 113 +++++++++- src/main/daemon/daemon-pty-adapter.ts | 53 ++++- ...daemon-tcc-attribution-main-thread.test.ts | 154 +++++++++++++ .../daemon/daemon-tcc-attribution.test.ts | 8 +- .../src/hooks/MacosTccPromptNoticeHost.tsx | 3 + ...useMacTccAttributionSeveredNotice.test.tsx | 206 ++++++++++++++++++ .../useMacTccAttributionSeveredNotice.ts | 113 ++++++++++ src/renderer/src/i18n/locales/en.json | 6 + 10 files changed, 770 insertions(+), 33 deletions(-) create mode 100644 src/main/daemon/daemon-tcc-attribution-main-thread.test.ts create mode 100644 src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx create mode 100644 src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 4efded40d98..5afd67f8ba2 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -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 { + try { + const stdout = await new Promise((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 { 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 +} | 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 => { + 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 { diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 7b95d4a010c..cf9fc1826d7 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -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 { 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 { 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 }) diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 79466d56fa7..49f2915b7df 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -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() 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 } + } + 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') diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 8812d0ca725..04982f9643c 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -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)> } -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 { + // 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 { try { await this.client.ensureConnected() diff --git a/src/main/daemon/daemon-tcc-attribution-main-thread.test.ts b/src/main/daemon/daemon-tcc-attribution-main-thread.test.ts new file mode 100644 index 00000000000..88ffda1ee39 --- /dev/null +++ b/src/main/daemon/daemon-tcc-attribution-main-thread.test.ts @@ -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()), + execFile: execFileMock, + execFileSync: execFileSyncMock +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + 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() + }) +}) diff --git a/src/main/daemon/daemon-tcc-attribution.test.ts b/src/main/daemon/daemon-tcc-attribution.test.ts index f3b6e2a33ba..8813f33f5a4 100644 --- a/src/main/daemon/daemon-tcc-attribution.test.ts +++ b/src/main/daemon/daemon-tcc-attribution.test.ts @@ -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( diff --git a/src/renderer/src/hooks/MacosTccPromptNoticeHost.tsx b/src/renderer/src/hooks/MacosTccPromptNoticeHost.tsx index 19109c2d377..d4d4c1d5541 100644 --- a/src/renderer/src/hooks/MacosTccPromptNoticeHost.tsx +++ b/src/renderer/src/hooks/MacosTccPromptNoticeHost.tsx @@ -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 } diff --git a/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx new file mode 100644 index 00000000000..1d15ddde948 --- /dev/null +++ b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx @@ -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) => unknown) => + selector({ + openSettingsPage, + openSettingsTarget, + setSettingsSearchQuery, + settings: { uiLanguage: 'en' } + }) +})) + +vi.mock('@/store/plugin-language-packs', () => ({ + usePluginLanguagePackStore: (selector: (s: Record) => 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() + await waitFor(() => { + expect(macTccAttribution).toHaveBeenCalled() + }) + expect(toast.warning).not.toHaveBeenCalled() + }) + + it('does not probe on non-macOS focus', async () => { + platform.value = 'win32' + render() + + 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() + 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() + await waitFor(() => { + expect(toast.warning).toHaveBeenCalledTimes(1) + }) + rerender() + 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() + 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() + 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() + 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) + }) + }) +}) diff --git a/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts new file mode 100644 index 00000000000..448c47f10b6 --- /dev/null +++ b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts @@ -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 => { + if (checkInFlight.current) { + return + } + checkInFlight.current = true + try { + const { health } = await macTccAttribution() + if (health !== 'severed') { + if (toastedThisSession.current) { + toast.dismiss(SEVERED_TCC_NOTICE_ID) + } + return + } + if (toastedThisSession.current) { + return + } + toastedThisSession.current = true + toast.warning( + translate( + 'auto.hooks.useMacTccAttributionSeveredNotice.title', + 'macOS permissions may not reach Orca terminals' + ), + { + id: SEVERED_TCC_NOTICE_ID, + description: translate( + 'auto.hooks.useMacTccAttributionSeveredNotice.description', + 'Running Orca terminals are hosted by a daemon started by a previous Orca installation. macOS may not apply Orca’s Accessibility, Automation, or protected-file permissions to them. Restart the daemon from Manage Sessions to restore access. This will close all running Orca terminals.' + ), + duration: Infinity, + action: { + label: translate( + 'auto.hooks.useMacTccAttributionSeveredNotice.openManageSessions', + 'Open Manage Sessions' + ), + onClick: () => { + setSettingsSearchQuery('') + openSettingsTarget({ + pane: 'terminal', + repoId: null, + sectionId: MANAGE_SESSIONS_SECTION_ID + }) + openSettingsPage() + } + }, + cancel: { + label: translate('auto.hooks.useMacTccAttributionSeveredNotice.dismiss', 'Dismiss'), + onClick: () => {} + } + } + ) + } 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]) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8383effe10a..d298a94d996 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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 Orca’s Accessibility, Automation, or protected-file permissions to them. Restart the daemon from Manage Sessions to restore access. This will close all running Orca terminals.", + "openManageSessions": "Open Manage Sessions", + "dismiss": "Dismiss" } }, "components": {