From e5e1468c01e626f0d52866b7a2d2df14ab1d23bb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:19:49 -0700 Subject: [PATCH] fix(mac): declare terminal helper protected-folder access --- config/electron-builder.config.cjs | 14 +-- config/mac-electron-node-host-privacy.cjs | 37 ++++++++ .../mac-electron-node-host-privacy.test.mjs | 85 +++++++++++++++++++ 3 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 config/mac-electron-node-host-privacy.cjs create mode 100644 config/scripts/mac-electron-node-host-privacy.test.mjs diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index bf7d5b1382c..3b5aeedefa0 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -18,6 +18,10 @@ const { verifyPackagedNodePtyJobOwnership } = require('./scripts/verify-packaged-node-pty-job-ownership.cjs') const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') +const { + extendMacElectronNodeHostInfoPlist, + macTerminalProtectedFolderUsageDescriptions +} = require('./mac-electron-node-host-privacy.cjs') // Why: dev-channel builds must carry the *release* identity — same bundle id, // Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to @@ -241,6 +245,9 @@ module.exports = { throw new Error(`Missing packaged resources directory: ${resourcesDir}`) } if (context.electronPlatformName === 'darwin') { + const appPath = join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`) + // afterPack runs before signing seals the nested helper bundle. + extendMacElectronNodeHostInfoPlist(appPath, context.packager.appInfo.productFilename) const architectureByEnum = { 1: 'x64', 3: 'arm64' } const architecture = architectureByEnum[context.arch] if (!architecture) { @@ -377,6 +384,7 @@ module.exports = { entitlements: 'resources/build/entitlements.mac.plist', entitlementsInherit: 'resources/build/entitlements.mac.plist', extendInfo: { + ...macTerminalProtectedFolderUsageDescriptions, NSAppleEventsUsageDescription: 'Orca allows terminal-launched developer tools to automate local apps when you request it.', NSBluetoothAlwaysUsageDescription: @@ -391,11 +399,7 @@ module.exports = { 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." + 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-electron-node-host-privacy.cjs b/config/mac-electron-node-host-privacy.cjs new file mode 100644 index 00000000000..ea96782c61e --- /dev/null +++ b/config/mac-electron-node-host-privacy.cjs @@ -0,0 +1,37 @@ +const { execFileSync } = require('node:child_process') +const { existsSync } = require('node:fs') +const { join } = require('node:path') + +// The detached terminal daemon executes as Electron's generic Node helper, so +// protected-folder prompts are owned by this nested bundle rather than Orca.app. +const macTerminalProtectedFolderUsageDescriptions = { + NSDesktopFolderUsageDescription: + 'Orca allows terminal-launched developer tools to access files on your Desktop when you request it.', + NSDocumentsFolderUsageDescription: + 'Orca allows terminal-launched developer tools to access files in Documents when you request it.', + NSDownloadsFolderUsageDescription: + 'Orca allows terminal-launched developer tools to access files in Downloads when you request it.' +} + +function extendMacElectronNodeHostInfoPlist(appPath, productFilename) { + const infoPlistPath = join( + appPath, + 'Contents', + 'Frameworks', + `${productFilename} Helper.app`, + 'Contents', + 'Info.plist' + ) + if (!existsSync(infoPlistPath)) { + throw new Error(`Missing Electron Node host Info.plist: ${infoPlistPath}`) + } + for (const [key, value] of Object.entries(macTerminalProtectedFolderUsageDescriptions)) { + execFileSync('/usr/bin/plutil', ['-replace', key, '-string', value, infoPlistPath]) + } + return infoPlistPath +} + +module.exports = { + extendMacElectronNodeHostInfoPlist, + macTerminalProtectedFolderUsageDescriptions +} diff --git a/config/scripts/mac-electron-node-host-privacy.test.mjs b/config/scripts/mac-electron-node-host-privacy.test.mjs new file mode 100644 index 00000000000..949e571075f --- /dev/null +++ b/config/scripts/mac-electron-node-host-privacy.test.mjs @@ -0,0 +1,85 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const electronBuilderConfig = require('../electron-builder.config.cjs') +const { + extendMacElectronNodeHostInfoPlist, + macTerminalProtectedFolderUsageDescriptions +} = require('../mac-electron-node-host-privacy.cjs') + +async function createInfoPlist(path, bundleId) { + await mkdir(join(path, 'Contents'), { recursive: true }) + await writeFile( + join(path, 'Contents', 'Info.plist'), + [ + '', + '', + '', + '', + ' CFBundleIdentifier', + ` ${bundleId}`, + '', + '' + ].join('\n'), + 'utf8' + ) +} + +describe('macOS Electron Node host privacy', () => { + it('keeps protected-folder purposes identical in the main app and terminal host', () => { + expect(electronBuilderConfig.mac.extendInfo).toMatchObject( + macTerminalProtectedFolderUsageDescriptions + ) + }) + + it.skipIf(process.platform !== 'darwin')( + 'adds protected-folder purposes only to the generic Electron helper', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-electron-node-host-privacy-')) + const appPath = join(root, 'Orca.app') + const genericHelper = join(appPath, 'Contents', 'Frameworks', 'Orca Helper.app') + const rendererHelper = join(appPath, 'Contents', 'Frameworks', 'Orca Helper (Renderer).app') + try { + await createInfoPlist(genericHelper, 'com.stablyai.orca.helper') + await createInfoPlist(rendererHelper, 'com.stablyai.orca.helper.Renderer') + + const updatedPath = extendMacElectronNodeHostInfoPlist(appPath, 'Orca') + + expect(updatedPath).toBe(join(genericHelper, 'Contents', 'Info.plist')) + const genericInfo = JSON.parse( + execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', updatedPath], { + encoding: 'utf8' + }) + ) + const rendererInfo = JSON.parse( + execFileSync( + '/usr/bin/plutil', + ['-convert', 'json', '-o', '-', join(rendererHelper, 'Contents', 'Info.plist')], + { encoding: 'utf8' } + ) + ) + expect(genericInfo.CFBundleIdentifier).toBe('com.stablyai.orca.helper') + expect(genericInfo).toMatchObject(macTerminalProtectedFolderUsageDescriptions) + expect(rendererInfo).not.toMatchObject(macTerminalProtectedFolderUsageDescriptions) + } finally { + await rm(root, { recursive: true, force: true }) + } + } + ) + + it('fails packaging when the generic Electron helper is missing', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-electron-node-host-missing-')) + try { + expect(() => extendMacElectronNodeHostInfoPlist(join(root, 'Orca.app'), 'Orca')).toThrow( + /Missing Electron Node host Info\.plist/ + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +})