fix(mac): declare terminal helper protected-folder access

This commit is contained in:
Brennan Benson
2026-08-27 10:19:49 -07:00
parent 642607bfa7
commit e5e1468c01
3 changed files with 131 additions and 5 deletions
+9 -5
View File
@@ -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
+37
View File
@@ -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
}
@@ -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'),
[
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
'<dict>',
' <key>CFBundleIdentifier</key>',
` <string>${bundleId}</string>`,
'</dict>',
'</plist>'
].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 })
}
})
})