diff --git a/.gitignore b/.gitignore
index 2790357..f349fb6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,2 @@
node_modules/
-AGENTS.md
\ No newline at end of file
+AGENTS.md
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/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..6ce5bed 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);
@@ -269,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",
},
@@ -878,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}`,
@@ -1223,6 +1223,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 +1833,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 +1903,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)}
+ ${ICON_CLOSE} `;
+ 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)}
+
+
+
+ `;
+
+ 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
new file mode 100644
index 0000000..f567f22
--- /dev/null
+++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts
@@ -0,0 +1,193 @@
+/**
+ * NeMoClaw DevX Extension
+ *
+ * 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
+ * 3. A model selector wired to NVIDIA endpoints
+ *
+ * Operates purely as an overlay — no original OpenClaw source files are modified.
+ */
+
+import "./styles.css";
+import { injectButton } from "./deploy-modal.ts";
+import { injectNavGroup, activateNemoPage, watchOpenClawNavClicks } from "./nav-group.ts";
+import { injectModelSelector, watchChatCompose } from "./model-selector.ts";
+import { ingestKeysFromUrl, DEFAULT_MODEL, resolveApiKey, 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;
+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();
+ const hasNav = injectNavGroup();
+ return hasButton && hasNav;
+}
+
+/**
+ * Delegated click handler for [data-nemoclaw-goto] links embedded in
+ * error messages (deploy modal, model selector banners). Navigates to
+ * the target NeMoClaw page without a full page reload.
+ */
+function watchGotoLinks() {
+ document.addEventListener("click", (e) => {
+ const link = (e.target as HTMLElement).closest("[data-nemoclaw-goto]");
+ if (!link) return;
+ e.preventDefault();
+ const pageId = link.dataset.nemoclawGoto;
+ if (pageId) activateNemoPage(pageId);
+ });
+}
+
+/**
+ * Insert a full-screen loading overlay that covers the OpenClaw UI while the
+ * gateway connects and auto-pairs the device. The overlay is styled via
+ * styles.css and is automatically faded out once `data-nemoclaw-ready` is set
+ * on . We remove it from the DOM after the CSS transition completes.
+ */
+function showConnectOverlay(): void {
+ if (document.querySelector(".nemoclaw-connect-overlay")) return;
+ const overlay = document.createElement("div");
+ overlay.className = "nemoclaw-connect-overlay";
+ overlay.setAttribute("aria-live", "polite");
+ overlay.innerHTML =
+ '
' +
+ 'Auto-approving device pairing. Hang tight...
';
+ 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");
+ if (overlay) {
+ overlay.addEventListener("transitionend", () => overlay.remove(), { once: true });
+ setTimeout(() => overlay.remove(), 600);
+ }
+ startDenialWatcher();
+}
+
+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 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();
+
+ const finalizeConnectedState = async () => {
+ setConnectOverlayText("Device pairing approved. Finalizing dashboard...");
+ console.info("[NeMoClaw] pairing bootstrap: reconnect detected");
+ if (shouldForcePairingReload()) {
+ forcePairingReload("post-reconnect", "Device pairing approved. Reloading dashboard...");
+ return;
+ }
+ 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();
+ };
+
+ waitForReconnect(INITIAL_CONNECT_TIMEOUT_MS)
+ .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);
+ await finalizeConnectedState();
+ } catch {
+ console.warn("[NeMoClaw] pairing bootstrap: extended reconnect timed out; revealing app anyway");
+ clearPairingReloadFlag();
+ revealApp();
+ }
+ });
+
+ const keysIngested = ingestKeysFromUrl();
+
+ watchOpenClawNavClicks();
+ watchChatCompose();
+ watchGotoLinks();
+
+ const defaultKey = resolveApiKey(DEFAULT_MODEL.keyType);
+ if (keysIngested || isKeyConfigured(defaultKey)) {
+ syncKeysToProviders().catch((e) =>
+ console.warn("[NeMoClaw] bootstrap provider key sync failed:", e),
+ );
+ }
+
+ 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);
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", bootstrap);
+} else {
+ bootstrap();
+}
diff --git a/sandboxes/nemoclaw/policy-proxy.js b/sandboxes/nemoclaw/policy-proxy.js
new file mode 100644
index 0000000..64efb40
--- /dev/null
+++ b/sandboxes/nemoclaw/policy-proxy.js
@@ -0,0 +1,719 @@
+#!/usr/bin/env node
+
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+// policy-proxy.js — Lightweight reverse proxy that sits in front of the
+// OpenClaw gateway. Intercepts /api/policy requests to read/write the
+// sandbox policy YAML file and push updates to the NemoClaw gateway via
+// gRPC so changes take effect on the running sandbox. Everything else
+// (including WebSocket upgrades) is transparently forwarded to the
+// upstream OpenClaw gateway.
+
+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);
+const LISTEN_PORT = parseInt(process.env.LISTEN_PORT || "18789", 10);
+const UPSTREAM_HOST = "127.0.0.1";
+
+const PROTO_DIR = "/usr/local/lib/nemoclaw-proto";
+
+// Well-known paths for TLS credentials (volume-mounted by the NemoClaw
+// platform). When the proxy runs inside an SSH session the env vars are
+// cleared, but the files on disk remain accessible.
+const TLS_WELL_KNOWN = {
+ ca: "/etc/openshell-tls/client/ca.crt",
+ cert: "/etc/openshell-tls/client/tls.crt",
+ key: "/etc/openshell-tls/client/tls.key",
+};
+
+const WELL_KNOWN_ENDPOINT = "https://navigator.navigator.svc.cluster.local:8080";
+
+// Resolved at init time.
+let gatewayEndpoint = "";
+let sandboxName = "";
+
+function formatRequestLine(req) {
+ const host = req.headers.host || "unknown-host";
+ return `${req.method || "GET"} ${req.url || "/"} host=${host}`;
+}
+
+// ---------------------------------------------------------------------------
+// Discovery helpers
+// ---------------------------------------------------------------------------
+
+function discoverFromSupervisor() {
+ try {
+ const raw = fs.readFileSync("/proc/1/cmdline");
+ const args = raw.toString("utf8").split("\0").filter(Boolean);
+ const result = {};
+ for (let i = 0; i < args.length; i++) {
+ if (args[i] === "--navigator-endpoint" && i + 1 < args.length) {
+ result.endpoint = args[i + 1];
+ } else if (args[i] === "--sandbox-id" && i + 1 < args.length) {
+ result.sandboxId = args[i + 1];
+ } else if (args[i] === "--sandbox" && i + 1 < args.length) {
+ result.sandbox = args[i + 1];
+ }
+ }
+ return result;
+ } catch (e) {
+ return {};
+ }
+}
+
+function resolveTlsPaths() {
+ const ca = process.env.NEMOCLAW_TLS_CA || (fileExists(TLS_WELL_KNOWN.ca) ? TLS_WELL_KNOWN.ca : "");
+ const cert = process.env.NEMOCLAW_TLS_CERT || (fileExists(TLS_WELL_KNOWN.cert) ? TLS_WELL_KNOWN.cert : "");
+ const key = process.env.NEMOCLAW_TLS_KEY || (fileExists(TLS_WELL_KNOWN.key) ? TLS_WELL_KNOWN.key : "");
+ return { ca, cert, key };
+}
+
+function fileExists(p) {
+ try { fs.accessSync(p, fs.constants.R_OK); return true; } catch { return false; }
+}
+
+// ---------------------------------------------------------------------------
+// gRPC client (lazy-initialized)
+// ---------------------------------------------------------------------------
+
+let grpcClient = null;
+let grpcEnabled = false;
+let grpcPermanentlyDisabled = false;
+
+function initGrpcClient() {
+ // 1. Resolve gateway endpoint.
+ gatewayEndpoint = process.env.NEMOCLAW_ENDPOINT || "";
+
+ // 2. Resolve sandbox name. NEMOCLAW_SANDBOX is overridden to "1" by
+ // the supervisor for all child processes, so prefer NEMOCLAW_SANDBOX_ID.
+ sandboxName = process.env.NEMOCLAW_SANDBOX_ID || "";
+
+ // 3. Cmdline fallback (useful when env vars were passed as CLI args).
+ if (!gatewayEndpoint || !sandboxName) {
+ const discovered = discoverFromSupervisor();
+ if (!gatewayEndpoint && discovered.endpoint) {
+ gatewayEndpoint = discovered.endpoint;
+ console.log(`[policy-proxy] Discovered endpoint from supervisor cmdline: ${gatewayEndpoint}`);
+ }
+ if (!sandboxName) {
+ sandboxName = discovered.sandboxId || discovered.sandbox || "";
+ }
+ }
+
+ // 4. Well-known fallbacks for SSH sessions where env_clear() stripped
+ // the container env vars.
+ if (!gatewayEndpoint && fileExists(TLS_WELL_KNOWN.ca)) {
+ gatewayEndpoint = WELL_KNOWN_ENDPOINT;
+ console.log(`[policy-proxy] Using well-known gateway endpoint: ${gatewayEndpoint}`);
+ }
+ if (!sandboxName) {
+ sandboxName = os.hostname() || "";
+ if (sandboxName) {
+ console.log(`[policy-proxy] Using hostname as sandbox name: ${sandboxName}`);
+ }
+ }
+
+ if (!gatewayEndpoint || !sandboxName) {
+ console.log(
+ `[policy-proxy] Gateway sync disabled — endpoint=${gatewayEndpoint || "(unset)"}, ` +
+ `sandbox=${sandboxName || "(unset)"}.`
+ );
+ return;
+ }
+
+ let grpc, protoLoader;
+ try {
+ grpc = require("@grpc/grpc-js");
+ protoLoader = require("@grpc/proto-loader");
+ } catch (e) {
+ console.error("[policy-proxy] gRPC packages not available; gateway sync disabled:", e.message);
+ return;
+ }
+
+ let packageDef;
+ try {
+ packageDef = protoLoader.loadSync("navigator.proto", {
+ keepCase: true,
+ longs: Number,
+ enums: String,
+ defaults: true,
+ oneofs: true,
+ includeDirs: [PROTO_DIR],
+ });
+ } catch (e) {
+ console.error("[policy-proxy] Failed to load proto definitions:", e.message);
+ return;
+ }
+
+ const proto = grpc.loadPackageDefinition(packageDef);
+
+ // Build channel credentials: mTLS when certs exist, TLS-only with CA
+ // when only the CA is available, insecure as last resort.
+ const tls = resolveTlsPaths();
+ let creds;
+ try {
+ if (tls.ca && tls.cert && tls.key) {
+ const rootCerts = fs.readFileSync(tls.ca);
+ const privateKey = fs.readFileSync(tls.key);
+ const certChain = fs.readFileSync(tls.cert);
+ creds = grpc.credentials.createSsl(rootCerts, privateKey, certChain);
+ } else if (tls.ca) {
+ const rootCerts = fs.readFileSync(tls.ca);
+ creds = grpc.credentials.createSsl(rootCerts);
+ } else {
+ creds = grpc.credentials.createInsecure();
+ }
+ } catch (e) {
+ console.error("[policy-proxy] Failed to load TLS credentials:", e.message);
+ creds = grpc.credentials.createInsecure();
+ }
+
+ // Strip scheme prefix — grpc-js expects "host:port".
+ const target = gatewayEndpoint.replace(/^https?:\/\//, "");
+
+ grpcClient = new proto.navigator.v1.Navigator(target, creds);
+ grpcEnabled = true;
+ console.log(`[policy-proxy] gRPC client initialized → ${target} (sandbox: ${sandboxName})`);
+
+ // Proactive connectivity probe: try to establish a connection within 3s.
+ // If the network enforcement proxy blocks us, fail fast here instead of
+ // making every Save wait for a 5s RPC timeout.
+ const probeDeadline = new Date(Date.now() + 3000);
+ grpcClient.waitForReady(probeDeadline, (err) => {
+ if (err) {
+ console.warn(`[policy-proxy] gRPC connectivity probe failed — disabling gateway sync: ${err.message}`);
+ grpcEnabled = false;
+ grpcPermanentlyDisabled = true;
+ } else {
+ console.log("[policy-proxy] gRPC connectivity probe succeeded.");
+ }
+ });
+}
+
+// ---------------------------------------------------------------------------
+// YAML → proto conversion
+// ---------------------------------------------------------------------------
+
+function yamlToProto(parsed) {
+ const fp = parsed.filesystem_policy;
+ return {
+ version: parsed.version || 1,
+ filesystem: fp ? {
+ include_workdir: !!fp.include_workdir,
+ read_only: fp.read_only || [],
+ read_write: fp.read_write || [],
+ } : undefined,
+ landlock: parsed.landlock ? {
+ compatibility: parsed.landlock.compatibility || "",
+ } : undefined,
+ process: parsed.process ? {
+ run_as_user: parsed.process.run_as_user || "",
+ run_as_group: parsed.process.run_as_group || "",
+ } : undefined,
+ network_policies: convertNetworkPolicies(parsed.network_policies || {}),
+ };
+}
+
+function convertNetworkPolicies(policies) {
+ const result = {};
+ for (const [key, rule] of Object.entries(policies)) {
+ result[key] = {
+ name: rule.name || key,
+ endpoints: (rule.endpoints || []).map(convertEndpoint),
+ binaries: (rule.binaries || []).map((b) => ({ path: b.path || "" })),
+ };
+ }
+ return result;
+}
+
+function convertEndpoint(ep) {
+ return {
+ host: ep.host || "",
+ port: ep.port || 0,
+ protocol: ep.protocol || "",
+ tls: ep.tls || "",
+ enforcement: ep.enforcement || "",
+ access: ep.access || "",
+ rules: (ep.rules || []).map((r) => ({
+ allow: {
+ method: (r.allow && r.allow.method) || "",
+ path: (r.allow && r.allow.path) || "",
+ command: (r.allow && r.allow.command) || "",
+ },
+ })),
+ allowed_ips: ep.allowed_ips || [],
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Push policy to gateway via gRPC
+// ---------------------------------------------------------------------------
+
+function pushPolicyToGateway(yamlBody) {
+ return new Promise((resolve) => {
+ if (!grpcEnabled || !grpcClient || grpcPermanentlyDisabled) {
+ resolve({ applied: false, reason: "network_enforcement" });
+ return;
+ }
+
+ let yaml;
+ try {
+ yaml = require("js-yaml");
+ } catch (e) {
+ resolve({ applied: false, reason: "js-yaml not available: " + e.message });
+ return;
+ }
+
+ let parsed;
+ try {
+ parsed = yaml.load(yamlBody);
+ } catch (e) {
+ resolve({ applied: false, reason: "YAML parse error: " + e.message });
+ return;
+ }
+
+ let policyProto;
+ try {
+ policyProto = yamlToProto(parsed);
+ } catch (e) {
+ resolve({ applied: false, reason: "proto conversion error: " + e.message });
+ return;
+ }
+
+ const request = {
+ name: sandboxName,
+ policy: policyProto,
+ };
+
+ const deadline = new Date(Date.now() + 5000);
+ grpcClient.UpdateSandboxPolicy(request, { deadline }, (err, response) => {
+ if (err) {
+ console.error("[policy-proxy] gRPC UpdateSandboxPolicy failed:", err.message);
+ grpcEnabled = false;
+ grpcPermanentlyDisabled = true;
+ console.warn("[policy-proxy] Circuit-breaker tripped — disabling gateway sync for future requests.");
+ resolve({ applied: false, reason: "network_enforcement" });
+ return;
+ }
+ console.log(
+ `[policy-proxy] Policy pushed to gateway: version=${response.version}, hash=${response.policy_hash}`
+ );
+ resolve({
+ applied: true,
+ version: response.version,
+ policy_hash: response.policy_hash,
+ });
+ });
+ });
+}
+
+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);
+}
+
+// ---------------------------------------------------------------------------
+// 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
+// ---------------------------------------------------------------------------
+
+function proxyRequest(clientReq, clientRes) {
+ console.log(`[policy-proxy] http in ${formatRequestLine(clientReq)} -> ${UPSTREAM_HOST}:${UPSTREAM_PORT}`);
+ const opts = {
+ hostname: UPSTREAM_HOST,
+ port: UPSTREAM_PORT,
+ path: clientReq.url,
+ method: clientReq.method,
+ headers: clientReq.headers,
+ };
+
+ 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 });
+ });
+
+ upstream.on("error", (err) => {
+ console.error("[proxy] upstream error:", err.message);
+ if (!clientRes.headersSent) {
+ clientRes.writeHead(502, { "Content-Type": "application/json" });
+ }
+ clientRes.end(JSON.stringify({ error: "upstream unavailable" }));
+ });
+
+ clientReq.pipe(upstream, { end: true });
+}
+
+// ---------------------------------------------------------------------------
+// /api/policy handlers
+// ---------------------------------------------------------------------------
+
+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, {
+ "Content-Type": "application/json",
+ });
+ res.end(JSON.stringify({ error: err.code === "ENOENT" ? "policy file not found" : err.message }));
+ return;
+ }
+ res.writeHead(200, { "Content-Type": "text/yaml; charset=utf-8" });
+ res.end(data);
+ });
+}
+
+function handlePolicyPost(req, res) {
+ const t0 = Date.now();
+ console.log(`[policy-proxy] policy post ${formatRequestLine(req)}`);
+ const chunks = [];
+ req.on("data", (chunk) => chunks.push(chunk));
+ req.on("end", () => {
+ const body = Buffer.concat(chunks).toString("utf8");
+ console.log(`[policy-proxy] body: ${body.length} bytes`);
+
+ if (!body.trim()) {
+ console.log(`[policy-proxy] REJECTED: empty body`);
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "empty body" }));
+ return;
+ }
+
+ if (!body.includes("version:")) {
+ console.log(`[policy-proxy] REJECTED: missing version field`);
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "invalid policy: missing version field" }));
+ return;
+ }
+
+ console.log(`[policy-proxy] step 1/3: writing to disk → ${POLICY_PATH}`);
+ const tmp = os.tmpdir() + "/policy.yaml.tmp." + process.pid;
+ fs.writeFile(tmp, body, "utf8", (writeErr) => {
+ if (writeErr) {
+ console.error(`[policy-proxy] step 1/3: FAILED — ${writeErr.message}`);
+ res.writeHead(500, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "write failed: " + writeErr.message }));
+ return;
+ }
+ fs.rename(tmp, POLICY_PATH, (renameErr) => {
+ if (renameErr) {
+ fs.writeFile(POLICY_PATH, body, "utf8", (fallbackErr) => {
+ fs.unlink(tmp, () => {});
+ if (fallbackErr) {
+ console.error(`[policy-proxy] step 1/3: FAILED (fallback) — ${fallbackErr.message}`);
+ res.writeHead(500, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "write failed: " + fallbackErr.message }));
+ return;
+ }
+ console.log(`[policy-proxy] step 1/3: saved to disk (fallback write) [${Date.now() - t0}ms]`);
+ syncAndRespond(body, res, t0);
+ });
+ return;
+ }
+ console.log(`[policy-proxy] step 1/3: saved to disk (atomic rename) [${Date.now() - t0}ms]`);
+ syncAndRespond(body, res, t0);
+ });
+ });
+ });
+}
+
+function syncAndRespond(yamlBody, res, t0) {
+ console.log(`[policy-proxy] step 2/3: attempting gRPC gateway sync (enabled=${grpcEnabled}, disabled=${grpcPermanentlyDisabled})`);
+ pushPolicyToGateway(yamlBody).then((result) => {
+ const payload = { ok: true, ...result };
+ console.log(`[policy-proxy] step 3/3: responding — applied=${result.applied}, reason=${result.reason || "n/a"} [${Date.now() - t0}ms total]`);
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(payload));
+ });
+}
+
+// ---------------------------------------------------------------------------
+// HTTP server
+// ---------------------------------------------------------------------------
+
+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");
+
+ if (req.method === "OPTIONS") {
+ res.writeHead(204);
+ res.end();
+ } else if (req.method === "GET") {
+ handlePolicyGet(req, res);
+ } else if (req.method === "POST") {
+ handlePolicyPost(req, res);
+ } else {
+ res.writeHead(405, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "method not allowed" }));
+ }
+ return;
+ }
+
+ if (req.url && req.url.startsWith("/api/sandbox-denials")) {
+ handleDenialsGet(req, res);
+ return;
+ }
+
+ proxyRequest(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 = "";
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
+ headers += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`;
+ }
+ upstream.write(reqLine + headers + "\r\n");
+ if (head && head.length) upstream.write(head);
+ socket.pipe(upstream);
+ upstream.pipe(socket);
+ });
+
+ upstream.on("error", (err) => {
+ console.error("[proxy] websocket upstream error:", err.message);
+ socket.destroy();
+ });
+
+ socket.on("error", (err) => {
+ console.error("[proxy] websocket client error:", err.message);
+ upstream.destroy();
+ });
+});
+
+// Initialize gRPC client before starting the HTTP server.
+initGrpcClient();
+resolveSandboxId();
+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();
+});
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 = `
-
-
`;
-
- 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}
-
-
-
- ${ICON_EYE}
-
-
`;
-
- 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 = `
+
+ ${escapeHtml(label)}
+
+
+ ${ICON_TRASH} `;
+
+ 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)}
+ ${ICON_CLOSE} `;
+ 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)}
+
+
+
+ `;
+
+ 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/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.icon} ` +
- `${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 3389b89..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,
@@ -68,6 +69,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
// ---------------------------------------------------------------------------
@@ -141,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();
@@ -155,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",
@@ -170,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",
@@ -184,6 +197,104 @@ 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 {
+ if (isPreviewMode()) return [];
+ 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 +393,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 +421,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 = `
+
+ 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))}
+
+ ${ICON_CHECK} Approve `;
+ 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 +1573,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/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 43ad9e7..db6d073 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
=========================================== */
@@ -169,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;
@@ -469,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;
@@ -664,6 +704,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
=========================================== */
@@ -816,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;
@@ -1337,92 +1542,269 @@ body.nemoclaw-switching openclaw-app {
color: #76B900;
}
-/* Section */
+/* Recommendations (from recent blocks — one-click approve) */
-.nemoclaw-policy-section {
- margin-bottom: 28px;
+.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;
}
-.nemoclaw-policy-section__header {
+:root[data-theme="light"] .nemoclaw-policy-recommendations {
+ background: rgba(234, 179, 8, 0.08);
+}
+
+.nemoclaw-policy-recommendations__header {
display: flex;
align-items: center;
- gap: 10px;
- margin-bottom: 6px;
+ gap: 8px;
+ margin-bottom: 4px;
}
-.nemoclaw-policy-section__icon {
+.nemoclaw-policy-recommendations__icon {
display: flex;
- width: 20px;
- height: 20px;
- color: var(--muted, #71717a);
+ width: 18px;
+ height: 18px;
+ color: #eab308;
}
-.nemoclaw-policy-section__icon svg {
- width: 20px;
- height: 20px;
+.nemoclaw-policy-recommendations__icon svg {
+ width: 18px;
+ height: 18px;
stroke: currentColor;
fill: none;
- stroke-width: 1.5px;
+ stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
}
-.nemoclaw-policy-section__title {
- font-size: 16px;
+.nemoclaw-policy-recommendations__title {
+ font-size: 15px;
font-weight: 700;
color: var(--text-strong, #fafafa);
margin: 0;
}
-.nemoclaw-policy-section__desc {
- font-size: 13px;
- line-height: 1.55;
- color: var(--muted, #71717a);
- margin: 0 0 16px;
+.nemoclaw-policy-recommendations__count {
+ font-size: 14px;
+ font-weight: 600;
+ color: #eab308;
+ margin-left: 2px;
}
-.nemoclaw-policy-section__desc code {
+.nemoclaw-policy-recommendations__approve-all {
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
font-size: 12px;
- padding: 1px 5px;
- border-radius: 4px;
- background: rgba(118, 185, 0, 0.08);
- color: #76B900;
+ font-weight: 600;
+ color: #fff;
+ background: #76B900;
+ border: none;
+ border-radius: var(--radius-sm, 6px);
+ cursor: pointer;
+ transition: background 120ms ease, opacity 120ms ease;
}
-/* Immutable cards */
+.nemoclaw-policy-recommendations__approve-all:hover:not(:disabled) {
+ background: #6aa300;
+}
-.nemoclaw-policy-immutable-cards {
- display: grid;
- gap: 12px;
+.nemoclaw-policy-recommendations__approve-all:disabled {
+ opacity: 0.7;
+ cursor: not-allowed;
}
-.nemoclaw-policy-card {
- border: 1px solid var(--border, #27272a);
- border-radius: var(--radius-md, 8px);
- padding: 16px;
- background: var(--bg-elevated, #1a1d25);
+.nemoclaw-policy-recommendations__desc {
+ font-size: 12px;
+ color: var(--muted, #a1a1aa);
+ margin: 0 0 12px;
+ line-height: 1.45;
}
-:root[data-theme="light"] .nemoclaw-policy-card {
- background: #fff;
+.nemoclaw-policy-recommendations__list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
}
-.nemoclaw-policy-card--locked {
- opacity: 0.85;
+.nemoclaw-policy-recommendations__list--scrollable {
+ max-height: 280px;
+ overflow-y: auto;
}
-.nemoclaw-policy-card__header {
+.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;
- gap: 8px;
- margin-bottom: 8px;
+ 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);
}
-.nemoclaw-policy-card__icon {
- display: flex;
- width: 18px;
- height: 18px;
- color: var(--muted, #71717a);
+: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 {
+ margin-bottom: 28px;
+}
+
+.nemoclaw-policy-section__header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 6px;
+}
+
+.nemoclaw-policy-section__icon {
+ display: flex;
+ width: 20px;
+ height: 20px;
+ color: var(--muted, #71717a);
+}
+
+.nemoclaw-policy-section__icon svg {
+ width: 20px;
+ height: 20px;
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 1.5px;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.nemoclaw-policy-section__title {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-strong, #fafafa);
+ margin: 0;
+}
+
+.nemoclaw-policy-section__desc {
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--muted, #71717a);
+ margin: 0 0 16px;
+}
+
+.nemoclaw-policy-section__desc code {
+ font-size: 12px;
+ padding: 1px 5px;
+ border-radius: 4px;
+ background: rgba(118, 185, 0, 0.08);
+ color: #76B900;
+}
+
+/* Immutable cards */
+
+.nemoclaw-policy-immutable-cards {
+ display: grid;
+ gap: 12px;
+}
+
+.nemoclaw-policy-card {
+ border: 1px solid var(--border, #27272a);
+ border-radius: var(--radius-md, 8px);
+ padding: 16px;
+ background: var(--bg-elevated, #1a1d25);
+}
+
+:root[data-theme="light"] .nemoclaw-policy-card {
+ background: #fff;
+}
+
+.nemoclaw-policy-card--locked {
+ opacity: 0.85;
+}
+
+.nemoclaw-policy-card__header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.nemoclaw-policy-card__icon {
+ display: flex;
+ width: 18px;
+ height: 18px;
+ color: var(--muted, #71717a);
}
.nemoclaw-policy-card__icon svg {
@@ -2611,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:
@@ -2620,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;
@@ -2635,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);
}
@@ -2884,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
=================================================================== */
@@ -3014,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)
=================================================================== */
@@ -3036,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;
@@ -3114,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;
}
@@ -3185,6 +3847,7 @@ body.nemoclaw-switching openclaw-app {
.nc-providers-section__body {
padding: 0 16px 16px;
+ overflow: visible;
}
/* --- Type Pills --- */
@@ -3362,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 }