diff --git a/src/lib/actions/sandbox/destroy-host-local-inference.test.ts b/src/lib/actions/sandbox/destroy-host-local-inference.test.ts index c9365d10ba8..161a96a28cc 100644 --- a/src/lib/actions/sandbox/destroy-host-local-inference.test.ts +++ b/src/lib/actions/sandbox/destroy-host-local-inference.test.ts @@ -457,6 +457,19 @@ describe("sandbox destroy host-local inference transaction", () => { expect(runtimeProvider.destroy).not.toHaveBeenCalled(); }); + it("rejects a malformed durable receipt before sandbox deletion", async () => { + const runtimeProvider = provider(); + const entry = sandbox("alpha", receipt(), { hostLocalInferenceReceipt: "not-json" }); + const { result, runOpenshell, stopInferenceResources } = await runDestroy(runtimeProvider, { + entry, + }); + + expect(result).toMatchObject({ ok: false }); + expect(runOpenshell).not.toHaveBeenCalled(); + expect(stopInferenceResources).not.toHaveBeenCalled(); + expect(runtimeProvider.destroy).not.toHaveBeenCalled(); + }); + it("preserves authority when the registry row is missing or reused after deletion", async () => { const missingProvider = provider(); const missing = await runDestroy(missingProvider, { currentAfterDelete: null }); diff --git a/src/lib/actions/sandbox/snapshot-command-host-local-authority.test.ts b/src/lib/actions/sandbox/snapshot-command-host-local-authority.test.ts index 50a62452033..56a21d730f8 100644 --- a/src/lib/actions/sandbox/snapshot-command-host-local-authority.test.ts +++ b/src/lib/actions/sandbox/snapshot-command-host-local-authority.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { hostLocalInferenceReceipt } from "../../../../test/helpers/host-local-inference-receipt"; import { createInMemoryRuntimeProviderBundle } from "../../../../test/helpers/runtime-provider-bundle"; @@ -197,14 +197,9 @@ function successfulRestore( describe("snapshot command host-local inference authority", () => { beforeEach(() => { - vi.clearAllMocks(); harness.events.length = 0; }); - afterEach(() => { - vi.restoreAllMocks(); - }); - it("re-proves authority before restore, at the mutation fence, and after success", async () => { const receipt = receiptAtPort(8000); harness.setRegistryEntry(sandbox(receipt)); diff --git a/src/lib/inference/llama-cpp/managed-installer.test.ts b/src/lib/inference/llama-cpp/managed-installer.test.ts index a830b02a168..a149efe801f 100644 --- a/src/lib/inference/llama-cpp/managed-installer.test.ts +++ b/src/lib/inference/llama-cpp/managed-installer.test.ts @@ -772,6 +772,20 @@ describe("managed llama.cpp installer", () => { const createLifecycle = vi.fn(() => lifecycle); const operation = managedOperation(harness.engine, createLifecycle); const runtimeProvider = managedRuntimeProvider(harness.engine, createLifecycle); + const mismatchedOperation = managedOperation( + { ...harness.engine, engineId: "other-engine" }, + createLifecycle, + ); + + expect(() => + rehydrateManagedLlamaCppLifecycle({ + runtimeProvider, + runtimeOwnerSandboxName: "spark-agent", + homeDir, + operation: mismatchedOperation, + }), + ).toThrow("returned mismatched host-local-inference authority"); + expect(createLifecycle).not.toHaveBeenCalled(); const rehydrated = rehydrateManagedLlamaCppLifecycle({ runtimeProvider, diff --git a/src/lib/inference/llama-cpp/managed-installer.ts b/src/lib/inference/llama-cpp/managed-installer.ts index 5c82acf0674..52113a5fc79 100644 --- a/src/lib/inference/llama-cpp/managed-installer.ts +++ b/src/lib/inference/llama-cpp/managed-installer.ts @@ -512,15 +512,13 @@ export function rehydrateManagedLlamaCppLifecycle( const receipt = loadManagedLlamaCppReceipt(paths); if (!receipt) throw new Error("Managed llama.cpp private receipt is unavailable."); const selection = resolveManagedLlamaCppOwnerSelection(owner); - const operation = - options.operation ?? - requireRuntimeProviderHostLocalInferenceOperation(options.runtimeProvider, "llama-cpp", { - env: options.env ?? process.env, - }); + const operation = requireRuntimeProviderHostLocalInferenceOperation( + options.runtimeProvider, + "llama-cpp", + { env: options.env ?? process.env }, + options.operation, + ); operation.assertAuthority(); - if (operation.providerId !== options.runtimeProvider.identity.id) { - throw new Error("Managed llama.cpp runtime provider changed during lifecycle rehydration."); - } const current = currentManagedLlamaCppArtifact(selection, homeDir); return Object.freeze({ lifecycle: lifecycleFor({ diff --git a/src/lib/inference/llama-cpp/managed-lifecycle-adapter.test.ts b/src/lib/inference/llama-cpp/managed-lifecycle-adapter.test.ts index 4de5efb248b..a17630e5019 100644 --- a/src/lib/inference/llama-cpp/managed-lifecycle-adapter.test.ts +++ b/src/lib/inference/llama-cpp/managed-lifecycle-adapter.test.ts @@ -48,9 +48,10 @@ function temporaryHome(): string { describe("managed llama.cpp lifecycle adapter", () => { it("uses one exact operation and exposes rollback only before publication", () => { const homeDir = temporaryHome(); + const gatewayPort = 8080; const harness = engineHarness(); - createManagedState(homeDir, harness.engine); - const receipt = loadManagedLlamaCppReceipt(managedLlamaCppStatePaths(homeDir))!; + createManagedState(homeDir, harness.engine, { gatewayPort }); + const receipt = loadManagedLlamaCppReceipt(managedLlamaCppStatePaths(homeDir, gatewayPort))!; const operation: HostLocalInferenceOperation = { providerId: "docker", engine: harness.engine, @@ -92,7 +93,7 @@ describe("managed llama.cpp lifecycle adapter", () => { runtimeOwnerSandboxName: "spark-agent", expectedModel: "llama-cpp-model", expectedReceipt: receipt, - gatewayPort: 8080, + gatewayPort, homeDir, operation, rehydrate: vi.fn( @@ -130,9 +131,10 @@ describe("managed llama.cpp lifecycle adapter", () => { it("uses the retained receipt for an idempotent destroy retry after private state is gone", () => { const homeDir = temporaryHome(); + const gatewayPort = 8080; const harness = engineHarness(); - createManagedState(homeDir, harness.engine); - const paths = managedLlamaCppStatePaths(homeDir); + createManagedState(homeDir, harness.engine, { gatewayPort }); + const paths = managedLlamaCppStatePaths(homeDir, gatewayPort); const receipt = loadManagedLlamaCppReceipt(paths)!; fs.rmSync(paths.stateDir, { recursive: true }); const operation: HostLocalInferenceOperation = { @@ -159,7 +161,7 @@ describe("managed llama.cpp lifecycle adapter", () => { runtimeOwnerSandboxName: "spark-agent", expectedModel: "llama-cpp-model", expectedReceipt: receipt, - gatewayPort: 8080, + gatewayPort, homeDir, operation, finalizeCleanup: (owner, expected, options) => diff --git a/src/lib/onboard/runtime-provider/registry.ts b/src/lib/onboard/runtime-provider/registry.ts index 3908e621ef5..8f03c58fbb4 100644 --- a/src/lib/onboard/runtime-provider/registry.ts +++ b/src/lib/onboard/runtime-provider/registry.ts @@ -730,6 +730,7 @@ export function requireRuntimeProviderHostLocalInferenceOperation( bundle: RuntimeProviderBundle, service: HostLocalInferenceService, input: HostLocalInferenceOperationInput, + candidate?: HostLocalInferenceOperation, ): HostLocalInferenceOperation { const surface = bundle.hostLocalInference; if ( @@ -749,7 +750,7 @@ export function requireRuntimeProviderHostLocalInferenceOperation( `Runtime provider '${bundle.identity.id}' does not provide an operation-scoped host-local-inference engine for ${service}.`, ); } - const operation = surface.createOperation(input); + const operation = candidate ?? surface.createOperation(input); if ( operation.providerId !== bundle.identity.id || operation.engine.operation !== "host-local-inference" || diff --git a/src/lib/onboard/setup-inference.ts b/src/lib/onboard/setup-inference.ts index 4764b61165e..38f24ba0403 100644 --- a/src/lib/onboard/setup-inference.ts +++ b/src/lib/onboard/setup-inference.ts @@ -422,7 +422,12 @@ function resolveHostLocalInferenceRoute( } const operation = request.service === "llama-cpp" - ? request.adapter.operation + ? requireRuntimeProviderHostLocalInferenceOperation( + providerBundle, + request.service, + { env: hostLocalInferenceOperationEnvironment(request.service) }, + request.adapter.operation, + ) : requireRuntimeProviderHostLocalInferenceOperation(providerBundle, request.service, { env: hostLocalInferenceOperationEnvironment(request.service), acceleration: diff --git a/test/onboard-host-local-inference-routing.test.ts b/test/onboard-host-local-inference-routing.test.ts index 3f9dffdfbd5..f2b9cdb5d15 100644 --- a/test/onboard-host-local-inference-routing.test.ts +++ b/test/onboard-host-local-inference-routing.test.ts @@ -647,6 +647,39 @@ describe("onboard host-local inference routing", () => { }, ); + it("rejects injected llama.cpp engine drift before startup", async () => { + const route = llamaFixture("openclaw"); + const request = route.selection.request as Extract< + HostLocalInferenceStartupSelection["request"], + { service: "llama-cpp" } + >; + const selection: HostLocalInferenceStartupSelection = { + ...route.selection, + request: { + ...request, + adapter: { + ...request.adapter, + operation: { + ...request.adapter.operation, + engine: { ...request.adapter.operation.engine, engineId: "other-engine" }, + }, + }, + }, + }; + const harness = createHarness(); + + await expect( + harness.setupInference(SANDBOX, MODEL, "llama-cpp-local", null, null, null, [], { + hostLocalInference: selection, + }), + ).rejects.toThrow("EXIT_CALLED:1"); + + expect(route.prepareStartup).not.toHaveBeenCalled(); + expect(route.prepareGatewayMutation).not.toHaveBeenCalled(); + expect(harness.commands).toEqual([]); + expect(harness.errors.join(" ")).toContain("mismatched host-local-inference authority"); + }); + it.each([ ["nim", 8001], ["vllm", 8000],