Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 50 additions & 134 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 <sandbox> 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);

Expand Down Expand Up @@ -4810,73 +4775,24 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
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 <Dockerfile> requires --name <sandbox> (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({
Expand Down Expand Up @@ -5046,9 +4962,9 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
// 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);
Expand Down
162 changes: 162 additions & 0 deletions src/lib/onboard/entry-options.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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 <Dockerfile> requires --name <sandbox> (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.");
});
});
Loading
Loading