diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 669aac9f66..05616a00b1 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -297,6 +297,7 @@ const { OnboardRuntimeBoundary }: typeof import("./onboard/runtime-boundary") = const { handleGatewayState }: typeof import("./onboard/machine/handlers/gateway") = require("./onboard/machine/handlers/gateway"); const { handlePreflightState }: typeof import("./onboard/machine/handlers/preflight") = require("./onboard/machine/handlers/preflight"); const { handleProviderInferenceState }: typeof import("./onboard/machine/handlers/provider-inference") = require("./onboard/machine/handlers/provider-inference"); +const { handleSandboxState }: typeof import("./onboard/machine/handlers/sandbox") = require("./onboard/machine/handlers/sandbox"); const policies: typeof import("./policy") = require("./policy"); const tiers: typeof import("./policy/tiers") = require("./policy/tiers"); const { ensureUsageNoticeConsent } = require("./onboard/usage-notice"); @@ -9478,217 +9479,69 @@ async function onboard(opts: OnboardOptions = {}): Promise { } = providerInferenceResult; let webSearchConfig = providerInferenceResult.webSearchConfig; - const webSearchSupportProbePath = fromDockerfile ? path.resolve(fromDockerfile) : null; - const webSearchSupported = agentSupportsWebSearch(agent, webSearchSupportProbePath, ROOT); - if (webSearchConfig && !webSearchSupported) { - note( - ` Web search is not yet supported by ${agent?.displayName ?? "this sandbox image"}. Clearing stale config.`, - ); - webSearchConfig = null; - if (session) { - session.webSearchConfig = null; - } - onboardSession.updateSession((current: Session) => { - current.webSearchConfig = null; - return current; - }); - } - - const storedMessagingChannelConfig = getStoredMessagingChannelConfig(sandboxName, session); - const effectiveMessagingChannelConfig = hydrateMessagingChannelConfig(storedMessagingChannelConfig); - const messagingChannelConfigChanged = !messagingChannelConfigsEqual( - effectiveMessagingChannelConfig, - storedMessagingChannelConfig, - ); - if (effectiveMessagingChannelConfig) { - persistMessagingChannelConfigToSession(effectiveMessagingChannelConfig); - if (session) { - session.messagingChannelConfig = effectiveMessagingChannelConfig; - } - } - - const sandboxReuseState = getSandboxReuseState(sandboxName); - const webSearchConfigChanged = Boolean(session?.webSearchConfig) !== Boolean(webSearchConfig); - // Telegram mention-mode is baked into openclaw.json at sandbox build time, so - // changes to TELEGRAM_REQUIRE_MENTION only take effect after a rebuild. Treat - // a mismatch between the recorded config and the current env value as drift - // so the reuse path forces a recreate (mirrors webSearchConfigChanged). See - // #1737 and the CodeRabbit review on #2417. - // - // Compare *effective* modes — null and false both produce groupPolicy: open - // at config-generation time (default behavior), so they collapse to the same - // bucket here. Without this, a sandbox built before TELEGRAM_REQUIRE_MENTION - // existed (recordedTelegramRequireMention === null) would be reused with the - // old groupPolicy: open even after the user sets TELEGRAM_REQUIRE_MENTION=1, - // and vice versa. - const currentTelegramRequireMention = computeTelegramRequireMention(); - const recordedTelegramRequireMention = session?.telegramConfig?.requireMention ?? null; - const effectiveCurrent = currentTelegramRequireMention ?? false; - const effectiveRecorded = recordedTelegramRequireMention ?? false; - const telegramConfigChanged = effectiveCurrent !== effectiveRecorded; - const sandboxGpuConfigChanged = sandboxName - ? hasSandboxGpuDrift(sandboxName, sandboxGpuConfig) - : false; - const wechatConfigChanged = hasWechatConfigDrift(session); - const recordedHermesToolGateways = sandboxName - ? normalizeHermesToolGatewaySelections(registry.getSandbox(sandboxName)?.hermesToolGateways) - : []; - const hermesToolGatewayConfigChanged = !stringSetsEqual( - recordedHermesToolGateways, + const sandboxStateResult = await handleSandboxState({ + resume, + fresh, + resumeAgentChanged, + session, + sandboxName, + model, + provider, + nimContainer, + webSearchConfig, + selectedMessagingChannels, + fromDockerfile, + agent, + gpu, + preferredInferenceApi, + sandboxGpuConfig, hermesToolGateways, - ); - const resumeSandbox = - resume && - !resumeAgentChanged && - !webSearchConfigChanged && - !telegramConfigChanged && - !sandboxGpuConfigChanged && - !wechatConfigChanged && - !messagingChannelConfigChanged && - !hermesToolGatewayConfigChanged && - session?.steps?.sandbox?.status === "complete" && - sandboxReuseState === "ready"; - if (resumeSandbox) { - if (webSearchConfig) { - note(" [resume] Reusing Brave Search configuration already baked into the sandbox."); - } - selectedMessagingChannels = session?.messagingChannels ?? []; - skippedStepMessage("sandbox", sandboxName); - } else { - if (resume && session?.steps?.sandbox?.status === "complete") { - if (resumeAgentChanged) { - note( - " [resume] Agent selection changed; revalidating sandbox compatibility.", - ); - } else if (webSearchConfigChanged) { - note(" [resume] Web Search configuration changed; recreating sandbox."); - if (sandboxName) { - registry.removeSandbox(sandboxName); - } - } else if (telegramConfigChanged) { - note(" [resume] TELEGRAM_REQUIRE_MENTION changed; recreating sandbox."); - if (sandboxName) { - registry.removeSandbox(sandboxName); - } - } else if (sandboxGpuConfigChanged) { - note(" [resume] Sandbox GPU settings changed; recreating sandbox."); - if (sandboxName) { - registry.removeSandbox(sandboxName); - } - } else if (wechatConfigChanged) { - note(" [resume] WeChat account metadata changed; recreating sandbox."); - if (sandboxName) { - registry.removeSandbox(sandboxName); - } - } else if (messagingChannelConfigChanged) { - note(" [resume] Messaging channel configuration changed; recreating sandbox."); - if (sandboxName) { - registry.removeSandbox(sandboxName); - } - } else if (hermesToolGatewayConfigChanged) { - note(" [resume] Hermes managed tool gateway selection changed; recreating sandbox."); - if (sandboxName) { - registry.removeSandbox(sandboxName); - } - } else if (sandboxReuseState === "not_ready") { - note( - ` [resume] Recorded sandbox '${sandboxName}' exists but is not ready; recreating it.`, - ); - repairRecordedSandbox(sandboxName); - } else { - note(" [resume] Recorded sandbox state is unavailable; recreating it."); - if (sandboxName) { - registry.removeSandbox(sandboxName); - } - } - } - let nextWebSearchConfig = webSearchConfig; - if (nextWebSearchConfig) { - note(" [resume] Revalidating Brave Search configuration for sandbox recreation."); - const braveApiKey = await ensureValidatedBraveSearchCredential(); - nextWebSearchConfig = braveApiKey ? { fetchEnabled: true } : null; - if (nextWebSearchConfig) { - note(" [resume] Reusing Brave Search configuration."); - } - } else { - nextWebSearchConfig = await configureWebSearch(null, agent, webSearchSupportProbePath); - } - await startRecordedStep("sandbox", { provider, model }); - const recordedMessagingChannels = getRecordedMessagingChannelsForResume(resume, session, sandboxName); - if (recordedMessagingChannels) { - selectedMessagingChannels = recordedMessagingChannels; - if (selectedMessagingChannels.length > 0) { - note( - ` [non-interactive] Reusing messaging channel configuration: ${selectedMessagingChannels.join(", ")}`, - ); - } - } else { - const existing = sandboxName - ? registry.getSandbox(sandboxName)?.messagingChannels ?? - session?.messagingChannels ?? - null - : session?.messagingChannels ?? null; - selectedMessagingChannels = await setupMessagingChannels(agent, existing); - } - const messagingChannelConfig = readMessagingChannelConfigFromEnv(); - onboardSession.updateSession((current: Session) => { - current.messagingChannels = selectedMessagingChannels; - current.messagingChannelConfig = messagingChannelConfig; - return current; - }); - if (!sandboxName) { - sandboxName = await promptValidatedSandboxName(agent); - } - if (typeof model !== "string" || typeof provider !== "string") { - console.error(" Inference selection is incomplete; cannot create sandbox."); - process.exit(1); - } - if (fresh) { - stopStaleDashboardListenersForSandbox(registry.listSandboxes().sandboxes, sandboxName); - } - sandboxName = await createSandbox( - gpu, - model, - provider, - preferredInferenceApi, - sandboxName, - nextWebSearchConfig, - selectedMessagingChannels, - fromDockerfile, - agent, - opts.controlUiPort || null, - sandboxGpuConfig, - hermesToolGateways, - ); - webSearchConfig = nextWebSearchConfig; - registry.updateSandbox(sandboxName, { - model, - provider, - ...getSandboxAgentRegistryFields(agent, !fromDockerfile), - }); - registry.setDefault(sandboxName); - await recordStepComplete( - "sandbox", - toSessionUpdates({ - sandboxName, - provider, - model, - nimContainer, - webSearchConfig, - messagingChannelConfig, - hermesToolGateways, - }), - ); - } - - if ( - typeof sandboxName !== "string" || - typeof provider !== "string" || - typeof model !== "string" - ) { - console.error(" Onboarding state is incomplete after sandbox setup."); - process.exit(1); - } + controlUiPort: opts.controlUiPort || null, + rootDir: ROOT, + deps: { + resolvePath: path.resolve, + agentSupportsWebSearch, + note, + updateSession: onboardSession.updateSession, + getStoredMessagingChannelConfig, + hydrateMessagingChannelConfig, + messagingChannelConfigsEqual, + persistMessagingChannelConfigToSession, + getSandboxReuseState, + computeTelegramRequireMention, + hasSandboxGpuDrift, + hasWechatConfigDrift, + getSandboxHermesToolGateways: (name) => registry.getSandbox(name)?.hermesToolGateways, + normalizeHermesToolGatewaySelections, + stringSetsEqual, + removeSandboxFromRegistry: registry.removeSandbox.bind(registry), + repairRecordedSandbox, + ensureValidatedBraveSearchCredential, + configureWebSearch, + startRecordedStep, + getRecordedMessagingChannelsForResume, + getSandboxMessagingChannels: (name) => registry.getSandbox(name)?.messagingChannels, + setupMessagingChannels, + readMessagingChannelConfigFromEnv, + promptValidatedSandboxName, + stopStaleDashboardListenersForSandbox, + listRegistrySandboxes: registry.listSandboxes, + createSandbox, + updateSandboxRegistry: (name, updates) => registry.updateSandbox(name, updates), + setDefaultSandbox: registry.setDefault, + getSandboxAgentRegistryFields, + recordStepComplete, + toSessionUpdates: (updates) => toSessionUpdates(updates as Parameters[0]), + skippedStepMessage, + error: (message) => console.error(message), + exitProcess: (code) => process.exit(code), + }, + }); + session = sandboxStateResult.session; + sandboxName = sandboxStateResult.sandboxName; + webSearchConfig = sandboxStateResult.webSearchConfig ?? null; + selectedMessagingChannels = sandboxStateResult.selectedMessagingChannels; + const webSearchSupported = sandboxStateResult.webSearchSupported; if (agent) { await agentOnboard.handleAgentSetup(sandboxName, model, provider, agent, resume, session, { diff --git a/src/lib/onboard/machine/handlers/provider-inference.test.ts b/src/lib/onboard/machine/handlers/provider-inference.test.ts index bdc67f1cf6..d813cccf60 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.test.ts @@ -149,6 +149,51 @@ describe("handleProviderInferenceState", () => { }); }); + it("clears non-NVIDIA provider credentials when inference setup fails", async () => { + const setupNim = vi.fn(async () => ({ + ...baseSelection, + provider: "compatible-endpoint", + credentialEnv: "COMPATIBLE_API_KEY", + })); + const setupInference = vi.fn(async () => { + throw new Error("probe failed"); + }); + const { deps, calls } = createDeps({ setupNim, setupInference }); + + await expect(handleProviderInferenceState(baseOptions(deps))).rejects.toThrow("probe failed"); + + expect(calls.deleteEnv).toHaveBeenCalledWith("COMPATIBLE_API_KEY"); + }); + + it("exits through the injected CLI boundary when provider selection is incomplete", async () => { + const setupNim = vi.fn(async () => ({ ...baseSelection, model: null })); + const { deps, calls } = createDeps({ setupNim }); + + await expect(handleProviderInferenceState(baseOptions(deps))).rejects.toThrow("exit 1"); + + expect(calls.error).toHaveBeenCalledWith(" Inference selection did not yield a provider/model."); + expect(calls.exit).toHaveBeenCalledWith(1); + expect(calls.complete).not.toHaveBeenCalledWith("provider_selection", expect.anything()); + expect(calls.setupInference).not.toHaveBeenCalled(); + }); + + it("clears provider credentials when inference step recording fails", async () => { + const setupNim = vi.fn(async () => ({ + ...baseSelection, + provider: "compatible-endpoint", + credentialEnv: "COMPATIBLE_API_KEY", + })); + const startRecordedStep = vi.fn(async (stepName: string) => { + if (stepName === "inference") throw new Error("recording failed"); + }); + const { deps, calls } = createDeps({ setupNim, startRecordedStep }); + + await expect(handleProviderInferenceState(baseOptions(deps))).rejects.toThrow("recording failed"); + + expect(calls.deleteEnv).toHaveBeenCalledWith("COMPATIBLE_API_KEY"); + expect(calls.setupInference).not.toHaveBeenCalled(); + }); + it("skips provider selection and inference setup when resume state is already ready", async () => { const session = createSession({ provider: "ollama-local", diff --git a/src/lib/onboard/machine/handlers/provider-inference.ts b/src/lib/onboard/machine/handlers/provider-inference.ts index 161b0f0b0c..67eca675a2 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.ts @@ -107,13 +107,25 @@ export interface ProviderInferenceStateResult { session: Session | null; } -function requireSelection(provider: string | null, model: string | null): { provider: string; model: string } { +function requireSelection( + provider: string | null, + model: string | null, + deps: Pick["deps"], "error" | "exitProcess">, +): { provider: string; model: string } { if (typeof provider !== "string" || typeof model !== "string") { - throw new Error("Inference selection did not yield a provider/model."); + deps.error(" Inference selection did not yield a provider/model."); + deps.exitProcess(1); } return { provider, model }; } +function clearStagedCredentialEnv( + deps: Pick["deps"], "deleteEnv">, + credentialEnv: string | null, +): void { + if (credentialEnv) deps.deleteEnv(credentialEnv); +} + export async function handleProviderInferenceState({ resume, session, @@ -149,6 +161,7 @@ export async function handleProviderInferenceState({ session?.steps?.provider_selection?.status === "complete" && typeof provider === "string" && typeof model === "string"; + let shouldRecordProviderSelection = false; if (resumeProviderSelection) { deps.skippedStepMessage("provider_selection", `${provider} / ${model}`); deps.hydrateCredentialEnv(credentialEnv); @@ -164,6 +177,13 @@ export async function handleProviderInferenceState({ hermesToolGateways = selection.hermesToolGateways; preferredInferenceApi = selection.preferredInferenceApi; nimContainer = selection.nimContainer; + shouldRecordProviderSelection = true; + } + + const selected = requireSelection(provider, model, deps); + provider = selected.provider; + model = selected.model; + if (shouldRecordProviderSelection) { session = await deps.recordStepComplete( "provider_selection", deps.toSessionUpdates({ @@ -178,10 +198,6 @@ export async function handleProviderInferenceState({ }), ); } - - const selected = requireSelection(provider, model); - provider = selected.provider; - model = selected.model; env.NEMOCLAW_OPENSHELL_BIN = deps.getOpenshellBinary(); const needsBedrockRuntimeAdapter = deps.needsBedrockRuntimeAdapter(provider, endpointUrl); const resumeInference = @@ -191,17 +207,22 @@ export async function handleProviderInferenceState({ deps.isInferenceRouteReady(provider, model); if (resumeInference) { if (provider === constants.hermesProviderName) { - if (!sandboxName) sandboxName = await deps.promptValidatedSandboxName(agent); - await deps.startRecordedStep("inference", { provider, model }); - const inferenceResult = await deps.setupInference( - sandboxName, - model, - provider, - endpointUrl, - credentialEnv, - hermesAuthMethod, - hermesToolGateways, - ); + let inferenceResult: ProviderInferenceRetry; + try { + if (!sandboxName) sandboxName = await deps.promptValidatedSandboxName(agent); + await deps.startRecordedStep("inference", { provider, model }); + inferenceResult = await deps.setupInference( + sandboxName, + model, + provider, + endpointUrl, + credentialEnv, + hermesAuthMethod, + hermesToolGateways, + ); + } finally { + clearStagedCredentialEnv(deps, credentialEnv); + } if (inferenceResult?.retry === "selection") { forceProviderSelection = true; continue; @@ -229,45 +250,49 @@ export async function handleProviderInferenceState({ break; } - if (!sandboxName) sandboxName = await deps.promptValidatedSandboxName(agent); - const buildEstimateNote = - env.NEMOCLAW_IGNORE_RUNTIME_RESOURCES === "1" - ? null - : deps.formatSandboxBuildEstimateNote(deps.assessHost()); - deps.log( - deps.formatOnboardConfigSummary({ - provider, + let inferenceResult: ProviderInferenceRetry; + try { + if (!sandboxName) sandboxName = await deps.promptValidatedSandboxName(agent); + const buildEstimateNote = + env.NEMOCLAW_IGNORE_RUNTIME_RESOURCES === "1" + ? null + : deps.formatSandboxBuildEstimateNote(deps.assessHost()); + deps.log( + deps.formatOnboardConfigSummary({ + provider, + model, + credentialEnv, + hermesAuthMethod, + webSearchConfig, + hermesToolGateways, + enabledChannels: selectedMessagingChannels.length > 0 ? selectedMessagingChannels : null, + sandboxName, + notes: buildEstimateNote ? [buildEstimateNote] : [], + }), + ); + deps.log(" Web search and messaging channels will be prompted next."); + if (!deps.isNonInteractive()) { + if (!(await deps.promptYesNoOrDefault(" Apply this configuration?", null, true))) { + deps.log(` Aborted. Re-run \`${deps.cliName()} onboard\` to start over.`); + deps.log(" Credentials entered so far were only staged in memory for this run."); + deps.log(" No new gateway credential was registered because onboarding stopped here."); + deps.exitProcess(0); + } + } + + await deps.startRecordedStep("inference", { provider, model }); + inferenceResult = await deps.setupInference( + sandboxName, model, + provider, + endpointUrl, credentialEnv, hermesAuthMethod, - webSearchConfig, hermesToolGateways, - enabledChannels: selectedMessagingChannels.length > 0 ? selectedMessagingChannels : null, - sandboxName, - notes: buildEstimateNote ? [buildEstimateNote] : [], - }), - ); - deps.log(" Web search and messaging channels will be prompted next."); - if (!deps.isNonInteractive()) { - if (!(await deps.promptYesNoOrDefault(" Apply this configuration?", null, true))) { - deps.log(` Aborted. Re-run \`${deps.cliName()} onboard\` to start over.`); - deps.log(" Credentials entered so far were only staged in memory for this run."); - deps.log(" No new gateway credential was registered because onboarding stopped here."); - deps.exitProcess(0); - } + ); + } finally { + clearStagedCredentialEnv(deps, credentialEnv); } - - await deps.startRecordedStep("inference", { provider, model }); - const inferenceResult = await deps.setupInference( - sandboxName, - model, - provider, - endpointUrl, - credentialEnv, - hermesAuthMethod, - hermesToolGateways, - ); - deps.deleteEnv("NVIDIA_API_KEY"); if (inferenceResult?.retry === "selection") { forceProviderSelection = true; continue; diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts new file mode 100644 index 0000000000..e8a12d7601 --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -0,0 +1,223 @@ +// 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 { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; +import { handleSandboxState, type SandboxStateOptions } from "./sandbox"; + +type Gpu = { type: string } | null; +type Agent = { displayName?: string } | null; +type WebSearchConfig = { fetchEnabled: true }; +type MessagingChannelConfig = Record; +type SandboxGpuConfig = { sandboxGpuEnabled: boolean; mode: string }; + +function createDeps(overrides: Partial["deps"]> = {}) { + let session = createSession(); + const calls = { + note: vi.fn(), + updateSession: vi.fn((mutator: (value: Session) => Session | void) => { + session = mutator(session) ?? session; + return session; + }), + persistMessaging: vi.fn(), + removeSandbox: vi.fn(), + repairSandbox: vi.fn(), + validateBrave: vi.fn(async () => "brave-key"), + configureWebSearch: vi.fn(async () => null as WebSearchConfig | null), + startStep: vi.fn(async () => undefined), + getRecordedChannels: vi.fn(() => null), + setupMessaging: vi.fn(async () => [] as string[]), + promptName: vi.fn(async () => "my-assistant"), + stopStale: vi.fn(), + createSandbox: vi.fn(async () => "my-assistant"), + updateSandbox: vi.fn(), + setDefault: vi.fn(), + complete: vi.fn(async () => createSession()), + skipped: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + }; + return { + calls, + deps: { + resolvePath: (value: string) => `/abs/${value}`, + agentSupportsWebSearch: () => true, + note: calls.note, + updateSession: calls.updateSession, + getStoredMessagingChannelConfig: () => null, + hydrateMessagingChannelConfig: (config: MessagingChannelConfig | null) => config, + messagingChannelConfigsEqual: () => true, + persistMessagingChannelConfigToSession: calls.persistMessaging, + getSandboxReuseState: () => "missing", + computeTelegramRequireMention: () => null, + hasSandboxGpuDrift: () => false, + hasWechatConfigDrift: () => false, + getSandboxHermesToolGateways: () => [], + normalizeHermesToolGatewaySelections: (value: unknown) => (Array.isArray(value) ? (value as string[]) : []), + stringSetsEqual: (left: string[], right: string[]) => left.length === right.length && left.every((value) => right.includes(value)), + removeSandboxFromRegistry: calls.removeSandbox, + repairRecordedSandbox: calls.repairSandbox, + ensureValidatedBraveSearchCredential: calls.validateBrave, + configureWebSearch: calls.configureWebSearch, + startRecordedStep: calls.startStep, + getRecordedMessagingChannelsForResume: calls.getRecordedChannels, + getSandboxMessagingChannels: () => ["telegram"], + setupMessagingChannels: calls.setupMessaging, + readMessagingChannelConfigFromEnv: () => null, + promptValidatedSandboxName: calls.promptName, + stopStaleDashboardListenersForSandbox: calls.stopStale, + listRegistrySandboxes: () => ({ sandboxes: [{ name: "old" }] }), + createSandbox: calls.createSandbox, + updateSandboxRegistry: calls.updateSandbox, + setDefaultSandbox: calls.setDefault, + getSandboxAgentRegistryFields: () => ({ agent: null }), + recordStepComplete: calls.complete, + toSessionUpdates: (updates: Record) => updates as SessionUpdates, + skippedStepMessage: calls.skipped, + error: calls.error, + exitProcess: calls.exit, + ...overrides, + }, + getSession: () => session, + }; +} + +function baseOptions( + deps: SandboxStateOptions["deps"], + session: Session | null = createSession(), +): SandboxStateOptions { + return { + resume: false, + fresh: false, + resumeAgentChanged: false, + session, + sandboxName: null, + model: "model", + provider: "provider", + nimContainer: null, + webSearchConfig: null, + selectedMessagingChannels: [], + fromDockerfile: null, + agent: null, + gpu: { type: "nvidia" }, + preferredInferenceApi: "openai-completions", + sandboxGpuConfig: { sandboxGpuEnabled: false, mode: "0" }, + hermesToolGateways: [], + controlUiPort: null, + rootDir: "/repo", + deps, + }; +} + +describe("handleSandboxState", () => { + it("creates a sandbox and records messaging/web search state", async () => { + const { deps, calls } = createDeps({ + configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })), + readMessagingChannelConfigFromEnv: () => ({ telegram: "polling" }), + }); + calls.setupMessaging.mockResolvedValue(["telegram"]); + + const result = await handleSandboxState(baseOptions(deps)); + + expect(calls.startStep).toHaveBeenCalledWith("sandbox", { provider: "provider", model: "model" }); + expect(calls.setupMessaging).toHaveBeenCalledWith(null, null); + expect(calls.promptName).toHaveBeenCalledWith(null); + expect(calls.createSandbox).toHaveBeenCalledWith( + { type: "nvidia" }, + "model", + "provider", + "openai-completions", + "my-assistant", + { fetchEnabled: true }, + ["telegram"], + null, + null, + null, + { sandboxGpuEnabled: false, mode: "0" }, + [], + ); + expect(calls.updateSandbox).toHaveBeenCalledWith("my-assistant", expect.objectContaining({ model: "model", provider: "provider" })); + expect(calls.setDefault).toHaveBeenCalledWith("my-assistant"); + expect(calls.complete).toHaveBeenCalledWith("sandbox", expect.objectContaining({ sandboxName: "my-assistant" })); + expect(result).toMatchObject({ sandboxName: "my-assistant", selectedMessagingChannels: ["telegram"], webSearchSupported: true }); + }); + + it("reuses a completed ready sandbox on resume", async () => { + const session = createSession({ sandboxName: "saved", messagingChannels: ["slack"] }); + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready" }); + + const result = await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "saved" }); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.skipped).toHaveBeenCalledWith("sandbox", "saved"); + expect(result.selectedMessagingChannels).toEqual(["slack"]); + }); + + it("removes registry state when Telegram mention-mode drift forces sandbox recreation", async () => { + const session = createSession({ telegramConfig: { requireMention: true } }); + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + computeTelegramRequireMention: () => false, + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }); + + expect(calls.note).toHaveBeenCalledWith(" [resume] TELEGRAM_REQUIRE_MENTION changed; recreating sandbox."); + expect(calls.removeSandbox).toHaveBeenCalledWith("saved"); + expect(calls.createSandbox).toHaveBeenCalled(); + }); + + it("repairs not-ready resumed sandboxes before recreation", async () => { + const session = createSession({ sandboxName: "saved" }); + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ getSandboxReuseState: () => "not_ready" }); + + await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "saved" }); + + expect(calls.repairSandbox).toHaveBeenCalledWith("saved"); + expect(calls.createSandbox).toHaveBeenCalled(); + }); + + it("recreates when a saved web search sandbox is no longer supported", async () => { + const session = createSession({ sandboxName: "saved", webSearchConfig: { fetchEnabled: true } }); + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ + agentSupportsWebSearch: () => false, + getSandboxReuseState: () => "ready", + updateSession: vi.fn((mutator: (value: Session) => Session | void) => mutator(session) ?? session), + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + webSearchConfig: { fetchEnabled: true }, + }); + + expect(calls.note).toHaveBeenCalledWith( + " Web search is not yet supported by this sandbox image. Clearing stale config.", + ); + expect(calls.note).toHaveBeenCalledWith(" [resume] Web Search configuration changed; recreating sandbox."); + expect(calls.removeSandbox).toHaveBeenCalledWith("saved"); + expect(calls.createSandbox).toHaveBeenCalled(); + }); + + it("uses recorded messaging channels on non-interactive resume", async () => { + const { deps, calls } = createDeps({ getRecordedMessagingChannelsForResume: vi.fn(() => ["discord"]) }); + + const result = await handleSandboxState({ ...baseOptions(deps), resume: true }); + + expect(calls.setupMessaging).not.toHaveBeenCalled(); + expect(calls.note).toHaveBeenCalledWith(" [non-interactive] Reusing messaging channel configuration: discord"); + expect(result.selectedMessagingChannels).toEqual(["discord"]); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts new file mode 100644 index 0000000000..47755a05cc --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -0,0 +1,296 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Session, SessionUpdates } from "../../../state/onboard-session"; + +export interface SandboxStateOptions { + resume: boolean; + fresh: boolean; + resumeAgentChanged: boolean; + session: Session | null; + sandboxName: string | null; + model: string; + provider: string; + nimContainer: string | null; + webSearchConfig: WebSearchConfig | null; + selectedMessagingChannels: string[]; + fromDockerfile: string | null; + agent: Agent; + gpu: Gpu; + preferredInferenceApi: string | null; + sandboxGpuConfig: SandboxGpuConfig; + hermesToolGateways: string[]; + controlUiPort: number | null; + rootDir: string; + deps: { + resolvePath(value: string): string; + agentSupportsWebSearch(agent: Agent, dockerfilePathOverride: string | null, rootDir: string): boolean; + note(message: string): void; + updateSession(mutator: (session: Session) => Session | void): Session; + getStoredMessagingChannelConfig(sandboxName: string | null, session: Session | null): MessagingChannelConfig | null; + hydrateMessagingChannelConfig(config: MessagingChannelConfig | null): MessagingChannelConfig | null; + messagingChannelConfigsEqual(left: MessagingChannelConfig | null, right: MessagingChannelConfig | null): boolean; + persistMessagingChannelConfigToSession(config: MessagingChannelConfig | null): void; + getSandboxReuseState(sandboxName: string | null): string; + computeTelegramRequireMention(): boolean | null; + hasSandboxGpuDrift(sandboxName: string, config: SandboxGpuConfig): boolean; + hasWechatConfigDrift(session: Session | null): boolean; + getSandboxHermesToolGateways(sandboxName: string): unknown; + normalizeHermesToolGatewaySelections(value: unknown): string[]; + stringSetsEqual(left: string[], right: string[]): boolean; + removeSandboxFromRegistry(sandboxName: string): void; + repairRecordedSandbox(sandboxName: string | null): void; + ensureValidatedBraveSearchCredential(): Promise; + configureWebSearch( + existingConfig: WebSearchConfig | null, + agent: Agent, + dockerfilePathOverride: string | null, + ): Promise; + startRecordedStep(stepName: string, updates: { provider: string; model: string }): Promise; + getRecordedMessagingChannelsForResume( + resume: boolean, + session: Session | null, + sandboxName: string | null, + ): string[] | null; + getSandboxMessagingChannels(sandboxName: string): string[] | null | undefined; + setupMessagingChannels(agent: Agent, existingChannels: string[] | null): Promise; + readMessagingChannelConfigFromEnv(): MessagingChannelConfig | null; + promptValidatedSandboxName(agent: Agent): Promise; + stopStaleDashboardListenersForSandbox(sandboxes: unknown[], sandboxName: string): void; + listRegistrySandboxes(): { sandboxes: unknown[] }; + createSandbox( + gpu: Gpu, + model: string, + provider: string, + preferredInferenceApi: string | null, + sandboxName: string, + webSearchConfig: WebSearchConfig | null, + selectedMessagingChannels: string[], + fromDockerfile: string | null, + agent: Agent, + controlUiPort: number | null, + sandboxGpuConfig: SandboxGpuConfig, + hermesToolGateways: string[], + ): Promise; + updateSandboxRegistry(sandboxName: string, updates: Record): void; + setDefaultSandbox(sandboxName: string): void; + getSandboxAgentRegistryFields(agent: Agent, agentVersionKnown: boolean): Record; + recordStepComplete(stepName: string, updates: SessionUpdates): Promise; + toSessionUpdates(updates: Record): SessionUpdates; + skippedStepMessage(stepName: string, detail?: string | null): void; + error(message?: string): void; + exitProcess(code: number): never; + }; +} + +export interface SandboxStateResult { + sandboxName: string; + webSearchConfig: WebSearchConfig | null; + selectedMessagingChannels: string[]; + webSearchSupported: boolean; + session: Session | null; +} + +function sameEffectiveTelegramRequireMention(left: boolean | null, right: boolean | null): boolean { + return (left ?? false) === (right ?? false); +} + +export async function handleSandboxState({ + resume, + fresh, + resumeAgentChanged, + session, + sandboxName, + model, + provider, + nimContainer, + webSearchConfig, + selectedMessagingChannels, + fromDockerfile, + agent, + gpu, + preferredInferenceApi, + sandboxGpuConfig, + hermesToolGateways, + controlUiPort, + rootDir, + deps, +}: SandboxStateOptions< + Gpu, + Agent, + WebSearchConfig, + MessagingChannelConfig, + SandboxGpuConfig +>): Promise> { + const webSearchSupportProbePath = fromDockerfile ? deps.resolvePath(fromDockerfile) : null; + const webSearchSupported = deps.agentSupportsWebSearch(agent, webSearchSupportProbePath, rootDir); + const webSearchSupportDropped = Boolean(webSearchConfig) && !webSearchSupported; + if (webSearchSupportDropped) { + deps.note( + ` Web search is not yet supported by ${(agent as { displayName?: string } | null)?.displayName ?? "this sandbox image"}. Clearing stale config.`, + ); + webSearchConfig = null; + if (session) session.webSearchConfig = null; + session = deps.updateSession((current) => { + current.webSearchConfig = null; + return current; + }); + } + + const storedMessagingChannelConfig = deps.getStoredMessagingChannelConfig(sandboxName, session); + const effectiveMessagingChannelConfig = deps.hydrateMessagingChannelConfig(storedMessagingChannelConfig); + const messagingChannelConfigChanged = !deps.messagingChannelConfigsEqual( + effectiveMessagingChannelConfig, + storedMessagingChannelConfig, + ); + if (effectiveMessagingChannelConfig) { + deps.persistMessagingChannelConfigToSession(effectiveMessagingChannelConfig); + if (session) session.messagingChannelConfig = effectiveMessagingChannelConfig as Session["messagingChannelConfig"]; + } + + const sandboxReuseState = deps.getSandboxReuseState(sandboxName); + const webSearchConfigChanged = webSearchSupportDropped || Boolean(session?.webSearchConfig) !== Boolean(webSearchConfig); + const currentTelegramRequireMention = deps.computeTelegramRequireMention(); + const recordedTelegramRequireMention = session?.telegramConfig?.requireMention ?? null; + // Telegram mention-mode is baked into openclaw.json at sandbox build time. + // Compare effective modes because null and false both produce groupPolicy: open + // during config generation. This preserves the original #1737/#2417 drift rule. + const telegramConfigChanged = !sameEffectiveTelegramRequireMention( + currentTelegramRequireMention, + recordedTelegramRequireMention, + ); + const sandboxGpuConfigChanged = sandboxName ? deps.hasSandboxGpuDrift(sandboxName, sandboxGpuConfig) : false; + const wechatConfigChanged = deps.hasWechatConfigDrift(session); + const recordedHermesToolGateways = sandboxName + ? deps.normalizeHermesToolGatewaySelections(deps.getSandboxHermesToolGateways(sandboxName)) + : []; + const hermesToolGatewayConfigChanged = !deps.stringSetsEqual(recordedHermesToolGateways, hermesToolGateways); + const resumeSandbox = + resume && + !resumeAgentChanged && + !webSearchConfigChanged && + !telegramConfigChanged && + !sandboxGpuConfigChanged && + !wechatConfigChanged && + !messagingChannelConfigChanged && + !hermesToolGatewayConfigChanged && + session?.steps?.sandbox?.status === "complete" && + sandboxReuseState === "ready"; + + if (resumeSandbox) { + if (webSearchConfig) deps.note(" [resume] Reusing Brave Search configuration already baked into the sandbox."); + selectedMessagingChannels = session?.messagingChannels ?? []; + deps.skippedStepMessage("sandbox", sandboxName); + } else { + if (resume && session?.steps?.sandbox?.status === "complete") { + if (resumeAgentChanged) { + deps.note(" [resume] Agent selection changed; revalidating sandbox compatibility."); + } else if (webSearchConfigChanged) { + deps.note(" [resume] Web Search configuration changed; recreating sandbox."); + if (sandboxName) deps.removeSandboxFromRegistry(sandboxName); + } else if (telegramConfigChanged) { + deps.note(" [resume] TELEGRAM_REQUIRE_MENTION changed; recreating sandbox."); + if (sandboxName) deps.removeSandboxFromRegistry(sandboxName); + } else if (sandboxGpuConfigChanged) { + deps.note(" [resume] Sandbox GPU settings changed; recreating sandbox."); + if (sandboxName) deps.removeSandboxFromRegistry(sandboxName); + } else if (wechatConfigChanged) { + deps.note(" [resume] WeChat account metadata changed; recreating sandbox."); + if (sandboxName) deps.removeSandboxFromRegistry(sandboxName); + } else if (messagingChannelConfigChanged) { + deps.note(" [resume] Messaging channel configuration changed; recreating sandbox."); + if (sandboxName) deps.removeSandboxFromRegistry(sandboxName); + } else if (hermesToolGatewayConfigChanged) { + deps.note(" [resume] Hermes managed tool gateway selection changed; recreating sandbox."); + if (sandboxName) deps.removeSandboxFromRegistry(sandboxName); + } else if (sandboxReuseState === "not_ready") { + deps.note(` [resume] Recorded sandbox '${sandboxName}' exists but is not ready; recreating it.`); + deps.repairRecordedSandbox(sandboxName); + } else { + deps.note(" [resume] Recorded sandbox state is unavailable; recreating it."); + if (sandboxName) deps.removeSandboxFromRegistry(sandboxName); + } + } + + let nextWebSearchConfig = webSearchConfig; + if (nextWebSearchConfig) { + deps.note(" [resume] Revalidating Brave Search configuration for sandbox recreation."); + const braveApiKey = await deps.ensureValidatedBraveSearchCredential(); + nextWebSearchConfig = braveApiKey ? webSearchConfig : null; + if (nextWebSearchConfig) deps.note(" [resume] Reusing Brave Search configuration."); + } else { + nextWebSearchConfig = await deps.configureWebSearch(null, agent, webSearchSupportProbePath); + } + + await deps.startRecordedStep("sandbox", { provider, model }); + const recordedMessagingChannels = deps.getRecordedMessagingChannelsForResume(resume, session, sandboxName); + if (recordedMessagingChannels) { + selectedMessagingChannels = recordedMessagingChannels; + if (selectedMessagingChannels.length > 0) { + deps.note(` [non-interactive] Reusing messaging channel configuration: ${selectedMessagingChannels.join(", ")}`); + } + } else { + const existing = sandboxName + ? deps.getSandboxMessagingChannels(sandboxName) ?? session?.messagingChannels ?? null + : session?.messagingChannels ?? null; + selectedMessagingChannels = await deps.setupMessagingChannels(agent, existing); + } + const messagingChannelConfig = deps.readMessagingChannelConfigFromEnv(); + session = deps.updateSession((current) => { + current.messagingChannels = selectedMessagingChannels; + current.messagingChannelConfig = messagingChannelConfig as Session["messagingChannelConfig"]; + return current; + }); + + if (!sandboxName) sandboxName = await deps.promptValidatedSandboxName(agent); + if (fresh) deps.stopStaleDashboardListenersForSandbox(deps.listRegistrySandboxes().sandboxes, sandboxName); + sandboxName = await deps.createSandbox( + gpu, + model, + provider, + preferredInferenceApi, + sandboxName, + nextWebSearchConfig, + selectedMessagingChannels, + fromDockerfile, + agent, + controlUiPort, + sandboxGpuConfig, + hermesToolGateways, + ); + webSearchConfig = nextWebSearchConfig; + deps.updateSandboxRegistry(sandboxName, { + model, + provider, + ...deps.getSandboxAgentRegistryFields(agent, !fromDockerfile), + }); + deps.setDefaultSandbox(sandboxName); + session = await deps.recordStepComplete( + "sandbox", + deps.toSessionUpdates({ + sandboxName, + provider, + model, + nimContainer, + webSearchConfig, + messagingChannelConfig, + hermesToolGateways, + }), + ); + } + + if (!sandboxName) { + deps.error(" Onboarding state is incomplete after sandbox setup."); + deps.exitProcess(1); + } + const completedSandboxName = sandboxName; + if (!completedSandboxName) throw new Error("Sandbox name is required after sandbox setup"); + + return { + sandboxName: completedSandboxName, + webSearchConfig, + selectedMessagingChannels, + webSearchSupported, + session, + }; +}