Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7e3af79
feat(llamacpp): add a bounded public GGUF catalog
JaredP94 Nov 7, 2026
82c4758
feat(llamacpp): verify owned model downloads and safe deletion
JaredP94 Nov 7, 2026
1729d2f
fix(llamacpp): bind deletion claims to recorded file identities
JaredP94 Nov 7, 2026
8435f04
test(llamacpp): cover catalog RPC and restart fencing
JaredP94 Nov 7, 2026
7e496a1
fix(llamacpp): report temporary-file cleanup failures honestly
JaredP94 Nov 7, 2026
ab485a8
docs(llamacpp): distinguish owned temp cleanup from shared caches
JaredP94 Nov 7, 2026
d9510f5
fix(llamacpp): bound installed inventory scans and validate pulls early
JaredP94 Nov 7, 2026
70d8928
fix(llamacpp): retain the verified descriptor through cache commit
JaredP94 Nov 7, 2026
f2bb40f
test(llamacpp): fence ownership against a post-rename file swap
JaredP94 Nov 7, 2026
845350d
docs(llamacpp): normalize apostrophes in cache guidance
JaredP94 Nov 7, 2026
06d5565
fix(llamacpp): refuse ownership manifests beyond the read bound
JaredP94 Nov 7, 2026
c15d066
fix(llamacpp): bind committed models and launch to verified identity
JaredP94 Nov 7, 2026
d8806cf
fix(llamacpp): check cache entry identity immediately before cleanup
JaredP94 Nov 7, 2026
8babe5f
fix(llamacpp): recheck free space while streaming model downloads
JaredP94 Nov 7, 2026
2c98dbf
fix(llamacpp): prevent case aliases from bypassing model identity
JaredP94 Nov 7, 2026
590690c
fix(llamacpp): keep identity-check directory copies close-on-exec
JaredP94 Nov 7, 2026
4113fff
fix(llamacpp): preserve cancellation across download phases
JaredP94 Nov 7, 2026
f351dcd
fix(llamacpp): classify a download failure before closing its body
JaredP94 Nov 7, 2026
4a616f1
fix(llamacpp): classify early download failures before teardown
JaredP94 Nov 7, 2026
c9403ec
fix(llamacpp): classify before closing descriptors on early returns
JaredP94 Nov 7, 2026
1613ca0
fix(llamacpp): classify download cancellation by cause
JaredP94 Nov 7, 2026
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
2 changes: 2 additions & 0 deletions desktop/docs/services-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@
| `engine:install` | request (we call) | ✅ yes |
| `engine:install-tool` | request (we call) | ✅ yes |
| `engine:logs` | request (we call) | ⚠️ not called |
| `engine:model-catalog` | request (we call) | ✅ yes |
| `engine:models` | request (we call) | ✅ yes |
| `engine:prepare-shutdown` | request (we call) | ✅ yes |
| `engine:preview-launch` | request (we call) | ⚠️ not called |
Expand Down Expand Up @@ -395,6 +396,7 @@ Client surface: requests are sent to the broker; notifications are consumed from
| `engine:get-load-profile` | request (we call) | ✅ yes |
| `engine:get-settings` | request (we call) | ✅ yes |
| `engine:install` | request (we call) | ✅ yes |
| `engine:model-catalog` | request (we call) | ✅ yes |
| `engine:preview-load-profile` | request (we call) | ✅ yes |
| `engine:preview-settings` | request (we call) | ✅ yes |
| `engine:remote-cancel-pull` | request (we call) | ✅ yes |
Expand Down
13 changes: 13 additions & 0 deletions desktop/src/electron/service-bridge/json-rpc-subprocess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export interface JsonRpcInboundRequest {
id: JsonRpcId
}

// Go-owned llama.cpp model hub contract; screens are added separately.
type LlamaCatalogParams = { query?: string }

interface PendingCall {
resolve: (value: JsonValue | undefined) => void
reject: (error: Error) => void
Expand Down Expand Up @@ -174,6 +177,16 @@ export class JsonRpcSubprocess extends EventEmitter<JsonRpcSubprocessEvents> {
})
}

call(
method: 'engine:model-catalog',
params?: LlamaCatalogParams,
timeoutMs?: number | null
): Promise<JsonValue | undefined>
call(
method: string,
params?: JsonValue,
timeoutMs?: number | null
): Promise<JsonValue | undefined>
async call(
method: string,
params?: JsonValue,
Expand Down
14 changes: 9 additions & 5 deletions docs/engine-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ those engines for you, or work with a copy you already have. A macOS arm64
**llama.cpp engine** also appears in Engine settings: it can be installed,
started, stopped and configured, and is served by its own routing proxy on
**1237** (engine listener **127.0.0.1:8082**). Select a regular GGUF inside PAIR's
llama.cpp model cache with allowlisted `--model`/`-m`; model hub/download/delete
are not available yet. Its official b12171 macOS build is **not notarized**, like PAIR itself.
llama.cpp model cache with allowlisted `--model`/`-m`; the Go service now
provides catalog/download/cancel/delete RPCs, with desktop/TUI hub screens deferred. Its official b12171 macOS build is **not notarized**, like PAIR itself.
This page explains what you can expect when you install, start, stop, update, or
remove an engine.

Expand Down Expand Up @@ -72,11 +72,15 @@ Refer also to [Getting started](getting-started.mdx).

Install llama.cpp from its node row (not first-run automatic selection). The
managed listener defaults to `127.0.0.1:8082`; Settings exposes the server port
and launch arguments/environment but no proxy port. PAIR protects all listener
and proxy ports and launch arguments/environment. PAIR protects all listener
controls, including Unix-socket host values and port-changing presets. Readiness
requires the `/props` router identity, not merely HTTP success. Non-loopback
requires the `/props` router identity (or the inherited model descriptor in
single-model mode), not merely HTTP success. Non-loopback
binding still requires the existing explicit `engine_allow_lan_bind` permission.
There is no model hub, model download/delete, load profile or routed endpoint yet.
The Go model hub supplies a bounded public Q4_K_M catalog and PAIR-owned
verified downloads, asynchronous cancel and manifest-owned delete. See the
[model hub contract](../services/nvpair-engine-manager/LLAMACPP.md#go-model-hub-and-owned-cache).
The dedicated proxy routes inference; model hub screens and load profiles remain deferred.

Installation verifies the pinned SHA-256, bounded contained extraction, expected
server/libraries/Metal layout, absence of quarantine and existing signature
Expand Down
4 changes: 3 additions & 1 deletion docs/release-readiness.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ The current source adds `llamacpp-proxy` (default port 1237, engine 8082,
service `lc`): **13 supervised workers / 15 executables**. The broker, desktop
and TUI inventories, build/install lists and contract documentation include it.
Native b12171 Messages passthrough and exact-ID model routing are supported;
model hub/download/delete remain deferred. Historical packaged-app evidence
the Go model hub provides catalog/download/cancel/delete; desktop/TUI hub
screens remain deferred. The new hub uses fixture/local-server tests, not live
model downloads or packaged certification. Historical packaged-app evidence
below predates this worker and does not certify its packaged or multi-node
inference behavior. Validate those before release.

Expand Down
117 changes: 111 additions & 6 deletions services/nvpair-engine-manager/LLAMACPP.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ PAIR manages the official **b12171 macOS arm64** build. Other platforms are
unsupported. PAIR installs, starts, stops and configures the engine and serves
it through the dedicated `llamacpp-proxy` worker, default port **1237**. Routing
uses exact model IDs from the engine's `/v1/models` inventory, with separate
`llamacpp` attribution. There is no model hub, download/delete adapter or load
profile yet.
`llamacpp` attribution. The Go model hub supports catalog, downloads, cancel
and delete over JSON-RPC; desktop/TUI hub screens and load profiles are deferred.

The macOS build is **not notarized**, like this fork's PAIR application. The
release is checksum-pinned, not publisher-trusted by that checksum. Its existing
Expand Down Expand Up @@ -46,7 +46,112 @@ inherited descriptor's `model_path` from `tools/server/server-context.cpp`
A different service returning HTTP 200 is not an engine match. Foreground-process
shutdown uses the existing process lifecycle, not command-mode `wait_for_exit`.
The model cache is `LLAMA_CACHE={install_dir}-models`, a PAIR-managed sibling
that survives engine uninstall. Nothing in this PR downloads models.
that survives engine uninstall. PAIR downloads models itself; llama-server is
kept offline and none of its download launch options are enabled.

## Go model hub and owned cache

`engine:model-catalog {query?: string}` returns `{models: [{id, file, size?,
sha256?}]}`. Empty query reads ggml-org, bartowski and unsloth, up to 50 repos
each. Public search admits at most 50 repos and 100 query characters; at most
20 search queries are cached for six hours. The aggregate metadata deadline is
20 seconds (broker 25 seconds). Only public, ungated text-generation GGUF repos
with exactly one single-file Q4_K_M candidate are listed. IDs are
`owner/repo:Q4_K_M`. All path components use bounded strict ASCII validation.

Existing `engine:action` supports llama.cpp `pull_model {model: id}` with
`opId`, `list_downloaded` returning `{models: [{id, file, size, sha256,
finalHost}]}`, and `delete_model {model: id}`. Inventory combines the ownership
manifest with a bounded (4096 entries) no-follow regular-GGUF scan; manually
supplied GGUFs have their filename as ID and empty checksum/host and are never
deletable by PAIR. Recorded files must match their descriptor-derived file identity, size and valid
single-file GGUF metadata to appear. The live `list_models` remains the
llama-server served inventory, not this installed list. Installed scans check a
five-second context budget between bounded per-file metadata reads (broker ten
seconds; future clients fifteen seconds). Filesystem syscall latency itself
cannot be preempted by a Go context.

PAIR resolves metadata again before transfer, requiring LFS SHA-256 and size
and a pinned repository commit. Limit: 64 GiB per file plus 256 MiB free-space
reserve. During streaming, fstatfs on the open transfer descriptor rechecks
space every 256 MiB or five seconds, whichever occurs first at a streaming
boundary (a blocked read writes no additional bytes). Remaining free space must
cover the unreceived file bytes plus the 256 MiB safety margin. A drop below that
threshold aborts with "not enough disk space" through the existing progress/error
state and identity-checked cleanup of this operation's temp. Transfers have a
30-minute operation bound, 10-second dial/TLS/header
bounds, a 64 KiB response-header cap and a 30-second idle-read bound. Redirects
must stay HTTPS, cannot contain credentials and are limited to five; the final
host is recorded. Bodies and transfer URLs are never logged. An exclusive
random temporary file is created inside the pinned cache descriptor; exact
size, SHA-256 and single-file GGUF metadata are checked before fsync and an
atomic no-replace rename. The small ownership manifest is fsynced and atomically
replaced. Existing destinations are never overwritten. A crash between file
commit and manifest commit can leave an unrecorded file, never a deletable
owned entry; abandoned crash temp files are not automatically reclaimed.

The cache is in the user's own app data: entry swaps require another process
running as the same user, not a different unprivileged user. Staging uses
`openat(cacheFd, randomName, O_CREAT|O_EXCL|O_NOFOLLOW|O_RDWR|O_CLOEXEC, 0600)`
through the no-follow directory walk and records dev/ino immediately with fstat.
After hashing and fsync through that descriptor, commit uses renameatx_np with
RENAME_EXCL on macOS (renameat2 RENAME_NOREPLACE on Linux), then fstatat with
AT_SYMLINK_NOFOLLOW compares the final entry's dev/ino and size to the verified
file. A mismatch fails with no ownership record and leaves the unrelated entry
untouched. Only a match permits directory fsync and manifest commit; the manifest
records dev/ino (`fileIdentity`), size and SHA-256. Generated 64-hex `.gguf` names
are reserved case-insensitively, matching default macOS filesystem semantics:
unrecorded commits cannot become selectable manual models, and case aliases
cannot bypass recorded-model identity checks.
Selection and the actual opened launch descriptor must match a recorded model's
dev/ino/size; replaced files are refused without re-hashing up to 64 GiB.

macOS provides no rename-by-descriptor or unlink-by-descriptor. A same-user
process can still change an entry after the last identity check; the residual
check-to-use window is the minimum the OS allows, not an atomicity guarantee.
Launch validates the inherited descriptor, so a later pathname replacement
cannot redirect llama-server. These checks detect entry replacement, not writes
in place by a process already authorized as the same user.

`engine:cancel-pull` accepts asynchronously; only the terminal progress update
confirms transfer release and removal of that operation's own temp file.
The shared pull tracker reports `cancelled` only when that operation's user
cancel was requested and the returned error wraps `context.Canceled`. Metadata
requests and streaming reads preserve that cause, including transports whose
body read errors lose it when cancellation tears down the connection. The pull
then returns `{"status":"cancelled"}` without a `pull-failed` engine error.
Unrelated failures (including checksum/size mismatch, HTTP status, disk space,
identity checks and manifest writes) remain errors regardless of cancel timing,
even when cancellation arrives during teardown. Independent cleanup failures
also remain errors.
Completion is fenced against cancel acceptance after the post-rename identity
check and successful manifest write/fsync. A cancel accepted before that fence
rolls back any newly written manifest entry and removes the operation's
identity-matched published file as well as its temp. After the fence, cancellation
is no longer accepted: the pull reports success and the model remains installed.
No filesystem or network I/O is performed while holding the cancellation lock.
Before temp cleanup or completed-model deletion, fstatat with
AT_SYMLINK_NOFOLLOW checks dev/ino (and the recorded size for a completed model)
immediately before unlinkat. A changed entry is left untouched and reports
"cleanup skipped" through the terminal error state, not successful cancellation.
The original temp descriptor stays open until cleanup finishes. The same check
protects manifest staging cleanup and rollback of an unrecorded model. The
remaining check-to-unlink window cannot be eliminated on macOS; no unlink-by-fd
primitive exists. Unlike Ollama/LM Studio in #50, only this operation's
identity-matched temp is removed; shared or substituted files are not knowingly
removed. Concurrent
cache mutations are refused (including duplicate pulls), not queued. #50's
operation-ID fencing, broker worker-loss interruption and epoch recovery apply
unchanged. Verified-peer downloads/cancel/delete use the existing authenticated
remote methods and streams. Start is refused during a cache mutation; deletion
conservatively requires the engine to be stopped and no lifecycle op in flight.
Every cache ancestor is opened with openat/no-follow from `/`; mutations stay
relative to that descriptor even after a parent swap. Delete requires a matching
ownership manifest record, file identity and regular file, and never recursively removes data.

This intentionally differs from NVIDIA/Personal-AI-Router@6d2d67f: PAIR does not
ask llama-server's router-mode API to download. All checks and #50 tracking stay
inside Go. No real model was downloaded during development.

## Launch allowlist (b12171)

Expand Down Expand Up @@ -82,9 +187,9 @@ reopens the path with `llama_file` for reads or `mmap` on that descriptor. macOS
sibling paths, so split filenames and `split.*` metadata (including renamed
shards) are explicitly rejected at spawn. Metadata inspection is bounded to
64 MiB; malformed or unsupported GGUF metadata fails closed. Multi-file models
and mmproj are not supported in this PR. The user supplies a single-file model
for now; PR 3 adds the hub that fills the cache. Omitting `--model` retains router
mode.
and mmproj are not supported. The hub fills the cache with verified single-file
models. `--model` accepts the installed catalog ID, cache filename or contained
path. Omitting `--model` retains router mode.

Other allowed value options (documented short aliases included): `--ctx-size/-c`,
`--batch-size/-b`, `--ubatch-size/-ub` (context 0–1048576, batches 1–1048576),
Expand Down
32 changes: 25 additions & 7 deletions services/nvpair-engine-manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,18 @@ is a JSON manifest, not code.
[llama.cpp](LLAMACPP.md) is supported on macOS arm64, pinned to b12171. Its
macOS build is not notarized, like PAIR itself. The dedicated `llamacpp-proxy`
serves it on port 1237. Select a cached GGUF with the allowlisted `--model`/`-m`;
`list_models` reads its OpenAI inventory. Hub/download/delete remain deferred.
`list_models` reads its OpenAI inventory. `engine:model-catalog` accepts
`{query?: string}` and returns `{models: [{id, file, size?, sha256?}]}` from
public, ungated, text-generation GGUF repositories. IDs are `owner/repo:Q4_K_M`;
only one unsplit Q4_K_M file is admitted. Empty query reads ggml-org, bartowski
and unsloth (50 repositories each). Public search is capped at 50 repositories,
100 query characters, 20 cached queries and a six-hour TTL. Requests have a
20-second aggregate timeout (broker: 25 seconds); metadata bodies are bounded
to 4 MiB. Existing `engine:action` supports llama.cpp `pull_model`,
`list_downloaded` and `delete_model`, including existing operation-ID progress,
asynchronous cancel and verified-peer remote management. See [the owned-cache
contract](LLAMACPP.md#go-model-hub-and-owned-cache) for checks, inventory and
cleanup. Desktop/TUI catalog screens are deferred to PR 4.

The bundled manifests under `manifests/` are the working reference for manifest
authoring. [Pinned archive installs](MANIFEST.md#pinned-macos-archive-installs)
Expand Down Expand Up @@ -762,8 +773,10 @@ its own.
Pulls are attributed by both model and operation ID, locally and remotely.
The pull accepts an optional caller-generated `opId`; cancel requires that exact
ID plus engine and model. A stale ID cannot cancel a replacement. Concurrent
different models are independent; a duplicate model in flight is refused.
No PAIR download queue or cache-file cleanup is introduced.
different models are independent for engine-managed caches; a duplicate model
in flight is refused. llama.cpp cache mutations are conservatively serialized
by rejecting concurrent mutations, not by queuing them. No PAIR download queue
is introduced.

Cancel returns once accepted, **not** once stopped. Clients keep **Canceling**
until the original pull reports `cancelled`, `done`, `interrupted`, or `error`. A successful
Expand Down Expand Up @@ -794,8 +807,12 @@ progress subscription dropped its last frame.
cancellation while retaining completed files for retry. No admin-task/session
integration is added here.

**PAIR never inspects or deletes cache files when cancelling.** Partial data is
left for the engine to manage; the desktop briefly says “Partial data is kept so
- **llama.cpp:** PAIR streams and verifies the download itself, and removes only
its exclusive operation-owned temp file before reporting cancellation. A
cleanup failure reports an error. See the [owned-cache contract](LLAMACPP.md#go-model-hub-and-owned-cache).

**PAIR never inspects or deletes shared engine cache files when cancelling.**
Ollama/LM Studio partial data is left for the engine to manage; the desktop briefly says “Partial data is kept so
the download can resume.” This intentionally diverges from
NVIDIA/Personal-AI-Router#111's snapshot/stillness cleanup: a shared engine cache
does not establish file ownership, even when a partial appears during our pull.
Expand Down Expand Up @@ -841,8 +858,9 @@ and launcher exit; survivors produce an error (also logged on shutdown).
Signal/kill helpers each get at most 500 ms, and group/job verification shares
the final 2 s reap deadline: at most 3.5 s per CLI shutdown sequence.
Inherited-stdio waiting is capped at 1 s. The aggregate download join is 4 s,
regardless of transfer count. Joins abandon only after the fixed deadline; no
cache data is inspected or removed.
regardless of transfer count. Joins abandon only after the fixed deadline;
shared engine cache data is never inspected or removed. llama.cpp removes only
its own transfer temp file.

The outward budgets are download stop sequence **3.5 s < aggregate join 4 s <
broker prepare-shutdown 5 s < engine-manager worker EOF grace 6 s** (see
Expand Down
10 changes: 10 additions & 0 deletions services/nvpair-engine-manager/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ func (e *Executor) Action(ctx context.Context, engine, action string, params jso
if err != nil {
return nil, err
}
if engine == "llamacpp" {
switch action {
case "pull_model":
return e.PullModelStream(ctx, engine, modelFromParams(params), params)
case "list_downloaded":
return e.listLlamaModels(ctx)
case "delete_model":
return e.deleteLlamaModel(ctx, modelFromParams(params))
}
}
act, ok := st.manifest.Actions[action]
if !ok {
return nil, fmt.Errorf("engine %q has no action %q", engine, action)
Expand Down
3 changes: 3 additions & 0 deletions services/nvpair-engine-manager/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ func capabilitiesForPlatform(m *Manifest, p Platform, goos string, adopted bool,
_, list := m.Actions["list_models"]
_, pull := m.Actions["pull_model"]
deleteAction, deleteModel := m.Actions["delete_model"]
if m.Engine == "llamacpp" {
list, pull, deleteModel = true, true, true
}
uninstall := p.Uninstall != nil && (len(p.Uninstall.Run) > 0 || len(p.Uninstall.Remove) > 0) && !adopted
if uninstall && p.Runtime.modeOrDefault() == "command" {
// Uninstall declines a command-mode engine PAIR has no record of
Expand Down
3 changes: 2 additions & 1 deletion services/nvpair-engine-manager/download_shutdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import (
// Downloads stop concurrently with engines. The CLI shutdown sequence has at
// most 500ms signal helper + 500ms grace + 500ms kill helper + 2s reap (3.5s).
// Its 4s aggregate join is below the broker's 6s engine-manager EOF grace;
// StopAll runs alongside it, not afterward. No cache files are touched.
// StopAll runs alongside it, not afterward. Shared engine caches are untouched;
// the Go llama.cpp adapter removes only its own transfer temp file.
const downloadShutdownBudget = 4 * time.Second
const downloadShutdownSignalBudget = 500 * time.Millisecond
const downloadShutdownGrace = 500 * time.Millisecond
Expand Down
Loading
Loading