diff --git a/src/lib/actions/inference-set.test.ts b/src/lib/actions/inference-set.test.ts index 7fe4f1d9815..853cefbfabb 100644 --- a/src/lib/actions/inference-set.test.ts +++ b/src/lib/actions/inference-set.test.ts @@ -123,6 +123,7 @@ function createDeps(options: { writeSandboxConfig: ReturnType; recomputeSandboxConfigHash: ReturnType; updateSandbox: ReturnType; + readSandboxConfig: ReturnType; updateSession: ReturnType; appendAuditEntry: ReturnType; log: ReturnType; @@ -145,6 +146,7 @@ function createDeps(options: { writeSandboxConfig: vi.fn(), recomputeSandboxConfigHash: vi.fn(), updateSandbox: vi.fn(() => true), + readSandboxConfig: vi.fn(() => options.config), updateSession: vi.fn((mutator: (value: Session) => Session | void) => { const current = session ?? baseSession(); session = mutator(current) ?? current; @@ -167,7 +169,7 @@ function createDeps(options: { loadSession: () => session, updateSession: calls.updateSession, resolveAgentConfig: () => options.target ?? OPENCLAW_TARGET, - readSandboxConfig: () => options.config, + readSandboxConfig: calls.readSandboxConfig, writeSandboxConfig: calls.writeSandboxConfig, recomputeSandboxConfigHash: calls.recomputeSandboxConfigHash, runOpenshell: calls.runOpenshell, @@ -453,10 +455,24 @@ describe("runInferenceSet", () => { }); expect(deps.calls.writeSandboxConfig).toHaveBeenCalledWith("alpha", OPENCLAW_TARGET, config); expect(deps.calls.recomputeSandboxConfigHash).toHaveBeenCalledWith("alpha", OPENCLAW_TARGET); - expect(deps.calls.updateSandbox).toHaveBeenCalledWith("alpha", { - provider: "nvidia-prod", - model: "nvidia/nemotron-3-super-120b-a12b", - }); + expect(deps.calls.updateSandbox).toHaveBeenCalledWith( + "alpha", + expect.objectContaining({ + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + }), + ); + expect(deps.calls.updateSandbox.mock.calls.at(-1)).toEqual([ + "alpha", + expect.objectContaining({ + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + credentialEnv: null, + endpointUrl: null, + nimContainer: null, + preferredInferenceApi: null, + }), + ]); expect(deps.getSession()).toMatchObject({ provider: "nvidia-prod", model: "nvidia/nemotron-3-super-120b-a12b", @@ -545,10 +561,13 @@ describe("runInferenceSet", () => { "/sandbox/.hermes/config.yaml", ); expect(deps.calls.recomputeSandboxConfigHash).toHaveBeenCalledWith("hermes", HERMES_TARGET); - expect(deps.calls.updateSandbox).toHaveBeenCalledWith("hermes", { - provider: "hermes-provider", - model: "openai/gpt-5.4-mini", - }); + expect(deps.calls.updateSandbox).toHaveBeenCalledWith( + "hermes", + expect.objectContaining({ + provider: "hermes-provider", + model: "openai/gpt-5.4-mini", + }), + ); expect(deps.getSession()).toMatchObject({ provider: "hermes-provider", model: "openai/gpt-5.4-mini", @@ -586,7 +605,16 @@ describe("runInferenceSet", () => { }, }, }; - const deps = createDeps({ config, session: baseSession() }); + const deps = createDeps({ + config, + session: baseSession({ + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + endpointUrl: "https://anthropic-compatible.example/v1", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + preferredInferenceApi: "anthropic-messages", + }), + }); const result = await runInferenceSet( { @@ -616,6 +644,16 @@ describe("runInferenceSet", () => { }, }, }); + expect(deps.calls.updateSandbox.mock.calls.at(-1)).toEqual([ + "alpha", + expect.objectContaining({ + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + endpointUrl: "https://anthropic-compatible.example/v1", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + preferredInferenceApi: "anthropic-messages", + }), + ]); expect(deps.getSession()).toMatchObject({ provider: "compatible-anthropic-endpoint", model: "claude-sonnet-proxy", @@ -627,6 +665,34 @@ describe("runInferenceSet", () => { }); }); + it("rejects custom-compatible provider switches without trusted endpoint metadata", async () => { + const deps = createDeps({ + config: { agents: { defaults: { model: { primary: "inference/nvidia/model-a" } } } }, + entry: { + name: "alpha", + agent: "openclaw", + provider: "nvidia-prod", + model: "nvidia/model-a", + }, + session: baseSession({ + provider: "nvidia-prod", + model: "nvidia/model-a", + endpointUrl: "https://integrate.api.nvidia.com/v1", + credentialEnv: "NVIDIA_INFERENCE_API_KEY", + }), + }); + + await expect( + runInferenceSet( + { provider: "compatible-endpoint", model: "openai/gpt-5.4-mini", noVerify: true }, + deps, + ), + ).rejects.toThrow(/without trusted durable endpoint metadata/); + + expect(deps.calls.runOpenshell).not.toHaveBeenCalled(); + expect(deps.calls.updateSandbox).not.toHaveBeenCalled(); + }); + it("preserves same-provider Bedrock Runtime adapter routing for OpenClaw switches", async () => { const config: ConfigObject = { agents: { @@ -719,8 +785,11 @@ describe("runInferenceSet", () => { session: baseSession({ agent: "hermes", sandboxName: "hermes", - provider: "hermes-provider", - model: "openai/gpt-5.4-mini", + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + endpointUrl: "https://anthropic-compatible.example/v1", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + preferredInferenceApi: "anthropic-messages", }), }); @@ -747,6 +816,16 @@ describe("runInferenceSet", () => { provider: "compatible-anthropic-endpoint", model: "claude-sonnet-proxy", }); + expect(deps.calls.updateSandbox.mock.calls.at(-1)).toEqual([ + "hermes", + expect.objectContaining({ + provider: "compatible-anthropic-endpoint", + model: "claude-sonnet-proxy", + endpointUrl: "https://anthropic-compatible.example/v1", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + preferredInferenceApi: "anthropic-messages", + }), + ]); expect(deps.getSession()).toMatchObject({ provider: "compatible-anthropic-endpoint", model: "claude-sonnet-proxy", @@ -823,10 +902,13 @@ describe("runInferenceSet", () => { await runInferenceSet({ provider: "hermes-provider", model: "z-ai/glm-5.1" }, deps); expect(deps.calls.writeSandboxConfig).toHaveBeenCalledWith("hermes-one", HERMES_TARGET, config); - expect(deps.calls.updateSandbox).toHaveBeenCalledWith("hermes-one", { - provider: "hermes-provider", - model: "z-ai/glm-5.1", - }); + expect(deps.calls.updateSandbox).toHaveBeenCalledWith( + "hermes-one", + expect.objectContaining({ + provider: "hermes-provider", + model: "z-ai/glm-5.1", + }), + ); }); it("requires --sandbox when the nemohermes alias cannot choose one Hermes sandbox", async () => { @@ -876,6 +958,33 @@ describe("runInferenceSet", () => { expect(deps.calls.updateSandbox).not.toHaveBeenCalled(); }); + it("keeps gateway and registry consistent when the sandbox config read fails", async () => { + const deps = createDeps({ config: {}, session: baseSession() }); + deps.calls.readSandboxConfig.mockImplementation(() => { + throw new Error("sandbox config unreadable"); + }); + + await expect( + runInferenceSet( + { provider: "nvidia-prod", model: "nvidia/nemotron-3-super-120b-a12b", noVerify: true }, + deps, + ), + ).rejects.toThrow("sandbox config unreadable"); + + expect(deps.calls.updateSandbox).toHaveBeenCalledWith( + "alpha", + expect.objectContaining({ + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + }), + ); + expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled(); + }); + it("keeps gateway and registry consistent when the in-sandbox config write fails (#3726)", async () => { const config: ConfigObject = { agents: { defaults: { model: { primary: "inference/moonshotai/kimi-k2.6" } } }, @@ -899,10 +1008,13 @@ describe("runInferenceSet", () => { ); // Registry still updated despite the in-sandbox sync throwing (no stale registry → no revert). - expect(deps.calls.updateSandbox).toHaveBeenCalledWith("alpha", { - provider: "nvidia-prod", - model: "nvidia/nemotron-3-super-120b-a12b", - }); + expect(deps.calls.updateSandbox).toHaveBeenCalledWith( + "alpha", + expect.objectContaining({ + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + }), + ); expect(deps.calls.recomputeSandboxConfigHash).not.toHaveBeenCalled(); expect(result).toMatchObject({ provider: "nvidia-prod", @@ -940,10 +1052,13 @@ describe("runInferenceSet", () => { // Config write happened and registry is updated; the run resolves without aborting. expect(deps.calls.writeSandboxConfig).toHaveBeenCalled(); - expect(deps.calls.updateSandbox).toHaveBeenCalledWith("alpha", { - provider: "nvidia-prod", - model: "nvidia/nemotron-3-super-120b-a12b", - }); + expect(deps.calls.updateSandbox).toHaveBeenCalledWith( + "alpha", + expect.objectContaining({ + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + }), + ); expect(result).toMatchObject({ inSandboxConfigSynced: false }); // Degraded: warns about the stale integrity hash, points at rebuild, no "synced". diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index 046f4db7e97..5a14af0a238 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -12,6 +12,7 @@ import { type SandboxInferenceConfig, } from "../inference/config"; import { resolveContextWindowForModel } from "../inference/context-window"; +import { inferenceSelectionRegistryFields } from "../inference/selection"; import { type ValidationResult, validateLocalProvider } from "../inference/local"; import { ensureLocalProviderReachable } from "../onboard/local-inference-topology"; import { @@ -356,6 +357,71 @@ function getPreferredInferenceApi(config: ConfigObject): string | null { return typeof inferenceProvider.api === "string" ? inferenceProvider.api : null; } +type RegistryInferenceMetadata = Pick< + SandboxEntry, + "endpointUrl" | "credentialEnv" | "preferredInferenceApi" | "nimContainer" +>; + +function isCustomCompatibleProvider(provider: string): boolean { + return provider === "compatible-endpoint" || provider === "compatible-anthropic-endpoint"; +} + +function matchingSessionMetadata(options: { + session: onboardSession.Session | null; + sandboxName: string; + provider: string; + model: string; +}): RegistryInferenceMetadata | null { + const { session, sandboxName, provider, model } = options; + if ( + session?.sandboxName !== sandboxName || + session.provider !== provider || + session.model !== model || + !session.endpointUrl + ) { + return null; + } + return { + endpointUrl: session.endpointUrl, + credentialEnv: session.credentialEnv ?? null, + preferredInferenceApi: session.preferredInferenceApi ?? null, + nimContainer: session.nimContainer ?? null, + }; +} + +function registryMetadataForProviderSwitch(options: { + entry: SandboxEntry; + provider: string; + model: string; + sandboxName: string; + session: onboardSession.Session | null; +}): RegistryInferenceMetadata { + const { entry, provider, model, sandboxName, session } = options; + if (entry.provider === provider) { + return { + endpointUrl: entry.endpointUrl ?? null, + credentialEnv: entry.credentialEnv ?? null, + preferredInferenceApi: entry.preferredInferenceApi ?? null, + nimContainer: entry.nimContainer ?? null, + }; + } + const sessionMetadata = matchingSessionMetadata({ session, sandboxName, provider, model }); + if (sessionMetadata) return sessionMetadata; + if (isCustomCompatibleProvider(provider)) { + throw new InferenceSetError( + `Cannot switch sandbox '${sandboxName}' to '${provider}' without trusted durable endpoint metadata. ` + + `Re-run onboarding for this custom endpoint or restore a matching onboard session before using inference set.`, + 2, + ); + } + return { + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + }; +} + export async function runInferenceSet( options: InferenceSetOptions, deps: InferenceSetDeps = defaultDeps(), @@ -385,6 +451,14 @@ export async function runInferenceSet( 2, ); } + const session = deps.loadSession(); + const registryMetadata = registryMetadataForProviderSwitch({ + entry, + provider, + model, + sandboxName, + session, + }); // Local providers (ollama-local, vllm-local) route through the sandbox-facing // host.openshell.internal hostname, which the host-side `openshell inference set` @@ -429,9 +503,20 @@ export async function runInferenceSet( ); } - // Write the registry before the crash-prone in-sandbox sync so the gateway - // and registry can't end up split (#3725) and trigger a revert on connect (#3726). - if (!deps.updateSandbox(sandboxName, { provider, model })) { + // Write minimal registry state before any sandbox-facing config read so the + // gateway and registry cannot split if the in-sandbox layer is unavailable. + const registryFields = (preferredInferenceApi: string | null) => + inferenceSelectionRegistryFields({ + provider, + model, + endpointUrl: registryMetadata.endpointUrl ?? null, + credentialEnv: registryMetadata.credentialEnv ?? null, + preferredInferenceApi, + nimContainer: registryMetadata.nimContainer ?? null, + }); + if ( + !deps.updateSandbox(sandboxName, registryFields(registryMetadata.preferredInferenceApi ?? null)) + ) { throw new InferenceSetError(`Failed to update NemoClaw registry for sandbox '${sandboxName}'.`); } @@ -442,8 +527,14 @@ export async function runInferenceSet( currentProvider: entry.provider, provider, sandboxName, - session: deps.loadSession(), + session, }); + // Refresh the registry with config-derived API-family metadata before the + // crash-prone in-sandbox sync (#3725/#3726). + if (!deps.updateSandbox(sandboxName, registryFields(preferredInferenceApi))) { + throw new InferenceSetError(`Failed to update NemoClaw registry for sandbox '${sandboxName}'.`); + } + let patched: { changed: boolean; route: SandboxInferenceConfig }; if (agentName === "hermes") { patched = patchHermesInferenceConfig(config, provider, model, preferredInferenceApi); diff --git a/src/lib/actions/sandbox/rebuild-resume-config.test.ts b/src/lib/actions/sandbox/rebuild-resume-config.test.ts index 256477cf5b5..2286766da69 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.test.ts @@ -40,6 +40,32 @@ describe("getRebuildCredentialEnvFromRegistry", () => { it("returns the canonical credential env for a known remote provider", () => { expect(getRebuildCredentialEnvFromRegistry("nvidia-prod")).toBe("NVIDIA_INFERENCE_API_KEY"); }); + + it("ignores recorded credentials for local providers and prefers canonical remote envs", () => { + expect(getRebuildCredentialEnvFromRegistry("ollama-local", "OPENAI_API_KEY")).toBeNull(); + expect(getRebuildCredentialEnvFromRegistry("nvidia-prod", "OPENAI_API_KEY")).toBe( + "NVIDIA_INFERENCE_API_KEY", + ); + }); + + it("uses canonical compatible credential envs and ignores stale recorded values", () => { + expect(getRebuildCredentialEnvFromRegistry("compatible-endpoint", "COMPATIBLE_API_KEY")).toBe( + "COMPATIBLE_API_KEY", + ); + expect( + getRebuildCredentialEnvFromRegistry( + "compatible-anthropic-endpoint", + "COMPATIBLE_ANTHROPIC_API_KEY", + ), + ).toBe("COMPATIBLE_ANTHROPIC_API_KEY"); + expect(getRebuildCredentialEnvFromRegistry("compatible-endpoint", "OPENAI_API_KEY")).toBe( + "COMPATIBLE_API_KEY", + ); + expect(getRebuildCredentialEnvFromRegistry("compatible-endpoint", "bad-name")).toBe( + "COMPATIBLE_API_KEY", + ); + }); + it("returns null for local and unset providers", () => { expect(getRebuildCredentialEnvFromRegistry("ollama-local")).toBeNull(); expect(getRebuildCredentialEnvFromRegistry(null)).toBeNull(); @@ -66,9 +92,33 @@ describe("getRebuildEndpointFromRegistry", () => { expect(result.endpointUrl.length).toBeGreaterThan(0); }); - it("marks a custom OpenAI-compatible provider as unknown (session-only URL)", () => { + it("marks a custom OpenAI-compatible provider as unknown without durable endpoint metadata", () => { expect(getRebuildEndpointFromRegistry("compatible-endpoint")).toEqual({ known: false }); }); + + it("uses canonical durable custom endpoint metadata from the sandbox registry", () => { + expect( + getRebuildEndpointFromRegistry( + "compatible-endpoint", + " http://127.0.0.1:19999/v1/?x=1#frag ", + ), + ).toEqual({ + known: true, + endpointUrl: "http://127.0.0.1:19999/v1", + }); + }); + + it("rejects malformed or unsupported durable custom endpoint metadata", () => { + expect(getRebuildEndpointFromRegistry("compatible-endpoint", "not-a-url")).toEqual({ + known: false, + }); + expect(getRebuildEndpointFromRegistry("compatible-endpoint", "file:///tmp/x")).toEqual({ + known: false, + }); + expect( + getRebuildEndpointFromRegistry("compatible-endpoint", "https://u:p@example.test/v1"), + ).toEqual({ known: false }); + }); }); describe("prepareRebuildResumeConfig", () => { @@ -102,7 +152,7 @@ describe("prepareRebuildResumeConfig", () => { expect(typeof config?.endpointUrl).toBe("string"); }); - it("fails closed for a custom endpoint with a non-matching session", () => { + it("fails closed for a custom endpoint with a non-matching session and no registry endpoint", () => { vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "other" }); expect(() => prepareRebuildResumeConfig( @@ -115,6 +165,61 @@ describe("prepareRebuildResumeConfig", () => { ).toThrow("Cannot determine recreate endpoint"); }); + it("recreates custom endpoints from durable registry metadata when the session is unrelated", () => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "other" }); + const config = prepareRebuildResumeConfig( + "alpha", + entry({ + provider: "compatible-endpoint", + model: "m", + endpointUrl: "http://127.0.0.1:19999/v1", + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", + }), + null, + noopLog, + throwingBail, + ); + expect(config).toMatchObject({ + provider: "compatible-endpoint", + model: "m", + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", + pinEndpoint: true, + endpointUrl: "http://127.0.0.1:19999/v1", + }); + }); + + it("fails closed for invalid durable custom endpoint metadata before delete", () => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "other" }); + expect(() => + prepareRebuildResumeConfig( + "alpha", + entry({ provider: "compatible-endpoint", model: "m", endpointUrl: "not-a-url" }), + null, + noopLog, + throwingBail, + ), + ).toThrow("Cannot determine recreate endpoint"); + }); + + it("canonicalizes valid durable custom endpoint metadata before recreate", () => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "other" }); + const config = prepareRebuildResumeConfig( + "alpha", + entry({ + provider: "compatible-endpoint", + model: "m", + endpointUrl: " https://example.test/v1?x=1#frag ", + credentialEnv: "COMPATIBLE_API_KEY", + }), + null, + noopLog, + throwingBail, + ); + expect(config?.endpointUrl).toBe("https://example.test/v1"); + }); + it("surfaces an ambient agent mismatch in the assessment", () => { vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "alpha" }); const prior = process.env.NEMOCLAW_AGENT; diff --git a/src/lib/actions/sandbox/rebuild-resume-config.ts b/src/lib/actions/sandbox/rebuild-resume-config.ts index 65472535a3c..186bd670a32 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.ts @@ -11,6 +11,7 @@ import { CLI_NAME } from "../../cli/branding"; import { RD as _RD, D, R } from "../../cli/terminal-style"; +import { normalizeInferenceSelection } from "../../inference/selection"; import * as onboardSession from "../../state/onboard-session"; import { type AmbientRecreateEnvAssessment, @@ -36,18 +37,51 @@ export function isLocalInferenceProvider(provider: string | null | undefined): p return Boolean(provider && LOCAL_INFERENCE_PROVIDERS.includes(provider)); } +function canonicalRemoteProviderConfig(provider: string | null | undefined): { + providerName: string; + credentialEnv: string | null; + endpointUrl?: string | null; +} | null { + if (!provider) return null; + return ( + (provider === "nvidia-nim" + ? REMOTE_PROVIDER_CONFIG.build + : Object.values(REMOTE_PROVIDER_CONFIG).find((entry) => entry.providerName === provider)) || + null + ); +} + +function validCredentialEnvName(value: string | null | undefined): string | null { + const normalized = typeof value === "string" ? value.trim() : ""; + return /^[A-Z_][A-Z0-9_]*$/.test(normalized) ? normalized : null; +} + +function providerRecordedCredentialEnv( + provider: string | null | undefined, + recordedCredentialEnv?: string | null, +): string | null { + const envName = validCredentialEnvName(recordedCredentialEnv); + switch (provider) { + case "compatible-endpoint": + return envName === "COMPATIBLE_API_KEY" ? envName : null; + case "compatible-anthropic-endpoint": + return envName === "COMPATIBLE_ANTHROPIC_API_KEY" ? envName : null; + case "nvidia-router": + return envName; + default: + return null; + } +} + /** Resolve the credential environment variable required to recreate a sandbox. */ export function getRebuildCredentialEnvFromRegistry( provider: string | null | undefined, + recordedCredentialEnv?: string | null, ): string | null { - if (!provider || isLocalInferenceProvider(provider)) { - return null; - } - const remoteConfig = - provider === "nvidia-nim" - ? REMOTE_PROVIDER_CONFIG.build - : Object.values(REMOTE_PROVIDER_CONFIG).find((entry) => entry.providerName === provider); - return remoteConfig?.credentialEnv || null; + if (!provider || isLocalInferenceProvider(provider)) return null; + const remoteConfig = canonicalRemoteProviderConfig(provider); + if (remoteConfig?.credentialEnv) return remoteConfig.credentialEnv; + return providerRecordedCredentialEnv(provider, recordedCredentialEnv); } // Providers whose inference base URL is supplied by the operator at onboard time @@ -71,23 +105,45 @@ const SESSION_ONLY_ENDPOINT_PROVIDER_NAMES = new Set( * recreate endpoint can be re-derived without the target's own onboard session — * a known remote provider with a canonical URL (e.g. nvidia-prod → NVIDIA * Endpoints), a local or routed (blueprint-derived) provider (no static URL to - * pin), or any other provider that does not record a custom base URL. Returns - * `{ known: false }` only for custom OpenAI/Anthropic-compatible providers whose - * base URL lives solely in their own session — the caller must then refuse to - * destroy the sandbox from an unrelated session rather than guess the endpoint. + * pin), or a custom OpenAI/Anthropic-compatible provider with durable registry + * metadata. Returns `{ known: false }` only for custom providers whose base URL + * is absent from both the selected sandbox registry entry and its own session — + * the caller must then refuse to destroy the sandbox from an unrelated session + * rather than guess the endpoint. */ +function canonicalCustomEndpointUrl(value: string | null | undefined): string | null { + const raw = typeof value === "string" ? value.trim() : ""; + try { + const url = new URL(raw); + const supportedProtocol = url.protocol === "http:" || url.protocol === "https:"; + const hasUserInfo = Boolean(url.username || url.password); + if (!supportedProtocol || hasUserInfo) return null; + url.search = ""; + url.hash = ""; + const pathname = url.pathname.replace(/\/+$/, ""); + url.pathname = pathname || "/"; + return url.pathname === "/" ? url.origin : `${url.origin}${url.pathname}`; + } catch { + return null; + } +} + export function getRebuildEndpointFromRegistry( provider: string | null | undefined, + recordedEndpointUrl?: string | null, ): { known: true; endpointUrl: string | null } | { known: false } { if (!provider) return { known: true, endpointUrl: null }; if (isLocalInferenceProvider(provider)) return { known: true, endpointUrl: null }; // Custom OpenAI/Anthropic-compatible providers carry their base URL only in - // the session; without a matching session it cannot be recovered. - if (SESSION_ONLY_ENDPOINT_PROVIDER_NAMES.has(provider)) return { known: false }; - const remoteConfig = - provider === "nvidia-nim" - ? REMOTE_PROVIDER_CONFIG.build - : Object.values(REMOTE_PROVIDER_CONFIG).find((entry) => entry.providerName === provider); + // the selected sandbox's durable metadata or its own onboard session; never + // borrow the base URL from an unrelated session. Durable metadata is trusted + // only after strict URL parsing, HTTP(S) scheme validation, and canonical + // query/hash stripping at this pre-delete rebuild boundary. + if (SESSION_ONLY_ENDPOINT_PROVIDER_NAMES.has(provider)) { + const endpointUrl = canonicalCustomEndpointUrl(recordedEndpointUrl); + return endpointUrl ? { known: true, endpointUrl } : { known: false }; + } + const remoteConfig = canonicalRemoteProviderConfig(provider); // Known remote provider with a canonical endpoint → pin it. Otherwise (routed // inference, NIM, or any provider without a custom session-only URL) there is // no static URL to pin; the resume path derives it, so leave it unpinned. @@ -106,6 +162,7 @@ export interface RebuildResumeConfig { readonly model: string | null; readonly nimContainer: string | null; readonly credentialEnv: string | null; + readonly preferredInferenceApi: string | null; /** Overwrite the session endpoint with `endpointUrl`; false keeps a matching session's own custom URL. */ readonly pinEndpoint: boolean; readonly endpointUrl: string | null; @@ -156,26 +213,31 @@ export function prepareRebuildResumeConfig( const session = onboardSession.loadSession(); const sessionMatchesSandbox = session?.sandboxName === sandboxName; - const rebuildEndpoint = getRebuildEndpointFromRegistry(sb.provider); + const registrySelection = normalizeInferenceSelection(sb); + const rebuildEndpoint = getRebuildEndpointFromRegistry( + registrySelection.provider, + registrySelection.endpointUrl, + ); // When the loaded session belongs to a *different* sandbox (e.g. an // installer's just-completed onboard before `upgrade-sandboxes --auto`), the // target's inference endpoint can only be re-derived for providers with a - // canonical endpoint (NVIDIA Endpoints, Anthropic, etc.), local inference, or - // routed inference. For a custom OpenAI-compatible provider the base URL lives - // only in the target's own session — which we don't have — so recreating would - // either fail or silently reconfigure against the unrelated session's - // endpoint. Fail closed before any destructive work so the sandbox stays live. + // canonical endpoint (NVIDIA Endpoints, Anthropic, etc.), local inference, + // routed inference, or durable custom endpoint metadata recorded on the target + // registry entry. For legacy custom OpenAI-compatible entries without that + // metadata, recreating would either fail or silently reconfigure against the + // unrelated session's endpoint. Fail closed before any destructive work so the + // sandbox stays live. if ( !sessionMatchesSandbox && - sb.provider && - !isLocalInferenceProvider(sb.provider) && - sb.provider !== hermesProviderAuth.HERMES_PROVIDER_NAME && + registrySelection.provider && + !isLocalInferenceProvider(registrySelection.provider) && + registrySelection.provider !== hermesProviderAuth.HERMES_PROVIDER_NAME && !rebuildEndpoint.known ) { console.error(""); console.error( - ` ${_RD}Rebuild preflight failed:${R} cannot determine the inference endpoint for provider '${sb.provider}'.`, + ` ${_RD}Rebuild preflight failed:${R} cannot determine the inference endpoint for provider '${registrySelection.provider}'.`, ); console.error( ` The custom endpoint for '${sandboxName}' is recorded only in its own onboard session,`, @@ -186,17 +248,21 @@ export function prepareRebuildResumeConfig( console.error(""); console.error(" Sandbox is untouched — no data was lost."); bail( - `Cannot determine recreate endpoint for provider '${sb.provider}' without a matching session`, + `Cannot determine recreate endpoint for provider '${registrySelection.provider}' without a matching session`, ); return null; } return { agent: rebuildAgent, - provider: sb.provider ?? null, - model: sb.model ?? null, - nimContainer: sb.nimContainer ?? null, - credentialEnv: getRebuildCredentialEnvFromRegistry(sb.provider), + provider: registrySelection.provider, + model: registrySelection.model, + nimContainer: registrySelection.nimContainer, + credentialEnv: getRebuildCredentialEnvFromRegistry( + registrySelection.provider, + registrySelection.credentialEnv, + ), + preferredInferenceApi: registrySelection.preferredInferenceApi, pinEndpoint: !sessionMatchesSandbox && rebuildEndpoint.known, endpointUrl: rebuildEndpoint.known ? rebuildEndpoint.endpointUrl : null, ambient, diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index cb155eb09a7..148e79ed246 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -425,9 +425,11 @@ function preflightRebuildCredentials( // The target registry entry is authoritative when a matching legacy session // omitted credentialEnv; rebuild rewrites provider/model from this entry later, // so remote registry providers must still fail closed before backup/delete. - let rebuildCredentialEnv = sessionMatchesTarget - ? session?.credentialEnv || getRebuildCredentialEnvFromRegistry(sb.provider) - : getRebuildCredentialEnvFromRegistry(sb.provider); + const registryCredentialEnv = getRebuildCredentialEnvFromRegistry(sb.provider, sb.credentialEnv); + let rebuildCredentialEnv = registryCredentialEnv; + if (sessionMatchesTarget && registryCredentialEnv === null) { + rebuildCredentialEnv = session?.credentialEnv || null; + } if (!sessionMatchesTarget && session?.sandboxName) { log( `Preflight warning: session belongs to '${session.sandboxName}', not '${sandboxName}' — using registry credential env ${rebuildCredentialEnv || "(none)"}`, @@ -781,6 +783,7 @@ export async function rebuildSandbox( s.model = resumeConfig.model; s.nimContainer = resumeConfig.nimContainer; s.credentialEnv = resumeConfig.credentialEnv; + s.preferredInferenceApi = resumeConfig.preferredInferenceApi; if (resumeConfig.pinEndpoint) { s.endpointUrl = resumeConfig.endpointUrl; } diff --git a/src/lib/inference/selection.ts b/src/lib/inference/selection.ts new file mode 100644 index 00000000000..d47bf3a16de --- /dev/null +++ b/src/lib/inference/selection.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface InferenceSelection { + provider: string | null; + model: string | null; + endpointUrl: string | null; + credentialEnv: string | null; + preferredInferenceApi: string | null; + nimContainer: string | null; +} + +export type InferenceSelectionInput = Partial | null | undefined; + +function nullableString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +const SUPPORTED_INFERENCE_APIS = new Set([ + "openai-completions", + "anthropic-messages", + "openai-responses", +]); + +function nullableInferenceApi(value: unknown): string | null { + const normalized = nullableString(value); + return normalized && SUPPORTED_INFERENCE_APIS.has(normalized) ? normalized : null; +} + +export function normalizeInferenceSelection(input: InferenceSelectionInput): InferenceSelection { + return { + provider: nullableString(input?.provider), + model: nullableString(input?.model), + endpointUrl: nullableString(input?.endpointUrl), + credentialEnv: nullableString(input?.credentialEnv), + preferredInferenceApi: nullableInferenceApi(input?.preferredInferenceApi), + nimContainer: nullableString(input?.nimContainer), + }; +} + +export function inferenceSelectionRegistryFields( + input: InferenceSelectionInput, +): InferenceSelection { + return normalizeInferenceSelection(input); +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 30442fafd37..db939b9e04a 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3192,10 +3192,10 @@ async function createSandbox( const resolvedImageTag = resolveSandboxImageTagFromCreateOutput(createResult.output, buildId); const sandboxRuntimeFields = getSandboxRuntimeRegistryFields(effectiveSandboxGpuConfig); + const inferenceSelection = sandboxRegistration.selection; sandboxRegistration.registerCreatedSandbox({ sandboxName, - model, - provider, + inferenceSelection: inferenceSelection(sandboxName, provider, model, preferredInferenceApi), runtimeFields: sandboxRuntimeFields, agent, agentVersionKnown: !fromDockerfile, diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index b5c81122f46..c4dab1f723f 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -558,6 +558,8 @@ export async function handleSandboxState< deps.updateSandboxRegistry(sandboxName, { model, provider, + nimContainer, + preferredInferenceApi, ...agentRegistryFields, }); // Default-marking is deferred to finalization so a cancelled onboard never diff --git a/src/lib/onboard/sandbox-registration.test.ts b/src/lib/onboard/sandbox-registration.test.ts index ac8cf79a0e9..17e0dc87f22 100644 --- a/src/lib/onboard/sandbox-registration.test.ts +++ b/src/lib/onboard/sandbox-registration.test.ts @@ -1,13 +1,19 @@ // 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 { afterEach, describe, expect, it, vi } from "vitest"; + +import { createRequire } from "node:module"; import { buildCreatedSandboxRegistryEntry, registerCreatedSandbox, + selection, } from "../../../dist/lib/onboard/sandbox-registration"; +const requireDist = createRequire(import.meta.url); +const onboardSession = requireDist("../../../dist/lib/state/onboard-session.js"); + const runtimeFields = { gpuEnabled: true, hostGpuDetected: true, @@ -27,8 +33,14 @@ describe("buildCreatedSandboxRegistryEntry", () => { const entry = buildCreatedSandboxRegistryEntry({ sandboxName: "demo", - model: "llama", - provider: "openai-compatible", + inferenceSelection: { + model: "llama", + provider: "openai-compatible", + endpointUrl: "https://example.test/v1", + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", + nimContainer: null, + }, runtimeFields, agent: null, agentVersionKnown: true, @@ -49,6 +61,9 @@ describe("buildCreatedSandboxRegistryEntry", () => { name: "demo", model: "llama", provider: "openai-compatible", + endpointUrl: "https://example.test/v1", + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", imageTag: "nemoclaw-demo:123", policies: ["discord", "slack"], hermesToolGateways: ["filesystem"], @@ -74,8 +89,14 @@ describe("buildCreatedSandboxRegistryEntry", () => { it("skips stale messaging plans without writing legacy messaging fields", () => { const entry = buildCreatedSandboxRegistryEntry({ sandboxName: "demo", - model: "", - provider: "", + inferenceSelection: { + model: "", + provider: "", + endpointUrl: "", + credentialEnv: "", + preferredInferenceApi: "", + nimContainer: "", + }, runtimeFields, agent: null, agentVersionKnown: false, @@ -94,6 +115,10 @@ describe("buildCreatedSandboxRegistryEntry", () => { expect(entry.model).toBeNull(); expect(entry.provider).toBeNull(); + expect(entry.endpointUrl).toBeNull(); + expect(entry.credentialEnv).toBeNull(); + expect(entry.preferredInferenceApi).toBeNull(); + expect(entry.nimContainer).toBeNull(); const rawEntry = entry as unknown as Record; expect(rawEntry.messagingChannels).toBeUndefined(); expect(rawEntry.messagingChannelConfig).toBeUndefined(); @@ -105,6 +130,79 @@ describe("buildCreatedSandboxRegistryEntry", () => { expect(entry.hermesDashboardInternalPort).toBeUndefined(); expect(entry.hermesDashboardTui).toBeUndefined(); }); + + it("normalizes invalid preferred inference API values", () => { + const entry = buildCreatedSandboxRegistryEntry({ + sandboxName: "demo", + inferenceSelection: { + model: "llama", + provider: "compatible-endpoint", + endpointUrl: "https://example.test/v1", + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "chat", + nimContainer: null, + }, + runtimeFields, + agent: null, + agentVersionKnown: true, + imageTag: null, + appliedPolicies: [], + plannedMessagingState: undefined, + hermesToolGateways: [], + hermesDashboardState: { enabled: false, config: null }, + dashboardPort: 18789, + gatewayName: "nemoclaw", + gatewayPort: 8080, + }); + + expect(entry.preferredInferenceApi).toBeNull(); + }); +}); + +describe("selection", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not borrow endpoint credential or NIM metadata from an unrelated session", () => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue({ + sandboxName: "other", + provider: "compatible-endpoint", + model: "llama", + endpointUrl: "https://wrong.test/v1", + credentialEnv: "WRONG_KEY", + nimContainer: "wrong", + }); + + expect(selection("demo", "compatible-endpoint", "llama", "openai-completions")).toEqual({ + provider: "compatible-endpoint", + model: "llama", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: "openai-completions", + nimContainer: null, + }); + }); + + it("borrows session-scoped metadata only when sandbox provider and model match", () => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue({ + sandboxName: "demo", + provider: "compatible-endpoint", + model: "llama", + endpointUrl: "https://right.test/v1", + credentialEnv: "COMPATIBLE_API_KEY", + nimContainer: "nim-right", + }); + + expect(selection("demo", "compatible-endpoint", "llama", "openai-completions")).toEqual({ + provider: "compatible-endpoint", + model: "llama", + endpointUrl: "https://right.test/v1", + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", + nimContainer: "nim-right", + }); + }); }); describe("registerCreatedSandbox", () => { @@ -113,8 +211,14 @@ describe("registerCreatedSandbox", () => { const entry = registerCreatedSandbox({ sandboxName: "demo", - model: "llama", - provider: "openai-compatible", + inferenceSelection: { + model: "llama", + provider: "openai-compatible", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + }, runtimeFields, agent: null, agentVersionKnown: true, diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index 7cd7409c6d0..49fc399a5f5 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import type { AgentDefinition } from "../agent/defs"; +import type { InferenceSelection } from "../inference/selection"; +import { inferenceSelectionRegistryFields } from "../inference/selection"; +import * as onboardSession from "../state/onboard-session"; import type { SandboxEntry, SandboxMessagingState } from "../state/registry"; import * as registry from "../state/registry"; import { @@ -24,8 +27,7 @@ export type CreatedSandboxRuntimeFields = Pick< export interface CreatedSandboxRegistryEntryInput { sandboxName: string; - model: string; - provider: string; + inferenceSelection: InferenceSelection; runtimeFields: CreatedSandboxRuntimeFields; agent: AgentDefinition | null | undefined; agentVersionKnown: boolean; @@ -43,6 +45,27 @@ export interface CreatedSandboxRegistrationInput extends CreatedSandboxRegistryE registerSandbox?(entry: SandboxEntry): void; } +export function selection( + sandboxName: string, + provider: string, + model: string, + preferredInferenceApi: string | null, +): InferenceSelection { + const session = onboardSession.loadSession(); + const sessionMatches = + session?.sandboxName === sandboxName && + session.provider === provider && + session.model === model; + return inferenceSelectionRegistryFields({ + provider, + model, + endpointUrl: sessionMatches ? (session.endpointUrl ?? null) : null, + credentialEnv: sessionMatches ? (session.credentialEnv ?? null) : null, + preferredInferenceApi, + nimContainer: sessionMatches ? (session.nimContainer ?? null) : null, + }); +} + export function buildCreatedSandboxRegistryEntry( input: CreatedSandboxRegistryEntryInput, ): SandboxEntry { @@ -53,8 +76,7 @@ export function buildCreatedSandboxRegistryEntry( return { name: input.sandboxName, - model: input.model || null, - provider: input.provider || null, + ...inferenceSelectionRegistryFields(input.inferenceSelection), ...input.runtimeFields, ...getSandboxAgentRegistryFields(input.agent, input.agentVersionKnown), imageTag: input.imageTag, diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index ea2f982797f..b17af350aad 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -4,6 +4,8 @@ import fs from "node:fs"; import path from "node:path"; import { isErrnoException } from "../core/errno"; +import { inferenceSelectionRegistryFields } from "../inference/selection"; +import type { InferenceSelection } from "../inference/selection"; import { ensureConfigDir, readConfigFile, writeConfigFile } from "./config-io"; import type { SandboxMessagingState } from "./registry-messaging"; @@ -61,12 +63,9 @@ export interface SandboxGpuProofResult { at: string; } -export interface SandboxEntry { +export interface SandboxEntry extends Partial { name: string; createdAt?: string; - model?: string | null; - nimContainer?: string | null; - provider?: string | null; gpuEnabled?: boolean; hostGpuDetected?: boolean; sandboxGpuEnabled?: boolean; @@ -422,9 +421,7 @@ export function registerSandbox(entry: SandboxEntry): void { data.sandboxes[entry.name] = { name: entry.name, createdAt: entry.createdAt || new Date().toISOString(), - model: entry.model || null, - nimContainer: entry.nimContainer || null, - provider: entry.provider || null, + ...inferenceSelectionRegistryFields(entry), gpuEnabled: entry.gpuEnabled || false, hostGpuDetected: entry.hostGpuDetected === true, sandboxGpuEnabled: entry.sandboxGpuEnabled === true, diff --git a/test/e2e-scenario/live/double-onboard.test.ts b/test/e2e-scenario/live/double-onboard.test.ts index c7e34ff15ff..a83b358cc02 100644 --- a/test/e2e-scenario/live/double-onboard.test.ts +++ b/test/e2e-scenario/live/double-onboard.test.ts @@ -75,6 +75,13 @@ function onboardEnv(sandboxName: string, fakeBaseUrl: string, recreate = false): }); } +function staleRebuildEnv(sandboxName: string): NodeJS.ProcessEnv { + return { + ...onboardEnv(sandboxName, "http://127.0.0.1:9/v1"), + NEMOCLAW_MODEL: "ambient-wrong-model", + }; +} + async function ignoreCleanupError(run: () => Promise): Promise { try { await run(); @@ -317,37 +324,84 @@ function hasOwn(object: object, key: string): boolean { return Object.prototype.hasOwnProperty.call(object, key); } -function registryHas(sandboxName: string): boolean { - if (!fs.existsSync(REGISTRY_FILE)) return false; - try { - const registry = JSON.parse(fs.readFileSync(REGISTRY_FILE, "utf8")) as unknown; - if (!registry || typeof registry !== "object") return false; - - if (Array.isArray(registry)) { - return registry.some( - (entry) => - entry === sandboxName || - (entry && typeof entry === "object" && "name" in entry && entry.name === sandboxName), - ); - } +function registryEntryMatches(entry: unknown, sandboxName: string): boolean { + return ( + entry === sandboxName || + Boolean(entry && typeof entry === "object" && "name" in entry && entry.name === sandboxName) + ); +} - if (hasOwn(registry, sandboxName)) return true; - if (!hasOwn(registry, "sandboxes")) return false; +function registryContainsEntry(entries: unknown[], sandboxName: string): boolean { + return entries.some((entry) => registryEntryMatches(entry, sandboxName)); +} - const sandboxes = (registry as { sandboxes?: unknown }).sandboxes; - if (Array.isArray(sandboxes)) { - return sandboxes.some( - (entry) => - entry === sandboxName || - (entry && typeof entry === "object" && "name" in entry && entry.name === sandboxName), - ); - } - return !!sandboxes && typeof sandboxes === "object" && hasOwn(sandboxes, sandboxName); +function namedRegistryEntry( + entries: unknown[], + sandboxName: string, +): Record | null { + const found = entries.find((entry) => registryEntryMatches(entry, sandboxName)); + return found && typeof found === "object" ? (found as Record) : null; +} + +function registryEntry(sandboxName: string): Record | null { + try { + const registry = fs.existsSync(REGISTRY_FILE) + ? (JSON.parse(fs.readFileSync(REGISTRY_FILE, "utf8")) as unknown) + : null; + const registryObject = registry && typeof registry === "object" ? registry : null; + const registryRecord = + registryObject && !Array.isArray(registryObject) + ? (registryObject as Record) + : null; + const sandboxes = registryRecord?.sandboxes; + const directEntry = registryRecord?.[sandboxName] ?? null; + const arrayEntry = Array.isArray(registry) ? namedRegistryEntry(registry, sandboxName) : null; + const arraySandboxEntry = Array.isArray(sandboxes) + ? namedRegistryEntry(sandboxes, sandboxName) + : null; + const objectSandboxEntry = + sandboxes && typeof sandboxes === "object" && !Array.isArray(sandboxes) + ? (sandboxes as Record)[sandboxName] + : null; + const entry = directEntry ?? arrayEntry ?? arraySandboxEntry ?? objectSandboxEntry ?? null; + return entry && typeof entry === "object" ? (entry as Record) : null; + } catch { + return null; + } +} + +function registryHas(sandboxName: string): boolean { + try { + const registry = fs.existsSync(REGISTRY_FILE) + ? (JSON.parse(fs.readFileSync(REGISTRY_FILE, "utf8")) as unknown) + : null; + const registryRecord = + registry && typeof registry === "object" && !Array.isArray(registry) + ? (registry as Record) + : null; + const sandboxes = registryRecord?.sandboxes; + return ( + (Array.isArray(registry) && registryContainsEntry(registry, sandboxName)) || + (Array.isArray(sandboxes) && registryContainsEntry(sandboxes, sandboxName)) || + registryEntry(sandboxName) !== null + ); } catch { return false; } } +function assertRegistryInferenceMetadata(sandboxName: string, endpointUrl: string): void { + const entry = registryEntry(sandboxName); + expect(entry, `${REGISTRY_FILE} missing ${sandboxName}`).toBeTruthy(); + expect(entry).toMatchObject({ + provider: "compatible-endpoint", + model: "test-model", + endpointUrl, + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", + }); +} + async function waitOpenshellSandboxAbsent( sandbox: SandboxClient, sandboxName: string, @@ -471,6 +525,7 @@ liveTest( }); expect(sandboxAAfterFirst.exitCode, resultText(sandboxAAfterFirst)).toBe(0); expect(registryHas(SANDBOX_A), `${REGISTRY_FILE} missing ${SANDBOX_A}`).toBe(true); + assertRegistryInferenceMetadata(SANDBOX_A, fake.baseUrl); // Phase 3: second onboard with the same name must reuse the healthy gateway. const gatewayBeforeSecond = await gatewayRuntimeId(host, "phase-3-gateway-id-before"); @@ -548,6 +603,8 @@ liveTest( timeoutMs: 30_000, }); expect(sandboxAAfterThird.exitCode, resultText(sandboxAAfterThird)).toBe(0); + assertRegistryInferenceMetadata(SANDBOX_A, fake.baseUrl); + assertRegistryInferenceMetadata(SANDBOX_B, fake.baseUrl); const list = await command(host, ["list"], { artifactName: "phase-4-nemoclaw-list", @@ -603,6 +660,7 @@ liveTest( }); expect(await waitOpenshellSandboxAbsent(sandbox, SANDBOX_A, 60_000)).toBe(true); expect(registryHas(SANDBOX_A), "registry should still contain stale sandbox A").toBe(true); + assertRegistryInferenceMetadata(SANDBOX_A, fake.baseUrl); const staleStatus = await command(host, [SANDBOX_A, "status"], { artifactName: "phase-5-stale-status", @@ -627,7 +685,7 @@ liveTest( const rebuild = await command(host, [SANDBOX_A, "rebuild", "--yes"], { artifactName: "phase-5-stale-rebuild-recovery", - env: onboardEnv(SANDBOX_A, fake.baseUrl), + env: staleRebuildEnv(SANDBOX_A), timeoutMs: PHASE_TIMEOUT_MS, }); const rebuildText = resultText(rebuild); diff --git a/test/e2e-scenario/live/openclaw-inference-switch.test.ts b/test/e2e-scenario/live/openclaw-inference-switch.test.ts index 170967e9457..7f53aa7efc2 100644 --- a/test/e2e-scenario/live/openclaw-inference-switch.test.ts +++ b/test/e2e-scenario/live/openclaw-inference-switch.test.ts @@ -26,8 +26,8 @@ import { validateSandboxName, } from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; -import { shouldRunLiveE2EScenarios } from "../fixtures/live-project-gate.ts"; import { requireHostedInferenceConfig } from "../fixtures/hosted-inference.ts"; +import { shouldRunLiveE2EScenarios } from "../fixtures/live-project-gate.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); @@ -90,13 +90,26 @@ interface OpenClawConfig { } interface SandboxRegistry { - sandboxes?: Record; + sandboxes?: Record< + string, + { + provider?: unknown; + model?: unknown; + endpointUrl?: unknown; + credentialEnv?: unknown; + preferredInferenceApi?: unknown; + nimContainer?: unknown; + } + >; } interface OnboardSession { sandboxName?: unknown; provider?: unknown; model?: unknown; + endpointUrl?: unknown; + credentialEnv?: unknown; + preferredInferenceApi?: unknown; } interface MockAnthropicProvider { @@ -436,13 +449,35 @@ async function assertOpenShellRoute(host: HostCliClient, home: string): Promise< expect(plain).toContain(`Model: ${SWITCH_MODEL}`); } -async function assertRegistryAndSession(home: string): Promise { +async function assertRegistryAndSession( + home: string, + options: { hostedEndpointUrl: string; mockProvider?: MockAnthropicProvider }, +): Promise { const registryPath = path.join(home, ".nemoclaw", "sandboxes.json"); const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")) as SandboxRegistry; const sandbox = registry.sandboxes?.[SANDBOX_NAME]; expect(sandbox, `sandbox ${SANDBOX_NAME} missing from registry`).toBeTruthy(); expect(sandbox?.provider).toBe(SWITCH_PROVIDER); expect(sandbox?.model).toBe(SWITCH_MODEL); + expect(sandbox?.nimContainer).toBeNull(); + switch (SWITCH_PROVIDER) { + case "compatible-endpoint": + expect(sandbox?.endpointUrl).toBe(options.hostedEndpointUrl); + expect(sandbox?.credentialEnv).toBe("COMPATIBLE_API_KEY"); + expect(sandbox?.preferredInferenceApi).toBe("openai-completions"); + break; + case "compatible-anthropic-endpoint": + expect(sandbox?.endpointUrl).toBe( + process.env.NEMOCLAW_SWITCH_ENDPOINT_URL ?? options.mockProvider?.endpointUrl, + ); + expect(sandbox?.credentialEnv).toBe("COMPATIBLE_ANTHROPIC_API_KEY"); + expect(sandbox?.preferredInferenceApi).toBe("anthropic-messages"); + break; + default: + expect(sandbox?.endpointUrl).toBeNull(); + expect(sandbox?.credentialEnv).toBe(sandbox?.provider === SWITCH_PROVIDER ? null : undefined); + expect(sandbox?.preferredInferenceApi).toBeNull(); + } const sessionPath = path.join(home, ".nemoclaw", "onboard-session.json"); const session = JSON.parse(fs.readFileSync(sessionPath, "utf8")) as OnboardSession; @@ -450,6 +485,14 @@ async function assertRegistryAndSession(home: string): Promise { expect(session.sandboxName).toBe(SANDBOX_NAME); expect(session.provider).toBe(SWITCH_PROVIDER); expect(session.model).toBe(SWITCH_MODEL); + switch (SWITCH_PROVIDER) { + case "compatible-endpoint": + expect(session.preferredInferenceApi).toBe("openai-completions"); + break; + case "compatible-anthropic-endpoint": + expect(session.preferredInferenceApi).toBe("anthropic-messages"); + break; + } } async function assertOpenClawConfig(sandbox: SandboxClient, home: string): Promise { @@ -917,7 +960,7 @@ RUN_OPENCLAW_INFERENCE_SWITCH_TEST( await assertOpenShellRoute(host, home); await assertOpenClawConfig(sandbox, home); - await assertRegistryAndSession(home); + await assertRegistryAndSession(home, { hostedEndpointUrl: hosted.endpointUrl, mockProvider }); const inference = await checkSandboxInference(sandbox, home); if (inference !== "ok") { diff --git a/test/e2e-scenario/live/rebuild-hermes.test.ts b/test/e2e-scenario/live/rebuild-hermes.test.ts index a987f7502fd..b1ee6f6e10c 100644 --- a/test/e2e-scenario/live/rebuild-hermes.test.ts +++ b/test/e2e-scenario/live/rebuild-hermes.test.ts @@ -299,6 +299,9 @@ function seedRegistryAndSession(): SessionArtifactSummary { createdAt: new Date().toISOString(), model: HOSTED_MODEL, provider: "compatible-endpoint", + endpointUrl: HOSTED_ENDPOINT_URL, + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", gpuEnabled: false, policies: [], policyTier: null, @@ -322,6 +325,9 @@ function seedRegistryAndSession(): SessionArtifactSummary { status: "complete" as const, provider: "compatible-endpoint" as const, model: HOSTED_MODEL, + endpointUrl: HOSTED_ENDPOINT_URL, + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", messagingPlan, }; writeJsonFile(SESSION_FILE, session); @@ -609,7 +615,16 @@ test.skipIf(!shouldRunLiveE2EScenarios())( const sessionSummary = seedRegistryAndSession(); await artifacts.writeJson("phase-4-registry-session-summary.json", { registryVersion: registryVersion(), - provider: readJsonFile(REGISTRY_FILE, {}).sandboxes?.[SANDBOX_NAME]?.provider, + registryInference: { + provider: readJsonFile(REGISTRY_FILE, {}).sandboxes?.[SANDBOX_NAME]?.provider, + endpointUrl: readJsonFile(REGISTRY_FILE, {}).sandboxes?.[SANDBOX_NAME] + ?.endpointUrl, + credentialEnv: readJsonFile(REGISTRY_FILE, {}).sandboxes?.[SANDBOX_NAME] + ?.credentialEnv, + preferredInferenceApi: readJsonFile(REGISTRY_FILE, {}).sandboxes?.[ + SANDBOX_NAME + ]?.preferredInferenceApi, + }, session: sessionSummary, }); diff --git a/test/registry.test.ts b/test/registry.test.ts index 992da168098..238e32b14ff 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -69,16 +69,24 @@ describe("registry", () => { expect(registry.getDefault()).toBe("alpha"); }); - it("stores provided model/provider at registration time", () => { + it("stores durable inference metadata at registration time", () => { registry.registerSandbox({ name: "alpha", gpuEnabled: false, model: "nvidia/nemotron-3-super-120b-a12b", provider: "nvidia-prod", + endpointUrl: "https://integrate.api.nvidia.com/v1", + credentialEnv: "NVIDIA_INFERENCE_API_KEY", + preferredInferenceApi: "openai-completions", + nimContainer: null, }); const data = JSON.parse(fs.readFileSync(regFile, "utf-8")); expect(data.sandboxes.alpha.model).toBe("nvidia/nemotron-3-super-120b-a12b"); expect(data.sandboxes.alpha.provider).toBe("nvidia-prod"); + expect(data.sandboxes.alpha.endpointUrl).toBe("https://integrate.api.nvidia.com/v1"); + expect(data.sandboxes.alpha.credentialEnv).toBe("NVIDIA_INFERENCE_API_KEY"); + expect(data.sandboxes.alpha.preferredInferenceApi).toBe("openai-completions"); + expect(data.sandboxes.alpha.nimContainer).toBeNull(); }); it("persists distinct gateway bindings for two sandboxes on different ports (#4422)", () => {