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/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/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 new file mode 100644 index 00000000000..ceac139c7f1 --- /dev/null +++ b/docs/reference/nemoclaw-openshell-integration.md @@ -0,0 +1,220 @@ +# 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. | | + | +---------------------------------------------+ | + +---------------------------------------------------+ +``` + +## Access Types + +### Provider Access + +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. + +### Network Access + +Gives the agent network reachability only. Use it for public or unauthenticated +resource access. + +## Provider Workflow + +1. Agent checks what is already attached. + + Tool: `openshell_provider_access` + + ```json + {"action": "list"} + ``` + +2. If the needed provider is missing, the agent requests it. + + 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-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-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/nemoclaw/src/access-client.test.ts b/nemoclaw/src/access-client.test.ts new file mode 100644 index 00000000000..49d7f75d94b --- /dev/null +++ b/nemoclaw/src/access-client.test.ts @@ -0,0 +1,975 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// 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( + 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; + 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(); + }); + + 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" }), + ]), + }); + }); + + 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("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([ + { + 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("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) => { + 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, + }, + }); + }, + ); + }); + + 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 new file mode 100644 index 00000000000..fd8c51e92af --- /dev/null +++ b/nemoclaw/src/access-client.ts @@ -0,0 +1,978 @@ +// 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"; + +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 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; + user_intent: string; + llm_proposal: { + resource_type: "network"; + preset: string; + access: "read" | "read_write"; + duration: "session" | "persistent"; + reason: string; + }; +} + +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; +} + +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; +}; + +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; +}; + +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; + +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 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; + 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 = (() => { + 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 = allPresets().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, + 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"; +} + +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); + } + + 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 req = http.request( + { + method, + protocol: base.protocol, + hostname: base.hostname, + port: base.port === "" ? undefined : Number(base.port), + path: `${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 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 { + 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 : []; + 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, parsed.policy_reloaded, parsed.request_type), + 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, + }; +} + +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 = {}, +): Promise { + 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 = {}, +): 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: allPresets().map(({ name, description, provider_profile }) => ({ + name, + description, + ...(provider_profile ? { provider_profile } : {}), + })), + }); +} + +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 654a1f23cf0..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 { @@ -19,6 +20,21 @@ import { describeOnboardProvider, loadOnboardConfig, } 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"; @@ -26,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) @@ -100,6 +263,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 +384,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 +510,265 @@ 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 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, + 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 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", + additionalProperties: false, + required, + properties, + }; +} export default function register(api: OpenClawPluginApi): void { // 1. Register /nemoclaw slash command (chat interface) @@ -378,6 +812,224 @@ export default function register(api: OpenClawPluginApi): void { registeredProviderForConfig(onboardCfg, providerCredentialEnv, probed.model), ); + api.registerTool({ + name: "openshell_provider_access", + description: + "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", + 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.", + }, + provider_name: { + type: "string", + description: "Provider name, for example github.", + }, + provider_type: { + type: "string", + description: + "Optional expected provider type, for example github. Request approval fails if the provider exists with a different type.", + }, + request_id: { + type: "string", + 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 provider is needed for the current task. 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: + "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(); + + 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 { + status: "failed", + message: "Unknown action. Use one of: list, check, request.", + }; + }, + }); + + api.registerTool({ + name: "openshell_network_access", + description: + "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: "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: "For action=request or check, optional time to wait for terminal status.", + }, + }), + async execute(_id, params) { + const action = readToolAction(params); + const clientOptions = accessClientOptions(); + + if (action === "list_presets") { + const response = await listAccessPresets(clientOptions); + return { + presets: response.presets.map((preset) => ({ + name: preset.name, + description: preset.description, + ...(preset.provider_profile ? { provider_profile: preset.provider_profile } : {}), + })), + }; + } + + 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.", + }; + }, + }); + // 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/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/nemoclaw/src/register.test.ts b/nemoclaw/src/register.test.ts index d8e14e68419..8585227706e 100644 --- a/nemoclaw/src/register.test.ts +++ b/nemoclaw/src/register.test.ts @@ -2,25 +2,57 @@ // 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(), 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"), describeOnboardProvider: vi.fn(() => "NVIDIA Endpoint API"), })); +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(), +})); + import { execFileSync } from "node:child_process"; 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 { return { @@ -38,16 +70,31 @@ 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(); + mockedCreateProviderAccessRequest.mockReset(); + mockedGetAccessRequest.mockReset(); + mockedGetProviderAccess.mockReset(); + mockedListAccessPresets.mockReset(); + mockedListProviderAccess.mockReset(); + mockedWaitAccessRequest.mockReset(); + delete process.env.OPENSHELL_POLICY_LOCAL_URL; }); it("registers a slash command", () => { @@ -62,6 +109,404 @@ describe("plugin registration", () => { expect(api.registerProvider).toHaveBeenCalledWith(expect.objectContaining({ id: "inference" })); }); + it("registers OpenShell access tools", () => { + const api = createMockApi(); + register(api); + expect(api.registerTool).toHaveBeenCalledWith( + expect.objectContaining({ name: "openshell_provider_access" }), + ); + expect(api.registerTool).toHaveBeenCalledWith( + expect.objectContaining({ name: "openshell_network_access" }), + ); + }); + + 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" }, + { name: "outlook", description: "Outlook access", provider_profile: "outlook" }, + ], + }); + + const api = createMockApi(); + register(api); + const tool = getRegisteredTool(api, "openshell_network_access"); + const result = await tool.execute("call_1", { action: "list_presets" }); + + expect(mockedListAccessPresets).toHaveBeenCalledWith({}); + expect(result).toEqual({ + presets: [ + { name: "github", description: "GitHub access", provider_profile: "github" }, + { name: "outlook", description: "Outlook access", provider_profile: "outlook" }, + ], + }); + }); + + it("openshell_network_access action=request 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, "openshell_network_access"); + const result = await tool.execute("call_1", { + action: "request", + 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("openshell_network_access action=check 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, "openshell_network_access"); + const result = await tool.execute("call_2", { + action: "check", + request_id: "chunk_123", + }); + + expect(mockedGetAccessRequest).toHaveBeenCalledWith("chunk_123", {}); + expect(result).toEqual({ + request_id: "chunk_123", + status: "denied", + message: "Rejected.", + }); + }); + + 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/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/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/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/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/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/nemoclaw-policy-local-runner.mjs b/test/e2e/nemoclaw-policy-local-runner.mjs new file mode 100755 index 00000000000..e15400b587a --- /dev/null +++ b/test/e2e/nemoclaw-policy-local-runner.mjs @@ -0,0 +1,78 @@ +#!/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("openshell_network_access").execute("call_list", { + action: "list_presets", + }); +} else if (command === "request") { + result = await tool("openshell_network_access").execute("call_request", { + action: "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("openshell_network_access").execute("call_check", { + action: "check", + request_id: requestId, + wait_timeout_ms: 30_000, + }); +} else { + usage(); +} + +console.log(JSON.stringify(result)); 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/e2e/test-nemoclaw-policy-local-plugin.sh b/test/e2e/test-nemoclaw-policy-local-plugin.sh new file mode 100755 index 00000000000..11d61f55471 --- /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 "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}" 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 "openshell_network_access action=check did not return applied" >&2 + exit 1 +fi + +printf "NemoClaw plugin live policy.local flow passed for request_id=%s\n" "${REQ_ID}" 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/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", + }); + }); }); 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); 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 }); + } + }); +}); 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", + ]); }); });