From 631b0634081e2458e74c51684ca9d3987ded413b Mon Sep 17 00:00:00 2001 From: Patrick Riel Date: Wed, 13 May 2026 23:20:41 +0000 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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", + }); + }); }); From 8eeeb3e9f9fc55fff961c2ef0e5726c53327f2ac Mon Sep 17 00:00:00 2001 From: Patrick Riel Date: Mon, 18 May 2026 16:44:17 +0000 Subject: [PATCH 7/7] feat(plugin): add provider-first access tools --- docs/index.md | 1 + .../approve-network-requests.md | 1 + .../provider-access-requests.md | 156 ++++ .../nemoclaw-openshell-integration.md | 276 +++++-- nemoclaw/src/access-client.test.ts | 778 ++++++++++++++++++ nemoclaw/src/access-client.ts | 259 +++++- nemoclaw/src/index.ts | 520 ++++++++++-- nemoclaw/src/register.test.ts | 341 +++++++- nemoclaw/src/runtime-context.test.ts | 34 + nemoclaw/src/runtime-context.ts | 18 + test/e2e/nemoclaw-policy-local-runner.mjs | 10 +- test/e2e/test-nemoclaw-policy-local-plugin.sh | 10 +- 12 files changed, 2258 insertions(+), 146 deletions(-) create mode 100644 docs/network-policy/provider-access-requests.md diff --git a/docs/index.md b/docs/index.md index f5ec6b660e8..d10293123e8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -327,6 +327,7 @@ Backup and Restore :hidden: Approve or Deny Network Requests +Provider Access Requests Customize the Network Policy Integration Policy Examples ``` diff --git a/docs/network-policy/approve-network-requests.md b/docs/network-policy/approve-network-requests.md index 7e5ec3f52e0..d5adf501e31 100644 --- a/docs/network-policy/approve-network-requests.md +++ b/docs/network-policy/approve-network-requests.md @@ -80,6 +80,7 @@ The walkthrough requires tmux and the `NVIDIA_API_KEY` environment variable. ## Related Topics +- [Provider and Network Access Requests](provider-access-requests.md) explains provider-backed credentials, network-only access, and the sandbox-side tools. - [Customize the Sandbox Network Policy](customize-network-policy.md) to add endpoints permanently. - [Network Policies](../reference/network-policies.md) for the full baseline policy reference. - [Monitor Sandbox Activity](../monitoring/monitor-sandbox-activity.md) for general sandbox monitoring. diff --git a/docs/network-policy/provider-access-requests.md b/docs/network-policy/provider-access-requests.md new file mode 100644 index 00000000000..572d3177e27 --- /dev/null +++ b/docs/network-policy/provider-access-requests.md @@ -0,0 +1,156 @@ +--- +title: + page: "Provider and Network Access Requests" + nav: "Provider Access Requests" +description: + main: "How NemoClaw agents request provider-backed credentials and network-only access through OpenShell policy proposals." + agent: "Explains the provider-first access workflow, including openshell_provider_access, openshell_network_access, operator approval, credential placeholders, and when to use provider access instead of network-only policy." +keywords: ["nemoclaw provider access", "openshell provider access", "openshell_network_access", "openshell_provider_access", "provider credential placeholder"] +tags: ["openclaw", "openshell", "network_policy", "provider_access", "security", "nemoclaw"] +content: + type: how_to + difficulty: intermediate + audience: ["developer", "engineer", "security_engineer"] +status: published +--- + + + +# Provider and Network Access Requests + +NemoClaw agents can ask OpenShell for additional access while they are running in +a sandbox. The sandbox can submit a request, but it cannot approve its own +request. OpenShell records the proposal, waits for operator approval, and only +then attaches provider credentials or updates network policy. + +Use provider access when the task needs an account, token, OAuth identity, API +key, write permission, or service-specific CLI. Use network-only access when the +task only needs unauthenticated reachability. + +## Access Types + +| Type | Tool | What approval grants | +| ---- | ---- | -------------------- | +| Provider access | `openshell_provider_access` | A host-managed provider attachment, provider policy, and credential placeholders when the provider has credentials. | +| Network-only access | `openshell_network_access` | Network reachability for an approved preset. It does not attach credentials or account identity. | + +## Provider Access Flow + +1. The agent lists already attached providers: + + ```json + {"action": "list"} + ``` + +2. If the needed provider is missing, the agent requests it: + + ```json + { + "action": "request", + "provider_name": "github", + "provider_type": "github", + "user_intent": "Review pull requests for the current task", + "reason": "Use the host-managed GitHub provider without exposing a raw token", + "wait_timeout_ms": 0 + } + ``` + +3. OpenShell sends the provider proposal to the operator. + +4. After approval, OpenShell attaches the provider to the sandbox. The provider + can supply policy, credentials, and configuration. + +5. The agent checks whether the provider is attached: + + ```json + { + "action": "check", + "provider_name": "github" + } + ``` + +Provider requests return `pending_approval` while they wait. An approved provider +request is reported as `applied` when OpenShell has attached the provider. + +## Network-Only Access Flow + +1. The agent lists requestable network presets: + + ```json + {"action": "list_presets"} + ``` + +2. The agent requests a preset: + + ```json + { + "action": "request", + "resource": "github", + "access": "read", + "duration": "session", + "user_intent": "Fetch public repository metadata", + "reason": "No account credential is needed", + "wait_timeout_ms": 0 + } + ``` + +3. OpenShell sends the network proposal to the operator. + +4. After approval, OpenShell merges and reloads the sandbox policy. + +5. The agent checks the request status: + + ```json + { + "action": "check", + "request_id": "", + "wait_timeout_ms": 1000 + } + ``` + +Network-only requests are reported as `applied` only after OpenShell confirms the +policy reload. + +## Credential Placeholders + +Provider credentials can appear in the sandbox as placeholders: + +```text +GITHUB_TOKEN=openshell:resolve:env:... +``` + +These placeholders are not raw secrets. The sandbox should not print, decode, or +persist them. For direct API calls, the agent should follow the +`credential_usage` returned by `openshell_provider_access` and route requests +through `HTTP_PROXY` or `HTTPS_PROXY` so OpenShell can resolve placeholders at +egress. + +Different providers use different authentication formats. Some use bearer +headers, some use service-specific headers, and some require provider-specific +URL or SDK behavior. Do not assume every provider uses +`Authorization: Bearer`. + +## Operator Boundary + +The agent can: + +- Discover attached providers. +- Submit provider or network proposals. +- Check proposal status. +- Use approved access through the sandbox proxy. + +The agent cannot: + +- Approve its own proposal. +- Read raw host-managed provider secrets. +- Bypass OpenShell policy with an installed CLI. +- Use a provider endpoint before the provider or network policy is approved. + +## Related Pages + +- [Approve or Deny Network Requests](approve-network-requests.md) explains the operator approval workflow for access proposals. +- [Customize the Network Policy](customize-network-policy.md) explains persistent policy edits and presets. +- [NemoClaw Provider and Resource Access Flow](../reference/nemoclaw-openshell-integration.md) provides the architecture reference for this feature. diff --git a/docs/reference/nemoclaw-openshell-integration.md b/docs/reference/nemoclaw-openshell-integration.md index 287cd051998..ceac139c7f1 100644 --- a/docs/reference/nemoclaw-openshell-integration.md +++ b/docs/reference/nemoclaw-openshell-integration.md @@ -1,80 +1,220 @@ -# 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"] - 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"] - openclaw_tools --> profiles - hermes_tool --> profiles - profiles --> presets["Provider-backed access presets"] - presets --> openclaw_tools - presets --> hermes_tool - - openclaw_tools --> policy_local["policy.local HTTP API"] - hermes_tool --> 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 --> openclaw_tools - policy_runtime --> hermes_tool - - agent --> workload["Requested agent work"] - workload --> proxy - proxy --> policy_runtime - policy_runtime --> external["Approved external resources"] +# NemoClaw Provider and Resource Access Flow + +NemoClaw gives OpenClaw agents a provider-first way to get account-backed access +to external services. The important distinction is: + +- **Provider access** attaches a host-managed credential and the matching + provider policy to the sandbox. +- **Network access** only opens a resource path; it does not attach credentials. + +Agents should prefer provider access whenever a task needs an account, token, +OAuth identity, API key, write operation, or service-specific CLI. + +```text + Operator approval + | + v + +-------------------------+-------------------------+ + | OpenShell | + | | + | +-------------+ +-------------+ +----------+ | + | | Provider | | Policy | | L7 proxy | | + | | store +-->| engine +-->| | | + | | credentials | | rules | | egress | | + | | config | | reloads | | rewrite | | + | +-------------+ +-------------+ +----+-----+ | + +---------------------------------------------|-----+ + | + v + External services + GitHub, GitLab, APIs, registries + + +---------------------------------------------------+ + | OpenShell sandbox | + | | + | +----------------+ +----------------------+ | + | | OpenClaw agent |<---->| NemoClaw plugin | | + | | | | | | + | | plans task | | provider access | | + | | chooses tool | | network access | | + | | runs CLI/curl | +----------------------+ | + | +-------+--------+ | + | | | + | v | + | +---------------------------------------------+ | + | | Installed tools | | + | | gh, glab, claude, codex, opencode, copilot | | + | | curl, git, node, python | | + | +-------+-------------------------------------+ | + | | | + | | HTTP_PROXY / HTTPS_PROXY | + | v | + | +---------------------------------------------+ | + | | Credential placeholders | | + | | GITHUB_TOKEN=openshell:resolve:env:... | | + | | Proxy resolves placeholders at egress. | | + | +---------------------------------------------+ | + +---------------------------------------------------+ ``` -## Flow +## Access Types -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 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 checks the request; NemoClaw reports `applied` only after OpenShell reports the policy reload is complete. +### Provider Access -## Agent Tools +Gives the agent a credential placeholder, matching provider policy, and provider +endpoints. Use it for authenticated API calls, account-backed CLIs, writes, +OAuth flows, and API-key flows. -OpenClaw exposes one tool per operation: +### Network Access -- `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. +Gives the agent network reachability only. Use it for public or unauthenticated +resource access. -Hermes exposes a single operation-dispatched tool: +## Provider Workflow -- `openshell_network_access`: accepts `action` values `list_presets`, `request`, and `check`. +1. Agent checks what is already attached. -## Adapter Contract + Tool: `openshell_provider_access` -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. + ```json + {"action": "list"} + ``` -## Provider Profiles +2. If the needed provider is missing, the agent requests it. -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. + Tool: `openshell_provider_access` + + ```json + { + "action": "request", + "provider_name": "", + "provider_type": "", + "user_intent": "Describe the account-backed task", + "reason": "Need account-backed access for the task" + } + ``` + +3. Operator approves the provider request. + +4. OpenShell attaches the provider to this sandbox. + + ```text + credential placeholder appears + provider policy is composed into sandbox policy + provider endpoints become reachable through the proxy + provider tools become useful for that provider + ``` + +5. Agent checks state. + + Tool: `openshell_provider_access` + + ```json + { + "action": "check", + "provider_name": "" + } + ``` + +6. Agent uses an available tool through the proxy. + + ```text + provider CLI when available + curl/node/python fallback when appropriate + ``` + +## Network-Only Workflow + +Use network-only access when the task only needs unauthenticated reachability. + +1. Agent lists available network presets. + + Tool: `openshell_network_access` + + ```json + {"action": "list_presets"} + ``` + +2. Agent requests a preset. + + Tool: `openshell_network_access` + + ```json + { + "action": "request", + "resource": "", + "access": "read", + "user_intent": "Fetch public unauthenticated content", + "reason": "Need this resource for the task" + } + ``` + +3. Operator approves the network request. + +4. OpenShell reloads policy. + +5. Agent checks the request. + + Tool: `openshell_network_access` + + ```json + { + "action": "check", + "request_id": "" + } + ``` + +Network-only access does not create token environment variables and does not +grant account identity. + +## Credential Placeholder Behavior + +Provider credentials can appear inside the sandbox as placeholder values: + +```text +GITHUB_TOKEN=openshell:resolve:env:... +``` + +Those placeholders are intentional. They are not raw tokens. They only become +usable when the request goes through the sandbox HTTP(S) proxy: + +```text +HTTP_PROXY=http://10.200.0.1:3128 +HTTPS_PROXY=http://10.200.0.1:3128 +``` + +For direct API calls, the agent should follow the `credential_usage` returned by +`openshell_provider_access`. Some providers use bearer headers, while others use +service-specific headers, URL token formats, SDK conventions, or CLIs. For +GitHub API calls, for example: + +```bash +curl -x "$HTTPS_PROXY" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + https://api.github.com/user +``` + +The proxy resolves `openshell:resolve:env:*` at egress, enforces the approved +policy, and forwards the request to the external service. + +## Why the CLIs Are in the Image + +NemoClaw ships provider-related binaries in the sandbox image so an approved +provider is immediately usable. The presence of a binary is not the permission +boundary. OpenShell policy and provider attachment are the permission boundary. + +Before approval: + +```text +binary exists, but provider endpoint/credential use is blocked by policy +``` + +After approval: + +```text +binary exists, matching provider policy is active, credential placeholder is +available, and proxy-mediated requests can succeed +``` + +This keeps the agent experience smooth without weakening the sandbox access +model. diff --git a/nemoclaw/src/access-client.test.ts b/nemoclaw/src/access-client.test.ts index 4fe605fd887..49d7f75d94b 100644 --- a/nemoclaw/src/access-client.test.ts +++ b/nemoclaw/src/access-client.test.ts @@ -2,13 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import http from "node:http"; +import net from "node:net"; import { afterEach, describe, expect, it } from "vitest"; import { clearAccessPresetCache, createAccessRequest, + createProviderAccessRequest, getAccessRequest, + getProviderAccess, listAccessPresets, + listProviderAccess, + waitAccessRequest, } from "./access-client.js"; function withServer( @@ -37,6 +42,11 @@ function withServer( describe("access client", () => { afterEach(() => { delete process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON; + delete process.env.GITHUB_TOKEN; + delete process.env.GH_TOKEN; + delete process.env.GITLAB_TOKEN; + delete process.env.GLAB_TOKEN; + delete process.env.ACME_API_TOKEN; clearAccessPresetCache(); }); @@ -99,6 +109,101 @@ describe("access client", () => { expect(response.presets.some((preset) => preset.name === "empty-provider")).toBe(false); }); + it("ignores malformed provider profile overrides", async () => { + process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON = "{not-json"; + + await expect(listAccessPresets()).resolves.toMatchObject({ + presets: expect.arrayContaining([expect.objectContaining({ name: "github" })]), + }); + }); + + it("ignores blank provider profile overrides", async () => { + process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON = " "; + + await expect(listAccessPresets()).resolves.toMatchObject({ + presets: expect.arrayContaining([expect.objectContaining({ name: "github" })]), + }); + }); + + it("ignores provider profile override objects without profiles arrays", async () => { + process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON = JSON.stringify({ profiles: null }); + + const response = await listAccessPresets(); + expect(response.presets.some((preset) => preset.name === "mixed-provider")).toBe(false); + }); + + it("merges dynamic profiles into existing built-in presets", async () => { + process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON = JSON.stringify([ + { + id: "github", + description: "Gateway GitHub profile", + endpoints: [{ host: "api.github.com", port: 443 }], + binaries: ["/usr/bin/gh"], + }, + ]); + + await expect(listAccessPresets()).resolves.toMatchObject({ + presets: expect.arrayContaining([ + expect.objectContaining({ + name: "github", + provider_profile: "github", + }), + ]), + }); + }); + + it("drops invalid provider profile endpoints and binary entries", async () => { + process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON = JSON.stringify({ + profiles: [ + { + id: "mixed-provider", + display_name: "Mixed Provider", + endpoints: [ + { host: "api.mixed.example", port: 443 }, + { host: "bad.example", port: 0 }, + ], + binaries: ["/usr/bin/curl", { path: "/usr/bin/node" }, { path: 42 }], + }, + ], + }); + + 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_mixed"] })); + }); + }, + async (baseUrl) => { + await expect( + createAccessRequest( + { + version: "nemoclaw.access.v1", + user_intent: "Inspect mixed provider", + llm_proposal: { + resource_type: "network", + preset: "mixed-provider", + access: "read", + duration: "session", + reason: "Exercise profile cleanup.", + }, + }, + { policyLocalUrl: baseUrl }, + ), + ).resolves.toMatchObject({ request_id: "chunk_mixed" }); + }, + ); + + const rule = JSON.parse(captured).operations[0].addRule.rule; + expect(rule.endpoints).toHaveLength(1); + expect(rule.binaries).toEqual([{ path: "/usr/bin/curl" }, { path: "/usr/bin/node" }]); + }); + it("submits provider-profile-backed proposals", async () => { process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON = JSON.stringify([ { @@ -168,6 +273,142 @@ describe("access client", () => { ]); }); + it("rejects unknown access presets before submitting proposals", () => { + expect(() => + createAccessRequest({ + version: "nemoclaw.access.v1", + user_intent: "Need an unknown service", + llm_proposal: { + resource_type: "network", + preset: "missing-service", + access: "read", + duration: "session", + reason: "Exercise validation.", + }, + }), + ).toThrow("Unknown access preset 'missing-service'."); + }); + + it("normalizes URL resources and expands read-write methods", async () => { + 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_rw"] })); + }); + }, + async (baseUrl) => { + await expect( + createAccessRequest( + { + version: "nemoclaw.access.v1", + user_intent: "Update an issue", + llm_proposal: { + resource_type: "network", + preset: "https://api.github.com/repos/example/repo", + access: "read_write", + duration: "session", + reason: "Need GitHub API writes.", + }, + }, + { policyLocalUrl: baseUrl }, + ), + ).resolves.toMatchObject({ request_id: "chunk_rw" }); + }, + ); + + const rule = JSON.parse(captured).operations[0].addRule.rule; + expect(rule.name).toBe("github"); + expect(rule.endpoints[0].rules).toEqual([ + { allow: { method: "GET", path: "/**" } }, + { allow: { method: "HEAD", path: "/**" } }, + { allow: { method: "POST", path: "/**" } }, + { allow: { method: "PUT", path: "/**" } }, + { allow: { method: "PATCH", path: "/**" } }, + { allow: { method: "DELETE", path: "/**" } }, + ]); + }); + + it("preserves full-access endpoint policy without synthesized method rules", async () => { + process.env.NEMOCLAW_OPENSHELL_PROVIDER_PROFILES_JSON = JSON.stringify([ + { + id: "full-provider", + endpoints: [{ host: "full.example", port: 443, access: "full", tls: "skip" }], + binaries: ["/usr/bin/curl"], + }, + ]); + + 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_npm"] })); + }); + }, + async (baseUrl) => { + await expect( + createAccessRequest( + { + version: "nemoclaw.access.v1", + user_intent: "Install packages", + llm_proposal: { + resource_type: "network", + preset: "full-provider", + access: "read", + duration: "session", + reason: "Need full tunnel reachability.", + }, + }, + { policyLocalUrl: baseUrl }, + ), + ).resolves.toMatchObject({ request_id: "chunk_npm" }); + }, + ); + + const endpoint = JSON.parse(captured).operations[0].addRule.rule.endpoints.find( + (candidate: { access?: string }) => candidate.access === "full", + ); + expect(endpoint).toBeDefined(); + expect(endpoint.access).toBe("full"); + expect(endpoint.rules).toBeUndefined(); + }); + + it("rejects non-2xx and non-object policy.local responses", async () => { + await withServer( + (_req, res) => { + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "bad" })); + }, + async (baseUrl) => { + await expect(getAccessRequest("chunk", { policyLocalUrl: baseUrl })).rejects.toThrow( + /failed with HTTP 500/, + ); + }, + ); + + await withServer( + (_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify([])); + }, + async (baseUrl) => { + await expect(getAccessRequest("chunk", { policyLocalUrl: baseUrl })).rejects.toThrow( + /non-object response/, + ); + }, + ); + }); + it("does not report approved requests as applied until policy reloads", async () => { await withServer( (_req, res) => { @@ -194,4 +435,541 @@ describe("access client", () => { }, ); }); + + it("maps pending, rejected, and unknown proposal states", async () => { + await withServer( + (req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + if (req.url?.includes("rejected")) { + res.end( + JSON.stringify({ + chunk_id: "rejected", + status: "rejected", + rejection_reason: "Operator denied.", + }), + ); + return; + } + if (req.url?.includes("pending")) { + res.end(JSON.stringify({ chunk_id: "pending", status: "pending" })); + return; + } + res.end(JSON.stringify({ chunk_id: "weird", status: "unexpected" })); + }, + async (baseUrl) => { + await expect(getAccessRequest("rejected", { policyLocalUrl: baseUrl })).resolves.toEqual( + expect.objectContaining({ + request_id: "rejected", + status: "denied", + message: "Operator denied.", + }), + ); + await expect(getAccessRequest("pending", { policyLocalUrl: baseUrl })).resolves.toEqual( + expect.objectContaining({ + request_id: "pending", + status: "pending_approval", + }), + ); + await expect(getAccessRequest("weird", { policyLocalUrl: baseUrl })).resolves.toEqual( + expect.objectContaining({ + request_id: "weird", + status: "failed", + }), + ); + }, + ); + }); + + it("returns failed status when OpenShell rejects a proposal", async () => { + await withServer( + (_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ rejection_reasons: ["provider missing"] })); + }, + async (baseUrl) => { + await expect( + createProviderAccessRequest( + { + version: "nemoclaw.provider_access.v1", + user_intent: "Review pull requests", + provider_name: "github", + reason: "Need provider access.", + }, + { policyLocalUrl: baseUrl }, + ), + ).resolves.toEqual({ + request_id: "", + status: "failed", + message: 'OpenShell rejected the proposal: ["provider missing"]', + }); + }, + ); + }); + + it("waits for access requests with bounded timeout seconds", async () => { + let observedUrl = ""; + await withServer( + (req, res) => { + observedUrl = req.url ?? ""; + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ chunk_id: "chunk_wait", status: "approved", policy_reloaded: true }), + ); + }, + async (baseUrl) => { + await expect( + waitAccessRequest("chunk_wait", 350_000, { policyLocalUrl: baseUrl }), + ).resolves.toMatchObject({ + request_id: "chunk_wait", + status: "applied", + }); + }, + ); + expect(observedUrl).toBe("/v1/proposals/chunk_wait/wait?timeout=300"); + }); + + it("submits provider access requests", async () => { + 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_provider"] })); + }); + }, + async (baseUrl) => { + await expect( + createProviderAccessRequest( + { + version: "nemoclaw.provider_access.v1", + user_intent: "Review pull requests", + provider_name: "github", + provider_type: "github", + reason: "Need the host-managed GitHub token.", + }, + { policyLocalUrl: baseUrl }, + ), + ).resolves.toMatchObject({ request_id: "chunk_provider", status: "pending_approval" }); + }, + ); + + expect(JSON.parse(captured)).toEqual({ + human_summary: "Attach provider github", + intent_summary: "Review pull requests Need the host-managed GitHub token.", + operations: [ + { + requestProvider: { + providerName: "github", + providerType: "github", + }, + }, + ], + }); + }); + + it("reports approved provider requests as applied without policy reload", async () => { + await withServer( + (_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + chunk_id: "chunk_provider", + status: "approved", + request_type: "provider", + policy_reloaded: false, + }), + ); + }, + async (baseUrl) => { + await expect( + getAccessRequest("chunk_provider", { policyLocalUrl: baseUrl }), + ).resolves.toMatchObject({ + request_id: "chunk_provider", + status: "applied", + }); + }, + ); + }); + + it("lists attached provider access from OpenShell credential placeholders", async () => { + process.env.GITHUB_TOKEN = "openshell:resolve:env:v123_GITHUB_TOKEN"; + process.env.GITLAB_TOKEN = "openshell:resolve:env:v123_GITLAB_TOKEN"; + + await expect(listProviderAccess()).resolves.toEqual({ + providers: [ + { + provider_name: "github", + provider_type: "github", + status: "attached", + credential_env: "GITHUB_TOKEN", + credential_state: "attached_placeholder", + usable_via_proxy: true, + raw_secret_available: false, + credential_available: true, + }, + { + provider_name: "gitlab", + provider_type: "gitlab", + status: "attached", + credential_env: "GITLAB_TOKEN", + credential_state: "attached_placeholder", + usable_via_proxy: true, + raw_secret_available: false, + credential_available: true, + }, + ], + }); + }); + + it("deduplicates placeholder aliases for the same provider", async () => { + process.env.GITHUB_TOKEN = "openshell:resolve:env:v123_GITHUB_TOKEN"; + process.env.GH_TOKEN = "openshell:resolve:env:v123_GH_TOKEN"; + + await expect(getProviderAccess("github")).resolves.toMatchObject({ + provider_name: "github", + credential_env: "GH_TOKEN", + }); + }); + + it("infers provider names from unknown placeholder environment variables", async () => { + process.env.ACME_API_TOKEN = "openshell:resolve:env:v123_ACME_API_TOKEN"; + + await expect(getProviderAccess("acme")).resolves.toEqual({ + provider_name: "acme", + status: "attached", + credential_env: "ACME_API_TOKEN", + credential_state: "attached_placeholder", + usable_via_proxy: true, + raw_secret_available: false, + credential_available: true, + }); + }); + + it("lists attached provider access from policy.local provider state", async () => { + await withServer( + (_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + providers: [ + { + provider_name: "github", + provider_type: "github", + credential_keys: ["GITHUB_TOKEN"], + config_keys: [], + }, + ], + }), + ); + }, + async (baseUrl) => { + await expect(listProviderAccess({ policyLocalUrl: baseUrl })).resolves.toEqual({ + providers: [ + { + provider_name: "github", + provider_type: "github", + status: "attached", + credential_env: "GITHUB_TOKEN", + credential_state: "attached_placeholder", + usable_via_proxy: true, + raw_secret_available: false, + credential_available: true, + }, + ], + }); + }, + ); + }); + + it("reports attached providers without credential keys as unknown proxy state", async () => { + await withServer( + (_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + providers: [ + { + provider_name: "github", + provider_type: "github", + credential_keys: [], + }, + ], + }), + ); + }, + async (baseUrl) => { + await expect(listProviderAccess({ policyLocalUrl: baseUrl })).resolves.toEqual({ + providers: [ + { + provider_name: "github", + provider_type: "github", + status: "attached", + credential_state: "attached_unknown", + usable_via_proxy: false, + raw_secret_available: false, + credential_available: false, + }, + ], + }); + }, + ); + }); + + it("ignores blank provider records from policy.local provider state", async () => { + await withServer( + (_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + providers: [ + { provider_name: " ", provider_type: "github", credential_keys: ["GITHUB_TOKEN"] }, + { provider_name: "github", provider_type: "github", credential_keys: [] }, + ], + }), + ); + }, + async (baseUrl) => { + await expect(listProviderAccess({ policyLocalUrl: baseUrl })).resolves.toEqual({ + providers: [ + { + provider_name: "github", + provider_type: "github", + status: "attached", + credential_state: "attached_unknown", + usable_via_proxy: false, + raw_secret_available: false, + credential_available: false, + }, + ], + }); + }, + ); + }); + + it("merges policy.local provider state with visible credential placeholders", async () => { + process.env.GITHUB_TOKEN = "openshell:resolve:env:v123_GITHUB_TOKEN"; + + await withServer( + (_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + providers: [ + { + provider_name: "github", + provider_type: "github", + credential_keys: ["GITHUB_TOKEN"], + config_keys: [], + }, + ], + }), + ); + }, + async (baseUrl) => { + await expect(listProviderAccess({ policyLocalUrl: baseUrl })).resolves.toEqual({ + providers: [ + { + provider_name: "github", + provider_type: "github", + status: "attached", + credential_env: "GITHUB_TOKEN", + credential_state: "attached_placeholder", + usable_via_proxy: true, + raw_secret_available: false, + credential_available: true, + }, + ], + }); + }, + ); + }); + + it("checks attached provider access by provider name", async () => { + process.env.GITHUB_TOKEN = "openshell:resolve:env:v123_GITHUB_TOKEN"; + + await expect(getProviderAccess("github")).resolves.toEqual({ + provider_name: "github", + provider_type: "github", + status: "attached", + credential_env: "GITHUB_TOKEN", + credential_state: "attached_placeholder", + usable_via_proxy: true, + raw_secret_available: false, + credential_available: true, + }); + await expect(getProviderAccess("missing")).resolves.toBeNull(); + await expect(getProviderAccess(" ")).resolves.toBeNull(); + }); + + it("uses HTTP proxy transport for policy.local requests", async () => { + const oldHttpProxy = process.env.HTTP_PROXY; + const oldLowerHttpProxy = process.env.http_proxy; + const observedRequests: string[] = []; + const proxy = net.createServer((socket) => { + const chunks: Buffer[] = []; + socket.on("data", (chunk) => { + chunks.push(chunk); + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.includes("\r\n\r\n")) return; + observedRequests.push(raw); + socket.end( + [ + "HTTP/1.1 202 Accepted", + "Content-Type: application/json", + "Connection: close", + "", + JSON.stringify({ accepted_chunk_ids: ["chunk_proxy"] }), + ].join("\r\n"), + ); + }); + }); + + await new Promise((resolve, reject) => { + proxy.listen(0, "127.0.0.1", () => resolve()); + proxy.on("error", reject); + }); + + try { + const address = proxy.address(); + if (!address || typeof address === "string") throw new Error("proxy did not bind"); + process.env.HTTP_PROXY = `http://127.0.0.1:${address.port}`; + delete process.env.http_proxy; + await expect( + createProviderAccessRequest({ + version: "nemoclaw.provider_access.v1", + user_intent: "Review pull requests", + provider_name: "github", + reason: "Need provider access.", + }), + ).resolves.toMatchObject({ request_id: "chunk_proxy", status: "pending_approval" }); + } finally { + await new Promise((resolve) => proxy.close(() => resolve())); + if (oldHttpProxy === undefined) delete process.env.HTTP_PROXY; + else process.env.HTTP_PROXY = oldHttpProxy; + if (oldLowerHttpProxy === undefined) delete process.env.http_proxy; + else process.env.http_proxy = oldLowerHttpProxy; + } + + expect(observedRequests[0]).toContain("POST http://policy.local:80/v1/proposals HTTP/1.1"); + }); + + it("reports malformed and failed HTTP proxy responses", async () => { + const oldHttpProxy = process.env.HTTP_PROXY; + const oldLowerHttpProxy = process.env.http_proxy; + const proxy = net.createServer((socket) => { + socket.once("data", () => { + socket.end("not-http"); + }); + }); + + await new Promise((resolve, reject) => { + proxy.listen(0, "127.0.0.1", () => resolve()); + proxy.on("error", reject); + }); + + try { + const address = proxy.address(); + if (!address || typeof address === "string") throw new Error("proxy did not bind"); + process.env.HTTP_PROXY = `http://127.0.0.1:${address.port}`; + delete process.env.http_proxy; + await expect( + createProviderAccessRequest({ + version: "nemoclaw.provider_access.v1", + user_intent: "Review pull requests", + provider_name: "github", + reason: "Need provider access.", + }), + ).rejects.toThrow(/malformed HTTP response/); + } finally { + await new Promise((resolve) => proxy.close(() => resolve())); + if (oldHttpProxy === undefined) delete process.env.HTTP_PROXY; + else process.env.HTTP_PROXY = oldHttpProxy; + if (oldLowerHttpProxy === undefined) delete process.env.http_proxy; + else process.env.http_proxy = oldLowerHttpProxy; + } + }); + + it("reports non-2xx HTTP proxy responses", async () => { + const oldHttpProxy = process.env.HTTP_PROXY; + const oldLowerHttpProxy = process.env.http_proxy; + const proxy = net.createServer((socket) => { + socket.once("data", () => { + socket.end( + [ + "HTTP/1.1 403 Forbidden", + "Content-Type: application/json", + "Connection: close", + "", + JSON.stringify({ error: "blocked" }), + ].join("\r\n"), + ); + }); + }); + + await new Promise((resolve, reject) => { + proxy.listen(0, "127.0.0.1", () => resolve()); + proxy.on("error", reject); + }); + + try { + const address = proxy.address(); + if (!address || typeof address === "string") throw new Error("proxy did not bind"); + process.env.HTTP_PROXY = `http://127.0.0.1:${address.port}`; + delete process.env.http_proxy; + await expect( + createProviderAccessRequest({ + version: "nemoclaw.provider_access.v1", + user_intent: "Review pull requests", + provider_name: "github", + reason: "Need provider access.", + }), + ).rejects.toThrow(/failed with HTTP 403/); + } finally { + await new Promise((resolve) => proxy.close(() => resolve())); + if (oldHttpProxy === undefined) delete process.env.HTTP_PROXY; + else process.env.HTTP_PROXY = oldHttpProxy; + if (oldLowerHttpProxy === undefined) delete process.env.http_proxy; + else process.env.http_proxy = oldLowerHttpProxy; + } + }); + + it("reports HTTP proxy responses with invalid status codes", async () => { + const oldHttpProxy = process.env.HTTP_PROXY; + const oldLowerHttpProxy = process.env.http_proxy; + const proxy = net.createServer((socket) => { + socket.once("data", () => { + socket.end(["HTTP/1.1 nope Nope", "Connection: close", "", "bad status"].join("\r\n")); + }); + }); + + await new Promise((resolve, reject) => { + proxy.listen(0, "127.0.0.1", () => resolve()); + proxy.on("error", reject); + }); + + try { + const address = proxy.address(); + if (!address || typeof address === "string") throw new Error("proxy did not bind"); + process.env.HTTP_PROXY = `http://127.0.0.1:${address.port}`; + delete process.env.http_proxy; + await expect( + createProviderAccessRequest({ + version: "nemoclaw.provider_access.v1", + user_intent: "Review pull requests", + provider_name: "github", + reason: "Need provider access.", + }), + ).rejects.toThrow(/failed with HTTP unknown/); + } finally { + await new Promise((resolve) => proxy.close(() => resolve())); + if (oldHttpProxy === undefined) delete process.env.HTTP_PROXY; + else process.env.HTTP_PROXY = oldHttpProxy; + if (oldLowerHttpProxy === undefined) delete process.env.http_proxy; + else process.env.http_proxy = oldLowerHttpProxy; + } + }); }); diff --git a/nemoclaw/src/access-client.ts b/nemoclaw/src/access-client.ts index 339f41b8a55..fd8c51e92af 100644 --- a/nemoclaw/src/access-client.ts +++ b/nemoclaw/src/access-client.ts @@ -28,6 +28,22 @@ export interface AccessPresetsResponse { presets: AccessPresetInfo[]; } +export interface ProviderAccessInfo { + provider_name: string; + provider_type?: string; + status: "attached"; + credential_env?: string; + credential_state: "attached_placeholder" | "attached_unknown"; + usable_via_proxy: boolean; + raw_secret_available: boolean; + /** @deprecated Use credential_state and usable_via_proxy. */ + credential_available: boolean; +} + +export interface ProviderAccessResponse { + providers: ProviderAccessInfo[]; +} + export interface CreateAccessRequestBody { version: "nemoclaw.access.v1"; task_id?: string; @@ -41,6 +57,15 @@ export interface CreateAccessRequestBody { }; } +export interface CreateProviderAccessRequestBody { + version: "nemoclaw.provider_access.v1"; + task_id?: string; + user_intent: string; + provider_name: string; + provider_type?: string; + reason: string; +} + export interface AccessClientOptions { policyLocalUrl?: string; timeoutMs?: number; @@ -101,10 +126,40 @@ type ProviderProfile = { binaries?: Array; }; +type AttachedProvider = { + provider_name: string; + provider_type?: string; + credential_env?: string; +}; + +type AttachedProviderJson = Record & { + provider_name: string; +}; + 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; +const PROVIDER_ENV_PLACEHOLDER_PREFIX = "openshell:resolve:env:"; + +const PROVIDER_ENV_HINTS: Record = { + ANTHROPIC_API_KEY: { provider_name: "anthropic", provider_type: "anthropic" }, + BRAVE_API_KEY: { provider_name: "brave", provider_type: "brave" }, + CLAUDE_API_KEY: { provider_name: "claude", provider_type: "claude" }, + COPILOT_GITHUB_TOKEN: { provider_name: "copilot", provider_type: "copilot" }, + GITHUB_TOKEN: { provider_name: "github", provider_type: "github" }, + GITLAB_TOKEN: { provider_name: "gitlab", provider_type: "gitlab" }, + GLAB_TOKEN: { provider_name: "gitlab", provider_type: "gitlab" }, + GH_TOKEN: { provider_name: "github", provider_type: "github" }, + HF_TOKEN: { provider_name: "huggingface", provider_type: "huggingface" }, + HUGGINGFACE_TOKEN: { provider_name: "huggingface", provider_type: "huggingface" }, + NVIDIA_API_KEY: { provider_name: "nvidia", provider_type: "nvidia" }, + OPENAI_API_KEY: { provider_name: "openai", provider_type: "openai" }, + OPENCODE_API_KEY: { provider_name: "opencode", provider_type: "opencode" }, + SLACK_APP_TOKEN: { provider_name: "slack", provider_type: "slack" }, + SLACK_BOT_TOKEN: { provider_name: "slack", provider_type: "slack" }, + TELEGRAM_BOT_TOKEN: { provider_name: "telegram", provider_type: "telegram" }, +}; let cachedProviderPresets: { loadedAt: number; presets: AccessPreset[] } | null = null; @@ -262,6 +317,25 @@ function openshellBinary(): string { return process.env.NEMOCLAW_OPENSHELL_BIN || "openshell"; } +function inferProviderNameFromEnv(envName: string): string { + const hinted = PROVIDER_ENV_HINTS[envName]; + if (hinted) return hinted.provider_name; + return envName + .toLowerCase() + .replace(/_(api_)?token$/u, "") + .replace(/_api_key$/u, "") + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, ""); +} + +function inferProviderTypeFromEnv(envName: string): string | undefined { + return PROVIDER_ENV_HINTS[envName]?.provider_type; +} + +function isProviderCredentialPlaceholder(value: string | undefined): boolean { + return typeof value === "string" && value.startsWith(PROVIDER_ENV_PLACEHOLDER_PREFIX); +} + function parseProviderProfilesJson(raw: string): ProviderProfile[] { if (!raw.trim()) return []; let parsed: unknown; @@ -477,8 +551,14 @@ function parseJsonObject(raw: string): Record { return parsed as Record; } -function mapChunkStatus(status: unknown, policyReloaded: unknown): AccessStatus { - if (status === "approved") return policyReloaded === true ? "applied" : "pending_approval"; +function mapChunkStatus( + status: unknown, + policyReloaded: unknown, + requestType: unknown, +): AccessStatus { + if (status === "approved") { + return policyReloaded === true || requestType === "provider" ? "applied" : "pending_approval"; + } if (status === "rejected") return "denied"; if (status === "pending") return "pending_approval"; return "failed"; @@ -638,11 +718,27 @@ function requestJsonViaHttpProxy( function proposalBody(body: CreateAccessRequestBody): Record { const rule = ruleForRequest(body); return { + human_summary: `Request ${body.llm_proposal.access === "read_write" ? "read/write" : "read"} access to ${body.llm_proposal.preset}`, intent_summary: [body.user_intent, body.llm_proposal.reason].filter(Boolean).join(" "), operations: [{ addRule: { ruleName: rule.name, rule } }], }; } +function providerProposalBody(body: CreateProviderAccessRequestBody): Record { + return { + human_summary: `Attach provider ${body.provider_name}`, + intent_summary: [body.user_intent, body.reason].filter(Boolean).join(" "), + operations: [ + { + requestProvider: { + providerName: body.provider_name, + ...(body.provider_type ? { providerType: body.provider_type } : {}), + }, + }, + ], + }; +} + function parseCreateResponse(raw: string): AccessRequestResponse { const parsed = parseJsonObject(raw); const accepted = Array.isArray(parsed.accepted_chunk_ids) ? parsed.accepted_chunk_ids : []; @@ -666,7 +762,7 @@ function parseStateResponse(raw: string): AccessRequestResponse { const requestId = typeof parsed.chunk_id === "string" ? parsed.chunk_id : ""; return { request_id: requestId, - status: mapChunkStatus(parsed.status, parsed.policy_reloaded), + status: mapChunkStatus(parsed.status, parsed.policy_reloaded, parsed.request_type), message: typeof parsed.rejection_reason === "string" && parsed.rejection_reason ? parsed.rejection_reason @@ -677,6 +773,36 @@ function parseStateResponse(raw: string): AccessRequestResponse { }; } +function parseAttachedProvidersResponse(raw: string): AttachedProvider[] { + const parsed = parseJsonObject(raw); + const providers = Array.isArray(parsed.providers) ? parsed.providers : []; + return providers + .filter((provider): provider is AttachedProviderJson => { + return ( + typeof provider === "object" && + provider !== null && + typeof provider.provider_name === "string" && + provider.provider_name.trim().length > 0 + ); + }) + .map((provider) => ({ + provider_name: provider.provider_name.trim(), + ...(typeof provider.provider_type === "string" && provider.provider_type.trim() + ? { provider_type: provider.provider_type.trim() } + : {}), + ...(Array.isArray(provider.credential_keys) && + provider.credential_keys.find((key) => typeof key === "string" && key.trim().length > 0) + ? { + credential_env: ( + provider.credential_keys.find( + (key) => typeof key === "string" && key.trim().length > 0, + ) as string + ).trim(), + } + : {}), + })); +} + export function createAccessRequest( body: CreateAccessRequestBody, options: AccessClientOptions = {}, @@ -684,6 +810,19 @@ export function createAccessRequest( return requestJson("POST", "/v1/proposals", proposalBody(body), options, parseCreateResponse); } +export function createProviderAccessRequest( + body: CreateProviderAccessRequestBody, + options: AccessClientOptions = {}, +): Promise { + return requestJson( + "POST", + "/v1/proposals", + providerProposalBody(body), + options, + parseCreateResponse, + ); +} + export function getAccessRequest( requestId: string, options: AccessClientOptions = {}, @@ -723,3 +862,117 @@ export function listAccessPresets( })), }); } + +function listProviderAccessFromEnv(): ProviderAccessInfo[] { + const byName = new Map(); + for (const [envName, value] of Object.entries(process.env)) { + if (!isProviderCredentialPlaceholder(value)) continue; + const providerName = inferProviderNameFromEnv(envName); + if (!providerName) continue; + const existing = byName.get(providerName); + const next: ProviderAccessInfo = { + provider_name: providerName, + ...(inferProviderTypeFromEnv(envName) + ? { provider_type: inferProviderTypeFromEnv(envName) } + : {}), + status: "attached", + credential_env: envName, + credential_state: "attached_placeholder", + usable_via_proxy: true, + raw_secret_available: false, + credential_available: true, + }; + if (!existing || (existing.credential_env ?? "") > envName) { + byName.set(providerName, next); + } + } + return [...byName.values()].sort((left, right) => + left.provider_name.localeCompare(right.provider_name), + ); +} + +function mergeAttachedProviders( + attachedProviders: AttachedProvider[], + envProviders: ProviderAccessInfo[], +): ProviderAccessInfo[] { + const byName = new Map(); + for (const attached of attachedProviders) { + const providerName = attached.provider_name.trim(); + if (!providerName) continue; + byName.set(providerName.toLowerCase(), { + provider_name: providerName, + ...(attached.provider_type ? { provider_type: attached.provider_type } : {}), + status: "attached", + ...(attached.credential_env ? { credential_env: attached.credential_env } : {}), + credential_state: attached.credential_env ? "attached_placeholder" : "attached_unknown", + usable_via_proxy: Boolean(attached.credential_env), + raw_secret_available: false, + credential_available: Boolean(attached.credential_env), + }); + } + for (const envProvider of envProviders) { + const key = envProvider.provider_name.trim().toLowerCase(); + const existing = byName.get(key); + byName.set(key, { + ...envProvider, + ...(existing?.provider_name ? { provider_name: existing.provider_name } : {}), + ...(existing?.provider_type || envProvider.provider_type + ? { provider_type: existing?.provider_type ?? envProvider.provider_type } + : {}), + ...(existing?.credential_env || envProvider.credential_env + ? { credential_env: existing?.credential_env ?? envProvider.credential_env } + : {}), + credential_state: + existing?.credential_state === "attached_placeholder" || + envProvider.credential_state === "attached_placeholder" + ? "attached_placeholder" + : "attached_unknown", + usable_via_proxy: Boolean(existing?.usable_via_proxy || envProvider.usable_via_proxy), + raw_secret_available: false, + credential_available: Boolean( + existing?.credential_available || envProvider.credential_available, + ), + }); + } + return [...byName.values()].sort((left, right) => + left.provider_name.localeCompare(right.provider_name), + ); +} + +export async function listProviderAccess( + options: AccessClientOptions = {}, +): Promise { + const envProviders = listProviderAccessFromEnv(); + try { + const attachedProviders = await requestJson( + "GET", + "/v1/providers", + undefined, + options, + parseAttachedProvidersResponse, + ); + return { + providers: mergeAttachedProviders(attachedProviders, envProviders), + }; + } catch { + // Older OpenShell sandboxes do not expose /v1/providers. Preserve the + // previous behavior there, using injected credential placeholders only. + } + return Promise.resolve({ + providers: envProviders, + }); +} + +export async function getProviderAccess( + providerName: string, + options: AccessClientOptions = {}, +): Promise { + const normalized = providerName.trim().toLowerCase(); + if (!normalized) return null; + const response = await listProviderAccess(options); + return ( + response.providers.find( + (provider) => provider.provider_name.trim().toLowerCase() === normalized, + ) ?? null + ); +} diff --git a/nemoclaw/src/index.ts b/nemoclaw/src/index.ts index de7900e3c0b..6a14ca5f4bf 100644 --- a/nemoclaw/src/index.ts +++ b/nemoclaw/src/index.ts @@ -12,6 +12,7 @@ */ import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; import { renderBox } from "./banner.js"; import { handleSlashCommand } from "./commands/slash.js"; import { @@ -21,14 +22,18 @@ import { } from "./onboard/config.js"; import { createAccessRequest, + createProviderAccessRequest, getAccessRequest, + getProviderAccess, listAccessPresets, + listProviderAccess, waitAccessRequest, type AccessCanonicalRequest, type AccessClientOptions, type AccessRequestResponse, type AccessStatus, type CreateAccessRequestBody, + type CreateProviderAccessRequestBody, } from "./access-client.js"; import { registerRuntimeContext } from "./runtime-context.js"; import { scanForSecrets, isMemoryPath } from "./security/secret-scanner.js"; @@ -37,6 +42,153 @@ type PluginScalar = string | number | boolean | null | undefined; type PluginValue = PluginScalar | PluginRecord | PluginValue[]; type PluginRecord = { [key: string]: PluginValue }; +type ProviderToolHint = { + tool: string; + paths: string[]; + role: "preferred" | "fallback"; +}; + +type ProviderCredentialHint = { + kind: string; + header?: string; + value?: string; + note: string; +}; + +const PROVIDER_TOOL_HINTS: Record = { + anthropic: [ + { tool: "curl", paths: ["/usr/bin/curl", "/usr/local/bin/curl"], role: "preferred" }, + { tool: "node", paths: ["/usr/bin/node", "/usr/local/bin/node"], role: "fallback" }, + ], + brave: [ + { tool: "curl", paths: ["/usr/bin/curl", "/usr/local/bin/curl"], role: "preferred" }, + { tool: "node", paths: ["/usr/bin/node", "/usr/local/bin/node"], role: "fallback" }, + ], + claude: [ + { tool: "claude", paths: ["/usr/bin/claude", "/usr/local/bin/claude"], role: "preferred" }, + { tool: "curl", paths: ["/usr/bin/curl", "/usr/local/bin/curl"], role: "fallback" }, + { tool: "node", paths: ["/usr/bin/node", "/usr/local/bin/node"], role: "fallback" }, + ], + codex: [ + { tool: "codex", paths: ["/usr/bin/codex", "/usr/local/bin/codex"], role: "preferred" }, + { tool: "curl", paths: ["/usr/bin/curl", "/usr/local/bin/curl"], role: "fallback" }, + { tool: "node", paths: ["/usr/bin/node", "/usr/local/bin/node"], role: "fallback" }, + ], + copilot: [ + { tool: "copilot", paths: ["/usr/bin/copilot", "/usr/local/bin/copilot"], role: "preferred" }, + { tool: "gh", paths: ["/usr/bin/gh", "/usr/local/bin/gh"], role: "fallback" }, + ], + discord: [ + { tool: "node", paths: ["/usr/bin/node", "/usr/local/bin/node"], role: "preferred" }, + { tool: "curl", paths: ["/usr/bin/curl", "/usr/local/bin/curl"], role: "fallback" }, + ], + github: [ + { tool: "gh", paths: ["/usr/bin/gh", "/usr/local/bin/gh"], role: "preferred" }, + { tool: "curl", paths: ["/usr/bin/curl", "/usr/local/bin/curl"], role: "fallback" }, + { tool: "git", paths: ["/usr/bin/git", "/usr/local/bin/git"], role: "fallback" }, + { tool: "node", paths: ["/usr/bin/node", "/usr/local/bin/node"], role: "fallback" }, + ], + gitlab: [ + { tool: "glab", paths: ["/usr/bin/glab", "/usr/local/bin/glab"], role: "preferred" }, + { tool: "curl", paths: ["/usr/bin/curl", "/usr/local/bin/curl"], role: "fallback" }, + { tool: "git", paths: ["/usr/bin/git", "/usr/local/bin/git"], role: "fallback" }, + { tool: "node", paths: ["/usr/bin/node", "/usr/local/bin/node"], role: "fallback" }, + ], + huggingface: [ + { tool: "python3", paths: ["/usr/bin/python3", "/usr/local/bin/python3"], role: "preferred" }, + { tool: "node", paths: ["/usr/bin/node", "/usr/local/bin/node"], role: "fallback" }, + { tool: "curl", paths: ["/usr/bin/curl", "/usr/local/bin/curl"], role: "fallback" }, + ], + jira: [ + { tool: "node", paths: ["/usr/bin/node", "/usr/local/bin/node"], role: "preferred" }, + { tool: "curl", paths: ["/usr/bin/curl", "/usr/local/bin/curl"], role: "fallback" }, + ], + nvidia: [ + { tool: "curl", paths: ["/usr/bin/curl", "/usr/local/bin/curl"], role: "preferred" }, + { tool: "node", paths: ["/usr/bin/node", "/usr/local/bin/node"], role: "fallback" }, + ], + openai: [ + { tool: "curl", paths: ["/usr/bin/curl", "/usr/local/bin/curl"], role: "preferred" }, + { tool: "node", paths: ["/usr/bin/node", "/usr/local/bin/node"], role: "fallback" }, + ], + opencode: [ + { + tool: "opencode", + paths: ["/usr/bin/opencode", "/usr/local/bin/opencode"], + role: "preferred", + }, + { tool: "curl", paths: ["/usr/bin/curl", "/usr/local/bin/curl"], role: "fallback" }, + { tool: "node", paths: ["/usr/bin/node", "/usr/local/bin/node"], role: "fallback" }, + ], + slack: [ + { tool: "node", paths: ["/usr/bin/node", "/usr/local/bin/node"], role: "preferred" }, + { tool: "curl", paths: ["/usr/bin/curl", "/usr/local/bin/curl"], role: "fallback" }, + ], + telegram: [ + { tool: "node", paths: ["/usr/bin/node", "/usr/local/bin/node"], role: "preferred" }, + { tool: "curl", paths: ["/usr/bin/curl", "/usr/local/bin/curl"], role: "fallback" }, + ], +}; + +const PROVIDER_CREDENTIAL_HINTS: Record = { + anthropic: { + kind: "api_key_header", + header: "x-api-key", + value: "$ENV", + note: "Use the provider's required Anthropic version header alongside x-api-key. Route requests through the sandbox HTTP(S) proxy so OpenShell can resolve placeholders.", + }, + brave: { + kind: "api_key_header", + header: "X-Subscription-Token", + value: "$ENV", + note: "Use the Brave Search subscription token header through the sandbox HTTP(S) proxy.", + }, + github: { + kind: "bearer_header", + header: "Authorization", + value: "Bearer $ENV", + note: "Use the GitHub CLI when available, or pass this Authorization header through the sandbox HTTP(S) proxy for direct API calls.", + }, + gitlab: { + kind: "bearer_header", + header: "Authorization", + value: "Bearer $ENV", + note: "Use glab when available, or pass this Authorization header through the sandbox HTTP(S) proxy for direct API calls.", + }, + huggingface: { + kind: "bearer_header", + header: "Authorization", + value: "Bearer $ENV", + note: "Use this Authorization header through the sandbox HTTP(S) proxy for Hugging Face API calls.", + }, + nvidia: { + kind: "bearer_header", + header: "Authorization", + value: "Bearer $ENV", + note: "Use this Authorization header through the sandbox HTTP(S) proxy for NVIDIA API calls.", + }, + openai: { + kind: "bearer_header", + header: "Authorization", + value: "Bearer $ENV", + note: "Use this Authorization header through the sandbox HTTP(S) proxy for OpenAI-compatible API calls.", + }, + opencode: { + kind: "provider_cli_or_documented_auth", + note: "Prefer the provider CLI. For direct API calls, use the provider-documented authentication format through the sandbox HTTP(S) proxy; do not assume a bearer header.", + }, + slack: { + kind: "bearer_header", + header: "Authorization", + value: "Bearer $ENV", + note: "Use Slack SDKs or pass this Authorization header through the sandbox HTTP(S) proxy when the token type supports Web API calls.", + }, + telegram: { + kind: "provider_url_token", + note: "Telegram bot tokens are normally part of the Bot API URL path. Use Telegram-specific tooling or API URL construction through the sandbox HTTP(S) proxy; do not send it as a generic bearer header.", + }, +}; + function isToolParams(value: PluginValue | object | null | undefined): value is ToolParams { return ( value !== null && value !== undefined && typeof value === "object" && !Array.isArray(value) @@ -419,11 +571,125 @@ function toToolResult(response: AccessRequestResponse): PluginToolResult { 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."), + : "Access request is still pending; call the matching OpenShell access tool with action=check and this request_id to continue polling."), ...(response.canonical_request ? { canonical_request: response.canonical_request } : {}), }; } +function providerAccessToolResult( + providerName: string, + attached: Awaited>, +): PluginToolResult { + if (!attached) { + return { + provider_name: providerName, + status: "pending_approval", + message: + "Provider is not attached to this sandbox. Use openshell_provider_access action=request if this task needs its credential or account-backed network access.", + }; + } + return { + ...providerAccessDetails(attached), + status: "applied", + message: + "Provider credential and provider policy are attached to this sandbox. Follow credential_usage and available_tools; do not request this provider again unless it is detached.", + }; +} + +function normalizeProviderKey(providerName: string, providerType?: string): string { + return (providerType || providerName).trim().toLowerCase(); +} + +function providerToolReport(providerName: string, providerType?: string): PluginRecord { + const hints = PROVIDER_TOOL_HINTS[normalizeProviderKey(providerName, providerType)] ?? []; + const availableTools = new Set(); + const missingTools = new Set(); + const availableBinaries: string[] = []; + const missingBinaries: string[] = []; + const preferredTools = new Set(); + const fallbackTools = new Set(); + + for (const hint of hints) { + if (hint.role === "preferred") preferredTools.add(hint.tool); + if (hint.role === "fallback") fallbackTools.add(hint.tool); + const existingPath = hint.paths.find((path) => existsSync(path)); + if (existingPath) { + availableTools.add(hint.tool); + availableBinaries.push(existingPath); + for (const path of hint.paths) { + if (path !== existingPath && !existsSync(path)) missingBinaries.push(path); + } + } else { + missingTools.add(hint.tool); + missingBinaries.push(...hint.paths); + } + } + + return { + available_tools: [...availableTools], + missing_tools: [...missingTools], + preferred_tools: [...preferredTools], + fallback_tools: [...fallbackTools], + available_binaries: availableBinaries, + missing_binaries: missingBinaries, + }; +} + +function providerCredentialUsage( + attached: NonNullable>>, +): PluginRecord | undefined { + if (!attached.credential_env) return undefined; + const hint = PROVIDER_CREDENTIAL_HINTS[ + normalizeProviderKey(attached.provider_name, attached.provider_type) + ] ?? { + kind: "provider_cli_or_documented_auth", + note: "Prefer the provider CLI if available. For direct API calls, use the provider-documented authentication format through the sandbox HTTP(S) proxy; do not assume a bearer header.", + }; + const value = hint.value?.replace("$ENV", `$${attached.credential_env}`); + return { + kind: hint.kind, + ...(hint.header ? { header: hint.header } : {}), + ...(value ? { value } : {}), + proxy_required: true, + proxy_env: ["HTTP_PROXY", "HTTPS_PROXY"], + note: `${hint.note} OpenShell resolves openshell:resolve:env:* placeholders at the proxy; do not print or decode them.`, + }; +} + +function providerAccessDetails( + attached: NonNullable>>, +): PluginRecord { + const credentialUsage = providerCredentialUsage(attached); + const toolReport = providerToolReport(attached.provider_name, attached.provider_type); + const availableTools = Array.isArray(toolReport.available_tools) + ? toolReport.available_tools.filter((tool): tool is string => typeof tool === "string") + : []; + const nextStep = + credentialUsage && availableTools.some((tool) => tool === "gh" || tool === "glab") + ? "Use the provider CLI shown in available_tools, or use credential_usage through HTTPS_PROXY for direct API calls." + : credentialUsage && + availableTools.includes("curl") && + credentialUsage.header && + credentialUsage.value + ? `Use curl with ${credentialUsage.header}: ${credentialUsage.value} through HTTPS_PROXY.` + : credentialUsage + ? "Use credential_usage through HTTP_PROXY/HTTPS_PROXY with an available fallback tool." + : "Provider is attached, but no credential env was reported; inspect /v1/providers or ask the operator to reattach credentials."; + + return { + provider_name: attached.provider_name, + ...(attached.provider_type ? { provider_type: attached.provider_type } : {}), + credential_state: attached.credential_state, + usable_via_proxy: attached.usable_via_proxy, + raw_secret_available: attached.raw_secret_available, + credential_available: attached.credential_available, + ...(attached.credential_env ? { credential_env: attached.credential_env } : {}), + ...(credentialUsage ? { credential_usage: credentialUsage } : {}), + ...toolReport, + next_step: nextStep, + }; +} + async function waitForAccessStatus( initial: AccessRequestResponse, timeoutMs: number, @@ -463,6 +729,38 @@ function createAccessRequestBody(params: ToolParams): CreateAccessRequestBody { }; } +function createProviderAccessRequestBody(params: ToolParams): CreateProviderAccessRequestBody { + const userIntent = readStringProperty(params, "user_intent") ?? ""; + const providerName = readStringProperty(params, "provider_name") ?? ""; + const providerType = readStringProperty(params, "provider_type"); + const reason = readStringProperty(params, "reason") ?? ""; + const taskId = readStringProperty(params, "task_id"); + + return { + version: "nemoclaw.provider_access.v1", + ...(taskId ? { task_id: taskId } : {}), + user_intent: userIntent, + provider_name: providerName.trim(), + ...(providerType ? { provider_type: providerType.trim() } : {}), + reason, + }; +} + +function readToolAction(params: ToolParams): string { + return (readStringProperty(params, "action") ?? "").trim().toLowerCase(); +} + +function missingStringFields(params: ToolParams, fields: string[]): string[] { + return fields.filter((field) => !readStringProperty(params, field)?.trim()); +} + +function validationFailure(message: string): PluginToolResult { + return { + status: "failed", + message, + }; +} + function accessToolParameters(required: string[], properties: PluginRecord): PluginRecord { return { type: "object", @@ -515,34 +813,37 @@ export default function register(api: OpenClawPluginApi): void { ); api.registerTool({ - name: "request_resource_access", + name: "openshell_provider_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: { + "List, check, or request OpenShell provider access for this sandbox. Provider access is the preferred path for authenticated/account-backed work because a provider may attach both credentials and the required network/resource policy. Use action=list before requesting network-only access.", + parameters: accessToolParameters(["action"], { + action: { type: "string", - description: "The user's natural-language request.", + enum: ["list", "check", "request"], + description: + "list returns attached provider credentials; check reads a request_id or provider_name; request asks OpenShell to attach an existing host-managed provider.", }, - resource: { + provider_name: { + type: "string", + description: "Provider name, for example github.", + }, + provider_type: { 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.", + "Optional expected provider type, for example github. Request approval fails if the provider exists with a different type.", }, - access: { + request_id: { type: "string", - enum: ["read", "read_write"], - default: "read", - description: "Requested access mode. Use read unless mutation is required.", + description: "Request id returned by action=request.", }, - reason: { + user_intent: { type: "string", - description: "Why this access is needed for the current task.", + description: "The user's natural-language request. Required for action=request.", }, - duration: { + reason: { type: "string", - enum: ["session", "persistent"], - default: "session", - description: "Requested duration. Session access is the default.", + description: + "Why this provider is needed for the current task. Required for action=request.", }, task_id: { type: "string", @@ -552,69 +853,180 @@ export default function register(api: OpenClawPluginApi): void { 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.", + default: 0, + description: + "For action=request or check by request_id, optional time to wait for terminal status.", }, }), async execute(_id, params) { + const action = readToolAction(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()); + if (action === "list") { + const response = await listProviderAccess(clientOptions); + return { + credential_usage: + "Provider credential environment values may be openshell:resolve:env:* placeholders. Use the per-provider credential_usage through the sandbox HTTP_PROXY/HTTPS_PROXY so OpenShell can resolve the placeholder at the proxy; do not decode, print, or treat it as a raw token.", + providers: response.providers.map((provider) => ({ + ...providerAccessDetails(provider), + status: provider.status, + })), + }; + } + + if (action === "check") { + const requestId = readStringProperty(params, "request_id"); + if (requestId) { + const response = await getAccessRequest(requestId, clientOptions); + const timeoutMs = clampWaitTimeout(readNumberProperty(params, "wait_timeout_ms"), 0); + return toToolResult(await waitForAccessStatus(response, timeoutMs, clientOptions)); + } + const providerName = readStringProperty(params, "provider_name")?.trim(); + if (!providerName) { + return { + provider_name: "", + status: "failed", + message: "For action=check, provide either request_id or provider_name.", + }; + } + return providerAccessToolResult( + providerName, + await getProviderAccess(providerName, clientOptions), + ); + } + + if (action === "request") { + const missing = missingStringFields(params, ["provider_name", "user_intent", "reason"]); + if (missing.length > 0) { + return validationFailure( + `For action=request, provide required field(s): ${missing.join(", ")}.`, + ); + } + const providerName = readStringProperty(params, "provider_name")?.trim(); + if (providerName) { + const attached = await getProviderAccess(providerName, clientOptions); + if (attached) { + return providerAccessToolResult(providerName, attached); + } + } + const response = await createProviderAccessRequest( + createProviderAccessRequestBody(params), + clientOptions, + ); + const timeoutMs = clampWaitTimeout( + readNumberProperty(params, "wait_timeout_ms"), + DEFAULT_ACCESS_WAIT_MS, + ); + return toToolResult(await waitForAccessStatus(response, timeoutMs, clientOptions)); + } + return { - presets: response.presets.map((preset) => ({ - name: preset.name, - description: preset.description, - ...(preset.provider_profile ? { provider_profile: preset.provider_profile } : {}), - })), + status: "failed", + message: "Unknown action. Use one of: list, check, request.", }; }, }); api.registerTool({ - name: "check_resource_access", + name: "openshell_network_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"], { + "List, check, or request OpenShell network-only access for this sandbox. Use this for unauthenticated network/resource reachability. If the task may need authentication, API tokens, OAuth, or account identity, call openshell_provider_access action=list first and prefer provider access.", + parameters: accessToolParameters(["action"], { + action: { + type: "string", + enum: ["list_presets", "check", "request"], + description: + "list_presets returns requestable network/resource presets; check reads a request_id; request asks OpenShell for network-only access.", + }, + resource: { + type: "string", + description: + "Preset id to request. Use action=list_presets to discover valid 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.", + }, + duration: { + type: "string", + enum: ["session", "persistent"], + default: "session", + description: "Requested duration. Session access is the default.", + }, request_id: { type: "string", - description: "The request_id returned by request_resource_access.", + description: "Request id returned by action=request.", + }, + user_intent: { + type: "string", + description: "The user's natural-language request. Required for action=request.", + }, + reason: { + type: "string", + description: "Why this network access is needed. Required for action=request.", + }, + task_id: { + type: "string", + description: "Optional opaque task identifier for correlation.", }, 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.", + description: "For action=request or check, optional time to wait for terminal status.", }, }), async execute(_id, params) { - const requestId = readStringProperty(params, "request_id"); - if (!requestId) { + const action = readToolAction(params); + const clientOptions = accessClientOptions(); + + if (action === "list_presets") { + const response = await listAccessPresets(clientOptions); return { - request_id: "", - status: "failed", - message: "Missing request_id.", + presets: response.presets.map((preset) => ({ + name: preset.name, + description: preset.description, + ...(preset.provider_profile ? { provider_profile: preset.provider_profile } : {}), + })), }; } - 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)); + if (action === "check") { + const requestId = readStringProperty(params, "request_id"); + if (!requestId) { + return { + request_id: "", + status: "failed", + message: "For action=check, provide request_id.", + }; + } + const response = await getAccessRequest(requestId, clientOptions); + const timeoutMs = clampWaitTimeout(readNumberProperty(params, "wait_timeout_ms"), 0); + return toToolResult(await waitForAccessStatus(response, timeoutMs, clientOptions)); + } + + if (action === "request") { + const missing = missingStringFields(params, ["resource", "user_intent", "reason"]); + if (missing.length > 0) { + return validationFailure( + `For action=request, provide required field(s): ${missing.join(", ")}.`, + ); + } + 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)); + } + + return { + status: "failed", + message: "Unknown action. Use one of: list_presets, check, request.", + }; }, }); diff --git a/nemoclaw/src/register.test.ts b/nemoclaw/src/register.test.ts index a655d8c7269..8585227706e 100644 --- a/nemoclaw/src/register.test.ts +++ b/nemoclaw/src/register.test.ts @@ -9,6 +9,12 @@ vi.mock("node:child_process", () => ({ execFile: vi.fn(), })); +vi.mock("node:fs", () => ({ + existsSync: vi.fn((path: string) => + ["/usr/bin/curl", "/usr/bin/git", "/usr/local/bin/node"].includes(path), + ), +})); + vi.mock("./onboard/config.js", () => ({ loadOnboardConfig: vi.fn(), describeOnboardEndpoint: vi.fn(() => "build.nvidia.com"), @@ -17,8 +23,11 @@ vi.mock("./onboard/config.js", () => ({ vi.mock("./access-client.js", () => ({ createAccessRequest: vi.fn(), + createProviderAccessRequest: vi.fn(), getAccessRequest: vi.fn(), + getProviderAccess: vi.fn(), listAccessPresets: vi.fn(), + listProviderAccess: vi.fn(), waitAccessRequest: vi.fn(), })); @@ -27,16 +36,22 @@ import register, { getPluginConfig } from "./index.js"; import { loadOnboardConfig } from "./onboard/config.js"; import { createAccessRequest, + createProviderAccessRequest, getAccessRequest, + getProviderAccess, listAccessPresets, + listProviderAccess, waitAccessRequest, } from "./access-client.js"; const mockedExecFileSync = vi.mocked(execFileSync); const mockedLoadOnboardConfig = vi.mocked(loadOnboardConfig); const mockedCreateAccessRequest = vi.mocked(createAccessRequest); +const mockedCreateProviderAccessRequest = vi.mocked(createProviderAccessRequest); const mockedGetAccessRequest = vi.mocked(getAccessRequest); +const mockedGetProviderAccess = vi.mocked(getProviderAccess); const mockedListAccessPresets = vi.mocked(listAccessPresets); +const mockedListProviderAccess = vi.mocked(listProviderAccess); const mockedWaitAccessRequest = vi.mocked(waitAccessRequest); function createMockApi(): OpenClawPluginApi { @@ -73,8 +88,11 @@ describe("plugin registration", () => { mockedExecFileSync.mockReset(); mockedLoadOnboardConfig.mockReturnValue(null); mockedCreateAccessRequest.mockReset(); + mockedCreateProviderAccessRequest.mockReset(); mockedGetAccessRequest.mockReset(); + mockedGetProviderAccess.mockReset(); mockedListAccessPresets.mockReset(); + mockedListProviderAccess.mockReset(); mockedWaitAccessRequest.mockReset(); delete process.env.OPENSHELL_POLICY_LOCAL_URL; }); @@ -91,21 +109,18 @@ describe("plugin registration", () => { expect(api.registerProvider).toHaveBeenCalledWith(expect.objectContaining({ id: "inference" })); }); - it("registers OpenShell resource access tools", () => { + it("registers OpenShell 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.objectContaining({ name: "openshell_provider_access" }), ); expect(api.registerTool).toHaveBeenCalledWith( - expect.objectContaining({ name: "check_resource_access" }), + expect.objectContaining({ name: "openshell_network_access" }), ); }); - it("list_resource_access_presets surfaces OpenShell provider profile backed presets", async () => { + it("openshell_network_access action=list_presets surfaces OpenShell provider profile backed presets", async () => { mockedListAccessPresets.mockResolvedValue({ presets: [ { name: "github", description: "GitHub access", provider_profile: "github" }, @@ -115,8 +130,8 @@ describe("plugin registration", () => { const api = createMockApi(); register(api); - const tool = getRegisteredTool(api, "list_resource_access_presets"); - const result = await tool.execute("call_1", {}); + const tool = getRegisteredTool(api, "openshell_network_access"); + const result = await tool.execute("call_1", { action: "list_presets" }); expect(mockedListAccessPresets).toHaveBeenCalledWith({}); expect(result).toEqual({ @@ -127,7 +142,7 @@ describe("plugin registration", () => { }); }); - it("request_resource_access submits an OpenShell proposal and waits for approval", async () => { + it("openshell_network_access action=request submits an OpenShell proposal and waits for approval", async () => { mockedCreateAccessRequest.mockResolvedValue({ request_id: "chunk_123", status: "pending_approval", @@ -141,8 +156,9 @@ describe("plugin registration", () => { const api = createMockApi(); register(api); - const tool = getRegisteredTool(api, "request_resource_access"); + const tool = getRegisteredTool(api, "openshell_network_access"); const result = await tool.execute("call_1", { + action: "request", user_intent: "Inspect a repo", resource: "github.com", reason: "Need repository metadata.", @@ -170,7 +186,7 @@ describe("plugin registration", () => { }); }); - it("check_resource_access reads an existing OpenShell proposal status", async () => { + it("openshell_network_access action=check reads an existing OpenShell proposal status", async () => { mockedGetAccessRequest.mockResolvedValue({ request_id: "chunk_123", status: "denied", @@ -179,8 +195,9 @@ describe("plugin registration", () => { const api = createMockApi(); register(api); - const tool = getRegisteredTool(api, "check_resource_access"); + const tool = getRegisteredTool(api, "openshell_network_access"); const result = await tool.execute("call_2", { + action: "check", request_id: "chunk_123", }); @@ -192,6 +209,304 @@ describe("plugin registration", () => { }); }); + it("openshell_provider_access action=request submits a provider request and waits for approval", async () => { + mockedGetProviderAccess.mockResolvedValue(null); + mockedCreateProviderAccessRequest.mockResolvedValue({ + request_id: "chunk_provider", + status: "pending_approval", + message: "Proposal submitted.", + }); + mockedWaitAccessRequest.mockResolvedValue({ + request_id: "chunk_provider", + status: "applied", + message: "Approved.", + }); + + const api = createMockApi(); + register(api); + const tool = getRegisteredTool(api, "openshell_provider_access"); + const result = await tool.execute("call_provider", { + action: "request", + user_intent: "Review PRs", + provider_name: "github", + provider_type: "github", + reason: "Need a GitHub token.", + }); + + expect(mockedCreateProviderAccessRequest).toHaveBeenCalledWith( + { + version: "nemoclaw.provider_access.v1", + user_intent: "Review PRs", + provider_name: "github", + provider_type: "github", + reason: "Need a GitHub token.", + }, + {}, + ); + expect(mockedWaitAccessRequest).toHaveBeenCalledWith("chunk_provider", 90_000, {}); + expect(result).toEqual({ + request_id: "chunk_provider", + status: "applied", + message: "Approved.", + }); + }); + + it("openshell_provider_access action=request returns attached provider without duplicate proposal", async () => { + mockedGetProviderAccess.mockResolvedValue({ + provider_name: "github", + provider_type: "github", + status: "attached", + credential_env: "GITHUB_TOKEN", + credential_state: "attached_placeholder", + usable_via_proxy: true, + raw_secret_available: false, + credential_available: true, + }); + + const api = createMockApi(); + register(api); + const tool = getRegisteredTool(api, "openshell_provider_access"); + const result = await tool.execute("call_provider", { + action: "request", + user_intent: "Review PRs", + provider_name: "github", + provider_type: "github", + reason: "Need a GitHub token.", + }); + + expect(mockedGetProviderAccess).toHaveBeenCalledWith("github", {}); + expect(mockedCreateProviderAccessRequest).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + provider_name: "github", + provider_type: "github", + status: "applied", + credential_env: "GITHUB_TOKEN", + credential_state: "attached_placeholder", + usable_via_proxy: true, + raw_secret_available: false, + credential_available: true, + credential_usage: expect.objectContaining({ + kind: "bearer_header", + header: "Authorization", + value: "Bearer $GITHUB_TOKEN", + proxy_required: true, + }), + available_tools: ["curl", "git", "node"], + missing_tools: ["gh"], + }); + }); + + it("openshell_provider_access action=list reports attached provider credentials without secret values", async () => { + mockedListProviderAccess.mockResolvedValue({ + providers: [ + { + provider_name: "github", + provider_type: "github", + status: "attached", + credential_env: "GITHUB_TOKEN", + credential_state: "attached_placeholder", + usable_via_proxy: true, + raw_secret_available: false, + credential_available: true, + }, + ], + }); + + const api = createMockApi(); + register(api); + const tool = getRegisteredTool(api, "openshell_provider_access"); + const result = await tool.execute("call_provider_list", { action: "list" }); + + expect(mockedListProviderAccess).toHaveBeenCalledWith({}); + expect(result).toMatchObject({ + credential_usage: + "Provider credential environment values may be openshell:resolve:env:* placeholders. Use the per-provider credential_usage through the sandbox HTTP_PROXY/HTTPS_PROXY so OpenShell can resolve the placeholder at the proxy; do not decode, print, or treat it as a raw token.", + providers: [ + expect.objectContaining({ + provider_name: "github", + provider_type: "github", + status: "attached", + credential_env: "GITHUB_TOKEN", + credential_state: "attached_placeholder", + usable_via_proxy: true, + raw_secret_available: false, + credential_available: true, + credential_usage: expect.objectContaining({ + kind: "bearer_header", + value: "Bearer $GITHUB_TOKEN", + }), + available_tools: ["curl", "git", "node"], + missing_tools: ["gh"], + }), + ], + }); + }); + + it("openshell_provider_access action=check checks an attached provider by name", async () => { + mockedGetProviderAccess.mockResolvedValue({ + provider_name: "github", + provider_type: "github", + status: "attached", + credential_env: "GITHUB_TOKEN", + credential_state: "attached_placeholder", + usable_via_proxy: true, + raw_secret_available: false, + credential_available: true, + }); + + const api = createMockApi(); + register(api); + const tool = getRegisteredTool(api, "openshell_provider_access"); + const result = await tool.execute("call_provider_check", { + action: "check", + provider_name: "github", + }); + + expect(mockedGetProviderAccess).toHaveBeenCalledWith("github", {}); + expect(result).toMatchObject({ + provider_name: "github", + provider_type: "github", + status: "applied", + message: + "Provider credential and provider policy are attached to this sandbox. Follow credential_usage and available_tools; do not request this provider again unless it is detached.", + credential_env: "GITHUB_TOKEN", + credential_state: "attached_placeholder", + usable_via_proxy: true, + raw_secret_available: false, + credential_available: true, + credential_usage: expect.objectContaining({ + kind: "bearer_header", + value: "Bearer $GITHUB_TOKEN", + }), + available_tools: ["curl", "git", "node"], + missing_tools: ["gh"], + }); + }); + + it("openshell_provider_access action=check can poll a provider request by request id", async () => { + mockedGetAccessRequest.mockResolvedValue({ + request_id: "chunk_provider", + status: "pending_approval", + message: "Proposal submitted.", + }); + mockedWaitAccessRequest.mockResolvedValue({ + request_id: "chunk_provider", + status: "applied", + message: "Approved.", + }); + + const api = createMockApi(); + register(api); + const tool = getRegisteredTool(api, "openshell_provider_access"); + const result = await tool.execute("call_provider_check", { + action: "check", + request_id: "chunk_provider", + wait_timeout_ms: 1000, + }); + + expect(mockedGetAccessRequest).toHaveBeenCalledWith("chunk_provider", {}); + expect(mockedWaitAccessRequest).toHaveBeenCalledWith("chunk_provider", 1000, {}); + expect(result).toEqual({ + request_id: "chunk_provider", + status: "applied", + message: "Approved.", + }); + }); + + it("openshell_provider_access reports non-bearer provider credential guidance", async () => { + mockedGetProviderAccess.mockResolvedValue({ + provider_name: "brave", + provider_type: "brave", + status: "attached", + credential_env: "BRAVE_API_KEY", + credential_state: "attached_placeholder", + usable_via_proxy: true, + raw_secret_available: false, + credential_available: true, + }); + + const api = createMockApi(); + register(api); + const tool = getRegisteredTool(api, "openshell_provider_access"); + const result = await tool.execute("call_provider_check", { + action: "check", + provider_name: "brave", + }); + + expect(result).toMatchObject({ + provider_name: "brave", + credential_usage: expect.objectContaining({ + kind: "api_key_header", + header: "X-Subscription-Token", + value: "$BRAVE_API_KEY", + }), + }); + expect(JSON.stringify(result)).not.toContain("Bearer $BRAVE_API_KEY"); + }); + + it("openshell_provider_access uses conservative guidance when auth is provider-specific", async () => { + mockedGetProviderAccess.mockResolvedValue({ + provider_name: "telegram", + provider_type: "telegram", + status: "attached", + credential_env: "TELEGRAM_BOT_TOKEN", + credential_state: "attached_placeholder", + usable_via_proxy: true, + raw_secret_available: false, + credential_available: true, + }); + + const api = createMockApi(); + register(api); + const tool = getRegisteredTool(api, "openshell_provider_access"); + const result = await tool.execute("call_provider_check", { + action: "check", + provider_name: "telegram", + }); + + expect(result).toMatchObject({ + provider_name: "telegram", + credential_usage: expect.objectContaining({ + kind: "provider_url_token", + }), + }); + expect(JSON.stringify(result)).not.toContain("Authorization"); + expect(JSON.stringify(result)).not.toContain("Bearer $TELEGRAM_BOT_TOKEN"); + }); + + it("openshell_provider_access action=request validates required fields before client calls", async () => { + const api = createMockApi(); + register(api); + const tool = getRegisteredTool(api, "openshell_provider_access"); + const result = await tool.execute("call_provider_invalid", { + action: "request", + provider_name: "github", + }); + + expect(result).toEqual({ + status: "failed", + message: "For action=request, provide required field(s): user_intent, reason.", + }); + expect(mockedGetProviderAccess).not.toHaveBeenCalled(); + expect(mockedCreateProviderAccessRequest).not.toHaveBeenCalled(); + }); + + it("openshell_network_access action=request validates required fields before client calls", async () => { + const api = createMockApi(); + register(api); + const tool = getRegisteredTool(api, "openshell_network_access"); + const result = await tool.execute("call_network_invalid", { + action: "request", + resource: "github", + }); + + expect(result).toEqual({ + status: "failed", + message: "For action=request, provide required field(s): user_intent, reason.", + }); + expect(mockedCreateAccessRequest).not.toHaveBeenCalled(); + }); + it("continues registration when the runtime context hook is unsupported", () => { const api = createMockApi(); vi.mocked(api.on).mockImplementation((hookName: string) => { diff --git a/nemoclaw/src/runtime-context.test.ts b/nemoclaw/src/runtime-context.test.ts index 0ba758cb195..c12f2d22a80 100644 --- a/nemoclaw/src/runtime-context.test.ts +++ b/nemoclaw/src/runtime-context.test.ts @@ -429,6 +429,37 @@ describe("registerRuntimeContext", () => { }; expect(result.prependContext).toContain("Do not claim unrestricted host or internet access."); }); + + it("instructs agents to prefer provider access before resource access", async () => { + const { api } = makeMockApi(); + registerRuntimeContext(api, defaultConfig); + const result = (await api._trigger( + "before_prompt_build", + {}, + { sessionKey: nextSessionKey() }, + )) as { + prependContext: string; + }; + expect(result.prependContext).toContain("Access workflow:"); + expect(result.prependContext).toContain("call openshell_provider_access"); + expect(result.prependContext).toContain("Prefer an attached provider credential"); + expect(result.prependContext).toContain("openshell:resolve:env:* placeholders"); + expect(result.prependContext).toContain("HTTP_PROXY/HTTPS_PROXY"); + expect(result.prependContext).toContain("Do not decode, print, or treat it as a raw token"); + expect(result.prependContext).toContain("use an available fallback tool"); + expect(result.prependContext).toContain("required auth header"); + expect(result.prependContext).toContain("action=request"); + expect(result.prependContext).toContain("Access examples:"); + expect(result.prependContext).toContain('openshell_provider_access {"action":"list"}'); + expect(result.prependContext).toContain( + '"provider_name":"","provider_type":""', + ); + expect(result.prependContext).toContain( + 'openshell_provider_access {"action":"check","request_id":""', + ); + expect(result.prependContext).toContain("openshell_network_access"); + expect(result.prependContext).not.toContain('"provider_name":"github"'); + }); }); describe("caching — same session, unchanged fingerprint", () => { @@ -536,6 +567,9 @@ describe("registerRuntimeContext", () => { expect(result.prependContext).toContain(""); expect(result.prependContext).toContain("deny-by-default"); + expect(result.prependContext).toContain("call openshell_provider_access"); + expect(result.prependContext).toContain("Access examples:"); + expect(result.prependContext).toContain('openshell_provider_access {"action":"list"}'); expect( warnMessages.some((m) => m.includes("nemoclaw runtime context injection failed")), ).toBe(true); diff --git a/nemoclaw/src/runtime-context.ts b/nemoclaw/src/runtime-context.ts index b9c920580d7..f7f429b6f4c 100644 --- a/nemoclaw/src/runtime-context.ts +++ b/nemoclaw/src/runtime-context.ts @@ -16,6 +16,22 @@ const MAX_SUMMARY_PATHS = 4; const CACHE_MAX_SIZE = 100; /** Time-to-live in milliseconds for a session cache entry (1 hour). */ const CACHE_TTL_MS = 60 * 60 * 1000; +const ACCESS_WORKFLOW_LINES = [ + "Access workflow:", + "- Before requesting network-only access, call openshell_provider_access with action=list first to view currently attached provider credentials.", + "- Prefer an attached provider credential over a network-only resource rule when the task needs authentication, API tokens, OAuth, code hosting, messaging, email, calendars, issue trackers, or other account-backed access.", + "- If a needed provider is not attached, call openshell_provider_access with action=request and wait for operator approval before falling back to openshell_network_access.", + "- Use openshell_provider_access with action=check and the returned request_id to view approval status, or action=check with provider_name to verify an attached provider.", + "- Attached provider credentials may appear as openshell:resolve:env:* placeholders. Use the listed credential_env normally through the sandbox HTTP_PROXY/HTTPS_PROXY; OpenShell resolves the placeholder at the proxy. Do not decode, print, or treat it as a raw token.", + "- When a provider is attached, follow openshell_provider_access usage guidance exactly. If a preferred CLI is missing, use an available fallback tool. For bearer-token providers, pass the listed credential_env in the required auth header through HTTP_PROXY/HTTPS_PROXY.", + "- Use openshell_network_access only for unauthenticated network reachability or package/document fetches that do not need account credentials.", + "Access examples:", + '- View provider access: openshell_provider_access {"action":"list"}', + '- Request provider access: openshell_provider_access {"action":"request","provider_name":"","provider_type":"","user_intent":"Describe the account-backed task","reason":"Use the host-managed provider credential without exposing a raw token","wait_timeout_ms":0}', + '- Check request status: openshell_provider_access {"action":"check","request_id":"","wait_timeout_ms":1000}', + '- Check attached provider: openshell_provider_access {"action":"check","provider_name":""}', + '- Request network-only access only after provider access is not applicable: openshell_network_access {"action":"request","resource":"","access":"read","duration":"session","user_intent":"Fetch public unauthenticated content","reason":"No account credential is needed"}', +]; /** Uniquely identifies a sandbox+policy state at a point in time. */ interface RuntimeFingerprint { @@ -348,6 +364,7 @@ function buildRuntimeContextText(summary: RuntimeSummary): string { ...summary.networkLines.map((line) => `- ${line}`), "Filesystem policy:", ...summary.filesystemLines.map((line) => `- ${line}`), + ...ACCESS_WORKFLOW_LINES, "Behavior:", "- Do not claim unrestricted host or internet access.", "- if access is blocked, say it is blocked and ask the operator to adjust policy or approve it in OpenShell", @@ -492,6 +509,7 @@ export function registerRuntimeContext(api: OpenClawPluginApi, pluginConfig: Nem "", `You are running inside OpenShell sandbox "${activeSandbox}" via NemoClaw.`, "Treat network access as deny-by-default and report proxy 403 responses as policy blocks.", + ...ACCESS_WORKFLOW_LINES, "Do not claim unrestricted host or internet access.", "", ].join("\n"), diff --git a/test/e2e/nemoclaw-policy-local-runner.mjs b/test/e2e/nemoclaw-policy-local-runner.mjs index ed5115d8a72..e15400b587a 100755 --- a/test/e2e/nemoclaw-policy-local-runner.mjs +++ b/test/e2e/nemoclaw-policy-local-runner.mjs @@ -51,9 +51,12 @@ function tool(name) { let result; if (command === "list") { - result = await tool("list_resource_access_presets").execute("call_list", {}); + result = await tool("openshell_network_access").execute("call_list", { + action: "list_presets", + }); } else if (command === "request") { - result = await tool("request_resource_access").execute("call_request", { + result = await tool("openshell_network_access").execute("call_request", { + action: "request", user_intent: "Verify NemoClaw plugin access request integration", resource: "github", access: "read", @@ -63,7 +66,8 @@ if (command === "list") { }); } else if (command === "check") { if (!requestId) usage(); - result = await tool("check_resource_access").execute("call_check", { + result = await tool("openshell_network_access").execute("call_check", { + action: "check", request_id: requestId, wait_timeout_ms: 30_000, }); diff --git a/test/e2e/test-nemoclaw-policy-local-plugin.sh b/test/e2e/test-nemoclaw-policy-local-plugin.sh index 109f8278ae1..11d61f55471 100755 --- a/test/e2e/test-nemoclaw-policy-local-plugin.sh +++ b/test/e2e/test-nemoclaw-policy-local-plugin.sh @@ -43,7 +43,7 @@ cp "${NEMOCLAW_ROOT}/test/e2e/nemoclaw-policy-local-runner.mjs" "${UPLOAD_DIR}/r --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" +"${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 @@ -62,17 +62,17 @@ LIST_JSON="$(ssh -F "${TMP_DIR}/ssh_config" "${SSH_HOST}" node /sandbox/runner.m printf "LIST_JSON=%s\n" "${LIST_JSON}" printf "%s" "${LIST_JSON}" \ | jq -e '.presets[] | select(.name == "github" and .provider_profile == "github")' \ - >/dev/null + >/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 + 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 "request_resource_access did not return pending_approval" >&2 + echo "openshell_network_access action=request did not return pending_approval" >&2 exit 1 fi @@ -81,7 +81,7 @@ fi 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 + echo "openshell_network_access action=check did not return applied" >&2 exit 1 fi