diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 34b786eaf..692c5c2d0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -91,6 +91,14 @@ repos: pass_filenames: true priority: 5 + - id: prettier-js + name: Prettier (JavaScript) + entry: npx prettier --write + language: system + files: ^(bin|test)/.*\.js$ + pass_filenames: true + priority: 5 + # ── Priority 6: auto-fix after formatting ───────────────────────────────── - repo: local hooks: diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..550f6d209 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +coverage/ +nemoclaw/node_modules/ +nemoclaw/dist/ +nemoclaw-blueprint/ +docs/_build/ +*.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..4a1222f56 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/Makefile b/Makefile index 77f0de15f..cad420be0 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,10 @@ lint: check lint-ts: cd nemoclaw && npm run check -format: format-ts +format: format-ts format-cli + +format-cli: + npx prettier --write 'bin/**/*.js' 'test/**/*.js' format-ts: cd nemoclaw && npm run lint:fix && npm run format diff --git a/bin/lib/credentials.js b/bin/lib/credentials.js index 412f04459..683ba80b8 100644 --- a/bin/lib/credentials.js +++ b/bin/lib/credentials.js @@ -14,7 +14,7 @@ function resolveHomeDir() { if (!raw) { throw new Error( "Cannot determine safe home directory for credential storage. " + - "Set the HOME environment variable to a user-owned directory." + "Set the HOME environment variable to a user-owned directory.", ); } const home = path.resolve(raw); @@ -22,8 +22,10 @@ function resolveHomeDir() { const real = fs.realpathSync(home); if (UNSAFE_HOME_PATHS.has(real)) { throw new Error( - "Cannot store credentials: HOME resolves to '" + real + "' which is world-readable. " + - "Set the HOME environment variable to a user-owned directory." + "Cannot store credentials: HOME resolves to '" + + real + + "' which is world-readable. " + + "Set the HOME environment variable to a user-owned directory.", ); } } catch (e) { @@ -31,8 +33,10 @@ function resolveHomeDir() { } if (UNSAFE_HOME_PATHS.has(home)) { throw new Error( - "Cannot store credentials: HOME resolves to '" + home + "' which is world-readable. " + - "Set the HOME environment variable to a user-owned directory." + "Cannot store credentials: HOME resolves to '" + + home + + "' which is world-readable. " + + "Set the HOME environment variable to a user-owned directory.", ); } return home; @@ -57,7 +61,9 @@ function loadCredentials() { if (fs.existsSync(file)) { return JSON.parse(fs.readFileSync(file, "utf-8")); } - } catch { /* ignored */ } + } catch { + /* ignored */ + } return {}; } @@ -277,7 +283,9 @@ async function ensureGithubToken() { process.env.GITHUB_TOKEN = token; return; } - } catch { /* ignored */ } + } catch { + /* ignored */ + } console.log(""); console.log(" ┌──────────────────────────────────────────────────┐"); diff --git a/bin/lib/local-inference.js b/bin/lib/local-inference.js index 3452e59e3..923363389 100644 --- a/bin/lib/local-inference.js +++ b/bin/lib/local-inference.js @@ -70,7 +70,8 @@ function validateLocalProvider(provider, runCapture) { case "ollama-local": return { ok: false, - message: "Local Ollama was selected, but nothing is responding on http://localhost:11434.", + message: + "Local Ollama was selected, but nothing is responding on http://localhost:11434.", }; default: return { ok: false, message: "The selected local inference provider is unavailable." }; @@ -101,7 +102,10 @@ function validateLocalProvider(provider, runCapture) { "Local Ollama is responding on localhost, but containers cannot reach http://host.openshell.internal:11434. Ensure Ollama listens on 0.0.0.0:11434 instead of 127.0.0.1 so sandboxes can reach it.", }; default: - return { ok: false, message: "The selected local inference provider is unavailable from containers." }; + return { + ok: false, + message: "The selected local inference provider is unavailable from containers.", + }; } } @@ -127,7 +131,9 @@ function parseOllamaTags(output) { } function getOllamaModelOptions(runCapture) { - const tagsOutput = runCapture("curl -sf http://localhost:11434/api/tags 2>/dev/null", { ignoreError: true }); + const tagsOutput = runCapture("curl -sf http://localhost:11434/api/tags 2>/dev/null", { + ignoreError: true, + }); const tagsParsed = parseOllamaTags(tagsOutput); if (tagsParsed.length > 0) { return tagsParsed; @@ -193,7 +199,9 @@ function validateOllamaModel(model, runCapture) { message: `Selected Ollama model '${model}' failed the local probe: ${parsed.error.trim()}`, }; } - } catch { /* ignored */ } + } catch { + /* ignored */ + } return { ok: true }; } diff --git a/bin/lib/nim.js b/bin/lib/nim.js index 64c074dad..6c4261a99 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -26,10 +26,9 @@ function listModels() { function detectGpu() { // Try NVIDIA first — query VRAM try { - const output = runCapture( - "nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits", - { ignoreError: true } - ); + const output = runCapture("nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits", { + ignoreError: true, + }); if (output) { const lines = output.split("\n").filter((l) => l.trim()); const perGpuMB = lines.map((l) => parseInt(l.trim(), 10)).filter((n) => !isNaN(n)); @@ -46,21 +45,24 @@ function detectGpu() { }; } } - } catch { /* ignored */ } + } catch { + /* ignored */ + } // Fallback: DGX Spark (GB10) — VRAM not queryable due to unified memory architecture try { - const nameOutput = runCapture( - "nvidia-smi --query-gpu=name --format=csv,noheader,nounits", - { ignoreError: true } - ); + const nameOutput = runCapture("nvidia-smi --query-gpu=name --format=csv,noheader,nounits", { + ignoreError: true, + }); if (nameOutput && nameOutput.includes("GB10")) { // GB10 has 128GB unified memory shared with Grace CPU — use system RAM let totalMemoryMB = 0; try { const memLine = runCapture("free -m | awk '/Mem:/ {print $2}'", { ignoreError: true }); if (memLine) totalMemoryMB = parseInt(memLine.trim(), 10) || 0; - } catch { /* ignored */ } + } catch { + /* ignored */ + } return { type: "nvidia", count: 1, @@ -70,15 +72,16 @@ function detectGpu() { spark: true, }; } - } catch { /* ignored */ } + } catch { + /* ignored */ + } // macOS: detect Apple Silicon or discrete GPU if (process.platform === "darwin") { try { - const spOutput = runCapture( - "system_profiler SPDisplaysDataType 2>/dev/null", - { ignoreError: true } - ); + const spOutput = runCapture("system_profiler SPDisplaysDataType 2>/dev/null", { + ignoreError: true, + }); if (spOutput) { const chipMatch = spOutput.match(/Chipset Model:\s*(.+)/); const vramMatch = spOutput.match(/VRAM.*?:\s*(\d+)\s*(MB|GB)/i); @@ -96,7 +99,9 @@ function detectGpu() { try { const memBytes = runCapture("sysctl -n hw.memsize", { ignoreError: true }); if (memBytes) memoryMB = Math.floor(parseInt(memBytes, 10) / 1024 / 1024); - } catch { /* ignored */ } + } catch { + /* ignored */ + } } return { @@ -110,7 +115,9 @@ function detectGpu() { }; } } - } catch { /* ignored */ } + } catch { + /* ignored */ + } } return null; @@ -145,7 +152,7 @@ function startNimContainerByName(name, model, port = 8000) { console.log(` Starting NIM container: ${name}`); run( - `docker run -d --gpus all -p ${Number(port)}:8000 --name ${qn} --shm-size 16g ${shellQuote(image)}` + `docker run -d --gpus all -p ${Number(port)}:8000 --name ${qn} --shm-size 16g ${shellQuote(image)}`, ); return name; } @@ -165,7 +172,9 @@ function waitForNimHealth(port = 8000, timeout = 300) { console.log(" NIM is healthy."); return true; } - } catch { /* ignored */ } + } catch { + /* ignored */ + } require("child_process").spawnSync("sleep", [String(intervalSec)]); } console.error(` NIM did not become healthy within ${timeout}s.`); @@ -192,10 +201,9 @@ function nimStatus(sandboxName, port) { function nimStatusByName(name, port) { try { const qn = shellQuote(name); - const state = runCapture( - `docker inspect --format '{{.State.Status}}' ${qn} 2>/dev/null`, - { ignoreError: true } - ); + const state = runCapture(`docker inspect --format '{{.State.Status}}' ${qn} 2>/dev/null`, { + ignoreError: true, + }); if (!state) return { running: false, container: name }; let healthy = false; @@ -210,7 +218,7 @@ function nimStatusByName(name, port) { } const health = runCapture( `curl -sf http://localhost:${resolvedHostPort}/v1/models 2>/dev/null`, - { ignoreError: true } + { ignoreError: true }, ); healthy = !!health; } diff --git a/bin/lib/onboard-session.js b/bin/lib/onboard-session.js index bbe095b43..901eccac9 100644 --- a/bin/lib/onboard-session.js +++ b/bin/lib/onboard-session.js @@ -54,7 +54,9 @@ function createSession(overrides = {}) { credentialEnv: overrides.credentialEnv || null, preferredInferenceApi: overrides.preferredInferenceApi || null, nimContainer: overrides.nimContainer || null, - policyPresets: Array.isArray(overrides.policyPresets) ? overrides.policyPresets.filter((value) => typeof value === "string") : null, + policyPresets: Array.isArray(overrides.policyPresets) + ? overrides.policyPresets.filter((value) => typeof value === "string") + : null, metadata: { gatewayName: overrides.metadata?.gatewayName || "nemoclaw", }, @@ -72,7 +74,10 @@ function isObject(value) { function redactSensitiveText(value) { if (typeof value !== "string") return null; return value - .replace(/(NVIDIA_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|GEMINI_API_KEY|COMPATIBLE_API_KEY|COMPATIBLE_ANTHROPIC_API_KEY)=\S+/gi, "$1=") + .replace( + /(NVIDIA_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|GEMINI_API_KEY|COMPATIBLE_API_KEY|COMPATIBLE_ANTHROPIC_API_KEY)=\S+/gi, + "$1=", + ) .replace(/Bearer\s+\S+/gi, "Bearer ") .replace(/nvapi-[A-Za-z0-9_-]{10,}/g, "") .replace(/ghp_[A-Za-z0-9]{20,}/g, "") @@ -84,7 +89,8 @@ function sanitizeFailure(input) { if (!input) return null; const step = typeof input.step === "string" ? input.step : null; const message = redactSensitiveText(input.message); - const recordedAt = typeof input.recordedAt === "string" ? input.recordedAt : new Date().toISOString(); + const recordedAt = + typeof input.recordedAt === "string" ? input.recordedAt : new Date().toISOString(); return step || message ? { step, message, recordedAt } : null; } @@ -127,9 +133,12 @@ function normalizeSession(data) { model: typeof data.model === "string" ? data.model : null, endpointUrl: typeof data.endpointUrl === "string" ? redactUrl(data.endpointUrl) : null, credentialEnv: typeof data.credentialEnv === "string" ? data.credentialEnv : null, - preferredInferenceApi: typeof data.preferredInferenceApi === "string" ? data.preferredInferenceApi : null, + preferredInferenceApi: + typeof data.preferredInferenceApi === "string" ? data.preferredInferenceApi : null, nimContainer: typeof data.nimContainer === "string" ? data.nimContainer : null, - policyPresets: Array.isArray(data.policyPresets) ? data.policyPresets.filter((value) => typeof value === "string") : null, + policyPresets: Array.isArray(data.policyPresets) + ? data.policyPresets.filter((value) => typeof value === "string") + : null, lastStepStarted: typeof data.lastStepStarted === "string" ? data.lastStepStarted : null, lastCompletedStep: typeof data.lastCompletedStep === "string" ? data.lastCompletedStep : null, failure: sanitizeFailure(data.failure), @@ -172,7 +181,7 @@ function saveSession(session) { ensureSessionDir(); const tmpFile = path.join( SESSION_DIR, - `.onboard-session.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp` + `.onboard-session.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`, ); fs.writeFileSync(tmpFile, JSON.stringify(normalized, null, 2), { mode: 0o600 }); fs.renameSync(tmpFile, SESSION_FILE); @@ -222,7 +231,7 @@ function acquireOnboardLock(command = null) { command: typeof command === "string" ? command : null, }, null, - 2 + 2, ); for (let attempt = 0; attempt < 2; attempt++) { @@ -358,7 +367,8 @@ function filterSafeUpdates(updates) { if (typeof updates.model === "string") safe.model = updates.model; if (typeof updates.endpointUrl === "string") safe.endpointUrl = redactUrl(updates.endpointUrl); if (typeof updates.credentialEnv === "string") safe.credentialEnv = updates.credentialEnv; - if (typeof updates.preferredInferenceApi === "string") safe.preferredInferenceApi = updates.preferredInferenceApi; + if (typeof updates.preferredInferenceApi === "string") + safe.preferredInferenceApi = updates.preferredInferenceApi; if (typeof updates.nimContainer === "string") safe.nimContainer = updates.nimContainer; if (Array.isArray(updates.policyPresets)) { safe.policyPresets = updates.policyPresets.filter((value) => typeof value === "string"); @@ -401,7 +411,7 @@ function summarizeForDebug(session = loadSession()) { completedAt: step.completedAt, error: step.error, }, - ]) + ]), ), }; } diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index e520d68ac..6ed5e4e91 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -139,17 +139,8 @@ const REMOTE_PROVIDER_CONFIG = { }; const REMOTE_MODEL_OPTIONS = { - openai: [ - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5.4-nano", - "gpt-5.4-pro-2026-03-05", - ], - anthropic: [ - "claude-sonnet-4-6", - "claude-haiku-4-5", - "claude-opus-4-6", - ], + openai: ["gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.4-pro-2026-03-05"], + anthropic: ["claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-6"], gemini: [ "gemini-3.1-pro-preview", "gemini-3.1-flash-lite-preview", @@ -255,7 +246,8 @@ function isGatewayHealthy(statusOutput = "", gwInfoOutput = "", activeGatewayInf const namedGatewayKnown = hasStaleGateway(gwInfoOutput); if (!namedGatewayKnown || !isGatewayConnected(statusOutput)) return false; - const activeGatewayName = getReportedGatewayName(statusOutput) || getReportedGatewayName(activeGatewayInfoOutput); + const activeGatewayName = + getReportedGatewayName(statusOutput) || getReportedGatewayName(activeGatewayInfoOutput); return activeGatewayName === GATEWAY_NAME; } @@ -264,7 +256,8 @@ function getGatewayReuseState(statusOutput = "", gwInfoOutput = "", activeGatewa return "healthy"; } const connected = isGatewayConnected(statusOutput); - const activeGatewayName = getReportedGatewayName(statusOutput) || getReportedGatewayName(activeGatewayInfoOutput); + const activeGatewayName = + getReportedGatewayName(statusOutput) || getReportedGatewayName(activeGatewayInfoOutput); if (connected && activeGatewayName === GATEWAY_NAME) { return "active-unnamed"; } @@ -391,7 +384,11 @@ function streamSandboxCreate(command, env = process.env, options = {}) { lastOutputAt = Date.now(); if (/^ {2}Building image /.test(line) || /^ {2}Step \d+\/\d+ : /.test(line)) { setPhase("build"); - } else if (/^ {2}Pushing image /.test(line) || /^\s*\[progress\]/.test(line) || /^ {2}Image .*available in the gateway/.test(line)) { + } else if ( + /^ {2}Pushing image /.test(line) || + /^\s*\[progress\]/.test(line) || + /^ {2}Image .*available in the gateway/.test(line) + ) { setPhase("upload"); } else if (/^Created sandbox: /.test(line)) { setPhase("create"); @@ -574,9 +571,10 @@ function normalizeProviderBaseUrl(value, flavor) { const url = new URL(raw); url.search = ""; url.hash = ""; - const suffixes = flavor === "anthropic" - ? ["/v1/messages", "/v1/models", "/v1", "/messages", "/models"] - : ["/responses", "/chat/completions", "/completions", "/models"]; + const suffixes = + flavor === "anthropic" + ? ["/v1/messages", "/v1/models", "/v1", "/messages", "/models"] + : ["/responses", "/chat/completions", "/completions", "/models"]; let pathname = stripEndpointSuffix(url.pathname.replace(/\/+$/, ""), suffixes); pathname = pathname.replace(/\/+$/, ""); url.pathname = pathname || "/"; @@ -601,7 +599,9 @@ function summarizeProbeFailure(body = "", status = 0, curlStatus = 0, stderr = " } function getNavigationChoice(value = "") { - const normalized = String(value || "").trim().toLowerCase(); + const normalized = String(value || "") + .trim() + .toLowerCase(); if (normalized === "back") return "back"; if (normalized === "exit" || normalized === "quit") return "exit"; return null; @@ -680,11 +680,16 @@ function getProbeRecovery(probe, options = {}) { if (failures.some((failure) => classifyValidationFailure(failure).kind === "credential")) { return { kind: "credential", retry: "credential" }; } - const transportFailure = failures.find((failure) => classifyValidationFailure(failure).kind === "transport"); + const transportFailure = failures.find( + (failure) => classifyValidationFailure(failure).kind === "transport", + ); if (transportFailure) { return { kind: "transport", retry: "retry", failure: transportFailure }; } - if (allowModelRetry && failures.some((failure) => classifyValidationFailure(failure).kind === "model")) { + if ( + allowModelRetry && + failures.some((failure) => classifyValidationFailure(failure).kind === "model") + ) { return { kind: "model", retry: "model" }; } if (failures.some((failure) => classifyValidationFailure(failure).kind === "endpoint")) { @@ -699,7 +704,10 @@ function getProbeRecovery(probe, options = {}) { // eslint-disable-next-line complexity function runCurlProbe(argv) { - const bodyFile = path.join(os.tmpdir(), `nemoclaw-curl-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + const bodyFile = path.join( + os.tmpdir(), + `nemoclaw-curl-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); try { const args = [...argv]; const url = args.pop(); @@ -717,7 +725,7 @@ function runCurlProbe(argv) { const rawErrorCode = spawnError.errno ?? spawnError.code; const errorCode = typeof rawErrorCode === "number" ? rawErrorCode : 1; const errorMessage = compactText( - `${spawnError.message || String(spawnError)} ${String(result.stderr || "")}` + `${spawnError.message || String(spawnError)} ${String(result.stderr || "")}`, ); return { ok: false, @@ -735,7 +743,12 @@ function runCurlProbe(argv) { curlStatus: result.status || 0, body, stderr: String(result.stderr || ""), - message: summarizeProbeFailure(body, status || 0, result.status || 0, String(result.stderr || "")), + message: summarizeProbeFailure( + body, + status || 0, + result.status || 0, + String(result.stderr || ""), + ), }; } catch (error) { return { @@ -794,8 +807,12 @@ async function promptValidationRecovery(label, recovery, credentialEnv = null, h } if (recovery.kind === "credential" && credentialEnv) { - console.log(` ${label} authorization failed. Re-enter the API key or choose a different provider/model.`); - const choice = (await prompt(" Type 'retry', 'back', or 'exit' [retry]: ")).trim().toLowerCase(); + console.log( + ` ${label} authorization failed. Re-enter the API key or choose a different provider/model.`, + ); + const choice = (await prompt(" Type 'retry', 'back', or 'exit' [retry]: ")) + .trim() + .toLowerCase(); if (choice === "back") { console.log(" Returning to provider selection."); console.log(""); @@ -816,7 +833,9 @@ async function promptValidationRecovery(label, recovery, credentialEnv = null, h if (recovery.kind === "transport") { console.log(getTransportRecoveryMessage(recovery.failure || {})); - const choice = (await prompt(" Type 'retry', 'back', or 'exit' [retry]: ")).trim().toLowerCase(); + const choice = (await prompt(" Type 'retry', 'back', or 'exit' [retry]: ")) + .trim() + .toLowerCase(); if (choice === "back") { console.log(" Returning to provider selection."); console.log(""); @@ -870,7 +889,8 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { const updateArgs = buildProviderArgs("update", name, type, credentialEnv, baseUrl); const updateResult = runOpenshell(updateArgs, runOpts); if (updateResult.status !== 0) { - const output = compactText(`${createResult.stderr || ""} ${updateResult.stderr || ""}`) || + const output = + compactText(`${createResult.stderr || ""} ${updateResult.stderr || ""}`) || compactText(`${createResult.stdout || ""} ${updateResult.stdout || ""}`) || `Failed to create or update provider '${name}'.`; return { @@ -892,7 +912,9 @@ function verifyInferenceRoute(_provider, _model) { } function isInferenceRouteReady(provider, model) { - const live = parseGatewayInference(runCaptureOpenshell(["inference", "get"], { ignoreError: true })); + const live = parseGatewayInference( + runCaptureOpenshell(["inference", "get"], { ignoreError: true }), + ); return Boolean(live && live.provider === provider && live.model === model); } @@ -985,46 +1007,42 @@ function getSandboxInferenceConfig(model, provider = null, preferredInferenceApi return { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat }; } -function patchStagedDockerfile(dockerfilePath, model, chatUiUrl, buildId = String(Date.now()), provider = null, preferredInferenceApi = null) { - const { - providerKey, - primaryModelRef, - inferenceBaseUrl, - inferenceApi, - inferenceCompat, - } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); +function patchStagedDockerfile( + dockerfilePath, + model, + chatUiUrl, + buildId = String(Date.now()), + provider = null, + preferredInferenceApi = null, +) { + const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = + getSandboxInferenceConfig(model, provider, preferredInferenceApi); let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_MODEL=.*$/m, - `ARG NEMOCLAW_MODEL=${model}` - ); + dockerfile = dockerfile.replace(/^ARG NEMOCLAW_MODEL=.*$/m, `ARG NEMOCLAW_MODEL=${model}`); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_PROVIDER_KEY=.*$/m, - `ARG NEMOCLAW_PROVIDER_KEY=${providerKey}` + `ARG NEMOCLAW_PROVIDER_KEY=${providerKey}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_PRIMARY_MODEL_REF=.*$/m, - `ARG NEMOCLAW_PRIMARY_MODEL_REF=${primaryModelRef}` - ); - dockerfile = dockerfile.replace( - /^ARG CHAT_UI_URL=.*$/m, - `ARG CHAT_UI_URL=${chatUiUrl}` + `ARG NEMOCLAW_PRIMARY_MODEL_REF=${primaryModelRef}`, ); + dockerfile = dockerfile.replace(/^ARG CHAT_UI_URL=.*$/m, `ARG CHAT_UI_URL=${chatUiUrl}`); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_BASE_URL=.*$/m, - `ARG NEMOCLAW_INFERENCE_BASE_URL=${inferenceBaseUrl}` + `ARG NEMOCLAW_INFERENCE_BASE_URL=${inferenceBaseUrl}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_API=.*$/m, - `ARG NEMOCLAW_INFERENCE_API=${inferenceApi}` + `ARG NEMOCLAW_INFERENCE_API=${inferenceApi}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_COMPAT_B64=.*$/m, - `ARG NEMOCLAW_INFERENCE_COMPAT_B64=${encodeDockerJsonArg(inferenceCompat)}` + `ARG NEMOCLAW_INFERENCE_COMPAT_B64=${encodeDockerJsonArg(inferenceCompat)}`, ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_BUILD_ID=.*$/m, - `ARG NEMOCLAW_BUILD_ID=${buildId}` + `ARG NEMOCLAW_BUILD_ID=${buildId}`, ); fs.writeFileSync(dockerfilePath, dockerfile); } @@ -1040,7 +1058,9 @@ function summarizeProbeError(body, status) { parsed?.detail || parsed?.details; if (message) return `HTTP ${status}: ${String(message)}`; - } catch { /* non-JSON body — fall through to raw text */ } + } catch { + /* non-JSON body — fall through to raw text */ + } const compact = String(body).replace(/\s+/g, " ").trim(); return `HTTP ${status}: ${compact.slice(0, 200)}`; } @@ -1062,9 +1082,7 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { url: `${String(endpointUrl).replace(/\/+$/, "")}/chat/completions`, body: JSON.stringify({ model, - messages: [ - { role: "user", content: "Reply with exactly: OK" }, - ], + messages: [{ role: "user", content: "Reply with exactly: OK" }], }), }, ]; @@ -1074,9 +1092,11 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { const result = runCurlProbe([ "-sS", ...getCurlTimingArgs(), - "-H", "Content-Type: application/json", + "-H", + "Content-Type: application/json", ...(apiKey ? ["-H", `Authorization: Bearer ${normalizeCredentialValue(apiKey)}`] : []), - "-d", probe.body, + "-d", + probe.body, probe.url, ]); if (result.ok) { @@ -1101,10 +1121,14 @@ 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({ + "-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" }], @@ -1134,7 +1158,7 @@ async function validateOpenAiLikeSelection( model, credentialEnv = null, retryMessage = "Please choose a provider/model again.", - helpUrl = null + helpUrl = null, ) { const apiKey = credentialEnv ? getCredential(credentialEnv) : ""; const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey); @@ -1144,7 +1168,12 @@ async function validateOpenAiLikeSelection( if (isNonInteractive()) { process.exit(1); } - const retry = await promptValidationRecovery(label, getProbeRecovery(probe), credentialEnv, helpUrl); + const retry = await promptValidationRecovery( + label, + getProbeRecovery(probe), + credentialEnv, + helpUrl, + ); if (retry === "selection") { console.log(` ${retryMessage}`); console.log(""); @@ -1161,7 +1190,7 @@ async function validateAnthropicSelectionWithRetryMessage( model, credentialEnv, retryMessage = "Please choose a provider/model again.", - helpUrl = null + helpUrl = null, ) { const apiKey = getCredential(credentialEnv); const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); @@ -1171,7 +1200,12 @@ async function validateAnthropicSelectionWithRetryMessage( if (isNonInteractive()) { process.exit(1); } - const retry = await promptValidationRecovery(label, getProbeRecovery(probe), credentialEnv, helpUrl); + const retry = await promptValidationRecovery( + label, + getProbeRecovery(probe), + credentialEnv, + helpUrl, + ); if (retry === "selection") { console.log(` ${retryMessage}`); console.log(""); @@ -1182,7 +1216,13 @@ async function validateAnthropicSelectionWithRetryMessage( return { ok: true, api: probe.api }; } -async function validateCustomOpenAiLikeSelection(label, endpointUrl, model, credentialEnv, helpUrl = null) { +async function validateCustomOpenAiLikeSelection( + label, + endpointUrl, + model, + credentialEnv, + helpUrl = null, +) { const apiKey = getCredential(credentialEnv); const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey); if (probe.ok) { @@ -1194,7 +1234,12 @@ async function validateCustomOpenAiLikeSelection(label, endpointUrl, model, cred if (isNonInteractive()) { process.exit(1); } - const retry = await promptValidationRecovery(label, getProbeRecovery(probe, { allowModelRetry: true }), credentialEnv, helpUrl); + const retry = await promptValidationRecovery( + label, + getProbeRecovery(probe, { allowModelRetry: true }), + credentialEnv, + helpUrl, + ); if (retry === "selection") { console.log(" Please choose a provider/model again."); console.log(""); @@ -1202,7 +1247,13 @@ async function validateCustomOpenAiLikeSelection(label, endpointUrl, model, cred return { ok: false, retry }; } -async function validateCustomAnthropicSelection(label, endpointUrl, model, credentialEnv, helpUrl = null) { +async function validateCustomAnthropicSelection( + label, + endpointUrl, + model, + credentialEnv, + helpUrl = null, +) { const apiKey = getCredential(credentialEnv); const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); if (probe.ok) { @@ -1214,7 +1265,12 @@ async function validateCustomAnthropicSelection(label, endpointUrl, model, crede if (isNonInteractive()) { process.exit(1); } - const retry = await promptValidationRecovery(label, getProbeRecovery(probe, { allowModelRetry: true }), credentialEnv, helpUrl); + const retry = await promptValidationRecovery( + label, + getProbeRecovery(probe, { allowModelRetry: true }), + credentialEnv, + helpUrl, + ); if (retry === "selection") { console.log(" Please choose a provider/model again."); console.log(""); @@ -1227,12 +1283,19 @@ function fetchNvidiaEndpointModels(apiKey) { const result = runCurlProbe([ "-sS", ...getCurlTimingArgs(), - "-H", "Content-Type: application/json", - "-H", `Authorization: Bearer ${normalizeCredentialValue(apiKey)}`, + "-H", + "Content-Type: application/json", + "-H", + `Authorization: Bearer ${normalizeCredentialValue(apiKey)}`, `${BUILD_ENDPOINT_URL}/models`, ]); if (!result.ok) { - return { ok: false, message: result.message, status: result.httpStatus, curlStatus: result.curlStatus }; + return { + ok: false, + message: result.message, + status: result.httpStatus, + curlStatus: result.curlStatus, + }; } const parsed = JSON.parse(result.body); const ids = Array.isArray(parsed?.data) @@ -1270,7 +1333,12 @@ function fetchOpenAiLikeModels(endpointUrl, apiKey) { `${String(endpointUrl).replace(/\/+$/, "")}/models`, ]); if (!result.ok) { - return { ok: false, status: result.httpStatus, curlStatus: result.curlStatus, message: result.message }; + return { + ok: false, + status: result.httpStatus, + curlStatus: result.curlStatus, + message: result.message, + }; } const parsed = JSON.parse(result.body); const ids = Array.isArray(parsed?.data) @@ -1287,12 +1355,19 @@ function fetchAnthropicModels(endpointUrl, apiKey) { const result = runCurlProbe([ "-sS", ...getCurlTimingArgs(), - "-H", `x-api-key: ${normalizeCredentialValue(apiKey)}`, - "-H", "anthropic-version: 2023-06-01", + "-H", + `x-api-key: ${normalizeCredentialValue(apiKey)}`, + "-H", + "anthropic-version: 2023-06-01", `${String(endpointUrl).replace(/\/+$/, "")}/v1/models`, ]); if (!result.ok) { - return { ok: false, status: result.httpStatus, curlStatus: result.curlStatus, message: result.message }; + return { + ok: false, + status: result.httpStatus, + curlStatus: result.curlStatus, + message: result.message, + }; } const parsed = JSON.parse(result.body); const ids = Array.isArray(parsed?.data) @@ -1438,7 +1513,9 @@ function printSandboxCreateRecoveryHints(output = "") { console.error(" Hint: image upload into the OpenShell gateway timed out."); console.error(" Recovery: nemoclaw onboard --resume"); if (failure.uploadedToGateway) { - console.error(" Progress reached the gateway upload stage, so resume may be able to reuse existing gateway state."); + console.error( + " Progress reached the gateway upload stage, so resume may be able to reuse existing gateway state.", + ); } console.error(" If this repeats, check Docker memory and retry on a host with more RAM."); return; @@ -1455,7 +1532,9 @@ function printSandboxCreateRecoveryHints(output = "") { if (failure.kind === "sandbox_create_incomplete") { console.error(" Hint: sandbox creation started but the create stream did not finish cleanly."); console.error(" Recovery: nemoclaw onboard --resume"); - console.error(" Check: openshell sandbox list # verify whether the sandbox became ready"); + console.error( + " Check: openshell sandbox list # verify whether the sandbox became ready", + ); return; } console.error(" Recovery: nemoclaw onboard --resume"); @@ -1484,10 +1563,8 @@ async function promptCloudModel() { return CLOUD_MODEL_OPTIONS[index].id; } - return promptManualModelId( - " NVIDIA Endpoints model id: ", - "NVIDIA Endpoints", - (model) => validateNvidiaEndpointModel(model, getCredential("NVIDIA_API_KEY")) + return promptManualModelId(" NVIDIA Endpoints model id: ", "NVIDIA Endpoints", (model) => + validateNvidiaEndpointModel(model, getCredential("NVIDIA_API_KEY")), ); } @@ -1580,7 +1657,9 @@ function pullOllamaModel(model) { 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.`); + console.error( + ` Model pull timed out after 10 minutes. Try a smaller model or check your network connection.`, + ); return false; } return result.status === 0; @@ -1665,7 +1744,11 @@ function getResumeConfigConflicts(session, opts = {}) { const requestedProvider = getRequestedProviderHint(nonInteractive); const effectiveRequestedProvider = getEffectiveProviderName(requestedProvider); - if (effectiveRequestedProvider && session?.provider && effectiveRequestedProvider !== session.provider) { + if ( + effectiveRequestedProvider && + session?.provider && + effectiveRequestedProvider !== session.provider + ) { conflicts.push({ field: "provider", requested: effectiveRequestedProvider, @@ -1764,7 +1847,10 @@ function destroyGateway() { runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME], { ignoreError: true }); // openshell gateway destroy doesn't remove Docker volumes, which leaves // corrupted cluster state that breaks the next gateway start. Clean them up. - run(`docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | grep . && docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | xargs docker volume rm || true`, { ignoreError: true }); + run( + `docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | grep . && docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | xargs docker volume rm || true`, + { ignoreError: true }, + ); } async function ensureNamedCredential(envName, label, helpUrl = null) { @@ -1779,8 +1865,20 @@ async function ensureNamedCredential(envName, label, helpUrl = null) { function waitForSandboxReady(sandboxName, attempts = 10, delaySeconds = 2) { for (let i = 0; i < attempts; i += 1) { const podPhase = runCaptureOpenshell( - ["doctor", "exec", "--", "kubectl", "-n", "openshell", "get", "pod", sandboxName, "-o", "jsonpath={.status.phase}"], - { ignoreError: true } + [ + "doctor", + "exec", + "--", + "kubectl", + "-n", + "openshell", + "get", + "pod", + sandboxName, + "-o", + "jsonpath={.status.phase}", + ], + { ignoreError: true }, ); if (podPhase === "Running") return true; sleep(delaySeconds); @@ -1809,10 +1907,23 @@ function getNonInteractiveProvider() { anthropiccompatible: "anthropicCompatible", }; const normalized = aliases[providerKey] || providerKey; - const validProviders = new Set(["build", "openai", "anthropic", "anthropicCompatible", "gemini", "ollama", "custom", "nim-local", "vllm", "bedrock"]); + const validProviders = new Set([ + "build", + "openai", + "anthropic", + "anthropicCompatible", + "gemini", + "ollama", + "custom", + "nim-local", + "vllm", + "bedrock", + ]); if (!validProviders.has(normalized)) { console.error(` Unsupported NEMOCLAW_PROVIDER: ${providerKey}`); - console.error(" Valid values: build, openai, anthropic, anthropicCompatible, gemini, ollama, custom, nim-local, vllm, bedrock"); + console.error( + " Valid values: build, openai, anthropic, anthropicCompatible, gemini, ollama, custom, nim-local, vllm, bedrock", + ); process.exit(1); } @@ -1846,7 +1957,9 @@ async function preflight() { const runtime = getContainerRuntime(); if (isUnsupportedMacosRuntime(runtime)) { console.error(" Podman on macOS is not supported by NemoClaw at this time."); - console.error(" OpenShell currently depends on Docker host-gateway behavior that Podman on macOS does not provide."); + console.error( + " OpenShell currently depends on Docker host-gateway behavior that Podman on macOS does not provide.", + ); console.error(" Use Colima or Docker Desktop on macOS instead."); process.exit(1); } @@ -1865,11 +1978,17 @@ async function preflight() { process.exit(1); } } - console.log(` ✓ openshell CLI: ${runCaptureOpenshell(["--version"], { ignoreError: true }) || "unknown"}`); + console.log( + ` ✓ openshell CLI: ${runCaptureOpenshell(["--version"], { ignoreError: true }) || "unknown"}`, + ); if (openshellInstall.futureShellPathHint) { - console.log(` Note: openshell was installed to ${openshellInstall.localBin} for this onboarding run.`); + console.log( + ` Note: openshell was installed to ${openshellInstall.localBin} for this onboarding run.`, + ); console.log(` Future shells may still need: ${openshellInstall.futureShellPathHint}`); - console.log(" Add that export to your shell profile, or open a new terminal before running openshell directly."); + console.log( + " Add that export to your shell profile, or open a new terminal before running openshell directly.", + ); } // Clean up stale or unnamed NemoClaw gateway state before checking ports. @@ -1877,7 +1996,9 @@ async function preflight() { // tearing it down here. If some other gateway is active, do not treat it // as NemoClaw state; let the port checks surface the conflict instead. const gatewayStatus = runCaptureOpenshell(["status"], { ignoreError: true }); - const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { ignoreError: true }); + const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { + ignoreError: true, + }); const activeGatewayInfo = runCaptureOpenshell(["gateway", "info"], { ignoreError: true }); const gatewayReuseState = getGatewayReuseState(gatewayStatus, gwInfo, activeGatewayInfo); if (gatewayReuseState === "stale" || gatewayReuseState === "active-unnamed") { @@ -1904,7 +2025,9 @@ async function preflight() { console.error(` ${label} needs this port.`); console.error(""); if (portCheck.process && portCheck.process !== "unknown") { - console.error(` Blocked by: ${portCheck.process}${portCheck.pid ? ` (PID ${portCheck.pid})` : ""}`); + console.error( + ` Blocked by: ${portCheck.process}${portCheck.pid ? ` (PID ${portCheck.pid})` : ""}`, + ); console.error(""); console.error(" To fix, stop the conflicting process:"); console.error(""); @@ -1935,7 +2058,9 @@ async function preflight() { console.log(" ⓘ GPU VRAM too small for local NIM — will use cloud inference"); } } else if (gpu && gpu.type === "apple") { - console.log(` ✓ Apple GPU detected: ${gpu.name}${gpu.cores ? ` (${gpu.cores} cores)` : ""}, ${gpu.totalMemoryMB} MB unified memory`); + console.log( + ` ✓ Apple GPU detected: ${gpu.name}${gpu.cores ? ` (${gpu.cores} cores)` : ""}, ${gpu.totalMemoryMB} MB unified memory`, + ); console.log(" ⓘ NIM requires NVIDIA GPU — will use cloud inference"); } else { console.log(" ⓘ No GPU detected — will use cloud inference"); @@ -1946,18 +2071,22 @@ async function preflight() { const mem = getMemoryInfo(); if (mem) { if (mem.totalMB < 12000) { - console.log(` ⚠ Low memory detected (${mem.totalRamMB} MB RAM + ${mem.totalSwapMB} MB swap = ${mem.totalMB} MB total)`); + console.log( + ` ⚠ Low memory detected (${mem.totalRamMB} MB RAM + ${mem.totalSwapMB} MB swap = ${mem.totalMB} MB total)`, + ); let proceedWithSwap = false; if (!isNonInteractive()) { const answer = await prompt( - " Create a 4 GB swap file to prevent OOM during sandbox build? (requires sudo) [y/N]: " + " Create a 4 GB swap file to prevent OOM during sandbox build? (requires sudo) [y/N]: ", ); proceedWithSwap = answer && answer.toLowerCase().startsWith("y"); } if (!proceedWithSwap) { - console.log(" ⓘ Skipping swap creation. Sandbox build may fail with OOM on this system."); + console.log( + " ⓘ Skipping swap creation. Sandbox build may fail with OOM on this system.", + ); } else { console.log(" Creating 4 GB swap file to prevent OOM during sandbox build..."); const swapResult = ensureSwap(12000); @@ -1989,7 +2118,9 @@ async function startGatewayWithOptions(_gpu, { exitOnFailure = true } = {}) { step(2, 7, "Starting OpenShell gateway"); const gatewayStatus = runCaptureOpenshell(["status"], { ignoreError: true }); - const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { ignoreError: true }); + const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { + ignoreError: true, + }); const activeGatewayInfo = runCaptureOpenshell(["gateway", "info"], { ignoreError: true }); if (isGatewayHealthy(gatewayStatus, gwInfo, activeGatewayInfo)) { console.log(" ✓ Reusing existing gateway"); @@ -2020,31 +2151,38 @@ async function startGatewayWithOptions(_gpu, { exitOnFailure = true } = {}) { // See: https://github.com/NVIDIA/OpenShell/issues/433 const retries = exitOnFailure ? 2 : 0; try { - await pRetry(() => { - runOpenshell(["gateway", "start", ...gwArgs], { ignoreError: true, env: gatewayEnv }); - - for (let i = 0; i < 5; i++) { - const status = runCaptureOpenshell(["status"], { ignoreError: true }); - const namedInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { ignoreError: true }); - const currentInfo = runCaptureOpenshell(["gateway", "info"], { ignoreError: true }); - if (isGatewayHealthy(status, namedInfo, currentInfo)) { - return; // success + await pRetry( + () => { + runOpenshell(["gateway", "start", ...gwArgs], { ignoreError: true, env: gatewayEnv }); + + for (let i = 0; i < 5; i++) { + const status = runCaptureOpenshell(["status"], { ignoreError: true }); + const namedInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { + ignoreError: true, + }); + const currentInfo = runCaptureOpenshell(["gateway", "info"], { ignoreError: true }); + if (isGatewayHealthy(status, namedInfo, currentInfo)) { + return; // success + } + if (i < 4) sleep(2); } - if (i < 4) sleep(2); - } - throw new Error("Gateway failed to start"); - }, { - retries, - minTimeout: 10_000, - factor: 3, - onFailedAttempt: (err) => { - console.log(` Gateway start attempt ${err.attemptNumber} failed. ${err.retriesLeft} retries left...`); - if (err.retriesLeft > 0 && exitOnFailure) { - destroyGateway(); - } + throw new Error("Gateway failed to start"); }, - }); + { + retries, + minTimeout: 10_000, + factor: 3, + onFailedAttempt: (err) => { + console.log( + ` Gateway start attempt ${err.attemptNumber} failed. ${err.retriesLeft} retries left...`, + ); + if (err.retriesLeft > 0 && exitOnFailure) { + destroyGateway(); + } + }, + }, + ); } catch { if (exitOnFailure) { console.error(` Gateway failed to start after ${retries + 1} attempts.`); @@ -2064,7 +2202,9 @@ async function startGatewayWithOptions(_gpu, { exitOnFailure = true } = {}) { const runtime = getContainerRuntime(); if (shouldPatchCoredns(runtime)) { console.log(" Patching CoreDNS DNS forwarding..."); - run(`bash "${path.join(SCRIPTS, "fix-coredns.sh")}" ${GATEWAY_NAME} 2>&1 || true`, { ignoreError: true }); + run(`bash "${path.join(SCRIPTS, "fix-coredns.sh")}" ${GATEWAY_NAME} 2>&1 || true`, { + ignoreError: true, + }); } sleep(5); runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); @@ -2112,7 +2252,9 @@ 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} 2>&1 || true`, { ignoreError: true }); + run(`bash "${path.join(SCRIPTS, "fix-coredns.sh")}" ${GATEWAY_NAME} 2>&1 || true`, { + ignoreError: true, + }); } return true; } @@ -2127,7 +2269,8 @@ async function recoverGatewayRuntime() { async function promptValidatedSandboxName() { const nameAnswer = await promptOrDefault( " Sandbox name (lowercase, numbers, hyphens) [my-assistant]: ", - "NEMOCLAW_SANDBOX_NAME", "my-assistant" + "NEMOCLAW_SANDBOX_NAME", + "my-assistant", ); const sandboxName = (nameAnswer || "my-assistant").trim().toLowerCase(); @@ -2146,7 +2289,13 @@ async function promptValidatedSandboxName() { // ── Step 5: Sandbox ────────────────────────────────────────────── // eslint-disable-next-line complexity -async function createSandbox(gpu, model, provider, preferredInferenceApi = null, sandboxNameOverride = null) { +async function createSandbox( + gpu, + model, + provider, + preferredInferenceApi = null, + sandboxNameOverride = null, +) { step(5, 7, "Creating sandbox"); const sandboxName = sandboxNameOverride || (await promptValidatedSandboxName()); @@ -2184,21 +2333,34 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, const stagedDockerfile = path.join(buildCtx, "Dockerfile"); fs.copyFileSync(path.join(ROOT, "Dockerfile"), stagedDockerfile); copyBuildContextDir(path.join(ROOT, "nemoclaw"), path.join(buildCtx, "nemoclaw")); - copyBuildContextDir(path.join(ROOT, "nemoclaw-blueprint"), path.join(buildCtx, "nemoclaw-blueprint")); + copyBuildContextDir( + path.join(ROOT, "nemoclaw-blueprint"), + path.join(buildCtx, "nemoclaw-blueprint"), + ); copyBuildContextDir(path.join(ROOT, "scripts"), path.join(buildCtx, "scripts")); // Create sandbox (use -- echo to avoid dropping into interactive shell) // Pass the base policy so sandbox starts in proxy mode (required for policy updates later) const basePolicyPath = path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); const createArgs = [ - "--from", `${buildCtx}/Dockerfile`, - "--name", sandboxName, - "--policy", basePolicyPath, + "--from", + `${buildCtx}/Dockerfile`, + "--name", + sandboxName, + "--policy", + basePolicyPath, ]; // --gpu is intentionally omitted. See comment in startGateway(). console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); - patchStagedDockerfile(stagedDockerfile, model, chatUiUrl, String(Date.now()), provider, preferredInferenceApi); + patchStagedDockerfile( + stagedDockerfile, + model, + chatUiUrl, + String(Date.now()), + provider, + preferredInferenceApi, + ); // Only pass non-sensitive env vars to the sandbox. NVIDIA_API_KEY is NOT // needed inside the sandbox — inference is proxied through the OpenShell // gateway which injects the stored credential server-side. The gateway @@ -2287,7 +2449,10 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, // or seeing 502/503 errors during initial load. console.log(" Waiting for NemoClaw dashboard to become ready..."); for (let i = 0; i < 15; i++) { - const readyMatch = runCapture(`openshell sandbox exec ${sandboxName} curl -sf http://localhost:18789/ 2>/dev/null || echo "no"`, { ignoreError: true }); + const readyMatch = runCapture( + `openshell sandbox exec ${sandboxName} curl -sf http://localhost:18789/ 2>/dev/null || echo "no"`, + { ignoreError: true }, + ); if (readyMatch && !readyMatch.includes("no")) { console.log(" ✓ Dashboard is live"); break; @@ -2313,7 +2478,10 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, // DNS proxy — run a forwarder in the sandbox pod so the isolated // sandbox namespace can resolve hostnames (fixes #626). console.log(" Setting up sandbox DNS proxy..."); - run(`bash "${path.join(SCRIPTS, "setup-dns-proxy.sh")}" ${GATEWAY_NAME} "${sandboxName}" 2>&1 || true`, { ignoreError: true }); + run( + `bash "${path.join(SCRIPTS, "setup-dns-proxy.sh")}" ${GATEWAY_NAME} "${sandboxName}" 2>&1 || true`, + { ignoreError: true }, + ); console.log(` ✓ Sandbox '${sandboxName}' created`); return sandboxName; @@ -2334,10 +2502,16 @@ async function setupNim(gpu) { // Detect local inference options const hasOllama = !!runCapture("command -v ollama", { ignoreError: true }); - const ollamaRunning = !!runCapture("curl -sf http://localhost:11434/api/tags 2>/dev/null", { ignoreError: true }); - const vllmRunning = !!runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { ignoreError: true }); + const ollamaRunning = !!runCapture("curl -sf http://localhost:11434/api/tags 2>/dev/null", { + ignoreError: true, + }); + const vllmRunning = !!runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { + ignoreError: true, + }); const requestedProvider = isNonInteractive() ? getNonInteractiveProvider() : null; - const requestedModel = isNonInteractive() ? getNonInteractiveModel(requestedProvider || "build") : null; + const requestedModel = isNonInteractive() + ? getNonInteractiveModel(requestedProvider || "build") + : null; const options = []; options.push({ key: "build", label: "NVIDIA Endpoints" }); options.push({ key: "openai", label: "OpenAI" }); @@ -2369,475 +2543,539 @@ async function setupNim(gpu) { } if (options.length > 1) { - selectionLoop: - while (true) { - let selected; - - if (isNonInteractive()) { - const providerKey = requestedProvider || "build"; - selected = options.find((o) => o.key === providerKey); - if (!selected) { - console.error(` Requested provider '${providerKey}' is not available in this environment.`); - process.exit(1); - } - note(` [non-interactive] Provider: ${selected.key}`); - } else { - const suggestions = []; - if (vllmRunning) suggestions.push("vLLM"); - if (ollamaRunning) suggestions.push("Ollama"); - if (suggestions.length > 0) { - console.log(` Detected local inference option${suggestions.length > 1 ? "s" : ""}: ${suggestions.join(", ")}`); - console.log(""); - } + selectionLoop: while (true) { + let selected; - console.log(""); - console.log(" Inference options:"); - options.forEach((o, i) => { - console.log(` ${i + 1}) ${o.label}`); - }); - console.log(""); + if (isNonInteractive()) { + const providerKey = requestedProvider || "build"; + selected = options.find((o) => o.key === providerKey); + if (!selected) { + console.error( + ` Requested provider '${providerKey}' is not available in this environment.`, + ); + process.exit(1); + } + note(` [non-interactive] Provider: ${selected.key}`); + } else { + const suggestions = []; + if (vllmRunning) suggestions.push("vLLM"); + if (ollamaRunning) suggestions.push("Ollama"); + if (suggestions.length > 0) { + console.log( + ` Detected local inference option${suggestions.length > 1 ? "s" : ""}: ${suggestions.join(", ")}`, + ); + console.log(""); + } - const defaultIdx = options.findIndex((o) => o.key === "build") + 1; - const choice = await prompt(` Choose [${defaultIdx}]: `); - const idx = parseInt(choice || String(defaultIdx), 10) - 1; - selected = options[idx] || options[defaultIdx - 1]; - } + console.log(""); + console.log(" Inference options:"); + options.forEach((o, i) => { + console.log(` ${i + 1}) ${o.label}`); + }); + console.log(""); - if (REMOTE_PROVIDER_CONFIG[selected.key]) { - const remoteConfig = REMOTE_PROVIDER_CONFIG[selected.key]; - provider = remoteConfig.providerName; - credentialEnv = remoteConfig.credentialEnv; - endpointUrl = remoteConfig.endpointUrl; - preferredInferenceApi = null; + const defaultIdx = options.findIndex((o) => o.key === "build") + 1; + const choice = await prompt(` Choose [${defaultIdx}]: `); + const idx = parseInt(choice || String(defaultIdx), 10) - 1; + selected = options[idx] || options[defaultIdx - 1]; + } - if (selected.key === "bedrock") { - const bedrockRegion = process.env.BEDROCK_REGION || process.env.AWS_REGION; - if (!bedrockRegion) { - console.error(" BEDROCK_REGION or AWS_REGION must be set for Amazon Bedrock."); - if (isNonInteractive()) { - process.exit(1); + if (REMOTE_PROVIDER_CONFIG[selected.key]) { + const remoteConfig = REMOTE_PROVIDER_CONFIG[selected.key]; + provider = remoteConfig.providerName; + credentialEnv = remoteConfig.credentialEnv; + endpointUrl = remoteConfig.endpointUrl; + preferredInferenceApi = null; + + if (selected.key === "bedrock") { + const bedrockRegion = process.env.BEDROCK_REGION || process.env.AWS_REGION; + if (!bedrockRegion) { + console.error(" BEDROCK_REGION or AWS_REGION must be set for Amazon Bedrock."); + if (isNonInteractive()) { + process.exit(1); + } + continue selectionLoop; } - continue selectionLoop; + endpointUrl = `https://bedrock-mantle.${bedrockRegion}.api.aws/v1`; } - endpointUrl = `https://bedrock-mantle.${bedrockRegion}.api.aws/v1`; - } - if (selected.key === "custom") { - const endpointInput = isNonInteractive() - ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() - : await prompt(" OpenAI-compatible base URL (e.g., https://openrouter.ai/api/v1): "); - const navigation = getNavigationChoice(endpointInput); - if (navigation === "back") { - console.log(" Returning to provider selection."); - console.log(""); - continue selectionLoop; - } - if (navigation === "exit") { - exitOnboardFromPrompt(); - } - endpointUrl = normalizeProviderBaseUrl(endpointInput, "openai"); - if (!endpointUrl) { - console.error(" Endpoint URL is required for Other OpenAI-compatible endpoint."); - if (isNonInteractive()) { - process.exit(1); + if (selected.key === "custom") { + const endpointInput = isNonInteractive() + ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() + : await prompt(" OpenAI-compatible base URL (e.g., https://openrouter.ai/api/v1): "); + const navigation = getNavigationChoice(endpointInput); + if (navigation === "back") { + console.log(" Returning to provider selection."); + console.log(""); + continue selectionLoop; } - console.log(""); - continue selectionLoop; - } - } else if (selected.key === "anthropicCompatible") { - const endpointInput = isNonInteractive() - ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() - : await prompt(" Anthropic-compatible base URL (e.g., https://proxy.example.com): "); - const navigation = getNavigationChoice(endpointInput); - if (navigation === "back") { - console.log(" Returning to provider selection."); - console.log(""); - continue selectionLoop; - } - if (navigation === "exit") { - exitOnboardFromPrompt(); - } - endpointUrl = normalizeProviderBaseUrl(endpointInput, "anthropic"); - if (!endpointUrl) { - console.error(" Endpoint URL is required for Other Anthropic-compatible endpoint."); - if (isNonInteractive()) { - process.exit(1); + if (navigation === "exit") { + exitOnboardFromPrompt(); } - console.log(""); - continue selectionLoop; - } - } - - if (selected.key === "build") { - if (isNonInteractive()) { - if (!process.env.NVIDIA_API_KEY) { - console.error(" NVIDIA_API_KEY is required for NVIDIA Endpoints in non-interactive mode."); - process.exit(1); + endpointUrl = normalizeProviderBaseUrl(endpointInput, "openai"); + if (!endpointUrl) { + console.error(" Endpoint URL is required for Other OpenAI-compatible endpoint."); + if (isNonInteractive()) { + process.exit(1); + } + console.log(""); + continue selectionLoop; } - } else { - await ensureApiKey(); - } - model = requestedModel || (isNonInteractive() ? DEFAULT_CLOUD_MODEL : await promptCloudModel()) || DEFAULT_CLOUD_MODEL; - if (model === BACK_TO_SELECTION) { - console.log(" Returning to provider selection."); - console.log(""); - continue selectionLoop; - } - } else { - if (isNonInteractive()) { - if (!process.env[credentialEnv]) { - console.error(` ${credentialEnv} is required for ${remoteConfig.label} in non-interactive mode.`); - process.exit(1); + } else if (selected.key === "anthropicCompatible") { + const endpointInput = isNonInteractive() + ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() + : await prompt(" Anthropic-compatible base URL (e.g., https://proxy.example.com): "); + const navigation = getNavigationChoice(endpointInput); + if (navigation === "back") { + console.log(" Returning to provider selection."); + console.log(""); + continue selectionLoop; + } + if (navigation === "exit") { + exitOnboardFromPrompt(); + } + endpointUrl = normalizeProviderBaseUrl(endpointInput, "anthropic"); + if (!endpointUrl) { + console.error(" Endpoint URL is required for Other Anthropic-compatible endpoint."); + if (isNonInteractive()) { + process.exit(1); + } + console.log(""); + continue selectionLoop; } - } else { - await ensureNamedCredential(credentialEnv, remoteConfig.label + " API key", remoteConfig.helpUrl); - } - const defaultModel = requestedModel || remoteConfig.defaultModel; - let modelValidator = null; - if (selected.key === "openai" || selected.key === "gemini" || selected.key === "bedrock") { - modelValidator = (candidate) => - validateOpenAiLikeModel(remoteConfig.label, endpointUrl, candidate, getCredential(credentialEnv)); - } else if (selected.key === "anthropic") { - modelValidator = (candidate) => - validateAnthropicModel(endpointUrl || ANTHROPIC_ENDPOINT_URL, candidate, getCredential(credentialEnv)); } - while (true) { + + if (selected.key === "build") { if (isNonInteractive()) { - model = defaultModel; - } else if (remoteConfig.modelMode === "curated") { - model = await promptRemoteModel(remoteConfig.label, selected.key, defaultModel, modelValidator); + if (!process.env.NVIDIA_API_KEY) { + console.error( + " NVIDIA_API_KEY is required for NVIDIA Endpoints in non-interactive mode.", + ); + process.exit(1); + } } else { - model = await promptInputModel(remoteConfig.label, defaultModel, modelValidator); + await ensureApiKey(); } + model = + requestedModel || + (isNonInteractive() ? DEFAULT_CLOUD_MODEL : await promptCloudModel()) || + DEFAULT_CLOUD_MODEL; if (model === BACK_TO_SELECTION) { console.log(" Returning to provider selection."); console.log(""); continue selectionLoop; } - - if (selected.key === "custom") { - const validation = await validateCustomOpenAiLikeSelection( - remoteConfig.label, - endpointUrl, - model, - credentialEnv, - remoteConfig.helpUrl - ); - if (validation.ok) { - preferredInferenceApi = validation.api; - break; - } - if (validation.retry === "credential" || validation.retry === "retry" || validation.retry === "model") { - continue; - } - if (validation.retry === "selection") { - continue selectionLoop; - } - } else if (selected.key === "bedrock") { - // Bedrock Mantle exposes an OpenAI-compatible API - const retryMessage = "Please choose a provider/model again."; - preferredInferenceApi = await validateOpenAiLikeSelection( - remoteConfig.label, - endpointUrl, - model, - credentialEnv, - retryMessage - ); - if (preferredInferenceApi) { - break; + } else { + if (isNonInteractive()) { + if (!process.env[credentialEnv]) { + console.error( + ` ${credentialEnv} is required for ${remoteConfig.label} in non-interactive mode.`, + ); + process.exit(1); } - continue selectionLoop; - } else if (selected.key === "anthropicCompatible") { - const validation = await validateCustomAnthropicSelection( - remoteConfig.label, - endpointUrl || ANTHROPIC_ENDPOINT_URL, - model, + } else { + await ensureNamedCredential( credentialEnv, - remoteConfig.helpUrl + remoteConfig.label + " API key", + remoteConfig.helpUrl, ); - if (validation.ok) { - preferredInferenceApi = validation.api; - break; - } - if (validation.retry === "credential" || validation.retry === "retry" || validation.retry === "model") { - continue; + } + const defaultModel = requestedModel || remoteConfig.defaultModel; + let modelValidator = null; + if ( + selected.key === "openai" || + selected.key === "gemini" || + selected.key === "bedrock" + ) { + modelValidator = (candidate) => + validateOpenAiLikeModel( + remoteConfig.label, + endpointUrl, + candidate, + getCredential(credentialEnv), + ); + } else if (selected.key === "anthropic") { + modelValidator = (candidate) => + validateAnthropicModel( + endpointUrl || ANTHROPIC_ENDPOINT_URL, + candidate, + getCredential(credentialEnv), + ); + } + while (true) { + if (isNonInteractive()) { + model = defaultModel; + } else if (remoteConfig.modelMode === "curated") { + model = await promptRemoteModel( + remoteConfig.label, + selected.key, + defaultModel, + modelValidator, + ); + } else { + model = await promptInputModel(remoteConfig.label, defaultModel, modelValidator); } - if (validation.retry === "selection") { + if (model === BACK_TO_SELECTION) { + console.log(" Returning to provider selection."); + console.log(""); continue selectionLoop; } - } else { - const retryMessage = "Please choose a provider/model again."; - if (selected.key === "anthropic") { - const validation = await validateAnthropicSelectionWithRetryMessage( + + if (selected.key === "custom") { + const validation = await validateCustomOpenAiLikeSelection( remoteConfig.label, - endpointUrl || ANTHROPIC_ENDPOINT_URL, + endpointUrl, model, credentialEnv, - retryMessage, - remoteConfig.helpUrl + remoteConfig.helpUrl, ); if (validation.ok) { preferredInferenceApi = validation.api; break; } - if (validation.retry === "credential" || validation.retry === "retry" || validation.retry === "model") { + if ( + validation.retry === "credential" || + validation.retry === "retry" || + validation.retry === "model" + ) { continue; } - } else { - const validation = await validateOpenAiLikeSelection( + if (validation.retry === "selection") { + continue selectionLoop; + } + } else if (selected.key === "bedrock") { + // Bedrock Mantle exposes an OpenAI-compatible API + const retryMessage = "Please choose a provider/model again."; + preferredInferenceApi = await validateOpenAiLikeSelection( remoteConfig.label, endpointUrl, model, credentialEnv, retryMessage, - remoteConfig.helpUrl + ); + if (preferredInferenceApi) { + break; + } + continue selectionLoop; + } else if (selected.key === "anthropicCompatible") { + const validation = await validateCustomAnthropicSelection( + remoteConfig.label, + endpointUrl || ANTHROPIC_ENDPOINT_URL, + model, + credentialEnv, + remoteConfig.helpUrl, ); if (validation.ok) { preferredInferenceApi = validation.api; break; } - if (validation.retry === "credential" || validation.retry === "retry" || validation.retry === "model") { + if ( + validation.retry === "credential" || + validation.retry === "retry" || + validation.retry === "model" + ) { continue; } + if (validation.retry === "selection") { + continue selectionLoop; + } + } else { + const retryMessage = "Please choose a provider/model again."; + if (selected.key === "anthropic") { + const validation = await validateAnthropicSelectionWithRetryMessage( + remoteConfig.label, + endpointUrl || ANTHROPIC_ENDPOINT_URL, + model, + credentialEnv, + retryMessage, + remoteConfig.helpUrl, + ); + if (validation.ok) { + preferredInferenceApi = validation.api; + break; + } + if ( + validation.retry === "credential" || + validation.retry === "retry" || + validation.retry === "model" + ) { + continue; + } + } else { + const validation = await validateOpenAiLikeSelection( + remoteConfig.label, + endpointUrl, + model, + credentialEnv, + retryMessage, + remoteConfig.helpUrl, + ); + if (validation.ok) { + preferredInferenceApi = validation.api; + break; + } + if ( + validation.retry === "credential" || + validation.retry === "retry" || + validation.retry === "model" + ) { + continue; + } + } + continue selectionLoop; } - continue selectionLoop; } } - } - if (selected.key === "build") { - while (true) { - const validation = await validateOpenAiLikeSelection( - remoteConfig.label, - endpointUrl, - model, - credentialEnv, - "Please choose a provider/model again.", - remoteConfig.helpUrl - ); - if (validation.ok) { - preferredInferenceApi = validation.api; - break; - } - if (validation.retry === "credential" || validation.retry === "retry") { - continue; + if (selected.key === "build") { + while (true) { + const validation = await validateOpenAiLikeSelection( + remoteConfig.label, + endpointUrl, + model, + credentialEnv, + "Please choose a provider/model again.", + remoteConfig.helpUrl, + ); + if (validation.ok) { + preferredInferenceApi = validation.api; + break; + } + if (validation.retry === "credential" || validation.retry === "retry") { + continue; + } + continue selectionLoop; } - continue selectionLoop; } - } - console.log(` Using ${remoteConfig.label} with model: ${model}`); - break; - } else if (selected.key === "nim-local") { - // List models that fit GPU VRAM - const models = nim.listModels().filter((m) => m.minGpuMemoryMB <= gpu.totalMemoryMB); - if (models.length === 0) { - console.log(" No NIM models fit your GPU VRAM. Falling back to cloud API."); - } else { - let sel; - if (isNonInteractive()) { - if (requestedModel) { - sel = models.find((m) => m.name === requestedModel); - if (!sel) { - console.error(` Unsupported NEMOCLAW_MODEL for NIM: ${requestedModel}`); - process.exit(1); + console.log(` Using ${remoteConfig.label} with model: ${model}`); + break; + } else if (selected.key === "nim-local") { + // List models that fit GPU VRAM + const models = nim.listModels().filter((m) => m.minGpuMemoryMB <= gpu.totalMemoryMB); + if (models.length === 0) { + console.log(" No NIM models fit your GPU VRAM. Falling back to cloud API."); + } else { + let sel; + if (isNonInteractive()) { + if (requestedModel) { + sel = models.find((m) => m.name === requestedModel); + if (!sel) { + console.error(` Unsupported NEMOCLAW_MODEL for NIM: ${requestedModel}`); + process.exit(1); + } + } else { + sel = models[0]; } + note(` [non-interactive] NIM model: ${sel.name}`); } else { - sel = models[0]; - } - note(` [non-interactive] NIM model: ${sel.name}`); - } else { - console.log(""); - console.log(" Models that fit your GPU:"); - models.forEach((m, i) => { - console.log(` ${i + 1}) ${m.name} (min ${m.minGpuMemoryMB} MB)`); - }); - console.log(""); + console.log(""); + console.log(" Models that fit your GPU:"); + models.forEach((m, i) => { + console.log(` ${i + 1}) ${m.name} (min ${m.minGpuMemoryMB} MB)`); + }); + console.log(""); - const modelChoice = await prompt(` Choose model [1]: `); - const midx = parseInt(modelChoice || "1", 10) - 1; - sel = models[midx] || models[0]; - } - model = sel.name; + const modelChoice = await prompt(` Choose model [1]: `); + const midx = parseInt(modelChoice || "1", 10) - 1; + sel = models[midx] || models[0]; + } + model = sel.name; - console.log(` Pulling NIM image for ${model}...`); - nim.pullNimImage(model); + console.log(` Pulling NIM image for ${model}...`); + nim.pullNimImage(model); - console.log(" Starting NIM container..."); - nimContainer = nim.startNimContainerByName(nim.containerName(GATEWAY_NAME), model); + console.log(" Starting NIM container..."); + nimContainer = nim.startNimContainerByName(nim.containerName(GATEWAY_NAME), model); - console.log(" Waiting for NIM to become healthy..."); - if (!nim.waitForNimHealth()) { - console.error(" NIM failed to start. Falling back to cloud API."); - model = null; - nimContainer = null; - } else { - provider = "vllm-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); + console.log(" Waiting for NIM to become healthy..."); + if (!nim.waitForNimHealth()) { + console.error(" NIM failed to start. Falling back to cloud API."); + model = null; + nimContainer = null; + } else { + provider = "vllm-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + const validation = await validateOpenAiLikeSelection( + "Local NVIDIA NIM", + endpointUrl, + model, + credentialEnv, + ); + if ( + validation.retry === "selection" || + validation.retry === "back" || + validation.retry === "model" + ) { + continue selectionLoop; + } + if (!validation.ok) { + continue selectionLoop; + } + preferredInferenceApi = validation.api; + // NIM uses vLLM internally — same tool-call-parser limitation + // applies to /v1/responses. Force chat completions. + if (preferredInferenceApi !== "openai-completions") { + console.log( + " ℹ Using chat completions API (tool-call-parser requires /v1/chat/completions)", + ); + } + preferredInferenceApi = "openai-completions"; + } + } + break; + } else if (selected.key === "ollama") { + if (!ollamaRunning) { + console.log(" Starting Ollama..."); + // On WSL2, binding to 0.0.0.0 creates a dual-stack socket that Docker + // cannot reach via host-gateway. The default 127.0.0.1 binding works + // because WSL2 relays IPv4-only sockets to the Windows host. + const ollamaEnv = isWsl() ? "" : "OLLAMA_HOST=0.0.0.0:11434 "; + run(`${ollamaEnv}ollama serve > /dev/null 2>&1 &`, { ignoreError: true }); + sleep(2); + } + console.log(" ✓ Using Ollama on localhost:11434"); + provider = "ollama-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + while (true) { + const installedModels = getOllamaModelOptions(runCapture); + if (isNonInteractive()) { + model = requestedModel || getDefaultOllamaModel(runCapture, gpu); + } else { + model = await promptOllamaModel(gpu); + } + const probe = prepareOllamaModel(model, installedModels); + if (!probe.ok) { + console.error(` ${probe.message}`); + if (isNonInteractive()) { + process.exit(1); + } + console.log(" Choose a different Ollama model or select Other."); + console.log(""); + continue; + } const validation = await validateOpenAiLikeSelection( - "Local NVIDIA NIM", - endpointUrl, + "Local Ollama", + getLocalProviderValidationBaseUrl(provider), model, - credentialEnv + null, + "Choose a different Ollama model or select Other.", ); - if (validation.retry === "selection" || validation.retry === "back" || validation.retry === "model") { + if (validation.retry === "selection" || validation.retry === "back") { continue selectionLoop; } if (!validation.ok) { - continue selectionLoop; + continue; } preferredInferenceApi = validation.api; - // NIM uses vLLM internally — same tool-call-parser limitation - // applies to /v1/responses. Force chat completions. - if (preferredInferenceApi !== "openai-completions") { - console.log(" ℹ Using chat completions API (tool-call-parser requires /v1/chat/completions)"); - } - preferredInferenceApi = "openai-completions"; + break; } - } - break; - } else if (selected.key === "ollama") { - if (!ollamaRunning) { + break; + } else if (selected.key === "install-ollama") { + // macOS only — this option is gated by process.platform === "darwin" above + console.log(" Installing Ollama via Homebrew..."); + run("brew install ollama", { ignoreError: true }); console.log(" Starting Ollama..."); - // On WSL2, binding to 0.0.0.0 creates a dual-stack socket that Docker - // cannot reach via host-gateway. The default 127.0.0.1 binding works - // because WSL2 relays IPv4-only sockets to the Windows host. - const ollamaEnv = isWsl() ? "" : "OLLAMA_HOST=0.0.0.0:11434 "; - run(`${ollamaEnv}ollama serve > /dev/null 2>&1 &`, { ignoreError: true }); + run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { ignoreError: true }); sleep(2); - } - console.log(" ✓ Using Ollama on localhost:11434"); - provider = "ollama-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); - while (true) { - const installedModels = getOllamaModelOptions(runCapture); - if (isNonInteractive()) { - model = requestedModel || getDefaultOllamaModel(runCapture, gpu); - } else { - model = await promptOllamaModel(gpu); - } - const probe = prepareOllamaModel(model, installedModels); - if (!probe.ok) { - console.error(` ${probe.message}`); + console.log(" ✓ Using Ollama on localhost:11434"); + provider = "ollama-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + while (true) { + const installedModels = getOllamaModelOptions(runCapture); if (isNonInteractive()) { - process.exit(1); + model = requestedModel || getDefaultOllamaModel(runCapture, gpu); + } else { + model = await promptOllamaModel(gpu); } - console.log(" Choose a different Ollama model or select Other."); - console.log(""); - continue; - } - const validation = await validateOpenAiLikeSelection( - "Local Ollama", - getLocalProviderValidationBaseUrl(provider), - model, - null, - "Choose a different Ollama model or select Other." - ); - if (validation.retry === "selection" || validation.retry === "back") { - continue selectionLoop; - } - if (!validation.ok) { - continue; + const probe = prepareOllamaModel(model, installedModels); + if (!probe.ok) { + console.error(` ${probe.message}`); + if (isNonInteractive()) { + process.exit(1); + } + console.log(" Choose a different Ollama model or select Other."); + console.log(""); + continue; + } + const validation = await validateOpenAiLikeSelection( + "Local Ollama", + getLocalProviderValidationBaseUrl(provider), + model, + null, + "Choose a different Ollama model or select Other.", + ); + if (validation.retry === "selection" || validation.retry === "back") { + continue selectionLoop; + } + if (!validation.ok) { + continue; + } + preferredInferenceApi = validation.api; + break; } - preferredInferenceApi = validation.api; break; - } - break; - } else if (selected.key === "install-ollama") { - // macOS only — this option is gated by process.platform === "darwin" above - console.log(" Installing Ollama via Homebrew..."); - run("brew install ollama", { ignoreError: true }); - console.log(" Starting Ollama..."); - run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { ignoreError: true }); - sleep(2); - console.log(" ✓ Using Ollama on localhost:11434"); - provider = "ollama-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); - while (true) { - const installedModels = getOllamaModelOptions(runCapture); - if (isNonInteractive()) { - model = requestedModel || getDefaultOllamaModel(runCapture, gpu); - } else { - model = await promptOllamaModel(gpu); - } - const probe = prepareOllamaModel(model, installedModels); - if (!probe.ok) { - console.error(` ${probe.message}`); - if (isNonInteractive()) { + } else if (selected.key === "vllm") { + console.log(" ✓ Using existing vLLM on localhost:8000"); + provider = "vllm-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + // Query vLLM for the actual model ID + const vllmModelsRaw = runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { + ignoreError: true, + }); + try { + const vllmModels = JSON.parse(vllmModelsRaw); + if (vllmModels.data && vllmModels.data.length > 0) { + model = vllmModels.data[0].id; + if (!isSafeModelId(model)) { + console.error(` Detected model ID contains invalid characters: ${model}`); + process.exit(1); + } + console.log(` Detected model: ${model}`); + } else { + console.error(" Could not detect model from vLLM. Please specify manually."); process.exit(1); } - console.log(" Choose a different Ollama model or select Other."); - console.log(""); - continue; + } catch { + console.error( + " Could not query vLLM models endpoint. Is vLLM running on localhost:8000?", + ); + process.exit(1); } const validation = await validateOpenAiLikeSelection( - "Local Ollama", + "Local vLLM", getLocalProviderValidationBaseUrl(provider), model, - null, - "Choose a different Ollama model or select Other." + credentialEnv, ); - if (validation.retry === "selection" || validation.retry === "back") { + if ( + validation.retry === "selection" || + validation.retry === "back" || + validation.retry === "model" + ) { continue selectionLoop; } if (!validation.ok) { - continue; + continue selectionLoop; } preferredInferenceApi = validation.api; - break; - } - break; - } else if (selected.key === "vllm") { - console.log(" ✓ Using existing vLLM on localhost:8000"); - provider = "vllm-local"; - credentialEnv = "OPENAI_API_KEY"; - endpointUrl = getLocalProviderBaseUrl(provider); - // Query vLLM for the actual model ID - const vllmModelsRaw = runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { ignoreError: true }); - try { - const vllmModels = JSON.parse(vllmModelsRaw); - if (vllmModels.data && vllmModels.data.length > 0) { - model = vllmModels.data[0].id; - if (!isSafeModelId(model)) { - console.error(` Detected model ID contains invalid characters: ${model}`); - process.exit(1); - } - console.log(` Detected model: ${model}`); - } else { - console.error(" Could not detect model from vLLM. Please specify manually."); - process.exit(1); + // Force chat completions — vLLM's /v1/responses endpoint does not + // run the --tool-call-parser, so tool calls arrive as raw text. + // See: https://github.com/NVIDIA/NemoClaw/issues/976 + if (preferredInferenceApi !== "openai-completions") { + console.log( + " ℹ Using chat completions API (tool-call-parser requires /v1/chat/completions)", + ); } - } catch { - console.error(" Could not query vLLM models endpoint. Is vLLM running on localhost:8000?"); - process.exit(1); - } - const validation = await validateOpenAiLikeSelection( - "Local vLLM", - getLocalProviderValidationBaseUrl(provider), - model, - credentialEnv - ); - if (validation.retry === "selection" || validation.retry === "back" || validation.retry === "model") { - continue selectionLoop; - } - if (!validation.ok) { - continue selectionLoop; - } - preferredInferenceApi = validation.api; - // Force chat completions — vLLM's /v1/responses endpoint does not - // run the --tool-call-parser, so tool calls arrive as raw text. - // See: https://github.com/NVIDIA/NemoClaw/issues/976 - if (preferredInferenceApi !== "openai-completions") { - console.log(" ℹ Using chat completions API (tool-call-parser requires /v1/chat/completions)"); + preferredInferenceApi = "openai-completions"; + break; } - preferredInferenceApi = "openai-completions"; - break; } } - } return { model, provider, endpointUrl, credentialEnv, preferredInferenceApi, nimContainer }; } @@ -2845,28 +3083,56 @@ async function setupNim(gpu) { // ── Step 4: Inference provider ─────────────────────────────────── // eslint-disable-next-line complexity -async function setupInference(sandboxName, model, provider, endpointUrl = null, credentialEnv = null) { +async function setupInference( + sandboxName, + model, + provider, + endpointUrl = null, + credentialEnv = null, +) { step(4, 7, "Setting up inference provider"); runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); - if (provider === "nvidia-prod" || provider === "nvidia-nim" || provider === "openai-api" || provider === "anthropic-prod" || provider === "compatible-anthropic-endpoint" || provider === "gemini-api" || provider === "compatible-endpoint" || provider === "bedrock") { - const config = provider === "nvidia-nim" - ? REMOTE_PROVIDER_CONFIG.build - : Object.values(REMOTE_PROVIDER_CONFIG).find((entry) => entry.providerName === provider); + if ( + provider === "nvidia-prod" || + provider === "nvidia-nim" || + provider === "openai-api" || + provider === "anthropic-prod" || + provider === "compatible-anthropic-endpoint" || + provider === "gemini-api" || + provider === "compatible-endpoint" || + provider === "bedrock" + ) { + const config = + provider === "nvidia-nim" + ? REMOTE_PROVIDER_CONFIG.build + : Object.values(REMOTE_PROVIDER_CONFIG).find((entry) => entry.providerName === provider); while (true) { const resolvedCredentialEnv = credentialEnv || (config && config.credentialEnv); const resolvedEndpointUrl = endpointUrl || (config && config.endpointUrl); const credentialValue = hydrateCredentialEnv(resolvedCredentialEnv); - const env = resolvedCredentialEnv && credentialValue - ? { [resolvedCredentialEnv]: credentialValue } - : {}; - const providerResult = upsertProvider(provider, config.providerType, resolvedCredentialEnv, resolvedEndpointUrl, env); + const env = + resolvedCredentialEnv && credentialValue + ? { [resolvedCredentialEnv]: credentialValue } + : {}; + const providerResult = upsertProvider( + provider, + config.providerType, + resolvedCredentialEnv, + resolvedEndpointUrl, + env, + ); if (!providerResult.ok) { console.error(` ${providerResult.message}`); if (isNonInteractive()) { process.exit(providerResult.status || 1); } - const retry = await promptValidationRecovery(config.label, classifyApplyFailure(providerResult.message), resolvedCredentialEnv, config.helpUrl); + const retry = await promptValidationRecovery( + config.label, + classifyApplyFailure(providerResult.message), + resolvedCredentialEnv, + config.helpUrl, + ); if (retry === "credential" || retry === "retry") { continue; } @@ -2891,7 +3157,12 @@ async function setupInference(sandboxName, model, provider, endpointUrl = null, if (isNonInteractive()) { process.exit(applyResult.status || 1); } - const retry = await promptValidationRecovery(config.label, classifyApplyFailure(message), resolvedCredentialEnv, config.helpUrl); + const retry = await promptValidationRecovery( + config.label, + classifyApplyFailure(message), + resolvedCredentialEnv, + config.helpUrl, + ); if (retry === "credential" || retry === "retry") { continue; } @@ -2930,7 +3201,15 @@ async function setupInference(sandboxName, model, provider, endpointUrl = null, console.error(` ${providerResult.message}`); process.exit(providerResult.status || 1); } - runOpenshell(["inference", "set", "--no-verify", "--provider", "ollama-local", "--model", model]); + runOpenshell([ + "inference", + "set", + "--no-verify", + "--provider", + "ollama-local", + "--model", + model, + ]); console.log(` Priming Ollama model: ${model}`); run(getOllamaWarmupCommand(model), { ignoreError: true }); const probe = validateOllamaModel(model, runCapture); @@ -2962,7 +3241,7 @@ async function setupOpenclaw(sandboxName, model, provider) { try { run( `${openshellShellCommand(["sandbox", "connect", sandboxName])} < ${shellQuote(scriptFile)}`, - { stdio: ["ignore", "ignore", "inherit"] } + { stdio: ["ignore", "ignore", "inherit"] }, ); } finally { fs.rmSync(path.dirname(scriptFile), { recursive: true, force: true }); @@ -3064,7 +3343,9 @@ async function _setupPolicies(sandboxName) { }); console.log(""); - const answer = await prompt(` Apply suggested presets (${suggestions.join(", ")})? [Y/n/list]: `); + const answer = await prompt( + ` Apply suggested presets (${suggestions.join(", ")})? [Y/n/list]: `, + ); if (answer.toLowerCase() === "n") { console.log(" Skipping policy presets."); @@ -3079,7 +3360,10 @@ async function _setupPolicies(sandboxName) { if (answer.toLowerCase() === "list") { // Let user pick const picks = await prompt(" Enter preset names (comma-separated): "); - const selected = picks.split(",").map((s) => s.trim()).filter(Boolean); + const selected = picks + .split(",") + .map((s) => s.trim()) + .filter(Boolean); for (const name of selected) { try { policies.applyPreset(sandboxName, name); @@ -3128,7 +3412,8 @@ async function setupPoliciesWithSelection(sandboxName, options = {}) { const suggestions = ["pypi", "npm"]; if (getCredential("TELEGRAM_BOT_TOKEN")) suggestions.push("telegram"); if (getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN) suggestions.push("slack"); - if (getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN) suggestions.push("discord"); + if (getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN) + suggestions.push("discord"); const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); @@ -3218,7 +3503,9 @@ async function setupPoliciesWithSelection(sandboxName, options = {}) { }); console.log(""); - const answer = await prompt(` Apply suggested presets (${suggestions.join(", ")})? [Y/n/list]: `); + const answer = await prompt( + ` Apply suggested presets (${suggestions.join(", ")})? [Y/n/list]: `, + ); if (answer.toLowerCase() === "n") { console.log(" Skipping policy presets."); @@ -3256,8 +3543,13 @@ const CONTROL_UI_PORT = 18789; const CONTROL_UI_PATH = "/"; function isLoopbackHostname(hostname = "") { - const normalized = String(hostname || "").trim().toLowerCase().replace(/^\[|\]$/g, ""); - return normalized === "localhost" || normalized === "::1" || /^127(?:\.\d{1,3}){3}$/.test(normalized); + const normalized = String(hostname || "") + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ""); + return ( + normalized === "localhost" || normalized === "::1" || /^127(?:\.\d{1,3}){3}$/.test(normalized) + ); } function resolveDashboardForwardTarget(chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`) { @@ -3265,7 +3557,9 @@ function resolveDashboardForwardTarget(chatUiUrl = `http://127.0.0.1:${CONTROL_U if (!raw) return String(CONTROL_UI_PORT); try { const parsed = new URL(/^[a-z]+:\/\//i.test(raw) ? raw : `http://${raw}`); - return isLoopbackHostname(parsed.hostname) ? String(CONTROL_UI_PORT) : `0.0.0.0:${CONTROL_UI_PORT}`; + return isLoopbackHostname(parsed.hostname) + ? String(CONTROL_UI_PORT) + : `0.0.0.0:${CONTROL_UI_PORT}`; } catch { return /localhost|::1|127(?:\.\d{1,3}){3}/i.test(raw) ? String(CONTROL_UI_PORT) @@ -3276,7 +3570,9 @@ function resolveDashboardForwardTarget(chatUiUrl = `http://127.0.0.1:${CONTROL_U function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`) { const forwardTarget = resolveDashboardForwardTarget(chatUiUrl); runOpenshell(["forward", "stop", String(CONTROL_UI_PORT)], { ignoreError: true }); - runOpenshell(["forward", "start", "--background", forwardTarget, sandboxName], { ignoreError: true }); + runOpenshell(["forward", "start", "--background", forwardTarget, sandboxName], { + ignoreError: true, + }); } function findOpenclawJsonPath(dir) { @@ -3304,7 +3600,7 @@ function fetchGatewayAuthTokenFromSandbox(sandboxName) { const destDir = `${tmpDir}${path.sep}`; const result = runOpenshell( ["sandbox", "download", sandboxName, "/sandbox/.openclaw/openclaw.json", destDir], - { ignoreError: true, stdio: ["ignore", "ignore", "ignore"] } + { ignoreError: true, stdio: ["ignore", "ignore", "ignore"] }, ); if (result.status !== 0) return null; const jsonPath = findOpenclawJsonPath(tmpDir); @@ -3342,7 +3638,8 @@ function printDashboard(sandboxName, model, provider, nimContainer = null) { 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 === "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"; @@ -3375,8 +3672,12 @@ function printDashboard(sandboxName, model, provider, nimContainer = null) { for (const url of buildControlUiUrls()) { console.log(` ${url}`); } - console.log(` Token: nemoclaw ${sandboxName} connect → jq -r '.gateway.auth.token' /sandbox/.openclaw/openclaw.json`); - console.log(` append #token= to the URL, or see /tmp/gateway.log inside the sandbox.`); + console.log( + ` Token: nemoclaw ${sandboxName} connect → jq -r '.gateway.auth.token' /sandbox/.openclaw/openclaw.json`, + ); + console.log( + ` append #token= to the URL, or see /tmp/gateway.log inside the sandbox.`, + ); } console.log(` ${"─".repeat(50)}`); console.log(""); @@ -3421,7 +3722,7 @@ async function onboard(opts = {}) { delete process.env.OPENSHELL_GATEWAY; const resume = opts.resume === true; const lockResult = onboardSession.acquireOnboardLock( - `nemoclaw onboard${resume ? " --resume" : ""}${isNonInteractive() ? " --non-interactive" : ""}` + `nemoclaw onboard${resume ? " --resume" : ""}${isNonInteractive() ? " --non-interactive" : ""}`, ); if (!lockResult.acquired) { console.error(" Another NemoClaw onboarding run is already in progress."); @@ -3453,16 +3754,18 @@ async function onboard(opts = {}) { console.error(" Run: nemoclaw onboard"); process.exit(1); } - const resumeConflicts = getResumeConfigConflicts(session, { nonInteractive: isNonInteractive() }); + const resumeConflicts = getResumeConfigConflicts(session, { + nonInteractive: isNonInteractive(), + }); if (resumeConflicts.length > 0) { for (const conflict of resumeConflicts) { if (conflict.field === "sandbox") { console.error( - ` Resumable state belongs to sandbox '${conflict.recorded}', not '${conflict.requested}'.` + ` Resumable state belongs to sandbox '${conflict.recorded}', not '${conflict.requested}'.`, ); } else { console.error( - ` Resumable state recorded ${conflict.field} '${conflict.recorded}', not '${conflict.requested}'.` + ` Resumable state recorded ${conflict.field} '${conflict.recorded}', not '${conflict.requested}'.`, ); } } @@ -3482,7 +3785,7 @@ async function onboard(opts = {}) { onboardSession.createSession({ mode: isNonInteractive() ? "non-interactive" : "interactive", metadata: { gatewayName: "nemoclaw" }, - }) + }), ); } @@ -3515,11 +3818,14 @@ async function onboard(opts = {}) { } const gatewayStatus = runCaptureOpenshell(["status"], { ignoreError: true }); - const gatewayInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { ignoreError: true }); + const gatewayInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { + ignoreError: true, + }); const activeGatewayInfo = runCaptureOpenshell(["gateway", "info"], { ignoreError: true }); const gatewayReuseState = getGatewayReuseState(gatewayStatus, gatewayInfo, activeGatewayInfo); const canReuseHealthyGateway = gatewayReuseState === "healthy"; - const resumeGateway = resume && session?.steps?.gateway?.status === "complete" && canReuseHealthyGateway; + const resumeGateway = + resume && session?.steps?.gateway?.status === "complete" && canReuseHealthyGateway; if (resumeGateway) { skippedStepMessage("gateway", "running"); } else if (!resume && canReuseHealthyGateway) { @@ -3592,12 +3898,23 @@ async function onboard(opts = {}) { if (nimContainer) { registry.updateSandbox(sandboxName, { nimContainer }); } - onboardSession.markStepComplete("inference", { sandboxName, provider, model, nimContainer }); + onboardSession.markStepComplete("inference", { + sandboxName, + provider, + model, + nimContainer, + }); break; } startRecordedStep("inference", { sandboxName, provider, model }); - const inferenceResult = await setupInference(GATEWAY_NAME, model, provider, endpointUrl, credentialEnv); + const inferenceResult = await setupInference( + GATEWAY_NAME, + model, + provider, + endpointUrl, + credentialEnv, + ); delete process.env.NVIDIA_API_KEY; if (inferenceResult?.retry === "selection") { forceProviderSelection = true; @@ -3611,13 +3928,16 @@ async function onboard(opts = {}) { } const sandboxReuseState = getSandboxReuseState(sandboxName); - const resumeSandbox = resume && session?.steps?.sandbox?.status === "complete" && sandboxReuseState === "ready"; + const resumeSandbox = + resume && session?.steps?.sandbox?.status === "complete" && sandboxReuseState === "ready"; if (resumeSandbox) { skippedStepMessage("sandbox", sandboxName); } else { if (resume && session?.steps?.sandbox?.status === "complete") { if (sandboxReuseState === "not_ready") { - note(` [resume] Recorded sandbox '${sandboxName}' exists but is not ready; recreating it.`); + note( + ` [resume] Recorded sandbox '${sandboxName}' exists but is not ready; recreating it.`, + ); repairRecordedSandbox(sandboxName); } else { note(" [resume] Recorded sandbox state is unavailable; recreating it."); @@ -3641,14 +3961,19 @@ async function onboard(opts = {}) { onboardSession.markStepComplete("openclaw", { sandboxName, provider, model }); } - const recordedPolicyPresets = Array.isArray(session?.policyPresets) ? session.policyPresets : null; + const recordedPolicyPresets = Array.isArray(session?.policyPresets) + ? session.policyPresets + : null; const resumePolicies = - resume && - sandboxName && - arePolicyPresetsApplied(sandboxName, recordedPolicyPresets || []); + resume && sandboxName && arePolicyPresetsApplied(sandboxName, recordedPolicyPresets || []); if (resumePolicies) { skippedStepMessage("policies", (recordedPolicyPresets || []).join(", ")); - onboardSession.markStepComplete("policies", { sandboxName, provider, model, policyPresets: recordedPolicyPresets || [] }); + onboardSession.markStepComplete("policies", { + sandboxName, + provider, + model, + policyPresets: recordedPolicyPresets || [], + }); } else { startRecordedStep("policies", { sandboxName, diff --git a/bin/lib/policies.js b/bin/lib/policies.js index b17e65570..a555d04bf 100644 --- a/bin/lib/policies.js +++ b/bin/lib/policies.js @@ -157,7 +157,7 @@ function applyPreset(sandboxName, presetName) { if (!sandboxName || sandboxName.length > 63 || !isRfc1123Label) { throw new Error( `Invalid or truncated sandbox name: '${sandboxName}'. ` + - `Names must be 1-63 chars, lowercase alphanumeric, with optional internal hyphens.` + `Names must be 1-63 chars, lowercase alphanumeric, with optional internal hyphens.`, ); } @@ -176,11 +176,10 @@ function applyPreset(sandboxName, presetName) { // Get current policy YAML from sandbox let rawPolicy = ""; try { - rawPolicy = runCapture( - buildPolicyGetCommand(sandboxName), - { ignoreError: true } - ); - } catch { /* ignored */ } + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true }); + } catch { + /* ignored */ + } const currentPolicy = parseCurrentPolicy(rawPolicy); const merged = mergePresetIntoPolicy(currentPolicy, presetEntries); @@ -194,8 +193,16 @@ function applyPreset(sandboxName, presetName) { console.log(` Applied preset: ${presetName}`); } finally { - try { fs.unlinkSync(tmpFile); } catch { /* ignored */ } - try { fs.rmdirSync(tmpDir); } catch { /* ignored */ } + try { + fs.unlinkSync(tmpFile); + } catch { + /* ignored */ + } + try { + fs.rmdirSync(tmpDir); + } catch { + /* ignored */ + } } const sandbox = registry.getSandbox(sandboxName); diff --git a/bin/lib/preflight.js b/bin/lib/preflight.js index 8d2a579f4..604eac7dc 100644 --- a/bin/lib/preflight.js +++ b/bin/lib/preflight.js @@ -75,10 +75,7 @@ async function checkPortAvailable(port, opts) { } else { const hasLsof = runCapture("command -v lsof", { ignoreError: true }); if (hasLsof) { - lsofOut = runCapture( - `lsof -i :${p} -sTCP:LISTEN -P -n 2>/dev/null`, - { ignoreError: true } - ); + lsofOut = runCapture(`lsof -i :${p} -sTCP:LISTEN -P -n 2>/dev/null`, { ignoreError: true }); } } @@ -104,10 +101,9 @@ async function checkPortAvailable(port, opts) { // through to the net probe (which can only detect EADDRINUSE but not // the owning process). if (dataLines.length === 0 && !o.lsofOutput) { - const sudoOut = runCapture( - `sudo lsof -i :${p} -sTCP:LISTEN -P -n 2>/dev/null`, - { ignoreError: true } - ); + const sudoOut = runCapture(`sudo lsof -i :${p} -sTCP:LISTEN -P -n 2>/dev/null`, { + ignoreError: true, + }); if (typeof sudoOut === "string") { const sudoLines = sudoOut.split("\n").filter((l) => l.trim()); const sudoData = sudoLines.filter((l) => !l.startsWith("COMMAND")); @@ -173,10 +169,7 @@ function getMemoryInfo(opts) { if (platform === "darwin") { try { - const memBytes = parseInt( - runCapture("sysctl -n hw.memsize", { ignoreError: true }), - 10 - ); + const memBytes = parseInt(runCapture("sysctl -n hw.memsize", { ignoreError: true }), 10); if (!memBytes || isNaN(memBytes)) return null; const totalRamMB = Math.floor(memBytes / 1024 / 1024); // macOS does not use traditional swap files in the same way @@ -272,13 +265,15 @@ function cleanupPartialSwap() { function createSwapfile(mem) { try { - runCapture("sudo dd if=/dev/zero of=/swapfile bs=1M count=4096 status=none", { ignoreError: false }); + runCapture("sudo dd if=/dev/zero of=/swapfile bs=1M count=4096 status=none", { + ignoreError: false, + }); runCapture("sudo chmod 600 /swapfile", { ignoreError: false }); runCapture("sudo mkswap /swapfile", { ignoreError: false }); runCapture("sudo swapon /swapfile", { ignoreError: false }); runCapture( "grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab", - { ignoreError: false } + { ignoreError: false }, ); writeManagedSwapMarker(); @@ -287,7 +282,8 @@ function createSwapfile(mem) { cleanupPartialSwap(); return { ok: false, - reason: `swap creation failed: ${err.message}. Create swap manually:\n` + + reason: + `swap creation failed: ${err.message}. Create swap manually:\n` + " sudo dd if=/dev/zero of=/swapfile bs=1M count=4096 status=none && sudo chmod 600 /swapfile && " + "sudo mkswap /swapfile && sudo swapon /swapfile", }; diff --git a/bin/lib/registry.js b/bin/lib/registry.js index 79df06c3d..885ea8c2e 100644 --- a/bin/lib/registry.js +++ b/bin/lib/registry.js @@ -29,9 +29,21 @@ function acquireLock() { fs.renameSync(ownerTmp, LOCK_OWNER); } catch (ownerErr) { // Remove the directory we just created so it doesn't look like a stale lock - try { fs.unlinkSync(ownerTmp); } catch { /* best effort */ } - try { fs.unlinkSync(LOCK_OWNER); } catch { /* best effort */ } - try { fs.rmdirSync(LOCK_DIR); } catch { /* best effort */ } + try { + fs.unlinkSync(ownerTmp); + } catch { + /* best effort */ + } + try { + fs.unlinkSync(LOCK_OWNER); + } catch { + /* best effort */ + } + try { + fs.rmdirSync(LOCK_DIR); + } catch { + /* best effort */ + } throw ownerErr; } return; @@ -85,11 +97,15 @@ function acquireLock() { } function releaseLock() { - try { fs.unlinkSync(LOCK_OWNER); } catch (err) { + try { + fs.unlinkSync(LOCK_OWNER); + } catch (err) { if (err.code !== "ENOENT") throw err; } // rmSync handles leftover tmp files from crashed acquireLock attempts - try { fs.rmSync(LOCK_DIR, { recursive: true, force: true }); } catch (err) { + try { + fs.rmSync(LOCK_DIR, { recursive: true, force: true }); + } catch (err) { if (err.code !== "ENOENT") throw err; } } @@ -109,7 +125,9 @@ function load() { if (fs.existsSync(REGISTRY_FILE)) { return JSON.parse(fs.readFileSync(REGISTRY_FILE, "utf-8")); } - } catch { /* ignored */ } + } catch { + /* ignored */ + } return { sandboxes: {}, defaultSandbox: null }; } @@ -123,7 +141,11 @@ function save(data) { fs.renameSync(tmp, REGISTRY_FILE); } catch (err) { // Clean up partial temp file on failure - try { fs.unlinkSync(tmp); } catch { /* best effort */ } + try { + fs.unlinkSync(tmp); + } catch { + /* best effort */ + } throw err; } } diff --git a/bin/lib/resolve-openshell.js b/bin/lib/resolve-openshell.js index 345e218e4..1f80f8685 100644 --- a/bin/lib/resolve-openshell.js +++ b/bin/lib/resolve-openshell.js @@ -24,15 +24,24 @@ function resolveOpenshell(opts = {}) { try { const found = execSync("command -v openshell", { encoding: "utf-8" }).trim(); if (found.startsWith("/")) return found; - } catch { /* ignored */ } + } catch { + /* ignored */ + } } else if (opts.commandVResult && opts.commandVResult.startsWith("/")) { return opts.commandVResult; } // Step 2: fallback candidates - const checkExecutable = opts.checkExecutable || ((p) => { - try { fs.accessSync(p, fs.constants.X_OK); return true; } catch { return false; } - }); + const checkExecutable = + opts.checkExecutable || + ((p) => { + try { + fs.accessSync(p, fs.constants.X_OK); + return true; + } catch { + return false; + } + }); const candidates = [ ...(home && home.startsWith("/") ? [`${home}/.local/bin/openshell`] : []), diff --git a/bin/lib/runner.js b/bin/lib/runner.js index d0ca4ceea..3b09e4fb8 100644 --- a/bin/lib/runner.js +++ b/bin/lib/runner.js @@ -79,7 +79,7 @@ function validateName(name, label = "name") { } if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) { throw new Error( - `Invalid ${label}: '${name}'. Must be lowercase alphanumeric with optional internal hyphens.` + `Invalid ${label}: '${name}'. Must be lowercase alphanumeric with optional internal hyphens.`, ); } return name; diff --git a/bin/lib/runtime-recovery.js b/bin/lib/runtime-recovery.js index b4c1301c0..ddd358932 100644 --- a/bin/lib/runtime-recovery.js +++ b/bin/lib/runtime-recovery.js @@ -34,7 +34,7 @@ function classifySandboxLookup(output = "") { } if ( /transport error|client error|Connection reset by peer|Connection refused|No active gateway|Gateway: .*Error/i.test( - clean + clean, ) ) { return { state: "unavailable", reason: "gateway_unavailable" }; @@ -52,7 +52,7 @@ function classifyGatewayStatus(output = "") { } if ( /No active gateway|transport error|client error|Connection reset by peer|Connection refused|Gateway: .*Error/i.test( - clean + clean, ) ) { return { state: "unavailable", reason: "gateway_unavailable" }; @@ -60,7 +60,10 @@ function classifyGatewayStatus(output = "") { return { state: "inactive", reason: "not_connected" }; } -function shouldAttemptGatewayRecovery({ sandboxState = "missing", gatewayState = "inactive" } = {}) { +function shouldAttemptGatewayRecovery({ + sandboxState = "missing", + gatewayState = "inactive", +} = {}) { return sandboxState === "unavailable" && gatewayState !== "connected"; } diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 81cc18b33..54eb722f8 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -12,7 +12,8 @@ const os = require("os"); // Uses exact NVIDIA green #76B900 on truecolor terminals; 256-color otherwise. // --------------------------------------------------------------------------- const _useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; -const _tc = _useColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit"); +const _tc = + _useColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit"); const G = _useColor ? (_tc ? "\x1b[38;2;118;185;0m" : "\x1b[38;5;148m") : ""; const B = _useColor ? "\x1b[1m" : ""; const D = _useColor ? "\x1b[2m" : ""; @@ -20,7 +21,15 @@ const R = _useColor ? "\x1b[0m" : ""; const _RD = _useColor ? "\x1b[1;31m" : ""; const YW = _useColor ? "\x1b[1;33m" : ""; -const { ROOT, SCRIPTS, run, runCapture: _runCapture, runInteractive, shellQuote, validateName } = require("./lib/runner"); +const { + ROOT, + SCRIPTS, + run, + runCapture: _runCapture, + runInteractive, + shellQuote, + validateName, +} = require("./lib/runner"); const { resolveOpenshell } = require("./lib/resolve-openshell"); const { startGatewayForRecovery } = require("./lib/onboard"); const { @@ -39,12 +48,25 @@ const { parseLiveSandboxNames } = require("./lib/runtime-recovery"); // ── Global commands ────────────────────────────────────────────── const GLOBAL_COMMANDS = new Set([ - "onboard", "list", "deploy", "setup", "setup-spark", - "start", "stop", "status", "debug", "uninstall", - "help", "--help", "-h", "--version", "-v", + "onboard", + "list", + "deploy", + "setup", + "setup-spark", + "start", + "stop", + "status", + "debug", + "uninstall", + "help", + "--help", + "-h", + "--version", + "-v", ]); -const REMOTE_UNINSTALL_URL = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh"; +const REMOTE_UNINSTALL_URL = + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh"; let OPENSHELL_BIN = null; const MIN_LOGS_OPENSHELL_VERSION = "0.0.7"; @@ -92,8 +114,12 @@ function parseVersionFromText(value = "") { } function versionGte(left = "0.0.0", right = "0.0.0") { - 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 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; @@ -149,12 +175,12 @@ function upsertRecoveredSandbox(name, metadata = {}) { function shouldRecoverRegistryEntries(current, session, requestedSandboxName) { const hasSessionSandbox = Boolean(session?.sandboxName); const missingSessionSandbox = - hasSessionSandbox && - !current.sandboxes.some((sandbox) => sandbox.name === session.sandboxName); + hasSessionSandbox && !current.sandboxes.some((sandbox) => sandbox.name === session.sandboxName); const missingRequestedSandbox = Boolean(requestedSandboxName) && !current.sandboxes.some((sandbox) => sandbox.name === requestedSandboxName); - const hasRecoverySeed = current.sandboxes.length > 0 || hasSessionSandbox || Boolean(requestedSandboxName); + const hasRecoverySeed = + current.sandboxes.length > 0 || hasSessionSandbox || Boolean(requestedSandboxName); return { missingRequestedSandbox, shouldRecover: @@ -178,13 +204,20 @@ function seedRecoveryMetadata(current, session, requestedSandboxName) { provider: session.provider || null, nimContainer: session.nimContainer || null, policyPresets: session.policyPresets || null, - }) + }), + ); + const sessionSandboxMissing = !current.sandboxes.some( + (sandbox) => sandbox.name === session.sandboxName, ); - const sessionSandboxMissing = !current.sandboxes.some((sandbox) => sandbox.name === session.sandboxName); const shouldRecoverSessionSandbox = - current.sandboxes.length === 0 || sessionSandboxMissing || requestedSandboxName === session.sandboxName; + current.sandboxes.length === 0 || + sessionSandboxMissing || + requestedSandboxName === session.sandboxName; if (shouldRecoverSessionSandbox) { - recoveredFromSession = upsertRecoveredSandbox(session.sandboxName, metadataByName.get(session.sandboxName)); + recoveredFromSession = upsertRecoveredSandbox( + session.sandboxName, + metadataByName.get(session.sandboxName), + ); } return { metadataByName, recoveredFromSession }; } @@ -216,8 +249,12 @@ async function recoverRegistryFromLiveGateway(metadataByName) { function applyRecoveredDefault(currentDefaultSandbox, requestedSandboxName, session) { const recovered = registry.listSandboxes(); - const preferredDefault = requestedSandboxName || (!currentDefaultSandbox ? session?.sandboxName || null : null); - if (preferredDefault && recovered.sandboxes.some((sandbox) => sandbox.name === preferredDefault)) { + const preferredDefault = + requestedSandboxName || (!currentDefaultSandbox ? session?.sandboxName || null : null); + if ( + preferredDefault && + recovered.sandboxes.some((sandbox) => sandbox.name === preferredDefault) + ) { registry.setDefault(preferredDefault); } return registry.listSandboxes(); @@ -260,7 +297,9 @@ function getNamedGatewayLifecycleState() { const activeGateway = getActiveGatewayName(status.output); const connected = /^\s*Status:\s*Connected\b/im.test(cleanStatus); const named = hasNamedGateway(gatewayInfo.output); - const refusing = /Connection refused|client error \(Connect\)|tcp connect error/i.test(cleanStatus); + const refusing = /Connection refused|client error \(Connect\)|tcp connect error/i.test( + cleanStatus, + ); if (connected && activeGateway === "nemoclaw" && named) { return { state: "healthy_named", status: status.output, gatewayInfo: gatewayInfo.output }; } @@ -290,7 +329,7 @@ async function recoverNamedGatewayRuntime() { } const shouldStartGateway = [before.state, after.state].some((state) => - ["missing_named", "named_unhealthy", "named_unreachable", "connected_other"].includes(state) + ["missing_named", "named_unhealthy", "named_unreachable", "connected_other"].includes(state), ); if (shouldStartGateway) { @@ -320,7 +359,11 @@ function getSandboxGatewayState(sandboxName) { if (/NotFound|sandbox not found/i.test(output)) { return { state: "missing", output }; } - if (/transport error|Connection refused|handshake verification failed|Missing gateway auth token|device identity required/i.test(output)) { + if ( + /transport error|Connection refused|handshake verification failed|Missing gateway auth token|device identity required/i.test( + output, + ) + ) { return { state: "gateway_error", output }; } return { state: "unknown_error", output }; @@ -329,30 +372,51 @@ function getSandboxGatewayState(sandboxName) { function printGatewayLifecycleHint(output = "", sandboxName = "", writer = console.error) { const cleanOutput = stripAnsi(output); if (/No gateway configured/i.test(cleanOutput)) { - writer(" The selected NemoClaw gateway is no longer configured or its metadata/runtime has been lost."); - writer(" Start the gateway again with `openshell gateway start --name nemoclaw` before expecting existing sandboxes to reconnect."); - writer(" If the gateway has to be rebuilt from scratch, recreate the affected sandbox afterward."); + writer( + " The selected NemoClaw gateway is no longer configured or its metadata/runtime has been lost.", + ); + writer( + " Start the gateway again with `openshell gateway start --name nemoclaw` before expecting existing sandboxes to reconnect.", + ); + writer( + " If the gateway has to be rebuilt from scratch, recreate the affected sandbox afterward.", + ); return; } - if (/Connection refused|client error \(Connect\)|tcp connect error/i.test(cleanOutput) && /Gateway:\s+nemoclaw/i.test(cleanOutput)) { - writer(" The selected NemoClaw gateway exists in metadata, but its API is refusing connections after restart."); + if ( + /Connection refused|client error \(Connect\)|tcp connect error/i.test(cleanOutput) && + /Gateway:\s+nemoclaw/i.test(cleanOutput) + ) { + writer( + " The selected NemoClaw gateway exists in metadata, but its API is refusing connections after restart.", + ); writer(" This usually means the gateway runtime did not come back cleanly after the restart."); - writer(" Retry `openshell gateway start --name nemoclaw`; if it stays in this state, rebuild the gateway before expecting existing sandboxes to reconnect."); + writer( + " Retry `openshell gateway start --name nemoclaw`; if it stays in this state, rebuild the gateway before expecting existing sandboxes to reconnect.", + ); return; } if (/handshake verification failed/i.test(cleanOutput)) { writer(" This looks like gateway identity drift after restart."); - writer(" Existing sandboxes may still be recorded locally, but the current gateway no longer trusts their prior connection state."); - writer(" Try re-establishing the NemoClaw gateway/runtime first. If the sandbox is still unreachable, recreate just that sandbox with `nemoclaw onboard`."); + writer( + " Existing sandboxes may still be recorded locally, but the current gateway no longer trusts their prior connection state.", + ); + writer( + " Try re-establishing the NemoClaw gateway/runtime first. If the sandbox is still unreachable, recreate just that sandbox with `nemoclaw onboard`.", + ); return; } if (/Connection refused|transport error/i.test(cleanOutput)) { - writer(` The sandbox '${sandboxName}' may still exist, but the current gateway/runtime is not reachable.`); + writer( + ` The sandbox '${sandboxName}' may still exist, but the current gateway/runtime is not reachable.`, + ); writer(" Check `openshell status`, verify the active gateway, and retry."); return; } if (/Missing gateway auth token|device identity required/i.test(cleanOutput)) { - writer(" The gateway is reachable, but the current auth or device identity state is not usable."); + writer( + " The gateway is reachable, but the current auth or device identity state is not usable.", + ); writer(" Verify the active gateway and retry after re-establishing the runtime."); } } @@ -392,13 +456,19 @@ async function getReconciledSandboxGatewayState(sandboxName) { output: latestLifecycle.status || lookup.output, }; } - if (/Connection refused|client error \(Connect\)|tcp connect error/i.test(latestStatus) && /Gateway:\s+nemoclaw/i.test(latestStatus)) { + if ( + /Connection refused|client error \(Connect\)|tcp connect error/i.test(latestStatus) && + /Gateway:\s+nemoclaw/i.test(latestStatus) + ) { return { state: "gateway_unreachable_after_restart", output: latestLifecycle.status || lookup.output, }; } - if (recovery.after?.state === "named_unreachable" || recovery.before?.state === "named_unreachable") { + if ( + recovery.after?.state === "named_unreachable" || + recovery.before?.state === "named_unreachable" + ) { return { state: "gateway_unreachable_after_restart", output: recovery.after?.status || recovery.before?.status || lookup.output, @@ -419,34 +489,54 @@ async function ensureLiveSandboxOrExit(sandboxName) { registry.removeSandbox(sandboxName); console.error(` Sandbox '${sandboxName}' is not present in the live OpenShell gateway.`); console.error(" Removed stale local registry entry."); - console.error(" Run `nemoclaw list` to confirm the remaining sandboxes, or `nemoclaw onboard` to create a new one."); + console.error( + " Run `nemoclaw list` to confirm the remaining sandboxes, or `nemoclaw onboard` to create a new one.", + ); process.exit(1); } if (lookup.state === "identity_drift") { - console.error(` Sandbox '${sandboxName}' is recorded locally, but the gateway trust material rotated after restart.`); + console.error( + ` Sandbox '${sandboxName}' is recorded locally, but the gateway trust material rotated after restart.`, + ); if (lookup.output) { console.error(lookup.output); } - console.error(" Existing sandbox connections cannot be reattached safely after this gateway identity change."); - console.error(" Recreate this sandbox with `nemoclaw onboard` once the gateway runtime is stable."); + console.error( + " Existing sandbox connections cannot be reattached safely after this gateway identity change.", + ); + console.error( + " Recreate this sandbox with `nemoclaw onboard` once the gateway runtime is stable.", + ); process.exit(1); } if (lookup.state === "gateway_unreachable_after_restart") { - console.error(` Sandbox '${sandboxName}' may still exist, but the selected NemoClaw gateway is still refusing connections after restart.`); + console.error( + ` Sandbox '${sandboxName}' may still exist, but the selected NemoClaw gateway is still refusing connections after restart.`, + ); if (lookup.output) { console.error(lookup.output); } - console.error(" Retry `openshell gateway start --name nemoclaw` and verify `openshell status` is healthy before reconnecting."); - console.error(" If the gateway never becomes healthy, rebuild the gateway and then recreate the affected sandbox."); + console.error( + " Retry `openshell gateway start --name nemoclaw` and verify `openshell status` is healthy before reconnecting.", + ); + console.error( + " If the gateway never becomes healthy, rebuild the gateway and then recreate the affected sandbox.", + ); process.exit(1); } if (lookup.state === "gateway_missing_after_restart") { - console.error(` Sandbox '${sandboxName}' may still exist locally, but the NemoClaw gateway is no longer configured after restart/rebuild.`); + console.error( + ` Sandbox '${sandboxName}' may still exist locally, but the NemoClaw gateway is no longer configured after restart/rebuild.`, + ); if (lookup.output) { console.error(lookup.output); } - console.error(" Start the gateway again with `openshell gateway start --name nemoclaw` before retrying."); - console.error(" If the gateway had to be rebuilt from scratch, recreate the affected sandbox afterward."); + console.error( + " Start the gateway again with `openshell gateway start --name nemoclaw` before retrying.", + ); + console.error( + " If the gateway had to be rebuilt from scratch, recreate the affected sandbox afterward.", + ); process.exit(1); } console.error(` Unable to verify sandbox '${sandboxName}' against the live OpenShell gateway.`); @@ -460,16 +550,17 @@ async function ensureLiveSandboxOrExit(sandboxName) { function printOldLogsCompatibilityGuidance(installedVersion = null) { const versionText = installedVersion ? ` (${installedVersion})` : ""; - console.error(` Installed OpenShell${versionText} is too old or incompatible with \`nemoclaw logs\`.`); + console.error( + ` Installed OpenShell${versionText} is too old or incompatible with \`nemoclaw logs\`.`, + ); console.error(` NemoClaw expects \`openshell logs \` and live streaming via \`--tail\`.`); - console.error(" Upgrade OpenShell by rerunning `nemoclaw onboard`, or reinstall the OpenShell CLI and try again."); + console.error( + " Upgrade OpenShell by rerunning `nemoclaw onboard`, or reinstall the OpenShell CLI and try again.", + ); } function resolveUninstallScript() { - const candidates = [ - path.join(ROOT, "uninstall.sh"), - path.join(__dirname, "..", "uninstall.sh"), - ]; + const candidates = [path.join(ROOT, "uninstall.sh"), path.join(__dirname, "..", "uninstall.sh")]; for (const candidate of candidates) { if (fs.existsSync(candidate)) { @@ -516,7 +607,8 @@ async function setup() { console.log(""); await ensureApiKey(); const { defaultSandbox } = registry.listSandboxes(); - const safeName = defaultSandbox && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(defaultSandbox) ? defaultSandbox : ""; + const safeName = + defaultSandbox && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(defaultSandbox) ? defaultSandbox : ""; run(`bash "${SCRIPTS}/setup.sh" ${shellQuote(safeName)}`); } @@ -577,7 +669,11 @@ async function deploy(instanceName) { process.stdout.write(` Waiting for SSH `); for (let i = 0; i < 60; i++) { try { - execFileSync("ssh", ["-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", name, "echo", "ok"], { encoding: "utf-8", stdio: "ignore" }); + execFileSync( + "ssh", + ["-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", name, "echo", "ok"], + { encoding: "utf-8", stdio: "ignore" }, + ); process.stdout.write(` ${G}✓${R}\n`); break; } catch { @@ -592,8 +688,12 @@ async function deploy(instanceName) { } console.log(" Syncing NemoClaw to VM..."); - run(`ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'mkdir -p /home/ubuntu/nemoclaw'`); - run(`rsync -az --delete --exclude node_modules --exclude .git --exclude src -e "ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR" "${ROOT}/scripts" "${ROOT}/Dockerfile" "${ROOT}/nemoclaw" "${ROOT}/nemoclaw-blueprint" "${ROOT}/bin" "${ROOT}/package.json" ${qname}:/home/ubuntu/nemoclaw/`); + run( + `ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'mkdir -p /home/ubuntu/nemoclaw'`, + ); + run( + `rsync -az --delete --exclude node_modules --exclude .git --exclude src -e "ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR" "${ROOT}/scripts" "${ROOT}/Dockerfile" "${ROOT}/nemoclaw" "${ROOT}/nemoclaw-blueprint" "${ROOT}/bin" "${ROOT}/package.json" ${qname}:/home/ubuntu/nemoclaw/`, + ); const envLines = [`NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY || "")}`]; const ghToken = process.env.GITHUB_TOKEN; @@ -608,31 +708,50 @@ async function deploy(instanceName) { const envTmp = path.join(envDir, "env"); fs.writeFileSync(envTmp, envLines.join("\n") + "\n", { mode: 0o600 }); try { - run(`scp -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${shellQuote(envTmp)} ${qname}:/home/ubuntu/nemoclaw/.env`); - run(`ssh -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'chmod 600 /home/ubuntu/nemoclaw/.env'`); + run( + `scp -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${shellQuote(envTmp)} ${qname}:/home/ubuntu/nemoclaw/.env`, + ); + run( + `ssh -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'chmod 600 /home/ubuntu/nemoclaw/.env'`, + ); } finally { - try { fs.unlinkSync(envTmp); } catch { /* ignored */ } - try { fs.rmdirSync(envDir); } catch { /* ignored */ } + try { + fs.unlinkSync(envTmp); + } catch { + /* ignored */ + } + try { + fs.rmdirSync(envDir); + } catch { + /* ignored */ + } } console.log(" Running setup..."); - runInteractive(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/brev-setup.sh'`); + runInteractive( + `ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/brev-setup.sh'`, + ); if (tgToken) { console.log(" Starting services..."); - run(`ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/start-services.sh'`); + run( + `ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/start-services.sh'`, + ); } console.log(""); console.log(" Connecting to sandbox..."); console.log(""); - runInteractive(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && openshell sandbox connect nemoclaw'`); + runInteractive( + `ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && openshell sandbox connect nemoclaw'`, + ); } async function start() { await ensureApiKey(); const { defaultSandbox } = registry.listSandboxes(); - const safeName = defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; + const safeName = + defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; const sandboxEnv = safeName ? `SANDBOX_NAME=${shellQuote(safeName)}` : ""; run(`${sandboxEnv} bash "${SCRIPTS}/start-services.sh"`); } @@ -674,7 +793,9 @@ function uninstall(args) { let downloadFailed = false; try { try { - execFileSync("curl", ["-fsSL", REMOTE_UNINSTALL_URL, "-o", uninstallScript], { stdio: "inherit" }); + execFileSync("curl", ["-fsSL", REMOTE_UNINSTALL_URL, "-o", uninstallScript], { + stdio: "inherit", + }); } catch { console.error(` Failed to download uninstall script from ${REMOTE_UNINSTALL_URL}`); downloadFailed = true; @@ -698,7 +819,7 @@ function showStatus() { const { sandboxes, defaultSandbox } = registry.listSandboxes(); if (sandboxes.length > 0) { const live = parseGatewayInference( - captureOpenshell(["inference", "get"], { ignoreError: true }).output + captureOpenshell(["inference", "get"], { ignoreError: true }).output, ); console.log(""); console.log(" Sandboxes:"); @@ -721,8 +842,12 @@ async function listSandboxes() { console.log(""); const session = onboardSession.loadSession(); if (session?.sandboxName) { - console.log(` No sandboxes registered locally, but the last onboarded sandbox was '${session.sandboxName}'.`); - console.log(" Retry `nemoclaw connect` or `nemoclaw status` once the gateway/runtime is healthy."); + console.log( + ` No sandboxes registered locally, but the last onboarded sandbox was '${session.sandboxName}'.`, + ); + console.log( + " Retry `nemoclaw connect` or `nemoclaw status` once the gateway/runtime is healthy.", + ); } else { console.log(" No sandboxes registered. Run `nemoclaw onboard` to get started."); } @@ -732,7 +857,7 @@ async function listSandboxes() { // Query live gateway inference once; prefer it over stale registry values. const live = parseGatewayInference( - captureOpenshell(["inference", "get"], { ignoreError: true }).output + captureOpenshell(["inference", "get"], { ignoreError: true }).output, ); console.log(""); @@ -741,7 +866,9 @@ async function listSandboxes() { console.log(""); } if (recovery.recoveredFromGateway > 0) { - console.log(` Recovered ${recovery.recoveredFromGateway} sandbox entr${recovery.recoveredFromGateway === 1 ? "y" : "ies"} from the live OpenShell gateway.`); + console.log( + ` Recovered ${recovery.recoveredFromGateway} sandbox entr${recovery.recoveredFromGateway === 1 ? "y" : "ies"} from the live OpenShell gateway.`, + ); console.log(""); } console.log(" Sandboxes:"); @@ -775,7 +902,7 @@ async function sandboxConnect(sandboxName) { async function sandboxStatus(sandboxName) { const sb = registry.getSandbox(sandboxName); const live = parseGatewayInference( - captureOpenshell(["inference", "get"], { ignoreError: true }).output + captureOpenshell(["inference", "get"], { ignoreError: true }).output, ); if (sb) { console.log(""); @@ -790,7 +917,9 @@ async function sandboxStatus(sandboxName) { if (lookup.state === "present") { console.log(""); if (lookup.recoveredGateway) { - console.log(` Recovered NemoClaw gateway runtime via ${lookup.recoveryVia || "gateway reattach"}.`); + console.log( + ` Recovered NemoClaw gateway runtime via ${lookup.recoveryVia || "gateway reattach"}.`, + ); console.log(""); } console.log(lookup.output); @@ -801,28 +930,46 @@ async function sandboxStatus(sandboxName) { console.log(" Removed stale local registry entry."); } else if (lookup.state === "identity_drift") { console.log(""); - console.log(` Sandbox '${sandboxName}' is recorded locally, but the gateway trust material rotated after restart.`); + console.log( + ` Sandbox '${sandboxName}' is recorded locally, but the gateway trust material rotated after restart.`, + ); if (lookup.output) { console.log(lookup.output); } - console.log(" Existing sandbox connections cannot be reattached safely after this gateway identity change."); - console.log(" Recreate this sandbox with `nemoclaw onboard` once the gateway runtime is stable."); + console.log( + " Existing sandbox connections cannot be reattached safely after this gateway identity change.", + ); + console.log( + " Recreate this sandbox with `nemoclaw onboard` once the gateway runtime is stable.", + ); } else if (lookup.state === "gateway_unreachable_after_restart") { console.log(""); - console.log(` Sandbox '${sandboxName}' may still exist, but the selected NemoClaw gateway is still refusing connections after restart.`); + console.log( + ` Sandbox '${sandboxName}' may still exist, but the selected NemoClaw gateway is still refusing connections after restart.`, + ); if (lookup.output) { console.log(lookup.output); } - console.log(" Retry `openshell gateway start --name nemoclaw` and verify `openshell status` is healthy before reconnecting."); - console.log(" If the gateway never becomes healthy, rebuild the gateway and then recreate the affected sandbox."); + console.log( + " Retry `openshell gateway start --name nemoclaw` and verify `openshell status` is healthy before reconnecting.", + ); + console.log( + " If the gateway never becomes healthy, rebuild the gateway and then recreate the affected sandbox.", + ); } else if (lookup.state === "gateway_missing_after_restart") { console.log(""); - console.log(` Sandbox '${sandboxName}' may still exist locally, but the NemoClaw gateway is no longer configured after restart/rebuild.`); + console.log( + ` Sandbox '${sandboxName}' may still exist locally, but the NemoClaw gateway is no longer configured after restart/rebuild.`, + ); if (lookup.output) { console.log(lookup.output); } - console.log(" Start the gateway again with `openshell gateway start --name nemoclaw` before retrying."); - console.log(" If the gateway had to be rebuilt from scratch, recreate the affected sandbox afterward."); + console.log( + " Start the gateway again with `openshell gateway start --name nemoclaw` before retrying.", + ); + console.log( + " If the gateway had to be rebuilt from scratch, recreate the affected sandbox afterward.", + ); } else { console.log(""); console.log(` Could not verify sandbox '${sandboxName}' against the live OpenShell gateway.`); @@ -833,8 +980,11 @@ async function sandboxStatus(sandboxName) { } // NIM health - const nimStat = sb && sb.nimContainer ? nim.nimStatusByName(sb.nimContainer) : nim.nimStatus(sandboxName); - console.log(` NIM: ${nimStat.running ? `running (${nimStat.container})` : "not running"}`); + const nimStat = + sb && sb.nimContainer ? nim.nimStatusByName(sb.nimContainer) : nim.nimStatus(sandboxName); + console.log( + ` NIM: ${nimStat.running ? `running (${nimStat.container})` : "not running"}`, + ); if (nimStat.running) { console.log(` Healthy: ${nimStat.healthy ? "yes" : "no"}`); } @@ -869,7 +1019,9 @@ function sandboxLogs(sandboxName, follow) { process.stderr.write(stderr); } if ( - /unrecognized subcommand 'logs'|unexpected argument '--tail'|unexpected argument '--follow'/i.test(combined) || + /unrecognized subcommand 'logs'|unexpected argument '--tail'|unexpected argument '--follow'/i.test( + combined, + ) || (installedVersion && !versionGte(installedVersion, MIN_LOGS_OPENSHELL_VERSION)) ) { printOldLogsCompatibilityGuidance(installedVersion); @@ -1006,23 +1158,45 @@ const [cmd, ...args] = process.argv.slice(2); // Global commands if (GLOBAL_COMMANDS.has(cmd)) { switch (cmd) { - case "onboard": await onboard(args); break; - case "setup": await setup(); break; - case "setup-spark": await setupSpark(); break; - case "deploy": await deploy(args[0]); break; - case "start": await start(); break; - case "stop": stop(); break; - case "status": showStatus(); break; - case "debug": debug(args); break; - case "uninstall": uninstall(args); break; - case "list": await listSandboxes(); break; + case "onboard": + await onboard(args); + break; + case "setup": + await setup(); + break; + case "setup-spark": + await setupSpark(); + break; + case "deploy": + await deploy(args[0]); + break; + case "start": + await start(); + break; + case "stop": + stop(); + break; + case "status": + showStatus(); + break; + case "debug": + debug(args); + break; + case "uninstall": + uninstall(args); + break; + case "list": + await listSandboxes(); + break; case "--version": case "-v": { const pkg = require(path.join(__dirname, "..", "package.json")); console.log(`nemoclaw v${pkg.version}`); break; } - default: help(); break; + default: + help(); + break; } return; } @@ -1035,12 +1209,24 @@ const [cmd, ...args] = process.argv.slice(2); const actionArgs = args.slice(1); switch (action) { - case "connect": await sandboxConnect(cmd); break; - case "status": await sandboxStatus(cmd); break; - case "logs": sandboxLogs(cmd, actionArgs.includes("--follow")); break; - case "policy-add": await sandboxPolicyAdd(cmd); break; - case "policy-list": sandboxPolicyList(cmd); break; - case "destroy": await sandboxDestroy(cmd, actionArgs); break; + case "connect": + await sandboxConnect(cmd); + break; + case "status": + await sandboxStatus(cmd); + break; + case "logs": + sandboxLogs(cmd, actionArgs.includes("--follow")); + break; + case "policy-add": + await sandboxPolicyAdd(cmd); + break; + case "policy-list": + sandboxPolicyList(cmd); + break; + case "destroy": + await sandboxDestroy(cmd, actionArgs); + break; default: console.error(` Unknown action: ${action}`); console.error(` Valid actions: connect, status, logs, policy-add, policy-list, destroy`); diff --git a/package-lock.json b/package-lock.json index f2197f791..b4807c80a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "nemoclaw", "version": "0.1.0", + "bundleDependencies": [ + "p-retry" + ], "license": "Apache-2.0", "dependencies": { "openclaw": "2026.3.11", @@ -24,6 +27,7 @@ "@vitest/coverage-v8": "^4.1.0", "eslint": "^10.1.0", "execa": "^9.6.1", + "prettier": "^3.8.1", "tsx": "^4.21.0", "typescript": "^6.0.2", "vitest": "^4.1.0" @@ -948,14 +952,6 @@ "scripts/actions/documentation" ] }, - "node_modules/@buape/carbon/node_modules/opusscript": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz", - "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@buape/carbon/node_modules/prism-media": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", @@ -1348,14 +1344,6 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, - "node_modules/@discordjs/voice/node_modules/opusscript": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz", - "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@discordjs/voice/node_modules/prism-media": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", @@ -5893,6 +5881,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "inBundle": true, "license": "MIT" }, "node_modules/@types/send": { @@ -10994,6 +10983,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "inBundle": true, "license": "MIT", "dependencies": { "@types/retry": "0.12.0", @@ -11374,6 +11364,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-bytes": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", @@ -11771,6 +11777,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "inBundle": true, "license": "MIT", "engines": { "node": ">= 4" diff --git a/package.json b/package.json index 603801061..5e79c83d9 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "test": "vitest run", "lint": "eslint .", "lint:fix": "eslint . --fix", + "format": "prettier --write 'bin/**/*.js' 'test/**/*.js'", + "format:check": "prettier --check 'bin/**/*.js' 'test/**/*.js'", "typecheck": "tsc -p jsconfig.json", "typecheck:cli": "tsc -p tsconfig.cli.json", "prepare": "npm install --omit=dev --ignore-scripts 2>/dev/null || true && if [ -d .git ]; then if command -v prek >/dev/null 2>&1; then prek install; elif [ -d node_modules/@j178/prek ]; then echo \"ERROR: prek package found but binary not in PATH\" && exit 1; else echo \"Skipping git hook setup (prek not installed)\"; fi; fi", @@ -48,6 +50,7 @@ "@vitest/coverage-v8": "^4.1.0", "eslint": "^10.1.0", "execa": "^9.6.1", + "prettier": "^3.8.1", "tsx": "^4.21.0", "typescript": "^6.0.2", "vitest": "^4.1.0" diff --git a/test/cli.test.js b/test/cli.test.js index 9f0e1801a..dd4d4972f 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -125,17 +125,17 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", `marker_file=${JSON.stringify(markerFile)}`, - "printf '%s ' \"$@\" > \"$marker_file\"", + 'printf \'%s \' "$@" > "$marker_file"', "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); const r = runWithEnv("alpha logs --follow", { @@ -169,21 +169,21 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", `marker_file=${JSON.stringify(markerFile)}`, - "if [ \"$1\" = \"--version\" ]; then", + 'if [ "$1" = "--version" ]; then', " echo 'openshell 0.0.16'", " exit 0", "fi", - "printf '%s ' \"$@\" > \"$marker_file\"", + 'printf \'%s \' "$@" > "$marker_file"', "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); const r = runWithEnv("alpha logs", { @@ -216,20 +216,20 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"--version\" ]; then", + 'if [ "$1" = "--version" ]; then', " echo 'openshell 0.0.4'", " exit 0", "fi", "echo \"error: unrecognized subcommand 'logs'\" >&2", "exit 2", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); const r = runWithEnv("alpha logs --follow", { @@ -263,15 +263,15 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", `marker_file=${JSON.stringify(markerFile)}`, - "printf '%s\\n' \"$*\" >> \"$marker_file\"", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + 'printf \'%s\\n\' "$*" >> "$marker_file"', + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', " echo 'Sandbox:'", " echo", " echo ' Id: abc'", @@ -280,12 +280,12 @@ describe("CLI dispatch", () => { " echo ' Phase: Ready'", " exit 0", "fi", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"connect\" ] && [ \"$3\" = \"alpha\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "connect" ] && [ "$3" = "alpha" ]; then', " exit 0", "fi", "exit 0", - ].join("\n"), - { mode: 0o755 } + ].join("\n"), + { mode: 0o755 }, ); const r = runWithEnv("alpha connect", { @@ -320,19 +320,19 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', " echo 'Error: status: NotFound, message: \"sandbox not found\"' >&2", " exit 1", "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); const r = runWithEnv("alpha connect", { @@ -366,73 +366,82 @@ describe("CLI dispatch", () => { }, defaultSandbox: "gamma", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(nemoclawDir, "onboard-session.json"), - JSON.stringify({ - version: 1, - sessionId: "session-1", - resumable: true, - status: "complete", - mode: "interactive", - startedAt: "2026-03-31T00:00:00.000Z", - updatedAt: "2026-03-31T00:00:00.000Z", - lastStepStarted: "policies", - lastCompletedStep: "policies", - failure: null, - sandboxName: "alpha", - provider: "nvidia-prod", - model: "nvidia/nemotron-3-super-120b-a12b", - endpointUrl: null, - credentialEnv: null, - preferredInferenceApi: null, - nimContainer: null, - policyPresets: ["pypi"], - metadata: { gatewayName: "nemoclaw" }, - steps: { - preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, - gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, - sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, - provider_selection: { status: "complete", startedAt: null, completedAt: null, error: null }, - inference: { status: "complete", startedAt: null, completedAt: null, error: null }, - openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, - policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + JSON.stringify( + { + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-03-31T00:00:00.000Z", + updatedAt: "2026-03-31T00:00:00.000Z", + lastStepStarted: "policies", + lastCompletedStep: "policies", + failure: null, + sandboxName: "alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + policyPresets: ["pypi"], + metadata: { gatewayName: "nemoclaw" }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { + status: "complete", + startedAt: null, + completedAt: null, + error: null, + }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, }, - }, null, 2), - { mode: 0o600 } + null, + 2, + ), + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"status\" ]; then", + 'if [ "$1" = "status" ]; then', " echo 'Server Status'", " echo", " echo ' Gateway: nemoclaw'", " echo ' Status: Connected'", " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', " echo 'Gateway Info'", " echo", " echo ' Gateway: nemoclaw'", " exit 0", "fi", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"list\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', " echo 'No sandboxes found.'", " exit 0", "fi", - "if [ \"$1\" = \"inference\" ] && [ \"$2\" = \"get\" ]; then", + 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', " exit 0", "fi", - "if [ \"$1\" = \"--version\" ]; then", + 'if [ "$1" = "--version" ]; then', " echo 'openshell 0.0.16'", " exit 0", "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); const r = runWithEnv("list", { @@ -441,7 +450,9 @@ describe("CLI dispatch", () => { }); expect(r.code).toBe(0); - expect(r.out.includes("Recovered sandbox inventory from the last onboard session.")).toBeTruthy(); + expect( + r.out.includes("Recovered sandbox inventory from the last onboard session."), + ).toBeTruthy(); expect(r.out.includes("alpha")).toBeTruthy(); expect(r.out.includes("gamma")).toBeTruthy(); const saved = JSON.parse(fs.readFileSync(path.join(nemoclawDir, "sandboxes.json"), "utf8")); @@ -471,75 +482,84 @@ describe("CLI dispatch", () => { }, defaultSandbox: "gamma", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(nemoclawDir, "onboard-session.json"), - JSON.stringify({ - version: 1, - sessionId: "session-1", - resumable: true, - status: "complete", - mode: "interactive", - startedAt: "2026-03-31T00:00:00.000Z", - updatedAt: "2026-03-31T00:00:00.000Z", - lastStepStarted: "policies", - lastCompletedStep: "policies", - failure: null, - sandboxName: "alpha", - provider: "nvidia-prod", - model: "nvidia/nemotron-3-super-120b-a12b", - endpointUrl: null, - credentialEnv: null, - preferredInferenceApi: null, - nimContainer: null, - policyPresets: ["pypi"], - metadata: { gatewayName: "nemoclaw" }, - steps: { - preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, - gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, - sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, - provider_selection: { status: "complete", startedAt: null, completedAt: null, error: null }, - inference: { status: "complete", startedAt: null, completedAt: null, error: null }, - openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, - policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + JSON.stringify( + { + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-03-31T00:00:00.000Z", + updatedAt: "2026-03-31T00:00:00.000Z", + lastStepStarted: "policies", + lastCompletedStep: "policies", + failure: null, + sandboxName: "alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + policyPresets: ["pypi"], + metadata: { gatewayName: "nemoclaw" }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { + status: "complete", + startedAt: null, + completedAt: null, + error: null, + }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, }, - }, null, 2), - { mode: 0o600 } + null, + 2, + ), + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"status\" ]; then", + 'if [ "$1" = "status" ]; then', " echo 'Server Status'", " echo", " echo ' Gateway: nemoclaw'", " echo ' Status: Connected'", " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', " echo 'Gateway Info'", " echo", " echo ' Gateway: nemoclaw'", " exit 0", "fi", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"list\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', " echo 'NAME PHASE'", " echo 'alpha Ready'", " echo 'beta Ready'", " exit 0", "fi", - "if [ \"$1\" = \"inference\" ] && [ \"$2\" = \"get\" ]; then", + 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', " exit 0", "fi", - "if [ \"$1\" = \"--version\" ]; then", + 'if [ "$1" = "--version" ]; then', " echo 'openshell 0.0.16'", " exit 0", "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); const r = runWithEnv("list", { @@ -548,8 +568,12 @@ describe("CLI dispatch", () => { }); expect(r.code).toBe(0); - expect(r.out.includes("Recovered sandbox inventory from the last onboard session.")).toBeTruthy(); - expect(r.out.includes("Recovered 1 sandbox entry from the live OpenShell gateway.")).toBeTruthy(); + expect( + r.out.includes("Recovered sandbox inventory from the last onboard session."), + ).toBeTruthy(); + expect( + r.out.includes("Recovered 1 sandbox entry from the live OpenShell gateway."), + ).toBeTruthy(); expect(r.out.includes("alpha")).toBeTruthy(); expect(r.out.includes("beta")).toBeTruthy(); expect(r.out.includes("gamma")).toBeTruthy(); @@ -581,75 +605,84 @@ describe("CLI dispatch", () => { }, defaultSandbox: "gamma", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(nemoclawDir, "onboard-session.json"), - JSON.stringify({ - version: 1, - sessionId: "session-1", - resumable: true, - status: "complete", - mode: "interactive", - startedAt: "2026-03-31T00:00:00.000Z", - updatedAt: "2026-03-31T00:00:00.000Z", - lastStepStarted: "policies", - lastCompletedStep: "policies", - failure: null, - sandboxName: "Alpha", - provider: "nvidia-prod", - model: "nvidia/nemotron-3-super-120b-a12b", - endpointUrl: null, - credentialEnv: null, - preferredInferenceApi: null, - nimContainer: null, - policyPresets: ["pypi"], - metadata: { gatewayName: "nemoclaw" }, - steps: { - preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, - gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, - sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, - provider_selection: { status: "complete", startedAt: null, completedAt: null, error: null }, - inference: { status: "complete", startedAt: null, completedAt: null, error: null }, - openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, - policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + JSON.stringify( + { + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-03-31T00:00:00.000Z", + updatedAt: "2026-03-31T00:00:00.000Z", + lastStepStarted: "policies", + lastCompletedStep: "policies", + failure: null, + sandboxName: "Alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + policyPresets: ["pypi"], + metadata: { gatewayName: "nemoclaw" }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { + status: "complete", + startedAt: null, + completedAt: null, + error: null, + }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, }, - }, null, 2), - { mode: 0o600 } + null, + 2, + ), + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"status\" ]; then", + 'if [ "$1" = "status" ]; then', " echo 'Server Status'", " echo", " echo ' Gateway: nemoclaw'", " echo ' Status: Connected'", " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', " echo 'Gateway Info'", " echo", " echo ' Gateway: nemoclaw'", " exit 0", "fi", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"list\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', " echo 'NAME PHASE'", " echo 'alpha Ready'", " echo 'Bad_Name Ready'", " exit 0", "fi", - "if [ \"$1\" = \"inference\" ] && [ \"$2\" = \"get\" ]; then", + 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', " exit 0", "fi", - "if [ \"$1\" = \"--version\" ]; then", + 'if [ "$1" = "--version" ]; then', " echo 'openshell 0.0.16'", " exit 0", "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); const r = runWithEnv("list", { @@ -676,62 +709,71 @@ describe("CLI dispatch", () => { fs.mkdirSync(nemoclawDir, { recursive: true }); fs.writeFileSync( path.join(nemoclawDir, "onboard-session.json"), - JSON.stringify({ - version: 1, - sessionId: "session-1", - resumable: true, - status: "complete", - mode: "interactive", - startedAt: "2026-03-31T00:00:00.000Z", - updatedAt: "2026-03-31T00:00:00.000Z", - lastStepStarted: "policies", - lastCompletedStep: "policies", - failure: null, - sandboxName: "alpha", - provider: "nvidia-prod", - model: "nvidia/nemotron-3-super-120b-a12b", - endpointUrl: null, - credentialEnv: null, - preferredInferenceApi: null, - nimContainer: null, - policyPresets: null, - metadata: { gatewayName: "nemoclaw" }, - steps: { - preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, - gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, - sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, - provider_selection: { status: "complete", startedAt: null, completedAt: null, error: null }, - inference: { status: "complete", startedAt: null, completedAt: null, error: null }, - openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, - policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + JSON.stringify( + { + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-03-31T00:00:00.000Z", + updatedAt: "2026-03-31T00:00:00.000Z", + lastStepStarted: "policies", + lastCompletedStep: "policies", + failure: null, + sandboxName: "alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + policyPresets: null, + metadata: { gatewayName: "nemoclaw" }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { + status: "complete", + startedAt: null, + completedAt: null, + error: null, + }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, }, - }, null, 2), - { mode: 0o600 } + null, + 2, + ), + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", `marker_file=${JSON.stringify(markerFile)}`, - "printf '%s\\n' \"$*\" >> \"$marker_file\"", - "if [ \"$1\" = \"status\" ]; then", + 'printf \'%s\\n\' "$*" >> "$marker_file"', + 'if [ "$1" = "status" ]; then', " echo 'Server Status'", " echo", " echo ' Gateway: nemoclaw'", " echo ' Status: Connected'", " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', " echo 'Gateway Info'", " echo", " echo ' Gateway: nemoclaw'", " exit 0", "fi", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"list\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', " echo 'No sandboxes found.'", " exit 0", "fi", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', " echo 'Sandbox:'", " echo", " echo ' Id: abc'", @@ -740,16 +782,16 @@ describe("CLI dispatch", () => { " echo ' Phase: Ready'", " exit 0", "fi", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"connect\" ] && [ \"$3\" = \"alpha\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "connect" ] && [ "$3" = "alpha" ]; then', " exit 0", "fi", - "if [ \"$1\" = \"--version\" ]; then", + 'if [ "$1" = "--version" ]; then', " echo 'openshell 0.0.16'", " exit 0", "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); const r = runWithEnv("alpha connect", { @@ -765,73 +807,84 @@ describe("CLI dispatch", () => { }); it("connect keeps the unknown command path when recovery cannot find the requested sandbox", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-unknown-after-recovery-")); + const home = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-cli-connect-unknown-after-recovery-"), + ); const localBin = path.join(home, "bin"); const nemoclawDir = path.join(home, ".nemoclaw"); fs.mkdirSync(localBin, { recursive: true }); fs.mkdirSync(nemoclawDir, { recursive: true }); fs.writeFileSync( path.join(nemoclawDir, "onboard-session.json"), - JSON.stringify({ - version: 1, - sessionId: "session-1", - resumable: true, - status: "complete", - mode: "interactive", - startedAt: "2026-03-31T00:00:00.000Z", - updatedAt: "2026-03-31T00:00:00.000Z", - lastStepStarted: "policies", - lastCompletedStep: "policies", - failure: null, - sandboxName: "alpha", - provider: "nvidia-prod", - model: "nvidia/nemotron-3-super-120b-a12b", - endpointUrl: null, - credentialEnv: null, - preferredInferenceApi: null, - nimContainer: null, - policyPresets: null, - metadata: { gatewayName: "nemoclaw" }, - steps: { - preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, - gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, - sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, - provider_selection: { status: "complete", startedAt: null, completedAt: null, error: null }, - inference: { status: "complete", startedAt: null, completedAt: null, error: null }, - openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, - policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + JSON.stringify( + { + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-03-31T00:00:00.000Z", + updatedAt: "2026-03-31T00:00:00.000Z", + lastStepStarted: "policies", + lastCompletedStep: "policies", + failure: null, + sandboxName: "alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + policyPresets: null, + metadata: { gatewayName: "nemoclaw" }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { + status: "complete", + startedAt: null, + completedAt: null, + error: null, + }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, }, - }, null, 2), - { mode: 0o600 } + null, + 2, + ), + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"status\" ]; then", + 'if [ "$1" = "status" ]; then', " echo 'Server Status'", " echo", " echo ' Gateway: nemoclaw'", " echo ' Status: Connected'", " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ]; then', " echo 'Gateway Info'", " echo", " echo ' Gateway: nemoclaw'", " exit 0", "fi", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"list\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', " echo 'No sandboxes found.'", " exit 0", "fi", - "if [ \"$1\" = \"--version\" ]; then", + 'if [ "$1" = "--version" ]; then', " echo 'openshell 0.0.16'", " exit 0", "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); const r = runWithEnv("beta connect", { @@ -864,19 +917,19 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"--version\" ]; then", + 'if [ "$1" = "--version" ]; then', " echo 'openshell 0.0.16'", " exit 0", "fi", "kill -INT $$", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); const result = spawnSync(process.execPath, [CLI, "alpha", "logs", "--follow"], { @@ -908,25 +961,29 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', " echo 'Error: transport error: handshake verification failed' >&2", " exit 1", "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); - const r = runWithEnv("alpha status", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, 25000); + const r = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + 25000, + ); expect(r.code).toBe(0); expect(r.out.includes("Could not verify sandbox 'alpha'")).toBeTruthy(); @@ -956,32 +1013,32 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", `state_file=${JSON.stringify(stateFile)}`, - "count=$(cat \"$state_file\" 2>/dev/null || echo 0)", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + 'count=$(cat "$state_file" 2>/dev/null || echo 0)', + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', " count=$((count + 1))", - " echo \"$count\" > \"$state_file\"", - " if [ \"$count\" -eq 1 ]; then", + ' echo "$count" > "$state_file"', + ' if [ "$count" -eq 1 ]; then', " echo 'Error: transport error: Connection refused' >&2", " exit 1", " fi", " echo 'Sandbox: alpha'", " exit 0", "fi", - "if [ \"$1\" = \"status\" ]; then", + 'if [ "$1" = "status" ]; then', " echo 'Server Status'", " echo", " echo ' Gateway: nemoclaw'", " echo ' Status: Connected'", " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ] && [ \"$3\" = \"-g\" ] && [ \"$4\" = \"nemoclaw\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', " echo 'Gateway Info'", " echo", " echo ' Gateway: nemoclaw'", @@ -989,7 +1046,7 @@ describe("CLI dispatch", () => { "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); const r = runWithEnv("alpha status", { @@ -1022,47 +1079,51 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', " echo 'Error: transport error: Connection refused' >&2", " exit 1", "fi", - "if [ \"$1\" = \"status\" ]; then", + 'if [ "$1" = "status" ]; then', " echo 'Server Status'", " echo", " echo ' Gateway: openshell'", " echo ' Status: Connected'", " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ] && [ \"$3\" = \"-g\" ] && [ \"$4\" = \"nemoclaw\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', " echo 'Gateway Info'", " echo", " echo ' Gateway: nemoclaw'", " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"select\" ] && [ \"$3\" = \"nemoclaw\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"start\" ] && [ \"$3\" = \"--name\" ] && [ \"$4\" = \"nemoclaw\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "start" ] && [ "$3" = "--name" ] && [ "$4" = "nemoclaw" ]; then', " exit 0", "fi", - "if [ \"$1\" = \"inference\" ] && [ \"$2\" = \"get\" ]; then", + 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', " exit 0", "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); - const r = runWithEnv("alpha status", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, 25000); + const r = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + 25000, + ); expect(r.code).toBe(0); expect(r.out.includes("Recovered NemoClaw gateway runtime")).toBeFalsy(); @@ -1090,39 +1151,43 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', " printf '\\033[31mError: trans\\033[0mport error: Connec\\033[33mtion refused\\033[0m\\n' >&2", " exit 1", "fi", - "if [ \"$1\" = \"status\" ]; then", + 'if [ "$1" = "status" ]; then', " echo 'Server Status'", " echo", " echo ' Gateway: openshell'", " echo ' Status: Disconnected'", " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ] && [ \"$3\" = \"-g\" ] && [ \"$4\" = \"nemoclaw\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', " printf 'Gateway Info\\n\\n Gateway: openshell\\n'", " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"select\" ] && [ \"$3\" = \"nemoclaw\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', " exit 0", "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); - const r = runWithEnv("alpha status", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, 25000); + const r = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + 25000, + ); expect(r.code).toBe(0); expect(r.out.includes("current gateway/runtime is not reachable")).toBeTruthy(); @@ -1148,42 +1213,48 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', " printf '\\033[31mMissing gateway auth\\033[0m token\\n' >&2", " exit 1", "fi", - "if [ \"$1\" = \"status\" ]; then", + 'if [ "$1" = "status" ]; then', " echo 'Server Status'", " echo", " echo ' Gateway: openshell'", " echo ' Status: Disconnected'", " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ] && [ \"$3\" = \"-g\" ] && [ \"$4\" = \"nemoclaw\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', " printf 'Gateway Info\\n\\n Gateway: openshell\\n'", " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"select\" ] && [ \"$3\" = \"nemoclaw\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', " exit 0", "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); - const r = runWithEnv("alpha status", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, 25000); + const r = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + 25000, + ); expect(r.code).toBe(0); - expect(r.out.includes("Verify the active gateway and retry after re-establishing the runtime.")).toBeTruthy(); + expect( + r.out.includes("Verify the active gateway and retry after re-establishing the runtime."), + ).toBeTruthy(); }, 25000); it("explains unrecoverable gateway trust rotation after restart", () => { @@ -1206,24 +1277,24 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', " echo 'Error: transport error: handshake verification failed' >&2", " exit 1", "fi", - "if [ \"$1\" = \"status\" ]; then", + 'if [ "$1" = "status" ]; then', " echo 'Server Status'", " echo", " echo ' Gateway: nemoclaw'", " echo ' Status: Connected'", " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ] && [ \"$3\" = \"-g\" ] && [ \"$4\" = \"nemoclaw\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', " echo 'Gateway Info'", " echo", " echo ' Gateway: nemoclaw'", @@ -1231,13 +1302,17 @@ describe("CLI dispatch", () => { "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); - const statusResult = runWithEnv("alpha status", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, 25000); + const statusResult = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + 25000, + ); expect(statusResult.code).toBe(0); expect(statusResult.out.includes("gateway trust material rotated after restart")).toBeTruthy(); expect(statusResult.out.includes("cannot be reattached safely")).toBeTruthy(); @@ -1271,17 +1346,17 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', " echo 'Error: transport error: Connection refused' >&2", " exit 1", "fi", - "if [ \"$1\" = \"status\" ]; then", + 'if [ "$1" = "status" ]; then', " echo 'Server Status'", " echo", " echo ' Gateway: nemoclaw'", @@ -1290,37 +1365,47 @@ describe("CLI dispatch", () => { " echo 'Connection refused (os error 111)' >&2", " exit 1", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ] && [ \"$3\" = \"-g\" ] && [ \"$4\" = \"nemoclaw\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', " echo 'Gateway Info'", " echo", " echo ' Gateway: nemoclaw'", " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"select\" ] && [ \"$3\" = \"nemoclaw\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"start\" ] && [ \"$3\" = \"--name\" ] && [ \"$4\" = \"nemoclaw\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "start" ] && [ "$3" = "--name" ] && [ "$4" = "nemoclaw" ]; then', " exit 0", "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); - const statusResult = runWithEnv("alpha status", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, 25000); + const statusResult = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + 25000, + ); expect(statusResult.code).toBe(0); - expect(statusResult.out.includes("gateway is still refusing connections after restart")).toBeTruthy(); - expect(statusResult.out.includes("Retry `openshell gateway start --name nemoclaw`")).toBeTruthy(); + expect( + statusResult.out.includes("gateway is still refusing connections after restart"), + ).toBeTruthy(); + expect( + statusResult.out.includes("Retry `openshell gateway start --name nemoclaw`"), + ).toBeTruthy(); const connectResult = runWithEnv("alpha connect", { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, }); expect(connectResult.code).toBe(1); - expect(connectResult.out.includes("gateway is still refusing connections after restart")).toBeTruthy(); + expect( + connectResult.out.includes("gateway is still refusing connections after restart"), + ).toBeTruthy(); expect(connectResult.out.includes("If the gateway never becomes healthy")).toBeTruthy(); }, 25000); @@ -1344,42 +1429,48 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', " echo 'Error: transport error: Connection refused' >&2", " exit 1", "fi", - "if [ \"$1\" = \"status\" ]; then", + 'if [ "$1" = "status" ]; then', " echo 'Gateway Status'", " echo", " echo ' Status: No gateway configured.'", " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ] && [ \"$3\" = \"-g\" ] && [ \"$4\" = \"nemoclaw\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', " exit 1", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"select\" ] && [ \"$3\" = \"nemoclaw\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', " exit 0", "fi", - "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"start\" ] && [ \"$3\" = \"--name\" ] && [ \"$4\" = \"nemoclaw\" ]; then", + 'if [ "$1" = "gateway" ] && [ "$2" = "start" ] && [ "$3" = "--name" ] && [ "$4" = "nemoclaw" ]; then', " exit 1", "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); - const statusResult = runWithEnv("alpha status", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, 25000); + const statusResult = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + 25000, + ); expect(statusResult.code).toBe(0); - expect(statusResult.out.includes("gateway is no longer configured after restart/rebuild")).toBeTruthy(); + expect( + statusResult.out.includes("gateway is no longer configured after restart/rebuild"), + ).toBeTruthy(); expect(statusResult.out.includes("Start the gateway again")).toBeTruthy(); }, 25000); }); @@ -1406,14 +1497,14 @@ describe("list shows live gateway inference", () => { }, defaultSandbox: "test", }), - { mode: 0o600 } + { mode: 0o600 }, ); // Stub openshell: inference get returns live provider/model fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"inference\" ] && [ \"$2\" = \"get\" ]; then", + 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', " echo 'Gateway inference:'", " echo ' Provider: nvidia-prod'", " echo ' Model: nvidia/nemotron-3-super-120b-a12b'", @@ -1422,7 +1513,7 @@ describe("list shows live gateway inference", () => { "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); const r = runWithEnv("list", { @@ -1456,19 +1547,19 @@ describe("list shows live gateway inference", () => { }, defaultSandbox: "test", }), - { mode: 0o600 } + { mode: 0o600 }, ); // Stub openshell: inference get fails fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", - "if [ \"$1\" = \"inference\" ] && [ \"$2\" = \"get\" ]; then", + 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', " exit 1", "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); const r = runWithEnv("list", { diff --git a/test/credential-exposure.test.js b/test/credential-exposure.test.js index 2439c6900..828432633 100644 --- a/test/credential-exposure.test.js +++ b/test/credential-exposure.test.js @@ -11,21 +11,8 @@ import fs from "node:fs"; import path from "node:path"; import { describe, it, expect } from "vitest"; -const ONBOARD_JS = path.join( - import.meta.dirname, - "..", - "bin", - "lib", - "onboard.js", -); -const RUNNER_TS = path.join( - import.meta.dirname, - "..", - "nemoclaw", - "src", - "blueprint", - "runner.ts", -); +const ONBOARD_JS = path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"); +const RUNNER_TS = path.join(import.meta.dirname, "..", "nemoclaw", "src", "blueprint", "runner.ts"); // Matches --credential followed by a value containing "=" (i.e. KEY=VALUE). // Catches quoted KEY=VALUE patterns in JS and Python f-string interpolation. diff --git a/test/credentials.test.js b/test/credentials.test.js index 68537235b..d84c04701 100644 --- a/test/credentials.test.js +++ b/test/credentials.test.js @@ -38,7 +38,7 @@ describe("credential prompts", () => { expect(credentials.getCredential("TEST_API_KEY")).toBe("nvapi-saved-key"); const saved = JSON.parse( - fs.readFileSync(path.join(home, ".nemoclaw", "credentials.json"), "utf-8") + fs.readFileSync(path.join(home, ".nemoclaw", "credentials.json"), "utf-8"), ); expect(saved).toEqual({ TEST_API_KEY: "nvapi-saved-key" }); }); @@ -90,7 +90,7 @@ describe("credential prompts", () => { it("settles the outer prompt promise on secret prompt errors", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "credentials.js"), - "utf-8" + "utf-8", ); expect(source).toMatch(/return new Promise\(\(resolve, reject\) => \{/); @@ -101,7 +101,7 @@ describe("credential prompts", () => { it("re-raises SIGINT from standard readline prompts instead of treating it like an empty answer", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "credentials.js"), - "utf-8" + "utf-8", ); expect(source).toContain('rl.on("SIGINT"'); @@ -115,7 +115,7 @@ describe("credential prompts", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "credentials.js"), - "utf-8" + "utf-8", ); expect(source).toMatch(/while \(true\) \{/); expect(source).toMatch(/Invalid key\. Must start with nvapi-/); @@ -125,7 +125,7 @@ describe("credential prompts", () => { it("masks secret input with asterisks while preserving the underlying value", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "credentials.js"), - "utf-8" + "utf-8", ); expect(source).toContain('output.write("*")'); diff --git a/test/dns-proxy.test.js b/test/dns-proxy.test.js index 99ba489f5..d5066ee11 100644 --- a/test/dns-proxy.test.js +++ b/test/dns-proxy.test.js @@ -66,7 +66,7 @@ describe("setup-dns-proxy.sh", () => { it("uses grep -F for fixed-string sandbox name matching", () => { const content = fs.readFileSync(SETUP_DNS_PROXY, "utf-8"); - expect(content).toContain('grep -F'); + expect(content).toContain("grep -F"); }); it("discovers CoreDNS pod IP via kube-dns endpoints", () => { diff --git a/test/e2e/brev-e2e.test.js b/test/e2e/brev-e2e.test.js index d3c0d62e9..98f8560f3 100644 --- a/test/e2e/brev-e2e.test.js +++ b/test/e2e/brev-e2e.test.js @@ -102,7 +102,11 @@ function waitForSsh(maxAttempts = 60, intervalMs = 5_000) { } catch { if (i === maxAttempts) throw new Error(`SSH not ready after ${maxAttempts} attempts`); if (i % 5 === 0) { - try { brev("refresh"); } catch { /* ignore */ } + try { + brev("refresh"); + } catch { + /* ignore */ + } } execSync(`sleep ${intervalMs / 1000}`); } @@ -155,18 +159,30 @@ describe.runIf(hasRequiredVars)("Brev E2E", () => { // brev start with a git URL may take longer than the default 60s brev() timeout // (it registers the instance + kicks off provisioning before returning) - execFileSync("brev", [ - "start", NEMOCLAW_REPO_URL, - "--name", INSTANCE_NAME, - "--cpu", BREV_CPU, - "--setup-script", LAUNCHABLE_SETUP_SCRIPT, - "--detached", - ], { encoding: "utf-8", timeout: 180_000, stdio: ["pipe", "inherit", "inherit"] }); + execFileSync( + "brev", + [ + "start", + NEMOCLAW_REPO_URL, + "--name", + INSTANCE_NAME, + "--cpu", + BREV_CPU, + "--setup-script", + LAUNCHABLE_SETUP_SCRIPT, + "--detached", + ], + { encoding: "utf-8", timeout: 180_000, stdio: ["pipe", "inherit", "inherit"] }, + ); instanceCreated = true; console.log(`[${elapsed()}] brev start returned (instance provisioning in background)`); // Wait for SSH - try { brev("refresh"); } catch { /* ignore */ } + try { + brev("refresh"); + } catch { + /* ignore */ + } waitForSsh(); console.log(`[${elapsed()}] SSH is up`); @@ -186,20 +202,29 @@ describe.runIf(hasRequiredVars)("Brev E2E", () => { try { // The launch script writes to /tmp/launch-plugin.log and the last step // prints "=== Ready ===" when complete - const log = ssh("cat /tmp/launch-plugin.log 2>/dev/null || echo 'NO_LOG'", { timeout: 15_000 }); + const log = ssh("cat /tmp/launch-plugin.log 2>/dev/null || echo 'NO_LOG'", { + timeout: 15_000, + }); if (log.includes("=== Ready ===")) { - console.log(`[${elapsed()}] Launchable setup complete (detected '=== Ready ===' in log)`); + console.log( + `[${elapsed()}] Launchable setup complete (detected '=== Ready ===' in log)`, + ); break; } // Also check if nemoclaw onboard has run (install marker) - const markerCheck = ssh("test -f ~/.cache/nemoclaw-plugin/install-ran && echo DONE || echo PENDING", { timeout: 10_000 }); + const markerCheck = ssh( + "test -f ~/.cache/nemoclaw-plugin/install-ran && echo DONE || echo PENDING", + { timeout: 10_000 }, + ); if (markerCheck.includes("DONE")) { console.log(`[${elapsed()}] Launchable setup complete (install-ran marker found)`); break; } // Print last few lines of log for progress visibility - const tail = ssh("tail -3 /tmp/launch-plugin.log 2>/dev/null || echo '(no log yet)'", { timeout: 10_000 }); - console.log(`[${elapsed()}] Setup still running... ${tail.replace(/\n/g, ' | ')}`); + const tail = ssh("tail -3 /tmp/launch-plugin.log 2>/dev/null || echo '(no log yet)'", { + timeout: 10_000, + }); + console.log(`[${elapsed()}] Setup still running... ${tail.replace(/\n/g, " | ")}`); } catch { console.log(`[${elapsed()}] Setup poll: SSH command failed, retrying...`); } @@ -210,7 +235,7 @@ describe.runIf(hasRequiredVars)("Brev E2E", () => { if (Date.now() - setupStart >= setupMaxWait) { throw new Error( `Launchable setup did not complete within ${setupMaxWait / 60_000} minutes. ` + - `Neither '=== Ready ===' in /tmp/launch-plugin.log nor install-ran marker found.`, + `Neither '=== Ready ===' in /tmp/launch-plugin.log nor install-ran marker found.`, ); } @@ -231,7 +256,10 @@ describe.runIf(hasRequiredVars)("Brev E2E", () => { // Install deps for our branch console.log(`[${elapsed()}] Running npm ci to sync dependencies...`); - sshWithSecrets(`set -o pipefail && source ~/.nvm/nvm.sh 2>/dev/null || true && cd ${remoteDir} && npm ci --ignore-scripts 2>&1 | tail -5`, { timeout: 300_000, stream: true }); + sshWithSecrets( + `set -o pipefail && source ~/.nvm/nvm.sh 2>/dev/null || true && cd ${remoteDir} && npm ci --ignore-scripts 2>&1 | tail -5`, + { timeout: 300_000, stream: true }, + ); console.log(`[${elapsed()}] Dependencies synced`); // Run nemoclaw onboard (non-interactive) — this is the path real users take. @@ -253,7 +281,6 @@ describe.runIf(hasRequiredVars)("Brev E2E", () => { } catch (e) { console.log(`[${elapsed()}] Warning: could not check sandbox status: ${e.message}`); } - } else { // --- Legacy path: bare brev create + brev-setup.sh --- console.log(`[${elapsed()}] Creating bare instance via brev create...`); @@ -261,7 +288,11 @@ describe.runIf(hasRequiredVars)("Brev E2E", () => { instanceCreated = true; // Wait for SSH - try { brev("refresh"); } catch { /* ignore */ } + try { + brev("refresh"); + } catch { + /* ignore */ + } waitForSsh(); console.log(`[${elapsed()}] SSH is up`); @@ -277,7 +308,10 @@ describe.runIf(hasRequiredVars)("Brev E2E", () => { // Bootstrap VM — stream output to CI log so we can see progress console.log(`[${elapsed()}] Running brev-setup.sh (manual bootstrap)...`); - sshWithSecrets(`cd ${remoteDir} && SKIP_VLLM=1 bash scripts/brev-setup.sh`, { timeout: 2_400_000, stream: true }); + sshWithSecrets(`cd ${remoteDir} && SKIP_VLLM=1 bash scripts/brev-setup.sh`, { + timeout: 2_400_000, + stream: true, + }); console.log(`[${elapsed()}] Bootstrap complete`); // Install nemoclaw CLI — brev-setup.sh creates the sandbox but doesn't diff --git a/test/gateway-cleanup.test.js b/test/gateway-cleanup.test.js index 7a71d9abe..0972e8c6a 100644 --- a/test/gateway-cleanup.test.js +++ b/test/gateway-cleanup.test.js @@ -16,9 +16,7 @@ const ROOT = path.resolve(import.meta.dirname, ".."); describe("gateway cleanup: Docker volumes removed on failure (#17)", () => { it("onboard.js: destroyGateway() removes Docker volumes", () => { const content = fs.readFileSync(path.join(ROOT, "bin/lib/onboard.js"), "utf-8"); - expect( - content.includes("docker volume") && content.includes("openshell-cluster"), - ).toBe(true); + expect(content.includes("docker volume") && content.includes("openshell-cluster")).toBe(true); }); it("onboard.js: volume cleanup runs on gateway start failure", () => { @@ -29,23 +27,21 @@ describe("gateway cleanup: Docker volumes removed on failure (#17)", () => { // Current behavior: // 1. stale gateway metadata is destroyed directly before start, if present // 2. destroyGateway() runs inside the retry loop on each failed attempt - expect(startGwBlock[0].includes('if (hasStaleGateway(gwInfo))')).toBe(true); - expect(startGwBlock[0].includes('runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME]')).toBe(true); + expect(startGwBlock[0].includes("if (hasStaleGateway(gwInfo))")).toBe(true); + expect( + startGwBlock[0].includes('runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME]'), + ).toBe(true); expect(startGwBlock[0]).toContain("destroyGateway()"); }); it("uninstall.sh: includes Docker volume cleanup", () => { const content = fs.readFileSync(path.join(ROOT, "uninstall.sh"), "utf-8"); - expect( - content.includes("docker volume") && content.includes("openshell-cluster"), - ).toBe(true); + expect(content.includes("docker volume") && content.includes("openshell-cluster")).toBe(true); expect(content.includes("remove_related_docker_volumes")).toBe(true); }); it("setup.sh: includes Docker volume cleanup on failure", () => { const content = fs.readFileSync(path.join(ROOT, "scripts/setup.sh"), "utf-8"); - expect( - content.includes("docker volume") && content.includes("openshell-cluster"), - ).toBe(true); + expect(content.includes("docker volume") && content.includes("openshell-cluster")).toBe(true); }); }); diff --git a/test/inference-config.test.js b/test/inference-config.test.js index 92be68433..f34482812 100644 --- a/test/inference-config.test.js +++ b/test/inference-config.test.js @@ -42,9 +42,7 @@ describe("inference selection config", () => { }); it("maps nvidia-nim to the sandbox inference route", () => { - expect( - getProviderSelectionConfig("nvidia-nim", "nvidia/nemotron-3-super-120b-a12b") - ).toEqual({ + expect(getProviderSelectionConfig("nvidia-nim", "nvidia/nemotron-3-super-120b-a12b")).toEqual({ endpointType: "custom", endpointUrl: INFERENCE_ROUTE_URL, ncpPartner: null, @@ -57,16 +55,19 @@ describe("inference selection config", () => { }); it("maps compatible-anthropic-endpoint to the sandbox inference route", () => { - assert.deepEqual(getProviderSelectionConfig("compatible-anthropic-endpoint", "claude-sonnet-proxy"), { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: "claude-sonnet-proxy", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", - provider: "compatible-anthropic-endpoint", - providerLabel: "Other Anthropic-compatible endpoint", - }); + assert.deepEqual( + getProviderSelectionConfig("compatible-anthropic-endpoint", "claude-sonnet-proxy"), + { + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: "claude-sonnet-proxy", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + provider: "compatible-anthropic-endpoint", + providerLabel: "Other Anthropic-compatible endpoint", + }, + ); }); it("maps the remaining hosted providers to the sandbox inference route", () => { @@ -142,7 +143,9 @@ describe("inference selection config", () => { }); it("builds a qualified OpenClaw primary model for ollama-local", () => { - expect(getOpenClawPrimaryModel("ollama-local", "nemotron-3-nano:30b")).toBe(`${MANAGED_PROVIDER_ID}/nemotron-3-nano:30b`); + expect(getOpenClawPrimaryModel("ollama-local", "nemotron-3-nano:30b")).toBe( + `${MANAGED_PROVIDER_ID}/nemotron-3-nano:30b`, + ); }); it("falls back to provider defaults when model is omitted", () => { @@ -150,14 +153,20 @@ describe("inference selection config", () => { expect(getProviderSelectionConfig("anthropic-prod").model).toBe("claude-sonnet-4-6"); expect(getProviderSelectionConfig("gemini-api").model).toBe("gemini-2.5-flash"); expect(getProviderSelectionConfig("compatible-endpoint").model).toBe("custom-model"); - expect(getProviderSelectionConfig("compatible-anthropic-endpoint").model).toBe("custom-anthropic-model"); + expect(getProviderSelectionConfig("compatible-anthropic-endpoint").model).toBe( + "custom-anthropic-model", + ); expect(getProviderSelectionConfig("vllm-local").model).toBe("vllm-local"); expect(getProviderSelectionConfig("bedrock").model).toBe("nvidia.nemotron-super-3-120b"); }); it("builds a default OpenClaw primary model for non-ollama providers", () => { - expect(getOpenClawPrimaryModel("nvidia-prod")).toBe(`${MANAGED_PROVIDER_ID}/nvidia/nemotron-3-super-120b-a12b`); - expect(getOpenClawPrimaryModel("ollama-local")).toBe(`${MANAGED_PROVIDER_ID}/${DEFAULT_OLLAMA_MODEL}`); + expect(getOpenClawPrimaryModel("nvidia-prod")).toBe( + `${MANAGED_PROVIDER_ID}/nvidia/nemotron-3-super-120b-a12b`, + ); + expect(getOpenClawPrimaryModel("ollama-local")).toBe( + `${MANAGED_PROVIDER_ID}/${DEFAULT_OLLAMA_MODEL}`, + ); }); }); diff --git a/test/install-preflight.test.js b/test/install-preflight.test.js index 186dc17ab..d27f37fef 100644 --- a/test/install-preflight.test.js +++ b/test/install-preflight.test.js @@ -489,7 +489,9 @@ fi`, }); expect(result.status).toBe(0); - expect(`${result.stdout}${result.stderr}`).toMatch(/Found an interrupted onboarding session — resuming it\./); + expect(`${result.stdout}${result.stderr}`).toMatch( + /Found an interrupted onboarding session — resuming it\./, + ); expect(fs.readFileSync(onboardLog, "utf-8")).toMatch(/^onboard --resume --non-interactive$/m); }); @@ -786,22 +788,15 @@ describe("installer release-tag resolution", () => { * `fakeBin` must contain a `curl` stub (and optionally `node`). */ function callResolveReleaseTag(fakeBin, env = {}) { - return spawnSync( - "bash", - [ - "-c", - `source "${INSTALLER}" 2>/dev/null; resolve_release_tag`, - ], - { - cwd: path.join(import.meta.dirname, ".."), - encoding: "utf-8", - env: { - HOME: os.tmpdir(), - PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, - ...env, - }, + return spawnSync("bash", ["-c", `source "${INSTALLER}" 2>/dev/null; resolve_release_tag`], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + HOME: os.tmpdir(), + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + ...env, }, - ); + }); } it("defaults to 'latest' with no env override", () => { @@ -881,7 +876,11 @@ exit 0`, // Write package.json that triggers source-checkout path fs.writeFileSync( path.join(tmp, "package.json"), - JSON.stringify({ name: "nemoclaw", version: "0.1.0", dependencies: { openclaw: "2026.3.11" } }, null, 2), + JSON.stringify( + { name: "nemoclaw", version: "0.1.0", dependencies: { openclaw: "2026.3.11" } }, + null, + 2, + ), ); fs.mkdirSync(path.join(tmp, "nemoclaw"), { recursive: true }); fs.writeFileSync( @@ -982,19 +981,15 @@ describe("installer pure helpers", () => { * Helper: source install.sh and call a function, returning stdout. */ function callInstallerFn(fnCall, env = {}) { - return spawnSync( - "bash", - ["-c", `source "${INSTALLER}" 2>/dev/null; ${fnCall}`], - { - cwd: path.join(import.meta.dirname, ".."), - encoding: "utf-8", - env: { - HOME: os.tmpdir(), - PATH: TEST_SYSTEM_PATH, - ...env, - }, + return spawnSync("bash", ["-c", `source "${INSTALLER}" 2>/dev/null; ${fnCall}`], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + HOME: os.tmpdir(), + PATH: TEST_SYSTEM_PATH, + ...env, }, - ); + }); } // -- version_gte -- diff --git a/test/local-inference.test.js b/test/local-inference.test.js index 6bd04f8b1..e028aa736 100644 --- a/test/local-inference.test.js +++ b/test/local-inference.test.js @@ -43,20 +43,24 @@ describe("local inference helpers", () => { }); it("returns the expected health check command for ollama-local", () => { - expect(getLocalProviderHealthCheck("ollama-local")).toBe("curl -sf http://localhost:11434/api/tags 2>/dev/null"); + expect(getLocalProviderHealthCheck("ollama-local")).toBe( + "curl -sf http://localhost:11434/api/tags 2>/dev/null", + ); }); it("returns the expected validation and health check commands for vllm-local", () => { expect(getLocalProviderValidationBaseUrl("ollama-local")).toBe("http://localhost:11434/v1"); - expect(getLocalProviderHealthCheck("vllm-local")).toBe("curl -sf http://localhost:8000/v1/models 2>/dev/null"); + expect(getLocalProviderHealthCheck("vllm-local")).toBe( + "curl -sf http://localhost:8000/v1/models 2>/dev/null", + ); expect(getLocalProviderContainerReachabilityCheck("vllm-local")).toBe( - `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:8000/v1/models 2>/dev/null` + `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:8000/v1/models 2>/dev/null`, ); }); it("returns the expected container reachability command for ollama-local", () => { expect(getLocalProviderContainerReachabilityCheck("ollama-local")).toBe( - `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:11434/api/tags 2>/dev/null` + `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:11434/api/tags 2>/dev/null`, ); }); @@ -108,13 +112,15 @@ describe("local inference helpers", () => { }); it("parses model names from ollama list output", () => { - expect(parseOllamaList( - [ - "NAME ID SIZE MODIFIED", - "nemotron-3-nano:30b abc123 24 GB 2 hours ago", - "qwen3:32b def456 20 GB 1 day ago", - ].join("\n"), - )).toEqual(["nemotron-3-nano:30b", "qwen3:32b"]); + expect( + parseOllamaList( + [ + "NAME ID SIZE MODIFIED", + "nemotron-3-nano:30b abc123 24 GB 2 hours ago", + "qwen3:32b def456 20 GB 1 day ago", + ].join("\n"), + ), + ).toEqual(["nemotron-3-nano:30b", "qwen3:32b"]); }); it("ignores headers and blank lines in ollama list output", () => { @@ -123,7 +129,9 @@ describe("local inference helpers", () => { it("returns parsed ollama model options when available", () => { expect( - getOllamaModelOptions(() => "nemotron-3-nano:30b abc 24 GB now\nqwen3:32b def 20 GB now") + getOllamaModelOptions( + () => "nemotron-3-nano:30b abc 24 GB now\nqwen3:32b def 20 GB now", + ), ).toEqual(["nemotron-3-nano:30b", "qwen3:32b"]); }); @@ -131,19 +139,18 @@ describe("local inference helpers", () => { expect( parseOllamaTags( JSON.stringify({ - models: [ - { name: "nemotron-3-nano:30b" }, - { name: "qwen2.5:7b" }, - ], - }) - ) + models: [{ name: "nemotron-3-nano:30b" }, { name: "qwen2.5:7b" }], + }), + ), ).toEqual(["nemotron-3-nano:30b", "qwen2.5:7b"]); }); it("returns no tags for malformed Ollama API output", () => { expect(parseOllamaTags("{not-json")).toEqual([]); expect(parseOllamaTags(JSON.stringify({ models: null }))).toEqual([]); - expect(parseOllamaTags(JSON.stringify({ models: [{}, { name: "qwen2.5:7b" }] }))).toEqual(["qwen2.5:7b"]); + expect(parseOllamaTags(JSON.stringify({ models: [{}, { name: "qwen2.5:7b" }] }))).toEqual([ + "qwen2.5:7b", + ]); }); it("prefers Ollama /api/tags over parsing the CLI list output", () => { @@ -155,7 +162,7 @@ describe("local inference helpers", () => { return JSON.stringify({ models: [{ name: "qwen2.5:7b" }] }); } return ""; - }) + }), ).toEqual(["qwen2.5:7b"]); }); @@ -165,24 +172,27 @@ describe("local inference helpers", () => { it("prefers the default ollama model when present", () => { expect( - getDefaultOllamaModel(() => "qwen3:32b abc 20 GB now\nnemotron-3-nano:30b def 24 GB now") + getDefaultOllamaModel( + () => "qwen3:32b abc 20 GB now\nnemotron-3-nano:30b def 24 GB now", + ), ).toBe(DEFAULT_OLLAMA_MODEL); }); it("falls back to the first listed ollama model when the default is absent", () => { expect( - getDefaultOllamaModel(() => "qwen3:32b abc 20 GB now\ngemma3:4b def 3 GB now") + getDefaultOllamaModel(() => "qwen3:32b abc 20 GB now\ngemma3:4b def 3 GB now"), ).toBe("qwen3:32b"); }); it("falls back to bootstrap model options when no Ollama models are installed", () => { expect(getBootstrapOllamaModelOptions(null)).toEqual(["qwen2.5:7b"]); expect( - getBootstrapOllamaModelOptions({ totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB - 1 }) + getBootstrapOllamaModelOptions({ totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB - 1 }), ).toEqual(["qwen2.5:7b"]); - expect( - getBootstrapOllamaModelOptions({ totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB }) - ).toEqual(["qwen2.5:7b", DEFAULT_OLLAMA_MODEL]); + expect(getBootstrapOllamaModelOptions({ totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB })).toEqual([ + "qwen2.5:7b", + DEFAULT_OLLAMA_MODEL, + ]); expect(getDefaultOllamaModel(() => "", { totalMemoryMB: 16384 })).toBe("qwen2.5:7b"); }); @@ -212,18 +222,16 @@ describe("local inference helpers", () => { }); it("fails ollama model validation when Ollama returns an error payload", () => { - const result = validateOllamaModel( - "gabegoodhart/minimax-m2.1:latest", - () => JSON.stringify({ error: "model requires more system memory" }), + const result = validateOllamaModel("gabegoodhart/minimax-m2.1:latest", () => + JSON.stringify({ error: "model requires more system memory" }), ); expect(result.ok).toBe(false); expect(result.message).toMatch(/requires more system memory/); }); it("passes ollama model validation when the probe returns a normal payload", () => { - const result = validateOllamaModel( - "nemotron-3-nano:30b", - () => JSON.stringify({ model: "nemotron-3-nano:30b", response: "hello", done: true }), + const result = validateOllamaModel("nemotron-3-nano:30b", () => + JSON.stringify({ model: "nemotron-3-nano:30b", response: "hello", done: true }), ); expect(result).toEqual({ ok: true }); }); diff --git a/test/nemoclaw-cli-recovery.test.js b/test/nemoclaw-cli-recovery.test.js index f3ba3df0a..841b2dee8 100644 --- a/test/nemoclaw-cli-recovery.test.js +++ b/test/nemoclaw-cli-recovery.test.js @@ -35,7 +35,7 @@ describe("nemoclaw CLI runtime recovery", () => { }, }, }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync(stateFile, JSON.stringify({ statusCalls: 0, sandboxGetCalls: 0 })); fs.writeFileSync( @@ -85,18 +85,22 @@ if (args[0] === "logs") { process.exit(0); `, - { mode: 0o755 } + { mode: 0o755 }, ); - const result = spawnSync(process.execPath, [path.join(repoRoot, "bin", "nemoclaw.js"), "my-assistant", "status"], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: "/usr/bin:/bin", + const result = spawnSync( + process.execPath, + [path.join(repoRoot, "bin", "nemoclaw.js"), "my-assistant", "status"], + { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: "/usr/bin:/bin", + }, }, - }); + ); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /Recovered NemoClaw gateway runtime via (start|select)/); diff --git a/test/nemoclaw-start.test.js b/test/nemoclaw-start.test.js index 868c05c7f..d54ac9709 100644 --- a/test/nemoclaw-start.test.js +++ b/test/nemoclaw-start.test.js @@ -20,9 +20,7 @@ describe("nemoclaw-start non-root fallback", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); // Non-root block must call verify_config_integrity and exit 1 on failure - expect(src).toMatch( - /if ! verify_config_integrity; then\s+.*exit 1/s, - ); + expect(src).toMatch(/if ! verify_config_integrity; then\s+.*exit 1/s); // Must not contain the old "proceeding anyway" fallback expect(src).not.toMatch(/proceeding anyway/i); }); diff --git a/test/nim.test.js b/test/nim.test.js index 468a55c94..71d62e8a5 100644 --- a/test/nim.test.js +++ b/test/nim.test.js @@ -47,7 +47,9 @@ describe("nim", () => { describe("getImageForModel", () => { it("returns correct image for known model", () => { - expect(nim.getImageForModel("nvidia/nemotron-3-nano-30b-a3b")).toBe("nvcr.io/nim/nvidia/nemotron-3-nano:latest"); + expect(nim.getImageForModel("nvidia/nemotron-3-nano-30b-a3b")).toBe( + "nvcr.io/nim/nvidia/nemotron-3-nano:latest", + ); }); it("returns null for unknown model", () => { @@ -108,7 +110,12 @@ describe("nim", () => { const st = nimModule.nimStatusByName("foo", 9000); const commands = runCapture.mock.calls.map(([cmd]) => cmd); - expect(st).toMatchObject({ running: true, healthy: true, container: "foo", state: "running" }); + expect(st).toMatchObject({ + running: true, + healthy: true, + container: "foo", + state: "running", + }); expect(commands.some((cmd) => cmd.includes("docker port"))).toBe(false); expect(commands.some((cmd) => cmd.includes("http://localhost:9000/v1/models"))).toBe(true); } finally { @@ -130,9 +137,16 @@ describe("nim", () => { const st = nimModule.nimStatusByName("foo"); const commands = runCapture.mock.calls.map(([cmd]) => cmd); - expect(st).toMatchObject({ running: true, healthy: true, container: "foo", state: "running" }); + expect(st).toMatchObject({ + running: true, + healthy: true, + container: "foo", + state: "running", + }); expect(commands.some((cmd) => cmd.includes("docker port"))).toBe(true); - expect(commands.some((cmd) => cmd.includes("http://localhost:9000/v1/models"))).toBe(true); + expect(commands.some((cmd) => cmd.includes("http://localhost:9000/v1/models"))).toBe( + true, + ); } finally { restore(); } @@ -152,7 +166,12 @@ describe("nim", () => { const st = nimModule.nimStatusByName("foo"); const commands = runCapture.mock.calls.map(([cmd]) => cmd); - expect(st).toMatchObject({ running: true, healthy: true, container: "foo", state: "running" }); + expect(st).toMatchObject({ + running: true, + healthy: true, + container: "foo", + state: "running", + }); expect(commands.some((cmd) => cmd.includes("docker port"))).toBe(true); expect(commands.some((cmd) => cmd.includes("http://localhost:8000/v1/models"))).toBe(true); } finally { @@ -171,7 +190,12 @@ describe("nim", () => { const st = nimModule.nimStatusByName("foo"); const commands = runCapture.mock.calls.map(([cmd]) => cmd); - expect(st).toMatchObject({ running: false, healthy: false, container: "foo", state: "exited" }); + expect(st).toMatchObject({ + running: false, + healthy: false, + container: "foo", + state: "exited", + }); expect(commands).toHaveLength(1); expect(commands.some((cmd) => cmd.includes("docker port"))).toBe(false); expect(commands.some((cmd) => cmd.includes("http://localhost:"))).toBe(false); diff --git a/test/onboard-readiness.test.js b/test/onboard-readiness.test.js index 049ceed42..9872dcb07 100644 --- a/test/onboard-readiness.test.js +++ b/test/onboard-readiness.test.js @@ -20,17 +20,18 @@ describe("sandbox readiness parsing", () => { }); it("strips ANSI escape codes before matching", () => { - expect(isSandboxReady( - "\x1b[1mmy-assistant\x1b[0m \x1b[32mReady\x1b[0m 2m ago", - "my-assistant" - )).toBeTruthy(); + expect( + isSandboxReady("\x1b[1mmy-assistant\x1b[0m \x1b[32mReady\x1b[0m 2m ago", "my-assistant"), + ).toBeTruthy(); }); it("rejects ANSI-wrapped NotReady", () => { - expect(!isSandboxReady( - "\x1b[1mmy-assistant\x1b[0m \x1b[31mNotReady\x1b[0m crash", - "my-assistant" - )).toBeTruthy(); + expect( + !isSandboxReady( + "\x1b[1mmy-assistant\x1b[0m \x1b[31mNotReady\x1b[0m crash", + "my-assistant", + ), + ).toBeTruthy(); }); it("exact-matches sandbox name in first column", () => { @@ -40,7 +41,7 @@ describe("sandbox readiness parsing", () => { it("does not match sandbox name in non-first column", () => { expect( - !isSandboxReady("other-box Ready owned-by-my-assistant", "my-assistant") + !isSandboxReady("other-box Ready owned-by-my-assistant", "my-assistant"), ).toBeTruthy(); }); @@ -59,13 +60,13 @@ describe("sandbox readiness parsing", () => { it("handles Ready sandbox with extra status columns", () => { expect( - isSandboxReady("my-assistant Ready Running 2m ago 1/1", "my-assistant") + isSandboxReady("my-assistant Ready Running 2m ago 1/1", "my-assistant"), ).toBeTruthy(); }); it("rejects when output only contains name in a URL or path", () => { expect( - !isSandboxReady("Connecting to my-assistant.openshell.internal Ready", "my-assistant") + !isSandboxReady("Connecting to my-assistant.openshell.internal Ready", "my-assistant"), ).toBeTruthy(); // "my-assistant.openshell.internal" is cols[0], not "my-assistant" }); @@ -81,7 +82,7 @@ describe("WSL sandbox name handling", () => { it("buildPolicySetCommand preserves hyphenated sandbox name", () => { const cmd = buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); expect(cmd.includes("'my-assistant'")).toBeTruthy(); - expect(!cmd.includes(' my-assistant ')).toBeTruthy(); + expect(!cmd.includes(" my-assistant ")).toBeTruthy(); }); it("buildPolicyGetCommand preserves hyphenated sandbox name", () => { diff --git a/test/onboard-selection.test.js b/test/onboard-selection.test.js index d644565b6..46ff08152 100644 --- a/test/onboard-selection.test.js +++ b/test/onboard-selection.test.js @@ -42,7 +42,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); } @@ -77,7 +77,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); } @@ -108,7 +108,7 @@ done printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` const credentials = require(${credentialsPath}); @@ -202,7 +202,7 @@ done printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -254,7 +254,9 @@ const { setupNim } = require(${onboardPath}); it("accepts a manually entered NVIDIA Endpoints model after validating it against /models", () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-build-model-selection-")); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-build-model-selection-"), + ); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "build-model-selection-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); @@ -281,7 +283,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -374,7 +376,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -431,8 +433,13 @@ const { setupNim } = require(${onboardPath}); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.model, "z-ai/glm5"); - assert.equal(payload.messages.filter((message) => /NVIDIA Endpoints model id:/.test(message)).length, 2); - assert.ok(payload.lines.some((line) => line.includes("is not available from NVIDIA Endpoints"))); + assert.equal( + payload.messages.filter((message) => /NVIDIA Endpoints model id:/.test(message)).length, + 2, + ); + assert.ok( + payload.lines.some((line) => line.includes("is not available from NVIDIA Endpoints")), + ); }); it("shows curated Gemini models and supports Other for manual entry", () => { @@ -468,7 +475,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -554,7 +561,7 @@ done printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -618,8 +625,12 @@ const { setupNim } = require(${onboardPath}); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.provider, "ollama-local"); assert.equal(payload.result.preferredInferenceApi, "openai-responses"); - assert.ok(payload.lines.some((line) => line.includes("Loading Ollama model: nemotron-3-nano:30b"))); - assert.ok(payload.commands.some((command) => command.includes("http://localhost:11434/api/generate"))); + assert.ok( + payload.lines.some((line) => line.includes("Loading Ollama model: nemotron-3-nano:30b")), + ); + assert.ok( + payload.commands.some((command) => command.includes("http://localhost:11434/api/generate")), + ); }); it("offers starter Ollama models when none are installed and pulls the selected model", () => { @@ -648,7 +659,7 @@ done printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); fs.writeFileSync( path.join(fakeBin, "ollama"), @@ -659,7 +670,7 @@ if [ "$1" = "pull" ]; then fi exit 0 `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -719,7 +730,9 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.provider, "ollama-local"); assert.equal(payload.result.model, "qwen2.5:7b"); assert.ok(payload.lines.some((line) => line.includes("Ollama starter models:"))); - assert.ok(payload.lines.some((line) => line.includes("No local Ollama models are installed yet"))); + assert.ok( + payload.lines.some((line) => line.includes("No local Ollama models are installed yet")), + ); assert.ok(payload.lines.some((line) => line.includes("Pulling Ollama model: qwen2.5:7b"))); assert.equal(fs.readFileSync(pullLog, "utf8").trim(), "qwen2.5:7b"); }); @@ -750,7 +763,7 @@ done printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); fs.writeFileSync( path.join(fakeBin, "ollama"), @@ -764,7 +777,7 @@ if [ "$1" = "pull" ]; then fi exit 0 `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -823,8 +836,14 @@ const { setupNim } = require(${onboardPath}); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.provider, "ollama-local"); assert.equal(payload.result.model, "llama3.2:3b"); - assert.ok(payload.lines.some((line) => line.includes("Failed to pull Ollama model 'qwen2.5:7b'"))); - assert.ok(payload.lines.some((line) => line.includes("Choose a different Ollama model or select Other."))); + assert.ok( + payload.lines.some((line) => line.includes("Failed to pull Ollama model 'qwen2.5:7b'")), + ); + assert.ok( + payload.lines.some((line) => + line.includes("Choose a different Ollama model or select Other."), + ), + ); assert.equal(payload.messages.filter((message) => /Ollama model id:/.test(message)).length, 1); assert.equal(fs.readFileSync(pullLog, "utf8").trim(), "qwen2.5:7b\nllama3.2:3b"); }); @@ -860,7 +879,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -918,7 +937,9 @@ const { setupNim } = require(${onboardPath}); it("reprompts for an Anthropic Other model when /v1/models validation rejects it", () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-anthropic-model-retry-")); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-anthropic-model-retry-"), + ); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "anthropic-model-retry-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); @@ -941,7 +962,7 @@ done printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -993,13 +1014,18 @@ const { setupNim } = require(${onboardPath}); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.model, "claude-haiku-4-5"); - assert.equal(payload.messages.filter((message) => /Anthropic model id:/.test(message)).length, 2); + assert.equal( + payload.messages.filter((message) => /Anthropic model id:/.test(message)).length, + 2, + ); assert.ok(payload.lines.some((line) => line.includes("is not available from Anthropic"))); }); it("returns to provider selection when Anthropic live validation fails interactively", () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-anthropic-validation-retry-")); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-anthropic-validation-retry-"), + ); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "anthropic-validation-retry-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); @@ -1031,7 +1057,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -1114,7 +1140,7 @@ done printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -1206,7 +1232,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -1260,16 +1286,33 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.provider, "compatible-endpoint"); assert.equal(payload.result.model, "good-model"); assert.equal(payload.result.preferredInferenceApi, "openai-responses"); - assert.ok(payload.lines.some((line) => line.includes("Other OpenAI-compatible endpoint endpoint validation failed"))); - assert.ok(payload.lines.some((line) => line.includes("Please enter a different Other OpenAI-compatible endpoint model name."))); - assert.equal(payload.messages.filter((message) => /OpenAI-compatible base URL/.test(message)).length, 1); - assert.equal(payload.messages.filter((message) => /Other OpenAI-compatible endpoint model/.test(message)).length, 2); + assert.ok( + payload.lines.some((line) => + line.includes("Other OpenAI-compatible endpoint endpoint validation failed"), + ), + ); + assert.ok( + payload.lines.some((line) => + line.includes("Please enter a different Other OpenAI-compatible endpoint model name."), + ), + ); + assert.equal( + payload.messages.filter((message) => /OpenAI-compatible base URL/.test(message)).length, + 1, + ); + assert.equal( + payload.messages.filter((message) => /Other OpenAI-compatible endpoint model/.test(message)) + .length, + 2, + ); assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); }); it("returns to provider selection instead of exiting on blank custom endpoint input", () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-custom-endpoint-blank-")); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-custom-endpoint-blank-"), + ); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "custom-endpoint-blank-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); @@ -1292,7 +1335,7 @@ done printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -1345,14 +1388,20 @@ const { setupNim } = require(${onboardPath}); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.provider, "nvidia-prod"); assert.equal(payload.result.model, "nvidia/nemotron-3-super-120b-a12b"); - assert.ok(payload.lines.some((line) => line.includes("Endpoint URL is required for Other OpenAI-compatible endpoint."))); + assert.ok( + payload.lines.some((line) => + line.includes("Endpoint URL is required for Other OpenAI-compatible endpoint."), + ), + ); assert.ok(payload.messages.some((message) => /OpenAI-compatible base URL/.test(message))); assert.ok(payload.messages.filter((message) => /Choose \[1\]/.test(message)).length >= 2); }); it("reprompts only for model name when Other Anthropic-compatible endpoint validation fails", () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-custom-anthropic-retry-")); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-custom-anthropic-retry-"), + ); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "custom-anthropic-retry-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); @@ -1382,7 +1431,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -1436,10 +1485,26 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.provider, "compatible-anthropic-endpoint"); assert.equal(payload.result.model, "good-claude"); assert.equal(payload.result.preferredInferenceApi, "anthropic-messages"); - assert.ok(payload.lines.some((line) => line.includes("Other Anthropic-compatible endpoint endpoint validation failed"))); - assert.ok(payload.lines.some((line) => line.includes("Please enter a different Other Anthropic-compatible endpoint model name."))); - assert.equal(payload.messages.filter((message) => /Anthropic-compatible base URL/.test(message)).length, 1); - assert.equal(payload.messages.filter((message) => /Other Anthropic-compatible endpoint model/.test(message)).length, 2); + assert.ok( + payload.lines.some((line) => + line.includes("Other Anthropic-compatible endpoint endpoint validation failed"), + ), + ); + assert.ok( + payload.lines.some((line) => + line.includes("Please enter a different Other Anthropic-compatible endpoint model name."), + ), + ); + assert.equal( + payload.messages.filter((message) => /Anthropic-compatible base URL/.test(message)).length, + 1, + ); + assert.equal( + payload.messages.filter((message) => + /Other Anthropic-compatible endpoint model/.test(message), + ).length, + 2, + ); assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); }); @@ -1468,7 +1533,7 @@ done printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -1523,7 +1588,10 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.provider, "nvidia-prod"); assert.ok(payload.lines.some((line) => line.includes("Returning to provider selection."))); assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 2); - assert.equal(payload.messages.filter((message) => /OpenAI-compatible base URL/.test(message)).length, 1); + assert.equal( + payload.messages.filter((message) => /OpenAI-compatible base URL/.test(message)).length, + 1, + ); }); it("lets users type back after a transport validation failure to return to provider selection", () => { @@ -1554,7 +1622,7 @@ fi printf '%s' '{"id":"resp_123"}' > "$outfile" printf '200' `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -1607,9 +1675,16 @@ const { setupNim } = require(${onboardPath}); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.provider, "nvidia-prod"); - assert.ok(payload.lines.some((line) => line.includes("could not resolve the provider hostname"))); + assert.ok( + payload.lines.some((line) => line.includes("could not resolve the provider hostname")), + ); assert.ok(payload.lines.some((line) => line.includes("Returning to provider selection."))); - assert.equal(payload.messages.filter((message) => /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message)).length, 1); + assert.equal( + payload.messages.filter((message) => + /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message), + ).length, + 1, + ); assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 2); }); @@ -1646,7 +1721,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -1745,7 +1820,7 @@ fi printf '%s' "$body" > "$outfile" printf '%s' "$status" `, - { mode: 0o755 } + { mode: 0o755 }, ); const script = String.raw` @@ -1801,8 +1876,15 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.key, "nvapi-good"); assert.ok(payload.lines.some((line) => line.includes("NVIDIA Endpoints authorization failed"))); assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); - assert.equal(payload.messages.filter((message) => /Choose model \[1\]/.test(message)).length, 1); - assert.ok(payload.messages.some((message) => /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message))); + assert.equal( + payload.messages.filter((message) => /Choose model \[1\]/.test(message)).length, + 1, + ); + assert.ok( + payload.messages.some((message) => + /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message), + ), + ); assert.ok(payload.messages.some((message) => /NVIDIA Endpoints API key: /.test(message))); }); @@ -1871,10 +1953,17 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.preferredInferenceApi, "openai-responses"); assert.equal(payload.key, "sk-good"); assert.ok(payload.lines.some((line) => line.includes("OpenAI authorization failed"))); - assert.ok(payload.messages.some((message) => /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message))); + assert.ok( + payload.messages.some((message) => + /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message), + ), + ); assert.ok(payload.messages.some((message) => /OpenAI API key: /.test(message))); assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); - assert.equal(payload.messages.filter((message) => /Choose model \[1\]/.test(message)).length, 2); + assert.equal( + payload.messages.filter((message) => /Choose model \[1\]/.test(message)).length, + 2, + ); }); it("lets users re-enter an Anthropic API key after authorization failure", () => { @@ -1942,10 +2031,17 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.preferredInferenceApi, "anthropic-messages"); assert.equal(payload.key, "anthropic-good"); assert.ok(payload.lines.some((line) => line.includes("Anthropic authorization failed"))); - assert.ok(payload.messages.some((message) => /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message))); + assert.ok( + payload.messages.some((message) => + /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message), + ), + ); assert.ok(payload.messages.some((message) => /Anthropic API key: /.test(message))); assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); - assert.equal(payload.messages.filter((message) => /Choose model \[1\]/.test(message)).length, 2); + assert.equal( + payload.messages.filter((message) => /Choose model \[1\]/.test(message)).length, + 2, + ); }); it("lets users re-enter a Gemini API key after authorization failure", () => { @@ -2013,15 +2109,24 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.preferredInferenceApi, "openai-responses"); assert.equal(payload.key, "gemini-good"); assert.ok(payload.lines.some((line) => line.includes("Google Gemini authorization failed"))); - assert.ok(payload.messages.some((message) => /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message))); + assert.ok( + payload.messages.some((message) => + /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message), + ), + ); assert.ok(payload.messages.some((message) => /Google Gemini API key: /.test(message))); assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); - assert.equal(payload.messages.filter((message) => /Choose model \[5\]/.test(message)).length, 2); + assert.equal( + payload.messages.filter((message) => /Choose model \[5\]/.test(message)).length, + 2, + ); }); it("lets users re-enter a custom OpenAI-compatible API key without re-entering the endpoint URL", () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-custom-openai-auth-retry-")); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-custom-openai-auth-retry-"), + ); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "custom-openai-auth-retry-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); @@ -2084,17 +2189,38 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.endpointUrl, "https://proxy.example.com/v1"); assert.equal(payload.result.preferredInferenceApi, "openai-responses"); assert.equal(payload.key, "proxy-good"); - assert.ok(payload.lines.some((line) => line.includes("Other OpenAI-compatible endpoint authorization failed"))); - assert.ok(payload.messages.some((message) => /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message))); - assert.ok(payload.messages.some((message) => /Other OpenAI-compatible endpoint API key: /.test(message))); - assert.equal(payload.messages.filter((message) => /OpenAI-compatible base URL/.test(message)).length, 1); - assert.equal(payload.messages.filter((message) => /Other OpenAI-compatible endpoint model/.test(message)).length, 2); + assert.ok( + payload.lines.some((line) => + line.includes("Other OpenAI-compatible endpoint authorization failed"), + ), + ); + assert.ok( + payload.messages.some((message) => + /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message), + ), + ); + assert.ok( + payload.messages.some((message) => + /Other OpenAI-compatible endpoint API key: /.test(message), + ), + ); + assert.equal( + payload.messages.filter((message) => /OpenAI-compatible base URL/.test(message)).length, + 1, + ); + assert.equal( + payload.messages.filter((message) => /Other OpenAI-compatible endpoint model/.test(message)) + .length, + 2, + ); assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); }); it("lets users re-enter a custom Anthropic-compatible API key without re-entering the endpoint URL", () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-custom-anthropic-auth-retry-")); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-custom-anthropic-auth-retry-"), + ); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "custom-anthropic-auth-retry-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); @@ -2157,11 +2283,31 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.endpointUrl, "https://proxy.example.com"); assert.equal(payload.result.preferredInferenceApi, "anthropic-messages"); assert.equal(payload.key, "anthropic-proxy-good"); - assert.ok(payload.lines.some((line) => line.includes("Other Anthropic-compatible endpoint authorization failed"))); - assert.ok(payload.messages.some((message) => /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message))); - assert.ok(payload.messages.some((message) => /Other Anthropic-compatible endpoint API key: /.test(message))); - assert.equal(payload.messages.filter((message) => /Anthropic-compatible base URL/.test(message)).length, 1); - assert.equal(payload.messages.filter((message) => /Other Anthropic-compatible endpoint model/.test(message)).length, 2); + assert.ok( + payload.lines.some((line) => + line.includes("Other Anthropic-compatible endpoint authorization failed"), + ), + ); + assert.ok( + payload.messages.some((message) => + /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message), + ), + ); + assert.ok( + payload.messages.some((message) => + /Other Anthropic-compatible endpoint API key: /.test(message), + ), + ); + assert.equal( + payload.messages.filter((message) => /Anthropic-compatible base URL/.test(message)).length, + 1, + ); + assert.equal( + payload.messages.filter((message) => + /Other Anthropic-compatible endpoint model/.test(message), + ).length, + 2, + ); assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); }); diff --git a/test/onboard-session.test.js b/test/onboard-session.test.js index 08dc5d30f..43537b532 100644 --- a/test/onboard-session.test.js +++ b/test/onboard-session.test.js @@ -56,7 +56,7 @@ describe("onboard session", () => { const loaded = session.loadSession(); expect(loaded.endpointUrl).toBe( - "https://example.com/v1/models?token=%3CREDACTED%3E&sig=%3CREDACTED%3E&X-Amz-Signature=%3CREDACTED%3E&keep=yes" + "https://example.com/v1/models?token=%3CREDACTED%3E&sig=%3CREDACTED%3E&X-Amz-Signature=%3CREDACTED%3E&keep=yes", ); expect(session.summarizeForDebug().endpointUrl).toBe(loaded.endpointUrl); }); @@ -151,8 +151,12 @@ describe("onboard session", () => { fs.mkdirSync(path.dirname(session.LOCK_FILE), { recursive: true }); fs.writeFileSync( session.LOCK_FILE, - JSON.stringify({ pid: 999999, startedAt: "2026-03-25T00:00:00.000Z", command: "nemoclaw onboard" }), - { mode: 0o600 } + JSON.stringify({ + pid: 999999, + startedAt: "2026-03-25T00:00:00.000Z", + command: "nemoclaw onboard", + }), + { mode: 0o600 }, ); const acquired = session.acquireOnboardLock("nemoclaw onboard --resume"); @@ -184,7 +188,7 @@ describe("onboard session", () => { session.saveSession(session.createSession()); session.markStepFailed( "inference", - "provider auth failed with NVIDIA_API_KEY=nvapi-secret Bearer topsecret sk-secret-value ghp_1234567890123456789012345" + "provider auth failed with NVIDIA_API_KEY=nvapi-secret Bearer topsecret sk-secret-value ghp_1234567890123456789012345", ); const loaded = session.loadSession(); diff --git a/test/onboard.test.js b/test/onboard.test.js index ffc027aa7..820a0fae1 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -37,16 +37,16 @@ import { describe("onboard helpers", () => { it("classifies sandbox create timeout failures and tracks upload progress", () => { expect( - classifySandboxCreateFailure("Error: failed to read image export stream\nTimeout error").kind + classifySandboxCreateFailure("Error: failed to read image export stream\nTimeout error").kind, ).toBe("image_transfer_timeout"); expect( classifySandboxCreateFailure( [ - " Pushing image openshell/sandbox-from:123 into gateway \"nemoclaw\"", + ' Pushing image openshell/sandbox-from:123 into gateway "nemoclaw"', " [progress] Uploaded to gateway", "Error: failed to read image export stream", - ].join("\n") - ) + ].join("\n"), + ), ).toEqual({ kind: "image_transfer_timeout", uploadedToGateway: true, @@ -54,15 +54,17 @@ describe("onboard helpers", () => { }); it("classifies sandbox create connection resets and incomplete create streams", () => { - expect(classifySandboxCreateFailure("Connection reset by peer").kind).toBe("image_transfer_reset"); + expect(classifySandboxCreateFailure("Connection reset by peer").kind).toBe( + "image_transfer_reset", + ); expect( classifySandboxCreateFailure( [ " Image openshell/sandbox-from:123 is available in the gateway.", "Created sandbox: my-assistant", "Error: stream closed unexpectedly", - ].join("\n") - ) + ].join("\n"), + ), ).toEqual({ kind: "sandbox_create_incomplete", uploadedToGateway: true, @@ -100,11 +102,17 @@ describe("onboard helpers", () => { "ARG CHAT_UI_URL=http://127.0.0.1:18789", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", - ].join("\n") + ].join("\n"), ); try { - patchStagedDockerfile(dockerfilePath, "gpt-5.4", "http://127.0.0.1:19999", "build-123", "openai-api"); + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:19999", + "build-123", + "openai-api", + ); const patched = fs.readFileSync(dockerfilePath, "utf8"); assert.match(patched, /^ARG NEMOCLAW_MODEL=gpt-5\.4$/m); assert.match(patched, /^ARG NEMOCLAW_PROVIDER_KEY=openai$/m); @@ -125,7 +133,7 @@ describe("onboard helpers", () => { inferenceBaseUrl: "https://inference.local/v1", inferenceApi: "openai-completions", inferenceCompat: null, - } + }, ); }); @@ -134,22 +142,22 @@ describe("onboard helpers", () => { classifyValidationFailure({ httpStatus: 404, message: "HTTP 404: model not found", - }) + }), ).toEqual({ kind: "model", retry: "model" }); expect( classifyValidationFailure({ httpStatus: 405, message: "HTTP 405: unsupported model", - }) + }), ).toEqual({ kind: "model", retry: "model" }); }); it("normalizes anthropic-compatible base URLs with a trailing /v1", () => { expect(normalizeProviderBaseUrl("https://proxy.example.com/v1", "anthropic")).toBe( - "https://proxy.example.com" + "https://proxy.example.com", ); expect(normalizeProviderBaseUrl("https://proxy.example.com/v1/messages", "anthropic")).toBe( - "https://proxy.example.com" + "https://proxy.example.com", ); }); @@ -170,7 +178,9 @@ describe("onboard helpers", () => { it("prints platform-appropriate service hints for port conflicts", () => { expect(getPortConflictServiceHints("darwin").join("\n")).toMatch(/launchctl unload/); expect(getPortConflictServiceHints("darwin").join("\n")).not.toMatch(/systemctl --user/); - expect(getPortConflictServiceHints("linux").join("\n")).toMatch(/systemctl --user stop openclaw-gateway.service/); + expect(getPortConflictServiceHints("linux").join("\n")).toMatch( + /systemctl --user stop openclaw-gateway.service/, + ); }); it("patches the staged Dockerfile for Anthropic with anthropic-messages routing", () => { @@ -187,7 +197,7 @@ describe("onboard helpers", () => { "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", - ].join("\n") + ].join("\n"), ); try { @@ -196,7 +206,7 @@ describe("onboard helpers", () => { "claude-sonnet-4-5", "http://127.0.0.1:18789", "build-claude", - "anthropic-prod" + "anthropic-prod", ); const patched = fs.readFileSync(dockerfilePath, "utf8"); assert.match(patched, /^ARG NEMOCLAW_MODEL=claude-sonnet-4-5$/m); @@ -210,39 +220,37 @@ describe("onboard helpers", () => { }); it("maps Gemini to the routed inference provider with supportsStore disabled", () => { - assert.deepEqual( - getSandboxInferenceConfig("gemini-2.5-flash", "gemini-api"), - { - providerKey: "inference", - primaryModelRef: "inference/gemini-2.5-flash", - inferenceBaseUrl: "https://inference.local/v1", - inferenceApi: "openai-completions", - inferenceCompat: { - supportsStore: false, - }, - } - ); + assert.deepEqual(getSandboxInferenceConfig("gemini-2.5-flash", "gemini-api"), { + providerKey: "inference", + primaryModelRef: "inference/gemini-2.5-flash", + inferenceBaseUrl: "https://inference.local/v1", + inferenceApi: "openai-completions", + inferenceCompat: { + supportsStore: false, + }, + }); }); it("uses a probed Responses API override when one is available", () => { - assert.deepEqual( - getSandboxInferenceConfig("gpt-5.4", "openai-api", "openai-responses"), - { - providerKey: "openai", - primaryModelRef: "openai/gpt-5.4", - inferenceBaseUrl: "https://inference.local/v1", - inferenceApi: "openai-responses", - inferenceCompat: null, - } - ); + assert.deepEqual(getSandboxInferenceConfig("gpt-5.4", "openai-api", "openai-responses"), { + providerKey: "openai", + primaryModelRef: "openai/gpt-5.4", + inferenceBaseUrl: "https://inference.local/v1", + inferenceApi: "openai-responses", + inferenceCompat: null, + }); }); it("pins the gateway image to the installed OpenShell release version", () => { expect(getInstalledOpenshellVersion("openshell 0.0.12")).toBe("0.0.12"); expect(getInstalledOpenshellVersion("openshell 0.0.13-dev.8+gbbcaed2ea")).toBe("0.0.13"); expect(getInstalledOpenshellVersion("bogus")).toBe(null); - expect(getStableGatewayImageRef("openshell 0.0.12")).toBe("ghcr.io/nvidia/openshell/cluster:0.0.12"); - expect(getStableGatewayImageRef("openshell 0.0.13-dev.8+gbbcaed2ea")).toBe("ghcr.io/nvidia/openshell/cluster:0.0.13"); + expect(getStableGatewayImageRef("openshell 0.0.12")).toBe( + "ghcr.io/nvidia/openshell/cluster:0.0.12", + ); + expect(getStableGatewayImageRef("openshell 0.0.13-dev.8+gbbcaed2ea")).toBe( + "ghcr.io/nvidia/openshell/cluster:0.0.13", + ); expect(getStableGatewayImageRef("bogus")).toBe(null); }); @@ -251,22 +259,22 @@ describe("onboard helpers", () => { isGatewayHealthy( "Gateway status: Connected\nGateway: nemoclaw", "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", - "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080" - ) + "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", + ), ).toBe(true); expect( isGatewayHealthy( "\u001b[1mServer Status\u001b[0m\n\n Gateway: openshell\n Server: https://127.0.0.1:8080\n Status: Connected", "Error: × No gateway metadata found for 'nemoclaw'.", - "Gateway Info\n\n Gateway: openshell\n Gateway endpoint: https://127.0.0.1:8080" - ) + "Gateway Info\n\n Gateway: openshell\n Gateway endpoint: https://127.0.0.1:8080", + ), ).toBe(false); expect( isGatewayHealthy( "Server Status\n\n Gateway: openshell\n Status: Connected", "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", - "Gateway Info\n\n Gateway: openshell\n Gateway endpoint: https://127.0.0.1:8080" - ) + "Gateway Info\n\n Gateway: openshell\n Gateway endpoint: https://127.0.0.1:8080", + ), ).toBe(false); expect(isGatewayHealthy("Gateway status: Disconnected", "Gateway: nemoclaw")).toBe(false); expect(isGatewayHealthy("Gateway status: Connected", "Gateway: something-else")).toBe(false); @@ -277,42 +285,42 @@ describe("onboard helpers", () => { getGatewayReuseState( "Gateway status: Connected\nGateway: nemoclaw", "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", - "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080" - ) + "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", + ), ).toBe("healthy"); expect( getGatewayReuseState( "Gateway status: Connected", "Error: × No gateway metadata found for 'nemoclaw'.", - "Gateway Info\n\n Gateway: openshell\n Gateway endpoint: https://127.0.0.1:8080" - ) + "Gateway Info\n\n Gateway: openshell\n Gateway endpoint: https://127.0.0.1:8080", + ), ).toBe("foreign-active"); expect( getGatewayReuseState( "Server Status\n\n Gateway: openshell\n Status: Connected", "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", - "Gateway Info\n\n Gateway: openshell\n Gateway endpoint: https://127.0.0.1:8080" - ) + "Gateway Info\n\n Gateway: openshell\n Gateway endpoint: https://127.0.0.1:8080", + ), ).toBe("foreign-active"); expect( getGatewayReuseState( "Gateway status: Disconnected", - "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080" - ) + "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", + ), ).toBe("stale"); expect( getGatewayReuseState( "Gateway status: Connected\nGateway: nemoclaw", "", - "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080" - ) + "Gateway Info\n\n Gateway: nemoclaw\n Gateway endpoint: https://127.0.0.1:8080", + ), ).toBe("active-unnamed"); expect( getGatewayReuseState( "Gateway status: Connected", "", - "Gateway Info\n\n Gateway: openshell\n Gateway endpoint: https://127.0.0.1:8080" - ) + "Gateway Info\n\n Gateway: openshell\n Gateway endpoint: https://127.0.0.1:8080", + ), ).toBe("foreign-active"); expect(getGatewayReuseState("", "")).toBe("missing"); }); @@ -322,15 +330,15 @@ describe("onboard helpers", () => { getSandboxStateFromOutputs( "my-assistant", "Name: my-assistant", - "my-assistant Ready 2m ago" - ) + "my-assistant Ready 2m ago", + ), ).toBe("ready"); expect( getSandboxStateFromOutputs( "my-assistant", "Name: my-assistant", - "my-assistant NotReady init failed" - ) + "my-assistant NotReady init failed", + ), ).toBe("not_ready"); expect(getSandboxStateFromOutputs("my-assistant", "", "")).toBe("missing"); }); @@ -339,26 +347,26 @@ describe("onboard helpers", () => { expect( shouldIncludeBuildContextPath( "/repo/nemoclaw-blueprint", - "/repo/nemoclaw-blueprint/orchestrator/main.py" - ) + "/repo/nemoclaw-blueprint/orchestrator/main.py", + ), ).toBe(true); expect( shouldIncludeBuildContextPath( "/repo/nemoclaw-blueprint", - "/repo/nemoclaw-blueprint/.venv/bin/python" - ) + "/repo/nemoclaw-blueprint/.venv/bin/python", + ), ).toBe(false); expect( shouldIncludeBuildContextPath( "/repo/nemoclaw-blueprint", - "/repo/nemoclaw-blueprint/.ruff_cache/cache" - ) + "/repo/nemoclaw-blueprint/.ruff_cache/cache", + ), ).toBe(false); expect( shouldIncludeBuildContextPath( "/repo/nemoclaw-blueprint", - "/repo/nemoclaw-blueprint/._pyvenv.cfg" - ) + "/repo/nemoclaw-blueprint/._pyvenv.cfg", + ), ).toBe(false); }); @@ -434,8 +442,8 @@ describe("onboard helpers", () => { provider: "nvidia-nim", model: "nvidia/nemotron-3-super-120b-a12b", }, - { nonInteractive: true } - ) + { nonInteractive: true }, + ), ).toEqual([ { field: "provider", @@ -464,13 +472,16 @@ describe("onboard helpers", () => { it("returns a future-shell PATH hint for user-local openshell installs", () => { expect(getFutureShellPathHint("/home/test/.local/bin", "/usr/local/bin:/usr/bin")).toBe( - 'export PATH="/home/test/.local/bin:$PATH"' + 'export PATH="/home/test/.local/bin:$PATH"', ); }); it("skips the future-shell PATH hint when the bin dir is already on PATH", () => { expect( - getFutureShellPathHint("/home/test/.local/bin", "/home/test/.local/bin:/usr/local/bin:/usr/bin") + getFutureShellPathHint( + "/home/test/.local/bin", + "/home/test/.local/bin:/usr/local/bin:/usr/bin", + ), ).toBe(null); }); @@ -499,7 +510,9 @@ describe("onboard helpers", () => { const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -582,7 +595,7 @@ EOF fi exit 1 `, - { mode: 0o755 } + { mode: 0o755 }, ); fs.writeFileSync( @@ -594,7 +607,7 @@ console.log(JSON.stringify({ otherModel: isInferenceRouteReady("nvidia-prod", "nvidia/other-model"), otherProvider: isInferenceRouteReady("openai-api", "nvidia/nemotron-3-super-120b-a12b"), })); -` +`, ); const result = spawnSync(process.execPath, [scriptPath], { @@ -638,7 +651,7 @@ EOF fi exit 1 `, - { mode: 0o755 } + { mode: 0o755 }, ); fs.writeFileSync( @@ -648,7 +661,7 @@ const { isOpenclawReady } = require(${onboardPath}); console.log(JSON.stringify({ ready: isOpenclawReady("my-assistant"), })); -` +`, ); const result = spawnSync(process.execPath, [scriptPath], { @@ -690,8 +703,8 @@ console.log(JSON.stringify({ defaultSandbox: "my-assistant", }, null, - 2 - ) + 2, + ), ); fs.writeFileSync( @@ -703,7 +716,7 @@ console.log(JSON.stringify({ missing: arePolicyPresetsApplied("my-assistant", ["pypi", "slack"]), empty: arePolicyPresetsApplied("my-assistant", []), })); -` +`, ); const result = spawnSync(process.execPath, [scriptPath], { @@ -738,7 +751,9 @@ console.log(JSON.stringify({ const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -808,7 +823,9 @@ const { setupInference } = require(${onboardPath}); const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -881,7 +898,9 @@ const { setupInference } = require(${onboardPath}); const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -964,7 +983,9 @@ const { setupInference } = require(${onboardPath}); const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -1012,14 +1033,14 @@ const { setupInference } = require(${onboardPath}); assert.deepEqual(payload.result, { retry: "selection" }); assert.equal( payload.commands.filter((entry) => entry.command.includes("'inference' 'set'")).length, - 1 + 1, ); }); it("uses split curl timeout args and does not mislabel curl usage errors as timeouts", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), - "utf-8" + "utf-8", ); assert.match(source, /return \["--connect-timeout", "10", "--max-time", "60"\];/); @@ -1030,7 +1051,7 @@ const { setupInference } = require(${onboardPath}); it("suppresses expected provider-create AlreadyExists noise when update succeeds", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), - "utf-8" + "utf-8", ); assert.match(source, /stdio: \["ignore", "pipe", "pipe"\]/); @@ -1041,32 +1062,35 @@ const { setupInference } = require(${onboardPath}); it("starts the sandbox step before prompting for the sandbox name", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), - "utf-8" + "utf-8", ); assert.match( source, - /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*sandboxName = await createSandbox\(gpu, model, provider, preferredInferenceApi, sandboxName\);/ + /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*sandboxName = await createSandbox\(gpu, model, provider, preferredInferenceApi, sandboxName\);/, ); }); it("prints numbered step headers even when onboarding skips resumed steps", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), - "utf-8" + "utf-8", ); assert.match(source, /const ONBOARD_STEP_INDEX = \{/); assert.match(source, /function skippedStepMessage\(stepName, detail, reason = "resume"\)/); assert.match(source, /step\(stepInfo\.number, 7, stepInfo\.title\);/); assert.match(source, /skippedStepMessage\("openclaw", sandboxName\)/); - assert.match(source, /skippedStepMessage\("policies", \(recordedPolicyPresets \|\| \[\]\)\.join\(", "\)\)/); + assert.match( + source, + /skippedStepMessage\("policies", \(recordedPolicyPresets \|\| \[\]\)\.join\(", "\)\)/, + ); }); it("surfaces sandbox-create phases and silence heartbeats during long image operations", () => { const source = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), - "utf-8" + "utf-8", ); assert.match(source, /function setPhase\(nextPhase\)/); @@ -1074,7 +1098,10 @@ const { setupInference } = require(${onboardPath}); assert.match(source, /Uploading image into OpenShell gateway\.\.\./); assert.match(source, /Creating sandbox in gateway\.\.\./); assert.match(source, /Still building sandbox image\.\.\. \(\$\{elapsed\}s elapsed\)/); - assert.match(source, /Still uploading image into OpenShell gateway\.\.\. \(\$\{elapsed\}s elapsed\)/); + assert.match( + source, + /Still uploading image into OpenShell gateway\.\.\. \(\$\{elapsed\}s elapsed\)/, + ); }); it("hydrates stored provider credentials when setupInference runs without process env set", () => { @@ -1088,7 +1115,9 @@ const { setupInference } = require(${onboardPath}); const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -1157,7 +1186,9 @@ const { setupInference } = require(${onboardPath}); const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const registry = require(${registryPath}); @@ -1208,7 +1239,9 @@ console.log(JSON.stringify({ liveExists, sandbox: registry.getSandbox("my-assist const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -1280,7 +1313,9 @@ const { createSandbox } = require(${onboardPath}); assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); const payload = JSON.parse(payloadLine); assert.equal(payload.sandboxName, "my-assistant"); - const createCommand = payload.commands.find((entry) => entry.command.includes("'sandbox' 'create'")); + const createCommand = payload.commands.find((entry) => + entry.command.includes("'sandbox' 'create'"), + ); assert.ok(createCommand, "expected sandbox create command"); assert.match(createCommand.command, /'nemoclaw-start'/); assert.doesNotMatch(createCommand.command, /'--upload'/); @@ -1289,8 +1324,10 @@ const { createSandbox } = require(${onboardPath}); assert.doesNotMatch(createCommand.command, /DISCORD_BOT_TOKEN=/); assert.doesNotMatch(createCommand.command, /SLACK_BOT_TOKEN=/); assert.ok( - payload.commands.some((entry) => entry.command.includes("'forward' 'start' '--background' '18789' 'my-assistant'")), - "expected default loopback dashboard forward" + payload.commands.some((entry) => + entry.command.includes("'forward' 'start' '--background' '18789' 'my-assistant'"), + ), + "expected default loopback dashboard forward", ); }); @@ -1306,7 +1343,9 @@ const { createSandbox } = require(${onboardPath}); const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -1373,9 +1412,9 @@ const { createSandbox } = require(${onboardPath}); const commands = JSON.parse(result.stdout.trim().split("\n").pop()); assert.ok( commands.some((entry) => - entry.command.includes("'forward' 'start' '--background' '0.0.0.0:18789' 'my-assistant'") + entry.command.includes("'forward' 'start' '--background' '0.0.0.0:18789' 'my-assistant'"), ), - "expected remote dashboard forward target" + "expected remote dashboard forward target", ); }); @@ -1392,7 +1431,9 @@ const { createSandbox } = require(${onboardPath}); const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -1508,7 +1549,9 @@ const { createSandbox } = require(${onboardPath}); const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -1556,13 +1599,13 @@ const { createSandbox } = require(${onboardPath}); assert.equal(payload.sandboxName, "my-assistant"); assert.ok( payload.commands.some((entry) => - entry.command.includes("'forward' 'start' '--background' '0.0.0.0:18789' 'my-assistant'") + entry.command.includes("'forward' 'start' '--background' '0.0.0.0:18789' 'my-assistant'"), ), - "expected dashboard forward restore on sandbox reuse" + "expected dashboard forward restore on sandbox reuse", ); assert.ok( payload.commands.every((entry) => !entry.command.includes("'sandbox' 'create'")), - "did not expect sandbox create when reusing existing sandbox" + "did not expect sandbox create when reusing existing sandbox", ); }); @@ -1577,7 +1620,7 @@ const { createSandbox } = require(${onboardPath}); " [progress] Uploaded to gateway", "Error: failed to read image export stream", "Timeout error", - ].join("\n") + ].join("\n"), ); } finally { console.error = originalError; @@ -1588,7 +1631,7 @@ const { createSandbox } = require(${onboardPath}); assert.match(joined, /Recovery: nemoclaw onboard --resume/); assert.match( joined, - /Progress reached the gateway upload stage, so resume may be able to reuse existing gateway state\./ + /Progress reached the gateway upload stage, so resume may be able to reuse existing gateway state\./, ); }); @@ -1602,7 +1645,7 @@ const { createSandbox } = require(${onboardPath}); " Pushing image openshell/sandbox-from:123 into gateway nemoclaw", " [progress] Uploaded to gateway", "Error: Connection reset by peer", - ].join("\n") + ].join("\n"), ); } finally { console.error = originalError; @@ -1611,7 +1654,10 @@ const { createSandbox } = require(${onboardPath}); const joined = errors.join("\n"); assert.match(joined, /Hint: the image push\/import stream was interrupted\./); assert.match(joined, /Recovery: nemoclaw onboard --resume/); - assert.match(joined, /The image appears to have reached the gateway before the stream failed\./); + assert.match( + joined, + /The image appears to have reached the gateway before the stream failed\./, + ); }); it("accepts gateway inference when system inference is separately not configured", () => { @@ -1624,7 +1670,9 @@ const { createSandbox } = require(${onboardPath}); const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -1692,7 +1740,9 @@ const { setupInference } = require(${onboardPath}); const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); const script = String.raw` const runner = require(${runnerPath}); @@ -1748,5 +1798,4 @@ const { setupInference } = require(${onboardPath}); const commands = JSON.parse(result.stdout.trim().split("\n").pop()); assert.equal(commands.length, 3); }); - }); diff --git a/test/platform.test.js b/test/platform.test.js index 8bbc14c0b..f76e2ffb2 100644 --- a/test/platform.test.js +++ b/test/platform.test.js @@ -20,28 +20,34 @@ import { describe("platform helpers", () => { describe("isWsl", () => { it("detects WSL from environment", () => { - expect(isWsl({ - platform: "linux", - env: { WSL_DISTRO_NAME: "Ubuntu" }, - release: "6.6.87.2-microsoft-standard-WSL2", - })).toBe(true); + expect( + isWsl({ + platform: "linux", + env: { WSL_DISTRO_NAME: "Ubuntu" }, + release: "6.6.87.2-microsoft-standard-WSL2", + }), + ).toBe(true); }); it("does not treat macOS as WSL", () => { - expect(isWsl({ - platform: "darwin", - env: {}, - release: "24.6.0", - })).toBe(false); + expect( + isWsl({ + platform: "darwin", + env: {}, + release: "24.6.0", + }), + ).toBe(false); }); it("detects WSL from /proc version text even without WSL env vars", () => { - expect(isWsl({ - platform: "linux", - env: {}, - release: "6.6.87-generic", - procVersion: "Linux version 6.6.87.2-microsoft-standard-WSL2", - })).toBe(true); + expect( + isWsl({ + platform: "linux", + env: {}, + release: "6.6.87-generic", + procVersion: "Linux version 6.6.87.2-microsoft-standard-WSL2", + }), + ).toBe(true); }); }); @@ -75,11 +81,15 @@ describe("platform helpers", () => { const sockets = new Set([path.join(home, ".config/colima/default/docker.sock")]); const existsSync = (socketPath) => sockets.has(socketPath); - expect(findColimaDockerSocket({ home, existsSync })).toBe(path.join(home, ".config/colima/default/docker.sock")); + expect(findColimaDockerSocket({ home, existsSync })).toBe( + path.join(home, ".config/colima/default/docker.sock"), + ); }); it("returns null when no Colima socket exists", () => { - expect(findColimaDockerSocket({ home: "/tmp/test-home", existsSync: () => false })).toBeNull(); + expect( + findColimaDockerSocket({ home: "/tmp/test-home", existsSync: () => false }), + ).toBeNull(); }); it("uses fs.existsSync when no custom existsSync is provided", () => { @@ -94,12 +104,14 @@ describe("platform helpers", () => { describe("detectDockerHost", () => { it("respects an existing DOCKER_HOST", () => { - expect(detectDockerHost({ - env: { DOCKER_HOST: "unix:///custom/docker.sock" }, - platform: "darwin", - home: "/tmp/test-home", - existsSync: () => false, - })).toEqual({ + expect( + detectDockerHost({ + env: { DOCKER_HOST: "unix:///custom/docker.sock" }, + platform: "darwin", + home: "/tmp/test-home", + existsSync: () => false, + }), + ).toEqual({ dockerHost: "unix:///custom/docker.sock", source: "env", socketPath: null, @@ -134,12 +146,14 @@ describe("platform helpers", () => { }); it("returns null when no auto-detected socket is available", () => { - expect(detectDockerHost({ - env: {}, - platform: "linux", - home: "/tmp/test-home", - existsSync: () => false, - })).toBe(null); + expect( + detectDockerHost({ + env: {}, + platform: "linux", + home: "/tmp/test-home", + existsSync: () => false, + }), + ).toBe(null); }); it("uses fs.existsSync when no custom existsSync is provided", () => { diff --git a/test/policies.test.js b/test/policies.test.js index bf3f18db9..960bf5aed 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -83,28 +83,17 @@ describe("policies", () => { describe("buildPolicySetCommand", () => { it("shell-quotes sandbox name to prevent injection", () => { - const cmd = policies.buildPolicySetCommand( - "/tmp/policy.yaml", - "my-assistant", - ); - expect(cmd).toBe( - "openshell policy set --policy '/tmp/policy.yaml' --wait 'my-assistant'", - ); + const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); + expect(cmd).toBe("openshell policy set --policy '/tmp/policy.yaml' --wait 'my-assistant'"); }); it("escapes shell metacharacters in sandbox name", () => { - const cmd = policies.buildPolicySetCommand( - "/tmp/policy.yaml", - "test; whoami", - ); + const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "test; whoami"); expect(cmd.includes("'test; whoami'")).toBeTruthy(); }); it("places --wait before the sandbox name", () => { - const cmd = policies.buildPolicySetCommand( - "/tmp/policy.yaml", - "test-box", - ); + const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "test-box"); const waitIdx = cmd.indexOf("--wait"); const nameIdx = cmd.indexOf("'test-box'"); expect(waitIdx < nameIdx).toBeTruthy(); @@ -113,10 +102,7 @@ describe("policies", () => { it("uses the resolved openshell binary when provided by the installer path", () => { process.env.NEMOCLAW_OPENSHELL_BIN = "/tmp/fake path/openshell"; try { - const cmd = policies.buildPolicySetCommand( - "/tmp/policy.yaml", - "my-assistant", - ); + const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); assert.equal( cmd, "'/tmp/fake path/openshell' policy set --policy '/tmp/policy.yaml' --wait 'my-assistant'", @@ -130,9 +116,7 @@ describe("policies", () => { describe("buildPolicyGetCommand", () => { it("shell-quotes sandbox name", () => { const cmd = policies.buildPolicyGetCommand("my-assistant"); - expect(cmd).toBe( - "openshell policy get --full 'my-assistant' 2>/dev/null", - ); + expect(cmd).toBe("openshell policy get --full 'my-assistant' 2>/dev/null"); }); }); @@ -264,8 +248,7 @@ describe("policies", () => { }); it("appends preset entries when current policy has network_policies but no version", () => { - const versionlessWithNp = - "network_policies:\n - host: existing.com\n allow: true"; + const versionlessWithNp = "network_policies:\n - host: existing.com\n allow: true"; const merged = policies.mergePresetIntoPolicy(versionlessWithNp, sampleEntries); expect(merged.trimStart().startsWith("version: 1\n")).toBe(true); expect(merged).toContain("existing.com"); diff --git a/test/preflight.test.js b/test/preflight.test.js index 948c61cdd..5f9a738d8 100644 --- a/test/preflight.test.js +++ b/test/preflight.test.js @@ -153,7 +153,10 @@ describe("getMemoryInfo", () => { }); it("handles malformed /proc/meminfo gracefully", () => { - const result = getMemoryInfo({ meminfoContent: "garbage data\nno fields here", platform: "linux" }); + const result = getMemoryInfo({ + meminfoContent: "garbage data\nno fields here", + platform: "linux", + }); assert.equal(result.totalRamMB, 0); assert.equal(result.totalSwapMB, 0); assert.equal(result.totalMB, 0); diff --git a/test/registry.test.js b/test/registry.test.js index 25a3138f2..80fd0dded 100644 --- a/test/registry.test.js +++ b/test/registry.test.js @@ -149,7 +149,9 @@ describe("atomic writes", () => { // Stub renameSync so writeFileSync succeeds (temp file is created) // but the rename step throws — exercising the cleanup branch. const original = fs.renameSync; - fs.renameSync = () => { throw Object.assign(new Error("EACCES"), { code: "EACCES" }); }; + fs.renameSync = () => { + throw Object.assign(new Error("EACCES"), { code: "EACCES" }); + }; try { expect(() => registry.save({ sandboxes: {}, defaultSandbox: null })).toThrow("EACCES"); } finally { @@ -247,7 +249,9 @@ describe("advisory file locking", () => { it("concurrent writers do not corrupt the registry", () => { const { spawnSync } = require("child_process"); - const registryPath = path.resolve(path.join(import.meta.dirname, "..", "bin", "lib", "registry.js")); + const registryPath = path.resolve( + path.join(import.meta.dirname, "..", "bin", "lib", "registry.js"), + ); const homeDir = path.dirname(path.dirname(regFile)); // Script that spawns 4 workers in parallel, each writing 5 sandboxes const orchestrator = ` diff --git a/test/resolve-openshell.test.js b/test/resolve-openshell.test.js index daaf51544..7e2df8e1a 100644 --- a/test/resolve-openshell.test.js +++ b/test/resolve-openshell.test.js @@ -7,7 +7,9 @@ import { resolveOpenshell } from "../bin/lib/resolve-openshell"; describe("resolveOpenshell", () => { it("returns an absolute command -v result immediately", () => { - expect(resolveOpenshell({ commandVResult: "/usr/local/bin/openshell" })).toBe("/usr/local/bin/openshell"); + expect(resolveOpenshell({ commandVResult: "/usr/local/bin/openshell" })).toBe( + "/usr/local/bin/openshell", + ); }); it("ignores non-absolute command -v output and falls back to known locations", () => { @@ -16,7 +18,7 @@ describe("resolveOpenshell", () => { home: "/tmp/test-home", commandVResult: "openshell", checkExecutable: (candidate) => candidate === "/usr/local/bin/openshell", - }) + }), ).toBe("/usr/local/bin/openshell"); }); @@ -26,7 +28,7 @@ describe("resolveOpenshell", () => { home: "/tmp/test-home", commandVResult: "", checkExecutable: (candidate) => candidate === "/tmp/test-home/.local/bin/openshell", - }) + }), ).toBe("/tmp/test-home/.local/bin/openshell"); }); @@ -36,7 +38,7 @@ describe("resolveOpenshell", () => { home: "relative-home", commandVResult: null, checkExecutable: (candidate) => candidate === "/usr/bin/openshell", - }) + }), ).toBe("/usr/bin/openshell"); }); @@ -46,7 +48,7 @@ describe("resolveOpenshell", () => { home: "/tmp/test-home", commandVResult: "", checkExecutable: () => false, - }) + }), ).toBe(null); }); }); diff --git a/test/runner.test.js b/test/runner.test.js index f9222f0c4..7ef659110 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -63,7 +63,7 @@ describe("runner env merging", () => { const originalGateway = process.env.OPENSHELL_GATEWAY; process.env.OPENSHELL_GATEWAY = "nemoclaw"; try { - const output = runCapture("printf '%s %s' \"$OPENSHELL_GATEWAY\" \"$OPENAI_API_KEY\"", { + const output = runCapture('printf \'%s %s\' "$OPENSHELL_GATEWAY" "$OPENAI_API_KEY"', { env: { OPENAI_API_KEY: "sk-test-secret" }, }); expect(output).toBe("nemoclaw sk-test-secret"); @@ -90,7 +90,9 @@ describe("runner env merging", () => { delete require.cache[require.resolve(runnerPath)]; const { run } = require(runnerPath); process.env.PATH = "/usr/local/bin:/usr/bin"; - run("echo test", { env: { OPENSHELL_CLUSTER_IMAGE: "ghcr.io/nvidia/openshell/cluster:0.0.12" } }); + run("echo test", { + env: { OPENSHELL_CLUSTER_IMAGE: "ghcr.io/nvidia/openshell/cluster:0.0.12" }, + }); } finally { if (originalPath === undefined) { delete process.env.PATH; @@ -170,7 +172,10 @@ describe("validateName", () => { describe("regression guards", () => { it("nemoclaw.js does not use execSync", () => { - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), + "utf-8", + ); const lines = src.split("\n"); for (let i = 0; i < lines.length; i += 1) { if (lines[i].includes("execSync") && !lines[i].includes("execFileSync")) { @@ -205,15 +210,19 @@ describe("regression guards", () => { const canaryDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-canary-")); const canary = path.join(canaryDir, "executed"); try { - const result = spawnSync("node", [ - path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), - `test; touch ${canary}`, - "connect", - ], { - encoding: "utf-8", - timeout: 10000, - cwd: path.join(import.meta.dirname, ".."), - }); + const result = spawnSync( + "node", + [ + path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), + `test; touch ${canary}`, + "connect", + ], + { + encoding: "utf-8", + timeout: 10000, + cwd: path.join(import.meta.dirname, ".."), + }, + ); expect(result.status).not.toBe(0); expect(fs.existsSync(canary)).toBe(false); } finally { @@ -222,7 +231,10 @@ describe("regression guards", () => { }); it("telegram bridge validates SANDBOX_NAME on startup", () => { - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "telegram-bridge.js"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "telegram-bridge.js"), + "utf-8", + ); expect(src.includes("validateName(SANDBOX")).toBeTruthy(); expect(src.includes("execSync")).toBeFalsy(); }); @@ -230,7 +242,10 @@ describe("regression guards", () => { describe("credential exposure guards (#429)", () => { it("onboard createSandbox does not pass NVIDIA_API_KEY to sandbox env", () => { const fs = require("fs"); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); // Find the envArgs block in createSandbox — it should not contain NVIDIA_API_KEY const envArgsMatch = src.match(/const envArgs = \[[\s\S]*?\];/); expect(envArgsMatch).toBeTruthy(); @@ -239,13 +254,19 @@ describe("regression guards", () => { it("onboard clears NVIDIA_API_KEY from process.env after setupInference", () => { const fs = require("fs"); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); expect(src.includes("delete process.env.NVIDIA_API_KEY")).toBeTruthy(); }); it("setup.sh uses env-name-only form for nvidia-nim credential", () => { const fs = require("fs"); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "setup.sh"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "setup.sh"), + "utf-8", + ); // Should use "NVIDIA_API_KEY" (name only), not "NVIDIA_API_KEY=$NVIDIA_API_KEY" (value) const lines = src.split("\n"); for (const line of lines) { @@ -261,7 +282,10 @@ describe("regression guards", () => { it("setup.sh does not pass NVIDIA_API_KEY in sandbox create env args", () => { const fs = require("fs"); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "setup.sh"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "setup.sh"), + "utf-8", + ); // Find sandbox create command — should not have env NVIDIA_API_KEY const createLines = src.split("\n").filter((l) => l.includes("sandbox create")); for (const line of createLines) { @@ -271,11 +295,14 @@ describe("regression guards", () => { it("setupSpark does not pass NVIDIA_API_KEY to sudo", () => { const fs = require("fs"); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), "utf-8"); - // Find the run() call inside setupSpark — it should not contain the key - const sparkLines = src.split("\n").filter( - (l) => l.includes("setup-spark") && l.includes("run(") + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), + "utf-8", ); + // Find the run() call inside setupSpark — it should not contain the key + const sparkLines = src + .split("\n") + .filter((l) => l.includes("setup-spark") && l.includes("run(")); for (const line of sparkLines) { expect(line.includes("NVIDIA_API_KEY")).toBe(false); } @@ -283,19 +310,29 @@ describe("regression guards", () => { it("walkthrough.sh does not embed NVIDIA_API_KEY in tmux or sandbox commands", () => { const fs = require("fs"); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "walkthrough.sh"), "utf-8"); - // Check only executable lines (tmux spawn, openshell connect) — not comments/docs - const cmdLines = src.split("\n").filter( - (l) => !l.trim().startsWith("#") && !l.trim().startsWith("echo") && - (l.includes("tmux") || l.includes("openshell sandbox connect")) + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "walkthrough.sh"), + "utf-8", ); + // Check only executable lines (tmux spawn, openshell connect) — not comments/docs + const cmdLines = src + .split("\n") + .filter( + (l) => + !l.trim().startsWith("#") && + !l.trim().startsWith("echo") && + (l.includes("tmux") || l.includes("openshell sandbox connect")), + ); for (const line of cmdLines) { expect(line.includes("NVIDIA_API_KEY")).toBe(false); } }); it("install-openshell.sh verifies OpenShell binary checksum after download", () => { - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"), + "utf-8", + ); expect(src).toContain("openshell-checksums-sha256.txt"); expect(src).toContain("shasum -a 256 -c"); }); @@ -305,13 +342,14 @@ describe("regression guards", () => { // Strip comment lines, then join line continuations so multiline // curl ... |\n bash patterns are caught by the single-line regex. const stripComments = (src, commentPrefix) => - src.split("\n").filter((l) => !l.trim().startsWith(commentPrefix)).join("\n"); + src + .split("\n") + .filter((l) => !l.trim().startsWith(commentPrefix)) + .join("\n"); - const joinContinuations = (src) => - src.replace(/\\\n\s*/g, " "); + const joinContinuations = (src) => src.replace(/\\\n\s*/g, " "); - const collapseMultilinePipes = (src) => - src.replace(/\|\s*\n\s*/g, "| "); + const collapseMultilinePipes = (src) => src.replace(/\|\s*\n\s*/g, "| "); const normalize = (src, commentPrefix) => collapseMultilinePipes(joinContinuations(stripComments(src, commentPrefix))); @@ -343,17 +381,26 @@ describe("regression guards", () => { }); it("scripts/install.sh does not pipe curl to shell", () => { - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "install.sh"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "install.sh"), + "utf-8", + ); expect(findShellViolations(src)).toEqual([]); }); it("scripts/brev-setup.sh does not pipe curl to shell", () => { - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "brev-setup.sh"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "brev-setup.sh"), + "utf-8", + ); expect(findShellViolations(src)).toEqual([]); }); it("bin/nemoclaw.js does not pipe curl to shell", () => { - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), + "utf-8", + ); expect(findJsViolations(src)).toEqual([]); }); }); diff --git a/test/runtime-recovery.test.js b/test/runtime-recovery.test.js index b6870877c..c58b51af3 100644 --- a/test/runtime-recovery.test.js +++ b/test/runtime-recovery.test.js @@ -19,9 +19,9 @@ describe("runtime recovery helpers", () => { "NAME NAMESPACE CREATED PHASE", "alpha openshell 2026-03-24 10:00:00 Ready", "beta openshell 2026-03-24 10:01:00 Provisioning", - ].join("\n") - ) - ) + ].join("\n"), + ), + ), ).toEqual(["alpha", "beta"]); }); @@ -30,17 +30,23 @@ describe("runtime recovery helpers", () => { }); it("classifies missing sandbox lookups", () => { - expect(classifySandboxLookup('Error: × status: NotFound, message: "sandbox not found"').state).toBe("missing"); + expect( + classifySandboxLookup('Error: × status: NotFound, message: "sandbox not found"').state, + ).toBe("missing"); expect(classifySandboxLookup("").state).toBe("missing"); }); it("classifies transport and gateway failures as unavailable", () => { - expect(classifySandboxLookup("Error: × transport error\n ╰─▶ Connection reset by peer (os error 104)").state).toBe( - "unavailable" - ); - expect(classifySandboxLookup("Error: × client error (Connect)\n ╰─▶ Connection refused (os error 111)").state).toBe( - "unavailable" - ); + expect( + classifySandboxLookup( + "Error: × transport error\n ╰─▶ Connection reset by peer (os error 104)", + ).state, + ).toBe("unavailable"); + expect( + classifySandboxLookup( + "Error: × client error (Connect)\n ╰─▶ Connection refused (os error 111)", + ).state, + ).toBe("unavailable"); }); it("classifies successful sandbox lookups as present", () => { @@ -53,8 +59,8 @@ describe("runtime recovery helpers", () => { " Name: my-assistant", " Namespace: openshell", " Phase: Ready", - ].join("\n") - ).state + ].join("\n"), + ).state, ).toBe("present"); }); @@ -65,10 +71,20 @@ describe("runtime recovery helpers", () => { }); it("only attempts gateway recovery when sandbox access is unavailable and gateway is down", () => { - expect(shouldAttemptGatewayRecovery({ sandboxState: "unavailable", gatewayState: "unavailable" })).toBe(true); - expect(shouldAttemptGatewayRecovery({ sandboxState: "unavailable", gatewayState: "inactive" })).toBe(true); - expect(shouldAttemptGatewayRecovery({ sandboxState: "present", gatewayState: "unavailable" })).toBe(false); - expect(shouldAttemptGatewayRecovery({ sandboxState: "missing", gatewayState: "inactive" })).toBe(false); - expect(shouldAttemptGatewayRecovery({ sandboxState: "unavailable", gatewayState: "connected" })).toBe(false); + expect( + shouldAttemptGatewayRecovery({ sandboxState: "unavailable", gatewayState: "unavailable" }), + ).toBe(true); + expect( + shouldAttemptGatewayRecovery({ sandboxState: "unavailable", gatewayState: "inactive" }), + ).toBe(true); + expect( + shouldAttemptGatewayRecovery({ sandboxState: "present", gatewayState: "unavailable" }), + ).toBe(false); + expect( + shouldAttemptGatewayRecovery({ sandboxState: "missing", gatewayState: "inactive" }), + ).toBe(false); + expect( + shouldAttemptGatewayRecovery({ sandboxState: "unavailable", gatewayState: "connected" }), + ).toBe(false); }); }); diff --git a/test/runtime-shell.test.js b/test/runtime-shell.test.js index 2e5702f22..4be87381c 100644 --- a/test/runtime-shell.test.js +++ b/test/runtime-shell.test.js @@ -58,7 +58,9 @@ describe("shell runtime helpers", () => { }); it("classifies a Docker Desktop DOCKER_HOST correctly", () => { - const result = runShell(`source "${RUNTIME_SH}"; docker_host_runtime "unix:///Users/test/.docker/run/docker.sock"`); + const result = runShell( + `source "${RUNTIME_SH}"; docker_host_runtime "unix:///Users/test/.docker/run/docker.sock"`, + ); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("docker-desktop"); @@ -98,7 +100,9 @@ describe("shell runtime helpers", () => { }); it("detects podman from docker info output", () => { - const result = runShell(`source "${RUNTIME_SH}"; infer_container_runtime_from_info "podman version 5.4.1"`); + const result = runShell( + `source "${RUNTIME_SH}"; infer_container_runtime_from_info "podman version 5.4.1"`, + ); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("podman"); }); diff --git a/test/security-binaries-restriction.test.js b/test/security-binaries-restriction.test.js index 6873e0bb9..648fc4689 100644 --- a/test/security-binaries-restriction.test.js +++ b/test/security-binaries-restriction.test.js @@ -5,8 +5,20 @@ import { describe, it, expect } from "vitest"; import fs from "node:fs"; import path from "node:path"; -const BASELINE = path.join(import.meta.dirname, "..", "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); -const PRESETS_DIR = path.join(import.meta.dirname, "..", "nemoclaw-blueprint", "policies", "presets"); +const BASELINE = path.join( + import.meta.dirname, + "..", + "nemoclaw-blueprint", + "policies", + "openclaw-sandbox.yaml", +); +const PRESETS_DIR = path.join( + import.meta.dirname, + "..", + "nemoclaw-blueprint", + "policies", + "presets", +); describe("binaries restriction: baseline policy", () => { it("every network_policies entry has a binaries section", () => { @@ -20,7 +32,10 @@ describe("binaries restriction: baseline policy", () => { for (let i = 0; i < lines.length; i++) { const line = lines[i]; - if (/^network_policies:/.test(line)) { inNetworkPolicies = true; continue; } + if (/^network_policies:/.test(line)) { + inNetworkPolicies = true; + continue; + } if (inNetworkPolicies && /^\S/.test(line) && line.trim() !== "") { if (currentBlock) blocks.push(currentBlock); currentBlock = null; @@ -40,15 +55,15 @@ describe("binaries restriction: baseline policy", () => { expect(blocks.length).toBeGreaterThan(0); - const violators = blocks.filter(b => !b.lines.some(l => /^\s+binaries:/.test(l))); + const violators = blocks.filter((b) => !b.lines.some((l) => /^\s+binaries:/.test(l))); - expect(violators.map(b => b.name)).toEqual([]); + expect(violators.map((b) => b.name)).toEqual([]); }); }); describe("binaries restriction: policy presets", () => { it("every preset YAML has a binaries section", () => { - const presets = fs.readdirSync(PRESETS_DIR).filter(f => f.endsWith(".yaml")); + const presets = fs.readdirSync(PRESETS_DIR).filter((f) => f.endsWith(".yaml")); expect(presets.length).toBeGreaterThan(0); const missing = []; diff --git a/test/security-c2-dockerfile-injection.test.js b/test/security-c2-dockerfile-injection.test.js index 285df61bd..00a9c51c7 100644 --- a/test/security-c2-dockerfile-injection.test.js +++ b/test/security-c2-dockerfile-injection.test.js @@ -76,7 +76,11 @@ describe("C-2 PoC: vulnerable pattern (ARG interpolation into python3 -c)", () = expect(fs.existsSync(canary)).toBeTruthy(); expect(fs.readFileSync(canary, "utf-8")).toBe("PWNED"); } finally { - try { fs.unlinkSync(canary); } catch { /* cleanup */ } + try { + fs.unlinkSync(canary); + } catch { + /* cleanup */ + } } }); }); @@ -106,7 +110,11 @@ describe("C-2 fix: env var pattern (os.environ) is safe", () => { expect(result.status).toBe(0); expect(fs.existsSync(canary)).toBe(false); } finally { - try { fs.unlinkSync(canary); } catch { /* cleanup */ } + try { + fs.unlinkSync(canary); + } catch { + /* cleanup */ + } } }); @@ -138,8 +146,8 @@ describe("C-2 regression: Dockerfile must not interpolate build-args into Python if (inPythonRunBlock && vulnerablePattern.test(line)) { expect.unreachable( `Dockerfile:${i + 1} interpolates CHAT_UI_URL into a Python string literal.\n` + - ` Line: ${line.trim()}\n` + - ` Fix: use os.environ['CHAT_UI_URL'] instead.` + ` Line: ${line.trim()}\n` + + ` Fix: use os.environ['CHAT_UI_URL'] instead.`, ); } if (inPythonRunBlock && !/\\\s*$/.test(line)) { @@ -161,8 +169,8 @@ describe("C-2 regression: Dockerfile must not interpolate build-args into Python if (inPythonRunBlock && vulnerablePattern.test(line)) { expect.unreachable( `Dockerfile:${i + 1} interpolates NEMOCLAW_MODEL into a Python string literal.\n` + - ` Line: ${line.trim()}\n` + - ` Fix: use os.environ['NEMOCLAW_MODEL'] instead.` + ` Line: ${line.trim()}\n` + + ` Fix: use os.environ['NEMOCLAW_MODEL'] instead.`, ); } if (inPythonRunBlock && !/\\\s*$/.test(line)) { diff --git a/test/security-c4-manifest-traversal.test.js b/test/security-c4-manifest-traversal.test.js index 21996bb71..74894bbbd 100644 --- a/test/security-c4-manifest-traversal.test.js +++ b/test/security-c4-manifest-traversal.test.js @@ -62,10 +62,7 @@ function buildSnapshotDir(parentDir, manifest) { path.join(snapshotDir, "config", "openclaw.json"), JSON.stringify({ model: "attacker-model" }), ); - fs.writeFileSync( - path.join(snapshotDir, "snapshot.json"), - JSON.stringify(manifest, null, 2), - ); + fs.writeFileSync(path.join(snapshotDir, "snapshot.json"), JSON.stringify(manifest, null, 2)); return snapshotDir; } @@ -74,9 +71,7 @@ function buildSnapshotDir(parentDir, manifest) { * Returns { result, errors, written }. */ function restoreVulnerable(snapshotDir) { - const manifest = JSON.parse( - fs.readFileSync(path.join(snapshotDir, "snapshot.json"), "utf-8"), - ); + const manifest = JSON.parse(fs.readFileSync(path.join(snapshotDir, "snapshot.json"), "utf-8")); const snapshotStateDir = path.join(snapshotDir, "openclaw"); const errors = []; let written = false; @@ -107,9 +102,7 @@ function restoreVulnerable(snapshotDir) { * @param {string} [trustedRoot] - trusted host root (defaults to os.homedir()) */ function restoreFixed(snapshotDir, trustedRoot) { - const manifest = JSON.parse( - fs.readFileSync(path.join(snapshotDir, "snapshot.json"), "utf-8"), - ); + const manifest = JSON.parse(fs.readFileSync(path.join(snapshotDir, "snapshot.json"), "utf-8")); const snapshotStateDir = path.join(snapshotDir, "openclaw"); const errors = []; let written = false; @@ -200,7 +193,9 @@ describe("C-4 PoC: vulnerable restoreSnapshotToHost allows path traversal", () = expect(result).toBeTruthy(); expect(written).toBeTruthy(); expect(fs.existsSync(path.join(traversalTarget, "sentinel.txt"))).toBeTruthy(); - expect(fs.readFileSync(path.join(traversalTarget, "sentinel.txt"), "utf-8")).toBe("attacker-controlled-content"); + expect(fs.readFileSync(path.join(traversalTarget, "sentinel.txt"), "utf-8")).toBe( + "attacker-controlled-content", + ); } finally { fs.rmSync(workDir, { recursive: true, force: true }); } @@ -443,7 +438,9 @@ describe("C-4 regression: migration-state.ts contains path validation", () => { it("restoreSnapshotToHost fails closed when hasExternalConfig is true with missing configPath", () => { const fnBody = getRestoreFnBody(); - expect(/manifest\.hasExternalConfig\b/.test(fnBody) && - /typeof\s+manifest\.configPath\s*!==\s*["']string["']/.test(fnBody)).toBeTruthy(); + expect( + /manifest\.hasExternalConfig\b/.test(fnBody) && + /typeof\s+manifest\.configPath\s*!==\s*["']string["']/.test(fnBody), + ).toBeTruthy(); }); }); diff --git a/test/security-method-wildcards.test.js b/test/security-method-wildcards.test.js index 33ab19bf0..17ead7346 100644 --- a/test/security-method-wildcards.test.js +++ b/test/security-method-wildcards.test.js @@ -5,10 +5,16 @@ import { describe, it, expect } from "vitest"; import fs from "node:fs"; import path from "node:path"; -const BASELINE = path.join(import.meta.dirname, "..", "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); +const BASELINE = path.join( + import.meta.dirname, + "..", + "nemoclaw-blueprint", + "policies", + "openclaw-sandbox.yaml", +); describe("method wildcards: baseline policy", () => { - it("no endpoint uses method: \"*\" wildcard", () => { + it('no endpoint uses method: "*" wildcard', () => { // method: "*" permits DELETE, PUT, PATCH which inference APIs do not // require. All endpoints should use explicit method rules (GET, POST). const yaml = fs.readFileSync(BASELINE, "utf-8"); diff --git a/test/service-env.test.js b/test/service-env.test.js index 3d9e39c75..0c5263b65 100644 --- a/test/service-env.test.js +++ b/test/service-env.test.js @@ -15,52 +15,66 @@ describe("service environment", () => { }); it("rejects non-absolute command -v result (alias)", () => { - expect( - resolveOpenshell({ commandVResult: "openshell", checkExecutable: () => false }) - ).toBe(null); + expect(resolveOpenshell({ commandVResult: "openshell", checkExecutable: () => false })).toBe( + null, + ); }); it("rejects alias definition from command -v", () => { expect( - resolveOpenshell({ commandVResult: "alias openshell='echo pwned'", checkExecutable: () => false }) + resolveOpenshell({ + commandVResult: "alias openshell='echo pwned'", + checkExecutable: () => false, + }), ).toBe(null); }); it("falls back to ~/.local/bin when command -v fails", () => { - expect(resolveOpenshell({ - commandVResult: null, - checkExecutable: (p) => p === "/fakehome/.local/bin/openshell", - home: "/fakehome", - })).toBe("/fakehome/.local/bin/openshell"); + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/fakehome/.local/bin/openshell", + home: "/fakehome", + }), + ).toBe("/fakehome/.local/bin/openshell"); }); it("falls back to /usr/local/bin", () => { - expect(resolveOpenshell({ - commandVResult: null, - checkExecutable: (p) => p === "/usr/local/bin/openshell", - })).toBe("/usr/local/bin/openshell"); + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/usr/local/bin/openshell", + }), + ).toBe("/usr/local/bin/openshell"); }); it("falls back to /usr/bin", () => { - expect(resolveOpenshell({ - commandVResult: null, - checkExecutable: (p) => p === "/usr/bin/openshell", - })).toBe("/usr/bin/openshell"); + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/usr/bin/openshell", + }), + ).toBe("/usr/bin/openshell"); }); it("prefers ~/.local/bin over /usr/local/bin", () => { - expect(resolveOpenshell({ - commandVResult: null, - checkExecutable: (p) => p === "/fakehome/.local/bin/openshell" || p === "/usr/local/bin/openshell", - home: "/fakehome", - })).toBe("/fakehome/.local/bin/openshell"); + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => + p === "/fakehome/.local/bin/openshell" || p === "/usr/local/bin/openshell", + home: "/fakehome", + }), + ).toBe("/fakehome/.local/bin/openshell"); }); it("returns null when openshell not found anywhere", () => { - expect(resolveOpenshell({ - commandVResult: null, - checkExecutable: () => false, - })).toBe(null); + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: () => false, + }), + ).toBe(null); }); }); @@ -71,7 +85,7 @@ describe("service environment", () => { { encoding: "utf-8", env: { ...process.env, NEMOCLAW_SANDBOX: "", SANDBOX_NAME: "my-box" }, - } + }, ).trim(); expect(result).toBe("my-box"); }); @@ -82,7 +96,7 @@ describe("service environment", () => { { encoding: "utf-8", env: { ...process.env, NEMOCLAW_SANDBOX: "from-env", SANDBOX_NAME: "old" }, - } + }, ).trim(); expect(result).toBe("from-env"); }); @@ -93,7 +107,7 @@ describe("service environment", () => { { encoding: "utf-8", env: { ...process.env, NEMOCLAW_SANDBOX: "", SANDBOX_NAME: "" }, - } + }, ).trim(); expect(result).toBe("default"); }); @@ -105,12 +119,12 @@ describe("service environment", () => { const proxyBlock = execFileSync( "sed", ["-n", "/^PROXY_HOST=/,/^export no_proxy=/p", scriptPath], - { encoding: "utf-8" } + { encoding: "utf-8" }, ); if (!proxyBlock.trim()) { throw new Error( "Failed to extract proxy configuration from scripts/nemoclaw-start.sh — " + - "the PROXY_HOST..no_proxy block may have been moved or renamed" + "the PROXY_HOST..no_proxy block may have been moved or renamed", ); } const wrapper = [ @@ -130,12 +144,18 @@ describe("service environment", () => { encoding: "utf-8", env: { ...process.env, ...env }, }).trim(); - return Object.fromEntries(out.split("\n").map((l) => { - const idx = l.indexOf("="); - return [l.slice(0, idx), l.slice(idx + 1)]; - })); + return Object.fromEntries( + out.split("\n").map((l) => { + const idx = l.indexOf("="); + return [l.slice(0, idx), l.slice(idx + 1)]; + }), + ); } finally { - try { unlinkSync(tmpFile); } catch { /* ignore */ } + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } } } @@ -193,7 +213,7 @@ describe("service environment", () => { const persistBlock = execFileSync( "sed", ["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath], - { encoding: "utf-8" } + { encoding: "utf-8" }, ); const wrapper = [ "#!/usr/bin/env bash", @@ -217,8 +237,16 @@ describe("service environment", () => { const profile = readFileSync(join(fakeHome, ".profile"), "utf-8"); expect(profile).not.toContain("inference.local"); } finally { - try { unlinkSync(tmpFile); } catch { /* ignore */ } - try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ } + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + execFileSync("rm", ["-rf", fakeHome]); + } catch { + /* ignore */ + } } }); @@ -231,7 +259,7 @@ describe("service environment", () => { const persistBlock = execFileSync( "sed", ["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath], - { encoding: "utf-8" } + { encoding: "utf-8" }, ); const wrapper = [ "#!/usr/bin/env bash", @@ -240,7 +268,10 @@ describe("service environment", () => { persistBlock.trimEnd(), ].join("\n"); writeFileSync(tmpFile, wrapper, { mode: 0o700 }); - const runOpts = { encoding: /** @type {const} */ ("utf-8"), env: { ...process.env, HOME: fakeHome } }; + const runOpts = { + encoding: /** @type {const} */ ("utf-8"), + env: { ...process.env, HOME: fakeHome }, + }; execFileSync("bash", [tmpFile], runOpts); execFileSync("bash", [tmpFile], runOpts); execFileSync("bash", [tmpFile], runOpts); @@ -251,8 +282,16 @@ describe("service environment", () => { expect(beginCount).toBe(1); expect(endCount).toBe(1); } finally { - try { unlinkSync(tmpFile); } catch { /* ignore */ } - try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ } + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + execFileSync("rm", ["-rf", fakeHome]); + } catch { + /* ignore */ + } } }); @@ -265,14 +304,15 @@ describe("service environment", () => { const persistBlock = execFileSync( "sed", ["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath], - { encoding: "utf-8" } + { encoding: "utf-8" }, ); - const makeWrapper = (host) => [ - "#!/usr/bin/env bash", - `PROXY_HOST="${host}"`, - 'PROXY_PORT="3128"', - persistBlock.trimEnd(), - ].join("\n"); + const makeWrapper = (host) => + [ + "#!/usr/bin/env bash", + `PROXY_HOST="${host}"`, + 'PROXY_PORT="3128"', + persistBlock.trimEnd(), + ].join("\n"); writeFileSync(tmpFile, makeWrapper("10.200.0.1"), { mode: 0o700 }); execFileSync("bash", [tmpFile], { @@ -293,8 +333,16 @@ describe("service environment", () => { const beginCount = (bashrc.match(/nemoclaw-proxy-config begin/g) || []).length; expect(beginCount).toBe(1); } finally { - try { unlinkSync(tmpFile); } catch { /* ignore */ } - try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ } + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + execFileSync("rm", ["-rf", fakeHome]); + } catch { + /* ignore */ + } } }); @@ -314,19 +362,31 @@ describe("service environment", () => { ].join("\n"); writeFileSync(join(fakeHome, ".bashrc"), bashrcContent); - const out = execFileSync("bash", ["--norc", "-c", [ - `export HOME=${JSON.stringify(fakeHome)}`, - 'export NO_PROXY="127.0.0.1,localhost,::1"', - 'export no_proxy="127.0.0.1,localhost,::1"', - `source ${JSON.stringify(join(fakeHome, ".bashrc"))}`, - 'echo "NO_PROXY=$NO_PROXY"', - 'echo "no_proxy=$no_proxy"', - ].join("; ")], { encoding: "utf-8" }).trim(); + const out = execFileSync( + "bash", + [ + "--norc", + "-c", + [ + `export HOME=${JSON.stringify(fakeHome)}`, + 'export NO_PROXY="127.0.0.1,localhost,::1"', + 'export no_proxy="127.0.0.1,localhost,::1"', + `source ${JSON.stringify(join(fakeHome, ".bashrc"))}`, + 'echo "NO_PROXY=$NO_PROXY"', + 'echo "no_proxy=$no_proxy"', + ].join("; "), + ], + { encoding: "utf-8" }, + ).trim(); expect(out).toContain("NO_PROXY=localhost,127.0.0.1,::1,10.200.0.1"); expect(out).toContain("no_proxy=localhost,127.0.0.1,::1,10.200.0.1"); } finally { - try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ } + try { + execFileSync("rm", ["-rf", fakeHome]); + } catch { + /* ignore */ + } } }); }); diff --git a/test/setup-sandbox-name.test.js b/test/setup-sandbox-name.test.js index f3e122d7f..be6899d8a 100644 --- a/test/setup-sandbox-name.test.js +++ b/test/setup-sandbox-name.test.js @@ -18,14 +18,16 @@ describe("setup.sh sandbox name parameterization (#197)", () => { it("accepts sandbox name as $1 with env var fallback and default", () => { // $1 takes priority, then NEMOCLAW_SANDBOX_NAME env var, then "nemoclaw" - expect(content.includes('SANDBOX_NAME="${1:-${NEMOCLAW_SANDBOX_NAME:-nemoclaw}}"')).toBeTruthy(); + expect( + content.includes('SANDBOX_NAME="${1:-${NEMOCLAW_SANDBOX_NAME:-nemoclaw}}"'), + ).toBeTruthy(); }); it("sandbox create uses $SANDBOX_NAME, not hardcoded", () => { const createLine = content.match(/openshell sandbox create.*--name\s+(\S+)/); expect(createLine).toBeTruthy(); expect( - createLine[1].includes("$SANDBOX_NAME") || createLine[1].includes('"$SANDBOX_NAME"') + createLine[1].includes("$SANDBOX_NAME") || createLine[1].includes('"$SANDBOX_NAME"'), ).toBeTruthy(); }); @@ -33,7 +35,7 @@ describe("setup.sh sandbox name parameterization (#197)", () => { const deleteLine = content.match(/openshell sandbox delete\s+(\S+)/); expect(deleteLine).toBeTruthy(); expect( - deleteLine[1].includes("$SANDBOX_NAME") || deleteLine[1].includes('"$SANDBOX_NAME"') + deleteLine[1].includes("$SANDBOX_NAME") || deleteLine[1].includes('"$SANDBOX_NAME"'), ).toBeTruthy(); }); @@ -41,7 +43,7 @@ describe("setup.sh sandbox name parameterization (#197)", () => { const getLine = content.match(/openshell sandbox get\s+(\S+)/); expect(getLine).toBeTruthy(); expect( - getLine[1].includes("$SANDBOX_NAME") || getLine[1].includes('"$SANDBOX_NAME"') + getLine[1].includes("$SANDBOX_NAME") || getLine[1].includes('"$SANDBOX_NAME"'), ).toBeTruthy(); }); @@ -53,7 +55,7 @@ describe("setup.sh sandbox name parameterization (#197)", () => { it("$1 arg actually sets SANDBOX_NAME in bash", () => { const result = execSync( 'bash -c \'SANDBOX_NAME="${1:-${NEMOCLAW_SANDBOX_NAME:-nemoclaw}}"; echo "$SANDBOX_NAME"\' -- my-test-box', - { encoding: "utf-8" } + { encoding: "utf-8" }, ).trim(); expect(result).toBe("my-test-box"); }); @@ -61,7 +63,7 @@ describe("setup.sh sandbox name parameterization (#197)", () => { it("NEMOCLAW_SANDBOX_NAME env var is used when no $1 arg", () => { const result = execSync( 'bash -c \'SANDBOX_NAME="${1:-${NEMOCLAW_SANDBOX_NAME:-nemoclaw}}"; echo "$SANDBOX_NAME"\'', - { encoding: "utf-8", env: { ...process.env, NEMOCLAW_SANDBOX_NAME: "e2e-test" } } + { encoding: "utf-8", env: { ...process.env, NEMOCLAW_SANDBOX_NAME: "e2e-test" } }, ).trim(); expect(result).toBe("e2e-test"); }); @@ -69,7 +71,7 @@ describe("setup.sh sandbox name parameterization (#197)", () => { it("no arg and no env var defaults to nemoclaw in bash", () => { const result = execSync( 'bash -c \'SANDBOX_NAME="${1:-${NEMOCLAW_SANDBOX_NAME:-nemoclaw}}"; echo "$SANDBOX_NAME"\'', - { encoding: "utf-8", env: { PATH: process.env.PATH } } + { encoding: "utf-8", env: { PATH: process.env.PATH } }, ).trim(); expect(result).toBe("nemoclaw"); }); diff --git a/test/uninstall.test.js b/test/uninstall.test.js index cba70840e..afcf77df7 100644 --- a/test/uninstall.test.js +++ b/test/uninstall.test.js @@ -13,11 +13,7 @@ function createFakeNpmEnv(tmp) { const fakeBin = path.join(tmp, "bin"); const npmPath = path.join(fakeBin, "npm"); fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync( - npmPath, - "#!/usr/bin/env bash\nexit 0\n", - { mode: 0o755 } - ); + fs.writeFileSync(npmPath, "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); return { ...process.env, PATH: `${fakeBin}:${process.env.PATH || "/usr/bin:/bin"}`,