diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 23525ee6208..b270cb72725 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -296,6 +296,18 @@ jobs: pnpm-lock.yaml mobile/pnpm-lock.yaml + # Why: packaging bundles the pinned Node into the macOS terminal host. Keyed on the pin alone + # with no restore-keys, so a pin bump starts empty instead of carrying old archives forward. + - name: Locate the pinned Node runtime cache + shell: bash + run: echo "ORCA_NODE_RUNTIME_CACHE_DIR=$HOME/Library/Caches/orca-node-runtime" >>"$GITHUB_ENV" + + - name: Cache the pinned Node runtime + uses: actions/cache@v5 + with: + path: ~/Library/Caches/orca-node-runtime + key: orca-node-runtime-mac-${{ hashFiles('src/shared/node-runtime-pin.ts') }} + - name: Cache electron-builder downloads uses: actions/cache@v5 with: @@ -451,7 +463,7 @@ jobs: timeout_minutes: 45 max_attempts: 2 retry_wait_seconds: 30 - command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_ADHOC=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always + command: node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/pinned-node-downloads.mjs darwin-arm64 darwin-x64 && node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_ADHOC=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always env: # Why: electron-builder's github publisher targets the repo named in the # config; the token must therefore carry write access to orca-adhoc. diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index c50ca782c56..28e95ba96d6 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -192,6 +192,20 @@ jobs: pnpm-lock.yaml mobile/pnpm-lock.yaml + # Why: packaging bundles the pinned Node into the macOS terminal host. Keyed on the pin alone + # with no restore-keys, so a pin bump starts empty instead of carrying old archives forward. + - name: Locate the pinned Node runtime cache + if: steps.freshness.outputs.should_build == 'true' + shell: bash + run: echo "ORCA_NODE_RUNTIME_CACHE_DIR=$HOME/Library/Caches/orca-node-runtime" >>"$GITHUB_ENV" + + - name: Cache the pinned Node runtime + if: steps.freshness.outputs.should_build == 'true' + uses: actions/cache@v5 + with: + path: ~/Library/Caches/orca-node-runtime + key: orca-node-runtime-mac-${{ hashFiles('src/shared/node-runtime-pin.ts') }} + - name: Cache electron-builder downloads if: steps.freshness.outputs.should_build == 'true' uses: actions/cache@v5 @@ -374,7 +388,7 @@ jobs: timeout_minutes: 45 max_attempts: 2 retry_wait_seconds: 30 - command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_DAILY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always + command: node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/pinned-node-downloads.mjs darwin-arm64 darwin-x64 && node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_DAILY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always env: # Why: electron-builder's github publisher targets the repo named in the # config; the token must therefore carry write access to orca-daily. diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index d254ff393d5..f78cc9c47e1 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -181,6 +181,18 @@ jobs: pnpm-lock.yaml mobile/pnpm-lock.yaml + # Why: packaging bundles the pinned Node into the macOS terminal host. Keyed on the pin alone + # with no restore-keys, so a pin bump starts empty instead of carrying old archives forward. + - name: Locate the pinned Node runtime cache + shell: bash + run: echo "ORCA_NODE_RUNTIME_CACHE_DIR=$HOME/Library/Caches/orca-node-runtime" >>"$GITHUB_ENV" + + - name: Cache the pinned Node runtime + uses: actions/cache@v5 + with: + path: ~/Library/Caches/orca-node-runtime + key: orca-node-runtime-mac-${{ hashFiles('src/shared/node-runtime-pin.ts') }} + - name: Cache electron-builder downloads uses: actions/cache@v5 with: @@ -349,7 +361,7 @@ jobs: timeout_minutes: 45 max_attempts: 2 retry_wait_seconds: 30 - command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_HOURLY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always + command: node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/pinned-node-downloads.mjs darwin-arm64 darwin-x64 && node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_HOURLY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always env: # Why: electron-builder's github publisher targets the repo named in the # config; the token must therefore carry write access to orca-hourly. diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index c71c522b5fb..6884309511d 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -79,6 +79,18 @@ jobs: pnpm-lock.yaml mobile/pnpm-lock.yaml + # Why: packaging bundles the pinned Node into the macOS terminal host. Keyed on the pin alone + # with no restore-keys, so a pin bump starts empty instead of carrying old archives forward. + - name: Locate the pinned Node runtime cache + shell: bash + run: echo "ORCA_NODE_RUNTIME_CACHE_DIR=$HOME/Library/Caches/orca-node-runtime" >>"$GITHUB_ENV" + + - name: Cache the pinned Node runtime + uses: actions/cache@v5 + with: + path: ~/Library/Caches/orca-node-runtime + key: orca-node-runtime-mac-${{ hashFiles('src/shared/node-runtime-pin.ts') }} + # Cache the Electron binary + electron-builder tool downloads (notarytool, # winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac. - name: Cache electron-builder downloads @@ -212,7 +224,7 @@ jobs: timeout_minutes: 45 max_attempts: 3 retry_wait_seconds: 30 - command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always -c.publish.releaseType=draft + command: node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/pinned-node-downloads.mjs darwin-arm64 darwin-x64 && node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always -c.publish.releaseType=draft env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ORCA_REQUIRE_ORCAD_TEMPLATE: '1' diff --git a/AGENTS.md b/AGENTS.md index bd9ca722ec1..d30faf95d34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. - **Windows MSYS/Git Bash panes**: their children break away from the per-PTY job unless it is created without `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, and a `conpty.node` built before that fix passes every existing gate. - **Windows daemon-host relocation**: the terminal daemon runs from a copy of the app runtime under `%LOCALAPPDATA%`, which is what survives an auto-update. +- **macOS daemon launch**: packaged macOS runs the terminal daemon from a copy of the signed terminal host helper through launchd, so an update cannot sever its privacy grants. Before touching that copy, its cleanup, or the fork fallback, read [`docs/reference/macos-daemon-tcc-attribution.md`](./docs/reference/macos-daemon-tcc-attribution.md). - **Windows EDR signal**: don't add `-ExecutionPolicy Bypass`, `-EncodedCommand`, `cmd.exe /c` with escaped free text, per-operation interpreter spawning, or runtime `Add-Type` compilation without reviewing the EDR impact first — behavioural EDR scores each of those, and being signed does not clear them. - **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. - **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. Packaging fails if a bundled native binary needs newer glibc. diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index ef545043146..b3c3a6cc102 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -1,11 +1,18 @@ 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 electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs') const { assertPackagedDaemonEntryExists, - verifyPackagedDaemonEntryBoots + verifyPackagedDaemonEntryBoots, + verifyPackagedMacTerminalHostBoots } = require('./scripts/verify-packaged-daemon-entry.cjs') +const { + buildMacTerminalHost, + macTerminalHostSignIgnore, + verifyMacTerminalHost +} = require('./scripts/macos-terminal-host-bundle.cjs') const { assertPackagedNativeVariantsInstalled, assertProcessHostOutputBuilt, @@ -187,6 +194,28 @@ const windowsRuntimeResources = existsSync( ? createPackagedRuntimeNodeModuleResources('win32') : [] +// Shared with the terminal host helper so its purpose strings cannot drift from the app's. +const macUsageDescriptions = { + 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'], + ...MACOS_FOLDER_USAGE_DESCRIPTIONS +} +// Developer ID team that signs every release-channel mac build. +const ORCA_MAC_TEAM_ID = '6CX3WHS9HZ' + /** @type {import('electron-builder').Configuration} */ module.exports = { appId, @@ -368,11 +397,7 @@ module.exports = { writeFileSync(join(resourcesDir, 'package-type'), 'AppImage') } if (context.electronPlatformName === 'darwin') { - const architectureByEnum = { 1: 'x64', 3: 'arm64' } - const architecture = architectureByEnum[context.arch] - if (!architecture) { - throw new Error(`Unsupported local-build compatibility architecture: ${context.arch}`) - } + 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' if (commit === 'unknown') { @@ -463,6 +488,7 @@ module.exports = { 'orca-keyboard-layout', context.packager ) + await packageMacTerminalHost(join(resourcesDir, '..', '..'), context) } }, win: { @@ -547,28 +573,12 @@ module.exports = { icon: 'resources/build/icon.icns', entitlements: 'resources/build/entitlements.mac.plist', entitlementsInherit: 'resources/build/entitlements.mac.plist', - signIgnore: [...bundledRipgrepMacSignIgnore, ...orcadTemplateMacSignIgnore], - 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." - }, + signIgnore: [ + ...bundledRipgrepMacSignIgnore, + ...orcadTemplateMacSignIgnore, + ...macTerminalHostSignIgnore + ], + extendInfo: macUsageDescriptions, // Why: local macOS validation builds should launch without Apple release // credentials. Hardened runtime + notarization stay enabled only on the // explicit release path so production artifacts remain strict while dev @@ -788,7 +798,41 @@ async function signMacComputerUseHelper(helperAppPath, packager) { }) } -async function signMacStandaloneHelper(helperPath, helperName, packager) { +// electron-builder `Arch` enum: x64=1, arm64=3. A universal (4) slice has no single arch. +function macSliceArch(archEnum, purpose) { + const arch = { 1: 'x64', 3: 'arm64' }[archEnum] + if (!arch) { + throw new Error(`Unsupported ${purpose} architecture: ${archEnum}`) + } + return arch +} + +// Why after the node_modules prune: the helper copies this slice's node-pty. +async function packageMacTerminalHost(appPath, context) { + const arch = macSliceArch(context.arch, 'macOS terminal host') + await buildMacTerminalHost({ + appPath, + arch, + iconPath: resolve(__dirname, '../resources/build/icon.icns'), + entitlementsPath: resolve(__dirname, '../resources/build/entitlements.mac.plist'), + usageDescriptions: macUsageDescriptions, + signCode: (path, { entitlements }) => + signMacStandaloneHelper(path, 'terminal host', context.packager, entitlements) + }) + // Only a local build may skip a slice this host cannot run; a shipped slice must serve a PTY. + await verifyPackagedMacTerminalHostBoots(appPath, { + arch, + required: isMacRelease || process.env.CI === 'true' + }) + await verifyMacTerminalHost({ + appPath, + arch, + usageDescriptions: macUsageDescriptions, + expectedTeamId: isMacRelease ? ORCA_MAC_TEAM_ID : null + }) +} + +async function signMacStandaloneHelper(helperPath, helperName, packager, entitlements) { if (!existsSync(helperPath)) { if (isMacRelease) { throw new Error(`Missing ${helperName} helper at ${helperPath}`) @@ -811,6 +855,9 @@ async function signMacStandaloneHelper(helperPath, helperName, packager) { if (isMacRelease) { args.push('--options', 'runtime', '--timestamp') } + if (entitlements) { + args.push('--entitlements', entitlements) + } args.push(helperPath) execFileSync('codesign', args, { stdio: 'inherit' }) execFileSync('codesign', ['--verify', '--strict', helperPath], { stdio: 'inherit' }) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index cdd86175c4b..eacb68bd994 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -20711,6 +20711,92 @@ "Collect cross-platform and provider soak evidence with no weakened identity, transaction or bounded-work assertions." ], "demotionRule": "Keep experimental until soak and platform evidence; investigate failures without weakening ownership, late recovery, or zero-write retry evidence." + }, + { + "id": "terminal.macos-tcc-update-identity", + "title": "Protected terminal reads survive macOS app-bundle replacement", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "daemon-native-signing-and-provider-contract", + "surfaces": ["packaged macOS daemon launch", "signed bundle retirement"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime", "mobile"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["daemon"], + "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.", + "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" + ], + "testFiles": [ + "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" + ], + "assertionRefs": [ + { + "file": "src/main/daemon/macos-daemon-launchd.test.ts", + "assertions": [ + "stops only its own job and hands the endpoint to the fork launcher when another daemon owns it", + "forks without submitting a job whose own readiness wait would overrun the deadline", + "never starts beside an earlier job that reads running" + ] + }, + { + "file": "src/main/daemon/daemon-init-macos-stable-launch-fallback.test.ts", + "assertions": [ + "hands off to the fork early enough for its readiness wait and adapter to fit the gate" + ] + }, + { + "file": "src/main/daemon/macos-daemon-bundle-retirement.test.ts", + "assertions": ["keeps a copy whose lsof scan lists a process even though lsof exits 1"] + }, + { + "file": "src/main/daemon/macos-daemon-bundle.test.ts", + "assertions": ["reuses a published copy without copying or verifying again"] + } + ], + "evidenceRuns": [ + { + "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", + "result": "passed", + "durationSeconds": 1.53, + "summary": "All 64 focused launch, fallback-deadline, per-build copy, retirement and packaging tests passed." + } + ], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "Deterministic focused tests." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local deterministic tests only." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Signed native experiment reproduced the update-gap denial and its absence under the stable copy (#25848); it predates the per-build copy and fixed label." + }, + "performanceBudget": { + "required": true, + "evidence": "Adoption skips copying; each helper build is cloned and verified once, and later daemons reuse that copy with no copy or verify; retirement runs once per launch in the background." + }, + "knownGaps": [ + "x64, older macOS, real ShipIt relaunch, notarized release and non-APFS copies are untested." + ], + "promotionCriteria": [ + "Signed release journeys on supported macOS versions with soak history." + ], + "demotionRule": "Keep experimental until release and soak evidence; retain code on unexplained inspection failures." } ] } diff --git a/config/scripts/macos-folder-usage-descriptions.cjs b/config/scripts/macos-folder-usage-descriptions.cjs new file mode 100644 index 00000000000..7be46e809f2 --- /dev/null +++ b/config/scripts/macos-folder-usage-descriptions.cjs @@ -0,0 +1,10 @@ +const MACOS_FOLDER_USAGE_DESCRIPTIONS = { + NSDesktopFolderUsageDescription: + "Orca allows terminal tools to access the user's Desktop folder when requested.", + NSDocumentsFolderUsageDescription: + "Orca allows terminal tools to access the user's Documents folder when requested.", + NSDownloadsFolderUsageDescription: + "Orca allows terminal tools to access the user's Downloads folder when requested." +} + +module.exports = { MACOS_FOLDER_USAGE_DESCRIPTIONS } diff --git a/config/scripts/macos-folder-usage-descriptions.test.mjs b/config/scripts/macos-folder-usage-descriptions.test.mjs new file mode 100644 index 00000000000..71969fbc046 --- /dev/null +++ b/config/scripts/macos-folder-usage-descriptions.test.mjs @@ -0,0 +1,11 @@ +import { createRequire } from 'node:module' +import { expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { MACOS_FOLDER_USAGE_DESCRIPTIONS } = 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', () => { + expect(config.mac.extendInfo).toMatchObject(MACOS_FOLDER_USAGE_DESCRIPTIONS) + expect(Object.keys(MACOS_FOLDER_USAGE_DESCRIPTIONS)).toHaveLength(3) +}) diff --git a/config/scripts/macos-terminal-host-bundle.cjs b/config/scripts/macos-terminal-host-bundle.cjs new file mode 100644 index 00000000000..7339c7e3e52 --- /dev/null +++ b/config/scripts/macos-terminal-host-bundle.cjs @@ -0,0 +1,425 @@ +// The signed `com.stablyai.orca` helper that runs the terminal daemon on the pinned Node. +// See docs/reference/macos-daemon-tcc-attribution.md. +const { spawnSync } = require('node:child_process') +const { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync +} = require('node:fs') +const { builtinModules } = require('node:module') +const { basename, dirname, join, relative, resolve, sep } = require('node:path') +const { pathToFileURL } = require('node:url') + +const MAC_TERMINAL_HOST_NAME = 'Orca Terminal Host' +const MAC_TERMINAL_HOST_EXECUTABLE = 'orca-terminal-host' +const macTerminalHostSignIgnore = [`/Contents/Helpers/${MAC_TERMINAL_HOST_NAME}\\.app(/|$)`] +const NODE_PTY_FILES = [ + 'package.json', + 'lib', + join('build', 'Release', 'pty.node'), + join('build', 'Release', 'spawn-helper') +] +// A helper that claimed any of these could take `orca:` links or documents from Orca.app. +const FORBIDDEN_INFO_KEYS = [ + 'CFBundleURLTypes', + 'CFBundleDocumentTypes', + 'UTExportedTypeDeclarations', + 'UTImportedTypeDeclarations', + 'NSServices' +] +// node-pty ships as the slice subset above. +const PACKAGES_NOT_COPIED = new Set(['node-pty']) +const LOAD_CALL = /\b(?:require|import)\(/g +// The whole argument must be one plain string literal; anything else is a computed name. +const LITERAL_ARGUMENT = /^\s*([`'"])([^`'"$]+)\1\s*\)/ +const LIPO_ARCH_TO_NODE_ARCH = { x86_64: 'x64', arm64: 'arm64' } + +function macTerminalHostPaths(appPath) { + const bundle = join(appPath, 'Contents', 'Helpers', `${MAC_TERMINAL_HOST_NAME}.app`) + const contents = join(bundle, 'Contents') + const daemon = join(contents, 'Resources', 'daemon') + const ptyRelease = join(daemon, 'node_modules', 'node-pty', 'build', 'Release') + return { + bundle, + infoPlist: join(contents, 'Info.plist'), + executable: join(contents, 'MacOS', MAC_TERMINAL_HOST_EXECUTABLE), + icon: join(contents, 'Resources', 'icon.icns'), + daemon, + entry: join(daemon, 'out', 'main', 'daemon-entry.js'), + outPackageJson: join(daemon, 'out', 'package.json'), + nodePty: join(daemon, 'node_modules', 'node-pty'), + ptyNode: join(ptyRelease, 'pty.node'), + spawnHelper: join(ptyRelease, 'spawn-helper') + } +} + +function packageNameOf(specifier) { + const parts = specifier.split('/') + return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] +} + +/** The file a relative load names, as Node resolves it; null for a non-JS target. */ +function resolveRelativeLoad(fromFile, specifier) { + const base = resolve(dirname(fromFile), specifier) + for (const candidate of [base, `${base}.js`, join(base, 'index.js')]) { + if (existsSync(candidate) && statSync(candidate).isFile()) { + return candidate.endsWith('.js') ? candidate : null + } + } + throw new Error(`[macos-terminal-host] ${fromFile} loads ${specifier}, which does not exist`) +} + +/** Package specifiers the daemon loads, walking its files from daemon-entry.js. */ +function daemonBareRequires(outMainDir) { + const builtins = new Set(builtinModules) + const specifiers = new Set() + const visited = new Set() + const pending = [join(outMainDir, 'daemon-entry.js')] + while (pending.length > 0) { + const file = pending.pop() + if (visited.has(file)) { + continue + } + visited.add(file) + const source = readFileSync(file, 'utf8') + for (const call of source.matchAll(LOAD_CALL)) { + const literal = LITERAL_ARGUMENT.exec(source.slice(call.index + call[0].length)) + // A computed specifier could load a package this walk cannot see. + if (!literal) { + throw new Error(`[macos-terminal-host] ${file} loads a module by a computed name`) + } + const specifier = literal[2] + if (specifier.startsWith('.')) { + const target = resolveRelativeLoad(file, specifier) + if (target) { + pending.push(target) + } + } else if (!specifier.startsWith('node:') && !builtins.has(packageNameOf(specifier))) { + specifiers.add(specifier) + } + } + } + return [...specifiers].sort() +} + +/** Where Node loads package `name` from when required inside `fromDir`, never above `nodeModules`. */ +function resolvePackageDir(name, fromDir, nodeModules) { + for (let dir = fromDir; ; dir = dirname(dir)) { + const candidate = + basename(dir) === 'node_modules' ? join(dir, name) : join(dir, 'node_modules', name) + if (existsSync(join(candidate, 'package.json'))) { + return candidate + } + if (!dir.startsWith(`${nodeModules}${sep}`)) { + return null + } + } +} + +/** Copies each package the daemon requires, and its dependencies, to the same relative place. */ +function copyDaemonPackages(outMainDir, sourceNodeModules, targetNodeModules) { + const pending = daemonBareRequires(outMainDir) + .map(packageNameOf) + .filter((name) => !PACKAGES_NOT_COPIED.has(name)) + .map((name) => ({ name, from: sourceNodeModules })) + const copied = new Set() + while (pending.length > 0) { + const { name, from } = pending.pop() + const source = resolvePackageDir(name, from, sourceNodeModules) + if (!source) { + throw new Error(`[macos-terminal-host] the daemon requires ${name}, missing from ${from}`) + } + if (copied.has(source)) { + continue + } + // A package nested inside one already copied came along with it. + if (![...copied].some((parent) => source.startsWith(`${parent}${sep}`))) { + cpSync(source, join(targetNodeModules, relative(sourceNodeModules, source)), { + recursive: true + }) + } + copied.add(source) + const manifest = JSON.parse(readFileSync(join(source, 'package.json'), 'utf8')) + pending.push( + ...Object.keys(manifest.dependencies ?? {}).map((dep) => ({ name: dep, from: source })) + ) + } +} + +/** Bare requires that do not resolve to a file inside the helper bundle. */ +function findUnresolvedDaemonRequires(paths) { + const outMain = join(paths.daemon, 'out', 'main') + // Resolution returns real paths, so compare against the bundle's real path. + const bundle = realpathSync(paths.bundle) + const problems = [] + for (const specifier of daemonBareRequires(outMain)) { + let resolved = null + try { + resolved = require.resolve(specifier, { paths: [outMain] }) + } catch { + // Reported below. + } + if (!resolved || relative(bundle, resolved).startsWith(`..${sep}`)) { + problems.push(`${specifier} resolves to ${resolved ?? 'nothing'}, not inside the helper`) + } + } + return problems +} + +function readPlist(path) { + return JSON.parse(spawnChecked('/usr/bin/plutil', ['-convert', 'json', '-o', '-', path]).stdout) +} + +function writePlist(path, value) { + writeFileSync(path, JSON.stringify(value)) + spawnChecked('/usr/bin/plutil', ['-convert', 'xml1', path]) +} + +function spawnChecked(program, args) { + const result = spawnSync(program, args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }) + if (result.error || result.status !== 0) { + throw new Error( + `[macos-terminal-host] ${program} ${args.join(' ')} failed: ${result.error?.message ?? result.stderr}` + ) + } + return result +} + +function buildMacTerminalHostInfo({ appInfo, usageDescriptions, minimumOs }) { + return { + ...usageDescriptions, + CFBundleIdentifier: appInfo.CFBundleIdentifier, + CFBundleName: 'Orca', + CFBundleDisplayName: 'Orca', + CFBundleExecutable: MAC_TERMINAL_HOST_EXECUTABLE, + CFBundlePackageType: 'APPL', + CFBundleInfoDictionaryVersion: '6.0', + CFBundleIconFile: 'icon.icns', + // Lower than the app's so LaunchServices ranks the helper below Orca.app among duplicates. + CFBundleVersion: '0', + CFBundleShortVersionString: appInfo.CFBundleShortVersionString, + LSMinimumSystemVersion: minimumOs, + LSUIElement: true + } +} + +function findMacTerminalHostInfoProblems(info, { appId, usageDescriptions, minimumOs }) { + const problems = [] + if (info.CFBundleIdentifier !== appId) { + problems.push(`CFBundleIdentifier is ${info.CFBundleIdentifier}, expected ${appId}`) + } + for (const key of FORBIDDEN_INFO_KEYS) { + if (Object.hasOwn(info, key)) { + problems.push(`declares ${key}`) + } + } + for (const [key, value] of Object.entries(usageDescriptions)) { + if (JSON.stringify(info[key]) !== JSON.stringify(value)) { + problems.push(`${key} differs from the app's`) + } + } + if (info.LSMinimumSystemVersion !== minimumOs) { + problems.push(`LSMinimumSystemVersion is ${info.LSMinimumSystemVersion}, expected ${minimumOs}`) + } + return problems +} + +/** `otool -l` prints `minos 13.5` under the LC_BUILD_VERSION load command. */ +function parseBuildVersionMinos(otoolOutput) { + const lines = otoolOutput.split('\n') + const command = lines.findIndex((line) => /cmd LC_BUILD_VERSION\s*$/.test(line)) + if (command === -1) { + return null + } + for (const line of lines.slice(command + 1)) { + if (/^\s*cmd /.test(line)) { + return null + } + const match = /^\s*minos (\S+)/.exec(line) + if (match) { + return match[1] + } + } + return null +} + +function lipoArchsToNodeArchs(lipoOutput) { + return lipoOutput + .trim() + .split(/\s+/) + .filter(Boolean) + .map((arch) => LIPO_ARCH_TO_NODE_ARCH[arch] ?? arch) +} + +/** Copies the pinned Node, the daemon JS with its packages, and this slice's node-pty. */ +function assembleMacTerminalHost({ appPath, nodeExecutable, iconPath, info }) { + const resourcesDir = join(appPath, 'Contents', 'Resources') + const daemonOut = join(resourcesDir, 'app.asar.unpacked', 'out') + const nodePty = join(resourcesDir, 'node_modules', 'node-pty') + for (const required of [join(daemonOut, 'main', 'daemon-entry.js'), nodePty, nodeExecutable]) { + if (!existsSync(required)) { + throw new Error(`[macos-terminal-host] missing ${required}`) + } + } + const paths = macTerminalHostPaths(appPath) + rmSync(paths.bundle, { recursive: true, force: true }) + mkdirSync(join(paths.bundle, 'Contents', 'MacOS'), { recursive: true }) + cpSync(nodeExecutable, paths.executable) + chmodSync(paths.executable, 0o755) + 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. + cpSync(join(daemonOut, 'package.json'), paths.outPackageJson) + copyDaemonPackages( + join(daemonOut, 'main'), + join(resourcesDir, 'node_modules'), + join(paths.daemon, 'node_modules') + ) + for (const file of NODE_PTY_FILES) { + cpSync(join(nodePty, file), join(paths.nodePty, file), { recursive: true }) + } + chmodSync(paths.spawnHelper, 0o755) + writePlist(paths.infoPlist, info) + return paths +} + +async function loadNodeRuntimePin() { + return import(pathToFileURL(resolve(__dirname, '../../src/shared/node-runtime-pin.ts')).href) +} + +async function pinnedDarwinNode(arch) { + const { ensurePinnedNodeExecutable } = await import( + pathToFileURL(resolve(__dirname, 'pinned-node-downloads.mjs')).href + ) + return ensurePinnedNodeExecutable({ target: `darwin-${arch}` }) +} + +/** + * Assembles and signs the helper inside-out. `signCode(path, { entitlements })` signs one path + * with the build's helper identity and verifies it. + */ +async function buildMacTerminalHost({ + appPath, + arch, + iconPath, + entitlementsPath, + usageDescriptions, + signCode +}) { + const { NODE_RUNTIME_DARWIN_MINIMUM_OS } = await loadNodeRuntimePin() + const paths = assembleMacTerminalHost({ + appPath, + nodeExecutable: await pinnedDarwinNode(arch), + iconPath, + info: buildMacTerminalHostInfo({ + appInfo: readPlist(join(appPath, 'Contents', 'Info.plist')), + usageDescriptions, + minimumOs: NODE_RUNTIME_DARWIN_MINIMUM_OS + }) + }) + await signCode(paths.ptyNode, {}) + await signCode(paths.spawnHelper, {}) + await signCode(paths.bundle, { entitlements: entitlementsPath }) + return paths +} + +function readCodesignDetails(bundle) { + const output = spawnChecked('/usr/bin/codesign', ['-dvv', bundle]).stderr + const field = (name) => new RegExp(`^${name}=(.*)$`, 'm').exec(output)?.[1] ?? null + return { identifier: field('Identifier'), teamId: field('TeamIdentifier') } +} + +function hasAllowJitEntitlement(bundle) { + const { stdout } = spawnChecked('/usr/bin/codesign', [ + '-d', + '--entitlements', + '-', + '--xml', + bundle + ]) + return /com\.apple\.security\.cs\.allow-jit<\/key>\s*/.test(stdout) +} + +/** Fails packaging on any layout, plist, OS floor, architecture or signature drift. */ +async function verifyMacTerminalHost({ appPath, arch, usageDescriptions, expectedTeamId }) { + const { NODE_RUNTIME_DARWIN_MINIMUM_OS: minimumOs } = await loadNodeRuntimePin() + const paths = macTerminalHostPaths(appPath) + const problems = [] + for (const key of ['executable', 'icon', 'entry', 'outPackageJson', 'ptyNode', 'spawnHelper']) { + if (!existsSync(paths[key])) { + problems.push(`missing ${paths[key]}`) + } + } + if (problems.length === 0 && (statSync(paths.spawnHelper).mode & 0o111) === 0) { + problems.push('spawn-helper is not executable') + } + if (problems.length > 0) { + throw new Error(`[macos-terminal-host] ${problems.join('; ')}`) + } + // The build tree's node_modules sits above dist/, so the boot check alone cannot catch these. + problems.push(...findUnresolvedDaemonRequires(paths)) + const appId = readPlist(join(appPath, 'Contents', 'Info.plist')).CFBundleIdentifier + problems.push( + ...findMacTerminalHostInfoProblems(readPlist(paths.infoPlist), { + appId, + usageDescriptions, + minimumOs + }) + ) + const minos = parseBuildVersionMinos( + spawnChecked('/usr/bin/otool', ['-l', paths.executable]).stdout + ) + if (minos !== minimumOs) { + problems.push( + `the Node binary's minos is ${minos}, but NODE_RUNTIME_DARWIN_MINIMUM_OS is ${minimumOs}` + ) + } + for (const binary of [paths.executable, paths.ptyNode, paths.spawnHelper]) { + const archs = lipoArchsToNodeArchs(spawnChecked('/usr/bin/lipo', ['-archs', binary]).stdout) + if (archs.length !== 1 || archs[0] !== arch) { + problems.push(`${binary} has architectures ${archs.join(',')}, expected ${arch}`) + } + } + const { identifier, teamId } = readCodesignDetails(paths.bundle) + if (identifier !== appId) { + problems.push(`signed identifier is ${identifier}, expected ${appId}`) + } + if (expectedTeamId && teamId !== expectedTeamId) { + problems.push(`signed team is ${teamId}, expected ${expectedTeamId}`) + } + if (!hasAllowJitEntitlement(paths.bundle)) { + problems.push('the signature lacks the allow-jit entitlement Node needs') + } + // After the boot check, so a write into the bundle while it ran fails the build. + const verified = spawnSync('/usr/bin/codesign', ['--verify', '--strict', paths.bundle], { + encoding: 'utf8' + }) + if (verified.status !== 0) { + problems.push(`codesign --verify --strict failed: ${verified.stderr.trim()}`) + } + if (problems.length > 0) { + throw new Error(`[macos-terminal-host] ${problems.join('; ')}`) + } + console.log(`[macos-terminal-host] OK — ${paths.bundle} (${arch}, macOS ${minimumOs}+)`) +} + +module.exports = { + MAC_TERMINAL_HOST_NAME, + assembleMacTerminalHost, + buildMacTerminalHost, + buildMacTerminalHostInfo, + daemonBareRequires, + findMacTerminalHostInfoProblems, + findUnresolvedDaemonRequires, + lipoArchsToNodeArchs, + macTerminalHostPaths, + macTerminalHostSignIgnore, + parseBuildVersionMinos, + verifyMacTerminalHost +} diff --git a/config/scripts/macos-terminal-host-bundle.test.mjs b/config/scripts/macos-terminal-host-bundle.test.mjs new file mode 100644 index 00000000000..63f1eaa9d56 --- /dev/null +++ b/config/scripts/macos-terminal-host-bundle.test.mjs @@ -0,0 +1,358 @@ +import { spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + MAC_TERMINAL_HOST_BUNDLE, + MAC_TERMINAL_HOST_EXECUTABLE +} from '../../src/main/daemon/macos-daemon-bundle.ts' + +const require = createRequire(import.meta.url) +const { + assembleMacTerminalHost, + buildMacTerminalHostInfo, + daemonBareRequires, + findMacTerminalHostInfoProblems, + findUnresolvedDaemonRequires, + lipoArchsToNodeArchs, + macTerminalHostPaths, + macTerminalHostSignIgnore, + parseBuildVersionMinos +} = require('./macos-terminal-host-bundle.cjs') +const { macCommandPrefixForArch } = require('./verify-packaged-daemon-entry.cjs') +const electronBuilderConfig = require('../electron-builder.config.cjs') + +const usageDescriptions = electronBuilderConfig.mac.extendInfo +const appInfo = { CFBundleIdentifier: 'com.stablyai.orca', CFBundleShortVersionString: '1.4.300' } +const expectations = { appId: 'com.stablyai.orca', usageDescriptions, minimumOs: '13.5' } + +describe('terminal host Info.plist', () => { + const info = buildMacTerminalHostInfo({ appInfo, usageDescriptions, minimumOs: '13.5' }) + + it("carries the app's identifier, every purpose string and the Node floor", () => { + expect(info).toMatchObject({ + ...usageDescriptions, + CFBundleIdentifier: 'com.stablyai.orca', + CFBundleName: 'Orca', + CFBundleDisplayName: 'Orca', + CFBundleExecutable: 'orca-terminal-host', + CFBundleVersion: '0', + LSMinimumSystemVersion: '13.5', + LSUIElement: true + }) + expect(usageDescriptions).toHaveProperty('NSDocumentsFolderUsageDescription') + expect(findMacTerminalHostInfoProblems(info, expectations)).toEqual([]) + }) + + it('rejects link, document and type claims, drifted purpose strings and a stale floor', () => { + expect( + findMacTerminalHostInfoProblems( + { + ...info, + CFBundleIdentifier: 'com.stablyai.orca.helper', + CFBundleURLTypes: [], + CFBundleDocumentTypes: [], + UTExportedTypeDeclarations: [], + NSLocalNetworkUsageDescription: 'other', + LSMinimumSystemVersion: '12.0' + }, + expectations + ) + ).toEqual([ + 'CFBundleIdentifier is com.stablyai.orca.helper, expected com.stablyai.orca', + 'declares CFBundleURLTypes', + 'declares CFBundleDocumentTypes', + 'declares UTExportedTypeDeclarations', + "NSLocalNetworkUsageDescription differs from the app's", + 'LSMinimumSystemVersion is 12.0, expected 13.5' + ]) + }) +}) + +describe('terminal host binary checks', () => { + it('reads minos from the LC_BUILD_VERSION load command only', () => { + const otool = [ + 'Load command 8', + ' cmd LC_SEGMENT_64', + ' minos 99.0', + 'Load command 9', + ' cmd LC_BUILD_VERSION', + ' cmdsize 32', + ' platform 1', + ' minos 13.5', + ' sdk 14.0', + 'Load command 10', + ' cmd LC_SOURCE_VERSION' + ].join('\n') + expect(parseBuildVersionMinos(otool)).toBe('13.5') + expect(parseBuildVersionMinos('Load command 1\n cmd LC_SEGMENT_64\n')).toBeNull() + }) + + it("maps lipo's x86_64 to Node's x64", () => { + expect(lipoArchsToNodeArchs('x86_64\n')).toEqual(['x64']) + expect(lipoArchsToNodeArchs('arm64\n')).toEqual(['arm64']) + expect(lipoArchsToNodeArchs('x86_64 arm64\n')).toEqual(['x64', 'arm64']) + }) + + it('boots the host slice directly and never claims an arm64 slice runs on Intel', () => { + expect(macCommandPrefixForArch(process.arch)).toEqual([]) + const noRosetta = () => ({ status: 1 }) + if (process.arch === 'arm64') { + expect(macCommandPrefixForArch('x64', noRosetta)).toBeNull() + expect(macCommandPrefixForArch('x64', () => ({ status: 0 }))).toEqual([ + '/usr/bin/arch', + '-x86_64' + ]) + } else { + expect(macCommandPrefixForArch('arm64', () => ({ status: 0 }))).toBeNull() + } + }) +}) + +describe('terminal host packaging config', () => { + const pattern = new RegExp(macTerminalHostSignIgnore[0]) + + it('keeps the outer signer off the separately signed helper', () => { + expect(electronBuilderConfig.mac.signIgnore).toEqual( + expect.arrayContaining(macTerminalHostSignIgnore) + ) + const helper = '/out/mac-arm64/Orca.app/Contents/Helpers/Orca Terminal Host.app' + expect(pattern.test(helper)).toBe(true) + expect(pattern.test(`${helper}/Contents/MacOS/orca-terminal-host`)).toBe(true) + expect(pattern.test('/out/mac-arm64/Orca.app/Contents/Helpers/Orca Terminal Host.bundle')).toBe( + false + ) + expect(pattern.test('/out/mac-arm64/Orca.app/Contents/Frameworks/Orca Helper.app')).toBe(false) + }) + + it('packages the helper where the desktop looks for it', () => { + const paths = macTerminalHostPaths('/Orca.app') + expect(paths.bundle).toBe(join('/Orca.app', 'Contents', 'Helpers', MAC_TERMINAL_HOST_BUNDLE)) + expect(basename(paths.executable)).toBe(MAC_TERMINAL_HOST_EXECUTABLE) + }) +}) + +describe.skipIf(process.platform !== 'darwin')('terminal host assembly', () => { + let root + let appPath + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-terminal-host-')) + appPath = join(root, 'Orca.app') + const resources = join(appPath, 'Contents', 'Resources') + const out = join(resources, 'app.asar.unpacked', 'out') + mkdirSync(join(out, 'main', 'chunks'), { recursive: true }) + writeFileSync(join(out, 'main', 'daemon-entry.js'), 'entry') + writeFileSync(join(out, 'main', 'chunks', 'protocol.js'), 'chunk') + writeFileSync(join(out, 'package.json'), '{"type":"commonjs"}') + const nodePty = join(resources, 'node_modules', 'node-pty') + mkdirSync(join(nodePty, 'lib'), { recursive: true }) + mkdirSync(join(nodePty, 'build', 'Release'), { recursive: true }) + mkdirSync(join(nodePty, 'src'), { recursive: true }) + writeFileSync(join(nodePty, 'package.json'), '{}') + writeFileSync(join(nodePty, 'lib', 'index.js'), 'lib') + writeFileSync(join(nodePty, 'build', 'Release', 'pty.node'), 'addon') + // Some packaged node-pty copies ship spawn-helper without its execute bit. + writeFileSync(join(nodePty, 'build', 'Release', 'spawn-helper'), 'helper', { mode: 0o644 }) + writeFileSync(join(nodePty, 'src', 'unix.cc'), 'source') + writeFileSync(join(root, 'node'), 'pinned-node', { mode: 0o644 }) + writeFileSync(join(root, 'icon.icns'), 'icon') + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('lays out the pinned Node, daemon JS and runtime node-pty files only', () => { + const info = buildMacTerminalHostInfo({ appInfo, usageDescriptions, minimumOs: '13.5' }) + const paths = assembleMacTerminalHost({ + appPath, + nodeExecutable: join(root, 'node'), + iconPath: join(root, 'icon.icns'), + info + }) + expect(paths).toEqual(macTerminalHostPaths(appPath)) + expect(paths.bundle).toBe(join(appPath, 'Contents', 'Helpers', 'Orca Terminal Host.app')) + expect(readFileSync(paths.executable, 'utf8')).toBe('pinned-node') + expect(statSync(paths.executable).mode & 0o777).toBe(0o755) + expect(statSync(paths.spawnHelper).mode & 0o777).toBe(0o755) + expect(readFileSync(paths.entry, 'utf8')).toBe('entry') + expect(existsSync(join(paths.daemon, 'out', 'main', 'chunks', 'protocol.js'))).toBe(true) + expect(readFileSync(paths.outPackageJson, 'utf8')).toBe('{"type":"commonjs"}') + expect(existsSync(join(paths.nodePty, 'lib', 'index.js'))).toBe(true) + expect(existsSync(join(paths.nodePty, 'src'))).toBe(false) + expect(readFileSync(paths.icon, 'utf8')).toBe('icon') + const plist = spawnSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', paths.infoPlist], { + encoding: 'utf8' + }) + expect(findMacTerminalHostInfoProblems(JSON.parse(plist.stdout), expectations)).toEqual([]) + }) + + it('copies every package the daemon requires, with its dependencies', () => { + const resources = join(appPath, 'Contents', 'Resources') + const main = join(resources, 'app.asar.unpacked', 'out', 'main') + writeFileSync(join(main, 'daemon-entry.js'), "require('./chunks/kill.js')") + writeFileSync( + join(main, 'chunks', 'kill.js'), + "require(`node:fs`);require('os');require(`@orca/process-host/gate`)" + ) + // Main-process code shipped alongside is not part of the daemon. + writeFileSync(join(main, 'index.js'), "require('unused');require(`electron`)") + const modules = join(resources, 'node_modules') + mkdirSync(join(modules, '@orca', 'process-host', 'dist'), { recursive: true }) + writeFileSync( + join(modules, '@orca', 'process-host', 'package.json'), + '{"dependencies":{"retry":"1"}}' + ) + mkdirSync(join(modules, 'retry'), { recursive: true }) + writeFileSync(join(modules, 'retry', 'package.json'), '{}') + mkdirSync(join(modules, 'unused'), { recursive: true }) + writeFileSync(join(modules, 'unused', 'package.json'), '{}') + const paths = assembleMacTerminalHost({ + appPath, + nodeExecutable: join(root, 'node'), + iconPath: join(root, 'icon.icns'), + info: {} + }) + const helperModules = join(paths.daemon, 'node_modules') + expect(daemonBareRequires(join(paths.daemon, 'out', 'main'))).toEqual([ + '@orca/process-host/gate' + ]) + expect(existsSync(join(helperModules, '@orca', 'process-host', 'package.json'))).toBe(true) + expect(existsSync(join(helperModules, 'retry', 'package.json'))).toBe(true) + expect(existsSync(join(helperModules, 'unused'))).toBe(false) + expect(existsSync(join(helperModules, 'electron'))).toBe(false) + }) + + it('fails when the app lacks a package the daemon requires', () => { + const main = join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'out', 'main') + writeFileSync(join(main, 'daemon-entry.js'), "require('./gone')") + writeFileSync(join(main, 'gone.js'), "require('yaml')") + expect(() => + assembleMacTerminalHost({ + appPath, + nodeExecutable: join(root, 'node'), + iconPath: join(root, 'icon.icns'), + info: {} + }) + ).toThrow(/requires yaml, missing/) + }) + + it('flags a require that only resolves outside the helper', () => { + const paths = assembleMacTerminalHost({ + appPath, + nodeExecutable: join(root, 'node'), + iconPath: join(root, 'icon.icns'), + info: {} + }) + writeFileSync(paths.entry, "require('jsonc-parser')") + // The build tree's node_modules above dist/ is what hid this from the boot check. + mkdirSync(join(root, 'node_modules', 'jsonc-parser'), { recursive: true }) + writeFileSync(join(root, 'node_modules', 'jsonc-parser', 'package.json'), '{"main":"i.js"}') + writeFileSync(join(root, 'node_modules', 'jsonc-parser', 'i.js'), '') + expect(findUnresolvedDaemonRequires(paths)).toEqual([ + expect.stringMatching( + /^jsonc-parser resolves to .*node_modules\/jsonc-parser\/i\.js, not inside/ + ) + ]) + // Node caches resolutions, so the in-bundle case uses a package not resolved above. + writeFileSync(paths.entry, "require('yaml')") + mkdirSync(join(paths.daemon, 'node_modules', 'yaml'), { recursive: true }) + writeFileSync(join(paths.daemon, 'node_modules', 'yaml', 'package.json'), '{"main":"i.js"}') + writeFileSync(join(paths.daemon, 'node_modules', 'yaml', 'i.js'), '') + expect(findUnresolvedDaemonRequires(paths)).toEqual([]) + }) + + it('follows directory and JSON loads and keeps nested dependencies with their parent', () => { + const resources = join(appPath, 'Contents', 'Resources') + const main = join(resources, 'app.asar.unpacked', 'out', 'main') + mkdirSync(join(main, 'lib'), { recursive: true }) + writeFileSync(join(main, 'daemon-entry.js'), "require('./lib');require('./data.json')") + writeFileSync(join(main, 'lib', 'index.js'), "require('outer')") + writeFileSync(join(main, 'data.json'), '{}') + const outer = join(resources, 'node_modules', 'outer') + mkdirSync(join(outer, 'node_modules', 'inner'), { recursive: true }) + writeFileSync(join(outer, 'package.json'), '{"dependencies":{"inner":"1"}}') + writeFileSync(join(outer, 'node_modules', 'inner', 'package.json'), '{}') + const paths = assembleMacTerminalHost({ + appPath, + nodeExecutable: join(root, 'node'), + iconPath: join(root, 'icon.icns'), + info: {} + }) + expect(daemonBareRequires(join(paths.daemon, 'out', 'main'))).toEqual(['outer']) + expect(existsSync(join(paths.daemon, 'node_modules', 'outer', 'node_modules', 'inner'))).toBe( + true + ) + }) + + it("copies a nested package's dependency from wherever Node resolves it", () => { + const resources = join(appPath, 'Contents', 'Resources') + writeFileSync( + join(resources, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js'), + "require('b')" + ) + const modules = join(resources, 'node_modules') + const packages = { + b: { dependencies: { c: '2' } }, + // The top-level c is another version; b gets its own nested copy. + c: {}, + [join('b', 'node_modules', 'c')]: { dependencies: { e: '1' } }, + e: {} + } + for (const [dir, manifest] of Object.entries(packages)) { + mkdirSync(join(modules, dir), { recursive: true }) + writeFileSync(join(modules, dir, 'package.json'), JSON.stringify(manifest)) + } + const paths = assembleMacTerminalHost({ + appPath, + nodeExecutable: join(root, 'node'), + iconPath: join(root, 'icon.icns'), + info: {} + }) + const helperModules = join(paths.daemon, 'node_modules') + expect(existsSync(join(helperModules, 'b', 'node_modules', 'c', 'package.json'))).toBe(true) + expect(existsSync(join(helperModules, 'e', 'package.json'))).toBe(true) + expect(existsSync(join(helperModules, 'c'))).toBe(false) + }) + + it.each([ + ['a variable', 'const name = pick();require(name)'], + ['a concatenated literal', "require('yaml' + suffix)"], + ['a template with a substitution', 'require(`./chunk-${id}.js`)'] + ])('fails when the daemon loads a module through %s', (_label, source) => { + const main = join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'out', 'main') + writeFileSync(join(main, 'daemon-entry.js'), source) + expect(() => + assembleMacTerminalHost({ + appPath, + nodeExecutable: join(root, 'node'), + iconPath: join(root, 'icon.icns'), + info: {} + }) + ).toThrow(/loads a module by a computed name/) + }) + + it('fails before writing anything when the daemon entry is missing', () => { + rmSync(join(appPath, 'Contents', 'Resources', 'app.asar.unpacked'), { recursive: true }) + expect(() => + assembleMacTerminalHost({ + appPath, + nodeExecutable: join(root, 'node'), + iconPath: join(root, 'icon.icns'), + info: {} + }) + ).toThrow(/missing .*daemon-entry\.js/) + expect(existsSync(join(appPath, 'Contents', 'Helpers'))).toBe(false) + }) +}) diff --git a/config/scripts/pinned-node-downloads.mjs b/config/scripts/pinned-node-downloads.mjs index f4bf1e2bd93..ea1a8fa99b1 100644 --- a/config/scripts/pinned-node-downloads.mjs +++ b/config/scripts/pinned-node-downloads.mjs @@ -9,6 +9,7 @@ import { nodeRuntimeHeadersUrl, nodeRuntimeReleaseUrl } from '../../src/shared/node-runtime-pin.ts' +import { isDirectInvocation } from './script-entry-detection.mjs' import { download, extract, sha256File } from './update-node-runtime-pin.mjs' const ROOT = resolve(import.meta.dirname, '..', '..') @@ -91,3 +92,10 @@ export async function ensurePinnedNodeExecutable({ target, cacheDir = pinnedNode } return executable } + +// Usage: node config/scripts/pinned-node-downloads.mjs ... — warms the cache before packaging. +if (isDirectInvocation(import.meta.url, process.argv[1])) { + for (const target of process.argv.slice(2)) { + console.log(await ensurePinnedNodeExecutable({ target })) + } +} diff --git a/config/scripts/verify-packaged-daemon-entry.cjs b/config/scripts/verify-packaged-daemon-entry.cjs index 006f5715012..ce31446640b 100644 --- a/config/scripts/verify-packaged-daemon-entry.cjs +++ b/config/scripts/verify-packaged-daemon-entry.cjs @@ -1,6 +1,14 @@ -const { existsSync } = require('node:fs') -const { spawnSync } = require('node:child_process') -const { join } = require('node:path') +const { existsSync, mkdtempSync, readFileSync, rmSync, statSync } = require('node:fs') +const { spawn, spawnSync } = require('node:child_process') +const { randomUUID } = require('node:crypto') +const { connect } = require('node:net') +const { tmpdir } = require('node:os') +const { join, relative } = require('node:path') +const { setTimeout: delay } = require('node:timers/promises') +const { macTerminalHostPaths } = require('./macos-terminal-host-bundle.cjs') +const { + daemonProtocolVersion +} = require('../../src/shared/local-build-compatibility-contract.json') // Why: `asarUnpack` in config/electron-builder.config.cjs lists // out/main/daemon-entry.js on every platform, and the packaged daemon fork @@ -55,4 +63,252 @@ function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) { console.log('[verify-packaged-daemon-entry] OK — packaged daemon-entry loads under plain Node') } -module.exports = { assertPackagedDaemonEntryExists, verifyPackagedDaemonEntryBoots } +const TERMINAL_HOST_BOOT_TIMEOUT_MS = 20_000 +// Arithmetic so the shell echoing the typed command cannot satisfy the check. +const TERMINAL_HOST_BOOT_COMMAND = 'echo "OK $((6*7))" && exit' +const TERMINAL_HOST_BOOT_OUTPUT = 'OK 42' + +/** The command prefix that runs a `targetArch` Mach-O here, or null when this host cannot. */ +function macCommandPrefixForArch(targetArch, run = spawnSync) { + if (targetArch === process.arch) { + return [] + } + if (targetArch === 'x64' && process.arch === 'arm64') { + const rosetta = run('/usr/bin/arch', ['-x86_64', '/usr/bin/true'], { timeout: 10_000 }) + return rosetta.status === 0 ? ['/usr/bin/arch', '-x86_64'] : null + } + return null +} + +/** Settles with `promise`, or rejects once `deadline` passes. */ +function beforeDeadline(promise, deadline, what) { + let timer + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`timed out waiting for ${what}`)), + Math.max(0, deadline - Date.now()) + ) + }) + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) +} + +/** + * One NDJSON daemon connection. `ready` settles on the hello reply; `broken` rejects when the + * connection errors, closes or sends a line that is not JSON, at any point. + */ +function openDaemonConnection(socketPath, hello) { + const socket = connect(socketPath) + const listeners = [] + let fail + const broken = new Promise((_, reject) => { + fail = (error) => { + reject(error) + socket.destroy() + } + }) + broken.catch(() => {}) + let greet + const greeted = new Promise((resolve) => { + greet = resolve + }) + let isGreeted = false + let buffer = '' + socket.setEncoding('utf8') + socket.on('error', (error) => fail(error)) + socket.on('close', () => fail(new Error(`the daemon closed the ${hello.role} connection`))) + socket.on('data', (chunk) => { + buffer += chunk.toString() + let newline + while ((newline = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newline) + buffer = buffer.slice(newline + 1) + let message + try { + message = JSON.parse(line) + } catch { + fail(new Error(`the daemon sent a non-JSON ${hello.role} line: ${line.slice(0, 200)}`)) + return + } + if (!isGreeted) { + isGreeted = true + if (message?.ok) { + greet() + } else { + fail(new Error(`daemon rejected ${hello.role} hello: ${message?.error}`)) + } + continue + } + for (const listener of listeners) { + listener(message) + } + } + }) + socket.write(`${JSON.stringify({ type: 'hello', ...hello })}\n`) + return { + socket, + broken, + ready: Promise.race([greeted, broken]), + onMessage: (listener) => listeners.push(listener) + } +} + +async function stopDaemon(daemon, isExited) { + if (isExited()) { + return + } + const exited = new Promise((resolve) => daemon.once('exit', resolve)) + const waitForExit = (ms) => + beforeDeadline(exited, Date.now() + ms, 'the daemon to exit').catch(() => {}) + daemon.kill('SIGTERM') + await waitForExit(3_000) + if (!isExited()) { + daemon.kill('SIGKILL') + await waitForExit(1_000) + } +} + +async function runTerminalHostRoundTrip({ + command, + entry, + deadline, + stderr, + scratchPrefix = '/tmp/oth-' +}) { + // A short path: the socket must fit sun_path. + const scratch = mkdtempSync(scratchPrefix) + const socketPath = join(scratch, 'd.sock') + const tokenPath = join(scratch, 'token') + const env = { ...process.env, ORCA_USER_DATA_PATH: join(scratch, 'user-data') } + delete env.ELECTRON_RUN_AS_NODE + const daemon = spawn( + command[0], + [...command.slice(1), entry, '--socket', socketPath, '--token', tokenPath], + { env, stdio: ['ignore', 'ignore', 'pipe'] } + ) + let exited = false + daemon.on('exit', () => { + exited = true + }) + daemon.on('error', (error) => { + exited = true + stderr.push(error.message) + }) + daemon.stderr.setEncoding('utf8') + daemon.stderr.on('data', (chunk) => stderr.push(chunk)) + // If something exits the process mid-check, still leave no daemon or scratch dir behind. + const cleanupOnExit = () => { + daemon.kill('SIGKILL') + rmSync(scratch, { recursive: true, force: true }) + } + process.once('exit', cleanupOnExit) + const connections = [] + try { + // The first exec of a freshly signed binary can be slow while the system assesses it. + while (!(existsSync(socketPath) && existsSync(tokenPath) && statSync(tokenPath).size > 0)) { + if (exited || Date.now() > deadline) { + throw new Error(exited ? 'the daemon exited before it was ready' : 'no daemon token') + } + await delay(100) + } + const token = readFileSync(tokenPath, 'utf8').trim() + const clientId = randomUUID() + const hello = { version: daemonProtocolVersion, token, clientId } + const control = openDaemonConnection(socketPath, { ...hello, role: 'control' }) + connections.push(control) + await beforeDeadline(control.ready, deadline, 'the control hello reply') + const stream = openDaemonConnection(socketPath, { ...hello, role: 'stream' }) + connections.push(stream) + await beforeDeadline(stream.ready, deadline, 'the stream hello reply') + let output = '' + const sawOutput = new Promise((resolve) => { + stream.onMessage((message) => { + output += message?.payload?.data ?? '' + if (output.includes(TERMINAL_HOST_BOOT_OUTPUT)) { + resolve() + } + }) + }) + control.onMessage((message) => { + if (message?.id === 'boot-1' && message.ok === false) { + stderr.push(`createOrAttach failed: ${message.error}`) + } + }) + control.socket.write( + `${JSON.stringify({ + id: 'boot-1', + type: 'createOrAttach', + payload: { + sessionId: 'terminal-host-boot', + cols: 80, + rows: 24, + cwd: scratch, + command: TERMINAL_HOST_BOOT_COMMAND + } + })}\n` + ) + try { + await beforeDeadline( + Promise.race([sawOutput, control.broken, stream.broken]), + deadline, + `"${TERMINAL_HOST_BOOT_OUTPUT}" from the PTY` + ) + } catch (error) { + throw new Error(`${error.message}; output: ${output.slice(-400)}`) + } + } finally { + for (const connection of connections) { + connection.socket.destroy() + } + await stopDaemon(daemon, () => exited) + process.removeListener('exit', cleanupOnExit) + rmSync(scratch, { recursive: true, force: true }) + } +} + +// Why: the helper's own Node must load node-pty and serve a PTY; a `Usage:` boot never loads +// node-pty. Release builds run it under hardened runtime, so allow-jit and same-team library +// validation are exercised before notarization. +async function verifyPackagedMacTerminalHostBoots(appPath, { arch, required }) { + const paths = macTerminalHostPaths(appPath) + const prefix = macCommandPrefixForArch(arch) + if (!prefix) { + const message = `terminal host boot on ${arch} cannot run on this ${process.arch} host` + if (required) { + throw new Error(`[verify-packaged-daemon-entry] the ${message}`) + } + console.log(`[verify-packaged-daemon-entry] skipped ${message}`) + return + } + const stderr = [] + // Boot a clone outside the build tree, as installs do, so no ancestor node_modules can + // satisfy a require the helper lacks. + const isolated = mkdtempSync(join(tmpdir(), 'orca-terminal-host-boot-')) + const clone = join(isolated, 'Orca Terminal Host.app') + try { + const cloned = spawnSync('/bin/cp', ['-cR', paths.bundle, clone], { encoding: 'utf8' }) + if (cloned.status !== 0) { + throw new Error(`could not clone the helper: ${cloned.stderr.trim()}`) + } + await runTerminalHostRoundTrip({ + command: [...prefix, join(clone, relative(paths.bundle, paths.executable))], + entry: join(clone, relative(paths.bundle, paths.entry)), + deadline: Date.now() + TERMINAL_HOST_BOOT_TIMEOUT_MS, + stderr + }) + } catch (error) { + throw new Error( + `[verify-packaged-daemon-entry] the ${arch} terminal host did not serve a PTY: ${error.message}\n${stderr.join('').slice(-2000)}` + ) + } finally { + rmSync(isolated, { recursive: true, force: true }) + } + console.log(`[verify-packaged-daemon-entry] OK — ${arch} terminal host served a PTY`) +} + +module.exports = { + assertPackagedDaemonEntryExists, + macCommandPrefixForArch, + runTerminalHostRoundTrip, + verifyPackagedDaemonEntryBoots, + verifyPackagedMacTerminalHostBoots +} diff --git a/config/scripts/verify-packaged-daemon-entry.test.mjs b/config/scripts/verify-packaged-daemon-entry.test.mjs index 8705953ba61..9169efea8b2 100644 --- a/config/scripts/verify-packaged-daemon-entry.test.mjs +++ b/config/scripts/verify-packaged-daemon-entry.test.mjs @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -7,7 +7,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' const require = createRequire(import.meta.url) const { assertPackagedDaemonEntryExists, - verifyPackagedDaemonEntryBoots + runTerminalHostRoundTrip, + verifyPackagedDaemonEntryBoots, + verifyPackagedMacTerminalHostBoots } = require('./verify-packaged-daemon-entry.cjs') describe('verify-packaged-daemon-entry', () => { @@ -55,3 +57,97 @@ describe('verify-packaged-daemon-entry', () => { expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow(/did not reach argv parsing/) }) }) + +describe('terminal host boot check on a slice this host cannot run', () => { + // No Mac runs a riscv64 binary, so this is the same path as an x64 slice without Rosetta. + it('fails a release or CI build', async () => { + await expect( + verifyPackagedMacTerminalHostBoots('/Orca.app', { arch: 'riscv64', required: true }) + ).rejects.toThrow(/riscv64 cannot run on this/) + }) + + it('lets a local build skip it', async () => { + await expect( + verifyPackagedMacTerminalHostBoots('/Orca.app', { arch: 'riscv64', required: false }) + ).resolves.toBeUndefined() + }) +}) + +describe.skipIf(process.platform === 'win32')('terminal host round trip', () => { + let root + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'vpd-')) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + // Listens, publishes its token, records its pid, then treats each connection with `onConnection`. + function fakeDaemon(onConnection) { + const entry = join(root, 'daemon.cjs') + writeFileSync( + entry, + [ + "const net = require('node:net'), fs = require('node:fs')", + 'const arg = (name) => process.argv[process.argv.indexOf(name) + 1]', + `fs.writeFileSync(${JSON.stringify(join(root, 'pid'))}, String(process.pid))`, + `net.createServer(${onConnection}).listen(arg('--socket'), () => fs.writeFileSync(arg('--token'), 'tok'))` + ].join('\n') + ) + return entry + } + + async function roundTrip(entry, timeoutMs) { + const startedAt = Date.now() + const error = await runTerminalHostRoundTrip({ + command: [process.execPath], + entry, + deadline: startedAt + timeoutMs, + stderr: [], + scratchPrefix: join(root, 'oth-') + }).then( + () => null, + (rejection) => rejection + ) + const pid = Number(readFileSync(join(root, 'pid'), 'utf8')) + let daemonAlive = true + try { + process.kill(pid, 0) + } catch { + daemonAlive = false + } + return { + error, + elapsedMs: Date.now() - startedAt, + daemonAlive, + scratchDirs: readdirSync(root).filter((name) => name.startsWith('oth-')) + } + } + + it('rejects by the deadline and cleans up when the daemon never answers the hello', async () => { + const result = await roundTrip(fakeDaemon('() => {}'), 1_500) + expect(result.error?.message).toMatch(/timed out waiting for the control hello reply/) + expect(result.elapsedMs).toBeLessThan(1_500 + 5_000) + expect(result.daemonAlive).toBe(false) + expect(result.scratchDirs).toEqual([]) + }) + + it('rejects at once and cleans up when the daemon closes the connection', async () => { + const result = await roundTrip(fakeDaemon('(socket) => socket.destroy()'), 10_000) + // A destroyed socket with unread input may surface as a reset instead of a clean close. + expect(result.error?.message).toMatch(/closed the control connection|ECONNRESET/) + expect(result.elapsedMs).toBeLessThan(10_000) + expect(result.daemonAlive).toBe(false) + expect(result.scratchDirs).toEqual([]) + }) + + it('rejects and cleans up when the daemon replies with malformed JSON', async () => { + const result = await roundTrip(fakeDaemon("(socket) => socket.write('{not json\\n')"), 10_000) + expect(result.error?.message).toMatch(/non-JSON control line: \{not json/) + expect(result.elapsedMs).toBeLessThan(10_000) + expect(result.daemonAlive).toBe(false) + expect(result.scratchDirs).toEqual([]) + }) +}) diff --git a/docs/reference/macos-daemon-tcc-attribution.md b/docs/reference/macos-daemon-tcc-attribution.md new file mode 100644 index 00000000000..12e676f75bd --- /dev/null +++ b/docs/reference/macos-daemon-tcc-attribution.md @@ -0,0 +1,125 @@ +# macOS terminal permissions across updates + +Read this before changing how the packaged macOS terminal daemon is launched, copied or cleaned +up, or before treating a missing executable as a permission denial. Background: issue +[#13921](https://github.com/stablyai/orca/issues/13921); the signed native evidence is in PR #25848. + +## Why a signed copy started by launchd + +A daemon forked from the app runs as `Orca Helper` and macOS charges its privacy access to the app +at its original path. While an update replaces that path, a daemon that first touches a protected +file is denied, and the denial persists after the new bundle arrives even though System Settings +still shows the grant. A missing executable alone does not cause it: a daemon that already read +protected files keeps working after its old executable is deleted. + +So a packaged macOS GUI app starts its daemon from a private copy that the updater never touches: + +- Each packaged macOS app ships a signed helper, `Contents/Helpers/Orca Terminal Host.app` (about + 124 MiB): the pinned Node from `src/shared/node-runtime-pin.ts` as its main executable, the + daemon's `out/main` JS and that slice's node-pty. `config/scripts/macos-terminal-host-bundle.cjs` + builds and signs it in `afterPack`, boots it through one PTY round trip, and fails the build on any + layout, Info.plist, OS floor, architecture or signature drift. +- `macos-daemon-bundle.ts` keeps one copy per helper build, named by the helper's CDHash, at + `userData/daemon-host/macos/terminal-host-/app.noindex/Orca Terminal Host.app`. The helper + is read from the bundle the running main process executes from, never the whole app. A present + copy is reused as is. Otherwise the helper is cloned (APFS clone, regular copy otherwise) into a + `.staging--*` sibling, verified once with + `codesign --verify --strict -R "="`, and published with one `rename`; + losing that rename to another publisher is fine. An app without the helper falls back to the + fork. +- `macos-daemon-launchd.ts` runs the copy's Node on the copy's `daemon-entry.js` as a + non-persistent launchd job, so the daemon has Orca's own identity rather than the UI process's + replaceable path. The label is fixed per profile and protocol + (`com.stablyai.orca.terminal..v`). Before bootstrap a stopped job with + that label is booted out; a running or unreadable one is fatal. `--entry-path` stays the installed + app's entry, which the replacement preflight compares. The job file is 0600 and deleted right + after bootstrap, because the environment may hold credentials. It sets + `AssociatedBundleIdentifiers` to `com.stablyai.orca`, which is how a launchd job not installed + through SMAppService names the app it belongs to for Local Network access. Readiness is the + 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. + +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 +fork. + +## Why the helper is `com.stablyai.orca` + +TCC records grants against the designated requirement, which for Orca is the identifier plus team. +Signing the helper with the app's own identifier lets it inherit every existing grant, including Full +Disk Access, which a user can only add by hand. A distinct identifier would be a new client: new +folder prompts naming it, and a new Full Disk Access row for every user. + +## macOS floor + +The helper's Node declares `LC_BUILD_VERSION minos 13.5`, which is also the helper's +`LSMinimumSystemVersion` and `NODE_RUNTIME_DARWIN_MINIMUM_OS`. Packaging fails if a Node pin bump +changes it. Below 13.5 the launcher returns before copying and the fork runs, so those Macs keep the +pre-#25848 behaviour; Orca itself still supports macOS 12.0. + +## First launch + +macOS takes about 1 s the first time a binary runs from a new path, so the first daemon of each +build starts that much slower; later daemons reuse the copy and pay nothing. During that first exec +the daemon's `/usr/bin/login` preflight can time out, and that one shell is spawned directly. + +## Security + +The helper is a signed "run any JS as Orca" binary: it honours `NODE_OPTIONS` and any script path, +and Electron's fuses do not apply to it. Orca's own binary already allows the same, because its +RunAsNode fuse is enabled, so the helper adds no new exposure. + +## Fallback states + +`launchMacDaemonFromStableBundle` returns null, and the launcher forks, only when no job from the +attempt can still claim the endpoint. It logs one `[daemon] macOS terminal host fallback reason=<…>` +line with `MacStableLaunchFallbackReason`: `prepare-failed` (helper, copy, signature or job file), +`deadline`, `bootstrap-refused` (launchd refused the job and it is absent), `exited-before-ready` +(the job stopped before answering), or `endpoint-taken` (another daemon owns the endpoint, which +the fork path then adopts normally). After `bootstrap-refused` or `exited-before-ready` the copy is +deleted if unused, so the next launch re-copies it. A timed-out bootstrap, or a job still running or +unreadable, throws: forking beside it could split the endpoint. + +The stable attempt must hand off by `MAC_STABLE_LAUNCH_HANDOFF_MS` after the launch starts, so the +fork's own readiness wait and adapter install still fit the 60 s startup PTY gate. Preparation +shares one abort signal that ends early enough for bootstrap and the job's readiness wait to fit +too. A fallback after the handoff deadline throws instead of forking a daemon that would arrive +after the app has moved to local PTYs. + +## Cleanup rule + +After publishing or reusing the current copy, each launch starts one background collection (one at +a time per process). It deletes every other entry under `daemon-host/macos/`, older copies and +crashed staging dirs, once `lsof +D` over it reports no process. `lsof +D` exits 1 even when it +lists users, so only empty stdout counts as unused; any timeout, stderr, truncated output or other +exit code keeps the copy. Nothing else can run a non-current copy: only this app launches daemons, +and only from the current copy. Staging dirs named with this process's pid are skipped, since they +may still be copying. + +## LaunchServices and Spotlight + +The helper declares no links, documents or types, has a lower `CFBundleVersion` than the app and +`LSUIElement`, and was not registered in the signed spike, nested or copied. Copies sit in a +`.noindex` folder, which Spotlight skips, and Orca never registers them. Starting the daemon through +launchd leaves no record, but another process running the copy's executable directly can make +macOS register it, so retirement runs `lsregister -u` on the bundle before deleting it. + +The helper's main-executable UUID is Node's, shared by every official Node 24.21.0 binary of that +architecture, so it is not unique to Orca. Local Network access still passed in the signed spike: +macOS matched the client by its code signature, and the job's `AssociatedBundleIdentifiers` ties it +to Orca. Do not register copies explicitly (`lsregister -f` or `LSRegisterURL`). + +## Rollback + +To turn this off, make `launchMacDaemonFromStableBundle` (`macos-daemon-launchd.ts`) return `null` +right after its platform checks: every launch then takes the fork path. Copy-launched daemons that +are still running are adopted as usual. Copies are only deleted by the collection, so keep it running +there (`retireOtherMacDaemonCopies(root, '')`) until no copy-launched daemon can remain; each copy +keeps about 124 MiB. + +## Existing sessions + +Running old daemons keep their terminals; a denied terminal is recovered by closing it or restarting +the daemon. diff --git a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts index 0e25f326b14..43b1b4ba877 100644 --- a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts +++ b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts @@ -56,7 +56,6 @@ describe('every search entry point carries the scope identity through', () => { paths: ['/work/app'] }) }) - }) describe('the shared remote client', () => { diff --git a/src/main/ai-vault/antigravity-index-file-admission.test.ts b/src/main/ai-vault/antigravity-index-file-admission.test.ts index 95b3a9743cd..db8236a3aa0 100644 --- a/src/main/ai-vault/antigravity-index-file-admission.test.ts +++ b/src/main/ai-vault/antigravity-index-file-admission.test.ts @@ -42,7 +42,6 @@ afterEach(async () => { }) describe('bounded regular metadata reads', () => { - it.skipIf(process.platform === 'win32')( 'rejects a regular path replaced by a FIFO between stat and open', async () => { diff --git a/src/main/ai-vault/openclaw-session-layout.test.ts b/src/main/ai-vault/openclaw-session-layout.test.ts index 12c21b47c1b..c62b987dbc4 100644 --- a/src/main/ai-vault/openclaw-session-layout.test.ts +++ b/src/main/ai-vault/openclaw-session-layout.test.ts @@ -44,5 +44,4 @@ describe('OpenClaw session discovery', () => { expect(relativeToAgents(files)).toEqual(EXPECTED) }) - }) diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 3356d1a4cda..22e42b543d7 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(44) + expect(PROTOCOL_VERSION).toBe(45) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) diff --git a/src/main/daemon/daemon-init-macos-stable-launch-fallback.test.ts b/src/main/daemon/daemon-init-macos-stable-launch-fallback.test.ts new file mode 100644 index 00000000000..45de6bf0992 --- /dev/null +++ b/src/main/daemon/daemon-init-macos-stable-launch-fallback.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as MacDaemonLaunchd from './macos-daemon-launchd' +import type { DaemonLauncher } from './daemon-spawner' +import { LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS } from '../startup/first-window-startup-services' +import { DAEMON_CHILD_STARTUP_TIMEOUT_MS } from './daemon-launched-child' + +const { + stableLaunch, + materializeMock, + forkMock, + isPackagedMock, + getAppPathMock, + checkDaemonHealthMock, + spawnerInstances, + importFresh, + installDefaultNetConnectStub, + moduleFactories +} = await vi.hoisted(async () => { + const stableLaunch: { + failure: 'unavailable' | 'fatal' | 'real' + deadlinesMs: number[] + } = { failure: 'unavailable', deadlinesMs: [] } + return { + stableLaunch, + materializeMock: vi.fn(), + ...(await (await import('./daemon-init-test-harness')).createDaemonInitMocks()) + } +}) + +vi.mock('fs', () => moduleFactories.fs()) +vi.mock('child_process', async (importOriginal) => + moduleFactories.childProcess(await importOriginal>()) +) +vi.mock('net', () => moduleFactories.net()) +vi.mock('./daemon-health', () => moduleFactories.daemonHealth()) +vi.mock('./daemon-pid-identity', () => moduleFactories.daemonPidIdentity()) +vi.mock('./daemon-tcc-attribution', () => moduleFactories.daemonTccAttribution()) +vi.mock('./daemon-bundle-staleness', () => moduleFactories.daemonBundleStaleness()) +vi.mock('./daemon-stale-kill', () => moduleFactories.daemonStaleKill()) +vi.mock('./daemon-process-start-time', () => moduleFactories.daemonProcessStartTime()) +vi.mock('./daemon-pid-file-parse', () => moduleFactories.daemonPidFileParse()) +vi.mock('./client', () => moduleFactories.client()) +vi.mock('./daemon-lifecycle-event', () => moduleFactories.daemonLifecycleEvent()) +vi.mock('./daemon-spawner', () => moduleFactories.daemonSpawner()) +vi.mock('./daemon-pty-adapter', () => moduleFactories.daemonPtyAdapter()) +vi.mock('../ipc/pty', () => moduleFactories.ipcPty()) +vi.mock('./macos-daemon-launchd', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + launchMacDaemonFromStableBundle: async ( + options: Parameters[0], + deadlineMs: number + ) => { + stableLaunch.deadlinesMs.push(deadlineMs) + if (stableLaunch.failure === 'real') { + return actual.launchMacDaemonFromStableBundle(options, deadlineMs) + } + if (stableLaunch.failure === 'unavailable') { + return null + } + throw new Error('Could not start the macOS terminal service') + } + } +}) + +vi.mock('./macos-daemon-bundle', async (importOriginal) => ({ + ...(await importOriginal>()), + materializeMacDaemonBundle: materializeMock +})) + +function isDaemonLauncher(value: unknown): value is DaemonLauncher { + return typeof value === 'function' +} + +function readyChild(): unknown { + return { + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready', pid: 12345, startedAtMs: 1_000_000 })) + } + return this + }, + off: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn() + } +} + +describe('daemon-init: macOS stable-bundle launch fallback', () => { + beforeEach(() => { + stableLaunch.deadlinesMs = [] + installDefaultNetConnectStub() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + let launcherCalledAtMs = 0 + + async function launchOnce(): Promise { + const mod = await importFresh() + if (stableLaunch.failure === 'real') { + // The real launcher only runs from a packaged app. + isPackagedMock.mockReturnValue(true) + getAppPathMock.mockReturnValue('/Applications/Orca.app/Contents/Resources/app.asar') + } + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true }) + const launcher = spawnerInstances.at(-1)?.launcher + if (!isDaemonLauncher(launcher)) { + throw new Error('initDaemonPtyProvider did not construct a spawner') + } + forkMock.mockClear() + forkMock.mockReturnValueOnce(readyChild()) + launcherCalledAtMs = Date.now() + return launcher('/fake/socket', '/fake/token') + } + + it('forks the daemon from the app when no stable-bundle job can claim the endpoint', async () => { + stableLaunch.failure = 'unavailable' + await expect(launchOnce()).resolves.toBeTruthy() + expect(forkMock).toHaveBeenCalledOnce() + }) + + it('hands off to the fork early enough for its readiness wait and adapter to fit the gate', async () => { + stableLaunch.failure = 'unavailable' + await launchOnce() + const handoffMs = (stableLaunch.deadlinesMs[0] ?? Infinity) - launcherCalledAtMs + // A stable failure at the handoff deadline still leaves the whole fork budget in the gate... + expect(handoffMs + DAEMON_CHILD_STARTUP_TIMEOUT_MS + 5_000).toBeLessThanOrEqual( + LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS + ) + // ...without starving the stable launch of its own bootstrap and readiness time. + expect(handoffMs).toBeGreaterThan(20_000) + }) + + describe('with the real stable launcher', () => { + const originalGetSystemVersion = Object.getOwnPropertyDescriptor(process, 'getSystemVersion') + + function stubSystemVersion(version: string): void { + Object.defineProperty(process, 'getSystemVersion', { + configurable: true, + value: () => version + }) + } + + beforeEach(() => { + stableLaunch.failure = 'real' + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + materializeMock.mockReset() + }) + + afterEach(() => { + stableLaunch.failure = 'unavailable' + if (originalGetSystemVersion) { + Object.defineProperty(process, 'getSystemVersion', originalGetSystemVersion) + } else { + Reflect.deleteProperty(process, 'getSystemVersion') + } + }) + + it('forks when the running app ships no terminal host helper', async () => { + stubSystemVersion('15.4') + materializeMock.mockRejectedValue( + new Error('The running app has no macOS terminal host helper') + ) + await expect(launchOnce()).resolves.toBeTruthy() + expect(materializeMock).toHaveBeenCalledOnce() + expect(forkMock).toHaveBeenCalledOnce() + }) + + it('forks without copying on macOS below the helper floor', async () => { + stubSystemVersion('13.4.1') + await expect(launchOnce()).resolves.toBeTruthy() + expect(materializeMock).not.toHaveBeenCalled() + expect(forkMock).toHaveBeenCalledOnce() + }) + }) + + it('never forks beside a stable-bundle job whose fate is unknown', async () => { + stableLaunch.failure = 'fatal' + await expect(launchOnce()).rejects.toThrow('Could not start the macOS terminal service') + expect(forkMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/daemon/daemon-launched-child-spawn.ts b/src/main/daemon/daemon-launched-child-spawn.ts index 2093c94c90d..06b94f0779c 100644 --- a/src/main/daemon/daemon-launched-child-spawn.ts +++ b/src/main/daemon/daemon-launched-child-spawn.ts @@ -29,7 +29,10 @@ export type DaemonChildSpawnOptions = { startupTimeoutMs?: number } -function buildDaemonScriptArgs(options: DaemonChildSpawnOptions): string[] { +export function buildDaemonScriptArgs( + options: Omit, + spawnerExecPath = process.execPath +): string[] { const { socketPath, tokenPath, @@ -53,7 +56,7 @@ function buildDaemonScriptArgs(options: DaemonChildSpawnOptions): string[] { '--app-version', getAppEnvironment().getVersion(), '--spawner-exec-path', - process.execPath, + spawnerExecPath, ...(macosLoginSessionWatch ? ['--login-session-watch'] : []), ...(options.spawnAdmission === 'enforce' ? [TERMINAL_SPAWN_ADMISSION_ARG, 'enforce'] : []), ...daemonLogArgs() diff --git a/src/main/daemon/daemon-launched-child.ts b/src/main/daemon/daemon-launched-child.ts index 63f454b70c6..6041a6a5774 100644 --- a/src/main/daemon/daemon-launched-child.ts +++ b/src/main/daemon/daemon-launched-child.ts @@ -11,10 +11,11 @@ import { parseDaemonReadyIdentity } from './daemon-ready-identity' import { unlinkOwnedDaemonPidFile } from './daemon-spawner' import { childProcessHasExited } from '@orca/process-host/process-tree-termination' +/** How long a forked daemon gets to report ready over IPC, unless the caller overrides it. */ +export const DAEMON_CHILD_STARTUP_TIMEOUT_MS = 10_000 const DAEMON_CHILD_TERMINATION_GRACE_MS = 5_000 const DAEMON_CHILD_FORCE_EXIT_WAIT_MS = 1_000 const STARTUP_STDERR_MAX_BYTES = 8192 -const DEFAULT_DAEMON_STARTUP_TIMEOUT_MS = 10_000 export class DaemonEndpointUnavailableError extends Error { constructor( @@ -225,7 +226,7 @@ async function launchDaemonChildAttempt( timer = setTimeout(() => { void fail(new Error('Daemon startup timed out')) - }, options.startupTimeoutMs ?? DEFAULT_DAEMON_STARTUP_TIMEOUT_MS) + }, options.startupTimeoutMs ?? DAEMON_CHILD_STARTUP_TIMEOUT_MS) child.on('message', onReadyMessage) child.on('error', onStartupError) diff --git a/src/main/daemon/daemon-mac-code-identity.test.ts b/src/main/daemon/daemon-mac-code-identity.test.ts index 8e7117680dc..502cf844c7b 100644 --- a/src/main/daemon/daemon-mac-code-identity.test.ts +++ b/src/main/daemon/daemon-mac-code-identity.test.ts @@ -3,7 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) -import { classifyCodesignDisplayOutput, getDaemonMacCodeIdentity } from './daemon-mac-code-identity' +import { + classifyCodesignDisplayOutput, + getDaemonMacCodeIdentity, + inspectMacProcessCodeIdentity +} from './daemon-mac-code-identity' const HELPER_PATH = '/Applications/Orca.app/Contents/Frameworks/Orca Helper.app/Contents/MacOS/Orca Helper' @@ -69,6 +73,22 @@ describe('classifyCodesignDisplayOutput', () => { }) describe('getDaemonMacCodeIdentity', () => { + it('shares the resolved path inspection with telemetry without another process', async () => { + codesignReturns(`Executable=${PARKED_PATH}\n`, 0) + await expect( + Promise.all([inspectMacProcessCodeIdentity(3337), getDaemonMacCodeIdentity(3337)]) + ).resolves.toEqual([{ identity: 'parked', executablePath: PARKED_PATH }, 'parked']) + expect(runProcessMock).toHaveBeenCalledTimes(1) + }) + + it('withholds a partial path from a timed-out inspection', async () => { + codesignReturns(`Executable=${HELPER_PATH}\n`, null, true) + await expect(inspectMacProcessCodeIdentity(3337)).resolves.toEqual({ + identity: 'probe-failed', + executablePath: null + }) + }) + it('asks codesign to display the running pid and reads its stderr', async () => { codesignReturns(`Executable=${HELPER_PATH}\n`, 0) await expect(getDaemonMacCodeIdentity(3337)).resolves.toBe('resolved') diff --git a/src/main/daemon/daemon-mac-code-identity.ts b/src/main/daemon/daemon-mac-code-identity.ts index a06c1da2f53..56b8c997c8a 100644 --- a/src/main/daemon/daemon-mac-code-identity.ts +++ b/src/main/daemon/daemon-mac-code-identity.ts @@ -1,5 +1,5 @@ // Adapted from David Bebawy's PR #21826: `codesign --display +` is the probe that answers -// where a running pid's executable lives now. Measurement only; nothing reads the verdict. +// where a running pid's executable lives now. An unresolved image alone does not prove denial. import { runProcess } from '@orca/process-host' import type { DaemonCodeIdentity } from '../../shared/daemon-adoption-telemetry' @@ -12,6 +12,20 @@ const UNLINKED_EXECUTABLE_PATTERN = /No such file or directory/ // Squirrel parks the outgoing bundle under a `…ShipIt…` directory in $TMPDIR or ~/Library/Caches. const PARKED_BUNDLE_PATTERN = /\/[^/]*ShipIt[^/]*\// +export type MacProcessCodeIdentity = { + identity: DaemonCodeIdentity + executablePath: string | null +} + +function executablePathFromDisplay(output: string): string | null { + for (const line of output.split(/\r?\n/)) { + if (line.startsWith('Executable=')) { + return line.slice('Executable='.length).trim() || null + } + } + return null +} + export function classifyCodesignDisplayOutput( output: string, code: number | null @@ -27,7 +41,7 @@ export function classifyCodesignDisplayOutput( return code !== 0 && UNLINKED_EXECUTABLE_PATTERN.test(output) ? 'unresolvable' : 'probe-failed' } -async function probe(pid: number): Promise { +async function probe(pid: number): Promise { try { const result = await runProcess({ program: '/usr/bin/codesign', @@ -37,24 +51,29 @@ async function probe(pid: number): Promise { }) // A killed codesign can still have printed a path; that half-written display proves nothing. if (result.timedOut) { - return 'probe-failed' + return { identity: 'probe-failed', executablePath: null } } // codesign writes both the display fields and its diagnostics to stderr. - return classifyCodesignDisplayOutput(`${result.stderr}\n${result.stdout}`, result.code) + const output = `${result.stderr}\n${result.stdout}` + return { + identity: classifyCodesignDisplayOutput(output, result.code), + executablePath: + result.code === 0 && !result.outputTruncated ? executablePathFromDisplay(output) : null + } } catch { - return 'probe-failed' + return { identity: 'probe-failed', executablePath: null } } } // Concurrent asks about one pid (a burst of spawns) share a probe; nothing outlives it. -let inFlight: { pid: number; pending: Promise } | null = null +let inFlight: { pid: number; pending: Promise } | null = null /** Read fresh on every ask: a parked bundle can be deleted mid-run, flipping `parked` to `unresolvable`. */ -export function getDaemonMacCodeIdentity( +export function inspectMacProcessCodeIdentity( pid: number | null | undefined -): Promise { +): Promise { if (process.platform !== 'darwin' || !pid || !Number.isSafeInteger(pid) || pid <= 0) { - return Promise.resolve('probe-failed') + return Promise.resolve({ identity: 'probe-failed', executablePath: null }) } if (inFlight?.pid !== pid) { const entry = { pid, pending: probe(pid) } @@ -67,3 +86,9 @@ export function getDaemonMacCodeIdentity( } return inFlight.pending } + +export async function getDaemonMacCodeIdentity( + pid: number | null | undefined +): Promise { + return (await inspectMacProcessCodeIdentity(pid)).identity +} diff --git a/src/main/daemon/daemon-out-of-process-launcher-host-prune.test.ts b/src/main/daemon/daemon-out-of-process-launcher-host-prune.test.ts index 6bd55cf57a8..22a689e3cb9 100644 --- a/src/main/daemon/daemon-out-of-process-launcher-host-prune.test.ts +++ b/src/main/daemon/daemon-out-of-process-launcher-host-prune.test.ts @@ -34,6 +34,7 @@ vi.mock('./daemon-endpoint-adoption', () => ({ reconcileDaemonPidOwnership: vi.fn() })) vi.mock('./daemon-launched-child', () => ({ + DAEMON_CHILD_STARTUP_TIMEOUT_MS: 10_000, DaemonEndpointUnavailableError: class extends Error {}, launchDaemonChild: harness.launchDaemonChild, terminateLaunchedDaemonChild: vi.fn() diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index c24cac90336..8e47635fa7b 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -8,7 +8,9 @@ import { holdDaemonAdoptionLease, reconcileDaemonPidOwnership } from './daemon-endpoint-adoption' +import { LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS } from '../startup/first-window-startup-services' import { + DAEMON_CHILD_STARTUP_TIMEOUT_MS, DaemonEndpointUnavailableError, launchDaemonChild, terminateLaunchedDaemonChild @@ -16,6 +18,7 @@ import { import { getDaemonEntryPath, probeDaemonSocket as probeSocket } from './daemon-launch-paths' import { materializeRelocatedDaemonHost, pruneDaemonHostStaging } from './daemon-host-relocation' import { appImageDaemonIdentityPath, resolveAppImageDaemonLaunch } from './daemon-appimage-launch' +import { launchMacDaemonFromStableBundle } from './macos-daemon-launchd' import { DAEMON_RECOVERY_BUDGET_MS, daemonRecoveryProbeTimeoutMs } from './daemon-recovery-budget' import { cleanupDaemonForProtocol } from './daemon-protocol-cleanup' import { @@ -34,6 +37,10 @@ import { ensureDaemonSocketDir } from './daemon-socket-endpoint-path' // The adapter hands the reason across so the launch it triggers reports what actually drove it. let attributedReplaceReason: DaemonReplaceReason | null = null +// The fork fallback's readiness wait plus the lease and adapter connects must still fit the gate. +const MAC_STABLE_LAUNCH_HANDOFF_MS = + LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS - DAEMON_CHILD_STARTUP_TIMEOUT_MS - 5_000 + export function attributeNextDaemonReplacement(reason: DaemonReplaceReason): void { attributedReplaceReason = reason } @@ -68,7 +75,8 @@ export function createOutOfProcessLauncher( const entryPath = getDaemonEntryPath() // Why here: everything up to the fork is one recovery, so the adoption connect and the // preflight's probes share a single absolute budget rather than each carrying its own. - const recoveryDeadlineMs = Date.now() + DAEMON_RECOVERY_BUDGET_MS + const launchStartedAtMs = Date.now() + const recoveryDeadlineMs = launchStartedAtMs + DAEMON_RECOVERY_BUDGET_MS const pidPath = suppliedPidPath ?? getDaemonPidPath(runtimeDir) const launchNonce = suppliedLaunchNonce ?? randomUUID() // One-shot: whichever launch consumes it owns the attribution, so a later unrelated launch can't @@ -133,6 +141,22 @@ export function createOutOfProcessLauncher( } const userDataPath = environment.getPath('userData') + const macHandle = await launchMacDaemonFromStableBundle( + { + entryPath, + userDataPath, + socketPath, + tokenPath, + pidPath, + launchNonce, + macosLoginSessionWatch, + ...(spawnAdmission ? { spawnAdmission } : {}) + }, + launchStartedAtMs + MAC_STABLE_LAUNCH_HANDOFF_MS + ) + if (macHandle) { + return macHandle + } // Why: on win32 packaged, stage a daemon-host copy in userData so its image escapes the NSIS updater's kill zone; lazy so it's off first-paint. Fail-open: null → in-dir host. const relocatedHost = materializeRelocatedDaemonHost() pruneDaemonHostStaging() diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 2cede1068df..de8e76e35ad 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,9 +1,10 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. +// v45 starts new macOS tabs in the update-safe terminal host helper beside an existing daemon. // v44 gives every pane the `claude` function that follows the selected Claude account (v42 had it, // v43 dropped it with the revert); v41 stages long startup commands as sourced scripts; v40 rolled // the #25130/#24636 shell-wrapper changes and the wider agent list into a fresh daemon; older owners // stay attachable. -export const PROTOCOL_VERSION = 44 +export const PROTOCOL_VERSION = 45 export const CLAUDE_ACCOUNT_FUNCTION_DAEMON_PROTOCOL_VERSION = 42 export const CLAUDE_ACCOUNT_FUNCTION_REVERTED_DAEMON_PROTOCOL_VERSION = 43 // v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS. @@ -37,7 +38,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43 + 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44 ] as const export function supportsColorQueryReplyColors(protocolVersion: number): boolean { diff --git a/src/main/daemon/macos-daemon-bundle-retirement.test.ts b/src/main/daemon/macos-daemon-bundle-retirement.test.ts new file mode 100644 index 00000000000..9417e31464b --- /dev/null +++ b/src/main/daemon/macos-daemon-bundle-retirement.test.ts @@ -0,0 +1,118 @@ +import { access, mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' + +const { run } = vi.hoisted(() => ({ + run: vi.fn<(spec: ProcessSpec) => Promise>() +})) +vi.mock('@orca/process-host', () => ({ runProcess: run })) +import { + retireOtherMacDaemonCopies, + retireUnusedMacDaemonCopy +} from './macos-daemon-bundle-retirement' + +// What `lsof -F p +D` prints for an unused tree: nothing, exit 1. +const unused: ProcessResult = { code: 1, signal: null, stdout: '', stderr: '', timedOut: false } +// Observed on a live copy: the user's pid on stdout, and still exit 1. +const inUse: ProcessResult = { ...unused, stdout: 'p123\nftxt\nftxt\n' } +const LSREGISTER = + '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister' +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-runtime-retire-')) + run.mockReset().mockResolvedValue(unused) +}) +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +async function copy(name: string): Promise { + const directory = join(root, name) + await mkdir(join(directory, 'app.noindex', 'Orca Terminal Host.app'), { recursive: true }) + return directory +} + +const exists = (path: string): Promise => + access(path).then( + () => true, + () => false + ) + +function lsofReports(byDirectory: Record): void { + run.mockImplementation(async (spec) => + spec.program === '/usr/sbin/lsof' ? (byDirectory[spec.args?.at(-1) ?? ''] ?? unused) : unused + ) +} + +it('keeps a copy whose lsof scan lists a process even though lsof exits 1', async () => { + const directory = await copy('terminal-host-old') + run.mockResolvedValue(inUse) + await retireUnusedMacDaemonCopy(directory) + expect(await exists(directory)).toBe(true) + expect(run.mock.calls.some(([spec]) => spec.program === LSREGISTER)).toBe(false) +}) + +it.each([ + { stderr: 'lsof: WARNING: can not stat' }, + { timedOut: true }, + { outputTruncated: true }, + { code: null }, + { code: 0 } +])('keeps a copy when the scan is incomplete: %j', async (override) => { + const directory = await copy('terminal-host-old') + run.mockResolvedValue({ ...unused, ...override }) + await retireUnusedMacDaemonCopy(directory) + expect(await exists(directory)).toBe(true) +}) + +it('unregisters an unused copy from LaunchServices, then deletes it', async () => { + const directory = await copy('terminal-host-old') + const bundle = join(directory, 'app.noindex', 'Orca Terminal Host.app') + let bundleExistedAtUnregister = false + run.mockImplementation(async (spec) => { + if (spec.program === LSREGISTER) { + expect(spec.args).toEqual(['-u', bundle]) + bundleExistedAtUnregister = await exists(bundle) + } + return unused + }) + await retireUnusedMacDaemonCopy(directory) + expect(bundleExistedAtUnregister).toBe(true) + expect(await exists(directory)).toBe(false) +}) + +it('deletes an unused copy even when LaunchServices cannot unregister it', async () => { + const directory = await copy('terminal-host-old') + run.mockImplementation(async (spec) => { + if (spec.program === LSREGISTER) { + throw new Error('lsregister unavailable') + } + return unused + }) + await retireUnusedMacDaemonCopy(directory) + expect(await exists(directory)).toBe(false) +}) + +it("retires every unused copy and staging dir except the current build's and this process's staging", async () => { + const current = await copy('terminal-host-current') + const oldUnused = await copy('terminal-host-old') + const oldInUse = await copy('terminal-host-older') + const crashedStaging = await copy('terminal-host-current.staging-1-AbCdEf') + const ownStaging = await copy(`terminal-host-current.staging-${process.pid}-AbCdEf`) + lsofReports({ [oldInUse]: inUse }) + await retireOtherMacDaemonCopies(root, current) + expect(await exists(current)).toBe(true) + expect(await exists(ownStaging)).toBe(true) + expect(await exists(oldInUse)).toBe(true) + expect(await exists(oldUnused)).toBe(false) + expect(await exists(crashedStaging)).toBe(false) +}) + +it('runs one collection at a time and tolerates a missing root', async () => { + const first = retireOtherMacDaemonCopies(join(root, 'absent'), join(root, 'absent', 'x')) + expect(retireOtherMacDaemonCopies(root, join(root, 'x'))).toBe(first) + await expect(first).resolves.toBeUndefined() +}) diff --git a/src/main/daemon/macos-daemon-bundle-retirement.ts b/src/main/daemon/macos-daemon-bundle-retirement.ts new file mode 100644 index 00000000000..d0d9ac2fa9c --- /dev/null +++ b/src/main/daemon/macos-daemon-bundle-retirement.ts @@ -0,0 +1,61 @@ +import { readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { runProcess } from '@orca/process-host' +import { rm } from '../asar-transparent-fs' +import { macDaemonCopyBundlePath } from './macos-daemon-bundle' + +const LSREGISTER = + '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister' +let collectionInFlight: Promise | null = null + +/** Deletes a copy unless any process still has a file in it open, mapped or as its executable. */ +export async function retireUnusedMacDaemonCopy(directory: string): Promise { + try { + const result = await runProcess({ + program: '/usr/sbin/lsof', + args: ['-F', 'p', '+D', directory], + timeoutMs: 5_000, + maxOutputBytes: 8192 + }) + // `lsof +D` exits 1 even when it lists users (any closed file in the tree does that), so only + // empty stdout proves the copy unused; the remaining checks reject an incomplete scan. + const unused = + result.stdout.trim() === '' && + result.stderr.trim() === '' && + result.code === 1 && + !result.timedOut && + !result.outputTruncated + if (!unused) { + return + } + // Something that ran the executable directly may have registered the copy with LaunchServices. + await runProcess({ + program: LSREGISTER, + args: ['-u', macDaemonCopyBundlePath(directory)], + timeoutMs: 5_000, + maxOutputBytes: 8192 + }).catch(() => {}) + await rm(directory, { recursive: true, force: true }) + } catch { + // Unverifiable use retains code. + } +} + +async function collect(root: string, current: string): Promise { + const names = await readdir(root).catch(() => []) + for (const name of names) { + const directory = join(root, name) + // Only this process launches, and only from `current`; its own staging may still be copying. + if (directory !== current && !name.includes(`.staging-${process.pid}-`)) { + await retireUnusedMacDaemonCopy(directory) + } + } +} + +/** One background collection at a time, never one scan per terminal. */ +export function retireOtherMacDaemonCopies(root: string, current: string): Promise { + collectionInFlight ??= collect(root, current).finally(() => { + collectionInFlight = null + }) + return collectionInFlight +} diff --git a/src/main/daemon/macos-daemon-bundle.test.ts b/src/main/daemon/macos-daemon-bundle.test.ts new file mode 100644 index 00000000000..f353d6891cd --- /dev/null +++ b/src/main/daemon/macos-daemon-bundle.test.ts @@ -0,0 +1,212 @@ +import { + cp, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + symlink, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' + +const { runProcessMock, inspectMock } = vi.hoisted(() => ({ + runProcessMock: vi.fn<(spec: ProcessSpec) => Promise>(), + inspectMock: vi.fn() +})) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) +vi.mock('./daemon-mac-code-identity', () => ({ inspectMacProcessCodeIdentity: inspectMock })) + +import { materializeMacDaemonBundle } from './macos-daemon-bundle' + +const APP_REQUIREMENT = 'identifier "com.stablyai.orca" and anchor apple generic' +const ok: ProcessResult = { code: 0, signal: null, stdout: '', stderr: '', timedOut: false } +let root: string +let source: string +let helper: string +let copies: string +let cdhash: string +let cloneLeavesPartialTree: boolean +let verifyCode: number +let onCopy: (() => Promise) | null + +const signal = (): AbortSignal => new AbortController().signal +const helperExecutable = (): string => join(helper, 'Contents', 'MacOS', 'orca-terminal-host') + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'orca-mac-runtime-'))) + // The running app was parked by an update; a new build already sits at the install path. + source = join(root, 'com.stablyai.orca.ShipIt.old', 'Orca.app') + helper = join(source, 'Contents', 'Helpers', 'Orca Terminal Host.app') + copies = join(root, 'profile', 'daemon-host', 'macos') + await mkdir(join(source, 'Contents', 'MacOS'), { recursive: true }) + await mkdir(join(helper, 'Contents', 'MacOS'), { recursive: true }) + await mkdir(join(helper, 'Contents', 'Resources', 'daemon', 'out', 'main'), { recursive: true }) + await writeFile(helperExecutable(), 'signed-node') + await writeFile( + join(helper, 'Contents', 'Resources', 'daemon', 'out', 'main', 'daemon-entry.js'), + 'daemon-code' + ) + cdhash = 'a'.repeat(40) + cloneLeavesPartialTree = false + verifyCode = 0 + onCopy = null + inspectMock.mockReset().mockResolvedValue({ + identity: 'parked', + executablePath: join(source, 'Contents', 'MacOS', 'Orca') + }) + runProcessMock.mockReset().mockImplementation(async (spec) => { + const args = spec.args ?? [] + if (spec.program === '/bin/cp') { + const [flag, from, destination] = args + if (!from || !destination) { + throw new Error('No copy source or destination') + } + if (cloneLeavesPartialTree && flag === '-cR') { + await mkdir(destination) + await writeFile(join(destination, 'partial'), 'partial-copy') + return { ...ok, code: 1 } + } + await cp(from, destination, { recursive: true }) + await onCopy?.() + return ok + } + if (args[0] === '-dvvv') { + return args[1] === helper + ? { ...ok, stderr: `Identifier=x\nCDHash=${cdhash}\n` } + : { ...ok, code: 1 } + } + if (args[0] === '--display') { + return args.at(-1) === source + ? { ...ok, stderr: `designated => ${APP_REQUIREMENT}\n` } + : { ...ok, code: 1 } + } + if (args[0] === '--verify') { + return { ...ok, code: args.includes(`=${APP_REQUIREMENT}`) ? verifyCode : 3 } + } + throw new Error(`Unexpected ${spec.program} ${args.join(' ')}`) + }) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +const runs = (flag: string): number => + runProcessMock.mock.calls.filter(([spec]) => spec.args?.[0] === flag).length + +it('publishes one verified copy per helper build, from the running app rather than its replacement', async () => { + const copy = await materializeMacDaemonBundle(copies, signal()) + await rm(source, { recursive: true }) + expect(copy.directory).toBe(join(copies, `terminal-host-${cdhash}`)) + // Spotlight never indexes the copy, so it is not offered as a second Orca app. + expect(copy.execPath).toBe( + join( + copy.directory, + 'app.noindex', + 'Orca Terminal Host.app', + 'Contents', + 'MacOS', + 'orca-terminal-host' + ) + ) + expect(await readFile(copy.execPath, 'utf8')).toBe('signed-node') + expect(await readFile(copy.entryPath, 'utf8')).toBe('daemon-code') + expect(await readdir(copies)).toEqual([`terminal-host-${cdhash}`]) +}) + +it('reuses a published copy without copying or verifying again', async () => { + const first = await materializeMacDaemonBundle(copies, signal()) + await writeFile(helperExecutable(), 'same-build-rewritten') + const second = await materializeMacDaemonBundle(copies, signal()) + expect(second).toEqual(first) + expect(await readFile(second.execPath, 'utf8')).toBe('signed-node') + expect(runs('-cR')).toBe(1) + expect(runs('--verify')).toBe(1) +}) + +it('publishes a new copy beside the old one when the helper build changes', async () => { + const first = await materializeMacDaemonBundle(copies, signal()) + cdhash = 'b'.repeat(40) + await writeFile(helperExecutable(), 'next-build') + const second = await materializeMacDaemonBundle(copies, signal()) + expect(second.directory).not.toBe(first.directory) + expect(await readFile(second.execPath, 'utf8')).toBe('next-build') + expect(await readFile(first.execPath, 'utf8')).toBe('signed-node') +}) + +it("rejects a copy that does not satisfy the running app's requirement and publishes nothing", async () => { + verifyCode = 3 + await expect(materializeMacDaemonBundle(copies, signal())).rejects.toThrow( + "does not satisfy the app's signature" + ) + expect(await readdir(copies)).toEqual([]) +}) + +it('keeps the winning copy when a concurrent publisher renames first', async () => { + onCopy = async () => { + onCopy = null + const winner = join(copies, `terminal-host-${cdhash}`, 'app.noindex', 'Orca Terminal Host.app') + await cp(helper, winner, { recursive: true }) + await writeFile(join(winner, 'Contents', 'MacOS', 'orca-terminal-host'), 'winner') + } + const copy = await materializeMacDaemonBundle(copies, signal()) + expect(await readFile(copy.execPath, 'utf8')).toBe('winner') + expect(await readdir(copies)).toEqual([`terminal-host-${cdhash}`]) +}) + +it('replaces a partial clone with a regular copy', async () => { + cloneLeavesPartialTree = true + const copy = await materializeMacDaemonBundle(copies, signal()) + expect(await readdir(join(copy.directory, 'app.noindex', 'Orca Terminal Host.app'))).toEqual([ + 'Contents' + ]) + expect(await readFile(copy.entryPath, 'utf8')).toBe('daemon-code') +}) + +it('stops at the shared deadline and removes the staging copy', async () => { + const controller = new AbortController() + const signed = runProcessMock.getMockImplementation() + runProcessMock.mockImplementation(async (spec) => { + if (spec.program === '/bin/cp') { + controller.abort() + } + return controller.signal.aborted ? { ...ok, code: null } : (signed?.(spec) ?? ok) + }) + await expect(materializeMacDaemonBundle(copies, controller.signal)).rejects.toThrow( + 'Could not copy the macOS terminal host' + ) + expect(await readdir(copies)).toEqual([]) +}) + +it('copies the app behind a symbolic link rather than retaining a link to the updater path', async () => { + const alias = join(root, 'Linked.app') + await symlink(source, alias, 'junction') + inspectMock.mockResolvedValue({ + identity: 'resolved', + executablePath: join(alias, 'Contents', 'MacOS', 'Orca') + }) + const copy = await materializeMacDaemonBundle(copies, signal()) + await rm(alias) + await rm(source, { recursive: true }) + expect(await readFile(copy.execPath, 'utf8')).toBe('signed-node') +}) + +it('rejects an unresolvable running image before running anything', async () => { + inspectMock.mockResolvedValue({ identity: 'unresolvable', executablePath: null }) + await expect(materializeMacDaemonBundle(copies, signal())).rejects.toThrow( + 'running macOS app bundle' + ) + expect(runProcessMock).not.toHaveBeenCalled() +}) + +it('rejects an app without a signed helper and makes no copy', async () => { + await rm(helper, { recursive: true }) + helper = join(root, 'elsewhere') + await expect(materializeMacDaemonBundle(copies, signal())).rejects.toThrow('CDHash') + await expect(readdir(copies)).rejects.toThrow() +}) diff --git a/src/main/daemon/macos-daemon-bundle.ts b/src/main/daemon/macos-daemon-bundle.ts new file mode 100644 index 00000000000..6823e86889d --- /dev/null +++ b/src/main/daemon/macos-daemon-bundle.ts @@ -0,0 +1,145 @@ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, realpath, rename } from 'node:fs/promises' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { runProcess } from '@orca/process-host' +import { rm } from '../asar-transparent-fs' +import { ensurePrivateDir } from './daemon-private-file-modes' +import { inspectMacProcessCodeIdentity } from './daemon-mac-code-identity' + +/** Packaged by config/scripts/macos-terminal-host-bundle.cjs; its main executable is plain Node. */ +export const MAC_TERMINAL_HOST_BUNDLE = 'Orca Terminal Host.app' +export const MAC_TERMINAL_HOST_EXECUTABLE = 'orca-terminal-host' +/** One published copy per helper build: `terminal-host-`. */ +export const MAC_DAEMON_COPY_PREFIX = 'terminal-host-' + +export type MacDaemonBundle = { + directory: string + execPath: string + entryPath: string +} + +/** Private signed copies the updater never touches. */ +export function getMacDaemonBundleRoot(userDataPath: string): string { + return join(userDataPath, 'daemon-host', 'macos') +} + +/** Spotlight skips `.noindex` folders, so a copy is never indexed or listed as an app. */ +export function macDaemonCopyBundlePath(directory: string): string { + return join(directory, 'app.noindex', MAC_TERMINAL_HOST_BUNDLE) +} + +function describeCopy(directory: string): MacDaemonBundle { + const contents = join(macDaemonCopyBundlePath(directory), 'Contents') + return { + directory, + execPath: join(contents, 'MacOS', MAC_TERMINAL_HOST_EXECUTABLE), + entryPath: join(contents, 'Resources', 'daemon', 'out', 'main', 'daemon-entry.js') + } +} + +function appBundleForMainExecutable(executable: string): string { + const bundle = resolve(dirname(executable), '..', '..') + if ( + !isAbsolute(executable) || + !bundle.endsWith('.app') || + dirname(executable) !== join(bundle, 'Contents', 'MacOS') + ) { + throw new Error('The running macOS process is not an app bundle executable') + } + return bundle +} + +/** One `codesign` display line, e.g. `CDHash=` or `designated => `; codesign prints to stderr. */ +async function readCodesignField( + args: string[], + prefix: string, + signal: AbortSignal +): Promise { + const result = await runProcess({ + program: '/usr/bin/codesign', + args, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + signal + }) + const line = `${result.stderr}\n${result.stdout}` + .split(/\r?\n/) + .find((candidate) => candidate.startsWith(prefix)) + if (result.code !== 0 || result.timedOut || result.outputTruncated || !line) { + throw new Error(`Could not read ${prefix.trim()} from codesign`) + } + return line.slice(prefix.length) +} + +async function copyBundle(source: string, destination: string, signal: AbortSignal): Promise { + // APFS clone first; a failed clone can leave a partial tree on other filesystems. + for (const args of [['-cR'], ['-R']]) { + await rm(destination, { recursive: true, force: true }) + const result = await runProcess({ + program: '/bin/cp', + args: [...args, source, destination], + maxOutputBytes: 8192, + signal + }) + if (result.code === 0 && !result.timedOut) { + return + } + } + throw new Error('Could not copy the macOS terminal host') +} + +/** The copy of the running app's helper for its build, published once and reused after. */ +export async function materializeMacDaemonBundle( + root: string, + signal: AbortSignal +): Promise { + // Not process.execPath: after an update that path names the new bundle, not the running one. + const running = await inspectMacProcessCodeIdentity(process.pid) + if (!running.executablePath) { + throw new Error('Could not resolve the running macOS app bundle') + } + const appBundle = await realpath(appBundleForMainExecutable(running.executablePath)) + const source = join(appBundle, 'Contents', 'Helpers', MAC_TERMINAL_HOST_BUNDLE) + const cdhash = await readCodesignField(['-dvvv', source], 'CDHash=', signal) + if (!/^[0-9a-f]{40,64}$/.test(cdhash)) { + throw new Error('The macOS terminal host has an unexpected CDHash') + } + const name = `${MAC_DAEMON_COPY_PREFIX}${cdhash}` + const copy = describeCopy(join(root, name)) + // Only a verified copy is ever renamed into place, so presence alone proves it. + if (existsSync(copy.directory)) { + return copy + } + ensurePrivateDir(root) + // The pid keeps this process's collection off its own in-flight staging copy. + const staging = await mkdtemp(join(root, `${name}.staging-${process.pid}-`)) + try { + const stagedBundle = macDaemonCopyBundlePath(staging) + await mkdir(dirname(stagedBundle)) + await copyBundle(source, stagedBundle, signal) + // The copy must satisfy the running app's requirement to inherit Orca's privacy grants. + const requirement = await readCodesignField( + ['--display', '-r-', appBundle], + 'designated => ', + signal + ) + const verified = await runProcess({ + program: '/usr/bin/codesign', + args: ['--verify', '--strict', '-R', `=${requirement}`, stagedBundle], + maxOutputBytes: 8192, + signal + }) + if (verified.code !== 0 || verified.timedOut) { + throw new Error("The macOS terminal host copy does not satisfy the app's signature") + } + await rename(staging, copy.directory).catch((error: unknown) => { + // Losing to a concurrent publisher is fine: its copy passed the same verification. + if (!existsSync(copy.directory)) { + throw error + } + }) + return copy + } finally { + await rm(staging, { recursive: true, force: true }).catch(() => {}) + } +} diff --git a/src/main/daemon/macos-daemon-job-state.test.ts b/src/main/daemon/macos-daemon-job-state.test.ts new file mode 100644 index 00000000000..af9da9e3c74 --- /dev/null +++ b/src/main/daemon/macos-daemon-job-state.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' + +const { run, clock } = vi.hoisted(() => ({ + run: vi.fn<(spec: ProcessSpec) => Promise>(), + clock: { now: 0 } +})) +vi.mock('@orca/process-host', () => ({ runProcess: run })) +// Each poll wait advances a virtual clock, so the removal deadline runs without real time. +vi.mock('node:timers/promises', () => ({ + setTimeout: async (ms: number) => { + clock.now += ms + } +})) +import { + readMacDaemonJobState, + releaseMacDaemonJob, + stopMacDaemonJob +} from './macos-daemon-job-state' + +const result: ProcessResult = { code: 0, signal: null, stdout: '', stderr: '', timedOut: false } +beforeEach(() => { + run.mockReset() + clock.now = 0 + vi.spyOn(Date, 'now').mockImplementation(() => clock.now) +}) + +it.each([ + ['\tstate = running\n\t\tstate = active', 'running'], + ['\tstate = not running\n\t\tstate = active', 'stopped'], + ['\t\tstate = active', 'unverifiable'] +])('reads only the top-level job state from %s', async (stdout, expected) => { + run.mockResolvedValue({ ...result, stdout }) + expect(await readMacDaemonJobState('gui/501/owned')).toBe(expected) +}) + +it.each([ + { code: 113, stderr: 'Could not find service "owned"', expected: 'missing' }, + { code: 113, stderr: 'Could not find service; Permission denied', expected: 'unverifiable' }, + { code: 1, stderr: 'Could not find service "owned"', expected: 'unverifiable' }, + { code: 0, stdout: '\tstate = running', timedOut: true, expected: 'unverifiable' }, + { code: 0, stdout: '\tstate = running', outputTruncated: true, expected: 'unverifiable' } +])('preserves uncertainty for $expected', async ({ expected, ...override }) => { + run.mockResolvedValue({ ...result, ...override }) + expect(await readMacDaemonJobState('gui/501/owned')).toBe(expected) +}) + +const missing = { ...result, code: 113, stderr: 'Could not find service "owned"' } +const lingering = { ...result, stdout: '\tstate = running' } + +it('returns only once launchd no longer lists a job it was asked to remove', async () => { + run + .mockResolvedValueOnce(result) + .mockResolvedValueOnce(lingering) + .mockResolvedValueOnce(lingering) + .mockResolvedValueOnce(missing) + await expect(stopMacDaemonJob('gui/501/owned')).resolves.toBeUndefined() + expect(run.mock.calls.map(([spec]) => spec.args?.[0])).toEqual([ + 'bootout', + 'print', + 'print', + 'print' + ]) +}) + +it('accepts an already removed job but refuses one launchd keeps listing', async () => { + run.mockResolvedValueOnce({ ...result, code: 3 }).mockResolvedValueOnce(missing) + await expect(stopMacDaemonJob('gui/501/owned')).resolves.toBeUndefined() + run.mockResolvedValue(lingering) + await expect(stopMacDaemonJob('gui/501/owned')).rejects.toThrow('Could not stop') +}) + +it.each([ + ['missing', [missing], true], + [ + 'stopped and booted out', + [{ ...result, stdout: '\tstate = not running' }, result, missing], + true + ], + [ + 'stopped but not booted out', + [ + { ...result, stdout: '\tstate = not running' }, + { ...result, code: 1 }, + { ...result, code: 1 } + ], + false + ], + ['running', [{ ...result, stdout: '\tstate = running' }], false], + ['unverifiable', [{ ...result, code: 1, stderr: 'Operation not permitted' }], false] +] as const)( + 'releases the label only when no process can start under it: %s', + async (_, replies, released) => { + for (const reply of replies) { + run.mockResolvedValueOnce(reply) + } + expect(await releaseMacDaemonJob('gui/501/owned')).toBe(released) + } +) diff --git a/src/main/daemon/macos-daemon-job-state.ts b/src/main/daemon/macos-daemon-job-state.ts new file mode 100644 index 00000000000..00fd0f726b0 --- /dev/null +++ b/src/main/daemon/macos-daemon-job-state.ts @@ -0,0 +1,66 @@ +import { setTimeout as delay } from 'node:timers/promises' +import { runProcess } from '@orca/process-host' + +const JOB_REMOVAL_TIMEOUT_MS = 3_000 +const JOB_REMOVAL_POLL_MS = 50 + +export type MacDaemonJobState = 'running' | 'stopped' | 'missing' | 'unverifiable' + +export async function readMacDaemonJobState(service: string): Promise { + try { + const result = await runProcess({ + program: '/bin/launchctl', + args: ['print', service], + timeoutMs: 3_000, + maxOutputBytes: 128 * 1024 + }) + if (result.timedOut || result.outputTruncated) { + return 'unverifiable' + } + if ( + result.code === 113 && + result.stderr.includes('Could not find service') && + !/Operation not permitted|Permission denied/i.test(result.stderr) + ) { + return 'missing' + } + if (result.code !== 0) { + return 'unverifiable' + } + // Only the job's top-level state; nested resource-pressure states say nothing about its process. + const state = /^\tstate = (.+)$/m.exec(result.stdout)?.[1] + return state === 'running' ? 'running' : state === 'not running' ? 'stopped' : 'unverifiable' + } catch { + return 'unverifiable' + } +} + +export async function stopMacDaemonJob(service: string): Promise { + await runProcess({ + program: '/bin/launchctl', + args: ['bootout', service], + timeoutMs: 10_000, + maxOutputBytes: 8192 + }) + // bootout can return while launchd still holds a job that was running, and a bootstrap + // under the same label in that window is refused. + const goneByMs = Date.now() + JOB_REMOVAL_TIMEOUT_MS + while ((await readMacDaemonJobState(service)) !== 'missing') { + if (Date.now() >= goneByMs) { + throw new Error('Could not stop the macOS terminal service') + } + await delay(JOB_REMOVAL_POLL_MS) + } +} + +/** True once no process can start under `service`: missing, or stopped and booted out. */ +export async function releaseMacDaemonJob(service: string): Promise { + const state = await readMacDaemonJobState(service) + if (state !== 'stopped') { + return state === 'missing' + } + return stopMacDaemonJob(service).then( + () => true, + () => false + ) +} diff --git a/src/main/daemon/macos-daemon-launchd.test.ts b/src/main/daemon/macos-daemon-launchd.test.ts new file mode 100644 index 00000000000..bcef4116b5c --- /dev/null +++ b/src/main/daemon/macos-daemon-launchd.test.ts @@ -0,0 +1,400 @@ +import { access, mkdir, mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' + +const { state, runProcessMock, materializeMock, ensureWithinMock, disconnectMock } = vi.hoisted( + () => { + const state: { root: string; packaged: boolean; identity: DaemonEndpointIdentity | null } = { + root: '', + packaged: true, + identity: null + } + return { + state, + runProcessMock: vi.fn<(spec: ProcessSpec) => Promise>(), + materializeMock: vi.fn(), + ensureWithinMock: vi.fn(), + disconnectMock: vi.fn() + } + } +) +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) +vi.mock('../../shared/app-environment', () => ({ + hasAppEnvironment: () => true, + getAppEnvironment: () => ({ + getAppPath: () => '/Applications/Orca.app/Contents/Resources/app.asar', + getPath: () => state.root, + getVersion: () => '1.2.3', + isPackaged: () => state.packaged + }) +})) +vi.mock('./macos-daemon-bundle', async (importOriginal) => ({ + ...(await importOriginal>()), + materializeMacDaemonBundle: materializeMock +})) +vi.mock('./client', () => ({ + DaemonClient: class { + ensureConnectedWithin = ensureWithinMock + ensureConnected = vi.fn(async () => {}) + disconnect = disconnectMock + getDaemonIdentity(): DaemonEndpointIdentity | null { + return state.identity + } + } +})) + +import { launchMacDaemonFromStableBundle, macDaemonJobLabel } from './macos-daemon-launchd' +import type { DaemonChildSpawnOptions } from './daemon-launched-child-spawn' + +const ok: ProcessResult = { code: 0, signal: null, stdout: '', stderr: '', timedOut: false } +const stopped = { ...ok, stdout: 'gui/501/x = {\n\tstate = not running\n}\n' } +const running = { ...ok, stdout: 'gui/501/x = {\n\tstate = running\n}\n' } +const missing = { + ...ok, + code: 113, + stderr: 'Could not find service "x" in domain for user gui: 501' +} +// `lsof +D` over an unused copy prints nothing and exits 1. +const lsofUnused = { ...ok, code: 1 } + +type Launchctl = { bootstrap: ProcessResult; bootout: ProcessResult; print: ProcessResult[] } +let launchctl: Launchctl +let job: unknown +let jobMode = 0 +let options: DaemonChildSpawnOptions +let copyDir: string +const nativePlatform = process.platform +const originalGetuid = Object.getOwnPropertyDescriptor(process, 'getuid') +const originalGetSystemVersion = Object.getOwnPropertyDescriptor(process, 'getSystemVersion') +const roomyDeadline = (): number => Date.now() + 60_000 +const helperExec = (): string => join(copyDir, 'MacOS', 'orca-terminal-host') +const helperEntry = (): string => join(copyDir, 'daemon-entry.js') +const service = (): string => `gui/501/${macDaemonJobLabel(state.root)}` +const launchctlCalls = (verb: string): ProcessSpec[] => + runProcessMock.mock.calls + .map(([spec]) => spec) + .filter((spec) => spec.program === '/bin/launchctl' && spec.args?.[0] === verb) +const exists = (path: string): Promise => + access(path).then( + () => true, + () => false + ) + +function stubSystemVersion(version: string): void { + Object.defineProperty(process, 'getSystemVersion', { configurable: true, value: () => version }) +} + +/** Lets a readiness timeout elapse without waiting for it in real time. */ +function elapseOnEachConnectAttempt(stepMs: number): () => number { + const realNow = Date.now() + let elapsedMs = 0 + vi.spyOn(Date, 'now').mockImplementation(() => realNow + elapsedMs) + ensureWithinMock.mockImplementation(async () => { + elapsedMs += stepMs + throw new Error('ECONNREFUSED') + }) + return () => elapsedMs +} + +async function launchFailure(deadlineMs = roomyDeadline()): Promise { + return launchMacDaemonFromStableBundle(options, deadlineMs).then( + (handle) => handle, + (error: unknown) => error + ) +} + +beforeEach(async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(process, 'getuid', { configurable: true, value: () => 501 }) + stubSystemVersion('15.4.1') + state.root = await mkdtemp(join(tmpdir(), 'orca-mac-launch-job-')) + state.packaged = true + state.identity = { pid: 12345, startedAtMs: 1000, launchNonce: 'owned-launch' } + copyDir = join(state.root, 'daemon-host', 'macos', 'terminal-host-abc') + await mkdir(copyDir, { recursive: true }) + options = { + entryPath: '/Applications/Orca.app/Contents/Resources/daemon-entry.js', + forkEntryPath: '/Applications/Orca.app/Contents/Resources/daemon-entry.js', + userDataPath: state.root, + socketPath: join(state.root, 'daemon.sock'), + tokenPath: join(state.root, 'daemon.token'), + pidPath: join(state.root, 'daemon.pid'), + launchNonce: 'owned-launch', + macosLoginSessionWatch: true + } + materializeMock + .mockReset() + .mockResolvedValue({ directory: copyDir, execPath: helperExec(), entryPath: helperEntry() }) + ensureWithinMock.mockReset().mockResolvedValue(undefined) + disconnectMock.mockReset() + launchctl = { bootstrap: ok, bootout: ok, print: [missing] } + job = undefined + runProcessMock.mockReset().mockImplementation(async (spec) => { + const path = spec.args?.at(-1) ?? '' + if (spec.program === '/usr/bin/plutil') { + job = JSON.parse(await readFile(path, 'utf8')) + jobMode = (await stat(path)).mode & 0o777 + return ok + } + if (spec.program === '/usr/sbin/lsof') { + return lsofUnused + } + if (spec.program === '/bin/launchctl') { + const verb = spec.args?.[0] + if (verb === 'print') { + // The last configured state repeats once the earlier ones are used up. + return ( + (launchctl.print.length > 1 ? launchctl.print.shift() : launchctl.print[0]) ?? missing + ) + } + if (verb === 'bootout' && launchctl.bootout.code === 0) { + // A successful bootout leaves nothing for later prints to find. + launchctl.print = [missing] + } + return verb === 'bootstrap' ? launchctl.bootstrap : launchctl.bootout + } + return ok + }) +}) + +afterEach(async () => { + vi.restoreAllMocks() + for (const [key, descriptor] of [ + ['getuid', originalGetuid], + ['getSystemVersion', originalGetSystemVersion] + ] as const) { + if (descriptor) { + Object.defineProperty(process, key, descriptor) + } else { + Reflect.deleteProperty(process, key) + } + } + vi.unstubAllEnvs() + await rm(state.root, { recursive: true, force: true }) +}) + +it('launches the copied helper on plain Node and leaves no inherited credentials on disk', async () => { + vi.stubEnv('ORCA_TEST_SECRET', 'test-value') + vi.stubEnv('NODE_CHANNEL_FD', '3') + vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') + const handle = await launchMacDaemonFromStableBundle(options, roomyDeadline()) + if (nativePlatform !== 'win32') { + expect(jobMode).toBe(0o600) + } + expect(job).toMatchObject({ + Label: macDaemonJobLabel(state.root), + KeepAlive: false, + // Local Network access for a plist-launched job resolves through its associated app. + AssociatedBundleIdentifiers: ['com.stablyai.orca'], + EnvironmentVariables: { ORCA_TEST_SECRET: 'test-value' } + }) + expect(JSON.stringify(job)).not.toContain('NODE_CHANNEL_FD') + expect(job).not.toHaveProperty('EnvironmentVariables.ELECTRON_RUN_AS_NODE') + const args = + job && + typeof job === 'object' && + 'ProgramArguments' in job && + Array.isArray(job.ProgramArguments) + ? job.ProgramArguments.map(String) + : [] + expect(args.slice(0, 4)).toEqual([helperExec(), helperEntry(), '--socket', options.socketPath]) + // The replacement preflight compares the installed entry, never the private copy's. + expect(args[args.indexOf('--entry-path') + 1]).toBe(options.entryPath) + expect(args[args.indexOf('--spawner-exec-path') + 1]).toBe(helperExec()) + // The job file is gone and the published copy holds nothing else. + expect(await readdir(copyDir)).toEqual([]) + expect(handle?.releaseAdoptionLease).toBeTypeOf('function') + await handle?.shutdown() + expect(launchctlCalls('bootout').map((spec) => spec.args?.[1])).toEqual([service()]) + expect(await exists(copyDir)).toBe(true) +}) + +it('uses one launchd label per profile and protocol', () => { + expect(macDaemonJobLabel('/Users/a/Library/Application Support/orca')).toBe( + macDaemonJobLabel('/Users/a/Library/Application Support/orca/') + ) + expect(macDaemonJobLabel('/Users/a/Library/Application Support/orca')).not.toBe( + macDaemonJobLabel('/Users/a/Library/Application Support/orca-dev') + ) + expect(macDaemonJobLabel('/p')).toMatch(/^com\.stablyai\.orca\.terminal\.[0-9a-f]{16}\.v45$/) +}) + +it.each(['13.4.1', '12.7.6'])( + 'keeps macOS %s below the helper floor on the fork', + async (version) => { + stubSystemVersion(version) + await expect(launchMacDaemonFromStableBundle(options, roomyDeadline())).resolves.toBeNull() + expect(materializeMock).not.toHaveBeenCalled() + expect(runProcessMock).not.toHaveBeenCalled() + } +) + +it('runs the helper from the floor itself', async () => { + stubSystemVersion('13.5') + await expect(launchMacDaemonFromStableBundle(options, roomyDeadline())).resolves.not.toBeNull() +}) + +it.each(['linux', 'win32'] as const)('keeps %s on the existing launcher', async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + await expect(launchMacDaemonFromStableBundle(options, roomyDeadline())).resolves.toBeNull() + expect(materializeMock).not.toHaveBeenCalled() +}) + +it('keeps Node/SSH hosts and unpackaged Electron on the existing launcher', async () => { + await expect( + launchMacDaemonFromStableBundle({ ...options, macosLoginSessionWatch: false }, roomyDeadline()) + ).resolves.toBeNull() + state.packaged = false + await expect(launchMacDaemonFromStableBundle(options, roomyDeadline())).resolves.toBeNull() + expect(materializeMock).not.toHaveBeenCalled() +}) + +it("boots out an earlier daemon's stopped job before starting the new one", async () => { + launchctl.print = [stopped, missing] + await expect(launchMacDaemonFromStableBundle(options, roomyDeadline())).resolves.not.toBeNull() + const verbs = runProcessMock.mock.calls + .map(([spec]) => spec) + .filter((spec) => spec.program === '/bin/launchctl' && spec.args?.[0] !== 'print') + .map((spec) => spec.args?.[0]) + expect(verbs).toEqual(['bootout', 'bootstrap']) +}) + +it.each([ + ['running', running], + ['unverifiable', { ...ok, code: 1, stderr: 'Operation not permitted' }] +])('never starts beside an earlier job that reads %s', async (_state, print) => { + launchctl.print = [print] + const error = await launchFailure() + expect(error).toBeInstanceOf(Error) + expect(launchctlCalls('bootstrap')).toHaveLength(0) + expect(launchctlCalls('bootout')).toHaveLength(0) +}) + +it('stops only its own job and hands the endpoint to the fork launcher when another daemon owns it', async () => { + state.identity = { pid: 55555, startedAtMs: 2000, launchNonce: 'another-launch' } + expect(await launchFailure()).toBeNull() + await expect(access(options.pidPath)).rejects.toThrow() + expect(disconnectMock).toHaveBeenCalled() + expect(launchctlCalls('bootout').map((spec) => spec.args?.[1])).toEqual([service()]) + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('reason=endpoint-taken'), + expect.anything() + ) + expect(await exists(copyDir)).toBe(true) +}) + +it('stays fatal and keeps the copy after an uncertain bootstrap, removing the job file', async () => { + launchctl.bootstrap = { ...ok, code: null, signal: 'SIGTERM', timedOut: true } + const error = await launchFailure() + expect(error).toHaveProperty('message', 'Could not start the macOS terminal service') + expect(await readdir(copyDir)).toEqual([]) + expect(await exists(copyDir)).toBe(true) + expect(ensureWithinMock).not.toHaveBeenCalled() +}) + +it('forks and drops an unused copy when launchd refused the job and it is absent', async () => { + launchctl.bootstrap = { ...ok, code: 5, stderr: 'Bootstrap failed: 5' } + expect(await launchFailure()).toBeNull() + expect(ensureWithinMock).not.toHaveBeenCalled() + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('reason=bootstrap-refused'), + expect.anything() + ) + await vi.waitFor(async () => expect(await exists(copyDir)).toBe(false)) +}) + +it.each([ + ['running', running], + ['unverifiable', { ...ok, code: 1, stderr: 'Operation not permitted' }] +])('keeps a refused bootstrap fatal when the job reads %s', async (_state, print) => { + launchctl.bootstrap = { ...ok, code: 5 } + launchctl.print = [missing, print] + const error = await launchFailure() + expect(error).toHaveProperty('message', 'launchd refused the terminal service') + expect(await exists(copyDir)).toBe(true) +}) + +it('hands off as soon as the job exits, and drops the copy so the next launch re-copies it', async () => { + launchctl.print = [missing, stopped] + const elapsed = elapseOnEachConnectAttempt(200) + expect(await launchFailure()).toBeNull() + expect(elapsed()).toBeLessThan(1_000) + expect(launchctlCalls('bootout').map((spec) => spec.args?.[1])).toEqual([service()]) + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('reason=exited-before-ready'), + expect.anything() + ) + await vi.waitFor(async () => expect(await exists(copyDir)).toBe(false)) +}) + +it('waits out a silent job that is still running and never forks beside it', async () => { + launchctl.print = [missing, running] + const elapsed = elapseOnEachConnectAttempt(2_000) + const error = await launchFailure() + expect(error).toHaveProperty('message', 'ECONNREFUSED') + expect(elapsed()).toBeGreaterThanOrEqual(10_000) + expect(launchctlCalls('bootout')).toHaveLength(0) + expect(await exists(copyDir)).toBe(true) +}) + +it('retries a connection refusal within the same job', async () => { + ensureWithinMock.mockRejectedValueOnce(new Error('ECONNREFUSED')) + launchctl.print = [missing, running] + await expect(launchMacDaemonFromStableBundle(options, roomyDeadline())).resolves.not.toBeNull() + expect(launchctlCalls('bootstrap')).toHaveLength(1) +}) + +it('forks without submitting a job whose own readiness wait would overrun the deadline', async () => { + materializeMock.mockImplementation(async (_root: string, signal: AbortSignal) => { + signal.throwIfAborted() + return { directory: copyDir, execPath: helperExec(), entryPath: helperEntry() } + }) + expect(await launchFailure(Date.now() + 19_000)).toBeNull() + expect(launchctlCalls('bootstrap')).toHaveLength(0) + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('reason=deadline'), + expect.anything() + ) +}) + +it('reports a failure too late for the fork to fit the startup gate instead of forking', async () => { + materializeMock.mockImplementation(async () => { + vi.spyOn(Date, 'now').mockReturnValue(deadline + 1) + throw new Error('Could not copy the macOS terminal host') + }) + const deadline = Date.now() + 25_000 + const error = await launchFailure(deadline) + expect(error).toHaveProperty('message', 'Could not copy the macOS terminal host') +}) + +it('shares one preparation deadline between the copy and the job file', async () => { + await launchMacDaemonFromStableBundle(options, Date.now() + 25_000) + const signal: unknown = materializeMock.mock.calls[0]?.[1] + expect(signal).toHaveProperty('aborted', false) + const plutil = runProcessMock.mock.calls.find(([spec]) => spec.program === '/usr/bin/plutil') + expect(plutil?.[0].signal).toBe(signal) +}) + +it('forks without a job when the job file cannot be prepared', async () => { + const route = runProcessMock.getMockImplementation() + runProcessMock.mockImplementation(async (spec) => + spec.program === '/usr/bin/plutil' ? { ...ok, code: 1 } : (route?.(spec) ?? ok) + ) + expect(await launchFailure()).toBeNull() + expect(launchctlCalls('bootstrap')).toHaveLength(0) + expect(await readdir(copyDir)).toEqual([]) + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('reason=prepare-failed'), + expect.anything() + ) +}) + +it('forks when the helper copy cannot be prepared', async () => { + materializeMock.mockRejectedValueOnce(new Error('Could not copy the macOS terminal host')) + expect(await launchFailure()).toBeNull() + expect(runProcessMock).not.toHaveBeenCalled() +}) diff --git a/src/main/daemon/macos-daemon-launchd.ts b/src/main/daemon/macos-daemon-launchd.ts new file mode 100644 index 00000000000..e878fd41838 --- /dev/null +++ b/src/main/daemon/macos-daemon-launchd.ts @@ -0,0 +1,214 @@ +import { createHash } from 'node:crypto' +import { writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { getAppEnvironment } from '../../shared/app-environment' +import { LOCAL_BUILD_COMPATIBILITY_CONTRACT } from '../../shared/local-build-compatibility-contract' +import { NODE_RUNTIME_DARWIN_MINIMUM_OS } from '../../shared/node-runtime-pin' +import { runProcess } from '@orca/process-host' +import { rm } from '../asar-transparent-fs' +import { compareDottedVersions } from '../orcad/native-host-abi' +import { DaemonClient } from './client' +import { DaemonEndpointOwnershipError, holdDaemonAdoptionLease } from './daemon-endpoint-adoption' +import { buildDaemonScriptArgs, type DaemonChildSpawnOptions } from './daemon-launched-child-spawn' +import { PROTOCOL_VERSION } from './daemon-protocol-version' +import type { DaemonProcessHandle } from './daemon-spawner' +import { + getMacDaemonBundleRoot, + materializeMacDaemonBundle, + type MacDaemonBundle +} from './macos-daemon-bundle' +import { + retireOtherMacDaemonCopies, + retireUnusedMacDaemonCopy +} from './macos-daemon-bundle-retirement' +import { + releaseMacDaemonJob, + readMacDaemonJobState, + stopMacDaemonJob +} from './macos-daemon-job-state' + +type MacDaemonLaunchOptions = Omit + +/** Why a launch fell back to forking the daemon from the app. */ +export type MacStableLaunchFallbackReason = + | 'prepare-failed' + | 'deadline' + | 'bootstrap-refused' + | 'exited-before-ready' + | 'endpoint-taken' + +const BOOTSTRAP_TIMEOUT_MS = 10_000 +const STARTUP_TIMEOUT_MS = 10_000 +const JOB_CHECK_INTERVAL_MS = 250 + +/** One job per profile and protocol, matching the one daemon each of them may run. */ +export function macDaemonJobLabel(userDataPath: string): string { + const profileKey = createHash('sha256').update(resolve(userDataPath)).digest('hex').slice(0, 16) + return `com.stablyai.orca.terminal.${profileKey}.v${PROTOCOL_VERSION}` +} + +function buildMacDaemonLaunchJob( + options: MacDaemonLaunchOptions, + bundle: MacDaemonBundle, + label: string +): Record { + const env: NodeJS.ProcessEnv = { ...process.env, ORCA_USER_DATA_PATH: options.userDataPath } + // The helper's executable is plain Node; Electron's Node-mode switch and IPC fds mean nothing there. + delete env.ELECTRON_RUN_AS_NODE + delete env.NODE_CHANNEL_FD + delete env.NODE_CHANNEL_SERIALIZATION_MODE + delete env.NODE_UNIQUE_ID + return { + Label: label, + ProgramArguments: [ + bundle.execPath, + bundle.entryPath, + ...buildDaemonScriptArgs(options, bundle.execPath) + ], + EnvironmentVariables: env, + WorkingDirectory: options.userDataPath, + RunAtLoad: true, + KeepAlive: false, + AbandonProcessGroup: true, + // A plist job outside SMAppService names its app here so Local Network access resolves to Orca. + AssociatedBundleIdentifiers: [LOCAL_BUILD_COMPATIBILITY_CONTRACT.appId], + ProcessType: 'Interactive', + StandardOutPath: '/dev/null', + StandardErrorPath: '/dev/null' + } +} + +/** Fork instead, unless the fork could no longer finish before the startup gate. */ +function fallBackToFork( + reason: MacStableLaunchFallbackReason, + error: unknown, + deadlineMs: number +): null { + if (Date.now() > deadlineMs) { + throw error + } + console.warn( + `[daemon] macOS terminal host fallback reason=${reason}; forking from the app`, + error + ) + return null +} + +/** + * Starts the daemon under launchd from the signed helper copy, so it keeps Orca's identity when an + * update replaces the app. Null means no job can claim the endpoint and the caller should fork. + */ +export async function launchMacDaemonFromStableBundle( + options: MacDaemonLaunchOptions, + deadlineMs: number +): Promise { + // `macosLoginSessionWatch` is set only for the macOS GUI desktop, never for serve mode. + if ( + process.platform !== 'darwin' || + !options.macosLoginSessionWatch || + !getAppEnvironment().isPackaged() + ) { + return null + } + // The helper's Node cannot load below its floor; those Macs keep the fork and make no copy. + const systemVersion = process.getSystemVersion?.() + if (!systemVersion || compareDottedVersions(systemVersion, NODE_RUNTIME_DARWIN_MINIMUM_OS) < 0) { + return null + } + const label = macDaemonJobLabel(options.userDataPath) + const domain = `gui/${process.getuid?.()}` + const service = `${domain}/${label}` + // Preparation ends early enough for bootstrap and this job's readiness wait to meet the deadline. + const remainingMs = deadlineMs - BOOTSTRAP_TIMEOUT_MS - STARTUP_TIMEOUT_MS - Date.now() + const prepareSignal = remainingMs > 0 ? AbortSignal.timeout(remainingMs) : AbortSignal.abort() + const root = getMacDaemonBundleRoot(options.userDataPath) + let bundle: MacDaemonBundle + try { + bundle = await materializeMacDaemonBundle(root, prepareSignal) + } catch (error) { + return fallBackToFork(prepareSignal.aborted ? 'deadline' : 'prepare-failed', error, deadlineMs) + } + void retireOtherMacDaemonCopies(root, bundle.directory) + // Forks only once the job provably cannot start; the copy itself may be why it did not. + const abandon = async (reason: MacStableLaunchFallbackReason, error: unknown): Promise => { + if (!(await releaseMacDaemonJob(service))) { + throw error + } + void retireUnusedMacDaemonCopy(bundle.directory) + return fallBackToFork(reason, error, deadlineMs) + } + + // A stopped job from an earlier daemon holds the label; a running one may own the endpoint. + if (!(await releaseMacDaemonJob(service))) { + throw new Error('A macOS terminal service from an earlier launch is still registered') + } + // The inherited environment can contain credentials; never leave it on disk after bootstrap. + const jobPath = join(bundle.directory, `launch-${options.launchNonce}.plist`) + const removeJobFile = (): Promise => rm(jobPath, { force: true }).catch(() => {}) + try { + await writeFile(jobPath, JSON.stringify(buildMacDaemonLaunchJob(options, bundle, label)), { + mode: 0o600, + flag: 'wx' + }) + const converted = await runProcess({ + program: '/usr/bin/plutil', + args: ['-convert', 'xml1', jobPath], + timeoutMs: 5_000, + maxOutputBytes: 8192, + signal: prepareSignal + }) + if (converted.code !== 0 || converted.timedOut) { + throw new Error('Could not write the macOS terminal service job') + } + } catch (error) { + await removeJobFile() + return fallBackToFork(prepareSignal.aborted ? 'deadline' : 'prepare-failed', error, deadlineMs) + } + // Never aborted: a killed launchctl leaves the job's fate unknown. + const bootstrap = await runProcess({ + program: '/bin/launchctl', + args: ['bootstrap', domain, jobPath], + timeoutMs: BOOTSTRAP_TIMEOUT_MS, + maxOutputBytes: 8192 + }).finally(removeJobFile) + if (bootstrap.timedOut) { + throw new Error('Could not start the macOS terminal service') + } + if (bootstrap.code !== 0) { + return await abandon('bootstrap-refused', new Error('launchd refused the terminal service')) + } + + const client = new DaemonClient({ socketPath: options.socketPath, tokenPath: options.tokenPath }) + const readyByMs = Date.now() + STARTUP_TIMEOUT_MS + for (;;) { + try { + await client.ensureConnectedWithin(Math.max(1, readyByMs - Date.now())) + break + } catch (error) { + client.disconnect() + // A copy refused at exec never answers; hand off as soon as its job stops. + const state = await readMacDaemonJobState(service) + if (state === 'stopped' || state === 'missing' || Date.now() >= readyByMs) { + return await abandon('exited-before-ready', error) + } + await delay(JOB_CHECK_INTERVAL_MS) + } + } + if (client.getDaemonIdentity()?.launchNonce !== options.launchNonce) { + client.disconnect() + // The fork launcher adopts that occupant normally rather than as a degraded endpoint. + await stopMacDaemonJob(service) + const error = new DaemonEndpointOwnershipError('Another daemon owns the terminal endpoint') + return fallBackToFork('endpoint-taken', error, deadlineMs) + } + return await holdDaemonAdoptionLease( + { shutdown: () => stopMacDaemonJob(service) }, + { + socketPath: options.socketPath, + tokenPath: options.tokenPath, + pidPath: options.pidPath, + connectedClient: client + } + ) +} diff --git a/src/main/git/git-network-safety-real-git.test.ts b/src/main/git/git-network-safety-real-git.test.ts index db5dc805c60..8bea61f7bcf 100644 --- a/src/main/git/git-network-safety-real-git.test.ts +++ b/src/main/git/git-network-safety-real-git.test.ts @@ -72,7 +72,6 @@ async function expectIdentity(identity: string): Promise { } describe('network SSH configuration with real Git', () => { - it('preserves command-line SSH configuration ahead of repository configuration', async () => { await git(['config', 'core.sshCommand', sshCommand('repository')]) await expect( diff --git a/src/main/git/worktree-safety-real-git.test.ts b/src/main/git/worktree-safety-real-git.test.ts index d6a8e2ce4ea..9a3e2fdfffa 100644 --- a/src/main/git/worktree-safety-real-git.test.ts +++ b/src/main/git/worktree-safety-real-git.test.ts @@ -88,7 +88,6 @@ afterEach(async () => { }) describe('worktree safety with the real Git binary', () => { - it.each([ ['feature', 'Feature'], ['Ä', 'ä'], @@ -117,5 +116,4 @@ describe('worktree safety with the real Git binary', () => { }, 90_000 ) - }) diff --git a/src/main/ipc/worktree-push-target-reconciliation.test.ts b/src/main/ipc/worktree-push-target-reconciliation.test.ts index d1f37561c15..ee1093b1511 100644 --- a/src/main/ipc/worktree-push-target-reconciliation.test.ts +++ b/src/main/ipc/worktree-push-target-reconciliation.test.ts @@ -232,7 +232,6 @@ describe('reconcileOrphanedPrRemotesWithExec', () => { ) expect(reclaimed).toEqual([]) }) - }) describe('reconcileOrphanedPrRemotes rate limiting', () => { diff --git a/src/main/runtime/orca-runtime-browser-screencast-e2ee-backlog.test.ts b/src/main/runtime/orca-runtime-browser-screencast-e2ee-backlog.test.ts index 246e9613c67..47eb2398929 100644 --- a/src/main/runtime/orca-runtime-browser-screencast-e2ee-backlog.test.ts +++ b/src/main/runtime/orca-runtime-browser-screencast-e2ee-backlog.test.ts @@ -39,7 +39,14 @@ function connectLegacyChannel() { serverSecretKey: serverKeys.secretKey, resolveAuthenticatedDevice: (token) => token === 'valid-token' - ? { deviceId: 'device-1', deviceToken: token, scope: 'mobile', clientId: token, callerFingerprint: 'test-fingerprint', credentialRevision: 0 } + ? { + deviceId: 'device-1', + deviceToken: token, + scope: 'mobile', + clientId: token, + callerFingerprint: 'test-fingerprint', + credentialRevision: 0 + } : null, onReady: vi.fn(), onError diff --git a/src/main/runtime/remote-browser-screencast-frame-admission.test.ts b/src/main/runtime/remote-browser-screencast-frame-admission.test.ts index 7be3df0af29..7e8056c4796 100644 --- a/src/main/runtime/remote-browser-screencast-frame-admission.test.ts +++ b/src/main/runtime/remote-browser-screencast-frame-admission.test.ts @@ -44,7 +44,14 @@ function createRuntimeBinarySender() { serverSecretKey: serverKeys.secretKey, resolveAuthenticatedDevice: (token) => token === 'valid-token' - ? { deviceId: 'device-1', deviceToken: token, scope: 'runtime', clientId: token, callerFingerprint: 'test-fingerprint', credentialRevision: 0 } + ? { + deviceId: 'device-1', + deviceToken: token, + scope: 'runtime', + clientId: token, + callerFingerprint: 'test-fingerprint', + credentialRevision: 0 + } : null, onReady: vi.fn(), onError: onTransportError diff --git a/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts b/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts index 77ca5e475b0..03a874e6570 100644 --- a/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts +++ b/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts @@ -43,7 +43,14 @@ function setup(overrides?: Partial) { serverSecretKey: serverKeys.secretKey, resolveAuthenticatedDevice: (token) => token === 'valid-token' - ? { deviceId: 'device-1', deviceToken: token, scope: 'mobile', clientId: token, callerFingerprint: 'test-fingerprint', credentialRevision: 0 } + ? { + deviceId: 'device-1', + deviceToken: token, + scope: 'mobile', + clientId: token, + callerFingerprint: 'test-fingerprint', + credentialRevision: 0 + } : null, onReady: vi.fn(), onError, diff --git a/src/main/runtime/rpc/e2ee-channel-v2.test.ts b/src/main/runtime/rpc/e2ee-channel-v2.test.ts index 694023174e5..6bfbe2eb7b9 100644 --- a/src/main/runtime/rpc/e2ee-channel-v2.test.ts +++ b/src/main/runtime/rpc/e2ee-channel-v2.test.ts @@ -55,7 +55,14 @@ function setup() { const onError = vi.fn() const resolveAuthenticatedDevice = vi.fn((token: string) => token === 'valid-token' - ? { deviceId: 'device-1', deviceToken: token, scope: 'mobile' as const, clientId: token, callerFingerprint: 'test-fingerprint', credentialRevision: 0 } + ? { + deviceId: 'device-1', + deviceToken: token, + scope: 'mobile' as const, + clientId: token, + callerFingerprint: 'test-fingerprint', + credentialRevision: 0 + } : null ) const channel = new E2EEChannel(ws as unknown as WebSocket, { diff --git a/src/main/runtime/rpc/e2ee-channel.test.ts b/src/main/runtime/rpc/e2ee-channel.test.ts index dd988f12c36..5e88a5bee2c 100644 --- a/src/main/runtime/rpc/e2ee-channel.test.ts +++ b/src/main/runtime/rpc/e2ee-channel.test.ts @@ -36,7 +36,14 @@ function setup(overrides?: Partial) { serverSecretKey: serverKeys.secretKey, resolveAuthenticatedDevice: (token) => token === 'valid-token' - ? { deviceId: 'device-1', deviceToken: token, scope: 'mobile', clientId: token, callerFingerprint: 'test-fingerprint', credentialRevision: 0 } + ? { + deviceId: 'device-1', + deviceToken: token, + scope: 'mobile', + clientId: token, + callerFingerprint: 'test-fingerprint', + credentialRevision: 0 + } : null, onReady, onError, @@ -110,7 +117,14 @@ describe('E2EEChannel', () => { const ctx = setup({ resolveAuthenticatedDevice: (token) => token === 'valid-token' - ? { deviceId: 'device-1', deviceToken: token, scope: 'runtime', clientId: token, callerFingerprint: 'test-fingerprint', credentialRevision: 0 } + ? { + deviceId: 'device-1', + deviceToken: token, + scope: 'runtime', + clientId: token, + callerFingerprint: 'test-fingerprint', + credentialRevision: 0 + } : null }) ctx.channel.handleRawMessage( diff --git a/src/main/runtime/rpc/e2ee-integration.test.ts b/src/main/runtime/rpc/e2ee-integration.test.ts index 91968229cd8..1171848e8e7 100644 --- a/src/main/runtime/rpc/e2ee-integration.test.ts +++ b/src/main/runtime/rpc/e2ee-integration.test.ts @@ -46,7 +46,14 @@ describe('E2EE integration (simulated mobile ↔ desktop)', () => { serverSecretKey: serverKeys.secretKey, resolveAuthenticatedDevice: (token) => token === 'device-abc' - ? { deviceId: 'device-abc', deviceToken: token, scope: 'mobile', clientId: token, callerFingerprint: 'test-fingerprint', credentialRevision: 0 } + ? { + deviceId: 'device-abc', + deviceToken: token, + scope: 'mobile', + clientId: token, + callerFingerprint: 'test-fingerprint', + credentialRevision: 0 + } : null, onReady, onError @@ -123,7 +130,14 @@ describe('E2EE integration (simulated mobile ↔ desktop)', () => { serverSecretKey: serverKeys.secretKey, resolveAuthenticatedDevice: (token) => token === 'device-abc' - ? { deviceId: 'device-abc', deviceToken: token, scope: 'mobile', clientId: token, callerFingerprint: 'test-fingerprint', credentialRevision: 0 } + ? { + deviceId: 'device-abc', + deviceToken: token, + scope: 'mobile', + clientId: token, + callerFingerprint: 'test-fingerprint', + credentialRevision: 0 + } : null, onReady, onError diff --git a/src/main/runtime/runtime-repository-ref-queries.test.ts b/src/main/runtime/runtime-repository-ref-queries.test.ts index 9b64b1c3c93..accf18dec15 100644 --- a/src/main/runtime/runtime-repository-ref-queries.test.ts +++ b/src/main/runtime/runtime-repository-ref-queries.test.ts @@ -16,7 +16,6 @@ const repo: Repo = { } describe('qualified refs in repository searches', () => { - it('keeps folder workspaces outside Git searches', async () => { vi.mocked(getSshGitProvider).mockClear() const queries = new RuntimeRepositoryRefQueries({ diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index ebcc65f67d2..1b097ab4f27 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -3,9 +3,9 @@ "appId": "com.stablyai.orca", "stateSchemaVersion": 1, "readableStateSchemaVersions": [1], - "daemonProtocolVersion": 44, + "daemonProtocolVersion": 45, "attachableDaemonProtocolVersions": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index 7d5b534dea6..7d71fa8030b 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = { appId: 'com.stablyai.orca', stateSchemaVersion: 1, readableStateSchemaVersions: [1], - daemonProtocolVersion: 44, + daemonProtocolVersion: 45, attachableDaemonProtocolVersions: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45 ] } as const diff --git a/src/shared/node-runtime-pin.ts b/src/shared/node-runtime-pin.ts index 3f24eb7fae4..cc334cd4fad 100644 --- a/src/shared/node-runtime-pin.ts +++ b/src/shared/node-runtime-pin.ts @@ -149,6 +149,12 @@ export const NODE_RUNTIME_COMPAT_ASSETS: Record known === target) }