diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index d776be250df..b55b275ceda 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -180,8 +180,11 @@ RUN set -eu; \ test -x /usr/local/bin/dcode; \ /usr/local/bin/dcode --version -# Copy config generator, wrapper, startup script, and shared blueprint files. -COPY agents/langchain-deepagents-code/generate-config.ts /opt/nemoclaw-deepagents-code/generate-config.ts +# Copy the managed-startup entrypoint, config generator, its shared identity contract, +# wrapper, startup script, and shared blueprint files. +COPY agents/langchain-deepagents-code/generate-config-entrypoint.ts /opt/nemoclaw-deepagents-code/generate-config.ts +COPY agents/langchain-deepagents-code/generate-config.ts /opt/nemoclaw-deepagents-code/agents/langchain-deepagents-code/generate-config.ts +COPY src/lib/inference/managed-dcode/identity.ts /opt/nemoclaw-deepagents-code/src/lib/inference/managed-dcode/identity.ts COPY agents/langchain-deepagents-code/managed-dcode-runtime.py /opt/nemoclaw-deepagents-code/managed-dcode-runtime.py COPY agents/langchain-deepagents-code/patch-managed-deepagents-code.py /opt/nemoclaw-deepagents-code/patch-managed-deepagents-code.py # SECURITY: copy only the two hash-verified plugin inputs, never the source directory. @@ -219,7 +222,7 @@ RUN test -f /usr/local/bin/nemoclaw-managed-bootstrap \ && test -f /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh \ && test ! -L /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh \ && test "$(stat -c '%u:%g:%a' /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh)" = '0:0:444' \ - && chmod 444 /opt/nemoclaw-deepagents-code/generate-config.ts /opt/nemoclaw-deepagents-code/managed-dcode-runtime.py /opt/nemoclaw-deepagents-code/patch-managed-deepagents-code.py /opt/nemoclaw-deepagents-code/validate-nemotron-ultra-profile.py /opt/nemoclaw-deepagents-code/progressive_tool_disclosure.py /opt/nemoclaw-deepagents-code/nemoclaw_observability.py /opt/nemoclaw-deepagents-code/validate-progressive-tool-disclosure.py /opt/nemoclaw-deepagents-code/validate-observability.py /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh /usr/local/lib/nemoclaw/dcode-login-profile.sh \ + && chmod 444 /opt/nemoclaw-deepagents-code/generate-config.ts /opt/nemoclaw-deepagents-code/agents/langchain-deepagents-code/generate-config.ts /opt/nemoclaw-deepagents-code/src/lib/inference/managed-dcode/identity.ts /opt/nemoclaw-deepagents-code/managed-dcode-runtime.py /opt/nemoclaw-deepagents-code/patch-managed-deepagents-code.py /opt/nemoclaw-deepagents-code/validate-nemotron-ultra-profile.py /opt/nemoclaw-deepagents-code/progressive_tool_disclosure.py /opt/nemoclaw-deepagents-code/nemoclaw_observability.py /opt/nemoclaw-deepagents-code/validate-progressive-tool-disclosure.py /opt/nemoclaw-deepagents-code/validate-observability.py /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh /usr/local/lib/nemoclaw/dcode-login-profile.sh \ && chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-managed-startup-hold /usr/local/bin/nemoclaw-managed-bootstrap /usr/local/lib/nemoclaw/dcode-wrapper.sh /usr/local/lib/nemoclaw/dcode-launcher.sh /usr/local/lib/nemoclaw/dcode-session-supervisor.py \ && test "$(stat -c '%u:%g:%a' /usr/local/lib/nemoclaw/dcode-session-supervisor.py)" = "0:0:755" \ && install -o root -g root -m 0755 /usr/local/lib/nemoclaw/dcode-launcher.sh /usr/local/lib/nemoclaw/dcode-managed-exec \ diff --git a/agents/langchain-deepagents-code/generate-config-entrypoint.ts b/agents/langchain-deepagents-code/generate-config-entrypoint.ts new file mode 100644 index 00000000000..0e108df1ddc --- /dev/null +++ b/agents/langchain-deepagents-code/generate-config-entrypoint.ts @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Preserve the managed-startup path while the generator keeps its repository-relative imports. +import "/opt/nemoclaw-deepagents-code/agents/langchain-deepagents-code/generate-config.ts"; diff --git a/agents/langchain-deepagents-code/generate-config.ts b/agents/langchain-deepagents-code/generate-config.ts index d67a9481bc0..0b9a29407a6 100644 --- a/agents/langchain-deepagents-code/generate-config.ts +++ b/agents/langchain-deepagents-code/generate-config.ts @@ -10,6 +10,12 @@ import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { + type ManagedDcodeProvider, + normalizeManagedDcodeEndpointUrl, + resolveManagedDcodeIdentity, +} from "../../src/lib/inference/managed-dcode/identity.ts"; + type ReasoningEffort = "low" | "medium" | "high"; type Settings = { @@ -22,11 +28,9 @@ type Settings = { reasoningEffort: ReasoningEffort | null; }; -type ManagedDeepAgentsProvider = "openai" | "openrouter"; - type ManagedDeepAgentsConfig = { text: string; - provider: ManagedDeepAgentsProvider; + provider: ManagedDcodeProvider; model: string; defaultModel: string; }; @@ -36,10 +40,6 @@ const NEMOTRON_ULTRA_MODEL_IDS = new Set([ "nvidia/nvidia/nemotron-3-ultra", ]); -const OPENROUTER_UPSTREAM_PROVIDERS = new Set(["openrouter", "openrouter-api"]); -const OPENROUTER_ENDPOINT_HOST = "openrouter.ai"; -const OPENROUTER_ENDPOINT_PATH = "/api/v1"; - function readSettings(env: NodeJS.ProcessEnv): Settings { const providerKey = normalizeCommentMetadata( env.NEMOCLAW_INFERENCE_PROVIDER_ID || env.NEMOCLAW_PROVIDER_KEY || "inference", @@ -58,7 +58,7 @@ function readSettings(env: NodeJS.ProcessEnv): Settings { "inference", "NEMOCLAW_UPSTREAM_PROVIDER", ), - upstreamEndpointUrl: normalizeOptionalEndpointUrl( + upstreamEndpointUrl: normalizeManagedDcodeEndpointUrl( env.NEMOCLAW_UPSTREAM_ENDPOINT_URL, "NEMOCLAW_UPSTREAM_ENDPOINT_URL", ), @@ -92,30 +92,6 @@ function normalizeCommentMetadata(value: string, name: string): string { return value.trim(); } -function normalizeOptionalEndpointUrl(value: string | undefined, name: string): string | null { - if (value === undefined || value.trim() === "") return null; - if (/[\p{Cc}\p{Cf}]/u.test(value)) { - throw new Error(`${name} must not contain control characters.`); - } - const text = value.trim(); - let url: URL; - try { - url = new URL(text); - } catch { - throw new Error(`${name} must be a valid URL.`); - } - if (url.protocol !== "http:" && url.protocol !== "https:") { - throw new Error(`${name} must use HTTP or HTTPS.`); - } - if (url.username || url.password) { - throw new Error(`${name} must not include credentials.`); - } - if (url.search || url.hash) { - throw new Error(`${name} must not include query strings or fragments.`); - } - return url.href; -} - function normalizeInferenceBaseUrl(value: string): string { if (/[\r\n]/.test(value)) { throw new Error("NEMOCLAW_INFERENCE_BASE_URL must not contain line breaks."); @@ -149,35 +125,6 @@ function tomlArray(values: readonly string[]): string { return `[${values.map(tomlString).join(", ")}]`; } -function managedDeepAgentsProviderFor(settings: Settings): ManagedDeepAgentsProvider { - if (OPENROUTER_UPSTREAM_PROVIDERS.has(settings.upstreamProvider)) return "openrouter"; - if ( - settings.upstreamProvider === "compatible-endpoint" && - isOpenRouterEndpointUrl(settings.upstreamEndpointUrl) - ) { - return "openrouter"; - } - return "openai"; -} - -function isOpenRouterEndpointUrl(value: string | null): boolean { - if (!value) return false; - const url = new URL(value); - return ( - url.protocol === "https:" && - url.hostname.toLowerCase() === OPENROUTER_ENDPOINT_HOST && - url.pathname.replace(/\/+$/, "") === OPENROUTER_ENDPOINT_PATH - ); -} - -function modelNameForManagedProvider(model: string): string { - const trimmed = model.trim(); - for (const prefix of ["openai:", "openrouter:"]) { - if (trimmed.startsWith(prefix)) return trimmed.slice(prefix.length); - } - return trimmed; -} - function openAiModelRequestParamLines( model: string, reasoningEffort: ReasoningEffort | null, @@ -206,7 +153,7 @@ function openAiModelRequestParamLines( } function providerConfigLines( - provider: ManagedDeepAgentsProvider, + provider: ManagedDcodeProvider, model: string, baseUrl: string, reasoningEffort: ReasoningEffort | null, @@ -231,9 +178,11 @@ function providerConfigLines( } function buildConfig(settings: Settings): ManagedDeepAgentsConfig { - const provider = managedDeepAgentsProviderFor(settings); - const model = modelNameForManagedProvider(settings.model); - const defaultModel = `${provider}:${model}`; + const { provider, model, defaultModel } = resolveManagedDcodeIdentity( + settings.upstreamProvider, + settings.model, + settings.upstreamEndpointUrl, + ); const text = [ "# Generated by NemoClaw. This file contains no provider secrets.", `# NemoClaw provider route: ${settings.providerKey}; upstream provider: ${settings.upstreamProvider}; API: ${settings.inferenceApi}.`, diff --git a/src/lib/inference/managed-dcode/identity.ts b/src/lib/inference/managed-dcode/identity.ts new file mode 100644 index 00000000000..211cb7472e8 --- /dev/null +++ b/src/lib/inference/managed-dcode/identity.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const OPENROUTER_ENDPOINT_URL = "https://openrouter.ai/api/v1"; +export const OPENROUTER_PROVIDER_NAME = "openrouter-api"; + +export type ManagedDcodeProvider = "openai" | "openrouter"; + +export type ManagedDcodeIdentity = { + provider: ManagedDcodeProvider; + model: string; + defaultModel: string; +}; + +export function normalizeManagedDcodeEndpointUrl( + value: string | null | undefined, + name: string, +): string | null { + if (value === undefined || value === null || value.trim() === "") return null; + if (/[\p{Cc}\p{Cf}]/u.test(value)) { + throw new Error(`${name} must not contain control characters.`); + } + const text = value.trim(); + let url: URL; + try { + url = new URL(text); + } catch { + throw new Error(`${name} must be a valid URL.`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${name} must use HTTP or HTTPS.`); + } + if (url.username || url.password) { + throw new Error(`${name} must not include credentials.`); + } + if (url.search || url.hash) { + throw new Error(`${name} must not include query strings or fragments.`); + } + return url.href; +} + +export function normalizeManagedDcodeModelName(model: string): string { + const trimmed = model.trim(); + for (const prefix of ["openai:", "openrouter:"]) { + if (trimmed.startsWith(prefix)) return trimmed.slice(prefix.length); + } + return trimmed; +} + +function isOpenRouterEndpointUrl(value: string | null | undefined): boolean { + try { + const normalized = normalizeManagedDcodeEndpointUrl(value, "endpoint URL"); + if (!normalized) return false; + const url = new URL(normalized); + const openRouterUrl = new URL(OPENROUTER_ENDPOINT_URL); + return ( + url.origin === openRouterUrl.origin && + url.pathname.replace(/\/+$/, "") === openRouterUrl.pathname.replace(/\/+$/, "") + ); + } catch { + return false; + } +} + +export function resolveManagedDcodeIdentity( + upstreamProvider: string | null | undefined, + model: string, + upstreamEndpointUrl: string | null | undefined, +): ManagedDcodeIdentity { + const providerName = upstreamProvider?.trim(); + const provider = + providerName === "openrouter" || + providerName === OPENROUTER_PROVIDER_NAME || + (providerName === "compatible-endpoint" && isOpenRouterEndpointUrl(upstreamEndpointUrl)) + ? "openrouter" + : "openai"; + const normalizedModel = normalizeManagedDcodeModelName(model); + return { + provider, + model: normalizedModel, + defaultModel: `${provider}:${normalizedModel}`, + }; +} diff --git a/src/lib/inference/openrouter.ts b/src/lib/inference/openrouter.ts index 29a451d11d4..4744330b865 100644 --- a/src/lib/inference/openrouter.ts +++ b/src/lib/inference/openrouter.ts @@ -3,10 +3,12 @@ import { OPENROUTER_RUNTIME_ADAPTER_PORT } from "../core/ports"; -export const OPENROUTER_ENDPOINT_URL = "https://openrouter.ai/api/v1"; +export { + OPENROUTER_ENDPOINT_URL, + OPENROUTER_PROVIDER_NAME, +} from "./managed-dcode/identity"; export const OPENROUTER_HELP_URL = "https://openrouter.ai/workspaces/default/keys"; export const OPENROUTER_CREDENTIAL_ENV = "OPENROUTER_API_KEY"; -export const OPENROUTER_PROVIDER_NAME = "openrouter-api"; // OpenShell does not expose a native OpenRouter provider profile yet. Register // OpenRouter through the OpenAI-compatible provider profile while keeping a // distinct provider name and credential binding in NemoClaw. diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 976bf33a3c8..dca59da8be3 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -111,7 +111,7 @@ const { getSelectionDrift, }: typeof import("./onboard/selection-drift") = require("./onboard/selection-drift"); const { - getDcodeSelectionDrift, + createDcodeSelectionDriftReader, requiresSelectionRecreate, usesManagedDcodeIdentity, }: typeof import("./onboard/dcode-selection-drift") = require("./onboard/dcode-selection-drift"); @@ -1582,7 +1582,7 @@ const sandboxCreateOrchestrationRuntime = { get getDashboardForwardPort() { return getDashboardForwardPort; }, - getDcodeSelectionDrift, + readDcodeSelectionDrift: createDcodeSelectionDriftReader(runCaptureOpenshell), getDefaultSandboxNameForAgent, getDockerDriverGatewayStateDir, getHermesToolGatewayBroker, @@ -1668,6 +1668,7 @@ const createSandboxWithBaseImageResolution = sandboxCreateOrchestrationRuntime, ); + const { createSandbox, createSandboxWithTemporaryManagedRuntime } = agentOnboard.createHermesApiPortScopedSandboxEntryPoints({ createBaseImageResolutionContext: () => @@ -3231,6 +3232,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { }, }, }, + sandbox: { gatewayName: GATEWAY_NAME, hermesPortableLifecycle: @@ -3263,10 +3265,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { messagingChannelConfigsEqual, getSandboxReuseState, getSandboxRecreateObservation, - getDcodeSelectionDrift: (name, selectedProvider, selectedModel, selectedApi) => - getDcodeSelectionDrift(name, selectedProvider, selectedModel, selectedApi, { - runCaptureOpenshell, - }), + getDcodeSelectionDrift: createDcodeSelectionDriftReader(runCaptureOpenshell), hasSandboxGpuDrift, getSandboxHermesToolGateways: (name) => registry.getSandbox(name)?.hermesToolGateways, getSandboxRegistryEntry: registry.getSandbox, @@ -3315,6 +3314,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { hermesApiPortReservationScope, ...createArgs, ), + ), ), updateSandboxRegistry: (name, updates) => registry.updateSandbox(name, updates), diff --git a/src/lib/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index ad7ed9bef4d..58c8ea1cc3d 100644 --- a/src/lib/onboard/created-sandbox-finalization.test.ts +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -12,6 +12,7 @@ import type { SandboxEntry } from "../state/registry"; import * as sandboxState from "../state/sandbox"; import { createCreatedSandboxCompletionActions, + createOnboardCreatedSandboxCompletion, finalizeCreatedSandbox, } from "./created-sandbox-finalization"; import { getDcodeSelectionDrift } from "./dcode-selection-drift"; @@ -246,6 +247,186 @@ describe("created DCode sandbox finalization", () => { } }); + it("publishes fresh metadata after endpoint-aware OpenRouter validation (#9555)", () => { + const endpointUrl = "https://openrouter.ai/api/v1"; + const getDcodeSelectionDrift = vi.fn(() => ({ + changed: false, + providerChanged: false, + modelChanged: false, + existingProvider: "openrouter", + existingModel: "openrouter:nvidia/nemotron-3-ultra-550b-a55b", + unknown: false, + })); + const register = vi.fn(); + + finalizeCreatedSandbox( + { + sandboxName: "dcode", + restoreBackupPath: null, + preUpgradeBackup: false, + targetAgentType: "langchain-deepagents-code", + validateManagedDcode: true, + provider: "compatible-endpoint", + model: "nvidia/nemotron-3-ultra-550b-a55b", + preferredInferenceApi: "openai-completions", + endpointUrl, + }, + { + discoverFreshOpenClawImagePluginInstalls: vi.fn(), + restoreRecreatedSandboxState: vi.fn(), + getDcodeSelectionDrift, + register, + note: vi.fn(), + error: vi.fn(), + exitProcess: (code): never => { + throw new Error(`exit ${code}`); + }, + }, + ); + + expect(getDcodeSelectionDrift).toHaveBeenCalledWith( + "dcode", + "compatible-endpoint", + "nvidia/nemotron-3-ultra-550b-a55b", + "openai-completions", + endpointUrl, + ); + expect(register).toHaveBeenCalledOnce(); + }); + + it("passes the fresh create endpoint through the production completion constructor (#9555)", async () => { + const endpointUrl = "https://openrouter.ai/api/v1"; + const model = "nvidia/nemotron-3-ultra-550b-a55b"; + const runCaptureOpenshell = vi.fn(() => + [ + "Sandbox: dcode", + "Route: inference", + "Provider: compatible-endpoint", + `Model: openai:${model}`, + "Endpoint: https://inference.local/v1", + "Runtime: Deep Agents Code (terminal)", + ].join("\n"), + ); + vi.spyOn(process, "exit").mockImplementation((code): never => { + throw new Error(`exit ${code}`); + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + const completionArgs = [ + "dcode", + null, + null, + null, + null, + { customOpenClawImage: false, isManagedDcodeAgent: true }, + { + provider: "compatible-endpoint", + model, + preferredInferenceApi: "openai-completions", + endpointUrl, + }, + { + createIntent: { endpointUrl, endpointSource: null, observabilityEnabled: false }, + resolvedCreateIntent: { + policy: { options: { baselineExclusions: [] } }, + hostMounts: undefined, + }, + }, + { + gpuEnabled: false, + hostGpuDetected: false, + sandboxGpuEnabled: false, + sandboxGpuMode: "none", + sandboxGpuDevice: null, + sandboxGpuProof: null, + openshellDriver: "docker", + openshellVersion: "0.0.101", + }, + false, + { toolDisclosure: undefined, dcodeAutoApprovalMode: "disabled" }, + { webSearchConfig: null, hermesAuthMethod: null }, + { + plannedMessagingState: undefined, + preservedMcpState: undefined, + hermesToolGateways: [], + }, + null, + { gatewayName: "nemoclaw", gatewayPort: 8080 }, + { + initialSandboxPolicy: { appliedPresets: ["personal-open-internet"] }, + policyTier: null, + dashboardRemoteBindPrepared: false, + }, + null, + "build-1", + { + mode: "none", + hostGpuDetected: false, + hostGpuPlatform: "linux", + sandboxGpuEnabled: false, + sandboxGpuDevice: null, + errors: [], + }, + false, + vi.fn(), + runCaptureOpenshell, + "http://127.0.0.1:8643", + { config: null, enabled: false }, + vi.fn(), + vi.fn(), + vi.fn(), + vi.fn(), + vi.fn(), + { + runtimeProvider: null, + ensurePreparedWorkload: vi.fn(), + ensurePreparedProfile: vi.fn(), + }, + { + source: { + kind: "legacy-dockerfile", + dockerfilePath: "/workspace/Dockerfile", + reason: "agent-not-managed", + }, + release: null, + fallbackDiagnostic: null, + }, + vi.fn(), + ] as unknown as Parameters; + const completion = createOnboardCreatedSandboxCompletion(...completionArgs); + const created = { + createResult: { status: 0, output: "", sawProgress: true }, + route: "native", + firstCreateOutput: "", + registryImageRef: null, + lifecycleRegistrationFields: { lifecycleGeneration: "generation-1" }, + } as SandboxGpuCreateFlowResult; + const lifecycleLiveIdentityFingerprint = "a".repeat(64); + const lifecycle = { + generation: "generation-1", + capture: () => ({ + lifecycleGeneration: "generation-1", + lifecycleLiveIdentityFingerprint, + }), + revalidate: (registration: { + lifecycleGeneration: string; + lifecycleLiveIdentityFingerprint: string; + }) => registration, + }; + + await expect( + completion.complete( + created, + null, + "disabled", + false, + () => ({ lifecycleGeneration: "generation-1" }), + lifecycle, + ), + ).rejects.toThrow("exit 1"); + expect(runCaptureOpenshell).toHaveBeenCalledOnce(); + }); + it("does not publish registry metadata when live validation fails (#6311)", () => { const register = vi.fn(); const error = vi.fn(); diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts index f61a5de0d0d..310eaa10bba 100644 --- a/src/lib/onboard/created-sandbox-finalization.ts +++ b/src/lib/onboard/created-sandbox-finalization.ts @@ -21,7 +21,7 @@ import * as sandboxState from "../state/sandbox"; import * as buildContext from "../build-context"; import { resolveSandboxImageTagFromCreateOutput } from "../domain/sandbox/image-tag"; import { restoreDefaultAfterRecreate } from "./default-preservation"; -import { getDcodeSelectionDrift } from "./dcode-selection-drift"; +import { createDcodeSelectionDriftReader } from "./dcode-selection-drift"; import * as dockerGpuLocalInference from "./docker-gpu-local-inference"; import type { HermesDashboardOnboardState } from "./hermes-dashboard"; import type { HermesPortableConfiguredReceipt } from "./experimental/hermes-portable-receipt"; @@ -53,6 +53,7 @@ export type CreatedSandboxFinalizationOptions = { provider: string; model: string; preferredInferenceApi: string | null; + endpointUrl?: string | null; }; export type CreatedSandboxFinalizationDeps = { @@ -69,6 +70,7 @@ export type CreatedSandboxFinalizationDeps = { provider: string, model: string, preferredInferenceApi: string | null, + endpointUrl: string | null, ): SelectionDrift; register( openclawImagePluginInstalls?: readonly OpenClawImagePluginInstall[], @@ -399,6 +401,7 @@ type OnboardInferenceSelection = { readonly provider: string; readonly model: string; readonly preferredInferenceApi: string | null; + readonly endpointUrl: string | null; }; type OnboardMessagingRegistration = { readonly plannedMessagingState: RegistrationSeed["plannedMessagingState"]; @@ -457,7 +460,7 @@ export function createOnboardCreatedSandboxCompletion( workload: WorkloadResolutionInput["workload"], note: (message: string) => void, ): CreatedSandboxCompletionActions { - const { provider, model, preferredInferenceApi } = inference; + const { provider, model, preferredInferenceApi, endpointUrl } = inference; const { createIntent, resolvedCreateIntent } = createContext; return createCreatedSandboxCompletionActions( { @@ -472,6 +475,7 @@ export function createOnboardCreatedSandboxCompletion( provider, model, preferredInferenceApi, + endpointUrl, }, registration: { sandboxName, @@ -538,10 +542,7 @@ export function createOnboardCreatedSandboxCompletion( agent?.configPaths.dir, ), restoreRecreatedSandboxState: sandboxState.restoreRecreatedSandboxState, - getDcodeSelectionDrift: (name, selectedProvider, selectedModel, selectedApi) => - getDcodeSelectionDrift(name, selectedProvider, selectedModel, selectedApi, { - runCaptureOpenshell, - }), + getDcodeSelectionDrift: createDcodeSelectionDriftReader(runCaptureOpenshell), note, error: console.error, exitProcess: (code) => process.exit(code), @@ -632,6 +633,7 @@ export function finalizeCreatedSandbox( options.provider, options.model, options.preferredInferenceApi, + options.endpointUrl ?? null, ); if (finalSelection.changed || finalSelection.unknown) { deps.error( diff --git a/src/lib/onboard/dcode-selection-drift.test.ts b/src/lib/onboard/dcode-selection-drift.test.ts index d343f88b41b..5483805d18d 100644 --- a/src/lib/onboard/dcode-selection-drift.test.ts +++ b/src/lib/onboard/dcode-selection-drift.test.ts @@ -3,10 +3,11 @@ import { describe, expect, it, vi } from "vitest"; +import { normalizeManagedDcodeModelName } from "../inference/managed-dcode/identity"; import { + createDcodeSelectionDriftReader, getDcodeSelectionDrift, getExpectedDcodeInferenceIdentity, - normalizeDcodeModelName, parseDcodeInferenceIdentity, requiresSelectionRecreate, usesManagedDcodeIdentity, @@ -52,7 +53,8 @@ describe("live DCode selection drift", () => { }); it("mirrors generated DCode model and route identity (#6311)", () => { - expect(normalizeDcodeModelName(" openai:model:tag ")).toBe("model:tag"); + expect(normalizeManagedDcodeModelName(" openai:model:tag ")).toBe("model:tag"); + expect(normalizeManagedDcodeModelName(" openrouter:model:tag ")).toBe("model:tag"); expect( getExpectedDcodeInferenceIdentity( "compatible-anthropic-endpoint", @@ -67,8 +69,117 @@ describe("live DCode selection drift", () => { }); }); + it("accepts the generated OpenRouter identity (#9555)", () => { + const output = identity({ + Provider: "openrouter", + Model: "openrouter:nvidia/nemotron-3-ultra-550b-a55b", + }); + + expect( + getDcodeSelectionDrift("alpha", "openrouter-api", "nvidia/nemotron-3-ultra-550b-a55b", null, { + runCaptureOpenshell: () => output, + }), + ).toEqual({ + changed: false, + providerChanged: false, + modelChanged: false, + existingProvider: "openrouter", + existingModel: "openrouter:nvidia/nemotron-3-ultra-550b-a55b", + unknown: false, + }); + }); + + it("accepts the generated OpenRouter identity for its compatible endpoint (#9555)", () => { + const output = identity({ + Provider: "openrouter", + Model: "openrouter:nvidia/nemotron-3-ultra-550b-a55b", + }); + const readDcodeSelectionDrift = createDcodeSelectionDriftReader(() => output); + + expect( + readDcodeSelectionDrift( + "alpha", + "compatible-endpoint", + "nvidia/nemotron-3-ultra-550b-a55b", + null, + "https://openrouter.ai/api/v1/", + ), + ).toMatchObject({ + changed: false, + providerChanged: false, + modelChanged: false, + unknown: false, + }); + }); + + it.each([ + "https://openrouter.ai:8443/api/v1", + "https://user:password@openrouter.ai/api/v1", + "https://openrouter.ai/api/v1?route=other", + "https://openrouter.ai/api/v1#route", + ])("rejects a noncanonical OpenRouter-compatible endpoint: %s (#9555)", (endpointUrl) => { + const output = identity({ + Provider: "openrouter", + Model: "openrouter:nvidia/nemotron-3-ultra-550b-a55b", + }); + const readDcodeSelectionDrift = createDcodeSelectionDriftReader(() => output); + + expect( + readDcodeSelectionDrift( + "alpha", + "compatible-endpoint", + "nvidia/nemotron-3-ultra-550b-a55b", + null, + endpointUrl, + ), + ).toMatchObject({ + changed: true, + providerChanged: true, + modelChanged: true, + unknown: false, + }); + }); + + it("keeps ordinary compatible endpoints on the OpenAI identity (#9555)", () => { + const output = identity({ + Provider: "compatible-endpoint", + Model: "openai:model-a", + }); + const readDcodeSelectionDrift = createDcodeSelectionDriftReader(() => output); + + expect( + readDcodeSelectionDrift( + "alpha", + "compatible-endpoint", + "model-a", + null, + "https://example.test/v1", + ), + ).toMatchObject({ + changed: false, + providerChanged: false, + modelChanged: false, + unknown: false, + }); + }); + + it("rejects an OpenAI identity for an OpenRouter selection (#9555)", () => { + expect( + getDcodeSelectionDrift("alpha", "openrouter-api", "nvidia/nemotron-3-ultra-550b-a55b", null, { + runCaptureOpenshell: () => identity(), + }), + ).toMatchObject({ + changed: true, + providerChanged: true, + modelChanged: true, + unknown: false, + }); + }); + it("preserves colon-bearing model IDs in expected DCode identity (#6311)", () => { - expect(normalizeDcodeModelName("minimax/minimax-m2.5:free")).toBe("minimax/minimax-m2.5:free"); + expect(normalizeManagedDcodeModelName("minimax/minimax-m2.5:free")).toBe( + "minimax/minimax-m2.5:free", + ); expect( getExpectedDcodeInferenceIdentity("compatible-endpoint", "minimax/minimax-m2.5:free", null), ).toMatchObject({ model: "openai:minimax/minimax-m2.5:free" }); diff --git a/src/lib/onboard/dcode-selection-drift.ts b/src/lib/onboard/dcode-selection-drift.ts index c9bf87c332f..972f07e8dc4 100644 --- a/src/lib/onboard/dcode-selection-drift.ts +++ b/src/lib/onboard/dcode-selection-drift.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { getSandboxInferenceConfig } from "../inference/config"; +import { resolveManagedDcodeIdentity } from "../inference/managed-dcode/identity"; import type { SelectionDrift } from "./selection-drift"; export type DcodeInferenceIdentity = { @@ -12,12 +13,21 @@ export type DcodeInferenceIdentity = { }; export type DcodeSelectionDriftDeps = { + requestedEndpointUrl?: string | null; runCaptureOpenshell( args: string[], options?: { ignoreError?: boolean }, ): string | null | undefined; }; +export type DcodeSelectionDriftReader = ( + sandboxName: string, + requestedProvider: string | null, + requestedModel: string | null, + preferredInferenceApi: string | null, + requestedEndpointUrl: string | null, +) => SelectionDrift; + const IDENTITY_FIELDS = ["Route", "Provider", "Model", "Endpoint"] as const; type IdentityField = (typeof IDENTITY_FIELDS)[number]; @@ -46,11 +56,6 @@ const UNKNOWN_SELECTION_DRIFT: SelectionDrift = { unknown: true, }; -export function normalizeDcodeModelName(model: string): string { - const trimmed = model.trim(); - return trimmed.startsWith("openai:") ? trimmed.slice("openai:".length) : trimmed; -} - export function parseDcodeInferenceIdentity( output: string | null | undefined, ): DcodeInferenceIdentity | null { @@ -83,14 +88,23 @@ export function getExpectedDcodeInferenceIdentity( requestedProvider: string | null, requestedModel: string | null, preferredInferenceApi: string | null, + requestedEndpointUrl?: string | null, ): DcodeInferenceIdentity | null { if (requestedModel === null) return null; const route = getSandboxInferenceConfig(requestedModel, requestedProvider, preferredInferenceApi); + const managedIdentity = resolveManagedDcodeIdentity( + requestedProvider, + requestedModel, + requestedEndpointUrl, + ); return { route: route.providerKey, - provider: requestedProvider?.trim() || route.providerKey, - model: `openai:${normalizeDcodeModelName(requestedModel)}`, + provider: + managedIdentity.provider === "openrouter" + ? managedIdentity.provider + : requestedProvider?.trim() || route.providerKey, + model: managedIdentity.defaultModel, endpoint: route.inferenceBaseUrl, }; } @@ -106,6 +120,7 @@ export function getDcodeSelectionDrift( requestedProvider, requestedModel, preferredInferenceApi, + deps.requestedEndpointUrl, ); if (!sandboxName || !expected) return { ...UNKNOWN_SELECTION_DRIFT }; @@ -136,3 +151,19 @@ export function getDcodeSelectionDrift( unknown: false, }; } + +export function createDcodeSelectionDriftReader( + runCaptureOpenshell: DcodeSelectionDriftDeps["runCaptureOpenshell"], +): DcodeSelectionDriftReader { + return ( + sandboxName, + requestedProvider, + requestedModel, + preferredInferenceApi, + requestedEndpointUrl, + ) => + getDcodeSelectionDrift(sandboxName, requestedProvider, requestedModel, preferredInferenceApi, { + runCaptureOpenshell, + requestedEndpointUrl, + }); +} diff --git a/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts b/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts index 08ebc4de889..56f2c545feb 100644 --- a/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts +++ b/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts @@ -17,6 +17,7 @@ export interface Deps { provider: string, model: string, preferredInferenceApi: string | null, + endpointUrl: string | null, ): { changed: boolean; unknown: boolean }; error(message?: string): void; exitProcess(code: number): never; @@ -32,6 +33,7 @@ interface SelectionOptions { interface ResumeOptions extends SelectionOptions { readonly resume: boolean; readonly preferredInferenceApi: string | null; + readonly endpointUrl: string | null; readonly requestedDcodeAutoApprovalMode?: DcodeAutoApprovalMode | null; } @@ -116,6 +118,7 @@ export function resolveSignals( options.provider, options.model, options.preferredInferenceApi, + options.endpointUrl, ); return { inferenceSelectionChanged: Boolean(drift.changed || drift.unknown), diff --git a/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts b/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts index 1f7459dd70b..04dfc50fca8 100644 --- a/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts @@ -207,6 +207,7 @@ describe("handleSandboxState live DCode selection", () => { "provider", "model", "openai-completions", + null, ); expect(calls.createSandbox.mock.calls[0]?.at(-1)).toEqual({ resolved: expect.any(Object), @@ -257,6 +258,37 @@ describe("handleSandboxState live DCode selection", () => { expect(calls.skipped).toHaveBeenCalledWith("sandbox", "saved"); }); + it("reuses a ready OpenRouter-compatible sandbox after endpoint-aware verification (#9555)", async () => { + const endpointUrl = "https://openrouter.ai/api/v1/"; + const getDcodeSelectionDrift = vi.fn(() => ({ changed: false, unknown: false })); + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getDcodeSelectionDrift, + getSandboxRegistryEntry: (name) => + dcodeRegistryEntry(name, { + provider: "compatible-endpoint", + model: "nvidia/nemotron-3-ultra-550b-a55b", + }), + }); + + await handleSandboxState({ + ...dcodeOptions(deps), + provider: "compatible-endpoint", + model: "nvidia/nemotron-3-ultra-550b-a55b", + endpointUrl, + }); + + expect(getDcodeSelectionDrift).toHaveBeenCalledWith( + "saved", + "compatible-endpoint", + "nvidia/nemotron-3-ultra-550b-a55b", + "openai-completions", + endpointUrl, + ); + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.skipped).toHaveBeenCalledWith("sandbox", "saved"); + }); + it("refuses managed DCode reuse when the registry record is missing (#6311)", async () => { const getDcodeSelectionDrift = vi.fn(() => ({ changed: false, unknown: false })); const { deps, calls } = createDeps({ diff --git a/src/lib/onboard/sandbox-create/orchestration.test.ts b/src/lib/onboard/sandbox-create/orchestration.test.ts index 484d5559789..3174b3d6564 100644 --- a/src/lib/onboard/sandbox-create/orchestration.test.ts +++ b/src/lib/onboard/sandbox-create/orchestration.test.ts @@ -4,7 +4,45 @@ import { describe, expect, it, vi } from "vitest"; import type { SandboxEntry } from "../../state/registry"; -import { completeHermesPortableSandboxRegistration } from "./orchestration"; +import { + completeHermesPortableSandboxRegistration, + readManagedDcodeCreateSelectionDrift, +} from "./orchestration"; + +describe("managed DCode sandbox create selection", () => { + it.each([null, "https://openrouter.ai/api/v1"])( + "passes the selected endpoint to live drift validation: %s (#9555)", + (endpointUrl) => { + const readDcodeSelectionDrift = vi.fn(() => ({ + changed: false, + providerChanged: false, + modelChanged: false, + existingProvider: "openrouter", + existingModel: "openrouter:nvidia/nemotron-3-ultra-550b-a55b", + unknown: false, + })); + + readManagedDcodeCreateSelectionDrift( + { + sandboxName: "saved", + provider: "compatible-endpoint", + model: "nvidia/nemotron-3-ultra-550b-a55b", + preferredInferenceApi: "openai-completions", + createIntent: { endpointUrl }, + }, + readDcodeSelectionDrift, + ); + + expect(readDcodeSelectionDrift).toHaveBeenCalledWith( + "saved", + "compatible-endpoint", + "nvidia/nemotron-3-ultra-550b-a55b", + "openai-completions", + endpointUrl, + ); + }, + ); +}); describe("Hermes portable registration adapter", () => { it("returns the durable normalized registry entry after registration (#9211)", async () => { diff --git a/src/lib/onboard/sandbox-create/orchestration.ts b/src/lib/onboard/sandbox-create/orchestration.ts index 11beb1ecb2d..4fa8dad1c26 100644 --- a/src/lib/onboard/sandbox-create/orchestration.ts +++ b/src/lib/onboard/sandbox-create/orchestration.ts @@ -8,10 +8,11 @@ import type { BackupResult } from "../../state/sandbox"; import type { SandboxEntry } from "../../state/registry"; import type { HermesAuthMethod } from "../hermes-auth"; import type { PreparedSandboxBuildContext } from "../build-context-stage"; +import type { DcodeSelectionDriftReader } from "../dcode-selection-drift"; import type { OwnedSandboxRecreateRuntime } from "../onboard-recreate-journal"; import type { SandboxGpuConfig } from "../sandbox-gpu-mode"; import type { PortableOnboardRuntimeContext } from "../session-bootstrap"; -import type { InferenceRouteReservationAuthority } from "../types"; +import type { InferenceRouteReservationAuthority, SandboxCreateIntent } from "../types"; import * as sandboxCreatePlanMaterialization from "../sandbox-create-plan-materialization"; type SandboxRecreateReasonInput = { @@ -31,6 +32,25 @@ type SandboxRecreateReasonInput = { existingSandboxState: string; }; +export function readManagedDcodeCreateSelectionDrift( + input: { + sandboxName: string; + provider: string; + model: string; + preferredInferenceApi: string | null; + createIntent: Pick | null; + }, + readDcodeSelectionDrift: DcodeSelectionDriftReader, +) { + return readDcodeSelectionDrift( + input.sandboxName, + input.provider, + input.model, + input.preferredInferenceApi, + input.createIntent?.endpointUrl ?? null, + ); +} + function reportSandboxRecreateReason( input: SandboxRecreateReasonInput, deps: { @@ -144,7 +164,7 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche formatSandboxAgentName, formatSandboxBuildEstimateNote, getDashboardForwardPort, - getDcodeSelectionDrift, + readDcodeSelectionDrift, getDefaultSandboxNameForAgent, getDockerDriverGatewayStateDir, getHermesToolGatewayBroker, @@ -536,9 +556,10 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche hasMessagingTokens && messagingTokenDefs.some(({ name, token }) => token && !providerExistsInGateway(name)); const selectionDrift = isManagedDcodeAgent - ? getDcodeSelectionDrift(sandboxName, provider, model, preferredInferenceApi, { - runCaptureOpenshell, - }) + ? readManagedDcodeCreateSelectionDrift( + { sandboxName, provider, model, preferredInferenceApi, createIntent }, + readDcodeSelectionDrift, + ) : getSelectionDrift(sandboxName, provider, model, { runOpenshell }); const actionableSelectionDrift = requiresSelectionRecreate( selectionDrift, @@ -1043,7 +1064,7 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche agent, fromDockerfile, { customOpenClawImage, isManagedDcodeAgent }, - { provider, model, preferredInferenceApi }, + { provider, model, preferredInferenceApi, endpointUrl: createIntent?.endpointUrl ?? null }, { createIntent, resolvedCreateIntent }, sandboxRuntimeFields, agentCreateInput.portableLifecycle, diff --git a/test/langchain-deepagents-code-config.test.ts b/test/langchain-deepagents-code-config.test.ts index 7c0b4e99e24..bf5305d7a8d 100644 --- a/test/langchain-deepagents-code-config.test.ts +++ b/test/langchain-deepagents-code-config.test.ts @@ -170,6 +170,31 @@ describe("LangChain Deep Agents Code config generator", () => { expect(fs.existsSync(path.join(result.home, ".deepagents", "config.toml"))).toBe(false); }); + it.each([ + ["credentials", "https://user:password@openrouter.ai/api/v1", "must not include credentials"], + [ + "a query string", + "https://openrouter.ai/api/v1?route=other", + "must not include query strings or fragments", + ], + [ + "a fragment", + "https://openrouter.ai/api/v1#route", + "must not include query strings or fragments", + ], + ])("rejects an upstream endpoint URL with %s (#9555)", (_label, endpointUrl, message) => { + const result = runGeneratorProcess({ + NEMOCLAW_UPSTREAM_PROVIDER: "compatible-endpoint", + NEMOCLAW_UPSTREAM_ENDPOINT_URL: endpointUrl, + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain( + `NEMOCLAW_UPSTREAM_ENDPOINT_URL ${message}.`, + ); + expect(fs.existsSync(path.join(result.home, ".deepagents", "config.toml"))).toBe(false); + }); + it.each([ "nvidia/nemotron-3-ultra-550b-a55b", "nvidia/nvidia/nemotron-3-ultra", diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index 0a111e46397..e574e1c3a40 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -170,10 +170,20 @@ describe("LangChain Deep Agents Code image contracts", () => { "USER root", ].join("\n"); const managedRuntimeDirectory = "&& install -d -o root -g root -m 0755 /run/nemoclaw"; - const runtimeModeReplay = "&& chmod 444 /opt/nemoclaw-deepagents-code/generate-config.ts"; + const runtimeModeReplay = + "&& chmod 444 /opt/nemoclaw-deepagents-code/generate-config.ts /opt/nemoclaw-deepagents-code/agents/langchain-deepagents-code/generate-config.ts"; expect(dockerfile).toContain("ARG BASE_IMAGE\n"); expect(dockerfile).toContain("ARG NEMOCLAW_MODEL=nvidia/nemotron-3-ultra-550b-a55b"); + expect(dockerfile).toContain( + "COPY agents/langchain-deepagents-code/generate-config-entrypoint.ts /opt/nemoclaw-deepagents-code/generate-config.ts", + ); + expect(dockerfile).toContain( + "COPY src/lib/inference/managed-dcode/identity.ts /opt/nemoclaw-deepagents-code/src/lib/inference/managed-dcode/identity.ts", + ); + expect(dockerfile).toContain( + "node --experimental-strip-types /opt/nemoclaw-deepagents-code/generate-config.ts", + ); expect(dockerfile).not.toContain("langchain-deepagents-code-sandbox-base:latest"); expect(dockerfile).toContain( 'timeout 10 env -i /usr/local/lib/nemoclaw/dcode-wrapper.sh -n ""',