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)} + `; + const dismissBtn = row.querySelector(".nemoclaw-sandbox-denial-row__dismiss"); + if (dismissBtn) { + dismissBtn.addEventListener("click", (e) => { + e.stopPropagation(); + dismissRow(row); + }); + } + return row; +} + +function dismissRow(row: HTMLElement): void { + const key = row.getAttribute("data-denial-key"); + if (key) { + seenKeys.delete(key); + activeDenials = activeDenials.filter((d) => denialKey(d) !== key); + } + renderDenialMessages(); +} + +function renderDenialMessages(): void { + const parent = getOrCreateContainer(); + if (!parent) return; + + if (activeDenials.length === 0) { + if (container?.parentElement) { + container.remove(); + container = null; + } + return; + } + + const n = activeDenials.length; + const label = n === 1 ? "1 blocked request" : `${n} blocked requests`; + + parent.innerHTML = ""; + parent.className = "nemoclaw-sandbox-denials"; + + const card = document.createElement("div"); + card.className = "nemoclaw-sandbox-denial-card"; + card.innerHTML = ` +
+ ${ICON_SHIELD} + OpenShell Sandbox — ${escapeHtml(label)} +
+
+
+
+ Add allow rules in Sandbox Policy to continue. +
`; + + const list = card.querySelector(".nemoclaw-sandbox-denials__list")!; + const ordered = sortedDenials(); + for (const denial of ordered) { + list.appendChild(createRow(denial)); + } + + parent.appendChild(card); +} + +function injectDenialAsMessage(denial: DenialEvent): void { + const key = denialKey(denial); + if (seenKeys.has(key)) return; + seenKeys.add(key); + activeDenials.push(denial); + renderDenialMessages(); +} + +/** + * Clear denial UI and state. + * @param keepSeenKeys - If true, do not clear seenKeys so the same denials + * won't be re-shown on next poll (use when policy was just saved/approved). + */ +function clearAllDenialMessages(keepSeenKeys = false): void { + if (!keepSeenKeys) seenKeys.clear(); + activeDenials = []; + if (container?.parentElement) { + container.remove(); + container = null; + } +} + +// --------------------------------------------------------------------------- +// Poll loop +// --------------------------------------------------------------------------- + +async function poll(): Promise { + try { + const data = await fetchDenials(lastTs); + if (data.latest_ts > lastTs) lastTs = data.latest_ts; + + for (const denial of data.denials) { + injectDenialAsMessage(denial); + } + } catch { + // Non-fatal — will retry on next poll + } + + if (running) { + pollTimer = setTimeout(poll, POLL_INTERVAL_MS); + } +} + +// --------------------------------------------------------------------------- +// Policy-saved event handler +// --------------------------------------------------------------------------- + +function onPolicySaved(): void { + clearAllDenialMessages(true); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function startDenialWatcher(): void { + if (running) return; + running = true; + + lastTs = Date.now() - 60_000; + + document.addEventListener("nemoclaw:policy-saved", onPolicySaved); + + poll(); +} + +export function stopDenialWatcher(): void { + running = false; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + document.removeEventListener("nemoclaw:policy-saved", onPolicySaved); + clearAllDenialMessages(); +} diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts 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 = ` -
-
-
API Keys
-
Configure your NVIDIA API keys for model endpoints
-
-
-
`; - - const page = container.querySelector(".nemoclaw-key-page")!; - - const intro = document.createElement("div"); - intro.className = "nemoclaw-key-intro"; - intro.innerHTML = ` -
${ICON_KEY}
-

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

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

${def.description}

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

${targetsHtml}
+

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

`; @@ -109,6 +110,16 @@ function buildModal(): HTMLElement { if ((e as KeyboardEvent).key === "Escape") closeModal(); }); + // Set partner link to include current model so landing page can preselect it + fetch("/api/cluster-inference") + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { + const modelId = body?.ok && body?.modelId ? body.modelId : ""; + const link = overlay.querySelector(".nemoclaw-modal__partner-link"); + if (link) link.href = getUpgradeIntegrationsUrl(modelId); + }) + .catch(() => {}); + return overlay; } @@ -154,7 +165,7 @@ function disableTargets(overlay: HTMLElement, disabled: boolean) { async function handleDeploy(target: DeployTarget, overlay: HTMLElement) { const apiKey = getApiKey(target); if (!isKeyConfigured(apiKey)) { - setStatus(overlay, "error", `API key not configured. Add your keys to get started.`); + setStatus(overlay, "error", `API key not configured. Add your keys in Inference to get started.`); return; } diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts index 016228f..b40c8f7 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/index.ts @@ -3,8 +3,7 @@ * * Injects into the OpenClaw UI: * 1. A green "Deploy DGX Spark/Station" CTA button in the topbar - * 2. A "NeMoClaw" collapsible nav group with Policy, Inference Routes, - * and API Keys pages + * 2. A "NeMoClaw" collapsible nav group with Policy and Inference * 3. A model selector wired to NVIDIA endpoints * * Operates purely as an overlay — no original OpenClaw source files are modified. @@ -17,6 +16,8 @@ import { injectModelSelector, watchChatCompose } from "./model-selector.ts"; import { ingestKeysFromUrl, DEFAULT_MODEL, resolveApiKey, isKeyConfigured } from "./model-registry.ts"; import { hasBlockingGatewayMessage, waitForStableConnection } from "./gateway-bridge.ts"; import { syncKeysToProviders } from "./api-keys-page.ts"; +import { startDenialWatcher } from "./denial-watcher.ts"; +import { isPreviewMode } from "./preview-mode.ts"; const STABLE_CONNECTION_WINDOW_MS = 1_500; const INITIAL_CONNECTION_TIMEOUT_MS = 20_000; @@ -114,6 +115,7 @@ function revealApp(): void { overlay.addEventListener("transitionend", () => overlay.remove(), { once: true }); setTimeout(() => overlay.remove(), 600); } + startDenialWatcher(); } function shouldAllowRecoveryReload(): boolean { @@ -184,6 +186,27 @@ function getOverlayTextForPairingState(state: PairingBootstrapState | null): str } function bootstrap() { + // Preview mode: no gateway, no pairing overlay — show UI immediately for local dev. + if (isPreviewMode()) { + document.body.setAttribute("data-nemoclaw-ready", ""); + watchOpenClawNavClicks(); + watchChatCompose(); + watchGotoLinks(); + if (inject()) { + injectModelSelector(); + return; + } + const observer = new MutationObserver(() => { + if (inject()) { + injectModelSelector(); + observer.disconnect(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + setTimeout(() => observer.disconnect(), 30_000); + return; + } + console.info("[NeMoClaw] pairing bootstrap: start"); let pairingPollTimer = 0; diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/inference-page.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/inference-page.ts index 2c30b13..680a270 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/inference-page.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/inference-page.ts @@ -26,7 +26,12 @@ import { refreshModelSelector, setActiveModelFromExternal } from "./model-select import { CURATED_MODELS, getCuratedByModelId, + getUpgradeIntegrationsUrl, + PARTNER_PROVIDERS, } from "./model-registry.ts"; +import { getPartnerLogoImgSrc } from "./partner-logos.ts"; +import { buildApiKeysSection, getSectionCredentialEntries, getSectionCredentialKeyNames, getSectionKeyValue } from "./api-keys-page.ts"; +import { isPreviewMode } from "./preview-mode.ts"; // --------------------------------------------------------------------------- // Types @@ -54,6 +59,9 @@ interface ProviderDraft { type: string; credentials: Record; config: Record; + /** When "section", use value from API keys section below when saving. */ + /** For each credential key: "__custom__" = use draft.credentials; otherwise use section key value (e.g. OPENAI_API_KEY). */ + _credentialSource?: Record; } interface ProviderProfile { @@ -99,6 +107,12 @@ const PROVIDER_TEMPLATES: { label: string; name: string; type: string; config: R { label: "OpenAI", name: "openai", type: "openai", config: { OPENAI_BASE_URL: "https://api.openai.com/v1" } }, { label: "Anthropic", name: "anthropic", type: "anthropic", config: { ANTHROPIC_BASE_URL: "https://api.anthropic.com/v1" } }, { label: "Local (LM Studio)", name: "local_lmstudio", type: "openai", config: { OPENAI_BASE_URL: "http://localhost:1234/v1" } }, + ...PARTNER_PROVIDERS.map((p) => ({ + label: p.name, + name: `partner_${p.id}`, + type: "openai" as const, + config: { [p.configUrlKey]: p.baseUrl }, + })), ]; const PROVIDER_TYPE_OPTIONS = ["openai", "anthropic", "nvidia"]; @@ -125,6 +139,7 @@ let providersExpanded = true; // --------------------------------------------------------------------------- async function fetchProviders(): Promise { + if (isPreviewMode()) return []; const res = await fetch("/api/providers"); if (!res.ok) throw new Error(`Failed to load providers: ${res.status}`); const body = await res.json(); @@ -133,6 +148,7 @@ async function fetchProviders(): Promise { } async function apiCreateProvider(draft: { name: string; type: string; credentials: Record; config: Record }): Promise { + if (isPreviewMode()) return; const res = await fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -143,6 +159,7 @@ async function apiCreateProvider(draft: { name: string; type: string; credential } async function apiUpdateProvider(name: string, draft: { type: string; credentials: Record; config: Record }): Promise { + if (isPreviewMode()) return; const res = await fetch(`/api/providers/${encodeURIComponent(name)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -153,12 +170,14 @@ async function apiUpdateProvider(name: string, draft: { type: string; credential } async function apiDeleteProvider(name: string): Promise { + if (isPreviewMode()) return; const res = await fetch(`/api/providers/${encodeURIComponent(name)}`, { method: "DELETE" }); const body = await res.json(); if (!body.ok) throw new Error(body.error || "Delete failed"); } async function fetchClusterInference(): Promise { + if (isPreviewMode()) return null; const res = await fetch("/api/cluster-inference"); if (!res.ok) return null; const body = await res.json(); @@ -167,6 +186,7 @@ async function fetchClusterInference(): Promise { } async function apiSetClusterInference(providerName: string, modelId: string): Promise { + if (isPreviewMode()) return; const res = await fetch("/api/cluster-inference", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -243,9 +263,9 @@ async function loadAndRender(container: HTMLElement): Promise { function renderPageContent(page: HTMLElement): void { page.innerHTML = ""; page.appendChild(buildGatewayStrip()); - page.appendChild(buildQuickPicker()); page.appendChild(buildActiveConfig()); page.appendChild(buildProviderSection()); + page.appendChild(buildApiKeysSection()); saveBarEl = buildSaveBar(); page.appendChild(saveBarEl); } @@ -321,44 +341,38 @@ function saveCustomQuickSelects(items: { modelId: string; name: string; provider localStorage.setItem("nemoclaw:custom-quick-selects", JSON.stringify(items)); } -function buildQuickPicker(): HTMLElement { - const section = document.createElement("div"); - section.className = "nc-quick-picker"; - - const label = document.createElement("div"); - label.className = "nc-quick-picker__label"; - label.textContent = "Quick Select"; - section.appendChild(label); +/** Builds only the chip strip (no "Quick Select" label, no Add). Used inside Active Config Model row. */ +function buildQuickPickerStrip(currentModelId: string, onRefresh: () => void): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "nc-active-config__model-quick-strip"; const strip = document.createElement("div"); strip.className = "nc-quick-picker__strip"; - const currentModelId = pendingActivation?.modelId ?? activeRoute?.modelId ?? ""; - for (const curated of CURATED_MODELS) { - strip.appendChild(buildQuickChip(curated.modelId, curated.name, curated.providerName, currentModelId, section, false)); + strip.appendChild(buildQuickChip(curated.modelId, curated.name, curated.providerName, currentModelId, null, false, onRefresh)); } const custom = getCustomQuickSelects(); const curatedIds = new Set(CURATED_MODELS.map((c) => c.modelId)); for (const item of custom) { if (curatedIds.has(item.modelId)) continue; - strip.appendChild(buildQuickChip(item.modelId, item.name, item.providerName, currentModelId, section, true)); + strip.appendChild(buildQuickChip(item.modelId, item.name, item.providerName, currentModelId, null, true, onRefresh)); } - section.appendChild(strip); - - const addBtn = document.createElement("button"); - addBtn.type = "button"; - addBtn.className = "nc-quick-picker__add-btn"; - addBtn.innerHTML = `${ICON_PLUS} Add`; - addBtn.addEventListener("click", () => showAddQuickSelectForm(section)); - section.appendChild(addBtn); - - return section; + wrap.appendChild(strip); + return wrap; } -function buildQuickChip(modelId: string, name: string, providerName: string, currentModelId: string, section: HTMLElement, removable: boolean): HTMLElement { +function buildQuickChip( + modelId: string, + name: string, + providerName: string, + currentModelId: string, + section: HTMLElement | null, + removable: boolean, + onActivate?: () => void, +): HTMLElement { const chip = document.createElement("button"); chip.type = "button"; const isActive = modelId === currentModelId; @@ -378,7 +392,7 @@ function buildQuickChip(modelId: string, name: string, providerName: string, cur e.stopPropagation(); const items = getCustomQuickSelects().filter((i) => i.modelId !== modelId); saveCustomQuickSelects(items); - chip.remove(); + if (onActivate) onActivate(); refreshModelSelector().catch(() => {}); }); chip.appendChild(removeBtn); @@ -387,79 +401,58 @@ function buildQuickChip(modelId: string, name: string, providerName: string, cur chip.addEventListener("click", () => { pendingActivation = { providerName, modelId }; markDirty(); - rerenderQuickPicker(section); - rerenderActiveConfig(); + if (onActivate) onActivate(); + else if (section) { + rerenderQuickPicker(section); + rerenderActiveConfig(); + } }); return chip; } -function showAddQuickSelectForm(section: HTMLElement): void { - const existing = section.querySelector(".nc-quick-picker__add-form"); - if (existing) { existing.remove(); return; } - - const form = document.createElement("div"); - form.className = "nc-quick-picker__add-form"; - - const nameInput = document.createElement("input"); - nameInput.type = "text"; - nameInput.className = "nemoclaw-policy-input nc-quick-picker__add-input"; - nameInput.placeholder = "Display name"; - - const modelInput = document.createElement("input"); - modelInput.type = "text"; - modelInput.className = "nemoclaw-policy-input nc-quick-picker__add-input"; - modelInput.placeholder = "Model ID (e.g. nvidia/meta/llama-3.3-70b-instruct)"; - - const provInput = document.createElement("input"); - provInput.type = "text"; - provInput.className = "nemoclaw-policy-input nc-quick-picker__add-input"; - provInput.placeholder = "Provider name (e.g. nvidia-inference)"; - provInput.value = "nvidia-inference"; - - const btns = document.createElement("div"); - btns.className = "nc-quick-picker__add-actions"; - const addConfirm = document.createElement("button"); - addConfirm.type = "button"; - addConfirm.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--create"; - addConfirm.textContent = "Add"; - const cancelBtn = document.createElement("button"); - cancelBtn.type = "button"; - cancelBtn.className = "nemoclaw-policy-confirm-btn nemoclaw-policy-confirm-btn--cancel"; - cancelBtn.textContent = "Cancel"; +function rerenderQuickPicker(section: HTMLElement): void { + const fresh = buildQuickPicker(); + section.replaceWith(fresh); +} - cancelBtn.addEventListener("click", () => form.remove()); - addConfirm.addEventListener("click", () => { - const name = nameInput.value.trim(); - const mid = modelInput.value.trim(); - const prov = provInput.value.trim(); - if (!name || !mid || !prov) return; - const items = getCustomQuickSelects(); - if (items.some((i) => i.modelId === mid)) { form.remove(); return; } - items.push({ modelId: mid, name, providerName: prov }); - saveCustomQuickSelects(items); - form.remove(); +function buildQuickPicker(): HTMLElement { + const section = document.createElement("div"); + section.className = "nc-quick-picker"; + const label = document.createElement("div"); + label.className = "nc-quick-picker__label"; + label.textContent = "Quick Select"; + section.appendChild(label); + const currentModelId = pendingActivation?.modelId ?? activeRoute?.modelId ?? ""; + const stripWrap = buildQuickPickerStrip(currentModelId, () => { rerenderQuickPicker(section); - refreshModelSelector().catch(() => {}); + rerenderActiveConfig(); }); - - btns.appendChild(addConfirm); - btns.appendChild(cancelBtn); - form.appendChild(nameInput); - form.appendChild(modelInput); - form.appendChild(provInput); - form.appendChild(btns); - section.appendChild(form); - requestAnimationFrame(() => nameInput.focus()); + section.appendChild(stripWrap); + return section; } -function rerenderQuickPicker(section: HTMLElement): void { - const fresh = buildQuickPicker(); - section.replaceWith(fresh); +// --------------------------------------------------------------------------- +// Upgrade banner — when active route is NVIDIA free tier +// --------------------------------------------------------------------------- + +function buildUpgradeBanner(): HTMLElement | null { + const routeProviderName = pendingActivation?.providerName ?? activeRoute?.providerName ?? ""; + const routeModelId = pendingActivation?.modelId ?? activeRoute?.modelId ?? ""; + const isNvidiaFree = + routeProviderName === "nvidia-endpoints" || + (routeProviderName !== "" && routeProviderName.toLowerCase().includes("nvidia")); + if (!isNvidiaFree || !routeModelId) return null; + + const banner = document.createElement("div"); + banner.className = "nc-upgrade-banner"; + const url = getUpgradeIntegrationsUrl(routeModelId); + banner.innerHTML = `On NVIDIA free tier. For higher limits and speed: OpenShell integrations`; + return banner; } // --------------------------------------------------------------------------- -// Section 3 — Active Configuration +// Active Configuration (includes Quick Select and optional upgrade banner) // --------------------------------------------------------------------------- function buildActiveConfig(): HTMLElement { @@ -507,16 +500,16 @@ function buildActiveConfig(): HTMLElement { }; markDirty(); rerenderActiveConfig(); - const pickerSection = pageContainer?.querySelector(".nc-quick-picker"); - if (pickerSection) rerenderQuickPicker(pickerSection as HTMLElement); }); - // Model row + // Model row: input + embedded quick-select chips (no standalone "Quick Select" block) const modelRow = document.createElement("div"); - modelRow.className = "nc-active-config__row"; + modelRow.className = "nc-active-config__row nc-active-config__row--model"; const modelLabel = document.createElement("label"); modelLabel.className = "nc-active-config__label"; modelLabel.textContent = "Model"; + const modelWrap = document.createElement("div"); + modelWrap.className = "nc-active-config__model-wrap"; const modelInput = document.createElement("input"); modelInput.type = "text"; modelInput.className = "nemoclaw-policy-input nc-active-config__model-input"; @@ -528,11 +521,12 @@ function buildActiveConfig(): HTMLElement { modelId: modelInput.value, }; markDirty(); - const pickerSection = pageContainer?.querySelector(".nc-quick-picker"); - if (pickerSection) rerenderQuickPicker(pickerSection as HTMLElement); }); + const quickStrip = buildQuickPickerStrip(routeModelId, rerenderActiveConfig); + modelWrap.appendChild(modelInput); + modelWrap.appendChild(quickStrip); modelRow.appendChild(modelLabel); - modelRow.appendChild(modelInput); + modelRow.appendChild(modelWrap); card.appendChild(modelRow); // Endpoint row (read-only, derived from provider config) @@ -569,6 +563,12 @@ function buildActiveConfig(): HTMLElement { statusRow.appendChild(statusValue); card.appendChild(statusRow); + const upgradeBanner = buildUpgradeBanner(); + if (upgradeBanner) { + upgradeBanner.classList.add("nc-active-config__upgrade-banner"); + card.appendChild(upgradeBanner); + } + return card; } @@ -618,15 +618,11 @@ function buildProviderSection(): HTMLElement { section.appendChild(headerRow); - // Provider list + // Provider list — no empty-state tiles; Add Provider dropdown is the only way to add const list = document.createElement("div"); list.className = "nemoclaw-policy-netpolicies nemoclaw-inference-provider-list"; - if (providers.length === 0) { - list.appendChild(buildProviderEmptyState(list)); - } else { - for (const provider of providers) { - list.appendChild(buildProviderCard(provider, list)); - } + for (const provider of providers) { + list.appendChild(buildProviderCard(provider, list)); } body.appendChild(list); @@ -652,7 +648,18 @@ function buildProviderSection(): HTMLElement { if (dropdownOpen) { closeDropdown(); return; } dropdownOpen = true; dropdownEl = document.createElement("div"); - dropdownEl.className = "nemoclaw-policy-templates"; + dropdownEl.className = "nemoclaw-policy-templates nemoclaw-policy-templates--portal"; + + const rect = addBtn.getBoundingClientRect(); + const gap = 6; + const maxHeight = Math.max(200, rect.top - 12); + dropdownEl.style.position = "fixed"; + dropdownEl.style.bottom = `${window.innerHeight - rect.top + gap}px`; + dropdownEl.style.left = `${rect.left}px`; + dropdownEl.style.minWidth = `${Math.max(280, rect.width)}px`; + dropdownEl.style.maxHeight = `${maxHeight}px`; + dropdownEl.style.overflowY = "auto"; + dropdownEl.style.zIndex = "10000"; const blankOpt = document.createElement("button"); blankOpt.type = "button"; @@ -671,15 +678,36 @@ function buildProviderSection(): HTMLElement { const opt = document.createElement("button"); opt.type = "button"; opt.className = "nemoclaw-policy-template-option"; - opt.innerHTML = `${escapeHtml(tmpl.label)} - ${escapeHtml(tmpl.type)} — ${escapeHtml(urlPreview)}`; + + const partnerId = tmpl.name.startsWith("partner_") ? tmpl.name.slice(8) : ""; + const partner = partnerId ? PARTNER_PROVIDERS.find((p) => p.id === partnerId) : null; + if (partner) { + const img = document.createElement("img"); + img.src = getPartnerLogoImgSrc(partner.logoId); + img.alt = ""; + img.width = 20; + img.height = 20; + img.className = "nemoclaw-policy-template-option__logo"; + img.onerror = () => { img.src = getPartnerLogoImgSrc("generic"); }; + opt.appendChild(img); + } + + const labelSpan = document.createElement("span"); + labelSpan.className = "nemoclaw-policy-template-option__label"; + labelSpan.textContent = tmpl.label; + opt.appendChild(labelSpan); + const metaSpan = document.createElement("span"); + metaSpan.className = "nemoclaw-policy-template-option__meta"; + metaSpan.textContent = `${tmpl.type} — ${urlPreview}`; + opt.appendChild(metaSpan); + opt.addEventListener("click", (ev) => { ev.stopPropagation(); closeDropdown(); showInlineNewProviderForm(list, tmpl); }); dropdownEl.appendChild(opt); } - addWrap.appendChild(dropdownEl); + document.body.appendChild(dropdownEl); }); document.addEventListener("click", () => { if (dropdownOpen) closeDropdown(); }); @@ -689,27 +717,6 @@ function buildProviderSection(): HTMLElement { return section; } -function buildProviderEmptyState(list: HTMLElement): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "nemoclaw-inference-empty-tiles"; - for (const tmpl of PROVIDER_TEMPLATES) { - const profile = PROVIDER_PROFILES[tmpl.type]; - const tile = document.createElement("button"); - tile.type = "button"; - tile.className = "nemoclaw-inference-empty-tile"; - tile.innerHTML = ` - ${escapeHtml(tmpl.label)} - ${escapeHtml(tmpl.type)} - ${escapeHtml(profile?.defaultUrl || "")}`; - tile.addEventListener("click", () => { - wrap.remove(); - showInlineNewProviderForm(list, tmpl); - }); - wrap.appendChild(tile); - } - return wrap; -} - // --------------------------------------------------------------------------- // Provider card // --------------------------------------------------------------------------- @@ -721,6 +728,25 @@ function getProviderDraft(p: InferenceProvider): ProviderDraft { return p._draft; } +/** Resolve credentials for save: use section key value when _credentialSource[k] is a section key name; otherwise use custom. */ +function resolveCredentialsForSave(draft: ProviderDraft): Record { + const out: Record = {}; + const source = draft._credentialSource || {}; + const sectionKeys = getSectionCredentialKeyNames(); + const allKeys = new Set([...Object.keys(draft.credentials), ...Object.keys(source)]); + for (const k of allKeys) { + const src = source[k]; + const sectionKey = src === "section" ? k : src; + if (sectionKey && sectionKey !== CREDENTIAL_SOURCE_CUSTOM && sectionKeys.includes(sectionKey)) { + const v = getSectionKeyValue(sectionKey); + if (v) out[k] = v; + } else if (draft.credentials[k]) { + out[k] = draft.credentials[k]; + } + } + return out; +} + function getUrlPreview(p: InferenceProvider): string { const draft = p._draft; const profile = PROVIDER_PROFILES[draft?.type || p.type]; @@ -989,44 +1015,33 @@ function renderProviderBody(body: HTMLElement, provider: InferenceProvider): voi typeRow.appendChild(typeField); body.appendChild(typeRow); - // Credentials - const credRow = document.createElement("div"); - credRow.className = "nemoclaw-inference-flat-row"; - if (provider._isNew) { - credRow.appendChild(buildCredentialInput(provider, profile.credentialKey)); - } else if (provider.credentialKeys.length > 0) { - const chipRow = document.createElement("div"); - chipRow.className = "nemoclaw-inference-cred-chips"; - for (const key of provider.credentialKeys) { - const chip = document.createElement("span"); - chip.className = "nemoclaw-inference-cred-chip"; - chip.innerHTML = `${escapeHtml(key)} configured`; - chipRow.appendChild(chip); - } - credRow.appendChild(chipRow); - - const rotateToggle = document.createElement("button"); - rotateToggle.type = "button"; - rotateToggle.className = "nemoclaw-policy-ep-advanced-toggle"; - rotateToggle.innerHTML = `${ICON_CHEVRON_RIGHT} Rotate`; - let rotateOpen = Object.keys(draft.credentials).length > 0; - const rotatePanel = document.createElement("div"); - rotatePanel.style.display = rotateOpen ? "" : "none"; - if (rotateOpen) rotateToggle.classList.add("nemoclaw-policy-ep-advanced-toggle--open"); - for (const key of provider.credentialKeys) { - rotatePanel.appendChild(buildCredentialInput(provider, key)); - } - rotateToggle.addEventListener("click", () => { - rotateOpen = !rotateOpen; - rotatePanel.style.display = rotateOpen ? "" : "none"; - rotateToggle.classList.toggle("nemoclaw-policy-ep-advanced-toggle--open", rotateOpen); - }); - credRow.appendChild(rotateToggle); - credRow.appendChild(rotatePanel); - } else { - credRow.appendChild(buildCredentialInput(provider, profile.credentialKey)); + // Credentials — always show inline inputs; no chips or Rotate + const credWrap = document.createElement("div"); + credWrap.className = "nemoclaw-inference-cred-wrap"; + const credHeading = document.createElement("div"); + credHeading.className = "nemoclaw-inference-cred-heading"; + credHeading.textContent = "API keys"; + credWrap.appendChild(credHeading); + + const allCredKeys = provider._isNew + ? [profile.credentialKey] + : [...new Set([...provider.credentialKeys, ...Object.keys(draft.credentials)])]; + for (const key of allCredKeys) { + credWrap.appendChild(buildCredentialInput(provider, key)); } - body.appendChild(credRow); + + const addCredBtn = document.createElement("button"); + addCredBtn.type = "button"; + addCredBtn.className = "nemoclaw-policy-add-small-btn"; + addCredBtn.innerHTML = `${ICON_PLUS} Add credential`; + addCredBtn.addEventListener("click", () => { + const row = buildCredentialKeyValueRow(provider, credWrap); + credWrap.insertBefore(row, addCredBtn); + if (!provider._isNew) changeTracker.modified.add(provider.name); + markDirty(); + }); + credWrap.appendChild(addCredBtn); + body.appendChild(credWrap); // Config key-value pairs (label "Endpoint" for *_BASE_URL keys) const configRow = document.createElement("div"); @@ -1056,19 +1071,59 @@ function renderProviderBody(body: HTMLElement, provider: InferenceProvider): voi // Credential input // --------------------------------------------------------------------------- +const CREDENTIAL_SOURCE_CUSTOM = "__custom__"; + function buildCredentialInput(provider: InferenceProvider, keyName: string): HTMLElement { const draft = getProviderDraft(provider); + const sectionEntries = getSectionCredentialEntries(); + if (!draft._credentialSource) draft._credentialSource = {}; + let current = draft._credentialSource[keyName]; + if (current === "section") { + current = keyName; + draft._credentialSource[keyName] = keyName; + } + if (!(keyName in draft._credentialSource)) draft._credentialSource[keyName] = CREDENTIAL_SOURCE_CUSTOM; + const isCustom = !current || current === CREDENTIAL_SOURCE_CUSTOM; + const row = document.createElement("div"); - row.className = "nemoclaw-inference-cred-input-row"; + row.className = "nemoclaw-inference-cred-input-row nemoclaw-inference-cred-input-row--inline"; + const label = document.createElement("label"); label.className = "nemoclaw-policy-field"; label.innerHTML = `${escapeHtml(keyName)}`; + + const selectWrap = document.createElement("div"); + selectWrap.className = "nemoclaw-inference-cred-source-select-wrap"; + const select = document.createElement("select"); + select.className = "nemoclaw-policy-select nemoclaw-inference-cred-source-select"; + const customOpt = document.createElement("option"); + customOpt.value = CREDENTIAL_SOURCE_CUSTOM; + customOpt.textContent = "Custom"; + select.appendChild(customOpt); + for (const { keyName: sectionKey, label: sectionLabel } of sectionEntries) { + const opt = document.createElement("option"); + opt.value = sectionKey; + opt.textContent = sectionLabel; + if (current === sectionKey) opt.selected = true; + select.appendChild(opt); + } + if (isCustom) select.selectedIndex = 0; + select.addEventListener("change", () => { + draft._credentialSource![keyName] = select.value; + if (!provider._isNew) changeTracker.modified.add(provider.name); + markDirty(); + inputWrap.style.display = select.value === CREDENTIAL_SOURCE_CUSTOM ? "" : "none"; + }); + selectWrap.appendChild(select); + const inputWrap = document.createElement("div"); inputWrap.className = "nemoclaw-key-field__input-row"; + if (!isCustom) inputWrap.style.display = "none"; + const input = document.createElement("input"); input.type = "password"; input.className = "nemoclaw-policy-input"; - input.placeholder = provider._isNew ? "sk-... or nvapi-..." : "Enter new value to rotate"; + input.placeholder = "Paste value"; input.value = draft.credentials[keyName] || ""; input.addEventListener("input", () => { if (input.value.trim()) { draft.credentials[keyName] = input.value; } @@ -1087,11 +1142,76 @@ function buildCredentialInput(provider: InferenceProvider, keyName: string): HTM }); inputWrap.appendChild(input); inputWrap.appendChild(toggleBtn); - label.appendChild(inputWrap); + + const lineWrap = document.createElement("div"); + lineWrap.className = "nemoclaw-inference-cred-source-line"; + lineWrap.appendChild(selectWrap); + lineWrap.appendChild(inputWrap); + label.appendChild(lineWrap); row.appendChild(label); return row; } +/** Row for adding a new credential (key name + value). */ +function buildCredentialKeyValueRow(provider: InferenceProvider, credWrap: HTMLElement): HTMLElement { + const draft = getProviderDraft(provider); + const row = document.createElement("div"); + row.className = "nemoclaw-inference-cred-input-row nemoclaw-inference-cred-key-value-row"; + + const keyInput = document.createElement("input"); + keyInput.type = "text"; + keyInput.className = "nemoclaw-policy-input nemoclaw-inference-cred-key-input"; + keyInput.placeholder = "e.g. OPENAI_API_KEY"; + + const inputWrap = document.createElement("div"); + inputWrap.className = "nemoclaw-key-field__input-row"; + const valInput = document.createElement("input"); + valInput.type = "password"; + valInput.className = "nemoclaw-policy-input"; + valInput.placeholder = "Paste value"; + const updateDraft = () => { + const k = keyInput.value.trim(); + if (k) { + if (valInput.value.trim()) draft.credentials[k] = valInput.value; + else delete draft.credentials[k]; + } + if (!provider._isNew) changeTracker.modified.add(provider.name); + markDirty(); + }; + keyInput.addEventListener("input", updateDraft); + valInput.addEventListener("input", updateDraft); + + const toggleBtn = document.createElement("button"); + toggleBtn.type = "button"; + toggleBtn.className = "nemoclaw-key-field__toggle"; + toggleBtn.innerHTML = ICON_EYE; + toggleBtn.addEventListener("click", () => { + const isHidden = valInput.type === "password"; + valInput.type = isHidden ? "text" : "password"; + toggleBtn.innerHTML = isHidden ? ICON_EYE_OFF : ICON_EYE; + }); + inputWrap.appendChild(valInput); + inputWrap.appendChild(toggleBtn); + + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.className = "nemoclaw-policy-icon-btn nemoclaw-policy-icon-btn--danger"; + delBtn.title = "Remove"; + delBtn.innerHTML = ICON_TRASH; + delBtn.addEventListener("click", () => { + const k = keyInput.value.trim(); + if (k) delete draft.credentials[k]; + if (!provider._isNew) changeTracker.modified.add(provider.name); + markDirty(); + row.remove(); + }); + + row.appendChild(keyInput); + row.appendChild(inputWrap); + row.appendChild(delBtn); + return row; +} + // --------------------------------------------------------------------------- // Config row // --------------------------------------------------------------------------- @@ -1228,12 +1348,12 @@ async function handleSave(btn: HTMLButtonElement, feedback: HTMLElement, bar: HT const draft = provider._draft; if (!draft) continue; try { - await apiCreateProvider({ name: provider.name, type: draft.type, credentials: draft.credentials, config: draft.config }); + await apiCreateProvider({ name: provider.name, type: draft.type, credentials: resolveCredentialsForSave(draft), config: draft.config }); } catch (err) { const msg = String(err); if (msg.includes("AlreadyExists") || msg.includes("already exists")) { try { - await apiUpdateProvider(provider.name, { type: draft.type, credentials: draft.credentials, config: draft.config }); + await apiUpdateProvider(provider.name, { type: draft.type, credentials: resolveCredentialsForSave(draft), config: draft.config }); } catch (updateErr) { errors.push(`Update ${provider.name}: ${updateErr}`); } } else { errors.push(`Create ${provider.name}: ${err}`); @@ -1249,7 +1369,7 @@ async function handleSave(btn: HTMLButtonElement, feedback: HTMLElement, bar: HT const draft = provider._draft; if (!draft) continue; try { - await apiUpdateProvider(provider.name, { type: draft.type, credentials: draft.credentials, config: draft.config }); + await apiUpdateProvider(provider.name, { type: draft.type, credentials: resolveCredentialsForSave(draft), config: draft.config }); } catch (err) { errors.push(`Update ${provider.name}: ${err}`); } } } diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-registry.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-registry.ts index c59964e..5689d15 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-registry.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-registry.ts @@ -9,7 +9,6 @@ * `agents.defaults.model.primary` * * The two NVIDIA API platforms use separate API keys: - * - inference-api.nvidia.com — NVIDIA_INFERENCE_API_KEY * - integrate.api.nvidia.com — NVIDIA_INTEGRATE_API_KEY * * Keys are resolved at call time: localStorage (user-entered) takes @@ -130,24 +129,18 @@ export interface CuratedModel { } export const CURATED_MODELS: readonly CuratedModel[] = [ - { - id: "curated-kimi-k25", - name: "Kimi K2.5", - modelId: "moonshotai/kimi-k2.5", - providerName: "nvidia-endpoints", - }, - { - id: "curated-claude-opus", - name: "Claude Opus 4.6", - modelId: "aws/anthropic/bedrock-claude-opus-4-6", - providerName: "nvidia-inference", - }, { id: "curated-minimax-m25", name: "MiniMax M2.5", modelId: "minimaxai/minimax-m2.5", providerName: "nvidia-endpoints", }, + { + id: "curated-kimi-k25", + name: "Kimi K2.5", + modelId: "moonshotai/kimi-k2.5", + providerName: "nvidia-endpoints", + }, { id: "curated-glm5", name: "GLM 5", @@ -173,7 +166,7 @@ export function curatedToModelEntry(c: CuratedModel): ModelEntry { return { id: c.id, name: c.name, - isDefault: c.id === "curated-qwen35", + isDefault: c.id === "curated-minimax-m25", providerKey: key, modelRef: `${key}/${c.modelId}`, keyType: "inference", @@ -208,19 +201,19 @@ const DEFAULT_PROVIDER_KEY = "curated-nvidia-endpoints"; export const MODEL_REGISTRY: readonly ModelEntry[] = [ { - id: "curated-qwen35", - name: "Qwen 3.5 397B", + id: "curated-minimax-m25", + name: "MiniMax M2.5", isDefault: true, providerKey: DEFAULT_PROVIDER_KEY, - modelRef: `${DEFAULT_PROVIDER_KEY}/qwen/qwen3.5-397b-a17b`, + modelRef: `${DEFAULT_PROVIDER_KEY}/minimaxai/minimax-m2.5`, keyType: "inference", providerConfig: { baseUrl: "https://inference.local/v1", api: "openai-completions", models: [ { - id: "qwen/qwen3.5-397b-a17b", - name: "Qwen 3.5 397B", + id: "minimaxai/minimax-m2.5", + name: "MiniMax M2.5", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -380,3 +373,47 @@ export function getApiKey(target: DeployTarget): string { } return getInferenceApiKey(); } + +// --------------------------------------------------------------------------- +// Upgrade / partner offramp — build.nvidia.com/openshell/integrations +// --------------------------------------------------------------------------- + +const UPGRADE_INTEGRATIONS_BASE = "https://build.nvidia.com/openshell/integrations"; + +/** + * URL for upgrading the same model via an NVIDIA Cloud Partner. + * Use when active route is NVIDIA free tier; pass current model id (e.g. qwen/qwen3.5-397b-a17b). + */ +export function getUpgradeIntegrationsUrl(modelId: string): string { + if (!modelId || !modelId.trim()) return UPGRADE_INTEGRATIONS_BASE; + return `${UPGRADE_INTEGRATIONS_BASE}?model=${encodeURIComponent(modelId.trim())}`; +} + +/** + * Partner provider metadata for quick-add tiles and logos. + * OpenAI-compatible base URLs; credential key typically API_KEY or provider-specific. + * URLs verified from official docs where available; others are conventional placeholders + * (user can edit in Inference provider config). + */ +export interface PartnerProviderMeta { + id: string; + name: string; + baseUrl: string; + credentialKey: string; + configUrlKey: string; + /** LobeHub/lobe-icons icon id or "generic" for fallback */ + logoId: string; +} + +export const PARTNER_PROVIDERS: readonly PartnerProviderMeta[] = [ + { id: "baseten", name: "Baseten", baseUrl: "https://inference.baseten.co/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "Baseten" }, + { id: "bitdeer", name: "Bitdeer", baseUrl: "https://api-inference.bitdeer.ai/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "generic" }, + { id: "coreweave", name: "CoreWeave", baseUrl: "https://api.coreweave.com/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "generic" }, + { id: "deepinfra", name: "DeepInfra", baseUrl: "https://api.deepinfra.com/v1/openai", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "DeepInfra" }, + { id: "digitalocean", name: "Digital Ocean", baseUrl: "https://inference.do-ai.run/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "generic" }, + { id: "fireworks", name: "Fireworks AI", baseUrl: "https://api.fireworks.ai/inference/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "Fireworks" }, + { id: "gmicloud", name: "GMI Cloud", baseUrl: "https://api.gmi-serving.com/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "generic" }, + { id: "lightning", name: "Lightning AI", baseUrl: "https://lightning.ai/api/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "generic" }, + { id: "togetherai", name: "Together AI", baseUrl: "https://api.together.xyz/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "TogetherAI" }, + { id: "vultr", name: "Vultr", baseUrl: "https://api.vultr.com/v1", credentialKey: "OPENAI_API_KEY", configUrlKey: "OPENAI_BASE_URL", logoId: "generic" }, +]; diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-selector.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-selector.ts index 3c897ce..dd76683 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-selector.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/model-selector.ts @@ -25,6 +25,7 @@ import { CURATED_MODELS, curatedToModelEntry, getCuratedByModelId, + getUpgradeIntegrationsUrl, type ModelEntry, } from "./model-registry.ts"; import { patchConfig, waitForReconnect } from "./gateway-bridge.ts"; @@ -150,8 +151,12 @@ async function fetchDynamic(): Promise { let activeBanner: HTMLElement | null = null; let propagationTimer: ReturnType | null = null; -/** Sandbox polls the gateway every 30s for route updates. */ -const ROUTE_PROPAGATION_SECS = 30; +/** + * Max time (seconds) for inference route propagation into the sandbox. + * Must match DEFAULT_ROUTE_REFRESH_INTERVAL_SECS in + * openshell-sandbox/src/lib.rs (overridable there via OPENSHELL_ROUTE_REFRESH_INTERVAL_SECS). + */ +const ROUTE_PROPAGATION_SECS = 5; function showTransitionBanner(modelName: string): void { dismissTransitionBanner(); @@ -186,7 +191,7 @@ function showTransitionBannerLight(modelName: string): void { /** * Show an honest propagation banner for proxy-managed models. - * The NemoClaw sandbox polls for route updates every 30 seconds, so the + * The NemoClaw sandbox polls for route updates every ROUTE_PROPAGATION_SECS seconds, so the * switch isn't truly instant. This banner shows a progress bar that * counts down from ROUTE_PROPAGATION_SECS and transitions to a success * state when the propagation window has elapsed. @@ -297,7 +302,7 @@ async function applyModelSelection( if (isProxyManaged(entry)) { // Proxy-managed models route through inference.local. We update the // NemoClaw cluster-inference route (no OpenClaw config.patch, no - // gateway disconnect). The sandbox polls every ~30s for route + // gateway disconnect). The sandbox polls every ROUTE_PROPAGATION_SECS for route // updates, so we show an honest propagation countdown. const curated = getCuratedByModelId(entry.providerConfig.models[0]?.id || ""); const provName = curated?.providerName || entry.providerKey.replace(/^dynamic-/, ""); @@ -333,7 +338,7 @@ async function applyModelSelection( if (valueEl) valueEl.textContent = prev.name; updateDropdownSelection(wrapper, previousModelId); updateTransitionBannerError( - `API key not configured. Add your keys to switch models.`, + `API key not configured. Add your keys in Inference to switch models.`, ); return; } @@ -460,14 +465,30 @@ function buildModelSelector(): HTMLElement { populateDropdown(dropdown); + const poweredByBlock = document.createElement("div"); + poweredByBlock.className = "nemoclaw-model-powered-block"; const poweredBy = document.createElement("a"); poweredBy.className = "nemoclaw-model-powered"; poweredBy.href = "https://build.nvidia.com/models"; poweredBy.target = "_blank"; poweredBy.rel = "noopener noreferrer"; - poweredBy.textContent = "Powered by NVIDIA endpoints from build.nvidia.com"; + poweredBy.textContent = "Free endpoints by NVIDIA"; + const upgradeLink = document.createElement("a"); + upgradeLink.className = "nemoclaw-model-upgrade-link"; + upgradeLink.target = "_blank"; + upgradeLink.rel = "noopener noreferrer"; + upgradeLink.textContent = "Upgrade now"; + function updateUpgradeLink(): void { + const entry = getModelById(selectedModelId); + const modelId = entry?.providerConfig?.models?.[0]?.id ?? ""; + upgradeLink.href = getUpgradeIntegrationsUrl(modelId); + } + updateUpgradeLink(); + poweredByBlock.appendChild(poweredBy); + poweredByBlock.appendChild(document.createTextNode(". Rate-limited. ")); + poweredByBlock.appendChild(upgradeLink); - wrapper.appendChild(poweredBy); + wrapper.appendChild(poweredByBlock); wrapper.appendChild(trigger); wrapper.appendChild(dropdown); @@ -504,6 +525,7 @@ function buildModelSelector(): HTMLElement { updateDropdownSelection(wrapper, newModelId); const valueEl = trigger.querySelector(".nemoclaw-model-trigger__value"); if (valueEl) valueEl.textContent = entry.name; + updateUpgradeLink(); dropdown.style.display = "none"; trigger.setAttribute("aria-expanded", "false"); @@ -533,6 +555,7 @@ function buildModelSelector(): HTMLElement { if (valueEl) { valueEl.textContent = current ? current.name : "No model"; } + updateUpgradeLink(); }); return wrapper; diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/nav-group.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/nav-group.ts index 7218fa1..7cc2318 100644 --- a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/nav-group.ts +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/nav-group.ts @@ -1,12 +1,11 @@ /** * NeMoClaw DevX — Sidebar Nav Group * - * Collapsible "NeMoClaw" nav group with Policy, Inference Routes, and - * API Keys pages. Renders page overlays on top of . + * Collapsible "NeMoClaw" nav group with Policy and Inference Routes. + * Renders page overlays on top of . */ -import { ICON_SHIELD, ICON_ROUTE, ICON_KEY } from "./icons.ts"; -import { renderApiKeysPage, areAllKeysConfigured, updateStatusDots } from "./api-keys-page.ts"; +import { ICON_SHIELD, ICON_ROUTE } from "./icons.ts"; import { renderPolicyPage } from "./policy-page.ts"; import { renderInferencePage } from "./inference-page.ts"; @@ -22,7 +21,6 @@ interface NemoClawPage { subtitle: string; emptyMessage: string; customRender?: (container: HTMLElement) => void; - showStatusDot?: boolean; } const NEMOCLAW_PAGES: NemoClawPage[] = [ @@ -37,23 +35,13 @@ const NEMOCLAW_PAGES: NemoClawPage[] = [ }, { id: "nemoclaw-inference-routes", - label: "Inference Routes", + label: "Inference", icon: ICON_ROUTE, - title: "Inference Routes", - subtitle: "Configure model routing and endpoint mappings", + title: "Inference", + subtitle: "Configure model routing and API keys", emptyMessage: "", customRender: renderInferencePage, }, - { - id: "nemoclaw-api-keys", - label: "API Keys", - icon: ICON_KEY, - title: "API Keys", - subtitle: "Configure your NVIDIA API keys for model endpoints", - emptyMessage: "", - customRender: renderApiKeysPage, - showStatusDot: true, - }, ]; // --------------------------------------------------------------------------- @@ -91,18 +79,9 @@ function buildNavGroup(): HTMLElement { item.href = "#"; item.className = "nav-item"; item.dataset.nemoclawPage = page.id; - - let dotHtml = ""; - if (page.showStatusDot) { - const ok = areAllKeysConfigured(); - const dotClass = ok ? "nemoclaw-nav-dot--ok" : "nemoclaw-nav-dot--missing"; - dotHtml = ``; - } - item.innerHTML = `` + - `${page.label}` + - dotHtml; + `${page.label}`; item.addEventListener("click", (e) => { e.preventDefault(); diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/package-lock.json b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/package-lock.json new file mode 100644 index 0000000..a2d20f1 --- /dev/null +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "extension", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "js-yaml": "^4.1.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + } + } +} diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/partner-logos.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/partner-logos.ts new file mode 100644 index 0000000..247880c --- /dev/null +++ b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/partner-logos.ts @@ -0,0 +1,52 @@ +/** + * Partner provider logos for Inference tab tiles. + * Uses data URL for reliable rendering across contexts (no inline SVG parsing issues). + */ + +/** Generic cloud icon as data URL so renders reliably. */ +const GENERIC_ICON_SVG = + ''; +const GENERIC_ICON_DATA_URL = `data:image/svg+xml,${encodeURIComponent(GENERIC_ICON_SVG)}`; + +/** logoId -> domain for Clearbit logo CDN (logo.clearbit.com). Falls back to generic if missing or load fails. */ +const PARTNER_LOGO_DOMAINS: Record = { + Baseten: "baseten.co", + DeepInfra: "deepinfra.com", + Fireworks: "fireworks.ai", + TogetherAI: "together.xyz", + Anthropic: "anthropic.com", + OpenAI: "openai.com", + CoreWeave: "coreweave.com", + Lightning: "lightning.ai", + Vultr: "vultr.com", + DigitalOcean: "digitalocean.com", + Bitdeer: "bitdeer.com", +}; + +/** + * Returns a URL suitable for so the logo renders on screen. + * Uses Clearbit logo CDN for known partners; otherwise generic cloud icon. + */ +export function getPartnerLogoImgSrc(logoId: string): string { + if (!logoId || logoId === "generic") return GENERIC_ICON_DATA_URL; + const domain = PARTNER_LOGO_DOMAINS[logoId]; + if (domain) return `https://logo.clearbit.com/${domain}`; + return GENERIC_ICON_DATA_URL; +} + +/** + * Returns inline SVG HTML for a partner logo (legacy). + * Prefer getPartnerLogoImgSrc + for reliable rendering. + */ +export function getPartnerLogoHtml(logoId: string): string { + const src = getPartnerLogoImgSrc(logoId); + return ``; +} + +/** + * Returns a URL for a partner logo image (for ). + */ +export function getPartnerLogoUrl(logoId: string): string | null { + if (!logoId || logoId === "generic") return null; + return null; +} diff --git a/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/policy-page.ts b/sandboxes/openclaw-nvidia/nemoclaw-ui-extension/extension/policy-page.ts index 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 = ` +
+ ${ICON_SHIELD} +

Recommended from recent blocks

+ + +
+

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

+
+ Loading… +
`; + + const titleEl = section.querySelector(".nemoclaw-policy-recommendations__title")!; + const countEl = section.querySelector(".nemoclaw-policy-recommendations__count")!; + const approveAllBtn = section.querySelector(".nemoclaw-policy-recommendations__approve-all")!; + const list = section.querySelector(".nemoclaw-policy-recommendations__list")!; + + function setCount(n: number): void { + if (n === 0) { + countEl.textContent = ""; + approveAllBtn.style.display = "none"; + } else { + countEl.textContent = `(${n})`; + approveAllBtn.style.display = ""; + approveAllBtn.textContent = n === 1 ? "Approve all" : `Approve all ${n}`; + } + } + + (async () => { + try { + const denials = await fetchDenials(); + const toShow = denials.filter((d) => !denialAlreadyAllowed(d)); + const seen = new Set(); + const unique: DenialEvent[] = []; + for (const d of toShow) { + const key = `${d.host}:${d.port}:${d.binary}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(d); + } + + list.classList.remove("nemoclaw-policy-recommendations__list--empty"); + list.innerHTML = ""; + setCount(unique.length); + + if (unique.length === 0) { + list.classList.add("nemoclaw-policy-recommendations__list--empty"); + list.textContent = "No recent blocks. Denied connections will appear here."; + return; + } + + approveAllBtn.onclick = async () => { + approveAllBtn.disabled = true; + const snapshot = [...unique]; + for (const denial of snapshot) { + try { + await approveRecommendation(denial); + } catch (err) { + console.warn("[policy] approve all: one failed:", err); + } + } + approveAllBtn.disabled = false; + }; + + for (const denial of unique) { + const card = document.createElement("div"); + card.className = "nemoclaw-policy-recommendation-card"; + const binShort = binaryBasename(denial.binary) || "process"; + const portSuffix = denial.port === 443 || denial.port === 80 ? "" : `:${denial.port}`; + card.innerHTML = ` +
+ ${escapeHtml(binShort)}${escapeHtml(denial.host)}${escapeHtml(String(portSuffix))} +
+ `; + const btn = card.querySelector(".nemoclaw-policy-recommendation-card__approve"); + if (btn) { + btn.addEventListener("click", async () => { + btn.disabled = true; + btn.textContent = "Applying…"; + try { + await approveRecommendation(denial); + } catch (err) { + btn.disabled = false; + btn.innerHTML = `${ICON_CHECK} Approve`; + console.warn("[policy] approve recommendation failed:", err); + } + }); + } + list.appendChild(card); + } + } catch { + list.innerHTML = ""; + list.classList.add("nemoclaw-policy-recommendations__list--empty"); + list.textContent = "Could not load recommendations."; + setCount(0); + } + })(); + + return section; +} + // --------------------------------------------------------------------------- // Immutable grid (3 flat read-only cards) // --------------------------------------------------------------------------- @@ -1353,6 +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 }