Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 111 additions & 4 deletions desktop/docs/macos-privileged-helper.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,27 @@ flowchart TD
background helper.
2. Call `macPrivilege.ensureConfigured(firstTime)`, which (no UI):
- reads `nvpair-helper-ctl status`;
- if the daemon version != app version (in-place Squirrel.Mac update),
`uninstall` + `install` to refresh it, and force a firewall reconfigure;
- if enabled but unreachable, or the daemon version differs from the app
version, attempt `uninstall` + `install` once per app version, re-check
`status`, and force a firewall reconfigure;
- if not registered, `install` (which calls `SMAppService.daemon().register()`);
- when `enabled`, run `configure-firewall` (no `--app-path`: the daemon
- when `enabled` and reachable with no known version mismatch, run
`configure-firewall` (no `--app-path`: the daemon
derives the target bundle from the verified caller — see Security).
3. On `requiresApproval`, the control tool calls
`SMAppService.openSystemSettingsLoginItems()`; main surfaces guidance to
enable the background item under **System Settings → General → Login Items &
Extensions**, then relaunch.
4. On success (`enabled` + firewall configured), set the one-time
`macHelperSetupComplete` flag in `ui-config.ts`.
`macHelperSetupComplete` flag in `ui-config.ts`. Clear it if setup fails or
approval is pending so firewall configuration is retried on the next launch.

Before replacing a stale helper, PAIR persists `macHelperRepairAttemptVersion`
and clears `macHelperSetupComplete` in `configs/ui-config.json`. This also covers
upgrades from 1.0.0 where signature rejection prevents reading a daemon version.
An interrupted or unsuccessful replacement is not repeated for the same app
version. An enabled helper that remains unreachable or reports a different
version leaves setup incomplete; registration alone does not imply success.

The firewall is reconfigured on first run, after a version refresh, and after a
fresh registration (so a release that adds a networked binary still gets its
Expand Down Expand Up @@ -144,6 +154,103 @@ any non-zero `socketfilterfw` exit.
- macOS packaging requires `swiftc`; build environments should verify it with
`xcrun --find swiftc`.

The daemon embeds `native/PrivilegedHelper/Info.plist` in `__TEXT,__info_plist`
in both architectures. Its `CFBundleIdentifier` supplies the signing identifier
even when a signing service re-signs the enclosing app recursively. Without it,
the filename-derived default can strip `.helper` and produce `com.nvidia.nvpair`.
The control tool must continue using the enclosing app's `Bundle.main` for
ServiceManagement; it does not embed a helper-specific Info.plist.

On macOS, check the built daemon's identifier through standalone and recursive
ad hoc signing of disposable copies:

```bash
npm run build:helper:mac
npm run test:helper-signing:mac
```

This regression test checks identifier stability in every architecture, not
Developer ID trust. It does not run or register the copied daemon.

### Required release validation

The signing identifiers of the loose helper executables are part of the XPC
authentication contract:

| Executable | Required signing identifier |
| --- | --- |
| `Contents/MacOS/com.nvidia.nvpair.helper` | `com.nvidia.nvpair.helper` |
| `Contents/MacOS/nvpair-helper-ctl` | `nvpair-helper-ctl` |
| Host application | `com.nvidia.nvpair` |

The release signer must assign each helper its own identifier, retain hardened
runtime and NVIDIA's Developer ID Application signature, and sign the enclosing
app after signing its nested executables. It must not overwrite the daemon's
identifier with the host app's identifier during a later signing pass.
Pass `--identifier com.nvidia.nvpair.helper` when signing the daemon and
`--identifier nvpair-helper-ctl` when signing the control tool (or the signing
service's equivalent). Do not rely on filename-derived defaults: `codesign`
can strip `.helper` from the daemon's filename and produce the app's identifier.
An explicit identifier in an earlier signature is insufficient if a later
signing pass derives a new default.

After the **final** app signing pass, before notarization or creating release
archives, the signed release pipeline must run this command from `desktop/`
and fail the build if it exits nonzero:

```bash
npm run verify:helper-signatures:mac -- "/path/to/NVIDIA PAIR.app"
```

The command compiles a temporary validator with the same `HelperProtocol.swift`
used by both helpers. It verifies each helper and the app against their complete
pinned requirements with `codesign --verify --strict --all-architectures`.
It does not register the helper, modify signatures, or change firewall rules.
Unsigned public builds are expected to fail this release check.

Release maintainers must integrate this check into the signing pipeline after
the final signing pass; adding the validator to the source tree does not wire
it into release signing. The public build remains unsigned. Run the check again
against the app extracted from each final DMG and ZIP before publishing.
General app signature verification
alone is insufficient: a valid NVIDIA signature can still have an identifier
that the XPC peer rejects, as reported in
[issue #175](https://github.com/NVIDIA/Personal-AI-Router/issues/175).

### Signed-build acceptance checks

Use a signed release candidate on a test Mac, including an upgrade from the
affected release. Install in `/Applications`, launch PAIR as the normal user,
and approve its background helper if prompted. Do not re-sign the installed
app or alter the pinned requirements to make these checks pass.

For upgrade coverage, first confirm the affected release reports `enabled` but
`reachable: false`, then replace the app in place without unregistering the old
helper or clearing user data. On the updated app's first launch, confirm it
re-registers the helper and records the new app version in
`macHelperRepairAttemptVersion`. Follow the checks below after any required
approval. If repair fails, verify setup remains incomplete and relaunching the
same version does not trigger another unregister/register cycle.

1. Run the release validation command above against the installed app.
2. Run `"/Applications/NVIDIA PAIR.app/Contents/MacOS/nvpair-helper-ctl" status`.
Require `registration: "enabled"`, `reachable: true`, and a `daemonVersion`
matching `ctlVersion` and the app version. The command's exit status alone
does not prove reachability.
3. Run `"/Applications/NVIDIA PAIR.app/Contents/MacOS/nvpair-helper-ctl" configure-firewall`.
This changes firewall rules; require exit zero and JSON `ok: true`.
Check each of the seven networked binaries listed above with
`/usr/libexec/ApplicationFirewall/socketfilterfw --getappblocked <binary-path>`.
4. Confirm normal app setup writes `macHelperSetupComplete: true` in the
app's user-data `configs/ui-config.json`. Do not set the flag manually.
5. Fully quit and reopen PAIR twice. Confirm the flag remains true, the helper
stays reachable, and no helper-authorization dialog returns. Repeat after
a reboot. Closing only the window leaves PAIR running in the tray.

Record the app/build version, macOS version, architecture, command results,
and whether this was a clean install or an upgrade. A running launchd job or
the Service page's Running state is not evidence of helper XPC reachability.

## Uninstall

`scripts/build/macos/uninstall.sh` (shipped at
Expand Down
12 changes: 12 additions & 0 deletions desktop/native/PrivilegedHelper/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-License-Identifier: Apache-2.0
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.nvidia.nvpair.helper</string>
</dict>
</plist>
4 changes: 2 additions & 2 deletions desktop/native/Shared/HelperProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ enum HelperConstants {
static let teamIdentifier = "6KR3T733EC"
static let appBundleIdentifier = "com.nvidia.nvpair"

/// Basenames `codesign` assigns as the signing identifier of the two loose
/// helper Mach-O files (codesign defaults the identifier to the file name).
/// Required signing identifiers of the two loose helper Mach-O files.
/// Set these explicitly when signing: codesign's default can strip suffixes.
static let ctlIdentifier = "nvpair-helper-ctl"
static let daemonIdentifier = "com.nvidia.nvpair.helper"

Expand Down
45 changes: 45 additions & 0 deletions desktop/native/VerifyHelperSignatures/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import Foundation

guard CommandLine.arguments.count == 2 else {
fputs("usage: verify-helper-signatures <signed-app.app>\n", stderr)
exit(2)
}

let app = URL(fileURLWithPath: CommandLine.arguments[1]).standardizedFileURL
let checks = [
(
app.appendingPathComponent("Contents/MacOS/\(HelperConstants.daemonIdentifier)"),
HelperConstants.daemonCodeSigningRequirement
),
(
app.appendingPathComponent("Contents/MacOS/\(HelperConstants.ctlIdentifier)"),
HelperConstants.ctlCodeSigningRequirement
),
(app, HelperConstants.appCodeSigningRequirement)
]

var failed = false
for (target, requirement) in checks {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign")
process.arguments = [
"--verify", "--strict", "--all-architectures", "--verbose=2",
"-R=\(requirement)", target.path
]
do {
try process.run()
process.waitUntilExit()
if process.terminationStatus != 0 {
fputs("Signing requirement failed: \(target.path)\n", stderr)
failed = true
}
} catch {
fputs("Cannot verify \(target.path): \(error.localizedDescription)\n", stderr)
failed = true
}
}

exit(failed ? 1 : 0)
2 changes: 2 additions & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
"build:electron:linux:x64": "npm run licenses && npm run build:modular-binaries:linux:x64 -- --force && npm run build:collect-logs:linux:x64 && electron-builder -c electron-builder.config.ts --linux --x64 --publish never",
"build:electron:linux:arm64": "npm run licenses && npm run build:modular-binaries:linux:arm64 -- --force && npm run build:collect-logs:linux:arm64 && electron-builder -c electron-builder.config.ts --linux --arm64 --publish never",
"build:helper:mac": "tsx scripts/build/macos/build-helper.ts",
"test:helper-signing:mac": "sh scripts/build/macos/test-helper-signing.sh",
"verify:helper-signatures:mac": "sh scripts/build/macos/verify-helper-signatures.sh",
"build:electron:mac:x64": "npm run licenses && npm run build:modular-binaries:mac:x64 -- --force && npm run build:collect-logs:mac:x64 && npm run build:helper:mac && electron-builder -c electron-builder.config.ts --mac --x64 --publish never",
"build:electron:mac:arm64": "npm run licenses && npm run build:modular-binaries:mac:arm64 -- --force && npm run build:collect-logs:mac:arm64 && npm run build:helper:mac && electron-builder -c electron-builder.config.ts --mac --arm64 --publish never",
"build:win": "npm run build && npm run build:electron:win:x64 && npm run build:electron:win:arm64",
Expand Down
19 changes: 16 additions & 3 deletions desktop/scripts/build/macos/build-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ function buildUniversal(
sdkPath: string,
outputName: string,
sources: string[],
frameworks: string[]
frameworks: string[],
linkerArgs: string[]
): void {
const archBinaries: string[] = []
for (const arch of ARCHS) {
Expand All @@ -117,6 +118,7 @@ function buildUniversal(
'-target',
`${arch}-apple-macos${DEPLOYMENT_TARGET}`,
...frameworkArgs,
...linkerArgs,
'-o',
archOut,
...sources
Expand Down Expand Up @@ -149,14 +151,25 @@ function main(): void {
sdkPath,
DAEMON_OUTPUT,
[...shared, versionSource, ...swiftSources(DAEMON_DIR)],
['Foundation', 'Security']
['Foundation', 'Security'],
[
'-Xlinker',
'-sectcreate',
'-Xlinker',
'__TEXT',
'-Xlinker',
'__info_plist',
'-Xlinker',
path.join(DAEMON_DIR, 'Info.plist')
]
)
buildUniversal(
swiftc,
sdkPath,
CTL_OUTPUT,
[...shared, versionSource, ...swiftSources(CTL_DIR)],
['Foundation', 'ServiceManagement']
['Foundation', 'ServiceManagement'],
[]
)

console.log(`[build-helper] helper binaries ready in ${BUILD_DIR} (v${version})`)
Expand Down
31 changes: 31 additions & 0 deletions desktop/scripts/build/macos/test-helper-signing.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/bin/sh
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

set -eu

DESKTOP_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)
TEST_DIR=$(mktemp -d "${TMPDIR:-/tmp}/pair-helper-signing.XXXXXX")
trap 'rm -rf "$TEST_DIR"' EXIT
trap 'exit 1' HUP INT TERM

APP="$TEST_DIR/Fixture.app"
DAEMON="$APP/Contents/MacOS/com.nvidia.nvpair.helper"
mkdir -p "$APP/Contents/MacOS"
cp "$DESKTOP_DIR/native/build/com.nvidia.nvpair.helper" "$DAEMON"
cp /usr/bin/true "$APP/Contents/MacOS/Fixture"
/usr/bin/plutil -create xml1 "$APP/Contents/Info.plist"
/usr/bin/plutil -insert CFBundleIdentifier -string com.nvidia.nvpair "$APP/Contents/Info.plist"
/usr/bin/plutil -insert CFBundleExecutable -string Fixture "$APP/Contents/Info.plist"
/usr/bin/plutil -insert CFBundlePackageType -string APPL "$APP/Contents/Info.plist"

echo 'Checking daemon identifier after standalone signing'
/usr/bin/codesign --force --sign - "$DAEMON"
/usr/bin/codesign --verify --strict --all-architectures \
'-R=identifier "com.nvidia.nvpair.helper"' "$DAEMON"

echo 'Checking daemon identifier after recursive bundle signing'
/usr/bin/codesign --force --deep --sign - "$APP"
/usr/bin/codesign --verify --deep --strict "$APP"
/usr/bin/codesign --verify --strict --all-architectures \
'-R=identifier "com.nvidia.nvpair.helper"' "$DAEMON"
23 changes: 23 additions & 0 deletions desktop/scripts/build/macos/verify-helper-signatures.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/bin/sh
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

set -eu

if [ "$#" -ne 1 ]; then
echo "usage: $0 <signed-app.app>" >&2
exit 2
fi

DESKTOP_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)
VERIFY_DIR=$(mktemp -d "${TMPDIR:-/tmp}/pair-helper-signatures.XXXXXX")
trap 'rm -rf "$VERIFY_DIR"' EXIT
trap 'exit 1' HUP INT TERM

/usr/bin/xcrun --sdk macosx swiftc \
-module-cache-path "$VERIFY_DIR/module-cache" \
"$DESKTOP_DIR/native/Shared/HelperProtocol.swift" \
"$DESKTOP_DIR/native/VerifyHelperSignatures/main.swift" \
-o "$VERIFY_DIR/verify-helper-signatures"

"$VERIFY_DIR/verify-helper-signatures" "$1"
16 changes: 15 additions & 1 deletion desktop/src/electron/config/ui-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@ interface UiConfig {
modularLogLevel: ModularLogLevel
/** macOS only: the one-time privileged-helper setup (register the SMAppService daemon + configure the Application Firewall) has completed. Gates the first-run admin prompt; left false until the daemon is enabled and firewall configuration succeeds, so an approval-pending launch retries next time. */
macHelperSetupComplete: boolean
/** Last app version that attempted to replace an enabled but stale helper. */
macHelperRepairAttemptVersion: string
}

const DEFAULTS: UiConfig = {
firstRun: true,
modularLogLevel: MODULAR_DEFAULT_LOG_LEVEL,
macHelperSetupComplete: false
macHelperSetupComplete: false,
macHelperRepairAttemptVersion: ''
}

let config: UiConfig = { ...DEFAULTS }
Expand Down Expand Up @@ -121,3 +124,14 @@ export function setMacHelperSetupComplete(value: boolean): void {
config.macHelperSetupComplete = value
save()
}

export function getMacHelperRepairAttemptVersion(): string {
return config.macHelperRepairAttemptVersion
}

/** Record the attempt and invalidate completed setup before replacing the helper. */
export function setMacHelperRepairAttemptVersion(value: string): void {
config.macHelperRepairAttemptVersion = value
config.macHelperSetupComplete = false
save()
}
5 changes: 4 additions & 1 deletion desktop/src/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ if (!gotTheLock || exitRequested) {

const result = await macPrivilege.ensureConfigured(firstTime)
if (!result.supported) return
setMacHelperSetupComplete(
result.registration === 'enabled' && result.firewallConfigured
)

if (result.registration === 'requiresApproval') {
if (firstTime) {
Expand All @@ -190,7 +193,6 @@ if (!gotTheLock || exitRequested) {
}

if (result.registration === 'enabled' && result.firewallConfigured) {
setMacHelperSetupComplete(true)
log.info({ sublevel: 'mac-helper', message: 'Privileged helper configured' })
} else {
log.error({
Expand All @@ -199,6 +201,7 @@ if (!gotTheLock || exitRequested) {
})
}
} catch (err) {
setMacHelperSetupComplete(false)
log.error({ sublevel: 'mac-helper', message: `Privileged setup error: ${err}` })
}
}
Expand Down
Loading
Loading