From 631b0634081e2458e74c51684ca9d3987ded413b Mon Sep 17 00:00:00 2001 From: Patrick Riel Date: Wed, 13 May 2026 23:20:41 +0000 Subject: [PATCH 1/2] feat(policy): synthesize presets from openshell provider profiles --- src/lib/policy/index.ts | 236 +++++++++++++++++++++++++++++++++++++--- test/policies.test.ts | 79 ++++++++++++++ 2 files changed, 301 insertions(+), 14 deletions(-) diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 762a8debce9..d0d029cac23 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -17,11 +17,42 @@ const { loadAgent } = require("../agent/defs"); const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets"); const MAX_PRESET_FILE_BYTES = 10_000_000; +const PROVIDER_PROFILE_PRESET_PREFIX = "provider:"; +let cachedProviderProfiles: ProviderProfile[] | null = null; type PresetInfo = { file: string; name: string; description: string; + provider_profile?: string; +}; + +type ProviderProfileEndpoint = { + host?: unknown; + port?: unknown; + protocol?: unknown; + tls?: unknown; + access?: unknown; + enforcement?: unknown; + rules?: unknown; + allowed_ips?: unknown; + ports?: unknown; + deny_rules?: unknown; + allow_encoded_slash?: unknown; + websocket_credential_rewrite?: unknown; + request_body_credential_rewrite?: unknown; + persisted_queries?: unknown; + graphql_persisted_queries?: unknown; + graphql_max_body_bytes?: unknown; + path?: unknown; +}; + +type ProviderProfile = { + id: string; + display_name?: string; + description?: string; + endpoints?: ProviderProfileEndpoint[]; + binaries?: Array; }; // Re-use shared JSON types under policy-domain names. @@ -51,20 +82,22 @@ function isPolicyDocument(value: PolicyValue): value is PolicyDocument { * `preset:` header. */ function listPresets(): PresetInfo[] { - if (!fs.existsSync(PRESETS_DIR)) return []; - return fs - .readdirSync(PRESETS_DIR) - .filter((f: string) => f.endsWith(".yaml")) - .map((f: string) => { - const content = fs.readFileSync(path.join(PRESETS_DIR, f), "utf-8"); - const nameMatch = content.match(/^\s*name:\s*(.+)$/m); - const descMatch = content.match(/^\s*description:\s*"?([^"]*)"?$/m); - return { - file: f, - name: nameMatch ? nameMatch[1].trim() : f.replace(".yaml", ""), - description: descMatch ? descMatch[1].trim() : "", - }; - }); + const builtinPresets = fs.existsSync(PRESETS_DIR) + ? fs + .readdirSync(PRESETS_DIR) + .filter((f: string) => f.endsWith(".yaml")) + .map((f: string) => { + const content = fs.readFileSync(path.join(PRESETS_DIR, f), "utf-8"); + const nameMatch = content.match(/^\s*name:\s*(.+)$/m); + const descMatch = content.match(/^\s*description:\s*"?([^"]*)"?$/m); + return { + file: f, + name: nameMatch ? nameMatch[1].trim() : f.replace(".yaml", ""), + description: descMatch ? descMatch[1].trim() : "", + }; + }) + : []; + return mergeProviderProfilePresets(builtinPresets, listOpenShellProviderPresets()); } /** @@ -72,6 +105,9 @@ function listPresets(): PresetInfo[] { * path traversal and returns `null` if the preset does not exist. */ function loadPreset(name: string): string | null { + const providerPreset = loadOpenShellProviderPreset(name); + if (providerPreset) return providerPreset; + const file = path.resolve(PRESETS_DIR, `${name}.yaml`); if (!file.startsWith(PRESETS_DIR + path.sep) && file !== PRESETS_DIR) { console.error(` Invalid preset name: ${name}`); @@ -84,6 +120,173 @@ function loadPreset(name: string): string | null { return fs.readFileSync(file, "utf-8"); } +function openshellBinary(): string { + return process.env.NEMOCLAW_OPENSHELL_BIN || "openshell"; +} + +function parseProviderProfilesJson(raw: string): ProviderProfile[] { + if (!raw.trim()) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + + const candidates = Array.isArray(parsed) + ? parsed + : parsed && typeof parsed === "object" && Array.isArray((parsed as { profiles?: unknown }).profiles) + ? (parsed as { profiles: unknown[] }).profiles + : []; + + return candidates + .filter((value): value is Record => { + return typeof value === "object" && value !== null && typeof value.id === "string"; + }) + .map((profile) => ({ + id: String(profile.id), + display_name: typeof profile.display_name === "string" ? profile.display_name : undefined, + description: typeof profile.description === "string" ? profile.description : undefined, + endpoints: Array.isArray(profile.endpoints) + ? (profile.endpoints as ProviderProfileEndpoint[]) + : [], + binaries: Array.isArray(profile.binaries) + ? (profile.binaries as Array) + : [], + })); +} + +function readProviderProfilesFromOpenShell(): ProviderProfile[] { + if (process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON) { + return parseProviderProfilesJson(process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON); + } + if (cachedProviderProfiles !== null) return cachedProviderProfiles; + + const raw = runCapture([openshellBinary(), "provider", "list-profiles", "-o", "json"], { + ignoreError: true, + timeout: 5_000, + }); + cachedProviderProfiles = parseProviderProfilesJson(raw); + return cachedProviderProfiles; +} + +function providerProfileHasPolicy(profile: ProviderProfile): boolean { + return Array.isArray(profile.endpoints) && profile.endpoints.some((endpoint) => { + return typeof endpoint.host === "string" && Number(endpoint.port) > 0; + }); +} + +function providerProfileToPresetInfo(profile: ProviderProfile): PresetInfo | null { + if (!providerProfileHasPolicy(profile)) return null; + return { + file: `${PROVIDER_PROFILE_PRESET_PREFIX}${profile.id}`, + name: profile.id, + description: profile.description || profile.display_name || `${profile.id} provider profile`, + provider_profile: profile.id, + }; +} + +function listOpenShellProviderPresets(): PresetInfo[] { + return readProviderProfilesFromOpenShell() + .map(providerProfileToPresetInfo) + .filter((preset): preset is PresetInfo => preset !== null); +} + +function mergeProviderProfilePresets( + builtinPresets: PresetInfo[], + providerPresets: PresetInfo[], +): PresetInfo[] { + const byName = new Map(); + for (const preset of builtinPresets) byName.set(preset.name, preset); + for (const preset of providerPresets) { + const existing = byName.get(preset.name); + byName.set( + preset.name, + existing ? { ...existing, provider_profile: preset.provider_profile } : preset, + ); + } + return [...byName.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +function providerBinaryPath(binary: string | { path?: unknown }): string | null { + if (typeof binary === "string") return binary; + if (binary && typeof binary.path === "string") return binary.path; + return null; +} + +function cleanProviderEndpoint(endpoint: ProviderProfileEndpoint): PolicyObject | null { + if (typeof endpoint.host !== "string" || Number(endpoint.port) <= 0) return null; + const output: PolicyObject = { + host: endpoint.host, + port: Number(endpoint.port), + }; + for (const key of [ + "protocol", + "tls", + "access", + "enforcement", + "rules", + "allowed_ips", + "ports", + "deny_rules", + "allow_encoded_slash", + "websocket_credential_rewrite", + "request_body_credential_rewrite", + "persisted_queries", + "graphql_persisted_queries", + "graphql_max_body_bytes", + "path", + ] as const) { + const value = endpoint[key]; + if ( + value !== undefined && + value !== null && + value !== "" && + !(Array.isArray(value) && value.length === 0) + ) { + output[key] = value as PolicyValue; + } + } + return output; +} + +function providerProfileToPresetContent(profile: ProviderProfile): string | null { + const endpoints = (profile.endpoints || []) + .map(cleanProviderEndpoint) + .filter((endpoint): endpoint is PolicyObject => endpoint !== null); + if (endpoints.length === 0) return null; + + const binaries = (profile.binaries || []) + .map(providerBinaryPath) + .filter((binary): binary is string => Boolean(binary)) + .map((binary) => ({ path: binary })); + + const policyName = profile.id.replace(/-/g, "_"); + return YAML.stringify({ + preset: { + name: profile.id, + description: profile.description || profile.display_name || `${profile.id} provider profile`, + provider_profile: profile.id, + }, + network_policies: { + [policyName]: { + name: policyName, + endpoints, + ...(binaries.length > 0 ? { binaries } : {}), + }, + }, + }); +} + +function loadOpenShellProviderPreset(name: string): string | null { + const profile = readProviderProfilesFromOpenShell().find((candidate) => candidate.id === name); + return profile ? providerProfileToPresetContent(profile) : null; +} + +function clearProviderProfileCache(): void { + cachedProviderProfiles = null; +} + /** * Extract the bare hostnames declared in a preset YAML (anything matched by * `host: `), with surrounding quotes stripped. Used to show the @@ -1071,6 +1274,11 @@ export { PERMISSIVE_POLICY_PATH, listPresets, loadPreset, + listOpenShellProviderPresets, + loadOpenShellProviderPreset, + providerProfileToPresetContent, + parseProviderProfilesJson, + clearProviderProfileCache, getPresetEndpoints, getMessagingPresetWarning, setupPolicyPresetSupported, diff --git a/test/policies.test.ts b/test/policies.test.ts index 876c03a410b..83a52e16d7d 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -129,6 +129,11 @@ selectFromList(items, options) describe("policies", () => { describe("listPresets", () => { + afterEach(() => { + delete process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON; + policies.clearProviderProfileCache?.(); + }); + it("returns all 13 presets", () => { const presets = policies.listPresets(); expect(presets.length).toBe(13); @@ -163,9 +168,48 @@ describe("policies", () => { ]; expect(names).toEqual(expected); }); + + it("adds OpenShell provider profiles as provider-backed presets", () => { + process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON = JSON.stringify({ + profiles: [ + { + id: "gitlab", + display_name: "GitLab", + description: "GitLab API and Git operations", + endpoints: [{ host: "gitlab.com", port: 443, protocol: "rest", access: "read-write" }], + binaries: ["/usr/bin/git"], + }, + { + id: "empty-provider", + display_name: "Empty", + endpoints: [], + }, + ], + }); + policies.clearProviderProfileCache?.(); + + const presets = policies.listPresets(); + expect(presets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "gitlab", + file: "provider:gitlab", + provider_profile: "gitlab", + }), + ]), + ); + expect(presets.some((preset: { name: string }) => preset.name === "empty-provider")).toBe( + false, + ); + }); }); describe("loadPreset", () => { + afterEach(() => { + delete process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON; + policies.clearProviderProfileCache?.(); + }); + it("loads existing preset", () => { const content = requirePresetContent(policies.loadPreset("outlook")); expect(content.includes("network_policies:")).toBeTruthy(); @@ -175,6 +219,41 @@ describe("policies", () => { expect(policies.loadPreset("nonexistent")).toBe(null); }); + it("can synthesize preset YAML from an OpenShell provider profile", () => { + process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON = JSON.stringify([ + { + id: "gitlab", + display_name: "GitLab", + description: "GitLab API and Git operations", + endpoints: [ + { + host: "gitlab.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", + }, + ], + binaries: ["/usr/bin/git", { path: "/usr/local/bin/glab" }], + }, + ]); + policies.clearProviderProfileCache?.(); + + const content = requirePresetContent(policies.loadPreset("gitlab")); + const parsed = YAML.parse(content); + expect(parsed.preset.provider_profile).toBe("gitlab"); + expect(parsed.network_policies.gitlab.endpoints[0]).toMatchObject({ + host: "gitlab.com", + port: 443, + protocol: "rest", + access: "read-write", + }); + expect(parsed.network_policies.gitlab.binaries).toEqual([ + { path: "/usr/bin/git" }, + { path: "/usr/local/bin/glab" }, + ]); + }); + it("rejects path traversal attempts", () => { expect(policies.loadPreset("../../etc/passwd")).toBe(null); expect(policies.loadPreset("../../../etc/shadow")).toBe(null); From e2f6ffc9161c11312fc070f30c989531c23e663e Mon Sep 17 00:00:00 2001 From: Patrick Riel Date: Thu, 14 May 2026 01:19:57 +0000 Subject: [PATCH 2/2] feat(onboard): import nemoclaw provider profiles --- .../nemoclaw-openshell-integration.md | 72 +++++++ .../provider-profiles/brave.yaml | 19 ++ .../provider-profiles/brew.yaml | 38 ++++ .../provider-profiles/discord.yaml | 42 ++++ .../provider-profiles/huggingface.yaml | 30 +++ .../provider-profiles/jira.yaml | 31 +++ .../provider-profiles/local-inference.yaml | 47 +++++ nemoclaw-blueprint/provider-profiles/npm.yaml | 23 +++ .../provider-profiles/pypi.yaml | 31 +++ .../provider-profiles/slack.yaml | 51 +++++ .../provider-profiles/telegram.yaml | 19 ++ src/lib/onboard.ts | 16 ++ src/lib/onboard/provider-profiles.ts | 186 ++++++++++++++++++ test/provider-profile-onboard.test.ts | 146 ++++++++++++++ 14 files changed, 751 insertions(+) create mode 100644 docs/reference/nemoclaw-openshell-integration.md create mode 100644 nemoclaw-blueprint/provider-profiles/brave.yaml create mode 100644 nemoclaw-blueprint/provider-profiles/brew.yaml create mode 100644 nemoclaw-blueprint/provider-profiles/discord.yaml create mode 100644 nemoclaw-blueprint/provider-profiles/huggingface.yaml create mode 100644 nemoclaw-blueprint/provider-profiles/jira.yaml create mode 100644 nemoclaw-blueprint/provider-profiles/local-inference.yaml create mode 100644 nemoclaw-blueprint/provider-profiles/npm.yaml create mode 100644 nemoclaw-blueprint/provider-profiles/pypi.yaml create mode 100644 nemoclaw-blueprint/provider-profiles/slack.yaml create mode 100644 nemoclaw-blueprint/provider-profiles/telegram.yaml create mode 100644 src/lib/onboard/provider-profiles.ts create mode 100644 test/provider-profile-onboard.test.ts diff --git a/docs/reference/nemoclaw-openshell-integration.md b/docs/reference/nemoclaw-openshell-integration.md new file mode 100644 index 00000000000..d03b03573bd --- /dev/null +++ b/docs/reference/nemoclaw-openshell-integration.md @@ -0,0 +1,72 @@ +# NemoClaw OpenShell Integration + +```mermaid +flowchart LR + user["User"] --> agent["Agent runtime"] + agent --> adapter["NemoClaw agent adapter"] + + subgraph adapters["Current adapters"] + openclaw["OpenClaw plugin"] + hermes["Hermes plugin"] + end + + subgraph plugin_tools["NemoClaw access tools"] + list["list_resource_access_presets"] + request["request_resource_access"] + check["check_resource_access"] + end + + adapter --> adapters + adapters --> plugin_tools + onboard["nemoclaw onboard"] --> profile_import["Import NemoClaw provider profiles"] + profile_import --> profiles["OpenShell provider profiles"] + list --> profiles + profiles --> presets["Provider-backed access presets"] + presets --> request + + request --> policy_local["policy.local HTTP API"] + check --> policy_local + + subgraph sandbox["OpenShell sandbox"] + policy_local + proxy["Sandbox HTTP proxy"] + policy_runtime["Sandbox policy runtime"] + end + + policy_local --> proposals["OpenShell policy proposals"] + proposals --> review["Operator review"] + review --> approve["Approve or reject"] + approve --> merge["Policy merge and reload"] + merge --> policy_runtime + policy_runtime --> check + + agent --> workload["Requested agent work"] + workload --> proxy + proxy --> policy_runtime + policy_runtime --> external["Approved external resources"] +``` + +## Flow + +1. The agent asks NemoClaw for allowed resource presets with `list_resource_access_presets`. +2. During onboarding, NemoClaw imports its provider profiles into OpenShell for package registries, messaging platforms, Brave Search, Jira, Hugging Face, and local inference. +3. NemoClaw builds the agent-visible preset list from OpenShell provider profiles, with built-in presets as fallback coverage for older OpenShell versions. +4. The agent calls `request_resource_access` with a preset, access mode, reason, and optional wait timeout. +5. NemoClaw submits a least-privilege proposal to `policy.local`. +6. OpenShell surfaces the proposal for operator review. +7. After approval, OpenShell merges and reloads the sandbox policy. +8. The agent calls `check_resource_access`; NemoClaw reports `applied` only after OpenShell reports the policy reload is complete. + +## Agent Tools + +- `list_resource_access_presets`: discovers provider-backed preset ids. +- `request_resource_access`: submits a network access proposal through OpenShell. +- `check_resource_access`: polls an existing proposal until it is pending, denied, failed, or applied. + +## Adapter Contract + +Each agent adapter exposes the same tool names and response shape through the harness-native mechanism. OpenClaw uses its plugin API. Hermes uses its Python plugin API. Additional harnesses can implement the same contract without changing the OpenShell policy proposal flow. + +## Provider Profiles + +NemoClaw imports OpenShell provider profiles for its policy presets during onboarding. Existing OpenShell profiles are left untouched, and already-imported NemoClaw profiles are skipped so repeated onboarding remains idempotent. If the OpenShell gateway does not support provider-profile import, NemoClaw continues with local fallback presets. diff --git a/nemoclaw-blueprint/provider-profiles/brave.yaml b/nemoclaw-blueprint/provider-profiles/brave.yaml new file mode 100644 index 00000000000..2ece0b74c47 --- /dev/null +++ b/nemoclaw-blueprint/provider-profiles/brave.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: brave +display_name: Brave Search +description: Brave Search API access +category: knowledge +endpoints: + - host: api.search.brave.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } +binaries: + - /usr/local/bin/node + - /usr/bin/node + - /usr/bin/curl diff --git a/nemoclaw-blueprint/provider-profiles/brew.yaml b/nemoclaw-blueprint/provider-profiles/brew.yaml new file mode 100644 index 00000000000..74d89390598 --- /dev/null +++ b/nemoclaw-blueprint/provider-profiles/brew.yaml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: brew +display_name: Homebrew +description: Homebrew (Linuxbrew) package manager access +category: data +endpoints: + - host: formulae.brew.sh + port: 443 + access: full + tls: skip + - host: github.com + port: 443 + access: full + tls: skip + - host: ghcr.io + port: 443 + access: full + tls: skip + - host: pkg-containers.githubusercontent.com + port: 443 + access: full + tls: skip + - host: objects.githubusercontent.com + port: 443 + access: full + tls: skip + - host: raw.githubusercontent.com + port: 443 + access: full + tls: skip +binaries: + - /usr/bin/curl + - /usr/bin/git + - /home/linuxbrew/.linuxbrew/bin/brew + - /home/linuxbrew/.linuxbrew/bin/* + - /home/linuxbrew/.linuxbrew/Homebrew/bin/* diff --git a/nemoclaw-blueprint/provider-profiles/discord.yaml b/nemoclaw-blueprint/provider-profiles/discord.yaml new file mode 100644 index 00000000000..8fa00424812 --- /dev/null +++ b/nemoclaw-blueprint/provider-profiles/discord.yaml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: discord +display_name: Discord +description: Discord API, gateway, and CDN access +category: messaging +endpoints: + - host: discord.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - allow: { method: PUT, path: "/**" } + - allow: { method: PATCH, path: "/**" } + - allow: { method: DELETE, path: "/api/v*/channels/*/messages/*" } + - allow: { method: DELETE, path: "/api/v*/channels/*/messages/*/reactions/*/*" } + - host: gateway.discord.gg + port: 443 + protocol: websocket + enforcement: enforce + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } + - host: cdn.discordapp.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: media.discordapp.net + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } +binaries: + - /usr/local/bin/node + - /usr/bin/node diff --git a/nemoclaw-blueprint/provider-profiles/huggingface.yaml b/nemoclaw-blueprint/provider-profiles/huggingface.yaml new file mode 100644 index 00000000000..27e610f556f --- /dev/null +++ b/nemoclaw-blueprint/provider-profiles/huggingface.yaml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: huggingface +display_name: Hugging Face +description: Hugging Face Hub, LFS, and Inference API access +category: knowledge +endpoints: + - host: huggingface.co + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: cdn-lfs.huggingface.co + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - host: router.huggingface.co + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } +binaries: + - /usr/local/bin/python3 + - /usr/local/bin/node diff --git a/nemoclaw-blueprint/provider-profiles/jira.yaml b/nemoclaw-blueprint/provider-profiles/jira.yaml new file mode 100644 index 00000000000..e55d7d97535 --- /dev/null +++ b/nemoclaw-blueprint/provider-profiles/jira.yaml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: jira +display_name: Jira +description: Jira and Atlassian Cloud access +category: data +endpoints: + - host: "*.atlassian.net" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: auth.atlassian.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: api.atlassian.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } +binaries: + - /usr/local/bin/node diff --git a/nemoclaw-blueprint/provider-profiles/local-inference.yaml b/nemoclaw-blueprint/provider-profiles/local-inference.yaml new file mode 100644 index 00000000000..1acdc174205 --- /dev/null +++ b/nemoclaw-blueprint/provider-profiles/local-inference.yaml @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: local-inference +display_name: Local Inference +description: Local inference access (Ollama, vLLM) via host gateway +category: inference +endpoints: + - host: host.openshell.internal + port: 11434 + protocol: rest + enforcement: enforce + allowed_ips: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: host.openshell.internal + port: 11435 + protocol: rest + enforcement: enforce + allowed_ips: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: host.openshell.internal + port: 8000 + protocol: rest + enforcement: enforce + allowed_ips: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } +binaries: + - /usr/local/bin/openclaw + - /usr/local/bin/node + - /usr/bin/node + - /usr/bin/curl + - /usr/bin/python3 diff --git a/nemoclaw-blueprint/provider-profiles/npm.yaml b/nemoclaw-blueprint/provider-profiles/npm.yaml new file mode 100644 index 00000000000..d066c3e54cc --- /dev/null +++ b/nemoclaw-blueprint/provider-profiles/npm.yaml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: npm +display_name: npm +description: npm and Yarn registry access +category: data +endpoints: + - host: registry.npmjs.org + port: 443 + access: full + tls: skip + - host: registry.yarnpkg.com + port: 443 + access: full + tls: skip +binaries: + - /usr/local/bin/npm* + - /usr/local/bin/npx* + - /usr/local/bin/node* + - /usr/local/bin/yarn* + - /usr/bin/npm* + - /usr/bin/node* diff --git a/nemoclaw-blueprint/provider-profiles/pypi.yaml b/nemoclaw-blueprint/provider-profiles/pypi.yaml new file mode 100644 index 00000000000..df466dab358 --- /dev/null +++ b/nemoclaw-blueprint/provider-profiles/pypi.yaml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: pypi +display_name: PyPI +description: Python Package Index (PyPI) access +category: data +endpoints: + - host: pypi.org + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: HEAD, path: "/**" } + - host: files.pythonhosted.org + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: HEAD, path: "/**" } +binaries: + - /usr/bin/python3* + - /usr/bin/pip* + - /usr/local/bin/python3* + - /usr/local/bin/pip* + - /sandbox/.venv/bin/python* + - /sandbox/.venv/bin/pip* + - /sandbox/.uv/python/**/python* + - /sandbox/.local/bin/pip* diff --git a/nemoclaw-blueprint/provider-profiles/slack.yaml b/nemoclaw-blueprint/provider-profiles/slack.yaml new file mode 100644 index 00000000000..2790502e090 --- /dev/null +++ b/nemoclaw-blueprint/provider-profiles/slack.yaml @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: slack +display_name: Slack +description: Slack API, Socket Mode, and webhooks access +category: messaging +endpoints: + - host: slack.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: api.slack.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: hooks.slack.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: wss-primary.slack.com + port: 443 + protocol: websocket + enforcement: enforce + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } + - host: wss-backup.slack.com + port: 443 + protocol: websocket + enforcement: enforce + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } +binaries: + - /usr/local/bin/node + - /usr/bin/node diff --git a/nemoclaw-blueprint/provider-profiles/telegram.yaml b/nemoclaw-blueprint/provider-profiles/telegram.yaml new file mode 100644 index 00000000000..eb1e95c4c8d --- /dev/null +++ b/nemoclaw-blueprint/provider-profiles/telegram.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: telegram +display_name: Telegram +description: Telegram Bot API access +category: messaging +endpoints: + - host: api.telegram.org + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/bot*/**" } + - allow: { method: POST, path: "/bot*/**" } + - allow: { method: GET, path: "/file/bot*/**" } +binaries: + - /usr/local/bin/node + - /usr/bin/node diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index f04cedafde0..383ec786927 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -330,6 +330,8 @@ const openshellPinFlow: typeof import("./onboard/openshell-pin") = require("./onboard/openshell-pin"); const sandboxCreateFailureDiagnostics: typeof import("./onboard/sandbox-create-failure") = require("./onboard/sandbox-create-failure"); +const providerProfileOnboard: typeof import("./onboard/provider-profiles") = + require("./onboard/provider-profiles"); import type { AgentDefinition } from "./agent/defs"; import type { CurlProbeResult } from "./adapters/http/probe"; @@ -1790,6 +1792,18 @@ function providerExistsInGateway(name: string) { return onboardProviders.providerExistsInGateway(name, runOpenshell); } +function ensureProviderProfilesAvailable(): void { + const result = providerProfileOnboard.ensureNemoClawProviderProfiles(runOpenshell, { + log: note, + }); + if (result.status === "unsupported") { + note(` ${result.message}`); + } else if (result.status === "already-present" && result.skipped.length > 0) { + note(` NemoClaw provider profiles already registered: ${result.skipped.join(", ")}`); + } + policies.clearProviderProfileCache(); +} + function getMessagingChannelForEnvKey(envKey: string): string | null { if (envKey === "DISCORD_BOT_TOKEN") return "discord"; if (envKey === "SLACK_BOT_TOKEN") return "slack"; @@ -9734,6 +9748,8 @@ async function onboard(opts: OnboardOptions = {}): Promise { onboardSession.markStepComplete("gateway"); } + ensureProviderProfilesAvailable(); + // #2753: prefer requestedSandboxName over an unconfirmed session name. // A pre-fix session may carry sandboxName even though sandbox creation // never completed; users supplying `--name` / NEMOCLAW_SANDBOX_NAME on diff --git a/src/lib/onboard/provider-profiles.ts b/src/lib/onboard/provider-profiles.ts new file mode 100644 index 00000000000..ae5491d83fa --- /dev/null +++ b/src/lib/onboard/provider-profiles.ts @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import YAML from "yaml"; + +const ROOT = path.resolve(__dirname, "..", "..", ".."); + +type RunOpenshell = ( + args: string[], + opts?: { + ignoreError?: boolean; + stdio?: Array<"ignore" | "pipe" | "inherit">; + suppressOutput?: boolean; + timeout?: number; + }, +) => { status?: number | null; stdout?: string | Buffer | null; stderr?: string | Buffer | null }; + +export type ProviderProfileImportResult = + | { status: "missing-directory"; imported: string[]; skipped: string[] } + | { status: "unsupported"; imported: string[]; skipped: string[]; message: string } + | { status: "already-present"; imported: string[]; skipped: string[] } + | { status: "imported"; imported: string[]; skipped: string[] }; + +export const NEMOCLAW_PROVIDER_PROFILES_DIR = path.join( + ROOT, + "nemoclaw-blueprint", + "provider-profiles", +); + +function outputText(value: string | Buffer | null | undefined): string { + if (typeof value === "string") return value; + if (Buffer.isBuffer(value)) return value.toString("utf-8"); + return ""; +} + +function isUnsupportedProviderProfileCommand(result: { + stdout?: string | Buffer | null; + stderr?: string | Buffer | null; +}): boolean { + const text = `${outputText(result.stderr)}\n${outputText(result.stdout)}`.toLowerCase(); + return ( + text.includes("unrecognized subcommand") || + text.includes("unknown command") || + text.includes("invalid subcommand") + ); +} + +function parseProfileIds(raw: string): Set { + if (!raw.trim()) return new Set(); + try { + const parsed = JSON.parse(raw); + const candidates = Array.isArray(parsed) + ? parsed + : parsed && typeof parsed === "object" && Array.isArray(parsed.profiles) + ? parsed.profiles + : []; + return new Set( + candidates + .map((profile: unknown) => + profile && typeof profile === "object" && typeof (profile as { id?: unknown }).id === "string" + ? (profile as { id: string }).id + : null, + ) + .filter((id: string | null): id is string => Boolean(id)), + ); + } catch { + return new Set(); + } +} + +function readProfileId(filePath: string): string | null { + try { + const parsed = YAML.parse(fs.readFileSync(filePath, "utf-8")); + return parsed && typeof parsed === "object" && typeof parsed.id === "string" + ? parsed.id + : null; + } catch { + return null; + } +} + +function providerProfileFiles(dir: string): Array<{ id: string; path: string }> { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((file) => file.endsWith(".yaml") || file.endsWith(".yml")) + .map((file) => { + const filePath = path.join(dir, file); + const id = readProfileId(filePath); + return id ? { id, path: filePath } : null; + }) + .filter((item): item is { id: string; path: string } => item !== null) + .sort((left, right) => left.id.localeCompare(right.id)); +} + +export function ensureNemoClawProviderProfiles( + runOpenshell: RunOpenshell, + options: { profilesDir?: string; log?: (message: string) => void } = {}, +): ProviderProfileImportResult { + const profilesDir = options.profilesDir || NEMOCLAW_PROVIDER_PROFILES_DIR; + const log = options.log || (() => {}); + const profiles = providerProfileFiles(profilesDir); + if (profiles.length === 0) { + return { status: "missing-directory", imported: [], skipped: [] }; + } + + const list = runOpenshell(["provider", "list-profiles", "-o", "json"], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + suppressOutput: true, + timeout: 10_000, + }); + if (list.status !== 0) { + return { + status: "unsupported", + imported: [], + skipped: [], + message: "OpenShell provider profiles are not available; using local preset fallbacks.", + }; + } + + const existing = parseProfileIds(outputText(list.stdout)); + const missing = profiles.filter((profile) => !existing.has(profile.id)); + const skipped = profiles + .filter((profile) => existing.has(profile.id)) + .map((profile) => profile.id); + if (missing.length === 0) { + return { status: "already-present", imported: [], skipped }; + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-provider-profiles-")); + try { + for (const profile of missing) { + fs.copyFileSync(profile.path, path.join(tempDir, path.basename(profile.path))); + } + + const lint = runOpenshell(["provider", "profile", "lint", "--from", tempDir], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + suppressOutput: true, + timeout: 10_000, + }); + if (lint.status !== 0) { + if (isUnsupportedProviderProfileCommand(lint)) { + return { + status: "unsupported", + imported: [], + skipped, + message: "OpenShell provider profile import is not available; using local preset fallbacks.", + }; + } + const details = + outputText(lint.stderr) || outputText(lint.stdout) || "provider profile lint failed"; + throw new Error(`NemoClaw provider profile lint failed: ${details.trim()}`); + } + + const importedIds = missing.map((profile) => profile.id); + const imported = runOpenshell(["provider", "profile", "import", "--from", tempDir], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + suppressOutput: true, + timeout: 10_000, + }); + if (imported.status !== 0) { + if (isUnsupportedProviderProfileCommand(imported)) { + return { + status: "unsupported", + imported: [], + skipped, + message: "OpenShell provider profile import is not available; using local preset fallbacks.", + }; + } + const details = + outputText(imported.stderr) || outputText(imported.stdout) || "provider profile import failed"; + throw new Error(`NemoClaw provider profile import failed: ${details.trim()}`); + } + + log(` Imported NemoClaw provider profiles: ${importedIds.join(", ")}`); + return { status: "imported", imported: importedIds, skipped }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} diff --git a/test/provider-profile-onboard.test.ts b/test/provider-profile-onboard.test.ts new file mode 100644 index 00000000000..7c5d0739259 --- /dev/null +++ b/test/provider-profile-onboard.test.ts @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +import { + ensureNemoClawProviderProfiles, + NEMOCLAW_PROVIDER_PROFILES_DIR, +} from "../src/lib/onboard/provider-profiles"; + +function writeProfile(dir: string, id: string): void { + fs.writeFileSync( + path.join(dir, `${id}.yaml`), + [ + `id: ${id}`, + `display_name: ${id}`, + "description: fixture profile", + "category: other", + "endpoints:", + " - host: example.com", + " port: 443", + "binaries:", + " - /usr/bin/curl", + "", + ].join("\n"), + ); +} + +describe("NemoClaw provider profile onboarding", () => { + it("ships provider profiles for NemoClaw presets not built into OpenShell", () => { + const ids = fs + .readdirSync(NEMOCLAW_PROVIDER_PROFILES_DIR) + .filter((file) => file.endsWith(".yaml")) + .map((file) => { + const parsed = YAML.parse( + fs.readFileSync(path.join(NEMOCLAW_PROVIDER_PROFILES_DIR, file), "utf-8"), + ); + return parsed.id; + }) + .sort(); + + expect(ids).toEqual([ + "brave", + "brew", + "discord", + "huggingface", + "jira", + "local-inference", + "npm", + "pypi", + "slack", + "telegram", + ]); + }); + + it("imports only profiles missing from OpenShell", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-provider-profile-test-")); + const calls: string[][] = []; + try { + writeProfile(tmp, "brave"); + writeProfile(tmp, "npm"); + const result = ensureNemoClawProviderProfiles( + (args) => { + calls.push(args); + if (args.join(" ") === "provider list-profiles -o json") { + return { + status: 0, + stdout: JSON.stringify({ profiles: [{ id: "brave" }] }), + stderr: "", + }; + } + return { status: 0, stdout: "", stderr: "" }; + }, + { profilesDir: tmp }, + ); + + expect(result).toMatchObject({ + status: "imported", + imported: ["npm"], + skipped: ["brave"], + }); + expect(calls).toEqual([ + ["provider", "list-profiles", "-o", "json"], + ["provider", "profile", "lint", "--from", expect.any(String)], + ["provider", "profile", "import", "--from", expect.any(String)], + ]); + const importDir = calls[2][4]; + expect(fs.existsSync(importDir)).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("skips import when all profiles already exist", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-provider-profile-test-")); + const calls: string[][] = []; + try { + writeProfile(tmp, "brave"); + const result = ensureNemoClawProviderProfiles( + (args) => { + calls.push(args); + return { + status: 0, + stdout: JSON.stringify({ profiles: [{ id: "brave" }] }), + stderr: "", + }; + }, + { profilesDir: tmp }, + ); + + expect(result).toMatchObject({ + status: "already-present", + imported: [], + skipped: ["brave"], + }); + expect(calls).toEqual([["provider", "list-profiles", "-o", "json"]]); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("falls back when OpenShell does not support provider profiles", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-provider-profile-test-")); + try { + writeProfile(tmp, "brave"); + const result = ensureNemoClawProviderProfiles( + () => ({ + status: 2, + stdout: "", + stderr: "error: unrecognized subcommand 'profile'", + }), + { profilesDir: tmp }, + ); + + expect(result.status).toBe("unsupported"); + expect(result.imported).toEqual([]); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +});