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) => {