diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index 8aacfb69b11..ec71a69e8b3 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -21,12 +21,13 @@ RUN set -eu; \ # Copy config generator, wrapper, startup script, and shared blueprint files. COPY agents/langchain-deepagents-code/generate-config.ts /opt/nemoclaw-deepagents-code/generate-config.ts +COPY agents/langchain-deepagents-code/managed-dcode-runtime.py /opt/nemoclaw-deepagents-code/managed-dcode-runtime.py COPY agents/langchain-deepagents-code/patch-managed-deepagents-code.py /opt/nemoclaw-deepagents-code/patch-managed-deepagents-code.py COPY agents/langchain-deepagents-code/dcode-wrapper.sh /usr/local/lib/nemoclaw/dcode-wrapper.sh COPY agents/langchain-deepagents-code/dcode-launcher.sh /usr/local/lib/nemoclaw/dcode-launcher.sh COPY agents/langchain-deepagents-code/start.sh /usr/local/bin/nemoclaw-start COPY nemoclaw-blueprint/ /opt/nemoclaw-blueprint/ -RUN chmod 444 /opt/nemoclaw-deepagents-code/generate-config.ts /opt/nemoclaw-deepagents-code/patch-managed-deepagents-code.py \ +RUN chmod 444 /opt/nemoclaw-deepagents-code/generate-config.ts /opt/nemoclaw-deepagents-code/managed-dcode-runtime.py /opt/nemoclaw-deepagents-code/patch-managed-deepagents-code.py \ && chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/dcode-wrapper.sh /usr/local/lib/nemoclaw/dcode-launcher.sh \ && chmod -R a+rX /opt/nemoclaw-blueprint \ && python3 /opt/nemoclaw-deepagents-code/patch-managed-deepagents-code.py \ diff --git a/agents/langchain-deepagents-code/dcode-wrapper.sh b/agents/langchain-deepagents-code/dcode-wrapper.sh index 5471fe0b0fd..9741e33dac4 100755 --- a/agents/langchain-deepagents-code/dcode-wrapper.sh +++ b/agents/langchain-deepagents-code/dcode-wrapper.sh @@ -5,6 +5,12 @@ # Managed Deep Agents Code launcher for NemoClaw/OpenShell sandboxes. set -euo pipefail + +if [ "${1:-}" = "--nemoclaw-mcp-capability" ] && [ "$#" -eq 1 ]; then + printf '%s\n' 'NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=2' + exit 0 +fi + unset BASH_ENV ENV OPENAI_PROXY export HOME=/sandbox @@ -74,12 +80,12 @@ run_dcode() { # * OpenShell credential placeholders are allowed only when the complete # value names the same valid env key, either canonically or with an # OpenShell `v_` revision prefix. Any other occurrence is refused. -# - Regression: the parity tests in -# test/langchain-deepagents-code-image.test.ts pin the canonical -# TOKEN_PREFIX_PATTERNS, CONTEXT_PATTERNS, and SECRET_BLOCK_PATTERNS -# fingerprints (source + flags) and feed representative samples through the -# wrapper; any canonical change trips the fingerprint test and forces this -# matcher (and its samples) to update. +# - Regression: test/langchain-deepagents-code-secret-pattern-parity.test.ts +# pins the canonical TOKEN_PREFIX_PATTERNS, CONTEXT_PATTERNS, and +# SECRET_BLOCK_PATTERNS fingerprints (source + flags), while +# test/langchain-deepagents-code-image.test.ts feeds the shared positive +# corpus through this wrapper. Any canonical change trips the parity gate and +# forces this matcher (and its samples) to update. # The live no-network acceptance clause is covered by # test/e2e/e2e-cloud-experimental/checks/08-deepagents-code-secret-boundary.sh # which exercises a real sandbox launch under `nemoclaw exec` and inspects @@ -96,6 +102,20 @@ has_context_secret_shape() { [[ "$upper" =~ (_KEY|API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)[=:[:space:]][\'\"]?[A-Z0-9_.+/=-]{10,} ]] } +has_bearer_secret_shape() { + # Spell out ECMAScript `\s` so matching does not depend on the host locale's + # POSIX `[:space:]` definition (notably for NBSP, narrow NBSP, and BOM). + local ecmascript_whitespace + # Use UTF-8 byte escapes so the expression is identical in C and UTF-8 + # locales; Bash leaves `\u` escapes literal in the C locale. + ecmascript_whitespace=$'([\t\n\v\f\r ]|\xC2\xA0|\xE1\x9A\x80' + ecmascript_whitespace+=$'|\xE2\x80\x80|\xE2\x80\x81|\xE2\x80\x82|\xE2\x80\x83' + ecmascript_whitespace+=$'|\xE2\x80\x84|\xE2\x80\x85|\xE2\x80\x86|\xE2\x80\x87' + ecmascript_whitespace+=$'|\xE2\x80\x88|\xE2\x80\x89|\xE2\x80\x8A|\xE2\x80\xA8' + ecmascript_whitespace+=$'|\xE2\x80\xA9|\xE2\x80\xAF|\xE2\x81\x9F|\xE3\x80\x80|\xEF\xBB\xBF)' + [[ "$1" =~ [Bb][Ee][Aa][Rr][Ee][Rr]${ecmascript_whitespace}+[A-Za-z0-9_.+/=-]{10,} ]] +} + has_private_key_block_shape() { local value="$1" local begin_marker="-----BEGIN " @@ -150,7 +170,7 @@ has_non_slack_secret_shape() { if [[ "$value" =~ [A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,} ]]; then return 0 fi - if [[ "$value" =~ [Bb]earer[[:space:]]+[A-Za-z0-9_.+/=-]{10,} ]]; then + if has_bearer_secret_shape "$value"; then return 0 fi if has_context_secret_shape "$value"; then @@ -247,7 +267,7 @@ is_secret_shaped_value() { if [[ "$value" =~ [A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,} ]]; then return 0 fi - if [[ "$value" =~ [Bb]earer[[:space:]]+[A-Za-z0-9_.+/=-]{10,} ]]; then + if has_bearer_secret_shape "$value"; then return 0 fi if has_context_secret_shape "$value"; then @@ -503,11 +523,6 @@ assert_no_secret_env_file assert_no_auth_store_credentials assert_no_codex_auth_credentials -if [ "${1:-}" = "--nemoclaw-mcp-capability" ] && [ "$#" -eq 1 ]; then - printf '%s\n' 'NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=1' - exit 0 -fi - # SECURITY: managed identity/status display boundary. # - Invalid state: config.toml and runtime environment values are mutable inside # the sandbox and can contain terminal controls, credentials, unsafe endpoint @@ -830,17 +845,13 @@ while [ "$arg_index" -lt "${#dcode_args[@]}" ]; do arg_index=$((arg_index + 1)) done -extra_args=(--sandbox none) -# The root-owned package helper validates the complete sandbox-user-owned file -# as strict HTTPS-only NemoClaw config before any upstream parser sees it. -managed_mcp_config="$( - /opt/venv/bin/python3 -I -c \ - 'from deepagents_code._nemoclaw_managed import managed_mcp_config_path; print(managed_mcp_config_path() or "")' -)" -if [ -n "$managed_mcp_config" ]; then - extra_args+=(--mcp-config "$managed_mcp_config") -else - extra_args+=(--no-mcp) -fi +extra_args=(--sandbox none --no-mcp) +# The patched Python entrypoint opens, validates, canonicalizes, and snapshots +# the dedicated NemoClaw MCP projection inside this long-lived process. A shell +# command substitution cannot own that descriptor: its subprocess would close +# the process-local snapshot before Deep Agents Code or its LangGraph child +# could consume it. +# `--no-mcp` also keeps upstream auto-discovery fail-closed until the managed +# entrypoint replaces it with the integrity-bound /proc/self/fd path. run_dcode "${extra_args[@]}" "$@" diff --git a/agents/langchain-deepagents-code/managed-dcode-runtime.py b/agents/langchain-deepagents-code/managed-dcode-runtime.py new file mode 100644 index 00000000000..cf0abf2b5c2 --- /dev/null +++ b/agents/langchain-deepagents-code/managed-dcode-runtime.py @@ -0,0 +1,877 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# NemoClaw-managed Deep Agents Code hardening v2. +"""Runtime invariants for the NemoClaw-managed Deep Agents Code image.""" + +from __future__ import annotations + +import errno +import fcntl +import hashlib +import ipaddress +import json +import os +import re +import stat +from pathlib import Path +from urllib.parse import urlparse, urlsplit + +_MANAGED_STATE_DIR = Path("/sandbox/.deepagents/.state") +_AUTH_FILE = _MANAGED_STATE_DIR / "auth.json" +_CODEX_AUTH_FILE = _MANAGED_STATE_DIR / "chatgpt-auth.json" +_MCP_CONFIG_FILE = Path("/sandbox/.deepagents/.nemoclaw-mcp.json") +_INFERENCE_BASE_URL_FILE = Path( + "/usr/local/share/nemoclaw/dcode-inference-base-url" +) +_MANAGED_FILE_OWNER_UID = 0 +_CREDENTIAL_NAME = re.compile( + r"(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASS|CREDENTIAL)$", + re.IGNORECASE, +) +_CREDENTIAL_ENV_NAMES = { + "LANGSMITH_RUNS_ENDPOINTS", + "LANGCHAIN_RUNS_ENDPOINTS", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", +} +_OPENSHELL_ENV_PLACEHOLDER_PREFIX = "openshell:resolve:env:" +_MCP_SERVER_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,63}") +_MCP_ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,127}") +_MCP_DNS_NAME = re.compile( + r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*" + r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?" +) +_MCP_NUMERIC_HOST = re.compile( + r"(?:0x[0-9a-f]+|[0-9]+)(?:\.(?:0x[0-9a-f]+|[0-9]+))*" +) +_MCP_MAX_CONFIG_BYTES = 262_144 +_MCP_MAX_SERVERS = 64 +_MCP_DESCRIPTOR_PREFIX = "/proc/self/fd/" +_MCP_CHILD_BINDING_ENV = "NEMOCLAW_DCODE_MCP_BINDING" +_MCP_SEALED_KIND = "sealed-memfd" +_MCP_ANONYMOUS_KIND = "anonymous-otmpfile" +_MCP_ANONYMOUS_DIRECTORY = Path("/tmp") +_MCP_FALLBACK_ERRNOS = { + errno.EACCES, + errno.EINVAL, + errno.ENOSYS, + errno.EPERM, +} +_MCP_REQUIRED_SEALS = ( + fcntl.F_SEAL_WRITE + | fcntl.F_SEAL_GROW + | fcntl.F_SEAL_SHRINK + | fcntl.F_SEAL_SEAL +) +_MCP_BLOCKED_ALIASES = { + "host.openshell.internal", + "host.docker.internal", + "host.containers.internal", +} +_MCP_RESERVED_NAMES = {"localhost", "local", "internal", "metadata"} +_MCP_BLOCKED_IPV4_NETWORKS = tuple( + ipaddress.ip_network(network) + for network in ( + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.31.196.0/24", + "192.52.193.0/24", + "192.88.99.0/24", + "192.168.0.0/16", + "192.175.48.0/24", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + ) +) +_MANAGED_MCP_FD: int | None = None +_MANAGED_MCP_BINDING: dict[str, int | str] | None = None +_MANAGED_MCP_CHILD_BINDING: dict[str, int | str] | None = None +_MANAGED_MCP_READY = False +# SECURITY -- Source boundary: this isolated Python runtime cannot import the +# canonical TypeScript groups in src/lib/security/secret-patterns.ts, so these +# expressions deliberately mirror their secret-shape behavior. +# Regression gate: test/langchain-deepagents-code-secret-pattern-parity.test.ts +# fingerprints all canonical groups and runs one shared positive corpus through +# both those groups and _contains_secret_shape; the Bash wrapper consumes the +# same corpus in test/langchain-deepagents-code-image.test.ts. +# Removal condition: delete this mirror only when the managed runtime can consume +# the canonical patterns directly or upstream rejects these shapes before boot. +_SECRET_PATTERNS = tuple( + (platform, re.compile(pattern, flags)) + for platform, pattern, flags in ( + (None, r"(?:sk-proj-|sk-ant-)[A-Za-z0-9_-]{10,}", 0), + (None, r"sk-[A-Za-z0-9_-]{20,}", 0), + (None, r"(?:nvapi-|nvcf-|ghp_|hf_|glpat-|gsk_|pypi-|tvly-)[A-Za-z0-9_-]{10,}", 0), + (None, r"github_pat_[A-Za-z0-9_]{30,}", 0), + ("slack", r"xox[bpas]-[A-Za-z0-9_-]{10,}", 0), + ("slack", r"xapp-[A-Za-z0-9_-]{10,}", 0), + (None, r"A(?:K|S)IA[A-Z0-9]{16}", 0), + ("telegram", r"(?:bot)?[0-9]{8,10}:[A-Za-z0-9_-]{35}", 0), + ("discord", r"[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}", 0), + ( + None, + r"Bearer[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+[A-Za-z0-9_.+/=-]{10,}", + re.IGNORECASE, + ), + (None, r"(?:_KEY|API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)[=:\s]['\"]?[A-Za-z0-9_.+/=-]{10,}", re.IGNORECASE), + (None, r"lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*", 0), + (None, r"-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*-----END [^-\r\n]*PRIVATE KEY-----", 0), + ) +) + + +def _contains_secret_shape(value: str) -> bool: + return any(pattern.search(value) for _platform, pattern in _SECRET_PATTERNS) + + +def _contains_other_platform_secret(value: str, platform: str) -> bool: + return any( + pattern.search(value) + for pattern_platform, pattern in _SECRET_PATTERNS + if pattern_platform != platform + ) + + +def _is_openshell_placeholder_for_name(name: str, value: str) -> bool: + if name == "OPENSHELL_TLS_KEY" or not _MCP_ENV_NAME.fullmatch(name): + return False + canonical = f"{_OPENSHELL_ENV_PLACEHOLDER_PREFIX}{name}" + versioned = re.fullmatch( + rf"{re.escape(_OPENSHELL_ENV_PLACEHOLDER_PREFIX)}v[0-9]{{1,20}}_{re.escape(name)}", + value, + ) + return value == canonical or versioned is not None + + +def _is_managed_value(name: str, value: str) -> bool: + if name == "DEEPAGENTS_CODE_OPENAI_API_KEY": + return value == "nemoclaw-managed-inference" + if name == "OPENSHELL_TLS_KEY": + return value == "/etc/openshell/tls/client/tls.key" + if name == "SLACK_BOT_TOKEN": + return bool(re.fullmatch(r"xoxb-[A-Za-z0-9_-]{10,}", value)) and not _contains_other_platform_secret(value, "slack") + if name == "SLACK_APP_TOKEN": + return bool(re.fullmatch(r"xapp-[A-Za-z0-9_-]{10,}", value)) and not _contains_other_platform_secret(value, "slack") + if name == "TELEGRAM_BOT_TOKEN": + return bool(re.fullmatch(r"(?:bot)?[0-9]{8,10}:[A-Za-z0-9_-]{35}", value)) and not _contains_other_platform_secret(value, "telegram") + if name == "DISCORD_BOT_TOKEN": + return bool( + re.fullmatch(r"[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}", value) + ) and not _contains_other_platform_secret(value, "discord") + return False + + +def _assert_safe_environment() -> None: + for name, value in os.environ.items(): + if _OPENSHELL_ENV_PLACEHOLDER_PREFIX in value: + if _is_openshell_placeholder_for_name(name, value): + continue + raise RuntimeError( + f"runtime environment variable {name} contains an invalid " + "OpenShell credential placeholder" + ) + if _is_managed_value(name, value): + continue + if _contains_secret_shape(value) or ( + len(value) >= 10 and _CREDENTIAL_NAME.search(name) + ) or ( + bool(value) and name.upper() in _CREDENTIAL_ENV_NAMES + ): + raise RuntimeError( + f"runtime environment variable {name} contains a credential; " + "use NemoClaw credential handling" + ) + + +def _assert_safe_auth_state() -> None: + if _CODEX_AUTH_FILE.exists() or _CODEX_AUTH_FILE.is_symlink(): + raise RuntimeError( + "chatgpt-auth.json is not allowed in a NemoClaw-managed sandbox" + ) + if not _AUTH_FILE.exists() and not _AUTH_FILE.is_symlink(): + return + if _AUTH_FILE.is_symlink(): + raise RuntimeError("auth.json must not be a symlink in a managed sandbox") + try: + data = json.loads(_AUTH_FILE.read_text(encoding="utf-8")) + except Exception as exc: + raise RuntimeError( + "auth.json is unreadable or malformed in a NemoClaw-managed sandbox" + ) from exc + credentials = data.get("credentials") if isinstance(data, dict) else None + if credentials: + raise RuntimeError( + "auth.json contains credentials; use NemoClaw credential handling" + ) + + +def _validate_managed_mcp_hostname(hostname: str) -> None: + if ( + hostname != hostname.lower() + or hostname.endswith(".") + or hostname in _MCP_BLOCKED_ALIASES + or hostname in _MCP_RESERVED_NAMES + or any( + hostname.endswith(f".{reserved}") + for reserved in _MCP_RESERVED_NAMES + ) + ): + raise RuntimeError("managed MCP server URL hostname is invalid") + try: + address = ipaddress.ip_address(hostname) + except ValueError: + if ( + _MCP_NUMERIC_HOST.fullmatch(hostname) + or len(hostname) > 253 + or not _MCP_DNS_NAME.fullmatch(hostname) + ): + raise RuntimeError("managed MCP server URL hostname is invalid") + return + if ( + address.version != 4 + or not address.is_global + or any(address in network for network in _MCP_BLOCKED_IPV4_NETWORKS) + ): + raise RuntimeError("managed MCP server URL address is not public IPv4") + + +def _validate_managed_mcp_url(value: object) -> str: + if not isinstance(value, str) or not value or len(value) > 2048: + raise RuntimeError("managed MCP server URL is invalid") + if ( + not value.isascii() + or any( + character.isspace() + or ord(character) < 32 + or ord(character) == 127 + for character in value + ) + ): + raise RuntimeError( + "managed MCP server URL must be ASCII without whitespace" + ) + if any( + character in value + for character in ("%", "\\", "*", "[", "]", "{", "}", ";") + ): + raise RuntimeError("managed MCP server URL is not canonical") + parsed = urlsplit(value) + if ( + parsed.scheme != "https" + or not value.startswith("https://") + or not parsed.netloc + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise RuntimeError("managed MCP server URL is invalid") + try: + port = parsed.port + except ValueError as exc: + raise RuntimeError("managed MCP server URL port is invalid") from exc + if port is not None and not 1 <= port <= 65535: + raise RuntimeError("managed MCP server URL port is invalid") + hostname = parsed.hostname + _validate_managed_mcp_hostname(hostname) + path = parsed.path or "/" + if ( + not path.startswith("/") + or "//" in path + or any(segment in {".", ".."} for segment in path.split("/")) + ): + raise RuntimeError("managed MCP server URL path is not canonical") + if any( + _contains_secret_shape(segment) + for segment in path.split("/") + if segment + ): + raise RuntimeError( + "managed MCP server URL path contains credential-shaped data" + ) + port_suffix = f":{port}" if port is not None and port != 443 else "" + canonical = f"https://{hostname}{port_suffix}{path}" + if value != canonical: + raise RuntimeError("managed MCP server URL is not canonical") + return canonical + + +def _validate_managed_mcp_entry( + server: object, entry: object +) -> dict[str, object]: + if not isinstance(server, str) or not _MCP_SERVER_NAME.fullmatch(server): + raise RuntimeError("managed MCP config contains an invalid server name") + if not isinstance(entry, dict) or set(entry) != {"type", "url", "headers"}: + raise RuntimeError(f"managed MCP server {server} has an invalid shape") + if entry["type"] != "http": + raise RuntimeError(f"managed MCP server {server} must use HTTP transport") + url = _validate_managed_mcp_url(entry["url"]) + headers = entry["headers"] + if not isinstance(headers, dict) or set(headers) != {"Authorization"}: + raise RuntimeError(f"managed MCP server {server} has invalid headers") + authorization = headers["Authorization"] + if not isinstance(authorization, str) or not authorization.startswith("Bearer "): + raise RuntimeError(f"managed MCP server {server} has invalid authorization") + placeholder = authorization.removeprefix("Bearer ") + if not placeholder.startswith(_OPENSHELL_ENV_PLACEHOLDER_PREFIX): + raise RuntimeError(f"managed MCP server {server} must use an OpenShell placeholder") + suffix = placeholder.removeprefix(_OPENSHELL_ENV_PLACEHOLDER_PREFIX) + match = re.fullmatch(r"(?:v[0-9]{1,20}_)?([A-Za-z_][A-Za-z0-9_]{0,127})", suffix) + if match is None or not _is_openshell_placeholder_for_name(match.group(1), placeholder): + raise RuntimeError(f"managed MCP server {server} has an invalid OpenShell placeholder") + return { + "headers": {"Authorization": authorization}, + "type": "http", + "url": url, + } + + +def _reject_duplicate_json_keys( + pairs: list[tuple[str, object]], +) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise RuntimeError( + "managed MCP config contains a duplicate JSON key" + ) + result[key] = value + return result + + +def _reject_non_json_constant(value: str) -> None: + raise RuntimeError( + f"managed MCP config contains invalid JSON constant {value}" + ) + + +def _read_managed_mcp_config() -> bytes | None: + flags = os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK + try: + descriptor = os.open(_MCP_CONFIG_FILE, flags) + except FileNotFoundError: + return None + except OSError as exc: + raise RuntimeError( + "managed MCP config is unreadable or unsafe" + ) from exc + try: + before = os.fstat(descriptor) + if ( + not stat.S_ISREG(before.st_mode) + or before.st_nlink != 1 + or before.st_uid != os.getuid() + or stat.S_IMODE(before.st_mode) != 0o600 + or before.st_size <= 0 + or before.st_size > _MCP_MAX_CONFIG_BYTES + ): + raise RuntimeError( + "managed MCP config has unsafe ownership or mode or invalid size" + ) + chunks: list[bytes] = [] + total = 0 + while total <= _MCP_MAX_CONFIG_BYTES: + chunk = os.read( + descriptor, + min(65_536, _MCP_MAX_CONFIG_BYTES + 1 - total), + ) + if not chunk: + break + chunks.append(chunk) + total += len(chunk) + raw = b"".join(chunks) + after = os.fstat(descriptor) + except OSError as exc: + raise RuntimeError("managed MCP config is unreadable") from exc + finally: + os.close(descriptor) + stable_fields = ( + "st_dev", + "st_ino", + "st_mode", + "st_nlink", + "st_uid", + "st_gid", + "st_size", + "st_mtime_ns", + "st_ctime_ns", + ) + if ( + len(raw) != before.st_size + or len(raw) > _MCP_MAX_CONFIG_BYTES + or any( + getattr(before, field) != getattr(after, field) + for field in stable_fields + ) + ): + raise RuntimeError( + "managed MCP config changed while it was being validated" + ) + return raw + + +def _canonicalize_managed_mcp_config(raw: bytes) -> bytes | None: + try: + data = json.loads( + raw.decode("utf-8"), + object_pairs_hook=_reject_duplicate_json_keys, + parse_constant=_reject_non_json_constant, + ) + except Exception as exc: + if isinstance(exc, RuntimeError): + raise + raise RuntimeError("managed MCP config is malformed") from exc + if not isinstance(data, dict) or set(data) != {"mcpServers"}: + raise RuntimeError("managed MCP config must contain only mcpServers") + servers = data["mcpServers"] + if not isinstance(servers, dict) or len(servers) > _MCP_MAX_SERVERS: + raise RuntimeError("managed MCP config has an invalid server map") + if not servers: + return None + canonical_servers = { + server: _validate_managed_mcp_entry(server, servers[server]) + for server in sorted(servers) + } + canonical = {"mcpServers": canonical_servers} + return ( + json.dumps( + canonical, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ) + + "\n" + ).encode("utf-8") + + +def _validate_sealed_managed_mcp_descriptor( + descriptor: int, + *, + expected_size: int | None, + unavailable_message: str, + invalid_message: str, +) -> None: + """Require one bounded, regular, completely sealed managed MCP memfd.""" + try: + metadata = os.fstat(descriptor) + seals = fcntl.fcntl(descriptor, fcntl.F_GET_SEALS) + except OSError as exc: + raise RuntimeError(unavailable_message) from exc + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_size <= 0 + or metadata.st_size > _MCP_MAX_CONFIG_BYTES + or (expected_size is not None and metadata.st_size != expected_size) + or seals != _MCP_REQUIRED_SEALS + ): + raise RuntimeError(invalid_message) + + +def is_managed_mcp_config_path(value: object) -> bool: + """Return whether a value is a canonical process-local descriptor path.""" + if not isinstance(value, str) or not value.startswith(_MCP_DESCRIPTOR_PREFIX): + return False + descriptor_text = value.removeprefix(_MCP_DESCRIPTOR_PREFIX) + return ( + descriptor_text.isascii() + and descriptor_text.isdecimal() + and str(int(descriptor_text)) == descriptor_text + ) + + +def _managed_mcp_descriptor(path: str) -> int: + descriptor_text = path.removeprefix(_MCP_DESCRIPTOR_PREFIX) + if not is_managed_mcp_config_path(path): + raise RuntimeError("managed MCP config path is not a canonical descriptor") + return int(descriptor_text) + + +def _validate_managed_mcp_binding( + value: object, +) -> dict[str, int | str]: + fields = {"fd", "dev", "ino", "size", "sha256", "kind"} + if not isinstance(value, dict) or set(value) != fields: + raise RuntimeError("managed MCP child descriptor binding is invalid") + integers = (value["fd"], value["dev"], value["ino"], value["size"]) + if any(type(item) is not int or item < 0 for item in integers): + raise RuntimeError("managed MCP child descriptor binding is invalid") + if value["size"] <= 0 or value["size"] > _MCP_MAX_CONFIG_BYTES: + raise RuntimeError("managed MCP child descriptor binding is invalid") + if value["kind"] not in {_MCP_SEALED_KIND, _MCP_ANONYMOUS_KIND}: + raise RuntimeError("managed MCP child descriptor binding is invalid") + digest = value["sha256"] + if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None: + raise RuntimeError("managed MCP child descriptor binding is invalid") + return value + + +def _managed_mcp_child_binding() -> dict[str, int | str]: + global _MANAGED_MCP_CHILD_BINDING # noqa: PLW0603 + if _MANAGED_MCP_CHILD_BINDING is not None: + return _MANAGED_MCP_CHILD_BINDING + raw = os.environ.pop(_MCP_CHILD_BINDING_ENV, None) + if raw is None: + raise RuntimeError("managed MCP child descriptor binding is unavailable") + try: + parsed = json.loads(raw) + except (TypeError, ValueError) as exc: + raise RuntimeError("managed MCP child descriptor binding is invalid") from exc + _MANAGED_MCP_CHILD_BINDING = _validate_managed_mcp_binding(parsed) + return _MANAGED_MCP_CHILD_BINDING + + +def _validate_bound_managed_mcp_descriptor( + descriptor: int, + binding: dict[str, int | str], +) -> os.stat_result: + try: + metadata = os.fstat(descriptor) + access_mode = fcntl.fcntl(descriptor, fcntl.F_GETFL) & os.O_ACCMODE + except OSError as exc: + raise RuntimeError("managed MCP config descriptor is unavailable") from exc + if ( + not stat.S_ISREG(metadata.st_mode) + or descriptor != binding["fd"] + or metadata.st_dev != binding["dev"] + or metadata.st_ino != binding["ino"] + or metadata.st_size != binding["size"] + or metadata.st_uid != os.getuid() + ): + raise RuntimeError("managed MCP config descriptor binding changed") + if binding["kind"] == _MCP_SEALED_KIND: + _validate_sealed_managed_mcp_descriptor( + descriptor, + expected_size=int(binding["size"]), + unavailable_message="managed MCP config descriptor is unavailable", + invalid_message="managed MCP config descriptor is not sealed", + ) + elif ( + metadata.st_nlink != 0 + or stat.S_IMODE(metadata.st_mode) != 0 + or access_mode != os.O_RDONLY + ): + raise RuntimeError("managed MCP anonymous descriptor is not read-only") + return metadata + + +def _read_bound_managed_mcp_descriptor( + descriptor: int, + binding: dict[str, int | str], +) -> bytes: + before = _validate_bound_managed_mcp_descriptor(descriptor, binding) + expected_size = int(binding["size"]) + chunks: list[bytes] = [] + offset = 0 + try: + while offset < expected_size: + chunk = os.pread(descriptor, min(65_536, expected_size - offset), offset) + if not chunk: + break + chunks.append(chunk) + offset += len(chunk) + extra = os.pread(descriptor, 1, expected_size) + except OSError as exc: + raise RuntimeError("managed MCP config descriptor is unreadable") from exc + raw = b"".join(chunks) + after = _validate_bound_managed_mcp_descriptor(descriptor, binding) + stable_fields = ( + "st_dev", + "st_ino", + "st_mode", + "st_nlink", + "st_uid", + "st_gid", + "st_size", + "st_mtime_ns", + "st_ctime_ns", + ) + if ( + len(raw) != expected_size + or extra + or any( + getattr(before, field) != getattr(after, field) + for field in stable_fields + ) + or hashlib.sha256(raw).hexdigest() != binding["sha256"] + ): + raise RuntimeError("managed MCP config descriptor contents changed") + return raw + + +def managed_mcp_config_bytes(config_path: str) -> bytes | None: + """Read and verify a managed descriptor; leave ordinary paths upstream.""" + if not isinstance(config_path, str) or not config_path.startswith( + _MCP_DESCRIPTOR_PREFIX + ): + return None + descriptor = _managed_mcp_descriptor(config_path) + if _MANAGED_MCP_READY: + binding = _MANAGED_MCP_BINDING + if ( + _MANAGED_MCP_FD is None + or binding is None + or descriptor != _MANAGED_MCP_FD + ): + raise RuntimeError( + "managed MCP config descriptor is not process-local" + ) + else: + binding = _managed_mcp_child_binding() + if config_path != f"{_MCP_DESCRIPTOR_PREFIX}{binding['fd']}": + raise RuntimeError("managed MCP config descriptor binding does not match") + return _read_bound_managed_mcp_descriptor(descriptor, binding) + + +def managed_mcp_server_binding(path: str) -> tuple[int, str]: + """Validate and serialize the exact snapshot inherited by a server child.""" + descriptor = _managed_mcp_descriptor(path) + if ( + not _MANAGED_MCP_READY + or _MANAGED_MCP_FD is None + or _MANAGED_MCP_BINDING is None + or descriptor != _MANAGED_MCP_FD + or path != f"{_MCP_DESCRIPTOR_PREFIX}{_MANAGED_MCP_FD}" + ): + raise RuntimeError( + "managed MCP server config descriptor is not process-local" + ) + managed_mcp_config_bytes(path) + return descriptor, json.dumps( + _MANAGED_MCP_BINDING, + sort_keys=True, + separators=(",", ":"), + ) + + +def managed_mcp_server_descriptor(path: str) -> int: + """Validate the exact descriptor inherited by a managed server child.""" + descriptor, _binding = managed_mcp_server_binding(path) + return descriptor + + +def _sealed_managed_mcp_snapshot(payload: bytes) -> int: + try: + descriptor = os.memfd_create( + "nemoclaw-dcode-mcp", + flags=os.MFD_CLOEXEC | os.MFD_ALLOW_SEALING, + ) + except (AttributeError, OSError) as exc: + raise RuntimeError( + "managed MCP config requires Linux sealed memfd support" + ) from exc + try: + remaining = memoryview(payload) + while remaining: + written = os.write(descriptor, remaining) + if written <= 0: + raise RuntimeError( + "could not write managed MCP config snapshot" + ) + remaining = remaining[written:] + try: + fcntl.fcntl(descriptor, fcntl.F_ADD_SEALS, _MCP_REQUIRED_SEALS) + except OSError as exc: + raise RuntimeError( + "managed MCP config snapshot could not be sealed" + ) from exc + _validate_sealed_managed_mcp_descriptor( + descriptor, + expected_size=len(payload), + unavailable_message="managed MCP config snapshot could not be sealed", + invalid_message="managed MCP config snapshot could not be sealed", + ) + os.lseek(descriptor, 0, os.SEEK_SET) + return descriptor + except Exception: + os.close(descriptor) + raise + + +def _anonymous_managed_mcp_snapshot(payload: bytes) -> int: + writer: int | None = None + reader: int | None = None + complete = False + try: + flags = os.O_TMPFILE | os.O_EXCL | os.O_RDWR | os.O_CLOEXEC + writer = os.open(_MCP_ANONYMOUS_DIRECTORY, flags, 0o600) + remaining = memoryview(payload) + while remaining: + written = os.write(writer, remaining) + if written <= 0: + raise RuntimeError( + "could not write managed MCP config snapshot" + ) + remaining = remaining[written:] + os.fsync(writer) + reader = os.open( + f"{_MCP_DESCRIPTOR_PREFIX}{writer}", + os.O_RDONLY | os.O_CLOEXEC, + ) + writer_metadata = os.fstat(writer) + reader_metadata = os.fstat(reader) + if ( + writer_metadata.st_dev != reader_metadata.st_dev + or writer_metadata.st_ino != reader_metadata.st_ino + or reader_metadata.st_size != len(payload) + ): + raise RuntimeError("managed MCP anonymous descriptor binding changed") + os.fchmod(writer, 0) + os.close(writer) + writer = None + complete = True + return reader + except AttributeError as exc: + raise RuntimeError( + "managed MCP config requires anonymous O_TMPFILE support" + ) from exc + except OSError as exc: + raise RuntimeError( + "managed MCP config requires anonymous O_TMPFILE support" + ) from exc + finally: + if writer is not None: + try: + os.close(writer) + except OSError: + # Best-effort teardown must not replace the primary result or error. + pass + if reader is not None and not complete: + try: + os.close(reader) + except OSError: + # Best-effort teardown must not replace the primary result or error. + pass + + +def _managed_mcp_fallback_allowed(exc: BaseException) -> bool: + current: BaseException | None = exc + while current is not None: + if isinstance(current, AttributeError): + return True + if isinstance(current, OSError): + return current.errno in _MCP_FALLBACK_ERRNOS + current = current.__cause__ + return False + + +def _managed_mcp_binding( + descriptor: int, + payload: bytes, + kind: str, +) -> dict[str, int | str]: + metadata = os.fstat(descriptor) + binding: dict[str, int | str] = { + "fd": descriptor, + "dev": metadata.st_dev, + "ino": metadata.st_ino, + "size": len(payload), + "sha256": hashlib.sha256(payload).hexdigest(), + "kind": kind, + } + return _validate_managed_mcp_binding(binding) + + +def _managed_mcp_snapshot( + payload: bytes, +) -> tuple[int, dict[str, int | str]]: + try: + descriptor = _sealed_managed_mcp_snapshot(payload) + kind = _MCP_SEALED_KIND + except RuntimeError as exc: + if not _managed_mcp_fallback_allowed(exc): + raise + descriptor = _anonymous_managed_mcp_snapshot(payload) + kind = _MCP_ANONYMOUS_KIND + try: + binding = _managed_mcp_binding(descriptor, payload, kind) + if _read_bound_managed_mcp_descriptor(descriptor, binding) != payload: + raise RuntimeError("managed MCP config snapshot changed") + return descriptor, binding + except Exception: + os.close(descriptor) + raise + + +def managed_mcp_config_path() -> str | None: + """Return an integrity-bound process-local snapshot of managed MCP state.""" + global _MANAGED_MCP_BINDING, _MANAGED_MCP_FD, _MANAGED_MCP_READY # noqa: PLW0603 + if _MANAGED_MCP_READY: + if _MANAGED_MCP_FD is None: + return None + return f"/proc/self/fd/{_MANAGED_MCP_FD}" + + raw = _read_managed_mcp_config() + if raw is None: + _MANAGED_MCP_READY = True + return None + canonical = _canonicalize_managed_mcp_config(raw) + if canonical is None: + _MANAGED_MCP_READY = True + return None + _MANAGED_MCP_FD, _MANAGED_MCP_BINDING = _managed_mcp_snapshot(canonical) + _MANAGED_MCP_READY = True + return f"{_MCP_DESCRIPTOR_PREFIX}{_MANAGED_MCP_FD}" + + +def managed_inference_base_url() -> str: + """Read and validate the root-owned inference route baked into the image.""" + path = _INFERENCE_BASE_URL_FILE + if not path.is_file() or path.is_symlink(): + raise RuntimeError("managed inference base URL file is missing or unsafe") + try: + metadata = path.stat() + raw = path.read_text(encoding="utf-8") + except OSError as exc: + raise RuntimeError("managed inference base URL file is unreadable") from exc + if ( + metadata.st_uid != _MANAGED_FILE_OWNER_UID + or stat.S_IMODE(metadata.st_mode) != 0o444 + ): + raise RuntimeError("managed inference base URL file has unsafe ownership or mode") + value = raw.rstrip("\n") + if not value or len(value) > 2048 or raw not in {value, f"{value}\n"}: + raise RuntimeError("managed inference base URL file has invalid contents") + if value != value.strip() or any(ord(character) < 32 for character in value): + raise RuntimeError("managed inference base URL file has invalid contents") + parsed = urlparse(value) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise RuntimeError("managed inference base URL is invalid") + return value + + +def assert_safe_runtime() -> None: + """Reject unmanaged runtime credentials before dcode bootstraps settings.""" + _assert_safe_environment() + _assert_safe_auth_state() + base_url = managed_inference_base_url() + os.environ["OPENAI_BASE_URL"] = base_url + os.environ["NEMOCLAW_INFERENCE_BASE_URL"] = base_url + os.environ["LANGGRAPH_NO_VERSION_CHECK"] = "true" + os.environ["OTEL_ENABLED"] = "false" + for name in ( + "OPENAI_PROXY", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + ): + os.environ.pop(name, None) diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index aa0ac8232a5..fe30f5032b3 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -49,8 +49,10 @@ state_dirs: # config.toml is non-secret NemoClaw-generated provider/model configuration. # .env and user-authored .deepagents/.mcp.json content are intentionally omitted # because they may contain service credentials. NemoClaw writes only direct-HTTP -# bridge endpoint config and OpenShell placeholders to the user-level MCP file, -# then restores its managed entries from the registry after rebuild. +# bridge endpoint config and OpenShell placeholders to its separate +# .deepagents/.nemoclaw-mcp.json projection, then restores that projection from +# the registry after rebuild. The managed projection is reconstructable state, +# not user-authored durable state. state_files: - path: config.toml user_managed_files: diff --git a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py index 6986baee31a..e03f80f84fe 100644 --- a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py +++ b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py @@ -2,6 +2,19 @@ # SPDX-License-Identifier: Apache-2.0 """Patch the pinned Deep Agents Code package for NemoClaw-managed posture.""" +# Source-of-truth review for this pinned third-party patch boundary: +# invalidState: upstream entrypoints can independently enable credential stores, +# ambient MCP discovery, update/install flows, or child-process config paths that +# bypass NemoClaw's managed inference, policy, and integrity-bound MCP boundaries. +# sourceBoundary: deepagents-code owns those Python entrypoints; NemoClaw owns the +# sandbox image posture and therefore validates every patched symbol before build. +# whyNotSourceFix: upstream 0.1.30 has no single managed-runtime hook that can +# enforce these constraints across CLI, UI, headless, server, and restart paths. +# regressionTest: the exact version plus AST symbol/method gates fail the image +# build on drift, and direct-module tests execute the patched start/restart paths. +# removalCondition: replace these sites only when a pinned upstream release offers +# equivalent discovery-free, credential-free, update-disabled managed MCP hooks. + from __future__ import annotations import ast @@ -11,6 +24,7 @@ EXPECTED_DCODE_VERSION = "0.1.30" PATCH_MARKER = "NemoClaw-managed Deep Agents Code hardening v2." +MANAGED_RUNTIME_SOURCE_PATH = Path(__file__).with_name("managed-dcode-runtime.py") MAIN_MARKER = " args = parser.parse_args()\n" ENTRYPOINT_MARKER = "from deepagents_code.main import cli_main\n" @@ -102,8 +116,9 @@ managed_mcp_config_path as _nemoclaw_managed_mcp_config_path, ) - # The pinned release treats this as its trusted user-level config; - # /sandbox/.mcp.json is project-level and remains untrusted. + # Load only NemoClaw's dedicated projection. The helper canonicalizes it + # into a process-local integrity-bound snapshot; user/project discovery is + # disabled separately in the patched MCP loader. managed_mcp_config = _nemoclaw_managed_mcp_config_path() has_managed_mcp = managed_mcp_config is not None if hasattr(args, "mcp_config"): @@ -139,6 +154,9 @@ ) _nemoclaw_original_handle_command = DeepAgentsApp._handle_command _nemoclaw_original_switch_model = DeepAgentsApp._switch_model +_nemoclaw_original_absolutize_launch_relative_path = ( + DeepAgentsApp._absolutize_launch_relative_path +) async def _nemoclaw_handle_command(self, command: str) -> None: @@ -181,6 +199,18 @@ async def _nemoclaw_switch_model( ) +def _nemoclaw_absolutize_launch_relative_path( + raw: object, + launch_cwd: Path, +) -> str | None: + """Keep the managed descriptor path from resolving to its deleted inode.""" + from deepagents_code._nemoclaw_managed import is_managed_mcp_config_path + + if is_managed_mcp_config_path(raw): + return raw + return _nemoclaw_original_absolutize_launch_relative_path(raw, launch_cwd) + + async def _nemoclaw_check_for_updates(self, *, periodic: bool = False) -> None: del periodic update_done = getattr(self, "_update_check_done", None) @@ -270,6 +300,9 @@ def _nemoclaw_block_mcp_login(self, server_name: str) -> None: DeepAgentsApp._handle_command = _nemoclaw_handle_command DeepAgentsApp._switch_model = _nemoclaw_switch_model +DeepAgentsApp._absolutize_launch_relative_path = staticmethod( + _nemoclaw_absolutize_launch_relative_path +) DeepAgentsApp._check_for_updates = _nemoclaw_check_for_updates DeepAgentsApp._handle_update_command = _nemoclaw_block_update_command DeepAgentsApp._handle_install_command = _nemoclaw_block_install_command @@ -495,6 +528,132 @@ def _build_server_env() -> dict[str, str]: return env ''' +SERVER_CONFIG_PATCH = r''' + +# NemoClaw-managed Deep Agents Code hardening v2. +_nemoclaw_original_normalize_path = _normalize_path + + +def _normalize_path(raw_path, project_context, label): + """Preserve the process-local managed MCP descriptor across serialization.""" + from deepagents_code._nemoclaw_managed import is_managed_mcp_config_path + + if ( + label == "MCP config" + and isinstance(raw_path, str) + and raw_path.startswith("/proc/self/fd/") + ): + if is_managed_mcp_config_path(raw_path): + return raw_path + raise ValueError("NemoClaw managed MCP descriptor path is invalid") + return _nemoclaw_original_normalize_path(raw_path, project_context, label) +''' + +MCP_TOOLS_PATCH = r''' + +# NemoClaw-managed Deep Agents Code hardening v2. +def discover_mcp_configs(*, project_context=None) -> list[Path]: + """Disable user and project MCP layering in the managed image.""" + del project_context + return [] +''' + +MCP_CONFIG_LOAD_MARKER = ''' path = Path(config_path) + + if not path.exists(): + error_msg = f"MCP config file not found: {config_path}" + raise FileNotFoundError(error_msg) + + try: + with path.open(encoding="utf-8") as file_obj: + config = json.load(file_obj) +''' + +MCP_CONFIG_LOAD_PATCH = ''' from deepagents_code._nemoclaw_managed import ( + managed_mcp_config_bytes, + ) + + path = Path(config_path) + try: + managed_payload = managed_mcp_config_bytes(config_path) + if managed_payload is not None: + config = json.loads(managed_payload) + else: + if not path.exists(): + error_msg = f"MCP config file not found: {config_path}" + raise FileNotFoundError(error_msg) + with path.open(encoding="utf-8") as file_obj: + config = json.load(file_obj) +''' + +MCP_EXPLICIT_CONFIG_MARKER = ''' if explicit_config_path: + config_path = ( + str(project_context.resolve_user_path(explicit_config_path)) + if project_context is not None + else explicit_config_path + ) + configs.append(load_mcp_config(config_path)) +''' + +MCP_EXPLICIT_CONFIG_PATCH = ''' if explicit_config_path: + from deepagents_code._nemoclaw_managed import ( + is_managed_mcp_config_path, + ) + + config_path = ( + explicit_config_path + if is_managed_mcp_config_path(explicit_config_path) + else ( + str(project_context.resolve_user_path(explicit_config_path)) + if project_context is not None + else explicit_config_path + ) + ) + configs.append(load_mcp_config(config_path)) +''' + +SERVER_ENV_OVERRIDES_MARKER = ''' env.update(self._persistent_env_overrides) + env.update(self._env_overrides) +''' + +SERVER_ENV_OVERRIDES_PATCH = ''' env.update(self._persistent_env_overrides) + env.update(self._env_overrides) + + # Revalidate and bind the exact managed MCP snapshot before creating + # any launch artifacts. Initial start and restart share this path. + nemoclaw_mcp_pass_fds: tuple[int, ...] = () + nemoclaw_mcp_binding_env = "NEMOCLAW_DCODE_MCP_BINDING" + env.pop(nemoclaw_mcp_binding_env, None) + nemoclaw_mcp_path = env.get("DEEPAGENTS_CODE_SERVER_MCP_CONFIG_PATH") + if nemoclaw_mcp_path: + from deepagents_code._nemoclaw_managed import ( + managed_mcp_server_binding, + ) + + descriptor, binding = managed_mcp_server_binding(nemoclaw_mcp_path) + nemoclaw_mcp_pass_fds = (descriptor,) + env[nemoclaw_mcp_binding_env] = binding +''' + +SERVER_POPEN_MARKER = ''' self._process = subprocess.Popen( # noqa: S603, ASYNC220 + cmd, + cwd=str(work_dir), + env=env, + stdout=self._log_file, + stderr=subprocess.STDOUT, + ) +''' + +SERVER_POPEN_PATCH = ''' self._process = subprocess.Popen( # noqa: S603, ASYNC220 + cmd, + cwd=str(work_dir), + env=env, + stdout=self._log_file, + stderr=subprocess.STDOUT, + pass_fds=nemoclaw_mcp_pass_fds, + ) +''' + UPDATE_CHECK_PATCH = r''' # NemoClaw-managed Deep Agents Code hardening v2. @@ -630,307 +789,6 @@ def _nemoclaw_select_with_auth_check(self, model_spec: str, provider: str) -> No ModelSelectorScreen._select_with_auth_check = _nemoclaw_select_with_auth_check ''' -HELPER_SOURCE = r'''# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# NemoClaw-managed Deep Agents Code hardening v2. -"""Runtime invariants for the NemoClaw-managed Deep Agents Code image.""" - -from __future__ import annotations - -import json -import ipaddress -import os -import re -import stat -from pathlib import Path -from urllib.parse import urlparse - -_MANAGED_STATE_DIR = Path("/sandbox/.deepagents/.state") -_AUTH_FILE = _MANAGED_STATE_DIR / "auth.json" -_CODEX_AUTH_FILE = _MANAGED_STATE_DIR / "chatgpt-auth.json" -_MCP_CONFIG_FILE = Path("/sandbox/.deepagents/.mcp.json") -_INFERENCE_BASE_URL_FILE = Path( - "/usr/local/share/nemoclaw/dcode-inference-base-url" -) -_MANAGED_FILE_OWNER_UID = 0 -_CREDENTIAL_NAME = re.compile( - r"(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASS|CREDENTIAL)$", - re.IGNORECASE, -) -_CREDENTIAL_ENV_NAMES = { - "LANGSMITH_RUNS_ENDPOINTS", - "LANGCHAIN_RUNS_ENDPOINTS", - "OTEL_EXPORTER_OTLP_ENDPOINT", - "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", - "OTEL_EXPORTER_OTLP_HEADERS", - "OTEL_EXPORTER_OTLP_TRACES_HEADERS", -} -_OPENSHELL_ENV_PLACEHOLDER_PREFIX = "openshell:resolve:env:" -_MCP_SERVER_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,63}") -_MCP_ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,127}") -_MCP_DNS_NAME = re.compile( - r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*" - r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?" -) -_SECRET_PATTERNS = tuple( - (platform, re.compile(pattern, flags)) - for platform, pattern, flags in ( - (None, r"(?:sk-proj-|sk-ant-)[A-Za-z0-9_-]{10,}", 0), - (None, r"sk-[A-Za-z0-9_-]{20,}", 0), - (None, r"(?:nvapi-|nvcf-|ghp_|hf_|glpat-|gsk_|pypi-|tvly-)[A-Za-z0-9_-]{10,}", 0), - (None, r"github_pat_[A-Za-z0-9_]{30,}", 0), - ("slack", r"xox[bpas]-[A-Za-z0-9_-]{10,}", 0), - ("slack", r"xapp-[A-Za-z0-9_-]{10,}", 0), - (None, r"A(?:K|S)IA[A-Z0-9]{16}", 0), - ("telegram", r"(?:bot)?[0-9]{8,10}:[A-Za-z0-9_-]{35}", 0), - ("discord", r"[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}", 0), - (None, r"Bearer\s+[A-Za-z0-9_.+/=-]{10,}", re.IGNORECASE), - (None, r"(?:_KEY|API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)[=:\s]['\"]?[A-Za-z0-9_.+/=-]{10,}", re.IGNORECASE), - (None, r"lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*", 0), - (None, r"-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*-----END [^-\r\n]*PRIVATE KEY-----", 0), - ) -) - - -def _contains_secret_shape(value: str) -> bool: - return any(pattern.search(value) for _platform, pattern in _SECRET_PATTERNS) - - -def _contains_other_platform_secret(value: str, platform: str) -> bool: - return any( - pattern.search(value) - for pattern_platform, pattern in _SECRET_PATTERNS - if pattern_platform != platform - ) - - -def _is_openshell_placeholder_for_name(name: str, value: str) -> bool: - if name == "OPENSHELL_TLS_KEY" or not _MCP_ENV_NAME.fullmatch(name): - return False - canonical = f"{_OPENSHELL_ENV_PLACEHOLDER_PREFIX}{name}" - versioned = re.fullmatch( - rf"{re.escape(_OPENSHELL_ENV_PLACEHOLDER_PREFIX)}v[0-9]{{1,20}}_{re.escape(name)}", - value, - ) - return value == canonical or versioned is not None - - -def _is_managed_value(name: str, value: str) -> bool: - if name == "DEEPAGENTS_CODE_OPENAI_API_KEY": - return value == "nemoclaw-managed-inference" - if name == "OPENSHELL_TLS_KEY": - return value == "/etc/openshell/tls/client/tls.key" - if name == "SLACK_BOT_TOKEN": - return bool(re.fullmatch(r"xoxb-[A-Za-z0-9_-]{10,}", value)) and not _contains_other_platform_secret(value, "slack") - if name == "SLACK_APP_TOKEN": - return bool(re.fullmatch(r"xapp-[A-Za-z0-9_-]{10,}", value)) and not _contains_other_platform_secret(value, "slack") - if name == "TELEGRAM_BOT_TOKEN": - return bool(re.fullmatch(r"(?:bot)?[0-9]{8,10}:[A-Za-z0-9_-]{35}", value)) and not _contains_other_platform_secret(value, "telegram") - if name == "DISCORD_BOT_TOKEN": - return bool( - re.fullmatch(r"[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}", value) - ) and not _contains_other_platform_secret(value, "discord") - return False - - -def _assert_safe_environment() -> None: - for name, value in os.environ.items(): - if _OPENSHELL_ENV_PLACEHOLDER_PREFIX in value: - if _is_openshell_placeholder_for_name(name, value): - continue - raise RuntimeError( - f"runtime environment variable {name} contains an invalid " - "OpenShell credential placeholder" - ) - if _is_managed_value(name, value): - continue - if _contains_secret_shape(value) or ( - len(value) >= 10 and _CREDENTIAL_NAME.search(name) - ) or ( - bool(value) and name.upper() in _CREDENTIAL_ENV_NAMES - ): - raise RuntimeError( - f"runtime environment variable {name} contains a credential; " - "use NemoClaw credential handling" - ) - - -def _assert_safe_auth_state() -> None: - if _CODEX_AUTH_FILE.exists() or _CODEX_AUTH_FILE.is_symlink(): - raise RuntimeError( - "chatgpt-auth.json is not allowed in a NemoClaw-managed sandbox" - ) - if not _AUTH_FILE.exists() and not _AUTH_FILE.is_symlink(): - return - if _AUTH_FILE.is_symlink(): - raise RuntimeError("auth.json must not be a symlink in a managed sandbox") - try: - data = json.loads(_AUTH_FILE.read_text(encoding="utf-8")) - except Exception as exc: - raise RuntimeError( - "auth.json is unreadable or malformed in a NemoClaw-managed sandbox" - ) from exc - credentials = data.get("credentials") if isinstance(data, dict) else None - if credentials: - raise RuntimeError( - "auth.json contains credentials; use NemoClaw credential handling" - ) - - -def _validate_managed_mcp_url(value: object) -> None: - if not isinstance(value, str) or not value or len(value) > 2048: - raise RuntimeError("managed MCP server URL is invalid") - if value != value.strip() or any(ord(character) < 32 for character in value): - raise RuntimeError("managed MCP server URL is invalid") - if any(character in value for character in ("%", "\\", "*", "[", "]", "{", "}", ";")): - raise RuntimeError("managed MCP server URL is not canonical") - parsed = urlparse(value) - if ( - parsed.scheme != "https" - or not parsed.netloc - or not parsed.hostname - or parsed.username is not None - or parsed.password is not None - or parsed.params - or parsed.query - or parsed.fragment - or not parsed.path.startswith("/") - or "//" in parsed.path - ): - raise RuntimeError("managed MCP server URL is invalid") - try: - port = parsed.port - except ValueError as exc: - raise RuntimeError("managed MCP server URL port is invalid") from exc - if port is not None and not 1 <= port <= 65535: - raise RuntimeError("managed MCP server URL port is invalid") - hostname = parsed.hostname - expected_netloc = hostname if port is None else f"{hostname}:{port}" - if parsed.netloc != expected_netloc: - raise RuntimeError("managed MCP server URL hostname is not canonical") - try: - address = ipaddress.ip_address(hostname) - except ValueError: - if ( - hostname != hostname.lower() - or hostname.endswith(".") - or not _MCP_DNS_NAME.fullmatch(hostname) - or hostname == "localhost" - or hostname.endswith((".localhost", ".local", ".internal")) - ): - raise RuntimeError("managed MCP server URL hostname is invalid") - else: - if address.version != 4 or not address.is_global: - raise RuntimeError("managed MCP server URL address is not public IPv4") - if _contains_secret_shape(parsed.path): - raise RuntimeError("managed MCP server URL path contains credential-shaped data") - - -def _validate_managed_mcp_entry(server: object, entry: object) -> None: - if not isinstance(server, str) or not _MCP_SERVER_NAME.fullmatch(server): - raise RuntimeError("managed MCP config contains an invalid server name") - if not isinstance(entry, dict) or set(entry) != {"type", "url", "headers"}: - raise RuntimeError(f"managed MCP server {server} has an invalid shape") - if entry["type"] != "http": - raise RuntimeError(f"managed MCP server {server} must use HTTP transport") - _validate_managed_mcp_url(entry["url"]) - headers = entry["headers"] - if not isinstance(headers, dict) or set(headers) != {"Authorization"}: - raise RuntimeError(f"managed MCP server {server} has invalid headers") - authorization = headers["Authorization"] - if not isinstance(authorization, str) or not authorization.startswith("Bearer "): - raise RuntimeError(f"managed MCP server {server} has invalid authorization") - placeholder = authorization.removeprefix("Bearer ") - if not placeholder.startswith(_OPENSHELL_ENV_PLACEHOLDER_PREFIX): - raise RuntimeError(f"managed MCP server {server} must use an OpenShell placeholder") - suffix = placeholder.removeprefix(_OPENSHELL_ENV_PLACEHOLDER_PREFIX) - match = re.fullmatch(r"(?:v[0-9]{1,20}_)?([A-Za-z_][A-Za-z0-9_]{0,127})", suffix) - if match is None or not _is_openshell_placeholder_for_name(match.group(1), placeholder): - raise RuntimeError(f"managed MCP server {server} has an invalid OpenShell placeholder") - - -def managed_mcp_config_path() -> str | None: - """Return only a complete, strict, HTTP-only NemoClaw MCP config.""" - path = _MCP_CONFIG_FILE - if not path.exists() and not path.is_symlink(): - return None - if not path.is_file() or path.is_symlink(): - raise RuntimeError("managed MCP config is missing or unsafe") - try: - metadata = path.stat() - raw = path.read_text(encoding="utf-8") - except OSError as exc: - raise RuntimeError("managed MCP config is unreadable") from exc - if metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o600: - raise RuntimeError("managed MCP config has unsafe ownership or mode") - if not raw or len(raw.encode("utf-8")) > 262144: - raise RuntimeError("managed MCP config has invalid size") - try: - data = json.loads(raw) - except Exception as exc: - raise RuntimeError("managed MCP config is malformed") from exc - if not isinstance(data, dict) or set(data) != {"mcpServers"}: - raise RuntimeError("managed MCP config must contain only mcpServers") - servers = data["mcpServers"] - if not isinstance(servers, dict) or not servers or len(servers) > 64: - raise RuntimeError("managed MCP config has an invalid server map") - for server, entry in servers.items(): - _validate_managed_mcp_entry(server, entry) - return str(path) - - -def managed_inference_base_url() -> str: - """Read and validate the root-owned inference route baked into the image.""" - path = _INFERENCE_BASE_URL_FILE - if not path.is_file() or path.is_symlink(): - raise RuntimeError("managed inference base URL file is missing or unsafe") - try: - metadata = path.stat() - raw = path.read_text(encoding="utf-8") - except OSError as exc: - raise RuntimeError("managed inference base URL file is unreadable") from exc - if ( - metadata.st_uid != _MANAGED_FILE_OWNER_UID - or stat.S_IMODE(metadata.st_mode) != 0o444 - ): - raise RuntimeError("managed inference base URL file has unsafe ownership or mode") - value = raw.rstrip("\n") - if not value or len(value) > 2048 or raw not in {value, f"{value}\n"}: - raise RuntimeError("managed inference base URL file has invalid contents") - if value != value.strip() or any(ord(character) < 32 for character in value): - raise RuntimeError("managed inference base URL file has invalid contents") - parsed = urlparse(value) - if ( - parsed.scheme not in {"http", "https"} - or not parsed.netloc - or parsed.username is not None - or parsed.password is not None - or parsed.query - or parsed.fragment - ): - raise RuntimeError("managed inference base URL is invalid") - return value - - -def assert_safe_runtime() -> None: - """Reject unmanaged runtime credentials before dcode bootstraps settings.""" - _assert_safe_environment() - _assert_safe_auth_state() - base_url = managed_inference_base_url() - os.environ["OPENAI_BASE_URL"] = base_url - os.environ["NEMOCLAW_INFERENCE_BASE_URL"] = base_url - os.environ["LANGGRAPH_NO_VERSION_CHECK"] = "true" - os.environ["OTEL_ENABLED"] = "false" - for name in ( - "OPENAI_PROXY", - "OTEL_EXPORTER_OTLP_ENDPOINT", - "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", - "OTEL_EXPORTER_OTLP_HEADERS", - "OTEL_EXPORTER_OTLP_TRACES_HEADERS", - ): - os.environ.pop(name, None) -''' - def _top_level_functions(tree: ast.Module) -> set[str]: return { @@ -996,6 +854,19 @@ def main() -> None: f"Expected deepagents-code=={EXPECTED_DCODE_VERSION}, found {actual_version}" ) + try: + managed_runtime_source = MANAGED_RUNTIME_SOURCE_PATH.read_text(encoding="utf-8") + except OSError as exc: + raise RuntimeError( + f"Managed runtime source is unreadable: {MANAGED_RUNTIME_SOURCE_PATH}" + ) from exc + if PATCH_MARKER not in managed_runtime_source: + raise RuntimeError( + f"Managed runtime source is missing its patch marker: " + f"{MANAGED_RUNTIME_SOURCE_PATH}" + ) + compile(managed_runtime_source, str(MANAGED_RUNTIME_SOURCE_PATH), "exec") + root = _package_root() paths = { "entrypoint": root / "__main__.py", @@ -1012,6 +883,8 @@ def main() -> None: "model_selector": root / "widgets" / "model_selector.py", "approval": root / "widgets" / "approval.py", "server": root / "server.py", + "server_config": root / "_server_config.py", + "mcp_tools": root / "mcp_tools.py", "subagents": root / "subagents.py", "hooks": root / "hooks.py", "non_interactive": root / "non_interactive.py", @@ -1049,6 +922,7 @@ def main() -> None: "_show_auth_manager", "_start_mcp_login", "_switch_model", + "_absolutize_launch_relative_path", "_set_rubric_model", "_on_auto_approve_enabled", "action_toggle_auto_approve", @@ -1122,6 +996,14 @@ def main() -> None: {"_handle_selection"}, ) _require_functions(paths["server"], texts["server"], {"_build_server_env"}) + _require_functions( + paths["server_config"], texts["server_config"], {"_normalize_path"} + ) + _require_functions( + paths["mcp_tools"], + texts["mcp_tools"], + {"discover_mcp_configs", "load_mcp_config"}, + ) _require_functions(paths["subagents"], texts["subagents"], {"list_subagents"}) _require_functions( paths["hooks"], texts["hooks"], {"_load_hooks", "_run_single_hook"} @@ -1140,6 +1022,16 @@ def main() -> None: raise RuntimeError( f"Expected one Deep Agents Code entrypoint marker in {paths['entrypoint']}" ) + if texts["mcp_tools"].count(MCP_CONFIG_LOAD_MARKER) != 1: + raise RuntimeError( + "Expected one Deep Agents Code MCP config loader marker in " + f"{paths['mcp_tools']}" + ) + if texts["mcp_tools"].count(MCP_EXPLICIT_CONFIG_MARKER) != 1: + raise RuntimeError( + "Expected one Deep Agents Code explicit MCP config marker in " + f"{paths['mcp_tools']}" + ) transformed = dict(texts) transformed["entrypoint"] = texts["entrypoint"].replace( ENTRYPOINT_MARKER, ENTRYPOINT_PATCH, 1 @@ -1174,8 +1066,46 @@ def main() -> None: transformed["approval"] = _append_patch( paths["approval"], texts["approval"], APPROVAL_PATCH ) + if texts["server"].count(SERVER_POPEN_MARKER) != 1: + raise RuntimeError( + "Expected one Deep Agents Code server Popen marker in " + f"{paths['server']}" + ) + if texts["server"].count(SERVER_ENV_OVERRIDES_MARKER) != 1: + raise RuntimeError( + "Expected one Deep Agents Code server environment marker in " + f"{paths['server']}" + ) + transformed_server = texts["server"].replace( + SERVER_ENV_OVERRIDES_MARKER, + SERVER_ENV_OVERRIDES_PATCH, + 1, + ) transformed["server"] = _append_patch( - paths["server"], texts["server"], SERVER_PATCH + paths["server"], + transformed_server.replace( + SERVER_POPEN_MARKER, + SERVER_POPEN_PATCH, + 1, + ), + SERVER_PATCH, + ) + transformed["server_config"] = _append_patch( + paths["server_config"], + texts["server_config"], + SERVER_CONFIG_PATCH, + ) + transformed_mcp_tools = texts["mcp_tools"].replace( + MCP_CONFIG_LOAD_MARKER, + MCP_CONFIG_LOAD_PATCH, + 1, + ).replace( + MCP_EXPLICIT_CONFIG_MARKER, + MCP_EXPLICIT_CONFIG_PATCH, + 1, + ) + transformed["mcp_tools"] = _append_patch( + paths["mcp_tools"], transformed_mcp_tools, MCP_TOOLS_PATCH ) transformed["subagents"] = _append_patch( paths["subagents"], texts["subagents"], SUBAGENTS_PATCH @@ -1191,10 +1121,9 @@ def main() -> None: for name, text in transformed.items(): compile(text, str(paths[name]), "exec") - compile(HELPER_SOURCE, str(helper_path), "exec") for name, text in transformed.items(): paths[name].write_text(text, encoding="utf-8") - helper_path.write_text(HELPER_SOURCE, encoding="utf-8") + helper_path.write_text(managed_runtime_source, encoding="utf-8") if __name__ == "__main__": diff --git a/docs/deployment/set-up-mcp-bridge.mdx b/docs/deployment/set-up-mcp-bridge.mdx index 21e6db37d2e..a977379b18f 100644 --- a/docs/deployment/set-up-mcp-bridge.mdx +++ b/docs/deployment/set-up-mcp-bridge.mdx @@ -53,8 +53,10 @@ The accepted design record is tracked in [NVIDIA/NemoClaw#566](https://github.co Use the same workflow for OpenClaw, Hermes, and LangChain Deep Agents Code sandboxes. NemoClaw selects the agent-specific adapter from the sandbox registry. -Rebuild sandboxes created before this release onto a current image before the first managed MCP change. -Hermes and Deep Agents probe their managed MCP runtime before an active add or restart changes a live provider or policy; OpenClaw verifies the pinned `mcporter` during adapter registration and rolls back an incomplete add. +Deep Agents Code `mcp add` and `mcp restart` require managed MCP capability v2. +A v1 image stops with rebuild guidance before it changes a live provider, policy, or adapter. +The early capability check identifies the managed image version only; NemoClaw still verifies config ownership and content at the mutation boundary. +Hermes performs its managed runtime probe before an active add or restart changes a live provider or policy, while OpenClaw verifies the pinned `mcporter` during adapter registration and rolls back an incomplete add. When recovery finds that a provider was already deleted, NemoClaw may first remove only that dangling sandbox-spec reference because OpenShell cannot start the capability-probe child while a missing provider name remains attached. That prerequisite does not delete or replace a live provider, credential, or policy, and the durable bridge manifest remains retryable if the later capability probe fails. @@ -82,6 +84,10 @@ NemoClaw requires exactly one `--env` bearer credential per server. Every endpoint must use HTTPS. The full URL, including its path, is persisted and displayed, so never put a credential in the URL path. NemoClaw rejects userinfo, query strings, fragments, and known secret-shaped path material; put the bearer value in `--env KEY`. +Server names must start with a letter and contain at most 64 letters, digits, hyphens, or underscores. +Endpoint hostnames must use lowercase RFC-style DNS labels with no empty, leading-hyphen, trailing-hyphen, or overlong labels. +NemoClaw rejects invalid names and hostnames before it writes lifecycle state or changes OpenShell resources. +Deep Agents Code supports at most 64 managed MCP servers in one sandbox and rejects an over-limit add or restart before mutation. Use a distinct environment variable name for each managed MCP server in the same sandbox. OpenShell static credential keys are sandbox-wide and cannot be attached twice. Endpoint paths must be literal and canonical, so NemoClaw rejects percent escapes, backslashes, semicolons, OpenShell glob metacharacters, and explicit port zero. @@ -164,8 +170,10 @@ Success still requires a replacement gateway identity, healthy loopback endpoint There is no host listener, persistent control socket, MCP relay, or service for this operation. The command carries no MCP traffic or raw service credential, and its payload contains only the endpoint definition and OpenShell placeholder. -LangChain Deep Agents Code writes an HTTP entry under its user-level discovery path, `/sandbox/.deepagents/.mcp.json`. -Deep Agents Code `0.1.12` treats the sandbox-root `.mcp.json` as project configuration and gates it on project trust, so NemoClaw does not use that path for managed MCP definitions. +The managed image pins Deep Agents Code `0.1.30` and keeps NemoClaw definitions in `/sandbox/.deepagents/.nemoclaw-mcp.json`. +The launcher validates canonical HTTPS endpoints and exact OpenShell credential placeholders, then supplies Deep Agents Code with a process-local, integrity-bound snapshot for server starts and restarts. +It prefers a sealed in-memory file when available; the OpenShell-compatible anonymous read-only descriptor fallback verifies the inode, size, and SHA-256 digest and fails closed on drift. +NemoClaw never auto-loads the user-owned `/sandbox/.deepagents/.mcp.json` or project MCP files into the managed configuration. ```json { @@ -225,14 +233,17 @@ Export only the variables whose credentials you intend to replace. `rebuild` preserves each provider that matches the recorded ID, type, and credential-key metadata at inspection time. It removes the agent adapter entry and detaches the provider before replacing the sandbox. It then reattaches the provider, waits for credential readiness, reapplies the generated policy, and restores the adapter. -Removing the old adapter entry does not require the current Deep Agents launcher marker, so an MCP entry created by a compatible older image cannot block its own removal or upgrade. -The replacement image must expose the exact managed launcher marker before NemoClaw reattaches any provider or reapplies policy and reports rebuild restoration as successful. +For Deep Agents Code, NemoClaw revalidates the prepared replacement after MCP preparation and before stopping inference or deleting the old sandbox. +If that check fails, it restores the previous MCP attachment and adapter state and keeps the old sandbox. +For a Deep Agents Code v1 image, `mcp remove`, rebuild, and destroy inspect the legacy `.deepagents/.mcp.json` and scrub only the matching registry-owned server entry. +Other user servers and unrelated top-level content remain unchanged; if NemoClaw cannot prove ownership, it fails closed and preserves retryable registry, provider, and policy state. +The replacement image must expose managed MCP capability v2 before NemoClaw reattaches any provider or reapplies policy and reports rebuild restoration as successful. If sandbox replacement fails, NemoClaw attempts to restore the previous attachment and adapter state. -A rollback targets the same old image and restores its previously compatible entry without imposing the new-image marker. +A rollback restores and verifies the entry at the legacy or v2 path used by the surviving old image without imposing the new-image requirement. A later `mcp restart` can retry an incomplete post-rebuild restore. `destroy` removes the adapter entry and detaches providers that match the recorded metadata before asking OpenShell to delete the sandbox. -Like remove and rebuild teardown, this scrub does not require the new Deep Agents launcher marker from an older image. +Like remove and rebuild teardown, this scrub does not require Deep Agents managed MCP capability v2 from an older image. If deletion is refused, NemoClaw attempts to restore the previous MCP state, reports any rollback failure, and preserves recovery state. Provider deletion and registry cleanup happen only after OpenShell confirms that the sandbox is gone. NemoClaw prechecks the recorded provider ID and credential-key shape before mutation and uses a random per-add provider-name suffix to avoid accidental name reuse. @@ -255,8 +266,8 @@ Re-export the value if the provider still needs to be created. To abandon the transaction, run `mcp remove --force`. NemoClaw cleans only resources whose ownership it can prove and keeps the registry entry when residual cleanup remains. -If MCP add or restart reports that `mcporter`, the Hermes transaction helper, or the managed Deep Agents MCP-aware launcher is unavailable, rebuild the sandbox onto a current image before retrying. -An existing Deep Agents MCP entry remains removable, destroyable, and eligible for rebuild teardown on an older image; the rebuilt image must pass the launcher probe before its MCP runtime is restored. +If MCP add or restart reports that `mcporter`, the Hermes transaction helper, or Deep Agents managed MCP capability v2 is unavailable, rebuild the sandbox onto a current image before retrying. +An existing Deep Agents v1 entry remains removable, destroyable, and eligible for rebuild teardown when NemoClaw can identify the exact registry-owned legacy entry; the rebuilt image must pass the v2 capability check before its MCP runtime is restored. If the generated policy or provider has drifted, `restart` fails closed instead of overwriting same-name state. Resolve the reported OpenShell ownership or content mismatch, then retry. diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index 221572bfbdd..5a987b8d397 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -31,7 +31,7 @@ nemoclaw onboard --agent deepagents nemoclaw onboard --agent langchain ``` -The image installs a hash-locked, pinned Deep Agents Code release with NVIDIA provider support. +The image installs hash-locked Deep Agents Code `0.1.30` with NVIDIA provider support. After the terminal smoke checks, onboarding runs `dcode --version` and compares the result with the version required by the agent manifest. Fresh and resumed onboarding exit nonzero instead of reporting the runtime ready when the installed version is too old, uses an incompatible version scheme, or cannot be verified. If the version check fails, review the reported version error and run `nemo-deepagents rebuild` before resuming onboarding. @@ -72,8 +72,12 @@ The managed model constructor accepts only Deep Agents Code's `openai` provider It supplies the non-secret gateway placeholder key and ignores mutable provider classes, credentials, endpoints, and constructor parameters in Deep Agents Code config. CLI and TUI model parameter overrides and custom rubric models are blocked. Project and user-defined subagents remain available, but they inherit the managed chat model instead of accepting their own model override. -MCP servers registered through `nemoclaw mcp add` remain available through the single managed user-level config and OpenShell egress policy; arbitrary project and user MCP configuration remains blocked. -Before launch, NemoClaw validates the complete managed file as HTTPS-only definitions with exact OpenShell credential placeholders; stdio commands, extra headers, raw credentials, and unrelated top-level configuration fail closed. +MCP servers registered through `nemoclaw mcp add` remain available through NemoClaw's dedicated `/sandbox/.deepagents/.nemoclaw-mcp.json` projection and OpenShell egress policy. +Project and user MCP files are never auto-loaded. +Sandboxes with the older managed MCP v1 runtime must rebuild before `mcp add` or `mcp restart`; remove, rebuild, and destroy can still scrub exact registry-owned legacy entries without claiming unrelated user content. +Before launch, NemoClaw validates and canonicalizes the complete managed file as HTTPS-only definitions with exact OpenShell credential placeholders, then gives Deep Agents Code a process-local, integrity-bound snapshot for server starts and restarts. +It prefers a sealed in-memory file when available; the OpenShell-compatible anonymous read-only descriptor fallback verifies the inode, size, and SHA-256 digest and fails closed on drift. +Stdio commands, extra headers, raw credentials, and unrelated top-level configuration fail closed. For authenticated MCP setup and credential rotation, see [Set Up MCP Servers](../manage-sandboxes/set-up-mcp-servers). This isolated-mode guarantee applies to those managed launchers, not arbitrary Python commands in the sandbox. @@ -108,13 +112,16 @@ Deep Agents Code state lives under `/sandbox/.deepagents`. NemoClaw snapshot and rebuild flows preserve the app state directory, skills, and generated config when those files exist. Run `nemoclaw snapshot create` after active `dcode` tasks finish. For `langchain-deepagents-code` sandboxes, NemoClaw refuses backup when it detects an active `dcode` task or cannot verify that the state tree is idle. -NemoClaw intentionally does not back up `.deepagents/.env` or user-authored portions of `.deepagents/.mcp.json` because users may put Tavily, LangSmith, MCP service, or provider credentials there. -NemoClaw restores its managed MCP definitions separately from the credential-free registry; service credentials remain in OpenShell provider state. +NemoClaw intentionally does not back up `.deepagents/.env` or the user-owned `.deepagents/.mcp.json` because users may put Tavily, LangSmith, MCP service, or provider credentials there. +The managed `.deepagents/.nemoclaw-mcp.json` projection is also excluded because NemoClaw reconstructs it from the credential-free registry after recreation. +Service credentials remain in OpenShell provider state. It also does not preserve `hooks.json`; executable Deep Agents Code hooks are disabled in the managed harness. If `.deepagents/.state/auth.json` contains upstream credentials, or `.deepagents/.state/chatgpt-auth.json` exists, the managed Deep Agents Code launch paths refuse to start until that credential state is removed. -Before a managed Deep Agents Code rebuild changes the sandbox, NemoClaw selects its recorded OpenShell gateway, tests the recorded inference route through `https://inference.local`, successfully builds the replacement from a pinned base and fingerprinted context, and revalidates the target and route. +Before a managed Deep Agents Code rebuild changes the sandbox, NemoClaw selects its recorded OpenShell gateway, tests the recorded inference route through `https://inference.local`, and prepares the replacement from the recorded provider, model, reasoning, and web search settings with a pinned base and fingerprinted context. Initial failures stop before backup. -NemoClaw checks the target, route, and retained build inputs again after backup, immediately before deletion, so late failures can leave a backup but keep the existing sandbox intact. +After backup, NemoClaw rechecks the target, route, and retained build inputs before changing MCP state, then checks again after MCP preparation and before stopping inference or deleting the old sandbox. +If the final check fails, NemoClaw restores the previous MCP state and keeps the existing sandbox intact. +Rebuild also preserves the standalone Deep Agents Code `tavily` preset and replays recorded custom policies from their exact stored content. ## Optional Web Search diff --git a/docs/network-policy/customize-network-policy.mdx b/docs/network-policy/customize-network-policy.mdx index 5ebbdee6569..10cb64e9b3a 100644 --- a/docs/network-policy/customize-network-policy.mdx +++ b/docs/network-policy/customize-network-policy.mdx @@ -302,6 +302,10 @@ network_policies: The top-level `preset.name` must be a lowercase RFC 1123 label (letters, digits, hyphens) and must not collide with a built-in preset name such as `slack` or `pypi`. Rename `preset.name` if NemoClaw refuses to apply the file because of a collision. +Rule matchers must match the endpoint protocol. +REST and WebSocket rules require `method` and `path`; REST accepts standard HTTP methods or `*`, while WebSocket accepts `GET`, `WEBSOCKET_TEXT`, or `*`. +JSON-RPC rules accept only `method`, and MCP rules accept `method` plus optional `tool` or `params.name` matchers. +The same protocol-specific matcher shape applies to `deny_rules`. User-authored presets must not declare `allowed_ips` for ordinary endpoints. NemoClaw rejects that field in files passed through `--from-file` or `--from-dir` because it can widen the private-address ranges that OpenShell checks during SSRF protection. Use hostnames, ports, protocols, methods, paths, and binary restrictions instead. diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 49dfd69f413..88cecb9e918 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -1207,6 +1207,8 @@ Inline `--env KEY=VALUE` is rejected because it would expose the value in NemoCl Load the variable from a secret manager or masked prompt, export it without recording the value in shell history, and pass only `--env KEY`. All endpoints must use HTTPS. The full URL and path are persisted and displayed, so URLs cannot contain userinfo, query strings, fragments, known secret-shaped path material, percent-escaped or glob-style paths, or port zero. +Server names must start with a letter and contain at most 64 letters, digits, hyphens, or underscores, and endpoint hostnames must use canonical lowercase DNS labels. +NemoClaw rejects invalid names and endpoints before it writes lifecycle state or changes OpenShell resources. NemoClaw generates a narrow `protocol: mcp` policy for the destination, literal path, adapter binaries, pinned addresses, explicit MCP methods, and a 131,072-byte request-body limit. OpenShell `0.0.72` evaluates that policy before replacing the attached provider placeholder in the allowed request header. Static provider placeholders are sandbox-scoped rather than endpoint-exclusive, so do not grant broader inspected-HTTP routes to the same adapter runtime and credential key. @@ -1264,8 +1266,8 @@ Hermes shields must be down for this config mutation. Keep them down until the command returns; a concurrent relock refuses the config commit and can leave an earlier policy/provider stage for the next retry to converge. NemoClaw unregisters the sandbox agent adapter, prechecks and removes the recorded OpenShell provider, removes the owned generated policy, and clears the sandbox registry entry. -Deep Agents teardown does not require the managed launcher marker from the old image, so an existing compatible MCP entry cannot block `mcp remove`, sandbox rebuild, or destroy merely because the image predates that probe. -A replacement image must pass the marker probe before post-rebuild MCP providers or policy are restored. +Deep Agents teardown does not require managed MCP capability v2 from the old image. +For a v1 image, NemoClaw removes the exact registry-owned entry from the legacy `.mcp.json` while preserving unrelated user state; a replacement image must pass the v2 capability check before post-rebuild providers or policy are restored. The command fails closed on observed drift. `--force` may remove a modified same-name agent adapter entry, but provider deletion still requires the exact recorded ID, type, and credential key and policy deletion still requires exact owned content. Residuals preserve registry state. @@ -1475,6 +1477,7 @@ Upgrade a sandbox to the current agent version while preserving workspace state. The command backs up workspace state, destroys the old sandbox (including its host-side Docker image), recreates it with the current image via `onboard --resume`, and restores workspace state into the new sandbox. Credentials are stripped from backups before storage. Policy presets applied to the old sandbox are reapplied to the new one so your egress rules survive the rebuild. +The replacement uses the recorded compatible-endpoint reasoning mode and web search selection instead of ambient shell values. The recorded sandbox GPU mode is preserved across rebuild. A sandbox onboarded with an explicit GPU opt-out (stored as `sandboxGpuMode: "0"`, plus legacy registry entries that only record `gpuEnabled: false`) is recreated with the same opt-out, so the inner `onboard --resume` skips the Docker CDI GPU preflight on hosts without an NVIDIA GPU. Auto-mode sandboxes remain auto. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 2338adc1869..a85271255db 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1525,6 +1525,8 @@ Inline `--env KEY=VALUE` is rejected because it would expose the value in NemoCl Load the variable from a secret manager or masked prompt, export it without recording the value in shell history, and pass only `--env KEY`. All endpoints must use HTTPS. The full URL and path are persisted and displayed, so URLs cannot contain userinfo, query strings, fragments, known secret-shaped path material, percent-escaped or glob-style paths, or port zero. +Server names must start with a letter and contain at most 64 letters, digits, hyphens, or underscores, and endpoint hostnames must use canonical lowercase DNS labels. +NemoClaw rejects invalid names and endpoints before it writes lifecycle state or changes OpenShell resources. NemoClaw generates a narrow `protocol: mcp` policy for the destination, literal path, adapter binaries, pinned addresses, explicit MCP methods, and a 131,072-byte request-body limit. OpenShell `0.0.72` evaluates that policy before replacing the attached provider placeholder in the allowed request header. Static provider placeholders are sandbox-scoped rather than endpoint-exclusive, so do not grant broader inspected-HTTP routes to the same adapter runtime and credential key. @@ -1594,8 +1596,8 @@ Keep them down until the command returns; a concurrent relock refuses the config NemoClaw unregisters the sandbox agent adapter, prechecks and removes the recorded OpenShell provider, removes the owned generated policy, and clears the sandbox registry entry. -Deep Agents teardown does not require the managed launcher marker from the old image, so an existing compatible MCP entry cannot block `mcp remove`, sandbox rebuild, or destroy merely because the image predates that probe. -A replacement image must pass the marker probe before post-rebuild MCP providers or policy are restored. +Deep Agents teardown does not require managed MCP capability v2 from the old image. +For a v1 image, NemoClaw removes the exact registry-owned entry from the legacy `.mcp.json` while preserving unrelated user state; a replacement image must pass the v2 capability check before post-rebuild providers or policy are restored. The command fails closed on observed drift. `--force` may remove a modified same-name agent adapter entry, but provider deletion still requires the exact recorded ID, type, and credential key and policy deletion still requires exact owned content. Residuals preserve registry state. @@ -1861,6 +1863,7 @@ Upgrade a sandbox to the current agent version while preserving workspace state. The command backs up workspace state, destroys the old sandbox (including its host-side Docker image), recreates it with the current image via `onboard --resume`, and restores workspace state into the new sandbox. Credentials are stripped from backups before storage. Policy presets applied to the old sandbox are reapplied to the new one so your egress rules survive the rebuild. +The replacement uses the recorded compatible-endpoint reasoning mode and web search selection instead of ambient shell values. The recorded sandbox GPU mode is preserved across rebuild. A sandbox onboarded with an explicit GPU opt-out (stored as `sandboxGpuMode: "0"`, plus legacy registry entries that only record `gpuEnabled: false`) is recreated with the same opt-out, so the inner `onboard --resume` skips the Docker CDI GPU preflight on hosts without an NVIDIA GPU. Auto-mode sandboxes remain auto. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 35864bc07a9..aee9705c68e 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -183,9 +183,9 @@ The `protocol` field on an endpoint controls whether the proxy also inspects ind | Aspect | Detail | |---|---| | Default | Endpoints without a `protocol` field use L4-only enforcement: the proxy checks host, port, and binary identity, then relays the TCP stream without inspecting payloads. Setting `protocol: rest` enables L7 inspection: the proxy auto-detects and terminates TLS, then evaluates each HTTP request's method and path against the endpoint's `rules` or `access` preset. | -| What you can change | Add `protocol: rest` to an endpoint to enable per-request HTTP inspection. Use the `access` preset (`full`, `read-only`, `read-write`) or explicit `rules` to control allowed methods and paths. | +| What you can change | Set `protocol` to `rest`, `websocket`, `json-rpc`, or `mcp` and use rules that match that protocol. REST and WebSocket rules match methods and paths, JSON-RPC rules match RPC methods, and MCP rules can additionally match tools or parameter names. | | Risk if relaxed | L4-only endpoints (no `protocol` field) allow the agent to send any data through the tunnel after the initial connection is permitted. The proxy cannot see or filter the HTTP method, path, or body. The `access: full` preset with `protocol: rest` enables inspection but allows all methods and paths, so it does not restrict what the agent can do at the HTTP level. | -| Recommendation | Use `protocol: rest` with specific `rules` for REST APIs where you want method and path control. Use `protocol: rest` with `access: read-only` for read-only endpoints. Omit `protocol` only for non-HTTP protocols (WebSocket, gRPC streaming), endpoints that do not need HTTP inspection, or documented compatibility exceptions that require a client-managed CONNECT tunnel. | +| Recommendation | Select the matching L7 protocol and use the narrowest supported rules. Omit `protocol` only for protocols without an inspectable mode, endpoints that do not need request inspection, or documented compatibility exceptions that require a client-managed CONNECT tunnel. | ### Operator Approval Flow diff --git a/schemas/policy-preset.schema.json b/schemas/policy-preset.schema.json index b97c0c7f618..d4d9e2fa3b3 100644 --- a/schemas/policy-preset.schema.json +++ b/schemas/policy-preset.schema.json @@ -48,6 +48,7 @@ "properties": { "host": { "type": "string" }, "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "path": { "type": "string", "pattern": "^/" }, "protocol": { "type": "string", "enum": ["rest", "websocket", "json-rpc", "mcp"] }, "enforcement": { "type": "string", "enum": ["enforce", "audit"] }, "tls": { "type": "string", "enum": ["terminate", "passthrough", "skip"] }, @@ -75,10 +76,43 @@ "allOf": [ { "if": { - "properties": { "protocol": { "enum": ["rest", "websocket"] } }, + "anyOf": [ + { "not": { "required": ["protocol"] } }, + { + "properties": { "protocol": { "const": "rest" } }, + "required": ["protocol"] + } + ] + }, + "then": { + "properties": { + "rules": { + "items": { "$ref": "#/$defs/restRule" } + }, + "deny_rules": { + "items": { "$ref": "#/$defs/restMatcher" } + } + }, + "anyOf": [ + { "required": ["rules"] }, + { "required": ["access"] } + ] + } + }, + { + "if": { + "properties": { "protocol": { "const": "websocket" } }, "required": ["protocol"] }, "then": { + "properties": { + "rules": { + "items": { "$ref": "#/$defs/websocketRule" } + }, + "deny_rules": { + "items": { "$ref": "#/$defs/websocketMatcher" } + } + }, "anyOf": [ { "required": ["rules"] }, { "required": ["access"] } @@ -92,6 +126,14 @@ }, "then": { "required": ["rules"], + "properties": { + "rules": { + "items": { "$ref": "#/$defs/jsonRpcRule" } + }, + "deny_rules": { + "items": { "$ref": "#/$defs/jsonRpcMatcher" } + } + }, "not": { "required": ["access"] } } }, @@ -101,6 +143,14 @@ "required": ["protocol"] }, "then": { + "properties": { + "rules": { + "items": { "$ref": "#/$defs/mcpRule" } + }, + "deny_rules": { + "items": { "$ref": "#/$defs/mcpMatcher" } + } + }, "not": { "required": ["access"] }, "anyOf": [ { "required": ["rules"] }, @@ -117,6 +167,103 @@ } ] } + }, + { + "if": { + "properties": { "protocol": { "const": "mcp" } }, + "required": ["protocol"], + "not": { + "required": ["mcp"], + "properties": { + "mcp": { + "required": ["allow_all_known_mcp_methods"], + "properties": { + "allow_all_known_mcp_methods": { "const": true } + } + } + } + } + }, + "then": { + "properties": { + "rules": { + "items": { "$ref": "#/$defs/mcpMethodRule" } + }, + "deny_rules": { + "items": { "$ref": "#/$defs/mcpMethodMatcher" } + } + } + } + }, + { + "if": { + "properties": { + "protocol": { "const": "mcp" }, + "mcp": { + "required": ["strict_tool_names"], + "properties": { "strict_tool_names": { "const": false } } + } + }, + "required": ["protocol", "mcp"] + }, + "then": { + "properties": { + "rules": { + "items": { "$ref": "#/$defs/mcpExactToolRule" } + }, + "deny_rules": { + "items": { "$ref": "#/$defs/mcpExactToolMatcher" } + } + } + } + }, + { + "if": { + "anyOf": [ + { "not": { "required": ["protocol"] } }, + { + "properties": { "protocol": { "not": { "const": "mcp" } } }, + "required": ["protocol"] + } + ] + }, + "then": { + "properties": { + "mcp": { + "not": { + "anyOf": [ + { "required": ["strict_tool_names"] }, + { "required": ["allow_all_known_mcp_methods"] } + ] + } + } + } + } + }, + { + "if": { + "properties": { + "protocol": { "const": "mcp" }, + "rules": { + "contains": { "$ref": "#/$defs/mcpToolSelectorRule" } + } + }, + "required": ["protocol", "rules"] + }, + "then": { + "properties": { + "rules": { + "not": { + "contains": { "$ref": "#/$defs/mcpBroadToolsCallRule" } + } + }, + "deny_rules": { + "not": { + "contains": { "$ref": "#/$defs/mcpBroadToolsCallMatcher" } + } + } + } + } } ] }, @@ -144,6 +291,196 @@ } } }, + "restRule": { + "type": "object", + "required": ["allow"], + "additionalProperties": false, + "properties": { + "allow": { "$ref": "#/$defs/restMatcher" } + } + }, + "restMatcher": { + "type": "object", + "required": ["method", "path"], + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + "*" + ] + }, + "path": { "type": "string", "pattern": "^/" } + } + }, + "websocketRule": { + "type": "object", + "required": ["allow"], + "additionalProperties": false, + "properties": { + "allow": { "$ref": "#/$defs/websocketMatcher" } + } + }, + "websocketMatcher": { + "type": "object", + "required": ["method", "path"], + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "enum": ["GET", "WEBSOCKET_TEXT", "*"] + }, + "path": { "type": "string", "pattern": "^/" } + } + }, + "jsonRpcRule": { + "type": "object", + "required": ["allow"], + "additionalProperties": false, + "properties": { + "allow": { "$ref": "#/$defs/jsonRpcMatcher" } + } + }, + "jsonRpcMatcher": { + "type": "object", + "required": ["method"], + "additionalProperties": false, + "properties": { + "method": { "$ref": "#/$defs/rpcMethod" } + } + }, + "mcpRule": { + "type": "object", + "required": ["allow"], + "additionalProperties": false, + "properties": { + "allow": { "$ref": "#/$defs/mcpMatcher" } + } + }, + "mcpMatcher": { + "type": "object", + "additionalProperties": false, + "properties": { + "method": { "$ref": "#/$defs/mcpMethod" }, + "tool": { "$ref": "#/$defs/matcher" }, + "params": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { "$ref": "#/$defs/matcher" } + }, + "required": ["name"] + } + }, + "allOf": [ + { "not": { "required": ["tool", "params"] } }, + { + "if": { + "anyOf": [{ "required": ["tool"] }, { "required": ["params"] }] + }, + "then": { + "anyOf": [ + { "not": { "required": ["method"] } }, + { + "required": ["method"], + "properties": { "method": { "const": "tools/call" } } + } + ] + } + } + ] + }, + "mcpToolSelectorRule": { + "type": "object", + "required": ["allow"], + "properties": { + "allow": { "$ref": "#/$defs/mcpToolSelectorMatcher" } + } + }, + "mcpToolSelectorMatcher": { + "type": "object", + "anyOf": [{ "required": ["tool"] }, { "required": ["params"] }] + }, + "mcpBroadToolsCallRule": { + "type": "object", + "required": ["allow"], + "properties": { + "allow": { "$ref": "#/$defs/mcpBroadToolsCallMatcher" } + } + }, + "mcpBroadToolsCallMatcher": { + "type": "object", + "required": ["method"], + "properties": { + "method": { "$ref": "#/$defs/mcpBroadToolsCallMethod" } + }, + "not": { + "anyOf": [{ "required": ["tool"] }, { "required": ["params"] }] + } + }, + "mcpBroadToolsCallMethod": { + "anyOf": [ + { "const": "tools/call" }, + { + "type": "string", + "pattern": "^tools/.*[*?\\[\\]{}].*$" + } + ] + }, + "mcpMethodRule": { + "type": "object", + "required": ["allow"], + "additionalProperties": false, + "properties": { + "allow": { "$ref": "#/$defs/mcpMethodMatcher" } + } + }, + "mcpMethodMatcher": { + "allOf": [ + { "$ref": "#/$defs/mcpMatcher" }, + { "required": ["method"] } + ] + }, + "mcpExactToolRule": { + "type": "object", + "required": ["allow"], + "additionalProperties": false, + "properties": { + "allow": { "$ref": "#/$defs/mcpExactToolMatcher" } + } + }, + "mcpExactToolMatcher": { + "allOf": [ + { "$ref": "#/$defs/mcpMatcher" }, + { + "properties": { + "tool": { "$ref": "#/$defs/exactMatcher" }, + "params": { + "properties": { + "name": { "$ref": "#/$defs/exactMatcher" } + } + } + } + } + ] + }, + "rpcMethod": { + "type": "string", + "minLength": 1, + "pattern": "^(\\*|[^*?\\[\\]{}]+)$" + }, + "mcpMethod": { + "type": "string", + "minLength": 1, + "pattern": "^(?:[^*?\\[\\]{}]+|tools/.*[*?\\[\\]{}].*)$" + }, "matcher": { "oneOf": [ { "type": "string", "minLength": 1 }, @@ -161,6 +498,31 @@ } ] }, + "exactMatcher": { + "oneOf": [ + { + "type": "string", + "minLength": 1, + "pattern": "^[^*?\\[\\]{}]+$" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "any": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[^*?\\[\\]{}]+$" + }, + "minItems": 1 + } + }, + "required": ["any"] + } + ] + }, "paramMatcher": { "oneOf": [ { "$ref": "#/$defs/matcher" }, diff --git a/schemas/sandbox-policy.schema.json b/schemas/sandbox-policy.schema.json index 4bf75276eab..a840288cddb 100644 --- a/schemas/sandbox-policy.schema.json +++ b/schemas/sandbox-policy.schema.json @@ -73,6 +73,7 @@ "properties": { "host": { "type": "string" }, "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "path": { "type": "string", "pattern": "^/" }, "protocol": { "type": "string", "enum": ["rest", "websocket", "json-rpc", "mcp"] }, "enforcement": { "type": "string", "enum": ["enforce", "audit"] }, "tls": { "type": "string", "enum": ["terminate", "passthrough", "skip"] }, @@ -100,10 +101,43 @@ "allOf": [ { "if": { - "properties": { "protocol": { "enum": ["rest", "websocket"] } }, + "anyOf": [ + { "not": { "required": ["protocol"] } }, + { + "properties": { "protocol": { "const": "rest" } }, + "required": ["protocol"] + } + ] + }, + "then": { + "properties": { + "rules": { + "items": { "$ref": "#/$defs/restRule" } + }, + "deny_rules": { + "items": { "$ref": "#/$defs/restMatcher" } + } + }, + "anyOf": [ + { "required": ["rules"] }, + { "required": ["access"] } + ] + } + }, + { + "if": { + "properties": { "protocol": { "const": "websocket" } }, "required": ["protocol"] }, "then": { + "properties": { + "rules": { + "items": { "$ref": "#/$defs/websocketRule" } + }, + "deny_rules": { + "items": { "$ref": "#/$defs/websocketMatcher" } + } + }, "anyOf": [ { "required": ["rules"] }, { "required": ["access"] } @@ -117,6 +151,14 @@ }, "then": { "required": ["rules"], + "properties": { + "rules": { + "items": { "$ref": "#/$defs/jsonRpcRule" } + }, + "deny_rules": { + "items": { "$ref": "#/$defs/jsonRpcMatcher" } + } + }, "not": { "required": ["access"] } } }, @@ -126,6 +168,14 @@ "required": ["protocol"] }, "then": { + "properties": { + "rules": { + "items": { "$ref": "#/$defs/mcpRule" } + }, + "deny_rules": { + "items": { "$ref": "#/$defs/mcpMatcher" } + } + }, "not": { "required": ["access"] }, "anyOf": [ { "required": ["rules"] }, @@ -142,6 +192,103 @@ } ] } + }, + { + "if": { + "properties": { "protocol": { "const": "mcp" } }, + "required": ["protocol"], + "not": { + "required": ["mcp"], + "properties": { + "mcp": { + "required": ["allow_all_known_mcp_methods"], + "properties": { + "allow_all_known_mcp_methods": { "const": true } + } + } + } + } + }, + "then": { + "properties": { + "rules": { + "items": { "$ref": "#/$defs/mcpMethodRule" } + }, + "deny_rules": { + "items": { "$ref": "#/$defs/mcpMethodMatcher" } + } + } + } + }, + { + "if": { + "properties": { + "protocol": { "const": "mcp" }, + "mcp": { + "required": ["strict_tool_names"], + "properties": { "strict_tool_names": { "const": false } } + } + }, + "required": ["protocol", "mcp"] + }, + "then": { + "properties": { + "rules": { + "items": { "$ref": "#/$defs/mcpExactToolRule" } + }, + "deny_rules": { + "items": { "$ref": "#/$defs/mcpExactToolMatcher" } + } + } + } + }, + { + "if": { + "anyOf": [ + { "not": { "required": ["protocol"] } }, + { + "properties": { "protocol": { "not": { "const": "mcp" } } }, + "required": ["protocol"] + } + ] + }, + "then": { + "properties": { + "mcp": { + "not": { + "anyOf": [ + { "required": ["strict_tool_names"] }, + { "required": ["allow_all_known_mcp_methods"] } + ] + } + } + } + } + }, + { + "if": { + "properties": { + "protocol": { "const": "mcp" }, + "rules": { + "contains": { "$ref": "#/$defs/mcpToolSelectorRule" } + } + }, + "required": ["protocol", "rules"] + }, + "then": { + "properties": { + "rules": { + "not": { + "contains": { "$ref": "#/$defs/mcpBroadToolsCallRule" } + } + }, + "deny_rules": { + "not": { + "contains": { "$ref": "#/$defs/mcpBroadToolsCallMatcher" } + } + } + } + } } ] }, @@ -169,6 +316,196 @@ } } }, + "restRule": { + "type": "object", + "required": ["allow"], + "additionalProperties": false, + "properties": { + "allow": { "$ref": "#/$defs/restMatcher" } + } + }, + "restMatcher": { + "type": "object", + "required": ["method", "path"], + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + "*" + ] + }, + "path": { "type": "string", "pattern": "^/" } + } + }, + "websocketRule": { + "type": "object", + "required": ["allow"], + "additionalProperties": false, + "properties": { + "allow": { "$ref": "#/$defs/websocketMatcher" } + } + }, + "websocketMatcher": { + "type": "object", + "required": ["method", "path"], + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "enum": ["GET", "WEBSOCKET_TEXT", "*"] + }, + "path": { "type": "string", "pattern": "^/" } + } + }, + "jsonRpcRule": { + "type": "object", + "required": ["allow"], + "additionalProperties": false, + "properties": { + "allow": { "$ref": "#/$defs/jsonRpcMatcher" } + } + }, + "jsonRpcMatcher": { + "type": "object", + "required": ["method"], + "additionalProperties": false, + "properties": { + "method": { "$ref": "#/$defs/rpcMethod" } + } + }, + "mcpRule": { + "type": "object", + "required": ["allow"], + "additionalProperties": false, + "properties": { + "allow": { "$ref": "#/$defs/mcpMatcher" } + } + }, + "mcpMatcher": { + "type": "object", + "additionalProperties": false, + "properties": { + "method": { "$ref": "#/$defs/mcpMethod" }, + "tool": { "$ref": "#/$defs/matcher" }, + "params": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { "$ref": "#/$defs/matcher" } + }, + "required": ["name"] + } + }, + "allOf": [ + { "not": { "required": ["tool", "params"] } }, + { + "if": { + "anyOf": [{ "required": ["tool"] }, { "required": ["params"] }] + }, + "then": { + "anyOf": [ + { "not": { "required": ["method"] } }, + { + "required": ["method"], + "properties": { "method": { "const": "tools/call" } } + } + ] + } + } + ] + }, + "mcpToolSelectorRule": { + "type": "object", + "required": ["allow"], + "properties": { + "allow": { "$ref": "#/$defs/mcpToolSelectorMatcher" } + } + }, + "mcpToolSelectorMatcher": { + "type": "object", + "anyOf": [{ "required": ["tool"] }, { "required": ["params"] }] + }, + "mcpBroadToolsCallRule": { + "type": "object", + "required": ["allow"], + "properties": { + "allow": { "$ref": "#/$defs/mcpBroadToolsCallMatcher" } + } + }, + "mcpBroadToolsCallMatcher": { + "type": "object", + "required": ["method"], + "properties": { + "method": { "$ref": "#/$defs/mcpBroadToolsCallMethod" } + }, + "not": { + "anyOf": [{ "required": ["tool"] }, { "required": ["params"] }] + } + }, + "mcpBroadToolsCallMethod": { + "anyOf": [ + { "const": "tools/call" }, + { + "type": "string", + "pattern": "^tools/.*[*?\\[\\]{}].*$" + } + ] + }, + "mcpMethodRule": { + "type": "object", + "required": ["allow"], + "additionalProperties": false, + "properties": { + "allow": { "$ref": "#/$defs/mcpMethodMatcher" } + } + }, + "mcpMethodMatcher": { + "allOf": [ + { "$ref": "#/$defs/mcpMatcher" }, + { "required": ["method"] } + ] + }, + "mcpExactToolRule": { + "type": "object", + "required": ["allow"], + "additionalProperties": false, + "properties": { + "allow": { "$ref": "#/$defs/mcpExactToolMatcher" } + } + }, + "mcpExactToolMatcher": { + "allOf": [ + { "$ref": "#/$defs/mcpMatcher" }, + { + "properties": { + "tool": { "$ref": "#/$defs/exactMatcher" }, + "params": { + "properties": { + "name": { "$ref": "#/$defs/exactMatcher" } + } + } + } + } + ] + }, + "rpcMethod": { + "type": "string", + "minLength": 1, + "pattern": "^(\\*|[^*?\\[\\]{}]+)$" + }, + "mcpMethod": { + "type": "string", + "minLength": 1, + "pattern": "^(?:[^*?\\[\\]{}]+|tools/.*[*?\\[\\]{}].*)$" + }, "matcher": { "oneOf": [ { "type": "string", "minLength": 1 }, @@ -186,6 +523,31 @@ } ] }, + "exactMatcher": { + "oneOf": [ + { + "type": "string", + "minLength": 1, + "pattern": "^[^*?\\[\\]{}]+$" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "any": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[^*?\\[\\]{}]+$" + }, + "minItems": 1 + } + }, + "required": ["any"] + } + ] + }, "paramMatcher": { "oneOf": [ { "$ref": "#/$defs/matcher" }, diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-capability.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-capability.ts new file mode 100644 index 00000000000..b6bbcaa64f9 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-capability.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { McpBridgeError } from "./mcp-bridge-contracts"; +import { executeSandboxCommand } from "./process-recovery"; + +const DEEPAGENTS_MCP_CAPABILITY_MARKER = "NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=2"; +const DEEPAGENTS_MCP_CAPABILITY_COMMAND = + "/usr/local/bin/deepagents-code --nemoclaw-mcp-capability"; + +export function assertDeepAgentsMcpMutationRuntimeCapability(sandboxName: string): void { + const result = executeSandboxCommand(sandboxName, DEEPAGENTS_MCP_CAPABILITY_COMMAND); + if (result?.status !== 0 || result.stdout.trim() !== DEEPAGENTS_MCP_CAPABILITY_MARKER) { + throw new McpBridgeError( + `LangChain Deep Agents Code sandbox '${sandboxName}' does not contain managed MCP capability v2. Rebuild the sandbox before changing authenticated MCP state.`, + ); + } +} diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-command.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-command.ts new file mode 100644 index 00000000000..f7994e40eb9 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-command.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { McpBridgeEntry } from "../../state/registry"; +import type { AdapterMutationOptions } from "./mcp-bridge-adapter-inspection"; +import { McpBridgeError } from "./mcp-bridge-contracts"; +import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; +import { executeSandboxCommand } from "./process-recovery"; + +export function runDeepAgentsAdapterCommand( + sandboxName: string, + entry: Pick, + command: string, + failureMessage: string, + options: AdapterMutationOptions = {}, +): string { + const result = executeSandboxCommand(sandboxName, command); + const output = redactBridgeSecretsForDisplay( + [result?.stdout, result?.stderr].filter(Boolean).join("\n").trim(), + entry, + options.envValues ?? {}, + ); + if (!result || result.status !== 0) { + if (options.bestEffort) return ""; + throw new McpBridgeError(output || failureMessage); + } + return result.stdout; +} diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-inspection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-inspection.ts new file mode 100644 index 00000000000..42f607827a7 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-inspection.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { McpBridgeEntry } from "../../state/registry"; +import { + type AdapterRegistrationInspection, + inspectAdapterRegistrationCommand, +} from "./mcp-bridge-adapter-inspection"; +import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; + +export function inspectDeepAgentsAdapterRegistration( + sandboxName: string, + entry: McpBridgeEntry, +): AdapterRegistrationInspection { + return inspectAdapterRegistrationCommand( + sandboxName, + entry, + buildDeepAgentsMcpStatusCommand(entry), + ); +} diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy-teardown.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy-teardown.test.ts new file mode 100644 index 00000000000..1a9bcfa3735 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy-teardown.test.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + baseEntry, + runDeepAgentsConfigCommand, +} from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; +import { buildDeepAgentsMcpRemoveCommand } from "./mcp-bridge-adapter-deepagents"; + +describe("Deep Agents MCP config adapter legacy teardown", () => { + it("surgically removes an exact legacy entry and preserves user-owned content", () => { + const managedServer = { + type: "http", + url: baseEntry.url, + headers: { Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN" }, + }; + const userServer = { type: "stdio", command: "user-owned" }; + const legacyConfig = { + mcpServers: { github: managedServer, local: userServer }, + ui: { theme: "dark" }, + }; + + const removal = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRemoveCommand(baseEntry, false, true), + undefined, + "legacy", + legacyConfig, + ); + + expect(removal.status, removal.stderr).toBe(0); + expect(removal.stdout.trim()).toBe("NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=removed"); + expect(removal.configExists).toBe(false); + expect(removal.legacyConfig).toEqual({ + mcpServers: { local: userServer }, + ui: { theme: "dark" }, + }); + }); + + it("treats legacy absence as proved and refuses drift unless force can remove one slot", () => { + const userServer = { type: "stdio", command: "user-owned" }; + const absentConfig = { mcpServers: { local: userServer }, ui: { theme: "dark" } }; + const absent = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRemoveCommand(baseEntry, false, true), + undefined, + "legacy", + absentConfig, + ); + expect(absent.status, absent.stderr).toBe(0); + expect(absent.stdout.trim()).toBe("NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=absent"); + expect(absent.legacyConfig).toEqual(absentConfig); + + const driftedConfig = { + mcpServers: { + github: { type: "http", url: "https://user.example/mcp" }, + local: userServer, + }, + ui: { theme: "dark" }, + }; + const refused = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRemoveCommand(baseEntry, false, true), + undefined, + "legacy", + driftedConfig, + ); + expect(refused.status, refused.stderr).toBe(0); + expect(refused.stdout.trim()).toBe("NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=unowned"); + expect(refused.legacyConfig).toEqual(driftedConfig); + + const forced = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRemoveCommand(baseEntry, true, true), + undefined, + "legacy", + driftedConfig, + ); + expect(forced.status, forced.stderr).toBe(0); + expect(forced.stdout.trim()).toBe("NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=removed"); + expect(forced.legacyConfig).toEqual({ + mcpServers: { local: userServer }, + ui: { theme: "dark" }, + }); + }); +}); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts new file mode 100644 index 00000000000..8e453b24ac1 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { McpBridgeEntry } from "../../state/registry"; +import { + DEEPAGENTS_MANAGED_PROJECTION_HELPERS, + DEEPAGENTS_MCP_MAX_SERVERS, + DEEPAGENTS_STRICT_JSON_HELPERS, +} from "./mcp-bridge-adapter-deepagents-projection"; +import { + DEEPAGENTS_MCP_CONFIG_PATH, + deepAgentsManagedServerConfig, + pythonJsonLiteral, +} from "./mcp-bridge-adapter-status"; + +// Source-of-truth review for the legacy compatibility boundary: +// invalidState: a v1 sandbox keeps NemoClaw's owned server inside the mutable, +// user-shared .mcp.json file, while current images use a dedicated projection. +// sourceBoundary: the surviving v1 Deep Agents runtime selects the legacy path; +// the host registry remains authoritative for the exact entry NemoClaw owns. +// whyNotSourceFix: replacing the image before teardown would strand its provider +// and policy, so old images must be scrubbed and rolled back in their own format. +// regressionTest: focused legacy teardown, rollback, drift, duplicate-key, mode, +// and runtime-generation suites execute the rendered helper against real files. +// removalCondition: delete this compatibility module after supported releases can +// no longer contain registry-owned v1 entries and the migration window has ended. +export const DEEPAGENTS_LEGACY_MCP_CONFIG_PATH = "/sandbox/.deepagents/.mcp.json"; + +export const DEEPAGENTS_LEGACY_CONFIG_HELPERS = [ + "LEGACY_MCP_MAX_BYTES = 262144", + "def legacy_fingerprint(metadata):", + " return (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns, metadata.st_mode, metadata.st_nlink, metadata.st_uid)", + "def read_legacy_config(path):", + " flags = os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW", + " descriptor = os.open(path, flags)", + " try:", + " before = os.fstat(descriptor)", + " linked = os.stat(path, follow_symlinks=False)", + " safe = (stat.S_ISREG(before.st_mode) and before.st_uid == os.getuid() and stat.S_IMODE(before.st_mode) == 0o600 and before.st_nlink == 1 and (before.st_dev, before.st_ino) == (linked.st_dev, linked.st_ino))", + " if not safe:", + " raise ValueError('legacy MCP config has unsafe ownership, mode, type, or links')", + " if before.st_size <= 0 or before.st_size > LEGACY_MCP_MAX_BYTES:", + " raise ValueError('legacy MCP config has invalid size')", + " chunks = []", + " remaining = before.st_size", + " while remaining:", + " chunk = os.read(descriptor, remaining)", + " if not chunk:", + " break", + " chunks.append(chunk)", + " remaining -= len(chunk)", + " after = os.fstat(descriptor)", + " linked_after = os.stat(path, follow_symlinks=False)", + " stable = (legacy_fingerprint(before) == legacy_fingerprint(after) and legacy_fingerprint(after) == legacy_fingerprint(linked_after))", + " if remaining or not stable:", + " raise ValueError('legacy MCP config changed while reading')", + " finally:", + " os.close(descriptor)", + " raw = b''.join(chunks).decode('utf-8')", + " data = strict_json_loads(raw)", + " return data, legacy_fingerprint(before)", + "def assert_legacy_source_stable(path, identity):", + " if identity is None:", + " if os.path.lexists(path):", + " raise ValueError('legacy MCP config appeared during mutation')", + " return", + " current = os.stat(path, follow_symlinks=False)", + " safe = (stat.S_ISREG(current.st_mode) and current.st_uid == os.getuid() and stat.S_IMODE(current.st_mode) == 0o600 and current.st_nlink == 1 and legacy_fingerprint(current) == identity)", + " if not safe:", + " raise ValueError('legacy MCP config changed before mutation')", +]; + +export function buildDeepAgentsMcpRollbackRegisterCommand( + entry: McpBridgeEntry, + expectedServers: Record>, +): string { + const payload = { + server: entry.server, + expected: deepAgentsManagedServerConfig(entry), + expectedServers, + }; + return [ + "/opt/venv/bin/python3 -I - <<'PY'", + "import json, os, pathlib, stat, sys, tempfile", + `payload = json.loads(${pythonJsonLiteral(payload)})`, + `managed_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, + `legacy_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_LEGACY_MCP_CONFIG_PATH)})`, + ...DEEPAGENTS_STRICT_JSON_HELPERS, + ...DEEPAGENTS_MANAGED_PROJECTION_HELPERS, + ...DEEPAGENTS_LEGACY_CONFIG_HELPERS, + `runtime_kind = "auto" # NEMOCLAW_DEEPAGENTS_RUNTIME_TEST_ANCHOR`, + "if runtime_kind == 'auto':", + " runtime_kind = 'unknown'", + " try:", + " from deepagents_code import _nemoclaw_managed as managed", + " runtime_path = str(getattr(managed, '_MCP_CONFIG_FILE', ''))", + " if runtime_path == str(managed_path):", + " runtime_kind = 'v2'", + " elif runtime_path == str(legacy_path):", + " runtime_kind = 'legacy'", + " except Exception:", + " pass", + "if runtime_kind not in ('v2', 'legacy'):", + " print('Could not identify the managed Deep Agents MCP runtime; refusing rollback', file=sys.stderr)", + " raise SystemExit(2)", + "is_v2 = runtime_kind == 'v2'", + `if is_v2 and len(payload['expectedServers']) > ${String(DEEPAGENTS_MCP_MAX_SERVERS)}:`, + ` print('Managed MCP v2 supports at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers', file=sys.stderr)`, + " raise SystemExit(2)", + "config_path = managed_path if is_v2 else legacy_path", + "data = {}", + "managed_identity = None", + "managed_descriptor = None", + "legacy_identity = None", + "def fail_rollback(message):", + " close_managed_projection_descriptor(managed_descriptor)", + " print(message, file=sys.stderr)", + " raise SystemExit(2)", + "try:", + " if is_v2:", + " data, managed_identity, managed_descriptor = load_managed_projection_for_update(config_path)", + " elif os.path.lexists(config_path):", + " data, legacy_identity = read_legacy_config(config_path)", + "except (OSError, UnicodeDecodeError, ValueError) as exc:", + " fail_rollback(f'Invalid managed MCP rollback state at {config_path}: {exc}')", + "if not isinstance(data, dict):", + " fail_rollback(f'Invalid managed MCP rollback state at {config_path}: expected object')", + "if is_v2:", + " if data and set(data) != {'mcpServers'}:", + " fail_rollback(f'Invalid managed MCP v2 projection at {config_path}')", + " servers = data.get('mcpServers', {})", + " if not isinstance(servers, dict):", + " fail_rollback(f'Invalid managed MCP v2 server map at {config_path}')", + " if any(payload['expectedServers'].get(name) != current for name, current in servers.items()):", + " fail_rollback(f'Refusing to overwrite drifted managed MCP v2 projection at {config_path}')", + " data = {'mcpServers': payload['expectedServers']}", + "else:", + " servers = data.setdefault('mcpServers', {})", + " if not isinstance(servers, dict):", + " fail_rollback(f'Refusing to overwrite mixed legacy MCP state at {config_path}')", + " current = servers.get(payload['server'])", + " if payload['server'] in servers and current != payload['expected']:", + " fail_rollback(f'Refusing to overwrite user-owned legacy MCP server at {config_path}')", + " servers[payload['server']] = payload['expected']", + "config_path.parent.mkdir(parents=True, exist_ok=True)", + "if not is_v2:", + " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=config_path.parent)", + " try:", + " os.fchmod(tmp_fd, 0o600)", + " with os.fdopen(tmp_fd, 'w', encoding='utf-8') as tmp_file:", + " json.dump(data, tmp_file, indent=2, sort_keys=True)", + " tmp_file.write('\\n')", + " tmp_file.flush()", + " os.fsync(tmp_file.fileno())", + " assert_legacy_source_stable(config_path, legacy_identity)", + " if legacy_identity is None:", + " os.link(tmp_name, config_path, follow_symlinks=False)", + " os.unlink(tmp_name)", + " else:", + " os.replace(tmp_name, config_path)", + " finally:", + " try:", + " os.unlink(tmp_name)", + " except FileNotFoundError:", + " pass", + "else:", + " try:", + " write_managed_projection(config_path, data, managed_identity, managed_descriptor)", + " except (OSError, ValueError) as exc:", + " fail_rollback(f'Could not publish managed MCP rollback state at {config_path}: {exc}')", + "try:", + " persisted = read_managed_projection(config_path)[0] if is_v2 else read_legacy_config(config_path)[0]", + "except (OSError, UnicodeDecodeError, ValueError) as exc:", + " fail_rollback(f'Could not verify managed MCP rollback state at {config_path}: {exc}')", + "if is_v2:", + " restored = persisted == {'mcpServers': payload['expectedServers']}", + "else:", + " persisted_servers = persisted.get('mcpServers') if isinstance(persisted, dict) else None", + " restored = isinstance(persisted_servers, dict) and persisted_servers.get(payload['server']) == payload['expected']", + "if not restored:", + " fail_rollback(f'Managed MCP rollback verification failed at {config_path}')", + "print('NEMOCLAW_DEEPAGENTS_MCP_ROLLBACK_RESTORED=1')", + "PY", + ].join("\n"); +} diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts new file mode 100644 index 00000000000..7599edec142 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -0,0 +1,201 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + baseEntry, + runDeepAgentsConfigCommand, +} from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; +import type { McpBridgeEntry } from "../../state/registry"; +import { + buildDeepAgentsMcpRegisterCommand, + buildDeepAgentsMcpRemoveCommand, +} from "./mcp-bridge-adapter-deepagents"; +import { DEEPAGENTS_MCP_MAX_SERVERS } from "./mcp-bridge-adapter-deepagents-projection"; +import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; + +const emptyProjection = { mcpServers: {} }; +const duplicateProjection = '{"mcpServers":{},"mcpServers":{"shadow":{}}}\n'; +const attackerProjection = '{"mcpServers":{"attacker":{"type":"stdio"}}}\n'; + +const registrationCommand = buildDeepAgentsMcpRegisterCommand(baseEntry); +const rollbackCommand = buildDeepAgentsMcpRegisterCommand(baseEntry, true, [baseEntry], true); +const removalCommand = buildDeepAgentsMcpRemoveCommand(baseEntry); + +describe("Deep Agents managed MCP projection safety", () => { + it("uses the isolated runtime and one stable-read contract for every v2 mutation", () => { + expect(registrationCommand).toMatch(/^\/opt\/venv\/bin\/python3 -I - <<'PY'/); + expect(buildDeepAgentsMcpStatusCommand(baseEntry)).toMatch( + /^\/opt\/venv\/bin\/python3 -I - <<'PY'/, + ); + + for (const command of [registrationCommand, rollbackCommand, removalCommand]) { + expect(command).toContain("os.O_NOFOLLOW"); + expect(command).toContain("os.fstat(descriptor)"); + expect(command).toContain("assert_managed_source_stable(path, identity)"); + expect(command).toContain("os.link(tmp_name, path, follow_symlinks=False)"); + expect(command).toContain("os.ftruncate(descriptor, 0)"); + expect(command).not.toContain("\n path.unlink()\n"); + expect(command).not.toContain("config_path.read_text"); + } + expect(rollbackCommand).toContain( + `len(payload['expectedServers']) > ${String(DEEPAGENTS_MCP_MAX_SERVERS)}`, + ); + const sizeCheckIndex = registrationCommand.indexOf("len(payload) > MANAGED_MCP_MAX_BYTES"); + const truncateIndex = registrationCommand.indexOf("os.ftruncate(descriptor, 0)"); + expect(sizeCheckIndex).toBeGreaterThanOrEqual(0); + expect(truncateIndex).toBeGreaterThanOrEqual(0); + expect(sizeCheckIndex).toBeLessThan(truncateIndex); + }); + + it("applies the shared server cap before normal and rollback v2 publication", () => { + const entries = Array.from( + { length: DEEPAGENTS_MCP_MAX_SERVERS + 1 }, + (_, index): McpBridgeEntry => ({ + ...baseEntry, + server: `server${String(index)}`, + env: [`SERVER_${String(index)}_TOKEN`], + }), + ); + + expect(() => buildDeepAgentsMcpRegisterCommand(entries[0], false, entries)).toThrow( + `at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, + ); + const rollback = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRegisterCommand(entries[0], true, entries, true), + ); + expect(rollback.status).toBe(2); + expect(rollback.stderr).toContain( + `supports at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, + ); + }); + + it("keeps status inspection nonblocking and no-follow for hostile projection paths", () => { + const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); + expect(statusCommand).toContain("os.O_NONBLOCK | os.O_NOFOLLOW"); + expect(statusCommand).not.toContain("config_path.read_text"); + const symlink = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { symlink: true }, + ); + expect(symlink.status, symlink.stderr).toBe(0); + expect(symlink.stdout.trim()).toBe("absent"); + expect(symlink.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); + + const fifo = runDeepAgentsConfigCommand(statusCommand, undefined, "v2", undefined, 0o600, { + fifo: true, + }); + expect(fifo.status, fifo.stderr).toBe(0); + expect(fifo.stdout.trim()).toBe("absent"); + }); + + it.each([ + ["registration", registrationCommand], + ["v2 rollback", rollbackCommand], + ])("rejects duplicate JSON and unsafe projection metadata during %s", (_name, command) => { + const duplicate = runDeepAgentsConfigCommand(command, duplicateProjection); + expect(duplicate.status).toBe(2); + expect(duplicate.stderr).toContain("duplicate JSON key: mcpServers"); + expect(duplicate.configText).toBe(duplicateProjection); + + const unsafeMode = runDeepAgentsConfigCommand( + command, + emptyProjection, + "v2", + undefined, + 0o600, + { mode: 0o644 }, + ); + expect(unsafeMode.status).toBe(2); + expect(unsafeMode.stderr).toContain("unsafe ownership, mode, type, links, or path identity"); + expect(unsafeMode.config).toEqual(emptyProjection); + + const symlink = runDeepAgentsConfigCommand(command, emptyProjection, "v2", undefined, 0o600, { + symlink: true, + }); + expect(symlink.status).toBe(2); + expect(symlink.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); + }); + + it("never clobbers a projection that appears during absent publication or fd rewrite", () => { + const absentRace = registrationCommand.replace( + " write_managed_projection(config_path, data, source_identity, source_descriptor)", + ` config_path.write_text(${JSON.stringify(attackerProjection)}, encoding='utf-8')\n os.chmod(config_path, 0o600)\n write_managed_projection(config_path, data, source_identity, source_descriptor)`, + ); + const absentResult = runDeepAgentsConfigCommand(absentRace); + expect(absentResult.status).toBe(2); + expect(absentResult.stderr).toContain("appeared during mutation"); + expect(absentResult.configText).toBe(attackerProjection); + + const existingRace = registrationCommand.replace( + " payload = managed_projection_bytes(value)\n os.lseek(descriptor, 0, os.SEEK_SET)", + ` payload = managed_projection_bytes(value)\n path.unlink()\n path.write_text(${JSON.stringify(attackerProjection)}, encoding='utf-8')\n os.chmod(path, 0o600)\n os.lseek(descriptor, 0, os.SEEK_SET)`, + ); + const existingResult = runDeepAgentsConfigCommand(existingRace, emptyProjection); + expect(existingResult.status).toBe(2); + expect(existingResult.stderr).toContain("links, or path identity"); + expect(existingResult.configText).toBe(attackerProjection); + }); + + it("keeps forced removal identity-bound for malformed files and symlinks", () => { + const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); + const racedCommand = forcedCommand.replace( + " payload = managed_projection_bytes(value)\n os.lseek(descriptor, 0, os.SEEK_SET)", + ` payload = managed_projection_bytes(value)\n path.unlink()\n path.write_text(${JSON.stringify(attackerProjection)}, encoding='utf-8')\n os.chmod(path, 0o600)\n os.lseek(descriptor, 0, os.SEEK_SET)`, + ); + const raced = runDeepAgentsConfigCommand(racedCommand, { ui: { theme: "dark" } }); + expect(raced.status).toBe(2); + expect(raced.stderr).toContain("Refusing unsafe managed MCP v2 repair"); + expect(raced.stderr).not.toContain("Traceback"); + expect(raced.configText).toBe(attackerProjection); + + const forcedSymlink = runDeepAgentsConfigCommand( + forcedCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { symlink: true }, + ); + expect(forcedSymlink.status).toBe(2); + expect(forcedSymlink.configExists).toBe(true); + expect(forcedSymlink.managedSymlinkTargetExists).toBe(true); + expect(forcedSymlink.managedSymlinkTargetText).toBe( + `${JSON.stringify(emptyProjection, null, 2)}\n`, + ); + + const forcedUnsafeMode = runDeepAgentsConfigCommand( + forcedCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { mode: 0o644 }, + ); + expect(forcedUnsafeMode.status).toBe(2); + expect(forcedUnsafeMode.config).toEqual(emptyProjection); + + const forcedFifo = runDeepAgentsConfigCommand( + forcedCommand, + undefined, + "v2", + undefined, + 0o600, + { fifo: true }, + ); + expect(forcedFifo.status).toBe(2); + expect(forcedFifo.configExists).toBe(true); + + const duplicate = runDeepAgentsConfigCommand(removalCommand, duplicateProjection); + expect(duplicate.status).toBe(2); + expect(duplicate.configText).toBe(duplicateProjection); + + const forcedDuplicate = runDeepAgentsConfigCommand(forcedCommand, duplicateProjection); + expect(forcedDuplicate.status, forcedDuplicate.stderr).toBe(0); + expect(forcedDuplicate.config).toEqual(emptyProjection); + }); +}); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts new file mode 100644 index 00000000000..357bfcfbbc3 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const DEEPAGENTS_MCP_MAX_SERVERS = 64; + +export const DEEPAGENTS_STRICT_JSON_HELPERS = [ + "def reject_duplicate_keys(pairs):", + " result = {}", + " for key, value in pairs:", + " if key in result:", + " raise ValueError(f'duplicate JSON key: {key}')", + " result[key] = value", + " return result", + "def reject_non_json_constant(value):", + " raise ValueError(f'non-JSON numeric constant: {value}')", + "def strict_json_loads(raw):", + " return json.loads(raw, object_pairs_hook=reject_duplicate_keys, parse_constant=reject_non_json_constant)", +]; + +export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ + "MANAGED_MCP_MAX_BYTES = 262144", + "def managed_fingerprint(metadata):", + " return (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns, metadata.st_mode, metadata.st_nlink, metadata.st_uid)", + "def managed_path_identity(path):", + " try:", + " return managed_fingerprint(os.stat(path, follow_symlinks=False))", + " except FileNotFoundError:", + " return None", + "def assert_managed_source_stable(path, identity):", + " current = managed_path_identity(path)", + " if identity is None:", + " if current is not None:", + " raise ValueError('managed MCP projection appeared during mutation')", + " return", + " if current != identity:", + " raise ValueError('managed MCP projection changed before mutation')", + "def validate_managed_descriptor_path(path, descriptor):", + " opened = os.fstat(descriptor)", + " linked = os.stat(path, follow_symlinks=False)", + " safe = (stat.S_ISREG(opened.st_mode) and opened.st_uid == os.getuid() and stat.S_IMODE(opened.st_mode) == 0o600 and opened.st_nlink == 1 and (opened.st_dev, opened.st_ino) == (linked.st_dev, linked.st_ino))", + " if not safe:", + " raise ValueError('managed MCP projection has unsafe ownership, mode, type, links, or path identity')", + " return managed_fingerprint(opened)", + "def open_managed_projection(path, writable=False):", + " access = os.O_RDWR if writable else os.O_RDONLY", + " flags = access | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW", + " try:", + " descriptor = os.open(path, flags)", + " except FileNotFoundError:", + " assert_managed_source_stable(path, None)", + " return b'', None, None", + " try:", + " before = os.fstat(descriptor)", + " validate_managed_descriptor_path(path, descriptor)", + " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", + " raise ValueError('managed MCP projection has invalid size')", + " chunks = []", + " remaining = before.st_size", + " while remaining:", + " chunk = os.read(descriptor, remaining)", + " if not chunk:", + " break", + " chunks.append(chunk)", + " remaining -= len(chunk)", + " after = os.fstat(descriptor)", + " linked_after = os.stat(path, follow_symlinks=False)", + " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", + " if remaining or not stable:", + " raise ValueError('managed MCP projection changed while reading')", + " return b''.join(chunks), managed_fingerprint(after), descriptor", + " except Exception:", + " os.close(descriptor)", + " raise", + "def decode_managed_projection(raw):", + " return strict_json_loads(raw.decode('utf-8')) if raw else {}", + "def close_managed_projection_descriptor(descriptor):", + " if descriptor is None:", + " return", + " try:", + " os.close(descriptor)", + " except OSError:", + " pass", + "def load_managed_projection_for_update(path):", + " raw, identity, descriptor = open_managed_projection(path, True)", + " try:", + " return decode_managed_projection(raw), identity, descriptor", + " except Exception:", + " close_managed_projection_descriptor(descriptor)", + " raise", + "def read_managed_projection(path):", + " raw, identity, descriptor = open_managed_projection(path)", + " try:", + " return decode_managed_projection(raw), identity", + " finally:", + " close_managed_projection_descriptor(descriptor)", +]; + +export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ + "def managed_projection_bytes(value):", + " payload = (json.dumps(value, indent=2, sort_keys=True) + '\\n').encode('utf-8')", + " if not payload or len(payload) > MANAGED_MCP_MAX_BYTES:", + " raise ValueError('managed MCP projection has invalid rendered size')", + " return payload", + "def rewrite_managed_projection(path, value, identity, descriptor):", + " if identity is None or descriptor is None:", + " raise ValueError('managed MCP projection descriptor is unavailable')", + " assert_managed_source_stable(path, identity)", + " payload = managed_projection_bytes(value)", + " os.lseek(descriptor, 0, os.SEEK_SET)", + " os.ftruncate(descriptor, 0)", + " offset = 0", + " while offset < len(payload):", + " written = os.write(descriptor, payload[offset:])", + " if written <= 0:", + " raise OSError('managed MCP projection write made no progress')", + " offset += written", + " os.fsync(descriptor)", + " os.lseek(descriptor, 0, os.SEEK_SET)", + " persisted = os.read(descriptor, len(payload) + 1)", + " if persisted != payload or os.fstat(descriptor).st_size != len(payload):", + " raise ValueError('managed MCP projection verification failed')", + " validate_managed_descriptor_path(path, descriptor)", + "def publish_absent_managed_projection(path, value):", + " assert_managed_source_stable(path, None)", + " payload = managed_projection_bytes(value)", + " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=path.parent)", + " try:", + " os.fchmod(tmp_fd, 0o600)", + " with os.fdopen(tmp_fd, 'wb') as tmp_file:", + " tmp_file.write(payload)", + " tmp_file.flush()", + " os.fsync(tmp_file.fileno())", + " try:", + " os.link(tmp_name, path, follow_symlinks=False)", + " except FileExistsError as exc:", + " raise ValueError('managed MCP projection appeared during publication') from exc", + " os.unlink(tmp_name)", + " finally:", + " try:", + " os.unlink(tmp_name)", + " except FileNotFoundError:", + " pass", + " persisted, _ = read_managed_projection(path)", + " if persisted != value:", + " raise ValueError('managed MCP projection verification failed')", + "def write_managed_projection(path, value, identity, descriptor):", + " if identity is None:", + " if descriptor is not None:", + " raise ValueError('unexpected managed MCP projection descriptor')", + " publish_absent_managed_projection(path, value)", + " else:", + " try:", + " rewrite_managed_projection(path, value, identity, descriptor)", + " finally:", + " close_managed_projection_descriptor(descriptor)", +]; + +export const DEEPAGENTS_MANAGED_PROJECTION_HELPERS = [ + ...DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS, + ...DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS, +]; diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.test.ts new file mode 100644 index 00000000000..698b05de4e8 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.test.ts @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + baseEntry, + runDeepAgentsConfigCommand, +} from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; +import type { McpBridgeEntry } from "../../state/registry"; +import { buildDeepAgentsMcpRegisterCommand } from "./mcp-bridge-adapter-deepagents"; +import { DEEPAGENTS_MCP_CONFIG_PATH } from "./mcp-bridge-adapter-status"; + +describe("Deep Agents MCP config adapter registration", () => { + it("constructs a dedicated NemoClaw MCP projection with placeholders", () => { + const command = buildDeepAgentsMcpRegisterCommand(baseEntry); + + expect(DEEPAGENTS_MCP_CONFIG_PATH).toBe("/sandbox/.deepagents/.nemoclaw-mcp.json"); + expect(command).toContain(DEEPAGENTS_MCP_CONFIG_PATH); + expect(command).not.toContain('pathlib.Path("/sandbox/.mcp.json")'); + expect(command).toContain("mcpServers"); + expect(command).toContain('\\"type\\":\\"http\\"'); + expect(command).toContain("https://api.githubcopilot.com/mcp/"); + expect(command).toContain("openshell:resolve:env:GITHUB_TOKEN"); + expect(command).toContain("Invalid /sandbox/.deepagents/.nemoclaw-mcp.json"); + expect(command).toContain("mcpServers must be an object"); + expect(command).toContain("already exists in /sandbox/.deepagents/.nemoclaw-mcp.json"); + }); + + it("creates the Deep Agents config parent on first registration", () => { + const registration = runDeepAgentsConfigCommand(buildDeepAgentsMcpRegisterCommand(baseEntry)); + + expect(registration.status, registration.stderr).toBe(0); + expect(registration.configExists).toBe(true); + expect(registration.config).toEqual({ + mcpServers: { + github: { + type: "http", + url: "https://api.githubcopilot.com/mcp/", + headers: { + Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN", + }, + }, + }, + }); + }); + + it("rejects unowned config before registration mutates the file", () => { + const initialConfig = { ui: { theme: "dark" } }; + const registration = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRegisterCommand(baseEntry), + initialConfig, + ); + + expect(registration.status).toBe(2); + expect(registration.stderr).toContain("only mcpServers is allowed"); + expect(registration.config).toEqual(initialConfig); + }); + + it("renders the complete registry-owned server projection", () => { + const jiraEntry: McpBridgeEntry = { + ...baseEntry, + server: "jira", + url: "https://mcp.atlassian.com/v1/", + env: ["JIRA_MCP_TOKEN"], + providerName: "alpha-mcp-jira", + policyName: "mcp-bridge-jira", + }; + const registration = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRegisterCommand(jiraEntry, false, [baseEntry, jiraEntry]), + { + mcpServers: { + github: { + type: "http", + url: baseEntry.url, + headers: { + Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN", + }, + }, + }, + }, + ); + + expect(registration.status, registration.stderr).toBe(0); + expect(registration.config).toEqual({ + mcpServers: { + github: { + type: "http", + url: baseEntry.url, + headers: { Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN" }, + }, + jira: { + type: "http", + url: jiraEntry.url, + headers: { Authorization: "Bearer openshell:resolve:env:JIRA_MCP_TOKEN" }, + }, + }, + }); + }); + + it("rejects a 65-server projection before rendering a mutation command", () => { + const managedEntries = Array.from( + { length: 65 }, + (_, index): McpBridgeEntry => ({ + ...baseEntry, + server: `server${String(index)}`, + env: [`SERVER_${String(index)}_TOKEN`], + providerName: `alpha-mcp-server-${String(index)}`, + policyName: `mcp-bridge-server-${String(index)}`, + }), + ); + + expect(() => + buildDeepAgentsMcpRegisterCommand(managedEntries[0], false, managedEntries), + ).toThrow(/at most 64 servers.*refusing to render a 65-server mutation/); + expect(() => + buildDeepAgentsMcpRegisterCommand(managedEntries[0], false, managedEntries.slice(0, 64)), + ).not.toThrow(); + }); + + it("rejects an oversized rendered projection before truncating existing state", () => { + const initialConfig = { mcpServers: {} }; + const oversized = buildDeepAgentsMcpRegisterCommand(baseEntry).replace( + "data = {'mcpServers': payload['expectedServers']}", + "data = {'mcpServers': {'oversized': {'blob': 'x' * 300000}}}", + ); + const registration = runDeepAgentsConfigCommand(oversized, initialConfig); + + expect(registration.status).toBe(2); + expect(registration.stderr).toContain("invalid rendered size"); + expect(registration.config).toEqual(initialConfig); + }); +}); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts new file mode 100644 index 00000000000..54d02108ae1 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getSandbox, type McpBridgeEntry } from "../../state/registry"; +import { runDeepAgentsAdapterCommand } from "./mcp-bridge-adapter-deepagents-command"; +import { inspectDeepAgentsAdapterRegistration } from "./mcp-bridge-adapter-deepagents-inspection"; +import { buildDeepAgentsMcpRollbackRegisterCommand } from "./mcp-bridge-adapter-deepagents-legacy"; +import { + DEEPAGENTS_MANAGED_PROJECTION_HELPERS, + DEEPAGENTS_MCP_MAX_SERVERS, + DEEPAGENTS_STRICT_JSON_HELPERS, +} from "./mcp-bridge-adapter-deepagents-projection"; +import { + DEEPAGENTS_MCP_CONFIG_PATH, + deepAgentsManagedServerConfig, + pythonJsonLiteral, +} from "./mcp-bridge-adapter-status"; +import { McpBridgeError } from "./mcp-bridge-contracts"; + +export function buildDeepAgentsMcpRegisterCommand( + entry: McpBridgeEntry, + replaceExisting = false, + managedEntries: readonly McpBridgeEntry[] = [entry], + teardownRollback = false, +): string { + const expectedServers = Object.fromEntries( + managedEntries + .map((managedEntry): [string, Record] => [ + managedEntry.server, + deepAgentsManagedServerConfig(managedEntry), + ]) + .sort(([left], [right]) => left.localeCompare(right)), + ); + const expectedServerCount = Object.keys(expectedServers).length; + if (!teardownRollback && expectedServerCount > DEEPAGENTS_MCP_MAX_SERVERS) { + throw new McpBridgeError( + `Deep Agents managed MCP supports at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers; refusing to render a ${String(expectedServerCount)}-server mutation.`, + ); + } + if (teardownRollback) { + return buildDeepAgentsMcpRollbackRegisterCommand(entry, expectedServers); + } + const payload = { + server: entry.server, + expected: deepAgentsManagedServerConfig(entry), + expectedServers, + replaceExisting, + }; + return [ + "/opt/venv/bin/python3 -I - <<'PY'", + "import json, os, pathlib, stat, sys, tempfile", + `payload = json.loads(${pythonJsonLiteral(payload)})`, + `config_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, + ...DEEPAGENTS_STRICT_JSON_HELPERS, + ...DEEPAGENTS_MANAGED_PROJECTION_HELPERS, + "source_descriptor = None", + "def fail_registration(message):", + " close_managed_projection_descriptor(source_descriptor)", + " print(message, file=sys.stderr)", + " raise SystemExit(2)", + "try:", + " data, source_identity, source_descriptor = load_managed_projection_for_update(config_path)", + "except (OSError, UnicodeDecodeError, ValueError) as exc:", + ` fail_registration(f'Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}')`, + "if not isinstance(data, dict):", + ` fail_registration('Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: expected a JSON object')`, + "if data and set(data) != {'mcpServers'}:", + ` fail_registration('Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: only mcpServers is allowed')`, + "servers = data.setdefault('mcpServers', {})", + "if not isinstance(servers, dict):", + ` fail_registration('Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: mcpServers must be an object')`, + "if payload['server'] in servers and not payload['replaceExisting']:", + ` fail_registration(f"MCP server '{payload['server']}' already exists in ${DEEPAGENTS_MCP_CONFIG_PATH} and is not managed by NemoClaw.")`, + "for name, current in servers.items():", + " if name == payload['server'] and payload['replaceExisting']:", + " continue", + " if payload['expectedServers'].get(name) != current:", + ` fail_registration(f"Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: MCP server '{name}' is not exact registry-owned state")`, + "data = {'mcpServers': payload['expectedServers']}", + "config_path.parent.mkdir(parents=True, exist_ok=True)", + "try:", + " write_managed_projection(config_path, data, source_identity, source_descriptor)", + "except (OSError, ValueError) as exc:", + ` fail_registration(f'Could not publish ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}')`, + "PY", + ].join("\n"); +} + +function registryOwnedDeepAgentsEntries( + sandboxName: string, + entry: McpBridgeEntry, +): McpBridgeEntry[] { + const entries = new Map(); + const bridges = getSandbox(sandboxName)?.mcp?.bridges ?? {}; + for (const bridge of Object.values(bridges)) entries.set(bridge.server, bridge); + entries.set(entry.server, entry); + return [...entries.values()]; +} + +function verifyDeepAgentsAdapterRegistration(sandboxName: string, entry: McpBridgeEntry): void { + const inspection = inspectDeepAgentsAdapterRegistration(sandboxName, entry); + if (inspection.state === "registered") return; + const detail = inspection.state === "error" ? inspection.detail : inspection.state; + throw new McpBridgeError( + `deepagents-config config verification failed after adding '${entry.server}': ${detail}.`, + ); +} + +export function registerDeepAgentsAdapter( + sandboxName: string, + entry: McpBridgeEntry, + envValues: Record = {}, + replaceExisting = false, + teardownRollback = false, +): void { + const stdout = runDeepAgentsAdapterCommand( + sandboxName, + entry, + buildDeepAgentsMcpRegisterCommand( + entry, + replaceExisting, + registryOwnedDeepAgentsEntries(sandboxName, entry), + teardownRollback, + ), + `Deep Agents Code MCP config registration failed for '${entry.server}'.`, + { envValues }, + ); + if (teardownRollback) { + if (!stdout.includes("NEMOCLAW_DEEPAGENTS_MCP_ROLLBACK_RESTORED=1")) { + throw new McpBridgeError( + `Deep Agents Code MCP rollback verification failed for '${entry.server}'.`, + ); + } + } else { + verifyDeepAgentsAdapterRegistration(sandboxName, entry); + } +} diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-rollback.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-rollback.test.ts new file mode 100644 index 00000000000..c43034bf5a0 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-rollback.test.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + baseEntry, + runDeepAgentsConfigCommand, +} from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; +import type { McpBridgeEntry } from "../../state/registry"; +import { + buildDeepAgentsMcpRegisterCommand, + buildDeepAgentsMcpRemoveCommand, +} from "./mcp-bridge-adapter-deepagents"; + +describe("Deep Agents MCP config adapter rollback", () => { + it("restores one legacy entry on rollback without creating the v2 projection", () => { + const userServer = { type: "stdio", command: "user-owned" }; + const rollback = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRegisterCommand(baseEntry, true, [baseEntry], true), + undefined, + "legacy", + { mcpServers: { local: userServer }, ui: { theme: "dark" } }, + ); + + expect(rollback.status, rollback.stderr).toBe(0); + expect(rollback.stdout.trim()).toBe("NEMOCLAW_DEEPAGENTS_MCP_ROLLBACK_RESTORED=1"); + expect(rollback.configExists).toBe(false); + expect(rollback.legacyConfig).toEqual({ + mcpServers: { + github: { + type: "http", + url: baseEntry.url, + headers: { Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN" }, + }, + local: userServer, + }, + ui: { theme: "dark" }, + }); + }); + + it("keeps v2 teardown and rollback isolated from the legacy user file", () => { + const managedServer = { + type: "http", + url: baseEntry.url, + headers: { Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN" }, + }; + const legacyConfig = { + mcpServers: { local: { type: "stdio", command: "user-owned" } }, + ui: { theme: "dark" }, + }; + const removal = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRemoveCommand(baseEntry, false, true), + { mcpServers: { github: managedServer } }, + "v2", + legacyConfig, + ); + expect(removal.status, removal.stderr).toBe(0); + expect(removal.config).toEqual({ mcpServers: {} }); + expect(removal.legacyConfig).toEqual(legacyConfig); + + const rollback = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRegisterCommand(baseEntry, true, [baseEntry], true), + undefined, + "v2", + legacyConfig, + ); + expect(rollback.status, rollback.stderr).toBe(0); + expect(rollback.config).toEqual({ mcpServers: { github: managedServer } }); + expect(rollback.legacyConfig).toEqual(legacyConfig); + }); + + it("does not apply the v2 server cap to a single-entry legacy rollback", () => { + const managedEntries = Array.from( + { length: 65 }, + (_, index): McpBridgeEntry => ({ + ...baseEntry, + server: `server${String(index)}`, + env: [`SERVER_${String(index)}_TOKEN`], + }), + ); + + const rollback = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRegisterCommand(managedEntries[0], true, managedEntries, true), + undefined, + "legacy", + { mcpServers: { local: { type: "stdio", command: "user-owned" } } }, + ); + + expect(rollback.status, rollback.stderr).toBe(0); + expect(rollback.legacyConfig).toMatchObject({ + mcpServers: { + local: { type: "stdio", command: "user-owned" }, + server0: { + type: "http", + url: baseEntry.url, + headers: { Authorization: "Bearer openshell:resolve:env:SERVER_0_TOKEN" }, + }, + }, + }); + }); +}); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-runtime-guards.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-runtime-guards.test.ts new file mode 100644 index 00000000000..19205a1df62 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-runtime-guards.test.ts @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + baseEntry, + runDeepAgentsConfigCommand, +} from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; +import { + buildDeepAgentsMcpRegisterCommand, + buildDeepAgentsMcpRemoveCommand, +} from "./mcp-bridge-adapter-deepagents"; + +describe("Deep Agents MCP config adapter runtime guards", () => { + it("fails closed without touching either config when the runtime generation is unknown", () => { + const v2Config = { + mcpServers: { + github: { + type: "http", + url: baseEntry.url, + headers: { Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN" }, + }, + }, + }; + const legacyConfig = { + mcpServers: { local: { type: "stdio", command: "user-owned" } }, + ui: { theme: "dark" }, + }; + + for (const command of [ + buildDeepAgentsMcpRemoveCommand(baseEntry, true, true), + buildDeepAgentsMcpRegisterCommand(baseEntry, true, [baseEntry], true), + ]) { + const result = runDeepAgentsConfigCommand(command, v2Config, "unknown", legacyConfig); + expect(result.status).toBe(2); + expect(result.stderr).toContain("Could not identify the managed Deep Agents MCP runtime"); + expect(result.config).toEqual(v2Config); + expect(result.legacyConfig).toEqual(legacyConfig); + } + }); + + it("preserves ambiguous legacy JSON byte-for-byte during teardown and rollback", () => { + const exactServer = JSON.stringify({ + type: "http", + url: baseEntry.url, + headers: { Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN" }, + }); + const duplicateConfig = + `{"mcpServers":{"local":{"type":"stdio","command":"first"}},` + + `"mcpServers":{"github":${exactServer},"local":{"type":"stdio","command":"second"}},` + + `"ui":{"theme":"dark"}}\n`; + + const removal = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRemoveCommand(baseEntry, true, true), + undefined, + "legacy", + duplicateConfig, + ); + expect(removal.status, removal.stderr).toBe(0); + expect(removal.stdout.trim()).toBe("NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=unowned"); + expect(removal.legacyConfigText).toBe(duplicateConfig); + + const rollback = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRegisterCommand(baseEntry, true, [baseEntry], true), + undefined, + "legacy", + duplicateConfig, + ); + expect(rollback.status).toBe(2); + expect(rollback.stderr).toContain("duplicate JSON key: mcpServers"); + expect(rollback.legacyConfigText).toBe(duplicateConfig); + }); + + it("does not mutate a legacy file that the v1 runtime would reject", () => { + const legacyConfig = { + mcpServers: { + github: { + type: "http", + url: baseEntry.url, + headers: { Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN" }, + }, + }, + ui: { theme: "dark" }, + }; + const original = `${JSON.stringify(legacyConfig, null, 2)}\n`; + + for (const command of [ + buildDeepAgentsMcpRemoveCommand(baseEntry, true, true), + buildDeepAgentsMcpRegisterCommand(baseEntry, true, [baseEntry], true), + ]) { + const result = runDeepAgentsConfigCommand(command, undefined, "legacy", legacyConfig, 0o644); + expect(result.legacyConfigText).toBe(original); + expect(result.status === 2 || result.stdout.includes("REMOVAL=unowned")).toBe(true); + } + }); +}); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts new file mode 100644 index 00000000000..cf81ca1fd52 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts @@ -0,0 +1,192 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { McpBridgeEntry } from "../../state/registry"; +import { runDeepAgentsAdapterCommand } from "./mcp-bridge-adapter-deepagents-command"; +import { + DEEPAGENTS_LEGACY_CONFIG_HELPERS, + DEEPAGENTS_LEGACY_MCP_CONFIG_PATH, +} from "./mcp-bridge-adapter-deepagents-legacy"; +import { + DEEPAGENTS_MANAGED_PROJECTION_HELPERS, + DEEPAGENTS_STRICT_JSON_HELPERS, +} from "./mcp-bridge-adapter-deepagents-projection"; +import type { + AdapterMutationOptions, + AdapterRemovalOutcome, +} from "./mcp-bridge-adapter-inspection"; +import { + DEEPAGENTS_MCP_CONFIG_PATH, + deepAgentsManagedServerConfig, + pythonJsonLiteral, +} from "./mcp-bridge-adapter-status"; + +export function buildDeepAgentsMcpRemoveCommand( + entry: McpBridgeEntry, + force = false, + adaptiveTeardown = false, +): string { + const payload = { + server: entry.server, + expected: deepAgentsManagedServerConfig(entry), + force, + }; + return [ + "/opt/venv/bin/python3 -I - <<'PY'", + "import json, os, pathlib, stat, sys, tempfile", + `payload = json.loads(${pythonJsonLiteral(payload)})`, + `managed_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, + `legacy_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_LEGACY_MCP_CONFIG_PATH)})`, + ...DEEPAGENTS_STRICT_JSON_HELPERS, + ...DEEPAGENTS_MANAGED_PROJECTION_HELPERS, + ...DEEPAGENTS_LEGACY_CONFIG_HELPERS, + `runtime_kind = "${adaptiveTeardown ? "auto" : "v2"}" # NEMOCLAW_DEEPAGENTS_RUNTIME_TEST_ANCHOR`, + "if runtime_kind == 'auto':", + " runtime_kind = 'unknown'", + " try:", + " from deepagents_code import _nemoclaw_managed as managed", + " runtime_path = str(getattr(managed, '_MCP_CONFIG_FILE', ''))", + " if runtime_path == str(managed_path):", + " runtime_kind = 'v2'", + " elif runtime_path == str(legacy_path):", + " runtime_kind = 'legacy'", + " except Exception:", + " pass", + "if runtime_kind not in ('v2', 'legacy'):", + " print('Could not identify the managed Deep Agents MCP runtime; refusing teardown', file=sys.stderr)", + " raise SystemExit(2)", + "is_v2 = runtime_kind == 'v2'", + "config_path = managed_path if is_v2 else legacy_path", + "managed_identity = None", + "managed_descriptor = None", + "legacy_identity = None", + "def finish(outcome):", + " close_managed_projection_descriptor(managed_descriptor)", + " print('NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=' + outcome)", + " raise SystemExit(0)", + "def fail_teardown(message):", + " close_managed_projection_descriptor(managed_descriptor)", + " print(message, file=sys.stderr)", + " raise SystemExit(2)", + "def repair_v2_projection(identity, descriptor):", + " try:", + " write_managed_projection(config_path, {'mcpServers': {}}, identity, descriptor)", + " except (OSError, ValueError) as exc:", + " fail_teardown(f'Refusing unsafe managed MCP v2 repair at {config_path}: {exc}')", + "def write_legacy_data(value):", + " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=config_path.parent)", + " try:", + " os.fchmod(tmp_fd, 0o600)", + " with os.fdopen(tmp_fd, 'w', encoding='utf-8') as tmp_file:", + " json.dump(value, tmp_file, indent=2, sort_keys=True)", + " tmp_file.write('\\n')", + " tmp_file.flush()", + " os.fsync(tmp_file.fileno())", + " assert_legacy_source_stable(config_path, legacy_identity)", + " if legacy_identity is None:", + " os.link(tmp_name, config_path, follow_symlinks=False)", + " os.unlink(tmp_name)", + " else:", + " os.replace(tmp_name, config_path)", + " finally:", + " try:", + " os.unlink(tmp_name)", + " except FileNotFoundError:", + " pass", + "if is_v2:", + " try:", + " raw, managed_identity, managed_descriptor = open_managed_projection(config_path, True)", + " except (OSError, ValueError) as exc:", + " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: {exc}')", + " if managed_descriptor is None:", + " finish('absent')", + " try:", + " data = decode_managed_projection(raw)", + " except (UnicodeDecodeError, ValueError) as exc:", + " if payload['force']:", + " repair_v2_projection(managed_identity, managed_descriptor)", + " finish('removed')", + " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: {exc}')", + "else:", + " if not os.path.lexists(config_path):", + " finish('absent')", + " try:", + " data, legacy_identity = read_legacy_config(config_path)", + " except (OSError, UnicodeDecodeError, ValueError):", + " finish('unowned')", + "if not isinstance(data, dict):", + " if is_v2 and payload['force']:", + " repair_v2_projection(managed_identity, managed_descriptor)", + " finish('removed')", + " if is_v2:", + " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: expected object')", + " finish('unowned')", + "servers = data.get('mcpServers')", + "if not isinstance(servers, dict):", + " if not is_v2 and 'mcpServers' not in data:", + " finish('absent')", + " if is_v2 and payload['force']:", + " repair_v2_projection(managed_identity, managed_descriptor)", + " finish('removed')", + " if is_v2:", + " fail_teardown(f'Invalid managed MCP v2 server map at {config_path}')", + " finish('unowned')", + "present = payload['server'] in servers", + "current = servers.get(payload['server'])", + "if is_v2:", + " if data and set(data) != {'mcpServers'}:", + " if payload['force']:", + " repair_v2_projection(managed_identity, managed_descriptor)", + " finish('removed')", + " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: only mcpServers is allowed')", + " if present and not payload['force'] and current != payload['expected']:", + " fail_teardown(f\"Refusing to remove modified MCP server '{payload['server']}' from {config_path}. Use --force to remove it.\")", + " if not present:", + " finish('absent')", + "else:", + " if not present:", + " finish('absent')", + " if current != payload['expected'] and not payload['force']:", + " finish('unowned')", + "servers.pop(payload['server'])", + "if is_v2:", + " data = {'mcpServers': servers}", + "elif not servers:", + " data.pop('mcpServers', None)", + "if data:", + " try:", + " if is_v2:", + " write_managed_projection(config_path, data, managed_identity, managed_descriptor)", + " persisted = read_managed_projection(config_path)[0]", + " else:", + " write_legacy_data(data)", + " persisted = read_legacy_config(config_path)[0]", + " except (OSError, UnicodeDecodeError, ValueError) as exc:", + " fail_teardown(f'MCP teardown mutation failed at {config_path}: {exc}')", + " if persisted != data:", + " fail_teardown(f'MCP teardown verification failed at {config_path}')", + "else:", + " assert_legacy_source_stable(config_path, legacy_identity)", + " config_path.unlink()", + " if os.path.lexists(config_path):", + " fail_teardown(f'Managed MCP teardown verification failed at {config_path}')", + "finish('removed')", + "PY", + ].join("\n"); +} + +export function unregisterDeepAgentsAdapter( + sandboxName: string, + entry: McpBridgeEntry, + options: AdapterMutationOptions = {}, +): AdapterRemovalOutcome { + const stdout = runDeepAgentsAdapterCommand( + sandboxName, + entry, + buildDeepAgentsMcpRemoveCommand(entry, options.force === true, options.teardown === true), + `Deep Agents Code MCP config removal failed for '${entry.server}'.`, + options, + ); + const marker = stdout.match(/NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=(removed|absent|unowned)/); + return (marker?.[1] as AdapterRemovalOutcome | undefined) ?? "unowned"; +} diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-v2-removal.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-v2-removal.test.ts new file mode 100644 index 00000000000..65397930c76 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-v2-removal.test.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + baseEntry, + runDeepAgentsConfigCommand, +} from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; +import { buildDeepAgentsMcpRemoveCommand } from "./mcp-bridge-adapter-deepagents"; +import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; + +describe("Deep Agents MCP config adapter v2 removal", () => { + it("fails Deep Agents removal on corrupt config unless forced", () => { + const corruptProjection = { mcpServers: [] }; + const normal = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRemoveCommand(baseEntry), + corruptProjection, + ); + expect(normal.status).toBe(2); + expect(normal.stderr).toContain("Invalid managed MCP v2 server map"); + expect(normal.config).toEqual(corruptProjection); + + const forced = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRemoveCommand(baseEntry, true), + corruptProjection, + ); + expect(forced.status, forced.stderr).toBe(0); + expect(forced.stdout.trim()).toBe("NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=removed"); + expect(forced.config).toEqual({ mcpServers: {} }); + }); + + it("treats every extra Deep Agents server field as ownership drift", () => { + const managedServer = { + type: "http", + url: baseEntry.url, + headers: { + Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN", + }, + }; + const driftedConfig = { + mcpServers: { + github: { + ...managedServer, + allowedTools: ["get_issue"], + }, + }, + }; + + const status = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + driftedConfig, + ); + expect(status.status, status.stderr).toBe(0); + expect(status.stdout.trim()).toBe("mismatch"); + + const remove = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRemoveCommand(baseEntry), + driftedConfig, + ); + expect(remove.status).toBe(2); + expect(remove.stderr).toContain("Refusing to remove modified MCP server 'github'"); + expect(remove.config).toEqual(driftedConfig); + }); + + it("writes an empty tombstone and refuses unrelated state unless forced", () => { + const managedServer = { + type: "http", + url: baseEntry.url, + headers: { + Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN", + }, + }; + const onlyManagedServer = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRemoveCommand(baseEntry), + { mcpServers: { github: managedServer } }, + ); + expect(onlyManagedServer.status, onlyManagedServer.stderr).toBe(0); + expect(onlyManagedServer.config).toEqual({ mcpServers: {} }); + + const withUnrelatedConfig = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRemoveCommand(baseEntry), + { + mcpServers: { github: managedServer }, + ui: { theme: "dark" }, + }, + ); + expect(withUnrelatedConfig.status).toBe(2); + expect(withUnrelatedConfig.configExists).toBe(true); + expect(withUnrelatedConfig.config).toEqual({ + mcpServers: { github: managedServer }, + ui: { theme: "dark" }, + }); + + const forced = runDeepAgentsConfigCommand(buildDeepAgentsMcpRemoveCommand(baseEntry, true), { + mcpServers: { github: managedServer }, + ui: { theme: "dark" }, + }); + expect(forced.status, forced.stderr).toBe(0); + expect(forced.config).toEqual({ mcpServers: {} }); + }); +}); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents.test.ts deleted file mode 100644 index c2ee05f178c..00000000000 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -import type { McpBridgeEntry } from "../../state/registry"; -import { - buildDeepAgentsMcpRegisterCommand, - buildDeepAgentsMcpRemoveCommand, -} from "./mcp-bridge-adapter-deepagents"; -import { - buildDeepAgentsMcpStatusCommand, - DEEPAGENTS_MCP_CONFIG_PATH, -} from "./mcp-bridge-adapter-status"; - -const baseEntry: McpBridgeEntry = { - server: "github", - agent: "langchain-deepagents-code", - adapter: "deepagents-config", - url: "https://api.githubcopilot.com/mcp/", - env: ["GITHUB_TOKEN"], - providerName: "alpha-mcp-github", - policyName: "mcp-bridge-github", - addedAt: new Date(0).toISOString(), -}; - -function runDeepAgentsConfigCommand( - command: string, - initialConfig?: Record, -): { - status: number | null; - stdout: string; - stderr: string; - configExists: boolean; - config: Record | null; -} { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-deepagents-mcp-")); - const configPath = path.join(tmp, ".deepagents", ".mcp.json"); - const initializeConfig = - initialConfig === undefined - ? () => undefined - : () => { - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync(configPath, `${JSON.stringify(initialConfig, null, 2)}\n`, { - mode: 0o600, - }); - }; - initializeConfig(); - try { - const result = spawnSync( - "bash", - ["-c", command.replaceAll(DEEPAGENTS_MCP_CONFIG_PATH, configPath)], - { encoding: "utf-8", timeout: 5000 }, - ); - const configExists = fs.existsSync(configPath); - return { - status: result.status, - stdout: result.stdout, - stderr: result.stderr, - configExists, - config: configExists - ? (JSON.parse(fs.readFileSync(configPath, "utf-8")) as Record) - : null, - }; - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } -} - -describe("Deep Agents MCP config adapter", () => { - it("constructs a Deep Agents .mcp.json registration with placeholders", () => { - const command = buildDeepAgentsMcpRegisterCommand(baseEntry); - - expect(DEEPAGENTS_MCP_CONFIG_PATH).toBe("/sandbox/.deepagents/.mcp.json"); - expect(command).toContain(DEEPAGENTS_MCP_CONFIG_PATH); - expect(command).not.toContain('pathlib.Path("/sandbox/.mcp.json")'); - expect(command).toContain("mcpServers"); - expect(command).toContain('\\"type\\":\\"http\\"'); - expect(command).toContain("https://api.githubcopilot.com/mcp/"); - expect(command).toContain("openshell:resolve:env:GITHUB_TOKEN"); - expect(command).toContain("Invalid /sandbox/.deepagents/.mcp.json"); - expect(command).toContain("mcpServers must be an object"); - expect(command).toContain("already exists in /sandbox/.deepagents/.mcp.json"); - }); - - it("creates the Deep Agents config parent on first registration", () => { - const registration = runDeepAgentsConfigCommand(buildDeepAgentsMcpRegisterCommand(baseEntry)); - - expect(registration.status, registration.stderr).toBe(0); - expect(registration.configExists).toBe(true); - expect(registration.config).toEqual({ - mcpServers: { - github: { - type: "http", - url: "https://api.githubcopilot.com/mcp/", - headers: { - Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN", - }, - }, - }, - }); - }); - - it("rejects unowned config before registration mutates the file", () => { - const initialConfig = { ui: { theme: "dark" } }; - const registration = runDeepAgentsConfigCommand( - buildDeepAgentsMcpRegisterCommand(baseEntry), - initialConfig, - ); - - expect(registration.status).toBe(2); - expect(registration.stderr).toContain("only mcpServers is allowed"); - expect(registration.config).toEqual(initialConfig); - }); - - it("renders the complete registry-owned server projection", () => { - const jiraEntry: McpBridgeEntry = { - ...baseEntry, - server: "jira", - url: "https://mcp.atlassian.com/v1/", - env: ["JIRA_MCP_TOKEN"], - providerName: "alpha-mcp-jira", - policyName: "mcp-bridge-jira", - }; - const registration = runDeepAgentsConfigCommand( - buildDeepAgentsMcpRegisterCommand(jiraEntry, false, [baseEntry, jiraEntry]), - { - mcpServers: { - github: { - type: "http", - url: baseEntry.url, - headers: { - Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN", - }, - }, - }, - }, - ); - - expect(registration.status, registration.stderr).toBe(0); - expect(registration.config).toEqual({ - mcpServers: { - github: { - type: "http", - url: baseEntry.url, - headers: { Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN" }, - }, - jira: { - type: "http", - url: jiraEntry.url, - headers: { Authorization: "Bearer openshell:resolve:env:JIRA_MCP_TOKEN" }, - }, - }, - }); - }); - - it("fails Deep Agents removal on corrupt config unless forced", () => { - const normal = buildDeepAgentsMcpRemoveCommand(baseEntry); - const forced = buildDeepAgentsMcpRemoveCommand(baseEntry, true); - - expect(normal).toContain("Invalid /sandbox/.deepagents/.mcp.json"); - expect(normal).toContain('\\"force\\":false'); - expect(normal).toContain("raise SystemExit(2)"); - expect(normal).toContain("Refusing to remove modified MCP server"); - expect(forced).toContain('\\"force\\":true'); - }); - - it("treats every extra Deep Agents server field as ownership drift", () => { - const managedServer = { - type: "http", - url: baseEntry.url, - headers: { - Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN", - }, - }; - const driftedConfig = { - mcpServers: { - github: { - ...managedServer, - allowedTools: ["get_issue"], - }, - }, - }; - - const status = runDeepAgentsConfigCommand( - buildDeepAgentsMcpStatusCommand(baseEntry), - driftedConfig, - ); - expect(status.status, status.stderr).toBe(0); - expect(status.stdout.trim()).toBe("mismatch"); - - const remove = runDeepAgentsConfigCommand( - buildDeepAgentsMcpRemoveCommand(baseEntry), - driftedConfig, - ); - expect(remove.status).toBe(2); - expect(remove.stderr).toContain("Refusing to remove modified MCP server 'github'"); - expect(remove.config).toEqual(driftedConfig); - }); - - it("deletes an empty managed file but preserves unrelated Deep Agents config", () => { - const managedServer = { - type: "http", - url: baseEntry.url, - headers: { - Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN", - }, - }; - const onlyManagedServer = runDeepAgentsConfigCommand( - buildDeepAgentsMcpRemoveCommand(baseEntry), - { mcpServers: { github: managedServer } }, - ); - expect(onlyManagedServer.status, onlyManagedServer.stderr).toBe(0); - expect(onlyManagedServer.configExists).toBe(false); - - const withUnrelatedConfig = runDeepAgentsConfigCommand( - buildDeepAgentsMcpRemoveCommand(baseEntry), - { - mcpServers: { github: managedServer }, - ui: { theme: "dark" }, - }, - ); - expect(withUnrelatedConfig.status, withUnrelatedConfig.stderr).toBe(0); - expect(withUnrelatedConfig.configExists).toBe(true); - expect(withUnrelatedConfig.config).toEqual({ ui: { theme: "dark" } }); - }); -}); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents.ts index 649082b8956..cfdb249832f 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents.ts @@ -1,223 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { getSandbox, type McpBridgeEntry } from "../../state/registry"; -import { - type AdapterMutationOptions, - type AdapterRegistrationInspection, - inspectAdapterRegistrationCommand, -} from "./mcp-bridge-adapter-inspection"; -import { - buildDeepAgentsMcpStatusCommand, - DEEPAGENTS_MCP_CONFIG_PATH, - deepAgentsManagedServerConfig, - pythonJsonLiteral, -} from "./mcp-bridge-adapter-status"; -import { McpBridgeError } from "./mcp-bridge-contracts"; -import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; -import { executeSandboxCommand } from "./process-recovery"; - -const DEEPAGENTS_MCP_CAPABILITY_MARKER = "NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=1"; -const DEEPAGENTS_MCP_CAPABILITY_COMMAND = - "/usr/local/bin/deepagents-code --nemoclaw-mcp-capability"; - -export function buildDeepAgentsMcpRegisterCommand( - entry: McpBridgeEntry, - replaceExisting = false, - managedEntries: readonly McpBridgeEntry[] = [entry], -): string { - const expectedServers = Object.fromEntries( - managedEntries - .map((managedEntry): [string, Record] => [ - managedEntry.server, - deepAgentsManagedServerConfig(managedEntry), - ]) - .sort(([left], [right]) => left.localeCompare(right)), - ); - const payload = { - server: entry.server, - expected: deepAgentsManagedServerConfig(entry), - expectedServers, - replaceExisting, - }; - return [ - "python3 - <<'PY'", - "import json, os, pathlib, sys", - `payload = json.loads(${pythonJsonLiteral(payload)})`, - `config_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, - "data = {}", - "if config_path.exists():", - " try:", - " data = json.loads(config_path.read_text(encoding='utf-8') or '{}')", - " except json.JSONDecodeError as exc:", - ` print(f'Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}', file=sys.stderr)`, - " raise SystemExit(2)", - "if not isinstance(data, dict):", - ` print('Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: expected a JSON object', file=sys.stderr)`, - " raise SystemExit(2)", - "if data and set(data) != {'mcpServers'}:", - ` print('Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: only mcpServers is allowed', file=sys.stderr)`, - " raise SystemExit(2)", - "servers = data.setdefault('mcpServers', {})", - "if not isinstance(servers, dict):", - ` print('Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: mcpServers must be an object', file=sys.stderr)`, - " raise SystemExit(2)", - "if payload['server'] in servers and not payload['replaceExisting']:", - ` print(f"MCP server '{payload['server']}' already exists in ${DEEPAGENTS_MCP_CONFIG_PATH} and is not managed by NemoClaw.", file=sys.stderr)`, - " raise SystemExit(2)", - "for name, current in servers.items():", - " if name == payload['server'] and payload['replaceExisting']:", - " continue", - " if payload['expectedServers'].get(name) != current:", - ` print(f"Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: MCP server '{name}' is not exact registry-owned state", file=sys.stderr)`, - " raise SystemExit(2)", - "data = {'mcpServers': payload['expectedServers']}", - "config_path.parent.mkdir(parents=True, exist_ok=True)", - "tmp = config_path.with_name(config_path.name + '.nemoclaw-mcp.tmp')", - "tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + '\\n', encoding='utf-8')", - "os.chmod(tmp, 0o600)", - "os.replace(tmp, config_path)", - "os.chmod(config_path, 0o600)", - "PY", - ].join("\n"); -} - -export function buildDeepAgentsMcpRemoveCommand(entry: McpBridgeEntry, force = false): string { - const payload = { - server: entry.server, - expected: deepAgentsManagedServerConfig(entry), - force, - }; - return [ - "python3 - <<'PY'", - "import json, os, pathlib, sys", - `payload = json.loads(${pythonJsonLiteral(payload)})`, - `config_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, - "if not config_path.exists():", - " raise SystemExit(0)", - "try:", - " data = json.loads(config_path.read_text(encoding='utf-8') or '{}')", - "except json.JSONDecodeError as exc:", - ` print(f'Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}', file=sys.stderr)`, - " raise SystemExit(2)", - "if not isinstance(data, dict):", - ` print('Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: expected a JSON object', file=sys.stderr)`, - " raise SystemExit(2)", - "servers = data.get('mcpServers')", - "if servers is not None and not isinstance(servers, dict):", - ` print('Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: mcpServers must be an object', file=sys.stderr)`, - " raise SystemExit(2)", - "if isinstance(servers, dict):", - " present = payload['server'] in servers", - " current = servers.get(payload['server'])", - " if present and not payload['force']:", - " if current != payload['expected']:", - ` print(f"Refusing to remove modified MCP server '{payload['server']}' from ${DEEPAGENTS_MCP_CONFIG_PATH}. Use --force to remove it.", file=sys.stderr)`, - " raise SystemExit(2)", - " servers.pop(payload['server'], None)", - " if not servers:", - " data.pop('mcpServers', None)", - " if not data:", - " config_path.unlink()", - " raise SystemExit(0)", - "tmp = config_path.with_name(config_path.name + '.nemoclaw-mcp.tmp')", - "tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + '\\n', encoding='utf-8')", - "os.chmod(tmp, 0o600)", - "os.replace(tmp, config_path)", - "os.chmod(config_path, 0o600)", - "PY", - ].join("\n"); -} - -export function inspectDeepAgentsAdapterRegistration( - sandboxName: string, - entry: McpBridgeEntry, -): AdapterRegistrationInspection { - return inspectAdapterRegistrationCommand( - sandboxName, - entry, - buildDeepAgentsMcpStatusCommand(entry), - ); -} - -export function assertDeepAgentsMcpMutationRuntimeCapability(sandboxName: string): void { - const result = executeSandboxCommand(sandboxName, DEEPAGENTS_MCP_CAPABILITY_COMMAND); - if (result?.status !== 0 || result.stdout.trim() !== DEEPAGENTS_MCP_CAPABILITY_MARKER) { - throw new McpBridgeError( - `LangChain Deep Agents Code sandbox '${sandboxName}' does not contain the managed MCP-aware launcher. Rebuild the sandbox before changing authenticated MCP state.`, - ); - } -} - -function runDeepAgentsAdapterCommand( - sandboxName: string, - entry: Pick, - command: string, - failureMessage: string, - options: AdapterMutationOptions = {}, -): void { - const result = executeSandboxCommand(sandboxName, command); - const output = redactBridgeSecretsForDisplay( - [result?.stdout, result?.stderr].filter(Boolean).join("\n").trim(), - entry, - options.envValues ?? {}, - ); - if (!result || result.status !== 0) { - if (options.bestEffort) return; - throw new McpBridgeError(output || failureMessage); - } -} - -function verifyDeepAgentsAdapterRegistration(sandboxName: string, entry: McpBridgeEntry): void { - const inspection = inspectDeepAgentsAdapterRegistration(sandboxName, entry); - if (inspection.state === "registered") return; - const detail = inspection.state === "error" ? inspection.detail : inspection.state; - throw new McpBridgeError( - `deepagents-config config verification failed after adding '${entry.server}': ${detail}.`, - ); -} - -function registryOwnedDeepAgentsEntries( - sandboxName: string, - entry: McpBridgeEntry, -): McpBridgeEntry[] { - const entries = new Map(); - const bridges = getSandbox(sandboxName)?.mcp?.bridges ?? {}; - for (const bridge of Object.values(bridges)) entries.set(bridge.server, bridge); - entries.set(entry.server, entry); - return [...entries.values()]; -} - -export function registerDeepAgentsAdapter( - sandboxName: string, - entry: McpBridgeEntry, - envValues: Record = {}, - replaceExisting = false, -): void { - runDeepAgentsAdapterCommand( - sandboxName, - entry, - buildDeepAgentsMcpRegisterCommand( - entry, - replaceExisting, - registryOwnedDeepAgentsEntries(sandboxName, entry), - ), - `Deep Agents Code MCP config registration failed for '${entry.server}'.`, - { envValues }, - ); - verifyDeepAgentsAdapterRegistration(sandboxName, entry); -} - -export function unregisterDeepAgentsAdapter( - sandboxName: string, - entry: McpBridgeEntry, - options: AdapterMutationOptions = {}, -): void { - runDeepAgentsAdapterCommand( - sandboxName, - entry, - buildDeepAgentsMcpRemoveCommand(entry, options.force === true), - `Deep Agents Code MCP config removal failed for '${entry.server}'.`, - options, - ); -} +export { assertDeepAgentsMcpMutationRuntimeCapability } from "./mcp-bridge-adapter-deepagents-capability"; +export { inspectDeepAgentsAdapterRegistration } from "./mcp-bridge-adapter-deepagents-inspection"; +export { + buildDeepAgentsMcpRegisterCommand, + registerDeepAgentsAdapter, +} from "./mcp-bridge-adapter-deepagents-registration"; +export { + buildDeepAgentsMcpRemoveCommand, + unregisterDeepAgentsAdapter, +} from "./mcp-bridge-adapter-deepagents-teardown"; diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-inspection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-inspection.ts index 165f70ecdff..773e87eace0 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-inspection.ts @@ -13,8 +13,11 @@ export type AdapterMutationOptions = { force?: boolean; bestEffort?: boolean; envValues?: Record; + teardown?: boolean; }; +export type AdapterRemovalOutcome = "removed" | "absent" | "unowned"; + export function parseAdapterRegistrationInspection( result: SandboxCommandResult, entry: McpBridgeEntry, diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts index 172034dfe8b..ee51f0bc943 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts @@ -2,11 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import type { McpBridgeEntry } from "../../state/registry"; +import { + DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS, + DEEPAGENTS_STRICT_JSON_HELPERS, +} from "./mcp-bridge-adapter-deepagents-projection"; -// The pinned Deep Agents Code release auto-discovers this as the user-level MCP config. -// `/sandbox/.mcp.json` is project-level and is intentionally rejected by -// headless `dcode -n` unless project MCP has been separately trusted. -export const DEEPAGENTS_MCP_CONFIG_PATH = "/sandbox/.deepagents/.mcp.json"; +// NemoClaw owns this dedicated projection. Deep Agents Code's user/project +// `.mcp.json` discovery is disabled in the managed image so user-authored MCP +// state can never be layered over the validated registry projection. +export const DEEPAGENTS_MCP_CONFIG_PATH = "/sandbox/.deepagents/.nemoclaw-mcp.json"; const DEFAULT_AUTH_HEADER = "Authorization"; const DEFAULT_AUTH_SCHEME = "Bearer"; @@ -111,12 +115,14 @@ export function buildDeepAgentsMcpStatusCommand(entry: McpBridgeEntry): string { expected: deepAgentsManagedServerConfig(entry), }; return [ - "python3 - <<'PY'", - "import json, pathlib", + "/opt/venv/bin/python3 -I - <<'PY'", + "import json, os, pathlib, stat", `payload = json.loads(${pythonJsonLiteral(payload)})`, `config_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, + ...DEEPAGENTS_STRICT_JSON_HELPERS, + ...DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS, "try:", - " data = json.loads(config_path.read_text(encoding='utf-8') or '{}')", + " data = read_managed_projection(config_path)[0]", "except Exception:", " data = {}", "servers = data.get('mcpServers') if isinstance(data, dict) else None", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.ts new file mode 100644 index 00000000000..ac66ef08746 --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-teardown.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentMcpAdapter } from "../../agent/defs"; +import type { McpBridgeEntry, SandboxEntry } from "../../state/registry"; +import { registerAgentAdapter, unregisterAgentAdapter } from "./mcp-bridge-adapters"; +import { isAgentMcpAdapter, McpBridgeError } from "./mcp-bridge-contracts"; +import { getBridgeAdapter, getSandboxAgent } from "./mcp-bridge-state"; + +/** Resolve the exact persisted adapter, falling back only for legacy entries. */ +export function resolveManagedMcpAdapter( + sandbox: SandboxEntry, + entry: McpBridgeEntry, +): AgentMcpAdapter { + return isAgentMcpAdapter(entry.adapter) + ? entry.adapter + : getBridgeAdapter(getSandboxAgent(sandbox)); +} + +/** Scrub one registry-owned adapter entry, failing closed when ownership is unproved. */ +export function scrubManagedMcpAdapterOrThrow( + sandboxName: string, + sandbox: SandboxEntry, + entry: McpBridgeEntry, +): void { + const adapter = resolveManagedMcpAdapter(sandbox, entry); + const removal = unregisterAgentAdapter(sandboxName, adapter, entry, { + envValues: {}, + teardown: true, + }); + if (removal === "unowned") { + throw new McpBridgeError( + `Could not prove removal of the exact managed adapter entry for MCP server '${entry.server}'.`, + ); + } +} + +/** Restore scrubbed adapter entries without hiding failures from provider rollback. */ +export function rollbackScrubbedMcpAdapters( + sandboxName: string, + sandbox: SandboxEntry, + entries: readonly McpBridgeEntry[], +): string[] { + const failures: string[] = []; + for (const entry of entries) { + try { + registerAgentAdapter( + sandboxName, + resolveManagedMcpAdapter(sandbox, entry), + entry, + {}, + { + replaceExisting: true, + teardownRollback: true, + }, + ); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } + } + return failures; +} diff --git a/src/lib/actions/sandbox/mcp-bridge-adapters.ts b/src/lib/actions/sandbox/mcp-bridge-adapters.ts index 64ebd2af1f6..5b58ca6043f 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapters.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapters.ts @@ -19,6 +19,7 @@ import { import type { AdapterMutationOptions, AdapterRegistrationInspection, + AdapterRemovalOutcome, } from "./mcp-bridge-adapter-inspection"; import { inspectOpenClawAdapterRegistration, @@ -122,7 +123,7 @@ export function registerAgentAdapter( adapter: AgentMcpAdapter, entry: McpBridgeEntry, envValues: Record = {}, - options: { replaceExisting?: boolean } = {}, + options: { replaceExisting?: boolean; teardownRollback?: boolean } = {}, ): void { switch (adapter) { case "mcporter": @@ -132,7 +133,13 @@ export function registerAgentAdapter( registerHermesAdapter(sandboxName, entry, envValues, options.replaceExisting === true); return; case "deepagents-config": - registerDeepAgentsAdapter(sandboxName, entry, envValues, options.replaceExisting === true); + registerDeepAgentsAdapter( + sandboxName, + entry, + envValues, + options.replaceExisting === true, + options.teardownRollback === true, + ); return; } } @@ -142,16 +149,15 @@ export function unregisterAgentAdapter( adapter: AgentMcpAdapter, entry: McpBridgeEntry, options: AdapterMutationOptions = {}, -): void { +): AdapterRemovalOutcome { switch (adapter) { case "mcporter": unregisterOpenClawAdapter(sandboxName, entry, options); - return; + return "removed"; case "hermes-config": unregisterHermesAdapter(sandboxName, entry, options); - return; + return "removed"; case "deepagents-config": - unregisterDeepAgentsAdapter(sandboxName, entry, options); - return; + return unregisterDeepAgentsAdapter(sandboxName, entry, options); } } diff --git a/src/lib/actions/sandbox/mcp-bridge-destroy.ts b/src/lib/actions/sandbox/mcp-bridge-destroy.ts index 5a393e8fccd..3607ee0c847 100644 --- a/src/lib/actions/sandbox/mcp-bridge-destroy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-destroy.ts @@ -3,12 +3,11 @@ import type { McpBridgeEntry } from "../../state/registry"; import * as registry from "../../state/registry"; -import { registerAgentAdapter, unregisterAgentAdapter } from "./mcp-bridge-adapters"; import { - isAgentMcpAdapter, - MCP_BRIDGE_POLICY_SOURCE, - McpBridgeError, -} from "./mcp-bridge-contracts"; + rollbackScrubbedMcpAdapters, + scrubManagedMcpAdapterOrThrow, +} from "./mcp-bridge-adapter-teardown"; +import { MCP_BRIDGE_POLICY_SOURCE, McpBridgeError } from "./mcp-bridge-contracts"; import type { McpDestroyPreparation } from "./mcp-bridge-destroy-preflight"; import { assertMcpDestroySnapshotCurrent, @@ -32,8 +31,6 @@ import { import { bridgeState, ensureSandboxGatewaySelected, - getBridgeAdapter, - getSandboxAgent, getSandboxOrThrow, nowIso, } from "./mcp-bridge-state"; @@ -127,12 +124,7 @@ export async function prepareMcpBridgesForDestroy( const scrubbedAdapters: McpBridgeEntry[] = []; try { for (const entry of entries) { - const adapter = isAgentMcpAdapter(entry.adapter) - ? entry.adapter - : getBridgeAdapter(getSandboxAgent(sandbox)); - unregisterAgentAdapter(sandboxName, adapter, entry, { - envValues: {}, - }); + scrubManagedMcpAdapterOrThrow(sandboxName, sandbox, entry); scrubbedAdapters.push(entry); } for (const entry of entries) { @@ -178,26 +170,7 @@ export async function prepareMcpBridgesForDestroy( ); } } - for (const entry of scrubbedAdapters) { - try { - const adapter = isAgentMcpAdapter(entry.adapter) - ? entry.adapter - : getBridgeAdapter(getSandboxAgent(sandbox)); - registerAgentAdapter( - sandboxName, - adapter, - entry, - {}, - { - replaceExisting: true, - }, - ); - } catch (rollbackError) { - rollbackFailures.push( - rollbackError instanceof Error ? rollbackError.message : String(rollbackError), - ); - } - } + rollbackFailures.push(...rollbackScrubbedMcpAdapters(sandboxName, sandbox, scrubbedAdapters)); const current = registry.getSandbox(sandboxName); if (current?.mcp?.destroyPreparedAt) { try { diff --git a/src/lib/actions/sandbox/mcp-bridge-input-validation.test.ts b/src/lib/actions/sandbox/mcp-bridge-input-validation.test.ts index 8ba82d212df..20c654be597 100644 --- a/src/lib/actions/sandbox/mcp-bridge-input-validation.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-input-validation.test.ts @@ -207,6 +207,21 @@ describe("MCP CLI input validation", () => { expect(() => normalizeMcpServerUrl("https://mcp.example.test:0/mcp")).toThrow( /port must be between 1 and 65535/, ); + for (const hostname of [ + "mcp_bad.example.test", + "-mcp.example.test", + "mcp-.example.test", + "mcp..example.test", + `${"a".repeat(64)}.example.test`, + `${"a".repeat(63)}.${"b".repeat(63)}.${"c".repeat(63)}.${"d".repeat(63)}`, + ]) { + expect(() => normalizeMcpServerUrl(`https://${hostname}/mcp`)).toThrow( + /canonical DNS labels/, + ); + } + expect(normalizeMcpServerUrl(`https://${"a".repeat(63)}.example.test/mcp`)).toBe( + `https://${"a".repeat(63)}.example.test/mcp`, + ); for (const path of [ "/mcp/**", "/mcp/%2A%2A", diff --git a/src/lib/actions/sandbox/mcp-bridge-rebuild.ts b/src/lib/actions/sandbox/mcp-bridge-rebuild.ts index 118417218d8..f8321b2fcbe 100644 --- a/src/lib/actions/sandbox/mcp-bridge-rebuild.ts +++ b/src/lib/actions/sandbox/mcp-bridge-rebuild.ts @@ -2,8 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import type { McpBridgeEntry } from "../../state/registry"; -import { registerAgentAdapter, unregisterAgentAdapter } from "./mcp-bridge-adapters"; -import { isAgentMcpAdapter, McpBridgeError } from "./mcp-bridge-contracts"; +import { + rollbackScrubbedMcpAdapters, + scrubManagedMcpAdapterOrThrow, +} from "./mcp-bridge-adapter-teardown"; +import { McpBridgeError } from "./mcp-bridge-contracts"; import { cloneMcpBridgeEntry, discardSafeIncompleteMcpAdds, @@ -30,8 +33,6 @@ import { assertMcpDestroyNotPending, bridgeState, ensureSandboxGatewaySelected, - getBridgeAdapter, - getSandboxAgent, getSandboxOrThrow, setBridgeState, } from "./mcp-bridge-state"; @@ -126,13 +127,10 @@ export async function prepareMcpBridgesForRebuild( const scrubbedAdapters: McpBridgeEntry[] = []; try { for (const entry of entries) { - const adapter = isAgentMcpAdapter(entry.adapter) - ? entry.adapter - : getBridgeAdapter(getSandboxAgent(sandbox)); // `/sandbox` may be a retained PVC. Scrub before delete so a replacement // Hermes/agent cannot boot with a stale placeholder while its provider // is intentionally detached during recreate. - unregisterAgentAdapter(sandboxName, adapter, entry, { envValues: {} }); + scrubManagedMcpAdapterOrThrow(sandboxName, sandbox, entry); scrubbedAdapters.push(entry); } for (const entry of entries) { @@ -166,26 +164,7 @@ export async function prepareMcpBridgesForRebuild( ); } } - for (const entry of scrubbedAdapters) { - try { - const adapter = isAgentMcpAdapter(entry.adapter) - ? entry.adapter - : getBridgeAdapter(getSandboxAgent(sandbox)); - registerAgentAdapter( - sandboxName, - adapter, - entry, - {}, - { - replaceExisting: true, - }, - ); - } catch (rollbackError) { - rollbackFailures.push( - rollbackError instanceof Error ? rollbackError.message : String(rollbackError), - ); - } - } + rollbackFailures.push(...rollbackScrubbedMcpAdapters(sandboxName, sandbox, scrubbedAdapters)); const detail = error instanceof Error ? error.message : String(error); throw new McpBridgeError( rollbackFailures.length > 0 @@ -226,24 +205,7 @@ export async function reattachMcpProvidersAfterRebuildAbort( failures.push(error instanceof Error ? error.message : String(error)); } } - for (const entry of scrubbedAdapterEntries) { - try { - const adapter = isAgentMcpAdapter(entry.adapter) - ? entry.adapter - : getBridgeAdapter(getSandboxAgent(sandbox)); - registerAgentAdapter( - sandboxName, - adapter, - entry, - {}, - { - replaceExisting: true, - }, - ); - } catch (error) { - failures.push(error instanceof Error ? error.message : String(error)); - } - } + failures.push(...rollbackScrubbedMcpAdapters(sandboxName, sandbox, scrubbedAdapterEntries)); if (failures.length > 0) { throw new McpBridgeError(failures.join("; ")); } diff --git a/src/lib/actions/sandbox/mcp-bridge-remove.ts b/src/lib/actions/sandbox/mcp-bridge-remove.ts index e28a473b4a9..8a31a1586f8 100644 --- a/src/lib/actions/sandbox/mcp-bridge-remove.ts +++ b/src/lib/actions/sandbox/mcp-bridge-remove.ts @@ -228,12 +228,22 @@ async function removeMcpBridgeUnlocked( // retains its helper/lifecycle validation; Deep Agents intentionally // skips only the marker that an older image cannot expose. assertAgentMcpTeardownRuntimeCapability(sandboxName, adapter); - unregisterAgentAdapter( + const adapterRemoval = unregisterAgentAdapter( sandboxName, (entry.adapter as AgentMcpAdapter | undefined) ?? adapter, entry, - { force: options.force === true, envValues: adapterEnvValues }, + { + force: options.force === true, + envValues: adapterEnvValues, + teardown: true, + }, ); + if (adapterRemoval === "unowned") { + adapterCleanupProved = false; + throw new McpBridgeError( + `Could not prove removal of the exact managed adapter entry for MCP server '${entry.server}'. Preserved provider, policy, and registry ownership state.`, + ); + } } catch (error) { const detail = error instanceof Error ? error.message : String(error); if (!options.force) throw new McpBridgeError(detail); diff --git a/src/lib/actions/sandbox/mcp-bridge-restart.ts b/src/lib/actions/sandbox/mcp-bridge-restart.ts index 24a41b38c84..bbdeac5c0e0 100644 --- a/src/lib/actions/sandbox/mcp-bridge-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-restart.ts @@ -209,7 +209,16 @@ export async function restoreExistingMcpBridgeRuntime( waitForAttachedMcpCredential(sandboxName, entry); const adapter = (entry.adapter as AgentMcpAdapter | undefined) ?? getBridgeAdapter(getSandboxAgent(sandbox)); - registerAgentAdapter(sandboxName, adapter, entry, {}, { replaceExisting: true }); + registerAgentAdapter( + sandboxName, + adapter, + entry, + {}, + { + replaceExisting: true, + teardownRollback: options.lifecyclePhase === "teardown-rollback", + }, + ); writeBridgeEntry(sandboxName, { ...entry, adapter, updatedAt: nowIso() }); } } diff --git a/src/lib/actions/sandbox/mcp-bridge-status.ts b/src/lib/actions/sandbox/mcp-bridge-status.ts index 8ea71accd58..57474f1baf1 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status.ts @@ -39,6 +39,17 @@ export interface McpBridgeJsonSummary { bridges: McpBridgeStatus[]; } +// Source-of-truth review for the provider warning: +// invalidState: OpenShell can resolve one sandbox-scoped provider placeholder +// from another inspected route attributed to the same adapter runtime. +// sourceBoundary: OpenShell owns provider attachment and HTTP rewrite binding; +// NemoClaw owns the generated least-privilege route and operator diagnostics. +// whyNotSourceFix: v0.0.72 has no endpoint-exclusive provider attachment or +// enforceable Host, scheme, and query binding that NemoClaw can request. +// regressionTest: mcp-bridge-status-boundaries.test.ts pins this warning and the +// generated policy tests pin unique keys, explicit methods, and allowed IPs. +// removalCondition: remove only when OpenShell exposes and NemoClaw requires +// endpoint-exclusive credential binding plus Host, scheme, and query enforcement. const SANDBOX_SCOPED_PROVIDER_WARNING = "OpenShell currently attaches this credential provider at sandbox scope, not exclusively to this MCP endpoint. Keep other inspected routes for the same adapter binary at least as restrictive until OpenShell supports endpoint-exclusive credential binding plus Host, scheme, and query enforcement."; const UNSUPPORTED_STORED_URL_WARNING = diff --git a/src/lib/actions/sandbox/mcp-bridge-url-validation.ts b/src/lib/actions/sandbox/mcp-bridge-url-validation.ts index fe1f38b56cb..e45728f0422 100644 --- a/src/lib/actions/sandbox/mcp-bridge-url-validation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-url-validation.ts @@ -18,6 +18,16 @@ const MCP_PATH_CREDENTIAL_PATTERNS = TOKEN_PREFIX_PATTERNS.map( // final Telegram/Discord token character but is not a RegExp "word" byte. (pattern) => new RegExp(pattern.source.replaceAll("\\b", ""), pattern.flags.replace("g", "")), ); +const MCP_DNS_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +function validateCanonicalMcpDnsHostname(hostname: string): void { + if (hostname.length > 253 || hostname.split(".").some((label) => !MCP_DNS_LABEL_RE.test(label))) { + throw new McpBridgeError( + "MCP server URL hostnames must use canonical DNS labels: lowercase letters, digits, and internal hyphens only, with no empty or overlong labels.", + 2, + ); + } +} /** Reject self-identifying credentials in persisted endpoint path segments. */ function hasSecretShapedMcpPathSegment(pathname: string): boolean { @@ -139,6 +149,7 @@ export function normalizeMcpServerUrl(rawUrl: string): string { 2, ); } + validateCanonicalMcpDnsHostname(parsed.hostname); if (!parsed.pathname) parsed.pathname = "/"; const normalized = parsed.toString(); if (normalized.length > MCP_SERVER_URL_MAX_LENGTH) { diff --git a/src/lib/actions/sandbox/rebuild-backup-phase.test.ts b/src/lib/actions/sandbox/rebuild-backup-phase.test.ts new file mode 100644 index 00000000000..36c5f2efcfd --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-backup-phase.test.ts @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + normalizeRebuildWebSearchPolicyPresets, + runRebuildBackupPhase, +} from "./rebuild-backup-phase"; + +describe("rebuild web-search policy normalization", () => { + it("keeps only the durable Tavily provider and removes stale nous-web", () => { + expect( + normalizeRebuildWebSearchPolicyPresets( + ["npm", "brave", "nous-web", "tavily"], + { name: "alpha", agent: "hermes" }, + { fetchEnabled: true, provider: "tavily" }, + ), + ).toEqual(["npm", "tavily"]); + expect( + normalizeRebuildWebSearchPolicyPresets( + ["npm", "brave"], + { name: "alpha", agent: "hermes" }, + { fetchEnabled: true, provider: "tavily" }, + ), + ).toEqual(["npm", "tavily"]); + }); + + it("removes both built-in providers for an authoritative disable", () => { + expect( + normalizeRebuildWebSearchPolicyPresets( + ["npm", "brave", "tavily"], + { name: "alpha", agent: "openclaw" }, + null, + ), + ).toEqual(["npm"]); + }); + + it("preserves DCode's standalone Tavily and excludes custom names from built-in replay", () => { + expect( + normalizeRebuildWebSearchPolicyPresets( + ["npm", "tavily"], + { name: "alpha", agent: "langchain-deepagents-code" }, + null, + ), + ).toEqual(["npm", "tavily"]); + expect( + normalizeRebuildWebSearchPolicyPresets( + ["npm", "tavily"], + { + name: "alpha", + agent: "openclaw", + customPolicies: [{ name: "tavily", content: "allow: []" }], + }, + null, + ), + ).toEqual(["npm"]); + }); + + it("keeps a finalized custom-only built-in selection empty instead of resetting it", () => { + const result = runRebuildBackupPhase({ + sandboxName: "alpha", + sandboxEntry: { + name: "alpha", + agent: "openclaw", + policies: ["tavily"], + customPolicies: [{ name: "tavily", content: "allow: []" }], + policyPresetsFinalized: true, + }, + staleRecovery: false, + preparedRecoveryManifest: { + policyPresets: ["tavily"], + customPolicies: [{ name: "tavily", content: "allow: []" }], + } as never, + messagingPlan: null, + webSearchConfig: null, + log: vi.fn(), + bail: (message): never => { + throw new Error(message); + }, + relockShieldsIfNeeded: () => true, + }); + + expect(result?.policyPresets).toEqual([]); + expect(result?.sessionPolicyPresets).toEqual([]); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-backup-phase.ts b/src/lib/actions/sandbox/rebuild-backup-phase.ts index 147b8d0af68..6117e55d06d 100644 --- a/src/lib/actions/sandbox/rebuild-backup-phase.ts +++ b/src/lib/actions/sandbox/rebuild-backup-phase.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { SandboxMessagingPlan } from "../../messaging"; import { type WebSearchConfig, webSearchProviderForConfig } from "../../inference/web-search"; +import type { SandboxMessagingPlan } from "../../messaging"; import { mergeRebuildMessagingPolicyPresets } from "../../onboard/messaging-policy-presets"; import { resolveRecreatePolicyPresets } from "../../onboard/policy-preset-persistence"; import { isStaleBuiltinWebSearchPolicyPreset } from "../../onboard/policy-selection"; @@ -32,6 +32,38 @@ export interface RebuildBackupPhaseResult { sessionPolicyPresets: string[] | null; } +/** Align built-in web-search egress with the durable provider selection. */ +export function normalizeRebuildWebSearchPolicyPresets( + presets: readonly string[], + sandboxEntry: RebuildSandboxEntry, + webSearchConfig: WebSearchConfig | null, +): string[] { + const customPresetNames = new Set( + (sandboxEntry.customPolicies ?? []).map((policy) => policy.name), + ); + const selectedProvider = webSearchConfig ? webSearchProviderForConfig(webSearchConfig) : null; + const preserveStandaloneDcodeTavily = + selectedProvider === null && sandboxEntry.agent === "langchain-deepagents-code"; + const normalized = presets.filter((name) => { + // Exact custom content is replayed from backupManifest.customPolicies. + // Never substitute a same-name built-in during onboard or restore. + if (customPresetNames.has(name)) return false; + if (preserveStandaloneDcodeTavily && name === "tavily") return true; + return !isStaleBuiltinWebSearchPolicyPreset(name, { + webSearchConfig, + customPresetNames, + }); + }); + if ( + selectedProvider && + !customPresetNames.has(selectedProvider) && + !normalized.includes(selectedProvider) + ) { + normalized.push(selectedProvider); + } + return [...new Set(normalized)]; +} + export function runRebuildBackupPhase( input: RebuildBackupPhaseInput, ): RebuildBackupPhaseResult | null { @@ -62,26 +94,17 @@ export function runRebuildBackupPhase( enabledChannelIds, disabledChannels, ); - const customPresetNames = new Set( - (input.sandboxEntry.customPolicies ?? []).map((policy) => policy.name), + const policyPresets = normalizeRebuildWebSearchPolicyPresets( + mergedPolicyPresets, + input.sandboxEntry, + input.webSearchConfig, ); - const policyPresets = mergedPolicyPresets.filter( - (name) => - !isStaleBuiltinWebSearchPolicyPreset(name, { - webSearchConfig: input.webSearchConfig, - customPresetNames, - }) && !(customPresetNames.has(name) && ["brave", "tavily", "nous-web"].includes(name)), - ); - if (input.webSearchConfig) { - const activePreset = webSearchProviderForConfig(input.webSearchConfig); - if (!customPresetNames.has(activePreset) && !policyPresets.includes(activePreset)) { - policyPresets.push(activePreset); - } - } const sessionPolicyPresets = resolveRecreatePolicyPresets( policyPresets, input.sandboxEntry.policyPresetsFinalized === true, - (input.sandboxEntry.customPolicies?.length ?? 0) > 0, + // Rebuild now replays exact custom policy content after recreate, so the + // built-in selection can independently preserve an intentional empty set. + false, {}, true, ).policyPresets; diff --git a/src/lib/actions/sandbox/rebuild-dcode-artifact-drift.test.ts b/src/lib/actions/sandbox/rebuild-dcode-artifact-drift.test.ts new file mode 100644 index 00000000000..e172220aaf6 --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-dcode-artifact-drift.test.ts @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + configureDcodeSession, + makeDcodeSandboxEntry, +} from "../../../../test/helpers/rebuild-dcode-flow-support"; +import { + createRebuildFlowHarness, + resetRebuildFlowTestEnvironment, + restoreRebuildFlowTestEnvironment, + snapshotEnv, +} from "../../../../test/helpers/rebuild-flow-harness"; + +describe("rebuildSandbox DCode flow: prepared artifact drift", () => { + beforeEach(resetRebuildFlowTestEnvironment); + afterEach(restoreRebuildFlowTestEnvironment); + + it("preserves live DCode when retained replacement inputs drift after backup (#6195)", async () => { + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: makeDcodeSandboxEntry(), + dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }], + dcodeImageVerificationResults: [true, false], + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("the prepared DCode replacement inputs changed before deletion"); + + expect(harness.openShieldsSpy).toHaveBeenCalledOnce(); + expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce(); + expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.anything(), + ); + expect(harness.removeSandboxRegistryEntrySpy).not.toHaveBeenCalled(); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); + expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( + harness.preparedDcodeBuildContext, + ); + }); + it("preserves live DCode when its pinned base image drifts after backup (#6195)", async () => { + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: makeDcodeSandboxEntry(), + dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }], + dcodeBaseImageIds: ["sha256:dcode-base", "sha256:dcode-base", "sha256:changed"], + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("the prepared DCode replacement inputs changed before deletion"); + + expect(harness.openShieldsSpy).toHaveBeenCalledOnce(); + expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce(); + expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.anything(), + ); + expect(harness.removeSandboxRegistryEntrySpy).not.toHaveBeenCalled(); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); + expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( + harness.preparedDcodeBuildContext, + ); + }); + it("restores the prior gateway and disposes DCode inputs when shields opening throws (#6195)", async () => { + const restoreEnv = snapshotEnv(["OPENSHELL_GATEWAY"]); + process.env.OPENSHELL_GATEWAY = "previous-gateway"; + let gatewayAtShields: string | undefined; + + try { + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: makeDcodeSandboxEntry(), + dcodeRouteResults: [{ ok: true }, { ok: true }], + openShieldsWindow: () => { + gatewayAtShields = process.env.OPENSHELL_GATEWAY; + throw new Error("shields opening threw unexpectedly"); + }, + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("shields opening threw unexpectedly"); + + expect(gatewayAtShields).toBe("nemoclaw"); + expect(process.env.OPENSHELL_GATEWAY).toBe("previous-gateway"); + expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledOnce(); + expect(harness.openShieldsSpy).toHaveBeenCalledOnce(); + expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); + expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.anything(), + ); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( + harness.preparedDcodeBuildContext, + ); + } finally { + restoreEnv(); + } + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-dcode-flow.test.ts b/src/lib/actions/sandbox/rebuild-dcode-flow.test.ts deleted file mode 100644 index 84ecbc9492e..00000000000 --- a/src/lib/actions/sandbox/rebuild-dcode-flow.test.ts +++ /dev/null @@ -1,472 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { - createRebuildFlowHarness, - makePreparedRecoveryManifest, - type RebuildFlowHarness, - resetRebuildFlowTestEnvironment, - restoreRebuildFlowTestEnvironment, - snapshotEnv, -} from "../../../../test/helpers/rebuild-flow-harness"; - -function makeDcodeSandboxEntry(): Record { - return { - name: "alpha", - agent: "langchain-deepagents-code", - agentVersion: "0.1.12", - nemoclawVersion: "0.0.72", - provider: "compatible-endpoint", - model: "nvidia/nemotron-3-super-120b-a12b", - endpointUrl: "https://inference-api.nvidia.com/v1", - credentialEnv: "COMPATIBLE_API_KEY", - preferredInferenceApi: "openai-completions", - nimContainer: null, - policies: [], - dashboardPort: 0, - gatewayName: "nemoclaw", - gatewayPort: 8080, - gpuEnabled: false, - sandboxGpuEnabled: false, - sandboxGpuMode: "0", - }; -} - -function configureDcodeSession(harness: RebuildFlowHarness): void { - Object.assign(harness.session, { - agent: "langchain-deepagents-code", - provider: "compatible-endpoint", - model: "nvidia/nemotron-3-super-120b-a12b", - endpointUrl: "https://inference-api.nvidia.com/v1", - credentialEnv: "COMPATIBLE_API_KEY", - preferredInferenceApi: "openai-completions", - gpuPassthrough: false, - }); -} - -function expectNoDcodeMutation(harness: RebuildFlowHarness): void { - expect(harness.openShieldsSpy).not.toHaveBeenCalled(); - expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); - expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( - ["sandbox", "delete", "alpha"], - expect.anything(), - ); - expect(harness.removeSandboxRegistryEntrySpy).not.toHaveBeenCalled(); - expect(harness.onboardSpy).not.toHaveBeenCalled(); -} - -describe("rebuildSandbox DCode flow", () => { - beforeEach(resetRebuildFlowTestEnvironment); - afterEach(restoreRebuildFlowTestEnvironment); - - it("rejects a stored DCode route failure before any rebuild mutation (#6195)", async () => { - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), - dcodeRouteResults: [ - { ok: false, detail: "existing sandbox inference probe returned HTTP 401" }, - ], - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("Recorded inference route smoke check failed"); - - expect(harness.preflightDcodeRouteSpy).toHaveBeenCalledOnce(); - expect(harness.prepareManagedDcodeRebuildImageSpy).not.toHaveBeenCalled(); - expect(harness.disposePreparedDcodeRebuildImageSpy).not.toHaveBeenCalled(); - expectNoDcodeMutation(harness); - }); - - it("keeps DCode intact when its recorded gateway cannot become healthy (#6195)", async () => { - const restoreEnv = snapshotEnv(["OPENSHELL_GATEWAY"]); - process.env.OPENSHELL_GATEWAY = "previous-gateway"; - - try { - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), - gatewayRecoveryResult: { - recovered: false, - attempted: true, - before: { state: "named_unhealthy" }, - after: { state: "named_unhealthy" }, - }, - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("Could not select healthy gateway 'nemoclaw'"); - - expect(process.env.OPENSHELL_GATEWAY).toBe("previous-gateway"); - expect(harness.preflightDcodeRouteSpy).not.toHaveBeenCalled(); - expect(harness.prepareManagedDcodeRebuildImageSpy).not.toHaveBeenCalled(); - expectNoDcodeMutation(harness); - } finally { - restoreEnv(); - } - }); - - it("restores the prior gateway when messaging conflict preflight throws after target pin (#6195)", async () => { - const restoreEnv = snapshotEnv(["OPENSHELL_GATEWAY"]); - process.env.OPENSHELL_GATEWAY = "previous-gateway"; - - try { - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), - preflightMessagingConflicts: () => { - throw new Error("messaging conflict preflight failed"); - }, - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("messaging conflict preflight failed"); - - expect(harness.preflightMessagingConflictsSpy).toHaveBeenCalledOnce(); - expect(process.env.OPENSHELL_GATEWAY).toBe("previous-gateway"); - expect(harness.preflightDcodeRouteSpy).not.toHaveBeenCalled(); - expect(harness.prepareManagedDcodeRebuildImageSpy).not.toHaveBeenCalled(); - expect(harness.disposePreparedDcodeRebuildImageSpy).not.toHaveBeenCalled(); - expectNoDcodeMutation(harness); - } finally { - restoreEnv(); - } - }); - - it("rejects a DCode replacement-image failure before any rebuild mutation (#6195)", async () => { - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), - dcodeImageResult: { ok: false, detail: "replacement image build failed" }, - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow(); - - expect(harness.preflightDcodeRouteSpy).toHaveBeenCalledOnce(); - expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledOnce(); - expect(harness.disposePreparedDcodeRebuildImageSpy).not.toHaveBeenCalled(); - expectNoDcodeMutation(harness); - }); - - it("rejects a managed DCode session with a recorded custom Dockerfile before image preparation (#6195)", async () => { - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), - }); - configureDcodeSession(harness); - harness.session.metadata = { fromDockerfile: "/tmp/custom/Dockerfile" }; - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("Managed DCode rebuild cannot use a recorded custom Dockerfile"); - - expect(harness.preflightDcodeRouteSpy).not.toHaveBeenCalled(); - expect(harness.prepareManagedDcodeRebuildImageSpy).not.toHaveBeenCalled(); - expect(harness.disposePreparedDcodeRebuildImageSpy).not.toHaveBeenCalled(); - expectNoDcodeMutation(harness); - }); - - it("rejects registry drift during the final DCode preflight before shields and backup (#6195)", async () => { - const originalEntry = makeDcodeSandboxEntry(); - const driftedEntry = { ...originalEntry, model: "nvidia/changed-during-preflight" }; - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: originalEntry, - sandboxEntryReads: [ - originalEntry, // Initial rebuild target. - originalEntry, // Messaging-conflict gateway selection (#5954). - originalEntry, // Prepared DCode target capture. - driftedEntry, // Final pre-backup target verification. - ], - dcodeRouteResults: [{ ok: true }, { ok: true }], - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("the recorded sandbox target changed during preflight"); - - expect(harness.preflightDcodeRouteSpy).toHaveBeenCalledTimes(2); - expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledOnce(); - expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( - harness.preparedDcodeBuildContext, - ); - expectNoDcodeMutation(harness); - }); - - it("disposes the prepared DCode image when the final route recheck fails (#6195)", async () => { - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), - dcodeRouteResults: [ - { ok: true }, - { ok: false, detail: "existing sandbox inference probe returned HTTP 401" }, - ], - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("Recorded inference route smoke check failed"); - - expect(harness.preflightDcodeRouteSpy).toHaveBeenCalledTimes(2); - expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledOnce(); - expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( - harness.preparedDcodeBuildContext, - ); - expectNoDcodeMutation(harness); - }); - - it("preserves the live DCode sandbox when its registry target drifts after backup (#6195)", async () => { - const originalEntry = makeDcodeSandboxEntry(); - const driftedEntry = { ...originalEntry, model: "nvidia/changed-at-delete-edge" }; - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: originalEntry, - sandboxEntryReads: [ - originalEntry, // Initial rebuild target. - originalEntry, // Messaging-conflict gateway selection (#5954). - originalEntry, // Prepared DCode target capture. - originalEntry, // Final pre-backup target verification. - originalEntry, // Delete-edge target verification input. - driftedEntry, // Registry reread at the destructive boundary. - ], - dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }], - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("the recorded sandbox target changed during preflight"); - - expect(harness.openShieldsSpy).toHaveBeenCalledOnce(); - expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce(); - expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( - ["sandbox", "delete", "alpha"], - expect.anything(), - ); - expect(harness.removeSandboxRegistryEntrySpy).not.toHaveBeenCalled(); - expect(harness.onboardSpy).not.toHaveBeenCalled(); - expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); - expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( - harness.preparedDcodeBuildContext, - ); - }); - - it("preserves the live DCode sandbox when its credential route drifts after backup (#6195)", async () => { - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), - dcodeRouteResults: [ - { ok: true }, - { ok: true }, - { ok: false, detail: "existing sandbox inference probe returned HTTP 401" }, - ], - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("Recorded inference route smoke check failed"); - - expect(harness.preflightDcodeRouteSpy).toHaveBeenCalledTimes(3); - expect(harness.openShieldsSpy).toHaveBeenCalledOnce(); - expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce(); - expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( - ["sandbox", "delete", "alpha"], - expect.anything(), - ); - expect(harness.removeSandboxRegistryEntrySpy).not.toHaveBeenCalled(); - expect(harness.onboardSpy).not.toHaveBeenCalled(); - expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); - expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( - harness.preparedDcodeBuildContext, - ); - }); - - it("preserves live DCode when retained replacement inputs drift after backup (#6195)", async () => { - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), - dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }], - dcodeImageVerificationResults: [true, false], - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("the prepared DCode replacement inputs changed before deletion"); - - expect(harness.openShieldsSpy).toHaveBeenCalledOnce(); - expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce(); - expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( - ["sandbox", "delete", "alpha"], - expect.anything(), - ); - expect(harness.removeSandboxRegistryEntrySpy).not.toHaveBeenCalled(); - expect(harness.onboardSpy).not.toHaveBeenCalled(); - expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); - expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( - harness.preparedDcodeBuildContext, - ); - }); - - it("preserves live DCode when its pinned base image drifts after backup (#6195)", async () => { - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), - dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }], - dcodeBaseImageIds: ["sha256:dcode-base", "sha256:dcode-base", "sha256:changed"], - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("the prepared DCode replacement inputs changed before deletion"); - - expect(harness.openShieldsSpy).toHaveBeenCalledOnce(); - expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce(); - expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( - ["sandbox", "delete", "alpha"], - expect.anything(), - ); - expect(harness.removeSandboxRegistryEntrySpy).not.toHaveBeenCalled(); - expect(harness.onboardSpy).not.toHaveBeenCalled(); - expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); - expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( - harness.preparedDcodeBuildContext, - ); - }); - - it("restores the prior gateway and disposes DCode inputs when shields opening throws (#6195)", async () => { - const restoreEnv = snapshotEnv(["OPENSHELL_GATEWAY"]); - process.env.OPENSHELL_GATEWAY = "previous-gateway"; - let gatewayAtShields: string | undefined; - - try { - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), - dcodeRouteResults: [{ ok: true }, { ok: true }], - openShieldsWindow: () => { - gatewayAtShields = process.env.OPENSHELL_GATEWAY; - throw new Error("shields opening threw unexpectedly"); - }, - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("shields opening threw unexpectedly"); - - expect(gatewayAtShields).toBe("nemoclaw"); - expect(process.env.OPENSHELL_GATEWAY).toBe("previous-gateway"); - expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledOnce(); - expect(harness.openShieldsSpy).toHaveBeenCalledOnce(); - expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); - expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( - ["sandbox", "delete", "alpha"], - expect.anything(), - ); - expect(harness.onboardSpy).not.toHaveBeenCalled(); - expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( - harness.preparedDcodeBuildContext, - ); - } finally { - restoreEnv(); - } - }); - - it("finishes DCode preparation and recheck before backup, delete, and recreate (#6195)", async () => { - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), - dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }], - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).resolves.toBeUndefined(); - - expect(harness.preflightDcodeRouteSpy).toHaveBeenCalledTimes(3); - expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledOnce(); - expect(harness.onboardSpy).toHaveBeenCalledWith( - expect.objectContaining({ - agent: "langchain-deepagents-code", - preparedDcodeRebuild: expect.objectContaining({ - buildContext: harness.preparedDcodeBuildContext, - gatewayName: "nemoclaw", - }), - }), - ); - - const [firstRouteOrder, preBackupRouteOrder, deleteEdgeRouteOrder] = - harness.preflightDcodeRouteSpy.mock.invocationCallOrder; - const imageOrder = harness.prepareManagedDcodeRebuildImageSpy.mock.invocationCallOrder[0]; - const shieldsOrder = harness.openShieldsSpy.mock.invocationCallOrder[0]; - const backupOrder = harness.backupSandboxStateSpy.mock.invocationCallOrder[0]; - const deleteCall = harness.runOpenshellSpy.mock.calls.findIndex( - ([args]) => Array.isArray(args) && args.join(" ") === "sandbox delete alpha", - ); - const deleteOrder = harness.runOpenshellSpy.mock.invocationCallOrder[deleteCall]; - const onboardOrder = harness.onboardSpy.mock.invocationCallOrder[0]; - - expect(firstRouteOrder).toBeLessThan(imageOrder); - expect(imageOrder).toBeLessThan(preBackupRouteOrder); - expect(preBackupRouteOrder).toBeLessThan(shieldsOrder); - expect(shieldsOrder).toBeLessThan(backupOrder); - expect(backupOrder).toBeLessThan(deleteEdgeRouteOrder); - expect(deleteEdgeRouteOrder).toBeLessThan(deleteOrder); - expect(deleteOrder).toBeLessThan(onboardOrder); - expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( - harness.preparedDcodeBuildContext, - ); - }); - - it("recreates non-Ready DCode from a validated backup without requiring a live route (#6195)", async () => { - const recoveryManifest = { - ...makePreparedRecoveryManifest(), - agentType: "langchain-deepagents-code", - agentVersion: "0.1.12", - dir: "/sandbox/.deepagents", - }; - const harness = createRebuildFlowHarness({ - agentName: "langchain-deepagents-code", - sandboxEntry: makeDcodeSandboxEntry(), - sandboxListOutput: "alpha Error", - preDeleteLatestManifest: recoveryManifest, - }); - configureDcodeSession(harness); - - await expect( - harness.rebuildSandbox("alpha", ["--yes"], { - throwOnError: true, - recoveryManifest, - }), - ).resolves.toBeUndefined(); - - expect(harness.preflightDcodeRouteSpy).not.toHaveBeenCalled(); - expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledOnce(); - expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); - expect(harness.runOpenshellSpy).toHaveBeenCalledWith( - ["sandbox", "delete", "alpha"], - expect.objectContaining({ ignoreError: true }), - ); - expect(harness.onboardSpy).toHaveBeenCalledOnce(); - expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( - "alpha", - recoveryManifest.backupPath, - ); - }); -}); diff --git a/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts b/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts new file mode 100644 index 00000000000..f35f201c2af --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + configureDcodeSession, + makeDcodeSandboxEntry, +} from "../../../../test/helpers/rebuild-dcode-flow-support"; +import { + createRebuildFlowHarness, + resetRebuildFlowTestEnvironment, + restoreRebuildFlowTestEnvironment, +} from "../../../../test/helpers/rebuild-flow-harness"; + +describe("rebuildSandbox DCode flow: mutation edge", () => { + beforeEach(resetRebuildFlowTestEnvironment); + afterEach(restoreRebuildFlowTestEnvironment); + + it("finishes DCode preparation and recheck before backup, delete, and recreate (#6195)", async () => { + const mcpEntry = { server: "search", providerName: "mcp-search" }; + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: makeDcodeSandboxEntry(), + dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }, { ok: true }], + mcpPreparation: { + entries: [mcpEntry], + detachedProviderEntries: [], + scrubbedAdapterEntries: [], + }, + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).resolves.toBeUndefined(); + + expect(harness.preflightDcodeRouteSpy).toHaveBeenCalledTimes(4); + expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledOnce(); + expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledWith( + expect.objectContaining({ + compatibleEndpointReasoning: null, + webSearchConfig: null, + }), + ); + expect(harness.onboardSpy).toHaveBeenCalledWith( + expect.objectContaining({ + agent: "langchain-deepagents-code", + preparedDcodeRebuild: expect.objectContaining({ + buildContext: harness.preparedDcodeBuildContext, + gatewayName: "nemoclaw", + }), + }), + ); + + const [firstRouteOrder, preBackupRouteOrder, preMcpRouteOrder, deleteEdgeRouteOrder] = + harness.preflightDcodeRouteSpy.mock.invocationCallOrder; + const imageOrder = harness.prepareManagedDcodeRebuildImageSpy.mock.invocationCallOrder[0]; + const shieldsOrder = harness.openShieldsSpy.mock.invocationCallOrder[0]; + const backupOrder = harness.backupSandboxStateSpy.mock.invocationCallOrder[0]; + const mcpPreparationOrder = harness.prepareMcpBridgesForRebuildSpy.mock.invocationCallOrder[0]; + const warningProbeOrder = + harness.warnUnpreservedUserManagedFilesSpy.mock.invocationCallOrder[0]; + const deleteCall = harness.runOpenshellSpy.mock.calls.findIndex( + ([args]) => Array.isArray(args) && args.join(" ") === "sandbox delete alpha", + ); + const deleteOrder = harness.runOpenshellSpy.mock.invocationCallOrder[deleteCall]; + const onboardOrder = harness.onboardSpy.mock.invocationCallOrder[0]; + + expect(firstRouteOrder).toBeLessThan(imageOrder); + expect(imageOrder).toBeLessThan(preBackupRouteOrder); + expect(preBackupRouteOrder).toBeLessThan(shieldsOrder); + expect(shieldsOrder).toBeLessThan(backupOrder); + expect(backupOrder).toBeLessThan(preMcpRouteOrder); + expect(preMcpRouteOrder).toBeLessThan(mcpPreparationOrder); + expect(mcpPreparationOrder).toBeLessThan(warningProbeOrder); + expect(warningProbeOrder).toBeLessThan(deleteEdgeRouteOrder); + expect(deleteEdgeRouteOrder).toBeLessThan(deleteOrder); + expect(deleteOrder).toBeLessThan(onboardOrder); + expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( + harness.preparedDcodeBuildContext, + ); + expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]); + }); + it("rolls back managed MCP mutation when DCode inputs drift during MCP preparation (#6195)", async () => { + const detached = { server: "search", providerName: "mcp-search" }; + const scrubbed = { server: "filesystem", adapter: "deepagents-config" }; + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: makeDcodeSandboxEntry(), + dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }, { ok: true }], + dcodeImageVerificationResults: [true, true, false], + mcpPreparation: { + entries: [detached], + detachedProviderEntries: [detached], + scrubbedAdapterEntries: [scrubbed], + }, + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("the prepared DCode replacement inputs changed before deletion"); + + expect(harness.prepareMcpBridgesForRebuildSpy).toHaveBeenCalledWith("alpha"); + expect(harness.reattachMcpProvidersAfterRebuildAbortSpy).toHaveBeenCalledWith( + "alpha", + [detached], + [scrubbed], + ); + expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.anything(), + ); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-dcode-orchestrator.test.ts b/src/lib/actions/sandbox/rebuild-dcode-orchestrator.test.ts index 077f0b95600..c6ccaaa8252 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-orchestrator.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-orchestrator.test.ts @@ -46,7 +46,7 @@ describe("DCode rebuild orchestrator", () => { const baseImageOptions = { resolutionHint, forceBaseImageRefresh: true }; await expect( - orchestrator.prepareImage({} as RebuildResumeConfig, false, 19_080, baseImageOptions), + orchestrator.prepareImage({} as RebuildResumeConfig, null, false, 19_080, baseImageOptions), ).resolves.toBe(true); expect(ensureAgentBaseImage).toHaveBeenCalledWith("hermes", bail, baseImageOptions); }); @@ -80,7 +80,7 @@ describe("DCode rebuild orchestrator", () => { const resolutionHint = { key: "sandbox-alpha" } as SandboxBaseImageResolutionMetadata; await expect( - orchestrator.prepareImage(resumeConfig, false, 19_080, { + orchestrator.prepareImage(resumeConfig, null, false, 19_080, { resolutionHint, forceBaseImageRefresh: true, }), @@ -91,6 +91,7 @@ describe("DCode rebuild orchestrator", () => { sandboxName: "alpha", entry, resumeConfig, + webSearchConfig: null, skipLiveRoute: false, gatewayPort: 19_080, }), diff --git a/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts b/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts index a8f82ea312a..da474bbc61f 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { WebSearchConfig } from "../../inference/web-search"; import type { Session } from "../../state/onboard-session"; import { createDcodeRebuildPreflightScope, @@ -46,6 +47,7 @@ export type DcodeRebuildOrchestrator = { preflightCredentials(): Promise; prepareImage( resumeConfig: RebuildResumeConfig, + webSearchConfig: WebSearchConfig | null, skipLiveRoute: boolean, gatewayPort: number, baseImageOptions?: RebuildAgentBaseImageOptions, @@ -55,12 +57,27 @@ export type DcodeRebuildOrchestrator = { skipLiveRoute: boolean, gatewayPort: number, ): Promise; + checkAtDeleteEdge( + resumeConfig: RebuildResumeConfig, + skipLiveRoute: boolean, + gatewayPort: number, + ): Promise<{ ok: true } | { ok: false; message: string; code?: number }>; clearManagedCustomDockerfile(session: Session): void; storedDockerfile(sessionMatchesSandbox: boolean, session: Session | null): string | null; applyDockerGpuPatchNetwork(): () => void; cleanup(): void; }; +class CapturedDcodeRebuildBail extends Error { + readonly code: number | undefined; + + constructor(message: string, code?: number) { + super(message); + this.name = "CapturedDcodeRebuildBail"; + this.code = code; + } +} + export function isDcodeRebuildAgent(agentName: string | null): boolean { return agentName === DCODE_AGENT_NAME; } @@ -112,7 +129,7 @@ export function createDcodeRebuildOrchestrator( } return deps.preflightCredentials(sandboxName, entry, log, scope.bail); }), - prepareImage: (resumeConfig, skipLiveRoute, gatewayPort, baseImageOptions) => + prepareImage: (resumeConfig, webSearchConfig, skipLiveRoute, gatewayPort, baseImageOptions) => run(async () => { if (!scope.enabled) { return deps.ensureAgentBaseImage(rebuildAgent, scope.bail, baseImageOptions); @@ -121,6 +138,7 @@ export function createDcodeRebuildOrchestrator( sandboxName, entry, resumeConfig, + webSearchConfig, skipLiveRoute, gatewayPort, log, @@ -151,6 +169,43 @@ export function createDcodeRebuildOrchestrator( replacement, }); }), + checkAtDeleteEdge: async (resumeConfig, skipLiveRoute, gatewayPort) => { + if (!scope.enabled) return { ok: true }; + const replacement = scope.preparedReplacement; + if (!replacement) { + return { ok: false, message: "DCode replacement preflight was not retained." }; + } + const capturedBail = (message: string, code?: number): never => { + throw new CapturedDcodeRebuildBail(message, code); + }; + try { + const valid = await revalidateDcodeReplacementAtMutationEdge({ + sandboxName, + entry, + resumeConfig, + skipLiveRoute, + gatewayPort, + log, + bail: capturedBail, + checkGatewaySchema: () => deps.checkGatewaySchema(sandboxName, capturedBail), + replacement, + }); + if (!valid) { + scope.cleanup(); + return { + ok: false, + message: "DCode replacement validation failed before sandbox deletion.", + }; + } + return { ok: true }; + } catch (error) { + scope.cleanup(); + if (error instanceof CapturedDcodeRebuildBail) { + return { ok: false, message: error.message, code: error.code }; + } + throw error; + } + }, clearManagedCustomDockerfile(session) { if (scope.enabled) session.metadata = { ...session.metadata, fromDockerfile: null }; }, diff --git a/src/lib/actions/sandbox/rebuild-dcode-pre-delete-drift.test.ts b/src/lib/actions/sandbox/rebuild-dcode-pre-delete-drift.test.ts new file mode 100644 index 00000000000..94a6d7106b0 --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-dcode-pre-delete-drift.test.ts @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + configureDcodeSession, + expectNoDcodeMutation, + makeDcodeSandboxEntry, +} from "../../../../test/helpers/rebuild-dcode-flow-support"; +import { + createRebuildFlowHarness, + resetRebuildFlowTestEnvironment, + restoreRebuildFlowTestEnvironment, +} from "../../../../test/helpers/rebuild-flow-harness"; + +describe("rebuildSandbox DCode flow: pre-delete drift", () => { + beforeEach(resetRebuildFlowTestEnvironment); + afterEach(restoreRebuildFlowTestEnvironment); + + it("rejects registry drift during the final DCode preflight before shields and backup (#6195)", async () => { + const originalEntry = makeDcodeSandboxEntry(); + const driftedEntry = { ...originalEntry, model: "nvidia/changed-during-preflight" }; + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: originalEntry, + sandboxEntryReads: [ + originalEntry, // Initial rebuild target. + originalEntry, // Messaging-conflict gateway selection (#5954). + originalEntry, // Prepared DCode target capture. + driftedEntry, // Final pre-backup target verification. + ], + dcodeRouteResults: [{ ok: true }, { ok: true }], + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("the recorded sandbox target changed during preflight"); + + expect(harness.preflightDcodeRouteSpy).toHaveBeenCalledTimes(2); + expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledOnce(); + expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( + harness.preparedDcodeBuildContext, + ); + expectNoDcodeMutation(harness); + }); + it("disposes the prepared DCode image when the final route recheck fails (#6195)", async () => { + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: makeDcodeSandboxEntry(), + dcodeRouteResults: [ + { ok: true }, + { ok: false, detail: "existing sandbox inference probe returned HTTP 401" }, + ], + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("Recorded inference route smoke check failed"); + + expect(harness.preflightDcodeRouteSpy).toHaveBeenCalledTimes(2); + expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledOnce(); + expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( + harness.preparedDcodeBuildContext, + ); + expectNoDcodeMutation(harness); + }); + it("preserves the live DCode sandbox when its registry target drifts after backup (#6195)", async () => { + const originalEntry = makeDcodeSandboxEntry(); + const driftedEntry = { ...originalEntry, model: "nvidia/changed-at-delete-edge" }; + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: originalEntry, + sandboxEntryReads: [ + originalEntry, // Initial rebuild target. + originalEntry, // Messaging-conflict gateway selection (#5954). + originalEntry, // Prepared DCode target capture. + originalEntry, // Final pre-backup target verification. + originalEntry, // Delete-edge target verification input. + driftedEntry, // Registry reread at the destructive boundary. + ], + dcodeRouteResults: [{ ok: true }, { ok: true }, { ok: true }], + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("the recorded sandbox target changed during preflight"); + + expect(harness.openShieldsSpy).toHaveBeenCalledOnce(); + expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce(); + expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.anything(), + ); + expect(harness.removeSandboxRegistryEntrySpy).not.toHaveBeenCalled(); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); + expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( + harness.preparedDcodeBuildContext, + ); + }); + it("preserves the live DCode sandbox when its credential route drifts after backup (#6195)", async () => { + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: makeDcodeSandboxEntry(), + dcodeRouteResults: [ + { ok: true }, + { ok: true }, + { ok: false, detail: "existing sandbox inference probe returned HTTP 401" }, + ], + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("Recorded inference route smoke check failed"); + + expect(harness.preflightDcodeRouteSpy).toHaveBeenCalledTimes(3); + expect(harness.openShieldsSpy).toHaveBeenCalledOnce(); + expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce(); + expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.anything(), + ); + expect(harness.removeSandboxRegistryEntrySpy).not.toHaveBeenCalled(); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); + expect(harness.disposePreparedDcodeRebuildImageSpy).toHaveBeenCalledWith( + harness.preparedDcodeBuildContext, + ); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-dcode-preflight.test.ts b/src/lib/actions/sandbox/rebuild-dcode-preflight.test.ts new file mode 100644 index 00000000000..f845e48b041 --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-dcode-preflight.test.ts @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + configureDcodeSession, + expectNoDcodeMutation, + makeDcodeSandboxEntry, +} from "../../../../test/helpers/rebuild-dcode-flow-support"; +import { + createRebuildFlowHarness, + resetRebuildFlowTestEnvironment, + restoreRebuildFlowTestEnvironment, + snapshotEnv, +} from "../../../../test/helpers/rebuild-flow-harness"; + +describe("rebuildSandbox DCode flow: preflight", () => { + beforeEach(resetRebuildFlowTestEnvironment); + afterEach(restoreRebuildFlowTestEnvironment); + + it("rejects a stored DCode route failure before any rebuild mutation (#6195)", async () => { + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: makeDcodeSandboxEntry(), + dcodeRouteResults: [ + { ok: false, detail: "existing sandbox inference probe returned HTTP 401" }, + ], + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("Recorded inference route smoke check failed"); + + expect(harness.preflightDcodeRouteSpy).toHaveBeenCalledOnce(); + expect(harness.prepareManagedDcodeRebuildImageSpy).not.toHaveBeenCalled(); + expect(harness.disposePreparedDcodeRebuildImageSpy).not.toHaveBeenCalled(); + expectNoDcodeMutation(harness); + }); + it("keeps DCode intact when its recorded gateway cannot become healthy (#6195)", async () => { + const restoreEnv = snapshotEnv(["OPENSHELL_GATEWAY"]); + process.env.OPENSHELL_GATEWAY = "previous-gateway"; + + try { + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: makeDcodeSandboxEntry(), + gatewayRecoveryResult: { + recovered: false, + attempted: true, + before: { state: "named_unhealthy" }, + after: { state: "named_unhealthy" }, + }, + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("Could not select healthy gateway 'nemoclaw'"); + + expect(process.env.OPENSHELL_GATEWAY).toBe("previous-gateway"); + expect(harness.preflightDcodeRouteSpy).not.toHaveBeenCalled(); + expect(harness.prepareManagedDcodeRebuildImageSpy).not.toHaveBeenCalled(); + expectNoDcodeMutation(harness); + } finally { + restoreEnv(); + } + }); + it("restores the prior gateway when messaging conflict preflight throws after target pin (#6195)", async () => { + const restoreEnv = snapshotEnv(["OPENSHELL_GATEWAY"]); + process.env.OPENSHELL_GATEWAY = "previous-gateway"; + + try { + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: makeDcodeSandboxEntry(), + preflightMessagingConflicts: () => { + throw new Error("messaging conflict preflight failed"); + }, + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("messaging conflict preflight failed"); + + expect(harness.preflightMessagingConflictsSpy).toHaveBeenCalledOnce(); + expect(process.env.OPENSHELL_GATEWAY).toBe("previous-gateway"); + expect(harness.preflightDcodeRouteSpy).not.toHaveBeenCalled(); + expect(harness.prepareManagedDcodeRebuildImageSpy).not.toHaveBeenCalled(); + expect(harness.disposePreparedDcodeRebuildImageSpy).not.toHaveBeenCalled(); + expectNoDcodeMutation(harness); + } finally { + restoreEnv(); + } + }); + it("rejects a DCode replacement-image failure before any rebuild mutation (#6195)", async () => { + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: makeDcodeSandboxEntry(), + dcodeImageResult: { ok: false, detail: "replacement image build failed" }, + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow(); + + expect(harness.preflightDcodeRouteSpy).toHaveBeenCalledOnce(); + expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledOnce(); + expect(harness.disposePreparedDcodeRebuildImageSpy).not.toHaveBeenCalled(); + expectNoDcodeMutation(harness); + }); + it("rejects a managed DCode session with a recorded custom Dockerfile before image preparation (#6195)", async () => { + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: makeDcodeSandboxEntry(), + }); + configureDcodeSession(harness); + harness.session.metadata = { fromDockerfile: "/tmp/custom/Dockerfile" }; + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("Managed DCode rebuild cannot use a recorded custom Dockerfile"); + + expect(harness.preflightDcodeRouteSpy).not.toHaveBeenCalled(); + expect(harness.prepareManagedDcodeRebuildImageSpy).not.toHaveBeenCalled(); + expect(harness.disposePreparedDcodeRebuildImageSpy).not.toHaveBeenCalled(); + expectNoDcodeMutation(harness); + }); + it("rejects a registry-owned DCode custom Dockerfile before image preparation (#6195)", async () => { + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: { + ...makeDcodeSandboxEntry(), + fromDockerfile: "/tmp/registry-owned-custom.Dockerfile", + }, + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("Managed DCode rebuild cannot use a recorded custom Dockerfile"); + + expect(harness.prepareManagedDcodeRebuildImageSpy).not.toHaveBeenCalled(); + expectNoDcodeMutation(harness); + }); + it("lets explicit registry-managed DCode state override stale session Dockerfile metadata (#6195)", async () => { + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: { ...makeDcodeSandboxEntry(), fromDockerfile: null }, + }); + configureDcodeSession(harness); + harness.session.metadata = { fromDockerfile: "/tmp/stale-session.Dockerfile" }; + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).resolves.toBeUndefined(); + + expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledOnce(); + expect(harness.onboardSpy).toHaveBeenCalledWith( + expect.objectContaining({ fromDockerfile: null }), + ); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-dcode-preflight.ts b/src/lib/actions/sandbox/rebuild-dcode-preflight.ts index 4de339a0d68..e1264fb00a6 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-preflight.ts @@ -9,6 +9,7 @@ import { loadAgent } from "../../agent/defs"; import { RD as _RD, R } from "../../cli/terminal-style"; import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; import * as nim from "../../inference/nim"; +import type { WebSearchConfig } from "../../inference/web-search"; import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; import { getResumeSandboxGpuOverrides, @@ -61,6 +62,10 @@ export type DcodeReplacementPreflightInput = { checkGatewaySchema(): boolean; }; +export type DcodeReplacementPreparationInput = DcodeReplacementPreflightInput & { + webSearchConfig: WebSearchConfig | null; +}; + export type DcodeRebuildPreflightScope = { readonly enabled: boolean; readonly bail: DcodeRebuildPreflightBail; @@ -204,19 +209,11 @@ function requireInferenceRoute( } } -function requireManagedDcodeSession( +function loadMatchingDcodeSession( sandboxName: string, - bail: DcodeRebuildPreflightBail, ): ReturnType { const session = onboardSession.loadSession(); - if (session?.sandboxName === sandboxName && session.metadata?.fromDockerfile) { - fail( - "the managed DCode registry entry conflicts with a recorded custom Dockerfile", - bail, - "Managed DCode rebuild cannot use a recorded custom Dockerfile", - ); - } - return session; + return session?.sandboxName === sandboxName ? session : null; } function requireCurrentTarget( @@ -235,7 +232,6 @@ function requireCurrentTarget( if (!isDeepStrictEqual(currentTarget, target)) { fail("the resolved DCode target changed during preflight", bail); } - requireManagedDcodeSession(sandboxName, bail); } function getRecordedGpuConfig( @@ -352,9 +348,18 @@ function disposePreparation( /** Prebuild and revalidate the managed DCode replacement inputs before mutation. */ export async function prepareDcodeReplacementBeforeMutation( - input: DcodeReplacementPreflightInput, + input: DcodeReplacementPreparationInput, ): Promise { - const { sandboxName, entry, resumeConfig, skipLiveRoute, gatewayPort, log, bail } = input; + const { + sandboxName, + entry, + resumeConfig, + webSearchConfig, + skipLiveRoute, + gatewayPort, + log, + bail, + } = input; let buildContext: PreparedDcodeRebuildImage | null = null; let pinnedBase: PinnedDcodeBaseImage | null = null; let transferred = false; @@ -366,7 +371,7 @@ export async function prepareDcodeReplacementBeforeMutation( ); } - const session = requireManagedDcodeSession(sandboxName, bail); + const session = loadMatchingDcodeSession(sandboxName); const target = resolveTarget(entry, resumeConfig, bail, gatewayPort); if (!skipLiveRoute) requireInferenceRoute(sandboxName, target, bail); @@ -379,6 +384,8 @@ export async function prepareDcodeReplacementBeforeMutation( provider: target.provider, model: target.model, preferredInferenceApi: target.preferredInferenceApi, + compatibleEndpointReasoning: resumeConfig.compatibleEndpointReasoning, + webSearchConfig, sandboxGpuConfig, gatewayPort, }), diff --git a/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts b/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts new file mode 100644 index 00000000000..7efc3c817ba --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + configureDcodeSession, + makeDcodeSandboxEntry, +} from "../../../../test/helpers/rebuild-dcode-flow-support"; +import { + createRebuildFlowHarness, + makePreparedRecoveryManifest, + resetRebuildFlowTestEnvironment, + restoreRebuildFlowTestEnvironment, +} from "../../../../test/helpers/rebuild-flow-harness"; + +describe("rebuildSandbox DCode flow: recovery", () => { + beforeEach(resetRebuildFlowTestEnvironment); + afterEach(restoreRebuildFlowTestEnvironment); + + it("recreates non-Ready DCode from a validated backup without requiring a live route (#6195)", async () => { + const recoveryManifest = { + ...makePreparedRecoveryManifest(), + agentType: "langchain-deepagents-code", + agentVersion: "0.1.12", + dir: "/sandbox/.deepagents", + }; + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: makeDcodeSandboxEntry(), + sandboxListOutput: "alpha Error", + preDeleteLatestManifest: recoveryManifest, + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { + throwOnError: true, + recoveryManifest, + }), + ).resolves.toBeUndefined(); + + expect(harness.preflightDcodeRouteSpy).not.toHaveBeenCalled(); + expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledOnce(); + expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); + expect(harness.runOpenshellSpy).toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.objectContaining({ ignoreError: true }), + ); + expect(harness.onboardSpy).toHaveBeenCalledOnce(); + expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( + "alpha", + recoveryManifest.backupPath, + ); + }); + it("replays captured custom policies during stale DCode recovery without a backup (#6195)", async () => { + const customPolicy = { + name: "custom-egress", + content: "network_policies:\n custom-egress: {}\n", + sourcePath: "/tmp/custom-egress.yaml", + }; + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: { + ...makeDcodeSandboxEntry(), + customPolicies: [customPolicy], + policyPresetsFinalized: true, + }, + sandboxListOutput: "", + reconciledSandboxGatewayState: { state: "missing", output: "" }, + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).resolves.toBeUndefined(); + + expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); + expect(harness.applyPresetSpy).not.toHaveBeenCalled(); + expect(harness.applyPresetContentSpy).toHaveBeenCalledWith( + "alpha", + customPolicy.name, + customPolicy.content, + { custom: { sourcePath: customPolicy.sourcePath } }, + ); + expect(harness.registryUpdateSpy).toHaveBeenCalledWith( + "alpha", + expect.objectContaining({ policies: [], policyPresetsFinalized: true }), + ); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts new file mode 100644 index 00000000000..b7dbe99098c --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.test.ts @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + prepareMcpForRebuild: vi.fn(), + reattachMcpAfterDeleteFailure: vi.fn(), + warnUnpreservedUserManagedFiles: vi.fn(), +})); + +vi.mock("./rebuild-flow-helpers", async (importOriginal) => ({ + ...(await importOriginal()), + warnUnpreservedUserManagedFiles: mocks.warnUnpreservedUserManagedFiles, +})); + +vi.mock("./rebuild-mcp-phase", async (importOriginal) => ({ + ...(await importOriginal()), + prepareMcpForRebuild: mocks.prepareMcpForRebuild, + reattachMcpAfterDeleteFailure: mocks.reattachMcpAfterDeleteFailure, +})); + +import { runRebuildDestroyPhase } from "./rebuild-destroy-phase"; + +describe("rebuild destroy validation diagnostics", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + mocks.prepareMcpForRebuild.mockResolvedValue({ + entries: [], + detachedProviderEntries: [], + scrubbedAdapterEntries: [], + }); + mocks.reattachMcpAfterDeleteFailure.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("retains unexpected delete-edge diagnostics without logging credentials (#6195)", async () => { + const secret = `nvapi-${"a".repeat(32)}`; + const log = vi.fn(); + const relockShieldsIfNeeded = vi.fn(() => true); + const bail = vi.fn((message: string): never => { + throw new Error(message); + }); + + await expect( + runRebuildDestroyPhase({ + sandboxName: "alpha", + sandboxEntry: { name: "alpha", agent: "langchain-deepagents-code" }, + staleRecovery: false, + backupManifest: null, + log, + bail, + relockShieldsIfNeeded, + validateAfterMcpPreparation: async () => { + throw new Error(`route probe failed with ${secret}`); + }, + onDeleted: vi.fn(), + }), + ).rejects.toThrow("DCode replacement validation failed before sandbox deletion."); + + const diagnostics = log.mock.calls.flat().join("\n"); + expect(diagnostics).toContain("Unexpected DCode replacement validation failure"); + expect(diagnostics).toContain("route probe failed"); + expect(diagnostics).toContain(""); + expect(diagnostics).not.toContain(secret); + expect(mocks.reattachMcpAfterDeleteFailure).toHaveBeenCalledOnce(); + expect(relockShieldsIfNeeded).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-destroy-phase.ts b/src/lib/actions/sandbox/rebuild-destroy-phase.ts index ec9b1a06b12..a3b95753624 100644 --- a/src/lib/actions/sandbox/rebuild-destroy-phase.ts +++ b/src/lib/actions/sandbox/rebuild-destroy-phase.ts @@ -5,6 +5,7 @@ import { runOpenshell } from "../../adapters/openshell/runtime"; import { G, R } from "../../cli/terminal-style"; import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy"; import * as nim from "../../inference/nim"; +import { redactFull } from "../../security/redact"; import * as registry from "../../state/registry"; import { removeSandboxRegistryEntry } from "./destroy"; import type { RebuildBackupManifest } from "./rebuild-backup-phase"; @@ -17,6 +18,10 @@ import { reattachMcpAfterDeleteFailure, } from "./rebuild-mcp-phase"; +export type RebuildDeleteValidationResult = + | { ok: true } + | { ok: false; message: string; code?: number }; + export interface RebuildDestroyPhaseInput { sandboxName: string; sandboxEntry: RebuildSandboxEntry; @@ -25,6 +30,7 @@ export interface RebuildDestroyPhaseInput { log: RebuildLog; bail: RebuildBail; relockShieldsIfNeeded: (sandboxStillExists: boolean) => boolean; + validateAfterMcpPreparation?: () => Promise; onDeleted: () => void; } @@ -43,6 +49,7 @@ export async function runRebuildDestroyPhase( log, bail, relockShieldsIfNeeded, + validateAfterMcpPreparation, onDeleted, } = input; @@ -56,6 +63,39 @@ export async function runRebuildDestroyPhase( ); const mcpPreparation = await prepareMcpBeforeBestEffortNimStop({ prepareMcp: () => prepareMcpForRebuild(sandboxName, staleRecovery, relockShieldsIfNeeded, bail), + afterPrepare: async (preparation) => { + // MCP preparation removes only adapter entries whose exact ownership + // fingerprints match the registry. Probe afterward so a Deep Agents + // user `.mcp.json` is not confused with the separate managed projection. + // This can block on SSH, so it must finish before the final DCode check. + if (!staleRecovery) warnUnpreservedUserManagedFiles(sandboxName, log); + if (validateAfterMcpPreparation) { + let validation: RebuildDeleteValidationResult; + try { + validation = await validateAfterMcpPreparation(); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + log(`Unexpected DCode replacement validation failure: ${redactFull(detail)}`); + validation = { + ok: false, + message: "DCode replacement validation failed before sandbox deletion.", + }; + } + if (validation.ok) return; + const mcpRecoveryFailure = await reattachMcpAfterDeleteFailure( + sandboxName, + preparation.detachedProviderEntries, + preparation.scrubbedAdapterEntries, + ); + relockShieldsIfNeeded(true); + bail( + mcpRecoveryFailure + ? `${validation.message} MCP provider recovery also failed: ${mcpRecoveryFailure}` + : validation.message, + validation.code, + ); + } + }, stopNim: () => { if (sbMeta && sbMeta.nimContainer) { log(`Stopping NIM container: ${sbMeta.nimContainer}`); @@ -68,11 +108,6 @@ export async function runRebuildDestroyPhase( log, }); if (!mcpPreparation) return null; - // MCP preparation removes only adapter entries whose exact ownership - // fingerprints match the registry. Probe afterward so a Deep Agents - // `.mcp.json` containing only NemoClaw-managed entries is not mislabeled as - // unpreserved user state; any file that remains still needs the warning. - if (!staleRecovery) warnUnpreservedUserManagedFiles(sandboxName, log); const rebuildMcpEntries = mcpPreparation.entries; const rebuildDetachedMcpProviderEntries = mcpPreparation.detachedProviderEntries; const rebuildScrubbedMcpAdapterEntries = mcpPreparation.scrubbedAdapterEntries; diff --git a/src/lib/actions/sandbox/rebuild-durable-config.test.ts b/src/lib/actions/sandbox/rebuild-durable-config.test.ts index fe1d2628aa0..bac8f9fcbad 100644 --- a/src/lib/actions/sandbox/rebuild-durable-config.test.ts +++ b/src/lib/actions/sandbox/rebuild-durable-config.test.ts @@ -102,6 +102,37 @@ describe("resolveRebuildDurableConfig", () => { expect(config.webSearchError).toBeNull(); }); + it("recovers provider-less Tavily for an explicitly enabled DCode selection", () => { + const config = resolveRebuildDurableConfig( + "alpha", + { + name: "alpha", + agent: "langchain-deepagents-code", + policies: ["tavily"], + webSearchEnabled: true, + nemoclawVersion: "0.1.0", + }, + createSession({ sandboxName: "other", webSearchConfig: null }), + ); + expect(config.webSearchConfig).toEqual({ fetchEnabled: true, provider: "tavily" }); + expect(config.webSearchError).toBeNull(); + }); + + it.each([null, "hermes"])('migrates a provider-less Tavily policy for agent "%s"', (agent) => { + const config = resolveRebuildDurableConfig( + "alpha", + { + name: "alpha", + agent, + policies: ["tavily"], + nemoclawVersion: "0.1.0", + }, + createSession({ sandboxName: "other", webSearchConfig: null }), + ); + expect(config.webSearchConfig).toEqual({ fetchEnabled: true, provider: "tavily" }); + expect(config.webSearchError).toBeNull(); + }); + it("backfills a legacy enabled provider from the matching Tavily session", () => { const config = resolveRebuildDurableConfig( "alpha", @@ -136,6 +167,84 @@ describe("resolveRebuildDurableConfig", () => { expect(config.webSearchConfig).toBeNull(); }); + it("does not infer managed Tavily from a custom same-name policy", () => { + const config = resolveRebuildDurableConfig( + "alpha", + { + name: "alpha", + policies: ["tavily"], + customPolicies: [{ name: "tavily", content: "allow: []" }], + nemoclawVersion: "0.1.0", + }, + createSession({ sandboxName: "other", webSearchConfig: null }), + ); + expect(config.webSearchConfig).toBeNull(); + }); + + it("fails closed when provider-less durable policies select both web-search providers", () => { + const config = resolveRebuildDurableConfig( + "alpha", + { + name: "alpha", + policies: ["brave", "tavily"], + webSearchEnabled: true, + nemoclawVersion: "0.1.0", + }, + createSession({ sandboxName: "other", webSearchConfig: null }), + ); + expect(config.webSearchConfig).toBeNull(); + expect(config.webSearchError).toContain("more than one provider"); + }); + + it("lets an explicit provider resolve stale dual-policy state", () => { + const config = resolveRebuildDurableConfig( + "alpha", + { + name: "alpha", + policies: ["brave", "tavily"], + webSearchEnabled: true, + webSearchProvider: "tavily", + nemoclawVersion: "0.1.0", + }, + createSession({ sandboxName: "other", webSearchConfig: null }), + ); + expect(config.webSearchConfig).toEqual({ fetchEnabled: true, provider: "tavily" }); + expect(config.webSearchError).toBeNull(); + }); + + it("uses the unshadowed provider when the other policy name is custom", () => { + const config = resolveRebuildDurableConfig( + "alpha", + { + name: "alpha", + policies: ["brave", "tavily"], + customPolicies: [{ name: "brave", content: "allow: []" }], + webSearchEnabled: true, + nemoclawVersion: "0.1.0", + }, + createSession({ sandboxName: "other", webSearchConfig: null }), + ); + expect(config.webSearchConfig).toEqual({ fetchEnabled: true, provider: "tavily" }); + expect(config.webSearchError).toBeNull(); + }); + + it("fails closed when the managed provider is shadowed by a custom same-name policy", () => { + const config = resolveRebuildDurableConfig( + "alpha", + { + name: "alpha", + policies: ["tavily"], + customPolicies: [{ name: "tavily", content: "allow: []" }], + webSearchEnabled: true, + webSearchProvider: "tavily", + nemoclawVersion: "0.1.0", + }, + createSession({ sandboxName: "other", webSearchConfig: null }), + ); + expect(config.webSearchConfig).toBeNull(); + expect(config.webSearchError).toContain("conflicts with a custom same-name policy"); + }); + it("fails closed for an invalid durable web-search provider", () => { const config = resolveRebuildDurableConfig( "alpha", diff --git a/src/lib/actions/sandbox/rebuild-durable-config.ts b/src/lib/actions/sandbox/rebuild-durable-config.ts index cb685ef0e0a..96ce9a02fa9 100644 --- a/src/lib/actions/sandbox/rebuild-durable-config.ts +++ b/src/lib/actions/sandbox/rebuild-durable-config.ts @@ -22,6 +22,7 @@ import { } from "../../inference/web-search"; import { resolveHermesDashboardOnboardState } from "../../onboard/hermes-dashboard"; import type { Session } from "../../state/onboard-session"; +import { DCODE_AGENT_NAME } from "./rebuild-dcode-target"; import type { RebuildSandboxEntry } from "./rebuild-flow-helpers"; import type { RebuildResumeConfig } from "./rebuild-resume-config"; @@ -101,6 +102,13 @@ function normalizeHermesAuthMethod(value: unknown): "oauth" | "api_key" | null { return value === "oauth" || value === "api_key" ? value : null; } +function builtinWebSearchPolicyProviders(entry: RebuildSandboxEntry): WebSearchProvider[] { + const customPolicyNames = new Set(entry.customPolicies?.map((policy) => policy.name) ?? []); + return (["brave", "tavily"] as const).filter( + (provider) => entry.policies?.includes(provider) === true && !customPolicyNames.has(provider), + ); +} + export function resolveRebuildDurableConfig( sandboxName: string, entry: RebuildSandboxEntry, @@ -116,21 +124,26 @@ export function resolveRebuildDurableConfig( (!resolvedSelection.model || session.model === resolvedSelection.model) ? session : null; - const legacyBravePolicy = - entry.policies?.includes("brave") === true && - !entry.customPolicies?.some((policy) => policy.name === "brave"); - const legacyTavilyPolicy = - entry.agent !== "langchain-deepagents-code" && - entry.policies?.includes("tavily") === true && - !entry.customPolicies?.some((policy) => policy.name === "tavily"); + const customPolicyNames = new Set(entry.customPolicies?.map((policy) => policy.name) ?? []); + const policyProviders = builtinWebSearchPolicyProviders(entry); + const migrationPolicyProviders = + entry.webSearchEnabled === true || entry.agent !== DCODE_AGENT_NAME + ? policyProviders + : policyProviders.filter((provider) => provider === "brave"); const recordedWebSearchProvider = entry.webSearchProvider; + const validRecordedWebSearchProvider = isWebSearchProvider(recordedWebSearchProvider) + ? recordedWebSearchProvider + : null; + const sessionWebSearchProvider = + matchingSession?.webSearchConfig?.fetchEnabled === true + ? webSearchProviderForConfig(matchingSession.webSearchConfig) + : null; const webSearchEnabled = typeof entry.webSearchEnabled === "boolean" ? entry.webSearchEnabled - : isWebSearchProvider(recordedWebSearchProvider) || + : validRecordedWebSearchProvider !== null || matchingSession?.webSearchConfig?.fetchEnabled === true || - legacyBravePolicy || - legacyTavilyPolicy; + migrationPolicyProviders.length > 0; let webSearchError: string | null = null; if (entry.webSearchEnabled !== undefined && typeof entry.webSearchEnabled !== "boolean") { webSearchError = "recorded webSearchEnabled value is not boolean"; @@ -140,18 +153,27 @@ export function resolveRebuildDurableConfig( !isWebSearchProvider(recordedWebSearchProvider) ) { webSearchError = "recorded webSearchProvider value is invalid"; - } else if (!webSearchEnabled && isWebSearchProvider(recordedWebSearchProvider)) { + } else if (!webSearchEnabled && validRecordedWebSearchProvider) { webSearchError = "recorded webSearchProvider is set while web search is disabled"; + } else if ( + webSearchEnabled && + !validRecordedWebSearchProvider && + !sessionWebSearchProvider && + migrationPolicyProviders.length > 1 + ) { + webSearchError = "recorded web-search policies select more than one provider"; } let webSearchProvider: WebSearchProvider | null = null; if (webSearchEnabled && !webSearchError) { - webSearchProvider = isWebSearchProvider(recordedWebSearchProvider) - ? recordedWebSearchProvider - : matchingSession?.webSearchConfig?.fetchEnabled === true - ? webSearchProviderForConfig(matchingSession.webSearchConfig) - : legacyTavilyPolicy - ? "tavily" - : "brave"; + webSearchProvider = + validRecordedWebSearchProvider ?? + sessionWebSearchProvider ?? + migrationPolicyProviders[0] ?? + "brave"; + if (customPolicyNames.has(webSearchProvider)) { + webSearchError = `managed web-search provider '${webSearchProvider}' conflicts with a custom same-name policy`; + webSearchProvider = null; + } } const recordedFromDockerfile: unknown = entry.fromDockerfile !== undefined diff --git a/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts b/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts new file mode 100644 index 00000000000..a57dede558f --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts @@ -0,0 +1,109 @@ +// 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, vi } from "vitest"; + +import { restoreEnv } from "../../../../test/helpers/env-test-helpers"; +import { + dcodeInput, + expectPreparedImage, +} from "../../../../test/helpers/rebuild-managed-image-preflight-harness"; +import { + disposePreparedDcodeRebuildImage, + prepareManagedDcodeRebuildImage, +} from "./rebuild-managed-image-preflight"; + +describe("managed DCode rebuild image configuration", () => { + it("pins recorded reasoning and web search while restoring ambient state (#6195)", async () => { + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-fidelity-")); + const stagedDockerfile = path.join(testRoot, "Dockerfile"); + fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + const previousReasoning = process.env.NEMOCLAW_REASONING; + process.env.NEMOCLAW_REASONING = "false"; + let reasoningDuringPatch: string | undefined; + const prepareDockerfilePatch = vi.fn(async () => { + reasoningDuringPatch = process.env.NEMOCLAW_REASONING; + return { buildId: "dcode-fidelity", resolvedBaseImage: null }; + }); + + try { + const result = await prepareManagedDcodeRebuildImage( + dcodeInput({ + compatibleEndpointReasoning: "true", + webSearchConfig: { fetchEnabled: true, provider: "tavily" }, + }), + { + stageBuildContext: () => ({ + buildCtx: testRoot, + stagedDockerfile, + origin: "generated" as const, + cleanupBuildCtx: () => { + fs.rmSync(testRoot, { recursive: true, force: true }); + return true; + }, + }), + prepareDockerfilePatch, + buildImage: () => ({ status: 0 }) as never, + removeImage: () => ({ status: 0 }) as never, + }, + ); + + expect(result.ok).toBe(true); + expect(reasoningDuringPatch).toBe("true"); + expect(prepareDockerfilePatch).toHaveBeenCalledWith( + expect.objectContaining({ + webSearchConfig: { fetchEnabled: true, provider: "tavily" }, + }), + ); + expect(process.env.NEMOCLAW_REASONING).toBe("false"); + disposePreparedDcodeRebuildImage(expectPreparedImage(result)); + } finally { + fs.rmSync(testRoot, { recursive: true, force: true }); + restoreEnv("NEMOCLAW_REASONING", previousReasoning); + } + }); + + it("defaults missing compatible-endpoint reasoning without borrowing ambient state (#6195)", async () => { + const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-reasoning-")); + const stagedDockerfile = path.join(buildCtx, "Dockerfile"); + fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + const previousReasoning = process.env.NEMOCLAW_REASONING; + process.env.NEMOCLAW_REASONING = "true"; + let reasoningDuringPatch: string | undefined; + + try { + const result = await prepareManagedDcodeRebuildImage( + dcodeInput({ compatibleEndpointReasoning: null }), + { + stageBuildContext: () => ({ + buildCtx, + stagedDockerfile, + origin: "generated" as const, + cleanupBuildCtx: () => { + fs.rmSync(buildCtx, { recursive: true, force: true }); + return true; + }, + }), + prepareDockerfilePatch: async () => { + reasoningDuringPatch = process.env.NEMOCLAW_REASONING; + return { buildId: "dcode-reasoning-default", resolvedBaseImage: null }; + }, + buildImage: () => ({ status: 0 }) as never, + removeImage: () => ({ status: 0 }) as never, + }, + ); + + expect(result.ok).toBe(true); + expect(reasoningDuringPatch).toBe("false"); + expect(process.env.NEMOCLAW_REASONING).toBe("true"); + disposePreparedDcodeRebuildImage(expectPreparedImage(result)); + } finally { + fs.rmSync(buildCtx, { recursive: true, force: true }); + restoreEnv("NEMOCLAW_REASONING", previousReasoning); + } + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-managed-image-preflight.test.ts b/src/lib/actions/sandbox/rebuild-managed-image-preflight.test.ts deleted file mode 100644 index e1b5335a33c..00000000000 --- a/src/lib/actions/sandbox/rebuild-managed-image-preflight.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -// 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, vi } from "vitest"; - -import { loadAgent } from "../../agent/defs"; -import { ROOT } from "../../runner"; -import { - disposePreparedDcodeRebuildImage, - type ManagedDcodeRebuildImageInput, - type ManagedDcodeRebuildImageResult, - type PreparedDcodeRebuildImage, - prepareManagedDcodeRebuildImage, - verifyPreparedDcodeRebuildImage, -} from "./rebuild-managed-image-preflight"; - -function expectPreparedImage(result: ManagedDcodeRebuildImageResult): PreparedDcodeRebuildImage { - expect(result.ok).toBe(true); - return (result as Extract).prepared; -} - -function dcodeInput( - overrides: Partial = {}, -): ManagedDcodeRebuildImageInput { - return { - agent: loadAgent("langchain-deepagents-code"), - model: "nvidia/nemotron-3-super-120b-a12b", - provider: "compatible-endpoint", - preferredInferenceApi: "openai-completions", - sandboxGpuConfig: { - mode: "0", - hostGpuDetected: false, - hostGpuPlatform: null, - sandboxGpuEnabled: false, - sandboxGpuDevice: null, - errors: [], - }, - ...overrides, - }; -} - -describe("managed DCode rebuild image preflight", () => { - it("prebuilds the recorded DCode replacement and transfers one disposable context (#6195)", async () => { - const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-context-")); - const buildCtx = path.join(testRoot, "context"); - fs.mkdirSync(buildCtx); - const stagedDockerfile = path.join(buildCtx, "Dockerfile"); - const originalDockerfile = path.join(testRoot, "Dockerfile.original"); - const replacementDockerfile = path.join(testRoot, "Dockerfile.replacement"); - fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); - fs.writeFileSync(replacementDockerfile, "FROM attacker-controlled\n"); - const cleanupBuildCtx = vi.fn(() => { - fs.rmSync(testRoot, { recursive: true, force: true }); - return true; - }); - const stageBuildContext = vi.fn(() => ({ - buildCtx, - stagedDockerfile, - cleanupBuildCtx, - origin: "generated" as const, - })); - const prepareDockerfilePatch = vi.fn(async () => ({ - buildId: "dcode-build-1", - resolvedBaseImage: null, - })); - const buildImage = vi.fn(() => ({ status: 0 }) as never); - const removeImage = vi.fn(() => ({ status: 0 }) as never); - - const result = await prepareManagedDcodeRebuildImage(dcodeInput(), { - stageBuildContext, - prepareDockerfilePatch, - buildImage, - removeImage, - createImageTag: () => "nemoclaw-rebuild-preflight:dcode-success", - }); - - expect(result).toMatchObject({ - ok: true, - prepared: { - buildCtx, - stagedDockerfile, - buildId: "dcode-build-1", - dockerGpuPatchNetwork: null, - }, - }); - expect(stageBuildContext).toHaveBeenCalledWith( - expect.objectContaining({ - root: ROOT, - agent: expect.objectContaining({ name: "langchain-deepagents-code" }), - fromDockerfile: null, - }), - ); - expect(prepareDockerfilePatch).toHaveBeenCalledWith( - expect.objectContaining({ - agent: expect.objectContaining({ name: "langchain-deepagents-code" }), - provider: "compatible-endpoint", - model: "nvidia/nemotron-3-super-120b-a12b", - preferredInferenceApi: "openai-completions", - chatUiUrl: "", - }), - ); - expect(buildImage).toHaveBeenCalledWith( - stagedDockerfile, - "nemoclaw-rebuild-preflight:dcode-success", - buildCtx, - expect.objectContaining({ ignoreError: true, suppressOutput: true }), - ); - expect(removeImage).toHaveBeenCalledWith("nemoclaw-rebuild-preflight:dcode-success", { - ignoreError: true, - suppressOutput: true, - }); - expect(cleanupBuildCtx).not.toHaveBeenCalled(); - - const prepared = expectPreparedImage(result); - const mutationFd = fs.openSync(stagedDockerfile, fs.constants.O_WRONLY); - const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; - const nonBlock = typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0; - const stableOpen = vi.spyOn(fs, "openSync"); - const stableRead = vi.spyOn(fs, "readFileSync"); - try { - expect(verifyPreparedDcodeRebuildImage(prepared)).toBe(true); - const fileOpen = stableOpen.mock.calls.find( - ([candidate]) => String(candidate) === stagedDockerfile, - ); - const flags = Number(fileOpen?.[1] ?? 0); - expect(flags & noFollow).toBe(noFollow); - expect(flags & nonBlock).toBe(nonBlock); - expect(stableRead).toHaveBeenCalledWith(expect.any(Number)); - expect(stableRead).not.toHaveBeenCalledWith(stagedDockerfile); - } finally { - stableRead.mockRestore(); - stableOpen.mockRestore(); - } - - const realOpen: typeof fs.openSync = fs.openSync.bind(fs); - const preOpenSwap = new Map void>([ - [ - stagedDockerfile, - () => { - fs.renameSync(stagedDockerfile, originalDockerfile); - fs.symlinkSync(replacementDockerfile, stagedDockerfile); - }, - ], - ]); - const preOpenRead = vi.spyOn(fs, "readFileSync"); - const preOpen = vi.spyOn(fs, "openSync").mockImplementation(((target, flags, mode) => { - const key = String(target); - const swap = preOpenSwap.get(key); - preOpenSwap.delete(key); - swap?.(); - return realOpen(target, flags, mode); - }) as typeof fs.openSync); - try { - expect(verifyPreparedDcodeRebuildImage(prepared)).toBe(false); - expect(preOpenRead).not.toHaveBeenCalled(); - } finally { - preOpen.mockRestore(); - preOpenRead.mockRestore(); - } - expect(preOpenSwap.size).toBe(0); - fs.rmSync(stagedDockerfile); - fs.renameSync(originalDockerfile, stagedDockerfile); - - const swapOnOpen = new Map void>([ - [ - stagedDockerfile, - () => { - fs.renameSync(stagedDockerfile, originalDockerfile); - fs.symlinkSync(replacementDockerfile, stagedDockerfile); - }, - ], - ]); - const racingOpen = vi.spyOn(fs, "openSync").mockImplementation(((target, flags, mode) => { - const fd = realOpen(target, flags, mode); - const key = String(target); - const swap = swapOnOpen.get(key); - swapOnOpen.delete(key); - swap?.(); - return fd; - }) as typeof fs.openSync); - try { - expect(verifyPreparedDcodeRebuildImage(prepared)).toBe(false); - } finally { - racingOpen.mockRestore(); - } - expect(swapOnOpen.size).toBe(0); - expect(fs.lstatSync(stagedDockerfile).isSymbolicLink()).toBe(true); - - const fallbackRead = vi.spyOn(fs, "readFileSync"); - const fallbackOpen = vi - .spyOn(fs, "openSync") - .mockImplementation(((target, flags, mode) => - realOpen(target, Number(flags) & ~noFollow, mode)) as typeof fs.openSync); - try { - expect(verifyPreparedDcodeRebuildImage(prepared)).toBe(false); - expect(fallbackRead).not.toHaveBeenCalled(); - } finally { - fallbackOpen.mockRestore(); - fallbackRead.mockRestore(); - } - - fs.rmSync(stagedDockerfile); - fs.renameSync(originalDockerfile, stagedDockerfile); - fs.writeFileSync(replacementDockerfile, "FROM scratch\n"); - - const originalRead: typeof fs.readFileSync = fs.readFileSync.bind(fs); - const replaceAfterRead = vi.spyOn(fs, "readFileSync").mockImplementationOnce((( - ...args: unknown[] - ) => { - const contents = Reflect.apply(originalRead, fs, args) as Buffer; - fs.renameSync(stagedDockerfile, originalDockerfile); - fs.renameSync(replacementDockerfile, stagedDockerfile); - return contents; - }) as never); - try { - expect(verifyPreparedDcodeRebuildImage(prepared)).toBe(false); - } finally { - replaceAfterRead.mockRestore(); - } - fs.rmSync(stagedDockerfile); - fs.renameSync(originalDockerfile, stagedDockerfile); - - const appendAfterRead = vi.spyOn(fs, "readFileSync").mockImplementationOnce((( - ...args: unknown[] - ) => { - const contents = Reflect.apply(originalRead, fs, args) as Buffer; - fs.appendFileSync(stagedDockerfile, "# changed during fingerprinting\n"); - return contents; - }) as never); - try { - expect(verifyPreparedDcodeRebuildImage(prepared)).toBe(false); - } finally { - appendAfterRead.mockRestore(); - } - fs.ftruncateSync(mutationFd, 0); - fs.writeSync(mutationFd, "FROM scratch\n", 0, "utf8"); - expect(verifyPreparedDcodeRebuildImage(prepared)).toBe(true); - - fs.writeSync(mutationFd, "# changed after preflight\n", 0, "utf8"); - expect(verifyPreparedDcodeRebuildImage(prepared)).toBe(false); - fs.closeSync(mutationFd); - - expect(disposePreparedDcodeRebuildImage(prepared)).toBe(true); - expect(disposePreparedDcodeRebuildImage(prepared)).toBe(true); - expect(cleanupBuildCtx).toHaveBeenCalledOnce(); - }); - - it("retries retained-context cleanup after a transient removal failure (#6195)", async () => { - const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-cleanup-")); - const stagedDockerfile = path.join(buildCtx, "Dockerfile"); - fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); - const cleanupBuildCtx = vi - .fn<() => boolean>() - .mockReturnValueOnce(false) - .mockImplementationOnce(() => { - fs.rmSync(buildCtx, { recursive: true, force: true }); - return true; - }); - const result = await prepareManagedDcodeRebuildImage(dcodeInput(), { - stageBuildContext: vi.fn(() => ({ - buildCtx, - stagedDockerfile, - cleanupBuildCtx, - origin: "generated" as const, - })), - prepareDockerfilePatch: vi.fn(async () => ({ - buildId: "dcode-build-cleanup", - resolvedBaseImage: null, - })), - buildImage: vi.fn(() => ({ status: 0 }) as never), - removeImage: vi.fn(() => ({ status: 0 }) as never), - createImageTag: () => "nemoclaw-rebuild-preflight:dcode-cleanup", - }); - - const prepared = expectPreparedImage(result); - expect(disposePreparedDcodeRebuildImage(prepared)).toBe(false); - expect(disposePreparedDcodeRebuildImage(prepared)).toBe(true); - expect(cleanupBuildCtx).toHaveBeenCalledTimes(2); - }); - - it("redacts failed build output and cleans every temporary image input (#6195)", async () => { - const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-failure-")); - const stagedDockerfile = path.join(buildCtx, "Dockerfile"); - fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); - const cleanupBuildCtx = vi.fn(() => { - fs.rmSync(buildCtx, { recursive: true, force: true }); - return true; - }); - const removeImage = vi.fn(() => ({ status: 0 }) as never); - const secret = "nvapi-secret-value-that-must-not-leak"; - - const result = await prepareManagedDcodeRebuildImage(dcodeInput(), { - stageBuildContext: vi.fn(() => ({ - buildCtx, - stagedDockerfile, - cleanupBuildCtx, - origin: "generated" as const, - })), - prepareDockerfilePatch: vi.fn(async () => ({ - buildId: "dcode-build-failure", - resolvedBaseImage: null, - })), - buildImage: vi.fn( - () => - ({ - status: 23, - stderr: `provider rejected ${secret}`, - stdout: "buffered build output", - }) as never, - ), - removeImage, - createImageTag: () => "nemoclaw-rebuild-preflight:dcode-failure", - }); - - expect(result).toMatchObject({ - ok: false, - detail: expect.stringContaining("provider rejected"), - }); - expect(JSON.stringify(result)).not.toContain(secret); - expect(removeImage).toHaveBeenCalledWith("nemoclaw-rebuild-preflight:dcode-failure", { - ignoreError: true, - suppressOutput: true, - }); - expect(cleanupBuildCtx).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts b/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts index 5f457dadf86..ba109ac980c 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts @@ -7,8 +7,9 @@ import path from "node:path"; import { dockerBuild, dockerRmi } from "../../adapters/docker"; import type { AgentDefinition } from "../../agent/defs"; -import { GATEWAY_PORT } from "../../core/ports"; import { createAgentSandbox } from "../../agent/onboard"; +import { GATEWAY_PORT } from "../../core/ports"; +import type { WebSearchConfig } from "../../inference/web-search"; import { type PreparedSandboxBuildContext, stageCreateSandboxBuildContext, @@ -28,6 +29,8 @@ export type ManagedDcodeRebuildImageInput = { model: string; provider: string; preferredInferenceApi: string | null; + compatibleEndpointReasoning: "true" | "false" | null; + webSearchConfig: WebSearchConfig | null; sandboxGpuConfig: SandboxGpuConfig; gatewayPort?: number; }; @@ -222,6 +225,7 @@ export async function prepareManagedDcodeRebuildImage( const removeImage = deps.removeImage ?? dockerRmi; const imageTag = (deps.createImageTag ?? defaultImageTag)(); const previousDockerGpuPatchNetwork = process.env.NEMOCLAW_DOCKER_GPU_PATCH_NETWORK; + const previousReasoning = process.env.NEMOCLAW_REASONING; let cleanupBuildContext: (() => boolean) | null = null; let imageBuilt = false; let retainBuildContext = false; @@ -230,6 +234,11 @@ export async function prepareManagedDcodeRebuildImage( // Recompute the patch decision from the recorded target rather than a // caller's unrelated ambient rebuild environment. delete process.env.NEMOCLAW_DOCKER_GPU_PATCH_NETWORK; + if (input.provider === "compatible-endpoint") { + process.env.NEMOCLAW_REASONING = input.compatibleEndpointReasoning ?? "false"; + } else { + delete process.env.NEMOCLAW_REASONING; + } const staged = stage({ root: ROOT, @@ -255,7 +264,7 @@ export async function prepareManagedDcodeRebuildImage( chatUiUrl: "", provider: input.provider, preferredInferenceApi: input.preferredInferenceApi, - webSearchConfig: null, + webSearchConfig: input.webSearchConfig, hermesToolGateways: [], sandboxGpuConfig: input.sandboxGpuConfig, gatewayPort: input.gatewayPort ?? GATEWAY_PORT, @@ -318,5 +327,7 @@ export async function prepareManagedDcodeRebuildImage( } else { process.env.NEMOCLAW_DOCKER_GPU_PATCH_NETWORK = previousDockerGpuPatchNetwork; } + if (previousReasoning === undefined) delete process.env.NEMOCLAW_REASONING; + else process.env.NEMOCLAW_REASONING = previousReasoning; } } diff --git a/src/lib/actions/sandbox/rebuild-managed-image-preparation.test.ts b/src/lib/actions/sandbox/rebuild-managed-image-preparation.test.ts new file mode 100644 index 00000000000..4e6e1ee4697 --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-managed-image-preparation.test.ts @@ -0,0 +1,149 @@ +// 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, vi } from "vitest"; + +import { + cleanupPreparedDcodeImageFixture, + createPreparedDcodeImageFixture, + dcodeInput, + expectPreparedImage, +} from "../../../../test/helpers/rebuild-managed-image-preflight-harness"; +import { ROOT } from "../../runner"; +import { + disposePreparedDcodeRebuildImage, + prepareManagedDcodeRebuildImage, +} from "./rebuild-managed-image-preflight"; + +describe("managed DCode rebuild image preparation", () => { + it("prebuilds the recorded DCode replacement and transfers one disposable context (#6195)", async () => { + const fixture = await createPreparedDcodeImageFixture(); + try { + expect(fixture.result).toMatchObject({ + ok: true, + prepared: { + buildCtx: fixture.buildCtx, + stagedDockerfile: fixture.stagedDockerfile, + origin: "generated", + buildId: "dcode-build-1", + dockerGpuPatchNetwork: null, + }, + }); + expect(fixture.stageBuildContext).toHaveBeenCalledWith( + expect.objectContaining({ + root: ROOT, + agent: expect.objectContaining({ name: "langchain-deepagents-code" }), + fromDockerfile: null, + }), + ); + expect(fixture.prepareDockerfilePatch).toHaveBeenCalledWith( + expect.objectContaining({ + agent: expect.objectContaining({ name: "langchain-deepagents-code" }), + provider: "compatible-endpoint", + model: "nvidia/nemotron-3-super-120b-a12b", + preferredInferenceApi: "openai-completions", + chatUiUrl: "", + }), + ); + expect(fixture.buildImage).toHaveBeenCalledWith( + fixture.stagedDockerfile, + "nemoclaw-rebuild-preflight:dcode-success", + fixture.buildCtx, + expect.objectContaining({ ignoreError: true, suppressOutput: true }), + ); + expect(fixture.removeImage).toHaveBeenCalledWith("nemoclaw-rebuild-preflight:dcode-success", { + ignoreError: true, + suppressOutput: true, + }); + expect(fixture.cleanupBuildCtx).not.toHaveBeenCalled(); + expect(disposePreparedDcodeRebuildImage(fixture.prepared)).toBe(true); + expect(disposePreparedDcodeRebuildImage(fixture.prepared)).toBe(true); + expect(fixture.cleanupBuildCtx).toHaveBeenCalledOnce(); + } finally { + cleanupPreparedDcodeImageFixture(fixture); + } + }); + + it("retries retained-context cleanup after a transient removal failure (#6195)", async () => { + const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-cleanup-")); + const stagedDockerfile = path.join(buildCtx, "Dockerfile"); + fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + const cleanupBuildCtx = vi + .fn<() => boolean>() + .mockReturnValueOnce(false) + .mockImplementationOnce(() => { + fs.rmSync(buildCtx, { recursive: true, force: true }); + return true; + }); + const result = await prepareManagedDcodeRebuildImage(dcodeInput(), { + stageBuildContext: vi.fn(() => ({ + buildCtx, + stagedDockerfile, + cleanupBuildCtx, + origin: "generated" as const, + })), + prepareDockerfilePatch: vi.fn(async () => ({ + buildId: "dcode-build-cleanup", + resolvedBaseImage: null, + })), + buildImage: vi.fn(() => ({ status: 0 }) as never), + removeImage: vi.fn(() => ({ status: 0 }) as never), + createImageTag: () => "nemoclaw-rebuild-preflight:dcode-cleanup", + }); + + const prepared = expectPreparedImage(result); + expect(disposePreparedDcodeRebuildImage(prepared)).toBe(false); + expect(disposePreparedDcodeRebuildImage(prepared)).toBe(true); + expect(cleanupBuildCtx).toHaveBeenCalledTimes(2); + }); + + it("redacts failed build output and cleans every temporary image input (#6195)", async () => { + const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-failure-")); + const stagedDockerfile = path.join(buildCtx, "Dockerfile"); + fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + const cleanupBuildCtx = vi.fn(() => { + fs.rmSync(buildCtx, { recursive: true, force: true }); + return true; + }); + const removeImage = vi.fn(() => ({ status: 0 }) as never); + const secret = "nvapi-secret-value-that-must-not-leak"; + + const result = await prepareManagedDcodeRebuildImage(dcodeInput(), { + stageBuildContext: vi.fn(() => ({ + buildCtx, + stagedDockerfile, + cleanupBuildCtx, + origin: "generated" as const, + })), + prepareDockerfilePatch: vi.fn(async () => ({ + buildId: "dcode-build-failure", + resolvedBaseImage: null, + })), + buildImage: vi.fn( + () => + ({ + status: 23, + stderr: `provider rejected ${secret}`, + stdout: "buffered build output", + }) as never, + ), + removeImage, + createImageTag: () => "nemoclaw-rebuild-preflight:dcode-failure", + }); + + expect(result).toMatchObject({ + ok: false, + detail: expect.stringContaining("provider rejected"), + }); + expect(JSON.stringify(result)).not.toContain(secret); + expect(removeImage).toHaveBeenCalledWith("nemoclaw-rebuild-preflight:dcode-failure", { + ignoreError: true, + suppressOutput: true, + }); + expect(cleanupBuildCtx).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-managed-image-verification.test.ts b/src/lib/actions/sandbox/rebuild-managed-image-verification.test.ts new file mode 100644 index 00000000000..5da256ec77f --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-managed-image-verification.test.ts @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +import { describe, expect, it, vi } from "vitest"; + +import { + cleanupPreparedDcodeImageFixture, + createPreparedDcodeImageFixture, + NO_FOLLOW_FLAG, + NON_BLOCK_FLAG, +} from "../../../../test/helpers/rebuild-managed-image-preflight-harness"; +import { + disposePreparedDcodeRebuildImage, + verifyPreparedDcodeRebuildImage, +} from "./rebuild-managed-image-preflight"; + +describe("managed DCode rebuild image verification", () => { + it("reads the prepared Dockerfile through a no-follow nonblocking descriptor (#6195)", async () => { + const fixture = await createPreparedDcodeImageFixture(); + const stableOpen = vi.spyOn(fs, "openSync"); + const stableRead = vi.spyOn(fs, "readFileSync"); + try { + expect(verifyPreparedDcodeRebuildImage(fixture.prepared)).toBe(true); + const fileOpen = stableOpen.mock.calls.find( + ([candidate]) => String(candidate) === fixture.stagedDockerfile, + ); + const flags = Number(fileOpen?.[1] ?? 0); + expect(flags & NO_FOLLOW_FLAG).toBe(NO_FOLLOW_FLAG); + expect(flags & NON_BLOCK_FLAG).toBe(NON_BLOCK_FLAG); + expect(stableRead).toHaveBeenCalledWith(expect.any(Number)); + expect(stableRead).not.toHaveBeenCalledWith(fixture.stagedDockerfile); + } finally { + stableRead.mockRestore(); + stableOpen.mockRestore(); + cleanupPreparedDcodeImageFixture(fixture); + } + }); + + it("rejects a symlink swapped in before the prepared Dockerfile opens (#6195)", async () => { + const fixture = await createPreparedDcodeImageFixture(); + const realOpen: typeof fs.openSync = fs.openSync.bind(fs); + const pendingSwap = new Map([ + [ + fixture.stagedDockerfile, + () => { + fs.renameSync(fixture.stagedDockerfile, fixture.originalDockerfile); + fs.symlinkSync(fixture.replacementDockerfile, fixture.stagedDockerfile); + }, + ], + ]); + const read = vi.spyOn(fs, "readFileSync"); + const open = vi.spyOn(fs, "openSync").mockImplementation(((target, flags, mode) => { + const key = String(target); + const swap = pendingSwap.get(key); + pendingSwap.delete(key); + swap?.(); + return realOpen(target, flags, mode); + }) as typeof fs.openSync); + try { + expect(verifyPreparedDcodeRebuildImage(fixture.prepared)).toBe(false); + expect(read).not.toHaveBeenCalled(); + expect(pendingSwap.size).toBe(0); + } finally { + open.mockRestore(); + read.mockRestore(); + cleanupPreparedDcodeImageFixture(fixture); + } + }); + + it("rejects a symlink swapped in after the prepared Dockerfile opens (#6195)", async () => { + const fixture = await createPreparedDcodeImageFixture(); + const realOpen: typeof fs.openSync = fs.openSync.bind(fs); + const pendingSwap = new Map([ + [ + fixture.stagedDockerfile, + () => { + fs.renameSync(fixture.stagedDockerfile, fixture.originalDockerfile); + fs.symlinkSync(fixture.replacementDockerfile, fixture.stagedDockerfile); + }, + ], + ]); + const open = vi.spyOn(fs, "openSync").mockImplementation(((target, flags, mode) => { + const descriptor = realOpen(target, flags, mode); + const key = String(target); + const swap = pendingSwap.get(key); + pendingSwap.delete(key); + swap?.(); + return descriptor; + }) as typeof fs.openSync); + try { + expect(verifyPreparedDcodeRebuildImage(fixture.prepared)).toBe(false); + expect(pendingSwap.size).toBe(0); + expect(fs.lstatSync(fixture.stagedDockerfile).isSymbolicLink()).toBe(true); + } finally { + open.mockRestore(); + cleanupPreparedDcodeImageFixture(fixture); + } + }); + + it("rejects a symlink even when the no-follow flag is stripped at open (#6195)", async () => { + const fixture = await createPreparedDcodeImageFixture(); + fs.renameSync(fixture.stagedDockerfile, fixture.originalDockerfile); + fs.symlinkSync(fixture.replacementDockerfile, fixture.stagedDockerfile); + const realOpen: typeof fs.openSync = fs.openSync.bind(fs); + const read = vi.spyOn(fs, "readFileSync"); + const open = vi + .spyOn(fs, "openSync") + .mockImplementation(((target, flags, mode) => + realOpen(target, Number(flags) & ~NO_FOLLOW_FLAG, mode)) as typeof fs.openSync); + try { + expect(verifyPreparedDcodeRebuildImage(fixture.prepared)).toBe(false); + expect(read).not.toHaveBeenCalled(); + } finally { + open.mockRestore(); + read.mockRestore(); + cleanupPreparedDcodeImageFixture(fixture); + } + }); + + it("rejects an inode replacement after the prepared Dockerfile is read (#6195)", async () => { + const fixture = await createPreparedDcodeImageFixture(); + fs.writeFileSync(fixture.replacementDockerfile, "FROM scratch\n"); + const originalRead: typeof fs.readFileSync = fs.readFileSync.bind(fs); + const read = vi.spyOn(fs, "readFileSync").mockImplementationOnce(((...args: unknown[]) => { + const contents = Reflect.apply(originalRead, fs, args) as Buffer; + fs.renameSync(fixture.stagedDockerfile, fixture.originalDockerfile); + fs.renameSync(fixture.replacementDockerfile, fixture.stagedDockerfile); + return contents; + }) as never); + try { + expect(verifyPreparedDcodeRebuildImage(fixture.prepared)).toBe(false); + } finally { + read.mockRestore(); + cleanupPreparedDcodeImageFixture(fixture); + } + }); + + it("rejects content appended while the prepared Dockerfile is fingerprinted (#6195)", async () => { + const fixture = await createPreparedDcodeImageFixture(); + const originalRead: typeof fs.readFileSync = fs.readFileSync.bind(fs); + const read = vi.spyOn(fs, "readFileSync").mockImplementationOnce(((...args: unknown[]) => { + const contents = Reflect.apply(originalRead, fs, args) as Buffer; + fs.appendFileSync(fixture.stagedDockerfile, "# changed during fingerprinting\n"); + return contents; + }) as never); + try { + expect(verifyPreparedDcodeRebuildImage(fixture.prepared)).toBe(false); + } finally { + read.mockRestore(); + cleanupPreparedDcodeImageFixture(fixture); + } + }); + + it("rejects changes through an already-open descriptor and disposes idempotently (#6195)", async () => { + const fixture = await createPreparedDcodeImageFixture(); + const mutationFd = fs.openSync( + fixture.stagedDockerfile, + fs.constants.O_WRONLY | fs.constants.O_APPEND, + ); + try { + expect(verifyPreparedDcodeRebuildImage(fixture.prepared)).toBe(true); + fs.writeSync(mutationFd, "# temporary drift\n", null, "utf8"); + expect(verifyPreparedDcodeRebuildImage(fixture.prepared)).toBe(false); + fs.ftruncateSync(mutationFd, 0); + fs.writeSync(mutationFd, "FROM scratch\n", 0, "utf8"); + expect(verifyPreparedDcodeRebuildImage(fixture.prepared)).toBe(true); + fs.writeSync(mutationFd, "# changed after preflight\n", 0, "utf8"); + expect(verifyPreparedDcodeRebuildImage(fixture.prepared)).toBe(false); + fs.closeSync(mutationFd); + expect(disposePreparedDcodeRebuildImage(fixture.prepared)).toBe(true); + expect(disposePreparedDcodeRebuildImage(fixture.prepared)).toBe(true); + expect(fixture.cleanupBuildCtx).toHaveBeenCalledOnce(); + } finally { + vi.restoreAllMocks(); + cleanupPreparedDcodeImageFixture(fixture); + } + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-mcp-order.test.ts b/src/lib/actions/sandbox/rebuild-mcp-order.test.ts index 181dbcb068c..5b796705aed 100644 --- a/src/lib/actions/sandbox/rebuild-mcp-order.test.ts +++ b/src/lib/actions/sandbox/rebuild-mcp-order.test.ts @@ -38,6 +38,9 @@ describe("rebuild MCP and local NIM ordering", () => { order.push("mcp-prepared"); return { entries: 1 }; }, + afterPrepare: async () => { + order.push("validated"); + }, stopNim: () => { order.push("nim-stop"); throw new Error("runtime unavailable"); @@ -45,7 +48,22 @@ describe("rebuild MCP and local NIM ordering", () => { log, }), ).resolves.toEqual({ entries: 1 }); - expect(order).toEqual(["mcp-prepared", "nim-stop"]); + expect(order).toEqual(["mcp-prepared", "validated", "nim-stop"]); expect(log).toHaveBeenCalledWith(expect.stringContaining("runtime unavailable")); }); + + it("does not stop NIM when post-MCP validation aborts", async () => { + const stopNim = vi.fn(); + await expect( + prepareMcpBeforeBestEffortNimStop({ + prepareMcp: async () => ({ entries: 1 }), + afterPrepare: async () => { + throw new Error("replacement drift"); + }, + stopNim, + log: vi.fn(), + }), + ).rejects.toThrow("replacement drift"); + expect(stopNim).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/actions/sandbox/rebuild-mcp-order.ts b/src/lib/actions/sandbox/rebuild-mcp-order.ts index 69b0f78c916..1ffc517124c 100644 --- a/src/lib/actions/sandbox/rebuild-mcp-order.ts +++ b/src/lib/actions/sandbox/rebuild-mcp-order.ts @@ -4,11 +4,13 @@ /** Keep local inference available until MCP preservation has fully succeeded. */ export async function prepareMcpBeforeBestEffortNimStop(options: { prepareMcp(): Promise; + afterPrepare?(preparation: T): Promise; stopNim(): void; log(message: string): void; }): Promise { const preparation = await options.prepareMcp(); if (preparation === null) return null; + await options.afterPrepare?.(preparation); try { options.stopNim(); diff --git a/src/lib/actions/sandbox/rebuild-pipeline.ts b/src/lib/actions/sandbox/rebuild-pipeline.ts index 1f83235d2da..9f34b9eab40 100644 --- a/src/lib/actions/sandbox/rebuild-pipeline.ts +++ b/src/lib/actions/sandbox/rebuild-pipeline.ts @@ -94,6 +94,9 @@ async function rebuildSandboxUnlocked( fromDockerfile, } = targetConfig; const { staleRecovery } = liveState; + const preservedCustomPolicies = (sandboxEntry.customPolicies ?? []).map((entry) => ({ + ...entry, + })); let recoveryManifest = validatedRecoveryManifest; const preparedBackupRecovery = recoveryManifest !== null; const recoveryRecreate = staleRecovery || preparedBackupRecovery; @@ -160,6 +163,12 @@ async function rebuildSandboxUnlocked( log, bail, relockShieldsIfNeeded, + validateAfterMcpPreparation: () => + dcodePreflight.checkAtDeleteEdge( + resumeConfig, + recoveryRecreate, + recreateOptions.targetGatewayPort, + ), onDeleted: () => { sandboxStillExists = false; }, @@ -207,11 +216,15 @@ async function rebuildSandboxUnlocked( sandboxName, backupManifest: backup.backupManifest, policyPresets: backup.policyPresets, + customPolicies: + backup.backupManifest?.customPolicies?.map((entry) => ({ ...entry })) ?? + preservedCustomPolicies, log, }); await runRebuildPostRestorePhase({ sandboxName, sandboxEntry, + preservedCustomPolicies, messagingPlan, backupManifest: backup.backupManifest, mcpEntries: mcpPreparation.entries, diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts index 9b00930a761..c11d54f1d20 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts @@ -26,6 +26,7 @@ import { reapplyMessagingManifestAfterOpenClawDoctor } from "./rebuild-messaging export interface RebuildPostRestorePhaseInput { sandboxName: string; sandboxEntry: RebuildSandboxEntry; + preservedCustomPolicies: NonNullable; messagingPlan: SandboxMessagingPlan | null; backupManifest: RebuildBackupManifest; mcpEntries: McpRebuildPreparation["entries"]; @@ -42,6 +43,19 @@ export interface RebuildPostRestorePhaseInput { bail: RebuildBail; } +export function resolveRestoredPolicyRegistryState( + sandboxEntry: Pick, + restoredPresets: readonly string[], + failedPresets: readonly string[], +): { policies: string[]; policyPresetsFinalized: true | undefined } { + const customPolicyNames = new Set((sandboxEntry.customPolicies ?? []).map((entry) => entry.name)); + return { + policies: restoredPresets.filter((name) => !customPolicyNames.has(name)), + policyPresetsFinalized: + sandboxEntry.policyPresetsFinalized === true && failedPresets.length === 0 ? true : undefined, + }; +} + /** * Repair agent state, restore MCP/forwarding, reconcile the registry, and report * the final transaction result. Boundary coverage: rebuild-flow.test.ts and @@ -53,6 +67,7 @@ export async function runRebuildPostRestorePhase( const { sandboxName, sandboxEntry: sb, + preservedCustomPolicies, messagingPlan, backupManifest, mcpEntries, @@ -128,20 +143,23 @@ export async function runRebuildPostRestorePhase( } const mcpBridgeRestoreUnverified = !(await restoreMcpAfterRebuild(sandboxName, mcpEntries)); - const policyPresetsFinalized = - sb.policyPresetsFinalized === true && - failedPresets.length === 0 && - (sb.customPolicies?.length ?? 0) === 0 - ? true - : undefined; + const { policies: restoredBuiltinPresets, policyPresetsFinalized } = + resolveRestoredPolicyRegistryState( + { + customPolicies: backupManifest?.customPolicies ?? preservedCustomPolicies, + policyPresetsFinalized: sb.policyPresetsFinalized, + }, + restoredPresets, + failedPresets, + ); registry.updateSandbox(sandboxName, { agentVersion: agentDef.expectedVersion || null, - policies: restoredPresets, + policies: restoredBuiltinPresets, policyTier: sb.policyTier ?? null, policyPresetsFinalized, }); log( - `Registry updated: agentVersion=${agentDef.expectedVersion}, policies=[${restoredPresets.join(",")}], policyPresetsFinalized=${String(policyPresetsFinalized === true)}`, + `Registry updated: agentVersion=${agentDef.expectedVersion}, policies=[${restoredBuiltinPresets.join(",")}], policyPresetsFinalized=${String(policyPresetsFinalized === true)}`, ); if (!relockShieldsIfNeeded(true)) { diff --git a/src/lib/actions/sandbox/rebuild-preflight-phase.ts b/src/lib/actions/sandbox/rebuild-preflight-phase.ts index 3335fbcdc96..35ed334fb86 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-phase.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-phase.ts @@ -140,6 +140,7 @@ export async function runRebuildPreflightPhase( const recoveryRecreate = liveState.staleRecovery || recoveryManifest !== null; const imageReady = await dcodePreflight.prepareImage( preparedTarget.targetConfig.resumeConfig, + preparedTarget.targetConfig.durableConfig.webSearchConfig, recoveryRecreate, preparedTarget.recreateOptions.targetGatewayPort, ); diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts new file mode 100644 index 00000000000..c552fb6bbb9 --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import * as policies from "../../policy"; +import * as sandboxState from "../../state/sandbox"; +import { MCP_BRIDGE_POLICY_SOURCE } from "./mcp-bridge-contracts"; +import { resolveRestoredPolicyRegistryState } from "./rebuild-post-restore-phase"; +import { runRebuildRestorePhase } from "./rebuild-restore-phase"; + +describe("rebuild policy restore fidelity", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("replays custom web-policy names from exact content instead of same-name built-ins", () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(sandboxState, "restoreSandboxState").mockReturnValue({ + success: true, + restoredDirs: [], + restoredFiles: [], + failedDirs: [], + failedFiles: [], + }); + const applyPreset = vi.spyOn(policies, "applyPreset").mockReturnValue(true); + const applyPresetContent = vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); + const customPolicies = ["brave", "tavily", "nous-web"].map((name) => ({ + name, + content: `network_policies:\n ${name}-custom:\n name: ${name}-custom\n`, + sourcePath: `/tmp/${name}.yaml`, + })); + + const result = runRebuildRestorePhase({ + sandboxName: "alpha", + backupManifest: { + backupPath: "/tmp/rebuild-backup", + customPolicies, + } as never, + policyPresets: ["npm", "brave", "tavily", "nous-web"], + customPolicies, + log: vi.fn(), + }); + + expect(applyPreset).toHaveBeenCalledOnce(); + expect(applyPreset).toHaveBeenCalledWith("alpha", "npm"); + for (const entry of customPolicies) { + expect(applyPresetContent).toHaveBeenCalledWith("alpha", entry.name, entry.content, { + custom: { sourcePath: entry.sourcePath }, + }); + } + expect(result.restoredPresets).toEqual(["npm", "brave", "tavily", "nous-web"]); + expect(result.failedPresets).toEqual([]); + }); + + it("replays captured registry custom policies during stale recovery without a backup", () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(sandboxState, "restoreSandboxState").mockReturnValue({ + success: true, + restoredDirs: [], + restoredFiles: [], + failedDirs: [], + failedFiles: [], + }); + const applyPresetContent = vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); + const customPolicies = [ + { + name: "custom-egress", + content: "network_policies:\n custom-egress: {}\n", + sourcePath: "/tmp/custom-egress.yaml", + }, + ]; + + const result = runRebuildRestorePhase({ + sandboxName: "alpha", + backupManifest: null, + policyPresets: [], + customPolicies, + log: vi.fn(), + }); + + expect(applyPresetContent).toHaveBeenCalledWith( + "alpha", + "custom-egress", + customPolicies[0]!.content, + { custom: { sourcePath: "/tmp/custom-egress.yaml" } }, + ); + expect(result.restoredPresets).toEqual(["custom-egress"]); + }); + + it("leaves generated MCP policy replay exclusively to MCP restoration", () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + const applyPresetContent = vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); + const genuineCustomPolicy = { + name: "custom-egress", + content: "network_policies:\n custom-egress: {}\n", + sourcePath: "/tmp/custom-egress.yaml", + }; + const generatedMcpPolicy = { + name: "mcp-bridge-search", + content: + "network_policies:\n mcp-bridge-search:\n endpoints:\n - host: mcp.example.com\n allowed_ips: [203.0.113.10]\n", + sourcePath: MCP_BRIDGE_POLICY_SOURCE, + }; + + const result = runRebuildRestorePhase({ + sandboxName: "alpha", + backupManifest: null, + policyPresets: [], + customPolicies: [genuineCustomPolicy, generatedMcpPolicy], + log: vi.fn(), + }); + + expect(applyPresetContent).toHaveBeenCalledOnce(); + expect(applyPresetContent).toHaveBeenCalledWith( + "alpha", + genuineCustomPolicy.name, + genuineCustomPolicy.content, + { custom: { sourcePath: genuineCustomPolicy.sourcePath } }, + ); + expect(result.restoredPresets).toEqual([genuineCustomPolicy.name]); + expect(result.failedPresets).toEqual([]); + }); + + it("keeps finalized custom-only policy state empty after exact replay", () => { + expect( + resolveRestoredPolicyRegistryState( + { + customPolicies: [{ name: "tavily", content: "allow: []" }], + policyPresetsFinalized: true, + }, + ["tavily"], + [], + ), + ).toEqual({ policies: [], policyPresetsFinalized: true }); + expect( + resolveRestoredPolicyRegistryState( + { + customPolicies: [{ name: "tavily", content: "allow: []" }], + policyPresetsFinalized: true, + }, + [], + ["tavily"], + ).policyPresetsFinalized, + ).toBeUndefined(); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.ts b/src/lib/actions/sandbox/rebuild-restore-phase.ts index 5790dd0fc20..93925529e6b 100644 --- a/src/lib/actions/sandbox/rebuild-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-restore-phase.ts @@ -5,13 +5,16 @@ import { CLI_NAME } from "../../cli/branding"; import { G, R, YW } from "../../cli/terminal-style"; import * as policies from "../../policy"; import * as sandboxState from "../../state/sandbox"; +import { MCP_BRIDGE_POLICY_SOURCE } from "./mcp-bridge-contracts"; import type { RebuildBackupManifest } from "./rebuild-backup-phase"; import type { RebuildLog } from "./rebuild-credential-preflight"; +import type { RebuildSandboxEntry } from "./rebuild-flow-helpers"; export interface RebuildRestorePhaseInput { sandboxName: string; backupManifest: RebuildBackupManifest; policyPresets: string[]; + customPolicies: NonNullable; log: RebuildLog; } @@ -27,7 +30,7 @@ export interface RebuildRestorePhaseResult { * stale recovery, successful presets, and incomplete preset recovery reporting. */ export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): RebuildRestorePhaseResult { - const { sandboxName, backupManifest, policyPresets, log } = input; + const { sandboxName, backupManifest, policyPresets, customPolicies, log } = input; let restoreSucceeded = true; if (backupManifest) { console.log(""); @@ -54,11 +57,16 @@ export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): Rebuild const restoredPresets: string[] = []; const failedPresets: string[] = []; - if (policyPresets.length > 0) { + const customPolicyNames = new Set(customPolicies.map((entry) => entry.name)); + const replayableCustomPolicies = customPolicies.filter( + (entry) => entry.sourcePath !== MCP_BRIDGE_POLICY_SOURCE, + ); + const builtinPolicyPresets = policyPresets.filter((name) => !customPolicyNames.has(name)); + if (builtinPolicyPresets.length > 0 || replayableCustomPolicies.length > 0) { console.log(""); console.log(" Restoring policy presets..."); - log(`Policy presets to restore: [${policyPresets.join(",")}]`); - for (const presetName of policyPresets) { + log(`Policy presets to restore: [${builtinPolicyPresets.join(",")}]`); + for (const presetName of builtinPolicyPresets) { try { log(`Applying preset: ${presetName}`); const applied = policies.applyPreset(sandboxName, presetName); @@ -70,6 +78,20 @@ export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): Rebuild failedPresets.push(presetName); } } + for (const entry of replayableCustomPolicies) { + try { + log(`Applying custom preset: ${entry.name}`); + const applied = policies.applyPresetContent(sandboxName, entry.name, entry.content, { + custom: { sourcePath: entry.sourcePath }, + }); + if (applied) restoredPresets.push(entry.name); + else failedPresets.push(entry.name); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`Failed to apply custom preset '${entry.name}': ${message}`); + failedPresets.push(entry.name); + } + } if (restoredPresets.length > 0) { console.log(` ${G}\u2713${R} Policy presets restored: ${restoredPresets.join(", ")}`); } diff --git a/test/deepagents-mcp-legacy-lifecycle.test.ts b/test/deepagents-mcp-legacy-lifecycle.test.ts index fec72474152..e49c8f7d49e 100644 --- a/test/deepagents-mcp-legacy-lifecycle.test.ts +++ b/test/deepagents-mcp-legacy-lifecycle.test.ts @@ -22,6 +22,7 @@ const providerId = "11111111-2222-4333-8444-555555555555"; let providerExists = true; let attached = true; let adapterRegistered = true; +let adapterRemovalOutcome = ""; let deepAgentsCapability = false; let policyApplyCalls = 0; let policyState = "match"; @@ -85,16 +86,27 @@ processRecovery.executeSandboxCommand = (_sandbox, command) => { adapterCalls.push(command); if (command === "/usr/local/bin/deepagents-code --nemoclaw-mcp-capability") { return deepAgentsCapability - ? { status: 0, stdout: "NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=1\n", stderr: "" } + ? { status: 0, stdout: "NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=2\n", stderr: "" } : { status: 2, stdout: "", stderr: "unknown option" }; } - if (command.includes("servers.pop(payload['server'], None)")) { - adapterRegistered = false; - return { status: 0, stdout: "", stderr: "" }; + if (command.includes("servers.pop(payload['server'])")) { + const outcome = adapterRemovalOutcome || (adapterRegistered ? "removed" : "absent"); + if (outcome !== "unowned") adapterRegistered = false; + return { + status: 0, + stdout: "NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=" + outcome + "\n", + stderr: "", + }; } if (command.includes("data = {'mcpServers': payload['expectedServers']}")) { adapterRegistered = true; - return { status: 0, stdout: "", stderr: "" }; + return { + status: 0, + stdout: command.includes("NEMOCLAW_DEEPAGENTS_MCP_ROLLBACK_RESTORED") + ? "NEMOCLAW_DEEPAGENTS_MCP_ROLLBACK_RESTORED=1\n" + : "", + stderr: "", + }; } if (command.includes("print('registered' if ok else ('mismatch' if present else 'absent'))")) { return { @@ -162,6 +174,7 @@ function parseResult(result: ReturnType) { providerExists: boolean; policyApplyCalls: number; markerCalls: number; + registryEntryPresent?: boolean; }; } @@ -191,6 +204,54 @@ describe("legacy Deep Agents managed MCP lifecycle", () => { }); }); + it("treats an already-absent legacy entry as an idempotent removal retry", () => { + const result = runLegacyLifecycle(` +adapterRegistered = false; +(async () => { + await bridge.removeMcpBridge("alpha", "github"); + process.stdout.write(${resultExpression}); +})().catch((error) => { console.error(error); process.exit(1); }); +`); + expect(parseResult(result)).toMatchObject({ + attached: false, + adapterRegistered: false, + providerExists: false, + markerCalls: 0, + }); + }); + + it("preserves ownership state when legacy adapter cleanup is unproved", () => { + const result = runLegacyLifecycle(` +adapterRemovalOutcome = "unowned"; +(async () => { + let error = ""; + try { + await bridge.removeMcpBridge("alpha", "github", { force: true }); + } catch (caught) { + error = caught instanceof Error ? caught.message : String(caught); + } + process.stdout.write(JSON.stringify({ + error, + attached, + adapterRegistered, + providerExists, + policyApplyCalls, + registryEntryPresent: Boolean(registry.getSandbox("alpha")?.mcp?.bridges?.github), + markerCalls: adapterCalls.filter((call) => + call.includes("deepagents-code --nemoclaw-mcp-capability") + ).length, + })); +})().catch((error) => { console.error(error); process.exit(1); }); +`); + expect(parseResult(result)).toMatchObject({ + error: expect.stringMatching(/left residual resources/), + adapterRegistered: true, + providerExists: true, + registryEntryPresent: true, + markerCalls: 0, + }); + }); + for (const [label, method] of [ ["destroy", "prepareMcpBridgesForDestroy"], ["rebuild", "prepareMcpBridgesForRebuild"], @@ -219,6 +280,36 @@ describe("legacy Deep Agents managed MCP lifecycle", () => { markerCalls: 0, }); }); + + it(`${label} teardown fails closed when adapter ownership is unproved`, () => { + const result = runLegacyLifecycle(` +adapterRemovalOutcome = "unowned"; +(async () => { + let error = ""; + try { + await bridge.${method}("alpha"); + } catch (caught) { + error = caught instanceof Error ? caught.message : String(caught); + } + process.stdout.write(JSON.stringify({ + error, + attached, + adapterRegistered, + providerExists, + markerCalls: adapterCalls.filter((call) => + call.includes("deepagents-code --nemoclaw-mcp-capability") + ).length, + })); +})().catch((error) => { console.error(error); process.exit(1); }); +`); + expect(parseResult(result)).toMatchObject({ + error: expect.stringMatching(/Could not prove removal of the exact managed adapter entry/), + attached: true, + adapterRegistered: true, + providerExists: true, + markerCalls: 0, + }); + }); } it("proves the replacement image marker before post-rebuild reattachment", () => { @@ -244,7 +335,7 @@ describe("legacy Deep Agents managed MCP lifecycle", () => { })().catch((error) => { console.error(error); process.exit(1); }); `); expect(parseResult(result)).toMatchObject({ - error: expect.stringMatching(/does not contain the managed MCP-aware launcher/i), + error: expect.stringMatching(/does not contain managed MCP capability v2/i), attached: false, adapterRegistered: false, providerExists: true, diff --git a/test/deepagents-mcp-runtime-capability.test.ts b/test/deepagents-mcp-runtime-capability.test.ts index fd6f203aef7..a5579a2e8de 100644 --- a/test/deepagents-mcp-runtime-capability.test.ts +++ b/test/deepagents-mcp-runtime-capability.test.ts @@ -41,7 +41,7 @@ describe("Deep Agents managed MCP runtime capability", () => { expect( runDeepAgentsProbe({ status: 0, - stdout: "NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=1\n", + stdout: "NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=2\n", stderr: "", }), ).toEqual({ @@ -59,11 +59,12 @@ describe("Deep Agents managed MCP runtime capability", () => { for (const result of [ null, { status: 2, stdout: "", stderr: "unknown option" }, + { status: 0, stdout: "NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=1\n", stderr: "" }, { status: 0, stdout: "deepagents-code 0.1.12\n", stderr: "" }, ]) { const probe = runDeepAgentsProbe(result); expect(probe.calls).toHaveLength(1); - expect(probe.message).toMatch(/does not contain the managed MCP-aware launcher/i); + expect(probe.message).toMatch(/does not contain managed MCP capability v2/i); expect(probe.message).toMatch(/rebuild the sandbox before changing authenticated MCP state/i); expect(probe.message).not.toContain("unknown option"); } diff --git a/test/e2e/e2e-cloud-experimental/checks/07-deepagents-code-headless-inference.sh b/test/e2e/e2e-cloud-experimental/checks/07-deepagents-code-headless-inference.sh index 678f44a820c..bfb565b76fc 100755 --- a/test/e2e/e2e-cloud-experimental/checks/07-deepagents-code-headless-inference.sh +++ b/test/e2e/e2e-cloud-experimental/checks/07-deepagents-code-headless-inference.sh @@ -77,7 +77,7 @@ sandbox_login_proxy_contract() { sandbox_artifact_scan_command() { cat <<'SCAN' -for path in /sandbox/.deepagents/config.toml /sandbox/.deepagents/.env /sandbox/.deepagents/.mcp.json /tmp/nemoclaw-proxy-env.sh; do +for path in /sandbox/.deepagents/config.toml /sandbox/.deepagents/.env /sandbox/.deepagents/.mcp.json /sandbox/.deepagents/.nemoclaw-mcp.json /tmp/nemoclaw-proxy-env.sh; do if [ -e "$path" ]; then cat "$path" 2>/dev/null || true fi diff --git a/test/e2e/live/mcp-bridge.test.ts b/test/e2e/live/mcp-bridge.test.ts index 0c2403793c6..a4714377fb5 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -702,7 +702,7 @@ async function assertDeepAgentsConfig( "set -eu", "python3 - <<'PY'", "import json, pathlib", - "path = pathlib.Path('/sandbox/.deepagents/.mcp.json')", + "path = pathlib.Path('/sandbox/.deepagents/.nemoclaw-mcp.json')", "text = path.read_text(encoding='utf-8')", "data = json.loads(text)", `entry = data['mcpServers'][${JSON.stringify(SERVER_NAME)}]`, diff --git a/test/fixtures/langchain-deepagents-code/app.py b/test/fixtures/langchain-deepagents-code/app.py new file mode 100644 index 00000000000..801c2aceba4 --- /dev/null +++ b/test/fixtures/langchain-deepagents-code/app.py @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Minimal pinned app fixture for the managed package patch tests.""" + +from __future__ import annotations + +from pathlib import Path + + +class UserMessage: + def __init__(self, value): + self.value = value + + +class AppMessage(UserMessage): + pass + + +class _Event: + def __init__(self): + self.was_set = False + + def set(self): + self.was_set = True + + +class DeepAgentsApp: + def __init__(self): + self.messages = [] + self.notifications = [] + self.original_commands = [] + self.original_auth_manager = False + self.original_mcp_login = False + self.original_service_key = False + self.original_tavily = False + self.original_update_action = False + self.original_switch_kwargs = "not-called" + self._update_check_done = _Event() + self._auto_approve = True + self._status_bar = None + self._session_state = None + self._rubric_model = "attacker:model" + self._server_kwargs = {"rubric_model": "attacker:model"} + + async def _mount_message(self, message): + self.messages.append(message.value) + + def notify(self, message, **kwargs): + self.notifications.append((message, kwargs)) + + async def _handle_command(self, command): + self.original_commands.append(command) + + async def _switch_model(self, model_spec, **kwargs): + del model_spec + self.original_switch_kwargs = kwargs.get("extra_kwargs") + + @staticmethod + def _absolutize_launch_relative_path(raw, launch_cwd): + if not isinstance(raw, str) or not raw: + return None + path = Path(raw).expanduser() + if path.is_absolute(): + return str(path.resolve()) + return str((launch_cwd / path).resolve()) + + async def _check_for_updates(self, *, periodic=False): + pass + + async def _handle_update_command(self, command="/update"): + pass + + async def _handle_install_command(self, command): + pass + + async def _install_extra(self, *args, **kwargs): + del args, kwargs + return True + + async def _handle_install_package(self, *args, **kwargs): + pass + + async def _handle_auto_update_toggle(self): + return None + + async def _prompt_launch_tavily(self): + self.original_tavily = True + + async def _prompt_model_auth_if_needed(self, model_spec): + del model_spec + return True + + async def _show_auth_manager(self, **kwargs): + del kwargs + self.original_auth_manager = True + + async def _enter_service_api_key(self, *args, **kwargs): + del args, kwargs + self.original_service_key = True + + async def _handle_update_action(self, *args, **kwargs): + del args, kwargs + self.original_update_action = True + + def _start_mcp_login(self, server_name): + del server_name + self.original_mcp_login = True + + async def _on_auto_approve_enabled(self): + self._auto_approve = True + + async def action_toggle_auto_approve(self): + self._auto_approve = not self._auto_approve + + async def _set_rubric_model(self, model_spec): + self._rubric_model = model_spec diff --git a/test/fixtures/langchain-deepagents-code/mcp_tools.py b/test/fixtures/langchain-deepagents-code/mcp_tools.py new file mode 100644 index 00000000000..d745e1f22bc --- /dev/null +++ b/test/fixtures/langchain-deepagents-code/mcp_tools.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Minimal pinned MCP loader fixture for the managed package patch tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def load_mcp_config(config_path): + path = Path(config_path) + + if not path.exists(): + error_msg = f"MCP config file not found: {config_path}" + raise FileNotFoundError(error_msg) + + try: + with path.open(encoding="utf-8") as file_obj: + config = json.load(file_obj) + except json.JSONDecodeError: + raise + if "mcpServers" not in config: + raise ValueError("missing mcpServers") + return config + + +async def resolve_and_load_mcp_tools( + *, + explicit_config_path=None, + project_context=None, +): + configs = [] + if explicit_config_path: + config_path = ( + str(project_context.resolve_user_path(explicit_config_path)) + if project_context is not None + else explicit_config_path + ) + configs.append(load_mcp_config(config_path)) + return configs + + +def discover_mcp_configs(*, project_context=None): + del project_context + return [Path.home() / ".deepagents" / ".mcp.json"] diff --git a/test/fixtures/langchain-deepagents-code/server.py b/test/fixtures/langchain-deepagents-code/server.py new file mode 100644 index 00000000000..d564c819707 --- /dev/null +++ b/test/fixtures/langchain-deepagents-code/server.py @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Minimal pinned server lifecycle fixture for the managed package patch tests.""" + +from __future__ import annotations + +import os +import subprocess + + +def _build_server_env(): + return dict(os.environ) + + +class ServerProcess: + def __init__(self, cmd, work_dir, env): + self.cmd = cmd + self.work_dir = work_dir + self.env = env + self.outputs = [] + self._process = None + self._persistent_env_overrides = {} + self._env_overrides = {} + + async def start(self): + cmd = self.cmd + work_dir = self.work_dir + env = self.env + env.update(self._persistent_env_overrides) + env.update(self._env_overrides) + self._log_file = subprocess.PIPE + self._process = subprocess.Popen( # noqa: S603, ASYNC220 + cmd, + cwd=str(work_dir), + env=env, + stdout=self._log_file, + stderr=subprocess.STDOUT, + ) + output, _ = self._process.communicate(timeout=10) + if self._process.returncode != 0: + raise RuntimeError(output.decode()) + self.outputs.append(output.decode()) + + async def restart(self): + if self._process is not None and self._process.poll() is None: + self._process.terminate() + self._process.wait(timeout=10) + await self.start() diff --git a/test/helpers/langchain-deepagents-code-secret-patterns.ts b/test/helpers/langchain-deepagents-code-secret-patterns.ts new file mode 100644 index 00000000000..1ab3b954c81 --- /dev/null +++ b/test/helpers/langchain-deepagents-code-secret-patterns.ts @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type CanonicalSecretPatternGroup = "token" | "context" | "block"; + +export interface CanonicalSecretPositiveVector { + label: string; + value: string; + patternGroup: CanonicalSecretPatternGroup; + patternIndex: number; +} + +const ECMASCRIPT_WHITESPACE_VECTORS = [ + ["tab", "\t"], + ["line_feed", "\n"], + ["vertical_tab", "\v"], + ["form_feed", "\f"], + ["carriage_return", "\r"], + ["space", " "], + ["no_break_space", "\u00a0"], + ["ogham_space", "\u1680"], + ["en_quad", "\u2000"], + ["em_quad", "\u2001"], + ["en_space", "\u2002"], + ["em_space", "\u2003"], + ["three_per_em_space", "\u2004"], + ["four_per_em_space", "\u2005"], + ["six_per_em_space", "\u2006"], + ["figure_space", "\u2007"], + ["punctuation_space", "\u2008"], + ["thin_space", "\u2009"], + ["hair_space", "\u200a"], + ["line_separator", "\u2028"], + ["paragraph_separator", "\u2029"], + ["narrow_no_break_space", "\u202f"], + ["medium_mathematical_space", "\u205f"], + ["ideographic_space", "\u3000"], + ["byte_order_mark", "\ufeff"], +] as const; + +/** + * Positive examples shared by the TypeScript, Bash, and Python parity gates. + * Each entry names the canonical TypeScript pattern that owns its behavior. + */ +export const CANONICAL_SECRET_POSITIVE_VECTORS: readonly CanonicalSecretPositiveVector[] = [ + { label: "nvapi", value: "nvapi-abcdefghijklmnop", patternGroup: "token", patternIndex: 0 }, + { label: "nvcf", value: "nvcf-abcdefghijklmnopq", patternGroup: "token", patternIndex: 1 }, + { label: "ghp", value: "ghp_abcdefghijklmnopqr", patternGroup: "token", patternIndex: 2 }, + { + label: "github_pat", + value: "github_pat_abcdefghijklmnopqrstuvwxyz0123", + patternGroup: "token", + patternIndex: 3, + }, + { label: "sk_proj", value: "sk-proj-abcdefghij", patternGroup: "token", patternIndex: 4 }, + { label: "sk_ant", value: "sk-ant-abcdefghijk", patternGroup: "token", patternIndex: 5 }, + { + label: "sk", + value: "sk-abcdefghijklmnopqrstuvwx", + patternGroup: "token", + patternIndex: 6, + }, + { + label: "xoxb", + value: ["xoxb", "1234567890"].join("-"), + patternGroup: "token", + patternIndex: 7, + }, + { + label: "xoxp", + value: ["xoxp", "1234567890"].join("-"), + patternGroup: "token", + patternIndex: 7, + }, + { + label: "xoxa", + value: ["xoxa", "1234567890"].join("-"), + patternGroup: "token", + patternIndex: 7, + }, + { + label: "xoxs", + value: ["xoxs", "1234567890"].join("-"), + patternGroup: "token", + patternIndex: 7, + }, + { + label: "xapp", + value: ["xapp", "1", "A1B2C3", "12345", "abcde"].join("-"), + patternGroup: "token", + patternIndex: 7, + }, + { + label: "akia", + value: ["AKIA", "ABCDEFGHIJKLMNOP"].join(""), + patternGroup: "token", + patternIndex: 8, + }, + { + label: "asia", + value: ["ASIA", "ABCDEFGHIJKLMNOP"].join(""), + patternGroup: "token", + patternIndex: 8, + }, + { label: "hf", value: "hf_abcdefghijklmnopq", patternGroup: "token", patternIndex: 9 }, + { + label: "glpat", + value: "glpat-abcdefghijklmn", + patternGroup: "token", + patternIndex: 10, + }, + { label: "gsk", value: "gsk_abcdefghijklmnop", patternGroup: "token", patternIndex: 11 }, + { + label: "pypi", + value: "pypi-abcdefghijklmnop", + patternGroup: "token", + patternIndex: 12, + }, + { + label: "telegram_bot", + value: "bot123456789:AbcDefGhiJklMnoPqrStuVwxYz012345678", + patternGroup: "token", + patternIndex: 13, + }, + { + label: "telegram", + value: "123456789:AbcDefGhiJklMnoPqrStuVwxYz012345678", + patternGroup: "token", + patternIndex: 14, + }, + { + label: "discord", + value: "ABCDEFGHIJKLMNOPQRSTUVWX.Abcdef.ZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + patternGroup: "token", + patternIndex: 15, + }, + { + label: "tavily", + value: "tvly-abcdefghijklmnop", + patternGroup: "token", + patternIndex: 16, + }, + { + label: "langsmith_pt", + value: `lsv2_pt_${"a".repeat(36)}_${"b".repeat(10)}`, + patternGroup: "token", + patternIndex: 17, + }, + { + label: "langsmith_sk", + value: `lsv2_sk_${"a".repeat(36)}_${"b".repeat(10)}`, + patternGroup: "token", + patternIndex: 17, + }, + ...ECMASCRIPT_WHITESPACE_VECTORS.map(([label, whitespace]) => ({ + label: `bearer_${label}`, + value: `bEaReR${whitespace}opaqueRandomSessionTokenZ1234567890`, + patternGroup: "context" as const, + patternIndex: 0, + })), + { + label: "credential_context", + value: "API_KEY=opaqueCredentialPayloadZ1234567890", + patternGroup: "context", + patternIndex: 1, + }, + { + label: "private_key_block", + value: "-----BEGIN TEST PRIVATE KEY-----\nopaque-test-body\n-----END TEST PRIVATE KEY-----", + patternGroup: "block", + patternIndex: 0, + }, +]; diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts new file mode 100644 index 00000000000..6d869d0a57b --- /dev/null +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { DEEPAGENTS_MCP_CONFIG_PATH } from "../../src/lib/actions/sandbox/mcp-bridge-adapter-status"; +import type { McpBridgeEntry } from "../../src/lib/state/registry"; + +export const baseEntry: McpBridgeEntry = { + server: "github", + agent: "langchain-deepagents-code", + adapter: "deepagents-config", + url: "https://api.githubcopilot.com/mcp/", + env: ["GITHUB_TOKEN"], + providerName: "alpha-mcp-github", + policyName: "mcp-bridge-github", + addedAt: new Date(0).toISOString(), +}; + +export interface DeepAgentsConfigCommandResult { + status: number | null; + stdout: string; + stderr: string; + configExists: boolean; + config: Record | null; + configText: string | null; + legacyConfigExists: boolean; + legacyConfig: Record | null; + legacyConfigText: string | null; + managedSymlinkTargetExists: boolean; + managedSymlinkTargetText: string | null; +} + +export interface DeepAgentsManagedFixtureOptions { + fifo?: boolean; + mode?: number; + symlink?: boolean; +} + +export function runDeepAgentsConfigCommand( + command: string, + initialConfig?: Record | string, + runtimeKind: "v2" | "legacy" | "unknown" = "v2", + initialLegacyConfig?: Record | string, + initialLegacyMode = 0o600, + managedOptions: DeepAgentsManagedFixtureOptions = {}, +): DeepAgentsConfigCommandResult { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-deepagents-mcp-")); + const configPath = path.join(tmp, ".deepagents", ".nemoclaw-mcp.json"); + const managedSymlinkTarget = path.join(tmp, "managed-projection-target.json"); + const legacyConfigPath = path.join(tmp, ".deepagents", ".mcp.json"); + const initializeConfig = ( + target: string, + value: Record | string | undefined, + mode = 0o600, + ) => { + if (value === undefined) return; + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync( + target, + typeof value === "string" ? value : `${JSON.stringify(value, null, 2)}\n`, + { mode }, + ); + }; + const managedInitialPath = managedOptions.symlink ? managedSymlinkTarget : configPath; + if (managedOptions.fifo) { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + const fifo = spawnSync("mkfifo", [configPath], { encoding: "utf-8", timeout: 5000 }); + if (fifo.status !== 0) throw new Error(fifo.stderr || "could not create managed fixture FIFO"); + fs.chmodSync(configPath, managedOptions.mode ?? 0o600); + } else { + initializeConfig(managedInitialPath, initialConfig, managedOptions.mode); + if (initialConfig !== undefined) fs.chmodSync(managedInitialPath, managedOptions.mode ?? 0o600); + if (managedOptions.symlink && initialConfig !== undefined) { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.symlinkSync(managedSymlinkTarget, configPath); + } + } + initializeConfig(legacyConfigPath, initialLegacyConfig); + if (initialLegacyConfig !== undefined) fs.chmodSync(legacyConfigPath, initialLegacyMode); + try { + const fixtureCommand = command + .replaceAll(DEEPAGENTS_MCP_CONFIG_PATH, configPath) + .replaceAll("/sandbox/.deepagents/.mcp.json", legacyConfigPath) + .replaceAll("/opt/venv/bin/python3", "python3") + .replace( + 'runtime_kind = "auto" # NEMOCLAW_DEEPAGENTS_RUNTIME_TEST_ANCHOR', + `runtime_kind = "${runtimeKind}" # NEMOCLAW_DEEPAGENTS_RUNTIME_TEST_ANCHOR`, + ); + const result = spawnSync("bash", ["-c", fixtureCommand], { encoding: "utf-8", timeout: 5000 }); + const configExists = fs.existsSync(configPath); + const legacyConfigExists = fs.existsSync(legacyConfigPath); + const configIsFifo = configExists && fs.lstatSync(configPath).isFIFO(); + const configText = configExists && !configIsFifo ? fs.readFileSync(configPath, "utf-8") : null; + const managedSymlinkTargetExists = fs.existsSync(managedSymlinkTarget); + const managedSymlinkTargetText = managedSymlinkTargetExists + ? fs.readFileSync(managedSymlinkTarget, "utf-8") + : null; + const legacyConfigText = legacyConfigExists ? fs.readFileSync(legacyConfigPath, "utf-8") : null; + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + configExists, + config: configText ? (JSON.parse(configText) as Record) : null, + configText, + legacyConfigExists, + legacyConfig: legacyConfigText + ? (JSON.parse(legacyConfigText) as Record) + : null, + legacyConfigText, + managedSymlinkTargetExists, + managedSymlinkTargetText, + }; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} diff --git a/test/helpers/rebuild-dcode-flow-support.ts b/test/helpers/rebuild-dcode-flow-support.ts new file mode 100644 index 00000000000..8ac165c830b --- /dev/null +++ b/test/helpers/rebuild-dcode-flow-support.ts @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect } from "vitest"; + +import { type RebuildFlowHarness } from "./rebuild-flow-harness"; + +export function makeDcodeSandboxEntry(): Record { + return { + name: "alpha", + agent: "langchain-deepagents-code", + agentVersion: "0.1.12", + nemoclawVersion: "0.0.72", + provider: "compatible-endpoint", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: "https://inference-api.nvidia.com/v1", + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", + nimContainer: null, + policies: [], + dashboardPort: 0, + gatewayName: "nemoclaw", + gatewayPort: 8080, + gpuEnabled: false, + sandboxGpuEnabled: false, + sandboxGpuMode: "0", + }; +} + +export function configureDcodeSession(harness: RebuildFlowHarness): void { + Object.assign(harness.session, { + agent: "langchain-deepagents-code", + provider: "compatible-endpoint", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: "https://inference-api.nvidia.com/v1", + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", + gpuPassthrough: false, + }); +} + +export function expectNoDcodeMutation(harness: RebuildFlowHarness): void { + expect(harness.openShieldsSpy).not.toHaveBeenCalled(); + expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); + expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.anything(), + ); + expect(harness.removeSandboxRegistryEntrySpy).not.toHaveBeenCalled(); + expect(harness.onboardSpy).not.toHaveBeenCalled(); +} diff --git a/test/helpers/rebuild-flow-harness.ts b/test/helpers/rebuild-flow-harness.ts index 415f0213208..92685771aaf 100644 --- a/test/helpers/rebuild-flow-harness.ts +++ b/test/helpers/rebuild-flow-harness.ts @@ -67,6 +67,7 @@ export type RebuildFlowOverrides = { ) => { ok: true; manifest: Record } | { ok: false; reason: string }; dcodeRouteResults?: Array<{ ok: true } | { ok: false; detail: string }>; gatewayRecoveryResult?: Record; + reconciledSandboxGatewayState?: Record; dcodeImageVerificationResults?: boolean[]; dcodeBaseImageIds?: string[]; sandboxBaseImageLabelsOutput?: string; @@ -75,11 +76,17 @@ export type RebuildFlowOverrides = { | { ok: false; detail: string }; openShieldsWindow?: () => { relocked: boolean; wasLocked: boolean } | null; preflightMessagingConflicts?: () => Promise | void; + mcpPreparation?: { + entries: Array>; + detachedProviderEntries: Array>; + scrubbedAdapterEntries: Array>; + }; }; export type RebuildFlowHarness = { rebuildSandbox: RebuildSandbox; applyPresetSpy: MockInstance; + applyPresetContentSpy: MockInstance; backupSandboxStateSpy: MockInstance; disposePreparedDcodeRebuildImageSpy: MockInstance; errorSpy: MockInstance; @@ -101,6 +108,10 @@ export type RebuildFlowHarness = { restoreSandboxStateSpy: MockInstance; runOpenshellSpy: MockInstance; messagingRebuildPlanSpy: MockInstance; + prepareMcpBridgesForRebuildSpy: MockInstance; + reattachMcpProvidersAfterRebuildAbortSpy: MockInstance; + restoreMcpBridgesAfterRebuildSpy: MockInstance; + warnUnpreservedUserManagedFilesSpy: MockInstance; preparedDcodeBuildContext: Record & { cleanupBuildCtx: MockInstance }; session: RebuildFlowSession; }; @@ -217,6 +228,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const agentOnboard = requireDist("../../agent/onboard.js"); const agentRuntime = requireDist("../../agent/runtime.js"); const gatewayRuntime = requireDist("../../gateway-runtime-action.js"); + const gatewayState = requireDist("./gateway-state.js"); const onboardMod = requireDist("../../onboard.js"); const onboardSession = requireDist("../../state/onboard-session.js"); const registry = requireDist("../../state/registry.js"); @@ -230,7 +242,9 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const processRecovery = requireDist("./process-recovery.js"); const messagingHostForwardLifecycle = requireDist("./messaging-host-forward-lifecycle.js"); const messaging = requireDist("../../messaging/index.js"); + const mcpBridge = requireDist("./mcp-bridge.js"); const rebuildInference = requireDist("./rebuild-inference-preflight.js"); + const rebuildFlowHelpers = requireDist("./rebuild-flow-helpers.js"); const rebuildManagedImage = requireDist("./rebuild-managed-image-preflight.js"); const rebuildMessagingConflict = requireDist("./rebuild-messaging-conflict-preflight.js"); const shields = requireDist("../../shields/index.js"); @@ -283,6 +297,9 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): ); }, ); + vi.spyOn(gatewayState, "getReconciledSandboxGatewayState").mockResolvedValue( + overrides.reconciledSandboxGatewayState ?? { state: "present", output: "alpha Ready" }, + ); vi.spyOn(onboardSession, "loadSession").mockReturnValue(session); vi.spyOn(onboardSession, "acquireOnboardLock").mockReturnValue({ acquired: true }); vi.spyOn(onboardSession, "updateSession").mockImplementation((mutator: unknown) => { @@ -439,6 +456,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): if (normalizedPresetName === "throw") throw new Error("preset boom"); return normalizedPresetName === "npm"; }); + const applyPresetContentSpy = vi.spyOn(policies, "applyPresetContent").mockReturnValue(true); const executeSandboxCommandSpy = vi .spyOn(processRecovery, "executeSandboxCommand") .mockImplementation( @@ -460,6 +478,26 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const ensureMessagingHostForwardAfterRebuildSpy = vi .spyOn(messagingHostForwardLifecycle, "ensureMessagingHostForwardAfterRebuild") .mockReturnValue(true); + const emptyMcpPreparation = { + entries: [], + detachedProviderEntries: [], + scrubbedAdapterEntries: [], + }; + const prepareMcpBridgesForRebuildSpy = vi + .spyOn(mcpBridge, "prepareMcpBridgesForRebuild") + .mockResolvedValue(overrides.mcpPreparation ?? emptyMcpPreparation); + vi.spyOn(mcpBridge, "prepareMcpBridgesForAbsentSandboxRebuild").mockResolvedValue( + overrides.mcpPreparation ?? emptyMcpPreparation, + ); + const reattachMcpProvidersAfterRebuildAbortSpy = vi + .spyOn(mcpBridge, "reattachMcpProvidersAfterRebuildAbort") + .mockResolvedValue(undefined); + const restoreMcpBridgesAfterRebuildSpy = vi + .spyOn(mcpBridge, "restoreMcpBridgesAfterRebuild") + .mockResolvedValue(undefined); + const warnUnpreservedUserManagedFilesSpy = vi + .spyOn(rebuildFlowHelpers, "warnUnpreservedUserManagedFiles") + .mockImplementation(() => undefined); errorSpy.mockClear(); logSpy.mockClear(); @@ -468,6 +506,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): return { rebuildSandbox: requireDist(rebuildModulePath).rebuildSandbox, applyPresetSpy, + applyPresetContentSpy, backupSandboxStateSpy, disposePreparedDcodeRebuildImageSpy, errorSpy, @@ -489,6 +528,10 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): restoreSandboxStateSpy, runOpenshellSpy, messagingRebuildPlanSpy, + prepareMcpBridgesForRebuildSpy, + reattachMcpProvidersAfterRebuildAbortSpy, + restoreMcpBridgesAfterRebuildSpy, + warnUnpreservedUserManagedFilesSpy, preparedDcodeBuildContext, session, }; diff --git a/test/helpers/rebuild-managed-image-preflight-harness.ts b/test/helpers/rebuild-managed-image-preflight-harness.ts new file mode 100644 index 00000000000..ffc5f981cb4 --- /dev/null +++ b/test/helpers/rebuild-managed-image-preflight-harness.ts @@ -0,0 +1,105 @@ +// 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 { expect, vi } from "vitest"; +import { + disposePreparedDcodeRebuildImage, + type ManagedDcodeRebuildImageInput, + type ManagedDcodeRebuildImageResult, + type PreparedDcodeRebuildImage, + prepareManagedDcodeRebuildImage, +} from "../../src/lib/actions/sandbox/rebuild-managed-image-preflight"; +import { loadAgent } from "../../src/lib/agent/defs"; + +export const NO_FOLLOW_FLAG = + typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; +export const NON_BLOCK_FLAG = + typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0; + +export function expectPreparedImage( + result: ManagedDcodeRebuildImageResult, +): PreparedDcodeRebuildImage { + expect(result.ok).toBe(true); + return (result as Extract).prepared; +} + +export function dcodeInput( + overrides: Partial = {}, +): ManagedDcodeRebuildImageInput { + return { + agent: loadAgent("langchain-deepagents-code"), + model: "nvidia/nemotron-3-super-120b-a12b", + provider: "compatible-endpoint", + preferredInferenceApi: "openai-completions", + compatibleEndpointReasoning: "false", + webSearchConfig: null, + sandboxGpuConfig: { + mode: "0", + hostGpuDetected: false, + hostGpuPlatform: null, + sandboxGpuEnabled: false, + sandboxGpuDevice: null, + errors: [], + }, + ...overrides, + }; +} + +export async function createPreparedDcodeImageFixture() { + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-context-")); + const buildCtx = path.join(testRoot, "context"); + fs.mkdirSync(buildCtx); + const stagedDockerfile = path.join(buildCtx, "Dockerfile"); + const originalDockerfile = path.join(testRoot, "Dockerfile.original"); + const replacementDockerfile = path.join(testRoot, "Dockerfile.replacement"); + fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + fs.writeFileSync(replacementDockerfile, "FROM attacker-controlled\n"); + const cleanupBuildCtx = vi.fn(() => { + fs.rmSync(testRoot, { recursive: true, force: true }); + return true; + }); + const stageBuildContext = vi.fn(() => ({ + buildCtx, + stagedDockerfile, + cleanupBuildCtx, + origin: "generated" as const, + })); + const prepareDockerfilePatch = vi.fn(async () => ({ + buildId: "dcode-build-1", + resolvedBaseImage: null, + })); + const buildImage = vi.fn(() => ({ status: 0 }) as never); + const removeImage = vi.fn(() => ({ status: 0 }) as never); + const result = await prepareManagedDcodeRebuildImage(dcodeInput(), { + stageBuildContext, + prepareDockerfilePatch, + buildImage, + removeImage, + createImageTag: () => "nemoclaw-rebuild-preflight:dcode-success", + }); + return { + testRoot, + buildCtx, + stagedDockerfile, + originalDockerfile, + replacementDockerfile, + cleanupBuildCtx, + stageBuildContext, + prepareDockerfilePatch, + buildImage, + removeImage, + result, + prepared: expectPreparedImage(result), + }; +} + +export function cleanupPreparedDcodeImageFixture( + fixture: Awaited>, +): void { + disposePreparedDcodeRebuildImage(fixture.prepared); + fs.rmSync(fixture.testRoot, { recursive: true, force: true }); +} diff --git a/test/langchain-deepagents-code-direct-module-patch.test.ts b/test/langchain-deepagents-code-direct-module-patch.test.ts index 8665698a4f9..6803533dda1 100644 --- a/test/langchain-deepagents-code-direct-module-patch.test.ts +++ b/test/langchain-deepagents-code-direct-module-patch.test.ts @@ -110,109 +110,10 @@ def cli_main(): writeFixtureFile( packageDir, "app.py", - ` -from __future__ import annotations - - -class UserMessage: - def __init__(self, value): - self.value = value - - -class AppMessage(UserMessage): - pass - - -class _Event: - def __init__(self): - self.was_set = False - - def set(self): - self.was_set = True - - -class DeepAgentsApp: - def __init__(self): - self.messages = [] - self.notifications = [] - self.original_commands = [] - self.original_auth_manager = False - self.original_mcp_login = False - self.original_service_key = False - self.original_tavily = False - self.original_update_action = False - self.original_switch_kwargs = "not-called" - self._update_check_done = _Event() - self._auto_approve = True - self._status_bar = None - self._session_state = None - self._rubric_model = "attacker:model" - self._server_kwargs = {"rubric_model": "attacker:model"} - - async def _mount_message(self, message): - self.messages.append(message.value) - - def notify(self, message, **kwargs): - self.notifications.append((message, kwargs)) - - async def _handle_command(self, command): - self.original_commands.append(command) - - async def _switch_model(self, model_spec, **kwargs): - del model_spec - self.original_switch_kwargs = kwargs.get("extra_kwargs") - - async def _check_for_updates(self, *, periodic=False): - del periodic - - async def _handle_update_command(self, command="/update"): - del command - - async def _handle_install_command(self, command): - del command - - async def _install_extra(self, *args, **kwargs): - del args, kwargs - return True - - async def _handle_install_package(self, *args, **kwargs): - del args, kwargs - - async def _handle_auto_update_toggle(self): - return None - - async def _prompt_launch_tavily(self): - self.original_tavily = True - - async def _prompt_model_auth_if_needed(self, model_spec): - del model_spec - return True - - async def _show_auth_manager(self, **kwargs): - del kwargs - self.original_auth_manager = True - - async def _enter_service_api_key(self, *args, **kwargs): - del args, kwargs - self.original_service_key = True - - async def _handle_update_action(self, *args, **kwargs): - del args, kwargs - self.original_update_action = True - - def _start_mcp_login(self, server_name): - del server_name - self.original_mcp_login = True - - async def _on_auto_approve_enabled(self): - self._auto_approve = True - - async def action_toggle_auto_approve(self): - self._auto_approve = not self._auto_approve - - async def _set_rubric_model(self, model_spec): - self._rubric_model = model_spec -`, + fs.readFileSync( + path.join(process.cwd(), "test", "fixtures", "langchain-deepagents-code", "app.py"), + "utf8", + ), ); writeFixtureFile( packageDir, @@ -339,16 +240,36 @@ def list_subagents(*args, **kwargs): writeFixtureFile( packageDir, "server.py", + fs.readFileSync( + path.join(process.cwd(), "test", "fixtures", "langchain-deepagents-code", "server.py"), + "utf8", + ), + ); + writeFixtureFile( + packageDir, + "_server_config.py", ` from __future__ import annotations -import os +from pathlib import Path -def _build_server_env(): - return dict(os.environ) +def _normalize_path(raw_path, project_context, label): + if not raw_path: + return None + if project_context is not None: + return str(project_context.resolve_user_path(raw_path)) + return str(Path(raw_path).expanduser().resolve()) `, ); + writeFixtureFile( + packageDir, + "mcp_tools.py", + fs.readFileSync( + path.join(process.cwd(), "test", "fixtures", "langchain-deepagents-code", "mcp_tools.py"), + "utf8", + ), + ); writeFixtureFile( packageDir, "hooks.py", @@ -667,6 +588,8 @@ describe("LangChain Deep Agents Code managed package patch", () => { "widgets/model_selector.py", "widgets/approval.py", "server.py", + "_server_config.py", + "mcp_tools.py", "subagents.py", "hooks.py", "non_interactive.py", @@ -838,7 +761,8 @@ describe("LangChain Deep Agents Code managed package patch", () => { "from pathlib import Path", "from deepagents_code import _nemoclaw_managed as managed", "managed._MCP_CONFIG_FILE = Path(sys.argv[1])", - "print(managed.managed_mcp_config_path() or 'absent')", + "snapshot = managed.managed_mcp_config_path()", + "print(managed.managed_mcp_config_bytes(snapshot).decode() if snapshot else 'absent', end='')", ].join("; "), configPath, ], @@ -858,7 +782,7 @@ describe("LangChain Deep Agents Code managed package patch", () => { const valid = validate({ mcpServers: { github: validServer } }); expect(valid.status, valid.stderr).toBe(0); - expect(valid.stdout.trim()).toBe(configPath); + expect(JSON.parse(valid.stdout)).toEqual({ mcpServers: { github: validServer } }); for (const config of [ { mcpServers: { github: { command: "bash", args: ["-c", "id"] } } }, @@ -878,6 +802,48 @@ describe("LangChain Deep Agents Code managed package patch", () => { github: { ...validServer, url: "https://127.0.0.1/mcp/" }, }, }, + { + mcpServers: { + github: { ...validServer, url: "https://2130706433/mcp/" }, + }, + }, + { + mcpServers: { + github: { ...validServer, url: "https://0177.0.0.1/mcp/" }, + }, + }, + { + mcpServers: { + github: { ...validServer, url: "https://api.githubcopilot.com:443/mcp/" }, + }, + }, + { + mcpServers: { + github: { ...validServer, url: "https://api.githubcopilot.com/a/../mcp/" }, + }, + }, + { + mcpServers: { + github: { ...validServer, url: "https://api.githubcopilot.com/mcp path/" }, + }, + }, + ...[ + "mcp_bad.example.test", + "-mcp.example.test", + "mcp-.example.test", + "mcp..example.test", + `${"a".repeat(64)}.example.test`, + `${"a".repeat(63)}.${"b".repeat(63)}.${"c".repeat(63)}.${"d".repeat(63)}`, + ].map((hostname) => ({ + mcpServers: { + github: { ...validServer, url: `https://${hostname}/mcp/` }, + }, + })), + { + mcpServers: Object.fromEntries( + Array.from({ length: 65 }, (_, index) => [`server${index}`, validServer]), + ), + }, ]) { const result = validate(config); expect(result.status, JSON.stringify(config)).not.toBe(0); @@ -888,6 +854,213 @@ describe("LangChain Deep Agents Code managed package patch", () => { expect(badMode.stderr).toContain("unsafe ownership or mode"); }); + it("rejects duplicate keys and configs beyond the 256 KiB cap", () => { + const tempDir = createPackageFixture(); + patchFixture(tempDir); + const configPath = path.join(tempDir, ".mcp.json"); + const run = () => + spawnSync( + "python3", + [ + "-c", + [ + "import sys", + "from pathlib import Path", + "from deepagents_code import _nemoclaw_managed as managed", + "managed._MCP_CONFIG_FILE = Path(sys.argv[1])", + "managed.managed_mcp_config_path()", + ].join("; "), + configPath, + ], + { + env: { PATH: process.env.PATH, PYTHONPATH: tempDir }, + encoding: "utf8", + }, + ); + + fs.writeFileSync( + configPath, + '{"mcpServers":{"github":{"type":"http","type":"http","url":"https://api.githubcopilot.com/mcp/","headers":{"Authorization":"Bearer openshell:resolve:env:GITHUB_MCP_TOKEN"}}}}\n', + { mode: 0o600 }, + ); + const duplicate = run(); + expect(duplicate.status).not.toBe(0); + expect(duplicate.stderr).toContain("duplicate JSON key"); + + fs.writeFileSync(configPath, " ".repeat(262_145), { mode: 0o600 }); + const oversized = run(); + expect(oversized.status).not.toBe(0); + expect(oversized.stderr).toContain("invalid size"); + + const targetPath = path.join(tempDir, "symlink-target.json"); + fs.writeFileSync(targetPath, '{"mcpServers":{}}\n', { mode: 0o600 }); + fs.rmSync(configPath); + fs.symlinkSync(targetPath, configPath); + const symlinked = run(); + expect(symlinked.status).not.toBe(0); + }); + + it("passes sealed and anonymous MCP snapshots through ServerProcess restart", () => { + const tempDir = createPackageFixture(); + patchFixture(tempDir); + const configPath = path.join(tempDir, ".nemoclaw-mcp.json"); + const managedConfig = { + mcpServers: { + github: { + type: "http", + url: "https://api.githubcopilot.com/mcp/", + headers: { + Authorization: "Bearer openshell:resolve:env:GITHUB_MCP_TOKEN", + }, + }, + }, + }; + for (const snapshotKind of ["sealed-memfd", "anonymous-otmpfile"] as const) { + fs.writeFileSync(configPath, `${JSON.stringify(managedConfig)}\n`, { mode: 0o600 }); + + const result = spawnSync( + "python3", + [ + "-c", + ` +import asyncio +import errno +import fcntl +import json +import os +import sys +from pathlib import Path + +from deepagents_code import _nemoclaw_managed as managed +from deepagents_code import _server_config, app, mcp_tools +from deepagents_code.server import ServerProcess + +real_memfd_create = os.memfd_create +if sys.argv[2] == "anonymous-otmpfile": + def blocked_memfd(*_args, **_kwargs): + raise PermissionError(errno.EPERM, "blocked by seccomp") + managed.os.memfd_create = blocked_memfd +managed._MCP_CONFIG_FILE = Path(sys.argv[1]) +snapshot_path = managed.managed_mcp_config_path() +assert snapshot_path is not None +descriptor = int(snapshot_path.removeprefix("/proc/self/fd/")) +binding = managed._MANAGED_MCP_BINDING +assert binding is not None +required_seals = ( + fcntl.F_SEAL_WRITE + | fcntl.F_SEAL_GROW + | fcntl.F_SEAL_SHRINK + | fcntl.F_SEAL_SEAL +) +if binding["kind"] == managed._MCP_SEALED_KIND: + assert fcntl.fcntl(descriptor, fcntl.F_GET_SEALS) == required_seals +else: + assert binding["kind"] == managed._MCP_ANONYMOUS_KIND + assert fcntl.fcntl(descriptor, fcntl.F_GETFL) & os.O_ACCMODE == os.O_RDONLY +assert managed.managed_mcp_config_bytes(snapshot_path) == managed.managed_mcp_config_bytes(snapshot_path) +assert _server_config._normalize_path(snapshot_path, None, "MCP config") == snapshot_path +assert app.DeepAgentsApp._absolutize_launch_relative_path( + snapshot_path, Path.cwd() +) == snapshot_path +assert mcp_tools.discover_mcp_configs() == [] +expected_config = json.loads(managed.managed_mcp_config_bytes(snapshot_path)) + +class RejectingProjectContext: + def resolve_user_path(self, _path): + raise AssertionError("managed descriptor path must not be resolved") + +child = ( + "import json, os; from deepagents_code.mcp_tools import load_mcp_config; " + "config = load_mcp_config(os.environ['DEEPAGENTS_CODE_SERVER_MCP_CONFIG_PATH']); " + "assert 'NEMOCLAW_DCODE_MCP_BINDING' not in os.environ; " + "print(json.dumps(config), end='')" +) +def server_for_path(config_path): + env = os.environ.copy() + env["DEEPAGENTS_CODE_SERVER_MCP_CONFIG_PATH"] = config_path + env["NEMOCLAW_DCODE_MCP_BINDING"] = "hostile-binding" + return ServerProcess([sys.executable, "-c", child], os.getcwd(), env) + +def make_descriptor_server(name, payload, seals): + descriptor = real_memfd_create(name, flags=os.MFD_ALLOW_SEALING) + os.write(descriptor, payload) + fcntl.fcntl(descriptor, fcntl.F_ADD_SEALS, seals) + return descriptor, server_for_path(f"/proc/self/fd/{descriptor}") + +server = server_for_path(snapshot_path) +unsealed_descriptor, unsealed_server = make_descriptor_server( + "unsealed-dcode-mcp", b"{}", 0 +) +empty_descriptor, empty_server = make_descriptor_server( + "empty-dcode-mcp", b"", required_seals +) +oversized_descriptor, oversized_server = make_descriptor_server( + "oversized-dcode-mcp", b"x" * 262_145, required_seals +) + +async def exercise(): + resolved_configs = await mcp_tools.resolve_and_load_mcp_tools( + explicit_config_path=snapshot_path, + project_context=RejectingProjectContext(), + ) + assert resolved_configs == [expected_config] + await server.start() + Path(sys.argv[1]).write_text( + json.dumps({ + "mcpServers": { + "attacker": { + "type": "http", + "url": "https://attacker.example/mcp/", + "headers": { + "Authorization": "Bearer openshell:resolve:env:ATTACKER_TOKEN" + }, + } + } + }), + encoding="utf-8", + ) + await server.restart() + for invalid_server in (unsealed_server, empty_server, oversized_server): + try: + await invalid_server.start() + except RuntimeError as exc: + assert "not process-local" in str(exc) + assert not hasattr(invalid_server, "_log_file") + else: + raise AssertionError("invalid MCP descriptor was inherited") + +asyncio.run(exercise()) +for descriptor in (unsealed_descriptor, empty_descriptor, oversized_descriptor): + os.close(descriptor) +print(json.dumps({ + "path": snapshot_path, + "kind": binding["kind"], + "outputs": [json.loads(output) for output in server.outputs], +})) +`, + configPath, + snapshotKind, + ], + { + cwd: tempDir, + env: { PATH: process.env.PATH, PYTHONPATH: tempDir }, + encoding: "utf8", + }, + ); + + expect(result.status, result.stderr).toBe(0); + const proof = JSON.parse(result.stdout) as { + path: string; + kind: string; + outputs: unknown[]; + }; + expect(proof.path).toMatch(/^\/proc\/self\/fd\/[0-9]+$/); + expect(proof.kind).toBe(snapshotKind); + expect(proof.outputs).toEqual([managedConfig, managedConfig]); + expect(result.stdout).not.toContain("attacker"); + } + }); + it("blocks TUI commands, credential screens, dotenv, OAuth, and install backends", () => { const tempDir = createPackageFixture(); patchFixture(tempDir); @@ -1093,8 +1266,15 @@ async def validate(): assert headless_kwargs["rubric_model"] is None assert non_interactive.settings.shell_allow_list is None _nemoclaw_managed._MCP_CONFIG_FILE = Path(${JSON.stringify(managedMcpPath)}) + _nemoclaw_managed._MANAGED_MCP_FD = _nemoclaw_managed._MANAGED_MCP_BINDING = None + _nemoclaw_managed._MANAGED_MCP_READY = False managed_args = dcode_main.parse_args() - assert managed_args.mcp_config == ${JSON.stringify(managedMcpPath)} + snapshot_mcp_path = managed_args.mcp_config + assert snapshot_mcp_path.startswith("/proc/self/fd/") + assert Path(snapshot_mcp_path).is_file() + assert instance._absolutize_launch_relative_path( + snapshot_mcp_path, Path.cwd() + ) == snapshot_mcp_path assert managed_args.no_mcp is False assert managed_args.trust_project_mcp is False managed_headless_kwargs = await non_interactive.run_non_interactive( @@ -1104,7 +1284,7 @@ async def validate(): no_mcp=True, trust_project_mcp=True, ) - assert managed_headless_kwargs["mcp_config_path"] == ${JSON.stringify(managedMcpPath)} + assert managed_headless_kwargs["mcp_config_path"] == snapshot_mcp_path assert managed_headless_kwargs["no_mcp"] is False assert managed_headless_kwargs["trust_project_mcp"] is False assert model_config.ModelConfig().get_class_path("openai") is None diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index 6417b1ddf29..3faec57c3c8 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -8,7 +8,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import YAML from "yaml"; -import { CONTEXT_PATTERNS, TOKEN_PREFIX_PATTERNS } from "../src/lib/security/secret-patterns.ts"; +import { TOKEN_PREFIX_PATTERNS } from "../src/lib/security/secret-patterns.ts"; import { cloudExperimentalChecksForOnboarding } from "./e2e/live/cloud-experimental-check-list.ts"; import { DCODE_CANONICAL_PATH, @@ -20,12 +20,9 @@ import { runStartScriptProxyProbe, TRACING_ENABLE_ENV_NAMES, } from "./helpers/langchain-deepagents-code-headless.ts"; +import { CANONICAL_SECRET_POSITIVE_VECTORS } from "./helpers/langchain-deepagents-code-secret-patterns.ts"; import { makeStartScriptFixture as makeIdentityStartScriptFixture } from "./support/dcode-start-script-fixture.ts"; -function fingerprint(patterns: readonly RegExp[]): string[] { - return patterns.map((re) => `${re.source}::${re.flags}`); -} - function containsTokenShapedSecret(value: string): boolean { return TOKEN_PREFIX_PATTERNS.some((pattern) => { pattern.lastIndex = 0; @@ -63,8 +60,8 @@ const MANAGED_MCP_VALIDATOR_INVOCATION = [ ].join("\n"); function stubManagedMcpValidator(source: string): string { - expect(source).toContain(MANAGED_MCP_VALIDATOR_INVOCATION); - return source.replace(MANAGED_MCP_VALIDATOR_INVOCATION, 'managed_mcp_config=""'); + expect(source).not.toContain(MANAGED_MCP_VALIDATOR_INVOCATION); + return source; } function makeWrapperFixture( @@ -310,10 +307,9 @@ describe("LangChain Deep Agents Code image contracts", () => { expect(wrapper).toContain("unset PYTHONHOME PYTHONPATH"); expect(wrapper).toContain('/opt/venv/bin/python3 -I - "$auth_file"'); expect(wrapper).toContain("exec /opt/venv/bin/python3 -I -m deepagents_code"); - expect(wrapper).toContain("extra_args=(--sandbox none)"); - expect(wrapper).toContain('extra_args+=(--mcp-config "$managed_mcp_config")'); + expect(wrapper).toContain("extra_args=(--sandbox none --no-mcp)"); + expect(wrapper).not.toContain("managed_mcp_config_path"); expect(wrapper).not.toContain("--mcp-config /sandbox/.mcp.json"); - expect(wrapper).toContain("extra_args+=(--no-mcp)"); expect(wrapper).toContain("assert_no_auth_store_credentials"); expect(wrapper).toContain("assert_no_codex_auth_credentials"); for (const s of [ @@ -331,6 +327,7 @@ describe("LangChain Deep Agents Code image contracts", () => { expect(wrapper).toContain(s); } for (const s of [ + "managed-dcode-runtime.py", "patch-managed-deepagents-code.py", "DEEPAGENTS_CODE_LANGSMITH_TRACING=false", "LANGSMITH_TRACING=false", @@ -352,33 +349,45 @@ describe("LangChain Deep Agents Code image contracts", () => { it("exposes an exact managed MCP capability marker without starting dcode", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-mcp-capability-")); try { - const { wrapperPath, ranMarker } = makeWrapperFixture(tempDir); - const result = runWrapper(wrapperPath, ["--nemoclaw-mcp-capability"], {}); + const { wrapperPath, ranMarker, authFile, codexAuthFile } = makeWrapperFixture(tempDir); + fs.writeFileSync(authFile, '{"api_key":"forbidden"}\n', "utf8"); + fs.writeFileSync(codexAuthFile, '{"access_token":"forbidden"}\n', "utf8"); + const result = runWrapper(wrapperPath, ["--nemoclaw-mcp-capability"], { + OPENAI_API_KEY: "forbidden", + NEMOCLAW_DEEPAGENTS_CODE_AUTH_MODE: "invalid", + }); expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toBe("NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=1\n"); + expect(result.stdout).toBe("NEMOCLAW_DEEPAGENTS_MCP_CAPABILITY=2\n"); expect(fs.existsSync(ranMarker)).toBe(false); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } }); - it("uses the pinned Deep Agents Code user-level MCP discovery path", () => { + it("keeps NemoClaw MCP state separate from user discovery", () => { const requirements = readAgentFile("requirements.lock"); const wrapper = readAgentFile("dcode-wrapper.sh"); + const managedRuntime = readAgentFile("managed-dcode-runtime.py"); const patcher = readAgentFile("patch-managed-deepagents-code.py"); const manifest = readAgentFile("manifest.yaml"); - const userLevelPath = "/sandbox/.deepagents/.mcp.json"; + const managedPath = "/sandbox/.deepagents/.nemoclaw-mcp.json"; - // The pinned Deep Agents Code release discovers ~/.deepagents/.mcp.json as user-level - // config. /sandbox/.mcp.json is project-level and headless `dcode -n` - // rejects it unless the project trust gate has been satisfied. + // The pinned release's user/project .mcp.json files remain user-authored. + // Managed images suppress discovery and pass only an integrity-bound + // snapshot of NemoClaw's dedicated projection. expect(requirements).toContain("deepagents-code==0.1.30"); - expect(wrapper).toContain("managed_mcp_config_path"); - expect(patcher).toContain(`_MCP_CONFIG_FILE = Path("${userLevelPath}")`); + expect(wrapper).toContain("extra_args=(--sandbox none --no-mcp)"); + expect(managedRuntime).toContain(`_MCP_CONFIG_FILE = Path("${managedPath}")`); expect(patcher).toContain("managed_mcp_config = _nemoclaw_managed_mcp_config_path()"); + expect(managedRuntime).toContain("if not servers:\n return None"); + expect(managedRuntime).toContain("or descriptor != _MANAGED_MCP_FD"); + expect(patcher).toContain("def discover_mcp_configs("); + expect(patcher).toContain("return []"); expect(manifest).toContain("- .deepagents/.mcp.json"); + expect(manifest).toContain(".deepagents/.nemoclaw-mcp.json projection"); expect(wrapper).not.toContain("--mcp-config /sandbox/.mcp.json"); + expect(wrapper).not.toContain("managed_mcp_config_path"); expect(patcher).not.toContain('managed_mcp_config = "/sandbox/.mcp.json"'); }); @@ -1417,72 +1426,20 @@ describe("LangChain Deep Agents Code image contracts", () => { expect(fs.existsSync(ranMarker)).toBe(false); }); - it("pins the wrapper parity contract to the canonical TOKEN_PREFIX_PATTERNS fingerprint to surface drift", () => { - expect(fingerprint(TOKEN_PREFIX_PATTERNS)).toEqual([ - "nvapi-[A-Za-z0-9_-]{10,}::g", - "nvcf-[A-Za-z0-9_-]{10,}::g", - "ghp_[A-Za-z0-9_-]{10,}::g", - "(?:github_pat_)[A-Za-z0-9_]{30,}::g", - "sk-proj-[A-Za-z0-9_-]{10,}::g", - "sk-ant-[A-Za-z0-9_-]{10,}::g", - "sk-[A-Za-z0-9_-]{20,}::g", - "(?:xox[bpas]|xapp)-[A-Za-z0-9-]{10,}::g", - "A(?:K|S)IA[A-Z0-9]{16}::g", - "hf_[A-Za-z0-9]{10,}::g", - "glpat-[A-Za-z0-9_-]{10,}::g", - "gsk_[A-Za-z0-9]{10,}::g", - "pypi-[A-Za-z0-9_-]{10,}::g", - "\\bbot\\d{8,10}:[A-Za-z0-9_-]{35}\\b::g", - "\\b\\d{8,10}:[A-Za-z0-9_-]{35}\\b::g", - "\\b[A-Za-z0-9]{24}\\.[A-Za-z0-9_-]{6}\\.[A-Za-z0-9_-]{27,}\\b::g", - "tvly-[A-Za-z0-9_-]{10,}::g", - "lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*::g", - ]); - }); - - it("pins the wrapper parity contract to the canonical CONTEXT_PATTERNS fingerprint to surface drift", () => { - expect(fingerprint(CONTEXT_PATTERNS)).toEqual([ - "(?<=Bearer\\s+)[A-Za-z0-9_.+/=-]{10,}::gi", - "(?<=(?:_KEY|API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)[=: ]['\"]?)[A-Za-z0-9_.+/=-]{10,}::gi", - ]); - }); - - it("rejects every canonical token shape declared by the secret-pattern contract", () => { - const cases: Array<{ name: string; sample: string }> = [ - { name: "nvapi", sample: "nvapi-abcdefghijklmnop" }, - { name: "nvcf", sample: "nvcf-abcdefghijklmnopq" }, - { name: "ghp", sample: "ghp_abcdefghijklmnopqr" }, - { name: "github_pat", sample: "github_pat_abcdefghijklmnopqrstuvwxyz0123" }, - { name: "sk_proj", sample: "sk-proj-abcdefghij" }, - { name: "sk_ant", sample: "sk-ant-abcdefghijk" }, - { name: "sk", sample: "sk-abcdefghijklmnopqrstuvwx" }, - { name: "xoxb", sample: "xoxb-1234567890" }, - { name: "xoxp", sample: "xoxp-1234567890" }, - { name: "xoxa", sample: ["xoxa", "1234567890"].join("-") }, - { name: "xoxs", sample: "xoxs-1234567890" }, - { name: "xapp", sample: ["xapp", "1", "A1B2C3", "12345", "abcde"].join("-") }, - { name: "akia", sample: ["AKIA", "ABCDEFGHIJKLMNOP"].join("") }, - { name: "asia", sample: ["ASIA", "ABCDEFGHIJKLMNOP"].join("") }, - { name: "hf", sample: "hf_abcdefghijklmnopq" }, - { name: "glpat", sample: "glpat-abcdefghijklmn" }, - { name: "gsk", sample: "gsk_abcdefghijklmnop" }, - { name: "pypi", sample: "pypi-abcdefghijklmnop" }, - { name: "tavily", sample: "tvly-abcdefghijklmnop" }, - { name: "telegram", sample: "123456789:AbcDefGhiJklMnoPqrStuVwxYz012345678" }, - { name: "telegram_bot", sample: "bot123456789:AbcDefGhiJklMnoPqrStuVwxYz012345678" }, - { name: "discord", sample: "ABCDEFGHIJKLMNOPQRSTUVWX.Abcdef.ZZZZZZZZZZZZZZZZZZZZZZZZZZZ" }, - { name: "langsmith_pt", sample: `lsv2_pt_${"a".repeat(36)}_${"b".repeat(10)}` }, - { name: "langsmith_sk", sample: `lsv2_sk_${"a".repeat(36)}_${"b".repeat(10)}` }, - ]; - for (const { name, sample } of cases) { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `nemoclaw-dcode-parity-${name}-`)); - const { wrapperPath, ranMarker } = makeWrapperFixture(tempDir); - const varName = `NEMOCLAW_PARITY_${name.toUpperCase()}`; - const result = runWrapper(wrapperPath, ["-n", "hi"], { [varName]: sample }); - expect(result.status, `${name} via runtime env not rejected`).not.toBe(0); - expect(result.stderr).toContain(varName); - expect(result.stderr).not.toContain(sample); - expect(fs.existsSync(ranMarker)).toBe(false); + it("rejects the canonical positive secret corpus before dcode starts (#6195)", () => { + for (const { label, value } of CANONICAL_SECRET_POSITIVE_VECTORS) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `nemoclaw-dcode-parity-${label}-`)); + try { + const { wrapperPath, ranMarker } = makeWrapperFixture(tempDir); + const varName = `NEMOCLAW_PARITY_${label.toUpperCase()}`; + const result = runWrapper(wrapperPath, ["-n", "hi"], { [varName]: value }); + expect(result.status, `${label} via runtime env not rejected`).not.toBe(0); + expect(result.stderr).toContain(varName); + expect(result.stderr).not.toContain(value); + expect(fs.existsSync(ranMarker)).toBe(false); + } finally { + fs.rmSync(tempDir, { force: true, recursive: true }); + } } }); }); diff --git a/test/langchain-deepagents-code-managed-entrypoints.test.ts b/test/langchain-deepagents-code-managed-entrypoints.test.ts index e465f9fab0d..c0cbc243e2b 100644 --- a/test/langchain-deepagents-code-managed-entrypoints.test.ts +++ b/test/langchain-deepagents-code-managed-entrypoints.test.ts @@ -37,8 +37,12 @@ function makeWrapperFixture(tempDir: string): { wrapperPath: string; ranMarker: const envFile = path.join(tempDir, ".env"); const authFile = path.join(tempDir, "auth.json"); const codexAuthFile = path.join(tempDir, "chatgpt-auth.json"); - const fixture = readAgentFile("dcode-wrapper.sh") - .replace(MANAGED_MCP_VALIDATOR_INVOCATION, 'managed_mcp_config=""') + const source = readAgentFile("dcode-wrapper.sh"); + expect( + source, + "managed MCP descriptors must be opened by the long-lived Python process", + ).not.toContain(MANAGED_MCP_VALIDATOR_INVOCATION); + const fixture = source .replace( 'readonly DEEPAGENTS_ENV_FILE="/sandbox/.deepagents/.env"', `readonly DEEPAGENTS_ENV_FILE="${envFile}"`, diff --git a/test/langchain-deepagents-code-managed-mcp-hardening.test.ts b/test/langchain-deepagents-code-managed-mcp-hardening.test.ts new file mode 100644 index 00000000000..03a52021123 --- /dev/null +++ b/test/langchain-deepagents-code-managed-mcp-hardening.test.ts @@ -0,0 +1,335 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const managedRuntimePath = path.join( + process.cwd(), + "agents", + "langchain-deepagents-code", + "managed-dcode-runtime.py", +); + +function runManagedHelper(source: string) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-managed-mcp-")); + try { + const helperPath = path.join(tempDir, "_nemoclaw_managed.py"); + const helperSource = fs.readFileSync(managedRuntimePath, "utf-8"); + fs.writeFileSync(helperPath, helperSource, "utf-8"); + return spawnSync("python3", ["-I", "-c", source, helperPath], { + encoding: "utf-8", + timeout: 5000, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +describe("Deep Agents managed MCP runtime hardening", () => { + it("treats only the exact empty managed projection as an absent snapshot", () => { + const result = runManagedHelper(String.raw` +import importlib.util +import sys + +spec = importlib.util.spec_from_file_location("_nemoclaw_managed", sys.argv[1]) +managed = importlib.util.module_from_spec(spec) +spec.loader.exec_module(managed) + +tombstone = b'{"mcpServers":{}}\n' +assert managed._canonicalize_managed_mcp_config(tombstone) is None +managed._read_managed_mcp_config = lambda: tombstone +assert managed.managed_mcp_config_path() is None +assert managed._MANAGED_MCP_READY is True +assert managed._MANAGED_MCP_FD is None + +invalid = ( + b'{}', + b'[]', + b'null', + b'{"mcpServers":[]}', + b'{"mcpServers":null}', + b'{"mcpServers":{},"extra":{}}', + b'{"mcpServers":{},"mcpServers":{}}', + b'{"mcpServers":NaN}', +) +for raw in invalid: + try: + managed._canonicalize_managed_mcp_config(raw) + except RuntimeError: + pass + else: + raise AssertionError(f"accepted malformed empty projection: {raw!r}") +print("strict-tombstone-ok") +`); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe("strict-tombstone-ok"); + }); + + it("rejects a same-sized fully sealed descriptor not created by this process state", () => { + const result = runManagedHelper(String.raw` +import fcntl +import importlib.util +import os +import sys + +spec = importlib.util.spec_from_file_location("_nemoclaw_managed", sys.argv[1]) +managed = importlib.util.module_from_spec(spec) +spec.loader.exec_module(managed) + +raw = b'{"mcpServers":{"github":{"type":"http","url":"https://example.test/mcp","headers":{"Authorization":"Bearer openshell:resolve:env:GITHUB_TOKEN"}}}}' +payload = managed._canonicalize_managed_mcp_config(raw) +assert payload is not None +local_descriptor, local_binding = managed._managed_mcp_snapshot(payload) +foreign_descriptor, foreign_binding = managed._managed_mcp_snapshot(payload) +assert local_binding["kind"] == managed._MCP_SEALED_KIND +assert foreign_binding["kind"] == managed._MCP_SEALED_KIND +managed._MANAGED_MCP_FD = local_descriptor +managed._MANAGED_MCP_BINDING = local_binding +managed._MANAGED_MCP_READY = True +local_path = f"/proc/self/fd/{local_descriptor}" +foreign_path = f"/proc/self/fd/{foreign_descriptor}" + +assert os.fstat(local_descriptor).st_size == os.fstat(foreign_descriptor).st_size +assert fcntl.fcntl(local_descriptor, fcntl.F_GET_SEALS) == managed._MCP_REQUIRED_SEALS +assert fcntl.fcntl(foreign_descriptor, fcntl.F_GET_SEALS) == managed._MCP_REQUIRED_SEALS +assert managed.managed_mcp_server_descriptor(local_path) == local_descriptor +try: + managed.managed_mcp_server_descriptor(foreign_path) +except RuntimeError as exc: + assert "process-local" in str(exc) +else: + raise AssertionError("foreign sealed descriptor was accepted") +finally: + os.close(local_descriptor) + os.close(foreign_descriptor) +print("descriptor-provenance-ok") +`); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe("descriptor-provenance-ok"); + }); + + it("falls back on blocked memfd with repeatable digest-bound child reads", () => { + const result = runManagedHelper(String.raw` +import errno +import fcntl +import importlib.util +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +spec = importlib.util.spec_from_file_location("_nemoclaw_managed", sys.argv[1]) +managed = importlib.util.module_from_spec(spec) +spec.loader.exec_module(managed) + +raw = b'{"mcpServers":{"github":{"type":"http","url":"https://example.test/mcp","headers":{"Authorization":"Bearer openshell:resolve:env:GITHUB_TOKEN"}}}}' +payload = managed._canonicalize_managed_mcp_config(raw) +assert payload is not None + +def blocked_memfd(*_args, **_kwargs): + raise PermissionError(errno.EPERM, "blocked by seccomp") + +with tempfile.TemporaryDirectory() as tempdir: + managed._MCP_CONFIG_FILE = Path(tempdir) / ".nemoclaw-mcp.json" + managed._read_managed_mcp_config = lambda: raw + managed.os.memfd_create = blocked_memfd + snapshot_path = managed.managed_mcp_config_path() + assert snapshot_path is not None + descriptor = int(snapshot_path.removeprefix("/proc/self/fd/")) + binding = managed._MANAGED_MCP_BINDING + assert binding is not None + assert binding["kind"] == managed._MCP_ANONYMOUS_KIND + metadata = os.fstat(descriptor) + assert metadata.st_nlink == 0 + assert metadata.st_mode & 0o777 == 0 + assert fcntl.fcntl(descriptor, fcntl.F_GETFL) & os.O_ACCMODE == os.O_RDONLY + assert managed.managed_mcp_config_bytes(snapshot_path) == payload + assert managed.managed_mcp_config_bytes(snapshot_path) == payload + + bound_descriptor, child_binding = managed.managed_mcp_server_binding(snapshot_path) + assert bound_descriptor == descriptor + child_code = """ +import importlib.util, os, sys +spec = importlib.util.spec_from_file_location("_nemoclaw_managed_child", sys.argv[1]) +child = importlib.util.module_from_spec(spec) +spec.loader.exec_module(child) +assert child.managed_mcp_config_bytes(sys.argv[2]) == child.managed_mcp_config_bytes(sys.argv[2]) +assert child._MCP_CHILD_BINDING_ENV not in os.environ +print(child.managed_mcp_config_bytes(sys.argv[2]).decode(), end="") +""" + child_env = os.environ.copy() + child_env[managed._MCP_CHILD_BINDING_ENV] = child_binding + for _start_or_restart in range(2): + result = subprocess.run( + [sys.executable, "-I", "-c", child_code, sys.argv[1], snapshot_path], + pass_fds=(descriptor,), + env=child_env, + capture_output=True, + ) + assert result.returncode == 0, result.stderr.decode() + assert result.stdout == payload + + os.environ[managed._MCP_CHILD_BINDING_ENV] = child_binding + child_spec = importlib.util.spec_from_file_location("_nemoclaw_managed_child", sys.argv[1]) + child = importlib.util.module_from_spec(child_spec) + child_spec.loader.exec_module(child) + assert child.managed_mcp_config_bytes(snapshot_path) == payload + assert child.managed_mcp_config_bytes(snapshot_path) == payload + assert managed._MCP_CHILD_BINDING_ENV not in os.environ + + foreign_descriptor = managed._anonymous_managed_mcp_snapshot(payload) + foreign_path = f"/proc/self/fd/{foreign_descriptor}" + try: + managed.managed_mcp_server_binding(foreign_path) + except RuntimeError as exc: + assert "not process-local" in str(exc) + else: + raise AssertionError("foreign anonymous descriptor was accepted by parent") + try: + child.managed_mcp_config_bytes(foreign_path) + except RuntimeError as exc: + assert "binding does not match" in str(exc) + else: + raise AssertionError("foreign anonymous descriptor was accepted by child") + os.close(foreign_descriptor) + + os.fchmod(descriptor, 0o600) + writer = os.open(snapshot_path, os.O_RDWR | os.O_CLOEXEC) + os.pwrite(writer, b"!" + payload[1:], 0) + os.close(writer) + os.fchmod(descriptor, 0) + tampered_child = subprocess.run( + [sys.executable, "-I", "-c", child_code, sys.argv[1], snapshot_path], + pass_fds=(descriptor,), + env=child_env, + capture_output=True, + ) + assert tampered_child.returncode != 0 + assert b"contents changed" in tampered_child.stderr + try: + managed.managed_mcp_config_bytes(snapshot_path) + except RuntimeError as exc: + assert "contents changed" in str(exc) + else: + raise AssertionError("same-size anonymous descriptor overwrite was accepted") + os.close(descriptor) +print("anonymous-fallback-ok") +`); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe("anonymous-fallback-ok"); + }); + + it("fails closed without O_TMPFILE and does not mask unrelated memfd errors", () => { + const result = runManagedHelper(String.raw` +import errno +import importlib.util +import os +import sys +import tempfile +from pathlib import Path + +spec = importlib.util.spec_from_file_location("_nemoclaw_managed", sys.argv[1]) +managed = importlib.util.module_from_spec(spec) +spec.loader.exec_module(managed) +raw = b'{"mcpServers":{"github":{"type":"http","url":"https://example.test/mcp","headers":{"Authorization":"Bearer openshell:resolve:env:GITHUB_TOKEN"}}}}' +managed._read_managed_mcp_config = lambda: raw +payload = managed._canonicalize_managed_mcp_config(raw) +assert payload is not None + +real_sealed_snapshot = managed._sealed_managed_mcp_snapshot +real_anonymous_snapshot = managed._anonymous_managed_mcp_snapshot +anonymous_calls = [] + +def tracked_anonymous_snapshot(snapshot_payload): + anonymous_calls.append(snapshot_payload) + return real_anonymous_snapshot(snapshot_payload) + +def wrapped_eperm(_payload): + try: + raise PermissionError(errno.EPERM, "blocked by seccomp") + except PermissionError as cause: + raise RuntimeError("sealed snapshot unavailable") from cause + +managed._sealed_managed_mcp_snapshot = wrapped_eperm +managed._anonymous_managed_mcp_snapshot = tracked_anonymous_snapshot +descriptor, binding = managed._managed_mcp_snapshot(payload) +try: + assert binding["kind"] == managed._MCP_ANONYMOUS_KIND + assert anonymous_calls == [payload] + assert managed._read_bound_managed_mcp_descriptor(descriptor, binding) == payload +finally: + os.close(descriptor) + +def wrapped_emfile(_payload): + try: + raise OSError(errno.EMFILE, "too many open files") + except OSError as cause: + raise RuntimeError("sealed snapshot unavailable") from cause + +managed._sealed_managed_mcp_snapshot = wrapped_emfile +try: + managed._managed_mcp_snapshot(payload) +except RuntimeError as exc: + assert str(exc) == "sealed snapshot unavailable" + assert isinstance(exc.__cause__, OSError) + assert exc.__cause__.errno == errno.EMFILE + assert managed._managed_mcp_fallback_allowed(exc) is False +else: + raise AssertionError("nested unrelated errno was masked by fallback") +assert anonymous_calls == [payload] +managed._sealed_managed_mcp_snapshot = real_sealed_snapshot +managed._anonymous_managed_mcp_snapshot = real_anonymous_snapshot + +def blocked_memfd(*_args, **_kwargs): + raise PermissionError(errno.EPERM, "blocked by seccomp") + +with tempfile.TemporaryDirectory() as tempdir: + managed._MCP_CONFIG_FILE = Path(tempdir) / ".nemoclaw-mcp.json" + managed.os.memfd_create = blocked_memfd + real_open = managed.os.open + before = set(os.listdir("/proc/self/fd")) + + def unsupported_tmpfile(path, flags, *args, **kwargs): + if flags & os.O_TMPFILE: + raise OSError(errno.EOPNOTSUPP, "O_TMPFILE unavailable") + return real_open(path, flags, *args, **kwargs) + + managed.os.open = unsupported_tmpfile + try: + managed.managed_mcp_config_path() + except RuntimeError as exc: + assert "anonymous O_TMPFILE support" in str(exc) + else: + raise AssertionError("linked temporary fallback was used") + finally: + managed.os.open = real_open + assert set(os.listdir("/proc/self/fd")) == before + assert managed._MANAGED_MCP_FD is None + assert managed._MANAGED_MCP_BINDING is None + assert managed._MANAGED_MCP_READY is False + + def exhausted_memfd(*_args, **_kwargs): + raise OSError(errno.EMFILE, "too many open files") + + managed.os.memfd_create = exhausted_memfd + try: + managed.managed_mcp_config_path() + except RuntimeError as exc: + assert "sealed memfd support" in str(exc) + else: + raise AssertionError("unexpected memfd error was masked by fallback") +print("fallback-fail-closed-ok") +`); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe("fallback-fail-closed-ok"); + }); +}); diff --git a/test/langchain-deepagents-code-secret-pattern-parity.test.ts b/test/langchain-deepagents-code-secret-pattern-parity.test.ts new file mode 100644 index 00000000000..53a4406d4a9 --- /dev/null +++ b/test/langchain-deepagents-code-secret-pattern-parity.test.ts @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + CONTEXT_PATTERNS, + SECRET_BLOCK_PATTERNS, + TOKEN_PREFIX_PATTERNS, +} from "../src/lib/security/secret-patterns.ts"; +import { + CANONICAL_SECRET_POSITIVE_VECTORS, + type CanonicalSecretPatternGroup, +} from "./helpers/langchain-deepagents-code-secret-patterns.ts"; + +const repoRoot = path.resolve(import.meta.dirname, ".."); +const managedRuntimePath = path.join( + repoRoot, + "agents", + "langchain-deepagents-code", + "managed-dcode-runtime.py", +); + +const canonicalPatterns: Record = { + token: TOKEN_PREFIX_PATTERNS, + context: CONTEXT_PATTERNS, + block: SECRET_BLOCK_PATTERNS, +}; + +function fingerprint(patterns: readonly RegExp[]): string[] { + return patterns.map((pattern) => `${pattern.source}::${pattern.flags}`); +} + +function matches(pattern: RegExp, value: string): boolean { + pattern.lastIndex = 0; + const matched = pattern.test(value); + pattern.lastIndex = 0; + return matched; +} + +describe("Deep Agents Code secret-pattern parity", () => { + it("pins every canonical pattern source and flag for non-TypeScript mirrors (#6195)", () => { + expect({ + token: fingerprint(TOKEN_PREFIX_PATTERNS), + context: fingerprint(CONTEXT_PATTERNS), + block: fingerprint(SECRET_BLOCK_PATTERNS), + }).toEqual({ + token: [ + "nvapi-[A-Za-z0-9_-]{10,}::g", + "nvcf-[A-Za-z0-9_-]{10,}::g", + "ghp_[A-Za-z0-9_-]{10,}::g", + "(?:github_pat_)[A-Za-z0-9_]{30,}::g", + "sk-proj-[A-Za-z0-9_-]{10,}::g", + "sk-ant-[A-Za-z0-9_-]{10,}::g", + "sk-[A-Za-z0-9_-]{20,}::g", + "(?:xox[bpas]|xapp)-[A-Za-z0-9-]{10,}::g", + "A(?:K|S)IA[A-Z0-9]{16}::g", + "hf_[A-Za-z0-9]{10,}::g", + "glpat-[A-Za-z0-9_-]{10,}::g", + "gsk_[A-Za-z0-9]{10,}::g", + "pypi-[A-Za-z0-9_-]{10,}::g", + "\\bbot\\d{8,10}:[A-Za-z0-9_-]{35}\\b::g", + "\\b\\d{8,10}:[A-Za-z0-9_-]{35}\\b::g", + "\\b[A-Za-z0-9]{24}\\.[A-Za-z0-9_-]{6}\\.[A-Za-z0-9_-]{27,}\\b::g", + "tvly-[A-Za-z0-9_-]{10,}::g", + "lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*::g", + ], + context: [ + "(?<=Bearer\\s+)[A-Za-z0-9_.+/=-]{10,}::gi", + "(?<=(?:_KEY|API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)[=: ]['\"]?)[A-Za-z0-9_.+/=-]{10,}::gi", + ], + block: [ + "-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----[\\s\\S]*?-----END (?:[A-Z0-9]+ )?PRIVATE KEY-----::g", + ], + }); + }); + + it("matches every shared positive vector with its designated canonical regex (#6195)", () => { + for (const [group, patterns] of Object.entries(canonicalPatterns) as Array< + [CanonicalSecretPatternGroup, readonly RegExp[]] + >) { + const coveredIndices = new Set( + CANONICAL_SECRET_POSITIVE_VECTORS.filter((vector) => vector.patternGroup === group).map( + (vector) => vector.patternIndex, + ), + ); + expect(coveredIndices, `${group} patterns must all have a positive vector`).toEqual( + new Set(patterns.map((_pattern, index) => index)), + ); + } + + for (const vector of CANONICAL_SECRET_POSITIVE_VECTORS) { + const pattern = canonicalPatterns[vector.patternGroup][vector.patternIndex]; + expect(pattern, `${vector.label} designates an existing canonical regex`).toBeDefined(); + expect(matches(pattern as RegExp, vector.value), vector.label).toBe(true); + } + }); + + it("detects every shared positive vector in the managed Python runtime (#6195)", () => { + const probe = ` +import importlib.util +import json +import sys + +sys.dont_write_bytecode = True +spec = importlib.util.spec_from_file_location("_nemoclaw_managed_parity", sys.argv[1]) +if spec is None or spec.loader is None: + raise RuntimeError("managed runtime module could not be loaded") +managed = importlib.util.module_from_spec(spec) +spec.loader.exec_module(managed) +values = json.load(sys.stdin) +json.dump([managed._contains_secret_shape(value) for value in values], sys.stdout) +`; + const output = execFileSync("python3", ["-I", "-c", probe, managedRuntimePath], { + encoding: "utf8", + input: JSON.stringify(CANONICAL_SECRET_POSITIVE_VECTORS.map((vector) => vector.value)), + }); + + expect(JSON.parse(output)).toEqual(CANONICAL_SECRET_POSITIVE_VECTORS.map(() => true)); + }); +}); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 4f50305f948..7065c9cb7e2 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -1226,6 +1226,10 @@ describe("Deep Agents Code durable state files", () => { fs.writeFileSync(path.join(deepAgentsDir, "config.toml"), "generated config\n"); fs.writeFileSync(path.join(deepAgentsDir, ".env"), "NVIDIA_API_KEY=should-not-copy\n"); fs.writeFileSync(path.join(deepAgentsDir, ".mcp.json"), '{"token":"should-not-copy"}\n'); + fs.writeFileSync( + path.join(deepAgentsDir, ".nemoclaw-mcp.json"), + '{"mcpServers":{"reconstructable":{}}}\n', + ); const openshell = path.join(binDir, "openshell"); writeExecutable( @@ -1309,9 +1313,13 @@ process.exit(0); ); expect(fs.existsSync(path.join(backup.manifest!.backupPath, ".env"))).toBe(false); expect(fs.existsSync(path.join(backup.manifest!.backupPath, ".mcp.json"))).toBe(false); + expect(fs.existsSync(path.join(backup.manifest!.backupPath, ".nemoclaw-mcp.json"))).toBe( + false, + ); const loggedCommands = fs.readFileSync(sshLog, "utf-8"); expect(loggedCommands).not.toContain(".env"); expect(loggedCommands).not.toContain(".mcp.json"); + expect(loggedCommands).not.toContain(".nemoclaw-mcp.json"); // #5753 is "lost after rebuild" (backup + recreate + restore): restore // must list agent/skills among the dirs it brings back into the sandbox. diff --git a/test/validate-config-schemas.test.ts b/test/validate-config-schemas.test.ts index 340e4cea2cd..91e2abd12c9 100644 --- a/test/validate-config-schemas.test.ts +++ b/test/validate-config-schemas.test.ts @@ -90,6 +90,159 @@ function expectValid(validate: ValidateFunction, data: object, label: string): v } } +function l7SchemaFixture(kind: "sandbox" | "preset", endpoint: Record): object { + const network_policies = { + test_service: { + name: "Test Service", + binaries: [{ path: "/usr/bin/node" }], + endpoints: [ + { + host: "api.example.com", + port: 443, + ...endpoint, + }, + ], + }, + }; + return kind === "sandbox" + ? { version: 1, network_policies } + : { preset: { name: "test", description: "test" }, network_policies }; +} + +function registerOpenShellJsonRpcMcpMatcherTests( + kind: "sandbox" | "preset", + validate: ValidateFunction, +): void { + it("matches the OpenShell MCP method-profile contract", () => { + const profiled = l7SchemaFixture(kind, { + protocol: "mcp", + mcp: { allow_all_known_mcp_methods: true }, + rules: [{ allow: { tool: "search" } }], + deny_rules: [{ params: { name: "admin" } }], + }); + expectValid(validate, profiled, `${kind} profiled MCP selectors`); + + const toolsFamilyGlob = l7SchemaFixture(kind, { + protocol: "mcp", + rules: [{ allow: { method: "tools/*" } }], + }); + expectValid(validate, toolsFamilyGlob, `${kind} MCP tools-family method glob`); + + const missingMethod = l7SchemaFixture(kind, { + protocol: "mcp", + mcp: { allow_all_known_mcp_methods: false }, + rules: [{ allow: { tool: "search" } }], + }); + expect(validate(missingMethod)).toBe(false); + }); + + it.each([ + ["a bare wildcard method", { method: "*" }], + ["a non-tools method glob", { method: "vendor/*" }], + ["a tools-family glob plus selector", { method: "tools/*", tool: "search" }], + [ + "both tool selector forms", + { method: "tools/call", tool: "search", params: { name: "search" } }, + ], + ])("rejects MCP rules with %s", (_label, allow) => { + const fixture = l7SchemaFixture(kind, { + protocol: "mcp", + mcp: { allow_all_known_mcp_methods: true }, + rules: [{ allow }], + }); + expect(validate(fixture)).toBe(false); + }); + + it("rejects wildcard tool selectors when strict tool names are disabled", () => { + const exact = l7SchemaFixture(kind, { + protocol: "mcp", + mcp: { strict_tool_names: false }, + rules: [{ allow: { method: "tools/call", tool: "search" } }], + }); + expectValid(validate, exact, `${kind} exact MCP tool selector`); + + for (const tool of ["search*", { any: ["search", "admin?"] }]) { + const wildcard = l7SchemaFixture(kind, { + protocol: "mcp", + mcp: { strict_tool_names: false }, + rules: [{ allow: { method: "tools/call", tool } }], + }); + expect(validate(wildcard)).toBe(false); + } + }); + + it("allows empty MCP matchers only under the allow-all method profile", () => { + const profiled = l7SchemaFixture(kind, { + protocol: "mcp", + mcp: { allow_all_known_mcp_methods: true }, + rules: [{ allow: {} }], + deny_rules: [{}], + }); + expectValid(validate, profiled, `${kind} empty profiled MCP matchers`); + + const unprofiled = l7SchemaFixture(kind, { + protocol: "mcp", + rules: [{ allow: {} }], + }); + expect(validate(unprofiled)).toBe(false); + + const unprofiledDeny = l7SchemaFixture(kind, { + protocol: "mcp", + rules: [{ allow: { method: "tools/list" } }], + deny_rules: [{}], + }); + expect(validate(unprofiledDeny)).toBe(false); + }); + + it.each([ + ["an exact tools/call allow", [{ allow: { method: "tools/call" } }], undefined], + ["a tools-family wildcard allow", [{ allow: { method: "tools/*" } }], undefined], + ["an exact tools/call deny", [], [{ method: "tools/call" }]], + ["a tools-family wildcard deny", [], [{ method: "tools/*" }]], + ])("rejects a tool-specific allow combined with %s", (_label, extraRules, denyRules) => { + const fixture = l7SchemaFixture(kind, { + protocol: "mcp", + rules: [{ allow: { method: "tools/call", tool: "search" } }, ...(extraRules ?? [])], + ...(denyRules === undefined ? {} : { deny_rules: denyRules }), + }); + expect(validate(fixture)).toBe(false); + }); + + it("keeps MCP-only options off non-MCP protocols while retaining the body-size alias", () => { + const bodySizeAlias = l7SchemaFixture(kind, { + protocol: "json-rpc", + mcp: { max_body_bytes: 131072 }, + rules: [{ allow: { method: "ping" } }], + }); + expectValid(validate, bodySizeAlias, `${kind} non-MCP body-size alias`); + + for (const option of ["strict_tool_names", "allow_all_known_mcp_methods"]) { + const invalid = l7SchemaFixture(kind, { + protocol: "json-rpc", + mcp: { max_body_bytes: 131072, [option]: true }, + rules: [{ allow: { method: "ping" } }], + }); + expect(validate(invalid)).toBe(false); + } + }); + + it("accepts only exact JSON-RPC methods or the sole wildcard sentinel", () => { + const wildcard = l7SchemaFixture(kind, { + protocol: "json-rpc", + rules: [{ allow: { method: "*" } }], + }); + expectValid(validate, wildcard, `${kind} JSON-RPC wildcard sentinel`); + + for (const method of ["reports.*", "reports?", "reports[0]", "reports{admin}"]) { + const glob = l7SchemaFixture(kind, { + protocol: "json-rpc", + rules: [{ allow: { method } }], + }); + expect(validate(glob)).toBe(false); + } + }); +} + // ── Validation target discovery ───────────────────────────────────────────── describe("config validation target discovery", () => { @@ -288,6 +441,7 @@ describe("router-pool-config.schema.json", () => { describe("sandbox-policy.schema.json", () => { const validate = compileSchema("schemas/sandbox-policy.schema.json"); + registerOpenShellJsonRpcMcpMatcherTests("sandbox", validate); const data = loadYAML(repoPath("nemoclaw-blueprint/policies/openclaw-sandbox.yaml")); it("openclaw-sandbox.yaml passes schema validation", () => { @@ -339,6 +493,31 @@ describe("sandbox-policy.schema.json", () => { expect(validate(bad)).toBe(false); }); + it.each([ + ["an empty allow object", {}], + ["an invalid method without a path", { method: "GTE" }], + ["an MCP-only tool matcher", { tool: "admin" }], + ])("rejects sandbox-policy REST rules with %s", (_label, allow) => { + const bad = { + version: 1, + network_policies: { + test_service: { + name: "Test Service", + binaries: [{ path: "/usr/bin/node" }], + endpoints: [ + { + host: "api.example.com", + port: 443, + protocol: "rest", + rules: [{ allow }], + }, + ], + }, + }, + }; + expect(validate(bad)).toBe(false); + }); + it("rejects sandbox-policy network entries without explicit binary scoping", () => { const bad = { version: 1, @@ -379,6 +558,54 @@ describe("sandbox-policy.schema.json", () => { expectValid(validate, valid, "websocket policy"); }); + it.each([ + ["rest", "*"], + ["websocket", "*"], + ])("accepts sandbox-policy %s wildcard methods", (protocol, method) => { + const valid = { + version: 1, + network_policies: { + test_service: { + name: "Test Service", + binaries: [{ path: "/usr/bin/node" }], + endpoints: [ + { + host: "api.example.com", + port: 443, + protocol, + rules: [{ allow: { method, path: "/**" } }], + }, + ], + }, + }, + }; + expectValid(validate, valid, `${protocol} wildcard policy`); + }); + + it.each([ + ["rest", "WEBSOCKET_TEXT"], + ["websocket", "POST"], + ])("rejects sandbox-policy %s rules with %s", (protocol, method) => { + const bad = { + version: 1, + network_policies: { + test_service: { + name: "Test Service", + binaries: [{ path: "/usr/bin/node" }], + endpoints: [ + { + host: "api.example.com", + port: 443, + protocol, + rules: [{ allow: { method, path: "/**" } }], + }, + ], + }, + }, + }; + expect(validate(bad)).toBe(false); + }); + it("accepts sandbox-policy request-body credential rewrite on REST endpoints", () => { const valid = { version: 1, @@ -413,14 +640,16 @@ describe("sandbox-policy.schema.json", () => { { host: "host.openshell.internal", port: 31337, + path: "/mcp", protocol: "json-rpc", enforcement: "enforce", json_rpc: { max_body_bytes: 131072 }, - rules: [{ allow: { method: "tools/list", path: "/mcp" } }], + rules: [{ allow: { method: "tools/list" } }], }, { host: "host.openshell.internal", port: 31337, + path: "/mcp", protocol: "mcp", enforcement: "enforce", mcp: { max_body_bytes: 131072, strict_tool_names: true }, @@ -428,13 +657,17 @@ describe("sandbox-policy.schema.json", () => { { allow: { method: "tools/call", - path: "/mcp", tool: { any: ["search", "read"] }, - params: { query: { any: ["safe", "readonly"] } }, + }, + }, + { + allow: { + method: "tools/call", + params: { name: { any: ["search", "read"] } }, }, }, ], - deny_rules: [{ tool: "admin" }], + deny_rules: [{ method: "tools/call", tool: "admin" }], }, ], }, @@ -443,6 +676,33 @@ describe("sandbox-policy.schema.json", () => { expectValid(validate, valid, "json-rpc and mcp policy"); }); + it("accepts sandbox-policy JSON-RPC and MCP endpoints without endpoint paths", () => { + const valid = { + version: 1, + network_policies: { + rpc: { + name: "RPC", + binaries: [{ path: "/usr/bin/node" }], + endpoints: [ + { + host: "rpc.example.com", + port: 443, + protocol: "json-rpc", + rules: [{ allow: { method: "ping" } }], + }, + { + host: "mcp.example.com", + port: 443, + protocol: "mcp", + rules: [{ allow: { method: "tools/list" } }], + }, + ], + }, + }, + }; + expectValid(validate, valid, "pathless JSON-RPC and MCP policy"); + }); + it("rejects sandbox-policy MCP endpoints without rules or explicit MCP allow-all", () => { const bad = { version: 1, @@ -454,6 +714,7 @@ describe("sandbox-policy.schema.json", () => { { host: "host.openshell.internal", port: 31337, + path: "/mcp", protocol: "mcp", mcp: { max_body_bytes: 131072 }, }, @@ -475,6 +736,7 @@ describe("sandbox-policy.schema.json", () => { { host: "host.openshell.internal", port: 31337, + path: "/mcp", protocol: "mcp", mcp: { max_body_bytes: 131072, allow_all_known_mcp_methods: true }, }, @@ -496,6 +758,7 @@ describe("sandbox-policy.schema.json", () => { { host: "mcp.example.com", port: 443, + path: "/rpc", protocol: "json-rpc", json_rpc: { max_body_bytes: 1048577 }, rules: [{ allow: { method: "initialize" } }], @@ -516,6 +779,7 @@ describe("sandbox-policy.schema.json", () => { { host: "mcp.example.com", port: 443, + path: "/mcp", protocol: "mcp", mcp: { max_body_bytes: 1048577, allow_all_known_mcp_methods: true }, }, @@ -587,6 +851,7 @@ describe("sandbox-policy.schema.json", () => { describe("policy-preset.schema.json", () => { const validate = compileSchema("schemas/policy-preset.schema.json"); + registerOpenShellJsonRpcMcpMatcherTests("preset", validate); const presetFiles = discoverTargets().find((target) => target.schema === "schemas/policy-preset.schema.json") ?.files ?? []; @@ -626,6 +891,31 @@ describe("policy-preset.schema.json", () => { expect(validate(bad)).toBe(false); }); + it.each([ + ["an empty allow object", {}], + ["an invalid method without a path", { method: "GTE" }], + ["an MCP-only tool matcher", { tool: "admin" }], + ])("rejects preset REST rules with %s", (_label, allow) => { + const bad = { + preset: { name: "test", description: "test" }, + network_policies: { + test_service: { + name: "Test Service", + binaries: [{ path: "/usr/bin/node" }], + endpoints: [ + { + host: "api.example.com", + port: 443, + protocol: "rest", + rules: [{ allow }], + }, + ], + }, + }, + }; + expect(validate(bad)).toBe(false); + }); + it("rejects preset network entries without explicit binary scoping", () => { const bad = { preset: { name: "test", description: "test" }, @@ -666,6 +956,54 @@ describe("policy-preset.schema.json", () => { expectValid(validate, valid, "websocket preset"); }); + it.each([ + ["rest", "*"], + ["websocket", "*"], + ])("accepts preset %s wildcard methods", (protocol, method) => { + const valid = { + preset: { name: "test", description: "test" }, + network_policies: { + test_service: { + name: "Test Service", + binaries: [{ path: "/usr/bin/node" }], + endpoints: [ + { + host: "api.example.com", + port: 443, + protocol, + rules: [{ allow: { method, path: "/**" } }], + }, + ], + }, + }, + }; + expectValid(validate, valid, `${protocol} wildcard preset`); + }); + + it.each([ + ["rest", "WEBSOCKET_TEXT"], + ["websocket", "POST"], + ])("rejects preset %s rules with %s", (protocol, method) => { + const bad = { + preset: { name: "test", description: "test" }, + network_policies: { + test_service: { + name: "Test Service", + binaries: [{ path: "/usr/bin/node" }], + endpoints: [ + { + host: "api.example.com", + port: 443, + protocol, + rules: [{ allow: { method, path: "/**" } }], + }, + ], + }, + }, + }; + expect(validate(bad)).toBe(false); + }); + it("accepts preset request-body credential rewrite on REST endpoints", () => { const valid = { preset: { name: "slack", description: "Slack" }, @@ -700,17 +1038,19 @@ describe("policy-preset.schema.json", () => { { host: "mcp.example.com", port: 443, + path: "/rpc", protocol: "json-rpc", json_rpc: { max_body_bytes: 131072 }, - rules: [{ allow: { method: "initialize", path: "/mcp" } }], + rules: [{ allow: { method: "initialize" } }], }, { host: "mcp.example.com", port: 443, + path: "/mcp", protocol: "mcp", mcp: { max_body_bytes: 131072, allow_all_known_mcp_methods: false }, - rules: [{ allow: { method: "tools/call", path: "/mcp", tool: "search" } }], - deny_rules: [{ params: { mode: "admin" } }], + rules: [{ allow: { method: "tools/call", tool: "search" } }], + deny_rules: [{ method: "tools/call", params: { name: "admin" } }], }, ], }, @@ -719,6 +1059,33 @@ describe("policy-preset.schema.json", () => { expectValid(validate, valid, "json-rpc and mcp preset"); }); + it("accepts preset JSON-RPC and MCP endpoints without endpoint paths", () => { + const valid = { + preset: { name: "rpc", description: "RPC" }, + network_policies: { + rpc: { + name: "RPC", + binaries: [{ path: "/usr/bin/node" }], + endpoints: [ + { + host: "rpc.example.com", + port: 443, + protocol: "json-rpc", + rules: [{ allow: { method: "ping" } }], + }, + { + host: "mcp.example.com", + port: 443, + protocol: "mcp", + rules: [{ allow: { method: "tools/list" } }], + }, + ], + }, + }, + }; + expectValid(validate, valid, "pathless JSON-RPC and MCP preset"); + }); + it("rejects preset MCP endpoints with missing rules, invalid options, or invalid matchers", () => { const base = { preset: { name: "mcp", description: "MCP" }, @@ -730,9 +1097,10 @@ describe("policy-preset.schema.json", () => { { host: "mcp.example.com", port: 443, + path: "/mcp", protocol: "mcp", mcp: { max_body_bytes: 131072 }, - rules: [{ allow: { method: "tools/list", path: "/mcp" } }], + rules: [{ allow: { method: "tools/list" } }], }, ], }, @@ -774,6 +1142,7 @@ describe("policy-preset.schema.json", () => { { host: "mcp.example.com", port: 443, + path: "/mcp", protocol: "mcp", mcp: { allow_all_known_mcp_methods: true }, }, @@ -835,6 +1204,7 @@ describe("policy-preset.schema.json", () => { { host: "mcp.example.com", port: 443, + path: "/rpc", protocol: "json-rpc", json_rpc: { max_body_bytes: 1048577 }, rules: [{ allow: { method: "initialize" } }], @@ -855,6 +1225,7 @@ describe("policy-preset.schema.json", () => { { host: "mcp.example.com", port: 443, + path: "/mcp", protocol: "mcp", mcp: { max_body_bytes: 1048577, allow_all_known_mcp_methods: true }, },