diff --git a/config/scripts/desktop-distribution/macos/build-mac-notarization-overlap.mjs b/config/scripts/desktop-distribution/macos/build-mac-notarization-overlap.mjs index 7350c3e8e32..4137558da8d 100644 --- a/config/scripts/desktop-distribution/macos/build-mac-notarization-overlap.mjs +++ b/config/scripts/desktop-distribution/macos/build-mac-notarization-overlap.mjs @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process' import { createRequire } from 'node:module' import { basename, join, resolve } from 'node:path' -import { macTerminalHostPaths } from '../../macos/terminal-host-bundle/macos-terminal-host-bundle.cjs' +import { assertMacTerminalHostUuid } from '../../macos/terminal-host-bundle/macos-terminal-host-bundle.cjs' import { assertMacExecutableUuid } from './macos-executable-uuid.cjs' const ARCHITECTURES = [1, 3] // electron-builder: x64, arm64. @@ -144,7 +144,7 @@ export async function main(args = process.argv.slice(2)) { join(appPath, 'Contents', 'MacOS', basename(appPath, '.app')), config.appId ) - assertMacExecutableUuid(macTerminalHostPaths(appPath).executable, config.appId) + assertMacTerminalHostUuid(appPath, config.appId) }, targets: new Map([ [ diff --git a/config/scripts/desktop-distribution/macos/macos-executable-uuid.cjs b/config/scripts/desktop-distribution/macos/macos-executable-uuid.cjs index f07939c09b4..678719c52c4 100644 --- a/config/scripts/desktop-distribution/macos/macos-executable-uuid.cjs +++ b/config/scripts/desktop-distribution/macos/macos-executable-uuid.cjs @@ -74,11 +74,14 @@ function rewriteMachOUuids(buffer, appId, executableName) { }) } -/** Gives the executable its own UUID, then re-signs it ad hoc; real signing replaces that later. */ -function giveMacExecutableUniqueUuid(executable, appId) { +/** + * Gives the executable the UUID derived for executable `uuidName` (its own name by default), then + * re-signs it ad hoc; real signing replaces that later. + */ +function giveMacExecutableUniqueUuid(executable, appId, uuidName = basename(executable)) { const name = basename(executable) const buffer = readFileSync(executable) - const changes = rewriteMachOUuids(buffer, appId, name) + const changes = rewriteMachOUuids(buffer, appId, uuidName) // The edit breaks Electron's linker ad hoc signature and arm64 kills unsigned code, but identity-less builds skip app signing. // Signed outside the bundle: in place, codesign would treat it as the bundle and require its unsigned siblings be signed. const staging = mkdtempSync(join(tmpdir(), 'orca-executable-uuid-')) @@ -98,12 +101,11 @@ function giveMacExecutableUniqueUuid(executable, appId) { } /** Fails when a shipped executable lost its own UUID, e.g. the hook was skipped or signing rewrote it. */ -function assertMacExecutableUuid(executable, appId) { - const name = basename(executable) +function assertMacExecutableUuid(executable, appId, uuidName = basename(executable)) { const buffer = readFileSync(executable) for (const { cpuType, uuidOffset } of machOUuidSlots(buffer)) { const actual = formatUuid(buffer.subarray(uuidOffset, uuidOffset + 16)) - const expected = formatUuid(deriveExecutableUuid(appId, name, cpuType)) + const expected = formatUuid(deriveExecutableUuid(appId, uuidName, cpuType)) if (actual !== expected) { throw new Error( `[macos-executable-uuid] ${executable} cpu ${cpuType} has UUID ${actual}, expected ${expected}` diff --git a/config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.cjs b/config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.cjs index 91c644b766b..75f05ca2d6d 100644 --- a/config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.cjs +++ b/config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.cjs @@ -43,6 +43,20 @@ const LOAD_CALL = /\b(?:require|import)\(/g const LITERAL_ARGUMENT = /^\s*([`'"])([^`'"$]+)\1\s*\)/ const LIPO_ARCH_TO_NODE_ARCH = { x86_64: 'x64', arm64: 'arm64' } +// electron-builder names the app bundle and its main executable after productFilename. +function macAppExecutableName(appPath) { + return basename(appPath, '.app') +} + +/** Throws unless the helper executable carries the app's main-executable UUID for every CPU type. */ +function assertMacTerminalHostUuid(appPath, appId) { + assertMacExecutableUuid( + macTerminalHostPaths(appPath).executable, + appId, + macAppExecutableName(appPath) + ) +} + function macTerminalHostPaths(appPath) { const bundle = join(appPath, 'Contents', 'Helpers', `${MAC_TERMINAL_HOST_NAME}.app`) const contents = join(bundle, 'Contents') @@ -284,9 +298,13 @@ function assembleMacTerminalHost({ appPath, nodeExecutable, iconPath, info }) { mkdirSync(join(paths.bundle, 'Contents', 'MacOS'), { recursive: true }) cpSync(nodeExecutable, paths.executable) chmodSync(paths.executable, 0o755) - // Terminals' responsible process is this helper. Stock Node's UUID is shared by every install of that build - // (nvm, Homebrew, other apps), so Local Network privacy could attribute terminals to another program (#21988). - giveMacExecutableUniqueUuid(paths.executable, info.CFBundleIdentifier) + // Terminals' responsible process is this helper, and stock Node's UUID is shared by every install of that build (#21988). + // Local Network caches one UUID per bundle id, so the helper carries the app's: its own would flip that cache. + giveMacExecutableUniqueUuid( + paths.executable, + info.CFBundleIdentifier, + macAppExecutableName(appPath) + ) cpSync(iconPath, paths.icon) cpSync(join(daemonOut, 'main'), join(paths.daemon, 'out', 'main'), { recursive: true }) // Pins out/ to CommonJS so a stray parent package.json cannot change the loader. @@ -392,7 +410,7 @@ async function verifyMacTerminalHost({ appPath, arch, usageDescriptions, expecte }) ) try { - assertMacExecutableUuid(paths.executable, appId) + assertMacTerminalHostUuid(appPath, appId) } catch (error) { problems.push(error.message) } @@ -436,6 +454,7 @@ async function verifyMacTerminalHost({ appPath, arch, usageDescriptions, expecte module.exports = { MAC_TERMINAL_HOST_NAME, assembleMacTerminalHost, + assertMacTerminalHostUuid, buildMacTerminalHost, buildMacTerminalHostInfo, daemonBareRequires, diff --git a/config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.test.mjs b/config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.test.mjs index 07da300a9f7..f3425260753 100644 --- a/config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.test.mjs +++ b/config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.test.mjs @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process' import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' @@ -7,13 +8,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' const require = createRequire(new URL('../../macos-terminal-host-bundle.test.mjs', import.meta.url)) const { assembleMacTerminalHost, + assertMacTerminalHostUuid, daemonBareRequires, findUnresolvedDaemonRequires, parseBuildVersionMinos } = require('./macos/terminal-host-bundle/macos-terminal-host-bundle.cjs') -const { - assertMacExecutableUuid -} = require('./desktop-distribution/macos/macos-executable-uuid.cjs') const INFO = { CFBundleIdentifier: 'com.stablyai.orca' } describe('terminal host binary checks', () => { it('reads minos from the LC_BUILD_VERSION load command only', () => { @@ -67,15 +66,19 @@ describe.skipIf(process.platform !== 'darwin')('terminal host assembly', () => { rmSync(root, { recursive: true, force: true }) }) - it("gives the helper executable Orca's own UUID instead of stock Node's", () => { + it("gives the helper executable the main executable's UUID instead of stock Node's", () => { const paths = assembleMacTerminalHost({ appPath, nodeExecutable: join(root, 'node'), iconPath: join(root, 'icon.icns'), info: INFO }) - expect(() => assertMacExecutableUuid(join(root, 'node'), INFO.CFBundleIdentifier)).toThrow() - expect(() => assertMacExecutableUuid(paths.executable, INFO.CFBundleIdentifier)).not.toThrow() + execFileSync(paths.executable) + const uuids = execFileSync('dwarfdump', ['--uuid', paths.executable], { encoding: 'utf8' }) + // Orca.app's main executable carries these (macos-executable-uuid.test.mjs). + expect(uuids).toContain('016EE681-3DC3-81C7-A6DA-12DC23B89DD9 (x86_64)') + expect(uuids).toContain('E6430410-3B22-8BD6-9677-B18884034A30 (arm64e)') + assertMacTerminalHostUuid(appPath, INFO.CFBundleIdentifier) }) it('copies every package the daemon requires, with its dependencies', () => { diff --git a/docs/agents-md-refs/macos-daemon-tcc-attribution.md b/docs/agents-md-refs/macos-daemon-tcc-attribution.md index 69623e865f4..2e56dd74c8e 100644 --- a/docs/agents-md-refs/macos-daemon-tcc-attribution.md +++ b/docs/agents-md-refs/macos-daemon-tcc-attribution.md @@ -124,12 +124,17 @@ Do not register copies explicitly (`lsregister -f` or `LSRegisterURL`). Terminal processes' responsible process is the helper, and macOS Local Network privacy maps a responsible process to an app by its main executable's `LC_UUID` (TN3179). Stock Node's UUID is -shared by every install of that Node build (nvm, Homebrew, other apps), so with it Local Network -attributed Orca terminals to other programs and blocked LAN access while System Settings showed -Orca allowed (#21988). Packaging rewrites the helper executable's UUID before signing, the same -way as the main executable's (#41676): derived from the app id, `Orca Agents` and the CPU type, so -it is unique to Orca and stable across releases. `verifyMacTerminalHost` and the release check -fail if it drifts. +shared by every install of that Node build, so with it Orca's terminals and any other program on +that Node shared Local Network decisions in both directions, and LAN access was blocked while System +Settings showed Orca allowed (#21988). + +Local Network also caches one UUID per bundle id, and the helper shares `com.stablyai.orca` with +the app. A helper UUID of its own keeps replacing the app's cached UUID and back: in testing it +flip-flopped, blocked some terminal and app connections, and prompted again after an update. So +packaging gives the helper exactly the main executable's UUID per CPU type (#41676: derived from +the app id, the app's executable name and the CPU type), stable across releases. +`verifyMacTerminalHost` and the release check fail if it drifts. The designated requirement is +unchanged, so file-access grants are unaffected. ## Rollback