Repository navigation
Conversation
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>
a6828dc to
a1b3de5
Compare
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
left a comment
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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].
There was a problem hiding this comment.
Also, is there a reason we can't add lms.exe to the path anyway?
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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`) | |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
"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?
There was a problem hiding this comment.
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
| - **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. |
There was a problem hiding this comment.
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 \ |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Should we set PURGE_DATA back to 1 if this is false?
| return err | ||
| } | ||
| switch now, err := statProfile(profile); { | ||
| case err != nil: |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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") { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Why do we need to do something complicated like that?
Changelog title
Installed engines can be added to your PATH
Changelog body
ollamaorlmsworks 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.apt purge) releases any entries its engines still own.Bumps
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
pathonengine:installandengine:uninstall. A missing or falsepathnever touchesPATH, so a client that does not ask the user changes nothing. On uninstall the
answer has three values:
trueremoves the entries,falsekeeps them andhands them to the user, and a missing
path(the client did not ask) keepsthem and PAIR's claim, so a stale or unasking client can never strand an entry
with no record that could remove it.
EngineStatusgainspath_managed, truewhile 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.
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.
answer applies to all of them.
from my PATH", checked by default, shown only when
path_managedis true.path_managedfresh fromengine:statusand carries thatanswer through its uninstall and reinstall without asking again.
checkbox starts unchecked, since the failed attempt may have kept the entry.
iasks(y/n, esc cancels);uasks 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_managedis read from the ownership record on each status snapshot ratherthan cached:
nvpair-tuiruns 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 pathalone 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 aself-identifying block to the login shell's profiles, resolving
ZDOTDIRforzsh 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
purgefrom aplain
remove, but dpkg has deleted the package's files by then, so prerm keepsa copy of
nvpair-engine-managerunder/var/lib/<package>for postrm's purgeto run. A plain
apt removekeeps 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 manifestwritten 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.
runCommandtakes a variadic environment, for install commands that declareoverrides. The launch closure in
lifecycle.gotherefore accepts and ignoresit: 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.
writeJSONAtomickeeps this tree's unique temporary filename and gains thefile and directory flush the ownership records need. It states
0600explicitly rather than inheriting it from
os.CreateTemp, becauseTestSettingsOverrideRestrictsExistingPermissionsrequires the write torestrict 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
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.nvpair-ui-brokerrelaysengine:*paramsverbatim, and
enginePortAssignmentRequestdecodes only{engine, port, start}, sopath— including its absence — passes through;its suite passes. The remote
ecinstall path still never publishes, and itsresponses no longer carry
path_managed.--remove-user-pathstill releasesevery recorded entry without asking — the user already consented to those
entries, and the uninstaller has no UI of its own.
Test plan
nvpair-engine-managerGo 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.
TestUninstallTerminatesRunningInstancefails in a container, but it does soidentically on unmodified
develop, so it is an environment artifact ratherthan a regression — it passes on the hosted runners.
install_path_test.go, run through the JSON-RPCfront end:
path:truepublishes and reportspath_managed; an omittedpathinstalls without touching PATH; a declined install records only theinstalled flag and a later consented install publishes; a declined install
over a present engine records nothing; uninstall with
path:trueremoves theprofile entry, with
path:falsekeeps the entry and drops the record, andwith no
pathkeeps both, after which a consented uninstall still removes it.Also:
path_managedneeds an entry PAIR wrote, the last emitted state afteruninstall 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.nvpair-tuitests: install waits for an answer, esc cancels, uninstall asksonly when PAIR owns an entry, the params carry the answer, and an unasked op
sends none.
nvpair-ui-brokersuite passes.go vetclean forlinux,darwin, andwindows;gofmtclean.cross-handle lock exclusion and a bounded wait, a symlinked profile written
through rather than replaced, the concurrent-edit refusal, and a
ZDOTDIRparsing table including a trailing comment after an unquoted value and an
unreadable
.zshenv.pass after: republishing PATH for an engine living outside the install
directory, and reporting failure for an unreadable ownership record.
typecheck,lint,dead-code:check,test:unit, andservice-contracts:checkpass. New bridge tests cover the answer reachingengine:install/engine:uninstall, an unasked uninstall sending noanswer, update carrying a freshly read
path_managedthrough both steps, andpath_managedprojected onto the local status.services-api.mdregenerated.Not covered by automation, and worth native verification before release:
renderer test harness), including the nested prompt in the first-run wizard;
executed by CI);
apt removeandapt purge;Tests use temporary profiles, fake engines, and in-memory Windows PATH values
throughout, so no run can edit a developer's own account.