Skip to content

Publish installed engine CLIs on the user's PATH - #113

Open
ckelseynv wants to merge 4 commits into
developfrom
feat/engine-install-path-gh
Open

ckelseynv wants to merge 4 commits into
developfrom
feat/engine-install-path-gh

Conversation

@ckelseynv

@ckelseynv ckelseynv commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Changelog title

Installed engines can be added to your PATH

Changelog body

  • Installing Ollama or LM Studio through PAIR now asks whether to put its command-line tool on your PATH, so ollama or lms works in a new terminal without extra setup. First-run setup asks once for every engine you selected, and the terminal interface asks the same question.
  • Uninstalling an engine that PAIR added to your PATH offers to remove that entry too. PAIR only ever removes entries it added, and leaves your own PATH entries and shell configuration untouched. Uninstalling PAIR together with its data (on Linux, apt purge) releases any entries its engines still own.

Bumps

  • services: minor
  • nvpair-cluster-manager: none
  • nvpair-engine-manager: minor
  • nvpair-errors: none
  • nvpair-job-scheduler: none
  • nvpair-manual-nodes: none
  • nvpair-node-info: none
  • nvpair-node-scanner: none
  • nvpair-node-settings: none
  • nvpair-proxy: none
  • nvpair-tui: minor
  • nvpair-ui-broker: none
  • nvpair-workload-manager: none

Summary

When PAIR installs Ollama or LM Studio, it can add that engine's command-line
directory to the installing user's persistent PATH on that device, and gives
back only what it recorded once 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.

The user decides

Every PATH change needs the user's consent, carried as path on
engine:install and engine:uninstall. A missing or false path never touches
PATH, so a client that does not ask the user changes nothing. On uninstall the
answer has three values: true removes the entries, false keeps them and
hands them to the user, and a missing path (the client did not ask) keeps
them and PAIR's claim, so a stale or unasking client can never strand an entry
with no record that could remove it. EngineStatus gains path_managed, true
while the record holds an entry PAIR actually wrote, so a client asks about
removal only when there is something to remove. It is emitted after every PATH
step and served to peers as false.

  • Desktop install: a yes/no modal before any local install — the engine
    card, the node list's install chip, and Retry on a failed install. No still
    installs; dismissing the modal cancels. Remote installs never ask, since they
    never change PATH.
  • First-run wizard: one modal names every selected engine, and the single
    answer applies to all of them.
  • Desktop uninstall: the existing confirmation gains "Also remove
    from my PATH", checked by default, shown only when path_managed is true.
  • Update: reads path_managed fresh from engine:status and carries that
    answer through its uninstall and reinstall without asking again.
  • Retry from the error banner: asks the same questions; the uninstall
    checkbox starts unchecked, since the failed attempt may have kept the entry.
  • TUI: i asks (y/n, esc cancels); u asks only when PAIR owns an entry,
    and otherwise sends no answer.

A declined install still records that PAIR ran the installer, so a later
consented install can re-adopt an engine whose vendor owns its location; a
peer's remote install records the same flag and never publishes. An install
over an engine already on disk records nothing without consent, and a declined
install keeps any claim already on record. A declined uninstall leaves the
entries in place and deletes the record, so they become the user's and the
application uninstaller no longer touches them. A consented uninstall always
deletes the record, including one holding only the installed flag.

path_managed is read from the ownership record on each status snapshot rather
than cached: nvpair-tui runs its own engine-manager against the same records,
so a cached answer would go stale the moment the other one changed them. The
read takes no lock; on Windows, a record rename that races it is retried
briefly instead of failing on the sharing violation.

Ownership is the whole design

Nothing else can identify what PAIR put on a user's PATH, so an ownership record
is written outside the engine directory and before the PATH is touched. That
ordering makes a crash mid-update retryable, and it means an uninstall deletes
exactly what PAIR added. Existing entries, user-edited shell snippets, and
engines the user installed themselves are all preserved, because an entry PAIR
cannot prove it wrote is an entry it must not remove.

The record also notes that PAIR ran the installer, which is a longer-lived fact
than any individual PATH entry. It is what lets a reinstall re-adopt an engine
whose vendor owns its location — LM Studio writes ~/.lmstudio, so the path
alone 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, since the
alternative is claiming the entries were released while stranding them.

Platform behavior

Windows persists through HKCU\Environment; the other platforms append a
self-identifying block to the login shell's profiles, resolving ZDOTDIR for
zsh users. Both run under a cross-process lock that covers the entire
read-modify-write rather than just the write, bounded with a timeout so a hung
peer cannot wedge the operation while the engine's own mutex is held. Both
publish through a temporary file and an atomic rename, with the file and its
directory flushed — a rename is atomic for a concurrent reader but says nothing
about bytes reaching stable storage.

A profile rewrite re-checks the file's size and modification time immediately
before the rename, so a concurrent editor's save is refused as retryable rather
than silently discarded. A symlinked profile is written through to its target
instead of being replaced by a regular file.

The platform uninstallers release the entries before deleting the data directory
that holds the records, only when they are deleting it, and keep that data when
the release fails, so a reinstall can finish the cleanup instead of leaving
entries nothing can identify. On Debian only postrm can tell purge from a
plain remove, but dpkg has deleted the package's files by then, so prerm keeps
a copy of nvpair-engine-manager under /var/lib/<package> for postrm's purge
to run. A plain apt remove keeps the engines and their PATH entries.

A manifest whose CLI resolves to a relative path is rejected with a warning
rather than anchored to the daemon's working directory, which would publish an
entry that means nothing. {install_dir} is resolved first, so a manifest
written against the documented placeholders works.

Adaptations to this tree

Two changes are not part of the feature and exist because this tree has moved
independently. Both are called out so they are reviewed as decisions rather than
skimmed as noise.

  • runCommand takes a variadic environment, for install commands that declare
    overrides. The launch closure in lifecycle.go therefore accepts and ignores
    it: a launch carries its own environment separately, and the one call site
    passes none, so merging two sources there would be dead code pretending to be
    a feature.
  • writeJSONAtomic keeps this tree's unique temporary filename and gains the
    file and directory flush the ownership records need. It states 0600
    explicitly rather than inheriting it from os.CreateTemp, because
    TestSettingsOverrideRestrictsExistingPermissions requires the write to
    restrict an already group-readable file, not merely avoid widening one. The
    temporary is also named after its target, since the same helper now writes
    port overrides, desired state, and PATH records.

Components audited

  • Changed: nvpair-engine-manager (params, status field, consent gating),
    nvpair-tui (prompts), desktop bridge (empty-handlers.ts,
    modular-state.ts), shared types, renderer install/uninstall call sites.
  • Unchanged, with evidence: nvpair-ui-broker relays engine:* params
    verbatim, and enginePortAssignmentRequest decodes only
    {engine, port, start}, so path — including its absence — passes through;
    its suite passes. The remote ec install path still never publishes, and its
    responses no longer carry path_managed.
  • Excluded: the application uninstallers' --remove-user-path still releases
    every recorded entry without asking — the user already consented to those
    entries, and the uninstaller has no UI of its own.

Test plan

  • Full nvpair-engine-manager Go suite passes on Linux as well as Windows.
    Running it on Linux was not optional: the permissions test above skips on
    Windows, and the first push of this branch failed on it.
    TestUninstallTerminatesRunningInstance fails in a container, but it does so
    identically on unmodified develop, so it is an environment artifact rather
    than a regression — it passes on the hosted runners.
  • New consent coverage in install_path_test.go, run through the JSON-RPC
    front end: path:true publishes and reports path_managed; an omitted
    path installs without touching PATH; a declined install records only the
    installed flag and a later consented install publishes; a declined install
    over a present engine records nothing; uninstall with path:true removes the
    profile entry, with path:false keeps the entry and drops the record, and
    with no path keeps both, after which a consented uninstall still removes it.
    Also: path_managed needs an entry PAIR wrote, the last emitted state after
    uninstall and after adopting a present engine carries the final value, a
    consented uninstall drops an installed-only record, a remote install records
    only the flag, and neither peer response exposes path_managed.
  • Windows: a record rename succeeds while another handle holds the file open.
  • nvpair-tui tests: install waits for an answer, esc cancels, uninstall asks
    only when PAIR owns an entry, the params carry the answer, and an unasked op
    sends none.
  • nvpair-ui-broker suite passes.
  • go vet clean for linux, darwin, and windows; gofmt clean.
  • New coverage for the layers this feature rests on, which previously had none:
    cross-handle lock exclusion and a bounded wait, a symlinked profile written
    through rather than replaced, the concurrent-edit refusal, and a ZDOTDIR
    parsing table including a trailing comment after an unquoted value and an
    unreadable .zshenv.
  • Two regression tests were confirmed to fail against the previous behavior and
    pass after: republishing PATH for an engine living outside the install
    directory, and reporting failure for an unreadable ownership record.
  • Desktop typecheck, lint, dead-code:check, test:unit, and
    service-contracts:check pass. New bridge tests cover the answer reaching
    engine:install / engine:uninstall, an unasked uninstall sending no
    answer, update carrying a freshly read path_managed through both steps, and
    path_managed projected onto the local status. services-api.md regenerated.
  • SPDX headers pass.

Not covered by automation, and worth native verification before release:

  • the consent and uninstall modals themselves (the desktop suite has no
    renderer test harness), including the nested prompt in the first-run wizard;
  • real OS PATH changes, and the Windows registry functions (compiled but not
    executed by CI);
  • the installer's uninstall branch, and the Debian prerm/postrm pair on a real
    apt remove and apt purge;
  • live vendor installations.

Tests use temporary profiles, fake engines, and in-memory Windows PATH values
throughout, so no run can edit a developer's own account.

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>
@ckelseynv
ckelseynv force-pushed the feat/engine-install-path-gh branch from a6828dc to a1b3de5 Compare September 22, 2026 23:18
@kjlubick
kjlubick self-requested a review September 23, 2026 13:46
PATH changes are now the user's decision. engine:install and
engine:uninstall take a path flag, and an absent or false flag leaves PATH
untouched, so a client that does not ask changes nothing. EngineStatus
reports path_managed so a client offers removal only when PAIR owns an
entry.

A declined install still records that PAIR ran the installer, so a later
consented install can re-adopt an engine whose vendor owns its location.
A declined uninstall leaves the entries and drops the record, handing them
to the user.

The desktop app asks with a yes/no modal before a local install, once for
every engine the first-run wizard installs, and adds an "also remove from
my PATH" checkbox to the uninstall confirmation when PAIR owns the entry.
Update carries the existing answer through without asking. The TUI asks
the same questions with y/n prompts.

Signed-off-by: Chris Kelsey <ckelsey@nvidia.com>
…rent

Addresses review of the PATH consent change:

- engine:uninstall without path keeps PAIR's claim instead of handing the
  entries to the user, so a stale client can never strand them.
- engine:state-changed is emitted after every PATH step, and desktop update
  reads path_managed fresh from engine:status.
- path_managed counts only entries PAIR wrote; peers never see it.
- A consented uninstall always deletes the record; peer installs record the
  installed flag so a later local install can adopt them.
- Debian releases PATH entries only on purge, from a copy of the engine
  manager kept by prerm, and keeps data when the release fails.
- Windows retries a record rename that races an unlocked status read.
- Error-banner uninstall retry no longer defaults to removing the entry.

Signed-off-by: Chris Kelsey <ckelsey@nvidia.com>
Signed-off-by: Chris Kelsey <ckelsey@nvidia.com>

@kjlubick kjlubick left a comment

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.

I'm not sure about the syncdir* and userpath* code. It's rather complicated and I got a bit lost going through it. I gave the rest of the spec and go code as good a review as I could.


## 3. Key Use Cases
- **Install an engine, user-mode**: `engine:install {engine:"ollama"}` downloads the per-OS user-scoped package (Windows/Linux standalone archive extracted into a user dir; macOS app bundle — never an elevated `Setup.exe` or `curl | sh`), checksum-verifies, extracts, re-detects.
- **Install an engine, user-mode**: `engine:install {engine:"ollama"}` downloads the per-OS user-scoped package (Windows/Linux standalone archive extracted into a user dir; macOS app bundle — never an elevated `Setup.exe` or `curl | sh`), checksum-verifies, extracts, re-detects, then — when the request carries `path:true` — publishes the engine's CLI directory on this user's PATH.

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.

IIUC, this only applies to ollama (and llama.cpp soon), right? services/nvpair-engine-manager/manifests/lmstudio.json has a "script + bash" install on Linux and similar for Windows. I think it might be better to make it more obvious with a [ollama only] or [lmstudio excluded].

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.

Also, is there a reason we can't add lms.exe to the path anyway?

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.

Also also, this bullet point is a bit of a run on with several em dashes. Let's break it up into more succinct sentences.

- **Run a declared action**: `engine:action {engine, action, params}` → the manifest-declared HTTP call to the engine's loopback control API (e.g. `127.0.0.1:{port}/api/pull`). (Methods, notifications, and UI events all use the colon form `engine:*`, matching the POC UI and the `errors:*` notifications.)
- **Onboard a new engine (no code)**: a vendor adds `engines/<vendor>.json`; the generic runner exposes their lifecycle + actions immediately.
- **Edge case — already installed**: detect short-circuits install (idempotent).
- **Edge case — already installed**: detect short-circuits the download and the install command. With `path:true` the PATH step still runs, so an engine whose ownership record was lost — the data directory was wiped, or an earlier save failed — reacquires it instead of reporting success with nothing on PATH. Recovery does not depend on where the CLI lives: the record's `installed` flag survives the application uninstaller's release, and a wiped record is recovered from the executable's location only when that location is inside PAIR's install directory. It is idempotent: an entry PAIR already owns is left as it is. If the CLI has moved — a manifest update, or a vendor that relocated it — the recorded entry is released and the new directory claimed, so the stale one does not outlive the engine it pointed at.

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.

Can the edge cases go under a subheading?

| `engine:status` | `{ engine }` | `EngineStatus` |
| `engine:install` | `{ engine }` | `EngineStatus` (after install) |
| `engine:uninstall` | `{ engine }` | `EngineStatus` (after removal) |
| `engine:install` | `{ engine, start?, path? }` | `EngineStatus` (after install, and PATH publication when `path:true`) |

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.

What does "start?" do here? I don't seen any docs about it in this file.

## 3. Key Use Cases
- **Install an engine, user-mode**: `engine:install {engine:"ollama"}` downloads the per-OS user-scoped package (Windows/Linux standalone archive extracted into a user dir; macOS app bundle — never an elevated `Setup.exe` or `curl | sh`), checksum-verifies, extracts, re-detects.
- **Install an engine, user-mode**: `engine:install {engine:"ollama"}` downloads the per-OS user-scoped package (Windows/Linux standalone archive extracted into a user dir; macOS app bundle — never an elevated `Setup.exe` or `curl | sh`), checksum-verifies, extracts, re-detects, then — when the request carries `path:true` — publishes the engine's CLI directory on this user's PATH.
- **PATH consent**: every PATH change is the user's decision, carried as `path` on `engine:install` and `engine:uninstall`. A missing or false `path` never touches PATH. A declined install still records that PAIR ran the installer, and keeps any claim already on record; so does a peer's remote install, which never publishes. A declined uninstall (`path:false`) leaves the entries and deletes the record, handing them to the user. An uninstall with no `path` means the client did not ask: the entries stay and so does PAIR's claim on them, so an unasked uninstall can never strand an entry. `EngineStatus.path_managed` is true only while the record holds an entry PAIR wrote — a Windows entry or a shell block — and is emitted after every PATH step; the control surface serves it to peers as false.

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.

"EngineStatus.path_managed is true only while the record holds an entry PAIR wrote — a Windows entry or a shell block — and is emitted after every PATH step; the control surface serves it to peers as false." confuses me. What is going on with path_managed?

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.

This prose might be easier to understand as a flowchart

flowchart TD
    A["Engine uninstall requested"]
    B{"Engine removed or already absent?"}
    C{"Value of path parameter?"}
    D["Stop with an error<br/>Do not change PATH"]
    E["Leave PATH unchanged<br/>Delete receipt and give up PAIR ownership"]
    F{"Receipt readable or absent?"}
    G["Remove recorded entries if present<br/>Preserve user-edited shell blocks"]
    H{"Cleanup succeeded?"}
    I["Delete receipt"]
    J["Keep receipt<br/>Report an error and allow retry"]
    K["Leave PATH unchanged<br/>Keep PAIR ownership of claimed entries"]
    A --> B
    B -->|No| D
    B -->|Yes| C
    C -->|false - user said No| E
    C -->|true - user said Yes| F
    C -->|omitted - user was not asked| K
    F -->|No| J
    F -->|Yes| G
    G --> H
    H -->|No| J
    H -->|Yes| I
Loading

- **Source of truth**: no — `nvpair-errors` owns the node's error list (in memory, for the session); model inventories belong to the engines; manifests on disk are authored elsewhere.
- **Storage**: in-memory; manifests read from the per-user data dir's `engines/*.json` (`%LocalAppData%\Nvidia Corporation\Personal AI Router` on Windows, `~/.config/Nvidia Corporation/Personal AI Router` on Linux, `~/Library/Application Support/Nvidia Corporation/Personal AI Router` on macOS) plus bundled `manifests/*.json`. No database.
- **Owned**: the in-memory engine registry (parsed manifests + per-engine runtime state) and per-engine log/error ring buffers — transient. Also the durable PATH ownership records under the per-user data dir's `engine-bin/engine-path/<engine>.json`, which are **not** transient: they are the only thing that can identify what PAIR added to the user's PATH, and they have to outlive both a restart and the engine's own files.
- **Source of truth**: yes, for what PAIR published on this user's PATH — nothing else records it. Otherwise no: `nvpair-errors` owns the node's error list (in memory, for the session); model inventories belong to the engines; manifests on disk are authored elsewhere.

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.

This is worded very strangely, as if it's the answer to the question (and I'm not sure what that question is)

| 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.

# 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?

return err
}
switch now, err := statProfile(profile); {
case err != nil:

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.

nit: I don't like switch/case when a simple if/else if would work

case now != since:
return fmt.Errorf("%w: %s", errProfileChanged, filepath.Base(profile))
}
if err := os.Rename(f.Name(), target); err != nil {

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.

Do we need to try to get a file lock before we check for modification? Otherwise we might have a TOUTOC issue.

return "", err
}
found := ""
for _, line := range strings.Split(string(data), "\n") {

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.

I don't think this follows https://zsh.sourceforge.io/Doc/Release/Shell-Grammar.html

For example
ZDOTDIR='$HOME/.config/zsh' should have $HOME remain literal but this expands it

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.

Why do we need to do something complicated like that?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants