mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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 })
|
||||
|
||||
@@ -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 }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
: {})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,19 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DaemonSessionInfo } from '../daemon/types'
|
||||
|
||||
const { handleMock, removeHandlerMock, getDaemonProviderMock, restartDaemonMock } = vi.hoisted(
|
||||
() => ({
|
||||
handleMock: vi.fn(),
|
||||
removeHandlerMock: vi.fn(),
|
||||
getDaemonProviderMock: vi.fn(),
|
||||
restartDaemonMock: vi.fn()
|
||||
})
|
||||
)
|
||||
const {
|
||||
handleMock,
|
||||
removeHandlerMock,
|
||||
getDaemonProviderMock,
|
||||
restartDaemonMock,
|
||||
getCurrentDaemonMacTccAttributionHealthMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
removeHandlerMock: vi.fn(),
|
||||
getDaemonProviderMock: vi.fn(),
|
||||
restartDaemonMock: vi.fn(),
|
||||
getCurrentDaemonMacTccAttributionHealthMock: vi.fn(async () => 'unknown')
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: handleMock, removeHandler: removeHandlerMock }
|
||||
@@ -16,7 +21,8 @@ vi.mock('electron', () => ({
|
||||
|
||||
vi.mock('../daemon/daemon-init', () => ({
|
||||
getDaemonProvider: getDaemonProviderMock,
|
||||
restartDaemon: restartDaemonMock
|
||||
restartDaemon: restartDaemonMock,
|
||||
getCurrentDaemonMacTccAttributionHealth: getCurrentDaemonMacTccAttributionHealthMock
|
||||
}))
|
||||
|
||||
// Why: the handler uses `provider instanceof DaemonPtyRouter` to branch
|
||||
@@ -140,6 +146,8 @@ describe('pty:management IPC handlers', () => {
|
||||
beforeEach(() => {
|
||||
getDaemonProviderMock.mockReset()
|
||||
restartDaemonMock.mockReset()
|
||||
getCurrentDaemonMacTccAttributionHealthMock.mockReset()
|
||||
getCurrentDaemonMacTccAttributionHealthMock.mockResolvedValue('unknown')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -456,6 +464,36 @@ describe('pty:management IPC handlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('macTccAttribution', () => {
|
||||
it('reports the daemon attribution health', async () => {
|
||||
getCurrentDaemonMacTccAttributionHealthMock.mockResolvedValue('severed')
|
||||
|
||||
const { registerDaemonManagementHandlers } = await importFresh()
|
||||
registerDaemonManagementHandlers()
|
||||
|
||||
const handlers = buildHandlerMap()
|
||||
const result = (await handlers['pty:management:macTccAttribution']({})) as {
|
||||
health: string
|
||||
}
|
||||
|
||||
expect(result.health).toBe('severed')
|
||||
})
|
||||
|
||||
it('fails open to unknown when the probe throws', async () => {
|
||||
getCurrentDaemonMacTccAttributionHealthMock.mockRejectedValue(new Error('no pid record'))
|
||||
|
||||
const { registerDaemonManagementHandlers } = await importFresh()
|
||||
registerDaemonManagementHandlers()
|
||||
|
||||
const handlers = buildHandlerMap()
|
||||
const result = (await handlers['pty:management:macTccAttribution']({})) as {
|
||||
health: string
|
||||
}
|
||||
|
||||
expect(result.health).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('restart', () => {
|
||||
it('delegates to restartDaemon and reports success', async () => {
|
||||
restartDaemonMock.mockResolvedValue({ killedCount: 2 })
|
||||
|
||||
@@ -2,7 +2,12 @@ import { ipcMain } from 'electron'
|
||||
import { DaemonPtyRouter } from '../daemon/daemon-pty-router'
|
||||
import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider'
|
||||
import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter'
|
||||
import { getDaemonProvider, restartDaemon } from '../daemon/daemon-init'
|
||||
import {
|
||||
getCurrentDaemonMacTccAttributionHealth,
|
||||
getDaemonProvider,
|
||||
restartDaemon
|
||||
} from '../daemon/daemon-init'
|
||||
import type { MacDaemonTccAttributionHealth } from '../daemon/daemon-health'
|
||||
import type { DaemonSessionInfo } from '../daemon/types'
|
||||
|
||||
// Why: poll past the daemon's 5s SIGTERM→SIGKILL ladder (KILL_TIMEOUT_MS in session.ts), else slow-exiting shells falsely look "refused".
|
||||
@@ -51,6 +56,19 @@ export function registerDaemonManagementHandlers(): void {
|
||||
ipcMain.removeHandler('pty:management:killAll')
|
||||
ipcMain.removeHandler('pty:management:killOne')
|
||||
ipcMain.removeHandler('pty:management:restart')
|
||||
ipcMain.removeHandler('pty:management:macTccAttribution')
|
||||
|
||||
// Why: lets Settings warn that macOS privacy grants no longer reach daemon terminals (STA-3491).
|
||||
ipcMain.handle(
|
||||
'pty:management:macTccAttribution',
|
||||
async (): Promise<{ health: MacDaemonTccAttributionHealth }> => {
|
||||
try {
|
||||
return { health: await getCurrentDaemonMacTccAttributionHealth() }
|
||||
} catch {
|
||||
return { health: 'unknown' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'pty:management:listSessions',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 aren’t reaching terminals')
|
||||
expect(alert?.textContent).toContain('-25211')
|
||||
|
||||
stubAttributionHealth('intact')
|
||||
await act(async () => {
|
||||
root.render(<TerminalTccAttributionNotice key="fresh" />)
|
||||
})
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('navigates to Manage Sessions from the banner action', async () => {
|
||||
stubAttributionHealth('severed')
|
||||
await act(async () => {
|
||||
root.render(<TerminalTccAttributionNotice />)
|
||||
})
|
||||
|
||||
const button = container.querySelector('button')
|
||||
expect(button?.textContent).toContain('Open Manage Sessions')
|
||||
await act(async () => {
|
||||
button?.click()
|
||||
})
|
||||
|
||||
expect(setSettingsSearchQuery).toHaveBeenCalledWith('')
|
||||
expect(openSettingsTarget).toHaveBeenCalledWith({
|
||||
pane: 'terminal',
|
||||
repoId: null,
|
||||
sectionId: MANAGE_SESSIONS_SECTION_ID
|
||||
})
|
||||
expect(openSettingsPage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the navigation button on the Manage Sessions surface itself', async () => {
|
||||
stubAttributionHealth('severed')
|
||||
await act(async () => {
|
||||
root.render(<TerminalTccAttributionNotice showManageSessionsButton={false} />)
|
||||
})
|
||||
expect(container.querySelector('[role="alert"]')).not.toBeNull()
|
||||
expect(container.querySelector('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('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 aren’t reaching terminals'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs leading-snug">
|
||||
{translate(
|
||||
'auto.components.settings.TerminalTccAttributionNotice.body',
|
||||
'The terminal daemon was started by an Orca install that no longer exists, so macOS can’t attribute its commands to Orca — Accessibility and Automation grants are silently ignored (osascript fails with error -25211). Restarting the daemon fixes this; running terminal sessions will close.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{props.showManageSessionsButton !== false && (
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={openManageSessions}>
|
||||
{translate(
|
||||
'auto.components.settings.TerminalTccAttributionNotice.openManageSessions',
|
||||
'Open Manage Sessions'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -3224,7 +3224,9 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
|
||||
listSessions: () => Promise.resolve({ sessions: [], degraded: false }),
|
||||
killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }),
|
||||
killOne: () => Promise.resolve({ success: false }),
|
||||
restart: () => Promise.resolve({ success: false })
|
||||
restart: () => Promise.resolve({ success: false }),
|
||||
// Why: web clients can't inspect the host daemon's pid record; 'unknown' keeps the banner hidden.
|
||||
macTccAttribution: () => Promise.resolve({ health: 'unknown' as const })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ export const DAEMON_REPLACE_REASONS = [
|
||||
'unhealthy_resolver',
|
||||
'stale_bundle',
|
||||
'different_app_path',
|
||||
'failed_health_check'
|
||||
'failed_health_check',
|
||||
'severed_tcc_attribution'
|
||||
] as const
|
||||
export type DaemonReplaceReason = (typeof DAEMON_REPLACE_REASONS)[number]
|
||||
|
||||
|
||||
@@ -208,4 +208,3 @@ export function scheduleSharedControlReconnect(args: {
|
||||
}
|
||||
return { timer, reconnectAttempt: args.reconnectAttempt + 1 }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user