diff --git a/brev/welcome-ui/app.js b/brev/welcome-ui/app.js index 645eb7a..435c9a9 100644 --- a/brev/welcome-ui/app.js +++ b/brev/welcome-ui/app.js @@ -116,6 +116,10 @@ let sandboxUrl = null; let installTriggered = false; let pollTimer = null; + let keyInjected = false; + let injectInFlight = false; + let injectTimer = null; + let lastSubmittedKey = ""; function stopPolling() { if (pollTimer) { @@ -124,12 +128,38 @@ } } + async function submitKeyForInjection(key) { + if (key === lastSubmittedKey) return; + lastSubmittedKey = key; + keyInjected = false; + injectInFlight = true; + updateButtonState(); + try { + await fetch("/api/inject-key", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key }), + }); + } catch {} + injectInFlight = false; + if (!pollTimer && sandboxReady) startPolling(); + } + + function onApiKeyInput() { + updateButtonState(); + const key = apiKeyInput.value.trim(); + if (!isApiKeyValid()) return; + if (injectTimer) clearTimeout(injectTimer); + injectTimer = setTimeout(() => submitKeyForInjection(key), 300); + } + /** - * Four-state CTA button: - * 1. API empty + tasks running -> "Waiting for API key…" (disabled) - * 2. API valid + tasks running -> "Provisioning Sandbox…" (disabled, spinner) - * 3. API empty + tasks complete -> "Waiting for API key…" (disabled) - * 4. API valid + tasks complete -> "Open NemoClaw" (enabled) + * Five-state CTA button: + * 1. API empty + tasks running -> "Waiting for API key…" (disabled) + * 2. API valid + tasks running -> "Provisioning Sandbox…" (disabled, spinner) + * 3. API empty + tasks complete -> "Waiting for API key…" (disabled) + * 4. API valid + sandbox ready + !key -> "Configuring API key…" (disabled, spinner) + * 5. API valid + sandbox ready + key -> "Open NemoClaw" (enabled) */ function updateButtonState() { const keyValid = isApiKeyValid(); @@ -148,7 +178,7 @@ } // Console "ready" line - if (sandboxReady && keyValid) { + if (sandboxReady && keyValid && keyInjected) { logReady.hidden = false; logReady.querySelector(".console__icon").textContent = CHECK_CHAR; logReady.querySelector(".console__icon").className = "console__icon console__icon--done"; @@ -156,12 +186,18 @@ logReady.hidden = true; } - if (sandboxReady && keyValid) { + if (sandboxReady && keyValid && keyInjected) { btnLaunch.disabled = false; btnLaunch.classList.add("btn--ready"); btnSpinner.hidden = true; btnSpinner.style.display = "none"; btnLaunchLabel.textContent = "Open NemoClaw"; + } else if (sandboxReady && keyValid && !keyInjected) { + btnLaunch.disabled = true; + btnLaunch.classList.remove("btn--ready"); + btnSpinner.hidden = false; + btnSpinner.style.display = ""; + btnLaunchLabel.textContent = "Configuring API key\u2026"; } else if (!sandboxReady && keyValid) { btnLaunch.disabled = true; btnLaunch.classList.remove("btn--ready"); @@ -229,19 +265,28 @@ const res = await fetch("/api/sandbox-status"); const data = await res.json(); + if (!injectInFlight) { + keyInjected = !!data.key_injected; + } + if (data.status === "running") { - stopPolling(); sandboxReady = true; sandboxUrl = data.url || null; setLogIcon(logGatewayIcon, "done"); logGateway.querySelector(".console__text").textContent = "OpenClaw agent gateway online."; + + if (keyInjected) { + stopPolling(); + } updateButtonState(); } else if (data.status === "error") { stopPolling(); installTriggered = false; showError(data.error || "Sandbox creation failed"); + } else { + updateButtonState(); } } catch { // transient fetch error, keep polling @@ -250,7 +295,7 @@ } function openOpenClaw() { - if (!sandboxReady || !isApiKeyValid() || !sandboxUrl) return; + if (!sandboxReady || !isApiKeyValid() || !keyInjected || !sandboxUrl) return; const apiKey = apiKeyInput.value.trim(); const url = new URL(sandboxUrl); @@ -262,6 +307,8 @@ sandboxReady = false; sandboxUrl = null; installTriggered = false; + keyInjected = false; + lastSubmittedKey = ""; stopPolling(); setLogIcon(logSandboxIcon, null); @@ -277,7 +324,7 @@ triggerInstall(); } - apiKeyInput.addEventListener("input", updateButtonState); + apiKeyInput.addEventListener("input", onApiKeyInput); btnLaunch.addEventListener("click", openOpenClaw); btnRetry.addEventListener("click", resetInstall); @@ -288,6 +335,10 @@ const res = await fetch("/api/sandbox-status"); const data = await res.json(); + if (data.key_injected) { + keyInjected = true; + } + if (data.status === "running" && data.url) { sandboxReady = true; sandboxUrl = data.url; @@ -302,6 +353,9 @@ updateButtonState(); showOverlay(overlayInstall); + if (!keyInjected) { + startPolling(); + } } else if (data.status === "creating") { installTriggered = true; diff --git a/brev/welcome-ui/index.html b/brev/welcome-ui/index.html index 7c17d77..d0c52cd 100644 --- a/brev/welcome-ui/index.html +++ b/brev/welcome-ui/index.html @@ -230,6 +230,6 @@

4. Manage policies with the TUI

NemoClaw Sandbox · NVIDIA - + diff --git a/brev/welcome-ui/server.py b/brev/welcome-ui/server.py index 2c27bfc..80891d0 100644 --- a/brev/welcome-ui/server.py +++ b/brev/welcome-ui/server.py @@ -5,6 +5,7 @@ """NemoClaw Welcome UI — HTTP server with sandbox lifecycle APIs.""" +import hashlib import http.client import http.server import json @@ -43,6 +44,66 @@ "error": None, } +_inject_key_lock = threading.Lock() +_inject_key_state = { + "status": "idle", # idle | injecting | done | error + "error": None, + "key_hash": None, +} + + +def _hash_key(key: str) -> str: + return hashlib.sha256(key.encode()).hexdigest() + + +def _inject_log(msg: str) -> None: + ts = time.strftime("%H:%M:%S") + sys.stderr.write(f"[inject-key {ts}] {msg}\n") + sys.stderr.flush() + + +def _run_inject_key(key: str, key_hash: str) -> None: + """Background thread: update the NemoClaw provider credential.""" + _inject_log(f"step 1/3: received key (hash={key_hash[:12]}…)") + cmd = [ + "nemoclaw", "provider", "update", "nvidia-inference", + "--type", "openai", + "--credential", f"OPENAI_API_KEY={key}", + "--config", "OPENAI_BASE_URL=https://inference-api.nvidia.com/v1", + ] + _inject_log(f"step 2/3: running nemoclaw provider update nvidia-inference …") + try: + t0 = time.time() + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=120, + ) + elapsed = time.time() - t0 + _inject_log(f" CLI exited {result.returncode} in {elapsed:.1f}s") + if result.stdout.strip(): + _inject_log(f" stdout: {result.stdout.strip()}") + if result.stderr.strip(): + _inject_log(f" stderr: {result.stderr.strip()}") + + if result.returncode != 0: + err = (result.stderr or result.stdout or "unknown error").strip() + _inject_log(f"step 3/3: FAILED — {err}") + with _inject_key_lock: + _inject_key_state["status"] = "error" + _inject_key_state["error"] = err + return + + _inject_log(f"step 3/3: SUCCESS — provider nvidia-inference updated") + with _inject_key_lock: + _inject_key_state["status"] = "done" + _inject_key_state["error"] = None + _inject_key_state["key_hash"] = key_hash + + except Exception as exc: + _inject_log(f"step 3/3: EXCEPTION — {exc}") + with _inject_key_lock: + _inject_key_state["status"] = "error" + _inject_key_state["error"] = str(exc) + def _sandbox_ready() -> bool: with _sandbox_lock: @@ -391,6 +452,8 @@ def _route(self): return self._handle_install_openclaw() if path == "/api/policy-sync" and self.command == "POST": return self._handle_policy_sync() + if path == "/api/inject-key" and self.command == "POST": + return self._handle_inject_key() if _sandbox_ready(): return self._proxy_to_sandbox() @@ -551,6 +614,50 @@ def _handle_policy_sync(self): _log(f"── responding {status}: {json.dumps(result)}") return self._json_response(status, result) + # -- POST /api/inject-key ------------------------------------------- + + def _handle_inject_key(self): + """Asynchronously update the NemoClaw provider credential. + + Returns immediately (202) and runs the slow CLI command in a + background thread. The frontend polls /api/sandbox-status to + learn when injection is complete. + """ + content_length = int(self.headers.get("Content-Length", 0)) + if content_length == 0: + return self._json_response(400, {"ok": False, "error": "empty body"}) + raw = self.rfile.read(content_length).decode("utf-8", errors="replace") + try: + data = json.loads(raw) + except json.JSONDecodeError: + return self._json_response(400, {"ok": False, "error": "invalid JSON"}) + + key = data.get("key", "").strip() + if not key: + return self._json_response(400, {"ok": False, "error": "missing key"}) + + key_hash = _hash_key(key) + + with _inject_key_lock: + if (_inject_key_state["status"] == "done" + and _inject_key_state["key_hash"] == key_hash): + return self._json_response(200, {"ok": True, "already": True}) + + if (_inject_key_state["status"] == "injecting" + and _inject_key_state["key_hash"] == key_hash): + return self._json_response(202, {"ok": True, "started": True}) + + _inject_key_state["status"] = "injecting" + _inject_key_state["error"] = None + _inject_key_state["key_hash"] = key_hash + + thread = threading.Thread( + target=_run_inject_key, args=(key, key_hash), daemon=True, + ) + thread.start() + + return self._json_response(202, {"ok": True, "started": True}) + # -- GET /api/sandbox-status ---------------------------------------- def _handle_sandbox_status(self): @@ -568,10 +675,16 @@ def _handle_sandbox_status(self): state["status"] = "running" state["url"] = url + with _inject_key_lock: + key_injected = _inject_key_state["status"] == "done" + key_inject_error = _inject_key_state.get("error") + return self._json_response(200, { "status": state["status"], "url": state.get("url"), "error": state.get("error"), + "key_injected": key_injected, + "key_inject_error": key_inject_error, }) # -- GET /api/connection-details ------------------------------------ diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index 4ed94c4..8ea681f 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -69,6 +69,7 @@ fi # Onboard and start the gateway # -------------------------------------------------------------------------- export NVIDIA_API_KEY="${NVIDIA_INFERENCE_API_KEY:- }" +_ONBOARD_KEY="${NVIDIA_INFERENCE_API_KEY:-not-used}" openclaw onboard \ --non-interactive \ --accept-risk \ @@ -79,7 +80,7 @@ openclaw onboard \ --auth-choice custom-api-key \ --custom-base-url "https://inference.local/v1" \ --custom-model-id "aws/anthropic/bedrock-claude-opus-4-6" \ - --custom-api-key "not-used" \ + --custom-api-key "$_ONBOARD_KEY" \ --secret-input-mode plaintext \ --custom-compatibility openai \ --gateway-port 18788 \ diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index 40321af..d5e6f1c 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -14,7 +14,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 { ingestKeysFromUrl, DEFAULT_MODEL, resolveApiKey } from "./model-registry.ts"; +import { ingestKeysFromUrl, DEFAULT_MODEL, resolveApiKey, isKeyConfigured } from "./model-registry.ts"; import { waitForClient, patchConfig, waitForReconnect } from "./gateway-bridge.ts"; function inject(): boolean { @@ -38,6 +38,22 @@ function watchGotoLinks() { }); } +/** + * Update the NemoClaw provider credential on the host so the sandbox + * proxy / inference router uses the real key for inference.local requests. + * Mirrors the policy-sync pattern in policy-page.ts. + */ +function injectKeyViaHost(key: string): void { + fetch("/api/inject-key", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key }), + }) + .then((r) => r.json()) + .then((b) => console.log("[NeMoClaw] inject-key:", b)) + .catch((e) => console.warn("[NeMoClaw] inject-key failed:", e)); +} + /** * When API keys arrive via URL parameters (from the welcome UI), apply * the default model's provider config so the gateway has a valid key @@ -103,8 +119,10 @@ function bootstrap() { watchChatCompose(); watchGotoLinks(); - if (keysIngested) { + const defaultKey = resolveApiKey(DEFAULT_MODEL.keyType); + if (keysIngested || isKeyConfigured(defaultKey)) { applyIngestedKeys(); + injectKeyViaHost(defaultKey); } if (inject()) { diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts index a2b197d..81181ec 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts @@ -45,8 +45,13 @@ export function setIntegrateApiKey(key: string): void { else localStorage.removeItem(LS_INTEGRATE_KEY); } +const PLACEHOLDER_KEYS = ["not-used", "unused", "placeholder", "none", "null", "undefined"]; + export function isKeyConfigured(key: string): boolean { - return !!key && !key.startsWith("__"); + if (!key || !key.trim()) return false; + const lower = key.trim().toLowerCase(); + if (lower.startsWith("__")) return false; + return !PLACEHOLDER_KEYS.includes(lower); } /** diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts index 29afe05..b0da379 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts @@ -29,7 +29,7 @@ let applyInFlight = false; // Build the config.patch payload for a given model entry // --------------------------------------------------------------------------- -function buildModelPatch(entry: ModelEntry): Record | null { +export function buildModelPatch(entry: ModelEntry): Record | null { const apiKey = resolveApiKey(entry.keyType); if (!isKeyConfigured(apiKey)) { diff --git a/sandboxes/nemoclaw/skills/.gitkeep b/sandboxes/nemoclaw/skills/.gitkeep deleted file mode 100644 index e69de29..0000000