diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index bf3a8a787c..c22f87ff9e 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -31,8 +31,10 @@ 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 { ensureOllamaLoopbackSystemdOverride, }: typeof import("./onboard/ollama-systemd") = require("./onboard/ollama-systemd"); @@ -406,7 +408,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"); @@ -2571,77 +2572,41 @@ 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, + 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); @@ -4810,73 +4775,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({ @@ -5046,9 +4962,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 0000000000..5985538e95 --- /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 0000000000..7f808fb8ae --- /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-messaging-preflight.test.ts b/src/lib/onboard/sandbox-messaging-preflight.test.ts new file mode 100644 index 0000000000..a972977ab6 --- /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 0000000000..dbfc9007a3 --- /dev/null +++ b/src/lib/onboard/sandbox-messaging-preflight.ts @@ -0,0 +1,122 @@ +// 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 { resolveDisabledChannels as defaultResolveDisabledChannels } from "./channel-state"; +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 ?? defaultResolveDisabledChannels)( + 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 3a5c29410d..83d2f5d1ee 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