fix(daemon): detect severed macOS TCC attribution behind terminal automation denials (STA-3491) (#12848)

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

macOS pins the detached PTY daemon's TCC responsible process to the app
binary that forked it. Once that binary is deleted (packaged updates
replace the bundle), Accessibility/Automation grants on Orca silently
stop covering every daemon-hosted terminal: osascript/System Events
fails with -25211 no matter what the user grants.

- record spawnerExecPath in the daemon pid file at fork
- adoption checks it: severed + 0 live sessions -> replace the daemon
  (reason severed_tcc_attribution); live sessions are preserved
- Settings (Developer Permissions + Manage Sessions) show a visible
  banner pointing at Manage Sessions -> Restart while severed

* fix(daemon): harden TCC attribution recovery
This commit is contained in:
Brennan Benson
2026-08-05 23:54:30 -07:00
committed by GitHub
parent f82d5c3e6c
commit a2d438db17
25 changed files with 747 additions and 60 deletions
+11 -1
View File
@@ -73,6 +73,9 @@ describe('DaemonClient', () => {
pid: number
startedAtMs: number
launchNonce: string
entryPath?: string
appVersion?: string
spawnerExecPath?: string
}
}): Promise<void> {
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 })
+4
View File
@@ -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 }
: {})
}
}
+5 -2
View File
@@ -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'
})
})
+9
View File
@@ -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<void> {
launchNonce,
entryPath,
appVersion,
spawnerExecPath,
loginSessionWatch,
logFilePath
} = parseArgs(process.argv.slice(2))
@@ -259,6 +266,7 @@ async function main(): Promise<void> {
...(pidPath ? { startedAtMs } : {}),
...(entryPath ? { entryPath } : {}),
...(appVersion ? { appVersion } : {}),
...(spawnerExecPath ? { spawnerExecPath } : {}),
...(pidPath && launchNonce
? {
publishEndpointOwnership: () =>
@@ -267,6 +275,7 @@ async function main(): Promise<void> {
...readyIdentity,
...(entryPath ? { entryPath } : {}),
...(appVersion ? { appVersion } : {}),
...(spawnerExecPath ? { spawnerExecPath } : {}),
launchNonce
})
}
+10 -5
View File
@@ -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
})
})
+78 -30
View File
@@ -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<string | null> {
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<MacDaemonTccAttributionHealth> {
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'
}
+1
View File
@@ -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 = {
+84 -4
View File
@@ -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<void> }>
getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed')
forkMock.mockImplementationOnce(() => {
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
message: [],
error: [],
exit: []
}
return {
pid: 12345,
on(event: string, cb: (arg?: unknown) => void) {
handlers[event]?.push(cb)
if (event === 'message') {
queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
}
return this
},
off(event: string, cb: (arg?: unknown) => void) {
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
return this
},
disconnect: vi.fn(),
unref: vi.fn()
}
})
await launcher('/fake/socket', '/fake/token')
expect(forkMock).toHaveBeenCalledTimes(1)
// STA-3491: attribution-severed replacement is billed to its own reason, exactly once.
expect(trackDaemonReplacedMock).toHaveBeenCalledTimes(1)
expect(trackDaemonReplacedMock).toHaveBeenCalledWith('severed_tcc_attribution', 0)
})
it('preserves a severed-attribution daemon that owns live sessions', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true })
const launcher = spawnerInstances[0].launcher as (
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed')
// Why: live sessions must veto replacement — the Settings surface owns the remedy instead.
daemonClientMock.mockImplementation(function MockDaemonClient() {
return {
ensureConnected: vi.fn(async () => {}),
request: vi.fn(async () => ({ sessions: [{ sessionId: 's1', isAlive: true }] })),
disconnect: vi.fn()
}
})
const handle = await launcher('/fake/socket', '/fake/token')
expect(handle).toBeDefined()
expect(forkMock).not.toHaveBeenCalled()
expect(killStaleDaemonMock).not.toHaveBeenCalled()
expect(trackDaemonReplacedMock).not.toHaveBeenCalled()
})
it('holds a full adoption pair before a healthy launcher resolves', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -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
])
)
})
+45 -3
View File
@@ -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<MacDaemonTccAttributionHealth> {
const runtimeDir = getRuntimeDir()
return getMacDaemonTccAttributionHealth(
runtimeDir,
getDaemonSocketPath(runtimeDir),
getDaemonTokenPath(runtimeDir),
app.isPackaged ? app.getVersion() : null
)
}
/** Returns null unless every daemon generation supplied an authoritative inventory. */
export async function listLiveDaemonPtyIds(): Promise<string[] | null> {
if (!adapter) {
+2
View File
@@ -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<DaemonHandl
: {}),
...(opts.entryPath ? { entryPath: opts.entryPath } : {}),
...(opts.appVersion ? { appVersion: opts.appVersion } : {}),
...(opts.spawnerExecPath ? { spawnerExecPath: opts.spawnerExecPath } : {}),
...(opts.protocolVersion !== undefined ? { protocolVersion: opts.protocolVersion } : {}),
spawnSubprocess: opts.spawnSubprocess,
...(opts.preparePtySpawn ? { preparePtySpawn: opts.preparePtySpawn } : {}),
+5 -1
View File
@@ -55,6 +55,7 @@ export type DaemonServerOptions = {
/** Reported in the hello so a repaired PID record can carry the real owner's metadata. */
entryPath?: string
appVersion?: string
spawnerExecPath?: string
/** Direct-construction seam for protocol fixture tests; production never overrides it. */
protocolVersion?: number
onIdleShutdown?: () => 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<typeof setInterval> | 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 } : {})
}
}
: {})
+2
View File
@@ -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 = {
@@ -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<number | null> {
if (!pid) {
return null
}
await new Promise((resolve) => setTimeout(resolve, 100))
return getProcessStartedAtMs(pid)
}
describe('parseDaemonPidFile spawnerExecPath', () => {
it('round-trips the spawner exec path', () => {
const parsed = parseDaemonPidFile(
serializeDaemonPidFile({
pid: 123,
startedAtMs: 1,
spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca'
})
)
expect(parsed?.spawnerExecPath).toBe('/Applications/Orca.app/Contents/MacOS/Orca')
})
it('reads legacy records without a spawner exec path as null', () => {
expect(
parseDaemonPidFile(serializeDaemonPidFile({ pid: 123, startedAtMs: 1 }))?.spawnerExecPath
).toBeNull()
expect(parseDaemonPidFile('123')?.spawnerExecPath).toBeNull()
})
})
describe('macOS daemon TCC attribution health', () => {
let dir: string
let socketPath: string
let tokenPath: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-tcc-attribution-test-'))
socketPath = join(dir, 'daemon.sock')
tokenPath = join(dir, 'daemon.token')
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
async function withDaemonLikeProcess(
run: (writePidFile: (extra: Record<string, unknown>) => void) => Promise<void>
): Promise<void> {
const child = spawnDaemonLikeProcess(socketPath, tokenPath)
try {
const startedAtMs = await getStartedAtMs(child.pid)
if (startedAtMs === null || !child.pid) {
return
}
const writePidFile = (extra: Record<string, unknown>): void => {
writeFileSync(
getDaemonPidPath(dir),
JSON.stringify({ pid: child.pid, startedAtMs, ...extra }),
{ mode: 0o600 }
)
}
await run(writePidFile)
} finally {
child.kill('SIGKILL')
}
}
it('reports severed when the recorded spawning binary no longer exists', async () => {
if (process.platform !== 'darwin') {
return
}
await withDaemonLikeProcess(async (writePidFile) => {
writePidFile({ spawnerExecPath: join(dir, 'deleted-bundle', 'Orca') })
expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe(
'severed'
)
})
})
it('reports intact when the recorded spawning binary still exists', async () => {
if (process.platform !== 'darwin') {
return
}
await withDaemonLikeProcess(async (writePidFile) => {
const spawnerPath = join(dir, 'Orca')
writeFileSync(spawnerPath, '', 'utf8')
writePidFile({ spawnerExecPath: spawnerPath })
expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe(
'intact'
)
})
})
it('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'
)
})
})
+47 -9
View File
@@ -1,14 +1,19 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DaemonSessionInfo } from '../daemon/types'
const { handleMock, removeHandlerMock, getDaemonProviderMock, restartDaemonMock } = vi.hoisted(
() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
getDaemonProviderMock: vi.fn(),
restartDaemonMock: vi.fn()
})
)
const {
handleMock,
removeHandlerMock,
getDaemonProviderMock,
restartDaemonMock,
getCurrentDaemonMacTccAttributionHealthMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
getDaemonProviderMock: vi.fn(),
restartDaemonMock: vi.fn(),
getCurrentDaemonMacTccAttributionHealthMock: vi.fn(async () => 'unknown')
}))
vi.mock('electron', () => ({
ipcMain: { handle: handleMock, removeHandler: removeHandlerMock }
@@ -16,7 +21,8 @@ vi.mock('electron', () => ({
vi.mock('../daemon/daemon-init', () => ({
getDaemonProvider: getDaemonProviderMock,
restartDaemon: restartDaemonMock
restartDaemon: restartDaemonMock,
getCurrentDaemonMacTccAttributionHealth: getCurrentDaemonMacTccAttributionHealthMock
}))
// Why: the handler uses `provider instanceof DaemonPtyRouter` to branch
@@ -140,6 +146,8 @@ describe('pty:management IPC handlers', () => {
beforeEach(() => {
getDaemonProviderMock.mockReset()
restartDaemonMock.mockReset()
getCurrentDaemonMacTccAttributionHealthMock.mockReset()
getCurrentDaemonMacTccAttributionHealthMock.mockResolvedValue('unknown')
})
afterEach(() => {
@@ -456,6 +464,36 @@ describe('pty:management IPC handlers', () => {
})
})
describe('macTccAttribution', () => {
it('reports the daemon attribution health', async () => {
getCurrentDaemonMacTccAttributionHealthMock.mockResolvedValue('severed')
const { registerDaemonManagementHandlers } = await importFresh()
registerDaemonManagementHandlers()
const handlers = buildHandlerMap()
const result = (await handlers['pty:management:macTccAttribution']({})) as {
health: string
}
expect(result.health).toBe('severed')
})
it('fails open to unknown when the probe throws', async () => {
getCurrentDaemonMacTccAttributionHealthMock.mockRejectedValue(new Error('no pid record'))
const { registerDaemonManagementHandlers } = await importFresh()
registerDaemonManagementHandlers()
const handlers = buildHandlerMap()
const result = (await handlers['pty:management:macTccAttribution']({})) as {
health: string
}
expect(result.health).toBe('unknown')
})
})
describe('restart', () => {
it('delegates to restartDaemon and reports success', async () => {
restartDaemonMock.mockResolvedValue({ killedCount: 2 })
+19 -1
View File
@@ -2,7 +2,12 @@ import { ipcMain } from 'electron'
import { DaemonPtyRouter } from '../daemon/daemon-pty-router'
import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider'
import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter'
import { getDaemonProvider, restartDaemon } from '../daemon/daemon-init'
import {
getCurrentDaemonMacTccAttributionHealth,
getDaemonProvider,
restartDaemon
} from '../daemon/daemon-init'
import type { MacDaemonTccAttributionHealth } from '../daemon/daemon-health'
import type { DaemonSessionInfo } from '../daemon/types'
// Why: poll past the daemon's 5s SIGTERM→SIGKILL ladder (KILL_TIMEOUT_MS in session.ts), else slow-exiting shells falsely look "refused".
@@ -51,6 +56,19 @@ export function registerDaemonManagementHandlers(): void {
ipcMain.removeHandler('pty:management:killAll')
ipcMain.removeHandler('pty:management:killOne')
ipcMain.removeHandler('pty:management:restart')
ipcMain.removeHandler('pty:management:macTccAttribution')
// Why: lets Settings warn that macOS privacy grants no longer reach daemon terminals (STA-3491).
ipcMain.handle(
'pty:management:macTccAttribution',
async (): Promise<{ health: MacDaemonTccAttributionHealth }> => {
try {
return { health: await getCurrentDaemonMacTccAttributionHealth() }
} catch {
return { health: 'unknown' }
}
}
)
ipcMain.handle(
'pty:management:listSessions',
+5
View File
@@ -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 = {
+2 -1
View File
@@ -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')
}
},
@@ -24,6 +24,7 @@ import {
developerPermissionStatusClass,
developerPermissionStatusLabel
} from './developer-permission-status'
import { TerminalTccAttributionNotice } from './TerminalTccAttributionNotice'
export { getDeveloperPermissionsPaneSearchEntries } from './developer-permissions-search'
type DeveloperPermissionsPaneProps = {
@@ -329,6 +330,7 @@ export function DeveloperPermissionsPane({
return (
<div className="space-y-5">
<TerminalTccAttributionNotice />
<div className="flex items-start justify-between gap-4 rounded-lg border border-border/60 bg-muted/25 px-4 py-3">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
@@ -10,6 +10,10 @@ import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions
import { ManageSessionKillDialog } from './ManageSessionKillDialog'
import { ManageSessionsTable } from './ManageSessionsTable'
import { notifyDaemonSessionInventoryInvalidated } from '../status-bar/daemon-session-inventory-invalidation'
import {
MANAGE_SESSIONS_SECTION_ID,
TerminalTccAttributionNotice
} from './TerminalTccAttributionNotice'
import { translate } from '@/i18n/i18n'
type ConfirmKind = 'killOne'
@@ -20,6 +24,7 @@ export function ManageSessionsSection(): React.JSX.Element {
const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
const [pendingKillSession, setPendingKillSession] = useState<PtyManagementSession | null>(null)
const [busyKind, setBusyKind] = useState<ConfirmKind | null>(null)
const [attributionRefreshRevision, setAttributionRefreshRevision] = useState(0)
const optimisticRollback = useRef<PtyManagementSession[] | null>(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}
>
<TerminalTccAttributionNotice
showManageSessionsButton={false}
refreshRevision={attributionRefreshRevision}
/>
<ManageSessionsTable
sessions={sessions}
hasLoadedOnce={hasLoadedOnce}
@@ -0,0 +1,121 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import {
MANAGE_SESSIONS_SECTION_ID,
TerminalTccAttributionNotice
} from './TerminalTccAttributionNotice'
const openSettingsTarget = vi.fn()
const openSettingsPage = vi.fn()
const setSettingsSearchQuery = vi.fn()
vi.mock('../../store', () => ({
useAppStore: (
selector: (state: {
openSettingsTarget: typeof openSettingsTarget
openSettingsPage: typeof openSettingsPage
setSettingsSearchQuery: typeof setSettingsSearchQuery
}) => unknown
) => selector({ openSettingsTarget, openSettingsPage, setSettingsSearchQuery })
}))
let container: HTMLDivElement
let root: Root
function stubAttributionHealth(health: 'intact' | 'severed' | 'unknown'): void {
Object.assign(window, {
api: {
pty: {
management: {
macTccAttribution: vi.fn(async () => ({ health }))
}
}
}
})
}
beforeEach(() => {
openSettingsTarget.mockClear()
openSettingsPage.mockClear()
setSettingsSearchQuery.mockClear()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
Reflect.deleteProperty(window, 'api')
})
it('renders the remedy banner only while attribution is severed', async () => {
stubAttributionHealth('severed')
await act(async () => {
root.render(<TerminalTccAttributionNotice />)
})
const alert = container.querySelector('[role="alert"]')
expect(alert?.textContent).toContain('macOS permission grants arent reaching terminals')
expect(alert?.textContent).toContain('-25211')
stubAttributionHealth('intact')
await act(async () => {
root.render(<TerminalTccAttributionNotice key="fresh" />)
})
expect(container.querySelector('[role="alert"]')).toBeNull()
})
it('navigates to Manage Sessions from the banner action', async () => {
stubAttributionHealth('severed')
await act(async () => {
root.render(<TerminalTccAttributionNotice />)
})
const button = container.querySelector('button')
expect(button?.textContent).toContain('Open Manage Sessions')
await act(async () => {
button?.click()
})
expect(setSettingsSearchQuery).toHaveBeenCalledWith('')
expect(openSettingsTarget).toHaveBeenCalledWith({
pane: 'terminal',
repoId: null,
sectionId: MANAGE_SESSIONS_SECTION_ID
})
expect(openSettingsPage).toHaveBeenCalled()
})
it('hides the navigation button on the Manage Sessions surface itself', async () => {
stubAttributionHealth('severed')
await act(async () => {
root.render(<TerminalTccAttributionNotice showManageSessionsButton={false} />)
})
expect(container.querySelector('[role="alert"]')).not.toBeNull()
expect(container.querySelector('button')).toBeNull()
})
it('refreshes the warning after the daemon restart remedy settles', async () => {
stubAttributionHealth('severed')
await act(async () => {
root.render(<TerminalTccAttributionNotice refreshRevision={0} />)
})
expect(container.querySelector('[role="alert"]')).not.toBeNull()
stubAttributionHealth('intact')
await act(async () => {
root.render(<TerminalTccAttributionNotice refreshRevision={1} />)
})
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(<TerminalTccAttributionNotice />)
})
expect(container.querySelector('[role="alert"]')).toBeNull()
})
@@ -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<void> => {
try {
const { health } = await window.api.pty.management.macTccAttribution()
setSevered(health === 'severed')
} catch {
setSevered(false)
}
}, [])
useEffect(() => {
void refresh()
// Why: a daemon restart or drain changes the verdict without a pane remount.
const onFocus = (): void => {
void refresh()
}
window.addEventListener('focus', onFocus)
return () => window.removeEventListener('focus', onFocus)
}, [refresh, 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 (
<div
role="alert"
className="flex items-start justify-between gap-4 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-amber-700 dark:text-amber-300"
>
<div className="flex min-w-0 items-start gap-2.5">
<TriangleAlert className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium">
{translate(
'auto.components.settings.TerminalTccAttributionNotice.title',
'macOS permission grants arent reaching terminals'
)}
</p>
<p className="text-xs leading-snug">
{translate(
'auto.components.settings.TerminalTccAttributionNotice.body',
'The terminal daemon was started by an Orca install that no longer exists, so macOS cant attribute its commands to Orca — Accessibility and Automation grants are silently ignored (osascript fails with error -25211). Restarting the daemon fixes this; running terminal sessions will close.'
)}
</p>
</div>
</div>
{props.showManageSessionsButton !== false && (
<Button variant="outline" size="sm" className="shrink-0" onClick={openManageSessions}>
{translate(
'auto.components.settings.TerminalTccAttributionNotice.openManageSessions',
'Open Manage Sessions'
)}
</Button>
)}
</div>
)
}
+5
View File
@@ -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 cant 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 arent reaching terminals"
}
},
"right": {
+3 -1
View File
@@ -3224,7 +3224,9 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
listSessions: () => Promise.resolve({ sessions: [], degraded: false }),
killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }),
killOne: () => Promise.resolve({ success: false }),
restart: () => Promise.resolve({ success: false })
restart: () => Promise.resolve({ success: false }),
// Why: web clients can't inspect the host daemon's pid record; 'unknown' keeps the banner hidden.
macTccAttribution: () => Promise.resolve({ health: 'unknown' as const })
}
}
}
+2 -1
View File
@@ -8,7 +8,8 @@ export const DAEMON_REPLACE_REASONS = [
'unhealthy_resolver',
'stale_bundle',
'different_app_path',
'failed_health_check'
'failed_health_check',
'severed_tcc_attribution'
] as const
export type DaemonReplaceReason = (typeof DAEMON_REPLACE_REASONS)[number]
@@ -208,4 +208,3 @@ export function scheduleSharedControlReconnect(args: {
}
return { timer, reconnectAttempt: args.reconnectAttempt + 1 }
}