diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index bd5c9d7db8a..228b4180e16 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -31,7 +31,11 @@ const extraPlaceholderKeysModule: typeof import("./onboard/extra-placeholder-key const buildContextStage: typeof import("./onboard/build-context-stage") = require("./onboard/build-context-stage"); const sandboxBuildPatchConfig: typeof import("./onboard/sandbox-build-patch-config") = require("./onboard/sandbox-build-patch-config"); const sandboxDockerfilePatchFlow: typeof import("./onboard/sandbox-dockerfile-patch-flow") = require("./onboard/sandbox-dockerfile-patch-flow"); +const sandboxMessagingPreflight: typeof import("./onboard/sandbox-messaging-preflight") = require("./onboard/sandbox-messaging-preflight"); +const sandboxCreatePlan: typeof import("./onboard/sandbox-create-plan") = require("./onboard/sandbox-create-plan"); const sandboxCreateLaunch: typeof import("./onboard/sandbox-create-launch") = require("./onboard/sandbox-create-launch"); +const onboardEntryOptions: typeof import("./onboard/entry-options") = require("./onboard/entry-options"); +const channelState: typeof import("./onboard/channel-state") = require("./onboard/channel-state"); const { ensureOllamaLoopbackSystemdOverride, }: typeof import("./onboard/ollama-systemd") = require("./onboard/ollama-systemd"); @@ -79,7 +83,6 @@ const { const { buildDirectGpuPolicyYaml, buildDirectSandboxGpuProofCommands, - prepareInitialSandboxCreatePolicy, }: typeof import("./onboard/initial-policy") = require("./onboard/initial-policy"); const { getSelectionDrift, @@ -406,7 +409,6 @@ const { const messagingPlanSession: typeof import("./onboard/messaging-plan-session") = require("./onboard/messaging-plan-session"); const { getChannelsFromPlan } = messagingPlanSession; -const messagingPrep: typeof import("./onboard/messaging-prep") = require("./onboard/messaging-prep"); const sandboxAgent: typeof import("./onboard/sandbox-agent") = require("./onboard/sandbox-agent"); const sandboxLifecycle: typeof import("./onboard/sandbox-lifecycle") = require("./onboard/sandbox-lifecycle"); const sandboxRegistryMetadata: typeof import("./onboard/sandbox-registry-metadata") = require("./onboard/sandbox-registry-metadata"); @@ -556,7 +558,7 @@ import { stringSetsEqual, } from "./onboard/hermes-managed-tools"; import { mergePolicyMessagingChannels } from "./onboard/messaging-policy-presets"; -import { filterEnabledChannelsByAgent, resolveQrSelectedChannels } from "./onboard/messaging-state"; +import { filterEnabledChannelsByAgent } from "./onboard/messaging-state"; import { getValidatedMessagingTokenByEnvKey } from "./onboard/messaging-token"; import { handleOllamaProbeFailure } from "./onboard/ollama-probe-failure"; import { runOllamaStartupOrGate } from "./onboard/ollama-startup"; @@ -2571,77 +2573,42 @@ async function createSandbox( }); const hermesDashboardState = hermesDashboardForwarding.resolveStateForPort(effectivePort); - // Check whether messaging providers will be needed — this must happen before - // the sandbox reuse decision so we can detect stale sandboxes that were created - // without provider attachments (security: prevents legacy raw-env-var leaks). - - // Messaging channels like Telegram (getUpdates), Discord (gateway), and Slack - // (Socket Mode) enforce one consumer per channel credential. Two sandboxes - // sharing a credential silently break both bridges (see #1953). Warn before - // we commit. - // - // The compiled plan (written to env by setupMessagingChannels) is the source - // of truth: credential hashes and active-channel membership are read from - // plan.credentialBindings rather than from MESSAGING_CHANNELS constants. - // Validate sandbox identity before trusting the env plan: a stale plan from a - // prior run of a different sandbox must not gate or bypass conflict detection - // for the current sandbox creation. - const envPlan = readMessagingPlanFromEnv(); - const currentPlan = envPlan?.sandboxName === sandboxName ? envPlan : null; - // Drop channels the operator disabled via `nemoclaw channels stop`. - // Credentials stay in the keychain; the bridge simply isn't registered with - // the gateway on the next rebuild. `channels start` removes the entry and - // the bridge comes back. Resolved before conflict detection so a *stopped* - // channel on this sandbox is not treated as an active consumer (a stopped - // Slack bridge must not block a second sandbox on the same gateway). - const disabledChannels: string[] = - require("./onboard/channel-state").resolveDisabledChannels(sandboxName); - const disabledChannelNames = new Set(disabledChannels); - const { enforceMessagingChannelConflicts } = - require("./onboard/messaging-conflict-guard") as typeof import("./onboard/messaging-conflict-guard"); - await enforceMessagingChannelConflicts({ - sandboxName, - gatewayName: GATEWAY_NAME, - currentPlan, - currentSandboxDisabledChannels: disabledChannels, - registry, - isNonInteractive, - promptContinue: () => promptYesNoOrDefault(" Continue anyway?", null, false), - cliName, - log: (message) => console.log(message), - error: (message) => console.error(message), - }); - const { messagingTokenDefs, extraPlaceholderKeys, hasMessagingTokens, reusableMessagingProviders, reusableMessagingChannels, - missingBraveApiKey, - } = messagingPrep.prepareCreateSandboxMessaging({ - sandboxName, - channels: MESSAGING_CHANNELS, - enabledChannels, + disabledChannelNames, disabledChannels, - webSearchConfig, - env: process.env, - getValidatedMessagingTokenByEnvKey, - getCredential, - normalizeCredentialValue, - registerExtraPlaceholderProviders: extraPlaceholderKeysModule.registerExtraPlaceholderProviders, - getMessagingChannelForEnvKey, - providerExistsInGateway, - }); - // Fail before any recreate/delete path runs: otherwise a missing key would - // destroy the existing sandbox first and only then surface the abort (#3626). - if (missingBraveApiKey) { - console.error(" Brave Search is enabled, but BRAVE_API_KEY is not available in this process."); - console.error( - " Re-run with BRAVE_API_KEY set, or disable Brave Search before recreating the sandbox.", - ); - process.exit(1); - } + } = await sandboxMessagingPreflight.prepareSandboxMessagingPreflight( + { + channels: MESSAGING_CHANNELS, + enabledChannels, + sandboxName, + webSearchConfig, + env: process.env, + }, + { + readMessagingPlanFromEnv, + resolveDisabledChannels: channelState.resolveDisabledChannels, + gatewayName: GATEWAY_NAME, + registry, + providerExistsInGateway, + isNonInteractive, + promptYesNoOrDefault, + cliName, + log: (message) => console.log(message), + error: (message) => console.error(message), + exitProcess: (code) => process.exit(code), + getValidatedMessagingTokenByEnvKey, + getCredential, + normalizeCredentialValue, + registerExtraPlaceholderProviders: + extraPlaceholderKeysModule.registerExtraPlaceholderProviders, + getMessagingChannelForEnvKey, + }, + ); const existingRegistryEntryBeforePrune = registry.getSandbox(sandboxName); @@ -2959,44 +2926,44 @@ async function createSandbox( "openclaw-sandbox.yaml", ); const basePolicyPath = (agent && agentOnboard.getAgentPolicyPath(agent)) || defaultPolicyPath; - const tokensByEnvKey = Object.fromEntries( - messagingTokenDefs.map(({ envKey, token }) => [envKey, token]), - ); - const qrSelectedChannels = resolveQrSelectedChannels( - MESSAGING_CHANNELS, + const { + activeMessagingChannels, + initialSandboxPolicy, + createArgs, + messagingProviders, + useDockerGpuPatch, + sandboxGpuLogMessage, + } = sandboxCreatePlan.prepareSandboxCreatePlan({ + basePolicyPath, + buildCtx, + sandboxName, + channels: MESSAGING_CHANNELS, enabledChannels, disabledChannelNames, - ); - const activeMessagingChannels = [ - ...new Set([ - ...messagingTokenDefs - .filter(({ token }) => !!token) - .flatMap(({ envKey }) => { - const channel = getMessagingChannelForEnvKey(envKey); - if (channel) return [channel]; - // SLACK_APP_TOKEN alone does not enable slack; bot token is required. - if (envKey === "SLACK_APP_TOKEN") { - return tokensByEnvKey["SLACK_BOT_TOKEN"] ? ["slack"] : []; - } - return []; - }), - ...reusableMessagingChannels, - ...qrSelectedChannels, - ]), - ]; - const { useDockerGpuPatch, logMessage: sandboxGpuLogMessage } = - dockerGpuSandboxCreate.resolveDockerGpuSandboxCreatePlan(effectiveSandboxGpuConfig, { - dockerDriverGateway: isLinuxDockerDriverGatewayEnabled(), - }); - const initialSandboxPolicy = prepareInitialSandboxCreatePolicy( - basePolicyPath, - activeMessagingChannels, - { - directGpu: effectiveSandboxGpuConfig.sandboxGpuEnabled, - dockerGpuPatch: useDockerGpuPatch, - additionalPresets: hermesToolGateways, - }, - ); + messagingTokenDefs, + reusableMessagingChannels, + reusableMessagingProviders, + hermesToolGateways, + sandboxGpuConfig: effectiveSandboxGpuConfig, + dockerDriverGateway: isLinuxDockerDriverGatewayEnabled(), + appendResourceFlags: (args) => + appendResourceFlagsForProfile(args, resourceProfile, getOpenshellBinary(), { + isNonInteractive, + note, + prompt, + promptOrDefault, + }), + runProviderPreDeleteCleanup: () => + runSandboxProviderPreDeleteCleanup(sandboxName, { + runOpenshell, + redact, + tolerateMissingSandbox: true, + }), + upsertMessagingProviders, + getMessagingChannelForEnvKey, + getHermesToolGatewayProviderName: (targetSandbox) => + getHermesToolGatewayBroker().getHermesToolGatewayProviderName(targetSandbox), + }); if (initialSandboxPolicy.cleanup) { process.on("exit", initialSandboxPolicy.cleanup); } @@ -3006,42 +2973,6 @@ async function createSandbox( ); } if (sandboxGpuLogMessage) console.log(sandboxGpuLogMessage); - const createArgs = [ - "--from", - `${buildCtx}/Dockerfile`, - "--name", - sandboxName, - "--policy", - initialSandboxPolicy.policyPath, - ...buildSandboxGpuCreateArgs(effectiveSandboxGpuConfig, { - suppressGpuFlag: useDockerGpuPatch, - }), - ]; - - appendResourceFlagsForProfile(createArgs, resourceProfile, getOpenshellBinary(), { - isNonInteractive, - note, - prompt, - promptOrDefault, - }); - runSandboxProviderPreDeleteCleanup(sandboxName, { - runOpenshell, - redact, - tolerateMissingSandbox: true, - }); - const messagingProviders = [ - ...new Set([ - ...upsertMessagingProviders(messagingTokenDefs, { replaceExisting: true }), - ...reusableMessagingProviders, - ]), - ]; - for (const p of messagingProviders) { - createArgs.push("--provider", p); - } - if (hermesToolGateways.length > 0) { - const hermesToolGateway = getHermesToolGatewayBroker(); - createArgs.push("--provider", hermesToolGateway.getHermesToolGatewayProviderName(sandboxName)); - } console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); const envMessagingState = MessagingHostStateApplier.readPlanStateFromEnv(); @@ -3049,8 +2980,7 @@ async function createSandbox( envMessagingState?.plan.sandboxName === sandboxName ? envMessagingState : undefined; const plannedMessagingPlan = plannedMessagingState?.plan; const configuredMessagingChannels = - getChannelsFromPlan(plannedMessagingPlan) ?? - (enabledChannels != null ? [...new Set(enabledChannels)] : activeMessagingChannels); + getChannelsFromPlan(plannedMessagingPlan) ?? activeMessagingChannels; sandboxBuildPatchConfig.prepareSandboxBuildPatchConfig({ configuredMessagingChannels, }); @@ -4846,73 +4776,24 @@ async function onboard(opts: OnboardOptions = {}): Promise { opts.controlUiPort ?? (process.env.NEMOCLAW_DASHBOARD_PORT != null ? DASHBOARD_PORT : null); onboardRuntimeBoundary.reset(); delete process.env.OPENSHELL_GATEWAY; - const resume = opts.resume === true; - const fresh = opts.fresh === true; - if (resume && fresh) { - console.error(" --resume and --fresh cannot both be set."); - process.exit(1); - } - // In non-interactive mode also accept the env var so CI pipelines can set it. - // This is the explicitly requested value; on resume it may be absent and the - // session-recorded path is used instead (see below). - const requestedFromDockerfile = - opts.fromDockerfile || - (isNonInteractive() ? process.env.NEMOCLAW_FROM_DOCKERFILE || null : null); - // Resolve the explicit sandbox name early so both validation and the - // --from guard work off the same source. --name always counts; the env - // var is used as the interactive prompt default via getSandboxPromptDefault, - // and also as the resolved name when we cannot prompt (non-interactive or - // missing-TTY runs such as CI scripts and piped stdin). - const stdinIsTty = Boolean(process.stdin && process.stdin.isTTY); - const stdoutIsTty = Boolean(process.stdout && process.stdout.isTTY); - const cannotPrompt = isNonInteractive() || !stdinIsTty || !stdoutIsTty; - let requestedSandboxName: string | null = - typeof opts.sandboxName === "string" && opts.sandboxName.length > 0 ? opts.sandboxName : null; - let requestedSandboxSource: "--name" | "NEMOCLAW_SANDBOX_NAME" | null = requestedSandboxName - ? "--name" - : null; - if (!requestedSandboxName && cannotPrompt) { - const envName = process.env.NEMOCLAW_SANDBOX_NAME; - if (typeof envName === "string" && envName.trim().length > 0) { - requestedSandboxName = envName.trim(); - requestedSandboxSource = "NEMOCLAW_SANDBOX_NAME"; - } - } - if (requestedSandboxName) { - try { - const validated = validateName(requestedSandboxName, "sandbox name"); - if (RESERVED_SANDBOX_NAMES.has(validated)) { - console.error(` Reserved name: '${validated}' is a ${cliDisplayName()} CLI command.`); - console.error( - ` Choose a different sandbox name (passed via ${requestedSandboxSource}) to avoid routing conflicts.`, - ); - process.exit(1); - } - requestedSandboxName = validated; - } catch (error) { - console.error(` ${error instanceof Error ? error.message : String(error)}`); - for (const line of getNameValidationGuidance("sandbox name", requestedSandboxName, { - includeAllowedFormat: false, - })) { - console.error(` ${line}`); - } - process.exit(1); - } - } - // The downstream prompt path silently defaults to 'my-assistant' when no - // input arrives. With --from in play that would clobber the default - // sandbox, so refuse to proceed unless the caller has supplied a name - // out-of-band. Cover both --non-interactive and missing-TTY runs (CI - // scripts, piped stdin) — the issue's test plan asks for both. The resume - // case is handled separately after session load (see below) because its - // recorded sandboxName may already satisfy the requirement. - if (cannotPrompt && !resume && requestedFromDockerfile && !requestedSandboxName) { - console.error( - " --from requires --name (or NEMOCLAW_SANDBOX_NAME) when running without a TTY or with --non-interactive.", + const { resume, fresh, requestedFromDockerfile, requestedSandboxName, cannotPrompt } = + onboardEntryOptions.resolveOnboardEntryOptions( + { + opts, + env: process.env, + stdinIsTty: Boolean(process.stdin && process.stdin.isTTY), + stdoutIsTty: Boolean(process.stdout && process.stdout.isTTY), + }, + { + isNonInteractive, + validateName, + reservedSandboxNames: RESERVED_SANDBOX_NAMES, + cliDisplayName, + getNameValidationGuidance, + error: (message) => console.error(message), + exitProcess: (code) => process.exit(code), + }, ); - console.error(" A sandbox name cannot be prompted for in this context."); - process.exit(1); - } // Fail fast for NEMOCLAW_POLICY_TIER only where selectPolicyTier reads it. if (isNonInteractive()) policyTierEnv.validatePolicyTierEnvEarly(); const noticeAccepted = await ensureUsageNoticeConsent({ @@ -5082,9 +4963,9 @@ async function onboard(opts: OnboardOptions = {}): Promise { // createSandbox succeeds). Falling through would silently default to // the agent's `my-assistant` instead of the user's original --name. // Use `cannotPrompt` so non-TTY runs without explicit --non-interactive - // are also caught, and `requestedSandboxName` (already env-var-resolved - // and trimmed above, lines 8302-8308) so whitespace-only env values - // can't satisfy the guard. + // are also caught, and `requestedSandboxName` from + // resolveOnboardEntryOptions so whitespace-only env values can't satisfy + // the guard. const sandboxStepCompleted = session?.steps?.sandbox?.status === "complete"; const recoveredSandboxName = requestedSandboxName || (sandboxStepCompleted ? session?.sandboxName || null : null); diff --git a/src/lib/onboard/entry-options.test.ts b/src/lib/onboard/entry-options.test.ts new file mode 100644 index 00000000000..5985538e957 --- /dev/null +++ b/src/lib/onboard/entry-options.test.ts @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + resolveOnboardEntryOptions, + type OnboardEntryOptionsDeps, +} from "../../../dist/lib/onboard/entry-options"; + +class ExitError extends Error { + constructor(readonly code: number) { + super(`exit ${code}`); + } +} + +function createDeps(overrides: Partial = {}): OnboardEntryOptionsDeps { + return { + isNonInteractive: vi.fn(() => false), + validateName: vi.fn((name: string) => name.trim().toLowerCase()), + reservedSandboxNames: new Set(["status"]), + cliDisplayName: vi.fn(() => "NemoClaw"), + getNameValidationGuidance: vi.fn(() => ["Use lowercase letters, numbers, and hyphens."]), + error: vi.fn(), + exitProcess: vi.fn((code: number) => { + throw new ExitError(code); + }) as (code: number) => never, + ...overrides, + }; +} + +describe("resolveOnboardEntryOptions", () => { + it("rejects mutually exclusive resume and fresh flags", () => { + const deps = createDeps(); + + expect(() => + resolveOnboardEntryOptions( + { + opts: { resume: true, fresh: true }, + env: {}, + stdinIsTty: true, + stdoutIsTty: true, + }, + deps, + ), + ).toThrow(ExitError); + expect(deps.error).toHaveBeenCalledWith(" --resume and --fresh cannot both be set."); + }); + + it("uses non-interactive env defaults for Dockerfile and sandbox name", () => { + const deps = createDeps({ + isNonInteractive: vi.fn(() => true), + }); + + const result = resolveOnboardEntryOptions( + { + opts: {}, + env: { + NEMOCLAW_FROM_DOCKERFILE: "Dockerfile.custom", + NEMOCLAW_SANDBOX_NAME: " Demo-Box ", + }, + stdinIsTty: false, + stdoutIsTty: false, + }, + deps, + ); + + expect(result).toMatchObject({ + resume: false, + fresh: false, + requestedFromDockerfile: "Dockerfile.custom", + requestedSandboxName: "demo-box", + cannotPrompt: true, + }); + expect(deps.validateName).toHaveBeenCalledWith("Demo-Box", "sandbox name"); + }); + + it("requires a sandbox name for --from when prompts are unavailable", () => { + const deps = createDeps(); + + expect(() => + resolveOnboardEntryOptions( + { + opts: { fromDockerfile: "Dockerfile.custom" }, + env: {}, + stdinIsTty: false, + stdoutIsTty: true, + }, + deps, + ), + ).toThrow(ExitError); + expect(deps.error).toHaveBeenCalledWith( + " --from requires --name (or NEMOCLAW_SANDBOX_NAME) when running without a TTY or with --non-interactive.", + ); + expect(deps.error).toHaveBeenCalledWith( + " A sandbox name cannot be prompted for in this context.", + ); + }); + + it("allows resume with --from and no recovered sandbox name so later resume guards can decide", () => { + const deps = createDeps(); + + const result = resolveOnboardEntryOptions( + { + opts: { resume: true, fromDockerfile: "Dockerfile.custom" }, + env: {}, + stdinIsTty: false, + stdoutIsTty: true, + }, + deps, + ); + + expect(result.resume).toBe(true); + expect(result.requestedFromDockerfile).toBe("Dockerfile.custom"); + expect(result.requestedSandboxName).toBeNull(); + }); + + it("rejects reserved sandbox command names with the original request source", () => { + const deps = createDeps(); + + expect(() => + resolveOnboardEntryOptions( + { + opts: { sandboxName: "Status" }, + env: {}, + stdinIsTty: true, + stdoutIsTty: true, + }, + deps, + ), + ).toThrow(ExitError); + expect(deps.error).toHaveBeenCalledWith(" Reserved name: 'status' is a NemoClaw CLI command."); + expect(deps.error).toHaveBeenCalledWith( + " Choose a different sandbox name (passed via --name) to avoid routing conflicts.", + ); + expect(deps.error).not.toHaveBeenCalledWith(" Use lowercase letters, numbers, and hyphens."); + expect(deps.getNameValidationGuidance).not.toHaveBeenCalled(); + expect(deps.exitProcess).toHaveBeenCalledTimes(1); + }); + + it("prints validation guidance for invalid sandbox names", () => { + const deps = createDeps({ + validateName: vi.fn(() => { + throw new Error("Invalid sandbox name"); + }), + }); + + expect(() => + resolveOnboardEntryOptions( + { + opts: { sandboxName: "bad name" }, + env: {}, + stdinIsTty: true, + stdoutIsTty: true, + }, + deps, + ), + ).toThrow(ExitError); + expect(deps.error).toHaveBeenCalledWith(" Invalid sandbox name"); + expect(deps.error).toHaveBeenCalledWith(" Use lowercase letters, numbers, and hyphens."); + }); +}); diff --git a/src/lib/onboard/entry-options.ts b/src/lib/onboard/entry-options.ts new file mode 100644 index 00000000000..7f808fb8aeb --- /dev/null +++ b/src/lib/onboard/entry-options.ts @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface OnboardEntryOptionsInput { + opts: { + resume?: boolean; + fresh?: boolean; + fromDockerfile?: string | null; + sandboxName?: string | null; + }; + env: NodeJS.ProcessEnv | Record; + stdinIsTty: boolean; + stdoutIsTty: boolean; +} + +export interface OnboardEntryOptionsDeps { + isNonInteractive(): boolean; + validateName(name: string, kind: string): string; + reservedSandboxNames: ReadonlySet; + cliDisplayName(): string; + getNameValidationGuidance( + kind: string, + value: string | null | undefined, + options?: { includeAllowedFormat?: boolean }, + ): string[]; + error(message: string): void; + exitProcess(code: number): never; +} + +export interface ResolvedOnboardEntryOptions { + resume: boolean; + fresh: boolean; + requestedFromDockerfile: string | null; + requestedSandboxName: string | null; + cannotPrompt: boolean; +} + +export function resolveOnboardEntryOptions( + input: OnboardEntryOptionsInput, + deps: OnboardEntryOptionsDeps, +): ResolvedOnboardEntryOptions { + const resume = input.opts.resume === true; + const fresh = input.opts.fresh === true; + if (resume && fresh) { + deps.error(" --resume and --fresh cannot both be set."); + deps.exitProcess(1); + } + + const requestedFromDockerfile = + input.opts.fromDockerfile || + (deps.isNonInteractive() ? input.env.NEMOCLAW_FROM_DOCKERFILE || null : null); + const cannotPrompt = deps.isNonInteractive() || !input.stdinIsTty || !input.stdoutIsTty; + let requestedSandboxName: string | null = + typeof input.opts.sandboxName === "string" && input.opts.sandboxName.length > 0 + ? input.opts.sandboxName + : null; + let requestedSandboxSource: "--name" | "NEMOCLAW_SANDBOX_NAME" | null = requestedSandboxName + ? "--name" + : null; + if (!requestedSandboxName && cannotPrompt) { + const envName = input.env.NEMOCLAW_SANDBOX_NAME; + if (typeof envName === "string" && envName.trim().length > 0) { + requestedSandboxName = envName.trim(); + requestedSandboxSource = "NEMOCLAW_SANDBOX_NAME"; + } + } + if (requestedSandboxName) { + let validated: string; + try { + validated = deps.validateName(requestedSandboxName, "sandbox name"); + } catch (error) { + deps.error(` ${error instanceof Error ? error.message : String(error)}`); + for (const line of deps.getNameValidationGuidance("sandbox name", requestedSandboxName, { + includeAllowedFormat: false, + })) { + deps.error(` ${line}`); + } + deps.exitProcess(1); + } + if (deps.reservedSandboxNames.has(validated)) { + deps.error(` Reserved name: '${validated}' is a ${deps.cliDisplayName()} CLI command.`); + deps.error( + ` Choose a different sandbox name (passed via ${requestedSandboxSource}) to avoid routing conflicts.`, + ); + deps.exitProcess(1); + } + requestedSandboxName = validated; + } + if (cannotPrompt && !resume && requestedFromDockerfile && !requestedSandboxName) { + deps.error( + " --from requires --name (or NEMOCLAW_SANDBOX_NAME) when running without a TTY or with --non-interactive.", + ); + deps.error(" A sandbox name cannot be prompted for in this context."); + deps.exitProcess(1); + } + + return { + resume, + fresh, + requestedFromDockerfile, + requestedSandboxName, + cannotPrompt, + }; +} diff --git a/src/lib/onboard/sandbox-create-plan.test.ts b/src/lib/onboard/sandbox-create-plan.test.ts new file mode 100644 index 00000000000..0875815dff1 --- /dev/null +++ b/src/lib/onboard/sandbox-create-plan.test.ts @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { prepareSandboxCreatePlan } from "./sandbox-create-plan"; +import type { SandboxGpuCreateConfig } from "./sandbox-gpu-create"; + +const sandboxGpuConfig: SandboxGpuCreateConfig = { + sandboxGpuEnabled: true, + sandboxGpuDevice: "nvidia.com/gpu=0", +}; + +const channels = [ + { + name: "telegram", + envKey: "TELEGRAM_BOT_TOKEN", + label: "Telegram", + description: "Telegram", + help: "Telegram", + }, + { + name: "slack", + envKey: "SLACK_BOT_TOKEN", + appTokenEnvKey: "SLACK_APP_TOKEN", + label: "Slack", + description: "Slack", + help: "Slack", + }, + { + name: "whatsapp", + loginMethod: "in-sandbox-qr" as const, + label: "WhatsApp", + description: "WhatsApp", + help: "WhatsApp", + }, +]; + +describe("prepareSandboxCreatePlan", () => { + it("builds create args, policy, providers, and active channels in onboard order", () => { + const events: string[] = []; + const appendResourceFlags = vi.fn((args: string[]) => { + events.push("resources"); + args.push("--memory", "16g"); + }); + const runProviderPreDeleteCleanup = vi.fn(() => events.push("cleanup")); + const upsertMessagingProviders = vi.fn(() => { + events.push("upsert"); + return ["sandbox-telegram-bridge", "sandbox-slack-bridge"]; + }); + const prepareInitialSandboxCreatePolicy = vi.fn(() => ({ + policyPath: "/tmp/policy.yaml", + appliedPresets: ["telegram"], + cleanup: vi.fn(), + })); + + const result = prepareSandboxCreatePlan({ + basePolicyPath: "/repo/policy.yaml", + buildCtx: "/tmp/nemoclaw-build-1", + sandboxName: "sandbox", + channels, + enabledChannels: ["telegram", "whatsapp"], + disabledChannelNames: new Set(), + messagingTokenDefs: [ + { envKey: "TELEGRAM_BOT_TOKEN", token: "telegram-token" }, + { envKey: "SLACK_APP_TOKEN", token: "slack-app-token" }, + { envKey: "SLACK_BOT_TOKEN", token: "slack-bot-token" }, + ], + reusableMessagingChannels: ["discord"], + reusableMessagingProviders: ["sandbox-existing-discord"], + hermesToolGateways: ["github"], + sandboxGpuConfig, + dockerDriverGateway: true, + appendResourceFlags, + runProviderPreDeleteCleanup, + upsertMessagingProviders, + getMessagingChannelForEnvKey: (envKey) => + envKey === "TELEGRAM_BOT_TOKEN" + ? "telegram" + : envKey === "SLACK_BOT_TOKEN" + ? "slack" + : null, + getHermesToolGatewayProviderName: (sandboxName) => `${sandboxName}-hermes-tools`, + deps: { + resolveDockerGpuSandboxCreatePlan: vi.fn(() => ({ + useDockerGpuPatch: false, + logMessage: "gpu note", + })), + prepareInitialSandboxCreatePolicy, + buildSandboxGpuCreateArgs: vi.fn(() => ["--gpu", "--gpu-device", "nvidia.com/gpu=0"]), + }, + }); + + expect(result.activeMessagingChannels).toEqual(["telegram", "slack", "discord", "whatsapp"]); + expect(prepareInitialSandboxCreatePolicy).toHaveBeenCalledWith( + "/repo/policy.yaml", + ["telegram", "slack", "discord", "whatsapp"], + { + directGpu: true, + dockerGpuPatch: false, + additionalPresets: ["github"], + }, + ); + expect(result.createArgs).toEqual([ + "--from", + "/tmp/nemoclaw-build-1/Dockerfile", + "--name", + "sandbox", + "--policy", + "/tmp/policy.yaml", + "--gpu", + "--gpu-device", + "nvidia.com/gpu=0", + "--memory", + "16g", + "--provider", + "sandbox-telegram-bridge", + "--provider", + "sandbox-slack-bridge", + "--provider", + "sandbox-existing-discord", + "--provider", + "sandbox-hermes-tools", + ]); + expect(result.messagingProviders).toEqual([ + "sandbox-telegram-bridge", + "sandbox-slack-bridge", + "sandbox-existing-discord", + ]); + expect(result.sandboxGpuLogMessage).toBe("gpu note"); + expect(events).toEqual(["resources", "cleanup", "upsert"]); + }); + + it("does not activate slack from an app token alone and suppresses --gpu for Docker GPU patching", () => { + const result = prepareSandboxCreatePlan({ + basePolicyPath: "/repo/policy.yaml", + buildCtx: "/tmp/nemoclaw-build-1", + sandboxName: "sandbox", + channels, + enabledChannels: ["slack", "whatsapp"], + disabledChannelNames: new Set(["whatsapp"]), + messagingTokenDefs: [{ envKey: "SLACK_APP_TOKEN", token: "slack-app-token" }], + reusableMessagingChannels: [], + reusableMessagingProviders: [], + hermesToolGateways: [], + sandboxGpuConfig, + dockerDriverGateway: true, + appendResourceFlags: vi.fn(), + runProviderPreDeleteCleanup: vi.fn(), + upsertMessagingProviders: vi.fn(() => []), + getMessagingChannelForEnvKey: () => null, + getHermesToolGatewayProviderName: vi.fn(), + deps: { + resolveDockerGpuSandboxCreatePlan: vi.fn(() => ({ + useDockerGpuPatch: true, + logMessage: null, + })), + prepareInitialSandboxCreatePolicy: vi.fn(() => ({ + policyPath: "/tmp/policy.yaml", + appliedPresets: [], + })), + buildSandboxGpuCreateArgs: vi.fn(() => []), + }, + }); + + expect(result.activeMessagingChannels).toEqual([]); + expect(result.useDockerGpuPatch).toBe(true); + expect(result.createArgs).toEqual([ + "--from", + "/tmp/nemoclaw-build-1/Dockerfile", + "--name", + "sandbox", + "--policy", + "/tmp/policy.yaml", + ]); + }); +}); diff --git a/src/lib/onboard/sandbox-create-plan.ts b/src/lib/onboard/sandbox-create-plan.ts new file mode 100644 index 00000000000..b55be675594 --- /dev/null +++ b/src/lib/onboard/sandbox-create-plan.ts @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { InitialSandboxPolicy } from "./initial-policy"; +import type { MessagingChannel } from "./messaging-state"; +import { resolveQrSelectedChannels } from "./messaging-state"; +import { buildSandboxGpuCreateArgs, type SandboxGpuCreateConfig } from "./sandbox-gpu-create"; + +type MessagingTokenDef = { + envKey: string; + token: string | null; +}; + +type ResolveDockerGpuSandboxCreatePlan = + typeof import("./docker-gpu-sandbox-create").resolveDockerGpuSandboxCreatePlan; +type PrepareInitialSandboxCreatePolicy = + typeof import("./initial-policy").prepareInitialSandboxCreatePolicy; + +export type SandboxCreatePlanDeps = { + resolveDockerGpuSandboxCreatePlan?: ResolveDockerGpuSandboxCreatePlan; + prepareInitialSandboxCreatePolicy?: PrepareInitialSandboxCreatePolicy; + buildSandboxGpuCreateArgs?: typeof buildSandboxGpuCreateArgs; +}; + +export type PrepareSandboxCreatePlanInput = { + basePolicyPath: string; + buildCtx: string; + sandboxName: string; + channels: MessagingChannel[]; + enabledChannels: string[] | null; + disabledChannelNames: ReadonlySet; + messagingTokenDefs: MessagingTokenDef[]; + reusableMessagingChannels: string[]; + reusableMessagingProviders: string[]; + hermesToolGateways: string[]; + sandboxGpuConfig: SandboxGpuCreateConfig; + dockerDriverGateway: boolean; + appendResourceFlags(createArgs: string[]): void; + runProviderPreDeleteCleanup(): void; + upsertMessagingProviders( + tokenDefs: MessagingTokenDef[], + options: { replaceExisting: true }, + ): string[]; + getMessagingChannelForEnvKey(envKey: string): string | null; + getHermesToolGatewayProviderName(sandboxName: string): string; + deps?: SandboxCreatePlanDeps; +}; + +export type SandboxCreatePlan = { + activeMessagingChannels: string[]; + initialSandboxPolicy: InitialSandboxPolicy; + createArgs: string[]; + messagingProviders: string[]; + useDockerGpuPatch: boolean; + sandboxGpuLogMessage: string | null; +}; + +function getDockerGpuSandboxCreatePlan( + ...args: Parameters +): ReturnType { + const { resolveDockerGpuSandboxCreatePlan } = + require("./docker-gpu-sandbox-create") as typeof import("./docker-gpu-sandbox-create"); + return resolveDockerGpuSandboxCreatePlan(...args); +} + +function getInitialSandboxCreatePolicy( + ...args: Parameters +): ReturnType { + const { prepareInitialSandboxCreatePolicy } = + require("./initial-policy") as typeof import("./initial-policy"); + return prepareInitialSandboxCreatePolicy(...args); +} + +function resolveActiveMessagingChannels({ + channels, + disabledChannelNames, + enabledChannels, + getMessagingChannelForEnvKey, + messagingTokenDefs, + reusableMessagingChannels, +}: Pick< + PrepareSandboxCreatePlanInput, + | "channels" + | "disabledChannelNames" + | "enabledChannels" + | "getMessagingChannelForEnvKey" + | "messagingTokenDefs" + | "reusableMessagingChannels" +>): string[] { + const tokensByEnvKey = Object.fromEntries( + messagingTokenDefs.map(({ envKey, token }) => [envKey, token]), + ); + const qrSelectedChannels = resolveQrSelectedChannels( + channels, + enabledChannels, + disabledChannelNames, + ); + return [ + ...new Set([ + ...messagingTokenDefs + .filter(({ token }) => !!token) + .flatMap(({ envKey }) => { + const channel = getMessagingChannelForEnvKey(envKey); + if (channel) return [channel]; + // SLACK_APP_TOKEN alone does not enable slack; bot token is required. + if (envKey === "SLACK_APP_TOKEN") { + return tokensByEnvKey["SLACK_BOT_TOKEN"] ? ["slack"] : []; + } + return []; + }), + ...reusableMessagingChannels, + ...qrSelectedChannels, + ]), + ]; +} + +export function prepareSandboxCreatePlan({ + basePolicyPath, + buildCtx, + sandboxName, + channels, + enabledChannels, + disabledChannelNames, + messagingTokenDefs, + reusableMessagingChannels, + reusableMessagingProviders, + hermesToolGateways, + sandboxGpuConfig, + dockerDriverGateway, + appendResourceFlags, + runProviderPreDeleteCleanup, + upsertMessagingProviders, + getMessagingChannelForEnvKey, + getHermesToolGatewayProviderName, + deps = {}, +}: PrepareSandboxCreatePlanInput): SandboxCreatePlan { + const activeMessagingChannels = resolveActiveMessagingChannels({ + channels, + disabledChannelNames, + enabledChannels, + getMessagingChannelForEnvKey, + messagingTokenDefs, + reusableMessagingChannels, + }); + const { useDockerGpuPatch, logMessage: sandboxGpuLogMessage } = ( + deps.resolveDockerGpuSandboxCreatePlan ?? getDockerGpuSandboxCreatePlan + )(sandboxGpuConfig, { dockerDriverGateway }); + const initialSandboxPolicy = ( + deps.prepareInitialSandboxCreatePolicy ?? getInitialSandboxCreatePolicy + )(basePolicyPath, activeMessagingChannels, { + directGpu: sandboxGpuConfig.sandboxGpuEnabled, + dockerGpuPatch: useDockerGpuPatch, + additionalPresets: hermesToolGateways, + }); + const createArgs = [ + "--from", + `${buildCtx}/Dockerfile`, + "--name", + sandboxName, + "--policy", + initialSandboxPolicy.policyPath, + ...(deps.buildSandboxGpuCreateArgs ?? buildSandboxGpuCreateArgs)(sandboxGpuConfig, { + suppressGpuFlag: useDockerGpuPatch, + }), + ]; + + appendResourceFlags(createArgs); + runProviderPreDeleteCleanup(); + const messagingProviders = [ + ...new Set([ + ...upsertMessagingProviders(messagingTokenDefs, { replaceExisting: true }), + ...reusableMessagingProviders, + ]), + ]; + for (const provider of messagingProviders) { + createArgs.push("--provider", provider); + } + if (hermesToolGateways.length > 0) { + createArgs.push("--provider", getHermesToolGatewayProviderName(sandboxName)); + } + + return { + activeMessagingChannels, + initialSandboxPolicy, + createArgs, + messagingProviders, + useDockerGpuPatch, + sandboxGpuLogMessage, + }; +} diff --git a/src/lib/onboard/sandbox-messaging-preflight.test.ts b/src/lib/onboard/sandbox-messaging-preflight.test.ts new file mode 100644 index 00000000000..a972977ab6b --- /dev/null +++ b/src/lib/onboard/sandbox-messaging-preflight.test.ts @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + prepareSandboxMessagingPreflight, + type SandboxMessagingPreflightDeps, +} from "../../../dist/lib/onboard/sandbox-messaging-preflight"; +import { listChannels } from "../../../dist/lib/sandbox/channels"; + +class ExitError extends Error { + constructor(readonly code: number) { + super(`exit ${code}`); + } +} + +function createResult(overrides = {}) { + return { + disabledChannelNames: new Set(), + messagingTokenDefs: [], + extraPlaceholderKeys: [], + hasMessagingTokens: false, + reusableMessagingProviders: [], + reusableMessagingChannels: [], + missingBraveApiKey: false, + ...overrides, + }; +} + +function planChannel(channelId: string) { + return { + channelId, + displayName: channelId, + authMode: "token-paste" as const, + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }; +} + +function credentialBinding(channelId: string, envKey: string, sandboxName: string, hash?: string) { + return { + channelId, + credentialId: `${channelId}Token`, + sourceInput: "token", + providerName: `${sandboxName}-${channelId}-bridge`, + providerEnvKey: envKey, + placeholder: `openshell:resolve:env:${envKey}`, + credentialAvailable: true, + ...(hash !== undefined ? { credentialHash: hash } : {}), + }; +} + +function createPlan( + sandboxName = "demo", + channelId = "telegram", + hash: string | undefined = "hash", +): NonNullable> { + const envKey = + channelId === "slack" + ? "SLACK_BOT_TOKEN" + : channelId === "discord" + ? "DISCORD_BOT_TOKEN" + : "TELEGRAM_BOT_TOKEN"; + return { + schemaVersion: 1, + sandboxName, + agent: "openclaw", + workflow: "onboard", + channels: [planChannel(channelId)], + disabledChannels: [], + credentialBindings: + channelId === "slack" + ? [ + credentialBinding("slack", "SLACK_BOT_TOKEN", sandboxName, `${hash ?? "missing"}-bot`), + credentialBinding("slack", "SLACK_APP_TOKEN", sandboxName, `${hash ?? "missing"}-app`), + ] + : [credentialBinding(channelId, envKey, sandboxName, hash)], + networkPolicy: { presets: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + } as unknown as NonNullable< + ReturnType + >; +} + +function createDeps( + overrides: Partial = {}, +): SandboxMessagingPreflightDeps { + return { + readMessagingPlanFromEnv: vi.fn(() => null), + resolveDisabledChannels: vi.fn(() => []), + gatewayName: "nemoclaw", + registry: { + listSandboxes: vi.fn(() => ({ sandboxes: [] })), + }, + providerExistsInGateway: vi.fn(() => false), + isNonInteractive: vi.fn(() => false), + promptYesNoOrDefault: vi.fn(async () => true), + cliName: vi.fn(() => "nemoclaw"), + log: vi.fn(), + error: vi.fn(), + exitProcess: vi.fn((code: number) => { + throw new ExitError(code); + }) as (code: number) => never, + getValidatedMessagingTokenByEnvKey: vi.fn(() => null), + getCredential: vi.fn(() => null), + normalizeCredentialValue: vi.fn((value: unknown) => + typeof value === "string" ? value.trim() : "", + ), + registerExtraPlaceholderProviders: vi.fn(() => []), + getMessagingChannelForEnvKey: vi.fn(() => null), + prepareCreateSandboxMessaging: vi.fn((input) => + createResult({ disabledChannelNames: new Set(input.disabledChannels) }), + ), + ...overrides, + }; +} + +const baseInput = { + sandboxName: "demo", + channels: listChannels(), + enabledChannels: ["slack"], + webSearchConfig: null, + env: {}, +}; + +describe("prepareSandboxMessagingPreflight", () => { + it("passes resolved disabled channels into messaging prep", async () => { + const deps = createDeps({ + resolveDisabledChannels: vi.fn(() => ["telegram"]), + }); + + const result = await prepareSandboxMessagingPreflight(baseInput, deps); + + expect([...result.disabledChannelNames]).toEqual(["telegram"]); + expect(result.disabledChannels).toEqual(["telegram"]); + expect(deps.prepareCreateSandboxMessaging).toHaveBeenCalledWith( + expect.objectContaining({ + sandboxName: "demo", + enabledChannels: ["slack"], + disabledChannels: ["telegram"], + }), + ); + }); + + it("ignores stale env plans for a different sandbox", async () => { + const enforceMessagingChannelConflicts = vi.fn(async () => undefined); + const deps = createDeps({ + readMessagingPlanFromEnv: vi.fn(() => createPlan("other")), + enforceMessagingChannelConflicts, + }); + + await prepareSandboxMessagingPreflight(baseInput, deps); + + expect(enforceMessagingChannelConflicts).not.toHaveBeenCalled(); + expect(deps.prepareCreateSandboxMessaging).toHaveBeenCalled(); + }); + + it("lets interactive users continue through a matching-token conflict", async () => { + const deps = createDeps({ + readMessagingPlanFromEnv: vi.fn(() => createPlan("demo", "telegram", "same")), + registry: { + listSandboxes: vi.fn(() => ({ + sandboxes: [ + { name: "other", messaging: { plan: createPlan("other", "telegram", "same") } }, + ], + })), + }, + promptYesNoOrDefault: vi.fn(async () => true), + }); + + await prepareSandboxMessagingPreflight(baseInput, deps); + + expect(deps.log).toHaveBeenCalledWith( + expect.stringContaining("uses the same telegram credential"), + ); + expect(deps.promptYesNoOrDefault).toHaveBeenCalledWith(" Continue anyway?", null, false); + expect(deps.prepareCreateSandboxMessaging).toHaveBeenCalled(); + }); + + it("aborts non-interactive runs when the current plan conflicts", async () => { + const deps = createDeps({ + readMessagingPlanFromEnv: vi.fn(() => createPlan("demo", "discord", undefined)), + isNonInteractive: vi.fn(() => true), + registry: { + listSandboxes: vi.fn(() => ({ + sandboxes: [ + { name: "other", messaging: { plan: createPlan("other", "discord", undefined) } }, + ], + })), + }, + }); + + await expect(prepareSandboxMessagingPreflight(baseInput, deps)).rejects.toMatchObject({ + code: 1, + }); + expect(deps.error).toHaveBeenCalledWith(expect.stringContaining("channels stop ")); + expect(deps.promptYesNoOrDefault).not.toHaveBeenCalled(); + }); + + it("aborts a second Slack Socket Mode sandbox on the same gateway", async () => { + const deps = createDeps({ + readMessagingPlanFromEnv: vi.fn(() => createPlan("demo", "slack", "demo")), + isNonInteractive: vi.fn(() => true), + registry: { + listSandboxes: vi.fn(() => ({ + sandboxes: [ + { + name: "other", + gatewayName: "nemoclaw", + messaging: { plan: createPlan("other", "slack", "other") }, + }, + ], + })), + }, + }); + + await expect(prepareSandboxMessagingPreflight(baseInput, deps)).rejects.toMatchObject({ + code: 1, + }); + expect(deps.log).toHaveBeenCalledWith( + expect.stringContaining("Slack Socket Mode is already enabled for sandbox 'other'"), + ); + expect(deps.error).toHaveBeenCalledWith( + expect.stringContaining("only one sandbox per gateway can receive Slack Socket Mode events"), + ); + }); + + it("fails before recreate/delete when Brave search has no API key", async () => { + const deps = createDeps({ + prepareCreateSandboxMessaging: vi.fn(() => createResult({ missingBraveApiKey: true })), + }); + + await expect(prepareSandboxMessagingPreflight(baseInput, deps)).rejects.toMatchObject({ + code: 1, + }); + expect(deps.error).toHaveBeenCalledWith( + " Brave Search is enabled, but BRAVE_API_KEY is not available in this process.", + ); + }); +}); diff --git a/src/lib/onboard/sandbox-messaging-preflight.ts b/src/lib/onboard/sandbox-messaging-preflight.ts new file mode 100644 index 00000000000..69860ca1a45 --- /dev/null +++ b/src/lib/onboard/sandbox-messaging-preflight.ts @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { WebSearchConfig } from "../inference/web-search"; +import type { SandboxMessagingPlan } from "../messaging/manifest/types"; +import { + enforceMessagingChannelConflicts as defaultEnforceMessagingChannelConflicts, + type MessagingConflictGuardDeps, +} from "./messaging-conflict-guard"; +import { + prepareCreateSandboxMessaging as defaultPrepareCreateSandboxMessaging, + type CreateSandboxMessagingPrepInput, + type CreateSandboxMessagingPrepResult, + type NamedMessagingChannel, +} from "./messaging-prep"; + +export interface SandboxMessagingPreflightInput { + sandboxName: string; + channels: readonly NamedMessagingChannel[]; + enabledChannels: readonly string[] | null; + webSearchConfig: WebSearchConfig | null; + env: NodeJS.ProcessEnv | Record; +} + +export interface SandboxMessagingPreflightDeps { + readMessagingPlanFromEnv(): SandboxMessagingPlan | null; + resolveDisabledChannels(sandboxName: string): string[]; + gatewayName: string; + registry: MessagingConflictGuardDeps["registry"]; + providerExistsInGateway(name: string): boolean; + isNonInteractive(): boolean; + promptYesNoOrDefault( + message: string, + defaultValue: string | null, + fallback: boolean, + ): Promise; + cliName(): string; + log(message: string): void; + error(message: string): void; + exitProcess(code: number): never; + getValidatedMessagingTokenByEnvKey( + channels: readonly NamedMessagingChannel[], + envKey: string, + ): string | null; + getCredential(envKey: string): string | null; + normalizeCredentialValue(value: unknown): string; + registerExtraPlaceholderProviders( + sandboxName: string, + messagingTokenDefs: CreateSandboxMessagingPrepResult["messagingTokenDefs"], + ): string[]; + getMessagingChannelForEnvKey(envKey: string): string | null; + prepareCreateSandboxMessaging?: ( + input: CreateSandboxMessagingPrepInput, + ) => CreateSandboxMessagingPrepResult; + enforceMessagingChannelConflicts?: (deps: MessagingConflictGuardDeps) => Promise; +} + +export interface SandboxMessagingPreflightResult extends CreateSandboxMessagingPrepResult { + disabledChannels: string[]; +} + +export async function prepareSandboxMessagingPreflight( + input: SandboxMessagingPreflightInput, + deps: SandboxMessagingPreflightDeps, +): Promise { + const disabledChannels = deps.resolveDisabledChannels(input.sandboxName); + await checkMessagingPlanConflicts(input.sandboxName, disabledChannels, deps); + + const result = (deps.prepareCreateSandboxMessaging ?? defaultPrepareCreateSandboxMessaging)({ + sandboxName: input.sandboxName, + channels: input.channels, + enabledChannels: input.enabledChannels, + disabledChannels, + webSearchConfig: input.webSearchConfig, + env: input.env, + getValidatedMessagingTokenByEnvKey: deps.getValidatedMessagingTokenByEnvKey, + getCredential: deps.getCredential, + normalizeCredentialValue: deps.normalizeCredentialValue, + registerExtraPlaceholderProviders: deps.registerExtraPlaceholderProviders, + getMessagingChannelForEnvKey: deps.getMessagingChannelForEnvKey, + providerExistsInGateway: deps.providerExistsInGateway, + }); + + if (result.missingBraveApiKey) { + deps.error(" Brave Search is enabled, but BRAVE_API_KEY is not available in this process."); + deps.error( + " Re-run with BRAVE_API_KEY set, or disable Brave Search before recreating the sandbox.", + ); + deps.exitProcess(1); + } + + return { ...result, disabledChannels }; +} + +async function checkMessagingPlanConflicts( + sandboxName: string, + disabledChannels: readonly string[], + deps: SandboxMessagingPreflightDeps, +): Promise { + const envPlan = deps.readMessagingPlanFromEnv(); + const currentPlan = envPlan?.sandboxName === sandboxName ? envPlan : null; + if (!currentPlan) return; + + const enforceMessagingChannelConflicts = + deps.enforceMessagingChannelConflicts ?? defaultEnforceMessagingChannelConflicts; + await enforceMessagingChannelConflicts({ + sandboxName, + gatewayName: deps.gatewayName, + currentPlan, + currentSandboxDisabledChannels: disabledChannels, + registry: deps.registry, + isNonInteractive: deps.isNonInteractive, + promptContinue: () => deps.promptYesNoOrDefault(" Continue anyway?", null, false), + cliName: deps.cliName, + log: deps.log, + error: deps.error, + exit: deps.exitProcess, + }); +} diff --git a/test/e2e/test-onboard-negative-paths.sh b/test/e2e/test-onboard-negative-paths.sh index 3a5c29410dd..83d2f5d1ee5 100755 --- a/test/e2e/test-onboard-negative-paths.sh +++ b/test/e2e/test-onboard-negative-paths.sh @@ -15,6 +15,8 @@ # 5. A host listener on the configured gateway port produces a friendly conflict. # 6. Custom non-interactive policy presets are applied. # 7. NEMOCLAW_PROVIDER=cloud and NEMOCLAW_MODEL are honored. +# 8. --from without --name/NEMOCLAW_SANDBOX_NAME fails before defaulting. +# 9. --from with NEMOCLAW_SANDBOX_NAME proceeds past entry validation. set -uo pipefail @@ -327,7 +329,74 @@ else fail "NEMOCLAW_POLICY_MODE=nonexistent did not fall back cleanly" fi -section "Phase 3: Provider credential validation" +section "Phase 3: Entry option validation" + +FROM_GUARD_LOG="$(mktemp)" +env -u NEMOCLAW_SANDBOX_NAME \ + NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ + NEMOCLAW_PROVIDER=cloud \ + NEMOCLAW_POLICY_MODE=skip \ + NVIDIA_API_KEY="$RESTORE_API_KEY" \ + node "$REPO/bin/nemoclaw.js" onboard --non-interactive --from "$REPO/Dockerfile" \ + >"$FROM_GUARD_LOG" 2>&1 +from_guard_exit=$? +from_guard_output="$(cat "$FROM_GUARD_LOG")" +rm -f "$FROM_GUARD_LOG" +rm -f "$SESSION_FILE" + +if [ "$from_guard_exit" -eq 1 ]; then + pass "--from without sandbox name exited 1" +else + fail "--from without sandbox name exited $from_guard_exit (expected 1)" +fi + +if printf '%s\n' "$from_guard_output" | grep -q -- "--from requires --name "; then + pass "--from missing-name guard message is explicit" +else + fail "--from missing-name guard message missing" +fi + +if assert_no_stack_trace "$from_guard_output"; then + pass "--from missing-name guard did not print a stack trace" +else + fail "--from missing-name guard printed a stack trace" +fi + +FROM_ENV_NAME_LOG="$(mktemp)" +env \ + NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ + NEMOCLAW_SANDBOX_NAME="bad name" \ + NEMOCLAW_PROVIDER=cloud \ + NEMOCLAW_POLICY_MODE=skip \ + NVIDIA_API_KEY="$RESTORE_API_KEY" \ + node "$REPO/bin/nemoclaw.js" onboard --non-interactive --from "$REPO/Dockerfile" \ + >"$FROM_ENV_NAME_LOG" 2>&1 +from_env_name_exit=$? +from_env_name_output="$(cat "$FROM_ENV_NAME_LOG")" +rm -f "$FROM_ENV_NAME_LOG" +rm -f "$SESSION_FILE" + +if [ "$from_env_name_exit" -eq 1 ]; then + pass "--from with NEMOCLAW_SANDBOX_NAME reached name validation" +else + fail "--from with NEMOCLAW_SANDBOX_NAME exited $from_env_name_exit (expected 1)" +fi + +if printf '%s\n' "$from_env_name_output" | grep -q "Invalid sandbox name"; then + pass "--from with env sandbox name used NEMOCLAW_SANDBOX_NAME" +else + fail "--from with env sandbox name did not reach name validation" +fi + +if printf '%s\n' "$from_env_name_output" | grep -q -- "--from requires --name "; then + fail "--from with env sandbox name still printed missing-name guard" +else + pass "--from with env sandbox name did not print missing-name guard" +fi + +section "Phase 4: Provider credential validation" INVALID_KEY_LOG="$(mktemp)" NEMOCLAW_NON_INTERACTIVE=1 \ @@ -368,7 +437,7 @@ else fail "Provider-aware credential validation rejected a non-NVIDIA key prefix" fi -section "Phase 4: Gateway port conflict" +section "Phase 5: Gateway port conflict" if start_port_holder "$PORT_CONFLICT_PORT"; then pass "Held gateway port ${PORT_CONFLICT_PORT} with a host listener" @@ -415,7 +484,7 @@ else fail "Port conflict path printed a stack trace" fi -section "Phase 5: Live non-interactive onboard honors presets and model" +section "Phase 6: Live non-interactive onboard honors presets and model" LIVE_LOG="$(mktemp)" NEMOCLAW_NON_INTERACTIVE=1 \ @@ -491,7 +560,7 @@ else fail "Session did not record requested provider, model, and policy presets" fi -section "Phase 6: Final cleanup" +section "Phase 7: Final cleanup" if [[ "${NEMOCLAW_E2E_KEEP_SANDBOX:-}" != "1" ]]; then run_nemoclaw "$SANDBOX_NAME" destroy --yes >/dev/null 2>&1 || true