Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 64 additions & 10 deletions brev/welcome-ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
Expand All @@ -148,20 +178,26 @@
}

// 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";
} else {
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");
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -262,6 +307,8 @@
sandboxReady = false;
sandboxUrl = null;
installTriggered = false;
keyInjected = false;
lastSubmittedKey = "";
stopPolling();

setLogIcon(logSandboxIcon, null);
Expand All @@ -277,7 +324,7 @@
triggerInstall();
}

apiKeyInput.addEventListener("input", updateButtonState);
apiKeyInput.addEventListener("input", onApiKeyInput);
btnLaunch.addEventListener("click", openOpenClaw);
btnRetry.addEventListener("click", resetInstall);

Expand All @@ -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;
Expand All @@ -302,6 +353,9 @@
updateButtonState();

showOverlay(overlayInstall);
if (!keyInjected) {
startPolling();
}
} else if (data.status === "creating") {
installTriggered = true;

Expand Down
2 changes: 1 addition & 1 deletion brev/welcome-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,6 @@ <h4 class="instructions-section__title">4. Manage policies with the TUI</h4>
NemoClaw Sandbox &middot; NVIDIA
</footer>

<script src="app.js?v=11"></script>
<script src="app.js?v=12"></script>
</body>
</html>
113 changes: 113 additions & 0 deletions brev/welcome-ui/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

"""NemoClaw Welcome UI — HTTP server with sandbox lifecycle APIs."""

import hashlib
import http.client
import http.server
import json
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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):
Expand All @@ -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 ------------------------------------
Expand Down
3 changes: 2 additions & 1 deletion sandboxes/nemoclaw/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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 \
Expand Down
22 changes: 20 additions & 2 deletions sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Loading