diff --git a/desktop/src/electron/service-bridge/empty-handlers.ts b/desktop/src/electron/service-bridge/empty-handlers.ts index 45fe2c214..0d1a89403 100644 --- a/desktop/src/electron/service-bridge/empty-handlers.ts +++ b/desktop/src/electron/service-bridge/empty-handlers.ts @@ -504,6 +504,19 @@ function routeEngineManagerCommand(payload: WsInvokeRequest<'engine:command'>): ) break case 'update': + if (payload.engineType === 'llamacpp') { + // Archive replacement/rollback belongs to Go. Never uninstall + // the working copy first; a running engine asks to stop first. + state.beginLocalEngineOp(payload.engineType, 'installing') + supervisor.sendProcess( + 'broker', + 'engine:install', + { engine, start: true }, + failPendingOp('update', 'install'), + true + ) + break + } // The engine-manager serializes per-engine ops via its lifecycle // lock, so the queued install waits for the uninstall to finish. The // uninstall's engine:state-changed briefly clears this, then the diff --git a/desktop/src/electron/service-bridge/engine-settings.ts b/desktop/src/electron/service-bridge/engine-settings.ts index 83d92a422..80a54fc42 100644 --- a/desktop/src/electron/service-bridge/engine-settings.ts +++ b/desktop/src/electron/service-bridge/engine-settings.ts @@ -68,7 +68,7 @@ function flag(value: JsonValue | undefined): boolean { return value } function isSettingsEngine(value: JsonValue | undefined): value is EngineSettingsTarget['engine'] { - return value === 'ollama' || value === 'lmstudio' || value === 'omlx' + return value === 'ollama' || value === 'lmstudio' || value === 'omlx' || value === 'llamacpp' } function isSnapshotPhase(value: JsonValue | undefined): value is EngineSettingsSnapshot['phase'] { return isReceiptPhase(value) || value === 'idle' diff --git a/desktop/src/electron/service-bridge/modular-state.ts b/desktop/src/electron/service-bridge/modular-state.ts index aa0bc7e0d..886ea10ea 100644 --- a/desktop/src/electron/service-bridge/modular-state.ts +++ b/desktop/src/electron/service-bridge/modular-state.ts @@ -47,6 +47,7 @@ import type { WorkerState, WorkerStatus } from '@/shared/types/workers' import type { ClusterNode, Invite } from '@/shared/types/cluster' import type { WsInvokeResponse } from '@/shared/types/ws-channels' import { MODULAR_NODE_INFO_SELF_HOST } from '@/shared/constants/modular-runtime' +import { EngineTypes } from '@/shared/constants/engines' import { WorkloadStates } from '@/shared/constants/workloads' import { isEngineType, @@ -1755,10 +1756,10 @@ class ModularBridgeState { // known — see {@link resolveRemoteEngineStatus}. for (const node of Array.from(this.nodes.values())) { if (node.id === selfId || !isPairPeer(node)) continue - for (const engine of PROXY_ENGINES) { + for (const engine of EngineTypes) { const status = this.resolveRemoteEngineStatus(node.id, engine) if (status) statuses.push(status) - models.push(this.toEngineModels(node, engine)) + if (isProxyEngine(engine)) models.push(this.toEngineModels(node, engine)) } } @@ -2174,11 +2175,10 @@ class ModularBridgeState { * decision matches the displayed status. */ isRemoteEngineRunning(nodeId: string, engineType: EngineType): boolean { - if (!isProxyEngine(engineType)) return false const facts = this.remoteEngineFacts.get(this.remoteOpKey(nodeId, engineType)) if (facts) return facts.running const node = this.nodes.get(nodeId) - if (!node || node.clustered || node.trusted) return false + if (!node || node.clustered || node.trusted || !isProxyEngine(engineType)) return false return node.engines[engineType].up } @@ -2301,7 +2301,6 @@ class ModularBridgeState { } private emitRemoteEngineStatus(nodeId: string, engineType: EngineType): void { - if (!isProxyEngine(engineType)) return if (!this.nodes.has(nodeId)) return // Re-emit models alongside status: authoritative facts arriving here can // flip which engine is the single active one, which changes model @@ -2313,7 +2312,15 @@ class ModularBridgeState { nodeId, engineType, status: this.remoteEngineStatusForPush(nodeId, engineType), - models: this.discoveryModelsForNode(this.nodes.get(nodeId), engineType, nodeId) + ...(isProxyEngine(engineType) + ? { + models: this.discoveryModelsForNode( + this.nodes.get(nodeId), + engineType, + nodeId + ) + } + : {}) }) } @@ -2322,7 +2329,7 @@ class ModularBridgeState { * placeholder when nothing is known so stale inferred state is cleared * without asserting installed-but-off. */ - private remoteEngineStatusForPush(nodeId: string, engine: ProxyEngine): EngineStatusData { + private remoteEngineStatusForPush(nodeId: string, engine: EngineType): EngineStatusData { const status = this.resolveRemoteEngineStatus(nodeId, engine) return status ?? emptyEngineStatus(nodeId, engine) } @@ -2358,12 +2365,9 @@ class ModularBridgeState { * The peer's engine port stays private (loopback) and comes only from facts; * the LOCAL proxy port is never attributed to a peer. */ - private resolveRemoteEngineStatus( - nodeId: string, - engine: ProxyEngine - ): EngineStatusData | null { + private resolveRemoteEngineStatus(nodeId: string, engine: EngineType): EngineStatusData | null { const node = this.nodes.get(nodeId) - const presence = node?.engines[engine] + const presence = node && isProxyEngine(engine) ? node.engines[engine] : undefined const facts = this.remoteEngineFacts.get(this.remoteOpKey(nodeId, engine)) const pending = this.pendingRemoteEngineOps.get(this.remoteOpKey(nodeId, engine)) @@ -2386,7 +2390,13 @@ class ModularBridgeState { // The peer's own report of what this engine supports there. capabilities: facts.capabilities } - } else if (node && presence?.up && !node.clustered && !node.trusted) { + } else if ( + node && + isProxyEngine(engine) && + presence?.up && + !node.clustered && + !node.trusted + ) { // A live advertisement means the engine is reachable and serving, so // `running` is sound even without facts — for an unclustered peer that // cannot be polled over ec. Clustered / pinned members must wait for @@ -2425,12 +2435,12 @@ class ModularBridgeState { const obj = objectValue(result) const list = obj?.engines if (!Array.isArray(list)) return - const seen = new Set() + const seen = new Set() for (const entry of list) { const engineObj = objectValue(entry) if (!engineObj) continue const engineType = engineManagerEngineType(stringValue(engineObj.engine)) - if (!engineType || !isProxyEngine(engineType)) continue + if (!engineType) continue this.remoteEngineFacts.set(this.remoteOpKey(nodeId, engineType), { installed: booleanValue(engineObj.installed), running: booleanValue(engineObj.running), @@ -2440,7 +2450,7 @@ class ModularBridgeState { }) seen.add(engineType) } - for (const engine of PROXY_ENGINES) { + for (const engine of EngineTypes) { if (!seen.has(engine)) { this.remoteEngineFacts.delete(this.remoteOpKey(nodeId, engine)) } @@ -2458,7 +2468,6 @@ class ModularBridgeState { engineType: EngineType, result: JsonValue | undefined ): void { - if (!isProxyEngine(engineType)) return const obj = objectValue(result) if (!obj) return this.remoteEngineFacts.set(this.remoteOpKey(nodeId, engineType), { @@ -2473,7 +2482,7 @@ class ModularBridgeState { /** Forget a node's authoritative remote-engine facts (on eviction/leave). */ private dropRemoteEngineFacts(nodeId: string): void { - for (const engine of PROXY_ENGINES) { + for (const engine of EngineTypes) { this.remoteEngineFacts.delete(this.remoteOpKey(nodeId, engine)) } } diff --git a/desktop/src/shared/constants/engines.ts b/desktop/src/shared/constants/engines.ts index f0f9a2c5f..5426d7899 100644 --- a/desktop/src/shared/constants/engines.ts +++ b/desktop/src/shared/constants/engines.ts @@ -4,23 +4,21 @@ import { EngineType, ModelExpiry } from '@/shared/types/engines' // The engines `nvpair-engine-manager` ships a manifest for, and therefore the -// only ones PAIR can install, run or route to. llama-cpp, whisper-cpp, -// piper-tts, sherpa-onnx-tts and stable-diffusion-cpp were carried here as -// never-enabled placeholders; they were removed with the chat window, which was -// their only in-app consumer. Adding an engine back means shipping its manifest -// first -- an engine row without one renders commands that fail with `-32000`. -export const EngineTypes = ['ollama', 'lm-studio', 'omlx'] as const +// engines PAIR can install and manage. Proxy/routing support is a separate +// inventory; llama.cpp currently has lifecycle and settings only. +export const EngineTypes = ['ollama', 'lm-studio', 'omlx', 'llamacpp'] as const // Kept as a distinct export so a future engine can ship behind it rather than // appearing the moment its type exists. -export const EnabledEngineTypes: EngineType[] = ['ollama', 'lm-studio', 'omlx'] as const +export const EnabledEngineTypes: EngineType[] = ['ollama', 'lm-studio', 'omlx', 'llamacpp'] as const export const EngineSources = ['bundled', 'detected', 'installed'] as const export const EngineDisplayNames: Record = { ollama: 'Ollama', 'lm-studio': 'LM Studio', - omlx: 'oMLX' + omlx: 'oMLX', + llamacpp: 'llama.cpp' } as const /** Default docs/install URLs for built-in backends. Single source of truth for UI and adapter buildInfo(). */ @@ -30,6 +28,10 @@ export const EngineDefaultLinks: Record + + diff --git a/desktop/src/ui/components/BackendRow/BackendHeader.tsx b/desktop/src/ui/components/BackendRow/BackendHeader.tsx index a8eb11f08..b17fd443b 100644 --- a/desktop/src/ui/components/BackendRow/BackendHeader.tsx +++ b/desktop/src/ui/components/BackendRow/BackendHeader.tsx @@ -13,6 +13,7 @@ import { gatewayEndpointDisplayUrl } from '@/ui/utils/gateway-inference-paths' import { EngineCapabilities } from '@/ui/constants/engine-capabilities' +import EngineIcon from '@/ui/components/EngineIcon' import { statusLabel } from '@/ui/utils/status' /** Install/uninstall lines include asset names + percentages — allow more room than generic status. */ @@ -103,6 +104,7 @@ export function BackendHeader({ className={`transition-transform ${expanded ? 'rotate-180' : ''}`} /> )} + {backend.type === 'llamacpp' && } {backend.displayName} diff --git a/desktop/src/ui/components/BackendRow/BackendRow.tsx b/desktop/src/ui/components/BackendRow/BackendRow.tsx index c40b38bbe..f02ea6d9b 100644 --- a/desktop/src/ui/components/BackendRow/BackendRow.tsx +++ b/desktop/src/ui/components/BackendRow/BackendRow.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { useCallback, useEffect, useMemo, useState } from 'react' -import { Divider, Stack } from '@nvidia/foundations-react-core' +import { Divider, Stack, Text } from '@nvidia/foundations-react-core' import type { BackendInfo } from '@/ui/types/engine-info' import type { EngineProcessStatus } from '@/shared/types/engines' import { noEngineCapabilities } from '@/shared/utils/engines' @@ -188,7 +188,7 @@ export function BackendRow({ onUpdate={handleUpdate} /> - {canShowAccordions && ( + {canShowAccordions && backend.type !== 'llamacpp' && ( )} @@ -227,6 +227,12 @@ export function BackendRow({ canInstall={reported.install} onInstall={handleInstall} /> + {backend.type === 'llamacpp' && ( + + Not notarized, like PAIR itself. Does not serve requests through PAIR + yet. + + )} {expanded && } {content} diff --git a/desktop/src/ui/components/BackendRow/EngineSettingsSection.tsx b/desktop/src/ui/components/BackendRow/EngineSettingsSection.tsx index ff27225d3..2cd89490a 100644 --- a/desktop/src/ui/components/BackendRow/EngineSettingsSection.tsx +++ b/desktop/src/ui/components/BackendRow/EngineSettingsSection.tsx @@ -94,6 +94,7 @@ function SettingsEditor({ target, disabled }: { target: EngineSettingsTarget; di snapshot.format !== 'pair-arguments-v1' const locked = readOnly || disabled || applying const isOmlx = engine === 'omlx' + const hasProxy = engine !== 'llamacpp' useEffect(() => watchEngineSettings(), []) useEffect(() => { @@ -204,9 +205,9 @@ function SettingsEditor({ target, disabled }: { target: EngineSettingsTarget; di } const id = ++generation.current const serverPort = Number(draft.serverPort) - const proxyPort = Number(draft.proxyPort) + const proxyPort = hasProxy ? Number(draft.proxyPort) : 0 if ( - ![serverPort, proxyPort].every( + !(hasProxy ? [serverPort, proxyPort] : [serverPort]).every( port => Number.isInteger(port) && port >= 1 && port <= 65535 ) ) { @@ -289,17 +290,19 @@ function SettingsEditor({ target, disabled }: { target: EngineSettingsTarget; di aria-invalid={!!errors.serverPort} /> - - change('proxyPort', value)} - disabled={locked} - inputMode="numeric" - aria-invalid={!!errors.proxyPort} - /> - + {hasProxy && ( + + change('proxyPort', value)} + disabled={locked} + inputMode="numeric" + aria-invalid={!!errors.proxyPort} + /> + + )} {isOmlx ? ( diff --git a/desktop/src/ui/components/EngineIcon.tsx b/desktop/src/ui/components/EngineIcon.tsx index 4ee81367a..2d124ddad 100644 --- a/desktop/src/ui/components/EngineIcon.tsx +++ b/desktop/src/ui/components/EngineIcon.tsx @@ -4,6 +4,7 @@ import type { CSSProperties } from 'react' import { type EngineType } from '@/shared/types/engines' import ollamaIcon from '@/ui/assets/engine-icons/ollama.png?inline' +import llamaIcon from '@/ui/assets/engine-icons/llamacpp.svg?inline' import lmStudioIcon from '@/ui/assets/engine-icons/lm-studio.png?inline' export default function EngineIcon({ type, size = 32 }: { type: EngineType; size?: number }) { @@ -39,5 +40,13 @@ export default function EngineIcon({ type, size = 32 }: { type: EngineType; size ) } + if (type === 'llamacpp') { + return ( +
+ llama.cpp +
+ ) + } + return null } diff --git a/desktop/src/ui/constants/engine-capabilities.ts b/desktop/src/ui/constants/engine-capabilities.ts index 3291ed3ab..cfb44503e 100644 --- a/desktop/src/ui/constants/engine-capabilities.ts +++ b/desktop/src/ui/constants/engine-capabilities.ts @@ -13,6 +13,17 @@ import type { EngineCaps } from '@/ui/types/engine-manifest' * (`EngineCaps` carries no delete/restart/port policy). */ export const EngineCapabilities: Record = { + llamacpp: { + hasExpiry: false, + hasEject: false, + hasInstall: ['darwin'], + hasInstallPath: false, + hasProxyWebUI: false, + hasPreferredNode: false, + hasCrashAlert: false, + hasModelSearchOnlyWhenRunning: false, + modelOpsWhenStopped: false + }, ollama: { // Ollama has no model keep-alive/expiry UI; unload uses unload_model // (POST /api/generate with keep_alive: 0). See docs/services-parity.md#models. diff --git a/desktop/src/ui/constants/welcome.ts b/desktop/src/ui/constants/welcome.ts index 01b22fae8..601ce4996 100644 --- a/desktop/src/ui/constants/welcome.ts +++ b/desktop/src/ui/constants/welcome.ts @@ -14,12 +14,14 @@ export const WELCOME_STEP_SUB_HEADINGS = ['', 'You can update later by clicking export const WELCOME_ENGINE_DEFAULT_SELECTED: Record = { ollama: true, 'lm-studio': true, - omlx: true + omlx: true, + llamacpp: false } export function getWelcomeEngineCandidates(os: PlatformDisplayName): EngineType[] { const platform = displayNameToPlatform(os) return EnabledEngineTypes.filter(t => { + if (t === 'llamacpp') return false // lifecycle-only preview; install from its node row const caps = EngineCapabilities[t] if (!caps.hasInstall.length) return false if (caps.hasInstallPath) return false diff --git a/desktop/tests/modular/backend-row-capabilities.test.ts b/desktop/tests/modular/backend-row-capabilities.test.ts index dbef41592..707104b23 100644 --- a/desktop/tests/modular/backend-row-capabilities.test.ts +++ b/desktop/tests/modular/backend-row-capabilities.test.ts @@ -115,6 +115,26 @@ function expand(): void { } describe('BackendRow reported capability gates', () => { + it('discloses llama.cpp trust and deferred routing before installation', () => { + act(() => + root.render( + createElement(BackendRow, { + backend: { + ...backend, + type: 'llamacpp', + displayName: 'llama.cpp', + processStatus: 'not-installed' + }, + nodeId: 'local', + statusKnown: true + }) + ) + ) + expect(container.textContent).toContain('Not notarized, like PAIR itself.') + expect(container.textContent).toContain('Does not serve requests through PAIR yet.') + expect(container.querySelector('img[alt="llama.cpp"]')).not.toBeNull() + }) + beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) vi.stubGlobal('pairApi', { diff --git a/desktop/tests/modular/engine-settings-bridge.test.ts b/desktop/tests/modular/engine-settings-bridge.test.ts index e520dcb52..f9e32bdc0 100644 --- a/desktop/tests/modular/engine-settings-bridge.test.ts +++ b/desktop/tests/modular/engine-settings-bridge.test.ts @@ -61,6 +61,21 @@ describe('unified settings bridge', () => { ) }) + it('updates llama.cpp through atomic install without uninstalling the working tree', async () => { + await handleServiceBridgeInvoke('engine:command', { + command: 'update', + engineType: 'llamacpp', + nodeId: 'local-node' + }) + expect(mocks.supervisor.sendProcess).toHaveBeenCalledExactlyOnceWith( + 'broker', + 'engine:install', + { engine: 'llamacpp', start: true }, + expect.any(Function), + true + ) + }) + // Applying settings can stop, rebind, and restart an engine, so it needs // the Apply deadline rather than the shorter lifecycle request timeout. it('sends one node-owned apply with an end-to-end deadline', async () => { diff --git a/desktop/tests/modular/engine-settings-panel.test.ts b/desktop/tests/modular/engine-settings-panel.test.ts index 9f43ff147..5fb401979 100644 --- a/desktop/tests/modular/engine-settings-panel.test.ts +++ b/desktop/tests/modular/engine-settings-panel.test.ts @@ -161,6 +161,28 @@ afterEach(async () => { }) describe('engine launch settings panel', () => { + it('edits llama.cpp server and launch settings without a proxy port', async () => { + getSettings.mockImplementation(async target => ({ + ...baseline, + ...target, + settings: { serverPort: 8082, proxyPort: 0, launchText: '' }, + effectiveServerPort: 8082, + effectiveProxyPort: 0 + })) + await render('llamacpp') + expect(container.querySelector('[aria-label="Proxy port"]')).toBeNull() + expect(field('Server port').disabled).toBe(false) + await change('Engine arguments', 'CUSTOM=value --threads 4') + await click('Apply') + expect(previewSettings).toHaveBeenCalledWith( + expect.objectContaining({ + engine: 'llamacpp', + settings: { serverPort: 8082, proxyPort: 0, launchText: 'CUSTOM=value --threads 4' } + }) + ) + expect(applySettings).toHaveBeenCalledOnce() + }) + it('loads read-only, then enables editing when settings arrive', async () => { let finish = (_snapshot: EngineSettingsSnapshot): void => {} getSettings.mockImplementationOnce( diff --git a/desktop/tests/modular/llamacpp-lifecycle.test.ts b/desktop/tests/modular/llamacpp-lifecycle.test.ts new file mode 100644 index 000000000..c5f7f51c2 --- /dev/null +++ b/desktop/tests/modular/llamacpp-lifecycle.test.ts @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from 'vitest' +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) +vi.mock('@/electron/window', () => ({ createOverviewWindow: vi.fn() })) +import { getModularBridgeState, isProxyEngine } from '@/electron/service-bridge/modular-state' +import { parseEngineSettings } from '@/electron/service-bridge/engine-settings' + +const capabilities = { + list_models: false, + pull_model: false, + delete_model: false, + delete_restarts_engine: false, + set_port: true, + install: true, + uninstall: true +} + +describe('llama.cpp lifecycle-only projection', () => { + it('shows local installed and running facts without a proxy or model capabilities', () => { + const state = getModularBridgeState() + state.setSelfId('llama-self') + for (const running of [false, true]) { + state.applyEngineManagerStatus({ + engine: 'llamacpp', + installed: true, + running, + port: 8082, + capabilities + }) + const status = state + .getEngineInitialState() + .statuses.find(s => s.nodeId === 'llama-self' && s.engineType === 'llamacpp') + expect(status).toMatchObject({ + processStatus: running ? 'running' : 'stopped', + enginePort: 8082, + proxyPort: null, + capabilities: { install: true, listModels: false, pullModel: false } + }) + } + expect(isProxyEngine('llamacpp')).toBe(false) + }) + + it('shows remote lifecycle facts without attributing a routed endpoint', () => { + const state = getModularBridgeState() + state.setSelfId('llama-self') + state.handleNotification({ + source: 'broker', + method: 'discovery:nodes-changed', + params: { + nodes: [ + { + identityVerification: 'verified', + peerRecordId: 'llama-proof', + hostUuid: 'llama-peer', + name: 'Peer', + ipAddress: '192.0.2.80', + port: 14318, + trusted: true, + clustered: true + } + ] + } + }) + state.applyRemoteEngineFacts('llama-peer', { + engines: [ + { + engine: 'llamacpp', + installed: true, + running: true, + healthy: true, + port: 8082, + capabilities + } + ] + }) + expect( + state + .getEngineInitialState() + .statuses.find(s => s.nodeId === 'llama-peer' && s.engineType === 'llamacpp') + ).toMatchObject({ processStatus: 'running', enginePort: 8082, proxyPort: null }) + expect( + state + .getEngineInitialState() + .models.filter(s => s.nodeId === 'llama-peer' && s.engineType === 'llamacpp') + ).toEqual([]) + expect(state.isRemoteEngineRunning('llama-peer', 'llamacpp')).toBe(true) + }) + + it('accepts the zero-proxy-port settings snapshot without adding load profiles', () => { + expect( + parseEngineSettings({ + nodeId: 'llama-self', + engine: 'llamacpp', + phase: 'idle', + epoch: 'fixture', + sequence: 1, + revision: 1, + appliedRevision: 1, + settings: { serverPort: 8082, proxyPort: 0, launchText: '' }, + effectiveServerPort: 8082, + effectiveProxyPort: 0, + running: false, + adopted: false, + editable: true, + format: 'pair-arguments-v1' + }) + ).toMatchObject({ engine: 'llamacpp', settings: { proxyPort: 0 } }) + }) +}) diff --git a/docs/engine-lifecycle.mdx b/docs/engine-lifecycle.mdx index ae0e8268b..928d9f9e3 100644 --- a/docs/engine-lifecycle.mdx +++ b/docs/engine-lifecycle.mdx @@ -6,8 +6,11 @@ SPDX-License-Identifier: Apache-2.0 # Managing Engines in NVIDIA Personal AI Router An **engine** is the local inference runtime Personal AI Router (PAIR) uses to -run models. Today that means Ollama or LM Studio on a given machine. PAIR can -install and run those engines for you, or work with a copy you already have. +run models. PAIR supports Ollama, LM Studio and oMLX. It can install and run +those engines for you, or work with a copy you already have. A macOS arm64 +**llama.cpp lifecycle-only preview** also appears in Engine settings: it can be +installed, started, stopped and configured, but **does not serve requests through +PAIR yet**. 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. @@ -63,6 +66,25 @@ It does not do everything that the application does. Check that the terminal interface covers your workflow before relying on it. Refer also to [Getting started](getting-started.mdx). +## llama.cpp preview + +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 +controls, including Unix-socket host values and port-changing presets. Readiness +requires the `/props` router identity, 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. + +Installation verifies the pinned SHA-256, bounded contained extraction, expected +server/libraries/Metal layout, absence of quarantine and existing signature +integrity. PAIR never clears quarantine or re-signs the download. Stop the engine +before updating: archive validation failures keep the old installation, and +successful replacements exchange the complete directory atomically. Uninstall +requires PAIR's identity-bound install claim and preserves `{install_dir}-models`. +See the [release pin, parser audit and safeguards](../services/nvpair-engine-manager/LLAMACPP.md). +Packaged live smoke testing remains a maintainer pre-merge check. + ## Installing an Engine You can install a supported engine during first-run setup or later from diff --git a/services/nvpair-engine-manager/LAUNCH_TEXT.md b/services/nvpair-engine-manager/LAUNCH_TEXT.md index b45d6a708..434c9d80e 100644 --- a/services/nvpair-engine-manager/LAUNCH_TEXT.md +++ b/services/nvpair-engine-manager/LAUNCH_TEXT.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: Apache-2.0 # Engine launch text -Ollama and LM Studio accept editable arguments and leading environment +Ollama, LM Studio and llama.cpp accept editable arguments and leading environment assignments in `pair-arguments-v1` notation. The executable and startup subcommand remain manifest-owned. oMLX is launched unchanged through `omlx start`; its app manages server settings. Only its PAIR proxy port is editable. @@ -40,7 +40,10 @@ numeric server-port and proxy-port fields. Managed flag aliases, equals forms, attached short values and ambiguous bundles containing a managed short option are rejected, including after `--`. The bundled protected controls include Ollama's `OLLAMA_HOST` and LM Studio's `--port`/`-p`, `--bind`/`--host`, -`LMS_SERVER_PORT` and `LMS_SERVER_HOST`. +`LMS_SERVER_PORT` and `LMS_SERVER_HOST`. llama.cpp also protects `--host`, +`--port`, `LLAMA_ARG_HOST`, `LLAMA_ARG_PORT`, port-changing presets, reuse-port +controls and the PAIR-owned cache. See the [complete b12171 audit](LLAMACPP.md#binding-audit-b12171). +llama.cpp has a server-port field only; its proxy port remains zero until a later PR. Saved overrides undergo the same checks before execution. Launch settings do not change the existing bind policy: default loopback and the explicit live diff --git a/services/nvpair-engine-manager/LLAMACPP.md b/services/nvpair-engine-manager/LLAMACPP.md new file mode 100644 index 000000000..99e2d3dca --- /dev/null +++ b/services/nvpair-engine-manager/LLAMACPP.md @@ -0,0 +1,128 @@ + + +# llama.cpp: lifecycle-only preview + +PAIR manages the official **b12171 macOS arm64** build. Other platforms are +unsupported. It can install, start, stop and configure the engine, but **llama.cpp +does not serve requests through PAIR yet**. There is no proxy worker, routing, +model hub, download/delete adapter or load profile in this change. + +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 +ad-hoc signature is preserved and checked for integrity with the system +`codesign --verify --strict --all-architectures` on **every Mach-O file**, +identified by thin/fat magic bytes rather than extension or execute bits. This +includes adjacent dylibs, extensionless code and every universal-binary slice. +Missing codesign or a broken signature fails installation. Quarantine/signature +checks run again for the same-pin already-installed return and before every +spawn, with required layout and link containment revalidated. Quarantined files fail installation naming the file; PAIR never +clears quarantine, re-signs code or bypasses the operating system's checks. + +Artifact: `llama-b12171-bin-macos-arm64.tar.gz` from ggml-org/llama.cpp, SHA-256 +`870ef967970fa18bbd31460bc5f7929bbb5f091359d8dc0f58e20133aa4b5fcd`. +The archive wraps files in `llama-b12171/`; installation strips that component. +The required layout was derived from that release archive and `otool -L`: +`llama-server`, `LICENSE`, `libllama-server-impl.dylib`, all versioned and alias +names for `libllama`, `libllama-common`, `libmtmd` (0.9.0), and `libggml`, +`libggml-base`, `libggml-blas`, `libggml-cpu`, `libggml-metal`, `libggml-rpc` +(0.29.0). Metal resources are bundled in the Metal library, not a separate +`.metallib` or `.metal` file in this archive. Internal library symlinks stay intact. +See [archive safeguards](MANIFEST.md#pinned-macos-archive-installs) for limits, +atomic replacement/rollback and identity-bound uninstall. Updating requires the +engine to be stopped; repeated `engine:install` applies a changed manifest pin. + +Default listener: **127.0.0.1:8082**, distinct from the fork's other engine and +service ports. Configured-port reservations and occupied-port checks still apply. +Readiness and health require `/props` to return the top-level `"role":"router"` +identity, present in b12171 `tools/server/server-models.cpp` (`get_router_props`). +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. + +## Launch allowlist (b12171) + +Unlike #48's open Ollama/LM Studio launch text, llama.cpp's real parser exposes +host tools, agents, tunnels, MCP, filesystem paths and model downloads. As with +#49's LM Studio load options, only explicitly typed inference/resource tuning is +accepted. Unknown options, positional arguments, `--`, repeated options (including +aliases or opposing switches), non-ASCII and **all** environment assignments are +invalid. Saved settings use the same checks; invalid settings are reported, not +dropped or applied. No model/download or template-file option is permitted. + +Allowed value options (documented short aliases included): `--ctx-size/-c`, +`--batch-size/-b`, `--ubatch-size/-ub` (context 0–1048576, batches 1–1048576), +`--threads/-t`, `--threads-batch/-tb` (1–4096), +`--gpu-layers/--n-gpu-layers/-ngl` (0–1048576, `auto`, `all`), +`--flash-attn/-fa` (`on/off/auto`), `--cache-type-k/-ctk`, `--cache-type-v/-ctv` +(`f32/f16/bf16/q8_0/q4_0/q4_1/q5_0/q5_1/iq4_nl`), `--parallel/-np` (1–1024), +`--predict/--n-predict/-n` (-1–1048576), `--temp/--temperature` (0–10), +`--top-k` (0–1048576), `--top-p`, `--min-p` (0–1), +`--chat-template` (only named built-ins from b12171 `src/llama-chat.cpp`), +`--reasoning-format` (`none/auto/deepseek/deepseek-legacy`), +`--reasoning/-rea` (`on/off/auto`), `--reasoning-effort` +(`default/minimal/low/medium/high/xhigh/max`), `--reasoning-budget` (-1–1048576), +`--load-mode/-lm` (`auto/none/mmap/mlock/mmap+mlock/dio`). +`--flag value` and `--flag=value` are normalized to separate argv values because +b12171's parser requires them. Presence-only boolean switches: `--cont-batching/-cb`, +`--no-cont-batching/-nocb`, `--kv-offload/-kvo`, `--no-kv-offload/-nkvo`, +`--metrics`, `--slots`, `--no-slots`. Every allowlisted name is checked against +`testdata/llamacpp-b12171-options.txt`, the option names b12171 defines; regenerate +it from `common/arg.cpp` when the pinned release changes. +No arbitrary template programs, paths, URLs or non-finite numeric values are allowed. + +## Binding audit (b12171) + +Reviewed the real parser at +[common/arg.cpp](https://github.com/ggml-org/llama.cpp/blob/b12171/common/arg.cpp), +including every assignment to `hostnames`, `port` and `reuse_port`, its environment +handling and long-option normalization: + +- `--host` / `LLAMA_ARG_HOST`: IP lists **or Unix socket paths ending in `.sock`**. + There is no separate Unix-socket option or short host alias in this parser. +- `--port` / `LLAMA_ARG_PORT`: TCP port; no short port alias. +- `--reuse-port` / `LLAMA_ARG_REUSE_PORT`: enables multiple sockets on the port. + PAIR sets `0` only after excluding configuration files; `0` cannot undo a + config-enabled reuse-port value in this tag. +- Port-changing presets (no environment aliases): `--embd-gemma-default`, + `--fim-qwen-1.5b-default`, `--fim-qwen-3b-default`, `--fim-qwen-7b-default`, + `--fim-qwen-7b-spec`, `--fim-qwen-14b-spec`, `--fim-qwen-30b-default`, + `--gpt-oss-20b-default`, `--gpt-oss-120b-default`, `--vision-gemma-4b-default`, + `--vision-gemma-12b-default`. + +All are forbidden in editable launch input, including matching values, +duplicates, `--flag=value`, underscore spellings (`--reuse_port`, etc.) and tokens +after `--`. Protected environment assignments are rejected even in argument +positions. `LLAMA_CACHE` is also protected to keep the managed cache identity. +The child inherits **no** parent environment. This removes all `LLAMA_ARG_*`, +`LLAMA_CACHE`, `HF_TOKEN`, `HF_ENDPOINT`, `MODEL_ENDPOINT`, `MTMD_BACKEND_DEVICE`, +`LLAMA_APP_CMD` and dynamic-loader overrides, rather than maintaining a removal +denylist. PAIR supplies only manifest-owned values: cache, listener, reuse-port=0, +empty API prefix, offline mode, fixed system PATH and child-only HOME/XDG paths. +The configuration audit uses b12171 `common/arg.cpp`: +`common_params_apply_system_config` always reads `/etc/llama.cpp/config.ini` and +user `config.ini` **before** environment/CLI; there is no disable-config option. +`common/common.cpp:fs_get_config_directory` uses `XDG_CONFIG_HOME`, then +`HOME/.config` (not macOS Application Support). PAIR points HOME/XDG_CONFIG_HOME +at `{install_dir}/.pair-home` and `{install_dir}/.pair-config`; the actual user +configuration cannot be found. Before every spawn it refuses any file (including +symlinks or inspection failures) at `/etc/llama.cpp/config.ini` or +`{install_dir}/.pair-config/llama.cpp/config.ini`, naming the path. It never +modifies ambient files. Thus a config enabling reuse-port or a tool runtime is +never applied. `common/common.cpp:fs_get_cache_directory` and +`common_get_model_endpoint`, and `common/arg.cpp` environment handlers are the audited +cache/download configuration sources. Additional launch options stay literal, +without shell evaluation. Non-loopback binding is available only through +the existing validated start path with `engine_allow_lan_bind`, not launch text. + +Selected lifecycle/manifest concepts are adapted from +NVIDIA/Personal-AI-Router@6d2d67f; its unified-proxy plumbing is not included. +NVIDIA/Personal-AI-Router#153 informs identity-bound containment/removal; +NVIDIA/Personal-AI-Router#186's optional ModelScope work remains deferred. + +Packaged install, update, inference and stop/restart smoke testing is still a +maintainer pre-merge task. Development tests use generated fixtures and never +execute a downloaded release binary. diff --git a/services/nvpair-engine-manager/MANIFEST.md b/services/nvpair-engine-manager/MANIFEST.md index ce7169422..181d1fbf0 100644 --- a/services/nvpair-engine-manager/MANIFEST.md +++ b/services/nvpair-engine-manager/MANIFEST.md @@ -5,6 +5,33 @@ SPDX-License-Identifier: Apache-2.0 # Engine manifest capabilities +## Pinned macOS archive installs + +`install.archive` is an alternative to `install.disk_image`, scripts and commands. +It requires a single checksum-pinned `fetch`, a nonempty `required` relative-file +list and a `codesign` target in that list. Files are unpacked with one wrapper +component stripped into a private sibling staging directory, never by a shell. +The runner rejects absolute/traversing paths, escaping or invalid links, duplicate +case-alias paths, special files, setuid/setgid modes, quarantine metadata, more +than 512 entries or 2 GiB of expanded data. Internal library links are preserved. +Required files must be nonempty regular files (or contained links to them). + +On macOS the source archive and every extracted entry are checked for `com.apple.quarantine`; any such +attribute fails installation naming the file, without changing the attribute. +The system `/usr/bin/codesign --verify --strict ` must succeed; this +checks existing signature integrity, not notarization or publisher trust. PAIR +never re-signs the files. Other platforms fail closed. + +The completed staging directory includes an identity-bound PAIR install claim +and artifact digest. First installation uses exclusive rename; updates atomically +exchange the complete directories using macOS `RENAME_SWAP`. Extraction, +validation, claim-write or exchange failure leaves the previous install intact. +Repeated `engine:install` is a no-op for the same claimed digest; a changed pinned +digest replaces a stopped, claimed installation. Stop the engine before updating. +Unclaimed installations are never replaced or uninstalled. Archive installs may +use the special `models_dir: "{install_dir}-models"` sibling cache; uninstall +removes only the engine tree, never this cache. There is no separate update RPC. + ## Command-mode shutdown confirmation `runtime.stop.wait_for_exit` is an optional boolean (default `false`). It @@ -30,6 +57,15 @@ mutex is never held across lookup, command execution, or confirmation; the lifecycle operation lock still serializes stop/start. Failed-start cleanup uses the same confirmation policy. +## JSON identity probes + +HTTP `runtime.ready` and `runtime.health` may declare +`"json_match": {"field":"role", "value":"router"}`. A successful status alone +then does not identify the engine: a valid JSON object, within 64 KiB, must +contain that top-level string value. Invalid, missing, differently typed or +mismatching fields fail readiness. This uses llama.cpp b12171's `/props` router +identity; a model-mode instance does not qualify. + ## Editable launch manifest bindings The engine-manager runner remains manifest-driven. `runtime.editable_launch` @@ -61,6 +97,11 @@ assignments pass through literally after trusted manifest expansion. - The numeric server-port field supplies `server.port`. Existing runtime bind policy supplies `server.host`; no user launch setting can replace either. These controls are rejected in editable input, including their aliases. +- `forbidden_flags` and `forbidden_env` reject additional vendor inputs that + would undermine managed state without mapping them to a port field. + `normalize_flag_underscores` matches vendors that accept long-option underscore + aliases. All tokens are checked, including after `--` and assignments supplied + in argument positions. Fixed managed defaults are not shown as editable text. - The proxy listener belongs to the broker and is never a launch binding. [manifest.schema.json](manifest.schema.json) supplies editor validation; Go diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md index b3a39a893..6114d1b2f 100644 --- a/services/nvpair-engine-manager/README.md +++ b/services/nvpair-engine-manager/README.md @@ -11,8 +11,15 @@ engine **except serving inference**: detect, user-mode install, start/stop/restart, health, and config-declared actions. Adding an engine is a JSON manifest, not code. +[llama.cpp](LLAMACPP.md) is a macOS arm64 lifecycle-only preview, pinned to +b12171. Its macOS build is not notarized, like PAIR itself. It does not serve +requests through PAIR yet; proxy/routing and model integration are deferred. + The bundled manifests under `manifests/` are the working reference for manifest -authoring. +authoring. [Pinned archive installs](MANIFEST.md#pinned-macos-archive-installs) +use bounded extraction, signature/quarantine verification and atomic replacement; +repeating `engine:install` updates a stopped, PAIR-owned archive installation when +the manifest's pinned artifact changes, preserving its sibling model cache. ## Worker control health diff --git a/services/nvpair-engine-manager/archive.go b/services/nvpair-engine-manager/archive.go new file mode 100644 index 000000000..846e42f9d --- /dev/null +++ b/services/nvpair-engine-manager/archive.go @@ -0,0 +1,358 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "archive/tar" + "compress/gzip" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + "time" +) + +const archiveMaxBytes int64 = 2 << 30 +const archiveMaxFiles = 512 + +// ArchiveInstall unpacks a pinned tar.gz into a private sibling directory. +// Required names are relative to the stripped wrapper. Codesign identifies the +// executable whose existing signature must verify; it is never executed. +type ArchiveInstall struct { + Required []string `json:"required"` + Codesign string `json:"codesign"` +} + +func safeArchivePath(name string) bool { + if name == "" || path.IsAbs(name) || strings.ContainsAny(name, "\\:\x00") { + return false + } + for _, part := range strings.Split(name, "/") { + if part == ".." { + return false + } + } + return true +} + +func stripArchivePath(name string) (string, error) { + if !safeArchivePath(name) { + return "", fmt.Errorf("unsafe archive path %q", name) + } + _, rest, _ := strings.Cut(strings.TrimSuffix(name, "/"), "/") + if rest == "" { + return "", nil + } + rest = path.Clean(rest) + if rest == "." { + return "", nil + } + return rest, nil +} + +// Links are created only after every regular file, so no archive entry can +// write through a link. A final resolution pass also rejects cycles, dangling +// links and links outside the staging root. Duplicate/case-alias names are +// refused rather than depending on the destination filesystem's case rules. +func extractTarGzip(ctx context.Context, source, root string, maxBytes int64, maxFiles int) error { + root, err := filepath.EvalSymlinks(root) + if err != nil { + return err + } + f, err := os.Open(source) + if err != nil { + return err + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + bounded := &io.LimitedReader{R: gz, N: maxBytes + int64(maxFiles)*4096 + 1} + tr := tar.NewReader(bounded) + type link struct { + name, target string + hard bool + } + var links []link + seen := map[string]bool{} + var total int64 + count := 0 + for { + if err := ctx.Err(); err != nil { + return err + } + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("read archive: %w", err) + } + count++ + if count > maxFiles { + return fmt.Errorf("archive exceeds %d entries", maxFiles) + } + name, err := stripArchivePath(h.Name) + if err != nil { + return err + } + if h.Mode&06000 != 0 { + return fmt.Errorf("archive file %q has setuid/setgid bits", h.Name) + } + for key := range h.PAXRecords { + if strings.Contains(strings.ToLower(key), "com.apple.quarantine") { + return fmt.Errorf("cannot install %s: it carries com.apple.quarantine; leave the file unchanged and obtain an unquarantined official archive", h.Name) + } + } + if h.Typeflag != tar.TypeReg && h.Typeflag != tar.TypeDir && h.Typeflag != tar.TypeSymlink && h.Typeflag != tar.TypeLink { + return fmt.Errorf("unsupported archive entry %q", h.Name) + } + if name == "" { + if h.Typeflag != tar.TypeDir { + return fmt.Errorf("archive file %q has no path after stripping its wrapper", h.Name) + } + continue + } + key := strings.ToLower(name) + if seen[key] { + return fmt.Errorf("duplicate archive path %q", name) + } + seen[key] = true + dest := filepath.Join(root, filepath.FromSlash(name)) + switch h.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(dest, 0755); err != nil { + return err + } + case tar.TypeReg: + if h.Size < 0 || h.Size > maxBytes-total { + return fmt.Errorf("archive exceeds %d extracted bytes", maxBytes) + } + total += h.Size + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return err + } + out, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, os.FileMode(h.Mode)&0777) + if err != nil { + return err + } + _, copyErr := io.CopyN(out, tr, h.Size) + closeErr := out.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + case tar.TypeSymlink, tar.TypeLink: + if !safeArchivePath(h.Linkname) { + return fmt.Errorf("unsafe archive link %q", h.Name) + } + target := h.Linkname + if h.Typeflag == tar.TypeLink { + target, err = stripArchivePath(target) + if err != nil || target == "" { + return fmt.Errorf("unsafe archive hardlink %q", h.Name) + } + } + links = append(links, link{name, target, h.Typeflag == tar.TypeLink}) + } + } + // Consume the gzip trailer too: a truncated or corrupt stream must not be + // accepted merely because the tar end marker occurred before the trailer. + if _, err := io.Copy(io.Discard, bounded); err != nil { + return err + } + if bounded.N <= 0 { + return fmt.Errorf("archive decompression limit exceeded") + } + for _, l := range links { + dest := filepath.Join(root, filepath.FromSlash(l.name)) + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return err + } + if l.hard { + continue + } + if err := os.Symlink(filepath.FromSlash(l.target), dest); err != nil { + return err + } + } + for _, l := range links { + if l.hard { + continue + } + dest := filepath.Join(root, filepath.FromSlash(l.name)) + resolved, err := filepath.EvalSymlinks(dest) + if err != nil || !pathWithinRoot(root, resolved) { + return fmt.Errorf("archive link %q does not resolve inside the install directory", l.name) + } + } + for _, l := range links { + if !l.hard { + continue + } + target := filepath.Join(root, filepath.FromSlash(l.target)) + info, err := os.Lstat(target) + if err != nil || !info.Mode().IsRegular() { + return fmt.Errorf("invalid archive hardlink %q", l.name) + } + if err := os.Link(target, filepath.Join(root, filepath.FromSlash(l.name))); err != nil { + return err + } + } + return nil +} + +func installArchive(ctx context.Context, source, target, engine, digest string, spec *ArchiveInstall, verify func(context.Context, string, *ArchiveInstall) error) error { + if err := rejectArchiveQuarantine(source); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { + return err + } + exists := false + oldID := "" + if _, err := os.Lstat(target); err == nil { + if !installedByPAIR(target, target) { + return fmt.Errorf("PAIR has no identity-bound record of installing %s; leaving it unchanged", target) + } + exists = true + oldID = fileIdentity(target) + } else if !os.IsNotExist(err) { + return err + } + stage, err := os.MkdirTemp(filepath.Dir(target), ".engine-stage-*") + if err != nil { + return err + } + defer os.RemoveAll(stage) + stage, err = filepath.EvalSymlinks(stage) + if err != nil { + return err + } + if err := extractTarGzip(ctx, source, stage, archiveMaxBytes, archiveMaxFiles); err != nil { + return err + } + for _, name := range spec.Required { + if !safeArchivePath(name) { + return fmt.Errorf("invalid required archive path %q", name) + } + p := filepath.Join(stage, filepath.FromSlash(name)) + resolved, err := filepath.EvalSymlinks(p) + if err != nil || !pathWithinRoot(stage, resolved) { + return fmt.Errorf("required archive file %q is missing or unsafe", name) + } + info, err := os.Stat(p) + if err != nil || !info.Mode().IsRegular() || info.Size() == 0 { + return fmt.Errorf("required archive file %q is missing or empty", name) + } + } + if err := verify(ctx, stage, spec); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + marker := installMarker{Engine: engine, InstalledAt: time.Now().UTC().Format(time.RFC3339), Creates: target, Identity: fileIdentity(stage), ArtifactSHA256: digest} + if marker.Identity == "" { + return fmt.Errorf("cannot establish install directory identity") + } + if err := writeJSONAtomic(filepath.Join(stage, installMarkerName), marker); err != nil { + return err + } + if exists { + if oldID == "" || fileIdentity(target) != oldID || !installedByPAIR(target, target) { + return fmt.Errorf("PAIR install identity changed during update; leaving it unchanged") + } + // Exchange is a single atomic filesystem operation. After success stage + // holds the previous tree and its previous claim, removed by the defer. + return swapArchiveDirectories(stage, target) + } + return renameArchiveNew(stage, target) +} + +func (i *Install) validateArchive() error { + if i.Fetch == nil || len(i.FetchVariants) > 0 || len(i.Script) > 0 || len(i.Run) > 0 || i.DiskImage != nil || i.Creates != "" || i.ModeOrDefault() != "user" { + return fmt.Errorf("install.archive requires one pinned fetch in user mode, without script, run, disk_image or creates") + } + digest, err := hex.DecodeString(i.Fetch.SHA256) + if err != nil || len(digest) != 32 { + return fmt.Errorf("install.archive requires a SHA-256 checksum") + } + spec := i.Archive + if len(spec.Required) == 0 || !safeArchivePath(spec.Codesign) { + return fmt.Errorf("install.archive requires files and a safe codesign target") + } + found := false + for _, name := range spec.Required { + if !safeArchivePath(name) || path.Clean(name) != name || name == "." { + return fmt.Errorf("invalid required archive path %q", name) + } + if name == spec.Codesign { + found = true + } + } + if !found { + return fmt.Errorf("install.archive codesign target must be required") + } + return nil +} + +func (e *Executor) installManagedArchive(ctx context.Context, engine string, st *engineState) (err error) { + defer func() { + if err != nil { + e.reportInstallFailed(engine, err) + } + }() + inst := st.plat.Install + if err := inst.validateArchive(); err != nil { + return err + } + if ok, _ := e.Detect(engine); ok { + if !installedByPAIR(st.installDir, st.installDir) { + return fmt.Errorf("PAIR has no identity-bound record of installing %s; leaving it unchanged", engine) + } + data, readErr := os.ReadFile(installMarkerPath(st.installDir)) + var marker installMarker + if readErr == nil && json.Unmarshal(data, &marker) == nil && marker.Engine == engine && strings.EqualFold(marker.ArtifactSHA256, inst.Fetch.SHA256) { + if err := verifyArchivePlatform(ctx, st.installDir, inst.Archive); err != nil { + return err + } + e.reporter.clear(installFailedID(engine)) + e.emitInstallProgress(engine, "already-installed", 100) + return nil + } + } + st.mu.Lock() + port := st.port + running := st.running + st.mu.Unlock() + presence := e.reconcilePresence(ctx, engine, st, fileExists(filepath.Join(st.installDir, inst.Archive.Codesign)), port, false) + if running || presence.Occupied { + return fmt.Errorf("stop %s before installing or updating it; its server port must be available", st.manifest.DisplayName) + } + e.emitInstallProgress(engine, "downloading", 0) + download, err := e.download(ctx, engine, inst.Fetch) + if err != nil { + return err + } + defer os.Remove(download) + e.emitInstallProgress(engine, "installing", 0) + if err := installArchive(ctx, download, st.installDir, engine, inst.Fetch.SHA256, inst.Archive, verifyArchivePlatform); err != nil { + return err + } + e.Detect(engine) + e.reporter.clear(installFailedID(engine)) + e.emitInstallProgress(engine, "done", 100) + e.emitState(engine) + return nil +} diff --git a/services/nvpair-engine-manager/archive_darwin.go b/services/nvpair-engine-manager/archive_darwin.go new file mode 100644 index 000000000..aaff5f82f --- /dev/null +++ b/services/nvpair-engine-manager/archive_darwin.go @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + + "golang.org/x/sys/unix" +) + +func swapArchiveDirectories(stage, target string) error { + return unix.RenameatxNp(unix.AT_FDCWD, stage, unix.AT_FDCWD, target, unix.RENAME_SWAP) +} + +func renameArchiveNew(stage, target string) error { + return unix.RenameatxNp(unix.AT_FDCWD, stage, unix.AT_FDCWD, target, unix.RENAME_EXCL) +} + +func rejectArchiveQuarantine(p string) error { + _, err := unix.Lgetxattr(p, "com.apple.quarantine", nil) + if err == nil { + return fmt.Errorf("cannot install %s: it carries com.apple.quarantine; the file was left unchanged", p) + } + if !errors.Is(err, unix.ENOATTR) { + return fmt.Errorf("cannot check quarantine for %s: %w", p, err) + } + return nil +} + +func verifyArchivePlatform(ctx context.Context, root string, spec *ArchiveInstall) error { + if err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + return rejectArchiveQuarantine(p) + }); err != nil { + return err + } + return verifyArchiveSignature(ctx, root, spec, "/usr/bin/codesign") +} + +// argv only; never re-sign, execute downloaded code or alter quarantine. +// The fixed system tool is required, not a PATH-provided replacement. +func verifyArchiveSignature(ctx context.Context, root string, spec *ArchiveInstall, tool string) error { + if !safeArchivePath(spec.Codesign) { + return fmt.Errorf("invalid signature target") + } + info, err := os.Lstat(root) + if err != nil || !info.IsDir() { + return fmt.Errorf("signature root is not a directory") + } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return err + } + for _, name := range spec.Required { + if !safeArchivePath(name) { + return fmt.Errorf("invalid required archive path") + } + path := filepath.Join(root, filepath.FromSlash(name)) + resolved, err := filepath.EvalSymlinks(path) + if err != nil || !pathWithinRoot(root, resolved) { + return fmt.Errorf("required archive file %s is missing or unsafe", name) + } + info, err := os.Stat(resolved) + if err != nil || !info.Mode().IsRegular() || info.Size() == 0 { + return fmt.Errorf("required archive file %s is missing or empty", name) + } + } + target := filepath.Join(root, filepath.FromSlash(spec.Codesign)) + info, err = os.Stat(target) + if err != nil || !info.Mode().IsRegular() || info.Mode()&0111 == 0 { + return fmt.Errorf("signature target %s is missing or not executable", spec.Codesign) + } + mach, err := archiveMachO(target) + if err != nil { + return err + } + if !mach { + return fmt.Errorf("signature target %s is not Mach-O", spec.Codesign) + } + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.Type()&os.ModeSymlink != 0 { + resolved, err := filepath.EvalSymlinks(path) + if err != nil || !pathWithinRoot(root, resolved) { + return fmt.Errorf("unsafe installed archive link %s", path) + } + return nil // The contained target is visited as a regular file. + } + info, err := d.Info() + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if !info.Mode().IsRegular() { + return fmt.Errorf("unsupported installed archive file %s", path) + } + mach, err := archiveMachO(path) + if err != nil { + return err + } + if !mach { + return nil + } + if err := exec.CommandContext(ctx, tool, "--verify", "--strict", "--all-architectures", path).Run(); err != nil { + return fmt.Errorf("cannot verify the existing signature of %s (codesign is required): %w", path, err) + } + return nil + }) +} + +// Thin 32/64-bit and fat 32/64-bit Mach-O, in both byte orders. File names +// and execute bits do not identify code: adjacent dylibs often have neither. +func archiveMachO(path string) (bool, error) { + f, err := os.Open(path) + if err != nil { + return false, err + } + defer f.Close() + var magic [4]byte + if _, err := io.ReadFull(f, magic[:]); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return false, nil + } + return false, err + } + switch binary.BigEndian.Uint32(magic[:]) { + case 0xfeedface, 0xcefaedfe, 0xfeedfacf, 0xcffaedfe, 0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca: + return true, nil + } + return false, nil +} diff --git a/services/nvpair-engine-manager/archive_darwin_test.go b/services/nvpair-engine-manager/archive_darwin_test.go new file mode 100644 index 000000000..698e64e74 --- /dev/null +++ b/services/nvpair-engine-manager/archive_darwin_test.go @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "debug/macho" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "golang.org/x/sys/unix" +) + +// Copy an already signed system fixture; never sign or execute it. +func signedArchiveFixture(t *testing.T) (string, *ArchiveInstall) { + t.Helper() + root := t.TempDir() + b, err := os.ReadFile("/usr/bin/true") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "llama-server"), b, 0755); err != nil { + t.Fatal(err) + } + return root, &ArchiveInstall{Required: []string{"llama-server"}, Codesign: "llama-server"} +} + +func TestArchiveSignatureIntegrity(t *testing.T) { + root, spec := signedArchiveFixture(t) + if err := verifyArchivePlatform(context.Background(), root, spec); err != nil { + t.Fatal(err) + } + p := filepath.Join(root, "llama-server") + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + b[len(b)/2] ^= 0xff + if err := os.WriteFile(p, b, 0755); err != nil { + t.Fatal(err) + } + if err := verifyArchivePlatform(context.Background(), root, spec); err == nil { + t.Fatal("broken signature accepted") + } +} + +func TestArchiveDamagedLibrary(t *testing.T) { + for _, name := range []string{"libllama-server-impl.dylib", "no-extension"} { + t.Run(name, func(t *testing.T) { + root, spec := signedArchiveFixture(t) + body, err := os.ReadFile("/usr/lib/dyld") // always on disk, signed and universal + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, name) + if err := os.WriteFile(path, body, 0644); err != nil { + t.Fatal(err) + } + if err := verifyArchivePlatform(context.Background(), root, spec); err != nil { + t.Fatal(err) + } + offset := 1024 + if fat, err := macho.NewFatFile(bytes.NewReader(body)); err == nil { + offset += int(fat.Arches[0].Offset) + } + body[offset] ^= 0xff + if err := os.WriteFile(path, body, 0644); err != nil { + t.Fatal(err) + } + if err := verifyArchivePlatform(context.Background(), root, spec); err == nil || !strings.Contains(err.Error(), name) { + t.Fatalf("damaged Mach-O accepted: %v", err) + } + }) + } +} + +func TestArchiveSamePinRevalidation(t *testing.T) { + for _, damage := range []string{"signature", "quarantine"} { + t.Run(damage, func(t *testing.T) { + ex, st := archiveExecutor(t) + root, spec := signedArchiveFixture(t) + body, err := os.ReadFile(filepath.Join(root, "llama-server")) + if err != nil { + t.Fatal(err) + } + source := archiveFixture(t, archiveEntry{name: "bundle/llama-server", body: string(body)}, archiveEntry{name: "bundle/library", body: string(body)}) + if err := installArchive(context.Background(), source, st.installDir, "archive-fixture", st.plat.Install.Fetch.SHA256, spec, verifyArchivePlatform); err != nil { + t.Fatal(err) + } + path := filepath.Join(st.installDir, "library") + if damage == "signature" { + body[len(body)/2] ^= 0xff + if err := os.WriteFile(path, body, 0644); err != nil { + t.Fatal(err) + } + } else { + if err := unix.Setxattr(path, "com.apple.quarantine", []byte("0081;fixture;PAIR;"), 0); err != nil { + t.Fatal(err) + } + } + if err := ex.Install(context.Background(), "archive-fixture"); err == nil || !strings.Contains(err.Error(), "library") { + t.Fatalf("same-pin skipped revalidation: %v", err) + } + }) + } +} + +func TestArchivePreStartRevalidation(t *testing.T) { + ex, st := archiveExecutor(t) + root, _ := signedArchiveFixture(t) + st.installDir = root + body, err := os.ReadFile(filepath.Join(root, "llama-server")) + if err != nil { + t.Fatal(err) + } + body[len(body)/2] ^= 0xff + if err := os.WriteFile(filepath.Join(root, "library"), body, 0644); err != nil { + t.Fatal(err) + } + rt := st.plat.Runtime + rt.Bin = "/fixture/never-execute" + err = ex.bringUpProcess(context.Background(), st, "archive-fixture", rt, 8082, map[string]string{"host": "127.0.0.1", "port": "8082", "install_dir": root}) + if err == nil || !strings.Contains(err.Error(), "library") { + t.Fatalf("start skipped signature checks: %v", err) + } +} + +func TestArchiveCodesignUnavailable(t *testing.T) { + root, spec := signedArchiveFixture(t) + if err := verifyArchiveSignature(context.Background(), root, spec, filepath.Join(root, "missing-codesign")); err == nil { + t.Fatal("missing codesign accepted") + } +} + +func TestArchiveQuarantinePreservedAndRejected(t *testing.T) { + root, spec := signedArchiveFixture(t) + p := filepath.Join(root, "metal-resource") + if err := os.WriteFile(p, []byte("fixture"), 0644); err != nil { + t.Fatal(err) + } + if err := unix.Setxattr(p, "com.apple.quarantine", []byte("0081;fixture;PAIR;"), 0); err != nil { + t.Fatal(err) + } + err := verifyArchivePlatform(context.Background(), root, spec) + if err == nil || !strings.Contains(err.Error(), "metal-resource") { + t.Fatalf("quarantine not identified: %v", err) + } + if _, err := unix.Getxattr(p, "com.apple.quarantine", nil); err != nil { + t.Fatalf("quarantine was changed: %v", err) + } +} + +func TestArchiveQuarantinedSourceRejected(t *testing.T) { + p := archiveFixture(t, archiveEntry{name: "bundle/llama-server", body: "fixture"}) + if err := unix.Setxattr(p, "com.apple.quarantine", []byte("0081;fixture;PAIR;"), 0); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "engine") + if err := installArchive(context.Background(), p, target, "fixture", "digest", &ArchiveInstall{Required: []string{"llama-server"}, Codesign: "llama-server"}, acceptArchive); err == nil { + t.Fatal("quarantined source accepted") + } + if _, err := unix.Getxattr(p, "com.apple.quarantine", nil); err != nil { + t.Fatal("quarantine was altered") + } +} + +func TestArchiveExecutorInstallUpdateRollback(t *testing.T) { + ex, st := archiveExecutor(t) + st.plat.Install.Archive.Required = []string{"llama-server", "lib.dylib"} + signed, err := os.ReadFile("/usr/bin/true") + if err != nil { + t.Fatal(err) + } + install := func(server []byte, library string) error { + p := archiveFixture(t, archiveEntry{name: "bundle/llama-server", body: string(server)}, archiveEntry{name: "bundle/lib.dylib", body: library}) + payload, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(payload) + st.plat.Install.Fetch.SHA256 = hex.EncodeToString(sum[:]) + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); _, _ = w.Write(payload) })) + defer srv.Close() + st.plat.Install.Fetch.URL = srv.URL + "/fixture.tar.gz" + if err := ex.Install(context.Background(), "archive-fixture"); err != nil { + return err + } + if err := ex.Install(context.Background(), "archive-fixture"); err != nil { + return err + } + if requests.Load() != 1 { + t.Fatal("unchanged pin downloaded again") + } + return nil + } + if err := install([]byte("broken signature"), "old"); err == nil { + t.Fatal("broken initial signature accepted") + } + if _, err := os.Stat(st.installDir); !os.IsNotExist(err) { + t.Fatal("broken initial install was published") + } + if err := install(signed, "old"); err != nil { + t.Fatal(err) + } + for _, attempt := range []struct { + server []byte + library string + }{{signed, ""}, {[]byte("broken signature"), "new"}} { + if err := install(attempt.server, attempt.library); err == nil { + t.Fatal("invalid update accepted") + } + body, err := os.ReadFile(filepath.Join(st.installDir, "lib.dylib")) + if err != nil || string(body) != "old" || !installedByPAIR(st.installDir, st.installDir) { + t.Fatal("old install was not restored") + } + } + if err := install(signed, "updated"); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(filepath.Join(st.installDir, "lib.dylib")) + if err != nil || string(body) != "updated" { + t.Fatalf("update not published: %q %v", body, err) + } + if err := ex.Uninstall(context.Background(), "archive-fixture"); err != nil { + t.Fatal(err) + } +} + +func TestArchiveAtomicUpdate(t *testing.T) { + target := filepath.Join(t.TempDir(), "engine") + spec := &ArchiveInstall{Required: []string{"llama-server"}, Codesign: "llama-server"} + for _, version := range []string{"first", "updated"} { + p := archiveFixture(t, archiveEntry{name: "bundle/llama-server", body: version}) + if err := installArchive(context.Background(), p, target, "fixture", version, spec, acceptArchive); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(filepath.Join(target, "llama-server")) + if err != nil || string(b) != version || !installedByPAIR(target, target) { + t.Fatalf("update: %q %v", b, err) + } + } +} diff --git a/services/nvpair-engine-manager/archive_install_test.go b/services/nvpair-engine-manager/archive_install_test.go new file mode 100644 index 000000000..864ca0ec6 --- /dev/null +++ b/services/nvpair-engine-manager/archive_install_test.go @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "runtime" + "strings" + "testing" +) + +func archiveExecutor(t *testing.T) (*Executor, *engineState) { + t.Helper() + reg := NewRegistry() + raw := `{"engine":"archive-fixture","display_name":"Archive fixture","manifest_version":1,"platforms":{"` + runtime.GOOS + `/` + runtime.GOARCH + `":{"detect":["{install_dir}/llama-server"],"install":{"fetch":{"url":"https://example.test/engine.tar.gz","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"archive":{"required":["llama-server"],"codesign":"llama-server"}},"models_dir":"{install_dir}-models","uninstall":{"remove":["{install_dir}"]},"runtime":{"bin":"{install_dir}/llama-server","port":0}}}}` + if _, err := reg.addManifest("fixture.json", []byte(raw)); err != nil { + t.Fatal(err) + } + ex := reconciledTestExecutor(NewExecutor(reg, NewReporter(nil), nil, t.TempDir())) + st, err := ex.state("archive-fixture") + if err != nil { + t.Fatal(err) + } + return ex, st +} + +func TestArchiveChecksumMismatch(t *testing.T) { + ex, st := archiveExecutor(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("wrong archive")) })) + defer srv.Close() + st.plat.Install.Fetch.URL = srv.URL + "/engine.tar.gz" + if err := ex.Install(context.Background(), "archive-fixture"); err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("got %v", err) + } + if _, err := os.Stat(st.installDir); !os.IsNotExist(err) { + t.Fatal("checksum failure left install directory") + } +} + +func TestArchiveManifestValidation(t *testing.T) { + for name, change := range map[string]func(*Platform){ + "no checksum": func(p *Platform) { p.Install.Fetch.SHA256 = "" }, + "bad checksum": func(p *Platform) { p.Install.Fetch.SHA256 = "not a digest" }, + "script": func(p *Platform) { p.Install.Script = []string{"script"} }, + "run": func(p *Platform) { p.Install.Run = []string{"tar"} }, + "disk image": func(p *Platform) { p.Install.DiskImage = &DiskImage{App: "Fixture.app", Into: "/tmp"} }, + "no required": func(p *Platform) { p.Install.Archive.Required = nil }, + "unsafe required": func(p *Platform) { p.Install.Archive.Required = []string{"../server"} }, + "missing signature target": func(p *Platform) { p.Install.Archive.Codesign = "other" }, + "unsafe signature target": func(p *Platform) { p.Install.Archive.Codesign = "../server" }, + "creates": func(p *Platform) { p.Install.Creates = "/tmp/other" }, + } { + t.Run(name, func(t *testing.T) { + _, st := archiveExecutor(t) + change(st.plat) + if err := st.plat.validate(runtime.GOOS + "/" + runtime.GOARCH); err == nil { + t.Fatal("unsafe archive manifest accepted") + } + }) + } +} diff --git a/services/nvpair-engine-manager/archive_other.go b/services/nvpair-engine-manager/archive_other.go new file mode 100644 index 000000000..bc9cfdad1 --- /dev/null +++ b/services/nvpair-engine-manager/archive_other.go @@ -0,0 +1,27 @@ +//go:build !darwin + +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "fmt" + "os" +) + +func swapArchiveDirectories(stage, target string) error { + return fmt.Errorf("atomic archive updates are supported on macOS only") +} +func renameArchiveNew(stage, target string) error { + if _, err := os.Lstat(target); !os.IsNotExist(err) { + return fmt.Errorf("archive destination already exists") + } + return os.Rename(stage, target) +} +func rejectArchiveQuarantine(string) error { return nil } // no supported archive installs on these hosts + +func verifyArchivePlatform(context.Context, string, *ArchiveInstall) error { + return fmt.Errorf("archive signature and quarantine verification require macOS") +} diff --git a/services/nvpair-engine-manager/archive_ownership_darwin_test.go b/services/nvpair-engine-manager/archive_ownership_darwin_test.go new file mode 100644 index 000000000..12d91b884 --- /dev/null +++ b/services/nvpair-engine-manager/archive_ownership_darwin_test.go @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +// Successful directory claims and atomic updates are macOS-only: birth time +// survives writing the marker and renaming the staging directory. Linux's +// existing ctime identity deliberately invalidates such a claim; the runtime +// installer fails closed there. Portable extraction and validation stay in the +// platform-independent test files. +func TestArchiveInstallRollback(t *testing.T) { + target := filepath.Join(t.TempDir(), "engine") + spec := &ArchiveInstall{Required: []string{"llama-server", "lib.dylib"}, Codesign: "llama-server"} + good := archiveFixture(t, archiveEntry{name: "bundle/llama-server", body: "old"}, archiveEntry{name: "bundle/lib.dylib", body: "metal"}) + bad := archiveFixture(t, archiveEntry{name: "bundle/llama-server", body: "partial"}) + if err := installArchive(context.Background(), bad, target, "fixture", "old-digest", spec, acceptArchive); err == nil { + t.Fatal("partial install succeeded") + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("partial install left target: %v", err) + } + if err := installArchive(context.Background(), good, target, "fixture", "old-digest", spec, acceptArchive); err != nil { + t.Fatal(err) + } + if !installedByPAIR(target, target) { + t.Fatal("missing identity-bound claim") + } + for _, attempt := range []struct { + archive string + verify func(context.Context, string, *ArchiveInstall) error + }{ + {bad, acceptArchive}, {good, func(context.Context, string, *ArchiveInstall) error { return errors.New("broken signature") }}, + } { + if err := installArchive(context.Background(), attempt.archive, target, "fixture", "new-digest", spec, attempt.verify); err == nil { + t.Fatal("failed update succeeded") + } + b, err := os.ReadFile(filepath.Join(target, "llama-server")) + if err != nil || string(b) != "old" || !installedByPAIR(target, target) { + t.Fatal("update did not preserve old installation and claim") + } + } + entries, err := os.ReadDir(filepath.Dir(target)) + if err != nil || len(entries) != 1 { + t.Fatalf("staging debris: %v %v", entries, err) + } +} + +func TestArchiveUninstallPreservesModelCacheAndRequiresClaim(t *testing.T) { + for _, owned := range []bool{false, true} { + t.Run(map[bool]string{false: "unowned", true: "owned"}[owned], func(t *testing.T) { + ex, st := archiveExecutor(t) + p := archiveFixture(t, archiveEntry{name: "bundle/llama-server", body: "fixture"}) + if err := installArchive(context.Background(), p, st.installDir, "archive-fixture", "digest", st.plat.Install.Archive, acceptArchive); err != nil { + t.Fatal(err) + } + if !owned { + clearInstallMarker(st.installDir) + } + cache := st.installDir + "-models" + if err := os.MkdirAll(cache, 0755); err != nil { + t.Fatal(err) + } + sentinel := filepath.Join(cache, "keep.gguf") + if err := os.WriteFile(sentinel, []byte("model"), 0644); err != nil { + t.Fatal(err) + } + err := ex.Uninstall(context.Background(), "archive-fixture") + if owned && err != nil { + t.Fatal(err) + } + if !owned && err == nil { + t.Fatal("unclaimed installation removed") + } + if !fileExists(sentinel) { + t.Fatal("model cache deleted") + } + if fileExists(filepath.Join(st.installDir, "llama-server")) == owned { + t.Fatal("wrong uninstall result") + } + }) + } +} diff --git a/services/nvpair-engine-manager/archive_test.go b/services/nvpair-engine-manager/archive_test.go new file mode 100644 index 000000000..5337fe722 --- /dev/null +++ b/services/nvpair-engine-manager/archive_test.go @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +type archiveEntry struct { + name, link, body string + kind byte + mode int64 + pax map[string]string +} + +func archiveFixture(t *testing.T, entries ...archiveEntry) string { + t.Helper() + p := filepath.Join(t.TempDir(), "engine.tar.gz") + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + for _, e := range entries { + kind := e.kind + if kind == 0 { + kind = tar.TypeReg + } + mode := e.mode + if mode == 0 { + mode = 0755 + } + h := &tar.Header{Name: e.name, Linkname: e.link, Typeflag: kind, Mode: mode, PAXRecords: e.pax} + if kind == tar.TypeReg { + h.Size = int64(len(e.body)) + } + if err := tw.WriteHeader(h); err != nil { + t.Fatal(err) + } + if kind == tar.TypeReg { + if _, err := tw.Write([]byte(e.body)); err != nil { + t.Fatal(err) + } + } + } + for _, close := range []func() error{tw.Close, gz.Close, f.Close} { + if err := close(); err != nil { + t.Fatal(err) + } + } + return p +} + +func TestArchiveContainment(t *testing.T) { + cases := map[string][]archiveEntry{ + "absolute": {{name: "/outside"}}, + "parent": {{name: "bundle/../outside"}}, + "symlink escape": {{name: "bundle/link", kind: tar.TypeSymlink, link: "../../outside"}}, + "hardlink escape": {{name: "bundle/link", kind: tar.TypeLink, link: "../outside"}}, + "absolute symlink": {{name: "bundle/link", kind: tar.TypeSymlink, link: "/tmp/outside"}}, + "absolute hardlink": {{name: "bundle/link", kind: tar.TypeLink, link: "/tmp/outside"}}, + "device": {{name: "bundle/device", kind: tar.TypeChar}}, + "block": {{name: "bundle/device", kind: tar.TypeBlock}}, + "fifo": {{name: "bundle/pipe", kind: tar.TypeFifo}}, + "setuid": {{name: "bundle/server", mode: 04755}}, + "setgid": {{name: "bundle/server", mode: 02755}}, + "duplicate": {{name: "bundle/server"}, {name: "bundle/server"}}, + "case alias": {{name: "bundle/server"}, {name: "bundle/SERVER"}}, + "link parent": {{name: "bundle/link", kind: tar.TypeSymlink, link: "dir"}, {name: "bundle/link/evil"}}, + "cycle": {{name: "bundle/a", kind: tar.TypeSymlink, link: "b"}, {name: "bundle/b", kind: tar.TypeSymlink, link: "a"}}, + "quarantine metadata": {{name: "bundle/server", pax: map[string]string{"SCHILY.xattr.com.apple.quarantine": "fixture"}}}, + } + for name, entries := range cases { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + if err := extractTarGzip(context.Background(), archiveFixture(t, entries...), root, 1024, 20); err == nil { + t.Fatal("unsafe archive accepted") + } + }) + } +} + +func TestArchiveStripsWrapperAndPreservesInternalLinks(t *testing.T) { + root := t.TempDir() + p := archiveFixture(t, archiveEntry{name: "bundle", kind: tar.TypeDir}, archiveEntry{name: "bundle/lib.dylib", body: "signed bytes"}, archiveEntry{name: "bundle/lib.0.dylib", kind: tar.TypeSymlink, link: "lib.dylib"}, archiveEntry{name: "bundle/copy", kind: tar.TypeLink, link: "bundle/lib.dylib"}) + if err := extractTarGzip(context.Background(), p, root, 1024, 20); err != nil { + t.Fatal(err) + } + for _, name := range []string{"lib.dylib", "lib.0.dylib", "copy"} { + b, err := os.ReadFile(filepath.Join(root, name)) + if err != nil || string(b) != "signed bytes" { + t.Fatalf("%s: %q %v", name, b, err) + } + } +} + +func TestArchiveLimitsAndCancellation(t *testing.T) { + p := archiveFixture(t, archiveEntry{name: "bundle/a", body: "12345"}, archiveEntry{name: "bundle/b", body: "67890"}) + for _, limit := range []struct { + bytes int64 + files int + }{{9, 20}, {100, 1}} { + if err := extractTarGzip(context.Background(), p, t.TempDir(), limit.bytes, limit.files); err == nil { + t.Fatal("archive limit ignored") + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := extractTarGzip(ctx, p, t.TempDir(), 100, 20); !errors.Is(err, context.Canceled) { + t.Fatalf("got %v", err) + } +} + +func acceptArchive(context.Context, string, *ArchiveInstall) error { return nil } + +func TestArchiveRefusesUnownedReplacement(t *testing.T) { + target := filepath.Join(t.TempDir(), "engine") + if err := os.Mkdir(target, 0755); err != nil { + t.Fatal(err) + } + p := archiveFixture(t, archiveEntry{name: "bundle/llama-server", body: "new"}) + if err := installArchive(context.Background(), p, target, "fixture", "digest", &ArchiveInstall{Required: []string{"llama-server"}, Codesign: "llama-server"}, acceptArchive); err == nil || !strings.Contains(err.Error(), "PAIR") { + t.Fatalf("unowned replacement: %v", err) + } +} diff --git a/services/nvpair-engine-manager/capabilities.go b/services/nvpair-engine-manager/capabilities.go index 4c4bcee17..bac3e8b34 100644 --- a/services/nvpair-engine-manager/capabilities.go +++ b/services/nvpair-engine-manager/capabilities.go @@ -60,6 +60,9 @@ func capabilitiesForPlatform(m *Manifest, p Platform, goos string, adopted bool, uninstall = installedByPAIR(installDir, creates) && uninstallerCoversCLI(p.Uninstall, detectedExecutable(&p, installDir, goos), installDir, goos) } + if uninstall && p.Install != nil && p.Install.Archive != nil { + uninstall = installedByPAIR(installDir, installDir) + } return Capabilities{ ListModels: list, PullModel: pull, diff --git a/services/nvpair-engine-manager/executor.go b/services/nvpair-engine-manager/executor.go index 11532024b..6f1b746ee 100644 --- a/services/nvpair-engine-manager/executor.go +++ b/services/nvpair-engine-manager/executor.go @@ -281,7 +281,11 @@ func (e *Executor) state(engine string) (*engineState, error) { installDir: filepath.Join(e.baseDir, engine), } if plat.ModelsDir != "" { - st.modelsDir = expandPath(plat.ModelsDir) + if plat.Install != nil && plat.Install.Archive != nil && plat.ModelsDir == "{install_dir}-models" { + st.modelsDir = st.installDir + "-models" + } else { + st.modelsDir = expandPath(plat.ModelsDir) + } } e.engines[engine] = st return st, nil diff --git a/services/nvpair-engine-manager/install.go b/services/nvpair-engine-manager/install.go index 99a9a288e..8f03e4743 100644 --- a/services/nvpair-engine-manager/install.go +++ b/services/nvpair-engine-manager/install.go @@ -29,6 +29,9 @@ func (e *Executor) Install(ctx context.Context, engine string) error { } st.lockOperation() defer st.unlockOperation() + if st.plat.Install != nil && st.plat.Install.Archive != nil { + return e.installManagedArchive(ctx, engine, st) + } if ok, _ := e.Detect(engine); ok { e.reporter.clear(installFailedID(engine)) e.emitInstallProgress(engine, "already-installed", 100) @@ -219,6 +222,9 @@ func (e *Executor) Uninstall(ctx context.Context, engine string) error { e.reporter.report(serviceError{ID: uninstallFailedID(engine), Message: err.Error(), Severity: "error", Action: "none", EngineType: engine, Operation: "uninstall"}) return err } + if st.plat.Install != nil && st.plat.Install.Archive != nil && !installedByPAIR(st.installDir, st.installDir) { + return fmt.Errorf("cannot uninstall %s: PAIR has no identity-bound record of installing it", engine) + } // Detect proves only that the managed image exists, not whether a process is // serving. Reconcile first so doStop can either stop our exact managed // process (including a handle-less orphan) or refuse an external owner. diff --git a/services/nvpair-engine-manager/launch.go b/services/nvpair-engine-manager/launch.go index 8e0dec055..c6f4d61b9 100644 --- a/services/nvpair-engine-manager/launch.go +++ b/services/nvpair-engine-manager/launch.go @@ -65,7 +65,7 @@ func (command launchCommand) argumentText(policy *EditableLaunch) (string, error } env := make(map[string]string) for name, value := range command.Env { - if policy.environmentControl(name) == nil { + if !policy.protectedEnvironment(name) { env[name] = value } } diff --git a/services/nvpair-engine-manager/launch_controls.go b/services/nvpair-engine-manager/launch_controls.go index 29556612e..f12a4f5a2 100644 --- a/services/nvpair-engine-manager/launch_controls.go +++ b/services/nvpair-engine-manager/launch_controls.go @@ -46,7 +46,7 @@ type launchBinding struct { } var bindingToken = regexp.MustCompile(`\{([^{}]+)\}`) -var launchFlagName = regexp.MustCompile(`^(?:-[a-zA-Z]|--[a-zA-Z0-9_-]+)$`) +var launchFlagName = regexp.MustCompile(`^(?:-[a-zA-Z]|--[a-zA-Z0-9_.-]+)$`) func (control LaunchControl) binding() (launchBinding, error) { binding := launchBinding{} @@ -108,7 +108,32 @@ func (p *EditableLaunch) environmentControl(name string) *LaunchControl { return nil } +func (p *EditableLaunch) protectedEnvironment(name string) bool { + if p.llamaCPP() && validEnvironmentKey(name) { + return true + } + if p.environmentControl(name) != nil { + return true + } + for _, key := range p.ForbiddenEnv { + if environmentKey(key) == environmentKey(name) { + return true + } + } + return false +} + +func (p *EditableLaunch) normalizeFlag(name string) string { + if p.NormalizeFlagUnderscores && strings.HasPrefix(name, "--") { + return strings.ReplaceAll(name, "_", "-") + } + return name +} + func (p *EditableLaunch) validateControls() error { + if p.OptionPolicy != "" && !p.llamaCPP() { + return fmt.Errorf("unknown launch option policy") + } flags, env, fields := map[string]bool{}, map[string]bool{}, map[string]bool{} for _, control := range p.Controls { binding, err := control.binding() @@ -135,6 +160,19 @@ func (p *EditableLaunch) validateControls() error { env[key] = true } } + for _, name := range p.ForbiddenFlags { + if !launchFlagName.MatchString(name) || flags[name] { + return fmt.Errorf("invalid or duplicate forbidden option %q", name) + } + flags[name] = true + } + for _, name := range p.ForbiddenEnv { + key := environmentKey(name) + if !validEnvironmentKey(name) || env[key] { + return fmt.Errorf("invalid or duplicate forbidden environment name") + } + env[key] = true + } for name, field := range launchFields { if field.managed != nil && !fields[name] { return fmt.Errorf("editable launch controls must bind managed field %s", name) @@ -147,7 +185,16 @@ func (p *EditableLaunch) validateControls() error { // bundle containing a protected option is ambiguous without knowing all other // vendor options' arities; reject it instead of guessing or letting it bypass us. func (p *EditableLaunch) readControl(tokens []string, index int) (*LaunchControl, string, int, error) { - token := tokens[index] + token := p.normalizeFlag(tokens[index]) + if key, _, ok := strings.Cut(token, "="); ok && p.protectedEnvironment(key) { + return nil, "", index, fmt.Errorf("managed environment assignment in launch arguments") + } + name, _, _ := strings.Cut(token, "=") + for _, forbidden := range p.ForbiddenFlags { + if name == p.normalizeFlag(forbidden) { + return nil, "", index, fmt.Errorf("this option changes PAIR-managed launch state") + } + } for ci := range p.Controls { control := &p.Controls[ci] for _, name := range control.Flags { diff --git a/services/nvpair-engine-manager/launch_controls_test.go b/services/nvpair-engine-manager/launch_controls_test.go index 82d4a6928..62bfcf6f1 100644 --- a/services/nvpair-engine-manager/launch_controls_test.go +++ b/services/nvpair-engine-manager/launch_controls_test.go @@ -120,6 +120,9 @@ func TestBundledNetworkingControls(t *testing.T) { } valid = []string{"--port 23456", "--port=23456", "-p 23456", "-p23456", "-p=23456", `"-p" "23456"`, "--port 23456 -p23456", "-- -p23456", "--host 127.0.0.1"} invalid = []string{"-p0", "-p65536", "-p", "-pno", "--port 23456 -p23457", "-vp23456", "-vp=23456", "--bind 0.0.0.0", "--bind=::", "LMS_SERVER_HOST=0.0.0.0", "--host 0.0.0.0", "--host=0.0.0.0", "-- --bind 0.0.0.0"} + } else if name == "llamacpp" { + valid = []string{"--host 127.0.0.1", "--port 23456", "LLAMA_ARG_HOST=127.0.0.1", "LLAMA_ARG_PORT=23456"} + invalid = []string{"--host /tmp/server.sock", "--port 0", "--reuse_port", "LLAMA_ARG_REUSE_PORT=1"} } else { if !reflect.DeepEqual(policy.Controls, []LaunchControl{ {Value: "{server.host}:{server.port}", Env: []string{"OLLAMA_HOST"}}, diff --git a/services/nvpair-engine-manager/lifecycle.go b/services/nvpair-engine-manager/lifecycle.go index e157b3b00..8f5418226 100644 --- a/services/nvpair-engine-manager/lifecycle.go +++ b/services/nvpair-engine-manager/lifecycle.go @@ -5,8 +5,10 @@ package main import ( "context" + "encoding/json" "errors" "fmt" + "io" "log/slog" "net" "net/http" @@ -245,6 +247,11 @@ func (e *Executor) bringUp(ctx context.Context, st *engineState, engine string, // bringUpProcess spawns the engine as a foreground process this service // owns, then waits for readiness. func (e *Executor) bringUpProcess(ctx context.Context, st *engineState, engine string, rt Runtime, port int, vars map[string]string) error { + if inst := st.plat.Install; inst != nil && inst.Archive != nil { + if err := verifyArchivePlatform(ctx, st.installDir, inst.Archive); err != nil { + return err + } + } st.mu.Lock() binPath := st.binPath st.mu.Unlock() @@ -256,9 +263,14 @@ func (e *Executor) bringUpProcess(ctx context.Context, st *engineState, engine s return err } + if rt.EditableLaunch.llamaCPP() { + if err := rejectLlamaConfigFiles(llamaConfigPaths(launch.Env)...); err != nil { + return err + } + } diagnostics := newStartupOutput(launchDiagnosticArgs(rt), launch.Env) defer diagnostics.close() - proc, err := startManagedProc(launch.Bin, launch.Args, launch.Env, func(stream, line string) { + proc, err := startManagedProcWithEnvironment(launch.Bin, launch.Args, launch.Env, !rt.EditableLaunch.llamaCPP(), func(stream, line string) { _, _ = diagnostics.Write([]byte(line + "\n")) // Vendor diagnostics may echo arbitrary custom arguments. Keep those // out of exported engine logs; report exit/readiness failures separately. @@ -1016,12 +1028,27 @@ func (e *Executor) probe(ctx context.Context, p *Probe, port int) bool { if err != nil { return false } - httpcon.DrainAndClose(resp.Body) + defer httpcon.DrainAndClose(resp.Body) want := p.Status if want == 0 { want = 200 } - return resp.StatusCode == want + if resp.StatusCode != want { + return false + } + if p.JSONMatch != nil { + body, err := io.ReadAll(io.LimitReader(resp.Body, 65537)) + if err != nil || len(body) > 65536 { + return false + } + var object map[string]json.RawMessage + var value string + if json.Unmarshal(body, &object) != nil || json.Unmarshal(object[p.JSONMatch.Field], &value) != nil { + return false + } + return value == p.JSONMatch.Value + } + return true } if p.TCP != "" { addr, err := resolvePlaceholders(p.TCP, vars) diff --git a/services/nvpair-engine-manager/llamacpp_environment_test.go b/services/nvpair-engine-manager/llamacpp_environment_test.go new file mode 100644 index 000000000..521cdf3e9 --- /dev/null +++ b/services/nvpair-engine-manager/llamacpp_environment_test.go @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +func TestLlamaControlledChildEnvironment(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("macOS-only engine; Unix env fixture") + } + for _, name := range []string{"LLAMA_ARG_REUSE_PORT", "LLAMA_ARG_TOOLS_RUNTIME", "LLAMA_ARG_CONNECT", "LLAMA_ARG_HF_REPO", "LLAMA_CACHE", "HF_TOKEN", "HF_ENDPOINT", "MODEL_ENDPOINT", "MTMD_BACKEND_DEVICE", "LLAMA_APP_CMD", "GGML_BACKEND_DL_PATH", "DYLD_INSERT_LIBRARIES", "CUSTOM"} { + t.Setenv(name, "unwanted") + } + var mu sync.Mutex + var lines []string + proc, err := startManagedProcWithEnvironment("/usr/bin/env", nil, map[string]string{"LLAMA_CACHE": "/fixture-models", "LLAMA_ARG_REUSE_PORT": "0"}, false, func(_, line string) { mu.Lock(); defer mu.Unlock(); lines = append(lines, line) }) + if err != nil { + t.Fatal(err) + } + <-proc.done + mu.Lock() + defer mu.Unlock() + for _, line := range lines { + if strings.HasSuffix(line, "=unwanted") { + t.Errorf("inherited setting escaped: %s", strings.SplitN(line, "=", 2)[0]) + } + } + if len(lines) != 2 { + t.Fatalf("expected exactly controlled environment, got %d variables", len(lines)) + } +} + +func TestLlamaConfigRefused(t *testing.T) { + for _, body := range []string{"reuse-port = true\ntools-runtime = /fixture/runtime\n", "[server]\nreuse-port = true\n"} { + path := filepath.Join(t.TempDir(), "config.ini") + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + if err := rejectLlamaConfigFiles(path); err == nil || !strings.Contains(err.Error(), path) { + t.Fatalf("ambient config accepted: %v", err) + } + } + path := filepath.Join(t.TempDir(), "config.ini") + if err := rejectLlamaConfigFiles(path); err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" { + if err := os.Symlink(path+".missing", path); err != nil { + t.Fatal(err) + } + if err := rejectLlamaConfigFiles(path); err == nil { + t.Fatal("dangling config accepted") + } + } +} + +func TestLlamaAmbientConfigNeutralized(t *testing.T) { + home := t.TempDir() + xdg := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", xdg) + for _, base := range []string{filepath.Join(home, ".config"), xdg} { + dir := filepath.Join(base, "llama.cpp") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "config.ini"), []byte("reuse-port = true\ntools-runtime = /fixture/runtime\n"), 0600); err != nil { + t.Fatal(err) + } + } + p, _ := llamaManifest(t).PlatformFor("darwin", "arm64") + root := t.TempDir() + cmd, err := resolveProcessLaunch(p.Runtime, "/fixture/server", map[string]string{"host": "127.0.0.1", "port": "8082", "install_dir": root}) + if err != nil { + t.Fatal(err) + } + if cmd.Env["HOME"] != filepath.Join(root, ".pair-home") || cmd.Env["XDG_CONFIG_HOME"] != filepath.Join(root, ".pair-config") { + t.Fatalf("ambient config search still reachable: %+v", cmd.Env) + } + if cmd.Env["LLAMA_ARG_REUSE_PORT"] != "0" || cmd.Env["LLAMA_ARG_API_PREFIX"] != "" { + t.Fatal("managed settings missing") + } +} diff --git a/services/nvpair-engine-manager/llamacpp_policy.go b/services/nvpair-engine-manager/llamacpp_policy.go new file mode 100644 index 000000000..f34c6f2e3 --- /dev/null +++ b/services/nvpair-engine-manager/llamacpp_policy.go @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "math" + "os" + "path/filepath" + "slices" + "strconv" + "strings" +) + +const llamaCPPOptionPolicy = "llamacpp-b12171" + +func (p *EditableLaunch) llamaCPP() bool { return p != nil && p.OptionPolicy == llamaCPPOptionPolicy } + +type llamaOption struct { + names string + kind string + min, max float64 + choices string + group string +} + +// Deliberately closed policy, audited against ggml-org/llama.cpp tag b12171: +// common/arg.cpp (common_params_parser_init/common_params_parse_ex), and +// src/llama-chat.cpp (LLM_CHAT_TEMPLATES). No files, URLs or free-form programs. +// Unlike open server launch text, this parser also exposes host tools, agents, +// tunnels, MCP and downloads. New upstream options must not become permissions. +var llamaInferenceOptions = []llamaOption{ + {names: "--ctx-size -c", kind: "int", min: 0, max: 1048576}, + {names: "--batch-size -b", kind: "int", min: 1, max: 1048576}, + {names: "--ubatch-size -ub", kind: "int", min: 1, max: 1048576}, + {names: "--threads -t", kind: "int", min: 1, max: 4096}, + {names: "--threads-batch -tb", kind: "int", min: 1, max: 4096}, + {names: "--gpu-layers --n-gpu-layers -ngl", kind: "gpu", min: 0, max: 1048576}, + {names: "--flash-attn -fa", kind: "enum", choices: "on off auto"}, + {names: "--cache-type-k -ctk", kind: "enum", choices: "f32 f16 bf16 q8_0 q4_0 q4_1 q5_0 q5_1 iq4_nl"}, + {names: "--cache-type-v -ctv", kind: "enum", choices: "f32 f16 bf16 q8_0 q4_0 q4_1 q5_0 q5_1 iq4_nl"}, + {names: "--parallel -np", kind: "int", min: 1, max: 1024}, + {names: "--predict --n-predict -n", kind: "int", min: -1, max: 1048576}, + {names: "--temp --temperature", kind: "float", min: 0, max: 10}, + {names: "--top-k", kind: "int", min: 0, max: 1048576}, + {names: "--top-p", kind: "float", min: 0, max: 1}, + {names: "--min-p", kind: "float", min: 0, max: 1}, + {names: "--chat-template", kind: "enum", choices: "chatml llama2 llama2-sys llama2-sys-bos llama2-sys-strip mistral-v1 mistral-v3 mistral-v3-tekken mistral-v7 mistral-v7-tekken phi3 phi4 falcon3 zephyr monarch gemma orion openchat vicuna vicuna-orca deepseek deepseek2 deepseek3 deepseek-ocr command-r llama3 chatglm3 chatglm4 glmedge minicpm exaone3 exaone4 exaone-moe rwkv-world granite granite-4.0 granite-4.1 gigachat megrez yandex bailing bailing-think bailing2 llama4 smolvlm hunyuan-moe gpt-oss hunyuan-dense hunyuan-vl kimi-k2 seed_oss grok-2 pangu-embedded solar-open"}, + {names: "--reasoning-format", kind: "enum", choices: "none auto deepseek deepseek-legacy"}, + {names: "--reasoning -rea", kind: "enum", choices: "on off auto"}, + {names: "--reasoning-effort", kind: "enum", choices: "default minimal low medium high xhigh max"}, + {names: "--reasoning-budget", kind: "int", min: -1, max: 1048576}, + {names: "--cont-batching -cb", kind: "switch", group: "batching"}, + {names: "--no-cont-batching -nocb", kind: "switch", group: "batching"}, + {names: "--kv-offload -kvo", kind: "switch", group: "kv-offload"}, + {names: "--no-kv-offload -nkvo", kind: "switch", group: "kv-offload"}, + {names: "--load-mode -lm", kind: "enum", choices: "auto none mmap mlock mmap+mlock dio"}, + {names: "--metrics", kind: "switch"}, + {names: "--slots", kind: "switch", group: "slots"}, + {names: "--no-slots", kind: "switch", group: "slots"}, +} + +func normalizeLlamaOptions(args []string) ([]string, error) { + for _, arg := range args { + for _, c := range arg { + if c < 32 || c > 126 { + return nil, fmt.Errorf("llama.cpp launch options must be ASCII") + } + } + } + result := []string{} + seen := map[string]bool{} + for i := 0; i < len(args); i++ { + name, value, equals := strings.Cut(args[i], "=") + var option *llamaOption + for j := range llamaInferenceOptions { + if slices.Contains(strings.Fields(llamaInferenceOptions[j].names), name) { + option = &llamaInferenceOptions[j] + break + } + } + if option == nil { + return nil, fmt.Errorf("option is not in the llama.cpp inference allowlist") + } + canonical := strings.Fields(option.names)[0] + group := canonical + if option.group != "" { + group = option.group + } + if seen[group] { + return nil, fmt.Errorf("repeated llama.cpp option %s", canonical) + } + seen[group] = true + if option.kind == "switch" { + if equals { + return nil, fmt.Errorf("%s is a presence-only boolean switch", canonical) + } + result = append(result, canonical) + continue + } + if !equals { + i++ + if i >= len(args) { + return nil, fmt.Errorf("%s needs a value", canonical) + } + value = args[i] + } + valid := false + switch option.kind { + case "enum": + valid = slices.Contains(strings.Fields(option.choices), value) + case "gpu": + valid = value == "auto" || value == "all" + if valid { + break + } + fallthrough + case "int": + n, err := strconv.ParseInt(value, 10, 32) + valid = err == nil && float64(n) >= option.min && float64(n) <= option.max + case "float": + n, err := strconv.ParseFloat(value, 32) + valid = err == nil && !math.IsNaN(n) && !math.IsInf(n, 0) && n >= option.min && n <= option.max + } + if !valid { + return nil, fmt.Errorf("invalid value for %s", canonical) + } + // b12171 accepts separate argv values, not equals syntax. Canonicalize both + // editor forms here, preserving only validated values, never shell expansion. + result = append(result, canonical, value) + } + return result, nil +} + +// b12171 common/arg.cpp:common_params_apply_system_config unconditionally +// reads /etc/llama.cpp/config.ini; there is no disable-config switch. The user +// search in common/common.cpp:fs_get_config_directory uses XDG_CONFIG_HOME or +// HOME/.config. The child has PAIR-owned search paths and no inherited env. +// Refuse any file at the remaining paths, including unreadable/dangling links; +// never parse or log its contents. A false env value cannot undo reuse-port. +func llamaConfigPaths(env map[string]string) []string { + return []string{"/etc/llama.cpp/config.ini", filepath.Join(env["XDG_CONFIG_HOME"], "llama.cpp", "config.ini")} +} + +func rejectLlamaConfigFiles(paths ...string) error { + for _, path := range paths { + if _, err := os.Lstat(path); !os.IsNotExist(err) { + if err != nil { + return fmt.Errorf("cannot inspect llama.cpp configuration %s: %w", path, err) + } + return fmt.Errorf("cannot start llama.cpp while configuration file %s exists; remove it from the child search path", path) + } + } + return nil +} + +func validateLlamaOverrides(rt Runtime) error { + if !rt.EditableLaunch.llamaCPP() { + return nil + } + if rt.LaunchEnv != nil && len(*rt.LaunchEnv) != 0 { + return fmt.Errorf("llama.cpp launch environment assignments are not allowed") + } + if rt.LaunchArgs != nil { + _, err := normalizeLlamaOptions(*rt.LaunchArgs) + return err + } + return nil +} diff --git a/services/nvpair-engine-manager/llamacpp_policy_test.go b/services/nvpair-engine-manager/llamacpp_policy_test.go new file mode 100644 index 000000000..273c0e9d9 --- /dev/null +++ b/services/nvpair-engine-manager/llamacpp_policy_test.go @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLlamaInferenceAllowlist(t *testing.T) { + p, _ := llamaManifest(t).PlatformFor("darwin", "arm64") + cases := []struct{ names, value string }{ + {"--ctx-size -c", "4096"}, {"--batch-size -b", "512"}, {"--ubatch-size -ub", "128"}, + {"--threads -t", "4"}, {"--threads-batch -tb", "8"}, {"--gpu-layers --n-gpu-layers -ngl", "auto"}, + {"--flash-attn -fa", "on"}, {"--cache-type-k -ctk", "f16"}, {"--cache-type-v -ctv", "q8_0"}, + {"--parallel -np", "2"}, {"--predict --n-predict -n", "128"}, {"--temp --temperature", "0.7"}, + {"--top-k", "40"}, {"--top-p", "0.9"}, {"--min-p", "0.05"}, + {"--chat-template", "chatml"}, {"--reasoning-format", "deepseek"}, {"--reasoning -rea", "auto"}, + {"--reasoning-effort", "high"}, {"--reasoning-budget", "1024"}, + } + for _, c := range cases { + for _, name := range strings.Fields(c.names) { + for _, args := range [][]string{{name, c.value}, {name + "=" + c.value}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + rt := p.Runtime + rt.LaunchArgs = &args + cmd, err := resolveProcessLaunch(rt, "/fixture/server", map[string]string{"host": "127.0.0.1", "port": "8082", "install_dir": "/fixture"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(cmd.Args, " "), "=") { + t.Fatal("equals syntax must be lowered for b12171") + } + }) + } + } + } + for _, names := range []string{"--cont-batching -cb", "--no-cont-batching -nocb", "--kv-offload -kvo", "--no-kv-offload -nkvo", "--metrics", "--slots", "--no-slots"} { + for _, name := range strings.Fields(names) { + args := []string{name} + rt := p.Runtime + rt.LaunchArgs = &args + if _, err := resolveProcessLaunch(rt, "/fixture/server", map[string]string{"host": "127.0.0.1", "port": "8082", "install_dir": "/fixture"}); err != nil { + t.Fatalf("%s: %v", name, err) + } + } + } +} + +func TestLlamaRejectsUncontrolledLaunch(t *testing.T) { + p, _ := llamaManifest(t).PlatformFor("darwin", "arm64") + flags := strings.Fields("--connect --connect-code --tools --tools-runtime -ag --agent --mcp-servers-config --mcp-servers-json --path --media-path --slot-save-path -hf -hfr --hf-repo -hff --hf-file -mu --model-url -dr --docker-repo -mmu --mmproj-url --spec-draft-hf -hfd -hfrd --hf-repo-draft --models-preset --api-prefix --reuse-port --unknown --model -m --chat-template-file") + var inputs []string + for _, flag := range flags { + inputs = append(inputs, flag+" value", flag+"=value", flag+" value "+flag+" value", "-- "+flag+" value") + } + inputs = append(inputs, "--", "positional", "--threads 4 --threads 4", "-t 4 --threads 4", "--ctx-size -1", "--threads 4x", "--top-p NaN", "--top-p 1.1", "--chat-template /tmp/template", "--chat-template '{{evil}}'", "--threads 4", "--reasoning on/off", "--metrics=true") + envs := strings.Fields("LLAMA_ARG_CONNECT LLAMA_ARG_CONNECT_CODE LLAMA_ARG_TOOLS LLAMA_ARG_TOOLS_RUNTIME LLAMA_ARG_AGENT LLAMA_ARG_MCP_SERVERS_CONFIG LLAMA_ARG_MCP_SERVERS_JSON LLAMA_ARG_STATIC_PATH LLAMA_ARG_SLOT_SAVE_PATH LLAMA_ARG_HF_REPO LLAMA_ARG_HF_FILE LLAMA_ARG_MODEL_URL LLAMA_ARG_DOCKER_REPO LLAMA_ARG_MMPROJ_URL LLAMA_ARG_SPEC_DRAFT_HF_REPO LLAMA_ARG_MODELS_PRESET LLAMA_ARG_API_PREFIX LLAMA_ARG_REUSE_PORT LLAMA_CACHE HF_TOKEN HF_ENDPOINT CUSTOM") + for _, env := range envs { + inputs = append(inputs, env+"=value", "-- "+env+"=value") + } + for _, input := range inputs { + t.Run(input, func(t *testing.T) { + ex := settingsExecutor(t, true) + settingsState(t, ex).plat.Runtime = p.Runtime + req := settingsRequest(t, ex) + req.Settings.LaunchText = input + if len(previewSettings(t, ex, req).Errors) == 0 { + t.Error("preview accepted unsafe input") + } + args, err := parseLaunchText(input) + if err != nil { + t.Fatal(err) + } + rt := p.Runtime + rt.LaunchArgs = &args + if _, err := resolveProcessLaunch(rt, "/fixture/server", map[string]string{"host": "127.0.0.1", "port": "8082", "install_dir": "/fixture"}); err == nil { + t.Error("persisted arguments accepted unsafe input") + } + }) + } + for _, env := range envs { + rt := p.Runtime + assignments := []string{env + "=value"} + rt.LaunchEnv = &assignments + if _, err := resolveProcessLaunch(rt, "/fixture/server", map[string]string{"host": "127.0.0.1", "port": "8082", "install_dir": "/fixture"}); err == nil { + t.Errorf("persisted environment accepted %s", env) + } + } +} + +// Every allowlisted name must exist in the pinned release: b12171 rejects +// unknown arguments, so a stale name would pass PAIR's validation and then +// stop llama-server from starting. +func TestLlamaAllowlistMatchesPinnedRelease(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "llamacpp-b12171-options.txt")) + if err != nil { + t.Fatal(err) + } + known := map[string]bool{} + for _, line := range strings.Split(string(data), "\n") { + if line != "" && !strings.HasPrefix(line, "#") { + known[line] = true + } + } + for _, option := range llamaInferenceOptions { + for _, name := range strings.Fields(option.names) { + if !known[name] { + t.Errorf("%s is not a llama-server b12171 option", name) + } + } + } + for _, mode := range strings.Fields("auto none mmap mlock mmap+mlock dio") { + if _, err := normalizeLlamaOptions([]string{"--load-mode", mode}); err != nil { + t.Errorf("load mode %s: %v", mode, err) + } + } + for _, rejected := range [][]string{{"--load-mode", "lazy"}, {"--mlock"}, {"--mmap"}, {"--no-mmap"}} { + if _, err := normalizeLlamaOptions(rejected); err == nil { + t.Errorf("%v accepted", rejected) + } + } +} diff --git a/services/nvpair-engine-manager/llamacpp_test.go b/services/nvpair-engine-manager/llamacpp_test.go new file mode 100644 index 000000000..3b653848f --- /dev/null +++ b/services/nvpair-engine-manager/llamacpp_test.go @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func llamaManifest(t *testing.T) *Manifest { + t.Helper() + m := bundledManifestSet(t)["llamacpp"] + if m == nil { + t.Fatal("llama.cpp manifest missing") + } + return m +} + +func TestLlamaManifestLifecycleOnly(t *testing.T) { + m := llamaManifest(t) + if err := m.Validate(); err != nil { + t.Fatal(err) + } + if len(m.Platforms) != 1 { + t.Fatal("only macOS arm64 supported") + } + p, ok := m.PlatformFor("darwin", "arm64") + if !ok { + t.Fatal("macOS arm64 missing") + } + if p.Install.Fetch.SHA256 != "870ef967970fa18bbd31460bc5f7929bbb5f091359d8dc0f58e20133aa4b5fcd" || !strings.HasSuffix(p.Install.Fetch.URL, "/b12171/llama-b12171-bin-macos-arm64.tar.gz") { + t.Fatal("unverified release") + } + if len(m.Actions) != 0 { + t.Fatal("model integration must remain deferred") + } + if p.Runtime.Port != 8082 || p.Runtime.Bind != "127.0.0.1" || p.Runtime.Stop.WaitForExit { + t.Fatal("unsafe lifecycle defaults") + } + if p.Runtime.Ready.JSONMatch == nil || p.Runtime.Ready.JSONMatch.Field != "role" || p.Runtime.Ready.JSONMatch.Value != "router" { + t.Fatal("identity probe missing") + } + for _, key := range []string{"linux/amd64", "linux/arm64", "windows/amd64", "windows/arm64", "darwin/amd64"} { + parts := strings.Split(key, "/") + if _, ok := m.PlatformFor(parts[0], parts[1]); ok { + t.Fatal("unsupported platform enabled") + } + } +} + +func TestLlamaProtectedLaunchInputs(t *testing.T) { + p, _ := llamaManifest(t).PlatformFor("darwin", "arm64") + flags := []string{"--host", "--port", "--reuse-port", "--embd-gemma-default", "--fim-qwen-1.5b-default", "--fim-qwen-3b-default", "--fim-qwen-7b-default", "--fim-qwen-7b-spec", "--fim-qwen-14b-spec", "--fim-qwen-30b-default", "--gpt-oss-20b-default", "--gpt-oss-120b-default", "--vision-gemma-4b-default", "--vision-gemma-12b-default"} + for _, flag := range flags { + for _, spelling := range []string{flag, strings.ReplaceAll(flag, "-", "_")} { + // Only underscores after the leading -- are accepted by the vendor parser. + spelling = "--" + strings.TrimLeft(spelling, "-_") + for _, input := range []string{spelling + " 8082", spelling + "=8082", spelling + " 8082 " + spelling + " 8082", "-- " + spelling + " 8082"} { + t.Run(input, func(t *testing.T) { + ex := settingsExecutor(t, true) + settingsState(t, ex).plat.Runtime = p.Runtime + req := settingsRequest(t, ex) + req.Settings.LaunchText = input + if len(previewSettings(t, ex, req).Errors) == 0 { + t.Fatalf("accepted %s", input) + } + }) + } + } + } + for _, key := range []string{"LLAMA_ARG_HOST", "LLAMA_ARG_PORT", "LLAMA_ARG_REUSE_PORT", "LLAMA_CACHE"} { + for _, input := range []string{key + "=127.0.0.1", key + "=8082 " + key + "=8082", "-- " + key + "=8082"} { + t.Run(input, func(t *testing.T) { + ex := settingsExecutor(t, true) + settingsState(t, ex).plat.Runtime = p.Runtime + req := settingsRequest(t, ex) + req.Settings.LaunchText = input + if len(previewSettings(t, ex, req).Errors) == 0 { + t.Fatalf("accepted %s", input) + } + }) + } + } + // Persisted overrides must not be a second, less strict launch path. + for _, flag := range flags { + rt := p.Runtime + args := []string{"--", flag + "=8082"} + rt.LaunchArgs = &args + if _, err := resolveProcessLaunch(rt, "/fixture/server", map[string]string{"host": "127.0.0.1", "port": "8082", "install_dir": "/fixture"}); err == nil { + t.Fatalf("persisted %s accepted", flag) + } + } +} + +func TestLlamaResolvedLaunch(t *testing.T) { + p, _ := llamaManifest(t).PlatformFor("darwin", "arm64") + t.Setenv("LLAMA_ARG_HOST", "/tmp/unwanted.sock") + t.Setenv("LLAMA_ARG_PORT", "9999") + t.Setenv("LLAMA_ARG_REUSE_PORT", "1") + t.Setenv("LLAMA_CACHE", "/tmp/unwanted-cache") + args, env := []string{"--threads", "4"}, []string{} + p.Runtime.LaunchArgs = &args + p.Runtime.LaunchEnv = &env + cmd, err := resolveProcessLaunch(p.Runtime, "/fixture/llama-server", map[string]string{"host": "127.0.0.1", "port": "8082", "install_dir": "/fixture"}) + if err != nil { + t.Fatal(err) + } + if strings.Join(cmd.Args, " ") != "--sleep-idle-seconds 300 --host 127.0.0.1 --port 8082 --threads 4" || cmd.Env["LLAMA_CACHE"] != "/fixture-models" || cmd.Env["LLAMA_ARG_HOST"] != "127.0.0.1" || cmd.Env["LLAMA_ARG_PORT"] != "8082" || cmd.Env["LLAMA_ARG_REUSE_PORT"] != "0" { + t.Fatalf("unsafe launch: %+v", cmd) + } +} + +func TestLlamaReadinessIdentity(t *testing.T) { + p, _ := llamaManifest(t).PlatformFor("darwin", "arm64") + for _, body := range []string{`{"role":"router"}`, `{}`, `{"role":"model"}`, `{"role":true}`, `not json`, `{"role":"router"} trailing`, strings.Repeat(" ", 65537) + `{"role":"router"}`} { + t.Run(body[:min(30, len(body))], func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/props" { + t.Error("wrong identity path") + } + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + ex := NewExecutor(NewRegistry(), NewReporter(nil), nil, t.TempDir()) + if got := ex.probe(context.Background(), p.Runtime.Ready, portOf(t, srv.URL)); got != (body == `{"role":"router"}`) { + t.Fatalf("identity accepted=%v", got) + } + }) + } +} + +func TestLlamaNoProxyLaunchSettings(t *testing.T) { + p, _ := llamaManifest(t).PlatformFor("darwin", "arm64") + ex := settingsExecutor(t, true) + st := settingsState(t, ex) + st.plat.Runtime = p.Runtime + ex.engines["llamacpp"] = st + request := settingsRequest(t, ex) + request.Engine = "llamacpp" + request.Settings.ServerPort = 8082 + request.Settings.ProxyPort = 0 + request.Settings.LaunchText = "--threads 4" + preview, err := ex.PreviewLaunch(request) + if err != nil || len(preview.Errors) != 0 || preview.Settings.ProxyPort != 0 { + t.Fatalf("proxy-free preview: %+v %v", preview, err) + } + request.Settings.ProxyPort = 8081 + preview, err = ex.PreviewLaunch(request) + if err == nil && len(preview.Errors) == 0 { + t.Fatal("future proxy accepted") + } +} + +func TestLlamaPortCollision(t *testing.T) { + m := llamaManifest(t) + p, _ := m.PlatformFor("darwin", "arm64") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"role":"other"}`)) })) + defer srv.Close() + reg := NewRegistry() + reg.engines["llamacpp"] = m + ex := reconciledTestExecutor(NewExecutor(reg, NewReporter(nil), nil, t.TempDir())) + dir := filepath.Join(t.TempDir(), "engine") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "llama-server"), []byte("not executable fixture"), 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"libllama-server-impl.dylib", "libggml-metal.0.29.0.dylib"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("fixture"), 0644); err != nil { + t.Fatal(err) + } + } + p.Runtime.Port = portOf(t, srv.URL) + ex.engines["llamacpp"] = &engineState{manifest: m, plat: p, port: p.Runtime.Port, installDir: dir, logs: newLogBuffer()} + if err := ex.Start(context.Background(), "llamacpp"); err == nil || !strings.Contains(err.Error(), "occupied") { + t.Fatalf("collision: %v", err) + } +} diff --git a/services/nvpair-engine-manager/manifest.schema.json b/services/nvpair-engine-manager/manifest.schema.json index 3259a895d..9284c4b41 100644 --- a/services/nvpair-engine-manager/manifest.schema.json +++ b/services/nvpair-engine-manager/manifest.schema.json @@ -45,6 +45,18 @@ "type": "integer", "minimum": 0 }, + "forbidden_flags": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^(?:-[a-zA-Z]|--[a-zA-Z0-9_.-]+)$" } + }, + "forbidden_env": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" } + }, + "normalize_flag_underscores": { "type": "boolean" }, + "option_policy": { "enum": ["llamacpp-b12171"] }, "controls": { "type": "array", "minItems": 1, @@ -79,7 +91,7 @@ "uniqueItems": true, "items": { "type": "string", - "pattern": "^(?:-[a-zA-Z]|--[a-zA-Z0-9_-]+)$" + "pattern": "^(?:-[a-zA-Z]|--[a-zA-Z0-9_.-]+)$" } }, "env": { diff --git a/services/nvpair-engine-manager/manifests/llamacpp.json b/services/nvpair-engine-manager/manifests/llamacpp.json new file mode 100644 index 000000000..746bae66b --- /dev/null +++ b/services/nvpair-engine-manager/manifests/llamacpp.json @@ -0,0 +1,136 @@ +{ + "engine": "llamacpp", + "display_name": "llama.cpp", + "manifest_version": 1, + "platforms": { + "darwin/arm64": { + "detect": [ + "{install_dir}/llama-server" + ], + "detect_requires": [ + "{install_dir}/libllama-server-impl.dylib", + "{install_dir}/libggml-metal.0.29.0.dylib" + ], + "install": { + "fetch": { + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b12171/llama-b12171-bin-macos-arm64.tar.gz", + "sha256": "870ef967970fa18bbd31460bc5f7929bbb5f091359d8dc0f58e20133aa4b5fcd" + }, + "archive": { + "required": [ + "llama-server", + "LICENSE", + "libllama-server-impl.dylib", + "libggml-base.0.29.0.dylib", + "libggml-base.0.dylib", + "libggml-base.dylib", + "libggml-blas.0.29.0.dylib", + "libggml-blas.0.dylib", + "libggml-blas.dylib", + "libggml-cpu.0.29.0.dylib", + "libggml-cpu.0.dylib", + "libggml-cpu.dylib", + "libggml-metal.0.29.0.dylib", + "libggml-metal.0.dylib", + "libggml-metal.dylib", + "libggml-rpc.0.29.0.dylib", + "libggml-rpc.0.dylib", + "libggml-rpc.dylib", + "libggml.0.29.0.dylib", + "libggml.0.dylib", + "libggml.dylib", + "libllama.0.9.0.dylib", + "libllama.0.dylib", + "libllama.dylib", + "libllama-common.0.9.0.dylib", + "libllama-common.0.dylib", + "libllama-common.dylib", + "libmtmd.0.9.0.dylib", + "libmtmd.0.dylib", + "libmtmd.dylib" + ], + "codesign": "llama-server" + }, + "mode": "user" + }, + "models_dir": "{install_dir}-models", + "uninstall": { + "remove": [ + "{install_dir}" + ] + }, + "runtime": { + "mode": "process", + "bin": "{install_dir}/llama-server", + "args": [ + "--sleep-idle-seconds", + "300", + "--host", + "{host}", + "--port", + "{port}" + ], + "env": { + "LLAMA_CACHE": "{install_dir}-models", + "LLAMA_ARG_REUSE_PORT": "0", + "LLAMA_ARG_API_PREFIX": "", + "LLAMA_ARG_OFFLINE": "1", + "HOME": "{install_dir}/.pair-home", + "XDG_CONFIG_HOME": "{install_dir}/.pair-config", + "PATH": "/usr/bin:/bin" + }, + "bind": "127.0.0.1", + "port": 8082, + "editable_launch": { + "fixed_args": [ + "--sleep-idle-seconds", + "300" + ], + "option_policy": "llamacpp-b12171", + "controls": [ + { + "flags": [ + "--host" + ], + "env": [ + "LLAMA_ARG_HOST" + ], + "value": "{server.host}" + }, + { + "flags": [ + "--port" + ], + "env": [ + "LLAMA_ARG_PORT" + ], + "value": "{server.port}" + } + ] + }, + "ready": { + "http": "http://127.0.0.1:{port}/props", + "status": 200, + "timeout_s": 60, + "json_match": { + "field": "role", + "value": "router" + } + }, + "health": { + "http": "http://127.0.0.1:{port}/props", + "status": 200, + "interval_s": 5, + "json_match": { + "field": "role", + "value": "router" + } + }, + "stop": { + "signal": "term", + "grace_s": 10 + } + } + } + } +} diff --git a/services/nvpair-engine-manager/proc.go b/services/nvpair-engine-manager/proc.go index ca0025dbc..cd1c2375f 100644 --- a/services/nvpair-engine-manager/proc.go +++ b/services/nvpair-engine-manager/proc.go @@ -29,9 +29,16 @@ type managedProc struct { // stdout/stderr lines to onLine. The returned proc's done channel // closes once the process exits. func startManagedProc(bin string, args []string, env map[string]string, onLine func(stream, line string)) (*managedProc, error) { + return startManagedProcWithEnvironment(bin, args, env, true, onLine) +} + +func startManagedProcWithEnvironment(bin string, args []string, env map[string]string, inherit bool, onLine func(stream, line string)) (*managedProc, error) { cmd := exec.Command(bin, args...) - if len(env) > 0 { - cmd.Env = os.Environ() + if len(env) > 0 || !inherit { + cmd.Env = []string{} + if inherit { + cmd.Env = os.Environ() + } for k, v := range env { cmd.Env = append(cmd.Env, k+"="+v) } diff --git a/services/nvpair-engine-manager/provenance.go b/services/nvpair-engine-manager/provenance.go index 1bc9cac90..1e20f9d43 100644 --- a/services/nvpair-engine-manager/provenance.go +++ b/services/nvpair-engine-manager/provenance.go @@ -35,8 +35,9 @@ type installMarker struct { // in place (install.creates) by its file identity. A copy put there later // — by the user, or by a vendor updater that replaces the bundle — is a // different file, and the claim no longer covers it. - Creates string `json:"creates,omitempty"` - Identity string `json:"identity,omitempty"` + Creates string `json:"creates,omitempty"` + Identity string `json:"identity,omitempty"` + ArtifactSHA256 string `json:"artifact_sha256,omitempty"` } func installMarkerPath(installDir string) string { diff --git a/services/nvpair-engine-manager/registry.go b/services/nvpair-engine-manager/registry.go index 587b92978..f1b18a5d7 100644 --- a/services/nvpair-engine-manager/registry.go +++ b/services/nvpair-engine-manager/registry.go @@ -91,7 +91,8 @@ type Install struct { // runner mounts it read-only, copies App into Into unless an app is // already there, detaches it and, with Open, opens the app. The runner // owns the mount, so it is detached on every path, cancellation included. - DiskImage *DiskImage `json:"disk_image,omitempty"` + DiskImage *DiskImage `json:"disk_image,omitempty"` + Archive *ArchiveInstall `json:"archive,omitempty"` // Creates names what the install puts in place, when that is somewhere a // user's own copy could already be (an app in /Applications). PAIR records // the install as its own only if Creates did not exist before it ran, so @@ -191,9 +192,13 @@ type Runtime struct { } type EditableLaunch struct { - FixedArgs []string `json:"fixed_args"` - Controls []LaunchControl `json:"controls"` - StartIndex int `json:"start_index,omitempty"` + OptionPolicy string `json:"option_policy,omitempty"` + ForbiddenFlags []string `json:"forbidden_flags,omitempty"` + ForbiddenEnv []string `json:"forbidden_env,omitempty"` + NormalizeFlagUnderscores bool `json:"normalize_flag_underscores,omitempty"` + FixedArgs []string `json:"fixed_args"` + Controls []LaunchControl `json:"controls"` + StartIndex int `json:"start_index,omitempty"` } // LaunchControl binds CLI/environment syntax to PAIR policy fields. Value is a @@ -224,12 +229,18 @@ func (r *Runtime) hasCustomLaunch() bool { // Probe is an HTTP or TCP reachability check. Exactly one of HTTP/TCP // should be set; HTTP wins if both are. +type ProbeJSONMatch struct { + Field string `json:"field"` + Value string `json:"value"` +} + type Probe struct { - HTTP string `json:"http,omitempty"` // url template, e.g. "http://127.0.0.1:{port}/" - TCP string `json:"tcp,omitempty"` // host:port template, e.g. "127.0.0.1:{port}" - Status int `json:"status,omitempty"` // expected HTTP status (default 200) - TimeoutS int `json:"timeout_s,omitempty"` - IntervalS int `json:"interval_s,omitempty"` + JSONMatch *ProbeJSONMatch `json:"json_match,omitempty"` + HTTP string `json:"http,omitempty"` // url template, e.g. "http://127.0.0.1:{port}/" + TCP string `json:"tcp,omitempty"` // host:port template, e.g. "127.0.0.1:{port}" + Status int `json:"status,omitempty"` // expected HTTP status (default 200) + TimeoutS int `json:"timeout_s,omitempty"` + IntervalS int `json:"interval_s,omitempty"` } // StopSpec is how to terminate the engine. Default is a graceful @@ -746,6 +757,11 @@ func (p *Platform) validate(key string) error { return fmt.Errorf("platform %q: install.creates must name the app disk_image copies", key) } } + if p.Install.Archive != nil { + if err := p.Install.validateArchive(); err != nil { + return fmt.Errorf("platform %q: %w", key, err) + } + } if strings.Contains(p.Install.Creates, "{") { return fmt.Errorf("platform %q: install.creates %q must be a literal path", key, p.Install.Creates) } @@ -755,7 +771,7 @@ func (p *Platform) validate(key string) error { return fmt.Errorf("platform %q: install.mode %q invalid (want \"user\" or \"admin\")", key, p.Install.Mode) } } - if strings.Contains(p.ModelsDir, "{") { + if strings.Contains(p.ModelsDir, "{") && !(p.Install != nil && p.Install.Archive != nil && p.ModelsDir == "{install_dir}-models") { return fmt.Errorf("platform %q: models_dir %q must be a literal path, not a template — the model store must not be derived from the install directory", key, p.ModelsDir) } if p.Uninstall != nil { @@ -803,6 +819,9 @@ func validateProbe(key, which string, p *Probe) error { if p == nil { return nil } + if p.JSONMatch != nil && (p.HTTP == "" || p.JSONMatch.Field == "" || p.JSONMatch.Value == "") { + return fmt.Errorf("platform %q: runtime.%s.json_match requires HTTP, field and value", key, which) + } if strings.TrimSpace(p.HTTP) == "" && strings.TrimSpace(p.TCP) == "" { return fmt.Errorf("platform %q: runtime.%s must set either http or tcp", key, which) } diff --git a/services/nvpair-engine-manager/settings.go b/services/nvpair-engine-manager/settings.go index 71eb1e9ba..490f0f3c8 100644 --- a/services/nvpair-engine-manager/settings.go +++ b/services/nvpair-engine-manager/settings.go @@ -17,6 +17,9 @@ import ( ) func applyLiteralLaunch(rt Runtime, command launchCommand, vars map[string]string) (launchCommand, error) { + if err := validateLlamaOverrides(rt); err != nil { + return launchCommand{}, err + } if err := rejectManagedOverrides(rt); err != nil { return launchCommand{}, err } @@ -81,7 +84,15 @@ func applyLiteralLaunch(rt Runtime, command launchCommand, vars map[string]strin command.Args = append(command.Args, control.flag(), value) } } - command.Args = append(command.Args, (*rt.LaunchArgs)...) + args := *rt.LaunchArgs + if p.llamaCPP() { + var err error + args, err = normalizeLlamaOptions(args) + if err != nil { + return launchCommand{}, err + } + } + command.Args = append(command.Args, args...) return command, nil } @@ -180,7 +191,7 @@ func (e *Executor) previewLaunchLocked(st *engineState, request settings.Request result := settings.Preview{Settings: request.Settings, Args: []string{}, Env: []string{}, Errors: map[string]string{}} fail := func(message string) settings.Preview { result.Errors["launchText"] = message; return result } - if request.Settings.ProxyPort < 1 || request.Settings.ProxyPort > 65535 { + if (request.Engine == "llamacpp" && request.Settings.ProxyPort != 0) || (request.Engine != "llamacpp" && (request.Settings.ProxyPort < 1 || request.Settings.ProxyPort > 65535)) { result.Errors["proxyPort"] = "Enter a proxy port from 1 to 65535." return result } @@ -227,12 +238,18 @@ func (e *Executor) previewLaunchLocked(st *engineState, request settings.Request if err != nil { return fail(err.Error()) } + if policy.llamaCPP() { + tokens, err = normalizeLlamaOptions(tokens) + if err != nil { + return fail(err.Error()) + } + } for len(tokens) > 0 { key, _, ok := strings.Cut(tokens[0], "=") if !ok || strings.HasPrefix(key, "-") || strings.ContainsAny(key, "/\\") { break } - if policy.environmentControl(key) != nil { + if policy.protectedEnvironment(key) { return fail(managedLaunchMessage) } result.Env = append(result.Env, tokens[0]) @@ -457,7 +474,7 @@ func rejectManagedOverrides(rt Runtime) error { if rt.LaunchEnv != nil { for _, assignment := range *rt.LaunchEnv { key, _, _ := strings.Cut(assignment, "=") - if policy.environmentControl(key) != nil { + if policy.protectedEnvironment(key) { return fmt.Errorf("%s", managedLaunchMessage) } } diff --git a/services/nvpair-engine-manager/testdata/llamacpp-b12171-options.txt b/services/nvpair-engine-manager/testdata/llamacpp-b12171-options.txt new file mode 100644 index 000000000..4865bd25d --- /dev/null +++ b/services/nvpair-engine-manager/testdata/llamacpp-b12171-options.txt @@ -0,0 +1,555 @@ +# Option names defined by common_arg(...) in ggml-org/llama.cpp b12171 common/arg.cpp. +# Regenerate when the pinned release changes. +--adaptive-decay +--adaptive-target +--agent +--alias +--api-key +--api-key-file +--api-prefix +--attention +--audio +--backend-sampling +--batch-size +--binary-file +--cache-idle-slots +--cache-list +--cache-prompt +--cache-ram +--cache-reuse +--cache-type-k +--cache-type-k-draft +--cache-type-v +--cache-type-v-draft +--chat-template +--chat-template-file +--chat-template-kwargs +--check +--check-tensors +--checkpoint-min-step +--chunk +--chunk-separator +--chunk-size +--chunks +--cls-separator +--color +--completion-bash +--connect +--connect-code +--cont-batching +--context-file +--context-shift +--control-vector +--control-vector-layer-range +--control-vector-scaled +--conversation +--cors-credentials +--cors-headers +--cors-methods +--cors-origins +--cpu-mask +--cpu-mask-batch +--cpu-mask-batch-draft +--cpu-mask-draft +--cpu-moe +--cpu-moe-draft +--cpu-range +--cpu-range-batch +--cpu-range-batch-draft +--cpu-range-draft +--cpu-strict +--cpu-strict-batch +--cpu-strict-batch-draft +--cpu-strict-draft +--ctx-checkpoints +--ctx-size +--defrag-thold +--device +--device-draft +--dflash +--diffusion-add-gumbel-noise +--diffusion-alg-temp +--diffusion-algorithm +--diffusion-block-length +--diffusion-cfg-scale +--diffusion-eps +--diffusion-steps +--diffusion-visual +--display-prompt +--docker-repo +--draft +--draft-max +--draft-min +--draft-n +--draft-n-min +--draft-p-min +--draft-p-split +--dry-allowed-length +--dry-base +--dry-multiplier +--dry-penalty-last-n +--dry-sequence-breaker +--dynatemp-exp +--dynatemp-range +--eagle3 +--embd-gemma-default +--embd-normalize +--embd-output-format +--embd-separator +--embedding +--embeddings +--epochs +--escape +--file +--fim-qwen-1.5b-default +--fim-qwen-14b-spec +--fim-qwen-30b-default +--fim-qwen-3b-default +--fim-qwen-7b-default +--fim-qwen-7b-spec +--fit +--fit-ctx +--fit-print +--fit-target +--flash-attn +--frequency-penalty +--from-chunk +--gpt-oss-120b-default +--gpt-oss-20b-default +--gpu-layers +--gpu-layers-draft +--grammar +--grammar-file +--grp-attn-n +--grp-attn-w +--hellaswag +--hellaswag-tasks +--help +--hf-file +--hf-repo +--hf-repo-draft +--hf-token +--host +--ids +--ignore-eos +--image +--image-max-tokens +--image-min-tokens +--in-file +--in-prefix +--in-prefix-bos +--in-suffix +--interactive +--interactive-first +--jinja +--json-schema +--json-schema-file +--junk +--keep +--kl-divergence +--kl-divergence-base +--kv-offload +--kv-unified +--kv-unified-per-slot +--lazy-mode +--learning-rate +--learning-rate-decay-epochs +--learning-rate-min +--list-devices +--load-mode +--log-colors +--log-disable +--log-file +--log-jsonl +--log-prefix +--log-prompts-dir +--log-timestamps +--log-verbose +--log-verbosity +--logit-bias +--logits-output-dir +--lookup-cache-dynamic +--lookup-cache-static +--lora +--lora-init-without-apply +--lora-scaled +--main-gpu +--mcp-servers-config +--mcp-servers-json +--media-path +--method +--metrics +--min-p +--mirostat +--mirostat-ent +--mirostat-lr +--mmproj +--mmproj-auto +--mmproj-offload +--mmproj-url +--model +--model-draft +--model-url +--models-autoload +--models-dir +--models-max +--models-memory-margin +--models-preset +--moe-cache-mib +--mtmd-batch-max-tokens +--mtp +--multiline-input +--multiple-choice +--multiple-choice-tasks +--n-cpu-ffn +--n-cpu-moe +--n-cpu-moe-draft +--n-gpu-layers +--n-gpu-layers-draft +--n-predict +--negative-file +--nextn +--no-agent +--no-bos +--no-cache-idle-slots +--no-cache-prompt +--no-cont-batching +--no-context-shift +--no-conversation +--no-cors-credentials +--no-display-prompt +--no-escape +--no-host +--no-jinja +--no-kv-offload +--no-kv-unified +--no-log-jsonl +--no-log-prefix +--no-log-timestamps +--no-mmproj +--no-mmproj-auto +--no-mmproj-offload +--no-models-autoload +--no-op-offload +--no-parse-special +--no-perf +--no-ppl +--no-prefill-assistant +--no-reasoning-preserve +--no-repack +--no-show-timings +--no-skip-chat-parsing +--no-slots +--no-spec-draft-backend-sampling +--no-ui +--no-ui-mcp-proxy +--no-warmup +--no-webui +--no-webui-mcp-proxy +--numa +--offline +--op-offload +--optimizer +--output +--output-file +--output-format +--output-frequency +--override-kv +--override-tensor +--override-tensor-draft +--parallel +--parse-special +--path +--pca-batch +--pca-iter +--perf +--poll +--poll-batch +--poll-batch-draft +--poll-draft +--pooling +--port +--pos +--positive-file +--ppl +--ppl-output-type +--ppl-stride +--predict +--prefill-assistant +--presence-penalty +--print-token-count +--prio +--prio-batch +--prio-batch-draft +--prio-draft +--process-output +--prompt +--prompt-cache +--prompt-cache-all +--prompt-cache-ro +--props +--reasoning +--reasoning-budget +--reasoning-budget-message +--reasoning-effort +--reasoning-format +--reasoning-preserve +--repack +--repeat-last-n +--repeat-penalty +--rerank +--reranking +--reuse-port +--reverse-prompt +--rope-freq-base +--rope-freq-scale +--rope-scale +--rope-scaling +--rpc +--sampler-seq +--samplers +--sampling-seq +--save-all-logits +--save-frequency +--save-logits +--seed +--sequences +--server-base +--show-count +--show-statistics +--show-timings +--simple-io +--single-turn +--skip-chat-parsing +--sleep-idle-seconds +--slot-prompt-similarity +--slot-save-path +--slots +--spec-default +--spec-draft-backend-sampling +--spec-draft-cpu-mask +--spec-draft-cpu-mask-batch +--spec-draft-cpu-moe +--spec-draft-cpu-range +--spec-draft-cpu-range-batch +--spec-draft-cpu-strict +--spec-draft-cpu-strict-batch +--spec-draft-device +--spec-draft-hf +--spec-draft-model +--spec-draft-n-cpu-moe +--spec-draft-n-max +--spec-draft-n-min +--spec-draft-n-ubatch +--spec-draft-ncmoe +--spec-draft-ngl +--spec-draft-override-tensor +--spec-draft-p-min +--spec-draft-p-split +--spec-draft-poll +--spec-draft-poll-batch +--spec-draft-prio +--spec-draft-prio-batch +--spec-draft-sampling +--spec-draft-threads +--spec-draft-threads-batch +--spec-draft-type-k +--spec-draft-type-v +--spec-ngram-map-k-min-hits +--spec-ngram-map-k-size-m +--spec-ngram-map-k-size-n +--spec-ngram-map-k4v-min-hits +--spec-ngram-map-k4v-size-m +--spec-ngram-map-k4v-size-n +--spec-ngram-min-hits +--spec-ngram-mod-n-match +--spec-ngram-mod-n-max +--spec-ngram-mod-n-min +--spec-ngram-simple-min-hits +--spec-ngram-simple-size-m +--spec-ngram-simple-size-n +--spec-ngram-size-m +--spec-ngram-size-n +--spec-synth-len +--spec-synth-rates +--spec-type +--special +--split-mode +--spm-infill +--sse-ping-interval +--ssl-cert-file +--ssl-key-file +--stdin +--swa-checkpoints +--swa-full +--system-prompt +--system-prompt-file +--tags +--temp +--temperature +--tensor-filter +--tensor-split +--threads +--threads-batch +--threads-batch-draft +--threads-draft +--threads-http +--timeout +--tools +--tools-runtime +--top-k +--top-n-sigma +--top-nsigma +--top-p +--tts-lang +--tts-speaker-file +--tts-voice +--typical +--typical-p +--ubatch-size +--ui +--ui-config +--ui-config-file +--ui-mcp-proxy +--usage +--val-split +--verbose +--verbose-prompt +--verbosity +--version +--video +--video-ffmpeg-dir +--video-fps +--video-timestamp-interval +--vision-gemma-12b-default +--vision-gemma-4b-default +--warmup +--webui +--webui-config +--webui-config-file +--webui-mcp-proxy +--weight-decay +--winogrande +--winogrande-tasks +--xtc-probability +--xtc-threshold +--yarn-attn-factor +--yarn-beta-fast +--yarn-beta-slow +--yarn-ext-factor +--yarn-orig-ctx +-C +-Cb +-Cbd +-Cd +-Cr +-Crb +-Crbd +-Crd +-a +-ag +-b +-bf +-bs +-c +-cb +-cl +-cmoe +-cmoed +-cms +-cnv +-co +-cram +-ctk +-ctkd +-ctv +-ctvd +-ctxcp +-decay-epochs +-dev +-devd +-dr +-dt +-e +-epochs +-f +-fa +-fit +-fitc +-fitp +-fitt +-gan +-gaw +-h +-hf +-hfd +-hff +-hfr +-hfrd +-hft +-i +-if +-j +-jf +-kvo +-kvu +-l +-lcd +-lcs +-lm +-lr +-lr-min +-lv +-lzm +-m +-md +-mg +-mli +-mm +-mmu +-mu +-n +-ncffn +-ncmoe +-ncmoed +-ngl +-ngld +-nkvo +-no-ag +-no-cnv +-no-kvu +-nocb +-np +-npl +-npp +-nr +-ns +-ntg +-o +-ofreq +-opt +-ot +-otd +-p +-pps +-ptc +-r +-rea +-s +-sm +-sp +-sps +-st +-sys +-sysf +-t +-tb +-tbd +-td +-tgs +-to +-ts +-ub +-v +-val-split +-wd diff --git a/services/nvpair-tui/README.md b/services/nvpair-tui/README.md index ec146b680..96b351dde 100644 --- a/services/nvpair-tui/README.md +++ b/services/nvpair-tui/README.md @@ -122,7 +122,14 @@ followed by a failed engine restart is shown as **deleted, restart failed**. engine restart before `engine:set-port`. It does not change the proxy port. `U` confirms destructive reinstall: `engine:uninstall` must succeed before `engine:install {engine,start:true}` is sent. Failures identify the phase. Files -removed by the uninstaller are deleted; update has no rollback guarantee. +removed by the uninstaller are deleted; update has no rollback guarantee for +these command-installed engines. llama.cpp is the exception: stop it first; +`U` sends only `engine:install` so Go can validate and atomically exchange the +PAIR-owned archive installation, keeping the previous tree on failure and +preserving the sibling model cache. The engine list reports llama.cpp lifecycle +state with a note: its macOS arm64 build is not notarized, like PAIR itself, and +it does not serve requests through PAIR yet. No new proxy or model controls are +added. All destructive confirmations (including `u`, cluster remove/leave and manual remove) use the shared default-cancel prompt: only `y` dispatches; `n`, Esc and diff --git a/services/nvpair-tui/ui/engines.go b/services/nvpair-tui/ui/engines.go index c5f569733..1ee61d012 100644 --- a/services/nvpair-tui/ui/engines.go +++ b/services/nvpair-tui/ui/engines.go @@ -401,7 +401,11 @@ func (v *enginesView) handleKey(msg tea.KeyMsg) tea.Cmd { if v.target != "" || engine == "" || !e.Capabilities.Install || !e.Capabilities.Uninstall { return nil } - v.confirm = newConfirm("Update engine?", fmt.Sprintf("Update reinstalls %s: it is uninstalled, then installed and started. Inference stops meanwhile, and files the engine's uninstaller removes are deleted.", engine), v.updateCmd(engine)) + warning := fmt.Sprintf("Update reinstalls %s: it is uninstalled, then installed and started. Inference stops meanwhile, and files the engine's uninstaller removes are deleted.", engine) + if engine == "llamacpp" { + warning = "Stop llama.cpp before updating. PAIR atomically replaces its owned engine files and keeps the model cache; failed validation leaves the previous install unchanged." + } + v.confirm = newConfirm("Update engine?", warning, v.updateCmd(engine)) return nil } if key.Matches(msg, engPullKey) { @@ -494,6 +498,9 @@ func (v *enginesView) submitPort() tea.Cmd { // deadline. Do not dispatch install until uninstall has replied successfully. func (v *enginesView) updateCmd(engine string) tea.Cmd { client := v.client + if engine == "llamacpp" { + return callWithTimeout(client, "engine:install", map[string]any{"engine": engine, "start": true}, longOpTimeout, func(_ *rpc.Message, err error) tea.Msg { return engineOpMsg{what: "update", engine: engine, err: err} }) + } return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), longOpTimeout) defer cancel() @@ -616,6 +623,9 @@ func (v *enginesView) View() string { if v.models.open { out = v.modelsView() } + if v.selectedEngine() == "llamacpp" { + out += "\n" + footerStyle.Render("llama.cpp is not notarized, like PAIR itself; it does not serve requests through PAIR yet.") + } if v.confirm.open() { out += "\n" + v.confirm.View() } diff --git a/services/nvpair-tui/ui/llamacpp_test.go b/services/nvpair-tui/ui/llamacpp_test.go new file mode 100644 index 000000000..65bd036f4 --- /dev/null +++ b/services/nvpair-tui/ui/llamacpp_test.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" + "strings" + "testing" +) + +func TestLlamaLifecycleNotice(t *testing.T) { + v := newEnginesView(nil) + v.SetSize(100, 20) + v.merge(engineStatus{Engine: "llamacpp", DisplayName: "llama.cpp", Installed: true, Running: true, Port: 8082}) + rendered := v.View() + for _, notice := range []string{"llama.cpp", "8082", "not notarized", "does not serve requests through PAIR yet"} { + if !strings.Contains(rendered, notice) { + t.Fatalf("missing %s in %s", notice, rendered) + } + } +} + +func TestLlamaUpdateKeepsInstalledTree(t *testing.T) { + v, c := localEditableEngine(t) + done := make(chan tea.Msg, 1) + go func() { done <- v.updateCmd("llamacpp")() }() + q, err := c.Read() + if err != nil { + t.Fatal(err) + } + if q.Method != "engine:install" { + _ = c.RespondError(q.ID, -32000, "fixture refusal") + <-done + t.Fatalf("archive update first request = %s; must not uninstall", q.Method) + } + if err := c.RespondError(q.ID, -32000, "stop the engine before updating"); err != nil { + t.Fatal(err) + } + result := <-done + v.Update(result) + if !strings.Contains(v.status, "stop the engine") { + t.Fatalf("failure not surfaced: %s", v.status) + } +} diff --git a/services/nvpair-ui-broker/ENGINE_SETTINGS.md b/services/nvpair-ui-broker/ENGINE_SETTINGS.md index c15c0814d..11a566e51 100644 --- a/services/nvpair-ui-broker/ENGINE_SETTINGS.md +++ b/services/nvpair-ui-broker/ENGINE_SETTINGS.md @@ -19,7 +19,7 @@ worker's existing `set-port` RPC and `ready` port notification. | `engine:preview-settings` | `Request` | `Preview`, without saving or runtime mutation | | `engine:apply-settings` | `Request`, with `requestId` | `{revision, phase}` receipt | -`engine` is `ollama`, `lmstudio` or `omlx`. Omitted/local `nodeId` addresses +`engine` is `ollama`, `lmstudio`, `omlx` or `llamacpp`. Omitted/local `nodeId` addresses this device; a remote stable host UUID routes through engine-manager's verified peer directory and pinned cluster mTLS. Previews and bare manual engines do not qualify, even if a claimed identity matches a saved pin. @@ -38,7 +38,13 @@ engines do not qualify, even if a claimed identity matches a saved pin. `launchText` follows [`pair-arguments-v1`](../nvpair-engine-manager/LAUNCH_TEXT.md). Bind-address, port and proxy-wiring assignments are rejected, not reconciled with numeric -fields. Unrelated vendor arguments/environment are opaque. `resolution` is +fields. Unrelated vendor arguments/environment are opaque for Ollama and LM Studio. +llama.cpp instead uses the pinned, typed inference-only allowlist documented in +[LLAMACPP.md](../nvpair-engine-manager/LLAMACPP.md#launch-allowlist-b12171), with no +user environment assignments. Unlike #48's server launch text, its parser exposes +host execution, agents, tunnels, MCP and downloads: the same closed-policy +reasoning as #49's load options applies. Persisted invalid settings are reported +as invalid, never silently applied or discarded. `resolution` is accepted as an optional upstream request field but cannot authorize managed launch controls; `conflict` remains absent because those controls are rejected. @@ -55,6 +61,15 @@ PAIR-owned proxy port can change. Go rejects server-port, argument and environment edits with a message directing the user to the oMLX app. A proxy rebind never stops or restarts that app, including adopted running instances. +For llama.cpp, the same revision/recovery protocol manages the server port and +launch text without a proxy worker: `proxyPort` and `effectiveProxyPort` are +**zero**, the client hides the proxy-port field, and nonzero proxy ports are +rejected. No service is advertised and no model routing is enabled. A missing +proxy is not an editing error for this engine. Configured engine ports, existing +service reservations and live listener collisions still apply. Readiness uses +`/props` router identity. Load profiles remain LM Studio-only. See the +[b12171 launch audit and installation limits](../nvpair-engine-manager/LLAMACPP.md). + ## Per-model LM Studio load options The same node-owned settings surface layers model load profiles over the engine diff --git a/services/nvpair-ui-broker/README.md b/services/nvpair-ui-broker/README.md index baf192d0e..5a8925328 100644 --- a/services/nvpair-ui-broker/README.md +++ b/services/nvpair-ui-broker/README.md @@ -109,7 +109,9 @@ local engines and verified paired devices. It validates and journals the whole change before coordinating the engine lifecycle and the selected one of the three existing proxies. The broker, not the desktop, owns revisioning, port reservations and recovery. oMLX's app launch remains unchanged; its settings -permit only the PAIR proxy port. +permit only the PAIR proxy port. llama.cpp exposes the server port and launch +text with proxy port zero: no additional worker, service advertisement or routing +is enabled. Settings recovery and collision checks still apply. ## Communication diff --git a/services/nvpair-ui-broker/enginesettings.go b/services/nvpair-ui-broker/enginesettings.go index ccb10ed82..4f6399af9 100644 --- a/services/nvpair-ui-broker/enginesettings.go +++ b/services/nvpair-ui-broker/enginesettings.go @@ -185,7 +185,7 @@ func (b *Broker) settingsProxy(engine string) *proxyProcess { } func (b *Broker) settingsSnapshotLocked(ctx context.Context, engine string) (settings.Snapshot, error) { - if engine != "ollama" && engine != "lmstudio" && engine != "omlx" { + if engine != "ollama" && engine != "lmstudio" && engine != "omlx" && engine != "llamacpp" { return settings.Snapshot{}, fmt.Errorf("this engine does not support settings") } if err := b.loadEngineSettingsLocked(); err != nil { @@ -196,7 +196,7 @@ func (b *Broker) settingsSnapshotLocked(ctx context.Context, engine string) (set return settings.Snapshot{}, err } proxyPort := 0 - ready := false + ready := engine == "llamacpp" // lifecycle-only; no proxy exists yet if proxy := b.settingsProxy(engine); proxy != nil { ready, proxyPort = proxy.Status() } @@ -254,7 +254,7 @@ type settingsPublication struct { // full projection. Coalescing never reverses any engine's sequence. func (b *Broker) publishSettingsLocked() { all := make([]settings.Snapshot, 0, len(b.engineSettings)) - for _, engine := range []string{"ollama", "lmstudio", "omlx"} { + for _, engine := range []string{"ollama", "lmstudio", "omlx", "llamacpp"} { if record := b.engineSettings[engine]; record != nil { record.Snapshot.Sequence++ all = append(all, record.Snapshot) @@ -290,7 +290,10 @@ func (b *Broker) sendSettingsPublications() { } func (b *Broker) validateSettingsPortsLocked(ctx context.Context, engine string, config settings.Config, current settings.Snapshot) error { - if config.ServerPort < 1 || config.ServerPort > 65535 || config.ProxyPort < 1 || config.ProxyPort > 65535 { + if engine == "llamacpp" && config.ProxyPort != 0 { + return fmt.Errorf("llama.cpp has no PAIR proxy yet; proxy port must be zero") + } + if config.ServerPort < 1 || config.ServerPort > 65535 || (engine != "llamacpp" && (config.ProxyPort < 1 || config.ProxyPort > 65535)) { return fmt.Errorf("ports must be between 1 and 65535") } if config.ServerPort == config.ProxyPort { @@ -321,7 +324,7 @@ func (b *Broker) validateSettingsPortsLocked(ctx context.Context, engine string, return err } for _, other := range installed.Engines { - if other.Engine != engine && (other.Port == config.ServerPort || other.Port == config.ProxyPort) { + if other.Engine != engine && other.Port > 0 && (other.Port == config.ServerPort || other.Port == config.ProxyPort) { return fmt.Errorf("a selected port is reserved by another configured engine") } } @@ -343,6 +346,9 @@ func (b *Broker) validateSettingsPortsLocked(ctx context.Context, engine string, } } for _, port := range []int{config.ServerPort, config.ProxyPort} { + if port == 0 { + continue + } if port == current.EffectiveProxyPort || (current.Running && port == current.EffectiveServerPort) { continue } @@ -394,7 +400,7 @@ func (b *Broker) runSettingsOperationLocked(ctx context.Context, engine string, func (b *Broker) runSettingsOperationWithJoinLocked(ctx context.Context, engine string, record *engineSettingsRecord, joinBudget time.Duration) error { service := engineSettingsService(engine) - if b.regCache != nil { + if b.regCache != nil && service != "" { b.unregisterService(service) } b.setProxyLocalBackend(b.settingsProxy(engine), engine, record.Snapshot.EffectiveServerPort, false) @@ -769,6 +775,12 @@ func (b *Broker) rebindSettingsProxy(ctx context.Context, engine string, port in } func (b *Broker) rebindSettingsProxyOn(ctx context.Context, p *proxyProcess, engine string, port int) error { + if engine == "llamacpp" { + if p != nil || port != 0 { + return fmt.Errorf("llama.cpp has no PAIR proxy yet") + } + return ctx.Err() + } if p == nil { return fmt.Errorf("proxy unavailable") } @@ -803,7 +815,7 @@ func (b *Broker) refreshEngineSettings(ctx context.Context) { readCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() changed := false - for _, engine := range []string{"ollama", "lmstudio", "omlx"} { + for _, engine := range []string{"ollama", "lmstudio", "omlx", "llamacpp"} { var before settings.Snapshot if r := b.engineSettings[engine]; r != nil { before = r.Snapshot @@ -827,8 +839,10 @@ func engineSettingsService(engine string) noderec.ServiceKey { return noderec.ServiceOllama case "lmstudio": return noderec.ServiceLMStudio - default: + case "omlx": return noderec.ServiceOMLX + default: + return "" } } diff --git a/services/nvpair-ui-broker/enginesettings_recovery.go b/services/nvpair-ui-broker/enginesettings_recovery.go index 07f53f13d..3abb80017 100644 --- a/services/nvpair-ui-broker/enginesettings_recovery.go +++ b/services/nvpair-ui-broker/enginesettings_recovery.go @@ -58,7 +58,7 @@ func (b *Broker) recoverEngineSettings() []string { b.engineConfigMu.Lock() b.settingsRecoveryBlocked = make(map[string]bool) loadErr := b.loadEngineSettingsLocked() - for _, engine := range []string{"ollama", "lmstudio", "omlx"} { + for _, engine := range []string{"ollama", "lmstudio", "omlx", "llamacpp"} { record := b.engineSettings[engine] if loadErr != nil || (record != nil && (record.Snapshot.Phase == "failed" || record.Snapshot.Phase == "applying")) { b.settingsRecoveryBlocked[engine] = true @@ -67,7 +67,7 @@ func (b *Broker) recoverEngineSettings() []string { } b.engineConfigMu.Unlock() var excluded []string - for _, engine := range []string{"ollama", "lmstudio", "omlx"} { + for _, engine := range []string{"ollama", "lmstudio", "omlx", "llamacpp"} { if !b.recoverEngineSettingsForEngine(engine) { excluded = append(excluded, engine) } @@ -205,7 +205,9 @@ func (b *Broker) settingsAvailabilityBlockedLocked(engine string) bool { if !b.settingsRecoveryBlocked[engine] { return false } - b.unregisterService(engineSettingsService(engine)) + if service := engineSettingsService(engine); service != "" { + b.unregisterService(service) + } b.setProxyLocalBackend(b.settingsProxy(engine), engine, 0, false) return true } diff --git a/services/nvpair-ui-broker/enginesettings_startup_test.go b/services/nvpair-ui-broker/enginesettings_startup_test.go index dd42bb6f4..6162e0d19 100644 --- a/services/nvpair-ui-broker/enginesettings_startup_test.go +++ b/services/nvpair-ui-broker/enginesettings_startup_test.go @@ -176,7 +176,7 @@ func TestStartupUnreadableSettingsJournalBlocksAllManagedEngines(t *testing.T) { t.Fatal(err) } h.b.restoreEnabledEngines(h.b.getEngineMgr()) - if excluded := settingsRestoreNotification(t, h); !slices.Equal(excluded, []string{"ollama", "lmstudio", "omlx"}) { + if excluded := settingsRestoreNotification(t, h); !slices.Equal(excluded, []string{"ollama", "lmstudio", "omlx", "llamacpp"}) { t.Fatalf("unreadable journal exclusions = %v", excluded) } for _, reconcile := range []func(){ diff --git a/services/nvpair-ui-broker/enginesettings_test.go b/services/nvpair-ui-broker/enginesettings_test.go index cd56078f4..562324837 100644 --- a/services/nvpair-ui-broker/enginesettings_test.go +++ b/services/nvpair-ui-broker/enginesettings_test.go @@ -122,6 +122,9 @@ func newSettingsHarness(t *testing.T) *settingsHarness { h.launchMu.Lock() current := launch h.launchMu.Unlock() + var request settings.Request + _ = json.Unmarshal(msg.Params, &request) + current.Engine = request.Engine _ = codec.Respond(msg.ID, current) case "engine:configured-ports": _ = codec.Respond(msg.ID, map[string]any{"engines": []any{map[string]any{"engine": "lmstudio", "port": h.otherEnginePort.Load()}}}) @@ -154,9 +157,13 @@ func newSettingsHarness(t *testing.T) *settingsHarness { } var p settings.Configure _ = json.Unmarshal(msg.Params, &p) - _, previousProxy := h.b.settingsProxy(p.Engine).Status() + previousProxy := 0 + if proxy := h.b.settingsProxy(p.Engine); proxy != nil { + _, previousProxy = proxy.Status() + } h.launchMu.Lock() previousLaunch := launch + launch.Engine = p.Engine launch.ServerPort = p.Settings.ServerPort launch.LaunchText = p.Settings.LaunchText launch.Running = false diff --git a/services/nvpair-ui-broker/enginestartgate.go b/services/nvpair-ui-broker/enginestartgate.go index 51fefeba9..89b940773 100644 --- a/services/nvpair-ui-broker/enginestartgate.go +++ b/services/nvpair-ui-broker/enginestartgate.go @@ -19,7 +19,7 @@ func (b *Broker) prepareEngineStart(ctx context.Context, engine, operationID str if ctx.Err() != nil || !b.managedPortAuthorizedLocked(engine, operationID) { return "", fmt.Errorf("%s", settingsRecoveryError) } - if engine == "ollama" || engine == "lmstudio" || engine == "omlx" { + if engine == "ollama" || engine == "lmstudio" || engine == "omlx" || engine == "llamacpp" { loadErr := b.loadEngineSettingsLocked() record := b.engineSettings[engine] needsRecovery := loadErr != nil || b.settingsRecoveryBlocked[engine] || (record != nil && (record.Snapshot.Phase == "failed" || record.Snapshot.Phase == "applying")) diff --git a/services/nvpair-ui-broker/llamacpp_settings_test.go b/services/nvpair-ui-broker/llamacpp_settings_test.go new file mode 100644 index 000000000..395f8cbb0 --- /dev/null +++ b/services/nvpair-ui-broker/llamacpp_settings_test.go @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "net" + "strings" + "testing" + + settings "nvpair-shared/enginesettings" + "nvpair-shared/noderec" +) + +func TestLlamaSettingsWithoutProxy(t *testing.T) { + h := newSettingsHarness(t) + ctx := context.Background() + snapshot, err := h.b.getEngineSettings(ctx, settings.Request{Engine: "llamacpp"}, "") + if err != nil { + t.Fatal(err) + } + if !snapshot.Editable || snapshot.Settings.ProxyPort != 0 || snapshot.EffectiveProxyPort != 0 || h.b.settingsProxy("llamacpp") != nil { + t.Fatalf("llama.cpp requires a proxy: %+v", snapshot) + } + request := settings.Request{Engine: "llamacpp", ExpectedRevision: snapshot.Revision, RequestID: settingsID(), Settings: snapshot.Settings} + request.Settings.LaunchText = "--threads 4" + preview, err := h.b.previewEngineSettings(ctx, request, "") + if err != nil || len(preview.Errors) != 0 || preview.Rebind { + t.Fatalf("preview: %+v %v", preview, err) + } + receipt, err := h.b.applyEngineSettings(ctx, request, "") + if err != nil || receipt.Phase != "succeeded" || h.applies.Load() != 1 { + t.Fatalf("apply: %+v %v", receipt, err) + } + after, err := h.b.getEngineSettings(ctx, settings.Request{Engine: "llamacpp"}, "") + if err != nil || after.Settings.LaunchText != "--threads 4" || !after.Running || after.EffectiveProxyPort != 0 { + t.Fatalf("result: %+v %v", after, err) + } + if engineSettingsService("llamacpp") != "" { + t.Fatal("lifecycle-only engine maps to a routed service") + } + // In particular a settings change must not unregister another engine's service. + if engineSettingsService("omlx") != noderec.ServiceOMLX { + t.Fatal("existing service changed") + } + token, err := h.b.prepareEngineStart(ctx, "llamacpp", "") + if err != nil { + t.Fatal(err) + } + if err := h.b.authorizeEngineStart(settings.Request{Engine: "llamacpp", RequestID: token}); err != nil { + t.Fatal(err) + } +} + +func TestLlamaSettingsPortReservations(t *testing.T) { + h := newSettingsHarness(t) + ctx := context.Background() + s, err := h.b.getEngineSettings(ctx, settings.Request{Engine: "llamacpp"}, "") + if err != nil { + t.Fatal(err) + } + request := settings.Request{Engine: "llamacpp", ExpectedRevision: s.Revision, Settings: s.Settings} + for _, port := range []int{1, 8081} { + request.Settings.ProxyPort = port + p, err := h.b.previewEngineSettings(ctx, request, "") + if err == nil && len(p.Errors) == 0 { + t.Fatal("proxy port accepted before proxy integration") + } + } + request.Settings.ProxyPort = 0 + h.otherEnginePort.Store(int32(request.Settings.ServerPort)) + p, err := h.b.previewEngineSettings(ctx, request, "") + if err == nil && !strings.Contains(p.Errors["ports"], "configured engine") { + t.Fatalf("reservation not enforced: %+v %v", p, err) + } + h.otherEnginePort.Store(0) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + request.Settings.ServerPort = listener.Addr().(*net.TCPAddr).Port + p, err = h.b.previewEngineSettings(ctx, request, "") + if err == nil && !strings.Contains(p.Errors["ports"], "in use") { + t.Fatalf("occupied port accepted: %+v %v", p, err) + } +} + +func TestLlamaSettingsRecoveryWithoutProxy(t *testing.T) { + h := newSettingsHarness(t) + ctx := context.Background() + s, err := h.b.getEngineSettings(ctx, settings.Request{Engine: "llamacpp"}, "") + if err != nil { + t.Fatal(err) + } + r := h.b.engineSettings["llamacpp"] + r.Explicit = true + r.Previous = s.Settings + r.Snapshot.Phase = "applying" + r.Snapshot.Settings.LaunchText = "--threads 2" + if err := h.b.saveEngineSettingsLocked(); err != nil { + t.Fatal(err) + } + if excluded := h.b.recoverEngineSettings(); len(excluded) != 0 { + t.Fatalf("recovery blocked: %v", excluded) + } + if r.Snapshot.Phase != "succeeded" || r.Snapshot.Settings.ProxyPort != 0 { + t.Fatalf("bad recovery: %+v", r.Snapshot) + } +} diff --git a/services/versions.json b/services/versions.json index b14e0eff7..181d07ab0 100644 --- a/services/versions.json +++ b/services/versions.json @@ -11,11 +11,11 @@ "nvpair-workload-manager": "1.3.2", "nvpair-errors": "1.1.2", "nvpair-node-settings": "3.2.1", - "nvpair-ui-broker": "2.5.1", - "nvpair-engine-manager": "1.4.1", + "nvpair-ui-broker": "2.6.0", + "nvpair-engine-manager": "1.5.0", "nvpair-cluster-manager": "2.3.0", "nvpair-job-scheduler": "1.1.1", - "nvpair-tui": "1.3.1", + "nvpair-tui": "1.4.0", "omlx-proxy": "1.5.1" } }