diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index b3c3a6cc102..f8fc9f36060 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -1,7 +1,10 @@ const { chmodSync, existsSync, readdirSync, readFileSync, writeFileSync } = require('node:fs') const { execFileSync } = require('node:child_process') const { join, resolve } = require('node:path') -const { MACOS_FOLDER_USAGE_DESCRIPTIONS } = require('./scripts/macos-folder-usage-descriptions.cjs') +const { + MACOS_FOLDER_USAGE_DESCRIPTIONS, + applyMacHelperFolderUsageDescriptions +} = require('./scripts/macos-folder-usage-descriptions.cjs') const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs') const { assertPackagedDaemonEntryExists, @@ -397,6 +400,10 @@ module.exports = { writeFileSync(join(resourcesDir, 'package-type'), 'AppImage') } if (context.electronPlatformName === 'darwin') { + applyMacHelperFolderUsageDescriptions( + join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`), + context.packager.appInfo.productFilename + ) const architecture = macSliceArch(context.arch, 'local-build compatibility') const version = context.packager.appInfo.version let commit = process.env.ORCA_BUILD_COMMIT || process.env.GITHUB_SHA || 'unknown' diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index eacb68bd994..1a636561482 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -20719,7 +20719,11 @@ "protection": "partial", "owner": "terminal-runtime", "layer": "daemon-native-signing-and-provider-contract", - "surfaces": ["packaged macOS daemon launch", "signed bundle retirement"], + "surfaces": [ + "packaged macOS daemon launch", + "signed bundle retirement", + "Full Disk Access status" + ], "platforms": ["macos", "linux", "windows"], "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime", "mobile"], "coveredPlatforms": ["macos"], @@ -20727,9 +20731,9 @@ "coverageNotes": "Signed arm64 macOS evidence; other platforms and hosts keep the existing launcher. See docs/reference/macos-daemon-tcc-attribution.md.", "motivatingLinks": ["https://github.com/stablyai/orca/issues/13921"], "invariant": "A packaged macOS daemon runs from the verified signed helper copy for its build, which the updater never touches, and never beside a job that may own the endpoint; the fork fallback still fits the startup gate. A copy is deleted only when lsof proves it unused.", - "oracle": "Deterministic launch, fallback-deadline and retirement tests, plus the signed launcher validation recorded in #25848.", + "oracle": "Deterministic launch, fallback-deadline, per-build copy, retirement and status tests, plus the signed launcher validation recorded in #25848.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/daemon/macos-daemon-bundle.test.ts src/main/daemon/macos-daemon-launchd.test.ts src/main/daemon/daemon-init-macos-stable-launch-fallback.test.ts src/main/daemon/macos-daemon-job-state.test.ts src/main/daemon/macos-daemon-bundle-retirement.test.ts config/scripts/macos-folder-usage-descriptions.test.mjs" + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/daemon/macos-daemon-bundle.test.ts src/main/daemon/macos-daemon-launchd.test.ts src/main/daemon/daemon-init-macos-stable-launch-fallback.test.ts src/main/daemon/macos-daemon-job-state.test.ts src/main/daemon/macos-daemon-bundle-retirement.test.ts src/main/daemon/daemon-full-disk-access-status.test.ts src/main/daemon/daemon-pty-adapter-full-disk-access.test.ts config/scripts/macos-folder-usage-descriptions.test.mjs src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx" ], "testFiles": [ "src/main/daemon/macos-daemon-bundle.test.ts", @@ -20737,7 +20741,10 @@ "src/main/daemon/daemon-init-macos-stable-launch-fallback.test.ts", "src/main/daemon/macos-daemon-job-state.test.ts", "src/main/daemon/macos-daemon-bundle-retirement.test.ts", - "config/scripts/macos-folder-usage-descriptions.test.mjs" + "src/main/daemon/daemon-full-disk-access-status.test.ts", + "src/main/daemon/daemon-pty-adapter-full-disk-access.test.ts", + "config/scripts/macos-folder-usage-descriptions.test.mjs", + "src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx" ], "assertionRefs": [ { @@ -20768,10 +20775,10 @@ "date": "2026-10-10", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/daemon/macos-daemon-bundle.test.ts src/main/daemon/macos-daemon-launchd.test.ts src/main/daemon/daemon-init-macos-stable-launch-fallback.test.ts src/main/daemon/macos-daemon-job-state.test.ts src/main/daemon/macos-daemon-bundle-retirement.test.ts config/scripts/macos-folder-usage-descriptions.test.mjs", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/daemon/macos-daemon-bundle.test.ts src/main/daemon/macos-daemon-launchd.test.ts src/main/daemon/daemon-init-macos-stable-launch-fallback.test.ts src/main/daemon/macos-daemon-job-state.test.ts src/main/daemon/macos-daemon-bundle-retirement.test.ts src/main/daemon/daemon-full-disk-access-status.test.ts src/main/daemon/daemon-pty-adapter-full-disk-access.test.ts config/scripts/macos-folder-usage-descriptions.test.mjs src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx", "result": "passed", - "durationSeconds": 1.53, - "summary": "All 64 focused launch, fallback-deadline, per-build copy, retirement and packaging tests passed." + "durationSeconds": 2.38, + "summary": "All 82 focused launch, fallback-deadline, per-build copy, retirement, status and packaging tests passed." } ], "runtimeBudget": { diff --git a/config/scripts/macos-folder-usage-descriptions.cjs b/config/scripts/macos-folder-usage-descriptions.cjs index 7be46e809f2..6dd3e2c0736 100644 --- a/config/scripts/macos-folder-usage-descriptions.cjs +++ b/config/scripts/macos-folder-usage-descriptions.cjs @@ -1,3 +1,7 @@ +const { readdirSync, writeFileSync } = require('node:fs') +const { join } = require('node:path') +const { execFileSync } = require('node:child_process') + const MACOS_FOLDER_USAGE_DESCRIPTIONS = { NSDesktopFolderUsageDescription: "Orca allows terminal tools to access the user's Desktop folder when requested.", @@ -7,4 +11,26 @@ const MACOS_FOLDER_USAGE_DESCRIPTIONS = { "Orca allows terminal tools to access the user's Downloads folder when requested." } -module.exports = { MACOS_FOLDER_USAGE_DESCRIPTIONS } +function applyMacHelperFolderUsageDescriptions(appPath, productFilename, run = execFileSync) { + const frameworks = join(appPath, 'Contents', 'Frameworks') + for (const entry of readdirSync(frameworks, { withFileTypes: true })) { + if ( + !entry.isDirectory() || + !entry.name.startsWith(`${productFilename} Helper`) || + !entry.name.endsWith('.app') + ) { + continue + } + const plist = join(frameworks, entry.name, 'Contents', 'Info.plist') + const info = JSON.parse( + run('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plist], { encoding: 'utf8' }) + ) + if (!info || typeof info !== 'object' || Array.isArray(info)) { + throw new Error('The packaged macOS Helper has an invalid Info.plist') + } + writeFileSync(plist, JSON.stringify({ ...info, ...MACOS_FOLDER_USAGE_DESCRIPTIONS })) + run('/usr/bin/plutil', ['-convert', 'xml1', plist], { encoding: 'utf8' }) + } +} + +module.exports = { MACOS_FOLDER_USAGE_DESCRIPTIONS, applyMacHelperFolderUsageDescriptions } diff --git a/config/scripts/macos-folder-usage-descriptions.test.mjs b/config/scripts/macos-folder-usage-descriptions.test.mjs index 71969fbc046..8d5d9287222 100644 --- a/config/scripts/macos-folder-usage-descriptions.test.mjs +++ b/config/scripts/macos-folder-usage-descriptions.test.mjs @@ -1,11 +1,57 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { createRequire } from 'node:module' -import { expect, it } from 'vitest' +import { expect, it, vi } from 'vitest' const require = createRequire(import.meta.url) -const { MACOS_FOLDER_USAGE_DESCRIPTIONS } = require('./macos-folder-usage-descriptions.cjs') +const { + MACOS_FOLDER_USAGE_DESCRIPTIONS, + applyMacHelperFolderUsageDescriptions +} = require('./macos-folder-usage-descriptions.cjs') const config = require('../electron-builder.config.cjs') -it('gives the app the same three folder descriptions as the terminal host helper', () => { +it('uses the same three folder descriptions for the app and every packaged Electron Helper', () => { expect(config.mac.extendInfo).toMatchObject(MACOS_FOLDER_USAGE_DESCRIPTIONS) expect(Object.keys(MACOS_FOLDER_USAGE_DESCRIPTIONS)).toHaveLength(3) + const root = mkdtempSync(join(tmpdir(), 'orca-helper-privacy-')) + try { + const names = [ + 'Orca Dev Helper.app', + 'Orca Dev Helper (GPU).app', + 'Orca Dev Helper (Renderer).app', + 'Orca Dev Helper (Plugin).app', + 'Orca Computer.app' + ] + for (const name of names) { + const contents = join(root, 'Contents', 'Frameworks', name, 'Contents') + mkdirSync(contents, { recursive: true }) + writeFileSync( + join(contents, 'Info.plist'), + JSON.stringify({ CFBundleIdentifier: name, preserved: true }) + ) + } + const run = vi.fn((_program, args) => + args.includes('json') ? readFileSync(args.at(-1), 'utf8') : '' + ) + applyMacHelperFolderUsageDescriptions(root, 'Orca Dev', run) + for (const name of names.slice(0, 4)) { + expect( + JSON.parse( + readFileSync(join(root, 'Contents', 'Frameworks', name, 'Contents', 'Info.plist'), 'utf8') + ) + ).toEqual({ CFBundleIdentifier: name, preserved: true, ...MACOS_FOLDER_USAGE_DESCRIPTIONS }) + } + expect(run).toHaveBeenCalledTimes(8) + expect( + JSON.parse( + readFileSync( + join(root, 'Contents', 'Frameworks', names[4], 'Contents', 'Info.plist'), + 'utf8' + ) + ) + ).not.toHaveProperty('NSDesktopFolderUsageDescription') + } finally { + rmSync(root, { recursive: true, force: true }) + } }) diff --git a/docs/reference/macos-daemon-tcc-attribution.md b/docs/reference/macos-daemon-tcc-attribution.md index 12e676f75bd..26772dda46f 100644 --- a/docs/reference/macos-daemon-tcc-attribution.md +++ b/docs/reference/macos-daemon-tcc-attribution.md @@ -39,7 +39,8 @@ So a packaged macOS GUI app starts its daemon from a private copy that the updat normal authenticated handshake, fenced by the launch nonce. Node servers, SSH hosts, unpackaged builds, Linux and Windows keep the fork launcher. Adopting an -existing daemon never copies. +existing daemon never copies. Telemetry reports a daemon started from a copy as +`spawner_path_class=stable-copy`. Only Developer ID builds exercise the helper path in practice. A local ad hoc build's designated requirement is a cdhash, which the helper (a different binary) cannot match, so it falls back to the @@ -119,7 +120,9 @@ are still running are adopted as usual. Copies are only deleted by the collectio there (`retireOtherMacDaemonCopies(root, '')`) until no copy-launched daemon can remain; each copy keeps about 124 MiB. -## Existing sessions +## Existing sessions and status -Running old daemons keep their terminals; a denied terminal is recovered by closing it or restarting -the daemon. +Running old daemons keep their terminals; settings reports app and terminal-host Full Disk Access +separately, and a denied terminal is recovered by closing it or restarting the daemon. The packaging +hook also gives the main bundle and every Helper the Desktop, Documents and Downloads descriptions, +because the fork fallback still hosts terminals in `Orca Helper`. diff --git a/src/main/daemon/daemon-adoption-telemetry-event.test.ts b/src/main/daemon/daemon-adoption-telemetry-event.test.ts index fc493ea75ff..e4024f3f620 100644 --- a/src/main/daemon/daemon-adoption-telemetry-event.test.ts +++ b/src/main/daemon/daemon-adoption-telemetry-event.test.ts @@ -8,6 +8,7 @@ const { existsSyncMock, readFileSyncMock, getVersionMock, + getPathMock, codeIdentityMock } = vi.hoisted(() => ({ trackMock: vi.fn(), @@ -15,6 +16,7 @@ const { existsSyncMock: vi.fn(() => true), readFileSyncMock: vi.fn(), getVersionMock: vi.fn(() => '1.4.191'), + getPathMock: vi.fn((_name: string) => '/Users/alice/Library/Application Support/Orca'), codeIdentityMock: vi.fn(async () => 'parked') })) vi.mock('../telemetry/client', () => ({ track: trackMock })) @@ -35,7 +37,7 @@ vi.mock('node:os', async (importOriginal) => ({ homedir: () => '/Users/alice' })) vi.mock('../../shared/app-environment', () => ({ - getAppEnvironment: () => ({ getVersion: getVersionMock }) + getAppEnvironment: () => ({ getVersion: getVersionMock, getPath: getPathMock }) })) import { @@ -110,6 +112,24 @@ describe('classifyDaemonAdoptionOrigin', () => { }) expect(codeIdentityMock).toHaveBeenLastCalledWith(undefined) }) + + it('reports a daemon started from the private stable copy', async () => { + const copies = '/Users/alice/Library/Application Support/Orca/daemon-host/macos' + const stableCopy = { + ...stalePidRecord, + spawnerExecPath: `${copies}/terminal-host-0f3c/app.noindex/Orca Terminal Host.app/Contents/MacOS/orca-terminal-host` + } + expect(await classifyDaemonAdoptionOrigin(stableCopy)).toMatchObject({ + spawner_path_class: 'stable-copy' + }) + expect(getPathMock).toHaveBeenCalledWith('userData') + getPathMock.mockImplementationOnce(() => { + throw new Error('AppEnvironment not initialized') + }) + expect(await classifyDaemonAdoptionOrigin(stableCopy)).toMatchObject({ + spawner_path_class: 'other' + }) + }) }) describe('trackDaemonAdopted', () => { diff --git a/src/main/daemon/daemon-adoption-telemetry-event.ts b/src/main/daemon/daemon-adoption-telemetry-event.ts index e912abb1751..48be0060eaf 100644 --- a/src/main/daemon/daemon-adoption-telemetry-event.ts +++ b/src/main/daemon/daemon-adoption-telemetry-event.ts @@ -4,6 +4,7 @@ import { existsSync } from 'node:fs' import { homedir } from 'node:os' +import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import { classifyDaemonPtyCwd, @@ -17,6 +18,7 @@ import type { EventProps } from '../../shared/telemetry-events' import { track } from '../telemetry/client' import { readDaemonPidRecord } from './daemon-endpoint-incarnation' import { getDaemonMacCodeIdentity } from './daemon-mac-code-identity' +import { getMacDaemonBundleRoot, MAC_DAEMON_COPY_PREFIX } from './macos-daemon-bundle' import { enumerateDirectoryOnce } from './directory-enumeration-probe' import type { ParsedDaemonPid } from './daemon-pid-file-parse' import type { MacDaemonTccAttributionHealth } from './daemon-tcc-attribution' @@ -31,6 +33,17 @@ export type DaemonAdoptionOrigin = Pick< 'app_version_match' | 'code_identity' | 'spawner_path_class' > +function stableCopyPrefix(): string | null { + try { + return join( + getMacDaemonBundleRoot(getAppEnvironment().getPath('userData')), + MAC_DAEMON_COPY_PREFIX + ) + } catch { + return null + } +} + /** Classifies the adopted daemon's pid record against the running app; enum-only by construction. */ export async function classifyDaemonAdoptionOrigin( pidRecord: ParsedDaemonPid | null @@ -42,7 +55,8 @@ export async function classifyDaemonAdoptionOrigin( : 'different' const spawnerPathClass: DaemonSpawnerPathClass = classifyDaemonSpawnerPath( pidRecord?.spawnerExecPath ?? null, - existsSync + existsSync, + stableCopyPrefix() ) return { app_version_match: appVersionMatch, diff --git a/src/main/daemon/daemon-full-disk-access-status.test.ts b/src/main/daemon/daemon-full-disk-access-status.test.ts new file mode 100644 index 00000000000..b92c610a842 --- /dev/null +++ b/src/main/daemon/daemon-full-disk-access-status.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type { DeveloperPermissionStatus } from '../../shared/developer-permissions-types' + +const { state, current, legacy, DegradedProvider, localStatus } = vi.hoisted(() => ({ + state: { installed: true, degraded: false }, + DegradedProvider: class {}, + localStatus: vi.fn<() => Promise>(), + current: { getFullDiskAccessStatus: vi.fn<() => Promise>() }, + legacy: { getFullDiskAccessStatus: vi.fn<() => Promise>() } +})) +vi.mock('./daemon-provider-state', () => ({ + getDaemonProvider: () => (state.installed ? (state.degraded ? new DegradedProvider() : {}) : null) +})) +vi.mock('./degraded-daemon-pty-provider', () => ({ DegradedDaemonPtyProvider: DegradedProvider })) +vi.mock('../macos-full-disk-access-status', () => ({ getMacosFullDiskAccessStatus: localStatus })) +vi.mock('./daemon-provider-routing', () => ({ + getCurrentDaemonAdapter: () => current, + getLegacyDaemonAdapters: () => [legacy] +})) +import { getTerminalHostsFullDiskAccessStatus } from './daemon-full-disk-access-status' + +beforeEach(() => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + state.installed = true + state.degraded = false + localStatus.mockReset().mockResolvedValue('denied') + current.getFullDiskAccessStatus.mockReset().mockResolvedValue('granted') + legacy.getFullDiskAccessStatus.mockReset().mockResolvedValue('granted') +}) + +it.each(['granted', 'denied', 'unknown'] as const)( + 'includes the older generation when it reports %s', + async (status) => { + legacy.getFullDiskAccessStatus.mockResolvedValue(status) + expect(await getTerminalHostsFullDiskAccessStatus()).toBe(status) + expect(current.getFullDiskAccessStatus).toHaveBeenCalledTimes(1) + expect(legacy.getFullDiskAccessStatus).toHaveBeenCalledTimes(1) + } +) + +it('keeps an observed denial even if another generation cannot answer', async () => { + current.getFullDiskAccessStatus.mockResolvedValue('unknown') + legacy.getFullDiskAccessStatus.mockResolvedValue('denied') + expect(await getTerminalHostsFullDiskAccessStatus()).toBe('denied') +}) + +it('does not launch a daemon merely to check permissions', async () => { + state.installed = false + expect(await getTerminalHostsFullDiskAccessStatus()).toBe('unknown') + expect(current.getFullDiskAccessStatus).not.toHaveBeenCalled() +}) + +it('includes the in-process host used for fresh terminals in degraded mode', async () => { + state.degraded = true + expect(await getTerminalHostsFullDiskAccessStatus()).toBe('denied') + expect(localStatus).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/daemon/daemon-full-disk-access-status.ts b/src/main/daemon/daemon-full-disk-access-status.ts new file mode 100644 index 00000000000..a7fc132cb5f --- /dev/null +++ b/src/main/daemon/daemon-full-disk-access-status.ts @@ -0,0 +1,25 @@ +import type { DeveloperPermissionStatus } from '../../shared/developer-permissions-types' +import { getCurrentDaemonAdapter, getLegacyDaemonAdapters } from './daemon-provider-routing' +import { getDaemonProvider } from './daemon-provider-state' +import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' +import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status' + +/** macOS only; the caller gates it. A denied generation wins; granted needs every generation. */ +export async function getTerminalHostsFullDiskAccessStatus(): Promise { + const provider = getDaemonProvider() + if (!provider) { + return 'unknown' + } + const probes = [getCurrentDaemonAdapter(provider), ...getLegacyDaemonAdapters(provider)].map( + (adapter) => adapter.getFullDiskAccessStatus() + ) + if (provider instanceof DegradedDaemonPtyProvider) { + probes.push(getMacosFullDiskAccessStatus()) + } + const statuses = await Promise.all(probes) + return statuses.includes('denied') + ? 'denied' + : statuses.every((status) => status === 'granted') + ? 'granted' + : 'unknown' +} diff --git a/src/main/daemon/daemon-health-requests.ts b/src/main/daemon/daemon-health-requests.ts new file mode 100644 index 00000000000..60c7170adfa --- /dev/null +++ b/src/main/daemon/daemon-health-requests.ts @@ -0,0 +1,19 @@ +export type PingRequest = { + id: string + type: 'ping' +} + +export type SystemResolverHealthRequest = { + id: string + type: 'systemResolverHealth' +} + +export type PtySpawnHealthRequest = { + id: string + type: 'ptySpawnHealth' +} + +export type FullDiskAccessStatusRequest = { + id: string + type: 'fullDiskAccessStatus' +} diff --git a/src/main/daemon/daemon-pty-adapter-full-disk-access.test.ts b/src/main/daemon/daemon-pty-adapter-full-disk-access.test.ts new file mode 100644 index 00000000000..1f45be336a5 --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-full-disk-access.test.ts @@ -0,0 +1,37 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { DaemonClient } from './client' +import { DaemonPtyAdapter } from './daemon-pty-adapter' + +afterEach(() => vi.restoreAllMocks()) + +it.each([ + [{ status: 'granted' }, 'granted'], + [{ status: 'denied' }, 'denied'], + [{ status: 'future-status' }, 'unknown'], + [null, 'unknown'] +])('checks the owning connection and validates %j', async (response, expected) => { + vi.spyOn(DaemonClient.prototype, 'isConnected').mockReturnValue(true) + const request = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue(response) + const adapter = new DaemonPtyAdapter({ socketPath: '/unused', tokenPath: '/unused' }) + expect(await adapter.getFullDiskAccessStatus()).toBe(expected) + expect(request).toHaveBeenCalledWith('fullDiskAccessStatus', undefined, 3000) +}) + +it.each(['Unknown request type', 'Request timed out', 'Connection lost'])( + 'keeps old or unreachable hosts unknown: %s', + async (message) => { + vi.spyOn(DaemonClient.prototype, 'isConnected').mockReturnValue(true) + vi.spyOn(DaemonClient.prototype, 'request').mockRejectedValue(new Error(message)) + const adapter = new DaemonPtyAdapter({ socketPath: '/unused', tokenPath: '/unused' }) + expect(await adapter.getFullDiskAccessStatus()).toBe('unknown') + } +) + +it('does not reconnect or respawn a disconnected host to obtain a verdict', async () => { + const request = vi.spyOn(DaemonClient.prototype, 'request') + const connect = vi.spyOn(DaemonClient.prototype, 'ensureConnected') + const adapter = new DaemonPtyAdapter({ socketPath: '/unused', tokenPath: '/unused' }) + expect(await adapter.getFullDiskAccessStatus()).toBe('unknown') + expect(request).not.toHaveBeenCalled() + expect(connect).not.toHaveBeenCalled() +}) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 098142a3fe7..af08ffc3c7d 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -3,6 +3,7 @@ import { DaemonPtyDaemonRecovery } from './daemon-pty-daemon-recovery' import { supportsMode2031UnsubscribeFact, type DaemonEvent } from './types' import type { DaemonEndpointIdentity } from './daemon-hello-protocol' import type { IPtyProvider } from '../providers/types' +import type { DeveloperPermissionStatus } from '../../shared/developer-permissions-types' import { AUTOMATIC_WORK_RESUME_UNSUPPORTED, type AutomaticWorkResumeOutcome, @@ -13,6 +14,24 @@ import { isUnknownRequestTypeError } from './daemon-endpoint-errors' import type { AutomaticWorkRequestType } from './daemon-automatic-work-protocol' export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyProvider { + async getFullDiskAccessStatus(): Promise { + if (!this.client.isConnected()) { + return 'unknown' + } + try { + const result = await this.client.request('fullDiskAccessStatus', undefined, 3000) + if (result && typeof result === 'object' && 'status' in result) { + const { status } = result + if (status === 'granted' || status === 'denied' || status === 'unsupported') { + return status + } + } + } catch { + // Older daemons and lost host contact cannot establish a permission verdict. + } + return 'unknown' + } + /** Identity of the daemon behind this adapter; null until hello completes or after a disconnect. */ getDaemonIdentity(): DaemonEndpointIdentity | null { return this.client.getDaemonIdentity() diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index 1184517c6dc..28f4d073a0d 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -1,6 +1,7 @@ import { performance } from 'node:perf_hooks' import { setPtyOwnerHostColors } from '../../shared/pty-owner-color-query-colors' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' +import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status' import type { ConnectedDaemonClient, DaemonClientConnections } from './daemon-client-connections' import type { DaemonFileLog } from './daemon-file-log' import type { DaemonPtySpawnPreparations } from './daemon-pty-spawn-preparations' @@ -155,6 +156,8 @@ export class DaemonRequestRouter { return { pong: true } case 'systemResolverHealth': return { health: await readCurrentProcessMacSystemResolverHealth() } + case 'fullDiskAccessStatus': + return { status: await getMacosFullDiskAccessStatus() } case 'ptySpawnHealth': await this.options.ptySpawnHealthCheck() return { healthy: true, coverage: ptySpawnHealthPlatformCoverage() } diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 3479b4eea45..8d30326e857 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -437,6 +437,13 @@ describe('DaemonServer', () => { expect(['healthy', 'unhealthy', 'unknown']).toContain(result.health) }) + it('probes Full Disk Access in the owning daemon', async () => { + await startServer() + const c = await connectClient() + const result = await c.request<{ status: unknown }>('fullDiskAccessStatus', undefined) + expect(['granted', 'denied', 'unknown', 'unsupported']).toContain(result.status) + }) + it('handles resize', async () => { await startServer() const c = await connectClient() diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index ff7068fecf0..5819978ce26 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,3 +1,9 @@ +import type { + FullDiskAccessStatusRequest, + PingRequest, + PtySpawnHealthRequest, + SystemResolverHealthRequest +} from './daemon-health-requests' import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { ConfirmForegroundProcessRequest, @@ -221,21 +227,6 @@ export type ShutdownRequest = { } } -export type PingRequest = { - id: string - type: 'ping' -} - -export type SystemResolverHealthRequest = { - id: string - type: 'systemResolverHealth' -} - -export type PtySpawnHealthRequest = { - id: string - type: 'ptySpawnHealth' -} - export type GetSnapshotRequest = { id: string type: 'getSnapshot' @@ -325,6 +316,7 @@ export type DaemonRequest = | PingRequest | SystemResolverHealthRequest | PtySpawnHealthRequest + | FullDiskAccessStatusRequest | GetSnapshotRequest | GetSizeRequest | TakePendingOutputRequest diff --git a/src/main/ipc/developer-permissions.test.ts b/src/main/ipc/developer-permissions.test.ts index 5bece99b825..bfea16e6ad8 100644 --- a/src/main/ipc/developer-permissions.test.ts +++ b/src/main/ipc/developer-permissions.test.ts @@ -7,6 +7,7 @@ const { getMediaAccessStatusMock, isTrustedAccessibilityClientMock, getMacosFullDiskAccessStatusMock, + terminalHostsStatusMock, execFileMock, createSocketMock, socketMock, @@ -31,6 +32,7 @@ const { getMediaAccessStatusMock: vi.fn(), isTrustedAccessibilityClientMock: vi.fn(), getMacosFullDiskAccessStatusMock: vi.fn(), + terminalHostsStatusMock: vi.fn(async () => 'denied'), execFileMock: vi.fn(), createSocketMock: vi.fn(() => socketMock), socketMock, @@ -65,6 +67,9 @@ vi.mock('node:child_process', () => ({ vi.mock('../macos-full-disk-access-status', () => ({ getMacosFullDiskAccessStatus: getMacosFullDiskAccessStatusMock })) +vi.mock('../daemon/daemon-full-disk-access-status', () => ({ + getTerminalHostsFullDiskAccessStatus: terminalHostsStatusMock +})) import type { DeveloperPermissionState } from '../../shared/developer-permissions-types' import { registerDeveloperPermissionHandlers } from './developer-permissions' @@ -166,10 +171,24 @@ describe('registerDeveloperPermissionHandlers', () => { await expect(handler?.()).resolves.toContainEqual({ id: 'full-disk-access', - status: 'granted' + status: 'granted', + terminalHostStatus: 'denied' }) }) + it.each(['win32', 'linux'] as const)('does not probe terminal hosts on %s', async (platform) => { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + getMacosFullDiskAccessStatusMock.mockResolvedValue('unsupported') + terminalHostsStatusMock.mockClear() + registerDeveloperPermissionHandlers() + + await expect(getStatusHandler()()).resolves.toContainEqual({ + id: 'full-disk-access', + status: 'unsupported' + }) + expect(terminalHostsStatusMock).not.toHaveBeenCalled() + }) + it('keeps the local-network status unknown when UDP send settles without an error', async () => { registerDeveloperPermissionHandlers() diff --git a/src/main/ipc/developer-permissions.ts b/src/main/ipc/developer-permissions.ts index 1ce5e228324..8116af8f6b7 100644 --- a/src/main/ipc/developer-permissions.ts +++ b/src/main/ipc/developer-permissions.ts @@ -2,6 +2,7 @@ import { execFile } from 'node:child_process' import dgram from 'node:dgram' import { ipcMain, shell, systemPreferences } from 'electron' import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status' +import { getTerminalHostsFullDiskAccessStatus } from '../daemon/daemon-full-disk-access-status' import { testLocalNetworkConnection } from './local-network-connection-test' import type { DeveloperPermissionId, @@ -153,7 +154,13 @@ async function getPermissionState(id: DeveloperPermissionId): Promise ({ + readMock: vi.fn(), + closeMock: vi.fn(), + openMock: vi.fn() +})) +vi.mock('node:fs/promises', () => ({ open: openMock })) import { getMacosFullDiskAccessStatus, probeMacosFullDiskAccess @@ -22,15 +28,34 @@ function fileSystemError(code: string): NodeJS.ErrnoException { afterEach(() => { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) }) +beforeEach(() => { + readMock.mockReset().mockResolvedValue({ bytesRead: 1 }) + closeMock.mockReset().mockResolvedValue(undefined) + openMock.mockReset().mockResolvedValue({ read: readMock, close: closeMock }) +}) describe('probeMacosFullDiskAccess', () => { - it('reports granted only when the TCC database opens for reading', async () => { + it('reports granted only when the TCC database can be read', async () => { const readProbe = vi.fn().mockResolvedValue(undefined) await expect(probeMacosFullDiskAccess({ homeDirectory, readProbe })).resolves.toBe('granted') expect(readProbe).toHaveBeenCalledWith(databasePath) }) + it('reads only one discarded byte and closes the handle', async () => { + expect(await probeMacosFullDiskAccess({ homeDirectory })).toBe('granted') + expect(openMock).toHaveBeenCalledWith(databasePath, 'r') + expect(readMock).toHaveBeenCalledWith(expect.any(Buffer), 0, 1, 0) + expect(readMock.mock.calls[0]?.[0].length).toBe(1) + expect(closeMock).toHaveBeenCalledTimes(1) + }) + + it('does not mistake an allowed open for allowed contents, and closes after denial', async () => { + readMock.mockRejectedValueOnce(fileSystemError('EPERM')) + expect(await probeMacosFullDiskAccess({ homeDirectory })).toBe('denied') + expect(closeMock).toHaveBeenCalledTimes(1) + }) + it.each(['EACCES', 'EPERM'])('reports denied for %s', async (code) => { await expect( probeMacosFullDiskAccess({ diff --git a/src/main/macos-full-disk-access-status.ts b/src/main/macos-full-disk-access-status.ts index aea18b95677..44af4fe8d29 100644 --- a/src/main/macos-full-disk-access-status.ts +++ b/src/main/macos-full-disk-access-status.ts @@ -5,20 +5,24 @@ import type { DeveloperPermissionStatus } from '../shared/developer-permissions- type ReadProbe = (filePath: string) => Promise -async function openForRead(filePath: string): Promise { +async function readProbeByte(filePath: string): Promise { const handle = await open(filePath, 'r') - await handle.close() + try { + await handle.read(Buffer.alloc(1), 0, 1, 0) + } finally { + await handle.close() + } } function errorCode(error: unknown): string | undefined { - return error instanceof Error && 'code' in error - ? (error as NodeJS.ErrnoException).code + return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code : undefined } export async function probeMacosFullDiskAccess({ homeDirectory = homedir(), - readProbe = openForRead + readProbe = readProbeByte }: { homeDirectory?: string readProbe?: ReadProbe diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx index 6400e71795e..f33bc4a8b95 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx @@ -53,6 +53,23 @@ function setPermissionStates(states: DeveloperPermissionState[]): void { api.developerPermissions.getStatus = vi.fn(async () => states) } +it.each(['denied', 'unknown'] as const)( + 'distinguishes app access from a %s terminal host', + async (terminalHostStatus) => { + setPermissionStates([{ id: 'full-disk-access', status: 'granted', terminalHostStatus }]) + await act(async () => root.render()) + const row = container.querySelector( + '[data-settings-section="developer-permissions-full-disk-access"]' + ) + expect(row?.textContent).toContain('App: Granted') + expect(row?.textContent).toContain( + terminalHostStatus === 'denied' ? 'Terminal hosts: Denied' : 'Terminal hosts: Check manually' + ) + expect(row?.textContent).toContain('restarting closes its current terminals') + expect(window.api.developerPermissions.request).not.toHaveBeenCalled() + } +) + it('requests Local Network access without claiming a permission verdict', async () => { setPermissionStates([{ id: 'local-network', status: 'unknown' }]) diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx index 18b48aa918b..2abd9d7a9c5 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx @@ -231,7 +231,7 @@ export function DeveloperPermissionsPane({ const refreshSequenceRef = useRef(0) const stateById = useMemo( - () => new Map(states.map((state) => [state.id, state.status] as const)), + () => new Map(states.map((state) => [state.id, state] as const)), [states] ) @@ -341,7 +341,8 @@ export function DeveloperPermissionsPane({
{PERMISSIONS.map((permission) => { - const status = stateById.get(permission.id) + const state = stateById.get(permission.id) + const status = state?.status const pending = pendingId === permission.id const settingId = `developer-permissions-${permission.id}` @@ -362,10 +363,33 @@ export function DeveloperPermissionsPane({ status )}`} > + {permission.id === 'full-disk-access' && + translate( + 'auto.components.settings.DeveloperPermissionsPane.appStatus', + 'App: ' + )} {developerPermissionStatusLabel(permission.id, status)}

{permission.description}

+ {permission.id === 'full-disk-access' && state?.terminalHostStatus && ( +

+ {translate( + 'auto.components.settings.DeveloperPermissionsPane.terminalHostStatus', + 'Terminal hosts: ' + )} + {developerPermissionStatusLabel(permission.id, state.terminalHostStatus)} + {state.terminalHostStatus !== 'granted' && ( + <> + {' '} + {translate( + 'auto.components.settings.DeveloperPermissionsPane.terminalHostRecovery', + 'If a terminal cannot read protected files, save its work and close it in Terminal → Manage Sessions. Restart the daemon if new terminals are also affected; restarting closes its current terminals.' + )} + + )} +

+ )} { it('classifies the installed app, the ShipIt staging area, and everything else', () => { expect( - classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists) + classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists, null) ).toBe('applications') expect( - classifyDaemonSpawnerPath('/private/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists) + classifyDaemonSpawnerPath( + '/private/Applications/Orca.app/Contents/MacOS/Orca', + alwaysExists, + null + ) ).toBe('applications') expect( classifyDaemonSpawnerPath( '/Users/a/Library/Caches/com.stablyai.orca.ShipIt/update.abc/Orca.app/Contents/MacOS/Orca', - alwaysExists + alwaysExists, + null ) ).toBe('updater-cache') expect( - classifyDaemonSpawnerPath('/Users/a/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists) + classifyDaemonSpawnerPath( + '/Users/a/Applications/Orca.app/Contents/MacOS/Orca', + alwaysExists, + null + ) + ).toBe('other') + expect( + classifyDaemonSpawnerPath('/tmp/OrcaA.app/Contents/MacOS/Orca', alwaysExists, null) ).toBe('other') - expect(classifyDaemonSpawnerPath('/tmp/OrcaA.app/Contents/MacOS/Orca', alwaysExists)).toBe( - 'other' - ) }) it('reports a deleted spawner as missing and an unrecorded one as unknown', () => { expect( - classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', () => false) + classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', () => false, null) ).toBe('missing') - expect(classifyDaemonSpawnerPath(null, alwaysExists)).toBe('unknown') + expect(classifyDaemonSpawnerPath(null, alwaysExists, null)).toBe('unknown') + }) + + it("classifies Orca's private stable copy from the recorded spawner path", () => { + const prefix = '/Users/alice/Library/Application Support/Orca/daemon-host/macos/terminal-host-' + const copy = `${prefix}0f3c/app.noindex/Orca Terminal Host.app/Contents/MacOS/orca-terminal-host` + expect(classifyDaemonSpawnerPath(copy, alwaysExists, prefix)).toBe('stable-copy') + expect(classifyDaemonSpawnerPath(copy, () => false, prefix)).toBe('missing') + expect(classifyDaemonSpawnerPath(copy, alwaysExists, null)).toBe('other') + expect( + classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists, prefix) + ).toBe('applications') }) }) @@ -106,6 +126,16 @@ describe('daemon_adopted / daemon_pty_cwd_denied schemas', () => { expect( eventSchemas.daemon_adopted.safeParse({ ...adopted, code_identity: 'severed' }).success ).toBe(false) + expect( + eventSchemas.daemon_pty_cwd_readable.safeParse({ ...denied, launch_method: 'stable-copy' }) + .success + ).toBe(false) + expect( + eventSchemas.daemon_pty_cwd_readable.safeParse({ + ...denied, + spawner_path_class: 'stable-copy' + }).success + ).toBe(true) }) }) diff --git a/src/shared/daemon-adoption-telemetry.ts b/src/shared/daemon-adoption-telemetry.ts index 149dc4da910..3318f4f5e68 100644 --- a/src/shared/daemon-adoption-telemetry.ts +++ b/src/shared/daemon-adoption-telemetry.ts @@ -9,10 +9,12 @@ export type DaemonAdoptedAppVersionMatch = (typeof DAEMON_ADOPTED_APP_VERSION_MA /** * Where the binary that forked the adopted daemon lives now. `updater-cache` is the Squirrel - * ShipIt staging area — a daemon attributed there is the reported #17696 shape. + * ShipIt staging area — a daemon attributed there is the reported #17696 shape. `stable-copy` is + * Orca's private signed bundle copy, run by launchd (#25848). */ export const DAEMON_SPAWNER_PATH_CLASSES = [ 'applications', + 'stable-copy', 'updater-cache', 'other', 'missing', @@ -60,7 +62,8 @@ export function isMacTccFolderClass(cwdClass: DaemonPtyCwdClass): cwdClass is Ma export function classifyDaemonSpawnerPath( spawnerExecPath: string | null, - exists: (path: string) => boolean + exists: (path: string) => boolean, + stableCopyPrefix: string | null ): DaemonSpawnerPathClass { if (!spawnerExecPath) { return 'unknown' @@ -68,6 +71,9 @@ export function classifyDaemonSpawnerPath( if (!exists(spawnerExecPath)) { return 'missing' } + if (stableCopyPrefix && spawnerExecPath.startsWith(stableCopyPrefix)) { + return 'stable-copy' + } if (/\/Library\/Caches\/[^/]*ShipIt\//.test(spawnerExecPath)) { return 'updater-cache' } diff --git a/src/shared/developer-permissions-types.ts b/src/shared/developer-permissions-types.ts index 6b541e62fef..20255575257 100644 --- a/src/shared/developer-permissions-types.ts +++ b/src/shared/developer-permissions-types.ts @@ -24,6 +24,7 @@ export type DeveloperPermissionStatus = export type DeveloperPermissionState = { id: DeveloperPermissionId status: DeveloperPermissionStatus + terminalHostStatus?: DeveloperPermissionStatus } export type DeveloperPermissionRequestResult = {