diff --git a/docs/get-started/quickstart-hermes.mdx b/docs/get-started/quickstart-hermes.mdx index aa46a187bd7..99f5276b4dc 100644 --- a/docs/get-started/quickstart-hermes.mdx +++ b/docs/get-started/quickstart-hermes.mdx @@ -130,6 +130,26 @@ Use these details when your first-run path needs more control. Refer to [Previous onboarding session failed](../reference/troubleshooting#previous-onboarding-session-failed) for recovery details. + + On Linux, fresh Hermes Portable onboarding can run the selected Ollama model in a current-user rootless Podman container. + This path requires the Portable preflight to accept the current user's Podman and NVIDIA GPU authority. + Start fresh onboarding with an explicit Ollama model: + + ```bash + NEMOCLAW_PROVIDER=ollama \ + NEMOCLAW_MODEL=qwen3-vl:4b \ + nemohermes onboard --experimental-profile portable --fresh + ``` + + This path does not inspect, start, or use a host Ollama process. + It does not use the default Docker runtime. + Before the provider-selection step completes, NemoClaw creates the receipt-owned runner, sends one validation request for the selected model, and confirms that the exact model is loaded. + NemoClaw records the provider selection only after those checks pass. + + This onboarding path does not establish complete `destroy` or `uninstall` cleanup for the Portable Ollama runner. + Refer to [Set Up Ollama](../inference/local-inference/set-up-ollama#use-portable-ollama-with-hermes) for the fail-closed uninstall boundary. + + Hermes forwards its dashboard on port `18789` and its OpenAI-compatible API on port `8642`. A sandbox receives those ports when no other sandbox or host listener already holds them. diff --git a/docs/inference/set-up-ollama.mdx b/docs/inference/set-up-ollama.mdx index ad67533df2b..7f3e240be81 100644 --- a/docs/inference/set-up-ollama.mdx +++ b/docs/inference/set-up-ollama.mdx @@ -18,7 +18,29 @@ NemoClaw detects Ollama on the host and can install, start, or upgrade it on sup ## Prerequisites - Install NemoClaw by following the [Quickstart](../../get-started/quickstart). -- Use a host where Ollama is running or where the onboard wizard can install or start it. +- For the host Ollama path, use a host where Ollama is running or where the onboard wizard can install or start it. + + + +## Use Portable Ollama with Hermes + +Fresh Hermes Portable onboarding does not use the host Ollama setup described in the remaining sections. +It creates a receipt-owned Ollama runner through the current user's rootless Podman authority. +Run the fresh Portable command in the [Hermes quickstart](../../get-started/quickstart#use-portable-ollama) with `NEMOCLAW_PROVIDER=ollama` and an explicit `NEMOCLAW_MODEL`. + +Before the provider-selection step completes, NemoClaw verifies the Portable network, registry, GPU, and Podman authority. +It then creates the runner, sends one validation request for the selected model, and confirms that the exact model is loaded. +This path does not inspect, start, or use a host Ollama process, and it does not use the default Docker runtime. + + +This path covers fresh onboarding and provider selection. +It does not establish complete `destroy` or `uninstall` cleanup for the Portable Ollama runner. +Portable uninstall stops when it cannot prove exact cleanup authority for a Portable lifecycle receipt with schema `5`. +It exits nonzero before deleting the runner or its receipt. +Preserve the Portable Ollama runner and its lifecycle receipt. + + + ## Install or Upgrade Ollama diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index dca59da8be3..6b3b28d6acd 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3179,7 +3179,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { recoverySessionId, ), setupInference, - resolveHostLocalInferenceStartupSelection: () => null, + resolveHostLocalInferenceStartupSelection: setupNimFlow.createHermesPortableOllamaInferenceResolver({ runtimeContext: lockedRuntime.portableRuntimeContext, credentialEnv: OLLAMA_PROXY_CREDENTIAL_ENV, getReservationSessionId: () => session?.sessionId, runGatewayOpenshell: runCoreGatewayOpenshell }), startRecordedStep, recordStepComplete, recordStepRejected, diff --git a/src/lib/onboard/experimental/hermes-portable-ollama-authority.ts b/src/lib/onboard/experimental/hermes-portable-ollama-authority.ts new file mode 100644 index 00000000000..d73fa0e59ef --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-ollama-authority.ts @@ -0,0 +1,337 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; + +import type { + HostLocalInferenceOperationInput, + HostLocalInferenceReceiptWriter, + HostLocalInferenceRuntime, + HostLocalManagedInferenceInput, +} from "../runtime-provider/host-local-inference"; +import type { RuntimeProviderBundle } from "../runtime-provider/contract"; +import { createHermesPortablePodmanOperationEngines } from "./hermes-portable-podman-authority"; +import { + PORTABLE_DOCKER_NETWORK_NAME, + PORTABLE_DOCKER_NETWORK_SUBNET, + PORTABLE_HOST_GATEWAY_IP, + PORTABLE_REGISTRY_IP, +} from "./portable-profile"; + +export const PORTABLE_OLLAMA_IMAGE = + "docker.io/ollama/ollama@sha256:268c47cdc4718ded54babcd842579a7295ad79fd8d5c2ea64d7ba2e76872de6b"; +export const PORTABLE_PROBE_IMAGE = + "docker.io/curlimages/curl@sha256:fcff5cf7a4b895da7bd2933c914938db2b05d2113fa0d6c55b6d29930408f661"; +const GPU_UUID = /^GPU-[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$/u; +const NETWORK_ID = /^[a-f0-9]{64}$/u; +const REGISTRY_CONTAINER = "nemoclaw-portable-registry"; +const PORTABLE_MANAGED_LABEL = "com.nvidia.nemoclaw.portable"; +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u; +const IMAGE_PULL_TIMEOUT_MS = 30 * 60_000; + +function digest(value: object): string { + return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex"); +} + +export function captureCurrentGpuDevices(): readonly string[] { + const result = spawnSync("nvidia-smi", ["--query-gpu=uuid", "--format=csv,noheader"], { + encoding: "utf8", + timeout: 10_000, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error || result.status !== 0) { + throw new Error("Hermes Portable inference could not capture NVIDIA GPU UUID authority."); + } + const uuids = String(result.stdout ?? "") + .split(/\r?\n/u) + .map((value) => value.trim()) + .filter(Boolean); + if ( + uuids.length === 0 || + new Set(uuids).size !== uuids.length || + uuids.some((id) => !GPU_UUID.test(id)) + ) { + throw new Error("Hermes Portable inference received missing or ambiguous NVIDIA GPU UUIDs."); + } + return Object.freeze(uuids.sort().map((id) => `nvidia.com/gpu=${id}`)); +} + +export function captureCurrentCdiDevices(): readonly string[] { + const result = spawnSync("nvidia-ctk", ["cdi", "list"], { + encoding: "utf8", + timeout: 10_000, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error || result.status !== 0) { + throw new Error("Hermes Portable inference could not capture NVIDIA CDI authority."); + } + const devices = String(result.stdout ?? "") + .split(/\r?\n/u) + .map((value) => value.trim()) + .filter((value) => value.startsWith("nvidia.com/gpu=")); + if (devices.length === 0 || new Set(devices).size !== devices.length) { + throw new Error("Hermes Portable inference received missing or ambiguous NVIDIA CDI entries."); + } + return Object.freeze([...devices].sort()); +} + +export function captureQualifiedGpuDevices( + captureGpuDevices: () => readonly string[], + captureCdiDevices: () => readonly string[], +): readonly string[] { + const physicalDevices = Object.freeze([...captureGpuDevices()].sort()); + const cdiInventory = Object.freeze([...captureCdiDevices()].sort()); + const cdiPhysicalDevices = cdiInventory.filter( + (device) => + device.startsWith("nvidia.com/gpu=") && GPU_UUID.test(device.slice("nvidia.com/gpu=".length)), + ); + if ( + physicalDevices.length === 0 || + new Set(physicalDevices).size !== physicalDevices.length || + physicalDevices.some( + (device) => + !device.startsWith("nvidia.com/gpu=") || + !GPU_UUID.test(device.slice("nvidia.com/gpu=".length)), + ) || + new Set(cdiPhysicalDevices).size !== cdiPhysicalDevices.length || + cdiPhysicalDevices.join("\n") !== physicalDevices.join("\n") + ) { + throw new Error("Hermes Portable inference host GPU and CDI authority disagree."); + } + return physicalDevices; +} + +function requireRecord(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} is malformed.`); + } + return value as Record; +} + +function sortedStringRecord(value: unknown, label: string): Readonly> { + const source = requireRecord(value ?? {}, label); + const result: Record = Object.create(null); + for (const [key, entry] of Object.entries(source).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + )) { + if (typeof entry !== "string" || CONTROL_CHARACTERS.test(entry)) { + throw new Error(`${label} is malformed.`); + } + result[key] = entry; + } + return Object.freeze(result); +} + +function parseExactInspection(output: string, label: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + throw new Error(`${label} is malformed.`); + } + if (!Array.isArray(parsed) || parsed.length !== 1) { + throw new Error(`${label} is missing or ambiguous.`); + } + return requireRecord(parsed[0], label); +} + +function inspectPortableNetworkSnapshot( + engine: ReturnType["hostLocalInference"], +): { + readonly networkId: string; + readonly gatewayIp: string; + readonly authoritySha256: string; + readonly canonical: object; +} { + const result = engine.capture(["network", "inspect", PORTABLE_DOCKER_NETWORK_NAME], 30_000); + if (result.error || result.status !== 0) { + throw new Error("Hermes Portable inference could not inspect its Podman network authority."); + } + const network = parseExactInspection( + result.stdout, + "Hermes Portable inference network authority", + ); + const id = network.id; + const name = network.name; + const subnets = network.subnets; + const subnet = + Array.isArray(subnets) && subnets.length === 1 + ? requireRecord(subnets[0], "Hermes Portable inference network subnet") + : null; + if ( + typeof id !== "string" || + !NETWORK_ID.test(id) || + name !== PORTABLE_DOCKER_NETWORK_NAME || + network.driver !== "bridge" || + network.internal !== false || + network.ipv6_enabled !== false || + !subnet || + subnet.subnet !== PORTABLE_DOCKER_NETWORK_SUBNET || + typeof subnet.gateway !== "string" + ) { + throw new Error("Hermes Portable inference network authority changed after host preparation."); + } + const registryResult = engine.capture(["container", "inspect", REGISTRY_CONTAINER], 30_000); + if (registryResult.error || registryResult.status !== 0) { + throw new Error("Hermes Portable inference could not inspect its registry authority."); + } + const registry = parseExactInspection( + registryResult.stdout, + "Hermes Portable inference registry authority", + ); + const config = requireRecord(registry.Config, "Hermes Portable inference registry configuration"); + const state = requireRecord(registry.State, "Hermes Portable inference registry state"); + const networkSettings = requireRecord( + registry.NetworkSettings, + "Hermes Portable inference registry network settings", + ); + const networks = requireRecord( + networkSettings.Networks, + "Hermes Portable inference registry attachments", + ); + const attachments = Object.entries(networks); + const attachment = + attachments.length === 1 && attachments[0]?.[0] === PORTABLE_DOCKER_NETWORK_NAME + ? requireRecord(attachments[0][1], "Hermes Portable inference registry attachment") + : null; + const labels = sortedStringRecord(config.Labels, "Hermes Portable inference registry labels"); + if ( + typeof registry.Id !== "string" || + !NETWORK_ID.test(registry.Id) || + registry.Name !== REGISTRY_CONTAINER || + state.Running !== true || + labels[PORTABLE_MANAGED_LABEL] !== "1" || + !attachment || + attachment.NetworkID !== id || + attachment.IPAddress !== PORTABLE_REGISTRY_IP + ) { + throw new Error("Hermes Portable inference registry authority changed after host preparation."); + } + const canonical = Object.freeze({ + network: Object.freeze({ + id, + name, + driver: "bridge", + internal: false, + ipv6Enabled: false, + dnsEnabled: network.dns_enabled, + networkInterface: network.network_interface, + subnet: Object.freeze({ subnet: PORTABLE_DOCKER_NETWORK_SUBNET, gateway: subnet.gateway }), + labels: sortedStringRecord(network.labels, "Hermes Portable inference network labels"), + ipamOptions: sortedStringRecord( + network.ipam_options, + "Hermes Portable inference network IPAM options", + ), + options: sortedStringRecord(network.options, "Hermes Portable inference network options"), + listenerIp: PORTABLE_HOST_GATEWAY_IP, + }), + registry: Object.freeze({ + runtimeId: registry.Id, + name: REGISTRY_CONTAINER, + running: true, + labels, + networkId: id, + networkName: PORTABLE_DOCKER_NETWORK_NAME, + ipAddress: PORTABLE_REGISTRY_IP, + }), + }); + return Object.freeze({ + networkId: id, + gatewayIp: subnet.gateway, + authoritySha256: digest(canonical), + canonical, + }); +} + +export function capturePortableNetworkAuthority( + engine: ReturnType["hostLocalInference"], +) { + const captured = inspectPortableNetworkSnapshot(engine); + return Object.freeze({ + networkId: captured.networkId, + name: PORTABLE_DOCKER_NETWORK_NAME, + subnet: PORTABLE_DOCKER_NETWORK_SUBNET, + gatewayIp: captured.gatewayIp, + listenerIp: PORTABLE_HOST_GATEWAY_IP, + authoritySha256: captured.authoritySha256, + assertCurrent() { + const refreshed = inspectPortableNetworkSnapshot(engine); + if ( + refreshed.authoritySha256 !== captured.authoritySha256 || + !isDeepStrictEqual(refreshed.canonical, captured.canonical) + ) { + throw new Error("Hermes Portable inference network or registry authority drifted."); + } + }, + }); +} + +function acquireRetainedImage( + engine: ReturnType["hostLocalInference"], + image: string, + assertCurrent: () => void, +): void { + assertCurrent(); + const prior = engine.capture(["image", "exists", image], 30_000); + if (prior.error || (prior.status !== 0 && prior.status !== 1)) { + throw new Error("Hermes Portable inference could not inspect immutable image custody."); + } + if (prior.status === 0) return; + const pull = engine.capture(["pull", image], IMAGE_PULL_TIMEOUT_MS); + if (pull.error || pull.status !== 0) { + throw new Error("Hermes Portable inference could not acquire an immutable runtime image."); + } + const exists = engine.capture(["image", "exists", image], 30_000); + if (exists.error || exists.status !== 0) { + throw new Error("Hermes Portable inference could not prove its immutable runtime image."); + } + assertCurrent(); +} + +export function withRetainedImageAcquisition( + bundle: RuntimeProviderBundle, + engine: ReturnType["hostLocalInference"], + assertCurrent: () => void, +): RuntimeProviderBundle { + if (!bundle.hostLocalInference.supported) { + throw new Error("Hermes Portable inference runtime lacks managed startup authority."); + } + const hostLocalInference = bundle.hostLocalInference; + return Object.freeze({ + ...bundle, + hostLocalInference: Object.freeze({ + ...hostLocalInference, + createOperation(input: HostLocalInferenceOperationInput) { + const operation = hostLocalInference.createOperation(input); + const managedRuntime = operation.managedRuntime; + if (!managedRuntime) { + throw new Error("Hermes Portable inference operation lacks managed runtime authority."); + } + const runtime: HostLocalInferenceRuntime = Object.freeze({ + ...managedRuntime, + startManaged( + managedInput: HostLocalManagedInferenceInput, + writer: HostLocalInferenceReceiptWriter, + ) { + // Images are immutable shared cache state. This transaction owns + // the exact container, model, route, and receipt, but deliberately + // never deletes a digest image that another operation may reuse. + acquireRetainedImage(engine, PORTABLE_OLLAMA_IMAGE, assertCurrent); + acquireRetainedImage(engine, PORTABLE_PROBE_IMAGE, assertCurrent); + return managedRuntime.startManaged(managedInput, writer); + }, + }); + return Object.freeze({ ...operation, managedRuntime: runtime }); + }, + }), + }); +} + +export const hermesPortableOllamaAuthorityInternals = Object.freeze({ + captureCurrentCdiDevices, + captureCurrentGpuDevices, + captureQualifiedGpuDevices, + capturePortableNetworkAuthority, +}); diff --git a/src/lib/onboard/experimental/hermes-portable-ollama-gateway-transaction.ts b/src/lib/onboard/experimental/hermes-portable-ollama-gateway-transaction.ts new file mode 100644 index 00000000000..dfffbf429b2 --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-ollama-gateway-transaction.ts @@ -0,0 +1,879 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; + +import { ensureConfigDir, rejectSymlinksOnPath } from "../../state/config-io"; +import { parseGatewayProviderMetadata } from "../gateway-provider-metadata"; +import type { HostLocalInferenceReceiptWriter } from "../runtime-provider/host-local-inference"; +import { + parseHostLocalInferenceReceipt, + serializeHostLocalInferenceReceipt, +} from "../runtime-provider/host-local-inference"; +import type { HostLocalInferenceStartupSelection } from "../runtime-provider/host-local-inference-routing"; +import { createHermesPortablePodmanOperationEngines } from "./hermes-portable-podman-authority"; + +const NETWORK_ID = /^[a-f0-9]{64}$/u; +const GATEWAY_PROVIDER_ID = /^[A-Za-z0-9._:-]{1,128}$/u; +const SAFE_CREDENTIAL_ENV = /^[A-Z_][A-Z0-9_]*$/u; +const MAX_RECEIPT_BYTES = 32 * 1024; +const PRIVATE_FILE_MODE = 0o600; +const MAX_GATEWAY_PROVIDER_OUTPUT_BYTES = 16 * 1024; +const GATEWAY_PROVIDER_PROBE_TIMEOUT_MS = 15_000; +const GATEWAY_PROVIDER_MUTATION_TIMEOUT_MS = 30_000; +const GATEWAY_PROVIDER_JOURNAL_FILE = "portable-gateway-provider.json"; +const TEMPORARY_FILE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g; + +function stripAnsi(value: string): string { + return value.replace(ANSI_ESCAPE, ""); +} + +type GatewayCommandResult = { + status: number | null; + stdout?: string | Buffer | null; + stderr?: string | Buffer | null; +}; + +export type HermesPortableOllamaGatewayRunner = ( + args: string[], + options: { + ignoreError: true; + suppressOutput: true; + stdio: ["ignore", "pipe", "pipe"]; + env?: NodeJS.ProcessEnv; + timeout: number; + }, +) => GatewayCommandResult; + +function requireRecord(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} is malformed.`); + } + return value as Record; +} + +function createPrivateStateFile(directory: string, fileName: string, label: string) { + ensureConfigDir(directory); + rejectSymlinksOnPath(directory); + const directoryMetadata = fs.lstatSync(directory, { bigint: true }); + const uid = BigInt(process.getuid?.() ?? directoryMetadata.uid); + if ( + !directoryMetadata.isDirectory() || + directoryMetadata.isSymbolicLink() || + directoryMetadata.uid !== uid || + (directoryMetadata.mode & 0o077n) !== 0n + ) { + throw new Error(`Hermes Portable inference ${label} directory lacks private authority.`); + } + const target = path.join(directory, fileName); + const hasRecoverablePublicationLink = (metadata: fs.BigIntStats): boolean => { + if (metadata.nlink === 1n) return true; + if (metadata.nlink !== 2n) return false; + const prefix = `.${fileName}.`; + const suffix = ".tmp"; + const candidates = fs + .readdirSync(directory) + .filter((entry) => { + if (!entry.startsWith(prefix) || !entry.endsWith(suffix)) return false; + return TEMPORARY_FILE_ID.test(entry.slice(prefix.length, -suffix.length)); + }) + .map((entry) => fs.lstatSync(path.join(directory, entry), { bigint: true })) + .filter( + (candidate) => + candidate.isFile() && + !candidate.isSymbolicLink() && + candidate.dev === metadata.dev && + candidate.ino === metadata.ino && + candidate.mode === metadata.mode && + candidate.nlink === metadata.nlink && + candidate.uid === metadata.uid && + candidate.gid === metadata.gid && + candidate.size === metadata.size && + candidate.mtimeNs === metadata.mtimeNs && + candidate.ctimeNs === metadata.ctimeNs, + ); + return candidates.length === 1; + }; + const readExact = (): string | null => { + if (typeof fs.constants.O_NOFOLLOW !== "number") { + throw new Error(`Hermes Portable inference ${label} reads require O_NOFOLLOW.`); + } + const nonblock = fs.constants.O_NONBLOCK ?? 0; + let descriptor: number; + try { + descriptor = fs.openSync(target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | nonblock); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + if ( + !before.isFile() || + !hasRecoverablePublicationLink(before) || + before.uid !== uid || + (before.mode & 0o077n) !== 0n || + before.size <= 0n || + before.size > BigInt(MAX_RECEIPT_BYTES) + ) { + throw new Error(`Hermes Portable inference ${label} lacks private file authority.`); + } + const bytes = Buffer.alloc(Number(before.size)); + let offset = 0; + while (offset < bytes.length) { + const count = fs.readSync(descriptor, bytes, offset, bytes.length - offset, offset); + if (count === 0) break; + offset += count; + } + const after = fs.fstatSync(descriptor, { bigint: true }); + if ( + offset !== bytes.length || + before.dev !== after.dev || + before.ino !== after.ino || + before.mode !== after.mode || + before.nlink !== after.nlink || + !hasRecoverablePublicationLink(after) || + before.uid !== after.uid || + before.gid !== after.gid || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs + ) { + throw new Error(`Hermes Portable inference ${label} changed during its stable read.`); + } + return bytes.toString("utf8"); + } finally { + fs.closeSync(descriptor); + } + }; + const publishExclusive = (serialized: string): boolean => { + if (typeof fs.constants.O_NOFOLLOW !== "number") { + throw new Error(`Hermes Portable inference ${label} writes require O_NOFOLLOW.`); + } + const temporary = path.join(directory, `.${fileName}.${randomUUID()}.tmp`); + let descriptor: number | null = null; + try { + descriptor = fs.openSync( + temporary, + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_WRONLY | + fs.constants.O_NOFOLLOW, + PRIVATE_FILE_MODE, + ); + fs.writeFileSync(descriptor, serialized, { encoding: "utf8" }); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = null; + try { + fs.linkSync(temporary, target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + throw error; + } + fs.unlinkSync(temporary); + const directoryDescriptor = fs.openSync(directory, "r"); + try { + fs.fsyncSync(directoryDescriptor); + } finally { + fs.closeSync(directoryDescriptor); + } + return true; + } finally { + if (descriptor !== null) fs.closeSync(descriptor); + fs.rmSync(temporary, { force: true }); + } + }; + const replaceExact = (expected: string, replacement: string): void => { + if (readExact() !== expected) { + throw new Error(`Hermes Portable inference ${label} changed before its durable update.`); + } + const temporary = path.join(directory, `.${fileName}.${randomUUID()}.tmp`); + let descriptor: number | null = null; + try { + descriptor = fs.openSync( + temporary, + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_WRONLY | + fs.constants.O_NOFOLLOW, + PRIVATE_FILE_MODE, + ); + fs.writeFileSync(descriptor, replacement, { encoding: "utf8" }); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = null; + if (readExact() !== expected) { + throw new Error(`Hermes Portable inference ${label} changed concurrently.`); + } + fs.renameSync(temporary, target); + const directoryDescriptor = fs.openSync(directory, "r"); + try { + fs.fsyncSync(directoryDescriptor); + } finally { + fs.closeSync(directoryDescriptor); + } + if (readExact() !== replacement) { + throw new Error(`Hermes Portable inference ${label} durable update is indeterminate.`); + } + } finally { + if (descriptor !== null) fs.closeSync(descriptor); + fs.rmSync(temporary, { force: true }); + } + }; + return Object.freeze({ publishExclusive, readExact, replaceExact }); +} + +type GatewayProviderAuthority = Readonly<{ + id: string; + resourceVersion: number; +}>; + +type GatewayProviderJournalPhase = + | "prepared" + | "creating" + | "created" + | "rolling-back" + | "rolled-back" + | "committed"; + +type GatewayProviderJournalIntent = Readonly<{ + transactionId: string; + targetSha256: string; + gatewayName: "nemoclaw"; + sandboxName: string; + provider: "ollama-local"; + model: string; + type: "openai"; + credentialEnv: string; + providerCredentialEnv: string; + baseUrl: "http://host.openshell.internal:11434/v1"; +}>; + +type GatewayProviderJournal = Readonly<{ + schemaVersion: 1; + kind: "hermes-portable-ollama-gateway-provider"; + phase: GatewayProviderJournalPhase; + intent: GatewayProviderJournalIntent; + providerAuthority: GatewayProviderAuthority | null; +}>; + +function createGatewayProviderJournalStore( + directory: string, + intent: GatewayProviderJournalIntent, +) { + const stateFile = createPrivateStateFile( + directory, + GATEWAY_PROVIDER_JOURNAL_FILE, + "gateway provider journal", + ); + const serialize = (journal: GatewayProviderJournal): string => `${JSON.stringify(journal)}\n`; + const parse = (serialized: string): GatewayProviderJournal => { + let parsed: unknown; + try { + parsed = JSON.parse(serialized); + } catch { + throw new Error("Hermes Portable inference gateway provider journal is malformed."); + } + const record = requireRecord(parsed, "Hermes Portable inference gateway provider journal"); + const phase = record.phase; + const authority = record.providerAuthority; + if ( + record.schemaVersion !== 1 || + record.kind !== "hermes-portable-ollama-gateway-provider" || + typeof phase !== "string" || + !["prepared", "creating", "created", "rolling-back", "rolled-back", "committed"].includes( + phase, + ) || + !isDeepStrictEqual(record.intent, intent) || + Object.keys(record).sort().join("\n") !== + ["schemaVersion", "kind", "phase", "intent", "providerAuthority"].sort().join("\n") + ) { + throw new Error("Hermes Portable inference gateway provider journal authority changed."); + } + let providerAuthority: GatewayProviderAuthority | null = null; + if (authority !== null) { + const authorityRecord = requireRecord( + authority, + "Hermes Portable inference gateway provider journal authority", + ); + if ( + Object.keys(authorityRecord).sort().join("\n") !== ["id", "resourceVersion"].join("\n") || + typeof authorityRecord.id !== "string" || + !GATEWAY_PROVIDER_ID.test(authorityRecord.id) || + !Number.isSafeInteger(authorityRecord.resourceVersion) || + Number(authorityRecord.resourceVersion) < 1 + ) { + throw new Error( + "Hermes Portable inference gateway provider journal identity is malformed.", + ); + } + providerAuthority = Object.freeze({ + id: authorityRecord.id, + resourceVersion: Number(authorityRecord.resourceVersion), + }); + } + if ( + (["created", "rolling-back", "committed"].includes(phase) && !providerAuthority) || + (["prepared", "creating"].includes(phase) && providerAuthority) + ) { + throw new Error("Hermes Portable inference gateway provider journal phase is inconsistent."); + } + const journal = Object.freeze({ + schemaVersion: 1 as const, + kind: "hermes-portable-ollama-gateway-provider" as const, + phase: phase as GatewayProviderJournalPhase, + intent, + providerAuthority, + }); + if (serialize(journal) !== serialized) { + throw new Error("Hermes Portable inference gateway provider journal is not canonical."); + } + return journal; + }; + const load = (): GatewayProviderJournal | null => { + const serialized = stateFile.readExact(); + return serialized === null ? null : parse(serialized); + }; + const transition = ( + current: GatewayProviderJournal, + phase: GatewayProviderJournalPhase, + providerAuthority: GatewayProviderAuthority | null, + ): GatewayProviderJournal => { + const next = Object.freeze({ ...current, phase, providerAuthority }); + stateFile.replaceExact(serialize(current), serialize(next)); + return next; + }; + return Object.freeze({ + load, + prepare(current: GatewayProviderJournal | null): GatewayProviderJournal { + const prepared = Object.freeze({ + schemaVersion: 1 as const, + kind: "hermes-portable-ollama-gateway-provider" as const, + phase: "prepared" as const, + intent, + providerAuthority: null, + }); + if (current === null) { + if (!stateFile.publishExclusive(serialize(prepared))) { + throw new Error( + "Hermes Portable inference gateway provider journal appeared concurrently.", + ); + } + return prepared; + } + if (current.phase !== "rolled-back") { + throw new Error("Hermes Portable inference gateway provider journal is already active."); + } + return transition(current, "prepared", null); + }, + transition, + markCommitted(): void { + const current = load(); + if (current?.phase === "committed") return; + if (current?.phase !== "created" || !current.providerAuthority) { + throw new Error( + "Hermes Portable inference gateway provider publication journal is incomplete.", + ); + } + transition(current, "committed", current.providerAuthority); + }, + }); +} + +function createReceiptWriter( + directory: string, + transactionId: string, + targetSha256: string, + markGatewayProviderCommitted: () => void, +): HostLocalInferenceReceiptWriter & { + readonly readPublished: () => ReturnType | null; +} { + const stateFile = createPrivateStateFile(directory, "portable-inference.json", "receipt"); + return Object.freeze({ + transactionId, + targetSha256, + readPublished() { + const serialized = stateFile.readExact(); + return serialized === null ? null : parseHostLocalInferenceReceipt(serialized); + }, + writeExact(serializedReceipt: string) { + const canonical = serializeHostLocalInferenceReceipt( + parseHostLocalInferenceReceipt(serializedReceipt), + ); + if ( + canonical !== serializedReceipt || + Buffer.byteLength(canonical, "utf8") > MAX_RECEIPT_BYTES + ) { + throw new Error("Hermes Portable inference receipt exceeds its canonical boundary."); + } + const existing = stateFile.readExact(); + if (existing === canonical) { + markGatewayProviderCommitted(); + return existing; + } + if (existing !== null) { + throw new Error("Hermes Portable inference receipt target already has other authority."); + } + if (stateFile.publishExclusive(canonical)) { + markGatewayProviderCommitted(); + return canonical; + } + const raced = stateFile.readExact(); + if (raced === canonical) { + markGatewayProviderCommitted(); + return raced; + } + throw new Error("Hermes Portable inference receipt target changed concurrently."); + }, + }); +} + +function exactGatewayMutation( + runGatewayOpenshell: HermesPortableOllamaGatewayRunner, + expectedModel: string, + expectedSandboxName: string, + expectedCredentialEnv: string, + expectedProviderCredentialEnv: string, + journalStore: ReturnType, + receiptPublished: boolean, +): Readonly<{ + prepareGatewayMutation: HostLocalInferenceStartupSelection["prepareGatewayMutation"]; + recoverUnpublishedRoute: boolean; +}> { + type ProviderObservation = + | { readonly kind: "absent" } + | { + readonly kind: "present"; + readonly id: string; + readonly resourceVersion: number; + }; + const commandText = (result: GatewayCommandResult): string => { + const stdout = Buffer.isBuffer(result.stdout) + ? result.stdout.toString("utf8") + : (result.stdout ?? ""); + const stderr = Buffer.isBuffer(result.stderr) + ? result.stderr.toString("utf8") + : (result.stderr ?? ""); + return Buffer.from(`${stdout}\n${stderr}`, "utf8") + .subarray(0, MAX_GATEWAY_PROVIDER_OUTPUT_BYTES) + .toString("utf8"); + }; + const reportsAbsent = (output: string, provider: string): boolean => { + const escaped = provider.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + return ( + new RegExp(`provider\\s+['\"\`]${escaped}['\"\`]\\s+(?:was\\s+)?not found`, "iu").test( + output, + ) || + (/code:\s*['"]some requested entity was not found['"]/iu.test(output) && + /message:\s*['"]provider not found['"]/iu.test(output)) + ); + }; + const readExact = (provider: string): ProviderObservation => { + const result = runGatewayOpenshell(["provider", "get", provider], { + ignoreError: true, + suppressOutput: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: GATEWAY_PROVIDER_PROBE_TIMEOUT_MS, + }); + const output = commandText(result); + if (result.status !== 0) { + if (reportsAbsent(output, provider)) return { kind: "absent" }; + throw new Error("Hermes Portable inference could not prove gateway provider absence."); + } + const metadata = parseGatewayProviderMetadata(output); + const cleanOutput = stripAnsi(output); + const ids = Array.from(cleanOutput.matchAll(/^\s*Id:\s*([A-Za-z0-9._:-]{1,128})\s*$/gimu)); + const versions = Array.from(cleanOutput.matchAll(/^\s*Resource version:\s*([0-9]+)\s*$/gimu)); + const id = ids.length === 1 ? ids[0]![1]! : ""; + const resourceVersion = versions.length === 1 ? Number(versions[0]![1]) : Number.NaN; + if ( + !metadata || + metadata.name !== provider || + metadata.type !== "openai" || + metadata.credentialKeys.length !== 1 || + metadata.credentialKeys[0] !== expectedProviderCredentialEnv || + metadata.configKeys.length !== 1 || + metadata.configKeys[0] !== "OPENAI_BASE_URL" || + !GATEWAY_PROVIDER_ID.test(id) || + !Number.isSafeInteger(resourceVersion) || + resourceVersion < 1 + ) { + throw new Error("Hermes Portable inference found ambiguous gateway provider authority."); + } + return { kind: "present", id, resourceVersion }; + }; + const matchesAuthority = ( + observation: ProviderObservation, + authority: GatewayProviderAuthority, + ): observation is Extract => + observation.kind === "present" && + observation.id === authority.id && + observation.resourceVersion === authority.resourceVersion; + const createdAuthority = (observation: ProviderObservation): GatewayProviderAuthority | null => + observation.kind === "present" && observation.resourceVersion === 1 + ? Object.freeze({ id: observation.id, resourceVersion: observation.resourceVersion }) + : null; + const deleteRecordedProvider = ( + provider: string, + journal: GatewayProviderJournal, + ): GatewayProviderJournal => { + const authority = journal.providerAuthority; + if (journal.phase !== "rolling-back" || !authority) { + throw new Error("Hermes Portable inference gateway provider rollback journal is incomplete."); + } + const current = readExact(provider); + if (current.kind === "absent") { + return journalStore.transition(journal, "rolled-back", authority); + } + if (!matchesAuthority(current, authority)) { + throw new Error("Hermes Portable inference refused to mutate changed gateway authority."); + } + const removed = runGatewayOpenshell(["provider", "delete", provider], { + ignoreError: true, + suppressOutput: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: GATEWAY_PROVIDER_MUTATION_TIMEOUT_MS, + }); + const after = readExact(provider); + if (after.kind === "absent") { + return journalStore.transition(journal, "rolled-back", authority); + } + if (!matchesAuthority(after, authority)) { + throw new Error( + "Hermes Portable inference gateway authority changed during recorded rollback.", + ); + } + if (removed.status !== 0) { + throw new Error("Hermes Portable inference could not resume its gateway provider rollback."); + } + throw new Error("Hermes Portable inference gateway provider remained after recorded rollback."); + }; + const journalAtEntry = journalStore.load(); + const providerAtEntry = readExact("ollama-local"); + const recoverUnpublishedRoute = + !receiptPublished && + journalAtEntry?.phase === "created" && + journalAtEntry.providerAuthority !== null && + matchesAuthority(providerAtEntry, journalAtEntry.providerAuthority); + if (journalAtEntry === null || journalAtEntry.phase === "rolled-back") { + if (providerAtEntry.kind !== "absent") { + throw new Error("Hermes Portable inference found an unowned existing gateway provider."); + } + } else if (journalAtEntry.phase === "prepared") { + if (providerAtEntry.kind !== "absent") { + throw new Error( + "Hermes Portable inference gateway provider appeared before recorded create.", + ); + } + } else if (journalAtEntry.phase === "creating") { + if (providerAtEntry.kind === "present" && createdAuthority(providerAtEntry) === null) { + throw new Error("Hermes Portable inference recorded provider creation is ambiguous."); + } + } else if (journalAtEntry.phase === "rolling-back") { + if ( + providerAtEntry.kind === "present" && + (!journalAtEntry.providerAuthority || + !matchesAuthority(providerAtEntry, journalAtEntry.providerAuthority)) + ) { + throw new Error("Hermes Portable inference recorded gateway provider authority changed."); + } + } else if ( + !journalAtEntry.providerAuthority || + !matchesAuthority(providerAtEntry, journalAtEntry.providerAuthority) + ) { + throw new Error("Hermes Portable inference recorded gateway provider authority changed."); + } + const prepareGatewayMutation: HostLocalInferenceStartupSelection["prepareGatewayMutation"] = + async (input) => { + if ( + input.gatewayName !== "nemoclaw" || + input.sandboxName !== expectedSandboxName || + input.provider !== "ollama-local" || + input.model !== expectedModel || + input.providerBaseUrl !== "http://host.openshell.internal:11434/v1" + ) { + throw new Error("Hermes Portable inference gateway mutation authority changed."); + } + let journal = journalStore.load(); + let current = readExact(input.provider); + if (journal?.phase === "rolling-back") { + journal = deleteRecordedProvider(input.provider, journal); + current = readExact(input.provider); + } + if (receiptPublished) { + if (journal?.phase !== "created" && journal?.phase !== "committed") { + throw new Error( + "Hermes Portable inference published route lacks gateway ownership state.", + ); + } + } else if (journal?.phase === "committed") { + throw new Error( + "Hermes Portable inference gateway ownership outlived its published receipt.", + ); + } + if (journal === null || journal.phase === "rolled-back") { + if (current.kind !== "absent") { + throw new Error("Hermes Portable inference found an unowned existing gateway provider."); + } + journalStore.prepare(journal); + } else if (journal.phase === "prepared") { + if (current.kind !== "absent") { + throw new Error( + "Hermes Portable inference gateway provider appeared before recorded create.", + ); + } + } else if (journal.phase === "creating") { + if (current.kind === "present") { + const authority = createdAuthority(current); + if (!authority) { + throw new Error("Hermes Portable inference recorded provider creation is ambiguous."); + } + journalStore.transition(journal, "created", authority); + } + } else { + const authority = journal.providerAuthority; + if (!authority || !matchesAuthority(current, authority)) { + throw new Error("Hermes Portable inference recorded gateway provider authority changed."); + } + } + return Object.freeze({ + upsertProvider( + name: string, + type: string, + credentialEnv: string, + baseUrl: string, + env: NodeJS.ProcessEnv = {}, + ) { + if ( + name !== input.provider || + type !== "openai" || + credentialEnv !== expectedCredentialEnv || + baseUrl !== input.providerBaseUrl || + Object.keys(env).length !== 1 || + env[expectedCredentialEnv] !== "ollama" + ) { + throw new Error("Hermes Portable inference provider mutation authority changed."); + } + let active = journalStore.load(); + if (!active) { + throw new Error("Hermes Portable inference gateway provider intent disappeared."); + } + let before = readExact(input.provider); + if (active.phase === "created" || active.phase === "committed") { + if (!active.providerAuthority || !matchesAuthority(before, active.providerAuthority)) { + throw new Error( + "Hermes Portable inference recorded gateway provider authority changed.", + ); + } + return { ok: true }; + } + if (active.phase === "prepared") { + if (before.kind !== "absent") { + throw new Error("Hermes Portable inference provider name is no longer unclaimed."); + } + active = journalStore.transition(active, "creating", null); + before = readExact(input.provider); + } + if (active.phase !== "creating") { + throw new Error("Hermes Portable inference gateway provider intent cannot create."); + } + if (before.kind === "present") { + const authority = createdAuthority(before); + if (!authority) { + throw new Error("Hermes Portable inference recorded provider creation is ambiguous."); + } + journalStore.transition(active, "created", authority); + return { ok: true }; + } + const result = runGatewayOpenshell( + [ + "provider", + "create", + "--name", + input.provider, + "--type", + "openai", + "--credential", + expectedProviderCredentialEnv, + "--config", + `OPENAI_BASE_URL=${input.providerBaseUrl}`, + ], + { + ignoreError: true, + suppressOutput: true, + stdio: ["ignore", "pipe", "pipe"], + env: { [expectedProviderCredentialEnv]: "ollama" }, + timeout: GATEWAY_PROVIDER_MUTATION_TIMEOUT_MS, + }, + ); + const after = readExact(input.provider); + const authority = createdAuthority(after); + if (!authority) { + if (after.kind === "absent" && result.status !== 0) { + throw new Error("Hermes Portable inference could not create its gateway provider."); + } + throw new Error( + "Hermes Portable inference gateway provider creation is indeterminate.", + ); + } + journalStore.transition(active, "created", authority); + return { ok: true }; + }, + commit() { + const current = readExact(input.provider); + const active = journalStore.load(); + if ( + (active?.phase !== "created" && active?.phase !== "committed") || + !active.providerAuthority || + !matchesAuthority(current, active.providerAuthority) + ) { + throw new Error("Hermes Portable inference gateway provider authority changed."); + } + }, + rollback() { + let active = journalStore.load(); + if (!active) { + throw new Error( + "Hermes Portable inference gateway provider rollback intent disappeared.", + ); + } + if (active.phase === "committed") { + throw new Error("Hermes Portable inference refused rollback of a published provider."); + } + const observed = readExact(input.provider); + if (active.phase === "rolled-back") { + if (observed.kind !== "absent") { + throw new Error("Hermes Portable inference rolled-back gateway provider reappeared."); + } + return; + } + if (active.phase === "prepared" || active.phase === "creating") { + if (observed.kind === "absent") { + journalStore.transition(active, "rolled-back", null); + return; + } + const authority = active.phase === "creating" ? createdAuthority(observed) : null; + if (!authority) { + throw new Error( + "Hermes Portable inference refused rollback of unowned gateway authority.", + ); + } + active = journalStore.transition(active, "created", authority); + } + if (active.phase === "created") { + if ( + !active.providerAuthority || + !matchesAuthority(observed, active.providerAuthority) + ) { + throw new Error( + "Hermes Portable inference refused to delete changed gateway authority.", + ); + } + active = journalStore.transition(active, "rolling-back", active.providerAuthority); + } + deleteRecordedProvider(input.provider, active); + }, + }); + }; + return Object.freeze({ prepareGatewayMutation, recoverUnpublishedRoute }); +} + +export function createHermesPortableOllamaGatewayTransaction(options: { + readonly directory: string; + readonly transactionId: string; + readonly targetSha256: string; + readonly sandboxName: string; + readonly model: string; + readonly credentialEnv: string; + readonly runGatewayOpenshell: HermesPortableOllamaGatewayRunner; +}) { + const providerCredentialEnv = `${options.credentialEnv}_${options.transactionId.toUpperCase()}`; + if (providerCredentialEnv.length > 128 || !SAFE_CREDENTIAL_ENV.test(providerCredentialEnv)) { + throw new Error("Hermes Portable Ollama transaction credential authority is invalid."); + } + const gatewayProviderJournal = createGatewayProviderJournalStore( + options.directory, + Object.freeze({ + transactionId: options.transactionId, + targetSha256: options.targetSha256, + gatewayName: "nemoclaw", + sandboxName: options.sandboxName, + provider: "ollama-local", + model: options.model, + type: "openai", + credentialEnv: options.credentialEnv, + providerCredentialEnv, + baseUrl: "http://host.openshell.internal:11434/v1", + }), + ); + const receiptWriter = createReceiptWriter( + options.directory, + options.transactionId, + options.targetSha256, + gatewayProviderJournal.markCommitted, + ); + const publishedReceipt = receiptWriter.readPublished(); + const gatewayJournalState = gatewayProviderJournal.load(); + if ( + (publishedReceipt !== null && + gatewayJournalState?.phase !== "created" && + gatewayJournalState?.phase !== "committed") || + (publishedReceipt === null && gatewayJournalState?.phase === "committed") + ) { + throw new Error("Hermes Portable Ollama gateway publication authority is inconsistent."); + } + const gatewayMutation = exactGatewayMutation( + options.runGatewayOpenshell, + options.model, + options.sandboxName, + options.credentialEnv, + providerCredentialEnv, + gatewayProviderJournal, + publishedReceipt !== null, + ); + return Object.freeze({ + receiptWriter, + publishedReceipt, + recoverUnpublishedRoute: gatewayMutation.recoverUnpublishedRoute, + prepareGatewayMutation: gatewayMutation.prepareGatewayMutation, + }); +} + +export function hasHermesPortableOllamaRecoveryContainer( + engine: ReturnType["hostLocalInference"], + containerName: string, + assertCurrent: () => void, +): boolean { + assertCurrent(); + const result = engine.capture( + [ + "ps", + "--all", + "--no-trunc", + "--filter", + `name=^${containerName}$`, + "--format", + "{{.ID}}\t{{.Names}}", + ], + 30_000, + ); + assertCurrent(); + if (result.error || result.status !== 0) { + throw new Error("Hermes Portable inference could not inspect interrupted runtime authority."); + } + const rows = result.stdout + .split(/\r?\n/u) + .map((row) => row.trim()) + .filter(Boolean); + if (rows.length === 0) return false; + const fields = rows.length === 1 ? rows[0]!.split("\t") : []; + if (fields.length !== 2 || !NETWORK_ID.test(fields[0]!) || fields[1] !== containerName) { + throw new Error("Hermes Portable inference found ambiguous interrupted runtime authority."); + } + return true; +} diff --git a/src/lib/onboard/experimental/hermes-portable-ollama-inference.test.ts b/src/lib/onboard/experimental/hermes-portable-ollama-inference.test.ts new file mode 100644 index 00000000000..ed07364b29c --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-ollama-inference.test.ts @@ -0,0 +1,854 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { + PodmanExecutableAuthorityDeps, + PodmanExecutableStat, + PodmanSocketAuthority, +} from "../../adapters/podman"; +import { + OPENSHELL_OPERATION_TIMEOUT_MS, + OPENSHELL_PROBE_TIMEOUT_MS, +} from "../../adapters/openshell/timeouts"; +import type { CheckpointPortableRuntimeAuthority } from "../../state/onboard-checkpoint-types"; +import { createSession } from "../../state/onboard-session"; +import { makeDeps, makeHostState, unexpected } from "../__test-helpers__/setup-nim-flow"; +import { handleProviderInferenceState } from "../machine/handlers/provider-inference"; +import { baseOptions, createDeps } from "../machine/handlers/provider-inference.test-support"; +import { + type HostLocalInferenceGatewayMutation, + prepareHostLocalInferenceStartup, +} from "../runtime-provider/host-local-inference-routing"; +import { createPortableOnboardEnvironmentScope } from "../session-bootstrap"; +import type { SetupInference } from "../setup-inference"; +import { createSetupNim } from "../setup-nim-flow"; +import { createPodmanHostLocalInferenceTestHarness } from "../../../../test/helpers/podman-host-local-inference-test-harness"; +import { + createPortableGatewayProviderHarness, + createPortablePodmanCapture, + type PortablePodmanAuthorityState, +} from "../../../../test/helpers/hermes-portable-ollama-test-harness"; +import { hermesPortableOllamaAuthorityInternals } from "./hermes-portable-ollama-authority"; +import { createHermesPortableOllamaInferenceResolver } from "./hermes-portable-ollama-inference"; + +const PODMAN_PATH = "/usr/bin/podman"; +const PODMAN_BYTES = Buffer.from("portable-podman-5.7.0", "utf8"); +const NETWORK_ID = "6".repeat(64); +const GPU_DEVICE = "nvidia.com/gpu=GPU-12345678-1234-1234-1234-123456789abc"; +const PORTABLE_PROBE_IMAGE = + "docker.io/curlimages/curl@sha256:fcff5cf7a4b895da7bd2933c914938db2b05d2113fa0d6c55b6d29930408f661"; +const temporaryDirectories: string[] = []; +const environmentRestorers: Array<() => void> = []; + +const freshPortableInput = { + application: "hermes" as const, + sandboxName: "portable-hermes", + provider: "ollama-local", + model: "qwen3-vl:4b", + acceleration: "nvidia-gpu" as const, + requireToolCalling: true, + allowPublishedResume: false, + recover: false, +}; + +function runtimeAuthority(homeDir: string): CheckpointPortableRuntimeAuthority { + const uid = process.getuid!(); + return { + schemaVersion: 1, + kind: "podman", + ownership: "current-user", + uid, + homeDir, + configHome: path.join(homeDir, ".config"), + runtimeDir: `/run/user/${String(uid)}`, + socketPath: `/run/user/${String(uid)}/podman/podman.sock`, + }; +} + +function socketAuthority(runtime: CheckpointPortableRuntimeAuthority): PodmanSocketAuthority { + return { + device: "1", + inode: "2", + mode: String(0o140600), + ownerUid: String(runtime.uid), + socketPath: runtime.socketPath, + directoryChain: [], + }; +} + +function executableAuthorityDeps(): PodmanExecutableAuthorityDeps { + const executable = (): PodmanExecutableStat => ({ + dev: 1n, + ino: 10n, + mode: 0o100755n, + uid: 0n, + size: BigInt(PODMAN_BYTES.byteLength), + mtimeNs: 10n, + ctimeNs: 11n, + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + }); + return { + uid: process.getuid!(), + lstat: (filePath) => + filePath === PODMAN_PATH + ? executable() + : { + ...executable(), + ino: filePath === "/usr/bin" ? 20n : 30n, + mode: 0o40755n, + size: 0n, + isDirectory: () => true, + isFile: () => false, + }, + readFile: () => PODMAN_BYTES, + realpath: (filePath) => filePath, + }; +} + +function createRuntimeFixture() { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-inference-")); + temporaryDirectories.push(homeDir); + const runtime = runtimeAuthority(homeDir); + vi.stubEnv("HOME", homeDir); + vi.stubEnv("PATH", "/usr/bin"); + const environmentScope = createPortableOnboardEnvironmentScope(process.env, null); + environmentRestorers.push(() => environmentScope.restore()); + environmentScope.installRuntime({ + containersConf: path.join(runtime.configHome, "nemoclaw", "portable", "containers.conf"), + socketPath: runtime.socketPath, + }); + const events: string[] = []; + const authorityState: PortablePodmanAuthorityState = { + networkId: NETWORK_ID, + images: new Set(), + failPull: null as string | null, + }; + const gatewayProvider = createPortableGatewayProviderHarness(events); + const runGatewayOpenshell = vi.fn(gatewayProvider.run); + const assertSocketAuthority = vi.fn(); + const harness = createPodmanHostLocalInferenceTestHarness({ + probeImageRef: PORTABLE_PROBE_IMAGE, + }); + harness.state.networkId = NETWORK_ID; + harness.state.networkName = "openshell-docker"; + harness.state.networkGatewayIp = "169.254.1.1"; + harness.state.ollamaPsModels = [ + { + name: "qwen3-vl:4b", + model: "qwen3-vl:4b", + size: 8 * 1024 ** 3, + size_vram: 8 * 1024 ** 3, + digest: "8".repeat(64), + }, + ]; + let cdiDevices = ["nvidia.com/gpu=all", GPU_DEVICE]; + const resolverOptions = { + runtimeContext: { authority: runtime, environmentScope }, + credentialEnv: "NEMOCLAW_OLLAMA_PROXY_TOKEN", + getReservationSessionId: () => "portable-session", + runGatewayOpenshell, + stateDir: path.join(homeDir, "state"), + captureSocketAuthority: () => socketAuthority(runtime), + captureGpuDevices: () => [GPU_DEVICE], + captureCdiDevices: () => cdiDevices, + podmanAuthorityDeps: { + capture: createPortablePodmanCapture(events, authorityState, harness.engine.capture), + executableAuthorityDeps: executableAuthorityDeps(), + assertSocketAuthority, + resolveExecutablePath: () => PODMAN_PATH, + platform: "linux", + architecture: "x64", + uid: runtime.uid, + }, + } as const; + return { + assertSocketAuthority, + authorityState, + events, + gatewayProvider, + harness, + homeDir, + resolverOptions, + runtime, + resolve: (input = freshPortableInput) => + createHermesPortableOllamaInferenceResolver(resolverOptions)(input), + setCdiDevices: (devices: string[]) => { + cdiDevices = devices; + }, + }; +} + +function prepareManagedRoute( + fixture: ReturnType, + selection = fixture.resolve()!, +) { + const bundle = selection.resolveRuntimeProvider("portable-hermes")!; + expect(bundle.hostLocalInference.supported).toBe(true); + const hostLocalInference = bundle.hostLocalInference as Extract< + typeof bundle.hostLocalInference, + { supported: true } + >; + const operation = hostLocalInference.createOperation({ + env: {}, + acceleration: "nvidia-gpu", + }); + return prepareHostLocalInferenceStartup(operation, selection.request); +} + +const gatewayMutationInput = { + gatewayName: "nemoclaw", + sandboxName: "portable-hermes", + provider: "ollama-local", + model: "qwen3-vl:4b", + providerBaseUrl: "http://host.openshell.internal:11434/v1", +} as const; + +function createExactGatewayProvider( + mutation: HostLocalInferenceGatewayMutation, + baseUrl: string = gatewayMutationInput.providerBaseUrl, +) { + return mutation.upsertProvider!( + gatewayMutationInput.provider, + "openai", + "NEMOCLAW_OLLAMA_PROXY_TOKEN", + baseUrl, + { NEMOCLAW_OLLAMA_PROXY_TOKEN: "ollama" }, + ); +} + +function gatewayJournalPath(fixture: ReturnType): string { + const root = path.join(fixture.homeDir, "state", "portable-inference"); + const directories = fs.readdirSync(root); + expect(directories).toHaveLength(1); + return path.join(root, directories[0]!, "portable-gateway-provider.json"); +} + +function gatewayJournal(fixture: ReturnType) { + return JSON.parse(fs.readFileSync(gatewayJournalPath(fixture), "utf8")) as { + phase: string; + intent: { providerCredentialEnv: string }; + providerAuthority: { id: string; resourceVersion: number } | null; + }; +} + +function inferenceReceiptPath(fixture: ReturnType): string { + return path.join(path.dirname(gatewayJournalPath(fixture)), "portable-inference.json"); +} + +afterEach(() => { + for (const restore of environmentRestorers.splice(0).reverse()) restore(); + vi.unstubAllEnvs(); + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); + +describe("Hermes Portable Ollama inference activation", () => { + it("rejects ambiguous Portable registry authority before selection (#9596)", () => { + const capture = createPortablePodmanCapture([], { + networkId: NETWORK_ID, + registryCopies: 2, + }); + const engine = { + capture: (args: readonly string[], timeoutMs = 30_000) => + capture( + PODMAN_PATH, + ["--url", "unix:///run/user/1000/podman/podman.sock", ...args], + timeoutMs, + ), + }; + + expect(() => + hermesPortableOllamaAuthorityInternals.capturePortableNetworkAuthority(engine as never), + ).toThrow("registry authority is missing or ambiguous"); + }); + + it("canonicalizes Portable authority labels without locale-dependent ordering (#9596)", () => { + const state: PortablePodmanAuthorityState = { + networkId: NETWORK_ID, + networkLabels: { z: "last", a: "first" }, + }; + const capture = createPortablePodmanCapture([], state); + const engine = { + capture: (args: readonly string[], timeoutMs = 30_000) => + capture( + PODMAN_PATH, + ["--url", "unix:///run/user/1000/podman/podman.sock", ...args], + timeoutMs, + ), + }; + const localeCompare = vi.spyOn(String.prototype, "localeCompare").mockReturnValue(-1); + try { + const first = hermesPortableOllamaAuthorityInternals.capturePortableNetworkAuthority( + engine as never, + ); + localeCompare.mockReturnValue(1); + const second = hermesPortableOllamaAuthorityInternals.capturePortableNetworkAuthority( + engine as never, + ); + + expect(second.authoritySha256).toBe(first.authoritySha256); + expect(localeCompare).not.toHaveBeenCalled(); + } finally { + localeCompare.mockRestore(); + } + }); + + it("rejects a malformed gateway provider create command in its test harness (#9596)", () => { + const harness = createPortableGatewayProviderHarness([]); + + expect(() => + harness.run(["provider", "create", "--name", "ollama-local"], { + ignoreError: true, + suppressOutput: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: OPENSHELL_OPERATION_TIMEOUT_MS, + }), + ).toThrow("without a credential value"); + }); + + it("rejects an unknown Podman global command prefix in its test harness (#9596)", () => { + const capture = createPortablePodmanCapture([], { networkId: NETWORK_ID }); + + expect(() => capture(PODMAN_PATH, ["--connection", "ambient", "version"], 30_000)).toThrow( + "Unexpected Podman global arguments", + ); + }); + + it("fails closed when a fresh Portable selection has no runtime receipt (#9596)", () => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + const resolverOptions = { + runtimeContext: null, + credentialEnv: "NEMOCLAW_OLLAMA_PROXY_TOKEN", + getReservationSessionId: () => null, + runGatewayOpenshell: () => ({ status: 1, stdout: "", stderr: "" }), + } as const; + const resolver = createHermesPortableOllamaInferenceResolver(resolverOptions); + + expect(() => resolver(freshPortableInput)).toThrow("no current-user Podman runtime authority"); + expect(resolver({ ...freshPortableInput, application: "openclaw" })).toBeNull(); + expect( + resolver({ ...freshPortableInput, application: "langchain-deepagents-code" }), + ).toBeNull(); + expect(resolver({ ...freshPortableInput, provider: "compatible-endpoint" })).toBeNull(); + }); + + it("creates managed Ollama through Podman before recording provider selection (#9596)", async () => { + const fixture = createRuntimeFixture(); + const session = createSession(); + const resolver = createHermesPortableOllamaInferenceResolver({ + ...fixture.resolverOptions, + getReservationSessionId: () => session.sessionId, + }); + const setupNim = createSetupNim( + makeDeps({ + isNonInteractive: () => true, + getNonInteractiveProvider: () => process.env.NEMOCLAW_PROVIDER ?? null, + getNonInteractiveModel: () => process.env.NEMOCLAW_MODEL ?? null, + localModelProfileIntegration: { + resolvePlan: () => null, + onboard: async () => unexpected("local model profile onboarding"), + }, + detectInferenceProviderHostState: (input) => { + fixture.events.push(`host-probe:${String(input.probeOllama)}`); + return makeHostState(); + }, + handleRunningOllamaSelection: async () => unexpected("legacy host Ollama selection"), + handleInstallOllamaSelection: async () => unexpected("host Ollama installation"), + }), + ); + const setupInference = vi.fn(async (...args) => { + fixture.events.push("setup-inference"); + const inferenceOptions = args[7]; + expect(inferenceOptions?.reservationSessionId).toBe(session.sessionId); + const selection = inferenceOptions!.hostLocalInference!; + expect(selection.request).toMatchObject({ + application: "hermes", + service: "ollama", + managed: { + model: "qwen3-vl:4b", + networkName: "openshell-docker", + networkId: NETWORK_ID, + networkGatewayIp: "169.254.1.1", + networkListenerIp: "169.254.1.2", + gpuDevices: [GPU_DEVICE], + }, + }); + const route = prepareManagedRoute(fixture, selection); + route.prepared.validateBeforeCommit(); + const gatewayMutation = await selection.prepareGatewayMutation(gatewayMutationInput); + createExactGatewayProvider(gatewayMutation); + await gatewayMutation.commit(); + route.prepared.commit(); + fixture.events.push("provider-operation"); + return { ok: true as const }; + }); + const recordStepComplete = vi.fn(async (stepName: string) => { + fixture.events.push(`complete:${stepName}`); + return session; + }); + const { deps, calls } = createDeps({ + setupNim: setupNim as never, + setupInference: setupInference as never, + resolveHostLocalInferenceStartupSelection: resolver, + recordStepComplete: recordStepComplete as never, + }); + + await handleProviderInferenceState({ + ...baseOptions(deps, session), + agent: { name: "hermes" }, + gpu: { type: "nvidia" }, + gpuPassthrough: true, + sandboxName: "portable-hermes", + }); + + expect(fixture.events.some((event) => event.startsWith("host-probe:"))).toBe(false); + expect(fixture.events.indexOf("provider-operation")).toBeLessThan( + fixture.events.indexOf("complete:provider_selection"), + ); + expect(fixture.events.indexOf("complete:provider_selection")).toBeLessThan( + fixture.events.indexOf("complete:inference"), + ); + expect(calls.prepareLocalProviderForInference).not.toHaveBeenCalled(); + const podmanEvents = fixture.events.filter((event) => event.startsWith("podman:")); + expect(podmanEvents.length).toBeGreaterThan(0); + expect( + podmanEvents.every((event) => + event.endsWith(`executable=${PODMAN_PATH} socket=unix://${fixture.runtime.socketPath}`), + ), + ).toBe(true); + expect(podmanEvents.some((event) => event.includes("executable=docker"))).toBe(false); + expect( + fixture.gatewayProvider + .calls() + .every(({ args, timeout }) => + args[1] === "get" + ? timeout === OPENSHELL_PROBE_TIMEOUT_MS + : timeout === OPENSHELL_OPERATION_TIMEOUT_MS, + ), + ).toBe(true); + }); + + it("binds an explicit Portable model through runtime and gateway authority (#9596)", async () => { + const fixture = createRuntimeFixture(); + const model = "llama3.2:1b"; + fixture.harness.state.ollamaPsModels = [ + { + name: model, + model, + size: 2 * 1024 ** 3, + size_vram: 2 * 1024 ** 3, + digest: "7".repeat(64), + }, + ]; + const selection = fixture.resolve({ ...freshPortableInput, model })!; + expect(selection.request).toMatchObject({ managed: { model } }); + const route = prepareManagedRoute(fixture, selection); + route.prepared.validateBeforeCommit(); + const mutation = await selection.prepareGatewayMutation({ ...gatewayMutationInput, model }); + createExactGatewayProvider(mutation); + await mutation.commit(); + route.prepared.commit(); + expect(route.receipt).toMatchObject({ inference: { model } }); + const published = fixture.resolve({ + ...freshPortableInput, + model, + allowPublishedResume: true, + recover: true, + })!; + await expect( + published.prepareGatewayMutation({ ...gatewayMutationInput, model }), + ).resolves.toBeDefined(); + await expect(published.prepareGatewayMutation(gatewayMutationInput)).rejects.toThrow( + "gateway mutation authority changed", + ); + }); + + it("fails before runtime mutation when current CDI authority drifts (#9596)", () => { + const fixture = createRuntimeFixture(); + const initialPulls = fixture.events.filter((event) => event.includes("podman:pull ")).length; + fixture.setCdiDevices(["nvidia.com/gpu=all"]); + expect(() => fixture.resolve()).toThrow("GPU and CDI authority disagree"); + expect(fixture.events.filter((event) => event.includes("podman:pull "))).toHaveLength( + initialPulls, + ); + }); + + it.each([ + [ + "network identity", + (state: PortablePodmanAuthorityState) => (state.networkId = "8".repeat(64)), + "network or registry authority drifted", + ], + [ + "registry identity", + (state: PortablePodmanAuthorityState) => (state.registryId = "9".repeat(64)), + "network or registry authority drifted", + ], + [ + "registry label", + (state: PortablePodmanAuthorityState) => (state.registryLabel = "0"), + "registry authority changed after host preparation", + ], + [ + "registry network", + (state: PortablePodmanAuthorityState) => (state.registryNetworkId = "5".repeat(64)), + "registry authority changed after host preparation", + ], + ])("fails before runtime mutation when current %s drifts (#9596)", (_label, mutate, error) => { + const fixture = createRuntimeFixture(); + const initialPulls = fixture.events.filter((event) => event.includes("podman:pull ")).length; + const selection = fixture.resolve()!; + mutate(fixture.authorityState); + expect(() => selection.resolveRuntimeProvider("portable-hermes")).toThrow(error); + expect(fixture.events.filter((event) => event.includes("podman:pull "))).toHaveLength( + initialPulls, + ); + }); + + it.each([ + [ + "network backend", + (state: PortablePodmanAuthorityState) => (state.networkBackend = "cni"), + "network backend must be 'netavark'", + ], + [ + "subordinate IDs", + (state: PortablePodmanAuthorityState) => (state.subordinateIdSize = 1), + "subordinate UID range for the API service user", + ], + ])("rejects changed Portable %s after runtime resolution (#9596)", (_label, mutate, error) => { + const fixture = createRuntimeFixture(); + const selection = fixture.resolve()!; + const runtime = selection.resolveRuntimeProvider("portable-hermes")!; + expect(runtime.hostLocalInference.supported).toBe(true); + const hostLocalInference = runtime.hostLocalInference as Extract< + typeof runtime.hostLocalInference, + { supported: true } + >; + mutate(fixture.authorityState); + expect(() => + hostLocalInference.createOperation({ + env: {}, + acceleration: "nvidia-gpu", + }), + ).toThrow(error); + }); + + it("retains immutable image cache while recovering the exact interrupted runtime (#9596)", async () => { + const fixture = createRuntimeFixture(); + const selection = fixture.resolve()!; + const request = selection.request as Extract; + fixture.authorityState.failPull = request.managed.probeImageRef; + expect(() => prepareManagedRoute(fixture, selection)).toThrow( + "could not acquire an immutable runtime image", + ); + expect(fixture.authorityState.images).toContain(request.managed.imageRef); + expect(fixture.authorityState.images).not.toContain(request.managed.probeImageRef); + expect(fixture.harness.container()).toBeNull(); + fixture.authorityState.failPull = null; + prepareManagedRoute(fixture, selection).prepared.validateBeforeCommit(); + const interrupted = fixture.resolve()!; + expect(interrupted.request).toMatchObject({ recover: true }); + const recovered = prepareManagedRoute(fixture, interrupted); + recovered.prepared.validateBeforeCommit(); + const gatewayMutation = await interrupted.prepareGatewayMutation(gatewayMutationInput); + createExactGatewayProvider(gatewayMutation); + await gatewayMutation.commit(); + recovered.prepared.commit(); + const published = fixture.resolve({ + ...freshPortableInput, + allowPublishedResume: true, + recover: true, + })!; + expect(published.request).toMatchObject({ + resumeReceipt: { service: "ollama", runtime: { kind: "container" } }, + }); + expect(recovered.receipt).toMatchObject({ + service: "ollama", + runtime: { modelDigest: `sha256:${"8".repeat(64)}` }, + }); + expect(fixture.harness.events.filter((event) => event.includes("ollama pull"))).toHaveLength(2); + expect(fixture.events.some((event) => event.includes("image rm"))).toBe(false); + }); + + it("refuses ambiguous gateway metadata and rolls back only its exact provider (#9596)", async () => { + const fixture = createRuntimeFixture(); + const selection = fixture.resolve()!; + fixture.gatewayProvider.setMalformed(true); + await expect(selection.prepareGatewayMutation(gatewayMutationInput)).rejects.toThrow( + "ambiguous gateway provider authority", + ); + fixture.gatewayProvider.setMalformed(false); + const mutation = await selection.prepareGatewayMutation(gatewayMutationInput); + expect(createExactGatewayProvider(mutation)).toEqual({ ok: true }); + await mutation.commit(); + await mutation.rollback(); + expect(fixture.gatewayProvider.isPresent()).toBe(false); + }); + + it("resumes the journaled provider-create crash window and publishes exact ownership (#9596)", async () => { + // Crash-window state transitions: + // S0 has no runtime, provider, journal, or receipt. Runtime preparation creates the container. + // S1 durably records the exact create intent before issuing `provider create`. + // S2 has the provider, but crashes before its generated ID is added to the journal. + // A rerun may adopt only the exact version-1 provider reserved by S1, records its ID, then + // publishes the route receipt and commits the journal as durable provider ownership. + const fixture = createRuntimeFixture(); + const selection = fixture.resolve()!; + const route = prepareManagedRoute(fixture, selection); + route.prepared.validateBeforeCommit(); + const mutation = await selection.prepareGatewayMutation(gatewayMutationInput); + const renameSync = fs.renameSync.bind(fs); + const rename = vi + .spyOn(fs, "renameSync") + .mockImplementationOnce(renameSync) + .mockImplementationOnce(() => { + throw new Error("injected death before provider identity persistence"); + }); + expect(() => createExactGatewayProvider(mutation)).toThrow( + "injected death before provider identity persistence", + ); + rename.mockRestore(); + expect(gatewayJournal(fixture)).toMatchObject({ phase: "creating", providerAuthority: null }); + + const restarted = fixture.resolve()!; + expect(restarted.request).toMatchObject({ recover: true }); + const recoveredRoute = prepareManagedRoute(fixture, restarted); + recoveredRoute.prepared.validateBeforeCommit(); + const resumedMutation = await restarted.prepareGatewayMutation(gatewayMutationInput); + expect(createExactGatewayProvider(resumedMutation)).toEqual({ ok: true }); + await resumedMutation.commit(); + recoveredRoute.prepared.commit(); + const published = fixture.resolve({ + ...freshPortableInput, + allowPublishedResume: true, + recover: true, + }); + + expect(published?.request).toHaveProperty("resumeReceipt"); + expect(gatewayJournal(fixture)).toMatchObject({ + phase: "committed", + providerAuthority: { id: "portable-ollama-provider", resourceVersion: 1 }, + }); + expect(gatewayJournal(fixture).intent.providerCredentialEnv).toMatch( + /^NEMOCLAW_OLLAMA_PROXY_TOKEN_[A-F0-9]{64}$/u, + ); + expect( + fixture.events.filter((event) => + event.startsWith( + "openshell:provider create --name ollama-local --type openai --credential NEMOCLAW_OLLAMA_PROXY_TOKEN_", + ), + ), + ).toHaveLength(1); + expect(fixture.gatewayProvider.isPresent()).toBe(true); + expect(fixture.harness.container()).not.toBeNull(); + }); + + it("recovers the exact route-publication gap before receipt commit (#9596)", async () => { + const fixture = createRuntimeFixture(); + const selection = fixture.resolve()!; + const route = prepareManagedRoute(fixture, selection); + route.prepared.validateBeforeCommit(); + const mutation = await selection.prepareGatewayMutation(gatewayMutationInput); + createExactGatewayProvider(mutation); + await mutation.commit(); + + const restarted = fixture.resolve({ + ...freshPortableInput, + allowPublishedResume: true, + recover: true, + })!; + expect(restarted.request).toMatchObject({ recover: true }); + const recoveredRoute = prepareManagedRoute(fixture, restarted); + recoveredRoute.prepared.validateBeforeCommit(); + const resumedMutation = await restarted.prepareGatewayMutation(gatewayMutationInput); + expect(createExactGatewayProvider(resumedMutation)).toEqual({ ok: true }); + await resumedMutation.commit(); + recoveredRoute.prepared.commit(); + + expect( + fixture.events.filter((event) => event.includes("provider create --name ollama-local")), + ).toHaveLength(1); + expect(gatewayJournal(fixture)).toMatchObject({ phase: "committed" }); + expect(fs.existsSync(inferenceReceiptPath(fixture))).toBe(true); + }); + + it("accepts only the exact hard-link residue from durable file publication (#9596)", async () => { + const fixture = createRuntimeFixture(); + const selection = fixture.resolve()!; + const route = prepareManagedRoute(fixture, selection); + route.prepared.validateBeforeCommit(); + const mutation = await selection.prepareGatewayMutation(gatewayMutationInput); + createExactGatewayProvider(mutation); + await mutation.commit(); + route.prepared.commit(); + const journalPath = gatewayJournalPath(fixture); + const receiptPath = inferenceReceiptPath(fixture); + fs.linkSync( + journalPath, + path.join( + path.dirname(journalPath), + ".portable-gateway-provider.json.00000000-0000-4000-8000-000000000001.tmp", + ), + ); + fs.linkSync( + receiptPath, + path.join( + path.dirname(receiptPath), + ".portable-inference.json.00000000-0000-4000-8000-000000000002.tmp", + ), + ); + + const resumed = fixture.resolve({ + ...freshPortableInput, + allowPublishedResume: true, + recover: true, + })!; + + expect(resumed.request).toHaveProperty("resumeReceipt"); + await expect(resumed.prepareGatewayMutation(gatewayMutationInput)).resolves.toBeDefined(); + }); + + it("rejects a hard link outside the exact durable publication name (#9596)", async () => { + const fixture = createRuntimeFixture(); + const selection = fixture.resolve()!; + await selection.prepareGatewayMutation(gatewayMutationInput); + const journalPath = gatewayJournalPath(fixture); + fs.linkSync(journalPath, path.join(path.dirname(journalPath), "foreign-hard-link")); + + expect(() => fixture.resolve()).toThrow( + "gateway provider journal lacks private file authority", + ); + }); + + it("rejects gateway lookup failure and changed provider generations (#9596)", async () => { + const fixture = createRuntimeFixture(); + const selection = fixture.resolve()!; + fixture.gatewayProvider.setLookupFailure(true); + await expect(selection.prepareGatewayMutation(gatewayMutationInput)).rejects.toThrow( + "could not prove gateway provider absence", + ); + fixture.gatewayProvider.setLookupFailure(false); + const mutation = await selection.prepareGatewayMutation(gatewayMutationInput); + expect(() => createExactGatewayProvider(mutation, "http://169.254.1.2:11434/v1")).toThrow( + "provider mutation authority changed", + ); + createExactGatewayProvider(mutation); + fixture.gatewayProvider.bumpResourceVersion(); + expect(() => mutation.commit()).toThrow("gateway provider authority changed"); + expect(() => mutation.rollback()).toThrow("refused to delete changed gateway authority"); + expect(fixture.gatewayProvider.isPresent()).toBe(true); + }); + + it("rejects a same-name foreign create across the ambiguous create boundary (#9596)", async () => { + const fixture = createRuntimeFixture(); + const selection = fixture.resolve()!; + const mutation = await selection.prepareGatewayMutation(gatewayMutationInput); + fixture.gatewayProvider.setForeignCreateCredentialEnv( + "NEMOCLAW_OLLAMA_PROXY_TOKEN_FOREIGN_TRANSACTION", + ); + + expect(() => createExactGatewayProvider(mutation)).toThrow( + "ambiguous gateway provider authority", + ); + + expect(gatewayJournal(fixture)).toMatchObject({ + phase: "creating", + providerAuthority: null, + }); + expect(fixture.gatewayProvider.isPresent()).toBe(true); + expect(fixture.events.some((event) => event.includes("provider delete"))).toBe(false); + }); + + it("adopts an exact transaction-marked provider after create transport ambiguity (#9596)", async () => { + const fixture = createRuntimeFixture(); + const selection = fixture.resolve()!; + const mutation = await selection.prepareGatewayMutation(gatewayMutationInput); + fixture.gatewayProvider.setCreateTransportAmbiguity(true); + + expect(createExactGatewayProvider(mutation)).toEqual({ ok: true }); + expect(gatewayJournal(fixture)).toMatchObject({ + phase: "created", + providerAuthority: { id: "portable-ollama-provider", resourceVersion: 1 }, + }); + expect(fixture.gatewayProvider.credentialEnv()).toBe( + gatewayJournal(fixture).intent.providerCredentialEnv, + ); + expect(fixture.gatewayProvider.credentialEnv()).toMatch( + /^NEMOCLAW_OLLAMA_PROXY_TOKEN_[A-F0-9]{64}$/u, + ); + }); + + it.each([ + { + name: "missing journal", + mutate: (fixture: ReturnType) => + fs.rmSync(gatewayJournalPath(fixture)), + expected: "unowned existing gateway provider", + }, + { + name: "missing provider", + mutate: (fixture: ReturnType) => + fixture.gatewayProvider.setPresent(false), + expected: "recorded gateway provider authority changed", + }, + { + name: "changed generation", + mutate: (fixture: ReturnType) => + fixture.gatewayProvider.bumpResourceVersion(), + expected: "recorded gateway provider authority changed", + }, + { + name: "ambiguous provider output", + mutate: (fixture: ReturnType) => + fixture.gatewayProvider.setMalformed(true), + expected: "ambiguous gateway provider authority", + }, + ])("fails closed with zero gateway mutation for $name (#9596)", async ({ mutate, expected }) => { + const fixture = createRuntimeFixture(); + const selection = fixture.resolve()!; + const mutation = await selection.prepareGatewayMutation(gatewayMutationInput); + createExactGatewayProvider(mutation); + mutate(fixture); + const mutationsBefore = fixture.events.filter( + (event) => event.includes("provider create") || event.includes("provider delete"), + ); + + expect(() => fixture.resolve()).toThrow(expected); + + expect( + fixture.events.filter( + (event) => event.includes("provider create") || event.includes("provider delete"), + ), + ).toEqual(mutationsBefore); + }); + + it("retains runtime authority when exact gateway provider deletion fails (#9596)", async () => { + const fixture = createRuntimeFixture(); + const selection = fixture.resolve()!; + const mutation = await selection.prepareGatewayMutation(gatewayMutationInput); + createExactGatewayProvider(mutation); + await mutation.commit(); + fixture.gatewayProvider.setDeleteFailure(true); + expect(() => mutation.rollback()).toThrow("could not resume its gateway provider rollback"); + expect(fixture.gatewayProvider.isPresent()).toBe(true); + expect(gatewayJournal(fixture)).toMatchObject({ phase: "rolling-back" }); + + fixture.gatewayProvider.setDeleteFailure(false); + const restarted = fixture.resolve()!; + const resumed = await restarted.prepareGatewayMutation(gatewayMutationInput); + expect(fixture.gatewayProvider.isPresent()).toBe(false); + expect(gatewayJournal(fixture)).toMatchObject({ phase: "prepared" }); + expect(createExactGatewayProvider(resumed)).toEqual({ ok: true }); + expect(fixture.gatewayProvider.isPresent()).toBe(true); + }); +}); diff --git a/src/lib/onboard/experimental/hermes-portable-ollama-inference.ts b/src/lib/onboard/experimental/hermes-portable-ollama-inference.ts new file mode 100644 index 00000000000..04b0cdc5040 --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-ollama-inference.ts @@ -0,0 +1,288 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import path from "node:path"; + +import { capturePodmanSocketAuthority, type PodmanSocketAuthority } from "../../adapters/podman"; +import type { PortableOnboardRuntimeContext } from "../session-bootstrap"; +import type { HostLocalInferenceRouteAuthorityStore } from "../runtime-provider/host-local-inference"; +import type { + HostLocalInferenceStartupSelection, + HostLocalInferenceStartupSelectionInput, + HostLocalInferenceStartupSelectionResolver, +} from "../runtime-provider/host-local-inference-routing"; +import { createFilePersistedEngineAuthorityStore } from "../runtime-provider/persisted-engine-authority"; +import { createPodmanRuntimeProviderBundle } from "../runtime-provider/podman"; +import { + qualifyPodmanInferenceAuthority, + revalidatePodmanInferenceAuthority, +} from "../runtime-provider/podman-preflight"; +import { redactOnboardDiagnosticText } from "../session-bootstrap"; +import { PORTABLE_DOCKER_NETWORK_NAME, isPortableExperimentalProfile } from "./portable-profile"; +import { + captureHermesPortablePodmanExecutableAuthority, + createHermesPortablePodmanOperationEngines, + HERMES_PORTABLE_PODMAN_VERSION, + type HermesPortablePodmanAuthorityDeps, +} from "./hermes-portable-podman-authority"; +import { + captureCurrentCdiDevices, + captureCurrentGpuDevices, + capturePortableNetworkAuthority, + captureQualifiedGpuDevices, + PORTABLE_OLLAMA_IMAGE, + PORTABLE_PROBE_IMAGE, + withRetainedImageAcquisition, +} from "./hermes-portable-ollama-authority"; +import { + createHermesPortableOllamaGatewayTransaction, + hasHermesPortableOllamaRecoveryContainer, + type HermesPortableOllamaGatewayRunner, +} from "./hermes-portable-ollama-gateway-transaction"; +import { defaultPortableDemoStateDir } from "./portable-runtime-receipt-readiness"; + +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u; +const MODEL_MAX_LENGTH = 512; +const SAFE_MODEL_ID = /^[A-Za-z0-9._:/-]+$/u; +const SAFE_CREDENTIAL_ENV = /^[A-Z_][A-Z0-9_]*$/u; + +export interface HermesPortableOllamaInferenceResolverOptions { + readonly runtimeContext: PortableOnboardRuntimeContext | null; + readonly credentialEnv: string; + readonly getReservationSessionId: () => string | null | undefined; + readonly runGatewayOpenshell: HermesPortableOllamaGatewayRunner; + readonly stateDir?: string; + readonly podmanAuthorityDeps?: HermesPortablePodmanAuthorityDeps; + readonly captureSocketAuthority?: (socketPath: string, uid: number) => PodmanSocketAuthority; + readonly captureGpuDevices?: () => readonly string[]; + readonly captureCdiDevices?: () => readonly string[]; +} + +function digest(value: object): string { + return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex"); +} + +function requireSessionId(value: string | null | undefined): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 256 || + value !== value.trim() || + CONTROL_CHARACTERS.test(value) + ) { + throw new Error("Hermes Portable inference requires the current reservation session."); + } + return value; +} + +function requirePortableOllamaModel(model: string): string { + if ( + model.length === 0 || + model.length > MODEL_MAX_LENGTH || + model !== model.trim() || + !SAFE_MODEL_ID.test(model) + ) { + throw new Error("Hermes Portable Ollama has an invalid selected model authority."); + } + return model; +} + +function createUnusedRouteAuthorityStore(): HostLocalInferenceRouteAuthorityStore { + return Object.freeze({ + load: () => null, + record: () => { + throw new Error("Managed Hermes Portable Ollama cannot publish host-process authority."); + }, + }); +} + +export function createHermesPortableOllamaInferenceResolver( + options: HermesPortableOllamaInferenceResolverOptions, +): HostLocalInferenceStartupSelectionResolver { + return (input: HostLocalInferenceStartupSelectionInput) => { + if (input.application !== "hermes") return null; + if (input.provider !== "ollama-local") return null; + if (!SAFE_CREDENTIAL_ENV.test(options.credentialEnv)) { + throw new Error("Hermes Portable Ollama gateway credential authority is invalid."); + } + if (!options.runtimeContext) { + if (isPortableExperimentalProfile(process.env)) { + throw new Error("Hermes Portable inference has no current-user Podman runtime authority."); + } + return null; + } + const model = requirePortableOllamaModel(input.model); + if (input.acceleration !== "nvidia-gpu") { + throw new Error("Hermes Portable Ollama requires NVIDIA GPU acceleration authority."); + } + if (input.requireToolCalling === null) { + throw new Error("Hermes Portable Ollama requires explicit tool-calling authority."); + } + if (input.allowPublishedResume !== input.recover) { + throw new Error("Hermes Portable Ollama recovery authority is inconsistent."); + } + const runtimeContext = options.runtimeContext; + if (!runtimeContext.environmentScope) { + throw new Error("Hermes Portable inference has no active environment authority."); + } + const sessionId = requireSessionId(options.getReservationSessionId()); + const sourceEnv = runtimeContext.environmentScope.createHermesPortablePodmanSourceEnvironment( + runtimeContext.authority, + ); + const socketAuthority = ( + options.captureSocketAuthority ?? + ((socketPath, uid) => capturePodmanSocketAuthority(socketPath, { uid })) + )(runtimeContext.authority.socketPath, runtimeContext.authority.uid); + const executableAuthority = captureHermesPortablePodmanExecutableAuthority( + socketAuthority, + runtimeContext.authority, + sourceEnv, + options.podmanAuthorityDeps, + ); + const engines = createHermesPortablePodmanOperationEngines( + executableAuthority, + socketAuthority, + runtimeContext.authority, + sourceEnv, + options.podmanAuthorityDeps, + ); + engines.assertCurrent(); + const captureGpuDevices = () => + captureQualifiedGpuDevices( + options.captureGpuDevices ?? captureCurrentGpuDevices, + options.captureCdiDevices ?? captureCurrentCdiDevices, + ); + const authorityQualification = Object.freeze({ + expectedVersion: HERMES_PORTABLE_PODMAN_VERSION, + captureCurrentCdiDevices: () => captureGpuDevices(), + assertCurrentAuthority: engines.assertCurrent, + }); + const authority = qualifyPodmanInferenceAuthority( + engines.hostLocalInference, + authorityQualification, + ); + const gpuDevices = authority.cdiDevices; + const networkAuthority = capturePortableNetworkAuthority(engines.hostLocalInference); + const assertCurrent = () => { + engines.assertCurrent(); + networkAuthority.assertCurrent(); + revalidatePodmanInferenceAuthority( + engines.hostLocalInference, + authority, + authorityQualification, + ); + }; + + const sandboxDigest = digest({ sandboxName: input.sandboxName }); + const stateDir = path.join( + options.stateDir ?? defaultPortableDemoStateDir(sourceEnv), + "portable-inference", + sandboxDigest, + ); + const transactionId = digest({ + kind: "hermes-portable-ollama", + sessionId, + sandboxName: input.sandboxName, + model, + runtimeAuthority: runtimeContext.authority, + engineAuthority: authority, + networkAuthority: networkAuthority.authoritySha256, + }); + const targetSha256 = digest({ + kind: "hermes-portable-ollama-receipt", + sandboxName: input.sandboxName, + }); + const gatewayTransaction = createHermesPortableOllamaGatewayTransaction({ + directory: stateDir, + transactionId, + targetSha256, + sandboxName: input.sandboxName, + model, + credentialEnv: options.credentialEnv, + runGatewayOpenshell: options.runGatewayOpenshell, + }); + const publishedReceipt = gatewayTransaction.publishedReceipt; + const containerName = `nemoclaw-portable-ollama-${sandboxDigest.slice(0, 16)}`; + const recoverInterrupted = + publishedReceipt === null && + hasHermesPortableOllamaRecoveryContainer( + engines.hostLocalInference, + containerName, + assertCurrent, + ); + const recoverUnpublishedRoute = + input.allowPublishedResume && + gatewayTransaction.recoverUnpublishedRoute && + recoverInterrupted; + if ( + (input.allowPublishedResume && publishedReceipt === null && !recoverUnpublishedRoute) || + (!input.allowPublishedResume && publishedReceipt !== null) || + (gatewayTransaction.recoverUnpublishedRoute && !recoverInterrupted) + ) { + throw new Error("Hermes Portable Ollama publication authority is inconsistent."); + } + const bundle = withRetainedImageAcquisition( + createPodmanRuntimeProviderBundle({ + engines: { + hostDoctor: engines.hostDoctor, + hostLocalInference: engines.hostLocalInference, + sandboxLifecycle: engines.sandboxLifecycle, + }, + hostLocalInference: { + authority, + authorityQualification, + authorityStore: createFilePersistedEngineAuthorityStore(stateDir), + routeAuthorityStore: createUnusedRouteAuthorityStore(), + externalNetwork: networkAuthority, + onFailureEvidence: (evidence) => { + const message = redactOnboardDiagnosticText(evidence.message); + if (message) console.error(` Podman inference ${evidence.phase}: ${message}`); + }, + redactSensitive: redactOnboardDiagnosticText, + }, + preflight: { platform: "linux", architecture: "x64" }, + }), + engines.hostLocalInference, + assertCurrent, + ); + const selection: HostLocalInferenceStartupSelection = Object.freeze({ + runtimeProviderId: "podman", + request: Object.freeze({ + application: "hermes" as const, + service: "ollama" as const, + managed: Object.freeze({ + service: "ollama" as const, + containerName, + containerPort: 11434, + imageRef: PORTABLE_OLLAMA_IMAGE, + gpuDevices, + environment: Object.freeze([]), + model, + requireToolCalling: input.requireToolCalling, + networkName: PORTABLE_DOCKER_NETWORK_NAME, + networkId: networkAuthority.networkId, + networkGatewayIp: networkAuthority.gatewayIp, + networkListenerIp: networkAuthority.listenerIp, + hostPort: 11434, + probeImageRef: PORTABLE_PROBE_IMAGE, + }), + ...(publishedReceipt + ? { resumeReceipt: publishedReceipt } + : recoverInterrupted || recoverUnpublishedRoute + ? { recover: true } + : {}), + receiptWriter: gatewayTransaction.receiptWriter, + }), + resolveRuntimeProvider: (sandboxName: string) => { + if (sandboxName !== input.sandboxName) { + throw new Error("Hermes Portable inference runtime belongs to another sandbox."); + } + assertCurrent(); + return bundle; + }, + prepareGatewayMutation: gatewayTransaction.prepareGatewayMutation, + }); + return selection; + }; +} diff --git a/src/lib/onboard/experimental/hermes-portable-podman-authority.ts b/src/lib/onboard/experimental/hermes-portable-podman-authority.ts index 9e650f00900..82f79476077 100644 --- a/src/lib/onboard/experimental/hermes-portable-podman-authority.ts +++ b/src/lib/onboard/experimental/hermes-portable-podman-authority.ts @@ -15,6 +15,7 @@ import { type PodmanSocketAuthorityDeps, } from "../../adapters/podman"; import type { ContainerEngineCommandCapture } from "../../adapters/container-engine"; +import type { ContainerEngineOperationScope } from "../../adapters/container-engine"; import type { CheckpointPortableRuntimeAuthority } from "../../state/onboard-checkpoint-types"; import { parsePortableRuntimeAuthority } from "../../state/onboard/portable-runtime-authority"; import { qualifyPodmanEndpointHost } from "../runtime-provider/podman-preflight"; @@ -47,6 +48,13 @@ export interface HermesPortablePodmanCommandAuthority { readonly assertCurrent: () => void; } +export interface HermesPortablePodmanOperationEngines { + readonly hostDoctor: PodmanBoundContainerEngine; + readonly hostLocalInference: PodmanBoundContainerEngine; + readonly sandboxLifecycle: PodmanBoundContainerEngine; + readonly assertCurrent: () => void; +} + function fail(message: string): never { throw new Error(`Hermes portable Podman authority ${message}`); } @@ -117,10 +125,14 @@ function qualifyExactMatrix( } } -export function createHermesPortablePodmanCommandAuthority( +function createHermesPortablePodmanOperationCommandAuthority( authority: HermesPortablePodmanExecutableAuthority, socketAuthority: PodmanSocketAuthority, runtimeAuthority: CheckpointPortableRuntimeAuthority, + operation: Extract< + ContainerEngineOperationScope, + "host-doctor" | "host-local-inference" | "sandbox-lifecycle" | "state-mutation" + >, sourceEnv: NodeJS.ProcessEnv = process.env, deps: HermesPortablePodmanAuthorityDeps = {}, ): HermesPortablePodmanCommandAuthority { @@ -130,7 +142,7 @@ export function createHermesPortablePodmanCommandAuthority( requireResolvedExecutable(authority, sourceEnv, deps); assertPodmanExecutableAuthority(authority.executable, deps.executableAuthorityDeps); const engine = createPodmanContainerEngine({ - operation: "state-mutation", + operation, socketAuthority, executable: authority.executable.executablePath, executableAuthority: authority.executable, @@ -148,12 +160,69 @@ export function createHermesPortablePodmanCommandAuthority( requireResolvedExecutable(authority, sourceEnv, deps); assertPodmanExecutableAuthority(authority.executable, deps.executableAuthorityDeps); engine.assertAuthority(); - qualifyExactMatrix(engine, deps); + if (operation === "state-mutation") qualifyExactMatrix(engine, deps); engine.assertAuthority(); }; return Object.freeze({ engine, assertCurrent }); } +export function createHermesPortablePodmanCommandAuthority( + authority: HermesPortablePodmanExecutableAuthority, + socketAuthority: PodmanSocketAuthority, + runtimeAuthority: CheckpointPortableRuntimeAuthority, + sourceEnv: NodeJS.ProcessEnv = process.env, + deps: HermesPortablePodmanAuthorityDeps = {}, +): HermesPortablePodmanCommandAuthority { + return createHermesPortablePodmanOperationCommandAuthority( + authority, + socketAuthority, + runtimeAuthority, + "state-mutation", + sourceEnv, + deps, + ); +} + +export function createHermesPortablePodmanOperationEngines( + authority: HermesPortablePodmanExecutableAuthority, + socketAuthority: PodmanSocketAuthority, + runtimeAuthority: CheckpointPortableRuntimeAuthority, + sourceEnv: NodeJS.ProcessEnv = process.env, + deps: HermesPortablePodmanAuthorityDeps = {}, +): HermesPortablePodmanOperationEngines { + const create = (operation: "host-doctor" | "host-local-inference" | "sandbox-lifecycle") => + createHermesPortablePodmanOperationCommandAuthority( + authority, + socketAuthority, + runtimeAuthority, + operation, + sourceEnv, + deps, + ); + const hostDoctor = create("host-doctor"); + const hostLocalInference = create("host-local-inference"); + const sandboxLifecycle = create("sandbox-lifecycle"); + const matrixAuthority = createHermesPortablePodmanOperationCommandAuthority( + authority, + socketAuthority, + runtimeAuthority, + "state-mutation", + sourceEnv, + deps, + ); + return Object.freeze({ + hostDoctor: hostDoctor.engine, + hostLocalInference: hostLocalInference.engine, + sandboxLifecycle: sandboxLifecycle.engine, + assertCurrent: () => { + matrixAuthority.assertCurrent(); + hostDoctor.assertCurrent(); + hostLocalInference.assertCurrent(); + sandboxLifecycle.assertCurrent(); + }, + }); +} + export function captureHermesPortablePodmanExecutableAuthority( socketAuthority: PodmanSocketAuthority, runtimeAuthority: CheckpointPortableRuntimeAuthority, diff --git a/src/lib/onboard/machine/handlers/provider-inference-host-local-startup.test.ts b/src/lib/onboard/machine/handlers/provider-inference-host-local-startup.test.ts index 7847d034aba..6a177709960 100644 --- a/src/lib/onboard/machine/handlers/provider-inference-host-local-startup.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference-host-local-startup.test.ts @@ -20,6 +20,7 @@ import { baseOptions, baseSelection, createDeps } from "./provider-inference.tes const PROBE_IMAGE = `quay.io/curl/curl@sha256:${"b".repeat(64)}`; const NIM_IMAGE = `nvcr.io/nim/meta/llama@sha256:${"d".repeat(64)}`; const MANAGED_IMAGE = `nvcr.io/nvidia/vllm@sha256:${"c".repeat(64)}`; +const OLLAMA_IMAGE = `docker.io/ollama/ollama@sha256:${"e".repeat(64)}`; const NETWORK_ID = "2".repeat(64); const NETWORK_GATEWAY_IP = "10.89.0.1"; const NETWORK_AUTHORITY = "3".repeat(64); @@ -162,13 +163,44 @@ function hostLocalPublishedResumeSelection( }; } +function managedOllamaSelection( + input: HostLocalInferenceStartupSelectionInput, + recover: boolean, +): HostLocalInferenceStartupSelection { + const selected = hostLocalStartupSelection( + input, + "vllm", + false, + recover ? "interrupted" : "fresh", + ); + const request = selected.request as Extract< + HostLocalInferenceStartupSelection["request"], + { managed: unknown } + >; + return { + ...selected, + request: { + ...request, + service: "ollama", + managed: { + ...request.managed, + service: "ollama", + containerName: "nemoclaw-ollama", + containerPort: 11434, + hostPort: 11434, + imageRef: OLLAMA_IMAGE, + }, + }, + }; +} + function expectedOllamaSelection( selected: HostLocalInferenceStartupSelection, -): Extract { +): Extract { expect(selected.request.service).toBe("ollama"); return selected.request as Extract< HostLocalInferenceStartupSelection["request"], - { service: "ollama" } + { endpoint: unknown } >; } @@ -480,6 +512,61 @@ describe("provider inference host-local startup selection", () => { expect(calls.setupInference).not.toHaveBeenCalled(); }); + it.each([ + { + name: "non-boolean recovery", + recover: "true" as unknown as boolean, + error: /invalid recovery authority/u, + }, + { name: "interrupted recovery", recover: true, error: /drifted from recovery authority/u }, + ])("rejects $name for fresh managed Ollama before provider setup", async ({ recover, error }) => { + const model = "qwen3-vl:4b"; + const session = createSession({ + provider: "ollama-local", + model, + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: "openai-completions", + }); + const { deps, calls } = createDeps({ + setupNim: vi.fn(async () => ({ + ...baseSelection, + provider: "ollama-local", + model, + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: "openai-completions", + })), + resolveHostLocalInferenceStartupSelection: (input) => { + const selected = hostLocalStartupSelection(input, "vllm", true, "fresh"); + const request = selected.request as Extract< + HostLocalInferenceStartupSelection["request"], + { managed: unknown } + >; + return { + ...selected, + request: { + ...request, + service: "ollama" as const, + recover, + managed: { ...request.managed, service: "ollama" as const }, + }, + }; + }, + }); + + await expect( + handleProviderInferenceState({ + ...baseOptions(deps, session), + agent: { name: "hermes" }, + forceInferenceSetup: true, + sandboxName: "portable-hermes", + }), + ).rejects.toThrow(error); + + expect(calls.setupInference).not.toHaveBeenCalled(); + }); + it("keeps fresh Ollama CPU-scoped when GPU passthrough was disabled on NVIDIA", async () => { const model = "nemotron:latest"; const setupNim = vi.fn(async () => ({ @@ -1097,6 +1184,135 @@ describe("provider inference host-local startup selection", () => { ); }); + it("resumes the exact managed Ollama route-publication gap through provider setup", async () => { + const model = "qwen3-vl:4b"; + const session = createSession({ + provider: "ollama-local", + model, + endpointUrl: "https://inference.local/v1", + credentialEnv: null, + preferredInferenceApi: "openai-completions", + }); + session.steps.provider_selection.status = "complete"; + const resolver = vi.fn((input: HostLocalInferenceStartupSelectionInput) => + managedOllamaSelection(input, true), + ); + const { deps, calls } = createDeps({ + isInferenceRouteReady: vi.fn(() => true), + resolveHostLocalInferenceStartupSelection: resolver, + }); + const options = baseOptions(deps, session); + + const result = await handleProviderInferenceState({ + ...options, + agent: { name: "hermes" }, + initial: { ...options.initial, endpointSource: "inference-set" }, + resume: true, + sandboxName: "portable-hermes", + }); + + expect(resolver).toHaveBeenCalledWith({ + application: "hermes", + sandboxName: "portable-hermes", + provider: "ollama-local", + model, + acceleration: "nvidia-gpu", + requireToolCalling: null, + allowPublishedResume: true, + recover: true, + }); + const setupCall = calls.setupInference.mock.calls[0] as unknown as readonly unknown[]; + expect(setupCall[7]).toEqual( + expect.objectContaining({ + hostLocalInference: expect.objectContaining({ + request: expect.objectContaining({ + service: "ollama", + recover: true, + managed: expect.objectContaining({ + service: "ollama", + containerName: "nemoclaw-ollama", + }), + }), + }), + }), + ); + expect( + (setupCall[7] as { hostLocalInference: HostLocalInferenceStartupSelection }) + .hostLocalInference.request, + ).not.toHaveProperty("resumeReceipt"); + expect(result.hostLocalInferenceRouteOnly).toBe(true); + }); + + it("resumes after provider selection commits before inference completion (#9596)", async () => { + const model = "qwen3-vl:4b"; + const session = createSession(); + const setupNim = vi.fn(async () => ({ + ...baseSelection, + provider: "ollama-local", + model, + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: "openai-completions", + })); + const resolver = vi.fn((input: HostLocalInferenceStartupSelectionInput) => + managedOllamaSelection(input, input.recover), + ); + const completeStep = async (stepName: string, updates: Record) => { + Object.assign(session, updates); + session.steps[stepName]!.status = "complete"; + return session; + }; + const recordStepComplete = vi + .fn(completeStep) + .mockImplementationOnce(completeStep) + .mockImplementationOnce(async () => { + throw new Error("simulated inference completion interruption"); + }); + const startRecordedStep = vi.fn(async (stepName: string) => { + session.steps[stepName]!.status = "in_progress"; + }); + const setupInference = vi.fn(async () => ({ ok: true as const })); + const { deps } = createDeps({ + setupNim, + setupInference, + resolveHostLocalInferenceStartupSelection: resolver, + recordStepComplete, + startRecordedStep, + isInferenceRouteReady: vi.fn(() => true), + }); + + await expect( + handleProviderInferenceState({ + ...baseOptions(deps, session), + agent: { name: "hermes" }, + sandboxName: "portable-hermes", + }), + ).rejects.toThrow("simulated inference completion interruption"); + + expect(session.steps.provider_selection.status).toBe("complete"); + expect(session.steps.inference.status).toBe("in_progress"); + const resumed = await handleProviderInferenceState({ + ...baseOptions(deps, session), + agent: { name: "hermes" }, + initial: { + ...baseOptions(deps, session).initial, + endpointSource: "inference-set", + }, + resume: true, + sandboxName: "portable-hermes", + }); + + expect(resumed.hostLocalInferenceRouteOnly).toBe(true); + expect(resolver).toHaveBeenLastCalledWith( + expect.objectContaining({ + allowPublishedResume: true, + recover: true, + }), + ); + expect(setupInference).toHaveBeenCalledTimes(2); + expect(session.steps.inference.status).toBe("complete"); + }); + it("fails closed when canonical resumed state loses its injected runtime resolver", async () => { const session = createSession({ provider: "ollama-local", diff --git a/src/lib/onboard/machine/handlers/provider-inference.ts b/src/lib/onboard/machine/handlers/provider-inference.ts index dd39d539cfa..bc5a8362804 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.ts @@ -167,10 +167,7 @@ export interface ProviderInferenceStateOptions { gatewayName: string, operation: () => Promise | T, ): Promise; - withModelRouterPortLifecycleLock?( - port: number, - operation: () => Promise | T, - ): Promise; + withModelRouterPortLifecycleLock?(port: number, operation: () => Promise | T): Promise; getModelRouterPort?(): number; normalizeHermesAuthMethod(value: string | null | undefined): HermesAuthMethod | null; setupNim( @@ -551,19 +548,19 @@ function hostLocalInferenceSetupOptions( throw new Error("Host-local inference startup selection drifted from the accepted provider."); } if ( - selected.request.service !== "ollama" && selected.request.service !== "llama-cpp" && + (selected.request.service !== "ollama" || "managed" in selected.request) && selected.request.recover !== undefined && typeof selected.request.recover !== "boolean" ) { throw new Error("Host-local inference startup selection has invalid recovery authority."); } const hasDurableToolCallingAuthority = - selected.request.service === "ollama" + selected.request.service === "ollama" && "endpoint" in selected.request ? input.recover : selected.request.service === "llama-cpp" ? selected.request.publishedRoute - : selected.request.resumeReceipt !== undefined || selected.request.recover === true; + : selected.request.resumeReceipt !== undefined || selected.request.recover === true; const expectedToolCalling = input.requireToolCalling ?? (hasDurableToolCallingAuthority ? null : input.freshRequireToolCalling); @@ -575,6 +572,7 @@ function hostLocalInferenceSetupOptions( if ( selectedModel !== acceptedModel || (selected.request.service === "ollama" && + "endpoint" in selected.request && selected.request.endpoint.acceleration !== input.acceleration) || (expectedToolCalling !== null && hostLocalInferenceRequestToolCalling(selected.request) !== expectedToolCalling) @@ -590,7 +588,7 @@ function hostLocalInferenceSetupOptions( ) { throw new Error("Managed llama.cpp startup selection drifted from recovery authority."); } - } else if (selected.request.service !== "ollama") { + } else if (selected.request.service !== "ollama" || "managed" in selected.request) { if (input.acceleration !== "nvidia-gpu") { throw new Error( "Managed host-local inference requires accepted NVIDIA GPU passthrough authority.", @@ -598,14 +596,20 @@ function hostLocalInferenceSetupOptions( } const hasPublishedResume = selected.request.resumeReceipt !== undefined; const hasInterruptedRecovery = selected.request.recover === true; - // These two accepted-state bits encode three fail-closed modes: - // canonical route = published only; accepted pre-publication = fresh, - // published, or interrupted; every other selection = fresh only. + // Canonical managed Ollama recovery also admits the exact journaled + // route-publication gap: its runtime is interrupted and its receipt is + // not published yet. Other canonical managed routes remain published-only. const recoveryAuthorityMatches = input.recover - ? input.allowPublishedResume && hasPublishedResume && !hasInterruptedRecovery - : input.allowPublishedResume - ? !(hasPublishedResume && hasInterruptedRecovery) - : !hasPublishedResume && !hasInterruptedRecovery; + ? input.allowPublishedResume && + ((hasPublishedResume && !hasInterruptedRecovery) || + (selected.request.service === "ollama" && + !hasPublishedResume && + hasInterruptedRecovery)) + : selected.request.service === "ollama" + ? !input.allowPublishedResume && !hasPublishedResume && !hasInterruptedRecovery + : input.allowPublishedResume + ? !(hasPublishedResume && hasInterruptedRecovery) + : !hasPublishedResume && !hasInterruptedRecovery; if (!recoveryAuthorityMatches) { throw new Error("Host-local inference startup selection drifted from recovery authority."); } @@ -691,7 +695,7 @@ function hasActiveMessagingChannels( const channels = session?.messagingPlan?.channels; return Boolean( Array.isArray(channels) && - channels.some((channel) => channel.active === true && channel.disabled !== true), + channels.some((channel) => channel.active === true && channel.disabled !== true), ); } @@ -1065,6 +1069,7 @@ export async function handleProviderInferenceState({ session, sandboxName, ); + let deferProviderSelectionUntilInference = completeRecoveredReviewSelectionAfterInference; const reviewRecoveredInteractively = completeRecoveredReviewSelectionAfterInference && !deps.isNonInteractive(); const resumeProviderSelection = canResumeProviderSelection( @@ -1345,9 +1350,7 @@ export async function handleProviderInferenceState({ endpointSource, }); const acceptedHostLocalResume = - effectiveResume && - resumeProviderSelection && - isHostLocalInferenceProvider(selectedProvider); + effectiveResume && resumeProviderSelection && isHostLocalInferenceProvider(selectedProvider); const resolveCachedHostLocalInferenceSetupOptions = createCachedHostLocalInferenceSetupResolver( { resolver: deps.resolveHostLocalInferenceStartupSelection, @@ -1603,10 +1606,21 @@ export async function handleProviderInferenceState({ continue; } const confirmedSandboxName = review.sandboxName; - // The review acceptance authorizes this fresh selection. Persist it - // before inference setup starts so an interruption in setup can resume - // the accepted provider/model rather than returning to the default menu. - if (shouldRecordProviderSelection && !effectiveResume) { + activeHostLocalInferenceSetupOptions = + resolveCachedHostLocalInferenceSetupOptions(confirmedSandboxName); + const freshManagedOllama = + activeHostLocalInferenceSetupOptions.hostLocalInference?.request.service === "ollama" && + "managed" in activeHostLocalInferenceSetupOptions.hostLocalInference.request; + deferProviderSelectionUntilInference = + completeRecoveredReviewSelectionAfterInference || freshManagedOllama; + // Ordinary selections retain the accepted provider/model before setup. + // Fresh managed Ollama stays in progress until its runtime, route, + // receipt, and registry reservation have committed together. + if ( + shouldRecordProviderSelection && + !effectiveResume && + !deferProviderSelectionUntilInference + ) { session = await deps.recordStepComplete( "provider_selection", deps.toSessionUpdates({ @@ -1624,8 +1638,6 @@ export async function handleProviderInferenceState({ }), ); } - activeHostLocalInferenceSetupOptions = - resolveCachedHostLocalInferenceSetupOptions(confirmedSandboxName); // The injected host-local transaction owns its runtime and uses a // secret-free route. Do not start or persist the legacy host Ollama // proxy alongside it; that would leave cross-engine residue before the @@ -1647,6 +1659,7 @@ export async function handleProviderInferenceState({ ...(endpointSource === "onboard" && onboardEndpointUrl ? { onboardEndpointUrl } : {}), ...(endpointTrustedPrivateCapability ? { endpointTrustedPrivateCapability } : {}), ...(inferenceCapabilityCache ? { inferenceCapabilityCache } : {}), + ...(freshManagedOllama ? { reservationSessionId: session?.sessionId } : {}), ...providerRecovery.setupOptions( recoveredRecordedProvider, confirmedSandboxName, @@ -1694,27 +1707,7 @@ export async function handleProviderInferenceState({ endpointSource = hostLocalRoute.endpointSource; onboardEndpointUrl = hostLocalRoute.onboardEndpointUrl; if (nimContainer && sandboxName) deps.registryUpdateSandbox(sandboxName, { nimContainer }); - session = await deps.recordStepComplete( - "inference", - deps.toSessionUpdates({ - provider, - model, - hermesAuthMethod, - compatibleEndpointReasoning, - compatibleEndpointReasoningEffort, - nimContainer, - hermesToolGateways, - ...hostLocalInferenceSessionRoute( - hostLocalInferenceRouteOnly, - endpointUrl, - endpointSource, - ), - // The forced #6294/#6289 heal succeeded: the gateway registration now - // matches the adjusted route, so the stale session seed can be replaced. - ...(healAdjustedInferenceApi ? { preferredInferenceApi } : {}), - }), - ); - if (completeRecoveredReviewSelectionAfterInference) { + if (deferProviderSelectionUntilInference) { // Provider selection remains in progress until its inference route has // configured successfully. This retains the selected provider/model for // interruption recovery without claiming a usable route prematurely. @@ -1737,6 +1730,22 @@ export async function handleProviderInferenceState({ }), ); } + session = await deps.recordStepComplete( + "inference", + deps.toSessionUpdates({ + provider, + model, + hermesAuthMethod, + compatibleEndpointReasoning, + compatibleEndpointReasoningEffort, + nimContainer, + hermesToolGateways, + ...hostLocalInferenceSessionRoute(hostLocalInferenceRouteOnly, endpointUrl, endpointSource), + // The forced #6294/#6289 heal succeeded: the gateway registration now + // matches the adjusted route, so the stale session seed can be replaced. + ...(healAdjustedInferenceApi ? { preferredInferenceApi } : {}), + }), + ); break; } diff --git a/src/lib/onboard/runtime-provider/host-local-inference-routing.ts b/src/lib/onboard/runtime-provider/host-local-inference-routing.ts index 7273861ddb1..a3a9f705489 100644 --- a/src/lib/onboard/runtime-provider/host-local-inference-routing.ts +++ b/src/lib/onboard/runtime-provider/host-local-inference-routing.ts @@ -56,6 +56,14 @@ export interface HostLocalInferenceGatewayMutationInput { * owner captures the prior provider and inference selection before returning. */ export interface HostLocalInferenceGatewayMutation { + /** Exact provider mutation owned by a product transaction when supplied. */ + upsertProvider?: ( + name: string, + type: string, + credentialEnv: string, + baseUrl: string, + env?: NodeJS.ProcessEnv, + ) => { ok: boolean; message?: string; status?: number }; commit(): void | Promise; rollback(): void | Promise; } @@ -67,6 +75,16 @@ export type HostLocalInferenceStartupRequest = readonly endpoint: HostLocalOllamaInferenceInput; readonly receiptWriter: HostLocalInferenceReceiptWriter; } + | { + readonly application: HostLocalInferenceApplication; + readonly service: "ollama"; + readonly managed: HostLocalManagedInferenceInput; + /** Exact durable receipt for a previously published canonical route. */ + readonly resumeReceipt?: HostLocalInferenceReceipt; + /** Recover only an interrupted, not-yet-published same-transaction start. */ + readonly recover?: boolean; + readonly receiptWriter: HostLocalInferenceReceiptWriter; + } | { readonly application: HostLocalInferenceApplication; readonly service: "nim" | "vllm"; @@ -125,15 +143,20 @@ export interface HostLocalInferenceSandboxProofAuthority { readonly toolCallingRequired: boolean; } +function isHostOllamaRequest( + request: HostLocalInferenceStartupRequest, +): request is Extract { + return request.service === "ollama" && "endpoint" in request; +} + export function hostLocalInferenceSandboxProofAuthority( request: HostLocalInferenceStartupRequest, ): HostLocalInferenceSandboxProofAuthority { - const input = - request.service === "ollama" - ? request.endpoint - : request.service === "llama-cpp" - ? null - : request.managed; + const input = isHostOllamaRequest(request) + ? request.endpoint + : request.service === "llama-cpp" + ? null + : request.managed; const directHealthPath = request.service === "ollama" ? "/api/tags" @@ -151,14 +174,14 @@ export function hostLocalInferenceSandboxProofAuthority( } export function hostLocalInferenceRequestModel(request: HostLocalInferenceStartupRequest): string { - if (request.service === "ollama") return normalizeHostLocalOllamaModelRef(request.endpoint.model); + if (isHostOllamaRequest(request)) return normalizeHostLocalOllamaModelRef(request.endpoint.model); return request.service === "llama-cpp" ? request.adapter.model : request.managed.model; } export function hostLocalInferenceRequestToolCalling( request: HostLocalInferenceStartupRequest, ): boolean { - if (request.service === "ollama") return request.endpoint.requireToolCalling; + if (isHostOllamaRequest(request)) return request.endpoint.requireToolCalling; return request.service === "llama-cpp" ? request.requireToolCalling : request.managed.requireToolCalling; @@ -231,17 +254,15 @@ function normalizeStartupReceipt( } return normalized; } - const model = - request.service === "ollama" - ? normalizeHostLocalOllamaModelRef(request.endpoint.model) - : request.managed.model; - const toolCallingRequired = - request.service === "ollama" - ? request.endpoint.requireToolCalling - : request.managed.requireToolCalling; + const model = isHostOllamaRequest(request) + ? normalizeHostLocalOllamaModelRef(request.endpoint.model) + : request.managed.model; + const toolCallingRequired = isHostOllamaRequest(request) + ? request.endpoint.requireToolCalling + : request.managed.requireToolCalling; const protocol = "openai-chat-completions"; const { inference, publication } = normalized; - const requestInput = request.service === "ollama" ? request.endpoint : request.managed; + const requestInput = isHostOllamaRequest(request) ? request.endpoint : request.managed; const expectedProbeImageRef = normalizeHostLocalInferenceImageRef(requestInput.probeImageRef); const endpointMatches = "networkId" in normalized.endpoint && @@ -249,20 +270,20 @@ function normalizeStartupReceipt( normalized.endpoint.port === requestInput.hostPort && normalized.endpoint.networkName === requestInput.networkName && normalized.endpoint.networkId === requestInput.networkId && - normalized.endpoint.networkGatewayIp === requestInput.networkGatewayIp; - const runtimeMatches = - request.service === "ollama" - ? normalized.runtime.kind === "host" && - normalized.runtime.probeImageRef === expectedProbeImageRef && - normalized.runtime.acceleration === request.endpoint.acceleration - : normalized.runtime.kind === "container" && - normalized.runtime.name === request.managed.containerName && - normalized.runtime.imageRef === - normalizeHostLocalInferenceImageRef(request.managed.imageRef) && - normalized.runtime.probeImageRef === expectedProbeImageRef && - "devices" in normalized.runtime.gpu && - canonicalGpuDevices(normalized.runtime.gpu.devices) === - canonicalGpuDevices(request.managed.gpuDevices); + normalized.endpoint.networkGatewayIp === requestInput.networkGatewayIp && + normalized.endpoint.networkListenerIp === requestInput.networkListenerIp; + const runtimeMatches = isHostOllamaRequest(request) + ? normalized.runtime.kind === "host" && + normalized.runtime.probeImageRef === expectedProbeImageRef && + normalized.runtime.acceleration === request.endpoint.acceleration + : normalized.runtime.kind === "container" && + normalized.runtime.name === request.managed.containerName && + normalized.runtime.imageRef === + normalizeHostLocalInferenceImageRef(request.managed.imageRef) && + normalized.runtime.probeImageRef === expectedProbeImageRef && + "devices" in normalized.runtime.gpu && + canonicalGpuDevices(normalized.runtime.gpu.devices) === + canonicalGpuDevices(request.managed.gpuDevices); if ( normalized.providerId !== operation.providerId || normalized.providerId !== runtime.providerId || @@ -361,7 +382,7 @@ export function prepareHostLocalInferenceStartup( throw new Error("Managed llama.cpp route publication authority is invalid."); } prepared = request.adapter.prepareStartup(); - } else if (request.service === "ollama") { + } else if (isHostOllamaRequest(request)) { prepared = runtime.qualifyOllama(request.endpoint, request.receiptWriter); } else { if (request.managed.service !== request.service) { @@ -413,22 +434,22 @@ export function prepareHostLocalInferenceStartup( } throw error; } + const managedPublishedResume = + request.service !== "llama-cpp" && + !isHostOllamaRequest(request) && + request.resumeReceipt !== undefined; const expectedRollbackPriorState = request.service === "llama-cpp" ? prepared.rollbackPriorState - : request.service !== "ollama" && request.resumeReceipt !== undefined - ? prepared.rollbackPriorState - : receipt.publication?.priorState; + : managedPublishedResume + ? prepared.rollbackPriorState + : receipt.publication?.priorState; if ( prepared.rollbackPriorState !== expectedRollbackPriorState || - (request.service !== "ollama" && - request.service !== "llama-cpp" && - request.resumeReceipt !== undefined && + (managedPublishedResume && prepared.rollbackPriorState !== "running" && prepared.rollbackPriorState !== "stopped") || - (request.service !== "ollama" && - request.service !== "llama-cpp" && - request.resumeReceipt !== undefined && + (managedPublishedResume && serializeHostLocalInferenceReceipt( normalizeHostLocalInferenceReceipt(request.resumeReceipt), ) !== serializeHostLocalInferenceReceipt(receipt)) diff --git a/src/lib/onboard/runtime-provider/host-local-inference.test.ts b/src/lib/onboard/runtime-provider/host-local-inference.test.ts index 63098e1cda7..6f38b3cab73 100644 --- a/src/lib/onboard/runtime-provider/host-local-inference.test.ts +++ b/src/lib/onboard/runtime-provider/host-local-inference.test.ts @@ -277,7 +277,7 @@ describe("host-local inference receipt contract", () => { ).toThrow("GPU device is malformed"); }); - it("rejects a host runtime for managed services and container runtime for Ollama", () => { + it("rejects host runtime authority for managed services", () => { expect(() => normalizeHostLocalInferenceReceipt({ ...receipt("nim"), @@ -289,12 +289,6 @@ describe("host-local inference receipt contract", () => { }, }), ).toThrow("only Ollama"); - expect(() => - normalizeHostLocalInferenceReceipt({ - ...receipt("ollama"), - runtime: receipt("vllm").runtime, - }), - ).toThrow("Ollama must use host-process authority"); }); it("rejects mutable probe images and malformed managed specification digests", () => { diff --git a/src/lib/onboard/runtime-provider/host-local-inference.ts b/src/lib/onboard/runtime-provider/host-local-inference.ts index 17b48ff3ab1..530e329adae 100644 --- a/src/lib/onboard/runtime-provider/host-local-inference.ts +++ b/src/lib/onboard/runtime-provider/host-local-inference.ts @@ -40,6 +40,8 @@ export interface HostLocalInferenceEndpointInput { readonly networkId: string; /** Exact bridge gateway listener used by provider-network probes. */ readonly networkGatewayIp: string; + /** Optional qualified host listener when it differs from the bridge IPAM gateway. */ + readonly networkListenerIp?: string; readonly hostPort: number; readonly probeImageRef: string; /** Secret-free provider-native model identity used for a real inference proof. */ @@ -62,7 +64,7 @@ export interface HostLocalInferenceMount { } export interface HostLocalManagedInferenceInput extends HostLocalInferenceEndpointInput { - readonly service: "nim" | "vllm"; + readonly service: "ollama" | "nim" | "vllm"; readonly containerName: string; readonly containerPort: number; readonly imageRef: string; @@ -85,6 +87,8 @@ export interface HostLocalInferenceProofEndpointAuthority extends HostLocalInferenceLegacyEndpointAuthority { readonly networkId: string; readonly networkGatewayIp: string; + /** Qualified host listener used by Portable sandboxes and provider probes. */ + readonly networkListenerIp?: string; /** Digest of the exact inspected bridge configuration and ownership labels. */ readonly networkAuthoritySha256: string; } @@ -126,6 +130,8 @@ export type HostLocalInferenceRuntimeAuthority = readonly specSha256: string; /** Digest of the exact provider-translated create argv recorded by the engine. */ readonly launchSha256?: string; + /** Provider-native digest of the exact model placed inside managed Ollama. */ + readonly modelDigest?: string; /** * Declarative model identity for runtimes that bind one verified local * artifact. Host paths and executor-only filesystem identity never enter @@ -433,7 +439,17 @@ function normalizeEndpoint( exactKeys( endpoint, proofReceipt - ? ["host", "networkAuthoritySha256", "networkGatewayIp", "networkId", "networkName", "port"] + ? "networkListenerIp" in endpoint + ? [ + "host", + "networkAuthoritySha256", + "networkGatewayIp", + "networkId", + "networkListenerIp", + "networkName", + "port", + ] + : ["host", "networkAuthoritySha256", "networkGatewayIp", "networkId", "networkName", "port"] : ["host", "networkName", "port"], "endpoint authority", ); @@ -447,6 +463,9 @@ function normalizeEndpoint( ...common, networkId: exactText(endpoint.networkId, SHA256, "endpoint network identity"), networkGatewayIp: exactIpv4(endpoint.networkGatewayIp, "endpoint network gateway"), + ...("networkListenerIp" in endpoint + ? { networkListenerIp: exactIpv4(endpoint.networkListenerIp, "endpoint network listener") } + : {}), networkAuthoritySha256: exactText( endpoint.networkAuthoritySha256, SHA256, @@ -522,21 +541,32 @@ function normalizeRuntime( runtime, service === "llama-cpp" ? ["gpu", "imageRef", "kind", "model", "name", "probeImageRef", "runtimeId", "specSha256"] - : proofReceipt + : service === "ollama" ? [ "gpu", "imageRef", "kind", "launchSha256", + "modelDigest", "name", "probeImageRef", "runtimeId", "specSha256", ] - : ["gpu", "imageRef", "kind", "name", "probeImageRef", "runtimeId", "specSha256"], + : proofReceipt + ? [ + "gpu", + "imageRef", + "kind", + "launchSha256", + "name", + "probeImageRef", + "runtimeId", + "specSha256", + ] + : ["gpu", "imageRef", "kind", "name", "probeImageRef", "runtimeId", "specSha256"], "container authority", ); - if (service === "ollama") fail("Ollama must use host-process authority"); const gpu = exactRecord(runtime.gpu, "GPU authority"); exactKeys( gpu, @@ -597,6 +627,9 @@ function normalizeRuntime( ), }), ...(model ? { model } : {}), + ...(service === "ollama" + ? { modelDigest: exactText(runtime.modelDigest, SHA256_DIGEST, "Ollama model digest") } + : {}), gpu: normalizedGpu, }); } @@ -648,10 +681,13 @@ export function normalizeHostLocalInferenceReceipt(value: unknown): HostLocalInf fail("provider identity does not match engine authority"); } const publication = proofReceipt ? normalizePublicationAuthority(receipt.publication) : undefined; + const runtime = normalizeRuntime(service, receipt.runtime, proofReceipt); if ( publication !== undefined && - ((service === "ollama" && publication.priorState !== "host-process") || - ((service === "nim" || service === "vllm") && publication.priorState === "host-process")) + ((service === "ollama" && + runtime.kind === "host" && + publication.priorState !== "host-process") || + (runtime.kind === "container" && publication.priorState === "host-process")) ) { fail("receipt publication prior state does not match the service lifecycle"); } @@ -661,7 +697,7 @@ export function normalizeHostLocalInferenceReceipt(value: unknown): HostLocalInf service, engineAuthority, endpoint: normalizeEndpoint(receipt.endpoint, proofReceipt), - runtime: normalizeRuntime(service, receipt.runtime, proofReceipt), + runtime, ...(!proofReceipt ? {} : { diff --git a/src/lib/onboard/runtime-provider/podman-host-local-inference-ollama.test.ts b/src/lib/onboard/runtime-provider/podman-host-local-inference-ollama.test.ts new file mode 100644 index 00000000000..36592dd98f5 --- /dev/null +++ b/src/lib/onboard/runtime-provider/podman-host-local-inference-ollama.test.ts @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + createPodmanHostLocalInferenceTestHarness, + throwAfterPodmanEvent, +} from "../../../../test/helpers/podman-host-local-inference-test-harness"; +import { + type HostLocalManagedInferenceInput, + normalizeHostLocalInferenceReceipt, +} from "./host-local-inference"; +import { prepareHostLocalInferenceStartup } from "./host-local-inference-routing"; +import { createPodmanHostLocalInferenceOperation } from "./podman-host-local-inference"; + +const OLLAMA_MODEL_SIZE = 8 * 1024 ** 3; +const OLLAMA_MODEL_DIGEST = "7".repeat(64); + +function managedOllamaFixture( + options: { + readonly externalNetwork?: boolean; + readonly externalListenerIp?: string; + readonly inputListenerIp?: string; + } = {}, +) { + const harness = createPodmanHostLocalInferenceTestHarness(); + const assertCurrent = vi.fn(); + const externalListenerIp = options.externalListenerIp ?? "10.89.0.2"; + const inputListenerIp = options.inputListenerIp ?? externalListenerIp; + const operation = createPodmanHostLocalInferenceOperation({ + engine: harness.engine, + env: harness.env, + acceleration: harness.operationAcceleration, + authorityStore: harness.authorityStore, + routeAuthorityStore: harness.routeAuthorityStore, + ...(options.externalNetwork === false + ? {} + : { + externalNetwork: { + networkId: harness.input.networkId, + name: harness.input.networkName, + subnet: "10.89.0.0/24", + gatewayIp: harness.input.networkGatewayIp, + listenerIp: externalListenerIp, + authoritySha256: "8".repeat(64), + assertCurrent, + }, + }), + onFailureEvidence: harness.onFailureEvidence, + redactSensitive: harness.redactSensitive, + }); + const input = { + ...harness.input, + service: "ollama" as const, + containerName: "nemoclaw-hermes-ollama", + containerPort: 11434, + imageRef: `docker.io/ollama/ollama@sha256:${"1".repeat(64)}`, + environment: [], + model: "qwen3-vl:4b", + networkListenerIp: inputListenerIp, + hostPort: 11434, + } as HostLocalManagedInferenceInput; + harness.state.ollamaPsModels = [ + { + name: input.model, + model: input.model, + size: OLLAMA_MODEL_SIZE, + size_vram: OLLAMA_MODEL_SIZE, + digest: OLLAMA_MODEL_DIGEST, + }, + ]; + return { assertCurrent, harness, input, operation }; +} + +function prepareManagedOllama(fixture: ReturnType) { + return prepareHostLocalInferenceStartup(fixture.operation, { + application: "hermes", + service: "ollama", + managed: fixture.input, + receiptWriter: fixture.harness.writer, + }); +} + +describe("Podman managed Ollama lifecycle", () => { + it.each([ + ["wildcard", "0.0.0.0"], + ["alternate", "10.89.0.2"], + ])("rejects a %s listener without external network authority (#9596)", (_name, listenerIp) => { + const fixture = managedOllamaFixture({ externalNetwork: false, inputListenerIp: listenerIp }); + + expect(() => prepareManagedOllama(fixture)).toThrow( + "listener requires exact external network authority", + ); + expect(fixture.harness.events.some((event) => event.startsWith("podman:run "))).toBe(false); + }); + + it.each([ + ["wildcard", "0.0.0.0"], + ["network", "10.89.0.0"], + ["broadcast", "10.89.0.255"], + ["multicast", "224.0.0.1"], + ])("rejects a %s external listener before Podman run (#9596)", (_name, listenerIp) => { + const fixture = managedOllamaFixture({ + externalListenerIp: listenerIp, + inputListenerIp: listenerIp, + }); + + expect(() => prepareManagedOllama(fixture)).toThrow("exact unicast host address"); + expect(fixture.harness.events.some((event) => event.startsWith("podman:run "))).toBe(false); + }); + + it("rejects listener drift from external network authority before Podman run (#9596)", () => { + const fixture = managedOllamaFixture({ + externalListenerIp: "10.89.0.3", + inputListenerIp: "10.89.0.2", + }); + + expect(() => prepareManagedOllama(fixture)).toThrow( + "network listener changed after qualification", + ); + expect(fixture.harness.events.some((event) => event.startsWith("podman:run "))).toBe(false); + }); + + it("reports model acquisition failure before trailing authority drift (#9596)", () => { + const fixture = managedOllamaFixture(); + fixture.harness.state.ollamaPullFailure = "model pull failed"; + fixture.assertCurrent.mockImplementation(() => + throwAfterPodmanEvent(fixture.harness.events, "ollama pull", "trailing authority drift"), + ); + + expect(() => prepareManagedOllama(fixture)).toThrow("managed Ollama model acquisition"); + }); + + it("creates and rolls back a receipt-owned runtime for fresh Portable Hermes (#9596)", () => { + const fixture = managedOllamaFixture(); + const { assertCurrent, harness, input } = fixture; + const route = prepareManagedOllama(fixture); + const prepared = route.prepared; + + expect(route.applicationBaseUrl).toBe("https://inference.local/v1"); + expect(route.gatewayProviderBaseUrl).toBe("http://host.openshell.internal:11434/v1"); + expect(prepared.receipt).toMatchObject({ + providerId: "podman", + service: "ollama", + endpoint: { + networkId: harness.input.networkId, + networkName: harness.input.networkName, + networkGatewayIp: harness.input.networkGatewayIp, + networkListenerIp: "10.89.0.2", + networkAuthoritySha256: "8".repeat(64), + }, + inference: { model: "qwen3-vl:4b" }, + runtime: { + kind: "container", + runtimeId: "a".repeat(64), + name: "nemoclaw-hermes-ollama", + imageRef: input.imageRef, + }, + }); + expect(() => + normalizeHostLocalInferenceReceipt({ + ...prepared.receipt, + runtime: { ...prepared.receipt.runtime, modelDigest: undefined }, + }), + ).toThrow("Ollama model digest is malformed"); + expect(assertCurrent).toHaveBeenCalled(); + expect(harness.events).toContainEqual( + expect.stringContaining("--publish 10.89.0.2:11434:11434"), + ); + const ready = harness.events.findIndex((event) => event.includes("/api/tags")); + const pull = harness.events.findIndex((event) => event.includes("ollama pull qwen3-vl:4b")); + const inference = harness.events.findIndex((event) => event.includes("/v1/chat/completions")); + const placement = harness.events.findIndex((event) => event.includes("/api/ps")); + expect(ready).toBeGreaterThanOrEqual(0); + expect(pull).toBeGreaterThan(ready); + expect(inference).toBeGreaterThan(pull); + expect(placement).toBeGreaterThan(inference); + expect(prepared.rollback()).toMatchObject({ status: "removed", priorState: "absent" }); + expect(harness.container()).toBeNull(); + expect(harness.written).toHaveLength(0); + }); +}); diff --git a/src/lib/onboard/runtime-provider/podman-host-local-inference.ts b/src/lib/onboard/runtime-provider/podman-host-local-inference.ts index d0c759a5a0d..6f0da3aaf56 100644 --- a/src/lib/onboard/runtime-provider/podman-host-local-inference.ts +++ b/src/lib/onboard/runtime-provider/podman-host-local-inference.ts @@ -45,6 +45,7 @@ import { import { translatePodmanLocalInferenceArgs } from "./podman-inference-args"; import { type PodmanInferenceAuthorityReceipt, + type PodmanInferenceQualificationOptions, qualifyPodmanInferenceAuthority, revalidatePodmanInferenceAuthority, } from "./podman-preflight"; @@ -94,8 +95,10 @@ const AT_REST_STATES = new Set(["configured", "created", "dead", "exited", "stop const PROBE_TIMEOUT_MS = 30_000; const READY_PROBE_TIMEOUT_MS = 240_000; const MUTATION_TIMEOUT_MS = 60_000; +const OLLAMA_MODEL_PULL_TIMEOUT_MS = 30 * 60_000; const STOP_GRACE_SECONDS = 30; const SECRET_ENVIRONMENT_BY_SERVICE = Object.freeze({ + ollama: new Set(), nim: new Set(["NGC_API_KEY", "NIM_NGC_API_KEY"]), // B4-D qualifies only loopback, gateway-controlled, unauthenticated vLLM. vllm: new Set(), @@ -108,6 +111,8 @@ export interface PodmanHostLocalInferenceRuntimeOptions { readonly authorityStore: PersistedEngineAuthorityStore; readonly routeAuthorityStore: HostLocalInferenceRouteAuthorityStore; readonly authority: PodmanInferenceAuthorityReceipt; + readonly authorityQualification?: PodmanInferenceQualificationOptions; + readonly externalNetwork?: PodmanExternalInferenceNetworkAuthority; /** Immutable accepted acceleration scope for this one operation. */ readonly operationAcceleration?: HostLocalOllamaAccelerationAuthority; readonly onFailureEvidence: (evidence: PodmanInferenceFailureEvidence) => void; @@ -120,6 +125,11 @@ export interface PodmanHostLocalInferenceOperationOptions { readonly env: NodeJS.ProcessEnv; /** Accepted acceleration scope for this one operation. */ readonly acceleration?: HostLocalOllamaAccelerationAuthority; + /** Prequalified product network whose host listener is not its IPAM gateway. */ + readonly externalNetwork?: PodmanExternalInferenceNetworkAuthority; + /** Product-specific exact authority when the generic Podman 6 discovery path is unavailable. */ + readonly authority?: PodmanInferenceAuthorityReceipt; + readonly authorityQualification?: PodmanInferenceQualificationOptions; readonly authorityStore: PersistedEngineAuthorityStore; readonly routeAuthorityStore: HostLocalInferenceRouteAuthorityStore; readonly onFailureEvidence: (evidence: PodmanInferenceFailureEvidence) => void; @@ -128,6 +138,20 @@ export interface PodmanHostLocalInferenceOperationOptions { export type PodmanInferenceRedactor = (value: string) => string; +export interface PodmanExternalInferenceNetworkAuthority { + /** + * The caller's fresh assertion and the pinned identity, addressing, and listener below replace + * provider-owned network labels as the trust anchor, so assertCurrent must prove them exactly. + */ + readonly networkId: string; + readonly name: string; + readonly subnet: string; + readonly gatewayIp: string; + readonly listenerIp: string; + readonly authoritySha256: string; + readonly assertCurrent: () => void; +} + function normalizeOperationAcceleration(value: unknown): HostLocalOllamaAccelerationAuthority { if (value === undefined) return "nvidia-gpu"; if (value === "cpu" || value === "nvidia-gpu") return value; @@ -149,7 +173,7 @@ export interface PodmanInferenceFailureEvidence { } interface ManagedSpec { - readonly service: "nim" | "vllm"; + readonly service: "ollama" | "nim" | "vllm"; readonly containerName: string; readonly containerPort: number; readonly imageRef: string; @@ -191,7 +215,7 @@ type OllamaHostRuntimeAuthority = Extract< >; type ManagedReceipt = HostLocalInferenceReceipt & { - readonly service: "nim" | "vllm"; + readonly service: "ollama" | "nim" | "vllm"; readonly runtime: ManagedContainerRuntime; readonly inference: HostLocalInferenceProofAuthority; readonly publication: HostLocalInferencePublicationAuthority; @@ -568,11 +592,15 @@ function inspectProviderNetwork( authority: PodmanInferenceAuthorityReceipt, expected: Pick< HostLocalInferenceEndpointInput, - "networkGatewayIp" | "networkId" | "networkName" + "networkGatewayIp" | "networkId" | "networkListenerIp" | "networkName" > & { readonly networkAuthoritySha256?: string; }, + external?: PodmanExternalInferenceNetworkAuthority, ): PodmanInferenceNetworkAuthority { + if (!external && expected.networkListenerIp !== undefined) { + throw new Error("Podman inference listener requires exact external network authority."); + } const expectedId = exactText(expected.networkId, FULL_NETWORK_ID, "Inference network identity"); const expectedName = exactNetworkName(expected.networkName, "Inference network name"); const expectedGateway = exactIpv4(expected.networkGatewayIp, "Inference network gateway"); @@ -608,14 +636,73 @@ function inspectProviderNetwork( const subnet = record(network.subnets[0], "Podman inference network subnet"); const gatewayIp = exactIpv4(subnet.gateway, "Inspected inference network gateway"); const subnetCidr = exactIpv4Subnet(subnet.subnet); - if (gatewayIp !== expectedGateway) { + const externalListenerIp = external + ? exactIpv4(external.listenerIp, "External inference listener") + : null; + if (externalListenerIp !== null) { + const toNumber = (address: string): number => + address + .split(".") + .map(Number) + .reduce((total, octet) => (total * 256 + octet) >>> 0, 0); + const separator = subnetCidr.lastIndexOf("/"); + const subnetAddress = subnetCidr.slice(0, separator); + const prefix = Number(subnetCidr.slice(separator + 1)); + const mask = prefix === 32 ? 0xffffffff : (0xffffffff << (32 - prefix)) >>> 0; + const listener = toNumber(externalListenerIp); + const network = (toNumber(subnetAddress) & mask) >>> 0; + const broadcast = (network | (~mask >>> 0)) >>> 0; + const firstOctet = Number(externalListenerIp.slice(0, externalListenerIp.indexOf("."))); + if ( + firstOctet === 0 || + firstOctet >= 224 || + (listener & mask) >>> 0 !== network || + listener === network || + listener === broadcast || + externalListenerIp === gatewayIp + ) { + throw new Error("External inference listener must be an exact unicast host address."); + } + } + const externalNetwork = external + ? Object.freeze({ + networkId: exactText(external.networkId, FULL_NETWORK_ID, "External network identity"), + name: exactNetworkName(external.name, "External inference network name"), + subnet: exactIpv4Subnet(external.subnet), + gatewayIp: exactIpv4(external.gatewayIp, "External inference network gateway"), + listenerIp: externalListenerIp!, + authoritySha256: exactText( + external.authoritySha256, + SHA256, + "External inference network authority digest", + ), + }) + : null; + external?.assertCurrent(); + if ( + externalNetwork + ? externalNetwork.networkId !== expectedId || + externalNetwork.name !== expectedName || + externalNetwork.subnet !== subnetCidr || + externalNetwork.gatewayIp !== gatewayIp || + expectedGateway !== gatewayIp + : gatewayIp !== expectedGateway + ) { throw new Error("Podman inference network gateway changed after qualification."); } + if ( + externalNetwork && + externalNetwork.listenerIp !== + exactIpv4(expected.networkListenerIp, "Inference network listener") + ) { + throw new Error("Podman inference network listener changed after qualification."); + } const labels = sortedStringRecord(network.labels, "Inference network labels"); if ( - labels[PODMAN_INFERENCE_NETWORK_MANAGED_LABEL] !== "true" || - labels[PODMAN_INFERENCE_NETWORK_PROVIDER_LABEL] !== PROVIDER_ID || - labels[PODMAN_INFERENCE_NETWORK_ENGINE_AUTHORITY_LABEL] !== authority.receiptSha256 + !externalNetwork && + (labels[PODMAN_INFERENCE_NETWORK_MANAGED_LABEL] !== "true" || + labels[PODMAN_INFERENCE_NETWORK_PROVIDER_LABEL] !== PROVIDER_ID || + labels[PODMAN_INFERENCE_NETWORK_ENGINE_AUTHORITY_LABEL] !== authority.receiptSha256) ) { throw new Error("Podman inference network lacks exact provider ownership authority."); } @@ -636,8 +723,9 @@ function inspectProviderNetwork( labels, ipamOptions: sortedStringRecord(network.ipam_options, "Inference network IPAM options"), options: sortedStringRecord(network.options, "Inference network options"), + ...(externalNetwork ? { external: externalNetwork } : {}), }); - const authoritySha256 = digest(canonical); + const authoritySha256 = externalNetwork?.authoritySha256 ?? digest(canonical); if ( expected.networkAuthoritySha256 !== undefined && exactText(expected.networkAuthoritySha256, SHA256, "Inference network authority digest") !== @@ -645,7 +733,12 @@ function inspectProviderNetwork( ) { throw new Error("Podman inference network authority changed after qualification."); } - return Object.freeze({ id, name, gatewayIp, authoritySha256 }); + return Object.freeze({ + id, + name, + gatewayIp, + authoritySha256, + }); } function requireProofEndpoint( @@ -714,8 +807,8 @@ function normalizeManagedSpec( network: PodmanInferenceNetworkAuthority, priorState: "absent" | "running" | "stopped", ): ManagedSpec { - if (input.service !== "nim" && input.service !== "vllm") { - throw new Error("Podman managed inference supports NIM or vLLM containers."); + if (input.service !== "ollama" && input.service !== "nim" && input.service !== "vllm") { + throw new Error("Podman managed inference supports Ollama, NIM, or vLLM containers."); } const writer = requireWriter(writerValue); const containerName = exactText(input.containerName, SAFE_NAME, "Inference container name"); @@ -761,7 +854,10 @@ function normalizeManagedSpec( throw new Error("Podman host-local inference requires a private IPC namespace."); } const command = normalizedArguments(input.command ?? [], "Inference command arguments"); - const model = exactText(input.model, /^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,511}$/u, "Inference model"); + const model = + input.service === "ollama" + ? normalizeHostLocalOllamaModelRef(input.model) + : exactText(input.model, /^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,511}$/u, "Inference model"); if (typeof input.requireToolCalling !== "boolean") { throw new Error("Inference tool-calling requirement must be a boolean."); } @@ -771,6 +867,7 @@ function normalizeManagedSpec( networkName, networkId: network.id, networkGatewayIp: network.gatewayIp, + ...(input.networkListenerIp ? { networkListenerIp: input.networkListenerIp } : {}), networkAuthoritySha256: network.authoritySha256, }); const canonical = { @@ -805,7 +902,7 @@ function normalizeManagedSpec( } function managedAuthorityDigest(input: { - readonly service: "nim" | "vllm"; + readonly service: "ollama" | "nim" | "vllm"; readonly endpoint: HostLocalInferenceEndpointAuthority; readonly name: string; readonly imageRef: string; @@ -863,7 +960,7 @@ function managedSpecAuthorityDigest(spec: ManagedSpec): string { function managedReceiptAuthorityDigest(receipt: HostLocalInferenceReceipt): string { if ( receipt.runtime.kind !== "container" || - (receipt.service !== "nim" && receipt.service !== "vllm") || + (receipt.service !== "ollama" && receipt.service !== "nim" && receipt.service !== "vllm") || receipt.inference === undefined || receipt.publication === undefined || !("devices" in receipt.runtime.gpu) @@ -910,7 +1007,7 @@ function receiptProbeParent(receipt: HostLocalInferenceReceipt): ProbeParentAuth transactionId: receipt.publication.transactionId, receiptTargetSha256: receipt.publication.targetSha256, parentAuthoritySha256: - receipt.service === "ollama" + receipt.service === "ollama" && receipt.runtime.kind === "host" ? ollamaRouteAuthority(receipt).receiptSha256 : managedReceiptAuthorityDigest(receipt), }); @@ -1184,7 +1281,7 @@ function requireManagedIdentity( readonly runtimeId: string; readonly name: string; readonly imageRef: string; - readonly service: "nim" | "vllm"; + readonly service: "ollama" | "nim" | "vllm"; readonly specSha256: string; readonly authoritySha256: string; readonly transactionId: string; @@ -1271,7 +1368,7 @@ function requireReceiptIdentity( ): ManagedContainer { if ( receipt.runtime.kind !== "container" || - (receipt.service !== "nim" && receipt.service !== "vllm") + (receipt.service !== "ollama" && receipt.service !== "nim" && receipt.service !== "vllm") ) { throw new Error("Podman managed inference requires a container receipt."); } @@ -1313,6 +1410,7 @@ function receiptFor( authority: PersistedEngineAuthority, spec: ManagedSpec, runtimeId: string, + modelDigest?: string, ): HostLocalInferenceReceipt { return normalizeHostLocalInferenceReceipt({ schemaVersion: 2, @@ -1338,12 +1436,22 @@ function receiptFor( probeImageRef: spec.probeImageRef, specSha256: spec.specSha256, launchSha256: spec.launchSha256, + ...(spec.service === "ollama" + ? { + modelDigest: exactText( + modelDigest, + SHA256_DIGEST, + "Podman managed Ollama model digest", + ), + } + : {}), gpu: { vendor: "nvidia", devices: spec.gpuDevices }, }, }); } function runArguments(spec: ManagedSpec): readonly string[] { + const listenerIp = spec.endpoint.networkListenerIp ?? spec.endpoint.networkGatewayIp; const args = [ "run", "--detach", @@ -1374,7 +1482,7 @@ function runArguments(spec: ManagedSpec): readonly string[] { "--publish", `127.0.0.1:${String(spec.endpoint.port)}:${String(spec.containerPort)}`, "--publish", - `${spec.endpoint.networkGatewayIp}:${String(spec.endpoint.port)}:${String(spec.containerPort)}`, + `${listenerIp}:${String(spec.endpoint.port)}:${String(spec.containerPort)}`, ]; for (const device of spec.gpuDevices) args.push("--device", device); for (const name of spec.environment) args.push("--env", name); @@ -1389,7 +1497,7 @@ function translatedRunArguments( authority: PodmanInferenceAuthorityReceipt, ): readonly string[] { return translatePodmanLocalInferenceArgs(runArguments(spec), authority, { - allowedPublishAddresses: [spec.endpoint.networkGatewayIp], + allowedPublishAddresses: [spec.endpoint.networkListenerIp ?? spec.endpoint.networkGatewayIp], }); } @@ -1439,7 +1547,10 @@ function requireLaunchIdentity( throw new Error("Podman inference runtime launch arguments drifted from durable authority."); } const publishes = repeatedOptionValues(container.createArguments, "--publish"); - const expectedListeners = ["127.0.0.1", endpoint.networkGatewayIp].sort(); + const expectedListeners = [ + "127.0.0.1", + endpoint.networkListenerIp ?? endpoint.networkGatewayIp, + ].sort(); const parsedPublishes = publishes.map((mapping) => { const fields = mapping.split(":"); if (fields.length !== 3) throw new Error("Podman inference launch publish mapping is invalid."); @@ -1915,7 +2026,9 @@ function probeOllamaReady( endpoint, probeImageRef, parent, - [`http://${endpoint.networkGatewayIp}:${String(endpoint.port)}/api/tags`], + [ + `http://${endpoint.networkListenerIp ?? endpoint.networkGatewayIp}:${String(endpoint.port)}/api/tags`, + ], authorityReceipt, ); executeExactProbe( @@ -1954,7 +2067,9 @@ function probeOllamaAcceleration( endpoint, probeImageRef, parent, - [`http://${endpoint.networkGatewayIp}:${String(endpoint.port)}/api/ps`], + [ + `http://${endpoint.networkListenerIp ?? endpoint.networkGatewayIp}:${String(endpoint.port)}/api/ps`, + ], authorityReceipt, ); let observed: OllamaModelPlacementAuthority | null = null; @@ -2034,7 +2149,12 @@ function probeManagedReady( onFailureEvidence: (evidence: PodmanInferenceFailureEvidence) => void, redactor: PodmanInferenceRedactor, ): void { - const healthPath = spec.service === "nim" ? "/v1/health/ready" : "/health"; + const healthPath = + spec.service === "ollama" + ? "/api/tags" + : spec.service === "nim" + ? "/v1/health/ready" + : "/health"; const probe = createProbeSpec( spec.service, "ready", @@ -2049,7 +2169,7 @@ function probeManagedReady( "--retry-max-time", "220", "--retry-connrefused", - `http://${spec.endpoint.networkGatewayIp}:${String(spec.endpoint.port)}${healthPath}`, + `http://${spec.endpoint.networkListenerIp ?? spec.endpoint.networkGatewayIp}:${String(spec.endpoint.port)}${healthPath}`, ], authorityReceipt, ); @@ -2097,6 +2217,21 @@ function proveManagedGpu( } } +function pullManagedOllamaModel( + engine: ContainerEngine, + authority: () => void, + runtimeId: string, + model: string, +): void { + authority(); + const result = engine.capture( + ["exec", runtimeId, "ollama", "pull", normalizeHostLocalOllamaModelRef(model)], + OLLAMA_MODEL_PULL_TIMEOUT_MS, + ); + requireSuccess("managed Ollama model acquisition", result); + authority(); +} + function probeOpenAiInference( engine: ContainerEngine, authorityReceipt: PodmanInferenceAuthorityReceipt, @@ -2143,7 +2278,7 @@ function probeOpenAiInference( "Content-Type: application/json", "--data-binary", body, - `http://${proofEndpoint.networkGatewayIp}:${String(proofEndpoint.port)}/v1/chat/completions`, + `http://${proofEndpoint.networkListenerIp ?? proofEndpoint.networkGatewayIp}:${String(proofEndpoint.port)}/v1/chat/completions`, ], authorityReceipt, ); @@ -2555,7 +2690,17 @@ export function createPodmanHostLocalInferenceRuntime( operationEnv, ); const engine = redactingEngine(options.engine, sensitiveRedactor); - const { authorityStore, routeAuthorityStore, authority, onFailureEvidence } = options; + const { + authorityStore, + routeAuthorityStore, + authority, + authorityQualification, + onFailureEvidence, + } = options; + const inspectNetwork = ( + expected: Parameters[2], + ): PodmanInferenceNetworkAuthority => + inspectProviderNetwork(engine, authority, expected, options.externalNetwork); if (engine.operation !== "host-local-inference" || engine.engineId !== PROVIDER_ID) { throw new Error("Podman host-local inference requires an operation-scoped Podman engine."); } @@ -2591,7 +2736,9 @@ export function createPodmanHostLocalInferenceRuntime( requireAccelerationAuthority(authority); const assertAuthority = () => { - requireAccelerationAuthority(revalidatePodmanInferenceAuthority(engine, authority)); + requireAccelerationAuthority( + revalidatePodmanInferenceAuthority(engine, authority, authorityQualification), + ); }; const currentAuthority = () => createPersistedEngineAuthority(PROVIDER_ID, engine, authority.receiptSha256); @@ -2621,7 +2768,7 @@ export function createPodmanHostLocalInferenceRuntime( throw new Error("Host-local inference receipt belongs to another runtime provider."); } if (normalized.runtime.kind === "container" && operationAcceleration !== "nvidia-gpu") { - throw new Error("Podman managed NIM and vLLM require NVIDIA GPU operation authority."); + throw new Error("Podman managed inference services require NVIDIA GPU operation authority."); } if ( normalized.runtime.kind === "host" && @@ -2640,20 +2787,26 @@ export function createPodmanHostLocalInferenceRuntime( engine, authority.receiptSha256, ); - if (normalized.service === "ollama" && requireRouteAuthority) { + if ( + normalized.service === "ollama" && + normalized.runtime.kind === "host" && + requireRouteAuthority + ) { requireOllamaRouteAuthority( routeAuthorityStore.load("ollama"), ollamaRouteAuthority(normalized), ); } - inspectProviderNetwork(engine, authority, endpoint); + inspectNetwork(endpoint); return normalized; }; const inspectReceipt = (receipt: HostLocalInferenceReceipt) => { const normalized = authorizeReceipt(receipt); if ( normalized.runtime.kind !== "container" || - (normalized.service !== "nim" && normalized.service !== "vllm") || + (normalized.service !== "ollama" && + normalized.service !== "nim" && + normalized.service !== "vllm") || normalized.inference === undefined || normalized.publication === undefined || !("devices" in normalized.runtime.gpu) @@ -2674,7 +2827,7 @@ export function createPodmanHostLocalInferenceRuntime( const endpoint = requireProofEndpoint(normalized.endpoint); const assertReceiptAuthority = () => { assertAuthority(); - inspectProviderNetwork(engine, authority, endpoint); + inspectNetwork(endpoint); }; if (normalized.inference === undefined) { throw new Error("Podman inference receipt lacks real-inference authority."); @@ -2725,7 +2878,7 @@ export function createPodmanHostLocalInferenceRuntime( throw new Error("Podman managed inference validation requires a running runtime."); } const service = inspected.receipt.service; - if (service !== "nim" && service !== "vllm") { + if (service !== "ollama" && service !== "nim" && service !== "vllm") { throw new Error("Podman managed inference validation has an unsupported service."); } const spec = { @@ -2766,6 +2919,21 @@ export function createPodmanHostLocalInferenceRuntime( onFailureEvidence, sensitiveRedactor, ); + if (service === "ollama") { + probeOllamaAcceleration( + engine, + authority, + assertReceiptAuthority, + spec.endpoint, + spec.probeImageRef, + spec.model, + "nvidia-gpu", + inspected.receipt.runtime.modelDigest ?? null, + receiptProbeParent(inspected.receipt), + onFailureEvidence, + sensitiveRedactor, + ); + } assertReceiptAuthority(); return normalized; }; @@ -2776,11 +2944,11 @@ export function createPodmanHostLocalInferenceRuntime( recoveryOnly: boolean, ): HostLocalInferencePreparedStartup => { if (operationAcceleration !== "nvidia-gpu") { - throw new Error("Podman managed NIM and vLLM require NVIDIA GPU operation authority."); + throw new Error("Podman managed inference services require NVIDIA GPU operation authority."); } const writer = requireWriter(writerValue); const persisted = authorize(true); - const network = inspectProviderNetwork(engine, authority, input); + const network = inspectNetwork(input); const containerName = exactText(input.containerName, SAFE_NAME, "Inference container name"); const existingId = lookupContainerId(engine, containerName); if (existingId === null && recoveryOnly) { @@ -2804,7 +2972,7 @@ export function createPodmanHostLocalInferenceRuntime( const spec = normalizeManagedSpec(input, writer, authority, network, priorState); const assertSpecAuthority = () => { assertAuthority(); - inspectProviderNetwork(engine, authority, spec.endpoint); + inspectNetwork(spec.endpoint); }; const secretEnvironment = managedSecretEnvironment(spec, operationEnv); requireSecretFreeCommand(spec.command, secretEnvironment, sensitiveRedactor); @@ -2868,6 +3036,9 @@ export function createPodmanHostLocalInferenceRuntime( onFailureEvidence, sensitiveRedactor, ); + if (spec.service === "ollama") { + pullManagedOllamaModel(engine, assertSpecAuthority, container.runtimeId, spec.model); + } phase = "gpu"; proveManagedGpu(engine, assertSpecAuthority, container.runtimeId, spec.gpuDevices); phase = "inference"; @@ -2884,8 +3055,24 @@ export function createPodmanHostLocalInferenceRuntime( onFailureEvidence, sensitiveRedactor, ); + const placement = + spec.service === "ollama" + ? probeOllamaAcceleration( + engine, + authority, + assertSpecAuthority, + spec.endpoint, + spec.probeImageRef, + spec.model, + "nvidia-gpu", + null, + managedSpecProbeParent(spec), + onFailureEvidence, + sensitiveRedactor, + ) + : null; assertSpecAuthority(); - return receiptFor(persisted, spec, container.runtimeId); + return receiptFor(persisted, spec, container.runtimeId, placement?.modelDigest); }, () => { rollbackExisting(); @@ -2978,6 +3165,9 @@ export function createPodmanHostLocalInferenceRuntime( onFailureEvidence, sensitiveRedactor, ); + if (spec.service === "ollama") { + pullManagedOllamaModel(engine, assertSpecAuthority, created.runtimeId, spec.model); + } phase = "gpu"; proveManagedGpu(engine, assertSpecAuthority, created.runtimeId, spec.gpuDevices); phase = "inference"; @@ -2994,8 +3184,24 @@ export function createPodmanHostLocalInferenceRuntime( onFailureEvidence, sensitiveRedactor, ); + const placement = + spec.service === "ollama" + ? probeOllamaAcceleration( + engine, + authority, + assertSpecAuthority, + spec.endpoint, + spec.probeImageRef, + spec.model, + "nvidia-gpu", + null, + managedSpecProbeParent(spec), + onFailureEvidence, + sensitiveRedactor, + ) + : null; assertSpecAuthority(); - return receiptFor(persisted, spec, created.runtimeId); + return receiptFor(persisted, spec, created.runtimeId, placement?.modelDigest); }, () => { if (created !== null) { @@ -3073,7 +3279,9 @@ export function createPodmanHostLocalInferenceRuntime( const normalizedReceipt = authorizeReceipt(receiptValue); if ( normalizedReceipt.runtime.kind !== "container" || - (normalizedReceipt.service !== "nim" && normalizedReceipt.service !== "vllm") || + (normalizedReceipt.service !== "ollama" && + normalizedReceipt.service !== "nim" && + normalizedReceipt.service !== "vllm") || normalizedReceipt.inference === undefined || normalizedReceipt.publication === undefined || !("devices" in normalizedReceipt.runtime.gpu) @@ -3082,7 +3290,7 @@ export function createPodmanHostLocalInferenceRuntime( } const receipt = normalizedReceipt as ManagedReceipt; const endpoint = requireProofEndpoint(receipt.endpoint); - const network = inspectProviderNetwork(engine, authority, input); + const network = inspectNetwork(input); const originalPriorState = receipt.publication.priorState; if ( originalPriorState !== "absent" && @@ -3108,6 +3316,7 @@ export function createPodmanHostLocalInferenceRuntime( exactNetworkName(input.networkName, "Inference network name") !== endpoint.networkName || input.networkId !== endpoint.networkId || input.networkGatewayIp !== endpoint.networkGatewayIp || + input.networkListenerIp !== endpoint.networkListenerIp || network.authoritySha256 !== endpoint.networkAuthoritySha256 || input.model !== receipt.inference.model || input.requireToolCalling !== receipt.inference.toolCallingRequired || @@ -3133,7 +3342,7 @@ export function createPodmanHostLocalInferenceRuntime( const priorState = wasRunning ? ("running" as const) : ("stopped" as const); const assertReceiptAuthority = () => { assertAuthority(); - inspectProviderNetwork(engine, authority, endpoint); + inspectNetwork(endpoint); container = requireReceiptIdentity( inspectContainer(engine, receipt.runtime.runtimeId), receipt, @@ -3210,6 +3419,21 @@ export function createPodmanHostLocalInferenceRuntime( onFailureEvidence, sensitiveRedactor, ); + if (receipt.service === "ollama") { + probeOllamaAcceleration( + engine, + authority, + assertReceiptAuthority, + endpoint, + receipt.runtime.probeImageRef, + receipt.inference.model, + "nvidia-gpu", + receipt.runtime.modelDigest ?? null, + receiptProbeParent(receipt), + onFailureEvidence, + sensitiveRedactor, + ); + } assertReceiptAuthority(); }, () => { @@ -3266,7 +3490,7 @@ export function createPodmanHostLocalInferenceRuntime( } const writer = requireWriter(writerValue); const persisted = authorize(true); - const network = inspectProviderNetwork(engine, authority, input); + const network = inspectNetwork(input); if (typeof input.requireToolCalling !== "boolean") { throw new Error("Ollama tool-calling requirement must be a boolean."); } @@ -3302,7 +3526,7 @@ export function createPodmanHostLocalInferenceRuntime( }); const assertOllamaAuthority = () => { assertAuthority(); - inspectProviderNetwork(engine, authority, endpoint); + inspectNetwork(endpoint); }; let phase: PodmanInferenceFailureEvidence["phase"] = "ready"; let placement: OllamaModelPlacementAuthority; @@ -3497,7 +3721,13 @@ export function createPodmanHostLocalInferenceOperation( const acceleration = normalizeOperationAcceleration(options.acceleration); const redactor = requireRedactor(options.redactSensitive); const qualifiedEngine = redactingEngine(options.engine, redactor); - const authority = qualifyPodmanInferenceAuthority(qualifiedEngine); + const authority = options.authority + ? revalidatePodmanInferenceAuthority( + qualifiedEngine, + options.authority, + options.authorityQualification, + ) + : qualifyPodmanInferenceAuthority(qualifiedEngine, options.authorityQualification); const runtime = createPodmanHostLocalInferenceRuntime({ ...options, engine: qualifiedEngine, @@ -3525,7 +3755,11 @@ export function createPodmanHostLocalInferenceOperation( engine: publicEngine, bindingSha256: authority.receiptSha256, assertAuthority: () => { - const refreshed = revalidatePodmanInferenceAuthority(qualifiedEngine, authority); + const refreshed = revalidatePodmanInferenceAuthority( + qualifiedEngine, + authority, + options.authorityQualification, + ); if (acceleration === "nvidia-gpu" && refreshed.cdiDevices.length === 0) { throw new Error( "Podman NVIDIA GPU operation authority requires at least one discovered NVIDIA CDI device.", diff --git a/src/lib/onboard/runtime-provider/podman-inference-args.ts b/src/lib/onboard/runtime-provider/podman-inference-args.ts index 20213d6a5d1..f5f298b7043 100644 --- a/src/lib/onboard/runtime-provider/podman-inference-args.ts +++ b/src/lib/onboard/runtime-provider/podman-inference-args.ts @@ -7,7 +7,7 @@ import path from "node:path"; import type { HostLocalOllamaAccelerationAuthority } from "./host-local-inference"; import { qualifyPodmanGpuAttachments } from "./podman-gpu"; import { - normalizePodmanInferenceAuthorityReceipt, + normalizeQualifiedPodmanInferenceAuthorityReceipt, type PodmanInferenceAuthorityReceipt, } from "./podman-preflight"; @@ -360,7 +360,7 @@ export function translatePodmanLocalInferenceArgs( if (source[0] !== "run") { throw new Error("Podman local inference translates only an explicit container run command."); } - const qualified = normalizePodmanInferenceAuthorityReceipt(authority); + const qualified = normalizeQualifiedPodmanInferenceAuthorityReceipt(authority); const acceleration = options.acceleration ?? "nvidia-gpu"; const allowedPublishAddresses = new Set(["127.0.0.1"]); for (const address of options.allowedPublishAddresses ?? []) { diff --git a/src/lib/onboard/runtime-provider/podman-preflight.test.ts b/src/lib/onboard/runtime-provider/podman-preflight.test.ts index b53efc21e50..1af7408b32a 100644 --- a/src/lib/onboard/runtime-provider/podman-preflight.test.ts +++ b/src/lib/onboard/runtime-provider/podman-preflight.test.ts @@ -8,6 +8,7 @@ import { inspectPodmanHost, isPodmanVersionSupported, normalizePodmanInferenceAuthorityReceipt, + normalizeQualifiedPodmanInferenceAuthorityReceipt, PodmanHostPreflightError, qualifyPodmanEndpointHost, qualifyPodmanHost, @@ -345,6 +346,75 @@ describe("Podman host-local-inference authority", () => { expect(runtime.capture).not.toHaveBeenCalledWith(["info", "--format", "json"], 15_000); }); + it("binds exact Portable Podman 5.7 to a fresh external CDI inventory (#9596)", () => { + const runtime = engine({ + operation: "host-local-inference", + version: "5.7.0", + serverVersion: "5.7.0", + }); + const cdiDevices = [ + "nvidia.com/gpu=GPU-12345678-1234-1234-1234-123456789abc", + "nvidia.com/gpu=all", + ]; + const captureCurrentCdiDevices = vi.fn(() => cdiDevices); + const options = { expectedVersion: "5.7.0", captureCurrentCdiDevices }; + + const receipt = qualifyPodmanInferenceAuthority(runtime, options); + + expect(receipt).toMatchObject({ serverVersion: "5.7.0", cdiDevices }); + expect(() => normalizePodmanInferenceAuthorityReceipt(receipt)).toThrow( + "inference authority receipt is malformed", + ); + expect(normalizeQualifiedPodmanInferenceAuthorityReceipt(receipt)).toMatchObject({ + serverVersion: "5.7.0", + }); + expect(() => normalizeQualifiedPodmanInferenceAuthorityReceipt({ ...receipt })).toThrow( + "inference authority receipt is malformed", + ); + expect(revalidatePodmanInferenceAuthority(runtime, receipt, options)).toEqual(receipt); + expect(captureCurrentCdiDevices).toHaveBeenCalledTimes(2); + }); + + it("rejects partial or changed Portable Podman 5.7 authority (#9596)", () => { + const runtime = engine({ + operation: "host-local-inference", + version: "5.7.0", + serverVersion: "5.7.0", + }); + expect(() => qualifyPodmanInferenceAuthority(runtime, { expectedVersion: "5.7.0" })).toThrow( + "version and CDI inventory must be supplied together", + ); + + const captureCurrentCdiDevices = vi + .fn<() => readonly string[]>() + .mockReturnValueOnce(["nvidia.com/gpu=all"]) + .mockReturnValueOnce([ + "nvidia.com/gpu=GPU-12345678-1234-1234-1234-123456789abc", + "nvidia.com/gpu=all", + ]); + const options = { expectedVersion: "5.7.0", captureCurrentCdiDevices }; + const receipt = qualifyPodmanInferenceAuthority(runtime, options); + + expect(() => revalidatePodmanInferenceAuthority(runtime, receipt, options)).toThrow( + "server or NVIDIA CDI authority changed before local-inference mutation", + ); + }); + + it("revalidates current authority when it is the only Podman 6 option (#9596)", () => { + const runtime = engine({ + operation: "host-local-inference", + version: "6.0.1", + serverVersion: "6.0.1", + }); + const receipt = qualifyPodmanInferenceAuthority(runtime); + const assertCurrentAuthority = vi.fn(); + + expect( + revalidatePodmanInferenceAuthority(runtime, receipt, { assertCurrentAuthority }), + ).toEqual(receipt); + expect(assertCurrentAuthority).toHaveBeenCalledTimes(2); + }); + it("treats Podman's omitted lowercase host.discoveredDevices field as exact empty authority", () => { const nestedOnly = JSON.stringify({ ...JSON.parse(INFO), diff --git a/src/lib/onboard/runtime-provider/podman-preflight.ts b/src/lib/onboard/runtime-provider/podman-preflight.ts index 9baa439754b..bd36121336b 100644 --- a/src/lib/onboard/runtime-provider/podman-preflight.ts +++ b/src/lib/onboard/runtime-provider/podman-preflight.ts @@ -19,6 +19,7 @@ const AUTHORITY_ID = /^[a-z][a-z0-9-]{0,62}:[A-Za-z0-9._:-]{1,255}$/u; const SHA256 = /^[a-f0-9]{64}$/u; const CDI_QUALIFIED_DEVICE = /^[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?\/[A-Za-z0-9][A-Za-z0-9._-]{0,62}=[A-Za-z0-9][A-Za-z0-9_.:/-]{0,255}$/u; +const PRODUCT_QUALIFIED_INFERENCE_AUTHORITY = Symbol("product-qualified-inference-authority"); export interface PodmanHostPreflightReceipt { readonly providerId: "podman"; @@ -58,6 +59,18 @@ export interface PodmanEndpointHostQualificationOptions extends PodmanHostPrefli readonly expectedNetworkBackend: string; } +export interface PodmanInferenceQualificationOptions { + /** Exact server version accepted by a product-specific runtime matrix. */ + readonly expectedVersion?: string; + /** + * Fresh authoritative CDI capture for a product-specific runtime matrix + * whose Podman release cannot report its inventory through `podman info`. + */ + readonly captureCurrentCdiDevices?: (engine: ContainerEngine) => readonly string[]; + /** Product-owned authority matrix that must stay current around every qualification. */ + readonly assertCurrentAuthority?: () => void; +} + export class PodmanHostPreflightError extends Error { constructor(message: string) { super(`Podman preflight failed: ${message}`); @@ -392,8 +405,9 @@ function inferenceAuthorityDigest( return createHash("sha256").update(JSON.stringify(payload), "utf8").digest("hex"); } -export function normalizePodmanInferenceAuthorityReceipt( +function normalizeInferenceAuthorityReceipt( value: unknown, + minimumVersion: string, ): PodmanInferenceAuthorityReceipt { const receipt = record(value); if ( @@ -408,7 +422,7 @@ export function normalizePodmanInferenceAuthorityReceipt( !AUTHORITY_ID.test(receipt.authorityId) || typeof receipt.serverVersion !== "string" || receipt.serverVersion !== receipt.serverVersion.trim() || - !isPodmanVersionSupported(receipt.serverVersion, MINIMUM_PODMAN_INFERENCE_VERSION) || + !isPodmanVersionSupported(receipt.serverVersion, minimumVersion) || receipt.rootless !== true || receipt.cgroupVersion !== "v2" || receipt.os !== "linux" || @@ -433,13 +447,51 @@ export function normalizePodmanInferenceAuthorityReceipt( return Object.freeze({ ...payload, receiptSha256 }); } +export function normalizePodmanInferenceAuthorityReceipt( + value: unknown, +): PodmanInferenceAuthorityReceipt { + return normalizeInferenceAuthorityReceipt(value, MINIMUM_PODMAN_INFERENCE_VERSION); +} + +export function normalizeQualifiedPodmanInferenceAuthorityReceipt( + value: unknown, +): PodmanInferenceAuthorityReceipt { + if ( + typeof value === "object" && + value !== null && + (value as { [PRODUCT_QUALIFIED_INFERENCE_AUTHORITY]?: unknown })[ + PRODUCT_QUALIFIED_INFERENCE_AUTHORITY + ] === true + ) { + return normalizeInferenceAuthorityReceipt(value, MINIMUM_PODMAN_VERSION); + } + return normalizePodmanInferenceAuthorityReceipt(value); +} + /** Qualify the exact endpoint's authoritative NVIDIA CDI inventory, including empty. */ export function qualifyPodmanInferenceAuthority( engine: ContainerEngine, + options: PodmanInferenceQualificationOptions = {}, ): PodmanInferenceAuthorityReceipt { + options.assertCurrentAuthority?.(); requireInferenceEngine(engine); - const { reportedVersion } = inspectServerVersion(engine, MINIMUM_PODMAN_INFERENCE_VERSION); + if ( + (options.expectedVersion === undefined) !== + (options.captureCurrentCdiDevices === undefined) + ) { + throw new PodmanHostPreflightError( + "an exact inference version and CDI inventory must be supplied together", + ); + } + const minimumVersion = options.expectedVersion ?? MINIMUM_PODMAN_INFERENCE_VERSION; + if (!isPodmanVersionSupported(minimumVersion, MINIMUM_PODMAN_VERSION)) { + throw new PodmanHostPreflightError("the exact inference version is unsupported"); + } + const { reportedVersion } = inspectServerVersion(engine, minimumVersion); const serverVersion = safeSchemaText(reportedVersion, "Podman server version"); + if (options.expectedVersion !== undefined && serverVersion !== options.expectedVersion) { + throw new PodmanHostPreflightError("the Podman server version changed after qualification"); + } const info = inspectInfo(engine, "CDI inventory inspection"); const host = record(info.host); const architecture = normalizeArchitecture(textField(host, "arch").toLowerCase()); @@ -458,24 +510,48 @@ export function qualifyPodmanInferenceAuthority( engine.authorityId, serverVersion, architecture, - exactNvidiaCdiInventory(info), + options.captureCurrentCdiDevices === undefined + ? exactNvidiaCdiInventory(info) + : normalizePodmanCdiInventory(options.captureCurrentCdiDevices(engine)), ); - return Object.freeze({ ...payload, receiptSha256: inferenceAuthorityDigest(payload) }); + const receipt = { ...payload, receiptSha256: inferenceAuthorityDigest(payload) }; + if (options.expectedVersion !== undefined) { + Object.defineProperty(receipt, PRODUCT_QUALIFIED_INFERENCE_AUTHORITY, { value: true }); + } + options.assertCurrentAuthority?.(); + return Object.freeze(receipt); } /** Refresh endpoint-native CDI state and reject drift before one mutation. */ export function revalidatePodmanInferenceAuthority( engine: ContainerEngine, expected: PodmanInferenceAuthorityReceipt, + options: PodmanInferenceQualificationOptions = {}, ): PodmanInferenceAuthorityReceipt { requireInferenceEngine(engine); - const normalized = normalizePodmanInferenceAuthorityReceipt(expected); + const normalized = normalizeQualifiedPodmanInferenceAuthorityReceipt(expected); if (engine.authorityId !== normalized.authorityId) { throw new PodmanHostPreflightError( "the Podman endpoint authority changed before local-inference mutation", ); } - const refreshed = qualifyPodmanInferenceAuthority(engine); + const refreshed = + options.captureCurrentCdiDevices === undefined && + isPodmanVersionSupported(normalized.serverVersion, MINIMUM_PODMAN_INFERENCE_VERSION) + ? qualifyPodmanInferenceAuthority(engine, { + assertCurrentAuthority: options.assertCurrentAuthority, + }) + : qualifyPodmanInferenceAuthority(engine, { + expectedVersion: normalized.serverVersion, + assertCurrentAuthority: options.assertCurrentAuthority, + captureCurrentCdiDevices: + options.captureCurrentCdiDevices ?? + (() => { + throw new PodmanHostPreflightError( + "fresh CDI authority is required to revalidate this Podman release", + ); + }), + }); if (refreshed.receiptSha256 !== normalized.receiptSha256) { throw new PodmanHostPreflightError( "the Podman server or NVIDIA CDI authority changed before local-inference mutation", diff --git a/src/lib/onboard/runtime-provider/podman.ts b/src/lib/onboard/runtime-provider/podman.ts index 88aa1175a1f..18b5e458233 100644 --- a/src/lib/onboard/runtime-provider/podman.ts +++ b/src/lib/onboard/runtime-provider/podman.ts @@ -13,9 +13,14 @@ import type { HostLocalInferenceRouteAuthorityStore } from "./host-local-inferen import type { PersistedEngineAuthorityStore } from "./persisted-engine-authority"; import { createPodmanHostLocalInferenceOperation, + type PodmanExternalInferenceNetworkAuthority, type PodmanInferenceFailureEvidence, type PodmanInferenceRedactor, } from "./podman-host-local-inference"; +import type { + PodmanInferenceAuthorityReceipt, + PodmanInferenceQualificationOptions, +} from "./podman-preflight"; import { startPodmanSandbox, stopPodmanSandbox } from "./podman-lifecycle"; import { inspectPodmanHost, @@ -37,6 +42,9 @@ export interface PodmanHostLocalInferenceOptions { readonly routeAuthorityStore: HostLocalInferenceRouteAuthorityStore; readonly onFailureEvidence: (evidence: PodmanInferenceFailureEvidence) => void; readonly redactSensitive: PodmanInferenceRedactor; + readonly externalNetwork?: PodmanExternalInferenceNetworkAuthority; + readonly authority?: PodmanInferenceAuthorityReceipt; + readonly authorityQualification?: PodmanInferenceQualificationOptions; } export interface PodmanRuntimeProviderOptions { @@ -184,6 +192,13 @@ export function createPodmanRuntimeProviderBundle( routeAuthorityStore: inferenceOptions.routeAuthorityStore, onFailureEvidence: inferenceOptions.onFailureEvidence, redactSensitive: inferenceOptions.redactSensitive, + ...(inferenceOptions.externalNetwork + ? { externalNetwork: inferenceOptions.externalNetwork } + : {}), + ...(inferenceOptions.authority ? { authority: inferenceOptions.authority } : {}), + ...(inferenceOptions.authorityQualification + ? { authorityQualification: inferenceOptions.authorityQualification } + : {}), }), } : unsupported( diff --git a/src/lib/onboard/setup-inference.ts b/src/lib/onboard/setup-inference.ts index 4e0d12520a1..7edc11234f2 100644 --- a/src/lib/onboard/setup-inference.ts +++ b/src/lib/onboard/setup-inference.ts @@ -448,7 +448,9 @@ function resolveHostLocalInferenceRoute( : requireRuntimeProviderHostLocalInferenceOperation(providerBundle, request.service, { env: hostLocalInferenceOperationEnvironment(request.service), acceleration: - request.service === "ollama" ? request.endpoint.acceleration : "nvidia-gpu", + request.service === "ollama" && "endpoint" in request + ? request.endpoint.acceleration + : "nvidia-gpu", }); return prepareHostLocalInferenceStartup(operation, request); } @@ -635,9 +637,13 @@ export function createSetupInference( return reserved; }; + const defaultUpsertProvider = bindGatewayUpsertProvider(deps.upsertProvider, gatewayName); const commonDeps = { runOpenshell: runGatewayOpenshell, - upsertProvider: bindGatewayUpsertProvider(deps.upsertProvider, gatewayName), + upsertProvider: (...args: Parameters) => { + const exactUpsertProvider = hostLocalGatewayMutation?.upsertProvider; + return (exactUpsertProvider ?? defaultUpsertProvider)(...args); + }, verifyInferenceRoute: (selectedProvider: string, selectedModel: string) => { if (!hostLocalRoute && sandboxName) { reserveRoute(sandboxName, selectedProvider, selectedModel); diff --git a/src/lib/onboard/setup-nim-flow.ts b/src/lib/onboard/setup-nim-flow.ts index 859f7dc5a2d..44b6b17212c 100644 --- a/src/lib/onboard/setup-nim-flow.ts +++ b/src/lib/onboard/setup-nim-flow.ts @@ -26,8 +26,10 @@ import { } from "../inference/llama-cpp/managed-selection"; import { getOllamaContextWindowFloorForAgent } from "../inference/ollama-runtime-context"; import type { VllmProfile } from "../inference/vllm"; +import { promptManualModelId } from "../inference/model-prompts"; import { isBackToSelection } from "../navigation"; import type { HermesAuthMethod } from "./hermes-auth"; +import { isPortableExperimentalProfile } from "./experimental/portable-profile"; import { OnboardInferenceCapabilityCache } from "./inference-capability-cache"; import { createLocalModelProfileIntegration, @@ -48,6 +50,7 @@ import type { RebuildRouteHandoff, RegistryInferenceRoute } from "./rebuild-rout import type { RuntimeProviderBundle } from "./runtime-provider/contract"; export { resolveCurrentRuntimeProviderBundle } from "./runtime-provider/current"; +export { createHermesPortableOllamaInferenceResolver } from "./experimental/hermes-portable-ollama-inference"; import { prepareProviderDiscovery } from "./setup-nim-provider-discovery"; import type { SetupNimSelectionState as BaseSetupNimSelectionState } from "./setup-nim-selection"; @@ -230,10 +233,7 @@ function requireSelectedProvider( } function handleSelectedOllama( - deps: Pick< - SetupNimFlowDeps, - "handleInstallOllamaSelection" | "handleRunningOllamaSelection" - >, + deps: Pick, args: { gpu: SetupNimGpu; requestedModel: string | null; @@ -521,6 +521,78 @@ function vllmPortConflictMessage( return "vLLM is already running on this host. Select Local vLLM, or stop the existing server before selecting the managed install path."; } +async function resolveFreshHermesPortableOllamaSelection(input: { + deps: SetupNimFlowDeps; + agent: AgentDefinition | null; + requestedProvider: string | null; + requestedModel: string | null; + recoverProvider: boolean; + recoveredRegistryRoute: RegistryInferenceRoute | null; + createSelectionState: () => SetupNimSelectionState; + inferenceCapabilityCache: OnboardInferenceCapabilityCache; +}): Promise { + if ( + input.agent?.name !== "hermes" || + !isPortableExperimentalProfile(process.env) || + input.requestedProvider !== "ollama" || + input.recoverProvider || + input.recoveredRegistryRoute !== null + ) { + return null; + } + const nonInteractive = input.deps.isNonInteractive(); + let portableModel = + input.requestedModel ?? + input.deps.getNonInteractiveModel("ollama", { allowProviderModelFallback: false }); + if (!portableModel && !nonInteractive) { + const promptedModel = await promptManualModelId(" Ollama model id: ", "Ollama", null, { + promptFn: input.deps.prompt, + errorLine: input.deps.error, + writeLine: input.deps.log, + exitFn: () => input.deps.exitProcess(1), + }); + if (isBackToSelection(promptedModel)) { + throw new Error("Hermes Portable Ollama model selection was cancelled."); + } + portableModel = promptedModel; + } + if (!portableModel) { + input.deps.abortNonInteractive( + "Hermes Portable Ollama requires an explicit local model selection.", + ); + } + const state = input.createSelectionState(); + state.provider = "ollama-local"; + state.model = portableModel; + state.endpointUrl = null; + state.credentialEnv = null; + state.preferredInferenceApi = "openai-completions"; + state.assertRouteCompatible?.(); + const selectedModel = isBackToSelection(state.model) ? null : state.model; + await input.deps.maybePromptForInferenceInputCapability(selectedModel); + return { + model: selectedModel, + provider: state.provider, + endpointUrl: state.endpointUrl, + endpointSource: null, + credentialEnv: state.credentialEnv, + hermesAuthMethod: null, + hermesToolGateways: [], + preferredInferenceApi: input.deps.resolveAgentInferenceApi( + input.agent.name, + state.provider, + input.deps.coerceAgentInferenceApi(input.agent, state.preferredInferenceApi), + ), + compatibleEndpointReasoning: null, + compatibleEndpointReasoningEffort: null, + nimContainer: null, + allowToolsIncompatible: false, + skipHostInferenceSmoke: false, + reuseGatewayCredentialWithoutLocalKey: false, + inferenceCapabilityCache: input.inferenceCapabilityCache, + }; +} + /** Create the provider-selection flow and seed agent-specific Ollama defaults. */ export function createSetupNim( defaults: SetupNimFlowDeps, @@ -628,6 +700,19 @@ export function createSetupNim( canProbeRoute, recoverySessionId, }); + const freshHermesPortableOllama = await resolveFreshHermesPortableOllamaSelection({ + deps, + agent, + requestedProvider, + requestedModel, + recoverProvider, + recoveredRegistryRoute, + createSelectionState, + inferenceCapabilityCache, + }); + if (freshHermesPortableOllama) { + return freshHermesPortableOllama; + } const providerHostState = deps.detectInferenceProviderHostState({ gpu, experimental: deps.experimental, diff --git a/src/lib/onboard/setup-nim-portable-ollama.test.ts b/src/lib/onboard/setup-nim-portable-ollama.test.ts new file mode 100644 index 00000000000..206dc0297b3 --- /dev/null +++ b/src/lib/onboard/setup-nim-portable-ollama.test.ts @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AgentDefinition } from "../agent/defs"; +import { makeDeps, makeHostState, unexpected } from "./__test-helpers__/setup-nim-flow"; +import { detectInferenceProviderHostState } from "./provider-host-state"; +import { createSetupNim, type SetupNimFlowDeps, type SetupNimGpu } from "./setup-nim-flow"; + +afterEach(() => vi.unstubAllEnvs()); + +describe("fresh Hermes Portable provider selection", () => { + it("selects managed Ollama without probing or starting host Ollama (#9596)", async () => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + vi.stubEnv("NEMOCLAW_OLLAMA_NO_AUTOSTART", "1"); + const dockerCapture = vi.fn(() => unexpected("default Docker inspection")); + const hostCommandExists = vi.fn(() => unexpected("host Ollama discovery")); + const detectHostState = vi.fn((input: Parameters[0]) => + detectInferenceProviderHostState({ + ...input, + deps: { dockerCapture, hostCommandExists }, + }), + ); + const handleRunningOllamaSelection = vi.fn( + async () => unexpected("legacy host Ollama selection"), + ); + const handleInstallOllamaSelection = vi.fn( + async () => unexpected("host Ollama installation"), + ); + const setupNim = createSetupNim( + makeDeps({ + isNonInteractive: () => true, + getNonInteractiveProvider: () => "ollama", + getNonInteractiveModel: () => "qwen3-vl:4b", + localModelProfileIntegration: { + resolvePlan: () => null, + onboard: async () => unexpected("local model profile onboarding"), + }, + detectInferenceProviderHostState: detectHostState, + handleRunningOllamaSelection, + handleInstallOllamaSelection, + }), + ); + + const result = await setupNim( + { type: "nvidia" } as SetupNimGpu, + "portable-hermes", + { name: "hermes" } as AgentDefinition, + false, + ); + + expect(detectHostState).not.toHaveBeenCalled(); + expect(dockerCapture).not.toHaveBeenCalled(); + expect(hostCommandExists).not.toHaveBeenCalled(); + expect(handleRunningOllamaSelection).not.toHaveBeenCalled(); + expect(handleInstallOllamaSelection).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + provider: "ollama-local", + model: "qwen3-vl:4b", + endpointUrl: null, + endpointSource: null, + credentialEnv: null, + preferredInferenceApi: "openai-completions", + }); + }); + + it("uses the scoped Portable model before host discovery in interactive mode (#9596)", async () => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + const detectHostState = vi.fn(() => makeHostState()); + const handleRunningOllamaSelection = vi.fn(() => unexpected("host Ollama selection")); + const handleInstallOllamaSelection = vi.fn(() => unexpected("host Ollama installation")); + const setupNim = createSetupNim( + makeDeps({ + isNonInteractive: () => false, + getNonInteractiveProvider: () => "ollama", + getNonInteractiveModel: () => "qwen3-vl:4b", + detectInferenceProviderHostState: detectHostState, + handleRunningOllamaSelection, + handleInstallOllamaSelection, + }), + ); + + await expect( + setupNim( + { type: "nvidia" } as SetupNimGpu, + "portable-hermes", + { name: "hermes" } as AgentDefinition, + false, + ), + ).resolves.toMatchObject({ provider: "ollama-local", model: "qwen3-vl:4b" }); + + expect(detectHostState).not.toHaveBeenCalled(); + expect(handleRunningOllamaSelection).not.toHaveBeenCalled(); + expect(handleInstallOllamaSelection).not.toHaveBeenCalled(); + }); + + it("prompts for a Portable model without discovering host runtimes (#9596)", async () => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + const detectHostState = vi.fn(() => makeHostState()); + const handleRunningOllamaSelection = vi.fn(() => unexpected("host Ollama selection")); + const handleInstallOllamaSelection = vi.fn(() => unexpected("host Ollama installation")); + const prompt = vi.fn(async () => "prompted-model"); + const setupNim = createSetupNim( + makeDeps({ + isNonInteractive: () => false, + getNonInteractiveProvider: () => "ollama", + getNonInteractiveModel: () => null, + prompt, + detectInferenceProviderHostState: detectHostState, + handleRunningOllamaSelection, + handleInstallOllamaSelection, + }), + ); + + await expect( + setupNim( + { type: "nvidia" } as SetupNimGpu, + "portable-hermes", + { name: "hermes" } as AgentDefinition, + false, + ), + ).resolves.toMatchObject({ provider: "ollama-local", model: "prompted-model" }); + + expect(prompt).toHaveBeenCalledWith(" Ollama model id: "); + expect(detectHostState).not.toHaveBeenCalled(); + expect(handleRunningOllamaSelection).not.toHaveBeenCalled(); + expect(handleInstallOllamaSelection).not.toHaveBeenCalled(); + }); + + it("requires an explicit Portable Ollama model in non-interactive mode (#9596)", async () => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + const abortNonInteractive = vi.fn((message) => { + throw new Error(message); + }); + const detectHostState = vi.fn(() => makeHostState()); + const setupNim = createSetupNim( + makeDeps({ + isNonInteractive: () => true, + getNonInteractiveProvider: () => "ollama", + getNonInteractiveModel: () => null, + abortNonInteractive, + detectInferenceProviderHostState: detectHostState, + }), + ); + + await expect( + setupNim( + { type: "nvidia" } as SetupNimGpu, + "portable-hermes", + { name: "hermes" } as AgentDefinition, + false, + ), + ).rejects.toThrow("requires an explicit local model selection"); + + expect(abortNonInteractive).toHaveBeenCalledOnce(); + expect(detectHostState).not.toHaveBeenCalled(); + }); +}); diff --git a/test/helpers/hermes-portable-ollama-test-harness.ts b/test/helpers/hermes-portable-ollama-test-harness.ts new file mode 100644 index 00000000000..ef57f71f171 --- /dev/null +++ b/test/helpers/hermes-portable-ollama-test-harness.ts @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ContainerEngineCommandCapture, + ContainerEngineCommandResult, +} from "../../src/lib/adapters/container-engine"; + +const REGISTRY_ID = "7".repeat(64); + +export interface PortablePodmanAuthorityState { + networkId: string; + networkLabels?: Record; + networkBackend?: string; + subordinateIdSize?: number; + registryCopies?: number; + registryId?: string; + registryLabel?: string; + registryNetworkId?: string; + images?: Set; + failPull?: string | null; +} + +export function createPortablePodmanCapture( + events: string[], + authorityState: PortablePodmanAuthorityState, + fallback?: ( + args: readonly string[], + timeoutMs?: number, + input?: Buffer, + ) => ContainerEngineCommandResult, +): ContainerEngineCommandCapture { + return (executable, args, timeoutMs, input) => { + const socketUrl = args[1]; + if (args[0] !== "--url" || typeof socketUrl !== "string" || socketUrl.length === 0) { + throw new Error(`Unexpected Podman global arguments: ${args.join(" ")}`); + } + const command = args.slice(2); + events.push(`podman:${command.join(" ")} executable=${executable} socket=${socketUrl}`); + if (command[0] === "version") { + return { + status: 0, + stdout: JSON.stringify({ Client: { Version: "5.7.0" }, Server: { Version: "5.7.0" } }), + stderr: "", + }; + } + if (command[0] === "info") { + return { + status: 0, + stdout: JSON.stringify({ + host: { + arch: "amd64", + os: "linux", + cgroupVersion: "v2", + networkBackend: authorityState.networkBackend ?? "netavark", + security: { rootless: true }, + idMappings: { + uidmap: [ + { container_id: 0, host_id: 1000, size: 1 }, + { + container_id: 1, + host_id: 100000, + size: authorityState.subordinateIdSize ?? 65536, + }, + ], + gidmap: [ + { container_id: 0, host_id: 1000, size: 1 }, + { + container_id: 1, + host_id: 100000, + size: authorityState.subordinateIdSize ?? 65536, + }, + ], + }, + }, + }), + stderr: "", + }; + } + if (command[0] === "network" && command[1] === "inspect") { + return { + status: 0, + stdout: JSON.stringify([ + { + id: authorityState.networkId, + name: "openshell-docker", + driver: "bridge", + internal: false, + ipv6_enabled: false, + dns_enabled: true, + network_interface: "podman9", + subnets: [{ subnet: "169.254.1.0/24", gateway: "169.254.1.1" }], + labels: authorityState.networkLabels ?? {}, + ipam_options: {}, + options: {}, + }, + ]), + stderr: "", + }; + } + if ( + command[0] === "container" && + command[1] === "inspect" && + command[2] === "nemoclaw-portable-registry" + ) { + const registry = { + Id: authorityState.registryId ?? REGISTRY_ID, + Name: "nemoclaw-portable-registry", + Config: { + Labels: { "com.nvidia.nemoclaw.portable": authorityState.registryLabel ?? "1" }, + }, + State: { Running: true }, + NetworkSettings: { + Networks: { + "openshell-docker": { + NetworkID: authorityState.registryNetworkId ?? authorityState.networkId, + IPAddress: "169.254.1.3", + }, + }, + }, + }; + return { + status: 0, + stdout: JSON.stringify( + Array.from({ length: authorityState.registryCopies ?? 1 }, () => registry), + ), + stderr: "", + }; + } + if (command[0] === "image" && command[1] === "exists") { + return { + status: authorityState.images?.has(String(command[2])) === false ? 1 : 0, + stdout: "", + stderr: "", + }; + } + if (command[0] === "pull") { + if (authorityState.failPull === command[1]) { + return { status: 125, stdout: "", stderr: "injected image pull failure" }; + } + authorityState.images?.add(String(command[1])); + return { status: 0, stdout: "", stderr: "" }; + } + if (fallback) return fallback(command, timeoutMs, input); + throw new Error(`Unexpected Podman command: ${command.join(" ")}`); + }; +} + +export interface PortableGatewayProviderHarness { + readonly run: ( + args: string[], + options: { + ignoreError: true; + suppressOutput: true; + stdio: ["ignore", "pipe", "pipe"]; + env?: NodeJS.ProcessEnv; + timeout: number; + }, + ) => ContainerEngineCommandResult; + readonly calls: () => ReadonlyArray<{ + readonly args: readonly string[]; + readonly timeout: number; + }>; + readonly credentialEnv: () => string; + readonly isPresent: () => boolean; + readonly bumpResourceVersion: () => void; + readonly setDeleteFailure: (value: boolean) => void; + readonly setCreateTransportAmbiguity: (value: boolean) => void; + readonly setForeignCreateCredentialEnv: (value: string | null) => void; + readonly setCredentialEnv: (value: string) => void; + readonly setLookupFailure: (value: boolean) => void; + readonly setMalformed: (value: boolean) => void; + readonly setPresent: (value: boolean) => void; +} + +export function createPortableGatewayProviderHarness( + events: string[], +): PortableGatewayProviderHarness { + let present = false; + let malformed = false; + let deleteFailure = false; + let createTransportAmbiguity = false; + let foreignCreateCredentialEnv: string | null = null; + let lookupFailure = false; + let resourceVersion = 1; + let credentialEnv = "NEMOCLAW_OLLAMA_PROXY_TOKEN"; + const calls: Array<{ readonly args: readonly string[]; readonly timeout: number }> = []; + return Object.freeze({ + calls: () => calls, + credentialEnv: () => credentialEnv, + isPresent: () => present, + bumpResourceVersion: () => { + resourceVersion += 1; + }, + setDeleteFailure: (value: boolean) => { + deleteFailure = value; + }, + setCreateTransportAmbiguity: (value: boolean) => { + createTransportAmbiguity = value; + }, + setForeignCreateCredentialEnv: (value: string | null) => { + foreignCreateCredentialEnv = value; + }, + setCredentialEnv: (value: string) => { + credentialEnv = value; + }, + setLookupFailure: (value: boolean) => { + lookupFailure = value; + }, + setMalformed: (value: boolean) => { + malformed = value; + }, + setPresent: (value: boolean) => { + present = value; + }, + run(args: string[], options: Parameters[1]) { + calls.push(Object.freeze({ args: Object.freeze([...args]), timeout: options.timeout })); + events.push(`openshell:${args.join(" ")}`); + if (args[0] === "provider" && args[1] === "get") { + if (lookupFailure) { + return { status: 1, stdout: "", stderr: "gateway lookup failed" }; + } + if (malformed) { + return { status: 0, stdout: "untrusted provider output", stderr: "" }; + } + return present + ? { + status: 0, + stdout: [ + "\u001b[2mId:\u001b[0m portable-ollama-provider", + `\u001b[2mResource version:\u001b[0m ${String(resourceVersion)}`, + "Name: ollama-local", + "Type: openai", + `Credential keys: ${credentialEnv}`, + "Config keys: OPENAI_BASE_URL", + ].join("\n"), + stderr: "", + } + : { + status: 1, + stdout: "", + stderr: + "Error: code: 'Some requested entity was not found', message: \"Provider not found\"", + }; + } + if (args[0] === "provider" && args[1] === "create") { + if (present) return { status: 1, stdout: "", stderr: "provider already exists" }; + if (foreignCreateCredentialEnv !== null) { + credentialEnv = foreignCreateCredentialEnv; + present = true; + resourceVersion = 1; + return { status: 1, stdout: "", stderr: "provider already exists" }; + } + const credentialIndex = args.indexOf("--credential"); + if (credentialIndex < 0 || typeof args[credentialIndex + 1] !== "string") { + throw new Error("Unexpected OpenShell provider create without a credential value."); + } + credentialEnv = args[credentialIndex + 1]; + present = true; + resourceVersion = 1; + return createTransportAmbiguity + ? { status: 1, stdout: "", stderr: "transport result unavailable" } + : { status: 0, stdout: "", stderr: "" }; + } + if (args[0] === "provider" && args[1] === "delete") { + if (deleteFailure) { + return { status: 1, stdout: "", stderr: "gateway delete failed" }; + } + present = false; + return { status: 0, stdout: "", stderr: "" }; + } + throw new Error(`Unexpected OpenShell command: ${args.join(" ")}`); + }, + }); +} diff --git a/test/helpers/podman-host-local-inference-test-harness.ts b/test/helpers/podman-host-local-inference-test-harness.ts index 8b76fc76b1d..fdb3a766db4 100644 --- a/test/helpers/podman-host-local-inference-test-harness.ts +++ b/test/helpers/podman-host-local-inference-test-harness.ts @@ -43,6 +43,14 @@ const PROBE_DIGEST = "2".repeat(64); const TRANSACTION_ID = "3".repeat(64); const TARGET_SHA256 = "4".repeat(64); const GPU_UUID = "GPU-12345678-1234-1234-1234-123456789abc"; + +export function throwAfterPodmanEvent( + events: readonly string[], + fragment: string, + message: string, +): void { + if (events.some((event) => event.includes(fragment))) throw new Error(message); +} const NETWORK_ID = "6".repeat(64); const NETWORK_NAME = "nemoclaw-net"; const NETWORK_GATEWAY_IP = "10.89.0.1"; @@ -73,6 +81,7 @@ export interface PodmanHostLocalInferenceHarnessOptions { readonly gpuIdentities?: readonly string[]; readonly authorityId?: string; readonly service?: "nim" | "vllm"; + readonly probeImageRef?: string; } export interface PodmanHostLocalInferenceHarness { @@ -97,6 +106,7 @@ export interface PodmanHostLocalInferenceHarness { networkName: string; probeFailure: "ready" | "gpu" | "inference" | null; probeFailureText: string; + ollamaPullFailure: string | null; ollamaPsModels: unknown[]; runLostAcknowledgement: boolean; runAcknowledgementText: string | null; @@ -158,10 +168,8 @@ function labelsFrom(args: readonly string[]): Record { return labels; } -function immutableManagedImage(args: readonly string[]): string { - const imageRef = args.find( - (arg) => arg.includes("@sha256:") && !arg.includes(`@sha256:${PROBE_DIGEST}`), - ); +function immutableManagedImage(args: readonly string[], probeImageRef: string): string { + const imageRef = args.find((arg) => arg.includes("@sha256:") && arg !== probeImageRef); if (!imageRef) throw new Error("test harness expected an immutable managed image reference"); return imageRef; } @@ -347,6 +355,7 @@ function discoveredDevicesAuthority(state: { export function createPodmanHostLocalInferenceTestHarness( options: PodmanHostLocalInferenceHarnessOptions = {}, ): PodmanHostLocalInferenceHarness { + const probeImageRef = options.probeImageRef ?? `registry.test/curl@sha256:${PROBE_DIGEST}`; const events: string[] = []; const failures: PodmanInferenceFailureEvidence[] = []; const written: string[] = []; @@ -360,6 +369,7 @@ export function createPodmanHostLocalInferenceTestHarness( probeFailure: null as "ready" | "gpu" | "inference" | null, probeFailureText: "provider\u0001failed\u0002 nvapi-1234567890abcdef Authorization: Bearer bearer-secret-1234 NGC_API_KEY=environment-secret https://user:pass@example.invalid/a?token=query-secret", + ollamaPullFailure: null as string | null, ollamaPsModels: [ { name: "nemotron:latest", @@ -520,8 +530,7 @@ export function createPodmanHostLocalInferenceTestHarness( if (state.probeInheritedImageLabel) { labels["org.opencontainers.image.source"] = "https://example.invalid/probe"; } - const imageRef = - args.find((arg) => arg.includes(`@sha256:${PROBE_DIGEST}`)) ?? "missing-probe-image"; + const imageRef = args.find((arg) => arg === probeImageRef) ?? "missing-probe-image"; currentProbe = { id: PROBE_CONTAINER_ID, name, @@ -539,7 +548,7 @@ export function createPodmanHostLocalInferenceTestHarness( : result(0, state.probeRunAcknowledgementText ?? `${PROBE_CONTAINER_ID}\n`); } // Locate the immutable workload reference independent of optional flags. - const immutableImage = immutableManagedImage(args); + const immutableImage = immutableManagedImage(args, probeImageRef); if (state.parentInheritedImageLabel) { labels["org.opencontainers.image.source"] = "https://example.invalid/managed"; } @@ -572,6 +581,9 @@ export function createPodmanHostLocalInferenceTestHarness( return result(0, currentProbe.logsStdout, currentProbe.logsStderr); } if (args[0] === "exec") { + if (args[2] === "ollama" && args[3] === "pull" && state.ollamaPullFailure !== null) { + return result(1, "", state.ollamaPullFailure); + } if (state.parentExitDuringProof === "gpu") { if (currentContainer) { currentContainer.running = false; @@ -681,7 +693,7 @@ export function createPodmanHostLocalInferenceTestHarness( networkId: NETWORK_ID, networkGatewayIp: NETWORK_GATEWAY_IP, hostPort: 18000, - probeImageRef: `registry.test/curl@sha256:${PROBE_DIGEST}`, + probeImageRef, model: `${service}-model`, requireToolCalling: true, environment: secretNames, diff --git a/test/onboard-host-local-inference-routing.test.ts b/test/onboard-host-local-inference-routing.test.ts index f2b9cdb5d15..2d872bb1f14 100644 --- a/test/onboard-host-local-inference-routing.test.ts +++ b/test/onboard-host-local-inference-routing.test.ts @@ -14,6 +14,7 @@ import type { import { parseHostLocalInferenceReceipt } from "../src/lib/onboard/runtime-provider/host-local-inference.js"; import type { HostLocalInferenceApplication, + HostLocalInferenceGatewayMutation, HostLocalInferenceStartupSelection, } from "../src/lib/onboard/runtime-provider/host-local-inference-routing.js"; import { createPodmanHostLocalInferenceOperation } from "../src/lib/onboard/runtime-provider/podman-host-local-inference.js"; @@ -241,6 +242,7 @@ function fixture( options: Parameters[2] & { gatewayCommitError?: Error; gatewayRollbackError?: Error; + gatewayUpsertProvider?: NonNullable; recover?: boolean; resume?: boolean; } = {}, @@ -277,7 +279,11 @@ function fixture( }); const prepareGatewayMutation = vi.fn(() => { events.push("gateway-snapshot"); - return { commit: gatewayCommit, rollback: gatewayRollback }; + return { + ...(options.gatewayUpsertProvider ? { upsertProvider: options.gatewayUpsertProvider } : {}), + commit: gatewayCommit, + rollback: gatewayRollback, + }; }); const request: HostLocalInferenceStartupSelection["request"] = service === "ollama" @@ -478,8 +484,8 @@ describe("onboard host-local inference routing", () => { _sandboxName: string, _reservation: Parameters[1], ) => { - route.events.push("sandbox-reserve"); - return true; + route.events.push("sandbox-reserve"); + return true; }, ); const harness = createHarness({ @@ -577,6 +583,36 @@ describe("onboard host-local inference routing", () => { }, ); + it("uses a transaction-owned provider create instead of the generic gateway upsert", async () => { + const exactProviderCreate = vi.fn(() => ({ ok: true })); + const genericUpsertProvider = vi.fn(() => ({ ok: true })); + const route = fixture("hermes", "ollama", { + gatewayUpsertProvider: exactProviderCreate, + }); + const harness = createHarness({ + overrides: { + applyLocalInferenceRoute: undefined, + upsertProvider: genericUpsertProvider, + }, + }); + + await expect( + harness.setupInference(SANDBOX, MODEL, "ollama-local", null, null, null, [], { + gatewayName: "nemoclaw", + hostLocalInference: route.selection, + }), + ).resolves.toEqual({ ok: true }); + + expect(exactProviderCreate).toHaveBeenCalledWith( + "ollama-local", + "openai", + "NEMOCLAW_OLLAMA_PROXY_TOKEN", + "http://host.openshell.internal:11434/v1", + { NEMOCLAW_OLLAMA_PROXY_TOKEN: "ollama" }, + ); + expect(genericUpsertProvider).not.toHaveBeenCalled(); + }); + it.each(APPLICATIONS)( "registers explicitly selected llama.cpp for %s with exact private provenance", async (application) => {