Skip to content
Open
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
9 changes: 9 additions & 0 deletions desktop/docs/frontend-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,15 @@ environment assignments pass through without an engine-option catalog.
- `deleteModel`;
- `setModelExpiry`.

`install` and `uninstall` carry `path`, the user's answer to changing their
PATH. The renderer asks before a local install (once for every engine the
first-run wizard installs) and offers removal on uninstall only when the local
status reports `pathManaged`; otherwise the uninstall omits `path`, which keeps
PAIR's claim on any entry instead of handing it to the user unasked. Remote
installs never change PATH. `update` reads `path_managed` fresh from
`engine:status`, since `nvpair-tui` can change it without a push, and reuses
that answer without asking.

Commands return no state. Renderer stores update from
`engines:state-changed`, `engines:progress-changed`, and
`engines:progress-cleared`.
Expand Down
2 changes: 2 additions & 0 deletions desktop/docs/services-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,11 @@
|---|---|---|
| `cluster:identity-changed` | request (we call) | ✅ yes |
| `cluster:invite-received` | request (we call) | ✅ yes |
| `engine:install` | request (we call) | ✅ yes |
| `engine:install-progress` | request (we call) | ✅ yes |
| `engine:pull-progress` | request (we call) | ✅ yes |
| `engine:state-changed` | request (we call) | ✅ yes |
| `engine:uninstall` | request (we call) | ✅ yes |
| `error` | request (we call) | ✅ yes |
| `nodes:changed` | request (we call) | ✅ yes |
| `workloads:remove` | request (we call) | ✅ yes |
Expand Down
3 changes: 2 additions & 1 deletion desktop/docs/services-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ sign the OFF choice was discarded.

For ease of use, Personal AI Router issues starts in two additional cases and
keeps no auto-start list of its own: every install sends `engine:install` with
`start: true`, so a successful install (or managed update) starts the engine;
`start: true` (and `path` set to the user's PATH answer), so a successful
install (or managed update) starts the engine;
and on the first app open Personal AI Router starts every already-installed local
engine once. Both paths rely on the backend recording desired-enabled as a side
effect of start, so `nvpair-engine-manager` remains the single owner of
Expand Down
11 changes: 10 additions & 1 deletion desktop/electron-builder.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
// signing and notarization live outside this repository, so anything built here
// is unsigned. Released builds come from NVIDIA's own signed pipeline.
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import type { Configuration } from 'electron-builder'
import electronPkg from 'electron/package.json'
import pkg from './package.json'
Expand Down Expand Up @@ -390,7 +391,15 @@ const config: Configuration = {
},
deb: {
afterInstall: 'scripts/build/linux/after-install.sh',
afterRemove: 'scripts/build/linux/after-remove.sh'
afterRemove: 'scripts/build/linux/after-remove.sh',
// prerm has no dedicated option, so it goes through the raw fpm passthrough.
// It keeps a copy of the engine-manager for the purge-time PATH cleanup in
// after-remove, because dpkg deletes the package's files before postrm
// runs — see before-remove.sh. Unlike afterInstall/afterRemove, fpm arguments are
// forwarded verbatim: no ${macro} expansion, and the path is resolved
// against fpm's working directory rather than this file, so pass an
// absolute one.
fpm: [`--before-remove=${join(__dirname, 'scripts/build/linux/before-remove.sh')}`]
},
mac: {
executableName: APP_EXECUTABLE_NAME,
Expand Down
50 changes: 47 additions & 3 deletions desktop/scripts/build/installer.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,45 @@
ClearErrors
!macroend

; Release the PATH entries this user's engines own, before the data directory
; that records them is deleted.
;
; Engine-manager writes its ownership receipts under the data root
; (engine-bin\engine-path\), while the entries themselves live in
; HKCU\Environment\Path — outside everything pairRemoveUserData touches. Deleting
; the receipts first would strand those entries with no record left to remove
; them by, pointing at engine directories this uninstall is about to delete.
;
; Runs the shipped binary rather than editing the registry here, so one
; implementation owns the format and the ownership rules. It must run before the
; template's RMDir /r $INSTDIR, which is why this lives in customUnInstall.
; nsExec::ExecToLog never aborts the uninstaller.
;
; The exit code is kept in $7 because the caller has to act on it. A failed
; release is not a no-op: the binary removes what it can and reports the rest,
; so some entries may be gone and some may remain — and the records that could
; still identify the remaining ones are inside the data root the caller is about
; to delete.
;
; The child inherits this process's environment and user, so it resolves the
; same profile pairRemoveUserData does. An elevated uninstall authenticated as a
; different administrator targets that account instead — the same limitation the
; data removal above already has.
!macro pairReleaseEnginePathEntries
DetailPrint "Releasing engine PATH entries..."
nsExec::ExecToLog '"$INSTDIR\resources\cli-bin\nvpair-engine-manager.exe" --remove-user-path'
Pop $7
!macroend

; The release failed, so the ownership records are the only thing that can still
; identify the entries it left behind. Deleting the data root would strand them
; permanently — the outcome the whole ordering above exists to avoid — so the
; data stays and the user is told why. A reinstall retries the cleanup.
!macro pairWarnPathEntriesRemain
DetailPrint "Could not release every engine PATH entry; keeping user data so a reinstall can retry."
MessageBox MB_OK|MB_ICONEXCLAMATION "Personal AI Router could not remove every engine entry from your PATH.$\n$\nYour data has been kept so that reinstalling can finish the cleanup. If you delete it by hand, remove those PATH entries yourself as well — nothing else will be able to identify them." /SD IDOK
!macroend

; Best-effort: when the user opts to remove data, stop any process whose
; executable lives UNDER one of the data roots (e.g. an engine like Ollama
; running from %LOCALAPPDATA%\Nvidia Corporation\Personal AI Router\engine-bin\)
Expand Down Expand Up @@ -314,9 +353,14 @@
pairDataDone:
${endif}
${if} $8 == "1"
!insertmacro pairKillProcessesInDataDirs
!insertmacro pairRemoveUserData
!insertmacro pairWarnIfDataRemains
!insertmacro pairReleaseEnginePathEntries
${if} $7 == "0"
!insertmacro pairKillProcessesInDataDirs
!insertmacro pairRemoveUserData
!insertmacro pairWarnIfDataRemains
${else}
!insertmacro pairWarnPathEntriesRemain
${endif}
${endif}
${endif}
!macroend
23 changes: 22 additions & 1 deletion desktop/scripts/build/linux/after-remove.sh
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,28 @@ case "${1:-}" in
real_user="$(logname 2>/dev/null || true)"
fi

if [ -n "$real_user" ]; then
# before-remove.sh kept this copy of nvpair-engine-manager, because the
# package's own is gone by now. It releases the PATH entries this user's
# engines own, which live in the login shell profiles rather than the data
# root, so they have to go before the records that identify them. A failed
# release keeps the data root, so the records survive for a later attempt.
stash_dir="/var/lib/$DPKG_MAINTSCRIPT_PACKAGE"
keep_data=0
if [ -n "$real_user" ] && [ "$real_user" != root ] && [ -n "$DPKG_MAINTSCRIPT_PACKAGE" ] \
&& [ -x "$stash_dir/nvpair-engine-manager" ]; then
if command -v runuser >/dev/null 2>&1; then
runuser -u "$real_user" -- "$stash_dir/nvpair-engine-manager" --remove-user-path >/dev/null 2>&1 || keep_data=1
else
su -s /bin/sh -c "'$stash_dir/nvpair-engine-manager' --remove-user-path" "$real_user" >/dev/null 2>&1 || keep_data=1
fi
fi
if [ -n "$DPKG_MAINTSCRIPT_PACKAGE" ]; then
rm -rf "$stash_dir" 2>/dev/null || true
fi

if [ -n "$real_user" ] && [ "$keep_data" = 1 ]; then
echo "Personal AI Router: could not remove the PATH entries its engines added; keeping its data so they can still be removed after reinstalling." >&2
elif [ -n "$real_user" ]; then
user_home="$(getent passwd "$real_user" 2>/dev/null | cut -d: -f6 || true)"
[ -n "$user_home" ] || user_home="/home/$real_user"

Expand Down
47 changes: 47 additions & 0 deletions desktop/scripts/build/linux/before-remove.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/bin/bash
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

# Personal AI Router Debian pre-remove. Keeps a copy of nvpair-engine-manager so
# that after-remove can release the user's PATH entries on `purge`. No `set -e`,
# and a trailing `exit 0`, so a best-effort step never fails the removal.
#
# Unlike after-install.sh and after-remove.sh, this one reaches fpm through the
# raw passthrough in electron-builder.config.ts, so ${macro} is NOT expanded
# here. It finds its own paths at runtime instead.
#
# WHY A COPY. Engine-manager records what it added to PATH under the user's data
# root (engine-bin/engine-path/), while the entries themselves live in the login
# shell's profiles, which no maintainer script touches. Only a purge deletes the
# data root, and only postrm can tell a purge from a plain remove -- prerm sees
# "remove" either way. But dpkg deletes the package's files before postrm runs,
# and a purge can come long after the remove, so the binary that understands the
# records has to outlive the package. It is kept under /var/lib/<package>, which
# after-remove deletes on purge.
#
# Releasing here instead would give up the entries on a plain `apt remove`, which
# keeps the engines: a reinstalled app would then have nothing that republishes
# them short of reinstalling each engine.
#
# dpkg calls prerm with "upgrade" during an update, and "failed-upgrade" when
# recovering from one. Those keep the installation, so there is nothing to keep.
case "${1:-}" in
upgrade|failed-upgrade)
exit 0
;;
esac

# Ask dpkg where it put the binary rather than rebuilding /opt/<product>/...
# from a name this script cannot be told at build time.
package="${DPKG_MAINTSCRIPT_PACKAGE:-}"
[ -n "$package" ] || exit 0
command -v dpkg-query >/dev/null 2>&1 || exit 0
engine_manager="$(dpkg-query -L "$package" 2>/dev/null \
| grep -E '/cli-bin/nvpair-engine-manager$' | head -n 1)"
[ -n "$engine_manager" ] && [ -x "$engine_manager" ] || exit 0

mkdir -p "/var/lib/$package" 2>/dev/null \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex pointed this out:

before-remove.sh (line 43) ignores failure to save the executable. In after-remove.sh (line 69, keep_data starts at zero and changes only when an attempted cleanup returns an error. If the saved executable is absent, cleanup is skipped and the data directory is deleted anyway. PATH entries survive without their ownership records. This matters because dpkg removes package files before calling postrm.

&& install -m 0755 "$engine_manager" "/var/lib/$package/nvpair-engine-manager" 2>/dev/null \
|| true

exit 0
39 changes: 39 additions & 0 deletions desktop/scripts/build/macos/uninstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,45 @@ if [ -x "$CTL" ]; then
fi
fi

# Release the PATH entries this user's engines own, while the binary that owns
# the ownership records still exists.
#
# Engine-manager records what it added under the data root
# (engine-bin/engine-path/), while the entries themselves live in the login
# shell's profiles — which the purge below never touches. Removing the records
# first would strand those entries with no way left to identify them, pointing
# at engine directories this script is about to delete. Only needed when data is
# going away: keeping it keeps the engines, the records, and a reinstall's
# ability to clean up later.
#
# Runs as the invoking user for the same reason as the helper above: the
# profiles and the records are theirs, not root's.
#
# Unlike every other step here, a failure is not shrugged off. The binary
# removes what it can and reports the rest, and the records that could still
# identify whatever it left are inside the data root the purge is about to
# delete — so a failed release cancels the purge rather than making those
# entries unidentifiable. The app bundle still goes; a reinstall retries.
if [ "$PURGE_DATA" = "1" ]; then
EM="$APP_PATH/Contents/Resources/cli-bin/nvpair-engine-manager"
if [ -x "$EM" ]; then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we set PURGE_DATA back to 1 if this is false?

echo "Releasing engine PATH entries..."
# `|| released=$?` rather than a bare call: set -e is on, so a failure would
# otherwise abort before the check below could keep the data.
released=0
if [ -n "$real_user" ] && [ "$real_user" != "root" ]; then
sudo -u "$real_user" "$EM" --remove-user-path >/dev/null 2>&1 || released=$?
else
"$EM" --remove-user-path >/dev/null 2>&1 || released=$?
fi
if [ "$released" -ne 0 ]; then
echo "Warning: could not release every engine PATH entry." >&2
echo "Keeping user data so a reinstall can finish the cleanup; re-run with --purge afterwards." >&2
PURGE_DATA=0
fi
fi
fi

echo "Removing $APP_PATH ..."
rm -rf "$APP_PATH" 2>/dev/null || true

Expand Down
65 changes: 45 additions & 20 deletions desktop/src/electron/service-bridge/empty-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,46 @@ async function toggleLocalEngine(engine: string, engineType: EngineType): Promis
}
}

/**
* Update is an uninstall followed by an install. The engine-manager serializes
* per-engine ops via its lifecycle lock, so the queued install waits for the
* uninstall to finish; the uninstall's engine:state-changed briefly clears the
* pending status, then the install's progress re-establishes `installing`.
*
* It carries the user's earlier PATH answer through both steps rather than
* asking again. That answer is read fresh, because `nvpair-tui` runs its own
* engine-manager against the same PATH records and its changes never reach this
* bridge as a push.
*/
async function updateLocalEngine(
engine: string,
engineType: EngineType,
failPendingOp: (action: string, operation: 'install' | 'uninstall') => (error: string) => void
): Promise<void> {
const supervisor = getModularSupervisor()
let pathManaged = getModularBridgeState().localEnginePathManaged(engineType)
try {
const status = await supervisor.callProcess('broker', 'engine:status', { engine })
pathManaged = booleanValue(objectValue(status)?.path_managed)
} catch {
// The last pushed state is the best answer left.
}
supervisor.sendProcess(
'broker',
'engine:uninstall',
pathManaged ? { engine, path: true } : { engine },
failPendingOp('update', 'uninstall'),
true
)
supervisor.sendProcess(
'broker',
'engine:install',
{ engine, start: true, path: pathManaged },
failPendingOp('update', 'install'),
true
)
}

/**
* Dispatch a UI engine command to the local `nvpair-engine-manager`. Lifecycle and
* model operations are **fire-and-forget**: the engine-manager runs each in its
Expand Down Expand Up @@ -203,41 +243,26 @@ function routeEngineManagerCommand(payload: WsInvokeRequest<'engine:command'>):
supervisor.sendProcess(
'broker',
'engine:install',
{ engine, start: true },
{ engine, start: true, path: payload.path === true },
failPendingOp('install', 'install'),
true
)
break
case 'uninstall':
state.beginLocalEngineOp(payload.engineType, 'uninstalling')
// An absent answer is forwarded as absent: the engine-manager then
// keeps its claim on PATH entries instead of handing them over.
supervisor.sendProcess(
'broker',
'engine:uninstall',
{ engine },
payload.path === undefined ? { engine } : { engine, path: payload.path },
failPendingOp('uninstall', 'uninstall'),
true
)
break
case 'update':
// The engine-manager serializes per-engine ops via its lifecycle
// lock, so the queued install waits for the uninstall to finish. The
// uninstall's engine:state-changed briefly clears this, then the
// install's progress re-establishes `installing`.
state.beginLocalEngineOp(payload.engineType, 'installing')
supervisor.sendProcess(
'broker',
'engine:uninstall',
{ engine },
failPendingOp('update', 'uninstall'),
true
)
supervisor.sendProcess(
'broker',
'engine:install',
{ engine, start: true },
failPendingOp('update', 'install'),
true
)
void updateLocalEngine(engine, payload.engineType, failPendingOp)
break
case 'toggle':
void toggleLocalEngine(engine, payload.engineType)
Expand Down
16 changes: 12 additions & 4 deletions desktop/src/electron/service-bridge/modular-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -903,7 +903,7 @@ class ModularBridgeState {
*/
private engineManagerFacts = new Map<
EngineType,
{ installed: boolean; running: boolean; port: number }
{ installed: boolean; running: boolean; port: number; pathManaged: boolean }
>()
/**
* Local model lists pulled from `nvpair-engine-manager`'s `list_models` action by
Expand Down Expand Up @@ -1368,7 +1368,8 @@ class ModularBridgeState {
this.engineManagerFacts.set(engineType, {
installed: booleanValue(obj.installed),
running: booleanValue(obj.running),
port: numberValue(obj.port)
port: numberValue(obj.port),
pathManaged: booleanValue(obj.path_managed)
})
// A fresh authoritative state is the resolution of whatever op was in
// flight (start/stop done, install `done`+installed, uninstall removed).
Expand All @@ -1378,6 +1379,11 @@ class ModularBridgeState {
this.emitLocalEngineStatus(engineType)
}

/** Whether PAIR owns a PATH entry for a local engine, per the engine-manager. */
localEnginePathManaged(engineType: EngineType): boolean {
return this.engineManagerFacts.get(engineType)?.pathManaged ?? false
}

/**
* Begin an optimistic transitional status for a local-engine lifecycle op so
* the UI shows a spinner / progress line immediately. The backend never emits
Expand Down Expand Up @@ -2068,7 +2074,8 @@ class ModularBridgeState {
nodeId,
processStatus: pending,
enginePort: facts && facts.running && facts.port > 0 ? facts.port : null,
proxyPort: isProxyEngine(engineType) ? this.getProxyPort(engineType) : null
proxyPort: isProxyEngine(engineType) ? this.getProxyPort(engineType) : null,
pathManaged: facts?.pathManaged ?? false
}
}

Expand All @@ -2091,7 +2098,8 @@ class ModularBridgeState {
// Each proxy-fronted engine has its own broker proxy
// (`ollama-proxy` / `lmstudio-proxy`); report that engine's bound
// proxy port. Loopback-only engines get null.
proxyPort: isProxyEngine(engineType) ? this.getProxyPort(engineType) : null
proxyPort: isProxyEngine(engineType) ? this.getProxyPort(engineType) : null,
pathManaged: facts.pathManaged
}
}

Expand Down
Loading
Loading