diff --git a/.github/workflows/build-sandboxes.yml b/.github/workflows/build-sandboxes.yml index 6768a04..ffc85b3 100644 --- a/.github/workflows/build-sandboxes.yml +++ b/.github/workflows/build-sandboxes.yml @@ -187,13 +187,39 @@ jobs: tags: localhost:5000/sandboxes/base:latest cache-from: type=gha,scope=base + - name: Determine parent sandbox + id: parent + run: | + set -euo pipefail + DEFAULT_BASE=$(grep '^ARG BASE_IMAGE=' "sandboxes/${{ matrix.sandbox }}/Dockerfile" | head -1 | cut -d= -f2-) + PARENT=$(echo "$DEFAULT_BASE" | sed -n 's|.*/sandboxes/\([^:]*\).*|\1|p') + if [ -z "$PARENT" ]; then + PARENT="base" + fi + echo "sandbox=$PARENT" >> "$GITHUB_OUTPUT" + echo "Parent for ${{ matrix.sandbox }}: $PARENT" + + # When a sandbox depends on another sandbox (not base), build that + # intermediate parent locally so it is available to the buildx build. + - name: Build parent sandbox locally (PR only) + if: github.ref != 'refs/heads/main' && steps.parent.outputs.sandbox != 'base' + uses: docker/build-push-action@v6 + with: + context: sandboxes/${{ steps.parent.outputs.sandbox }} + push: true + tags: localhost:5000/sandboxes/${{ steps.parent.outputs.sandbox }}:latest + build-args: | + BASE_IMAGE=localhost:5000/sandboxes/base:latest + cache-from: type=gha,scope=${{ steps.parent.outputs.sandbox }} + - name: Set BASE_IMAGE id: base run: | + PARENT="${{ steps.parent.outputs.sandbox }}" if [ "${{ github.ref }}" = "refs/heads/main" ]; then - echo "image=${{ env.REGISTRY }}/${{ steps.repo.outputs.image_prefix }}/sandboxes/base:latest" >> "$GITHUB_OUTPUT" + echo "image=${{ env.REGISTRY }}/${{ steps.repo.outputs.image_prefix }}/sandboxes/${PARENT}:latest" >> "$GITHUB_OUTPUT" else - echo "image=localhost:5000/sandboxes/base:latest" >> "$GITHUB_OUTPUT" + echo "image=localhost:5000/sandboxes/${PARENT}:latest" >> "$GITHUB_OUTPUT" fi - name: Generate image metadata diff --git a/brev/.gitignore b/brev/.gitignore new file mode 100644 index 0000000..c26c3f6 --- /dev/null +++ b/brev/.gitignore @@ -0,0 +1 @@ +brev-start-vm.sh \ No newline at end of file diff --git a/brev/welcome-ui/app.js b/brev/welcome-ui/app.js new file mode 100644 index 0000000..31979c8 --- /dev/null +++ b/brev/welcome-ui/app.js @@ -0,0 +1,252 @@ +(() => { + "use strict"; + + const $ = (sel) => document.querySelector(sel); + + // -- DOM refs -------------------------------------------------------- + + const cardOpenclaw = $("#card-openclaw"); + const cardOther = $("#card-other"); + const overlayInstall = $("#overlay-install"); + const overlayInstr = $("#overlay-instructions"); + const closeInstall = $("#close-install"); + const closeInstr = $("#close-instructions"); + + // Path 1 elements + const stepKey = $("#install-step-key"); + const stepProgress = $("#install-step-progress"); + const stepSuccess = $("#install-step-success"); + const stepError = $("#install-step-error"); + const apiKeyInput = $("#api-key-input"); + const toggleKeyVis = $("#toggle-key-vis"); + const btnInstall = $("#btn-install"); + const btnRetry = $("#btn-retry"); + const btnOpenOpenclaw = $("#btn-open-openclaw"); + const errorMessage = $("#error-message"); + + // Progress steps + const pstepSandbox = $("#pstep-sandbox"); + const pstepGateway = $("#pstep-gateway"); + const pstepReady = $("#pstep-ready"); + + // Path 2 elements + const connectCmd = $("#connect-cmd"); + const copyConnect = $("#copy-connect"); + + // -- SVG icons ------------------------------------------------------- + + const iconEye = ``; + const iconEyeOff = ``; + + // -- Modal helpers --------------------------------------------------- + + function showOverlay(el) { + el.hidden = false; + } + function hideOverlay(el) { + el.hidden = true; + } + + function closeOnBackdrop(overlay) { + overlay.addEventListener("click", (e) => { + if (e.target === overlay) hideOverlay(overlay); + }); + } + + // -- Visibility toggle for API key ---------------------------------- + + let keyVisible = false; + toggleKeyVis.addEventListener("click", () => { + keyVisible = !keyVisible; + apiKeyInput.type = keyVisible ? "text" : "password"; + toggleKeyVis.innerHTML = keyVisible ? iconEyeOff : iconEye; + }); + + // -- Copy to clipboard ---------------------------------------------- + + function flashCopied(btn) { + const original = btn.innerHTML; + btn.innerHTML = ``; + btn.classList.add("copy-btn--done"); + setTimeout(() => { + btn.innerHTML = original; + btn.classList.remove("copy-btn--done"); + }, 1500); + } + + document.addEventListener("click", (e) => { + const btn = e.target.closest(".copy-btn"); + if (!btn) return; + const text = btn.dataset.copy || btn.closest(".code-block")?.textContent?.trim(); + if (text) { + navigator.clipboard.writeText(text).then(() => flashCopied(btn)); + } + }); + + // -- Progress step state machine ------------------------------------ + + function setStepState(el, state) { + el.classList.remove("progress-step--active", "progress-step--done", "progress-step--error"); + if (state) el.classList.add(`progress-step--${state}`); + } + + // -- Path 1: Install flow ------------------------------------------- + + function showInstallStep(step) { + stepKey.hidden = step !== "key"; + stepProgress.hidden = step !== "progress"; + stepSuccess.hidden = step !== "success"; + stepError.hidden = step !== "error"; + } + + let pollTimer = null; + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + async function startInstall() { + const apiKey = apiKeyInput.value.trim(); + if (!apiKey) { + apiKeyInput.focus(); + apiKeyInput.classList.add("form-field__input--error"); + setTimeout(() => apiKeyInput.classList.remove("form-field__input--error"), 1500); + return; + } + + showInstallStep("progress"); + setStepState(pstepSandbox, "active"); + setStepState(pstepGateway, null); + setStepState(pstepReady, null); + + try { + const res = await fetch("/api/install-openclaw", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey }), + }); + const data = await res.json(); + + if (!data.ok) { + showError(data.error || "Failed to start sandbox creation"); + return; + } + + setStepState(pstepSandbox, "done"); + setStepState(pstepGateway, "active"); + startPolling(); + } catch (err) { + showError("Could not reach the server. Please try again."); + } + } + + function startPolling() { + stopPolling(); + pollTimer = setInterval(async () => { + try { + const res = await fetch("/api/sandbox-status"); + const data = await res.json(); + + if (data.status === "running") { + stopPolling(); + setStepState(pstepGateway, "done"); + setStepState(pstepReady, "done"); + + btnOpenOpenclaw.href = data.url || "http://127.0.0.1:18789/"; + showInstallStep("success"); + } else if (data.status === "error") { + stopPolling(); + showError(data.error || "Sandbox creation failed"); + } + } catch { + // transient fetch error, keep polling + } + }, 3000); + } + + function showError(msg) { + stopPolling(); + errorMessage.textContent = msg; + showInstallStep("error"); + } + + function resetInstall() { + showInstallStep("key"); + setStepState(pstepSandbox, null); + setStepState(pstepGateway, null); + setStepState(pstepReady, null); + } + + btnInstall.addEventListener("click", startInstall); + apiKeyInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") startInstall(); + }); + btnRetry.addEventListener("click", resetInstall); + + // -- Path 1: Check if sandbox already running on load --------------- + + async function checkExistingSandbox() { + try { + const res = await fetch("/api/sandbox-status"); + const data = await res.json(); + if (data.status === "running" && data.url) { + btnOpenOpenclaw.href = data.url; + showInstallStep("success"); + showOverlay(overlayInstall); + } else if (data.status === "creating") { + showInstallStep("progress"); + setStepState(pstepSandbox, "done"); + setStepState(pstepGateway, "active"); + showOverlay(overlayInstall); + startPolling(); + } + } catch { + // server not ready yet, ignore + } + } + + // -- Path 2: Load connection details -------------------------------- + + async function loadConnectionDetails() { + try { + const res = await fetch("/api/connection-details"); + const data = await res.json(); + const cmd = `nemoclaw cluster connect ${data.hostname}`; + connectCmd.textContent = cmd; + copyConnect.dataset.copy = cmd; + } catch { + connectCmd.textContent = "nemoclaw cluster connect "; + } + } + + // -- Event wiring --------------------------------------------------- + + cardOpenclaw.addEventListener("click", () => { + showOverlay(overlayInstall); + }); + + cardOther.addEventListener("click", () => { + loadConnectionDetails(); + showOverlay(overlayInstr); + }); + + closeInstall.addEventListener("click", () => hideOverlay(overlayInstall)); + closeInstr.addEventListener("click", () => hideOverlay(overlayInstr)); + + closeOnBackdrop(overlayInstall); + closeOnBackdrop(overlayInstr); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + hideOverlay(overlayInstall); + hideOverlay(overlayInstr); + } + }); + + // -- Init ----------------------------------------------------------- + + checkExistingSandbox(); +})(); diff --git a/brev/welcome-ui/index.html b/brev/welcome-ui/index.html new file mode 100644 index 0000000..c891641 --- /dev/null +++ b/brev/welcome-ui/index.html @@ -0,0 +1,251 @@ + + + + + + NemoClaw — Agent Sandbox + + + + + + + + +
+
+ + + NemoClaw + Sandbox +
+
+ + +
+
+
+ +
+

+ Run Any AI Agent in
the NemoClaw Sandbox +

+

+ NemoClaw lets any AI agent run in a secure sandbox with policy guardrails + the agent itself helps manage. One launchable, two paths. +

+
+ +
+ +
+
+ +
+
+

Install OpenClaw

+

Experience it first, learn later. One-click install of the OpenClaw coding agent with NemoClaw safety policies, sandboxing, and model routing. Everything in one browser tab.

+
+ +
+ + +
+
+ +
+
+

Other Agents

+

Bring your own agent. Run Claude Code, OpenCode, DeepAgents, or your own framework. Manage policies via the NemoClaw CLI and TUI from your laptop.

+
+ +
+
+
+ + + + + + + + + + + + + diff --git a/brev/welcome-ui/server.py b/brev/welcome-ui/server.py new file mode 100644 index 0000000..309b411 --- /dev/null +++ b/brev/welcome-ui/server.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""NemoClaw Welcome UI — HTTP server with sandbox lifecycle APIs.""" + +import http.server +import json +import os +import re +import socket +import subprocess +import sys +import threading +import time + +PORT = int(os.environ.get("PORT", 8081)) +ROOT = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.environ.get("REPO_ROOT", os.path.join(ROOT, "..", "..")) +SANDBOX_DIR = os.path.join(REPO_ROOT, "sandboxes", "nemoclaw") + +LOG_FILE = "/tmp/nemoclaw-sandbox-create.log" + +_sandbox_lock = threading.Lock() +_sandbox_state = { + "status": "idle", # idle | creating | running | error + "pid": None, + "url": None, + "error": None, +} + + +def _port_open(host: str, port: int, timeout: float = 1.0) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +def _read_openclaw_token() -> str | None: + """Try to extract the auth token from the sandbox's openclaw config via logs.""" + try: + with open(LOG_FILE) as f: + content = f.read() + match = re.search(r"token=([A-Za-z0-9_\-]+)", content) + if match: + return match.group(1) + except FileNotFoundError: + pass + return None + + +def _cleanup_existing_sandbox(): + """Delete any leftover sandbox named 'nemoclaw' from a previous attempt.""" + try: + subprocess.run( + ["nemoclaw", "sandbox", "delete", "nemoclaw"], + capture_output=True, timeout=30, + ) + except Exception: + pass + + +def _run_sandbox_create(api_key: str, brev_ui_url: str): + """Background thread: runs nemoclaw sandbox create and monitors until ready.""" + global _sandbox_state + + with _sandbox_lock: + _sandbox_state["status"] = "creating" + _sandbox_state["error"] = None + _sandbox_state["url"] = None + + _cleanup_existing_sandbox() + + env = os.environ.copy() + # Use `env` to inject vars into the sandbox command. Avoids the + # nemoclaw -e flag which has a quoting bug that causes SSH to + # misinterpret the export string as a cipher type. + cmd = [ + "nemoclaw", "sandbox", "create", + "--name", "nemoclaw", + "--from", SANDBOX_DIR, + "--forward", "18789", + "--", + "env", + f"NVIDIA_INFERENCE_API_KEY={api_key}", + f"NVIDIA_INTEGRATE_API_KEY={api_key}", + f"BREV_UI_URL={brev_ui_url}", + "nemoclaw-start", + ] + + cmd_display = " ".join(cmd[:8]) + " -- ..." + sys.stderr.write(f"[welcome-ui] Running: {cmd_display}\n") + sys.stderr.flush() + + try: + log_fh = open(LOG_FILE, "w") + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + + def _stream_output(): + for line in proc.stdout: + log_fh.write(line.decode("utf-8", errors="replace")) + log_fh.flush() + sys.stderr.write(f"[sandbox] {line.decode('utf-8', errors='replace')}") + sys.stderr.flush() + log_fh.close() + + streamer = threading.Thread(target=_stream_output, daemon=True) + streamer.start() + + with _sandbox_lock: + _sandbox_state["pid"] = proc.pid + + proc.wait() + streamer.join(timeout=5) + + if proc.returncode != 0: + with _sandbox_lock: + _sandbox_state["status"] = "error" + try: + with open(LOG_FILE) as f: + _sandbox_state["error"] = f.read()[-2000:] + except Exception: + _sandbox_state["error"] = f"Process exited with code {proc.returncode}" + return + + deadline = time.time() + 120 + while time.time() < deadline: + if _port_open("127.0.0.1", 18789): + token = _read_openclaw_token() + url = "http://127.0.0.1:18789/" + if token: + url += f"?token={token}" + with _sandbox_lock: + _sandbox_state["status"] = "running" + _sandbox_state["url"] = url + return + time.sleep(3) + + with _sandbox_lock: + _sandbox_state["status"] = "error" + _sandbox_state["error"] = "Timed out waiting for OpenClaw gateway on port 18789" + + except Exception as exc: + with _sandbox_lock: + _sandbox_state["status"] = "error" + _sandbox_state["error"] = str(exc) + + +def _get_hostname() -> str: + """Best-effort external hostname for connection details.""" + try: + result = subprocess.run( + ["hostname", "-f"], capture_output=True, text=True, timeout=5 + ) + hostname = result.stdout.strip() + if hostname: + return hostname + except Exception: + pass + return socket.getfqdn() + + +class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=ROOT, **kwargs) + + def end_headers(self): + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + super().end_headers() + + # -- Routing -------------------------------------------------------- + + def do_POST(self): + if self.path == "/api/install-openclaw": + return self._handle_install_openclaw() + self.send_error(404) + + def do_GET(self): + if self.path == "/api/sandbox-status": + return self._handle_sandbox_status() + if self.path == "/api/connection-details": + return self._handle_connection_details() + return super().do_GET() + + # -- POST /api/install-openclaw ------------------------------------ + + def _handle_install_openclaw(self): + content_length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(content_length) if content_length else b"{}" + try: + data = json.loads(raw) + except json.JSONDecodeError: + return self._json_response(400, {"ok": False, "error": "Invalid JSON"}) + + api_key = data.get("apiKey", "").strip() + if not api_key: + return self._json_response(400, {"ok": False, "error": "apiKey is required"}) + + with _sandbox_lock: + if _sandbox_state["status"] == "creating": + return self._json_response(409, { + "ok": False, + "error": "Sandbox is already being created", + }) + if _sandbox_state["status"] == "running": + return self._json_response(409, { + "ok": False, + "error": "Sandbox is already running", + }) + + brev_ui_url = f"http://{self.headers.get('Host', 'localhost:8080')}" + + thread = threading.Thread( + target=_run_sandbox_create, + args=(api_key, brev_ui_url), + daemon=True, + ) + thread.start() + + return self._json_response(200, {"ok": True}) + + # -- GET /api/sandbox-status ---------------------------------------- + + def _handle_sandbox_status(self): + with _sandbox_lock: + state = dict(_sandbox_state) + + if state["status"] == "creating" and _port_open("127.0.0.1", 18789): + token = _read_openclaw_token() + url = "http://127.0.0.1:18789/" + if token: + url += f"?token={token}" + with _sandbox_lock: + _sandbox_state["status"] = "running" + _sandbox_state["url"] = url + state["status"] = "running" + state["url"] = url + + return self._json_response(200, { + "status": state["status"], + "url": state.get("url"), + "error": state.get("error"), + }) + + # -- GET /api/connection-details ------------------------------------ + + def _handle_connection_details(self): + hostname = _get_hostname() + return self._json_response(200, { + "hostname": hostname, + "gatewayPort": 8080, + "instructions": { + "install": "pip install nemoclaw", + "connect": f"nemoclaw cluster connect {hostname}", + "createSandbox": "nemoclaw sandbox create -- claude", + "tui": "nemoclaw term", + }, + }) + + # -- Helpers -------------------------------------------------------- + + def _json_response(self, status: int, body: dict): + raw = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def log_message(self, fmt, *args): + sys.stderr.write(f"[welcome-ui] {fmt % args}\n") + + +if __name__ == "__main__": + server = http.server.ThreadingHTTPServer(("", PORT), Handler) + print(f"NemoClaw Welcome UI → http://localhost:{PORT}") + server.serve_forever() diff --git a/brev/welcome-ui/styles.css b/brev/welcome-ui/styles.css new file mode 100644 index 0000000..6c0a9e9 --- /dev/null +++ b/brev/welcome-ui/styles.css @@ -0,0 +1,880 @@ +/* ============================================ + NemoClaw Welcome UI — NVIDIA build.nvidia.com style + Primary green: #76B900 + ============================================ */ + +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --nv-green: #76b900; + --nv-green-hover: #6aa300; + --nv-green-active: #5a8500; + --nv-green-glow: rgba(118, 185, 0, 0.35); + --nv-green-subtle: rgba(118, 185, 0, 0.08); + --nv-green-border: rgba(118, 185, 0, 0.25); + + --bg: #0a0a0a; + --bg-card: #141414; + --bg-card-hover: #1a1a1a; + --bg-elevated: #1e1e1e; + + --border: #262626; + --border-hover: #404040; + + --text: #fafafa; + --text-secondary: #a3a3a3; + --text-muted: #737373; + + --red: #ef4444; + --red-subtle: rgba(239, 68, 68, 0.08); + --red-border: rgba(239, 68, 68, 0.25); + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + + --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* ── Top bar ─────────────────────────────── */ + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 32px; + border-bottom: 1px solid var(--border); + background: rgba(10, 10, 10, 0.8); + backdrop-filter: blur(12px); + position: sticky; + top: 0; + z-index: 100; +} + +.topbar__brand { + display: flex; + align-items: center; + gap: 14px; +} + +.topbar__logo { + height: 26px; + width: auto; +} + +.topbar__divider { + width: 1px; + height: 20px; + background: var(--border); +} + +.topbar__title { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--text); +} + +.topbar__badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 3px 8px; + border-radius: 9999px; + background: var(--nv-green-subtle); + color: var(--nv-green); + border: 1px solid var(--nv-green-border); +} + +/* ── Main content ────────────────────────── */ + +.main { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px 80px; + text-align: center; +} + +/* ── Hero ─────────────────────────────────── */ + +.hero { + max-width: 640px; + margin-bottom: 48px; +} + +.hero__icon { + width: 64px; + height: 64px; + border-radius: var(--radius-lg); + background: var(--nv-green-subtle); + border: 1px solid var(--nv-green-border); + display: grid; + place-items: center; + margin: 0 auto 24px; + color: var(--nv-green); +} + +.hero__icon svg { + width: 32px; + height: 32px; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.hero__heading { + font-size: 36px; + font-weight: 800; + letter-spacing: -0.04em; + line-height: 1.15; + margin-bottom: 12px; +} + +.hero__heading span { + color: var(--nv-green); +} + +.hero__sub { + font-size: 16px; + line-height: 1.6; + color: var(--text-secondary); + max-width: 500px; + margin: 0 auto; +} + +/* ── Cards grid ──────────────────────────── */ + +.cards { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; + max-width: 720px; + width: 100%; +} + +.card { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + padding: 28px 24px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + cursor: pointer; + text-align: left; + transition: + border-color 200ms ease, + background 200ms ease, + box-shadow 200ms ease, + transform 200ms ease; +} + +.card:hover { + border-color: var(--nv-green); + background: var(--bg-card-hover); + box-shadow: + 0 0 0 1px rgba(118, 185, 0, 0.12), + 0 8px 32px rgba(118, 185, 0, 0.08); + transform: translateY(-2px); +} + +.card:active { + transform: translateY(0); +} + +.card__icon { + width: 44px; + height: 44px; + border-radius: var(--radius-md); + background: var(--nv-green-subtle); + display: grid; + place-items: center; + color: var(--nv-green); + flex-shrink: 0; +} + +.card__icon svg { + width: 22px; + height: 22px; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.card__body { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.card__title { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text); +} + +.card__desc { + font-size: 14px; + line-height: 1.55; + color: var(--text-muted); +} + +.card__footer { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: var(--nv-green); + margin-top: auto; + transition: gap 200ms ease; +} + +.card:hover .card__footer { + gap: 10px; +} + +.card__footer svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* ── Overlay / Modal ─────────────────────── */ + +.overlay { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(6px); + animation: fade-in 150ms ease; +} + +.overlay[hidden] { + display: none; +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal { + width: min(560px, 100%); + max-height: 85vh; + overflow-y: auto; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 28px; + animation: scale-in 200ms cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: + 0 24px 64px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(255, 255, 255, 0.04); +} + +.modal--wide { + width: min(620px, 100%); +} + +@keyframes scale-in { + from { opacity: 0; transform: scale(0.96) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} + +.modal__title { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text); +} + +.modal__close { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.modal__close:hover { + background: var(--bg-elevated); + color: var(--text); +} + +.modal__close svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.modal__body { + display: flex; + flex-direction: column; + gap: 16px; +} + +.modal__text { + font-size: 14px; + line-height: 1.6; + color: var(--text-secondary); +} + +.modal__text em { + color: var(--nv-green); + font-style: italic; +} + +/* ── Form fields ─────────────────────────── */ + +.form-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-field__label { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +.form-field__row { + display: flex; + gap: 0; +} + +.form-field__input { + flex: 1; + padding: 10px 14px; + font-family: var(--font-mono); + font-size: 14px; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm) 0 0 var(--radius-sm); + outline: none; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.form-field__input:focus { + border-color: var(--nv-green); + box-shadow: 0 0 0 2px var(--nv-green-glow); +} + +.form-field__input--error { + border-color: var(--red); + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); +} + +.form-field__input::placeholder { + color: var(--text-muted); +} + +.form-field__toggle { + width: 42px; + display: grid; + place-items: center; + border: 1px solid var(--border); + border-left: none; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + background: var(--bg-elevated); + color: var(--text-muted); + cursor: pointer; + transition: color 120ms ease, background 120ms ease; +} + +.form-field__toggle:hover { + color: var(--text); + background: var(--border); +} + +.form-field__toggle svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.form-field__help { + font-size: 12px; + color: var(--nv-green); + text-decoration: none; + transition: color 120ms ease; +} + +.form-field__help:hover { + color: var(--nv-green-hover); + text-decoration: underline; +} + +/* ── Buttons ─────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 20px; + font-family: var(--font); + font-size: 14px; + font-weight: 600; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + text-decoration: none; + transition: background 150ms ease, box-shadow 150ms ease, transform 100ms ease; +} + +.btn svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.btn--primary { + background: var(--nv-green); + color: #000; +} + +.btn--primary:hover { + background: var(--nv-green-hover); + box-shadow: 0 4px 16px var(--nv-green-glow); +} + +.btn--primary:active { + transform: translateY(1px); + background: var(--nv-green-active); +} + +.btn--secondary { + background: var(--bg-elevated); + color: var(--text); + border: 1px solid var(--border); +} + +.btn--secondary:hover { + background: var(--border); +} + +/* ── Progress steps ──────────────────────── */ + +.progress-steps { + display: flex; + flex-direction: column; + gap: 0; +} + +.progress-step { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 16px 0; + position: relative; +} + +.progress-step:not(:last-child)::after { + content: ""; + position: absolute; + left: 13px; + top: 42px; + bottom: 0; + width: 2px; + background: var(--border); +} + +.progress-step--done:not(:last-child)::after { + background: var(--nv-green); +} + +.progress-step__icon { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid var(--border); + background: var(--bg); + flex-shrink: 0; + display: grid; + place-items: center; + position: relative; + z-index: 1; + transition: border-color 200ms ease, background 200ms ease; +} + +.progress-step--active .progress-step__icon { + border-color: var(--nv-green); + background: var(--nv-green-subtle); +} + +.progress-step--active .progress-step__icon::after { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--nv-green); + animation: pulse 1.5s ease-in-out infinite; +} + +.progress-step--done .progress-step__icon { + border-color: var(--nv-green); + background: var(--nv-green); +} + +.progress-step--done .progress-step__icon::after { + content: ""; + width: 12px; + height: 12px; + background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") center/contain no-repeat; +} + +.progress-step--error .progress-step__icon { + border-color: var(--red); + background: var(--red-subtle); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.8); } +} + +.progress-step__content { + padding-top: 2px; +} + +.progress-step__title { + font-size: 14px; + font-weight: 600; + color: var(--text); + margin-bottom: 2px; +} + +.progress-step--active .progress-step__title { + color: var(--nv-green); +} + +.progress-step__desc { + font-size: 13px; + color: var(--text-muted); + line-height: 1.4; +} + +/* ── Success card ────────────────────────── */ + +.success-card { + text-align: center; + padding: 12px 0; +} + +.success-card__icon { + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--nv-green); + display: grid; + place-items: center; + margin: 0 auto 16px; +} + +.success-card__icon svg { + width: 28px; + height: 28px; + stroke: #000; + fill: none; + stroke-width: 2.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.success-card__title { + font-size: 18px; + font-weight: 700; + color: var(--text); + margin-bottom: 8px; +} + +.success-card__desc { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; + max-width: 420px; + margin: 0 auto 20px; +} + +/* ── Error card ──────────────────────────── */ + +.error-card { + text-align: center; + padding: 12px 0; +} + +.error-card__icon { + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--red-subtle); + border: 2px solid var(--red-border); + display: grid; + place-items: center; + margin: 0 auto 16px; +} + +.error-card__icon svg { + width: 28px; + height: 28px; + stroke: var(--red); + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.error-card__title { + font-size: 18px; + font-weight: 700; + color: var(--text); + margin-bottom: 8px; +} + +.error-card__desc { + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; + max-width: 420px; + margin: 0 auto 20px; + word-break: break-word; +} + +/* ── Code block ──────────────────────────── */ + +.code-block { + position: relative; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 16px; + padding-right: 48px; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.7; + color: var(--text-secondary); + overflow-x: auto; + white-space: pre; +} + +.code-block .comment { + color: var(--text-muted); +} + +.code-block .cmd { + color: var(--nv-green); +} + +/* ── Copy button ─────────────────────────── */ + +.copy-btn { + position: absolute; + top: 8px; + right: 8px; + width: 32px; + height: 32px; + display: grid; + place-items: center; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + color: var(--text-muted); + cursor: pointer; + transition: color 120ms ease, background 120ms ease, border-color 120ms ease; +} + +.copy-btn:hover { + color: var(--text); + background: var(--bg-elevated); + border-color: var(--border-hover); +} + +.copy-btn--done { + color: var(--nv-green); + border-color: var(--nv-green-border); +} + +.copy-btn svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* ── Instructions sections ───────────────── */ + +.instructions-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.instructions-section__title { + font-size: 13px; + font-weight: 600; + color: var(--text); + letter-spacing: -0.01em; +} + +/* ── Status banner (legacy compat) ───────── */ + +.status-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-radius: var(--radius-sm); + font-size: 14px; + font-weight: 500; + animation: slide-down 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.status-banner--loading { + border: 1px solid var(--nv-green-border); + background: var(--nv-green-subtle); + color: var(--nv-green); +} + +.status-banner--success { + border: 1px solid rgba(118, 185, 0, 0.3); + background: rgba(118, 185, 0, 0.1); + color: var(--nv-green); +} + +.status-banner--error { + border: 1px solid var(--red-border); + background: var(--red-subtle); + color: var(--red); +} + +.status-banner svg { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.status-banner--loading svg { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes slide-down { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Footer ──────────────────────────────── */ + +.footer { + padding: 16px 32px; + border-top: 1px solid var(--border); + text-align: center; + font-size: 12px; + color: var(--text-muted); +} + +/* ── Responsive ──────────────────────────── */ + +@media (max-width: 640px) { + .cards { + grid-template-columns: 1fr; + } + + .hero__heading { + font-size: 28px; + } + + .topbar { + padding: 14px 16px; + } + + .main { + padding: 32px 16px 64px; + } + + .modal { + padding: 20px; + } + + .modal--wide { + width: 100%; + } +} diff --git a/sandboxes/nemoclaw/.gitignore b/sandboxes/nemoclaw/.gitignore new file mode 100644 index 0000000..4c2fcb0 --- /dev/null +++ b/sandboxes/nemoclaw/.gitignore @@ -0,0 +1,2 @@ +# Synced from brev/nemoclaw-ui-extension/extension/ at build time — do not edit here. +nemoclaw-devx/ diff --git a/sandboxes/nemoclaw/Dockerfile b/sandboxes/nemoclaw/Dockerfile new file mode 100644 index 0000000..17e375e --- /dev/null +++ b/sandboxes/nemoclaw/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.4 + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# NeMoClaw sandbox image +# +# 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 + +ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw-community/sandboxes/openclaw:latest +FROM ${BASE_IMAGE} + +USER root + +# 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 + +# Stage the NeMoClaw DevX extension source +COPY nemoclaw-ui-extension/extension/ /opt/nemoclaw-devx/ + +# Bundle the extension with esbuild and inject into the pre-built OpenClaw UI. +# The openclaw npm package ships a pre-built SPA at dist/control-ui/; there is +# no TypeScript source tree to patch, so we bundle the extension separately and +# add \n|' "$UI_DIR/index.html"; \ + npm uninstall -g esbuild + +ENTRYPOINT ["/bin/bash"] diff --git a/sandboxes/nemoclaw/README.md b/sandboxes/nemoclaw/README.md new file mode 100644 index 0000000..b540b4b --- /dev/null +++ b/sandboxes/nemoclaw/README.md @@ -0,0 +1,94 @@ +# NeMoClaw Sandbox + +NemoClaw sandbox image that layers the **NeMoClaw DevX UI extension** on top of the [OpenClaw](https://github.com/openclaw) sandbox. + +## What's Included + +Everything from the `openclaw` sandbox (OpenClaw CLI, gateway, Node.js 22, developer tools), plus: + +- **NVIDIA Model Selector** — switch between NVIDIA-hosted models (Kimi K2.5, Nemotron 3 Super, DeepSeek V3.2) directly from the OpenClaw UI +- **Deploy Modal** — one-click deploy to DGX Spark / DGX Station from any conversation +- **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 + +## Build + +Build from the sandbox directory: + +```bash +docker build -t nemoclaw sandboxes/nemoclaw/ +``` + +## Usage + +### Create a sandbox + +```bash +nemoclaw sandbox create --from sandboxes/nemoclaw \ + --forward 18789 \ + -- nemoclaw-start +``` + +The `--from ` flag builds the image and imports it into the cluster automatically. + +`nemoclaw-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 +3. Starts the OpenClaw gateway in the background +4. Prints the gateway URL with auth token + +Access the UI at `http://127.0.0.1:18789/`. + +### API Keys + +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` + +| Variable | Description | +|---|---| +| `NVIDIA_INTEGRATE_API_KEY` | Key for `integrate.api.nvidia.com` (Kimi K2.5, Nemotron Ultra, DeepSeek V3.2) | + +Keys are optional at sandbox creation time. If omitted, the UI will prompt users to enter them via the API Keys page. + +### Manual startup + +If you prefer to start OpenClaw manually inside the sandbox: + +```bash +nemoclaw 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. + +## How the Extension Works + +The extension source lives in `nemoclaw-ui-extension/extension/` within this directory. At Docker build time: + +1. The TypeScript + CSS source is staged at `/opt/nemoclaw-devx/` +2. `esbuild` bundles it into `nemoclaw-devx.js` and `nemoclaw-devx.css` +3. The bundles are placed in the OpenClaw SPA assets directory (`dist/control-ui/assets/`) +4. `