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
2 changes: 1 addition & 1 deletion brev/welcome-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ <h3 class="modal__title">Install OpenClaw</h3>
</div>
<div class="form-field__footer">
<span class="form-field__hint" id="key-hint"></span>
<a class="form-field__help" href="https://build.nvidia.com/models" target="_blank" rel="noopener noreferrer">
<a class="form-field__help" href="https://build.nvidia.com/settings/api-keys" target="_blank" rel="noopener noreferrer">
Get a key at build.nvidia.com
<svg viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" x2="21" y1="14" y2="3"/></svg>
</a>
Expand Down
53 changes: 48 additions & 5 deletions brev/welcome-ui/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

LOG_FILE = "/tmp/nemoclaw-sandbox-create.log"
BREV_ENV_ID = os.environ.get("BREV_ENV_ID", "")
_detected_brev_id = ""

_sandbox_lock = threading.Lock()
_sandbox_state = {
Expand All @@ -34,14 +35,31 @@
}


def _extract_brev_id(host: str) -> str:
"""Extract the Brev environment ID from a Host header like '80810-xxx.brevlab.com'."""
match = re.match(r"\d+-(.+?)\.brevlab\.com", host)
return match.group(1) if match else ""


def _maybe_detect_brev_id(host: str) -> None:
"""Cache the Brev environment ID from the request Host header (idempotent)."""
global _detected_brev_id
if not _detected_brev_id:
brev_id = _extract_brev_id(host)
if brev_id:
_detected_brev_id = brev_id


def _build_openclaw_url(token: str | None) -> str:
"""Build the externally reachable OpenClaw URL.

Uses the Cloudflare tunnel pattern from nemoclaw-start.sh when
BREV_ENV_ID is available, otherwise falls back to localhost.
BREV_ENV_ID is available (or detected from the request Host header),
otherwise falls back to localhost.
"""
if BREV_ENV_ID:
url = f"https://187890-{BREV_ENV_ID}.brevlab.com/"
brev_id = BREV_ENV_ID or _detected_brev_id
if brev_id:
url = f"https://187890-{brev_id}.brevlab.com/"
else:
url = "http://127.0.0.1:18789/"
if token:
Expand Down Expand Up @@ -70,6 +88,21 @@ def _read_openclaw_token() -> str | None:
return None


def _gateway_log_ready() -> bool:
"""True once nemoclaw-start.sh has launched the OpenClaw gateway.

The startup script prints this sentinel *after* ``openclaw gateway``
has been backgrounded and the auth token extracted, so its presence
in the log is a reliable readiness signal — unlike a bare port check
which fires as soon as the forwarding tunnel opens.
"""
try:
with open(LOG_FILE) as f:
return "OpenClaw gateway starting in background" in f.read()
except FileNotFoundError:
return False


def _cleanup_existing_sandbox():
"""Delete any leftover sandbox named 'nemoclaw' from a previous attempt."""
try:
Expand Down Expand Up @@ -153,8 +186,14 @@ def _stream_output():

deadline = time.time() + 120
while time.time() < deadline:
if _port_open("127.0.0.1", 18789):
if _gateway_log_ready() and _port_open("127.0.0.1", 18789):
token = _read_openclaw_token()
if token is None:
for _ in range(5):
time.sleep(1)
token = _read_openclaw_token()
if token is not None:
break
url = _build_openclaw_url(token)
with _sandbox_lock:
_sandbox_state["status"] = "running"
Expand Down Expand Up @@ -197,11 +236,13 @@ def end_headers(self):
# -- Routing --------------------------------------------------------

def do_POST(self):
_maybe_detect_brev_id(self.headers.get("Host", ""))
if self.path == "/api/install-openclaw":
return self._handle_install_openclaw()
self.send_error(404)

def do_GET(self):
_maybe_detect_brev_id(self.headers.get("Host", ""))
if self.path == "/api/sandbox-status":
return self._handle_sandbox_status()
if self.path == "/api/connection-details":
Expand Down Expand Up @@ -240,7 +281,9 @@ def _handle_sandbox_status(self):
with _sandbox_lock:
state = dict(_sandbox_state)

if state["status"] == "creating" and _port_open("127.0.0.1", 18789):
if (state["status"] == "creating"
and _gateway_log_ready()
and _port_open("127.0.0.1", 18789)):
token = _read_openclaw_token()
url = _build_openclaw_url(token)
with _sandbox_lock:
Expand Down
5 changes: 3 additions & 2 deletions sandboxes/nemoclaw/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ RUN set -e; \
--bundle \
--format=esm \
--outfile="$UI_DIR/assets/nemoclaw-devx.js"; \
sed -i 's|</head>|<link rel="stylesheet" href="./assets/nemoclaw-devx.css">\n</head>|' "$UI_DIR/index.html"; \
sed -i 's|</head>|<script type="module" src="./assets/nemoclaw-devx.js"></script>\n</head>|' "$UI_DIR/index.html"; \
HASH=$(md5sum "$UI_DIR/assets/nemoclaw-devx.js" | cut -c1-8); \
sed -i "s|</head>|<link rel=\"stylesheet\" href=\"./assets/nemoclaw-devx.css?v=${HASH}\">\n</head>|" "$UI_DIR/index.html"; \
sed -i "s|</head>|<script type=\"module\" src=\"./assets/nemoclaw-devx.js?v=${HASH}\"></script>\n</head>|" "$UI_DIR/index.html"; \
npm uninstall -g esbuild

ENTRYPOINT ["/bin/bash"]
29 changes: 25 additions & 4 deletions sandboxes/nemoclaw/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -74,23 +74,44 @@ openclaw onboard \
export NVIDIA_API_KEY=" "

GATEWAY_PORT=18789
if [ -z "$BREV_UI_URL" ]; then
BREV_UI_URL="https://${GATEWAY_PORT}0-${BREV_ENV_ID}.brevlab.com"

# Derive the Brev environment ID so we can build the correct gateway origin.
# BREV_UI_URL (if set) points at the *welcome UI* port, not the gateway port,
# so we must always compute the gateway origin separately.
if [ -z "${BREV_ENV_ID:-}" ] && [ -n "${BREV_UI_URL:-}" ]; then
BREV_ENV_ID=$(echo "$BREV_UI_URL" | sed -n 's|.*//[0-9]*-\([^.]*\)\.brevlab\.com.*|\1|p')
fi

if [ -n "${BREV_ENV_ID:-}" ]; then
export OPENCLAW_ORIGIN="https://${GATEWAY_PORT}0-${BREV_ENV_ID}.brevlab.com"
else
export OPENCLAW_ORIGIN="http://127.0.0.1:${GATEWAY_PORT}"
fi
export BREV_UI_URL

python3 -c "
import json, os
cfg = json.load(open(os.environ['HOME'] + '/.openclaw/openclaw.json'))
cfg['gateway']['controlUi'] = {
'allowInsecureAuth': True,
'allowedOrigins': [os.environ['BREV_UI_URL']]
'allowedOrigins': [os.environ['OPENCLAW_ORIGIN']]
}
json.dump(cfg, open(os.environ['HOME'] + '/.openclaw/openclaw.json', 'w'), indent=2)
"

nohup openclaw gateway > /tmp/gateway.log 2>&1 &

# Auto-approve pending device pairing requests so the browser is paired
# before the user notices the "pairing required" prompt in the Control UI.
(
_pair_deadline=$(($(date +%s) + 300))
while [ "$(date +%s)" -lt "$_pair_deadline" ]; do
sleep 0.5
if openclaw devices approve --latest --json 2>/dev/null | grep -q '"ok"'; then
echo "[auto-pair] Approved pending device pairing request."
fi
done
) >> /tmp/gateway.log 2>&1 &

CONFIG_FILE="${HOME}/.openclaw/openclaw.json"
token=$(grep -o '"token"\s*:\s*"[^"]*"' "${CONFIG_FILE}" 2>/dev/null | head -1 | cut -d'"' -f4 || true)

Expand Down
32 changes: 31 additions & 1 deletion sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ 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 { waitForClient, patchConfig } from "./gateway-bridge.ts";
import { waitForClient, patchConfig, waitForReconnect } from "./gateway-bridge.ts";

function inject(): boolean {
const hasButton = injectButton();
Expand Down Expand Up @@ -66,7 +66,37 @@ function applyIngestedKeys(): void {
});
}

/**
* Insert a full-screen loading overlay that covers the OpenClaw UI while the
* gateway connects and auto-pairs the device. The overlay is styled via
* styles.css and is automatically faded out once `data-nemoclaw-ready` is set
* on <body>. We remove it from the DOM after the CSS transition completes.
*/
function showConnectOverlay(): void {
if (document.querySelector(".nemoclaw-connect-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "nemoclaw-connect-overlay";
overlay.setAttribute("aria-live", "polite");
overlay.innerHTML =
'<div class="nemoclaw-connect-overlay__spinner"></div>' +
'<div class="nemoclaw-connect-overlay__text">Auto-approving device pairing. Hang tight...</div>';
document.body.prepend(overlay);
}

function revealApp(): void {
document.body.setAttribute("data-nemoclaw-ready", "");
const overlay = document.querySelector(".nemoclaw-connect-overlay");
if (overlay) {
overlay.addEventListener("transitionend", () => overlay.remove(), { once: true });
setTimeout(() => overlay.remove(), 600);
}
}

function bootstrap() {
showConnectOverlay();

waitForReconnect(30_000).then(revealApp).catch(revealApp);

const keysIngested = ingestKeysFromUrl();

watchOpenClawNavClicks();
Expand Down
68 changes: 55 additions & 13 deletions sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -597,29 +597,71 @@ main.content {
}

/* ===========================================
Model Switching — Suppress OpenClaw disconnect artifacts
Initial Connection — Hide the entire app until gateway pairing completes.
The OpenClaw UI uses Lit Web Components with Shadow DOM, so external
CSS cannot target error elements inside the shadow root. Instead we
hide the whole <openclaw-app> custom element (which lives in the
light DOM) and show a branded loading overlay on top.
=========================================== */

body.nemoclaw-switching .card.chat > .callout {
display: none !important;
body:not([data-nemoclaw-ready]) openclaw-app {
opacity: 0 !important;
pointer-events: none !important;
}

body.nemoclaw-switching .statusDot:not(.ok) {
visibility: hidden;
/* Loading overlay shown while gateway connects + auto-pairs */
.nemoclaw-connect-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
background: var(--bg, #12141a);
color: var(--text, #e4e4e7);
font-family: system-ui, -apple-system, sans-serif;
transition: opacity 400ms ease;
}

body.nemoclaw-switching .topbar-status .pill .mono {
visibility: hidden;
:root[data-theme="light"] .nemoclaw-connect-overlay {
background: var(--bg-content, #f8f8fa);
color: #1a1a1a;
}

body.nemoclaw-switching .chat-compose textarea[disabled] {
opacity: 1 !important;
color: var(--text, #e4e4e7) !important;
cursor: text;
body[data-nemoclaw-ready] .nemoclaw-connect-overlay {
opacity: 0;
pointer-events: none;
}

body.nemoclaw-switching .chat-compose__actions button[disabled] {
opacity: 0.6;
.nemoclaw-connect-overlay__spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(118, 185, 0, 0.2);
border-top-color: #76B900;
border-radius: 50%;
animation: nemoclaw-spin 0.8s linear infinite;
}

.nemoclaw-connect-overlay__text {
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--muted, #71717a);
}

/* ===========================================
Model Switching — Suppress OpenClaw disconnect artifacts.
The OpenClaw UI renders inside Shadow DOM so we cannot target
individual elements with external CSS. Instead we reduce the
whole app's opacity during the brief config restart.
=========================================== */

body.nemoclaw-switching openclaw-app {
opacity: 0.4 !important;
pointer-events: none !important;
transition: opacity 200ms ease;
}

/* ===========================================
Expand Down