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