From f81e586500aa4e31d35513fae2e65fb4ce522d35 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Wed, 11 Mar 2026 15:03:18 -0700 Subject: [PATCH 01/27] Add rebrand for welcome UI, include launch.sh script --- brev/launch.sh | 51 ++++++++++++++++++++++++++- brev/welcome-ui/server.js | 73 ++++++++++++++++++++++++++++++++++----- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/brev/launch.sh b/brev/launch.sh index dfee5f8..881a1af 100755 --- a/brev/launch.sh +++ b/brev/launch.sh @@ -32,6 +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:-}" +NEMOCLAW_IMAGE="${NEMOCLAW_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/nemoclaw:latest}" mkdir -p "$(dirname "$LAUNCH_LOG")" touch "$LAUNCH_LOG" @@ -252,6 +253,47 @@ docker_login_ghcr_if_needed() { fi } +should_build_nemoclaw_image() { + [[ -n "$COMMUNITY_REF" && "$COMMUNITY_REF" != "main" ]] +} + +build_nemoclaw_image_if_needed() { + local docker_cmd=() + local image_context="$REPO_ROOT/sandboxes/nemoclaw" + local dockerfile_path="$image_context/Dockerfile" + + if ! should_build_nemoclaw_image; then + log "Skipping local NeMoClaw image build (COMMUNITY_REF=${COMMUNITY_REF:-})." + return + fi + + if [[ ! -f "$dockerfile_path" ]]; then + log "NeMoClaw Dockerfile not found: $dockerfile_path" + exit 1 + 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 + + 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 + + log "Local NeMoClaw image ready: $NEMOCLAW_IMAGE" +} + checkout_repo_ref() { if [[ -z "$COMMUNITY_REF" ]]; then return @@ -518,7 +560,12 @@ start_welcome_ui() { 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" node server.js >> "$WELCOME_UI_LOG" 2>&1 & + 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" @@ -544,6 +591,8 @@ main() { ensure_cli_compat_aliases step "Authenticating registries" docker_login_ghcr_if_needed + step "Preparing NeMoClaw image" + build_nemoclaw_image_if_needed step "Ensuring Node.js" ensure_node diff --git a/brev/welcome-ui/server.js b/brev/welcome-ui/server.js index abc63b8..240947b 100644 --- a/brev/welcome-ui/server.js +++ b/brev/welcome-ui/server.js @@ -38,6 +38,7 @@ const SANDBOX_START_CMD = process.env.SANDBOX_START_CMD || "nemoclaw-start"; const SANDBOX_BASE_IMAGE = process.env.SANDBOX_BASE_IMAGE || "ghcr.io/nvidia/openshell-community/sandboxes/openclaw:latest"; +const NEMOCLAW_IMAGE = (process.env.NEMOCLAW_IMAGE || "").trim(); const POLICY_FILE = path.join(SANDBOX_DIR, "policy.yaml"); const LOG_FILE = "/tmp/nemoclaw-sandbox-create.log"; @@ -264,6 +265,13 @@ const injectKeyState = { keyHash: null, }; +// Raw API key stored in memory so it can be passed to the sandbox at +// creation time and forwarded to LiteLLM for inference. Not persisted +// to disk. +let _nvidiaApiKey = process.env.NVIDIA_INFERENCE_API_KEY + || process.env.NVIDIA_INTEGRATE_API_KEY + || ""; + // ── Brev ID detection & URL building ─────────────────────────────────────── function extractBrevId(host) { @@ -286,7 +294,7 @@ function buildOpenclawUrl(token) { } else { url = `http://127.0.0.1:${PORT}/`; } - if (token) url += `?token=${token}`; + if (token) url += `#token=${token}`; return url; } @@ -627,18 +635,26 @@ function runSandboxCreate() { const cmd = [ CLI_BIN, "sandbox", "create", "--name", SANDBOX_NAME, - "--from", SANDBOX_DIR, + "--from", NEMOCLAW_IMAGE || SANDBOX_DIR, "--forward", "18789", ]; if (policyPath) cmd.push("--policy", policyPath); - cmd.push( - "--", - "env", - `CHAT_UI_URL=${chatUiUrl}`, - SANDBOX_START_CMD - ); + const envArgs = [`CHAT_UI_URL=${chatUiUrl}`]; + const nvapiKey = _nvidiaApiKey + || process.env.NVIDIA_INFERENCE_API_KEY + || process.env.NVIDIA_INTEGRATE_API_KEY + || ""; + if (nvapiKey) { + envArgs.push(`NVIDIA_INFERENCE_API_KEY=${nvapiKey}`); + envArgs.push(`NVIDIA_INTEGRATE_API_KEY=${nvapiKey}`); + } + + cmd.push("--", "env", ...envArgs, SANDBOX_START_CMD); const cmdDisplay = cmd.slice(0, 8).join(" ") + " -- ..."; + if (NEMOCLAW_IMAGE) { + logWelcome(`Using NeMoClaw image override: ${NEMOCLAW_IMAGE}`); + } logWelcome(`Running: ${cmdDisplay}`); const logFd = fs.openSync(LOG_FILE, "w"); @@ -788,6 +804,38 @@ function runInjectKey(key, keyHash) { }); } +/** + * Forward the API key to the sandbox's LiteLLM instance via the + * policy-proxy's /api/litellm-key endpoint. This triggers a config + * regeneration and LiteLLM restart with the new key. + */ +function forwardKeyToSandbox(key) { + const body = JSON.stringify({ apiKey: key }); + const opts = { + hostname: "127.0.0.1", + port: SANDBOX_PORT, + path: "/api/litellm-key", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }, + timeout: 10000, + }; + const req = http.request(opts, (res) => { + res.resume(); + if (res.statusCode === 200) { + log("inject-key", "Forwarded API key to sandbox LiteLLM"); + } else { + log("inject-key", `Sandbox LiteLLM key forward returned ${res.statusCode}`); + } + }); + req.on("error", (err) => { + log("inject-key", `Failed to forward key to sandbox: ${err.message}`); + }); + req.end(body); +} + // ── Provider CRUD ────────────────────────────────────────────────────────── function parseProviderDetail(stdout) { @@ -1271,8 +1319,16 @@ async function handleInjectKey(req, res) { injectKeyState.status = "injecting"; injectKeyState.error = null; injectKeyState.keyHash = keyH; + _nvidiaApiKey = key; runInjectKey(key, keyH); + + // If the sandbox is already running, forward the key to LiteLLM inside + // the sandbox so it can authenticate with upstream NVIDIA APIs. + if (sandboxState.status === "running") { + forwardKeyToSandbox(key); + } + return jsonResponse(res, 202, { ok: true, started: true }); } @@ -1561,6 +1617,7 @@ function _resetForTesting() { detectedBrevId = ""; _brevEnvId = ""; renderedIndex = null; + _nvidiaApiKey = ""; } function _setMocksForTesting(mocks) { From 53258bc6076094830eed0099414297f53dc5ff64 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Wed, 11 Mar 2026 16:40:54 -0700 Subject: [PATCH 02/27] Remove BASH_SOURCE dependency --- brev/launch.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/brev/launch.sh b/brev/launch.sh index 881a1af..1fdd5d5 100755 --- a/brev/launch.sh +++ b/brev/launch.sh @@ -38,6 +38,10 @@ mkdir -p "$(dirname "$LAUNCH_LOG")" touch "$LAUNCH_LOG" exec > >(tee -a "$LAUNCH_LOG") 2>&1 +mkdir -p "$(dirname "$LAUNCH_LOG")" +touch "$LAUNCH_LOG" +exec > >(tee -a "$LAUNCH_LOG") 2>&1 + log() { printf '[launch.sh] %s\n' "$*" } From 9842d40d712120aa28ef290d65d48bb8ba30b0f3 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Wed, 11 Mar 2026 17:03:29 -0700 Subject: [PATCH 03/27] Address silent fail on launch.sh --- brev/launch.sh | 4 ---- 1 file changed, 4 deletions(-) diff --git a/brev/launch.sh b/brev/launch.sh index 1fdd5d5..881a1af 100755 --- a/brev/launch.sh +++ b/brev/launch.sh @@ -38,10 +38,6 @@ mkdir -p "$(dirname "$LAUNCH_LOG")" touch "$LAUNCH_LOG" exec > >(tee -a "$LAUNCH_LOG") 2>&1 -mkdir -p "$(dirname "$LAUNCH_LOG")" -touch "$LAUNCH_LOG" -exec > >(tee -a "$LAUNCH_LOG") 2>&1 - log() { printf '[launch.sh] %s\n' "$*" } From eb74ff4c9f5771f8835dce89ec4b4d2b6687df20 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Wed, 11 Mar 2026 17:22:00 -0700 Subject: [PATCH 04/27] Add favicon, handle ghcr.io login if params present, fix logo --- brev/welcome-ui/favicon.ico | Bin 32038 -> 32038 bytes brev/welcome-ui/index.html | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/brev/welcome-ui/favicon.ico b/brev/welcome-ui/favicon.ico index dce06225d9aa9a234d2faa96a7807ab14f790700..76d821c156c32261e5063776228679bc49c1915d 100644 GIT binary patch literal 32038 zcmdU&37A|(wSYSeWC%+-fC&&FlK??fWDyKJ5hj3&M%h6Y7Xm)^9U<&w@`40W2!fz2 z0)h_(1=$3HERzHbAc%q>vQCI7pu$8HMi|rY|NHcvdwZSk1jRQWKUKHtoKvT^Q`J2^ zom_56Zt2{J5joPaxr3L^iPF1P3- zx!n5bAc}=3C&^*9{?Du_xm?N^@&~|KFar*U@sP??TADRw2=(fH5l(1KEo!}HW z5Z((*m8pl_bt#_)ov<9(mbyp7mthKQ0aIZDq$WakPs%=CERW}*w>T>;?l(74x&EY(@I{Qhq7YqhP(#^M8>Z4;z7fJrUjqSsSBxc8-R@ zYM{)r&<5(R1N!Upmteo*b7H~yi#Fd+`Y~|MmAC)e8VSR}F}W6wg|u_L|9~>9gZkn+*bW?1?Vk%@1^bf7m^zOafpg(lY!7)@ z4NihRz`AWfZN%|hjb^_-> znfHQa+SImvv#msaU`Wc!W=&a%XV>1D@HKE8R)s3PzK&e3o0*vd53Q8Toh8TQa_5~y z{MaIwTMEXm8Ej0NBkpupPvx z#ce06SKg*$q}fgx*@C!r-E~J8yNEjZMrBkYswC!kAeM9_G!6p zw5^}lz>V+)aBuz`PJ_h1waDKB?zeIqwVoo@8QWY_VWUzm%7;>aKKvcdfl*+rz7?hx zRTBL;6XsB!uQXNlJZs7b#O01U^|-)D25N?Df=}zj{{Xz znUHn={0i2tQ$L)#888>R;m6?EK37WDBrR9Ny?Kn&K}$QK(Ri~w_lPs_Eoo= z>RD%ea1IR3)jy%XK}n|eUWs${K4f+Q?D^tuogpD(iy&BRxMc&;vB>_qKE<&& z=8uMQoyw=M@p0rHhBYdyDH{XM?LHo5DN_x*ms0NDUI~)9N>bO1WAS-tJP#2!9Ix}rWFzKXNZB{b%5@~x?W3d{ zL$sMVm^{y#awv8BP(7}RY}itNFEGAwqbdEwc{7hP*K|@EY20&NXUh##r0sg=-WNirEw$lR;FwkxSm{>lVSBL{mOzkqkn{~ zaV}|JJJ&$^ zJzbZfe==qIbPhO{KD!TU7^pBA`mQDQJ_Mdg=08v&8IN)IGH$-NSP(JZJJ?ZhIvCU3 z7uUnVHEmQD#_Pyg$R7`$q23QXyDKG~AMY5h&7F(&(f+mMkAyYgK{x{RV{)~D8YM9X ziSO!v1D1wlJ)}ypXD^`c@{Ek~t~uq6=a0j4uuG~~BbT)w>*_UhwDSmD3GRc4W971& zQvMk11h#3cnGLQTxfyzaS0xoc<#v`rW+U*v@DSVx_B&hV+Zr7o&&!C*G}SN zq;@|o)Bd*bUC>|S>jX&WIvK81NBuEyEZht3%`d?yu+F=N{_Eqei8}dgyN;b>=vX?= zXTyfzIHqy~%9%{IK3;Plh>m-G4jcueK|A?Un%ax8a?Bh%{d0{wrm0M(sfO9CPQ1rs z95lwb7f%7_JhAK8><^BWW9Ha7hDm+HrW#geZNysN5xYMG`*$XM8Qcqwkz=(QEP1+dgw) zPk!El7dq!H=qWOHZg=N`d0ia~=H>JAXLQV5ICD&X;mkGj3-hbx`#MGp(BCm6-`9Rt zzGq0+%&zWLN5RW5Z(Zaj!@&C#>+)`&gAMVkX*F>wXHIz@= zHwJ$Mynnt7e}}}+;pE%E`S?26uQ$Q@_yW8K{=NN3+Z#sP+rjtXHSnCf7Pg1wAoVNq zuB-K-3!a95fU)8bFm|T$4F_&9Dzmm@4I3kF0N2Eyz;k13C_m$~O_kdihwSn27`z3K z!f`Mb-r0Vn_Ey67XTjL!8gWfu03U|*+*CgMlj^5_rDewCL*YTNA5XzFaGsp6tfj@v z$9o5^RnJM+?(;AMJ_w$dsb7^Xq;+c}Hx-OYV!vm?B+$>1upi6@*VPN)eo?Q|YV*(0 z{se5i3eSP(>v*W|bJlvut&4m&^g&OF1*Ct2KfuP2*z->19Nz)|1Y=p_*RI+2D)V92 zb?Myh4M{tdlGV>4I~twaVIdp=-W@t%9GnIHuzgWEYdg+*&);7{`Rn%Tmg<+pJ=FFc zH`}c*TUitL!v96505`*yklo)W@SOUe=%@W~eK|*4K~md%8g|}??n_`?tbPq!%UasL zarCdCkDg`5*dxI{>;k`mKf{Nr%!CbNoO|Iks1g~pAZ)m&-Ujb)gQ^r|Vm})H*MR>9 z@Z}Eb^3lW7|(~oieMj} z2G`S4(Ae*+H}QL9`+pszzmLtz)XK*iJ`WxD|3{&gRFs83dn4l>y#_p|lkxL>eG2Y^ z!NcyGqo`w*;LSI&xf55V~7K6xJ`zMFmsdf^;s{TvEg$D`{y z*c}?WQmhOeW3FTGnrPf^tdrZ2Gd>#Yj>_Qvcl{d225-AkB7E5#UE|fap!{K z^!h*HyL)mII0M|fSzI?;LmSk$)2Jr);#ugszqV^=C;ZtE8P7#yZ~ZejWS2+A_32&O z`SOmI#dZ5f@UE~7H1f1|U9|H_bhYc*Qd`c_kUg06ZP>T2Y{+@WehuCP=WB16RKXrR z-wod1c7|x9z6Z5y;`e#KMSnJ|P+O?zzhh*qeF?dmQLe57bt&v~7 z{=THXR(Va>_#C>f$HSq#-lvdpzLjxot7SsY{rXKXzJ98%T%sLzU29ju-(jDms--mE zn|7bw2-RnC%#ktHwd(lRHXnBbW7%Breo$M^((v8Xb?LY*f=lWvG^mMt_f6<8fOV=Q z@%r7p`y;3x|7gcJb1fL3jGykS{lWcyJB+P1+(b!yrfx3!Pe6L6$Cy~|{HEo# zOh&By9dfS4^7AcxUz>7$c7J<5><0GLu@U3Yq*|s2EerdOv*Yp!NcPxfkla^cmes}>qPqu-jz_D}hZ&b^4Q)OwKxeq%l!LndXT>yjsh9LTU zA+qkH4a?e$_x)a`{5)s}_w4Ji3ph>}fpf85nO-XoF%I9xhV5?!j+6J?8=(4JsI(n^ z9E_}SY#&G^;&YI$m-CC|@w(qRaJ`KNW0w6JmnyZGi}pW@4fm1j^Pp1xP}1g%#rd`n z`HLYN$Mn&=&Mhz$T%RX`eLV&41pCwYUf#%y@P8~eo`YY&ZSV%{3a(M-xse6ySAaXf zdGkC7`FQPc56@4)sA741j&~mU7eV=lq4oC!6Tc!|3mcxZ{{+{KYxECr0Zf3UA^MT{ zmhDm0?S_o~z8r>SIKPDJ*Zx^`7j1`|%q%0oH+}ANJRE<~l4ln#w4v z%pTy{bd9=JE`-e?bSw4Ct+n=4-nQ%I0JsnO;8{2WJ^=ck^d;KJKF8Xdj7&GE+XJpq z_o{0)bX)VTdFjM{q^r>0-f#!J0iI9i!KRSJ)9@$We?E-z2f?{}5?qt6&9u+Wd$MGb z<^5O@A9jQvgK_5-_zrl6>E9G^t?Bo#;aG4jCVg8nUzSum^}{n{JGcsrA+N#<@Fx5m z4usX=Kf~|Jey4s7#m9;8BQO>n45Q#3_q$N&<%3GOTzpu`v_yYfcc=N@JfCDD?e1JO z#57mv;(xh9dm%S@Drv`}+=--%I{H^ArMXi(dG72jrv05gL;88{@9b_{WIDAgzo>)1 zrJ6dlBhuj=h2nFrv%tF^eT7JSlXOw8qcAg9G~c&h zF>UJ~H@(oqMD?#Uy+G2vb#7iiH}2lNLql;}7enK?bTc%hJ)88mnfA3au%wHI#5fkZ zhQ!$V0WHe?kQS+La!!OU82Fr)1@FdBc74Se;9cZ<;QlGV6L2JqhIf1|z8hUGwwdiq z+z+>=f0u!0qW$)qdAXGLwbcD^1Z3|I@0$CI-Eeuom!m)1!}r0sX}_I!&oQBFd2uZ$$telfPeiR zWU(h4#k4Q0kpCPccNhEe8e9Xe1MjsVpR|`eM>>}JW8fk1uCKmlt@ob~!#jV6O#Emy zE%$pheDm%(7v2K>ehGZdWxQ*L@_tvgm()jUEc9M+FJ$c*Ka3-(J>~zMKjnUo!Iz`K z`C0_o`EqZk_tj>7EVut2Ygyg(1j#RIwfAu(|etEAR52rwOzdprtvR~7?_h5dd zcC-7aGH<-t4Q_$If#X~|o-{kot;)sk%Z)|$(b%L9@)(>9j!C)SgSC;$t9uZ*_bb_% zvLtS-KzjVQKQag7fMbIJ(ME;zN91*Yn2M@NuXTU7~`xlRk-E&*U#H zk^S&_O>Eo*&eQG}hby zDJ|Pg`|Z5Cr;S;z|E zx5Sobt@keT&1j}FVPhnEj@>M{3+{wF!1biB;~_Z*?6+(FMzFoOOQjZT=OS*NhrIgg z4y!HLLTUJZBDNe;U!Skkf_AcxS3~a_a2+Rovw!0A+u)t&d~p353w$471cZ%d8Vy(0sp> z_DtjbL;Y{<)GywnFy{=s0e2G#L9*t%z5TP-g!R zDXUARTFXVdjL}3^iJuS^O2VA z$KNn}A94L2+me2??>Zj|2famUoBw=!=$=;6y_>YT*HY03&v)+$$+=c< z-Dk_6Mt&l+Jk50#!WaGc2e`+Yf6W?qzHW4kAFkWF%?wneecuQ9!+I| z(*x?914ChnyIwR9G5o5M?Q2K}e_t^CJfx&!{2ToK6gul7o2+^F{1}M7*XvExd*?k2 zya#v>+7;?)v{oMWo%esj6HxtqVU-r+wfT+6Iv*Fq;CF|(*BWbFXOBbk@hp5Cj?VYN zHXTF9veL8GpTo9y!585zXnf9w{CMP^1@F(kmTcT^=-6M^y|Ko-cVnH(+OVOY&XHqq zDJ=);5G*o)g_&M6u&Y|!&^umW5 zD>YdgwvWS}{htT*KjR#)O)oFWUPoH{Z{V{{M~q2ye4R7+eo6Gxez&2wCEN`9`4ViD zh%HeXZFv7Pw!98oL2Z*Ucb-}9t6@Y8*Tgn4u`?V zKI=!Le7uh|w`Bh`(rW4O=M&Vu3Hq{0wNQCStzqa)hF_O-ZP&QF zHKgNHrMwsgai9BYshyvaR=?JYHqS1}cz!h(e`5Y`FX>&-(7Uu>Ya{QT@%6dm<~i%V z$+h5F=vm@AtskozHdIj=?M}j;@!7eJ^C>G9Yh*Tc`g00oYwPFZ>`UGwxv;KY+V@S7 zN!H?PUh;*UJ7D_5fqk)nL1p8>dX4fx*w8=Dc)28DqZ#+V-xI#+UGI3T2M{gHZpB zsBACQ)$X-W@9&dWpza1x&U4aOa}q27%U*?VLgL56|wDSHd_tFDQf6|r~ihc?!!BjecHuLocyu$?#Hudp*DG5-+q z${D9yd(Ww6!!2+y`LJW3tjh&WAid3=i%w8@)G8Bfcq@fh&_K7Wwv7*Q5U|g!gEq*y=&pHRCzIT zVLO>m+t>$Yf$QCN{tDi?kAa6E83*UUyVaz7+y1!2+Km_ zaftfuk$VZ8!|%Z;NZR-~dG9~o^)@S(Cmo4#-jA{?;CN{L?vu7-dmV5z`~s4B?ICZx zwe6T!+b`F1o$L+XKazFw8hPXKB=8=a>LmWBcHB2#0{iF~dX9|;bykK4L4Qt#R6Dg9 zer4-Ysp?#+m-w_EcD+M92DUFx!5PqaUzgkRezGIzzh|X(oV#0LcNhUlo2g$>W*-g$ z*N=J+gL^LzQ^B?2J}JK|*Rq#duFj?Mj`;^+1{lL^Q;hG%q;;X%*J_JVl9ly+fcUrB zt|I*tXtxjUhJ7KszO(vm)H#01Jb2f4O}X#X{|m%9SkI4o<*9#;voY!dFlOnS@%I5Z z0@i}&`(4>yRyXdfAHm*5@FLijcjkF;7_13tTWQ|)_;vU_sFUE_4E}z6)~D2_v1lR~ zn_dBJI;Qu){;(>fzGQ7IZa%fQG`2T^v*9_gZP&B+-xFXRNZXFQYeAhZxDRfJPr~5$ z+j3jRo=I>WBxi+t@+Yt-7+=!17q_pC^iuntXY0f1@B}3D`aAN|;r*aY>QChD!?1z! zdTo{K8Ef_cV~_o{E&KmN_zbKFX*>07zLVwI_Tu}skHv?F!L^`&^Wj|B6kIc@e@VWU zKS^DrBhlLz?uO({HU@ncwuN*Ym47#W%Kcp(pALk%;2p+s@GfvEYz6HQ<51~qQXc6V z$Q=sC7UP+Aj5)?0W6)4Y$FtJbyYZa$$8#;tJLCNup#QJJHQ>Hq9#a3){MwXHfkz-& zN6(S}8W=y)b@XoZv6(j0zSzIvuoGMl$sOW#@;Ae7;J!9S7%Qg1li*m%Z=nm`2TMVW zS2I5RC&(oJM!FpSe+;ey_pkl)yt)It4?GV^zn%NzU>u}tH+42?A>TW7 zIxdj%f4pk>z~FqgE~bZ()&hjjMb!i;Xx`O{13 z4APAm1=A6v?S&b0sOLKyrkB#WbFs_w>|#2El=ixFQ+sC5X6U**diR^n*fZ*5-$cEC zh|P9&6mqBXoeyNYJNOO=#<#bFI|iP358Y{5VE>}65o8`k6#qw Nk6#qwPhSWB`#j$;a+@= zt#*c1D5Z&570?kMAX*F&BM3e^(`lXIVSvF)J8j1&AhaORtp0y%optuP`|jsG=Ugs& z@60djJoaAe`@Z$~zV+BAK@bMDV9Jz0>73yFS`e%af?&=Z_x?-z{A>Db@#5zD`9W~< zUO~{`@7^ywHVAeu4}yidN0%xG_qmcl*{1((-53OpU6t-9`V!bvpH;;0eB6CCeSW;S zUF;D59^+}f|Gv0J>@R$Iq@ma#el3h;+Z87G&ZgnqtIyo`_b8n$d>-@knlepruekeC zQ~AWnrh3jXrh4`&Q{5oWTW!M6o}=$ud0g9e{~3DyJA41PFE`bT-e&4^_cZl6(<0{Y zWhx&z(FDllWOz{Nbb$=yj}CBNztsudtd7pU!|!j(5dQs{E|0hldXH{_bX0lN&&d19 z1G|6gvX<;t2g#34=oUyv*)jS4Z+*~IuK81Eb1!={tsl{62Mmi~8MVrp+c z*feHOjciXn95Bt)-nPh~TeA*-s(Was*#E8KS%F?A_}axLy!`?besMU#U6k9Zzgu_4(yu zAO>yhEHO`1M09Tlub!P01mCL$!C=@#c@1Wv>%J$3bdND^oSCER`vv2b{bS&n_$zJ! zSNBU)r`L-ik<@n5K4aYbq&QrtW=j5T-MCQqJt@-iFY`IF$Ric}+q$v8OXbF8?9Y_| zIlH5Cylnp-7o7a({GIbxy_11DO)HwOnqyJ^;VI=aU+PxOnWhImK0O|Gac|na-S|8|4t_1;UtcjU z4o;1SO12Z~G(GP1WyVt%4-=Czk0~n)eVP75n~vu^@qV0Gz%>7M2Ej3}20`CTK`{B* zAeb~11l4Xqe&kF3l;g`JrBN;xXNuN3#XP;4_cZ>p?wzhY`o+!S=i*h- ziM&Pb{U4R_Bf+{aZEKRij>XzjBtNp#ROUyJI~X=$&Rk{ zqm#rp1hN;gTQ9D8RAv9Ac$3I)OHO{;`%-bMC@vFwQHlEqY5NQQ^V~A12l{rjj~1_o zsLjyt`^0APAENj2=a)fSVw_tq+_*&A{uh1D*tpZW?`4P4&G;I~^#GHke-S7xdjxg7)1meN-n~ zH!jzApBBFm>3P|E%8snJy4}i8T37(RUAs7euoB$bOI8@2p;^z2AdpN{(GjW7nE#>{fzZs-`i0S5sd$ z*Hi|zF7Eq(TDh)2(l7f&n*0~*yZ;uih-CTMlfLT`Q~A`XZtv8$ll>-}`l5YI{g8c4 z{mrukX$A+)FtwHQOy#QMO?cb;lgk<|!^@IsyI?F$lmA2dF56Gtx5KQBSetvM@l_uDJRWX#8-G7hvsq=JrSn?6?rrAGVe#sWza*nAl=^Hk_ zR5$#Q3I6FacjlqAA1h>gbn9Gx*_ZZOJMjopKX7_WhuSGenef*0ZQr)%HcII*hVpaX zLT$FP={QqgexMucH5S$n-`}7E^-w*xc@CuAU&UW@)vU@$9pP2 zZ74ymBA+2UajbkEe#6Vp7(hR=F&*{OZTXRn^MZ^$AAa}nbBl2wmGvGUC%Yp%zR$*i z8iN_f7z=D%N1N=n{NDdwsqtpTJXbDbdHdKtn%DRO`9gdm{ojrW*s9y|6Mri1C3a{2 z(XrgJB@ka->x^+KhvBYUu5T88FQ_qc^LN) z^XdF=^^&zwn<4+?vNj2 zIc^lJYa*YQc4X?lujv}r5t+;Xmq=d=>b-u9<+O!W!tDX%%a4xeaHuHOmwTtj-I9Sl z6rN>qw$GBE8i}rB4}-oiAn+U5g1wPmw<*e?-ftK05z__h;v?Hj)+bI6>S2a>zxa-L zOpy08;w91P{vEbpY+=ufc0MH7le|S7Ep`*mrdhHkedq3DF9{u}ze5E6?J9AHcvSpW zbW$JmE##(tZxS2CO3^2F6_h_Z`P+4Jc~FOw#Uk-1;%sq+80>&+bnm6&G=bc+M1DVQ zmu=$pga!UUE#S@!@oW=+N{4hnsXfIm1A4}iv@c3^?noz(f*_viOp-DPg5Q_a>ZfPM z;(FTmZT67KteslvP}7dyt|=+!wED%MXr0sIgNeTh3rtv`GYj~*#hq7}q_}|CgZN;9 zI7}=P#0CJuE zk9^3Fapx&fR=%;m9vP9<+rX`Hc)g;LE%}wd&W$7VCE61Vxr&KZ)ju&)N(Z}TcF$77OtK&%wgM6rJ0uX8@` z!@BPwf!`^wKYJF)jO^&ZzB_vhqoLgf#bkL#GXF??RN$|(_4nVqls?lh@GE|-E3Q8} ze_OiqJ2U#7m+%Ycn{e~{OnAe36JCGX=(y=jyT2Fcw_WUcvV0q`2Kl_WTdWq@<3OZ; zL)Y#th%4|Z#r5V|Ti>c@&Yx%xo#$Drm%iOTTU~qWB2zzlu7h(mMbJ6W`mzH|?S#ed zOeK53*r0e{AVygy_7Uio+z$NbE}kc@B(@^HD6T&;>9>3AZ~f@}bLB%PnCh7;O>NCV zb}!SOL4Kpo7X#;vi@>$opXGOyYVSDIRJ6Zt&mj2mAYVRoxI!!u%vJLAXI?>UO?>$? zQB414y`y{OV<$PiSIu{4XC-@M=I-uXGJDG~xgh@fJ$05?=dx?Z9qP_y`|%)Oz9H$b zMbMsSi9G%1>l*Auj7^+bOn>@2ZC>?Go!5`lne%;hhR@fr*Q2Q3{yKl(==CQq-!85e z3q+p&%rA)_c8C{5zW$8!8tZI-SDUY?-mUJOLr4DIpu<*)^?Qx;cC4fBIXj0Q=&p8OUo^v>rQl3^p)s$0T?+kbESz0OR^$1%>^v5x+3>rv;9qz%V|C_t@7>(_U9Z2|Z{<_#oGgslo}zJZ>w5Ju+t&)VOW!`D^!2I#Fgtl0 zD2?+mXyIh&d>~SX=0w z{>acP|JR%^Fn8ehY49=lS^G>1XXEG}#ca?U{jq(o;~!!mp6SrpM*J)BC36d6P5VrN z<{SlV&^!IHL9gRqVo72t#TLX@#F*9&nrm1aTkyZjf35u|HS73K0emmf;k2@ zu+Q4k7pQ~uv7op5+kT*Npxg6buWu_=OigS-dyoxi2evQpj9T=&zDaiIz5d7}e_wXD)<6C8K0M#3XQJ&hrt}BefaV&^IqdThnuD}rBrijh zj=lbr&A4dCMy11ISz@^Y`1qex`7xJZ9^IN>^L&Wr9LznKgV;4@@{RQNuY0e5yuNL` zT0qAvJG`#+r;Y_Ok7kaWcF*gf{qu~s)}?s{CuFUn?D|I9_2Yf~^KBm8wKmW19R}{2s}IQO$o?(`9WpZ~c!o zV%CaTGiL2LTmP|W|MVlZDgOTo*3bDp02}k)RA~Oo8a`|Jtm(71pQk^21e*nW2J9W= z=--TSdNuy}^^arTWNOF1MZaO8|HI_3PARkgK|QjU!=4U%J9+xEM|8E=F4#ND*57~M zEC1*HqMi%2epUbfjQvfH;_F}R;jx#;o*sL9dHT=NHS85`5ksPw{#@td+-}D|HZD?3 z~tgaCV?fS>y3q&T!D4_ZCIYKhQonE3#T}c7!t|`F>#h*@o!X!&TMgZi!)sL=buLE&zUh|p#kxj$SP|*e)p(kzD67^ zri$co&+F||f1Nus$l1ZQ;x_TLcu|Z;UE5_sMr7s8<{H5n&ZzBsJ=4-ie?Jb)*8L|6 z;s;`)_Oiy~Yh*-LWJdPr3^ckG{fq3-e*PJoJ}&&(kezz{TH62_{8;ydUei{+?#N!O z-N)_X?;C#po%VFCz^7n?SHx?rKf3=`a3+6H&|j%{bdTyju3ypjksWHvkG{lsa-G1h zUq&iLb?Ee8Uw1u_+ diff --git a/brev/welcome-ui/index.html b/brev/welcome-ui/index.html index 19dcc37..4d95a34 100644 --- a/brev/welcome-ui/index.html +++ b/brev/welcome-ui/index.html @@ -4,7 +4,7 @@ OpenShell — Agent Sandbox - + @@ -16,7 +16,7 @@
- + OpenShell Sandbox From 485a94ea58c4b67dc53f31f9cb4c4b5e0a98fe2c Mon Sep 17 00:00:00 2001 From: nv-kasikritc Date: Thu, 12 Mar 2026 10:12:45 +0000 Subject: [PATCH 05/27] Init LiteLLM implementation --- sandboxes/nemoclaw/Dockerfile | 12 + sandboxes/nemoclaw/nemoclaw-start.sh | 70 +++++- .../nemoclaw-ui-extension/extension/index.ts | 43 +--- .../extension/model-registry.ts | 16 +- .../extension/model-selector.ts | 23 +- sandboxes/nemoclaw/policy-proxy.js | 226 +++++++++++++++++- 6 files changed, 323 insertions(+), 67 deletions(-) diff --git a/sandboxes/nemoclaw/Dockerfile b/sandboxes/nemoclaw/Dockerfile index bb10e19..9a5d96e 100644 --- a/sandboxes/nemoclaw/Dockerfile +++ b/sandboxes/nemoclaw/Dockerfile @@ -16,6 +16,13 @@ FROM ${BASE_IMAGE} USER root +ENV NO_PROXY=127.0.0.1,localhost,::1 +ENV no_proxy=127.0.0.1,localhost,::1 + +RUN apt-get update && \ + apt-get install -y --no-install-recommends jq && \ + rm -rf /var/lib/apt/lists/* + # 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 @@ -27,6 +34,11 @@ COPY policy-proxy.js /usr/local/lib/policy-proxy.js COPY proto/ /usr/local/lib/nemoclaw-proto/ RUN npm install -g @grpc/grpc-js @grpc/proto-loader js-yaml +# Install LiteLLM proxy for streaming-capable local LLM inference routing. +# LiteLLM handles SSE streaming natively, bypassing the sandbox proxy's +# inference interception path which buffers responses and times out. +RUN python3 -m pip install --no-cache-dir --break-system-packages 'litellm[proxy]' + # Fix @hono/node-server authorization bypass (GHSA-wc8c-qw6v-h7f6) RUN npm install -g @hono/node-server@1.19.11 diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index e1756f9..ba22672 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -65,6 +65,70 @@ if [ -f "$BUNDLE" ]; then ) || echo "Note: API key injection into UI bundle skipped (read-only /usr). Keys can be set via the API Keys page." fi +# -------------------------------------------------------------------------- +# LiteLLM streaming inference proxy +# +# LiteLLM runs on localhost:4000 and provides streaming-capable inference +# routing. This bypasses the sandbox proxy's inference.local interception +# path which buffers entire responses and has a 60s hard timeout. +# -------------------------------------------------------------------------- +LITELLM_PORT=4000 +LITELLM_CONFIG="/tmp/litellm_config.yaml" +LITELLM_LOG="/tmp/litellm.log" + +export NVIDIA_NIM_API_KEY="${NVIDIA_INFERENCE_API_KEY:-${NVIDIA_INTEGRATE_API_KEY:-not-set}}" + +_DEFAULT_MODEL="moonshotai/kimi-k2.5" +_DEFAULT_PROVIDER="nvidia-endpoints" + +generate_litellm_config() { + local model_id="${1:-$_DEFAULT_MODEL}" + local provider="${2:-$_DEFAULT_PROVIDER}" + local api_base="" + local litellm_prefix="nvidia_nim" + + case "$provider" in + nvidia-endpoints) + api_base="https://integrate.api.nvidia.com/v1" ;; + nvidia-inference) + api_base="https://inference-api.nvidia.com/v1" ;; + *) + api_base="https://integrate.api.nvidia.com/v1" ;; + esac + + cat > "$LITELLM_CONFIG" <> "$LITELLM_LOG" 2>&1 & +echo "[litellm] Starting on 127.0.0.1:${LITELLM_PORT} (pid $!)" + +# Wait for LiteLLM to accept connections before proceeding. +_litellm_deadline=$(($(date +%s) + 30)) +while ! curl -sf "http://127.0.0.1:${LITELLM_PORT}/health" >/dev/null 2>&1; do + if [ "$(date +%s)" -ge "$_litellm_deadline" ]; then + echo "[litellm] WARNING: LiteLLM did not become ready within 30s. Continuing anyway." + break + fi + sleep 0.5 +done + # -------------------------------------------------------------------------- # Onboard and start the gateway # -------------------------------------------------------------------------- @@ -78,9 +142,9 @@ openclaw onboard \ --skip-skills \ --skip-health \ --auth-choice custom-api-key \ - --custom-base-url "https://inference.local/v1" \ - --custom-model-id "-" \ - --custom-api-key "$_ONBOARD_KEY" \ + --custom-base-url "http://127.0.0.1:${LITELLM_PORT}/v1" \ + --custom-model-id "$_DEFAULT_MODEL" \ + --custom-api-key "sk-nemoclaw-local" \ --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 5ff25a2..2d4a239 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -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, isKeyConfigured } from "./model-registry.ts"; -import { waitForClient, waitForReconnect, patchConfig } from "./gateway-bridge.ts"; +import { waitForReconnect } from "./gateway-bridge.ts"; import { syncKeysToProviders } from "./api-keys-page.ts"; function inject(): boolean { @@ -65,50 +65,11 @@ function revealApp(): void { } } -/** - * Read the live OpenClaw config, find the active model.primary ref, and - * patch streaming: true for it. For proxy-managed models the model.primary - * never changes after onboard, so enabling it once covers every proxy model - * switch. - */ -async function enableStreamingForActiveModel(): Promise { - const client = await waitForClient(); - const snapshot = await client.request>("config.get", {}); - - const agents = snapshot?.agents as Record | undefined; - const defaults = agents?.defaults as Record | undefined; - const model = defaults?.model as Record | undefined; - const primary = model?.primary as string | undefined; - - if (!primary) { - console.warn("[NeMoClaw] Could not determine active model primary from config"); - return; - } - - const models = defaults?.models as Record> | undefined; - if (models?.[primary]?.streaming === true) return; - - await patchConfig({ - agents: { - defaults: { - models: { - [primary]: { streaming: true }, - }, - }, - }, - }); -} - function bootstrap() { showConnectOverlay(); waitForReconnect(30_000) - .then(() => { - revealApp(); - enableStreamingForActiveModel().catch((err) => - console.warn("[NeMoClaw] Failed to enable streaming:", err), - ); - }) + .then(revealApp) .catch(revealApp); const keysIngested = ingestKeysFromUrl(); diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts index 9016971..da97edc 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts @@ -118,8 +118,8 @@ export interface ModelEntry { } // --------------------------------------------------------------------------- -// Curated models — hardcoded presets routed through inference.local. -// The NemoClaw proxy injects credentials based on the providerName. +// Curated models — hardcoded presets routed through the local LiteLLM proxy. +// LiteLLM handles upstream credential injection and SSE streaming natively. // --------------------------------------------------------------------------- export interface CuratedModel { @@ -179,7 +179,7 @@ export function curatedToModelEntry(c: CuratedModel): ModelEntry { keyType: "inference", isDynamic: true, providerConfig: { - baseUrl: "https://inference.local/v1", + baseUrl: "http://127.0.0.1:4000/v1", api: "openai-completions", models: [ { @@ -215,7 +215,7 @@ export const MODEL_REGISTRY: readonly ModelEntry[] = [ modelRef: `${DEFAULT_PROVIDER_KEY}/moonshotai/kimi-k2.5`, keyType: "inference", providerConfig: { - baseUrl: "https://inference.local/v1", + baseUrl: "http://127.0.0.1:4000/v1", api: "openai-completions", models: [ { @@ -267,8 +267,8 @@ export function getModelByCuratedModelId(modelId: string): ModelEntry | undefine /** * Build a ModelEntry for a provider managed through the inference tab. - * These route through inference.local where the proxy injects credentials, - * so no client-side API key is needed. + * These route through the local LiteLLM proxy which handles credentials + * and streaming, so no client-side API key is needed. */ export function buildDynamicEntry( providerName: string, @@ -288,7 +288,7 @@ export function buildDynamicEntry( keyType: "inference", isDynamic: true, providerConfig: { - baseUrl: "https://inference.local/v1", + baseUrl: "http://127.0.0.1:4000/v1", api: "openai-completions", models: [ { @@ -328,7 +328,7 @@ export function buildQuickSelectEntry( keyType: "inference", isDynamic: true, providerConfig: { - baseUrl: "https://inference.local/v1", + baseUrl: "http://127.0.0.1:4000/v1", api: "openai-completions", models: [ { diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts index 3c897ce..7b2fbe6 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts @@ -2,11 +2,11 @@ * NeMoClaw DevX — Model Selector * * Dropdown injected into the chat compose area that lets users pick a - * model. For models routed through inference.local (curated + dynamic), - * switching only updates the NemoClaw cluster-inference route — no - * OpenClaw config.patch is needed because the NemoClaw proxy rewrites - * the model field in every request body. This avoids the gateway - * disconnect that config.patch causes. + * model. For models routed through the local LiteLLM proxy (curated + + * dynamic), switching only updates the NemoClaw cluster-inference route + * — no OpenClaw config.patch is needed because the LiteLLM proxy + * handles model routing and streaming natively. This avoids the + * gateway disconnect that config.patch causes. * * Models are fetched dynamically from the NemoClaw runtime (providers * and active route configured in the Inference tab). @@ -264,14 +264,14 @@ function dismissTransitionBanner(): void { // --------------------------------------------------------------------------- /** - * Returns true if the model routes through inference.local, meaning the - * NemoClaw proxy manages credential injection and model rewriting. + * Returns true if the model routes through the local LiteLLM proxy, + * meaning credential injection and streaming are handled server-side. * For these models we only need to update the cluster-inference route — * no OpenClaw config.patch (and therefore no gateway disconnect). */ function isProxyManaged(entry: ModelEntry): boolean { return entry.isDynamic === true || - entry.providerConfig.baseUrl === "https://inference.local/v1"; + entry.providerConfig.baseUrl === "http://127.0.0.1:4000/v1"; } async function applyModelSelection( @@ -295,10 +295,9 @@ async function applyModelSelection( try { if (isProxyManaged(entry)) { - // Proxy-managed models route through inference.local. We update the - // NemoClaw cluster-inference route (no OpenClaw config.patch, no - // gateway disconnect). The sandbox polls every ~30s for route - // updates, so we show an honest propagation countdown. + // Proxy-managed models route through the local LiteLLM proxy. We + // update the cluster-inference route and LiteLLM is restarted with the + // new model config (no OpenClaw config.patch, no gateway disconnect). const curated = getCuratedByModelId(entry.providerConfig.models[0]?.id || ""); const provName = curated?.providerName || entry.providerKey.replace(/^dynamic-/, ""); const modelId = entry.providerConfig.models[0]?.id || ""; diff --git a/sandboxes/nemoclaw/policy-proxy.js b/sandboxes/nemoclaw/policy-proxy.js index ea479f6..8b92b14 100644 --- a/sandboxes/nemoclaw/policy-proxy.js +++ b/sandboxes/nemoclaw/policy-proxy.js @@ -418,15 +418,218 @@ function syncAndRespond(yamlBody, res, t0) { }); } +// --------------------------------------------------------------------------- +// LiteLLM config manager +// +// When the user switches models via the UI, the extension POSTs to +// /api/cluster-inference. After forwarding to the gateway we regenerate +// the LiteLLM config and restart the proxy so the new model takes effect. +// --------------------------------------------------------------------------- + +const { execFile } = require("child_process"); + +const LITELLM_PORT = 4000; +const LITELLM_CONFIG_PATH = "/tmp/litellm_config.yaml"; +const LITELLM_LOG_PATH = "/tmp/litellm.log"; + +const PROVIDER_MAP = { + "nvidia-endpoints": { + litellmPrefix: "nvidia_nim", + apiBase: "https://integrate.api.nvidia.com/v1", + apiKeyEnv: "NVIDIA_NIM_API_KEY", + }, + "nvidia-inference": { + litellmPrefix: "nvidia_nim", + apiBase: "https://inference-api.nvidia.com/v1", + apiKeyEnv: "NVIDIA_NIM_API_KEY", + }, +}; + +let litellmPid = null; + +function generateLitellmConfig(providerName, modelId) { + const provider = PROVIDER_MAP[providerName] || PROVIDER_MAP["nvidia-endpoints"]; + const fullModel = `${provider.litellmPrefix}/${modelId}`; + + const config = [ + "model_list:", + ' - model_name: "*"', + " litellm_params:", + ` model: "${fullModel}"`, + ` api_key: os.environ/${provider.apiKeyEnv}`, + ` api_base: "${provider.apiBase}"`, + "general_settings:", + " master_key: sk-nemoclaw-local", + "litellm_settings:", + " request_timeout: 600", + " drop_params: true", + " num_retries: 0", + "", + ].join("\n"); + + fs.writeFileSync(LITELLM_CONFIG_PATH, config, "utf8"); + console.log(`[litellm-mgr] Config written: model=${fullModel} api_base=${provider.apiBase}`); +} + +function restartLitellm() { + return new Promise((resolve) => { + if (litellmPid) { + try { + process.kill(litellmPid, "SIGTERM"); + console.log(`[litellm-mgr] Sent SIGTERM to old LiteLLM (pid ${litellmPid})`); + } catch (e) { + // Process may have already exited. + } + litellmPid = null; + } + + // Brief grace period for the old process to release the port. + setTimeout(() => { + const logFd = fs.openSync(LITELLM_LOG_PATH, "a"); + const child = execFile( + "litellm", + ["--config", LITELLM_CONFIG_PATH, "--port", String(LITELLM_PORT), "--host", "127.0.0.1"], + { stdio: ["ignore", logFd, logFd], detached: true } + ); + child.unref(); + litellmPid = child.pid; + console.log(`[litellm-mgr] Started new LiteLLM (pid ${litellmPid})`); + fs.closeSync(logFd); + + // Wait for the health endpoint to become available. + let attempts = 0; + const maxAttempts = 20; + const poll = setInterval(() => { + attempts++; + const healthReq = http.get(`http://127.0.0.1:${LITELLM_PORT}/health`, (healthRes) => { + if (healthRes.statusCode === 200) { + clearInterval(poll); + console.log(`[litellm-mgr] LiteLLM ready after ${attempts * 500}ms`); + resolve(true); + } + healthRes.resume(); + }); + healthReq.on("error", () => {}); + healthReq.setTimeout(400, () => healthReq.destroy()); + if (attempts >= maxAttempts) { + clearInterval(poll); + console.warn("[litellm-mgr] LiteLLM did not become ready within 10s"); + resolve(false); + } + }, 500); + }, 500); + }); +} + +// Discover existing LiteLLM pid at startup so we can manage restarts. +try { + const { execSync } = require("child_process"); + const pidStr = execSync(`pgrep -f "litellm.*--port ${LITELLM_PORT}" 2>/dev/null || true`, { encoding: "utf8" }).trim(); + if (pidStr) { + litellmPid = parseInt(pidStr.split("\n")[0], 10); + console.log(`[litellm-mgr] Discovered existing LiteLLM pid: ${litellmPid}`); + } +} catch (e) {} + +// --------------------------------------------------------------------------- +// /api/cluster-inference intercept +// --------------------------------------------------------------------------- + +function handleClusterInferencePost(clientReq, clientRes) { + const chunks = []; + clientReq.on("data", (chunk) => chunks.push(chunk)); + clientReq.on("end", () => { + const rawBody = Buffer.concat(chunks); + let payload; + try { + payload = JSON.parse(rawBody.toString("utf8")); + } catch (e) { + clientRes.writeHead(400, { "Content-Type": "application/json" }); + clientRes.end(JSON.stringify({ error: "invalid JSON" })); + return; + } + + // Forward the original request to the upstream gateway first. + const opts = { + hostname: UPSTREAM_HOST, + port: UPSTREAM_PORT, + path: clientReq.url, + method: clientReq.method, + headers: { ...clientReq.headers, "content-length": rawBody.length }, + }; + + const upstream = http.request(opts, (upstreamRes) => { + const upChunks = []; + upstreamRes.on("data", (c) => upChunks.push(c)); + upstreamRes.on("end", () => { + const upBody = Buffer.concat(upChunks); + clientRes.writeHead(upstreamRes.statusCode, upstreamRes.headers); + clientRes.end(upBody); + + // On success, regenerate LiteLLM config and restart. + if (upstreamRes.statusCode >= 200 && upstreamRes.statusCode < 300) { + const providerName = payload.providerName || "nvidia-endpoints"; + const modelId = payload.modelId || payload.model || ""; + if (modelId) { + console.log(`[litellm-mgr] Model switch detected: provider=${providerName} model=${modelId}`); + generateLitellmConfig(providerName, modelId); + restartLitellm().then((ready) => { + console.log(`[litellm-mgr] Restart complete, ready=${ready}`); + }); + } + } + }); + }); + + upstream.on("error", (err) => { + console.error("[litellm-mgr] upstream error on cluster-inference forward:", err.message); + if (!clientRes.headersSent) { + clientRes.writeHead(502, { "Content-Type": "application/json" }); + } + clientRes.end(JSON.stringify({ error: "upstream unavailable" })); + }); + + upstream.end(rawBody); + }); +} + +// --------------------------------------------------------------------------- +// /api/litellm-health handler +// --------------------------------------------------------------------------- + +function handleLitellmHealth(req, res) { + const healthReq = http.get(`http://127.0.0.1:${LITELLM_PORT}/health`, (healthRes) => { + const chunks = []; + healthRes.on("data", (c) => chunks.push(c)); + healthRes.on("end", () => { + res.writeHead(healthRes.statusCode, { "Content-Type": "application/json" }); + res.end(Buffer.concat(chunks)); + }); + }); + healthReq.on("error", (err) => { + res.writeHead(503, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "litellm unreachable", detail: err.message, pid: litellmPid })); + }); + healthReq.setTimeout(3000, () => { + healthReq.destroy(); + res.writeHead(504, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "litellm health check timed out", pid: litellmPid })); + }); +} + // --------------------------------------------------------------------------- // HTTP server // --------------------------------------------------------------------------- +function setCorsHeaders(res) { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); +} + const server = http.createServer((req, res) => { if (req.url === "/api/policy") { - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + setCorsHeaders(res); if (req.method === "OPTIONS") { res.writeHead(204); @@ -442,6 +645,23 @@ const server = http.createServer((req, res) => { return; } + if (req.url === "/api/cluster-inference" && req.method === "POST") { + setCorsHeaders(res); + handleClusterInferencePost(req, res); + return; + } + + if (req.url === "/api/litellm-health") { + setCorsHeaders(res); + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + } else { + handleLitellmHealth(req, res); + } + return; + } + proxyRequest(req, res); }); From 754c756b33d8f062fad70acb7edcf1a4e23bcd5e Mon Sep 17 00:00:00 2001 From: nv-kasikritc Date: Thu, 12 Mar 2026 10:57:49 +0000 Subject: [PATCH 06/27] LiteLLM working --- sandboxes/nemoclaw/nemoclaw-start.sh | 51 +++++++++++--- sandboxes/nemoclaw/policy-proxy.js | 100 +++++++++++++++++++++++---- sandboxes/nemoclaw/policy.yaml | 3 + 3 files changed, 133 insertions(+), 21 deletions(-) diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index ba22672..e1c35ce 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -76,7 +76,22 @@ LITELLM_PORT=4000 LITELLM_CONFIG="/tmp/litellm_config.yaml" LITELLM_LOG="/tmp/litellm.log" -export NVIDIA_NIM_API_KEY="${NVIDIA_INFERENCE_API_KEY:-${NVIDIA_INTEGRATE_API_KEY:-not-set}}" +NVIDIA_NIM_API_KEY="${NVIDIA_INFERENCE_API_KEY:-${NVIDIA_INTEGRATE_API_KEY:-}}" +export NVIDIA_NIM_API_KEY + +# Persist the API key to a well-known file so the policy-proxy can read +# it later when regenerating the LiteLLM config (e.g. on model switch or +# late key injection from the welcome UI). +LITELLM_KEY_FILE="/tmp/litellm_api_key" +if [ -n "$NVIDIA_NIM_API_KEY" ]; then + echo -n "$NVIDIA_NIM_API_KEY" > "$LITELLM_KEY_FILE" + chmod 600 "$LITELLM_KEY_FILE" +fi + +# Use the local bundled cost map to avoid a blocked HTTPS fetch to GitHub +# at startup (the sandbox network policy doesn't allow Python to reach +# raw.githubusercontent.com, causing a ~5s timeout on every start). +export LITELLM_LOCAL_MODEL_COST_MAP="True" _DEFAULT_MODEL="moonshotai/kimi-k2.5" _DEFAULT_PROVIDER="nvidia-endpoints" @@ -86,6 +101,12 @@ generate_litellm_config() { local provider="${2:-$_DEFAULT_PROVIDER}" local api_base="" local litellm_prefix="nvidia_nim" + local api_key="${NVIDIA_NIM_API_KEY:-}" + + # Read from persisted key file if env var is empty. + if [ -z "$api_key" ] && [ -f "$LITELLM_KEY_FILE" ]; then + api_key="$(cat "$LITELLM_KEY_FILE")" + fi case "$provider" in nvidia-endpoints) @@ -96,12 +117,23 @@ generate_litellm_config() { api_base="https://integrate.api.nvidia.com/v1" ;; esac + # Write the actual key value into the config. Using os.environ/ references + # is fragile inside the sandbox where env vars may not be propagated to all + # child processes. If no key is available yet, use a placeholder — the + # policy-proxy will regenerate the config when the key arrives. + local key_yaml + if [ -n "$api_key" ]; then + key_yaml=" api_key: \"${api_key}\"" + else + key_yaml=" api_key: \"key-not-yet-configured\"" + fi + cat > "$LITELLM_CONFIG" <> "$LITELLM_LOG" 2>&1 & echo "[litellm] Starting on 127.0.0.1:${LITELLM_PORT} (pid $!)" # Wait for LiteLLM to accept connections before proceeding. -_litellm_deadline=$(($(date +%s) + 30)) -while ! curl -sf "http://127.0.0.1:${LITELLM_PORT}/health" >/dev/null 2>&1; do +# Use /health/liveliness (basic liveness, no model checks) and --noproxy +# to bypass the sandbox HTTP proxy for localhost connections. +_litellm_deadline=$(($(date +%s) + 60)) +while ! curl -sf --noproxy 127.0.0.1 "http://127.0.0.1:${LITELLM_PORT}/health/liveliness" >/dev/null 2>&1; do if [ "$(date +%s)" -ge "$_litellm_deadline" ]; then - echo "[litellm] WARNING: LiteLLM did not become ready within 30s. Continuing anyway." + echo "[litellm] WARNING: LiteLLM did not become ready within 60s. Continuing anyway." break fi - sleep 0.5 + sleep 1 done # -------------------------------------------------------------------------- diff --git a/sandboxes/nemoclaw/policy-proxy.js b/sandboxes/nemoclaw/policy-proxy.js index 8b92b14..308cc8b 100644 --- a/sandboxes/nemoclaw/policy-proxy.js +++ b/sandboxes/nemoclaw/policy-proxy.js @@ -431,32 +431,44 @@ const { execFile } = require("child_process"); const LITELLM_PORT = 4000; const LITELLM_CONFIG_PATH = "/tmp/litellm_config.yaml"; const LITELLM_LOG_PATH = "/tmp/litellm.log"; +const LITELLM_KEY_FILE = "/tmp/litellm_api_key"; const PROVIDER_MAP = { "nvidia-endpoints": { litellmPrefix: "nvidia_nim", apiBase: "https://integrate.api.nvidia.com/v1", - apiKeyEnv: "NVIDIA_NIM_API_KEY", }, "nvidia-inference": { litellmPrefix: "nvidia_nim", apiBase: "https://inference-api.nvidia.com/v1", - apiKeyEnv: "NVIDIA_NIM_API_KEY", }, }; let litellmPid = null; +function readApiKey() { + try { + const key = fs.readFileSync(LITELLM_KEY_FILE, "utf8").trim(); + if (key) return key; + } catch (e) {} + return process.env.NVIDIA_NIM_API_KEY || ""; +} + +function writeApiKey(key) { + fs.writeFileSync(LITELLM_KEY_FILE, key, { mode: 0o600 }); +} + function generateLitellmConfig(providerName, modelId) { const provider = PROVIDER_MAP[providerName] || PROVIDER_MAP["nvidia-endpoints"]; const fullModel = `${provider.litellmPrefix}/${modelId}`; + const apiKey = readApiKey() || "key-not-yet-configured"; const config = [ "model_list:", ' - model_name: "*"', " litellm_params:", ` model: "${fullModel}"`, - ` api_key: os.environ/${provider.apiKeyEnv}`, + ` api_key: "${apiKey}"`, ` api_base: "${provider.apiBase}"`, "general_settings:", " master_key: sk-nemoclaw-local", @@ -468,7 +480,8 @@ function generateLitellmConfig(providerName, modelId) { ].join("\n"); fs.writeFileSync(LITELLM_CONFIG_PATH, config, "utf8"); - console.log(`[litellm-mgr] Config written: model=${fullModel} api_base=${provider.apiBase}`); + const keyStatus = apiKey === "key-not-yet-configured" ? "missing" : "present"; + console.log(`[litellm-mgr] Config written: model=${fullModel} api_base=${provider.apiBase} key=${keyStatus}`); } function restartLitellm() { @@ -486,37 +499,38 @@ function restartLitellm() { // Brief grace period for the old process to release the port. setTimeout(() => { const logFd = fs.openSync(LITELLM_LOG_PATH, "a"); + const env = { ...process.env, LITELLM_LOCAL_MODEL_COST_MAP: "True" }; const child = execFile( "litellm", ["--config", LITELLM_CONFIG_PATH, "--port", String(LITELLM_PORT), "--host", "127.0.0.1"], - { stdio: ["ignore", logFd, logFd], detached: true } + { stdio: ["ignore", logFd, logFd], detached: true, env } ); child.unref(); litellmPid = child.pid; console.log(`[litellm-mgr] Started new LiteLLM (pid ${litellmPid})`); fs.closeSync(logFd); - // Wait for the health endpoint to become available. + // Wait for the liveness endpoint (no model connectivity checks). let attempts = 0; - const maxAttempts = 20; + const maxAttempts = 60; const poll = setInterval(() => { attempts++; - const healthReq = http.get(`http://127.0.0.1:${LITELLM_PORT}/health`, (healthRes) => { + const healthReq = http.get(`http://127.0.0.1:${LITELLM_PORT}/health/liveliness`, (healthRes) => { if (healthRes.statusCode === 200) { clearInterval(poll); - console.log(`[litellm-mgr] LiteLLM ready after ${attempts * 500}ms`); + console.log(`[litellm-mgr] LiteLLM ready after ${attempts}s`); resolve(true); } healthRes.resume(); }); healthReq.on("error", () => {}); - healthReq.setTimeout(400, () => healthReq.destroy()); + healthReq.setTimeout(800, () => healthReq.destroy()); if (attempts >= maxAttempts) { clearInterval(poll); - console.warn("[litellm-mgr] LiteLLM did not become ready within 10s"); + console.warn("[litellm-mgr] LiteLLM did not become ready within 60s"); resolve(false); } - }, 500); + }, 1000); }, 500); }); } @@ -593,12 +607,66 @@ function handleClusterInferencePost(clientReq, clientRes) { }); } +// --------------------------------------------------------------------------- +// /api/litellm-key handler — accepts an API key update from the welcome UI +// --------------------------------------------------------------------------- + +function handleLitellmKey(req, res) { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + let body; + try { + body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch (e) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid JSON" })); + return; + } + + const apiKey = (body.apiKey || "").trim(); + if (!apiKey) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "missing apiKey" })); + return; + } + + console.log(`[litellm-mgr] API key update received (${apiKey.length} chars)`); + writeApiKey(apiKey); + + // Read the current config to extract the model/provider, then regenerate + // with the new key. + let currentModel = "moonshotai/kimi-k2.5"; + let currentProvider = "nvidia-endpoints"; + try { + const cfg = fs.readFileSync(LITELLM_CONFIG_PATH, "utf8"); + const modelMatch = cfg.match(/model:\s*"[^/]+\/(.+?)"/); + if (modelMatch) currentModel = modelMatch[1]; + const baseMatch = cfg.match(/api_base:\s*"(.+?)"/); + if (baseMatch) { + const base = baseMatch[1]; + for (const [name, p] of Object.entries(PROVIDER_MAP)) { + if (p.apiBase === base) { currentProvider = name; break; } + } + } + } catch (e) {} + + generateLitellmConfig(currentProvider, currentModel); + restartLitellm().then((ready) => { + console.log(`[litellm-mgr] Restarted with new key, ready=${ready}`); + }); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); +} + // --------------------------------------------------------------------------- // /api/litellm-health handler // --------------------------------------------------------------------------- function handleLitellmHealth(req, res) { - const healthReq = http.get(`http://127.0.0.1:${LITELLM_PORT}/health`, (healthRes) => { + const healthReq = http.get(`http://127.0.0.1:${LITELLM_PORT}/health/liveliness`, (healthRes) => { const chunks = []; healthRes.on("data", (c) => chunks.push(c)); healthRes.on("end", () => { @@ -651,6 +719,12 @@ const server = http.createServer((req, res) => { return; } + if (req.url === "/api/litellm-key" && req.method === "POST") { + setCorsHeaders(res); + handleLitellmKey(req, res); + return; + } + if (req.url === "/api/litellm-health") { setCorsHeaders(res); if (req.method === "OPTIONS") { diff --git a/sandboxes/nemoclaw/policy.yaml b/sandboxes/nemoclaw/policy.yaml index 3a1422e..749a058 100644 --- a/sandboxes/nemoclaw/policy.yaml +++ b/sandboxes/nemoclaw/policy.yaml @@ -82,10 +82,13 @@ network_policies: name: nvidia endpoints: - { host: integrate.api.nvidia.com, port: 443 } + - { host: inference-api.nvidia.com, port: 443 } binaries: - { path: /usr/bin/curl } - { path: /bin/bash } - { path: /usr/local/bin/opencode } + - { path: /usr/bin/python3 } + - { path: /usr/bin/python3.12 } nvidia_web: name: nvidia_web endpoints: From dc1d7acc61bc1daa308c43c618ffd8982afeb36b Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Thu, 12 Mar 2026 11:37:11 -0700 Subject: [PATCH 07/27] Update welcome UI icon assets --- brev/welcome-ui/OpenShell-Icon.svg | 1 + brev/welcome-ui/openshell-mark.svg | 5 ----- 2 files changed, 1 insertion(+), 5 deletions(-) create mode 100644 brev/welcome-ui/OpenShell-Icon.svg delete mode 100644 brev/welcome-ui/openshell-mark.svg diff --git a/brev/welcome-ui/OpenShell-Icon.svg b/brev/welcome-ui/OpenShell-Icon.svg new file mode 100644 index 0000000..81bcd2c --- /dev/null +++ b/brev/welcome-ui/OpenShell-Icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/brev/welcome-ui/openshell-mark.svg b/brev/welcome-ui/openshell-mark.svg deleted file mode 100644 index 300ba64..0000000 --- a/brev/welcome-ui/openshell-mark.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - From ce8197af187006d9aeba3d31722c9f9bba36eab5 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Thu, 12 Mar 2026 14:05:58 -0700 Subject: [PATCH 08/27] Add on-demand nemoclaw build; improve auto-pair --- sandboxes/nemoclaw/nemoclaw-start.sh | 30 ++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index e1c35ce..541f2ed 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -210,6 +210,7 @@ json.dump(cfg, open(os.environ['HOME'] + '/.openclaw/openclaw.json', 'w'), inden " nohup openclaw gateway > /tmp/gateway.log 2>&1 & +echo "[gateway] openclaw gateway launched (pid $!)" # Copy the default policy to a writable location so that policy-proxy can # update it at runtime. /etc is read-only under Landlock, but /sandbox is @@ -228,17 +229,38 @@ _POLICY_PATH="${_POLICY_DST}" # /api/policy requests to read/write the sandbox policy file. NODE_PATH=$(npm root -g) POLICY_PATH=${_POLICY_PATH} UPSTREAM_PORT=${INTERNAL_GATEWAY_PORT} LISTEN_PORT=${PUBLIC_PORT} \ 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_deadline=$(($(date +%s) + 300)) + _pair_attempts=0 + _pair_approved=0 + _pair_errors=0 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." + _pair_attempts=$((_pair_attempts + 1)) + _approve_output="$(openclaw devices approve --latest --json 2>&1 || true)" + + if printf '%s\n' "$_approve_output" | grep -q '"ok"[[:space:]]*:[[:space:]]*true'; then + _pair_approved=$((_pair_approved + 1)) + echo "[auto-pair] Approved pending device pairing request: ${_approve_output}" + 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 returned non-success output: ${_approve_output}" + fi + + if [ $((_pair_attempts % 20)) -eq 0 ]; then + _list_output="$(openclaw devices list --json 2>&1 || true)" + echo "[auto-pair] heartbeat attempts=${_pair_attempts} approved=${_pair_approved} errors=${_pair_errors} devices=${_list_output}" fi done + echo "[auto-pair] watcher exiting attempts=${_pair_attempts} approved=${_pair_approved} errors=${_pair_errors}" ) >> /tmp/gateway.log 2>&1 & CONFIG_FILE="${HOME}/.openclaw/openclaw.json" @@ -246,8 +268,8 @@ token=$(grep -o '"token"\s*:\s*"[^"]*"' "${CONFIG_FILE}" 2>/dev/null | head -1 | CHAT_UI_BASE="${CHAT_UI_URL%/}" if [ -n "${token}" ]; then - LOCAL_URL="http://127.0.0.1:18789/?token=${token}" - CHAT_URL="${CHAT_UI_BASE}/?token=${token}" + LOCAL_URL="http://127.0.0.1:18789/#token=${token}" + CHAT_URL="${CHAT_UI_BASE}/#token=${token}" else LOCAL_URL="http://127.0.0.1:18789/" CHAT_URL="${CHAT_UI_BASE}/" From 6c319fa6f30880def7290694d04eb29694778439 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Thu, 12 Mar 2026 15:38:44 -0700 Subject: [PATCH 09/27] Logo fixup, improve auto-approve cycle, NO_PROXY for localhost --- brev/welcome-ui/OpenShell-Icon-Logo.svg | 20 +++++++++++ sandboxes/nemoclaw/nemoclaw-start.sh | 36 ++++++++++++++++--- .../nemoclaw-ui-extension/extension/index.ts | 16 +++++++-- 3 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 brev/welcome-ui/OpenShell-Icon-Logo.svg diff --git a/brev/welcome-ui/OpenShell-Icon-Logo.svg b/brev/welcome-ui/OpenShell-Icon-Logo.svg new file mode 100644 index 0000000..91e389d --- /dev/null +++ b/brev/welcome-ui/OpenShell-Icon-Logo.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index 541f2ed..06912a5 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -48,6 +48,12 @@ if [ -z "${CHAT_UI_URL:-}" ]; then exit 1 fi +# Keep local service-to-service traffic off the sandbox forward proxy. +# LiteLLM/OpenClaw must talk to 127.0.0.1 directly, while upstream NVIDIA +# requests should continue using the configured HTTP(S) proxy. +export NO_PROXY="${NO_PROXY:+${NO_PROXY},}127.0.0.1,localhost,::1" +export no_proxy="${no_proxy:+${no_proxy},}127.0.0.1,localhost,::1" + BUNDLE="$(npm root -g)/openclaw/dist/control-ui/assets/nemoclaw-devx.js" if [ -f "$BUNDLE" ]; then @@ -235,6 +241,26 @@ echo "[gateway] policy-proxy launched (pid $!) upstream=${INTERNAL_GATEWAY_PORT} # before the user notices the "pairing required" prompt in the Control UI. ( echo "[auto-pair] watcher starting" + _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" + } + _pair_deadline=$(($(date +%s) + 300)) _pair_attempts=0 _pair_approved=0 @@ -244,20 +270,22 @@ echo "[gateway] policy-proxy launched (pid $!) upstream=${INTERNAL_GATEWAY_PORT} _pair_attempts=$((_pair_attempts + 1)) _approve_output="$(openclaw devices approve --latest --json 2>&1 || true)" - if printf '%s\n' "$_approve_output" | grep -q '"ok"[[:space:]]*:[[:space:]]*true'; then + if printf '%s\n' "$_approve_output" | _json_has_approval; then _pair_approved=$((_pair_approved + 1)) - echo "[auto-pair] Approved pending device pairing request: ${_approve_output}" + _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 returned non-success output: ${_approve_output}" + echo "[auto-pair] approve --latest unexpected output attempts=${_pair_attempts} errors=${_pair_errors}: ${_approve_output}" fi if [ $((_pair_attempts % 20)) -eq 0 ]; then _list_output="$(openclaw devices list --json 2>&1 || true)" - echo "[auto-pair] heartbeat attempts=${_pair_attempts} approved=${_pair_approved} errors=${_pair_errors} devices=${_list_output}" + _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}" diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index 2d4a239..939ccdb 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -18,6 +18,9 @@ import { ingestKeysFromUrl, DEFAULT_MODEL, resolveApiKey, isKeyConfigured } from import { waitForReconnect } from "./gateway-bridge.ts"; import { syncKeysToProviders } from "./api-keys-page.ts"; +const INITIAL_CONNECT_TIMEOUT_MS = 30_000; +const POST_PAIRING_SETTLE_DELAY_MS = 15_000; + function inject(): boolean { const hasButton = injectButton(); const hasNav = injectNavGroup(); @@ -56,6 +59,11 @@ function showConnectOverlay(): void { document.body.prepend(overlay); } +function setConnectOverlayText(text: string): void { + const textNode = document.querySelector(".nemoclaw-connect-overlay__text"); + if (textNode) textNode.textContent = text; +} + function revealApp(): void { document.body.setAttribute("data-nemoclaw-ready", ""); const overlay = document.querySelector(".nemoclaw-connect-overlay"); @@ -68,8 +76,12 @@ function revealApp(): void { function bootstrap() { showConnectOverlay(); - waitForReconnect(30_000) - .then(revealApp) + waitForReconnect(INITIAL_CONNECT_TIMEOUT_MS) + .then(async () => { + setConnectOverlayText("Device pairing approved. Finalizing dashboard..."); + await new Promise((resolve) => setTimeout(resolve, POST_PAIRING_SETTLE_DELAY_MS)); + revealApp(); + }) .catch(revealApp); const keysIngested = ingestKeysFromUrl(); From 536e63c9efe34ae90ea1ede2fa9488e4d1e3ca6a Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Thu, 12 Mar 2026 17:10:40 -0700 Subject: [PATCH 10/27] Bump defualt context window, set NO_PROXY widely --- sandboxes/nemoclaw/nemoclaw-start.sh | 8 ++++++++ sandboxes/openclaw/policy.yaml | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index 06912a5..a6b5518 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -101,6 +101,8 @@ export LITELLM_LOCAL_MODEL_COST_MAP="True" _DEFAULT_MODEL="moonshotai/kimi-k2.5" _DEFAULT_PROVIDER="nvidia-endpoints" +_DEFAULT_CONTEXT_WINDOW=200000 +_DEFAULT_MAX_TOKENS=8192 generate_litellm_config() { local model_id="${1:-$_DEFAULT_MODEL}" @@ -212,6 +214,12 @@ cfg['gateway']['controlUi'] = { 'allowInsecureAuth': True, 'allowedOrigins': origins, } +provider = cfg.get('models', {}).get('providers', {}).get('custom-127-0-0-1-4000') +if isinstance(provider, dict): + for model in provider.get('models', []): + if isinstance(model, dict) and model.get('id') == '${_DEFAULT_MODEL}': + model['contextWindow'] = ${_DEFAULT_CONTEXT_WINDOW} + model['maxTokens'] = ${_DEFAULT_MAX_TOKENS} json.dump(cfg, open(os.environ['HOME'] + '/.openclaw/openclaw.json', 'w'), indent=2) " diff --git a/sandboxes/openclaw/policy.yaml b/sandboxes/openclaw/policy.yaml index a91da84..a12c46b 100644 --- a/sandboxes/openclaw/policy.yaml +++ b/sandboxes/openclaw/policy.yaml @@ -125,3 +125,7 @@ network_policies: binaries: - { path: /usr/local/bin/claude } - { path: /usr/bin/gh } + +inference: + allowed_routes: + - local From ec4895452e19d9e0f3ae80d599e69262f6d1f9e3 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Fri, 13 Mar 2026 08:21:22 -0700 Subject: [PATCH 11/27] Extend timer for device auto approval, minimize wait --- sandboxes/nemoclaw/nemoclaw-start.sh | 21 ++++++++--- .../extension/gateway-bridge.ts | 36 +++++++++++++++++++ .../nemoclaw-ui-extension/extension/index.ts | 12 +++++-- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index a6b5518..522e648 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -249,6 +249,9 @@ echo "[gateway] policy-proxy launched (pid $!) upstream=${INTERNAL_GATEWAY_PORT} # before the user notices the "pairing required" prompt in the Control UI. ( echo "[auto-pair] watcher starting" + _pair_timeout_secs="${AUTO_PAIR_TIMEOUT_SECS:-1800}" + _pair_sleep_secs="0.5" + _pair_heartbeat_every=120 _json_has_approval() { jq -e ' .device @@ -269,12 +272,22 @@ echo "[gateway] policy-proxy launched (pid $!) upstream=${INTERNAL_GATEWAY_PORT} ' 2>/dev/null || echo "unparseable" } - _pair_deadline=$(($(date +%s) + 300)) + 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 [ "$(date +%s)" -lt "$_pair_deadline" ]; do - sleep 0.5 + 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)" @@ -290,7 +303,7 @@ echo "[gateway] policy-proxy launched (pid $!) upstream=${INTERNAL_GATEWAY_PORT} echo "[auto-pair] approve --latest unexpected output attempts=${_pair_attempts} errors=${_pair_errors}: ${_approve_output}" fi - if [ $((_pair_attempts % 20)) -eq 0 ]; then + 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}" diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts index 8da56c0..dcdcce5 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/gateway-bridge.ts @@ -112,3 +112,39 @@ export function waitForReconnect(timeoutMs = 15_000): Promise { }, 500); }); } + +/** + * Wait until the app remains connected for a continuous stability window. + * + * This helps distinguish "socket connected for a moment" from "dashboard is + * actually ready to be revealed after pairing/bootstrap settles". + */ +export function waitForStableConnection( + stableForMs = 3_000, + timeoutMs = 15_000, +): Promise { + return new Promise((resolve, reject) => { + const start = Date.now(); + let connectedSince = isAppConnected() ? Date.now() : 0; + + const interval = setInterval(() => { + const now = Date.now(); + + if (isAppConnected()) { + if (!connectedSince) connectedSince = now; + if (now - connectedSince >= stableForMs) { + clearInterval(interval); + resolve(); + return; + } + } else { + connectedSince = 0; + } + + if (now - start > timeoutMs) { + clearInterval(interval); + reject(new Error("Timed out waiting for stable gateway connection")); + } + }, 500); + }); +} diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index 939ccdb..fc48f58 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -15,11 +15,12 @@ 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 } from "./gateway-bridge.ts"; +import { waitForReconnect, waitForStableConnection } from "./gateway-bridge.ts"; import { syncKeysToProviders } from "./api-keys-page.ts"; const INITIAL_CONNECT_TIMEOUT_MS = 30_000; const POST_PAIRING_SETTLE_DELAY_MS = 15_000; +const STABLE_CONNECTION_WINDOW_MS = 3_000; function inject(): boolean { const hasButton = injectButton(); @@ -79,7 +80,14 @@ function bootstrap() { waitForReconnect(INITIAL_CONNECT_TIMEOUT_MS) .then(async () => { setConnectOverlayText("Device pairing approved. Finalizing dashboard..."); - await new Promise((resolve) => setTimeout(resolve, POST_PAIRING_SETTLE_DELAY_MS)); + try { + await waitForStableConnection( + STABLE_CONNECTION_WINDOW_MS, + POST_PAIRING_SETTLE_DELAY_MS, + ); + } catch { + await new Promise((resolve) => setTimeout(resolve, POST_PAIRING_SETTLE_DELAY_MS)); + } revealApp(); }) .catch(revealApp); From 7d6355f8a46205ea0a4497562f04788f602f6d0a Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Fri, 13 Mar 2026 08:55:32 -0700 Subject: [PATCH 12/27] Reload dashboard once after pairing approval --- .../nemoclaw-ui-extension/extension/index.ts | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index fc48f58..37f0e70 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -21,6 +21,7 @@ import { syncKeysToProviders } from "./api-keys-page.ts"; const INITIAL_CONNECT_TIMEOUT_MS = 30_000; const POST_PAIRING_SETTLE_DELAY_MS = 15_000; const STABLE_CONNECTION_WINDOW_MS = 3_000; +const PAIRING_RELOAD_FLAG = "nemoclaw:pairing-bootstrap-reloaded"; function inject(): boolean { const hasButton = injectButton(); @@ -74,6 +75,30 @@ function revealApp(): void { } } +function shouldForcePairingReload(): boolean { + try { + return sessionStorage.getItem(PAIRING_RELOAD_FLAG) !== "1"; + } catch { + return true; + } +} + +function markPairingReloadComplete(): void { + try { + sessionStorage.setItem(PAIRING_RELOAD_FLAG, "1"); + } catch { + // ignore storage failures + } +} + +function clearPairingReloadFlag(): void { + try { + sessionStorage.removeItem(PAIRING_RELOAD_FLAG); + } catch { + // ignore storage failures + } +} + function bootstrap() { showConnectOverlay(); @@ -88,9 +113,19 @@ function bootstrap() { } catch { await new Promise((resolve) => setTimeout(resolve, POST_PAIRING_SETTLE_DELAY_MS)); } + if (shouldForcePairingReload()) { + markPairingReloadComplete(); + setConnectOverlayText("Device pairing approved. Reloading dashboard..."); + window.location.reload(); + return; + } + clearPairingReloadFlag(); revealApp(); }) - .catch(revealApp); + .catch(() => { + clearPairingReloadFlag(); + revealApp(); + }); const keysIngested = ingestKeysFromUrl(); From e512644017b8f136478cf6c101e94123fdb40222 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Fri, 13 Mar 2026 10:24:29 -0700 Subject: [PATCH 13/27] Revert nemoclaw runtime back to inference.local --- brev/welcome-ui/server.js | 41 +-- sandboxes/nemoclaw/Dockerfile | 8 - sandboxes/nemoclaw/nemoclaw-start.sh | 117 +------ .../extension/model-registry.ts | 16 +- .../extension/model-selector.ts | 23 +- sandboxes/nemoclaw/policy-proxy.js | 300 +----------------- 6 files changed, 31 insertions(+), 474 deletions(-) diff --git a/brev/welcome-ui/server.js b/brev/welcome-ui/server.js index 240947b..0a12223 100644 --- a/brev/welcome-ui/server.js +++ b/brev/welcome-ui/server.js @@ -266,8 +266,7 @@ const injectKeyState = { }; // Raw API key stored in memory so it can be passed to the sandbox at -// creation time and forwarded to LiteLLM for inference. Not persisted -// to disk. +// creation time. Not persisted to disk. let _nvidiaApiKey = process.env.NVIDIA_INFERENCE_API_KEY || process.env.NVIDIA_INTEGRATE_API_KEY || ""; @@ -804,38 +803,6 @@ function runInjectKey(key, keyHash) { }); } -/** - * Forward the API key to the sandbox's LiteLLM instance via the - * policy-proxy's /api/litellm-key endpoint. This triggers a config - * regeneration and LiteLLM restart with the new key. - */ -function forwardKeyToSandbox(key) { - const body = JSON.stringify({ apiKey: key }); - const opts = { - hostname: "127.0.0.1", - port: SANDBOX_PORT, - path: "/api/litellm-key", - method: "POST", - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(body), - }, - timeout: 10000, - }; - const req = http.request(opts, (res) => { - res.resume(); - if (res.statusCode === 200) { - log("inject-key", "Forwarded API key to sandbox LiteLLM"); - } else { - log("inject-key", `Sandbox LiteLLM key forward returned ${res.statusCode}`); - } - }); - req.on("error", (err) => { - log("inject-key", `Failed to forward key to sandbox: ${err.message}`); - }); - req.end(body); -} - // ── Provider CRUD ────────────────────────────────────────────────────────── function parseProviderDetail(stdout) { @@ -1323,12 +1290,6 @@ async function handleInjectKey(req, res) { runInjectKey(key, keyH); - // If the sandbox is already running, forward the key to LiteLLM inside - // the sandbox so it can authenticate with upstream NVIDIA APIs. - if (sandboxState.status === "running") { - forwardKeyToSandbox(key); - } - return jsonResponse(res, 202, { ok: true, started: true }); } diff --git a/sandboxes/nemoclaw/Dockerfile b/sandboxes/nemoclaw/Dockerfile index 9a5d96e..c07b6d6 100644 --- a/sandboxes/nemoclaw/Dockerfile +++ b/sandboxes/nemoclaw/Dockerfile @@ -16,9 +16,6 @@ FROM ${BASE_IMAGE} USER root -ENV NO_PROXY=127.0.0.1,localhost,::1 -ENV no_proxy=127.0.0.1,localhost,::1 - RUN apt-get update && \ apt-get install -y --no-install-recommends jq && \ rm -rf /var/lib/apt/lists/* @@ -34,11 +31,6 @@ COPY policy-proxy.js /usr/local/lib/policy-proxy.js COPY proto/ /usr/local/lib/nemoclaw-proto/ RUN npm install -g @grpc/grpc-js @grpc/proto-loader js-yaml -# Install LiteLLM proxy for streaming-capable local LLM inference routing. -# LiteLLM handles SSE streaming natively, bypassing the sandbox proxy's -# inference interception path which buffers responses and times out. -RUN python3 -m pip install --no-cache-dir --break-system-packages 'litellm[proxy]' - # Fix @hono/node-server authorization bypass (GHSA-wc8c-qw6v-h7f6) RUN npm install -g @hono/node-server@1.19.11 diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index 522e648..e1f1282 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -48,12 +48,6 @@ if [ -z "${CHAT_UI_URL:-}" ]; then exit 1 fi -# Keep local service-to-service traffic off the sandbox forward proxy. -# LiteLLM/OpenClaw must talk to 127.0.0.1 directly, while upstream NVIDIA -# requests should continue using the configured HTTP(S) proxy. -export NO_PROXY="${NO_PROXY:+${NO_PROXY},}127.0.0.1,localhost,::1" -export no_proxy="${no_proxy:+${no_proxy},}127.0.0.1,localhost,::1" - BUNDLE="$(npm root -g)/openclaw/dist/control-ui/assets/nemoclaw-devx.js" if [ -f "$BUNDLE" ]; then @@ -72,109 +66,11 @@ if [ -f "$BUNDLE" ]; then fi # -------------------------------------------------------------------------- -# LiteLLM streaming inference proxy -# -# LiteLLM runs on localhost:4000 and provides streaming-capable inference -# routing. This bypasses the sandbox proxy's inference.local interception -# path which buffers entire responses and has a 60s hard timeout. +# Onboard and start the gateway # -------------------------------------------------------------------------- -LITELLM_PORT=4000 -LITELLM_CONFIG="/tmp/litellm_config.yaml" -LITELLM_LOG="/tmp/litellm.log" - -NVIDIA_NIM_API_KEY="${NVIDIA_INFERENCE_API_KEY:-${NVIDIA_INTEGRATE_API_KEY:-}}" -export NVIDIA_NIM_API_KEY - -# Persist the API key to a well-known file so the policy-proxy can read -# it later when regenerating the LiteLLM config (e.g. on model switch or -# late key injection from the welcome UI). -LITELLM_KEY_FILE="/tmp/litellm_api_key" -if [ -n "$NVIDIA_NIM_API_KEY" ]; then - echo -n "$NVIDIA_NIM_API_KEY" > "$LITELLM_KEY_FILE" - chmod 600 "$LITELLM_KEY_FILE" -fi - -# Use the local bundled cost map to avoid a blocked HTTPS fetch to GitHub -# at startup (the sandbox network policy doesn't allow Python to reach -# raw.githubusercontent.com, causing a ~5s timeout on every start). -export LITELLM_LOCAL_MODEL_COST_MAP="True" - _DEFAULT_MODEL="moonshotai/kimi-k2.5" -_DEFAULT_PROVIDER="nvidia-endpoints" _DEFAULT_CONTEXT_WINDOW=200000 _DEFAULT_MAX_TOKENS=8192 - -generate_litellm_config() { - local model_id="${1:-$_DEFAULT_MODEL}" - local provider="${2:-$_DEFAULT_PROVIDER}" - local api_base="" - local litellm_prefix="nvidia_nim" - local api_key="${NVIDIA_NIM_API_KEY:-}" - - # Read from persisted key file if env var is empty. - if [ -z "$api_key" ] && [ -f "$LITELLM_KEY_FILE" ]; then - api_key="$(cat "$LITELLM_KEY_FILE")" - fi - - case "$provider" in - nvidia-endpoints) - api_base="https://integrate.api.nvidia.com/v1" ;; - nvidia-inference) - api_base="https://inference-api.nvidia.com/v1" ;; - *) - api_base="https://integrate.api.nvidia.com/v1" ;; - esac - - # Write the actual key value into the config. Using os.environ/ references - # is fragile inside the sandbox where env vars may not be propagated to all - # child processes. If no key is available yet, use a placeholder — the - # policy-proxy will regenerate the config when the key arrives. - local key_yaml - if [ -n "$api_key" ]; then - key_yaml=" api_key: \"${api_key}\"" - else - key_yaml=" api_key: \"key-not-yet-configured\"" - fi - - cat > "$LITELLM_CONFIG" <> "$LITELLM_LOG" 2>&1 & -echo "[litellm] Starting on 127.0.0.1:${LITELLM_PORT} (pid $!)" - -# Wait for LiteLLM to accept connections before proceeding. -# Use /health/liveliness (basic liveness, no model checks) and --noproxy -# to bypass the sandbox HTTP proxy for localhost connections. -_litellm_deadline=$(($(date +%s) + 60)) -while ! curl -sf --noproxy 127.0.0.1 "http://127.0.0.1:${LITELLM_PORT}/health/liveliness" >/dev/null 2>&1; do - if [ "$(date +%s)" -ge "$_litellm_deadline" ]; then - echo "[litellm] WARNING: LiteLLM did not become ready within 60s. Continuing anyway." - break - fi - sleep 1 -done - -# -------------------------------------------------------------------------- -# Onboard and start the gateway -# -------------------------------------------------------------------------- export NVIDIA_API_KEY="${NVIDIA_INFERENCE_API_KEY:- }" _ONBOARD_KEY="${NVIDIA_INFERENCE_API_KEY:-not-used}" openclaw onboard \ @@ -185,9 +81,9 @@ openclaw onboard \ --skip-skills \ --skip-health \ --auth-choice custom-api-key \ - --custom-base-url "http://127.0.0.1:${LITELLM_PORT}/v1" \ - --custom-model-id "$_DEFAULT_MODEL" \ - --custom-api-key "sk-nemoclaw-local" \ + --custom-base-url "https://inference.local/v1" \ + --custom-model-id "-" \ + --custom-api-key "$_ONBOARD_KEY" \ --secret-input-mode plaintext \ --custom-compatibility openai \ --gateway-port 18788 \ @@ -214,8 +110,9 @@ cfg['gateway']['controlUi'] = { 'allowInsecureAuth': True, 'allowedOrigins': origins, } -provider = cfg.get('models', {}).get('providers', {}).get('custom-127-0-0-1-4000') -if isinstance(provider, dict): +for provider in cfg.get('models', {}).get('providers', {}).values(): + if not isinstance(provider, dict): + continue for model in provider.get('models', []): if isinstance(model, dict) and model.get('id') == '${_DEFAULT_MODEL}': model['contextWindow'] = ${_DEFAULT_CONTEXT_WINDOW} diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts index da97edc..9016971 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-registry.ts @@ -118,8 +118,8 @@ export interface ModelEntry { } // --------------------------------------------------------------------------- -// Curated models — hardcoded presets routed through the local LiteLLM proxy. -// LiteLLM handles upstream credential injection and SSE streaming natively. +// Curated models — hardcoded presets routed through inference.local. +// The NemoClaw proxy injects credentials based on the providerName. // --------------------------------------------------------------------------- export interface CuratedModel { @@ -179,7 +179,7 @@ export function curatedToModelEntry(c: CuratedModel): ModelEntry { keyType: "inference", isDynamic: true, providerConfig: { - baseUrl: "http://127.0.0.1:4000/v1", + baseUrl: "https://inference.local/v1", api: "openai-completions", models: [ { @@ -215,7 +215,7 @@ export const MODEL_REGISTRY: readonly ModelEntry[] = [ modelRef: `${DEFAULT_PROVIDER_KEY}/moonshotai/kimi-k2.5`, keyType: "inference", providerConfig: { - baseUrl: "http://127.0.0.1:4000/v1", + baseUrl: "https://inference.local/v1", api: "openai-completions", models: [ { @@ -267,8 +267,8 @@ export function getModelByCuratedModelId(modelId: string): ModelEntry | undefine /** * Build a ModelEntry for a provider managed through the inference tab. - * These route through the local LiteLLM proxy which handles credentials - * and streaming, so no client-side API key is needed. + * These route through inference.local where the proxy injects credentials, + * so no client-side API key is needed. */ export function buildDynamicEntry( providerName: string, @@ -288,7 +288,7 @@ export function buildDynamicEntry( keyType: "inference", isDynamic: true, providerConfig: { - baseUrl: "http://127.0.0.1:4000/v1", + baseUrl: "https://inference.local/v1", api: "openai-completions", models: [ { @@ -328,7 +328,7 @@ export function buildQuickSelectEntry( keyType: "inference", isDynamic: true, providerConfig: { - baseUrl: "http://127.0.0.1:4000/v1", + baseUrl: "https://inference.local/v1", api: "openai-completions", models: [ { diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts index 7b2fbe6..3c897ce 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/model-selector.ts @@ -2,11 +2,11 @@ * NeMoClaw DevX — Model Selector * * Dropdown injected into the chat compose area that lets users pick a - * model. For models routed through the local LiteLLM proxy (curated + - * dynamic), switching only updates the NemoClaw cluster-inference route - * — no OpenClaw config.patch is needed because the LiteLLM proxy - * handles model routing and streaming natively. This avoids the - * gateway disconnect that config.patch causes. + * model. For models routed through inference.local (curated + dynamic), + * switching only updates the NemoClaw cluster-inference route — no + * OpenClaw config.patch is needed because the NemoClaw proxy rewrites + * the model field in every request body. This avoids the gateway + * disconnect that config.patch causes. * * Models are fetched dynamically from the NemoClaw runtime (providers * and active route configured in the Inference tab). @@ -264,14 +264,14 @@ function dismissTransitionBanner(): void { // --------------------------------------------------------------------------- /** - * Returns true if the model routes through the local LiteLLM proxy, - * meaning credential injection and streaming are handled server-side. + * Returns true if the model routes through inference.local, meaning the + * NemoClaw proxy manages credential injection and model rewriting. * For these models we only need to update the cluster-inference route — * no OpenClaw config.patch (and therefore no gateway disconnect). */ function isProxyManaged(entry: ModelEntry): boolean { return entry.isDynamic === true || - entry.providerConfig.baseUrl === "http://127.0.0.1:4000/v1"; + entry.providerConfig.baseUrl === "https://inference.local/v1"; } async function applyModelSelection( @@ -295,9 +295,10 @@ async function applyModelSelection( try { if (isProxyManaged(entry)) { - // Proxy-managed models route through the local LiteLLM proxy. We - // update the cluster-inference route and LiteLLM is restarted with the - // new model config (no OpenClaw config.patch, no gateway disconnect). + // Proxy-managed models route through inference.local. We update the + // NemoClaw cluster-inference route (no OpenClaw config.patch, no + // gateway disconnect). The sandbox polls every ~30s for route + // updates, so we show an honest propagation countdown. const curated = getCuratedByModelId(entry.providerConfig.models[0]?.id || ""); const provName = curated?.providerName || entry.providerKey.replace(/^dynamic-/, ""); const modelId = entry.providerConfig.models[0]?.id || ""; diff --git a/sandboxes/nemoclaw/policy-proxy.js b/sandboxes/nemoclaw/policy-proxy.js index 308cc8b..ea479f6 100644 --- a/sandboxes/nemoclaw/policy-proxy.js +++ b/sandboxes/nemoclaw/policy-proxy.js @@ -418,286 +418,15 @@ function syncAndRespond(yamlBody, res, t0) { }); } -// --------------------------------------------------------------------------- -// LiteLLM config manager -// -// When the user switches models via the UI, the extension POSTs to -// /api/cluster-inference. After forwarding to the gateway we regenerate -// the LiteLLM config and restart the proxy so the new model takes effect. -// --------------------------------------------------------------------------- - -const { execFile } = require("child_process"); - -const LITELLM_PORT = 4000; -const LITELLM_CONFIG_PATH = "/tmp/litellm_config.yaml"; -const LITELLM_LOG_PATH = "/tmp/litellm.log"; -const LITELLM_KEY_FILE = "/tmp/litellm_api_key"; - -const PROVIDER_MAP = { - "nvidia-endpoints": { - litellmPrefix: "nvidia_nim", - apiBase: "https://integrate.api.nvidia.com/v1", - }, - "nvidia-inference": { - litellmPrefix: "nvidia_nim", - apiBase: "https://inference-api.nvidia.com/v1", - }, -}; - -let litellmPid = null; - -function readApiKey() { - try { - const key = fs.readFileSync(LITELLM_KEY_FILE, "utf8").trim(); - if (key) return key; - } catch (e) {} - return process.env.NVIDIA_NIM_API_KEY || ""; -} - -function writeApiKey(key) { - fs.writeFileSync(LITELLM_KEY_FILE, key, { mode: 0o600 }); -} - -function generateLitellmConfig(providerName, modelId) { - const provider = PROVIDER_MAP[providerName] || PROVIDER_MAP["nvidia-endpoints"]; - const fullModel = `${provider.litellmPrefix}/${modelId}`; - const apiKey = readApiKey() || "key-not-yet-configured"; - - const config = [ - "model_list:", - ' - model_name: "*"', - " litellm_params:", - ` model: "${fullModel}"`, - ` api_key: "${apiKey}"`, - ` api_base: "${provider.apiBase}"`, - "general_settings:", - " master_key: sk-nemoclaw-local", - "litellm_settings:", - " request_timeout: 600", - " drop_params: true", - " num_retries: 0", - "", - ].join("\n"); - - fs.writeFileSync(LITELLM_CONFIG_PATH, config, "utf8"); - const keyStatus = apiKey === "key-not-yet-configured" ? "missing" : "present"; - console.log(`[litellm-mgr] Config written: model=${fullModel} api_base=${provider.apiBase} key=${keyStatus}`); -} - -function restartLitellm() { - return new Promise((resolve) => { - if (litellmPid) { - try { - process.kill(litellmPid, "SIGTERM"); - console.log(`[litellm-mgr] Sent SIGTERM to old LiteLLM (pid ${litellmPid})`); - } catch (e) { - // Process may have already exited. - } - litellmPid = null; - } - - // Brief grace period for the old process to release the port. - setTimeout(() => { - const logFd = fs.openSync(LITELLM_LOG_PATH, "a"); - const env = { ...process.env, LITELLM_LOCAL_MODEL_COST_MAP: "True" }; - const child = execFile( - "litellm", - ["--config", LITELLM_CONFIG_PATH, "--port", String(LITELLM_PORT), "--host", "127.0.0.1"], - { stdio: ["ignore", logFd, logFd], detached: true, env } - ); - child.unref(); - litellmPid = child.pid; - console.log(`[litellm-mgr] Started new LiteLLM (pid ${litellmPid})`); - fs.closeSync(logFd); - - // Wait for the liveness endpoint (no model connectivity checks). - let attempts = 0; - const maxAttempts = 60; - const poll = setInterval(() => { - attempts++; - const healthReq = http.get(`http://127.0.0.1:${LITELLM_PORT}/health/liveliness`, (healthRes) => { - if (healthRes.statusCode === 200) { - clearInterval(poll); - console.log(`[litellm-mgr] LiteLLM ready after ${attempts}s`); - resolve(true); - } - healthRes.resume(); - }); - healthReq.on("error", () => {}); - healthReq.setTimeout(800, () => healthReq.destroy()); - if (attempts >= maxAttempts) { - clearInterval(poll); - console.warn("[litellm-mgr] LiteLLM did not become ready within 60s"); - resolve(false); - } - }, 1000); - }, 500); - }); -} - -// Discover existing LiteLLM pid at startup so we can manage restarts. -try { - const { execSync } = require("child_process"); - const pidStr = execSync(`pgrep -f "litellm.*--port ${LITELLM_PORT}" 2>/dev/null || true`, { encoding: "utf8" }).trim(); - if (pidStr) { - litellmPid = parseInt(pidStr.split("\n")[0], 10); - console.log(`[litellm-mgr] Discovered existing LiteLLM pid: ${litellmPid}`); - } -} catch (e) {} - -// --------------------------------------------------------------------------- -// /api/cluster-inference intercept -// --------------------------------------------------------------------------- - -function handleClusterInferencePost(clientReq, clientRes) { - const chunks = []; - clientReq.on("data", (chunk) => chunks.push(chunk)); - clientReq.on("end", () => { - const rawBody = Buffer.concat(chunks); - let payload; - try { - payload = JSON.parse(rawBody.toString("utf8")); - } catch (e) { - clientRes.writeHead(400, { "Content-Type": "application/json" }); - clientRes.end(JSON.stringify({ error: "invalid JSON" })); - return; - } - - // Forward the original request to the upstream gateway first. - const opts = { - hostname: UPSTREAM_HOST, - port: UPSTREAM_PORT, - path: clientReq.url, - method: clientReq.method, - headers: { ...clientReq.headers, "content-length": rawBody.length }, - }; - - const upstream = http.request(opts, (upstreamRes) => { - const upChunks = []; - upstreamRes.on("data", (c) => upChunks.push(c)); - upstreamRes.on("end", () => { - const upBody = Buffer.concat(upChunks); - clientRes.writeHead(upstreamRes.statusCode, upstreamRes.headers); - clientRes.end(upBody); - - // On success, regenerate LiteLLM config and restart. - if (upstreamRes.statusCode >= 200 && upstreamRes.statusCode < 300) { - const providerName = payload.providerName || "nvidia-endpoints"; - const modelId = payload.modelId || payload.model || ""; - if (modelId) { - console.log(`[litellm-mgr] Model switch detected: provider=${providerName} model=${modelId}`); - generateLitellmConfig(providerName, modelId); - restartLitellm().then((ready) => { - console.log(`[litellm-mgr] Restart complete, ready=${ready}`); - }); - } - } - }); - }); - - upstream.on("error", (err) => { - console.error("[litellm-mgr] upstream error on cluster-inference forward:", err.message); - if (!clientRes.headersSent) { - clientRes.writeHead(502, { "Content-Type": "application/json" }); - } - clientRes.end(JSON.stringify({ error: "upstream unavailable" })); - }); - - upstream.end(rawBody); - }); -} - -// --------------------------------------------------------------------------- -// /api/litellm-key handler — accepts an API key update from the welcome UI -// --------------------------------------------------------------------------- - -function handleLitellmKey(req, res) { - const chunks = []; - req.on("data", (c) => chunks.push(c)); - req.on("end", () => { - let body; - try { - body = JSON.parse(Buffer.concat(chunks).toString("utf8")); - } catch (e) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "invalid JSON" })); - return; - } - - const apiKey = (body.apiKey || "").trim(); - if (!apiKey) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "missing apiKey" })); - return; - } - - console.log(`[litellm-mgr] API key update received (${apiKey.length} chars)`); - writeApiKey(apiKey); - - // Read the current config to extract the model/provider, then regenerate - // with the new key. - let currentModel = "moonshotai/kimi-k2.5"; - let currentProvider = "nvidia-endpoints"; - try { - const cfg = fs.readFileSync(LITELLM_CONFIG_PATH, "utf8"); - const modelMatch = cfg.match(/model:\s*"[^/]+\/(.+?)"/); - if (modelMatch) currentModel = modelMatch[1]; - const baseMatch = cfg.match(/api_base:\s*"(.+?)"/); - if (baseMatch) { - const base = baseMatch[1]; - for (const [name, p] of Object.entries(PROVIDER_MAP)) { - if (p.apiBase === base) { currentProvider = name; break; } - } - } - } catch (e) {} - - generateLitellmConfig(currentProvider, currentModel); - restartLitellm().then((ready) => { - console.log(`[litellm-mgr] Restarted with new key, ready=${ready}`); - }); - - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true })); - }); -} - -// --------------------------------------------------------------------------- -// /api/litellm-health handler -// --------------------------------------------------------------------------- - -function handleLitellmHealth(req, res) { - const healthReq = http.get(`http://127.0.0.1:${LITELLM_PORT}/health/liveliness`, (healthRes) => { - const chunks = []; - healthRes.on("data", (c) => chunks.push(c)); - healthRes.on("end", () => { - res.writeHead(healthRes.statusCode, { "Content-Type": "application/json" }); - res.end(Buffer.concat(chunks)); - }); - }); - healthReq.on("error", (err) => { - res.writeHead(503, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "litellm unreachable", detail: err.message, pid: litellmPid })); - }); - healthReq.setTimeout(3000, () => { - healthReq.destroy(); - res.writeHead(504, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "litellm health check timed out", pid: litellmPid })); - }); -} - // --------------------------------------------------------------------------- // HTTP server // --------------------------------------------------------------------------- -function setCorsHeaders(res) { - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); -} - const server = http.createServer((req, res) => { if (req.url === "/api/policy") { - setCorsHeaders(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); @@ -713,29 +442,6 @@ const server = http.createServer((req, res) => { return; } - if (req.url === "/api/cluster-inference" && req.method === "POST") { - setCorsHeaders(res); - handleClusterInferencePost(req, res); - return; - } - - if (req.url === "/api/litellm-key" && req.method === "POST") { - setCorsHeaders(res); - handleLitellmKey(req, res); - return; - } - - if (req.url === "/api/litellm-health") { - setCorsHeaders(res); - if (req.method === "OPTIONS") { - res.writeHead(204); - res.end(); - } else { - handleLitellmHealth(req, res); - } - return; - } - proxyRequest(req, res); }); From 10d871a91035d858afeecd5e69961a28e96d40a8 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Fri, 13 Mar 2026 10:26:25 -0700 Subject: [PATCH 14/27] Keep pairing watcher alive until approval --- sandboxes/nemoclaw/nemoclaw-start.sh | 2 +- .../nemoclaw-ui-extension/extension/index.ts | 47 +++++++++++-------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index e1f1282..bc82fa9 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -146,7 +146,7 @@ echo "[gateway] policy-proxy launched (pid $!) upstream=${INTERNAL_GATEWAY_PORT} # before the user notices the "pairing required" prompt in the Control UI. ( echo "[auto-pair] watcher starting" - _pair_timeout_secs="${AUTO_PAIR_TIMEOUT_SECS:-1800}" + _pair_timeout_secs="${AUTO_PAIR_TIMEOUT_SECS:-0}" _pair_sleep_secs="0.5" _pair_heartbeat_every=120 _json_has_approval() { diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index 37f0e70..249538b 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -19,6 +19,7 @@ import { waitForReconnect, 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 = 3_000; const PAIRING_RELOAD_FLAG = "nemoclaw:pairing-bootstrap-reloaded"; @@ -102,29 +103,37 @@ function clearPairingReloadFlag(): void { function bootstrap() { showConnectOverlay(); + const finalizeConnectedState = async () => { + setConnectOverlayText("Device pairing approved. Finalizing dashboard..."); + try { + await waitForStableConnection( + STABLE_CONNECTION_WINDOW_MS, + POST_PAIRING_SETTLE_DELAY_MS, + ); + } catch { + await new Promise((resolve) => setTimeout(resolve, POST_PAIRING_SETTLE_DELAY_MS)); + } + if (shouldForcePairingReload()) { + markPairingReloadComplete(); + setConnectOverlayText("Device pairing approved. Reloading dashboard..."); + window.location.reload(); + return; + } + clearPairingReloadFlag(); + revealApp(); + }; + waitForReconnect(INITIAL_CONNECT_TIMEOUT_MS) - .then(async () => { - setConnectOverlayText("Device pairing approved. Finalizing dashboard..."); + .then(finalizeConnectedState) + .catch(async () => { + setConnectOverlayText("Still waiting for device pairing approval..."); try { - await waitForStableConnection( - STABLE_CONNECTION_WINDOW_MS, - POST_PAIRING_SETTLE_DELAY_MS, - ); + await waitForReconnect(EXTENDED_CONNECT_TIMEOUT_MS); + await finalizeConnectedState(); } catch { - await new Promise((resolve) => setTimeout(resolve, POST_PAIRING_SETTLE_DELAY_MS)); - } - if (shouldForcePairingReload()) { - markPairingReloadComplete(); - setConnectOverlayText("Device pairing approved. Reloading dashboard..."); - window.location.reload(); - return; + clearPairingReloadFlag(); + revealApp(); } - clearPairingReloadFlag(); - revealApp(); - }) - .catch(() => { - clearPairingReloadFlag(); - revealApp(); }); const keysIngested = ingestKeysFromUrl(); From 9483694f53f259e1fa49f3a6b81a0b8e0e0d406e Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Fri, 13 Mar 2026 12:21:59 -0700 Subject: [PATCH 15/27] Add proxy request tracing for sandbox launch --- brev/welcome-ui/server.js | 7 +++++++ sandboxes/nemoclaw/policy-proxy.js | 14 +++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/brev/welcome-ui/server.js b/brev/welcome-ui/server.js index 0a12223..b0d58f9 100644 --- a/brev/welcome-ui/server.js +++ b/brev/welcome-ui/server.js @@ -1092,6 +1092,9 @@ 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}` + ); const headers = {}; for (const [key, val] of Object.entries(clientReq.headers)) { if (key.toLowerCase() === "host") continue; @@ -1109,6 +1112,9 @@ function proxyToSandbox(clientReq, clientRes) { }; const upstream = http.request(opts, (upstreamRes) => { + 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)) { @@ -1147,6 +1153,7 @@ 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}`); 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 ea479f6..9030097 100644 --- a/sandboxes/nemoclaw/policy-proxy.js +++ b/sandboxes/nemoclaw/policy-proxy.js @@ -37,6 +37,11 @@ const WELL_KNOWN_ENDPOINT = "https://navigator.navigator.svc.cluster.local:8080" let gatewayEndpoint = ""; let sandboxName = ""; +function formatRequestLine(req) { + const host = req.headers.host || "unknown-host"; + return `${req.method || "GET"} ${req.url || "/"} host=${host}`; +} + // --------------------------------------------------------------------------- // Discovery helpers // --------------------------------------------------------------------------- @@ -312,6 +317,7 @@ function pushPolicyToGateway(yamlBody) { // --------------------------------------------------------------------------- function proxyRequest(clientReq, clientRes) { + console.log(`[policy-proxy] http in ${formatRequestLine(clientReq)} -> ${UPSTREAM_HOST}:${UPSTREAM_PORT}`); const opts = { hostname: UPSTREAM_HOST, port: UPSTREAM_PORT, @@ -321,6 +327,10 @@ 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}` + ); clientRes.writeHead(upstreamRes.statusCode, upstreamRes.headers); upstreamRes.pipe(clientRes, { end: true }); }); @@ -341,6 +351,7 @@ function proxyRequest(clientReq, clientRes) { // --------------------------------------------------------------------------- function handlePolicyGet(req, res) { + console.log(`[policy-proxy] policy get ${formatRequestLine(req)}`); fs.readFile(POLICY_PATH, "utf8", (err, data) => { if (err) { res.writeHead(err.code === "ENOENT" ? 404 : 500, { @@ -356,7 +367,7 @@ function handlePolicyGet(req, res) { function handlePolicyPost(req, res) { const t0 = Date.now(); - console.log(`[policy-proxy] ── POST /api/policy received`); + console.log(`[policy-proxy] policy post ${formatRequestLine(req)}`); const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { @@ -447,6 +458,7 @@ const server = http.createServer((req, res) => { // WebSocket upgrade — pipe raw TCP to upstream server.on("upgrade", (req, socket, head) => { + 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 b2f361c8bf2a7ad0092c239958ccdc0ee535af87 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Fri, 13 Mar 2026 12:24:46 -0700 Subject: [PATCH 16/27] Add override to skip nemoclaw image build --- brev/launch.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/brev/launch.sh b/brev/launch.sh index 881a1af..b429498 100755 --- a/brev/launch.sh +++ b/brev/launch.sh @@ -33,6 +33,7 @@ CLI_RETRY_DELAY_SECS="${CLI_RETRY_DELAY_SECS:-3}" GHCR_LOGIN="${GHCR_LOGIN:-auto}" GHCR_USER="${GHCR_USER:-}" NEMOCLAW_IMAGE="${NEMOCLAW_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/nemoclaw:latest}" +SKIP_NEMOCLAW_IMAGE_BUILD="${SKIP_NEMOCLAW_IMAGE_BUILD:-}" mkdir -p "$(dirname "$LAUNCH_LOG")" touch "$LAUNCH_LOG" @@ -254,6 +255,9 @@ docker_login_ghcr_if_needed() { } 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" ]] } @@ -263,7 +267,11 @@ build_nemoclaw_image_if_needed() { local dockerfile_path="$image_context/Dockerfile" if ! should_build_nemoclaw_image; then - log "Skipping local NeMoClaw image build (COMMUNITY_REF=${COMMUNITY_REF:-})." + 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 From 6784eae86beba7da30a3b7eb3710ebcc8b0f8a2c Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Fri, 13 Mar 2026 14:16:59 -0700 Subject: [PATCH 17/27] Add revised policy and NO_PROXY --- brev/welcome-ui/server.js | 9 +++++++++ sandboxes/nemoclaw/policy.yaml | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/brev/welcome-ui/server.js b/brev/welcome-ui/server.js index b0d58f9..4631874 100644 --- a/brev/welcome-ui/server.js +++ b/brev/welcome-ui/server.js @@ -639,6 +639,15 @@ function runSandboxCreate() { ]; if (policyPath) cmd.push("--policy", policyPath); const envArgs = [`CHAT_UI_URL=${chatUiUrl}`]; + const loopbackNoProxy = "127.0.0.1,localhost,::1"; + const mergedNoProxy = [ + process.env.NO_PROXY || process.env.no_proxy || "", + loopbackNoProxy, + ] + .filter(Boolean) + .join(","); + envArgs.push(`NO_PROXY=${mergedNoProxy}`); + envArgs.push(`no_proxy=${mergedNoProxy}`); const nvapiKey = _nvidiaApiKey || process.env.NVIDIA_INFERENCE_API_KEY || process.env.NVIDIA_INTEGRATE_API_KEY diff --git a/sandboxes/nemoclaw/policy.yaml b/sandboxes/nemoclaw/policy.yaml index 749a058..ae34f93 100644 --- a/sandboxes/nemoclaw/policy.yaml +++ b/sandboxes/nemoclaw/policy.yaml @@ -36,6 +36,20 @@ process: # SHA256 integrity is enforced in Rust via trust-on-first-use, not here. network_policies: + allow_navigator_navigator_svc_cluster_local_8080: + name: allow_navigator_navigator_svc_cluster_local_8080 + endpoints: + - host: navigator.navigator.svc.cluster.local + port: 8080 + binaries: + - path: /usr/bin/node + allow_registry_npmjs_org_443: + name: allow_registry_npmjs_org_443 + endpoints: + - host: registry.npmjs.org + port: 443 + binaries: + - path: /usr/bin/node claude_code: name: claude_code endpoints: From 29720c9fb4a007329a71920d074dea634314c27e Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Fri, 13 Mar 2026 18:38:48 -0700 Subject: [PATCH 18/27] Fix unconditional chown --- sandboxes/nemoclaw/Dockerfile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sandboxes/nemoclaw/Dockerfile b/sandboxes/nemoclaw/Dockerfile index c07b6d6..d04d19a 100644 --- a/sandboxes/nemoclaw/Dockerfile +++ b/sandboxes/nemoclaw/Dockerfile @@ -16,6 +16,10 @@ FROM ${BASE_IMAGE} USER root +RUN apt-get update && \ + apt-get install -y --no-install-recommends jq && \ + rm -rf /var/lib/apt/lists/* + RUN apt-get update && \ apt-get install -y --no-install-recommends jq && \ rm -rf /var/lib/apt/lists/* @@ -34,6 +38,10 @@ RUN npm install -g @grpc/grpc-js @grpc/proto-loader js-yaml # Fix @hono/node-server authorization bypass (GHSA-wc8c-qw6v-h7f6) RUN npm install -g @hono/node-server@1.19.11 +# Allow the sandbox user to read the default policy (the startup script +# copies it to a writable location; this chown covers non-Landlock envs). +# Some base image variants do not pre-create /etc/navigator. +RUN mkdir -p /etc/navigator && chown -R sandbox:sandbox /etc/navigator # Stage the NeMoClaw DevX extension source COPY nemoclaw-ui-extension/extension/ /opt/nemoclaw-devx/ From 61e84fa3a0ebf4adfbfab7e112165fcecb919751 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Fri, 13 Mar 2026 19:16:16 -0700 Subject: [PATCH 19/27] Added guarded reload for pairing; ensure custom policy.yaml bake-in --- README.md | 19 ++- sandboxes/nemoclaw/Dockerfile | 8 +- sandboxes/nemoclaw/nemoclaw-start.sh | 1 + .../nemoclaw-ui-extension/extension/index.ts | 14 +- sandboxes/nemoclaw/policy-proxy.js | 142 ++++++++++++++++++ 5 files changed, 178 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 53762ab..489021e 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,24 @@ This repo is the community ecosystem around OpenShell -- a hub for contributed s ### Quick Start with Brev -TODO: Add Brev instructions +#### Deploy Instantly with NVIDIA Brev + +Skip the setup and launch OpenShell Community on a fully configured Brev instance. + +| Instance | Best For | Deploy | +| -------- | -------- | ------ | +| CPU-only | External inference endpoints, remote APIs, lighter-weight sandbox workflows | Deploy on Brev | +| NVIDIA H100 | Locally hosted LLM endpoints, GPU-heavy sandboxes, higher-throughput agent workloads | Deploy on Brev | + +After the Brev instance is ready, bootstrap the Welcome UI: + +```bash +git clone https://github.com/NVIDIA/OpenShell-Community.git +cd OpenShell-Community +bash brev/launch.sh +``` + +The launcher brings up the Welcome UI on `http://localhost:8081`, where you can inject provider keys and create the NeMoClaw sandbox flow. ### Using Sandboxes diff --git a/sandboxes/nemoclaw/Dockerfile b/sandboxes/nemoclaw/Dockerfile index d04d19a..686c3c3 100644 --- a/sandboxes/nemoclaw/Dockerfile +++ b/sandboxes/nemoclaw/Dockerfile @@ -20,9 +20,11 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends jq && \ rm -rf /var/lib/apt/lists/* -RUN apt-get update && \ - apt-get install -y --no-install-recommends jq && \ - rm -rf /var/lib/apt/lists/* +# Bake the NeMoClaw default policy into the same location used by the +# OpenClaw base image so direct image launches and create-time --policy +# launches start from the same policy. +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 diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index bc82fa9..6e65f66 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -134,6 +134,7 @@ if [ ! -f "$_POLICY_DST" ] && [ -f "$_POLICY_SRC" ]; then fi _POLICY_PATH="${_POLICY_DST}" [ -f "$_POLICY_PATH" ] || _POLICY_PATH="$_POLICY_SRC" +echo "[gateway] policy path selected: ${_POLICY_PATH} (src=${_POLICY_SRC} dst=${_POLICY_DST})" # Start the policy reverse proxy on the public-facing port. It forwards all # traffic to the OpenClaw gateway on the internal port and intercepts diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index 249538b..89b1d96 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -21,7 +21,8 @@ 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 = 3_000; +const STABLE_CONNECTION_WINDOW_MS = 10_000; +const STABLE_CONNECTION_TIMEOUT_MS = 45_000; const PAIRING_RELOAD_FLAG = "nemoclaw:pairing-bootstrap-reloaded"; function inject(): boolean { @@ -108,7 +109,7 @@ function bootstrap() { try { await waitForStableConnection( STABLE_CONNECTION_WINDOW_MS, - POST_PAIRING_SETTLE_DELAY_MS, + STABLE_CONNECTION_TIMEOUT_MS, ); } catch { await new Promise((resolve) => setTimeout(resolve, POST_PAIRING_SETTLE_DELAY_MS)); @@ -119,6 +120,15 @@ function bootstrap() { window.location.reload(); return; } + setConnectOverlayText("Device pairing approved. Verifying dashboard health..."); + try { + await waitForStableConnection( + STABLE_CONNECTION_WINDOW_MS, + STABLE_CONNECTION_TIMEOUT_MS, + ); + } catch { + await new Promise((resolve) => setTimeout(resolve, POST_PAIRING_SETTLE_DELAY_MS)); + } clearPairingReloadFlag(); revealApp(); }; diff --git a/sandboxes/nemoclaw/policy-proxy.js b/sandboxes/nemoclaw/policy-proxy.js index 9030097..e699e53 100644 --- a/sandboxes/nemoclaw/policy-proxy.js +++ b/sandboxes/nemoclaw/policy-proxy.js @@ -14,6 +14,7 @@ const http = require("http"); const fs = require("fs"); const os = require("os"); const net = require("net"); +const crypto = require("crypto"); const POLICY_PATH = process.env.POLICY_PATH || "/etc/openshell/policy.yaml"; const UPSTREAM_PORT = parseInt(process.env.UPSTREAM_PORT || "18788", 10); @@ -312,6 +313,145 @@ function pushPolicyToGateway(yamlBody) { }); } +function sha256Hex(text) { + return crypto.createHash("sha256").update(text, "utf8").digest("hex"); +} + +function hasCriticalNavigatorRule(parsed) { + const rule = parsed + && parsed.network_policies + && parsed.network_policies.allow_navigator_navigator_svc_cluster_local_8080; + if (!rule || !Array.isArray(rule.endpoints) || !Array.isArray(rule.binaries)) { + return false; + } + const hasEndpoint = rule.endpoints.some( + (ep) => ep && ep.host === "navigator.navigator.svc.cluster.local" && Number(ep.port) === 8080 + ); + const hasBinary = rule.binaries.some((bin) => bin && bin.path === "/usr/bin/node"); + return hasEndpoint && hasBinary; +} + +function policyStatusName(status) { + switch (status) { + case 1: return "PENDING"; + case 2: return "LOADED"; + case 3: return "FAILED"; + case 4: return "SUPERSEDED"; + default: return "UNSPECIFIED"; + } +} + +function auditStartupPolicyFile() { + let yaml; + try { + yaml = require("js-yaml"); + } catch (e) { + console.warn(`[policy-proxy] startup audit skipped: js-yaml unavailable (${e.message})`); + return; + } + + let raw; + try { + raw = fs.readFileSync(POLICY_PATH, "utf8"); + } catch (e) { + console.error(`[policy-proxy] startup audit failed: could not read ${POLICY_PATH}: ${e.message}`); + return; + } + + let parsed; + try { + parsed = yaml.load(raw); + } catch (e) { + console.error(`[policy-proxy] startup audit failed: YAML parse error in ${POLICY_PATH}: ${e.message}`); + return; + } + + const criticalRulePresent = hasCriticalNavigatorRule(parsed); + console.log( + `[policy-proxy] startup policy audit path=${POLICY_PATH} ` + + `sha256=${sha256Hex(raw)} version=${parsed && parsed.version ? parsed.version : 0} ` + + `critical_rule.allow_navigator_navigator_svc_cluster_local_8080=${criticalRulePresent}` + ); +} + +function listSandboxPolicies(request) { + return new Promise((resolve, reject) => { + grpcClient.ListSandboxPolicies(request, (err, response) => { + if (err) { + reject(err); + return; + } + resolve(response); + }); + }); +} + +function getSandboxPolicyStatus(request) { + return new Promise((resolve, reject) => { + grpcClient.GetSandboxPolicyStatus(request, (err, response) => { + if (err) { + reject(err); + return; + } + resolve(response); + }); + }); +} + +async function auditNavigatorPolicyState() { + if (!grpcEnabled || !grpcClient || grpcPermanentlyDisabled) { + console.log( + `[policy-proxy] startup navigator audit skipped: ` + + `grpcEnabled=${grpcEnabled} grpcClient=${!!grpcClient} disabled=${grpcPermanentlyDisabled}` + ); + return; + } + + try { + const listed = await listSandboxPolicies({ name: sandboxName, limit: 1, offset: 0 }); + const revision = listed && Array.isArray(listed.revisions) ? listed.revisions[0] : null; + if (!revision) { + console.log(`[policy-proxy] startup navigator audit: no policy revisions found for sandbox=${sandboxName}`); + return; + } + + const statusResp = await getSandboxPolicyStatus({ name: sandboxName, version: revision.version || 0 }); + console.log( + `[policy-proxy] startup navigator audit sandbox=${sandboxName} ` + + `latest_version=${revision.version || 0} latest_hash=${revision.policy_hash || ""} ` + + `latest_status=${policyStatusName(revision.status)} active_version=${statusResp.active_version || 0}` + ); + } catch (e) { + console.warn(`[policy-proxy] startup navigator audit failed: ${e.message}`); + } +} + +function scheduleStartupAudit(attempt = 1) { + const maxAttempts = 5; + const delayMs = 1500; + + setTimeout(async () => { + if (grpcEnabled && grpcClient && !grpcPermanentlyDisabled) { + await auditNavigatorPolicyState(); + return; + } + + if (attempt >= maxAttempts) { + console.log( + `[policy-proxy] startup navigator audit gave up after ${attempt} attempts ` + + `(grpcEnabled=${grpcEnabled} grpcClient=${!!grpcClient} disabled=${grpcPermanentlyDisabled})` + ); + return; + } + + console.log( + `[policy-proxy] startup navigator audit retry ${attempt}/${maxAttempts} ` + + `(grpcEnabled=${grpcEnabled} grpcClient=${!!grpcClient} disabled=${grpcPermanentlyDisabled})` + ); + scheduleStartupAudit(attempt + 1); + }, delayMs); +} + // --------------------------------------------------------------------------- // HTTP proxy helpers // --------------------------------------------------------------------------- @@ -484,7 +624,9 @@ server.on("upgrade", (req, socket, head) => { // Initialize gRPC client before starting the HTTP server. initGrpcClient(); +auditStartupPolicyFile(); server.listen(LISTEN_PORT, "127.0.0.1", () => { console.log(`[policy-proxy] Listening on 127.0.0.1:${LISTEN_PORT}, upstream 127.0.0.1:${UPSTREAM_PORT}`); + scheduleStartupAudit(); }); From 93436b09962b8148337756c3c1e26441aead1c38 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Fri, 13 Mar 2026 19:51:15 -0700 Subject: [PATCH 20/27] Add console logging for device pairing; extend NO_PROXY --- brev/.gitignore | 3 +- brev/reset.sh.log | 81 +++++++++++++++++++ brev/welcome-ui/server.js | 11 ++- .../nemoclaw-ui-extension/extension/index.ts | 16 ++-- 4 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 brev/reset.sh.log diff --git a/brev/.gitignore b/brev/.gitignore index c26c3f6..54affb1 100644 --- a/brev/.gitignore +++ b/brev/.gitignore @@ -1 +1,2 @@ -brev-start-vm.sh \ No newline at end of file +brev-start-vm.sh +reset.sh \ No newline at end of file diff --git a/brev/reset.sh.log b/brev/reset.sh.log new file mode 100644 index 0000000..d2acd08 --- /dev/null +++ b/brev/reset.sh.log @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" + +CLI_BIN="${CLI_BIN:-openshell}" +SANDBOX_NAME="${SANDBOX_NAME:-nemoclaw}" +WELCOME_UI_PATTERN="${WELCOME_UI_PATTERN:-node server.js}" +NEMOCLAW_IMAGE="${NEMOCLAW_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/nemoclaw:latest}" +REMOVE_IMAGE="${REMOVE_IMAGE:-0}" + +log() { + printf '[reset] %s\n' "$*" +} + +try_run() { + if "$@"; then + return 0 + fi + return 1 +} + +stop_welcome_ui() { + if pgrep -f "$WELCOME_UI_PATTERN" >/dev/null 2>&1; then + log "Stopping Welcome UI processes matching: $WELCOME_UI_PATTERN" + pkill -f "$WELCOME_UI_PATTERN" || true + else + log "No Welcome UI process found" + fi +} + +delete_sandbox() { + log "Deleting sandbox: $SANDBOX_NAME" + if ! try_run "$CLI_BIN" sandbox delete "$SANDBOX_NAME"; then + log "Sandbox delete returned non-zero; continuing" + fi +} + +stop_forward() { + if "$CLI_BIN" forward --help >/dev/null 2>&1; then + log "Stopping forwarded port 18789 for $SANDBOX_NAME" + if ! try_run "$CLI_BIN" forward stop 18789 "$SANDBOX_NAME"; then + log "Forward stop returned non-zero; continuing" + fi + else + log "openshell forward subcommand unavailable; skipping forward stop" + fi +} + +cleanup_logs() { + log "Removing temporary logs and generated policy files" + rm -f \ + /tmp/welcome-ui.log \ + /tmp/nemoclaw-sandbox-create.log \ + /tmp/sandbox-policy-*.yaml +} + +remove_image() { + if [[ "$REMOVE_IMAGE" == "1" || "$REMOVE_IMAGE" == "true" || "$REMOVE_IMAGE" == "yes" ]]; then + log "Removing local image: $NEMOCLAW_IMAGE" + if ! try_run docker rmi "$NEMOCLAW_IMAGE"; then + log "Image removal returned non-zero; continuing" + fi + else + log "Leaving local image in place (set REMOVE_IMAGE=1 to remove it)" + fi +} + +main() { + log "Repo root: $REPO_ROOT" + stop_welcome_ui + delete_sandbox + stop_forward + cleanup_logs + remove_image + log "Reset complete" +} + +main "$@" diff --git a/brev/welcome-ui/server.js b/brev/welcome-ui/server.js index 4631874..a6f9036 100644 --- a/brev/welcome-ui/server.js +++ b/brev/welcome-ui/server.js @@ -639,7 +639,16 @@ function runSandboxCreate() { ]; if (policyPath) cmd.push("--policy", policyPath); const envArgs = [`CHAT_UI_URL=${chatUiUrl}`]; - const loopbackNoProxy = "127.0.0.1,localhost,::1"; + const loopbackNoProxy = [ + "127.0.0.1", + "localhost", + "::1", + "navigator.navigator.svc.cluster.local", + ".svc", + ".svc.cluster.local", + "10.42.0.0/16", + "10.43.0.0/16", + ].join(","); const mergedNoProxy = [ process.env.NO_PROXY || process.env.no_proxy || "", loopbackNoProxy, diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index 89b1d96..2127453 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -102,19 +102,14 @@ function clearPairingReloadFlag(): void { } function bootstrap() { + console.info("[NeMoClaw] pairing bootstrap: start"); showConnectOverlay(); const finalizeConnectedState = async () => { setConnectOverlayText("Device pairing approved. Finalizing dashboard..."); - try { - await waitForStableConnection( - STABLE_CONNECTION_WINDOW_MS, - STABLE_CONNECTION_TIMEOUT_MS, - ); - } catch { - await new Promise((resolve) => setTimeout(resolve, POST_PAIRING_SETTLE_DELAY_MS)); - } + console.info("[NeMoClaw] pairing bootstrap: reconnect detected"); if (shouldForcePairingReload()) { + console.info("[NeMoClaw] pairing bootstrap: forcing one-time reload"); markPairingReloadComplete(); setConnectOverlayText("Device pairing approved. Reloading dashboard..."); window.location.reload(); @@ -122,13 +117,16 @@ function bootstrap() { } 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(); }; @@ -136,11 +134,13 @@ function bootstrap() { waitForReconnect(INITIAL_CONNECT_TIMEOUT_MS) .then(finalizeConnectedState) .catch(async () => { + console.warn("[NeMoClaw] pairing bootstrap: initial reconnect timed out; extending wait"); 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(); } From 59b4389649bf7d37a3c7f6f09a72bccfdc892a4b Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Fri, 13 Mar 2026 20:22:47 -0700 Subject: [PATCH 21/27] Handle context mod for inference.local --- .gitignore | 1 + sandboxes/nemoclaw/nemoclaw-start.sh | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3412b31 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/AGENTS.md diff --git a/sandboxes/nemoclaw/nemoclaw-start.sh b/sandboxes/nemoclaw/nemoclaw-start.sh index 6e65f66..5d70d53 100644 --- a/sandboxes/nemoclaw/nemoclaw-start.sh +++ b/sandboxes/nemoclaw/nemoclaw-start.sh @@ -114,7 +114,7 @@ for provider in cfg.get('models', {}).get('providers', {}).values(): if not isinstance(provider, dict): continue for model in provider.get('models', []): - if isinstance(model, dict) and model.get('id') == '${_DEFAULT_MODEL}': + if isinstance(model, dict) and model.get('id') in ('${_DEFAULT_MODEL}', '-'): model['contextWindow'] = ${_DEFAULT_CONTEXT_WINDOW} model['maxTokens'] = ${_DEFAULT_MAX_TOKENS} json.dump(cfg, open(os.environ['HOME'] + '/.openclaw/openclaw.json', 'w'), indent=2) From c35e759dbce5a53b6c8431167fe17a3a49f35e20 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Fri, 13 Mar 2026 21:04:54 -0700 Subject: [PATCH 22/27] Fix k3s image import on build; force reload on first pass timeout --- brev/launch.sh | 94 ++++++++++++++++++- brev/reset.sh.log | 81 ---------------- .../nemoclaw-ui-extension/extension/index.ts | 17 +++- 3 files changed, 106 insertions(+), 86 deletions(-) delete mode 100644 brev/reset.sh.log diff --git a/brev/launch.sh b/brev/launch.sh index b429498..782be4e 100755 --- a/brev/launch.sh +++ b/brev/launch.sh @@ -32,8 +32,15 @@ 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:-}" -NEMOCLAW_IMAGE="${NEMOCLAW_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/nemoclaw:latest}" +DEFAULT_NEMOCLAW_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/nemoclaw: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" @@ -261,6 +268,19 @@ should_build_nemoclaw_image() { [[ -n "$COMMUNITY_REF" && "$COMMUNITY_REF" != "main" ]] } +maybe_use_branch_local_nemoclaw_tag() { + if ! should_build_nemoclaw_image; then + return + fi + + if [[ "$NEMOCLAW_IMAGE_EXPLICIT" == "1" || "$NEMOCLAW_IMAGE" != "$DEFAULT_NEMOCLAW_IMAGE" ]]; then + return + fi + + NEMOCLAW_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/nemoclaw: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" @@ -302,6 +322,75 @@ build_nemoclaw_image_if_needed() { log "Local NeMoClaw image ready: $NEMOCLAW_IMAGE" } +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 +} + +resolve_cluster_container_name() { + local docker_bin + + if [[ -n "$CLUSTER_CONTAINER_NAME" ]]; then + printf '%s' "$CLUSTER_CONTAINER_NAME" + return 0 + fi + + docker_bin="$(resolve_docker_cmd)" || return 1 + + 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" ]] +} + +import_nemoclaw_image_into_cluster_if_needed() { + local docker_bin cluster_name + + 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)" || { + log "Docker not available; skipping cluster image import." + return + } + + 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 + + if ! cluster_name="$(resolve_cluster_container_name)"; then + log "OpenShell cluster container not found; skipping cluster image import." + 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 + + 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" + exit 1 + fi + + log "Cluster image import complete: $NEMOCLAW_IMAGE" +} + checkout_repo_ref() { if [[ -z "$COMMUNITY_REF" ]]; then return @@ -597,6 +686,7 @@ main() { 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" @@ -612,6 +702,8 @@ main() { 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 \ diff --git a/brev/reset.sh.log b/brev/reset.sh.log deleted file mode 100644 index d2acd08..0000000 --- a/brev/reset.sh.log +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" - -CLI_BIN="${CLI_BIN:-openshell}" -SANDBOX_NAME="${SANDBOX_NAME:-nemoclaw}" -WELCOME_UI_PATTERN="${WELCOME_UI_PATTERN:-node server.js}" -NEMOCLAW_IMAGE="${NEMOCLAW_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/nemoclaw:latest}" -REMOVE_IMAGE="${REMOVE_IMAGE:-0}" - -log() { - printf '[reset] %s\n' "$*" -} - -try_run() { - if "$@"; then - return 0 - fi - return 1 -} - -stop_welcome_ui() { - if pgrep -f "$WELCOME_UI_PATTERN" >/dev/null 2>&1; then - log "Stopping Welcome UI processes matching: $WELCOME_UI_PATTERN" - pkill -f "$WELCOME_UI_PATTERN" || true - else - log "No Welcome UI process found" - fi -} - -delete_sandbox() { - log "Deleting sandbox: $SANDBOX_NAME" - if ! try_run "$CLI_BIN" sandbox delete "$SANDBOX_NAME"; then - log "Sandbox delete returned non-zero; continuing" - fi -} - -stop_forward() { - if "$CLI_BIN" forward --help >/dev/null 2>&1; then - log "Stopping forwarded port 18789 for $SANDBOX_NAME" - if ! try_run "$CLI_BIN" forward stop 18789 "$SANDBOX_NAME"; then - log "Forward stop returned non-zero; continuing" - fi - else - log "openshell forward subcommand unavailable; skipping forward stop" - fi -} - -cleanup_logs() { - log "Removing temporary logs and generated policy files" - rm -f \ - /tmp/welcome-ui.log \ - /tmp/nemoclaw-sandbox-create.log \ - /tmp/sandbox-policy-*.yaml -} - -remove_image() { - if [[ "$REMOVE_IMAGE" == "1" || "$REMOVE_IMAGE" == "true" || "$REMOVE_IMAGE" == "yes" ]]; then - log "Removing local image: $NEMOCLAW_IMAGE" - if ! try_run docker rmi "$NEMOCLAW_IMAGE"; then - log "Image removal returned non-zero; continuing" - fi - else - log "Leaving local image in place (set REMOVE_IMAGE=1 to remove it)" - fi -} - -main() { - log "Repo root: $REPO_ROOT" - stop_welcome_ui - delete_sandbox - stop_forward - cleanup_logs - remove_image - log "Reset complete" -} - -main "$@" diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index 2127453..b167a0a 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -24,6 +24,7 @@ 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; function inject(): boolean { const hasButton = injectButton(); @@ -101,6 +102,13 @@ function clearPairingReloadFlag(): void { } } +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 bootstrap() { console.info("[NeMoClaw] pairing bootstrap: start"); showConnectOverlay(); @@ -109,10 +117,7 @@ function bootstrap() { setConnectOverlayText("Device pairing approved. Finalizing dashboard..."); console.info("[NeMoClaw] pairing bootstrap: reconnect detected"); if (shouldForcePairingReload()) { - console.info("[NeMoClaw] pairing bootstrap: forcing one-time reload"); - markPairingReloadComplete(); - setConnectOverlayText("Device pairing approved. Reloading dashboard..."); - window.location.reload(); + forcePairingReload("post-reconnect", "Device pairing approved. Reloading dashboard..."); return; } setConnectOverlayText("Device pairing approved. Verifying dashboard health..."); @@ -135,6 +140,10 @@ function bootstrap() { .then(finalizeConnectedState) .catch(async () => { console.warn("[NeMoClaw] pairing bootstrap: initial reconnect timed out; extending wait"); + if (shouldForcePairingReload()) { + forcePairingReload("initial-timeout", "Pairing is still settling. Reloading dashboard..."); + return; + } setConnectOverlayText("Still waiting for device pairing approval..."); try { await waitForReconnect(EXTENDED_CONNECT_TIMEOUT_MS); From 9ef9e78719f4b3bd23d4dcbb250f79b858951ddd Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Fri, 13 Mar 2026 21:16:00 -0700 Subject: [PATCH 23/27] Revise Brev README --- README.md | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 489021e..a9e2a18 100644 --- a/README.md +++ b/README.md @@ -32,22 +32,14 @@ This repo is the community ecosystem around OpenShell -- a hub for contributed s #### Deploy Instantly with NVIDIA Brev -Skip the setup and launch OpenShell Community on a fully configured Brev instance. +Skip the setup and launch OpenShell Community on a fully configured Brev instance, whether you want to use Brev as a remote OpenShell gateway with or without GPU accelerators, or as an all-in-one playground for sandboxes, inference, and UI workflows. | Instance | Best For | Deploy | | -------- | -------- | ------ | -| CPU-only | External inference endpoints, remote APIs, lighter-weight sandbox workflows | Deploy on Brev | -| NVIDIA H100 | Locally hosted LLM endpoints, GPU-heavy sandboxes, higher-throughput agent workloads | Deploy on Brev | +| CPU-only | Remote OpenShell gateway deployments, external inference endpoints, remote APIs, and lighter-weight sandbox workflows | Deploy on Brev | +| NVIDIA H100 | All-in-one OpenShell playgrounds, locally hosted LLM endpoints, GPU-heavy sandboxes, and higher-throughput agent workloads | Deploy on Brev | -After the Brev instance is ready, bootstrap the Welcome UI: - -```bash -git clone https://github.com/NVIDIA/OpenShell-Community.git -cd OpenShell-Community -bash brev/launch.sh -``` - -The launcher brings up the Welcome UI on `http://localhost:8081`, where you can inject provider keys and create the NeMoClaw sandbox flow. +After the Brev instance is ready, access the Welcome UI to inject provider keys and access your Openclaw sandbox. ### Using Sandboxes From efeb9aa8c27a2ba325407a5b08748f2e9881b1a1 Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Fri, 13 Mar 2026 21:29:09 -0700 Subject: [PATCH 24/27] Cleanup Brev section --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index a9e2a18..fa3557e 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,6 @@ This repo is the community ecosystem around OpenShell -- a hub for contributed s ### Quick Start with Brev -#### Deploy Instantly with NVIDIA Brev - Skip the setup and launch OpenShell Community on a fully configured Brev instance, whether you want to use Brev as a remote OpenShell gateway with or without GPU accelerators, or as an all-in-one playground for sandboxes, inference, and UI workflows. | Instance | Best For | Deploy | From 7aa8d1811595f22c1799da3e9879438d93bbb10c Mon Sep 17 00:00:00 2001 From: JR Morgan Date: Fri, 13 Mar 2026 22:08:06 -0700 Subject: [PATCH 25/27] Revert policy.yaml to orig --- sandboxes/openclaw/policy.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/sandboxes/openclaw/policy.yaml b/sandboxes/openclaw/policy.yaml index a12c46b..a91da84 100644 --- a/sandboxes/openclaw/policy.yaml +++ b/sandboxes/openclaw/policy.yaml @@ -125,7 +125,3 @@ network_policies: binaries: - { path: /usr/local/bin/claude } - { path: /usr/bin/gh } - -inference: - allowed_routes: - - local From 3e100b03cb37c4ca811d89e195a69b1a71c5f046 Mon Sep 17 00:00:00 2001 From: OpenShell-Community Dev Date: Sun, 15 Mar 2026 14:09:42 +0000 Subject: [PATCH 26/27] Added policy recommender --- brev/welcome-ui/package-lock.json | 335 ++++++++++++++++++ brev/welcome-ui/package.json | 6 +- brev/welcome-ui/server.js | 180 ++++++++++ .../extension/denial-watcher.ts | 235 ++++++++++++ .../nemoclaw-ui-extension/extension/index.ts | 2 + sandboxes/nemoclaw/policy-proxy.js | 87 +++++ .../extension/policy-page.ts | 217 ++++++++++++ .../extension/styles.css | 331 +++++++++++++++++ 8 files changed, 1391 insertions(+), 2 deletions(-) create mode 100644 sandboxes/nemoclaw/nemoclaw-ui-extension/extension/denial-watcher.ts diff --git a/brev/welcome-ui/package-lock.json b/brev/welcome-ui/package-lock.json index 85dfa49..bd5b9f2 100644 --- a/brev/welcome-ui/package-lock.json +++ b/brev/welcome-ui/package-lock.json @@ -8,6 +8,8 @@ "name": "openshell-welcome-ui", "version": "1.0.0", "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@grpc/proto-loader": "^0.8.0", "js-yaml": "^4" }, "devDependencies": { @@ -457,6 +459,37 @@ "node": ">=18" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -464,6 +497,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -487,6 +530,70 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -862,6 +969,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -977,6 +1093,30 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1075,6 +1215,38 @@ "node": ">= 16" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1179,6 +1351,12 @@ "node": ">= 0.4" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1277,6 +1455,15 @@ "@esbuild/win32-x64": "0.27.3" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1382,6 +1569,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1476,6 +1672,15 @@ "node": ">= 0.4" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1495,6 +1700,18 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1683,6 +1900,30 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -1699,6 +1940,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -1851,6 +2101,32 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -1961,6 +2237,12 @@ "node": ">=14.0.0" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -2149,12 +2431,65 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } } } } diff --git a/brev/welcome-ui/package.json b/brev/welcome-ui/package.json index cc597cf..f4aa20b 100644 --- a/brev/welcome-ui/package.json +++ b/brev/welcome-ui/package.json @@ -8,10 +8,12 @@ "test:watch": "vitest" }, "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@grpc/proto-loader": "^0.8.0", "js-yaml": "^4" }, "devDependencies": { - "vitest": "^3", - "supertest": "^7" + "supertest": "^7", + "vitest": "^3" } } diff --git a/brev/welcome-ui/server.js b/brev/welcome-ui/server.js index 77bc988..2441117 100644 --- a/brev/welcome-ui/server.js +++ b/brev/welcome-ui/server.js @@ -26,6 +26,15 @@ try { yaml = null; } +let grpc, protoLoader; +try { + grpc = require("@grpc/grpc-js"); + protoLoader = require("@grpc/proto-loader"); +} catch { + grpc = null; + protoLoader = null; +} + // ── Configuration ────────────────────────────────────────────────────────── const PORT = parseInt(process.env.PORT || "8081", 10); @@ -1223,6 +1232,173 @@ async function handleClusterInferenceSet(req, res) { } } +// ── Sandbox denial logs (gRPC to gateway) ────────────────────────────────── + +let _denialsGrpcClient = null; +let _sandboxUuid = ""; + +function initDenialsGrpc() { + if (!grpc || !protoLoader) { + logWelcome("gRPC packages not available; /api/sandbox-denials will proxy to sandbox"); + return; + } + + const configDir = path.join( + os.homedir(), + ".config", + "openshell", + "gateways" + ); + + let metaPath, mtlsDir; + try { + const activeGw = fs + .readFileSync(path.join(os.homedir(), ".config", "openshell", "active_gateway"), "utf-8") + .trim(); + metaPath = path.join(configDir, activeGw, "metadata.json"); + mtlsDir = path.join(configDir, activeGw, "mtls"); + } catch { + logWelcome("Cannot read active gateway config; denials gRPC disabled"); + return; + } + + let endpoint; + try { + const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8")); + endpoint = meta.gateway_endpoint; + } catch { + logWelcome("Cannot read gateway metadata; denials gRPC disabled"); + return; + } + + if (!endpoint) return; + + const openshellProtoDir = path.join(ROOT, "..", "..", "..", "OpenShell", "proto"); + const nemoclawProtoDir = path.join(REPO_ROOT, "sandboxes", "nemoclaw", "proto"); + const protoDir = fs.existsSync(path.join(openshellProtoDir, "openshell.proto")) + ? openshellProtoDir + : nemoclawProtoDir; + const protoFile = protoDir === openshellProtoDir ? "openshell.proto" : "navigator.proto"; + + let packageDef; + try { + packageDef = protoLoader.loadSync(protoFile, { + keepCase: true, + longs: Number, + enums: String, + defaults: true, + oneofs: true, + includeDirs: [protoDir], + }); + } catch (e) { + logWelcome(`Failed to load ${protoFile}: ${e.message}`); + return; + } + + const proto = grpc.loadPackageDefinition(packageDef); + const target = endpoint.replace(/^https?:\/\//, ""); + + const svc = (proto.openshell && proto.openshell.v1 && proto.openshell.v1.OpenShell) + || (proto.navigator && proto.navigator.v1 && proto.navigator.v1.Navigator); + if (!svc) { + logWelcome("Could not find OpenShell or Navigator service in proto definitions"); + return; + } + + let creds; + try { + const caPath = path.join(mtlsDir, "ca.crt"); + const certPath = path.join(mtlsDir, "tls.crt"); + const keyPath = path.join(mtlsDir, "tls.key"); + if (fs.existsSync(caPath) && fs.existsSync(certPath) && fs.existsSync(keyPath)) { + creds = grpc.credentials.createSsl( + fs.readFileSync(caPath), + fs.readFileSync(keyPath), + fs.readFileSync(certPath) + ); + } else if (fs.existsSync(caPath)) { + creds = grpc.credentials.createSsl(fs.readFileSync(caPath)); + } else { + creds = grpc.credentials.createInsecure(); + } + } catch { + creds = grpc.credentials.createInsecure(); + } + + _denialsGrpcClient = new svc(target, creds); + logWelcome(`Denials gRPC client initialized → ${target} (service: ${svc.serviceName || protoFile})`); + + resolveSandboxUuid(); +} + +function resolveSandboxUuid() { + execCmd(cliArgs("sandbox", "get", SANDBOX_NAME), 10000).then((result) => { + if (result.code !== 0) return; + const m = stripAnsi(result.stdout).match(/Id:\s+(\S+)/); + if (m) { + _sandboxUuid = m[1]; + logWelcome(`Resolved sandbox UUID: ${_sandboxUuid}`); + } + }); +} + +function grpcGetSandboxLogs(request) { + return new Promise((resolve, reject) => { + const deadline = new Date(Date.now() + 5000); + _denialsGrpcClient.GetSandboxLogs(request, { deadline }, (err, response) => { + if (err) return reject(err); + resolve(response); + }); + }); +} + +async function handleSandboxDenials(req, res) { + if (!_denialsGrpcClient || !_sandboxUuid) { + if (!_sandboxUuid && _denialsGrpcClient) resolveSandboxUuid(); + return jsonResponse(res, 200, { denials: [], latest_ts: 0 }); + } + + const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); + const sinceMs = parseInt(parsedUrl.searchParams.get("since") || "0", 10) || 0; + + try { + const resp = await grpcGetSandboxLogs({ + sandbox_id: _sandboxUuid, + lines: 2000, + since_ms: sinceMs || 0, + sources: ["sandbox"], + min_level: "INFO", + }); + + const denials = []; + let latestTs = sinceMs; + + for (const log of resp.logs || []) { + const fields = log.fields || {}; + if (fields.action !== "deny") continue; + + const host = fields.dst_host || ""; + if (!host) continue; + + const ts = Number(log.timestamp_ms) || 0; + if (ts > latestTs) latestTs = ts; + + denials.push({ + ts, + host, + port: parseInt(fields.dst_port || fields.port || "0", 10) || 0, + binary: fields.binary || "", + reason: fields.reason || "", + }); + } + + return jsonResponse(res, 200, { denials, latest_ts: latestTs }); + } catch (e) { + logWelcome(`GetSandboxLogs gRPC failed: ${e.message}`); + return jsonResponse(res, 200, { denials: [], latest_ts: 0 }); + } +} + // ── Reverse proxy (HTTP) ─────────────────────────────────────────────────── function proxyToSandbox(clientReq, clientRes) { @@ -1666,6 +1842,9 @@ async function handleRequest(req, res) { if (pathname === "/api/sandbox-logs" && method === "GET") { return handleSandboxLogs(req, res); } + if (pathname === "/api/sandbox-denials" && method === "GET") { + return handleSandboxDenials(req, res); + } // If sandbox is ready, proxy everything else to the sandbox if (await sandboxReady()) { @@ -1733,6 +1912,7 @@ function _setMocksForTesting(mocks) { if (require.main === module) { bootstrapConfigCache(); + initDenialsGrpc(); server.listen(PORT, "", () => { console.log(`OpenShell Welcome UI -> http://localhost:${PORT}`); }); diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/denial-watcher.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/denial-watcher.ts new file mode 100644 index 0000000..c60a61e --- /dev/null +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/denial-watcher.ts @@ -0,0 +1,235 @@ +/** + * NeMoClaw DevX — Sandbox Denial Watcher + * + * Polls the policy-proxy for sandbox network denial events and injects + * a single chat-style card above the compose area. The card lists blocked + * connections (newest nearest to input), with compact rows and one CTA to + * Sandbox Policy. A scrollable list keeps many denials visible without + * flooding the chat. + */ + +import { ICON_SHIELD, ICON_CLOSE } from "./icons.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface DenialEvent { + ts: number; + host: string; + port: number; + binary: string; + reason: string; +} + +interface DenialsResponse { + denials: DenialEvent[]; + latest_ts: number; + error?: string; +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +const POLL_INTERVAL_MS = 3_000; + +let lastTs = 0; +let pollTimer: ReturnType | null = null; +let seenKeys = new Set(); +let activeDenials: DenialEvent[] = []; +let container: HTMLElement | null = null; +let running = false; + +function denialKey(d: DenialEvent): string { + return `${d.host}:${d.port}:${d.binary}`; +} + +function binaryBasename(path: string): string { + const parts = path.split("/"); + return parts[parts.length - 1] || path; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +// --------------------------------------------------------------------------- +// API +// --------------------------------------------------------------------------- + +async function fetchDenials(sinceMs: number): Promise { + const res = await fetch(`/api/sandbox-denials?since=${sinceMs}`); + if (!res.ok) return { denials: [], latest_ts: sinceMs }; + return res.json(); +} + +// --------------------------------------------------------------------------- +// Single card with compact rows (above compose) +// --------------------------------------------------------------------------- + +function findChatCompose(): HTMLElement | null { + return document.querySelector(".chat-compose"); +} + +function getOrCreateContainer(): HTMLElement | null { + const chatCompose = findChatCompose(); + if (!chatCompose?.parentElement) return null; + + if (container?.parentElement) return container; + + container = document.createElement("div"); + container.className = "nemoclaw-sandbox-denials"; + container.setAttribute("role", "status"); + chatCompose.parentElement.insertBefore(container, chatCompose); + return container; +} + +/** Order by ts ascending so newest is last (nearest to input). */ +function sortedDenials(): DenialEvent[] { + return [...activeDenials].sort((a, b) => a.ts - b.ts); +} + +function createRow(denial: DenialEvent): HTMLElement { + const bin = binaryBasename(denial.binary); + const portSuffix = denial.port === 443 || denial.port === 80 ? "" : `:${denial.port}`; + const row = document.createElement("div"); + row.className = "nemoclaw-sandbox-denial-row"; + row.setAttribute("data-denial-key", denialKey(denial)); + row.innerHTML = ` + Request blocked: ${escapeHtml(bin)}${escapeHtml(denial.host)}${escapeHtml(portSuffix)} + `; + const dismissBtn = row.querySelector(".nemoclaw-sandbox-denial-row__dismiss"); + if (dismissBtn) { + dismissBtn.addEventListener("click", (e) => { + e.stopPropagation(); + dismissRow(row); + }); + } + return row; +} + +function dismissRow(row: HTMLElement): void { + const key = row.getAttribute("data-denial-key"); + if (key) { + seenKeys.delete(key); + activeDenials = activeDenials.filter((d) => denialKey(d) !== key); + } + renderDenialMessages(); +} + +function renderDenialMessages(): void { + const parent = getOrCreateContainer(); + if (!parent) return; + + if (activeDenials.length === 0) { + if (container?.parentElement) { + container.remove(); + container = null; + } + return; + } + + const n = activeDenials.length; + const label = n === 1 ? "1 blocked request" : `${n} blocked requests`; + + parent.innerHTML = ""; + parent.className = "nemoclaw-sandbox-denials"; + + const card = document.createElement("div"); + card.className = "nemoclaw-sandbox-denial-card"; + card.innerHTML = ` +
+ ${ICON_SHIELD} + OpenShell Sandbox — ${escapeHtml(label)} +
+
+
+
+ Add allow rules in Sandbox Policy to continue. +
`; + + const list = card.querySelector(".nemoclaw-sandbox-denials__list")!; + const ordered = sortedDenials(); + for (const denial of ordered) { + list.appendChild(createRow(denial)); + } + + parent.appendChild(card); +} + +function injectDenialAsMessage(denial: DenialEvent): void { + const key = denialKey(denial); + if (seenKeys.has(key)) return; + seenKeys.add(key); + activeDenials.push(denial); + renderDenialMessages(); +} + +/** + * Clear denial UI and state. + * @param keepSeenKeys - If true, do not clear seenKeys so the same denials + * won't be re-shown on next poll (use when policy was just saved/approved). + */ +function clearAllDenialMessages(keepSeenKeys = false): void { + if (!keepSeenKeys) seenKeys.clear(); + activeDenials = []; + if (container?.parentElement) { + container.remove(); + container = null; + } +} + +// --------------------------------------------------------------------------- +// Poll loop +// --------------------------------------------------------------------------- + +async function poll(): Promise { + try { + const data = await fetchDenials(lastTs); + if (data.latest_ts > lastTs) lastTs = data.latest_ts; + + for (const denial of data.denials) { + injectDenialAsMessage(denial); + } + } catch { + // Non-fatal — will retry on next poll + } + + if (running) { + pollTimer = setTimeout(poll, POLL_INTERVAL_MS); + } +} + +// --------------------------------------------------------------------------- +// Policy-saved event handler +// --------------------------------------------------------------------------- + +function onPolicySaved(): void { + clearAllDenialMessages(true); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function startDenialWatcher(): void { + if (running) return; + running = true; + + lastTs = Date.now() - 60_000; + + document.addEventListener("nemoclaw:policy-saved", onPolicySaved); + + poll(); +} + +export function stopDenialWatcher(): void { + running = false; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + document.removeEventListener("nemoclaw:policy-saved", onPolicySaved); + clearAllDenialMessages(); +} diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index b167a0a..f567f22 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -17,6 +17,7 @@ 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 { syncKeysToProviders } from "./api-keys-page.ts"; +import { startDenialWatcher } from "./denial-watcher.ts"; const INITIAL_CONNECT_TIMEOUT_MS = 30_000; const EXTENDED_CONNECT_TIMEOUT_MS = 300_000; @@ -76,6 +77,7 @@ function revealApp(): void { overlay.addEventListener("transitionend", () => overlay.remove(), { once: true }); setTimeout(() => overlay.remove(), 600); } + startDenialWatcher(); } function shouldForcePairingReload(): boolean { diff --git a/sandboxes/nemoclaw/policy-proxy.js b/sandboxes/nemoclaw/policy-proxy.js index e699e53..64efb40 100644 --- a/sandboxes/nemoclaw/policy-proxy.js +++ b/sandboxes/nemoclaw/policy-proxy.js @@ -452,6 +452,87 @@ function scheduleStartupAudit(attempt = 1) { }, delayMs); } +// --------------------------------------------------------------------------- +// Sandbox denial log fetcher (via GetSandboxLogs gRPC) +// --------------------------------------------------------------------------- + +let sandboxId = ""; + +function resolveSandboxId() { + sandboxId = process.env.NEMOCLAW_SANDBOX_ID || ""; + if (!sandboxId) { + const discovered = discoverFromSupervisor(); + sandboxId = discovered.sandboxId || discovered.sandbox || os.hostname() || ""; + } +} + +function getSandboxLogs(request) { + return new Promise((resolve, reject) => { + const deadline = new Date(Date.now() + 5000); + grpcClient.GetSandboxLogs(request, { deadline }, (err, response) => { + if (err) return reject(err); + resolve(response); + }); + }); +} + +async function handleDenialsGet(req, res) { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + if (!grpcEnabled || !grpcClient || grpcPermanentlyDisabled) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ denials: [], latest_ts: 0 })); + return; + } + + const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); + const sinceMs = parseInt(url.searchParams.get("since") || "0", 10) || 0; + + try { + const resp = await getSandboxLogs({ + sandbox_id: sandboxId || sandboxName, + lines: 200, + since_ms: sinceMs || 0, + sources: ["sandbox"], + min_level: "INFO", + }); + + const denials = []; + let latestTs = sinceMs; + + for (const log of (resp.logs || [])) { + const fields = log.fields || {}; + if (fields.action !== "deny") continue; + + const ts = Number(log.timestamp_ms) || 0; + if (ts > latestTs) latestTs = ts; + + denials.push({ + ts, + host: fields.dst_host || "", + port: parseInt(fields.dst_port || "0", 10) || 0, + binary: fields.binary || "", + reason: fields.reason || "", + }); + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ denials, latest_ts: latestTs })); + } catch (e) { + console.warn("[policy-proxy] GetSandboxLogs failed:", e.message); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ denials: [], latest_ts: 0, error: e.message })); + } +} + // --------------------------------------------------------------------------- // HTTP proxy helpers // --------------------------------------------------------------------------- @@ -593,6 +674,11 @@ const server = http.createServer((req, res) => { return; } + if (req.url && req.url.startsWith("/api/sandbox-denials")) { + handleDenialsGet(req, res); + return; + } + proxyRequest(req, res); }); @@ -624,6 +710,7 @@ server.on("upgrade", (req, socket, head) => { // Initialize gRPC client before starting the HTTP server. initGrpcClient(); +resolveSandboxId(); auditStartupPolicyFile(); server.listen(LISTEN_PORT, "127.0.0.1", () => { diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/policy-page.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/policy-page.ts index 3389b89..40e96b7 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/policy-page.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/policy-page.ts @@ -68,6 +68,15 @@ interface SelectOption { label: string; } +/** Denial event from /api/sandbox-denials (recent blocked connection). */ +interface DenialEvent { + ts: number; + host: string; + port: number; + binary: string; + reason: string; +} + // --------------------------------------------------------------------------- // Policy templates // --------------------------------------------------------------------------- @@ -184,6 +193,103 @@ async function syncPolicyViaHost(yamlText: string): Promise { return body; } +// --------------------------------------------------------------------------- +// Recommendations (from recent sandbox denials) +// --------------------------------------------------------------------------- + +const DENIALS_SINCE_MS = 5 * 60 * 1000; // 5 minutes + +async function fetchDenials(): Promise { + const since = Date.now() - DENIALS_SINCE_MS; + const res = await fetch(`/api/sandbox-denials?since=${since}`); + if (!res.ok) return []; + const data = (await res.json()) as { denials?: DenialEvent[] }; + return data.denials || []; +} + +function ruleNameFromDenial(host: string, port: number): string { + const sanitized = host + .replace(/\./g, "_") + .replace(/-/g, "_") + .replace(/[^a-zA-Z0-9_]/g, ""); + return `allow_${sanitized || "host"}_${port}`; +} + +function binaryBasename(path: string): string { + const parts = path.split("/"); + return parts[parts.length - 1] || path; +} + +/** True if current policy already allows this host:port for this binary. */ +function denialAlreadyAllowed(denial: DenialEvent): boolean { + const policies = currentPolicy?.network_policies || {}; + const denialPath = denial.binary || ""; + const denialBin = binaryBasename(denialPath); + for (const policy of Object.values(policies)) { + const hasEndpoint = (policy.endpoints || []).some( + (ep) => String(ep.host) === denial.host && Number(ep.port) === denial.port + ); + if (!hasEndpoint) continue; + const binaries = (policy.binaries || []).map((b) => b.path); + if (binaries.length === 0) return true; + if (binaries.some((p) => p === denialPath || binaryBasename(p) === denialBin)) return true; + } + return false; +} + +/** Add or merge policy from a denial, then save and refresh the page. */ +async function approveRecommendation(denial: DenialEvent): Promise { + if (!currentPolicy) return; + if (!currentPolicy.network_policies) currentPolicy.network_policies = {}; + const key = ruleNameFromDenial(denial.host, denial.port); + const existing = currentPolicy.network_policies[key]; + const binaryPath = denial.binary || ""; + const newBinary: PolicyBinary = { path: binaryPath }; + + if (existing) { + existing.binaries = existing.binaries || []; + if (binaryPath && !existing.binaries.some((b) => b.path === binaryPath)) { + existing.binaries.push(newBinary); + } + markDirty(key, "modified"); + } else { + const newPolicy: NetworkPolicy = { + name: key, + endpoints: [{ host: denial.host, port: denial.port }], + binaries: binaryPath ? [{ path: binaryPath }] : [], + }; + currentPolicy.network_policies[key] = newPolicy; + markDirty(key, "added"); + } + + const yamlText = yaml.dump(currentPolicy, { + lineWidth: -1, + noRefs: true, + quotingType: '"', + forceQuotes: false, + }); + + let result = await savePolicy(yamlText); + rawYaml = yamlText; + isDirty = false; + changeTracker.modified.clear(); + changeTracker.added.clear(); + changeTracker.deleted.clear(); + document.dispatchEvent(new CustomEvent("nemoclaw:policy-saved")); + + if (result.applied === false) { + try { + const hostResult = await syncPolicyViaHost(yamlText); + if (hostResult.ok && hostResult.applied) result = hostResult; + } catch { + // ignore + } + } + + const page = pageContainer?.querySelector(".nemoclaw-policy-page"); + if (page) renderPageContent(page); +} + // --------------------------------------------------------------------------- // Render entry point // --------------------------------------------------------------------------- @@ -282,6 +388,7 @@ function buildTabLayout(): HTMLElement { const editablePanel = document.createElement("div"); editablePanel.className = "nemoclaw-policy-tab-panel"; + editablePanel.appendChild(buildRecommendationsSection()); editablePanel.appendChild(buildNetworkPoliciesSection()); const lockedPanel = document.createElement("div"); @@ -309,6 +416,114 @@ function buildTabLayout(): HTMLElement { return wrapper; } +// --------------------------------------------------------------------------- +// Recommendations (from recent sandbox denials — one-click approve) +// --------------------------------------------------------------------------- + +function buildRecommendationsSection(): HTMLElement { + const section = document.createElement("div"); + section.className = "nemoclaw-policy-recommendations"; + section.innerHTML = ` +
+ ${ICON_SHIELD} +

Recommended from recent blocks

+ + +
+

These connections were blocked by the sandbox. Approve to add an allow rule.

+
+ Loading… +
`; + + const titleEl = section.querySelector(".nemoclaw-policy-recommendations__title")!; + const countEl = section.querySelector(".nemoclaw-policy-recommendations__count")!; + const approveAllBtn = section.querySelector(".nemoclaw-policy-recommendations__approve-all")!; + const list = section.querySelector(".nemoclaw-policy-recommendations__list")!; + + function setCount(n: number): void { + if (n === 0) { + countEl.textContent = ""; + approveAllBtn.style.display = "none"; + } else { + countEl.textContent = `(${n})`; + approveAllBtn.style.display = ""; + approveAllBtn.textContent = n === 1 ? "Approve all" : `Approve all ${n}`; + } + } + + (async () => { + try { + const denials = await fetchDenials(); + const toShow = denials.filter((d) => !denialAlreadyAllowed(d)); + const seen = new Set(); + const unique: DenialEvent[] = []; + for (const d of toShow) { + const key = `${d.host}:${d.port}:${d.binary}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(d); + } + + list.classList.remove("nemoclaw-policy-recommendations__list--empty"); + list.innerHTML = ""; + setCount(unique.length); + + if (unique.length === 0) { + list.classList.add("nemoclaw-policy-recommendations__list--empty"); + list.textContent = "No recent blocks. Denied connections will appear here."; + return; + } + + approveAllBtn.onclick = async () => { + approveAllBtn.disabled = true; + const snapshot = [...unique]; + for (const denial of snapshot) { + try { + await approveRecommendation(denial); + } catch (err) { + console.warn("[policy] approve all: one failed:", err); + } + } + approveAllBtn.disabled = false; + }; + + for (const denial of unique) { + const card = document.createElement("div"); + card.className = "nemoclaw-policy-recommendation-card"; + const binShort = binaryBasename(denial.binary) || "process"; + const portSuffix = denial.port === 443 || denial.port === 80 ? "" : `:${denial.port}`; + card.innerHTML = ` +
+ ${escapeHtml(binShort)}${escapeHtml(denial.host)}${escapeHtml(String(portSuffix))} +
+ `; + const btn = card.querySelector(".nemoclaw-policy-recommendation-card__approve"); + if (btn) { + btn.addEventListener("click", async () => { + btn.disabled = true; + btn.textContent = "Applying…"; + try { + await approveRecommendation(denial); + } catch (err) { + btn.disabled = false; + btn.innerHTML = `${ICON_CHECK} Approve`; + console.warn("[policy] approve recommendation failed:", err); + } + }); + } + list.appendChild(card); + } + } catch { + list.innerHTML = ""; + list.classList.add("nemoclaw-policy-recommendations__list--empty"); + list.textContent = "Could not load recommendations."; + setCount(0); + } + })(); + + return section; +} + // --------------------------------------------------------------------------- // Immutable grid (3 flat read-only cards) // --------------------------------------------------------------------------- @@ -1353,6 +1568,8 @@ async function handleSave(btn: HTMLButtonElement, feedback: HTMLElement, bar: HT changeTracker.added.clear(); changeTracker.deleted.clear(); + document.dispatchEvent(new CustomEvent("nemoclaw:policy-saved")); + // When the in-sandbox gRPC is blocked by network enforcement, relay // through the host-side welcome-ui server which can reach the gateway. if (result.applied === false) { diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/styles.css b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/styles.css index 43ad9e7..7dd2b20 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/styles.css +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/styles.css @@ -2,6 +2,11 @@ NeMoClaw DevX — NVIDIA Green: #76B900 =========================================== */ +/* Hide the OpenClaw version-update banner */ +.update-banner { + display: none !important; +} + /* =========================================== Deploy DGX Button =========================================== */ @@ -664,6 +669,155 @@ body.nemoclaw-switching openclaw-app { transition: opacity 200ms ease; } +/* =========================================== + Sandbox Denial Messages (single card, compact rows, above compose) + =========================================== */ + +.nemoclaw-sandbox-denials { + margin-bottom: 10px; +} + +.nemoclaw-sandbox-denial-card { + max-width: 100%; + border: 1px solid rgba(234, 179, 8, 0.25); + background: rgba(234, 179, 8, 0.06); + border-left: 3px solid #eab308; + border-radius: var(--radius-md, 8px); + font-size: 13px; + line-height: 1.5; + text-align: left; + animation: nemoclaw-slide-down 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +:root[data-theme="light"] .nemoclaw-sandbox-denial-card { + background: rgba(234, 179, 8, 0.08); +} + +.nemoclaw-sandbox-denial-card__header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px 8px; +} + +.nemoclaw-sandbox-denial-card__icon { + display: flex; + width: 16px; + height: 16px; + color: #eab308; + flex-shrink: 0; +} + +.nemoclaw-sandbox-denial-card__icon svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-sandbox-denial-card__label { + font-size: 12px; + font-weight: 700; + color: #eab308; +} + +.nemoclaw-sandbox-denials__list { + max-height: 240px; + overflow-y: auto; + padding: 0 14px 6px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.nemoclaw-sandbox-denial-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + border-radius: var(--radius-sm, 6px); + background: rgba(0, 0, 0, 0.15); +} + +:root[data-theme="light"] .nemoclaw-sandbox-denial-row { + background: rgba(0, 0, 0, 0.04); +} + +.nemoclaw-sandbox-denial-row__text { + font-size: 12px; + color: var(--text, #e4e4e7); + flex: 1; + min-width: 0; +} + +.nemoclaw-sandbox-denial-row__text code { + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + padding: 1px 4px; + border-radius: 3px; + background: rgba(234, 179, 8, 0.12); + color: var(--text, #e4e4e7); +} + +:root[data-theme="light"] .nemoclaw-sandbox-denial-row__text code { + background: rgba(234, 179, 8, 0.15); + color: #1a1a1a; +} + +.nemoclaw-sandbox-denial-row__dismiss { + width: 20px; + height: 20px; + display: grid; + place-items: center; + border: none; + border-radius: var(--radius-sm, 6px); + background: transparent; + color: var(--muted, #71717a); + cursor: pointer; + flex-shrink: 0; + transition: background 120ms ease, color 120ms ease; +} + +.nemoclaw-sandbox-denial-row__dismiss:hover { + background: rgba(234, 179, 8, 0.15); + color: #eab308; +} + +.nemoclaw-sandbox-denial-row__dismiss svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-sandbox-denial-card__cta { + padding: 8px 14px 10px; + font-size: 12px; + color: var(--text, #e4e4e7); + border-top: 1px solid rgba(234, 179, 8, 0.2); +} + +.nemoclaw-sandbox-denial-card__cta a { + font-weight: 700; + color: #eab308; + text-decoration: none; + cursor: pointer; + transition: color 150ms ease; +} + +.nemoclaw-sandbox-denial-card__cta a:hover { + color: #ca8a04; + text-decoration: underline; + text-underline-offset: 2px; +} + /* =========================================== Model Switching — Transition Banner =========================================== */ @@ -1337,6 +1491,183 @@ body.nemoclaw-switching openclaw-app { color: #76B900; } +/* Recommendations (from recent blocks — one-click approve) */ + +.nemoclaw-policy-recommendations { + margin-bottom: 24px; + padding: 14px 16px; + border: 1px solid rgba(234, 179, 8, 0.25); + background: rgba(234, 179, 8, 0.05); + border-radius: var(--radius-md, 8px); + border-left: 3px solid #eab308; +} + +:root[data-theme="light"] .nemoclaw-policy-recommendations { + background: rgba(234, 179, 8, 0.08); +} + +.nemoclaw-policy-recommendations__header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.nemoclaw-policy-recommendations__icon { + display: flex; + width: 18px; + height: 18px; + color: #eab308; +} + +.nemoclaw-policy-recommendations__icon svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nemoclaw-policy-recommendations__title { + font-size: 15px; + font-weight: 700; + color: var(--text-strong, #fafafa); + margin: 0; +} + +.nemoclaw-policy-recommendations__count { + font-size: 14px; + font-weight: 600; + color: #eab308; + margin-left: 2px; +} + +.nemoclaw-policy-recommendations__approve-all { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + color: #fff; + background: #76B900; + border: none; + border-radius: var(--radius-sm, 6px); + cursor: pointer; + transition: background 120ms ease, opacity 120ms ease; +} + +.nemoclaw-policy-recommendations__approve-all:hover:not(:disabled) { + background: #6aa300; +} + +.nemoclaw-policy-recommendations__approve-all:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.nemoclaw-policy-recommendations__desc { + font-size: 12px; + color: var(--muted, #a1a1aa); + margin: 0 0 12px; + line-height: 1.45; +} + +.nemoclaw-policy-recommendations__list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.nemoclaw-policy-recommendations__list--scrollable { + max-height: 280px; + overflow-y: auto; +} + +.nemoclaw-policy-recommendations__list--empty { + font-size: 13px; + color: var(--muted, #a1a1aa); + padding: 8px 0; +} + +.nemoclaw-policy-recommendations__loading { + font-size: 13px; + color: var(--muted, #a1a1aa); +} + +.nemoclaw-policy-recommendation-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + background: var(--bg-elevated, #1a1d25); + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-sm, 6px); +} + +:root[data-theme="light"] .nemoclaw-policy-recommendation-card { + background: #fff; + border-color: #e4e4e7; +} + +.nemoclaw-policy-recommendation-card__summary { + font-size: 13px; + color: var(--text, #e4e4e7); +} + +.nemoclaw-policy-recommendation-card__summary code { + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + padding: 2px 6px; + border-radius: 4px; + background: rgba(234, 179, 8, 0.1); + color: var(--text, #e4e4e7); +} + +:root[data-theme="light"] .nemoclaw-policy-recommendation-card__summary code { + background: rgba(234, 179, 8, 0.12); + color: #1a1a1a; +} + +.nemoclaw-policy-recommendation-card__approve { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 13px; + font-weight: 600; + color: #fff; + background: #76B900; + border: none; + border-radius: var(--radius-sm, 6px); + cursor: pointer; + transition: background 120ms ease, opacity 120ms ease; +} + +.nemoclaw-policy-recommendation-card__approve:hover:not(:disabled) { + background: #6aa300; +} + +.nemoclaw-policy-recommendation-card__approve:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.nemoclaw-policy-recommendation-card__approve svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + /* Section */ .nemoclaw-policy-section { From 2dda5331d28651c0e025c69b6d4f06a69c5e994f Mon Sep 17 00:00:00 2001 From: OpenShell-Community Dev Date: Sun, 15 Mar 2026 15:57:24 +0000 Subject: [PATCH 27/27] nemoclaw: inference UX overhaul, inline API keys, partner logos, denial watcher, preview tooling Made-with: Cursor --- brev/launch.sh | 10 +- brev/welcome-ui/SERVER_ARCHITECTURE.md | 44 +- brev/welcome-ui/__tests__/cli-parsing.test.js | 6 +- .../__tests__/cluster-inference.test.js | 4 +- .../welcome-ui/__tests__/config-cache.test.js | 7 +- brev/welcome-ui/__tests__/inject-key.test.js | 13 +- brev/welcome-ui/__tests__/providers.test.js | 4 +- brev/welcome-ui/__tests__/setup.js | 10 +- brev/welcome-ui/server.js | 9 - sandboxes/openclaw-nvidia/.gitignore | 4 + .../nemoclaw-ui-extension/dev-preview.sh | 65 +++ .../extension/api-keys-page.ts | 356 +++++++++----- .../extension/denial-watcher.ts | 235 +++++++++ .../extension/deploy-modal.ts | 15 +- .../extension/gateway-bridge.ts | 36 -- .../nemoclaw-ui-extension/extension/index.ts | 27 +- .../extension/inference-page.ts | 454 +++++++++++------- .../extension/model-registry.ts | 75 ++- .../extension/model-selector.ts | 37 +- .../extension/nav-group.ts | 35 +- .../extension/package-lock.json | 30 ++ .../extension/partner-logos.ts | 52 ++ .../extension/policy-page.ts | 5 + .../extension/preview-mode.ts | 29 ++ .../extension/styles.css | 418 +++++++++++++++- .../nemoclaw-ui-extension/install.sh | 11 - .../nemoclaw-ui-extension/preview/index.html | 66 +++ .../openclaw-nvidia/openclaw-nvidia-start.sh | 1 - sandboxes/openclaw-nvidia/policy.yaml | 1 - 29 files changed, 1591 insertions(+), 468 deletions(-) create mode 100755 sandboxes/openclaw-nvidia/nemoclaw-ui-extension/dev-preview.sh create mode 100644 sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/denial-watcher.ts create mode 100644 sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/package-lock.json create mode 100644 sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/partner-logos.ts create mode 100644 sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/preview-mode.ts create mode 100644 sandboxes/openclaw-nvidia/nemoclaw-ui-extension/preview/index.html diff --git a/brev/launch.sh b/brev/launch.sh index 4443172..9296266 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 qwen/qwen3.5-397b-a17b >/dev/null 2>&1; then + if "$CLI_BIN" inference set --provider nvidia-endpoints --model minimaxai/minimax-m2.5 >/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 qwen/qwen3.5-397b-a17b >/dev/null 2>&1; then + if "$CLI_BIN" cluster inference set --provider nvidia-endpoints --model minimaxai/minimax-m2.5 >/dev/null 2>&1; then log "Configured inference via legacy '$CLI_BIN cluster inference set'." return fi @@ -706,12 +706,6 @@ 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 \ diff --git a/brev/welcome-ui/SERVER_ARCHITECTURE.md b/brev/welcome-ui/SERVER_ARCHITECTURE.md index 34f7dad..fc6a762 100644 --- a/brev/welcome-ui/SERVER_ARCHITECTURE.md +++ b/brev/welcome-ui/SERVER_ARCHITECTURE.md @@ -148,7 +148,6 @@ main() ├── 1. _bootstrap_config_cache() │ If /tmp/nemoclaw-provider-config-cache.json does NOT exist: │ 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) │ @@ -436,13 +435,12 @@ Step 10: Cleanup temp policy file ``` Step 1: Log receipt (hash prefix) Step 2: Run CLI command: - nemoclaw provider update nvidia-inference \ - --type openai \ - --credential OPENAI_API_KEY= \ - --config OPENAI_BASE_URL=https://inference-api.nvidia.com/v1 + nemoclaw provider update nvidia-endpoints \ + --credential NVIDIA_API_KEY= \ + --config NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1 Timeout: 120s Step 3: If success: - - Cache config {"OPENAI_BASE_URL": "https://inference-api.nvidia.com/v1"} under name "nvidia-inference" + - Cache config under name "nvidia-endpoints" - State → "done" If failure: - State → "error" with stderr/stdout message @@ -537,10 +535,10 @@ Step 3: Merge with config cache values The CLI outputs text like: ``` Id: abc-123 -Name: nvidia-inference -Type: openai -Credential keys: OPENAI_API_KEY -Config keys: OPENAI_BASE_URL +Name: nvidia-endpoints +Type: nvidia +Credential keys: NVIDIA_API_KEY +Config keys: NVIDIA_BASE_URL ``` Parsing rules: @@ -560,11 +558,11 @@ After parsing, if the provider name has an entry in the config cache, a `configV "providers": [ { "id": "abc-123", - "name": "nvidia-inference", - "type": "openai", - "credentialKeys": ["OPENAI_API_KEY"], - "configKeys": ["OPENAI_BASE_URL"], - "configValues": {"OPENAI_BASE_URL": "https://inference-api.nvidia.com/v1"} + "name": "nvidia-endpoints", + "type": "nvidia", + "credentialKeys": ["NVIDIA_API_KEY"], + "configKeys": ["NVIDIA_BASE_URL"], + "configValues": {"NVIDIA_BASE_URL": "https://integrate.api.nvidia.com/v1"} } ] } @@ -671,7 +669,7 @@ nemoclaw cluster inference get **Output Parsing (`_parse_cluster_inference`):** ``` -Provider: nvidia-inference +Provider: nvidia-endpoints Model: meta/llama-3.1-70b-instruct Version: 2 ``` @@ -688,7 +686,7 @@ Version: 2 ```json { "ok": true, - "providerName": "nvidia-inference", + "providerName": "nvidia-endpoints", "modelId": "meta/llama-3.1-70b-instruct", "version": 2 } @@ -703,7 +701,7 @@ Version: 2 **Request Body:** ```json { - "providerName": "nvidia-inference", + "providerName": "nvidia-endpoints", "modelId": "meta/llama-3.1-70b-instruct" } ``` @@ -721,7 +719,7 @@ nemoclaw cluster inference set --provider --model ```json { "ok": true, - "providerName": "nvidia-inference", + "providerName": "nvidia-endpoints", "modelId": "meta/llama-3.1-70b-instruct", "version": 3 } @@ -925,7 +923,7 @@ The `nemoclaw provider get` CLI only returns config **key names**, not their val - Read on every `GET /api/providers` request - Written on every `POST` (create) and `PUT` (update) that includes config values - Cleaned up on `DELETE` -- Bootstrapped at server startup with a default for `nvidia-inference` +- Bootstrapped at server startup with a default for `nvidia-endpoints` --- @@ -948,8 +946,8 @@ Output is parsed the same way as provider detail (line-by-line, prefix matching, **Format:** ```json { - "nvidia-inference": { - "OPENAI_BASE_URL": "https://inference-api.nvidia.com/v1" + "nvidia-endpoints": { + "NVIDIA_BASE_URL": "https://integrate.api.nvidia.com/v1" }, "my-custom-provider": { "CUSTOM_URL": "https://example.com" @@ -1237,7 +1235,7 @@ This means it uses a local sandbox name rather than a container image reference. ### 18.10 Inject Key Hardcodes Provider Name -The `_run_inject_key` function hardcodes `nvidia-inference` as the provider name. This is not configurable via the API. +The `_run_inject_key` function hardcodes `nvidia-endpoints` as the provider name. This is not configurable via the API. ### 18.11 Error State Truncation diff --git a/brev/welcome-ui/__tests__/cli-parsing.test.js b/brev/welcome-ui/__tests__/cli-parsing.test.js index a4c38ae..50edb54 100644 --- a/brev/welcome-ui/__tests__/cli-parsing.test.js +++ b/brev/welcome-ui/__tests__/cli-parsing.test.js @@ -33,7 +33,7 @@ describe("parseProviderDetail", () => { const result = parseProviderDetail(FIXTURES.providerGetOutput); expect(result).toEqual({ id: "abc-123", - name: "nvidia-inference", + name: "nvidia-endpoints", type: "openai", credentialKeys: ["OPENAI_API_KEY"], configKeys: ["OPENAI_BASE_URL"], @@ -63,7 +63,7 @@ describe("parseProviderDetail", () => { it("TC-CL09: ANSI codes in output are stripped before parsing", () => { const result = parseProviderDetail(FIXTURES.providerGetAnsi); expect(result).not.toBeNull(); - expect(result.name).toBe("nvidia-inference"); + expect(result.name).toBe("nvidia-endpoints"); expect(result.type).toBe("openai"); }); }); @@ -72,7 +72,7 @@ describe("parseClusterInference", () => { it("TC-CL10: parses Provider, Model, Version lines", () => { const result = parseClusterInference(FIXTURES.clusterInferenceOutput); expect(result).toEqual({ - providerName: "nvidia-inference", + providerName: "nvidia-endpoints", modelId: "meta/llama-3.1-70b-instruct", version: 2, }); diff --git a/brev/welcome-ui/__tests__/cluster-inference.test.js b/brev/welcome-ui/__tests__/cluster-inference.test.js index 76f1450..7db882d 100644 --- a/brev/welcome-ui/__tests__/cluster-inference.test.js +++ b/brev/welcome-ui/__tests__/cluster-inference.test.js @@ -40,7 +40,7 @@ describe("GET /api/cluster-inference", () => { const res = await request(server).get("/api/cluster-inference"); expect(res.status).toBe(200); expect(res.body.ok).toBe(true); - expect(res.body.providerName).toBe("nvidia-inference"); + expect(res.body.providerName).toBe("nvidia-endpoints"); expect(res.body.modelId).toBe("meta/llama-3.1-70b-instruct"); expect(res.body.version).toBe(2); }); @@ -95,7 +95,7 @@ describe("GET /api/cluster-inference", () => { const res = await request(server).get("/api/cluster-inference"); expect(res.status).toBe(200); - expect(res.body.providerName).toBe("nvidia-inference"); + expect(res.body.providerName).toBe("nvidia-endpoints"); expect(res.body.modelId).toBe("meta/llama-3.1-70b-instruct"); }); }); diff --git a/brev/welcome-ui/__tests__/config-cache.test.js b/brev/welcome-ui/__tests__/config-cache.test.js index 36f48bd..e5e6ecd 100644 --- a/brev/welcome-ui/__tests__/config-cache.test.js +++ b/brev/welcome-ui/__tests__/config-cache.test.js @@ -19,7 +19,7 @@ describe("config cache", () => { bootstrapConfigCache(); const cache = readCacheFile(); expect(cache).not.toBeNull(); - expect(cache["nvidia-inference"]).toBeDefined(); + expect(cache["nvidia-endpoints"]).toBeDefined(); }); it("TC-CC02: bootstrapConfigCache is no-op when file already exists", () => { @@ -29,13 +29,10 @@ describe("config cache", () => { expect(cache).toEqual({ custom: { x: 1 } }); }); - it("TC-CC03: default bootstrap content seeds both NVIDIA inference providers", () => { + it("TC-CC03: default bootstrap content seeds NVIDIA endpoints provider", () => { 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 d03c410..e503978 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: updates both default inference providers with the submitted key", async () => { + it("TC-K10: updates default NVIDIA endpoints provider with the submitted key", async () => { execFile.mockImplementation((cmd, args, opts, cb) => { if (typeof opts === "function") { cb = opts; opts = {}; } cb(null, "", ""); @@ -161,15 +161,9 @@ describe("inject-key background process", () => { const updateCalls = execFile.mock.calls.filter( (c) => c[0] === "nemoclaw" && c[1]?.includes("update") ); - expect(updateCalls.length).toBeGreaterThanOrEqual(2); + expect(updateCalls.length).toBeGreaterThanOrEqual(1); - 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); @@ -245,7 +239,7 @@ describe("key hashing", () => { expect(hashKey("abc")).toBe(hashKey("abc")); }); - it("TC-K16: provider updates cover both nvidia-inference and nvidia-endpoints", async () => { + it("TC-K16: provider updates cover nvidia-endpoints", async () => { execFile.mockImplementation((cmd, args, opts, cb) => { if (typeof opts === "function") { cb = opts; opts = {}; } cb(null, "", ""); @@ -260,7 +254,6 @@ describe("key hashing", () => { const updateCalls = execFile.mock.calls.filter( (c) => c[0] === "nemoclaw" && c[1]?.includes("update") ); - 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/__tests__/providers.test.js b/brev/welcome-ui/__tests__/providers.test.js index a6f4462..a778e66 100644 --- a/brev/welcome-ui/__tests__/providers.test.js +++ b/brev/welcome-ui/__tests__/providers.test.js @@ -41,7 +41,7 @@ describe("GET /api/providers", () => { execFile.mockImplementation((cmd, args, opts, cb) => { if (typeof opts === "function") { cb = opts; opts = {}; } if (args?.[1] === "list") { - return cb(null, "nvidia-inference\n", ""); + return cb(null, "nvidia-endpoints\n", ""); } if (args?.[1] === "get") { return cb(null, FIXTURES.providerGetOutput, ""); @@ -54,7 +54,7 @@ describe("GET /api/providers", () => { expect(res.body.ok).toBe(true); expect(Array.isArray(res.body.providers)).toBe(true); expect(res.body.providers.length).toBe(1); - expect(res.body.providers[0].name).toBe("nvidia-inference"); + expect(res.body.providers[0].name).toBe("nvidia-endpoints"); }); it("TC-PR02: provider list CLI failure returns 502", async () => { diff --git a/brev/welcome-ui/__tests__/setup.js b/brev/welcome-ui/__tests__/setup.js index 6b9070e..1cd06ed 100644 --- a/brev/welcome-ui/__tests__/setup.js +++ b/brev/welcome-ui/__tests__/setup.js @@ -35,11 +35,11 @@ function readCacheFile() { // CLI output fixtures matching the nemoclaw CLI text format const FIXTURES = { - providerListOutput: "nvidia-inference\ncustom-provider\n", + providerListOutput: "nvidia-endpoints\ncustom-provider\n", providerGetOutput: [ "Id: abc-123", - "Name: nvidia-inference", + "Name: nvidia-endpoints", "Type: openai", "Credential keys: OPENAI_API_KEY", "Config keys: OPENAI_BASE_URL", @@ -55,19 +55,19 @@ const FIXTURES = { providerGetAnsi: "\x1b[32mId:\x1b[0m abc-123\n" + - "\x1b[32mName:\x1b[0m nvidia-inference\n" + + "\x1b[32mName:\x1b[0m nvidia-endpoints\n" + "\x1b[32mType:\x1b[0m openai\n" + "\x1b[32mCredential keys:\x1b[0m OPENAI_API_KEY\n" + "\x1b[32mConfig keys:\x1b[0m OPENAI_BASE_URL\n", clusterInferenceOutput: [ - "Provider: nvidia-inference", + "Provider: nvidia-endpoints", "Model: meta/llama-3.1-70b-instruct", "Version: 2", ].join("\n"), clusterInferenceAnsi: - "\x1b[1;34mProvider:\x1b[0m nvidia-inference\n" + + "\x1b[1;34mProvider:\x1b[0m nvidia-endpoints\n" + "\x1b[1;34mModel:\x1b[0m meta/llama-3.1-70b-instruct\n" + "\x1b[1;34mVersion:\x1b[0m 2\n", diff --git a/brev/welcome-ui/server.js b/brev/welcome-ui/server.js index 2441117..6ce5bed 100644 --- a/brev/welcome-ui/server.js +++ b/brev/welcome-ui/server.js @@ -278,9 +278,6 @@ function removeCachedProvider(name) { function bootstrapConfigCache() { if (fs.existsSync(PROVIDER_CONFIG_CACHE)) return; writeConfigCache({ - "nvidia-inference": { - OPENAI_BASE_URL: "https://inference-api.nvidia.com/v1", - }, "nvidia-endpoints": { NVIDIA_BASE_URL: "https://integrate.api.nvidia.com/v1", }, @@ -887,12 +884,6 @@ function runInjectKey(key, keyHash) { 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}`, diff --git a/sandboxes/openclaw-nvidia/.gitignore b/sandboxes/openclaw-nvidia/.gitignore index 4c2fcb0..451574a 100644 --- a/sandboxes/openclaw-nvidia/.gitignore +++ b/sandboxes/openclaw-nvidia/.gitignore @@ -1,2 +1,6 @@ # Synced from brev/nemoclaw-ui-extension/extension/ at build time — do not edit here. nemoclaw-devx/ + +# Local UI preview build artifacts +nemoclaw-ui-extension/preview/nemoclaw-devx.js +nemoclaw-ui-extension/preview/nemoclaw-devx.css diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/dev-preview.sh b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/dev-preview.sh new file mode 100755 index 0000000..bdc9b48 --- /dev/null +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/dev-preview.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Local UI preview — no Docker. Builds the extension and serves preview/index.html +# so you can see UI changes without rebuilding the full image. +# +# Usage: +# ./dev-preview.sh # build once and serve (Ctrl+C to stop) +# ./dev-preview.sh --watch # rebuild on file changes +# +# Open http://localhost:5173 (or the port shown). The page auto-adds ?preview=1 +# so the extension skips the pairing overlay and shows the UI immediately. + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +EXT_DIR="$SCRIPT_DIR/extension" +PREVIEW_DIR="$SCRIPT_DIR/preview" +OUT_JS="$PREVIEW_DIR/nemoclaw-devx.js" +OUT_CSS="$PREVIEW_DIR/nemoclaw-devx.css" +PORT="${PORT:-5173}" +WATCH="" + +for arg in "$@"; do + case "$arg" in + --watch) WATCH=1 ;; + esac +done + +mkdir -p "$PREVIEW_DIR" + +build() { + (cd "$EXT_DIR" && npm install --production 2>/dev/null || true) + # Bundle JS; CSS import is stubbed so we link styles separately below. + npx --yes esbuild "$EXT_DIR/index.ts" \ + --bundle \ + --format=esm \ + --outfile="$OUT_JS" \ + --loader:.css=empty + cp "$EXT_DIR/styles.css" "$OUT_CSS" + echo "[dev-preview] Built $OUT_JS and $OUT_CSS" +} + +build + +if [[ -n "$WATCH" ]]; then + echo "[dev-preview] Watching extension/*.ts — edit and refresh. For CSS changes, re-run without --watch or copy extension/styles.css to preview/nemoclaw-devx.css" + npx --yes esbuild "$EXT_DIR/index.ts" \ + --bundle --format=esm --outfile="$OUT_JS" --loader:.css=empty \ + --watch & + ESBUILD_PID=$! + trap 'kill $ESBUILD_PID 2>/dev/null' EXIT + sleep 1 +fi + +echo "[dev-preview] Serving at http://localhost:$PORT" +echo "[dev-preview] Open that URL (page auto-adds ?preview=1). Edit extension files and refresh to see UI changes." +if command -v python3 >/dev/null 2>&1; then + (cd "$PREVIEW_DIR" && python3 -m http.server "$PORT") +elif command -v npx >/dev/null 2>&1; then + (cd "$PREVIEW_DIR" && npx --yes serve -l "$PORT") +else + echo "Install python3 or Node to run a local server, or open preview/index.html in a browser after running the build step manually." + exit 1 +fi diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/api-keys-page.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/api-keys-page.ts index 29fc9ac..4719b77 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/api-keys-page.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/api-keys-page.ts @@ -1,19 +1,43 @@ /** - * NeMoClaw DevX — API Keys Settings Page + * NeMoClaw DevX — Environment variables (Inference tab section) * - * Full-page overlay for entering and persisting NVIDIA API keys. - * Keys are stored in localStorage and resolved at call time by - * model-registry.ts getter functions. + * Builds the Environment variables form for the Inference page. Keys are stored in + * localStorage and resolved at call time by model-registry.ts. */ -import { ICON_KEY, ICON_EYE, ICON_EYE_OFF, ICON_CHECK, ICON_LOADER, ICON_CLOSE } from "./icons.ts"; -import { - getInferenceApiKey, - getIntegrateApiKey, - setInferenceApiKey, - setIntegrateApiKey, - isKeyConfigured, -} from "./model-registry.ts"; +import { ICON_EYE, ICON_EYE_OFF, ICON_CHECK, ICON_LOADER, ICON_CLOSE, ICON_PLUS, ICON_TRASH } from "./icons.ts"; +import { getIntegrateApiKey, setIntegrateApiKey, isKeyConfigured } from "./model-registry.ts"; +import { isPreviewMode } from "./preview-mode.ts"; + +const CUSTOM_KEYS_STORAGE_KEY = "nemoclaw:api-keys-custom"; + +function getCustomKeys(): Record { + try { + const raw = localStorage.getItem(CUSTOM_KEYS_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + return typeof parsed === "object" && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +function setCustomKeys(keys: Record): void { + localStorage.setItem(CUSTOM_KEYS_STORAGE_KEY, JSON.stringify(keys)); +} + +function setCustomKey(keyName: string, value: string): void { + const keys = getCustomKeys(); + if (value) keys[keyName] = value; + else delete keys[keyName]; + setCustomKeys(keys); +} + +function removeCustomKey(keyName: string): void { + const keys = getCustomKeys(); + delete keys[keyName]; + setCustomKeys(keys); +} // --------------------------------------------------------------------------- // Key field definitions @@ -30,20 +54,11 @@ interface KeyFieldDef { } const KEY_FIELDS: KeyFieldDef[] = [ - { - id: "inference", - label: "Inference API Key", - description: "For inference-api.nvidia.com — powers NVIDIA Claude Opus 4.6", - placeholder: "nvapi-...", - serverCredentialKey: "OPENAI_API_KEY", - get: getInferenceApiKey, - set: setInferenceApiKey, - }, { id: "integrate", - label: "Integrate API Key", - description: "For integrate.api.nvidia.com — powers Kimi K2.5, Nemotron Ultra, DeepSeek V3.2", - placeholder: "nvapi-...", + label: "NVIDIA_API_KEY", + description: "NVIDIA API key (e.g. Integrate). Get keys at build.nvidia.com.", + placeholder: "Paste value", serverCredentialKey: "NVIDIA_API_KEY", get: getIntegrateApiKey, set: setIntegrateApiKey, @@ -61,11 +76,11 @@ interface ProviderSummary { } /** - * Push localStorage API keys to every server-side provider whose credential - * key matches. This bridges the gap between the browser-only API Keys tab - * and the NemoClaw proxy which reads credentials from the server-side store. + * Push all Environment variables (built-in + custom) to server-side providers whose + * credential key matches. Used when saving keys from the Inference tab. */ export async function syncKeysToProviders(): Promise { + if (isPreviewMode()) return; const res = await fetch("/api/providers"); if (!res.ok) throw new Error(`Failed to fetch providers: ${res.status}`); const body = await res.json(); @@ -73,12 +88,13 @@ export async function syncKeysToProviders(): Promise { const providers: ProviderSummary[] = body.providers || []; const errors: string[] = []; + const allKeyNames = getSectionCredentialKeyNames(); for (const provider of providers) { - for (const field of KEY_FIELDS) { - const key = field.get(); - if (!isKeyConfigured(key)) continue; - if (!provider.credentialKeys?.includes(field.serverCredentialKey)) continue; + for (const keyName of allKeyNames) { + if (!provider.credentialKeys?.includes(keyName)) continue; + const value = getSectionKeyValue(keyName); + if (!isKeyConfigured(value)) continue; try { const updateRes = await fetch(`/api/providers/${encodeURIComponent(provider.name)}`, { @@ -86,7 +102,7 @@ export async function syncKeysToProviders(): Promise { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: provider.type, - credentials: { [field.serverCredentialKey]: key }, + credentials: { [keyName]: value }, config: {}, }), }); @@ -106,48 +122,59 @@ export async function syncKeysToProviders(): Promise { } // --------------------------------------------------------------------------- -// Render the API Keys page into a container element +// Build Environment variables section for Inference tab // --------------------------------------------------------------------------- -export function renderApiKeysPage(container: HTMLElement): void { - container.innerHTML = ` -
-
-
API Keys
-
Configure your NVIDIA API keys for model endpoints
-
-
-
`; - - const page = container.querySelector(".nemoclaw-key-page")!; - - const intro = document.createElement("div"); - intro.className = "nemoclaw-key-intro"; - intro.innerHTML = ` -
${ICON_KEY}
-

- Enter your NVIDIA API keys to enable model switching and DGX deployment. - Keys are stored locally in your browser and never sent to third parties. -

- - Get your keys at build.nvidia.com → - `; - page.appendChild(intro); +export function buildApiKeysSection(): HTMLElement { + const section = document.createElement("div"); + section.className = "nemoclaw-inference-apikeys"; + + const heading = document.createElement("div"); + heading.className = "nemoclaw-inference-apikeys__heading"; + heading.innerHTML = `Environment variables`; + section.appendChild(heading); + + const intro = document.createElement("p"); + intro.className = "nemoclaw-inference-apikeys__intro"; + intro.textContent = "Env vars (e.g. API keys) used by providers. Values are synced to matching provider credentials. You can also set or override per-provider in the forms above."; + intro.textContent = "Env vars (e.g. API keys) used by providers. Values are synced to matching provider credentials. You can also set or override per-provider in the forms above. X’ll be synced to matching providers. You can also enter or override keys per-provider in the forms above."; + section.appendChild(intro); const form = document.createElement("div"); - form.className = "nemoclaw-key-form"; + form.className = "nemoclaw-key-form nemoclaw-inference-apikeys__form"; - for (const field of KEY_FIELDS) { - form.appendChild(buildKeyField(field)); + const allKeyNames = getSectionCredentialKeyNames(); + for (const keyName of allKeyNames) { + const field = KEY_FIELDS.find((f) => f.serverCredentialKey === keyName); + const label = field ? field.label : keyName; + form.appendChild(buildKeyRow(section, keyName, label, !!field)); } + const addKeyRow = document.createElement("div"); + addKeyRow.className = "nemoclaw-inference-apikeys__add-row"; + const addKeyBtn = document.createElement("button"); + addKeyBtn.type = "button"; + addKeyBtn.className = "nemoclaw-policy-add-small-btn"; + addKeyBtn.innerHTML = `${ICON_PLUS} Add variable`; + addKeyBtn.addEventListener("click", () => { + const existing = form.querySelector(".nemoclaw-inference-apikeys__add-form"); + if (existing) { + existing.remove(); + return; + } + const addForm = buildAddKeyForm(section, form, addKeyRow); + form.insertBefore(addForm, addKeyRow); + }); + addKeyRow.appendChild(addKeyBtn); + form.appendChild(addKeyRow); + const actions = document.createElement("div"); actions.className = "nemoclaw-key-actions"; const saveBtn = document.createElement("button"); saveBtn.type = "button"; saveBtn.className = "nemoclaw-key-save"; - saveBtn.textContent = "Save Keys"; + saveBtn.textContent = "Save"; const feedback = document.createElement("div"); feedback.className = "nemoclaw-key-feedback"; @@ -156,28 +183,26 @@ export function renderApiKeysPage(container: HTMLElement): void { actions.appendChild(saveBtn); actions.appendChild(feedback); form.appendChild(actions); - page.appendChild(form); + section.appendChild(form); saveBtn.addEventListener("click", async () => { - for (const field of KEY_FIELDS) { - const input = form.querySelector(`[data-key-id="${field.id}"]`); - if (input) field.set(input.value.trim()); - } - - updateStatusDots(); + form.querySelectorAll("input[data-api-key-name]").forEach((input) => { + const keyName = input.dataset.apiKeyName; + if (keyName) setSectionKeyValue(keyName, input.value.trim()); + }); feedback.className = "nemoclaw-key-feedback nemoclaw-key-feedback--saving"; - feedback.innerHTML = `${ICON_LOADER}Syncing keys to providers\u2026`; + feedback.innerHTML = `${ICON_LOADER}Syncing to providers\u2026`; saveBtn.disabled = true; try { await syncKeysToProviders(); feedback.className = "nemoclaw-key-feedback nemoclaw-key-feedback--success"; - feedback.innerHTML = `${ICON_CHECK}Keys saved & synced to providers`; + feedback.innerHTML = `${ICON_CHECK}Saved & synced`; } catch (err) { console.warn("[NeMoClaw] Provider key sync failed:", err); feedback.className = "nemoclaw-key-feedback nemoclaw-key-feedback--error"; - feedback.innerHTML = `${ICON_CLOSE}Keys saved locally but sync failed`; + feedback.innerHTML = `${ICON_CLOSE}Saved locally; sync failed`; } finally { saveBtn.disabled = false; setTimeout(() => { @@ -186,76 +211,157 @@ export function renderApiKeysPage(container: HTMLElement): void { }, 4000); } }); + + return section; } // --------------------------------------------------------------------------- -// Build a single key input field +// Key row and add-key form // --------------------------------------------------------------------------- -function buildKeyField(def: KeyFieldDef): HTMLElement { +function buildKeyRow(section: HTMLElement, keyName: string, label: string, _isBuiltIn: boolean): HTMLElement { + const value = getSectionKeyValue(keyName); const wrapper = document.createElement("div"); - wrapper.className = "nemoclaw-key-field"; - - const currentValue = def.get(); - const displayValue = isKeyConfigured(currentValue) ? currentValue : ""; - - const statusClass = isKeyConfigured(currentValue) - ? "nemoclaw-key-dot--ok" - : "nemoclaw-key-dot--missing"; - - wrapper.innerHTML = ` -
- -
-

${def.description}

-
- - -
`; - - const input = wrapper.querySelector("input")!; - const toggle = wrapper.querySelector(".nemoclaw-key-field__toggle")!; - let visible = false; - - toggle.addEventListener("click", () => { - visible = !visible; - input.type = visible ? "text" : "password"; - toggle.innerHTML = visible ? ICON_EYE_OFF : ICON_EYE; + wrapper.className = "nemoclaw-key-field nemoclaw-inference-apikeys__key-row"; + wrapper.dataset.apiKeyName = keyName; + + const statusClass = isKeyConfigured(value) ? "nemoclaw-key-dot--ok" : "nemoclaw-key-dot--missing"; + const header = document.createElement("div"); + header.className = "nemoclaw-key-field__header nemoclaw-inference-apikeys__key-row-header"; + header.innerHTML = ` + + `; + + const inputRow = document.createElement("div"); + inputRow.className = "nemoclaw-key-field__input-row"; + const input = document.createElement("input"); + input.type = "password"; + input.className = "nemoclaw-policy-input nemoclaw-key-field__input"; + input.placeholder = "Paste value"; + input.value = value; + input.dataset.apiKeyName = keyName; + input.autocomplete = "off"; + input.spellcheck = false; + input.addEventListener("input", () => { + const dot = wrapper.querySelector(".nemoclaw-key-dot"); + if (dot) { + dot.classList.toggle("nemoclaw-key-dot--ok", isKeyConfigured(input.value.trim())); + dot.classList.toggle("nemoclaw-key-dot--missing", !isKeyConfigured(input.value.trim())); + } + }); + + const toggleBtn = document.createElement("button"); + toggleBtn.type = "button"; + toggleBtn.className = "nemoclaw-key-field__toggle"; + toggleBtn.innerHTML = ICON_EYE; + toggleBtn.addEventListener("click", () => { + const isHidden = input.type === "password"; + input.type = isHidden ? "text" : "password"; + toggleBtn.innerHTML = isHidden ? ICON_EYE_OFF : ICON_EYE; + }); + inputRow.appendChild(input); + inputRow.appendChild(toggleBtn); + + wrapper.appendChild(header); + wrapper.appendChild(inputRow); + + const deleteBtn = wrapper.querySelector(".nemoclaw-inference-apikeys__key-row-delete"); + deleteBtn?.addEventListener("click", () => { + removeSectionKey(keyName); + section.replaceWith(buildApiKeysSection()); }); return wrapper; } -function escapeAttr(s: string): string { - return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); +function buildAddKeyForm(_section: HTMLElement, form: HTMLElement, addKeyRow: HTMLElement): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "nemoclaw-inference-apikeys__add-form"; + + const keyInput = document.createElement("input"); + keyInput.type = "text"; + keyInput.className = "nemoclaw-policy-input"; + keyInput.placeholder = "Name (e.g. OPENAI_API_KEY)"; + + const valInput = document.createElement("input"); + valInput.type = "password"; + valInput.className = "nemoclaw-policy-input"; + valInput.placeholder = "Value"; + + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--create"; + addBtn.textContent = "Add"; + + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--cancel"; + cancelBtn.textContent = "Cancel"; + + addBtn.addEventListener("click", () => { + const keyName = keyInput.value.trim(); + const value = valInput.value.trim(); + if (!keyName) return; + const builtIn = KEY_FIELDS.find((f) => f.serverCredentialKey === keyName); + const custom = getCustomKeys(); + if (builtIn || custom[keyName]) { + setSectionKeyValue(keyName, value); + } else { + setCustomKey(keyName, value); + } + wrap.remove(); + const section = form.closest(".nemoclaw-inference-apikeys"); + if (section) section.replaceWith(buildApiKeysSection()); + }); + cancelBtn.addEventListener("click", () => wrap.remove()); + + wrap.appendChild(keyInput); + wrap.appendChild(valInput); + wrap.appendChild(addBtn); + wrap.appendChild(cancelBtn); + return wrap; } -// --------------------------------------------------------------------------- -// Status dots — update all nav-item dots to reflect current key state -// --------------------------------------------------------------------------- +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} export function areAllKeysConfigured(): boolean { return KEY_FIELDS.every((f) => isKeyConfigured(f.get())); } -export function updateStatusDots(): void { - const dot = document.querySelector('[data-nemoclaw-page="nemoclaw-api-keys"] .nemoclaw-nav-dot'); - if (!dot) return; - const ok = areAllKeysConfigured(); - dot.classList.toggle("nemoclaw-nav-dot--ok", ok); - dot.classList.toggle("nemoclaw-nav-dot--missing", !ok); +/** Credential key names (e.g. NVIDIA_API_KEY) that the Environment variables section can provide. */ +export function getSectionCredentialKeyNames(): string[] { + const builtIn = KEY_FIELDS.map((f) => f.serverCredentialKey); + const custom = Object.keys(getCustomKeys()); + return [...builtIn, ...custom]; +} + +/** Key names and display labels for the Environment variables section (for dropdowns). */ +export function getSectionCredentialEntries(): { keyName: string; label: string }[] { + const builtIn = KEY_FIELDS.map((f) => ({ keyName: f.serverCredentialKey, label: f.label })); + const custom = Object.keys(getCustomKeys()).map((keyName) => ({ keyName, label: keyName })); + return [...builtIn, ...custom]; +} + +/** Value for a credential key from the Environment variables section, or empty if not set. */ +export function getSectionKeyValue(keyName: string): string { + const field = KEY_FIELDS.find((f) => f.serverCredentialKey === keyName); + if (field) return field.get(); + return getCustomKeys()[keyName] ?? ""; +} + +export function setSectionKeyValue(keyName: string, value: string): void { + const field = KEY_FIELDS.find((f) => f.serverCredentialKey === keyName); + if (field) field.set(value); + else setCustomKey(keyName, value); +} + +export function removeSectionKey(keyName: string): void { + const field = KEY_FIELDS.find((f) => f.serverCredentialKey === keyName); + if (field) field.set(""); + else removeCustomKey(keyName); } diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/denial-watcher.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/denial-watcher.ts new file mode 100644 index 0000000..c60a61e --- /dev/null +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/denial-watcher.ts @@ -0,0 +1,235 @@ +/** + * NeMoClaw DevX — Sandbox Denial Watcher + * + * Polls the policy-proxy for sandbox network denial events and injects + * a single chat-style card above the compose area. The card lists blocked + * connections (newest nearest to input), with compact rows and one CTA to + * Sandbox Policy. A scrollable list keeps many denials visible without + * flooding the chat. + */ + +import { ICON_SHIELD, ICON_CLOSE } from "./icons.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface DenialEvent { + ts: number; + host: string; + port: number; + binary: string; + reason: string; +} + +interface DenialsResponse { + denials: DenialEvent[]; + latest_ts: number; + error?: string; +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +const POLL_INTERVAL_MS = 3_000; + +let lastTs = 0; +let pollTimer: ReturnType | null = null; +let seenKeys = new Set(); +let activeDenials: DenialEvent[] = []; +let container: HTMLElement | null = null; +let running = false; + +function denialKey(d: DenialEvent): string { + return `${d.host}:${d.port}:${d.binary}`; +} + +function binaryBasename(path: string): string { + const parts = path.split("/"); + return parts[parts.length - 1] || path; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +// --------------------------------------------------------------------------- +// API +// --------------------------------------------------------------------------- + +async function fetchDenials(sinceMs: number): Promise { + const res = await fetch(`/api/sandbox-denials?since=${sinceMs}`); + if (!res.ok) return { denials: [], latest_ts: sinceMs }; + return res.json(); +} + +// --------------------------------------------------------------------------- +// Single card with compact rows (above compose) +// --------------------------------------------------------------------------- + +function findChatCompose(): HTMLElement | null { + return document.querySelector(".chat-compose"); +} + +function getOrCreateContainer(): HTMLElement | null { + const chatCompose = findChatCompose(); + if (!chatCompose?.parentElement) return null; + + if (container?.parentElement) return container; + + container = document.createElement("div"); + container.className = "nemoclaw-sandbox-denials"; + container.setAttribute("role", "status"); + chatCompose.parentElement.insertBefore(container, chatCompose); + return container; +} + +/** Order by ts ascending so newest is last (nearest to input). */ +function sortedDenials(): DenialEvent[] { + return [...activeDenials].sort((a, b) => a.ts - b.ts); +} + +function createRow(denial: DenialEvent): HTMLElement { + const bin = binaryBasename(denial.binary); + const portSuffix = denial.port === 443 || denial.port === 80 ? "" : `:${denial.port}`; + const row = document.createElement("div"); + row.className = "nemoclaw-sandbox-denial-row"; + row.setAttribute("data-denial-key", denialKey(denial)); + row.innerHTML = ` + Request blocked: ${escapeHtml(bin)}${escapeHtml(denial.host)}${escapeHtml(portSuffix)} + `; + const dismissBtn = row.querySelector(".nemoclaw-sandbox-denial-row__dismiss"); + if (dismissBtn) { + dismissBtn.addEventListener("click", (e) => { + e.stopPropagation(); + dismissRow(row); + }); + } + return row; +} + +function dismissRow(row: HTMLElement): void { + const key = row.getAttribute("data-denial-key"); + if (key) { + seenKeys.delete(key); + activeDenials = activeDenials.filter((d) => denialKey(d) !== key); + } + renderDenialMessages(); +} + +function renderDenialMessages(): void { + const parent = getOrCreateContainer(); + if (!parent) return; + + if (activeDenials.length === 0) { + if (container?.parentElement) { + container.remove(); + container = null; + } + return; + } + + const n = activeDenials.length; + const label = n === 1 ? "1 blocked request" : `${n} blocked requests`; + + parent.innerHTML = ""; + parent.className = "nemoclaw-sandbox-denials"; + + const card = document.createElement("div"); + card.className = "nemoclaw-sandbox-denial-card"; + card.innerHTML = ` +
+ ${ICON_SHIELD} + OpenShell Sandbox — ${escapeHtml(label)} +
+
+
+
+ Add allow rules in Sandbox Policy to continue. +
`; + + const list = card.querySelector(".nemoclaw-sandbox-denials__list")!; + const ordered = sortedDenials(); + for (const denial of ordered) { + list.appendChild(createRow(denial)); + } + + parent.appendChild(card); +} + +function injectDenialAsMessage(denial: DenialEvent): void { + const key = denialKey(denial); + if (seenKeys.has(key)) return; + seenKeys.add(key); + activeDenials.push(denial); + renderDenialMessages(); +} + +/** + * Clear denial UI and state. + * @param keepSeenKeys - If true, do not clear seenKeys so the same denials + * won't be re-shown on next poll (use when policy was just saved/approved). + */ +function clearAllDenialMessages(keepSeenKeys = false): void { + if (!keepSeenKeys) seenKeys.clear(); + activeDenials = []; + if (container?.parentElement) { + container.remove(); + container = null; + } +} + +// --------------------------------------------------------------------------- +// Poll loop +// --------------------------------------------------------------------------- + +async function poll(): Promise { + try { + const data = await fetchDenials(lastTs); + if (data.latest_ts > lastTs) lastTs = data.latest_ts; + + for (const denial of data.denials) { + injectDenialAsMessage(denial); + } + } catch { + // Non-fatal — will retry on next poll + } + + if (running) { + pollTimer = setTimeout(poll, POLL_INTERVAL_MS); + } +} + +// --------------------------------------------------------------------------- +// Policy-saved event handler +// --------------------------------------------------------------------------- + +function onPolicySaved(): void { + clearAllDenialMessages(true); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function startDenialWatcher(): void { + if (running) return; + running = true; + + lastTs = Date.now() - 60_000; + + document.addEventListener("nemoclaw:policy-saved", onPolicySaved); + + poll(); +} + +export function stopDenialWatcher(): void { + running = false; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + document.removeEventListener("nemoclaw:policy-saved", onPolicySaved); + clearAllDenialMessages(); +} diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/deploy-modal.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/deploy-modal.ts index fb95e87..99655f0 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/deploy-modal.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/deploy-modal.ts @@ -13,7 +13,7 @@ import { ICON_CHIP, TARGET_ICONS, } from "./icons.ts"; -import { DEPLOY_TARGETS, getApiKey, isKeyConfigured, type DeployTarget } from "./model-registry.ts"; +import { DEPLOY_TARGETS, getApiKey, getUpgradeIntegrationsUrl, isKeyConfigured, type DeployTarget } from "./model-registry.ts"; // --------------------------------------------------------------------------- // State @@ -82,6 +82,7 @@ function buildModal(): HTMLElement { Choose a deployment target to provision your OpenClaw agent on NVIDIA DGX hardware.

${targetsHtml}
+

Need more throughput? Use a partner and add them in Inference.

`; @@ -109,6 +110,16 @@ function buildModal(): HTMLElement { if ((e as KeyboardEvent).key === "Escape") closeModal(); }); + // Set partner link to include current model so landing page can preselect it + fetch("/api/cluster-inference") + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { + const modelId = body?.ok && body?.modelId ? body.modelId : ""; + const link = overlay.querySelector(".nemoclaw-modal__partner-link"); + if (link) link.href = getUpgradeIntegrationsUrl(modelId); + }) + .catch(() => {}); + return overlay; } @@ -154,7 +165,7 @@ function disableTargets(overlay: HTMLElement, disabled: boolean) { async function handleDeploy(target: DeployTarget, overlay: HTMLElement) { const apiKey = getApiKey(target); if (!isKeyConfigured(apiKey)) { - setStatus(overlay, "error", `API key not configured. Add your keys to get started.`); + setStatus(overlay, "error", `API key not configured. Add your keys in Inference to get started.`); return; } diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/gateway-bridge.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/gateway-bridge.ts index de60dee..eafb43e 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/gateway-bridge.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/gateway-bridge.ts @@ -196,39 +196,3 @@ export function waitForStableConnection( void tick(); }); } - -/** - * Wait until the app remains connected for a continuous stability window. - * - * This helps distinguish "socket connected for a moment" from "dashboard is - * actually ready to be revealed after pairing/bootstrap settles". - */ -export function waitForStableConnection( - stableForMs = 3_000, - timeoutMs = 15_000, -): Promise { - return new Promise((resolve, reject) => { - const start = Date.now(); - let connectedSince = isAppConnected() ? Date.now() : 0; - - const interval = setInterval(() => { - const now = Date.now(); - - if (isAppConnected()) { - if (!connectedSince) connectedSince = now; - if (now - connectedSince >= stableForMs) { - clearInterval(interval); - resolve(); - return; - } - } else { - connectedSince = 0; - } - - if (now - start > timeoutMs) { - clearInterval(interval); - reject(new Error("Timed out waiting for stable gateway connection")); - } - }, 500); - }); -} diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts index 016228f..b40c8f7 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts @@ -3,8 +3,7 @@ * * Injects into the OpenClaw UI: * 1. A green "Deploy DGX Spark/Station" CTA button in the topbar - * 2. A "NeMoClaw" collapsible nav group with Policy, Inference Routes, - * and API Keys pages + * 2. A "NeMoClaw" collapsible nav group with Policy and Inference * 3. A model selector wired to NVIDIA endpoints * * Operates purely as an overlay — no original OpenClaw source files are modified. @@ -17,6 +16,8 @@ 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"; +import { startDenialWatcher } from "./denial-watcher.ts"; +import { isPreviewMode } from "./preview-mode.ts"; const STABLE_CONNECTION_WINDOW_MS = 1_500; const INITIAL_CONNECTION_TIMEOUT_MS = 20_000; @@ -114,6 +115,7 @@ function revealApp(): void { overlay.addEventListener("transitionend", () => overlay.remove(), { once: true }); setTimeout(() => overlay.remove(), 600); } + startDenialWatcher(); } function shouldAllowRecoveryReload(): boolean { @@ -184,6 +186,27 @@ function getOverlayTextForPairingState(state: PairingBootstrapState | null): str } function bootstrap() { + // Preview mode: no gateway, no pairing overlay — show UI immediately for local dev. + if (isPreviewMode()) { + document.body.setAttribute("data-nemoclaw-ready", ""); + watchOpenClawNavClicks(); + watchChatCompose(); + watchGotoLinks(); + if (inject()) { + injectModelSelector(); + return; + } + const observer = new MutationObserver(() => { + if (inject()) { + injectModelSelector(); + observer.disconnect(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + setTimeout(() => observer.disconnect(), 30_000); + return; + } + console.info("[NeMoClaw] pairing bootstrap: start"); let pairingPollTimer = 0; diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/inference-page.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/inference-page.ts index 2c30b13..680a270 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/inference-page.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/inference-page.ts @@ -26,7 +26,12 @@ import { refreshModelSelector, setActiveModelFromExternal } from "./model-select import { CURATED_MODELS, getCuratedByModelId, + getUpgradeIntegrationsUrl, + PARTNER_PROVIDERS, } from "./model-registry.ts"; +import { getPartnerLogoImgSrc } from "./partner-logos.ts"; +import { buildApiKeysSection, getSectionCredentialEntries, getSectionCredentialKeyNames, getSectionKeyValue } from "./api-keys-page.ts"; +import { isPreviewMode } from "./preview-mode.ts"; // --------------------------------------------------------------------------- // Types @@ -54,6 +59,9 @@ interface ProviderDraft { type: string; credentials: Record; config: Record; + /** When "section", use value from API keys section below when saving. */ + /** For each credential key: "__custom__" = use draft.credentials; otherwise use section key value (e.g. OPENAI_API_KEY). */ + _credentialSource?: Record; } interface ProviderProfile { @@ -99,6 +107,12 @@ const PROVIDER_TEMPLATES: { label: string; name: string; type: string; config: R { label: "OpenAI", name: "openai", type: "openai", config: { OPENAI_BASE_URL: "https://api.openai.com/v1" } }, { label: "Anthropic", name: "anthropic", type: "anthropic", config: { ANTHROPIC_BASE_URL: "https://api.anthropic.com/v1" } }, { label: "Local (LM Studio)", name: "local_lmstudio", type: "openai", config: { OPENAI_BASE_URL: "http://localhost:1234/v1" } }, + ...PARTNER_PROVIDERS.map((p) => ({ + label: p.name, + name: `partner_${p.id}`, + type: "openai" as const, + config: { [p.configUrlKey]: p.baseUrl }, + })), ]; const PROVIDER_TYPE_OPTIONS = ["openai", "anthropic", "nvidia"]; @@ -125,6 +139,7 @@ let providersExpanded = true; // --------------------------------------------------------------------------- async function fetchProviders(): Promise { + if (isPreviewMode()) return []; const res = await fetch("/api/providers"); if (!res.ok) throw new Error(`Failed to load providers: ${res.status}`); const body = await res.json(); @@ -133,6 +148,7 @@ async function fetchProviders(): Promise { } async function apiCreateProvider(draft: { name: string; type: string; credentials: Record; config: Record }): Promise { + if (isPreviewMode()) return; const res = await fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -143,6 +159,7 @@ async function apiCreateProvider(draft: { name: string; type: string; credential } async function apiUpdateProvider(name: string, draft: { type: string; credentials: Record; config: Record }): Promise { + if (isPreviewMode()) return; const res = await fetch(`/api/providers/${encodeURIComponent(name)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -153,12 +170,14 @@ async function apiUpdateProvider(name: string, draft: { type: string; credential } async function apiDeleteProvider(name: string): Promise { + if (isPreviewMode()) return; const res = await fetch(`/api/providers/${encodeURIComponent(name)}`, { method: "DELETE" }); const body = await res.json(); if (!body.ok) throw new Error(body.error || "Delete failed"); } async function fetchClusterInference(): Promise { + if (isPreviewMode()) return null; const res = await fetch("/api/cluster-inference"); if (!res.ok) return null; const body = await res.json(); @@ -167,6 +186,7 @@ async function fetchClusterInference(): Promise { } async function apiSetClusterInference(providerName: string, modelId: string): Promise { + if (isPreviewMode()) return; const res = await fetch("/api/cluster-inference", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -243,9 +263,9 @@ async function loadAndRender(container: HTMLElement): Promise { function renderPageContent(page: HTMLElement): void { page.innerHTML = ""; page.appendChild(buildGatewayStrip()); - page.appendChild(buildQuickPicker()); page.appendChild(buildActiveConfig()); page.appendChild(buildProviderSection()); + page.appendChild(buildApiKeysSection()); saveBarEl = buildSaveBar(); page.appendChild(saveBarEl); } @@ -321,44 +341,38 @@ function saveCustomQuickSelects(items: { modelId: string; name: string; provider localStorage.setItem("nemoclaw:custom-quick-selects", JSON.stringify(items)); } -function buildQuickPicker(): HTMLElement { - const section = document.createElement("div"); - section.className = "nc-quick-picker"; - - const label = document.createElement("div"); - label.className = "nc-quick-picker__label"; - label.textContent = "Quick Select"; - section.appendChild(label); +/** Builds only the chip strip (no "Quick Select" label, no Add). Used inside Active Config Model row. */ +function buildQuickPickerStrip(currentModelId: string, onRefresh: () => void): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "nc-active-config__model-quick-strip"; const strip = document.createElement("div"); strip.className = "nc-quick-picker__strip"; - const currentModelId = pendingActivation?.modelId ?? activeRoute?.modelId ?? ""; - for (const curated of CURATED_MODELS) { - strip.appendChild(buildQuickChip(curated.modelId, curated.name, curated.providerName, currentModelId, section, false)); + strip.appendChild(buildQuickChip(curated.modelId, curated.name, curated.providerName, currentModelId, null, false, onRefresh)); } const custom = getCustomQuickSelects(); const curatedIds = new Set(CURATED_MODELS.map((c) => c.modelId)); for (const item of custom) { if (curatedIds.has(item.modelId)) continue; - strip.appendChild(buildQuickChip(item.modelId, item.name, item.providerName, currentModelId, section, true)); + strip.appendChild(buildQuickChip(item.modelId, item.name, item.providerName, currentModelId, null, true, onRefresh)); } - section.appendChild(strip); - - const addBtn = document.createElement("button"); - addBtn.type = "button"; - addBtn.className = "nc-quick-picker__add-btn"; - addBtn.innerHTML = `${ICON_PLUS} Add`; - addBtn.addEventListener("click", () => showAddQuickSelectForm(section)); - section.appendChild(addBtn); - - return section; + wrap.appendChild(strip); + return wrap; } -function buildQuickChip(modelId: string, name: string, providerName: string, currentModelId: string, section: HTMLElement, removable: boolean): HTMLElement { +function buildQuickChip( + modelId: string, + name: string, + providerName: string, + currentModelId: string, + section: HTMLElement | null, + removable: boolean, + onActivate?: () => void, +): HTMLElement { const chip = document.createElement("button"); chip.type = "button"; const isActive = modelId === currentModelId; @@ -378,7 +392,7 @@ function buildQuickChip(modelId: string, name: string, providerName: string, cur e.stopPropagation(); const items = getCustomQuickSelects().filter((i) => i.modelId !== modelId); saveCustomQuickSelects(items); - chip.remove(); + if (onActivate) onActivate(); refreshModelSelector().catch(() => {}); }); chip.appendChild(removeBtn); @@ -387,79 +401,58 @@ function buildQuickChip(modelId: string, name: string, providerName: string, cur chip.addEventListener("click", () => { pendingActivation = { providerName, modelId }; markDirty(); - rerenderQuickPicker(section); - rerenderActiveConfig(); + if (onActivate) onActivate(); + else if (section) { + rerenderQuickPicker(section); + rerenderActiveConfig(); + } }); return chip; } -function showAddQuickSelectForm(section: HTMLElement): void { - const existing = section.querySelector(".nc-quick-picker__add-form"); - if (existing) { existing.remove(); return; } - - const form = document.createElement("div"); - form.className = "nc-quick-picker__add-form"; - - const nameInput = document.createElement("input"); - nameInput.type = "text"; - nameInput.className = "nemoclaw-policy-input nc-quick-picker__add-input"; - nameInput.placeholder = "Display name"; - - const modelInput = document.createElement("input"); - modelInput.type = "text"; - modelInput.className = "nemoclaw-policy-input nc-quick-picker__add-input"; - modelInput.placeholder = "Model ID (e.g. nvidia/meta/llama-3.3-70b-instruct)"; - - const provInput = document.createElement("input"); - provInput.type = "text"; - provInput.className = "nemoclaw-policy-input nc-quick-picker__add-input"; - provInput.placeholder = "Provider name (e.g. nvidia-inference)"; - provInput.value = "nvidia-inference"; - - const btns = document.createElement("div"); - btns.className = "nc-quick-picker__add-actions"; - const addConfirm = document.createElement("button"); - addConfirm.type = "button"; - addConfirm.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--create"; - addConfirm.textContent = "Add"; - const cancelBtn = document.createElement("button"); - cancelBtn.type = "button"; - cancelBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--cancel"; - cancelBtn.textContent = "Cancel"; +function rerenderQuickPicker(section: HTMLElement): void { + const fresh = buildQuickPicker(); + section.replaceWith(fresh); +} - cancelBtn.addEventListener("click", () => form.remove()); - addConfirm.addEventListener("click", () => { - const name = nameInput.value.trim(); - const mid = modelInput.value.trim(); - const prov = provInput.value.trim(); - if (!name || !mid || !prov) return; - const items = getCustomQuickSelects(); - if (items.some((i) => i.modelId === mid)) { form.remove(); return; } - items.push({ modelId: mid, name, providerName: prov }); - saveCustomQuickSelects(items); - form.remove(); +function buildQuickPicker(): HTMLElement { + const section = document.createElement("div"); + section.className = "nc-quick-picker"; + const label = document.createElement("div"); + label.className = "nc-quick-picker__label"; + label.textContent = "Quick Select"; + section.appendChild(label); + const currentModelId = pendingActivation?.modelId ?? activeRoute?.modelId ?? ""; + const stripWrap = buildQuickPickerStrip(currentModelId, () => { rerenderQuickPicker(section); - refreshModelSelector().catch(() => {}); + rerenderActiveConfig(); }); - - btns.appendChild(addConfirm); - btns.appendChild(cancelBtn); - form.appendChild(nameInput); - form.appendChild(modelInput); - form.appendChild(provInput); - form.appendChild(btns); - section.appendChild(form); - requestAnimationFrame(() => nameInput.focus()); + section.appendChild(stripWrap); + return section; } -function rerenderQuickPicker(section: HTMLElement): void { - const fresh = buildQuickPicker(); - section.replaceWith(fresh); +// --------------------------------------------------------------------------- +// Upgrade banner — when active route is NVIDIA free tier +// --------------------------------------------------------------------------- + +function buildUpgradeBanner(): HTMLElement | null { + const routeProviderName = pendingActivation?.providerName ?? activeRoute?.providerName ?? ""; + const routeModelId = pendingActivation?.modelId ?? activeRoute?.modelId ?? ""; + const isNvidiaFree = + routeProviderName === "nvidia-endpoints" || + (routeProviderName !== "" && routeProviderName.toLowerCase().includes("nvidia")); + if (!isNvidiaFree || !routeModelId) return null; + + const banner = document.createElement("div"); + banner.className = "nc-upgrade-banner"; + const url = getUpgradeIntegrationsUrl(routeModelId); + banner.innerHTML = `On NVIDIA free tier. For higher limits and speed: OpenShell integrations`; + return banner; } // --------------------------------------------------------------------------- -// Section 3 — Active Configuration +// Active Configuration (includes Quick Select and optional upgrade banner) // --------------------------------------------------------------------------- function buildActiveConfig(): HTMLElement { @@ -507,16 +500,16 @@ function buildActiveConfig(): HTMLElement { }; markDirty(); rerenderActiveConfig(); - const pickerSection = pageContainer?.querySelector(".nc-quick-picker"); - if (pickerSection) rerenderQuickPicker(pickerSection as HTMLElement); }); - // Model row + // Model row: input + embedded quick-select chips (no standalone "Quick Select" block) const modelRow = document.createElement("div"); - modelRow.className = "nc-active-config__row"; + modelRow.className = "nc-active-config__row nc-active-config__row--model"; const modelLabel = document.createElement("label"); modelLabel.className = "nc-active-config__label"; modelLabel.textContent = "Model"; + const modelWrap = document.createElement("div"); + modelWrap.className = "nc-active-config__model-wrap"; const modelInput = document.createElement("input"); modelInput.type = "text"; modelInput.className = "nemoclaw-policy-input nc-active-config__model-input"; @@ -528,11 +521,12 @@ function buildActiveConfig(): HTMLElement { modelId: modelInput.value, }; markDirty(); - const pickerSection = pageContainer?.querySelector(".nc-quick-picker"); - if (pickerSection) rerenderQuickPicker(pickerSection as HTMLElement); }); + const quickStrip = buildQuickPickerStrip(routeModelId, rerenderActiveConfig); + modelWrap.appendChild(modelInput); + modelWrap.appendChild(quickStrip); modelRow.appendChild(modelLabel); - modelRow.appendChild(modelInput); + modelRow.appendChild(modelWrap); card.appendChild(modelRow); // Endpoint row (read-only, derived from provider config) @@ -569,6 +563,12 @@ function buildActiveConfig(): HTMLElement { statusRow.appendChild(statusValue); card.appendChild(statusRow); + const upgradeBanner = buildUpgradeBanner(); + if (upgradeBanner) { + upgradeBanner.classList.add("nc-active-config__upgrade-banner"); + card.appendChild(upgradeBanner); + } + return card; } @@ -618,15 +618,11 @@ function buildProviderSection(): HTMLElement { section.appendChild(headerRow); - // Provider list + // Provider list — no empty-state tiles; Add Provider dropdown is the only way to add const list = document.createElement("div"); list.className = "nemoclaw-policy-netpolicies nemoclaw-inference-provider-list"; - if (providers.length === 0) { - list.appendChild(buildProviderEmptyState(list)); - } else { - for (const provider of providers) { - list.appendChild(buildProviderCard(provider, list)); - } + for (const provider of providers) { + list.appendChild(buildProviderCard(provider, list)); } body.appendChild(list); @@ -652,7 +648,18 @@ function buildProviderSection(): HTMLElement { if (dropdownOpen) { closeDropdown(); return; } dropdownOpen = true; dropdownEl = document.createElement("div"); - dropdownEl.className = "nemoclaw-policy-templates"; + dropdownEl.className = "nemoclaw-policy-templates nemoclaw-policy-templates--portal"; + + const rect = addBtn.getBoundingClientRect(); + const gap = 6; + const maxHeight = Math.max(200, rect.top - 12); + dropdownEl.style.position = "fixed"; + dropdownEl.style.bottom = `${window.innerHeight - rect.top + gap}px`; + dropdownEl.style.left = `${rect.left}px`; + dropdownEl.style.minWidth = `${Math.max(280, rect.width)}px`; + dropdownEl.style.maxHeight = `${maxHeight}px`; + dropdownEl.style.overflowY = "auto"; + dropdownEl.style.zIndex = "10000"; const blankOpt = document.createElement("button"); blankOpt.type = "button"; @@ -671,15 +678,36 @@ function buildProviderSection(): HTMLElement { const opt = document.createElement("button"); opt.type = "button"; opt.className = "nemoclaw-policy-template-option"; - opt.innerHTML = `${escapeHtml(tmpl.label)} - ${escapeHtml(tmpl.type)} — ${escapeHtml(urlPreview)}`; + + const partnerId = tmpl.name.startsWith("partner_") ? tmpl.name.slice(8) : ""; + const partner = partnerId ? PARTNER_PROVIDERS.find((p) => p.id === partnerId) : null; + if (partner) { + const img = document.createElement("img"); + img.src = getPartnerLogoImgSrc(partner.logoId); + img.alt = ""; + img.width = 20; + img.height = 20; + img.className = "nemoclaw-policy-template-option__logo"; + img.onerror = () => { img.src = getPartnerLogoImgSrc("generic"); }; + opt.appendChild(img); + } + + const labelSpan = document.createElement("span"); + labelSpan.className = "nemoclaw-policy-template-option__label"; + labelSpan.textContent = tmpl.label; + opt.appendChild(labelSpan); + const metaSpan = document.createElement("span"); + metaSpan.className = "nemoclaw-policy-template-option__meta"; + metaSpan.textContent = `${tmpl.type} — ${urlPreview}`; + opt.appendChild(metaSpan); + opt.addEventListener("click", (ev) => { ev.stopPropagation(); closeDropdown(); showInlineNewProviderForm(list, tmpl); }); dropdownEl.appendChild(opt); } - addWrap.appendChild(dropdownEl); + document.body.appendChild(dropdownEl); }); document.addEventListener("click", () => { if (dropdownOpen) closeDropdown(); }); @@ -689,27 +717,6 @@ function buildProviderSection(): HTMLElement { return section; } -function buildProviderEmptyState(list: HTMLElement): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "nemoclaw-inference-empty-tiles"; - for (const tmpl of PROVIDER_TEMPLATES) { - const profile = PROVIDER_PROFILES[tmpl.type]; - const tile = document.createElement("button"); - tile.type = "button"; - tile.className = "nemoclaw-inference-empty-tile"; - tile.innerHTML = ` - ${escapeHtml(tmpl.label)} - ${escapeHtml(tmpl.type)} - ${escapeHtml(profile?.defaultUrl || "")}`; - tile.addEventListener("click", () => { - wrap.remove(); - showInlineNewProviderForm(list, tmpl); - }); - wrap.appendChild(tile); - } - return wrap; -} - // --------------------------------------------------------------------------- // Provider card // --------------------------------------------------------------------------- @@ -721,6 +728,25 @@ function getProviderDraft(p: InferenceProvider): ProviderDraft { return p._draft; } +/** Resolve credentials for save: use section key value when _credentialSource[k] is a section key name; otherwise use custom. */ +function resolveCredentialsForSave(draft: ProviderDraft): Record { + const out: Record = {}; + const source = draft._credentialSource || {}; + const sectionKeys = getSectionCredentialKeyNames(); + const allKeys = new Set([...Object.keys(draft.credentials), ...Object.keys(source)]); + for (const k of allKeys) { + const src = source[k]; + const sectionKey = src === "section" ? k : src; + if (sectionKey && sectionKey !== CREDENTIAL_SOURCE_CUSTOM && sectionKeys.includes(sectionKey)) { + const v = getSectionKeyValue(sectionKey); + if (v) out[k] = v; + } else if (draft.credentials[k]) { + out[k] = draft.credentials[k]; + } + } + return out; +} + function getUrlPreview(p: InferenceProvider): string { const draft = p._draft; const profile = PROVIDER_PROFILES[draft?.type || p.type]; @@ -989,44 +1015,33 @@ function renderProviderBody(body: HTMLElement, provider: InferenceProvider): voi typeRow.appendChild(typeField); body.appendChild(typeRow); - // Credentials - const credRow = document.createElement("div"); - credRow.className = "nemoclaw-inference-flat-row"; - if (provider._isNew) { - credRow.appendChild(buildCredentialInput(provider, profile.credentialKey)); - } else if (provider.credentialKeys.length > 0) { - const chipRow = document.createElement("div"); - chipRow.className = "nemoclaw-inference-cred-chips"; - for (const key of provider.credentialKeys) { - const chip = document.createElement("span"); - chip.className = "nemoclaw-inference-cred-chip"; - chip.innerHTML = `${escapeHtml(key)} configured`; - chipRow.appendChild(chip); - } - credRow.appendChild(chipRow); - - const rotateToggle = document.createElement("button"); - rotateToggle.type = "button"; - rotateToggle.className = "nemoclaw-policy-ep-advanced-toggle"; - rotateToggle.innerHTML = `${ICON_CHEVRON_RIGHT} Rotate`; - let rotateOpen = Object.keys(draft.credentials).length > 0; - const rotatePanel = document.createElement("div"); - rotatePanel.style.display = rotateOpen ? "" : "none"; - if (rotateOpen) rotateToggle.classList.add("nemoclaw-policy-ep-advanced-toggle--open"); - for (const key of provider.credentialKeys) { - rotatePanel.appendChild(buildCredentialInput(provider, key)); - } - rotateToggle.addEventListener("click", () => { - rotateOpen = !rotateOpen; - rotatePanel.style.display = rotateOpen ? "" : "none"; - rotateToggle.classList.toggle("nemoclaw-policy-ep-advanced-toggle--open", rotateOpen); - }); - credRow.appendChild(rotateToggle); - credRow.appendChild(rotatePanel); - } else { - credRow.appendChild(buildCredentialInput(provider, profile.credentialKey)); + // Credentials — always show inline inputs; no chips or Rotate + const credWrap = document.createElement("div"); + credWrap.className = "nemoclaw-inference-cred-wrap"; + const credHeading = document.createElement("div"); + credHeading.className = "nemoclaw-inference-cred-heading"; + credHeading.textContent = "API keys"; + credWrap.appendChild(credHeading); + + const allCredKeys = provider._isNew + ? [profile.credentialKey] + : [...new Set([...provider.credentialKeys, ...Object.keys(draft.credentials)])]; + for (const key of allCredKeys) { + credWrap.appendChild(buildCredentialInput(provider, key)); } - body.appendChild(credRow); + + const addCredBtn = document.createElement("button"); + addCredBtn.type = "button"; + addCredBtn.className = "nemoclaw-policy-add-small-btn"; + addCredBtn.innerHTML = `${ICON_PLUS} Add credential`; + addCredBtn.addEventListener("click", () => { + const row = buildCredentialKeyValueRow(provider, credWrap); + credWrap.insertBefore(row, addCredBtn); + if (!provider._isNew) changeTracker.modified.add(provider.name); + markDirty(); + }); + credWrap.appendChild(addCredBtn); + body.appendChild(credWrap); // Config key-value pairs (label "Endpoint" for *_BASE_URL keys) const configRow = document.createElement("div"); @@ -1056,19 +1071,59 @@ function renderProviderBody(body: HTMLElement, provider: InferenceProvider): voi // Credential input // --------------------------------------------------------------------------- +const CREDENTIAL_SOURCE_CUSTOM = "__custom__"; + function buildCredentialInput(provider: InferenceProvider, keyName: string): HTMLElement { const draft = getProviderDraft(provider); + const sectionEntries = getSectionCredentialEntries(); + if (!draft._credentialSource) draft._credentialSource = {}; + let current = draft._credentialSource[keyName]; + if (current === "section") { + current = keyName; + draft._credentialSource[keyName] = keyName; + } + if (!(keyName in draft._credentialSource)) draft._credentialSource[keyName] = CREDENTIAL_SOURCE_CUSTOM; + const isCustom = !current || current === CREDENTIAL_SOURCE_CUSTOM; + const row = document.createElement("div"); - row.className = "nemoclaw-inference-cred-input-row"; + row.className = "nemoclaw-inference-cred-input-row nemoclaw-inference-cred-input-row--inline"; + const label = document.createElement("label"); label.className = "nemoclaw-policy-field"; label.innerHTML = `${escapeHtml(keyName)}`; + + const selectWrap = document.createElement("div"); + selectWrap.className = "nemoclaw-inference-cred-source-select-wrap"; + const select = document.createElement("select"); + select.className = "nemoclaw-policy-select nemoclaw-inference-cred-source-select"; + const customOpt = document.createElement("option"); + customOpt.value = CREDENTIAL_SOURCE_CUSTOM; + customOpt.textContent = "Custom"; + select.appendChild(customOpt); + for (const { keyName: sectionKey, label: sectionLabel } of sectionEntries) { + const opt = document.createElement("option"); + opt.value = sectionKey; + opt.textContent = sectionLabel; + if (current === sectionKey) opt.selected = true; + select.appendChild(opt); + } + if (isCustom) select.selectedIndex = 0; + select.addEventListener("change", () => { + draft._credentialSource![keyName] = select.value; + if (!provider._isNew) changeTracker.modified.add(provider.name); + markDirty(); + inputWrap.style.display = select.value === CREDENTIAL_SOURCE_CUSTOM ? "" : "none"; + }); + selectWrap.appendChild(select); + const inputWrap = document.createElement("div"); inputWrap.className = "nemoclaw-key-field__input-row"; + if (!isCustom) inputWrap.style.display = "none"; + const input = document.createElement("input"); input.type = "password"; input.className = "nemoclaw-policy-input"; - input.placeholder = provider._isNew ? "sk-... or nvapi-..." : "Enter new value to rotate"; + input.placeholder = "Paste value"; input.value = draft.credentials[keyName] || ""; input.addEventListener("input", () => { if (input.value.trim()) { draft.credentials[keyName] = input.value; } @@ -1087,11 +1142,76 @@ function buildCredentialInput(provider: InferenceProvider, keyName: string): HTM }); inputWrap.appendChild(input); inputWrap.appendChild(toggleBtn); - label.appendChild(inputWrap); + + const lineWrap = document.createElement("div"); + lineWrap.className = "nemoclaw-inference-cred-source-line"; + lineWrap.appendChild(selectWrap); + lineWrap.appendChild(inputWrap); + label.appendChild(lineWrap); row.appendChild(label); return row; } +/** Row for adding a new credential (key name + value). */ +function buildCredentialKeyValueRow(provider: InferenceProvider, credWrap: HTMLElement): HTMLElement { + const draft = getProviderDraft(provider); + const row = document.createElement("div"); + row.className = "nemoclaw-inference-cred-input-row nemoclaw-inference-cred-key-value-row"; + + const keyInput = document.createElement("input"); + keyInput.type = "text"; + keyInput.className = "nemoclaw-policy-input nemoclaw-inference-cred-key-input"; + keyInput.placeholder = "e.g. OPENAI_API_KEY"; + + const inputWrap = document.createElement("div"); + inputWrap.className = "nemoclaw-key-field__input-row"; + const valInput = document.createElement("input"); + valInput.type = "password"; + valInput.className = "nemoclaw-policy-input"; + valInput.placeholder = "Paste value"; + const updateDraft = () => { + const k = keyInput.value.trim(); + if (k) { + if (valInput.value.trim()) draft.credentials[k] = valInput.value; + else delete draft.credentials[k]; + } + if (!provider._isNew) changeTracker.modified.add(provider.name); + markDirty(); + }; + keyInput.addEventListener("input", updateDraft); + valInput.addEventListener("input", updateDraft); + + const toggleBtn = document.createElement("button"); + toggleBtn.type = "button"; + toggleBtn.className = "nemoclaw-key-field__toggle"; + toggleBtn.innerHTML = ICON_EYE; + toggleBtn.addEventListener("click", () => { + const isHidden = valInput.type === "password"; + valInput.type = isHidden ? "text" : "password"; + toggleBtn.innerHTML = isHidden ? ICON_EYE_OFF : ICON_EYE; + }); + inputWrap.appendChild(valInput); + inputWrap.appendChild(toggleBtn); + + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger"; + delBtn.title = "Remove"; + delBtn.innerHTML = ICON_TRASH; + delBtn.addEventListener("click", () => { + const k = keyInput.value.trim(); + if (k) delete draft.credentials[k]; + if (!provider._isNew) changeTracker.modified.add(provider.name); + markDirty(); + row.remove(); + }); + + row.appendChild(keyInput); + row.appendChild(inputWrap); + row.appendChild(delBtn); + return row; +} + // --------------------------------------------------------------------------- // Config row // --------------------------------------------------------------------------- @@ -1228,12 +1348,12 @@ async function handleSave(btn: HTMLButtonElement, feedback: HTMLElement, bar: HT const draft = provider._draft; if (!draft) continue; try { - await apiCreateProvider({ name: provider.name, type: draft.type, credentials: draft.credentials, config: draft.config }); + await apiCreateProvider({ name: provider.name, type: draft.type, credentials: resolveCredentialsForSave(draft), config: draft.config }); } catch (err) { const msg = String(err); if (msg.includes("AlreadyExists") || msg.includes("already exists")) { try { - await apiUpdateProvider(provider.name, { type: draft.type, credentials: draft.credentials, config: draft.config }); + await apiUpdateProvider(provider.name, { type: draft.type, credentials: resolveCredentialsForSave(draft), config: draft.config }); } catch (updateErr) { errors.push(`Update ${provider.name}: ${updateErr}`); } } else { errors.push(`Create ${provider.name}: ${err}`); @@ -1249,7 +1369,7 @@ async function handleSave(btn: HTMLButtonElement, feedback: HTMLElement, bar: HT const draft = provider._draft; if (!draft) continue; try { - await apiUpdateProvider(provider.name, { type: draft.type, credentials: draft.credentials, config: draft.config }); + await apiUpdateProvider(provider.name, { type: draft.type, credentials: resolveCredentialsForSave(draft), config: draft.config }); } catch (err) { errors.push(`Update ${provider.name}: ${err}`); } } } 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 c59964e..5689d15 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-registry.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-registry.ts @@ -9,7 +9,6 @@ * `agents.defaults.model.primary` * * The two NVIDIA API platforms use separate API keys: - * - inference-api.nvidia.com — NVIDIA_INFERENCE_API_KEY * - integrate.api.nvidia.com — NVIDIA_INTEGRATE_API_KEY * * Keys are resolved at call time: localStorage (user-entered) takes @@ -130,24 +129,18 @@ export interface CuratedModel { } export const CURATED_MODELS: readonly CuratedModel[] = [ - { - id: "curated-kimi-k25", - name: "Kimi K2.5", - modelId: "moonshotai/kimi-k2.5", - providerName: "nvidia-endpoints", - }, - { - id: "curated-claude-opus", - name: "Claude Opus 4.6", - modelId: "aws/anthropic/bedrock-claude-opus-4-6", - providerName: "nvidia-inference", - }, { id: "curated-minimax-m25", name: "MiniMax M2.5", modelId: "minimaxai/minimax-m2.5", providerName: "nvidia-endpoints", }, + { + id: "curated-kimi-k25", + name: "Kimi K2.5", + modelId: "moonshotai/kimi-k2.5", + providerName: "nvidia-endpoints", + }, { id: "curated-glm5", name: "GLM 5", @@ -173,7 +166,7 @@ export function curatedToModelEntry(c: CuratedModel): ModelEntry { return { id: c.id, name: c.name, - isDefault: c.id === "curated-qwen35", + isDefault: c.id === "curated-minimax-m25", providerKey: key, modelRef: `${key}/${c.modelId}`, keyType: "inference", @@ -208,19 +201,19 @@ const DEFAULT_PROVIDER_KEY = "curated-nvidia-endpoints"; export const MODEL_REGISTRY: readonly ModelEntry[] = [ { - id: "curated-qwen35", - name: "Qwen 3.5 397B", + id: "curated-minimax-m25", + name: "MiniMax M2.5", isDefault: true, providerKey: DEFAULT_PROVIDER_KEY, - modelRef: `${DEFAULT_PROVIDER_KEY}/qwen/qwen3.5-397b-a17b`, + modelRef: `${DEFAULT_PROVIDER_KEY}/minimaxai/minimax-m2.5`, keyType: "inference", providerConfig: { baseUrl: "https://inference.local/v1", api: "openai-completions", models: [ { - id: "qwen/qwen3.5-397b-a17b", - name: "Qwen 3.5 397B", + id: "minimaxai/minimax-m2.5", + name: "MiniMax M2.5", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -380,3 +373,47 @@ export function getApiKey(target: DeployTarget): string { } return getInferenceApiKey(); } + +// --------------------------------------------------------------------------- +// Upgrade / partner offramp — build.nvidia.com/openshell/integrations +// --------------------------------------------------------------------------- + +const UPGRADE_INTEGRATIONS_BASE = "https://build.nvidia.com/openshell/integrations"; + +/** + * URL for upgrading the same model via an NVIDIA Cloud Partner. + * Use when active route is NVIDIA free tier; pass current model id (e.g. qwen/qwen3.5-397b-a17b). + */ +export function getUpgradeIntegrationsUrl(modelId: string): string { + if (!modelId || !modelId.trim()) return UPGRADE_INTEGRATIONS_BASE; + return `${UPGRADE_INTEGRATIONS_BASE}?model=${encodeURIComponent(modelId.trim())}`; +} + +/** + * Partner provider metadata for quick-add tiles and logos. + * OpenAI-compatible base URLs; credential key typically API_KEY or provider-specific. + * URLs verified from official docs where available; others are conventional placeholders + * (user can edit in Inference provider config). + */ +export interface PartnerProviderMeta { + id: string; + name: string; + baseUrl: string; + credentialKey: string; + configUrlKey: string; + /** LobeHub/lobe-icons icon id or "generic" for fallback */ + logoId: string; +} + +export const PARTNER_PROVIDERS: readonly PartnerProviderMeta[] = [ + { id: "baseten", name: "Baseten", baseUrl: "https://inference.baseten.co/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "Baseten" }, + { id: "bitdeer", name: "Bitdeer", baseUrl: "https://api-inference.bitdeer.ai/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "generic" }, + { id: "coreweave", name: "CoreWeave", baseUrl: "https://api.coreweave.com/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "generic" }, + { id: "deepinfra", name: "DeepInfra", baseUrl: "https://api.deepinfra.com/v1/openai", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "DeepInfra" }, + { id: "digitalocean", name: "Digital Ocean", baseUrl: "https://inference.do-ai.run/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "generic" }, + { id: "fireworks", name: "Fireworks AI", baseUrl: "https://api.fireworks.ai/inference/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "Fireworks" }, + { id: "gmicloud", name: "GMI Cloud", baseUrl: "https://api.gmi-serving.com/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "generic" }, + { id: "lightning", name: "Lightning AI", baseUrl: "https://lightning.ai/api/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "generic" }, + { id: "togetherai", name: "Together AI", baseUrl: "https://api.together.xyz/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "TogetherAI" }, + { id: "vultr", name: "Vultr", baseUrl: "https://api.vultr.com/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "generic" }, +]; 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 3c897ce..dd76683 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-selector.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-selector.ts @@ -25,6 +25,7 @@ import { CURATED_MODELS, curatedToModelEntry, getCuratedByModelId, + getUpgradeIntegrationsUrl, type ModelEntry, } from "./model-registry.ts"; import { patchConfig, waitForReconnect } from "./gateway-bridge.ts"; @@ -150,8 +151,12 @@ async function fetchDynamic(): Promise { let activeBanner: HTMLElement | null = null; let propagationTimer: ReturnType | null = null; -/** Sandbox polls the gateway every 30s for route updates. */ -const ROUTE_PROPAGATION_SECS = 30; +/** + * Max time (seconds) for inference route propagation into the sandbox. + * Must match DEFAULT_ROUTE_REFRESH_INTERVAL_SECS in + * openshell-sandbox/src/lib.rs (overridable there via OPENSHELL_ROUTE_REFRESH_INTERVAL_SECS). + */ +const ROUTE_PROPAGATION_SECS = 5; function showTransitionBanner(modelName: string): void { dismissTransitionBanner(); @@ -186,7 +191,7 @@ function showTransitionBannerLight(modelName: string): void { /** * Show an honest propagation banner for proxy-managed models. - * The NemoClaw sandbox polls for route updates every 30 seconds, so the + * The NemoClaw sandbox polls for route updates every ROUTE_PROPAGATION_SECS seconds, so the * switch isn't truly instant. This banner shows a progress bar that * counts down from ROUTE_PROPAGATION_SECS and transitions to a success * state when the propagation window has elapsed. @@ -297,7 +302,7 @@ async function applyModelSelection( if (isProxyManaged(entry)) { // Proxy-managed models route through inference.local. We update the // NemoClaw cluster-inference route (no OpenClaw config.patch, no - // gateway disconnect). The sandbox polls every ~30s for route + // gateway disconnect). The sandbox polls every ROUTE_PROPAGATION_SECS for route // updates, so we show an honest propagation countdown. const curated = getCuratedByModelId(entry.providerConfig.models[0]?.id || ""); const provName = curated?.providerName || entry.providerKey.replace(/^dynamic-/, ""); @@ -333,7 +338,7 @@ async function applyModelSelection( if (valueEl) valueEl.textContent = prev.name; updateDropdownSelection(wrapper, previousModelId); updateTransitionBannerError( - `API key not configured. Add your keys to switch models.`, + `API key not configured. Add your keys in Inference to switch models.`, ); return; } @@ -460,14 +465,30 @@ function buildModelSelector(): HTMLElement { populateDropdown(dropdown); + const poweredByBlock = document.createElement("div"); + poweredByBlock.className = "nemoclaw-model-powered-block"; const poweredBy = document.createElement("a"); poweredBy.className = "nemoclaw-model-powered"; poweredBy.href = "https://build.nvidia.com/models"; poweredBy.target = "_blank"; poweredBy.rel = "noopener noreferrer"; - poweredBy.textContent = "Powered by NVIDIA endpoints from build.nvidia.com"; + poweredBy.textContent = "Free endpoints by NVIDIA"; + const upgradeLink = document.createElement("a"); + upgradeLink.className = "nemoclaw-model-upgrade-link"; + upgradeLink.target = "_blank"; + upgradeLink.rel = "noopener noreferrer"; + upgradeLink.textContent = "Upgrade now"; + function updateUpgradeLink(): void { + const entry = getModelById(selectedModelId); + const modelId = entry?.providerConfig?.models?.[0]?.id ?? ""; + upgradeLink.href = getUpgradeIntegrationsUrl(modelId); + } + updateUpgradeLink(); + poweredByBlock.appendChild(poweredBy); + poweredByBlock.appendChild(document.createTextNode(". Rate-limited. ")); + poweredByBlock.appendChild(upgradeLink); - wrapper.appendChild(poweredBy); + wrapper.appendChild(poweredByBlock); wrapper.appendChild(trigger); wrapper.appendChild(dropdown); @@ -504,6 +525,7 @@ function buildModelSelector(): HTMLElement { updateDropdownSelection(wrapper, newModelId); const valueEl = trigger.querySelector(".nemoclaw-model-trigger__value"); if (valueEl) valueEl.textContent = entry.name; + updateUpgradeLink(); dropdown.style.display = "none"; trigger.setAttribute("aria-expanded", "false"); @@ -533,6 +555,7 @@ function buildModelSelector(): HTMLElement { if (valueEl) { valueEl.textContent = current ? current.name : "No model"; } + updateUpgradeLink(); }); return wrapper; diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/nav-group.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/nav-group.ts index 7218fa1..7cc2318 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/nav-group.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/nav-group.ts @@ -1,12 +1,11 @@ /** * NeMoClaw DevX — Sidebar Nav Group * - * Collapsible "NeMoClaw" nav group with Policy, Inference Routes, and - * API Keys pages. Renders page overlays on top of . + * Collapsible "NeMoClaw" nav group with Policy and Inference Routes. + * Renders page overlays on top of . */ -import { ICON_SHIELD, ICON_ROUTE, ICON_KEY } from "./icons.ts"; -import { renderApiKeysPage, areAllKeysConfigured, updateStatusDots } from "./api-keys-page.ts"; +import { ICON_SHIELD, ICON_ROUTE } from "./icons.ts"; import { renderPolicyPage } from "./policy-page.ts"; import { renderInferencePage } from "./inference-page.ts"; @@ -22,7 +21,6 @@ interface NemoClawPage { subtitle: string; emptyMessage: string; customRender?: (container: HTMLElement) => void; - showStatusDot?: boolean; } const NEMOCLAW_PAGES: NemoClawPage[] = [ @@ -37,23 +35,13 @@ const NEMOCLAW_PAGES: NemoClawPage[] = [ }, { id: "nemoclaw-inference-routes", - label: "Inference Routes", + label: "Inference", icon: ICON_ROUTE, - title: "Inference Routes", - subtitle: "Configure model routing and endpoint mappings", + title: "Inference", + subtitle: "Configure model routing and API keys", emptyMessage: "", customRender: renderInferencePage, }, - { - id: "nemoclaw-api-keys", - label: "API Keys", - icon: ICON_KEY, - title: "API Keys", - subtitle: "Configure your NVIDIA API keys for model endpoints", - emptyMessage: "", - customRender: renderApiKeysPage, - showStatusDot: true, - }, ]; // --------------------------------------------------------------------------- @@ -91,18 +79,9 @@ function buildNavGroup(): HTMLElement { item.href = "#"; item.className = "nav-item"; item.dataset.nemoclawPage = page.id; - - let dotHtml = ""; - if (page.showStatusDot) { - const ok = areAllKeysConfigured(); - const dotClass = ok ? "nemoclaw-nav-dot--ok" : "nemoclaw-nav-dot--missing"; - dotHtml = ``; - } - item.innerHTML = `` + - `${page.label}` + - dotHtml; + `${page.label}`; item.addEventListener("click", (e) => { e.preventDefault(); diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/package-lock.json b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/package-lock.json new file mode 100644 index 0000000..a2d20f1 --- /dev/null +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "extension", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "js-yaml": "^4.1.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + } + } +} diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/partner-logos.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/partner-logos.ts new file mode 100644 index 0000000..247880c --- /dev/null +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/partner-logos.ts @@ -0,0 +1,52 @@ +/** + * Partner provider logos for Inference tab tiles. + * Uses data URL for reliable rendering across contexts (no inline SVG parsing issues). + */ + +/** Generic cloud icon as data URL so renders reliably. */ +const GENERIC_ICON_SVG = + ''; +const GENERIC_ICON_DATA_URL = `data:image/svg+xml,${encodeURIComponent(GENERIC_ICON_SVG)}`; + +/** logoId -> domain for Clearbit logo CDN (logo.clearbit.com). Falls back to generic if missing or load fails. */ +const PARTNER_LOGO_DOMAINS: Record = { + Baseten: "baseten.co", + DeepInfra: "deepinfra.com", + Fireworks: "fireworks.ai", + TogetherAI: "together.xyz", + Anthropic: "anthropic.com", + OpenAI: "openai.com", + CoreWeave: "coreweave.com", + Lightning: "lightning.ai", + Vultr: "vultr.com", + DigitalOcean: "digitalocean.com", + Bitdeer: "bitdeer.com", +}; + +/** + * Returns a URL suitable for so the logo renders on screen. + * Uses Clearbit logo CDN for known partners; otherwise generic cloud icon. + */ +export function getPartnerLogoImgSrc(logoId: string): string { + if (!logoId || logoId === "generic") return GENERIC_ICON_DATA_URL; + const domain = PARTNER_LOGO_DOMAINS[logoId]; + if (domain) return `https://logo.clearbit.com/${domain}`; + return GENERIC_ICON_DATA_URL; +} + +/** + * Returns inline SVG HTML for a partner logo (legacy). + * Prefer getPartnerLogoImgSrc + for reliable rendering. + */ +export function getPartnerLogoHtml(logoId: string): string { + const src = getPartnerLogoImgSrc(logoId); + return ``; +} + +/** + * Returns a URL for a partner logo image (for ). + */ +export function getPartnerLogoUrl(logoId: string): string | null { + if (!logoId || logoId === "generic") return null; + return null; +} diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/policy-page.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/policy-page.ts index 40e96b7..2fc65ec 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/policy-page.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/policy-page.ts @@ -7,6 +7,7 @@ */ import * as yaml from "js-yaml"; +import { isPreviewMode, PREVIEW_POLICY_YAML } from "./preview-mode.ts"; import { ICON_LOCK, ICON_GLOBE, @@ -150,6 +151,7 @@ let saveBarEl: HTMLElement | null = null; // --------------------------------------------------------------------------- async function fetchPolicy(): Promise { + if (isPreviewMode()) return PREVIEW_POLICY_YAML; const res = await fetch("/api/policy"); if (!res.ok) throw new Error(`Failed to load policy: ${res.status}`); return res.text(); @@ -164,6 +166,7 @@ interface SavePolicyResult { } async function savePolicy(yamlText: string): Promise { + if (isPreviewMode()) return { ok: true, applied: true }; console.log("[policy-save] step 1/2: POST /api/policy →", yamlText.length, "bytes"); const res = await fetch("/api/policy", { method: "POST", @@ -179,6 +182,7 @@ async function savePolicy(yamlText: string): Promise { } async function syncPolicyViaHost(yamlText: string): Promise { + if (isPreviewMode()) return { ok: true, applied: true }; console.log("[policy-save] step 2/2: POST /api/policy-sync →", yamlText.length, "bytes"); const res = await fetch("/api/policy-sync", { method: "POST", @@ -200,6 +204,7 @@ async function syncPolicyViaHost(yamlText: string): Promise { const DENIALS_SINCE_MS = 5 * 60 * 1000; // 5 minutes async function fetchDenials(): Promise { + if (isPreviewMode()) return []; const since = Date.now() - DENIALS_SINCE_MS; const res = await fetch(`/api/sandbox-denials?since=${since}`); if (!res.ok) return []; diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/preview-mode.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/preview-mode.ts new file mode 100644 index 0000000..0dd5d12 --- /dev/null +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/preview-mode.ts @@ -0,0 +1,29 @@ +/** + * Preview mode: no backend. Used by dev-preview.sh so the UI renders with mock data. + */ + +export function isPreviewMode(): boolean { + try { + return new URL(document.URL).searchParams.get("preview") === "1"; + } catch { + return false; + } +} + +/** Minimal policy YAML so the policy page renders in preview. */ +export const PREVIEW_POLICY_YAML = `version: 1 + +filesystem_policy: + include_workdir: true + read_only: [] + read_write: [] + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: {} +`; diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/styles.css b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/styles.css index 7dd2b20..db6d073 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/styles.css +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/styles.css @@ -174,6 +174,22 @@ line-height: 1.55; } +.nemoclaw-modal__partner-cta { + margin-top: 12px; + font-size: 12px; + color: var(--muted, #71717a); +} + +.nemoclaw-modal__partner-cta a { + color: #76B900; + text-decoration: none; +} + +.nemoclaw-modal__partner-cta a:hover { + text-decoration: underline; + text-underline-offset: 2px; +} + .nemoclaw-target-list { display: grid; gap: 10px; @@ -474,6 +490,25 @@ main.content { text-underline-offset: 2px; } +.nemoclaw-model-powered-block { + font-size: 9px; + font-weight: 500; + color: var(--muted, #71717a); + white-space: normal; + line-height: 1.35; + margin-bottom: 4px; +} + +.nemoclaw-model-upgrade-link { + color: #76B900; + text-decoration: none; +} + +.nemoclaw-model-upgrade-link:hover { + text-decoration: underline; + text-underline-offset: 2px; +} + /* Trigger button — larger sizing */ .nemoclaw-model-trigger { display: inline-flex; @@ -970,6 +1005,22 @@ body.nemoclaw-switching openclaw-app { text-underline-offset: 2px; } +.nemoclaw-key-intro__partner-cta { + margin-top: 10px; + font-size: 12px; + color: var(--muted, #71717a); +} + +.nemoclaw-key-intro__partner-cta a { + color: #76B900; + text-decoration: none; +} + +.nemoclaw-key-intro__partner-cta a:hover { + text-decoration: underline; + text-underline-offset: 2px; +} + .nemoclaw-key-form { display: grid; gap: 24px; @@ -2942,6 +2993,12 @@ body.nemoclaw-switching openclaw-app { z-index: 50; } +/* Portaled to body so it is not clipped by overflow */ +.nemoclaw-policy-templates--portal { + position: fixed !important; + z-index: 10000 !important; +} + :root[data-theme="light"] .nemoclaw-policy-templates { background: var(--bg, #fff); box-shadow: @@ -2951,8 +3008,10 @@ body.nemoclaw-switching openclaw-app { .nemoclaw-policy-template-option { display: flex; - flex-direction: column; - gap: 2px; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 8px; width: 100%; padding: 8px 12px; border: none; @@ -2966,6 +3025,23 @@ body.nemoclaw-switching openclaw-app { transition: background 100ms ease; } +.nemoclaw-policy-template-option__logo { + width: 20px; + height: 20px; + object-fit: contain; + flex-shrink: 0; +} + +.nemoclaw-policy-template-option .nemoclaw-policy-template-option__label { + flex: 1; + min-width: 0; +} + +.nemoclaw-policy-template-option .nemoclaw-policy-template-option__meta { + width: 100%; + flex-basis: 100%; +} + .nemoclaw-policy-template-option:hover { background: var(--bg-hover, #262a35); } @@ -3215,6 +3291,119 @@ body.nemoclaw-switching openclaw-app { opacity: 0.6; } +/* API keys section (Inference tab) */ +.nemoclaw-inference-apikeys { + padding: 12px 16px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 8px); + background: var(--bg-elevated, #1a1d25); +} + +:root[data-theme="light"] .nemoclaw-inference-apikeys { + background: #fff; +} + +.nemoclaw-inference-apikeys__heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.nemoclaw-inference-apikeys__title { + font-size: 13px; + font-weight: 600; + color: var(--text-strong, #fafafa); +} + +.nemoclaw-inference-apikeys__intro { + font-size: 12px; + line-height: 1.45; + color: var(--muted, #71717a); + margin: 0 0 12px; +} + +.nemoclaw-inference-apikeys__link { + font-size: 12px; + color: var(--muted, #71717a); + text-decoration: none; +} + +.nemoclaw-inference-apikeys__link:hover { + color: #76B900; +} + +.nemoclaw-inference-apikeys__form.nemoclaw-key-form { + gap: 16px; +} + +.nemoclaw-inference-apikeys__key-row { + display: grid; + gap: 6px; +} + +.nemoclaw-inference-apikeys__key-row-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.nemoclaw-inference-apikeys__key-row-delete { + flex-shrink: 0; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted, #71717a); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.nemoclaw-inference-apikeys__key-row-delete:hover { + background: var(--bg-hover, #27272a); + color: var(--text, #e4e4e7); +} + +.nemoclaw-inference-apikeys__key-row-delete svg { + width: 14px; + height: 14px; +} + +.nemoclaw-inference-apikeys__add-row { + margin-top: 4px; +} + +.nemoclaw-inference-apikeys__add-form { + display: grid; + grid-template-columns: 1fr 1fr auto auto; + gap: 8px; + align-items: center; + padding: 10px 12px; + border: 1px dashed var(--border, #3f3f46); + border-radius: 8px; + background: var(--bg-subtle, #18181b); +} + +.nemoclaw-inference-apikeys__add-form .nemoclaw-policy-input { + min-width: 0; +} + +.nemoclaw-inference-apikeys__add-form .nemoclaw-policy-confirm-btn--create { + background: #76B900; + color: #0c0c0d; +} + +.nemoclaw-inference-apikeys__add-form .nemoclaw-policy-confirm-btn--cancel { + background: transparent; + color: var(--muted, #71717a); +} + /* =================================================================== Quick Model Picker (Section 2) — horizontal chip strip =================================================================== */ @@ -3345,6 +3534,108 @@ body.nemoclaw-switching openclaw-app { width: 100%; } +/* =================================================================== + Upgrade banner (NVIDIA free tier nudge) + =================================================================== */ + +.nc-upgrade-banner { + font-size: 12px; + color: var(--muted, #71717a); + padding: 8px 12px; + background: rgba(118, 185, 0, 0.06); + border: 1px solid rgba(118, 185, 0, 0.2); + border-radius: var(--radius-md, 6px); +} + +.nc-upgrade-banner__link { + color: #76B900; + font-weight: 500; + text-decoration: none; +} + +.nc-upgrade-banner__link:hover { + text-decoration: underline; + text-underline-offset: 2px; +} + +/* =================================================================== + Partner grid — Add a provider (NVIDIA Cloud Partners) + =================================================================== */ + +.nc-partner-grid-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.nc-partner-grid__label { + font-size: 13px; + font-weight: 600; + color: var(--text-strong, #fafafa); +} + +.nc-partner-grid__sub { + font-size: 12px; + color: var(--muted, #71717a); +} + +.nc-partner-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 8px; +} + +.nc-partner-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 12px 8px; + border: 1px solid var(--border, #27272a); + border-radius: var(--radius-md, 6px); + background: var(--bg-elevated, #1a1d25); + cursor: pointer; + font-family: inherit; + font-size: 12px; + font-weight: 500; + color: var(--text, #e4e4e7); + transition: border-color 150ms ease, background 150ms ease; +} + +.nc-partner-tile:hover { + border-color: rgba(118, 185, 0, 0.5); + background: rgba(118, 185, 0, 0.05); +} + +.nc-partner-tile__logo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + color: var(--muted, #71717a); +} + +.nc-partner-tile__logo svg, +.nc-partner-tile__logo .nc-partner-tile__logo-img, +.nc-partner-tile__logo-img { + width: 24px; + height: 24px; + display: block; + object-fit: contain; +} + +.nc-partner-tile__name { + text-align: center; + line-height: 1.2; +} + +.nc-partner-tile__add { + font-size: 11px; + color: #76B900; + font-weight: 600; +} + /* =================================================================== Active Configuration (Section 3) =================================================================== */ @@ -3367,6 +3658,45 @@ body.nemoclaw-switching openclaw-app { margin-bottom: 14px; } +.nc-active-config__row--model { + align-items: flex-start; +} + +.nc-active-config__row--model .nc-active-config__label { + padding-top: 10px; +} + +.nc-active-config__model-wrap { + display: flex; + flex-direction: column; + gap: 10px; + flex: 1; + min-width: 0; +} + +.nc-active-config__model-quick-strip { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.nc-active-config__model-quick-strip .nc-quick-picker__strip { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.nc-active-config__model-quick-strip .nc-quick-picker__add-btn { + margin: 0; +} + +.nc-active-config__upgrade-banner { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border, #27272a); +} + .nc-active-config__row { display: flex; align-items: center; @@ -3445,6 +3775,7 @@ body.nemoclaw-switching openclaw-app { border: 1px solid var(--border, #27272a); border-radius: var(--radius-md, 8px); background: var(--bg-elevated, #1a1d25); + overflow: visible; overflow: hidden; } @@ -3516,6 +3847,7 @@ body.nemoclaw-switching openclaw-app { .nc-providers-section__body { padding: 0 16px 16px; + overflow: visible; } /* --- Type Pills --- */ @@ -3693,12 +4025,94 @@ body.nemoclaw-switching openclaw-app { gap: 8px; } +.nemoclaw-inference-cred-wrap { + margin-bottom: 12px; +} + +.nemoclaw-inference-cred-heading { + font-size: 12px; + font-weight: 600; + color: var(--text-strong, #fafafa); + margin-bottom: 8px; +} + +.nemoclaw-inference-cred-wrap .nemoclaw-inference-cred-input-row { + margin-bottom: 8px; +} + .nemoclaw-inference-cred-input-row { display: flex; flex-direction: column; gap: 2px; } +.nemoclaw-inference-cred-key-value-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.nemoclaw-inference-cred-key-value-row .nemoclaw-inference-cred-key-input { + min-width: 160px; + flex: 0 1 auto; +} + +.nemoclaw-inference-cred-key-value-row .nemoclaw-key-field__input-row { + flex: 1; + min-width: 140px; +} + +.nemoclaw-inference-cred-key-value-row .nemoclaw-policy-icon-btn { + flex-shrink: 0; +} + +.nemoclaw-inference-cred-source { + display: flex; + flex-wrap: wrap; + gap: 12px 20px; + margin-bottom: 6px; +} + +.nemoclaw-inference-cred-source__option { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--muted, #71717a); + cursor: pointer; +} + +.nemoclaw-inference-cred-source__option input { + margin: 0; +} + +.nemoclaw-inference-cred-source-select-wrap { + margin-bottom: 0; + flex-shrink: 0; +} + +.nemoclaw-inference-cred-source-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.nemoclaw-inference-cred-source-line .nemoclaw-key-field__input-row { + flex: 1; + min-width: 160px; +} + +.nemoclaw-inference-cred-input-row--inline .nemoclaw-inference-cred-source-select-wrap { + margin-bottom: 0; +} + +.nemoclaw-inference-cred-source-select { + min-width: 180px; +} + /* --- Config key/value rows --- */ .nemoclaw-inference-config-list { diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/install.sh b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/install.sh index 86c5312..6591efd 100755 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/install.sh +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/install.sh @@ -64,12 +64,6 @@ set -a source "$ENV_FILE" set +a -if [ -z "${NVIDIA_INFERENCE_API_KEY:-}" ] || [ "$NVIDIA_INFERENCE_API_KEY" = "your-key-here" ]; then - echo -e "${RED}Error:${RESET} NVIDIA_INFERENCE_API_KEY is not set in .env" - echo " Edit $ENV_FILE and provide your inference-api.nvidia.com key." - exit 1 -fi - if [ -z "${NVIDIA_INTEGRATE_API_KEY:-}" ] || [ "$NVIDIA_INTEGRATE_API_KEY" = "your-key-here" ]; then echo -e "${RED}Error:${RESET} NVIDIA_INTEGRATE_API_KEY is not set in .env" echo " Edit $ENV_FILE and provide your integrate.api.nvidia.com key." @@ -90,11 +84,6 @@ echo -e " Copied files: ${GREEN}$FILE_COUNT${RESET} -> $TARGET_EXT/" REGISTRY="$TARGET_EXT/model-registry.ts" KEYS_INJECTED=0 -if grep -q '__NVIDIA_INFERENCE_API_KEY__' "$REGISTRY" 2>/dev/null; then - sed -i "s|__NVIDIA_INFERENCE_API_KEY__|${NVIDIA_INFERENCE_API_KEY}|g" "$REGISTRY" - KEYS_INJECTED=$((KEYS_INJECTED + 1)) -fi - if grep -q '__NVIDIA_INTEGRATE_API_KEY__' "$REGISTRY" 2>/dev/null; then sed -i "s|__NVIDIA_INTEGRATE_API_KEY__|${NVIDIA_INTEGRATE_API_KEY}|g" "$REGISTRY" KEYS_INJECTED=$((KEYS_INJECTED + 1)) diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/preview/index.html b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/preview/index.html new file mode 100644 index 0000000..afda497 --- /dev/null +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/preview/index.html @@ -0,0 +1,66 @@ + + + + + + NeMoClaw UI — Preview + + + + +
+
+ NeMoClaw UI Preview +
+
+
+ +
+

Content area. Use the sidebar to open Sandbox Policy or Inference.

+
+
+
+
+ +
+
+
+ + + + diff --git a/sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh b/sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh index d493180..d2d7792 100644 --- a/sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh +++ b/sandboxes/openclaw-nvidia/openclaw-nvidia-start.sh @@ -16,7 +16,6 @@ # https://187890-.brevlab.com for Brev) # # Optional env vars (for NVIDIA model endpoints): -# NVIDIA_INFERENCE_API_KEY — key for inference-api.nvidia.com # NVIDIA_INTEGRATE_API_KEY — key for integrate.api.nvidia.com # # Usage (env vars inlined via env command to avoid nemoclaw -e quoting bug): diff --git a/sandboxes/openclaw-nvidia/policy.yaml b/sandboxes/openclaw-nvidia/policy.yaml index ae34f93..0f5ddaf 100644 --- a/sandboxes/openclaw-nvidia/policy.yaml +++ b/sandboxes/openclaw-nvidia/policy.yaml @@ -96,7 +96,6 @@ network_policies: name: nvidia endpoints: - { host: integrate.api.nvidia.com, port: 443 } - - { host: inference-api.nvidia.com, port: 443 } binaries: - { path: /usr/bin/curl } - { path: /bin/bash }