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
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 has to be prerm rather than postrm because dpkg deletes the package's
// files before postrm runs, and the PATH cleanup runs a binary from /opt —
// 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
61 changes: 61 additions & 0 deletions desktop/scripts/build/linux/before-remove.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/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. Releases the PATH entries this user's
# engines own. 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 prerm AND NOT postrm. 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.
# after-remove handles the data root on `purge` -- but dpkg deletes the
# package's files before postrm runs, so by then the binary that understands
# those records is gone. prerm is the last point at which both still exist.
#
# This runs on `apt remove` as well as `apt purge`, so a plain remove gives up
# its PATH entries even though it keeps the engines. That is the recoverable
# direction: the drain releases each entry but keeps the record's note that PAIR
# installed the engine, so a reinstall re-adopts it and republishes. Deleting
# the record outright is what made this unrecoverable for an engine whose CLI
# lives outside PAIR's install directory -- nothing else tells it apart from an
# engine the user installed. The alternative -- entries no tool can identify,
# pointing into a directory a later purge deletes -- is worse either way.
#
# dpkg calls prerm with "upgrade" during an update, and "failed-upgrade" when
# recovering from one. Those keep the installation, so leave PATH alone.
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.
command -v dpkg-query >/dev/null 2>&1 || exit 0
engine_manager="$(dpkg-query -L "${DPKG_MAINTSCRIPT_PACKAGE:-}" 2>/dev/null \
| grep -E '/cli-bin/nvpair-engine-manager$' | head -n 1)"
[ -n "$engine_manager" ] && [ -x "$engine_manager" ] || exit 0

# prerm runs as root, while the records, the dotfiles, and $XDG_CONFIG_HOME all
# belong to the user who ran the app. Resolve that user the same way
# after-remove resolves the data root on purge, and run as them so the binary
# reads the environment it wrote under. Best-effort: on a multi-user box, other
# users' entries are left for their own reinstall to reclaim.
real_user="${SUDO_USER:-}"
if [ -z "$real_user" ] && command -v logname >/dev/null 2>&1; then
real_user="$(logname 2>/dev/null || true)"
fi
[ -n "$real_user" ] && [ "$real_user" != root ] || exit 0

if command -v runuser >/dev/null 2>&1; then
runuser -u "$real_user" -- "$engine_manager" --remove-user-path >/dev/null 2>&1 || true
else
su -s /bin/sh -c "'$engine_manager' --remove-user-path" "$real_user" >/dev/null 2>&1 || true
fi

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
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
17 changes: 17 additions & 0 deletions docs/engine-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,23 @@ Refer also to [Getting started](getting-started.mdx).
You can install a supported engine during first-run setup or later from
**Engine settings**.

PAIR automatically adds the installed engine's command-line tools (`ollama` or
`lms`) to your PATH. Open a new terminal after installation; on Windows, you may
need to restart your terminal app.

PATH setup only ever changes the account that asked for the install. Installing
an engine on another node from this one leaves that node's PATH alone, and an
engine that was already on the machine before PAIR keeps whatever its own
installer set up.

If PATH setup fails, PAIR reports it as a dismissible warning. The install still
succeeds, the engine still starts, and it works normally through its full path.

On uninstall, PAIR removes only the PATH entries it recorded adding for an
engine it installed. Pre-existing entries and user-edited shell blocks are
preserved. Installations without an ownership record are left unchanged.
Uninstalling PAIR itself releases any entries its engines still own.

When you install an engine, consider the following:

- Choosing install makes it an **NVPAIR-installed** engine. PAIR downloads it,
Expand Down
60 changes: 34 additions & 26 deletions docs/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -351,51 +351,59 @@ current URL.
### Using an Engine's Own Command Line

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

- The binaries are not on your `PATH` yet.
- The engine is not on the port its CLI expects by default.
When PAIR installs an engine, it adds that engine's command directory to your
user `PATH`, so `ollama` or `lms` works by name. **Open a new terminal
afterwards** — a shell that was already running keeps the `PATH` it started with.
Uninstalling the engine removes the entry again. If PAIR could not update your
`PATH`, it tells you so and the engine still works; use the full path below.

One thing still catches people out: the engine is not on the port its CLI expects
by default. Refer to [Set the port explicitly](#set-the-port-explicitly) below.

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

| Platform | Path |
| --- | --- |
| Windows | `%LOCALAPPDATA%\Nvidia Corporation\Personal AI Router\engine-bin\ollama\ollama.exe` |
| Linux | `~/.config/Nvidia Corporation/Personal AI Router/engine-bin/ollama/bin/ollama` |
```powershell
$env:OLLAMA_HOST = "127.0.0.1:11435"
ollama list
```

On Linux it needs its bundled libraries on the library path:
On Linux, Ollama also needs its bundled libraries on the library path:

```bash
ENGINE="$HOME/.config/Nvidia Corporation/Personal AI Router/engine-bin/ollama"
LD_LIBRARY_PATH="$ENGINE/lib/ollama" OLLAMA_HOST=127.0.0.1:11435 "$ENGINE/bin/ollama" list
```

```powershell
$ollama = "$env:LOCALAPPDATA\Nvidia Corporation\Personal AI Router\engine-bin\ollama\ollama.exe"
$env:OLLAMA_HOST = "127.0.0.1:11435"
& $ollama list
LD_LIBRARY_PATH="$ENGINE/lib/ollama" OLLAMA_HOST=127.0.0.1:11435 ollama list
```

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

```bash
~/.lmstudio/bin/lms status
lms status
```

**Set the port explicitly.** Set `OLLAMA_HOST` to the engine's own port.
Otherwise, the CLI connects to `11434`, which is PAIR's proxy, and `ollama list`
returns the cluster's view instead of the local machine's. Use the **Server**
value under **Engine settings > Ports** to address the local engine directly.
It defaults to `11435` for Ollama and `1235` for LM Studio.
If you need the full path — because `PATH` setup failed, or you are scripting
against a specific installation — these are the locations:

| Engine | Platform | Path |
| --- | --- | --- |
| Ollama | Windows | `%LOCALAPPDATA%\Nvidia Corporation\Personal AI Router\engine-bin\ollama\ollama.exe` |
| Ollama | Linux | `~/.config/Nvidia Corporation/Personal AI Router/engine-bin/ollama/bin/ollama` |
| LM Studio | Windows | `%USERPROFILE%\.lmstudio\bin\lms.exe` |
| LM Studio | Linux, macOS | `~/.lmstudio/bin/lms` |

#### Set the Port Explicitly

Set `OLLAMA_HOST` to the engine's own port. Otherwise, the CLI connects to
`11434`, which is PAIR's proxy, and `ollama list` returns the cluster's view
instead of the local machine's. Use the **Server** value under
**Engine settings > Ports** to address the local engine directly. It defaults to
`11435` for Ollama and `1235` for LM Studio.

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

PAIR does not put these binaries on `PATH` yet. Until then, use the full path or
add an alias yourself.

## Use PAIR with Existing Applications

Any client that lets you set a base URL and a model name can use PAIR — Hermes,
Expand Down
7 changes: 0 additions & 7 deletions docs/known-issues.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,6 @@ This matters most for a genuinely headless deployment, where those operations ha
no other route on that machine. Refer to
[What the Terminal Interface Cannot Do](terminal-interface.mdx#what-the-terminal-interface-cannot-do).

## Engine Binaries Are Not on Your PATH

PAIR does not add the engines it installs to your `PATH`, so running an engine's
CLI means using the full path. Refer to
[Using an engine's own command line](getting-started.mdx#using-an-engines-own-command-line)
for the locations. This is on the roadmap.

## Platform Limits Worth Knowing

- **The Linux desktop installer is a `.deb` only.** On RPM-based distributions,
Expand Down
10 changes: 10 additions & 0 deletions scripts/wipe-app-data.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ $Tmp = $env:TEMP
if (-not $Tmp) { $Tmp = $env:TMP }
$ControlDir = Join-Path $Tmp "nvpair-$Scope"

# The engine PATH entry in HKCU\Environment is deliberately NOT listed. Wiping
# the data root deletes engine-manager's ownership records along with the
# engines they describe, so nothing here could identify the entry to remove; the
# real uninstaller drains them first instead (desktop/scripts/build/installer.nsh).
# Recovery on the next install is partial, so prefer the real uninstaller. An
# engine PAIR installed into its own directory is recognized by the executable's
# location and reclaims the entry it finds already present. One whose vendor
# owns its location has no such evidence once the record is gone, so its entry
# is left orphaned until the user removes it.
#
# Append-only target list. Never remove entries — only append.
$Targets = @(
@{ Path = $CurrentRoot; Reason = 'Current shared Electron + backend app data root' },
Expand Down
10 changes: 10 additions & 0 deletions scripts/wipe-app-data.sh
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,16 @@ RUNTIME_BASE="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}"
RUNTIME_BASE="${RUNTIME_BASE%/}"
CONTROL_DIR="$RUNTIME_BASE/nvpair-$SCOPE"

# The engine PATH blocks in the user's shell profiles are deliberately NOT
# listed. Wiping the data root deletes engine-manager's ownership records along
# with the engines they describe, so nothing here could identify the blocks to
# remove; the real uninstallers drain them first instead (desktop/scripts/build).
# Recovery on the next install is partial, so prefer the real uninstallers. An
# engine PAIR installed into its own directory is recognized by the executable's
# location and re-adopts the block it finds already present. One whose vendor
# owns its location — LM Studio writes ~/.lmstudio — has no such evidence once
# the record is gone, so its block is left orphaned until the user removes it.
#
# Append-only target list (path|reason). Never remove entries — only append.
TARGETS=()
TARGETS+=("$CURRENT_ROOT|Current shared Electron + backend app data root")
Expand Down
15 changes: 15 additions & 0 deletions services/nvpair-engine-manager/MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,21 @@ and recovery. Editing `args`/`start` directly remains trusted manifest authoring
| `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). |
| `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. |
| `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. |
| `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. |

`install.env` exists so a vendor quirk stays in the manifest. LM Studio's
installer edits the user's PATH unless `LMS_NO_MODIFY_PATH=1` is set, and PAIR
publishes that directory itself with an ownership record it can later remove —
two writers, one removable. Declaring it keeps the runner engine-agnostic, so a
third-party engine with the same quirk needs no Go change:

```json
"install": {
"env": { "LMS_NO_MODIFY_PATH": "1" },
"fetch": { "url": "https://lmstudio.ai/install.sh" },
"run": ["bash", "{download}"]
}
```

### Runtime

Expand Down
Loading
Loading