diff --git a/.claude/refactor-onboard-plan.md b/.claude/refactor-onboard-plan.md new file mode 100644 index 00000000000..7dd9ab8afb7 --- /dev/null +++ b/.claude/refactor-onboard-plan.md @@ -0,0 +1,219 @@ +# Refactor `onboard.ts` (6,382 lines) into ~11 focused modules + +## Context + +`src/lib/onboard.ts` is a 6,382-line god-file containing the entire onboarding wizard — 133 functions, 83 exports, and functions up to 900 lines long. The project already has a pattern of extracting modules (gateway-state, validation, http-probe, dashboard, etc.) but onboard.ts was never broken up. + +**Goal:** No file over ~600 lines, no function over ~80 lines, each module has one clear domain. The refactor is purely structural — no behavior changes. + +## Critical rules + +1. **No behavior changes.** Move code, don't rewrite it. The only new code is glue (imports/exports), the consolidated `validateProviderSelection()` (Step 3), and `getProviderLabel()` (Step 1). +2. **Backward compat.** All 83 existing exports must remain accessible from `onboard.ts` via re-exports. The test file `test/onboard.test.ts` imports directly from `onboard.ts` and must not be modified. +3. **One step = one commit.** Each step is a separate commit. Run `npm test` after each step before committing. Use conventional commit format: `refactor(onboard): extract `. +4. **SPDX headers** on every new .ts file: `// @ts-nocheck` + SPDX block. +5. **CJS format** — `src/lib/` uses CommonJS (`require`/`module.exports`). +6. **No new file over 600 lines**, no function over ~80 lines. + +## Dependency injection pattern + +**Problem:** Several extracted functions (`upsertProvider`, `providerExistsInGateway`, etc.) call `runOpenshell()`, which is defined in `onboard.ts` and depends on mutable module state (`OPENSHELL_BIN`). Importing from `onboard.ts` back would create a circular dependency. + +**Solution:** Functions that need `runOpenshell` accept it as a **last parameter** in the extracted module. `onboard.ts` creates thin wrapper functions (same original signature) that inject `runOpenshell`: + +```js +// In onboard-providers.ts: +function upsertProvider(name, type, credentialEnv, baseUrl, env, _runOpenshell) { ... } + +// In onboard.ts: +const providers = require("./onboard-providers"); +function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { + return providers.upsertProvider(name, type, credentialEnv, baseUrl, env, runOpenshell); +} +``` + +Same pattern for `isNonInteractive` — functions like `getRequestedProviderHint(nonInteractive)` drop the default parameter; the `onboard.ts` wrapper supplies `isNonInteractive()` as default. + +Other dependencies (`compactText`, `redact`, `isSafeModelId`, etc.) come from their own modules (`url-utils`, `runner`, `validation`) and can be imported directly — no circular issue. + +--- + +## Execution order + +### Step 1: Extract `onboard-providers.ts` (~280 lines) + +**Why first:** Provider metadata is referenced everywhere. + +**Move from `onboard.ts`:** +- `REMOTE_PROVIDER_CONFIG` object (L163-228) + endpoint URL constants (L157-161) +- `LOCAL_INFERENCE_PROVIDERS` constant (L52) +- `DISCORD_SNOWFLAKE_RE` (L230) +- `getEffectiveProviderName()` (L2032-2048) +- `getNonInteractiveProvider()` (L2245-2275) +- `getNonInteractiveModel()` (L2277-2286) +- `getRequestedProviderHint()` (L2022-2024) — drop default, wrapper in onboard.ts +- `getRequestedModelHint()` (L2026-2030) — drop default, wrapper in onboard.ts +- `buildProviderArgs()` (L778-789) — pure, re-export directly +- `upsertProvider()` (L803-817) — DI for `runOpenshell` +- `providerExistsInGateway()` (L849-855) — DI for `runOpenshell` +- `upsertMessagingProviders()` (L826-838) — DI for `runOpenshell` +- `getSandboxInferenceConfig()` (L1130-1172) — pure, re-export directly + +**New function:** `getProviderLabel(provider)` — replaces scattered if/else chains in `printDashboard` (L5716-5729). + +**Imports needed:** `redact` (runner), `isSafeModelId` (validation), `compactText` (url-utils), `DEFAULT_CLOUD_MODEL` (inference-config). + +### Step 2: Extract `onboard-ollama-proxy.ts` (~180 lines) + +**Why second:** Zero coupling to other onboard logic. Pure self-contained subsystem. + +**Move from `onboard.ts`:** +- `PROXY_STATE_DIR`, `PROXY_TOKEN_PATH`, `PROXY_PID_PATH` (L1784-1786) +- `ollamaProxyToken` module state (L1788) +- `ensureProxyStateDir()` (L1790-1794) +- `persistProxyToken()` / `loadPersistedProxyToken()` (L1796-1813) +- `persistProxyPid()` / `loadPersistedProxyPid()` / `clearPersistedProxyPid()` (L1815-1841) +- `isOllamaProxyProcess()` (L1843-1847) +- `spawnOllamaAuthProxy()` (L1849-1863) — DI for `runOpenshell`/`runCaptureOpenshell` +- `killStaleProxy()` (L1865-1888) +- `startOllamaAuthProxy()` (L1890-1903) — DI +- `ensureOllamaAuthProxy()` (L1909-1925) — DI +- `getOllamaProxyToken()` (L1927-1932) +- `promptOllamaModel()` (L1934-1958) — DI for `prompt`/`isNonInteractive` +- `printOllamaExposureWarning()` (L1960-1967) +- `pullOllamaModel()` (L1969-1984) — DI for `runOpenshell` +- `prepareOllamaModel()` (L1986-2003) — DI + +### Step 3: Extract `onboard-inference-probes.ts` (~350 lines) + +**Move from `onboard.ts`:** +- `parseJsonObject()` (L1305-1312) +- `hasResponsesToolCall()` (L1314-1329) +- `shouldRequireResponsesToolCalling()` (L1331-1335) +- `getProbeAuthMode()` (L1340-1342) +- `getValidationProbeCurlArgs()` (L1351-1356) +- `probeResponsesToolCalling()` (L1358-1412) — DI for `runCaptureOpenshell` +- `probeOpenAiLikeEndpoint()` (L1414-1601) — DI +- `probeAnthropicEndpoint()` (L1603-1636) — DI + +**Consolidate 4 validation functions into `validateProviderSelection()`** replacing `validateOpenAiLikeSelection` (L1638-1669), `validateAnthropicSelectionWithRetryMessage` (L1671-1701), `validateCustomOpenAiLikeSelection` (L1703-1736), `validateCustomAnthropicSelection` (L1738-1767). + +### Step 4: Extract `onboard-dashboard.ts` (~200 lines) + +**Move from `onboard.ts`:** +- `CONTROL_UI_PORT` (L5552) +- `ensureDashboardForward()` (L5558-5578) +- `findOpenclawJsonPath()` (L5580-5593) +- `fetchGatewayAuthTokenFromSandbox()` (L5599-5622) +- `getDashboardForwardPort()` / `getDashboardForwardTarget()` / `getDashboardForwardStartCommand()` (L5626-5651) +- `buildAuthenticatedDashboardUrl()` (L5653-5656) +- `getWslHostAddress()` (L5658-5672) +- `getDashboardAccessInfo()` / `getDashboardGuidanceLines()` (L5674-5714) +- `printDashboard()` (L5716-5790) — use `getProviderLabel()` from Step 1 + +### Step 5: Extract `onboard-gateway.ts` (~300 lines) + +**Move from `onboard.ts`:** +- `verifyGatewayContainerRunning()` (L136-154) +- `streamGatewayStart()` (L307-450) +- `startGatewayWithOptions()` (L2625-2789) +- `startGateway()` / `startGatewayForRecovery()` (L2791-2797) +- `getGatewayStartEnv()` (L2799-2810) +- `recoverGatewayRuntime()` (L2812-2857) +- `destroyGateway()` (L2193-2207) + +**Decompose `streamGatewayStart()`** into `classifyLine()`, `setPhase()`, heartbeat timer setup. + +### Step 6: Extract `onboard-preflight.ts` (~350 lines) + +**Move from `onboard.ts`:** +- `preflight()` (L2291-2619) +- `isOpenshellInstalled()` (L2132-2134) +- `installOpenshell()` (L2158-2187) +- `getInstalledOpenshellVersion()` (L458-463) +- `versionGte()` (L469-484) +- `getBlueprintVersionField()` / `getBlueprintMinOpenshellVersion()` / `getBlueprintMaxOpenshellVersion()` (L492-518) +- `getStableGatewayImageRef()` (L571-575) +- `getPortConflictServiceHints()` (L2143-2156) +- `printRemediationActions()` (L2116-2130) +- `getContainerRuntime()` (L2111-2114) + +**Decompose `preflight()`** into ~5 sub-functions, each under 80 lines. + +### Step 7: Extract `onboard-messaging.ts` (~350 lines) + +**Move from `onboard.ts`:** +- `MESSAGING_CHANNELS` constant (L4461-4502) +- `TELEGRAM_NETWORK_CURL_CODES` (L4506) +- `checkTelegramReachability()` (L4508-4557) +- `setupMessagingChannels()` (L4559-4764) +- `makeConflictProbe()` (L901-919) + +**Decompose `setupMessagingChannels()`** into TUI selector + credential prompter + orchestrator. + +### Step 8: Extract `onboard-policies.ts` (~500 lines) + +**Move from `onboard.ts`:** +- `getSuggestedPolicyPresets()` (L4766-4795) +- `computeSetupPresetSuggestions()` (L5381-5396) +- `arePolicyPresetsApplied()` (L4930-4934) +- `selectPolicyTier()` (L4944-5058) +- `selectTierPresetsAndAccess()` (L5079-5249) +- `presetsCheckboxSelector()` (L5256-5379) +- `setupPoliciesWithSelection()` (L5399-5548) +- `_setupPolicies()` (L4826-4928) + +### Step 9: Extract `onboard-inference-setup.ts` (~400 lines) + +**Depends on:** Steps 1, 2, 3. + +**Move from `onboard.ts`:** +- `setupNim()` (L3616-4286) — decompose into ~5 sub-functions +- `setupInference()` (L4290-4457) — decompose into ~3 sub-functions + +### Step 10: Extract `onboard-sandbox.ts` (~500 lines) + +**Depends on:** Steps 1, 7. + +**Move from `onboard.ts`:** +- `promptValidatedSandboxName()` (L2861-2926) +- `getRequestedSandboxNameHint()` (L2005-2010) +- `getResumeSandboxConflict()` (L2012-2020) +- `getResumeConfigConflicts()` (L2050-2109) +- `getSandboxReuseState()` (L289-294) +- `repairRecordedSandbox()` (L296-302) +- `pruneStaleSandboxEntry()` (L941-948) +- `buildSandboxConfigSyncScript()` (L950-963) +- `writeSandboxConfigSyncFile()` (L969-973) +- `isOpenclawReady()` (L965-967) +- `waitForSandboxReady()` (L2218-2240) +- `patchStagedDockerfile()` (L1174-1303) +- `createSandbox()` (L2931-3611) — decompose into ~6 sub-functions +- Base image helpers: `SANDBOX_BASE_IMAGE`, `SANDBOX_BASE_TAG`, `pullAndResolveBaseImageDigest()` (L526-569) + +### Step 11: Slim down `onboard.ts` to orchestrator-only (~500 lines) + +**What remains:** +- Imports from all new modules +- Global state: `NON_INTERACTIVE`, `RECREATE_SANDBOX`, `OPENSHELL_BIN` +- Shared helpers: `isNonInteractive()`, `isRecreateSandbox()`, `note()`, `step()`, `sleep()` +- Prompt helpers: `promptOrDefault()`, `promptValidationRecovery()`, `replaceNamedCredential()`, `ensureNamedCredential()` +- Session helpers: `ONBOARD_STEP_INDEX`, `startRecordedStep()`, `skippedStepMessage()` +- Small utilities: `secureTempFile()`, `cleanupTempDir()`, `openshellShellCommand()`, `runOpenshell()`, `runCaptureOpenshell()`, `getOpenshellBinary()` +- `setupOpenclaw()` (L4799-4821) +- `onboard()` orchestrator (L5827-6297) +- `module.exports` — re-exports everything for backward compat + +--- + +## Verification + +After each step: +1. `npm test` — all existing tests pass (baseline: 2 pre-existing failures unrelated to onboard) +2. Verify new file has `// @ts-nocheck` + SPDX header +3. Verify `wc -l` of onboard.ts is shrinking and new file is under 600 lines + +After all steps: +1. `npm test` — full test suite +2. `wc -l src/lib/onboard*.ts` — no file exceeds 600 lines +3. `node bin/nemoclaw.js --help` — CLI still works diff --git a/src/lib/onboard-inference-probes.ts b/src/lib/onboard-inference-probes.ts new file mode 100644 index 00000000000..f120a526ef7 --- /dev/null +++ b/src/lib/onboard-inference-probes.ts @@ -0,0 +1,371 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Inference endpoint probes — validate that a provider's API responds +// before committing the onboard wizard to a model selection. + +const { normalizeCredentialValue } = require("./credentials"); +const { isWsl } = require("./platform"); +const httpProbe = require("./http-probe"); +const { + isNvcfFunctionNotFoundForAccount, + nvcfFunctionNotFoundMessage, + shouldForceCompletionsApi, +} = require("./validation"); + +const { getCurlTimingArgs, runCurlProbe, runStreamingEventProbe } = httpProbe; + +// ── Helpers ────────────────────────────────────────────────────── + +function parseJsonObject(body) { + if (!body) return null; + try { + return JSON.parse(body); + } catch { + return null; + } +} + +function hasResponsesToolCall(body) { + const parsed = parseJsonObject(body); + if (!parsed || !Array.isArray(parsed.output)) return false; + + const stack = [...parsed.output]; + while (stack.length > 0) { + const item = stack.pop(); + if (!item || typeof item !== "object") continue; + if (item.type === "function_call" || item.type === "tool_call") return true; + if (Array.isArray(item.content)) { + stack.push(...item.content); + } + } + + return false; +} + +function shouldRequireResponsesToolCalling(provider) { + return ( + provider === "nvidia-prod" || provider === "gemini-api" || provider === "compatible-endpoint" + ); +} + +// Google Gemini rejects requests that carry both an Authorization: Bearer +// The Gemini OpenAI-compat endpoint at /v1beta/openai/ requires +// `Authorization: Bearer ` and rejects `?key=` with HTTP 400 +// "Missing or invalid Authorization header." The dual-auth rejection +// described in #1960 applies to the native /v1beta/models/...:generateContent +// endpoint, which the onboarder probes do not use. Both callers of this +// helper (probeOpenAiLikeEndpoint, probeResponsesToolCalling) target the +// OpenAI-compat URL, so returning undefined for every provider is correct: +// probes default to Bearer auth and Gemini onboarding succeeds. +function getProbeAuthMode(_provider) { + return undefined; +} + +// Per-validation-probe curl timing. Tighter than the default 60s in +// getCurlTimingArgs() because validation must not hang the wizard for a +// minute on a misbehaving model. See issue #1601 (Bug 3). +function getValidationProbeCurlArgs(opts) { + if (isWsl(opts)) { + return ["--connect-timeout", "20", "--max-time", "30"]; + } + return ["--connect-timeout", "10", "--max-time", "15"]; +} + +// ── Responses API probe ────────────────────────────────────────── + +function probeResponsesToolCalling(endpointUrl, model, apiKey, options = {}) { + const useQueryParam = options.authMode === "query-param"; + const normalizedKey = apiKey ? normalizeCredentialValue(apiKey) : ""; + const baseUrl = String(endpointUrl).replace(/\/+$/, ""); + const authHeader = !useQueryParam && normalizedKey + ? ["-H", `Authorization: Bearer ${normalizedKey}`] + : []; + const url = useQueryParam && normalizedKey + ? `${baseUrl}/responses?key=${encodeURIComponent(normalizedKey)}` + : `${baseUrl}/responses`; + const result = runCurlProbe([ + "-sS", + ...getValidationProbeCurlArgs(), + "-H", + "Content-Type: application/json", + ...authHeader, + "-d", + JSON.stringify({ + model, + input: "Call the emit_ok function with value OK. Do not answer with plain text.", + tool_choice: "required", + tools: [ + { + type: "function", + name: "emit_ok", + description: "Returns the probe value for validation.", + parameters: { + type: "object", + properties: { + value: { type: "string" }, + }, + required: ["value"], + additionalProperties: false, + }, + }, + ], + }), + url, + ]); + + if (!result.ok) { + return result; + } + if (hasResponsesToolCall(result.body)) { + return result; + } + return { + ok: false, + httpStatus: result.httpStatus, + curlStatus: result.curlStatus, + body: result.body, + stderr: result.stderr, + message: `HTTP ${result.httpStatus}: Responses API did not return a tool call`, + }; +} + +// ── OpenAI-like probe ──────────────────────────────────────────── +// eslint-disable-next-line complexity +function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { + const useQueryParam = options.authMode === "query-param"; + const normalizedKey = apiKey ? normalizeCredentialValue(apiKey) : ""; + const baseUrl = String(endpointUrl).replace(/\/+$/, ""); + const authHeader = !useQueryParam && normalizedKey + ? ["-H", `Authorization: Bearer ${normalizedKey}`] + : []; + const appendKey = (urlPath) => + useQueryParam && normalizedKey ? `${baseUrl}${urlPath}?key=${encodeURIComponent(normalizedKey)}` : `${baseUrl}${urlPath}`; + + const responsesProbe = + options.requireResponsesToolCalling === true + ? { + name: "Responses API with tool calling", + api: "openai-responses", + execute: () => probeResponsesToolCalling(endpointUrl, model, apiKey, { authMode: options.authMode }), + } + : { + name: "Responses API", + api: "openai-responses", + execute: () => + runCurlProbe([ + "-sS", + ...getValidationProbeCurlArgs(), + "-H", + "Content-Type: application/json", + ...authHeader, + "-d", + JSON.stringify({ + model, + input: "Reply with exactly: OK", + }), + appendKey("/responses"), + ]), + }; + + const chatCompletionsProbe = { + name: "Chat Completions API", + api: "openai-completions", + execute: () => + runCurlProbe([ + "-sS", + ...getValidationProbeCurlArgs(), + "-H", + "Content-Type: application/json", + ...authHeader, + "-d", + JSON.stringify({ + model, + messages: [{ role: "user", content: "Reply with exactly: OK" }], + }), + appendKey("/chat/completions"), + ]), + }; + + // NVIDIA Build does not expose /v1/responses; probing it always returns + // "404 page not found" and only adds noise to error messages. Skip it + // entirely for that provider. See issue #1601. + const probes = options.skipResponsesProbe + ? [chatCompletionsProbe] + : [responsesProbe, chatCompletionsProbe]; + + const failures = []; + for (const probe of probes) { + const result = probe.execute(); + if (result.ok) { + // Streaming event validation — catch backends like SGLang that return + // valid non-streaming responses but emit incomplete SSE events in + // streaming mode. Only run for /responses probes on custom endpoints + // where probeStreaming was requested. + if (probe.api === "openai-responses" && options.probeStreaming === true) { + const streamResult = runStreamingEventProbe([ + "-sS", + ...getValidationProbeCurlArgs(), + "-H", + "Content-Type: application/json", + ...authHeader, + "-d", + JSON.stringify({ + model, + input: "Reply with exactly: OK", + stream: true, + }), + appendKey("/responses"), + ]); + if (!streamResult.ok && streamResult.missingEvents.length > 0) { + // Backend responds but lacks required streaming events — fall back + // to /chat/completions silently. + console.log(` ℹ ${streamResult.message}`); + failures.push({ + name: probe.name + " (streaming)", + httpStatus: 0, + curlStatus: 0, + message: streamResult.message, + body: "", + }); + continue; + } + if (!streamResult.ok) { + // Transport or execution failure — surface as a hard error instead + // of silently switching APIs. + return { + ok: false, + message: `${probe.name} (streaming): ${streamResult.message}`, + failures: [ + { + name: probe.name + " (streaming)", + httpStatus: 0, + curlStatus: 0, + message: streamResult.message, + body: "", + }, + ], + }; + } + } + return { ok: true, api: probe.api, label: probe.name }; + } + // Preserve the raw response body alongside the summarized message so the + // NVCF "Function not found for account" detector below can fall back to + // the raw body if summarizeProbeError ever stops surfacing the marker + // through `message`. + failures.push({ + name: probe.name, + httpStatus: result.httpStatus, + curlStatus: result.curlStatus, + message: result.message, + body: result.body, + }); + } + + // Single retry with doubled timeouts on timeout/connection failure. + // WSL2's virtualized network stack can cause the initial probe to time out + // before the TLS handshake completes. See issue #987. + const isTimeoutOrConnFailure = (cs) => cs === 28 || cs === 6 || cs === 7; + let retriedAfterTimeout = false; + if (failures.length > 0 && isTimeoutOrConnFailure(failures[0].curlStatus)) { + retriedAfterTimeout = true; + const baseArgs = getValidationProbeCurlArgs(); + const doubledArgs = baseArgs.map((arg) => + /^\d+$/.test(arg) ? String(Number(arg) * 2) : arg, + ); + const retryResult = runCurlProbe([ + "-sS", + ...doubledArgs, + "-H", + "Content-Type: application/json", + ...(apiKey ? ["-H", `Authorization: Bearer ${normalizeCredentialValue(apiKey)}`] : []), + "-d", + JSON.stringify({ + model, + messages: [{ role: "user", content: "Reply with exactly: OK" }], + }), + `${String(endpointUrl).replace(/\/+$/, "")}/chat/completions`, + ]); + if (retryResult.ok) { + return { ok: true, api: "openai-completions", label: "Chat Completions API" }; + } + } + + // Detect the NVCF "Function not found for account" error and reframe it + // with an actionable next step instead of dumping the raw NVCF body. + // See issue #1601 (Bug 2). + const accountFailure = failures.find( + (failure) => + isNvcfFunctionNotFoundForAccount(failure.message) || + isNvcfFunctionNotFoundForAccount(failure.body), + ); + if (accountFailure) { + return { + ok: false, + message: nvcfFunctionNotFoundMessage(model), + failures, + }; + } + + const baseMessage = failures.map((failure) => `${failure.name}: ${failure.message}`).join(" | "); + const wslHint = + isWsl() && retriedAfterTimeout + ? " · WSL2 detected \u2014 network verification may be slower than expected. " + + "Run `nemoclaw onboard` with the `--skip-verify` flag if this endpoint is known to be reachable." + : ""; + return { + ok: false, + message: baseMessage + wslHint, + failures, + }; +} + +// ── Anthropic probe ────────────────────────────────────────────── + +function probeAnthropicEndpoint(endpointUrl, model, apiKey) { + const result = runCurlProbe([ + "-sS", + ...getCurlTimingArgs(), + "-H", + `x-api-key: ${normalizeCredentialValue(apiKey)}`, + "-H", + "anthropic-version: 2023-06-01", + "-H", + "content-type: application/json", + "-d", + JSON.stringify({ + model, + max_tokens: 16, + messages: [{ role: "user", content: "Reply with exactly: OK" }], + }), + `${String(endpointUrl).replace(/\/+$/, "")}/v1/messages`, + ]); + if (result.ok) { + return { ok: true, api: "anthropic-messages", label: "Anthropic Messages API" }; + } + return { + ok: false, + message: result.message, + failures: [ + { + name: "Anthropic Messages API", + httpStatus: result.httpStatus, + curlStatus: result.curlStatus, + message: result.message, + }, + ], + }; +} + +module.exports = { + parseJsonObject, + hasResponsesToolCall, + shouldRequireResponsesToolCalling, + getProbeAuthMode, + getValidationProbeCurlArgs, + probeResponsesToolCalling, + probeOpenAiLikeEndpoint, + probeAnthropicEndpoint, +}; diff --git a/src/lib/onboard-ollama-proxy.ts b/src/lib/onboard-ollama-proxy.ts new file mode 100644 index 00000000000..4f25761c334 --- /dev/null +++ b/src/lib/onboard-ollama-proxy.ts @@ -0,0 +1,277 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Ollama auth-proxy lifecycle: token persistence, PID management, +// proxy start/stop, model pull and validation. + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawn, spawnSync } = require("child_process"); +const { ROOT, SCRIPTS, run, runCapture, shellQuote } = require("./runner"); +const { OLLAMA_PORT, OLLAMA_PROXY_PORT } = require("./ports"); +const { + getDefaultOllamaModel, + getBootstrapOllamaModelOptions, + getOllamaModelOptions, + getOllamaWarmupCommand, + validateOllamaModel, +} = require("./local-inference"); +const { prompt } = require("./credentials"); +const { promptManualModelId } = require("./model-prompts"); + +// ── State ──────────────────────────────────────────────────────── + +const PROXY_STATE_DIR = path.join(os.homedir(), ".nemoclaw"); +const PROXY_TOKEN_PATH = path.join(PROXY_STATE_DIR, "ollama-proxy-token"); +const PROXY_PID_PATH = path.join(PROXY_STATE_DIR, "ollama-auth-proxy.pid"); + +let ollamaProxyToken: string | null = null; + +function sleep(seconds) { + spawnSync("sleep", [String(seconds)]); +} + +// ── Proxy state dir ────────────────────────────────────────────── + +function ensureProxyStateDir(): void { + if (!fs.existsSync(PROXY_STATE_DIR)) { + fs.mkdirSync(PROXY_STATE_DIR, { recursive: true }); + } +} + +// ── Token persistence ──────────────────────────────────────────── + +function persistProxyToken(token: string): void { + ensureProxyStateDir(); + fs.writeFileSync(PROXY_TOKEN_PATH, token, { mode: 0o600 }); + // mode only applies on creation; ensure permissions on existing files too + fs.chmodSync(PROXY_TOKEN_PATH, 0o600); +} + +function loadPersistedProxyToken(): string | null { + try { + if (fs.existsSync(PROXY_TOKEN_PATH)) { + const token = fs.readFileSync(PROXY_TOKEN_PATH, "utf-8").trim(); + return token || null; + } + } catch { + /* ignore */ + } + return null; +} + +// ── PID persistence ────────────────────────────────────────────── + +function persistProxyPid(pid: number | null | undefined): void { + if (!Number.isInteger(pid) || pid <= 0) return; + ensureProxyStateDir(); + fs.writeFileSync(PROXY_PID_PATH, `${pid}\n`, { mode: 0o600 }); + fs.chmodSync(PROXY_PID_PATH, 0o600); +} + +function loadPersistedProxyPid(): number | null { + try { + if (!fs.existsSync(PROXY_PID_PATH)) return null; + const raw = fs.readFileSync(PROXY_PID_PATH, "utf-8").trim(); + const pid = Number.parseInt(raw, 10); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function clearPersistedProxyPid(): void { + try { + if (fs.existsSync(PROXY_PID_PATH)) { + fs.unlinkSync(PROXY_PID_PATH); + } + } catch { + /* ignore */ + } +} + +// ── Process management ─────────────────────────────────────────── + +function isOllamaProxyProcess(pid: number | null | undefined): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + const cmdline = runCapture(["ps", "-p", String(pid), "-o", "args="], { ignoreError: true }); + return Boolean(cmdline && cmdline.includes("ollama-auth-proxy.js")); +} + +function spawnOllamaAuthProxy(token: string): number | null { + const child = spawn(process.execPath, [path.join(SCRIPTS, "ollama-auth-proxy.js")], { + detached: true, + stdio: "ignore", + env: { + ...process.env, + OLLAMA_PROXY_TOKEN: token, + OLLAMA_PROXY_PORT: String(OLLAMA_PROXY_PORT), + OLLAMA_BACKEND_PORT: String(OLLAMA_PORT), + }, + }); + child.unref(); + persistProxyPid(child.pid); + return child.pid ?? null; +} + +function killStaleProxy(): void { + try { + const persistedPid = loadPersistedProxyPid(); + if (isOllamaProxyProcess(persistedPid)) { + run(["kill", String(persistedPid)], { ignoreError: true, suppressOutput: true }); + } + clearPersistedProxyPid(); + + // Best-effort cleanup for older proxy processes created before the PID file + // existed. Only kill processes that are actually the auth proxy, not + // unrelated services that happen to use the same port. + const pidOutput = runCapture(["lsof", "-ti", `:${OLLAMA_PROXY_PORT}`], { ignoreError: true }); + if (pidOutput && pidOutput.trim()) { + for (const pid of pidOutput.trim().split(/\s+/)) { + if (isOllamaProxyProcess(Number.parseInt(pid, 10))) { + run(["kill", pid], { ignoreError: true, suppressOutput: true }); + } + } + sleep(1); + } + } catch { + /* ignore */ + } +} + +// ── Public API ─────────────────────────────────────────────────── + +function startOllamaAuthProxy(): boolean { + const crypto = require("crypto"); + killStaleProxy(); + + const proxyToken = crypto.randomBytes(24).toString("hex"); + ollamaProxyToken = proxyToken; + // Don't persist yet — wait until provider is confirmed in setupInference. + // If the user backs out to a different provider, the token stays in memory + // only and is discarded. + const pid = spawnOllamaAuthProxy(proxyToken); + sleep(1); + if (!isOllamaProxyProcess(pid)) { + console.error(` Error: Ollama auth proxy failed to start on :${OLLAMA_PROXY_PORT}`); + console.error(` Containers will not be able to reach Ollama without the proxy.`); + console.error( + ` Check if port ${OLLAMA_PROXY_PORT} is already in use: lsof -ti :${OLLAMA_PROXY_PORT}`, + ); + return false; + } + return true; +} + +/** + * Ensure the auth proxy is running — called on sandbox connect to recover + * from host reboots where the background proxy process was lost. + */ +function ensureOllamaAuthProxy(): void { + // Try to load persisted token first — if none, this isn't an Ollama setup. + const token = loadPersistedProxyToken(); + if (!token) return; + + const pid = loadPersistedProxyPid(); + if (isOllamaProxyProcess(pid)) { + ollamaProxyToken = token; + return; + } + + // Proxy not running — restart it with the persisted token. + killStaleProxy(); + ollamaProxyToken = token; + spawnOllamaAuthProxy(token); + sleep(1); +} + +function getOllamaProxyToken(): string | null { + if (ollamaProxyToken) return ollamaProxyToken; + // Fall back to persisted token (resume / reconnect scenario) + ollamaProxyToken = loadPersistedProxyToken(); + return ollamaProxyToken; +} + +async function promptOllamaModel(gpu = null) { + const installed = getOllamaModelOptions(); + const options = installed.length > 0 ? installed : getBootstrapOllamaModelOptions(gpu); + const defaultModel = getDefaultOllamaModel(gpu); + const defaultIndex = Math.max(0, options.indexOf(defaultModel)); + + console.log(""); + console.log(installed.length > 0 ? " Ollama models:" : " Ollama starter models:"); + options.forEach((option, index) => { + console.log(` ${index + 1}) ${option}`); + }); + console.log(` ${options.length + 1}) Other...`); + if (installed.length === 0) { + console.log(""); + console.log(" No local Ollama models are installed yet. Choose one to pull and load now."); + } + console.log(""); + + const choice = await prompt(` Choose model [${defaultIndex + 1}]: `); + const index = parseInt(choice || String(defaultIndex + 1), 10) - 1; + if (index >= 0 && index < options.length) { + return options[index]; + } + return promptManualModelId(" Ollama model id: ", "Ollama"); +} + +function printOllamaExposureWarning() { + console.log(""); + console.log(" ⚠ Ollama is binding to 0.0.0.0 so the sandbox can reach it via Docker."); + console.log(" This exposes the Ollama API to your local network (no auth required)."); + console.log(" On public WiFi, any device on the same network can send prompts to your GPU."); + console.log(" See: CNVD-2025-04094, CVE-2024-37032"); + console.log(""); +} + +function pullOllamaModel(model) { + const result = spawnSync("bash", ["-c", `ollama pull ${shellQuote(model)}`], { + cwd: ROOT, + encoding: "utf8", + stdio: "inherit", + timeout: 600_000, + env: { ...process.env }, + }); + if (result.signal === "SIGTERM") { + console.error( + ` Model pull timed out after 10 minutes. Try a smaller model or check your network connection.`, + ); + return false; + } + return result.status === 0; +} + +function prepareOllamaModel(model, installedModels = []) { + const alreadyInstalled = installedModels.includes(model); + if (!alreadyInstalled) { + console.log(` Pulling Ollama model: ${model}`); + if (!pullOllamaModel(model)) { + return { + ok: false, + message: + `Failed to pull Ollama model '${model}'. ` + + "Check the model name and that Ollama can access the registry, then try another model.", + }; + } + } + + console.log(` Loading Ollama model: ${model}`); + run(getOllamaWarmupCommand(model), { ignoreError: true }); + return validateOllamaModel(model); +} + +module.exports = { + ensureOllamaAuthProxy, + getOllamaProxyToken, + persistProxyToken, + startOllamaAuthProxy, + promptOllamaModel, + printOllamaExposureWarning, + pullOllamaModel, + prepareOllamaModel, +}; diff --git a/src/lib/onboard-providers.ts b/src/lib/onboard-providers.ts new file mode 100644 index 00000000000..0dcf951e58c --- /dev/null +++ b/src/lib/onboard-providers.ts @@ -0,0 +1,347 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Provider metadata, lookup helpers, and gateway provider CRUD. + +const { redact } = require("./runner"); +const { DEFAULT_CLOUD_MODEL } = require("./inference-config"); +const { isSafeModelId } = require("./validation"); +const { compactText } = require("./url-utils"); + +// ── Constants ──────────────────────────────────────────────────── + +const BUILD_ENDPOINT_URL = "https://integrate.api.nvidia.com/v1"; +const OPENAI_ENDPOINT_URL = "https://api.openai.com/v1"; +const ANTHROPIC_ENDPOINT_URL = "https://api.anthropic.com"; +const GEMINI_ENDPOINT_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"; + +const REMOTE_PROVIDER_CONFIG = { + build: { + label: "NVIDIA Endpoints", + providerName: "nvidia-prod", + providerType: "nvidia", + credentialEnv: "NVIDIA_API_KEY", + endpointUrl: BUILD_ENDPOINT_URL, + helpUrl: "https://build.nvidia.com/settings/api-keys", + modelMode: "catalog", + defaultModel: DEFAULT_CLOUD_MODEL, + skipVerify: true, + }, + openai: { + label: "OpenAI", + providerName: "openai-api", + providerType: "openai", + credentialEnv: "OPENAI_API_KEY", + endpointUrl: OPENAI_ENDPOINT_URL, + helpUrl: "https://platform.openai.com/api-keys", + modelMode: "curated", + defaultModel: "gpt-5.4", + skipVerify: true, + }, + anthropic: { + label: "Anthropic", + providerName: "anthropic-prod", + providerType: "anthropic", + credentialEnv: "ANTHROPIC_API_KEY", + endpointUrl: ANTHROPIC_ENDPOINT_URL, + helpUrl: "https://console.anthropic.com/settings/keys", + modelMode: "curated", + defaultModel: "claude-sonnet-4-6", + }, + anthropicCompatible: { + label: "Other Anthropic-compatible endpoint", + providerName: "compatible-anthropic-endpoint", + providerType: "anthropic", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + endpointUrl: "", + helpUrl: null, + modelMode: "input", + defaultModel: "", + }, + gemini: { + label: "Google Gemini", + providerName: "gemini-api", + providerType: "openai", + credentialEnv: "GEMINI_API_KEY", + endpointUrl: GEMINI_ENDPOINT_URL, + helpUrl: "https://aistudio.google.com/app/apikey", + modelMode: "curated", + defaultModel: "gemini-2.5-flash", + skipVerify: true, + }, + custom: { + label: "Other OpenAI-compatible endpoint", + providerName: "compatible-endpoint", + providerType: "openai", + credentialEnv: "COMPATIBLE_API_KEY", + endpointUrl: "", + helpUrl: null, + modelMode: "input", + defaultModel: "", + skipVerify: true, + }, +}; + +// Providers that run on the host and need the local-inference policy preset. +const LOCAL_INFERENCE_PROVIDERS = ["ollama-local", "vllm-local"]; + +const DISCORD_SNOWFLAKE_RE = /^[0-9]{17,19}$/; + +// ── Provider label ─────────────────────────────────────────────── + +/** + * Human-readable label for a provider name. + * Consolidates the scattered if/else chains (printDashboard, etc.). + */ +function getProviderLabel(provider) { + for (const cfg of Object.values(REMOTE_PROVIDER_CONFIG)) { + if (cfg.providerName === provider) return cfg.label; + } + switch (provider) { + case "nvidia-nim": + return "NVIDIA Endpoints"; + case "vllm-local": + return "Local vLLM"; + case "ollama-local": + return "Local Ollama"; + default: + return provider; + } +} + +// ── Provider name resolution ───────────────────────────────────── + +function getEffectiveProviderName(providerKey) { + if (!providerKey) return null; + if (REMOTE_PROVIDER_CONFIG[providerKey]) { + return REMOTE_PROVIDER_CONFIG[providerKey].providerName; + } + switch (providerKey) { + case "nim-local": + return "nvidia-nim"; + case "ollama": + return "ollama-local"; + case "vllm": + return "vllm-local"; + default: + return providerKey; + } +} + +// ── Non-interactive helpers ────────────────────────────────────── + +function getNonInteractiveProvider() { + const providerKey = (process.env.NEMOCLAW_PROVIDER || "").trim().toLowerCase(); + if (!providerKey) return null; + const aliases = { + cloud: "build", + nim: "nim-local", + vllm: "vllm", + anthropiccompatible: "anthropicCompatible", + }; + const normalized = aliases[providerKey] || providerKey; + const validProviders = new Set([ + "build", + "openai", + "anthropic", + "anthropicCompatible", + "gemini", + "ollama", + "custom", + "nim-local", + "vllm", + ]); + if (!validProviders.has(normalized)) { + console.error(` Unsupported NEMOCLAW_PROVIDER: ${providerKey}`); + console.error( + " Valid values: build, openai, anthropic, anthropicCompatible, gemini, ollama, custom, nim-local, vllm", + ); + process.exit(1); + } + return normalized; +} + +function getNonInteractiveModel(providerKey) { + const model = (process.env.NEMOCLAW_MODEL || "").trim(); + if (!model) return null; + if (!isSafeModelId(model)) { + console.error(` Invalid NEMOCLAW_MODEL for provider '${providerKey}': ${model}`); + console.error(" Model values may only contain letters, numbers, '.', '_', ':', '/', and '-'."); + process.exit(1); + } + return model; +} + +// No default for nonInteractive — onboard.ts wrapper supplies isNonInteractive(). +function getRequestedProviderHint(nonInteractive) { + return nonInteractive ? getNonInteractiveProvider() : null; +} + +function getRequestedModelHint(nonInteractive) { + if (!nonInteractive) return null; + const providerKey = getRequestedProviderHint(nonInteractive) || "cloud"; + return getNonInteractiveModel(providerKey); +} + +// ── Gateway provider CRUD ──────────────────────────────────────── +// Functions that call runOpenshell accept it as the last parameter +// to avoid a circular dependency with onboard.ts. + +/** + * Build the argument array for an `openshell provider create` or `update` command. + * @param {"create"|"update"} action - Whether to create or update. + * @param {string} name - Provider name. + * @param {string} type - Provider type (e.g. "openai", "anthropic", "generic"). + * @param {string} credentialEnv - Credential environment variable name. + * @param {string|null} baseUrl - Optional base URL for API-compatible endpoints. + * @returns {string[]} Argument array for runOpenshell(). + */ +function buildProviderArgs(action, name, type, credentialEnv, baseUrl) { + const args = + action === "create" + ? ["provider", "create", "--name", name, "--type", type, "--credential", credentialEnv] + : ["provider", "update", name, "--credential", credentialEnv]; + if (baseUrl && type === "openai") { + args.push("--config", `OPENAI_BASE_URL=${baseUrl}`); + } else if (baseUrl && type === "anthropic") { + args.push("--config", `ANTHROPIC_BASE_URL=${baseUrl}`); + } + return args; +} + +/** + * Check whether an OpenShell provider exists in the gateway. + * + * Queries the gateway-level provider registry via `openshell provider get`. + * Does NOT verify that the provider is attached to a specific sandbox — + * OpenShell CLI does not currently expose a sandbox-scoped provider query. + * @param {string} name - Provider name to look up (e.g. "discord-bridge"). + * @param {Function} _runOpenshell - Injected runOpenshell from onboard.ts. + * @returns {boolean} True if the provider exists in the gateway. + */ +function providerExistsInGateway(name, _runOpenshell) { + const result = _runOpenshell(["provider", "get", name], { + ignoreError: true, + stdio: ["ignore", "ignore", "ignore"], + }); + return result.status === 0; +} + +/** + * Create or update an OpenShell provider in the gateway. + * + * Checks whether the provider already exists via `openshell provider get`; + * uses `create` for new providers and `update` for existing ones. + * @param {string} name - Provider name (e.g. "discord-bridge", "inference"). + * @param {string} type - Provider type ("openai", "anthropic", "generic"). + * @param {string} credentialEnv - Environment variable name for the credential. + * @param {string|null} baseUrl - Optional base URL for the provider endpoint. + * @param {Record} env - Environment variables for the openshell command. + * @param {Function} _runOpenshell - Injected runOpenshell from onboard.ts. + * @returns {{ ok: boolean, status?: number, message?: string }} + */ +function upsertProvider(name, type, credentialEnv, baseUrl, env, _runOpenshell) { + const exists = providerExistsInGateway(name, _runOpenshell); + const action = exists ? "update" : "create"; + const args = buildProviderArgs(action, name, type, credentialEnv, baseUrl); + const runOpts = { ignoreError: true, env, stdio: ["ignore", "pipe", "pipe"] }; + const result = _runOpenshell(args, runOpts); + if (result.status !== 0) { + const output = + compactText(redact(`${result.stderr || ""}`)) || + compactText(redact(`${result.stdout || ""}`)) || + `Failed to ${action} provider '${name}'.`; + return { ok: false, status: result.status || 1, message: output }; + } + return { ok: true }; +} + +/** + * Upsert all messaging providers that have tokens configured. + * Returns the list of provider names that were successfully created/updated. + * Exits the process if any upsert fails. + * @param {Array<{name: string, envKey: string, token: string|null}>} tokenDefs + * @param {Function} _runOpenshell - Injected runOpenshell from onboard.ts. + * @returns {string[]} Provider names that were upserted. + */ +function upsertMessagingProviders(tokenDefs, _runOpenshell) { + const upserted = []; + for (const { name, envKey, token } of tokenDefs) { + if (!token) continue; + const result = upsertProvider(name, "generic", envKey, null, { [envKey]: token }, _runOpenshell); + if (!result.ok) { + console.error(`\n ✗ Failed to create messaging provider '${name}': ${result.message}`); + process.exit(1); + } + upserted.push(name); + } + return upserted; +} + +// ── Sandbox inference config ───────────────────────────────────── + +function getSandboxInferenceConfig(model, provider = null, preferredInferenceApi = null) { + let providerKey; + let primaryModelRef; + let inferenceBaseUrl = "https://inference.local/v1"; + let inferenceApi = preferredInferenceApi || "openai-completions"; + let inferenceCompat = null; + + switch (provider) { + case "openai-api": + providerKey = "openai"; + primaryModelRef = `openai/${model}`; + break; + case "anthropic-prod": + case "compatible-anthropic-endpoint": + providerKey = "anthropic"; + primaryModelRef = `anthropic/${model}`; + inferenceBaseUrl = "https://inference.local"; + inferenceApi = "anthropic-messages"; + break; + case "gemini-api": + providerKey = "inference"; + primaryModelRef = `inference/${model}`; + inferenceCompat = { + supportsStore: false, + }; + break; + case "compatible-endpoint": + providerKey = "inference"; + primaryModelRef = `inference/${model}`; + inferenceCompat = { + supportsStore: false, + }; + break; + case "nvidia-prod": + case "nvidia-nim": + default: + providerKey = "inference"; + primaryModelRef = `inference/${model}`; + break; + } + + return { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat }; +} + +module.exports = { + BUILD_ENDPOINT_URL, + OPENAI_ENDPOINT_URL, + ANTHROPIC_ENDPOINT_URL, + GEMINI_ENDPOINT_URL, + REMOTE_PROVIDER_CONFIG, + LOCAL_INFERENCE_PROVIDERS, + DISCORD_SNOWFLAKE_RE, + getProviderLabel, + getEffectiveProviderName, + getNonInteractiveProvider, + getNonInteractiveModel, + getRequestedProviderHint, + getRequestedModelHint, + buildProviderArgs, + upsertProvider, + providerExistsInGateway, + upsertMessagingProviders, + getSandboxInferenceConfig, +}; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 0573381825e..40b50cbccfa 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -73,10 +73,41 @@ const { const inferenceConfig: typeof import("./inference-config") = require("./inference-config"); const { DEFAULT_CLOUD_MODEL, getProviderSelectionConfig, parseGatewayInference } = inferenceConfig; -// Providers that run on the host and need the local-inference policy preset. -// Shared constant so getSuggestedPolicyPresets() and setupPoliciesWithSelection() -// stay in sync. -const LOCAL_INFERENCE_PROVIDERS: string[] = ["ollama-local", "vllm-local"]; +const onboardProviders = require("./onboard-providers"); + +type RemoteProviderConfigEntry = { + label: string; + providerName: string; + providerType: string; + credentialEnv: string; + endpointUrl: string; + helpUrl: string | null; + modelMode: "catalog" | "curated" | "input"; + defaultModel: string; + skipVerify?: boolean; +}; + +const { + REMOTE_PROVIDER_CONFIG, + LOCAL_INFERENCE_PROVIDERS, + DISCORD_SNOWFLAKE_RE, + getProviderLabel, + getEffectiveProviderName, + getNonInteractiveProvider, + getNonInteractiveModel, + getSandboxInferenceConfig, +} = onboardProviders as { + REMOTE_PROVIDER_CONFIG: Record; + LOCAL_INFERENCE_PROVIDERS: string[]; + DISCORD_SNOWFLAKE_RE: RegExp; + getProviderLabel: (key: string) => string; + getEffectiveProviderName: (key: string | null | undefined) => string | null; + getNonInteractiveProvider: () => string | null; + getNonInteractiveModel: (providerKey: string) => string | null; + getSandboxInferenceConfig: (model: string, provider?: string | null, preferredInferenceApi?: string | null) => { + providerKey: string; primaryModelRef: string; inferenceBaseUrl: string; inferenceApi: string; inferenceCompat: LooseObject | null; + }; +}; const { sleepSeconds } = require("./wait"); const platformUtils: typeof import("./platform") = require("./platform"); const { inferContainerRuntime, isWsl, shouldPatchCoredns } = platformUtils; @@ -209,32 +240,8 @@ function verifyGatewayContainerRunning() { } const OPENCLAW_LAUNCH_AGENT_PLIST = "~/Library/LaunchAgents/ai.openclaw.gateway.plist"; -const BUILD_ENDPOINT_URL = "https://integrate.api.nvidia.com/v1"; -const OPENAI_ENDPOINT_URL = "https://api.openai.com/v1"; -const ANTHROPIC_ENDPOINT_URL = "https://api.anthropic.com"; -const GEMINI_ENDPOINT_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"; const BRAVE_SEARCH_HELP_URL = "https://brave.com/search/api/"; -type RemoteProviderKey = - | "build" - | "openai" - | "anthropic" - | "anthropicCompatible" - | "gemini" - | "custom"; - -type RemoteProviderConfigEntry = { - label: string; - providerName: string; - providerType: string; - credentialEnv: string; - endpointUrl: string; - helpUrl: string | null; - modelMode: "catalog" | "curated" | "input"; - defaultModel: string; - skipVerify?: boolean; -}; - // Re-export shared JSON types under the names used throughout this module. // See src/lib/json-types.ts for the canonical definitions. import type { JsonScalar as LooseScalar, JsonValue as LooseValue, JsonObject as LooseObject } from "./json-types"; @@ -249,76 +256,6 @@ type OnboardOptions = { acceptThirdPartySoftware?: boolean; agent?: string | null; }; - -const REMOTE_PROVIDER_CONFIG: Record = { - build: { - label: "NVIDIA Endpoints", - providerName: "nvidia-prod", - providerType: "nvidia", - credentialEnv: "NVIDIA_API_KEY", - endpointUrl: BUILD_ENDPOINT_URL, - helpUrl: "https://build.nvidia.com/settings/api-keys", - modelMode: "catalog", - defaultModel: DEFAULT_CLOUD_MODEL, - skipVerify: true, - }, - openai: { - label: "OpenAI", - providerName: "openai-api", - providerType: "openai", - credentialEnv: "OPENAI_API_KEY", - endpointUrl: OPENAI_ENDPOINT_URL, - helpUrl: "https://platform.openai.com/api-keys", - modelMode: "curated", - defaultModel: "gpt-5.4", - skipVerify: true, - }, - anthropic: { - label: "Anthropic", - providerName: "anthropic-prod", - providerType: "anthropic", - credentialEnv: "ANTHROPIC_API_KEY", - endpointUrl: ANTHROPIC_ENDPOINT_URL, - helpUrl: "https://console.anthropic.com/settings/keys", - modelMode: "curated", - defaultModel: "claude-sonnet-4-6", - }, - anthropicCompatible: { - label: "Other Anthropic-compatible endpoint", - providerName: "compatible-anthropic-endpoint", - providerType: "anthropic", - credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", - endpointUrl: "", - helpUrl: null, - modelMode: "input", - defaultModel: "", - }, - gemini: { - label: "Google Gemini", - providerName: "gemini-api", - providerType: "openai", - credentialEnv: "GEMINI_API_KEY", - endpointUrl: GEMINI_ENDPOINT_URL, - helpUrl: "https://aistudio.google.com/app/apikey", - modelMode: "curated", - defaultModel: "gemini-2.5-flash", - skipVerify: true, - }, - custom: { - label: "Other OpenAI-compatible endpoint", - providerName: "compatible-endpoint", - providerType: "openai", - credentialEnv: "COMPATIBLE_API_KEY", - endpointUrl: "", - helpUrl: null, - modelMode: "input", - defaultModel: "", - skipVerify: true, - }, -}; - -const DISCORD_SNOWFLAKE_RE = /^[0-9]{17,19}$/; - // Non-interactive mode: set by --non-interactive flag or env var. // When active, all prompts use env var overrides or sensible defaults. let NON_INTERACTIVE = false; @@ -886,80 +823,12 @@ async function promptValidationRecovery( return "selection"; } -/** - * Build the argument array for an `openshell provider create` or `update` command. - * @param {"create"|"update"} action - Whether to create or update. - * @param {string} name - Provider name. - * @param {string} type - Provider type (e.g. "openai", "anthropic", "generic"). - * @param {string} credentialEnv - Credential environment variable name. - * @param {string|null} baseUrl - Optional base URL for API-compatible endpoints. - * @returns {string[]} Argument array for runOpenshell(). - */ -function buildProviderArgs( - action: "create" | "update", - name: string, - type: string, - credentialEnv: string, - baseUrl: string | null, -): string[] { - const args = - action === "create" - ? ["provider", "create", "--name", name, "--type", type, "--credential", credentialEnv] - : ["provider", "update", name, "--credential", credentialEnv]; - if (baseUrl && type === "openai") { - args.push("--config", `OPENAI_BASE_URL=${baseUrl}`); - } else if (baseUrl && type === "anthropic") { - args.push("--config", `ANTHROPIC_BASE_URL=${baseUrl}`); - } - return args; -} - -/** - * Create or update an OpenShell provider in the gateway. - * - * Checks whether the provider already exists via `openshell provider get`; - * uses `create` for new providers and `update` for existing ones. - * @param {string} name - Provider name (e.g. "discord-bridge", "inference"). - * @param {string} type - Provider type ("openai", "anthropic", "generic"). - * @param {string} credentialEnv - Environment variable name for the credential. - * @param {string|null} baseUrl - Optional base URL for the provider endpoint. - * @param {Record} [env={}] - Environment variables for the openshell command. - * @returns {{ ok: boolean, status?: number, message?: string }} - */ -function upsertProvider( - name: string, - type: string, - credentialEnv: string, - baseUrl: string | null, - env: NodeJS.ProcessEnv = {}, -): { ok: boolean; status?: number; message?: string } { - const exists = providerExistsInGateway(name); - const action = exists ? "update" : "create"; - const args = buildProviderArgs(action, name, type, credentialEnv, baseUrl); - const stdio: RunnerOptions["stdio"] = ["ignore", "pipe", "pipe"]; - const runOpts: RunnerOptions = { - ignoreError: true, - env, - stdio, - }; - const result = runOpenshell(args, runOpts); - if (result.status !== 0) { - const output = - compactText(redact(`${result.stderr || ""}`)) || - compactText(redact(`${result.stdout || ""}`)) || - `Failed to ${action} provider '${name}'.`; - return { ok: false, status: result.status || 1, message: output }; - } - return { ok: true }; +// Provider CRUD — thin wrappers that inject runOpenshell to avoid circular deps. +const { buildProviderArgs } = onboardProviders; +function upsertProvider(name: string, type: string, credentialEnv: string, baseUrl: string | null, env: NodeJS.ProcessEnv = {}) { + return onboardProviders.upsertProvider(name, type, credentialEnv, baseUrl, env, runOpenshell); } -/** - * Upsert all messaging providers that have tokens configured. - * Returns the list of provider names that were successfully created/updated. - * Exits the process if any upsert fails. - * @param {Array<{name: string, envKey: string, token: string|null}>} tokenDefs - * @returns {string[]} Provider names that were upserted. - */ type MessagingTokenDef = { name: string; envKey: string; token: string | null }; type EndpointValidationResult = @@ -975,35 +844,11 @@ type SelectionDrift = { unknown: boolean; }; -function upsertMessagingProviders(tokenDefs: MessagingTokenDef[]): string[] { - const providers = []; - for (const { name, envKey, token } of tokenDefs) { - if (!token) continue; - const result = upsertProvider(name, "generic", envKey, null, { [envKey]: token }); - if (!result.ok) { - console.error(`\n ✗ Failed to create messaging provider '${name}': ${result.message}`); - process.exit(1); - } - providers.push(name); - } - return providers; +function upsertMessagingProviders(tokenDefs: MessagingTokenDef[]) { + return onboardProviders.upsertMessagingProviders(tokenDefs, runOpenshell); } - -/** - * Check whether an OpenShell provider exists in the gateway. - * - * Queries the gateway-level provider registry via `openshell provider get`. - * Does NOT verify that the provider is attached to a specific sandbox — - * OpenShell CLI does not currently expose a sandbox-scoped provider query. - * @param {string} name - Provider name to look up (e.g. "discord-bridge"). - * @returns {boolean} True if the provider exists in the gateway. - */ -function providerExistsInGateway(name: string): boolean { - const result = runOpenshell(["provider", "get", name], { - ignoreError: true, - stdio: ["ignore", "ignore", "ignore"], - }); - return result.status === 0; +function providerExistsInGateway(name: string) { + return onboardProviders.providerExistsInGateway(name, runOpenshell); } /** @@ -1411,59 +1256,7 @@ async function configureWebSearch( return { fetchEnabled: true }; } -function getSandboxInferenceConfig( - model: string, - provider: string | null = null, - preferredInferenceApi: string | null = null, -): { - providerKey: string; - primaryModelRef: string; - inferenceBaseUrl: string; - inferenceApi: string; - inferenceCompat: LooseObject | null; -} { - let providerKey; - let primaryModelRef; - let inferenceBaseUrl = "https://inference.local/v1"; - let inferenceApi = preferredInferenceApi || "openai-completions"; - let inferenceCompat = null; - - switch (provider) { - case "openai-api": - providerKey = "openai"; - primaryModelRef = `openai/${model}`; - break; - case "anthropic-prod": - case "compatible-anthropic-endpoint": - providerKey = "anthropic"; - primaryModelRef = `anthropic/${model}`; - inferenceBaseUrl = "https://inference.local"; - inferenceApi = "anthropic-messages"; - break; - case "gemini-api": - providerKey = "inference"; - primaryModelRef = `inference/${model}`; - inferenceCompat = { - supportsStore: false, - }; - break; - case "compatible-endpoint": - providerKey = "inference"; - primaryModelRef = `inference/${model}`; - inferenceCompat = { - supportsStore: false, - }; - break; - case "nvidia-prod": - case "nvidia-nim": - default: - providerKey = "inference"; - primaryModelRef = `inference/${model}`; - break; - } - - return { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat }; -} +// getSandboxInferenceConfig — moved to onboard-providers.ts function patchStagedDockerfile( dockerfilePath: string, @@ -1623,395 +1416,20 @@ function patchStagedDockerfile( fs.writeFileSync(dockerfilePath, dockerfile); } -type ResponseOutputValue = LooseScalar | ResponseOutputItem | ResponseOutputValue[]; -type ResponseOutputRoot = { output?: ResponseOutputValue[] }; -type ResponseOutputItem = { - type?: string; - content?: ResponseOutputValue[]; -}; - -function parseJsonObject(body: string | null | undefined): ResponseOutputRoot | null { - if (!body) return null; - try { - return parseJson(body); - } catch { - return null; - } -} - -function readResponseOutputItem( - value: ResponseOutputValue | object | undefined, -): ResponseOutputItem | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return null; - } - const type = Reflect.get(value, "type"); - const content = Reflect.get(value, "content"); - return { - type: typeof type === "string" ? type : undefined, - content: Array.isArray(content) ? content : undefined, - }; -} - -function hasResponsesToolCall(body: string | null | undefined): boolean { - const parsed = parseJsonObject(body); - if (!parsed || !Array.isArray(parsed.output)) return false; - - const stack = [...parsed.output]; - while (stack.length > 0) { - const item = readResponseOutputItem(stack.pop()); - if (!item) continue; - if (item.type === "function_call" || item.type === "tool_call") return true; - if (Array.isArray(item.content)) { - stack.push(...item.content); - } - } - - return false; -} - -function shouldRequireResponsesToolCalling(provider: string): boolean { - return ( - provider === "nvidia-prod" || provider === "gemini-api" || provider === "compatible-endpoint" - ); -} - -// The Gemini OpenAI-compat endpoint at /v1beta/openai/ requires -// `Authorization: Bearer ` and rejects `?key=` with HTTP 400 -// "Missing or invalid Authorization header." The dual-auth rejection -// described in #1960 applies to the native /v1beta/models/...:generateContent -// endpoint, which the onboarder probes do not use. Both callers of this -// helper (probeOpenAiLikeEndpoint, probeResponsesToolCalling) target the -// OpenAI-compat URL, so returning undefined for every provider is correct: -// probes default to Bearer auth and Gemini onboarding succeeds. -function getProbeAuthMode(_provider: string): "query-param" | undefined { - return undefined; -} +// Inference probes — moved to onboard-inference-probes.ts +const { + hasResponsesToolCall, + shouldRequireResponsesToolCalling, + getProbeAuthMode, + getValidationProbeCurlArgs, + probeOpenAiLikeEndpoint, + probeAnthropicEndpoint, +} = require("./onboard-inference-probes"); // shouldSkipResponsesProbe and isNvcfFunctionNotFoundForAccount / // nvcfFunctionNotFoundMessage — see validation import above. They live in // src/lib/validation.ts so they can be unit-tested independently. -// Per-validation-probe curl timing. Tighter than the default 60s in -// getCurlTimingArgs() because validation must not hang the wizard for a -// minute on a misbehaving model. See issue #1601 (Bug 3). -function getValidationProbeCurlArgs(opts?: { isWsl?: boolean }): string[] { - if (isWsl(opts)) { - return ["--connect-timeout", "20", "--max-time", "30"]; - } - return ["--connect-timeout", "10", "--max-time", "15"]; -} - -function probeResponsesToolCalling( - endpointUrl: string, - model: string, - apiKey: string | null, - options: { authMode?: "bearer" | "query-param" } = {}, -): CurlProbeResult { - const useQueryParam = options.authMode === "query-param"; - const normalizedKey = apiKey ? normalizeCredentialValue(apiKey) : ""; - const baseUrl = String(endpointUrl).replace(/\/+$/, ""); - const authHeader: string[] = - !useQueryParam && normalizedKey ? ["-H", `Authorization: Bearer ${normalizedKey}`] : []; - const url = - useQueryParam && normalizedKey - ? `${baseUrl}/responses?key=${encodeURIComponent(normalizedKey)}` - : `${baseUrl}/responses`; - const result = runCurlProbe([ - "-sS", - ...getValidationProbeCurlArgs(), - "-H", - "Content-Type: application/json", - ...authHeader, - "-d", - JSON.stringify({ - model, - input: "Call the emit_ok function with value OK. Do not answer with plain text.", - tool_choice: "required", - tools: [ - { - type: "function", - name: "emit_ok", - description: "Returns the probe value for validation.", - parameters: { - type: "object", - properties: { - value: { type: "string" }, - }, - required: ["value"], - additionalProperties: false, - }, - }, - ], - }), - url, - ]); - - if (!result.ok) { - return result; - } - if (hasResponsesToolCall(result.body)) { - return result; - } - return { - ok: false, - httpStatus: result.httpStatus, - curlStatus: result.curlStatus, - body: result.body, - stderr: result.stderr, - message: `HTTP ${result.httpStatus}: Responses API did not return a tool call`, - }; -} - -type EndpointProbeFailure = { - name: string; - httpStatus: number; - curlStatus: number; - message: string; - body?: string; -}; - -type EndpointProbeResult = - | { ok: true; api: string; label: string } - | { ok: false; message: string; failures: EndpointProbeFailure[] }; - -function probeOpenAiLikeEndpoint( - endpointUrl: string, - model: string, - apiKey: string | null, - options: { - authMode?: "bearer" | "query-param"; - requireResponsesToolCalling?: boolean; - skipResponsesProbe?: boolean; - probeStreaming?: boolean; - } = {}, -): EndpointProbeResult { - const useQueryParam = options.authMode === "query-param"; - const normalizedKey = apiKey ? normalizeCredentialValue(apiKey) : ""; - const baseUrl = String(endpointUrl).replace(/\/+$/, ""); - const authHeader: string[] = - !useQueryParam && normalizedKey ? ["-H", `Authorization: Bearer ${normalizedKey}`] : []; - const appendKey = (path: string): string => - useQueryParam && normalizedKey - ? `${baseUrl}${path}?key=${encodeURIComponent(normalizedKey)}` - : `${baseUrl}${path}`; - - const responsesProbe = - options.requireResponsesToolCalling === true - ? { - name: "Responses API with tool calling", - api: "openai-responses", - execute: () => - probeResponsesToolCalling(endpointUrl, model, apiKey, { authMode: options.authMode }), - } - : { - name: "Responses API", - api: "openai-responses", - execute: () => - runCurlProbe([ - "-sS", - ...getValidationProbeCurlArgs(), - "-H", - "Content-Type: application/json", - ...authHeader, - "-d", - JSON.stringify({ - model, - input: "Reply with exactly: OK", - }), - appendKey("/responses"), - ]), - }; - - const chatCompletionsProbe = { - name: "Chat Completions API", - api: "openai-completions", - execute: () => - runCurlProbe([ - "-sS", - ...getValidationProbeCurlArgs(), - "-H", - "Content-Type: application/json", - ...authHeader, - "-d", - JSON.stringify({ - model, - messages: [{ role: "user", content: "Reply with exactly: OK" }], - }), - appendKey("/chat/completions"), - ]), - }; - - // NVIDIA Build does not expose /v1/responses; probing it always returns - // "404 page not found" and only adds noise to error messages. Skip it - // entirely for that provider. See issue #1601. - const probes = options.skipResponsesProbe - ? [chatCompletionsProbe] - : [responsesProbe, chatCompletionsProbe]; - - const failures: EndpointProbeFailure[] = []; - for (const probe of probes) { - const result = probe.execute(); - if (result.ok) { - // Streaming event validation — catch backends like SGLang that return - // valid non-streaming responses but emit incomplete SSE events in - // streaming mode. Only run for /responses probes on custom endpoints - // where probeStreaming was requested. - if (probe.api === "openai-responses" && options.probeStreaming === true) { - const streamResult = runStreamingEventProbe([ - "-sS", - ...getValidationProbeCurlArgs(), - "-H", - "Content-Type: application/json", - ...authHeader, - "-d", - JSON.stringify({ - model, - input: "Reply with exactly: OK", - stream: true, - }), - appendKey("/responses"), - ]); - if (!streamResult.ok && streamResult.missingEvents.length > 0) { - // Backend responds but lacks required streaming events — fall back - // to /chat/completions silently. - console.log(` ℹ ${streamResult.message}`); - failures.push({ - name: probe.name + " (streaming)", - httpStatus: 0, - curlStatus: 0, - message: streamResult.message, - body: "", - }); - continue; - } - if (!streamResult.ok) { - // Transport or execution failure — surface as a hard error instead - // of silently switching APIs. - return { - ok: false, - message: `${probe.name} (streaming): ${streamResult.message}`, - failures: [ - { - name: probe.name + " (streaming)", - httpStatus: 0, - curlStatus: 0, - message: streamResult.message, - body: "", - }, - ], - }; - } - } - return { ok: true, api: probe.api, label: probe.name }; - } - // Preserve the raw response body alongside the summarized message so the - // NVCF "Function not found for account" detector below can fall back to - // the raw body if summarizeProbeError ever stops surfacing the marker - // through `message`. - failures.push({ - name: probe.name, - httpStatus: result.httpStatus, - curlStatus: result.curlStatus, - message: result.message, - body: result.body, - }); - } - - // Single retry with doubled timeouts on timeout/connection failure. - // WSL2's virtualized network stack can cause the initial probe to time out - // before the TLS handshake completes. See issue #987. - const isTimeoutOrConnFailure = (cs: number | undefined) => cs === 28 || cs === 6 || cs === 7; - let retriedAfterTimeout = false; - if (failures.length > 0 && isTimeoutOrConnFailure(failures[0].curlStatus)) { - retriedAfterTimeout = true; - const baseArgs = getValidationProbeCurlArgs(); - const doubledArgs = baseArgs.map((arg) => (/^\d+$/.test(arg) ? String(Number(arg) * 2) : arg)); - const retryResult = runCurlProbe([ - "-sS", - ...doubledArgs, - "-H", - "Content-Type: application/json", - ...(apiKey ? ["-H", `Authorization: Bearer ${normalizeCredentialValue(apiKey)}`] : []), - "-d", - JSON.stringify({ - model, - messages: [{ role: "user", content: "Reply with exactly: OK" }], - }), - `${String(endpointUrl).replace(/\/+$/, "")}/chat/completions`, - ]); - if (retryResult.ok) { - return { ok: true, api: "openai-completions", label: "Chat Completions API" }; - } - } - - // Detect the NVCF "Function not found for account" error and reframe it - // with an actionable next step instead of dumping the raw NVCF body. - // See issue #1601 (Bug 2). - const accountFailure = failures.find( - (failure) => - isNvcfFunctionNotFoundForAccount(failure.message) || - isNvcfFunctionNotFoundForAccount(failure.body || ""), - ); - if (accountFailure) { - return { - ok: false, - message: nvcfFunctionNotFoundMessage(model), - failures, - }; - } - - const baseMessage = failures.map((failure) => `${failure.name}: ${failure.message}`).join(" | "); - const wslHint = - isWsl() && retriedAfterTimeout - ? " · WSL2 detected \u2014 network verification may be slower than expected. " + - "Run `nemoclaw onboard` with the `--skip-verify` flag if this endpoint is known to be reachable." - : ""; - return { - ok: false, - message: baseMessage + wslHint, - failures, - }; -} - -function probeAnthropicEndpoint( - endpointUrl: string, - model: string, - apiKey: string | null, -): EndpointProbeResult { - const result = runCurlProbe([ - "-sS", - ...getCurlTimingArgs(), - "-H", - `x-api-key: ${normalizeCredentialValue(apiKey)}`, - "-H", - "anthropic-version: 2023-06-01", - "-H", - "content-type: application/json", - "-d", - JSON.stringify({ - model, - max_tokens: 16, - messages: [{ role: "user", content: "Reply with exactly: OK" }], - }), - `${String(endpointUrl).replace(/\/+$/, "")}/v1/messages`, - ]); - if (result.ok) { - return { ok: true, api: "anthropic-messages", label: "Anthropic Messages API" }; - } - return { - ok: false, - message: result.message, - failures: [ - { - name: "Anthropic Messages API", - httpStatus: result.httpStatus, - curlStatus: result.curlStatus, - message: result.message, - }, - ], - }; -} async function validateOpenAiLikeSelection( label: string, @@ -2158,246 +1576,17 @@ const { shouldIncludeBuildContextPath, copyBuildContextDir, printSandboxCreateRe // classifySandboxCreateFailure — see validation import above // --------------------------------------------------------------------------- -// Ollama auth proxy — keeps Ollama on localhost, exposes a token-gated proxy -// on 0.0.0.0 so containers can reach it without exposing Ollama to the network. -// Token is persisted to ~/.nemoclaw/ollama-proxy-token so the proxy can be -// restarted after a host reboot without re-running onboard. -// --------------------------------------------------------------------------- - -const PROXY_STATE_DIR = path.join(os.homedir(), ".nemoclaw"); -const PROXY_TOKEN_PATH = path.join(PROXY_STATE_DIR, "ollama-proxy-token"); -const PROXY_PID_PATH = path.join(PROXY_STATE_DIR, "ollama-auth-proxy.pid"); - -let ollamaProxyToken: string | null = null; - -function ensureProxyStateDir(): void { - if (!fs.existsSync(PROXY_STATE_DIR)) { - fs.mkdirSync(PROXY_STATE_DIR, { recursive: true }); - } -} - -function persistProxyToken(token: string): void { - ensureProxyStateDir(); - fs.writeFileSync(PROXY_TOKEN_PATH, token, { mode: 0o600 }); - // mode only applies on creation; ensure permissions on existing files too - fs.chmodSync(PROXY_TOKEN_PATH, 0o600); -} - -function loadPersistedProxyToken(): string | null { - try { - if (fs.existsSync(PROXY_TOKEN_PATH)) { - const token = fs.readFileSync(PROXY_TOKEN_PATH, "utf-8").trim(); - return token || null; - } - } catch { - /* ignore */ - } - return null; -} - -function persistProxyPid(pid: number | null | undefined): void { - const validPid = typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : null; - if (validPid === null) return; - ensureProxyStateDir(); - fs.writeFileSync(PROXY_PID_PATH, `${validPid}\n`, { mode: 0o600 }); - fs.chmodSync(PROXY_PID_PATH, 0o600); -} - -function loadPersistedProxyPid(): number | null { - try { - if (!fs.existsSync(PROXY_PID_PATH)) return null; - const raw = fs.readFileSync(PROXY_PID_PATH, "utf-8").trim(); - const pid = Number.parseInt(raw, 10); - return Number.isInteger(pid) && pid > 0 ? pid : null; - } catch { - return null; - } -} - -function clearPersistedProxyPid(): void { - try { - if (fs.existsSync(PROXY_PID_PATH)) { - fs.unlinkSync(PROXY_PID_PATH); - } - } catch { - /* ignore */ - } -} - -function isOllamaProxyProcess(pid: number | null | undefined): boolean { - const validPid = typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : null; - if (validPid === null) return false; - const cmdline = runCapture(["ps", "-p", String(validPid), "-o", "args="], { - ignoreError: true, - }); - return Boolean(cmdline && cmdline.includes("ollama-auth-proxy.js")); -} - -function spawnOllamaAuthProxy(token: string): number | null { - const child = spawn(process.execPath, [path.join(SCRIPTS, "ollama-auth-proxy.js")], { - detached: true, - stdio: "ignore", - env: { - ...process.env, - OLLAMA_PROXY_TOKEN: token, - OLLAMA_PROXY_PORT: String(OLLAMA_PROXY_PORT), - OLLAMA_BACKEND_PORT: String(OLLAMA_PORT), - }, - }); - child.unref(); - persistProxyPid(child.pid); - return child.pid ?? null; -} - -function killStaleProxy(): void { - try { - const persistedPid = loadPersistedProxyPid(); - if (isOllamaProxyProcess(persistedPid)) { - run(["kill", String(persistedPid)], { ignoreError: true, suppressOutput: true }); - } - clearPersistedProxyPid(); - - // Best-effort cleanup for older proxy processes created before the PID file - // existed. Only kill processes that are actually the auth proxy, not - // unrelated services that happen to use the same port. - const pidOutput = runCapture(["lsof", "-ti", `:${OLLAMA_PROXY_PORT}`], { ignoreError: true }); - if (pidOutput && pidOutput.trim()) { - for (const pid of pidOutput.trim().split(/\s+/)) { - if (isOllamaProxyProcess(Number.parseInt(pid, 10))) { - run(["kill", pid], { ignoreError: true, suppressOutput: true }); - } - } - sleep(1); - } - } catch { - /* ignore */ - } -} - -function startOllamaAuthProxy(): boolean { - const crypto = require("crypto"); - killStaleProxy(); - - const proxyToken = crypto.randomBytes(24).toString("hex"); - ollamaProxyToken = proxyToken; - // Don't persist yet — wait until provider is confirmed in setupInference. - // If the user backs out to a different provider, the token stays in memory - // only and is discarded. - const pid = spawnOllamaAuthProxy(proxyToken); - sleep(1); - if (!isOllamaProxyProcess(pid)) { - console.error(` Error: Ollama auth proxy failed to start on :${OLLAMA_PROXY_PORT}`); - console.error(` Containers will not be able to reach Ollama without the proxy.`); - console.error( - ` Check if port ${OLLAMA_PROXY_PORT} is already in use: lsof -ti :${OLLAMA_PROXY_PORT}`, - ); - return false; - } - return true; -} - -/** - * Ensure the auth proxy is running — called on sandbox connect to recover - * from host reboots where the background proxy process was lost. - */ -function ensureOllamaAuthProxy(): void { - // Try to load persisted token first — if none, this isn't an Ollama setup. - const token = loadPersistedProxyToken(); - if (!token) return; - - const pid = loadPersistedProxyPid(); - if (isOllamaProxyProcess(pid)) { - ollamaProxyToken = token; - return; - } - - // Proxy not running — restart it with the persisted token. - killStaleProxy(); - ollamaProxyToken = token; - spawnOllamaAuthProxy(token); - sleep(1); -} - -function getOllamaProxyToken(): string | null { - if (ollamaProxyToken) return ollamaProxyToken; - // Fall back to persisted token (resume / reconnect scenario) - ollamaProxyToken = loadPersistedProxyToken(); - return ollamaProxyToken; -} - -async function promptOllamaModel(gpu: GpuInfo | null = null): Promise { - const installed = getOllamaModelOptions(); - const options = installed.length > 0 ? installed : getBootstrapOllamaModelOptions(gpu); - const defaultModel = getDefaultOllamaModel(gpu); - const defaultIndex = Math.max(0, options.indexOf(defaultModel)); - - console.log(""); - console.log(installed.length > 0 ? " Ollama models:" : " Ollama starter models:"); - options.forEach((option, index) => { - console.log(` ${index + 1}) ${option}`); - }); - console.log(` ${options.length + 1}) Other...`); - if (installed.length === 0) { - console.log(""); - console.log(" No local Ollama models are installed yet. Choose one to pull and load now."); - } - console.log(""); - - const choice = await prompt(` Choose model [${defaultIndex + 1}]: `); - const index = parseInt(choice || String(defaultIndex + 1), 10) - 1; - if (index >= 0 && index < options.length) { - return options[index]; - } - return promptManualModelId(" Ollama model id: ", "Ollama"); -} - -function printOllamaExposureWarning() { - console.log(""); - console.log(" ⚠ Ollama is binding to 0.0.0.0 so the sandbox can reach it via Docker."); - console.log(" This exposes the Ollama API to your local network (no auth required)."); - console.log(" On public WiFi, any device on the same network can send prompts to your GPU."); - console.log(" See: CNVD-2025-04094, CVE-2024-37032"); - console.log(""); -} - -function pullOllamaModel(model: string): boolean { - const result = spawnSync("ollama", ["pull", model], { - cwd: ROOT, - encoding: "utf8", - stdio: "inherit", - timeout: 600_000, - env: { ...process.env }, - }); - if (result.signal === "SIGTERM") { - console.error( - ` Model pull timed out after 10 minutes. Try a smaller model or check your network connection.`, - ); - return false; - } - return result.status === 0; -} - -function prepareOllamaModel( - model: string, - installedModels: string[] = [], -): ValidationResult | { ok: false; message: string } { - const alreadyInstalled = installedModels.includes(model); - if (!alreadyInstalled) { - console.log(` Pulling Ollama model: ${model}`); - if (!pullOllamaModel(model)) { - return { - ok: false, - message: - `Failed to pull Ollama model '${model}'. ` + - "Check the model name and that Ollama can access the registry, then try another model.", - }; - } - } - - console.log(` Loading Ollama model: ${model}`); - run(getOllamaWarmupCommand(model), { ignoreError: true }); - return validateOllamaModel(model); -} +// Ollama auth proxy — moved to onboard-ollama-proxy.ts +const { + ensureOllamaAuthProxy, + getOllamaProxyToken, + persistProxyToken, + startOllamaAuthProxy, + promptOllamaModel, + printOllamaExposureWarning, + pullOllamaModel, + prepareOllamaModel, +} = require("./onboard-ollama-proxy"); function getRequestedSandboxNameHint(): string | null { const raw = process.env.NEMOCLAW_SANDBOX_NAME; @@ -2416,33 +1605,13 @@ function getResumeSandboxConflict(session: Session | null) { : null; } -function getRequestedProviderHint(nonInteractive = isNonInteractive()): string | null { - return nonInteractive ? getNonInteractiveProvider() : 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 getRequestedModelHint(nonInteractive = isNonInteractive()): string | null { - if (!nonInteractive) return null; - const providerKey = getRequestedProviderHint(nonInteractive) || "cloud"; - return getNonInteractiveModel(providerKey); -} - -function getEffectiveProviderName(providerKey: string | null | undefined): string | null { - if (!providerKey) return null; - if (REMOTE_PROVIDER_CONFIG[providerKey]) { - return REMOTE_PROVIDER_CONFIG[providerKey].providerName; - } - - switch (providerKey) { - case "nim-local": - return "nvidia-nim"; - case "ollama": - case "install-ollama": - return "ollama-local"; - case "vllm": - return "vllm-local"; - default: - return providerKey; - } } function getResumeConfigConflicts( @@ -2833,49 +2002,7 @@ function waitForSandboxReady(sandboxName: string, attempts = 10, delaySeconds = // parsePolicyPresetEnv — see urlUtils import above // isSafeModelId — see validation import above -function getNonInteractiveProvider(): string | null { - const providerKey = (process.env.NEMOCLAW_PROVIDER || "").trim().toLowerCase(); - if (!providerKey) return null; - const aliases: Record = { - cloud: "build", - nim: "nim-local", - vllm: "vllm", - anthropiccompatible: "anthropicCompatible", - }; - const normalized = aliases[providerKey] || providerKey; - const validProviders = new Set([ - "build", - "openai", - "anthropic", - "anthropicCompatible", - "gemini", - "ollama", - "custom", - "nim-local", - "vllm", - "install-ollama", - ]); - if (!validProviders.has(normalized)) { - console.error(` Unsupported NEMOCLAW_PROVIDER: ${providerKey}`); - console.error( - " Valid values: build, openai, anthropic, anthropicCompatible, gemini, ollama, custom, nim-local, vllm, install-ollama", - ); - process.exit(1); - } - - return normalized; -} - -function getNonInteractiveModel(providerKey: string): string | null { - const model = (process.env.NEMOCLAW_MODEL || "").trim(); - if (!model) return null; - if (!isSafeModelId(model)) { - console.error(` Invalid NEMOCLAW_MODEL for provider '${providerKey}': ${model}`); - console.error(" Model values may only contain letters, numbers, '.', '_', ':', '/', and '-'."); - process.exit(1); - } - return model; -} +// getNonInteractiveProvider, getNonInteractiveModel — moved to onboard-providers.ts // ── Step 1: Preflight ──────────────────────────────────────────── @@ -7023,16 +6150,7 @@ function printDashboard( const nimStat = nimContainer ? nim.nimStatusByName(nimContainer) : nim.nimStatus(sandboxName); const nimLabel = nimStat.running ? "running" : "not running"; - let providerLabel = provider; - if (provider === "nvidia-prod" || provider === "nvidia-nim") providerLabel = "NVIDIA Endpoints"; - else if (provider === "openai-api") providerLabel = "OpenAI"; - else if (provider === "anthropic-prod") providerLabel = "Anthropic"; - else if (provider === "compatible-anthropic-endpoint") - providerLabel = "Other Anthropic-compatible endpoint"; - else if (provider === "gemini-api") providerLabel = "Google Gemini"; - else if (provider === "compatible-endpoint") providerLabel = "Other OpenAI-compatible endpoint"; - else if (provider === "vllm-local") providerLabel = "Local vLLM"; - else if (provider === "ollama-local") providerLabel = "Local Ollama"; + const providerLabel = getProviderLabel(provider); const token = fetchGatewayAuthTokenFromSandbox(sandboxName); const chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`; diff --git a/test/credential-exposure.test.ts b/test/credential-exposure.test.ts index 81de834af7b..54b8e562187 100644 --- a/test/credential-exposure.test.ts +++ b/test/credential-exposure.test.ts @@ -12,6 +12,7 @@ import path from "node:path"; import { describe, it, expect } from "vitest"; const ONBOARD_JS = path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"); +const ONBOARD_PROVIDERS_JS = path.join(import.meta.dirname, "..", "src", "lib", "onboard-providers.ts"); const RUNNER_TS = path.join(import.meta.dirname, "..", "nemoclaw", "src", "blueprint", "runner.ts"); const SERVICES_TS = path.join(import.meta.dirname, "..", "src", "lib", "services.ts"); @@ -64,7 +65,9 @@ describe("credential exposure in process arguments", () => { }); it("onboard.js --credential flags pass env var names only", () => { - const src = fs.readFileSync(ONBOARD_JS, "utf-8"); + // buildProviderArgs lives in onboard-providers.ts; scan both files. + const src = fs.readFileSync(ONBOARD_JS, "utf-8") + + fs.readFileSync(ONBOARD_PROVIDERS_JS, "utf-8"); expect(src).toMatch(/"--credential", credentialEnv/); expect(src).not.toMatch(/"--credential",\s*["'][A-Z_]+=/); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 3e29b7dcc69..7224721e2aa 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -2361,16 +2361,17 @@ const { setupInference } = require(${onboardPath}); }); it("checks provider existence before create/update to avoid AlreadyExists noise (#1155)", () => { + // upsertProvider lives in onboard-providers.ts after the refactor. const source = fs.readFileSync( - path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), + path.join(import.meta.dirname, "..", "src", "lib", "onboard-providers.ts"), "utf-8", ); // upsertProvider must check existence first so it never triggers AlreadyExists. - assert.match(source, /providerExistsInGateway\(name\)/); + assert.match(source, /providerExistsInGateway\(name/); assert.match(source, /exists \? "update" : "create"/); // Only one openshell call should be made (no create-then-update fallback). - assert.match(source, /const result = runOpenshell\(args, runOpts\)/); + assert.match(source, /const result = _runOpenshell\(args, runOpts\)/); }); it("marks the unused agent_setup/openclaw sibling step as skipped (#1834)", () => { diff --git a/test/wsl2-probe-timeout.test.ts b/test/wsl2-probe-timeout.test.ts index d9fea4d3e00..722632a3795 100644 --- a/test/wsl2-probe-timeout.test.ts +++ b/test/wsl2-probe-timeout.test.ts @@ -65,8 +65,9 @@ describe("WSL2 inference verification timeouts (issue #987)", () => { // The retry logic is embedded in probeOpenAiLikeEndpoint which is not // exported. Verify the retry triggers on the correct curl exit codes by // scanning the compiled source for the guard condition. + // probeOpenAiLikeEndpoint moved to onboard-inference-probes.ts const onboardSrc = fs.readFileSync( - path.join(import.meta.dirname, "..", "dist", "lib", "onboard.js"), + path.join(import.meta.dirname, "..", "dist", "lib", "onboard-inference-probes.js"), "utf-8", );