From b464c345a22724028fde551db2132ec2176e296f Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 09:20:21 -0700 Subject: [PATCH 01/16] Improve device-pairing UX --- .gitignore | 3 +- sandboxes/nemoclaw/nemoclaw-start.sh | 69 +---- .../extension/gateway-bridge.ts | 13 +- .../nemoclaw-ui-extension/extension/index.ts | 146 +++++---- sandboxes/nemoclaw/policy-proxy.js | 277 ++++++++++++++++++ 5 files changed, 382 insertions(+), 126 deletions(-) diff --git a/.gitignore b/.gitignore index 3412b31..2790357 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -/AGENTS.md +node_modules/ +AGENTS.md \ No newline at end of file diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index 5d70d53..e1316c5 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -143,72 +143,9 @@ NODE_PATH=$(npm root -g) POLICY_PATH=${_POLICY_PATH} UPSTREAM_PORT=${INTERNAL_GA nohup node /usr/local/lib/policy-proxy.js >> /tmp/gateway.log 2>&1 & echo "[gateway] policy-proxy launched (pid $!) upstream=${INTERNAL_GATEWAY_PORT} public=${PUBLIC_PORT}" -# Auto-approve pending device pairing requests so the browser is paired -# before the user notices the "pairing required" prompt in the Control UI. -( - echo "[auto-pair] watcher starting" - _pair_timeout_secs="${AUTO_PAIR_TIMEOUT_SECS:-0}" - _pair_sleep_secs="0.5" - _pair_heartbeat_every=120 - _json_has_approval() { - jq -e ' - .device - | objects - | (.approvedAtMs? // empty) or ((.tokens? // []) | length > 0) - ' >/dev/null 2>&1 - } - - _summarize_device_list() { - jq -r ' - def labels($entries): - ($entries // []) - | map(select(type == "object" and (.deviceId? // "") != "") - | "\((.clientId // "unknown")):\((.deviceId // "")[0:12])"); - (labels(.pending)) as $pending - | (labels(.paired)) as $paired - | "pending=\($pending | length) [\(($pending | if length > 0 then join(", ") else "-" end))] paired=\($paired | length) [\(($paired | if length > 0 then join(", ") else "-" end))]" - ' 2>/dev/null || echo "unparseable" - } - - if [ "${_pair_timeout_secs}" -gt 0 ] 2>/dev/null; then - _pair_deadline=$(($(date +%s) + _pair_timeout_secs)) - echo "[auto-pair] watcher timeout=${_pair_timeout_secs}s" - else - _pair_deadline=0 - echo "[auto-pair] watcher timeout=disabled" - fi - _pair_attempts=0 - _pair_approved=0 - _pair_errors=0 - while true; do - if [ "${_pair_deadline}" -gt 0 ] && [ "$(date +%s)" -ge "${_pair_deadline}" ]; then - break - fi - - sleep "${_pair_sleep_secs}" - _pair_attempts=$((_pair_attempts + 1)) - _approve_output="$(openclaw devices approve --latest --json 2>&1 || true)" - - if printf '%s\n' "$_approve_output" | _json_has_approval; then - _pair_approved=$((_pair_approved + 1)) - _approved_device_id="$(printf '%s\n' "$_approve_output" | jq -r '.device.deviceId // ""' 2>/dev/null | cut -c1-12)" - echo "[auto-pair] approved request attempts=${_pair_attempts} count=${_pair_approved} device=${_approved_device_id:-unknown}" - continue - fi - - if [ -n "$_approve_output" ] && ! printf '%s\n' "$_approve_output" | grep -qiE 'no pending|no device|not paired|nothing to approve'; then - _pair_errors=$((_pair_errors + 1)) - echo "[auto-pair] approve --latest unexpected output attempts=${_pair_attempts} errors=${_pair_errors}: ${_approve_output}" - fi - - if [ $((_pair_attempts % _pair_heartbeat_every)) -eq 0 ]; then - _list_output="$(openclaw devices list --json 2>&1 || true)" - _device_summary="$(printf '%s\n' "$_list_output" | _summarize_device_list)" - echo "[auto-pair] heartbeat attempts=${_pair_attempts} approved=${_pair_approved} errors=${_pair_errors} ${_device_summary}" - fi - done - echo "[auto-pair] watcher exiting attempts=${_pair_attempts} approved=${_pair_approved} errors=${_pair_errors}" -) >> /tmp/gateway.log 2>&1 & +# Device pairing bootstrap now runs on-demand inside policy-proxy.js. +# The first real browser request arms the state machine, which approves a +# pending device once, exits after convergence, and exposes status to the UI. CONFIG_FILE="${HOME}/.openclaw/openclaw.json" token=$(grep -o '"token"\s*:\s*"[^"]*"' "${CONFIG_FILE}" 2>/dev/null | head -1 | cut -d'"' -f4 || true) diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts index dcdcce5..06a60c5 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts @@ -14,6 +14,8 @@ interface ConfigSnapshot { [key: string]: unknown; } +const CONNECTION_POLL_INTERVAL_MS = 200; + /** * Returns the live GatewayBrowserClient from the element, * or null if the app hasn't connected yet. @@ -26,7 +28,7 @@ export function getClient(): GatewayClient | null { } /** - * Wait until the gateway client is available (polls every 500ms, up to timeoutMs). + * Wait until the gateway client is available, up to timeoutMs. */ export function waitForClient(timeoutMs = 15_000): Promise { return new Promise((resolve, reject) => { @@ -46,7 +48,7 @@ export function waitForClient(timeoutMs = 15_000): Promise { clearInterval(interval); reject(new Error("Timed out waiting for OpenClaw gateway client")); } - }, 500); + }, CONNECTION_POLL_INTERVAL_MS); }); } @@ -90,8 +92,7 @@ export function isAppConnected(): boolean { /** * Wait for the gateway to reconnect after a restart (e.g. after config.patch). * - * Polls .connected every 500ms. Resolves when the app is - * connected again, or rejects after timeoutMs. + * Resolves when the app is connected again, or rejects after timeoutMs. */ export function waitForReconnect(timeoutMs = 15_000): Promise { return new Promise((resolve, reject) => { @@ -109,7 +110,7 @@ export function waitForReconnect(timeoutMs = 15_000): Promise { clearInterval(interval); reject(new Error("Timed out waiting for gateway to reconnect")); } - }, 500); + }, CONNECTION_POLL_INTERVAL_MS); }); } @@ -145,6 +146,6 @@ export function waitForStableConnection( clearInterval(interval); reject(new Error("Timed out waiting for stable gateway connection")); } - }, 500); + }, CONNECTION_POLL_INTERVAL_MS); }); } diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index b167a0a..b328902 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -15,16 +15,22 @@ import { injectButton } from "./deploy-modal.ts"; import { injectNavGroup, activateNemoPage, watchOpenClawNavClicks } from "./nav-group.ts"; import { injectModelSelector, watchChatCompose } from "./model-selector.ts"; import { ingestKeysFromUrl, DEFAULT_MODEL, resolveApiKey, isKeyConfigured } from "./model-registry.ts"; -import { waitForReconnect, waitForStableConnection } from "./gateway-bridge.ts"; +import { waitForStableConnection } from "./gateway-bridge.ts"; import { syncKeysToProviders } from "./api-keys-page.ts"; -const INITIAL_CONNECT_TIMEOUT_MS = 30_000; -const EXTENDED_CONNECT_TIMEOUT_MS = 300_000; -const POST_PAIRING_SETTLE_DELAY_MS = 15_000; -const STABLE_CONNECTION_WINDOW_MS = 10_000; -const STABLE_CONNECTION_TIMEOUT_MS = 45_000; -const PAIRING_RELOAD_FLAG = "nemoclaw:pairing-bootstrap-reloaded"; -const FORCED_RELOAD_DELAY_MS = 1_000; +const STABLE_CONNECTION_WINDOW_MS = 1_500; +const INITIAL_CONNECTION_TIMEOUT_MS = 20_000; +const EXTENDED_CONNECTION_TIMEOUT_MS = 90_000; +const PAIRING_STATUS_POLL_MS = 500; +const PAIRING_RELOAD_FLAG = "nemoclaw:pairing-bootstrap-recovery-reload"; + +interface PairingBootstrapState { + status?: string; + approvedCount?: number; + active?: boolean; + lastApprovalDeviceId?: string; + lastError?: string; +} function inject(): boolean { const hasButton = injectButton(); @@ -78,7 +84,7 @@ function revealApp(): void { } } -function shouldForcePairingReload(): boolean { +function shouldAllowRecoveryReload(): boolean { try { return sessionStorage.getItem(PAIRING_RELOAD_FLAG) !== "1"; } catch { @@ -86,7 +92,7 @@ function shouldForcePairingReload(): boolean { } } -function markPairingReloadComplete(): void { +function markRecoveryReloadUsed(): void { try { sessionStorage.setItem(PAIRING_RELOAD_FLAG, "1"); } catch { @@ -94,65 +100,99 @@ function markPairingReloadComplete(): void { } } -function clearPairingReloadFlag(): void { +async function fetchPairingBootstrapState(method: "GET" | "POST"): Promise { try { - sessionStorage.removeItem(PAIRING_RELOAD_FLAG); + const res = await fetch("/api/pairing-bootstrap", { method }); + if (!res.ok) return null; + return (await res.json()) as PairingBootstrapState; } catch { - // ignore storage failures + return null; } } -function forcePairingReload(reason: string, overlayText: string): void { - console.info(`[NeMoClaw] pairing bootstrap: forcing one-time reload (${reason})`); - markPairingReloadComplete(); - setConnectOverlayText(overlayText); - window.setTimeout(() => window.location.reload(), FORCED_RELOAD_DELAY_MS); +function getOverlayTextForPairingState(state: PairingBootstrapState | null): string | null { + switch (state?.status) { + case "armed": + return "Preparing device pairing bootstrap..."; + case "pending": + return "Waiting for device pairing request..."; + case "approving": + return "Approving device pairing..."; + case "approved-pending-settle": + return "Device pairing approved. Finalizing dashboard..."; + case "paired": + return "Device paired. Finalizing dashboard..."; + case "approved": + return "Device pairing approved. Opening dashboard..."; + case "timeout": + return "Pairing bootstrap timed out. Opening dashboard..."; + case "error": + return "Pairing bootstrap hit an error. Opening dashboard..."; + default: + return null; + } } function bootstrap() { console.info("[NeMoClaw] pairing bootstrap: start"); showConnectOverlay(); + void fetchPairingBootstrapState("POST"); - const finalizeConnectedState = async () => { - setConnectOverlayText("Device pairing approved. Finalizing dashboard..."); - console.info("[NeMoClaw] pairing bootstrap: reconnect detected"); - if (shouldForcePairingReload()) { - forcePairingReload("post-reconnect", "Device pairing approved. Reloading dashboard..."); - return; - } - setConnectOverlayText("Device pairing approved. Verifying dashboard health..."); - try { - console.info("[NeMoClaw] pairing bootstrap: waiting for stable post-reload connection"); - await waitForStableConnection( - STABLE_CONNECTION_WINDOW_MS, - STABLE_CONNECTION_TIMEOUT_MS, - ); - } catch { - console.warn("[NeMoClaw] pairing bootstrap: stable post-reload connection check timed out; delaying reveal"); - await new Promise((resolve) => setTimeout(resolve, POST_PAIRING_SETTLE_DELAY_MS)); - } - console.info("[NeMoClaw] pairing bootstrap: reveal app"); - clearPairingReloadFlag(); - revealApp(); + let pairingPollTimer = 0; + let stopped = false; + + const stopPairingPoll = () => { + stopped = true; + if (pairingPollTimer) window.clearTimeout(pairingPollTimer); }; - waitForReconnect(INITIAL_CONNECT_TIMEOUT_MS) - .then(finalizeConnectedState) + const pollPairingState = async () => { + if (stopped) return null; + const state = await fetchPairingBootstrapState("GET"); + const text = getOverlayTextForPairingState(state); + if (text) setConnectOverlayText(text); + pairingPollTimer = window.setTimeout(pollPairingState, PAIRING_STATUS_POLL_MS); + return state; + }; + + void pollPairingState(); + + const waitForDashboardReadiness = async (timeoutMs: number, overlayText: string) => { + setConnectOverlayText(overlayText); + await waitForStableConnection(STABLE_CONNECTION_WINDOW_MS, timeoutMs); + }; + + waitForDashboardReadiness( + INITIAL_CONNECTION_TIMEOUT_MS, + "Auto-approving device pairing. Hang tight...", + ) .catch(async () => { - console.warn("[NeMoClaw] pairing bootstrap: initial reconnect timed out; extending wait"); - if (shouldForcePairingReload()) { - forcePairingReload("initial-timeout", "Pairing is still settling. Reloading dashboard..."); + console.warn("[NeMoClaw] pairing bootstrap: initial dashboard readiness check timed out; extending wait"); + return waitForDashboardReadiness( + EXTENDED_CONNECTION_TIMEOUT_MS, + "Still waiting for device pairing approval...", + ); + }) + .then(() => { + console.info("[NeMoClaw] pairing bootstrap: reveal app"); + stopPairingPoll(); + setConnectOverlayText("Device pairing approved. Opening dashboard..."); + revealApp(); + }) + .catch(async () => { + const state = await fetchPairingBootstrapState("GET"); + const status = state?.status || "unknown"; + const recoveryEligible = status === "approved" || status === "approved-pending-settle" || status === "paired"; + if (recoveryEligible && shouldAllowRecoveryReload()) { + console.warn(`[NeMoClaw] pairing bootstrap: readiness timed out after ${status}; forcing one recovery reload`); + markRecoveryReloadUsed(); + setConnectOverlayText("Pairing succeeded. Recovering dashboard..."); + window.setTimeout(() => window.location.reload(), 750); return; } - setConnectOverlayText("Still waiting for device pairing approval..."); - try { - await waitForReconnect(EXTENDED_CONNECT_TIMEOUT_MS); - await finalizeConnectedState(); - } catch { - console.warn("[NeMoClaw] pairing bootstrap: extended reconnect timed out; revealing app anyway"); - clearPairingReloadFlag(); - revealApp(); - } + console.warn(`[NeMoClaw] pairing bootstrap: readiness timed out; revealing app anyway (status=${status})`); + stopPairingPoll(); + revealApp(); }); const keysIngested = ingestKeysFromUrl(); diff --git a/sandboxes/nemoclaw/policy-proxy.js b/sandboxes/nemoclaw/policy-proxy.js index e699e53..bc617ef 100644 --- a/sandboxes/nemoclaw/policy-proxy.js +++ b/sandboxes/nemoclaw/policy-proxy.js @@ -15,11 +15,16 @@ const fs = require("fs"); const os = require("os"); const net = require("net"); const crypto = require("crypto"); +const { execFile } = require("child_process"); const POLICY_PATH = process.env.POLICY_PATH || "/etc/openshell/policy.yaml"; const UPSTREAM_PORT = parseInt(process.env.UPSTREAM_PORT || "18788", 10); const LISTEN_PORT = parseInt(process.env.LISTEN_PORT || "18789", 10); const UPSTREAM_HOST = "127.0.0.1"; +const AUTO_PAIR_TIMEOUT_MS = parseInt(process.env.AUTO_PAIR_TIMEOUT_MS || "120000", 10); +const AUTO_PAIR_POLL_MS = parseInt(process.env.AUTO_PAIR_POLL_MS || "300", 10); +const AUTO_PAIR_HEARTBEAT_MS = parseInt(process.env.AUTO_PAIR_HEARTBEAT_MS || "10000", 10); +const AUTO_PAIR_QUIET_POLLS = parseInt(process.env.AUTO_PAIR_QUIET_POLLS || "4", 10); const PROTO_DIR = "/usr/local/lib/nemoclaw-proto"; @@ -38,11 +43,236 @@ const WELL_KNOWN_ENDPOINT = "https://navigator.navigator.svc.cluster.local:8080" let gatewayEndpoint = ""; let sandboxName = ""; +const pairingBootstrap = { + status: "idle", + startedAt: 0, + updatedAt: Date.now(), + approvedCount: 0, + errors: 0, + attempts: 0, + quietPolls: 0, + lastApprovalDeviceId: "", + lastError: "", + sawPending: false, + sawPaired: false, + active: false, + timer: null, + heartbeatAt: 0, +}; + function formatRequestLine(req) { const host = req.headers.host || "unknown-host"; return `${req.method || "GET"} ${req.url || "/"} host=${host}`; } +function updatePairingState(patch) { + Object.assign(pairingBootstrap, patch, { updatedAt: Date.now() }); +} + +function pairingSnapshot() { + return { + status: pairingBootstrap.status, + startedAt: pairingBootstrap.startedAt || null, + updatedAt: pairingBootstrap.updatedAt, + approvedCount: pairingBootstrap.approvedCount, + errors: pairingBootstrap.errors, + attempts: pairingBootstrap.attempts, + quietPolls: pairingBootstrap.quietPolls, + lastApprovalDeviceId: pairingBootstrap.lastApprovalDeviceId || "", + lastError: pairingBootstrap.lastError || "", + sawPending: pairingBootstrap.sawPending, + sawPaired: pairingBootstrap.sawPaired, + active: pairingBootstrap.active, + }; +} + +function execOpenClaw(args) { + return new Promise((resolve) => { + execFile("openclaw", args, { timeout: 5000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { + resolve({ + ok: !error, + stdout: stdout || "", + stderr: stderr || "", + error: error ? error.message : "", + }); + }); + }); +} + +function parseJsonBody(raw) { + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function normalizeDeviceList(raw) { + const parsed = parseJsonBody(raw); + if (!parsed || typeof parsed !== "object") { + return { pending: [], paired: [] }; + } + return { + pending: Array.isArray(parsed.pending) ? parsed.pending : [], + paired: Array.isArray(parsed.paired) ? parsed.paired : [], + }; +} + +function approvalSucceeded(raw) { + const parsed = parseJsonBody(raw); + const device = parsed && typeof parsed === "object" ? parsed.device : null; + if (!device || typeof device !== "object") return false; + return Boolean(device.approvedAtMs) || (Array.isArray(device.tokens) && device.tokens.length > 0); +} + +function approvalDeviceId(raw) { + const parsed = parseJsonBody(raw); + const deviceId = parsed && parsed.device && typeof parsed.device.deviceId === "string" + ? parsed.device.deviceId + : ""; + return deviceId ? deviceId.slice(0, 12) : ""; +} + +function summarizeDevices(devices) { + const format = (entries) => { + if (!Array.isArray(entries) || entries.length === 0) return "-"; + return entries + .filter((entry) => entry && typeof entry === "object" && entry.deviceId) + .map((entry) => `${entry.clientId || "unknown"}:${String(entry.deviceId).slice(0, 12)}`) + .join(", ") || "-"; + }; + return `pending=${devices.pending.length} [${format(devices.pending)}] paired=${devices.paired.length} [${format(devices.paired)}]`; +} + +function finishPairingBootstrap(status, extra = {}) { + if (pairingBootstrap.timer) { + clearTimeout(pairingBootstrap.timer); + } + updatePairingState({ + status, + active: false, + timer: null, + ...extra, + }); + console.log( + `[auto-pair] watcher exiting status=${status} attempts=${pairingBootstrap.attempts} ` + + `approved=${pairingBootstrap.approvedCount} errors=${pairingBootstrap.errors} ` + + `sawPending=${pairingBootstrap.sawPending} sawPaired=${pairingBootstrap.sawPaired}` + ); +} + +async function runPairingBootstrapTick() { + if (!pairingBootstrap.active) return; + + const now = Date.now(); + if (pairingBootstrap.startedAt && now - pairingBootstrap.startedAt >= AUTO_PAIR_TIMEOUT_MS) { + finishPairingBootstrap("timeout", { lastError: "pairing bootstrap timed out" }); + return; + } + + updatePairingState({ attempts: pairingBootstrap.attempts + 1 }); + + const listResult = await execOpenClaw(["devices", "list", "--json"]); + const devices = normalizeDeviceList(listResult.stdout); + const hasPending = devices.pending.length > 0; + const hasPaired = devices.paired.length > 0; + + if (!listResult.ok && !listResult.stdout.trim()) { + updatePairingState({ + errors: pairingBootstrap.errors + 1, + status: "error", + lastError: listResult.stderr || listResult.error || "device list failed", + }); + } else { + updatePairingState({ + status: hasPending ? "pending" : hasPaired ? "paired" : "armed", + sawPending: pairingBootstrap.sawPending || hasPending, + sawPaired: pairingBootstrap.sawPaired || hasPaired, + }); + } + + if (!hasPending && hasPaired) { + const quietPolls = pairingBootstrap.quietPolls + 1; + updatePairingState({ quietPolls }); + if (quietPolls >= AUTO_PAIR_QUIET_POLLS) { + finishPairingBootstrap("approved"); + return; + } + } else { + updatePairingState({ quietPolls: 0 }); + } + + if (hasPending) { + updatePairingState({ status: "approving" }); + const approveResult = await execOpenClaw(["devices", "approve", "--latest", "--json"]); + if (approvalSucceeded(approveResult.stdout)) { + updatePairingState({ + approvedCount: pairingBootstrap.approvedCount + 1, + lastApprovalDeviceId: approvalDeviceId(approveResult.stdout), + lastError: "", + quietPolls: 0, + status: "approved-pending-settle", + }); + console.log( + `[auto-pair] approved request attempts=${pairingBootstrap.attempts} ` + + `count=${pairingBootstrap.approvedCount} device=${pairingBootstrap.lastApprovalDeviceId || "unknown"}` + ); + } else if ((approveResult.stdout || approveResult.stderr).trim()) { + const noisy = !/no pending|no device|not paired|nothing to approve/i.test( + `${approveResult.stdout}\n${approveResult.stderr}` + ); + if (noisy) { + updatePairingState({ + errors: pairingBootstrap.errors + 1, + lastError: approveResult.stderr || approveResult.stdout || approveResult.error || "approve failed", + }); + console.warn( + `[auto-pair] approve --latest unexpected output attempts=${pairingBootstrap.attempts} ` + + `errors=${pairingBootstrap.errors}: ${pairingBootstrap.lastError}` + ); + } + } + } + + if (now - pairingBootstrap.heartbeatAt >= AUTO_PAIR_HEARTBEAT_MS) { + updatePairingState({ heartbeatAt: now }); + console.log( + `[auto-pair] heartbeat status=${pairingBootstrap.status} attempts=${pairingBootstrap.attempts} ` + + `approved=${pairingBootstrap.approvedCount} errors=${pairingBootstrap.errors} ${summarizeDevices(devices)}` + ); + } + + pairingBootstrap.timer = setTimeout(runPairingBootstrapTick, AUTO_PAIR_POLL_MS); +} + +function startPairingBootstrap(reason) { + if (pairingBootstrap.active) { + return pairingSnapshot(); + } + if (pairingBootstrap.status === "approved") { + return pairingSnapshot(); + } + updatePairingState({ + status: "armed", + startedAt: Date.now(), + approvedCount: pairingBootstrap.status === "timeout" || pairingBootstrap.status === "error" ? 0 : pairingBootstrap.approvedCount, + errors: 0, + attempts: 0, + quietPolls: 0, + lastApprovalDeviceId: "", + lastError: "", + sawPending: false, + sawPaired: false, + active: true, + heartbeatAt: 0, + }); + console.log(`[auto-pair] watcher starting reason=${reason} timeout=${AUTO_PAIR_TIMEOUT_MS}ms poll=${AUTO_PAIR_POLL_MS}ms`); + runPairingBootstrapTick().catch((error) => { + finishPairingBootstrap("error", { lastError: error.message || String(error) }); + }); + return pairingSnapshot(); +} + // --------------------------------------------------------------------------- // Discovery helpers // --------------------------------------------------------------------------- @@ -569,11 +799,53 @@ function syncAndRespond(yamlBody, res, t0) { }); } +function handlePairingBootstrap(req, res) { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + if (req.method === "GET") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(pairingSnapshot())); + return; + } + + if (req.method === "POST") { + const snapshot = startPairingBootstrap("api"); + res.writeHead(202, { "Content-Type": "application/json" }); + res.end(JSON.stringify(snapshot)); + return; + } + + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "method not allowed" })); +} + +function shouldArmPairingFromRequest(req) { + if (!req || !req.url) return false; + if (req.method && req.method !== "GET") return false; + if (req.url.startsWith("/api/")) return false; + if (req.url.startsWith("/assets/")) return false; + if (req.url === "/favicon.ico") return false; + return true; +} + // --------------------------------------------------------------------------- // HTTP server // --------------------------------------------------------------------------- const server = http.createServer((req, res) => { + if (req.url === "/api/pairing-bootstrap") { + handlePairingBootstrap(req, res); + return; + } + if (req.url === "/api/policy") { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); @@ -593,11 +865,16 @@ const server = http.createServer((req, res) => { return; } + if (shouldArmPairingFromRequest(req)) { + startPairingBootstrap(`http:${req.url}`); + } + proxyRequest(req, res); }); // WebSocket upgrade — pipe raw TCP to upstream server.on("upgrade", (req, socket, head) => { + startPairingBootstrap(`ws:${req.url || "/"}`); console.log(`[policy-proxy] ws in ${formatRequestLine(req)} -> ${UPSTREAM_HOST}:${UPSTREAM_PORT}`); const upstream = net.createConnection({ host: UPSTREAM_HOST, port: UPSTREAM_PORT }, () => { const reqLine = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`; From 52c5c8cbf92a5f6773201647a045f4ae8e5bf9f0 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 09:45:48 -0700 Subject: [PATCH 02/16] Support flexible port names; skip wait extend for device pairing auto-approve; update tests --- brev/welcome-ui/SERVER_ARCHITECTURE.md | 23 ++++--- .../__tests__/brev-detection.test.js | 35 +++++++--- .../__tests__/sandbox-lifecycle.test.js | 6 +- brev/welcome-ui/server.js | 64 ++++++++++++++++--- .../nemoclaw-ui-extension/extension/index.ts | 47 +++++++++++--- 5 files changed, 138 insertions(+), 37 deletions(-) diff --git a/brev/welcome-ui/SERVER_ARCHITECTURE.md b/brev/welcome-ui/SERVER_ARCHITECTURE.md index f7e7e72..31daa40 100644 --- a/brev/welcome-ui/SERVER_ARCHITECTURE.md +++ b/brev/welcome-ui/SERVER_ARCHITECTURE.md @@ -330,7 +330,7 @@ Global: _inject_key_state (dict, protected by _inject_key_lock) ```json { "status": "idle" | "creating" | "running" | "error", - "url": "https://80810-xxx.brevlab.com/?token=abc123" | null, + "url": "https:///#token=abc123" | null, "error": "error message" | null, "key_injected": true | false, "key_inject_error": "error message" | null @@ -992,15 +992,20 @@ Detection is **idempotent** — once a Brev ID is detected from a Host header, i ### URL Building -``` -_build_openclaw_url(token): - If Brev ID available: - → https://80810-{brev_id}.brevlab.com/[?token=xxx] - Else: - → http://127.0.0.1:{PORT}/[?token=xxx] -``` +`buildOpenclawUrl(token, req)` is now request-aware and prefers the browser-visible welcome UI origin. + +Resolution order: + +1. `CHAT_UI_URL` environment override, if set +2. `X-Forwarded-Proto` + `X-Forwarded-Host` from the incoming request +3. Incoming request `Host` +4. Last detected public welcome UI base URL cached from prior requests +5. Brev fallback: `https://80810-{brev_id}.brevlab.com/` +6. Local fallback: `http://127.0.0.1:{PORT}/` + +If a token is present, it is appended as a URL fragment: `#token=...` -**The URL points to the welcome-ui server itself** (port 8081 = `80810` in Brev URL format), NOT directly to port 18789. This is critical because: +**The URL points to the welcome-ui server itself**, not directly to port 18789. This is critical because: - Brev's port-forwarding creates subdomains per port - Cross-origin requests between Brev port subdomains are blocked - By proxying through port 8081, the browser stays on one origin diff --git a/brev/welcome-ui/__tests__/brev-detection.test.js b/brev/welcome-ui/__tests__/brev-detection.test.js index 2eb49f9..8da4531 100644 --- a/brev/welcome-ui/__tests__/brev-detection.test.js +++ b/brev/welcome-ui/__tests__/brev-detection.test.js @@ -32,6 +32,13 @@ describe("maybeDetectBrevId + buildOpenclawUrl", () => { _resetForTesting(); }); + function makeReq(host, forwardedProto = null, forwardedHost = null) { + const headers = { host }; + if (forwardedProto) headers["x-forwarded-proto"] = forwardedProto; + if (forwardedHost) headers["x-forwarded-host"] = forwardedHost; + return { headers }; + } + it("TC-B05: detection is idempotent (once set, never overwritten)", () => { maybeDetectBrevId("80810-first-id.brevlab.com"); maybeDetectBrevId("80810-second-id.brevlab.com"); @@ -40,35 +47,43 @@ describe("maybeDetectBrevId + buildOpenclawUrl", () => { expect(url).not.toContain("second-id"); }); - it("TC-B06: with Brev ID, URL is https://80810-{id}.brevlab.com/", () => { + it("TC-B06: request host takes priority when deriving URL", () => { + const req = makeReq("sandbox-preview.example.net", "https"); + expect(buildOpenclawUrl(null, req)).toBe("https://sandbox-preview.example.net/"); + }); + + it("TC-B07: forwarded host/proto are honored for external URL building", () => { + const req = makeReq("127.0.0.1:8081", "https", "80810-myenv.brevlab.com"); + expect(buildOpenclawUrl("tok123", req)).toBe( + "https://80810-myenv.brevlab.com/#token=tok123" + ); + }); + + it("TC-B08: with Brev ID fallback, URL uses https://80810-{id}.brevlab.com/", () => { maybeDetectBrevId("80810-myenv.brevlab.com"); expect(buildOpenclawUrl(null)).toBe("https://80810-myenv.brevlab.com/"); }); - it("TC-B07: with Brev ID + token, URL has ?token=xxx", () => { + it("TC-B09: with Brev ID fallback + token, URL appends fragment token", () => { maybeDetectBrevId("80810-myenv.brevlab.com"); expect(buildOpenclawUrl("tok123")).toBe( - "https://80810-myenv.brevlab.com/?token=tok123" + "https://80810-myenv.brevlab.com/#token=tok123" ); }); - it("TC-B08: without Brev ID, URL is http://127.0.0.1:{PORT}/", () => { + it("TC-B10: without request context or Brev ID, URL falls back to local 127.0.0.1", () => { const url = buildOpenclawUrl(null); expect(url).toBe(`http://127.0.0.1:${PORT}/`); }); - it("TC-B09: BREV_ENV_ID env var takes priority over Host detection", () => { - // BREV_ENV_ID is read at module load. If it was empty, detected takes over. - // We test that detected ID is used when BREV_ENV_ID is not set. + it("TC-B11: detected Brev ID still supplies fallback when request context is absent", () => { maybeDetectBrevId("80810-detected.brevlab.com"); const url = buildOpenclawUrl(null); expect(url).toContain("detected"); }); - it("TC-B10: connection details gateway URL uses port 8080 not 8081", () => { + it("TC-B12: buildOpenclawUrl still uses welcome-ui port family, not gateway port family", () => { maybeDetectBrevId("80810-env123.brevlab.com"); - // buildOpenclawUrl uses port 80810 (welcome-ui port in Brev) - // The gateway URL is separate (tested in connection-details) const url = buildOpenclawUrl(null); expect(url).toContain("80810"); expect(url).not.toContain("8080-"); diff --git a/brev/welcome-ui/__tests__/sandbox-lifecycle.test.js b/brev/welcome-ui/__tests__/sandbox-lifecycle.test.js index 188efeb..e2df1f1 100644 --- a/brev/welcome-ui/__tests__/sandbox-lifecycle.test.js +++ b/brev/welcome-ui/__tests__/sandbox-lifecycle.test.js @@ -118,9 +118,11 @@ describe("POST /api/install-openclaw", () => { expect(deleteCalls.length).toBeGreaterThanOrEqual(1); }); - it("TC-S09: CHAT_UI_URL is passed in the command after -- env", async () => { + it("TC-S09: CHAT_UI_URL is derived from the incoming public request URL", async () => { await request(server) .post("/api/install-openclaw") + .set("Host", "preview.example.net") + .set("X-Forwarded-Proto", "https") .set("Content-Type", "application/json"); if (spawn.mock.calls.length > 0) { @@ -128,7 +130,7 @@ describe("POST /api/install-openclaw", () => { const envIdx = args.indexOf("env"); expect(envIdx).toBeGreaterThan(-1); const chatUrl = args[envIdx + 1]; - expect(chatUrl).toMatch(/^CHAT_UI_URL=/); + expect(chatUrl).toBe("CHAT_UI_URL=https://preview.example.net/"); } }); }); diff --git a/brev/welcome-ui/server.js b/brev/welcome-ui/server.js index a6f9036..e9af4de 100644 --- a/brev/welcome-ui/server.js +++ b/brev/welcome-ui/server.js @@ -45,6 +45,7 @@ const LOG_FILE = "/tmp/nemoclaw-sandbox-create.log"; const PROVIDER_CONFIG_CACHE = "/tmp/nemoclaw-provider-config-cache.json"; let _brevEnvId = process.env.BREV_ENV_ID || ""; let detectedBrevId = ""; +let detectedPublicBaseUrl = ""; const SANDBOX_PORT = 18789; @@ -285,13 +286,53 @@ function maybeDetectBrevId(host) { } } -function buildOpenclawUrl(token) { +function firstForwardedValue(headerValue) { + return String(headerValue || "") + .split(",")[0] + .trim(); +} + +function normalizeBaseUrl(baseUrl) { + if (!baseUrl) return ""; + try { + const parsed = new URL(baseUrl); + return `${parsed.protocol}//${parsed.host}/`; + } catch { + return ""; + } +} + +function derivePublicBaseUrl(req) { + const forwardedProto = firstForwardedValue(req?.headers?.["x-forwarded-proto"]); + const forwardedHost = firstForwardedValue(req?.headers?.["x-forwarded-host"]); + const host = firstForwardedValue(req?.headers?.host); + const proto = forwardedProto || (host && !/^127\.0\.0\.1(?::\d+)?$/.test(host) ? "https" : "http"); + const authority = forwardedHost || host; + if (!authority) return ""; + return normalizeBaseUrl(`${proto}://${authority}/`); +} + +function rememberPublicBaseUrl(req) { + const resolved = derivePublicBaseUrl(req); + if (resolved) { + detectedPublicBaseUrl = resolved; + } + return detectedPublicBaseUrl; +} + +function buildOpenclawUrl(token, req = null) { + const dynamicBaseUrl = + normalizeBaseUrl(process.env.CHAT_UI_URL || "") || + rememberPublicBaseUrl(req) || + normalizeBaseUrl(detectedPublicBaseUrl); const brevId = _brevEnvId || detectedBrevId; - let url; - if (brevId) { - url = `https://80810-${brevId}.brevlab.com/`; - } else { - url = `http://127.0.0.1:${PORT}/`; + let url = dynamicBaseUrl; + if (!url) { + if (brevId) { + url = `https://80810-${brevId}.brevlab.com/`; + } else { + url = `http://127.0.0.1:${PORT}/`; + } } if (token) url += `#token=${token}`; return url; @@ -628,7 +669,11 @@ function runSandboxCreate() { return; } - const chatUiUrl = buildOpenclawUrl(null); + const chatUiUrl = buildOpenclawUrl(null, { + headers: { + host: detectedPublicBaseUrl ? new URL(detectedPublicBaseUrl).host : "", + }, + }); const policyPath = await generateGatewayPolicy(); const cmd = [ @@ -1218,7 +1263,7 @@ async function handleSandboxStatus(req, res) { (await portOpen("127.0.0.1", SANDBOX_PORT)) ) { const token = readOpenclawToken(); - const url = buildOpenclawUrl(token); + const url = buildOpenclawUrl(token, req); sandboxState.status = "running"; sandboxState.url = url; state.status = "running"; @@ -1497,6 +1542,7 @@ function serveStaticFile(req, res, pathname) { async function handleRequest(req, res) { maybeDetectBrevId(req.headers.host || ""); + rememberPublicBaseUrl(req); const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); const pathname = parsedUrl.pathname; @@ -1584,6 +1630,7 @@ const server = http.createServer((req, res) => { // WebSocket upgrade handler — checked BEFORE route matching (mirrors Python) server.on("upgrade", async (req, socket, head) => { maybeDetectBrevId(req.headers.host || ""); + rememberPublicBaseUrl(req); if (await sandboxReady()) { proxyWebSocket(req, socket, head); @@ -1601,6 +1648,7 @@ function _resetForTesting() { injectKeyState.error = null; injectKeyState.keyHash = null; detectedBrevId = ""; + detectedPublicBaseUrl = ""; _brevEnvId = ""; renderedIndex = null; _nvidiaApiKey = ""; diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index b328902..d5abc42 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -32,6 +32,17 @@ interface PairingBootstrapState { lastError?: string; } +function isPairingTerminal(state: PairingBootstrapState | null): boolean { + if (!state) return false; + if (state.active) return false; + return state.status === "approved" || state.status === "paired" || state.status === "timeout" || state.status === "error"; +} + +function isPairingRecoveryEligible(state: PairingBootstrapState | null): boolean { + if (!state) return false; + return state.status === "approved" || state.status === "approved-pending-settle" || state.status === "paired"; +} + function inject(): boolean { const hasButton = injectButton(); const hasNav = injectNavGroup(); @@ -140,6 +151,7 @@ function bootstrap() { let pairingPollTimer = 0; let stopped = false; + let latestPairingState: PairingBootstrapState | null = null; const stopPairingPoll = () => { stopped = true; @@ -149,6 +161,7 @@ function bootstrap() { const pollPairingState = async () => { if (stopped) return null; const state = await fetchPairingBootstrapState("GET"); + latestPairingState = state; const text = getOverlayTextForPairingState(state); if (text) setConnectOverlayText(text); pairingPollTimer = window.setTimeout(pollPairingState, PAIRING_STATUS_POLL_MS); @@ -162,12 +175,35 @@ function bootstrap() { await waitForStableConnection(STABLE_CONNECTION_WINDOW_MS, timeoutMs); }; + const handlePairingTerminalWithoutStableConnection = async (reason: string) => { + const state = latestPairingState || await fetchPairingBootstrapState("GET"); + const status = state?.status || "unknown"; + if (isPairingRecoveryEligible(state) && shouldAllowRecoveryReload()) { + console.warn(`[NeMoClaw] pairing bootstrap: ${reason}; pairing=${status}; forcing one recovery reload`); + stopPairingPoll(); + markRecoveryReloadUsed(); + setConnectOverlayText("Pairing succeeded. Recovering dashboard..."); + window.setTimeout(() => window.location.reload(), 750); + return true; + } + if (isPairingTerminal(state)) { + console.warn(`[NeMoClaw] pairing bootstrap: ${reason}; pairing=${status}; revealing app without further delay`); + stopPairingPoll(); + revealApp(); + return true; + } + return false; + }; + waitForDashboardReadiness( INITIAL_CONNECTION_TIMEOUT_MS, "Auto-approving device pairing. Hang tight...", ) .catch(async () => { console.warn("[NeMoClaw] pairing bootstrap: initial dashboard readiness check timed out; extending wait"); + if (await handlePairingTerminalWithoutStableConnection("initial readiness timed out")) { + return; + } return waitForDashboardReadiness( EXTENDED_CONNECTION_TIMEOUT_MS, "Still waiting for device pairing approval...", @@ -180,16 +216,11 @@ function bootstrap() { revealApp(); }) .catch(async () => { - const state = await fetchPairingBootstrapState("GET"); - const status = state?.status || "unknown"; - const recoveryEligible = status === "approved" || status === "approved-pending-settle" || status === "paired"; - if (recoveryEligible && shouldAllowRecoveryReload()) { - console.warn(`[NeMoClaw] pairing bootstrap: readiness timed out after ${status}; forcing one recovery reload`); - markRecoveryReloadUsed(); - setConnectOverlayText("Pairing succeeded. Recovering dashboard..."); - window.setTimeout(() => window.location.reload(), 750); + if (await handlePairingTerminalWithoutStableConnection("extended readiness timed out")) { return; } + const state = latestPairingState || await fetchPairingBootstrapState("GET"); + const status = state?.status || "unknown"; console.warn(`[NeMoClaw] pairing bootstrap: readiness timed out; revealing app anyway (status=${status})`); stopPairingPoll(); revealApp(); From 20c6a638973c47d9ad12f361c19c8f7914105daa Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 10:25:51 -0700 Subject: [PATCH 03/16] Add specific approval handling for device-pairing --- brev/welcome-ui/SERVER_ARCHITECTURE.md | 4 +- .../welcome-ui/__tests__/config-cache.test.js | 5 +- brev/welcome-ui/__tests__/inject-key.test.js | 28 ++-- brev/welcome-ui/server.js | 67 ++++++--- .../nemoclaw-ui-extension/extension/index.ts | 21 +++ sandboxes/nemoclaw/policy-proxy.js | 132 +++++++++++------- 6 files changed, 175 insertions(+), 82 deletions(-) diff --git a/brev/welcome-ui/SERVER_ARCHITECTURE.md b/brev/welcome-ui/SERVER_ARCHITECTURE.md index 31daa40..8fd2266 100644 --- a/brev/welcome-ui/SERVER_ARCHITECTURE.md +++ b/brev/welcome-ui/SERVER_ARCHITECTURE.md @@ -147,7 +147,9 @@ main() │ ├── 1. _bootstrap_config_cache() │ If /tmp/nemoclaw-provider-config-cache.json does NOT exist: - │ Write default: {"nvidia-inference": {"OPENAI_BASE_URL": "https://inference-api.nvidia.com/v1"}} + │ Write defaults for: + │ - nvidia-inference → OPENAI_BASE_URL=https://inference-api.nvidia.com/v1 + │ - nvidia-endpoints → NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1 │ If it already exists: skip (no-op) │ ├── 2. Create ThreadingHTTPServer on ("", PORT) diff --git a/brev/welcome-ui/__tests__/config-cache.test.js b/brev/welcome-ui/__tests__/config-cache.test.js index ee0e5b3..36f48bd 100644 --- a/brev/welcome-ui/__tests__/config-cache.test.js +++ b/brev/welcome-ui/__tests__/config-cache.test.js @@ -29,13 +29,16 @@ describe("config cache", () => { expect(cache).toEqual({ custom: { x: 1 } }); }); - it("TC-CC03: default bootstrap content has nvidia-inference with OPENAI_BASE_URL", () => { + it("TC-CC03: default bootstrap content seeds both NVIDIA inference providers", () => { bootstrapConfigCache(); const cache = readCacheFile(); expect(cache).toEqual({ "nvidia-inference": { OPENAI_BASE_URL: "https://inference-api.nvidia.com/v1", }, + "nvidia-endpoints": { + NVIDIA_BASE_URL: "https://integrate.api.nvidia.com/v1", + }, }); }); diff --git a/brev/welcome-ui/__tests__/inject-key.test.js b/brev/welcome-ui/__tests__/inject-key.test.js index 5490c87..d03c410 100644 --- a/brev/welcome-ui/__tests__/inject-key.test.js +++ b/brev/welcome-ui/__tests__/inject-key.test.js @@ -145,7 +145,7 @@ describe("inject-key background process", () => { execFile.mockClear(); }); - it("TC-K10: calls nemoclaw provider update nvidia-inference with correct args", async () => { + it("TC-K10: updates both default inference providers with the submitted key", async () => { execFile.mockImplementation((cmd, args, opts, cb) => { if (typeof opts === "function") { cb = opts; opts = {}; } cb(null, "", ""); @@ -161,13 +161,18 @@ describe("inject-key background process", () => { const updateCalls = execFile.mock.calls.filter( (c) => c[0] === "nemoclaw" && c[1]?.includes("update") ); - expect(updateCalls.length).toBeGreaterThanOrEqual(1); - const args = updateCalls[0][1]; - expect(args).toContain("nvidia-inference"); - expect(args).toContain("--type"); - expect(args).toContain("openai"); - expect(args.some((a) => a.startsWith("OPENAI_API_KEY="))).toBe(true); - expect(args.some((a) => a.includes("inference-api.nvidia.com"))).toBe(true); + expect(updateCalls.length).toBeGreaterThanOrEqual(2); + + const inferenceArgs = updateCalls.find((c) => c[1].includes("nvidia-inference"))?.[1] || []; + const endpointsArgs = updateCalls.find((c) => c[1].includes("nvidia-endpoints"))?.[1] || []; + + expect(inferenceArgs).toContain("nvidia-inference"); + expect(inferenceArgs.some((a) => a.startsWith("OPENAI_API_KEY="))).toBe(true); + expect(inferenceArgs.some((a) => a.includes("inference-api.nvidia.com"))).toBe(true); + + expect(endpointsArgs).toContain("nvidia-endpoints"); + expect(endpointsArgs.some((a) => a.startsWith("NVIDIA_API_KEY="))).toBe(true); + expect(endpointsArgs.some((a) => a.includes("integrate.api.nvidia.com"))).toBe(true); }); it("TC-K11: on CLI success, state becomes done", async () => { @@ -240,7 +245,7 @@ describe("key hashing", () => { expect(hashKey("abc")).toBe(hashKey("abc")); }); - it("TC-K16: provider name is hardcoded to nvidia-inference", async () => { + it("TC-K16: provider updates cover both nvidia-inference and nvidia-endpoints", async () => { execFile.mockImplementation((cmd, args, opts, cb) => { if (typeof opts === "function") { cb = opts; opts = {}; } cb(null, "", ""); @@ -255,8 +260,7 @@ describe("key hashing", () => { const updateCalls = execFile.mock.calls.filter( (c) => c[0] === "nemoclaw" && c[1]?.includes("update") ); - if (updateCalls.length > 0) { - expect(updateCalls[0][1]).toContain("nvidia-inference"); - } + expect(updateCalls.some((c) => c[1].includes("nvidia-inference"))).toBe(true); + expect(updateCalls.some((c) => c[1].includes("nvidia-endpoints"))).toBe(true); }); }); diff --git a/brev/welcome-ui/server.js b/brev/welcome-ui/server.js index e9af4de..e46cc3f 100644 --- a/brev/welcome-ui/server.js +++ b/brev/welcome-ui/server.js @@ -48,6 +48,10 @@ let detectedBrevId = ""; let detectedPublicBaseUrl = ""; const SANDBOX_PORT = 18789; +const INFERENCE_BUNDLE_SETTLE_MS = parseInt( + process.env.INFERENCE_BUNDLE_SETTLE_MS || "6000", + 10 +); const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g; @@ -247,6 +251,9 @@ function bootstrapConfigCache() { "nvidia-inference": { OPENAI_BASE_URL: "https://inference-api.nvidia.com/v1", }, + "nvidia-endpoints": { + NVIDIA_BASE_URL: "https://integrate.api.nvidia.com/v1", + }, }); logWelcome("Bootstrapped provider config cache"); } @@ -826,41 +833,63 @@ function hashKey(key) { } function runInjectKey(key, keyHash) { - log("inject-key", `step 1/3: received key (hash=${keyHash.slice(0, 12)}…)`); - - const args = [ - ...cliArgs("provider", "update", "nvidia-inference"), - "--credential", `OPENAI_API_KEY=${key}`, - "--config", "OPENAI_BASE_URL=https://inference-api.nvidia.com/v1", + log("inject-key", `step 1/4: received key (hash=${keyHash.slice(0, 12)}…)`); + + const providerUpdates = [ + { + name: "nvidia-inference", + credential: `OPENAI_API_KEY=${key}`, + config: "OPENAI_BASE_URL=https://inference-api.nvidia.com/v1", + cache: { OPENAI_BASE_URL: "https://inference-api.nvidia.com/v1" }, + }, + { + name: "nvidia-endpoints", + credential: `NVIDIA_API_KEY=${key}`, + config: "NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1", + cache: { NVIDIA_BASE_URL: "https://integrate.api.nvidia.com/v1" }, + }, ]; - log("inject-key", `step 2/3: running ${CLI_BIN} provider update nvidia-inference …`); const t0 = Date.now(); - execCmd(args, 120000) - .then((result) => { + Promise.all( + providerUpdates.map(async (update) => { + const args = [ + ...cliArgs("provider", "update", update.name), + "--credential", update.credential, + "--config", update.config, + ]; + log("inject-key", `step 2/4: running ${CLI_BIN} provider update ${update.name} …`); + const result = await execCmd(args, 120000); const elapsed = ((Date.now() - t0) / 1000).toFixed(1); - log("inject-key", ` CLI exited ${result.code} in ${elapsed}s`); + log("inject-key", ` provider=${update.name} CLI exited ${result.code} in ${elapsed}s`); if (result.stdout.trim()) log("inject-key", ` stdout: ${result.stdout.trim()}`); if (result.stderr.trim()) log("inject-key", ` stderr: ${result.stderr.trim()}`); if (result.code !== 0) { const err = (result.stderr || result.stdout || "unknown error").trim(); - log("inject-key", `step 3/3: FAILED — ${err}`); - injectKeyState.status = "error"; - injectKeyState.error = err; - return; + throw new Error(`${update.name}: ${err}`); } - log("inject-key", "step 3/3: SUCCESS — provider nvidia-inference updated"); - cacheProviderConfig("nvidia-inference", { - OPENAI_BASE_URL: "https://inference-api.nvidia.com/v1", - }); + cacheProviderConfig(update.name, update.cache); + return update.name; + }) + ) + .then(async (updatedProviders) => { + log("inject-key", `step 3/4: SUCCESS — providers updated: ${updatedProviders.join(", ")}`); + if (sandboxState.status === "creating" || sandboxState.status === "running") { + log( + "inject-key", + `step 4/4: waiting ${Math.ceil(INFERENCE_BUNDLE_SETTLE_MS / 1000)}s for sandbox inference bundle refresh` + ); + await sleep(INFERENCE_BUNDLE_SETTLE_MS); + } injectKeyState.status = "done"; injectKeyState.error = null; injectKeyState.keyHash = keyHash; + log("inject-key", "step 4/4: inference providers ready"); }) .catch((e) => { - log("inject-key", `step 3/3: EXCEPTION — ${e}`); + log("inject-key", `step 3/4: FAILED — ${e}`); injectKeyState.status = "error"; injectKeyState.error = String(e); }); diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index d5abc42..9f53291 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -22,6 +22,7 @@ const STABLE_CONNECTION_WINDOW_MS = 1_500; const INITIAL_CONNECTION_TIMEOUT_MS = 20_000; const EXTENDED_CONNECTION_TIMEOUT_MS = 90_000; const PAIRING_STATUS_POLL_MS = 500; +const PAIRING_REARM_INTERVAL_MS = 4_000; const PAIRING_RELOAD_FLAG = "nemoclaw:pairing-bootstrap-recovery-reload"; interface PairingBootstrapState { @@ -151,7 +152,9 @@ function bootstrap() { let pairingPollTimer = 0; let stopped = false; + let dashboardStable = false; let latestPairingState: PairingBootstrapState | null = null; + let lastPairingStartAt = Date.now(); const stopPairingPoll = () => { stopped = true; @@ -164,6 +167,23 @@ function bootstrap() { latestPairingState = state; const text = getOverlayTextForPairingState(state); if (text) setConnectOverlayText(text); + + if ( + !stopped && + !dashboardStable && + state && + !state.active && + Date.now() - lastPairingStartAt >= PAIRING_REARM_INTERVAL_MS + ) { + const rearmed = await fetchPairingBootstrapState("POST"); + if (rearmed) { + latestPairingState = rearmed; + lastPairingStartAt = Date.now(); + const rearmedText = getOverlayTextForPairingState(rearmed); + if (rearmedText) setConnectOverlayText(rearmedText); + } + } + pairingPollTimer = window.setTimeout(pollPairingState, PAIRING_STATUS_POLL_MS); return state; }; @@ -210,6 +230,7 @@ function bootstrap() { ); }) .then(() => { + dashboardStable = true; console.info("[NeMoClaw] pairing bootstrap: reveal app"); stopPairingPoll(); setConnectOverlayText("Device pairing approved. Opening dashboard..."); diff --git a/sandboxes/nemoclaw/policy-proxy.js b/sandboxes/nemoclaw/policy-proxy.js index bc617ef..0ea82a4 100644 --- a/sandboxes/nemoclaw/policy-proxy.js +++ b/sandboxes/nemoclaw/policy-proxy.js @@ -21,10 +21,11 @@ const POLICY_PATH = process.env.POLICY_PATH || "/etc/openshell/policy.yaml"; const UPSTREAM_PORT = parseInt(process.env.UPSTREAM_PORT || "18788", 10); const LISTEN_PORT = parseInt(process.env.LISTEN_PORT || "18789", 10); const UPSTREAM_HOST = "127.0.0.1"; -const AUTO_PAIR_TIMEOUT_MS = parseInt(process.env.AUTO_PAIR_TIMEOUT_MS || "120000", 10); -const AUTO_PAIR_POLL_MS = parseInt(process.env.AUTO_PAIR_POLL_MS || "300", 10); -const AUTO_PAIR_HEARTBEAT_MS = parseInt(process.env.AUTO_PAIR_HEARTBEAT_MS || "10000", 10); +const AUTO_PAIR_TIMEOUT_MS = parseInt(process.env.AUTO_PAIR_TIMEOUT_MS || "600000", 10); +const AUTO_PAIR_POLL_MS = parseInt(process.env.AUTO_PAIR_POLL_MS || "500", 10); +const AUTO_PAIR_HEARTBEAT_MS = parseInt(process.env.AUTO_PAIR_HEARTBEAT_MS || "30000", 10); const AUTO_PAIR_QUIET_POLLS = parseInt(process.env.AUTO_PAIR_QUIET_POLLS || "4", 10); +const AUTO_PAIR_APPROVAL_SETTLE_MS = parseInt(process.env.AUTO_PAIR_APPROVAL_SETTLE_MS || "30000", 10); const PROTO_DIR = "/usr/local/lib/nemoclaw-proto"; @@ -58,6 +59,7 @@ const pairingBootstrap = { active: false, timer: null, heartbeatAt: 0, + lastApprovalAt: 0, }; function formatRequestLine(req) { @@ -83,6 +85,7 @@ function pairingSnapshot() { sawPending: pairingBootstrap.sawPending, sawPaired: pairingBootstrap.sawPaired, active: pairingBootstrap.active, + lastApprovalAt: pairingBootstrap.lastApprovalAt || null, }; } @@ -133,6 +136,11 @@ function approvalDeviceId(raw) { return deviceId ? deviceId.slice(0, 12) : ""; } +function approvalRequestId(raw) { + const parsed = parseJsonBody(raw); + return parsed && typeof parsed.requestId === "string" ? parsed.requestId.trim() : ""; +} + function summarizeDevices(devices) { const format = (entries) => { if (!Array.isArray(entries) || entries.length === 0) return "-"; @@ -191,47 +199,69 @@ async function runPairingBootstrapTick() { }); } - if (!hasPending && hasPaired) { - const quietPolls = pairingBootstrap.quietPolls + 1; - updatePairingState({ quietPolls }); - if (quietPolls >= AUTO_PAIR_QUIET_POLLS) { - finishPairingBootstrap("approved"); - return; - } - } else { - updatePairingState({ quietPolls: 0 }); - } - + let approvalsThisTick = 0; + let lastApprovedDeviceId = ""; if (hasPending) { updatePairingState({ status: "approving" }); - const approveResult = await execOpenClaw(["devices", "approve", "--latest", "--json"]); - if (approvalSucceeded(approveResult.stdout)) { - updatePairingState({ - approvedCount: pairingBootstrap.approvedCount + 1, - lastApprovalDeviceId: approvalDeviceId(approveResult.stdout), - lastError: "", - quietPolls: 0, - status: "approved-pending-settle", - }); - console.log( - `[auto-pair] approved request attempts=${pairingBootstrap.attempts} ` + - `count=${pairingBootstrap.approvedCount} device=${pairingBootstrap.lastApprovalDeviceId || "unknown"}` - ); - } else if ((approveResult.stdout || approveResult.stderr).trim()) { - const noisy = !/no pending|no device|not paired|nothing to approve/i.test( - `${approveResult.stdout}\n${approveResult.stderr}` - ); - if (noisy) { - updatePairingState({ - errors: pairingBootstrap.errors + 1, - lastError: approveResult.stderr || approveResult.stdout || approveResult.error || "approve failed", - }); - console.warn( - `[auto-pair] approve --latest unexpected output attempts=${pairingBootstrap.attempts} ` + - `errors=${pairingBootstrap.errors}: ${pairingBootstrap.lastError}` + + for (const pending of devices.pending) { + const requestId = pending && typeof pending.requestId === "string" ? pending.requestId.trim() : ""; + if (!requestId) continue; + + const approveResult = await execOpenClaw(["devices", "approve", requestId, "--json"]); + if (approvalSucceeded(approveResult.stdout)) { + approvalsThisTick += 1; + lastApprovedDeviceId = approvalDeviceId(approveResult.stdout) || lastApprovedDeviceId; + console.log( + `[auto-pair] approved request attempts=${pairingBootstrap.attempts} ` + + `request=${approvalRequestId(approveResult.stdout) || requestId} device=${lastApprovedDeviceId || "unknown"}` ); + continue; } + + if ((approveResult.stdout || approveResult.stderr).trim()) { + const noisy = !/no pending|no device|not paired|nothing to approve|unknown requestId/i.test( + `${approveResult.stdout}\n${approveResult.stderr}` + ); + if (noisy) { + updatePairingState({ + errors: pairingBootstrap.errors + 1, + lastError: approveResult.stderr || approveResult.stdout || approveResult.error || "approve failed", + }); + console.warn( + `[auto-pair] approve ${requestId} unexpected output attempts=${pairingBootstrap.attempts} ` + + `errors=${pairingBootstrap.errors}: ${pairingBootstrap.lastError}` + ); + } + } + } + } + + if (approvalsThisTick > 0) { + const nextApprovedCount = pairingBootstrap.approvedCount + approvalsThisTick; + updatePairingState({ + approvedCount: nextApprovedCount, + lastApprovalDeviceId: lastApprovedDeviceId || pairingBootstrap.lastApprovalDeviceId, + lastApprovalAt: Date.now(), + lastError: "", + quietPolls: 0, + status: "approved-pending-settle", + sawPending: true, + }); + } else { + const quietPolls = pairingBootstrap.approvedCount > 0 ? pairingBootstrap.quietPolls + 1 : 0; + updatePairingState({ quietPolls }); + + if ( + pairingBootstrap.approvedCount > 0 && + pairingBootstrap.lastApprovalAt > 0 && + Date.now() - pairingBootstrap.lastApprovalAt >= AUTO_PAIR_APPROVAL_SETTLE_MS && + quietPolls >= AUTO_PAIR_QUIET_POLLS + ) { + finishPairingBootstrap("approved"); + return; } + } if (now - pairingBootstrap.heartbeatAt >= AUTO_PAIR_HEARTBEAT_MS) { @@ -249,13 +279,10 @@ function startPairingBootstrap(reason) { if (pairingBootstrap.active) { return pairingSnapshot(); } - if (pairingBootstrap.status === "approved") { - return pairingSnapshot(); - } updatePairingState({ status: "armed", startedAt: Date.now(), - approvedCount: pairingBootstrap.status === "timeout" || pairingBootstrap.status === "error" ? 0 : pairingBootstrap.approvedCount, + approvedCount: 0, errors: 0, attempts: 0, quietPolls: 0, @@ -265,6 +292,7 @@ function startPairingBootstrap(reason) { sawPaired: false, active: true, heartbeatAt: 0, + lastApprovalAt: 0, }); console.log(`[auto-pair] watcher starting reason=${reason} timeout=${AUTO_PAIR_TIMEOUT_MS}ms poll=${AUTO_PAIR_POLL_MS}ms`); runPairingBootstrapTick().catch((error) => { @@ -811,20 +839,17 @@ function handlePairingBootstrap(req, res) { } if (req.method === "GET") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(pairingSnapshot())); + sendJson(res, 200, pairingSnapshot()); return; } if (req.method === "POST") { const snapshot = startPairingBootstrap("api"); - res.writeHead(202, { "Content-Type": "application/json" }); - res.end(JSON.stringify(snapshot)); + sendJson(res, 202, snapshot); return; } - res.writeHead(405, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "method not allowed" })); + sendJson(res, 405, { error: "method not allowed" }); } function shouldArmPairingFromRequest(req) { @@ -836,6 +861,15 @@ function shouldArmPairingFromRequest(req) { return true; } +function sendJson(res, status, body) { + const raw = JSON.stringify(body); + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(raw), + }); + res.end(raw); +} + // --------------------------------------------------------------------------- // HTTP server // --------------------------------------------------------------------------- From 9f50d9d3c2f2d8803ed5089c77f03791cdf7f312 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 11:42:15 -0700 Subject: [PATCH 04/16] Ensure continuous status RPC response before ready state --- .../extension/gateway-bridge.ts | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts index 06a60c5..b5ba014 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts @@ -126,26 +126,45 @@ export function waitForStableConnection( ): Promise { return new Promise((resolve, reject) => { const start = Date.now(); - let connectedSince = isAppConnected() ? Date.now() : 0; + let healthySince = 0; + let cancelled = false; - const interval = setInterval(() => { + const tick = async () => { + if (cancelled) return; const now = Date.now(); - if (isAppConnected()) { - if (!connectedSince) connectedSince = now; - if (now - connectedSince >= stableForMs) { - clearInterval(interval); - resolve(); - return; - } + if (!isAppConnected()) { + healthySince = 0; } else { - connectedSince = 0; + const client = getClient(); + if (!client) { + healthySince = 0; + } else { + try { + await client.request("status", {}); + if (!healthySince) healthySince = now; + if (now - healthySince >= stableForMs) { + cancelled = true; + resolve(); + return; + } + } catch { + healthySince = 0; + } + } } if (now - start > timeoutMs) { - clearInterval(interval); - reject(new Error("Timed out waiting for stable gateway connection")); + cancelled = true; + reject(new Error("Timed out waiting for stable operational gateway connection")); + return; } - }, CONNECTION_POLL_INTERVAL_MS); + + window.setTimeout(() => { + void tick(); + }, CONNECTION_POLL_INTERVAL_MS); + }; + + void tick(); }); } From fa02a00a74adcdf76825adff18183d7e1e448eb5 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 11:46:08 -0700 Subject: [PATCH 05/16] Quiet noisy logs --- brev/welcome-ui/server.js | 38 ++++++++++++++++++++++++------ sandboxes/nemoclaw/policy-proxy.js | 36 +++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/brev/welcome-ui/server.js b/brev/welcome-ui/server.js index e46cc3f..3d28c90 100644 --- a/brev/welcome-ui/server.js +++ b/brev/welcome-ui/server.js @@ -111,6 +111,24 @@ function logWelcome(msg) { process.stderr.write(`[welcome-ui] ${msg}\n`); } +function isNoisyProxyPath(method, requestPath) { + if (!requestPath) return false; + if (requestPath === "/api/pairing-bootstrap") return true; + if (requestPath === "/favicon.ico" || requestPath === "/favicon.svg") return true; + if (requestPath.endsWith(".map")) return true; + if ((method || "GET") === "GET" && requestPath.startsWith("/assets/")) return true; + return false; +} + +function shouldLogProxyRequest(method, requestPath) { + return !isNoisyProxyPath(method, requestPath); +} + +function shouldLogProxyResponse(method, requestPath, statusCode) { + if ((statusCode || 0) >= 400) return true; + return !isNoisyProxyPath(method, requestPath); +} + /** * Promise wrapper around child_process.execFile with timeout. * Returns { code, stdout, stderr }. @@ -1184,9 +1202,11 @@ async function handleClusterInferenceSet(req, res) { // ── Reverse proxy (HTTP) ─────────────────────────────────────────────────── function proxyToSandbox(clientReq, clientRes) { - logWelcome( - `proxy http in ${clientReq.method || "GET"} ${clientReq.url || "/"} -> 127.0.0.1:${SANDBOX_PORT}` - ); + if (shouldLogProxyRequest(clientReq.method, clientReq.url || "/")) { + logWelcome( + `proxy http in ${clientReq.method || "GET"} ${clientReq.url || "/"} -> 127.0.0.1:${SANDBOX_PORT}` + ); + } const headers = {}; for (const [key, val] of Object.entries(clientReq.headers)) { if (key.toLowerCase() === "host") continue; @@ -1204,9 +1224,11 @@ function proxyToSandbox(clientReq, clientRes) { }; const upstream = http.request(opts, (upstreamRes) => { - logWelcome( - `proxy http out ${clientReq.method || "GET"} ${clientReq.url || "/"} status=${upstreamRes.statusCode || 0}` - ); + if (shouldLogProxyResponse(clientReq.method, clientReq.url || "/", upstreamRes.statusCode || 0)) { + logWelcome( + `proxy http out ${clientReq.method || "GET"} ${clientReq.url || "/"} status=${upstreamRes.statusCode || 0}` + ); + } // Filter hop-by-hop + content-length (we'll set our own) const outHeaders = {}; for (const [key, val] of Object.entries(upstreamRes.headers)) { @@ -1245,7 +1267,9 @@ function proxyToSandbox(clientReq, clientRes) { // ── Reverse proxy (WebSocket) ────────────────────────────────────────────── function proxyWebSocket(req, clientSocket, head) { - logWelcome(`proxy ws in ${req.method || "GET"} ${req.url || "/"} -> 127.0.0.1:${SANDBOX_PORT}`); + if (shouldLogProxyRequest(req.method, req.url || "/")) { + logWelcome(`proxy ws in ${req.method || "GET"} ${req.url || "/"} -> 127.0.0.1:${SANDBOX_PORT}`); + } const upstream = net.createConnection( { host: "127.0.0.1", port: SANDBOX_PORT }, () => { diff --git a/sandboxes/nemoclaw/policy-proxy.js b/sandboxes/nemoclaw/policy-proxy.js index 0ea82a4..5a68b1c 100644 --- a/sandboxes/nemoclaw/policy-proxy.js +++ b/sandboxes/nemoclaw/policy-proxy.js @@ -67,6 +67,24 @@ function formatRequestLine(req) { return `${req.method || "GET"} ${req.url || "/"} host=${host}`; } +function isNoisyProxyPath(method, requestPath) { + if (!requestPath) return false; + if (requestPath === "/api/pairing-bootstrap") return true; + if (requestPath === "/favicon.ico" || requestPath === "/favicon.svg") return true; + if (requestPath.endsWith(".map")) return true; + if ((method || "GET") === "GET" && requestPath.startsWith("/assets/")) return true; + return false; +} + +function shouldLogProxyRequest(method, requestPath) { + return !isNoisyProxyPath(method, requestPath); +} + +function shouldLogProxyResponse(method, requestPath, statusCode) { + if ((statusCode || 0) >= 400) return true; + return !isNoisyProxyPath(method, requestPath); +} + function updatePairingState(patch) { Object.assign(pairingBootstrap, patch, { updatedAt: Date.now() }); } @@ -715,7 +733,9 @@ function scheduleStartupAudit(attempt = 1) { // --------------------------------------------------------------------------- function proxyRequest(clientReq, clientRes) { - console.log(`[policy-proxy] http in ${formatRequestLine(clientReq)} -> ${UPSTREAM_HOST}:${UPSTREAM_PORT}`); + if (shouldLogProxyRequest(clientReq.method, clientReq.url || "/")) { + console.log(`[policy-proxy] http in ${formatRequestLine(clientReq)} -> ${UPSTREAM_HOST}:${UPSTREAM_PORT}`); + } const opts = { hostname: UPSTREAM_HOST, port: UPSTREAM_PORT, @@ -725,10 +745,12 @@ function proxyRequest(clientReq, clientRes) { }; const upstream = http.request(opts, (upstreamRes) => { - console.log( - `[policy-proxy] http out ${clientReq.method || "GET"} ${clientReq.url || "/"} ` + - `status=${upstreamRes.statusCode || 0}` - ); + if (shouldLogProxyResponse(clientReq.method, clientReq.url || "/", upstreamRes.statusCode || 0)) { + console.log( + `[policy-proxy] http out ${clientReq.method || "GET"} ${clientReq.url || "/"} ` + + `status=${upstreamRes.statusCode || 0}` + ); + } clientRes.writeHead(upstreamRes.statusCode, upstreamRes.headers); upstreamRes.pipe(clientRes, { end: true }); }); @@ -909,7 +931,9 @@ const server = http.createServer((req, res) => { // WebSocket upgrade — pipe raw TCP to upstream server.on("upgrade", (req, socket, head) => { startPairingBootstrap(`ws:${req.url || "/"}`); - console.log(`[policy-proxy] ws in ${formatRequestLine(req)} -> ${UPSTREAM_HOST}:${UPSTREAM_PORT}`); + if (shouldLogProxyRequest(req.method, req.url || "/")) { + console.log(`[policy-proxy] ws in ${formatRequestLine(req)} -> ${UPSTREAM_HOST}:${UPSTREAM_PORT}`); + } const upstream = net.createConnection({ host: UPSTREAM_HOST, port: UPSTREAM_PORT }, () => { const reqLine = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`; let headers = ""; From ea84f4d48a11f1809657252fb7f50532da082b90 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 12:17:28 -0700 Subject: [PATCH 06/16] Fixup trustedProxies, pairing re-arm --- brev/welcome-ui/server.js | 8 +------ sandboxes/nemoclaw/nemoclaw-start.sh | 2 ++ .../nemoclaw-ui-extension/extension/index.ts | 23 ++++++++++++++++--- sandboxes/nemoclaw/policy-proxy.js | 20 ++++++++++++++-- 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/brev/welcome-ui/server.js b/brev/welcome-ui/server.js index 3d28c90..7e83772 100644 --- a/brev/welcome-ui/server.js +++ b/brev/welcome-ui/server.js @@ -1209,10 +1209,8 @@ function proxyToSandbox(clientReq, clientRes) { } const headers = {}; for (const [key, val] of Object.entries(clientReq.headers)) { - if (key.toLowerCase() === "host") continue; headers[key] = val; } - headers["host"] = `127.0.0.1:${SANDBOX_PORT}`; const opts = { hostname: "127.0.0.1", @@ -1279,11 +1277,7 @@ function proxyWebSocket(req, clientSocket, head) { for (let i = 0; i < req.rawHeaders.length; i += 2) { const key = req.rawHeaders[i]; const val = req.rawHeaders[i + 1]; - if (key.toLowerCase() === "host") { - headers += `Host: 127.0.0.1:${SANDBOX_PORT}\r\n`; - } else { - headers += `${key}: ${val}\r\n`; - } + headers += `${key}: ${val}\r\n`; } upstream.write(reqLine + headers + "\r\n"); if (head && head.length) upstream.write(head); diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index e1316c5..5852c13 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -109,7 +109,9 @@ if chat_origin != local: cfg['gateway']['controlUi'] = { 'allowInsecureAuth': True, 'allowedOrigins': origins, + 'dangerouslyAllowHostHeaderOriginFallback': True, } +cfg['gateway']['trustedProxies'] = ['127.0.0.1', '::1'] for provider in cfg.get('models', {}).get('providers', {}).values(): if not isinstance(provider, dict): continue diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index 9f53291..1c80295 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -148,13 +148,12 @@ function getOverlayTextForPairingState(state: PairingBootstrapState | null): str function bootstrap() { console.info("[NeMoClaw] pairing bootstrap: start"); showConnectOverlay(); - void fetchPairingBootstrapState("POST"); let pairingPollTimer = 0; let stopped = false; let dashboardStable = false; let latestPairingState: PairingBootstrapState | null = null; - let lastPairingStartAt = Date.now(); + let lastPairingStartAt = 0; const stopPairingPoll = () => { stopped = true; @@ -173,6 +172,7 @@ function bootstrap() { !dashboardStable && state && !state.active && + !isPairingTerminal(state) && Date.now() - lastPairingStartAt >= PAIRING_REARM_INTERVAL_MS ) { const rearmed = await fetchPairingBootstrapState("POST"); @@ -188,7 +188,24 @@ function bootstrap() { return state; }; - void pollPairingState(); + void (async () => { + const initialState = await fetchPairingBootstrapState("GET"); + latestPairingState = initialState; + const initialText = getOverlayTextForPairingState(initialState); + if (initialText) setConnectOverlayText(initialText); + + if (!initialState || (!initialState.active && !isPairingTerminal(initialState))) { + const started = await fetchPairingBootstrapState("POST"); + if (started) { + latestPairingState = started; + lastPairingStartAt = Date.now(); + const startedText = getOverlayTextForPairingState(started); + if (startedText) setConnectOverlayText(startedText); + } + } + + await pollPairingState(); + })(); const waitForDashboardReadiness = async (timeoutMs: number, overlayText: string) => { setConnectOverlayText(overlayText); diff --git a/sandboxes/nemoclaw/policy-proxy.js b/sandboxes/nemoclaw/policy-proxy.js index 5a68b1c..0405354 100644 --- a/sandboxes/nemoclaw/policy-proxy.js +++ b/sandboxes/nemoclaw/policy-proxy.js @@ -270,6 +270,16 @@ async function runPairingBootstrapTick() { const quietPolls = pairingBootstrap.approvedCount > 0 ? pairingBootstrap.quietPolls + 1 : 0; updatePairingState({ quietPolls }); + if ( + pairingBootstrap.approvedCount === 0 && + !hasPending && + hasPaired && + quietPolls >= AUTO_PAIR_QUIET_POLLS + ) { + finishPairingBootstrap("paired"); + return; + } + if ( pairingBootstrap.approvedCount > 0 && pairingBootstrap.lastApprovalAt > 0 && @@ -293,10 +303,16 @@ async function runPairingBootstrapTick() { pairingBootstrap.timer = setTimeout(runPairingBootstrapTick, AUTO_PAIR_POLL_MS); } -function startPairingBootstrap(reason) { +function startPairingBootstrap(reason, force = false) { if (pairingBootstrap.active) { return pairingSnapshot(); } + if ( + !force && + (pairingBootstrap.status === "approved" || pairingBootstrap.status === "paired") + ) { + return pairingSnapshot(); + } updatePairingState({ status: "armed", startedAt: Date.now(), @@ -866,7 +882,7 @@ function handlePairingBootstrap(req, res) { } if (req.method === "POST") { - const snapshot = startPairingBootstrap("api"); + const snapshot = startPairingBootstrap("api", true); sendJson(res, 202, snapshot); return; } From ed83e5991aaf07a236f133208938b0cdd6dd9b6d Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 12:47:19 -0700 Subject: [PATCH 07/16] Track device pairing state, minimize overlay --- .../extension/gateway-bridge.ts | 30 +++- .../nemoclaw-ui-extension/extension/index.ts | 144 +++++++++++++----- 2 files changed, 139 insertions(+), 35 deletions(-) diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts index b5ba014..eafb43e 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts @@ -15,6 +15,7 @@ interface ConfigSnapshot { } const CONNECTION_POLL_INTERVAL_MS = 200; +const BLOCKING_GATEWAY_MESSAGE_RE = /(pairing required|origin not allowed)/i; /** * Returns the live GatewayBrowserClient from the element, @@ -89,6 +90,33 @@ export function isAppConnected(): boolean { return app?.connected === true; } +function collectVisibleText(root: ParentNode | ShadowRoot | null): string { + if (!root) return ""; + const chunks: string[] = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + + let node: Node | null = walker.currentNode; + while (node) { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent?.trim(); + if (text) chunks.push(text); + } else if (node instanceof Element && node.shadowRoot) { + const shadowText = collectVisibleText(node.shadowRoot); + if (shadowText) chunks.push(shadowText); + } + node = walker.nextNode(); + } + + return chunks.join(" "); +} + +export function hasBlockingGatewayMessage(): boolean { + const app = document.querySelector("openclaw-app") as (HTMLElement & { shadowRoot?: ShadowRoot | null }) | null; + if (!app) return false; + const text = `${collectVisibleText(app)} ${collectVisibleText(app.shadowRoot ?? null)}`; + return BLOCKING_GATEWAY_MESSAGE_RE.test(text); +} + /** * Wait for the gateway to reconnect after a restart (e.g. after config.patch). * @@ -133,7 +161,7 @@ export function waitForStableConnection( if (cancelled) return; const now = Date.now(); - if (!isAppConnected()) { + if (!isAppConnected() || hasBlockingGatewayMessage()) { healthySince = 0; } else { const client = getClient(); diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index 1c80295..042d7da 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -15,14 +15,19 @@ import { injectButton } from "./deploy-modal.ts"; import { injectNavGroup, activateNemoPage, watchOpenClawNavClicks } from "./nav-group.ts"; import { injectModelSelector, watchChatCompose } from "./model-selector.ts"; import { ingestKeysFromUrl, DEFAULT_MODEL, resolveApiKey, isKeyConfigured } from "./model-registry.ts"; -import { waitForStableConnection } from "./gateway-bridge.ts"; +import { hasBlockingGatewayMessage, waitForStableConnection } from "./gateway-bridge.ts"; import { syncKeysToProviders } from "./api-keys-page.ts"; const STABLE_CONNECTION_WINDOW_MS = 1_500; const INITIAL_CONNECTION_TIMEOUT_MS = 20_000; const EXTENDED_CONNECTION_TIMEOUT_MS = 90_000; +const WARM_START_CONNECTION_WINDOW_MS = 500; +const WARM_START_TIMEOUT_MS = 2_500; +const OVERLAY_SHOW_DELAY_MS = 400; const PAIRING_STATUS_POLL_MS = 500; const PAIRING_REARM_INTERVAL_MS = 4_000; +const POST_READY_SETTLE_MS = 750; +const PAIRING_BOOTSTRAPPED_FLAG = "nemoclaw:pairing-bootstrap-complete"; const PAIRING_RELOAD_FLAG = "nemoclaw:pairing-bootstrap-recovery-reload"; interface PairingBootstrapState { @@ -88,6 +93,7 @@ function setConnectOverlayText(text: string): void { } function revealApp(): void { + markPairingBootstrapped(); document.body.setAttribute("data-nemoclaw-ready", ""); const overlay = document.querySelector(".nemoclaw-connect-overlay"); if (overlay) { @@ -104,6 +110,22 @@ function shouldAllowRecoveryReload(): boolean { } } +function isPairingBootstrapped(): boolean { + try { + return sessionStorage.getItem(PAIRING_BOOTSTRAPPED_FLAG) === "1"; + } catch { + return false; + } +} + +function markPairingBootstrapped(): void { + try { + sessionStorage.setItem(PAIRING_BOOTSTRAPPED_FLAG, "1"); + } catch { + // ignore storage failures + } +} + function markRecoveryReloadUsed(): void { try { sessionStorage.setItem(PAIRING_RELOAD_FLAG, "1"); @@ -147,17 +169,33 @@ function getOverlayTextForPairingState(state: PairingBootstrapState | null): str function bootstrap() { console.info("[NeMoClaw] pairing bootstrap: start"); - showConnectOverlay(); let pairingPollTimer = 0; + let overlayTimer = 0; let stopped = false; let dashboardStable = false; let latestPairingState: PairingBootstrapState | null = null; let lastPairingStartAt = 0; + let overlayVisible = false; const stopPairingPoll = () => { stopped = true; if (pairingPollTimer) window.clearTimeout(pairingPollTimer); + if (overlayTimer) window.clearTimeout(overlayTimer); + }; + + const ensureOverlayVisible = () => { + if (overlayVisible) return; + overlayVisible = true; + showConnectOverlay(); + }; + + const scheduleOverlay = () => { + if (overlayVisible || overlayTimer) return; + overlayTimer = window.setTimeout(() => { + overlayTimer = 0; + ensureOverlayVisible(); + }, OVERLAY_SHOW_DELAY_MS); }; const pollPairingState = async () => { @@ -191,10 +229,31 @@ function bootstrap() { void (async () => { const initialState = await fetchPairingBootstrapState("GET"); latestPairingState = initialState; + + if (initialState && !initialState.active && isPairingTerminal(initialState)) { + const shouldWarmStart = isPairingBootstrapped() || initialState.status === "paired" || initialState.status === "approved"; + if (shouldWarmStart) { + try { + await waitForStableConnection(WARM_START_CONNECTION_WINDOW_MS, WARM_START_TIMEOUT_MS); + console.info("[NeMoClaw] pairing bootstrap: warm start succeeded"); + stopPairingPoll(); + revealApp(); + return; + } catch { + // Fall through to normal bootstrap flow. + } + } + } + + scheduleOverlay(); const initialText = getOverlayTextForPairingState(initialState); - if (initialText) setConnectOverlayText(initialText); + if (initialText) { + ensureOverlayVisible(); + setConnectOverlayText(initialText); + } if (!initialState || (!initialState.active && !isPairingTerminal(initialState))) { + ensureOverlayVisible(); const started = await fetchPairingBootstrapState("POST"); if (started) { latestPairingState = started; @@ -205,9 +264,11 @@ function bootstrap() { } await pollPairingState(); + runReadinessFlow(); })(); const waitForDashboardReadiness = async (timeoutMs: number, overlayText: string) => { + ensureOverlayVisible(); setConnectOverlayText(overlayText); await waitForStableConnection(STABLE_CONNECTION_WINDOW_MS, timeoutMs); }; @@ -232,37 +293,52 @@ function bootstrap() { return false; }; - waitForDashboardReadiness( - INITIAL_CONNECTION_TIMEOUT_MS, - "Auto-approving device pairing. Hang tight...", - ) - .catch(async () => { - console.warn("[NeMoClaw] pairing bootstrap: initial dashboard readiness check timed out; extending wait"); - if (await handlePairingTerminalWithoutStableConnection("initial readiness timed out")) { - return; - } - return waitForDashboardReadiness( - EXTENDED_CONNECTION_TIMEOUT_MS, - "Still waiting for device pairing approval...", - ); - }) - .then(() => { - dashboardStable = true; - console.info("[NeMoClaw] pairing bootstrap: reveal app"); - stopPairingPoll(); - setConnectOverlayText("Device pairing approved. Opening dashboard..."); - revealApp(); - }) - .catch(async () => { - if (await handlePairingTerminalWithoutStableConnection("extended readiness timed out")) { - return; - } - const state = latestPairingState || await fetchPairingBootstrapState("GET"); - const status = state?.status || "unknown"; - console.warn(`[NeMoClaw] pairing bootstrap: readiness timed out; revealing app anyway (status=${status})`); - stopPairingPoll(); - revealApp(); - }); + const runReadinessFlow = () => { + waitForDashboardReadiness( + INITIAL_CONNECTION_TIMEOUT_MS, + "Auto-approving device pairing. Hang tight...", + ) + .catch(async () => { + console.warn("[NeMoClaw] pairing bootstrap: initial dashboard readiness check timed out; extending wait"); + if (await handlePairingTerminalWithoutStableConnection("initial readiness timed out")) { + return; + } + return waitForDashboardReadiness( + EXTENDED_CONNECTION_TIMEOUT_MS, + "Still waiting for device pairing approval...", + ); + }) + .then(async () => { + await new Promise((resolve) => window.setTimeout(resolve, POST_READY_SETTLE_MS)); + const settledState = await fetchPairingBootstrapState("GET"); + if (settledState) latestPairingState = settledState; + + if (hasBlockingGatewayMessage() && shouldAllowRecoveryReload()) { + console.warn("[NeMoClaw] pairing bootstrap: stable connection reached but dashboard still needs one recovery reload"); + stopPairingPoll(); + markRecoveryReloadUsed(); + setConnectOverlayText("Pairing succeeded. Recovering dashboard..."); + window.setTimeout(() => window.location.reload(), 300); + return; + } + + dashboardStable = true; + console.info("[NeMoClaw] pairing bootstrap: reveal app"); + stopPairingPoll(); + setConnectOverlayText("Device pairing approved. Opening dashboard..."); + revealApp(); + }) + .catch(async () => { + if (await handlePairingTerminalWithoutStableConnection("extended readiness timed out")) { + return; + } + const state = latestPairingState || await fetchPairingBootstrapState("GET"); + const status = state?.status || "unknown"; + console.warn(`[NeMoClaw] pairing bootstrap: readiness timed out; revealing app anyway (status=${status})`); + stopPairingPoll(); + revealApp(); + }); + }; const keysIngested = ingestKeysFromUrl(); From 950e0723424b2030b1f537df1a237ea1c79ab5ce Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 13:14:10 -0700 Subject: [PATCH 08/16] Trigger model switch to assert inference route --- .../extension/model-selector.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts index 3c897ce..c2d6598 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts @@ -37,6 +37,8 @@ let selectedModelId = DEFAULT_MODEL.id; let modelSelectorObserver: MutationObserver | null = null; let applyInFlight = false; let currentWrapper: HTMLElement | null = null; +let activeClusterRoute: ClusterRoute | null = null; +const ACTIVE_ROUTE_PRIME_PREFIX = "nemoclaw:active-route-primed:"; // --------------------------------------------------------------------------- // Build the config.patch payload for a given model entry @@ -114,6 +116,7 @@ async function fetchDynamic(): Promise { route = { providerName: body.providerName, modelId: body.modelId || "", version: body.version || 0 }; } } + activeClusterRoute = route; const entries: ModelEntry[] = []; @@ -143,6 +146,39 @@ async function fetchDynamic(): Promise { } } +function hasPrimedActiveRoute(route: ClusterRoute): boolean { + try { + return sessionStorage.getItem(`${ACTIVE_ROUTE_PRIME_PREFIX}${route.providerName}:${route.modelId}`) === "1"; + } catch { + return false; + } +} + +function markActiveRoutePrimed(route: ClusterRoute): void { + try { + sessionStorage.setItem(`${ACTIVE_ROUTE_PRIME_PREFIX}${route.providerName}:${route.modelId}`, "1"); + } catch { + // ignore storage failures + } +} + +async function primeActiveRoute(): Promise { + const route = activeClusterRoute; + if (!route?.providerName || !route.modelId || hasPrimedActiveRoute(route)) return; + + const res = await fetch("/api/cluster-inference", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerName: route.providerName, modelId: route.modelId }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error || `HTTP ${res.status}`); + } + + markActiveRoutePrimed(route); +} + // --------------------------------------------------------------------------- // Transition banner lifecycle // --------------------------------------------------------------------------- @@ -533,6 +569,9 @@ function buildModelSelector(): HTMLElement { if (valueEl) { valueEl.textContent = current ? current.name : "No model"; } + void primeActiveRoute().catch((err) => { + console.warn("[NeMoClaw] active route prime failed:", err); + }); }); return wrapper; From 0fa71abddc8f598d9a6c9ae671e2625552d05390 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 14:55:56 -0700 Subject: [PATCH 09/16] Fix public host URL --- brev/welcome-ui/server.js | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/brev/welcome-ui/server.js b/brev/welcome-ui/server.js index 7e83772..8a080f7 100644 --- a/brev/welcome-ui/server.js +++ b/brev/welcome-ui/server.js @@ -327,11 +327,15 @@ function normalizeBaseUrl(baseUrl) { } } +function isLocalBrowserHost(host) { + return /^(localhost|127\.0\.0\.1|\[::1\]|::1)(:\d+)?$/i.test(String(host || "").trim()); +} + function derivePublicBaseUrl(req) { const forwardedProto = firstForwardedValue(req?.headers?.["x-forwarded-proto"]); const forwardedHost = firstForwardedValue(req?.headers?.["x-forwarded-host"]); const host = firstForwardedValue(req?.headers?.host); - const proto = forwardedProto || (host && !/^127\.0\.0\.1(?::\d+)?$/.test(host) ? "https" : "http"); + const proto = forwardedProto || (host && !isLocalBrowserHost(host) ? "https" : "http"); const authority = forwardedHost || host; if (!authority) return ""; return normalizeBaseUrl(`${proto}://${authority}/`); @@ -340,7 +344,24 @@ function derivePublicBaseUrl(req) { function rememberPublicBaseUrl(req) { const resolved = derivePublicBaseUrl(req); if (resolved) { - detectedPublicBaseUrl = resolved; + const resolvedHost = (() => { + try { + return new URL(resolved).host; + } catch { + return ""; + } + })(); + const hasDetectedPublicHost = (() => { + try { + return Boolean(detectedPublicBaseUrl) && !isLocalBrowserHost(new URL(detectedPublicBaseUrl).host); + } catch { + return false; + } + })(); + + if (!isLocalBrowserHost(resolvedHost) || !hasDetectedPublicHost) { + detectedPublicBaseUrl = resolved; + } } return detectedPublicBaseUrl; } From de2f6d70b450437140a11e2272fd92179112e121 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 15:21:51 -0700 Subject: [PATCH 10/16] Improve console logging for active route priming --- .../nemoclaw-ui-extension/extension/index.ts | 6 +++++- .../extension/model-selector.ts | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index 042d7da..b56a0e5 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -13,7 +13,7 @@ import "./styles.css"; import { injectButton } from "./deploy-modal.ts"; import { injectNavGroup, activateNemoPage, watchOpenClawNavClicks } from "./nav-group.ts"; -import { injectModelSelector, watchChatCompose } from "./model-selector.ts"; +import { bootstrapActiveRoutePrime, injectModelSelector, watchChatCompose } from "./model-selector.ts"; import { ingestKeysFromUrl, DEFAULT_MODEL, resolveApiKey, isKeyConfigured } from "./model-registry.ts"; import { hasBlockingGatewayMessage, waitForStableConnection } from "./gateway-bridge.ts"; import { syncKeysToProviders } from "./api-keys-page.ts"; @@ -353,6 +353,10 @@ function bootstrap() { ); } + bootstrapActiveRoutePrime().catch((e) => + console.warn("[NeMoClaw] bootstrap active route prime failed:", e), + ); + if (inject()) { injectModelSelector(); return; diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts index c2d6598..f696926 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts @@ -164,7 +164,16 @@ function markActiveRoutePrimed(route: ClusterRoute): void { async function primeActiveRoute(): Promise { const route = activeClusterRoute; - if (!route?.providerName || !route.modelId || hasPrimedActiveRoute(route)) return; + if (!route?.providerName || !route.modelId) { + console.info("[NeMoClaw] active route prime: skipped (no active route)"); + return; + } + if (hasPrimedActiveRoute(route)) { + console.info(`[NeMoClaw] active route prime: skipped (already primed ${route.providerName}/${route.modelId})`); + return; + } + + console.info(`[NeMoClaw] active route prime: start ${route.providerName}/${route.modelId}`); const res = await fetch("/api/cluster-inference", { method: "POST", @@ -177,6 +186,12 @@ async function primeActiveRoute(): Promise { } markActiveRoutePrimed(route); + console.info(`[NeMoClaw] active route prime: success ${route.providerName}/${route.modelId}`); +} + +export async function bootstrapActiveRoutePrime(): Promise { + await fetchDynamic(); + await primeActiveRoute(); } // --------------------------------------------------------------------------- From 372801a1a5a65d0f42542be6830f501e6879b07f Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 16:05:17 -0700 Subject: [PATCH 11/16] Rebrand openclaw-nvidia, fix readiness and prime timing --- brev/launch.sh | 8 +++---- brev/welcome-ui/SERVER_ARCHITECTURE.md | 22 +++++++++---------- .../__tests__/sandbox-lifecycle.test.js | 8 +++---- brev/welcome-ui/server.js | 6 ++--- .../{nemoclaw => openclaw-nvidia}/.gitignore | 0 .../{nemoclaw => openclaw-nvidia}/Dockerfile | 8 +++---- .../{nemoclaw => openclaw-nvidia}/README.md | 18 +++++++-------- .../{nemoclaw => openclaw-nvidia}/build.sh | 6 ++--- .../nemoclaw-ui-extension/.env.example | 0 .../extension/api-keys-page.ts | 0 .../extension/deploy-modal.ts | 0 .../extension/gateway-bridge.ts | 0 .../nemoclaw-ui-extension/extension/icons.ts | 0 .../nemoclaw-ui-extension/extension/index.ts | 16 +++++++++----- .../extension/inference-page.ts | 0 .../extension/model-registry.ts | 0 .../extension/model-selector.ts | 18 ++++----------- .../extension/nav-group.ts | 0 .../extension/package.json | 0 .../extension/policy-page.ts | 0 .../extension/styles.css | 0 .../nemoclaw-ui-extension/install.sh | 0 .../nemoclaw-ui-extension/uninstall.sh | 0 .../openclaw-nvidia-start.sh} | 4 ++-- .../policy-proxy.js | 0 .../{nemoclaw => openclaw-nvidia}/policy.yaml | 0 .../proto/datamodel.proto | 0 .../proto/navigator.proto | 0 .../proto/sandbox.proto | 0 29 files changed, 54 insertions(+), 60 deletions(-) rename sandboxes/{nemoclaw => openclaw-nvidia}/.gitignore (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/Dockerfile (92%) rename sandboxes/{nemoclaw => openclaw-nvidia}/README.md (86%) rename sandboxes/{nemoclaw => openclaw-nvidia}/build.sh (76%) rename sandboxes/{nemoclaw => openclaw-nvidia}/nemoclaw-ui-extension/.env.example (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/nemoclaw-ui-extension/extension/api-keys-page.ts (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/nemoclaw-ui-extension/extension/deploy-modal.ts (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/nemoclaw-ui-extension/extension/gateway-bridge.ts (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/nemoclaw-ui-extension/extension/icons.ts (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/nemoclaw-ui-extension/extension/index.ts (98%) rename sandboxes/{nemoclaw => openclaw-nvidia}/nemoclaw-ui-extension/extension/inference-page.ts (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/nemoclaw-ui-extension/extension/model-registry.ts (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/nemoclaw-ui-extension/extension/model-selector.ts (97%) rename sandboxes/{nemoclaw => openclaw-nvidia}/nemoclaw-ui-extension/extension/nav-group.ts (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/nemoclaw-ui-extension/extension/package.json (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/nemoclaw-ui-extension/extension/policy-page.ts (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/nemoclaw-ui-extension/extension/styles.css (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/nemoclaw-ui-extension/install.sh (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/nemoclaw-ui-extension/uninstall.sh (100%) rename sandboxes/{nemoclaw/nemoclaw-start.sh => openclaw-nvidia/openclaw-nvidia-start.sh} (98%) rename sandboxes/{nemoclaw => openclaw-nvidia}/policy-proxy.js (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/policy.yaml (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/proto/datamodel.proto (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/proto/navigator.proto (100%) rename sandboxes/{nemoclaw => openclaw-nvidia}/proto/sandbox.proto (100%) diff --git a/brev/launch.sh b/brev/launch.sh index 782be4e..891dbaf 100755 --- a/brev/launch.sh +++ b/brev/launch.sh @@ -32,7 +32,7 @@ CLI_RETRY_COUNT="${CLI_RETRY_COUNT:-5}" CLI_RETRY_DELAY_SECS="${CLI_RETRY_DELAY_SECS:-3}" GHCR_LOGIN="${GHCR_LOGIN:-auto}" GHCR_USER="${GHCR_USER:-}" -DEFAULT_NEMOCLAW_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/nemoclaw:latest" +DEFAULT_NEMOCLAW_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/openclaw-nvidia:latest" if [[ -n "${NEMOCLAW_IMAGE+x}" ]]; then NEMOCLAW_IMAGE_EXPLICIT=1 else @@ -277,13 +277,13 @@ maybe_use_branch_local_nemoclaw_tag() { return fi - NEMOCLAW_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/nemoclaw:local-dev" + NEMOCLAW_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/openclaw-nvidia:local-dev" log "Using non-main branch NeMoClaw image tag: $NEMOCLAW_IMAGE" } build_nemoclaw_image_if_needed() { local docker_cmd=() - local image_context="$REPO_ROOT/sandboxes/nemoclaw" + local image_context="$REPO_ROOT/sandboxes/openclaw-nvidia" local dockerfile_path="$image_context/Dockerfile" if ! should_build_nemoclaw_image; then @@ -384,7 +384,7 @@ import_nemoclaw_image_into_cluster_if_needed() { if ! $docker_bin exec -i "$cluster_name" sh -lc "ctr -n k8s.io images ls | awk '{print \$1}' | grep -Fx '$NEMOCLAW_IMAGE' >/dev/null"; then log "Imported image tag not found in cluster containerd: $NEMOCLAW_IMAGE" log "Cluster image list:" - $docker_bin exec -i "$cluster_name" sh -lc "ctr -n k8s.io images ls | grep 'sandboxes/nemoclaw' || true" + $docker_bin exec -i "$cluster_name" sh -lc "ctr -n k8s.io images ls | grep 'sandboxes/openclaw-nvidia' || true" exit 1 fi diff --git a/brev/welcome-ui/SERVER_ARCHITECTURE.md b/brev/welcome-ui/SERVER_ARCHITECTURE.md index 8fd2266..34f7dad 100644 --- a/brev/welcome-ui/SERVER_ARCHITECTURE.md +++ b/brev/welcome-ui/SERVER_ARCHITECTURE.md @@ -121,12 +121,12 @@ The server operates in **two distinct modes** depending on sandbox readiness: |----------|-------|-------------| | `ROOT` | `os.path.dirname(os.path.abspath(__file__))` | Directory containing `server.py` | | `REPO_ROOT` | env or `ROOT/../../` | Repository root | -| `SANDBOX_DIR` | `REPO_ROOT/sandboxes/nemoclaw` | Sandbox image source directory | +| `SANDBOX_DIR` | `REPO_ROOT/sandboxes/openclaw-nvidia` | Sandbox image source directory | | `POLICY_FILE` | `SANDBOX_DIR/policy.yaml` | Source policy for gateway creation | | `LOG_FILE` | `/tmp/nemoclaw-sandbox-create.log` | Sandbox creation log (written by subprocess) | | `PROVIDER_CONFIG_CACHE` | `/tmp/nemoclaw-provider-config-cache.json` | Provider config values cache | | `OTHER_AGENTS_YAML` | `ROOT/other-agents.yaml` | YAML modal definition file | -| `NEMOCLAW_IMAGE` | `ghcr.io/nvidia/openshell-community/sandboxes/nemoclaw:local` | (Currently unused, commented out) | +| `NEMOCLAW_IMAGE` | `ghcr.io/nvidia/openshell-community/sandboxes/openclaw-nvidia:local` | Optional image override | | `SANDBOX_PORT` | `18789` | Port the sandbox listens on (localhost) | ### Hardcoded Constants @@ -372,21 +372,21 @@ Global: _inject_key_state (dict, protected by _inject_key_lock) ``` Step 1: Set state to "creating" Step 2: _cleanup_existing_sandbox() - → runs: nemoclaw sandbox delete nemoclaw + → runs: openshell sandbox delete openclaw-nvidia → ignores all errors (best-effort cleanup) Step 3: Build chat UI URL (no token yet) Step 4: _generate_gateway_policy() - → Read POLICY_FILE (sandboxes/nemoclaw/policy.yaml) + → Read POLICY_FILE (sandboxes/openclaw-nvidia/policy.yaml) → Strip "inference" and "process" fields from the YAML → Write stripped YAML to a tempfile → Return tempfile path (or None if source not found) Step 5: Build and run command: - nemoclaw sandbox create \ - --name nemoclaw \ + openshell sandbox create \ + --name openclaw-nvidia \ --from nemoclaw \ --forward 18789 \ [--policy ] \ - -- env CHAT_UI_URL= nemoclaw-start + -- env CHAT_UI_URL= openclaw-nvidia-start Step 6: Stream stdout (merged with stderr) to LOG_FILE and to stderr → Uses subprocess.Popen with stdout=PIPE, stderr=STDOUT → A daemon thread reads lines and writes to both destinations @@ -1126,8 +1126,8 @@ All CLI commands are executed via `subprocess.run()` or `subprocess.Popen()`. Ev | Command | Timeout | Used By | |---------|---------|---------| -| `nemoclaw sandbox create --name ... --from ... --forward ... [--policy ...] -- env ... nemoclaw-start` | None (Popen, waited manually) | `_run_sandbox_create` | -| `nemoclaw sandbox delete nemoclaw` | 30s | `_cleanup_existing_sandbox` | +| `openshell sandbox create --name ... --from ... --forward ... [--policy ...] -- env ... openclaw-nvidia-start` | None (Popen, waited manually) | `_run_sandbox_create` | +| `openshell sandbox delete openclaw-nvidia` | 30s | `_cleanup_existing_sandbox` | | `nemoclaw provider list --names` | 30s | `_handle_providers_list` | | `nemoclaw provider get ` | 30s | `_handle_providers_list` | | `nemoclaw provider create --name --type --credential K=V --config K=V` | 30s | `_handle_provider_create` | @@ -1169,7 +1169,7 @@ All CLI commands are executed via `subprocess.run()` or `subprocess.Popen()`. Ev re.search(r"token=([A-Za-z0-9_\-]+)", content) ``` -The token is found in URLs printed by the `nemoclaw-start.sh` script inside the sandbox. +The token is found in URLs printed by the `openclaw-nvidia-start.sh` script inside the sandbox. ### Gateway Readiness Sentinel @@ -1177,7 +1177,7 @@ The token is found in URLs printed by the `nemoclaw-start.sh` script inside the "OpenClaw gateway starting in background" in f.read() ``` -This exact string is printed by `nemoclaw-start.sh` after the OpenClaw gateway has been backgrounded. +This exact string is printed by `openclaw-nvidia-start.sh` after the OpenClaw gateway has been backgrounded. --- diff --git a/brev/welcome-ui/__tests__/sandbox-lifecycle.test.js b/brev/welcome-ui/__tests__/sandbox-lifecycle.test.js index e2df1f1..89a6bf9 100644 --- a/brev/welcome-ui/__tests__/sandbox-lifecycle.test.js +++ b/brev/welcome-ui/__tests__/sandbox-lifecycle.test.js @@ -96,24 +96,24 @@ describe("POST /api/install-openclaw", () => { expect(spawn).toHaveBeenCalled(); const [cmd, args] = spawn.mock.calls[0]; - expect(cmd).toBe("nemoclaw"); + expect(cmd).toBe("openshell"); expect(args).toContain("sandbox"); expect(args).toContain("create"); expect(args).toContain("--name"); - expect(args).toContain("nemoclaw"); + expect(args).toContain("openclaw-nvidia"); expect(args).toContain("--from"); expect(args).toContain("--forward"); expect(args).toContain("18789"); }); - it("TC-S06: cleanup runs nemoclaw sandbox delete before creation", async () => { + it("TC-S06: cleanup runs openshell sandbox delete before creation", async () => { await request(server) .post("/api/install-openclaw") .set("Content-Type", "application/json"); // execFile should have been called with delete command const deleteCalls = execFile.mock.calls.filter( - (c) => c[0] === "nemoclaw" && c[1][0] === "sandbox" && c[1][1] === "delete" + (c) => c[0] === "openshell" && c[1][0] === "sandbox" && c[1][1] === "delete" ); expect(deleteCalls.length).toBeGreaterThanOrEqual(1); }); diff --git a/brev/welcome-ui/server.js b/brev/welcome-ui/server.js index 8a080f7..85be5d5 100644 --- a/brev/welcome-ui/server.js +++ b/brev/welcome-ui/server.js @@ -32,9 +32,9 @@ const PORT = parseInt(process.env.PORT || "8081", 10); const ROOT = __dirname; const REPO_ROOT = process.env.REPO_ROOT || path.join(ROOT, "..", ".."); const CLI_BIN = process.env.CLI_BIN || "openshell"; -const SANDBOX_DIR = path.join(REPO_ROOT, "sandboxes", "nemoclaw"); -const SANDBOX_NAME = process.env.SANDBOX_NAME || "nemoclaw"; -const SANDBOX_START_CMD = process.env.SANDBOX_START_CMD || "nemoclaw-start"; +const SANDBOX_DIR = path.join(REPO_ROOT, "sandboxes", "openclaw-nvidia"); +const SANDBOX_NAME = process.env.SANDBOX_NAME || "openclaw-nvidia"; +const SANDBOX_START_CMD = process.env.SANDBOX_START_CMD || "openclaw-nvidia-start"; const SANDBOX_BASE_IMAGE = process.env.SANDBOX_BASE_IMAGE || "ghcr.io/nvidia/openshell-community/sandboxes/openclaw:latest"; diff --git a/sandboxes/nemoclaw/.gitignore b/sandboxes/openclaw-nvidia/.gitignore similarity index 100% rename from sandboxes/nemoclaw/.gitignore rename to sandboxes/openclaw-nvidia/.gitignore diff --git a/sandboxes/nemoclaw/Dockerfile b/sandboxes/openclaw-nvidia/Dockerfile similarity index 92% rename from sandboxes/nemoclaw/Dockerfile rename to sandboxes/openclaw-nvidia/Dockerfile index 686c3c3..c1aeb41 100644 --- a/sandboxes/nemoclaw/Dockerfile +++ b/sandboxes/openclaw-nvidia/Dockerfile @@ -8,8 +8,8 @@ # Builds on the OpenClaw sandbox and adds the NeMoClaw DevX UI extension # (model selector, deploy modal, API keys page, nav group). # -# Build: docker build -t nemoclaw . -# Run: nemoclaw sandbox create --from nemoclaw --forward 18789 -- nemoclaw-start +# Build: docker build -t openclaw-nvidia . +# Run: openshell sandbox create --name openclaw-nvidia --from openclaw-nvidia --forward 18789 -- openclaw-nvidia-start ARG BASE_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/openclaw:latest FROM ${BASE_IMAGE} @@ -27,8 +27,8 @@ RUN mkdir -p /etc/navigator COPY policy.yaml /etc/navigator/policy.yaml # Override the startup script with our version (adds runtime API key injection) -COPY nemoclaw-start.sh /usr/local/bin/nemoclaw-start -RUN chmod +x /usr/local/bin/nemoclaw-start +COPY openclaw-nvidia-start.sh /usr/local/bin/openclaw-nvidia-start +RUN chmod +x /usr/local/bin/openclaw-nvidia-start # Install the policy reverse proxy (sits in front of the OpenClaw gateway, # intercepts /api/policy to read/write the sandbox policy file) and its diff --git a/sandboxes/nemoclaw/README.md b/sandboxes/openclaw-nvidia/README.md similarity index 86% rename from sandboxes/nemoclaw/README.md rename to sandboxes/openclaw-nvidia/README.md index 3fde1df..0daee14 100644 --- a/sandboxes/nemoclaw/README.md +++ b/sandboxes/openclaw-nvidia/README.md @@ -1,4 +1,4 @@ -# NeMoClaw Sandbox +# OpenClaw NVIDIA Sandbox NemoClaw sandbox image that layers the **NeMoClaw DevX UI extension** on top of the [OpenClaw](https://github.com/openclaw) sandbox. @@ -11,14 +11,14 @@ Everything from the `openclaw` sandbox (OpenClaw CLI, gateway, Node.js 22, devel - **API Keys Page** — settings page to enter and manage NVIDIA API keys, persisted in browser `localStorage` - **NeMoClaw Nav Group** — sidebar navigation with status indicators for key configuration - **Contextual Nudges** — inline links in error states that guide users to configure missing API keys -- **nemoclaw-start** — startup script that injects API keys, onboards, and starts the gateway +- **openclaw-nvidia-start** — startup script that injects API keys, onboards, and starts the gateway ## Build Build from the sandbox directory: ```bash -docker build -t nemoclaw sandboxes/nemoclaw/ +docker build -t openclaw-nvidia sandboxes/openclaw-nvidia/ ``` ## Usage @@ -26,10 +26,10 @@ docker build -t nemoclaw sandboxes/nemoclaw/ ### Create a sandbox ```bash -nemoclaw sandbox create --from sandboxes/nemoclaw \ +openshell sandbox create --name openclaw-nvidia --from sandboxes/openclaw-nvidia \ --forward 18789 \ -- env CHAT_UI_URL=http://127.0.0.1:18789 \ - nemoclaw-start + openclaw-nvidia-start ``` The `--from ` flag builds the image and imports it into the cluster automatically. @@ -43,7 +43,7 @@ device-pairing fallback. Examples: | Local | `http://127.0.0.1:18789` | | Brev | `https://187890-.brevlab.com` | -`nemoclaw-start` then: +`openclaw-nvidia-start` then: 1. Substitutes `__NVIDIA_*_API_KEY__` placeholders in the bundled JS with runtime environment variables (if provided) 2. Runs `openclaw onboard` to configure the environment @@ -57,7 +57,7 @@ Access the UI at `http://127.0.0.1:18789/`. API keys can be provided in two ways (in order of precedence): 1. **Browser `localStorage`** — enter keys via the API Keys page in the UI sidebar (persists across page reloads) -2. **Environment variables** — baked into the JS bundle at container startup by `nemoclaw-start` +2. **Environment variables** — baked into the JS bundle at container startup by `openclaw-nvidia-start` | Variable | Description | |---|---| @@ -70,12 +70,12 @@ Keys are optional at sandbox creation time. If omitted, the UI will prompt users If you prefer to start OpenClaw manually inside the sandbox: ```bash -nemoclaw sandbox connect +openshell sandbox connect openclaw onboard openclaw gateway run ``` -Note: without running `nemoclaw-start`, the API key placeholders will remain as literals and model endpoints will not work unless keys are entered via the UI. +Note: without running `openclaw-nvidia-start`, the API key placeholders will remain as literals and model endpoints will not work unless keys are entered via the UI. ## How the Extension Works diff --git a/sandboxes/nemoclaw/build.sh b/sandboxes/openclaw-nvidia/build.sh similarity index 76% rename from sandboxes/nemoclaw/build.sh rename to sandboxes/openclaw-nvidia/build.sh index c0ba202..373adba 100755 --- a/sandboxes/nemoclaw/build.sh +++ b/sandboxes/openclaw-nvidia/build.sh @@ -6,11 +6,11 @@ # Sync the UI extension source and launch a NemoClaw sandbox. # # Usage (from repo root): -# bash sandboxes/nemoclaw-launchable-ui/build.sh [extra nemoclaw args...] +# bash sandboxes/openclaw-nvidia/build.sh [extra openshell args...] # # The canonical extension source lives at brev/nemoclaw-ui-extension/extension/. # This script copies it into the sandbox directory so the Dockerfile build -# context can reach it, then delegates to `nemoclaw sandbox create`. +# context can reach it, then delegates to `openshell sandbox create`. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -21,4 +21,4 @@ rm -rf "$SCRIPT_DIR/nemoclaw-devx" cp -r "$REPO_ROOT/brev/nemoclaw-ui-extension/extension" "$SCRIPT_DIR/nemoclaw-devx" echo "Creating sandbox..." -exec nemoclaw sandbox create --from "$SCRIPT_DIR" "$@" +exec openshell sandbox create --name openclaw-nvidia --from "$SCRIPT_DIR" "$@" diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/.env.example b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/.env.example similarity index 100% rename from sandboxes/nemoclaw/nemoclaw-ui-extension/.env.example rename to sandboxes/openclaw-nvidia/nemoclaw-ui-extension/.env.example diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/api-keys-page.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/api-keys-page.ts similarity index 100% rename from sandboxes/nemoclaw/nemoclaw-ui-extension/extension/api-keys-page.ts rename to sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/api-keys-page.ts diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/deploy-modal.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/deploy-modal.ts similarity index 100% rename from sandboxes/nemoclaw/nemoclaw-ui-extension/extension/deploy-modal.ts rename to sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/deploy-modal.ts diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/gateway-bridge.ts similarity index 100% rename from sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts rename to sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/gateway-bridge.ts diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/icons.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/icons.ts similarity index 100% rename from sandboxes/nemoclaw/nemoclaw-ui-extension/extension/icons.ts rename to sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/icons.ts diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts similarity index 98% rename from sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts rename to sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts index b56a0e5..e3e27b6 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts @@ -29,6 +29,7 @@ const PAIRING_REARM_INTERVAL_MS = 4_000; const POST_READY_SETTLE_MS = 750; const PAIRING_BOOTSTRAPPED_FLAG = "nemoclaw:pairing-bootstrap-complete"; const PAIRING_RELOAD_FLAG = "nemoclaw:pairing-bootstrap-recovery-reload"; +const READINESS_HANDLED = Symbol("pairing-bootstrap-readiness-handled"); interface PairingBootstrapState { status?: string; @@ -100,6 +101,10 @@ function revealApp(): void { overlay.addEventListener("transitionend", () => overlay.remove(), { once: true }); setTimeout(() => overlay.remove(), 600); } + + bootstrapActiveRoutePrime().catch((e) => + console.warn("[NeMoClaw] bootstrap active route prime failed:", e), + ); } function shouldAllowRecoveryReload(): boolean { @@ -301,7 +306,7 @@ function bootstrap() { .catch(async () => { console.warn("[NeMoClaw] pairing bootstrap: initial dashboard readiness check timed out; extending wait"); if (await handlePairingTerminalWithoutStableConnection("initial readiness timed out")) { - return; + throw READINESS_HANDLED; } return waitForDashboardReadiness( EXTENDED_CONNECTION_TIMEOUT_MS, @@ -328,7 +333,10 @@ function bootstrap() { setConnectOverlayText("Device pairing approved. Opening dashboard..."); revealApp(); }) - .catch(async () => { + .catch(async (err) => { + if (err === READINESS_HANDLED) return; + if (stopped) return; + if (dashboardStable) return; if (await handlePairingTerminalWithoutStableConnection("extended readiness timed out")) { return; } @@ -353,10 +361,6 @@ function bootstrap() { ); } - bootstrapActiveRoutePrime().catch((e) => - console.warn("[NeMoClaw] bootstrap active route prime failed:", e), - ); - if (inject()) { injectModelSelector(); return; diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/inference-page.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/inference-page.ts similarity index 100% rename from sandboxes/nemoclaw/nemoclaw-ui-extension/extension/inference-page.ts rename to sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/inference-page.ts diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-registry.ts similarity index 100% rename from sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts rename to sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-registry.ts diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-selector.ts similarity index 97% rename from sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts rename to sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-selector.ts index f696926..d0555f2 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-selector.ts @@ -38,7 +38,7 @@ let modelSelectorObserver: MutationObserver | null = null; let applyInFlight = false; let currentWrapper: HTMLElement | null = null; let activeClusterRoute: ClusterRoute | null = null; -const ACTIVE_ROUTE_PRIME_PREFIX = "nemoclaw:active-route-primed:"; +let activeRoutePrimedThisPage = false; // --------------------------------------------------------------------------- // Build the config.patch payload for a given model entry @@ -147,19 +147,12 @@ async function fetchDynamic(): Promise { } function hasPrimedActiveRoute(route: ClusterRoute): boolean { - try { - return sessionStorage.getItem(`${ACTIVE_ROUTE_PRIME_PREFIX}${route.providerName}:${route.modelId}`) === "1"; - } catch { - return false; - } + return activeRoutePrimedThisPage; } function markActiveRoutePrimed(route: ClusterRoute): void { - try { - sessionStorage.setItem(`${ACTIVE_ROUTE_PRIME_PREFIX}${route.providerName}:${route.modelId}`, "1"); - } catch { - // ignore storage failures - } + void route; + activeRoutePrimedThisPage = true; } async function primeActiveRoute(): Promise { @@ -584,9 +577,6 @@ function buildModelSelector(): HTMLElement { if (valueEl) { valueEl.textContent = current ? current.name : "No model"; } - void primeActiveRoute().catch((err) => { - console.warn("[NeMoClaw] active route prime failed:", err); - }); }); return wrapper; diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/nav-group.ts similarity index 100% rename from sandboxes/nemoclaw/nemoclaw-ui-extension/extension/nav-group.ts rename to sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/nav-group.ts diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/package.json b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/package.json similarity index 100% rename from sandboxes/nemoclaw/nemoclaw-ui-extension/extension/package.json rename to sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/package.json diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/policy-page.ts similarity index 100% rename from sandboxes/nemoclaw/nemoclaw-ui-extension/extension/policy-page.ts rename to sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/policy-page.ts diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/styles.css similarity index 100% rename from sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css rename to sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/styles.css diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/install.sh b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/install.sh similarity index 100% rename from sandboxes/nemoclaw/nemoclaw-ui-extension/install.sh rename to sandboxes/openclaw-nvidia/nemoclaw-ui-extension/install.sh diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/uninstall.sh b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/uninstall.sh similarity index 100% rename from sandboxes/nemoclaw/nemoclaw-ui-extension/uninstall.sh rename to sandboxes/openclaw-nvidia/nemoclaw-ui-extension/uninstall.sh diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh similarity index 98% rename from sandboxes/nemoclaw/nemoclaw-start.sh rename to sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh index 5852c13..61c6ee3 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh @@ -20,12 +20,12 @@ # NVIDIA_INTEGRATE_API_KEY — key for integrate.api.nvidia.com # # Usage (env vars inlined via env command to avoid nemoclaw -e quoting bug): -# nemoclaw sandbox create --name nemoclaw --from sandboxes/nemoclaw/ \ +# openshell sandbox create --name openclaw-nvidia --from sandboxes/openclaw-nvidia/ \ # --forward 18789 \ # -- env CHAT_UI_URL=http://127.0.0.1:18789 \ # NVIDIA_INFERENCE_API_KEY= \ # NVIDIA_INTEGRATE_API_KEY= \ -# nemoclaw-start +# openclaw-nvidia-start set -euo pipefail # -------------------------------------------------------------------------- diff --git a/sandboxes/nemoclaw/policy-proxy.js b/sandboxes/openclaw-nvidia/policy-proxy.js similarity index 100% rename from sandboxes/nemoclaw/policy-proxy.js rename to sandboxes/openclaw-nvidia/policy-proxy.js diff --git a/sandboxes/nemoclaw/policy.yaml b/sandboxes/openclaw-nvidia/policy.yaml similarity index 100% rename from sandboxes/nemoclaw/policy.yaml rename to sandboxes/openclaw-nvidia/policy.yaml diff --git a/sandboxes/nemoclaw/proto/datamodel.proto b/sandboxes/openclaw-nvidia/proto/datamodel.proto similarity index 100% rename from sandboxes/nemoclaw/proto/datamodel.proto rename to sandboxes/openclaw-nvidia/proto/datamodel.proto diff --git a/sandboxes/nemoclaw/proto/navigator.proto b/sandboxes/openclaw-nvidia/proto/navigator.proto similarity index 100% rename from sandboxes/nemoclaw/proto/navigator.proto rename to sandboxes/openclaw-nvidia/proto/navigator.proto diff --git a/sandboxes/nemoclaw/proto/sandbox.proto b/sandboxes/openclaw-nvidia/proto/sandbox.proto similarity index 100% rename from sandboxes/nemoclaw/proto/sandbox.proto rename to sandboxes/openclaw-nvidia/proto/sandbox.proto From e778c57549d465ba1ee57e1fef942191cf91bf81 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 16:54:03 -0700 Subject: [PATCH 12/16] Add host-side route activation --- brev/launch.sh | 1222 ++++++++--------- brev/welcome-ui/server.js | 61 + .../nemoclaw-ui-extension/extension/index.ts | 6 +- .../extension/model-selector.ts | 44 - 4 files changed, 673 insertions(+), 660 deletions(-) diff --git a/brev/launch.sh b/brev/launch.sh index 891dbaf..e0567aa 100755 --- a/brev/launch.sh +++ b/brev/launch.sh @@ -1,734 +1,734 @@ -#!/usr/bin/env bash - -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -set -euo pipefail - -SOURCE_PATH="${BASH_SOURCE[0]-}" -if [[ -z "$SOURCE_PATH" || "$SOURCE_PATH" == "bash" || "$SOURCE_PATH" == "-bash" ]]; then - SCRIPT_DIR="$PWD" -else - SCRIPT_DIR="$(cd "$(dirname "$SOURCE_PATH")" && pwd)" -fi -SCRIPT_REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -REPO_ROOT="" -WELCOME_UI_DIR="" - -PORT="${PORT:-8081}" -CLI_BIN="${CLI_BIN:-}" -CLI_RELEASE_TAG="${CLI_RELEASE_TAG:-devel}" -AUTO_INSTALL_CLI="${AUTO_INSTALL_CLI:-1}" -GITHUB_TOKEN="${GITHUB_TOKEN:-${GH_TOKEN:-${GITHUB_PAT:-}}}" -COMMUNITY_REPO="${COMMUNITY_REPO:-NVIDIA/OpenShell-Community}" -COMMUNITY_REF="${COMMUNITY_REF:-${COMMUNITY_BRANCH:-}}" -CLONE_ROOT="${CLONE_ROOT:-/home/ubuntu}" -CLONE_DIR="${CLONE_DIR:-$CLONE_ROOT/OpenShell-Community}" -GATEWAY_LOG="${GATEWAY_LOG:-/tmp/openshell-gateway.log}" -WELCOME_UI_LOG="${WELCOME_UI_LOG:-/tmp/welcome-ui.log}" -LAUNCH_LOG="${LAUNCH_LOG:-/tmp/openshell-launch.log}" -WAIT_TIMEOUT_SECS="${WAIT_TIMEOUT_SECS:-30}" -CLI_RETRY_COUNT="${CLI_RETRY_COUNT:-5}" -CLI_RETRY_DELAY_SECS="${CLI_RETRY_DELAY_SECS:-3}" -GHCR_LOGIN="${GHCR_LOGIN:-auto}" -GHCR_USER="${GHCR_USER:-}" -DEFAULT_NEMOCLAW_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/openclaw-nvidia:latest" -if [[ -n "${NEMOCLAW_IMAGE+x}" ]]; then - NEMOCLAW_IMAGE_EXPLICIT=1 -else - NEMOCLAW_IMAGE_EXPLICIT=0 -fi -NEMOCLAW_IMAGE="${NEMOCLAW_IMAGE:-$DEFAULT_NEMOCLAW_IMAGE}" -SKIP_NEMOCLAW_IMAGE_BUILD="${SKIP_NEMOCLAW_IMAGE_BUILD:-}" -CLUSTER_CONTAINER_NAME="${CLUSTER_CONTAINER_NAME:-openshell-cluster-openshell}" - -mkdir -p "$(dirname "$LAUNCH_LOG")" -touch "$LAUNCH_LOG" -exec > >(tee -a "$LAUNCH_LOG") 2>&1 - -log() { - printf '[launch.sh] %s\n' "$*" -} - -require_non_root() { - if [[ "$(id -u)" -eq 0 ]]; then - log "Do not run the full launcher as root." - log "Run it as the target user and let the script use sudo only where required." - exit 1 - fi -} + #!/usr/bin/env bash -step() { - printf '\n[launch.sh] === %s ===\n' "$*" -} + # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + # SPDX-License-Identifier: Apache-2.0 -require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - log "Missing required command: $1" - exit 1 + set -euo pipefail + + SOURCE_PATH="${BASH_SOURCE[0]-}" + if [[ -z "$SOURCE_PATH" || "$SOURCE_PATH" == "bash" || "$SOURCE_PATH" == "-bash" ]]; then + SCRIPT_DIR="$PWD" + else + SCRIPT_DIR="$(cd "$(dirname "$SOURCE_PATH")" && pwd)" + fi + SCRIPT_REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + REPO_ROOT="" + WELCOME_UI_DIR="" + + PORT="${PORT:-8081}" + CLI_BIN="${CLI_BIN:-}" + CLI_RELEASE_TAG="${CLI_RELEASE_TAG:-devel}" + AUTO_INSTALL_CLI="${AUTO_INSTALL_CLI:-1}" + GITHUB_TOKEN="${GITHUB_TOKEN:-${GH_TOKEN:-${GITHUB_PAT:-}}}" + COMMUNITY_REPO="${COMMUNITY_REPO:-NVIDIA/OpenShell-Community}" + COMMUNITY_REF="${COMMUNITY_REF:-${COMMUNITY_BRANCH:-}}" + CLONE_ROOT="${CLONE_ROOT:-/home/ubuntu}" + CLONE_DIR="${CLONE_DIR:-$CLONE_ROOT/OpenShell-Community}" + GATEWAY_LOG="${GATEWAY_LOG:-/tmp/openshell-gateway.log}" + WELCOME_UI_LOG="${WELCOME_UI_LOG:-/tmp/welcome-ui.log}" + LAUNCH_LOG="${LAUNCH_LOG:-/tmp/openshell-launch.log}" + WAIT_TIMEOUT_SECS="${WAIT_TIMEOUT_SECS:-30}" + CLI_RETRY_COUNT="${CLI_RETRY_COUNT:-5}" + CLI_RETRY_DELAY_SECS="${CLI_RETRY_DELAY_SECS:-3}" + GHCR_LOGIN="${GHCR_LOGIN:-auto}" + GHCR_USER="${GHCR_USER:-}" + DEFAULT_NEMOCLAW_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/openclaw-nvidia:latest" + if [[ -n "${NEMOCLAW_IMAGE+x}" ]]; then + NEMOCLAW_IMAGE_EXPLICIT=1 + else + NEMOCLAW_IMAGE_EXPLICIT=0 fi -} + NEMOCLAW_IMAGE="${NEMOCLAW_IMAGE:-$DEFAULT_NEMOCLAW_IMAGE}" + SKIP_NEMOCLAW_IMAGE_BUILD="${SKIP_NEMOCLAW_IMAGE_BUILD:-}" + CLUSTER_CONTAINER_NAME="${CLUSTER_CONTAINER_NAME:-openshell-cluster-openshell}" -repo_has_welcome_ui() { - [[ -d "$1/brev/welcome-ui" ]] -} + mkdir -p "$(dirname "$LAUNCH_LOG")" + touch "$LAUNCH_LOG" + exec > >(tee -a "$LAUNCH_LOG") 2>&1 -wait_for_tcp_port() { - local port="$1" - local timeout_secs="${2:-30}" - local start_ts - start_ts="$(date +%s)" + log() { + printf '[launch.sh] %s\n' "$*" + } - while true; do - if (echo >"/dev/tcp/127.0.0.1/$port") >/dev/null 2>&1; then - return 0 + require_non_root() { + if [[ "$(id -u)" -eq 0 ]]; then + log "Do not run the full launcher as root." + log "Run it as the target user and let the script use sudo only where required." + exit 1 fi + } - if (( "$(date +%s)" - start_ts >= timeout_secs )); then - return 1 + step() { + printf '\n[launch.sh] === %s ===\n' "$*" + } + + require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + log "Missing required command: $1" + exit 1 fi + } - sleep 1 - done -} + repo_has_welcome_ui() { + [[ -d "$1/brev/welcome-ui" ]] + } -wait_for_log_pattern() { - local logfile="$1" - local pattern="$2" - local timeout_secs="${3:-30}" - local start_ts - start_ts="$(date +%s)" + wait_for_tcp_port() { + local port="$1" + local timeout_secs="${2:-30}" + local start_ts + start_ts="$(date +%s)" - while true; do - if [[ -f "$logfile" ]] && grep -q "$pattern" "$logfile"; then - return 0 - fi + while true; do + if (echo >"/dev/tcp/127.0.0.1/$port") >/dev/null 2>&1; then + return 0 + fi - if (( "$(date +%s)" - start_ts >= timeout_secs )); then - return 1 - fi + if (( "$(date +%s)" - start_ts >= timeout_secs )); then + return 1 + fi - sleep 1 - done -} + sleep 1 + done + } -retry_cli() { - local attempt=1 - local max_attempts="${CLI_RETRY_COUNT}" - local delay_secs="${CLI_RETRY_DELAY_SECS}" + wait_for_log_pattern() { + local logfile="$1" + local pattern="$2" + local timeout_secs="${3:-30}" + local start_ts + start_ts="$(date +%s)" - while true; do - if "$@"; then - return 0 - fi + while true; do + if [[ -f "$logfile" ]] && grep -q "$pattern" "$logfile"; then + return 0 + fi - if (( attempt >= max_attempts )); then - return 1 - fi + if (( "$(date +%s)" - start_ts >= timeout_secs )); then + return 1 + fi - log "Command failed, retrying (${attempt}/${max_attempts}): $*" - sleep "$delay_secs" - attempt=$((attempt + 1)) - done -} + sleep 1 + done + } -detect_arch() { - case "$(uname -m)" in - x86_64|amd64) echo "x86_64" ;; - aarch64|arm64) echo "aarch64" ;; - *) - log "Unsupported architecture: $(uname -m)" - exit 1 - ;; - esac -} - -ensure_gh() { - if command -v gh >/dev/null 2>&1; then - log "GitHub CLI already installed." - return - fi + retry_cli() { + local attempt=1 + local max_attempts="${CLI_RETRY_COUNT}" + local delay_secs="${CLI_RETRY_DELAY_SECS}" - log "Installing GitHub CLI..." - require_cmd sudo - require_cmd apt-get - sudo apt-get update - sudo apt-get install -y gh -} + while true; do + if "$@"; then + return 0 + fi -gh_auth_if_needed() { - if ! command -v gh >/dev/null 2>&1; then - return - fi + if (( attempt >= max_attempts )); then + return 1 + fi - if gh auth status >/dev/null 2>&1; then - return - fi + log "Command failed, retrying (${attempt}/${max_attempts}): $*" + sleep "$delay_secs" + attempt=$((attempt + 1)) + done + } - if [[ -z "$GITHUB_TOKEN" ]]; then - log "GitHub CLI is unauthenticated. Continuing without auth." - return - fi + detect_arch() { + case "$(uname -m)" in + x86_64|amd64) echo "x86_64" ;; + aarch64|arm64) echo "aarch64" ;; + *) + log "Unsupported architecture: $(uname -m)" + exit 1 + ;; + esac + } - log "Authenticating GitHub CLI from environment token..." - if ! printf '%s\n' "$GITHUB_TOKEN" | gh auth login --with-token >/dev/null 2>&1; then - log "GitHub authentication failed." - exit 1 - fi -} + ensure_gh() { + if command -v gh >/dev/null 2>&1; then + log "GitHub CLI already installed." + return + fi -resolve_ghcr_user() { - if [[ -n "$GHCR_USER" ]]; then - return 0 - fi + log "Installing GitHub CLI..." + require_cmd sudo + require_cmd apt-get + sudo apt-get update + sudo apt-get install -y gh + } - if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then - GHCR_USER="$(gh api user -q .login 2>/dev/null || true)" - fi + gh_auth_if_needed() { + if ! command -v gh >/dev/null 2>&1; then + return + fi - if [[ -z "$GHCR_USER" ]]; then - GHCR_USER="${GITHUB_USER:-${USER:-}}" - fi + if gh auth status >/dev/null 2>&1; then + return + fi - [[ -n "$GHCR_USER" ]] -} + if [[ -z "$GITHUB_TOKEN" ]]; then + log "GitHub CLI is unauthenticated. Continuing without auth." + return + fi -docker_login_ghcr_for_user() { - local login_user="$1" + log "Authenticating GitHub CLI from environment token..." + if ! printf '%s\n' "$GITHUB_TOKEN" | gh auth login --with-token >/dev/null 2>&1; then + log "GitHub authentication failed." + exit 1 + fi + } - if [[ "$login_user" == "root" ]]; then - log "Logging into ghcr.io as $GHCR_USER for root ..." - if printf '%s\n' "$GITHUB_TOKEN" | sudo docker login ghcr.io -u "$GHCR_USER" --password-stdin >/dev/null 2>&1; then - log "GHCR login succeeded for root." + resolve_ghcr_user() { + if [[ -n "$GHCR_USER" ]]; then return 0 fi - log "GHCR login failed for root." - return 1 - fi - log "Logging into ghcr.io as $GHCR_USER for user $login_user ..." - if [[ "$login_user" == "$(id -un)" ]]; then - if printf '%s\n' "$GITHUB_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin >/dev/null 2>&1; then - log "GHCR login succeeded for user $login_user." - return 0 + if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + GHCR_USER="$(gh api user -q .login 2>/dev/null || true)" fi - log "GHCR login failed for user $login_user." - return 1 - fi - if sudo -H -u "$login_user" env GITHUB_TOKEN="$GITHUB_TOKEN" GHCR_USER="$GHCR_USER" bash -lc \ - 'printf "%s\n" "$GITHUB_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin >/dev/null 2>&1'; then - log "GHCR login succeeded for user $login_user." - return 0 - fi - log "GHCR login failed for user $login_user." - return 1 -} + if [[ -z "$GHCR_USER" ]]; then + GHCR_USER="${GITHUB_USER:-${USER:-}}" + fi -docker_login_ghcr_if_needed() { - local login_failed=0 + [[ -n "$GHCR_USER" ]] + } - if [[ "$GHCR_LOGIN" == "0" || "$GHCR_LOGIN" == "false" || "$GHCR_LOGIN" == "no" ]]; then - log "Skipping GHCR login by configuration." - return - fi + docker_login_ghcr_for_user() { + local login_user="$1" - if [[ -z "$GITHUB_TOKEN" ]]; then - log "No GitHub token available; skipping GHCR login." - return - fi + if [[ "$login_user" == "root" ]]; then + log "Logging into ghcr.io as $GHCR_USER for root ..." + if printf '%s\n' "$GITHUB_TOKEN" | sudo docker login ghcr.io -u "$GHCR_USER" --password-stdin >/dev/null 2>&1; then + log "GHCR login succeeded for root." + return 0 + fi + log "GHCR login failed for root." + return 1 + fi - if ! command -v docker >/dev/null 2>&1; then - log "Docker not available; skipping GHCR login." - return - fi + log "Logging into ghcr.io as $GHCR_USER for user $login_user ..." + if [[ "$login_user" == "$(id -un)" ]]; then + if printf '%s\n' "$GITHUB_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin >/dev/null 2>&1; then + log "GHCR login succeeded for user $login_user." + return 0 + fi + log "GHCR login failed for user $login_user." + return 1 + fi - if ! resolve_ghcr_user; then - log "Could not determine GHCR username; skipping GHCR login." - return - fi + if sudo -H -u "$login_user" env GITHUB_TOKEN="$GITHUB_TOKEN" GHCR_USER="$GHCR_USER" bash -lc \ + 'printf "%s\n" "$GITHUB_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin >/dev/null 2>&1'; then + log "GHCR login succeeded for user $login_user." + return 0 + fi + log "GHCR login failed for user $login_user." + return 1 + } - docker_login_ghcr_for_user "root" || login_failed=1 + docker_login_ghcr_if_needed() { + local login_failed=0 - if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then - docker_login_ghcr_for_user "$SUDO_USER" || login_failed=1 - elif [[ "$(id -un)" != "root" ]]; then - docker_login_ghcr_for_user "$(id -un)" || login_failed=1 - fi + if [[ "$GHCR_LOGIN" == "0" || "$GHCR_LOGIN" == "false" || "$GHCR_LOGIN" == "no" ]]; then + log "Skipping GHCR login by configuration." + return + fi - if [[ "$login_failed" -ne 0 ]]; then - log "One or more GHCR logins failed. Continuing, but private image pulls may fail." - fi -} + if [[ -z "$GITHUB_TOKEN" ]]; then + log "No GitHub token available; skipping GHCR login." + return + fi -should_build_nemoclaw_image() { - if [[ "$SKIP_NEMOCLAW_IMAGE_BUILD" == "1" || "$SKIP_NEMOCLAW_IMAGE_BUILD" == "true" || "$SKIP_NEMOCLAW_IMAGE_BUILD" == "yes" ]]; then - return 1 - fi - [[ -n "$COMMUNITY_REF" && "$COMMUNITY_REF" != "main" ]] -} + if ! command -v docker >/dev/null 2>&1; then + log "Docker not available; skipping GHCR login." + return + fi -maybe_use_branch_local_nemoclaw_tag() { - if ! should_build_nemoclaw_image; then - return - fi + if ! resolve_ghcr_user; then + log "Could not determine GHCR username; skipping GHCR login." + return + fi - if [[ "$NEMOCLAW_IMAGE_EXPLICIT" == "1" || "$NEMOCLAW_IMAGE" != "$DEFAULT_NEMOCLAW_IMAGE" ]]; then - return - fi + docker_login_ghcr_for_user "root" || login_failed=1 - NEMOCLAW_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/openclaw-nvidia:local-dev" - log "Using non-main branch NeMoClaw image tag: $NEMOCLAW_IMAGE" -} + if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then + docker_login_ghcr_for_user "$SUDO_USER" || login_failed=1 + elif [[ "$(id -un)" != "root" ]]; then + docker_login_ghcr_for_user "$(id -un)" || login_failed=1 + fi -build_nemoclaw_image_if_needed() { - local docker_cmd=() - local image_context="$REPO_ROOT/sandboxes/openclaw-nvidia" - local dockerfile_path="$image_context/Dockerfile" + if [[ "$login_failed" -ne 0 ]]; then + log "One or more GHCR logins failed. Continuing, but private image pulls may fail." + fi + } - if ! should_build_nemoclaw_image; then + should_build_nemoclaw_image() { if [[ "$SKIP_NEMOCLAW_IMAGE_BUILD" == "1" || "$SKIP_NEMOCLAW_IMAGE_BUILD" == "true" || "$SKIP_NEMOCLAW_IMAGE_BUILD" == "yes" ]]; then - log "Skipping local NeMoClaw image build by override (SKIP_NEMOCLAW_IMAGE_BUILD=${SKIP_NEMOCLAW_IMAGE_BUILD})." - else - log "Skipping local NeMoClaw image build (COMMUNITY_REF=${COMMUNITY_REF:-})." + return 1 fi - return - fi + [[ -n "$COMMUNITY_REF" && "$COMMUNITY_REF" != "main" ]] + } - if [[ ! -f "$dockerfile_path" ]]; then - log "NeMoClaw Dockerfile not found: $dockerfile_path" - exit 1 - fi + maybe_use_branch_local_nemoclaw_tag() { + if ! should_build_nemoclaw_image; then + return + fi - if command -v docker >/dev/null 2>&1; then - docker_cmd=(docker) - elif command -v sudo >/dev/null 2>&1; then - docker_cmd=(sudo docker) - else - log "Docker is required to build the NeMoClaw sandbox image." - exit 1 - fi + if [[ "$NEMOCLAW_IMAGE_EXPLICIT" == "1" || "$NEMOCLAW_IMAGE" != "$DEFAULT_NEMOCLAW_IMAGE" ]]; then + return + fi - log "Building local NeMoClaw image for non-main ref '$COMMUNITY_REF': $NEMOCLAW_IMAGE" - if ! "${docker_cmd[@]}" build \ - --pull \ - --tag "$NEMOCLAW_IMAGE" \ - --file "$dockerfile_path" \ - "$image_context"; then - log "Local NeMoClaw image build failed." - exit 1 - fi + NEMOCLAW_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/openclaw-nvidia:local-dev" + log "Using non-main branch NeMoClaw image tag: $NEMOCLAW_IMAGE" + } - log "Local NeMoClaw image ready: $NEMOCLAW_IMAGE" -} + build_nemoclaw_image_if_needed() { + local docker_cmd=() + local image_context="$REPO_ROOT/sandboxes/openclaw-nvidia" + local dockerfile_path="$image_context/Dockerfile" + + if ! should_build_nemoclaw_image; then + if [[ "$SKIP_NEMOCLAW_IMAGE_BUILD" == "1" || "$SKIP_NEMOCLAW_IMAGE_BUILD" == "true" || "$SKIP_NEMOCLAW_IMAGE_BUILD" == "yes" ]]; then + log "Skipping local NeMoClaw image build by override (SKIP_NEMOCLAW_IMAGE_BUILD=${SKIP_NEMOCLAW_IMAGE_BUILD})." + else + log "Skipping local NeMoClaw image build (COMMUNITY_REF=${COMMUNITY_REF:-})." + fi + return + fi -resolve_docker_cmd() { - if command -v docker >/dev/null 2>&1; then - printf 'docker' - return 0 - fi - if command -v sudo >/dev/null 2>&1; then - printf 'sudo docker' - return 0 - fi - return 1 -} + if [[ ! -f "$dockerfile_path" ]]; then + log "NeMoClaw Dockerfile not found: $dockerfile_path" + exit 1 + fi -resolve_cluster_container_name() { - local docker_bin + if command -v docker >/dev/null 2>&1; then + docker_cmd=(docker) + elif command -v sudo >/dev/null 2>&1; then + docker_cmd=(sudo docker) + else + log "Docker is required to build the NeMoClaw sandbox image." + exit 1 + fi - if [[ -n "$CLUSTER_CONTAINER_NAME" ]]; then - printf '%s' "$CLUSTER_CONTAINER_NAME" - return 0 - fi + log "Building local NeMoClaw image for non-main ref '$COMMUNITY_REF': $NEMOCLAW_IMAGE" + if ! "${docker_cmd[@]}" build \ + --pull \ + --tag "$NEMOCLAW_IMAGE" \ + --file "$dockerfile_path" \ + "$image_context"; then + log "Local NeMoClaw image build failed." + exit 1 + fi - docker_bin="$(resolve_docker_cmd)" || return 1 + log "Local NeMoClaw image ready: $NEMOCLAW_IMAGE" + } - CLUSTER_CONTAINER_NAME="$($docker_bin ps --format '{{.Names}}\t{{.Image}}' | awk '$1 ~ /^openshell-cluster-/ { print $1; exit }')" - if [[ -z "$CLUSTER_CONTAINER_NAME" ]]; then - CLUSTER_CONTAINER_NAME="$($docker_bin ps --format '{{.Names}}\t{{.Image}}' | awk '$2 ~ /ghcr.io\\/nvidia\\/openshell\\/cluster/ { print $1; exit }')" - fi + resolve_docker_cmd() { + if command -v docker >/dev/null 2>&1; then + printf 'docker' + return 0 + fi + if command -v sudo >/dev/null 2>&1; then + printf 'sudo docker' + return 0 + fi + return 1 + } - [[ -n "$CLUSTER_CONTAINER_NAME" ]] -} + resolve_cluster_container_name() { + local docker_bin -import_nemoclaw_image_into_cluster_if_needed() { - local docker_bin cluster_name + if [[ -n "$CLUSTER_CONTAINER_NAME" ]]; then + printf '%s' "$CLUSTER_CONTAINER_NAME" + return 0 + fi - if ! should_build_nemoclaw_image && [[ "$NEMOCLAW_IMAGE_EXPLICIT" != "1" ]]; then - log "Skipping cluster image import; using registry-backed image: $NEMOCLAW_IMAGE" - return - fi + docker_bin="$(resolve_docker_cmd)" || return 1 - docker_bin="$(resolve_docker_cmd)" || { - log "Docker not available; skipping cluster image import." - return + CLUSTER_CONTAINER_NAME="$($docker_bin ps --format '{{.Names}}\t{{.Image}}' | awk '$1 ~ /^openshell-cluster-/ { print $1; exit }')" + if [[ -z "$CLUSTER_CONTAINER_NAME" ]]; then + CLUSTER_CONTAINER_NAME="$($docker_bin ps --format '{{.Names}}\t{{.Image}}' | awk '$2 ~ /ghcr.io\\/nvidia\\/openshell\\/cluster/ { print $1; exit }')" + fi + + [[ -n "$CLUSTER_CONTAINER_NAME" ]] } - if ! $docker_bin image inspect "$NEMOCLAW_IMAGE" >/dev/null 2>&1; then - log "Local NeMoClaw image not present on host; skipping cluster image import: $NEMOCLAW_IMAGE" - return - fi + import_nemoclaw_image_into_cluster_if_needed() { + local docker_bin cluster_name - if ! cluster_name="$(resolve_cluster_container_name)"; then - log "OpenShell cluster container not found; skipping cluster image import." - return - fi + if ! should_build_nemoclaw_image && [[ "$NEMOCLAW_IMAGE_EXPLICIT" != "1" ]]; then + log "Skipping cluster image import; using registry-backed image: $NEMOCLAW_IMAGE" + return + fi - log "Importing NeMoClaw image into cluster containerd: $NEMOCLAW_IMAGE -> $cluster_name" - if ! $docker_bin save "$NEMOCLAW_IMAGE" | $docker_bin exec -i "$cluster_name" sh -lc 'ctr -n k8s.io images import -'; then - log "Failed to import NeMoClaw image into cluster containerd." - exit 1 - fi + docker_bin="$(resolve_docker_cmd)" || { + log "Docker not available; skipping cluster image import." + return + } - if ! $docker_bin exec -i "$cluster_name" sh -lc "ctr -n k8s.io images ls | awk '{print \$1}' | grep -Fx '$NEMOCLAW_IMAGE' >/dev/null"; then - log "Imported image tag not found in cluster containerd: $NEMOCLAW_IMAGE" - log "Cluster image list:" - $docker_bin exec -i "$cluster_name" sh -lc "ctr -n k8s.io images ls | grep 'sandboxes/openclaw-nvidia' || true" - exit 1 - fi + if ! $docker_bin image inspect "$NEMOCLAW_IMAGE" >/dev/null 2>&1; then + log "Local NeMoClaw image not present on host; skipping cluster image import: $NEMOCLAW_IMAGE" + return + fi - log "Cluster image import complete: $NEMOCLAW_IMAGE" -} + if ! cluster_name="$(resolve_cluster_container_name)"; then + log "OpenShell cluster container not found; skipping cluster image import." + return + fi -checkout_repo_ref() { - if [[ -z "$COMMUNITY_REF" ]]; then - return - fi + log "Importing NeMoClaw image into cluster containerd: $NEMOCLAW_IMAGE -> $cluster_name" + if ! $docker_bin save "$NEMOCLAW_IMAGE" | $docker_bin exec -i "$cluster_name" sh -lc 'ctr -n k8s.io images import -'; then + log "Failed to import NeMoClaw image into cluster containerd." + exit 1 + fi - require_cmd git - log "Checking out OpenShell-Community ref: $COMMUNITY_REF" + if ! $docker_bin exec -i "$cluster_name" sh -lc "ctr -n k8s.io images ls | awk '{print \$1}' | grep -Fx '$NEMOCLAW_IMAGE' >/dev/null"; then + log "Imported image tag not found in cluster containerd: $NEMOCLAW_IMAGE" + log "Cluster image list:" + $docker_bin exec -i "$cluster_name" sh -lc "ctr -n k8s.io images ls | grep 'sandboxes/openclaw-nvidia' || true" + exit 1 + fi - git -C "$CLONE_DIR" fetch --all --tags --prune + log "Cluster image import complete: $NEMOCLAW_IMAGE" + } - if git -C "$CLONE_DIR" show-ref --verify --quiet "refs/remotes/origin/$COMMUNITY_REF"; then - git -C "$CLONE_DIR" checkout -B "$COMMUNITY_REF" "origin/$COMMUNITY_REF" - return - fi + checkout_repo_ref() { + if [[ -z "$COMMUNITY_REF" ]]; then + return + fi - if git -C "$CLONE_DIR" show-ref --verify --quiet "refs/tags/$COMMUNITY_REF"; then - git -C "$CLONE_DIR" checkout --detach "refs/tags/$COMMUNITY_REF" - return - fi + require_cmd git + log "Checking out OpenShell-Community ref: $COMMUNITY_REF" - if git -C "$CLONE_DIR" rev-parse --verify --quiet "$COMMUNITY_REF^{commit}" >/dev/null; then - git -C "$CLONE_DIR" checkout --detach "$COMMUNITY_REF" - return - fi + git -C "$CLONE_DIR" fetch --all --tags --prune - git -C "$CLONE_DIR" fetch origin "$COMMUNITY_REF" - git -C "$CLONE_DIR" checkout --detach FETCH_HEAD -} + if git -C "$CLONE_DIR" show-ref --verify --quiet "refs/remotes/origin/$COMMUNITY_REF"; then + git -C "$CLONE_DIR" checkout -B "$COMMUNITY_REF" "origin/$COMMUNITY_REF" + return + fi -clone_repo_if_needed() { - if repo_has_welcome_ui "$CLONE_DIR"; then - log "Using existing repo checkout at $CLONE_DIR" - checkout_repo_ref - return - fi + if git -C "$CLONE_DIR" show-ref --verify --quiet "refs/tags/$COMMUNITY_REF"; then + git -C "$CLONE_DIR" checkout --detach "refs/tags/$COMMUNITY_REF" + return + fi - require_cmd git + if git -C "$CLONE_DIR" rev-parse --verify --quiet "$COMMUNITY_REF^{commit}" >/dev/null; then + git -C "$CLONE_DIR" checkout --detach "$COMMUNITY_REF" + return + fi - if [[ -e "$CLONE_DIR" ]]; then - log "Clone target exists but is not a valid repo checkout: $CLONE_DIR" - exit 1 - fi + git -C "$CLONE_DIR" fetch origin "$COMMUNITY_REF" + git -C "$CLONE_DIR" checkout --detach FETCH_HEAD + } - mkdir -p "$CLONE_ROOT" + clone_repo_if_needed() { + if repo_has_welcome_ui "$CLONE_DIR"; then + log "Using existing repo checkout at $CLONE_DIR" + checkout_repo_ref + return + fi - if [[ -n "$GITHUB_TOKEN" ]]; then - log "Cloning ${COMMUNITY_REPO} into $CLONE_DIR with token auth..." - if [[ -n "$COMMUNITY_REF" ]]; then - git clone --branch "$COMMUNITY_REF" "https://${GITHUB_TOKEN}@github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" \ - || git clone "https://${GITHUB_TOKEN}@github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" - else - git clone "https://${GITHUB_TOKEN}@github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" + require_cmd git + + if [[ -e "$CLONE_DIR" ]]; then + log "Clone target exists but is not a valid repo checkout: $CLONE_DIR" + exit 1 fi - else - log "Cloning ${COMMUNITY_REPO} into $CLONE_DIR..." - if [[ -n "$COMMUNITY_REF" ]]; then - git clone --branch "$COMMUNITY_REF" "https://github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" \ - || git clone "https://github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" + + mkdir -p "$CLONE_ROOT" + + if [[ -n "$GITHUB_TOKEN" ]]; then + log "Cloning ${COMMUNITY_REPO} into $CLONE_DIR with token auth..." + if [[ -n "$COMMUNITY_REF" ]]; then + git clone --branch "$COMMUNITY_REF" "https://${GITHUB_TOKEN}@github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" \ + || git clone "https://${GITHUB_TOKEN}@github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" + else + git clone "https://${GITHUB_TOKEN}@github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" + fi else - git clone "https://github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" + log "Cloning ${COMMUNITY_REPO} into $CLONE_DIR..." + if [[ -n "$COMMUNITY_REF" ]]; then + git clone --branch "$COMMUNITY_REF" "https://github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" \ + || git clone "https://github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" + else + git clone "https://github.com/${COMMUNITY_REPO}.git" "$CLONE_DIR" + fi fi - fi - - checkout_repo_ref -} -install_cli_from_release() { - local arch tmpdir repo pattern archive candidate + checkout_repo_ref + } - ensure_gh - gh_auth_if_needed + install_cli_from_release() { + local arch tmpdir repo pattern archive candidate + + ensure_gh + gh_auth_if_needed + + arch="$(detect_arch)" + tmpdir="$(mktemp -d)" + + for candidate in openshell nemoclaw; do + case "$candidate" in + openshell) repo="NVIDIA/OpenShell" ;; + nemoclaw) repo="NVIDIA/NemoClaw" ;; + esac + + pattern="${candidate}-${arch}-unknown-linux-musl.tar.gz" + log "Trying CLI download: ${repo} ${CLI_RELEASE_TAG} ${pattern}" + if gh release download "$CLI_RELEASE_TAG" --repo "$repo" --pattern "$pattern" --dir "$tmpdir" >/dev/null 2>&1; then + archive="$tmpdir/$pattern" + tar xzf "$archive" -C "$tmpdir" + sudo install -m 755 "$tmpdir/$candidate" "/usr/local/bin/$candidate" + CLI_BIN="$candidate" + log "Installed CLI from release: $CLI_BIN" + rm -rf "$tmpdir" + return 0 + fi + done + + rm -rf "$tmpdir" + log "Unable to install CLI from GitHub releases." + exit 1 + } - arch="$(detect_arch)" - tmpdir="$(mktemp -d)" + resolve_cli() { + log "Checking for installed CLI binaries..." - for candidate in openshell nemoclaw; do - case "$candidate" in - openshell) repo="NVIDIA/OpenShell" ;; - nemoclaw) repo="NVIDIA/NemoClaw" ;; - esac + if [[ -n "$CLI_BIN" ]]; then + require_cmd "$CLI_BIN" + log "Using CLI from CLI_BIN: $CLI_BIN" + return + fi - pattern="${candidate}-${arch}-unknown-linux-musl.tar.gz" - log "Trying CLI download: ${repo} ${CLI_RELEASE_TAG} ${pattern}" - if gh release download "$CLI_RELEASE_TAG" --repo "$repo" --pattern "$pattern" --dir "$tmpdir" >/dev/null 2>&1; then - archive="$tmpdir/$pattern" - tar xzf "$archive" -C "$tmpdir" - sudo install -m 755 "$tmpdir/$candidate" "/usr/local/bin/$candidate" - CLI_BIN="$candidate" - log "Installed CLI from release: $CLI_BIN" - rm -rf "$tmpdir" - return 0 + if command -v openshell >/dev/null 2>&1; then + CLI_BIN="openshell" + log "Detected installed CLI: $CLI_BIN" + return fi - done - rm -rf "$tmpdir" - log "Unable to install CLI from GitHub releases." - exit 1 -} + if command -v nemoclaw >/dev/null 2>&1; then + CLI_BIN="nemoclaw" + log "Detected installed CLI: $CLI_BIN" + return + fi -resolve_cli() { - log "Checking for installed CLI binaries..." + if [[ "$AUTO_INSTALL_CLI" != "1" ]]; then + log "Neither openshell nor nemoclaw is installed." + exit 1 + fi - if [[ -n "$CLI_BIN" ]]; then - require_cmd "$CLI_BIN" - log "Using CLI from CLI_BIN: $CLI_BIN" - return - fi + install_cli_from_release + } - if command -v openshell >/dev/null 2>&1; then - CLI_BIN="openshell" - log "Detected installed CLI: $CLI_BIN" - return - fi + ensure_cli_compat_aliases() { + local cli_path - if command -v nemoclaw >/dev/null 2>&1; then - CLI_BIN="nemoclaw" - log "Detected installed CLI: $CLI_BIN" - return - fi + cli_path="$(command -v "$CLI_BIN")" - if [[ "$AUTO_INSTALL_CLI" != "1" ]]; then - log "Neither openshell nor nemoclaw is installed." - exit 1 - fi + if [[ "$CLI_BIN" == "openshell" ]] && ! command -v nemoclaw >/dev/null 2>&1; then + sudo ln -sf "$cli_path" /usr/local/bin/nemoclaw + log "Created compatibility alias: nemoclaw -> openshell" + fi - install_cli_from_release -} + if [[ "$CLI_BIN" == "nemoclaw" ]] && ! command -v openshell >/dev/null 2>&1; then + sudo ln -sf "$cli_path" /usr/local/bin/openshell + log "Created compatibility alias: openshell -> nemoclaw" + fi + } -ensure_cli_compat_aliases() { - local cli_path + resolve_repo_root() { + if repo_has_welcome_ui "$SCRIPT_REPO_ROOT"; then + REPO_ROOT="$SCRIPT_REPO_ROOT" + elif repo_has_welcome_ui "$PWD"; then + REPO_ROOT="$PWD" + else + clone_repo_if_needed + REPO_ROOT="$CLONE_DIR" + fi - cli_path="$(command -v "$CLI_BIN")" + WELCOME_UI_DIR="$REPO_ROOT/brev/welcome-ui" + } - if [[ "$CLI_BIN" == "openshell" ]] && ! command -v nemoclaw >/dev/null 2>&1; then - sudo ln -sf "$cli_path" /usr/local/bin/nemoclaw - log "Created compatibility alias: nemoclaw -> openshell" - fi + ensure_node() { + if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then + log "Node.js already installed: $(node --version)" + log "npm already installed: $(npm --version)" + return + fi - if [[ "$CLI_BIN" == "nemoclaw" ]] && ! command -v openshell >/dev/null 2>&1; then - sudo ln -sf "$cli_path" /usr/local/bin/openshell - log "Created compatibility alias: openshell -> nemoclaw" - fi -} + log "Installing Node.js LTS via nvm..." + require_cmd curl + curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash -resolve_repo_root() { - if repo_has_welcome_ui "$SCRIPT_REPO_ROOT"; then - REPO_ROOT="$SCRIPT_REPO_ROOT" - elif repo_has_welcome_ui "$PWD"; then - REPO_ROOT="$PWD" - else - clone_repo_if_needed - REPO_ROOT="$CLONE_DIR" - fi + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + # shellcheck disable=SC1090 + . "$NVM_DIR/nvm.sh" + nvm install --lts + } - WELCOME_UI_DIR="$REPO_ROOT/brev/welcome-ui" -} + set_inference_route() { + log "Configuring inference route..." -ensure_node() { - if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then - log "Node.js already installed: $(node --version)" - log "npm already installed: $(npm --version)" - return - fi + if "$CLI_BIN" inference set --provider nvidia-endpoints --model moonshotai/kimi-k2.5 >/dev/null 2>&1; then + log "Configured inference via '$CLI_BIN inference set'." + return + fi - log "Installing Node.js LTS via nvm..." - require_cmd curl - curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash + if "$CLI_BIN" cluster inference set --provider nvidia-endpoints --model moonshotai/kimi-k2.5 >/dev/null 2>&1; then + log "Configured inference via legacy '$CLI_BIN cluster inference set'." + return + fi - export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" - # shellcheck disable=SC1090 - . "$NVM_DIR/nvm.sh" - nvm install --lts -} + log "Unable to configure inference route with either current or legacy CLI commands." + exit 1 + } -set_inference_route() { - log "Configuring inference route..." + run_provider_create_or_replace() { + local name="$1" + shift - if "$CLI_BIN" inference set --provider nvidia-endpoints --model moonshotai/kimi-k2.5 >/dev/null 2>&1; then - log "Configured inference via '$CLI_BIN inference set'." - return - fi + log "Configuring provider: $name" + if retry_cli "$CLI_BIN" provider create --name "$name" "$@" >/dev/null 2>&1; then + log "Created provider: $name" + return + fi - if "$CLI_BIN" cluster inference set --provider nvidia-endpoints --model moonshotai/kimi-k2.5 >/dev/null 2>&1; then - log "Configured inference via legacy '$CLI_BIN cluster inference set'." - return - fi + log "Provider create failed for $name. Replacing existing provider..." + retry_cli "$CLI_BIN" provider delete "$name" >/dev/null 2>&1 || true + retry_cli "$CLI_BIN" provider create --name "$name" "$@" + log "Recreated provider: $name" + } - log "Unable to configure inference route with either current or legacy CLI commands." - exit 1 -} + wait_for_gateway_cli() { + log "Waiting for gateway CLI operations to stabilize..." + if retry_cli "$CLI_BIN" provider list --names >/dev/null 2>&1; then + log "Gateway CLI is responsive." + return + fi -run_provider_create_or_replace() { - local name="$1" - shift + log "Gateway CLI did not stabilize. Last gateway log lines:" + tail -n 50 "$GATEWAY_LOG" || true + exit 1 + } - log "Configuring provider: $name" - if retry_cli "$CLI_BIN" provider create --name "$name" "$@" >/dev/null 2>&1; then - log "Created provider: $name" - return - fi + start_gateway() { + : > "$GATEWAY_LOG" + log "Resetting gateway state if it already exists..." + log "Gateway log: $GATEWAY_LOG" - log "Provider create failed for $name. Replacing existing provider..." - retry_cli "$CLI_BIN" provider delete "$name" >/dev/null 2>&1 || true - retry_cli "$CLI_BIN" provider create --name "$name" "$@" - log "Recreated provider: $name" -} - -wait_for_gateway_cli() { - log "Waiting for gateway CLI operations to stabilize..." - if retry_cli "$CLI_BIN" provider list --names >/dev/null 2>&1; then - log "Gateway CLI is responsive." - return - fi + if "$CLI_BIN" gateway destroy >> "$GATEWAY_LOG" 2>&1; then + log "Existing gateway destroyed." + else + log "Gateway destroy returned non-zero. Continuing with fresh start." + fi - log "Gateway CLI did not stabilize. Last gateway log lines:" - tail -n 50 "$GATEWAY_LOG" || true - exit 1 -} + log "Starting gateway..." + if ! "$CLI_BIN" gateway start 2>&1 | tee -a "$GATEWAY_LOG"; then + log "Gateway start failed. Last log lines:" + tail -n 50 "$GATEWAY_LOG" || true + exit 1 + fi -start_gateway() { - : > "$GATEWAY_LOG" - log "Resetting gateway state if it already exists..." - log "Gateway log: $GATEWAY_LOG" + if ! wait_for_log_pattern "$GATEWAY_LOG" "Gateway .* ready\\|Active gateway set" "$WAIT_TIMEOUT_SECS"; then + log "Gateway did not become ready within ${WAIT_TIMEOUT_SECS}s. Last log lines:" + tail -n 50 "$GATEWAY_LOG" || true + exit 1 + fi - if "$CLI_BIN" gateway destroy >> "$GATEWAY_LOG" 2>&1; then - log "Existing gateway destroyed." - else - log "Gateway destroy returned non-zero. Continuing with fresh start." - fi + log "Gateway reported ready." + wait_for_gateway_cli + } - log "Starting gateway..." - if ! "$CLI_BIN" gateway start 2>&1 | tee -a "$GATEWAY_LOG"; then - log "Gateway start failed. Last log lines:" - tail -n 50 "$GATEWAY_LOG" || true - exit 1 - fi + install_ui_deps() { + require_cmd npm + cd "$WELCOME_UI_DIR" - if ! wait_for_log_pattern "$GATEWAY_LOG" "Gateway .* ready\\|Active gateway set" "$WAIT_TIMEOUT_SECS"; then - log "Gateway did not become ready within ${WAIT_TIMEOUT_SECS}s. Last log lines:" - tail -n 50 "$GATEWAY_LOG" || true - exit 1 - fi + log "Installing welcome UI dependencies in $WELCOME_UI_DIR" + if [[ -f package-lock.json ]]; then + npm ci + else + npm install + fi + } - log "Gateway reported ready." - wait_for_gateway_cli -} + start_welcome_ui() { + cd "$WELCOME_UI_DIR" + + : > "$WELCOME_UI_LOG" + log "Starting welcome UI in background..." + log "Welcome UI log: $WELCOME_UI_LOG" + + nohup env \ + PORT="$PORT" \ + REPO_ROOT="$REPO_ROOT" \ + CLI_BIN="$CLI_BIN" \ + NEMOCLAW_IMAGE="$NEMOCLAW_IMAGE" \ + node server.js >> "$WELCOME_UI_LOG" 2>&1 & + WELCOME_UI_PID=$! + export WELCOME_UI_PID + log "Welcome UI PID: $WELCOME_UI_PID" + + if ! wait_for_tcp_port "$PORT" "$WAIT_TIMEOUT_SECS"; then + log "Welcome UI did not open port $PORT within ${WAIT_TIMEOUT_SECS}s. Last log lines:" + tail -n 100 "$WELCOME_UI_LOG" || true + exit 1 + fi -install_ui_deps() { - require_cmd npm - cd "$WELCOME_UI_DIR" + log "Welcome UI started at http://localhost:${PORT}" + } - log "Installing welcome UI dependencies in $WELCOME_UI_DIR" - if [[ -f package-lock.json ]]; then - npm ci - else - npm install - fi -} - -start_welcome_ui() { - cd "$WELCOME_UI_DIR" - - : > "$WELCOME_UI_LOG" - log "Starting welcome UI in background..." - log "Welcome UI log: $WELCOME_UI_LOG" - - nohup env \ - PORT="$PORT" \ - REPO_ROOT="$REPO_ROOT" \ - CLI_BIN="$CLI_BIN" \ - NEMOCLAW_IMAGE="$NEMOCLAW_IMAGE" \ - node server.js >> "$WELCOME_UI_LOG" 2>&1 & - WELCOME_UI_PID=$! - export WELCOME_UI_PID - log "Welcome UI PID: $WELCOME_UI_PID" - - if ! wait_for_tcp_port "$PORT" "$WAIT_TIMEOUT_SECS"; then - log "Welcome UI did not open port $PORT within ${WAIT_TIMEOUT_SECS}s. Last log lines:" - tail -n 100 "$WELCOME_UI_LOG" || true - exit 1 - fi + main() { + require_non_root + require_cmd tar + require_cmd sudo + + step "Resolving repo" + resolve_repo_root + step "Resolving CLI" + resolve_cli + ensure_cli_compat_aliases + maybe_use_branch_local_nemoclaw_tag + step "Authenticating registries" + docker_login_ghcr_if_needed + step "Preparing NeMoClaw image" + build_nemoclaw_image_if_needed + step "Ensuring Node.js" + ensure_node + + log "Using repo root: $REPO_ROOT" + if [[ -n "$COMMUNITY_REF" ]]; then + log "Using community ref: $COMMUNITY_REF" + fi + log "Using CLI: $CLI_BIN" + + step "Starting gateway" + start_gateway + step "Importing NeMoClaw image into cluster" + import_nemoclaw_image_into_cluster_if_needed + + step "Configuring providers" + run_provider_create_or_replace \ + nvidia-inference \ + --type openai \ + --credential OPENAI_API_KEY=unused \ + --config OPENAI_BASE_URL=https://inference-api.nvidia.com/v1 + + run_provider_create_or_replace \ + nvidia-endpoints \ + --type nvidia \ + --credential NVIDIA_API_KEY=unused \ + --config NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1 + + set_inference_route + + step "Installing welcome UI dependencies" + install_ui_deps + step "Starting welcome UI" + start_welcome_ui + + step "Ready" + log "Gateway log: $GATEWAY_LOG" + log "Welcome UI log: $WELCOME_UI_LOG" + log "Open http://localhost:${PORT}" + } - log "Welcome UI started at http://localhost:${PORT}" -} - -main() { - require_non_root - require_cmd tar - require_cmd sudo - - step "Resolving repo" - resolve_repo_root - step "Resolving CLI" - resolve_cli - ensure_cli_compat_aliases - maybe_use_branch_local_nemoclaw_tag - step "Authenticating registries" - docker_login_ghcr_if_needed - step "Preparing NeMoClaw image" - build_nemoclaw_image_if_needed - step "Ensuring Node.js" - ensure_node - - log "Using repo root: $REPO_ROOT" - if [[ -n "$COMMUNITY_REF" ]]; then - log "Using community ref: $COMMUNITY_REF" - fi - log "Using CLI: $CLI_BIN" - - step "Starting gateway" - start_gateway - step "Importing NeMoClaw image into cluster" - import_nemoclaw_image_into_cluster_if_needed - - step "Configuring providers" - run_provider_create_or_replace \ - nvidia-inference \ - --type openai \ - --credential OPENAI_API_KEY=unused \ - --config OPENAI_BASE_URL=https://inference-api.nvidia.com/v1 - - run_provider_create_or_replace \ - nvidia-endpoints \ - --type nvidia \ - --credential NVIDIA_API_KEY=unused \ - --config NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1 - - set_inference_route - - step "Installing welcome UI dependencies" - install_ui_deps - step "Starting welcome UI" - start_welcome_ui - - step "Ready" - log "Gateway log: $GATEWAY_LOG" - log "Welcome UI log: $WELCOME_UI_LOG" - log "Open http://localhost:${PORT}" -} - -main "$@" + main "$@" diff --git a/brev/welcome-ui/server.js b/brev/welcome-ui/server.js index 85be5d5..0af76e5 100644 --- a/brev/welcome-ui/server.js +++ b/brev/welcome-ui/server.js @@ -283,6 +283,8 @@ const sandboxState = { pid: null, url: null, error: null, + routeActivationAttempted: false, + routeActivationInFlight: false, }; const injectKeyState = { @@ -416,6 +418,7 @@ async function sandboxReady() { const url = buildOpenclawUrl(token); sandboxState.status = "running"; sandboxState.url = url; + await activateCurrentInferenceRouteOnce("sandbox-ready"); return true; } } @@ -702,6 +705,8 @@ function runSandboxCreate() { sandboxState.status = "creating"; sandboxState.error = null; sandboxState.url = null; + sandboxState.routeActivationAttempted = false; + sandboxState.routeActivationInFlight = false; (async () => { try { @@ -835,6 +840,7 @@ function runSandboxCreate() { const url = buildOpenclawUrl(token); sandboxState.status = "running"; sandboxState.url = url; + await activateCurrentInferenceRouteOnce("sandbox-create"); return; } await sleep(3000); @@ -1220,6 +1226,58 @@ async function handleClusterInferenceSet(req, res) { } } +async function activateCurrentInferenceRouteOnce(reason = "sandbox-ready") { + if (sandboxState.routeActivationAttempted || sandboxState.routeActivationInFlight) return; + + sandboxState.routeActivationInFlight = true; + try { + const current = await execFirstSuccess( + [ + cliArgs("inference", "get"), + cliArgs("cluster", "inference", "get"), + ], + 30000 + ); + if (current.code !== 0) { + const err = (current.stderr || current.stdout || "get failed").trim(); + logWelcome(`[inference-route] skipped (${reason}): could not read current route: ${err}`); + return; + } + + const parsed = parseClusterInference(current.stdout); + if (!parsed?.providerName || !parsed.modelId) { + logWelcome(`[inference-route] skipped (${reason}): no active route configured`); + return; + } + + logWelcome( + `[inference-route] activating once (${reason}): ${parsed.providerName}/${parsed.modelId}` + ); + const applied = await execFirstSuccess( + [ + cliArgs("inference", "set", "--provider", parsed.providerName, "--model", parsed.modelId), + cliArgs("cluster", "inference", "set", "--provider", parsed.providerName, "--model", parsed.modelId), + ], + 30000 + ); + if (applied.code !== 0) { + const err = (applied.stderr || applied.stdout || "set failed").trim(); + logWelcome(`[inference-route] activation failed (${reason}): ${err}`); + return; + } + + const result = parseClusterInference(applied.stdout) || parsed; + logWelcome( + `[inference-route] activation complete (${reason}): ${result.providerName}/${result.modelId}` + ); + } catch (e) { + logWelcome(`[inference-route] activation error (${reason}): ${String(e)}`); + } finally { + sandboxState.routeActivationAttempted = true; + sandboxState.routeActivationInFlight = false; + } +} + // ── Reverse proxy (HTTP) ─────────────────────────────────────────────────── function proxyToSandbox(clientReq, clientRes) { @@ -1334,6 +1392,7 @@ async function handleSandboxStatus(req, res) { const url = buildOpenclawUrl(token, req); sandboxState.status = "running"; sandboxState.url = url; + await activateCurrentInferenceRouteOnce("sandbox-status"); state.status = "running"; state.url = url; } @@ -1712,6 +1771,8 @@ function _resetForTesting() { sandboxState.pid = null; sandboxState.url = null; sandboxState.error = null; + sandboxState.routeActivationAttempted = false; + sandboxState.routeActivationInFlight = false; injectKeyState.status = "idle"; injectKeyState.error = null; injectKeyState.keyHash = null; diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts index e3e27b6..556a9fc 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts @@ -13,7 +13,7 @@ import "./styles.css"; import { injectButton } from "./deploy-modal.ts"; import { injectNavGroup, activateNemoPage, watchOpenClawNavClicks } from "./nav-group.ts"; -import { bootstrapActiveRoutePrime, injectModelSelector, watchChatCompose } from "./model-selector.ts"; +import { injectModelSelector, watchChatCompose } from "./model-selector.ts"; import { ingestKeysFromUrl, DEFAULT_MODEL, resolveApiKey, isKeyConfigured } from "./model-registry.ts"; import { hasBlockingGatewayMessage, waitForStableConnection } from "./gateway-bridge.ts"; import { syncKeysToProviders } from "./api-keys-page.ts"; @@ -101,10 +101,6 @@ function revealApp(): void { overlay.addEventListener("transitionend", () => overlay.remove(), { once: true }); setTimeout(() => overlay.remove(), 600); } - - bootstrapActiveRoutePrime().catch((e) => - console.warn("[NeMoClaw] bootstrap active route prime failed:", e), - ); } function shouldAllowRecoveryReload(): boolean { diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-selector.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-selector.ts index d0555f2..3c897ce 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-selector.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-selector.ts @@ -37,8 +37,6 @@ let selectedModelId = DEFAULT_MODEL.id; let modelSelectorObserver: MutationObserver | null = null; let applyInFlight = false; let currentWrapper: HTMLElement | null = null; -let activeClusterRoute: ClusterRoute | null = null; -let activeRoutePrimedThisPage = false; // --------------------------------------------------------------------------- // Build the config.patch payload for a given model entry @@ -116,7 +114,6 @@ async function fetchDynamic(): Promise { route = { providerName: body.providerName, modelId: body.modelId || "", version: body.version || 0 }; } } - activeClusterRoute = route; const entries: ModelEntry[] = []; @@ -146,47 +143,6 @@ async function fetchDynamic(): Promise { } } -function hasPrimedActiveRoute(route: ClusterRoute): boolean { - return activeRoutePrimedThisPage; -} - -function markActiveRoutePrimed(route: ClusterRoute): void { - void route; - activeRoutePrimedThisPage = true; -} - -async function primeActiveRoute(): Promise { - const route = activeClusterRoute; - if (!route?.providerName || !route.modelId) { - console.info("[NeMoClaw] active route prime: skipped (no active route)"); - return; - } - if (hasPrimedActiveRoute(route)) { - console.info(`[NeMoClaw] active route prime: skipped (already primed ${route.providerName}/${route.modelId})`); - return; - } - - console.info(`[NeMoClaw] active route prime: start ${route.providerName}/${route.modelId}`); - - const res = await fetch("/api/cluster-inference", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ providerName: route.providerName, modelId: route.modelId }), - }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error((body as { error?: string }).error || `HTTP ${res.status}`); - } - - markActiveRoutePrimed(route); - console.info(`[NeMoClaw] active route prime: success ${route.providerName}/${route.modelId}`); -} - -export async function bootstrapActiveRoutePrime(): Promise { - await fetchDynamic(); - await primeActiveRoute(); -} - // --------------------------------------------------------------------------- // Transition banner lifecycle // --------------------------------------------------------------------------- From 18f9dcf08935946264a7340dd636a5ef9a5fcd69 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 17:24:14 -0700 Subject: [PATCH 13/16] Change default model; cleanup route activiation; minimize logging --- brev/launch.sh | 4 +- brev/welcome-ui/server.js | 64 +------------------ .../extension/model-registry.ts | 12 ++-- .../openclaw-nvidia/openclaw-nvidia-start.sh | 2 +- 4 files changed, 12 insertions(+), 70 deletions(-) diff --git a/brev/launch.sh b/brev/launch.sh index e0567aa..4443172 100755 --- a/brev/launch.sh +++ b/brev/launch.sh @@ -568,12 +568,12 @@ set_inference_route() { log "Configuring inference route..." - if "$CLI_BIN" inference set --provider nvidia-endpoints --model moonshotai/kimi-k2.5 >/dev/null 2>&1; then + if "$CLI_BIN" inference set --provider nvidia-endpoints --model qwen/qwen3.5-397b-a17b >/dev/null 2>&1; then log "Configured inference via '$CLI_BIN inference set'." return fi - if "$CLI_BIN" cluster inference set --provider nvidia-endpoints --model moonshotai/kimi-k2.5 >/dev/null 2>&1; then + if "$CLI_BIN" cluster inference set --provider nvidia-endpoints --model qwen/qwen3.5-397b-a17b >/dev/null 2>&1; then log "Configured inference via legacy '$CLI_BIN cluster inference set'." return fi diff --git a/brev/welcome-ui/server.js b/brev/welcome-ui/server.js index 0af76e5..77bc988 100644 --- a/brev/welcome-ui/server.js +++ b/brev/welcome-ui/server.js @@ -115,6 +115,9 @@ function isNoisyProxyPath(method, requestPath) { if (!requestPath) return false; if (requestPath === "/api/pairing-bootstrap") return true; if (requestPath === "/favicon.ico" || requestPath === "/favicon.svg") return true; + if ((method || "GET") === "GET" && (requestPath === "/" || requestPath === "/chat" || requestPath.startsWith("/chat?"))) return true; + if (requestPath === "/__openclaw/control-ui-config.json") return true; + if ((method || "GET") === "GET" && requestPath.startsWith("/avatar/main?meta=1")) return true; if (requestPath.endsWith(".map")) return true; if ((method || "GET") === "GET" && requestPath.startsWith("/assets/")) return true; return false; @@ -283,8 +286,6 @@ const sandboxState = { pid: null, url: null, error: null, - routeActivationAttempted: false, - routeActivationInFlight: false, }; const injectKeyState = { @@ -418,7 +419,6 @@ async function sandboxReady() { const url = buildOpenclawUrl(token); sandboxState.status = "running"; sandboxState.url = url; - await activateCurrentInferenceRouteOnce("sandbox-ready"); return true; } } @@ -705,8 +705,6 @@ function runSandboxCreate() { sandboxState.status = "creating"; sandboxState.error = null; sandboxState.url = null; - sandboxState.routeActivationAttempted = false; - sandboxState.routeActivationInFlight = false; (async () => { try { @@ -840,7 +838,6 @@ function runSandboxCreate() { const url = buildOpenclawUrl(token); sandboxState.status = "running"; sandboxState.url = url; - await activateCurrentInferenceRouteOnce("sandbox-create"); return; } await sleep(3000); @@ -1226,58 +1223,6 @@ async function handleClusterInferenceSet(req, res) { } } -async function activateCurrentInferenceRouteOnce(reason = "sandbox-ready") { - if (sandboxState.routeActivationAttempted || sandboxState.routeActivationInFlight) return; - - sandboxState.routeActivationInFlight = true; - try { - const current = await execFirstSuccess( - [ - cliArgs("inference", "get"), - cliArgs("cluster", "inference", "get"), - ], - 30000 - ); - if (current.code !== 0) { - const err = (current.stderr || current.stdout || "get failed").trim(); - logWelcome(`[inference-route] skipped (${reason}): could not read current route: ${err}`); - return; - } - - const parsed = parseClusterInference(current.stdout); - if (!parsed?.providerName || !parsed.modelId) { - logWelcome(`[inference-route] skipped (${reason}): no active route configured`); - return; - } - - logWelcome( - `[inference-route] activating once (${reason}): ${parsed.providerName}/${parsed.modelId}` - ); - const applied = await execFirstSuccess( - [ - cliArgs("inference", "set", "--provider", parsed.providerName, "--model", parsed.modelId), - cliArgs("cluster", "inference", "set", "--provider", parsed.providerName, "--model", parsed.modelId), - ], - 30000 - ); - if (applied.code !== 0) { - const err = (applied.stderr || applied.stdout || "set failed").trim(); - logWelcome(`[inference-route] activation failed (${reason}): ${err}`); - return; - } - - const result = parseClusterInference(applied.stdout) || parsed; - logWelcome( - `[inference-route] activation complete (${reason}): ${result.providerName}/${result.modelId}` - ); - } catch (e) { - logWelcome(`[inference-route] activation error (${reason}): ${String(e)}`); - } finally { - sandboxState.routeActivationAttempted = true; - sandboxState.routeActivationInFlight = false; - } -} - // ── Reverse proxy (HTTP) ─────────────────────────────────────────────────── function proxyToSandbox(clientReq, clientRes) { @@ -1392,7 +1337,6 @@ async function handleSandboxStatus(req, res) { const url = buildOpenclawUrl(token, req); sandboxState.status = "running"; sandboxState.url = url; - await activateCurrentInferenceRouteOnce("sandbox-status"); state.status = "running"; state.url = url; } @@ -1771,8 +1715,6 @@ function _resetForTesting() { sandboxState.pid = null; sandboxState.url = null; sandboxState.error = null; - sandboxState.routeActivationAttempted = false; - sandboxState.routeActivationInFlight = false; injectKeyState.status = "idle"; injectKeyState.error = null; injectKeyState.keyHash = null; diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-registry.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-registry.ts index 9016971..c59964e 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-registry.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-registry.ts @@ -173,7 +173,7 @@ export function curatedToModelEntry(c: CuratedModel): ModelEntry { return { id: c.id, name: c.name, - isDefault: c.id === "curated-kimi-k25", + isDefault: c.id === "curated-qwen35", providerKey: key, modelRef: `${key}/${c.modelId}`, keyType: "inference", @@ -208,19 +208,19 @@ const DEFAULT_PROVIDER_KEY = "curated-nvidia-endpoints"; export const MODEL_REGISTRY: readonly ModelEntry[] = [ { - id: "curated-kimi-k25", - name: "Kimi K2.5", + id: "curated-qwen35", + name: "Qwen 3.5 397B", isDefault: true, providerKey: DEFAULT_PROVIDER_KEY, - modelRef: `${DEFAULT_PROVIDER_KEY}/moonshotai/kimi-k2.5`, + modelRef: `${DEFAULT_PROVIDER_KEY}/qwen/qwen3.5-397b-a17b`, keyType: "inference", providerConfig: { baseUrl: "https://inference.local/v1", api: "openai-completions", models: [ { - id: "moonshotai/kimi-k2.5", - name: "Kimi K2.5", + id: "qwen/qwen3.5-397b-a17b", + name: "Qwen 3.5 397B", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, diff --git a/sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh b/sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh index 61c6ee3..9e576cb 100644 --- a/sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh +++ b/sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh @@ -68,7 +68,7 @@ fi # -------------------------------------------------------------------------- # Onboard and start the gateway # -------------------------------------------------------------------------- -_DEFAULT_MODEL="moonshotai/kimi-k2.5" +_DEFAULT_MODEL="qwen/qwen3.5-397b-a17b" _DEFAULT_CONTEXT_WINDOW=200000 _DEFAULT_MAX_TOKENS=8192 export NVIDIA_API_KEY="${NVIDIA_INFERENCE_API_KEY:- }" From a2d5a302dd2576cbaf402a9cfb461e6448573438 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 17:31:58 -0700 Subject: [PATCH 14/16] Remove risky fallback --- sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh b/sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh index 9e576cb..d493180 100644 --- a/sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh +++ b/sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh @@ -109,7 +109,6 @@ if chat_origin != local: cfg['gateway']['controlUi'] = { 'allowInsecureAuth': True, 'allowedOrigins': origins, - 'dangerouslyAllowHostHeaderOriginFallback': True, } cfg['gateway']['trustedProxies'] = ['127.0.0.1', '::1'] for provider in cfg.get('models', {}).get('providers', {}).values(): From 31c5800ef13ede53fd380c39bee4fcf170dd8a0a Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 18:04:42 -0700 Subject: [PATCH 15/16] Fix ui pairing --- .../nemoclaw-ui-extension/extension/index.ts | 13 +++++++----- sandboxes/openclaw-nvidia/policy-proxy.js | 21 ++++++++++++++----- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts index 556a9fc..5d959e6 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts @@ -37,17 +37,18 @@ interface PairingBootstrapState { active?: boolean; lastApprovalDeviceId?: string; lastError?: string; + sawBrowserPaired?: boolean; } function isPairingTerminal(state: PairingBootstrapState | null): boolean { if (!state) return false; if (state.active) return false; - return state.status === "approved" || state.status === "paired" || state.status === "timeout" || state.status === "error"; + return state.status === "paired" || state.status === "timeout" || state.status === "error"; } function isPairingRecoveryEligible(state: PairingBootstrapState | null): boolean { if (!state) return false; - return state.status === "approved" || state.status === "approved-pending-settle" || state.status === "paired"; + return state.status === "paired"; } function inject(): boolean { @@ -154,11 +155,13 @@ function getOverlayTextForPairingState(state: PairingBootstrapState | null): str case "approving": return "Approving device pairing..."; case "approved-pending-settle": - return "Device pairing approved. Finalizing dashboard..."; + return "Device pairing approved. Waiting for dashboard device to finish pairing..."; + case "paired-other-device": + return "Pairing another device. Waiting for browser dashboard pairing..."; case "paired": return "Device paired. Finalizing dashboard..."; case "approved": - return "Device pairing approved. Opening dashboard..."; + return "Device pairing approved. Waiting for browser dashboard pairing..."; case "timeout": return "Pairing bootstrap timed out. Opening dashboard..."; case "error": @@ -232,7 +235,7 @@ function bootstrap() { latestPairingState = initialState; if (initialState && !initialState.active && isPairingTerminal(initialState)) { - const shouldWarmStart = isPairingBootstrapped() || initialState.status === "paired" || initialState.status === "approved"; + const shouldWarmStart = isPairingBootstrapped() || initialState.status === "paired"; if (shouldWarmStart) { try { await waitForStableConnection(WARM_START_CONNECTION_WINDOW_MS, WARM_START_TIMEOUT_MS); diff --git a/sandboxes/openclaw-nvidia/policy-proxy.js b/sandboxes/openclaw-nvidia/policy-proxy.js index 0405354..12d2ede 100644 --- a/sandboxes/openclaw-nvidia/policy-proxy.js +++ b/sandboxes/openclaw-nvidia/policy-proxy.js @@ -56,6 +56,7 @@ const pairingBootstrap = { lastError: "", sawPending: false, sawPaired: false, + sawBrowserPaired: false, active: false, timer: null, heartbeatAt: 0, @@ -102,6 +103,7 @@ function pairingSnapshot() { lastError: pairingBootstrap.lastError || "", sawPending: pairingBootstrap.sawPending, sawPaired: pairingBootstrap.sawPaired, + sawBrowserPaired: pairingBootstrap.sawBrowserPaired, active: pairingBootstrap.active, lastApprovalAt: pairingBootstrap.lastApprovalAt || null, }; @@ -170,6 +172,11 @@ function summarizeDevices(devices) { return `pending=${devices.pending.length} [${format(devices.pending)}] paired=${devices.paired.length} [${format(devices.paired)}]`; } +function isBrowserDevice(device) { + if (!device || typeof device !== "object") return false; + return device.clientId === "openclaw-control-ui" || device.clientMode === "webchat"; +} + function finishPairingBootstrap(status, extra = {}) { if (pairingBootstrap.timer) { clearTimeout(pairingBootstrap.timer); @@ -202,6 +209,7 @@ async function runPairingBootstrapTick() { const devices = normalizeDeviceList(listResult.stdout); const hasPending = devices.pending.length > 0; const hasPaired = devices.paired.length > 0; + const hasBrowserPaired = devices.paired.some(isBrowserDevice); if (!listResult.ok && !listResult.stdout.trim()) { updatePairingState({ @@ -211,9 +219,10 @@ async function runPairingBootstrapTick() { }); } else { updatePairingState({ - status: hasPending ? "pending" : hasPaired ? "paired" : "armed", + status: hasPending ? "pending" : hasBrowserPaired ? "paired" : hasPaired ? "paired-other-device" : "armed", sawPending: pairingBootstrap.sawPending || hasPending, sawPaired: pairingBootstrap.sawPaired || hasPaired, + sawBrowserPaired: pairingBootstrap.sawBrowserPaired || hasBrowserPaired, }); } @@ -273,10 +282,10 @@ async function runPairingBootstrapTick() { if ( pairingBootstrap.approvedCount === 0 && !hasPending && - hasPaired && + hasBrowserPaired && quietPolls >= AUTO_PAIR_QUIET_POLLS ) { - finishPairingBootstrap("paired"); + finishPairingBootstrap("paired", { sawBrowserPaired: true }); return; } @@ -284,9 +293,10 @@ async function runPairingBootstrapTick() { pairingBootstrap.approvedCount > 0 && pairingBootstrap.lastApprovalAt > 0 && Date.now() - pairingBootstrap.lastApprovalAt >= AUTO_PAIR_APPROVAL_SETTLE_MS && + hasBrowserPaired && quietPolls >= AUTO_PAIR_QUIET_POLLS ) { - finishPairingBootstrap("approved"); + finishPairingBootstrap("paired", { sawBrowserPaired: true }); return; } @@ -309,7 +319,7 @@ function startPairingBootstrap(reason, force = false) { } if ( !force && - (pairingBootstrap.status === "approved" || pairingBootstrap.status === "paired") + pairingBootstrap.status === "paired" ) { return pairingSnapshot(); } @@ -324,6 +334,7 @@ function startPairingBootstrap(reason, force = false) { lastError: "", sawPending: false, sawPaired: false, + sawBrowserPaired: false, active: true, heartbeatAt: 0, lastApprovalAt: 0, From 77510cbca40ee9dd847c31a449ecf8eedacf0b58 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Sat, 14 Mar 2026 18:45:28 -0700 Subject: [PATCH 16/16] Add clear status updates for device pairing --- .../nemoclaw-ui-extension/extension/index.ts | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts index 5d959e6..016228f 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts @@ -40,6 +40,18 @@ interface PairingBootstrapState { sawBrowserPaired?: boolean; } +const PAIRING_STATUS_PRIORITY: Record = { + idle: 0, + armed: 1, + pending: 2, + approving: 3, + "approved-pending-settle": 4, + "paired-other-device": 5, + paired: 6, + timeout: 7, + error: 7, +}; + function isPairingTerminal(state: PairingBootstrapState | null): boolean { if (!state) return false; if (state.active) return false; @@ -181,6 +193,7 @@ function bootstrap() { let latestPairingState: PairingBootstrapState | null = null; let lastPairingStartAt = 0; let overlayVisible = false; + let overlayPriority = -1; const stopPairingPoll = () => { stopped = true; @@ -194,6 +207,14 @@ function bootstrap() { showConnectOverlay(); }; + const setMonotonicOverlayText = (text: string | null, status?: string) => { + if (!text) return; + const nextPriority = PAIRING_STATUS_PRIORITY[status || ""] ?? overlayPriority; + if (nextPriority < overlayPriority) return; + overlayPriority = nextPriority; + setConnectOverlayText(text); + }; + const scheduleOverlay = () => { if (overlayVisible || overlayTimer) return; overlayTimer = window.setTimeout(() => { @@ -207,7 +228,7 @@ function bootstrap() { const state = await fetchPairingBootstrapState("GET"); latestPairingState = state; const text = getOverlayTextForPairingState(state); - if (text) setConnectOverlayText(text); + setMonotonicOverlayText(text, state?.status); if ( !stopped && @@ -222,7 +243,7 @@ function bootstrap() { latestPairingState = rearmed; lastPairingStartAt = Date.now(); const rearmedText = getOverlayTextForPairingState(rearmed); - if (rearmedText) setConnectOverlayText(rearmedText); + setMonotonicOverlayText(rearmedText, rearmed.status); } } @@ -253,7 +274,7 @@ function bootstrap() { const initialText = getOverlayTextForPairingState(initialState); if (initialText) { ensureOverlayVisible(); - setConnectOverlayText(initialText); + setMonotonicOverlayText(initialText, initialState?.status); } if (!initialState || (!initialState.active && !isPairingTerminal(initialState))) { @@ -263,7 +284,7 @@ function bootstrap() { latestPairingState = started; lastPairingStartAt = Date.now(); const startedText = getOverlayTextForPairingState(started); - if (startedText) setConnectOverlayText(startedText); + setMonotonicOverlayText(startedText, started.status); } } @@ -297,7 +318,7 @@ function bootstrap() { return false; }; - const runReadinessFlow = () => { + function runReadinessFlow() { waitForDashboardReadiness( INITIAL_CONNECTION_TIMEOUT_MS, "Auto-approving device pairing. Hang tight...", @@ -345,7 +366,7 @@ function bootstrap() { stopPairingPoll(); revealApp(); }); - }; + } const keysIngested = ingestKeysFromUrl();