diff --git a/scripts/checks/generate-managed-startup-profile-fixture.mts b/scripts/checks/generate-managed-startup-profile-fixture.mts new file mode 100755 index 00000000000..bef51c8d347 --- /dev/null +++ b/scripts/checks/generate-managed-startup-profile-fixture.mts @@ -0,0 +1,230 @@ +#!/usr/bin/env -S node --experimental-strip-types + +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + encodeManagedStartupProfile, + MANAGED_STARTUP_AGENTS, + MANAGED_STARTUP_PROFILE_SCHEMA_VERSION, + type ManagedStartupAgent, + type ManagedStartupProfile, +} from "../../src/lib/onboard/managed-startup/profile.ts"; + +const AGENTS = new Set(MANAGED_STARTUP_AGENTS); + +export const MANAGED_STARTUP_E2E_HTTP_PROXY = "http://fixture-http-proxy.example.test:18080"; +export const MANAGED_STARTUP_E2E_HTTPS_PROXY = "http://fixture-https-proxy.example.test:18443"; +export const MANAGED_STARTUP_E2E_NO_PROXY = ["localhost", "127.0.0.1", ".example.test"] as const; + +// Real self-signed X.509 CA used by the no-network managed-image lifecycle +// gate. DCode additionally proves its hardened fetch transport selects the +// root-owned merged bundle containing these exact bytes. +export const MANAGED_STARTUP_E2E_CORPORATE_CA_PEM = `-----BEGIN CERTIFICATE----- +MIIDKzCCAhOgAwIBAgIUL3YNpyohvjOEzlwisLKfyiU3dRwwDQYJKoZIhvcNAQEL +BQAwJTEjMCEGA1UEAwwaTmVtb0NsYXcgVGVzdCBDb3Jwb3JhdGUgQ0EwHhcNMjYw +NzA2MDQwMjM2WhcNMzYwNzAzMDQwMjM2WjAlMSMwIQYDVQQDDBpOZW1vQ2xhdyBU +ZXN0IENvcnBvcmF0ZSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ALVbV5tyMc65jEH39ejvQvBk7dvI8rz8rSZl+5BWSK2a4TzKm3jD3U+qCDZPicrA +ETCDcO09bN6YIAgpB6rYg5BIURJWxFuljBIBMCZEdO6AVlbURPaGsw6RKLA3cmhx +ZekT0qMcoOKm3N+Hb5MHXsWZ8EUf0co2LsWwJgDZrdwY26gF6w+9wr3iGLE92ZbO +LHhjHUYR1oWXmkXS3YW8MN2h5I+oyL71jBiwLHUi59wogxA/LTAD97/GqwJ6DC4C +UERbIpGYhZfrbiKmT+ASJuKRXaUp/0My3IzH90RqqY70d1E/pkAsd5M8SQ332qAZ +OgW4GgO3n7gAlaN/ILwunZ8CAwEAAaNTMFEwHQYDVR0OBBYEFMa5M8bvDm85eFQi +1D5fNATE/rawMB8GA1UdIwQYMBaAFMa5M8bvDm85eFQi1D5fNATE/rawMA8GA1Ud +EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8NR/0HBUH1WbbDOmGNDzge +o+4Pz0KWR5fPDSx9CrmvUk8ijKpJQcSjQcmrXuhCoRs6aExXLh+wImKkOyMIVXfd +YFWjCffSJzeBQfDlMVW+wiAjUh7xaIqpA6Z8EmpdfyoNWd30AuHjs9m8dAa8M/lP +0qhzCbjDiHNHfYSrAuBHlMJ5RsUrNVtSZGpg1dtaSBa+8XFWWNBeJrUANxb8i7Ax +MAhrfNQcxSkZH2lVY+TA2JO83v12nKXzaW1dC94SlsFf0tVSvM3QTeWVgijpr0q+ +J0N7VBg2CdK6jRjKLQOSOPq3ySCicHhVRI8hxIWotif7mK3jj6D8NRalwmlHgNM= +-----END CERTIFICATE----- +`; + +export function managedStartupE2eProfile( + agent: ManagedStartupAgent, + changed = false, + withCorporateCa = false, + withoutHostProxy = false, +): ManagedStartupProfile { + const model = changed ? "nvidia/nemotron-3-super-120b-a12b" : "nvidia/nemotron-3-ultra-550b-a55b"; + const common = { + schemaVersion: MANAGED_STARTUP_PROFILE_SCHEMA_VERSION, + inference: { + routeProvider: "inference", + upstreamProvider: "nvidia", + model, + routedBaseUrl: "https://inference.local/v1", + upstreamEndpointUrl: null, + api: "openai-completions" as const, + }, + proxy: { + managedHost: "10.200.0.1", + managedPort: 3128, + hostHttpUrl: withoutHostProxy ? null : MANAGED_STARTUP_E2E_HTTP_PROXY, + hostHttpsUrl: withoutHostProxy ? null : MANAGED_STARTUP_E2E_HTTPS_PROXY, + hostNoProxy: withoutHostProxy ? [] : MANAGED_STARTUP_E2E_NO_PROXY, + }, + tools: { + disclosure: "progressive" as const, + enabledGateways: [], + }, + messaging: { plan: null }, + corporateCa: { + bundleSha256: withCorporateCa + ? createHash("sha256").update(MANAGED_STARTUP_E2E_CORPORATE_CA_PEM).digest("hex") + : null, + }, + }; + + switch (agent) { + case "openclaw": + return { + ...common, + agent, + agentConfig: { + agent, + webSearch: { enabled: false, provider: "brave" }, + otel: { + enabled: false, + endpointUrl: "http://host.openshell.internal:4318", + serviceName: "openclaw-gateway", + sampleRate: 1, + }, + agentTimeoutSeconds: 600, + heartbeatEvery: null, + extraAgents: { agents: [], defaults: {}, main: {} }, + deviceAuth: { disabled: true, optOutSource: "managed-onboard" }, + minimalBootstrap: true, + }, + inference: { + ...common.inference, + primaryModelRef: `inference/${model}`, + compatibility: {}, + inputModalities: ["text"], + }, + dashboard: { + agent, + mode: "loopback", + url: "http://127.0.0.1:18789", + port: 18_789, + bindAddress: "127.0.0.1", + wslExposure: false, + }, + tuning: { + contextWindow: 131_072, + maxTokens: 8192, + reasoning: false, + reasoningEffort: "default", + }, + }; + case "hermes": + return { + ...common, + agent, + agentConfig: { + agent, + webSearch: { enabled: false, provider: "tavily" }, + }, + inference: { + ...common.inference, + primaryModelRef: null, + compatibility: null, + inputModalities: null, + }, + dashboard: { + agent, + mode: "disabled", + url: "http://127.0.0.1:18789", + publicPort: null, + internalPort: null, + tuiEnabled: false, + }, + tuning: { + contextWindow: 131_072, + maxTokens: null, + reasoning: null, + reasoningEffort: null, + }, + }; + case "langchain-deepagents-code": + return { + ...common, + agent, + agentConfig: { + agent, + autoApprovalMode: "disabled", + observabilityEnabled: false, + }, + inference: { + ...common.inference, + upstreamEndpointUrl: "https://integrate.api.nvidia.com/v1", + primaryModelRef: null, + compatibility: null, + inputModalities: null, + }, + dashboard: { + agent, + mode: "disabled", + }, + tuning: { + contextWindow: null, + maxTokens: null, + reasoning: null, + reasoningEffort: null, + }, + }; + } +} + +function readAgent(value: string | undefined): ManagedStartupAgent { + if (value && AGENTS.has(value as ManagedStartupAgent)) { + return value as ManagedStartupAgent; + } + throw new Error("--agent must identify a shipped managed-image agent"); +} + +function main(argv: readonly string[]): void { + if (argv.length === 1 && argv[0] === "--corporate-ca-b64") { + process.stdout.write( + `${Buffer.from(MANAGED_STARTUP_E2E_CORPORATE_CA_PEM, "utf8").toString("base64")}\n`, + ); + return; + } + const agentIndex = argv.indexOf("--agent"); + if (agentIndex < 0) throw new Error("--agent is required"); + const unexpected = argv.filter( + (value, index) => + index !== agentIndex && + index !== agentIndex + 1 && + value !== "--changed" && + value !== "--corporate-ca" && + value !== "--without-host-proxy", + ); + if (unexpected.length > 0) { + throw new Error(`unsupported arguments: ${unexpected.join(" ")}`); + } + const agent = readAgent(argv[agentIndex + 1]); + process.stdout.write( + `${encodeManagedStartupProfile( + managedStartupE2eProfile( + agent, + argv.includes("--changed"), + argv.includes("--corporate-ca"), + argv.includes("--without-host-proxy"), + ), + )}\n`, + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/src/lib/onboard/managed-startup-agent-environment.test.ts b/src/lib/onboard/managed-startup-agent-environment.test.ts new file mode 100644 index 00000000000..3695c179ef3 --- /dev/null +++ b/src/lib/onboard/managed-startup-agent-environment.test.ts @@ -0,0 +1,780 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; + +import { describe, expect, it } from "vitest"; + +import { readHermesBuildSettings } from "../../../agents/hermes/config/build-env"; +import { buildConfig as buildOpenClawConfig } from "../../../scripts/generate-openclaw-config.mts"; +import { + type ManagedStartupAgentEnvironment, + mapManagedStartupProfileToAgentEnvironment, +} from "./managed-startup/agent-environment"; +import { + MANAGED_STARTUP_AGENTS, + MANAGED_STARTUP_PROFILE_AFFORDANCE_INVENTORY, + MANAGED_STARTUP_PROFILE_SCHEMA_VERSION, + type ManagedStartupAgent, + type ManagedStartupJsonObject, + type ManagedStartupProfile, +} from "./managed-startup/profile"; + +const CA_SHA256 = "a".repeat(64); + +function messagingPlan(agent: "openclaw" | "hermes"): ManagedStartupJsonObject { + return { + schemaVersion: 1, + sandboxName: `${agent}-sandbox`, + agent, + workflow: "onboard", + channels: [], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + runtimeSetup: { + nodePreloads: [], + envAliases: [], + secretScans: [], + }, + stateUpdates: [], + healthChecks: [], + }; +} + +function openClawProfile(): ManagedStartupProfile { + return { + schemaVersion: MANAGED_STARTUP_PROFILE_SCHEMA_VERSION, + agent: "openclaw", + agentConfig: { + agent: "openclaw", + webSearch: { enabled: true, provider: "brave" }, + otel: { + enabled: true, + endpointUrl: "http://host.openshell.internal:4318", + serviceName: "openclaw-gateway", + sampleRate: 0.5, + }, + agentTimeoutSeconds: 900, + heartbeatEvery: "30m", + extraAgents: { + agents: [ + { + id: "reviewer", + workspace: "/sandbox/.openclaw/workspace-reviewer", + agentDir: "/sandbox/.openclaw/agents/reviewer", + tools: { profile: "minimal", allow: ["read"], deny: ["exec"] }, + }, + ], + defaults: { subagents: { maxSpawnDepth: 3 } }, + main: { tools: { profile: "minimal", allow: ["read"], deny: ["exec"] } }, + }, + deviceAuth: { disabled: true, optOutSource: "managed-onboard" }, + minimalBootstrap: true, + }, + inference: { + routeProvider: "inference", + upstreamProvider: "nvidia-prod", + model: "nvidia/nemotron-3-ultra-550b-a55b", + routedBaseUrl: "https://inference.local/v1", + upstreamEndpointUrl: null, + api: "openai-responses", + primaryModelRef: "inference/nvidia/nemotron-3-ultra-550b-a55b", + compatibility: { maxRetries: 2, supportsDeveloperRole: true }, + inputModalities: ["text", "image"], + }, + proxy: { + managedHost: "10.200.0.1", + managedPort: 3128, + hostHttpUrl: "http://proxy.example.test:8080", + hostHttpsUrl: "https://connect-proxy.example.test:8443", + hostNoProxy: ["localhost", "inference.local", "127.0.0.1"], + }, + dashboard: { + agent: "openclaw", + mode: "remote", + url: "https://dashboard.example.test:18789", + port: 18_789, + bindAddress: "0.0.0.0", + wslExposure: true, + }, + tools: { + disclosure: "progressive", + enabledGateways: [], + }, + messaging: { plan: messagingPlan("openclaw") }, + tuning: { + contextWindow: 131_072, + maxTokens: 8192, + reasoning: true, + reasoningEffort: "high", + }, + corporateCa: { bundleSha256: CA_SHA256 }, + }; +} + +function hermesProfile(): ManagedStartupProfile { + return { + schemaVersion: MANAGED_STARTUP_PROFILE_SCHEMA_VERSION, + agent: "hermes", + agentConfig: { + agent: "hermes", + webSearch: { enabled: true, provider: "tavily" }, + }, + inference: { + routeProvider: "custom", + upstreamProvider: "anthropic-prod", + model: "claude-sonnet-4-5", + routedBaseUrl: "https://inference.local/v1", + upstreamEndpointUrl: null, + api: "anthropic-messages", + primaryModelRef: null, + compatibility: null, + inputModalities: null, + }, + proxy: { + managedHost: "proxy_name", + managedPort: 43128, + hostHttpUrl: "http://proxy.example.test:8080", + hostHttpsUrl: "http://proxy.example.test:3128", + hostNoProxy: ["localhost", "127.0.0.1"], + }, + dashboard: { + agent: "hermes", + mode: "loopback-forwarded", + url: "http://127.0.0.1:19189", + publicPort: 19_189, + internalPort: 29_189, + tuiEnabled: true, + }, + tools: { + disclosure: "direct", + enabledGateways: ["nous-web", "nous-image", "nous-audio", "nous-browser", "nous-code"], + }, + messaging: { plan: messagingPlan("hermes") }, + tuning: { + contextWindow: 65_536, + maxTokens: null, + reasoning: null, + reasoningEffort: null, + }, + corporateCa: { bundleSha256: CA_SHA256 }, + }; +} + +function dcodeProfile(): ManagedStartupProfile { + return { + schemaVersion: MANAGED_STARTUP_PROFILE_SCHEMA_VERSION, + agent: "langchain-deepagents-code", + agentConfig: { + agent: "langchain-deepagents-code", + autoApprovalMode: "thread-opt-in", + observabilityEnabled: true, + }, + inference: { + routeProvider: "inference", + upstreamProvider: "openrouter", + model: "openai/gpt-5.4", + routedBaseUrl: "https://inference.local/v1", + upstreamEndpointUrl: "https://openrouter.ai/api/v1", + api: "openai-completions", + primaryModelRef: null, + compatibility: null, + inputModalities: null, + }, + proxy: { + managedHost: "10.200.0.1", + managedPort: 3128, + hostHttpUrl: null, + hostHttpsUrl: null, + hostNoProxy: [], + }, + dashboard: { + agent: "langchain-deepagents-code", + mode: "disabled", + }, + tools: { + disclosure: "progressive", + enabledGateways: [], + }, + messaging: { plan: null }, + tuning: { + contextWindow: null, + maxTokens: null, + reasoning: null, + reasoningEffort: null, + }, + corporateCa: { bundleSha256: CA_SHA256 }, + }; +} + +function decodeBase64Json(encoded: string): unknown { + return JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as unknown; +} + +function representedLegacyInputs(result: ManagedStartupAgentEnvironment): string[] { + return [ + ...new Set([ + ...Object.keys(result.configurationEnvironment), + ...Object.keys(result.runtimeEnvironment), + ...result.materials.map((material) => material.legacyInput), + ]), + ].sort(); +} + +const PROFILES: Readonly ManagedStartupProfile>> = { + openclaw: openClawProfile, + hermes: hermesProfile, + "langchain-deepagents-code": dcodeProfile, +}; + +describe("managed startup agent environment", () => { + it("maps every OpenClaw profile field to the existing generator and entrypoint contracts", () => { + const result = mapManagedStartupProfileToAgentEnvironment(openClawProfile()); + + expect(result.schemaVersion).toBe(1); + expect(result.agent).toBe("openclaw"); + expect(result.configurationEnvironment).toEqual({ + CHAT_UI_URL: "https://dashboard.example.test:18789", + NEMOCLAW_AGENT_HEARTBEAT_EVERY: "30m", + NEMOCLAW_AGENT_TIMEOUT: "900", + NEMOCLAW_CONTEXT_WINDOW: "131072", + NEMOCLAW_DASHBOARD_BIND: "0.0.0.0", + NEMOCLAW_DISABLE_DEVICE_AUTH: "1", + NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE: "managed-onboard", + NEMOCLAW_EXTRA_AGENTS_JSON_B64: expect.any(String), + NEMOCLAW_INFERENCE_API: "openai-responses", + NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", + NEMOCLAW_INFERENCE_COMPAT_B64: expect.any(String), + NEMOCLAW_INFERENCE_INPUTS: "image,text", + NEMOCLAW_INFERENCE_PROVIDER_ID: "inference", + NEMOCLAW_MAX_TOKENS: "8192", + NEMOCLAW_MESSAGING_PLAN_B64: expect.any(String), + NEMOCLAW_MODEL: "nvidia/nemotron-3-ultra-550b-a55b", + NEMOCLAW_OPENCLAW_OTEL: "1", + NEMOCLAW_OPENCLAW_OTEL_ENDPOINT: "http://host.openshell.internal:4318", + NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE: "0.5", + NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME: "openclaw-gateway", + NEMOCLAW_PRIMARY_MODEL_REF: "inference/nvidia/nemotron-3-ultra-550b-a55b", + NEMOCLAW_PROXY_HOST: "10.200.0.1", + NEMOCLAW_PROXY_PORT: "3128", + NEMOCLAW_REASONING: "true", + NEMOCLAW_REASONING_EFFORT: "high", + NEMOCLAW_TOOL_DISCLOSURE: "progressive", + NEMOCLAW_UPSTREAM_PROVIDER: "nvidia-prod", + NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "brave", + NEMOCLAW_WSL_DASHBOARD_EXPOSURE: "1", + }); + const expectedOpenClawRuntime = { ...result.configurationEnvironment }; + delete expectedOpenClawRuntime.NEMOCLAW_MESSAGING_PLAN_B64; + expect(result.runtimeEnvironment).toEqual({ + ...expectedOpenClawRuntime, + HTTP_PROXY: "http://proxy.example.test:8080", + HTTPS_PROXY: "https://connect-proxy.example.test:8443", + NO_PROXY: "127.0.0.1,inference.local,localhost", + NEMOCLAW_DASHBOARD_PORT: "18789", + NEMOCLAW_MINIMAL_BOOTSTRAP: "1", + http_proxy: "http://proxy.example.test:8080", + https_proxy: "https://connect-proxy.example.test:8443", + no_proxy: "127.0.0.1,inference.local,localhost", + }); + expect(Object.hasOwn(result.runtimeEnvironment, "NEMOCLAW_MESSAGING_PLAN_B64")).toBe(false); + + expect( + decodeBase64Json(result.configurationEnvironment.NEMOCLAW_INFERENCE_COMPAT_B64 ?? ""), + ).toEqual({ + maxRetries: 2, + supportsDeveloperRole: true, + }); + expect( + decodeBase64Json(result.configurationEnvironment.NEMOCLAW_EXTRA_AGENTS_JSON_B64 ?? ""), + ).toEqual({ + agents: [ + { + agentDir: "/sandbox/.openclaw/agents/reviewer", + id: "reviewer", + tools: { allow: ["read"], deny: ["exec"], profile: "minimal" }, + workspace: "/sandbox/.openclaw/workspace-reviewer", + }, + ], + defaults: { subagents: { maxSpawnDepth: 3 } }, + main: { tools: { allow: ["read"], deny: ["exec"], profile: "minimal" } }, + }); + const encodedPlan = result.configurationEnvironment.NEMOCLAW_MESSAGING_PLAN_B64 ?? ""; + expect(encodedPlan).toMatch(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/); + expect(decodeBase64Json(encodedPlan)).toMatchObject({ + schemaVersion: 1, + sandboxName: "openclaw-sandbox", + agent: "openclaw", + }); + expect(decodeBase64Json(encodedPlan)).not.toHaveProperty("workflow"); + + expect(result.materials).toEqual([ + { + kind: "corporate-ca-handoff", + legacyInput: "NEMOCLAW_CORPORATE_CA_B64", + expectedSha256: CA_SHA256, + }, + ]); + expect(result.actions).toEqual([ + { + kind: "apply-messaging-plan", + agent: "openclaw", + mode: "apply", + phase: "runtime-setup", + runAs: "root", + }, + { kind: "generate-agent-config", agent: "openclaw", runAs: "sandbox" }, + { + kind: "apply-messaging-plan", + agent: "openclaw", + mode: "apply", + phase: "post-agent-install", + runAs: "sandbox", + }, + { + kind: "configure-dashboard", + dashboard: openClawProfile().dashboard, + }, + ]); + }); + + it("maps every Hermes profile field, including gateway presets and dashboard forwarding", () => { + const result = mapManagedStartupProfileToAgentEnvironment(hermesProfile()); + + expect(result.configurationEnvironment).toEqual({ + CHAT_UI_URL: "http://127.0.0.1:19189", + NEMOCLAW_CONTEXT_WINDOW: "65536", + NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER: "1", + NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64: expect.any(String), + NEMOCLAW_INFERENCE_API: "anthropic-messages", + NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", + NEMOCLAW_INFERENCE_PROVIDER_ID: "custom", + NEMOCLAW_MESSAGING_PLAN_B64: expect.any(String), + NEMOCLAW_MODEL: "claude-sonnet-4-5", + NEMOCLAW_TOOL_DISCLOSURE: "direct", + NEMOCLAW_UPSTREAM_PROVIDER: "anthropic-prod", + NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "tavily", + }); + expect( + decodeBase64Json( + result.configurationEnvironment.NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64 ?? "", + ), + ).toEqual(["nous-audio", "nous-browser", "nous-code", "nous-image", "nous-web"]); + expect(result.runtimeEnvironment).toEqual({ + CHAT_UI_URL: "http://127.0.0.1:19189", + HTTP_PROXY: "http://proxy.example.test:8080", + HTTPS_PROXY: "http://proxy.example.test:3128", + NO_PROXY: "127.0.0.1,localhost", + NEMOCLAW_CONTEXT_WINDOW: "65536", + NEMOCLAW_DASHBOARD_PORT: "19189", + NEMOCLAW_HERMES_DASHBOARD: "1", + NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT: "29189", + NEMOCLAW_HERMES_DASHBOARD_PORT: "19189", + NEMOCLAW_HERMES_DASHBOARD_TUI: "1", + NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER: "1", + NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64: + result.configurationEnvironment.NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64, + NEMOCLAW_INFERENCE_API: "anthropic-messages", + NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", + NEMOCLAW_INFERENCE_PROVIDER_ID: "custom", + NEMOCLAW_MODEL: "claude-sonnet-4-5", + NEMOCLAW_PROXY_HOST: "proxy_name", + NEMOCLAW_PROXY_PORT: "43128", + NEMOCLAW_TOOL_DISCLOSURE: "direct", + NEMOCLAW_UPSTREAM_PROVIDER: "anthropic-prod", + NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "tavily", + http_proxy: "http://proxy.example.test:8080", + https_proxy: "http://proxy.example.test:3128", + no_proxy: "127.0.0.1,localhost", + }); + expect(result.actions).toContainEqual({ + kind: "apply-messaging-plan", + agent: "hermes", + mode: "apply", + phase: "runtime-setup", + runAs: "root", + }); + expect(result.actions).toContainEqual({ + kind: "apply-messaging-plan", + agent: "hermes", + mode: "apply", + phase: "post-agent-install", + runAs: "sandbox", + }); + expect(result.actions).toContainEqual({ + kind: "configure-dashboard", + dashboard: hermesProfile().dashboard, + }); + }); + + it("keeps DCode routing and auto-approval in root-owned files instead of ambient runtime env", () => { + const result = mapManagedStartupProfileToAgentEnvironment(dcodeProfile()); + + expect(result.configurationEnvironment).toEqual({ + HTTP_PROXY: "", + HTTPS_PROXY: "", + NEMOCLAW_INFERENCE_API: "openai-completions", + NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", + NEMOCLAW_INFERENCE_PROVIDER_ID: "inference", + NEMOCLAW_MODEL: "openai/gpt-5.4", + NEMOCLAW_TOOL_DISCLOSURE: "progressive", + NEMOCLAW_UPSTREAM_ENDPOINT_URL: "https://openrouter.ai/api/v1", + NEMOCLAW_UPSTREAM_PROVIDER: "openrouter", + NO_PROXY: "", + http_proxy: "", + https_proxy: "", + no_proxy: "", + }); + const expectedDcodeRuntime = { ...result.configurationEnvironment }; + delete expectedDcodeRuntime.NEMOCLAW_INFERENCE_BASE_URL; + for (const name of [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + ]) { + delete expectedDcodeRuntime[name]; + } + expect(result.runtimeEnvironment).toEqual({ + ...expectedDcodeRuntime, + NEMOCLAW_OBSERVABILITY: "1", + }); + for (const environment of [result.configurationEnvironment, result.runtimeEnvironment]) { + expect(environment).not.toHaveProperty("NEMOCLAW_DCODE_AUTO_APPROVAL"); + expect(environment).not.toHaveProperty("NEMOCLAW_MESSAGING_PLAN_B64"); + expect(environment).not.toHaveProperty("NEMOCLAW_PROXY_HOST"); + expect(environment).not.toHaveProperty("NEMOCLAW_PROXY_PORT"); + } + expect(result.runtimeEnvironment).not.toHaveProperty("HTTP_PROXY"); + expect(result.runtimeEnvironment).not.toHaveProperty("HTTPS_PROXY"); + expect(result.runtimeEnvironment).not.toHaveProperty("NEMOCLAW_INFERENCE_BASE_URL"); + + expect(result.materials).toEqual([ + { + kind: "corporate-ca-handoff", + legacyInput: "NEMOCLAW_CORPORATE_CA_B64", + expectedSha256: CA_SHA256, + }, + { + kind: "root-owned-file", + legacyInput: "NEMOCLAW_DCODE_AUTO_APPROVAL", + path: "/usr/local/share/nemoclaw/dcode-auto-approval", + contents: "thread-opt-in\n", + owner: "root", + group: "root", + mode: 0o444, + }, + { + kind: "root-owned-file", + legacyInput: "NEMOCLAW_INFERENCE_BASE_URL", + path: "/usr/local/share/nemoclaw/dcode-inference-base-url", + contents: "https://inference.local/v1\n", + owner: "root", + group: "root", + mode: 0o444, + }, + { + kind: "root-owned-file", + legacyInput: "NEMOCLAW_PROXY_HOST", + path: "/usr/local/share/nemoclaw/dcode-proxy-host", + contents: "10.200.0.1\n", + owner: "root", + group: "root", + mode: 0o444, + }, + { + kind: "root-owned-file", + legacyInput: "NEMOCLAW_PROXY_PORT", + path: "/usr/local/share/nemoclaw/dcode-proxy-port", + contents: "3128\n", + owner: "root", + group: "root", + mode: 0o444, + }, + ]); + expect(result.actions).toEqual([ + { + kind: "generate-agent-config", + agent: "langchain-deepagents-code", + runAs: "sandbox", + }, + { + kind: "configure-dashboard", + dashboard: { agent: "langchain-deepagents-code", mode: "disabled" }, + }, + ]); + }); + + it("feeds the existing OpenClaw and Hermes config consumers without translation", () => { + const openclaw = mapManagedStartupProfileToAgentEnvironment(openClawProfile()); + const openclawConfig = buildOpenClawConfig({ + ...openclaw.configurationEnvironment, + ...openclaw.runtimeEnvironment, + }); + expect(openclawConfig).toMatchObject({ + agents: { + defaults: { + heartbeat: { every: "30m" }, + subagents: { maxSpawnDepth: 3 }, + timeoutSeconds: 900, + }, + list: [{ default: true, id: "main" }, { id: "reviewer" }], + }, + models: { + providers: { + inference: { + api: "openai-responses", + baseUrl: "https://inference.local/v1", + }, + }, + }, + }); + + const hermes = mapManagedStartupProfileToAgentEnvironment(hermesProfile()); + const hermesSettings = readHermesBuildSettings({ + ...hermes.configurationEnvironment, + ...hermes.runtimeEnvironment, + }); + expect(hermesSettings).toMatchObject({ + model: "claude-sonnet-4-5", + baseUrl: "https://inference.local/v1", + providerKey: "custom", + upstreamProvider: "anthropic-prod", + inferenceApi: "anthropic-messages", + contextWindow: 65_536, + toolDisclosure: "direct", + webSearchProvider: "tavily", + managedToolGateways: { + brokerEnabled: true, + presets: ["nous-audio", "nous-browser", "nous-code", "nous-image", "nous-web"], + }, + }); + }); + + it.each( + MANAGED_STARTUP_AGENTS, + )("represents the complete $0 Docker/start affordance inventory", (agent) => { + const result = mapManagedStartupProfileToAgentEnvironment(PROFILES[agent]()); + expect(representedLegacyInputs(result)).toEqual( + MANAGED_STARTUP_PROFILE_AFFORDANCE_INVENTORY[agent] + .map((affordance) => affordance.input) + .sort(), + ); + const messagingActions = result.actions.filter( + (action) => action.kind === "apply-messaging-plan", + ); + expect(messagingActions.map(({ phase, runAs }) => [phase, runAs])).toEqual( + agent === "langchain-deepagents-code" + ? [] + : [ + ["runtime-setup", "root"], + ["post-agent-install", "sandbox"], + ], + ); + expect(messagingActions.map((action) => String(action.phase))).not.toContain("agent-install"); + }); + + it("uses explicit clear states without erasing launch-only ambient proxy credentials", () => { + const openclawBase = openClawProfile(); + assert(openclawBase.agentConfig.agent === "openclaw", "fixture mismatch"); + const openclaw: ManagedStartupProfile = { + ...openclawBase, + agentConfig: { + ...openclawBase.agentConfig, + heartbeatEvery: null, + minimalBootstrap: false, + }, + proxy: { + ...openclawBase.proxy, + hostHttpUrl: null, + hostHttpsUrl: null, + hostNoProxy: [], + }, + dashboard: { + agent: "openclaw", + mode: "loopback", + url: "http://127.0.0.1:18789", + port: 18_789, + bindAddress: "127.0.0.1", + wslExposure: false, + }, + messaging: { plan: null }, + corporateCa: { bundleSha256: null }, + }; + + const openclawResult = mapManagedStartupProfileToAgentEnvironment(openclaw); + expect(openclawResult.configurationEnvironment.NEMOCLAW_AGENT_HEARTBEAT_EVERY).toBe(""); + expect(openclawResult.configurationEnvironment.NEMOCLAW_DASHBOARD_BIND).toBe(""); + expect(openclawResult.runtimeEnvironment.NEMOCLAW_MINIMAL_BOOTSTRAP).toBe("0"); + for (const name of [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + ]) { + expect(openclawResult.runtimeEnvironment).not.toHaveProperty(name); + } + expect(openclawResult.configurationEnvironment).not.toHaveProperty( + "NEMOCLAW_MESSAGING_PLAN_B64", + ); + expect(openclawResult.actions).toContainEqual({ + kind: "apply-messaging-plan", + agent: "openclaw", + mode: "clear", + phase: "runtime-setup", + runAs: "root", + }); + expect(openclawResult.actions).toContainEqual({ + kind: "apply-messaging-plan", + agent: "openclaw", + mode: "clear", + phase: "post-agent-install", + runAs: "sandbox", + }); + expect(openclawResult.materials[0]).toMatchObject({ expectedSha256: null }); + + const hermes: ManagedStartupProfile = { + ...hermesProfile(), + proxy: { + ...hermesProfile().proxy, + hostHttpUrl: null, + hostHttpsUrl: null, + hostNoProxy: [], + }, + dashboard: { + agent: "hermes", + mode: "disabled", + url: "http://127.0.0.1:18789", + publicPort: null, + internalPort: null, + tuiEnabled: false, + }, + tuning: { + contextWindow: null, + maxTokens: null, + reasoning: null, + reasoningEffort: null, + }, + }; + const hermesResult = mapManagedStartupProfileToAgentEnvironment(hermes); + expect(hermesResult.configurationEnvironment.NEMOCLAW_CONTEXT_WINDOW).toBe(""); + expect(hermesResult.runtimeEnvironment).toMatchObject({ + NEMOCLAW_DASHBOARD_PORT: "", + NEMOCLAW_HERMES_DASHBOARD: "0", + NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT: "", + NEMOCLAW_HERMES_DASHBOARD_PORT: "", + NEMOCLAW_HERMES_DASHBOARD_TUI: "0", + }); + for (const name of [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + ]) { + expect(hermesResult.runtimeEnvironment).not.toHaveProperty(name); + } + + const dcodeBase = dcodeProfile(); + const dcode: ManagedStartupProfile = { + ...dcodeBase, + inference: { ...dcodeBase.inference, upstreamEndpointUrl: null }, + }; + const dcodeResult = mapManagedStartupProfileToAgentEnvironment(dcode); + expect(dcodeResult.configurationEnvironment.NEMOCLAW_UPSTREAM_ENDPOINT_URL).toBe(""); + for (const name of [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + ]) { + expect(dcodeResult.configurationEnvironment).toHaveProperty(name, ""); + expect(dcodeResult.runtimeEnvironment).not.toHaveProperty(name); + } + }); + + it("is deterministic across profile key order and never emits certificate or credential bytes", () => { + const profile = openClawProfile(); + const cloned = JSON.parse(JSON.stringify(profile)) as ManagedStartupProfile; + const reordered: ManagedStartupProfile = { + ...cloned, + inference: { + api: profile.inference.api, + upstreamEndpointUrl: profile.inference.upstreamEndpointUrl, + compatibility: profile.inference.compatibility, + inputModalities: profile.inference.inputModalities, + routeProvider: profile.inference.routeProvider, + upstreamProvider: profile.inference.upstreamProvider, + primaryModelRef: profile.inference.primaryModelRef, + routedBaseUrl: profile.inference.routedBaseUrl, + model: profile.inference.model, + }, + }; + const first = mapManagedStartupProfileToAgentEnvironment(profile); + const second = mapManagedStartupProfileToAgentEnvironment(reordered); + + expect(JSON.stringify(first)).toBe(JSON.stringify(second)); + const serialized = JSON.stringify(first); + expect(serialized).not.toContain("BEGIN CERTIFICATE"); + expect(serialized).not.toContain("nvapi-"); + expect(serialized).not.toContain("NVIDIA_API_KEY"); + expect(serialized).toContain(CA_SHA256); + }); + + it("revalidates mismatched or unsupported messaging profiles", () => { + const profile: ManagedStartupProfile = { + ...openClawProfile(), + messaging: { plan: messagingPlan("hermes") }, + }; + expect(() => mapManagedStartupProfileToAgentEnvironment(profile)).toThrow( + /messaging.plan must be a version 1 plan for the selected agent/, + ); + + const dcode: ManagedStartupProfile = { + ...dcodeProfile(), + messaging: { plan: messagingPlan("openclaw") }, + }; + expect(() => mapManagedStartupProfileToAgentEnvironment(dcode)).toThrow( + /messaging.plan must be null for langchain-deepagents-code/, + ); + }); + + it("revalidates typed input while keeping DCode host proxy intent outside its pinned runtime", () => { + const dcodeBase = dcodeProfile(); + const profile: ManagedStartupProfile = { + ...dcodeBase, + proxy: { + ...dcodeBase.proxy, + hostHttpUrl: "http://proxy.example.test:8080", + }, + }; + const mappedDcode = mapManagedStartupProfileToAgentEnvironment(profile); + expect(mappedDcode.runtimeEnvironment.HTTP_PROXY).toBeUndefined(); + + const openclawBase = openClawProfile(); + const credentialBearing: ManagedStartupProfile = { + ...openclawBase, + inference: { + ...openclawBase.inference, + routedBaseUrl: "https://user:password@inference.local/v1", + }, + }; + expect(() => mapManagedStartupProfileToAgentEnvironment(credentialBearing)).toThrow( + /credential/, + ); + }); +}); diff --git a/src/lib/onboard/managed-startup-application.test.ts b/src/lib/onboard/managed-startup-application.test.ts new file mode 100644 index 00000000000..9c04795ac8c --- /dev/null +++ b/src/lib/onboard/managed-startup-application.test.ts @@ -0,0 +1,588 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { LEAF_PEM, PEM } from "./__test-helpers__/corporate-ca-fixtures"; +import { + commitManagedStartupApplication, + type ManagedStartupApplicationTestRuntime, + prepareManagedStartupApplication, +} from "./managed-startup/application"; +import { + encodeManagedStartupProfile, + MANAGED_STARTUP_PROFILE_SCHEMA_VERSION, + type ManagedStartupAgent, + type ManagedStartupAgentConfig, + type ManagedStartupProfile, +} from "./managed-startup/profile"; + +function sha256(bytes: string | Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function agentConfigFor(agent: ManagedStartupAgent): ManagedStartupAgentConfig { + switch (agent) { + case "openclaw": + return { + agent, + webSearch: { enabled: false, provider: "brave" }, + otel: { + enabled: false, + endpointUrl: "http://host.openshell.internal:4318", + serviceName: "openclaw-gateway", + sampleRate: 1, + }, + agentTimeoutSeconds: 900, + heartbeatEvery: null, + extraAgents: { agents: [], defaults: {}, main: {} }, + deviceAuth: { disabled: true, optOutSource: "managed-onboard" }, + minimalBootstrap: true, + }; + case "hermes": + return { agent, webSearch: { enabled: false, provider: "tavily" } }; + case "langchain-deepagents-code": + return { agent, autoApprovalMode: "thread-opt-in", observabilityEnabled: true }; + } +} + +function profileFor( + agent: ManagedStartupAgent, + corporateCa: string | null = PEM, +): ManagedStartupProfile { + const inference = + agent === "openclaw" + ? { + routeProvider: "inference", + upstreamProvider: "nvidia", + model: "nvidia/nemotron-3-ultra-550b-a55b", + routedBaseUrl: "https://inference.local/v1", + upstreamEndpointUrl: null, + api: "openai-responses" as const, + primaryModelRef: "inference/nvidia/nemotron-3-ultra-550b-a55b", + compatibility: null, + inputModalities: ["text"] as const, + } + : { + routeProvider: "inference", + upstreamProvider: agent === "hermes" ? "nvidia" : "openrouter", + model: "nvidia/nemotron-3-ultra-550b-a55b", + routedBaseUrl: "https://inference.local/v1", + upstreamEndpointUrl: + agent === "langchain-deepagents-code" ? "https://openrouter.ai/api/v1" : null, + api: "openai-completions" as const, + primaryModelRef: null, + compatibility: null, + inputModalities: null, + }; + const dashboard = + agent === "openclaw" + ? { + agent, + mode: "loopback" as const, + url: "http://127.0.0.1:18789", + port: 18_789, + bindAddress: "127.0.0.1" as const, + wslExposure: false, + } + : agent === "hermes" + ? { + agent, + mode: "disabled" as const, + url: "http://127.0.0.1:19189", + publicPort: null, + internalPort: null, + tuiEnabled: false as const, + } + : { + agent, + mode: "disabled" as const, + }; + return { + schemaVersion: MANAGED_STARTUP_PROFILE_SCHEMA_VERSION, + agent, + agentConfig: agentConfigFor(agent), + inference, + proxy: { + managedHost: "10.200.0.1", + managedPort: 3128, + hostHttpUrl: null, + hostHttpsUrl: null, + hostNoProxy: [], + }, + dashboard, + tools: { + disclosure: "progressive", + enabledGateways: [], + }, + messaging: { plan: null }, + tuning: { + contextWindow: agent === "langchain-deepagents-code" ? null : 65_536, + maxTokens: agent === "openclaw" ? 8192 : null, + reasoning: agent === "openclaw" ? true : null, + reasoningEffort: agent === "openclaw" ? "default" : null, + }, + corporateCa: { + bundleSha256: corporateCa === null ? null : sha256(corporateCa), + }, + }; +} + +describe("managed startup application", () => { + let fixtureRoot: string; + let stateDirectory: string; + let runtime: ManagedStartupApplicationTestRuntime; + + beforeEach(() => { + vi.spyOn(process, "geteuid").mockReturnValue(0); + fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-startup-")); + fs.chmodSync(fixtureRoot, 0o700); + stateDirectory = path.join(fixtureRoot, "state"); + runtime = { + rootUid: process.getuid?.() ?? 0, + rootGid: process.getgid?.() ?? 0, + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(fixtureRoot, { force: true, recursive: true }); + }); + + function prepare( + agent: ManagedStartupAgent, + corporateCa: string | null = PEM, + corporateCaB64: string | undefined = corporateCa === null + ? undefined + : Buffer.from(corporateCa, "utf8").toString("base64"), + ) { + return prepareProfile(profileFor(agent, corporateCa), corporateCaB64); + } + + function prepareProfile( + profile: ManagedStartupProfile, + corporateCaB64: string | undefined = profile.corporateCa.bundleSha256 === null + ? undefined + : Buffer.from(PEM, "utf8").toString("base64"), + targetStateDirectory: string = stateDirectory, + ) { + return prepareManagedStartupApplication( + { + encodedProfile: encodeManagedStartupProfile(profile), + expectedAgent: profile.agent, + corporateCaB64, + stateDirectory: targetStateDirectory, + }, + runtime, + ); + } + + it("requires effective uid 0 before touching state", () => { + vi.mocked(process.geteuid as () => number).mockReturnValue(1000); + expect(() => + prepareManagedStartupApplication( + { + encodedProfile: encodeManagedStartupProfile(profileFor("openclaw")), + expectedAgent: "openclaw", + corporateCaB64: Buffer.from(PEM).toString("base64"), + stateDirectory, + }, + runtime, + ), + ).toThrow(/effective uid 0/u); + expect(fs.existsSync(stateDirectory)).toBe(false); + }); + + it.each([ + "openclaw", + "hermes", + "langchain-deepagents-code", + ] as const)("prepares and commits a root-owned envelope for %s", (agent) => { + const prepared = prepare(agent); + + expect(prepared.status).toBe("prepared"); + expect(prepared.profile.agent).toBe(agent); + expect(fs.existsSync(path.join(stateDirectory, "committed.json"))).toBe(false); + expect(fs.existsSync(path.join(stateDirectory, "pending.json"))).toBe(true); + expect(fs.readFileSync(prepared.profilePath, "utf8")).toBe( + JSON.stringify(JSON.parse(fs.readFileSync(prepared.profilePath, "utf8"))), + ); + expect(fs.readFileSync(prepared.corporateCaPath as string)).toEqual(Buffer.from(PEM)); + + const stateStat = fs.statSync(stateDirectory); + const profileStat = fs.statSync(prepared.profilePath); + expect(stateStat.mode & 0o777).toBe(0o700); + expect(profileStat.mode & 0o777).toBe(0o600); + expect(profileStat.uid).toBe(runtime.rootUid); + expect(profileStat.gid).toBe(runtime.rootGid); + + const committed = commitManagedStartupApplication(prepared, runtime); + expect(committed.status).toBe("committed"); + expect(fs.existsSync(path.join(stateDirectory, "committed.json"))).toBe(true); + expect(fs.existsSync(path.join(stateDirectory, "pending.json"))).toBe(false); + }); + + it("rejects a canonical profile for the wrong image agent", () => { + expect(() => + prepareManagedStartupApplication( + { + encodedProfile: encodeManagedStartupProfile(profileFor("hermes")), + expectedAgent: "openclaw", + corporateCaB64: Buffer.from(PEM).toString("base64"), + stateDirectory, + }, + runtime, + ), + ).toThrow(/targets hermes, expected openclaw/u); + }); + + it("requires the CA transport exactly when the profile records a digest", () => { + expect(() => + prepareManagedStartupApplication( + { + encodedProfile: encodeManagedStartupProfile(profileFor("openclaw")), + expectedAgent: "openclaw", + stateDirectory, + }, + runtime, + ), + ).toThrow(/canonical standard base64/u); + + expect(() => + prepareManagedStartupApplication( + { + encodedProfile: encodeManagedStartupProfile(profileFor("openclaw", null)), + expectedAgent: "openclaw", + corporateCaB64: Buffer.from(PEM).toString("base64"), + stateDirectory, + }, + runtime, + ), + ).toThrow(/must be absent/u); + + const prepared = prepare("openclaw", null); + expect(prepared.corporateCaPath).toBeNull(); + }); + + it.each([ + { + label: "non-canonical standard base64", + pem: PEM, + encoded: `${Buffer.from(PEM).toString("base64")}\n`, + message: /canonical standard base64/u, + }, + { + label: "wrong digest", + pem: PEM, + profilePem: LEAF_PEM, + encoded: Buffer.from(PEM).toString("base64"), + message: /SHA-256 digest/u, + }, + { + label: "invalid X.509", + pem: "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----\n", + message: /invalid X\.509/u, + }, + { + label: "non-CA certificate", + pem: LEAF_PEM, + message: /CA:TRUE/u, + }, + { + label: "trailing material", + pem: `${PEM}not-a-certificate`, + message: /trailing non-PEM material/u, + }, + { + label: "too many certificates", + pem: PEM.repeat(25), + message: /1-24 PEM CA certificates/u, + }, + ])("rejects a corporate CA with $label", ({ pem, encoded, message, ...testCase }) => { + const profilePem = + "profilePem" in testCase && typeof testCase.profilePem === "string" + ? testCase.profilePem + : encoded === undefined + ? pem + : PEM; + expect(() => + prepareManagedStartupApplication( + { + encodedProfile: encodeManagedStartupProfile(profileFor("openclaw", profilePem)), + expectedAgent: "openclaw", + corporateCaB64: encoded ?? Buffer.from(pem).toString("base64"), + stateDirectory, + }, + runtime, + ), + ).toThrow(message); + }); + + it("rejects a symlinked state directory", () => { + const redirected = path.join(fixtureRoot, "redirected"); + fs.mkdirSync(redirected, { mode: 0o700 }); + fs.symlinkSync(redirected, stateDirectory); + + expect(() => prepare("openclaw")).toThrow(/real directory/u); + }); + + it("rejects permissive or non-root-owned state components", () => { + fs.mkdirSync(stateDirectory, { mode: 0o700 }); + fs.chmodSync(stateDirectory, 0o755); + expect(() => prepare("openclaw")).toThrow(/mode 0700/u); + + fs.chmodSync(stateDirectory, 0o700); + expect(() => + prepareManagedStartupApplication( + { + encodedProfile: encodeManagedStartupProfile(profileFor("openclaw")), + expectedAgent: "openclaw", + corporateCaB64: Buffer.from(PEM).toString("base64"), + stateDirectory, + }, + { ...runtime, rootUid: runtime.rootUid + 1 }, + ), + ).toThrow(/root:root/u); + }); + + it("rejects hardlinked generation files before commit", () => { + const prepared = prepare("openclaw"); + const outside = path.join(fixtureRoot, "outside-profile"); + fs.writeFileSync(outside, fs.readFileSync(prepared.profilePath), { mode: 0o600 }); + fs.unlinkSync(prepared.profilePath); + fs.linkSync(outside, prepared.profilePath); + + expect(() => commitManagedStartupApplication(prepared, runtime)).toThrow(/hardlinked/u); + }); + + it("is idempotent for one committed fingerprint and rejects profile changes", () => { + const first = prepare("openclaw"); + commitManagedStartupApplication(first, runtime); + + const repeated = prepare("openclaw"); + expect(repeated.status).toBe("already-committed"); + expect(() => commitManagedStartupApplication(repeated, runtime)).not.toThrow(); + + const changed = { + ...profileFor("openclaw"), + inference: { + ...profileFor("openclaw").inference, + model: "nvidia/a-different-model", + primaryModelRef: "inference/nvidia/a-different-model", + }, + }; + expect(() => + prepareManagedStartupApplication( + { + encodedProfile: encodeManagedStartupProfile(changed), + expectedAgent: "openclaw", + corporateCaB64: Buffer.from(PEM).toString("base64"), + stateDirectory, + }, + runtime, + ), + ).toThrow(/recreate the sandbox/u); + }); + + it("recovers a crash before commit without accepting the profile as applied", () => { + const first = prepare("hermes"); + const abandoned = path.join(stateDirectory, `.prepare-999-${"a".repeat(24)}`); + fs.mkdirSync(abandoned, { mode: 0o700 }); + fs.writeFileSync(path.join(abandoned, "profile.json"), "partial", { mode: 0o600 }); + + const recovered = prepare("hermes"); + expect(recovered.status).toBe("prepared"); + expect(recovered.fingerprint).toBe(first.fingerprint); + expect(fs.existsSync(abandoned)).toBe(false); + expect(fs.existsSync(path.join(stateDirectory, "committed.json"))).toBe(false); + + commitManagedStartupApplication(recovered, runtime); + expect(fs.existsSync(path.join(stateDirectory, "committed.json"))).toBe(true); + }); + + it("recovers a complete generation left before pending-state publication", () => { + const first = prepare("hermes"); + fs.unlinkSync(path.join(stateDirectory, "pending.json")); + + const recovered = prepare("hermes"); + expect(recovered.status).toBe("prepared"); + expect(recovered.generationDirectory).toBe(first.generationDirectory); + expect(() => commitManagedStartupApplication(recovered, runtime)).not.toThrow(); + }); + + it("recovers an atomic-control link left after publication", () => { + const first = prepare("hermes"); + const pending = path.join(stateDirectory, "pending.json"); + const interruptedTemporary = path.join(stateDirectory, `.pending.json-${"a".repeat(24)}.tmp`); + fs.linkSync(pending, interruptedTemporary); + expect(fs.statSync(pending).nlink).toBe(2); + + const recovered = prepare("hermes"); + expect(recovered.fingerprint).toBe(first.fingerprint); + expect(fs.existsSync(interruptedTemporary)).toBe(false); + expect(fs.statSync(pending).nlink).toBe(1); + expect(() => commitManagedStartupApplication(recovered, runtime)).not.toThrow(); + }); + + it("does not let a different profile replace an active pending transaction", () => { + const active = prepare("openclaw"); + const pendingBefore = fs.readFileSync(path.join(stateDirectory, "pending.json"), "utf8"); + const changed = { + ...profileFor("openclaw"), + inference: { + ...profileFor("openclaw").inference, + model: "nvidia/a-competing-model", + primaryModelRef: "inference/nvidia/a-competing-model", + }, + }; + + expect(() => prepareProfile(changed)).toThrow(/different startup profile is already pending/u); + expect(fs.readFileSync(path.join(stateDirectory, "pending.json"), "utf8")).toBe(pendingBefore); + expect(fs.existsSync(active.generationDirectory)).toBe(true); + expect(() => commitManagedStartupApplication(active, runtime)).not.toThrow(); + }); + + it("uses compare-and-swap when two profiles interleave before pending publication", () => { + const changed = { + ...profileFor("openclaw"), + inference: { + ...profileFor("openclaw").inference, + model: "nvidia/a-competing-model", + primaryModelRef: "inference/nvidia/a-competing-model", + }, + }; + const originalRenameSync = fs.renameSync.bind(fs); + const race: { active: ReturnType | null } = { active: null }; + let interleaved = false; + vi.spyOn(fs, "renameSync").mockImplementation((source, destination) => { + originalRenameSync(source, destination); + void (!interleaved && + path.dirname(destination.toString()) === stateDirectory && + path.basename(destination.toString()).startsWith("generation-") + ? (() => { + interleaved = true; + race.active = prepare("openclaw"); + })() + : undefined); + }); + + expect(() => prepareProfile(changed)).toThrow(/won the pending-state transaction/u); + expect(race.active).not.toBeNull(); + const winner = race.active as ReturnType; + expect( + JSON.parse(fs.readFileSync(path.join(stateDirectory, "pending.json"), "utf8")), + ).toMatchObject({ fingerprint: winner.fingerprint }); + expect( + fs + .readdirSync(stateDirectory) + .filter((entry) => entry.startsWith("generation-")) + .sort(), + ).toEqual([path.basename(winner.generationDirectory)]); + expect(() => commitManagedStartupApplication(winner, runtime)).not.toThrow(); + }); + + it("rejects a delayed contender after the pending owner commits", () => { + const changed = { + ...profileFor("openclaw"), + inference: { + ...profileFor("openclaw").inference, + model: "nvidia/a-delayed-competing-model", + primaryModelRef: "inference/nvidia/a-delayed-competing-model", + }, + }; + const originalOpenSync = fs.openSync.bind(fs); + const race: { committed: ReturnType | null } = { committed: null }; + let interleaved = false; + vi.spyOn(fs, "openSync").mockImplementation((target, flags, mode) => { + void (!interleaved && + path.dirname(target.toString()) === stateDirectory && + /^\.pending\.json-[a-f0-9]{24}\.tmp$/u.test(path.basename(target.toString())) + ? (() => { + interleaved = true; + race.committed = prepare("openclaw"); + commitManagedStartupApplication(race.committed, runtime); + })() + : undefined); + return originalOpenSync(target, flags, mode); + }); + + expect(() => prepareProfile(changed)).toThrow( + /different startup profile committed during pending-state publication/u, + ); + expect(race.committed).not.toBeNull(); + const winner = race.committed as ReturnType; + expect(fs.existsSync(path.join(stateDirectory, "pending.json"))).toBe(false); + expect( + JSON.parse(fs.readFileSync(path.join(stateDirectory, "committed.json"), "utf8")), + ).toMatchObject({ fingerprint: winner.fingerprint }); + expect( + fs + .readdirSync(stateDirectory) + .filter((entry) => entry.startsWith("generation-")) + .sort(), + ).toEqual([path.basename(winner.generationDirectory)]); + }); + + it("makes committed state authoritative for a reader straddling pending publication", () => { + const winner = prepare("openclaw"); + commitManagedStartupApplication(winner, runtime); + const committedPath = path.join(stateDirectory, "committed.json"); + const committedBefore = fs.readFileSync(committedPath, "utf8"); + + const changed = { + ...profileFor("openclaw"), + inference: { + ...profileFor("openclaw").inference, + model: "nvidia/a-straddling-competing-model", + primaryModelRef: "inference/nvidia/a-straddling-competing-model", + }, + }; + const competingStateDirectory = path.join(fixtureRoot, "competing-state"); + const competing = prepareProfile(changed, undefined, competingStateDirectory); + const competingGeneration = path.join( + stateDirectory, + path.basename(competing.generationDirectory), + ); + fs.renameSync(competing.generationDirectory, competingGeneration); + fs.renameSync( + path.join(competingStateDirectory, "pending.json"), + path.join(stateDirectory, "pending.json"), + ); + + const originalLstatSync = fs.lstatSync.bind(fs); + let hidInitialCommittedRead = false; + vi.spyOn(fs, "lstatSync").mockImplementation((target) => { + return !hidInitialCommittedRead && target.toString() === committedPath + ? (() => { + hidInitialCommittedRead = true; + throw Object.assign(new Error("simulated pre-commit read"), { code: "ENOENT" }); + })() + : originalLstatSync(target); + }); + + expect(() => prepareProfile(changed)).toThrow( + /different startup profile is already committed/u, + ); + expect(hidInitialCommittedRead).toBe(true); + expect(fs.readFileSync(committedPath, "utf8")).toBe(committedBefore); + expect(fs.existsSync(path.join(stateDirectory, "pending.json"))).toBe(false); + expect(fs.existsSync(competingGeneration)).toBe(false); + expect(fs.existsSync(winner.generationDirectory)).toBe(true); + }); + + it("never accepts a partial committed generation", () => { + const prepared = prepare("langchain-deepagents-code"); + commitManagedStartupApplication(prepared, runtime); + fs.truncateSync(prepared.profilePath, 10); + + expect(() => prepare("langchain-deepagents-code")).toThrow( + /not valid JSON|canonical managed startup profile/u, + ); + }); +}); diff --git a/src/lib/onboard/managed-startup-coordinator.test.ts b/src/lib/onboard/managed-startup-coordinator.test.ts new file mode 100644 index 00000000000..dc99e569ca1 --- /dev/null +++ b/src/lib/onboard/managed-startup-coordinator.test.ts @@ -0,0 +1,254 @@ +// 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 { + type CommittedManagedStartupApplication, + type PreparedManagedStartupApplication, + type PrepareManagedStartupApplicationInput, +} from "./managed-startup/application"; +import { + coordinateManagedStartupApplication, + type ManagedStartupAgentAdapter, + type ManagedStartupCoordinatorDependencies, +} from "./managed-startup/coordinator"; +import { type ManagedStartupAgent, type ManagedStartupProfile } from "./managed-startup/profile"; + +function inputFor(agent: ManagedStartupAgent): PrepareManagedStartupApplicationInput { + return { + encodedProfile: `encoded-${agent}`, + expectedAgent: agent, + }; +} + +function preparedFor( + agent: ManagedStartupAgent, + status: PreparedManagedStartupApplication["status"] = "prepared", +): PreparedManagedStartupApplication { + return { + status, + stateDirectory: "/var/lib/nemoclaw/startup-profile", + generationDirectory: `/var/lib/nemoclaw/startup-profile/generation-${"a".repeat(64)}`, + profilePath: `/var/lib/nemoclaw/startup-profile/generation-${"a".repeat(64)}/profile.json`, + corporateCaPath: null, + fingerprint: "a".repeat(64), + expectedAgent: agent, + profile: { agent } as ManagedStartupProfile, + }; +} + +function committedFrom( + prepared: PreparedManagedStartupApplication, +): CommittedManagedStartupApplication { + const { status: _status, ...application } = prepared; + return { ...application, status: "committed" }; +} + +function dependenciesFor( + prepared: PreparedManagedStartupApplication, + order: string[] = [], +): ManagedStartupCoordinatorDependencies & { + prepareApplication: ReturnType; + commitApplication: ReturnType; +} { + return { + prepareApplication: vi.fn(async () => { + order.push("prepare"); + return prepared; + }), + commitApplication: vi.fn(async (application: PreparedManagedStartupApplication) => { + order.push("commit"); + return committedFrom(application); + }), + }; +} + +function adaptersFor(order: string[] = []): { + readonly adapters: ManagedStartupAgentAdapter[]; + readonly applyByAgent: Record>; +} { + const applyByAgent = { + openclaw: vi.fn(async () => { + order.push("apply:openclaw"); + }), + hermes: vi.fn(async () => { + order.push("apply:hermes"); + }), + "langchain-deepagents-code": vi.fn(async () => { + order.push("apply:langchain-deepagents-code"); + }), + }; + return { + adapters: [ + { agent: "openclaw", apply: applyByAgent.openclaw }, + { agent: "hermes", apply: applyByAgent.hermes }, + { + agent: "langchain-deepagents-code", + apply: applyByAgent["langchain-deepagents-code"], + }, + ], + applyByAgent, + }; +} + +describe("managed startup coordinator", () => { + it.each([ + "openclaw", + "hermes", + "langchain-deepagents-code", + ] as const)("dispatches exactly the %s adapter before commit", async (agent) => { + const order: string[] = []; + const prepared = preparedFor(agent); + const dependencies = dependenciesFor(prepared, order); + const { adapters, applyByAgent } = adaptersFor(order); + + const result = await coordinateManagedStartupApplication( + inputFor(agent), + adapters, + dependencies, + ); + + expect(result.adapterApplied).toBe(true); + expect(result.application.status).toBe("committed"); + expect(order).toEqual(["prepare", `apply:${agent}`, "commit"]); + expect(applyByAgent[agent]).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + agent, + profile: prepared.profile, + fingerprint: prepared.fingerprint, + }), + ); + for (const otherAgent of ["openclaw", "hermes", "langchain-deepagents-code"] as const) { + expect(applyByAgent[otherAgent]).toHaveBeenCalledTimes(otherAgent === agent ? 1 : 0); + } + expect(dependencies.commitApplication).toHaveBeenCalledWith(prepared); + }); + + it("does not reapply mutable config for an already committed profile", async () => { + const prepared = preparedFor("openclaw", "already-committed"); + const dependencies = dependenciesFor(prepared); + const { adapters, applyByAgent } = adaptersFor(); + + const result = await coordinateManagedStartupApplication( + inputFor("openclaw"), + adapters, + dependencies, + ); + + expect(result.adapterApplied).toBe(false); + expect(dependencies.commitApplication).toHaveBeenCalledExactlyOnceWith(prepared); + for (const apply of Object.values(applyByAgent)) { + expect(apply).not.toHaveBeenCalled(); + } + }); + + it("rejects a missing adapter before preparing state", async () => { + const prepared = preparedFor("openclaw"); + const dependencies = dependenciesFor(prepared); + const { adapters } = adaptersFor(); + + await expect( + coordinateManagedStartupApplication( + inputFor("openclaw"), + adapters.filter((adapter) => adapter.agent !== "hermes"), + dependencies, + ), + ).rejects.toThrow(/missing adapter for hermes/u); + expect(dependencies.prepareApplication).not.toHaveBeenCalled(); + }); + + it("rejects a duplicate adapter before preparing state", async () => { + const prepared = preparedFor("openclaw"); + const dependencies = dependenciesFor(prepared); + const { adapters } = adaptersFor(); + + await expect( + coordinateManagedStartupApplication( + inputFor("openclaw"), + [...adapters, adapters[0] as ManagedStartupAgentAdapter], + dependencies, + ), + ).rejects.toThrow(/duplicate adapter registered for openclaw/u); + expect(dependencies.prepareApplication).not.toHaveBeenCalled(); + }); + + it("rejects an adapter for an unshipped agent before preparing state", async () => { + const prepared = preparedFor("openclaw"); + const dependencies = dependenciesFor(prepared); + const { adapters } = adaptersFor(); + const wrong = { + agent: "not-a-shipped-agent", + apply: vi.fn(), + } as unknown as ManagedStartupAgentAdapter; + + await expect( + coordinateManagedStartupApplication(inputFor("openclaw"), [...adapters, wrong], dependencies), + ).rejects.toThrow(/one shipped agent/u); + expect(dependencies.prepareApplication).not.toHaveBeenCalled(); + }); + + it("fails closed instead of cross-dispatching a mismatched prepared profile", async () => { + const prepared = { + ...preparedFor("openclaw"), + profile: { agent: "hermes" } as ManagedStartupProfile, + }; + const dependencies = dependenciesFor(prepared); + const { adapters, applyByAgent } = adaptersFor(); + + await expect( + coordinateManagedStartupApplication(inputFor("openclaw"), adapters, dependencies), + ).rejects.toThrow(/targets hermes, expected openclaw/u); + expect(dependencies.commitApplication).not.toHaveBeenCalled(); + for (const apply of Object.values(applyByAgent)) { + expect(apply).not.toHaveBeenCalled(); + } + }); + + it("does not commit an adapter failure and can retry the pending profile", async () => { + const prepared = preparedFor("hermes"); + const dependencies = dependenciesFor(prepared); + const { adapters, applyByAgent } = adaptersFor(); + applyByAgent.hermes.mockRejectedValueOnce(new Error("adapter failed")); + + await expect( + coordinateManagedStartupApplication(inputFor("hermes"), adapters, dependencies), + ).rejects.toThrow("adapter failed"); + expect(dependencies.commitApplication).not.toHaveBeenCalled(); + + const retried = await coordinateManagedStartupApplication( + inputFor("hermes"), + adapters, + dependencies, + ); + expect(retried.application.status).toBe("committed"); + expect(applyByAgent.hermes).toHaveBeenCalledTimes(2); + expect(dependencies.commitApplication).toHaveBeenCalledTimes(1); + }); + + it("reapplies a pending adapter after a crash at the commit boundary", async () => { + const prepared = preparedFor("langchain-deepagents-code"); + const dependencies = dependenciesFor(prepared); + const { adapters, applyByAgent } = adaptersFor(); + dependencies.commitApplication.mockRejectedValueOnce( + new Error("simulated process interruption"), + ); + + await expect( + coordinateManagedStartupApplication( + inputFor("langchain-deepagents-code"), + adapters, + dependencies, + ), + ).rejects.toThrow("simulated process interruption"); + + const retried = await coordinateManagedStartupApplication( + inputFor("langchain-deepagents-code"), + adapters, + dependencies, + ); + expect(retried.application.status).toBe("committed"); + expect(applyByAgent["langchain-deepagents-code"]).toHaveBeenCalledTimes(2); + expect(dependencies.commitApplication).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/onboard/managed-startup/agent-environment.ts b/src/lib/onboard/managed-startup/agent-environment.ts new file mode 100644 index 00000000000..a154667b24b --- /dev/null +++ b/src/lib/onboard/managed-startup/agent-environment.ts @@ -0,0 +1,468 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Buffer } from "node:buffer"; + +import { parseSandboxMessagingPlan } from "../../messaging/plan-validation"; +import { + type ManagedStartupAgent, + type ManagedStartupDashboard, + type ManagedStartupProfile, + validateManagedStartupProfile, +} from "./profile"; + +export type ManagedStartupConfigAgent = ManagedStartupAgent; +export type ManagedStartupMessagingAgent = "openclaw" | "hermes"; + +export interface ManagedStartupCorporateCaMaterial { + readonly kind: "corporate-ca-handoff"; + readonly legacyInput: "NEMOCLAW_CORPORATE_CA_B64"; + /** + * The certificate bytes use a separate bounded transport. Keeping only its + * digest here prevents the driver-neutral profile mapper from becoming a + * secret or arbitrary-file transport. + */ + readonly expectedSha256: string | null; +} + +export interface ManagedStartupRootOwnedFileMaterial { + readonly kind: "root-owned-file"; + readonly legacyInput: + | "NEMOCLAW_DCODE_AUTO_APPROVAL" + | "NEMOCLAW_INFERENCE_BASE_URL" + | "NEMOCLAW_PROXY_HOST" + | "NEMOCLAW_PROXY_PORT"; + readonly path: + | "/usr/local/share/nemoclaw/dcode-auto-approval" + | "/usr/local/share/nemoclaw/dcode-inference-base-url" + | "/usr/local/share/nemoclaw/dcode-proxy-host" + | "/usr/local/share/nemoclaw/dcode-proxy-port"; + readonly contents: string; + readonly owner: "root"; + readonly group: "root"; + readonly mode: 0o444; +} + +export type ManagedStartupAgentMaterial = + | ManagedStartupCorporateCaMaterial + | ManagedStartupRootOwnedFileMaterial; + +export interface ManagedStartupGenerateConfigAction { + readonly kind: "generate-agent-config"; + readonly agent: ManagedStartupConfigAgent; + readonly runAs: "sandbox"; +} + +interface ManagedStartupApplyMessagingActionBase { + readonly kind: "apply-messaging-plan"; + readonly agent: ManagedStartupMessagingAgent; + readonly mode: "apply" | "clear"; + /** + * Complete managed images already contain the reviewed dependency union. + * The runtime action vocabulary intentionally cannot express the + * package-install phase. + */ + readonly phase: "runtime-setup" | "post-agent-install"; +} + +export interface ManagedStartupApplyMessagingRuntimeAction + extends ManagedStartupApplyMessagingActionBase { + readonly phase: "runtime-setup"; + /** Writes the reduced, root-owned messaging runtime-plan artifact. */ + readonly runAs: "root"; +} + +export interface ManagedStartupApplyMessagingConfigAction + extends ManagedStartupApplyMessagingActionBase { + readonly phase: "post-agent-install"; + /** Renders only sandbox-owned agent configuration from preinstalled assets. */ + readonly runAs: "sandbox"; +} + +export type ManagedStartupApplyMessagingAction = + | ManagedStartupApplyMessagingRuntimeAction + | ManagedStartupApplyMessagingConfigAction; + +export interface ManagedStartupConfigureDashboardAction { + readonly kind: "configure-dashboard"; + readonly dashboard: ManagedStartupDashboard; +} + +export type ManagedStartupAgentAction = + | ManagedStartupGenerateConfigAction + | ManagedStartupApplyMessagingAction + | ManagedStartupConfigureDashboardAction; + +export interface ManagedStartupAgentEnvironment { + readonly schemaVersion: ManagedStartupProfile["schemaVersion"]; + readonly agent: ManagedStartupAgent; + /** + * Inputs scoped to the trusted configuration-application phase. The + * application boundary must not blindly retain this whole map in the agent + * process environment. + */ + readonly configurationEnvironment: Readonly>; + /** + * Non-secret values intentionally retained for existing entrypoints and + * agent runtime adapters after generated configuration is committed. + */ + readonly runtimeEnvironment: Readonly>; + readonly materials: readonly ManagedStartupAgentMaterial[]; + readonly actions: readonly ManagedStartupAgentAction[]; +} + +export class ManagedStartupAgentEnvironmentError extends Error { + constructor(message: string) { + super(`Cannot map managed startup profile: ${message}`); + this.name = "ManagedStartupAgentEnvironmentError"; + } +} + +type MutableEnvironment = Record; + +function booleanFlag(value: boolean): "0" | "1" { + return value ? "1" : "0"; +} + +function canonicalizeJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map((item) => canonicalizeJson(item)); + if (value === null || typeof value !== "object") return value; + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, canonicalizeJson(record[key])]), + ); +} + +function encodeCanonicalJson(value: unknown): string { + return Buffer.from(JSON.stringify(canonicalizeJson(value)), "utf8").toString("base64"); +} + +function sortedEnvironment(environment: MutableEnvironment): Readonly> { + return Object.freeze( + Object.fromEntries( + Object.entries(environment).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ), + ), + ); +} + +function commonConfigurationEnvironment(profile: ManagedStartupProfile): MutableEnvironment { + return { + NEMOCLAW_INFERENCE_API: profile.inference.api, + NEMOCLAW_INFERENCE_BASE_URL: profile.inference.routedBaseUrl, + NEMOCLAW_INFERENCE_PROVIDER_ID: profile.inference.routeProvider, + NEMOCLAW_MODEL: profile.inference.model, + NEMOCLAW_TOOL_DISCLOSURE: profile.tools.disclosure, + NEMOCLAW_UPSTREAM_PROVIDER: profile.inference.upstreamProvider, + }; +} + +function appendHostProxyEnvironment( + environment: MutableEnvironment, + profile: ManagedStartupProfile, + options: { readonly preserveAmbientWhenAbsent?: boolean } = {}, +): void { + if ( + options.preserveAmbientWhenAbsent === true && + profile.proxy.hostHttpUrl === null && + profile.proxy.hostHttpsUrl === null && + profile.proxy.hostNoProxy.length === 0 + ) { + return; + } + const httpProxy = profile.proxy.hostHttpUrl ?? ""; + const httpsProxy = profile.proxy.hostHttpsUrl ?? ""; + const noProxy = profile.proxy.hostNoProxy.join(","); + environment.HTTP_PROXY = httpProxy; + environment.HTTPS_PROXY = httpsProxy; + environment.NO_PROXY = noProxy; + environment.http_proxy = httpProxy; + environment.https_proxy = httpsProxy; + environment.no_proxy = noProxy; +} + +function messagingEnvironment( + profile: ManagedStartupProfile, + expectedAgent: ManagedStartupMessagingAgent, +): MutableEnvironment { + if (profile.messaging.plan === null) return {}; + const plan = parseSandboxMessagingPlan(profile.messaging.plan, { agent: expectedAgent }); + if (!plan) { + throw new ManagedStartupAgentEnvironmentError( + `messaging.plan must contain a validated ${expectedAgent} messaging plan`, + ); + } + const { workflow: _workflow, ...imageBuildPlan } = plan; + return { + NEMOCLAW_MESSAGING_PLAN_B64: encodeCanonicalJson(imageBuildPlan), + }; +} + +function corporateCaMaterial(profile: ManagedStartupProfile): ManagedStartupCorporateCaMaterial { + return Object.freeze({ + kind: "corporate-ca-handoff", + legacyInput: "NEMOCLAW_CORPORATE_CA_B64", + expectedSha256: profile.corporateCa.bundleSha256, + }); +} + +function rootOwnedFile( + legacyInput: ManagedStartupRootOwnedFileMaterial["legacyInput"], + path: ManagedStartupRootOwnedFileMaterial["path"], + value: string, +): ManagedStartupRootOwnedFileMaterial { + return Object.freeze({ + kind: "root-owned-file", + legacyInput, + path, + contents: `${value}\n`, + owner: "root", + group: "root", + mode: 0o444, + }); +} + +function dashboardAction( + dashboard: ManagedStartupDashboard, +): ManagedStartupConfigureDashboardAction { + return Object.freeze({ + kind: "configure-dashboard", + dashboard: Object.freeze(structuredClone(dashboard)), + }); +} + +function applicationActions( + profile: ManagedStartupProfile, + messagingAgent: ManagedStartupMessagingAgent | null, +): readonly ManagedStartupAgentAction[] { + const actions: ManagedStartupAgentAction[] = []; + if (messagingAgent !== null) { + actions.push( + Object.freeze({ + kind: "apply-messaging-plan", + agent: messagingAgent, + mode: profile.messaging.plan === null ? "clear" : "apply", + phase: "runtime-setup", + runAs: "root", + }), + ); + } + actions.push( + Object.freeze({ + kind: "generate-agent-config", + agent: profile.agent, + runAs: "sandbox", + }), + ); + if (messagingAgent !== null) { + actions.push( + Object.freeze({ + kind: "apply-messaging-plan", + agent: messagingAgent, + mode: profile.messaging.plan === null ? "clear" : "apply", + phase: "post-agent-install", + runAs: "sandbox", + }), + ); + } + actions.push(dashboardAction(profile.dashboard)); + return Object.freeze(actions); +} + +function mapOpenClawProfile(profile: ManagedStartupProfile): ManagedStartupAgentEnvironment { + if ( + profile.agent !== "openclaw" || + profile.agentConfig.agent !== "openclaw" || + profile.dashboard.agent !== "openclaw" || + profile.inference.primaryModelRef === null || + profile.inference.inputModalities === null || + profile.tuning.contextWindow === null || + profile.tuning.maxTokens === null || + profile.tuning.reasoning === null || + profile.tuning.reasoningEffort === null + ) { + throw new ManagedStartupAgentEnvironmentError("OpenClaw profile state is inconsistent"); + } + + const configurationEnvironment: MutableEnvironment = { + ...commonConfigurationEnvironment(profile), + ...messagingEnvironment(profile, "openclaw"), + CHAT_UI_URL: profile.dashboard.url, + NEMOCLAW_AGENT_HEARTBEAT_EVERY: profile.agentConfig.heartbeatEvery ?? "", + NEMOCLAW_AGENT_TIMEOUT: String(profile.agentConfig.agentTimeoutSeconds), + NEMOCLAW_CONTEXT_WINDOW: String(profile.tuning.contextWindow), + NEMOCLAW_DASHBOARD_BIND: + profile.dashboard.bindAddress === "0.0.0.0" ? profile.dashboard.bindAddress : "", + NEMOCLAW_DISABLE_DEVICE_AUTH: booleanFlag(profile.agentConfig.deviceAuth.disabled), + NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE: profile.agentConfig.deviceAuth.optOutSource, + NEMOCLAW_EXTRA_AGENTS_JSON_B64: encodeCanonicalJson(profile.agentConfig.extraAgents), + NEMOCLAW_INFERENCE_COMPAT_B64: encodeCanonicalJson(profile.inference.compatibility), + NEMOCLAW_INFERENCE_INPUTS: profile.inference.inputModalities.join(","), + NEMOCLAW_MAX_TOKENS: String(profile.tuning.maxTokens), + NEMOCLAW_OPENCLAW_OTEL: booleanFlag(profile.agentConfig.otel.enabled), + NEMOCLAW_OPENCLAW_OTEL_ENDPOINT: profile.agentConfig.otel.endpointUrl, + NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE: String(profile.agentConfig.otel.sampleRate), + NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME: profile.agentConfig.otel.serviceName, + NEMOCLAW_PRIMARY_MODEL_REF: profile.inference.primaryModelRef, + NEMOCLAW_PROXY_HOST: profile.proxy.managedHost, + NEMOCLAW_PROXY_PORT: String(profile.proxy.managedPort), + NEMOCLAW_REASONING: String(profile.tuning.reasoning), + NEMOCLAW_REASONING_EFFORT: profile.tuning.reasoningEffort, + NEMOCLAW_WEB_SEARCH_ENABLED: booleanFlag(profile.agentConfig.webSearch.enabled), + NEMOCLAW_WEB_SEARCH_PROVIDER: profile.agentConfig.webSearch.provider, + NEMOCLAW_WSL_DASHBOARD_EXPOSURE: booleanFlag(profile.dashboard.wslExposure), + }; + + const runtimeEnvironment: MutableEnvironment = { ...configurationEnvironment }; + delete runtimeEnvironment.NEMOCLAW_MESSAGING_PLAN_B64; + runtimeEnvironment.NEMOCLAW_DASHBOARD_PORT = String(profile.dashboard.port); + runtimeEnvironment.NEMOCLAW_MINIMAL_BOOTSTRAP = booleanFlag(profile.agentConfig.minimalBootstrap); + appendHostProxyEnvironment(runtimeEnvironment, profile, { preserveAmbientWhenAbsent: true }); + + return Object.freeze({ + schemaVersion: profile.schemaVersion, + agent: profile.agent, + configurationEnvironment: sortedEnvironment(configurationEnvironment), + runtimeEnvironment: sortedEnvironment(runtimeEnvironment), + materials: Object.freeze([corporateCaMaterial(profile)]), + actions: applicationActions(profile, "openclaw"), + }); +} + +function mapHermesProfile(profile: ManagedStartupProfile): ManagedStartupAgentEnvironment { + if ( + profile.agent !== "hermes" || + profile.agentConfig.agent !== "hermes" || + profile.dashboard.agent !== "hermes" + ) { + throw new ManagedStartupAgentEnvironmentError("Hermes profile state is inconsistent"); + } + + const configurationEnvironment: MutableEnvironment = { + ...commonConfigurationEnvironment(profile), + ...messagingEnvironment(profile, "hermes"), + CHAT_UI_URL: profile.dashboard.url, + NEMOCLAW_CONTEXT_WINDOW: + profile.tuning.contextWindow === null ? "" : String(profile.tuning.contextWindow), + NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER: booleanFlag(profile.tools.enabledGateways.length > 0), + NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64: encodeCanonicalJson(profile.tools.enabledGateways), + NEMOCLAW_WEB_SEARCH_ENABLED: booleanFlag(profile.agentConfig.webSearch.enabled), + NEMOCLAW_WEB_SEARCH_PROVIDER: profile.agentConfig.webSearch.provider, + }; + + const runtimeEnvironment: MutableEnvironment = { ...configurationEnvironment }; + delete runtimeEnvironment.NEMOCLAW_MESSAGING_PLAN_B64; + runtimeEnvironment.NEMOCLAW_DASHBOARD_PORT = + profile.dashboard.publicPort === null ? "" : String(profile.dashboard.publicPort); + runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD = + profile.dashboard.mode === "loopback-forwarded" ? "1" : "0"; + runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT = + profile.dashboard.internalPort === null ? "" : String(profile.dashboard.internalPort); + runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD_PORT = + profile.dashboard.publicPort === null ? "" : String(profile.dashboard.publicPort); + runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD_TUI = booleanFlag(profile.dashboard.tuiEnabled); + runtimeEnvironment.NEMOCLAW_PROXY_HOST = profile.proxy.managedHost; + runtimeEnvironment.NEMOCLAW_PROXY_PORT = String(profile.proxy.managedPort); + appendHostProxyEnvironment(runtimeEnvironment, profile, { preserveAmbientWhenAbsent: true }); + + return Object.freeze({ + schemaVersion: profile.schemaVersion, + agent: profile.agent, + configurationEnvironment: sortedEnvironment(configurationEnvironment), + runtimeEnvironment: sortedEnvironment(runtimeEnvironment), + materials: Object.freeze([corporateCaMaterial(profile)]), + actions: applicationActions(profile, "hermes"), + }); +} + +function mapDcodeProfile(profile: ManagedStartupProfile): ManagedStartupAgentEnvironment { + if ( + profile.agent !== "langchain-deepagents-code" || + profile.agentConfig.agent !== "langchain-deepagents-code" || + profile.dashboard.agent !== "langchain-deepagents-code" || + profile.messaging.plan !== null + ) { + throw new ManagedStartupAgentEnvironmentError( + "LangChain Deep Agents Code profile state is inconsistent", + ); + } + + const configurationEnvironment: MutableEnvironment = { + ...commonConfigurationEnvironment(profile), + NEMOCLAW_UPSTREAM_ENDPOINT_URL: profile.inference.upstreamEndpointUrl ?? "", + }; + appendHostProxyEnvironment(configurationEnvironment, profile); + const runtimeEnvironment: MutableEnvironment = { + ...configurationEnvironment, + NEMOCLAW_OBSERVABILITY: booleanFlag(profile.agentConfig.observabilityEnabled), + }; + // The config generator needs the routed base URL, but the long-running + // DCode process trusts only the root-owned file consumed by + // managed-dcode-runtime.py. + delete runtimeEnvironment.NEMOCLAW_INFERENCE_BASE_URL; + for (const name of [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + ]) { + delete runtimeEnvironment[name]; + } + const materials: readonly ManagedStartupAgentMaterial[] = Object.freeze([ + corporateCaMaterial(profile), + rootOwnedFile( + "NEMOCLAW_DCODE_AUTO_APPROVAL", + "/usr/local/share/nemoclaw/dcode-auto-approval", + profile.agentConfig.autoApprovalMode, + ), + rootOwnedFile( + "NEMOCLAW_INFERENCE_BASE_URL", + "/usr/local/share/nemoclaw/dcode-inference-base-url", + profile.inference.routedBaseUrl, + ), + rootOwnedFile( + "NEMOCLAW_PROXY_HOST", + "/usr/local/share/nemoclaw/dcode-proxy-host", + profile.proxy.managedHost, + ), + rootOwnedFile( + "NEMOCLAW_PROXY_PORT", + "/usr/local/share/nemoclaw/dcode-proxy-port", + String(profile.proxy.managedPort), + ), + ]); + + return Object.freeze({ + schemaVersion: profile.schemaVersion, + agent: profile.agent, + configurationEnvironment: sortedEnvironment(configurationEnvironment), + runtimeEnvironment: sortedEnvironment(runtimeEnvironment), + materials, + actions: applicationActions(profile, null), + }); +} + +/** + * Convert a secret-free validated profile into existing agent-generator and + * entrypoint inputs without depending on Docker, Podman, or another compute + * driver. Validation is repeated at this trust boundary so callers cannot use + * a TypeScript assertion to bypass agent capability checks. + */ +export function mapManagedStartupProfileToAgentEnvironment( + profile: ManagedStartupProfile, +): ManagedStartupAgentEnvironment { + const validated = validateManagedStartupProfile(profile); + switch (validated.agent) { + case "openclaw": + return mapOpenClawProfile(validated); + case "hermes": + return mapHermesProfile(validated); + case "langchain-deepagents-code": + return mapDcodeProfile(validated); + } +} diff --git a/src/lib/onboard/managed-startup/application.ts b/src/lib/onboard/managed-startup/application.ts new file mode 100644 index 00000000000..9a9527ffb19 --- /dev/null +++ b/src/lib/onboard/managed-startup/application.ts @@ -0,0 +1,1000 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Buffer } from "node:buffer"; +import { createHash, randomBytes, X509Certificate } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { TextDecoder } from "node:util"; + +import { + decodeManagedStartupProfile, + fingerprintManagedStartupProfile, + MANAGED_STARTUP_PROFILE_MAX_BYTES, + type ManagedStartupAgent, + type ManagedStartupProfile, + serializeManagedStartupProfile, + validateManagedStartupProfile, +} from "./profile"; + +export const MANAGED_STARTUP_APPLICATION_STATE_DIR = "/var/lib/nemoclaw/startup-profile"; +export const MANAGED_STARTUP_CA_MAX_BYTES = 128 * 1024; +export const MANAGED_STARTUP_CA_MAX_CERTIFICATES = 24; + +const STATE_SCHEMA_VERSION = 1 as const; +const STATE_DIRECTORY_MODE = 0o700; +const STATE_FILE_MODE = 0o600; +const MAX_CONTROL_FILE_BYTES = 512; +const MAX_STATE_ENTRIES = 32; +const SHA256_RE = /^[a-f0-9]{64}$/u; +const GENERATION_RE = /^generation-([a-f0-9]{64})$/u; +const PREPARE_TEMP_RE = /^\.prepare-[0-9]+-[a-f0-9]{24}$/u; +const CONTROL_TEMP_RE = /^\.(?:committed|pending)\.json-[a-f0-9]{24}\.tmp$/u; +const PEM_CERTIFICATE_RE = + /-----BEGIN CERTIFICATE-----\r?\n[A-Za-z0-9+/=\r\n]+?-----END CERTIFICATE-----/gu; +const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); + +interface ManagedStartupApplicationRuntime { + readonly rootUid: number; + readonly rootGid: number; +} + +const DEFAULT_RUNTIME: ManagedStartupApplicationRuntime = { + rootUid: 0, + rootGid: 0, +}; + +/** + * Explicit filesystem seam for unit tests that cannot create uid-0 files. + * Image entrypoints must omit this argument so uid/gid 0 remain mandatory. + */ +export interface ManagedStartupApplicationTestRuntime { + readonly rootUid: number; + readonly rootGid: number; +} + +export interface PrepareManagedStartupApplicationInput { + readonly encodedProfile: string; + readonly expectedAgent: ManagedStartupAgent; + readonly corporateCaB64?: string; + readonly stateDirectory?: string; +} + +export interface PreparedManagedStartupApplication { + readonly status: "prepared" | "already-committed"; + readonly stateDirectory: string; + readonly generationDirectory: string; + readonly profilePath: string; + readonly corporateCaPath: string | null; + readonly fingerprint: string; + readonly expectedAgent: ManagedStartupAgent; + readonly profile: ManagedStartupProfile; +} + +export interface CommittedManagedStartupApplication + extends Omit { + readonly status: "committed"; +} + +interface StateControl { + readonly schemaVersion: typeof STATE_SCHEMA_VERSION; + readonly fingerprint: string; + readonly generation: string; +} + +interface ValidatedGeneration { + readonly directory: string; + readonly profilePath: string; + readonly corporateCaPath: string | null; + readonly profile: ManagedStartupProfile; + readonly fingerprint: string; +} + +export class ManagedStartupApplicationError extends Error { + constructor(message: string) { + super(`Managed startup application failed: ${message}`); + this.name = "ManagedStartupApplicationError"; + } +} + +function fail(message: string): never { + throw new ManagedStartupApplicationError(message); +} + +function runtimeFor( + override: ManagedStartupApplicationTestRuntime | undefined, +): ManagedStartupApplicationRuntime { + return override ?? DEFAULT_RUNTIME; +} + +function requireContainerRoot(): void { + if (process.geteuid?.() !== 0) { + fail("the image-side applicator must run with effective uid 0"); + } +} + +function modeOf(stat: fs.Stats): number { + return stat.mode & 0o777; +} + +function requireOwner(stat: fs.Stats, target: string, runtime: ManagedStartupApplicationRuntime) { + if (stat.uid !== runtime.rootUid || stat.gid !== runtime.rootGid) { + fail(`${target} must be owned by root:root`); + } +} + +function requireSecureDirectory( + target: string, + runtime: ManagedStartupApplicationRuntime, + exactMode: boolean, +): void { + let stat: fs.Stats; + try { + stat = fs.lstatSync(target); + } catch { + fail(`state directory component is missing or unreadable: ${target}`); + } + if (stat.isSymbolicLink() || !stat.isDirectory()) { + fail(`state directory component must be a real directory: ${target}`); + } + requireOwner(stat, target, runtime); + const mode = modeOf(stat); + if ((exactMode && mode !== STATE_DIRECTORY_MODE) || (!exactMode && (mode & 0o022) !== 0)) { + fail( + exactMode + ? `${target} must have mode 0700` + : `${target} must not be group- or world-writable`, + ); + } +} + +function ensureStateDirectory( + rawStateDirectory: string | undefined, + runtime: ManagedStartupApplicationRuntime, +): string { + const stateDirectory = rawStateDirectory ?? MANAGED_STARTUP_APPLICATION_STATE_DIR; + if (!path.isAbsolute(stateDirectory) || stateDirectory.includes("\0")) { + fail("stateDirectory must be an absolute path"); + } + const normalized = path.resolve(stateDirectory); + const parent = path.dirname(normalized); + requireSecureDirectory(parent, runtime, false); + try { + fs.mkdirSync(normalized, { mode: STATE_DIRECTORY_MODE }); + fs.chownSync(normalized, runtime.rootUid, runtime.rootGid); + fs.chmodSync(normalized, STATE_DIRECTORY_MODE); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + fail(`could not create the managed startup state directory: ${normalized}`); + } + } + requireSecureDirectory(normalized, runtime, true); + return normalized; +} + +function requireSecureRegularFileStat( + stat: fs.Stats, + target: string, + runtime: ManagedStartupApplicationRuntime, +): void { + if (!stat.isFile() || stat.isSymbolicLink()) { + fail(`${target} must be a regular file`); + } + if (stat.nlink !== 1) { + fail(`${target} must not be hardlinked`); + } + requireOwner(stat, target, runtime); + if (modeOf(stat) !== STATE_FILE_MODE) { + fail(`${target} must have mode 0600`); + } +} + +function readSecureFile( + target: string, + maxBytes: number, + runtime: ManagedStartupApplicationRuntime, +): Buffer { + let descriptor: number; + try { + descriptor = fs.openSync(target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + } catch { + fail(`state file is missing, unreadable, or a symlink: ${target}`); + } + try { + const stat = fs.fstatSync(descriptor); + requireSecureRegularFileStat(stat, target, runtime); + if (stat.size < 1 || stat.size > maxBytes) { + fail(`${target} is empty or exceeds its size limit`); + } + const content = fs.readFileSync(descriptor); + if (content.length !== stat.size) { + fail(`${target} changed while it was being read`); + } + return content; + } finally { + fs.closeSync(descriptor); + } +} + +function writeSecureNewFile( + target: string, + content: string | Buffer, + runtime: ManagedStartupApplicationRuntime, +): void { + let descriptor: number; + try { + descriptor = fs.openSync( + target, + fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW, + STATE_FILE_MODE, + ); + } catch { + fail(`refused to replace an existing state file: ${target}`); + } + try { + fs.fchownSync(descriptor, runtime.rootUid, runtime.rootGid); + fs.fchmodSync(descriptor, STATE_FILE_MODE); + fs.writeFileSync(descriptor, content); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function syncDirectory(target: string): void { + const descriptor = fs.openSync(target, fs.constants.O_RDONLY); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function randomToken(): string { + return randomBytes(12).toString("hex"); +} + +function stateControl(fingerprint: string): StateControl { + return { + schemaVersion: STATE_SCHEMA_VERSION, + fingerprint, + generation: `generation-${fingerprint}`, + }; +} + +function serializeStateControl(control: StateControl): string { + return JSON.stringify({ + fingerprint: control.fingerprint, + generation: control.generation, + schemaVersion: control.schemaVersion, + }); +} + +function parseStateControl( + target: string, + runtime: ManagedStartupApplicationRuntime, +): StateControl { + const bytes = readSecureFile(target, MAX_CONTROL_FILE_BYTES, runtime); + let raw: string; + try { + raw = UTF8_DECODER.decode(bytes); + } catch { + fail(`${target} is not valid UTF-8`); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + fail(`${target} is not valid JSON`); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + fail(`${target} does not contain a valid state control`); + } + const record = parsed as Record; + if ( + Object.keys(record).sort().join(",") !== "fingerprint,generation,schemaVersion" || + record.schemaVersion !== STATE_SCHEMA_VERSION || + typeof record.fingerprint !== "string" || + !SHA256_RE.test(record.fingerprint) || + record.generation !== `generation-${record.fingerprint}` + ) { + fail(`${target} does not contain a valid state control`); + } + const control = stateControl(record.fingerprint); + if (serializeStateControl(control) !== raw) { + fail(`${target} is not in canonical form`); + } + return control; +} + +function publishStateControlIfAbsent( + stateDirectory: string, + basename: "committed.json" | "pending.json", + control: StateControl, + runtime: ManagedStartupApplicationRuntime, +): { + readonly control: StateControl; + readonly created: boolean; +} { + const target = path.join(stateDirectory, basename); + const temporary = path.join(stateDirectory, `.${basename}-${randomToken()}.tmp`); + writeSecureNewFile(temporary, serializeStateControl(control), runtime); + + try { + fs.linkSync(temporary, target); + } catch (error) { + try { + unlinkSecureControlOrTemp(temporary, runtime); + } catch { + // Preserve the primary publication error. + } + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + return { + control: parseStateControl(target, runtime), + created: false, + }; + } + fail(`could not atomically publish ${basename}`); + } + + try { + fs.unlinkSync(temporary); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + fail(`could not finalize atomic publication of ${basename}`); + } + } + syncDirectory(stateDirectory); + return { control, created: true }; +} + +function validateCorporateCaBytes(bytes: Buffer): void { + if (bytes.length < 1 || bytes.length > MANAGED_STARTUP_CA_MAX_BYTES) { + fail(`corporate CA bundle must contain 1-${String(MANAGED_STARTUP_CA_MAX_BYTES)} bytes`); + } + let pem: string; + try { + pem = UTF8_DECODER.decode(bytes); + } catch { + fail("corporate CA bundle must be valid UTF-8 PEM"); + } + + const matches = [...pem.matchAll(PEM_CERTIFICATE_RE)]; + if ( + matches.length < 1 || + matches.length > MANAGED_STARTUP_CA_MAX_CERTIFICATES || + matches[0]?.index !== 0 + ) { + fail( + `corporate CA bundle must contain 1-${String( + MANAGED_STARTUP_CA_MAX_CERTIFICATES, + )} PEM CA certificates`, + ); + } + + let cursor = 0; + for (const match of matches) { + const index = match.index; + if (index === undefined || (!/^(?:\r?\n)+$/u.test(pem.slice(cursor, index)) && index !== 0)) { + fail("corporate CA bundle contains non-PEM material between certificates"); + } + const block = match[0]; + let certificate: X509Certificate; + try { + certificate = new X509Certificate(block); + } catch { + fail("corporate CA bundle contains an invalid X.509 certificate"); + } + if (!certificate.ca) { + fail("corporate CA bundle contains a certificate without basicConstraints CA:TRUE"); + } + cursor = index + block.length; + } + if (!/^(?:\r?\n)?$/u.test(pem.slice(cursor))) { + fail("corporate CA bundle contains trailing non-PEM material"); + } +} + +export function validateManagedStartupCorporateCaTransport( + encoded: string | undefined, + profile: ManagedStartupProfile, +): Buffer | null { + const expectedDigest = profile.corporateCa.bundleSha256; + if (expectedDigest === null) { + if (encoded !== undefined) { + fail("corporate CA transport must be absent when the profile has no CA digest"); + } + return null; + } + if ( + typeof encoded !== "string" || + encoded.length === 0 || + encoded.length > Math.ceil(MANAGED_STARTUP_CA_MAX_BYTES / 3) * 4 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded) + ) { + fail("corporate CA transport must be canonical standard base64"); + } + const bytes = Buffer.from(encoded, "base64"); + if (bytes.toString("base64") !== encoded) { + fail("corporate CA transport must be canonical standard base64"); + } + validateCorporateCaBytes(bytes); + const actualDigest = createHash("sha256").update(bytes).digest("hex"); + if (actualDigest !== expectedDigest) { + fail("corporate CA bundle does not match the profile SHA-256 digest"); + } + return bytes; +} + +function readCanonicalProfile( + profilePath: string, + runtime: ManagedStartupApplicationRuntime, +): { + profile: ManagedStartupProfile; + fingerprint: string; +} { + const bytes = readSecureFile(profilePath, MANAGED_STARTUP_PROFILE_MAX_BYTES, runtime); + let raw: string; + try { + raw = UTF8_DECODER.decode(bytes); + } catch { + fail(`${profilePath} is not valid UTF-8`); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + fail(`${profilePath} is not valid JSON`); + } + let profile: ManagedStartupProfile; + try { + profile = validateManagedStartupProfile(parsed); + } catch (error) { + fail(`${profilePath} is invalid: ${(error as Error).message}`); + } + if (serializeManagedStartupProfile(profile) !== raw) { + fail(`${profilePath} is not a canonical managed startup profile`); + } + return { + profile, + fingerprint: fingerprintManagedStartupProfile(profile), + }; +} + +function validateGeneration( + stateDirectory: string, + control: StateControl, + runtime: ManagedStartupApplicationRuntime, + expectedAgent?: ManagedStartupAgent, +): ValidatedGeneration { + if (!GENERATION_RE.test(control.generation)) { + fail("state control names an invalid generation"); + } + const directory = path.join(stateDirectory, control.generation); + requireSecureDirectory(directory, runtime, true); + const entries = fs.readdirSync(directory).sort(); + if ( + entries.some((entry) => entry !== "profile.json" && entry !== "corporate-ca.pem") || + !entries.includes("profile.json") + ) { + fail(`${directory} contains missing or unsupported state files`); + } + const profilePath = path.join(directory, "profile.json"); + const { profile, fingerprint } = readCanonicalProfile(profilePath, runtime); + if (fingerprint !== control.fingerprint) { + fail(`${directory} does not match its recorded profile fingerprint`); + } + if (expectedAgent !== undefined && profile.agent !== expectedAgent) { + fail(`managed startup profile targets ${profile.agent}, expected ${expectedAgent}`); + } + + const caPath = path.join(directory, "corporate-ca.pem"); + let corporateCaPath: string | null = null; + if (profile.corporateCa.bundleSha256 === null) { + if (entries.includes("corporate-ca.pem")) { + fail(`${directory} contains a CA bundle that is absent from the profile`); + } + } else { + if (!entries.includes("corporate-ca.pem")) { + fail(`${directory} is missing the CA bundle recorded by the profile`); + } + const caBytes = readSecureFile(caPath, MANAGED_STARTUP_CA_MAX_BYTES, runtime); + validateCorporateCaBytes(caBytes); + if (createHash("sha256").update(caBytes).digest("hex") !== profile.corporateCa.bundleSha256) { + fail(`${directory} contains a CA bundle with the wrong SHA-256 digest`); + } + corporateCaPath = caPath; + } + + return { + directory, + profilePath, + corporateCaPath, + profile, + fingerprint, + }; +} + +function validateDisposableDirectory( + target: string, + runtime: ManagedStartupApplicationRuntime, +): void { + requireSecureDirectory(target, runtime, true); + const entries = fs.readdirSync(target); + if ( + entries.length > 2 || + entries.some((entry) => entry !== "profile.json" && entry !== "corporate-ca.pem") + ) { + fail(`${target} is not a recognized disposable generation`); + } + for (const entry of entries) { + const file = path.join(target, entry); + const stat = fs.lstatSync(file); + requireSecureRegularFileStat(stat, file, runtime); + } +} + +function discardDirectory(target: string, runtime: ManagedStartupApplicationRuntime): void { + validateDisposableDirectory(target, runtime); + fs.rmSync(target, { recursive: true }); +} + +function discardDirectoryIfPresent( + target: string, + runtime: ManagedStartupApplicationRuntime, +): boolean { + try { + fs.lstatSync(target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + fail(`could not inspect disposable generation ${target}`); + } + discardDirectory(target, runtime); + return true; +} + +function unlinkSecureControlOrTemp( + target: string, + runtime: ManagedStartupApplicationRuntime, +): void { + const stat = fs.lstatSync(target); + requireSecureRegularFileStat(stat, target, runtime); + if (stat.size > MAX_CONTROL_FILE_BYTES) { + fail(`${target} exceeds the state-control size limit`); + } + fs.unlinkSync(target); +} + +function listStateEntries(stateDirectory: string): string[] { + const entries = fs.readdirSync(stateDirectory).sort(); + if (entries.length > MAX_STATE_ENTRIES) { + fail(`state directory exceeds ${String(MAX_STATE_ENTRIES)} entries`); + } + return entries; +} + +function unlinkRecoverableControlTemp( + stateDirectory: string, + entry: string, + runtime: ManagedStartupApplicationRuntime, +): void { + const temporary = path.join(stateDirectory, entry); + const stat = fs.lstatSync(temporary); + if (stat.nlink === 1) { + unlinkSecureControlOrTemp(temporary, runtime); + return; + } + + const basename = entry.startsWith(".committed.json-") + ? "committed.json" + : entry.startsWith(".pending.json-") + ? "pending.json" + : null; + const target = basename === null ? null : path.join(stateDirectory, basename); + let targetStat: fs.Stats | null = null; + try { + targetStat = target === null ? null : fs.lstatSync(target); + } catch { + fail(`refused to remove an unpaired atomic-control temporary file: ${temporary}`); + } + if ( + stat.nlink !== 2 || + targetStat === null || + stat.dev !== targetStat.dev || + stat.ino !== targetStat.ino || + !stat.isFile() || + stat.isSymbolicLink() || + modeOf(stat) !== STATE_FILE_MODE || + stat.size < 1 || + stat.size > MAX_CONTROL_FILE_BYTES + ) { + fail(`refused to remove an unpaired atomic-control temporary file: ${temporary}`); + } + requireOwner(stat, temporary, runtime); + requireOwner(targetStat, target as string, runtime); + fs.unlinkSync(temporary); +} + +function cleanAtomicTemps( + stateDirectory: string, + entries: readonly string[], + runtime: ManagedStartupApplicationRuntime, +): void { + let changed = false; + for (const entry of entries) { + const target = path.join(stateDirectory, entry); + if (PREPARE_TEMP_RE.test(entry)) { + discardDirectory(target, runtime); + changed = true; + } else if (CONTROL_TEMP_RE.test(entry)) { + unlinkRecoverableControlTemp(stateDirectory, entry, runtime); + changed = true; + } + } + if (changed) syncDirectory(stateDirectory); +} + +function requireKnownStateEntries(stateDirectory: string, entries: readonly string[]): void { + for (const entry of entries) { + if ( + entry === "committed.json" || + entry === "pending.json" || + GENERATION_RE.test(entry) || + PREPARE_TEMP_RE.test(entry) || + CONTROL_TEMP_RE.test(entry) + ) { + continue; + } + fail(`${stateDirectory} contains unsupported state component ${entry}`); + } +} + +function discardGenerationsExcept( + stateDirectory: string, + keepGeneration: string | null, + runtime: ManagedStartupApplicationRuntime, +): void { + for (const entry of listStateEntries(stateDirectory)) { + if (GENERATION_RE.test(entry) && entry !== keepGeneration) { + discardDirectoryIfPresent(path.join(stateDirectory, entry), runtime); + } + } +} + +function optionalStateControl( + stateDirectory: string, + basename: "committed.json" | "pending.json", + runtime: ManagedStartupApplicationRuntime, +): StateControl | null { + const target = path.join(stateDirectory, basename); + try { + fs.lstatSync(target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + fail(`could not inspect ${target}`); + } + return parseStateControl(target, runtime); +} + +function removePendingControl( + stateDirectory: string, + runtime: ManagedStartupApplicationRuntime, +): void { + try { + unlinkSecureControlOrTemp(path.join(stateDirectory, "pending.json"), runtime); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + syncDirectory(stateDirectory); +} + +function stateControlsMatch(left: StateControl, right: StateControl): boolean { + return left.fingerprint === right.fingerprint && left.generation === right.generation; +} + +function recoverCommittedState( + stateDirectory: string, + committedControl: StateControl, + pendingControl: StateControl | null, + requested: StateControl, + expectedAgent: ManagedStartupAgent, + runtime: ManagedStartupApplicationRuntime, +): ValidatedGeneration { + const committed = validateGeneration(stateDirectory, committedControl, runtime, expectedAgent); + if (pendingControl) removePendingControl(stateDirectory, runtime); + discardGenerationsExcept(stateDirectory, committedControl.generation, runtime); + syncDirectory(stateDirectory); + if (!stateControlsMatch(committedControl, requested)) { + fail("a different startup profile is already committed; recreate the sandbox to change it"); + } + return committed; +} + +function recoverState( + stateDirectory: string, + requested: StateControl, + expectedAgent: ManagedStartupAgent, + runtime: ManagedStartupApplicationRuntime, +): { + committed: ValidatedGeneration | null; + pending: ValidatedGeneration | null; +} { + const initialEntries = listStateEntries(stateDirectory); + requireKnownStateEntries(stateDirectory, initialEntries); + cleanAtomicTemps(stateDirectory, initialEntries, runtime); + + const initiallyCommittedControl = optionalStateControl(stateDirectory, "committed.json", runtime); + const pendingControl = optionalStateControl(stateDirectory, "pending.json", runtime); + const committedAfterPendingRead = optionalStateControl(stateDirectory, "committed.json", runtime); + const committedControl = committedAfterPendingRead ?? initiallyCommittedControl; + if (committedControl) { + return { + committed: recoverCommittedState( + stateDirectory, + committedControl, + pendingControl, + requested, + expectedAgent, + runtime, + ), + pending: null, + }; + } + + if (pendingControl) { + if (stateControlsMatch(pendingControl, requested)) { + const pending = validateGeneration(stateDirectory, pendingControl, runtime, expectedAgent); + const committedAfterPendingValidation = optionalStateControl( + stateDirectory, + "committed.json", + runtime, + ); + if (committedAfterPendingValidation) { + return { + committed: recoverCommittedState( + stateDirectory, + committedAfterPendingValidation, + pendingControl, + requested, + expectedAgent, + runtime, + ), + pending: null, + }; + } + discardGenerationsExcept(stateDirectory, pendingControl.generation, runtime); + return { committed: null, pending }; + } + fail("a different startup profile is already pending; wait for it to commit or recreate"); + } + + return { committed: null, pending: null }; +} + +function createGeneration( + stateDirectory: string, + control: StateControl, + profileJson: string, + corporateCa: Buffer | null, + runtime: ManagedStartupApplicationRuntime, +): ValidatedGeneration { + const temporaryName = `.prepare-${String(process.pid)}-${randomToken()}`; + const temporary = path.join(stateDirectory, temporaryName); + const generation = path.join(stateDirectory, control.generation); + let renameAttempted = false; + try { + fs.mkdirSync(temporary, { mode: STATE_DIRECTORY_MODE }); + fs.chownSync(temporary, runtime.rootUid, runtime.rootGid); + fs.chmodSync(temporary, STATE_DIRECTORY_MODE); + writeSecureNewFile(path.join(temporary, "profile.json"), profileJson, runtime); + if (corporateCa) { + writeSecureNewFile(path.join(temporary, "corporate-ca.pem"), corporateCa, runtime); + } + syncDirectory(temporary); + renameAttempted = true; + fs.renameSync(temporary, generation); + syncDirectory(stateDirectory); + } catch (error) { + try { + fs.lstatSync(temporary); + discardDirectory(temporary, runtime); + } catch { + // Preserve the generation error. + } + if (error instanceof ManagedStartupApplicationError) throw error; + if ( + renameAttempted && + ((error as NodeJS.ErrnoException).code === "EEXIST" || + (error as NodeJS.ErrnoException).code === "ENOTEMPTY") + ) { + return validateGeneration(stateDirectory, control, runtime); + } + fail(`could not atomically prepare generation ${control.generation}`); + } + return validateGeneration(stateDirectory, control, runtime); +} + +function toPrepared( + status: PreparedManagedStartupApplication["status"], + stateDirectory: string, + generation: ValidatedGeneration, + expectedAgent: ManagedStartupAgent, +): PreparedManagedStartupApplication { + return { + status, + stateDirectory, + generationDirectory: generation.directory, + profilePath: generation.profilePath, + corporateCaPath: generation.corporateCaPath, + fingerprint: generation.fingerprint, + expectedAgent, + profile: generation.profile, + }; +} + +/** + * Validate the secret-free envelope and atomically prepare immutable state. + * + * Agent-specific adapters may read the returned generation, make their own + * configuration changes, and then call commitManagedStartupApplication. A + * prepared generation is deliberately not treated as applied. + */ +export function prepareManagedStartupApplication( + input: PrepareManagedStartupApplicationInput, + testRuntime?: ManagedStartupApplicationTestRuntime, +): PreparedManagedStartupApplication { + const runtime = runtimeFor(testRuntime); + requireContainerRoot(); + + let profile: ManagedStartupProfile; + try { + profile = decodeManagedStartupProfile(input.encodedProfile); + } catch (error) { + fail((error as Error).message); + } + if (profile.agent !== input.expectedAgent) { + fail(`managed startup profile targets ${profile.agent}, expected ${input.expectedAgent}`); + } + const corporateCa = validateManagedStartupCorporateCaTransport(input.corporateCaB64, profile); + const profileJson = serializeManagedStartupProfile(profile); + const control = stateControl(fingerprintManagedStartupProfile(profile)); + const stateDirectory = ensureStateDirectory(input.stateDirectory, runtime); + const recovered = recoverState(stateDirectory, control, input.expectedAgent, runtime); + if (recovered.committed) { + return toPrepared( + "already-committed", + stateDirectory, + recovered.committed, + input.expectedAgent, + ); + } + if (recovered.pending) { + return toPrepared("prepared", stateDirectory, recovered.pending, input.expectedAgent); + } + + const generation = createGeneration(stateDirectory, control, profileJson, corporateCa, runtime); + const publication = publishStateControlIfAbsent(stateDirectory, "pending.json", control, runtime); + if ( + publication.control.fingerprint !== control.fingerprint || + publication.control.generation !== control.generation + ) { + discardDirectoryIfPresent(generation.directory, runtime); + syncDirectory(stateDirectory); + fail("a different startup profile won the pending-state transaction"); + } + const committedAfterPublication = optionalStateControl(stateDirectory, "committed.json", runtime); + if (committedAfterPublication) { + if ( + committedAfterPublication.fingerprint !== control.fingerprint || + committedAfterPublication.generation !== control.generation + ) { + if (publication.created) { + removePendingControl(stateDirectory, runtime); + discardDirectoryIfPresent(generation.directory, runtime); + syncDirectory(stateDirectory); + } + fail("a different startup profile committed during pending-state publication"); + } + const committedGeneration = validateGeneration( + stateDirectory, + committedAfterPublication, + runtime, + input.expectedAgent, + ); + removePendingControl(stateDirectory, runtime); + discardGenerationsExcept(stateDirectory, committedAfterPublication.generation, runtime); + return toPrepared( + "already-committed", + stateDirectory, + committedGeneration, + input.expectedAgent, + ); + } + const activeGeneration = publication.created + ? generation + : validateGeneration(stateDirectory, publication.control, runtime, input.expectedAgent); + return toPrepared("prepared", stateDirectory, activeGeneration, input.expectedAgent); +} + +function validatePreparedHandle(handle: PreparedManagedStartupApplication): StateControl { + if ( + !path.isAbsolute(handle.stateDirectory) || + !SHA256_RE.test(handle.fingerprint) || + handle.generationDirectory !== + path.join(handle.stateDirectory, `generation-${handle.fingerprint}`) || + handle.profilePath !== path.join(handle.generationDirectory, "profile.json") || + (handle.corporateCaPath !== null && + handle.corporateCaPath !== path.join(handle.generationDirectory, "corporate-ca.pem")) + ) { + fail("prepared startup handle is malformed"); + } + return stateControl(handle.fingerprint); +} + +/** + * Mark a prepared profile applied only after every agent-specific adapter has + * completed. Exclusive publication of the marker is the sole commit point. + */ +export function commitManagedStartupApplication( + prepared: PreparedManagedStartupApplication, + testRuntime?: ManagedStartupApplicationTestRuntime, +): CommittedManagedStartupApplication { + const runtime = runtimeFor(testRuntime); + requireContainerRoot(); + const requested = validatePreparedHandle(prepared); + const stateDirectory = ensureStateDirectory(prepared.stateDirectory, runtime); + const committedControl = optionalStateControl(stateDirectory, "committed.json", runtime); + if (committedControl) { + if ( + committedControl.fingerprint !== requested.fingerprint || + committedControl.generation !== requested.generation + ) { + fail("a different startup profile is already committed"); + } + const generation = validateGeneration( + stateDirectory, + committedControl, + runtime, + prepared.expectedAgent, + ); + return { + ...toPrepared("already-committed", stateDirectory, generation, prepared.expectedAgent), + status: "committed", + }; + } + + const pendingControl = optionalStateControl(stateDirectory, "pending.json", runtime); + if ( + !pendingControl || + pendingControl.fingerprint !== requested.fingerprint || + pendingControl.generation !== requested.generation + ) { + fail("the prepared startup generation is not the active pending generation"); + } + const generation = validateGeneration( + stateDirectory, + pendingControl, + runtime, + prepared.expectedAgent, + ); + const publication = publishStateControlIfAbsent( + stateDirectory, + "committed.json", + pendingControl, + runtime, + ); + if ( + publication.control.fingerprint !== requested.fingerprint || + publication.control.generation !== requested.generation + ) { + fail("a different startup profile won the committed-state transaction"); + } + removePendingControl(stateDirectory, runtime); + discardGenerationsExcept(stateDirectory, publication.control.generation, runtime); + syncDirectory(stateDirectory); + return { + ...toPrepared("already-committed", stateDirectory, generation, prepared.expectedAgent), + status: "committed", + }; +} diff --git a/src/lib/onboard/managed-startup/coordinator.ts b/src/lib/onboard/managed-startup/coordinator.ts new file mode 100644 index 00000000000..a2b99281c24 --- /dev/null +++ b/src/lib/onboard/managed-startup/coordinator.ts @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + type CommittedManagedStartupApplication, + commitManagedStartupApplication, + type PreparedManagedStartupApplication, + type PrepareManagedStartupApplicationInput, + prepareManagedStartupApplication, +} from "./application"; +import { + MANAGED_STARTUP_AGENTS, + type ManagedStartupAgent, + type ManagedStartupProfile, +} from "./profile"; + +export interface ManagedStartupAdapterContext { + readonly agent: ManagedStartupAgent; + readonly profile: ManagedStartupProfile; + readonly fingerprint: string; + readonly generationDirectory: string; + readonly profilePath: string; + readonly corporateCaPath: string | null; +} + +export interface ManagedStartupAgentAdapter { + readonly agent: ManagedStartupAgent; + readonly apply: (context: ManagedStartupAdapterContext) => void | Promise; +} + +export interface ManagedStartupCoordinatorDependencies { + readonly prepareApplication: ( + input: PrepareManagedStartupApplicationInput, + ) => PreparedManagedStartupApplication | Promise; + readonly commitApplication: ( + prepared: PreparedManagedStartupApplication, + ) => CommittedManagedStartupApplication | Promise; +} + +export interface ManagedStartupCoordinationResult { + readonly adapterApplied: boolean; + readonly application: CommittedManagedStartupApplication; +} + +type AdapterRegistry = Readonly>; + +const SHIPPED_AGENT_SET = new Set(MANAGED_STARTUP_AGENTS); + +const DEFAULT_DEPENDENCIES: ManagedStartupCoordinatorDependencies = { + prepareApplication: (input) => prepareManagedStartupApplication(input), + commitApplication: (prepared) => commitManagedStartupApplication(prepared), +}; + +export class ManagedStartupCoordinatorError extends Error { + constructor(message: string) { + super(`Managed startup coordination failed: ${message}`); + this.name = "ManagedStartupCoordinatorError"; + } +} + +function fail(message: string): never { + throw new ManagedStartupCoordinatorError(message); +} + +function createAdapterRegistry(adapters: readonly ManagedStartupAgentAdapter[]): AdapterRegistry { + const byAgent = new Map(); + for (const adapter of adapters) { + if ( + typeof adapter !== "object" || + adapter === null || + !SHIPPED_AGENT_SET.has(adapter.agent) || + typeof adapter.apply !== "function" + ) { + fail("every adapter must identify one shipped agent and provide an apply function"); + } + if (byAgent.has(adapter.agent)) { + fail(`duplicate adapter registered for ${adapter.agent}`); + } + byAgent.set(adapter.agent, adapter); + } + + const missing = MANAGED_STARTUP_AGENTS.filter((agent) => !byAgent.has(agent)); + if (missing.length > 0) { + fail(`missing adapter for ${missing.join(", ")}`); + } + if (byAgent.size !== MANAGED_STARTUP_AGENTS.length) { + fail("adapter registry must contain exactly the shipped agents"); + } + + return Object.freeze( + Object.fromEntries( + MANAGED_STARTUP_AGENTS.map((agent) => { + const adapter = byAgent.get(agent); + if (!adapter) fail(`missing adapter for ${agent}`); + return [agent, adapter]; + }), + ), + ) as AdapterRegistry; +} + +function requirePreparedIdentity( + prepared: PreparedManagedStartupApplication, + requestedAgent: ManagedStartupAgent, +): void { + if (prepared.expectedAgent !== requestedAgent || prepared.profile.agent !== requestedAgent) { + fail(`prepared profile targets ${prepared.profile.agent}, expected ${requestedAgent}`); + } +} + +function adapterContext(prepared: PreparedManagedStartupApplication): ManagedStartupAdapterContext { + return Object.freeze({ + agent: prepared.profile.agent, + profile: prepared.profile, + fingerprint: prepared.fingerprint, + generationDirectory: prepared.generationDirectory, + profilePath: prepared.profilePath, + corporateCaPath: prepared.corporateCaPath, + }); +} + +/** + * Coordinate one managed startup without depending on a host container driver. + * + * A complete, duplicate-free adapter registry is required before application + * state is prepared. New pending profiles dispatch exactly their matching + * adapter and commit only after it succeeds. An already committed profile is + * revalidated through commit without reapplying mutable agent configuration. + */ +export async function coordinateManagedStartupApplication( + input: PrepareManagedStartupApplicationInput, + adapters: readonly ManagedStartupAgentAdapter[], + dependencies: ManagedStartupCoordinatorDependencies = DEFAULT_DEPENDENCIES, +): Promise { + const registry = createAdapterRegistry(adapters); + const prepared = await dependencies.prepareApplication(input); + requirePreparedIdentity(prepared, input.expectedAgent); + + if (prepared.status === "already-committed") { + return { + adapterApplied: false, + application: await dependencies.commitApplication(prepared), + }; + } + + const adapter = registry[prepared.profile.agent]; + if (adapter.agent !== prepared.profile.agent) { + fail(`adapter registry cross-dispatch detected for ${prepared.profile.agent}`); + } + await adapter.apply(adapterContext(prepared)); + return { + adapterApplied: true, + application: await dependencies.commitApplication(prepared), + }; +} diff --git a/test/generate-managed-startup-profile-fixture.test.ts b/test/generate-managed-startup-profile-fixture.test.ts new file mode 100644 index 00000000000..9e6c7a644bb --- /dev/null +++ b/test/generate-managed-startup-profile-fixture.test.ts @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + MANAGED_STARTUP_E2E_CORPORATE_CA_PEM, + MANAGED_STARTUP_E2E_HTTP_PROXY, + MANAGED_STARTUP_E2E_HTTPS_PROXY, + MANAGED_STARTUP_E2E_NO_PROXY, +} from "../scripts/checks/generate-managed-startup-profile-fixture.mts"; +import { + decodeManagedStartupProfile, + MANAGED_STARTUP_AGENTS, +} from "../src/lib/onboard/managed-startup/profile"; + +const SCRIPT_PATH = path.join( + import.meta.dirname, + "..", + "scripts", + "checks", + "generate-managed-startup-profile-fixture.mts", +); +const DEFAULT_MODEL = "nvidia/nemotron-3-ultra-550b-a55b"; +const CHANGED_MODEL = "nvidia/nemotron-3-super-120b-a12b"; +const CORPORATE_CA_SHA256 = createHash("sha256") + .update(MANAGED_STARTUP_E2E_CORPORATE_CA_PEM) + .digest("hex"); + +function runFixture(args: readonly string[]) { + return spawnSync( + process.execPath, + ["--experimental-strip-types", "--no-warnings", SCRIPT_PATH, ...args], + { + encoding: "utf8", + timeout: 10_000, + }, + ); +} + +describe("generate-managed-startup-profile-fixture.mts CLI", () => { + it("emits the exact corporate CA as base64", () => { + const result = runFixture(["--corporate-ca-b64"]); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(Buffer.from(result.stdout.trim(), "base64").toString("utf8")).toBe( + MANAGED_STARTUP_E2E_CORPORATE_CA_PEM, + ); + }); + + it.each([ + { + name: "missing --agent", + args: [] as const, + error: "--agent is required", + }, + { + name: "invalid agent", + args: ["--agent", "not-a-shipped-agent"] as const, + error: "--agent must identify a shipped managed-image agent", + }, + { + name: "unsupported argument", + args: ["--agent", "openclaw", "--unsupported"] as const, + error: "unsupported arguments: --unsupported", + }, + ])("rejects $name", ({ args, error }) => { + const result = runFixture(args); + + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr.trim()).toBe(error); + }); + + it.each(MANAGED_STARTUP_AGENTS)("emits a valid default profile for %s", (agent) => { + const result = runFixture(["--agent", agent]); + const profile = decodeManagedStartupProfile(result.stdout.trim()); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(profile.agent).toBe(agent); + expect(profile.inference.model).toBe(DEFAULT_MODEL); + expect(profile.proxy.hostHttpUrl).toBe(MANAGED_STARTUP_E2E_HTTP_PROXY); + expect(profile.proxy.hostHttpsUrl).toBe(MANAGED_STARTUP_E2E_HTTPS_PROXY); + expect(profile.proxy.hostNoProxy).toEqual([...MANAGED_STARTUP_E2E_NO_PROXY].sort()); + expect(profile.corporateCa.bundleSha256).toBeNull(); + }); + + it.each( + MANAGED_STARTUP_AGENTS, + )("honors every supported optional flag together for %s", (agent) => { + const result = runFixture([ + "--agent", + agent, + "--changed", + "--corporate-ca", + "--without-host-proxy", + ]); + const profile = decodeManagedStartupProfile(result.stdout.trim()); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(profile.agent).toBe(agent); + expect(profile.inference.model).toBe(CHANGED_MODEL); + expect(profile.proxy.hostHttpUrl).toBeNull(); + expect(profile.proxy.hostHttpsUrl).toBeNull(); + expect(profile.proxy.hostNoProxy).toEqual([]); + expect(profile.corporateCa.bundleSha256).toBe(CORPORATE_CA_SHA256); + }); +});