From 09f083da99503cac984cddfa8b118a8de6fe3038 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:04:38 -0700
Subject: [PATCH] fix(macos): ship privacy usage-description strings in the
helper bundle
The detached PTY daemon keeps running inside the nested helper bundle
after the main app quits, so macOS TCC treats the helper as the
requesting app. The helper shipped no NS*UsageDescription keys, so
consent dialogs were never offered and terminals hit silent EPERM.
Merge the main app's usage strings into every nested helper Info.plist
during afterPack, before code signing seals the bundles.
Addresses one cause of #9756.
---
config/electron-builder.config.cjs | 31 ++--
config/mac-privacy-usage-descriptions.cjs | 54 +++++++
.../scripts/electron-builder-config.test.mjs | 140 ++++++++++++++++++
3 files changed, 206 insertions(+), 19 deletions(-)
create mode 100644 config/mac-privacy-usage-descriptions.cjs
diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs
index 50cfe7691e8..e098612fdd2 100644
--- a/config/electron-builder.config.cjs
+++ b/config/electron-builder.config.cjs
@@ -11,6 +11,10 @@ const {
prunePackagedRuntimeNodeModules,
verifyPackagedMainRuntimeDeps
} = require('./packaged-runtime-node-modules.cjs')
+const {
+ extendMacHelperBundleInfoPlists,
+ macPrivacyUsageDescriptions
+} = require('./mac-privacy-usage-descriptions.cjs')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
@@ -177,6 +181,12 @@ module.exports = {
chmodSync(join(resourcesDir, filename), 0o755)
}
if (context.electronPlatformName === 'darwin') {
+ // Why: the detached PTY daemon outlives the main app inside the Electron
+ // helper bundle, which TCC then treats as the requesting app (#9756).
+ // afterPack runs before electron-builder signs, so the edits get sealed.
+ extendMacHelperBundleInfoPlists(
+ join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
+ )
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
await signMacNotificationStatusHelper(
join(resourcesDir, '..', 'MacOS', 'orca-notification-status'),
@@ -229,25 +239,8 @@ module.exports = {
entitlements: 'resources/build/entitlements.mac.plist',
entitlementsInherit: 'resources/build/entitlements.mac.plist',
extendInfo: {
- NSAppleEventsUsageDescription:
- 'Orca allows terminal-launched developer tools to automate local apps when you request it.',
- NSBluetoothAlwaysUsageDescription:
- 'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.',
- NSBluetoothPeripheralUsageDescription:
- 'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.',
- NSCameraUsageDescription: "Application requests access to the device's camera.",
- NSLocationUsageDescription:
- 'Orca allows terminal-launched developer tools to access location when you request it.',
- NSLocalNetworkUsageDescription:
- 'Orca allows terminal-launched developer tools to discover and connect to local development servers when you request it.',
- NSMicrophoneUsageDescription: "Application requests access to the device's microphone.",
- NSAudioCaptureUsageDescription:
- 'Orca allows terminal-launched developer tools to capture desktop audio when you request it.',
- NSBonjourServices: ['_http._tcp', '_https._tcp'],
- NSDocumentsFolderUsageDescription:
- "Application requests access to the user's Documents folder.",
- NSDownloadsFolderUsageDescription:
- "Application requests access to the user's Downloads folder."
+ ...macPrivacyUsageDescriptions,
+ NSBonjourServices: ['_http._tcp', '_https._tcp']
},
// Why: local macOS validation builds should launch without Apple release
// credentials. Hardened runtime + notarization stay enabled only on the
diff --git a/config/mac-privacy-usage-descriptions.cjs b/config/mac-privacy-usage-descriptions.cjs
new file mode 100644
index 00000000000..c14e1fabcd9
--- /dev/null
+++ b/config/mac-privacy-usage-descriptions.cjs
@@ -0,0 +1,54 @@
+const { execFileSync } = require('node:child_process')
+const { existsSync, readdirSync } = require('node:fs')
+const { join } = require('node:path')
+
+// Why: macOS shows a consent dialog only when the requesting bundle declares a
+// usage description for the service. The detached PTY daemon keeps running
+// inside the Electron helper bundle after the main app quits, so the helper is
+// the effective requesting app and needs the same strings as the main app —
+// otherwise TCC denies with a silent EPERM and no dialog (#9756).
+const macPrivacyUsageDescriptions = {
+ NSAppleEventsUsageDescription:
+ 'Orca allows terminal-launched developer tools to automate local apps when you request it.',
+ NSBluetoothAlwaysUsageDescription:
+ 'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.',
+ NSBluetoothPeripheralUsageDescription:
+ 'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.',
+ NSCameraUsageDescription: "Application requests access to the device's camera.",
+ NSLocationUsageDescription:
+ 'Orca allows terminal-launched developer tools to access location when you request it.',
+ NSLocalNetworkUsageDescription:
+ 'Orca allows terminal-launched developer tools to discover and connect to local development servers when you request it.',
+ NSMicrophoneUsageDescription: "Application requests access to the device's microphone.",
+ NSAudioCaptureUsageDescription:
+ 'Orca allows terminal-launched developer tools to capture desktop audio when you request it.',
+ NSDocumentsFolderUsageDescription: "Application requests access to the user's Documents folder.",
+ NSDownloadsFolderUsageDescription: "Application requests access to the user's Downloads folder."
+}
+
+function extendMacHelperBundleInfoPlists(appPath) {
+ const frameworksDir = join(appPath, 'Contents', 'Frameworks')
+ const helperPlistPaths = existsSync(frameworksDir)
+ ? readdirSync(frameworksDir)
+ .filter((entry) => entry.includes(' Helper') && entry.endsWith('.app'))
+ .map((entry) => join(frameworksDir, entry, 'Contents', 'Info.plist'))
+ .filter((plistPath) => existsSync(plistPath))
+ : []
+ if (helperPlistPaths.length === 0) {
+ // Why: a darwin package always nests Electron helper apps; an empty match
+ // means a layout drift that would silently drop the TCC strings again.
+ throw new Error(`No Electron helper app Info.plist found under ${frameworksDir}`)
+ }
+ for (const plistPath of helperPlistPaths) {
+ for (const [key, value] of Object.entries(macPrivacyUsageDescriptions)) {
+ // Why: plutil -replace inserts the key when absent, so this is a merge.
+ execFileSync('plutil', ['-replace', key, '-string', value, plistPath])
+ }
+ }
+ return helperPlistPaths
+}
+
+module.exports = {
+ macPrivacyUsageDescriptions,
+ extendMacHelperBundleInfoPlists
+}
diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs
index 3e16b20e26a..bb5ba182759 100644
--- a/config/scripts/electron-builder-config.test.mjs
+++ b/config/scripts/electron-builder-config.test.mjs
@@ -1,3 +1,4 @@
+import { execFileSync } from 'node:child_process'
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
@@ -7,6 +8,10 @@ import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
+const {
+ extendMacHelperBundleInfoPlists,
+ macPrivacyUsageDescriptions
+} = require('../mac-privacy-usage-descriptions.cjs')
const {
createPackagedRuntimeNodeModuleResources,
findAsarEntry,
@@ -93,6 +98,141 @@ describe('electron-builder config', () => {
)
})
+ // Why: the detached PTY daemon runs inside the Electron helper bundle, so the
+ // helper needs the same TCC usage descriptions as the main app (#9756).
+ it('keeps the main-app extendInfo privacy strings in the shared helper-bundle map', () => {
+ expect(electronBuilderConfig.mac.extendInfo).toMatchObject(macPrivacyUsageDescriptions)
+ const extendInfoUsageKeys = Object.keys(electronBuilderConfig.mac.extendInfo).filter((key) =>
+ key.endsWith('UsageDescription')
+ )
+ expect(extendInfoUsageKeys.sort()).toEqual(Object.keys(macPrivacyUsageDescriptions).sort())
+ })
+
+ it('fails loudly when a darwin package carries no helper bundles', async () => {
+ const appPath = await mkdtemp(join(tmpdir(), 'orca-no-helper-bundles-'))
+ try {
+ await mkdir(join(appPath, 'Contents', 'Frameworks'), { recursive: true })
+ expect(() => extendMacHelperBundleInfoPlists(appPath)).toThrow(
+ /No Electron helper app Info\.plist/
+ )
+ } finally {
+ await rm(appPath, { recursive: true, force: true })
+ }
+ })
+
+ it.skipIf(process.platform !== 'darwin')(
+ 'merges privacy usage descriptions into every helper bundle Info.plist',
+ async () => {
+ const appPath = await mkdtemp(join(tmpdir(), 'orca-helper-usage-strings-'))
+ try {
+ const helperNames = ['Orca Helper.app', 'Orca Helper (Renderer).app']
+ for (const helperName of helperNames) {
+ const helperContents = join(appPath, 'Contents', 'Frameworks', helperName, 'Contents')
+ await mkdir(helperContents, { recursive: true })
+ await writeFile(
+ join(helperContents, 'Info.plist'),
+ [
+ '',
+ '',
+ '',
+ '',
+ ' CFBundleIdentifier',
+ ' com.stablyai.orca.helper',
+ '',
+ ''
+ ].join('\n'),
+ 'utf8'
+ )
+ }
+
+ const updatedPlists = extendMacHelperBundleInfoPlists(appPath)
+
+ expect(updatedPlists).toHaveLength(helperNames.length)
+ for (const helperName of helperNames) {
+ const plistJson = JSON.parse(
+ execFileSync(
+ 'plutil',
+ [
+ '-convert',
+ 'json',
+ '-o',
+ '-',
+ join(appPath, 'Contents', 'Frameworks', helperName, 'Contents', 'Info.plist')
+ ],
+ { encoding: 'utf8' }
+ )
+ )
+ expect(plistJson).toMatchObject(macPrivacyUsageDescriptions)
+ expect(plistJson.CFBundleIdentifier).toBe('com.stablyai.orca.helper')
+ }
+ } finally {
+ await rm(appPath, { recursive: true, force: true })
+ }
+ }
+ )
+
+ it.skipIf(process.platform !== 'darwin')(
+ 'extends helper bundle plists from the darwin afterPack hook',
+ async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-darwin-after-pack-'))
+ try {
+ const appPath = join(root, 'Orca.app')
+ const resourcesDir = join(appPath, 'Contents', 'Resources')
+ const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main')
+ await mkdir(unpackedMainDir, { recursive: true })
+ await writeFile(
+ join(unpackedMainDir, 'daemon-entry.js'),
+ 'console.error("Usage: daemon-entry "); process.exit(1)\n',
+ 'utf8'
+ )
+ const helperContents = join(
+ appPath,
+ 'Contents',
+ 'Frameworks',
+ 'Orca Helper.app',
+ 'Contents'
+ )
+ await mkdir(helperContents, { recursive: true })
+ await writeFile(
+ join(helperContents, 'Info.plist'),
+ [
+ '',
+ '',
+ '',
+ '',
+ ' CFBundleIdentifier',
+ ' com.stablyai.orca.helper',
+ '',
+ ''
+ ].join('\n'),
+ 'utf8'
+ )
+
+ await electronBuilderConfig.afterPack({
+ appOutDir: root,
+ electronPlatformName: 'darwin',
+ // Why: Arch.ia32 never matches a mac packaging host, so afterPack
+ // takes the existence-only daemon-entry path instead of booting it.
+ arch: 0,
+ packager: { appInfo: { productFilename: 'Orca' } }
+ })
+
+ const plistJson = JSON.parse(
+ execFileSync(
+ 'plutil',
+ ['-convert', 'json', '-o', '-', join(helperContents, 'Info.plist')],
+ {
+ encoding: 'utf8'
+ }
+ )
+ )
+ expect(plistJson).toMatchObject(macPrivacyUsageDescriptions)
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+ }
+ )
+
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**'])