From 631b0634081e2458e74c51684ca9d3987ded413b Mon Sep 17 00:00:00 2001 From: Patrick Riel Date: Wed, 13 May 2026 23:20:41 +0000 Subject: [PATCH 1/6] 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/6] 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 }); + } + }); +}); From af7158849e5c984bda291226788b8f13ad7b5236 Mon Sep 17 00:00:00 2001 From: Patrick Riel Date: Wed, 13 May 2026 23:16:16 +0000 Subject: [PATCH 3/6] feat(plugin): request access through openshell policy local --- nemoclaw/src/access-client.test.ts | 36 +++ nemoclaw/src/access-client.ts | 465 +++++++++++++++++++++++++++++ nemoclaw/src/index.ts | 240 +++++++++++++++ nemoclaw/src/register.test.ts | 132 +++++++- 4 files changed, 872 insertions(+), 1 deletion(-) create mode 100644 nemoclaw/src/access-client.test.ts create mode 100644 nemoclaw/src/access-client.ts diff --git a/nemoclaw/src/access-client.test.ts b/nemoclaw/src/access-client.test.ts new file mode 100644 index 00000000000..5d262ce007f --- /dev/null +++ b/nemoclaw/src/access-client.test.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { createAccessRequest, listAccessPresets } from "./access-client.js"; + +describe("access client", () => { + it("rejects non-HTTP policy.local URLs", () => { + expect(() => + createAccessRequest( + { + version: "nemoclaw.access.v1", + user_intent: "Need GitHub", + llm_proposal: { + resource_type: "network", + preset: "github", + access: "read", + duration: "session", + reason: "Inspect a repository.", + }, + }, + { policyLocalUrl: "https://policy.local" }, + ), + ).toThrow(/must use HTTP inside the sandbox/); + }); + + it("lists OpenShell-backed provider presets", async () => { + await expect(listAccessPresets()).resolves.toMatchObject({ + presets: expect.arrayContaining([ + expect.objectContaining({ name: "github", provider_profile: "github" }), + expect.objectContaining({ name: "outlook", provider_profile: "outlook" }), + ]), + }); + }); +}); diff --git a/nemoclaw/src/access-client.ts b/nemoclaw/src/access-client.ts new file mode 100644 index 00000000000..3126ae560fe --- /dev/null +++ b/nemoclaw/src/access-client.ts @@ -0,0 +1,465 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import http from "node:http"; + +export type AccessStatus = "pending_approval" | "applied" | "denied" | "failed"; + +export type AccessCanonicalRequest = { + [key: string]: unknown; +}; + +export interface AccessRequestResponse { + request_id: string; + status: AccessStatus; + message?: string; + canonical_request?: AccessCanonicalRequest; +} + +export interface AccessPresetInfo { + name: string; + description: string; + provider_profile?: string; +} + +export interface AccessPresetsResponse { + presets: AccessPresetInfo[]; +} + +export interface CreateAccessRequestBody { + version: "nemoclaw.access.v1"; + task_id?: string; + user_intent: string; + llm_proposal: { + resource_type: "network"; + preset: string; + access: "read" | "read_write"; + duration: "session" | "persistent"; + reason: string; + }; +} + +export interface AccessClientOptions { + policyLocalUrl?: string; + timeoutMs?: number; +} + +type L7Rule = { + allow: { + method: string; + path: string; + }; +}; + +type NetworkEndpoint = { + host: string; + port: number; + protocol?: string; + enforcement?: string; + access?: string; + tls?: string; + rules?: L7Rule[]; +}; + +type NetworkRule = { + name: string; + endpoints: NetworkEndpoint[]; + binaries: Array<{ path: string }>; +}; + +type AccessPreset = AccessPresetInfo & { + rule: NetworkRule; +}; + +const NODE_BINARIES = [{ path: "/usr/local/bin/node" }, { path: "/usr/bin/node" }]; +const READ_METHODS = ["GET", "HEAD"]; +const READ_WRITE_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]; + +const PRESETS: AccessPreset[] = [ + { + name: "github", + description: "GitHub.com and GitHub API access (git)", + provider_profile: "github", + rule: { + name: "github", + endpoints: [ + { host: "github.com", port: 443, protocol: "rest", enforcement: "enforce" }, + { host: "api.github.com", port: 443, protocol: "rest", enforcement: "enforce" }, + ], + binaries: [{ path: "/usr/bin/git" }], + }, + }, + { + name: "outlook", + description: "Microsoft Outlook and Graph API access", + provider_profile: "outlook", + rule: { + name: "outlook_graph", + endpoints: [ + { host: "graph.microsoft.com", port: 443, protocol: "rest", enforcement: "enforce" }, + { host: "login.microsoftonline.com", port: 443, protocol: "rest", enforcement: "enforce" }, + { host: "outlook.office365.com", port: 443, protocol: "rest", enforcement: "enforce" }, + { host: "outlook.office.com", port: 443, protocol: "rest", enforcement: "enforce" }, + ], + binaries: [{ path: "/usr/local/bin/node" }], + }, + }, + { + name: "pypi", + description: "Python Package Index (PyPI) access", + rule: { + name: "pypi", + endpoints: [ + { host: "pypi.org", port: 443, protocol: "rest", enforcement: "enforce" }, + { host: "files.pythonhosted.org", port: 443, protocol: "rest", enforcement: "enforce" }, + ], + binaries: [ + { path: "/usr/bin/python3*" }, + { path: "/usr/bin/pip*" }, + { path: "/usr/local/bin/python3*" }, + { path: "/usr/local/bin/pip*" }, + { path: "/sandbox/.venv/bin/python*" }, + { path: "/sandbox/.venv/bin/pip*" }, + ], + }, + }, + { + name: "npm", + description: "npm and Yarn registry access", + rule: { + name: "npm_yarn", + endpoints: [ + { host: "registry.npmjs.org", port: 443, protocol: "rest", enforcement: "enforce" }, + { host: "registry.yarnpkg.com", port: 443, protocol: "rest", enforcement: "enforce" }, + ], + binaries: [ + { path: "/usr/local/bin/npm*" }, + { path: "/usr/local/bin/npx*" }, + { path: "/usr/local/bin/node*" }, + { path: "/usr/local/bin/yarn*" }, + { path: "/usr/bin/npm*" }, + { path: "/usr/bin/node*" }, + ], + }, + }, + { + name: "brave", + description: "Brave Search API access", + rule: { + name: "brave", + endpoints: [ + { host: "api.search.brave.com", port: 443, protocol: "rest", enforcement: "enforce" }, + ], + binaries: [...NODE_BINARIES, { path: "/usr/bin/curl" }], + }, + }, + { + name: "local-inference", + description: "Local inference access via host gateway", + rule: { + name: "local_inference", + endpoints: [ + { host: "host.openshell.internal", port: 11434, protocol: "rest", enforcement: "enforce" }, + { host: "host.openshell.internal", port: 11435, protocol: "rest", enforcement: "enforce" }, + { host: "host.openshell.internal", port: 8000, protocol: "rest", enforcement: "enforce" }, + ], + binaries: [ + { path: "/usr/local/bin/openclaw" }, + { path: "/usr/local/bin/claude" }, + { path: "/usr/local/bin/node" }, + { path: "/usr/bin/node" }, + { path: "/usr/bin/curl" }, + { path: "/usr/bin/python3" }, + ], + }, + }, + { + name: "jira", + description: "Jira and Atlassian Cloud access", + rule: { + name: "atlassian", + endpoints: [ + { host: "*.atlassian.net", port: 443, protocol: "rest", enforcement: "enforce" }, + { host: "auth.atlassian.com", port: 443, protocol: "rest", enforcement: "enforce" }, + { host: "api.atlassian.com", port: 443, protocol: "rest", enforcement: "enforce" }, + ], + binaries: [{ path: "/usr/local/bin/node" }], + }, + }, + { + name: "slack", + description: "Slack API, Socket Mode, and webhooks access", + rule: { + name: "slack", + endpoints: [ + { host: "slack.com", port: 443, protocol: "rest", enforcement: "enforce" }, + { host: "api.slack.com", port: 443, protocol: "rest", enforcement: "enforce" }, + { host: "hooks.slack.com", port: 443, protocol: "rest", enforcement: "enforce" }, + ], + binaries: NODE_BINARIES, + }, + }, + { + name: "discord", + description: "Discord API, gateway, and CDN access", + rule: { + name: "discord", + endpoints: [ + { host: "discord.com", port: 443, protocol: "rest", enforcement: "enforce" }, + { host: "cdn.discordapp.com", port: 443, protocol: "rest", enforcement: "enforce" }, + { host: "media.discordapp.net", port: 443, protocol: "rest", enforcement: "enforce" }, + ], + binaries: NODE_BINARIES, + }, + }, + { + name: "telegram", + description: "Telegram Bot API access", + rule: { + name: "telegram_bot", + endpoints: [ + { host: "api.telegram.org", port: 443, protocol: "rest", enforcement: "enforce" }, + ], + binaries: NODE_BINARIES, + }, + }, +]; + +function normalizePresetName(resource: string): string { + const normalized = resource.trim().toLowerCase(); + const host = (() => { + if (!normalized.includes("://")) { + return normalized; + } + try { + return new URL(normalized).hostname.toLowerCase(); + } catch { + return normalized; + } + })(); + if (host === "github.com" || host === "api.github.com") { + return "github"; + } + return host; +} + +function rulesForAccess(access: "read" | "read_write"): L7Rule[] { + const methods = access === "read_write" ? READ_WRITE_METHODS : READ_METHODS; + return methods.map((method) => ({ allow: { method, path: "/**" } })); +} + +function ruleForRequest(body: CreateAccessRequestBody): NetworkRule { + const presetName = normalizePresetName(body.llm_proposal.preset); + const preset = PRESETS.find((candidate) => candidate.name === presetName); + if (!preset) { + throw new Error(`Unknown access preset '${body.llm_proposal.preset}'.`); + } + return { + ...preset.rule, + endpoints: preset.rule.endpoints.map((endpoint) => { + if (endpoint.access === "full" || endpoint.tls === "skip") { + return { ...endpoint }; + } + return { + ...endpoint, + access: undefined, + rules: endpoint.rules ?? rulesForAccess(body.llm_proposal.access), + }; + }), + binaries: preset.rule.binaries.map((binary) => ({ ...binary })), + }; +} + +function policyLocalUrl(options: AccessClientOptions = {}): URL { + return new URL( + options.policyLocalUrl ?? process.env.OPENSHELL_POLICY_LOCAL_URL ?? "http://policy.local", + ); +} + +function proxyUrl(): URL | null { + const raw = process.env.HTTP_PROXY ?? process.env.http_proxy; + if (!raw) return null; + try { + const parsed = new URL(raw); + return parsed.protocol === "http:" ? parsed : null; + } catch { + return null; + } +} + +function parseJsonObject(raw: string): Record { + const parsed: unknown = raw.length > 0 ? JSON.parse(raw) : {}; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("OpenShell policy.local returned a non-object response"); + } + return parsed as Record; +} + +function mapChunkStatus(status: unknown): AccessStatus { + if (status === "approved") return "applied"; + if (status === "rejected") return "denied"; + if (status === "pending") return "pending_approval"; + return "failed"; +} + +function requestJson( + method: "GET" | "POST", + requestPath: string, + body: Record | undefined, + options: AccessClientOptions, + parseResponse: (raw: string) => T, +): Promise { + const base = policyLocalUrl(options); + if (base.protocol !== "http:") { + throw new Error("OpenShell policy.local URL must use HTTP inside the sandbox."); + } + + const payload = body === undefined ? undefined : JSON.stringify(body); + const headers: Record = { + Accept: "application/json", + Host: base.host, + }; + if (payload !== undefined) { + headers["Content-Type"] = "application/json"; + headers["Content-Length"] = Buffer.byteLength(payload); + } + + return new Promise((resolve, reject) => { + const proxy = base.hostname === "policy.local" ? proxyUrl() : null; + const req = http.request( + { + method, + protocol: proxy?.protocol ?? base.protocol, + hostname: proxy?.hostname ?? base.hostname, + port: + proxy?.port !== undefined && proxy.port !== "" + ? Number(proxy.port) + : base.port === "" + ? undefined + : Number(base.port), + path: + proxy === null + ? `${base.pathname.replace(/\/$/, "")}${requestPath}` + : `${base.origin}${base.pathname.replace(/\/$/, "")}${requestPath}`, + headers, + timeout: options.timeoutMs ?? 310_000, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf-8"); + if (res.statusCode === undefined || res.statusCode < 200 || res.statusCode >= 300) { + reject( + new Error( + `OpenShell policy.local ${method} ${requestPath} failed with HTTP ${res.statusCode ?? "unknown"}: ${raw}`, + ), + ); + return; + } + try { + resolve(parseResponse(raw)); + } catch (err) { + reject(err); + } + }); + }, + ); + + req.on("timeout", () => { + req.destroy(new Error(`OpenShell policy.local ${method} ${requestPath} timed out`)); + }); + req.on("error", reject); + if (payload !== undefined) req.write(payload); + req.end(); + }); +} + +function proposalBody(body: CreateAccessRequestBody): Record { + const rule = ruleForRequest(body); + return { + intent_summary: [body.user_intent, body.llm_proposal.reason].filter(Boolean).join(" "), + operations: [{ addRule: { ruleName: rule.name, rule } }], + }; +} + +function parseCreateResponse(raw: string): AccessRequestResponse { + const parsed = parseJsonObject(raw); + const accepted = Array.isArray(parsed.accepted_chunk_ids) ? parsed.accepted_chunk_ids : []; + const requestId = accepted.find((id): id is string => typeof id === "string" && id.length > 0); + if (!requestId) { + return { + request_id: "", + status: "failed", + message: `OpenShell rejected the proposal: ${JSON.stringify(parsed.rejection_reasons ?? [])}`, + }; + } + return { + request_id: requestId, + status: "pending_approval", + message: "Proposal submitted to OpenShell; waiting for operator approval.", + }; +} + +function parseStateResponse(raw: string): AccessRequestResponse { + const parsed = parseJsonObject(raw); + const requestId = typeof parsed.chunk_id === "string" ? parsed.chunk_id : ""; + return { + request_id: requestId, + status: mapChunkStatus(parsed.status), + message: + typeof parsed.rejection_reason === "string" && parsed.rejection_reason + ? parsed.rejection_reason + : typeof parsed.validation_result === "string" && parsed.validation_result + ? parsed.validation_result + : undefined, + canonical_request: parsed, + }; +} + +export function createAccessRequest( + body: CreateAccessRequestBody, + options: AccessClientOptions = {}, +): Promise { + return requestJson("POST", "/v1/proposals", proposalBody(body), options, parseCreateResponse); +} + +export function getAccessRequest( + requestId: string, + options: AccessClientOptions = {}, +): Promise { + return requestJson( + "GET", + `/v1/proposals/${encodeURIComponent(requestId)}`, + undefined, + options, + parseStateResponse, + ); +} + +export function waitAccessRequest( + requestId: string, + timeoutMs: number, + options: AccessClientOptions = {}, +): Promise { + const seconds = Math.max(1, Math.min(300, Math.ceil(timeoutMs / 1000))); + return requestJson( + "GET", + `/v1/proposals/${encodeURIComponent(requestId)}/wait?timeout=${seconds}`, + undefined, + { ...options, timeoutMs: Math.max(options.timeoutMs ?? 0, (seconds + 10) * 1000) }, + parseStateResponse, + ); +} + +export function listAccessPresets( + _options: AccessClientOptions = {}, +): Promise { + return Promise.resolve({ + presets: PRESETS.map(({ name, description, provider_profile }) => ({ + name, + description, + ...(provider_profile ? { provider_profile } : {}), + })), + }); +} diff --git a/nemoclaw/src/index.ts b/nemoclaw/src/index.ts index 654a1f23cf0..de7900e3c0b 100644 --- a/nemoclaw/src/index.ts +++ b/nemoclaw/src/index.ts @@ -19,6 +19,17 @@ import { describeOnboardProvider, loadOnboardConfig, } from "./onboard/config.js"; +import { + createAccessRequest, + getAccessRequest, + listAccessPresets, + waitAccessRequest, + type AccessCanonicalRequest, + type AccessClientOptions, + type AccessRequestResponse, + type AccessStatus, + type CreateAccessRequestBody, +} from "./access-client.js"; import { registerRuntimeContext } from "./runtime-context.js"; import { scanForSecrets, isMemoryPath } from "./security/secret-scanner.js"; @@ -100,6 +111,17 @@ export interface PluginLogger { type ToolParams = { [key: string]: PluginValue }; +export interface PluginToolResult { + [key: string]: PluginValue | AccessCanonicalRequest; +} + +export interface PluginToolDefinition { + name: string; + description: string; + parameters: PluginRecord; + execute: (id: string, params: ToolParams) => PluginToolResult | Promise; +} + /** Context passed to slash-command handlers. */ export interface PluginCommandContext { senderId?: string; @@ -210,6 +232,7 @@ export interface OpenClawPluginApi { registerCommand: (command: PluginCommandDefinition) => void; registerProvider: (provider: ProviderPlugin) => void; registerService: (service: PluginService) => void; + registerTool: (tool: PluginToolDefinition) => void; resolvePath: (input: string) => string; on: ( hookName: string, @@ -335,6 +358,119 @@ export function getPluginConfig(api: OpenClawPluginApi): NemoClawConfig { /** Tool names that can write/modify files and should be scanned for secrets. */ const WRITE_TOOL_NAMES = new Set(["write", "edit", "apply_patch", "notebook_edit"]); +const DEFAULT_ACCESS_WAIT_MS = 90_000; +const MAX_ACCESS_WAIT_MS = 300_000; +const TERMINAL_ACCESS_STATUSES = new Set(["applied", "denied", "failed"]); + +function readNumberProperty( + value: PluginValue | object | null | undefined, + key: string, +): number | undefined { + if (!isToolParams(value)) { + return undefined; + } + const property = value[key]; + return typeof property === "number" && Number.isFinite(property) ? property : undefined; +} + +function readAccessMode(params: ToolParams): "read" | "read_write" { + return params["access"] === "read_write" ? "read_write" : "read"; +} + +function readDuration(params: ToolParams): "session" | "persistent" { + return params["duration"] === "persistent" ? "persistent" : "session"; +} + +function normalizeRequestedResource(resource: string): string { + const normalized = resource.trim().toLowerCase(); + const normalizedHost = (() => { + if (!normalized.includes("://")) { + return normalized; + } + try { + return new URL(normalized).hostname.toLowerCase(); + } catch { + return normalized; + } + })(); + if ( + normalizedHost === "github" || + normalizedHost === "github.com" || + normalizedHost === "api.github.com" + ) { + return "github"; + } + return normalizedHost; +} + +function clampWaitTimeout(value: number | undefined, fallback: number): number { + const timeout = value ?? fallback; + if (timeout <= 0) { + return 0; + } + return Math.min(timeout, MAX_ACCESS_WAIT_MS); +} + +function toToolResult(response: AccessRequestResponse): PluginToolResult { + return { + request_id: response.request_id, + status: response.status, + message: + response.message ?? + (TERMINAL_ACCESS_STATUSES.has(response.status) + ? "OpenShell returned a terminal access status." + : "Access request is still pending; call check_resource_access with the request_id to continue polling."), + ...(response.canonical_request ? { canonical_request: response.canonical_request } : {}), + }; +} + +async function waitForAccessStatus( + initial: AccessRequestResponse, + timeoutMs: number, + clientOptions: AccessClientOptions, +): Promise { + if (TERMINAL_ACCESS_STATUSES.has(initial.status) || timeoutMs <= 0) { + return initial; + } + return waitAccessRequest(initial.request_id, timeoutMs, clientOptions); +} + +function accessClientOptions(): AccessClientOptions { + return { + ...(process.env.OPENSHELL_POLICY_LOCAL_URL + ? { policyLocalUrl: process.env.OPENSHELL_POLICY_LOCAL_URL } + : {}), + }; +} + +function createAccessRequestBody(params: ToolParams): CreateAccessRequestBody { + const userIntent = readStringProperty(params, "user_intent") ?? ""; + const resource = normalizeRequestedResource(readStringProperty(params, "resource") ?? ""); + const reason = readStringProperty(params, "reason") ?? ""; + const taskId = readStringProperty(params, "task_id"); + + return { + version: "nemoclaw.access.v1", + ...(taskId ? { task_id: taskId } : {}), + user_intent: userIntent, + llm_proposal: { + resource_type: "network", + preset: resource, + access: readAccessMode(params), + duration: readDuration(params), + reason, + }, + }; +} + +function accessToolParameters(required: string[], properties: PluginRecord): PluginRecord { + return { + type: "object", + additionalProperties: false, + required, + properties, + }; +} export default function register(api: OpenClawPluginApi): void { // 1. Register /nemoclaw slash command (chat interface) @@ -378,6 +514,110 @@ export default function register(api: OpenClawPluginApi): void { registeredProviderForConfig(onboardCfg, providerCredentialEnv, probed.model), ); + api.registerTool({ + name: "request_resource_access", + description: + "Request least-privilege external resource access through OpenShell. The resource field must be a preset id, not a hostname. Call list_resource_access_presets first if you are unsure which preset to request. Prefer read access unless mutation is required.", + parameters: accessToolParameters(["user_intent", "resource", "reason"], { + user_intent: { + type: "string", + description: "The user's natural-language request.", + }, + resource: { + type: "string", + description: + "Preset id to request. Use list_resource_access_presets to discover valid preset ids. Use github for GitHub hosts such as github.com and api.github.com.", + }, + access: { + type: "string", + enum: ["read", "read_write"], + default: "read", + description: "Requested access mode. Use read unless mutation is required.", + }, + reason: { + type: "string", + description: "Why this access is needed for the current task.", + }, + duration: { + type: "string", + enum: ["session", "persistent"], + default: "session", + description: "Requested duration. Session access is the default.", + }, + task_id: { + type: "string", + description: "Optional opaque task identifier for correlation.", + }, + wait_timeout_ms: { + type: "number", + minimum: 0, + maximum: MAX_ACCESS_WAIT_MS, + default: DEFAULT_ACCESS_WAIT_MS, + description: "How long to wait for operator approval before returning pending.", + }, + }), + async execute(_id, params) { + const clientOptions = accessClientOptions(); + const response = await createAccessRequest(createAccessRequestBody(params), clientOptions); + const timeoutMs = clampWaitTimeout( + readNumberProperty(params, "wait_timeout_ms"), + DEFAULT_ACCESS_WAIT_MS, + ); + return toToolResult(await waitForAccessStatus(response, timeoutMs, clientOptions)); + }, + }); + + api.registerTool({ + name: "list_resource_access_presets", + description: + "List resource-access preset ids currently accepted for OpenShell access proposals.", + parameters: accessToolParameters([], {}), + async execute() { + const response = await listAccessPresets(accessClientOptions()); + return { + presets: response.presets.map((preset) => ({ + name: preset.name, + description: preset.description, + ...(preset.provider_profile ? { provider_profile: preset.provider_profile } : {}), + })), + }; + }, + }); + + api.registerTool({ + name: "check_resource_access", + description: + "Check or continue waiting for an OpenShell access proposal. This reports status only and cannot approve or modify access.", + parameters: accessToolParameters(["request_id"], { + request_id: { + type: "string", + description: "The request_id returned by request_resource_access.", + }, + wait_timeout_ms: { + type: "number", + minimum: 0, + maximum: MAX_ACCESS_WAIT_MS, + default: 0, + description: "Optional time to wait for a terminal status before returning pending.", + }, + }), + async execute(_id, params) { + const requestId = readStringProperty(params, "request_id"); + if (!requestId) { + return { + request_id: "", + status: "failed", + message: "Missing request_id.", + }; + } + + const clientOptions = accessClientOptions(); + const response = await getAccessRequest(requestId, clientOptions); + const timeoutMs = clampWaitTimeout(readNumberProperty(params, "wait_timeout_ms"), 0); + return toToolResult(await waitForAccessStatus(response, timeoutMs, clientOptions)); + }, + }); + // 3. Register before_tool_call hook to block secrets in memory writes (#1233) // NOTE: This relies on OpenClaw's before_tool_call plugin hook contract // (PluginHookBeforeToolCallEvent/Result in openclaw/src/plugins/types.ts). diff --git a/nemoclaw/src/register.test.ts b/nemoclaw/src/register.test.ts index d8e14e68419..a655d8c7269 100644 --- a/nemoclaw/src/register.test.ts +++ b/nemoclaw/src/register.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { OpenClawPluginApi } from "./index.js"; +import type { OpenClawPluginApi, PluginToolDefinition } from "./index.js"; vi.mock("node:child_process", () => ({ execFileSync: vi.fn(), @@ -15,12 +15,29 @@ vi.mock("./onboard/config.js", () => ({ describeOnboardProvider: vi.fn(() => "NVIDIA Endpoint API"), })); +vi.mock("./access-client.js", () => ({ + createAccessRequest: vi.fn(), + getAccessRequest: vi.fn(), + listAccessPresets: vi.fn(), + waitAccessRequest: vi.fn(), +})); + import { execFileSync } from "node:child_process"; import register, { getPluginConfig } from "./index.js"; import { loadOnboardConfig } from "./onboard/config.js"; +import { + createAccessRequest, + getAccessRequest, + listAccessPresets, + waitAccessRequest, +} from "./access-client.js"; const mockedExecFileSync = vi.mocked(execFileSync); const mockedLoadOnboardConfig = vi.mocked(loadOnboardConfig); +const mockedCreateAccessRequest = vi.mocked(createAccessRequest); +const mockedGetAccessRequest = vi.mocked(getAccessRequest); +const mockedListAccessPresets = vi.mocked(listAccessPresets); +const mockedWaitAccessRequest = vi.mocked(waitAccessRequest); function createMockApi(): OpenClawPluginApi { return { @@ -38,16 +55,28 @@ function createMockApi(): OpenClawPluginApi { registerCommand: vi.fn(), registerProvider: vi.fn(), registerService: vi.fn(), + registerTool: vi.fn(), resolvePath: vi.fn((p: string) => p), on: vi.fn(), }; } +function getRegisteredTool(api: OpenClawPluginApi, name: string): PluginToolDefinition { + const call = vi.mocked(api.registerTool).mock.calls.find(([tool]) => tool.name === name); + expect(call).toBeDefined(); + return call![0]; +} + describe("plugin registration", () => { beforeEach(() => { vi.clearAllMocks(); mockedExecFileSync.mockReset(); mockedLoadOnboardConfig.mockReturnValue(null); + mockedCreateAccessRequest.mockReset(); + mockedGetAccessRequest.mockReset(); + mockedListAccessPresets.mockReset(); + mockedWaitAccessRequest.mockReset(); + delete process.env.OPENSHELL_POLICY_LOCAL_URL; }); it("registers a slash command", () => { @@ -62,6 +91,107 @@ describe("plugin registration", () => { expect(api.registerProvider).toHaveBeenCalledWith(expect.objectContaining({ id: "inference" })); }); + it("registers OpenShell resource access tools", () => { + const api = createMockApi(); + register(api); + expect(api.registerTool).toHaveBeenCalledWith( + expect.objectContaining({ name: "request_resource_access" }), + ); + expect(api.registerTool).toHaveBeenCalledWith( + expect.objectContaining({ name: "list_resource_access_presets" }), + ); + expect(api.registerTool).toHaveBeenCalledWith( + expect.objectContaining({ name: "check_resource_access" }), + ); + }); + + it("list_resource_access_presets surfaces OpenShell provider profile backed presets", async () => { + mockedListAccessPresets.mockResolvedValue({ + presets: [ + { name: "github", description: "GitHub access", provider_profile: "github" }, + { name: "outlook", description: "Outlook access", provider_profile: "outlook" }, + ], + }); + + const api = createMockApi(); + register(api); + const tool = getRegisteredTool(api, "list_resource_access_presets"); + const result = await tool.execute("call_1", {}); + + expect(mockedListAccessPresets).toHaveBeenCalledWith({}); + expect(result).toEqual({ + presets: [ + { name: "github", description: "GitHub access", provider_profile: "github" }, + { name: "outlook", description: "Outlook access", provider_profile: "outlook" }, + ], + }); + }); + + it("request_resource_access submits an OpenShell proposal and waits for approval", async () => { + mockedCreateAccessRequest.mockResolvedValue({ + request_id: "chunk_123", + status: "pending_approval", + message: "Proposal submitted.", + }); + mockedWaitAccessRequest.mockResolvedValue({ + request_id: "chunk_123", + status: "applied", + message: "Approved.", + }); + + const api = createMockApi(); + register(api); + const tool = getRegisteredTool(api, "request_resource_access"); + const result = await tool.execute("call_1", { + user_intent: "Inspect a repo", + resource: "github.com", + reason: "Need repository metadata.", + }); + + expect(mockedCreateAccessRequest).toHaveBeenCalledWith( + { + version: "nemoclaw.access.v1", + user_intent: "Inspect a repo", + llm_proposal: { + resource_type: "network", + preset: "github", + access: "read", + duration: "session", + reason: "Need repository metadata.", + }, + }, + {}, + ); + expect(mockedWaitAccessRequest).toHaveBeenCalledWith("chunk_123", 90_000, {}); + expect(result).toEqual({ + request_id: "chunk_123", + status: "applied", + message: "Approved.", + }); + }); + + it("check_resource_access reads an existing OpenShell proposal status", async () => { + mockedGetAccessRequest.mockResolvedValue({ + request_id: "chunk_123", + status: "denied", + message: "Rejected.", + }); + + const api = createMockApi(); + register(api); + const tool = getRegisteredTool(api, "check_resource_access"); + const result = await tool.execute("call_2", { + request_id: "chunk_123", + }); + + expect(mockedGetAccessRequest).toHaveBeenCalledWith("chunk_123", {}); + expect(result).toEqual({ + request_id: "chunk_123", + status: "denied", + message: "Rejected.", + }); + }); + it("continues registration when the runtime context hook is unsupported", () => { const api = createMockApi(); vi.mocked(api.on).mockImplementation((hookName: string) => { From cdb8cfd9984d64b3efd83a6668d78e364aa15e08 Mon Sep 17 00:00:00 2001 From: Patrick Riel Date: Thu, 14 May 2026 00:24:01 +0000 Subject: [PATCH 4/6] test(plugin): verify openshell policy local flow --- nemoclaw/src/access-client.test.ts | 165 +++++++++- nemoclaw/src/access-client.ts | 296 ++++++++++++++++-- test/e2e/nemoclaw-policy-local-runner.mjs | 74 +++++ test/e2e/test-nemoclaw-policy-local-plugin.sh | 88 ++++++ 4 files changed, 603 insertions(+), 20 deletions(-) create mode 100755 test/e2e/nemoclaw-policy-local-runner.mjs create mode 100755 test/e2e/test-nemoclaw-policy-local-plugin.sh diff --git a/nemoclaw/src/access-client.test.ts b/nemoclaw/src/access-client.test.ts index 5d262ce007f..4fe605fd887 100644 --- a/nemoclaw/src/access-client.test.ts +++ b/nemoclaw/src/access-client.test.ts @@ -1,11 +1,45 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import http from "node:http"; +import { afterEach, describe, expect, it } from "vitest"; -import { createAccessRequest, listAccessPresets } from "./access-client.js"; +import { + clearAccessPresetCache, + createAccessRequest, + getAccessRequest, + listAccessPresets, +} from "./access-client.js"; + +function withServer( + handler: http.RequestListener, + fn: (baseUrl: string) => Promise, +): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer(handler); + server.listen(0, "127.0.0.1", async () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("test server did not bind to a TCP port")); + return; + } + try { + await fn(`http://127.0.0.1:${address.port}`); + server.close((err) => (err ? reject(err) : resolve())); + } catch (err) { + server.close(() => reject(err)); + } + }); + }); +} describe("access client", () => { + afterEach(() => { + delete process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON; + clearAccessPresetCache(); + }); + it("rejects non-HTTP policy.local URLs", () => { expect(() => createAccessRequest( @@ -33,4 +67,131 @@ describe("access client", () => { ]), }); }); + + it("adds dynamic OpenShell provider profiles to the access preset list", async () => { + 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" }], + binaries: ["/usr/bin/git"], + }, + { + id: "empty-provider", + display_name: "Empty", + endpoints: [], + }, + ], + }); + + await expect(listAccessPresets()).resolves.toMatchObject({ + presets: expect.arrayContaining([ + expect.objectContaining({ + name: "gitlab", + description: "GitLab API and Git operations", + provider_profile: "gitlab", + }), + ]), + }); + const response = await listAccessPresets(); + expect(response.presets.some((preset) => preset.name === "empty-provider")).toBe(false); + }); + + it("submits provider-profile-backed proposals", async () => { + process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON = JSON.stringify([ + { + id: "gitlab", + description: "GitLab access", + endpoints: [ + { + host: "gitlab.com", + port: 443, + protocol: "rest", + enforcement: "enforce", + }, + ], + binaries: ["/usr/bin/git"], + }, + ]); + + let captured = ""; + await withServer( + (req, res) => { + req.setEncoding("utf8"); + req.on("data", (chunk) => { + captured += chunk; + }); + req.on("end", () => { + res.writeHead(202, { "content-type": "application/json" }); + res.end(JSON.stringify({ accepted_chunk_ids: ["chunk_gitlab"] })); + }); + }, + async (baseUrl) => { + await expect( + createAccessRequest( + { + version: "nemoclaw.access.v1", + user_intent: "Inspect merge requests", + llm_proposal: { + resource_type: "network", + preset: "gitlab", + access: "read", + duration: "session", + reason: "Need GitLab API metadata.", + }, + }, + { policyLocalUrl: baseUrl }, + ), + ).resolves.toMatchObject({ request_id: "chunk_gitlab", status: "pending_approval" }); + }, + ); + + const body = JSON.parse(captured); + expect(body.operations[0].addRule.ruleName).toBe("gitlab"); + expect(body.operations[0].addRule.rule).toMatchObject({ + name: "gitlab", + endpoints: [ + { + host: "gitlab.com", + port: 443, + protocol: "rest", + enforcement: "enforce", + }, + ], + binaries: [{ path: "/usr/bin/git" }], + }); + expect(body.operations[0].addRule.rule.endpoints[0].rules).toEqual([ + { allow: { method: "GET", path: "/**" } }, + { allow: { method: "HEAD", path: "/**" } }, + ]); + }); + + it("does not report approved requests as applied until policy reloads", async () => { + await withServer( + (_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + chunk_id: "chunk_wait", + status: "approved", + policy_reloaded: false, + }), + ); + }, + async (baseUrl) => { + await expect(getAccessRequest("chunk_wait", { policyLocalUrl: baseUrl })).resolves.toEqual({ + request_id: "chunk_wait", + status: "pending_approval", + message: undefined, + canonical_request: { + chunk_id: "chunk_wait", + status: "approved", + policy_reloaded: false, + }, + }); + }, + ); + }); }); diff --git a/nemoclaw/src/access-client.ts b/nemoclaw/src/access-client.ts index 3126ae560fe..339f41b8a55 100644 --- a/nemoclaw/src/access-client.ts +++ b/nemoclaw/src/access-client.ts @@ -1,7 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { execFileSync } from "node:child_process"; import http from "node:http"; +import net from "node:net"; export type AccessStatus = "pending_approval" | "applied" | "denied" | "failed"; @@ -71,9 +73,40 @@ type AccessPreset = AccessPresetInfo & { rule: NetworkRule; }; +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; +}; + const NODE_BINARIES = [{ path: "/usr/local/bin/node" }, { path: "/usr/bin/node" }]; const READ_METHODS = ["GET", "HEAD"]; const READ_WRITE_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]; +const PROVIDER_PROFILE_CACHE_MS = 30_000; + +let cachedProviderPresets: { loadedAt: number; presets: AccessPreset[] } | null = null; const PRESETS: AccessPreset[] = [ { @@ -225,6 +258,155 @@ const PRESETS: AccessPreset[] = [ }, ]; +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 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): NetworkEndpoint | null { + if (typeof endpoint.host !== "string" || Number(endpoint.port) <= 0) return null; + const output: NetworkEndpoint = { + 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 as Record)[key] = value; + } + } + return output; +} + +function providerProfileToPreset(profile: ProviderProfile): AccessPreset | null { + const endpoints = (profile.endpoints || []) + .map(cleanProviderEndpoint) + .filter((endpoint): endpoint is NetworkEndpoint => endpoint !== null); + if (endpoints.length === 0) return null; + + const binaries = (profile.binaries || []) + .map(providerBinaryPath) + .filter((binary): binary is string => Boolean(binary)) + .map((path) => ({ path })); + + const ruleName = profile.id.replace(/-/g, "_"); + return { + name: profile.id, + description: profile.description || profile.display_name || `${profile.id} provider profile`, + provider_profile: profile.id, + rule: { + name: ruleName, + endpoints, + binaries, + }, + }; +} + +function readProviderProfilesFromOpenShell(): ProviderProfile[] { + if (process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON) { + return parseProviderProfilesJson(process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON); + } + try { + const raw = execFileSync(openshellBinary(), ["provider", "list-profiles", "-o", "json"], { + encoding: "utf-8", + timeout: 5_000, + stdio: ["ignore", "pipe", "ignore"], + }); + return parseProviderProfilesJson(raw); + } catch { + return []; + } +} + +function listProviderProfilePresets(): AccessPreset[] { + const now = Date.now(); + if (cachedProviderPresets && now - cachedProviderPresets.loadedAt < PROVIDER_PROFILE_CACHE_MS) { + return cachedProviderPresets.presets; + } + const presets = readProviderProfilesFromOpenShell() + .map(providerProfileToPreset) + .filter((preset): preset is AccessPreset => preset !== null); + cachedProviderPresets = { loadedAt: now, presets }; + return presets; +} + +function allPresets(): AccessPreset[] { + const byName = new Map(); + for (const preset of PRESETS) byName.set(preset.name, preset); + for (const preset of listProviderProfilePresets()) { + 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)); +} + +export function clearAccessPresetCache(): void { + cachedProviderPresets = null; +} + function normalizePresetName(resource: string): string { const normalized = resource.trim().toLowerCase(); const host = (() => { @@ -250,7 +432,7 @@ function rulesForAccess(access: "read" | "read_write"): L7Rule[] { function ruleForRequest(body: CreateAccessRequestBody): NetworkRule { const presetName = normalizePresetName(body.llm_proposal.preset); - const preset = PRESETS.find((candidate) => candidate.name === presetName); + const preset = allPresets().find((candidate) => candidate.name === presetName); if (!preset) { throw new Error(`Unknown access preset '${body.llm_proposal.preset}'.`); } @@ -295,8 +477,8 @@ function parseJsonObject(raw: string): Record { return parsed as Record; } -function mapChunkStatus(status: unknown): AccessStatus { - if (status === "approved") return "applied"; +function mapChunkStatus(status: unknown, policyReloaded: unknown): AccessStatus { + if (status === "approved") return policyReloaded === true ? "applied" : "pending_approval"; if (status === "rejected") return "denied"; if (status === "pending") return "pending_approval"; return "failed"; @@ -324,23 +506,27 @@ function requestJson( headers["Content-Length"] = Buffer.byteLength(payload); } + const proxy = base.hostname === "policy.local" ? proxyUrl() : null; + if (proxy) { + return requestJsonViaHttpProxy( + method, + base, + requestPath, + headers, + payload, + options, + parseResponse, + ); + } + return new Promise((resolve, reject) => { - const proxy = base.hostname === "policy.local" ? proxyUrl() : null; const req = http.request( { method, - protocol: proxy?.protocol ?? base.protocol, - hostname: proxy?.hostname ?? base.hostname, - port: - proxy?.port !== undefined && proxy.port !== "" - ? Number(proxy.port) - : base.port === "" - ? undefined - : Number(base.port), - path: - proxy === null - ? `${base.pathname.replace(/\/$/, "")}${requestPath}` - : `${base.origin}${base.pathname.replace(/\/$/, "")}${requestPath}`, + protocol: base.protocol, + hostname: base.hostname, + port: base.port === "" ? undefined : Number(base.port), + path: `${base.pathname.replace(/\/$/, "")}${requestPath}`, headers, timeout: options.timeoutMs ?? 310_000, }, @@ -375,6 +561,80 @@ function requestJson( }); } +function requestJsonViaHttpProxy( + method: "GET" | "POST", + base: URL, + requestPath: string, + headers: Record, + payload: string | undefined, + options: AccessClientOptions, + parseResponse: (raw: string) => T, +): Promise { + const proxy = proxyUrl(); + if (!proxy) { + return Promise.reject(new Error("HTTP proxy is not configured.")); + } + + const target = `http://policy.local:80${base.pathname.replace(/\/$/, "")}${requestPath}`; + const timeoutMs = options.timeoutMs ?? 310_000; + const proxyPort = proxy.port ? Number(proxy.port) : 80; + const headerLines = Object.entries(headers).map(([key, value]) => `${key}: ${value}`); + const requestBytes = [ + `${method} ${target} HTTP/1.1`, + ...headerLines, + "Connection: close", + "", + payload ?? "", + ].join("\r\n"); + + return new Promise((resolve, reject) => { + const socket = net.connect({ host: proxy.hostname, port: proxyPort }); + const chunks: Buffer[] = []; + const timer = setTimeout(() => { + socket.destroy(new Error(`OpenShell policy.local ${method} ${requestPath} timed out`)); + }, timeoutMs); + + socket.on("connect", () => { + socket.write(requestBytes); + }); + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + socket.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + socket.on("end", () => { + clearTimeout(timer); + const raw = Buffer.concat(chunks).toString("utf-8"); + const headerEnd = raw.indexOf("\r\n\r\n"); + if (headerEnd === -1) { + reject( + new Error( + `OpenShell policy.local ${method} ${requestPath} returned a malformed HTTP response`, + ), + ); + return; + } + const header = raw.slice(0, headerEnd); + const body = raw.slice(headerEnd + 4); + const statusLine = header.split("\r\n")[0] ?? ""; + const statusCode = Number(statusLine.split(/\s+/)[1]); + if (!Number.isFinite(statusCode) || statusCode < 200 || statusCode >= 300) { + reject( + new Error( + `OpenShell policy.local ${method} ${requestPath} failed with HTTP ${Number.isFinite(statusCode) ? statusCode : "unknown"}: ${body}`, + ), + ); + return; + } + try { + resolve(parseResponse(body)); + } catch (err) { + reject(err); + } + }); + }); +} + function proposalBody(body: CreateAccessRequestBody): Record { const rule = ruleForRequest(body); return { @@ -406,7 +666,7 @@ function parseStateResponse(raw: string): AccessRequestResponse { const requestId = typeof parsed.chunk_id === "string" ? parsed.chunk_id : ""; return { request_id: requestId, - status: mapChunkStatus(parsed.status), + status: mapChunkStatus(parsed.status, parsed.policy_reloaded), message: typeof parsed.rejection_reason === "string" && parsed.rejection_reason ? parsed.rejection_reason @@ -456,7 +716,7 @@ export function listAccessPresets( _options: AccessClientOptions = {}, ): Promise { return Promise.resolve({ - presets: PRESETS.map(({ name, description, provider_profile }) => ({ + presets: allPresets().map(({ name, description, provider_profile }) => ({ name, description, ...(provider_profile ? { provider_profile } : {}), diff --git a/test/e2e/nemoclaw-policy-local-runner.mjs b/test/e2e/nemoclaw-policy-local-runner.mjs new file mode 100755 index 00000000000..ed5115d8a72 --- /dev/null +++ b/test/e2e/nemoclaw-policy-local-runner.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import register from "/sandbox/nemoclaw/dist/index.js"; + +const tools = new Map(); +const api = { + id: "nemoclaw", + name: "NemoClaw", + version: "0.1.0", + config: {}, + pluginConfig: {}, + logger: { + info() {}, + warn(message) { + console.error(`[warn] ${message}`); + }, + error(message) { + console.error(`[error] ${message}`); + }, + debug() {}, + }, + registerCommand() {}, + registerProvider() {}, + registerService() {}, + registerTool(tool) { + tools.set(tool.name, tool); + }, + resolvePath(input) { + return input; + }, + on() {}, +}; + +function usage() { + console.error("usage: nemoclaw-policy-local-runner.mjs list|request|check "); + process.exit(2); +} + +register(api); + +const [command, requestId] = process.argv.slice(2); +if (!command) usage(); + +function tool(name) { + const entry = tools.get(name); + if (!entry) throw new Error(`tool not registered: ${name}`); + return entry; +} + +let result; +if (command === "list") { + result = await tool("list_resource_access_presets").execute("call_list", {}); +} else if (command === "request") { + result = await tool("request_resource_access").execute("call_request", { + user_intent: "Verify NemoClaw plugin access request integration", + resource: "github", + access: "read", + duration: "session", + reason: "The live e2e needs a deterministic provider-backed proposal.", + wait_timeout_ms: 0, + }); +} else if (command === "check") { + if (!requestId) usage(); + result = await tool("check_resource_access").execute("call_check", { + request_id: requestId, + wait_timeout_ms: 30_000, + }); +} else { + usage(); +} + +console.log(JSON.stringify(result)); diff --git a/test/e2e/test-nemoclaw-policy-local-plugin.sh b/test/e2e/test-nemoclaw-policy-local-plugin.sh new file mode 100755 index 00000000000..109f8278ae1 --- /dev/null +++ b/test/e2e/test-nemoclaw-policy-local-plugin.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 /path/to/openshell/repo" >&2 + exit 2 +fi + +OPEN_SHELL_ROOT="$1" +NEMOCLAW_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OPENSHELL_BIN="${OPEN_SHELL_ROOT}/target/debug/openshell" +SANDBOX="${SANDBOX:-nemoclaw-plugin-live-$(date +%Y%m%d-%H%M%S)}" +TMP_DIR="$(mktemp -d)" +UPLOAD_DIR="${TMP_DIR}/upload" + +cleanup() { + "${OPENSHELL_BIN}" sandbox delete "${SANDBOX}" >/dev/null 2>&1 || true + rm -rf "${TMP_DIR}" +} +trap cleanup EXIT + +"${OPENSHELL_BIN}" settings set --global \ + --key agent_policy_proposals_enabled \ + --value true \ + --yes + +mkdir -p "${UPLOAD_DIR}/nemoclaw" +cp -R "${NEMOCLAW_ROOT}/nemoclaw/dist" "${UPLOAD_DIR}/nemoclaw/dist" +cp "${NEMOCLAW_ROOT}/nemoclaw/package.json" "${UPLOAD_DIR}/nemoclaw/package.json" +cp -R "${NEMOCLAW_ROOT}/nemoclaw/node_modules" "${UPLOAD_DIR}/nemoclaw/node_modules" +cp "${NEMOCLAW_ROOT}/test/e2e/nemoclaw-policy-local-runner.mjs" "${UPLOAD_DIR}/runner.mjs" + +"${OPENSHELL_BIN}" sandbox delete "${SANDBOX}" >/dev/null 2>&1 || true +"${OPENSHELL_BIN}" sandbox create \ + --name "${SANDBOX}" \ + --upload "${UPLOAD_DIR}:/sandbox" \ + --no-git-ignore \ + --keep \ + --no-auto-providers \ + --no-tty \ + -- bash -lc "if [ -d /sandbox/upload ]; then cp -R /sandbox/upload/. /sandbox/; fi && node --version && test -f /sandbox/nemoclaw/dist/index.js && test -d /sandbox/nemoclaw/node_modules && test -f /sandbox/runner.mjs && echo plugin sandbox ready" + +"${OPENSHELL_BIN}" sandbox ssh-config "${SANDBOX}" > "${TMP_DIR}/ssh_config" +SSH_HOST="$(awk '/^Host / { print $2; exit }' "${TMP_DIR}/ssh_config")" +if [ -z "${SSH_HOST}" ]; then + echo "failed to parse sandbox ssh host" >&2 + exit 1 +fi + +for _ in $(seq 1 30); do + if ssh -F "${TMP_DIR}/ssh_config" "${SSH_HOST}" true >/dev/null 2>&1; then + break + fi + sleep 2 +done +ssh -F "${TMP_DIR}/ssh_config" "${SSH_HOST}" true + +LIST_JSON="$(ssh -F "${TMP_DIR}/ssh_config" "${SSH_HOST}" node /sandbox/runner.mjs list)" +printf "LIST_JSON=%s\n" "${LIST_JSON}" +printf "%s" "${LIST_JSON}" \ + | jq -e '.presets[] | select(.name == "github" and .provider_profile == "github")' \ + >/dev/null + +REQUEST_JSON="$(ssh -F "${TMP_DIR}/ssh_config" "${SSH_HOST}" node /sandbox/runner.mjs request)" +printf "REQUEST_JSON=%s\n" "${REQUEST_JSON}" +REQ_ID="$(printf "%s" "${REQUEST_JSON}" | jq -r '.request_id')" +if [ -z "${REQ_ID}" ] || [ "${REQ_ID}" = "null" ]; then + echo "request_resource_access did not return a request_id" >&2 + exit 1 +fi +if [ "$(printf "%s" "${REQUEST_JSON}" | jq -r '.status')" != "pending_approval" ]; then + echo "request_resource_access did not return pending_approval" >&2 + exit 1 +fi + +"${OPENSHELL_BIN}" rule approve "${SANDBOX}" --chunk-id "${REQ_ID}" + +CHECK_JSON="$(ssh -F "${TMP_DIR}/ssh_config" "${SSH_HOST}" node /sandbox/runner.mjs check "${REQ_ID}")" +printf "CHECK_JSON=%s\n" "${CHECK_JSON}" +if [ "$(printf "%s" "${CHECK_JSON}" | jq -r '.status')" != "applied" ]; then + echo "check_resource_access did not return applied" >&2 + exit 1 +fi + +printf "NemoClaw plugin live policy.local flow passed for request_id=%s\n" "${REQ_ID}" From faa6e8f3e86d67e26ee995f50cdb3f3358c97a2d Mon Sep 17 00:00:00 2001 From: Patrick Riel Date: Mon, 18 May 2026 16:40:07 +0000 Subject: [PATCH 5/6] feat(sandbox): install provider access tools --- Dockerfile | 11 +++- Dockerfile.base | 6 ++ .../policies/presets/github.yaml | 18 +++--- nemoclaw/src/onboard/config.test.ts | 12 ++++ nemoclaw/src/onboard/config.ts | 11 +++- scripts/generate-openclaw-config.py | 5 +- scripts/install-provider-tools.sh | 58 +++++++++++++++++++ src/lib/sandbox/build-context.ts | 4 ++ test/generate-openclaw-config.test.ts | 13 ++++- test/sandbox-build-context.test.ts | 8 ++- test/validate-blueprint.test.ts | 16 +++-- 11 files changed, 146 insertions(+), 16 deletions(-) create mode 100755 scripts/install-provider-tools.sh diff --git a/Dockerfile b/Dockerfile index d0425a65fe1..4045d1561f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,8 @@ RUN npm ci && npm run build # hadolint ignore=DL3006 FROM ${BASE_IMAGE} +COPY scripts/install-provider-tools.sh /usr/local/lib/nemoclaw/install-provider-tools.sh + # Harden: remove unnecessary build tools and network probes from base image (#830) # Protect runtime tools before autoremove — the GHCR base may predate the # procps/e2fsprogs additions, leaving ps/chattr absent or auto-marked. The @@ -57,6 +59,11 @@ RUN set -eu; \ ps --version; \ command -v chattr >/dev/null +# Provider tools are installed in the base image, but this derived-image replay +# keeps local and CI builds usable while GHCR sandbox-base catches up. OpenShell +# provider approval still controls network and credential use at runtime. +RUN chmod 755 /usr/local/lib/nemoclaw/install-provider-tools.sh \ + && /usr/local/lib/nemoclaw/install-provider-tools.sh # Copy built plugin and blueprint into the sandbox COPY --from=builder /opt/nemoclaw/dist/ /opt/nemoclaw/dist/ @@ -244,6 +251,8 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \ /usr/local/lib/nemoclaw/generate-openclaw-config.py \ /usr/local/lib/nemoclaw/seed-wechat-accounts.py \ && if [ -d /usr/local/lib/nemoclaw/preloads ]; then find /usr/local/lib/nemoclaw/preloads -type f -name '*.js' -exec chmod 644 {} +; fi \ + && mkdir -p /usr/local/share/nemoclaw/openclaw-plugins/nemoclaw \ + && cp -a /opt/nemoclaw/. /usr/local/share/nemoclaw/openclaw-plugins/nemoclaw/ \ && chmod 755 /usr/local/share/nemoclaw \ /usr/local/share/nemoclaw/openclaw-plugins \ && find /usr/local/share/nemoclaw/openclaw-plugins -type d -exec chmod 755 {} + \ @@ -621,7 +630,7 @@ RUN chown root:root /sandbox/.nemoclaw \ && chmod -R 755 /sandbox/.nemoclaw/blueprints \ && mkdir -p /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration /sandbox/.nemoclaw/snapshots /sandbox/.nemoclaw/staging \ && chown sandbox:sandbox /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration /sandbox/.nemoclaw/snapshots /sandbox/.nemoclaw/staging \ - && touch /sandbox/.nemoclaw/config.json \ + && printf '{}\n' > /sandbox/.nemoclaw/config.json \ && chown sandbox:sandbox /sandbox/.nemoclaw/config.json # OpenShell 0.0.37's macOS VM backend currently remaps rootfs ownership to the diff --git a/Dockerfile.base b/Dockerfile.base index f49a0d4e7c9..9298b97db1f 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -65,11 +65,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ procps=2:4.0.4-9 \ e2fsprogs=1.47.2-3+b11 \ "dos2unix=7.5.2-1*" \ + gh=2.46.0-3 \ + glab=1.53.0-1+b3 \ jq=1.7.1-6+deb13u2 \ vim-tiny=2:9.1.1230-2 \ openssh-sftp-server=1:10.0p1-7+deb13u4 \ && rm -rf /var/lib/apt/lists/* +COPY scripts/install-provider-tools.sh /usr/local/lib/nemoclaw/install-provider-tools.sh +RUN chmod 755 /usr/local/lib/nemoclaw/install-provider-tools.sh \ + && /usr/local/lib/nemoclaw/install-provider-tools.sh + # gosu for privilege separation (gateway vs sandbox user). # Install from GitHub release with checksum verification instead of # Debian's packaged gosu can lag upstream. Pinned to 1.19 (2025-09). diff --git a/nemoclaw-blueprint/policies/presets/github.yaml b/nemoclaw-blueprint/policies/presets/github.yaml index 962a93e6796..992f8bad222 100644 --- a/nemoclaw-blueprint/policies/presets/github.yaml +++ b/nemoclaw-blueprint/policies/presets/github.yaml @@ -11,16 +11,13 @@ # selects this preset during `nemoclaw onboard` (or applies it later via # `openshell policy set`). # -# The `gh` CLI was also whitelisted here historically (alongside `git`), -# but the sandbox base image (Dockerfile.base) only apt-installs `git`, -# not `gh`. Users selecting this preset and running `gh api …` would hit -# `bash: gh: command not found`. Dropping `/usr/bin/gh` from the binaries -# list and from the description keeps the preset surface honest about -# what's actually usable in the shipped image. Closes #2179. +# The sandbox image ships the provider tools used by the GitHub provider +# profile, so preset policy exposes the same usable surface. Credential +# injection is still governed by OpenShell provider approval. preset: name: github - description: "GitHub.com and GitHub API access (git)" + description: "GitHub.com and GitHub API access (gh, git, curl)" network_policies: github: @@ -33,4 +30,11 @@ network_policies: port: 443 access: full binaries: + - { path: /usr/bin/gh } + - { path: /usr/local/bin/gh } - { path: /usr/bin/git } + - { path: /usr/local/bin/git } + - { path: /usr/bin/curl } + - { path: /usr/local/bin/curl } + - { path: /usr/bin/node } + - { path: /usr/local/bin/node } diff --git a/nemoclaw/src/onboard/config.test.ts b/nemoclaw/src/onboard/config.test.ts index bb0b8627209..d3703c3a62e 100644 --- a/nemoclaw/src/onboard/config.test.ts +++ b/nemoclaw/src/onboard/config.test.ts @@ -143,6 +143,18 @@ describe("onboard/config", () => { expect(loadOnboardConfig()).toBeNull(); }); + it("returns null when the config file is empty", () => { + const configPath = `${homedir()}/.nemoclaw/config.json`; + store.set(configPath, ""); + expect(loadOnboardConfig()).toBeNull(); + }); + + it("returns null when the config file is malformed", () => { + const configPath = `${homedir()}/.nemoclaw/config.json`; + store.set(configPath, "{"); + expect(loadOnboardConfig()).toBeNull(); + }); + it("returns parsed config when file exists", () => { const config = makeConfig(); const configPath = `${homedir()}/.nemoclaw/config.json`; diff --git a/nemoclaw/src/onboard/config.ts b/nemoclaw/src/onboard/config.ts index ac59a05eebe..5de63cc56ba 100644 --- a/nemoclaw/src/onboard/config.ts +++ b/nemoclaw/src/onboard/config.ts @@ -161,7 +161,16 @@ export function loadOnboardConfig(): NemoClawOnboardConfig | null { if (!existsSync(path)) { return null; } - const parsed: unknown = JSON.parse(readFileSync(path, "utf-8")); + const raw = readFileSync(path, "utf-8").trim(); + if (!raw) { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } const parsedObject = typeof parsed === "object" && parsed !== null ? parsed : null; return isOnboardConfig(parsedObject) ? parsedObject : null; } diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index 1304f735c0f..ce456e2caa3 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -632,8 +632,11 @@ def _placeholder(channel: str, env_key: str) -> str: if provider_key not in _provider_keys: plugin_entries[_plugin_id] = {"enabled": False} + plugin_entries["nemoclaw"] = {"enabled": True} plugins = {"entries": plugin_entries} - plugin_load_paths: list[str] = [] + plugin_load_paths: list[str] = [ + "/usr/local/share/nemoclaw/openclaw-plugins/nemoclaw" + ] for plugin in openclaw_plugins: plugin_entries[plugin["id"]] = {"enabled": True} if plugin["loadPath"] not in plugin_load_paths: diff --git a/scripts/install-provider-tools.sh b/scripts/install-provider-tools.sh new file mode 100755 index 00000000000..7354fb0430f --- /dev/null +++ b/scripts/install-provider-tools.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -eu + +export DEBIAN_FRONTEND=noninteractive +export NPM_CONFIG_AUDIT=false +export NPM_CONFIG_FUND=false +export NPM_CONFIG_UPDATE_NOTIFIER=false + +needs_apt=0 +for tool in gh glab jq; do + if ! command -v "$tool" >/dev/null 2>&1; then + needs_apt=1 + fi +done + +if [ "$needs_apt" = "1" ]; then + apt-get update + apt-get install -y --no-install-recommends \ + gh=2.46.0-3 \ + glab=1.53.0-1+b3 \ + jq=1.7.1-6+deb13u2 +fi + +needs_npm=0 +for tool in claude codex opencode; do + if ! command -v "$tool" >/dev/null 2>&1; then + needs_npm=1 + fi +done + +if [ "$needs_npm" = "1" ]; then + npm install -g --no-audit --no-fund --no-progress \ + '@anthropic-ai/claude-code@2.1.143' \ + '@openai/codex@0.130.0' \ + 'opencode-ai@1.15.0' +fi + +# GitHub Copilot is exposed through the GitHub CLI. Keep the binary path named +# by the OpenShell provider profile present, while letting provider policy +# control whether the wrapper can reach GitHub after approval. +cat > /usr/local/bin/copilot <<'EOF' +#!/bin/sh +exec gh copilot "$@" +EOF +chmod 755 /usr/local/bin/copilot + +command -v gh >/dev/null +command -v glab >/dev/null +command -v jq >/dev/null +command -v claude >/dev/null +command -v codex >/dev/null +command -v opencode >/dev/null +command -v copilot >/dev/null + +rm -rf /var/lib/apt/lists/* diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 97e61bfa690..7d5d7e45590 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -120,6 +120,10 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "codex-acp-wrapper.sh"), path.join(stagedScriptsDir, "codex-acp-wrapper.sh"), ); + fs.copyFileSync( + path.join(rootDir, "scripts", "install-provider-tools.sh"), + path.join(stagedScriptsDir, "install-provider-tools.sh"), + ); // Shared sandbox initialisation library sourced by the entrypoint (#2277) fs.mkdirSync(path.join(stagedScriptsDir, "lib"), { recursive: true }); fs.copyFileSync( diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 6752821db5c..62c744a0812 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -393,6 +393,7 @@ describe("generate-openclaw-config.py: config generation", () => { enabled: true, }); expect(config.plugins.load.paths).toEqual([ + "/usr/local/share/nemoclaw/openclaw-plugins/nemoclaw", "/usr/local/share/nemoclaw/openclaw-plugins/kimi-inference-compat", ]); }); @@ -502,7 +503,9 @@ describe("generate-openclaw-config.py: config generation", () => { const providerConfig = Object.values(config.models.providers)[0] as any; expect(providerConfig.models[0].compat).toEqual({ supportsStore: false }); expect(config.plugins.entries["nemoclaw-kimi-inference-compat"]).toBeUndefined(); - expect(config.plugins.load).toBeUndefined(); + expect(config.plugins.load.paths).toEqual([ + "/usr/local/share/nemoclaw/openclaw-plugins/nemoclaw", + ]); } }); @@ -747,6 +750,14 @@ describe("generate-openclaw-config.py: config generation", () => { expect(config.plugins.entries.acpx.config).toBeUndefined(); }); + it("loads the NemoClaw OpenClaw plugin by default", () => { + const config = runConfigScript(); + expect(config.plugins.entries.nemoclaw).toEqual({ enabled: true }); + expect(config.plugins.load.paths).toEqual([ + "/usr/local/share/nemoclaw/openclaw-plugins/nemoclaw", + ]); + }); + it("disables unused bundled provider plugins with staged runtime deps", () => { const config = runConfigScript({ NEMOCLAW_PROVIDER_KEY: "inference" }); expect(config.plugins.entries["amazon-bedrock"].enabled).toBe(false); diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index 21b1a7d1d1d..04f6f29f991 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -70,6 +70,7 @@ describe("sandbox build context staging", () => { fs.chmodSync(blueprintManifestDir, 0o700); writeFixture(path.join("scripts", "nemoclaw-start.sh")); writeFixture(path.join("scripts", "codex-acp-wrapper.sh")); + writeFixture(path.join("scripts", "install-provider-tools.sh")); writeFixture(path.join("scripts", "lib", "sandbox-init.sh")); writeFixture(path.join("scripts", "generate-openclaw-config.py")); writeFixture(path.join("scripts", "seed-wechat-accounts.py")); @@ -86,7 +87,9 @@ describe("sandbox build context staging", () => { "index.js", ); - expect((fs.statSync(stagedManifestDir).mode & 0o777).toString(8)).toBe("755"); + const stagedManifestDirMode = fs.statSync(stagedManifestDir).mode & 0o777; + expect(stagedManifestDirMode & 0o555).toBe(0o555); + expect(stagedManifestDirMode & 0o002).toBe(0); expect((fs.statSync(stagedManifest).mode & 0o777).toString(8)).toBe("644"); expect((fs.statSync(stagedPlugin).mode & 0o777).toString(8)).toBe("644"); } @@ -191,6 +194,9 @@ describe("sandbox build context staging", () => { ).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "nemoclaw-start.sh"))).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "codex-acp-wrapper.sh"))).toBe(true); + expect(fs.existsSync(path.join(buildCtx, "scripts", "install-provider-tools.sh"))).toBe( + true, + ); expect(fs.existsSync(path.join(buildCtx, "scripts", "generate-openclaw-config.py"))).toBe( true, ); diff --git a/test/validate-blueprint.test.ts b/test/validate-blueprint.test.ts index ef2b059d20f..a9cad3fb160 100644 --- a/test/validate-blueprint.test.ts +++ b/test/validate-blueprint.test.ts @@ -477,16 +477,24 @@ describe("github preset", () => { expect(np && "github" in np).toBe(true); }); - it("regression #2179: github preset only advertises the installed git binary", () => { + it("regression #2179: github preset advertises installed provider tools", () => { const parsed = loadYaml(PRESET_PATH); const meta = parsed.preset; - expect(meta?.description).toBe("GitHub.com and GitHub API access (git)"); - expect(meta?.description ?? "").not.toMatch(/\bgh\b/); + expect(meta?.description).toBe("GitHub.com and GitHub API access (gh, git, curl)"); const binaries = (parsed.network_policies?.github?.binaries ?? []) .map((binary) => binary.path) .sort(); - expect(binaries).toEqual(["/usr/bin/git"]); + expect(binaries).toEqual([ + "/usr/bin/curl", + "/usr/bin/gh", + "/usr/bin/git", + "/usr/bin/node", + "/usr/local/bin/curl", + "/usr/local/bin/gh", + "/usr/local/bin/git", + "/usr/local/bin/node", + ]); }); }); From 5516fbbb0209a0f7aa2f9a84e883aee012f426f1 Mon Sep 17 00:00:00 2001 From: Patrick Riel Date: Thu, 14 May 2026 00:58:21 +0000 Subject: [PATCH 6/6] feat(hermes): request access through openshell policy local --- agents/hermes/plugin/__init__.py | 497 +++++++++++++++++- agents/hermes/plugin/plugin.yaml | 1 + .../nemoclaw-openshell-integration.md | 32 +- test/e2e/hermes-policy-local-runner.py | 77 +++ test/e2e/test-hermes-policy-local-plugin.sh | 87 +++ test/hermes-plugin-handlers.test.ts | 113 ++++ 6 files changed, 793 insertions(+), 14 deletions(-) create mode 100755 test/e2e/hermes-policy-local-runner.py create mode 100755 test/e2e/test-hermes-policy-local-plugin.sh diff --git a/agents/hermes/plugin/__init__.py b/agents/hermes/plugin/__init__.py index 6823d956dea..485f6ba6f7b 100644 --- a/agents/hermes/plugin/__init__.py +++ b/agents/hermes/plugin/__init__.py @@ -16,9 +16,465 @@ import json import os +import socket import subprocess +import time +from urllib.parse import quote, urlparse import yaml +READ_METHODS = ["GET", "HEAD"] +READ_WRITE_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"] +DEFAULT_ACCESS_WAIT_MS = 90000 +MAX_ACCESS_WAIT_MS = 300000 +TERMINAL_ACCESS_STATUSES = {"applied", "denied", "failed"} +PROVIDER_PROFILE_CACHE_SECONDS = 30 +_provider_preset_cache = {"loaded_at": 0.0, "presets": None} + +HERMES_BINARIES = [ + {"path": "/usr/local/bin/hermes"}, + {"path": "/opt/hermes/.venv/bin/python"}, + {"path": "/usr/bin/python3*"}, + {"path": "/usr/local/bin/python3*"}, +] + +FALLBACK_PRESETS = [ + { + "name": "github", + "description": "GitHub.com and GitHub API access", + "provider_profile": "github", + "rule": { + "name": "github", + "endpoints": [ + {"host": "github.com", "port": 443, "protocol": "rest", "enforcement": "enforce"}, + {"host": "api.github.com", "port": 443, "protocol": "rest", "enforcement": "enforce"}, + ], + "binaries": HERMES_BINARIES + [{"path": "/usr/bin/git"}, {"path": "/usr/bin/curl"}], + }, + }, + { + "name": "outlook", + "description": "Microsoft Outlook and Graph API access", + "provider_profile": "outlook", + "rule": { + "name": "outlook_graph", + "endpoints": [ + {"host": "graph.microsoft.com", "port": 443, "protocol": "rest", "enforcement": "enforce"}, + {"host": "login.microsoftonline.com", "port": 443, "protocol": "rest", "enforcement": "enforce"}, + {"host": "outlook.office365.com", "port": 443, "protocol": "rest", "enforcement": "enforce"}, + {"host": "outlook.office.com", "port": 443, "protocol": "rest", "enforcement": "enforce"}, + ], + "binaries": HERMES_BINARIES, + }, + }, + { + "name": "pypi", + "description": "Python Package Index (PyPI) access", + "rule": { + "name": "pypi", + "endpoints": [ + {"host": "pypi.org", "port": 443, "protocol": "rest", "enforcement": "enforce"}, + {"host": "files.pythonhosted.org", "port": 443, "protocol": "rest", "enforcement": "enforce"}, + ], + "binaries": HERMES_BINARIES + [{"path": "/usr/bin/pip*"}, {"path": "/usr/local/bin/pip*"}], + }, + }, + { + "name": "npm", + "description": "npm and Yarn registry access", + "rule": { + "name": "npm_yarn", + "endpoints": [ + {"host": "registry.npmjs.org", "port": 443, "protocol": "rest", "enforcement": "enforce"}, + {"host": "registry.yarnpkg.com", "port": 443, "protocol": "rest", "enforcement": "enforce"}, + ], + "binaries": HERMES_BINARIES + [{"path": "/usr/local/bin/node*"}, {"path": "/usr/bin/node*"}], + }, + }, + { + "name": "brave", + "description": "Brave Search API access", + "rule": { + "name": "brave", + "endpoints": [ + {"host": "api.search.brave.com", "port": 443, "protocol": "rest", "enforcement": "enforce"} + ], + "binaries": HERMES_BINARIES + [{"path": "/usr/bin/curl"}], + }, + }, + { + "name": "local-inference", + "description": "Local inference access via host gateway", + "rule": { + "name": "local_inference", + "endpoints": [ + {"host": "host.openshell.internal", "port": 11434, "protocol": "rest", "enforcement": "enforce"}, + {"host": "host.openshell.internal", "port": 11435, "protocol": "rest", "enforcement": "enforce"}, + {"host": "host.openshell.internal", "port": 8000, "protocol": "rest", "enforcement": "enforce"}, + ], + "binaries": HERMES_BINARIES + [{"path": "/usr/bin/curl"}], + }, + }, +] + + +def _normalize_preset_name(resource): + normalized = str(resource or "").strip().lower() + if "://" in normalized: + try: + normalized = urlparse(normalized).hostname or normalized + except Exception: + pass + if normalized in {"github.com", "api.github.com"}: + return "github" + return normalized + + +def _rules_for_access(access): + methods = READ_WRITE_METHODS if access == "read_write" else READ_METHODS + return [{"allow": {"method": method, "path": "/**"}} for method in methods] + + +def _parse_provider_profiles_json(raw): + try: + parsed = json.loads(raw or "") + except Exception: + return [] + candidates = parsed if isinstance(parsed, list) else parsed.get("profiles", []) if isinstance(parsed, dict) else [] + return [p for p in candidates if isinstance(p, dict) and isinstance(p.get("id"), str)] + + +def _read_provider_profiles(): + raw = os.environ.get("NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON") + if raw: + return _parse_provider_profiles_json(raw) + try: + result = subprocess.run( + [os.environ.get("NEMOCLAW_OPENSHELL_BIN", "openshell"), "provider", "list-profiles", "-o", "json"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return _parse_provider_profiles_json(result.stdout) + except Exception: + pass + return [] + + +def _provider_profile_to_preset(profile): + endpoints = [] + for endpoint in profile.get("endpoints", []): + if not isinstance(endpoint, dict) or not isinstance(endpoint.get("host"), str): + continue + try: + port = int(endpoint.get("port")) + except Exception: + continue + if port <= 0: + continue + clean = {"host": endpoint["host"], "port": port} + for key in [ + "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", + ]: + value = endpoint.get(key) + if value not in (None, "", []): + clean[key] = value + endpoints.append(clean) + if not endpoints: + return None + + binaries = [] + for binary in profile.get("binaries", []): + path = binary if isinstance(binary, str) else binary.get("path") if isinstance(binary, dict) else None + if isinstance(path, str) and path: + binaries.append({"path": path}) + if not binaries: + binaries = list(HERMES_BINARIES) + return { + "name": profile["id"], + "description": profile.get("description") or profile.get("display_name") or f"{profile['id']} provider profile", + "provider_profile": profile["id"], + "rule": { + "name": profile["id"].replace("-", "_"), + "endpoints": endpoints, + "binaries": binaries, + }, + } + + +def _provider_presets(): + now = time.time() + cached = _provider_preset_cache.get("presets") + if cached is not None and now - _provider_preset_cache.get("loaded_at", 0) < PROVIDER_PROFILE_CACHE_SECONDS: + return cached + presets = [p for p in (_provider_profile_to_preset(profile) for profile in _read_provider_profiles()) if p] + _provider_preset_cache["loaded_at"] = now + _provider_preset_cache["presets"] = presets + return presets + + +def _all_presets(): + by_name = {preset["name"]: dict(preset) for preset in FALLBACK_PRESETS} + for preset in _provider_presets(): + existing = by_name.get(preset["name"]) + if existing: + existing["provider_profile"] = preset.get("provider_profile") + else: + by_name[preset["name"]] = preset + return [by_name[name] for name in sorted(by_name)] + + +def _rule_for_access_request(params): + preset_name = _normalize_preset_name(params.get("resource")) + preset = next((p for p in _all_presets() if p["name"] == preset_name), None) + if not preset: + raise ValueError(f"Unknown access preset '{params.get('resource')}'.") + access = "read_write" if params.get("access") == "read_write" else "read" + rule = json.loads(json.dumps(preset["rule"])) + for endpoint in rule.get("endpoints", []): + if endpoint.get("access") == "full" or endpoint.get("tls") == "skip": + continue + endpoint.pop("access", None) + endpoint.setdefault("rules", _rules_for_access(access)) + return rule + + +def _proposal_body(params): + rule = _rule_for_access_request(params) + intent = " ".join(filter(None, [str(params.get("user_intent") or ""), str(params.get("reason") or "")])) + return {"intent_summary": intent, "operations": [{"addRule": {"ruleName": rule["name"], "rule": rule}}]} + + +def _policy_local_base(): + return urlparse(os.environ.get("OPENSHELL_POLICY_LOCAL_URL", "http://policy.local")) + + +def _http_proxy(): + raw = os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") + if not raw: + return None + parsed = urlparse(raw) + return parsed if parsed.scheme == "http" and parsed.hostname else None + + +def _decode_chunked(body): + output = b"" + rest = body + while True: + marker = rest.find(b"\r\n") + if marker < 0: + return body + size_text = rest[:marker].split(b";", 1)[0] + try: + size = int(size_text, 16) + except Exception: + return body + rest = rest[marker + 2 :] + if size == 0: + return output + output += rest[:size] + rest = rest[size + 2 :] + + +def _policy_local_json(method, path, payload=None, timeout=310): + base = _policy_local_base() + if base.scheme != "http": + raise RuntimeError("OpenShell policy.local URL must use HTTP inside the sandbox.") + body = json.dumps(payload).encode("utf-8") if payload is not None else b"" + proxy = _http_proxy() if base.hostname == "policy.local" else None + host = proxy.hostname if proxy else base.hostname + port = proxy.port if proxy and proxy.port else 80 if proxy else base.port or 80 + target = f"http://policy.local:80{(base.path or '').rstrip('/')}{path}" if proxy else f"{(base.path or '').rstrip('/')}{path}" + headers = [ + f"{method} {target} HTTP/1.1", + f"Host: {base.netloc or base.hostname}", + "Accept: application/json", + "Connection: close", + ] + if payload is not None: + headers += ["Content-Type: application/json", f"Content-Length: {len(body)}"] + request = ("\r\n".join(headers) + "\r\n\r\n").encode("utf-8") + body + with socket.create_connection((host, port), timeout=timeout) as sock: + sock.settimeout(timeout) + sock.sendall(request) + response = b"" + while True: + chunk = sock.recv(65536) + if not chunk: + break + response += chunk + header_end = response.find(b"\r\n\r\n") + if header_end < 0: + raise RuntimeError(f"OpenShell policy.local {method} {path} returned malformed HTTP") + header = response[:header_end].decode("iso-8859-1") + raw_body = response[header_end + 4 :] + status_line = header.splitlines()[0] if header else "" + try: + status = int(status_line.split()[1]) + except Exception: + status = 0 + if "transfer-encoding: chunked" in header.lower(): + raw_body = _decode_chunked(raw_body) + text = raw_body.decode("utf-8") + if status < 200 or status >= 300: + raise RuntimeError(f"OpenShell policy.local {method} {path} failed with HTTP {status}: {text}") + return json.loads(text or "{}") + + +def _map_chunk_status(status, policy_reloaded): + if status == "approved": + return "applied" if policy_reloaded is True else "pending_approval" + if status == "rejected": + return "denied" + if status == "pending": + return "pending_approval" + return "failed" + + +def _create_access_request(params): + parsed = _policy_local_json("POST", "/v1/proposals", _proposal_body(params)) + accepted = parsed.get("accepted_chunk_ids") if isinstance(parsed.get("accepted_chunk_ids"), list) else [] + request_id = next((item for item in accepted if isinstance(item, str) and item), "") + if not request_id: + return { + "request_id": "", + "status": "failed", + "message": f"OpenShell rejected the proposal: {json.dumps(parsed.get('rejection_reasons', []))}", + } + return { + "request_id": request_id, + "status": "pending_approval", + "message": "Proposal submitted to OpenShell; waiting for operator approval.", + } + + +def _get_access_request(request_id, wait_timeout_ms=0): + suffix = f"/wait?timeout={max(1, min(300, int((wait_timeout_ms + 999) / 1000)))}" if wait_timeout_ms > 0 else "" + parsed = _policy_local_json("GET", f"/v1/proposals/{quote(request_id)}{suffix}", timeout=max(310, int(wait_timeout_ms / 1000) + 10)) + request_id = parsed.get("chunk_id") if isinstance(parsed.get("chunk_id"), str) else request_id + return { + "request_id": request_id, + "status": _map_chunk_status(parsed.get("status"), parsed.get("policy_reloaded")), + "message": parsed.get("rejection_reason") or parsed.get("validation_result"), + "canonical_request": parsed, + } + + +def _clamp_wait_timeout(value, fallback): + try: + timeout = int(value) + except Exception: + timeout = fallback + if timeout <= 0: + return 0 + return min(timeout, MAX_ACCESS_WAIT_MS) + + +def _tool_result(response): + result = { + "request_id": response.get("request_id", ""), + "status": response.get("status", "failed"), + "message": response.get("message") + or ( + "OpenShell returned a terminal access status." + if response.get("status") in TERMINAL_ACCESS_STATUSES + else "Access request is still pending; call openshell_network_access with action=check and this request_id to continue polling." + ), + } + if response.get("canonical_request"): + result["canonical_request"] = response["canonical_request"] + return result + + +def _handle_list_access_presets(tool_input=None, context=None, **_kwargs): + return json.dumps( + { + "presets": [ + { + "name": preset["name"], + "description": preset["description"], + **({"provider_profile": preset["provider_profile"]} if preset.get("provider_profile") else {}), + } + for preset in _all_presets() + ] + } + ) + + +def _missing_string_fields(params, fields): + return [ + field + for field in fields + if not isinstance(params.get(field), str) or not params.get(field).strip() + ] + + +def _handle_create_network_access_request(tool_input=None, context=None, **_kwargs): + params = tool_input if isinstance(tool_input, dict) else {} + response = _create_access_request(params) + timeout = _clamp_wait_timeout(params.get("wait_timeout_ms"), DEFAULT_ACCESS_WAIT_MS) + if response.get("status") not in TERMINAL_ACCESS_STATUSES and timeout > 0 and response.get("request_id"): + response = _get_access_request(response["request_id"], timeout) + return json.dumps(_tool_result(response)) + + +def _handle_check_network_access(tool_input=None, context=None, **_kwargs): + params = tool_input if isinstance(tool_input, dict) else {} + request_id = params.get("request_id") + if not isinstance(request_id, str) or not request_id: + return json.dumps({"request_id": "", "status": "failed", "message": "Missing request_id."}) + timeout = _clamp_wait_timeout(params.get("wait_timeout_ms"), 0) + return json.dumps(_tool_result(_get_access_request(request_id, timeout))) + + +def _handle_network_access(tool_input=None, context=None, **_kwargs): + params = tool_input if isinstance(tool_input, dict) else {} + action = params.get("action") + action = action.strip().lower() if isinstance(action, str) else "" + if action == "list_presets": + return _handle_list_access_presets(params, context, **_kwargs) + if action == "check": + request_id = params.get("request_id") + if not isinstance(request_id, str) or not request_id.strip(): + return json.dumps( + { + "request_id": "", + "status": "failed", + "message": "For action=check, provide request_id.", + } + ) + return _handle_check_network_access(params, context, **_kwargs) + if action == "request": + missing = _missing_string_fields(params, ["resource", "user_intent", "reason"]) + if missing: + return json.dumps( + { + "status": "failed", + "message": f"For action=request, provide required field(s): {', '.join(missing)}.", + } + ) + return _handle_create_network_access_request(params, context, **_kwargs) + return json.dumps( + { + "status": "failed", + "message": "Unknown action. Use one of: list_presets, check, request.", + } + ) + def _load_nemoclaw_config(): """Load NemoClaw onboard config from ~/.nemoclaw/config.json.""" @@ -212,6 +668,44 @@ def register(ctx): description="Reload skills from disk without gateway restart", ) + ctx.register_tool( + name="openshell_network_access", + toolset="nemoclaw", + schema={ + "type": "function", + "function": { + "name": "openshell_network_access", + "description": ( + "List, check, or request OpenShell network-only access for this sandbox. " + "Use this for unauthenticated network/resource reachability." + ), + "parameters": { + "type": "object", + "additionalProperties": False, + "required": ["action"], + "properties": { + "action": {"type": "string", "enum": ["list_presets", "check", "request"]}, + "user_intent": {"type": "string"}, + "resource": {"type": "string"}, + "access": {"type": "string", "enum": ["read", "read_write"], "default": "read"}, + "reason": {"type": "string"}, + "duration": {"type": "string", "enum": ["session", "persistent"], "default": "session"}, + "request_id": {"type": "string"}, + "task_id": {"type": "string"}, + "wait_timeout_ms": { + "type": "number", + "minimum": 0, + "maximum": MAX_ACCESS_WAIT_MS, + "default": DEFAULT_ACCESS_WAIT_MS, + }, + }, + }, + }, + }, + handler=_handle_network_access, + description="OpenShell network access", + ) + # Startup banner on session start def _on_session_start(**kwargs): # Refresh skill cache so skills installed since last session are @@ -227,8 +721,7 @@ def _on_session_start(**kwargs): f" \u2502 Model: {info['model']:<40}\u2502\n" f" \u2502 Provider: {info['provider']:<40}\u2502\n" f" \u2502 Gateway: {info['gateway']:<40}\u2502\n" - " \u2502 Tools: nemoclaw_status, nemoclaw_info, \u2502\n" - " \u2502 nemoclaw_reload_skills \u2502\n" + " \u2502 Tools: status/info/reload + resource access \u2502\n" " \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n" ) try: diff --git a/agents/hermes/plugin/plugin.yaml b/agents/hermes/plugin/plugin.yaml index e9ba0916311..45979528e65 100644 --- a/agents/hermes/plugin/plugin.yaml +++ b/agents/hermes/plugin/plugin.yaml @@ -11,6 +11,7 @@ provides_tools: - nemoclaw_status - nemoclaw_info - nemoclaw_reload_skills + - openshell_network_access provides_hooks: - on_session_start diff --git a/docs/reference/nemoclaw-openshell-integration.md b/docs/reference/nemoclaw-openshell-integration.md index d03b03573bd..287cd051998 100644 --- a/docs/reference/nemoclaw-openshell-integration.md +++ b/docs/reference/nemoclaw-openshell-integration.md @@ -11,21 +11,22 @@ flowchart LR end subgraph plugin_tools["NemoClaw access tools"] - list["list_resource_access_presets"] - request["request_resource_access"] - check["check_resource_access"] + openclaw_tools["OpenClaw: list/request/check tools"] + hermes_tool["Hermes: openshell_network_access"] end adapter --> adapters adapters --> plugin_tools onboard["nemoclaw onboard"] --> profile_import["Import NemoClaw provider profiles"] profile_import --> profiles["OpenShell provider profiles"] - list --> profiles + openclaw_tools --> profiles + hermes_tool --> profiles profiles --> presets["Provider-backed access presets"] - presets --> request + presets --> openclaw_tools + presets --> hermes_tool - request --> policy_local["policy.local HTTP API"] - check --> policy_local + openclaw_tools --> policy_local["policy.local HTTP API"] + hermes_tool --> policy_local subgraph sandbox["OpenShell sandbox"] policy_local @@ -38,7 +39,8 @@ flowchart LR review --> approve["Approve or reject"] approve --> merge["Policy merge and reload"] merge --> policy_runtime - policy_runtime --> check + policy_runtime --> openclaw_tools + policy_runtime --> hermes_tool agent --> workload["Requested agent work"] workload --> proxy @@ -48,24 +50,30 @@ flowchart LR ## Flow -1. The agent asks NemoClaw for allowed resource presets with `list_resource_access_presets`. +1. The agent asks NemoClaw for allowed resource 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. +4. The agent requests 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. +8. The agent checks the request; NemoClaw reports `applied` only after OpenShell reports the policy reload is complete. ## Agent Tools +OpenClaw exposes one tool per operation: + - `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. +Hermes exposes a single operation-dispatched tool: + +- `openshell_network_access`: accepts `action` values `list_presets`, `request`, and `check`. + ## 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. +Each agent adapter exposes the same response shape through the harness-native mechanism. OpenClaw uses its plugin API. Hermes uses its Python plugin API. Additional harnesses can implement the same proposal flow without changing the OpenShell policy API. ## Provider Profiles diff --git a/test/e2e/hermes-policy-local-runner.py b/test/e2e/hermes-policy-local-runner.py new file mode 100755 index 00000000000..ebaae1f92c3 --- /dev/null +++ b/test/e2e/hermes-policy-local-runner.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import importlib.util +import json +import pathlib +import sys +import types + + +plugin_path = pathlib.Path("/sandbox/hermes-plugin/__init__.py") +yaml_stub = types.ModuleType("yaml") +yaml_stub.safe_load = lambda *_args, **_kwargs: {} +sys.modules.setdefault("yaml", yaml_stub) +spec = importlib.util.spec_from_file_location("nemoclaw_hermes_plugin", plugin_path) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class Context: + def __init__(self): + self.tools = {} + + def register_tool(self, **kwargs): + self.tools[kwargs["name"]] = kwargs + + def register_hook(self, *_args, **_kwargs): + pass + + def inject_message(self, *_args, **_kwargs): + pass + + +ctx = Context() +module.register(ctx) + + +def call_tool(name, payload): + if name not in ctx.tools: + raise RuntimeError(f"tool not registered: {name}") + result = ctx.tools[name]["handler"](payload) + if isinstance(result, str): + return json.loads(result) + return result + + +command = sys.argv[1] if len(sys.argv) > 1 else "" +if command == "list": + result = call_tool("openshell_network_access", {"action": "list_presets"}) +elif command == "request": + result = call_tool( + "openshell_network_access", + { + "action": "request", + "user_intent": "Verify Hermes NemoClaw access request integration", + "resource": "github", + "access": "read", + "duration": "session", + "reason": "The live e2e needs a deterministic provider-backed proposal.", + "wait_timeout_ms": 0, + }, + ) +elif command == "check" and len(sys.argv) == 3: + result = call_tool( + "openshell_network_access", + { + "action": "check", + "request_id": sys.argv[2], + "wait_timeout_ms": 30000, + }, + ) +else: + print("usage: hermes-policy-local-runner.py list|request|check ", file=sys.stderr) + sys.exit(2) + +print(json.dumps(result, separators=(",", ":"))) diff --git a/test/e2e/test-hermes-policy-local-plugin.sh b/test/e2e/test-hermes-policy-local-plugin.sh new file mode 100755 index 00000000000..5b1e1bb9ea8 --- /dev/null +++ b/test/e2e/test-hermes-policy-local-plugin.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 /path/to/openshell/repo" >&2 + exit 2 +fi + +OPEN_SHELL_ROOT="$1" +NEMOCLAW_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OPENSHELL_BIN="${OPEN_SHELL_ROOT}/target/debug/openshell" +SANDBOX="${SANDBOX:-nemoclaw-hermes-plugin-live-$(date +%Y%m%d-%H%M%S)}" +TMP_DIR="$(mktemp -d)" +UPLOAD_DIR="${TMP_DIR}/upload" + +cleanup() { + "${OPENSHELL_BIN}" sandbox delete "${SANDBOX}" >/dev/null 2>&1 || true + rm -rf "${TMP_DIR}" +} +trap cleanup EXIT + +"${OPENSHELL_BIN}" settings set --global \ + --key agent_policy_proposals_enabled \ + --value true \ + --yes + +mkdir -p "${UPLOAD_DIR}/hermes-plugin" +cp "${NEMOCLAW_ROOT}/agents/hermes/plugin/__init__.py" "${UPLOAD_DIR}/hermes-plugin/__init__.py" +cp "${NEMOCLAW_ROOT}/agents/hermes/plugin/plugin.yaml" "${UPLOAD_DIR}/hermes-plugin/plugin.yaml" +cp "${NEMOCLAW_ROOT}/test/e2e/hermes-policy-local-runner.py" "${UPLOAD_DIR}/runner.py" + +"${OPENSHELL_BIN}" sandbox delete "${SANDBOX}" >/dev/null 2>&1 || true +"${OPENSHELL_BIN}" sandbox create \ + --name "${SANDBOX}" \ + --upload "${UPLOAD_DIR}:/sandbox" \ + --no-git-ignore \ + --keep \ + --no-auto-providers \ + --no-tty \ + -- bash -lc "if [ -d /sandbox/upload ]; then cp -R /sandbox/upload/. /sandbox/; fi && python3 --version && test -f /sandbox/hermes-plugin/__init__.py && test -f /sandbox/runner.py && echo hermes plugin sandbox ready" + +"${OPENSHELL_BIN}" sandbox ssh-config "${SANDBOX}" >"${TMP_DIR}/ssh_config" +SSH_HOST="$(awk '/^Host / { print $2; exit }' "${TMP_DIR}/ssh_config")" +if [ -z "${SSH_HOST}" ]; then + echo "failed to parse sandbox ssh host" >&2 + exit 1 +fi + +for _ in $(seq 1 30); do + if ssh -F "${TMP_DIR}/ssh_config" "${SSH_HOST}" true >/dev/null 2>&1; then + break + fi + sleep 2 +done +ssh -F "${TMP_DIR}/ssh_config" "${SSH_HOST}" true + +LIST_JSON="$(ssh -F "${TMP_DIR}/ssh_config" "${SSH_HOST}" python3 /sandbox/runner.py list)" +printf "HERMES_LIST_JSON=%s\n" "${LIST_JSON}" +printf "%s" "${LIST_JSON}" \ + | jq -e '.presets[] | select(.name == "github" and .provider_profile == "github")' \ + >/dev/null + +REQUEST_JSON="$(ssh -F "${TMP_DIR}/ssh_config" "${SSH_HOST}" python3 /sandbox/runner.py request)" +printf "HERMES_REQUEST_JSON=%s\n" "${REQUEST_JSON}" +REQ_ID="$(printf "%s" "${REQUEST_JSON}" | jq -r '.request_id')" +if [ -z "${REQ_ID}" ] || [ "${REQ_ID}" = "null" ]; then + echo "openshell_network_access action=request did not return a request_id" >&2 + exit 1 +fi +if [ "$(printf "%s" "${REQUEST_JSON}" | jq -r '.status')" != "pending_approval" ]; then + echo "openshell_network_access action=request did not return pending_approval" >&2 + exit 1 +fi + +"${OPENSHELL_BIN}" rule approve "${SANDBOX}" --chunk-id "${REQ_ID}" + +CHECK_JSON="$(ssh -F "${TMP_DIR}/ssh_config" "${SSH_HOST}" python3 /sandbox/runner.py check "${REQ_ID}")" +printf "HERMES_CHECK_JSON=%s\n" "${CHECK_JSON}" +if [ "$(printf "%s" "${CHECK_JSON}" | jq -r '.status')" != "applied" ]; then + echo "openshell_network_access action=check did not return applied" >&2 + exit 1 +fi + +printf "Hermes NemoClaw plugin live policy.local flow passed for request_id=%s\n" "${REQ_ID}" diff --git a/test/hermes-plugin-handlers.test.ts b/test/hermes-plugin-handlers.test.ts index 2df02244e8a..08b9e191456 100644 --- a/test/hermes-plugin-handlers.test.ts +++ b/test/hermes-plugin-handlers.test.ts @@ -71,4 +71,117 @@ print(json.dumps(result)) expect(result.reload).toContain("alpha: First skill"); expect(result.reload).toContain("beta: Second skill"); }); + + it("registers Hermes resource-access tools and maps policy.local status", () => { + const output = runPython(` +import importlib.util +import json +import pathlib +import sys +import types + +plugin_path = pathlib.Path(sys.argv[1]) +yaml_stub = types.ModuleType("yaml") +yaml_stub.safe_load = lambda *_args, **_kwargs: {} +sys.modules.setdefault("yaml", yaml_stub) +spec = importlib.util.spec_from_file_location("hermes_plugin", plugin_path) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +class Ctx: + def __init__(self): + self.tools = {} + def register_tool(self, **kwargs): + self.tools[kwargs["name"]] = kwargs + def register_hook(self, *_args, **_kwargs): + pass + def inject_message(self, *_args, **_kwargs): + pass + +calls = [] +def fake_policy(method, path, payload=None, timeout=310): + calls.append({"method": method, "path": path, "payload": payload}) + if method == "POST": + return {"accepted_chunk_ids": ["chunk-123"]} + return {"chunk_id": "chunk-123", "status": "approved", "policy_reloaded": True} + +module._policy_local_json = fake_policy +module._read_provider_profiles = lambda: [{ + "id": "github", + "description": "OpenShell GitHub profile", + "endpoints": [{"host": "api.github.com", "port": 443, "protocol": "rest", "enforcement": "enforce"}], + "binaries": ["/usr/bin/git"], +}] +module._provider_preset_cache = {"loaded_at": 0, "presets": None} + +ctx = Ctx() +module.register(ctx) +request = json.loads(ctx.tools["openshell_network_access"]["handler"]({ + "action": "request", + "user_intent": "inspect a repo", + "resource": "github.com", + "access": "read", + "reason": "need repository metadata", + "wait_timeout_ms": 0, +})) +check = json.loads(ctx.tools["openshell_network_access"]["handler"]({ + "action": "check", + "request_id": "chunk-123", + "wait_timeout_ms": 1000, +})) +presets = json.loads(ctx.tools["openshell_network_access"]["handler"]({ + "action": "list_presets", +})) +invalid = json.loads(ctx.tools["openshell_network_access"]["handler"]({ + "action": "request", + "resource": "github", +})) +print(json.dumps({ + "tool_names": sorted(ctx.tools.keys()), + "request": request, + "check": check, + "presets": presets, + "invalid": invalid, + "calls": calls, +})) +`); + + const result = JSON.parse(output) as { + tool_names: string[]; + request: Record; + check: Record; + presets: { presets: Array> }; + invalid: Record; + calls: Array<{ method: string; path: string; payload?: Record }>; + }; + + expect(result.tool_names).toEqual( + expect.arrayContaining([ + "openshell_network_access", + ]), + ); + expect(result.request).toMatchObject({ + request_id: "chunk-123", + status: "pending_approval", + }); + expect(result.check).toMatchObject({ + request_id: "chunk-123", + status: "applied", + }); + expect(result.presets.presets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "github", provider_profile: "github" }), + ]), + ); + expect(result.invalid).toEqual({ + status: "failed", + message: "For action=request, provide required field(s): user_intent, reason.", + }); + expect(result.calls[0]).toMatchObject({ method: "POST", path: "/v1/proposals" }); + expect(result.calls[0].payload?.operations).toHaveLength(1); + expect(result.calls[1]).toMatchObject({ + method: "GET", + path: "/v1/proposals/chunk-123/wait?timeout=1", + }); + }); });