diff --git a/ci/env-var-doc-allowlist.json b/ci/env-var-doc-allowlist.json index aed046f4cbb..eaac8c08b27 100644 --- a/ci/env-var-doc-allowlist.json +++ b/ci/env-var-doc-allowlist.json @@ -90,5 +90,9 @@ { "name": "NEMOCLAW_DOCKER_GPU_PATCH_NETWORK", "reason": "Internal one-process handoff from Docker GPU patch preparation into sandbox creation. Rebuild scopes and restores it; users must not set it." + }, + { + "name": "NEMOCLAW_MANAGED_HERMES_HASH_B64", + "reason": "Internal one-process transport from the root image applicator to its sandbox-owned Hermes compatibility-hash writer. The value is a bounded base64-encoded hash receipt that only the private internal writer consumes; users must not set it." } ] diff --git a/scripts/lib/entrypoint-env-wrapper.sh b/scripts/lib/entrypoint-env-wrapper.sh new file mode 100755 index 00000000000..0e9e90368ed --- /dev/null +++ b/scripts/lib/entrypoint-env-wrapper.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Normalize OpenShell's sandbox-create command when an OCI runtime invokes the +# image ENTRYPOINT with the literal argv: +# +# env NAME=value ... nemoclaw-start [agent command...] +# +# This runs before any managed-startup gate. Only environment names emitted by +# NemoClaw's launch renderer are promoted into the root entrypoint process; +# interpreter/loader variables such as NODE_OPTIONS, BASH_ENV, PATH, and +# LD_PRELOAD therefore cannot be smuggled into the trusted profile applicator. +# +# Result: NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGV contains the command tail. +nemoclaw_normalize_entrypoint_env_wrapper() { + NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGV=("$@") + NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGC="$#" + [ "$#" -gt 0 ] || return 0 + + case "$1" in + nemoclaw-start | /usr/local/bin/nemoclaw-start) + shift + NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGV=("$@") + NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGC="$#" + return 0 + ;; + env) ;; + *) return 0 ;; + esac + + local -a _nemoclaw_original_argv=("$@") + local -a _nemoclaw_assignments=() + local _nemoclaw_self_index=-1 + local _nemoclaw_index + local _nemoclaw_token + local _nemoclaw_name + local _nemoclaw_seen_names="|" + + # Locate only the exact self-wrapper grammar. A normal explicit command such + # as `env FOO=bar printenv` remains a user command and is not interpreted by + # this root entrypoint normalization. + for ((_nemoclaw_index = 1; _nemoclaw_index < ${#_nemoclaw_original_argv[@]}; _nemoclaw_index += 1)); do + _nemoclaw_token="${_nemoclaw_original_argv[$_nemoclaw_index]}" + case "$_nemoclaw_token" in + nemoclaw-start | /usr/local/bin/nemoclaw-start) + _nemoclaw_self_index="$_nemoclaw_index" + break + ;; + *=*) ;; + *) break ;; + esac + done + + if [ "$_nemoclaw_self_index" -lt 0 ]; then + # A managed handoff must never silently degrade into an unmanaged command + # because the self-wrapper was absent or malformed. + for _nemoclaw_token in "${_nemoclaw_original_argv[@]:1}"; do + case "$_nemoclaw_token" in + NEMOCLAW_STARTUP_PROFILE_B64=* | NEMOCLAW_CORPORATE_CA_B64=*) + printf '%s\n' \ + '[SECURITY] Malformed managed startup env wrapper; expected nemoclaw-start after assignments.' >&2 + return 1 + ;; + esac + done + return 0 + fi + + if [ "$_nemoclaw_self_index" -gt 65 ]; then + printf '%s\n' '[SECURITY] Managed startup env wrapper has too many assignments.' >&2 + return 1 + fi + + for ((_nemoclaw_index = 1; _nemoclaw_index < _nemoclaw_self_index; _nemoclaw_index += 1)); do + _nemoclaw_token="${_nemoclaw_original_argv[$_nemoclaw_index]}" + _nemoclaw_name="${_nemoclaw_token%%=*}" + if [ "${#_nemoclaw_token}" -gt 122880 ] \ + || [[ ! "$_nemoclaw_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] \ + || [[ "$_nemoclaw_token" == *$'\n'* ]] \ + || [[ "$_nemoclaw_token" == *$'\r'* ]]; then + printf '%s\n' '[SECURITY] Managed startup env wrapper contains a malformed assignment.' >&2 + return 1 + fi + case "$_nemoclaw_name" in + AWS_EC2_METADATA_DISABLED | \ + CHAT_UI_URL | \ + HTTP_PROXY | HTTPS_PROXY | NO_PROXY | \ + http_proxy | https_proxy | no_proxy | \ + OPENCLAW_HOME | OPENCLAW_STATE_DIR | OPENCLAW_WORKSPACE_DIR | \ + NEMOCLAW_AUTO_PAIR_DEADLINE_SECS | \ + NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS | \ + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS | \ + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS | \ + NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS | \ + NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS | \ + NEMOCLAW_CORPORATE_CA_B64 | \ + NEMOCLAW_DASHBOARD_BIND | NEMOCLAW_DASHBOARD_PORT | \ + NEMOCLAW_EXTRA_PLACEHOLDER_KEYS | \ + NEMOCLAW_HERMES_DASHBOARD | \ + NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT | \ + NEMOCLAW_HERMES_DASHBOARD_PORT | \ + NEMOCLAW_HERMES_DASHBOARD_TUI | \ + NEMOCLAW_MINIMAL_BOOTSTRAP | \ + NEMOCLAW_OBSERVABILITY | \ + NEMOCLAW_PROXY_HOST | NEMOCLAW_PROXY_PORT | \ + NEMOCLAW_SANDBOX_NAME | \ + NEMOCLAW_STARTUP_PROFILE_B64) ;; + *) + printf '%s\n' \ + "[SECURITY] Managed startup env wrapper contains unsupported variable '${_nemoclaw_name}'." >&2 + return 1 + ;; + esac + case "$_nemoclaw_seen_names" in + *"|${_nemoclaw_name}|"*) + printf '%s\n' \ + "[SECURITY] Managed startup env wrapper repeats variable '${_nemoclaw_name}'." >&2 + return 1 + ;; + esac + _nemoclaw_assignments+=("$_nemoclaw_token") + _nemoclaw_seen_names="${_nemoclaw_seen_names}${_nemoclaw_name}|" + done + + # Export only after the complete vector has passed validation so malformed + # input cannot leave a partially mutated root process. + if [ "$_nemoclaw_self_index" -gt 1 ]; then + for _nemoclaw_token in "${_nemoclaw_assignments[@]}"; do + export "${_nemoclaw_token?}" + done + fi + # shellcheck disable=SC2034 # output array is consumed by the sourcing entrypoint + NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGV=( + "${_nemoclaw_original_argv[@]:$((_nemoclaw_self_index + 1))}" + ) + # shellcheck disable=SC2034 # output count is consumed by the sourcing entrypoint + NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGC=$((\ + ${#_nemoclaw_original_argv[@]} - _nemoclaw_self_index - 1)) +} diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index e6cfdab7835..ea03cea82f5 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -28,6 +28,11 @@ import { telegramManifest } from "../../channels/telegram/manifest.ts"; import { wechatManifest } from "../../channels/wechat/manifest.ts"; import { whatsappManifest } from "../../channels/whatsapp/manifest.ts"; import type { ChannelAgentPackageRuntimeLockSpec, ChannelManifest } from "../../manifest/types.ts"; +import { + selectActiveMessagingChannelIds, + selectEnabledMessagingAgentRender, + selectEnabledPostAgentInstallBuildFiles, +} from "../../post-agent-install-selection.ts"; type Env = Record; type JsonObject = Record; @@ -341,19 +346,7 @@ export function applyMessagingAgentRenderToLocalFiles( export function activeChannels(plan: MessagingBuildPlan | null): string[] { if (!plan) return []; - const seen = new Set(); - const channels: string[] = []; - for (const item of plan.channels) { - const channel = String(item.channelId || "") - .trim() - .toLowerCase(); - if (!channel || seen.has(channel)) continue; - if (item.active === true && item.disabled !== true) { - seen.add(channel); - channels.push(channel); - } - } - return channels; + return selectActiveMessagingChannelIds(plan); } export function messagingRuntimePlanPath(env: Env = process.env): string { @@ -974,10 +967,7 @@ function resolveAgentRenderTarget( } function enabledAgentRender(plan: MessagingBuildPlan): MessagingRenderEntry[] { - const active = new Set(activeChannels(plan)); - return plan.agentRender.filter( - (render) => render.agent === plan.agent && active.has(render.channelId), - ); + return selectEnabledMessagingAgentRender(plan); } function enabledBuildStepsForPhase( @@ -985,6 +975,9 @@ function enabledBuildStepsForPhase( phase: MessagingHookPhase, ): MessagingBuildStep[] { if (!plan) return []; + if (phase === "post-agent-install") { + return selectEnabledPostAgentInstallBuildFiles(plan); + } return enabledBuildSteps(plan).filter((step) => buildStepMatchesPhase(plan, step, phase)); } @@ -1714,11 +1707,25 @@ function formatError(error: unknown): string { export type MessagingBuildPhase = "runtime-setup" | "agent-install" | "post-agent-install"; +export interface MessagingBuildPhaseOptions { + /** + * A managed image already contains the reviewed capability union. Apply only + * the explicit render and build-file plan to its durable home directory. + */ + readonly managedStartupRuntime?: boolean; +} + export function applyMessagingBuildPhase( plan: MessagingBuildPlan | null, phase: MessagingBuildPhase, env: Env = process.env, + options: MessagingBuildPhaseOptions = {}, ): readonly string[] { + if (options.managedStartupRuntime && phase !== "post-agent-install") { + throw new MessagingBuildApplierError( + "Managed startup runtime mode is only valid for post-agent-install", + ); + } if (phase === "runtime-setup") { const target = writeMessagingRuntimePlanArtifact(plan, messagingRuntimePlanPath(env)); return target ? [target] : []; @@ -1732,7 +1739,7 @@ export function applyMessagingBuildPhase( ...applyPostAgentInstallBuildFilesToLocalFiles(plan), ]; const appliedTargets = applyPostAgentInstallOutputs(); - if (plan?.agent === "openclaw") { + if (plan?.agent === "openclaw" && !options.managedStartupRuntime) { runOpenClawMessagingDoctor(plan, env); return uniqueStrings([...appliedTargets, ...applyPostAgentInstallOutputs()]); } @@ -1802,23 +1809,25 @@ export function describeMessagingBuildPhase( } export function main(argv: readonly string[] = process.argv.slice(2)): void { - const { agent, phase, dryRun } = parseMessagingBuildArgs(argv); + const { agent, phase, dryRun, managedStartupRuntime } = parseMessagingBuildArgs(argv); const plan = readMessagingBuildPlanFromEnv(process.env, agent); if (dryRun) { console.log(JSON.stringify(describeMessagingBuildPhase(plan, phase, process.env), null, 2)); return; } - applyMessagingBuildPhase(plan, phase, process.env); + applyMessagingBuildPhase(plan, phase, process.env, { managedStartupRuntime }); } function parseMessagingBuildArgs(argv: readonly string[]): { readonly agent: MessagingAgentId; readonly phase: MessagingBuildPhase; readonly dryRun: boolean; + readonly managedStartupRuntime: boolean; } { let agent: MessagingAgentId | undefined; let phase: MessagingBuildPhase | undefined; let dryRun = false; + let managedStartupRuntime = false; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; @@ -1826,6 +1835,10 @@ function parseMessagingBuildArgs(argv: readonly string[]): { dryRun = true; continue; } + if (arg === "--managed-startup-runtime") { + managedStartupRuntime = true; + continue; + } if (arg === "--agent") { agent = readAgentArg(argv[index + 1]); index += 1; @@ -1851,10 +1864,17 @@ function parseMessagingBuildArgs(argv: readonly string[]): { throw new MessagingBuildApplierError(`Unknown messaging build applier argument: ${arg}`); } + const resolvedPhase = phase ?? "post-agent-install"; + if (managedStartupRuntime && resolvedPhase !== "post-agent-install") { + throw new MessagingBuildApplierError( + "--managed-startup-runtime requires --phase post-agent-install", + ); + } return { agent: agent ?? "openclaw", - phase: phase ?? "post-agent-install", + phase: resolvedPhase, dryRun, + managedStartupRuntime, }; } diff --git a/src/lib/messaging/post-agent-install-selection.ts b/src/lib/messaging/post-agent-install-selection.ts new file mode 100644 index 00000000000..63c9afb380c --- /dev/null +++ b/src/lib/messaging/post-agent-install-selection.ts @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +interface SelectionHook { + readonly id: string; + readonly phase: string; +} + +interface SelectionChannel { + readonly channelId: string; + readonly active?: boolean; + readonly disabled?: boolean; + readonly hooks?: readonly SelectionHook[]; +} + +interface SelectionPlanBase { + readonly channels: readonly SelectionChannel[]; +} + +/** + * Canonical active-channel selection for the image applier. Each selection + * consumer must resolve the same active channels and mutable outputs. + */ +export function selectActiveMessagingChannelIds(plan: SelectionPlanBase): string[] { + const seen = new Set(); + const channels: string[] = []; + for (const item of plan.channels) { + const channel = String(item.channelId || "") + .trim() + .toLowerCase(); + if (!channel || seen.has(channel)) continue; + if (item.active === true && item.disabled !== true) { + seen.add(channel); + channels.push(channel); + } + } + return channels; +} + +export function selectEnabledMessagingAgentRender< + Render extends { + readonly agent: string; + readonly channelId: string; + }, +>( + plan: SelectionPlanBase & { + readonly agent: string; + readonly agentRender: readonly Render[]; + }, +): Render[] { + const active = new Set(selectActiveMessagingChannelIds(plan)); + return plan.agentRender.filter( + (render) => render.agent === plan.agent && active.has(render.channelId), + ); +} + +export function selectEnabledPostAgentInstallBuildFiles< + Step extends { + readonly channelId: string; + readonly kind: string; + readonly hookId?: string; + }, +>( + plan: SelectionPlanBase & { + readonly buildSteps: readonly Step[]; + }, +): Step[] { + const active = new Set(selectActiveMessagingChannelIds(plan)); + return plan.buildSteps.filter((step) => { + if (!active.has(step.channelId) || step.kind !== "build-file") return false; + if (!step.hookId) return true; + const hookPhase = plan.channels + .find((channel) => channel.channelId === step.channelId) + ?.hooks?.find((hook) => hook.id === step.hookId)?.phase; + return hookPhase === undefined || hookPhase === "post-agent-install"; + }); +} diff --git a/src/lib/onboard/managed-startup-agent-environment.test.ts b/src/lib/onboard/managed-startup-agent-environment.test.ts index 3695c179ef3..98bd6c0b5a7 100644 --- a/src/lib/onboard/managed-startup-agent-environment.test.ts +++ b/src/lib/onboard/managed-startup-agent-environment.test.ts @@ -16,12 +16,26 @@ import { MANAGED_STARTUP_AGENTS, MANAGED_STARTUP_PROFILE_AFFORDANCE_INVENTORY, MANAGED_STARTUP_PROFILE_SCHEMA_VERSION, + MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS, type ManagedStartupAgent, type ManagedStartupJsonObject, type ManagedStartupProfile, } from "./managed-startup/profile"; const CA_SHA256 = "a".repeat(64); +const OPENCLAW_APPLICATION_RUNTIME_NAMES = [ + "NEMOCLAW_AUTO_PAIR_DEADLINE_SECS", + "NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS", + "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS", + "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS", + "NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS", + "NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS", +] as const; +const UNSUPPORTED_AGENT_RUNTIME_UNSETS = [ + ...OPENCLAW_APPLICATION_RUNTIME_NAMES, + "NEMOCLAW_DASHBOARD_BIND", + "NEMOCLAW_MINIMAL_BOOTSTRAP", +] as const; function messagingPlan(agent: "openclaw" | "hermes"): ManagedStartupJsonObject { return { @@ -233,7 +247,14 @@ const PROFILES: Readonly ManagedStartupProfile describe("managed startup agent environment", () => { it("maps every OpenClaw profile field to the existing generator and entrypoint contracts", () => { - const result = mapManagedStartupProfileToAgentEnvironment(openClawProfile()); + const result = mapManagedStartupProfileToAgentEnvironment(openClawProfile(), { + NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: " 30 ", + NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "3e0", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS: "0.25", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "03", + NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS: "10.0", + NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "6e2", + }); expect(result.schemaVersion).toBe(1); expect(result.agent).toBe("openclaw"); @@ -283,6 +304,20 @@ describe("managed startup agent environment", () => { no_proxy: "127.0.0.1,inference.local,localhost", }); expect(Object.hasOwn(result.runtimeEnvironment, "NEMOCLAW_MESSAGING_PLAN_B64")).toBe(false); + expect(result.applicationRuntime).toEqual({ + exportEnvironment: { + NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "30", + NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "3", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS: "0.25", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "3", + NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS: "10", + NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "600", + }, + unsetEnvironment: [], + }); + expect(Object.isFrozen(result.applicationRuntime)).toBe(true); + expect(Object.isFrozen(result.applicationRuntime.exportEnvironment)).toBe(true); + expect(Object.isFrozen(result.applicationRuntime.unsetEnvironment)).toBe(true); expect( decodeBase64Json(result.configurationEnvironment.NEMOCLAW_INFERENCE_COMPAT_B64 ?? ""), @@ -343,8 +378,69 @@ describe("managed startup agent environment", () => { ]); }); + it.each([ + ["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS", "0", /positive safe integer/u], + ["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS", "1.5", /positive safe integer/u], + [ + "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS", + String(Number.MAX_SAFE_INTEGER + 1), + /positive safe integer/u, + ], + ["NEMOCLAW_AUTO_PAIR_DEADLINE_SECS", "Infinity", /finite positive seconds/u], + ["NEMOCLAW_AUTO_PAIR_DEADLINE_SECS", "NaN", /finite positive seconds/u], + ["NEMOCLAW_AUTO_PAIR_DEADLINE_SECS", "not-a-number", /finite positive seconds/u], + ["NEMOCLAW_AUTO_PAIR_DEADLINE_SECS", "1\n", /single-line text/u], + ["NEMOCLAW_AUTO_PAIR_DEADLINE_SECS", "\r1", /single-line text/u], + ["NEMOCLAW_AUTO_PAIR_DEADLINE_SECS", "1\0", /single-line text/u], + ["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS", "-0.1", /finite positive seconds/u], + ["NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS", " ", /finite positive seconds/u], + ] as const)("rejects invalid application runtime input %s=%s", (name, value, message) => { + expect(() => + mapManagedStartupProfileToAgentEnvironment(openClawProfile(), { [name]: value }), + ).toThrow(message); + }); + + it.each( + MANAGED_STARTUP_AGENTS, + )("derives every unsupported $0 runtime unset from the closed contract", (agent) => { + const result = mapManagedStartupProfileToAgentEnvironment(PROFILES[agent](), { + NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "30", + NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "3", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS: "0.25", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "3", + NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS: "10", + NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "600", + }); + const unsets = new Set(result.applicationRuntime.unsetEnvironment); + for (const obligation of MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS) { + expect(unsets.has(obligation.input)).toBe(!obligation.supportedFor.includes(agent)); + } + for (const name of OPENCLAW_APPLICATION_RUNTIME_NAMES) { + expect(unsets.has(name)).toBe(agent !== "openclaw"); + } + }); + + it("keeps the profile mapper independent from mutable process-global runtime input", () => { + const name = "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS"; + const previous = process.env[name]; + process.env[name] = "not-a-number"; + try { + expect( + mapManagedStartupProfileToAgentEnvironment(openClawProfile()).applicationRuntime, + ).toEqual({ + exportEnvironment: {}, + unsetEnvironment: [], + }); + } finally { + delete process.env[name]; + Object.assign(process.env, previous === undefined ? {} : { [name]: previous }); + } + }); + it("maps every Hermes profile field, including gateway presets and dashboard forwarding", () => { - const result = mapManagedStartupProfileToAgentEnvironment(hermesProfile()); + const result = mapManagedStartupProfileToAgentEnvironment(hermesProfile(), { + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "not-a-number", + }); expect(result.configurationEnvironment).toEqual({ CHAT_UI_URL: "http://127.0.0.1:19189", @@ -394,6 +490,10 @@ describe("managed startup agent environment", () => { https_proxy: "http://proxy.example.test:3128", no_proxy: "127.0.0.1,localhost", }); + expect(result.applicationRuntime).toEqual({ + exportEnvironment: {}, + unsetEnvironment: UNSUPPORTED_AGENT_RUNTIME_UNSETS, + }); expect(result.actions).toContainEqual({ kind: "apply-messaging-plan", agent: "hermes", @@ -415,7 +515,9 @@ describe("managed startup agent environment", () => { }); it("keeps DCode routing and auto-approval in root-owned files instead of ambient runtime env", () => { - const result = mapManagedStartupProfileToAgentEnvironment(dcodeProfile()); + const result = mapManagedStartupProfileToAgentEnvironment(dcodeProfile(), { + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS: "not-a-number", + }); expect(result.configurationEnvironment).toEqual({ HTTP_PROXY: "", @@ -448,6 +550,10 @@ describe("managed startup agent environment", () => { ...expectedDcodeRuntime, NEMOCLAW_OBSERVABILITY: "1", }); + expect(result.applicationRuntime).toEqual({ + exportEnvironment: {}, + unsetEnvironment: UNSUPPORTED_AGENT_RUNTIME_UNSETS, + }); for (const environment of [result.configurationEnvironment, result.runtimeEnvironment]) { expect(environment).not.toHaveProperty("NEMOCLAW_DCODE_AUTO_APPROVAL"); expect(environment).not.toHaveProperty("NEMOCLAW_MESSAGING_PLAN_B64"); diff --git a/src/lib/onboard/managed-startup-image-runtime.test.ts b/src/lib/onboard/managed-startup-image-runtime.test.ts index f643e15bf80..51e842310ae 100644 --- a/src/lib/onboard/managed-startup-image-runtime.test.ts +++ b/src/lib/onboard/managed-startup-image-runtime.test.ts @@ -1,13 +1,42 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import { createHash, X509Certificate } 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"; +const coordinatorMock = vi.hoisted(() => ({ + coordinateManagedStartupApplication: vi.fn(), +})); +vi.mock("./managed-startup/coordinator", () => coordinatorMock); + +import { + MANAGED_STARTUP_E2E_CORPORATE_CA_PEM, + managedStartupE2eProfile, +} from "../../../scripts/checks/generate-managed-startup-profile-fixture.mts"; +import { mapManagedStartupProfileToAgentEnvironment } from "./managed-startup/agent-environment"; import { + applyManagedStartupCommandEnvironmentPlan, + applyManagedStartupImageProfile, buildManagedStartupImageActionPlan, + MANAGED_STARTUP_MERGED_CA_FILE, + MANAGED_STARTUP_PROFILE_ENV, + MANAGED_STARTUP_RUNTIME_ENV_FILE, type ManagedStartupImageActionPlanInput, + normalizeHermesManagedConfigDescriptor, + readStableRegularFile, + serializeManagedStartupRuntimeEnvironment, } from "./managed-startup/image-runtime"; -import type { ManagedStartupAgent, ManagedStartupDashboard } from "./managed-startup/profile"; +import { + encodeManagedStartupProfile, + fingerprintManagedStartupProfile, + MANAGED_STARTUP_AGENTS, + type ManagedStartupAgent, + type ManagedStartupDashboard, + validateManagedStartupProfile, +} from "./managed-startup/profile"; function dashboard(agent: ManagedStartupAgent): ManagedStartupDashboard { switch (agent) { @@ -229,3 +258,490 @@ describe("buildManagedStartupImageActionPlan", () => { ).toThrow(message); }); }); + +const PROXY_ENV_NAMES = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", +] as const; +const OPENCLAW_APPLICATION_RUNTIME_NAMES = [ + "NEMOCLAW_AUTO_PAIR_DEADLINE_SECS", + "NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS", + "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS", + "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS", + "NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS", + "NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS", +] as const; + +describe("managed startup image runtime", () => { + let temporaryDirectoryPath = ""; + + beforeEach(() => { + coordinatorMock.coordinateManagedStartupApplication.mockReset(); + temporaryDirectoryPath = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-startup-")); + }); + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(temporaryDirectoryPath, { force: true, recursive: true }); + }); + + function temporaryDirectory(): string { + return temporaryDirectoryPath; + } + + function mockDescriptorOwnership(uid: bigint, gid: bigint): void { + const realFstatSync = fs.fstatSync.bind(fs); + const realLstatSync = fs.lstatSync.bind(fs); + const ownership = new Map([ + ["uid", uid], + ["gid", gid], + ]); + const owned = (stat: fs.BigIntStats): fs.BigIntStats => + new Proxy(stat, { + get(inner, property) { + const value = ownership.has(property) + ? ownership.get(property) + : (Reflect.get(inner, property, inner) as unknown); + return typeof value === "function" ? value.bind(inner) : value; + }, + }); + vi.spyOn(fs, "fstatSync").mockImplementation(((descriptor: number, options: { bigint: true }) => + owned(realFstatSync(descriptor, options))) as typeof fs.fstatSync); + vi.spyOn(fs, "lstatSync").mockImplementation(((file: fs.PathLike, options: { bigint: true }) => + owned(realLstatSync(file, options))) as typeof fs.lstatSync); + } + + function mockRootReplayFilesystem(runtimeWrites: string[]): void { + const directories = new Set([ + "/", + "/run", + "/run/nemoclaw", + "/var", + "/var/lib", + "/var/lib/nemoclaw", + ]); + let runtimeFileWritten = false; + const stat = (kind: "directory" | "file", mode: number) => + ({ + gid: 0, + isDirectory: () => kind === "directory", + isFile: () => kind === "file", + isSymbolicLink: () => false, + mode, + nlink: 1, + uid: 0, + }) as fs.Stats; + const missing = (): never => { + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + }; + + vi.spyOn(process, "geteuid").mockReturnValue(0); + vi.spyOn(fs, "lstatSync").mockImplementation(((target: fs.PathLike) => { + const resolved = String(target); + return directories.has(resolved) + ? stat("directory", 0o755) + : resolved === MANAGED_STARTUP_RUNTIME_ENV_FILE && runtimeFileWritten + ? stat("file", 0o400) + : missing(); + }) as typeof fs.lstatSync); + vi.spyOn(fs, "mkdirSync").mockImplementation(() => undefined); + vi.spyOn(fs, "chownSync").mockImplementation(() => undefined); + vi.spyOn(fs, "chmodSync").mockImplementation(() => undefined); + vi.spyOn(fs, "existsSync").mockReturnValue(false); + vi.spyOn(fs, "openSync").mockReturnValue(91); + vi.spyOn(fs, "fchownSync").mockImplementation(() => undefined); + vi.spyOn(fs, "writeFileSync").mockImplementation(((target: fs.PathOrFileDescriptor, value) => { + runtimeWrites.push(...(target === 91 ? [String(value)] : [])); + }) as typeof fs.writeFileSync); + vi.spyOn(fs, "fchmodSync").mockImplementation(() => undefined); + vi.spyOn(fs, "fsyncSync").mockImplementation(() => undefined); + vi.spyOn(fs, "closeSync").mockImplementation(() => undefined); + vi.spyOn(fs, "renameSync").mockImplementation((_source, target) => { + runtimeFileWritten ||= String(target) === MANAGED_STARTUP_RUNTIME_ENV_FILE; + }); + vi.spyOn(fs, "unlinkSync").mockImplementation(missing); + } + + it("rejects invalid OpenClaw launch controls before filesystem or coordinator mutation", async () => { + const profile = managedStartupE2eProfile("openclaw"); + const lstat = vi.spyOn(fs, "lstatSync"); + vi.spyOn(process, "geteuid").mockReturnValue(0); + + await expect( + applyManagedStartupImageProfile("openclaw", { + NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION: "1", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS: "NaN", + [MANAGED_STARTUP_PROFILE_ENV]: encodeManagedStartupProfile(profile), + }), + ).rejects.toThrow(/finite positive seconds/u); + expect(lstat).not.toHaveBeenCalled(); + expect(coordinatorMock.coordinateManagedStartupApplication).not.toHaveBeenCalled(); + }); + + it("refreshes admitted launch controls on committed replay without changing the profile", async () => { + const profile = managedStartupE2eProfile("openclaw"); + const encodedProfile = encodeManagedStartupProfile(profile); + const fingerprint = fingerprintManagedStartupProfile(profile); + const runtimeWrites: string[] = []; + mockRootReplayFilesystem(runtimeWrites); + coordinatorMock.coordinateManagedStartupApplication.mockResolvedValue({ + adapterApplied: false, + application: { + status: "committed", + stateDirectory: "/var/lib/nemoclaw/managed-startup", + generationDirectory: `/var/lib/nemoclaw/managed-startup/generation-${fingerprint}`, + profilePath: `/var/lib/nemoclaw/managed-startup/generation-${fingerprint}/profile.json`, + corporateCaPath: null, + fingerprint, + expectedAgent: "openclaw", + profile, + }, + }); + + const first = await applyManagedStartupImageProfile("openclaw", { + NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION: "1", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "3", + [MANAGED_STARTUP_PROFILE_ENV]: encodedProfile, + }); + const second = await applyManagedStartupImageProfile("openclaw", { + NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION: "1", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "5", + [MANAGED_STARTUP_PROFILE_ENV]: encodedProfile, + }); + + expect(first).toMatchObject({ adapterApplied: false, fingerprint }); + expect(second).toMatchObject({ adapterApplied: false, fingerprint }); + expect(runtimeWrites).toHaveLength(2); + expect(runtimeWrites[0]).toContain("export NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS='3'"); + expect(runtimeWrites[1]).toContain("export NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS='5'"); + expect(coordinatorMock.coordinateManagedStartupApplication).toHaveBeenCalledTimes(2); + }); + + it.each( + MANAGED_STARTUP_AGENTS, + )("maps the complete %s profile into the reviewed image command contract", (agent) => { + const mapped = mapManagedStartupProfileToAgentEnvironment(managedStartupE2eProfile(agent)); + const plan = buildManagedStartupImageActionPlan({ + agent: mapped.agent, + actions: mapped.actions, + }); + + expect(plan.map(({ action }) => action)).toEqual( + agent === "langchain-deepagents-code" + ? ["generate-agent-config"] + : ["messaging-runtime-setup", "generate-agent-config", "messaging-post-agent-install"], + ); + expect(plan.some((command) => command.argv.includes("agent-install"))).toBe(false); + }); + + it.each( + MANAGED_STARTUP_AGENTS, + )("provides valid same-profile and changed-profile fixtures for %s recreation checks", (agent) => { + const initial = validateManagedStartupProfile(managedStartupE2eProfile(agent)); + const same = validateManagedStartupProfile(managedStartupE2eProfile(agent)); + const changed = validateManagedStartupProfile(managedStartupE2eProfile(agent, true)); + + expect(fingerprintManagedStartupProfile(same)).toBe(fingerprintManagedStartupProfile(initial)); + expect(fingerprintManagedStartupProfile(changed)).not.toBe( + fingerprintManagedStartupProfile(initial), + ); + }); + + it("binds the real corporate-CA fixture into every agent profile by exact digest", () => { + expect(() => new X509Certificate(MANAGED_STARTUP_E2E_CORPORATE_CA_PEM)).not.toThrow(); + const digest = createHash("sha256").update(MANAGED_STARTUP_E2E_CORPORATE_CA_PEM).digest("hex"); + + for (const agent of MANAGED_STARTUP_AGENTS) { + expect(managedStartupE2eProfile(agent, false, true).corporateCa.bundleSha256).toBe(digest); + } + }); + + it("writes a deterministic root-sourced runtime environment without profile transport", () => { + const applicationRuntime = { + exportEnvironment: { + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS: "0.25", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "3", + }, + unsetEnvironment: ["NEMOCLAW_MINIMAL_BOOTSTRAP"], + }; + const script = serializeManagedStartupRuntimeEnvironment( + { + NEMOCLAW_MODEL: "model-with-'quote", + NEMOCLAW_OBSERVABILITY: "0", + }, + true, + { + NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", + NEMOCLAW_MODEL: "model-with-'quote", + }, + applicationRuntime, + ); + + expect(script).toContain("unset NEMOCLAW_INFERENCE_BASE_URL"); + expect(script).toContain("unset NEMOCLAW_MINIMAL_BOOTSTRAP"); + expect(script).toContain("export NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS='0.25'"); + expect(script).toContain("export NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS='3'"); + expect(script).toContain("export NEMOCLAW_MANAGED_STARTUP_APPLIED='1'"); + expect(script).toContain("export NEMOCLAW_MODEL='model-with-'\"'\"'quote'"); + expect(script).toContain(`export SSL_CERT_FILE='${MANAGED_STARTUP_MERGED_CA_FILE}'`); + expect(script).toContain("export _NEMOCLAW_CORPORATE_CA_MERGED='1'"); + expect(script).not.toContain("NEMOCLAW_STARTUP_PROFILE_B64"); + expect(script).not.toContain("NEMOCLAW_CORPORATE_CA_B64"); + expect(script.endsWith("\n")).toBe(true); + expect( + serializeManagedStartupRuntimeEnvironment( + { + NEMOCLAW_MODEL: "model-with-'quote", + NEMOCLAW_OBSERVABILITY: "0", + }, + true, + { + NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", + NEMOCLAW_MODEL: "model-with-'quote", + }, + applicationRuntime, + ), + ).toBe(script); + }); + + it("validates runtime plans while removing launch-only exports and unsets from child commands", () => { + const ambient = { + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "stale", + NEMOCLAW_MINIMAL_BOOTSTRAP: "1", + PRESERVED: "yes", + }; + const applied = applyManagedStartupCommandEnvironmentPlan(ambient, { + exportEnvironment: { NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "3" }, + unsetEnvironment: ["NEMOCLAW_MINIMAL_BOOTSTRAP"], + }); + + expect(applied).toEqual({ + PRESERVED: "yes", + }); + expect(ambient).toHaveProperty("NEMOCLAW_MINIMAL_BOOTSTRAP", "1"); + expect(() => + applyManagedStartupCommandEnvironmentPlan(ambient, { + exportEnvironment: { NEMOCLAW_MINIMAL_BOOTSTRAP: "1" }, + unsetEnvironment: ["NEMOCLAW_MINIMAL_BOOTSTRAP"], + }), + ).toThrow(/both export and unset NEMOCLAW_MINIMAL_BOOTSTRAP/u); + expect(ambient).toHaveProperty("NEMOCLAW_MINIMAL_BOOTSTRAP", "1"); + }); + + it.each([ + "hermes", + "langchain-deepagents-code", + ] as const)("removes OpenClaw launch controls and cleanup obligations from %s children and runtime", (agent) => { + const mapped = mapManagedStartupProfileToAgentEnvironment(managedStartupE2eProfile(agent), { + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "invalid-for-this-agent", + }); + const ambient = { + ...Object.fromEntries(OPENCLAW_APPLICATION_RUNTIME_NAMES.map((name) => [name, "ambient"])), + NEMOCLAW_DASHBOARD_BIND: "0.0.0.0", + NEMOCLAW_MINIMAL_BOOTSTRAP: "1", + PRESERVED: "yes", + }; + const child = applyManagedStartupCommandEnvironmentPlan(ambient, mapped.applicationRuntime); + const script = serializeManagedStartupRuntimeEnvironment( + mapped.runtimeEnvironment, + false, + mapped.configurationEnvironment, + mapped.applicationRuntime, + ); + + expect(child).toEqual({ PRESERVED: "yes" }); + for (const name of [ + ...OPENCLAW_APPLICATION_RUNTIME_NAMES, + "NEMOCLAW_DASHBOARD_BIND", + "NEMOCLAW_MINIMAL_BOOTSTRAP", + ]) { + expect(script).toContain(`unset ${name}`); + expect(script).not.toContain(`export ${name}=`); + } + }); + + it("rejects a serialized runtime export that conflicts with an explicit unset", () => { + expect(() => + serializeManagedStartupRuntimeEnvironment( + { NEMOCLAW_MINIMAL_BOOTSTRAP: "1" }, + false, + {}, + { exportEnvironment: {}, unsetEnvironment: ["NEMOCLAW_MINIMAL_BOOTSTRAP"] }, + ), + ).toThrow(/runtime environment cannot both export and unset NEMOCLAW_MINIMAL_BOOTSTRAP/u); + }); + + it.each([ + [ + { exportEnvironment: { "BAD-NAME": "value" }, unsetEnvironment: [] }, + /invalid application runtime environment key/u, + ], + [ + { exportEnvironment: { VALID_NAME: "line 1\nline 2" }, unsetEnvironment: [] }, + /must be single-line text/u, + ], + [ + { exportEnvironment: {}, unsetEnvironment: ["DUPLICATE", "DUPLICATE"] }, + /duplicate application runtime unset/u, + ], + ])("rejects a malformed application runtime plan before command mutation", (plan, message) => { + const ambient = { PRESERVED: "yes" }; + expect(() => applyManagedStartupCommandEnvironmentPlan(ambient, plan)).toThrow(message); + expect(ambient).toEqual({ PRESERVED: "yes" }); + }); + + it.each(["openclaw", "hermes"] as const)("preserves launch-only proxy env for %s", (agent) => { + const mapped = mapManagedStartupProfileToAgentEnvironment( + managedStartupE2eProfile(agent, false, false, true), + ); + const script = serializeManagedStartupRuntimeEnvironment( + mapped.runtimeEnvironment, + false, + mapped.configurationEnvironment, + ); + for (const name of PROXY_ENV_NAMES) { + expect(script).not.toMatch(new RegExp(`(?:export|unset) ${name}(?:=|$)`, "mu")); + } + }); + + it("clears launch-only proxy env when DCode pins managed routing", () => { + const mapped = mapManagedStartupProfileToAgentEnvironment( + managedStartupE2eProfile("langchain-deepagents-code", false, false, true), + ); + const script = serializeManagedStartupRuntimeEnvironment( + mapped.runtimeEnvironment, + false, + mapped.configurationEnvironment, + ); + for (const name of PROXY_ENV_NAMES) { + expect(script).toContain(`unset ${name}`); + } + }); + + it("rejects multiline runtime values before producing a sourceable file", () => { + expect(() => + serializeManagedStartupRuntimeEnvironment({ NEMOCLAW_MODEL: "bad\nvalue" }, false), + ).toThrow(/single-line/u); + }); + + it("refuses a symlink instead of opening its target", () => { + const directory = temporaryDirectory(); + const target = path.join(directory, "target"); + const link = path.join(directory, "link"); + fs.writeFileSync(target, "trusted\n"); + fs.symlinkSync(target, link); + + expect(() => readStableRegularFile(link, 1024)).toThrow(/unsafe or unreadable/u); + }); + + it("rejects descriptor metadata drift after a bounded read", () => { + const directory = temporaryDirectory(); + const target = path.join(directory, "material"); + fs.writeFileSync(target, "trusted\n", { mode: 0o600 }); + const realReadSync = fs.readSync.bind(fs); + vi.spyOn(fs, "readSync") + .mockImplementationOnce((( + descriptor: number, + buffer: NodeJS.ArrayBufferView, + offset: number, + length: number, + position: number | null, + ) => { + const bytesRead = realReadSync(descriptor, buffer, offset, length, position); + fs.chmodSync(target, 0o644); + return bytesRead; + }) as typeof fs.readSync) + .mockImplementation(realReadSync as typeof fs.readSync); + + expect(() => readStableRegularFile(target, 1024)).toThrow(/changed while it was read/u); + }); + + it("normalizes mutable sandbox-owned Hermes config descriptors to mode 0640", () => { + const directory = temporaryDirectory(); + const target = path.join(directory, "config.yaml"); + fs.writeFileSync(target, "model: managed\n", { mode: 0o600 }); + mockDescriptorOwnership(501n, 20n); + + normalizeHermesManagedConfigDescriptor(target, { + uid: 501, + gid: 20, + }); + + expect(fs.readFileSync(target, "utf8")).toBe("model: managed\n"); + expect(fs.statSync(target).mode & 0o777).toBe(0o640); + }); + + it("preserves a root-owned shields-up Hermes descriptor without chmod", () => { + const directory = temporaryDirectory(); + const target = path.join(directory, ".env"); + fs.writeFileSync(target, "OPENAI_API_KEY=managed\n", { mode: 0o444 }); + mockDescriptorOwnership(0n, 0n); + const chmod = vi.spyOn(fs, "fchmodSync"); + + normalizeHermesManagedConfigDescriptor(target, { + uid: 501, + gid: 20, + }); + + expect(chmod).not.toHaveBeenCalled(); + expect(fs.readFileSync(target, "utf8")).toBe("OPENAI_API_KEY=managed\n"); + }); + + it.each([0o440, 0o644, 0o660])("fails closed on unexpected mutable Hermes mode %s", (mode) => { + const directory = temporaryDirectory(); + const target = path.join(directory, "config.yaml"); + fs.writeFileSync(target, "model: managed\n", { mode }); + fs.chmodSync(target, mode); + mockDescriptorOwnership(501n, 20n); + + expect(() => + normalizeHermesManagedConfigDescriptor(target, { + uid: 501, + gid: 20, + }), + ).toThrow(/unexpected Hermes managed config descriptor/u); + expect(fs.statSync(target).mode & 0o777).toBe(mode); + }); + + it("fails closed on an unexpected Hermes descriptor owner", () => { + const directory = temporaryDirectory(); + const target = path.join(directory, "config.yaml"); + fs.writeFileSync(target, "model: managed\n", { mode: 0o600 }); + mockDescriptorOwnership(502n, 21n); + + expect(() => + normalizeHermesManagedConfigDescriptor(target, { + uid: 501, + gid: 20, + }), + ).toThrow(/unexpected Hermes managed config descriptor/u); + expect(fs.statSync(target).mode & 0o777).toBe(0o600); + }); + + it("detects a path replacement while normalizing through the trusted descriptor", () => { + const directory = temporaryDirectory(); + const target = path.join(directory, "config.yaml"); + const displaced = path.join(directory, "displaced.yaml"); + const replacement = path.join(directory, "replacement.yaml"); + fs.writeFileSync(target, "model: managed\n", { mode: 0o600 }); + fs.writeFileSync(replacement, "model: replaced\n", { mode: 0o640 }); + mockDescriptorOwnership(501n, 20n); + const realFchmodSync = fs.fchmodSync.bind(fs); + vi.spyOn(fs, "fchmodSync").mockImplementation((descriptor, mode) => { + realFchmodSync(descriptor, mode); + fs.renameSync(target, displaced); + fs.renameSync(replacement, target); + }); + + expect(() => + normalizeHermesManagedConfigDescriptor(target, { + uid: 501, + gid: 20, + }), + ).toThrow(/changed during normalization/u); + expect(fs.readFileSync(target, "utf8")).toBe("model: replaced\n"); + }); +}); diff --git a/src/lib/onboard/managed-startup-profile.test.ts b/src/lib/onboard/managed-startup-profile.test.ts index 256c77bb0b2..21f60a6a419 100644 --- a/src/lib/onboard/managed-startup-profile.test.ts +++ b/src/lib/onboard/managed-startup-profile.test.ts @@ -277,6 +277,8 @@ const STOCK_RUNTIME_INPUT_AGENTS = { HTTP_PROXY: MANAGED_STARTUP_AGENTS, NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: ["openclaw"], NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: ["openclaw"], + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS: ["openclaw"], + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: ["openclaw"], NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS: ["openclaw"], NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: ["openclaw"], NEMOCLAW_DASHBOARD_BIND: ["openclaw"], @@ -504,8 +506,8 @@ describe("managed startup profile", () => { ).toEqual({ NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "managed-launch-forwarded", NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "managed-launch-forwarded", - NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS: "image-consumed-not-forwarded", - NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "image-consumed-not-forwarded", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS: "managed-launch-forwarded", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "managed-launch-forwarded", NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS: "managed-launch-forwarded", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "managed-launch-forwarded", }); diff --git a/src/lib/onboard/managed-startup/agent-environment.ts b/src/lib/onboard/managed-startup/agent-environment.ts index a154667b24b..fe34d8dae73 100644 --- a/src/lib/onboard/managed-startup/agent-environment.ts +++ b/src/lib/onboard/managed-startup/agent-environment.ts @@ -5,6 +5,7 @@ import { Buffer } from "node:buffer"; import { parseSandboxMessagingPlan } from "../../messaging/plan-validation"; import { + MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS, type ManagedStartupAgent, type ManagedStartupDashboard, type ManagedStartupProfile, @@ -47,6 +48,13 @@ export type ManagedStartupAgentMaterial = | ManagedStartupCorporateCaMaterial | ManagedStartupRootOwnedFileMaterial; +export interface ManagedStartupApplicationRuntimePlan { + /** Validated launch-only values that remain available to image setup and the agent runtime. */ + readonly exportEnvironment: Readonly>; + /** Ambient launch values that are unsupported for the selected agent and must be removed. */ + readonly unsetEnvironment: readonly string[]; +} + export interface ManagedStartupGenerateConfigAction { readonly kind: "generate-agent-config"; readonly agent: ManagedStartupConfigAgent; @@ -107,6 +115,7 @@ export interface ManagedStartupAgentEnvironment { * agent runtime adapters after generated configuration is committed. */ readonly runtimeEnvironment: Readonly>; + readonly applicationRuntime: ManagedStartupApplicationRuntimePlan; readonly materials: readonly ManagedStartupAgentMaterial[]; readonly actions: readonly ManagedStartupAgentAction[]; } @@ -119,6 +128,17 @@ export class ManagedStartupAgentEnvironmentError extends Error { } type MutableEnvironment = Record; +type ApplicationEnvironment = Readonly>; +const EMPTY_APPLICATION_ENVIRONMENT: ApplicationEnvironment = Object.freeze({}); + +const OPENCLAW_APPLICATION_RUNTIME_INPUTS = Object.freeze([ + ["NEMOCLAW_AUTO_PAIR_DEADLINE_SECS", "positive-finite-seconds"], + ["NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS", "positive-finite-seconds"], + ["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS", "positive-finite-seconds"], + ["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS", "positive-safe-integer"], + ["NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS", "positive-finite-seconds"], + ["NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS", "positive-finite-seconds"], +] as const); function booleanFlag(value: boolean): "0" | "1" { return value ? "1" : "0"; @@ -149,6 +169,58 @@ function sortedEnvironment(environment: MutableEnvironment): Readonly 0 + : Number.isFinite(value) && value > 0; + if (!valid) { + throw new ManagedStartupAgentEnvironmentError( + `${name} must be ${ + kind === "positive-safe-integer" ? "a positive safe integer" : "finite positive seconds" + }`, + ); + } + return String(value); +} + +function applicationRuntimePlan( + profile: ManagedStartupProfile, + environment: ApplicationEnvironment, +): ManagedStartupApplicationRuntimePlan { + const exportEnvironment: MutableEnvironment = {}; + if (profile.agent === "openclaw") { + for (const [name, kind] of OPENCLAW_APPLICATION_RUNTIME_INPUTS) { + const raw = environment[name]; + if (raw !== undefined) { + exportEnvironment[name] = canonicalApplicationRuntimeValue(name, raw, kind); + } + } + } + const unsetEnvironment = new Set( + MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS.filter( + ({ supportedFor }) => !supportedFor.includes(profile.agent), + ).map(({ input }) => input), + ); + if (profile.agent !== "openclaw") { + for (const [name] of OPENCLAW_APPLICATION_RUNTIME_INPUTS) { + unsetEnvironment.add(name); + } + } + return Object.freeze({ + exportEnvironment: sortedEnvironment(exportEnvironment), + unsetEnvironment: Object.freeze([...unsetEnvironment].sort()), + }); +} + function commonConfigurationEnvironment(profile: ManagedStartupProfile): MutableEnvironment { return { NEMOCLAW_INFERENCE_API: profile.inference.api, @@ -272,7 +344,10 @@ function applicationActions( return Object.freeze(actions); } -function mapOpenClawProfile(profile: ManagedStartupProfile): ManagedStartupAgentEnvironment { +function mapOpenClawProfile( + profile: ManagedStartupProfile, + environment: ApplicationEnvironment, +): ManagedStartupAgentEnvironment { if ( profile.agent !== "openclaw" || profile.agentConfig.agent !== "openclaw" || @@ -327,12 +402,16 @@ function mapOpenClawProfile(profile: ManagedStartupProfile): ManagedStartupAgent agent: profile.agent, configurationEnvironment: sortedEnvironment(configurationEnvironment), runtimeEnvironment: sortedEnvironment(runtimeEnvironment), + applicationRuntime: applicationRuntimePlan(profile, environment), materials: Object.freeze([corporateCaMaterial(profile)]), actions: applicationActions(profile, "openclaw"), }); } -function mapHermesProfile(profile: ManagedStartupProfile): ManagedStartupAgentEnvironment { +function mapHermesProfile( + profile: ManagedStartupProfile, + environment: ApplicationEnvironment, +): ManagedStartupAgentEnvironment { if ( profile.agent !== "hermes" || profile.agentConfig.agent !== "hermes" || @@ -373,12 +452,16 @@ function mapHermesProfile(profile: ManagedStartupProfile): ManagedStartupAgentEn agent: profile.agent, configurationEnvironment: sortedEnvironment(configurationEnvironment), runtimeEnvironment: sortedEnvironment(runtimeEnvironment), + applicationRuntime: applicationRuntimePlan(profile, environment), materials: Object.freeze([corporateCaMaterial(profile)]), actions: applicationActions(profile, "hermes"), }); } -function mapDcodeProfile(profile: ManagedStartupProfile): ManagedStartupAgentEnvironment { +function mapDcodeProfile( + profile: ManagedStartupProfile, + environment: ApplicationEnvironment, +): ManagedStartupAgentEnvironment { if ( profile.agent !== "langchain-deepagents-code" || profile.agentConfig.agent !== "langchain-deepagents-code" || @@ -442,6 +525,7 @@ function mapDcodeProfile(profile: ManagedStartupProfile): ManagedStartupAgentEnv agent: profile.agent, configurationEnvironment: sortedEnvironment(configurationEnvironment), runtimeEnvironment: sortedEnvironment(runtimeEnvironment), + applicationRuntime: applicationRuntimePlan(profile, environment), materials, actions: applicationActions(profile, null), }); @@ -455,14 +539,15 @@ function mapDcodeProfile(profile: ManagedStartupProfile): ManagedStartupAgentEnv */ export function mapManagedStartupProfileToAgentEnvironment( profile: ManagedStartupProfile, + environment: ApplicationEnvironment = EMPTY_APPLICATION_ENVIRONMENT, ): ManagedStartupAgentEnvironment { const validated = validateManagedStartupProfile(profile); switch (validated.agent) { case "openclaw": - return mapOpenClawProfile(validated); + return mapOpenClawProfile(validated, environment); case "hermes": - return mapHermesProfile(validated); + return mapHermesProfile(validated, environment); case "langchain-deepagents-code": - return mapDcodeProfile(validated); + return mapDcodeProfile(validated, environment); } } diff --git a/src/lib/onboard/managed-startup/image-runtime.ts b/src/lib/onboard/managed-startup/image-runtime.ts index 1c2f0c8af10..37642a97493 100644 --- a/src/lib/onboard/managed-startup/image-runtime.ts +++ b/src/lib/onboard/managed-startup/image-runtime.ts @@ -1,16 +1,49 @@ // 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, randomBytes } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { + type ManagedStartupAgentEnvironment, + type ManagedStartupAgentMaterial, + type ManagedStartupApplicationRuntimePlan, + mapManagedStartupProfileToAgentEnvironment, +} from "./agent-environment"; +import { + coordinateManagedStartupApplication, + type ManagedStartupAdapterContext, + type ManagedStartupAgentAdapter, +} from "./coordinator"; import { + decodeManagedStartupProfile, MANAGED_STARTUP_AGENTS, type ManagedStartupAgent, type ManagedStartupDashboard, } from "./profile"; +import { MANAGED_STARTUP_CA_ENV, MANAGED_STARTUP_PROFILE_ENV } from "./transport"; + +export { MANAGED_STARTUP_CA_ENV, MANAGED_STARTUP_PROFILE_ENV } from "./transport"; +export const MANAGED_STARTUP_RUNTIME_ENV_FILE = "/run/nemoclaw/managed-startup-runtime.env"; +export const MANAGED_STARTUP_RUNTIME_EXECUTABLE = + "/usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs"; +export const MANAGED_STARTUP_MERGED_CA_FILE = "/run/nemoclaw/managed-startup-ca-bundle.pem"; + +const MANAGED_STARTUP_CORPORATE_CA_FILE = "/usr/local/share/nemoclaw/corporate-ca.pem"; +const MESSAGING_RUNTIME_PLAN_FILE = "/usr/local/share/nemoclaw/messaging-runtime-plan.json"; +const ROOT_STATE_PARENT = "/var/lib/nemoclaw"; +const ROOT_RUNTIME_DIRECTORY = "/run/nemoclaw"; +const ROOT_OWNED_DIRECTORY_MODE = 0o755; +const MAX_TRUST_BUNDLE_BYTES = 4 * 1024 * 1024; +const HERMES_MANAGED_CONFIG_FILES = [ + "/sandbox/.hermes/config.yaml", + "/sandbox/.hermes/.env", +] as const; +const FIXED_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const SHA256_RE = /^[a-f0-9]{64}$/u; -/** - * This module owns only pure managed-image command construction. It does not - * execute commands, mutate sandbox state, or activate a compute driver. - */ export type ManagedStartupImageIdentity = "root" | "sandbox"; export type ManagedStartupMessagingAgent = "openclaw" | "hermes"; @@ -70,6 +103,13 @@ export interface ManagedStartupImageActionCommand { readonly argv: readonly string[]; } +export interface ManagedStartupImageApplyResult { + readonly agent: ManagedStartupAgent; + readonly adapterApplied: boolean; + readonly fingerprint: string; + readonly runtimeEnvironmentFile: string; +} + export class ManagedStartupImageActionPlanError extends Error { constructor(message: string) { super(`Cannot build managed startup image action plan: ${message}`); @@ -77,10 +117,97 @@ export class ManagedStartupImageActionPlanError extends Error { } } -function fail(message: string): never { +export class ManagedStartupImageRuntimeError extends Error { + constructor(message: string) { + super(`Managed startup image application failed: ${message}`); + this.name = "ManagedStartupImageRuntimeError"; + } +} + +type Environment = Record; + +interface CommandResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} + +function failActionPlan(message: string): never { throw new ManagedStartupImageActionPlanError(message); } +function exactActionPlanAgent(value: string): ManagedStartupAgent { + if ((MANAGED_STARTUP_AGENTS as readonly string[]).includes(value)) { + return value as ManagedStartupAgent; + } + return failActionPlan(`unsupported agent ${JSON.stringify(value)}`); +} + +function fail(message: string): never { + throw new ManagedStartupImageRuntimeError(message); +} + +export function validateManagedStartupApplicationRuntimePlan( + plan: ManagedStartupApplicationRuntimePlan, +): ManagedStartupApplicationRuntimePlan { + if (typeof plan !== "object" || plan === null) { + return fail("application runtime plan must be an object"); + } + const exportEnvironment = plan.exportEnvironment; + const unsetEnvironment = plan.unsetEnvironment; + if ( + typeof exportEnvironment !== "object" || + exportEnvironment === null || + Array.isArray(exportEnvironment) || + !Array.isArray(unsetEnvironment) + ) { + return fail("application runtime plan must contain exports and unsets"); + } + const exports: Record = {}; + for (const [name, value] of Object.entries(exportEnvironment)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) { + return fail(`invalid application runtime environment key ${JSON.stringify(name)}`); + } + if (typeof value !== "string" || value.includes("\0") || /[\r\n]/u.test(value)) { + return fail(`application runtime environment value for ${name} must be single-line text`); + } + exports[name] = value; + } + const unsets = new Set(); + for (const name of unsetEnvironment) { + if (typeof name !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) { + return fail(`invalid application runtime unset ${JSON.stringify(name)}`); + } + if (unsets.has(name)) { + return fail(`duplicate application runtime unset ${name}`); + } + if (Object.hasOwn(exports, name)) { + return fail(`application runtime cannot both export and unset ${name}`); + } + unsets.add(name); + } + return Object.freeze({ + exportEnvironment: Object.freeze( + Object.fromEntries( + Object.entries(exports).sort(([left], [right]) => left.localeCompare(right)), + ), + ), + unsetEnvironment: Object.freeze([...unsets].sort()), + }); +} + +export function applyManagedStartupCommandEnvironmentPlan( + environment: Readonly, + plan: ManagedStartupApplicationRuntimePlan, +): NodeJS.ProcessEnv { + const validated = validateManagedStartupApplicationRuntimePlan(plan); + const applied: NodeJS.ProcessEnv = { ...environment }; + for (const name of [...Object.keys(validated.exportEnvironment), ...validated.unsetEnvironment]) { + delete applied[name]; + } + return applied; +} + function exactAgent(value: string): ManagedStartupAgent { if ((MANAGED_STARTUP_AGENTS as readonly string[]).includes(value)) { return value as ManagedStartupAgent; @@ -88,6 +215,240 @@ function exactAgent(value: string): ManagedStartupAgent { return fail(`unsupported agent ${JSON.stringify(value)}`); } +function requireRoot(): void { + if (process.geteuid?.() !== 0) { + fail("managed startup requires container effective uid 0"); + } +} + +function modeOf(stat: fs.Stats): number { + return stat.mode & 0o777; +} + +function requireRootOwnedDirectory(target: string, mode: number): void { + let stat: fs.Stats; + try { + stat = fs.lstatSync(target); + } catch { + fail(`required root-owned directory is missing: ${target}`); + } + if ( + stat.isSymbolicLink() || + !stat.isDirectory() || + stat.uid !== 0 || + stat.gid !== 0 || + modeOf(stat) !== mode + ) { + fail(`${target} must be a root:root directory with mode ${mode.toString(8)}`); + } +} + +function ensureRootOwnedDirectory(target: string, mode = ROOT_OWNED_DIRECTORY_MODE): void { + const parent = path.dirname(target); + const parentStat = fs.lstatSync(parent); + if ( + parentStat.isSymbolicLink() || + !parentStat.isDirectory() || + parentStat.uid !== 0 || + parentStat.gid !== 0 || + (modeOf(parentStat) & 0o022) !== 0 + ) { + fail(`refusing unsafe parent directory for ${target}`); + } + try { + fs.mkdirSync(target, { mode }); + fs.chownSync(target, 0, 0); + fs.chmodSync(target, mode); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + fail(`could not create ${target}`); + } + } + requireRootOwnedDirectory(target, mode); +} + +function requireSafeExistingRootTarget(target: string): void { + let stat: fs.Stats; + try { + stat = fs.lstatSync(target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + fail(`could not inspect ${target}`); + } + if ( + stat.isSymbolicLink() || + !stat.isFile() || + stat.nlink !== 1 || + stat.uid !== 0 || + stat.gid !== 0 + ) { + fail(`refusing to replace unsafe root-owned file ${target}`); + } +} + +function atomicWriteRootFile(target: string, contents: string | Buffer, mode: number): void { + const parent = path.dirname(target); + const parentStat = fs.lstatSync(parent); + if ( + parentStat.isSymbolicLink() || + !parentStat.isDirectory() || + parentStat.uid !== 0 || + parentStat.gid !== 0 || + (modeOf(parentStat) & 0o022) !== 0 + ) { + fail(`refusing unsafe root-owned file parent ${parent}`); + } + requireSafeExistingRootTarget(target); + const temporary = path.join( + parent, + `.${path.basename(target)}.${randomBytes(12).toString("hex")}`, + ); + let descriptor: number | undefined; + try { + descriptor = fs.openSync( + temporary, + fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW, + 0o600, + ); + fs.fchownSync(descriptor, 0, 0); + fs.writeFileSync(descriptor, contents); + fs.fchmodSync(descriptor, mode); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + fs.renameSync(temporary, target); + } catch (error) { + if (descriptor !== undefined) fs.closeSync(descriptor); + try { + fs.unlinkSync(temporary); + } catch { + // Preserve the primary write failure. + } + fail(`could not atomically write ${target}: ${(error as Error).message}`); + } + const stat = fs.lstatSync(target); + if ( + stat.isSymbolicLink() || + !stat.isFile() || + stat.nlink !== 1 || + stat.uid !== 0 || + stat.gid !== 0 || + modeOf(stat) !== mode + ) { + fail(`root-owned output failed metadata verification: ${target}`); + } +} + +function removeSafeRootFile(target: string): void { + requireSafeExistingRootTarget(target); + try { + fs.unlinkSync(target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + fail(`could not remove ${target}`); + } + } +} + +function trustedExecutable(target: string): boolean { + try { + const stat = fs.lstatSync(target); + return ( + !stat.isSymbolicLink() && + stat.isFile() && + stat.uid === 0 && + stat.gid === 0 && + (modeOf(stat) & 0o022) === 0 && + (modeOf(stat) & 0o111) !== 0 + ); + } catch { + return false; + } +} + +function readSandboxIdentity(): { readonly uid: string; readonly gid: string } { + const readId = (flag: "-u" | "-g"): string => { + const result = spawnSync("/usr/bin/id", [flag, "sandbox"], { + encoding: "utf8", + env: { PATH: FIXED_PATH }, + }); + const value = result.stdout.trim(); + if (result.status !== 0 || !/^[1-9][0-9]*$/u.test(value)) { + fail("could not resolve the sandbox account"); + } + return value; + }; + return { uid: readId("-u"), gid: readId("-g") }; +} + +function sandboxPrefix(): readonly string[] { + if (trustedExecutable("/usr/local/bin/gosu")) { + return ["/usr/local/bin/gosu", "sandbox"]; + } + if (trustedExecutable("/usr/bin/setpriv")) { + const identity = readSandboxIdentity(); + return [ + "/usr/bin/setpriv", + `--reuid=${identity.uid}`, + `--regid=${identity.gid}`, + "--init-groups", + "--", + ]; + } + return fail("a trusted gosu or setpriv executable is required"); +} + +function commandEnvironment( + configurationEnvironment: Readonly>, + applicationRuntime: ManagedStartupApplicationRuntimePlan, +): NodeJS.ProcessEnv { + const env = applyManagedStartupCommandEnvironmentPlan( + { + ...process.env, + ...configurationEnvironment, + HOME: "/sandbox", + PATH: FIXED_PATH, + NPM_CONFIG_OFFLINE: "true", + npm_config_offline: "true", + PIP_DISABLE_PIP_VERSION_CHECK: "1", + PIP_NO_INDEX: "1", + UV_OFFLINE: "1", + }, + applicationRuntime, + ); + delete env[MANAGED_STARTUP_PROFILE_ENV]; + delete env[MANAGED_STARTUP_CA_ENV]; + return env; +} + +function execute( + argv: readonly string[], + runAs: ManagedStartupImageIdentity, + configurationEnvironment: Readonly>, + applicationRuntime: ManagedStartupApplicationRuntimePlan, + capture = false, +): CommandResult { + if (argv.length === 0) fail("refusing an empty managed startup command"); + const command = runAs === "sandbox" ? [...sandboxPrefix(), ...argv] : [...argv]; + const result = spawnSync(command[0] as string, command.slice(1), { + encoding: "utf8", + env: commandEnvironment(configurationEnvironment, applicationRuntime), + stdio: capture ? "pipe" : "inherit", + }); + if (result.error) { + fail(`could not execute ${argv[0]}: ${result.error.message}`); + } + if (result.status !== 0) { + const detail = capture ? `: ${(result.stderr || result.stdout).trim()}` : ""; + fail(`${argv[0]} exited with status ${String(result.status ?? "unknown")}${detail}`); + } + return { + status: result.status, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; +} + function generatorCommand(agent: ManagedStartupAgent): readonly string[] { switch (agent) { case "openclaw": @@ -132,19 +493,19 @@ function assertActionAgent( actionAgent: ManagedStartupAgent, ): void { if (inputAgent !== actionAgent) { - fail(`action for ${actionAgent} cannot be used by ${inputAgent}`); + failActionPlan(`action for ${actionAgent} cannot be used by ${inputAgent}`); } } /** * Convert the closed application-action vocabulary into immutable image - * commands. The vocabulary deliberately cannot express agent installation, - * package-manager access, command execution, or runtime activation. + * commands. The vocabulary cannot express agent installation, package-manager + * access, arbitrary command execution, or runtime activation. */ export function buildManagedStartupImageActionPlan( input: ManagedStartupImageActionPlanInput, ): readonly ManagedStartupImageActionCommand[] { - const inputAgent = exactAgent(input.agent); + const inputAgent = exactActionPlanAgent(input.agent); const commands: ManagedStartupImageActionCommand[] = []; let dashboardActions = 0; let generateActions = 0; @@ -155,15 +516,17 @@ export function buildManagedStartupImageActionPlan( switch (action.kind) { case "configure-dashboard": { if (action.dashboard.agent !== input.agent) { - fail(`dashboard for ${action.dashboard.agent} cannot be used by ${input.agent}`); + failActionPlan( + `dashboard for ${action.dashboard.agent} cannot be used by ${input.agent}`, + ); } dashboardActions += 1; break; } case "generate-agent-config": { - assertActionAgent(inputAgent, exactAgent(action.agent)); + assertActionAgent(inputAgent, exactActionPlanAgent(action.agent)); if (action.runAs !== "sandbox") { - fail("agent configuration generation must run as sandbox"); + failActionPlan("agent configuration generation must run as sandbox"); } generateActions += 1; commands.push({ @@ -174,13 +537,13 @@ export function buildManagedStartupImageActionPlan( break; } case "apply-messaging-plan": { - assertActionAgent(inputAgent, exactAgent(action.agent)); + assertActionAgent(inputAgent, exactActionPlanAgent(action.agent)); if (action.mode !== "apply" && action.mode !== "clear") { - fail("messaging intent must be apply or clear"); + failActionPlan("messaging intent must be apply or clear"); } if (action.phase === "runtime-setup") { if (action.runAs !== "root") { - fail("messaging runtime setup must run as root"); + failActionPlan("messaging runtime setup must run as root"); } runtimeMessagingActions += 1; commands.push({ @@ -190,7 +553,7 @@ export function buildManagedStartupImageActionPlan( }); } else if (action.phase === "post-agent-install") { if (action.runAs !== "sandbox") { - fail("messaging post-agent configuration must run as sandbox"); + failActionPlan("messaging post-agent configuration must run as sandbox"); } postMessagingActions += 1; commands.push({ @@ -199,23 +562,27 @@ export function buildManagedStartupImageActionPlan( argv: messagingCommand(action.agent, action.phase), }); } else { - fail("unsupported messaging construction phase"); + failActionPlan("unsupported messaging construction phase"); } break; } default: - fail("unsupported managed startup construction action"); + failActionPlan("unsupported managed startup construction action"); } } - if (dashboardActions !== 1) fail("exactly one dashboard construction action is required"); - if (generateActions !== 1) fail("exactly one agent config construction action is required"); + if (dashboardActions !== 1) { + failActionPlan("exactly one dashboard construction action is required"); + } + if (generateActions !== 1) { + failActionPlan("exactly one agent config construction action is required"); + } const expectedMessagingActions = inputAgent === "langchain-deepagents-code" ? 0 : 1; if ( runtimeMessagingActions !== expectedMessagingActions || postMessagingActions !== expectedMessagingActions ) { - fail( + failActionPlan( `${inputAgent} requires ${String(expectedMessagingActions)} action for each messaging phase`, ); } @@ -224,7 +591,7 @@ export function buildManagedStartupImageActionPlan( ? ["generate-agent-config"] : ["messaging-runtime-setup", "generate-agent-config", "messaging-post-agent-install"]; if (commands.some((command, index) => command.action !== expectedOrder[index])) { - fail(`${inputAgent} image actions are not in the required construction order`); + failActionPlan(`${inputAgent} image actions are not in the required construction order`); } return Object.freeze( @@ -236,3 +603,681 @@ export function buildManagedStartupImageActionPlan( ), ); } + +function prepareMessagingRuntimeTarget(mode: "apply" | "clear"): void { + if (mode === "clear") { + removeSafeRootFile(MESSAGING_RUNTIME_PLAN_FILE); + return; + } + requireSafeExistingRootTarget(MESSAGING_RUNTIME_PLAN_FILE); + try { + fs.unlinkSync(MESSAGING_RUNTIME_PLAN_FILE); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + fail("could not prepare the messaging runtime-plan target"); + } + } +} + +function verifyMessagingRuntimeTarget(mode: "apply" | "clear"): void { + if (mode === "clear") { + if (fs.existsSync(MESSAGING_RUNTIME_PLAN_FILE)) { + fail("clear messaging profile left a runtime-plan artifact"); + } + return; + } + const stat = fs.lstatSync(MESSAGING_RUNTIME_PLAN_FILE); + if ( + stat.isSymbolicLink() || + !stat.isFile() || + stat.nlink !== 1 || + stat.uid !== 0 || + stat.gid !== 0 || + modeOf(stat) !== 0o644 + ) { + fail("messaging runtime-plan artifact failed root ownership validation"); + } +} + +function runInternalSandboxAction( + action: "write-openclaw-hash" | "write-hermes-compat-hash", + configurationEnvironment: Readonly>, + applicationRuntime: ManagedStartupApplicationRuntimePlan, + extraEnvironment: Readonly> = {}, +): void { + execute( + ["/usr/local/bin/node", MANAGED_STARTUP_RUNTIME_EXECUTABLE, `--internal-${action}`], + "sandbox", + { ...configurationEnvironment, ...extraEnvironment }, + applicationRuntime, + ); +} + +function sealOpenClawConfiguration( + configurationEnvironment: Readonly>, + applicationRuntime: ManagedStartupApplicationRuntimePlan, +): void { + const validation = execute( + ["/usr/local/bin/openclaw", "config", "validate", "--json"], + "sandbox", + { + ...configurationEnvironment, + OPENCLAW_CONFIG_PATH: "/sandbox/.openclaw/openclaw.json", + }, + applicationRuntime, + true, + ); + let parsed: unknown; + try { + parsed = JSON.parse(validation.stdout); + } catch { + fail("OpenClaw config validation did not emit JSON"); + } + if ( + typeof parsed !== "object" || + parsed === null || + (parsed as Record).valid !== true + ) { + fail("OpenClaw rejected the generated managed startup config"); + } + runInternalSandboxAction("write-openclaw-hash", configurationEnvironment, applicationRuntime); +} + +interface StableRegularFile { + readonly bytes: Buffer; + readonly stat: fs.BigIntStats; +} + +interface NumericIdentity { + readonly uid: number; + readonly gid: number; +} + +function sameStableFileMetadata(left: fs.BigIntStats, right: fs.BigIntStats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.nlink === right.nlink && + left.uid === right.uid && + left.gid === right.gid && + left.size === right.size && + left.mtimeNs === right.mtimeNs && + left.ctimeNs === right.ctimeNs + ); +} + +function readStableRegularFileSnapshot(target: string, maxBytes: number): StableRegularFile { + if (typeof fs.constants.O_NOFOLLOW !== "number") { + fail("O_NOFOLLOW is unavailable for managed startup file reads"); + } + const nonblock = typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0; + let descriptor: number; + try { + descriptor = fs.openSync(target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | nonblock); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") throw error; + fail(`refusing unsafe or unreadable file ${target}`); + } + + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + if ( + !before.isFile() || + before.nlink !== 1n || + before.size < 1n || + before.size > BigInt(maxBytes) + ) { + fail(`refusing unsafe or oversized file ${target}`); + } + + const bytes = Buffer.alloc(Number(before.size)); + let offset = 0; + while (offset < bytes.length) { + const bytesRead = fs.readSync(descriptor, bytes, offset, bytes.length - offset, offset); + if (bytesRead === 0) break; + offset += bytesRead; + } + const overflow = Buffer.alloc(1); + const overflowBytes = fs.readSync(descriptor, overflow, 0, 1, offset); + const after = fs.fstatSync(descriptor, { bigint: true }); + if (offset !== bytes.length || overflowBytes !== 0 || !sameStableFileMetadata(before, after)) { + fail(`${target} changed while it was read`); + } + return { bytes, stat: before }; + } finally { + try { + fs.closeSync(descriptor); + } catch { + fail(`could not close safely opened file ${target}`); + } + } +} + +export function readStableRegularFile(target: string, maxBytes: number): Buffer { + return readStableRegularFileSnapshot(target, maxBytes).bytes; +} + +/** + * Restore the mutable Hermes image contract after its sandbox-side generator + * atomically replaces config.yaml or .env with mode 0600. The mode transition + * is performed through the already-authenticated descriptor, never by path. + * + * Shields-up turns these files into root:root 0444 trust anchors. That state is + * valid on an already-committed replay and must not be made mutable again. + */ +export function normalizeHermesManagedConfigDescriptor( + target: string, + sandboxIdentity: NumericIdentity, +): void { + if ( + !Number.isSafeInteger(sandboxIdentity.uid) || + sandboxIdentity.uid <= 0 || + !Number.isSafeInteger(sandboxIdentity.gid) || + sandboxIdentity.gid <= 0 + ) { + fail("invalid sandbox identity for Hermes descriptor normalization"); + } + if (typeof fs.constants.O_NOFOLLOW !== "number") { + fail("O_NOFOLLOW is unavailable for Hermes descriptor normalization"); + } + const nonblock = typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0; + let descriptor: number; + try { + descriptor = fs.openSync(target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | nonblock); + } catch { + fail(`refusing unsafe Hermes managed config descriptor ${target}`); + } + + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + const beforeMode = Number(before.mode & 0o777n); + const mutable = + before.uid === BigInt(sandboxIdentity.uid) && + before.gid === BigInt(sandboxIdentity.gid) && + (beforeMode === 0o600 || beforeMode === 0o640); + const shielded = before.uid === 0n && before.gid === 0n && beforeMode === 0o444; + if (!before.isFile() || before.nlink !== 1n || (!mutable && !shielded)) { + fail(`refusing unexpected Hermes managed config descriptor ${target}`); + } + + const expectedMode = mutable ? 0o640 : 0o444; + if (mutable && beforeMode === 0o600) { + try { + fs.fchmodSync(descriptor, expectedMode); + } catch { + fail(`could not normalize Hermes managed config descriptor ${target}`); + } + } + + const after = fs.fstatSync(descriptor, { bigint: true }); + let pathAfter: fs.BigIntStats; + try { + pathAfter = fs.lstatSync(target, { bigint: true }); + } catch { + fail(`Hermes managed config descriptor disappeared during normalization: ${target}`); + } + const expectedUid = mutable ? BigInt(sandboxIdentity.uid) : 0n; + const expectedGid = mutable ? BigInt(sandboxIdentity.gid) : 0n; + if ( + !after.isFile() || + after.nlink !== 1n || + after.dev !== before.dev || + after.ino !== before.ino || + after.uid !== expectedUid || + after.gid !== expectedGid || + Number(after.mode & 0o777n) !== expectedMode || + after.size !== before.size || + after.mtimeNs !== before.mtimeNs || + pathAfter.isSymbolicLink() || + !pathAfter.isFile() || + !sameStableFileMetadata(after, pathAfter) + ) { + fail(`Hermes managed config descriptor changed during normalization: ${target}`); + } + } finally { + try { + fs.closeSync(descriptor); + } catch { + fail(`could not close Hermes managed config descriptor ${target}`); + } + } +} + +function normalizeHermesManagedConfiguration(): void { + const identity = readSandboxIdentity(); + const sandboxIdentity = { + uid: Number(identity.uid), + gid: Number(identity.gid), + }; + for (const target of HERMES_MANAGED_CONFIG_FILES) { + normalizeHermesManagedConfigDescriptor(target, sandboxIdentity); + } +} + +function sealHermesConfiguration( + configurationEnvironment: Readonly>, + applicationRuntime: ManagedStartupApplicationRuntimePlan, +): void { + const configPath = "/sandbox/.hermes/config.yaml"; + const envPath = "/sandbox/.hermes/.env"; + const config = readStableRegularFile(configPath, 4 * 1024 * 1024); + const env = readStableRegularFile(envPath, 512 * 1024); + const digest = execute( + [ + "/opt/hermes/.venv/bin/python3", + "-I", + "/usr/local/lib/nemoclaw/build-hermes-mcp-digest.py", + "--guard", + "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py", + "--config", + configPath, + ], + "root", + configurationEnvironment, + applicationRuntime, + true, + ).stdout.trim(); + if (!SHA256_RE.test(digest)) { + fail("Hermes MCP digest helper returned an invalid digest"); + } + const hashText = [ + `${createHash("sha256").update(config).digest("hex")} ${configPath}`, + `${createHash("sha256").update(env).digest("hex")} ${envPath}`, + `# nemoclaw-hermes-mcp-state-v1 intended=${digest} applied=${digest}`, + "", + ].join("\n"); + atomicWriteRootFile("/etc/nemoclaw/hermes.config-hash", hashText, 0o444); + runInternalSandboxAction( + "write-hermes-compat-hash", + configurationEnvironment, + applicationRuntime, + { + NEMOCLAW_MANAGED_HERMES_HASH_B64: Buffer.from(hashText, "utf8").toString("base64"), + }, + ); +} + +function installRootOwnedMaterials(materials: readonly ManagedStartupAgentMaterial[]): void { + for (const material of materials) { + if (material.kind !== "root-owned-file") continue; + if (material.owner !== "root" || material.group !== "root" || material.mode !== 0o444) { + fail(`unsupported root-owned material contract for ${material.path}`); + } + atomicWriteRootFile(material.path, material.contents, material.mode); + } +} + +function verifyRootOwnedMaterials(materials: readonly ManagedStartupAgentMaterial[]): void { + for (const material of materials) { + if (material.kind !== "root-owned-file") continue; + const expected = Buffer.from(material.contents, "utf8"); + const { bytes, stat } = readStableRegularFileSnapshot(material.path, expected.length); + if ( + stat.nlink !== 1n || + stat.uid !== 0n || + stat.gid !== 0n || + Number(stat.mode & 0o777n) !== material.mode || + !bytes.equals(expected) + ) { + fail(`committed root-owned material drifted: ${material.path}`); + } + } +} + +function installCorporateCa(corporateCaPath: string | null): void { + if (corporateCaPath === null) { + removeSafeRootFile(MANAGED_STARTUP_CORPORATE_CA_FILE); + return; + } + const bytes = readStableRegularFile(corporateCaPath, 128 * 1024); + atomicWriteRootFile(MANAGED_STARTUP_CORPORATE_CA_FILE, bytes, 0o444); +} + +function safeTrustBundle(target: string): Buffer | null { + try { + const { bytes, stat } = readStableRegularFileSnapshot(target, MAX_TRUST_BUNDLE_BYTES); + if (Number(stat.mode & 0o022n) !== 0) { + fail(`refusing unsafe trust bundle ${target}`); + } + return bytes; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +function mergeCorporateCa(corporateCaPath: string | null): boolean { + if (corporateCaPath === null) { + removeSafeRootFile(MANAGED_STARTUP_MERGED_CA_FILE); + return false; + } + const corporate = readStableRegularFile(corporateCaPath, 128 * 1024); + const candidates = [ + "/etc/openshell-tls/ca-bundle.pem", + process.env.SSL_CERT_FILE ?? "", + "/etc/ssl/certs/ca-certificates.crt", + ].filter( + (candidate, index, values) => + candidate && + candidate !== MANAGED_STARTUP_MERGED_CA_FILE && + values.indexOf(candidate) === index, + ); + let base: Buffer | null = null; + for (const candidate of candidates) { + base = safeTrustBundle(candidate); + if (base) break; + } + const merged = Buffer.concat([ + ...(base ? [base, Buffer.from("\n", "utf8")] : []), + corporate, + ...(corporate.at(-1) === 0x0a ? [] : [Buffer.from("\n", "utf8")]), + ]); + atomicWriteRootFile(MANAGED_STARTUP_MERGED_CA_FILE, merged, 0o444); + return true; +} + +function shellSingleQuote(value: string): string { + if (value.includes("\0") || /[\r\n]/u.test(value)) { + fail("runtime environment values must be single-line text"); + } + return `'${value.replaceAll("'", `'\"'\"'`)}'`; +} + +export function serializeManagedStartupRuntimeEnvironment( + environment: Readonly>, + corporateCaMerged: boolean, + configurationEnvironment: Readonly> = {}, + applicationRuntime: ManagedStartupApplicationRuntimePlan = { + exportEnvironment: {}, + unsetEnvironment: [], + }, +): string { + const validatedApplicationRuntime = + validateManagedStartupApplicationRuntimePlan(applicationRuntime); + const output: Record = { + ...environment, + ...validatedApplicationRuntime.exportEnvironment, + NEMOCLAW_MANAGED_STARTUP_APPLIED: "1", + }; + if (corporateCaMerged) { + for (const name of [ + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + "NODE_EXTRA_CA_CERTS", + "REQUESTS_CA_BUNDLE", + "SSL_CERT_FILE", + ]) { + output[name] = MANAGED_STARTUP_MERGED_CA_FILE; + } + output._NEMOCLAW_CORPORATE_CA_MERGED = "1"; + } + const unsetNames = new Set([ + ...Object.keys(configurationEnvironment).filter((name) => !Object.hasOwn(output, name)), + ...validatedApplicationRuntime.unsetEnvironment, + ]); + for (const name of validatedApplicationRuntime.unsetEnvironment) { + if (Object.hasOwn(output, name)) { + fail(`runtime environment cannot both export and unset ${name}`); + } + } + const unsetLines = [...unsetNames].sort().map((name) => { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) { + fail(`invalid runtime environment key ${JSON.stringify(name)}`); + } + return `unset ${name}`; + }); + const exportLines = Object.entries(output) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, value]) => { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) { + fail(`invalid runtime environment key ${JSON.stringify(name)}`); + } + return `export ${name}=${shellSingleQuote(value)}`; + }); + return `${[...unsetLines, ...exportLines].join("\n")}\n`; +} + +function applyAdapter( + context: ManagedStartupAdapterContext, + mapped: ManagedStartupAgentEnvironment, +): void { + if (mapped.agent !== context.agent) { + fail(`mapped ${mapped.agent} environment for ${context.agent}`); + } + const commandPlan = buildManagedStartupImageActionPlan({ + agent: mapped.agent, + actions: mapped.actions, + }); + let commandIndex = 0; + for (const action of mapped.actions) { + if (action.kind === "configure-dashboard") continue; + const command = commandPlan[commandIndex]; + if (!command) fail(`missing image command for ${action.kind}`); + commandIndex += 1; + if (action.kind === "apply-messaging-plan") { + if (action.phase === "runtime-setup") { + prepareMessagingRuntimeTarget(action.mode); + } + execute( + command.argv, + command.runAs, + mapped.configurationEnvironment, + mapped.applicationRuntime, + ); + if (action.phase === "runtime-setup") { + verifyMessagingRuntimeTarget(action.mode); + } + continue; + } + execute( + command.argv, + command.runAs, + mapped.configurationEnvironment, + mapped.applicationRuntime, + ); + } + if (commandIndex !== commandPlan.length) { + fail("image action plan contains an unmatched command"); + } + + switch (context.agent) { + case "openclaw": + sealOpenClawConfiguration(mapped.configurationEnvironment, mapped.applicationRuntime); + break; + case "hermes": + sealHermesConfiguration(mapped.configurationEnvironment, mapped.applicationRuntime); + // Normalize before the coordinator commits a newly applied profile so + // the durable transaction never records generator-created 0600 files as + // a completed mutable image contract. + normalizeHermesManagedConfiguration(); + break; + case "langchain-deepagents-code": + break; + } + installRootOwnedMaterials(mapped.materials); + installCorporateCa(context.corporateCaPath); + mergeCorporateCa(context.corporateCaPath); +} + +function adapters(mapped: ManagedStartupAgentEnvironment): readonly ManagedStartupAgentAdapter[] { + return MANAGED_STARTUP_AGENTS.map((agent) => ({ + agent, + apply: (context: ManagedStartupAdapterContext) => applyAdapter(context, mapped), + })); +} + +export async function applyManagedStartupImageProfile( + expectedAgentInput: string, + env: Environment = process.env, +): Promise { + requireRoot(); + const expectedAgent = exactAgent(expectedAgentInput); + if (env.NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION !== "1") { + fail("startup profiles require a complete managed image"); + } + const encodedProfile = env[MANAGED_STARTUP_PROFILE_ENV]; + if (!encodedProfile) fail(`${MANAGED_STARTUP_PROFILE_ENV} is required`); + let profile; + try { + profile = decodeManagedStartupProfile(encodedProfile); + } catch (error) { + fail((error as Error).message); + } + if (profile.agent !== expectedAgent) { + fail(`managed startup profile targets ${profile.agent}, expected ${expectedAgent}`); + } + const mapped = mapManagedStartupProfileToAgentEnvironment(profile, env); + validateManagedStartupApplicationRuntimePlan(mapped.applicationRuntime); + + ensureRootOwnedDirectory(ROOT_STATE_PARENT); + ensureRootOwnedDirectory(ROOT_RUNTIME_DIRECTORY); + const result = await coordinateManagedStartupApplication( + { + encodedProfile, + expectedAgent, + ...(env[MANAGED_STARTUP_CA_ENV] === undefined + ? {} + : { corporateCaB64: env[MANAGED_STARTUP_CA_ENV] }), + }, + adapters(mapped), + ); + if (mapped.agent !== result.application.profile.agent) { + fail(`mapped ${mapped.agent} environment for ${result.application.profile.agent}`); + } + if (expectedAgent === "hermes" && !result.adapterApplied) { + // Committed startup replays still repair generator-created 0600 files, + // while the descriptor guard preserves root-owned shields-up files. + normalizeHermesManagedConfiguration(); + } + let corporateCaMerged: boolean; + if (result.adapterApplied) { + corporateCaMerged = result.application.corporateCaPath !== null; + } else { + verifyRootOwnedMaterials(mapped.materials); + if (result.application.corporateCaPath === null) { + if (fs.existsSync(MANAGED_STARTUP_CORPORATE_CA_FILE)) { + fail("committed profile without a corporate CA has a stale CA material"); + } + } else { + const expected = readStableRegularFile(result.application.corporateCaPath, 128 * 1024); + const installed = readStableRegularFile(MANAGED_STARTUP_CORPORATE_CA_FILE, 128 * 1024); + if (!expected.equals(installed)) { + fail("committed corporate CA material drifted"); + } + } + corporateCaMerged = mergeCorporateCa(result.application.corporateCaPath); + } + atomicWriteRootFile( + MANAGED_STARTUP_RUNTIME_ENV_FILE, + serializeManagedStartupRuntimeEnvironment( + mapped.runtimeEnvironment, + corporateCaMerged, + mapped.configurationEnvironment, + mapped.applicationRuntime, + ), + 0o400, + ); + return { + agent: expectedAgent, + adapterApplied: result.adapterApplied, + fingerprint: result.application.fingerprint, + runtimeEnvironmentFile: MANAGED_STARTUP_RUNTIME_ENV_FILE, + }; +} + +function writeSandboxFileAtomically(target: string, contents: string, mode: number): void { + const parent = path.dirname(target); + const parentStat = fs.lstatSync(parent); + if ( + parentStat.isSymbolicLink() || + !parentStat.isDirectory() || + parentStat.uid !== process.geteuid?.() || + parentStat.gid !== process.getegid?.() + ) { + fail(`refusing unsafe sandbox-owned directory ${parent}`); + } + const temporary = path.join( + parent, + `.${path.basename(target)}.${randomBytes(12).toString("hex")}`, + ); + let descriptor: number | undefined; + try { + descriptor = fs.openSync( + temporary, + fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW, + 0o600, + ); + fs.writeFileSync(descriptor, contents); + fs.fchmodSync(descriptor, mode); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + fs.renameSync(temporary, target); + } catch (error) { + if (descriptor !== undefined) fs.closeSync(descriptor); + try { + fs.unlinkSync(temporary); + } catch { + // Preserve the primary write failure. + } + fail(`could not write sandbox-owned file ${target}: ${(error as Error).message}`); + } +} + +function internalWriteOpenClawHash(): void { + if (process.geteuid?.() === 0) fail("sandbox hash writer must not run as root"); + const configPath = "/sandbox/.openclaw/openclaw.json"; + const config = readStableRegularFile(configPath, 16 * 1024 * 1024); + const text = `${createHash("sha256").update(config).digest("hex")} openclaw.json\n`; + writeSandboxFileAtomically("/sandbox/.openclaw/.config-hash", text, 0o660); +} + +function internalWriteHermesCompatHash(): void { + if (process.geteuid?.() === 0) fail("sandbox hash writer must not run as root"); + const encoded = process.env.NEMOCLAW_MANAGED_HERMES_HASH_B64 ?? ""; + if ( + encoded.length === 0 || + encoded.length > 4096 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded) + ) { + fail("Hermes compatibility hash transport is invalid"); + } + const decoded = Buffer.from(encoded, "base64"); + if (decoded.toString("base64") !== encoded) { + fail("Hermes compatibility hash transport is non-canonical"); + } + writeSandboxFileAtomically("/sandbox/.hermes/.config-hash", decoded.toString("utf8"), 0o640); +} + +function readCliAgent(argv: readonly string[]): string { + const index = argv.indexOf("--agent"); + if (index < 0 || index + 1 >= argv.length || argv.length !== 2) { + fail("usage: managed-startup-image-runtime --agent "); + } + return argv[index + 1] as string; +} + +export async function main(argv: readonly string[] = process.argv.slice(2)): Promise { + if (argv.length === 1 && argv[0] === "--internal-write-openclaw-hash") { + internalWriteOpenClawHash(); + return; + } + if (argv.length === 1 && argv[0] === "--internal-write-hermes-compat-hash") { + internalWriteHermesCompatHash(); + return; + } + const result = await applyManagedStartupImageProfile(readCliAgent(argv)); + console.log( + result.adapterApplied + ? `[managed-startup] applied ${result.agent} profile ${result.fingerprint}` + : `[managed-startup] ${result.agent} profile ${result.fingerprint} is already committed`, + ); +} + +if (require.main === module) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/src/lib/onboard/managed-startup/profile.ts b/src/lib/onboard/managed-startup/profile.ts index bacf4be6f3a..d57722afd4f 100644 --- a/src/lib/onboard/managed-startup/profile.ts +++ b/src/lib/onboard/managed-startup/profile.ts @@ -597,14 +597,12 @@ export const MANAGED_STARTUP_PROFILE_DEFERRED_RUNTIME_INPUTS = Object.freeze({ deferredRuntimeInput( "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS", "application-environment", - "the image consumes this documented scheduler control but managed launch must admit it in the application environment transaction", - "image-consumed-not-forwarded", + "operator scheduler tuning is applied by the application environment transaction", ), deferredRuntimeInput( "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS", "application-environment", - "the image consumes this documented scheduler control but managed launch must admit it in the application environment transaction", - "image-consumed-not-forwarded", + "operator scheduler tuning is applied by the application environment transaction", ), deferredRuntimeInput( "NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS", diff --git a/src/lib/onboard/sandbox-create-launch.test.ts b/src/lib/onboard/sandbox-create-launch.test.ts index e687681e3f4..d39c4611479 100644 --- a/src/lib/onboard/sandbox-create-launch.test.ts +++ b/src/lib/onboard/sandbox-create-launch.test.ts @@ -129,9 +129,10 @@ describe("prepareSandboxCreateLaunch", () => { env: { NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: " 30 ", NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "3", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS: " 0.25 ", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: " 99 ", NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS: "10", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "600", - NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "99", NEMOCLAW_PROVIDER_KEY: "must-not-enter-the-sandbox", }, extraPlaceholderKeys: [], @@ -150,12 +151,11 @@ describe("prepareSandboxCreateLaunch", () => { "OPENCLAW_WORKSPACE_DIR=/sandbox/.openclaw/workspace", "NEMOCLAW_AUTO_PAIR_DEADLINE_SECS=30", "NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS=3", + "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS=0.25", + "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS=99", "NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS=10", "NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS=600", ]); - expect(result.sandboxStartupCommand.join(" ")).not.toContain( - "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS", - ); expect(result.sandboxStartupCommand.join(" ")).not.toContain("NEMOCLAW_PROVIDER_KEY"); }); @@ -164,7 +164,11 @@ describe("prepareSandboxCreateLaunch", () => { agent: loadAgent("hermes"), chatUiUrl: "http://127.0.0.1:18789/", createArgs: [], - env: { NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "30" }, + env: { + NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "30", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS: "1", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "3", + }, extraPlaceholderKeys: [], getDashboardForwardPort: () => "18789", hermesDashboardState: { diff --git a/src/lib/onboard/sandbox-create-launch.ts b/src/lib/onboard/sandbox-create-launch.ts index 1e5431bb479..a0232e221bd 100644 --- a/src/lib/onboard/sandbox-create-launch.ts +++ b/src/lib/onboard/sandbox-create-launch.ts @@ -25,6 +25,8 @@ type OpenshellArgv = (args: string[]) => string[]; const OPENCLAW_AUTO_PAIR_RUNTIME_ENV_KEYS = [ "NEMOCLAW_AUTO_PAIR_DEADLINE_SECS", "NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS", + "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS", + "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS", "NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS", "NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS", ] as const; diff --git a/test/entrypoint-env-wrapper.test.ts b/test/entrypoint-env-wrapper.test.ts new file mode 100644 index 00000000000..8bd74015681 --- /dev/null +++ b/test/entrypoint-env-wrapper.test.ts @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; +import { sliceBlock } from "./helpers/corporate-ca-support"; + +const HELPER = path.join(import.meta.dirname, "..", "scripts", "lib", "entrypoint-env-wrapper.sh"); +const OPENCLAW_START = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); + +function runNormalizer(argv: readonly string[]) { + const harness = [ + "set -euo pipefail", + 'helper="$1"', + "shift", + 'source "$helper"', + 'nemoclaw_normalize_entrypoint_env_wrapper "$@"', + 'if [ "$NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGC" -eq 0 ]; then', + " set --", + "else", + ' set -- "${NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGV[@]}"', + "fi", + `printf 'UID=%s\\n' "$(id -u)"`, + "printf 'PROFILE=%s\\n' \"${NEMOCLAW_STARTUP_PROFILE_B64-__UNSET__}\"", + "printf 'CA=%s\\n' \"${NEMOCLAW_CORPORATE_CA_B64-__UNSET__}\"", + "printf 'HTTP_PROXY=%s\\n' \"${HTTP_PROXY-__UNSET__}\"", + "printf 'NO_PROXY=%s\\n' \"${NO_PROXY-__UNSET__}\"", + "printf 'FAST_REENTRY_INTERVAL=%s\\n' \"${NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS-__UNSET__}\"", + "printf 'FAST_REENTRY_POLLS=%s\\n' \"${NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS-__UNSET__}\"", + `printf 'ARG=%s\\n' "$@"`, + ].join("\n"); + return spawnSync("/bin/bash", ["-c", harness, "entrypoint-env-wrapper-test", HELPER, ...argv], { + encoding: "utf8", + env: { + PATH: "/usr/local/bin:/usr/bin:/bin", + }, + }); +} + +describe("OCI entrypoint env-wrapper normalization", () => { + it("promotes the exact managed handoff before preserving the command tail", () => { + const uid = String(process.getuid?.() ?? ""); + const result = runNormalizer([ + "env", + "NEMOCLAW_STARTUP_PROFILE_B64=eyJzY2hlbWFWZXJzaW9uIjoxfQ", + "NEMOCLAW_CORPORATE_CA_B64=Y2E=", + "HTTP_PROXY=http://user:pass@proxy.example.test:18080", + "NO_PROXY=localhost,127.0.0.1", + "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS=0.25", + "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS=3", + "nemoclaw-start", + "/bin/sh", + "-c", + "printf managed command", + ]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(`UID=${uid}`); + expect(result.stdout).toContain("PROFILE=eyJzY2hlbWFWZXJzaW9uIjoxfQ"); + expect(result.stdout).toContain("CA=Y2E="); + expect(result.stdout).toContain("HTTP_PROXY=http://user:pass@proxy.example.test:18080"); + expect(result.stdout).toContain("NO_PROXY=localhost,127.0.0.1"); + expect(result.stdout).toContain("FAST_REENTRY_INTERVAL=0.25"); + expect(result.stdout).toContain("FAST_REENTRY_POLLS=3"); + expect(result.stdout).toContain("ARG=/bin/sh\nARG=-c\nARG=printf managed command\n"); + }); + + it("strips a direct self invocation without interpreting its command arguments", () => { + const result = runNormalizer(["/usr/local/bin/nemoclaw-start", "env", "FOO=bar", "printenv"]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("PROFILE=__UNSET__"); + expect(result.stdout).toContain("ARG=env\nARG=FOO=bar\nARG=printenv\n"); + }); + + it("leaves an unrelated explicit env command untouched", () => { + const result = runNormalizer(["env", "FOO=bar", "printenv", "FOO"]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("ARG=env\nARG=FOO=bar\nARG=printenv\nARG=FOO\n"); + }); + + it.each([ + { + argv: ["env", "NODE_OPTIONS=--require=/sandbox/untrusted.cjs", "nemoclaw-start"], + message: "unsupported variable 'NODE_OPTIONS'", + }, + { + argv: [ + "env", + "NEMOCLAW_STARTUP_PROFILE_B64=first", + "NEMOCLAW_STARTUP_PROFILE_B64=second", + "nemoclaw-start", + ], + message: "repeats variable 'NEMOCLAW_STARTUP_PROFILE_B64'", + }, + { + argv: ["env", "NEMOCLAW_STARTUP_PROFILE_B64=profile", "/usr/bin/true"], + message: "Malformed managed startup env wrapper", + }, + { + argv: ["env", "NEMOCLAW_CORPORATE_CA_B64=Y2E=", "not-an-assignment", "nemoclaw-start"], + message: "Malformed managed startup env wrapper", + }, + ])("fails closed for malformed or unsafe root handoff: $message", ({ argv, message }) => { + const result = runNormalizer(argv); + + expect(result.status).toBe(1); + expect(result.stderr).toContain(message); + expect(result.stdout).toBe(""); + }); + + it("unwraps the sandbox-create env self-wrapper and applies dashboard port defaults", () => { + const normalizer = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "lib", "entrypoint-env-wrapper.sh"), + "utf-8", + ); + const openClawPortBlock = sliceBlock( + OPENCLAW_START, + 'NEMOCLAW_CMD=("$@")', + "# ── Config integrity check", + ); + const snippet = [ + normalizer, + 'nemoclaw_normalize_entrypoint_env_wrapper "$@"', + 'if [ "$NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGC" -eq 0 ]; then set --; ' + + 'else set -- "${NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGV[@]}"; fi', + openClawPortBlock, + ].join("\n"); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-env-wrapper-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "run.sh"); + + function runScenario(setArgs: string, extraEnv: Record = {}) { + const script = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + setArgs, + snippet, + 'printf "CHAT_UI_URL=%s\\n" "$CHAT_UI_URL"', + 'printf "PUBLIC_PORT=%s\\n" "$PUBLIC_PORT"', + 'printf "OPENCLAW_GATEWAY_PORT=%s\\n" "$OPENCLAW_GATEWAY_PORT"', + 'printf "OPENCLAW_GATEWAY_URL=%s\\n" "$OPENCLAW_GATEWAY_URL"', + 'printf "SANDBOX_HOME=%s\\n" "$_SANDBOX_HOME"', + 'printf "OPENCLAW_HOME=%s\\n" "$OPENCLAW_HOME"', + 'printf "OPENCLAW_STATE_DIR=%s\\n" "$OPENCLAW_STATE_DIR"', + 'printf "OPENCLAW_CONFIG_PATH=%s\\n" "$OPENCLAW_CONFIG_PATH"', + 'printf "OPENCLAW_OAUTH_DIR=%s\\n" "$OPENCLAW_OAUTH_DIR"', + 'printf "CMD=%s\\n" "${NEMOCLAW_CMD[*]}"', + ].join("\n"); + fs.writeFileSync(scriptPath, script, { mode: 0o700 }); + return spawnSync("bash", [scriptPath], { + encoding: "utf-8", + timeout: 5000, + env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH || ""}`, ...extraEnv }, + }); + } + + try { + fs.mkdirSync(fakeBin); + fs.writeFileSync(path.join(fakeBin, "openclaw"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const injected = runScenario( + "set -- env CHAT_UI_URL=https://chat.example.test NEMOCLAW_DASHBOARD_PORT=19000 nemoclaw-start openclaw agent --agent main", + ); + expect(injected.status).toBe(0); + expect(injected.stdout).toContain("CHAT_UI_URL=http://127.0.0.1:19000"); + expect(injected.stdout).toContain("PUBLIC_PORT=19000"); + expect(injected.stdout).toContain("OPENCLAW_GATEWAY_PORT=19000"); + expect(injected.stdout).toContain("OPENCLAW_GATEWAY_URL=ws://127.0.0.1:19000"); + expect(injected.stdout).toContain("SANDBOX_HOME=/sandbox"); + expect(injected.stdout).toContain("OPENCLAW_HOME=/sandbox"); + expect(injected.stdout).toContain("OPENCLAW_STATE_DIR=/sandbox/.openclaw"); + expect(injected.stdout).toContain("OPENCLAW_CONFIG_PATH=/sandbox/.openclaw/openclaw.json"); + expect(injected.stdout).toContain("OPENCLAW_OAUTH_DIR=/sandbox/.openclaw/credentials"); + expect(injected.stdout).toContain("CMD=openclaw agent --agent main"); + + const bakedCustomPort = runScenario("set -- nemoclaw-start openclaw agent", { + CHAT_UI_URL: "http://127.0.0.1:18790", + }); + expect(bakedCustomPort.status).toBe(0); + expect(bakedCustomPort.stdout).toContain("CHAT_UI_URL=http://127.0.0.1:18790"); + expect(bakedCustomPort.stdout).toContain("PUBLIC_PORT=18790"); + expect(bakedCustomPort.stdout).toContain("OPENCLAW_GATEWAY_PORT=18790"); + expect(bakedCustomPort.stdout).toContain("OPENCLAW_GATEWAY_URL=ws://127.0.0.1:18790"); + expect(bakedCustomPort.stdout).toContain("OPENCLAW_STATE_DIR=/sandbox/.openclaw"); + expect(bakedCustomPort.stdout).toContain("OPENCLAW_OAUTH_DIR=/sandbox/.openclaw/credentials"); + expect(bakedCustomPort.stdout).toContain("CMD=openclaw agent"); + + const baked = runScenario("set -- nemoclaw-start openclaw agent", { + CHAT_UI_URL: "https://baked.example.test/ui", + }); + expect(baked.status).toBe(0); + expect(baked.stdout).toContain("CHAT_UI_URL=https://baked.example.test/ui"); + expect(baked.stdout).toContain("PUBLIC_PORT=18789"); + expect(baked.stdout).toContain("OPENCLAW_GATEWAY_PORT=18789"); + expect(baked.stdout).toContain("OPENCLAW_GATEWAY_URL=ws://127.0.0.1:18789"); + expect(baked.stdout).toContain("SANDBOX_HOME=/sandbox"); + expect(baked.stdout).toContain("OPENCLAW_STATE_DIR=/sandbox/.openclaw"); + expect(baked.stdout).toContain("CMD=openclaw agent"); + + const invalidHighPort = runScenario("set -- nemoclaw-start openclaw agent", { + NEMOCLAW_DASHBOARD_PORT: "70000", + }); + expect(invalidHighPort.status).toBe(1); + expect(invalidHighPort.stderr).toContain("Invalid NEMOCLAW_DASHBOARD_PORT='70000'"); + expect(invalidHighPort.stderr).toContain("must be an integer between 1024 and 65535"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 52e33cfec35..30e3fdb5775 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -158,6 +158,7 @@ function runApplierProcess( agent: "hermes" | "openclaw", phase: MessagingBuildPhase, dryRun = false, + managedStartupRuntime = false, ) { return spawnSync( "node", @@ -169,6 +170,7 @@ function runApplierProcess( "--phase", phase, ...(dryRun ? ["--dry-run"] : []), + ...(managedStartupRuntime ? ["--managed-startup-runtime"] : []), ], { encoding: "utf-8", @@ -1209,7 +1211,7 @@ describe("messaging-build-applier.mts: agent-install", () => { } }); - it("reapplies OpenClaw messaging render after doctor rewrites config", async () => { + it("keeps doctor rerendering while managed startup skips the broad doctor", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-doctor-rewrite-")); const tracePath = path.join(tmp, "openclaw.trace"); const fakeOpenclaw = path.join(tmp, "openclaw"); @@ -1271,6 +1273,29 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(config.plugins?.entries?.slack).toEqual({ enabled: true }); expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ enabled: true }); expect(config.channels?.wechat).toBeUndefined(); + + fs.writeFileSync( + path.join(tmp, ".openclaw", "openclaw.json"), + `${JSON.stringify({ channels: {}, plugins: { entries: {} } }, null, 2)}\n`, + ); + fs.writeFileSync(tracePath, ""); + const managedResult = runApplierProcess(env, "openclaw", "post-agent-install", false, true); + expect(managedResult.status, managedResult.stderr).toBe(0); + expect(fs.readFileSync(tracePath, "utf-8")).toBe(""); + const managedConfig = JSON.parse( + fs.readFileSync(path.join(tmp, ".openclaw", "openclaw.json"), "utf-8"), + ); + expect(managedConfig.channels?.telegram?.accounts?.default).toMatchObject({ + botToken: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + enabled: true, + }); + expect(managedConfig.channels?.discord?.enabled).toBe(true); + expect(managedConfig.plugins?.entries?.discord).toEqual({ enabled: true }); + expect(managedConfig.channels?.slack?.enabled).toBe(true); + expect(managedConfig.plugins?.entries?.slack).toEqual({ enabled: true }); + expect(managedConfig.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ + enabled: true, + }); } finally { fs.rmSync(tmp, { recursive: true, force: true }); }