diff --git a/src/lib/core/require-value.ts b/src/lib/core/require-value.ts new file mode 100644 index 0000000000..a61fe98e3c --- /dev/null +++ b/src/lib/core/require-value.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export function requireValue(value: T | null | undefined, message: string): T { + if (value === null || value === undefined) { + throw new Error(message); + } + return value; +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 17dc307835..70e36b13b5 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -15,11 +15,17 @@ const { cliName, setOnboardBrandingAgent, }: typeof import("./onboard/branding") = require("./onboard/branding"); +const { createSelectOnboardAgent }: typeof import("./onboard/agent-selection") = require("./onboard/agent-selection"); +const { + createInferenceSelectionValidationHelpers, +}: typeof import("./onboard/inference-selection-validation") = require("./onboard/inference-selection-validation"); const { cleanupTempDir }: typeof import("./onboard/temp-files") = require("./onboard/temp-files"); const { stopStaleDashboardListenersForSandbox } = require("./onboard/stale-gateway-cleanup"); +const { + ensureManagedOllamaLoopbackSystemdOverride, + ensureOllamaLoopbackSystemdOverride, +}: typeof import("./onboard/ollama-systemd") = require("./onboard/ollama-systemd"); const { bestEffortForwardStop } = require("./onboard/forward-cleanup"); -const { looksLikeForwardPortConflict, runBackgroundForwardStartWithPortReleaseRetries }: typeof import("./onboard/forward-start") = require("./onboard/forward-start"); -const { ensureManagedOllamaLoopbackSystemdOverride, ensureOllamaLoopbackSystemdOverride }: typeof import("./onboard/ollama-systemd") = require("./onboard/ollama-systemd"); const { CUSTOM_BUILD_CONTEXT_WARN_BYTES, isInsideIgnoredCustomBuildContextPath, @@ -33,14 +39,14 @@ const { }: typeof import("./onboard/compatible-endpoint-smoke") = require("./onboard/compatible-endpoint-smoke"); const { buildSandboxConfigSyncScript, - writeSandboxConfigSyncFile, runSandboxConfigSync, + writeSandboxConfigSyncFile, }: typeof import("./onboard/config-sync") = require("./onboard/config-sync"); const dockerGpuPatch: typeof import("./onboard/docker-gpu-patch") = require("./onboard/docker-gpu-patch"); const dockerGpuLocalInference: typeof import("./onboard/docker-gpu-local-inference") = require("./onboard/docker-gpu-local-inference"); const dockerGpuSandboxCreate: typeof import("./onboard/docker-gpu-sandbox-create") = require("./onboard/docker-gpu-sandbox-create"); const dockerDriverGatewayLaunch: typeof import("./onboard/docker-driver-gateway-launch") = require("./onboard/docker-driver-gateway-launch"); -const { findReadableNvidiaCdiSpecFiles, getDockerCdiSpecDirs, parseDockerCdiSpecDirs }: typeof import("./onboard/docker-cdi") = require("./onboard/docker-cdi"); +const { findReadableNvidiaCdiSpecFiles, parseDockerCdiSpecDirs }: typeof import("./onboard/docker-cdi") = require("./onboard/docker-cdi"); const { buildSandboxGpuCreateArgs, getSandboxReadyTimeoutSecs }: typeof import("./onboard/sandbox-gpu-create") = require("./onboard/sandbox-gpu-create"); const { isValidProxyHost, @@ -50,7 +56,7 @@ const { const { agentSupportsWebSearch, }: typeof import("./onboard/web-search-support") = require("./onboard/web-search-support"); -const dashboardAccess: typeof import("./onboard/dashboard-access") = require("./onboard/dashboard-access"); +const onboardDashboard: typeof import("./onboard/dashboard") = require("./onboard/dashboard"); const { buildGatewayBootstrapSecretsScript, createGatewayBootstrapRepairHelpers, @@ -81,24 +87,18 @@ const { hasWechatConfigDrift, toSessionWechatConfig, } = require("./onboard/wechat-config") as typeof import("./onboard/wechat-config"); -const { - clearAgentScopedResumeState, -}: typeof import("./onboard/agent-resume-state") = require("./onboard/agent-resume-state"); const { setupSelectedMessagingChannels, } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); -const bedrockRuntimeOnboard: typeof import("./onboard/bedrock-runtime") = - require("./onboard/bedrock-runtime"); -const { buildVllmMenuEntries }: typeof import("./onboard/vllm-menu") = require("./onboard/vllm-menu"); const { - prepareModelRouterVenv, -}: typeof import("./onboard/model-router-python") = require("./onboard/model-router-python"); + clearAgentScopedResumeState, +}: typeof import("./onboard/agent-resume-state") = require("./onboard/agent-resume-state"); const { - isProcessRunning, - isRouterHealthy, - stopModelRouterProcess, stopTrackedModelRouterForAgentChange, }: typeof import("./onboard/model-router-process") = require("./onboard/model-router-process"); +const bedrockRuntimeOnboard: typeof import("./onboard/bedrock-runtime") = + require("./onboard/bedrock-runtime"); +const { buildVllmMenuEntries }: typeof import("./onboard/vllm-menu") = require("./onboard/vllm-menu"); const { detectWindowsHostOllama, }: typeof import("./onboard/windows-host-ollama") = require("./onboard/windows-host-ollama"); @@ -135,12 +135,14 @@ const sandboxBaseImage: typeof import("./sandbox-base-image") = require("./sandb const { OPENCLAW_SANDBOX_BASE_IMAGE: SANDBOX_BASE_IMAGE, SANDBOX_BASE_TAG, - defaultOpenclawBaseDockerfile, - buildLocalBaseTag, - resolveSandboxBaseImage, } = sandboxBaseImage; +const { + getStableGatewayImageRef, + pullAndResolveBaseImageDigest, +}: typeof import("./onboard/base-image") = require("./onboard/base-image"); const errnoUtils: typeof import("./core/errno") = require("./core/errno"); const { isErrnoException } = errnoUtils; +const { requireValue }: typeof import("./core/require-value") = require("./core/require-value"); type RunnerOptions = { env?: NodeJS.ProcessEnv; @@ -151,12 +153,6 @@ type RunnerOptions = { openshellBinary?: string; }; -function requireValue(value: T | null | undefined, message: string): T { - if (value == null) { - throw new Error(message); - } - return value; -} const { collectBuildContextStats, stageOptimizedSandboxBuildContext, @@ -205,10 +201,22 @@ const { getProviderSelectionConfig, parseGatewayInference, } = inferenceConfig; -const { ensureResumeProviderReady } = require("./onboard/resume-provider-shim"); const onboardProviders = require("./onboard/providers"); +const { ensureResumeProviderReady } = require("./onboard/resume-provider-shim"); const hermesProviderAuth = require("./hermes-provider-auth"); +const hermesAuth: typeof import("./onboard/hermes-auth") = require("./onboard/hermes-auth"); +const { + HERMES_AUTH_METHOD_API_KEY, + HERMES_AUTH_METHOD_OAUTH, + HERMES_NOUS_API_KEY_CREDENTIAL_ENV, + HERMES_NOUS_API_KEY_HELP_URL, + getRequestedHermesAuthMethod, + hermesAuthMethodLabel, + normalizeHermesAuthMethod, +} = hermesAuth; + +type HermesAuthMethod = import("./onboard/hermes-auth").HermesAuthMethod; function getHermesToolGatewayBroker(): any { return require("./hermes-tool-gateway-broker"); @@ -283,14 +291,6 @@ const { resolveProviderCredential, saveCredential, } = credentials; -const credentialNavigation: typeof import("./onboard/credential-navigation") = - require("./onboard/credential-navigation"); -const { - BACK_TO_SELECTION, - createCredentialPromptHelpers, - getNavigationChoice, - isBackToSelection, -} = credentialNavigation; const { hashCredential }: typeof import("./security/credential-hash") = require("./security/credential-hash"); const { cleanupStaleHostFiles, @@ -300,6 +300,101 @@ const { resolveSandboxImageTagFromCreateOutput } = require("./domain/sandbox/image-tag") as typeof import("./domain/sandbox/image-tag"); const nim: typeof import("./inference/nim") = require("./inference/nim"); const onboardSession: typeof import("./state/onboard-session") = require("./state/onboard-session"); +const { + getFutureShellPathHint, + getPortConflictServiceHints, + printRemediationActions, +}: typeof import("./onboard/remediation") = require("./onboard/remediation"); +const resumeConfig: typeof import("./onboard/resume-config") = require("./onboard/resume-config"); +const { + getRequestedModelHint, + getRequestedProviderHint, + getRequestedSandboxNameHint, + getResumeConfigConflicts, + getResumeSandboxConflict, +} = resumeConfig; +const { pruneKnownHostsEntries }: typeof import("./onboard/known-hosts") = require("./onboard/known-hosts"); +const { + exitOnboardFromPrompt, + getNavigationChoice, + isAffirmativeAnswer, + step, + ...onboardPromptHelpers +}: typeof import("./onboard/prompt-helpers") = require("./onboard/prompt-helpers"); +const providerRecovery: typeof import("./onboard/provider-recovery") = require("./onboard/provider-recovery"); +const { createOpenclawSetup }: typeof import("./onboard/openclaw-setup") = require("./onboard/openclaw-setup"); +const { createWebSearchFlowHelpers }: typeof import("./onboard/web-search-flow") = require("./onboard/web-search-flow"); +const { + createValidationRecoveryPromptHelpers, +}: typeof import("./onboard/validation-recovery-prompt") = require("./onboard/validation-recovery-prompt"); +const { createOpenshellCliHelpers }: typeof import("./onboard/openshell-cli") = require("./onboard/openshell-cli"); +const sandboxGpuPreflight: typeof import("./onboard/sandbox-gpu-preflight") = require("./onboard/sandbox-gpu-preflight"); +const { + exitOnSandboxGpuConfigErrors, + formatSandboxGpuPassthroughNote, + resolveSandboxGpuFlagFromOptions, + sandboxGpuRemediationLines, + validateSandboxGpuPreflight, +} = sandboxGpuPreflight; +const openshellVersion: typeof import("./onboard/openshell-version") = require("./onboard/openshell-version"); +const { + getBlueprintMaxOpenshellVersion, + getBlueprintMinOpenshellVersion, + getInstalledOpenshellVersion, + isOpenshellDevVersion, + shouldAllowOpenshellAboveBlueprintMax, + shouldUseOpenshellDevChannel, + versionGte, +} = openshellVersion; +const credentialNavigation: typeof import("./onboard/credential-navigation") = + require("./onboard/credential-navigation"); +const { + BACK_TO_SELECTION, + createCredentialPromptHelpers, + isBackToSelection, +} = credentialNavigation; +const { toSessionUpdates }: typeof import("./onboard/session-updates") = require("./onboard/session-updates"); +const gatewayReuse: typeof import("./onboard/gateway-reuse") = require("./onboard/gateway-reuse"); +const messagingConfig: typeof import("./onboard/messaging-config") = require("./onboard/messaging-config"); +const { + detectMessagingCredentialRotation, + getMessagingChannelForEnvKey, + getRecordedMessagingChannelsForResume: getRecordedMessagingChannelsForResumeFromState, +}: typeof import("./onboard/messaging-credentials") = require("./onboard/messaging-credentials"); +const { + computeTelegramRequireMention, + getStoredMessagingChannelConfig, + messagingChannelConfigsEqual, + persistMessagingChannelConfigToSession, +} = messagingConfig; +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"); +const sandboxReuse: typeof import("./onboard/sandbox-reuse") = require("./onboard/sandbox-reuse"); +const { + RESERVED_SANDBOX_NAMES, + formatSandboxAgentName, + getAgentInferenceProviderOptions, + getDefaultSandboxNameForAgent, + getRequestedSandboxAgentName, + getSandboxAgentDrift, + getSandboxAgentRegistryFields, + getSandboxPromptDefault, + normalizeSandboxAgentName, +} = sandboxAgent; +const promptValidatedSandboxName = sandboxAgent.createPromptValidatedSandboxName({ + promptOrDefault, + cliDisplayName, + isNonInteractive, + exit: process.exit, +}); +const modelRouter: typeof import("./onboard/model-router") = require("./onboard/model-router"); +const { + DEFAULT_MODEL_ROUTER_CREDENTIAL_ENV, + isRoutedInferenceProvider, + loadBlueprintProfile, + reconcileModelRouter, +} = modelRouter; const { OnboardRuntimeBoundary }: typeof import("./onboard/runtime-boundary") = require("./onboard/runtime-boundary"); const { handleAgentSetupState }: typeof import("./onboard/machine/handlers/agent-setup") = require("./onboard/machine/handlers/agent-setup"); const { handleFinalizationState }: typeof import("./onboard/machine/handlers/finalization") = require("./onboard/machine/handlers/finalization"); @@ -314,8 +409,6 @@ const { ensureUsageNoticeConsent } = require("./onboard/usage-notice"); const { findAvailableDashboardPort, findDashboardForwardOwner, - getOccupiedPorts, - isLiveForwardStatus, } = require("./onboard/dashboard-port") as typeof import("./onboard/dashboard-port"); const { destroyGatewayForReuse } = require("./onboard/gateway-cleanup") as typeof import("./onboard/gateway-cleanup"); const { verifyGatewayContainerRunning } = @@ -362,7 +455,6 @@ const sandboxState: typeof import("./state/sandbox") = require("./state/sandbox" const validation: typeof import("./validation") = require("./validation"); const urlUtils: typeof import("./core/url-utils") = require("./core/url-utils"); const buildContext = require("./build-context"); -const dashboardContract: typeof import("./dashboard/contract") = require("./dashboard/contract"); const httpProbe: typeof import("./adapters/http/probe") = require("./adapters/http/probe"); const modelPrompts: typeof import("./inference/model-prompts") = require("./inference/model-prompts"); const providerModels: typeof import("./inference/provider-models") = require("./inference/provider-models"); @@ -382,14 +474,12 @@ import type { WebSearchConfig } from "./inference/web-search"; import { hydrateMessagingChannelConfig, type MessagingChannelConfig, - mergeMessagingChannelConfigs, readMessagingChannelConfigFromEnv, - sanitizeMessagingChannelConfig, } from "./messaging-channel-config"; import { streamGatewayStart } from "./onboard/gateway"; import { - HERMES_TOOL_GATEWAY_PRESET_NAMES, mergeRequiredHermesToolGatewayPolicyPresets, + normalizeHermesToolGatewaySelections, setupHermesToolGateways, stringSetsEqual, } from "./onboard/hermes-managed-tools"; @@ -421,12 +511,6 @@ import { type SandboxGpuConfig, type SandboxGpuFlag, } from "./onboard/sandbox-gpu-mode"; -import { - exitOnSandboxGpuConfigErrors, - formatSandboxGpuPassthroughNote, - sandboxGpuRemediationLines, - validateSandboxGpuPreflight, -} from "./onboard/sandbox-gpu-preflight"; import type { SelectionDrift } from "./onboard/selection-drift"; import { formatOnboardConfigSummary, formatSandboxBuildEstimateNote } from "./onboard/summary"; import type { @@ -447,13 +531,6 @@ const DIM = USE_COLOR ? "\x1b[2m" : ""; const RESET = USE_COLOR ? "\x1b[0m" : ""; let OPENSHELL_BIN: string | null = null; const GATEWAY_NAME = "nemoclaw"; -type HermesAuthMethod = "oauth" | "api_key"; -const HERMES_AUTH_METHOD_OAUTH: HermesAuthMethod = "oauth"; -const HERMES_AUTH_METHOD_API_KEY: HermesAuthMethod = "api_key"; -const HERMES_NOUS_API_KEY_CREDENTIAL_ENV = - hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV || "NOUS_API_KEY"; -const HERMES_NOUS_API_KEY_HELP_URL = "https://portal.nousresearch.com/manage-subscription"; - const OPENCLAW_LAUNCH_AGENT_PLIST = "~/Library/LaunchAgents/ai.openclaw.gateway.plist"; const BRAVE_SEARCH_HELP_URL = "https://brave.com/search/api/"; @@ -489,18 +566,6 @@ let AUTO_YES = false; // null means "use auto-allocation" (skip dashboard port check in preflight). let _preflightDashboardPort: number | null = null; -// Read TELEGRAM_REQUIRE_MENTION (set either by the interactive mention prompt -// or by the user's shell) and map it to a boolean, or null when the env var -// is unset / invalid. Used at build time to bake groupPolicy into -// openclaw.json and at resume time to detect drift against the recorded -// session state. See #1737 and the CodeRabbit follow-up on #2417. -function computeTelegramRequireMention(): boolean | null { - const raw = process.env.TELEGRAM_REQUIRE_MENTION; - if (raw === "1") return true; - if (raw === "0") return false; - return null; -} - function isNonInteractive(): boolean { return NON_INTERACTIVE || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; } @@ -517,52 +582,44 @@ function note(message: string): void { console.log(`${DIM}${message}${RESET}`); } -// Prompt wrapper: returns env var value or default in non-interactive mode, -// otherwise prompts the user interactively. +const promptHelperDeps = { isNonInteractive, note, prompt }; + async function promptOrDefault( question: string, envVar: string | null, defaultValue: string, ): Promise { - if (isNonInteractive()) { - const val = envVar ? process.env[envVar] : null; - const result = val || defaultValue; - note(` [non-interactive] ${question.trim()} → ${result}`); - return result; - } - return prompt(question); + return onboardPromptHelpers.promptOrDefault(promptHelperDeps, question, envVar, defaultValue); } -// Yes/no prompt with a typed default. The `[Y/n]` / `[y/N]` indicator and -// the non-interactive echo letter are both derived from `defaultIsYes`, so -// the case of the indicator and the echoed default cannot drift apart. -// Returns a boolean — callers no longer have to parse reply strings. -// Replies of "y"/"yes" and "n"/"no" win regardless of case; empty and -// unknown input fall back to the default. async function promptYesNoOrDefault( question: string, envVar: string | null, defaultIsYes: boolean, ): Promise { - const fullQuestion = `${question} ${defaultIsYes ? "[Y/n]" : "[y/N]"}: `; - const nonInteractive = isNonInteractive(); - const input = nonInteractive ? (envVar ? process.env[envVar] : null) : await prompt(fullQuestion); - - const value = String(input ?? "") - .trim() - .toLowerCase(); - let chosen = defaultIsYes; - if (value === "y" || value === "yes") chosen = true; - else if (value === "n" || value === "no") chosen = false; - - if (nonInteractive) { - note(` [non-interactive] ${fullQuestion.trim()} → ${chosen ? "Y" : "N"}`); - } - return chosen; + return onboardPromptHelpers.promptYesNoOrDefault(promptHelperDeps, question, envVar, defaultIsYes); } // ── Helpers ────────────────────────────────────────────────────── +const { + getOpenshellBinary, + openshellShellCommand, + openshellArgv, + runOpenshell, + runCaptureOpenshell, + safeOpenShellArgument, + getGatewayPortArg, + getDockerDriverGatewayEndpointArg, +} = createOpenshellCliHelpers({ + getCachedBinary: () => OPENSHELL_BIN, + setCachedBinary: (binary: string) => { + OPENSHELL_BIN = binary; + }, + getGatewayPort: () => GATEWAY_PORT, + getDockerDriverGatewayEndpoint, +}); + // Gateway state functions — delegated to src/lib/state/gateway.ts const { isSandboxReady, @@ -571,1092 +628,152 @@ const { isSelectedGateway, isGatewayHealthy, getGatewayReuseState, - shouldSelectNamedGatewayForReuse, getSandboxStateFromOutputs, } = gatewayState; -type GatewayReuseSnapshot = { - gatewayStatus: string; - gwInfo: string; - activeGatewayInfo: string; - gatewayReuseState: ReturnType; -}; - -function getGatewayReuseSnapshot(): GatewayReuseSnapshot { - const gatewayStatus = runCaptureOpenshell(["status"], { ignoreError: true }); - const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { - ignoreError: true, - }); - const activeGatewayInfo = runCaptureOpenshell(["gateway", "info"], { ignoreError: true }); - return { - gatewayStatus, - gwInfo, - activeGatewayInfo, - gatewayReuseState: getGatewayReuseState(gatewayStatus, gwInfo, activeGatewayInfo), - }; -} - -function selectNamedGatewayForReuseIfNeeded(snapshot: GatewayReuseSnapshot): GatewayReuseSnapshot { - if ( - !shouldSelectNamedGatewayForReuse( - snapshot.gatewayStatus, - snapshot.gwInfo, - snapshot.activeGatewayInfo, - ) - ) { - return snapshot; - } - - const selectResult = runOpenshell(["gateway", "select", GATEWAY_NAME], { - ignoreError: true, - suppressOutput: true, +const { getGatewayReuseSnapshot, selectNamedGatewayForReuseIfNeeded } = + gatewayReuse.createGatewayReuseHelpers({ + gatewayName: GATEWAY_NAME, + runCaptureOpenshell, + runOpenshell, + cliDisplayName, }); - if (selectResult.status !== 0) { - return snapshot; - } - - const refreshed = getGatewayReuseSnapshot(); - if (refreshed.gatewayReuseState === "healthy") { - process.env.OPENSHELL_GATEWAY = GATEWAY_NAME; - console.log(` ✓ Selected existing ${cliDisplayName()} gateway`); - } - return refreshed; -} - -/** - * Remove known_hosts lines whose host field contains an openshell-* entry. - * Preserves blank lines and comments. Returns the cleaned string. - */ -function pruneKnownHostsEntries(contents: string): string { - return contents - .split("\n") - .filter((l) => { - const trimmed = l.trim(); - if (!trimmed || trimmed.startsWith("#")) return true; - const hostField = trimmed.split(/\s+/)[0]; - return !hostField.split(",").some((h) => h.startsWith("openshell-")); - }) - .join("\n"); -} - -function getSandboxReuseState(sandboxName: string | null) { - if (!sandboxName) return "missing"; - const getOutput = runCaptureOpenshell(["sandbox", "get", sandboxName], { ignoreError: true }); - const listOutput = runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); - return getSandboxStateFromOutputs(sandboxName, getOutput, listOutput); -} -function repairRecordedSandbox(sandboxName: string | null): void { - if (!sandboxName) return; - note(` [resume] Cleaning up recorded sandbox '${sandboxName}' before recreating it.`); - bestEffortForwardStop(runOpenshell, DASHBOARD_PORT); - runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); - registry.removeSandbox(sandboxName); -} +const { getSandboxReuseState, repairRecordedSandbox } = sandboxReuse.createSandboxReuseHelpers({ + runCaptureOpenshell, + runOpenshell, + getSandboxStateFromOutputs, + note, +}); const { streamSandboxCreate } = sandboxCreateStream; -function step(n: number, total: number, msg: string): void { - console.log(""); - console.log(` [${n}/${total}] ${msg}`); - console.log(` ${"─".repeat(50)}`); -} +const { executeSandboxCommandForVerification }: typeof import("./onboard/sandbox-verification-exec") = + require("./onboard/sandbox-verification-exec"); -function getInstalledOpenshellVersion(versionOutput: string | null = null): string | null { - const openshellBin = resolveOpenshell(); - if (!versionOutput && !openshellBin) return null; - const output = String( - versionOutput ?? runCapture([openshellBin, "-V"], { ignoreError: true }), - ).trim(); - const match = output.match(/openshell\s+([0-9]+\.[0-9]+\.[0-9]+)/i); - if (match) return match[1]; - return null; -} +// URL/string utilities — delegated to src/lib/core/url-utils.ts +const { + compactText, + normalizeProviderBaseUrl, + isLoopbackHostname, + formatEnvAssignment, + parsePolicyPresetEnv, +} = urlUtils; +const { hydrateCredentialEnv }: typeof import("./onboard/credential-env") = + require("./onboard/credential-env"); -/** - * Compare two semver-like x.y.z strings. Returns true iff `left >= right`. - * Non-numeric or missing components are treated as 0. - */ -function versionGte(left = "0.0.0", right = "0.0.0"): boolean { - const lhs = String(left) - .split(".") - .map((part) => Number.parseInt(part, 10) || 0); - const rhs = String(right) - .split(".") - .map((part) => Number.parseInt(part, 10) || 0); - const length = Math.max(lhs.length, rhs.length); - for (let index = 0; index < length; index += 1) { - const a = lhs[index] || 0; - const b = rhs[index] || 0; - if (a > b) return true; - if (a < b) return false; - } - return true; -} +const { + summarizeCurlFailure, + summarizeProbeFailure, + runCurlProbe, +} = httpProbe; -/** - * Read a semver field from nemoclaw-blueprint/blueprint.yaml. Returns null if - * the blueprint or field is missing or unparseable — callers must treat null - * as "no constraint configured" so a malformed install does not become a hard - * onboard blocker. See #1317. - */ -function getBlueprintVersionField(field: string, rootDir = ROOT): string | null { - try { - // Lazy require: yaml is already a dependency via the policy helpers but - // pulling it at module load would slow down `nemoclaw --help` for users - // who never reach the preflight path. - const YAML = require("yaml"); - const blueprintPath = path.join(rootDir, "nemoclaw-blueprint", "blueprint.yaml"); - if (!fs.existsSync(blueprintPath)) return null; - const raw = fs.readFileSync(blueprintPath, "utf8"); - const parsed = YAML.parse(raw); - const value = parsed && parsed[field]; - if (typeof value !== "string") return null; - const trimmed = value.trim(); - if (!/^[0-9]+\.[0-9]+\.[0-9]+/.test(trimmed)) return null; - return trimmed; - } catch { - return null; - } -} +const selectOnboardAgent = createSelectOnboardAgent({ + resolveAgent: agentOnboard.resolveAgent, + loadAgent: agentDefs.loadAgent, + isNonInteractive, + note, +}); -function getBlueprintMinOpenshellVersion(rootDir = ROOT): string | null { - return getBlueprintVersionField("min_openshell_version", rootDir); -} -function getBlueprintMaxOpenshellVersion(rootDir = ROOT): string | null { - return getBlueprintVersionField("max_openshell_version", rootDir); -} +const { getTransportRecoveryMessage, getProbeRecovery } = validationRecovery; -type OpenshellChannel = "stable" | "dev" | "auto"; +// Validation functions — delegated to src/lib/validation.ts +const { + classifyValidationFailure, + classifyApplyFailure, + classifySandboxCreateFailure, + validateNvidiaApiKeyValue, + isSafeModelId, + shouldSkipResponsesProbe, + shouldForceCompletionsApi, +} = validation; -/** - * Load a named inference profile and router config from blueprint.yaml. - * Returns null if the blueprint or profile is missing. - */ -type BlueprintRouterConfig = { - enabled?: boolean; - port?: number; - pool_config_path?: string; - credential_env?: string; -}; +// validateNvidiaApiKeyValue — see validation import above -type BlueprintInferenceProfile = { - provider_name?: string; - endpoint?: string; - model: string; - credential_env?: string; - credential_default?: string; - router: BlueprintRouterConfig; -}; +const credentialPrompt = createCredentialPromptHelpers(exitOnboardFromPrompt); +const replaceNamedCredential = credentialPrompt.replaceNamedCredential; -function loadBlueprintProfile( - profileName: string, - rootDir: string = ROOT, -): BlueprintInferenceProfile | null { - try { - const YAML = require("yaml"); - const blueprintPath = path.join(rootDir, "nemoclaw-blueprint", "blueprint.yaml"); - if (!fs.existsSync(blueprintPath)) return null; - const raw = fs.readFileSync(blueprintPath, "utf8"); - const parsed = YAML.parse(raw); - const profile = parsed?.components?.inference?.profiles?.[profileName]; - if (!profile) return null; - const router = { ...(parsed?.components?.router || {}) }; - if (typeof profile.credential_env === "string" && profile.credential_env.trim().length > 0) { - router.credential_env = profile.credential_env; - } - return { ...profile, router } as BlueprintInferenceProfile; - } catch { - return null; - } -} +const { + promptHermesAuthMethod, + resolveHermesNousApiKey, + stageNousApiKeyProviderEnv, + ensureHermesNousApiKeyEnv, + openshellResultMessage, + checkHermesProviderStoreReachable, +} = hermesAuth.createHermesAuthHelpers({ + isNonInteractive, + note, + prompt, + getNavigationChoice, + exitOnboardFromPrompt, + validateNvidiaApiKeyValue: (value: string, envName: string) => + validateNvidiaApiKeyValue(value, envName), + compactText, + redact, + runOpenshell, + backToSelection: BACK_TO_SELECTION, +}); -const ROUTER_HEALTH_RETRIES = 15; -const ROUTER_HEALTH_INTERVAL_MS = 2000; -const MODEL_ROUTER_RELATIVE_DIR = path.join("nemoclaw-blueprint", "router", "llm-router"); -const MODEL_ROUTER_VENV_DIR = path.join(os.homedir(), ".nemoclaw", "model-router-venv"); -const MODEL_ROUTER_FINGERPRINT_FILE = ".nemoclaw-source-fingerprint"; -const MODEL_ROUTER_FINGERPRINT_IGNORED_NAMES = new Set([ - ".git", - ".hg", - ".mypy_cache", - ".pytest_cache", - ".ruff_cache", - ".svn", - ".venv", - "__pycache__", - "build", - "dist", - "node_modules", - "venv", -]); -const DEFAULT_MODEL_ROUTER_CREDENTIAL_ENV = "NVIDIA_API_KEY"; - -function resolveHostCommandPath(commandName: string): string | null { - const result = runCapture(["sh", "-c", 'command -v "$1"', "--", commandName], { - ignoreError: true, - }).trim(); - return result || null; -} +const { promptValidationRecovery } = createValidationRecoveryPromptHelpers({ + isNonInteractive, + prompt, + validateNvidiaApiKeyValue: (key: string, credentialEnv: string | null) => + validateNvidiaApiKeyValue(key, credentialEnv ?? undefined), + getTransportRecoveryMessage: (failure: any) => getTransportRecoveryMessage(failure), + exitOnboardFromPrompt, +}); -function modelRouterPackageDir(): string { - return path.join(ROOT, MODEL_ROUTER_RELATIVE_DIR); -} +// Provider CRUD — thin wrappers that inject runOpenshell to avoid circular deps. +const { buildProviderArgs } = onboardProviders; -function modelRouterVenvDir(): string { - return process.env.NEMOCLAW_MODEL_ROUTER_VENV || MODEL_ROUTER_VENV_DIR; -} +// Snapshot of legacy {env-key → value} pairs that stageLegacyCredentialsToEnv() +// imported from ~/.nemoclaw/credentials.json at the start of this run. +// Captured by the onboard() entry point; consulted by the upsertProvider / +// upsertMessagingProviders wrappers below to decide whether a successful +// gateway upsert actually migrated the *legacy* value (vs. e.g. a vllm/ollama +// branch that upserts a placeholder under the same env-key name). +const stagedLegacyValues: Map = new Map(); -function modelRouterCommandPath(venvDir = modelRouterVenvDir()): string { - return path.join(venvDir, "bin", "model-router"); -} +// Env-keys whose successful gateway upsert actually used the staged legacy +// value. Seeded from the persisted onboard session at the start of every +// run so a `--resume` invocation that skips already-completed upserts still +// remembers the migrations the prior attempt committed. The post-onboard +// legacy-file cleanup is gated on `stagedLegacyKeys ⊆ migratedLegacyKeys` +// so picking a local inference provider, disabling a preselected messaging +// channel, or any other path that upserts a different value under the same +// env-key name leaves the file alone instead of stranding the user's only +// copy. +const migratedLegacyKeys: Set = new Set(); -function modelRouterFingerprintPath(venvDir = modelRouterVenvDir()): string { - return path.join(venvDir, MODEL_ROUTER_FINGERPRINT_FILE); +// SHA-256 hex digest of `value`. Used to fingerprint migrated legacy +// secrets in the persisted onboard session so a later `--resume` can +// detect when the legacy file value was edited between runs (or another +// session is on disk with stale entries) and refuse to inherit a stale +// "migrated" mark. +function legacyValueHash(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); } -function isExecutableFile(filePath: string): boolean { +// Mirror the in-memory `migratedLegacyKeys` set into the persisted onboard +// session along with each entry's value hash. `--resume` invocations that +// skip the upsert wrappers entirely use this to inherit migration state +// from the previous attempt — but only when the staged value at restore +// time still hashes to the same digest, so an edit to the legacy file or +// an out-of-band gateway reset cannot satisfy the cleanup gate. +function persistMigratedLegacyKeys(): void { try { - fs.accessSync(filePath, fs.constants.X_OK); - return true; - } catch { - return false; - } -} - -function isModelRouterPackageReady(routerDir = modelRouterPackageDir()): boolean { - return fs.existsSync(path.join(routerDir, "pyproject.toml")) || - fs.existsSync(path.join(routerDir, "setup.py")); -} - -function shouldSkipModelRouterFingerprintEntry(name: string): boolean { - return MODEL_ROUTER_FINGERPRINT_IGNORED_NAMES.has(name) || name.endsWith(".egg-info"); -} - -function hashModelRouterSourceTree(routerDir = modelRouterPackageDir()): string | null { - const sourceHash = crypto.createHash("sha256"); - - const hashDirectory = (currentDir: string): boolean => { - let entries: import("fs").Dirent[]; - try { - entries = fs - .readdirSync(currentDir, { withFileTypes: true }) - .sort((left: import("fs").Dirent, right: import("fs").Dirent) => - left.name.localeCompare(right.name), - ); - } catch { - return false; - } - - let hashedSourceFile = false; - for (const entry of entries) { - if (shouldSkipModelRouterFingerprintEntry(entry.name)) continue; - if (entry.name.endsWith(".pyc") || entry.name.endsWith(".pyo")) continue; - - const entryPath = path.join(currentDir, entry.name); - const relativePath = path.relative(routerDir, entryPath).split(path.sep).join("/"); - if (entry.isDirectory()) { - hashedSourceFile = hashDirectory(entryPath) || hashedSourceFile; - continue; - } - if (entry.isSymbolicLink()) { - try { - sourceHash.update(`link:${relativePath}\0`); - sourceHash.update(fs.readlinkSync(entryPath)); - sourceHash.update("\0"); - hashedSourceFile = true; - } catch { - // Ignore unreadable links; the install step will fail if they are required. - } - continue; - } - if (!entry.isFile()) continue; - sourceHash.update(`file:${relativePath}\0`); - sourceHash.update(fs.readFileSync(entryPath)); - sourceHash.update("\0"); - hashedSourceFile = true; - } - return hashedSourceFile; - }; - - return hashDirectory(routerDir) ? `files:${sourceHash.digest("hex")}` : null; -} - -function getModelRouterSourceFingerprint(routerDir = modelRouterPackageDir()): string | null { - const gitHead = runCapture(["git", "-C", routerDir, "rev-parse", "HEAD"], { - ignoreError: true, - }).trim(); - if (/^[0-9a-f]{40}$/i.test(gitHead)) return `git:${gitHead}`; - - const gitLink = runCapture(["git", "-C", ROOT, "rev-parse", `HEAD:${MODEL_ROUTER_RELATIVE_DIR}`], { - ignoreError: true, - }).trim(); - if (/^[0-9a-f]{40}$/i.test(gitLink)) return `gitlink:${gitLink}`; - - return hashModelRouterSourceTree(routerDir); -} - -function readModelRouterInstalledFingerprint(venvDir = modelRouterVenvDir()): string | null { - try { - const fingerprint = fs.readFileSync(modelRouterFingerprintPath(venvDir), "utf8").trim(); - return fingerprint || null; - } catch { - return null; - } -} - -function writeModelRouterInstalledFingerprint( - fingerprint: string | null, - venvDir = modelRouterVenvDir(), -): void { - if (!fingerprint) return; - fs.writeFileSync(modelRouterFingerprintPath(venvDir), `${fingerprint}\n`, { mode: 0o600 }); -} - -function isManagedModelRouterCurrent( - routerDir = modelRouterPackageDir(), - venvDir = modelRouterVenvDir(), -): boolean { - if (!isExecutableFile(modelRouterCommandPath(venvDir))) return false; - const sourceFingerprint = getModelRouterSourceFingerprint(routerDir); - return Boolean( - sourceFingerprint && readModelRouterInstalledFingerprint(venvDir) === sourceFingerprint, - ); -} - -function initializeModelRouterSubmodule(routerDir = modelRouterPackageDir()): void { - if (isModelRouterPackageReady(routerDir)) return; - if (!fs.existsSync(path.join(ROOT, ".gitmodules")) || !fs.existsSync(path.join(ROOT, ".git"))) { - return; - } - console.log(" Initializing Model Router source..."); - run(["git", "-C", ROOT, "submodule", "update", "--init", "--depth", "1", MODEL_ROUTER_RELATIVE_DIR], { - ignoreError: true, - }); -} - -function installModelRouterCommand(routerDir = modelRouterPackageDir()): string { - initializeModelRouterSubmodule(routerDir); - if (!isModelRouterPackageReady(routerDir)) { - throw new Error( - `Model Router source is not initialized at ${routerDir}. ` + - `Run: git -C ${ROOT} submodule update --init --depth 1 ${MODEL_ROUTER_RELATIVE_DIR}`, - ); - } - - const venvDir = modelRouterVenvDir(); - const routerCommand = modelRouterCommandPath(venvDir); - const sourceFingerprint = getModelRouterSourceFingerprint(routerDir); - const allowReplaceExistingVenv = - path.resolve(venvDir) === path.resolve(MODEL_ROUTER_VENV_DIR) || - readModelRouterInstalledFingerprint(venvDir) !== null; - const venvPython = prepareModelRouterVenv({ - venvDir, - allowReplaceExisting: allowReplaceExistingVenv, - }); - - const installResult = run( - [venvPython, "-m", "pip", "install", "--quiet", "--upgrade", `${routerDir}[prefill,proxy]`], - { - ignoreError: true, - timeout: 600_000, - }, - ); - if (installResult.status !== 0) { - throw new Error("Failed to install Model Router dependencies."); - } - if (!isExecutableFile(routerCommand)) { - throw new Error("Model Router install did not produce the model-router command."); - } - writeModelRouterInstalledFingerprint(sourceFingerprint, venvDir); - return routerCommand; -} - -function ensureModelRouterCommand(): string { - const routerDir = modelRouterPackageDir(); - const venvDir = modelRouterVenvDir(); - const managedCommand = modelRouterCommandPath(venvDir); - - if (isModelRouterPackageReady(routerDir) && isManagedModelRouterCurrent(routerDir, venvDir)) { - return managedCommand; - } - - if (!isModelRouterPackageReady(routerDir)) { - initializeModelRouterSubmodule(routerDir); - } - - if (isModelRouterPackageReady(routerDir)) { - if (isManagedModelRouterCurrent(routerDir, venvDir)) return managedCommand; - return installModelRouterCommand(routerDir); - } - - if (isExecutableFile(managedCommand)) return managedCommand; - return resolveHostCommandPath("model-router") || installModelRouterCommand(); -} - -/** - * Start the model-router proxy and wait for it to become healthy. - * Follows the same pattern as Ollama startup (spawn detached, poll health). - * Returns the PID of the child process. - */ -async function startModelRouter(routerCfg: BlueprintRouterConfig): Promise { - const routerCommand = ensureModelRouterCommand(); - const port = routerCfg.port || 4000; - const blueprintDir = path.join(ROOT, "nemoclaw-blueprint"); - const poolConfigPath = path.join( - blueprintDir, - routerCfg.pool_config_path || "router/pool-config.yaml", - ); - const stateDir = path.join(os.homedir(), ".nemoclaw", "state"); - const litellmConfigPath = path.join(stateDir, "litellm-proxy.yaml"); - - fs.mkdirSync(stateDir, { recursive: true }); - - const proxyConfigResult = spawnSync( - routerCommand, - ["proxy-config", "--config", poolConfigPath, "--output", litellmConfigPath], - { encoding: "utf8", timeout: 30_000, cwd: blueprintDir }, - ); - if (proxyConfigResult.status !== 0) { - throw new Error( - `model-router proxy-config failed: ${proxyConfigResult.stderr || proxyConfigResult.error || "unknown error"}`, - ); - } - - const { buildSubprocessEnv } = require("./subprocess-env"); - const credEnvVars: Record = {}; - const credName = routerCfg.credential_env || DEFAULT_MODEL_ROUTER_CREDENTIAL_ENV; - const routedCredential = resolveProviderCredential(credName); - const openAiCredential = resolveProviderCredential("OPENAI_API_KEY"); - if (routedCredential) { - credEnvVars[credName] = routedCredential; - if (!openAiCredential) credEnvVars.OPENAI_API_KEY = routedCredential; - } - if (openAiCredential) credEnvVars.OPENAI_API_KEY = openAiCredential; - const _providerKey = (process.env.NEMOCLAW_PROVIDER_KEY || "").trim(); - if (_providerKey) { - if (!credEnvVars[credName]) credEnvVars[credName] = _providerKey; - if (!credEnvVars.OPENAI_API_KEY) credEnvVars.OPENAI_API_KEY = _providerKey; - } - - if (await isRouterHealthy(port)) { - throw new Error( - `Port ${port} already has a healthy router endpoint; refusing to start a second router.`, - ); - } - - const child = spawn( - routerCommand, - [ - "proxy", - "--litellm-config", litellmConfigPath, - "--router-config", poolConfigPath, - "--host", "0.0.0.0", - "--port", String(port), - ], - { - detached: true, - stdio: "ignore", - cwd: blueprintDir, - env: buildSubprocessEnv(credEnvVars), - }, - ); - let childExited = false; - let childExitDetail = ""; - child.once("error", (err: Error) => { - childExited = true; - childExitDetail = `child failed to start: ${err.message}`; - }); - child.once("exit", (code: number | null, signal: string | null) => { - childExited = true; - if (!childExitDetail) { - childExitDetail = `child exited with code ${code ?? "null"}${signal ? ` signal ${signal}` : ""}`; - } - }); - child.unref(); - - const pid = child.pid; - if (!pid) { - throw new Error( - "Failed to start model-router proxy: no PID returned" + - (childExitDetail ? ` (${childExitDetail})` : ""), - ); - } - - for (let attempt = 0; attempt < ROUTER_HEALTH_RETRIES; attempt++) { - await new Promise((resolve) => setTimeout(resolve, ROUTER_HEALTH_INTERVAL_MS)); - if (childExited) break; - const healthy = await isRouterHealthy(port); - let processAlive = true; - try { - process.kill(pid, 0); - } catch { - processAlive = false; - } - if (healthy && processAlive) return pid; - if (!processAlive) { - childExited = true; - if (!childExitDetail) childExitDetail = "child process is no longer running"; - break; - } - } - try { - process.kill(pid, "SIGTERM"); - } catch { - // already dead - } - throw new Error( - `Model router failed to become healthy on port ${port} after ${ROUTER_HEALTH_RETRIES} attempts` + - (childExitDetail ? ` (${childExitDetail})` : ""), - ); -} - -function getRoutedProfile(): BlueprintInferenceProfile { - const bp = loadBlueprintProfile("routed"); - if (!bp || bp.router?.enabled !== true) { - throw new Error("Router is not enabled in nemoclaw-blueprint/blueprint.yaml."); - } - return bp; -} - -function isRoutedInferenceProvider(provider: string | null | undefined): boolean { - if (!provider) return false; - if (provider === "nvidia-router") return true; - const bp = loadBlueprintProfile("routed"); - return Boolean(bp?.provider_name && provider === bp.provider_name); -} - -async function reconcileModelRouter(): Promise { - const bp = getRoutedProfile(); - const routerPort = bp.router.port || 4000; - const routerCredentialEnv = - bp.router.credential_env || bp.credential_env || DEFAULT_MODEL_ROUTER_CREDENTIAL_ENV; - const routerCredential = - hydrateCredentialEnv(routerCredentialEnv) || - normalizeCredentialValue(bp.credential_default || ""); - if (!routerCredential) { - throw new Error(`${routerCredentialEnv} is required to start Model Router.`); - } - saveCredential(routerCredentialEnv, routerCredential); - const routerCredentialHash = hashCredential(routerCredential); - const session = onboardSession.loadSession(); - const recordedPid = session?.routerPid ?? null; - const recordedCredentialHash = session?.routerCredentialHash ?? null; - - if (await isRouterHealthy(routerPort)) { - if ( - routerCredentialHash && - recordedCredentialHash === routerCredentialHash && - isProcessRunning(recordedPid) - ) { - console.log(` ✓ Model router is already healthy on port ${routerPort}`); - return; - } - if (isProcessRunning(recordedPid)) { - console.log(" Restarting model router with updated credentials..."); - await stopModelRouterProcess(requireValue(recordedPid, "Expected recorded router PID"), routerPort); - } else { - throw new Error( - `Port ${routerPort} already has a healthy router endpoint, but its credential state is unknown. Stop the existing model-router process and rerun onboarding.`, - ); - } - } - - console.log(" Starting model router..."); - const routerPid = await startModelRouter(bp.router); - console.log(` ✓ Model router started (PID ${routerPid}) on port ${routerPort}`); - onboardSession.updateSession((current: Session) => { - current.routerPid = routerPid; - current.routerCredentialHash = routerCredentialHash; - return current; - }); -} - -function getOpenshellChannel(env: NodeJS.ProcessEnv = process.env): OpenshellChannel { - const raw = String(env.NEMOCLAW_OPENSHELL_CHANNEL || "auto") - .trim() - .toLowerCase(); - if (raw === "stable" || raw === "dev" || raw === "auto") return raw; - return "auto"; -} - -function shouldUseOpenshellDevChannel( - _platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, -): boolean { - const channel = getOpenshellChannel(env); - return channel === "dev"; -} - -function isOpenshellDevVersion(versionOutput: string | null | undefined): boolean { - return /\bdev[0-9.]*/i.test(String(versionOutput || "")); -} - -function shouldAllowOpenshellAboveBlueprintMax( - versionOutput: string | null | undefined, - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, -): boolean { - return shouldUseOpenshellDevChannel(platform, env) && isOpenshellDevVersion(versionOutput); -} - -function resolveSandboxGpuFlagFromOptions( - opts: Pick, -): SandboxGpuFlag { - const requestedGpuPassthrough = opts.gpu === true; - const optedOutGpuPassthrough = opts.noGpu === true; - const sandboxGpuFlag = opts.sandboxGpu ?? null; - if (requestedGpuPassthrough && optedOutGpuPassthrough) { - console.error(" --gpu and --no-gpu cannot both be set."); - process.exit(1); - } - if ( - (requestedGpuPassthrough && sandboxGpuFlag === "disable") || - (optedOutGpuPassthrough && sandboxGpuFlag === "enable") - ) { - console.error(" --gpu/--no-gpu conflict with the sandbox GPU flags."); - process.exit(1); - } - if (sandboxGpuFlag) return sandboxGpuFlag; - if (requestedGpuPassthrough) return "enable"; - if (optedOutGpuPassthrough) return "disable"; - return null; -} - -// ── Base image resolution ─────────────────────────────────────── -// Pulls candidate sandbox-base images from GHCR and inspects them to get the -// actual repo digest when available. This avoids the registry mismatch that -// broke e2e tests in #1937 while still allowing PR branches to use a source-SHA -// base image or local build before latest has been rebuilt. See #1904. - -/** - * Resolve a compatible sandbox-base image and pin it to a repo digest when - * possible. PR-branch validation first tries a source-SHA tag, then latest, - * and finally a local Dockerfile.base build when the OpenShell Docker driver - * requires a newer glibc than the published image provides. - */ -function pullAndResolveBaseImageDigest( - options: { requireOpenshellSandboxAbi?: boolean } = {}, -): { digest: string | null; ref: string; source?: string; glibcVersion?: string | null } | null { - return resolveSandboxBaseImage({ - imageName: SANDBOX_BASE_IMAGE, - dockerfilePath: defaultOpenclawBaseDockerfile(ROOT), - localTag: buildLocalBaseTag("nemoclaw-sandbox-base-local", ROOT), - envVar: "NEMOCLAW_SANDBOX_BASE_IMAGE_REF", - label: "OpenClaw sandbox base image", - requireOpenshellSandboxAbi: options.requireOpenshellSandboxAbi === true, - rootDir: ROOT, - }); -} - -function getStableGatewayImageRef(versionOutput: string | null = null): string | null { - const version = getInstalledOpenshellVersion(versionOutput); - if (!version) return null; - return `ghcr.io/nvidia/openshell/cluster:${version}`; -} - -function getOpenshellBinary(): string { - if (OPENSHELL_BIN) return OPENSHELL_BIN; - const resolved = resolveOpenshell(); - if (typeof resolved !== "string" || resolved.length === 0) { - console.error(" openshell CLI not found."); - console.error(" Install manually: https://github.com/NVIDIA/OpenShell/releases"); - process.exit(1); - } - OPENSHELL_BIN = resolved; - return OPENSHELL_BIN; -} - -function openshellShellCommand(args: string[], options: { openshellBinary?: string } = {}): string { - const openshellBinary = options.openshellBinary || getOpenshellBinary(); - return [shellQuote(openshellBinary), ...args.map((arg) => shellQuote(arg))].join(" "); -} - -function openshellArgv(args: string[], options: { openshellBinary?: string } = {}): string[] { - const openshellBinary = options.openshellBinary || getOpenshellBinary(); - return [openshellBinary, ...args]; -} - -function runOpenshell(args: string[], opts: RunnerOptions & { openshellBinary?: string } = {}) { - return run(openshellArgv(args, opts), opts); -} - -function runCaptureOpenshell( - args: string[], - opts: RunnerOptions & { openshellBinary?: string } = {}, -) { - return runCapture(openshellArgv(args, opts), opts); -} - -function safeOpenShellArgument(value: string, label: string): string { - if (!/^[A-Za-z0-9._~:/-]+$/.test(value)) { - throw new Error(`Invalid ${label}: contains characters unsafe for OpenShell CLI args`); - } - return value; -} - -function getGatewayPortArg(): string { - return safeOpenShellArgument(String(GATEWAY_PORT), "gateway port"); -} - -function getDockerDriverGatewayEndpointArg(): string { - return safeOpenShellArgument(getDockerDriverGatewayEndpoint(), "gateway endpoint"); -} - -const { executeSandboxCommandForVerification }: typeof import("./onboard/sandbox-verification-exec") = - require("./onboard/sandbox-verification-exec"); - -// URL/string utilities — delegated to src/lib/core/url-utils.ts -const { - compactText, - normalizeProviderBaseUrl, - isLoopbackHostname, - formatEnvAssignment, - parsePolicyPresetEnv, -} = urlUtils; -const { hydrateCredentialEnv }: typeof import("./onboard/credential-env") = - require("./onboard/credential-env"); - -function normalizeHermesToolGatewaySelections(value: unknown): string[] { - if (!Array.isArray(value)) return []; - const selected = new Set(); - for (const preset of value) { - if (typeof preset === "string" && HERMES_TOOL_GATEWAY_PRESET_NAMES.has(preset)) { - selected.add(preset); - } - } - return [...selected].sort(); -} - -const { - summarizeCurlFailure, - summarizeProbeFailure, - runCurlProbe, -} = httpProbe; - -function exitOnboardFromPrompt(): never { - console.log(" Exiting onboarding."); - process.exit(1); -} - -const credentialPrompt = createCredentialPromptHelpers(exitOnboardFromPrompt); -const replaceNamedCredential = credentialPrompt.replaceNamedCredential; - -function normalizeHermesAuthMethod(value: string | null | undefined): HermesAuthMethod | null { - const normalized = String(value || "") - .trim() - .toLowerCase() - .replace(/[\s-]+/g, "_"); - if (!normalized) return null; - if (normalized === "oauth" || normalized === "nous_oauth" || normalized === "nous_portal_oauth") { - return HERMES_AUTH_METHOD_OAUTH; - } - if ( - normalized === "api" || - normalized === "key" || - normalized === "api_key" || - normalized === "apikey" || - normalized === "nous_api_key" - ) { - return HERMES_AUTH_METHOD_API_KEY; - } - return null; -} - -function hermesAuthMethodLabel(method: HermesAuthMethod | null | undefined): string { - return method === HERMES_AUTH_METHOD_API_KEY ? "Nous API Key" : "Nous Portal OAuth"; -} - -function getRequestedHermesAuthMethod(): HermesAuthMethod | null { - const raw = - process.env.NEMOCLAW_HERMES_AUTH_METHOD || - process.env.NEMOCLAW_HERMES_AUTH || - process.env.NEMOCLAW_NOUS_AUTH_METHOD || - ""; - const method = normalizeHermesAuthMethod(raw); - if (!raw || method) return method; - console.error(` Unsupported Hermes Provider auth method: ${raw}`); - console.error(" Valid values: oauth, nous-portal-oauth, api-key, nous-api-key"); - process.exit(1); -} - -async function promptHermesAuthMethod(): Promise { - const methods: Array<{ key: HermesAuthMethod; label: string }> = [ - { key: HERMES_AUTH_METHOD_OAUTH, label: "Nous Portal OAuth (authenticate via browser)" }, - { - key: HERMES_AUTH_METHOD_API_KEY, - label: "Nous API Key (paste a key from the provider dashboard)", - }, - ]; - const requested = getRequestedHermesAuthMethod(); - if (isNonInteractive()) { - const method = - requested || - (resolveHermesNousApiKey() - ? HERMES_AUTH_METHOD_API_KEY - : HERMES_AUTH_METHOD_OAUTH); - note(` [non-interactive] Hermes auth: ${hermesAuthMethodLabel(method)}`); - return method; - } - - console.log(""); - console.log(" Hermes Provider authentication:"); - methods.forEach((method, index) => { - console.log(` ${index + 1}) ${method.label}`); - }); - console.log(""); - - const defaultIdx = (requested ? methods.findIndex((method) => method.key === requested) : 0) + 1; - const choice = await prompt(` Choose [${defaultIdx}]: `); - const navigation = getNavigationChoice(choice); - if (navigation === "back") return BACK_TO_SELECTION; - if (navigation === "exit") exitOnboardFromPrompt(); - const idx = parseInt(choice || String(defaultIdx), 10) - 1; - return methods[idx]?.key || methods[defaultIdx - 1]?.key || HERMES_AUTH_METHOD_OAUTH; -} - -function resolveHermesNousApiKey(): string | null { - return ( - // check-direct-credential-env-ignore -- Hermes Provider API keys are read only from the invoking shell for OpenShell provider registration; do not resolve host credentials.json. - normalizeCredentialValue(process.env[HERMES_NOUS_API_KEY_CREDENTIAL_ENV]) || - normalizeCredentialValue(process.env.NEMOCLAW_PROVIDER_KEY) || - null - ); -} - -function stageNousApiKeyProviderEnv(): void { - const key = resolveHermesNousApiKey(); - if (key) { - process.env[HERMES_NOUS_API_KEY_CREDENTIAL_ENV] = key; - } -} - -async function ensureHermesNousApiKeyEnv(): Promise { - const existing = resolveHermesNousApiKey(); - if (existing) { - process.env[HERMES_NOUS_API_KEY_CREDENTIAL_ENV] = existing; - return existing; - } - console.log(""); - console.log(" Hermes Provider Nous API Key"); - console.log(` Create or copy a key from ${HERMES_NOUS_API_KEY_HELP_URL}`); - const key = await credentialPrompt.readValue(" Nous API Key: "); - if (isBackToSelection(key)) return key; - const validationError = validateNvidiaApiKeyValue(key, HERMES_NOUS_API_KEY_CREDENTIAL_ENV); - if (validationError) { - console.error(validationError); - process.exit(1); - } - process.env[HERMES_NOUS_API_KEY_CREDENTIAL_ENV] = key; - return key; -} - -function openshellResultMessage(result: { - stdout?: string | Buffer | null; - stderr?: string | Buffer | null; -}): string { - return compactText(redact(`${result.stderr || ""} ${result.stdout || ""}`)); -} - -function checkHermesProviderStoreReachable( - runOpenshellImpl: typeof runOpenshell = runOpenshell, -): { ok: true } | { ok: false; message: string } { - const result = runOpenshellImpl(["provider", "list"], { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - timeout: 10_000, - }); - if (result.status === 0) return { ok: true }; - return { - ok: false, - message: - openshellResultMessage(result) || - "OpenShell provider storage is unreachable; the gateway may be stopped or refusing connections.", - }; -} - -async function selectOnboardAgent({ - agentFlag = null, - session = null, -}: { - agentFlag?: string | null; - session?: { agent?: string | null } | null; - resume?: boolean; - canPrompt?: boolean; -} = {}): Promise { - const agent = agentOnboard.resolveAgent({ agentFlag, session }); - if (isNonInteractive()) { - const displayName = agent?.displayName || agentDefs.loadAgent("openclaw").displayName; - note(` [non-interactive] Agent: ${displayName}`); - } - return agent; -} - -const { getTransportRecoveryMessage, getProbeRecovery } = validationRecovery; - -// Validation functions — delegated to src/lib/validation.ts -const { - classifyValidationFailure, - classifyApplyFailure, - classifySandboxCreateFailure, - validateNvidiaApiKeyValue, - isSafeModelId, - shouldSkipResponsesProbe, - shouldForceCompletionsApi, -} = validation; - -// validateNvidiaApiKeyValue — see validation import above - -async function promptValidationRecovery( - label: string, - recovery: ProbeRecovery, - credentialEnv: string | null = null, - helpUrl: string | null = null, -): Promise<"credential" | "selection" | "retry" | "model"> { - if (isNonInteractive()) { - process.exit(1); - } - - if (recovery.kind === "credential" && credentialEnv) { - console.log( - ` ${label} authorization failed. Re-enter the API key or choose a different provider/model.`, - ); - console.log(" ⚠️ Do NOT paste your API key here — use the options below:"); - const choice = ( - await prompt(" Options: retry (re-enter key), back (change provider), exit [retry]: ", { - secret: true, - }) - ) - .trim() - .toLowerCase(); - // Guard against the user accidentally pasting an API key at this prompt. - // Tokens don't contain spaces; human sentences do — the no-space + length check - // avoids false-positives on long typed sentences. - const API_KEY_PREFIXES = ["nvapi-", "ghp_", "gcm-", "sk-", "gpt-", "gemini-", "nvcf-"]; - const looksLikeToken = - API_KEY_PREFIXES.some((p) => choice.startsWith(p)) || - (!choice.includes(" ") && choice.length > 40) || - // Regex fallback: base64-safe token pattern (20+ chars, no spaces, mixed alphanum) - /^[A-Za-z0-9_\-\.]{20,}$/.test(choice); - // validateNvidiaApiKeyValue is provider-aware: it only enforces the - // nvapi- prefix when credentialEnv === "NVIDIA_API_KEY", so passing it - // unconditionally here is safe for Anthropic/OpenAI/Gemini too. - const validator = (key: string) => validateNvidiaApiKeyValue(key, credentialEnv); - const replaceCredential = async (): Promise<"credential" | "selection"> => { - const result = await credentialPrompt.replaceNamedCredential( - credentialEnv, - `${label} API key`, - helpUrl, - validator, - ); - if (credentialPrompt.returningToProviderSelection(result)) return "selection"; - return "credential"; - }; - if (looksLikeToken) { - console.log(" ⚠️ That looks like an API key — do not paste credentials here."); - console.log(" Treating as 'retry'. You will be prompted to enter the key securely."); - return replaceCredential(); - } - if (choice === "back") { - console.log(" Returning to provider selection."); - console.log(""); - return "selection"; - } - if (choice === "exit" || choice === "quit") { - exitOnboardFromPrompt(); - } - if (choice === "" || choice === "retry") { - return replaceCredential(); - } - console.log(" Please choose a provider/model again."); - console.log(""); - return "selection"; - } - - if (recovery.kind === "transport") { - console.log(getTransportRecoveryMessage("failure" in recovery ? recovery.failure || {} : {})); - const choice = (await prompt(" Type 'retry', 'back', or 'exit' [retry]: ")) - .trim() - .toLowerCase(); - if (choice === "back") { - console.log(" Returning to provider selection."); - console.log(""); - return "selection"; - } - if (choice === "exit" || choice === "quit") { - exitOnboardFromPrompt(); - } - if (choice === "" || choice === "retry") { - console.log(""); - return "retry"; - } - console.log(" Please choose a provider/model again."); - console.log(""); - return "selection"; - } - - if (recovery.kind === "model") { - console.log(` Please enter a different ${label} model name.`); - console.log(""); - return "model"; - } - - console.log(" Please choose a provider/model again."); - console.log(""); - return "selection"; -} - -// Provider CRUD — thin wrappers that inject runOpenshell to avoid circular deps. -const { buildProviderArgs } = onboardProviders; - -// Snapshot of legacy {env-key → value} pairs that stageLegacyCredentialsToEnv() -// imported from ~/.nemoclaw/credentials.json at the start of this run. -// Captured by the onboard() entry point; consulted by the upsertProvider / -// upsertMessagingProviders wrappers below to decide whether a successful -// gateway upsert actually migrated the *legacy* value (vs. e.g. a vllm/ollama -// branch that upserts a placeholder under the same env-key name). -const stagedLegacyValues: Map = new Map(); - -// Env-keys whose successful gateway upsert actually used the staged legacy -// value. Seeded from the persisted onboard session at the start of every -// run so a `--resume` invocation that skips already-completed upserts still -// remembers the migrations the prior attempt committed. The post-onboard -// legacy-file cleanup is gated on `stagedLegacyKeys ⊆ migratedLegacyKeys` -// so picking a local inference provider, disabling a preselected messaging -// channel, or any other path that upserts a different value under the same -// env-key name leaves the file alone instead of stranding the user's only -// copy. -const migratedLegacyKeys: Set = new Set(); - -// SHA-256 hex digest of `value`. Used to fingerprint migrated legacy -// secrets in the persisted onboard session so a later `--resume` can -// detect when the legacy file value was edited between runs (or another -// session is on disk with stale entries) and refuse to inherit a stale -// "migrated" mark. -function legacyValueHash(value: string): string { - return crypto.createHash("sha256").update(value).digest("hex"); -} - -// Mirror the in-memory `migratedLegacyKeys` set into the persisted onboard -// session along with each entry's value hash. `--resume` invocations that -// skip the upsert wrappers entirely use this to inherit migration state -// from the previous attempt — but only when the staged value at restore -// time still hashes to the same digest, so an edit to the legacy file or -// an out-of-band gateway reset cannot satisfy the cleanup gate. -function persistMigratedLegacyKeys(): void { - try { - const hashes: Record = {}; - for (const key of migratedLegacyKeys) { - const stagedValue = stagedLegacyValues.get(key); - if (stagedValue !== undefined) { - hashes[key] = legacyValueHash(stagedValue); - } - } - onboardSession.updateSession((current: Session) => { - current.migratedLegacyValueHashes = hashes; - return current; - }); + const hashes: Record = {}; + for (const key of migratedLegacyKeys) { + const stagedValue = stagedLegacyValues.get(key); + if (stagedValue !== undefined) { + hashes[key] = legacyValueHash(stagedValue); + } + } + onboardSession.updateSession((current: Session) => { + current.migratedLegacyValueHashes = hashes; + return current; + }); } catch { // updateSession can throw if the session file isn't yet writable // (e.g. very early in the run before lockless state is established). @@ -1716,30 +833,12 @@ type EndpointValidationResult = | { ok: true; api: string | null; retry?: undefined } | { ok: false; retry: "credential" | "selection" | "retry" | "model"; api?: undefined }; -function verifyDirectSandboxGpu(sandboxName: string): void { - console.log(" Verifying direct sandbox GPU access..."); - for (const proof of buildDirectSandboxGpuProofCommands(sandboxName)) { - const result = runOpenshell(proof.args, { - ignoreError: true, - suppressOutput: true, - timeout: 30_000, - }); - if (result.status === 0) { - console.log(` ✓ GPU proof passed: ${proof.label}`); - continue; - } - if (proof.optional === true) return; - const diagnostic = compactText(redact(`${result.stderr || ""} ${result.stdout || ""}`)); - console.error(` ✗ GPU proof failed: ${proof.label}`); - if (diagnostic) console.error(` ${diagnostic.slice(0, 300)}`); - for (const line of sandboxGpuRemediationLines()) { - console.error(` ${line}`); - } - const statusText = String(result.status || 1); - const diagnosticSuffix = diagnostic ? `: ${diagnostic.slice(0, 300)}` : ""; - throw new Error(`GPU proof failed: ${proof.label} (status ${statusText})${diagnosticSuffix}`); - } -} +const verifyDirectSandboxGpu = sandboxGpuPreflight.createDirectSandboxGpuVerifier({ + runOpenshell, + compactText, + redact, +}); + function upsertMessagingProviders(tokenDefs: MessagingTokenDef[]) { const upserted = onboardProviders.upsertMessagingProviders(tokenDefs, runOpenshell); @@ -1771,54 +870,6 @@ function providerExistsInGateway(name: string) { return onboardProviders.providerExistsInGateway(name, runOpenshell); } -function getMessagingChannelForEnvKey(envKey: string): string | null { - if (envKey === "DISCORD_BOT_TOKEN") return "discord"; - if (envKey === "SLACK_BOT_TOKEN") return "slack"; - if (envKey === "TELEGRAM_BOT_TOKEN") return "telegram"; - if (envKey === "WECHAT_BOT_TOKEN") return "wechat"; - return null; -} - - -function getRecordedMessagingChannelsForResume( - resume: boolean, - session: Session | null, sandboxName: string | null, -): string[] | null { - return require("./onboard/messaging-reuse").getNonInteractiveStoredMessagingChannels( - resume, session?.messagingChannels, sandboxName, MESSAGING_CHANNELS, (envKey: string) => Boolean(normalizeCredentialValue(process.env[envKey]) || getCredential(envKey)), - registry.getSandbox.bind(registry), registry.getDisabledChannels.bind(registry), providerExistsInGateway, isNonInteractive()); -} - -/** - * Detect whether any messaging provider credential has been rotated since - * the sandbox was created, by comparing SHA-256 hashes of the current - * token values against hashes stored in the sandbox registry. - * - * Returns `changed: false` for legacy sandboxes that have no stored hashes - * (conservative — avoids unnecessary rebuilds after upgrade). - * - * @param {string} sandboxName - Name of the sandbox to check. - * @param {Array<{name: string, envKey: string, token: string|null}>} tokenDefs - * @returns {{ changed: boolean, changedProviders: string[] }} - */ -function detectMessagingCredentialRotation( - sandboxName: string, - tokenDefs: MessagingTokenDef[], -): { changed: boolean; changedProviders: string[] } { - const sb = registry.getSandbox(sandboxName); - const storedHashes = sb?.providerCredentialHashes || {}; - const changedProviders = []; - for (const { name, envKey, token } of tokenDefs) { - if (!token) continue; - const storedHash = storedHashes[envKey]; - if (!storedHash) continue; - if (storedHash !== hashCredential(token)) { - changedProviders.push(name); - } - } - return { changed: changedProviders.length > 0, changedProviders }; -} - // Tri-state probe factory for messaging-conflict backfill. An upfront liveness // check is necessary because `openshell provider get` exits non-zero for both // "provider not attached" and "gateway unreachable"; without the liveness @@ -1830,269 +881,69 @@ function makeConflictProbe() { if (gatewayAlive === null) { const result = runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); // runCaptureOpenshell returns stdout/stderr as a single string; treat - // any non-empty output as a sign openshell answered. Empty output with - // ignoreError typically means the binary failed to produce anything. - gatewayAlive = typeof result === "string" && result.length > 0; - } - return gatewayAlive; - }; - return { - providerExists: (name: string) => { - if (!isGatewayAlive()) return "error"; - return providerExistsInGateway(name) ? "present" : "absent"; - }, - }; -} - -function verifyInferenceRoute(_provider: string, _model: string): void { - const output = runCaptureOpenshell(["inference", "get"], { ignoreError: true }); - if (!output || /Gateway inference:\s*[\r\n]+\s*Not configured/i.test(output)) { - console.error(" OpenShell inference route was not configured."); - process.exit(1); - } -} - -function isInferenceRouteReady(provider: string, model: string): boolean { - const live = parseGatewayInference( - runCaptureOpenshell(["inference", "get"], { ignoreError: true }), - ); - return Boolean(live && live.provider === provider && live.model === model); -} - -function sandboxExistsInGateway(sandboxName: string): boolean { - const output = runCaptureOpenshell(["sandbox", "get", sandboxName], { ignoreError: true }); - return Boolean(output); -} - -function pruneStaleSandboxEntry(sandboxName: string): boolean { - const existing = registry.getSandbox(sandboxName); - const liveExists = sandboxExistsInGateway(sandboxName); - if (existing && !liveExists) { - registry.removeSandbox(sandboxName); - } - return liveExists; -} - -function shouldRestoreLatestBackupOnRecreate(): boolean { - return process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE === "1"; -} - -async function confirmRecreateForSelectionDrift( - sandboxName: string, - drift: SelectionDrift, - requestedProvider: string | null, - requestedModel: string | null, -): Promise { - const currentProvider = drift.existingProvider || "unknown"; - const currentModel = drift.existingModel || "unknown"; - const nextProvider = requestedProvider || "unknown"; - const nextModel = requestedModel || "unknown"; - - console.log(` Sandbox '${sandboxName}' exists but requested inference selection changed.`); - console.log(` Current: provider=${currentProvider} model=${currentModel}`); - console.log(` Requested: provider=${nextProvider} model=${nextModel}`); - console.log( - ` Recreating the sandbox is required to apply this change to the running ${agentProductName()} UI.`, - ); - - if (isNonInteractive()) { - note(" [non-interactive] Recreating sandbox due to provider/model drift."); - return true; - } - - const answer = await prompt(` Recreate sandbox '${sandboxName}' now? [y/N]: `); - return isAffirmativeAnswer(answer); -} - -function isOpenclawReady(sandboxName: string): boolean { - return Boolean(fetchGatewayAuthTokenFromSandbox(sandboxName)); -} - -function isAffirmativeAnswer(value: string | null | undefined): boolean { - return ["y", "yes"].includes( - String(value || "") - .trim() - .toLowerCase(), - ); -} - -function validateBraveSearchApiKey(apiKey: string): CurlProbeResult { - return runCurlProbe([ - "-sS", - "--compressed", - "-H", - "Accept: application/json", - "-H", - "Accept-Encoding: gzip", - "-H", - `X-Subscription-Token: ${apiKey}`, - "--get", - "--data-urlencode", - "q=ping", - "--data-urlencode", - "count=1", - "https://api.search.brave.com/res/v1/web/search", - ]); -} - -async function promptBraveSearchRecovery( - validation: ValidationFailureLike, -): Promise<"retry" | "skip"> { - const recovery = classifyValidationFailure(validation); - - if (recovery.kind === "credential") { - console.log(" Brave Search rejected that API key."); - } else if (recovery.kind === "transport") { - console.log(getTransportRecoveryMessage(validation)); - } else { - console.log(" Brave Search validation did not succeed."); - } - - const answer = (await prompt(" Type 'retry', 'skip', or 'exit' [retry]: ")).trim().toLowerCase(); - if (answer === "skip") return "skip"; - if (answer === "exit" || answer === "quit") { - exitOnboardFromPrompt(); - } - return "retry"; -} - -async function promptBraveSearchApiKey(): Promise { - console.log(""); - console.log(` Get your Brave Search API key from: ${BRAVE_SEARCH_HELP_URL}`); - console.log(""); - - while (true) { - const value = await credentialPrompt.readValue(" Brave Search API key: "); - if (isBackToSelection(value)) { - return value; - } - const key = normalizeCredentialValue(value); - if (!key) { - console.error(" Brave Search API key is required."); - continue; - } - return key; - } -} - -async function ensureValidatedBraveSearchCredential( - nonInteractive = isNonInteractive(), -): Promise { - const savedApiKey = getCredential(webSearch.BRAVE_API_KEY_ENV); - let apiKey: string | null = - savedApiKey || normalizeCredentialValue(process.env[webSearch.BRAVE_API_KEY_ENV]); - let usingSavedKey = Boolean(savedApiKey); - - while (true) { - if (!apiKey) { - if (nonInteractive) { - throw new Error( - "Brave Search requires BRAVE_API_KEY or a saved Brave Search credential in non-interactive mode.", - ); - } - const promptedApiKey = await promptBraveSearchApiKey(); - if (isBackToSelection(promptedApiKey)) { - return promptedApiKey; - } - apiKey = promptedApiKey; - usingSavedKey = false; - } - - const validation = validateBraveSearchApiKey(apiKey); - if (validation.ok) { - saveCredential(webSearch.BRAVE_API_KEY_ENV, apiKey); - process.env[webSearch.BRAVE_API_KEY_ENV] = apiKey; - return apiKey; - } - - const prefix = usingSavedKey - ? " Saved Brave Search API key validation failed." - : " Brave Search API key validation failed."; - console.error(prefix); - if (validation.message) { - console.error(` ${validation.message}`); - } - - if (nonInteractive) { - throw new Error( - validation.message || "Brave Search API key validation failed in non-interactive mode.", - ); - } - - const action = await promptBraveSearchRecovery(validation); - if (action === "skip") { - console.log(" Skipping Brave Web Search setup."); - console.log(""); - return null; + // any non-empty output as a sign openshell answered. Empty output with + // ignoreError typically means the binary failed to produce anything. + gatewayAlive = typeof result === "string" && result.length > 0; } + return gatewayAlive; + }; + return { + providerExists: (name: string) => { + if (!isGatewayAlive()) return "error"; + return providerExistsInGateway(name) ? "present" : "absent"; + }, + }; +} - apiKey = null; - usingSavedKey = false; +function verifyInferenceRoute(_provider: string, _model: string): void { + const output = runCaptureOpenshell(["inference", "get"], { ignoreError: true }); + if (!output || /Gateway inference:\s*[\r\n]+\s*Not configured/i.test(output)) { + console.error(" OpenShell inference route was not configured."); + process.exit(1); } } -async function configureWebSearch( - existingConfig: WebSearchConfig | null = null, - agent: AgentDefinition | null = null, - dockerfilePathOverride: string | null = null, -): Promise { - if (!agentSupportsWebSearch(agent, dockerfilePathOverride, ROOT)) { - note(` Web search is not yet supported by ${agent?.displayName ?? "this agent"}. Skipping.`); - return null; - } +function isInferenceRouteReady(provider: string, model: string): boolean { + const live = parseGatewayInference( + runCaptureOpenshell(["inference", "get"], { ignoreError: true }), + ); + return Boolean(live && live.provider === provider && live.model === model); +} - if (existingConfig) { - return { fetchEnabled: true }; - } +const { + sandboxExistsInGateway, + pruneStaleSandboxEntry, + shouldRestoreLatestBackupOnRecreate, + confirmRecreateForSelectionDrift, + isOpenclawReady, +} = sandboxLifecycle.createSandboxLifecycleHelpers({ + runCaptureOpenshell, + fetchGatewayAuthTokenFromSandbox: (sandboxName: string) => fetchGatewayAuthTokenFromSandbox(sandboxName), + agentProductName, + prompt, + isAffirmativeAnswer, +}); - if (isNonInteractive()) { - const braveApiKey = normalizeCredentialValue(process.env[webSearch.BRAVE_API_KEY_ENV]); - if (!braveApiKey) { - return null; - } - note(" [non-interactive] Brave Web Search requested."); - const validation = validateBraveSearchApiKey(braveApiKey); - if (!validation.ok) { - console.warn( - ` Brave Search API key validation failed. Web search will be disabled — re-enable later via \`${cliName()} config web-search\`.`, - ); - if (validation.message) { - console.warn(` ${validation.message}`); - } - return null; - } - saveCredential(webSearch.BRAVE_API_KEY_ENV, braveApiKey); - process.env[webSearch.BRAVE_API_KEY_ENV] = braveApiKey; - return { fetchEnabled: true }; - } - const enableAnswer = await prompt(" Enable Brave Web Search? [y/N]: "); - if (!isAffirmativeAnswer(enableAnswer)) { - return null; - } - const braveApiKey = await ensureValidatedBraveSearchCredential(); - if (isBackToSelection(braveApiKey)) { - return configureWebSearch(existingConfig, agent, dockerfilePathOverride); - } - if (!braveApiKey) { - return null; - } +const { + validateBraveSearchApiKey, + promptBraveSearchRecovery, + promptBraveSearchApiKey, + ensureValidatedBraveSearchCredential, + configureWebSearch, + verifyWebSearchInsideSandbox, +} = createWebSearchFlowHelpers({ + prompt, + note, + isNonInteractive, + cliName, + runCaptureOpenshell, +}); - console.log(" ✓ Enabled Brave Web Search"); - console.log(""); - return { fetchEnabled: true }; -} -function verifyWebSearchInsideSandbox( - sandboxName: string, - agent: AgentDefinition | null | undefined, -): void { - verifyWebSearchInsideSandboxWithDeps(sandboxName, agent, { - runCaptureOpenshell, - cliName, - }); -} +// getSandboxInferenceConfig — moved to onboard-providers.ts +// Inference probes — moved to inference/onboard-probes.ts const { hasResponsesToolCall, hasChatCompletionsToolCall, @@ -2105,151 +956,17 @@ const { probeAnthropicEndpoint, } = require("./inference/onboard-probes"); -async function validateOpenAiLikeSelection( - label: string, - endpointUrl: string, - model: string, - credentialEnv: string | null = null, - retryMessage = "Please choose a provider/model again.", - helpUrl: string | null = null, - options: { - authMode?: "bearer" | "query-param"; - requireResponsesToolCalling?: boolean; - requireChatCompletionsToolCalling?: boolean; - skipResponsesProbe?: boolean; - probeStreaming?: boolean; - allowHostDockerInternal?: boolean; - } = {}, -): Promise { - const apiKey = credentialEnv ? getCredential(credentialEnv) : ""; - const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options); - if (!probe.ok) { - console.error(` ${label} endpoint validation failed.`); - console.error(` ${probe.message}`); - if (isNonInteractive()) { - process.exit(1); - } - const retry = await promptValidationRecovery( - label, - getProbeRecovery(probe), - credentialEnv, - helpUrl, - ); - if (retry === "selection") { - console.log(` ${retryMessage}`); - console.log(""); - } - return { ok: false, retry }; - } - if (probe.note) { - console.log(` ℹ ${probe.note}`); - } else { - console.log(` ${probe.label} available — ${agentProductName()} will use ${probe.api}.`); - } - return { ok: true, api: probe.api ?? "openai-completions" }; -} - -async function validateAnthropicSelectionWithRetryMessage( - label: string, - endpointUrl: string, - model: string, - credentialEnv: string, - retryMessage = "Please choose a provider/model again.", - helpUrl: string | null = null, -): Promise { - const apiKey = getCredential(credentialEnv); - const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); - if (!probe.ok) { - console.error(` ${label} endpoint validation failed.`); - console.error(` ${probe.message}`); - if (isNonInteractive()) { - process.exit(1); - } - const retry = await promptValidationRecovery( - label, - getProbeRecovery(probe), - credentialEnv, - helpUrl, - ); - if (retry === "selection") { - console.log(` ${retryMessage}`); - console.log(""); - } - return { ok: false, retry }; - } - console.log(` ${probe.label} available — ${agentProductName()} will use ${probe.api}.`); - return { ok: true, api: probe.api }; -} - -async function validateCustomOpenAiLikeSelection( - label: string, - endpointUrl: string, - model: string, - credentialEnv: string, - helpUrl: string | null = null, -): Promise { - const apiKey = getCredential(credentialEnv); - const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, { - requireResponsesToolCalling: true, - skipResponsesProbe: shouldForceCompletionsApi(process.env.NEMOCLAW_PREFERRED_API), - probeStreaming: true, - }); - if (probe.ok) { - if (probe.note) { - console.log(` ℹ ${probe.note}`); - } else { - console.log(` ${probe.label} available — ${agentProductName()} will use ${probe.api}.`); - } - return { ok: true, api: probe.api ?? "openai-completions" }; - } - console.error(` ${label} endpoint validation failed.`); - console.error(` ${probe.message}`); - if (isNonInteractive()) { - process.exit(1); - } - const retry = await promptValidationRecovery( - label, - getProbeRecovery(probe, { allowModelRetry: true }), - credentialEnv, - helpUrl, - ); - if (retry === "selection") { - console.log(" Please choose a provider/model again."); - console.log(""); - } - return { ok: false, retry }; -} +const { + validateOpenAiLikeSelection, + validateAnthropicSelectionWithRetryMessage, + validateCustomOpenAiLikeSelection, + validateCustomAnthropicSelection, +} = createInferenceSelectionValidationHelpers({ + isNonInteractive, + agentProductName, + promptValidationRecovery, +}); -async function validateCustomAnthropicSelection( - label: string, - endpointUrl: string, - model: string, - credentialEnv: string, - helpUrl: string | null = null, -): Promise { - const apiKey = getCredential(credentialEnv); - const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); - if (probe.ok) { - console.log(` ${probe.label} available — ${agentProductName()} will use ${probe.api}.`); - return { ok: true, api: probe.api }; - } - console.error(` ${label} endpoint validation failed.`); - console.error(` ${probe.message}`); - if (isNonInteractive()) { - process.exit(1); - } - const retry = await promptValidationRecovery( - label, - getProbeRecovery(probe, { allowModelRetry: true }), - credentialEnv, - helpUrl, - ); - if (retry === "selection") { - console.log(" Please choose a provider/model again."); - console.log(""); - } - return { ok: false, retry }; -} const { promptCloudModel, promptRemoteModel, promptInputModel } = modelPrompts; const { validateAnthropicModel, validateOpenAiLikeModel } = providerModels; @@ -2271,153 +988,10 @@ const { const ollamaModelSize: typeof import("./inference/ollama/model-size") = require("./inference/ollama/model-size"); -function getRequestedSandboxNameHint(opts: { sandboxName?: string | null } = {}): string | null { - const raw = - typeof opts.sandboxName === "string" && opts.sandboxName.length > 0 - ? opts.sandboxName - : process.env.NEMOCLAW_SANDBOX_NAME; - if (typeof raw !== "string") return null; - const normalized = raw.trim().toLowerCase(); - return normalized || null; -} - -function getResumeSandboxConflict( - session: Session | null, - opts: { sandboxName?: string | null } = {}, -) { - // Use opts.sandboxName as the sole source — the caller has already - // resolved it (--name first, NEMOCLAW_SANDBOX_NAME only when prompting - // is impossible). Falling back to the env var here would fire spurious - // conflicts for interactive resume runs whose shell happens to export - // NEMOCLAW_SANDBOX_NAME but which never actually consult it. - // #2753: only treat session.sandboxName as a conflict source if the - // sandbox step actually completed. A pre-fix incomplete session would - // otherwise reject a legitimate `--resume --name ` that the user - // is supplying precisely to recover from the phantom. - const raw = typeof opts.sandboxName === "string" ? opts.sandboxName.trim().toLowerCase() : ""; - const requestedSandboxName = raw || null; - const recordedSandboxName = - session?.steps?.sandbox?.status === "complete" ? session?.sandboxName ?? null : null; - if (!requestedSandboxName || !recordedSandboxName) { - return null; - } - return requestedSandboxName !== recordedSandboxName - ? { requestedSandboxName, recordedSandboxName } - : null; -} - -// Provider hint wrappers — supply isNonInteractive() default, delegate to onboard-providers. -function getRequestedProviderHint(nonInteractive = isNonInteractive()) { - return onboardProviders.getRequestedProviderHint(nonInteractive); -} -function getRequestedModelHint(nonInteractive = isNonInteractive()) { - return onboardProviders.getRequestedModelHint(nonInteractive); -} - -function getResumeConfigConflicts( - session: Session | null, - opts: { - nonInteractive?: boolean; - fromDockerfile?: string | null; - sandboxName?: string | null; - agent?: string | null; - } = {}, -) { - const conflicts = []; - const nonInteractive = opts.nonInteractive ?? isNonInteractive(); - - const sandboxConflict = getResumeSandboxConflict(session, { sandboxName: opts.sandboxName }); - if (sandboxConflict) { - conflicts.push({ - field: "sandbox", - requested: sandboxConflict.requestedSandboxName, - recorded: sandboxConflict.recordedSandboxName, - }); - } - - const requestedProvider = getRequestedProviderHint(nonInteractive); - const effectiveRequestedProvider = getEffectiveProviderName(requestedProvider); - if ( - effectiveRequestedProvider && - session?.provider && - effectiveRequestedProvider !== session.provider - ) { - conflicts.push({ - field: "provider", - requested: effectiveRequestedProvider, - recorded: session.provider, - }); - } - - const requestedModel = getRequestedModelHint(nonInteractive); - if (requestedModel && session?.model && requestedModel !== session.model) { - conflicts.push({ - field: "model", - requested: requestedModel, - recorded: session.model, - }); - } - - const requestedFrom = opts.fromDockerfile ? path.resolve(opts.fromDockerfile) : null; - const recordedFrom = session?.metadata?.fromDockerfile - ? path.resolve(session.metadata.fromDockerfile) - : null; - if (requestedFrom !== recordedFrom) { - conflicts.push({ - field: "fromDockerfile", - requested: requestedFrom, - recorded: recordedFrom, - }); - } - - return conflicts; -} - -function printRemediationActions( - actions: Array<{ title: string; reason: string; commands?: string[] }> | null | undefined, -): void { - if (!Array.isArray(actions) || actions.length === 0) { - return; - } - - console.error(""); - console.error(" Suggested fix:"); - console.error(""); - for (const action of actions) { - console.error(` - ${action.title}: ${action.reason}`); - for (const command of action.commands || []) { - console.error(` ${command}`); - } - } -} - function isOpenshellInstalled(): boolean { return resolveOpenshell() !== null; } -function getFutureShellPathHint(binDir: string, pathValue = process.env.PATH || ""): string | null { - const parts = String(pathValue).split(path.delimiter).filter(Boolean); - if (parts[0] === binDir) { - return null; - } - return `export PATH="${binDir}:$PATH"`; -} - -function getPortConflictServiceHints(platform = process.platform): string[] { - if (platform === "darwin") { - return [ - " # or, if it's a launchctl service (macOS):", - " launchctl list | grep -i claw # columns: PID | ExitStatus | Label", - ` launchctl unload ${OPENCLAW_LAUNCH_AGENT_PLIST}`, - " # or: launchctl bootout gui/$(id -u)/ai.openclaw.gateway", - ]; - } - return [ - " # or, if it's a systemd service:", - " systemctl --user stop openclaw-gateway.service", - ]; -} - function installOpenshell(): OpenShellInstallResult { return openshellPinFlow.runOpenshellInstall({ scriptsDir: SCRIPTS, @@ -2477,10 +1051,6 @@ function getOpenShellInstallDeps(): OpenShellInstallDeps { }; } -function sleep(seconds: number): void { - sleepSeconds(seconds); -} - function runQuietOpenshell(args: string[]) { return runOpenshell(args, { ignoreError: true, @@ -2509,7 +1079,7 @@ function terminateDockerDriverGatewayProcess(pid: number): boolean { process.kill(pid, "SIGTERM"); for (let i = 0; i < 10; i += 1) { if (!isPidAlive(pid)) break; - sleep(1); + sleepSeconds(1); } if (isPidAlive(pid)) process.kill(pid, "SIGKILL"); return true; @@ -2559,7 +1129,7 @@ function stopLegacyGatewayClusterContainer(): boolean { } function retireLegacyGatewayForDockerDriverUpgrade(): void { - bestEffortForwardStop(runOpenshell, DASHBOARD_PORT); + runOpenshell(["forward", "stop", String(DASHBOARD_PORT)], { ignoreError: true }); stopDockerDriverGatewayProcess(); const stoppedLegacyContainer = stopLegacyGatewayClusterContainer(); removeDockerDriverGatewayRegistration(); @@ -2838,7 +1408,7 @@ function getOpenShellDockerSupervisorImage(versionOutput: string | null = null): if (shouldUseOpenshellDevChannel() || isOpenshellDevVersion(versionOutput)) { return "ghcr.io/nvidia/openshell/supervisor:dev"; } - const supportedVersion = installedVersion ?? getBlueprintMaxOpenshellVersion() ?? "0.0.44"; + const supportedVersion = installedVersion ?? getBlueprintMaxOpenshellVersion() ?? "0.0.39"; return `ghcr.io/nvidia/openshell/supervisor:${supportedVersion}`; } @@ -3169,6 +1739,14 @@ function attachGatewayMetadataIfNeeded({ return false; } +async function ensureNamedCredential( + envName: string | null, + label: string, + helpUrl: string | null = null, +): Promise { + return credentialPrompt.ensureNamedCredential(envName, label, helpUrl); +} + function waitForSandboxReady(sandboxName: string, attempts = 10, delaySeconds = 2): boolean { for (let i = 0; i < attempts; i += 1) { const list = runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); @@ -3177,7 +1755,7 @@ function waitForSandboxReady(sandboxName: string, attempts = 10, delaySeconds = // Package-managed OpenShell gateways report readiness through // `sandbox list`; legacy Kubernetes gateways may still expose pod state. if (isLinuxDockerDriverGatewayEnabled()) { - if (i < attempts - 1) sleep(delaySeconds); + if (i < attempts - 1) sleepSeconds(delaySeconds); continue; } const podPhase = runCaptureOpenshell( @@ -3197,7 +1775,7 @@ function waitForSandboxReady(sandboxName: string, attempts = 10, delaySeconds = { ignoreError: true }, ); if (podPhase === "Running") return true; - sleep(delaySeconds); + sleepSeconds(delaySeconds); } return false; } @@ -3512,7 +2090,7 @@ async function preflight( const containerState = verifyGatewayContainerRunning(GATEWAY_NAME); if (containerState === "missing") { console.log(" Gateway metadata is stale (container not running). Cleaning up..."); - bestEffortForwardStop(runOpenshell, DASHBOARD_PORT); + runOpenshell(["forward", "stop", String(DASHBOARD_PORT)], { ignoreError: true }); gatewayReuseState = destroyGatewayForReuse( destroyGateway, " ✓ Stale gateway metadata cleaned up", @@ -3546,7 +2124,7 @@ async function preflight( console.log( ` Gateway container is running but ${getGatewayLocalEndpoint()}/ is not responding. Recreating...`, ); - bestEffortForwardStop(runOpenshell, DASHBOARD_PORT); + runOpenshell(["forward", "stop", String(DASHBOARD_PORT)], { ignoreError: true }); gatewayReuseState = destroyGatewayForReuse( destroyGateway, " ✓ Stale gateway cleaned up", @@ -3575,7 +2153,7 @@ async function preflight( gatewayReuseState = "missing"; console.log(" ✓ Previous session cleaned up"); } else { - bestEffortForwardStop(runOpenshell, DASHBOARD_PORT); + runOpenshell(["forward", "stop", String(DASHBOARD_PORT)], { ignoreError: true }); gatewayReuseState = destroyGatewayForReuse( destroyGateway, " ✓ Previous session cleaned up", @@ -3678,7 +2256,7 @@ async function preflight( ` Cleaning up orphaned SSH port-forward on port ${port} (PID ${portCheck.pid})...`, ); run(["kill", String(portCheck.pid)], { ignoreError: true }); - sleep(1); + sleepSeconds(1); portCheck = await checkPortAvailable(port, portCheckOptions); if (portCheck.ok) { console.log(` ✓ Port ${port} available after orphaned forward cleanup (${label})`); @@ -3850,14 +2428,12 @@ async function startGatewayWithOptions( } // Also purge any known_hosts entries matching the gateway hostname pattern const knownHostsPath = path.join(os.homedir(), ".ssh", "known_hosts"); - if (fs.existsSync(knownHostsPath)) { - try { - const kh = fs.readFileSync(knownHostsPath, "utf8"); - const cleaned = pruneKnownHostsEntries(kh); - if (cleaned !== kh) fs.writeFileSync(knownHostsPath, cleaned); - } catch { - /* best-effort cleanup — ignore read/write errors */ - } + try { + const kh = fs.readFileSync(knownHostsPath, "utf8"); + const cleaned = pruneKnownHostsEntries(kh); + if (cleaned !== kh) fs.writeFileSync(knownHostsPath, cleaned); + } catch { + /* best-effort cleanup — ignore absent/read/write errors */ } const gwArgs = ["--name", GATEWAY_NAME, "--port", getGatewayPortArg()]; @@ -3935,7 +2511,7 @@ async function startGatewayWithOptions( if (isGatewayHealthy(status, namedInfo, currentInfo) && (await isGatewayHttpReady())) { return; // success } - if (i < healthPollCount - 1) sleep(healthPollInterval); + if (i < healthPollCount - 1) sleepSeconds(healthPollInterval); } throw new Error("Gateway failed to start"); @@ -4069,7 +2645,7 @@ async function startDockerDriverGateway({ exitOnFailure = true, skipSandboxBridg } if (!gatewayBin) { console.error(" OpenShell Docker-driver gateway binary not found."); - console.error(" Install OpenShell v0.0.44, or set NEMOCLAW_OPENSHELL_GATEWAY_BIN."); + console.error(" Install OpenShell v0.0.39, or set NEMOCLAW_OPENSHELL_GATEWAY_BIN."); if (exitOnFailure) process.exit(1); throw new Error("OpenShell gateway binary not found"); } @@ -4082,7 +2658,7 @@ async function startDockerDriverGateway({ exitOnFailure = true, skipSandboxBridg console.log(` Restarting unhealthy Docker-driver gateway process (PID ${existingPid})...`); try { process.kill(existingPid, "SIGTERM"); - sleep(1); + sleepSeconds(1); } catch { /* best effort; the new process will surface any remaining port conflict */ } @@ -4091,8 +2667,10 @@ async function startDockerDriverGateway({ exitOnFailure = true, skipSandboxBridg fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); const logPath = path.join(stateDir, "openshell-gateway.log"); - const outFd = fs.openSync(logPath, "a", 0o600); - const errFd = fs.openSync(logPath, "a", 0o600); + // The gateway state directory is NemoClaw-owned; creating it before opening + // the append-only log is intentional and safe for this local runtime file. + const outFd = fs.openSync(logPath, "a", 0o600); // codeql[js/file-system-race] + const errFd = fs.openSync(logPath, "a", 0o600); // codeql[js/file-system-race] console.log(" Starting OpenShell Docker-driver gateway..."); console.log(` Gateway log: ${logPath}`); const launch = gatewayLaunch ?? { @@ -4124,7 +2702,7 @@ async function startDockerDriverGateway({ exitOnFailure = true, skipSandboxBridg break; } if (!registerDockerDriverGatewayEndpoint()) { - if (i < pollCount - 1) sleep(pollInterval); + if (i < pollCount - 1) sleepSeconds(pollInterval); continue; } const status = runCaptureOpenshell(["status"], { ignoreError: true }); @@ -4139,7 +2717,7 @@ async function startDockerDriverGateway({ exitOnFailure = true, skipSandboxBridg await verifySandboxBridgeGatewayReachableOrExit(exitOnFailure, { skip: skipSandboxBridgeReachability }); console.log(" ✓ Docker-driver gateway is healthy"); return; } - if (i < pollCount - 1) sleep(pollInterval); + if (i < pollCount - 1) sleepSeconds(pollInterval); } reportDockerDriverGatewayStartFailure(logPath, childExit, { exitOnFailure }); @@ -4327,228 +2905,28 @@ async function recoverGatewayRuntime() { ) { process.env.OPENSHELL_GATEWAY = GATEWAY_NAME; const runtime = getContainerRuntime(); - if (shouldPatchCoredns(runtime)) { - run(["bash", path.join(SCRIPTS, "fix-coredns.sh"), GATEWAY_NAME], { - ignoreError: true, - }); - } - return true; - } - if (i < recoveryPollCount - 1) sleep(recoveryPollInterval); - } - - return false; -} - -// ── Step 3: Sandbox ────────────────────────────────────────────── - -// Names that collide with CLI command namespaces. A sandbox named 'status' -// makes 'nemoclaw status connect' route to the global status command -// instead of the sandbox, and a sandbox named 'sandbox' collides with the -// oclif-native `nemoclaw sandbox ...` command namespace. Reject these wherever -// a sandbox name enters the system (interactive prompt, --name flag, -// NEMOCLAW_SANDBOX_NAME). -const RESERVED_SANDBOX_NAMES = new Set([ - "onboard", - "list", - "deploy", - "setup", - "setup-spark", - "start", - "stop", - "status", - "debug", - "uninstall", - "update", - "credentials", - "help", - "sandbox", -]); - -function normalizeSandboxAgentName(agentName: string | null | undefined): string { - const trimmed = typeof agentName === "string" ? agentName.trim() : ""; - return trimmed && trimmed !== "openclaw" ? trimmed : "openclaw"; -} - -const UNKNOWN_SANDBOX_AGENT_NAME = "unknown"; - -function getRequestedSandboxAgentName(agent: AgentDefinition | null | undefined): string { - return normalizeSandboxAgentName(agent?.name); -} - -function formatSandboxAgentName(agentName: string | null | undefined): string { - const normalized = normalizeSandboxAgentName(agentName); - if (normalized === "openclaw") return "OpenClaw"; - if (normalized === "hermes") return "Hermes"; - return normalized; -} - -function getDefaultSandboxNameForAgent(agent: AgentDefinition | null | undefined): string { - return getRequestedSandboxAgentName(agent) === "hermes" ? "hermes" : "my-assistant"; -} - -function getSandboxPromptDefault(agent: AgentDefinition | null | undefined): string { - const envName = (process.env.NEMOCLAW_SANDBOX_NAME || "").trim().toLowerCase(); - const agentDefault = getDefaultSandboxNameForAgent(agent); - if (!envName) return agentDefault; - try { - return validateName(envName, "sandbox name"); - } catch { - return agentDefault; - } -} - -function getEffectiveSandboxAgent(agent: AgentDefinition | null | undefined): AgentDefinition { - return agent || agentDefs.loadAgent("openclaw"); -} - -function getAgentInferenceProviderOptions(agent: AgentDefinition | null | undefined): string[] { - const effectiveAgent = agent?.name - ? agentDefs.loadAgent(agent.name) - : getEffectiveSandboxAgent(agent); - return Array.isArray(effectiveAgent.inferenceProviderOptions) - ? effectiveAgent.inferenceProviderOptions - : []; -} - -function getSandboxAgentRegistryFields( - agent: AgentDefinition | null | undefined, - agentVersionKnown = true, -): Pick { - const effectiveAgent = getEffectiveSandboxAgent(agent); - const agentName = normalizeSandboxAgentName(effectiveAgent.name); - return { - agent: agentName === "openclaw" ? null : agentName, - agentVersion: agentVersionKnown ? effectiveAgent.expectedVersion || null : null, - }; -} - -function getSandboxAgentDrift( - sandboxName: string, - requestedAgentName: string, -): { changed: boolean; existingAgentName: string; requestedAgentName: string } { - const existingEntry: SandboxEntry | null = registry.getSandbox(sandboxName); - if (!existingEntry) { - return { - changed: true, - existingAgentName: UNKNOWN_SANDBOX_AGENT_NAME, - requestedAgentName, - }; - } - const existingAgentName = normalizeSandboxAgentName(existingEntry?.agent); - return { - changed: existingAgentName !== requestedAgentName, - existingAgentName, - requestedAgentName, - }; -} - -function getSandboxRuntimeRegistryFields( - config: SandboxGpuConfig, -): Pick< - SandboxEntry, - | "gpuEnabled" - | "hostGpuDetected" - | "sandboxGpuEnabled" - | "sandboxGpuMode" - | "sandboxGpuDevice" - | "openshellDriver" - | "openshellVersion" -> { - return { - gpuEnabled: config.sandboxGpuEnabled, - hostGpuDetected: config.hostGpuDetected, - sandboxGpuEnabled: config.sandboxGpuEnabled, - sandboxGpuMode: config.mode, - sandboxGpuDevice: config.sandboxGpuDevice, - openshellDriver: isLinuxDockerDriverGatewayEnabled() ? (process.platform === "darwin" ? "vm" : "docker") : "kubernetes", - openshellVersion: getInstalledOpenshellVersion( - runCaptureOpenshell(["--version"], { ignoreError: true }), - ), - }; -} - -function hasSandboxGpuDrift(sandboxName: string, config: SandboxGpuConfig): boolean { - const existingEntry: SandboxEntry | null = registry.getSandbox(sandboxName); - if (!existingEntry) return false; - return ( - (existingEntry.sandboxGpuEnabled === true) !== config.sandboxGpuEnabled || - (existingEntry.sandboxGpuMode || "auto") !== config.mode || - (existingEntry.sandboxGpuDevice || null) !== config.sandboxGpuDevice - ); -} - -function updateReusedSandboxMetadata( - sandboxName: string, - agent: AgentDefinition | null | undefined, - model: string, - provider: string, - dashboardPort: number, - selectionVerified = true, - sandboxGpuConfig: SandboxGpuConfig | null = null, -): void { - const existingEntry = registry.getSandbox(sandboxName); - const agentVersionKnown = existingEntry?.agentVersion !== null; - const selectionUpdates = selectionVerified ? { model, provider } : {}; - registry.updateSandbox(sandboxName, { - ...selectionUpdates, - dashboardPort, - ...getSandboxAgentRegistryFields(agent, agentVersionKnown), - ...(sandboxGpuConfig ? getSandboxRuntimeRegistryFields(sandboxGpuConfig) : {}), - }); - registry.setDefault(sandboxName); -} - -async function promptValidatedSandboxName(agent: AgentDefinition | null = null) { - const MAX_ATTEMPTS = 3; - const defaultSandboxName = getSandboxPromptDefault(agent); - for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - const nameAnswer = await promptOrDefault( - ` Sandbox name (${NAME_ALLOWED_FORMAT}) [${defaultSandboxName}]: `, - "NEMOCLAW_SANDBOX_NAME", - defaultSandboxName, - ); - const sandboxName = (nameAnswer || defaultSandboxName).trim(); - - try { - const validatedSandboxName = validateName(sandboxName, "sandbox name"); - if (RESERVED_SANDBOX_NAMES.has(sandboxName)) { - console.error(` Reserved name: '${sandboxName}' is a ${cliDisplayName()} CLI command.`); - console.error(" Choose a different name to avoid routing conflicts."); - if (isNonInteractive()) { - process.exit(1); - } - if (attempt < MAX_ATTEMPTS - 1) { - console.error(" Please try again.\n"); - } - continue; + if (shouldPatchCoredns(runtime)) { + run(["bash", path.join(SCRIPTS, "fix-coredns.sh"), GATEWAY_NAME], { + ignoreError: true, + }); } - return validatedSandboxName; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error(` ${errorMessage}`); + return true; } + if (i < recoveryPollCount - 1) sleepSeconds(recoveryPollInterval); + } - for (const line of getNameValidationGuidance("sandbox name", sandboxName, { - includeAllowedFormat: false, - })) { - console.error(` ${line}`); - } + return false; +} - // Non-interactive runs cannot re-prompt — abort so the caller can fix the - // NEMOCLAW_SANDBOX_NAME env var and retry. - if (isNonInteractive()) { - process.exit(1); - } +// ── Step 3: Sandbox ────────────────────────────────────────────── - if (attempt < MAX_ATTEMPTS - 1) { - console.error(" Please try again.\n"); - } - } +const { getSandboxRuntimeRegistryFields, hasSandboxGpuDrift, updateReusedSandboxMetadata } = + sandboxRegistryMetadata.createSandboxRegistryMetadataHelpers({ + isLinuxDockerDriverGatewayEnabled, + getInstalledOpenshellVersion, + runCaptureOpenshell, + }); - console.error(" Too many invalid attempts."); - process.exit(1); -} // ── Step 5: Sandbox ────────────────────────────────────────────── @@ -5036,15 +3414,7 @@ async function createSandbox( } const previousEntry: SandboxEntry | null = registry.getSandbox(sandboxName); - const previousPoliciesForCarryForward = recreateForAgentDrift ? null : previousEntry?.policies; - if (recreateForAgentDrift && previousEntry?.policies && previousEntry.policies.length > 0) { - note(" Agent type changed; refreshing policy presets instead of carrying them forward."); - } - const decision = decidePolicyCarryForward( - previousPoliciesForCarryForward, - process.env, - isNonInteractive(), - ); + const decision = decidePolicyCarryForward(previousEntry?.policies, process.env, isNonInteractive()); onboardSession.updateSession((c: Session) => { c.policyPresets = decision.newPresets; return c; @@ -5465,7 +3835,7 @@ async function createSandbox( openshellSandboxCommand: sandboxStartupCommand, timeoutSecs: sandboxReadyTimeoutSecs, backend: effectiveSandboxGpuConfig.hostGpuPlatform === "jetson" ? "jetson" : "generic", - deps: { runOpenshell, runCaptureOpenshell, sleep }, + deps: { runOpenshell, runCaptureOpenshell, sleep: sleepSeconds }, }); const createResult = await streamSandboxCreate(createCommand, sandboxEnv, { readyCheck: () => { @@ -5532,7 +3902,7 @@ async function createSandbox( ready = true; break; } - if (i < readyAttempts - 1) sleep(2); + if (i < readyAttempts - 1) sleepSeconds(2); } const restoreBackupPath = @@ -5600,7 +3970,7 @@ async function createSandbox( if (i === 14) { console.warn(" Dashboard taking longer than expected to start. Continuing..."); } else { - sleep(2); + sleepSeconds(2); } } @@ -5773,131 +4143,14 @@ function providerNameToOptionKey( name: string | null | undefined, opts: { hasNimContainer?: boolean } = {}, ): string | null { - if (!name) return null; - if (name === "nvidia-router") return "routed"; - if (name === "ollama-local") return "ollama"; - // Local NIM and standalone vLLM both persist as provider="vllm-local". NIM - // is positively identified by a nimContainer record; the absence of one in - // registry/session recovery reliably means standalone vLLM (the standalone - // path never records a container), so default to "vllm" there. Live-gateway - // recovery doesn't carry container info either, but the caller's - // option-availability check still gates on whether vllm is actually running. - if (name === "vllm-local") return opts.hasNimContainer ? "nim-local" : "vllm"; - // `nvidia-nim` is a legacy alias for cloud NVIDIA Endpoints (see - // setupInference: it routes nvidia-nim through REMOTE_PROVIDER_CONFIG.build), - // not a marker for Local NIM. Local NIM persists as vllm-local + nimContainer. - if (name === "nvidia-nim") return "build"; - for (const [key, cfg] of Object.entries(REMOTE_PROVIDER_CONFIG)) { - if ((cfg as { providerName?: string }).providerName === name) return key; - } - return null; -} - -function readLiveInference( - sandboxName: string | null | undefined, -): { provider: string | null; model: string | null } | null { - if (!sandboxName) return null; - try { - const { defaultSandbox, sandboxes } = registry.listSandboxes(); - // The gateway holds one active inference config at a time. Trust the - // live read for the default sandbox, or when the registry has no - // entries (rebuild path: destroy wiped the entry but the gateway - // config persists). Other non-default sandboxes have a stored config - // that the gateway will swap to on their next connect. - const trustGateway = sandboxName === defaultSandbox || sandboxes.length === 0; - if (!trustGateway) return null; - const output = runCaptureOpenshell(["inference", "get"], { ignoreError: true }); - return parseGatewayInference(output); - } catch { - return null; - } -} - -function readRecordedProvider(sandboxName: string | null | undefined): string | null { - if (!sandboxName) return null; - try { - const entry = registry.getSandbox(sandboxName); - if (entry && typeof entry.provider === "string" && entry.provider) { - return entry.provider; - } - } catch { - // fall through to session - } - try { - const session = onboardSession.loadSession(); - if ( - session && - session.sandboxName === sandboxName && - typeof session.provider === "string" && - session.provider - ) { - return session.provider; - } - } catch { - // fall through to live gateway - } - const live = readLiveInference(sandboxName); - if (live && typeof live.provider === "string" && live.provider) { - return live.provider; - } - return null; -} - -function readRecordedNimContainer(sandboxName: string | null | undefined): string | null { - if (!sandboxName) return null; - try { - const entry = registry.getSandbox(sandboxName); - if (entry && typeof entry.nimContainer === "string" && entry.nimContainer) { - return entry.nimContainer; - } - } catch { - // fall through to session - } - try { - const session = onboardSession.loadSession(); - if ( - session && - session.sandboxName === sandboxName && - typeof session.nimContainer === "string" && - session.nimContainer - ) { - return session.nimContainer; - } - } catch { - return null; - } - return null; + return providerRecovery.providerNameToOptionKey(REMOTE_PROVIDER_CONFIG, name, opts); } -function readRecordedModel(sandboxName: string | null | undefined): string | null { - if (!sandboxName) return null; - try { - const entry = registry.getSandbox(sandboxName); - if (entry && typeof entry.model === "string" && entry.model) { - return entry.model; - } - } catch { - // fall through to session - } - try { - const session = onboardSession.loadSession(); - if ( - session && - session.sandboxName === sandboxName && - typeof session.model === "string" && - session.model - ) { - return session.model; - } - } catch { - // fall through to live gateway - } - const live = readLiveInference(sandboxName); - if (live && typeof live.model === "string" && live.model) { - return live.model; - } - return null; -} +const { readLiveInference, readRecordedProvider, readRecordedNimContainer, readRecordedModel } = + providerRecovery.createProviderRecoveryHelpers({ + parseGatewayInference, + runCaptureOpenshell, + }); type OllamaModelSelectionOutcome = | { outcome: "selected"; model: string } @@ -6019,16 +4272,6 @@ async function setupNim( let hermesAuthMethod: HermesAuthMethod | null = null; let hermesToolGateways: string[] = []; let preferredInferenceApi: string | null = null; - const resetSelectionAttemptState = () => { - model = null; - provider = REMOTE_PROVIDER_CONFIG.build.providerName; - nimContainer = null; - endpointUrl = REMOTE_PROVIDER_CONFIG.build.endpointUrl; - credentialEnv = REMOTE_PROVIDER_CONFIG.build.credentialEnv; - hermesAuthMethod = null; - hermesToolGateways = []; - preferredInferenceApi = null; - }; // Detect local inference options. Bound curl with --connect-timeout/--max-time // so a half-open port or stalled listener cannot hang the onboard at step 3 @@ -6042,7 +4285,8 @@ async function setupNim( ["curl", "-sf", ...localProbeCurlArgs, `http://127.0.0.1:${VLLM_PORT}/v1/models`], { ignoreError: true }, ); - // Pick a vLLM install recipe for this host. Profiles live in inference/vllm.ts. + // Pick a vLLM install recipe for this host. Profiles live in inference/vllm.ts; + // null means "no supported platform" (vLLM stays behind EXPERIMENTAL). const vllmProfile = detectVllmProfile(gpu); // If the profile's image is already cached, the install path is really a // "start" — docker pull is a no-op and the container can come up in seconds. @@ -6052,10 +4296,7 @@ async function setupNim( ); // Probed even when WSL has its own Ollama: users may prefer the Windows // instance for GPU access and a unified model cache. See - // src/lib/onboard/windows-host-ollama.ts for the probe semantics, in - // particular the Get-Process fallback for service-style installs - // (#3949) and the strict path-recovery requirement before flagging - // the install as restartable. + // src/lib/onboard/windows-host-ollama.ts for process/path fallback details. const winOllamaState = detectWindowsHostOllama(); const hasWindowsOllama = winOllamaState.installed; const winOllamaInstalledPath = winOllamaState.installedPath; @@ -6126,7 +4367,6 @@ async function setupNim( vllmRunning, vllmProfile, experimental: EXPERIMENTAL, - platform: gpu?.platform, hasVllmImage, }), ); @@ -6193,12 +4433,12 @@ async function setupNim( if (options.length > 1) { selectionLoop: while (true) { - resetSelectionAttemptState(); let selected: ProviderChoice | undefined; // Hoisted so downstream model-selection branches can fall back to a // recorded model from the same recovery decision. let recoveredFromSandbox = false; let recoveredModel: string | null = null; + hermesAuthMethod = null; if (isNonInteractive() || requestedProvider) { let providerKey = requestedProvider; @@ -6391,11 +4631,15 @@ async function setupNim( if (selected.key === "hermesProvider") { const selectedHermesAuthMethod = await promptHermesAuthMethod(); - if (credentialPrompt.returningToProviderSelection(selectedHermesAuthMethod)) { + if (isBackToSelection(selectedHermesAuthMethod)) { hermesAuthMethod = null; + console.log(" Returning to provider selection."); + console.log(""); continue selectionLoop; } - hermesAuthMethod = selectedHermesAuthMethod; + hermesAuthMethod = normalizeHermesAuthMethod( + selectedHermesAuthMethod as string | null | undefined, + ); if (hermesAuthMethod === HERMES_AUTH_METHOD_API_KEY) { credentialEnv = HERMES_NOUS_API_KEY_CREDENTIAL_ENV; stageNousApiKeyProviderEnv(); @@ -6407,11 +4651,9 @@ async function setupNim( process.exit(1); } } else { - const hermesCredentialResult = await ensureHermesNousApiKeyEnv(); - if (credentialPrompt.returningToProviderSelection(hermesCredentialResult)) { - hermesAuthMethod = null; + const hermesKeyResult = await ensureHermesNousApiKeyEnv(); + if (credentialPrompt.returningToProviderSelection(hermesKeyResult)) continue selectionLoop; - } } } else { credentialEnv = remoteConfig.credentialEnv; @@ -6454,7 +4696,11 @@ async function setupNim( }, ); } - if (credentialPrompt.returningToProviderSelection(model)) continue selectionLoop; + if (isBackToSelection(model)) { + console.log(" Returning to provider selection."); + console.log(""); + continue selectionLoop; + } preferredInferenceApi = "openai-completions"; console.log(` Using ${remoteConfig.label} with model: ${model}`); break; @@ -6491,7 +4737,7 @@ async function setupNim( process.exit(1); } } else { - if (credentialPrompt.returningToProviderSelection(await ensureApiKey())) continue selectionLoop; + await ensureApiKey(); } const _envModel = (process.env.NEMOCLAW_MODEL || "").trim(); model = @@ -6501,7 +4747,11 @@ async function setupNim( ? DEFAULT_CLOUD_MODEL : await promptCloudModel({ defaultModelId: _envModel || undefined })) || DEFAULT_CLOUD_MODEL; - if (credentialPrompt.returningToProviderSelection(model)) continue selectionLoop; + if (isBackToSelection(model)) { + console.log(" Returning to provider selection."); + console.log(""); + continue selectionLoop; + } } else { // NEMOCLAW_PROVIDER_KEY is a universal alias: if the specific credential env // isn't already set, use NEMOCLAW_PROVIDER_KEY as the API key for this provider. @@ -6535,7 +4785,7 @@ async function setupNim( backToSelection: BACK_TO_SELECTION, isNonInteractive, promptInputModel, - replaceNamedCredential: credentialPrompt.replaceNamedCredential, + replaceNamedCredential, }); if (bedrockSelection.action === "retry-selection") { console.log(" Returning to provider selection."); @@ -6555,7 +4805,7 @@ async function setupNim( process.exit(1); } } else { - const credentialResult = await credentialPrompt.ensureNamedCredential( + const credentialResult = await ensureNamedCredential( selectedCredentialEnv, remoteConfig.label + " API key", remoteConfig.helpUrl, @@ -6594,7 +4844,11 @@ async function setupNim( } else { model = await promptInputModel(remoteConfig.label, defaultModel, modelValidator); } - if (credentialPrompt.returningToProviderSelection(model)) continue selectionLoop; + if (isBackToSelection(model)) { + console.log(" Returning to provider selection."); + console.log(""); + continue selectionLoop; + } if (selected.key === "custom") { const validation = await validateCustomOpenAiLikeSelection( @@ -6753,7 +5007,6 @@ async function setupNim( const models = nim.listModels().filter((m) => m.minGpuMemoryMB <= localGpu.totalMemoryMB); if (models.length === 0) { console.log(" No NIM models fit your GPU VRAM. Falling back to cloud API."); - model = requestedModel || (recoveredFromSandbox && recoveredModel) || DEFAULT_CLOUD_MODEL; } else { let sel; if (isNonInteractive()) { @@ -6991,10 +5244,6 @@ async function setupNim( } console.log(` ✓ Using Ollama on host.docker.internal:${OLLAMA_PORT}`); } else { - // Pass the verified executable path so windows.ts can target - // it directly instead of falling back to the calling shell's - // Windows PATH — which is the broken case for service-style - // installs that #3949 surfaces. if ( !setupWindowsOllamaWith0000Binding({ announceStop: isRestart, @@ -7056,7 +5305,7 @@ async function setupNim( runShell("set -o pipefail; curl -fsSL https://ollama.com/install.sh | sh", { stdio: "inherit" }); // Give the just-started ollama.service a moment to bind port // 11434 before we probe or apply the systemd drop-in override. - sleep(2); + sleepSeconds(2); // Linux native + systemd: force a loopback-only OLLAMA_HOST drop-in // and let systemd own the daemon (avoids racing the installer's // daemon with our own `ollama serve`). This also repairs older @@ -7171,13 +5420,10 @@ async function setupNim( console.error(" Local vLLM validation URL could not be determined."); process.exit(1); } - if (isBackToSelection(model)) { - continue selectionLoop; - } const validation = await validateOpenAiLikeSelection( "Local vLLM", validationBaseUrl, - requireValue(model, "Expected a detected vLLM model"), + requireValue(model as string | null | undefined, "Expected a detected vLLM model"), null, ); if (validation.retry === "selection" || validation.retry === "model") { @@ -7230,7 +5476,7 @@ async function setupNim( console.log(" Model Router accepts NVIDIA API keys (nvapi-...)."); console.log(" Get one at https://build.nvidia.com"); console.log(""); - const routerCredentialResult = await credentialPrompt.ensureNamedCredential( + const routerCredentialResult = await ensureNamedCredential( routerCredentialEnv, "Model Router API key", null, @@ -7255,8 +5501,9 @@ async function setupNim( } } + const selectedModel = isBackToSelection(model) ? null : model; return { - model: isBackToSelection(model) ? null : model, + model: selectedModel, provider, endpointUrl, credentialEnv, @@ -7660,38 +5907,22 @@ async function setupInference( const MESSAGING_CHANNELS = listChannels(); -function getStoredMessagingChannelConfig( - sandboxName: string | null, +function getRecordedMessagingChannelsForResume( + resume: boolean, session: Session | null, -): MessagingChannelConfig | null { - const registryConfig = sandboxName - ? sanitizeMessagingChannelConfig(registry.getSandbox(sandboxName)?.messagingChannelConfig) - : null; - const sessionMatchesSandbox = - !session?.sandboxName || !sandboxName || session.sandboxName === sandboxName; - const sessionConfig = sessionMatchesSandbox - ? sanitizeMessagingChannelConfig(session?.messagingChannelConfig) - : null; - return mergeMessagingChannelConfigs(registryConfig, sessionConfig); -} - -function persistMessagingChannelConfigToSession(config: MessagingChannelConfig | null): void { - onboardSession.updateSession((current: Session) => { - current.messagingChannelConfig = config; - return current; + sandboxName: string | null, +): string[] | null { + return getRecordedMessagingChannelsForResumeFromState({ + resume, + sessionMessagingChannels: session?.messagingChannels, + sandboxName, + channels: MESSAGING_CHANNELS, + getCredential, + providerExistsInGateway, + isNonInteractive, }); } -function messagingChannelConfigsEqual( - left: MessagingChannelConfig | null, - right: MessagingChannelConfig | null, -): boolean { - const leftKeys = Object.keys(left || {}).sort(); - const rightKeys = Object.keys(right || {}).sort(); - if (leftKeys.length !== rightKeys.length) return false; - return leftKeys.every((key, index) => key === rightKeys[index] && left?.[key] === right?.[key]); -} - // Curl exit codes that indicate a network-level failure (not a token problem). // 35 (TLS handshake failure) covers corporate proxies that MITM HTTPS. const TELEGRAM_NETWORK_CURL_CODES = new Set([6, 7, 28, 35, 52, 56]); @@ -7806,7 +6037,7 @@ async function setupMessagingChannels( output.write(` [${i + 1}] ${marker} ${ch.name} — ${ch.description}${status}\n`); }); output.write("\n"); - output.write(` Press 1-${availableChannels.length} to toggle, Enter when done (none selected skips): `); + output.write(` Press 1-${availableChannels.length} to toggle, Enter when done: `); }; showList(); @@ -7925,11 +6156,16 @@ function syncNemoClawConfigInSandbox(sandboxName: string, provider: string, mode }); } -async function setupOpenclaw(sandboxName: string, model: string, provider: string): Promise { - step(7, 8, `Setting up ${agentProductName()} inside sandbox`); - syncNemoClawConfigInSandbox(sandboxName, provider, model); - console.log(` ✓ ${agentProductName()} gateway launched inside sandbox`); -} +const setupOpenclaw = createOpenclawSetup({ + step, + agentProductName, + getProviderSelectionConfig, + buildSandboxConfigSyncScript, + writeSandboxConfigSyncFile, + run, + openshellArgv, + cleanupTempDir, +}); // ── Step 7: Policy presets ─────────────────────────────────────── @@ -8461,452 +6697,49 @@ async function setupPoliciesWithSelection( const CONTROL_UI_PORT = DASHBOARD_PORT; -// Dashboard helpers — delegated to src/lib/dashboard/contract.ts -const { buildChain, buildControlUiUrls } = dashboardContract; - -function findForwardEntry( - forwardListOutput: string | null | undefined, - port: string, -): { sandboxName: string; status: string } | null { - if (!forwardListOutput) return null; - for (const rawLine of forwardListOutput.split("\n")) { - const line = rawLine.replace(ANSI_RE, ""); - if (/^\s*SANDBOX\s/i.test(line)) continue; - const parts = line.trim().split(/\s+/); - if (parts.length < 3 || parts[2] !== port) continue; - return { - sandboxName: parts[0] || "", - status: (parts[4] || "").toLowerCase(), - }; - } - return null; -} - -function getRunningForwardPorts(forwardListOutput: string | null | undefined): string[] { - const ports = new Set(); - if (!forwardListOutput) return []; - for (const rawLine of forwardListOutput.split("\n")) { - const line = rawLine.replace(ANSI_RE, ""); - if (/^\s*SANDBOX\s/i.test(line)) continue; - const parts = line.trim().split(/\s+/); - if (parts.length < 5 || !/^\d+$/.test(parts[2])) continue; - const status = (parts[4] || "").toLowerCase(); - if (isLiveForwardStatus(status)) { - ports.add(parts[2]); - } - } - return [...ports]; -} - -function stopAllDashboardForwards(): void { - const forwardList = runCaptureOpenshell(["forward", "list"], { ignoreError: true }); - for (const port of getRunningForwardPorts(forwardList)) { - bestEffortForwardStop(runOpenshell, port); - } -} - -/** - * Build the actionable error lines printed when the just-created openshell - * sandbox is rolled back after a dashboard port-allocation failure. Pure - * function over (sandboxName, alloc-error, delete-result) so the rollback path - * is testable without spawning subprocesses or exiting the process (#2174). - */ -function buildOrphanedSandboxRollbackMessage( - sandboxName: string, - err: unknown, - deleteSucceeded: boolean, -): string[] { - const lines = [ - "", - ` Could not allocate a dashboard port for '${sandboxName}'.`, - ` ${err instanceof Error ? err.message : String(err)}`, - ]; - if (deleteSucceeded) { - lines.push(" The orphaned sandbox has been removed — you can safely retry."); - } else { - lines.push(" Could not remove the orphaned sandbox. Manual cleanup:"); - lines.push(` openshell sandbox delete "${sandboxName}"`); - } - return lines; -} - -/** - * Set up the dashboard forward for a sandbox. Auto-allocates the next free - * port if the preferred port is taken by a different sandbox (Fixes #2174). - * Returns the actual port number used. - * - * When `rollbackSandboxOnFailure` is true, deletes the just-created openshell - * sandbox before exiting on unrecoverable port-allocation failure. This keeps - * `openshell sandbox list` and the NemoClaw registry from drifting when the - * range is exhausted between sandbox-create and forward-setup ("leaks ghost - * sandbox" half of #2174). Mirrors the not-ready rollback pattern in - * createSandbox. - */ -function ensureDashboardForward( - sandboxName: string, - chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`, - options: { rollbackSandboxOnFailure?: boolean } = {}, -): number { - const { rollbackSandboxOnFailure = false } = options; - const preferredPort = Number(getDashboardForwardPort(chatUiUrl)); - let existingForwards = runCaptureOpenshell(["forward", "list"], { ignoreError: true }); - const preferredEntry = findForwardEntry(existingForwards, String(preferredPort)); - if ( - preferredEntry && - (preferredEntry.sandboxName === sandboxName || !isLiveForwardStatus(preferredEntry.status)) - ) { - bestEffortForwardStop(runOpenshell, preferredPort); - existingForwards = runCaptureOpenshell(["forward", "list"], { ignoreError: true }); - } - let actualPort: number; - try { - actualPort = findAvailableDashboardPort(sandboxName, preferredPort, existingForwards); - } catch (err) { - if (!rollbackSandboxOnFailure) throw err; - const delResult = runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); - for (const line of buildOrphanedSandboxRollbackMessage( - sandboxName, - err, - delResult.status === 0, - )) { - console.error(line); - } - process.exit(1); - } - - if (actualPort !== preferredPort) { - if (rollbackSandboxOnFailure) { - // Create path: the sandbox was just built with CHAT_UI_URL and - // NEMOCLAW_DASHBOARD_PORT baked from `preferredPort` (see the - // `formatEnvAssignment("CHAT_UI_URL", …)` call in createSandbox). If - // the port was bound during the build window (TOCTOU), picking a new - // host port would leave the sandbox serving the dashboard on - // `preferredPort` internally while the forward listens on `actualPort` - // — reproducing the original "onboard exits but dashboard is - // unreachable" failure on the newly selected port. Reallocation is - // only safe on reuse paths where the sandbox image is fixed; on the - // create path we must roll back so the next onboard re-bakes with a - // clean port. (#3260) - const err = new Error( - `Dashboard port ${preferredPort} became host-bound during sandbox build; ` + - `cannot reallocate to ${actualPort} after the sandbox has been created with ` + - `CHAT_UI_URL=${preferredPort}. Free the port and re-run \`${cliName()} onboard\`, ` + - `or pass \`--control-ui-port \` to pick a different dashboard port.`, - ); - const delResult = runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); - for (const line of buildOrphanedSandboxRollbackMessage( - sandboxName, - err, - delResult.status === 0, - )) { - console.error(line); - } - process.exit(1); - } - console.warn(` ! Port ${preferredPort} is taken. Using port ${actualPort} instead.`); - } - - // Clean up any stale forwards owned by this sandbox on other ports so we - // don't leak forwards across port changes and exhaust the range over time. - const occupied = getOccupiedPorts(existingForwards); - for (const [port, owner] of occupied.entries()) { - if (owner === sandboxName && Number(port) !== actualPort) { - bestEffortForwardStop(runOpenshell, port); - } - } - - // Preserve the original URL's hostname (loopback vs remote) but swap to the actual port. - const parsedUrl = new URL(chatUiUrl.includes("://") ? chatUiUrl : `http://${chatUiUrl}`); - parsedUrl.port = String(actualPort); - const actualTarget = getDashboardForwardTarget(parsedUrl.toString()); - bestEffortForwardStop(runOpenshell, actualPort); - const { result: fwdResult, diagnostic: fwdDiagnostic } = runBackgroundForwardStartWithPortReleaseRetries( - (stdio, timeout) => - runOpenshell( - ["forward", "start", "--background", actualTarget, sandboxName], - { ignoreError: true, suppressOutput: true, stdio, timeout }, - ), - () => { sleep(1); bestEffortForwardStop(runOpenshell, actualPort); }, - ); - if (fwdResult && fwdResult.status !== 0) { - const looksLikePortConflict = looksLikeForwardPortConflict(fwdDiagnostic); - if (rollbackSandboxOnFailure) { - // The sandbox was just created, committed to actualPort via its - // baked-in CHAT_UI_URL and NEMOCLAW_DASHBOARD_PORT env. Silently - // returning here leaves the user with a dashboard URL that points - // at a port held by another process — a TOCTOU race where the - // proactive probe in findAvailableDashboardPort missed the - // conflict (e.g., another listener bound during the multi-minute - // image build). Roll back so the next `onboard` retry's allocator - // observes the bound port and picks a different one. Only the - // EADDRINUSE-style failure gets the port-conflict wording; other - // errors (gateway / transport) propagate the real diagnostic so - // users aren't pointed at the wrong fix (#3260). - const err = new Error( - looksLikePortConflict - ? `Failed to start dashboard forward on port ${actualPort} — the host port ` + - `is held by another process. Free it and run \`${cliName()} onboard\` again, ` + - `or pass \`--control-ui-port \` to pick a different dashboard port.` - : `Failed to start dashboard forward on port ${actualPort}: ${fwdDiagnostic.slice(0, 240)}`, - ); - const delResult = runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); - for (const line of buildOrphanedSandboxRollbackMessage( - sandboxName, - err, - delResult.status === 0, - )) { - console.error(line); - } - process.exit(1); - } - if (looksLikePortConflict) { - console.warn( - `! Port ${actualPort} forward did not start — port may be in use by another process.`, - ); - console.warn( - ` Check: docker ps --format 'table {{.Names}}\\t{{.Ports}}' | grep ${actualPort}`, - ); - console.warn(` Free the port, then reconnect: ${cliName()} ${sandboxName} connect`); - } else { - console.warn(`! Port ${actualPort} forward did not start: ${fwdDiagnostic.slice(0, 240)}`); - console.warn(` Reconnect after resolving the issue: ${cliName()} ${sandboxName} connect`); - } - } - return actualPort; -} - -function ensureAgentDashboardForward( - sandboxName: string, - agent: { forwardPort?: number | null }, -): number { - const agentDashboardPort = agent.forwardPort ?? CONTROL_UI_PORT; - const agentDashboardUrl = `http://127.0.0.1:${agentDashboardPort}`; - const actualAgentDashboardPort = ensureDashboardForward(sandboxName, agentDashboardUrl); - process.env.CHAT_UI_URL = `http://127.0.0.1:${actualAgentDashboardPort}`; - return actualAgentDashboardPort; -} - -function findOpenclawJsonPath(dir: string): string | null { - if (!fs.existsSync(dir)) return null; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const e of entries) { - const p = path.join(dir, e.name); - if (e.isDirectory()) { - const found: string | null = findOpenclawJsonPath(p); - if (found) return found; - } else if (e.name === "openclaw.json") { - return p; - } - } - return null; -} - -/** - * Pull gateway.auth.token from the sandbox image via openshell sandbox download - * so onboard can build dashboard access URLs. User-visible output must redact - * the token fragment. - */ -function fetchGatewayAuthTokenFromSandbox(sandboxName: string): string | null { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-token-")); - try { - const destDir = `${tmpDir}${path.sep}`; - const result = runOpenshell( - ["sandbox", "download", sandboxName, "/sandbox/.openclaw/openclaw.json", destDir], - { ignoreError: true, stdio: ["ignore", "ignore", "ignore"] }, - ); - if (result.status !== 0) return null; - const jsonPath = findOpenclawJsonPath(tmpDir); - if (!jsonPath) return null; - const cfg = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); - const token = cfg && cfg.gateway && cfg.gateway.auth && cfg.gateway.auth.token; - return typeof token === "string" && token.length > 0 ? token : null; - } catch { - return null; - } finally { - try { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } catch { - // ignore cleanup errors - } - } -} - -// buildControlUiUrls — see dashboard-contract import above - -function getDashboardForwardPort( - chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`, - options: Parameters[1] = {}, -): string { - return dashboardAccess.getDashboardForwardPort(chatUiUrl, { - ...options, - runCapture: options.runCapture || runCapture, - }); -} - -function getDashboardForwardTarget( - chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`, - options: Parameters[1] = {}, -): string { - return dashboardAccess.getDashboardForwardTarget(chatUiUrl, { - ...options, - runCapture: options.runCapture || runCapture, - }); -} - -function dashboardUrlForDisplay(url: string): string { - return dashboardAccess.dashboardUrlForDisplay(url, redact); -} - -function getWslHostAddress( - options: Parameters[0] = {}, -): string | null { - return dashboardAccess.getWslHostAddress({ ...options, runCapture: options.runCapture || runCapture }); -} - -/** Print the post-onboard dashboard with sandbox status and reconfiguration hints. */ -function printDashboard( - sandboxName: string, - model: string, - provider: string, - nimContainer: string | null = null, - agent: AgentDefinition | null = null, -): void { - const nimStat = nimContainer ? nim.nimStatusByName(nimContainer) : nim.nimStatus(sandboxName); - const showNim = nim.shouldShowNimLine(nimContainer, nimStat.running); - const nimLabel = nimStat.running ? "running" : "not running"; - - const providerLabel = getProviderLabel(provider); - - const token = fetchGatewayAuthTokenFromSandbox(sandboxName); - const chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`; - const chain = buildChain({ chatUiUrl, isWsl: isWsl(), wslHostAddress: getWslHostAddress() }); - const dashboardBaseUrl = `${chain.accessUrl.replace(/\/$/, "")}/`; - const dashboardUrl = dashboardUrlForDisplay( - dashboardAccess.buildAuthenticatedDashboardUrl(dashboardBaseUrl, token), - ); - - console.log(""); - console.log(` ${"─".repeat(50)}`); - console.log(` ${cliDisplayName()} is ready`); - console.log(""); - console.log(` Sandbox: ${sandboxName}`); - console.log(` Model: ${model} (${providerLabel})`); - if (showNim) { - console.log(` NIM: ${nimLabel}`); - } - console.log(""); - if (agent) { - console.log(" Access"); - console.log(""); - agentOnboard.printDashboardUi(sandboxName, token, agent, { - note, - buildControlUiUrls: (tokenValue: string | null, port: number) => { - return buildControlUiUrls(tokenValue, port, chain.accessUrl); - }, - }); - console.log(""); - console.log(" Terminal:"); - console.log(` ${cliName()} ${sandboxName} connect`); - } else if (token) { - console.log(" Start chatting"); - console.log(""); - console.log(" Browser:"); - console.log(` ${dashboardUrl}`); - console.log(""); - console.log(" Terminal:"); - console.log(` ${cliName()} ${sandboxName} connect`); - console.log(" then run: openclaw tui"); - console.log(""); - console.log(" Authenticated dashboard URL, if needed:"); - console.log(` ${cliName()} ${sandboxName} dashboard-url --quiet`); - } else { - note(" Could not read gateway token from the sandbox (download failed)."); - console.log(" Start chatting"); - console.log(""); - console.log(" Browser:"); - console.log(` ${dashboardUrl}`); - console.log(""); - console.log(" Terminal:"); - console.log(` ${cliName()} ${sandboxName} connect`); - console.log(" then run: openclaw tui"); - } - console.log(""); - console.log(" Manage later"); - console.log(""); - console.log(` Status: ${cliName()} ${sandboxName} status`); - console.log(` Logs: ${cliName()} ${sandboxName} logs --follow`); - console.log( - ` Model: ${cliName()} inference set --model --provider --sandbox ${sandboxName}`, - ); - console.log(` Policies: ${cliName()} ${sandboxName} policy-add`); - console.log(` Credentials: ${cliName()} credentials reset && ${cliName()} onboard`); - console.log(` ${"─".repeat(50)}`); - console.log(""); -} - -// Preserve the nullable contract end-to-end: `null` means "clear this -// field on the persisted session", `undefined` means "leave unchanged". -function toNullableString(value: string | null | undefined): string | null | undefined { - if (value === undefined) return undefined; - if (value === null) return null; - return value; -} - -function toSessionUpdates( - updates: { - sandboxName?: string | null; - provider?: string | null; - model?: string | null; - endpointUrl?: string | null; - credentialEnv?: string | null; - hermesAuthMethod?: HermesAuthMethod | string | null; - preferredInferenceApi?: string | null; - nimContainer?: string | null; - webSearchConfig?: WebSearchConfig | null; - policyPresets?: string[] | null; - messagingChannels?: string[] | null; - messagingChannelConfig?: MessagingChannelConfig | null; - hermesToolGateways?: string[] | null; - } = {}, -): SessionUpdates { - const normalized: SessionUpdates = {}; - if (updates.sandboxName !== undefined) - normalized.sandboxName = toNullableString(updates.sandboxName); - if (updates.provider !== undefined) normalized.provider = toNullableString(updates.provider); - if (updates.model !== undefined) normalized.model = toNullableString(updates.model); - if (updates.endpointUrl !== undefined) - normalized.endpointUrl = toNullableString(updates.endpointUrl); - if (updates.credentialEnv !== undefined) - normalized.credentialEnv = toNullableString(updates.credentialEnv); - if (updates.hermesAuthMethod !== undefined) - normalized.hermesAuthMethod = normalizeHermesAuthMethod(updates.hermesAuthMethod); - if (updates.preferredInferenceApi !== undefined) { - normalized.preferredInferenceApi = toNullableString(updates.preferredInferenceApi); - } - if (updates.nimContainer !== undefined) - normalized.nimContainer = toNullableString(updates.nimContainer); - if (updates.webSearchConfig !== undefined) normalized.webSearchConfig = updates.webSearchConfig; - if (updates.policyPresets !== undefined) normalized.policyPresets = updates.policyPresets; - if (updates.messagingChannels !== undefined) - normalized.messagingChannels = updates.messagingChannels; - if (updates.messagingChannelConfig !== undefined) { - normalized.messagingChannelConfig = updates.messagingChannelConfig; - } - if (updates.hermesToolGateways !== undefined) - normalized.hermesToolGateways = updates.hermesToolGateways; - return normalized; -} +const { + buildChain, + buildControlUiUrls, + buildOrphanedSandboxRollbackMessage, + ensureDashboardForward, + ensureAgentDashboardForward, + fetchGatewayAuthTokenFromSandbox, + getDashboardForwardPort, + getWslHostAddress, + printDashboard, + stopAllDashboardForwards, +} = onboardDashboard.createOnboardDashboardHelpers({ + runOpenshell, + runCaptureOpenshell, + runCapture, + cliName, + agentProductName, + getProviderLabel, + note, + isWsl, + redact, + sleep: sleepSeconds, + printAgentDashboardUi: agentOnboard.printDashboardUi, +}); const onboardRuntimeBoundary = new OnboardRuntimeBoundary({ - toSessionUpdates, + toSessionUpdates: (updates: Record) => + toSessionUpdates(updates as Parameters[0]), maybeForceE2eStepFailure, }); -const onboardRuntimeRecorders = onboardRuntimeBoundary.recorders(); + +const startRecordedStep = onboardRuntimeBoundary.startRecordedStep.bind(onboardRuntimeBoundary); +const recordStepComplete = onboardRuntimeBoundary.recordStepComplete.bind(onboardRuntimeBoundary); +const recordStepSkipped = onboardRuntimeBoundary.recordStepSkipped.bind(onboardRuntimeBoundary); +const recordStepFailed = onboardRuntimeBoundary.recordStepFailed.bind(onboardRuntimeBoundary); +const recordStateSkipped = onboardRuntimeBoundary.recordStateSkipped.bind(onboardRuntimeBoundary); +const recordRepairEvent = onboardRuntimeBoundary.recordRepairEvent.bind(onboardRuntimeBoundary); +const recordSessionComplete = onboardRuntimeBoundary.recordSessionComplete.bind(onboardRuntimeBoundary); const ONBOARD_STEP_INDEX: Record = { preflight: { number: 1, title: "Preflight checks" }, gateway: { number: 2, title: "Starting OpenShell gateway" }, - provider_selection: { number: 3, title: "Configuring inference provider" }, + provider_selection: { number: 3, title: "Configuring inference (NIM)" }, inference: { number: 4, title: "Setting up inference provider" }, messaging: { number: 5, title: "Messaging channels" }, sandbox: { number: 6, title: "Creating sandbox" }, @@ -9243,7 +7076,6 @@ async function onboard(opts: OnboardOptions = {}): Promise { note( ` Agent changed from ${formatSandboxAgentName(recordedAgentName)} to ${formatSandboxAgentName(selectedAgentName)}; refreshing provider selection.`, ); - // Agent changes are recoverable resume drift: refresh scoped state instead of rejecting --resume. await stopTrackedModelRouterForAgentChange( session, loadBlueprintProfile("routed")?.router.port || 4000, @@ -9260,6 +7092,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { const recordedSandboxName = session?.steps?.sandbox?.status === "complete" ? session?.sandboxName || null : null; + const resumeSandboxNameForGpu = recordedSandboxName || requestedSandboxName || null; console.log(""); console.log(` ${cliDisplayName()} Onboarding`); @@ -9289,7 +7122,9 @@ async function onboard(opts: OnboardOptions = {}): Promise { resolveSandboxGpuConfig, validateSandboxGpuPreflight, skippedStepMessage, - ...onboardRuntimeRecorders, + recordStateSkipped, + startRecordedStep, + recordStepComplete, updateSession: onboardSession.updateSession, }, }); @@ -9356,9 +7191,11 @@ async function onboard(opts: OnboardOptions = {}): Promise { retireLegacyGatewayForDockerDriverUpgrade, destroyGatewayRuntimeForGpuReuse: () => destroyGateway(() => undefined, () => false), skippedStepMessage, - ...onboardRuntimeRecorders, + recordStateSkipped, note, + startRecordedStep, startGateway, + recordStepComplete, exitProcess: (code) => process.exit(code), }, }); @@ -9389,7 +7226,12 @@ async function onboard(opts: OnboardOptions = {}): Promise { provider: session?.provider || null, endpointUrl: session?.endpointUrl || null, credentialEnv: session?.credentialEnv || null, - hermesAuthMethod: session?.hermesAuthMethod || null, + hermesAuthMethod: + normalizeHermesAuthMethod(session?.hermesAuthMethod) || + (session?.provider === hermesProviderAuth.HERMES_PROVIDER_NAME && + session?.credentialEnv === HERMES_NOUS_API_KEY_CREDENTIAL_ENV + ? HERMES_AUTH_METHOD_API_KEY + : null), hermesToolGateways: normalizeHermesToolGatewaySelections(session?.hermesToolGateways), preferredInferenceApi: session?.preferredInferenceApi || null, nimContainer: session?.nimContainer || null, @@ -9406,10 +7248,13 @@ async function onboard(opts: OnboardOptions = {}): Promise { normalizeHermesAuthMethod, setupNim, setupInference, - ...onboardRuntimeRecorders, + startRecordedStep, + recordStepComplete, toSessionUpdates: (updates) => toSessionUpdates(updates as Parameters[0]), skippedStepMessage, ensureResumeProviderReady, + recordStateSkipped, + recordRepairEvent, hydrateCredentialEnv, repairLocalInferenceSystemdOverrideOrExit, isNonInteractive, @@ -9447,7 +7292,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { preferredInferenceApi, nimContainer, } = providerInferenceResult; - let webSearchConfig = providerInferenceResult.webSearchConfig; + let webSearchConfig = providerInferenceResult.webSearchConfig as WebSearchConfig | null; const sandboxStateResult = await handleSandboxState({ resume, @@ -9489,7 +7334,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { ensureValidatedBraveSearchCredential, isBackToSelection, configureWebSearch, - ...onboardRuntimeRecorders, + startRecordedStep, getRecordedMessagingChannelsForResume, getSandboxMessagingChannels: (name) => registry.getSandbox(name)?.messagingChannels, setupMessagingChannels, @@ -9501,8 +7346,11 @@ async function onboard(opts: OnboardOptions = {}): Promise { updateSandboxRegistry: (name, updates) => registry.updateSandbox(name, updates), setDefaultSandbox: registry.setDefault, getSandboxAgentRegistryFields, + recordStepComplete, toSessionUpdates: (updates) => toSessionUpdates(updates as Parameters[0]), skippedStepMessage, + recordStateSkipped, + recordRepairEvent, error: (message) => console.error(message), exitProcess: (code) => process.exit(code), }, @@ -9529,15 +7377,23 @@ async function onboard(opts: OnboardOptions = {}): Promise { runCaptureOpenshell, openshellShellCommand, openshellBinary: getOpenshellBinary(), - ...onboardRuntimeRecorders, + buildSandboxConfigSyncScript, + writeSandboxConfigSyncFile, + cleanupTempDir, + startRecordedStep, + recordStepComplete, + recordStepFailed, skippedStepMessage, }), ensureAgentDashboardForward, - ...onboardRuntimeRecorders, + recordStepSkipped, isOpenclawReady, skippedStepMessage, + recordStateSkipped, + startRecordedStep, setupOpenclaw, syncNemoClawConfigInSandbox, + recordStepComplete, toSessionUpdates: (updates) => toSessionUpdates(updates as Parameters[0]), }, }); @@ -9569,9 +7425,11 @@ async function onboard(opts: OnboardOptions = {}): Promise { preparePolicyPresetResumeSelection({ policies }, name, options), arePolicyPresetsApplied, skippedStepMessage, - ...onboardRuntimeRecorders, + recordStateSkipped, + startRecordedStep, setupPoliciesWithSelection, updateSession: onboardSession.updateSession, + recordStepComplete, toSessionUpdates: (updates) => toSessionUpdates(updates as Parameters[0]), }, }); @@ -9589,7 +7447,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { migratedLegacyKeys, deps: { ensureAgentDashboardForward, - ...onboardRuntimeRecorders, + recordSessionComplete, toSessionUpdates: (updates) => toSessionUpdates(updates as Parameters[0]), removeLegacyCredentialsFile, cleanupStaleHostFiles, @@ -9627,7 +7485,6 @@ async function onboard(opts: OnboardOptions = {}): Promise { log: (message) => console.log(message), }, }); - completed = true; } finally { releaseOnboardLock(); onboardRuntimeBoundary.clear(); @@ -9713,6 +7570,7 @@ module.exports = { recoverGatewayRuntime, buildChain, buildControlUiUrls, + startGateway, findAvailableDashboardPort, findDashboardForwardOwner, diff --git a/src/lib/onboard/agent-selection.ts b/src/lib/onboard/agent-selection.ts new file mode 100644 index 0000000000..3f38593aee --- /dev/null +++ b/src/lib/onboard/agent-selection.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentDefinition } from "../agent/defs"; + +export interface SelectOnboardAgentDeps { + resolveAgent(options: { + agentFlag?: string | null; + session?: { agent?: string | null } | null; + }): AgentDefinition | null; + loadAgent(name: string): AgentDefinition; + isNonInteractive(): boolean; + note(message: string): void; +} + +export function createSelectOnboardAgent(deps: SelectOnboardAgentDeps) { + return async function selectOnboardAgent({ + agentFlag = null, + session = null, + }: { + agentFlag?: string | null; + session?: { agent?: string | null } | null; + resume?: boolean; + canPrompt?: boolean; + } = {}): Promise { + const agent = deps.resolveAgent({ agentFlag, session }); + if (deps.isNonInteractive()) { + const displayName = agent?.displayName || deps.loadAgent("openclaw").displayName; + deps.note(` [non-interactive] Agent: ${displayName}`); + } + return agent; + }; +} diff --git a/src/lib/onboard/base-image.ts b/src/lib/onboard/base-image.ts new file mode 100644 index 0000000000..3f9f14daa3 --- /dev/null +++ b/src/lib/onboard/base-image.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ROOT } from "../runner"; +import { + buildLocalBaseTag, + defaultOpenclawBaseDockerfile, + resolveSandboxBaseImage, + OPENCLAW_SANDBOX_BASE_IMAGE as SANDBOX_BASE_IMAGE, +} from "../sandbox-base-image"; +import { getInstalledOpenshellVersion } from "./openshell-version"; + +/** + * Resolve a compatible sandbox-base image and pin it to a repo digest when + * possible. PR-branch validation first tries a source-SHA tag, then latest, + * and finally a local Dockerfile.base build when the OpenShell Docker driver + * requires a newer glibc than the published image provides. + */ +export function pullAndResolveBaseImageDigest( + options: { requireOpenshellSandboxAbi?: boolean } = {}, +): { digest: string | null; ref: string; source?: string; glibcVersion?: string | null } | null { + return resolveSandboxBaseImage({ + imageName: SANDBOX_BASE_IMAGE, + dockerfilePath: defaultOpenclawBaseDockerfile(ROOT), + localTag: buildLocalBaseTag("nemoclaw-sandbox-base-local", ROOT), + envVar: "NEMOCLAW_SANDBOX_BASE_IMAGE_REF", + label: "OpenClaw sandbox base image", + requireOpenshellSandboxAbi: options.requireOpenshellSandboxAbi === true, + rootDir: ROOT, + }); +} + +export function getStableGatewayImageRef(versionOutput: string | null = null): string | null { + const version = getInstalledOpenshellVersion(versionOutput); + if (!version) return null; + return `ghcr.io/nvidia/openshell/cluster:${version}`; +} diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts new file mode 100644 index 0000000000..bfd3d35e56 --- /dev/null +++ b/src/lib/onboard/dashboard.ts @@ -0,0 +1,437 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import type { AgentDefinition } from "../agent/defs"; +import { DASHBOARD_PORT } from "../core/ports"; +import { buildChain, buildControlUiUrls } from "../dashboard/contract"; +import * as nim from "../inference/nim"; +import { runCapture as defaultRunCapture } from "../runner"; +import * as dashboardAccess from "./dashboard-access"; +import { + findAvailableDashboardPort, + getOccupiedPorts, + isLiveForwardStatus, +} from "./dashboard-port"; +import { + looksLikeForwardPortConflict, + runBackgroundForwardStartWithPortReleaseRetries, +} from "./forward-start"; +import { bestEffortForwardStop } from "./forward-cleanup"; + +const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; +export const CONTROL_UI_PORT = DASHBOARD_PORT; + +type CommandResult = { status: number | null }; + +export interface OnboardDashboardDeps { + runOpenshell(args: string[], opts?: Record): CommandResult; + runCaptureOpenshell(args: string[], opts?: Record): string | null; + runCapture?: typeof defaultRunCapture; + cliName(): string; + agentProductName(): string; + getProviderLabel(provider: string): string; + note(message: string): void; + isWsl(): boolean; + redact(value: unknown): string; + sleep(seconds: number): void; + printAgentDashboardUi( + sandboxName: string, + token: string | null, + agent: AgentDefinition, + deps: { + note: (msg: string) => void; + buildControlUiUrls: (token: string | null, port: number) => string[]; + }, + ): void; +} + +export interface OnboardDashboardHelpers { + buildChain: typeof buildChain; + buildControlUiUrls: typeof buildControlUiUrls; + buildOrphanedSandboxRollbackMessage( + sandboxName: string, + err: unknown, + deleteSucceeded: boolean, + ): string[]; + ensureDashboardForward( + sandboxName: string, + chatUiUrl?: string, + options?: { rollbackSandboxOnFailure?: boolean }, + ): number; + ensureAgentDashboardForward( + sandboxName: string, + agent: { forwardPort?: number | null }, + ): number; + fetchGatewayAuthTokenFromSandbox(sandboxName: string): string | null; + getDashboardForwardPort( + chatUiUrl?: string, + options?: Parameters[1], + ): string; + getDashboardForwardTarget( + chatUiUrl?: string, + options?: Parameters[1], + ): string; + getWslHostAddress( + options?: Parameters[0], + ): string | null; + printDashboard( + sandboxName: string, + model: string, + provider: string, + nimContainer?: string | null, + agent?: AgentDefinition | null, + ): void; + stopAllDashboardForwards(): void; +} + +function findForwardEntry( + forwardListOutput: string | null | undefined, + port: string, +): { sandboxName: string; status: string } | null { + if (!forwardListOutput) return null; + for (const rawLine of forwardListOutput.split("\n")) { + const line = rawLine.replace(ANSI_RE, ""); + if (/^\s*SANDBOX\s/i.test(line)) continue; + const parts = line.trim().split(/\s+/); + if (parts.length < 3 || parts[2] !== port) continue; + return { + sandboxName: parts[0] || "", + status: (parts[4] || "").toLowerCase(), + }; + } + return null; +} + +function getRunningForwardPorts(forwardListOutput: string | null | undefined): string[] { + const ports = new Set(); + if (!forwardListOutput) return []; + for (const rawLine of forwardListOutput.split("\n")) { + const line = rawLine.replace(ANSI_RE, ""); + if (/^\s*SANDBOX\s/i.test(line)) continue; + const parts = line.trim().split(/\s+/); + if (parts.length < 5 || !/^\d+$/.test(parts[2])) continue; + const status = (parts[4] || "").toLowerCase(); + if (isLiveForwardStatus(status)) { + ports.add(parts[2]); + } + } + return [...ports]; +} + +function findOpenclawJsonPath(dir: string): string | null { + if (!fs.existsSync(dir)) return null; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const found: string | null = findOpenclawJsonPath(entryPath); + if (found) return found; + } else if (entry.name === "openclaw.json") { + return entryPath; + } + } + return null; +} + +function dashboardUrlForDisplay(url: string, deps: OnboardDashboardDeps): string { + return dashboardAccess.dashboardUrlForDisplay(url, deps.redact); +} + +export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): OnboardDashboardHelpers { + const runCapture = deps.runCapture ?? defaultRunCapture; + + function getDashboardForwardPort( + chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`, + options: Parameters[1] = {}, + ): string { + return dashboardAccess.getDashboardForwardPort(chatUiUrl, { + ...options, + runCapture: options.runCapture || runCapture, + }); + } + + function getDashboardForwardTarget( + chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`, + options: Parameters[1] = {}, + ): string { + return dashboardAccess.getDashboardForwardTarget(chatUiUrl, { + ...options, + runCapture: options.runCapture || runCapture, + }); + } + + function getWslHostAddress( + options: Parameters[0] = {}, + ): string | null { + return dashboardAccess.getWslHostAddress({ ...options, runCapture: options.runCapture || runCapture }); + } + + function stopAllDashboardForwards(): void { + const forwardList = deps.runCaptureOpenshell(["forward", "list"], { ignoreError: true }); + for (const port of getRunningForwardPorts(forwardList)) { + bestEffortForwardStop(deps.runOpenshell, port); + } + } + + function buildOrphanedSandboxRollbackMessage( + sandboxName: string, + err: unknown, + deleteSucceeded: boolean, + ): string[] { + const lines = [ + "", + ` Could not allocate a dashboard port for '${sandboxName}'.`, + ` ${err instanceof Error ? err.message : String(err)}`, + ]; + if (deleteSucceeded) { + lines.push(" The orphaned sandbox has been removed — you can safely retry."); + } else { + lines.push(" Could not remove the orphaned sandbox. Manual cleanup:"); + lines.push(` openshell sandbox delete "${sandboxName}"`); + } + return lines; + } + + function rollbackSandboxAndExit(sandboxName: string, err: unknown): never { + const delResult = deps.runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); + for (const line of buildOrphanedSandboxRollbackMessage( + sandboxName, + err, + delResult.status === 0, + )) { + console.error(line); + } + process.exit(1); + } + + function ensureDashboardForward( + sandboxName: string, + chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`, + options: { rollbackSandboxOnFailure?: boolean } = {}, + ): number { + const { rollbackSandboxOnFailure = false } = options; + const preferredPort = Number(getDashboardForwardPort(chatUiUrl)); + let existingForwards = deps.runCaptureOpenshell(["forward", "list"], { ignoreError: true }); + const preferredEntry = findForwardEntry(existingForwards, String(preferredPort)); + if ( + preferredEntry && + (preferredEntry.sandboxName === sandboxName || !isLiveForwardStatus(preferredEntry.status)) + ) { + bestEffortForwardStop(deps.runOpenshell, preferredPort); + existingForwards = deps.runCaptureOpenshell(["forward", "list"], { ignoreError: true }); + } + let actualPort: number; + try { + actualPort = findAvailableDashboardPort(sandboxName, preferredPort, existingForwards); + } catch (err) { + if (!rollbackSandboxOnFailure) throw err; + rollbackSandboxAndExit(sandboxName, err); + } + + if (actualPort !== preferredPort) { + if (rollbackSandboxOnFailure) { + const err = new Error( + `Dashboard port ${preferredPort} became host-bound during sandbox build; ` + + `cannot reallocate to ${actualPort} after the sandbox has been created with ` + + `CHAT_UI_URL=${preferredPort}. Free the port and re-run \`${deps.cliName()} onboard\`, ` + + `or pass \`--control-ui-port \` to pick a different dashboard port.`, + ); + rollbackSandboxAndExit(sandboxName, err); + } + console.warn(` ! Port ${preferredPort} is taken. Using port ${actualPort} instead.`); + } + + const occupied = getOccupiedPorts(existingForwards); + for (const [port, owner] of occupied.entries()) { + if (owner === sandboxName && Number(port) !== actualPort) { + bestEffortForwardStop(deps.runOpenshell, port); + } + } + + const parsedUrl = new URL(chatUiUrl.includes("://") ? chatUiUrl : `http://${chatUiUrl}`); + parsedUrl.port = String(actualPort); + const actualTarget = getDashboardForwardTarget(parsedUrl.toString()); + bestEffortForwardStop(deps.runOpenshell, actualPort); + const { result: fwdResult, diagnostic: fwdDiagnostic } = runBackgroundForwardStartWithPortReleaseRetries( + (stdio, timeout) => + deps.runOpenshell( + ["forward", "start", "--background", actualTarget, sandboxName], + { ignoreError: true, suppressOutput: true, stdio, timeout }, + ), + () => { + deps.sleep(1); + bestEffortForwardStop(deps.runOpenshell, actualPort); + }, + ); + if (fwdResult && fwdResult.status !== 0) { + const looksLikePortConflict = looksLikeForwardPortConflict(fwdDiagnostic); + if (rollbackSandboxOnFailure) { + const err = new Error( + looksLikePortConflict + ? `Failed to start dashboard forward on port ${actualPort} — the host port ` + + `is held by another process. Free it and run \`${deps.cliName()} onboard\` again, ` + + `or pass \`--control-ui-port \` to pick a different dashboard port.` + : `Failed to start dashboard forward on port ${actualPort}: ${fwdDiagnostic.slice(0, 240)}`, + ); + rollbackSandboxAndExit(sandboxName, err); + } + if (looksLikePortConflict) { + console.warn( + `! Port ${actualPort} forward did not start — port may be in use by another process.`, + ); + console.warn( + ` Check: docker ps --format 'table {{.Names}}\\t{{.Ports}}' | grep ${actualPort}`, + ); + console.warn(` Free the port, then reconnect: ${deps.cliName()} ${sandboxName} connect`); + } else { + console.warn(`! Port ${actualPort} forward did not start: ${fwdDiagnostic.slice(0, 240)}`); + console.warn(` Reconnect after resolving the issue: ${deps.cliName()} ${sandboxName} connect`); + } + } + return actualPort; + } + + function ensureAgentDashboardForward( + sandboxName: string, + agent: { forwardPort?: number | null }, + ): number { + const agentDashboardPort = agent.forwardPort ?? CONTROL_UI_PORT; + const agentDashboardUrl = `http://127.0.0.1:${agentDashboardPort}`; + const actualAgentDashboardPort = ensureDashboardForward(sandboxName, agentDashboardUrl); + process.env.CHAT_UI_URL = `http://127.0.0.1:${actualAgentDashboardPort}`; + return actualAgentDashboardPort; + } + + function fetchGatewayAuthTokenFromSandbox(sandboxName: string): string | null { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-token-")); + try { + const destDir = `${tmpDir}${path.sep}`; + const result = deps.runOpenshell( + ["sandbox", "download", sandboxName, "/sandbox/.openclaw/openclaw.json", destDir], + { ignoreError: true, stdio: ["ignore", "ignore", "ignore"] }, + ); + if (result.status !== 0) return null; + const jsonPath = findOpenclawJsonPath(tmpDir); + if (!jsonPath) return null; + const cfg = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); + const token = cfg && cfg.gateway && cfg.gateway.auth && cfg.gateway.auth.token; + return typeof token === "string" && token.length > 0 ? token : null; + } catch { + return null; + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } + } + + function printDashboard( + sandboxName: string, + model: string, + provider: string, + nimContainer: string | null = null, + agent: AgentDefinition | null = null, + ): void { + const nimStat = nimContainer ? nim.nimStatusByName(nimContainer) : nim.nimStatus(sandboxName); + const showNim = nim.shouldShowNimLine(nimContainer, nimStat.running); + const nimLabel = nimStat.running ? "running" : "not running"; + const providerLabel = deps.getProviderLabel(provider); + const token = fetchGatewayAuthTokenFromSandbox(sandboxName); + const chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`; + const wslAddr = getWslHostAddress(); + const chain = buildChain({ chatUiUrl, isWsl: deps.isWsl(), wslHostAddress: wslAddr }); + + const dashboardAccessEntries = buildControlUiUrls(token, chain.port, chain.accessUrl).map((url, index) => ({ + label: index === 0 ? "Dashboard" : `Alt ${index}`, + url, + })); + if (wslAddr) { + const wslUrl = `http://${wslAddr}:${chain.port}/${token ? `#token=${encodeURIComponent(token)}` : ""}`; + const existing = dashboardAccessEntries.find((entry) => entry.url === wslUrl); + if (existing) existing.label = "VS Code/WSL"; + else dashboardAccessEntries.push({ label: "VS Code/WSL", url: wslUrl }); + } + const guidanceLines = [`Port ${chain.port} must be forwarded before opening these URLs.`]; + if (deps.isWsl()) { + guidanceLines.push( + "WSL detected: if localhost fails in Windows, use the WSL host IP shown by `hostname -I`.", + ); + } + if (dashboardAccessEntries.length === 0) guidanceLines.push("No dashboard URLs were generated."); + + console.log(""); + console.log(` ${"─".repeat(50)}`); + console.log(` Sandbox ${sandboxName} (Landlock + seccomp + netns)`); + console.log(` Model ${model} (${providerLabel})`); + if (showNim) { + console.log(` NIM ${nimLabel}`); + } + console.log(` ${"─".repeat(50)}`); + console.log(` Run: ${deps.cliName()} ${sandboxName} connect`); + console.log(` Status: ${deps.cliName()} ${sandboxName} status`); + console.log(` Logs: ${deps.cliName()} ${sandboxName} logs --follow`); + console.log(""); + if (agent) { + deps.printAgentDashboardUi(sandboxName, token, agent, { + note: deps.note, + buildControlUiUrls: (tokenValue: string | null, port: number) => { + return buildControlUiUrls(tokenValue, port, chain.accessUrl); + }, + }); + } else if (token) { + console.log( + ` ${deps.agentProductName()} UI (auth token redacted from displayed URLs)`, + ); + for (const line of guidanceLines) { + console.log(` ${line}`); + } + for (const entry of dashboardAccessEntries) { + console.log(` ${entry.label}: ${dashboardUrlForDisplay(entry.url, deps)}`); + } + console.log(` Token: ${deps.cliName()} ${sandboxName} gateway-token --quiet`); + console.log(" append #token= locally if the browser asks for auth."); + } else { + deps.note(" Could not read gateway token from the sandbox (download failed)."); + console.log(` ${deps.agentProductName()} UI`); + for (const line of guidanceLines) { + console.log(` ${line}`); + } + for (const entry of dashboardAccessEntries) { + console.log(` ${entry.label}: ${dashboardUrlForDisplay(entry.url, deps)}`); + } + console.log( + ` Token: ${deps.cliName()} ${sandboxName} connect → jq -r '.gateway.auth.token' /sandbox/.openclaw/openclaw.json`, + ); + console.log(" append #token= to the URL locally if needed."); + } + console.log(` ${"─".repeat(50)}`); + console.log(""); + console.log(" To change settings later:"); + console.log( + ` Model: ${deps.cliName()} inference get\n ${deps.cliName()} inference set --model --provider --sandbox ${sandboxName}`, + ); + console.log(` Policies: ${deps.cliName()} ${sandboxName} policy-add`); + console.log(` Credentials: ${deps.cliName()} credentials reset then ${deps.cliName()} onboard`); + console.log(""); + } + + return { + buildChain, + buildControlUiUrls, + buildOrphanedSandboxRollbackMessage, + ensureDashboardForward, + ensureAgentDashboardForward, + fetchGatewayAuthTokenFromSandbox, + getDashboardForwardPort, + getDashboardForwardTarget, + getWslHostAddress, + printDashboard, + stopAllDashboardForwards, + }; +} diff --git a/src/lib/onboard/gateway-reuse.ts b/src/lib/onboard/gateway-reuse.ts new file mode 100644 index 0000000000..0406e66300 --- /dev/null +++ b/src/lib/onboard/gateway-reuse.ts @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + getGatewayReuseState, + shouldSelectNamedGatewayForReuse, +} from "../state/gateway"; + +export type GatewayReuseSnapshot = { + gatewayStatus: string; + gwInfo: string; + activeGatewayInfo: string; + gatewayReuseState: ReturnType; +}; + +export interface GatewayReuseDeps { + gatewayName: string; + runCaptureOpenshell(args: string[], opts?: Record): string; + runOpenshell(args: string[], opts?: Record): { status: number | null }; + cliDisplayName(): string; +} + +export interface GatewayReuseHelpers { + getGatewayReuseSnapshot(): GatewayReuseSnapshot; + selectNamedGatewayForReuseIfNeeded(snapshot: GatewayReuseSnapshot): GatewayReuseSnapshot; +} + +export function createGatewayReuseHelpers(deps: GatewayReuseDeps): GatewayReuseHelpers { + function getGatewayReuseSnapshot(): GatewayReuseSnapshot { + const gatewayStatus = deps.runCaptureOpenshell(["status"], { ignoreError: true }); + const gwInfo = deps.runCaptureOpenshell(["gateway", "info", "-g", deps.gatewayName], { + ignoreError: true, + }); + const activeGatewayInfo = deps.runCaptureOpenshell(["gateway", "info"], { ignoreError: true }); + return { + gatewayStatus, + gwInfo, + activeGatewayInfo, + gatewayReuseState: getGatewayReuseState(gatewayStatus, gwInfo, activeGatewayInfo), + }; + } + + function selectNamedGatewayForReuseIfNeeded(snapshot: GatewayReuseSnapshot): GatewayReuseSnapshot { + if ( + !shouldSelectNamedGatewayForReuse( + snapshot.gatewayStatus, + snapshot.gwInfo, + snapshot.activeGatewayInfo, + ) + ) { + return snapshot; + } + + const selectResult = deps.runOpenshell(["gateway", "select", deps.gatewayName], { + ignoreError: true, + suppressOutput: true, + }); + if (selectResult.status !== 0) { + return snapshot; + } + + const refreshed = getGatewayReuseSnapshot(); + if (refreshed.gatewayReuseState === "healthy") { + process.env.OPENSHELL_GATEWAY = deps.gatewayName; + console.log(` ✓ Selected existing ${deps.cliDisplayName()} gateway`); + } + return refreshed; + } + + return { getGatewayReuseSnapshot, selectNamedGatewayForReuseIfNeeded }; +} diff --git a/src/lib/onboard/hermes-auth.ts b/src/lib/onboard/hermes-auth.ts new file mode 100644 index 0000000000..5e3be3aa95 --- /dev/null +++ b/src/lib/onboard/hermes-auth.ts @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { normalizeCredentialValue } from "../credentials/store"; +import type { HermesAuthMethod } from "../hermes-provider-auth"; +import * as hermesProviderAuth from "../hermes-provider-auth"; + +export type { HermesAuthMethod }; + +export const HERMES_AUTH_METHOD_OAUTH: HermesAuthMethod = "oauth"; +export const HERMES_AUTH_METHOD_API_KEY: HermesAuthMethod = "api_key"; +export const HERMES_NOUS_API_KEY_CREDENTIAL_ENV = + hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV || "NOUS_API_KEY"; +export const HERMES_NOUS_API_KEY_HELP_URL = "https://portal.nousresearch.com/manage-subscription"; + +export function normalizeHermesAuthMethod(value: string | null | undefined): HermesAuthMethod | null { + const normalized = String(value || "") + .trim() + .toLowerCase() + .replace(/[\s-]+/g, "_"); + if (!normalized) return null; + if (normalized === "oauth" || normalized === "nous_oauth" || normalized === "nous_portal_oauth") { + return HERMES_AUTH_METHOD_OAUTH; + } + if ( + normalized === "api" || + normalized === "key" || + normalized === "api_key" || + normalized === "apikey" || + normalized === "nous_api_key" + ) { + return HERMES_AUTH_METHOD_API_KEY; + } + return null; +} + +export function hermesAuthMethodLabel(method: HermesAuthMethod | null | undefined): string { + return method === HERMES_AUTH_METHOD_API_KEY ? "Nous API Key" : "Nous Portal OAuth"; +} + +export function getRequestedHermesAuthMethod(): HermesAuthMethod | null { + const raw = + process.env.NEMOCLAW_HERMES_AUTH_METHOD || + process.env.NEMOCLAW_HERMES_AUTH || + process.env.NEMOCLAW_NOUS_AUTH_METHOD || + ""; + const method = normalizeHermesAuthMethod(raw); + if (!raw || method) return method; + console.error(` Unsupported Hermes Provider auth method: ${raw}`); + console.error(" Valid values: oauth, nous-portal-oauth, api-key, nous-api-key"); + process.exit(1); +} + +export interface HermesAuthFlowDeps { + isNonInteractive(): boolean; + note(message: string): void; + prompt(question: string, options?: { secret?: boolean }): Promise; + getNavigationChoice(value?: string): "back" | "exit" | null; + exitOnboardFromPrompt(): never; + validateNvidiaApiKeyValue(value: string, envName: string): string | null; + compactText(value: string): string; + redact(value: unknown): string; + runOpenshell(args: string[], opts?: Record): { + status?: number | null; + stdout?: string | Buffer | null; + stderr?: string | Buffer | null; + }; + backToSelection: unknown; +} + +export interface HermesAuthHelpers { + promptHermesAuthMethod(): Promise; + resolveHermesNousApiKey(): string | null; + stageNousApiKeyProviderEnv(): void; + ensureHermesNousApiKeyEnv(): Promise; + openshellResultMessage(result: { + stdout?: string | Buffer | null; + stderr?: string | Buffer | null; + }): string; + checkHermesProviderStoreReachable( + runOpenshellImpl?: HermesAuthFlowDeps["runOpenshell"], + ): { ok: true } | { ok: false; message: string }; +} + +export function createHermesAuthHelpers(deps: HermesAuthFlowDeps): HermesAuthHelpers { + async function promptHermesAuthMethod(): Promise { + const methods: Array<{ key: HermesAuthMethod; label: string }> = [ + { key: HERMES_AUTH_METHOD_OAUTH, label: "Nous Portal OAuth (authenticate via browser)" }, + { + key: HERMES_AUTH_METHOD_API_KEY, + label: "Nous API Key (paste a key from the provider dashboard)", + }, + ]; + const requested = getRequestedHermesAuthMethod(); + if (deps.isNonInteractive()) { + const method = + requested || + (resolveHermesNousApiKey() + ? HERMES_AUTH_METHOD_API_KEY + : HERMES_AUTH_METHOD_OAUTH); + deps.note(` [non-interactive] Hermes auth: ${hermesAuthMethodLabel(method)}`); + return method; + } + + console.log(""); + console.log(" Hermes Provider authentication:"); + methods.forEach((method, index) => { + console.log(` ${index + 1}) ${method.label}`); + }); + console.log(""); + + const defaultIdx = (requested ? methods.findIndex((method) => method.key === requested) : 0) + 1; + const choice = await deps.prompt(` Choose [${defaultIdx}]: `); + const navigation = deps.getNavigationChoice(choice); + if (navigation === "back") return deps.backToSelection; + if (navigation === "exit") deps.exitOnboardFromPrompt(); + const idx = parseInt(choice || String(defaultIdx), 10) - 1; + return methods[idx]?.key || methods[defaultIdx - 1]?.key || HERMES_AUTH_METHOD_OAUTH; + } + + function resolveHermesNousApiKey(): string | null { + return ( + // check-direct-credential-env-ignore -- Hermes Provider API keys are read only from the invoking shell for OpenShell provider registration; do not resolve host credentials.json. + normalizeCredentialValue(process.env[HERMES_NOUS_API_KEY_CREDENTIAL_ENV]) || + normalizeCredentialValue(process.env.NEMOCLAW_PROVIDER_KEY) || + null + ); + } + + function stageNousApiKeyProviderEnv(): void { + const key = resolveHermesNousApiKey(); + if (key) { + process.env[HERMES_NOUS_API_KEY_CREDENTIAL_ENV] = key; + } + } + + async function ensureHermesNousApiKeyEnv(): Promise { + const existing = resolveHermesNousApiKey(); + if (existing) { + process.env[HERMES_NOUS_API_KEY_CREDENTIAL_ENV] = existing; + return existing; + } + console.log(""); + console.log(" Hermes Provider Nous API Key"); + console.log(` Create or copy a key from ${HERMES_NOUS_API_KEY_HELP_URL}`); + const rawKey = await deps.prompt(" Nous API Key: ", { + secret: true, + }); + const navigation = deps.getNavigationChoice(rawKey); + if (navigation === "back") return deps.backToSelection; + if (navigation === "exit") deps.exitOnboardFromPrompt(); + const key = normalizeCredentialValue(rawKey); + const validationError = deps.validateNvidiaApiKeyValue(key, HERMES_NOUS_API_KEY_CREDENTIAL_ENV); + if (validationError) { + console.error(validationError); + process.exit(1); + } + process.env[HERMES_NOUS_API_KEY_CREDENTIAL_ENV] = key; + return key; + } + + function openshellResultMessage(result: { + stdout?: string | Buffer | null; + stderr?: string | Buffer | null; + }): string { + return deps.compactText(deps.redact(`${result.stderr || ""} ${result.stdout || ""}`)); + } + + function checkHermesProviderStoreReachable( + runOpenshellImpl: HermesAuthFlowDeps["runOpenshell"] = deps.runOpenshell, + ): { ok: true } | { ok: false; message: string } { + const result = runOpenshellImpl(["provider", "list"], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: 10_000, + }); + if (result.status === 0) return { ok: true }; + return { + ok: false, + message: + openshellResultMessage(result) || + "OpenShell provider storage is unreachable; the gateway may be stopped or refusing connections.", + }; + } + + return { + promptHermesAuthMethod, + resolveHermesNousApiKey, + stageNousApiKeyProviderEnv, + ensureHermesNousApiKeyEnv, + openshellResultMessage, + checkHermesProviderStoreReachable, + }; +} diff --git a/src/lib/onboard/hermes-managed-tools.ts b/src/lib/onboard/hermes-managed-tools.ts index 1e90a5760e..f32afdc017 100644 --- a/src/lib/onboard/hermes-managed-tools.ts +++ b/src/lib/onboard/hermes-managed-tools.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import * as hermesProviderAuth from "../hermes-provider-auth"; import type { HermesAuthMethod } from "../hermes-provider-auth"; +import * as hermesProviderAuth from "../hermes-provider-auth"; type PromptFn = (message: string) => Promise; type RawInput = NodeJS.ReadStream & { @@ -238,6 +238,17 @@ async function selectHermesToolGatewaysInteractive( return [...selected]; } +export function normalizeHermesToolGatewaySelections(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const selected = new Set(); + for (const preset of value) { + if (typeof preset === "string" && HERMES_TOOL_GATEWAY_PRESET_NAMES.has(preset)) { + selected.add(preset); + } + } + return [...selected].sort(); +} + export function stringSetsEqual( a: string[] | null | undefined, b: string[] | null | undefined, diff --git a/src/lib/onboard/inference-selection-validation.ts b/src/lib/onboard/inference-selection-validation.ts new file mode 100644 index 0000000000..bd7d056bbc --- /dev/null +++ b/src/lib/onboard/inference-selection-validation.ts @@ -0,0 +1,225 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getCredential } from "../credentials/store"; + +const { probeAnthropicEndpoint, probeOpenAiLikeEndpoint } = require("../inference/onboard-probes") as { + probeAnthropicEndpoint(endpointUrl: string, model: string, apiKey: string | null | undefined): any; + probeOpenAiLikeEndpoint(endpointUrl: string, model: string, apiKey: string | null | undefined, options?: Record): any; +}; + +import { shouldForceCompletionsApi } from "../validation"; +import { getProbeRecovery } from "../validation-recovery"; + +export type EndpointValidationResult = + | { ok: true; api: string | null; retry?: undefined } + | { ok: false; retry: "credential" | "selection" | "retry" | "model"; api?: undefined }; + +export interface InferenceSelectionValidationDeps { + isNonInteractive(): boolean; + agentProductName(): string; + promptValidationRecovery( + label: string, + recovery: ReturnType, + credentialEnv?: string | null, + helpUrl?: string | null, + ): Promise<"credential" | "selection" | "retry" | "model">; +} + +export interface InferenceSelectionValidationHelpers { + validateOpenAiLikeSelection( + label: string, + endpointUrl: string, + model: string, + credentialEnv?: string | null, + retryMessage?: string, + helpUrl?: string | null, + options?: { + authMode?: "bearer" | "query-param"; + requireResponsesToolCalling?: boolean; + requireChatCompletionsToolCalling?: boolean; + skipResponsesProbe?: boolean; + probeStreaming?: boolean; + allowHostDockerInternal?: boolean; + }, + ): Promise; + validateAnthropicSelectionWithRetryMessage( + label: string, + endpointUrl: string, + model: string, + credentialEnv: string, + retryMessage?: string, + helpUrl?: string | null, + ): Promise; + validateCustomOpenAiLikeSelection( + label: string, + endpointUrl: string, + model: string, + credentialEnv: string, + helpUrl?: string | null, + ): Promise; + validateCustomAnthropicSelection( + label: string, + endpointUrl: string, + model: string, + credentialEnv: string, + helpUrl?: string | null, + ): Promise; +} + +export function createInferenceSelectionValidationHelpers( + deps: InferenceSelectionValidationDeps, +): InferenceSelectionValidationHelpers { + async function validateOpenAiLikeSelection( + label: string, + endpointUrl: string, + model: string, + credentialEnv: string | null = null, + retryMessage = "Please choose a provider/model again.", + helpUrl: string | null = null, + options: { + authMode?: "bearer" | "query-param"; + requireResponsesToolCalling?: boolean; + requireChatCompletionsToolCalling?: boolean; + skipResponsesProbe?: boolean; + probeStreaming?: boolean; + allowHostDockerInternal?: boolean; + } = {}, + ): Promise { + const apiKey = credentialEnv ? getCredential(credentialEnv) : ""; + const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options); + if (!probe.ok) { + console.error(` ${label} endpoint validation failed.`); + console.error(` ${probe.message}`); + if (deps.isNonInteractive()) { + process.exit(1); + } + const retry = await deps.promptValidationRecovery( + label, + getProbeRecovery(probe), + credentialEnv, + helpUrl, + ); + if (retry === "selection") { + console.log(` ${retryMessage}`); + console.log(""); + } + return { ok: false, retry }; + } + if (probe.note) { + console.log(` ℹ ${probe.note}`); + } else { + console.log(` ${probe.label} available — ${deps.agentProductName()} will use ${probe.api}.`); + } + return { ok: true, api: probe.api ?? "openai-completions" }; + } + + async function validateAnthropicSelectionWithRetryMessage( + label: string, + endpointUrl: string, + model: string, + credentialEnv: string, + retryMessage = "Please choose a provider/model again.", + helpUrl: string | null = null, + ): Promise { + const apiKey = getCredential(credentialEnv); + const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); + if (!probe.ok) { + console.error(` ${label} endpoint validation failed.`); + console.error(` ${probe.message}`); + if (deps.isNonInteractive()) { + process.exit(1); + } + const retry = await deps.promptValidationRecovery( + label, + getProbeRecovery(probe), + credentialEnv, + helpUrl, + ); + if (retry === "selection") { + console.log(` ${retryMessage}`); + console.log(""); + } + return { ok: false, retry }; + } + console.log(` ${probe.label} available — ${deps.agentProductName()} will use ${probe.api}.`); + return { ok: true, api: probe.api }; + } + + async function validateCustomOpenAiLikeSelection( + label: string, + endpointUrl: string, + model: string, + credentialEnv: string, + helpUrl: string | null = null, + ): Promise { + const apiKey = getCredential(credentialEnv); + const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, { + requireResponsesToolCalling: true, + skipResponsesProbe: shouldForceCompletionsApi(process.env.NEMOCLAW_PREFERRED_API), + probeStreaming: true, + }); + if (probe.ok) { + if (probe.note) { + console.log(` ℹ ${probe.note}`); + } else { + console.log(` ${probe.label} available — ${deps.agentProductName()} will use ${probe.api}.`); + } + return { ok: true, api: probe.api ?? "openai-completions" }; + } + console.error(` ${label} endpoint validation failed.`); + console.error(` ${probe.message}`); + if (deps.isNonInteractive()) { + process.exit(1); + } + const retry = await deps.promptValidationRecovery( + label, + getProbeRecovery(probe, { allowModelRetry: true }), + credentialEnv, + helpUrl, + ); + if (retry === "selection") { + console.log(" Please choose a provider/model again."); + console.log(""); + } + return { ok: false, retry }; + } + + async function validateCustomAnthropicSelection( + label: string, + endpointUrl: string, + model: string, + credentialEnv: string, + helpUrl: string | null = null, + ): Promise { + const apiKey = getCredential(credentialEnv); + const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); + if (probe.ok) { + console.log(` ${probe.label} available — ${deps.agentProductName()} will use ${probe.api}.`); + return { ok: true, api: probe.api }; + } + console.error(` ${label} endpoint validation failed.`); + console.error(` ${probe.message}`); + if (deps.isNonInteractive()) { + process.exit(1); + } + const retry = await deps.promptValidationRecovery( + label, + getProbeRecovery(probe, { allowModelRetry: true }), + credentialEnv, + helpUrl, + ); + if (retry === "selection") { + console.log(" Please choose a provider/model again."); + console.log(""); + } + return { ok: false, retry }; + } + + return { + validateOpenAiLikeSelection, + validateAnthropicSelectionWithRetryMessage, + validateCustomOpenAiLikeSelection, + validateCustomAnthropicSelection, + }; +} diff --git a/src/lib/onboard/known-hosts.ts b/src/lib/onboard/known-hosts.ts new file mode 100644 index 0000000000..0b3a4cb6ac --- /dev/null +++ b/src/lib/onboard/known-hosts.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Remove known_hosts lines whose host field contains an openshell-* entry. + * Preserves blank lines and comments. Returns the cleaned string. + */ +export function pruneKnownHostsEntries(contents: string): string { + return contents + .split("\n") + .filter((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) return true; + const hostField = trimmed.split(/\s+/)[0]; + return !hostField.split(",").some((host) => host.startsWith("openshell-")); + }) + .join("\n"); +} diff --git a/src/lib/onboard/messaging-config.ts b/src/lib/onboard/messaging-config.ts new file mode 100644 index 0000000000..2ac8fa7eae --- /dev/null +++ b/src/lib/onboard/messaging-config.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + type MessagingChannelConfig, + mergeMessagingChannelConfigs, + sanitizeMessagingChannelConfig, +} from "../messaging-channel-config"; +import type { Session } from "../state/onboard-session"; +import * as onboardSession from "../state/onboard-session"; +import * as registry from "../state/registry"; + +// Read TELEGRAM_REQUIRE_MENTION (set either by the interactive mention prompt +// or by the user's shell) and map it to a boolean, or null when the env var +// is unset / invalid. Used at build time to bake groupPolicy into +// openclaw.json and at resume time to detect drift against the recorded +// session state. See #1737 and the CodeRabbit follow-up on #2417. +export function computeTelegramRequireMention(): boolean | null { + const raw = process.env.TELEGRAM_REQUIRE_MENTION; + if (raw === "1") return true; + if (raw === "0") return false; + return null; +} + +export function getStoredMessagingChannelConfig( + sandboxName: string | null, + session: Session | null, +): MessagingChannelConfig | null { + const registryConfig = sandboxName + ? sanitizeMessagingChannelConfig(registry.getSandbox(sandboxName)?.messagingChannelConfig) + : null; + const sessionMatchesSandbox = + !session?.sandboxName || !sandboxName || session.sandboxName === sandboxName; + const sessionConfig = sessionMatchesSandbox + ? sanitizeMessagingChannelConfig(session?.messagingChannelConfig) + : null; + return mergeMessagingChannelConfigs(registryConfig, sessionConfig); +} + +export function persistMessagingChannelConfigToSession(config: MessagingChannelConfig | null): void { + onboardSession.updateSession((current: Session) => { + current.messagingChannelConfig = config; + return current; + }); +} + +export function messagingChannelConfigsEqual( + left: MessagingChannelConfig | null, + right: MessagingChannelConfig | null, +): boolean { + const leftKeys = Object.keys(left || {}).sort(); + const rightKeys = Object.keys(right || {}).sort(); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every((key, index) => key === rightKeys[index] && left?.[key] === right?.[key]); +} diff --git a/src/lib/onboard/messaging-credentials.ts b/src/lib/onboard/messaging-credentials.ts new file mode 100644 index 0000000000..fff0e7107b --- /dev/null +++ b/src/lib/onboard/messaging-credentials.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { normalizeCredentialValue } from "../credentials/store"; +import { hashCredential } from "../security/credential-hash"; +import * as registry from "../state/registry"; + +export interface MessagingTokenDefinition { + name: string; + envKey: string; + token?: string | null; +} + +export interface RecordedMessagingChannelsOptions { + resume: boolean; + sessionMessagingChannels?: string[] | null; + sandboxName: string | null; + channels: unknown[]; + getCredential(envKey: string): string | null | undefined; + providerExistsInGateway(name: string): boolean; + isNonInteractive(): boolean; +} + +export function getRecordedMessagingChannelsForResume({ + resume, + sessionMessagingChannels, + sandboxName, + channels, + getCredential, + providerExistsInGateway, + isNonInteractive, +}: RecordedMessagingChannelsOptions): string[] | null { + return require("./messaging-reuse").getNonInteractiveStoredMessagingChannels( + resume, + sessionMessagingChannels, + sandboxName, + channels, + (envKey: string) => Boolean(normalizeCredentialValue(process.env[envKey]) || getCredential(envKey)), + registry.getSandbox.bind(registry), + registry.getDisabledChannels.bind(registry), + providerExistsInGateway, + isNonInteractive(), + ); +} + +export function getMessagingChannelForEnvKey(envKey: string): string | null { + if (envKey === "DISCORD_BOT_TOKEN") return "discord"; + if (envKey === "SLACK_BOT_TOKEN") return "slack"; + if (envKey === "TELEGRAM_BOT_TOKEN") return "telegram"; + if (envKey === "WECHAT_BOT_TOKEN") return "wechat"; + return null; +} + +/** + * Detect whether any messaging provider credential has been rotated since + * the sandbox was created, by comparing SHA-256 hashes of the current + * token values against hashes stored in the sandbox registry. + * + * Returns `changed: false` for legacy sandboxes that have no stored hashes + * (conservative — avoids unnecessary rebuilds after upgrade). + */ +export function detectMessagingCredentialRotation( + sandboxName: string, + tokenDefs: MessagingTokenDefinition[], +): { changed: boolean; changedProviders: string[] } { + const sb = registry.getSandbox(sandboxName); + const storedHashes = sb?.providerCredentialHashes || {}; + const changedProviders = []; + for (const { name, envKey, token } of tokenDefs) { + if (!token) continue; + const storedHash = storedHashes[envKey]; + if (!storedHash) continue; + if (storedHash !== hashCredential(token)) { + changedProviders.push(name); + } + } + return { changed: changedProviders.length > 0, changedProviders }; +} diff --git a/src/lib/onboard/model-router.ts b/src/lib/onboard/model-router.ts new file mode 100644 index 0000000000..ec35e06063 --- /dev/null +++ b/src/lib/onboard/model-router.ts @@ -0,0 +1,515 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawn, spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { requireValue } from "../core/require-value"; +import { + normalizeCredentialValue, + resolveProviderCredential, + saveCredential, +} from "../credentials/store"; +import { ROOT, run, runCapture } from "../runner"; +import { hashCredential } from "../security/credential-hash"; +import type { Session } from "../state/onboard-session"; +import * as onboardSession from "../state/onboard-session"; +import { buildSubprocessEnv } from "../subprocess-env"; +import { hydrateCredentialEnv } from "./credential-env"; +import { prepareModelRouterVenv } from "./model-router-python"; + +const ROUTER_HEALTH_RETRIES = 15; +const ROUTER_HEALTH_INTERVAL_MS = 2000; +const ROUTER_HEALTH_TIMEOUT_MS = 3000; +const MODEL_ROUTER_RELATIVE_DIR = path.join("nemoclaw-blueprint", "router", "llm-router"); +const MODEL_ROUTER_VENV_DIR = path.join(os.homedir(), ".nemoclaw", "model-router-venv"); +const MODEL_ROUTER_FINGERPRINT_FILE = ".nemoclaw-source-fingerprint"; +const MODEL_ROUTER_FINGERPRINT_IGNORED_NAMES = new Set([ + ".git", + ".hg", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".svn", + ".venv", + "__pycache__", + "build", + "dist", + "node_modules", + "venv", +]); +export const DEFAULT_MODEL_ROUTER_CREDENTIAL_ENV = "NVIDIA_API_KEY"; + +export type BlueprintRouterConfig = { + enabled?: boolean; + port?: number; + pool_config_path?: string; + credential_env?: string; +}; + +export type BlueprintInferenceProfile = { + provider_name?: string; + endpoint?: string; + model: string; + credential_env?: string; + credential_default?: string; + router: BlueprintRouterConfig; +}; + +/** + * Load a named inference profile and router config from blueprint.yaml. + * Returns null if the blueprint or profile is missing. + */ +export function loadBlueprintProfile( + profileName: string, + rootDir: string = ROOT, +): BlueprintInferenceProfile | null { + try { + const YAML = require("yaml"); + const blueprintPath = path.join(rootDir, "nemoclaw-blueprint", "blueprint.yaml"); + if (!fs.existsSync(blueprintPath)) return null; + const raw = fs.readFileSync(blueprintPath, "utf8"); + const parsed = YAML.parse(raw); + const profile = parsed?.components?.inference?.profiles?.[profileName]; + if (!profile) return null; + const router = { ...(parsed?.components?.router || {}) }; + if (typeof profile.credential_env === "string" && profile.credential_env.trim().length > 0) { + router.credential_env = profile.credential_env; + } + return { ...profile, router } as BlueprintInferenceProfile; + } catch { + return null; + } +} + +async function isRouterHealthy(port: number, timeoutMs = ROUTER_HEALTH_TIMEOUT_MS): Promise { + return new Promise((resolve) => { + let settled = false; + const settle = (healthy: boolean) => { + if (settled) return; + settled = true; + resolve(healthy); + }; + const request = http + .get(`http://127.0.0.1:${port}/health`, (res: http.IncomingMessage) => { + res.resume(); + settle((res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300); + }) + .on("error", () => settle(false)); + request.setTimeout(timeoutMs, () => { + request.destroy(); + settle(false); + }); + }); +} + +function isProcessRunning(pid: number | null | undefined): boolean { + if (!Number.isInteger(pid) || Number(pid) <= 0) return false; + try { + process.kill(Number(pid), 0); + return true; + } catch { + return false; + } +} + +async function stopModelRouterProcess(pid: number, port: number): Promise { + try { + process.kill(pid, "SIGTERM"); + } catch { + return; + } + for (let attempt = 0; attempt < 10; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 500)); + if (!isProcessRunning(pid) && !(await isRouterHealthy(port, 1000))) return; + } + try { + process.kill(pid, "SIGKILL"); + } catch { + // already stopped + } + for (let attempt = 0; attempt < 5; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 500)); + if (!isProcessRunning(pid) && !(await isRouterHealthy(port, 1000))) return; + } +} + +function resolveHostCommandPath(commandName: string): string | null { + const result = runCapture(["sh", "-c", 'command -v "$1"', "--", commandName], { + ignoreError: true, + }).trim(); + return result || null; +} + +function modelRouterPackageDir(): string { + return path.join(ROOT, MODEL_ROUTER_RELATIVE_DIR); +} + +function modelRouterVenvDir(): string { + return process.env.NEMOCLAW_MODEL_ROUTER_VENV || MODEL_ROUTER_VENV_DIR; +} + +function modelRouterCommandPath(venvDir = modelRouterVenvDir()): string { + return path.join(venvDir, "bin", "model-router"); +} + +function modelRouterFingerprintPath(venvDir = modelRouterVenvDir()): string { + return path.join(venvDir, MODEL_ROUTER_FINGERPRINT_FILE); +} + +function isExecutableFile(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function isModelRouterPackageReady(routerDir = modelRouterPackageDir()): boolean { + return fs.existsSync(path.join(routerDir, "pyproject.toml")) || + fs.existsSync(path.join(routerDir, "setup.py")); +} + +function shouldSkipModelRouterFingerprintEntry(name: string): boolean { + return MODEL_ROUTER_FINGERPRINT_IGNORED_NAMES.has(name) || name.endsWith(".egg-info"); +} + +function hashModelRouterSourceTree(routerDir = modelRouterPackageDir()): string | null { + const sourceHash = crypto.createHash("sha256"); + + const hashDirectory = (currentDir: string): boolean => { + let entries: fs.Dirent[]; + try { + entries = fs + .readdirSync(currentDir, { withFileTypes: true }) + .sort((left: fs.Dirent, right: fs.Dirent) => left.name.localeCompare(right.name)); + } catch { + return false; + } + + let hashedSourceFile = false; + for (const entry of entries) { + if (shouldSkipModelRouterFingerprintEntry(entry.name)) continue; + if (entry.name.endsWith(".pyc") || entry.name.endsWith(".pyo")) continue; + + const entryPath = path.join(currentDir, entry.name); + const relativePath = path.relative(routerDir, entryPath).split(path.sep).join("/"); + if (entry.isDirectory()) { + hashedSourceFile = hashDirectory(entryPath) || hashedSourceFile; + continue; + } + if (entry.isSymbolicLink()) { + try { + sourceHash.update(`link:${relativePath}\0`); + sourceHash.update(fs.readlinkSync(entryPath)); + sourceHash.update("\0"); + hashedSourceFile = true; + } catch { + // Ignore unreadable links; the install step will fail if they are required. + } + continue; + } + if (!entry.isFile()) continue; + sourceHash.update(`file:${relativePath}\0`); + sourceHash.update(fs.readFileSync(entryPath)); + sourceHash.update("\0"); + hashedSourceFile = true; + } + return hashedSourceFile; + }; + + return hashDirectory(routerDir) ? `files:${sourceHash.digest("hex")}` : null; +} + +function getModelRouterSourceFingerprint(routerDir = modelRouterPackageDir()): string | null { + const gitHead = runCapture(["git", "-C", routerDir, "rev-parse", "HEAD"], { + ignoreError: true, + }).trim(); + if (/^[0-9a-f]{40}$/i.test(gitHead)) return `git:${gitHead}`; + + const gitLink = runCapture(["git", "-C", ROOT, "rev-parse", `HEAD:${MODEL_ROUTER_RELATIVE_DIR}`], { + ignoreError: true, + }).trim(); + if (/^[0-9a-f]{40}$/i.test(gitLink)) return `gitlink:${gitLink}`; + + return hashModelRouterSourceTree(routerDir); +} + +function readModelRouterInstalledFingerprint(venvDir = modelRouterVenvDir()): string | null { + try { + const fingerprint = fs.readFileSync(modelRouterFingerprintPath(venvDir), "utf8").trim(); + return fingerprint || null; + } catch { + return null; + } +} + +function writeModelRouterInstalledFingerprint( + fingerprint: string | null, + venvDir = modelRouterVenvDir(), +): void { + if (!fingerprint) return; + fs.writeFileSync(modelRouterFingerprintPath(venvDir), `${fingerprint}\n`, { mode: 0o600 }); +} + +function isManagedModelRouterCurrent( + routerDir = modelRouterPackageDir(), + venvDir = modelRouterVenvDir(), +): boolean { + if (!isExecutableFile(modelRouterCommandPath(venvDir))) return false; + const sourceFingerprint = getModelRouterSourceFingerprint(routerDir); + return Boolean( + sourceFingerprint && readModelRouterInstalledFingerprint(venvDir) === sourceFingerprint, + ); +} + +function initializeModelRouterSubmodule(routerDir = modelRouterPackageDir()): void { + if (isModelRouterPackageReady(routerDir)) return; + if (!fs.existsSync(path.join(ROOT, ".gitmodules")) || !fs.existsSync(path.join(ROOT, ".git"))) { + return; + } + console.log(" Initializing Model Router source..."); + run(["git", "-C", ROOT, "submodule", "update", "--init", "--depth", "1", MODEL_ROUTER_RELATIVE_DIR], { + ignoreError: true, + }); +} + +function installModelRouterCommand(routerDir = modelRouterPackageDir()): string { + initializeModelRouterSubmodule(routerDir); + if (!isModelRouterPackageReady(routerDir)) { + throw new Error( + `Model Router source is not initialized at ${routerDir}. ` + + `Run: git -C ${ROOT} submodule update --init --depth 1 ${MODEL_ROUTER_RELATIVE_DIR}`, + ); + } + + const venvDir = modelRouterVenvDir(); + const routerCommand = modelRouterCommandPath(venvDir); + const sourceFingerprint = getModelRouterSourceFingerprint(routerDir); + const allowReplaceExistingVenv = + path.resolve(venvDir) === path.resolve(MODEL_ROUTER_VENV_DIR) || + readModelRouterInstalledFingerprint(venvDir) !== null; + const venvPython = prepareModelRouterVenv({ + venvDir, + allowReplaceExisting: allowReplaceExistingVenv, + }); + + const installResult = run( + [venvPython, "-m", "pip", "install", "--quiet", "--upgrade", `${routerDir}[prefill,proxy]`], + { + ignoreError: true, + timeout: 600_000, + }, + ); + if (installResult.status !== 0) { + throw new Error("Failed to install Model Router dependencies."); + } + if (!isExecutableFile(routerCommand)) { + throw new Error("Model Router install did not produce the model-router command."); + } + writeModelRouterInstalledFingerprint(sourceFingerprint, venvDir); + return routerCommand; +} + +function ensureModelRouterCommand(): string { + const routerDir = modelRouterPackageDir(); + const venvDir = modelRouterVenvDir(); + const managedCommand = modelRouterCommandPath(venvDir); + + if (isModelRouterPackageReady(routerDir) && isManagedModelRouterCurrent(routerDir, venvDir)) { + return managedCommand; + } + + if (!isModelRouterPackageReady(routerDir)) { + initializeModelRouterSubmodule(routerDir); + } + + if (isModelRouterPackageReady(routerDir)) { + if (isManagedModelRouterCurrent(routerDir, venvDir)) return managedCommand; + return installModelRouterCommand(routerDir); + } + + if (isExecutableFile(managedCommand)) return managedCommand; + return resolveHostCommandPath("model-router") || installModelRouterCommand(); +} + +/** + * Start the model-router proxy and wait for it to become healthy. + * Follows the same pattern as Ollama startup (spawn detached, poll health). + * Returns the PID of the child process. + */ +async function startModelRouter(routerCfg: BlueprintRouterConfig): Promise { + const routerCommand = ensureModelRouterCommand(); + const port = routerCfg.port || 4000; + const blueprintDir = path.join(ROOT, "nemoclaw-blueprint"); + const poolConfigPath = path.join( + blueprintDir, + routerCfg.pool_config_path || "router/pool-config.yaml", + ); + const stateDir = path.join(os.homedir(), ".nemoclaw", "state"); + const litellmConfigPath = path.join(stateDir, "litellm-proxy.yaml"); + + fs.mkdirSync(stateDir, { recursive: true }); + + const proxyConfigResult = spawnSync( + routerCommand, + ["proxy-config", "--config", poolConfigPath, "--output", litellmConfigPath], + { encoding: "utf8", timeout: 30_000, cwd: blueprintDir }, + ); + if (proxyConfigResult.status !== 0) { + throw new Error( + `model-router proxy-config failed: ${proxyConfigResult.stderr || proxyConfigResult.error || "unknown error"}`, + ); + } + + const credEnvVars: Record = {}; + const credName = routerCfg.credential_env || DEFAULT_MODEL_ROUTER_CREDENTIAL_ENV; + const routedCredential = resolveProviderCredential(credName); + const openAiCredential = resolveProviderCredential("OPENAI_API_KEY"); + if (routedCredential) { + credEnvVars[credName] = routedCredential; + if (!openAiCredential) credEnvVars.OPENAI_API_KEY = routedCredential; + } + if (openAiCredential) credEnvVars.OPENAI_API_KEY = openAiCredential; + const _providerKey = (process.env.NEMOCLAW_PROVIDER_KEY || "").trim(); + if (_providerKey) { + if (!credEnvVars[credName]) credEnvVars[credName] = _providerKey; + if (!credEnvVars.OPENAI_API_KEY) credEnvVars.OPENAI_API_KEY = _providerKey; + } + + if (await isRouterHealthy(port)) { + throw new Error( + `Port ${port} already has a healthy router endpoint; refusing to start a second router.`, + ); + } + + const child = spawn( + routerCommand, + [ + "proxy", + "--litellm-config", litellmConfigPath, + "--router-config", poolConfigPath, + "--host", "0.0.0.0", + "--port", String(port), + ], + { + detached: true, + stdio: "ignore", + cwd: blueprintDir, + env: buildSubprocessEnv(credEnvVars), + }, + ); + let childExited = false; + let childExitDetail = ""; + child.once("error", (err: Error) => { + childExited = true; + childExitDetail = `child failed to start: ${err.message}`; + }); + child.once("exit", (code: number | null, signal: string | null) => { + childExited = true; + if (!childExitDetail) { + childExitDetail = `child exited with code ${code ?? "null"}${signal ? ` signal ${signal}` : ""}`; + } + }); + child.unref(); + + const pid = child.pid; + if (!pid) { + throw new Error( + "Failed to start model-router proxy: no PID returned" + + (childExitDetail ? ` (${childExitDetail})` : ""), + ); + } + + for (let attempt = 0; attempt < ROUTER_HEALTH_RETRIES; attempt++) { + await new Promise((resolve) => setTimeout(resolve, ROUTER_HEALTH_INTERVAL_MS)); + if (childExited) break; + const healthy = await isRouterHealthy(port); + let processAlive = true; + try { + process.kill(pid, 0); + } catch { + processAlive = false; + } + if (healthy && processAlive) return pid; + if (!processAlive) { + childExited = true; + if (!childExitDetail) childExitDetail = "child process is no longer running"; + break; + } + } + try { + process.kill(pid, "SIGTERM"); + } catch { + // already dead + } + throw new Error( + `Model router failed to become healthy on port ${port} after ${ROUTER_HEALTH_RETRIES} attempts` + + (childExitDetail ? ` (${childExitDetail})` : ""), + ); +} + +function getRoutedProfile(): BlueprintInferenceProfile { + const bp = loadBlueprintProfile("routed"); + if (!bp || bp.router?.enabled !== true) { + throw new Error("Router is not enabled in nemoclaw-blueprint/blueprint.yaml."); + } + return bp; +} + +export function isRoutedInferenceProvider(provider: string | null | undefined): boolean { + if (!provider) return false; + if (provider === "nvidia-router") return true; + const bp = loadBlueprintProfile("routed"); + return Boolean(bp?.provider_name && provider === bp.provider_name); +} + +export async function reconcileModelRouter(): Promise { + const bp = getRoutedProfile(); + const routerPort = bp.router.port || 4000; + const routerCredentialEnv = + bp.router.credential_env || bp.credential_env || DEFAULT_MODEL_ROUTER_CREDENTIAL_ENV; + const routerCredential = + hydrateCredentialEnv(routerCredentialEnv) || + normalizeCredentialValue(bp.credential_default || ""); + if (!routerCredential) { + throw new Error(`${routerCredentialEnv} is required to start Model Router.`); + } + saveCredential(routerCredentialEnv, routerCredential); + const routerCredentialHash = hashCredential(routerCredential); + const session = onboardSession.loadSession(); + const recordedPid = session?.routerPid ?? null; + const recordedCredentialHash = session?.routerCredentialHash ?? null; + + if (await isRouterHealthy(routerPort)) { + if ( + routerCredentialHash && + recordedCredentialHash === routerCredentialHash && + isProcessRunning(recordedPid) + ) { + console.log(` ✓ Model router is already healthy on port ${routerPort}`); + return; + } + if (isProcessRunning(recordedPid)) { + console.log(" Restarting model router with updated credentials..."); + await stopModelRouterProcess(requireValue(recordedPid, "Expected recorded router PID"), routerPort); + } else { + throw new Error( + `Port ${routerPort} already has a healthy router endpoint, but its credential state is unknown. Stop the existing model-router process and rerun onboarding.`, + ); + } + } + + console.log(" Starting model router..."); + const routerPid = await startModelRouter(bp.router); + console.log(` ✓ Model router started (PID ${routerPid}) on port ${routerPort}`); + onboardSession.updateSession((current: Session) => { + current.routerPid = routerPid; + current.routerCredentialHash = routerCredentialHash; + return current; + }); +} diff --git a/src/lib/onboard/openclaw-setup.ts b/src/lib/onboard/openclaw-setup.ts new file mode 100644 index 0000000000..de8fd11e3f --- /dev/null +++ b/src/lib/onboard/openclaw-setup.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +export interface OpenclawSetupDeps { + step(n: number, total: number, msg: string): void; + agentProductName(): string; + getProviderSelectionConfig(provider: string, model: string): unknown | null; + buildSandboxConfigSyncScript(config: any): string; + writeSandboxConfigSyncFile(script: string): string; + run(argv: string[], options: Record): unknown; + openshellArgv(args: string[]): string[]; + cleanupTempDir(file: string, prefix: string): void; +} + +export function createOpenclawSetup(deps: OpenclawSetupDeps) { + return async function setupOpenclaw( + sandboxName: string, + model: string, + provider: string, + ): Promise { + deps.step(7, 8, `Setting up ${deps.agentProductName()} inside sandbox`); + + const selectionConfig = deps.getProviderSelectionConfig(provider, model); + if (selectionConfig) { + const sandboxConfig = { + ...(selectionConfig as Record), + onboardedAt: new Date().toISOString(), + }; + const script = deps.buildSandboxConfigSyncScript(sandboxConfig); + const scriptFile = deps.writeSandboxConfigSyncFile(script); + try { + const scriptContent = fs.readFileSync(scriptFile, "utf-8"); + deps.run(deps.openshellArgv(["sandbox", "connect", sandboxName]), { + stdio: ["pipe", "ignore", "inherit"], + input: scriptContent, + }); + } finally { + deps.cleanupTempDir(scriptFile, "nemoclaw-sync"); + } + } + + console.log(` ✓ ${deps.agentProductName()} gateway launched inside sandbox`); + }; +} diff --git a/src/lib/onboard/openshell-cli.ts b/src/lib/onboard/openshell-cli.ts new file mode 100644 index 0000000000..961ec5e0dd --- /dev/null +++ b/src/lib/onboard/openshell-cli.ts @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { resolveOpenshell } from "../adapters/openshell/resolve"; +import { run, runCapture, shellQuote } from "../runner"; + +export interface OpenshellCliDeps { + getCachedBinary(): string | null; + setCachedBinary(binary: string): void; + getGatewayPort(): number; + getDockerDriverGatewayEndpoint(): string; +} + +export interface OpenshellCliHelpers { + getOpenshellBinary(): string; + openshellShellCommand(args: string[], options?: { openshellBinary?: string }): string; + openshellArgv(args: string[], options?: { openshellBinary?: string }): string[]; + runOpenshell(args: string[], opts?: any): ReturnType; + runCaptureOpenshell(args: string[], opts?: any): string; + safeOpenShellArgument(value: string, label: string): string; + getGatewayPortArg(): string; + getDockerDriverGatewayEndpointArg(): string; +} + +export function createOpenshellCliHelpers(deps: OpenshellCliDeps): OpenshellCliHelpers { + function getOpenshellBinary(): string { + const cached = deps.getCachedBinary(); + if (cached) return cached; + const resolved = resolveOpenshell(); + if (typeof resolved !== "string" || resolved.length === 0) { + console.error(" openshell CLI not found."); + console.error(" Install manually: https://github.com/NVIDIA/OpenShell/releases"); + process.exit(1); + } + deps.setCachedBinary(resolved); + return resolved; + } + + function openshellShellCommand(args: string[], options: { openshellBinary?: string } = {}): string { + const openshellBinary = options.openshellBinary || getOpenshellBinary(); + return [shellQuote(openshellBinary), ...args.map((arg) => shellQuote(arg))].join(" "); + } + + function openshellArgv(args: string[], options: { openshellBinary?: string } = {}): string[] { + const openshellBinary = options.openshellBinary || getOpenshellBinary(); + return [openshellBinary, ...args]; + } + + function runOpenshell(args: string[], opts: any = {}) { + return run(openshellArgv(args, opts), opts); + } + + function runCaptureOpenshell(args: string[], opts: any = {}) { + return runCapture(openshellArgv(args, opts), opts); + } + + function safeOpenShellArgument(value: string, label: string): string { + if (!/^[A-Za-z0-9._~:/-]+$/.test(value)) { + throw new Error(`Invalid ${label}: contains characters unsafe for OpenShell CLI args`); + } + return value; + } + + function getGatewayPortArg(): string { + return safeOpenShellArgument(String(deps.getGatewayPort()), "gateway port"); + } + + function getDockerDriverGatewayEndpointArg(): string { + return safeOpenShellArgument(deps.getDockerDriverGatewayEndpoint(), "gateway endpoint"); + } + + return { + getOpenshellBinary, + openshellShellCommand, + openshellArgv, + runOpenshell, + runCaptureOpenshell, + safeOpenShellArgument, + getGatewayPortArg, + getDockerDriverGatewayEndpointArg, + }; +} diff --git a/src/lib/onboard/openshell-version.ts b/src/lib/onboard/openshell-version.ts new file mode 100644 index 0000000000..e45a279130 --- /dev/null +++ b/src/lib/onboard/openshell-version.ts @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { resolveOpenshell } from "../adapters/openshell/resolve"; +import { ROOT, runCapture } from "../runner"; + +export function getInstalledOpenshellVersion(versionOutput: string | null = null): string | null { + const openshellBin = resolveOpenshell(); + if (!versionOutput && !openshellBin) return null; + const output = String( + versionOutput ?? runCapture([openshellBin as string, "-V"], { ignoreError: true }), + ).trim(); + const match = output.match(/openshell\s+([0-9]+\.[0-9]+\.[0-9]+)/i); + if (match) return match[1]; + return null; +} + +/** + * Compare two semver-like x.y.z strings. Returns true iff `left >= right`. + * Non-numeric or missing components are treated as 0. + */ +export function versionGte(left = "0.0.0", right = "0.0.0"): boolean { + const lhs = String(left) + .split(".") + .map((part) => Number.parseInt(part, 10) || 0); + const rhs = String(right) + .split(".") + .map((part) => Number.parseInt(part, 10) || 0); + const length = Math.max(lhs.length, rhs.length); + for (let index = 0; index < length; index += 1) { + const a = lhs[index] || 0; + const b = rhs[index] || 0; + if (a > b) return true; + if (a < b) return false; + } + return true; +} + +/** + * Read a semver field from nemoclaw-blueprint/blueprint.yaml. Returns null if + * the blueprint or field is missing or unparseable — callers must treat null + * as "no constraint configured" so a malformed install does not become a hard + * onboard blocker. See #1317. + */ +function getBlueprintVersionField(field: string, rootDir = ROOT): string | null { + try { + // Lazy require: yaml is already a dependency via the policy helpers but + // pulling it at module load would slow down `nemoclaw --help` for users + // who never reach the preflight path. + const YAML = require("yaml"); + const blueprintPath = path.join(rootDir, "nemoclaw-blueprint", "blueprint.yaml"); + if (!fs.existsSync(blueprintPath)) return null; + const raw = fs.readFileSync(blueprintPath, "utf8"); + const parsed = YAML.parse(raw); + const value = parsed && parsed[field]; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!/^[0-9]+\.[0-9]+\.[0-9]+/.test(trimmed)) return null; + return trimmed; + } catch { + return null; + } +} + +export function getBlueprintMinOpenshellVersion(rootDir = ROOT): string | null { + return getBlueprintVersionField("min_openshell_version", rootDir); +} + +export function getBlueprintMaxOpenshellVersion(rootDir = ROOT): string | null { + return getBlueprintVersionField("max_openshell_version", rootDir); +} + +export type OpenshellChannel = "stable" | "dev" | "auto"; + +export function getOpenshellChannel(env: NodeJS.ProcessEnv = process.env): OpenshellChannel { + const raw = String(env.NEMOCLAW_OPENSHELL_CHANNEL || "auto") + .trim() + .toLowerCase(); + if (raw === "stable" || raw === "dev" || raw === "auto") return raw; + return "auto"; +} + +export function shouldUseOpenshellDevChannel( + _platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const channel = getOpenshellChannel(env); + return channel === "dev"; +} + +export function isOpenshellDevVersion(versionOutput: string | null | undefined): boolean { + return /\bdev[0-9.]*/i.test(String(versionOutput || "")); +} + +export function shouldAllowOpenshellAboveBlueprintMax( + versionOutput: string | null | undefined, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return shouldUseOpenshellDevChannel(platform, env) && isOpenshellDevVersion(versionOutput); +} diff --git a/src/lib/onboard/prompt-helpers.ts b/src/lib/onboard/prompt-helpers.ts new file mode 100644 index 0000000000..c99e92f828 --- /dev/null +++ b/src/lib/onboard/prompt-helpers.ts @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export function step(n: number, total: number, msg: string): void { + console.log(""); + console.log(` [${n}/${total}] ${msg}`); + console.log(` ${"─".repeat(50)}`); +} + +export function getNavigationChoice(value = ""): "back" | "exit" | null { + const normalized = String(value || "") + .trim() + .toLowerCase(); + if (normalized === "back") return "back"; + if (normalized === "exit" || normalized === "quit") return "exit"; + return null; +} + +export function exitOnboardFromPrompt(): never { + console.log(" Exiting onboarding."); + process.exit(1); +} + +export function isAffirmativeAnswer(value: string | null | undefined): boolean { + return ["y", "yes"].includes( + String(value || "") + .trim() + .toLowerCase(), + ); +} + +export interface PromptHelperDeps { + isNonInteractive(): boolean; + note(message: string): void; + prompt(question: string): Promise; +} + +// Prompt wrapper: returns env var value or default in non-interactive mode, +// otherwise prompts the user interactively. +export async function promptOrDefault( + deps: PromptHelperDeps, + question: string, + envVar: string | null, + defaultValue: string, +): Promise { + if (deps.isNonInteractive()) { + const val = envVar ? process.env[envVar] : null; + const result = val || defaultValue; + deps.note(` [non-interactive] ${question.trim()} → ${result}`); + return result; + } + return deps.prompt(question); +} + +// Yes/no prompt with a typed default. The `[Y/n]` / `[y/N]` indicator and +// the non-interactive echo letter are both derived from `defaultIsYes`, so +// the case of the indicator and the echoed default cannot drift apart. +// Returns a boolean — callers no longer have to parse reply strings. +// Replies of "y"/"yes" and "n"/"no" win regardless of case; empty and +// unknown input fall back to the default. +export async function promptYesNoOrDefault( + deps: PromptHelperDeps, + question: string, + envVar: string | null, + defaultIsYes: boolean, +): Promise { + const fullQuestion = `${question} ${defaultIsYes ? "[Y/n]" : "[y/N]"}: `; + const nonInteractive = deps.isNonInteractive(); + const input = nonInteractive ? (envVar ? process.env[envVar] : null) : await deps.prompt(fullQuestion); + + const value = String(input ?? "") + .trim() + .toLowerCase(); + let chosen = defaultIsYes; + if (value === "y" || value === "yes") chosen = true; + else if (value === "n" || value === "no") chosen = false; + + if (nonInteractive) { + deps.note(` [non-interactive] ${fullQuestion.trim()} → ${chosen ? "Y" : "N"}`); + } + return chosen; +} diff --git a/src/lib/onboard/provider-recovery.ts b/src/lib/onboard/provider-recovery.ts new file mode 100644 index 0000000000..cf196a0f7c --- /dev/null +++ b/src/lib/onboard/provider-recovery.ts @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as onboardSession from "../state/onboard-session"; +import * as registry from "../state/registry"; + +export type RemoteProviderConfigEntryLike = { providerName?: string }; + +export function providerNameToOptionKey( + remoteProviderConfig: Record, + name: string | null | undefined, + opts: { hasNimContainer?: boolean } = {}, +): string | null { + if (!name) return null; + if (name === "nvidia-router") return "routed"; + if (name === "ollama-local") return "ollama"; + // Local NIM and standalone vLLM both persist as provider="vllm-local". NIM + // is positively identified by a nimContainer record; the absence of one in + // registry/session recovery reliably means standalone vLLM (the standalone + // path never records a container), so default to "vllm" there. Live-gateway + // recovery doesn't carry container info either, but the caller's + // option-availability check still gates on whether vllm is actually running. + if (name === "vllm-local") return opts.hasNimContainer ? "nim-local" : "vllm"; + // `nvidia-nim` is a legacy alias for cloud NVIDIA Endpoints (see + // setupInference: it routes nvidia-nim through REMOTE_PROVIDER_CONFIG.build), + // not a marker for Local NIM. Local NIM persists as vllm-local + nimContainer. + if (name === "nvidia-nim") return "build"; + for (const [key, cfg] of Object.entries(remoteProviderConfig)) { + if (cfg.providerName === name) return key; + } + return null; +} + +export interface ProviderRecoveryDeps { + parseGatewayInference(output: string | null): { provider: string | null; model: string | null } | null; + runCaptureOpenshell(args: string[], opts?: Record): string | null; +} + +export interface ProviderRecoveryHelpers { + readLiveInference(sandboxName: string | null | undefined): { provider: string | null; model: string | null } | null; + readRecordedProvider(sandboxName: string | null | undefined): string | null; + readRecordedNimContainer(sandboxName: string | null | undefined): string | null; + readRecordedModel(sandboxName: string | null | undefined): string | null; +} + +export function createProviderRecoveryHelpers(deps: ProviderRecoveryDeps): ProviderRecoveryHelpers { + function readLiveInference( + sandboxName: string | null | undefined, + ): { provider: string | null; model: string | null } | null { + if (!sandboxName) return null; + try { + const { defaultSandbox, sandboxes } = registry.listSandboxes(); + // The gateway holds one active inference config at a time. Trust the + // live read for the default sandbox, or when the registry has no + // entries (rebuild path: destroy wiped the entry but the gateway + // config persists). Other non-default sandboxes have a stored config + // that the gateway will swap to on their next connect. + const trustGateway = sandboxName === defaultSandbox || sandboxes.length === 0; + if (!trustGateway) return null; + const output = deps.runCaptureOpenshell(["inference", "get"], { ignoreError: true }); + return deps.parseGatewayInference(output); + } catch { + return null; + } + } + + function readRecordedProvider(sandboxName: string | null | undefined): string | null { + if (!sandboxName) return null; + try { + const entry = registry.getSandbox(sandboxName); + if (entry && typeof entry.provider === "string" && entry.provider) { + return entry.provider; + } + } catch { + // fall through to session + } + try { + const session = onboardSession.loadSession(); + if ( + session && + session.sandboxName === sandboxName && + typeof session.provider === "string" && + session.provider + ) { + return session.provider; + } + } catch { + // fall through to live gateway + } + const live = readLiveInference(sandboxName); + if (live && typeof live.provider === "string" && live.provider) { + return live.provider; + } + return null; + } + + function readRecordedNimContainer(sandboxName: string | null | undefined): string | null { + if (!sandboxName) return null; + try { + const entry = registry.getSandbox(sandboxName); + if (entry && typeof entry.nimContainer === "string" && entry.nimContainer) { + return entry.nimContainer; + } + } catch { + // fall through to session + } + try { + const session = onboardSession.loadSession(); + if ( + session && + session.sandboxName === sandboxName && + typeof session.nimContainer === "string" && + session.nimContainer + ) { + return session.nimContainer; + } + } catch { + return null; + } + return null; + } + + function readRecordedModel(sandboxName: string | null | undefined): string | null { + if (!sandboxName) return null; + try { + const entry = registry.getSandbox(sandboxName); + if (entry && typeof entry.model === "string" && entry.model) { + return entry.model; + } + } catch { + // fall through to session + } + try { + const session = onboardSession.loadSession(); + if ( + session && + session.sandboxName === sandboxName && + typeof session.model === "string" && + session.model + ) { + return session.model; + } + } catch { + // fall through to live gateway + } + const live = readLiveInference(sandboxName); + if (live && typeof live.model === "string" && live.model) { + return live.model; + } + return null; + } + + return { readLiveInference, readRecordedProvider, readRecordedNimContainer, readRecordedModel }; +} diff --git a/src/lib/onboard/remediation.ts b/src/lib/onboard/remediation.ts new file mode 100644 index 0000000000..256bb4f29b --- /dev/null +++ b/src/lib/onboard/remediation.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; + +const OPENCLAW_LAUNCH_AGENT_PLIST = "~/Library/LaunchAgents/ai.openclaw.gateway.plist"; + +export function printRemediationActions( + actions: Array<{ title: string; reason: string; commands?: string[] }> | null | undefined, +): void { + if (!Array.isArray(actions) || actions.length === 0) { + return; + } + + console.error(""); + console.error(" Suggested fix:"); + console.error(""); + for (const action of actions) { + console.error(` - ${action.title}: ${action.reason}`); + for (const command of action.commands || []) { + console.error(` ${command}`); + } + } +} + +export function getFutureShellPathHint(binDir: string, pathValue = process.env.PATH || ""): string | null { + const parts = String(pathValue).split(path.delimiter).filter(Boolean); + if (parts[0] === binDir) { + return null; + } + return `export PATH="${binDir}:$PATH"`; +} + +export function getPortConflictServiceHints(platform = process.platform): string[] { + if (platform === "darwin") { + return [ + " # or, if it's a launchctl service (macOS):", + " launchctl list | grep -i claw # columns: PID | ExitStatus | Label", + ` launchctl unload ${OPENCLAW_LAUNCH_AGENT_PLIST}`, + " # or: launchctl bootout gui/$(id -u)/ai.openclaw.gateway", + ]; + } + return [ + " # or, if it's a systemd service:", + " systemctl --user stop openclaw-gateway.service", + ]; +} diff --git a/src/lib/onboard/resume-config.ts b/src/lib/onboard/resume-config.ts new file mode 100644 index 0000000000..4cda42d0bc --- /dev/null +++ b/src/lib/onboard/resume-config.ts @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; + +const onboardProviders = require("./providers"); + +export interface ResumeSessionLike { + sandboxName?: string | null; + provider?: string | null; + model?: string | null; + agent?: string | null; + metadata?: { fromDockerfile?: string | null } | null; + steps?: { sandbox?: { status?: string | null } | null } | null; +} + +export interface ResumeConfigConflict { + field: string; + requested: string | null; + recorded: string | null; +} + +export function getRequestedSandboxNameHint(opts: { sandboxName?: string | null } = {}): string | null { + const raw = + typeof opts.sandboxName === "string" && opts.sandboxName.length > 0 + ? opts.sandboxName + : process.env.NEMOCLAW_SANDBOX_NAME; + if (typeof raw !== "string") return null; + const normalized = raw.trim().toLowerCase(); + return normalized || null; +} + +export function getResumeSandboxConflict( + session: ResumeSessionLike | null, + opts: { sandboxName?: string | null } = {}, +): { requestedSandboxName: string; recordedSandboxName: string } | null { + // Use opts.sandboxName as the sole source — the caller has already + // resolved it (--name first, NEMOCLAW_SANDBOX_NAME only when prompting + // is impossible). Falling back to the env var here would fire spurious + // conflicts for interactive resume runs whose shell happens to export + // NEMOCLAW_SANDBOX_NAME but which never actually consult it. + // #2753: only treat session.sandboxName as a conflict source if the + // sandbox step actually completed. A pre-fix incomplete session would + // otherwise reject a legitimate `--resume --name ` that the user + // is supplying precisely to recover from the phantom. + const raw = typeof opts.sandboxName === "string" ? opts.sandboxName.trim().toLowerCase() : ""; + const requestedSandboxName = raw || null; + const recordedSandboxName = + session?.steps?.sandbox?.status === "complete" ? session?.sandboxName ?? null : null; + if (!requestedSandboxName || !recordedSandboxName) { + return null; + } + return requestedSandboxName !== recordedSandboxName + ? { requestedSandboxName, recordedSandboxName } + : null; +} + +export function getRequestedProviderHint(nonInteractive = false): string | null { + return onboardProviders.getRequestedProviderHint(nonInteractive); +} + +export function getRequestedModelHint(nonInteractive = false): string | null { + return onboardProviders.getRequestedModelHint(nonInteractive); +} + +export function getResumeConfigConflicts( + session: ResumeSessionLike | null, + opts: { + nonInteractive?: boolean; + fromDockerfile?: string | null; + sandboxName?: string | null; + agent?: string | null; + } = {}, +): ResumeConfigConflict[] { + const conflicts: ResumeConfigConflict[] = []; + const nonInteractive = opts.nonInteractive ?? false; + + const sandboxConflict = getResumeSandboxConflict(session, { sandboxName: opts.sandboxName }); + if (sandboxConflict) { + conflicts.push({ + field: "sandbox", + requested: sandboxConflict.requestedSandboxName, + recorded: sandboxConflict.recordedSandboxName, + }); + } + + const requestedProvider = getRequestedProviderHint(nonInteractive); + const effectiveRequestedProvider = onboardProviders.getEffectiveProviderName(requestedProvider); + if ( + effectiveRequestedProvider && + session?.provider && + effectiveRequestedProvider !== session.provider + ) { + conflicts.push({ + field: "provider", + requested: effectiveRequestedProvider, + recorded: session.provider, + }); + } + + const requestedModel = getRequestedModelHint(nonInteractive); + if (requestedModel && session?.model && requestedModel !== session.model) { + conflicts.push({ + field: "model", + requested: requestedModel, + recorded: session.model, + }); + } + + const requestedFrom = opts.fromDockerfile ? path.resolve(opts.fromDockerfile) : null; + const recordedFrom = session?.metadata?.fromDockerfile + ? path.resolve(session.metadata.fromDockerfile) + : null; + if (requestedFrom !== recordedFrom) { + conflicts.push({ + field: "fromDockerfile", + requested: requestedFrom, + recorded: recordedFrom, + }); + } + + return conflicts; +} diff --git a/src/lib/onboard/sandbox-agent.ts b/src/lib/onboard/sandbox-agent.ts new file mode 100644 index 0000000000..f527333abf --- /dev/null +++ b/src/lib/onboard/sandbox-agent.ts @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentDefinition } from "../agent/defs"; +import { loadAgent } from "../agent/defs"; +import { getNameValidationGuidance, NAME_ALLOWED_FORMAT } from "../name-validation"; +import { validateName } from "../runner"; +import type { SandboxEntry } from "../state/registry"; +import * as registry from "../state/registry"; + +// Names that collide with CLI command namespaces. A sandbox named 'status' +// makes 'nemoclaw status connect' route to the global status command +// instead of the sandbox, and a sandbox named 'sandbox' collides with the +// oclif-native `nemoclaw sandbox ...` command namespace. Reject these wherever +// a sandbox name enters the system (interactive prompt, --name flag, +// NEMOCLAW_SANDBOX_NAME). +export const RESERVED_SANDBOX_NAMES = new Set([ + "onboard", + "list", + "deploy", + "setup", + "setup-spark", + "start", + "stop", + "status", + "debug", + "uninstall", + "update", + "credentials", + "help", + "sandbox", +]); + +export const UNKNOWN_SANDBOX_AGENT_NAME = "unknown"; + +export function normalizeSandboxAgentName(agentName: string | null | undefined): string { + const trimmed = typeof agentName === "string" ? agentName.trim() : ""; + return trimmed && trimmed !== "openclaw" ? trimmed : "openclaw"; +} + +export function getRequestedSandboxAgentName(agent: AgentDefinition | null | undefined): string { + return normalizeSandboxAgentName(agent?.name); +} + +export function formatSandboxAgentName(agentName: string | null | undefined): string { + const normalized = normalizeSandboxAgentName(agentName); + if (normalized === "openclaw") return "OpenClaw"; + if (normalized === "hermes") return "Hermes"; + return normalized; +} + +export function getDefaultSandboxNameForAgent(agent: AgentDefinition | null | undefined): string { + return getRequestedSandboxAgentName(agent) === "hermes" ? "hermes" : "my-assistant"; +} + +export function getSandboxPromptDefault(agent: AgentDefinition | null | undefined): string { + const envName = (process.env.NEMOCLAW_SANDBOX_NAME || "").trim().toLowerCase(); + const agentDefault = getDefaultSandboxNameForAgent(agent); + if (!envName) return agentDefault; + try { + return validateName(envName, "sandbox name"); + } catch { + return agentDefault; + } +} + +export function getEffectiveSandboxAgent(agent: AgentDefinition | null | undefined): AgentDefinition { + return agent || loadAgent("openclaw"); +} + +export function getAgentInferenceProviderOptions(agent: AgentDefinition | null | undefined): string[] { + const effectiveAgent = agent?.name ? loadAgent(agent.name) : getEffectiveSandboxAgent(agent); + return Array.isArray(effectiveAgent.inferenceProviderOptions) + ? effectiveAgent.inferenceProviderOptions + : []; +} + +export function getSandboxAgentRegistryFields( + agent: AgentDefinition | null | undefined, + agentVersionKnown = true, +): Pick { + const effectiveAgent = getEffectiveSandboxAgent(agent); + const agentName = normalizeSandboxAgentName(effectiveAgent.name); + return { + agent: agentName === "openclaw" ? null : agentName, + agentVersion: agentVersionKnown ? effectiveAgent.expectedVersion || null : null, + }; +} + +export function getSandboxAgentDrift( + sandboxName: string, + requestedAgentName: string, +): { changed: boolean; existingAgentName: string; requestedAgentName: string } { + const existingEntry: SandboxEntry | null = registry.getSandbox(sandboxName); + if (!existingEntry) { + return { + changed: true, + existingAgentName: UNKNOWN_SANDBOX_AGENT_NAME, + requestedAgentName, + }; + } + const existingAgentName = normalizeSandboxAgentName(existingEntry?.agent); + return { + changed: existingAgentName !== requestedAgentName, + existingAgentName, + requestedAgentName, + }; +} + +export interface PromptSandboxNameDeps { + promptOrDefault(question: string, envVar: string, defaultValue: string): Promise; + cliDisplayName(): string; + isNonInteractive(): boolean; + exit(code: number): never; +} + +export function createPromptValidatedSandboxName(deps: PromptSandboxNameDeps) { + return async function promptValidatedSandboxName(agent: AgentDefinition | null = null) { + const MAX_ATTEMPTS = 3; + const defaultSandboxName = getSandboxPromptDefault(agent); + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const nameAnswer = await deps.promptOrDefault( + ` Sandbox name (${NAME_ALLOWED_FORMAT}) [${defaultSandboxName}]: `, + "NEMOCLAW_SANDBOX_NAME", + defaultSandboxName, + ); + const sandboxName = (nameAnswer || defaultSandboxName).trim(); + + try { + const validatedSandboxName = validateName(sandboxName, "sandbox name"); + if (RESERVED_SANDBOX_NAMES.has(sandboxName)) { + console.error(` Reserved name: '${sandboxName}' is a ${deps.cliDisplayName()} CLI command.`); + console.error(" Choose a different name to avoid routing conflicts."); + if (deps.isNonInteractive()) { + deps.exit(1); + } + if (attempt < MAX_ATTEMPTS - 1) { + console.error(" Please try again.\n"); + } + continue; + } + return validatedSandboxName; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(` ${errorMessage}`); + } + + for (const line of getNameValidationGuidance("sandbox name", sandboxName, { + includeAllowedFormat: false, + })) { + console.error(` ${line}`); + } + + // Non-interactive runs cannot re-prompt — abort so the caller can fix the + // NEMOCLAW_SANDBOX_NAME env var and retry. + if (deps.isNonInteractive()) { + deps.exit(1); + } + + if (attempt < MAX_ATTEMPTS - 1) { + console.error(" Please try again.\n"); + } + } + + console.error(" Too many invalid attempts."); + deps.exit(1); + }; +} diff --git a/src/lib/onboard/sandbox-gpu-preflight.ts b/src/lib/onboard/sandbox-gpu-preflight.ts index 2778b1fb3b..31653f6568 100644 --- a/src/lib/onboard/sandbox-gpu-preflight.ts +++ b/src/lib/onboard/sandbox-gpu-preflight.ts @@ -3,7 +3,7 @@ import { dockerInfoFormat } from "../adapters/docker"; import { findReadableNvidiaCdiSpecFiles, getDockerCdiSpecDirs } from "./docker-cdi"; -import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; +import type { SandboxGpuConfig, SandboxGpuFlag } from "./sandbox-gpu-mode"; const SANDBOX_GPU_PREFLIGHT_TIMEOUT_MS = 30_000; @@ -14,6 +14,33 @@ export type SandboxGpuPreflightDeps = { findReadableNvidiaCdiSpecFiles?: (dirs: string[]) => string[]; }; +export interface SandboxGpuFlagOptions { + sandboxGpu?: SandboxGpuFlag; + gpu?: boolean; + noGpu?: boolean; +} + +export function resolveSandboxGpuFlagFromOptions(opts: SandboxGpuFlagOptions): SandboxGpuFlag { + const requestedGpuPassthrough = opts.gpu === true; + const optedOutGpuPassthrough = opts.noGpu === true; + const sandboxGpuFlag = opts.sandboxGpu ?? null; + if (requestedGpuPassthrough && optedOutGpuPassthrough) { + console.error(" --gpu and --no-gpu cannot both be set."); + process.exit(1); + } + if ( + (requestedGpuPassthrough && sandboxGpuFlag === "disable") || + (optedOutGpuPassthrough && sandboxGpuFlag === "enable") + ) { + console.error(" --gpu/--no-gpu conflict with the sandbox GPU flags."); + process.exit(1); + } + if (sandboxGpuFlag) return sandboxGpuFlag; + if (requestedGpuPassthrough) return "enable"; + if (optedOutGpuPassthrough) return "disable"; + return null; +} + export function sandboxGpuRemediationLines(): string[] { return [ "Install/configure NVIDIA Container Toolkit CDI, then restart Docker:", @@ -99,6 +126,50 @@ function validateJetsonSandboxGpuPreflight(deps: SandboxGpuPreflightDeps): void console.log(" ✓ Docker NVIDIA runtime detected for Jetson/Tegra sandbox GPU"); } +export interface DirectSandboxGpuVerifierDeps { + runOpenshell( + args: string[], + opts?: Record, + ): { status?: number | null; stdout?: unknown; stderr?: unknown }; + buildDirectSandboxGpuProofCommands?: (sandboxName: string) => Array<{ + args: string[]; + label: string; + optional?: boolean; + }>; + compactText(value: string): string; + redact(value: unknown): string; +} + +export function createDirectSandboxGpuVerifier(deps: DirectSandboxGpuVerifierDeps) { + return function verifyDirectSandboxGpu(sandboxName: string): void { + console.log(" Verifying direct sandbox GPU access..."); + const buildProofCommands = + deps.buildDirectSandboxGpuProofCommands ?? + require("./initial-policy").buildDirectSandboxGpuProofCommands; + for (const proof of buildProofCommands(sandboxName)) { + const result = deps.runOpenshell(proof.args, { + ignoreError: true, + suppressOutput: true, + timeout: 30_000, + }); + if (result.status === 0) { + console.log(` ✓ GPU proof passed: ${proof.label}`); + continue; + } + if (proof.optional === true) return; + const diagnostic = deps.compactText(deps.redact(`${result.stderr || ""} ${result.stdout || ""}`)); + console.error(` ✗ GPU proof failed: ${proof.label}`); + if (diagnostic) console.error(` ${diagnostic.slice(0, 300)}`); + for (const line of sandboxGpuRemediationLines()) { + console.error(` ${line}`); + } + const statusText = String(result.status || 1); + const diagnosticSuffix = diagnostic ? `: ${diagnostic.slice(0, 300)}` : ""; + throw new Error(`GPU proof failed: ${proof.label} (status ${statusText})${diagnosticSuffix}`); + } + }; +} + export function validateSandboxGpuPreflight( config: SandboxGpuConfig, deps: SandboxGpuPreflightDeps = {}, diff --git a/src/lib/onboard/sandbox-lifecycle.ts b/src/lib/onboard/sandbox-lifecycle.ts new file mode 100644 index 0000000000..74fed0814b --- /dev/null +++ b/src/lib/onboard/sandbox-lifecycle.ts @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as registry from "../state/registry"; +import type { SelectionDrift } from "./selection-drift"; + +export interface SandboxLifecycleDeps { + runCaptureOpenshell(args: string[], opts?: Record): string | null; + fetchGatewayAuthTokenFromSandbox(sandboxName: string): string | null; + agentProductName(): string; + prompt(question: string): Promise; + isAffirmativeAnswer(value: string | null | undefined): boolean; +} + +export interface SandboxLifecycleHelpers { + sandboxExistsInGateway(sandboxName: string): boolean; + pruneStaleSandboxEntry(sandboxName: string): boolean; + shouldRestoreLatestBackupOnRecreate(): boolean; + confirmRecreateForSelectionDrift( + sandboxName: string, + drift: SelectionDrift, + requestedProvider: string | null, + requestedModel: string | null, + ): Promise; + isOpenclawReady(sandboxName: string): boolean; +} + +export function createSandboxLifecycleHelpers(deps: SandboxLifecycleDeps): SandboxLifecycleHelpers { + function sandboxExistsInGateway(sandboxName: string): boolean { + const output = deps.runCaptureOpenshell(["sandbox", "get", sandboxName], { ignoreError: true }); + return Boolean(output); + } + + function pruneStaleSandboxEntry(sandboxName: string): boolean { + const existing = registry.getSandbox(sandboxName); + const liveExists = sandboxExistsInGateway(sandboxName); + if (existing && !liveExists) { + registry.removeSandbox(sandboxName); + } + return liveExists; + } + + function shouldRestoreLatestBackupOnRecreate(): boolean { + return process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE === "1"; + } + + async function confirmRecreateForSelectionDrift( + sandboxName: string, + drift: SelectionDrift, + requestedProvider: string | null, + requestedModel: string | null, + ): Promise { + const currentProvider = drift.existingProvider || "unknown"; + const currentModel = drift.existingModel || "unknown"; + const nextProvider = requestedProvider || "unknown"; + const nextModel = requestedModel || "unknown"; + + console.log(` Sandbox '${sandboxName}' exists but requested inference selection changed.`); + console.log(` Current: provider=${currentProvider} model=${currentModel}`); + console.log(` Requested: provider=${nextProvider} model=${nextModel}`); + console.log( + ` Recreating the sandbox is required to apply this change to the running ${deps.agentProductName()} UI.`, + ); + + const answer = await deps.prompt(` Recreate sandbox '${sandboxName}' now? [y/N]: `); + return deps.isAffirmativeAnswer(answer); + } + + function isOpenclawReady(sandboxName: string): boolean { + return Boolean(deps.fetchGatewayAuthTokenFromSandbox(sandboxName)); + } + + return { + sandboxExistsInGateway, + pruneStaleSandboxEntry, + shouldRestoreLatestBackupOnRecreate, + confirmRecreateForSelectionDrift, + isOpenclawReady, + }; +} diff --git a/src/lib/onboard/sandbox-registry-metadata.ts b/src/lib/onboard/sandbox-registry-metadata.ts new file mode 100644 index 0000000000..bbd84db74e --- /dev/null +++ b/src/lib/onboard/sandbox-registry-metadata.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentDefinition } from "../agent/defs"; +import type { SandboxEntry } from "../state/registry"; +import * as registry from "../state/registry"; +import { getSandboxAgentRegistryFields } from "./sandbox-agent"; +import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; + +export interface SandboxRegistryMetadataDeps { + isLinuxDockerDriverGatewayEnabled(): boolean; + getInstalledOpenshellVersion(versionOutput?: string | null): string | null; + runCaptureOpenshell(args: string[], opts?: Record): string | null; +} + +export interface SandboxRegistryMetadataHelpers { + getSandboxRuntimeRegistryFields(config: SandboxGpuConfig): Pick< + SandboxEntry, + | "gpuEnabled" + | "hostGpuDetected" + | "sandboxGpuEnabled" + | "sandboxGpuMode" + | "sandboxGpuDevice" + | "openshellDriver" + | "openshellVersion" + >; + hasSandboxGpuDrift(sandboxName: string, config: SandboxGpuConfig): boolean; + updateReusedSandboxMetadata( + sandboxName: string, + agent: AgentDefinition | null | undefined, + model: string, + provider: string, + dashboardPort: number, + selectionVerified?: boolean, + sandboxGpuConfig?: SandboxGpuConfig | null, + ): void; +} + +export function createSandboxRegistryMetadataHelpers( + deps: SandboxRegistryMetadataDeps, +): SandboxRegistryMetadataHelpers { + function getSandboxRuntimeRegistryFields(config: SandboxGpuConfig): Pick< + SandboxEntry, + | "gpuEnabled" + | "hostGpuDetected" + | "sandboxGpuEnabled" + | "sandboxGpuMode" + | "sandboxGpuDevice" + | "openshellDriver" + | "openshellVersion" + > { + return { + gpuEnabled: config.sandboxGpuEnabled, + hostGpuDetected: config.hostGpuDetected, + sandboxGpuEnabled: config.sandboxGpuEnabled, + sandboxGpuMode: config.mode, + sandboxGpuDevice: config.sandboxGpuDevice, + openshellDriver: deps.isLinuxDockerDriverGatewayEnabled() + ? process.platform === "darwin" + ? "vm" + : "docker" + : "kubernetes", + openshellVersion: deps.getInstalledOpenshellVersion( + deps.runCaptureOpenshell(["--version"], { ignoreError: true }), + ), + }; + } + + function hasSandboxGpuDrift(sandboxName: string, config: SandboxGpuConfig): boolean { + const existingEntry: SandboxEntry | null = registry.getSandbox(sandboxName); + if (!existingEntry) return false; + return ( + (existingEntry.sandboxGpuEnabled === true) !== config.sandboxGpuEnabled || + (existingEntry.sandboxGpuMode || "auto") !== config.mode || + (existingEntry.sandboxGpuDevice || null) !== config.sandboxGpuDevice + ); + } + + function updateReusedSandboxMetadata( + sandboxName: string, + agent: AgentDefinition | null | undefined, + model: string, + provider: string, + dashboardPort: number, + selectionVerified = true, + sandboxGpuConfig: SandboxGpuConfig | null = null, + ): void { + const existingEntry = registry.getSandbox(sandboxName); + const agentVersionKnown = existingEntry?.agentVersion !== null; + const selectionUpdates = selectionVerified ? { model, provider } : {}; + registry.updateSandbox(sandboxName, { + ...selectionUpdates, + dashboardPort, + ...getSandboxAgentRegistryFields(agent, agentVersionKnown), + ...(sandboxGpuConfig ? getSandboxRuntimeRegistryFields(sandboxGpuConfig) : {}), + }); + registry.setDefault(sandboxName); + } + + return { getSandboxRuntimeRegistryFields, hasSandboxGpuDrift, updateReusedSandboxMetadata }; +} diff --git a/src/lib/onboard/sandbox-reuse.ts b/src/lib/onboard/sandbox-reuse.ts new file mode 100644 index 0000000000..af5828971a --- /dev/null +++ b/src/lib/onboard/sandbox-reuse.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { DASHBOARD_PORT } from "../core/ports"; +import * as registry from "../state/registry"; +import { bestEffortForwardStop } from "./forward-cleanup"; + +export interface SandboxReuseDeps { + runCaptureOpenshell(args: string[], opts?: Record): string; + runOpenshell(args: string[], opts?: Record): unknown; + getSandboxStateFromOutputs(sandboxName: string, getOutput: string, listOutput: string): string; + note(message: string): void; +} + +export interface SandboxReuseHelpers { + getSandboxReuseState(sandboxName: string | null): string; + repairRecordedSandbox(sandboxName: string | null): void; +} + +export function createSandboxReuseHelpers(deps: SandboxReuseDeps): SandboxReuseHelpers { + function getSandboxReuseState(sandboxName: string | null): string { + if (!sandboxName) return "missing"; + const getOutput = deps.runCaptureOpenshell(["sandbox", "get", sandboxName], { ignoreError: true }); + const listOutput = deps.runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); + return deps.getSandboxStateFromOutputs(sandboxName, getOutput, listOutput); + } + + function repairRecordedSandbox(sandboxName: string | null): void { + if (!sandboxName) return; + deps.note(` [resume] Cleaning up recorded sandbox '${sandboxName}' before recreating it.`); + bestEffortForwardStop(deps.runOpenshell, DASHBOARD_PORT); + deps.runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); + registry.removeSandbox(sandboxName); + } + + return { getSandboxReuseState, repairRecordedSandbox }; +} diff --git a/src/lib/onboard/session-updates.ts b/src/lib/onboard/session-updates.ts new file mode 100644 index 0000000000..529d22e531 --- /dev/null +++ b/src/lib/onboard/session-updates.ts @@ -0,0 +1,63 @@ +// 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 { MessagingChannelConfig } from "../messaging-channel-config"; +import type { HermesAuthMethod, SessionUpdates } from "../state/onboard-session"; + +export interface OnboardSessionUpdateInput { + sandboxName?: string | null; + provider?: string | null; + model?: string | null; + endpointUrl?: string | null; + credentialEnv?: string | null; + hermesAuthMethod?: HermesAuthMethod | string | null; + preferredInferenceApi?: string | null; + nimContainer?: string | null; + webSearchConfig?: WebSearchConfig | null; + policyPresets?: string[] | null; + messagingChannels?: string[] | null; + messagingChannelConfig?: MessagingChannelConfig | null; + hermesToolGateways?: string[] | null; +} + +// Preserve the nullable contract end-to-end: `null` means "clear this +// field on the persisted session", `undefined` means "leave unchanged". +function toNullableString(value: string | null | undefined): string | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + return value; +} + +function normalizeHermesAuthMethod(value: string | null | undefined): HermesAuthMethod | null { + return value === "oauth" || value === "api_key" ? value : null; +} + +export function toSessionUpdates(updates: OnboardSessionUpdateInput = {}): SessionUpdates { + const normalized: SessionUpdates = {}; + if (updates.sandboxName !== undefined) + normalized.sandboxName = toNullableString(updates.sandboxName); + if (updates.provider !== undefined) normalized.provider = toNullableString(updates.provider); + if (updates.model !== undefined) normalized.model = toNullableString(updates.model); + if (updates.endpointUrl !== undefined) + normalized.endpointUrl = toNullableString(updates.endpointUrl); + if (updates.credentialEnv !== undefined) + normalized.credentialEnv = toNullableString(updates.credentialEnv); + if (updates.hermesAuthMethod !== undefined) + normalized.hermesAuthMethod = normalizeHermesAuthMethod(updates.hermesAuthMethod); + if (updates.preferredInferenceApi !== undefined) { + normalized.preferredInferenceApi = toNullableString(updates.preferredInferenceApi); + } + if (updates.nimContainer !== undefined) + normalized.nimContainer = toNullableString(updates.nimContainer); + if (updates.webSearchConfig !== undefined) normalized.webSearchConfig = updates.webSearchConfig; + if (updates.policyPresets !== undefined) normalized.policyPresets = updates.policyPresets; + if (updates.messagingChannels !== undefined) + normalized.messagingChannels = updates.messagingChannels; + if (updates.messagingChannelConfig !== undefined) { + normalized.messagingChannelConfig = updates.messagingChannelConfig; + } + if (updates.hermesToolGateways !== undefined) + normalized.hermesToolGateways = updates.hermesToolGateways; + return normalized; +} diff --git a/src/lib/onboard/validation-recovery-prompt.ts b/src/lib/onboard/validation-recovery-prompt.ts new file mode 100644 index 0000000000..b44cd0676d --- /dev/null +++ b/src/lib/onboard/validation-recovery-prompt.ts @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { normalizeCredentialValue, saveCredential } from "../credentials/store"; +import type { ProbeRecovery } from "../validation-recovery"; + +export interface ValidationRecoveryPromptDeps { + isNonInteractive(): boolean; + prompt(question: string, options?: { secret?: boolean }): Promise; + validateNvidiaApiKeyValue(key: string, credentialEnv: string | null): string | null; + getTransportRecoveryMessage(failure: any): string; + exitOnboardFromPrompt(): never; +} + +export interface ValidationRecoveryPromptHelpers { + replaceNamedCredential( + envName: string, + label: string, + helpUrl?: string | null, + validator?: ((value: string) => string | null) | null, + ): Promise; + promptValidationRecovery( + label: string, + recovery: ProbeRecovery, + credentialEnv?: string | null, + helpUrl?: string | null, + ): Promise<"credential" | "selection" | "retry" | "model">; +} + +export function createValidationRecoveryPromptHelpers( + deps: ValidationRecoveryPromptDeps, +): ValidationRecoveryPromptHelpers { + async function replaceNamedCredential( + envName: string, + label: string, + helpUrl: string | null = null, + validator: ((value: string) => string | null) | null = null, + ): Promise { + if (helpUrl) { + console.log(""); + console.log(` Get your ${label} from: ${helpUrl}`); + console.log(""); + } + + while (true) { + const key = normalizeCredentialValue(await deps.prompt(` ${label}: `, { secret: true })); + if (!key) { + console.error(` ${label} is required.`); + continue; + } + const validationError = typeof validator === "function" ? validator(key) : null; + if (validationError) { + console.error(validationError); + continue; + } + saveCredential(envName, key); + process.env[envName] = key; + console.log(""); + console.log(" Credential staged. Onboarding will register it with the OpenShell gateway."); + console.log(""); + return key; + } + } + + async function promptValidationRecovery( + label: string, + recovery: ProbeRecovery, + credentialEnv: string | null = null, + helpUrl: string | null = null, + ): Promise<"credential" | "selection" | "retry" | "model"> { + if (deps.isNonInteractive()) { + process.exit(1); + } + + if (recovery.kind === "credential" && credentialEnv) { + console.log( + ` ${label} authorization failed. Re-enter the API key or choose a different provider/model.`, + ); + console.log(" ⚠️ Do NOT paste your API key here — use the options below:"); + const choice = ( + await deps.prompt(" Options: retry (re-enter key), back (change provider), exit [retry]: ", { + secret: true, + }) + ) + .trim() + .toLowerCase(); + // Guard against the user accidentally pasting an API key at this prompt. + // Tokens don't contain spaces; human sentences do — the no-space + length check + // avoids false-positives on long typed sentences. + const API_KEY_PREFIXES = ["nvapi-", "ghp_", "gcm-", "sk-", "gpt-", "gemini-", "nvcf-"]; + const looksLikeToken = + API_KEY_PREFIXES.some((prefix) => choice.startsWith(prefix)) || + (!choice.includes(" ") && choice.length > 40) || + // Regex fallback: base64-safe token pattern (20+ chars, no spaces, mixed alphanum) + /^[A-Za-z0-9_\-.]{20,}$/.test(choice); + // validateNvidiaApiKeyValue is provider-aware: it only enforces the + // nvapi- prefix when credentialEnv === "NVIDIA_API_KEY", so passing it + // unconditionally here is safe for Anthropic/OpenAI/Gemini too. + const validator = (key: string) => deps.validateNvidiaApiKeyValue(key, credentialEnv); + if (looksLikeToken) { + console.log(" ⚠️ That looks like an API key — do not paste credentials here."); + console.log(" Treating as 'retry'. You will be prompted to enter the key securely."); + await replaceNamedCredential(credentialEnv, `${label} API key`, helpUrl, validator); + return "credential"; + } + if (choice === "back") { + console.log(" Returning to provider selection."); + console.log(""); + return "selection"; + } + if (choice === "exit" || choice === "quit") { + deps.exitOnboardFromPrompt(); + } + if (choice === "" || choice === "retry") { + await replaceNamedCredential(credentialEnv, `${label} API key`, helpUrl, validator); + return "credential"; + } + console.log(" Please choose a provider/model again."); + console.log(""); + return "selection"; + } + + if (recovery.kind === "transport") { + console.log(deps.getTransportRecoveryMessage("failure" in recovery ? recovery.failure || {} : {})); + const choice = (await deps.prompt(" Type 'retry', 'back', or 'exit' [retry]: ")) + .trim() + .toLowerCase(); + if (choice === "back") { + console.log(" Returning to provider selection."); + console.log(""); + return "selection"; + } + if (choice === "exit" || choice === "quit") { + deps.exitOnboardFromPrompt(); + } + if (choice === "" || choice === "retry") { + console.log(""); + return "retry"; + } + console.log(" Please choose a provider/model again."); + console.log(""); + return "selection"; + } + + if (recovery.kind === "model") { + console.log(` Please enter a different ${label} model name.`); + console.log(""); + return "model"; + } + + console.log(" Please choose a provider/model again."); + console.log(""); + return "selection"; + } + + return { replaceNamedCredential, promptValidationRecovery }; +} diff --git a/src/lib/onboard/web-search-flow.ts b/src/lib/onboard/web-search-flow.ts new file mode 100644 index 0000000000..7700482704 --- /dev/null +++ b/src/lib/onboard/web-search-flow.ts @@ -0,0 +1,237 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { CurlProbeResult } from "../adapters/http/probe"; +import { runCurlProbe } from "../adapters/http/probe"; +import type { AgentDefinition } from "../agent/defs"; +import { getCredential, normalizeCredentialValue, saveCredential } from "../credentials/store"; +import type { WebSearchConfig } from "../inference/web-search"; +import { BRAVE_API_KEY_ENV } from "../inference/web-search"; +import { ROOT } from "../runner"; +import { classifyValidationFailure } from "../validation"; +import { getTransportRecoveryMessage } from "../validation-recovery"; +import { BACK_TO_SELECTION, type BackToSelection, isBackToSelection } from "./credential-navigation"; +import { exitOnboardFromPrompt, isAffirmativeAnswer } from "./prompt-helpers"; +import type { ValidationFailureLike } from "./types"; +import { agentSupportsWebSearch } from "./web-search-support"; +import { verifyWebSearchInsideSandbox as verifyWebSearchInsideSandboxWithDeps } from "./web-search-verify"; + +const BRAVE_SEARCH_HELP_URL = "https://brave.com/search/api/"; + +export interface WebSearchFlowDeps { + prompt(question: string, options?: { secret?: boolean }): Promise; + note(message: string): void; + isNonInteractive(): boolean; + cliName(): string; + runCaptureOpenshell(args: string[], opts?: Record): string | null; +} + +export interface WebSearchFlowHelpers { + validateBraveSearchApiKey(apiKey: string): CurlProbeResult; + promptBraveSearchRecovery(validation: ValidationFailureLike): Promise<"retry" | "skip">; + promptBraveSearchApiKey(): Promise; + ensureValidatedBraveSearchCredential(nonInteractive?: boolean): Promise; + configureWebSearch( + existingConfig?: WebSearchConfig | null, + agent?: AgentDefinition | null, + dockerfilePathOverride?: string | null, + ): Promise; + verifyWebSearchInsideSandbox( + sandboxName: string, + agent: AgentDefinition | null | undefined, + ): void; +} + +export function createWebSearchFlowHelpers(deps: WebSearchFlowDeps): WebSearchFlowHelpers { + function validateBraveSearchApiKey(apiKey: string): CurlProbeResult { + return runCurlProbe([ + "-sS", + "--compressed", + "-H", + "Accept: application/json", + "-H", + "Accept-Encoding: gzip", + "-H", + `X-Subscription-Token: ${apiKey}`, + "--get", + "--data-urlencode", + "q=ping", + "--data-urlencode", + "count=1", + "https://api.search.brave.com/res/v1/web/search", + ]); + } + + async function promptBraveSearchRecovery( + validation: ValidationFailureLike, + ): Promise<"retry" | "skip"> { + const recovery = classifyValidationFailure(validation); + + if (recovery.kind === "credential") { + console.log(" Brave Search rejected that API key."); + } else if (recovery.kind === "transport") { + console.log(getTransportRecoveryMessage(validation)); + } else { + console.log(" Brave Search validation did not succeed."); + } + + const answer = (await deps.prompt(" Type 'retry', 'skip', or 'exit' [retry]: ")).trim().toLowerCase(); + if (answer === "skip") return "skip"; + if (answer === "exit" || answer === "quit") { + exitOnboardFromPrompt(); + } + return "retry"; + } + + async function promptBraveSearchApiKey(): Promise { + console.log(""); + console.log(` Get your Brave Search API key from: ${BRAVE_SEARCH_HELP_URL}`); + console.log(""); + + while (true) { + const value = await deps.prompt(" Brave Search API key: ", { secret: true }); + const intent = normalizeCredentialValue(value).toLowerCase(); + if (intent === "back") return BACK_TO_SELECTION; + if (intent === "exit" || intent === "quit") { + exitOnboardFromPrompt(); + } + if (intent === "?" || intent === "help") { + console.log(" Type back to choose again, or exit to quit."); + continue; + } + const key = normalizeCredentialValue(value); + if (!key) { + console.error(" Brave Search API key is required."); + continue; + } + return key; + } + } + + async function ensureValidatedBraveSearchCredential( + nonInteractive = deps.isNonInteractive(), + ): Promise { + const savedApiKey = getCredential(BRAVE_API_KEY_ENV); + let apiKey: string | null = + savedApiKey || normalizeCredentialValue(process.env[BRAVE_API_KEY_ENV]); + let usingSavedKey = Boolean(savedApiKey); + + while (true) { + if (!apiKey) { + if (nonInteractive) { + throw new Error( + "Brave Search requires BRAVE_API_KEY or a saved Brave Search credential in non-interactive mode.", + ); + } + const promptedApiKey = await promptBraveSearchApiKey(); + if (isBackToSelection(promptedApiKey)) { + return promptedApiKey; + } + apiKey = promptedApiKey; + usingSavedKey = false; + } + + const validation = validateBraveSearchApiKey(apiKey); + if (validation.ok) { + saveCredential(BRAVE_API_KEY_ENV, apiKey); + process.env[BRAVE_API_KEY_ENV] = apiKey; + return apiKey; + } + + const prefix = usingSavedKey + ? " Saved Brave Search API key validation failed." + : " Brave Search API key validation failed."; + console.error(prefix); + if (validation.message) { + console.error(` ${validation.message}`); + } + + if (nonInteractive) { + throw new Error( + validation.message || "Brave Search API key validation failed in non-interactive mode.", + ); + } + + const action = await promptBraveSearchRecovery(validation); + if (action === "skip") { + console.log(" Skipping Brave Web Search setup."); + console.log(""); + return null; + } + + apiKey = null; + usingSavedKey = false; + } + } + + async function configureWebSearch( + existingConfig: WebSearchConfig | null = null, + agent: AgentDefinition | null = null, + dockerfilePathOverride: string | null = null, + ): Promise { + if (!agentSupportsWebSearch(agent, dockerfilePathOverride, ROOT)) { + deps.note(` Web search is not yet supported by ${agent?.displayName ?? "this agent"}. Skipping.`); + return null; + } + + if (existingConfig) { + return { fetchEnabled: true }; + } + + if (deps.isNonInteractive()) { + const braveApiKey = normalizeCredentialValue(process.env[BRAVE_API_KEY_ENV]); + if (!braveApiKey) { + return null; + } + deps.note(" [non-interactive] Brave Web Search requested."); + const validation = validateBraveSearchApiKey(braveApiKey); + if (!validation.ok) { + console.warn( + ` Brave Search API key validation failed. Web search will be disabled — re-enable later via \`${deps.cliName()} config web-search\`.`, + ); + if (validation.message) { + console.warn(` ${validation.message}`); + } + return null; + } + saveCredential(BRAVE_API_KEY_ENV, braveApiKey); + process.env[BRAVE_API_KEY_ENV] = braveApiKey; + return { fetchEnabled: true }; + } + const enableAnswer = await deps.prompt(" Enable Brave Web Search? [y/N]: "); + if (!isAffirmativeAnswer(enableAnswer)) { + return null; + } + + const braveApiKey = await ensureValidatedBraveSearchCredential(); + if (isBackToSelection(braveApiKey)) { + return configureWebSearch(existingConfig, agent, dockerfilePathOverride); + } + if (!braveApiKey) { + return null; + } + + console.log(" ✓ Enabled Brave Web Search"); + console.log(""); + return { fetchEnabled: true }; + } + + function verifyWebSearchInsideSandbox( + sandboxName: string, + agent: AgentDefinition | null | undefined, + ): void { + verifyWebSearchInsideSandboxWithDeps(sandboxName, agent, { + runCaptureOpenshell: deps.runCaptureOpenshell, + cliName: deps.cliName, + }); + } + + return { + validateBraveSearchApiKey, + promptBraveSearchRecovery, + promptBraveSearchApiKey, + ensureValidatedBraveSearchCredential, + configureWebSearch, + verifyWebSearchInsideSandbox, + }; +} diff --git a/src/lib/security/credential-hash.ts b/src/lib/security/credential-hash.ts index 5554591e6f..492806eae6 100644 --- a/src/lib/security/credential-hash.ts +++ b/src/lib/security/credential-hash.ts @@ -6,5 +6,7 @@ import crypto from "node:crypto"; export function hashCredential(value: string | null | undefined): string | null { const normalized = String(value ?? "").trim(); if (!normalized) return null; - return crypto.createHash("sha256").update(normalized).digest("hex"); + // This is a non-secret change detector for credential rotation, not a + // password verifier or credential storage primitive. + return crypto.createHash("sha256").update(normalized).digest("hex"); // codeql[js/insufficient-password-hash] }