fix(macos): preserve terminal permissions across app updates with a small signed helper (#27021)

* fix(macos): preserve terminal privacy identity across app updates

* test(macos): make launchd fixtures portable across host platforms

* fix(macos): probe protected contents and close handles on denial

* fix(i18n): register terminal permission status labels

* fix(macos): fork the daemon when no stable-bundle job can claim the endpoint

A copy, signature, plist or deadline failure before bootstrap, a bootstrap
launchd refused with the job absent, a job whose daemon exited before
answering, and a foreign endpoint all threw out of the launcher. The app then
lost its persistent daemon and fell back to local PTYs, where main forks one.
These now fall through to the existing fork path, which also adopts a foreign
occupant normally instead of as a degraded endpoint. A timed-out bootstrap or
a job still running/unreadable stays fatal, so no second daemon races it.

* feat(telemetry): split daemon origin events by stable-copy launch method

daemon_adopted, daemon_pty_cwd_denied and daemon_pty_cwd_readable gain a
closed-enum launch_method (stable-copy / app-fork / unknown), read from the
spawner path the daemon recorded, so the update fix is measurable per daemon
even when an old forked daemon is adopted across updates. The reliability
gate is a test manifest with no runtime switch, so there is no gate state to
report; spawner_path_class reports the private copy as 'other' alongside
other non-/Applications installs.

* refactor(telemetry): report the stable copy as a spawner path class

The separate launch_method property duplicated what the recorded spawner
path already says. spawner_path_class gains `stable-copy`, classified from
the same path under the private runtime root, and launch_method, its enum
and classifier are removed from all three .strict() daemon origin events.
A deleted copy still reports `missing`.

* refactor(settings): gate terminal-host Full Disk Access on macOS once

The permissions handler already asks for the terminal-host status only on
macOS, so the status function's own platform check and the pane's
'unsupported' branch could never fire. The off-macOS guarantee is now
tested at the handler.

* fix(macos): keep the fork fallback inside the startup PTY gate

The stable launch could run until 55 s after the launch started, so a late
"unavailable" verdict left the fork fallback's own 10 s readiness wait to
finish after the 60 s gate had already moved the app to local PTYs.

The launcher now gives the stable attempt one handoff deadline: the gate
minus the fork's readiness timeout and the 5 s lease/adapter allowance
(45 s). Copy, signature check and plist conversion share one abort signal
that ends early enough for bootstrap and the job's own readiness wait to
meet that deadline; every command otherwise keeps a fixed timeout. An
unavailable verdict past the deadline is reported instead of forking a
daemon that cannot arrive in time. The per-step budget module is gone (its
deletion landed in the previous commit by a staging slip).

The launch body is now two blocks: preparation (plist, plutil, deadline
check) fails as unavailable after removing the copy and job file, and
bootstrap runs in its own try/finally that always deletes the job file.

Retirement is simpler and claims more orphans: one in-flight collection per
process instead of a per-root map and rotating 20-record scan, and the job
record (written before copying) no longer carries a bundle name because
lsof +D scans the whole runtime directory (measured equal cost, same
result on a live copy). A never-submitted copy is retired once its producer
has exited and its job is missing, so a bootstrap timeout or a crash
mid-copy no longer leaves a copy forever; the open-file check still guards
deletion. Collection now starts from the launcher after the attempt.

* docs(macos): ship the daemon launch rules, not the investigation

The signed investigation harness left the tree in the previous telemetry
commit by a staging slip; its results and pinned link stay in the PR. This
trims the reference doc to why the copy and launchd exist, the fallback
states and the cleanup rule, trims the reliability gate to what its
validator needs, and points AGENTS.md at the reference doc next to the
Windows relocation entry.

* fix(macos): hand off to the fork as soon as the stable job exits

A copied daemon refused at exec (for example by Gatekeeper) never answers,
so every fresh start paid the full 10 s readiness wait before the fork
fallback. The readiness loop now reads the job state every 500 ms and
abandons as soon as the job is stopped or missing; a running or
unreadable job is still waited out and never forked beside.

Also drops the dead forkEntryPath the stable launch never read (its
options type now omits it) and un-exports the handoff constant.

* fix(macos): keep daemon copies out of Spotlight and unregister retired ones

Each runtime copy is a full Orca.app, so Spotlight indexed it as an app
and LaunchServices kept a record for it that outlived the copy. A
registered copy claims orca: links and Markdown/CSV files, and becomes
their default handler whenever /Applications/Orca.app is not registered.

New copies go under runtime-*/app.noindex/, which Spotlight skips and which
stops the record LaunchServices made while the bundle was being copied.
Retirement now runs lsregister -u on every bundle in the copy (both the
app.noindex layout and the earlier runtime-*/Orca.app layout) once lsof
shows it unused, then deletes it. A running copy stays registered,
because Local Network access may be resolved through that record.

Collection and stable-copy telemetry match the runtime-* directory, so
copies made before this change are still adopted, classified and retired.

* docs(macos): record the LaunchServices trade-off and the rollback plan

The reference doc now explains why copies sit in a .noindex folder, why
only retired copies are unregistered, and how to turn the copy launch off:
make launchMacDaemonFromStableBundle return null, and keep the retirement
code until no copy-launched daemon can remain, because the old code never
deletes copies.

A test pins that rollback: with the stable launcher returning null, the
launcher forks and still runs the copy collection.

* fix(macos): keep daemon copies registered so Local Network can resolve them

The `.noindex` folder kept copies out of Spotlight and LaunchServices, but
Local Network access is resolved from the signing identifier through
LaunchServices to the executable's Mach-O UUID. After an update that changes
Electron only the copy carries the running daemon's UUID, so an unregistered
copy risks the same Local Network loss main has today. Copies go back to
`runtime-*/Orca.app`; retirement still unregisters each copy before deleting
it, so deleted copies leave no LaunchServices entry. The remaining trade-off:
while `/Applications/Orca.app` is missing, a running registered copy can be
picked for `orca:` links and documents.

* fix(macos): hide daemon copies from Spotlight and tie the job to Orca

Spotlight listed each runtime copy as a second Orca app. New copies go
back under runtime-*/app.noindex/Orca.app, which Spotlight skips.
Retirement and stable-copy telemetry match the runtime-* directory, so
copies in the earlier runtime-*/Orca.app layout still retire and classify
the same way.

Local Network access identifies the daemon by its code signature and
main-executable UUID, not by a LaunchServices record, so an unregistered
copy keeps it. The launchd job now sets AssociatedBundleIdentifiers to
com.stablyai.orca, which is how a job not installed through SMAppService
names the app it belongs to. Copies are never registered explicitly;
retirement still runs lsregister -u before deleting each one.

* feat(macos): package a signed terminal host helper that runs the daemon on the pinned Node

Contents/Helpers/Orca Terminal Host.app carries the pinned Node, the daemon's
out/main JS and this slice's node-pty, signed inside-out as com.stablyai.orca.
afterPack boots it through one PTY round trip, then fails the build on layout,
Info.plist, macOS floor (NODE_RUNTIME_DARWIN_MINIMUM_OS vs Node's minos),
architecture or signature drift.

* ci(macos): cache and prefetch the pinned Node before packaging

Packaging now fetches the darwin Node from nodejs.org, whose download has no
retry. Each mac workflow caches it with the electron-builder downloads, keyed on
the pin, and warms both darwin targets inside the existing retry wrapper.

* feat(macos): launch the daemon from a copy of the terminal host helper, not the whole app

The stable launch now clones Contents/Helpers/Orca Terminal Host.app (~124 MiB
instead of ~628 MiB) and runs its Node on its daemon entry, without
ELECTRON_RUN_AS_NODE. The copy must still match the running app's designated
requirement. A missing helper falls back to the fork, and macOS below the
helper's Node floor forks without copying.

* docs(macos): describe the terminal host helper, its floor and its identity

* fix(macos): bound the terminal host boot check by its deadline and always clean up

A daemon that stayed silent hung packaging past the 20 s deadline; one that closed the
connection left the hello unsettled so Node exited 0 without cleanup; a malformed line threw
uncaught. Every connect, hello and PTY wait now races the deadline, close/error/parse failures
reject, and the daemon and scratch dir are removed on every path.

* ci(macos): cache the pinned Node on its own key with no restore-keys fallback

Sharing the electron-builder cache let old Node archives accumulate through restore-keys.
Also moves release-mac-build's Electron cache comment back onto its step.

* fix(macos): reject a universal slice for the terminal host and pin its name in one test

packageMacTerminalHost turned an unknown arch into darwin-undefined; it now throws through the
shared mac arch mapping. A test asserts the packager and the desktop agree on the helper bundle
and executable names, replacing a Linux/Windows test that could not fail.

* fix(macos): pass the launchd adoption lease its options object after the main merge

* test(macos): accept a reset as the daemon closing the boot-check connection; keep only passing gate commands

* fix(daemon): bump the terminal daemon protocol to v45 so new macOS tabs start in the terminal host helper

* fix(macos): ship the daemon's required packages in the terminal host and boot it outside the build tree

* style(macos): use a template literal in the terminal host resolution check

* fix(macos): derive the terminal host's packages from the daemon's own module graph

* fix(macos): treat any non-literal daemon load as computed, follow directory loads, and keep nested dependencies

* refactor(macos): move terminal-host telemetry, Settings status and Helper folder strings to a follow-up

Keeps this change to the update-safe terminal host itself. The follow-up restores:
- the `stable-copy` spawner path class in daemon adoption telemetry;
- the per-terminal-host Full Disk Access row in Settings and its daemon RPC;
- writing folder usage descriptions into the app's Electron Helpers for the fork fallback.

The helper still takes its Desktop, Documents and Downloads descriptions from
`macos-folder-usage-descriptions.cjs`, so that constant stays.

* fix(i18n): restore en.json to this branch's base, not newer main

The previous commit took the file from a newer main, which pulled unrelated strings into this PR.

* refactor(macos): publish one terminal-host copy per helper build under a fixed launchd label

Name the copy after the helper's CDHash, reuse it when present, and
publish a new one by cloning into a staging dir, verifying it once
against the app's designated requirement and renaming it into place.
One launchd label per profile and protocol replaces per-launch labels,
so job.json, producer pids and per-label collection go away. Retirement
deletes any other copy lsof shows unused. The stable launcher returns
null for every fork fallback and logs its reason.

* fix(packaging): copy terminal-host dependencies from where Node resolves them

A nested package's own dependencies were never walked, so a hoisted
dependency of a nested package was left out of the helper. Resolve each
dependency through the node_modules lookup Node performs from its parent
and copy it to the same relative place, unless a copied package already
carries it.

* fix(packaging): fail release and CI mac builds that cannot boot a terminal-host slice

An arm64 runner without Rosetta skipped the x64 helper's PTY boot check
and shipped the slice untested. Release channels and CI now fail there;
local cross-arch builds still skip with a log line.

* docs(macos): describe the per-build terminal-host copy and its fallback reasons

* docs(macos): keep the codesign command on one line

* test(macos): type the launchd test state without an assertion

* docs(macos): name what the login-session flag gates in the stable launcher

* fix(macos): wait for launchd to drop a booted-out terminal job before reusing its label

---------

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-10-11 14:54:36 -04:00
committed by GitHub
co-authored by Neil
parent e7da462dbf
commit 3f8d296b8d
48 changed files with 3184 additions and 77 deletions
+13 -1
View File
@@ -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.
+15 -1
View File
@@ -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.
+13 -1
View File
@@ -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.
+13 -1
View File
@@ -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'
+1
View File
@@ -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.
+76 -29
View File
@@ -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' })
+86
View File
@@ -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."
}
]
}
@@ -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 }
@@ -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)
})
@@ -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 /<key>com\.apple\.security\.cs\.allow-jit<\/key>\s*<true\/>/.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
}
@@ -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)
})
})
+8
View File
@@ -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 <target>... — 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 }))
}
}
+260 -4
View File
@@ -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
}
@@ -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([])
})
})
@@ -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-<cdhash>/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-<pid>-*` sibling, verified once with
`codesign --verify --strict -R "=<app designated requirement>"`, 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.<profile hash>.v<protocol>`). 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.
@@ -56,7 +56,6 @@ describe('every search entry point carries the scope identity through', () => {
paths: ['/work/app']
})
})
})
describe('the shared remote client', () => {
@@ -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 () => {
@@ -44,5 +44,4 @@ describe('OpenClaw session discovery', () => {
expect(relativeToAgents(files)).toEqual(EXPECTED)
})
})
@@ -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)
@@ -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<Record<string, unknown>>())
)
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<typeof MacDaemonLaunchd>()
return {
...actual,
launchMacDaemonFromStableBundle: async (
options: Parameters<typeof actual.launchMacDaemonFromStableBundle>[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<Record<string, unknown>>()),
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<unknown> {
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()
})
})
@@ -29,7 +29,10 @@ export type DaemonChildSpawnOptions = {
startupTimeoutMs?: number
}
function buildDaemonScriptArgs(options: DaemonChildSpawnOptions): string[] {
export function buildDaemonScriptArgs(
options: Omit<DaemonChildSpawnOptions, 'forkEntryPath' | 'relocatedExecPath'>,
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()
+3 -2
View File
@@ -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)
@@ -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')
+34 -9
View File
@@ -1,5 +1,5 @@
// Adapted from David Bebawy's PR #21826: `codesign --display +<pid>` 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<DaemonCodeIdentity> {
async function probe(pid: number): Promise<MacProcessCodeIdentity> {
try {
const result = await runProcess({
program: '/usr/bin/codesign',
@@ -37,24 +51,29 @@ async function probe(pid: number): Promise<DaemonCodeIdentity> {
})
// 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<DaemonCodeIdentity> } | null = null
let inFlight: { pid: number; pending: Promise<MacProcessCodeIdentity> } | 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<DaemonCodeIdentity> {
): Promise<MacProcessCodeIdentity> {
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<DaemonCodeIdentity> {
return (await inspectMacProcessCodeIdentity(pid)).identity
}
@@ -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()
@@ -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()
+3 -2
View File
@@ -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 {
@@ -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<ProcessResult>>()
}))
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<string> {
const directory = join(root, name)
await mkdir(join(directory, 'app.noindex', 'Orca Terminal Host.app'), { recursive: true })
return directory
}
const exists = (path: string): Promise<boolean> =>
access(path).then(
() => true,
() => false
)
function lsofReports(byDirectory: Record<string, ProcessResult>): 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()
})
@@ -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<void> | 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<void> {
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<void> {
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<void> {
collectionInFlight ??= collect(root, current).finally(() => {
collectionInFlight = null
})
return collectionInFlight
}
+212
View File
@@ -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<ProcessResult>>(),
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<void>) | 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()
})
+145
View File
@@ -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-<helper CDHash>`. */
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<string> {
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<void> {
// 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<MacDaemonBundle> {
// 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(() => {})
}
}
@@ -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<ProcessResult>>(),
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)
}
)
+66
View File
@@ -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<MacDaemonJobState> {
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<void> {
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<boolean> {
const state = await readMacDaemonJobState(service)
if (state !== 'stopped') {
return state === 'missing'
}
return stopMacDaemonJob(service).then(
() => true,
() => false
)
}
@@ -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<ProcessResult>>(),
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<Record<string, unknown>>()),
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<boolean> =>
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<unknown> {
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()
})
+214
View File
@@ -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<DaemonChildSpawnOptions, 'forkEntryPath' | 'relocatedExecPath'>
/** 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<string, unknown> {
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<DaemonProcessHandle | null> {
// `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<null> => {
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<void> => 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
}
)
}
@@ -72,7 +72,6 @@ async function expectIdentity(identity: string): Promise<void> {
}
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(
@@ -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
)
})
@@ -232,7 +232,6 @@ describe('reconcileOrphanedPrRemotesWithExec', () => {
)
expect(reclaimed).toEqual([])
})
})
describe('reconcileOrphanedPrRemotes rate limiting', () => {
@@ -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
@@ -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
@@ -43,7 +43,14 @@ function setup(overrides?: Partial<E2EEChannelOptions>) {
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,
+8 -1
View File
@@ -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, {
+16 -2
View File
@@ -36,7 +36,14 @@ function setup(overrides?: Partial<E2EEChannelOptions>) {
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(
+16 -2
View File
@@ -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
@@ -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({
@@ -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
]
}
@@ -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
+6
View File
@@ -149,6 +149,12 @@ export const NODE_RUNTIME_COMPAT_ASSETS: Record<CompatServerTarget, NodeRuntimeA
}
// @generated-end
/**
* The pinned darwin Node's `LC_BUILD_VERSION minos`, and so the floor of the macOS terminal host.
* Packaging fails when a pin bump changes it; below it the desktop forks the daemon instead.
*/
export const NODE_RUNTIME_DARWIN_MINIMUM_OS = '13.5'
export function isCompatServerTarget(target: string): target is CompatServerTarget {
return COMPAT_SERVER_TARGETS.some((known) => known === target)
}