Skip to content

Commit a6828dc

Browse files
committed
feat(engine-manager): publish installed engine CLIs on the user's PATH
When PAIR installs Ollama or LM Studio, add that engine's command-line directory to the installing user's persistent PATH on that device, and give back only what PAIR recorded when the engine or the application is removed. Open a new terminal after either operation. Local installs only. An install driven by a cluster peer deliberately skips the PATH step, because rewriting the login shell of whoever is sitting at the target node is not a paired peer's decision. Ownership is recorded outside the engine directory and before the PATH is touched, so a crash mid-update is retryable and an uninstall can delete exactly what PAIR added. Existing entries, user-edited shell snippets, and engines the user installed themselves are preserved. The record also notes that PAIR ran the installer, which is what lets a reinstall re-adopt an engine whose vendor owns its location: LM Studio writes ~/.lmstudio, so the path is no evidence of who put it there. A record that exists but cannot be parsed fails an uninstall rather than reporting a clean one. Windows persists through HKCU\Environment; the other platforms append a self-identifying block to the login shell's profiles. Both run under a cross-process lock that covers the whole read-modify-write, bounded so a hung peer times out instead of wedging the operation, and both publish through a temporary file and an atomic rename. A profile rewrite re-checks the file's fingerprint before renaming, so a concurrent editor's save is refused rather than silently discarded. The platform uninstallers release the entries before deleting the data directory that holds the records, and keep that data when the release fails so a reinstall can finish the cleanup. Two changes are adaptations to this tree rather than part of the feature. runCommand takes a variadic environment for install commands that declare overrides, so the launch closure in lifecycle.go accepts and ignores it; a launch carries its own environment separately. writeJSONAtomic keeps this tree's unique temporary name and gains the file flush and directory flush that the ownership records need, since a rename alone says nothing about bytes reaching stable storage. Signed-off-by: Chris Kelsey <ckelsey@nvidia.com>
1 parent 6abb9ec commit a6828dc

38 files changed

Lines changed: 3059 additions & 85 deletions

‎desktop/electron-builder.config.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
// signing and notarization live outside this repository, so anything built here
1111
// is unsigned. Released builds come from NVIDIA's own signed pipeline.
1212
import { readFileSync, readdirSync } from 'node:fs'
13+
import { join } from 'node:path'
1314
import type { Configuration } from 'electron-builder'
1415
import electronPkg from 'electron/package.json'
1516
import pkg from './package.json'
@@ -390,7 +391,15 @@ const config: Configuration = {
390391
},
391392
deb: {
392393
afterInstall: 'scripts/build/linux/after-install.sh',
393-
afterRemove: 'scripts/build/linux/after-remove.sh'
394+
afterRemove: 'scripts/build/linux/after-remove.sh',
395+
// prerm has no dedicated option, so it goes through the raw fpm passthrough.
396+
// It has to be prerm rather than postrm because dpkg deletes the package's
397+
// files before postrm runs, and the PATH cleanup runs a binary from /opt —
398+
// see before-remove.sh. Unlike afterInstall/afterRemove, fpm arguments are
399+
// forwarded verbatim: no ${macro} expansion, and the path is resolved
400+
// against fpm's working directory rather than this file, so pass an
401+
// absolute one.
402+
fpm: [`--before-remove=${join(__dirname, 'scripts/build/linux/before-remove.sh')}`]
394403
},
395404
mac: {
396405
executableName: APP_EXECUTABLE_NAME,

‎desktop/scripts/build/installer.nsh‎

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,45 @@
7878
ClearErrors
7979
!macroend
8080

81+
; Release the PATH entries this user's engines own, before the data directory
82+
; that records them is deleted.
83+
;
84+
; Engine-manager writes its ownership receipts under the data root
85+
; (engine-bin\engine-path\), while the entries themselves live in
86+
; HKCU\Environment\Path — outside everything pairRemoveUserData touches. Deleting
87+
; the receipts first would strand those entries with no record left to remove
88+
; them by, pointing at engine directories this uninstall is about to delete.
89+
;
90+
; Runs the shipped binary rather than editing the registry here, so one
91+
; implementation owns the format and the ownership rules. It must run before the
92+
; template's RMDir /r $INSTDIR, which is why this lives in customUnInstall.
93+
; nsExec::ExecToLog never aborts the uninstaller.
94+
;
95+
; The exit code is kept in $7 because the caller has to act on it. A failed
96+
; release is not a no-op: the binary removes what it can and reports the rest,
97+
; so some entries may be gone and some may remain — and the records that could
98+
; still identify the remaining ones are inside the data root the caller is about
99+
; to delete.
100+
;
101+
; The child inherits this process's environment and user, so it resolves the
102+
; same profile pairRemoveUserData does. An elevated uninstall authenticated as a
103+
; different administrator targets that account instead — the same limitation the
104+
; data removal above already has.
105+
!macro pairReleaseEnginePathEntries
106+
DetailPrint "Releasing engine PATH entries..."
107+
nsExec::ExecToLog '"$INSTDIR\resources\cli-bin\nvpair-engine-manager.exe" --remove-user-path'
108+
Pop $7
109+
!macroend
110+
111+
; The release failed, so the ownership records are the only thing that can still
112+
; identify the entries it left behind. Deleting the data root would strand them
113+
; permanently — the outcome the whole ordering above exists to avoid — so the
114+
; data stays and the user is told why. A reinstall retries the cleanup.
115+
!macro pairWarnPathEntriesRemain
116+
DetailPrint "Could not release every engine PATH entry; keeping user data so a reinstall can retry."
117+
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
118+
!macroend
119+
81120
; Best-effort: when the user opts to remove data, stop any process whose
82121
; executable lives UNDER one of the data roots (e.g. an engine like Ollama
83122
; running from %LOCALAPPDATA%\Nvidia Corporation\Personal AI Router\engine-bin\)
@@ -314,9 +353,14 @@
314353
pairDataDone:
315354
${endif}
316355
${if} $8 == "1"
317-
!insertmacro pairKillProcessesInDataDirs
318-
!insertmacro pairRemoveUserData
319-
!insertmacro pairWarnIfDataRemains
356+
!insertmacro pairReleaseEnginePathEntries
357+
${if} $7 == "0"
358+
!insertmacro pairKillProcessesInDataDirs
359+
!insertmacro pairRemoveUserData
360+
!insertmacro pairWarnIfDataRemains
361+
${else}
362+
!insertmacro pairWarnPathEntriesRemain
363+
${endif}
320364
${endif}
321365
${endif}
322366
!macroend
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#!/bin/bash
2+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
# Personal AI Router Debian pre-remove. Releases the PATH entries this user's
6+
# engines own. No `set -e`, and a trailing `exit 0`, so a best-effort step never
7+
# fails the removal.
8+
#
9+
# Unlike after-install.sh and after-remove.sh, this one reaches fpm through the
10+
# raw passthrough in electron-builder.config.ts, so ${macro} is NOT expanded
11+
# here. It finds its own paths at runtime instead.
12+
#
13+
# WHY prerm AND NOT postrm. Engine-manager records what it added to PATH under
14+
# the user's data root (engine-bin/engine-path/), while the entries themselves
15+
# live in the login shell's profiles, which no maintainer script touches.
16+
# after-remove handles the data root on `purge` -- but dpkg deletes the
17+
# package's files before postrm runs, so by then the binary that understands
18+
# those records is gone. prerm is the last point at which both still exist.
19+
#
20+
# This runs on `apt remove` as well as `apt purge`, so a plain remove gives up
21+
# its PATH entries even though it keeps the engines. That is the recoverable
22+
# direction: the drain releases each entry but keeps the record's note that PAIR
23+
# installed the engine, so a reinstall re-adopts it and republishes. Deleting
24+
# the record outright is what made this unrecoverable for an engine whose CLI
25+
# lives outside PAIR's install directory -- nothing else tells it apart from an
26+
# engine the user installed. The alternative -- entries no tool can identify,
27+
# pointing into a directory a later purge deletes -- is worse either way.
28+
#
29+
# dpkg calls prerm with "upgrade" during an update, and "failed-upgrade" when
30+
# recovering from one. Those keep the installation, so leave PATH alone.
31+
case "${1:-}" in
32+
upgrade|failed-upgrade)
33+
exit 0
34+
;;
35+
esac
36+
37+
# Ask dpkg where it put the binary rather than rebuilding /opt/<product>/...
38+
# from a name this script cannot be told at build time.
39+
command -v dpkg-query >/dev/null 2>&1 || exit 0
40+
engine_manager="$(dpkg-query -L "${DPKG_MAINTSCRIPT_PACKAGE:-}" 2>/dev/null \
41+
| grep -E '/cli-bin/nvpair-engine-manager$' | head -n 1)"
42+
[ -n "$engine_manager" ] && [ -x "$engine_manager" ] || exit 0
43+
44+
# prerm runs as root, while the records, the dotfiles, and $XDG_CONFIG_HOME all
45+
# belong to the user who ran the app. Resolve that user the same way
46+
# after-remove resolves the data root on purge, and run as them so the binary
47+
# reads the environment it wrote under. Best-effort: on a multi-user box, other
48+
# users' entries are left for their own reinstall to reclaim.
49+
real_user="${SUDO_USER:-}"
50+
if [ -z "$real_user" ] && command -v logname >/dev/null 2>&1; then
51+
real_user="$(logname 2>/dev/null || true)"
52+
fi
53+
[ -n "$real_user" ] && [ "$real_user" != root ] || exit 0
54+
55+
if command -v runuser >/dev/null 2>&1; then
56+
runuser -u "$real_user" -- "$engine_manager" --remove-user-path >/dev/null 2>&1 || true
57+
else
58+
su -s /bin/sh -c "'$engine_manager' --remove-user-path" "$real_user" >/dev/null 2>&1 || true
59+
fi
60+
61+
exit 0

‎desktop/scripts/build/macos/uninstall.sh‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,45 @@ if [ -x "$CTL" ]; then
9898
fi
9999
fi
100100

101+
# Release the PATH entries this user's engines own, while the binary that owns
102+
# the ownership records still exists.
103+
#
104+
# Engine-manager records what it added under the data root
105+
# (engine-bin/engine-path/), while the entries themselves live in the login
106+
# shell's profiles — which the purge below never touches. Removing the records
107+
# first would strand those entries with no way left to identify them, pointing
108+
# at engine directories this script is about to delete. Only needed when data is
109+
# going away: keeping it keeps the engines, the records, and a reinstall's
110+
# ability to clean up later.
111+
#
112+
# Runs as the invoking user for the same reason as the helper above: the
113+
# profiles and the records are theirs, not root's.
114+
#
115+
# Unlike every other step here, a failure is not shrugged off. The binary
116+
# removes what it can and reports the rest, and the records that could still
117+
# identify whatever it left are inside the data root the purge is about to
118+
# delete — so a failed release cancels the purge rather than making those
119+
# entries unidentifiable. The app bundle still goes; a reinstall retries.
120+
if [ "$PURGE_DATA" = "1" ]; then
121+
EM="$APP_PATH/Contents/Resources/cli-bin/nvpair-engine-manager"
122+
if [ -x "$EM" ]; then
123+
echo "Releasing engine PATH entries..."
124+
# `|| released=$?` rather than a bare call: set -e is on, so a failure would
125+
# otherwise abort before the check below could keep the data.
126+
released=0
127+
if [ -n "$real_user" ] && [ "$real_user" != "root" ]; then
128+
sudo -u "$real_user" "$EM" --remove-user-path >/dev/null 2>&1 || released=$?
129+
else
130+
"$EM" --remove-user-path >/dev/null 2>&1 || released=$?
131+
fi
132+
if [ "$released" -ne 0 ]; then
133+
echo "Warning: could not release every engine PATH entry." >&2
134+
echo "Keeping user data so a reinstall can finish the cleanup; re-run with --purge afterwards." >&2
135+
PURGE_DATA=0
136+
fi
137+
fi
138+
fi
139+
101140
echo "Removing $APP_PATH ..."
102141
rm -rf "$APP_PATH" 2>/dev/null || true
103142

‎docs/engine-lifecycle.mdx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,23 @@ Refer also to [Getting started](getting-started.mdx).
7070
You can install a supported engine during first-run setup or later from
7171
**Engine settings**.
7272

73+
PAIR automatically adds the installed engine's command-line tools (`ollama` or
74+
`lms`) to your PATH. Open a new terminal after installation; on Windows, you may
75+
need to restart your terminal app.
76+
77+
PATH setup only ever changes the account that asked for the install. Installing
78+
an engine on another node from this one leaves that node's PATH alone, and an
79+
engine that was already on the machine before PAIR keeps whatever its own
80+
installer set up.
81+
82+
If PATH setup fails, PAIR reports it as a dismissible warning. The install still
83+
succeeds, the engine still starts, and it works normally through its full path.
84+
85+
On uninstall, PAIR removes only the PATH entries it recorded adding for an
86+
engine it installed. Pre-existing entries and user-edited shell blocks are
87+
preserved. Installations without an ownership record are left unchanged.
88+
Uninstalling PAIR itself releases any entries its engines still own.
89+
7390
When you install an engine, consider the following:
7491

7592
- Choosing install makes it an **NVPAIR-installed** engine. PAIR downloads it,

‎docs/getting-started.mdx‎

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -351,51 +351,59 @@ current URL.
351351
### Using an Engine's Own Command Line
352352

353353
An engine PAIR installed for you is a normal installation, and you can drive it
354-
with its own command-line interface (CLI). Two things make that less obvious:
354+
with its own command-line interface (CLI).
355355

356-
- The binaries are not on your `PATH` yet.
357-
- The engine is not on the port its CLI expects by default.
356+
When PAIR installs an engine, it adds that engine's command directory to your
357+
user `PATH`, so `ollama` or `lms` works by name. **Open a new terminal
358+
afterwards** — a shell that was already running keeps the `PATH` it started with.
359+
Uninstalling the engine removes the entry again. If PAIR could not update your
360+
`PATH`, it tells you so and the engine still works; use the full path below.
361+
362+
One thing still catches people out: the engine is not on the port its CLI expects
363+
by default. Refer to [Set the port explicitly](#set-the-port-explicitly) below.
358364

359365
**Ollama** is installed inside PAIR's own data directory:
360366

361-
| Platform | Path |
362-
| --- | --- |
363-
| Windows | `%LOCALAPPDATA%\Nvidia Corporation\Personal AI Router\engine-bin\ollama\ollama.exe` |
364-
| Linux | `~/.config/Nvidia Corporation/Personal AI Router/engine-bin/ollama/bin/ollama` |
367+
```powershell
368+
$env:OLLAMA_HOST = "127.0.0.1:11435"
369+
ollama list
370+
```
365371

366-
On Linux it needs its bundled libraries on the library path:
372+
On Linux, Ollama also needs its bundled libraries on the library path:
367373

368374
```bash
369375
ENGINE="$HOME/.config/Nvidia Corporation/Personal AI Router/engine-bin/ollama"
370-
LD_LIBRARY_PATH="$ENGINE/lib/ollama" OLLAMA_HOST=127.0.0.1:11435 "$ENGINE/bin/ollama" list
371-
```
372-
373-
```powershell
374-
$ollama = "$env:LOCALAPPDATA\Nvidia Corporation\Personal AI Router\engine-bin\ollama\ollama.exe"
375-
$env:OLLAMA_HOST = "127.0.0.1:11435"
376-
& $ollama list
376+
LD_LIBRARY_PATH="$ENGINE/lib/ollama" OLLAMA_HOST=127.0.0.1:11435 ollama list
377377
```
378378

379379
**LM Studio** installs to its own standard location instead, because PAIR runs its
380-
official installer: `~/.lmstudio/bin/lms`, or
381-
`%USERPROFILE%\.lmstudio\bin\lms.exe` on Windows.
380+
official installer:
382381

383382
```bash
384-
~/.lmstudio/bin/lms status
383+
lms status
385384
```
386385

387-
**Set the port explicitly.** Set `OLLAMA_HOST` to the engine's own port.
388-
Otherwise, the CLI connects to `11434`, which is PAIR's proxy, and `ollama list`
389-
returns the cluster's view instead of the local machine's. Use the **Server**
390-
value under **Engine settings > Ports** to address the local engine directly.
391-
It defaults to `11435` for Ollama and `1235` for LM Studio.
386+
If you need the full path — because `PATH` setup failed, or you are scripting
387+
against a specific installation — these are the locations:
388+
389+
| Engine | Platform | Path |
390+
| --- | --- | --- |
391+
| Ollama | Windows | `%LOCALAPPDATA%\Nvidia Corporation\Personal AI Router\engine-bin\ollama\ollama.exe` |
392+
| Ollama | Linux | `~/.config/Nvidia Corporation/Personal AI Router/engine-bin/ollama/bin/ollama` |
393+
| LM Studio | Windows | `%USERPROFILE%\.lmstudio\bin\lms.exe` |
394+
| LM Studio | Linux, macOS | `~/.lmstudio/bin/lms` |
395+
396+
#### Set the Port Explicitly
397+
398+
Set `OLLAMA_HOST` to the engine's own port. Otherwise, the CLI connects to
399+
`11434`, which is PAIR's proxy, and `ollama list` returns the cluster's view
400+
instead of the local machine's. Use the **Server** value under
401+
**Engine settings > Ports** to address the local engine directly. It defaults to
402+
`11435` for Ollama and `1235` for LM Studio.
392403

393404
Use the proxy port to check what your cluster can serve. Use the engine port to
394405
check what is installed on the local machine.
395406

396-
PAIR does not put these binaries on `PATH` yet. Until then, use the full path or
397-
add an alias yourself.
398-
399407
## Use PAIR with Existing Applications
400408

401409
Any client that lets you set a base URL and a model name can use PAIR — Hermes,

‎docs/known-issues.mdx‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,6 @@ This matters most for a genuinely headless deployment, where those operations ha
8181
no other route on that machine. Refer to
8282
[What the Terminal Interface Cannot Do](terminal-interface.mdx#what-the-terminal-interface-cannot-do).
8383

84-
## Engine Binaries Are Not on Your PATH
85-
86-
PAIR does not add the engines it installs to your `PATH`, so running an engine's
87-
CLI means using the full path. Refer to
88-
[Using an engine's own command line](getting-started.mdx#using-an-engines-own-command-line)
89-
for the locations. This is on the roadmap.
90-
9184
## Platform Limits Worth Knowing
9285

9386
- **The Linux desktop installer is a `.deb` only.** On RPM-based distributions,

‎scripts/wipe-app-data.ps1‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,16 @@ $Tmp = $env:TEMP
122122
if (-not $Tmp) { $Tmp = $env:TMP }
123123
$ControlDir = Join-Path $Tmp "nvpair-$Scope"
124124

125+
# The engine PATH entry in HKCU\Environment is deliberately NOT listed. Wiping
126+
# the data root deletes engine-manager's ownership records along with the
127+
# engines they describe, so nothing here could identify the entry to remove; the
128+
# real uninstaller drains them first instead (desktop/scripts/build/installer.nsh).
129+
# Recovery on the next install is partial, so prefer the real uninstaller. An
130+
# engine PAIR installed into its own directory is recognized by the executable's
131+
# location and reclaims the entry it finds already present. One whose vendor
132+
# owns its location has no such evidence once the record is gone, so its entry
133+
# is left orphaned until the user removes it.
134+
#
125135
# Append-only target list. Never remove entries — only append.
126136
$Targets = @(
127137
@{ Path = $CurrentRoot; Reason = 'Current shared Electron + backend app data root' },

‎scripts/wipe-app-data.sh‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,16 @@ RUNTIME_BASE="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}"
125125
RUNTIME_BASE="${RUNTIME_BASE%/}"
126126
CONTROL_DIR="$RUNTIME_BASE/nvpair-$SCOPE"
127127

128+
# The engine PATH blocks in the user's shell profiles are deliberately NOT
129+
# listed. Wiping the data root deletes engine-manager's ownership records along
130+
# with the engines they describe, so nothing here could identify the blocks to
131+
# remove; the real uninstallers drain them first instead (desktop/scripts/build).
132+
# Recovery on the next install is partial, so prefer the real uninstallers. An
133+
# engine PAIR installed into its own directory is recognized by the executable's
134+
# location and re-adopts the block it finds already present. One whose vendor
135+
# owns its location — LM Studio writes ~/.lmstudio — has no such evidence once
136+
# the record is gone, so its block is left orphaned until the user removes it.
137+
#
128138
# Append-only target list (path|reason). Never remove entries — only append.
129139
TARGETS=()
130140
TARGETS+=("$CURRENT_ROOT|Current shared Electron + backend app data root")

‎services/nvpair-engine-manager/MANIFEST.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,21 @@ and recovery. Editing `args`/`start` directly remains trusted manifest authoring
144144
| `run` | string[] | no | Argv to execute after download (e.g. run the installer, extract the archive). Placeholders resolved; OS env refs expanded. Requires a `fetch` (the artifact it unpacks). |
145145
| `script` | string[] | no | **Escape hatch** for vendors that only ship a script installer. Runs **without** checksum verification (logged as unpinned) and replaces `fetch`+`run`. Prefer `fetch`+`run` whenever the vendor publishes a script or artifact: download it first, then execute the local file. **Make failures loud:** a piped bootstrap such as `curl … \| bash` can mask a failed fetch, while a separate fetch prevents the run and reports the error. |
146146
| `mode` | string | no | `"user"` (default) or `"admin"`. The runner **refuses** `"admin"` (engine-manager is user-mode only); it is a deliberate, flagged exception, not a default. |
147+
| `env` | object | no | Literal `KEY: "value"` overrides layered onto the inherited environment of the **installer subprocess only** — not this service's environment and not the engine's runtime (that is `runtime.env`). Values are used verbatim: no placeholders, no OS env expansion. |
148+
149+
`install.env` exists so a vendor quirk stays in the manifest. LM Studio's
150+
installer edits the user's PATH unless `LMS_NO_MODIFY_PATH=1` is set, and PAIR
151+
publishes that directory itself with an ownership record it can later remove —
152+
two writers, one removable. Declaring it keeps the runner engine-agnostic, so a
153+
third-party engine with the same quirk needs no Go change:
154+
155+
```json
156+
"install": {
157+
"env": { "LMS_NO_MODIFY_PATH": "1" },
158+
"fetch": { "url": "https://lmstudio.ai/install.sh" },
159+
"run": ["bash", "{download}"]
160+
}
161+
```
147162

148163
### Runtime
149164

0 commit comments

Comments
 (0)