diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index 22e02965e0d..47b77cfb78e 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -73,6 +73,9 @@ describe('DaemonClient', () => { pid: number startedAtMs: number launchNonce: string + entryPath?: string + appVersion?: string + spawnerExecPath?: string } }): Promise { return new Promise((resolve) => { @@ -155,7 +158,14 @@ describe('DaemonClient', () => { }) it('captures one matching endpoint identity from both authenticated sockets', async () => { - const identity = { pid: 123, startedAtMs: 456, launchNonce: 'launch-a' } + const identity = { + pid: 123, + startedAtMs: 456, + launchNonce: 'launch-a', + entryPath: '/Applications/Orca.app/Contents/Resources/daemon-entry.js', + appVersion: '1.2.3', + spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca' + } await startMockDaemon({ helloIdentity: () => identity }) client = new DaemonClient({ socketPath, tokenPath }) diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index a43ca8d05e4..d137ee84ea5 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -515,6 +515,7 @@ function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | n launchNonce?: unknown entryPath?: unknown appVersion?: unknown + spawnerExecPath?: unknown } if ( !Number.isSafeInteger(identity.pid) || @@ -536,6 +537,9 @@ function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | n : {}), ...(typeof identity.appVersion === 'string' && identity.appVersion.length > 0 ? { appVersion: identity.appVersion } + : {}), + ...(typeof identity.spawnerExecPath === 'string' && identity.spawnerExecPath.length > 0 + ? { spawnerExecPath: identity.spawnerExecPath } : {}) } } diff --git a/src/main/daemon/daemon-entry.test.ts b/src/main/daemon/daemon-entry.test.ts index 8e5ff598df5..ca0dc1131f7 100644 --- a/src/main/daemon/daemon-entry.test.ts +++ b/src/main/daemon/daemon-entry.test.ts @@ -85,13 +85,16 @@ describe('daemon-entry parseArgs', () => { '--entry-path', '/app/daemon-entry.js', '--app-version', - '1.2.3' + '1.2.3', + '--spawner-exec-path', + '/Applications/Orca.app/Contents/MacOS/Orca' ]) ).toMatchObject({ pidPath: '/tmp/t.pid', launchNonce: 'launch-a', entryPath: '/app/daemon-entry.js', - appVersion: '1.2.3' + appVersion: '1.2.3', + spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca' }) }) diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 32628d5275d..9ad6b1564dd 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -29,6 +29,7 @@ export type ParsedDaemonArgs = { launchNonce?: string entryPath?: string appVersion?: string + spawnerExecPath?: string /** GUI-spawned daemons only — headless serve/SSH daemons must survive session loss. */ loginSessionWatch?: boolean /** Optional — absent for adopted old daemons and tests, which log nothing. */ @@ -43,6 +44,7 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { let launchNonce = '' let entryPath = '' let appVersion = '' + let spawnerExecPath = '' let loginSessionWatch = false for (let i = 0; i < argv.length; i++) { @@ -67,6 +69,9 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { } else if (argv[i] === '--app-version' && argv[i + 1]) { appVersion = argv[i + 1] i++ + } else if (argv[i] === '--spawner-exec-path' && argv[i + 1]) { + spawnerExecPath = argv[i + 1] + i++ } else if (argv[i] === '--login-session-watch') { loginSessionWatch = true } @@ -86,6 +91,7 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { ...(pidPath ? { pidPath, launchNonce } : {}), ...(entryPath ? { entryPath } : {}), ...(appVersion ? { appVersion } : {}), + ...(spawnerExecPath ? { spawnerExecPath } : {}), ...(loginSessionWatch ? { loginSessionWatch } : {}), ...(logFilePath ? { logFilePath } : {}) } @@ -106,6 +112,7 @@ async function main(): Promise { launchNonce, entryPath, appVersion, + spawnerExecPath, loginSessionWatch, logFilePath } = parseArgs(process.argv.slice(2)) @@ -259,6 +266,7 @@ async function main(): Promise { ...(pidPath ? { startedAtMs } : {}), ...(entryPath ? { entryPath } : {}), ...(appVersion ? { appVersion } : {}), + ...(spawnerExecPath ? { spawnerExecPath } : {}), ...(pidPath && launchNonce ? { publishEndpointOwnership: () => @@ -267,6 +275,7 @@ async function main(): Promise { ...readyIdentity, ...(entryPath ? { entryPath } : {}), ...(appVersion ? { appVersion } : {}), + ...(spawnerExecPath ? { spawnerExecPath } : {}), launchNonce }) } diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index fe459a875c5..8d2ae9db120 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -204,7 +204,8 @@ describe('parseDaemonPidFile', () => { appVersion: null, launchNonce: null, linuxStartTicks: null, - bootId: null + bootId: null, + spawnerExecPath: null }) }) @@ -222,7 +223,8 @@ describe('parseDaemonPidFile', () => { appVersion: '1.2.3', launchNonce: null, linuxStartTicks: null, - bootId: null + bootId: null, + spawnerExecPath: null }) }) @@ -251,7 +253,8 @@ describe('parseDaemonPidFile', () => { appVersion: null, launchNonce: null, linuxStartTicks: null, - bootId: null + bootId: null, + spawnerExecPath: null }) }) @@ -266,7 +269,8 @@ describe('parseDaemonPidFile', () => { appVersion: null, launchNonce: null, linuxStartTicks: null, - bootId: null + bootId: null, + spawnerExecPath: null }) expect(parseDaemonPidFile(' 12345\n')).toEqual({ pid: 12345, @@ -275,7 +279,8 @@ describe('parseDaemonPidFile', () => { appVersion: null, launchNonce: null, linuxStartTicks: null, - bootId: null + bootId: null, + spawnerExecPath: null }) }) diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 952b0c3688b..b6d264c9569 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -51,6 +51,7 @@ export type ParsedDaemonPid = { launchNonce: string | null linuxStartTicks: string | null bootId: string | null + spawnerExecPath: string | null } /** @@ -344,6 +345,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 { @@ -356,7 +358,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 { @@ -372,7 +375,8 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { appVersion: null, launchNonce: null, linuxStartTicks: null, - bootId: null + bootId: null, + spawnerExecPath: null } : null } @@ -435,16 +439,7 @@ export function getProcessStartedAtMs(pid: number): number | null { return null } - try { - const output = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { - encoding: 'utf8', - timeout: 2_000 - }).trim() - const startedAtMs = Date.parse(output) - return Number.isFinite(startedAtMs) ? startedAtMs : null - } catch { - return null - } + return getPsProcessIdentity(pid)?.startedAtMs ?? null } export function startTimeMatches(pid: number, expectedStartedAtMs: number | null): boolean { @@ -475,6 +470,29 @@ export type WindowsProcessIdentity = { startedAtMs: number | null } +type PsProcessIdentity = { + commandLine: string + startedAtMs: number | 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 + } + } catch { + return null + } +} + export function parseWindowsProcessIdentityJson(stdout: string): WindowsProcessIdentity | null { const trimmed = stdout.trim() if (!trimmed) { @@ -567,18 +585,14 @@ async function isDaemonProcess( commandLineMatchesDaemon(cmdline, socketPath, tokenPath) && startTimeMatches(pid, startedAtMs) ) } catch { - try { - const output = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { - encoding: 'utf8', - timeout: 2_000 - }) - return ( - commandLineMatchesDaemon(output, socketPath, tokenPath) && - startTimeMatches(pid, startedAtMs) - ) - } catch { + const identity = getPsProcessIdentity(pid) + if (!identity) { return false } + return ( + commandLineMatchesDaemon(identity.commandLine, socketPath, tokenPath) && + startTimesWithinTolerance(identity.startedAtMs, startedAtMs, START_TIME_TOLERANCE_MS) + ) } } @@ -590,14 +604,7 @@ async function getDaemonCommandLine(pid: number): Promise { try { return readFileSync(`/proc/${pid}/cmdline`, 'utf8') } catch { - try { - return execFileSync('ps', ['-p', String(pid), '-o', 'command='], { - encoding: 'utf8', - timeout: 2_000 - }) - } catch { - return null - } + return getPsProcessIdentity(pid)?.commandLine ?? null } } @@ -677,6 +684,47 @@ export async function isDaemonStaleForCurrentBundle( return true } +// 'severed': macOS can no longer resolve the daemon's TCC responsible process, so +// Accessibility/Automation grants on Orca silently stop covering its terminals (STA-3491). +// 'unknown' fails open: legacy pid files and probe failures must not trigger replacement. +export type MacDaemonTccAttributionHealth = 'intact' | 'severed' | 'unknown' + +/** + * macOS pins a process's TCC "responsible process" to the binary that forked it, + * by file reference. The detached daemon outlives that app instance, and once the + * spawning binary is deleted (every packaged update replaces the bundle) tccd + * can't resolve the grant subject — `osascript`/System Events from every terminal + * hosted by that daemon is silently denied (-25211) no matter what the user grants. + */ +export async function getMacDaemonTccAttributionHealth( + runtimeDir: string, + socketPath: string, + tokenPath: string, + packagedAppVersion: string | null, + protocolVersion = PROTOCOL_VERSION +): Promise { + if (process.platform !== 'darwin') { + return 'unknown' + } + const parsedPid = await readVerifiedDaemonPid(runtimeDir, socketPath, tokenPath, protocolVersion) + if (!parsedPid) { + return 'unknown' + } + // Packaged updates can replace the bundle at the same path, so path existence + // alone cannot prove the recorded spawning binary still backs this daemon. + if ( + packagedAppVersion !== null && + parsedPid.appVersion !== null && + parsedPid.appVersion !== packagedAppVersion + ) { + return 'severed' + } + if (parsedPid.spawnerExecPath) { + return existsSync(parsedPid.spawnerExecPath) ? 'intact' : 'severed' + } + return 'unknown' +} + function isNoSuchProcessError(error: unknown): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH' } diff --git a/src/main/daemon/daemon-hello-protocol.ts b/src/main/daemon/daemon-hello-protocol.ts index 8fe0df0ac8a..5d9d031cb9d 100644 --- a/src/main/daemon/daemon-hello-protocol.ts +++ b/src/main/daemon/daemon-hello-protocol.ts @@ -13,6 +13,7 @@ export type DaemonEndpointIdentity = { /** Optional launch metadata. Absent from daemons that predate it; readers must fall back. */ entryPath?: string appVersion?: string + spawnerExecPath?: string } export type HelloResponse = { diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 1ef20756681..77016298abc 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -24,6 +24,7 @@ const { checkDaemonHealthMock, healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, + getMacDaemonTccAttributionHealthMock, getDaemonLaunchIdentityMock, isDaemonStaleForCurrentBundleMock, killStaleDaemonMock, @@ -87,6 +88,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 () => ({ @@ -190,6 +192,7 @@ const { checkDaemonHealthMock, healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, + getMacDaemonTccAttributionHealthMock, getDaemonLaunchIdentityMock, isDaemonStaleForCurrentBundleMock, killStaleDaemonMock, @@ -282,6 +285,7 @@ vi.mock('./daemon-health', () => ({ getDaemonCommandLine: getDaemonCommandLineMock, getDaemonLaunchIdentity: getDaemonLaunchIdentityMock, getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock, + getMacDaemonTccAttributionHealth: getMacDaemonTccAttributionHealthMock, healthCheckDaemon: healthCheckDaemonMock, isDaemonStaleForCurrentBundle: isDaemonStaleForCurrentBundleMock, killStaleDaemon: killStaleDaemonMock, @@ -448,6 +452,8 @@ async function importFresh() { healthCheckDaemonMock.mockResolvedValue(true) getMacDaemonSystemResolverHealthMock.mockReset() getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy') + getMacDaemonTccAttributionHealthMock.mockReset() + getMacDaemonTccAttributionHealthMock.mockResolvedValue('unknown') getDaemonLaunchIdentityMock.mockClear() isDaemonStaleForCurrentBundleMock.mockReset() isDaemonStaleForCurrentBundleMock.mockReturnValue(false) @@ -1309,6 +1315,73 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(trackDaemonReplacedMock).toHaveBeenCalledWith('different_app_path', 0) }) + it('replaces a healthy daemon whose macOS TCC attribution is severed when it has no live sessions', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed') + forkMock.mockImplementationOnce(() => { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + return { + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + handlers[event]?.push(cb) + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) + } + return this + }, + off(event: string, cb: (arg?: unknown) => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + } + }) + + await launcher('/fake/socket', '/fake/token') + + expect(forkMock).toHaveBeenCalledTimes(1) + // STA-3491: attribution-severed replacement is billed to its own reason, exactly once. + expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1) + expect(trackDaemonReplacedMock).toHaveBeenCalledWith('severed_tcc_attribution', 0) + }) + + it('preserves a severed-attribution daemon that owns live sessions', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed') + // Why: live sessions must veto replacement — the Settings surface owns the remedy instead. + daemonClientMock.mockImplementation(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: vi.fn(async () => ({ sessions: [{ sessionId: 's1', isAlive: true }] })), + disconnect: vi.fn() + } + }) + + const handle = await launcher('/fake/socket', '/fake/token') + + expect(handle).toBeDefined() + expect(forkMock).not.toHaveBeenCalled() + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(trackDaemonReplacedMock).not.toHaveBeenCalled() + }) + it('holds a full adoption pair before a healthy launcher resolves', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1407,7 +1480,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { startedAtMs: 1_000_000, launchNonce: 'socket-owner', entryPath: '/Applications/Orca 2.app/Contents/out/main/daemon-entry.js', - appVersion: '9.9.9' + appVersion: '9.9.9', + spawnerExecPath: '/Applications/Orca 2.app/Contents/MacOS/Orca' } daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { return { @@ -1440,7 +1514,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { startedAtMs: 1_000_000, launchNonce: 'socket-owner', entryPath: '/Applications/Orca 2.app/Contents/out/main/daemon-entry.js', - appVersion: '9.9.9' + appVersion: '9.9.9', + spawnerExecPath: '/Applications/Orca 2.app/Contents/MacOS/Orca' }) handle.releaseAdoptionLease?.() } finally { @@ -2072,6 +2147,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(handlers.exit).toHaveLength(0) expect(child.disconnect).toHaveBeenCalledOnce() expect(child.unref).toHaveBeenCalledOnce() + expect(writeFileSyncMock).not.toHaveBeenCalled() const launchArgs = forkMock.mock.calls.at(-1)?.[1] as string[] const launchNonceIndex = launchArgs.indexOf('--launch-nonce') expect(launchArgs).toEqual( @@ -2083,7 +2159,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { '--entry-path', FAKE_DAEMON_ENTRY_PATH, '--app-version', - '1.2.3' + '1.2.3', + '--spawner-exec-path', + process.execPath ]) ) expect(launchArgs[launchNonceIndex + 1]).toMatch(/^[0-9a-f-]{36}$/) @@ -2500,7 +2578,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { '--entry-path', FAKE_DAEMON_ENTRY_PATH, '--app-version', - '1.2.3' + '1.2.3', + '--spawner-exec-path', + process.execPath ]) ) }) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 9a3339fc54e..13682828838 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -29,11 +29,13 @@ import { } from './types' import { getMacDaemonSystemResolverHealth, + getMacDaemonTccAttributionHealth, getDaemonLaunchIdentity, checkDaemonHealth, isDaemonStaleForCurrentBundle, killStaleDaemon, - parseDaemonPidFile + parseDaemonPidFile, + type MacDaemonTccAttributionHealth } from './daemon-health' import { collectPinnedDaemonVersions, @@ -280,6 +282,9 @@ async function readDaemonOwnerMetadata( if (identity.appVersion) { metadata.appVersion = identity.appVersion } + if (identity.spawnerExecPath) { + metadata.spawnerExecPath = identity.spawnerExecPath + } const incarnation = await readDaemonProcessIncarnation(identity.pid) if (incarnation) { metadata.linuxStartTicks = incarnation.linuxStartTicks @@ -544,8 +549,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 { @@ -655,6 +683,8 @@ function createOutOfProcessLauncher( entryPath, '--app-version', app.getVersion(), + '--spawner-exec-path', + process.execPath, ...(macosLoginSessionWatch ? ['--login-session-watch'] : []), ...daemonLogArgs() ], @@ -983,6 +1013,18 @@ export function getDaemonProvider(): DaemonProvider | null { return adapter } +// Why: computed from the pid record on demand (not cached at adoption) so the Settings +// remedy surface always reflects the daemon actually serving terminals right now. +export async function getCurrentDaemonMacTccAttributionHealth(): Promise { + const runtimeDir = getRuntimeDir() + return getMacDaemonTccAttributionHealth( + runtimeDir, + getDaemonSocketPath(runtimeDir), + getDaemonTokenPath(runtimeDir), + app.isPackaged ? app.getVersion() : null + ) +} + /** Returns null unless every daemon generation supplied an authoritative inventory. */ export async function listLiveDaemonPtyIds(): Promise { if (!adapter) { diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index 244a05f117b..63e763474bc 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -10,6 +10,7 @@ export type DaemonStartOptions = { publishEndpointOwnership?: DaemonServerOptions['publishEndpointOwnership'] entryPath?: string appVersion?: string + spawnerExecPath?: string /** Direct-construction seam for versioned protocol fixtures; never CLI/env configured. */ protocolVersion?: number spawnSubprocess: DaemonServerOptions['spawnSubprocess'] @@ -38,6 +39,7 @@ export async function startDaemon(opts: DaemonStartOptions): Promise void @@ -120,6 +121,7 @@ export class DaemonServer { private publishEndpointOwnership: () => void private entryPath: string | null private appVersion: string | null + private spawnerExecPath: string | null private ownedSocketIdentity: DaemonSocketIdentity | null = null private endpointOwnershipTimer: ReturnType | null = null private endpointOwnershipLossStreak = 0 @@ -197,6 +199,7 @@ export class DaemonServer { this.publishEndpointOwnership = opts.publishEndpointOwnership ?? (() => {}) this.entryPath = opts.entryPath ?? null this.appVersion = opts.appVersion ?? null + this.spawnerExecPath = opts.spawnerExecPath ?? null this.onIdleShutdown = opts.onIdleShutdown ?? (() => {}) this.onRpcShutdown = opts.onRpcShutdown ?? (() => {}) this.initialAdoptionTimeoutMs = @@ -610,7 +613,8 @@ export class DaemonServer { startedAtMs: this.startedAtMs, launchNonce: this.launchNonce, ...(this.entryPath ? { entryPath: this.entryPath } : {}), - ...(this.appVersion ? { appVersion: this.appVersion } : {}) + ...(this.appVersion ? { appVersion: this.appVersion } : {}), + ...(this.spawnerExecPath ? { spawnerExecPath: this.spawnerExecPath } : {}) } } : {}) diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index 2618cdf27aa..544245c5296 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -24,6 +24,8 @@ export type DaemonPidFile = { launchNonce?: string linuxStartTicks?: string bootId?: string + /** Forking app's binary — macOS pins the daemon's TCC responsible process to it (STA-3491). */ + spawnerExecPath?: string } export type DaemonProcessHandle = { diff --git a/src/main/daemon/daemon-tcc-attribution.test.ts b/src/main/daemon/daemon-tcc-attribution.test.ts new file mode 100644 index 00000000000..f3b6e2a33ba --- /dev/null +++ b/src/main/daemon/daemon-tcc-attribution.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { spawn } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getDaemonPidPath, serializeDaemonPidFile } from './daemon-spawner' +import { + getMacDaemonTccAttributionHealth, + getProcessStartedAtMs, + parseDaemonPidFile +} from './daemon-health' + +// Real-process harness (same shape as daemon-bundle-staleness.test.ts): the health +// check only trusts a pid record whose process is verifiably the daemon, so these +// tests spawn a daemon-shaped child instead of mocking process identity. +function spawnDaemonLikeProcess(socketPath: string, tokenPath: string) { + return spawn( + process.execPath, + [ + '-e', + 'setTimeout(() => {}, 30000)', + 'daemon-entry', + '--socket', + socketPath, + '--token', + tokenPath + ], + { stdio: 'ignore' } + ) +} + +async function getStartedAtMs(pid: number | undefined): Promise { + if (!pid) { + return null + } + await new Promise((resolve) => setTimeout(resolve, 100)) + return getProcessStartedAtMs(pid) +} + +describe('parseDaemonPidFile spawnerExecPath', () => { + it('round-trips the spawner exec path', () => { + const parsed = parseDaemonPidFile( + serializeDaemonPidFile({ + pid: 123, + startedAtMs: 1, + spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca' + }) + ) + expect(parsed?.spawnerExecPath).toBe('/Applications/Orca.app/Contents/MacOS/Orca') + }) + + it('reads legacy records without a spawner exec path as null', () => { + expect( + parseDaemonPidFile(serializeDaemonPidFile({ pid: 123, startedAtMs: 1 }))?.spawnerExecPath + ).toBeNull() + expect(parseDaemonPidFile('123')?.spawnerExecPath).toBeNull() + }) +}) + +describe('macOS daemon TCC attribution health', () => { + let dir: string + let socketPath: string + let tokenPath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-tcc-attribution-test-')) + socketPath = join(dir, 'daemon.sock') + tokenPath = join(dir, 'daemon.token') + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + async function withDaemonLikeProcess( + run: (writePidFile: (extra: Record) => void) => Promise + ): Promise { + const child = spawnDaemonLikeProcess(socketPath, tokenPath) + try { + const startedAtMs = await getStartedAtMs(child.pid) + if (startedAtMs === null || !child.pid) { + return + } + const writePidFile = (extra: Record): void => { + writeFileSync( + getDaemonPidPath(dir), + JSON.stringify({ pid: child.pid, startedAtMs, ...extra }), + { mode: 0o600 } + ) + } + await run(writePidFile) + } finally { + child.kill('SIGKILL') + } + } + + it('reports severed when the recorded spawning binary no longer exists', async () => { + if (process.platform !== 'darwin') { + return + } + await withDaemonLikeProcess(async (writePidFile) => { + writePidFile({ spawnerExecPath: join(dir, 'deleted-bundle', 'Orca') }) + expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe( + 'severed' + ) + }) + }) + + it('reports intact when the recorded spawning binary still exists', async () => { + if (process.platform !== 'darwin') { + return + } + await withDaemonLikeProcess(async (writePidFile) => { + const spawnerPath = join(dir, 'Orca') + writeFileSync(spawnerPath, '', 'utf8') + writePidFile({ spawnerExecPath: spawnerPath }) + expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe( + 'intact' + ) + }) + }) + + it('reports severed after a packaged update reuses the spawning binary path', async () => { + if (process.platform !== 'darwin') { + return + } + await withDaemonLikeProcess(async (writePidFile) => { + const spawnerPath = join(dir, 'Orca') + writeFileSync(spawnerPath, '', 'utf8') + writePidFile({ spawnerExecPath: spawnerPath, appVersion: '1.2.2' }) + expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe( + 'severed' + ) + }) + }) + + it('flags legacy records only on a packaged app-version change', async () => { + if (process.platform !== 'darwin') { + return + } + await withDaemonLikeProcess(async (writePidFile) => { + writePidFile({ appVersion: '1.2.2' }) + // Updater replaced the bundle since this daemon was forked → attribution is gone. + expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe( + 'severed' + ) + writePidFile({ appVersion: '1.2.3' }) + expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe( + 'unknown' + ) + // Dev builds pass null — no version heuristic, fail open. + expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, null)).toBe( + 'unknown' + ) + }) + }) + + it('fails open when no verifiable pid record exists', async () => { + if (process.platform !== 'darwin') { + return + } + expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe( + 'unknown' + ) + }) + + it('reports unknown off macOS', async () => { + if (process.platform === 'darwin') { + return + } + expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe( + 'unknown' + ) + }) +}) diff --git a/src/main/ipc/pty-management.test.ts b/src/main/ipc/pty-management.test.ts index 1586c566ffb..d1c2689000b 100644 --- a/src/main/ipc/pty-management.test.ts +++ b/src/main/ipc/pty-management.test.ts @@ -1,14 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DaemonSessionInfo } from '../daemon/types' -const { handleMock, removeHandlerMock, getDaemonProviderMock, restartDaemonMock } = vi.hoisted( - () => ({ - handleMock: vi.fn(), - removeHandlerMock: vi.fn(), - getDaemonProviderMock: vi.fn(), - restartDaemonMock: vi.fn() - }) -) +const { + handleMock, + removeHandlerMock, + getDaemonProviderMock, + restartDaemonMock, + getCurrentDaemonMacTccAttributionHealthMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + removeHandlerMock: vi.fn(), + getDaemonProviderMock: vi.fn(), + restartDaemonMock: vi.fn(), + getCurrentDaemonMacTccAttributionHealthMock: vi.fn(async () => 'unknown') +})) vi.mock('electron', () => ({ ipcMain: { handle: handleMock, removeHandler: removeHandlerMock } @@ -16,7 +21,8 @@ vi.mock('electron', () => ({ vi.mock('../daemon/daemon-init', () => ({ getDaemonProvider: getDaemonProviderMock, - restartDaemon: restartDaemonMock + restartDaemon: restartDaemonMock, + getCurrentDaemonMacTccAttributionHealth: getCurrentDaemonMacTccAttributionHealthMock })) // Why: the handler uses `provider instanceof DaemonPtyRouter` to branch @@ -140,6 +146,8 @@ describe('pty:management IPC handlers', () => { beforeEach(() => { getDaemonProviderMock.mockReset() restartDaemonMock.mockReset() + getCurrentDaemonMacTccAttributionHealthMock.mockReset() + getCurrentDaemonMacTccAttributionHealthMock.mockResolvedValue('unknown') }) afterEach(() => { @@ -456,6 +464,36 @@ describe('pty:management IPC handlers', () => { }) }) + describe('macTccAttribution', () => { + it('reports the daemon attribution health', async () => { + getCurrentDaemonMacTccAttributionHealthMock.mockResolvedValue('severed') + + const { registerDaemonManagementHandlers } = await importFresh() + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = (await handlers['pty:management:macTccAttribution']({})) as { + health: string + } + + expect(result.health).toBe('severed') + }) + + it('fails open to unknown when the probe throws', async () => { + getCurrentDaemonMacTccAttributionHealthMock.mockRejectedValue(new Error('no pid record')) + + const { registerDaemonManagementHandlers } = await importFresh() + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = (await handlers['pty:management:macTccAttribution']({})) as { + health: string + } + + expect(result.health).toBe('unknown') + }) + }) + describe('restart', () => { it('delegates to restartDaemon and reports success', async () => { restartDaemonMock.mockResolvedValue({ killedCount: 2 }) diff --git a/src/main/ipc/pty-management.ts b/src/main/ipc/pty-management.ts index 6a3f455aeea..16e6cfb41ac 100644 --- a/src/main/ipc/pty-management.ts +++ b/src/main/ipc/pty-management.ts @@ -2,7 +2,12 @@ import { ipcMain } from 'electron' import { DaemonPtyRouter } from '../daemon/daemon-pty-router' import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider' import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' -import { getDaemonProvider, restartDaemon } from '../daemon/daemon-init' +import { + getCurrentDaemonMacTccAttributionHealth, + getDaemonProvider, + restartDaemon +} from '../daemon/daemon-init' +import type { MacDaemonTccAttributionHealth } from '../daemon/daemon-health' import type { DaemonSessionInfo } from '../daemon/types' // Why: poll past the daemon's 5s SIGTERM→SIGKILL ladder (KILL_TIMEOUT_MS in session.ts), else slow-exiting shells falsely look "refused". @@ -51,6 +56,19 @@ export function registerDaemonManagementHandlers(): void { ipcMain.removeHandler('pty:management:killAll') ipcMain.removeHandler('pty:management:killOne') ipcMain.removeHandler('pty:management:restart') + ipcMain.removeHandler('pty:management:macTccAttribution') + + // Why: lets Settings warn that macOS privacy grants no longer reach daemon terminals (STA-3491). + ipcMain.handle( + 'pty:management:macTccAttribution', + async (): Promise<{ health: MacDaemonTccAttributionHealth }> => { + try { + return { health: await getCurrentDaemonMacTccAttributionHealth() } + } catch { + return { health: 'unknown' } + } + } + ) ipcMain.handle( 'pty:management:listSessions', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 69a6f104b22..34d102bcec9 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -764,6 +764,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 }> @@ -774,6 +778,7 @@ export type PtyManagementApi = { }> killOne: (args: { sessionId: string }) => Promise<{ success: boolean }> restart: () => Promise<{ success: boolean }> + macTccAttribution: () => Promise<{ health: PtyManagementMacTccAttributionHealth }> } export type ExportApi = { diff --git a/src/preload/index.ts b/src/preload/index.ts index 74ccf8b3876..ea32e22e182 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1256,7 +1256,8 @@ const api = { listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), killAll: () => ipcRenderer.invoke('pty:management:killAll'), killOne: (args: { sessionId: string }) => ipcRenderer.invoke('pty:management:killOne', args), - restart: () => ipcRenderer.invoke('pty:management:restart') + restart: () => ipcRenderer.invoke('pty:management:restart'), + macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution') } }, diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx index 31576f0e1ce..1924d91a81e 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx @@ -24,6 +24,7 @@ import { developerPermissionStatusClass, developerPermissionStatusLabel } from './developer-permission-status' +import { TerminalTccAttributionNotice } from './TerminalTccAttributionNotice' export { getDeveloperPermissionsPaneSearchEntries } from './developer-permissions-search' type DeveloperPermissionsPaneProps = { @@ -329,6 +330,7 @@ export function DeveloperPermissionsPane({ return (
+
diff --git a/src/renderer/src/components/settings/ManageSessionsSection.tsx b/src/renderer/src/components/settings/ManageSessionsSection.tsx index d0f39bbeec6..73256687792 100644 --- a/src/renderer/src/components/settings/ManageSessionsSection.tsx +++ b/src/renderer/src/components/settings/ManageSessionsSection.tsx @@ -10,6 +10,10 @@ import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions import { ManageSessionKillDialog } from './ManageSessionKillDialog' import { ManageSessionsTable } from './ManageSessionsTable' import { notifyDaemonSessionInventoryInvalidated } from '../status-bar/daemon-session-inventory-invalidation' +import { + MANAGE_SESSIONS_SECTION_ID, + TerminalTccAttributionNotice +} from './TerminalTccAttributionNotice' import { translate } from '@/i18n/i18n' type ConfirmKind = 'killOne' @@ -20,6 +24,7 @@ export function ManageSessionsSection(): React.JSX.Element { const [hasLoadedOnce, setHasLoadedOnce] = useState(false) const [pendingKillSession, setPendingKillSession] = useState(null) const [busyKind, setBusyKind] = useState(null) + const [attributionRefreshRevision, setAttributionRefreshRevision] = useState(0) const optimisticRollback = useRef(null) const isMounted = useRef(true) const mutationInFlight = useRef(false) @@ -124,6 +129,7 @@ export function ManageSessionsSection(): React.JSX.Element { }, onRestartSettled: () => { notifyDaemonSessionInventoryInvalidated() + setAttributionRefreshRevision((revision) => revision + 1) void refresh() } }) @@ -206,7 +212,12 @@ export function ManageSessionsSection(): React.JSX.Element { description={getManageSessionsSearchEntries()[0].description} keywords={getManageSessionsSearchEntries()[0].keywords} className="space-y-3" + id={MANAGE_SESSIONS_SECTION_ID} > + ({ + useAppStore: ( + selector: (state: { + openSettingsTarget: typeof openSettingsTarget + openSettingsPage: typeof openSettingsPage + setSettingsSearchQuery: typeof setSettingsSearchQuery + }) => unknown + ) => selector({ openSettingsTarget, openSettingsPage, setSettingsSearchQuery }) +})) + +let container: HTMLDivElement +let root: Root + +function stubAttributionHealth(health: 'intact' | 'severed' | 'unknown'): void { + Object.assign(window, { + api: { + pty: { + management: { + macTccAttribution: vi.fn(async () => ({ health })) + } + } + } + }) +} + +beforeEach(() => { + openSettingsTarget.mockClear() + openSettingsPage.mockClear() + setSettingsSearchQuery.mockClear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + Reflect.deleteProperty(window, 'api') +}) + +it('renders the remedy banner only while attribution is severed', async () => { + stubAttributionHealth('severed') + await act(async () => { + root.render() + }) + const alert = container.querySelector('[role="alert"]') + expect(alert?.textContent).toContain('macOS permission grants aren’t reaching terminals') + expect(alert?.textContent).toContain('-25211') + + stubAttributionHealth('intact') + await act(async () => { + root.render() + }) + expect(container.querySelector('[role="alert"]')).toBeNull() +}) + +it('navigates to Manage Sessions from the banner action', async () => { + stubAttributionHealth('severed') + await act(async () => { + root.render() + }) + + const button = container.querySelector('button') + expect(button?.textContent).toContain('Open Manage Sessions') + await act(async () => { + button?.click() + }) + + expect(setSettingsSearchQuery).toHaveBeenCalledWith('') + expect(openSettingsTarget).toHaveBeenCalledWith({ + pane: 'terminal', + repoId: null, + sectionId: MANAGE_SESSIONS_SECTION_ID + }) + expect(openSettingsPage).toHaveBeenCalled() +}) + +it('hides the navigation button on the Manage Sessions surface itself', async () => { + stubAttributionHealth('severed') + await act(async () => { + root.render() + }) + expect(container.querySelector('[role="alert"]')).not.toBeNull() + expect(container.querySelector('button')).toBeNull() +}) + +it('refreshes the warning after the daemon restart remedy settles', async () => { + stubAttributionHealth('severed') + await act(async () => { + root.render() + }) + expect(container.querySelector('[role="alert"]')).not.toBeNull() + + stubAttributionHealth('intact') + await act(async () => { + root.render() + }) + expect(container.querySelector('[role="alert"]')).toBeNull() +}) + +it('fails closed when the attribution probe is unavailable', async () => { + Object.assign(window, { api: { pty: {} } }) + await act(async () => { + root.render() + }) + expect(container.querySelector('[role="alert"]')).toBeNull() +}) diff --git a/src/renderer/src/components/settings/TerminalTccAttributionNotice.tsx b/src/renderer/src/components/settings/TerminalTccAttributionNotice.tsx new file mode 100644 index 00000000000..0165eca5f4f --- /dev/null +++ b/src/renderer/src/components/settings/TerminalTccAttributionNotice.tsx @@ -0,0 +1,99 @@ +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(refreshRevision = 0): boolean { + const [severed, setSevered] = useState(false) + + const refresh = useCallback(async (): Promise => { + try { + const { health } = await window.api.pty.management.macTccAttribution() + setSevered(health === 'severed') + } catch { + setSevered(false) + } + }, []) + + useEffect(() => { + void refresh() + // Why: a daemon restart or drain changes the verdict without a pane remount. + const onFocus = (): void => { + void refresh() + } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [refresh, refreshRevision]) + + return severed +} + +export function TerminalTccAttributionNotice(props: { + /** The Manage Sessions surface hosts the fix itself, so it hides the navigation button. */ + showManageSessionsButton?: boolean + /** Increment after a daemon replacement attempt so the remedy state is re-checked. */ + refreshRevision?: number +}): React.JSX.Element | null { + const severed = useMacTccAttributionSevered(props.refreshRevision) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const setSettingsSearchQuery = useAppStore((s) => s.setSettingsSearchQuery) + + if (!severed) { + return null + } + + const openManageSessions = (): void => { + // Why: a stale Settings search would hide the Manage Sessions section this points at. + setSettingsSearchQuery('') + openSettingsTarget({ + pane: 'terminal', + repoId: null, + sectionId: MANAGE_SESSIONS_SECTION_ID + }) + openSettingsPage() + } + + return ( +
+
+ +
+

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

+

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

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