diff --git a/Dockerfile b/Dockerfile index a9cdc7c956f..19a148d5fbb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -595,12 +595,16 @@ COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ COPY --from=runtime-preload-builder /opt/nemoclaw-root/dist/lib/messaging/channels/ /usr/local/lib/nemoclaw/preloads-compiled-channels/ COPY scripts/codex-acp-wrapper.sh /usr/local/bin/nemoclaw-codex-acp COPY scripts/generate-openclaw-config.mts /scripts/generate-openclaw-config.mts +COPY scripts/validate-openclaw-tool-search.mts /scripts/validate-openclaw-tool-search.mts +COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts COPY src/lib/messaging/ /src/lib/messaging/ COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \ /usr/local/lib/nemoclaw/sandbox-init.sh \ /scripts/generate-openclaw-config.mts \ + /scripts/validate-openclaw-tool-search.mts \ /src/lib/messaging/applier/build/messaging-build-applier.mts \ + && chmod 444 /src/lib/tool-disclosure.ts \ && chmod -R a+rX /src/lib/messaging \ && chown root:root /usr/local/bin/nemoclaw-gateway-control \ /usr/local/lib/nemoclaw/gateway-supervisor.sh \ @@ -645,6 +649,7 @@ ARG NEMOCLAW_INFERENCE_API=openai-completions ARG NEMOCLAW_CONTEXT_WINDOW=131072 ARG NEMOCLAW_MAX_TOKENS=4096 ARG NEMOCLAW_REASONING=false +ARG NEMOCLAW_TOOL_DISCLOSURE=progressive # Comma-separated list of input modalities accepted by the primary model # (e.g. "text" or "text,image" for vision-capable models). OpenClaw's # model schema currently accepts "text" and "image". See #2421. @@ -714,6 +719,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_CONTEXT_WINDOW=${NEMOCLAW_CONTEXT_WINDOW} \ NEMOCLAW_MAX_TOKENS=${NEMOCLAW_MAX_TOKENS} \ NEMOCLAW_REASONING=${NEMOCLAW_REASONING} \ + NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE} \ NEMOCLAW_INFERENCE_INPUTS=${NEMOCLAW_INFERENCE_INPUTS} \ NEMOCLAW_AGENT_TIMEOUT=${NEMOCLAW_AGENT_TIMEOUT} \ NEMOCLAW_AGENT_HEARTBEAT_EVERY=${NEMOCLAW_AGENT_HEARTBEAT_EVERY} \ @@ -762,6 +768,31 @@ USER sandbox # block until after build-time OpenClaw doctor/plugin commands complete. RUN NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 node --experimental-strip-types /scripts/generate-openclaw-config.mts +# Validate the patched OpenClaw tool-search contract against real generated +# configs for both supported disclosure modes. This runs at image build time so +# OpenClaw dist drift or a generator/schema mismatch fails the build closed. +# hadolint ignore=DL3059 +RUN set -eu; \ + validation_root="$(mktemp -d /tmp/nemoclaw-openclaw-tool-search.XXXXXX)"; \ + trap 'rm -rf "$validation_root"' EXIT; \ + for mode in progressive direct; do \ + validation_home="$validation_root/$mode"; \ + mkdir -p "$validation_home"; \ + HOME="$validation_home" \ + NEMOCLAW_MODEL=test-model \ + NEMOCLAW_PRIMARY_MODEL_REF=inference/test-model \ + NEMOCLAW_TOOL_DISCLOSURE="$mode" \ + NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 \ + node --experimental-strip-types /scripts/generate-openclaw-config.mts; \ + node --experimental-strip-types /scripts/validate-openclaw-tool-search.mts \ + /usr/local/lib/node_modules/openclaw/dist \ + "$validation_home/.openclaw/openclaw.json" \ + "$mode" \ + "$OPENCLAW_VERSION"; \ + done; \ + rm -rf "$validation_root"; \ + trap - EXIT + # Install non-messaging OpenClaw plugins that need to match the runtime. # hadolint ignore=DL3059,DL4006 RUN set -eu; \ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 7374d6cc701..befea5892fe 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -104,9 +104,11 @@ RUN chmod -R a+rX /opt/nemoclaw-hermes-plugin/ COPY agents/hermes/generate-config.ts /opt/nemoclaw-hermes-config/generate-config.ts COPY agents/hermes/config/ /opt/nemoclaw-hermes-config/config/ COPY agents/hermes/host/managed-tool-gateway-matrix.json /opt/nemoclaw-hermes-config/managed-tool-gateway-matrix.json +COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts COPY src/lib/messaging/ /src/lib/messaging/ RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \ && find /opt/nemoclaw-hermes-config -type f -exec chmod 444 {} + \ + && chmod 444 /src/lib/tool-disclosure.ts \ && chmod -R a+rX /src/lib/messaging # Copy blueprint (shared infrastructure) @@ -219,6 +221,7 @@ ARG NEMOCLAW_PROVIDER_KEY=custom ARG NEMOCLAW_UPSTREAM_PROVIDER=custom ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1 ARG NEMOCLAW_INFERENCE_API=openai-completions +ARG NEMOCLAW_TOOL_DISCLOSURE=progressive # CHAT_UI_URL is a legacy name shared with the OpenClaw build arg. For # Hermes this URL points at the browser dashboard. The OpenAI-compatible # API remains exposed separately on port 8642. @@ -237,6 +240,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_UPSTREAM_PROVIDER=${NEMOCLAW_UPSTREAM_PROVIDER} \ NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \ NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \ + NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE} \ CHAT_UI_URL=${CHAT_UI_URL} \ NEMOCLAW_MESSAGING_PLAN_B64=${NEMOCLAW_MESSAGING_PLAN_B64} \ NEMOCLAW_WEB_SEARCH_ENABLED=${NEMOCLAW_WEB_SEARCH_ENABLED} \ diff --git a/agents/hermes/config/build-env.ts b/agents/hermes/config/build-env.ts index dfb140134b4..088a0012548 100644 --- a/agents/hermes/config/build-env.ts +++ b/agents/hermes/config/build-env.ts @@ -4,6 +4,7 @@ import { Buffer } from "node:buffer"; import { normalizeProviderPlaceholderForEnvKey } from "../../../src/lib/messaging/provider-placeholders.ts"; +import { readToolDisclosureEnv } from "../../../src/lib/tool-disclosure.ts"; export type HermesWebSearchProvider = "tavily"; @@ -13,6 +14,7 @@ export type HermesBuildSettings = { providerKey: string; upstreamProvider: string; inferenceApi: string; + toolDisclosure: "progressive" | "direct"; webSearchProvider: HermesWebSearchProvider | null; messagingCredentialPlaceholders: Array<{ envKey: string; @@ -34,6 +36,7 @@ export function readHermesBuildSettings(env: NodeJS.ProcessEnv): HermesBuildSett providerKey: env.NEMOCLAW_PROVIDER_KEY || "custom", upstreamProvider: env.NEMOCLAW_UPSTREAM_PROVIDER || env.NEMOCLAW_PROVIDER_KEY || "custom", inferenceApi: env.NEMOCLAW_INFERENCE_API || "", + toolDisclosure: readToolDisclosureEnv(env), webSearchProvider: readWebSearchProvider(env), messagingCredentialPlaceholders: readMessagingCredentialPlaceholders(env), managedToolGateways: { diff --git a/agents/hermes/config/hermes-config.ts b/agents/hermes/config/hermes-config.ts index 66e8b7cd87c..8eaea91d146 100644 --- a/agents/hermes/config/hermes-config.ts +++ b/agents/hermes/config/hermes-config.ts @@ -102,6 +102,17 @@ export function buildHermesConfig(settings: HermesBuildSettings): Record&2; exit 1 ;; \ + esac + # The launcher and startup script read these root-owned files instead of # trusting process-level environment overrides for inference routing. Invoking # each launcher validates the build args before the image can complete. @@ -67,6 +79,7 @@ ENV HOME=/sandbox \ NEMOCLAW_UPSTREAM_PROVIDER=${NEMOCLAW_UPSTREAM_PROVIDER} \ NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \ NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \ + NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE} \ NEMOCLAW_BUILD_ID=${NEMOCLAW_BUILD_ID} \ DEEPAGENTS_CODE_NO_UPDATE_CHECK=1 \ LANGGRAPH_NO_VERSION_CHECK=true \ diff --git a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py index e03f80f84fe..8683abc8942 100644 --- a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py +++ b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py @@ -24,6 +24,8 @@ EXPECTED_DCODE_VERSION = "0.1.30" PATCH_MARKER = "NemoClaw-managed Deep Agents Code hardening v2." +TOOL_DISCLOSURE_PATCH_MARKER = "NemoClaw-managed progressive tool disclosure." +MIDDLEWARE_MODULE = "progressive_tool_disclosure.py" MANAGED_RUNTIME_SOURCE_PATH = Path(__file__).with_name("managed-dcode-runtime.py") MAIN_MARKER = " args = parser.parse_args()\n" @@ -396,20 +398,90 @@ def _nemoclaw_get_class_path(self, provider_name: str): ModelConfig.get_class_path = _nemoclaw_get_class_path ''' +# Source-of-truth boundary: pinned upstream deepagents-code==0.1.30 has no +# supported managed progressive-disclosure middleware hook in its agent factory +# API, and this repository cannot change that third-party package source. +# Patcher/unit guards plus validate-progressive-tool-disclosure.py cover this +# fail-closed integration. Remove it once upstream provides a supported hook +# preserving managed MCP, credentials, approvals, executor, sandbox, and private +# checkpoint-state boundaries. AGENT_PATCH = r''' # NemoClaw-managed Deep Agents Code hardening v2. +# NemoClaw-managed progressive tool disclosure. +from contextvars import ContextVar as _NemoClawContextVar + _nemoclaw_original_create_cli_agent = create_cli_agent +_nemoclaw_original_create_deep_agent = globals().get("create_deep_agent") +_nemoclaw_progressive_disclosure_active = _NemoClawContextVar( + "nemoclaw_progressive_disclosure_active", default=False +) + + +def _nemoclaw_create_deep_agent(*args, **kwargs): + """Install distinct disclosure middleware in the main and local subagent graphs.""" + if _nemoclaw_original_create_deep_agent is None: + raise RuntimeError("Deep Agents Code create_deep_agent boundary is unavailable") + if not _nemoclaw_progressive_disclosure_active.get(): + return _nemoclaw_original_create_deep_agent(*args, **kwargs) + from deepagents_code.progressive_tool_disclosure import ( + ProgressiveToolDisclosureMiddleware, + ) + + middleware = list(kwargs.get("middleware") or ()) + middleware.append(ProgressiveToolDisclosureMiddleware()) + kwargs["middleware"] = middleware + + subagents = kwargs.get("subagents") + if subagents: + patched_subagents = [] + for subagent in subagents: + if isinstance(subagent, dict): + subagent_middleware = list(subagent.get("middleware") or ()) + subagent_middleware.append(ProgressiveToolDisclosureMiddleware()) + subagent = {**subagent, "middleware": subagent_middleware} + patched_subagents.append(subagent) + kwargs["subagents"] = patched_subagents + + return _nemoclaw_original_create_deep_agent(*args, **kwargs) + + +if _nemoclaw_original_create_deep_agent is not None: + create_deep_agent = _nemoclaw_create_deep_agent def create_cli_agent(model, assistant_id, *args, **kwargs): - """Keep secondary model and remote-agent paths on the managed graph.""" + """Keep managed graph posture and progressively disclose loaded MCP tools.""" kwargs["rubric_model"] = None kwargs["async_subagents"] = None - return _nemoclaw_original_create_cli_agent( - model, assistant_id, *args, **kwargs + from deepagents_code.progressive_tool_disclosure import ( + assert_unique_callable_tool_names, ) + assert_unique_callable_tool_names( + kwargs.get("tools"), kwargs.get("mcp_server_info") + ) + has_loaded_mcp_tools = any( + getattr(info, "tools", ()) for info in kwargs.get("mcp_server_info") or () + ) + if has_loaded_mcp_tools: + from deepagents_code.progressive_tool_disclosure import ( + progressive_tool_disclosure_enabled, + ) + + progressive_active = progressive_tool_disclosure_enabled() + else: + progressive_active = False + if progressive_active and _nemoclaw_original_create_deep_agent is None: + raise RuntimeError("Deep Agents Code create_deep_agent boundary is unavailable") + token = _nemoclaw_progressive_disclosure_active.set(progressive_active) + try: + return _nemoclaw_original_create_cli_agent( + model, assistant_id, *args, **kwargs + ) + finally: + _nemoclaw_progressive_disclosure_active.reset(token) + def _resolve_ptc_option(*args, **kwargs): """Disable interpreter programmatic tool calling at the final build boundary.""" @@ -891,6 +963,24 @@ def main() -> None: } texts = {name: path.read_text(encoding="utf-8") for name, path in paths.items()} + module_source_path = Path(__file__).with_name(MIDDLEWARE_MODULE) + module_destination_path = root / MIDDLEWARE_MODULE + if not module_source_path.is_file(): + raise RuntimeError( + f"NemoClaw middleware source not found at {module_source_path}" + ) + module_source = module_source_path.read_text(encoding="utf-8") + compile(module_source, str(module_destination_path), "exec") + if module_destination_path.exists() or module_destination_path.is_symlink(): + if ( + not module_destination_path.is_file() + or module_destination_path.is_symlink() + or module_destination_path.read_text(encoding="utf-8") != module_source + ): + raise RuntimeError( + f"Refusing to overwrite unexpected middleware at {module_destination_path}" + ) + marker_states = {PATCH_MARKER in text for text in texts.values()} helper_path = root / "_nemoclaw_managed.py" if marker_states == {True}: @@ -898,9 +988,20 @@ def main() -> None: encoding="utf-8" ): raise RuntimeError("Managed package patch is partial: helper is missing") + if not module_destination_path.is_file(): + raise RuntimeError("Managed package patch is partial: middleware is missing") + if texts["agent"].count(AGENT_PATCH.lstrip()) != 1: + raise RuntimeError( + f"Managed package progressive-disclosure patch is incomplete in {paths['agent']}" + ) return if marker_states != {False} or helper_path.exists(): raise RuntimeError("Managed package patch is partial; refusing mixed source state") + if TOOL_DISCLOSURE_PATCH_MARKER in texts["agent"]: + raise RuntimeError( + "Managed package progressive-disclosure patch is partial; " + "refusing mixed source state" + ) _require_functions(paths["main"], texts["main"], {"parse_args"}) _require_methods( @@ -1124,6 +1225,8 @@ def main() -> None: for name, text in transformed.items(): paths[name].write_text(text, encoding="utf-8") helper_path.write_text(managed_runtime_source, encoding="utf-8") + if not module_destination_path.exists(): + module_destination_path.write_text(module_source, encoding="utf-8") if __name__ == "__main__": diff --git a/agents/langchain-deepagents-code/progressive_tool_disclosure.py b/agents/langchain-deepagents-code/progressive_tool_disclosure.py new file mode 100644 index 00000000000..6036b46ffce --- /dev/null +++ b/agents/langchain-deepagents-code/progressive_tool_disclosure.py @@ -0,0 +1,623 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Progressively disclose Deep Agents tools without changing execution policy. + +This middleware is a model-context optimization, not an authorization boundary. +It filters the tools bound to each model request while leaving LangGraph's full +executor registry intact. A model-generated call that guesses a hidden tool name +can therefore still reach that tool; existing tool-call middleware, approval, +credential, and sandbox controls remain responsible for governing execution. +Named discovery, checkpoint state, and model-visible schemas are deterministically +bounded. Opaque provider-native definitions without a callable name remain +visible by identity because they cannot be safely checkpointed or rediscovered. +""" + +from collections.abc import Awaitable, Callable, Sequence +import json +import os +from typing import Annotated, Any, NotRequired, cast + +from langchain.agents.middleware.types import ( + AgentMiddleware, + AgentState, + ContextT, + ModelRequest, + ModelResponse, + PrivateStateAttr, + ResponseT, +) +from langchain.tools import ToolRuntime +from langchain_core.messages import AIMessage, ToolMessage +from langchain_core.tools import BaseTool, StructuredTool +from langchain_core.utils.function_calling import convert_to_openai_tool +from langgraph.types import Command +from pydantic import BaseModel, Field + +MAX_SEARCH_QUERY_LENGTH = 256 +"""Maximum model-supplied search query length accepted by ``search_tools``.""" + +MAX_SEARCH_RESULTS = 20 +"""Maximum deterministic catalog matches exposed by one ``search_tools`` call.""" + +MAX_SEARCH_DESCRIPTION_CHARS = 256 +"""Maximum normalized description characters rendered for one search result.""" + +MAX_SEARCH_OUTPUT_BYTES = 8 * 1024 +"""Maximum UTF-8 bytes returned in one ``search_tools`` ToolMessage.""" + +MAX_DISCOVERED_TOOLS = 64 +"""Maximum named tools retained and exposed from one graph thread's state.""" + +MAX_DISCOVERED_TOOL_NAME_BYTES = 120 +"""Maximum UTF-8 and stable-JSON bytes in one discovered tool name.""" + +MAX_DISCOVERED_STATE_BYTES = 8 * 1024 +"""Maximum stable JSON bytes in the checkpointed discovered-name list.""" + +MAX_SINGLE_TOOL_SCHEMA_BYTES = 16 * 1024 +"""Maximum canonical JSON bytes accepted for one named model tool schema.""" + +MAX_VISIBLE_DISCOVERED_SCHEMA_BYTES = 128 * 1024 +"""Maximum canonical JSON bytes across discovered schemas in one model request.""" + +CORE_TOOL_NAMES = frozenset( + { + "search_tools", + "ls", + "read_file", + "write_file", + "edit_file", + "glob", + "grep", + "execute", + "ask_user", + "write_todos", + } +) +"""Tools that remain visible before any progressive discovery.""" + +SEARCH_TOOLS_DESCRIPTION = f"""Search hidden tools by a case-insensitive keyword. + +Use this when the visible tools do not provide a capability you need. The query +is matched against registered tool names and descriptions. Each call returns at +most {MAX_SEARCH_RESULTS} name-sorted matches, renders at most +{MAX_SEARCH_DESCRIPTION_CHARS} description characters per match and +{MAX_SEARCH_OUTPUT_BYTES} UTF-8 output bytes, and retains at most +{MAX_DISCOVERED_TOOLS} discovered named tools within a +{MAX_DISCOVERED_STATE_BYTES}-byte checkpoint budget. Names whose UTF-8 or +stable-JSON representation exceeds {MAX_DISCOVERED_TOOL_NAME_BYTES} bytes and +named schemas above +{MAX_SINGLE_TOOL_SCHEMA_BYTES} canonical JSON bytes are ineligible; each model +request exposes at most {MAX_VISIBLE_DISCOVERED_SCHEMA_BYTES} canonical JSON bytes of +discovered schemas; core tools remain unconditional. Refine broad queries to +reach omitted matches. An empty query discovers nothing; use a specific keyword +such as "database" or "calendar". +""" + + +def progressive_tool_disclosure_enabled() -> bool: + """Return the image-selected disclosure policy, rejecting invalid modes.""" + mode = os.environ.get("NEMOCLAW_TOOL_DISCLOSURE", "progressive").strip().casefold() + if mode not in {"progressive", "direct"}: + raise RuntimeError("NEMOCLAW_TOOL_DISCLOSURE must be 'progressive' or 'direct'") + return mode == "progressive" + + +def _merge_discovered_tools( + current: list[str] | None, + update: list[str] | None, +) -> list[str]: + """Merge concurrent updates with an order-independent deterministic cap.""" + values: list[object] = [] + if isinstance(current, list): + values.extend(current) + if isinstance(update, list): + values.extend(update) + return _bounded_discovered_tools(values) + + +def _eligible_discovered_name(value: object) -> bool: + """Return whether a name has a bounded checkpoint representation.""" + if not isinstance(value, str) or not value: + return False + try: + utf8_bytes = len(value.encode("utf-8")) + stable_json_bytes = len(json.dumps(value, ensure_ascii=False).encode("utf-8")) + except UnicodeEncodeError: + return False + return ( + utf8_bytes <= MAX_DISCOVERED_TOOL_NAME_BYTES + and stable_json_bytes <= MAX_DISCOVERED_TOOL_NAME_BYTES + ) + + +def _discovered_state_bytes(names: Sequence[str]) -> int: + """Return stable UTF-8 JSON bytes for the checkpointed name list.""" + return len( + json.dumps( + list(names), + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + ) + + +def _bounded_discovered_tools(values: Sequence[object] | None) -> list[str]: + """Normalize checkpointed names and enforce count and byte caps.""" + if values is None or isinstance(values, (str, bytes)): + return [] + # Selecting the lexical top-K eligible names is associative, commutative, + # and idempotent under reducer regrouping. The per-name stable-JSON cap + # makes the aggregate 64-name representation strictly smaller than the + # independent MAX_DISCOVERED_STATE_BYTES defense-in-depth assertion. + bounded = sorted({value for value in values if _eligible_discovered_name(value)})[ + :MAX_DISCOVERED_TOOLS + ] + if _discovered_state_bytes(bounded) > MAX_DISCOVERED_STATE_BYTES: + raise AssertionError("discovered tool state exceeded its invariant") + return bounded + + +def _bounded_description(description: str) -> str: + """Render one untrusted catalog description as a bounded single line.""" + normalized = ( + " ".join(description.split()).encode("utf-8", errors="replace").decode("utf-8") + ) + if not normalized: + return "No description provided." + if len(normalized) <= MAX_SEARCH_DESCRIPTION_CHARS: + return normalized + return f"{normalized[: MAX_SEARCH_DESCRIPTION_CHARS - 1]}…" + + +def _bounded_search_output(content: str) -> str: + """Truncate search output on a valid UTF-8 boundary with an explicit notice.""" + encoded = content.encode("utf-8") + if len(encoded) <= MAX_SEARCH_OUTPUT_BYTES: + return content + notice = ( + f"\n[Search output truncated at {MAX_SEARCH_OUTPUT_BYTES} UTF-8 bytes; " + "refine your query.]" + ) + budget = MAX_SEARCH_OUTPUT_BYTES - len(notice.encode("utf-8")) + prefix = encoded[:budget].decode("utf-8", errors="ignore").rstrip() + return f"{prefix}{notice}" + + +def _serialized_tool_schema_bytes(tool: BaseTool | dict[str, Any]) -> int | None: + """Return stable model-schema bytes, or ``None`` for an unsafe shape.""" + try: + schema = convert_to_openai_tool(tool) + serialized = json.dumps( + schema, + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + except Exception: # noqa: BLE001 - malformed provider schemas fail closed + return None + return len(serialized.encode("utf-8")) + + +class ProgressiveToolDisclosureState(AgentState): + """Private checkpoint state for tools discovered in one graph thread. + + ``PrivateStateAttr`` keeps discoveries out of parent/subagent input and + output while the checkpointer retains them for the owning graph thread. + """ + + # LangGraph 1.2.6 recognizes a reducer only when it is the final Annotated + # metadata value. Keep PrivateStateAttr before the reducer so concurrent + # search_tools calls merge instead of producing a LastValue conflict. + discovered_tools: NotRequired[ + Annotated[list[str], PrivateStateAttr, _merge_discovered_tools] + ] + + +class SearchToolsInput(BaseModel): + """Input contract for the ``search_tools`` model tool.""" + + query: str = Field( + max_length=MAX_SEARCH_QUERY_LENGTH, + description="Keyword to match against tool names and descriptions.", + ) + + +class _ToolCatalogEntry: + """Immutable searchable metadata for one registered model tool.""" + + __slots__ = ("description", "name", "tool") + + def __init__( + self, + name: str, + description: str, + tool: BaseTool | dict[str, Any], + ) -> None: + self.name = name + self.description = description + self.tool = tool + + +def _tool_name(tool: object) -> str | None: + """Return a registered tool name without changing the tool object.""" + if isinstance(tool, BaseTool): + return tool.name if isinstance(tool.name, str) and tool.name else None + if isinstance(tool, dict): + name = tool.get("name") + if isinstance(name, str) and name: + return name + function = tool.get("function") + if ( + isinstance(function, dict) + and isinstance(function.get("name"), str) + and function["name"] + ): + return cast("str", function["name"]) + name = getattr(tool, "name", None) + if isinstance(name, str) and name: + return name + callable_name = getattr(tool, "__name__", None) + if isinstance(callable_name, str) and callable_name: + return callable_name + return None + + +def _tool_description(tool: BaseTool | dict[str, Any]) -> str: + """Return searchable descriptive text for a registered tool.""" + if isinstance(tool, BaseTool): + return tool.description or "" + description = tool.get("description") + if isinstance(description, str): + return description + function = tool.get("function") + if isinstance(function, dict) and isinstance(function.get("description"), str): + return cast("str", function["description"]) + return "" + + +def assert_unique_callable_tool_names( + tools: Sequence[object] | None, + mcp_server_info: Sequence[object] | None, +) -> None: + """Reject ambiguous or non-managed registrations before graph creation. + + The pinned runtime combines middleware and regular tools into one executor + registry keyed by resolved callable name. Its model schema selection and + executor lookup do not share the same duplicate-name rule, so accepting two + implementations can bind one schema and execute another. Keep the executor + registry and MCP metadata as separate views: one loaded MCP tool normally + appears once in each, while duplicates within either view are ambiguous. + """ + collisions: set[str] = set() + registered_owners: dict[str, list[str]] = {} + for index, tool in enumerate(tools or ()): + name = _tool_name(tool) + if name is None: + continue + owner = f"registered tool[{index}]" + registered_owners.setdefault(name, []).append(owner) + if name in CORE_TOOL_NAMES: + collisions.add(f"{owner} is a non-managed owner of reserved name {name!r}") + + mcp_owners: dict[str, list[str]] = {} + for server in mcp_server_info or (): + raw_server_name = getattr(server, "name", None) + server_name = raw_server_name if isinstance(raw_server_name, str) else "" + for index, tool_info in enumerate(getattr(server, "tools", ()) or ()): + runtime_name = tool_info if isinstance(tool_info, str) else _tool_name(tool_info) + if runtime_name is None: + continue + owner = f"MCP server {server_name!r} tool[{index}]" + mcp_owners.setdefault(runtime_name, []).append(owner) + if runtime_name in CORE_TOOL_NAMES: + collisions.add( + f"{owner} is a non-managed owner of reserved name {runtime_name!r}" + ) + + for name, owners in registered_owners.items(): + if len(owners) > 1: + metadata = mcp_owners.get(name, []) + metadata_detail = ( + f"; MCP metadata owners: {', '.join(metadata)}" if metadata else "" + ) + collisions.add( + f"resolved callable name {name!r} has multiple registered " + f"implementations ({', '.join(owners)}){metadata_detail}" + ) + + for name, owners in mcp_owners.items(): + if len(owners) > 1: + collisions.add( + f"resolved callable name {name!r} has multiple MCP owners " + f"({', '.join(owners)})" + ) + + if collisions: + detail = "; ".join(sorted(collisions)) + raise RuntimeError( + "non-unique callable tool namespace before create_deep_agent: " + detail + ) + + +class ProgressiveToolDisclosureMiddleware( + AgentMiddleware[ProgressiveToolDisclosureState, ContextT, ResponseT] +): + """Expose a core tool set, then reveal matching tools for one thread. + + The full tool registry remains registered with the executor. Only the tools + sent to each model request are filtered. Consequently, a model call that + guesses a hidden tool name can still execute it through the normal executor; + existing policy, approval, credential, and sandbox controls continue to + govern every execution. Progressive disclosure must not be treated as an + authorization boundary. + """ + + state_schema = ProgressiveToolDisclosureState + + def __init__(self) -> None: + """Create an isolated disclosure middleware instance.""" + super().__init__() + + # Keep these annotations concrete (this module intentionally does not + # enable postponed annotations). StructuredTool uses inspect.signature + # to retain injected ToolRuntime arguments after validating the public + # SearchToolsInput schema. + def search_tools( + query: str, + runtime: ToolRuntime[ContextT, ProgressiveToolDisclosureState], + ) -> Command[Any]: + return self._search_tools(query, runtime) + + async def asearch_tools( + query: str, + runtime: ToolRuntime[ContextT, ProgressiveToolDisclosureState], + ) -> Command[Any]: + return self._search_tools(query, runtime) + + self.tools = [ + StructuredTool.from_function( + name="search_tools", + description=SEARCH_TOOLS_DESCRIPTION, + func=search_tools, + coroutine=asearch_tools, + args_schema=SearchToolsInput, + infer_schema=False, + ) + ] + + @staticmethod + def _catalog_entries( + tools: Sequence[BaseTool | dict[str, Any]], + ) -> tuple[_ToolCatalogEntry, ...]: + """Build searchable metadata from the full executor registry.""" + entries: dict[str, _ToolCatalogEntry] = {} + for tool in tools: + name = _tool_name(tool) + if name is None or name in CORE_TOOL_NAMES: + continue + if not _eligible_discovered_name(name): + continue + entries.setdefault( + name, + _ToolCatalogEntry(name, _tool_description(tool), tool), + ) + return tuple(sorted(entries.values(), key=lambda entry: entry.name)) + + def _matching_hidden_tools( + self, + query: str, + tools: Sequence[BaseTool | dict[str, Any]], + ) -> list[_ToolCatalogEntry]: + """Return hidden tools whose name or description contains ``query``.""" + normalized = query.strip().casefold() + if not normalized: + return [] + matches: list[_ToolCatalogEntry] = [] + for entry in self._catalog_entries(tools): + if not ( + normalized in entry.name.casefold() + or normalized in entry.description.casefold() + ): + continue + schema_bytes = _serialized_tool_schema_bytes(entry.tool) + if ( + schema_bytes is not None + and schema_bytes <= MAX_SINGLE_TOOL_SCHEMA_BYTES + ): + matches.append(entry) + return matches + + @staticmethod + def _visible_discovered_tools( + tools: Sequence[BaseTool | dict[str, Any]], + discovered_names: Sequence[str], + ) -> tuple[set[int], set[str]]: + """Select discovered schemas under deterministic per-tool/total budgets.""" + requested = set(discovered_names) + candidates: dict[str, tuple[int, BaseTool | dict[str, Any]]] = {} + for index, tool in enumerate(tools): + name = _tool_name(tool) + if name is not None and name not in CORE_TOOL_NAMES and name in requested: + candidates.setdefault(name, (index, tool)) + + selected_indices: set[int] = set() + selected_names: set[str] = set() + visible_schema_bytes = 0 + for name, (index, tool) in sorted(candidates.items()): + schema_bytes = _serialized_tool_schema_bytes(tool) + if ( + schema_bytes is None + or schema_bytes > MAX_SINGLE_TOOL_SCHEMA_BYTES + or visible_schema_bytes + schema_bytes + > MAX_VISIBLE_DISCOVERED_SCHEMA_BYTES + ): + continue + selected_indices.add(index) + selected_names.add(name) + visible_schema_bytes += schema_bytes + return selected_indices, selected_names + + def _search_tools( + self, + query: str, + runtime: ToolRuntime[ContextT, ProgressiveToolDisclosureState], + ) -> Command[Any]: + """Search for hidden tools and persist matches in graph state.""" + matches = self._matching_hidden_tools(query, runtime.tools) + page = matches[:MAX_SEARCH_RESULTS] + current_names = _bounded_discovered_tools(runtime.state.get("discovered_tools")) + current = set(current_names) + candidate_state = current_names + _, visible_discovered = self._visible_discovered_tools( + runtime.tools, current_names + ) + state_omitted_names: set[str] = set() + schema_omitted_names: set[str] = set() + for entry in page: + if entry.name in candidate_state: + continue + proposed_state = _bounded_discovered_tools([*candidate_state, entry.name]) + if entry.name not in proposed_state or not set(candidate_state).issubset( + proposed_state + ): + state_omitted_names.add(entry.name) + continue + _, proposed_visible = self._visible_discovered_tools( + runtime.tools, proposed_state + ) + if entry.name not in proposed_visible or not visible_discovered.issubset( + proposed_visible + ): + schema_omitted_names.add(entry.name) + continue + candidate_state = proposed_state + visible_discovered = proposed_visible + schema_omitted_names.update( + entry.name + for entry in page + if entry.name in candidate_state and entry.name not in visible_discovered + ) + exposed_entries = [ + entry + for entry in page + if entry.name in candidate_state and entry.name in visible_discovered + ] + matched_names = sorted({entry.name for entry in exposed_entries}) + newly_discovered = [name for name in matched_names if name not in current] + + if matches: + lines = [ + f"Found {len(matches)} matching hidden tool(s); returning " + f"{len(exposed_entries)} bounded discovery candidate(s) " + f"(per-search limit {MAX_SEARCH_RESULTS}):" + ] + lines.extend( + f"- {entry.name}: {_bounded_description(entry.description)}" + for entry in exposed_entries + ) + if exposed_entries and not newly_discovered: + lines.append( + "All returned matching tools were already available in this thread." + ) + if newly_discovered: + lines.append( + "Discovery updates commit through bounded thread state; after " + "concurrent searches, the next model tool list is authoritative." + ) + page_overflow = len(matches) - len(page) + if page_overflow: + lines.append( + f"{page_overflow} additional match(es) were not shown; " + "refine the query to discover them." + ) + state_omitted = len(state_omitted_names) + if state_omitted: + lines.append( + f"{state_omitted} match(es) were not exposed because the " + f"thread discovery state is limited to {MAX_DISCOVERED_TOOLS} " + f"names and {MAX_DISCOVERED_STATE_BYTES} JSON bytes." + ) + schema_omitted = len(schema_omitted_names) + if schema_omitted: + lines.append( + f"{schema_omitted} match(es) were not exposed because discovered " + f"schemas are limited to {MAX_SINGLE_TOOL_SCHEMA_BYTES} bytes each " + f"and {MAX_VISIBLE_DISCOVERED_SCHEMA_BYTES} bytes per model request." + ) + content = _bounded_search_output("\n".join(lines)) + else: + content = ( + f"No hidden tools matched {query.strip()!r}. " + "Try a different capability keyword." + ) + + update: dict[str, Any] = { + "messages": [ToolMessage(content, tool_call_id=runtime.tool_call_id)] + } + if matched_names: + update["discovered_tools"] = matched_names + return Command(update=update) + + def _prepare_request( + self, + request: ModelRequest[ContextT], + ) -> ModelRequest[ContextT]: + """Filter model-visible tools using checkpointed discovery state.""" + discovered = set( + _bounded_discovered_tools(request.state.get("discovered_tools")) + ) + selected_indices, _ = self._visible_discovered_tools( + request.tools, sorted(discovered) + ) + + visible: list[BaseTool | dict[str, Any]] = [] + for index, tool in enumerate(request.tools): + name = _tool_name(tool) + # Opaque provider-native definitions have no stable callable name, + # cannot be checkpointed/search-discovered, and may be transformed + # by the provider after LangChain binding. Preserve their identity + # by default; the named-schema byte budget intentionally cannot + # account for these provider-owned representations. + if name is None or name in CORE_TOOL_NAMES or index in selected_indices: + visible.append(tool) + return request.override(tools=visible) + + def wrap_model_call( + self, + request: ModelRequest[ContextT], + handler: Callable[[ModelRequest[ContextT]], ModelResponse[ResponseT]], + ) -> ModelResponse[ResponseT] | AIMessage: + """Filter tools for a synchronous model request.""" + return handler(self._prepare_request(request)) + + async def awrap_model_call( + self, + request: ModelRequest[ContextT], + handler: Callable[ + [ModelRequest[ContextT]], + Awaitable[ModelResponse[ResponseT]], + ], + ) -> ModelResponse[ResponseT] | AIMessage: + """Filter tools for an asynchronous model request.""" + return await handler(self._prepare_request(request)) + + +__all__ = [ + "CORE_TOOL_NAMES", + "MAX_DISCOVERED_STATE_BYTES", + "MAX_DISCOVERED_TOOL_NAME_BYTES", + "MAX_DISCOVERED_TOOLS", + "MAX_SEARCH_DESCRIPTION_CHARS", + "MAX_SEARCH_OUTPUT_BYTES", + "MAX_SEARCH_QUERY_LENGTH", + "MAX_SEARCH_RESULTS", + "MAX_SINGLE_TOOL_SCHEMA_BYTES", + "MAX_VISIBLE_DISCOVERED_SCHEMA_BYTES", + "ProgressiveToolDisclosureMiddleware", + "ProgressiveToolDisclosureState", + "SearchToolsInput", + "assert_unique_callable_tool_names", + "progressive_tool_disclosure_enabled", +] diff --git a/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py b/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py new file mode 100644 index 00000000000..1c5215c1329 --- /dev/null +++ b/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py @@ -0,0 +1,1057 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Validate progressive disclosure against the exact image-pinned runtime.""" + +from __future__ import annotations + +import asyncio +import importlib.metadata +import os +import tempfile +from collections.abc import Callable, Iterator, Sequence +from pathlib import Path +from typing import Any + +from deepagents_code import agent as agent_module +from deepagents_code import progressive_tool_disclosure as disclosure +from deepagents_code.agent import create_cli_agent +from deepagents_code.mcp_tools import MCPServerInfo, MCPToolInfo +from deepagents_code.progressive_tool_disclosure import ( + MAX_DISCOVERED_STATE_BYTES, + MAX_DISCOVERED_TOOL_NAME_BYTES, + MAX_DISCOVERED_TOOLS, + MAX_SEARCH_DESCRIPTION_CHARS, + MAX_SEARCH_OUTPUT_BYTES, + MAX_SEARCH_QUERY_LENGTH, + MAX_SEARCH_RESULTS, + MAX_SINGLE_TOOL_SCHEMA_BYTES, + MAX_VISIBLE_DISCOVERED_SCHEMA_BYTES, + ProgressiveToolDisclosureMiddleware, + SearchToolsInput, + progressive_tool_disclosure_enabled, +) +from langchain.agents import create_agent +from langchain.agents.middleware.types import AgentMiddleware +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_core.runnables import Runnable +from langchain_core.tools import BaseTool, tool +from langgraph.checkpoint.memory import InMemorySaver +from pydantic import Field, ValidationError + +PINNED_VERSIONS = { + "deepagents-code": "0.1.30", + "deepagents": "0.7.0a3", + "langchain": "1.3.11", + "langchain-core": "1.4.8", + "langgraph": "1.2.6", +} + + +def _tool_name(tool_value: BaseTool | dict[str, Any] | object) -> str: + if isinstance(tool_value, BaseTool): + return tool_value.name + if isinstance(tool_value, dict): + name = tool_value.get("name") + if isinstance(name, str): + return name + function = tool_value.get("function") + if isinstance(function, dict) and isinstance(function.get("name"), str): + return function["name"] + name = getattr(tool_value, "__name__", None) + return name if isinstance(name, str) else "" + + +def _call(name: str, call_id: str, **arguments: Any) -> dict[str, Any]: + return { + "name": name, + "args": arguments, + "id": call_id, + "type": "tool_call", + } + + +class ScriptedModel(GenericFakeChatModel): + """Deterministic tool-calling model that records every bound tool set.""" + + messages: Iterator[AIMessage | str] = Field(default_factory=lambda: iter(())) + scenario: str + step: int = 0 + bound_tools: list[list[str]] = Field(default_factory=list) + profile: dict[str, Any] | None = Field( + default_factory=lambda: { + "tool_calling": True, + "max_input_tokens": 1_000_000, + } + ) + + def bind_tools( + self, + tools: Sequence[dict[str, Any] | type | Callable[..., Any] | BaseTool], + *, + tool_choice: str | None = None, + **kwargs: Any, + ) -> Runnable[Any, AIMessage]: + del tool_choice, kwargs + self.bound_tools.append([_tool_name(tool_value) for tool_value in tools]) + return self + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: Any = None, + **kwargs: Any, + ) -> ChatResult: + del messages, stop, run_manager, kwargs + step = self.step + self.step += 1 + message = self._scripted_message(step) + return ChatResult(generations=[ChatGeneration(message=message)]) + + def _scripted_message(self, step: int) -> AIMessage: # noqa: C901, PLR0911 + if self.scenario == "guessed": + if step == 0: + return AIMessage( + content="", + tool_calls=[ + _call("guessed_hidden_probe", "guessed-call", value="proof") + ], + ) + return AIMessage(content="guessed tool complete") + + if self.scenario == "direct": + if step == 0: + return AIMessage( + content="", + tool_calls=[ + _call("direct_visible_probe", "direct-call", value="proof") + ], + ) + return AIMessage(content="direct tool complete") + + if self.scenario == "collision": + if step == 0: + return AIMessage( + content="", + tool_calls=[ + _call( + "schema_executor_collision", + "collision-call", + value="proof", + ) + ], + ) + return AIMessage(content="collision probe complete") + + if self.scenario == "checkpoint": + if step in (0, 3): + return AIMessage( + content="", + tool_calls=[ + _call("search_tools", f"search-{step}", query="weather") + ], + ) + return AIMessage(content="checkpoint turn complete") + + if self.scenario == "concurrent": + if step == 0: + return AIMessage( + content="", + tool_calls=[ + _call("search_tools", "search-alpha", query="alpha capability"), + _call("search_tools", "search-beta", query="beta capability"), + ], + ) + return AIMessage(content="parallel discovery complete") + + if self.scenario == "async": + if step == 0: + return AIMessage( + content="", + tool_calls=[ + _call("search_tools", "async-search", query="async capability") + ], + ) + if step == 1: + return AIMessage( + content="", + tool_calls=[_call("async_hidden_probe", "async-call")], + ) + return AIMessage(content="async execution complete") + + if self.scenario == "subagent": + if step == 0: + return AIMessage( + content="", + tool_calls=[ + _call( + "search_tools", "main-hidden-search", query="isolated probe" + ) + ], + ) + if step == 1: + return AIMessage( + content="", + tool_calls=[ + _call("search_tools", "main-task-search", query="task") + ], + ) + if step == 2: + return AIMessage( + content="", + tool_calls=[ + _call( + "task", + "main-task-call", + description="Prove your initial tool visibility is isolated.", + subagent_type="general-purpose", + ) + ], + ) + if step == 3: + return AIMessage( + content="", + tool_calls=[ + _call("search_tools", "subagent-search", query="isolated probe") + ], + ) + if step == 4: + return AIMessage(content="subagent isolation complete") + return AIMessage(content="main agent complete") + + raise AssertionError(f"unknown scripted scenario: {self.scenario}") + + +class ToolAuditMiddleware(AgentMiddleware): + """Record calls while delegating through the normal executor middleware.""" + + def __init__(self) -> None: + super().__init__() + self.seen: list[str] = [] + + def wrap_tool_call(self, request: Any, handler: Callable[[Any], Any]) -> Any: + self.seen.append(request.tool_call["name"]) + return handler(request) + + async def awrap_tool_call( + self, + request: Any, + handler: Callable[[Any], Any], + ) -> Any: + self.seen.append(request.tool_call["name"]) + return await handler(request) + + +def _validate_versions_and_schema() -> None: + actual = { + package: importlib.metadata.version(package) for package in PINNED_VERSIONS + } + assert actual == PINNED_VERSIONS, (actual, PINNED_VERSIONS) + + schema = SearchToolsInput.model_json_schema()["properties"]["query"] + assert schema["maxLength"] == MAX_SEARCH_QUERY_LENGTH == 256 + SearchToolsInput(query="q" * MAX_SEARCH_QUERY_LENGTH) + try: + SearchToolsInput(query="q" * (MAX_SEARCH_QUERY_LENGTH + 1)) + except ValidationError: + pass + else: + raise AssertionError("search_tools accepted an oversized query") + + public_args = ProgressiveToolDisclosureMiddleware().tools[0].args + assert set(public_args) == {"query"} + assert public_args["query"]["maxLength"] == MAX_SEARCH_QUERY_LENGTH + description = ProgressiveToolDisclosureMiddleware().tools[0].description + for limit in ( + MAX_SEARCH_RESULTS, + MAX_SEARCH_DESCRIPTION_CHARS, + MAX_SEARCH_OUTPUT_BYTES, + MAX_DISCOVERED_TOOLS, + MAX_DISCOVERED_TOOL_NAME_BYTES, + MAX_DISCOVERED_STATE_BYTES, + MAX_SINGLE_TOOL_SCHEMA_BYTES, + MAX_VISIBLE_DISCOVERED_SCHEMA_BYTES, + ): + assert str(limit) in description + + +class _RequestProbe: + """Minimal request shape for exact middleware filtering validation.""" + + def __init__(self, tools: list[Any], state: dict[str, Any]) -> None: + self.tools = tools + self.state = state + + def override(self, **changes: Any) -> _RequestProbe: + return _RequestProbe( + changes.get("tools", self.tools), changes.get("state", self.state) + ) + + +class _RuntimeProbe: + """Minimal runtime shape for exact search result validation.""" + + def __init__(self, tools: list[Any], state: dict[str, Any] | None = None) -> None: + self.tools = tools + self.state = state or {} + self.tool_call_id = "bounded-search" + + +def _validate_bounded_catalog_and_provider_native_tools() -> None: + middleware = ProgressiveToolDisclosureMiddleware() + description = "bulk capability " + ("🧰" * 1024) + catalog = [ + { + "type": "function", + "function": { + "name": f"bulk_{index:04d}", + "description": description, + }, + } + for index in range(1000) + ] + provider_native = {"type": "provider-native", "opaque": object()} + tools: list[Any] = [*catalog, middleware.tools[0], provider_native] + + result = middleware._search_tools( # noqa: SLF001 + "bulk capability", _RuntimeProbe(tools) + ) + reversed_result = middleware._search_tools( # noqa: SLF001 + "bulk capability", _RuntimeProbe(list(reversed(tools))) + ) + expected = [f"bulk_{index:04d}" for index in range(MAX_SEARCH_RESULTS)] + assert result.update["discovered_tools"] == expected + assert reversed_result.update["discovered_tools"] == expected + content = result.update["messages"][0].content + assert reversed_result.update["messages"][0].content == content + assert len(content.encode("utf-8")) <= MAX_SEARCH_OUTPUT_BYTES + assert "Search output truncated" in content + assert ("🧰" * MAX_SEARCH_DESCRIPTION_CHARS) not in content + first_state = disclosure._merge_discovered_tools( # noqa: SLF001 + None, result.update["discovered_tools"] + ) + first_visible = middleware._prepare_request( # noqa: SLF001 + _RequestProbe(tools, {"discovered_tools": first_state}) + ) + assert set(expected).issubset( + {_tool_name(tool_value) for tool_value in first_visible.tools} + ) + + all_names = [f"bulk_{index:04d}" for index in range(1000)] + bounded_state = disclosure._merge_discovered_tools(None, all_names) # noqa: SLF001 + assert bounded_state == all_names[:MAX_DISCOVERED_TOOLS] + assert ( + disclosure._discovered_state_bytes(bounded_state) # noqa: SLF001 + <= MAX_DISCOVERED_STATE_BYTES + ) + assert ( + disclosure._merge_discovered_tools( # noqa: SLF001 + None, list(reversed(all_names)) + ) + == bounded_state + ) + assert ( + disclosure._merge_discovered_tools( # noqa: SLF001 + all_names[:40], all_names[40:100] + ) + == disclosure._merge_discovered_tools( # noqa: SLF001 + all_names[40:100], all_names[:40] + ) + == bounded_state + ) + long_names = [f"long_{index:04d}_" + ("🧰" * 25) for index in range(64)] + long_state = disclosure._merge_discovered_tools(None, long_names) # noqa: SLF001 + assert len(long_state) == MAX_DISCOVERED_TOOLS + assert ( + disclosure._discovered_state_bytes(long_state) # noqa: SLF001 + <= MAX_DISCOVERED_STATE_BYTES + ) + overlong_name = "🧰" * ((MAX_DISCOVERED_TOOL_NAME_BYTES // 4) + 1) + assert disclosure._merge_discovered_tools(None, [overlong_name]) == [] # noqa: SLF001 + part_a, part_b, part_c = all_names[:50], all_names[50:100], all_names[100:150] + assert ( + disclosure._merge_discovered_tools( # noqa: SLF001 + disclosure._merge_discovered_tools(part_a, part_b), # noqa: SLF001 + part_c, + ) + == disclosure._merge_discovered_tools( # noqa: SLF001 + part_a, + disclosure._merge_discovered_tools(part_b, part_c), # noqa: SLF001 + ) + == disclosure._merge_discovered_tools( # noqa: SLF001 + None, [*part_a, *part_b, *part_c] + ) + ) + varying_a = [f"b{index:02d}_" + ("x" * (index % 80)) for index in range(64)] + varying_b = ["z"] + varying_c = ["a"] + assert ( + disclosure._merge_discovered_tools( # noqa: SLF001 + disclosure._merge_discovered_tools(varying_a, varying_b), # noqa: SLF001 + varying_c, + ) + == disclosure._merge_discovered_tools( # noqa: SLF001 + varying_a, + disclosure._merge_discovered_tools(varying_b, varying_c), # noqa: SLF001 + ) + == disclosure._merge_discovered_tools( # noqa: SLF001 + None, [*varying_a, *varying_b, *varying_c] + ) + ) + + prepared = middleware._prepare_request( # noqa: SLF001 + _RequestProbe(tools, {"discovered_tools": all_names}) + ) + visible_schemas = [ + tool_value + for tool_value in prepared.tools + if _tool_name(tool_value).startswith("bulk_") + ] + assert 0 < len(visible_schemas) < MAX_DISCOVERED_TOOLS + assert ( + sum( + disclosure._serialized_tool_schema_bytes(tool_value) or 0 # noqa: SLF001 + for tool_value in visible_schemas + ) + <= MAX_VISIBLE_DISCOVERED_SCHEMA_BYTES + ) + reversed_prepared = middleware._prepare_request( # noqa: SLF001 + _RequestProbe(list(reversed(tools)), {"discovered_tools": all_names}) + ) + assert sorted(_tool_name(tool_value) for tool_value in prepared.tools) == sorted( + _tool_name(tool_value) for tool_value in reversed_prepared.tools + ) + assert prepared.tools[-1] is provider_native + initial = middleware._prepare_request(_RequestProbe(tools, {})) # noqa: SLF001 + assert initial.tools[-1] is provider_native + + state_blocked = middleware._search_tools( # noqa: SLF001 + "bulk_0999", _RuntimeProbe(tools, {"discovered_tools": bounded_state}) + ) + assert "discovered_tools" not in state_blocked.update + assert ( + "thread discovery state is limited" + in state_blocked.update["messages"][0].content + ) + high_state = [f"z_current_{index:04d}" for index in range(64)] + earlier_state_tool = { + "type": "function", + "function": { + "name": "a_earlier", + "description": "earlier state candidate", + }, + } + high_state_tools = [ + *[ + { + "type": "function", + "function": {"name": name, "description": "existing"}, + } + for name in high_state + ], + earlier_state_tool, + middleware.tools[0], + ] + earlier_state_blocked = middleware._search_tools( # noqa: SLF001 + "a_earlier", + _RuntimeProbe(high_state_tools, {"discovered_tools": high_state}), + ) + assert "discovered_tools" not in earlier_state_blocked.update + assert ( + disclosure._merge_discovered_tools( # noqa: SLF001 + high_state, earlier_state_blocked.update.get("discovered_tools") + ) + == high_state + ) + + schema_full_state = all_names[: len(visible_schemas)] + schema_blocked = middleware._search_tools( # noqa: SLF001 + all_names[len(visible_schemas)], + _RuntimeProbe(tools, {"discovered_tools": schema_full_state}), + ) + assert "discovered_tools" not in schema_blocked.update + assert ( + "discovered schemas are limited" in schema_blocked.update["messages"][0].content + ) + earlier_schema = { + "type": "function", + "function": {"name": "aaa_schema", "description": description}, + } + earlier_tools = [earlier_schema, *tools] + earlier_blocked = middleware._search_tools( # noqa: SLF001 + "aaa_schema", + _RuntimeProbe(earlier_tools, {"discovered_tools": schema_full_state}), + ) + assert "discovered_tools" not in earlier_blocked.update + assert ( + "discovered schemas are limited" + in earlier_blocked.update["messages"][0].content + ) + + oversized_schema = { + "type": "function", + "function": { + "name": "oversized_schema", + "description": "oversized capability", + "parameters": { + "properties": { + "payload": {"const": "x" * MAX_SINGLE_TOOL_SCHEMA_BYTES} + }, + "type": "object", + }, + }, + } + overlong_tool = { + "type": "function", + "function": { + "name": overlong_name, + "description": "overlong capability", + "parameters": {"properties": {}, "type": "object"}, + }, + } + unserializable_schema = { + "type": "function", + "function": { + "name": "unserializable_schema", + "description": "unserializable capability", + "parameters": { + "properties": {"payload": {"const": object()}}, + "type": "object", + }, + }, + } + ineligible_tools = [ + oversized_schema, + overlong_tool, + unserializable_schema, + middleware.tools[0], + provider_native, + ] + for query, name in ( + ("oversized capability", "oversized_schema"), + ("overlong capability", overlong_name), + ("unserializable capability", "unserializable_schema"), + ): + omitted = middleware._search_tools( # noqa: SLF001 + query, _RuntimeProbe(ineligible_tools) + ) + assert "discovered_tools" not in omitted.update + assert "No hidden tools matched" in omitted.update["messages"][0].content + filtered = middleware._prepare_request( # noqa: SLF001 + _RequestProbe(ineligible_tools, {"discovered_tools": [name]}) + ) + assert oversized_schema not in filtered.tools + assert overlong_tool not in filtered.tools + assert unserializable_schema not in filtered.tools + assert filtered.tools[-1] is provider_native + + oversized_core = { + "name": "ls", + "description": "oversized core", + "parameters": { + "properties": {"payload": {"const": "x" * MAX_SINGLE_TOOL_SCHEMA_BYTES}}, + "type": "object", + }, + } + unserializable_core = { + "name": "read_file", + "description": "unserializable core", + "parameters": { + "properties": {"payload": {"const": object()}}, + "type": "object", + }, + } + core_request = middleware._prepare_request( # noqa: SLF001 + _RequestProbe( + [oversized_core, unserializable_core, middleware.tools[0]], + {}, + ) + ) + assert core_request.tools[0] is oversized_core + assert core_request.tools[1] is unserializable_core + + duplicate_first = { + "type": "function", + "function": { + "name": "duplicate_probe", + "description": "first duplicate description", + }, + } + duplicate_second = { + "type": "function", + "function": { + "name": "duplicate_probe", + "description": "second duplicate description", + }, + } + duplicate_tools = [duplicate_first, duplicate_second, middleware.tools[0]] + duplicate_result = middleware._search_tools( # noqa: SLF001 + "duplicate_probe", _RuntimeProbe(duplicate_tools) + ) + duplicate_content = duplicate_result.update["messages"][0].content + assert "first duplicate description" in duplicate_content + assert "second duplicate description" not in duplicate_content + duplicate_visible = middleware._prepare_request( # noqa: SLF001 + _RequestProbe(duplicate_tools, {"discovered_tools": ["duplicate_probe"]}) + ) + assert duplicate_visible.tools[0] is duplicate_first + assert duplicate_second not in duplicate_visible.tools + + empty_top_level = {"name": "", "description": "empty top-level name"} + empty_nested = {"type": "function", "function": {"name": ""}} + empty_visible = middleware._prepare_request( # noqa: SLF001 + _RequestProbe([empty_top_level, empty_nested, middleware.tools[0]], {}) + ) + assert empty_visible.tools[0] is empty_top_level + assert empty_visible.tools[1] is empty_nested + + concurrent_state = [f"base_{index:04d}" for index in range(63)] + concurrent_tools = [ + *[ + { + "type": "function", + "function": {"name": name, "description": "existing"}, + } + for name in concurrent_state + ], + { + "type": "function", + "function": {"name": "a_new", "description": "concurrent capacity"}, + }, + { + "type": "function", + "function": {"name": "z_new", "description": "concurrent capacity"}, + }, + middleware.tools[0], + ] + concurrent_results = [ + middleware._search_tools( # noqa: SLF001 + name, + _RuntimeProbe(concurrent_tools, {"discovered_tools": concurrent_state}), + ) + for name in ("a_new", "z_new") + ] + assert all( + "exposing" not in result.update["messages"][0].content + for result in concurrent_results + ) + concurrent_updates = disclosure._merge_discovered_tools( # noqa: SLF001 + concurrent_results[0].update.get("discovered_tools"), + concurrent_results[1].update.get("discovered_tools"), + ) + concurrent_merged = disclosure._merge_discovered_tools( # noqa: SLF001 + concurrent_state, concurrent_updates + ) + assert len(concurrent_merged) == MAX_DISCOVERED_TOOLS + concurrent_visible = middleware._prepare_request( # noqa: SLF001 + _RequestProbe(concurrent_tools, {"discovered_tools": concurrent_merged}) + ) + assert { + _tool_name(tool_value) for tool_value in concurrent_visible.tools + }.issuperset(concurrent_merged) + + +def _validate_guessed_tool_execution() -> None: + executions: list[str] = [] + + @tool("guessed_hidden_probe") + def hidden_probe(value: str) -> str: + """A capability deliberately omitted from the initial model tool list.""" + executions.append(value) + return "guessed-hidden-proof" + + model = ScriptedModel(scenario="guessed") + audit = ToolAuditMiddleware() + agent = create_agent( + model=model, + tools=[hidden_probe], + middleware=[ProgressiveToolDisclosureMiddleware(), audit], + ) + agent.invoke({"messages": [HumanMessage(content="Guess the hidden tool.")]}) + + assert "search_tools" in model.bound_tools[0] + assert "guessed_hidden_probe" not in model.bound_tools[0] + assert executions == ["proof"] + assert "guessed_hidden_probe" in audit.seen + + +def _validate_pinned_executor_collision_and_namespace_guard() -> None: + executions: list[str] = [] + + @tool("schema_executor_collision") + def model_schema_tool(value: str) -> str: + """model-visible-schema-sentinel""" + executions.append(f"model-schema:{value}") + return "wrong-implementation" + + @tool("schema_executor_collision") + def executor_tool(value: str) -> str: + """executor-implementation-sentinel""" + executions.append(f"executor:{value}") + return "executor-proof" + + # Pin the reason for the guard: disclosure selects the first schema from + # the full registry while the exact LangChain executor resolves the same + # duplicate name to the last implementation. + middleware = ProgressiveToolDisclosureMiddleware() + prepared = middleware._prepare_request( # noqa: SLF001 + _RequestProbe( + [model_schema_tool, executor_tool, middleware.tools[0]], + {"discovered_tools": ["schema_executor_collision"]}, + ) + ) + visible_collision_tools = [ + tool_value + for tool_value in prepared.tools + if _tool_name(tool_value) == "schema_executor_collision" + ] + assert visible_collision_tools == [model_schema_tool] + assert visible_collision_tools[0].description == "model-visible-schema-sentinel" + + collision_model = ScriptedModel(scenario="collision") + collision_agent = create_agent( + model=collision_model, + tools=[model_schema_tool, executor_tool], + ) + collision_agent.invoke( + {"messages": [HumanMessage(content="Exercise duplicate tool resolution.")]} + ) + assert executions == ["executor:proof"] + + @tool("read_file") + def reserved_regular() -> str: + """Represent an untrusted regular tool with a reserved core name.""" + return "must-not-run" + + def collision_tool(name: str, marker: str) -> BaseTool: + @tool(name) + def probe(value: str = "") -> str: + """Represent one implementation in a collision fixture.""" + return f"{marker}:{value}" + + return probe + + regular_a = collision_tool("regular_duplicate", "regular-a") + regular_b = collision_tool("regular_duplicate", "regular-b") + regular_mcp = collision_tool("mcp_echo", "regular") + mcp_peer = collision_tool("mcp_echo", "mcp") + cross_mcp_a = collision_tool("alpha_beta_echo", "alpha-beta_echo") + cross_mcp_b = collision_tool("alpha_beta_echo", "alpha_beta-echo") + + collision_cases = { + "regular_regular": ( + "progressive", + [regular_a, regular_b], + [], + ), + "regular_mcp": ( + "progressive", + [regular_mcp, mcp_peer], + [ + MCPServerInfo( + name="mcp", + transport="http", + tools=( + MCPToolInfo( + name="mcp_echo", + description="MCP implementation", + ), + ), + ) + ], + ), + "cross_mcp": ( + "progressive", + [cross_mcp_a, cross_mcp_b], + [ + MCPServerInfo( + name=server, + transport="http", + tools=( + MCPToolInfo( + name="alpha_beta_echo", + description=f"{server} implementation", + ), + ), + ) + for server in ("alpha", "alpha_beta") + ], + ), + "reserved_progressive": ( + "progressive", + [reserved_regular], + [], + ), + "reserved_mcp": ( + "progressive", + [collision_tool("search_tools", "reserved-mcp")], + [ + MCPServerInfo( + name="search", + transport="http", + tools=( + MCPToolInfo( + name="search_tools", + description="non-managed reserved implementation", + ), + ), + ) + ], + ), + "duplicate_direct": ( + "direct", + [regular_a, regular_b], + [], + ), + "reserved_direct": ( + "direct", + [collision_tool("execute", "reserved-direct")], + [], + ), + } + original_cli_factory = agent_module._nemoclaw_original_create_cli_agent + reached_original: list[str] = [] + + def forbidden_original(*args: Any, **kwargs: Any) -> None: + del args, kwargs + reached_original.append("called") + raise AssertionError("reserved-name validation ran too late") + + agent_module._nemoclaw_original_create_cli_agent = forbidden_original + previous = os.environ.get("NEMOCLAW_TOOL_DISCLOSURE") + try: + errors: dict[str, str] = {} + for label, (mode, tools, info) in collision_cases.items(): + os.environ["NEMOCLAW_TOOL_DISCLOSURE"] = mode + try: + create_cli_agent( + model=object(), + assistant_id="callable-namespace-validator", + tools=tools, + mcp_server_info=info, + ) + except RuntimeError as exc: + errors[label] = str(exc) + else: + raise AssertionError(f"callable namespace collision {label!r} was accepted") + finally: + agent_module._nemoclaw_original_create_cli_agent = original_cli_factory + if previous is None: + os.environ.pop("NEMOCLAW_TOOL_DISCLOSURE", None) + else: + os.environ["NEMOCLAW_TOOL_DISCLOSURE"] = previous + + assert reached_original == [] + assert set(errors) == set(collision_cases) + assert "multiple registered implementations" in errors["regular_regular"] + assert "MCP metadata owners" in errors["regular_mcp"] + assert "multiple MCP owners" in errors["cross_mcp"] + assert "reserved name 'read_file'" in errors["reserved_progressive"] + assert "MCP server 'search' tool[0]" in errors["reserved_mcp"] + assert "reserved name 'search_tools'" in errors["reserved_mcp"] + assert "multiple registered implementations" in errors["duplicate_direct"] + assert "reserved name 'execute'" in errors["reserved_direct"] + + +def _validate_direct_mode_execution() -> None: + executions: list[str] = [] + + @tool("direct_visible_probe") + def direct_probe(value: str) -> str: + """Return a direct-mode proof through the standard executor stack.""" + executions.append(value) + return "direct-proof" + + info = MCPServerInfo( + name="direct-runtime-validator", + transport="http", + tools=( + MCPToolInfo( + name=direct_probe.name, + description=direct_probe.description, + ), + ), + ) + model = ScriptedModel(scenario="direct") + previous = os.environ.get("NEMOCLAW_TOOL_DISCLOSURE") + os.environ["NEMOCLAW_TOOL_DISCLOSURE"] = "direct" + try: + assert not progressive_tool_disclosure_enabled() + with tempfile.TemporaryDirectory(prefix="deepagents-direct-runtime-") as cwd: + agent, _backend = create_cli_agent( + model=model, + assistant_id="direct-runtime-validator", + tools=[direct_probe], + cwd=Path(cwd), + interactive=False, + auto_approve=True, + enable_ask_user=False, + enable_memory=False, + enable_skills=False, + enable_shell=False, + mcp_server_info=[info], + ) + agent.invoke( + {"messages": [HumanMessage(content="Call the directly visible tool.")]} + ) + finally: + if previous is None: + os.environ.pop("NEMOCLAW_TOOL_DISCLOSURE", None) + else: + os.environ["NEMOCLAW_TOOL_DISCLOSURE"] = previous + + assert "direct_visible_probe" in model.bound_tools[0] + assert "search_tools" not in model.bound_tools[0] + assert executions == ["proof"] + + +def _validate_checkpoints_and_threads() -> None: + @tool("weather_checkpoint_probe") + def weather_probe() -> str: + """Return a weather checkpoint proof.""" + return "weather-proof" + + model = ScriptedModel(scenario="checkpoint") + agent = create_agent( + model=model, + tools=[weather_probe], + middleware=[ProgressiveToolDisclosureMiddleware()], + checkpointer=InMemorySaver(), + ) + thread_a = {"configurable": {"thread_id": "progressive-thread-a"}} + thread_b = {"configurable": {"thread_id": "progressive-thread-b"}} + + agent.invoke({"messages": [HumanMessage(content="Discover weather.")]}, thread_a) + assert "weather_checkpoint_probe" not in model.bound_tools[0] + assert "weather_checkpoint_probe" in model.bound_tools[1] + assert agent.get_state(thread_a).values["discovered_tools"] == [ + "weather_checkpoint_probe" + ] + + resume_index = len(model.bound_tools) + agent.invoke({"messages": [HumanMessage(content="Resume this thread.")]}, thread_a) + assert "weather_checkpoint_probe" in model.bound_tools[resume_index] + + other_thread_index = len(model.bound_tools) + agent.invoke({"messages": [HumanMessage(content="Use a fresh thread.")]}, thread_b) + assert "weather_checkpoint_probe" not in model.bound_tools[other_thread_index] + assert "weather_checkpoint_probe" in model.bound_tools[other_thread_index + 1] + + +def _validate_concurrent_discovery() -> None: + @tool("alpha_capability_probe") + def alpha_probe() -> str: + """Return the alpha capability proof.""" + return "alpha" + + @tool("beta_capability_probe") + def beta_probe() -> str: + """Return the beta capability proof.""" + return "beta" + + model = ScriptedModel(scenario="concurrent") + agent = create_agent( + model=model, + tools=[alpha_probe, beta_probe], + middleware=[ProgressiveToolDisclosureMiddleware()], + checkpointer=InMemorySaver(), + ) + config = {"configurable": {"thread_id": "parallel-discovery"}} + agent.invoke({"messages": [HumanMessage(content="Discover both tools.")]}, config) + + expected = ["alpha_capability_probe", "beta_capability_probe"] + assert agent.get_state(config).values["discovered_tools"] == expected + assert all(name not in model.bound_tools[0] for name in expected) + assert all(name in model.bound_tools[1] for name in expected) + + +async def _validate_async_discovery() -> None: + executions: list[str] = [] + + @tool("async_hidden_probe") + def async_probe() -> str: + """Return an async capability proof through the standard executor.""" + executions.append("async") + return "async-proof" + + model = ScriptedModel(scenario="async") + agent = create_agent( + model=model, + tools=[async_probe], + middleware=[ProgressiveToolDisclosureMiddleware()], + checkpointer=InMemorySaver(), + ) + config = {"configurable": {"thread_id": "async-discovery"}} + await agent.ainvoke( + {"messages": [HumanMessage(content="Discover asynchronously.")]}, + config, + ) + + assert "async_hidden_probe" not in model.bound_tools[0] + assert "async_hidden_probe" in model.bound_tools[1] + assert executions == ["async"] + assert agent.get_state(config).values["discovered_tools"] == ["async_hidden_probe"] + + +def _validate_local_subagent_isolation() -> None: + @tool("isolated_probe") + def isolated_probe() -> str: + """Return an isolated probe capability.""" + return "isolated-proof" + + model = ScriptedModel(scenario="subagent") + info = MCPServerInfo( + name="runtime-validator", + transport="http", + tools=( + MCPToolInfo( + name=isolated_probe.name, + description=isolated_probe.description, + ), + ), + ) + with tempfile.TemporaryDirectory(prefix="deepagents-progressive-runtime-") as cwd: + agent, _backend = create_cli_agent( + model=model, + assistant_id="progressive-runtime-validator", + tools=[isolated_probe], + cwd=Path(cwd), + interactive=False, + auto_approve=True, + enable_ask_user=False, + enable_memory=False, + enable_skills=False, + enable_shell=False, + mcp_server_info=[info], + ) + agent.invoke( + {"messages": [HumanMessage(content="Delegate an isolation proof.")]} + ) + + assert model.step == 6 + assert "isolated_probe" not in model.bound_tools[0] + assert "isolated_probe" in model.bound_tools[1] + assert "task" not in model.bound_tools[1] + assert "task" in model.bound_tools[2] + assert "isolated_probe" not in model.bound_tools[3] + assert "isolated_probe" in model.bound_tools[4] + assert "isolated_probe" in model.bound_tools[5] + + +def main() -> None: + _validate_versions_and_schema() + _validate_bounded_catalog_and_provider_native_tools() + _validate_guessed_tool_execution() + _validate_pinned_executor_collision_and_namespace_guard() + _validate_direct_mode_execution() + _validate_checkpoints_and_threads() + _validate_concurrent_discovery() + asyncio.run(_validate_async_discovery()) + _validate_local_subagent_isolation() + print("progressive-disclosure-runtime-ok") + + +if __name__ == "__main__": + main() diff --git a/docs/inference/model-capability-audit.mdx b/docs/inference/model-capability-audit.mdx index 5411a789a78..227a9117a24 100644 --- a/docs/inference/model-capability-audit.mdx +++ b/docs/inference/model-capability-audit.mdx @@ -68,7 +68,7 @@ Rows can remain `degraded`, `blocked`, or `not-yet-run` when a scenario cannot b | Shell tool loop | Separate structured `hostname`, `date`, and `uptime` tool calls are emitted, persisted, correlated with tool results, and followed by a final assistant response. | | Multi-turn continuation | Turn 2 uses a tool result from turn 1 and does not ask the user to continue after a complete tool result. | | Sub-agent delegation | The primary agent emits a structured `sessions_spawn` request, the sub-agent receives the intended task and workspace, and the primary agent consumes the result. | -| Hermes path | Hermes starts with the selected provider/model, returns the expected OpenAI-compatible response shape, and separates Hermes failures from OpenClaw-only request-shape issues. | +| Hermes path | Hermes starts with the selected provider/model, returns the expected OpenAI-compatible response shape, keeps core tools direct, and uses its native structured `tool_search` -> `tool_describe` -> `tool_call` path for a deferred tool. Keep Hermes `tools.tool_search.enabled: on` evidence separate from OpenClaw `tools.toolSearch.mode: tools` evidence. | | Performance and operability | The row records validation duration, first event timing when available, retry behavior, timeout budget, streaming requirement, request mutation requirement, API path forcing, and cold-start differences. | ## Audit Matrix @@ -79,7 +79,7 @@ When importing a completed row from an issue comment, preserve the exact commit | Agent surface | Provider class | Model or route | API path | State | Evidence | Required affordance | Follow-up | Source | |---|---|---|---|---|---|---|---|---| -| OpenClaw primary agent | NVIDIA Endpoints | `nvidia/nemotron-3-super-120b-a12b` | Managed `inference.local` OpenAI-compatible completions | `not-yet-run` | Add trajectory and session evidence before changing state. | Existing OpenClaw setup manifest disables `tool_search` for this route. | Verify evidence before changing state. | `src/lib/inference/config.ts`, `nemoclaw-blueprint/model-specific-setup/openclaw/nemotron-3-super-120b-managed-inference.json`. | +| OpenClaw primary agent | NVIDIA Endpoints | `nvidia/nemotron-3-super-120b-a12b` | Managed `inference.local` OpenAI-compatible completions | `not-yet-run` | Add trajectory and session evidence before changing state. | Existing setup keeps Tool Search disabled and preserves direct structured tool calls, overriding the generated `tools.toolSearch.mode: tools` default for this route. | Verify `tool_search`, `tool_describe`, `tool_call`, and final execution before replacing the safeguard. | `scripts/generate-openclaw-config.mts`, `nemoclaw-blueprint/model-specific-setup/openclaw/nemotron-3-super-120b-managed-inference.json`. | | OpenClaw primary agent | NVIDIA Endpoints | `moonshotai/kimi-k2.6` | Managed `inference.local` OpenAI-compatible completions | `not-yet-run` | Add trajectory and session evidence before changing state. | Existing OpenClaw setup manifest applies Kimi compatibility and plugin loading. | Verify Kimi regression evidence before changing state. | `src/lib/inference/config.ts`, `nemoclaw-blueprint/model-specific-setup/openclaw/kimi-k2.6-managed-inference.json`. | | OpenClaw primary agent | NVIDIA Endpoints | Any model from `CLOUD_MODEL_OPTIONS` | Managed `inference.local` OpenAI-compatible completions unless config selects another API. | `not-yet-run` | Add one evidence row per model before changing state. | Record `none`, model-specific setup, or provider-class transport behavior. | Expand into per-model rows as evidence lands. | `src/lib/inference/config.ts`. | | OpenClaw primary agent | OpenAI | Any model from `REMOTE_MODEL_OPTIONS.openai` | `openai` provider through `https://inference.local/v1`. | `not-yet-run` | Add one evidence row per model before changing state. | Record Responses or Chat Completions behavior explicitly. | Expand into per-model rows as evidence lands. | `src/lib/inference/model-prompts.ts`, `src/lib/inference/config.ts`. | @@ -89,7 +89,7 @@ When importing a completed row from an issue comment, preserve the exact commit | OpenClaw primary agent | Local vLLM | Any model from `VLLM_MODELS`. | Managed `inference.local` route to the host vLLM server. | `not-yet-run` | Add vLLM serve flags, model id, and trajectory evidence before changing state. | Record parser flags, reasoning parser, and tool-call parser behavior. | Add one row per audited vLLM model id. | `src/lib/inference/vllm-models.ts`, `src/lib/inference/config.ts`. | | OpenClaw primary agent | Other OpenAI-compatible endpoint | User-selected `custom-model` or another configured model id. | Managed `inference.local` route to the compatible endpoint. | `not-yet-run` | Add endpoint class and trajectory evidence before changing state. | Record endpoint API path forcing and store/streaming assumptions. | Add one row per endpoint class that is validated. | `src/lib/inference/config.ts`. | | OpenClaw primary agent | Other Anthropic-compatible endpoint | User-selected `custom-anthropic-model` or another configured model id. | `anthropic` route when supported, otherwise managed compatible route. | `not-yet-run` | Add endpoint class and trajectory evidence before changing state. | Record native Anthropic Messages or compatible-route transport behavior. | Add one row per endpoint class that is validated. | `src/lib/inference/config.ts`. | -| Hermes sandbox API | Hermes Provider | Default `moonshotai/kimi-k2.6` or any model from `HERMES_PROVIDER_MODEL_OPTIONS`. | Hermes Provider route through NemoClaw managed inference. | `not-yet-run` | Add Hermes session, request dump, logs, and local API evidence before changing state. | Record Hermes-specific config, transport, and response-shape behavior. | Keep Hermes rows separate from OpenClaw rows. | `src/lib/inference/config.ts`, `src/lib/inference/model-prompts.ts`. | +| Hermes sandbox API | Hermes Provider | Default `moonshotai/kimi-k2.6` or any model from `HERMES_PROVIDER_MODEL_OPTIONS`. | Hermes Provider route through NemoClaw managed inference. | `not-yet-run` | Add Hermes session, request dump, logs, and local API evidence before changing state. | Generated config uses native `tools.tool_search.enabled: on` with snake-case 5/20 limits; core tools stay direct while deferred MCP and non-core plugin tools use structured search, describe, and call. | Verify a deferred-tool trajectory and keep it separate from OpenClaw `mode: tools` evidence. | `agents/hermes/config/hermes-config.ts`, `test/generate-hermes-config.test.ts`. | ## Completed Row Template diff --git a/docs/inference/tool-calling-reliability.mdx b/docs/inference/tool-calling-reliability.mdx index 5cfcffb4507..a47adcbec12 100644 --- a/docs/inference/tool-calling-reliability.mdx +++ b/docs/inference/tool-calling-reliability.mdx @@ -3,9 +3,9 @@ # SPDX-License-Identifier: Apache-2.0 title: "Tool-Calling Reliability for Local Inference" sidebar-title: "Tool-Calling Reliability" -description: "Diagnose local inference setups where tool calls leak as plain text and choose when to use Ollama or vLLM." -description-agent: "Explains Ollama tool-call leak symptoms, when to use vLLM with a tool-call parser, and how to repoint NemoClaw to a parser-aware local endpoint." -keywords: ["nemoclaw tool calling", "ollama tool calls", "vllm tool-call-parser", "raw json in tui"] +description: "Understand progressive tool disclosure across agents and diagnose local inference setups where tool calls leak as plain text." +description-agent: "Explains agent-specific progressive Tool Search behavior, disclosure overrides, Ollama tool-call leak symptoms, and parser-aware vLLM setup. Use when troubleshooting search_tools, Tool Search, or raw tool-call JSON." +keywords: ["nemoclaw tool calling", "progressive tool disclosure", "search_tools", "ollama tool calls", "vllm tool-call-parser", "raw json in tui"] content: type: "troubleshooting" --- @@ -47,11 +47,42 @@ The common failure mode is: This is different from a network or policy block. `nemoclaw status`, `nemoclaw logs`, and `nemoclaw debug --quick` can all look healthy while tool dispatch still fails inside the conversation. +### Progressive Tool Disclosure by Agent + +Progressive disclosure is enabled by default across the supported agents, but each agent keeps its native mechanism and configuration schema. +The keys, tool names, and result limits are not interchangeable. + +| Agent | NemoClaw-generated mechanism | Default results | Maximum results | +|---|---|---:|---:| +| OpenClaw | `tools.toolSearch.mode: "tools"` with `searchDefaultLimit` and `maxSearchLimit` | 8 | 20 | +| Hermes | `tools.tool_search.enabled: "on"` with `search_default_limit` and `max_search_limit` | 5 | 20 | +| Deep Agents Code | NemoClaw `ProgressiveToolDisclosureMiddleware` and the `search_tools` model tool | Up to 20 | 20 | + +For OpenClaw, `mode: "tools"` selects its structured bridge instead of the JavaScript-based `tool_search_code` bridge. +A model-specific `toolSearch: false` override still disables Tool Search entirely. +For Hermes, `enabled: "on"` activates its native bridge whenever the session has at least one deferrable MCP or non-core plugin tool, even when that catalog is small; Hermes core tools remain directly visible. +For Deep Agents Code, the middleware activates only after at least one MCP tool loads successfully. +It initially exposes `search_tools` and the core filesystem, shell, user-input, and todo tools. +Each search returns up to 20 name-sorted tools whose names or descriptions match case-insensitively, and the graph thread retains up to 64 discovered named tools. +Search output is limited to 8 KiB, individual descriptions to 256 characters, individual name UTF-8 and stable-JSON representations to 120 bytes, individual named schemas to 16 KiB, and the discovered schemas visible in one model request to 128 KiB. +Named tools that exceed the name or schema limits are not discoverable, while core tools remain visible. +When a broad query exceeds a result or state limit, `search_tools` reports omitted matches so the model can refine its query. +Provider-native definitions without a callable name remain visible because the middleware cannot search or checkpoint them by name. +Main-agent and local-subagent discoveries use separate middleware instances. + +All three agents keep their full executor registry and route final calls through their normal execution, policy, approval, and hook paths. +Progressive disclosure is a model-context optimization, not an authorization boundary. + +Use `nemoclaw onboard --tool-disclosure direct` (or `NEMOCLAW_TOOL_DISCLOSURE=direct`) to present all registered tools directly. +For an existing sandbox with managed MCP servers, use `nemoclaw rebuild --tool-disclosure direct`; the transactional rebuild preserves MCP providers and adapter state while changing the mode. +An explicit `direct` selection is authoritative; model-specific safety settings may disable progressive search for a model, but cannot re-enable it over that selection. + ### Nemotron Managed Inference -For the `nvidia/nemotron-3-super-120b-a12b` managed inference route on `inference.local`, NemoClaw disables OpenClaw's native code-based tool search surface. -That route otherwise tends to generate invalid JavaScript for the `tool_search_code` helper, which creates `[tools] tool_search_code failed` noise even when normal turns succeed. -The agent still uses the structured tool-calling surface that the model handles correctly. +NemoClaw's generated OpenClaw default uses structured Tool Search, exposing `tool_search`, `tool_describe`, and `tool_call` instead of the JavaScript-based `tool_search_code` helper. +The managed Nemotron Super and Ultra routes retain model-specific `toolSearch: false` safeguards because their code-mode failures are documented and no live structured-search trajectory has cleared the replacement. +In the model-specific manifest contract, `false` disables Tool Search entirely while `true` selects OpenClaw's default code mode; neither boolean selects structured mode. +These routes therefore keep direct structured tool calling until search, describe, call, and final tool execution are verified through a real model trajectory. ## Recommended Fix diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 88cecb9e918..f599be1ccd1 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -93,7 +93,7 @@ The wizard creates an OpenShell gateway, registers inference providers, builds t Use this command for new installs and for recreating a sandbox after changes to policy or configuration. ```bash -nemohermes onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--agents ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [--yes-i-accept-third-party-software] +nemohermes onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--agents ] [--tool-disclosure ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [--yes-i-accept-third-party-software] ``` For Hermes, use the alias or pass the agent explicitly: @@ -120,6 +120,26 @@ It also bypasses locally recorded sandbox base-image resolution metadata and rer The installer also accepts `--fresh` and forwards it to `nemohermes onboard`, which skips automatic resume detection. `--resume` and `--fresh` are mutually exclusive. +#### `--tool-disclosure ` + +Choose how the selected agent presents its session-authorized tools to the model. +`progressive` is the default: OpenClaw and Hermes use their native Tool Search implementations, while Deep Agents Code initially shows its core tools plus `search_tools` after at least one MCP tool loads successfully. +`direct` restores the previous behavior and presents all registered tools directly. +This setting changes model context only; it does not bypass OpenShell policy, credentials, approvals, hooks, or sandbox controls. + +The flag takes precedence over `NEMOCLAW_TOOL_DISCLOSURE`. +A new sandbox defaults to `progressive` when neither is set. +NemoClaw records the selected value with the onboarding session and sandbox so rebuilds preserve it and ambient shell variables cannot silently change an internal rebuild. +Model-specific compatibility safeguards may downgrade a selected `progressive` mode to direct exposure for that model without changing the recorded preference. +To change an existing sandbox, recreate it explicitly: + +```bash +nemohermes onboard --name my-assistant --recreate-sandbox --tool-disclosure direct +``` + +Without an explicit flag or environment value, recreation preserves the recorded setting and only falls back to `progressive` for legacy state. +Resuming an interrupted session with a different explicit setting fails with a conflict instead of changing behavior mid-session. + When Docker exposes the required identity metadata, NemoClaw records the base-image resolution on managed OpenClaw and Hermes sandbox images. During a warm recreate or rebuild, it validates the local image identity and platform, plus the exact repository digest for a published image and any active OpenShell ABI requirement, before reusing it. A valid match avoids candidate discovery and a network pull. @@ -378,6 +398,16 @@ NemoClaw does not guarantee exact build timings. All NemoClaw build arguments (`NEMOCLAW_MODEL`, `NEMOCLAW_PROVIDER_KEY`, `NEMOCLAW_INFERENCE_BASE_URL`, etc.) are injected as `ARG` overrides at build time, so declare them in your Dockerfile if you need to reference them. +Custom Dockerfiles must declare `ARG NEMOCLAW_TOOL_DISCLOSURE=progressive` exactly once in the final build stage and promote it into that stage's runtime environment. +The usual runtime contract is: + +```dockerfile +ARG NEMOCLAW_TOOL_DISCLOSURE=progressive +ENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE} +``` + +Onboarding and rebuild preflight reject a missing, duplicate, or unconsumed declaration before replacing an existing sandbox. + In non-interactive mode, the path can also be supplied via the `NEMOCLAW_FROM_DOCKERFILE` environment variable. You must also supply a sandbox name via `--name ` or `NEMOCLAW_SANDBOX_NAME` so a `--from` build cannot silently clobber the default `my-assistant` sandbox. @@ -1479,17 +1509,19 @@ 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 rebuild preserves the recorded tool-disclosure mode unless `--tool-disclosure` explicitly changes it; it ignores an ambient `NEMOCLAW_TOOL_DISCLOSURE` value while recreating the sandbox. 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. ```bash -nemohermes my-assistant rebuild [--yes|-y|--force] [--verbose|-v] +nemohermes my-assistant rebuild [--yes|-y|--force] [--verbose|-v] [--tool-disclosure ] ``` | Flag | Description | |------|-------------| | `--yes`, `-y`, `--force` | Skip the confirmation prompt | | `--verbose`, `-v` | Log SSH commands, exit codes, and session state (also enabled by `NEMOCLAW_REBUILD_VERBOSE=1`) | +| `--tool-disclosure ` | Change the model-visible tool catalog during this transactional rebuild. Use this path for sandboxes with managed MCP servers so their providers and adapter state are preserved. | If another terminal has an active SSH session to the sandbox, `rebuild` prints an active-session warning and requires confirmation before destroying the sandbox. Pass `--yes`, `-y`, or `--force` to skip the prompt in scripted workflows. @@ -1876,7 +1908,7 @@ The `nemohermes setup` command is deprecated. Use `nemohermes onboard` instead. -This command remains as a compatibility alias to `nemohermes onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents `, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. +This command remains as a compatibility alias to `nemohermes onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents `, `--tool-disclosure `, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. ```bash nemohermes setup @@ -1889,7 +1921,7 @@ The `nemohermes setup-spark` command is deprecated. Use the standard installer and run `nemohermes onboard` instead, because current OpenShell releases handle the older DGX Spark cgroup behavior. -This command remains as a compatibility alias to `nemohermes onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents `, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. +This command remains as a compatibility alias to `nemohermes onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents `, `--tool-disclosure `, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. ```bash nemohermes setup-spark @@ -2142,6 +2174,7 @@ Set them before running `nemohermes onboard`. | Variable | Format | Effect | |----------|--------|--------| | `NEMOCLAW_PROVIDER` | provider key (e.g. `build`, `openai`, `anthropic`, `anthropicCompatible`, `gemini`, `ollama`, `custom`, `vllm`, `nim-local`, `routed`, `hermes-provider`, `install-vllm`, `install-ollama`, `install-windows-ollama`, `start-windows-ollama`) | Selects the inference provider during onboarding. The wizard skips the provider menu in both interactive and non-interactive runs when this is set. Aliases: `cloud` → `build`, `nim` → `nim-local`, `hermes` / `nous` / `nous-portal` → `hermes-provider`, `anthropiccompatible` → `anthropicCompatible`. Invalid values fail fast with the list of accepted keys. | +| `NEMOCLAW_TOOL_DISCLOSURE` | `progressive` or `direct` | Selects progressive tool discovery or the prior direct-exposure behavior. Defaults to `progressive`; `--tool-disclosure` takes precedence when both are set. | | `NEMOCLAW_ENDPOINT_URL` | URL | Custom endpoint URL. Used together with `NEMOCLAW_PROVIDER=custom` for OpenAI-compatible endpoints or `NEMOCLAW_PROVIDER=anthropicCompatible` for Anthropic-compatible endpoints. | | `NEMOCLAW_PREFERRED_API` | `completions` (currently the only honored value) | Forces the validation probe to use the `/v1/chat/completions` API path instead of the newer `/v1/responses` API. | | `NEMOCLAW_INFERENCE_INPUTS` | comma-separated list of `text` and/or `image` | Declares model input modalities for vision-capable models. Validated strictly; unknown tokens are ignored. | diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index a85271255db..6461d34ef40 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -133,7 +133,7 @@ The wizard creates an OpenShell gateway, registers inference providers, builds t Use this command for new installs and for recreating a sandbox after changes to policy or configuration. ```bash -$$nemoclaw onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--agents ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [--yes-i-accept-third-party-software] +$$nemoclaw onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--agents ] [--tool-disclosure ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [--yes-i-accept-third-party-software] ``` @@ -164,6 +164,26 @@ It also bypasses locally recorded sandbox base-image resolution metadata and rer The installer also accepts `--fresh` and forwards it to `$$nemoclaw onboard`, which skips automatic resume detection. `--resume` and `--fresh` are mutually exclusive. +#### `--tool-disclosure ` + +Choose how the selected agent presents its session-authorized tools to the model. +`progressive` is the default: OpenClaw and Hermes use their native Tool Search implementations, while Deep Agents Code initially shows its core tools plus `search_tools` after at least one MCP tool loads successfully. +`direct` restores the previous behavior and presents all registered tools directly. +This setting changes model context only; it does not bypass OpenShell policy, credentials, approvals, hooks, or sandbox controls. + +The flag takes precedence over `NEMOCLAW_TOOL_DISCLOSURE`. +A new sandbox defaults to `progressive` when neither is set. +NemoClaw records the selected value with the onboarding session and sandbox so rebuilds preserve it and ambient shell variables cannot silently change an internal rebuild. +Model-specific compatibility safeguards may downgrade a selected `progressive` mode to direct exposure for that model without changing the recorded preference. +To change an existing sandbox, recreate it explicitly: + +```bash +$$nemoclaw onboard --name my-assistant --recreate-sandbox --tool-disclosure direct +``` + +Without an explicit flag or environment value, recreation preserves the recorded setting and only falls back to `progressive` for legacy state. +Resuming an interrupted session with a different explicit setting fails with a conflict instead of changing behavior mid-session. + When Docker exposes the required identity metadata, NemoClaw records the base-image resolution on managed OpenClaw and Hermes sandbox images. During a warm recreate or rebuild, it validates the local image identity and platform, plus the exact repository digest for a published image and any active OpenShell ABI requirement, before reusing it. A valid match avoids candidate discovery and a network pull. @@ -482,6 +502,16 @@ NemoClaw does not guarantee exact build timings. All NemoClaw build arguments (`NEMOCLAW_MODEL`, `NEMOCLAW_PROVIDER_KEY`, `NEMOCLAW_INFERENCE_BASE_URL`, etc.) are injected as `ARG` overrides at build time, so declare them in your Dockerfile if you need to reference them. +Custom Dockerfiles must declare `ARG NEMOCLAW_TOOL_DISCLOSURE=progressive` exactly once in the final build stage and promote it into that stage's runtime environment. +The usual runtime contract is: + +```dockerfile +ARG NEMOCLAW_TOOL_DISCLOSURE=progressive +ENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE} +``` + +Onboarding and rebuild preflight reject a missing, duplicate, or unconsumed declaration before replacing an existing sandbox. + In non-interactive mode, the path can also be supplied via the `NEMOCLAW_FROM_DOCKERFILE` environment variable. You must also supply a sandbox name via `--name ` or `NEMOCLAW_SANDBOX_NAME` so a `--from` build cannot silently clobber the default `my-assistant` sandbox. @@ -1865,17 +1895,19 @@ 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 rebuild preserves the recorded tool-disclosure mode unless `--tool-disclosure` explicitly changes it; it ignores an ambient `NEMOCLAW_TOOL_DISCLOSURE` value while recreating the sandbox. 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. ```bash -$$nemoclaw my-assistant rebuild [--yes|-y|--force] [--verbose|-v] +$$nemoclaw my-assistant rebuild [--yes|-y|--force] [--verbose|-v] [--tool-disclosure ] ``` | Flag | Description | |------|-------------| | `--yes`, `-y`, `--force` | Skip the confirmation prompt | | `--verbose`, `-v` | Log SSH commands, exit codes, and session state (also enabled by `NEMOCLAW_REBUILD_VERBOSE=1`) | +| `--tool-disclosure ` | Change the model-visible tool catalog during this transactional rebuild. Use this path for sandboxes with managed MCP servers so their providers and adapter state are preserved. | If another terminal has an active SSH session to the sandbox, `rebuild` prints an active-session warning and requires confirmation before destroying the sandbox. Pass `--yes`, `-y`, or `--force` to skip the prompt in scripted workflows. @@ -2300,7 +2332,7 @@ The `$$nemoclaw setup` command is deprecated. Use `$$nemoclaw onboard` instead. -This command remains as a compatibility alias to `$$nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents `, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. +This command remains as a compatibility alias to `$$nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents `, `--tool-disclosure `, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. ```bash $$nemoclaw setup @@ -2313,7 +2345,7 @@ The `$$nemoclaw setup-spark` command is deprecated. Use the standard installer and run `$$nemoclaw onboard` instead, because current OpenShell releases handle the older DGX Spark cgroup behavior. -This command remains as a compatibility alias to `$$nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents `, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. +This command remains as a compatibility alias to `$$nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--agents `, `--tool-disclosure `, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. ```bash $$nemoclaw setup-spark @@ -2581,6 +2613,7 @@ Set them before running `$$nemoclaw onboard`. | Variable | Format | Effect | |----------|--------|--------| | `NEMOCLAW_PROVIDER` | provider key (e.g. `build`, `openai`, `anthropic`, `anthropicCompatible`, `gemini`, `ollama`, `custom`, `vllm`, `nim-local`, `routed`, `hermes-provider`, `install-vllm`, `install-ollama`, `install-windows-ollama`, `start-windows-ollama`) | Selects the inference provider during onboarding. The wizard skips the provider menu in both interactive and non-interactive runs when this is set. Aliases: `cloud` → `build`, `nim` → `nim-local`, `hermes` / `nous` / `nous-portal` → `hermes-provider`, `anthropiccompatible` → `anthropicCompatible`. Invalid values fail fast with the list of accepted keys. | +| `NEMOCLAW_TOOL_DISCLOSURE` | `progressive` or `direct` | Selects progressive tool discovery or the prior direct-exposure behavior. Defaults to `progressive`; `--tool-disclosure` takes precedence when both are set. | | `NEMOCLAW_ENDPOINT_URL` | URL | Custom endpoint URL. Used together with `NEMOCLAW_PROVIDER=custom` for OpenAI-compatible endpoints or `NEMOCLAW_PROVIDER=anthropicCompatible` for Anthropic-compatible endpoints. | | `NEMOCLAW_PREFERRED_API` | `completions` (currently the only honored value) | Forces the validation probe to use the `/v1/chat/completions` API path instead of the newer `/v1/responses` API. | | `NEMOCLAW_INFERENCE_INPUTS` | comma-separated list of `text` and/or `image` | Declares model input modalities for vision-capable models. Validated strictly; unknown tokens are ignored. | diff --git a/nemoclaw-blueprint/model-specific-setup/openclaw/nemotron-3-super-120b-managed-inference.json b/nemoclaw-blueprint/model-specific-setup/openclaw/nemotron-3-super-120b-managed-inference.json index dfc0c4f7ff6..d626b620b56 100644 --- a/nemoclaw-blueprint/model-specific-setup/openclaw/nemotron-3-super-120b-managed-inference.json +++ b/nemoclaw-blueprint/model-specific-setup/openclaw/nemotron-3-super-120b-managed-inference.json @@ -2,7 +2,7 @@ "$schema": "../schema.json", "id": "nemotron-3-super-120b-managed-inference", "agent": "openclaw", - "description": "Disables OpenClaw's native code-based tool search for nvidia/nemotron-3-super-120b-a12b on the NemoClaw managed inference.local route. The model emits invalid JavaScript for the tool_search_code surface (CommonJS require, openclaw.tools.search called with an object, bad describe/call ids), flooding successful runs with '[tools] tool_search_code failed' errors (#4780); routing it back to the structured tool-calling surface avoids the noise.", + "description": "Keeps OpenClaw Tool Search disabled for nvidia/nemotron-3-super-120b-a12b on the managed inference.local route. The model generated invalid JavaScript for tool_search_code; boolean false preserves direct structured tool calling until a live trajectory proves search, describe, and call can replace this safeguard. Boolean true would select OpenClaw code mode, not structured mode.", "match": { "modelIds": ["nvidia/nemotron-3-super-120b-a12b"], "providerKey": "inference", diff --git a/nemoclaw-blueprint/model-specific-setup/openclaw/nemotron-3-ultra-managed-inference.json b/nemoclaw-blueprint/model-specific-setup/openclaw/nemotron-3-ultra-managed-inference.json index 1f1397c80f9..e4ec4d1895e 100644 --- a/nemoclaw-blueprint/model-specific-setup/openclaw/nemotron-3-ultra-managed-inference.json +++ b/nemoclaw-blueprint/model-specific-setup/openclaw/nemotron-3-ultra-managed-inference.json @@ -2,9 +2,12 @@ "$schema": "../schema.json", "id": "nemotron-3-ultra-managed-inference", "agent": "openclaw", - "description": "Disables OpenClaw's native code-based tool search for hosted Nemotron 3 Ultra on the NemoClaw managed inference.local route. The model can emit invalid JavaScript for the tool_search_code surface and return '[tools] tool_search_code failed' instead of completing real tool calls; routing it back to the structured tool-calling surface preserves tool use.", + "description": "Keeps OpenClaw Tool Search disabled for hosted Nemotron 3 Ultra on the managed inference.local route. The model generated invalid JavaScript for tool_search_code; boolean false preserves direct structured tool calling until a live trajectory proves search, describe, and call can replace this safeguard. Boolean true would select OpenClaw code mode, not structured mode.", "match": { - "modelIds": ["nvidia/nvidia/nemotron-3-ultra"], + "modelIds": [ + "nvidia/nemotron-3-ultra-550b-a55b", + "nvidia/nvidia/nemotron-3-ultra" + ], "providerKey": "inference", "inferenceApi": "openai-completions", "baseUrl": "https://inference.local/v1" diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index a9876acaeda..13b973d570b 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -13,6 +13,7 @@ // NEMOCLAW_INFERENCE_BASE_URL, NEMOCLAW_INFERENCE_API, // NEMOCLAW_INFERENCE_INPUTS, NEMOCLAW_CONTEXT_WINDOW, // NEMOCLAW_MAX_TOKENS, NEMOCLAW_REASONING, +// NEMOCLAW_TOOL_DISCLOSURE, // NEMOCLAW_AGENT_TIMEOUT, NEMOCLAW_AGENT_HEARTBEAT_EVERY, // NEMOCLAW_INFERENCE_COMPAT_B64, // NEMOCLAW_DISABLE_DEVICE_AUTH, @@ -34,6 +35,7 @@ import { } from "node:fs"; import { dirname, isAbsolute, join, resolve, sep } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { readToolDisclosureEnv } from "../src/lib/tool-disclosure.ts"; type Env = Record; type JsonObject = Record; @@ -402,8 +404,14 @@ function validateSelectedAgentEffects( `${manifestPath}: unknown effects.openclawTools keys: ${unknownToolKeys.join(", ")}`, ); } + // Source: openclaw@2026.5.27 ToolSearchSchema and resolveToolSearchConfig + // (`src/config/zod-schema.agent-runtime.ts`, `src/agents/tool-search.ts`). + // Keep the registry override narrower than the runtime config: false + // disables Tool Search, while true selects its default code bridge. if ("toolSearch" in tools && typeof tools.toolSearch !== "boolean") { - throw new Error(`${manifestPath}: effects.openclawTools.toolSearch must be a boolean`); + throw new Error( + `${manifestPath}: effects.openclawTools.toolSearch must be a boolean override`, + ); } } @@ -1031,6 +1039,7 @@ export function buildConfig(env: Env = process.env): JsonObject { const inferenceApi = env.NEMOCLAW_INFERENCE_API as string; const contextWindow = coercePositiveInt(env, "NEMOCLAW_CONTEXT_WINDOW", 131072); const maxTokens = coercePositiveInt(env, "NEMOCLAW_MAX_TOKENS", 4096); + const toolDisclosure = readToolDisclosureEnv(env); const reasoning = (env.NEMOCLAW_REASONING || "false") === "true"; const inferenceInputs = (env.NEMOCLAW_INFERENCE_INPUTS || "text") @@ -1088,7 +1097,27 @@ export function buildConfig(env: Env = process.env): JsonObject { openclawToolOverrides, ); } - const openclawTools: JsonObject = { toolSearch: true, ...openclawToolOverrides }; + // OpenClaw v2026.5.27 accepts either a boolean shorthand or this object form. + // Model-specific manifests intentionally remain boolean-only and replace this + // value wholesale: false disables Tool Search; true restores upstream code + // mode. Do not shallow-merge a boolean override into the structured object. + const structuredToolSearch: JsonObject = { + mode: "tools", + searchDefaultLimit: 8, + maxSearchLimit: 20, + }; + const openclawTools: JsonObject = { + ...openclawToolOverrides, + // An explicit direct request is authoritative. Compatibility manifests may + // downgrade progressive mode to false, but may never re-enable search over + // a user's direct selection. + toolSearch: + toolDisclosure === "direct" + ? false + : "toolSearch" in openclawToolOverrides + ? openclawToolOverrides.toolSearch + : structuredToolSearch, + }; if (providerKey === "ollama" || providerKey === "ollama-local") { inferenceCompat.supportsUsageInStreaming ??= true; diff --git a/scripts/validate-openclaw-tool-search.mts b/scripts/validate-openclaw-tool-search.mts new file mode 100755 index 00000000000..418ef4b1afc --- /dev/null +++ b/scripts/validate-openclaw-tool-search.mts @@ -0,0 +1,609 @@ +#!/usr/bin/env -S node --experimental-strip-types +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { isDeepStrictEqual } from "node:util"; + +const RUNTIME_FUNCTION_NAMES = [ + "resolveToolSearchConfig", + "createOpenClawCodingTools", + "applyToolSearchCatalog", +] as const; +type RuntimeFunctionName = (typeof RUNTIME_FUNCTION_NAMES)[number]; +type ExpectedMode = "progressive" | "direct"; +interface JsonRecord { + [key: string]: unknown; +} + +interface RuntimeCandidate { + filePath: string; + source: string; +} + +interface CatalogRef { + current?: unknown; +} + +type ToolExecute = ( + toolCallId: string, + args: JsonRecord, + signal?: AbortSignal, + onUpdate?: unknown, +) => unknown | Promise; + +interface Tool { + name: string; + label?: string; + description?: string; + parameters?: JsonRecord; + execute: ToolExecute; +} + +interface ToolResult extends JsonRecord { + content?: unknown; + details?: unknown; +} + +interface RuntimeToolConstructionPlan { + includeBaseCodingTools: false; + includeShellTools: false; + includeChannelTools: false; + includeOpenClawTools: false; + includePluginTools: false; +} + +interface RuntimeToolOptions { + config: JsonRecord; + workspaceDir: string; + includeCoreTools: false; + includeToolSearchControls: true; + toolSearchCatalogRef: CatalogRef; + runId: string; + sessionId: string; + toolConstructionPlan: RuntimeToolConstructionPlan; +} + +interface CatalogParams { + config: JsonRecord; + tools: Tool[]; + catalogRef: CatalogRef; + runId: string; + sessionId: string; +} + +type ResolveToolSearchConfig = (config: JsonRecord) => unknown; +type CreateOpenClawCodingTools = (options: RuntimeToolOptions) => unknown; +type ApplyToolSearchCatalog = (params: CatalogParams) => unknown; + +interface RuntimeFunctions { + resolveToolSearchConfig: ResolveToolSearchConfig; + createOpenClawCodingTools: CreateOpenClawCodingTools; + applyToolSearchCatalog: ApplyToolSearchCatalog; +} + +interface ValidationOptions { + distDir: string; + configPath: string; + expectedMode: string; + expectedVersion: string; +} + +interface ValidationResult { + version: string; + expectedMode: ExpectedMode; + runtimeModulePath: string; + visibleToolNames: string[]; +} +const STRUCTURED_TOOL_SEARCH = { + mode: "tools", + searchDefaultLimit: 8, + maxSearchLimit: 20, +}; +const STRUCTURED_CONTROL_NAMES = ["tool_call", "tool_describe", "tool_search"]; +const ALL_CONTROL_NAMES = new Set([...STRUCTURED_CONTROL_NAMES, "tool_search_code"]); +const PROBE_NAME = "nemoclaw_runtime_validator_probe"; +const PROBE_SENTINEL = "NEMOCLAW_OPENCLAW_TOOL_SEARCH_RUNTIME_OK"; +let importSequence = 0; + +function fail(message: string): never { + throw new Error(`OpenClaw Tool Search runtime validation failed: ${message}`); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isRuntimeFunctionName(value: string): value is RuntimeFunctionName { + return (RUNTIME_FUNCTION_NAMES as readonly string[]).includes(value); +} + +function readJson(filePath: string, label: string): JsonRecord { + let text: string; + try { + text = fs.readFileSync(filePath, "utf8"); + } catch (error) { + fail(`could not read ${label} at ${filePath}: ${errorMessage(error)}`); + } + + let value: unknown; + try { + value = JSON.parse(text) as unknown; + } catch (error) { + fail(`could not parse ${label} at ${filePath}: ${errorMessage(error)}`); + } + if (!isRecord(value)) fail(`${label} at ${filePath} must contain a JSON object`); + return value; +} + +function countFunctionDeclarations(source: string, functionName: RuntimeFunctionName): number { + const escapedName = functionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return [...source.matchAll(new RegExp(`\\bfunction\\s+${escapedName}\\s*\\(`, "g"))].length; +} + +function readRuntimeCandidates(distDir: string): RuntimeCandidate[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(distDir, { withFileTypes: true }); + } catch (error) { + fail(`could not read OpenClaw dist directory ${distDir}: ${errorMessage(error)}`); + } + + const candidates: RuntimeCandidate[] = []; + for (const entry of entries) { + if (!entry.isFile() || !/^pi-tools-.*\.js$/.test(entry.name)) continue; + const filePath = path.join(distDir, entry.name); + let source: string; + try { + source = fs.readFileSync(filePath, "utf8"); + } catch (error) { + fail(`could not read compiled runtime candidate ${filePath}: ${errorMessage(error)}`); + } + if (RUNTIME_FUNCTION_NAMES.every((name) => source.includes(`function ${name}`))) { + candidates.push({ filePath, source }); + } + } + return candidates; +} + +function locateRuntimeModule(distDir: string): RuntimeCandidate { + const candidates = readRuntimeCandidates(distDir); + if (candidates.length !== 1) { + fail( + `expected exactly one pi-tools-*.js module containing ${RUNTIME_FUNCTION_NAMES.join( + ", ", + )}; found ${candidates.length}`, + ); + } + const candidate = candidates[0]; + if (!candidate) fail("compiled runtime candidate disappeared after cardinality check"); + for (const functionName of RUNTIME_FUNCTION_NAMES) { + const count = countFunctionDeclarations(candidate.source, functionName); + if (count !== 1) { + fail( + `${candidate.filePath} must declare compiled function ${functionName} exactly once; found ${count}`, + ); + } + } + return candidate; +} + +function parseRuntimeExportAliases( + source: string, + filePath: string, +): Map { + const aliases = new Map(); + const exportBlocks = [...source.matchAll(/\bexport\s*\{([\s\S]*?)\}\s*;?/g)]; + for (const block of exportBlocks) { + const blockBody = block[1]; + if (blockBody === undefined) continue; + for (const rawEntry of blockBody.split(",")) { + const entry = rawEntry.trim(); + if (!entry) continue; + const match = entry.match( + /^([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?$/, + ); + if (!match) continue; + const localName = match[1]; + if (localName === undefined || !isRuntimeFunctionName(localName)) continue; + if (aliases.has(localName)) { + fail(`${filePath} exports compiled function ${localName} more than once`); + } + aliases.set(localName, match[2] ?? localName); + } + } + + for (const functionName of RUNTIME_FUNCTION_NAMES) { + if (!aliases.has(functionName)) { + fail(`${filePath} does not export compiled function ${functionName}`); + } + } + if (new Set(aliases.values()).size !== RUNTIME_FUNCTION_NAMES.length) { + fail(`${filePath} reuses an export alias across required compiled functions`); + } + return aliases; +} + +function requiredAlias( + aliases: ReadonlyMap, + functionName: RuntimeFunctionName, + filePath: string, +): string { + const alias = aliases.get(functionName); + if (alias === undefined) fail(`${filePath} does not export compiled function ${functionName}`); + return alias; +} + +async function importRuntimeFunctions( + filePath: string, + aliases: ReadonlyMap, +): Promise { + const moduleUrl = pathToFileURL(filePath); + moduleUrl.searchParams.set( + "nemoclaw_tool_search_validator", + `${process.pid}-${Date.now()}-${importSequence++}`, + ); + + let runtimeModule: JsonRecord; + try { + const loaded: unknown = await import(moduleUrl.href); + if (!isRecord(loaded)) fail(`compiled runtime ${filePath} did not export a module object`); + runtimeModule = loaded; + } catch (error) { + fail(`could not import compiled runtime ${filePath}: ${errorMessage(error)}`); + } + + const runtimeExports = new Map unknown>(); + for (const functionName of RUNTIME_FUNCTION_NAMES) { + const exportName = requiredAlias(aliases, functionName, filePath); + const value = runtimeModule[exportName]; + if (typeof value !== "function") { + fail(`${filePath} export ${exportName} for ${functionName} is not a function`); + } + runtimeExports.set(functionName, value as (...args: never[]) => unknown); + } + return { + resolveToolSearchConfig: runtimeExports.get( + "resolveToolSearchConfig", + ) as ResolveToolSearchConfig, + createOpenClawCodingTools: runtimeExports.get( + "createOpenClawCodingTools", + ) as CreateOpenClawCodingTools, + applyToolSearchCatalog: runtimeExports.get("applyToolSearchCatalog") as ApplyToolSearchCatalog, + }; +} + +function assertExpectedVersion(distDir: string, expectedVersion: string): string { + const packagePath = path.resolve(distDir, "..", "package.json"); + const packageJson = readJson(packagePath, "OpenClaw package metadata"); + if (packageJson.version !== expectedVersion) { + fail( + `OpenClaw version mismatch at ${packagePath}: expected ${expectedVersion}, found ${String( + packageJson.version, + )}`, + ); + } + return packageJson.version; +} + +function readToolSearchConfig( + config: JsonRecord, + expectedMode: ExpectedMode, + configPath: string, +): void { + const tools = config.tools; + if (!isRecord(tools)) fail(`generated config ${configPath} is missing object tools`); + const toolSearch = tools.toolSearch; + if (expectedMode === "progressive") { + if (!isDeepStrictEqual(toolSearch, STRUCTURED_TOOL_SEARCH)) { + fail( + `generated config ${configPath} must set tools.toolSearch to exactly ${JSON.stringify( + STRUCTURED_TOOL_SEARCH, + )} for progressive mode; found ${JSON.stringify(toolSearch)}`, + ); + } + } else if (toolSearch !== false) { + fail( + `generated config ${configPath} must set tools.toolSearch to false for direct mode; found ${JSON.stringify( + toolSearch, + )}`, + ); + } +} + +function assertResolvedConfig( + resolveToolSearchConfig: ResolveToolSearchConfig, + config: JsonRecord, + expectedMode: ExpectedMode, +): void { + const resolved = resolveToolSearchConfig(config); + if (!isRecord(resolved)) fail("resolveToolSearchConfig did not return an object"); + if (expectedMode === "progressive") { + const expected = { + enabled: true, + mode: "tools", + searchDefaultLimit: 8, + maxSearchLimit: 20, + }; + for (const [key, value] of Object.entries(expected)) { + if (resolved[key] !== value) { + fail(`resolved progressive Tool Search ${key} must be ${JSON.stringify(value)}`); + } + } + } else if (resolved.enabled !== false) { + fail("resolved direct Tool Search must be disabled"); + } +} + +function createProbeTool(): Tool { + return { + name: PROBE_NAME, + label: "NemoClaw runtime validator probe", + description: "A deterministic hidden probe for the NemoClaw Tool Search runtime validator.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + value: { type: "string", description: "Deterministic proof input." }, + }, + required: ["value"], + }, + execute: async (_toolCallId: string, args: JsonRecord) => ({ + content: [{ type: "text", text: `${PROBE_SENTINEL}:${args?.value ?? ""}` }], + details: { sentinel: PROBE_SENTINEL, value: args?.value ?? null }, + }), + }; +} + +function readToolResultPayload(result: unknown, toolName: string): unknown { + if (!isRecord(result)) fail(`${toolName} returned a non-object result`); + const toolResult: ToolResult = result; + if (toolResult.details !== undefined) { + return toolResult.details; + } + const content = Array.isArray(toolResult.content) ? toolResult.content : []; + const textPart = content.find( + (entry): entry is JsonRecord & { type: "text"; text: string } => + isRecord(entry) && entry.type === "text" && typeof entry.text === "string", + ); + if (!textPart) fail(`${toolName} returned no JSON text or details payload`); + try { + return JSON.parse(textPart.text) as unknown; + } catch (error) { + fail(`${toolName} returned invalid JSON text: ${errorMessage(error)}`); + } +} + +function isTool(value: unknown): value is Tool { + return isRecord(value) && typeof value.name === "string" && typeof value.execute === "function"; +} + +function assertExactToolNames( + tools: unknown, + expectedNames: readonly string[], + label: string, +): Tool[] { + if (!Array.isArray(tools)) fail(`${label} must be an array`); + if (!tools.every(isTool)) fail(`${label} contains a non-executable or unnamed tool`); + const names = tools.map((tool) => tool.name); + const sortedNames = [...names].sort(); + if (!isDeepStrictEqual(sortedNames, [...expectedNames].sort())) { + fail(`${label} names must be ${expectedNames.join(", ")}; found ${sortedNames.join(", ")}`); + } + return tools; +} + +function toolByName(tools: readonly Tool[], name: string): Tool { + const matches = tools.filter((tool) => tool.name === name); + const match = matches[0]; + if (matches.length !== 1 || match === undefined) { + fail(`expected exactly one executable ${name} control; found ${matches.length}`); + } + return match; +} + +function createControls( + createOpenClawCodingTools: CreateOpenClawCodingTools, + config: JsonRecord, + catalogRef: CatalogRef, + runId: string, +): Tool[] { + const controls = createOpenClawCodingTools({ + config, + workspaceDir: process.cwd(), + includeCoreTools: false, + includeToolSearchControls: true, + toolSearchCatalogRef: catalogRef, + runId, + sessionId: runId, + toolConstructionPlan: { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: false, + includeOpenClawTools: false, + includePluginTools: false, + }, + }); + if (!Array.isArray(controls) || !controls.every(isTool)) { + fail("createOpenClawCodingTools did not return executable named tools"); + } + const unexpected = controls.filter((tool) => !ALL_CONTROL_NAMES.has(tool.name)); + if (unexpected.length > 0) { + fail("control-only createOpenClawCodingTools call returned a non-Tool-Search tool"); + } + return controls; +} + +async function validateProgressiveRuntime( + runtime: RuntimeFunctions, + config: JsonRecord, +): Promise { + const catalogRef: CatalogRef = {}; + const runId = `nemoclaw-tool-search-validator-${process.pid}-${Date.now()}-${importSequence}`; + const controls = createControls(runtime.createOpenClawCodingTools, config, catalogRef, runId); + const probe = createProbeTool(); + const compacted = runtime.applyToolSearchCatalog({ + config, + tools: [...controls, probe], + catalogRef, + runId, + sessionId: runId, + }); + if (!isRecord(compacted)) fail("applyToolSearchCatalog did not return an object"); + const visibleTools = assertExactToolNames( + compacted.tools, + STRUCTURED_CONTROL_NAMES, + "progressive model-visible tools", + ); + if ( + compacted.compacted !== true || + compacted.catalogToolCount !== 1 || + compacted.catalogRegistered !== true + ) { + fail("progressive catalog did not compact and register exactly one hidden probe"); + } + + const search = toolByName(visibleTools, "tool_search"); + const describe = toolByName(visibleTools, "tool_describe"); + const call = toolByName(visibleTools, "tool_call"); + const searchPayload = readToolResultPayload( + await search.execute("nemoclaw-validator-search", { query: PROBE_NAME, limit: 8 }), + "tool_search", + ); + if (!Array.isArray(searchPayload)) fail("tool_search payload must be an array"); + const hit = searchPayload.find((entry) => isRecord(entry) && entry.name === PROBE_NAME); + if (!hit || typeof hit.id !== "string") fail("tool_search did not discover the hidden probe"); + + const described = readToolResultPayload( + await describe.execute("nemoclaw-validator-describe", { id: hit.id }), + "tool_describe", + ); + if (!isRecord(described) || described.name !== PROBE_NAME) { + fail("tool_describe did not return the hidden probe schema"); + } + + const callPayload = readToolResultPayload( + await call.execute("nemoclaw-validator-call", { + id: hit.id, + args: { value: "progressive" }, + }), + "tool_call", + ); + if ( + !isRecord(callPayload) || + !isRecord(callPayload.tool) || + callPayload.tool.name !== PROBE_NAME || + !isRecord(callPayload.result) || + !isRecord(callPayload.result.details) || + callPayload.result.details.sentinel !== PROBE_SENTINEL || + callPayload.result.details.value !== "progressive" + ) { + fail("tool_call did not execute the hidden deterministic probe"); + } + return visibleTools.map((tool) => tool.name); +} + +async function validateDirectRuntime( + runtime: RuntimeFunctions, + config: JsonRecord, +): Promise { + const catalogRef: CatalogRef = {}; + const runId = `nemoclaw-tool-search-validator-direct-${process.pid}-${Date.now()}-${importSequence}`; + const controls = createControls(runtime.createOpenClawCodingTools, config, catalogRef, runId); + assertExactToolNames(controls, [], "direct Tool Search controls"); + const probe = createProbeTool(); + const direct = runtime.applyToolSearchCatalog({ + config, + tools: [probe], + catalogRef, + runId, + sessionId: runId, + }); + if (!isRecord(direct)) fail("applyToolSearchCatalog did not return an object"); + const visibleTools = assertExactToolNames( + direct.tools, + [PROBE_NAME], + "direct model-visible tools", + ); + if (direct.compacted !== false || direct.catalogToolCount !== 0) { + fail("direct mode unexpectedly compacted the hidden probe"); + } + const directProbe = visibleTools[0]; + if (directProbe === undefined) fail("direct probe disappeared after cardinality check"); + const proof = await directProbe.execute("nemoclaw-validator-direct", { value: "direct" }); + if (!isRecord(proof) || !isRecord(proof.details) || proof.details.sentinel !== PROBE_SENTINEL) { + fail("direct mode did not preserve executable direct tool exposure"); + } + return visibleTools.map((tool) => tool.name); +} + +export async function validateOpenClawToolSearchRuntime({ + distDir, + configPath, + expectedMode, + expectedVersion, +}: ValidationOptions): Promise { + if (expectedMode !== "progressive" && expectedMode !== "direct") { + fail(`expected mode must be progressive or direct; found ${String(expectedMode)}`); + } + const validatedMode: ExpectedMode = expectedMode; + if (typeof expectedVersion !== "string" || expectedVersion.trim() === "") { + fail("expected version must be a non-empty string"); + } + const resolvedDist = path.resolve(distDir); + const resolvedConfigPath = path.resolve(configPath); + const version = assertExpectedVersion(resolvedDist, expectedVersion); + const config = readJson(resolvedConfigPath, "generated OpenClaw config"); + readToolSearchConfig(config, validatedMode, resolvedConfigPath); + const { filePath, source } = locateRuntimeModule(resolvedDist); + const aliases = parseRuntimeExportAliases(source, filePath); + const runtime = await importRuntimeFunctions(filePath, aliases); + assertResolvedConfig(runtime.resolveToolSearchConfig, config, validatedMode); + const visibleToolNames = + validatedMode === "progressive" + ? await validateProgressiveRuntime(runtime, config) + : await validateDirectRuntime(runtime, config); + return { version, expectedMode: validatedMode, runtimeModulePath: filePath, visibleToolNames }; +} + +function usage(): string { + return "Usage: validate-openclaw-tool-search.mts "; +} + +async function main(argv: readonly string[]): Promise { + if (argv.length !== 4) fail(usage()); + const [distDir, configPath, expectedMode, expectedVersion] = argv; + if ( + distDir === undefined || + configPath === undefined || + expectedMode === undefined || + expectedVersion === undefined + ) { + fail(usage()); + } + const result = await validateOpenClawToolSearchRuntime({ + distDir, + configPath, + expectedMode, + expectedVersion, + }); + console.log( + `Validated OpenClaw ${result.version} Tool Search ${result.expectedMode} runtime: ${result.visibleToolNames.join( + ", ", + )}`, + ); +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null; +if (invokedPath === import.meta.url) { + main(process.argv.slice(2)).catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/src/commands/sandbox/oclif-command-adapters.test.ts b/src/commands/sandbox/oclif-command-adapters.test.ts index 230b43a3bc8..8bab85dff9f 100644 --- a/src/commands/sandbox/oclif-command-adapters.test.ts +++ b/src/commands/sandbox/oclif-command-adapters.test.ts @@ -118,13 +118,17 @@ describe("sandbox oclif command adapters", () => { try { await ConnectCliCommand.run(["alpha", "--probe-only"], rootDir); await DestroyCliCommand.run(["alpha", "--yes"], rootDir); - await RebuildCliCommand.run(["alpha", "--force", "--verbose"], rootDir); + await RebuildCliCommand.run( + ["alpha", "--force", "--verbose", "--tool-disclosure", "direct"], + rootDir, + ); await GatewayRestartCliCommand.run(["alpha", "--quiet"], rootDir); expect(mocks.connectSandbox).toHaveBeenCalledWith("alpha", { probeOnly: true }); expect(mocks.destroySandbox).toHaveBeenCalledWith("alpha", { force: false, yes: true }); expect(mocks.rebuildSandbox).toHaveBeenCalledWith("alpha", { force: true, + toolDisclosure: "direct", verbose: true, yes: false, }); @@ -203,6 +207,7 @@ describe("sandbox oclif command adapters", () => { expect(RecoverCliCommand.summary).not.toMatch(/^Restart\b/); expect(RebuildCliCommand.id).toBe("sandbox:rebuild"); expect(usage(RebuildCliCommand)).toContain("[--yes|-y|--force]"); + expect(usage(RebuildCliCommand)).toContain("[--tool-disclosure ]"); expect(SandboxPolicyListCommand.id).toBe("sandbox:policy:list"); expect(SandboxChannelsListCommand.id).toBe("sandbox:channels:list"); expect(SandboxConfigGetCommand.id).toBe("sandbox:config:get"); diff --git a/src/commands/sandbox/rebuild.ts b/src/commands/sandbox/rebuild.ts index 8ea6d59abcc..55db741e0e5 100644 --- a/src/commands/sandbox/rebuild.ts +++ b/src/commands/sandbox/rebuild.ts @@ -6,16 +6,20 @@ import { Args, Flags } from "@oclif/core"; import { rebuildSandbox } from "../../lib/actions/sandbox/rebuild"; import { forceFlag, yesFlag } from "../../lib/cli/common-flags"; import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; +import { TOOL_DISCLOSURE_VALUES, type ToolDisclosure } from "../../lib/tool-disclosure"; export default class RebuildCliCommand extends NemoClawCommand { static id = "sandbox:rebuild"; static strict = true; static summary = "Upgrade sandbox to current agent version"; static description = "Back up, recreate, and restore a sandbox using the current agent image."; - static usage = [" [--yes|-y|--force] [--verbose|-v]"]; + static usage = [ + " [--yes|-y|--force] [--verbose|-v] [--tool-disclosure ]", + ]; static examples = [ "<%= config.bin %> sandbox rebuild alpha", "<%= config.bin %> sandbox rebuild alpha --yes --verbose", + "<%= config.bin %> sandbox rebuild alpha --yes --tool-disclosure direct", ]; static args = { sandboxName: Args.string({ name: "sandbox", description: "Sandbox name", required: true }), @@ -24,12 +28,17 @@ export default class RebuildCliCommand extends NemoClawCommand { yes: yesFlag(), force: forceFlag(), verbose: Flags.boolean({ char: "v", description: "Show verbose rebuild diagnostics" }), + "tool-disclosure": Flags.string({ + description: "Change the sandbox tool-disclosure mode during the transactional rebuild", + options: [...TOOL_DISCLOSURE_VALUES], + }), }; public async run(): Promise { const { args, flags } = await this.parse(RebuildCliCommand); await rebuildSandbox(args.sandboxName, { force: flags.force === true, + toolDisclosure: (flags["tool-disclosure"] as ToolDisclosure | undefined) ?? undefined, verbose: flags.verbose === true, yes: flags.yes === true, }); diff --git a/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts b/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts index 8875d4b75e8..023027c34ff 100644 --- a/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts +++ b/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts @@ -7,7 +7,21 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { ROOT } from "../../runner"; -import { preflightRebuildImage } from "./rebuild-custom-image-preflight"; +import { + preflightRebuildImage, + type RebuildImagePreflightResult, +} from "./rebuild-custom-image-preflight"; +import { + disposePreparedBuildContext, + verifyPreparedBuildContext, +} from "./rebuild-prepared-image-context"; + +type SuccessfulPreflight = Extract; + +function successful(result: RebuildImagePreflightResult): SuccessfulPreflight { + expect(result.ok).toBe(true); + return result as SuccessfulPreflight; +} function input(fromDockerfile: string | null) { return { @@ -18,6 +32,7 @@ function input(fromDockerfile: string | null) { preferredInferenceApi: null, compatibleEndpointReasoning: null, webSearchConfig: null, + toolDisclosure: "progressive" as const, hermesToolGateways: [], sandboxGpuConfig: { mode: "0" as const, @@ -34,29 +49,86 @@ function input(fromDockerfile: string | null) { describe("preflightRebuildImage", () => { it("prebuilds the managed OpenClaw image instead of deferring its first build until delete", async () => { + const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-preflight-")); + const stagedDockerfile = path.join(buildCtx, "Dockerfile"); + fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); const buildImage = vi.fn(() => ({ status: 0 }) as never); - const cleanupBuildCtx = vi.fn(() => true); + const cleanupBuildCtx = vi.fn(() => { + fs.rmSync(buildCtx, { recursive: true, force: true }); + return true; + }); const stageBuildContext = vi.fn(() => ({ - buildCtx: "/tmp/rebuild-managed-context", - stagedDockerfile: "/tmp/rebuild-managed-context/Dockerfile", + buildCtx, + stagedDockerfile, cleanupBuildCtx, origin: "generated" as const, })); - const result = await preflightRebuildImage(input(null), { - stageBuildContext, - prepareDockerfilePatch: vi.fn(async () => ({ buildId: "1", resolvedBaseImage: null })), - buildImage, - removeImage: vi.fn(), - }); + try { + const result = successful( + await preflightRebuildImage(input(null), { + stageBuildContext, + prepareDockerfilePatch: vi.fn(async () => ({ buildId: "1", resolvedBaseImage: null })), + buildImage, + removeImage: vi.fn(() => ({ status: 0 }) as never), + }), + ); - expect(result.ok).toBe(true); - expect(stageBuildContext).toHaveBeenCalledWith( - expect.objectContaining({ root: ROOT, agent: null }), - ); - expect(buildImage).toHaveBeenCalledOnce(); - expect(cleanupBuildCtx).toHaveBeenCalledOnce(); + expect(stageBuildContext).toHaveBeenCalledWith( + expect.objectContaining({ root: ROOT, agent: null }), + ); + expect(buildImage).toHaveBeenCalledOnce(); + expect(cleanupBuildCtx).not.toHaveBeenCalled(); + expect(verifyPreparedBuildContext(result.prepared)).toBe(true); + expect(disposePreparedBuildContext(result.prepared)).toBe(true); + expect(cleanupBuildCtx).toHaveBeenCalledOnce(); + } finally { + fs.rmSync(buildCtx, { recursive: true, force: true }); + } }); + it.runIf(process.platform !== "win32")( + "rejects a symlinked build-context root before the preflight build", + async () => { + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preflight-root-link-")); + const targetBuildCtx = path.join(testRoot, "target"); + const linkedBuildCtx = path.join(testRoot, "context"); + fs.mkdirSync(targetBuildCtx); + fs.writeFileSync(path.join(targetBuildCtx, "Dockerfile"), "FROM scratch\n"); + fs.symlinkSync(targetBuildCtx, linkedBuildCtx, "dir"); + const cleanupBuildCtx = vi.fn(() => { + fs.rmSync(linkedBuildCtx, { force: true }); + return true; + }); + const buildImage = vi.fn(() => ({ status: 0 }) as never); + + try { + await expect( + preflightRebuildImage(input(null), { + stageBuildContext: vi.fn(() => ({ + buildCtx: linkedBuildCtx, + stagedDockerfile: path.join(linkedBuildCtx, "Dockerfile"), + cleanupBuildCtx, + origin: "generated" as const, + })), + prepareDockerfilePatch: vi.fn(async () => ({ + buildId: "root-link", + resolvedBaseImage: null, + })), + buildImage, + removeImage: vi.fn(() => ({ status: 0 }) as never), + }), + ).resolves.toEqual({ + ok: false, + detail: "build-context root must be a real directory", + }); + expect(buildImage).not.toHaveBeenCalled(); + expect(cleanupBuildCtx).toHaveBeenCalledOnce(); + } finally { + fs.rmSync(testRoot, { recursive: true, force: true }); + } + }, + ); + it.each([ ["malformed syntax", "THIS IS NOT A DOCKERFILE"], ["missing COPY context", "FROM scratch\nCOPY missing.txt /missing.txt\n"], @@ -64,7 +136,7 @@ describe("preflightRebuildImage", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-custom-preflight-")); const dockerfile = path.join(dir, "Dockerfile.custom"); fs.writeFileSync(dockerfile, dockerfileContents); - const removeImage = vi.fn(); + const removeImage = vi.fn(() => ({ status: 0 }) as never); try { const result = await preflightRebuildImage(input(dockerfile), { prepareDockerfilePatch: vi.fn(async () => ({ buildId: "1", resolvedBaseImage: null })), @@ -83,14 +155,15 @@ describe("preflightRebuildImage", () => { const dockerfile = path.join(dir, "Dockerfile.custom"); fs.writeFileSync(dockerfile, "FROM scratch\n"); const buildImage = vi.fn(() => ({ status: 0 }) as never); - const removeImage = vi.fn(); + const removeImage = vi.fn(() => ({ status: 0 }) as never); try { - const result = await preflightRebuildImage(input(dockerfile), { - prepareDockerfilePatch: vi.fn(async () => ({ buildId: "1", resolvedBaseImage: null })), - buildImage, - removeImage, - }); - expect(result.ok).toBe(true); + const result = successful( + await preflightRebuildImage(input(dockerfile), { + prepareDockerfilePatch: vi.fn(async () => ({ buildId: "1", resolvedBaseImage: null })), + buildImage, + removeImage, + }), + ); expect(buildImage).toHaveBeenCalledWith( expect.stringContaining("Dockerfile"), expect.stringMatching(/^nemoclaw-rebuild-preflight:/), @@ -98,7 +171,86 @@ describe("preflightRebuildImage", () => { expect.objectContaining({ ignoreError: true }), ); expect(removeImage).toHaveBeenCalledOnce(); + expect(fs.existsSync(result.prepared.buildCtx)).toBe(true); + expect(disposePreparedBuildContext(result.prepared)).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("pins a symlinked Dockerfile before the source link can be swapped", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-custom-preflight-link-")); + const dockerfile = path.join(dir, "Dockerfile"); + fs.writeFileSync(path.join(dir, "Dockerfile.safe"), "FROM scratch\n# safe\n"); + fs.writeFileSync(path.join(dir, "Dockerfile.changed"), "FROM scratch\n# changed\n"); + fs.symlinkSync("Dockerfile.safe", dockerfile); + const builtDockerfiles: string[] = []; + try { + const result = successful( + await preflightRebuildImage(input(dockerfile), { + prepareDockerfilePatch: vi.fn(async () => ({ buildId: "1", resolvedBaseImage: null })), + buildImage: vi.fn((stagedDockerfile) => { + builtDockerfiles.push(fs.readFileSync(stagedDockerfile, "utf8")); + return { status: 0 } as never; + }), + removeImage: vi.fn(() => ({ status: 0 }) as never), + }), + ); + + fs.unlinkSync(dockerfile); + fs.symlinkSync("Dockerfile.changed", dockerfile); + + expect(builtDockerfiles).toEqual(["FROM scratch\n# safe\n"]); + const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; + const stagedFd = fs.openSync( + result.prepared.stagedDockerfile, + fs.constants.O_RDONLY | noFollow, + ); + try { + expect(fs.fstatSync(stagedFd).isFile()).toBe(true); + expect(fs.readFileSync(stagedFd, "utf8")).toBe("FROM scratch\n# safe\n"); + } finally { + fs.closeSync(stagedFd); + } + expect(verifyPreparedBuildContext(result.prepared)).toBe(true); + expect(disposePreparedBuildContext(result.prepared)).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("warns and retries at process exit when a built preflight image cannot be removed", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-custom-preflight-cleanup-")); + const dockerfile = path.join(dir, "Dockerfile.custom"); + fs.writeFileSync(dockerfile, "FROM scratch\n"); + const removeImage = vi + .fn() + .mockReturnValueOnce({ status: 1 } as never) + .mockReturnValueOnce({ status: 0 } as never); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const processOnce = vi.spyOn(process, "once").mockImplementation((event, listener) => { + expect(event).toBe("exit"); + listener(0); + return process; + }); + try { + const result = successful( + await preflightRebuildImage(input(dockerfile), { + prepareDockerfilePatch: vi.fn(async () => ({ buildId: "1", resolvedBaseImage: null })), + buildImage: vi.fn(() => ({ status: 0 }) as never), + removeImage, + }), + ); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("failed to remove temporary rebuild preflight image"), + ); + expect(processOnce).toHaveBeenCalledWith("exit", expect.any(Function)); + expect(removeImage).toHaveBeenCalledTimes(2); + expect(disposePreparedBuildContext(result.prepared)).toBe(true); } finally { + processOnce.mockRestore(); + warn.mockRestore(); fs.rmSync(dir, { recursive: true, force: true }); } }); diff --git a/src/lib/actions/sandbox/rebuild-custom-image-preflight.ts b/src/lib/actions/sandbox/rebuild-custom-image-preflight.ts index f1ee9c0e9b8..8eb3dbba4c1 100644 --- a/src/lib/actions/sandbox/rebuild-custom-image-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-custom-image-preflight.ts @@ -1,7 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import path from "node:path"; + import { dockerBuild, dockerRmi } from "../../adapters/docker"; +import { fingerprintBuildContext } from "../../adapters/fs/build-context-fingerprint"; import type { AgentDefinition } from "../../agent/defs"; import { createAgentSandbox } from "../../agent/onboard"; import type { WebSearchConfig } from "../../inference/web-search"; @@ -10,6 +13,12 @@ import { prepareSandboxDockerfilePatch } from "../../onboard/sandbox-dockerfile- import type { SandboxGpuConfig } from "../../onboard/sandbox-gpu-mode"; import { ROOT } from "../../runner"; import { OPENCLAW_SANDBOX_BASE_IMAGE, SANDBOX_BASE_TAG } from "../../sandbox-base-image"; +import type { ToolDisclosure } from "../../tool-disclosure"; +import { + createBuildContextVerifier, + createIdempotentBuildContextCleanup, + type FingerprintedPreparedBuildContext, +} from "./rebuild-prepared-image-context"; type PreflightInput = { agent: AgentDefinition | null; @@ -19,6 +28,7 @@ type PreflightInput = { preferredInferenceApi: string | null; compatibleEndpointReasoning: "true" | "false" | null; webSearchConfig: WebSearchConfig | null; + toolDisclosure: ToolDisclosure; hermesToolGateways: string[]; sandboxGpuConfig: SandboxGpuConfig; gatewayPort: number; @@ -32,8 +42,15 @@ type PreflightDeps = { removeImage?: typeof dockerRmi; }; +export type PreparedRebuildImage = FingerprintedPreparedBuildContext & { + rebuildTarget: { + agentName: string | null; + fromDockerfile: string | null; + }; +}; + export type RebuildImagePreflightResult = - | { ok: true; imageTag: string | null } + | { ok: true; imageTag: string; prepared: PreparedRebuildImage } | { ok: false; detail: string }; function resultDetail(result: { stderr?: unknown; stdout?: unknown; status?: unknown }): string { @@ -54,6 +71,8 @@ export async function preflightRebuildImage( const removeImage = deps.removeImage ?? dockerRmi; let cleanup: (() => boolean) | null = null; let imageTag: string | null = null; + let imageBuilt = false; + let retainBuildContext = false; const previousReasoning = process.env.NEMOCLAW_REASONING; try { if (input.provider === "compatible-endpoint") { @@ -73,8 +92,8 @@ export async function preflightRebuildImage( throw new Error(`custom build-context staging exited with code ${String(code ?? 1)}`); }, }); - cleanup = staged.cleanupBuildCtx; - await preparePatch({ + cleanup = createIdempotentBuildContextCleanup(staged.cleanupBuildCtx); + const { buildId } = await preparePatch({ agent: input.agent, fromDockerfile: input.fromDockerfile, sandboxBaseImage: OPENCLAW_SANDBOX_BASE_IMAGE, @@ -85,26 +104,72 @@ export async function preflightRebuildImage( provider: input.provider, preferredInferenceApi: input.preferredInferenceApi, webSearchConfig: input.webSearchConfig, + toolDisclosure: input.toolDisclosure, hermesToolGateways: input.hermesToolGateways, sandboxGpuConfig: input.sandboxGpuConfig, gatewayPort: input.gatewayPort, log: () => {}, warn: () => {}, }); + const contextFingerprint = fingerprintBuildContext(staged.buildCtx); imageTag = `nemoclaw-rebuild-preflight:${String(process.pid)}-${String(Date.now())}`; const result = buildImage(staged.stagedDockerfile, imageTag, staged.buildCtx, { ignoreError: true, suppressOutput: true, stdio: ["ignore", "pipe", "pipe"], }); - return result.status === 0 - ? { ok: true, imageTag } - : { ok: false, detail: resultDetail(result) }; + if (result.status !== 0) return { ok: false, detail: resultDetail(result) }; + imageBuilt = true; + if (fingerprintBuildContext(staged.buildCtx) !== contextFingerprint) { + return { ok: false, detail: "replacement build context changed during preflight" }; + } + retainBuildContext = true; + return { + ok: true, + imageTag, + prepared: { + ...staged, + cleanupBuildCtx: cleanup, + buildId, + contextFingerprint, + verifyBuildCtx: createBuildContextVerifier(staged.buildCtx, contextFingerprint), + rebuildTarget: { + agentName: input.agent?.name ?? null, + fromDockerfile: input.fromDockerfile ? path.resolve(input.fromDockerfile) : null, + }, + }, + }; } catch (err) { return { ok: false, detail: err instanceof Error ? err.message : String(err) }; } finally { - if (imageTag) removeImage(imageTag, { ignoreError: true, suppressOutput: true }); - cleanup?.(); + let imageRemoved = false; + try { + imageRemoved = + imageTag !== null && + removeImage(imageTag, { ignoreError: true, suppressOutput: true }).status === 0; + } catch { + // Best effort; retained-context ownership and environment restoration must continue. + } + if (imageBuilt && imageTag && !imageRemoved) { + const retainedImageTag = imageTag; + console.warn( + ` Warning: failed to remove temporary rebuild preflight image '${retainedImageTag}'.`, + ); + process.once("exit", () => { + try { + removeImage(retainedImageTag, { ignoreError: true, suppressOutput: true }); + } catch { + // Best effort process-exit retry. + } + }); + } + if (!retainBuildContext) { + try { + cleanup?.(); + } catch { + // Preserve the original preflight result. + } + } if (previousReasoning === undefined) delete process.env.NEMOCLAW_REASONING; else process.env.NEMOCLAW_REASONING = previousReasoning; } diff --git a/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts b/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts index f35f201c2af..94645d640c6 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts @@ -31,7 +31,9 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { configureDcodeSession(harness); await expect( - harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + harness.rebuildSandbox("alpha", ["--yes", "--tool-disclosure", "direct"], { + throwOnError: true, + }), ).resolves.toBeUndefined(); expect(harness.preflightDcodeRouteSpy).toHaveBeenCalledTimes(4); @@ -39,12 +41,14 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledWith( expect.objectContaining({ compatibleEndpointReasoning: null, + toolDisclosure: "direct", webSearchConfig: null, }), ); expect(harness.onboardSpy).toHaveBeenCalledWith( expect.objectContaining({ agent: "langchain-deepagents-code", + toolDisclosure: "direct", preparedDcodeRebuild: expect.objectContaining({ buildContext: harness.preparedDcodeBuildContext, gatewayName: "nemoclaw", diff --git a/src/lib/actions/sandbox/rebuild-dcode-orchestrator.test.ts b/src/lib/actions/sandbox/rebuild-dcode-orchestrator.test.ts index c6ccaaa8252..0529b01d8cd 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-orchestrator.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-orchestrator.test.ts @@ -46,7 +46,14 @@ describe("DCode rebuild orchestrator", () => { const baseImageOptions = { resolutionHint, forceBaseImageRefresh: true }; await expect( - orchestrator.prepareImage({} as RebuildResumeConfig, null, false, 19_080, baseImageOptions), + orchestrator.prepareImage( + {} as RebuildResumeConfig, + null, + "progressive", + false, + 19_080, + baseImageOptions, + ), ).resolves.toBe(true); expect(ensureAgentBaseImage).toHaveBeenCalledWith("hermes", bail, baseImageOptions); }); @@ -80,7 +87,7 @@ describe("DCode rebuild orchestrator", () => { const resolutionHint = { key: "sandbox-alpha" } as SandboxBaseImageResolutionMetadata; await expect( - orchestrator.prepareImage(resumeConfig, null, false, 19_080, { + orchestrator.prepareImage(resumeConfig, null, "progressive", false, 19_080, { resolutionHint, forceBaseImageRefresh: true, }), @@ -92,6 +99,7 @@ describe("DCode rebuild orchestrator", () => { entry, resumeConfig, webSearchConfig: null, + toolDisclosure: "progressive", 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 da474bbc61f..604b2eb1104 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts @@ -3,6 +3,7 @@ import type { WebSearchConfig } from "../../inference/web-search"; import type { Session } from "../../state/onboard-session"; +import type { ToolDisclosure } from "../../tool-disclosure"; import { createDcodeRebuildPreflightScope, type DcodeRebuildPreflightBail, @@ -48,17 +49,20 @@ export type DcodeRebuildOrchestrator = { prepareImage( resumeConfig: RebuildResumeConfig, webSearchConfig: WebSearchConfig | null, + toolDisclosure: ToolDisclosure, skipLiveRoute: boolean, gatewayPort: number, baseImageOptions?: RebuildAgentBaseImageOptions, ): Promise; revalidateBeforeDelete( resumeConfig: RebuildResumeConfig, + toolDisclosure: ToolDisclosure, skipLiveRoute: boolean, gatewayPort: number, ): Promise; checkAtDeleteEdge( resumeConfig: RebuildResumeConfig, + toolDisclosure: ToolDisclosure, skipLiveRoute: boolean, gatewayPort: number, ): Promise<{ ok: true } | { ok: false; message: string; code?: number }>; @@ -129,7 +133,14 @@ export function createDcodeRebuildOrchestrator( } return deps.preflightCredentials(sandboxName, entry, log, scope.bail); }), - prepareImage: (resumeConfig, webSearchConfig, skipLiveRoute, gatewayPort, baseImageOptions) => + prepareImage: ( + resumeConfig, + webSearchConfig, + toolDisclosure, + skipLiveRoute, + gatewayPort, + baseImageOptions, + ) => run(async () => { if (!scope.enabled) { return deps.ensureAgentBaseImage(rebuildAgent, scope.bail, baseImageOptions); @@ -139,6 +150,7 @@ export function createDcodeRebuildOrchestrator( entry, resumeConfig, webSearchConfig, + toolDisclosure, skipLiveRoute, gatewayPort, log, @@ -152,7 +164,7 @@ export function createDcodeRebuildOrchestrator( scope.adopt(replacement); return true; }), - revalidateBeforeDelete: (resumeConfig, skipLiveRoute, gatewayPort) => + revalidateBeforeDelete: (resumeConfig, toolDisclosure, skipLiveRoute, gatewayPort) => run(async () => { if (!scope.enabled) return true; const replacement = scope.preparedReplacement; @@ -161,6 +173,7 @@ export function createDcodeRebuildOrchestrator( sandboxName, entry, resumeConfig, + toolDisclosure, skipLiveRoute, gatewayPort, log, @@ -169,7 +182,7 @@ export function createDcodeRebuildOrchestrator( replacement, }); }), - checkAtDeleteEdge: async (resumeConfig, skipLiveRoute, gatewayPort) => { + checkAtDeleteEdge: async (resumeConfig, toolDisclosure, skipLiveRoute, gatewayPort) => { if (!scope.enabled) return { ok: true }; const replacement = scope.preparedReplacement; if (!replacement) { @@ -183,6 +196,7 @@ export function createDcodeRebuildOrchestrator( sandboxName, entry, resumeConfig, + toolDisclosure, skipLiveRoute, gatewayPort, log, 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 index 94a6d7106b0..ce9e544c2d6 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-pre-delete-drift.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-pre-delete-drift.test.ts @@ -1,7 +1,7 @@ // 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { configureDcodeSession, expectNoDcodeMutation, @@ -12,11 +12,61 @@ import { resetRebuildFlowTestEnvironment, restoreRebuildFlowTestEnvironment, } from "../../../../test/helpers/rebuild-flow-harness"; +import { revalidateDcodeReplacementAtMutationEdge } from "./rebuild-dcode-preflight"; describe("rebuildSandbox DCode flow: pre-delete drift", () => { beforeEach(resetRebuildFlowTestEnvironment); afterEach(restoreRebuildFlowTestEnvironment); + it("rejects prepared-image tool-disclosure drift before gateway or mutation work", async () => { + const checkGatewaySchema = vi.fn(() => true); + const verify = vi.fn(() => true); + const dispose = vi.fn(() => true); + + await expect( + revalidateDcodeReplacementAtMutationEdge({ + sandboxName: "alpha", + entry: { + name: "alpha", + agent: "langchain-deepagents-code", + gatewayName: "nemoclaw", + gatewayPort: 8080, + }, + resumeConfig: { + 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", + compatibleEndpointReasoning: "false", + nimContainer: null, + pinEndpoint: true, + ambient: { presentVars: [], agentMismatch: null }, + }, + toolDisclosure: "direct", + skipLiveRoute: true, + gatewayPort: 8080, + log: vi.fn(), + bail: (message): never => { + throw new Error(message); + }, + checkGatewaySchema, + replacement: { + buildContext: {} as never, + gatewayName: "nemoclaw", + toolDisclosure: "progressive", + verify, + dispose, + }, + }), + ).rejects.toThrow("prepared DCode tool-disclosure mode changed before deletion"); + + expect(checkGatewaySchema).not.toHaveBeenCalled(); + expect(verify).not.toHaveBeenCalled(); + expect(dispose).not.toHaveBeenCalled(); + }); + 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" }; diff --git a/src/lib/actions/sandbox/rebuild-dcode-preflight.ts b/src/lib/actions/sandbox/rebuild-dcode-preflight.ts index e1264fb00a6..782673a5169 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-preflight.ts @@ -20,6 +20,7 @@ import { redact } from "../../security/redact"; import * as onboardSession from "../../state/onboard-session"; import * as registry from "../../state/registry"; import * as sandboxState from "../../state/sandbox"; +import type { ToolDisclosure } from "../../tool-disclosure"; import { DCODE_AGENT_NAME, type ResolvedDcodeRebuildTarget, @@ -46,6 +47,7 @@ type PinnedDcodeBaseImage = { export type PreparedDcodeReplacement = { readonly buildContext: PreparedDcodeRebuildImage; readonly gatewayName: string; + readonly toolDisclosure: ToolDisclosure; dispose(): boolean; verify(): boolean; }; @@ -54,6 +56,7 @@ export type DcodeReplacementPreflightInput = { sandboxName: string; entry: RebuildSandboxEntry; resumeConfig: RebuildResumeConfig; + toolDisclosure: ToolDisclosure; skipLiveRoute: boolean; /** Authoritative persisted gateway port carried by the rebuild target. */ gatewayPort?: number; @@ -386,6 +389,7 @@ export async function prepareDcodeReplacementBeforeMutation( preferredInferenceApi: target.preferredInferenceApi, compatibleEndpointReasoning: resumeConfig.compatibleEndpointReasoning, webSearchConfig, + toolDisclosure: input.toolDisclosure, sandboxGpuConfig, gatewayPort, }), @@ -408,6 +412,7 @@ export async function prepareDcodeReplacementBeforeMutation( const replacement: PreparedDcodeReplacement = { buildContext: preparedBuildContext, gatewayName: target.gatewayName, + toolDisclosure: input.toolDisclosure, dispose: () => disposePreparation(preparedBuildContext, preparedBase), verify: () => verifyPreparedDcodeRebuildImage(preparedBuildContext) && preparedBase.verify(), }; @@ -428,6 +433,9 @@ export async function revalidateDcodeReplacementAtMutationEdge( if (replacement.gatewayName !== target.gatewayName) { fail("the prepared DCode gateway changed before deletion", bail); } + if (replacement.toolDisclosure !== input.toolDisclosure) { + fail("the prepared DCode tool-disclosure mode changed before deletion", bail); + } if (!(await ensureDcodeRebuildTargetGatewaySelected(sandboxName, entry, log, bail))) { return false; } diff --git a/src/lib/actions/sandbox/rebuild-durable-config.test.ts b/src/lib/actions/sandbox/rebuild-durable-config.test.ts index bac8f9fcbad..014d85d5093 100644 --- a/src/lib/actions/sandbox/rebuild-durable-config.test.ts +++ b/src/lib/actions/sandbox/rebuild-durable-config.test.ts @@ -3,10 +3,111 @@ import { describe, expect, it } from "vitest"; -import { createSession } from "../../state/onboard-session"; +import { createSession, normalizeSession } from "../../state/onboard-session"; import { resolveRebuildDurableConfig } from "./rebuild-durable-config"; describe("resolveRebuildDurableConfig", () => { + it("keeps the registry tool-disclosure selection authoritative", () => { + const config = resolveRebuildDurableConfig( + "alpha", + { name: "alpha", toolDisclosure: "direct", nemoclawVersion: "0.1.0" }, + createSession({ sandboxName: "alpha", toolDisclosure: "progressive" }), + ); + + expect(config.toolDisclosure).toBe("direct"); + expect(config.toolDisclosureError).toBeNull(); + }); + + it("lets an explicit transactional rebuild override the recorded selection", () => { + const config = resolveRebuildDurableConfig( + "alpha", + { name: "alpha", toolDisclosure: "progressive", nemoclawVersion: "0.1.0" }, + createSession({ sandboxName: "alpha", toolDisclosure: "progressive" }), + undefined, + "direct", + ); + + expect(config.toolDisclosure).toBe("direct"); + expect(config.toolDisclosureError).toBeNull(); + }); + + it("recovers tool disclosure from a matching legacy session", () => { + const config = resolveRebuildDurableConfig( + "alpha", + { name: "alpha", provider: "ollama-local", model: "model", nemoclawVersion: "0.1.0" }, + createSession({ + sandboxName: "alpha", + provider: "ollama-local", + model: "model", + toolDisclosure: "direct", + }), + ); + + expect(config.toolDisclosure).toBe("direct"); + expect(config.toolDisclosureError).toBeNull(); + }); + + it("defaults missing legacy tool-disclosure state to progressive", () => { + const config = resolveRebuildDurableConfig( + "alpha", + { name: "alpha", nemoclawVersion: "0.1.0" }, + null, + ); + + expect(config.toolDisclosure).toBe("progressive"); + expect(config.toolDisclosureError).toBeNull(); + }); + + it("fails closed for corrupt durable tool-disclosure state", () => { + const config = resolveRebuildDurableConfig( + "alpha", + { name: "alpha", toolDisclosure: "everything" as never, nemoclawVersion: "0.1.0" }, + null, + ); + + expect(config.toolDisclosure).toBe("progressive"); + expect(config.toolDisclosureError).toContain("progressive or direct"); + }); + + it("does not let an explicit override mask corrupt durable state", () => { + const config = resolveRebuildDurableConfig( + "alpha", + { name: "alpha", toolDisclosure: "everything" as never, nemoclawVersion: "0.1.0" }, + null, + undefined, + "direct", + ); + + expect(config.toolDisclosure).toBe("direct"); + expect(config.toolDisclosureError).toContain("progressive or direct"); + }); + + it("fails closed for corrupt matching-session state when the registry value is missing", () => { + const session = normalizeSession({ + version: 1, + sandboxName: "alpha", + toolDisclosure: "everything", + } as never); + const config = resolveRebuildDurableConfig( + "alpha", + { name: "alpha", nemoclawVersion: "0.1.0" }, + session, + ); + + expect(config.toolDisclosureError).toContain("progressive or direct"); + }); + + it("uses a matching direct session when a legacy registry stores null", () => { + const config = resolveRebuildDurableConfig( + "alpha", + { name: "alpha", toolDisclosure: null as never, nemoclawVersion: "0.1.0" }, + createSession({ sandboxName: "alpha", toolDisclosure: "direct" }), + ); + + expect(config.toolDisclosure).toBe("direct"); + expect(config.toolDisclosureError).toBeNull(); + }); + it("uses a legacy built-in Brave policy for a nonmatching session", () => { const session = createSession({ sandboxName: "other", webSearchConfig: null }); const config = resolveRebuildDurableConfig( diff --git a/src/lib/actions/sandbox/rebuild-durable-config.ts b/src/lib/actions/sandbox/rebuild-durable-config.ts index 96ce9a02fa9..d7051dcf063 100644 --- a/src/lib/actions/sandbox/rebuild-durable-config.ts +++ b/src/lib/actions/sandbox/rebuild-durable-config.ts @@ -21,7 +21,13 @@ import { webSearchProviderForConfig, } from "../../inference/web-search"; import { resolveHermesDashboardOnboardState } from "../../onboard/hermes-dashboard"; -import type { Session } from "../../state/onboard-session"; +import { hasInvalidSessionToolDisclosure, type Session } from "../../state/onboard-session"; +import { + DEFAULT_TOOL_DISCLOSURE, + invalidRecordedToolDisclosure, + normalizeToolDisclosure, + type ToolDisclosure, +} from "../../tool-disclosure"; import { DCODE_AGENT_NAME } from "./rebuild-dcode-target"; import type { RebuildSandboxEntry } from "./rebuild-flow-helpers"; import type { RebuildResumeConfig } from "./rebuild-resume-config"; @@ -33,6 +39,8 @@ export type RebuildDurableConfig = { hermesAuthMethodError: string | null; webSearchConfig: WebSearchConfig | null; webSearchError: string | null; + toolDisclosure: ToolDisclosure; + toolDisclosureError: string | null; }; export const REBUILD_HERMES_DASHBOARD_ENV_KEYS = [ @@ -117,6 +125,7 @@ export function resolveRebuildDurableConfig( provider: entry.provider ?? null, model: entry.model ?? null, }, + requestedToolDisclosure?: ToolDisclosure, ): RebuildDurableConfig { const matchingSession = session?.sandboxName === sandboxName && @@ -175,6 +184,20 @@ export function resolveRebuildDurableConfig( webSearchProvider = null; } } + const recordedToolDisclosure = + entry.toolDisclosure !== undefined && entry.toolDisclosure !== null + ? entry.toolDisclosure + : matchingSession?.toolDisclosure; + const toolDisclosureError = + invalidRecordedToolDisclosure(recordedToolDisclosure) || + ((entry.toolDisclosure === undefined || entry.toolDisclosure === null) && + hasInvalidSessionToolDisclosure(matchingSession)) + ? "recorded toolDisclosure value must be progressive or direct" + : null; + const toolDisclosure = + requestedToolDisclosure ?? + normalizeToolDisclosure(recordedToolDisclosure) ?? + DEFAULT_TOOL_DISCLOSURE; const recordedFromDockerfile: unknown = entry.fromDockerfile !== undefined ? entry.fromDockerfile @@ -217,6 +240,8 @@ export function resolveRebuildDurableConfig( ? { fetchEnabled: true, provider: webSearchProvider } : null, webSearchError, + toolDisclosure, + toolDisclosureError, }; } @@ -246,6 +271,10 @@ export function validatedRebuildRegistryUpdate( fromDockerfile: string | null, credentialEnv: string | null, ): Partial { + // toolDisclosure is intentionally absent: this preflight update still + // describes the running old image. Replacement onboarding commits the + // requested mode only after creation succeeds; retry rollback keeps the old + // registry value if recreation fails. return { provider: resume.provider, model: resume.model, diff --git a/src/lib/actions/sandbox/rebuild-env-isolation.test.ts b/src/lib/actions/sandbox/rebuild-env-isolation.test.ts index 5efc28a5218..b5beda9c8d1 100644 --- a/src/lib/actions/sandbox/rebuild-env-isolation.test.ts +++ b/src/lib/actions/sandbox/rebuild-env-isolation.test.ts @@ -60,6 +60,7 @@ describe("AMBIENT_RECREATE_ENV_VARS contract PRA-4 (#5735)", () => { "NEMOCLAW_POLICY_PRESETS", "NEMOCLAW_SANDBOX_GPU", "NEMOCLAW_SANDBOX_GPU_DEVICE", + "NEMOCLAW_TOOL_DISCLOSURE", ]); }); }); diff --git a/src/lib/actions/sandbox/rebuild-env-isolation.ts b/src/lib/actions/sandbox/rebuild-env-isolation.ts index c0db1aacbd4..7d64e2c684e 100644 --- a/src/lib/actions/sandbox/rebuild-env-isolation.ts +++ b/src/lib/actions/sandbox/rebuild-env-isolation.ts @@ -28,6 +28,7 @@ // → src/lib/onboard/policy-tier-env.ts / policy selection // - NEMOCLAW_SANDBOX_GPU / NEMOCLAW_SANDBOX_GPU_DEVICE // → src/lib/onboard/sandbox-gpu-mode.ts +// - NEMOCLAW_TOOL_DISCLOSURE → src/lib/tool-disclosure.ts // This list MUST stay in sync with those reads; a contract test in // rebuild-env-isolation.test.ts pins the exact set so adding a new // onboard-selection env var forces a conscious update here. @@ -54,6 +55,7 @@ export const AMBIENT_RECREATE_ENV_VARS = [ "NEMOCLAW_POLICY_PRESETS", "NEMOCLAW_SANDBOX_GPU", "NEMOCLAW_SANDBOX_GPU_DEVICE", + "NEMOCLAW_TOOL_DISCLOSURE", ] as const; /** diff --git a/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts b/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts index 374103bd7bb..859b0135ec5 100644 --- a/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts +++ b/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts @@ -171,9 +171,19 @@ describe("buildRebuildRecreateOnboardOpts", () => { sandboxGpu: "disable", sandboxGpuDevice: null, autoYes: true, + toolDisclosure: "progressive", }); }); + it("carries an explicit direct tool-disclosure selection into inner onboard", () => { + const opts = buildRebuildRecreateOnboardOpts({ + ...baseArgs, + sb: { ...dashboard, toolDisclosure: "direct" }, + }); + + expect(opts.toolDisclosure).toBe("direct"); + }); + it("forwards noGpu:true for legacy entries with gpuEnabled:false and no sandboxGpuMode", () => { const opts = buildRebuildRecreateOnboardOpts({ ...baseArgs, diff --git a/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts b/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts index f9c3b2281f9..ef2068ca279 100644 --- a/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts +++ b/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts @@ -7,9 +7,13 @@ import { resolveGatewayPortFromName, resolveSandboxGatewayName, } from "../../onboard/gateway-binding"; -import type { PreparedDcodeRebuildHandoff } from "../../onboard/prepared-dcode-rebuild"; +import type { + PreparedDcodeRebuildHandoff, + PreparedImageRebuildHandoff, +} from "../../onboard/prepared-dcode-rebuild"; import { normalizeSandboxGpuMode } from "../../onboard/sandbox-gpu-mode"; import type { SandboxBaseImageResolutionMetadata } from "../../sandbox-base-image"; +import { type ToolDisclosure, toolDisclosureOrDefault } from "../../tool-disclosure"; export type RebuildGpuOptOutEntry = { sandboxGpuMode?: string | null; @@ -19,6 +23,7 @@ export type RebuildGpuOptOutEntry = { dashboardPort?: number | null; gatewayName?: string | null; gatewayPort?: number | null; + toolDisclosure?: ToolDisclosure; }; // Modern source of truth is the persisted `sandboxGpuMode` string ("0" / "1" / @@ -86,7 +91,9 @@ export type RebuildRecreateOnboardOpts = { targetGatewayPort: number; onboardLockAlreadyHeld: true; preparedDcodeRebuild?: PreparedDcodeRebuildHandoff; + preparedImageRebuild?: PreparedImageRebuildHandoff; autoYes: boolean; + toolDisclosure: ToolDisclosure; baseImageResolutionHint: SandboxBaseImageResolutionMetadata | null; noGpu?: true; }; @@ -138,6 +145,7 @@ export function buildRebuildRecreateOnboardOpts(args: { onboardLockAlreadyHeld: true, ...(args.preparedDcodeRebuild ? { preparedDcodeRebuild: args.preparedDcodeRebuild } : {}), autoYes: args.autoYes, + toolDisclosure: toolDisclosureOrDefault(args.sb?.toolDisclosure), baseImageResolutionHint: args.baseImageResolutionHint ?? null, ...(rebuildShouldOptOutGpu(args.sb) ? { noGpu: true as const } : {}), }; diff --git a/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts b/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts index ba109ac980c..dc62de1a09c 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts @@ -2,18 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import crypto from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; import { dockerBuild, dockerRmi } from "../../adapters/docker"; +import { fingerprintBuildContext } from "../../adapters/fs/build-context-fingerprint"; import type { AgentDefinition } from "../../agent/defs"; import { createAgentSandbox } from "../../agent/onboard"; import { GATEWAY_PORT } from "../../core/ports"; import type { WebSearchConfig } from "../../inference/web-search"; -import { - type PreparedSandboxBuildContext, - stageCreateSandboxBuildContext, -} from "../../onboard/build-context-stage"; +import { stageCreateSandboxBuildContext } from "../../onboard/build-context-stage"; import { prepareSandboxDockerfilePatch } from "../../onboard/sandbox-dockerfile-patch-flow"; import type { SandboxGpuConfig } from "../../onboard/sandbox-gpu-mode"; import { ROOT, redact } from "../../runner"; @@ -22,7 +18,15 @@ import { OPENCLAW_SANDBOX_BASE_IMAGE, SANDBOX_BASE_TAG, } from "../../sandbox-base-image"; +import type { ToolDisclosure } from "../../tool-disclosure"; import { DCODE_AGENT_NAME } from "./rebuild-dcode-target"; +import { + createBuildContextVerifier, + createIdempotentBuildContextCleanup, + disposePreparedBuildContext, + type FingerprintedPreparedBuildContext, + verifyPreparedBuildContext, +} from "./rebuild-prepared-image-context"; export type ManagedDcodeRebuildImageInput = { agent: AgentDefinition; @@ -31,6 +35,7 @@ export type ManagedDcodeRebuildImageInput = { preferredInferenceApi: string | null; compatibleEndpointReasoning: "true" | "false" | null; webSearchConfig: WebSearchConfig | null; + toolDisclosure: ToolDisclosure; sandboxGpuConfig: SandboxGpuConfig; gatewayPort?: number; }; @@ -43,8 +48,7 @@ export type ManagedDcodeRebuildImageDeps = { createImageTag?: () => string; }; -export type PreparedDcodeRebuildImage = PreparedSandboxBuildContext & { - contextFingerprint: string; +export type PreparedDcodeRebuildImage = FingerprintedPreparedBuildContext & { dockerGpuPatchNetwork: string | null; }; @@ -73,137 +77,14 @@ function defaultImageTag(): string { return `nemoclaw-rebuild-preflight:${String(process.pid)}-${crypto.randomUUID()}`; } -type EntrySnapshot = fs.BigIntStats; -const FINGERPRINT_OPEN_FLAGS = - fs.constants.O_RDONLY | - (typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0) | - (typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0); - -function lstatEntry(absolutePath: string): EntrySnapshot { - return fs.lstatSync(absolutePath, { bigint: true }); -} - -function fstatEntry(fd: number): EntrySnapshot { - return fs.fstatSync(fd, { bigint: true }); -} - -function sameEntrySnapshot(left: EntrySnapshot, right: EntrySnapshot): boolean { - return ( - left.dev === right.dev && - left.ino === right.ino && - left.mode === right.mode && - left.size === right.size && - left.mtimeNs === right.mtimeNs && - left.ctimeNs === right.ctimeNs - ); -} - -function requireStableEntry( - relativePath: string, - expected: EntrySnapshot, - actual: EntrySnapshot, -): void { - if (!sameEntrySnapshot(expected, actual)) { - throw new Error(`build-context entry changed during fingerprint: ${relativePath || "."}`); - } -} - -function readPinnedRegularFile( - absolutePath: string, - relativePath: string, -): { contents: Buffer; stat: EntrySnapshot } | null { - let fd: number; - try { - // Open before inspecting the path so CodeQL and the implementation agree on - // the security boundary. O_NONBLOCK also prevents a file-to-FIFO swap from - // hanging before fstat can reject the descriptor. - fd = fs.openSync(absolutePath, FINGERPRINT_OPEN_FLAGS); - } catch (openError) { - // O_NOFOLLOW rejects symlinks where it is available, and some platforms do - // not allow directories through openSync. Both remain path-fingerprinted; - // a regular file that could not be pinned must fail closed. - if (lstatEntry(absolutePath).isFile()) throw openError; - return null; - } - - try { - const descriptorBefore = fstatEntry(fd); - const pathBefore = lstatEntry(absolutePath); - // Without O_NOFOLLOW, openSync can follow a symlink. Never consume that - // descriptor as a regular build input; the caller fingerprints the link. - if (pathBefore.isSymbolicLink() || !descriptorBefore.isFile()) return null; - requireStableEntry(relativePath, pathBefore, descriptorBefore); - const contents = fs.readFileSync(fd); - requireStableEntry(relativePath, descriptorBefore, fstatEntry(fd)); - requireStableEntry(relativePath, pathBefore, lstatEntry(absolutePath)); - return { contents, stat: descriptorBefore }; - } finally { - fs.closeSync(fd); - } -} - -function fingerprintBuildContext(buildCtx: string): string { - const hash = crypto.createHash("sha256"); - const updateEntry = (kind: string, relativePath: string, stat: EntrySnapshot): void => { - hash.update(`${kind}\0${relativePath}\0${String(stat.mode & 0o777n)}\0${String(stat.size)}\0`); - }; - const visit = (relativePath: string): void => { - const absolutePath = path.join(buildCtx, relativePath); - const pinnedFile = readPinnedRegularFile(absolutePath, relativePath); - if (pinnedFile) { - updateEntry("file", relativePath, pinnedFile.stat); - hash.update(pinnedFile.contents); - } else { - const stat = lstatEntry(absolutePath); - if (stat.isDirectory()) { - updateEntry("dir", relativePath, stat); - for (const name of fs.readdirSync(absolutePath).sort()) { - visit(relativePath ? path.join(relativePath, name) : name); - } - requireStableEntry(relativePath, stat, lstatEntry(absolutePath)); - } else if (stat.isSymbolicLink()) { - const target = fs.readlinkSync(absolutePath); - requireStableEntry(relativePath, stat, lstatEntry(absolutePath)); - updateEntry("link", relativePath, stat); - hash.update(target); - } else { - throw new Error(`unsupported build-context entry: ${relativePath || "."}`); - } - } - hash.update("\0"); - }; - - visit(""); - return hash.digest("hex"); -} - /** Confirm that the retained, private build context still matches the prebuilt input. */ export function verifyPreparedDcodeRebuildImage(prepared: PreparedDcodeRebuildImage): boolean { - try { - return fingerprintBuildContext(prepared.buildCtx) === prepared.contextFingerprint; - } catch { - return false; - } -} - -function createIdempotentBuildContextCleanup(cleanup: () => boolean): () => boolean { - let cleaned = false; - const dispose = () => { - if (cleaned) return true; - const succeeded = cleanup(); - if (succeeded) { - cleaned = true; - process.removeListener("exit", dispose); - } - return succeeded; - }; - process.on("exit", dispose); - return dispose; + return verifyPreparedBuildContext(prepared); } /** Dispose the retained context after onboard consumes it or rebuild aborts. */ export function disposePreparedDcodeRebuildImage(prepared: PreparedDcodeRebuildImage): boolean { - return prepared.cleanupBuildCtx(); + return disposePreparedBuildContext(prepared); } /** @@ -265,6 +146,7 @@ export async function prepareManagedDcodeRebuildImage( provider: input.provider, preferredInferenceApi: input.preferredInferenceApi, webSearchConfig: input.webSearchConfig, + toolDisclosure: input.toolDisclosure, hermesToolGateways: [], sandboxGpuConfig: input.sandboxGpuConfig, gatewayPort: input.gatewayPort ?? GATEWAY_PORT, @@ -292,6 +174,7 @@ export async function prepareManagedDcodeRebuildImage( cleanupBuildCtx: cleanupBuildContext, buildId, contextFingerprint, + verifyBuildCtx: createBuildContextVerifier(staged.buildCtx, contextFingerprint), dockerGpuPatchNetwork: process.env.NEMOCLAW_DOCKER_GPU_PATCH_NETWORK || null, }, }; diff --git a/src/lib/actions/sandbox/rebuild-managed-image-preparation.test.ts b/src/lib/actions/sandbox/rebuild-managed-image-preparation.test.ts index 4e6e1ee4697..8472564e314 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-preparation.test.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-preparation.test.ts @@ -21,7 +21,7 @@ import { describe("managed DCode rebuild image preparation", () => { it("prebuilds the recorded DCode replacement and transfers one disposable context (#6195)", async () => { - const fixture = await createPreparedDcodeImageFixture(); + const fixture = await createPreparedDcodeImageFixture({ toolDisclosure: "direct" }); try { expect(fixture.result).toMatchObject({ ok: true, @@ -46,6 +46,7 @@ describe("managed DCode rebuild image preparation", () => { provider: "compatible-endpoint", model: "nvidia/nemotron-3-super-120b-a12b", preferredInferenceApi: "openai-completions", + toolDisclosure: "direct", chatUiUrl: "", }), ); diff --git a/src/lib/actions/sandbox/rebuild-managed-image-verification.test.ts b/src/lib/actions/sandbox/rebuild-managed-image-verification.test.ts index 5da256ec77f..a3441a8089d 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-verification.test.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-verification.test.ts @@ -159,12 +159,15 @@ describe("managed DCode rebuild image verification", () => { fixture.stagedDockerfile, fs.constants.O_WRONLY | fs.constants.O_APPEND, ); + const originalMutationStat = fs.fstatSync(mutationFd); 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"); + fs.futimesSync(mutationFd, originalMutationStat.atime, originalMutationStat.mtime); + fs.utimesSync(fixture.buildCtx, fixture.stableDockerfileTime, fixture.stableDockerfileTime); expect(verifyPreparedDcodeRebuildImage(fixture.prepared)).toBe(true); fs.writeSync(mutationFd, "# changed after preflight\n", 0, "utf8"); expect(verifyPreparedDcodeRebuildImage(fixture.prepared)).toBe(false); diff --git a/src/lib/actions/sandbox/rebuild-mcp-phase.ts b/src/lib/actions/sandbox/rebuild-mcp-phase.ts index 8b3c09e2e68..3e3018b9451 100644 --- a/src/lib/actions/sandbox/rebuild-mcp-phase.ts +++ b/src/lib/actions/sandbox/rebuild-mcp-phase.ts @@ -4,6 +4,7 @@ import { CLI_NAME } from "../../cli/branding"; import { G, R, YW } from "../../cli/terminal-style"; import * as registry from "../../state/registry"; +import type { ToolDisclosure } from "../../tool-disclosure"; import { prepareMcpBridgesForAbsentSandboxRebuild, prepareMcpBridgesForRebuild, @@ -69,15 +70,18 @@ export function restoreMcpRegistryForRebuildRetry( export function printMcpRebuildRetryCommand( sandboxName: string, entries: McpRebuildPreparation["entries"], + toolDisclosure?: ToolDisclosure, ): void { if (entries.length > 0) { - console.error(` 2. Run: ${CLI_NAME} ${sandboxName} rebuild --yes`); + const disclosureArg = toolDisclosure ? ` --tool-disclosure ${toolDisclosure}` : ""; + console.error(` 2. Run: ${CLI_NAME} ${sandboxName} rebuild --yes${disclosureArg}`); console.error( ` This will recreate sandbox '${sandboxName}' and restore its MCP bridges.`, ); return; } - console.error(` 2. Run: ${CLI_NAME} onboard --resume`); + const disclosureArg = toolDisclosure ? ` --tool-disclosure ${toolDisclosure}` : ""; + console.error(` 2. Run: ${CLI_NAME} onboard --resume${disclosureArg}`); console.error(` This will recreate sandbox '${sandboxName}'.`); } diff --git a/src/lib/actions/sandbox/rebuild-pipeline.ts b/src/lib/actions/sandbox/rebuild-pipeline.ts index 9f34b9eab40..9148bce6f1a 100644 --- a/src/lib/actions/sandbox/rebuild-pipeline.ts +++ b/src/lib/actions/sandbox/rebuild-pipeline.ts @@ -14,7 +14,12 @@ import { runRebuildDestroyPhase } from "./rebuild-destroy-phase"; import { REBUILD_HERMES_DASHBOARD_ENV_KEYS } from "./rebuild-durable-config"; import { stageMessagingManifestPlanForRebuild } from "./rebuild-messaging-phase"; import { runRebuildPostRestorePhase } from "./rebuild-post-restore-phase"; +import { printRebuildPreflightFailure } from "./rebuild-preflight-error"; import { runRebuildPreflightPhase } from "./rebuild-preflight-phase"; +import { + disposePreparedBuildContext, + verifyPreparedBuildContext, +} from "./rebuild-prepared-image-context"; import { type RebuildSandboxExecutionOptions, revalidatePreparedRecoveryBeforeDelete, @@ -79,6 +84,7 @@ async function rebuildSandboxUnlocked( liveState, recoveryManifest: validatedRecoveryManifest, dcodePreflight, + preparedImage, releaseOnboardLock, log, bail, @@ -142,12 +148,26 @@ async function rebuildSandboxUnlocked( }); if (!backup) return; + // The post-delete create must consume the exact context that passed the + // image preflight. Revalidate at the last safe point so mutation of the + // retained copy cannot cross the destructive boundary. + if (preparedImage && !verifyPreparedBuildContext(preparedImage)) { + printRebuildPreflightFailure( + "the retained replacement image context changed after preflight.", + "Retry the rebuild so the replacement inputs can be staged again.", + "Replacement sandbox image context changed before delete", + bail, + ); + return; + } + // DCode's retained replacement and live inference route must still match at // the last safe point. This check intentionally precedes MCP adapter scrub, // provider detach, NIM stop, and sandbox deletion in the destroy phase. if ( !(await dcodePreflight.revalidateBeforeDelete( resumeConfig, + durableConfig.toolDisclosure, recoveryRecreate, recreateOptions.targetGatewayPort, )) @@ -166,6 +186,7 @@ async function rebuildSandboxUnlocked( validateAfterMcpPreparation: () => dcodePreflight.checkAtDeleteEdge( resumeConfig, + durableConfig.toolDisclosure, recoveryRecreate, recreateOptions.targetGatewayPort, ), @@ -245,6 +266,9 @@ async function rebuildSandboxUnlocked( } } finally { dcodePreflight.cleanup(); + if (preparedImage && !disposePreparedBuildContext(preparedImage)) { + console.warn(" Warning: temporary rebuild image inputs could not be fully removed."); + } process.removeListener("exit", releaseOnboardLock); releaseOnboardLock(); } diff --git a/src/lib/actions/sandbox/rebuild-preflight-confirmation.ts b/src/lib/actions/sandbox/rebuild-preflight-confirmation.ts index d40b9628f25..52aee769053 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-confirmation.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-confirmation.ts @@ -15,6 +15,7 @@ import { createSystemDeps as createSessionDeps, getActiveSandboxSessions, } from "../../state/sandbox-session"; +import type { ToolDisclosure } from "../../tool-disclosure"; import { type RebuildBail, type RebuildLog } from "./rebuild-credential-preflight"; import { printRebuildPreflightFailure } from "./rebuild-preflight-error"; import { ensureRebuildUsageNoticeAccepted } from "./rebuild-usage-notice"; @@ -24,7 +25,12 @@ export type RebuildVersionCheck = ReturnType console.error(` ${D}[rebuild ${new Date().toISOString()}] ${redact(message)}${R}`) : () => {}, + requestedToolDisclosure: normalized.toolDisclosure, skipConfirm: normalized.yes === true || normalized.force === true, bail: opts.throwOnError ? (message: string) => { diff --git a/src/lib/actions/sandbox/rebuild-preflight-phase.ts b/src/lib/actions/sandbox/rebuild-preflight-phase.ts index 35ed334fb86..d1eb01edc01 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-phase.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-phase.ts @@ -9,6 +9,7 @@ import { type RebuildBail, type RebuildLog, } from "./rebuild-credential-preflight"; +import type { PreparedRebuildImage } from "./rebuild-custom-image-preflight"; import { createDcodeRebuildOrchestrator, type DcodeRebuildOrchestrator, @@ -36,6 +37,7 @@ import { isSingleAgentRebuildSupported, } from "./rebuild-preflight-guards"; import { prepareRebuildTargetPreflights } from "./rebuild-preflight-target-phase"; +import { disposePreparedBuildContext } from "./rebuild-prepared-image-context"; import { type RebuildSandboxExecutionOptions, validatePreparedRecoveryManifest, @@ -53,6 +55,7 @@ export interface RebuildPreflightPhaseResult { liveState: RebuildLiveState; recoveryManifest: RebuildManifest | null; dcodePreflight: DcodeRebuildOrchestrator; + preparedImage: PreparedRebuildImage | null; releaseOnboardLock: () => void; log: RebuildLog; bail: RebuildBail; @@ -70,7 +73,10 @@ export async function runRebuildPreflightPhase( options: string[] | RebuildSandboxOptions = {}, opts: RebuildSandboxExecutionOptions = {}, ): Promise { - const { log, bail, skipConfirm } = createRebuildCommandContext(options, opts); + const { log, bail, requestedToolDisclosure, skipConfirm } = createRebuildCommandContext( + options, + opts, + ); const activeSessionCount = countActiveSandboxSessionsForRebuild(sandboxName); const sandboxEntry = getRebuildSandboxEntryOrBail(sandboxName, bail); if (!sandboxEntry) return null; @@ -102,6 +108,8 @@ export async function runRebuildPreflightPhase( }, }); let retainDcodePreflight = false; + let preparedImage: PreparedRebuildImage | null = null; + let retainPreparedImage = false; try { if ( !isDcodeRebuildAgent(rebuildAgent) && @@ -129,10 +137,12 @@ export async function runRebuildPreflightPhase( // Reaching this point means either --yes was supplied or confirmation // succeeded, matching the previous `skipConfirm || confirmed` contract. autoYes: true, + requestedToolDisclosure, log, bail, }); if (!preparedTarget) return null; + preparedImage = preparedTarget.preparedImage; const liveState = await resolveRebuildLiveState(sandboxName, sandboxEntry, log, bail); if (!liveState) return null; @@ -141,6 +151,7 @@ export async function runRebuildPreflightPhase( const imageReady = await dcodePreflight.prepareImage( preparedTarget.targetConfig.resumeConfig, preparedTarget.targetConfig.durableConfig.webSearchConfig, + preparedTarget.targetConfig.durableConfig.toolDisclosure, recoveryRecreate, preparedTarget.recreateOptions.targetGatewayPort, ); @@ -149,6 +160,7 @@ export async function runRebuildPreflightPhase( } retainOnboardLock = true; retainDcodePreflight = true; + retainPreparedImage = true; return { sandboxEntry, rebuildAgent, @@ -169,5 +181,6 @@ export async function runRebuildPreflightPhase( } } finally { if (!retainDcodePreflight) dcodePreflight.cleanup(); + if (!retainPreparedImage && preparedImage) disposePreparedBuildContext(preparedImage); } } diff --git a/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts b/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts index ecdcb4aa0fe..d5d635f91f3 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts @@ -6,8 +6,10 @@ import type { SandboxMessagingPlan } from "../../messaging"; import { isSandboxBaseImageRefreshRequested } from "../../onboard/base-image-resolution-flow"; import { readSandboxBaseImageResolutionMetadata } from "../../sandbox-base-image"; import * as registry from "../../state/registry"; +import type { ToolDisclosure } from "../../tool-disclosure"; import { getSandboxTargetGatewayName } from "./gateway-target"; import type { RebuildBail, RebuildLog } from "./rebuild-credential-preflight"; +import type { PreparedRebuildImage } from "./rebuild-custom-image-preflight"; import { isDcodeRebuildAgent } from "./rebuild-dcode-orchestrator"; import { validatedRebuildRegistryUpdate } from "./rebuild-durable-config"; import { @@ -21,6 +23,7 @@ import type { RebuildRecreateOnboardOpts } from "./rebuild-gpu-opt-out"; import { preflightRebuildMessagingConflicts } from "./rebuild-messaging-conflict-preflight"; import { stageRebuildMessagingPlanOrBail } from "./rebuild-messaging-phase"; import { checkRebuildGatewaySchemaPreflight } from "./rebuild-preflight-guards"; +import { disposePreparedBuildContext } from "./rebuild-prepared-image-context"; import { hydrateMessagingConfigForRebuild, preflightAuthoritativeOnboardRuntime, @@ -36,6 +39,7 @@ export interface RebuildPreparedTarget { recreateOptions: RebuildRecreateOnboardOpts; messagingPlan: SandboxMessagingPlan | null; baseImagePreflight: RebuildAgentBaseImagePreflight; + preparedImage: PreparedRebuildImage | null; } /** Resolve, validate, and persist the complete non-destructive recreate target. */ @@ -44,10 +48,12 @@ export async function prepareRebuildTargetPreflights(args: { sandboxEntry: RebuildSandboxEntry; rebuildAgent: string | null; autoYes: boolean; + requestedToolDisclosure?: ToolDisclosure; log: RebuildLog; bail: RebuildBail; }): Promise { - const { sandboxName, sandboxEntry, rebuildAgent, autoYes, log, bail } = args; + const { sandboxName, sandboxEntry, rebuildAgent, autoYes, requestedToolDisclosure, log, bail } = + args; hydrateMessagingConfigForRebuild(sandboxName, log); if (!(await ensureRebuildTargetGatewaySelected(sandboxName, sandboxEntry, log, bail))) return null; @@ -58,6 +64,7 @@ export async function prepareRebuildTargetPreflights(args: { rebuildAgent, log, bail, + requestedToolDisclosure, ); if (!targetConfig) return null; const { resumeConfig, durableConfig, credentialEnv, fromDockerfile } = targetConfig; @@ -72,6 +79,10 @@ export async function prepareRebuildTargetPreflights(args: { bail, ); if (!recreateOptions) return null; + // The durable resolver may recover a legacy row's choice from its matching + // session. Use that authoritative value for both preflight and inner onboard, + // never the raw registry fallback used while constructing generic options. + recreateOptions.toolDisclosure = durableConfig.toolDisclosure; if ( !stageRebuildHermesDashboardConfig( rebuildAgent, @@ -119,9 +130,11 @@ export async function prepareRebuildTargetPreflights(args: { }); if (!baseImagePreflight.ok) return null; const restoreBaseImageOverride = pinRebuildAgentBaseImageForRecreate(baseImagePreflight); - let targetRuntimeReady = false; + let targetRuntimePreflight: Awaited> = { + ok: false, + }; try { - targetRuntimeReady = await preflightRebuildTargetRuntime( + targetRuntimePreflight = await preflightRebuildTargetRuntime( targetConfig, sandboxEntry, recreateOptions, @@ -132,19 +145,38 @@ export async function prepareRebuildTargetPreflights(args: { } finally { restoreBaseImageOverride(); } - if (!targetRuntimeReady) return null; + if (!targetRuntimePreflight.ok) return null; - const validatedRegistryUpdate = validatedRebuildRegistryUpdate( - resumeConfig, - durableConfig, - fromDockerfile, - credentialEnv, - ); - if (!registry.updateSandbox(sandboxName, validatedRegistryUpdate)) { - bail("Sandbox registry entry disappeared during rebuild preflight"); - return null; - } - Object.assign(sandboxEntry, validatedRegistryUpdate); + const preparedImage = targetRuntimePreflight.preparedImage; + let retainPreparedImage = false; + try { + const validatedRegistryUpdate = validatedRebuildRegistryUpdate( + resumeConfig, + durableConfig, + fromDockerfile, + credentialEnv, + ); + if (!registry.updateSandbox(sandboxName, validatedRegistryUpdate)) { + bail("Sandbox registry entry disappeared during rebuild preflight"); + return null; + } + Object.assign(sandboxEntry, validatedRegistryUpdate); + if (preparedImage) { + recreateOptions.preparedImageRebuild = { + buildContext: preparedImage, + gatewayName: recreateOptions.targetGatewayName, + }; + } - return { targetConfig, recreateOptions, messagingPlan, baseImagePreflight }; + retainPreparedImage = true; + return { + targetConfig, + recreateOptions, + messagingPlan, + baseImagePreflight, + preparedImage, + }; + } finally { + if (!retainPreparedImage && preparedImage) disposePreparedBuildContext(preparedImage); + } } diff --git a/src/lib/actions/sandbox/rebuild-prepared-image-context.ts b/src/lib/actions/sandbox/rebuild-prepared-image-context.ts new file mode 100644 index 00000000000..c95145e3cbd --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-prepared-image-context.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { fingerprintBuildContext } from "../../adapters/fs/build-context-fingerprint"; +import type { PreparedSandboxBuildContext } from "../../onboard/build-context-stage"; + +export type FingerprintedPreparedBuildContext = PreparedSandboxBuildContext & { + contextFingerprint: string; + verifyBuildCtx(): boolean; +}; + +/** Keep temporary rebuild inputs alive until the transaction releases them. */ +export function createIdempotentBuildContextCleanup(cleanup: () => boolean): () => boolean { + let cleaned = false; + const dispose = () => { + if (cleaned) return true; + const succeeded = cleanup(); + if (succeeded) { + cleaned = true; + process.removeListener("exit", dispose); + } + return succeeded; + }; + process.on("exit", dispose); + return dispose; +} + +/** Confirm that a retained private context still matches the prebuilt bytes. */ +export function verifyPreparedBuildContext(prepared: FingerprintedPreparedBuildContext): boolean { + try { + return fingerprintBuildContext(prepared.buildCtx) === prepared.contextFingerprint; + } catch { + return false; + } +} + +/** Bind an expected fingerprint to a context for final one-shot verification. */ +export function createBuildContextVerifier( + buildCtx: string, + contextFingerprint: string, +): () => boolean { + return () => { + try { + return fingerprintBuildContext(buildCtx) === contextFingerprint; + } catch { + return false; + } + }; +} + +/** Dispose retained build inputs after onboarding consumes them or rebuild aborts. */ +export function disposePreparedBuildContext(prepared: FingerprintedPreparedBuildContext): boolean { + return prepared.cleanupBuildCtx(); +} diff --git a/src/lib/actions/sandbox/rebuild-recreate-phase.ts b/src/lib/actions/sandbox/rebuild-recreate-phase.ts index 5e832cf66ee..bdc38669d55 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-phase.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-phase.ts @@ -107,6 +107,7 @@ export async function runRebuildRecreatePhase(input: RebuildRecreatePhaseInput): mode: "non-interactive", hermesAuthMethod: rebuildDurableConfig.hermesAuthMethod, webSearchConfig: rebuildDurableConfig.webSearchConfig, + toolDisclosure: rebuildDurableConfig.toolDisclosure, telegramConfig: sessionMatchesSandbox ? sessionBefore?.telegramConfig : null, wechatConfig: sessionMatchesSandbox ? sessionBefore?.wechatConfig : null, migratedLegacyValueHashes: sessionMatchesSandbox @@ -145,6 +146,7 @@ export async function runRebuildRecreatePhase(input: RebuildRecreatePhaseInput): s.preferredInferenceApi = resumeConfig.preferredInferenceApi; s.compatibleEndpointReasoning = resumeConfig.compatibleEndpointReasoning; s.endpointUrl = resumeConfig.endpointUrl; + s.toolDisclosure = rebuildDurableConfig.toolDisclosure; return s; }); const sessionAfter = onboardSession.loadSession(); @@ -230,7 +232,11 @@ export async function runRebuildRecreatePhase(input: RebuildRecreatePhaseInput): console.error(""); console.error(" To recover manually:"); console.error(" 1. Fix the issue above (missing credential, Docker problem, etc.)"); - printMcpRebuildRetryCommand(sandboxName, rebuildMcpEntries); + printMcpRebuildRetryCommand( + sandboxName, + rebuildMcpEntries, + rebuildDurableConfig.toolDisclosure, + ); if (backupManifest) { console.error(" 3. Then restore your workspace state:"); console.error( diff --git a/src/lib/actions/sandbox/rebuild-target-config.ts b/src/lib/actions/sandbox/rebuild-target-config.ts index 716008ec75a..a328f7a0978 100644 --- a/src/lib/actions/sandbox/rebuild-target-config.ts +++ b/src/lib/actions/sandbox/rebuild-target-config.ts @@ -5,6 +5,7 @@ import { loadAgent } from "../../agent/defs"; import { webSearchProviderForConfig } from "../../inference/web-search"; import type { Session } from "../../state/onboard-session"; import * as onboardSession from "../../state/onboard-session"; +import type { ToolDisclosure } from "../../tool-disclosure"; import type { RebuildBail } from "./rebuild-credential-preflight"; import { isDcodeRebuildAgent } from "./rebuild-dcode-orchestrator"; import { @@ -70,6 +71,15 @@ function validateRebuildDurableConfig( ); return false; } + if (durableConfig.toolDisclosureError) { + printRebuildPreflightFailure( + "recorded tool-disclosure state is invalid.", + durableConfig.toolDisclosureError, + "Recorded tool-disclosure state is invalid", + bail, + ); + return false; + } if (durableConfig.fromDockerfileError) { printRebuildPreflightFailure( "recorded custom Dockerfile is invalid.", @@ -102,15 +112,22 @@ export function prepareRebuildTargetConfig( rebuildAgent: string | null, log: (message: string) => void, bail: RebuildBail, + requestedToolDisclosure?: ToolDisclosure, ): RebuildTargetConfig | null { const resumeConfig = prepareRebuildResumeConfig(sandboxName, sb, rebuildAgent, log, bail); if (!resumeConfig) return null; const sessionSnapshot = onboardSession.loadSession(); const sessionMatchesSandbox = sessionSnapshot?.sandboxName === sandboxName; - const durableConfig = resolveRebuildDurableConfig(sandboxName, sb, sessionSnapshot, { - provider: resumeConfig.provider, - model: resumeConfig.model, - }); + const durableConfig = resolveRebuildDurableConfig( + sandboxName, + sb, + sessionSnapshot, + { + provider: resumeConfig.provider, + model: resumeConfig.model, + }, + requestedToolDisclosure, + ); if (!validateRebuildDurableConfig(durableConfig, resumeConfig, bail)) return null; if (isDcodeRebuildAgent(rebuildAgent) && durableConfig.fromDockerfile) { printRebuildPreflightFailure( diff --git a/src/lib/actions/sandbox/rebuild-target-runtime.ts b/src/lib/actions/sandbox/rebuild-target-runtime.ts index 3e6e32cc7fc..b2320399e65 100644 --- a/src/lib/actions/sandbox/rebuild-target-runtime.ts +++ b/src/lib/actions/sandbox/rebuild-target-runtime.ts @@ -18,11 +18,13 @@ import { type RebuildBail, type RebuildLog, } from "./rebuild-credential-preflight"; +import type { PreparedRebuildImage } from "./rebuild-custom-image-preflight"; import * as rebuildImagePreflight from "./rebuild-custom-image-preflight"; import type { RebuildDurableConfig } from "./rebuild-durable-config"; import type { RebuildSandboxEntry } from "./rebuild-flow-helpers"; import type { RebuildRecreateOnboardOpts } from "./rebuild-gpu-opt-out"; import { printRebuildPreflightFailure } from "./rebuild-preflight-error"; +import { disposePreparedBuildContext } from "./rebuild-prepared-image-context"; import type { RebuildResumeConfig } from "./rebuild-resume-config"; import type { RebuildTargetConfig } from "./rebuild-target-config"; @@ -65,6 +67,10 @@ async function preflightRebuildWebSearchCredential( } } +export type RebuildTargetRuntimePreflightResult = + | { ok: true; preparedImage: PreparedRebuildImage | null } + | { ok: false }; + export async function preflightRebuildTargetRuntime( target: RebuildTargetConfig, sb: RebuildSandboxEntry, @@ -72,7 +78,7 @@ export async function preflightRebuildTargetRuntime( log: RebuildLog, bail: RebuildBail, options: { skipImagePreflight?: boolean } = {}, -): Promise { +): Promise { const webSearchConfig = target.durableConfig.webSearchConfig; const webSearchProvider = webSearchConfig ? webSearchProviderForConfig(webSearchConfig) : null; if ( @@ -90,7 +96,7 @@ export async function preflightRebuildTargetRuntime( `Recorded ${label} is unsupported by the rebuild image`, bail, ); - return false; + return { ok: false }; } if (webSearchProvider) { const credentialEnv = webSearchEnvFor(webSearchProvider); @@ -104,7 +110,7 @@ export async function preflightRebuildTargetRuntime( "Web Search and MCP credential ownership conflict", bail, ); - return false; + return { ok: false }; } } @@ -124,7 +130,7 @@ export async function preflightRebuildTargetRuntime( "Recorded sandbox GPU state is invalid", bail, ); - return false; + return { ok: false }; } try { await enforceDockerGpuPatchPreserveNetwork(target.resumeConfig.provider, sandboxGpuConfig, { @@ -139,9 +145,10 @@ export async function preflightRebuildTargetRuntime( "Sandbox GPU network preflight failed", bail, ); - return false; + return { ok: false }; } + let preparedImage: PreparedRebuildImage | null = null; if (!options.skipImagePreflight) { const customImage = await rebuildImagePreflight.preflightRebuildImage({ agent: target.agentDefinition, @@ -151,6 +158,7 @@ export async function preflightRebuildTargetRuntime( preferredInferenceApi: target.resumeConfig.preferredInferenceApi, compatibleEndpointReasoning: target.resumeConfig.compatibleEndpointReasoning, webSearchConfig: target.durableConfig.webSearchConfig, + toolDisclosure: target.durableConfig.toolDisclosure, hermesToolGateways: target.hermesToolGateways, sandboxGpuConfig, gatewayPort: recreateOptions.targetGatewayPort, @@ -165,25 +173,39 @@ export async function preflightRebuildTargetRuntime( "Replacement sandbox image preflight failed", bail, ); - return false; + return { ok: false }; } + preparedImage = customImage.prepared; } - if (!(await preflightRebuildWebSearchCredential(target.durableConfig, bail))) return false; + try { + if (!(await preflightRebuildWebSearchCredential(target.durableConfig, bail))) { + return { ok: false }; + } - // Credential preflight must use the same trusted selection. Legacy registry - // rows may recover provider/model from their own matching onboard session; - // checking the raw row first would miss that remote credential requirement. - return preflightRebuildCredentials( - { - ...sb, - provider: target.resumeConfig.provider, - model: target.resumeConfig.model, - credentialEnv: target.credentialEnv, - hermesAuthMethod: target.durableConfig.hermesAuthMethod, - }, - log, - bail, - ); + // Credential preflight must use the same trusted selection. Legacy registry + // rows may recover provider/model from their own matching onboard session; + // checking the raw row first would miss that remote credential requirement. + if ( + !preflightRebuildCredentials( + { + ...sb, + provider: target.resumeConfig.provider, + model: target.resumeConfig.model, + credentialEnv: target.credentialEnv, + hermesAuthMethod: target.durableConfig.hermesAuthMethod, + }, + log, + bail, + ) + ) { + return { ok: false }; + } + const result: RebuildTargetRuntimePreflightResult = { ok: true, preparedImage }; + preparedImage = null; + return result; + } finally { + if (preparedImage) disposePreparedBuildContext(preparedImage); + } } export async function preflightAuthoritativeOnboardRuntime( diff --git a/src/lib/adapters/fs/build-context-fingerprint.test.ts b/src/lib/adapters/fs/build-context-fingerprint.test.ts new file mode 100644 index 00000000000..eb6b044d8af --- /dev/null +++ b/src/lib/adapters/fs/build-context-fingerprint.test.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { fingerprintBuildContext } from "./build-context-fingerprint"; + +const FIXED_TIME = new Date("2026-01-01T00:00:00.000Z"); + +describe("fingerprintBuildContext", () => { + it.runIf(process.platform !== "win32")( + "rejects a symlink root even when its target changes or is retargeted", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fingerprint-root-")); + const firstTarget = path.join(root, "first"); + const secondTarget = path.join(root, "second"); + const linkedRoot = path.join(root, "context"); + fs.mkdirSync(firstTarget); + fs.mkdirSync(secondTarget); + fs.writeFileSync(path.join(firstTarget, "Dockerfile"), "FROM first\n"); + fs.writeFileSync(path.join(secondTarget, "Dockerfile"), "FROM second\n"); + fs.symlinkSync(firstTarget, linkedRoot, "dir"); + + try { + expect(() => fingerprintBuildContext(linkedRoot)).toThrow( + "build-context root must be a real directory", + ); + fs.writeFileSync(path.join(firstTarget, "Dockerfile"), "FROM changed\n"); + expect(() => fingerprintBuildContext(linkedRoot)).toThrow( + "build-context root must be a real directory", + ); + fs.unlinkSync(linkedRoot); + fs.symlinkSync(secondTarget, linkedRoot, "dir"); + expect(() => fingerprintBuildContext(linkedRoot)).toThrow( + "build-context root must be a real directory", + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "distinguishes independent files from an otherwise identical hardlink pair", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fingerprint-hardlink-")); + const first = path.join(root, "first.txt"); + const second = path.join(root, "second.txt"); + fs.writeFileSync(first, "identical\n"); + fs.writeFileSync(second, "identical\n"); + fs.utimesSync(first, FIXED_TIME, FIXED_TIME); + fs.utimesSync(second, FIXED_TIME, FIXED_TIME); + fs.utimesSync(root, FIXED_TIME, FIXED_TIME); + + try { + const independentFingerprint = fingerprintBuildContext(root); + fs.unlinkSync(second); + fs.linkSync(first, second); + fs.utimesSync(root, FIXED_TIME, FIXED_TIME); + + expect(fs.statSync(first).nlink).toBe(2); + expect(fingerprintBuildContext(root)).not.toBe(independentFingerprint); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it("fingerprints a file mtime when bytes and permissions do not change", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fingerprint-mtime-")); + const dockerfile = path.join(root, "Dockerfile"); + fs.writeFileSync(dockerfile, "FROM scratch\n", { mode: 0o644 }); + fs.utimesSync(dockerfile, FIXED_TIME, FIXED_TIME); + + try { + const originalFingerprint = fingerprintBuildContext(root); + fs.utimesSync(dockerfile, FIXED_TIME, new Date(FIXED_TIME.getTime() + 1_000)); + + expect(fs.readFileSync(dockerfile, "utf8")).toBe("FROM scratch\n"); + expect(fs.statSync(dockerfile).mode & 0o7777).toBe(0o644); + expect(fingerprintBuildContext(root)).not.toBe(originalFingerprint); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/adapters/fs/build-context-fingerprint.ts b/src/lib/adapters/fs/build-context-fingerprint.ts new file mode 100644 index 00000000000..5c56b08ec6f --- /dev/null +++ b/src/lib/adapters/fs/build-context-fingerprint.ts @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +type EntrySnapshot = fs.BigIntStats; +const FINGERPRINT_OPEN_FLAGS = + fs.constants.O_RDONLY | + (typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0) | + (typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0); + +function lstatEntry(absolutePath: string): EntrySnapshot { + return fs.lstatSync(absolutePath, { bigint: true }); +} + +function fstatEntry(fd: number): EntrySnapshot { + return fs.fstatSync(fd, { bigint: true }); +} + +function sameEntrySnapshot(left: EntrySnapshot, right: EntrySnapshot): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.nlink === right.nlink && + left.size === right.size && + left.mtimeNs === right.mtimeNs && + left.ctimeNs === right.ctimeNs + ); +} + +function requireStableEntry( + relativePath: string, + expected: EntrySnapshot, + actual: EntrySnapshot, +): void { + if (!sameEntrySnapshot(expected, actual)) { + throw new Error(`build-context entry changed during fingerprint: ${relativePath || "."}`); + } +} + +function readPinnedRegularFile( + absolutePath: string, + relativePath: string, +): { contents: Buffer; stat: EntrySnapshot } | null { + let fd: number; + try { + // Open before inspecting the path so the implementation consumes the same + // inode it validates. O_NONBLOCK also prevents a file-to-FIFO swap from + // hanging before fstat can reject the descriptor. + fd = fs.openSync(absolutePath, FINGERPRINT_OPEN_FLAGS); + } catch (openError) { + // O_NOFOLLOW rejects symlinks where it is available, and some platforms do + // not allow directories through openSync. Both remain path-fingerprinted; + // a regular file that could not be pinned must fail closed. + if (lstatEntry(absolutePath).isFile()) throw openError; + return null; + } + + try { + const descriptorBefore = fstatEntry(fd); + const pathBefore = lstatEntry(absolutePath); + // Without O_NOFOLLOW, openSync can follow a symlink. Never consume that + // descriptor as a regular build input; the caller fingerprints the link. + if (pathBefore.isSymbolicLink() || !descriptorBefore.isFile()) return null; + requireStableEntry(relativePath, pathBefore, descriptorBefore); + const contents = fs.readFileSync(fd); + requireStableEntry(relativePath, descriptorBefore, fstatEntry(fd)); + requireStableEntry(relativePath, pathBefore, lstatEntry(absolutePath)); + return { contents, stat: descriptorBefore }; + } finally { + fs.closeSync(fd); + } +} + +/** Fingerprint every byte and entry type in a staged build context. */ +export function fingerprintBuildContext(buildCtx: string): string { + const hash = crypto.createHash("sha256"); + const contextRoot = path.resolve(buildCtx); + const hardlinkOwners = new Map(); + const updateEntry = (kind: string, relativePath: string, stat: EntrySnapshot): void => { + // Docker COPY preserves the sticky, setgid, and setuid bits as well as + // ordinary permissions, mtimes, and hardlink relationships. Include those + // Docker-observable surfaces so a post-preflight metadata-only mutation + // cannot reuse this fingerprint. + hash.update( + `${kind}\0${relativePath}\0${String(stat.mode & 0o7777n)}\0${String(stat.size)}\0${String(stat.mtimeNs)}\0`, + ); + if (!stat.isDirectory()) { + const inodeKey = `${String(stat.dev)}:${String(stat.ino)}`; + const hardlinkOwner = hardlinkOwners.get(inodeKey) ?? relativePath; + hardlinkOwners.set(inodeKey, hardlinkOwner); + hash.update(`${String(stat.nlink)}\0${hardlinkOwner}\0`); + } + }; + const visit = (relativePath: string): void => { + const absolutePath = path.join(contextRoot, relativePath); + // The retained context must be a directory itself, not a symlink whose + // target can change after preflight while the link text stays constant. + const pinnedFile = relativePath ? readPinnedRegularFile(absolutePath, relativePath) : null; + if (pinnedFile) { + updateEntry("file", relativePath, pinnedFile.stat); + hash.update(pinnedFile.contents); + } else { + const stat = lstatEntry(absolutePath); + if (!relativePath && !stat.isDirectory()) { + throw new Error("build-context root must be a real directory"); + } + if (stat.isDirectory()) { + updateEntry("dir", relativePath, stat); + for (const name of fs.readdirSync(absolutePath).sort()) { + visit(relativePath ? path.join(relativePath, name) : name); + } + requireStableEntry(relativePath, stat, lstatEntry(absolutePath)); + } else if (stat.isSymbolicLink()) { + const target = fs.readlinkSync(absolutePath); + requireStableEntry(relativePath, stat, lstatEntry(absolutePath)); + updateEntry("link", relativePath, stat); + hash.update(target); + } else { + throw new Error(`unsupported build-context entry: ${relativePath || "."}`); + } + } + hash.update("\0"); + }; + + visit(""); + return hash.digest("hex"); +} diff --git a/src/lib/domain/lifecycle/options.test.ts b/src/lib/domain/lifecycle/options.test.ts index c2bc260f2ff..89a25d9597f 100644 --- a/src/lib/domain/lifecycle/options.test.ts +++ b/src/lib/domain/lifecycle/options.test.ts @@ -118,15 +118,33 @@ describe("lifecycle option normalization", () => { }); it("preserves typed rebuild options and still accepts compatibility argv", () => { - expect(normalizeRebuildSandboxOptions({ verbose: true, yes: true })).toEqual({ + expect( + normalizeRebuildSandboxOptions({ toolDisclosure: "direct", verbose: true, yes: true }), + ).toEqual({ + toolDisclosure: "direct", verbose: true, yes: true, }); - expect(normalizeRebuildSandboxOptions(["-v", "--force"])).toEqual({ + expect( + normalizeRebuildSandboxOptions(["-v", "--force", "--tool-disclosure", "progressive"]), + ).toEqual({ force: true, + toolDisclosure: "progressive", verbose: true, yes: false, }); + expect(normalizeRebuildSandboxOptions(["--tool-disclosure=direct"]).toolDisclosure).toBe( + "direct", + ); + expect(() => normalizeRebuildSandboxOptions(["--tool-disclosure", "sometimes"])).toThrow( + /progressive, direct/, + ); + expect(() => normalizeRebuildSandboxOptions(["--tool-disclosure"])).toThrow( + /progressive, direct/, + ); + expect(() => normalizeRebuildSandboxOptions(["--tool-disclosure="])).toThrow( + /progressive, direct/, + ); }); it("preserves typed maintenance options and still accepts compatibility argv", () => { diff --git a/src/lib/domain/lifecycle/options.ts b/src/lib/domain/lifecycle/options.ts index 73533bd26ad..7069b698265 100644 --- a/src/lib/domain/lifecycle/options.ts +++ b/src/lib/domain/lifecycle/options.ts @@ -1,6 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + normalizeToolDisclosure, + TOOL_DISCLOSURE_VALUES, + type ToolDisclosure, +} from "../../tool-disclosure"; + export interface DestroySandboxOptions { force?: boolean; yes?: boolean; @@ -27,6 +33,7 @@ function readCleanupGatewayEnv(): boolean | undefined { export interface RebuildSandboxOptions { force?: boolean; + toolDisclosure?: ToolDisclosure; verbose?: boolean; yes?: boolean; } @@ -69,14 +76,30 @@ export function normalizeDestroySandboxOptions( export function normalizeRebuildSandboxOptions( options: string[] | RebuildSandboxOptions = {}, ): RebuildSandboxOptions { + let rawToolDisclosure: unknown; if (Array.isArray(options)) { + const splitIndex = options.lastIndexOf("--tool-disclosure"); + const inline = [...options].reverse().find((value) => value.startsWith("--tool-disclosure=")); + const toolDisclosureFlagProvided = splitIndex >= 0 || inline !== undefined; + rawToolDisclosure = + splitIndex >= 0 ? options[splitIndex + 1] : inline?.slice("--tool-disclosure=".length); + const toolDisclosure = normalizeToolDisclosure(rawToolDisclosure); + if (toolDisclosureFlagProvided && !toolDisclosure) { + throw new Error(`--tool-disclosure must be one of: ${TOOL_DISCLOSURE_VALUES.join(", ")}.`); + } return { force: options.includes("--force"), + ...(toolDisclosure ? { toolDisclosure } : {}), verbose: options.includes("--verbose") || options.includes("-v"), yes: options.includes("--yes"), }; } - return options; + rawToolDisclosure = options.toolDisclosure; + const toolDisclosure = normalizeToolDisclosure(rawToolDisclosure); + if (rawToolDisclosure !== undefined && !toolDisclosure) { + throw new Error(`toolDisclosure must be one of: ${TOOL_DISCLOSURE_VALUES.join(", ")}.`); + } + return { ...options, ...(toolDisclosure ? { toolDisclosure } : {}) }; } export function normalizeGarbageCollectImagesOptions( diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index c317e01bbd3..70aa5abc20e 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -33,6 +33,8 @@ const { const setupNimOllama: typeof import("./onboard/setup-nim-ollama") = require("./onboard/setup-nim-ollama"); const inferenceInputCapability = require("./onboard/inference-input-capability"); const reasoningMode: typeof import("./onboard/reasoning-mode") = require("./onboard/reasoning-mode"); +const toolDisclosureFlow: typeof import("./onboard/tool-disclosure-flow") = require("./onboard/tool-disclosure-flow"); +const inferenceRouteHelpers: typeof import("./onboard/inference-route") = require("./onboard/inference-route"); const { cleanupTempDir }: typeof import("./onboard/temp-files") = require("./onboard/temp-files"); const { abortNonInteractive, @@ -688,8 +690,8 @@ function isNonInteractive(): boolean { return NON_INTERACTIVE || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; } -function isRecreateSandbox(): boolean { - return RECREATE_SANDBOX || process.env.NEMOCLAW_RECREATE_SANDBOX === "1"; +function isRecreateSandbox(requested = false): boolean { + return requested || RECREATE_SANDBOX || process.env.NEMOCLAW_RECREATE_SANDBOX === "1"; } function isAutoYes(): boolean { @@ -1013,23 +1015,11 @@ function upsertMessagingProviders( const providerExistsInGateway = (name: string) => onboardProviders.providerExistsInGateway(name, runOpenshell); -function verifyInferenceRoute(_provider: string, _model: string): void { - const output = runCaptureOpenshell(["inference", "get"], { ignoreError: true }); - if (!output || /Gateway inference:\s*[\r\n]+\s*Not configured/i.test(output)) { - console.error(" OpenShell inference route was not configured."); - process.exit(1); - } -} - -function isInferenceRouteReady(provider: string, model: string): boolean { - const live = parseGatewayInference( - runCaptureOpenshell(["inference", "get"], { ignoreError: true }), - ); - return Boolean(live && live.provider === provider && live.model === model); -} +const { verifyInferenceRoute, isInferenceRouteReady } = + inferenceRouteHelpers.createInferenceRouteHelpers(runCaptureOpenshell); const { - reconcileSandboxForCreate, + inspectSandboxForCreate, pruneStaleSandboxEntry, confirmRecreateForSelectionDrift, isOpenclawReady, @@ -2391,6 +2381,7 @@ async function createSandboxWithBaseImageResolution( resourceProfile: import("./resources-cmd").ResourceProfile | null = null, hermesToolGateways: string[] = [], hermesAuthMethod: HermesAuthMethod | null = null, + createIntent: import("./onboard/types").SandboxCreateIntent | null = null, preparedBuildContext: PreparedSandboxBuildContext | null = null, ) { step(6, 8, "Creating sandbox"); @@ -2399,6 +2390,7 @@ async function createSandboxWithBaseImageResolution( sandboxNameOverride ?? (await promptValidatedSandboxName(agent)), "sandbox name", ); + preparedDcodeRebuild.assertPreparedDcodeTarget(preparedBuildContext, agent, fromDockerfile); enabledChannels = filterEnabledChannelsByAgent(enabledChannels, agent); const effectiveSandboxGpuConfig = sandboxGpuConfig ?? resolveSandboxGpuConfig(gpu, { flag: null, device: null }); @@ -2465,12 +2457,11 @@ async function createSandboxWithBaseImageResolution( }, ); - const { existingEntry, preservedMcpState, liveExists } = reconcileSandboxForCreate(sandboxName); + // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. + const { existingEntry, preservedMcpState, liveExists, effectiveToolDisclosure, toolDisclosureMigrationNeeded, toolDisclosureMigrationNote } = toolDisclosureFlow.prepareSandboxToolDisclosure(sandboxName, preparedBuildContext?.rebuildTarget?.fromDockerfile ? preparedBuildContext.stagedDockerfile : fromDockerfile, isRecreateSandbox(createIntent?.recreate), inspectSandboxForCreate, createIntent?.toolDisclosure ?? null); // #4614: capture default AFTER prune so a stale registry row isn't read as a live sandbox. const sandboxWasLiveDefault = liveExists && wasSandboxDefault(registry.getDefault(), sandboxName); - // Declared outside the liveExists block so it is accessible during - // post-creation restore (the sandbox create path runs after the block). let pendingStateRestore: BackupResult | null = null; let pendingStateRestoreBackupPath: string | null = null; let notReadyRecreateInProgress = false; @@ -2486,9 +2477,9 @@ async function createSandboxWithBaseImageResolution( const existingSandboxState = getSandboxReuseState(sandboxName); const requestedAgentName = getRequestedSandboxAgentName(agent); const agentDrift = getSandboxAgentDrift(sandboxName, requestedAgentName); - let recreateForAgentDrift = agentDrift.changed && isRecreateSandbox(); + let recreateForAgentDrift = agentDrift.changed && isRecreateSandbox(createIntent?.recreate); - if (agentDrift.changed && !isRecreateSandbox()) { + if (agentDrift.changed && !isRecreateSandbox(createIntent?.recreate)) { console.log( ` Sandbox '${sandboxName}' already exists as ${formatSandboxAgentName(agentDrift.existingAgentName)}.`, ); @@ -2550,13 +2541,14 @@ async function createSandboxWithBaseImageResolution( : { changed: false, changedProviders: [] }; if ( - !isRecreateSandbox() && + !isRecreateSandbox(createIntent?.recreate) && !recreateForAgentDrift && !needsProviderMigration && !sandboxGpuDrift && !credentialRotation.changed && !hermesToolGatewayDrift && - !hermesDashboardDrift + !hermesDashboardDrift && + !toolDisclosureMigrationNeeded ) { // Guard against reusing a CPU-only sandbox when GPU passthrough is enabled. // Placed before the non-interactive / interactive split so all reuse @@ -2707,6 +2699,8 @@ async function createSandboxWithBaseImageResolution( note(` Sandbox '${sandboxName}' exists — recreating to apply Hermes managed-tool changes.`); } else if (hermesDashboardDrift) { note(` Sandbox '${sandboxName}' exists — recreating to apply Hermes dashboard settings.`); + } else if (toolDisclosureMigrationNote) { + note(toolDisclosureMigrationNote); } else if (credentialRotation.changed) { // Message already printed above during backup. } else if (existingSandboxState === "ready") { @@ -2720,7 +2714,7 @@ async function createSandboxWithBaseImageResolution( ` Sandbox '${sandboxName}' has managed MCP servers. Refusing the generic onboard recreation path.`, ); console.error( - ` Run \`${cliName()} ${sandboxName} rebuild --yes\` so MCP providers and adapter state are preserved transactionally.`, + ` Run \`${cliName()} ${sandboxName} rebuild --yes --tool-disclosure ${effectiveToolDisclosure}\` so MCP providers and adapter state are preserved transactionally.`, ); process.exit(1); } @@ -2867,6 +2861,7 @@ async function createSandboxWithBaseImageResolution( provider, preferredInferenceApi, webSearchConfig, + toolDisclosure: effectiveToolDisclosure, hermesToolGateways, sandboxGpuConfig: effectiveSandboxGpuConfig, ...baseImageResolutionFlow.getBaseImageResolutionPatchOptions(baseImageResolutionContext), @@ -3046,6 +3041,7 @@ async function createSandboxWithBaseImageResolution( agentVersionKnown: !fromDockerfile, imageTag: resolvedImageTag, appliedPolicies: initialSandboxPolicy.appliedPresets, + toolDisclosure: effectiveToolDisclosure, // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. ...sandboxRegistration.creationFidelity(webSearchConfig, fromDockerfile, normalizeHermesAuthMethod(hermesAuthMethod)), plannedMessagingState, @@ -4550,6 +4546,9 @@ async function preflightAuthoritativeRebuildTarget( // ── Main ───────────────────────────────────────────────────────── const onboard = onboardEntryOptions.withNonInteractiveEnvironment(runOnboard); async function runOnboard(opts: OnboardOptions = {}): Promise { + const requestedToolDisclosure = toolDisclosureFlow.applyOnboardToolDisclosureRequest( + opts.toolDisclosure, + ); const authoritativeGateway = authoritativeRebuildTarget.resolveAuthoritativeOnboardGatewayBinding(opts); const previousGatewayBinding = { name: GATEWAY_NAME, port: GATEWAY_PORT }; @@ -4695,6 +4694,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { authoritativeResumeConfig: opts.authoritativeResumeConfig === true, agentFlag: opts.agent || null, envAgent: process.env.NEMOCLAW_AGENT || null, + requestedToolDisclosure, }, { loadSession: onboardSession.loadSession, @@ -4986,7 +4986,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { rootDir: ROOT, }, sandboxDeps: { - resolvePath: path.resolve, + resolvePath: preparedDcodeRuntime.resolveDockerfileProbePath, agentSupportsWebSearch, agentSupportsWebSearchProvider, note, diff --git a/src/lib/onboard/build-context-stage.ts b/src/lib/onboard/build-context-stage.ts index 7ef8ddafbcb..1e5c8a5dcd1 100644 --- a/src/lib/onboard/build-context-stage.ts +++ b/src/lib/onboard/build-context-stage.ts @@ -40,6 +40,13 @@ export interface CreateSandboxBuildContextResult extends StagedBuildContext { /** Exact staged and patched context transferred from rebuild preflight to create. */ export interface PreparedSandboxBuildContext extends CreateSandboxBuildContextResult { buildId: string; + /** Recheck retained bytes at the final one-shot consumption boundary. */ + verifyBuildCtx?(): boolean; + /** Exact recorded target authorized to consume a generic rebuild handoff. */ + rebuildTarget?: { + agentName: string | null; + fromDockerfile: string | null; + }; } function createCleanupBuildContext(buildCtx: string): () => boolean { @@ -110,9 +117,11 @@ export function stageCreateSandboxBuildContext( recursive: true, filter: shouldIncludeCustomContextPath, }); - if (path.basename(fromResolved) !== "Dockerfile") { - fs.copyFileSync(fromResolved, stagedDockerfile); - } + // Always materialize the selected Dockerfile as a regular file. cpSync + // preserves symlinks, which would otherwise leave a retained rebuild + // context dependent on a mutable source path after preflight succeeds. + fs.rmSync(stagedDockerfile, { force: true }); + fs.copyFileSync(fromResolved, stagedDockerfile); } catch (err) { cleanupCustomBuildCtx(); const errorObject = typeof err === "object" && err !== null ? err : null; diff --git a/src/lib/onboard/command-support.ts b/src/lib/onboard/command-support.ts index 2b164cd5bb2..f87b3845fe3 100644 --- a/src/lib/onboard/command-support.ts +++ b/src/lib/onboard/command-support.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Flags } from "@oclif/core"; - +import { TOOL_DISCLOSURE_VALUES, type ToolDisclosure } from "../tool-disclosure"; import { describeAgentFlag } from "./agent-flag-help"; import { NOTICE_ACCEPT_FLAG, NOTICE_ACCEPT_FLAG_NAME } from "./usage-notice"; @@ -46,7 +46,7 @@ function agentFlagDescription(): string { } export const onboardUsage = [ - `onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--agents ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [${NOTICE_ACCEPT_FLAG}]`, + `onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--agents ] [--tool-disclosure ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [${NOTICE_ACCEPT_FLAG}]`, ]; export const onboardExamples = [ @@ -74,6 +74,7 @@ export type OnboardFlags = { "sandbox-gpu-device"?: string; agent?: string; agents?: string; + "tool-disclosure"?: ToolDisclosure; "control-ui-port"?: number; yes?: boolean; "no-ollama-autostart"?: boolean; @@ -121,6 +122,11 @@ export function buildOnboardFlags(): Record { description: "Path to a YAML manifest declaring secondary OpenClaw agents, agents.defaults, and main-agent overrides; baked into the sandbox image", }), + "tool-disclosure": Flags.string({ + description: + "Choose progressive tool discovery or direct exposure of all session-authorized tools", + options: [...TOOL_DISCLOSURE_VALUES], + }), "control-ui-port": Flags.integer({ description: "Host port for the local control UI", max: 65535, diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index facff88dffd..158b14fa458 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -43,6 +43,7 @@ describe("onboard command options", () => { "sandbox-gpu": true, "sandbox-gpu-device": "nvidia.com/gpu=0", agent: "dcode", + "tool-disclosure": "direct", "control-ui-port": 18790, gpu: true, yes: true, @@ -63,6 +64,7 @@ describe("onboard command options", () => { acceptThirdPartySoftware: true, agent: "langchain-deepagents-code", agentsManifest: null, + toolDisclosure: "direct", controlUiPort: 18790, gpu: true, noGpu: false, @@ -84,6 +86,7 @@ describe("onboard command options", () => { acceptThirdPartySoftware: false, agent: null, agentsManifest: null, + toolDisclosure: null, controlUiPort: null, gpu: false, noGpu: false, @@ -98,6 +101,23 @@ describe("onboard command options", () => { ).toBe(true); }); + it("uses the agent-neutral tool-disclosure env and rejects unknown values", () => { + expect(resolve({}, { env: { NEMOCLAW_TOOL_DISCLOSURE: " DIRECT " } }).toolDisclosure).toBe( + "direct", + ); + const errors: string[] = []; + expect(() => + resolve( + {}, + { + env: { NEMOCLAW_TOOL_DISCLOSURE: "sometimes" }, + error: (message = "") => errors.push(message), + }, + ), + ).toThrow("exit:1"); + expect(errors.join("\n")).toContain("must be one of: progressive, direct"); + }); + it("preserves the requested Dockerfile path after validating the resolved file", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-from-")); const dockerfilePath = path.join(tmpDir, "Custom.Dockerfile"); diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index 09279d0e273..230ad7fee6f 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -5,6 +5,11 @@ import fs from "node:fs"; import path from "node:path"; import { formatAgentAliasSuffix, resolveAgentNameAlias } from "../agent/aliases"; +import { + resolveToolDisclosureRequest, + TOOL_DISCLOSURE_ENV, + type ToolDisclosure, +} from "../tool-disclosure"; import { applyAgentsManifestEnv } from "./agents-manifest"; import type { OnboardFlags } from "./command-support"; import { isOpenclawAgent } from "./openclaw-otel-policy-presets"; @@ -22,6 +27,7 @@ export interface OnboardCommandOptions { acceptThirdPartySoftware: boolean; agent: string | null; agentsManifest: string | null; + toolDisclosure: ToolDisclosure | null; controlUiPort: number | null; gpu: boolean; noGpu: boolean; @@ -127,6 +133,12 @@ export function resolveOnboardOptions( deps: ResolveOnboardOptionsDeps, ): OnboardCommandOptions { const agent = resolveAgent(flags.agent, deps); + let toolDisclosure: ToolDisclosure | null; + try { + toolDisclosure = resolveToolDisclosureRequest(flags["tool-disclosure"], deps.env); + } catch (error) { + fail(deps, ` ${error instanceof Error ? error.message : String(error)}`); + } return { nonInteractive: flags["non-interactive"] === true, resume: flags.resume === true, @@ -140,6 +152,7 @@ export function resolveOnboardOptions( flags[NOTICE_ACCEPT_FLAG_NAME] === true || String(deps.env[NOTICE_ACCEPT_ENV] || "") === "1", agent, agentsManifest: resolveAgentsManifest(flags.agents, agent, deps), + toolDisclosure, controlUiPort: flags["control-ui-port"] ?? null, gpu: flags.gpu === true, noGpu: flags["no-gpu"] === true, @@ -159,6 +172,10 @@ function isPromptCancellation(error: unknown): boolean { export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise { const options = resolveOnboardOptions(deps.flags, deps); if (options.noOllamaAutostart) process.env.NEMOCLAW_OLLAMA_NO_AUTOSTART = "1"; + // Keep direct callers and the legacy monolithic onboard path on the same + // canonical source. No value is written for the default so resume/rebuild + // can distinguish an explicit request from an unset environment. + if (options.toolDisclosure) process.env[TOOL_DISCLOSURE_ENV] = options.toolDisclosure; if (options.agentsManifest) applyAgentsManifestEnv(options.agentsManifest); try { await deps.runOnboard(options); diff --git a/src/lib/onboard/dockerfile-patch-security.test.ts b/src/lib/onboard/dockerfile-patch-security.test.ts index 7da00a7f28d..dc76b350b8a 100644 --- a/src/lib/onboard/dockerfile-patch-security.test.ts +++ b/src/lib/onboard/dockerfile-patch-security.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -8,6 +9,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { patchStagedDockerfile } from "./dockerfile-patch"; +import { assertToolDisclosureDockerfileContract } from "./dockerfile-tool-disclosure-contract"; const tmpRoots: string[] = []; @@ -61,10 +63,72 @@ describe("dockerfile patch security guards", () => { expect(() => patchStagedDockerfile(dockerfilePath, "custom-model", "https://chat.example"), - ).toThrow(/Refusing to patch Dockerfile through a symlink/); + ).toThrow(/Refusing to patch Dockerfile because it changed during validation/); expect(fs.readFileSync(swappedTarget, "utf-8")).toBe("ARG NEMOCLAW_MODEL=swapped\n"); }); + it("refuses an initially hard-linked staged Dockerfile without modifying its external alias", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dockerfile-hardlink-test-")); + tmpRoots.push(dir); + const externalPath = path.join(dir, "external.Dockerfile"); + const dockerfilePath = path.join(dir, "Dockerfile"); + const original = "ARG NEMOCLAW_MODEL=external\n"; + fs.writeFileSync(externalPath, original, "utf-8"); + fs.linkSync(externalPath, dockerfilePath); + + expect(() => + patchStagedDockerfile(dockerfilePath, "custom-model", "https://chat.example"), + ).toThrow(/Refusing to patch hard-linked Dockerfile path/); + expect(fs.readFileSync(externalPath, "utf-8")).toBe(original); + }); + + it("refuses an external hardlink swapped in between read and replacement", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dockerfile-hardlink-swap-test-")); + tmpRoots.push(dir); + const dockerfilePath = path.join(dir, "Dockerfile"); + const externalPath = path.join(dir, "external.Dockerfile"); + const externalAlias = path.join(dir, "external-alias.Dockerfile"); + const external = "ARG NEMOCLAW_MODEL=external\n"; + fs.writeFileSync(dockerfilePath, "ARG NEMOCLAW_MODEL=old\n", "utf-8"); + fs.writeFileSync(externalPath, external, "utf-8"); + fs.linkSync(externalPath, externalAlias); + + const readFileSync = fs.readFileSync.bind(fs); + vi.spyOn(fs, "readFileSync").mockImplementationOnce((file, options) => { + const content = readFileSync(file as Parameters[0], options as never); + fs.unlinkSync(dockerfilePath); + fs.linkSync(externalPath, dockerfilePath); + return content; + }); + + expect(() => + patchStagedDockerfile(dockerfilePath, "custom-model", "https://chat.example"), + ).toThrow(/Refusing to patch Dockerfile because it changed during validation/); + expect(fs.readFileSync(externalPath, "utf-8")).toBe(external); + expect(fs.readFileSync(externalAlias, "utf-8")).toBe(external); + }); + + it("refuses a Dockerfile reached through a stable symlinked staging parent", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dockerfile-parent-link-test-")); + tmpRoots.push(dir); + const realParent = path.join(dir, "real-parent"); + const linkedParent = path.join(dir, "linked-parent"); + fs.mkdirSync(realParent); + const realDockerfile = path.join(realParent, "Dockerfile"); + const original = "ARG NEMOCLAW_MODEL=outside\n"; + fs.writeFileSync(realDockerfile, original, "utf-8"); + fs.symlinkSync(realParent, linkedParent, "dir"); + + expect(() => + patchStagedDockerfile( + path.join(linkedParent, "Dockerfile"), + "custom-model", + "https://chat.example", + ), + ).toThrow(/Refusing to patch Dockerfile through a symlinked parent/); + expect(fs.readFileSync(realDockerfile, "utf-8")).toBe(original); + }); + it("refuses a non-regular staged Dockerfile swapped in before write without truncating", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dockerfile-dir-swap-test-")); tmpRoots.push(dir); @@ -86,4 +150,74 @@ describe("dockerfile patch security guards", () => { expect(truncateSpy).not.toHaveBeenCalled(); expect(fs.statSync(dockerfilePath).isDirectory()).toBe(true); }); + + it("uses read-only wording when contract validation rejects a Dockerfile symlink", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dockerfile-contract-link-test-")); + tmpRoots.push(dir); + const realDockerfile = path.join(dir, "real.Dockerfile"); + const linkDockerfile = path.join(dir, "Dockerfile"); + fs.writeFileSync(realDockerfile, "FROM scratch\n", "utf-8"); + fs.symlinkSync(realDockerfile, linkDockerfile); + + expect(() => assertToolDisclosureDockerfileContract(linkDockerfile, "progressive")).toThrow( + /Refusing to open Dockerfile through a symlink/, + ); + }); + + it.skipIf(process.platform === "win32" || typeof fs.constants.O_NONBLOCK !== "number")( + "rejects a Dockerfile FIFO without blocking during validation", + () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dockerfile-fifo-test-")); + tmpRoots.push(dir); + const fifo = path.join(dir, "Dockerfile"); + execFileSync("mkfifo", [fifo]); + + expect(() => assertToolDisclosureDockerfileContract(fifo, "progressive")).toThrow( + /Custom Dockerfile path is not a file/, + ); + }, + ); + + it("rejects an ancestor directory swap around the Dockerfile open", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dockerfile-parent-swap-test-")); + tmpRoots.push(dir); + const trustedDir = path.join(dir, "trusted"); + const movedTrustedDir = path.join(dir, "trusted-moved"); + const redirectedDir = path.join(dir, "redirected"); + fs.mkdirSync(trustedDir); + fs.mkdirSync(redirectedDir); + const validContract = [ + "FROM scratch", + "ARG NEMOCLAW_TOOL_DISCLOSURE=progressive", + "ENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}", + "", + ].join("\n"); + fs.writeFileSync(path.join(trustedDir, "Dockerfile"), validContract, "utf-8"); + fs.writeFileSync(path.join(redirectedDir, "Dockerfile"), validContract, "utf-8"); + + const openSync = fs.openSync.bind(fs); + let swappedParent = false; + const openThroughSwappedParent = (...args: Parameters) => { + fs.renameSync(trustedDir, movedTrustedDir); + fs.renameSync(redirectedDir, trustedDir); + try { + const fd = openSync(...args); + swappedParent = true; + return fd; + } finally { + fs.renameSync(trustedDir, redirectedDir); + fs.renameSync(movedTrustedDir, trustedDir); + } + }; + vi.spyOn(fs, "openSync").mockImplementation(((...args: Parameters) => { + return swappedParent || path.basename(String(args[0])) !== "Dockerfile" + ? openSync(...args) + : openThroughSwappedParent(...args); + }) as typeof fs.openSync); + + expect(() => + assertToolDisclosureDockerfileContract(path.join(trustedDir, "Dockerfile"), "progressive"), + ).toThrow(/Dockerfile because it changed during validation/); + expect(swappedParent).toBe(true); + }); }); diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 2dd565acea4..dbf59aba1b6 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -1,8 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import fs from "node:fs"; - import { getSandboxInferenceConfig } from "../inference/config"; import { isWebSearchEnabled, @@ -15,63 +13,25 @@ import { formatSandboxBaseImageResolutionLabels, type SandboxBaseImageResolutionMetadata, } from "../sandbox-base-image"; +import { + DEFAULT_TOOL_DISCLOSURE, + normalizeToolDisclosure, + type ToolDisclosure, +} from "../tool-disclosure"; +import { + dockerfileInstructions, + readDockerfilePatchSnapshot, + replaceDockerfilePatchSnapshot, + validateToolDisclosureDockerfileContract, +} from "./dockerfile-tool-disclosure-contract"; + +export { assertToolDisclosureDockerfileContract } from "./dockerfile-tool-disclosure-contract"; const SANDBOX_BASE_IMAGE = "ghcr.io/nvidia/nemoclaw/sandbox-base"; const PROXY_HOST_RE = /^[A-Za-z0-9._-]+$/; const POSITIVE_INT_RE = /^[1-9][0-9]*$/; type LooseObject = Record; -const O_NOFOLLOW = fs.constants.O_NOFOLLOW; - -function errnoCode(err: unknown): string | null { - return typeof err === "object" && err !== null && "code" in err - ? String((err as { code?: unknown }).code) - : null; -} - -function openExistingRegularDockerfileNoFollow(dockerfilePath: string, flags: number): number { - if (typeof O_NOFOLLOW !== "number") { - throw new Error("Refusing to patch Dockerfile: O_NOFOLLOW is unavailable on this platform."); - } - let fd: number; - try { - fd = fs.openSync(dockerfilePath, flags | O_NOFOLLOW, 0o600); - } catch (err) { - if (errnoCode(err) === "ELOOP") { - throw new Error(`Refusing to patch Dockerfile through a symlink: ${dockerfilePath}`); - } - throw err; - } - try { - const stat = fs.fstatSync(fd); - if (!stat.isFile()) { - throw new Error(`Refusing to patch non-regular Dockerfile path: ${dockerfilePath}`); - } - return fd; - } catch (err) { - fs.closeSync(fd); - throw err; - } -} - -function readExistingDockerfileNoFollow(dockerfilePath: string): string { - const fd = openExistingRegularDockerfileNoFollow(dockerfilePath, fs.constants.O_RDONLY); - try { - return fs.readFileSync(fd, "utf8"); - } finally { - fs.closeSync(fd); - } -} - -function writeExistingDockerfileNoFollow(dockerfilePath: string, dockerfile: string): void { - const fd = openExistingRegularDockerfileNoFollow(dockerfilePath, fs.constants.O_WRONLY); - try { - fs.ftruncateSync(fd, 0); - fs.writeFileSync(fd, dockerfile, { encoding: "utf8" }); - } finally { - fs.closeSync(fd); - } -} export function encodeDockerJsonArg(value: unknown): string { return Buffer.from(JSON.stringify(value ?? {}), "utf8").toString("base64"); @@ -89,6 +49,8 @@ export type DockerfileBuildIdPolicy = "preserve" | "rewrite"; export interface PatchStagedDockerfileOptions { buildIdPolicy?: DockerfileBuildIdPolicy; + toolDisclosure?: ToolDisclosure; + requireToolDisclosureContract?: boolean; baseImageResolutionMetadata?: SandboxBaseImageResolutionMetadata | null; } @@ -127,7 +89,17 @@ export function patchStagedDockerfile( inferenceBaseUrlOverride && inferenceBaseUrlOverride.trim() ? inferenceBaseUrlOverride : sandboxInference.inferenceBaseUrl; - let dockerfile = readExistingDockerfileNoFollow(dockerfilePath); + const patchSnapshot = readDockerfilePatchSnapshot(dockerfilePath); + let dockerfile = patchSnapshot.content; + const toolDisclosure = normalizeToolDisclosure(options.toolDisclosure) ?? DEFAULT_TOOL_DISCLOSURE; + const toolDisclosureInstruction = options.requireToolDisclosureContract + ? validateToolDisclosureDockerfileContract(dockerfile, toolDisclosure) + : dockerfileInstructions(dockerfile).find((instruction) => + /^ARG\s+NEMOCLAW_TOOL_DISCLOSURE\s*=/.test(instruction.text), + ); + if (toolDisclosureInstruction) { + dockerfile = `${dockerfile.slice(0, toolDisclosureInstruction.start)}ARG NEMOCLAW_TOOL_DISCLOSURE=${sanitizeDockerArg(toolDisclosure)}${dockerfile.slice(toolDisclosureInstruction.end)}`; + } // Pin the base image to a specific digest when available (#1904). // The ref must come from pullAndResolveBaseImageDigest() — never from // blueprint.yaml, whose digest belongs to a different registry. @@ -350,5 +322,5 @@ export function patchStagedDockerfile( `ARG NEMOCLAW_EXTRA_AGENTS_JSON_B64=${encoded}`, ); } - writeExistingDockerfileNoFollow(dockerfilePath, dockerfile); + replaceDockerfilePatchSnapshot(dockerfilePath, patchSnapshot, dockerfile); } diff --git a/src/lib/onboard/dockerfile-tool-disclosure-contract.test.ts b/src/lib/onboard/dockerfile-tool-disclosure-contract.test.ts new file mode 100644 index 00000000000..4e56509dc9a --- /dev/null +++ b/src/lib/onboard/dockerfile-tool-disclosure-contract.test.ts @@ -0,0 +1,169 @@ +// 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 { afterEach, describe, expect, it } from "vitest"; + +import { patchStagedDockerfile } from "./dockerfile-patch"; + +const tmpRoots: string[] = []; + +function dockerfileWith(content: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-tool-disclosure-contract-test-")); + tmpRoots.push(dir); + const file = path.join(dir, "Dockerfile"); + fs.writeFileSync(file, content, "utf-8"); + return file; +} + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("Dockerfile tool-disclosure contract", () => { + it("requires one consumed tool-disclosure ARG for custom image contracts", () => { + const patchCustom = (source: string) => { + const dockerfilePath = dockerfileWith(source); + patchStagedDockerfile( + dockerfilePath, + "custom-model", + "http://127.0.0.1:18789", + "build-1", + "nvidia-prod", + null, + null, + null, + false, + null, + [], + { + toolDisclosure: "direct", + requireToolDisclosureContract: true, + }, + ); + return fs.readFileSync(dockerfilePath, "utf8"); + }; + + expect(() => patchCustom("FROM scratch\n")).toThrow(/does not declare ARG/); + expect(() => + patchCustom( + "ARG NEMOCLAW_TOOL_DISCLOSURE=progressive\nARG NEMOCLAW_TOOL_DISCLOSURE=direct\n", + ), + ).toThrow(/exactly one/); + expect(() => patchCustom("ARG NEMOCLAW_TOOL_DISCLOSURE=progressive\n")).toThrow( + /promote.*final-stage ENV/, + ); + expect(() => + patchCustom( + "ARG NEMOCLAW_TOOL_DISCLOSURE=progressive\n# ENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}\n", + ), + ).toThrow(/promote.*final-stage ENV/); + expect(() => + patchCustom( + "ENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}\nARG NEMOCLAW_TOOL_DISCLOSURE=progressive\n", + ), + ).toThrow(/after its declaration/); + expect(() => + patchCustom( + "ARG NEMOCLAW_TOOL_DISCLOSURE=progressive\nENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}\nENV NEMOCLAW_TOOL_DISCLOSURE=progressive\n", + ), + ).toThrow(/no later override/); + expect(() => + patchCustom( + 'ARG NEMOCLAW_TOOL_DISCLOSURE=progressive\nENV FOO="prefix NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE} suffix"\n', + ), + ).toThrow(/promote.*final-stage ENV/); + expect(() => + patchCustom( + "ARG NEMOCLAW_TOOL_DISCLOSURE=progressive\nENV NEMOCLAW_TOOL_DISCLOSURE=\\$NEMOCLAW_TOOL_DISCLOSURE\n", + ), + ).toThrow(/promote.*final-stage ENV/); + expect(() => + patchCustom( + "ARG NEMOCLAW_TOOL_DISCLOSURE=progressive\nENV NEMOCLAW_TOOL_DISCLOSURE='$NEMOCLAW_TOOL_DISCLOSURE'\n", + ), + ).toThrow(/promote.*final-stage ENV/); + expect(() => + patchCustom( + [ + "FROM scratch AS discarded", + "ARG NEMOCLAW_TOOL_DISCLOSURE=progressive", + "ENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}", + "FROM scratch", + ].join("\n"), + ), + ).toThrow(/outside the final stage/); + expect( + patchCustom( + "ARG NEMOCLAW_TOOL_DISCLOSURE=progressive\nENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}\n", + ), + ).toContain("ARG NEMOCLAW_TOOL_DISCLOSURE=direct"); + expect( + patchCustom( + "FROM scratch\nARG NEMOCLAW_TOOL_DISCLOSURE=progressive\nENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}\n", + ), + ).toContain("ARG NEMOCLAW_TOOL_DISCLOSURE=direct"); + expect( + patchCustom( + 'FROM scratch\nARG NEMOCLAW_TOOL_DISCLOSURE=progressive\nENV NEMOCLAW_TOOL_DISCLOSURE="${NEMOCLAW_TOOL_DISCLOSURE}"\n', + ), + ).toContain("ARG NEMOCLAW_TOOL_DISCLOSURE=direct"); + expect( + patchCustom( + "FROM scratch\nARG \\\n NEMOCLAW_TOOL_DISCLOSURE=progressive\nENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}\n", + ), + ).toContain("ARG NEMOCLAW_TOOL_DISCLOSURE=direct"); + const patchedMultiStage = patchCustom( + [ + "FROM scratch AS build", + "ARG NEMOCLAW_TOOL_DISCLOSURE=progressive", + "ENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}", + "FROM scratch", + "ARG NEMOCLAW_TOOL_DISCLOSURE=progressive", + "ENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}", + ].join("\n"), + ); + expect(patchedMultiStage.match(/ARG NEMOCLAW_TOOL_DISCLOSURE=progressive/g)).toHaveLength(1); + expect(patchedMultiStage.match(/ARG NEMOCLAW_TOOL_DISCLOSURE=direct/g)).toHaveLength(1); + expect(() => + patchCustom( + [ + "FROM scratch", + 'RUN <<\'FIRST\' <<"SEC""OND"', + "ARG NEMOCLAW_TOOL_DISCLOSURE=progressive", + "FIRST", + "ENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}", + "SECOND", + ].join("\n"), + ), + ).toThrow(/does not declare ARG/); + const heredocSource = [ + "FROM scratch", + 'SHELL ["/bin/bash", "-c"]', + "RUN cat << patchCustom("FROM scratch\nRUN <]/.test(wordChar)) break; + } + const rawWord = instruction.slice(wordStart, wordEnd); + const delimiter = wordQuote === null ? decodeDockerfileHeredocWord(rawWord) : null; + if (!delimiter) { + throw new Error("Custom Dockerfile contains an invalid heredoc delimiter."); + } + heredocs.push({ delimiter, stripTabs }); + index = wordEnd - 1; + } + return heredocs; +} + +interface DockerfileWord { + decoded: string; + raw: string; +} + +function tokenizeDockerfileWords(input: string): DockerfileWord[] | null { + const words: DockerfileWord[] = []; + let decoded = ""; + let wordStart = -1; + let quote: "'" | '"' | null = null; + for (let index = 0; index < input.length; index += 1) { + const char = input[index]!; + if (quote) { + if (char === quote) quote = null; + else if (char === "\\" && quote === '"' && index + 1 < input.length) { + index += 1; + decoded += input[index]!; + } else decoded += char; + continue; + } + if (char === "'" || char === '"') { + quote = char; + if (wordStart < 0) wordStart = index; + } else if (char === "\\" && index + 1 < input.length) { + if (wordStart < 0) wordStart = index; + index += 1; + decoded += input[index]!; + } else if (/\s/.test(char)) { + if (wordStart >= 0) { + words.push({ decoded, raw: input.slice(wordStart, index) }); + decoded = ""; + wordStart = -1; + } + } else { + if (wordStart < 0) wordStart = index; + decoded += char; + } + } + if (quote) return null; + if (wordStart >= 0) words.push({ decoded, raw: input.slice(wordStart) }); + return words; +} + +function dockerfileEnvValue(instruction: string, key: string): DockerfileWord | undefined { + const envMatch = /^ENV\s+(.+)$/i.exec(instruction); + if (!envMatch) return undefined; + const words = tokenizeDockerfileWords(envMatch[1]!); + if (!words || words.length === 0) return undefined; + + if (!words[0]!.raw.includes("=")) { + if (words[0]!.decoded !== key) return undefined; + return { + decoded: words + .slice(1) + .map((word) => word.decoded) + .join(" "), + raw: words + .slice(1) + .map((word) => word.raw) + .join(" "), + }; + } + + let value: DockerfileWord | undefined; + for (const word of words) { + const rawEquals = word.raw.indexOf("="); + const decodedEquals = word.decoded.indexOf("="); + if (rawEquals > 0 && decodedEquals > 0 && word.raw.slice(0, rawEquals) === key) { + value = { + decoded: word.decoded.slice(decodedEquals + 1), + raw: word.raw.slice(rawEquals + 1), + }; + } + } + return value; +} + +export function dockerfileInstructions(dockerfile: string): DockerfileInstruction[] { + const instructions: DockerfileInstruction[] = []; + const pendingHeredocs: DockerfileHeredoc[] = []; + let current = ""; + let currentStart = -1; + + for (const match of dockerfile.matchAll(/[^\n]*(?:\n|$)/g)) { + if (!match[0]) continue; + const lineStart = match.index; + const lineWithEnding = match[0]; + const lineWithoutLf = lineWithEnding.endsWith("\n") + ? lineWithEnding.slice(0, -1) + : lineWithEnding; + const rawLine = lineWithoutLf.endsWith("\r") ? lineWithoutLf.slice(0, -1) : lineWithoutLf; + const pendingHeredoc = pendingHeredocs[0]; + if (pendingHeredoc) { + const candidate = pendingHeredoc.stripTabs ? rawLine.replace(/^\t+/, "") : rawLine; + if (candidate === pendingHeredoc.delimiter) pendingHeredocs.shift(); + continue; + } + const trimmed = rawLine.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + if (!current) currentStart = lineStart; + const continued = trimmed.endsWith("\\"); + const part = continued ? trimmed.slice(0, -1).trimEnd() : trimmed; + current = current ? `${current} ${part}` : part; + if (!continued) { + instructions.push({ + text: current, + start: currentStart, + end: lineStart + rawLine.length, + }); + pendingHeredocs.push(...dockerfileHeredocs(current)); + current = ""; + currentStart = -1; + } + } + if (current) { + instructions.push({ text: current, start: currentStart, end: dockerfile.length }); + pendingHeredocs.push(...dockerfileHeredocs(current)); + } + if (pendingHeredocs.length > 0) { + throw new Error( + `Custom Dockerfile contains an unterminated heredoc '${pendingHeredocs[0]!.delimiter}'.`, + ); + } + return instructions; +} + +export function validateToolDisclosureDockerfileContract( + dockerfile: string, + toolDisclosure: ToolDisclosure, +): DockerfileInstruction { + const instructions = dockerfileInstructions(dockerfile); + const finalFromIndex = instructions.reduce( + (last, instruction, index) => (/^FROM(?:\s|$)/i.test(instruction.text) ? index : last), + -1, + ); + const finalStage = instructions.slice(finalFromIndex + 1); + const declarations = finalStage.filter((instruction) => + /^ARG\s+NEMOCLAW_TOOL_DISCLOSURE\s*=/.test(instruction.text), + ); + if (declarations.length !== 1) { + const hasEarlierDeclaration = instructions + .slice(0, finalFromIndex + 1) + .some((instruction) => /^ARG\s+NEMOCLAW_TOOL_DISCLOSURE\s*=/.test(instruction.text)); + const detail = + declarations.length === 0 + ? hasEarlierDeclaration + ? "declares ARG NEMOCLAW_TOOL_DISCLOSURE outside the final stage but does not declare it in the final stage" + : "does not declare ARG NEMOCLAW_TOOL_DISCLOSURE" + : "declares ARG NEMOCLAW_TOOL_DISCLOSURE more than once in the final stage"; + throw new Error( + `Custom Dockerfile ${detail}; exactly one final-stage declaration is required to apply tool disclosure '${toolDisclosure}'.`, + ); + } + + const finalEnvAssignments = finalStage + .map((instruction, index) => ({ + index, + value: dockerfileEnvValue(instruction.text, "NEMOCLAW_TOOL_DISCLOSURE"), + })) + .filter((assignment) => assignment.value !== undefined); + const lastEnvAssignment = finalEnvAssignments.at(-1); + const declarationIndex = finalStage.indexOf(declarations[0]!); + const expandableRuntimeValues = new Set([ + "${NEMOCLAW_TOOL_DISCLOSURE}", + "$NEMOCLAW_TOOL_DISCLOSURE", + '"${NEMOCLAW_TOOL_DISCLOSURE}"', + '"$NEMOCLAW_TOOL_DISCLOSURE"', + ]); + const promotesToFinalRuntime = Boolean( + lastEnvAssignment && + lastEnvAssignment.index > declarationIndex && + expandableRuntimeValues.has(lastEnvAssignment.value!.raw), + ); + if (!promotesToFinalRuntime) { + throw new Error( + `Custom Dockerfile must promote ARG NEMOCLAW_TOOL_DISCLOSURE into the final-stage ENV after its declaration, with no later override; cannot apply tool disclosure '${toolDisclosure}'.`, + ); + } + return declarations[0]!; +} + +export function assertToolDisclosureDockerfileContract( + dockerfilePath: string, + toolDisclosure: ToolDisclosure, +): void { + let dockerfile: string; + try { + dockerfile = readExistingDockerfileNoFollow(dockerfilePath); + } catch (error) { + if (errnoCode(error) === "ENOENT") { + throw new Error(`Custom Dockerfile not found: ${dockerfilePath}`); + } + if (error instanceof Error && error.message.includes("non-regular Dockerfile")) { + throw new Error(`Custom Dockerfile path is not a file: ${dockerfilePath}`); + } + throw error; + } + validateToolDisclosureDockerfileContract(dockerfile, toolDisclosure); +} diff --git a/src/lib/onboard/inference-route.ts b/src/lib/onboard/inference-route.ts new file mode 100644 index 00000000000..397e50a255a --- /dev/null +++ b/src/lib/onboard/inference-route.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { parseGatewayInference } from "../inference/config"; + +type RunCaptureOpenshell = (args: string[], options?: { ignoreError?: boolean }) => string | null; + +export function createInferenceRouteHelpers(runCaptureOpenshell: RunCaptureOpenshell) { + function verifyInferenceRoute(_provider: string, _model: string): void { + const output = runCaptureOpenshell(["inference", "get"], { ignoreError: true }); + if (!output || /Gateway inference:\s*[\r\n]+\s*Not configured/i.test(output)) { + console.error(" OpenShell inference route was not configured."); + process.exit(1); + } + } + + function isInferenceRouteReady(provider: string, model: string): boolean { + const live = parseGatewayInference( + runCaptureOpenshell(["inference", "get"], { ignoreError: true }), + ); + return Boolean(live && live.provider === provider && live.model === model); + } + + return { verifyInferenceRoute, isInferenceRouteReady }; +} diff --git a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts index c3ad9199278..fd1ddd02194 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts @@ -15,6 +15,8 @@ function resumeSignals(overrides: Partial = {}): SandboxRe sandboxGpuConfigChanged: false, messagingChannelConfigChanged: false, hermesToolGatewayConfigChanged: false, + toolDisclosureMigrationNeeded: false, + toolDisclosureChanged: false, ...overrides, }; } @@ -30,6 +32,8 @@ describe("decideSandboxResume", () => { ["sandbox GPU", { sandboxGpuConfigChanged: true }, true], ["messaging", { messagingChannelConfigChanged: true }, true], ["Hermes tool gateway", { hermesToolGatewayConfigChanged: true }, true], + ["tool disclosure migration", { toolDisclosureMigrationNeeded: true }, false], + ["tool disclosure", { toolDisclosureChanged: true }, false], ] as const)("recreates for %s drift", (_label, overrides, removeRegistryEntry) => { expect(decideSandboxResume(resumeSignals(overrides))).toMatchObject({ kind: "recreate", @@ -37,6 +41,19 @@ describe("decideSandboxResume", () => { }); }); + it("distinguishes one-time tool-disclosure migration from user configuration drift", () => { + expect( + decideSandboxResume(resumeSignals({ toolDisclosureMigrationNeeded: true })), + ).toMatchObject({ + kind: "recreate", + note: expect.stringContaining("metadata is missing"), + }); + expect(decideSandboxResume(resumeSignals({ toolDisclosureChanged: true }))).toMatchObject({ + kind: "recreate", + note: expect.stringContaining("configuration changed"), + }); + }); + it("repairs a recorded sandbox that is present but not ready", () => { expect(decideSandboxResume(resumeSignals({ sandboxReuseState: "not_ready" }))).toEqual({ kind: "repair-and-recreate", diff --git a/src/lib/onboard/machine/handlers/sandbox-resume.ts b/src/lib/onboard/machine/handlers/sandbox-resume.ts index ae94c2bace2..9feb66d5102 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.ts @@ -1,6 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { Session } from "../../../state/onboard-session"; +import type { SandboxEntry } from "../../../state/registry"; +import { normalizeToolDisclosure, toolDisclosureOrDefault } from "../../../tool-disclosure"; + export interface SandboxResumeSignals { readonly resume: boolean; readonly resumeAgentChanged: boolean; @@ -10,6 +14,24 @@ export interface SandboxResumeSignals { readonly sandboxGpuConfigChanged: boolean; readonly messagingChannelConfigChanged: boolean; readonly hermesToolGatewayConfigChanged: boolean; + readonly toolDisclosureMigrationNeeded: boolean; + readonly toolDisclosureChanged: boolean; +} + +export function resolveToolDisclosureResumeSignals( + registryEntry: SandboxEntry | null, + session: Session | null, +): Pick { + const recorded = normalizeToolDisclosure(registryEntry?.toolDisclosure); + const migrationNeeded = Boolean(registryEntry && registryEntry.toolDisclosure === undefined); + return { + toolDisclosureMigrationNeeded: migrationNeeded, + toolDisclosureChanged: Boolean( + registryEntry && + !migrationNeeded && + recorded !== toolDisclosureOrDefault(session?.toolDisclosure), + ), + }; } export type SandboxResumeDecision = @@ -43,10 +65,33 @@ function canReuseSandbox(signals: SandboxResumeSignals): boolean { !signals.sandboxGpuConfigChanged && !signals.messagingChannelConfigChanged && !signals.hermesToolGatewayConfigChanged && + !signals.toolDisclosureMigrationNeeded && + !signals.toolDisclosureChanged && signals.sandboxReuseState === "ready" ); } +function toolDisclosureResumeDecision(signals: SandboxResumeSignals): SandboxResumeDecision | null { + if (signals.toolDisclosureMigrationNeeded) { + return { + kind: "recreate", + note: " [resume] Tool disclosure metadata is missing; recreating sandbox for one-time migration.", + // Preserve registry-only fidelity until createSandbox captures it. + removeRegistryEntry: false, + }; + } + if (signals.toolDisclosureChanged) { + return { + kind: "recreate", + note: " [resume] Tool disclosure configuration changed; recreating sandbox.", + // Keep the row until createSandbox captures registry-only fidelity such + // as managed MCP bridge state and can route it through transactional rebuild. + removeRegistryEntry: false, + }; + } + return null; +} + export function decideSandboxResume(signals: SandboxResumeSignals): SandboxResumeDecision { if (!signals.resume || !signals.sandboxStepComplete) return { kind: "create" }; if (canReuseSandbox(signals)) return { kind: "reuse" }; @@ -85,6 +130,8 @@ export function decideSandboxResume(signals: SandboxResumeSignals): SandboxResum removeRegistryEntry: true, }; } + const toolDisclosureDecision = toolDisclosureResumeDecision(signals); + if (toolDisclosureDecision) return toolDisclosureDecision; if (signals.sandboxReuseState === "not_ready") return { kind: "repair-and-recreate" }; return { kind: "recreate", diff --git a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts new file mode 100644 index 00000000000..59b6763e91f --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { vi } from "vitest"; + +import type { SandboxMessagingPlan } from "../../../messaging/manifest"; +import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; +import type { SandboxStateOptions } from "./sandbox"; + +export function makeMinimalPlan( + sandboxName: string, + agent = "openclaw", + channelIds: readonly SandboxMessagingPlan["channels"][number]["channelId"][] = [], + disabledChannels: readonly SandboxMessagingPlan["channels"][number]["channelId"][] = [], +): SandboxMessagingPlan { + const disabled = new Set(disabledChannels); + return { + schemaVersion: 1, + sandboxName, + agent: agent as SandboxMessagingPlan["agent"], + workflow: "onboard", + channels: channelIds.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: !disabled.has(channelId), + selected: true, + configured: true, + disabled: disabled.has(channelId), + inputs: [], + hooks: [], + })), + disabledChannels: [...disabled], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + +export function withTelegramCredentialHash( + plan: SandboxMessagingPlan, + credentialHash: string | null, +): SandboxMessagingPlan { + return { + ...plan, + credentialBindings: [ + { + channelId: "telegram", + credentialId: "bot-token", + sourceInput: "botToken", + providerName: `${plan.sandboxName}-telegram-bridge`, + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + ...(credentialHash ? { credentialHash } : {}), + }, + ], + }; +} + +export async function withEnv(key: string, value: string, run: () => Promise): Promise { + const previous = process.env[key]; + process.env[key] = value; + try { + return await run(); + } finally { + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } +} + +type Gpu = { type: string } | null; +type Agent = { displayName?: string; name?: string } | null; +type WebSearchConfig = { fetchEnabled: true; provider?: "brave" | "tavily" }; +type MessagingChannelConfig = Record; +type SandboxGpuConfig = { sandboxGpuEnabled: boolean; mode: string }; +type ResourceProfile = { cpu: string; memory: string }; + +export function createDeps( + overrides: Partial< + SandboxStateOptions< + Gpu, + Agent, + WebSearchConfig, + MessagingChannelConfig, + SandboxGpuConfig, + ResourceProfile + >["deps"] + > = {}, +) { + let session = createSession(); + const calls = { + note: vi.fn(), + updateSession: vi.fn((mutator: (value: Session) => Session | void) => { + session = mutator(session) ?? session; + return session; + }), + persistMessaging: vi.fn(), + clearPlanEnv: vi.fn(), + removeSandbox: vi.fn(), + repairSandbox: vi.fn(), + validateBrave: vi.fn(async () => "brave-key"), + isBackToSelection: vi.fn(() => false), + configureWebSearch: vi.fn(async () => null as WebSearchConfig | null), + startStep: vi.fn(async () => undefined), + getRecordedChannels: vi.fn(() => null), + setupMessaging: vi.fn(async () => [] as string[]), + promptName: vi.fn(async () => "my-assistant"), + selectResourceProfile: vi.fn(async () => null as ResourceProfile | null), + stopStale: vi.fn(), + createSandbox: vi.fn(async () => "my-assistant"), + updateSandbox: vi.fn(), + complete: vi.fn(async (_stepName: string, updates: SessionUpdates) => { + Object.assign(session, updates); + return session; + }), + skipped: vi.fn(), + recordSkip: vi.fn(async () => session), + repairEvent: vi.fn(async () => createSession()), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + }; + return { + calls, + deps: { + resolvePath: (value: string) => `/abs/${value}`, + agentSupportsWebSearch: () => true, + note: calls.note, + updateSession: calls.updateSession, + getStoredMessagingChannelConfig: () => null, + hydrateMessagingChannelConfig: (config: MessagingChannelConfig | null) => config, + messagingChannelConfigsEqual: () => true, + getSandboxReuseState: () => "missing", + hasSandboxGpuDrift: () => false, + getSandboxHermesToolGateways: () => [], + getSandboxRegistryEntry: (name: string) => ({ + name, + webSearchEnabled: false, + toolDisclosure: "progressive" as const, + fromDockerfile: null, + hermesAuthMethod: null, + }), + normalizeHermesToolGatewaySelections: (value: unknown) => + Array.isArray(value) ? (value as string[]) : [], + stringSetsEqual: (left: string[], right: string[]) => + left.length === right.length && left.every((value) => right.includes(value)), + removeSandboxFromRegistry: calls.removeSandbox, + repairRecordedSandbox: calls.repairSandbox, + ensureValidatedWebSearchCredential: calls.validateBrave, + isBackToSelection: calls.isBackToSelection, + configureWebSearch: calls.configureWebSearch, + startRecordedStep: calls.startStep, + getRecordedMessagingChannelsForResume: calls.getRecordedChannels, + setupMessagingChannels: calls.setupMessaging, + readMessagingPlanFromEnv: () => null, + writePlanToEnv: () => undefined, + clearPlanEnv: calls.clearPlanEnv, + getRegistrySandboxMessagingPlan: () => null, + promptValidatedSandboxName: calls.promptName, + selectResourceProfileForSandbox: calls.selectResourceProfile, + stopStaleDashboardListenersForSandbox: calls.stopStale, + listRegistrySandboxes: () => ({ sandboxes: [{ name: "old" }] }), + createSandbox: calls.createSandbox, + updateSandboxRegistry: calls.updateSandbox, + getSandboxAgentRegistryFields: () => ({ agent: null }), + recordStepComplete: calls.complete, + toSessionUpdates: (updates: Record) => updates as SessionUpdates, + skippedStepMessage: calls.skipped, + recordStateSkipped: calls.recordSkip, + recordRepairEvent: calls.repairEvent, + error: calls.error, + exitProcess: calls.exit, + ...overrides, + }, + getSession: () => session, + }; +} + +export function baseOptions( + deps: SandboxStateOptions< + Gpu, + Agent, + WebSearchConfig, + MessagingChannelConfig, + SandboxGpuConfig, + ResourceProfile + >["deps"], + session: Session | null = createSession(), +): SandboxStateOptions< + Gpu, + Agent, + WebSearchConfig, + MessagingChannelConfig, + SandboxGpuConfig, + ResourceProfile +> { + return { + resume: false, + fresh: false, + resumeAgentChanged: false, + session, + sandboxName: null, + model: "model", + provider: "provider", + nimContainer: null, + webSearchConfig: null, + selectedMessagingChannels: [], + fromDockerfile: null, + agent: null, + gpu: { type: "nvidia" }, + preferredInferenceApi: "openai-completions", + sandboxGpuConfig: { sandboxGpuEnabled: false, mode: "0" }, + hermesToolGateways: [], + hermesAuthMethod: null, + controlUiPort: null, + rootDir: "/repo", + env: {}, + deps, + }; +} diff --git a/src/lib/onboard/machine/handlers/sandbox-tool-disclosure.test.ts b/src/lib/onboard/machine/handlers/sandbox-tool-disclosure.test.ts new file mode 100644 index 00000000000..847bf6dd7b4 --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-tool-disclosure.test.ts @@ -0,0 +1,172 @@ +// 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 { createSession, type Session } from "../../../state/onboard-session"; +import { handleSandboxState } from "./sandbox"; +import { baseOptions, createDeps } from "./sandbox-test-fixtures"; + +vi.mock("../../messaging-channel-setup", () => ({ + detectMessagingChannelsFromEnv: vi.fn(() => []), +})); + +describe("handleSandboxState tool disclosure", () => { + it("does not claim an unregistered live sandbox as a managed legacy migration", async () => { + const session = createSession({ sandboxName: "saved", toolDisclosure: "progressive" }); + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: () => null, + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.removeSandbox).not.toHaveBeenCalled(); + }); + + it.each([ + [ + "a legacy managed image", + createSession({ toolDisclosure: "progressive" }), + undefined, + " [resume] Tool disclosure metadata is missing; recreating sandbox for one-time migration.", + ], + [ + "a changed selection", + createSession({ toolDisclosure: "direct" }), + "progressive" as const, + " [resume] Tool disclosure configuration changed; recreating sandbox.", + ], + ])("recreates instead of reusing %s tool disclosure", async (_label, session, recorded, note) => { + session.sandboxName = "saved"; + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: (name) => ({ + name, + nemoclawVersion: "0.1.0", + toolDisclosure: recorded, + fromDockerfile: null, + }), + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }); + + expect(calls.note).toHaveBeenCalledWith(note); + expect(calls.removeSandbox).not.toHaveBeenCalled(); + expect(calls.createSandbox).toHaveBeenCalled(); + }); + + it.each([ + ["progressive", "direct"], + ["direct", "progressive"], + ] as const)("passes resumed %s-to-%s tool-disclosure drift into the downstream create intent", async (recordedMode, requestedMode) => { + const session = createSession({ sandboxName: "saved", toolDisclosure: requestedMode }); + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + updateSession: vi.fn( + (mutator: (value: Session) => Session | void) => mutator(session) ?? session, + ), + getSandboxRegistryEntry: (name) => ({ + name, + nemoclawVersion: "0.1.0", + toolDisclosure: recordedMode, + }), + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }); + + expect(calls.removeSandbox).not.toHaveBeenCalled(); + expect(calls.createSandbox).toHaveBeenCalledWith( + expect.anything(), + "model", + "provider", + "openai-completions", + "saved", + null, + [], + null, + null, + null, + { sandboxGpuEnabled: false, mode: "0" }, + null, + [], + null, + { recreate: true, toolDisclosure: requestedMode }, + ); + }); + + it("recreates a legacy custom image so its tool-disclosure contract is validated", async () => { + const session = createSession({ sandboxName: "saved", toolDisclosure: "progressive" }); + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: (name) => ({ + name, + nemoclawVersion: null, + fromDockerfile: "/tmp/Dockerfile.custom", + }), + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }); + + expect(calls.note).toHaveBeenCalledWith( + " [resume] Tool disclosure metadata is missing; recreating sandbox for one-time migration.", + ); + expect(calls.createSandbox).toHaveBeenCalled(); + expect(calls.removeSandbox).not.toHaveBeenCalled(); + }); + + it("retains managed MCP registry fidelity until createSandbox can refuse generic migration", async () => { + const session = createSession({ sandboxName: "saved", toolDisclosure: "progressive" }); + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: (name) => ({ + name, + nemoclawVersion: "0.1.0", + mcp: { + version: 1, + bridges: { + fake: { + server: "fake", + agent: "openclaw", + url: "https://mcp.example.test", + env: [], + policyName: "mcp-bridge-fake", + addedAt: "2026-07-03T00:00:00.000Z", + }, + }, + }, + }), + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }); + + expect(calls.removeSandbox).not.toHaveBeenCalled(); + expect(calls.createSandbox).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 6b6bbd32c99..80f99f44517 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -3,11 +3,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import { hashCredential } from "../../../security/credential-hash"; -import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; +import { createSession, type Session } from "../../../state/onboard-session"; import { detectMessagingChannelsFromEnv } from "../../messaging-channel-setup"; -import { handleSandboxState, type SandboxStateOptions } from "./sandbox"; +import { handleSandboxState } from "./sandbox"; +import { + baseOptions, + createDeps, + makeMinimalPlan, + withEnv, + withTelegramCredentialHash, +} from "./sandbox-test-fixtures"; vi.mock("../../messaging-channel-setup", () => ({ detectMessagingChannelsFromEnv: vi.fn(() => []), @@ -15,225 +21,6 @@ vi.mock("../../messaging-channel-setup", () => ({ const detectMessagingChannelsFromEnvMock = vi.mocked(detectMessagingChannelsFromEnv); -function makeMinimalPlan( - sandboxName: string, - agent = "openclaw", - channelIds: readonly SandboxMessagingPlan["channels"][number]["channelId"][] = [], - disabledChannels: readonly SandboxMessagingPlan["channels"][number]["channelId"][] = [], -): SandboxMessagingPlan { - const disabled = new Set(disabledChannels); - return { - schemaVersion: 1, - sandboxName, - agent: agent as SandboxMessagingPlan["agent"], - workflow: "onboard", - channels: channelIds.map((channelId) => ({ - channelId, - displayName: channelId, - authMode: "token-paste", - active: !disabled.has(channelId), - selected: true, - configured: true, - disabled: disabled.has(channelId), - inputs: [], - hooks: [], - })), - disabledChannels: [...disabled], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; -} - -function withTelegramCredentialHash( - plan: SandboxMessagingPlan, - credentialHash: string | null, -): SandboxMessagingPlan { - return { - ...plan, - credentialBindings: [ - { - channelId: "telegram", - credentialId: "bot-token", - sourceInput: "botToken", - providerName: `${plan.sandboxName}-telegram-bridge`, - providerEnvKey: "TELEGRAM_BOT_TOKEN", - placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", - credentialAvailable: true, - ...(credentialHash ? { credentialHash } : {}), - }, - ], - }; -} - -async function withEnv(key: string, value: string, run: () => Promise): Promise { - const previous = process.env[key]; - process.env[key] = value; - try { - return await run(); - } finally { - if (previous === undefined) { - delete process.env[key]; - } else { - process.env[key] = previous; - } - } -} - -type Gpu = { type: string } | null; -type Agent = { displayName?: string; name?: string } | null; -type WebSearchConfig = { fetchEnabled: true; provider?: "brave" | "tavily" }; -type MessagingChannelConfig = Record; -type SandboxGpuConfig = { sandboxGpuEnabled: boolean; mode: string }; -type ResourceProfile = { cpu: string; memory: string }; - -function createDeps( - overrides: Partial< - SandboxStateOptions< - Gpu, - Agent, - WebSearchConfig, - MessagingChannelConfig, - SandboxGpuConfig, - ResourceProfile - >["deps"] - > = {}, -) { - let session = createSession(); - const calls = { - note: vi.fn(), - updateSession: vi.fn((mutator: (value: Session) => Session | void) => { - session = mutator(session) ?? session; - return session; - }), - persistMessaging: vi.fn(), - clearPlanEnv: vi.fn(), - removeSandbox: vi.fn(), - repairSandbox: vi.fn(), - validateBrave: vi.fn(async () => "brave-key"), - isBackToSelection: vi.fn(() => false), - configureWebSearch: vi.fn(async () => null as WebSearchConfig | null), - startStep: vi.fn(async () => undefined), - getRecordedChannels: vi.fn(() => null), - setupMessaging: vi.fn(async () => [] as string[]), - promptName: vi.fn(async () => "my-assistant"), - selectResourceProfile: vi.fn(async () => null as ResourceProfile | null), - stopStale: vi.fn(), - createSandbox: vi.fn(async () => "my-assistant"), - updateSandbox: vi.fn(), - complete: vi.fn(async (_stepName: string, updates: SessionUpdates) => { - Object.assign(session, updates); - return session; - }), - skipped: vi.fn(), - recordSkip: vi.fn(async () => session), - repairEvent: vi.fn(async () => createSession()), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; - return { - calls, - deps: { - resolvePath: (value: string) => `/abs/${value}`, - agentSupportsWebSearch: () => true, - note: calls.note, - updateSession: calls.updateSession, - getStoredMessagingChannelConfig: () => null, - hydrateMessagingChannelConfig: (config: MessagingChannelConfig | null) => config, - messagingChannelConfigsEqual: () => true, - getSandboxReuseState: () => "missing", - hasSandboxGpuDrift: () => false, - getSandboxHermesToolGateways: () => [], - getSandboxRegistryEntry: (name: string) => ({ - name, - webSearchEnabled: false, - fromDockerfile: null, - hermesAuthMethod: null, - }), - normalizeHermesToolGatewaySelections: (value: unknown) => - Array.isArray(value) ? (value as string[]) : [], - stringSetsEqual: (left: string[], right: string[]) => - left.length === right.length && left.every((value) => right.includes(value)), - removeSandboxFromRegistry: calls.removeSandbox, - repairRecordedSandbox: calls.repairSandbox, - ensureValidatedWebSearchCredential: calls.validateBrave, - isBackToSelection: calls.isBackToSelection, - configureWebSearch: calls.configureWebSearch, - startRecordedStep: calls.startStep, - getRecordedMessagingChannelsForResume: calls.getRecordedChannels, - setupMessagingChannels: calls.setupMessaging, - readMessagingPlanFromEnv: () => null, - writePlanToEnv: () => undefined, - clearPlanEnv: calls.clearPlanEnv, - getRegistrySandboxMessagingPlan: () => null, - promptValidatedSandboxName: calls.promptName, - selectResourceProfileForSandbox: calls.selectResourceProfile, - stopStaleDashboardListenersForSandbox: calls.stopStale, - listRegistrySandboxes: () => ({ sandboxes: [{ name: "old" }] }), - createSandbox: calls.createSandbox, - updateSandboxRegistry: calls.updateSandbox, - getSandboxAgentRegistryFields: () => ({ agent: null }), - recordStepComplete: calls.complete, - toSessionUpdates: (updates: Record) => updates as SessionUpdates, - skippedStepMessage: calls.skipped, - recordStateSkipped: calls.recordSkip, - recordRepairEvent: calls.repairEvent, - error: calls.error, - exitProcess: calls.exit, - ...overrides, - }, - getSession: () => session, - }; -} - -function baseOptions( - deps: SandboxStateOptions< - Gpu, - Agent, - WebSearchConfig, - MessagingChannelConfig, - SandboxGpuConfig, - ResourceProfile - >["deps"], - session: Session | null = createSession(), -): SandboxStateOptions< - Gpu, - Agent, - WebSearchConfig, - MessagingChannelConfig, - SandboxGpuConfig, - ResourceProfile -> { - return { - resume: false, - fresh: false, - resumeAgentChanged: false, - session, - sandboxName: null, - model: "model", - provider: "provider", - nimContainer: null, - webSearchConfig: null, - selectedMessagingChannels: [], - fromDockerfile: null, - agent: null, - gpu: { type: "nvidia" }, - preferredInferenceApi: "openai-completions", - sandboxGpuConfig: { sandboxGpuEnabled: false, mode: "0" }, - hermesToolGateways: [], - hermesAuthMethod: null, - controlUiPort: null, - rootDir: "/repo", - env: {}, - deps, - }; -} - describe("handleSandboxState", () => { beforeEach(() => { detectMessagingChannelsFromEnvMock.mockReturnValue([]); @@ -268,6 +55,7 @@ describe("handleSandboxState", () => { null, [], null, + { recreate: false, toolDisclosure: "progressive" }, ); expect(calls.updateSandbox).toHaveBeenCalledWith( "my-assistant", @@ -334,6 +122,7 @@ describe("handleSandboxState", () => { null, ["nous-audio"], null, + { recreate: false, toolDisclosure: "progressive" }, ); expect(result.hermesToolGateways).toEqual(["nous-audio"]); expect(calls.note).toHaveBeenCalledWith( @@ -384,7 +173,11 @@ describe("handleSandboxState", () => { session.steps.sandbox.status = "complete"; const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready", - getSandboxRegistryEntry: (name) => ({ name, nemoclawVersion: "0.1.0" }), + getSandboxRegistryEntry: (name) => ({ + name, + nemoclawVersion: "0.1.0", + toolDisclosure: "progressive", + }), }); await handleSandboxState({ @@ -566,6 +359,7 @@ describe("handleSandboxState", () => { null, [], null, + { recreate: true, toolDisclosure: "progressive" }, ); expect(result.webSearchConfigChanged).toBe(true); }); @@ -679,6 +473,7 @@ describe("handleSandboxState", () => { null, [], null, + { recreate: true, toolDisclosure: "progressive" }, ); expect(result.webSearchConfig).toBeNull(); }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 1e435360236..6e39bbf7a6c 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -13,12 +13,15 @@ import { import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import type { HermesAuthMethod, Session, SessionUpdates } from "../../../state/onboard-session"; import type { SandboxEntry } from "../../../state/registry"; +import { toolDisclosureOrDefault } from "../../../tool-disclosure"; import { withSandboxPhaseTrace } from "../../tracing"; +import type { SandboxCreateIntent } from "../../types"; import { branchTo, type OnboardStateTransitionResult } from "../result"; import { reconcileReusedSandboxMessaging, reconcileSandboxMessaging } from "./sandbox-messaging"; import { applySandboxResumeDecision, decideSandboxResume, + resolveToolDisclosureResumeSignals, type SandboxResumeDecision, } from "./sandbox-resume"; @@ -130,6 +133,7 @@ export interface SandboxStateOptions< resourceProfile: ResourceProfile | null, hermesToolGateways: string[], hermesAuthMethod: HermesAuthMethod | null, + createIntent: SandboxCreateIntent, ): Promise; updateSandboxRegistry(sandboxName: string, updates: Record): void; getSandboxAgentRegistryFields( @@ -368,6 +372,10 @@ class SandboxStateFlow< state.webSearchConfig as unknown as SharedWebSearchConfig | null, this.options.hermesToolGateways, ); + const toolDisclosureSignals = resolveToolDisclosureResumeSignals( + state.sandboxName ? this.deps.getSandboxRegistryEntry(state.sandboxName) : null, + state.session, + ); return decideSandboxResume({ resume: this.options.resume, resumeAgentChanged: this.options.resumeAgentChanged, @@ -385,6 +393,7 @@ class SandboxStateFlow< recordedToolGateways, effectiveToolGateways, ), + ...toolDisclosureSignals, }); } @@ -470,6 +479,7 @@ class SandboxStateFlow< state: SandboxStepState, requestedSandboxName: string, messagingPlan: SandboxMessagingPlan | null, + decision: SandboxCreationDecision, ): Promise> { const effectiveHermesToolGateways = effectiveHermesToolGatewaysForWebSearch( this.options.agent as { name?: string } | null, @@ -504,6 +514,10 @@ class SandboxStateFlow< resourceProfile, effectiveHermesToolGateways, this.options.hermesAuthMethod, + { + recreate: decision.kind !== "create", + toolDisclosure: toolDisclosureOrDefault(state.session?.toolDisclosure), + }, ), ); // createSandbox() owns the build fingerprint. In particular, reusing an @@ -587,6 +601,7 @@ class SandboxStateFlow< }, requestedSandboxName, messaging.plan, + decision, ); } diff --git a/src/lib/onboard/prepared-dcode-rebuild.test.ts b/src/lib/onboard/prepared-dcode-rebuild.test.ts index 571a0d1c540..a72da809b33 100644 --- a/src/lib/onboard/prepared-dcode-rebuild.test.ts +++ b/src/lib/onboard/prepared-dcode-rebuild.test.ts @@ -1,8 +1,14 @@ // 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 { createBuildContextVerifier } from "../actions/sandbox/rebuild-prepared-image-context"; +import { fingerprintBuildContext } from "../adapters/fs/build-context-fingerprint"; import type { AgentDefinition } from "../agent/defs"; import type { PreparedSandboxBuildContext } from "./build-context-stage"; import { @@ -30,6 +36,30 @@ const preparedOptions: PreparedDcodeRebuildOptions = { gatewayName: " nemoclaw ", }, }; +const preparedImageBuildContext: PreparedSandboxBuildContext = { + buildCtx: "/tmp/prepared-custom", + stagedDockerfile: "/tmp/prepared-custom/Dockerfile", + buildId: "custom-prepared", + cleanupBuildCtx: () => true, + origin: "custom", + verifyBuildCtx: () => true, + rebuildTarget: { + agentName: null, + fromDockerfile: "/tmp/custom/Dockerfile", + }, +}; +const preparedImageOptions: PreparedDcodeRebuildOptions = { + resume: true, + recreateSandbox: true, + authoritativeResumeConfig: true, + onboardLockAlreadyHeld: true, + agent: null, + fromDockerfile: "/tmp/custom/Dockerfile", + preparedImageRebuild: { + buildContext: preparedImageBuildContext, + gatewayName: "nemoclaw", + }, +}; const sandboxGpuConfig: SandboxGpuConfig = { mode: "0", hostGpuDetected: false, @@ -52,6 +82,70 @@ const preparedBuildIdInput = { sandboxGpuConfig, }; +type OneShotContextMutationPaths = { + buildCtx: string; + stagedDockerfile: string; + replacementCtx: string; + movedBuildCtx: string; +}; + +type OneShotContextMutation = { + label: string; + arrange(paths: OneShotContextMutationPaths): void; + mutate(paths: OneShotContextMutationPaths): void; +}; + +const FIXED_CONTEXT_TIME = new Date("2026-01-01T00:00:00.000Z"); +const oneShotContextMutations: OneShotContextMutation[] = [ + { + label: "file special bits change", + arrange: ({ stagedDockerfile }) => fs.chmodSync(stagedDockerfile, 0o755), + mutate: ({ stagedDockerfile }) => fs.chmodSync(stagedDockerfile, 0o4755), + }, + { + label: "independent files become hardlinks", + arrange: ({ buildCtx }) => { + const first = path.join(buildCtx, "first.txt"); + const second = path.join(buildCtx, "second.txt"); + fs.writeFileSync(first, "identical\n"); + fs.writeFileSync(second, "identical\n"); + fs.utimesSync(first, FIXED_CONTEXT_TIME, FIXED_CONTEXT_TIME); + fs.utimesSync(second, FIXED_CONTEXT_TIME, FIXED_CONTEXT_TIME); + fs.utimesSync(buildCtx, FIXED_CONTEXT_TIME, FIXED_CONTEXT_TIME); + }, + mutate: ({ buildCtx }) => { + const first = path.join(buildCtx, "first.txt"); + const second = path.join(buildCtx, "second.txt"); + fs.unlinkSync(second); + fs.linkSync(first, second); + fs.utimesSync(buildCtx, FIXED_CONTEXT_TIME, FIXED_CONTEXT_TIME); + }, + }, + { + label: "a file mtime alone changes", + arrange: ({ stagedDockerfile }) => + fs.utimesSync(stagedDockerfile, FIXED_CONTEXT_TIME, FIXED_CONTEXT_TIME), + mutate: ({ stagedDockerfile }) => + fs.utimesSync( + stagedDockerfile, + FIXED_CONTEXT_TIME, + new Date(FIXED_CONTEXT_TIME.getTime() + 1_000), + ), + }, + { + label: "the context root is retargeted through a symlink", + arrange: ({ stagedDockerfile, replacementCtx }) => { + fs.mkdirSync(replacementCtx); + fs.copyFileSync(stagedDockerfile, path.join(replacementCtx, "Dockerfile")); + }, + mutate: ({ buildCtx, replacementCtx, movedBuildCtx }) => { + fs.renameSync(buildCtx, movedBuildCtx); + fs.symlinkSync(replacementCtx, buildCtx, "dir"); + fs.writeFileSync(path.join(replacementCtx, "Dockerfile"), "FROM changed-target\n"); + }, + }, +]; + describe("prepared DCode rebuild adapter", () => { it.each([ ["resume", { ...preparedOptions, resume: false }], @@ -115,6 +209,78 @@ describe("prepared DCode rebuild adapter", () => { expect(contexts).toEqual([preparedBuildContext, null]); }); + it("rejects retained-context mutation at the post-delete one-shot boundary", async () => { + const verifyBuildCtx = vi.fn(() => false); + const create = vi.fn(async (_context: PreparedSandboxBuildContext | null) => true); + const bound = createPreparedDcodeRebuildRuntime( + { + ...preparedImageOptions, + preparedImageRebuild: { + ...preparedImageOptions.preparedImageRebuild!, + buildContext: { ...preparedImageBuildContext, verifyBuildCtx }, + }, + }, + "nemoclaw", + ).bindCreateSandbox(create); + + await expect(bound()).rejects.toThrow("context changed before use"); + expect(verifyBuildCtx).toHaveBeenCalledOnce(); + expect(create).not.toHaveBeenCalled(); + }); + + it.runIf(process.platform !== "win32").each(oneShotContextMutations)( + "rejects $label at the post-delete one-shot boundary", + async ({ arrange, mutate, label }) => { + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-one-shot-seal-")); + const buildCtx = path.join(testRoot, "context"); + const replacementCtx = path.join(testRoot, "replacement"); + const movedBuildCtx = path.join(testRoot, "context-moved"); + fs.mkdirSync(buildCtx); + const stagedDockerfile = path.join(buildCtx, "Dockerfile"); + fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + const mutationPaths = { buildCtx, stagedDockerfile, replacementCtx, movedBuildCtx }; + arrange(mutationPaths); + const contextFingerprint = fingerprintBuildContext(buildCtx); + const create = vi.fn(async (_context: PreparedSandboxBuildContext | null) => true); + const buildContext: PreparedSandboxBuildContext = { + ...preparedImageBuildContext, + buildCtx, + stagedDockerfile, + buildId: `one-shot-${label}`, + verifyBuildCtx: createBuildContextVerifier(buildCtx, contextFingerprint), + }; + const bound = createPreparedDcodeRebuildRuntime( + { + ...preparedImageOptions, + preparedImageRebuild: { + ...preparedImageOptions.preparedImageRebuild!, + buildContext, + }, + }, + "nemoclaw", + ).bindCreateSandbox(create); + + try { + mutate(mutationPaths); + await expect(bound()).rejects.toThrow("context changed before use"); + expect(create).not.toHaveBeenCalled(); + } finally { + fs.rmSync(testRoot, { recursive: true, force: true }); + } + }, + ); + + it("treats explicit OpenClaw and the legacy null agent as the same prepared target", () => { + const runtime = createPreparedDcodeRebuildRuntime( + { ...preparedImageOptions, agent: "openclaw" }, + "nemoclaw", + ); + + expect(runtime.resolveDockerfileProbePath("/tmp/custom/Dockerfile")).toBe( + preparedImageBuildContext.stagedDockerfile, + ); + }); + it("keeps prepared cleanup with rebuild and registers ordinary staged cleanup", () => { const stage = vi.fn(() => ({ buildCtx: "/tmp/ordinary", diff --git a/src/lib/onboard/prepared-dcode-rebuild.ts b/src/lib/onboard/prepared-dcode-rebuild.ts index 63fa34cedff..1c95fb00023 100644 --- a/src/lib/onboard/prepared-dcode-rebuild.ts +++ b/src/lib/onboard/prepared-dcode-rebuild.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import path from "node:path"; + import type { AgentDefinition } from "../agent/defs"; import { ROOT } from "../runner"; import { OPENCLAW_SANDBOX_BASE_IMAGE, SANDBOX_BASE_TAG } from "../sandbox-base-image"; @@ -27,11 +29,20 @@ export interface PreparedDcodeRebuildHandoff { gatewayName: string; } +export interface PreparedImageRebuildHandoff { + buildContext: PreparedSandboxBuildContext; + gatewayName: string; +} + export interface PreparedDcodeRebuildOptions { resume?: boolean; recreateSandbox?: boolean; + authoritativeResumeConfig?: boolean; + onboardLockAlreadyHeld?: boolean; agent?: string | null; + fromDockerfile?: string | null; preparedDcodeRebuild?: PreparedDcodeRebuildHandoff; + preparedImageRebuild?: PreparedImageRebuildHandoff; } export interface PreparedDcodeRebuildDeps { @@ -43,6 +54,7 @@ export interface PreparedDcodeRebuildDeps { export interface PreparedDcodeRebuildRuntime { applyGatewayEnv(env: NodeJS.ProcessEnv): void; + resolveDockerfileProbePath(fromDockerfile: string): string; bindCreateSandbox( createSandbox: ( ...args: [...Args, preparedBuildContext: PreparedSandboxBuildContext | null] @@ -65,13 +77,50 @@ function loadPrepareSandboxDockerfilePatch(): PrepareSandboxDockerfilePatch { ).prepareSandboxDockerfilePatch; } -function assertPreparedDcodeTarget( +function normalizedDockerfilePath(fromDockerfile: string | null | undefined): string | null { + return fromDockerfile ? path.resolve(fromDockerfile) : null; +} + +function normalizedAgentIdentity(agentName: string | null | undefined): string { + return agentName?.trim() || "openclaw"; +} + +function assertPreparedTargetIdentity( + preparedBuildContext: PreparedSandboxBuildContext, + agentName: string | null, + fromDockerfile: string | null, +): void { + const target = preparedBuildContext.rebuildTarget; + if (target) { + if ( + normalizedAgentIdentity(target.agentName) !== normalizedAgentIdentity(agentName) || + target.fromDockerfile !== normalizedDockerfilePath(fromDockerfile) + ) { + throw new Error("A prepared rebuild image cannot be used for this sandbox target."); + } + return; + } + if (agentName !== DCODE_AGENT || fromDockerfile) { + throw new Error("A prepared DCode build context cannot be used for this sandbox target."); + } +} + +function verifyPreparedBuildContextForUse(preparedBuildContext: PreparedSandboxBuildContext): void { + if ( + typeof preparedBuildContext.verifyBuildCtx === "function" && + !preparedBuildContext.verifyBuildCtx() + ) { + throw new Error("Prepared rebuild image context changed before use."); + } +} + +export function assertPreparedDcodeTarget( preparedBuildContext: PreparedSandboxBuildContext | null, agent: AgentDefinition | null | undefined, fromDockerfile: string | null, ): void { - if (preparedBuildContext && (agent?.name !== DCODE_AGENT || fromDockerfile)) { - throw new Error("A prepared DCode build context cannot be used for this sandbox target."); + if (preparedBuildContext) { + assertPreparedTargetIdentity(preparedBuildContext, agent?.name ?? null, fromDockerfile); } } @@ -79,33 +128,73 @@ export function createPreparedDcodeRebuildRuntime( options: PreparedDcodeRebuildOptions, expectedGatewayName: string, ): PreparedDcodeRebuildRuntime { - const prepared = options.preparedDcodeRebuild ?? null; + const preparedDcode = options.preparedDcodeRebuild ?? null; + const preparedImage = options.preparedImageRebuild ?? null; + if (preparedDcode && preparedImage) { + throw new Error("Only one prepared rebuild image handoff may be provided."); + } if ( - prepared && + preparedDcode && (options.resume !== true || options.recreateSandbox !== true || options.agent !== DCODE_AGENT) ) { throw new Error("A prepared DCode rebuild can only be used by DCode resume recreation."); } + if ( + preparedImage && + (options.resume !== true || + options.recreateSandbox !== true || + options.authoritativeResumeConfig !== true || + options.onboardLockAlreadyHeld !== true) + ) { + throw new Error( + "A prepared rebuild image can only be used by authoritative resume recreation.", + ); + } + if (preparedImage) { + if (!preparedImage.buildContext.rebuildTarget) { + throw new Error("Prepared rebuild image target is missing or invalid."); + } + if (typeof preparedImage.buildContext.verifyBuildCtx !== "function") { + throw new Error("Prepared rebuild image verifier is missing or invalid."); + } + assertPreparedTargetIdentity( + preparedImage.buildContext, + options.agent ?? null, + normalizedDockerfilePath(options.fromDockerfile), + ); + } + const prepared = preparedImage ?? preparedDcode; + const preparedLabel = preparedImage ? "Prepared rebuild image" : "Prepared DCode rebuild"; if (prepared && typeof prepared.gatewayName !== "string") { - throw new Error("Prepared DCode rebuild gateway is missing or invalid."); + throw new Error(`${preparedLabel} gateway is missing or invalid.`); } const gatewayName = prepared?.gatewayName.trim() ?? null; if (gatewayName !== null && gatewayName !== expectedGatewayName) { throw new Error( - `Prepared DCode rebuild gateway '${gatewayName}' does not match '${expectedGatewayName}'.`, + `${preparedLabel} gateway '${gatewayName}' does not match '${expectedGatewayName}'.`, ); } + const retainedBuildContext = preparedImage?.buildContext ?? null; let pendingBuildContext = prepared?.buildContext ?? null; return { applyGatewayEnv(env) { if (gatewayName) env.OPENSHELL_GATEWAY = gatewayName; else delete env.OPENSHELL_GATEWAY; }, + resolveDockerfileProbePath(fromDockerfile) { + const resolvedDockerfile = path.resolve(fromDockerfile); + if (!retainedBuildContext) return resolvedDockerfile; + assertPreparedTargetIdentity(retainedBuildContext, options.agent ?? null, resolvedDockerfile); + return retainedBuildContext.rebuildTarget?.fromDockerfile + ? retainedBuildContext.stagedDockerfile + : resolvedDockerfile; + }, bindCreateSandbox(createSandbox) { - return (...args) => { + return async (...args) => { const buildContext = pendingBuildContext; pendingBuildContext = null; + if (buildContext) verifyPreparedBuildContextForUse(buildContext); return createSandbox(...args, buildContext); }; }, @@ -122,7 +211,10 @@ export function resolveSandboxBuildContext( ): CreateSandboxBuildContextResult { const { preparedBuildContext, agent, fromDockerfile } = input; assertPreparedDcodeTarget(preparedBuildContext, agent, fromDockerfile); - if (preparedBuildContext) return preparedBuildContext; + if (preparedBuildContext) { + verifyPreparedBuildContextForUse(preparedBuildContext); + return preparedBuildContext; + } const staged = (deps.stageCreateSandboxBuildContext ?? loadStageCreateSandboxBuildContext())({ root: ROOT, @@ -147,7 +239,10 @@ export async function resolveSandboxBuildId( ): Promise { const { preparedBuildContext, ...patchInput } = input; assertPreparedDcodeTarget(preparedBuildContext, patchInput.agent, patchInput.fromDockerfile); - if (preparedBuildContext) return preparedBuildContext.buildId; + if (preparedBuildContext) { + verifyPreparedBuildContextForUse(preparedBuildContext); + return preparedBuildContext.buildId; + } const result: SandboxDockerfilePatchResult = await ( deps.prepareSandboxDockerfilePatch ?? loadPrepareSandboxDockerfilePatch() diff --git a/src/lib/onboard/resume-config.test.ts b/src/lib/onboard/resume-config.test.ts index bf0e73f6af1..67da110aea6 100644 --- a/src/lib/onboard/resume-config.test.ts +++ b/src/lib/onboard/resume-config.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { normalizeSession } from "../state/onboard-session"; import { getResumeConfigConflicts } from "./resume-config"; afterEach(() => { @@ -30,4 +31,35 @@ describe("authoritative rebuild resume config", () => { expect(process.env.NEMOCLAW_MODEL).toBe(""); expect(process.env.COMPATIBLE_API_KEY).toBe(""); }); + + it("reports an explicit tool-disclosure mismatch against recorded resume state", () => { + expect( + getResumeConfigConflicts( + { + sandboxName: "demo", + provider: "nvidia-prod", + model: "test-model", + toolDisclosure: "progressive", + }, + { toolDisclosure: "direct" }, + ), + ).toContainEqual({ + field: "tool disclosure", + requested: "direct", + recorded: "progressive", + }); + }); + + it("fails closed for a corrupt persisted tool-disclosure value", () => { + const corrupt = normalizeSession({ + version: 1, + toolDisclosure: "everything", + } as never); + + expect(getResumeConfigConflicts(corrupt, {})).toContainEqual({ + field: "tool disclosure", + requested: null, + recorded: "invalid", + }); + }); }); diff --git a/src/lib/onboard/resume-config.ts b/src/lib/onboard/resume-config.ts index d39d4718e21..cef8f5a0383 100644 --- a/src/lib/onboard/resume-config.ts +++ b/src/lib/onboard/resume-config.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import path from "node:path"; - +import { hasInvalidSessionToolDisclosure } from "../state/onboard-session"; +import { normalizeToolDisclosure, type ToolDisclosure } from "../tool-disclosure"; import { preflightVllmModelEnvOrExit } from "./vllm-model-preflight"; const onboardProviders = require("./providers"); @@ -12,6 +13,7 @@ export interface ResumeSessionLike { provider?: string | null; model?: string | null; agent?: string | null; + toolDisclosure?: ToolDisclosure; metadata?: { fromDockerfile?: string | null } | null; steps?: { sandbox?: { status?: string | null } | null } | null; } @@ -105,6 +107,7 @@ export function getResumeConfigConflicts( fromDockerfile?: string | null; sandboxName?: string | null; agent?: string | null; + toolDisclosure?: ToolDisclosure | null; /** * Internal rebuild-resume mode: the caller already rewrote the session from * validated registry state, so credential aliases must not synthesize a new @@ -161,5 +164,25 @@ export function getResumeConfigConflicts( }); } + const requestedToolDisclosure = normalizeToolDisclosure(opts.toolDisclosure); + const recordedToolDisclosure = normalizeToolDisclosure(session?.toolDisclosure); + if (hasInvalidSessionToolDisclosure(session)) { + conflicts.push({ + field: "tool disclosure", + requested: requestedToolDisclosure, + recorded: "invalid", + }); + } else if ( + requestedToolDisclosure && + recordedToolDisclosure && + requestedToolDisclosure !== recordedToolDisclosure + ) { + conflicts.push({ + field: "tool disclosure", + requested: requestedToolDisclosure, + recorded: recordedToolDisclosure, + }); + } + return conflicts; } diff --git a/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts b/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts index e18275b84d3..2d795af83db 100644 --- a/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts +++ b/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts @@ -97,6 +97,8 @@ describe("prepareSandboxDockerfilePatch", () => { }); expect(patchStagedDockerfile.mock.calls[0]?.[11]).toEqual({ buildIdPolicy: "preserve", + toolDisclosure: "progressive", + requireToolDisclosureContract: false, baseImageResolutionMetadata: resolutionMetadata, }); }); @@ -159,7 +161,11 @@ describe("prepareSandboxDockerfilePatch", () => { false, null, ["github"], - { buildIdPolicy: "preserve" }, + { + buildIdPolicy: "preserve", + toolDisclosure: "progressive", + requireToolDisclosureContract: false, + }, ); }); @@ -195,6 +201,8 @@ describe("prepareSandboxDockerfilePatch", () => { expect(dockerImageInspect).not.toHaveBeenCalled(); expect(patchStagedDockerfile.mock.calls[0]?.[11]).toEqual({ buildIdPolicy: "preserve", + toolDisclosure: "progressive", + requireToolDisclosureContract: false, }); }); @@ -240,6 +248,8 @@ describe("prepareSandboxDockerfilePatch", () => { ); expect(patchStagedDockerfile.mock.calls[0]?.[11]).toEqual({ buildIdPolicy: "rewrite", + toolDisclosure: "progressive", + requireToolDisclosureContract: true, }); }); @@ -269,6 +279,8 @@ describe("prepareSandboxDockerfilePatch", () => { expect(patchStagedDockerfile.mock.calls[0]?.[11]).toEqual({ buildIdPolicy: "rewrite", + toolDisclosure: "progressive", + requireToolDisclosureContract: false, }); }); diff --git a/src/lib/onboard/sandbox-dockerfile-patch-flow.ts b/src/lib/onboard/sandbox-dockerfile-patch-flow.ts index 4eb216749ac..8b1c0985dc9 100644 --- a/src/lib/onboard/sandbox-dockerfile-patch-flow.ts +++ b/src/lib/onboard/sandbox-dockerfile-patch-flow.ts @@ -7,6 +7,7 @@ import { SandboxBaseImageResolutionError, type SandboxBaseImageResolutionMetadata, } from "../sandbox-base-image"; +import { DEFAULT_TOOL_DISCLOSURE, type ToolDisclosure } from "../tool-disclosure"; import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; type DockerRunResult = { status: number | null }; @@ -38,6 +39,7 @@ export type PrepareSandboxDockerfilePatchInput = { provider: string | null; preferredInferenceApi: string | null; webSearchConfig: WebSearchConfig | null; + toolDisclosure?: ToolDisclosure; hermesToolGateways: string[]; sandboxGpuConfig: SandboxGpuConfig; resolutionHint?: SandboxBaseImageResolutionMetadata | null; @@ -101,6 +103,7 @@ export async function prepareSandboxDockerfilePatch({ provider, preferredInferenceApi, webSearchConfig, + toolDisclosure = DEFAULT_TOOL_DISCLOSURE, hermesToolGateways, sandboxGpuConfig, resolutionHint = null, @@ -184,6 +187,8 @@ export async function prepareSandboxDockerfilePatch({ const metadata = fromDockerfile ? null : (resolved?.metadata ?? preResolvedBaseImageMetadata); return { buildIdPolicy, + toolDisclosure, + requireToolDisclosureContract: Boolean(fromDockerfile), ...(metadata ? { baseImageResolutionMetadata: metadata } : {}), }; })(), diff --git a/src/lib/onboard/sandbox-lifecycle.test.ts b/src/lib/onboard/sandbox-lifecycle.test.ts index fd43a260424..ec8c8485dc9 100644 --- a/src/lib/onboard/sandbox-lifecycle.test.ts +++ b/src/lib/onboard/sandbox-lifecycle.test.ts @@ -58,7 +58,7 @@ describe("sandbox lifecycle MCP destroy boundaries", () => { isAffirmativeAnswer: () => false, }); - expect(() => helpers.reconcileSandboxForCreate("alpha")).toThrow( + expect(() => helpers.inspectSandboxForCreate("alpha")).toThrow( /incomplete MCP destroy transaction.*finish cleanup before recreating/i, ); expect(runCaptureOpenshell).not.toHaveBeenCalled(); @@ -67,4 +67,23 @@ describe("sandbox lifecycle MCP destroy boundaries", () => { }); } } + + it("inspects a stale registry entry without pruning it", () => { + const runCaptureOpenshell = vi.fn(() => null); + registryState.sandbox = { name: "alpha", agent: "openclaw" }; + const helpers = createSandboxLifecycleHelpers({ + runCaptureOpenshell, + fetchGatewayAuthTokenFromSandbox: () => null, + agentProductName: () => "OpenClaw", + prompt: async () => "no", + isAffirmativeAnswer: () => false, + }); + + expect(helpers.inspectSandboxForCreate("alpha")).toMatchObject({ + existingEntry: registryState.sandbox, + liveExists: false, + preservedMcpState: undefined, + }); + expect(registryState.removeSandbox).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/onboard/sandbox-lifecycle.ts b/src/lib/onboard/sandbox-lifecycle.ts index 1520d847ef1..88e392cd662 100644 --- a/src/lib/onboard/sandbox-lifecycle.ts +++ b/src/lib/onboard/sandbox-lifecycle.ts @@ -14,7 +14,7 @@ export interface SandboxLifecycleDeps { } export interface SandboxLifecycleHelpers { - reconcileSandboxForCreate(sandboxName: string): { + inspectSandboxForCreate(sandboxName: string): { existingEntry: SandboxEntry | null; preservedMcpState: SandboxMcpState | undefined; liveExists: boolean; @@ -45,7 +45,7 @@ export function createSandboxLifecycleHelpers(deps: SandboxLifecycleDeps): Sandb return liveExists; } - function reconcileSandboxForCreate(sandboxName: string) { + function inspectSandboxForCreate(sandboxName: string) { const existingEntry = registry.getSandbox(sandboxName); if (existingEntry?.mcp?.destroyPreparedAt || existingEntry?.mcp?.destroyPendingAt) { throw new Error( @@ -58,9 +58,7 @@ export function createSandboxLifecycleHelpers(deps: SandboxLifecycleDeps): Sandb : undefined; // MCP state is the rebuild transaction manifest. Preserve it while the // sandbox is absent; registration carries the validated state forward. - const liveExists = preservedMcpState - ? sandboxExistsInGateway(sandboxName) - : pruneStaleSandboxEntry(sandboxName); + const liveExists = sandboxExistsInGateway(sandboxName); return { existingEntry, preservedMcpState, liveExists }; } @@ -95,7 +93,7 @@ export function createSandboxLifecycleHelpers(deps: SandboxLifecycleDeps): Sandb } return { - reconcileSandboxForCreate, + inspectSandboxForCreate, pruneStaleSandboxEntry, shouldRestoreLatestBackupOnRecreate, confirmRecreateForSelectionDrift, diff --git a/src/lib/onboard/sandbox-registration.test.ts b/src/lib/onboard/sandbox-registration.test.ts index c06e8554d50..f9c381260c1 100644 --- a/src/lib/onboard/sandbox-registration.test.ts +++ b/src/lib/onboard/sandbox-registration.test.ts @@ -66,6 +66,7 @@ describe("buildCreatedSandboxRegistryEntry", () => { preferredInferenceApi: "openai-completions", imageTag: "nemoclaw-demo:123", policies: ["discord", "slack"], + toolDisclosure: "progressive", webSearchEnabled: true, fromDockerfile: "/tmp/Dockerfile.custom", hermesAuthMethod: "api_key", @@ -140,6 +141,7 @@ describe("buildCreatedSandboxRegistryEntry", () => { expect(entry.webSearchEnabled).toBe(false); expect(entry.fromDockerfile).toBeNull(); expect(entry.hermesAuthMethod).toBeNull(); + expect(entry.toolDisclosure).toBe("progressive"); }); it("carries a durable MCP rebuild manifest into the replacement registry entry", () => { @@ -173,6 +175,7 @@ describe("buildCreatedSandboxRegistryEntry", () => { agentVersionKnown: true, imageTag: "nemoclaw-demo:replacement", appliedPolicies: [], + toolDisclosure: "direct", plannedMessagingState: undefined, preservedMcpState, hermesToolGateways: [], @@ -185,6 +188,7 @@ describe("buildCreatedSandboxRegistryEntry", () => { expect(entry.mcp).toBe(preservedMcpState); expect(entry.mcp?.bridges.github?.providerName).toBe("demo-mcp-github"); expect(entry.compatibleEndpointReasoning).toBe("true"); + expect(entry.toolDisclosure).toBe("direct"); }); it("normalizes invalid preferred inference API values", () => { @@ -214,6 +218,35 @@ describe("buildCreatedSandboxRegistryEntry", () => { expect(entry.preferredInferenceApi).toBeNull(); }); + + it("records an explicit direct tool-disclosure selection", () => { + const entry = buildCreatedSandboxRegistryEntry({ + sandboxName: "demo", + inferenceSelection: { + model: "llama", + provider: "compatible-endpoint", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + compatibleEndpointReasoning: null, + nimContainer: null, + }, + runtimeFields, + agent: null, + agentVersionKnown: true, + imageTag: null, + appliedPolicies: [], + toolDisclosure: "direct", + plannedMessagingState: undefined, + hermesToolGateways: [], + hermesDashboardState: { enabled: false, config: null }, + dashboardPort: 18789, + gatewayName: "nemoclaw", + gatewayPort: 8080, + }); + + expect(entry.toolDisclosure).toBe("direct"); + }); }); describe("selection", () => { diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index 618ee3a30ea..b807f08976a 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -8,6 +8,7 @@ import { type WebSearchConfig, webSearchProviderForConfig } from "../inference/w import * as onboardSession from "../state/onboard-session"; import type { SandboxEntry, SandboxMcpState, SandboxMessagingState } from "../state/registry"; import * as registry from "../state/registry"; +import { DEFAULT_TOOL_DISCLOSURE, type ToolDisclosure } from "../tool-disclosure"; import { getHermesDashboardRegistryFields, type HermesDashboardOnboardState, @@ -34,6 +35,7 @@ export interface CreatedSandboxRegistryEntryInput { agentVersionKnown: boolean; imageTag: string | null; appliedPolicies: string[]; + toolDisclosure?: ToolDisclosure; webSearchEnabled?: boolean; webSearchProvider?: SandboxEntry["webSearchProvider"]; fromDockerfile?: string | null; @@ -110,6 +112,7 @@ export function buildCreatedSandboxRegistryEntry( ...getSandboxAgentRegistryFields(input.agent, input.agentVersionKnown), imageTag: input.imageTag, policies: input.appliedPolicies, + toolDisclosure: input.toolDisclosure ?? DEFAULT_TOOL_DISCLOSURE, webSearchEnabled: input.webSearchEnabled === true, webSearchProvider: input.webSearchEnabled === true ? (input.webSearchProvider ?? "brave") : null, diff --git a/src/lib/onboard/session-bootstrap.test.ts b/src/lib/onboard/session-bootstrap.test.ts index 65749601bb7..00c9b1877ed 100644 --- a/src/lib/onboard/session-bootstrap.test.ts +++ b/src/lib/onboard/session-bootstrap.test.ts @@ -4,8 +4,8 @@ import { describe, expect, it, vi } from "vitest"; import { createSession, type Session } from "../state/onboard-session"; -import { prepareOnboardSession, type OnboardSessionBootstrapDeps } from "./session-bootstrap"; import type { ResumeConfigConflict } from "./resume-config"; +import { type OnboardSessionBootstrapDeps, prepareOnboardSession } from "./session-bootstrap"; class ExitError extends Error { constructor(readonly code: number) { @@ -71,6 +71,7 @@ describe("prepareOnboardSession", () => { requestedSandboxName: null, cannotPrompt: false, nonInteractive: true, + requestedToolDisclosure: "direct", }, deps, ); @@ -79,9 +80,26 @@ describe("prepareOnboardSession", () => { expect(result.fromDockerfile).toBe("/abs/Dockerfile.custom"); expect(result.session?.mode).toBe("non-interactive"); expect(result.session?.metadata.fromDockerfile).toBe("/abs/Dockerfile.custom"); + expect(result.session?.toolDisclosure).toBe("direct"); expect(getSession()?.sessionId).not.toBe("old-session"); }); + it("defaults a fresh session to progressive disclosure", async () => { + const { deps } = createDeps(); + const result = await prepareOnboardSession( + { + resume: false, + fresh: false, + requestedFromDockerfile: null, + requestedSandboxName: null, + cannotPrompt: false, + nonInteractive: false, + }, + deps, + ); + expect(result.session?.toolDisclosure).toBe("progressive"); + }); + it("resumes an existing session and falls back to the recorded Dockerfile", async () => { const initial = createSession({ agent: "hermes", diff --git a/src/lib/onboard/session-bootstrap.ts b/src/lib/onboard/session-bootstrap.ts index e51f43947a3..14c1b0b7cf0 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { Session } from "../state/onboard-session"; +import { DEFAULT_TOOL_DISCLOSURE, type ToolDisclosure } from "../tool-disclosure"; import type { ResumeConfigConflict } from "./resume-config"; export interface OnboardSessionBootstrapInput { @@ -14,6 +15,7 @@ export interface OnboardSessionBootstrapInput { authoritativeResumeConfig?: boolean; agentFlag?: string | null; envAgent?: string | null; + requestedToolDisclosure?: ToolDisclosure | null; } export interface OnboardSessionBootstrapDeps { @@ -31,6 +33,7 @@ export interface OnboardSessionBootstrapDeps { fromDockerfile?: string | null; sandboxName?: string | null; agent?: string | null; + toolDisclosure?: ToolDisclosure | null; authoritativeResumeConfig?: boolean; }, ): ResumeConfigConflict[]; @@ -154,6 +157,7 @@ async function prepareResumeSession( fromDockerfile: input.requestedFromDockerfile, sandboxName: input.requestedSandboxName, agent: input.agentFlag || null, + toolDisclosure: input.requestedToolDisclosure ?? null, authoritativeResumeConfig: input.authoritativeResumeConfig, }); if (resumeConflicts.length > 0) { @@ -185,6 +189,7 @@ function prepareFreshSession( const session = deps.saveSession( deps.createSession({ mode: mode(input.nonInteractive), + toolDisclosure: input.requestedToolDisclosure ?? DEFAULT_TOOL_DISCLOSURE, metadata: { gatewayName: "nemoclaw", fromDockerfile: fromDockerfile || null }, }), ); diff --git a/src/lib/onboard/session-updates.ts b/src/lib/onboard/session-updates.ts index d9dd4f315b1..c1222240258 100644 --- a/src/lib/onboard/session-updates.ts +++ b/src/lib/onboard/session-updates.ts @@ -4,6 +4,7 @@ import type { WebSearchConfig } from "../inference/web-search"; import type { SandboxMessagingPlan } from "../messaging/manifest"; import type { HermesAuthMethod, SessionUpdates } from "../state/onboard-session"; +import { normalizeToolDisclosure, type ToolDisclosure } from "../tool-disclosure"; export interface OnboardSessionUpdateInput { sandboxName?: string | null; @@ -16,6 +17,7 @@ export interface OnboardSessionUpdateInput { compatibleEndpointReasoning?: string | null; nimContainer?: string | null; webSearchConfig?: WebSearchConfig | null; + toolDisclosure?: ToolDisclosure | string; policyPresets?: string[] | null; messagingPlan?: SandboxMessagingPlan | null; hermesToolGateways?: string[] | null; @@ -54,6 +56,10 @@ export function toSessionUpdates(updates: OnboardSessionUpdateInput = {}): Sessi if (updates.nimContainer !== undefined) normalized.nimContainer = toNullableString(updates.nimContainer); if (updates.webSearchConfig !== undefined) normalized.webSearchConfig = updates.webSearchConfig; + if (updates.toolDisclosure !== undefined) { + const toolDisclosure = normalizeToolDisclosure(updates.toolDisclosure); + if (toolDisclosure) normalized.toolDisclosure = toolDisclosure; + } if (updates.policyPresets !== undefined) normalized.policyPresets = updates.policyPresets; if (updates.messagingPlan !== undefined) normalized.messagingPlan = updates.messagingPlan; if (updates.hermesToolGateways !== undefined) diff --git a/src/lib/onboard/tool-disclosure-flow.test.ts b/src/lib/onboard/tool-disclosure-flow.test.ts new file mode 100644 index 00000000000..13c6575c176 --- /dev/null +++ b/src/lib/onboard/tool-disclosure-flow.test.ts @@ -0,0 +1,133 @@ +// 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(() => ({ + assertDockerfileContract: vi.fn(), + loadSession: vi.fn(), + removeSandbox: vi.fn(), + updateSession: vi.fn(), +})); + +vi.mock("../state/onboard-session", () => ({ + loadSession: mocks.loadSession, + updateSession: mocks.updateSession, +})); +vi.mock("../state/registry", () => ({ + removeSandbox: mocks.removeSandbox, +})); +vi.mock("./dockerfile-tool-disclosure-contract", () => ({ + assertToolDisclosureDockerfileContract: mocks.assertDockerfileContract, +})); + +import { + applyOnboardToolDisclosureRequest, + prepareSandboxToolDisclosure, +} from "./tool-disclosure-flow"; + +const ENV_KEY = "NEMOCLAW_TOOL_DISCLOSURE"; + +function interceptExit() { + return vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`EXIT:${code}`); + }) as never); +} + +describe("onboard tool-disclosure flow", () => { + beforeEach(() => { + vi.stubEnv(ENV_KEY, undefined); + mocks.assertDockerfileContract.mockReset(); + mocks.loadSession.mockReset(); + mocks.removeSandbox.mockReset(); + mocks.updateSession.mockReset(); + mocks.loadSession.mockReturnValue({ toolDisclosure: "progressive" }); + mocks.updateSession.mockImplementation( + (mutator: (session: { toolDisclosure?: string }) => unknown) => + mutator({ toolDisclosure: "progressive" }), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("resolves CLI before env and rejects an invalid request at the public boundary", () => { + vi.stubEnv(ENV_KEY, "direct"); + expect(applyOnboardToolDisclosureRequest("progressive")).toBe("progressive"); + expect(process.env[ENV_KEY]).toBe("progressive"); + + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + interceptExit(); + expect(() => applyOnboardToolDisclosureRequest("sometimes")).toThrow("EXIT:1"); + expect(error).toHaveBeenCalledWith(expect.stringContaining("must be one of")); + }); + + it("preserves an explicit mode and reports one-time migration for legacy live state", () => { + const result = prepareSandboxToolDisclosure( + "alpha", + null, + false, + () => ({ + existingEntry: { name: "alpha", toolDisclosure: undefined }, + preservedMcpState: undefined, + liveExists: true, + }), + "direct", + ); + + expect(result).toMatchObject({ + effectiveToolDisclosure: "direct", + toolDisclosureMigrationNeeded: true, + toolDisclosureMigrationNote: expect.stringContaining("apply direct tool disclosure"), + }); + expect(mocks.updateSession).toHaveBeenCalledOnce(); + expect(mocks.removeSandbox).not.toHaveBeenCalled(); + }); + + it("fails before session or registry mutation for invalid recorded state", () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + interceptExit(); + + expect(() => + prepareSandboxToolDisclosure( + "alpha", + null, + false, + () => ({ + existingEntry: { name: "alpha", toolDisclosure: "invalid" as never }, + preservedMcpState: undefined, + liveExists: true, + }), + null, + ), + ).toThrow("EXIT:1"); + expect(mocks.updateSession).not.toHaveBeenCalled(); + expect(mocks.removeSandbox).not.toHaveBeenCalled(); + }); + + it("fails before session or registry mutation when a custom Dockerfile violates the contract", () => { + mocks.assertDockerfileContract.mockImplementation(() => { + throw new Error("missing final-stage declaration"); + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + interceptExit(); + + expect(() => + prepareSandboxToolDisclosure( + "alpha", + "/tmp/Dockerfile.custom", + true, + () => ({ + existingEntry: null, + preservedMcpState: undefined, + liveExists: false, + }), + "progressive", + ), + ).toThrow("EXIT:1"); + expect(mocks.updateSession).not.toHaveBeenCalled(); + expect(mocks.removeSandbox).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/onboard/tool-disclosure-flow.ts b/src/lib/onboard/tool-disclosure-flow.ts new file mode 100644 index 00000000000..ef0ffb92e1a --- /dev/null +++ b/src/lib/onboard/tool-disclosure-flow.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; +import * as onboardSession from "../state/onboard-session"; +import * as registry from "../state/registry"; +import { + DEFAULT_TOOL_DISCLOSURE, + resolveSandboxToolDisclosure, + resolveToolDisclosureRequest, + TOOL_DISCLOSURE_ENV, + type ToolDisclosure, +} from "../tool-disclosure"; +import { assertToolDisclosureDockerfileContract } from "./dockerfile-tool-disclosure-contract"; +import type { SandboxLifecycleHelpers } from "./sandbox-lifecycle"; + +export function applyOnboardToolDisclosureRequest(value: unknown): ToolDisclosure | null { + let requested: ToolDisclosure | null; + try { + requested = resolveToolDisclosureRequest(value, process.env); + } catch (error) { + console.error(` ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + if (requested) process.env[TOOL_DISCLOSURE_ENV] = requested; + return requested; +} + +export function prepareSandboxToolDisclosure( + sandboxName: string, + fromDockerfile: string | null, + recreate: boolean, + inspectSandboxForCreate: SandboxLifecycleHelpers["inspectSandboxForCreate"], + desiredToolDisclosure: ToolDisclosure | null = null, +) { + const { existingEntry, preservedMcpState, liveExists } = inspectSandboxForCreate(sandboxName); + let mode: ToolDisclosure; + try { + mode = resolveSandboxToolDisclosure({ + requested: desiredToolDisclosure ?? resolveToolDisclosureRequest(null, process.env), + recorded: existingEntry?.toolDisclosure, + session: onboardSession.loadSession()?.toolDisclosure, + sandboxExists: liveExists, + recreate, + }); + } catch (error) { + console.error(` Tool disclosure configuration is invalid: ${String(error)}`); + console.error(` Re-run with --recreate-sandbox --tool-disclosure ${DEFAULT_TOOL_DISCLOSURE}.`); + process.exit(1); + } + + if (fromDockerfile) { + try { + assertToolDisclosureDockerfileContract(path.resolve(fromDockerfile), mode); + } catch (error) { + console.error( + ` Custom Dockerfile tool-disclosure contract is invalid: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } + } + + // Keep inspection and validation ahead of every mutation. Splitting these + // steps across lifecycle callbacks would require a transaction object to + // preserve this fail-closed ordering for registry and session state. + if (existingEntry && !liveExists && !preservedMcpState) registry.removeSandbox(sandboxName); + onboardSession.updateSession((session) => { + session.toolDisclosure = mode; + return session; + }); + + const migrationNeeded = Boolean( + liveExists && existingEntry && existingEntry.toolDisclosure === undefined, + ); + return { + existingEntry, + preservedMcpState, + liveExists, + effectiveToolDisclosure: mode, + toolDisclosureMigrationNeeded: migrationNeeded, + toolDisclosureMigrationNote: migrationNeeded + ? ` Sandbox '${sandboxName}' exists — recreating to apply ${mode} tool disclosure.` + : null, + }; +} diff --git a/src/lib/onboard/types.ts b/src/lib/onboard/types.ts index 3d4e74f4b2a..7c163f25afa 100644 --- a/src/lib/onboard/types.ts +++ b/src/lib/onboard/types.ts @@ -53,6 +53,11 @@ export interface ModelValidationFailure extends ValidationFailureLike { export type ModelValidationResult = ModelValidationSuccess | ModelValidationFailure; +export interface SandboxCreateIntent { + readonly recreate: boolean; + readonly toolDisclosure: import("../tool-disclosure").ToolDisclosure; +} + export type OnboardOptions = { nonInteractive?: boolean; recreateSandbox?: boolean; @@ -65,6 +70,8 @@ export type OnboardOptions = { onboardLockAlreadyHeld?: boolean; /** Internal one-shot handoff for a prevalidated managed DCode replacement. */ preparedDcodeRebuild?: import("./prepared-dcode-rebuild").PreparedDcodeRebuildHandoff; + /** Internal one-shot handoff for the exact image context validated before rebuild deletion. */ + preparedImageRebuild?: import("./prepared-dcode-rebuild").PreparedImageRebuildHandoff; resume?: boolean; fresh?: boolean; fromDockerfile?: string | null; @@ -73,6 +80,7 @@ export type OnboardOptions = { sandboxGpuDevice?: string | null; acceptThirdPartySoftware?: boolean; agent?: string | null; + toolDisclosure?: import("../tool-disclosure").ToolDisclosure | null; controlUiPort?: number | null; gpu?: boolean; noGpu?: boolean; diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index d3c50cd3fdd..cc075dfc511 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -76,6 +76,10 @@ function stageLegacySandboxBuildContext( path.join(buildCtx, "src", "lib", "messaging"), { recursive: true }, ); + fs.copyFileSync( + path.join(rootDir, "src", "lib", "tool-disclosure.ts"), + path.join(buildCtx, "src", "lib", "tool-disclosure.ts"), + ); normalizeReadModesForDockerCopy(path.join(buildCtx, "src")); fs.rmSync(path.join(buildCtx, "nemoclaw", "node_modules"), { recursive: true, @@ -183,6 +187,10 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "generate-openclaw-config.mts"), path.join(stagedScriptsDir, "generate-openclaw-config.mts"), ); + fs.copyFileSync( + path.join(rootDir, "scripts", "validate-openclaw-tool-search.mts"), + path.join(stagedScriptsDir, "validate-openclaw-tool-search.mts"), + ); // Shared sandbox initialisation library sourced by the entrypoint (#2277) fs.mkdirSync(path.join(stagedScriptsDir, "lib"), { recursive: true }); fs.copyFileSync( @@ -215,6 +223,10 @@ function stageOptimizedSandboxBuildContext( path.join(buildCtx, "src", "lib", "messaging"), { recursive: true }, ); + fs.copyFileSync( + path.join(rootDir, "src", "lib", "tool-disclosure.ts"), + path.join(buildCtx, "src", "lib", "tool-disclosure.ts"), + ); normalizeReadModesForDockerCopy(path.join(buildCtx, "src")); fs.copyFileSync( path.join(rootDir, "scripts", "patch-openclaw-tool-catalog.js"), diff --git a/src/lib/security/redact.test.ts b/src/lib/security/redact.test.ts index 349b581b16a..d2b54dbf7a9 100644 --- a/src/lib/security/redact.test.ts +++ b/src/lib/security/redact.test.ts @@ -3,7 +3,94 @@ import { describe, expect, it } from "vitest"; -import { redactForLog } from "./redact.js"; +import { redact, redactForLog, redactUrl } from "./redact.js"; + +describe("URL redaction", () => { + it.each([ + ["SOCKS", "socks5://socks-user:socks-password@proxy.example:1080"], + ["mixed-case FTP", "FtP://ftp-user:ftp-password@files.example/path"], + ["mixed-case HTTPS", "HTTPS://https-user:https-password@secure.example:8443"], + ])("redacts embedded credentials from %s URLs", (_label, value) => { + const result = redact(value); + + expect(result).toContain("****:****@"); + expect(result).not.toContain("-user"); + expect(result).not.toContain("-password"); + }); + + it("redacts a bracket-wrapped SOCKS URL without breaking its closing delimiter", () => { + const result = redact( + "proxy [socks5://bracket-user:bracket-password@proxy.example:1080] failed", + ); + + expect(result).toContain("socks5://****:****@proxy.example:1080]"); + expect(result).not.toContain("bracket-user"); + expect(result).not.toContain("bracket-password"); + }); + + it("bounds malformed wrapper parsing before falling back to userinfo redaction", () => { + const wrappers = "]".repeat(4_096); + const result = redact( + `proxy [socks5://bounded-user:bounded-password@proxy.example:1080${wrappers}`, + ); + + expect(result).toContain("socks5://****:****@proxy.example:1080"); + expect(result).not.toContain("bounded-user"); + expect(result).not.toContain("bounded-password"); + }); + + it("preserves a credentialed IPv6 host while redacting its userinfo", () => { + const result = redact("proxy https://ipv6-user:ipv6-password@[::1]:8443/path failed"); + + expect(result).toContain("https://****:****@[::1]:8443/path"); + expect(result).not.toContain("ipv6-user"); + expect(result).not.toContain("ipv6-password"); + }); + + it.each([ + [ + "parentheses and comma", + "proxy (https://wrapped-user:wrapped-password@proxy.example/path), retry", + "(https://****:****@proxy.example/path), retry", + ], + [ + "angle brackets and semicolon", + "proxy ; retry", + "; retry", + ], + [ + "a trailing sentence period", + "proxy socks5://wrapped-user:wrapped-password@proxy.example:1080. retry", + "socks5://****:****@proxy.example:1080. retry", + ], + ])("keeps %s outside the redacted URL token", (_label, value, expected) => { + const result = redact(value); + + expect(result).toContain(expected); + expect(result).not.toContain("wrapped-user"); + expect(result).not.toContain("wrapped-password"); + }); + + it.each([ + ["semicolon", "pa;ssword"], + ["comma", "pa,ssword"], + ["balanced parentheses", "pa(ss)word"], + ])("redacts credentials containing valid %s punctuation", (_label, password) => { + const result = redact(`proxy https://userinfo-user:${password}@proxy.example/path failed`); + + expect(result).toContain("https://****:****@proxy.example/path"); + expect(result).not.toContain("userinfo-user"); + expect(result).not.toContain(password); + }); + + it("fully removes generic-scheme userinfo and sensitive query values", () => { + const result = redactUrl( + "FtP://ftp-user:ftp-password@files.example/path?token=secret-value#fragment", + ); + + expect(result).toBe("ftp://files.example/path?token=%3CREDACTED%3E"); + }); +}); describe("redactForLog", () => { it("redacts sensitive object keys recursively while preserving safe fields", () => { diff --git a/src/lib/security/redact.ts b/src/lib/security/redact.ts index 65136359207..595c612f1c7 100644 --- a/src/lib/security/redact.ts +++ b/src/lib/security/redact.ts @@ -41,32 +41,95 @@ const SENSITIVE_ENV_ASSIGNMENT_PATTERN = new RegExp( "gi", ); +// Proxy variables and diagnostics are not limited to lowercase HTTP(S) URLs. +// Match any RFC-style URI scheme so credentials in uppercase or SOCKS proxy +// URLs receive the same URL-parser-backed redaction. +const URL_TOKEN_PATTERN = /[a-z][a-z0-9+.-]*:\/\/[^\s'"]+/gi; +const URL_TRAILING_DELIMITERS = ")]}>.,;:!?"; +const MAX_URL_PARSE_ATTEMPTS = 9; + // ── Partial redaction (runner.ts style) ───────────────────────── function redactMatch(match: string): string { return match.slice(0, 4) + "*".repeat(Math.min(match.length - 4, 20)); } +function isUnmatchedClosingDelimiter(value: string, closing: string): boolean { + const openingByClosing: Record = { + ")": "(", + "]": "[", + "}": "{", + ">": "<", + }; + const opening = openingByClosing[closing]; + if (!opening) return false; + let balance = 0; + for (const character of value) { + if (character === opening) balance += 1; + else if (character === closing) balance -= 1; + } + return balance < 0; +} + +function isProseUrlSuffix(value: string, trailing: string): boolean { + return ".,;".includes(trailing) || isUnmatchedClosingDelimiter(value, trailing); +} + +function parseUrlToken(value: string): { url: URL; suffix: string } | null { + let candidate = value; + let suffix = ""; + for (let attempt = 0; candidate && attempt < MAX_URL_PARSE_ATTEMPTS; attempt += 1) { + const trailing = candidate.at(-1); + // Capture the complete token first so punctuation that is valid in + // userinfo cannot terminate redaction. Only then peel terminal prose + // punctuation and unmatched wrapper closers before URL parsing. + if (trailing && isProseUrlSuffix(candidate, trailing)) { + candidate = candidate.slice(0, -1); + suffix = `${trailing}${suffix}`; + continue; + } + try { + return { url: new URL(candidate), suffix }; + } catch { + if (!trailing || !URL_TRAILING_DELIMITERS.includes(trailing)) return null; + candidate = candidate.slice(0, -1); + suffix = `${trailing}${suffix}`; + } + } + return null; +} + +function redactMalformedUrlUserinfo(value: string, replacement: string | null): string { + const schemeEnd = value.indexOf("://") + 3; + if (schemeEnd < 3) return value; + const relativeAuthorityEnd = value.slice(schemeEnd).search(/[/?#]/); + const authorityEnd = relativeAuthorityEnd < 0 ? value.length : schemeEnd + relativeAuthorityEnd; + const authority = value.slice(schemeEnd, authorityEnd); + const userinfoEnd = authority.lastIndexOf("@"); + if (userinfoEnd < 1) return value; + const userinfo = authority.slice(0, userinfoEnd); + const redactedUserinfo = + replacement === null ? "" : `${userinfo.includes(":") ? `${replacement}:` : ""}${replacement}@`; + return `${value.slice(0, schemeEnd)}${redactedUserinfo}${authority.slice(userinfoEnd + 1)}${value.slice(authorityEnd)}`; +} + function redactUrlPartial(value: string): string { if (typeof value !== "string" || value.length === 0) return value; - try { - const url = new URL(value); - if (url.username) url.username = "****"; - if (url.password) url.password = "****"; - for (const key of [...url.searchParams.keys()]) { - if (/(^|[-_])(?:signature|sig|token|auth|access_token)$/i.test(key)) { - url.searchParams.set(key, "****"); - } + const parsed = parseUrlToken(value); + if (!parsed) return redactMalformedUrlUserinfo(value, "****"); + if (parsed.url.username) parsed.url.username = "****"; + if (parsed.url.password) parsed.url.password = "****"; + for (const key of [...parsed.url.searchParams.keys()]) { + if (/(^|[-_])(?:signature|sig|token|auth|access_token)$/i.test(key)) { + parsed.url.searchParams.set(key, "****"); } - return url.toString(); - } catch { - return value; } + return `${parsed.url.toString()}${parsed.suffix}`; } export function redact(str: string): string { if (typeof str !== "string") return str; - let out = str.replace(/https?:\/\/[^\s'"]+/g, redactUrlPartial); + let out = str.replace(URL_TOKEN_PATTERN, redactUrlPartial); for (const pat of SECRET_PATTERNS) { pat.lastIndex = 0; out = out.replace(pat, redactMatch); @@ -166,22 +229,19 @@ function escapeRegExp(value: string): string { export function redactUrl(value: unknown): string | null { if (typeof value !== "string" || value.length === 0) return null; - try { - const url = new URL(value); - if (url.username || url.password) { - url.username = ""; - url.password = ""; - } - for (const key of [...url.searchParams.keys()]) { - if (/(^|[-_])(?:signature|sig|token|auth|access_token)$/i.test(key)) { - url.searchParams.set(key, ""); - } + const parsed = parseUrlToken(value); + if (!parsed) return redactSensitiveText(redactMalformedUrlUserinfo(value, null)); + if (parsed.url.username || parsed.url.password) { + parsed.url.username = ""; + parsed.url.password = ""; + } + for (const key of [...parsed.url.searchParams.keys()]) { + if (/(^|[-_])(?:signature|sig|token|auth|access_token)$/i.test(key)) { + parsed.url.searchParams.set(key, ""); } - url.hash = ""; - return url.toString(); - } catch { - return redactSensitiveText(value); } + parsed.url.hash = ""; + return `${parsed.url.toString()}${parsed.suffix}`; } function isSensitiveKey(key: string): boolean { diff --git a/src/lib/state/onboard-session-tool-disclosure.test.ts b/src/lib/state/onboard-session-tool-disclosure.test.ts new file mode 100644 index 00000000000..3bd9e552cce --- /dev/null +++ b/src/lib/state/onboard-session-tool-disclosure.test.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const modulePath = require.resolve("./onboard-session"); +const originalHome = process.env.HOME; +type OnboardSessionModule = typeof import("./onboard-session"); +type LoadedSession = NonNullable>; +type DebugSummary = NonNullable>; +let session: OnboardSessionModule; +let tmpDir: string; + +function requireLoadedSession( + loaded: ReturnType, +): LoadedSession { + expect(loaded).not.toBeNull(); + return loaded as LoadedSession; +} + +function requireDebugSummary( + summary: ReturnType, +): DebugSummary { + expect(summary).not.toBeNull(); + return summary as DebugSummary; +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-session-tool-disclosure-")); + process.env.HOME = tmpDir; + delete require.cache[modulePath]; + session = require("./onboard-session"); + session.clearSession(); + session.releaseOnboardLock(); +}); + +afterEach(() => { + delete require.cache[modulePath]; + fs.rmSync(tmpDir, { recursive: true, force: true }); + Reflect.deleteProperty(process.env, "HOME"); + Object.assign(process.env, originalHome === undefined ? {} : { HOME: originalHome }); +}); + +describe("onboard session tool disclosure", () => { + it("round-trips direct tool disclosure and defaults legacy sessions to progressive", () => { + session.saveSession(session.createSession({ toolDisclosure: "direct" })); + expect(requireLoadedSession(session.loadSession()).toolDisclosure).toBe("direct"); + expect(requireDebugSummary(session.summarizeForDebug()).toolDisclosure).toBe("direct"); + + const legacy = session.createSession() as unknown as Record; + delete legacy.toolDisclosure; + const normalized = session.normalizeSession( + legacy as Parameters[0], + ); + expect(requireLoadedSession(normalized).toolDisclosure).toBe("progressive"); + }); + + it("marks corrupt persisted tool-disclosure state instead of treating it as legacy missing", () => { + const corrupt = session.createSession() as unknown as Record; + corrupt.toolDisclosure = "everything"; + + const normalized = requireLoadedSession(session.normalizeSession(corrupt as never)); + expect(normalized.toolDisclosure).toBe("progressive"); + expect(session.hasInvalidSessionToolDisclosure(normalized)).toBe(true); + }); +}); diff --git a/src/lib/state/onboard-session-tool-disclosure.ts b/src/lib/state/onboard-session-tool-disclosure.ts new file mode 100644 index 00000000000..a739e2d7586 --- /dev/null +++ b/src/lib/state/onboard-session-tool-disclosure.ts @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + DEFAULT_TOOL_DISCLOSURE, + invalidRecordedToolDisclosure, + normalizeToolDisclosure, + type ToolDisclosure, +} from "../tool-disclosure"; + +const INVALID_TOOL_DISCLOSURE_SESSIONS = new WeakSet(); + +export type { ToolDisclosure } from "../tool-disclosure"; + +/** True when a normalized session carried a non-null, unsupported persisted value. */ +export function hasInvalidSessionToolDisclosure(session: unknown): boolean { + return typeof session === "object" && session !== null + ? INVALID_TOOL_DISCLOSURE_SESSIONS.has(session) + : false; +} + +export function normalizeSessionToolDisclosure(value: unknown): ToolDisclosure { + return normalizeToolDisclosure(value) ?? DEFAULT_TOOL_DISCLOSURE; +} + +export function preserveInvalidSessionToolDisclosure(source: unknown, target: object): void { + const recorded = + typeof source === "object" && source !== null + ? (source as { toolDisclosure?: unknown }).toolDisclosure + : undefined; + if (hasInvalidSessionToolDisclosure(source) || invalidRecordedToolDisclosure(recorded)) { + INVALID_TOOL_DISCLOSURE_SESSIONS.add(target); + } +} + +export function assignSafeToolDisclosureUpdate( + target: { toolDisclosure?: ToolDisclosure }, + value: unknown, +): void { + const toolDisclosure = normalizeToolDisclosure(value); + if (toolDisclosure) target.toolDisclosure = toolDisclosure; +} diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index dcb105f638d..14601738181 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -152,6 +152,7 @@ describe("onboard session", () => { const dirStat = fs.statSync(path.dirname(session.SESSION_FILE)); expect(saved.mode).toBe("non-interactive"); + expect(saved.toolDisclosure).toBe("progressive"); expect(saved.machine).toMatchObject({ version: 1, state: "init", diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index f4c0b88a0c1..b0269a1696d 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -25,6 +25,12 @@ import { import { isOnboardMachineState } from "../onboard/machine/transitions"; import type { OnboardMachineState } from "../onboard/machine/types"; import { redactSensitiveText, redactUrl } from "../security/redact"; +import { + assignSafeToolDisclosureUpdate, + normalizeSessionToolDisclosure, + preserveInvalidSessionToolDisclosure, + type ToolDisclosure, +} from "./onboard-session-tool-disclosure"; import { LEGACY_MACHINE_STEP_MUTATION_OPTIONS, RECORD_ONLY_STEP_MUTATION_OPTIONS, @@ -54,6 +60,8 @@ const STEP_STATES: readonly StepStatus[] = [ ]; const VALID_STEP_STATES: ReadonlySet = new Set(STEP_STATES); +export { hasInvalidSessionToolDisclosure } from "./onboard-session-tool-disclosure"; + // ── Types ──────────────────────────────────────────────────────── export interface StepState { @@ -105,6 +113,8 @@ export interface Session { routerPid: number | null; routerCredentialHash: string | null; webSearchConfig: WebSearchConfig | null; + /** Selected preference, retained even when a model-specific safeguard downgrades it. */ + toolDisclosure: ToolDisclosure; hermesToolGateways: string[] | null; policyPresets: string[] | null; messagingPlan: SandboxMessagingPlan | null; @@ -175,6 +185,7 @@ export interface SessionUpdates { routerPid?: number; routerCredentialHash?: string; webSearchConfig?: WebSearchConfig | null; + toolDisclosure?: ToolDisclosure; hermesToolGateways?: string[] | null; policyPresets?: string[] | null; messagingPlan?: SandboxMessagingPlan | null; @@ -202,6 +213,7 @@ export interface DebugSessionSummary { preferredInferenceApi: string | null; compatibleEndpointReasoning: string | null; nimContainer: string | null; + toolDisclosure: ToolDisclosure; hermesToolGateways: string[] | null; policyPresets: string[] | null; gpuPassthrough: boolean; @@ -456,6 +468,7 @@ export function createSession(overrides: Partial = {}): Session { routerPid: readPositiveInteger(overrides.routerPid), routerCredentialHash: overrides.routerCredentialHash ?? null, webSearchConfig: normalizeWebSearchConfig(overrides.webSearchConfig), + toolDisclosure: normalizeSessionToolDisclosure(overrides.toolDisclosure), hermesToolGateways: readStringArray(overrides.hermesToolGateways), policyPresets: readStringArray(overrides.policyPresets), messagingPlan: parseSandboxMessagingPlan(overrides.messagingPlan), @@ -474,6 +487,7 @@ export function createSession(overrides: Partial = {}): Session { createMachineSnapshot("init", startedAt), steps, }; + preserveInvalidSessionToolDisclosure(overrides, session); return session; } @@ -498,6 +512,7 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): routerPid: readPositiveInteger(data.routerPid), routerCredentialHash: readString(data.routerCredentialHash), webSearchConfig: parseWebSearchConfig(data.webSearchConfig), + toolDisclosure: normalizeSessionToolDisclosure(data.toolDisclosure), hermesToolGateways: readStringArray(data.hermesToolGateways), policyPresets: readStringArray(data.policyPresets), messagingPlan: parseSandboxMessagingPlan(data.messagingPlan), @@ -523,6 +538,7 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): } normalized.machine = parseMachineSnapshot(data.machine) ?? inferMachineSnapshot(normalized); + preserveInvalidSessionToolDisclosure(data, normalized); return normalized; } @@ -993,6 +1009,7 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { } else if (updates.webSearchConfig === null) { safe.webSearchConfig = null; } + assignSafeToolDisclosureUpdate(safe, updates.toolDisclosure); if (updates.hermesToolGateways === null) { safe.hermesToolGateways = null; } else if (Array.isArray(updates.hermesToolGateways)) { @@ -1286,6 +1303,7 @@ export function summarizeForDebug( preferredInferenceApi: session.preferredInferenceApi, compatibleEndpointReasoning: session.compatibleEndpointReasoning, nimContainer: session.nimContainer, + toolDisclosure: session.toolDisclosure, hermesToolGateways: session.hermesToolGateways, policyPresets: session.policyPresets, gpuPassthrough: session.gpuPassthrough, diff --git a/src/lib/state/openclaw-config-merge-tool-search.test.ts b/src/lib/state/openclaw-config-merge-tool-search.test.ts new file mode 100644 index 00000000000..3f777baa17e --- /dev/null +++ b/src/lib/state/openclaw-config-merge-tool-search.test.ts @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { mergeOpenClawRestoredConfig } from "./openclaw-config-merge"; + +describe("mergeOpenClawRestoredConfig Tool Search", () => { + it("keeps the rebuilt tool-search selection while restoring other tool settings", () => { + const merged = mergeOpenClawRestoredConfig( + { + tools: { + toolSearch: false, + web: { fetch: { enabled: false } }, + loopDetection: { enabled: true, historySize: 12 }, + }, + }, + { + tools: { + toolSearch: { mode: "tools", maxResults: 8 }, + web: { fetch: { enabled: true } }, + }, + }, + ) as { tools: Record }; + + expect(merged.tools.toolSearch).toEqual({ mode: "tools", maxResults: 8 }); + expect(merged.tools.web).toEqual({ fetch: { enabled: false } }); + expect(merged.tools.loopDetection).toEqual({ enabled: true, historySize: 12 }); + }); + + it("does not resurrect backed-up Tool Search when the rebuilt config omits it", () => { + const merged = mergeOpenClawRestoredConfig( + { + tools: { + toolSearch: { mode: "code" }, + loopDetection: { enabled: true }, + }, + }, + { gateway: { auth: { token: "fresh-token" } } }, + ) as { tools: Record }; + + expect(merged.tools.toolSearch).toBeUndefined(); + expect(merged.tools.loopDetection).toEqual({ enabled: true }); + }); +}); diff --git a/src/lib/state/openclaw-config-merge.ts b/src/lib/state/openclaw-config-merge.ts index d3eaaec3088..b3bd0cad369 100644 --- a/src/lib/state/openclaw-config-merge.ts +++ b/src/lib/state/openclaw-config-merge.ts @@ -34,6 +34,8 @@ export const OPENCLAW_CONFIG_RESTORE_OWNERSHIP = { modelRuntimeOwnedFields: ["id", "name"], /** Durable user-owned top-level sections are inherited from the backup. */ backupDurableSections: ["mcp", "mcpServers", "customAgents", "agents"], + /** NemoClaw's cross-agent disclosure selection owns this generated key. */ + currentGeneratedToolFields: ["toolSearch"], } as const; const MANAGED_OPENCLAW_CHANNELS = new Set( @@ -129,6 +131,9 @@ function mergeOpenClawEntryMap( function mergeOpenClawTools(backupTools: unknown, currentTools: unknown): unknown { if (!isPlainJsonObject(backupTools)) return cloneJson(currentTools); + if (!isPlainJsonObject(currentTools) && currentTools !== undefined && currentTools !== null) { + return cloneJson(currentTools); + } const current = isPlainJsonObject(currentTools) ? currentTools : {}; const merged = mergeJsonObjects(current, backupTools); @@ -143,6 +148,13 @@ function mergeOpenClawTools(backupTools: unknown, currentTools: unknown): unknow if (Object.keys(mergedWeb).length > 0) merged.web = mergedWeb; else delete merged.web; + + // Tool Search is generated from NemoClaw's current disclosure selection. + // Its absence is authoritative, just like omission of web.search above. + for (const field of OPENCLAW_CONFIG_RESTORE_OWNERSHIP.currentGeneratedToolFields) { + if (field in current) merged[field] = cloneJson(current[field]); + else delete merged[field]; + } return merged; } diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index c8fe3a9a6fa..46eebb3eab0 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { isErrnoException } from "../core/errno"; import type { InferenceSelection } from "../inference/selection"; import { inferenceSelectionRegistryFields } from "../inference/selection"; +import { normalizeToolDisclosure, type ToolDisclosure } from "../tool-disclosure"; import { ensureConfigDir, readConfigFile, writeConfigFile } from "./config-io"; import { applyAddExtraProvider, @@ -28,6 +29,7 @@ export { type SandboxEntryInference, } from "./registry-entry-view"; +import type { WebSearchProvider } from "../inference/web-search"; import { cloneSandboxMessagingState, getConfiguredMessagingChannels as getRegistryConfiguredMessagingChannels, @@ -35,7 +37,6 @@ import { serializeSandboxMessagingStateForDisk, setChannelDisabled as setRegistryChannelDisabled, } from "./registry-messaging"; -import type { WebSearchProvider } from "../inference/web-search"; export type { McpBridgeEntry, SandboxMcpState } from "./registry-mcp"; @@ -96,6 +97,8 @@ export interface SandboxEntry extends Partial { // represents a final selection it can carry forward. See #4621. policyPresetsFinalized?: boolean; webSearchEnabled?: boolean; + /** Selected disclosure preference; model compatibility safeguards may downgrade runtime behavior. */ + toolDisclosure?: ToolDisclosure; /** Durable provider identity for enabled managed web search. */ webSearchProvider?: WebSearchProvider | null; agent?: string | null; @@ -462,6 +465,9 @@ export function registerSandbox(entry: SandboxEntry): void { policyTier: entry.policyTier || null, webSearchEnabled: typeof entry.webSearchEnabled === "boolean" ? entry.webSearchEnabled : undefined, + // Preserve absence on reconstructed legacy rows. Only a freshly built + // sandbox registration may claim the new progressive default. + toolDisclosure: normalizeToolDisclosure(entry.toolDisclosure) ?? undefined, webSearchProvider: entry.webSearchEnabled === true && (entry.webSearchProvider === "brave" || entry.webSearchProvider === "tavily") diff --git a/src/lib/tool-disclosure.test.ts b/src/lib/tool-disclosure.test.ts new file mode 100644 index 00000000000..3c0b0e54c7e --- /dev/null +++ b/src/lib/tool-disclosure.test.ts @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_TOOL_DISCLOSURE, + readToolDisclosureEnv, + resolveSandboxToolDisclosure, + resolveToolDisclosureRequest, + toolDisclosureOrDefault, +} from "./tool-disclosure"; + +describe("tool disclosure", () => { + it("defaults missing legacy state to progressive", () => { + expect(DEFAULT_TOOL_DISCLOSURE).toBe("progressive"); + expect(toolDisclosureOrDefault(undefined)).toBe("progressive"); + }); + + it("resolves CLI before env and validates the closed enum", () => { + expect( + resolveToolDisclosureRequest("direct", { NEMOCLAW_TOOL_DISCLOSURE: "progressive" }), + ).toBe("direct"); + expect(resolveToolDisclosureRequest(undefined, { NEMOCLAW_TOOL_DISCLOSURE: " DIRECT " })).toBe( + "direct", + ); + expect(resolveToolDisclosureRequest(undefined, {})).toBeNull(); + expect(() => + resolveToolDisclosureRequest(undefined, { NEMOCLAW_TOOL_DISCLOSURE: "sometimes" }), + ).toThrow(/progressive, direct/); + }); + + it("shares the build-time environment parser across agent generators", () => { + expect(readToolDisclosureEnv({})).toBe("progressive"); + expect(readToolDisclosureEnv({ NEMOCLAW_TOOL_DISCLOSURE: " DIRECT " })).toBe("direct"); + expect(() => readToolDisclosureEnv({ NEMOCLAW_TOOL_DISCLOSURE: "sometimes" })).toThrow( + "NEMOCLAW_TOOL_DISCLOSURE must be progressive or direct", + ); + }); + + it("preserves recorded behavior on reuse and lets recreation override it", () => { + expect( + resolveSandboxToolDisclosure({ + requested: null, + recorded: "direct", + session: "progressive", + sandboxExists: true, + recreate: false, + }), + ).toBe("direct"); + expect( + resolveSandboxToolDisclosure({ + requested: "progressive", + recorded: "direct", + session: "direct", + sandboxExists: true, + recreate: true, + }), + ).toBe("progressive"); + expect(() => + resolveSandboxToolDisclosure({ + requested: "direct", + recorded: "progressive", + session: "progressive", + sandboxExists: true, + recreate: false, + }), + ).toThrow(/recreate the sandbox/); + }); + + it("recovers interrupted creation from session state", () => { + expect( + resolveSandboxToolDisclosure({ + requested: null, + recorded: undefined, + session: "direct", + sandboxExists: false, + recreate: true, + }), + ).toBe("direct"); + }); + + it("preserves an explicit mode while migrating missing live sandbox state", () => { + expect( + resolveSandboxToolDisclosure({ + requested: "direct", + recorded: undefined, + session: "progressive", + sandboxExists: true, + recreate: false, + }), + ).toBe("direct"); + }); +}); diff --git a/src/lib/tool-disclosure.ts b/src/lib/tool-disclosure.ts new file mode 100644 index 00000000000..ace6f6ce240 --- /dev/null +++ b/src/lib/tool-disclosure.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Agent-neutral model-visible tool catalog policy. */ +export type ToolDisclosure = "progressive" | "direct"; + +export const DEFAULT_TOOL_DISCLOSURE: ToolDisclosure = "progressive"; +export const TOOL_DISCLOSURE_ENV = "NEMOCLAW_TOOL_DISCLOSURE"; +export const TOOL_DISCLOSURE_VALUES = ["progressive", "direct"] as const; + +/** Normalize a user or persisted value without silently accepting unknown modes. */ +export function normalizeToolDisclosure(value: unknown): ToolDisclosure | null { + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + return normalized === "progressive" || normalized === "direct" ? normalized : null; +} + +/** Read the build-time environment contract with the shared closed-enum policy. */ +export function readToolDisclosureEnv( + env: NodeJS.ProcessEnv | Record = process.env, +): ToolDisclosure { + const raw = env[TOOL_DISCLOSURE_ENV] || DEFAULT_TOOL_DISCLOSURE; + const normalized = normalizeToolDisclosure(raw); + if (!normalized) { + throw new Error(`${TOOL_DISCLOSURE_ENV} must be progressive or direct`); + } + return normalized; +} + +/** Resolve an explicit CLI/env request. Blank values are treated as unset. */ +export function resolveToolDisclosureRequest( + cliValue: unknown, + env: NodeJS.ProcessEnv | Record = process.env, +): ToolDisclosure | null { + const rawCli = typeof cliValue === "string" ? cliValue.trim() : ""; + const rawEnv = + typeof env[TOOL_DISCLOSURE_ENV] === "string" ? env[TOOL_DISCLOSURE_ENV]!.trim() : ""; + const raw = rawCli || rawEnv; + if (!raw) return null; + const normalized = normalizeToolDisclosure(raw); + if (!normalized) { + throw new Error( + `${TOOL_DISCLOSURE_ENV} / --tool-disclosure must be one of: ${TOOL_DISCLOSURE_VALUES.join( + ", ", + )}.`, + ); + } + return normalized; +} + +/** Missing state predates this setting and adopts the new progressive default. */ +export function toolDisclosureOrDefault(value: unknown): ToolDisclosure { + return normalizeToolDisclosure(value) ?? DEFAULT_TOOL_DISCLOSURE; +} + +export function invalidRecordedToolDisclosure(value: unknown): boolean { + return value !== undefined && value !== null && normalizeToolDisclosure(value) === null; +} + +export function resolveSandboxToolDisclosure(input: { + requested: ToolDisclosure | null; + recorded: unknown; + session: unknown; + sandboxExists: boolean; + recreate: boolean; +}): ToolDisclosure { + if (invalidRecordedToolDisclosure(input.recorded)) { + throw new Error("recorded toolDisclosure value is invalid"); + } + const recorded = normalizeToolDisclosure(input.recorded); + const session = normalizeToolDisclosure(input.session); + + // Reusing a live sandbox must keep the behavior already baked into it. + if (input.sandboxExists && !input.recreate) { + if (recorded) { + if (input.requested && input.requested !== recorded) { + throw new Error( + `sandbox records tool disclosure '${recorded}', but '${input.requested}' was requested; recreate the sandbox to change it`, + ); + } + return recorded; + } + // Missing durable state marks a legacy sandbox that the caller will + // recreate. Preserve an explicit requested mode for that migration. + return input.requested ?? session ?? DEFAULT_TOOL_DISCLOSURE; + } + + // A deliberate recreation may override recorded state. With no explicit + // request, preserve the sandbox's durable choice; interrupted creation falls + // back to its session before adopting the new default. + return input.requested ?? recorded ?? session ?? DEFAULT_TOOL_DISCLOSURE; +} diff --git a/test/e2e/live/mcp-bridge-servers.ts b/test/e2e/live/mcp-bridge-servers.ts index c7cf2e7a170..6f129deb89b 100644 --- a/test/e2e/live/mcp-bridge-servers.ts +++ b/test/e2e/live/mcp-bridge-servers.ts @@ -53,7 +53,8 @@ const MCP_NOTIFICATION_METHODS = new Set([ const TRYCLOUDFLARE_ORIGIN_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com(?=$|[\s"'\\/])/i; const QUICK_TUNNEL_ATTEMPTS = 3; const QUICK_TUNNEL_ATTEMPT_TIMEOUT_MS = 45_000; -const QUICK_TUNNEL_LOG_LIMIT = 32 * 1024; +const QUICK_TUNNEL_DISCOVERY_CARRY_LIMIT = 512; +const OMITTED_CLOUDFLARED_OUTPUT_DIAGNOSTIC = "cloudflared child output omitted from diagnostics"; const CLOUDFLARED_ENV_NAMES = new Set([ "PATH", "TMPDIR", @@ -253,10 +254,17 @@ export async function startPublicMcpHttpsTunnel(options: { let lastFailure = "cloudflared did not publish a quick-tunnel URL"; for (let attempt = 1; attempt <= QUICK_TUNNEL_ATTEMPTS; attempt += 1) { - let output = ""; + let origin: string | null = null; + let childOutputSeen = false; let spawnError: Error | undefined; - const appendOutput = (chunk: string): void => { - output = `${output}${chunk}`.slice(-QUICK_TUNNEL_LOG_LIMIT); + const inspectOutputForOrigin = (): ((chunk: string) => void) => { + let carry = ""; + return (chunk: string): void => { + childOutputSeen = true; + const candidate = `${carry}${chunk}`; + origin ??= parseTryCloudflareOrigin(candidate); + carry = candidate.slice(-QUICK_TUNNEL_DISCOVERY_CARRY_LIMIT); + }; }; const child = spawn(options.cloudflaredBin ?? "cloudflared", args, { detached: true, @@ -265,9 +273,9 @@ export async function startPublicMcpHttpsTunnel(options: { }); const exited = waitForExit(child); child.stdout?.setEncoding("utf8"); - child.stdout?.on("data", appendOutput); + child.stdout?.on("data", inspectOutputForOrigin()); child.stderr?.setEncoding("utf8"); - child.stderr?.on("data", appendOutput); + child.stderr?.on("data", inspectOutputForOrigin()); child.once("error", (error) => { spawnError = error; }); @@ -278,7 +286,6 @@ export async function startPublicMcpHttpsTunnel(options: { return closePromise; }; const deadline = Date.now() + QUICK_TUNNEL_ATTEMPT_TIMEOUT_MS; - let origin: string | null = null; while (Date.now() < deadline) { if (spawnError) { @@ -289,7 +296,6 @@ export async function startPublicMcpHttpsTunnel(options: { lastFailure = `cloudflared exited before readiness (code=${String(child.exitCode)}, signal=${String(child.signalCode)})`; break; } - origin ??= parseTryCloudflareOrigin(output); if (origin) { const probe = await probePublicTunnel(origin); if (probe.ready) { @@ -307,8 +313,14 @@ export async function startPublicMcpHttpsTunnel(options: { } await close(); - const diagnostic = output.trim().split("\n").slice(-12).join("\n"); - if (diagnostic) lastFailure = `${lastFailure}\n${diagnostic}`; + // Raw child output is intentionally excluded from thrown diagnostics. + // Redacting completed chunks is unsafe when a credential continues in a + // later data event, while retaining an arbitrary unfinished token would + // make diagnostic memory unbounded. The bounded carry above exists only + // to discover a quick-tunnel origin and is never surfaced to callers. + if (childOutputSeen) { + lastFailure = `${lastFailure}\n${OMITTED_CLOUDFLARED_OUTPUT_DIAGNOSTIC}`; + } if (attempt < QUICK_TUNNEL_ATTEMPTS) await delay(attempt * 1_000); } @@ -324,6 +336,7 @@ export async function startCompatibleMock(options: { toolResultToken?: string; toolNames?: string[]; deferredToolName?: string; + progressiveToolSearch?: { toolName: string; query: string }; }): Promise { const server = http.createServer(async (req, res) => { const requestPath = new URL(req.url ?? "/", "http://compatible.mock").pathname; @@ -347,55 +360,148 @@ export async function startCompatibleMock(options: { ) { const body = JSON.parse(await readRequestBody(req)) as { stream?: boolean; - messages?: Array<{ role?: string; content?: unknown }>; + messages?: Array<{ role?: string; content?: unknown; tool_call_id?: string }>; tools?: Array<{ function?: { name?: string } }>; }; - const directToolName = body.tools - ?.map((tool) => tool.function?.name) - .find( - (name): name is string => - typeof name === "string" && (options.toolNames ?? []).includes(name), + const visibleToolNames = new Set( + (body.tools ?? []) + .map((tool) => tool.function?.name) + .filter((name): name is string => typeof name === "string"), + ); + const toolResults = (body.messages ?? []).filter((message) => message.role === "tool"); + const toolResultCount = toolResults.length; + const sawAuthenticatedToolResult = toolResults.some((message) => + JSON.stringify(message.content).includes(options.toolResultToken ?? "__never__"), + ); + const hasExpectedToolResult = ( + index: number, + toolCallId: string, + requiredContent: string[], + ) => { + const message = toolResults[index]; + const content = JSON.stringify(message?.content); + return ( + message?.tool_call_id === toolCallId && + requiredContent.every((value) => content.includes(value)) ); - const deferredToolWrapper = - !directToolName && - options.deferredToolName && - body.tools?.some((tool) => tool.function?.name === "tool_call") - ? "tool_call" - : undefined; - const toolName = directToolName ?? deferredToolWrapper; - const toolArguments = directToolName - ? { challenge: options.toolChallenge } - : { - name: options.deferredToolName, + }; + let plannedToolCall: + | { id: string; name: string; arguments: Record } + | undefined; + let protocolError: string | undefined; + + if (!sawAuthenticatedToolResult && options.progressiveToolSearch) { + const { query, toolName } = options.progressiveToolSearch; + if (toolResultCount === 0 && visibleToolNames.has(toolName)) { + protocolError = `progressive target ${toolName} was visible before search_tools`; + } else if (toolResultCount === 0 && !visibleToolNames.has("search_tools")) { + protocolError = "search_tools was not visible before progressive discovery"; + } else if (toolResultCount === 0) { + plannedToolCall = { + id: "call_progressive_tool_search", + name: "search_tools", + arguments: { query }, + }; + } else if ( + toolResultCount !== 1 || + !hasExpectedToolResult(0, "call_progressive_tool_search", [`- ${toolName}:`]) + ) { + protocolError = "search_tools did not return the expected progressive target"; + } else if (!visibleToolNames.has(toolName)) { + protocolError = `progressive target ${toolName} was not visible after search_tools`; + } else { + plannedToolCall = { + id: "call_progressive_mcp_proof", + name: toolName, arguments: { challenge: options.toolChallenge }, }; - const sawAuthenticatedToolResult = (body.messages ?? []).some( - (message) => - message.role === "tool" && - JSON.stringify(message.content).includes(options.toolResultToken ?? "__never__"), - ); + } + } else if (!sawAuthenticatedToolResult && options.deferredToolName) { + const bridgeNames = ["tool_search", "tool_describe", "tool_call"]; + const missingBridges = bridgeNames.filter((name) => !visibleToolNames.has(name)); + if (visibleToolNames.has(options.deferredToolName)) { + protocolError = `deferred target ${options.deferredToolName} leaked into model tools`; + } else if (missingBridges.length > 0) { + protocolError = `Hermes tool search bridges missing: ${missingBridges.join(", ")}`; + } else if (toolResultCount === 0) { + plannedToolCall = { + id: "call_hermes_tool_search", + name: "tool_search", + arguments: { query: options.deferredToolName }, + }; + } else if (toolResultCount === 1) { + if ( + hasExpectedToolResult(0, "call_hermes_tool_search", [ + "matches", + options.deferredToolName, + ]) + ) { + plannedToolCall = { + id: "call_hermes_tool_describe", + name: "tool_describe", + arguments: { name: options.deferredToolName }, + }; + } else { + protocolError = "Hermes tool_search did not return the deferred target"; + } + } else if (toolResultCount === 2) { + if ( + hasExpectedToolResult(1, "call_hermes_tool_describe", [ + options.deferredToolName, + "parameters", + "challenge", + ]) + ) { + plannedToolCall = { + id: "call_hermes_tool_call", + name: "tool_call", + arguments: { + name: options.deferredToolName, + arguments: { challenge: options.toolChallenge }, + }, + }; + } else { + protocolError = "Hermes tool_describe did not return the deferred schema"; + } + } else { + protocolError = "Hermes returned an unexpected number of tool results"; + } + } else if (!sawAuthenticatedToolResult) { + const directToolName = [...visibleToolNames].find((name) => + (options.toolNames ?? []).includes(name), + ); + if (directToolName) { + plannedToolCall = { + id: "call_mcp_bridge_proof", + name: directToolName, + arguments: { challenge: options.toolChallenge }, + }; + } + } const responseMessage = sawAuthenticatedToolResult ? { role: "assistant", content: options.toolResultToken, } - : toolName && options.toolChallenge - ? { - role: "assistant", - content: null, - tool_calls: [ - { - index: 0, - id: "call_mcp_bridge_proof", - type: "function", - function: { - name: toolName, - arguments: JSON.stringify(toolArguments), + : protocolError + ? { role: "assistant", content: `mock protocol error: ${protocolError}` } + : plannedToolCall && options.toolChallenge + ? { + role: "assistant", + content: null, + tool_calls: [ + { + index: 0, + id: plannedToolCall.id, + type: "function", + function: { + name: plannedToolCall.name, + arguments: JSON.stringify(plannedToolCall.arguments), + }, }, - }, - ], - } - : { role: "assistant", content: "ok" }; + ], + } + : { role: "assistant", content: "ok" }; const finishReason = "tool_calls" in responseMessage ? "tool_calls" : "stop"; if (body.stream) { res.writeHead(200, { diff --git a/test/e2e/live/mcp-bridge.test.ts b/test/e2e/live/mcp-bridge.test.ts index a4714377fb5..1d626a1dd60 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -1381,7 +1381,7 @@ liveAgentMatrixTest( model: COMPATIBLE_MODEL, toolChallenge: TOOL_CHALLENGE, toolResultToken: deepAgentsResult, - toolNames: ["fake_fake_echo"], + progressiveToolSearch: { toolName: "fake_fake_echo", query: "AuThEnTiCaTeD McP" }, }); cleanup.add("stop Deep Agents MCP bridge compatible endpoint mock", () => compatibleMock.close(), diff --git a/test/fixtures/deepagents-progressive-disclosure-harness.py b/test/fixtures/deepagents-progressive-disclosure-harness.py new file mode 100644 index 00000000000..9fff89daefd --- /dev/null +++ b/test/fixtures/deepagents-progressive-disclosure-harness.py @@ -0,0 +1,741 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Dependency-free behavioral harness for progressive_tool_disclosure.py.""" + +from __future__ import annotations + +import argparse +import asyncio +import importlib +import importlib.util +import inspect +import json +import sys +import types +from pathlib import Path +from typing import Any, TypeVar + + +class _Generic: + @classmethod + def __class_getitem__(cls, _item: object) -> type: + return cls + + +class AgentMiddleware(_Generic): + def __init__(self) -> None: + self.tools: list[BaseTool] = [] + + +class AgentState(dict[str, Any], _Generic): + pass + + +class ModelResponse(_Generic): + pass + + +class AIMessage: + pass + + +class ToolMessage: + def __init__(self, content: str, *, tool_call_id: str | None = None) -> None: + self.content = content + self.tool_call_id = tool_call_id + + +class BaseTool: + def __init__( + self, + name: str, + description: str = "", + schema: dict[str, Any] | None = None, + ) -> None: + self.name = name + self.description = description + self.schema = schema or {"properties": {}, "type": "object"} + + +class StructuredTool(BaseTool): + def __init__(self, name: str, description: str, func: Any, coroutine: Any) -> None: + super().__init__(name, description) + self.func = func + self.coroutine = coroutine + + @classmethod + def from_function( + cls, + *, + name: str, + description: str, + func: Any, + coroutine: Any, + **_kwargs: Any, + ) -> "StructuredTool": + return cls(name, description, func, coroutine) + + @property + def injected_args_keys(self) -> frozenset[str]: + """Model the pinned StructuredTool runtime-argument retention check.""" + return frozenset( + name + for name, parameter in inspect.signature(self.func).parameters.items() + if parameter.annotation is ToolRuntime + ) + + +class ToolRuntime(_Generic): + def __init__( + self, + state: dict[str, Any], + tool_call_id: str = "search-call", + tools: list[BaseTool] | None = None, + ) -> None: + self.state = state + self.tool_call_id = tool_call_id + self.tools = tools or [] + + +class ModelRequest(_Generic): + def __init__(self, tools: list[Any], state: dict[str, Any]) -> None: + self.tools = tools + self.state = state + + def override(self, **changes: Any) -> "ModelRequest": + return ModelRequest( + changes.get("tools", self.tools), changes.get("state", self.state) + ) + + +class Command(_Generic): + def __init__(self, *, update: dict[str, Any]) -> None: + self.update = update + + +class BaseModel: + pass + + +def Field(*, description: str, max_length: int | None = None) -> str: + del max_length + return description + + +def convert_to_openai_tool(tool: BaseTool | dict[str, Any]) -> dict[str, Any]: + if isinstance(tool, BaseTool): + return { + "type": "function", + "function": { + "description": tool.description, + "name": tool.name, + "parameters": tool.schema, + }, + } + return tool + + +def _install_stubs() -> None: + context_t = TypeVar("ContextT") + response_t = TypeVar("ResponseT") + modules: dict[str, types.ModuleType] = {} + for name in ( + "langchain", + "langchain.agents", + "langchain.agents.middleware", + "langchain.agents.middleware.types", + "langchain.tools", + "langchain_core", + "langchain_core.messages", + "langchain_core.tools", + "langchain_core.utils", + "langchain_core.utils.function_calling", + "langgraph", + "langgraph.runtime", + "langgraph.types", + "pydantic", + ): + module = types.ModuleType(name) + modules[name] = module + sys.modules[name] = module + + middleware_types = modules["langchain.agents.middleware.types"] + middleware_types.AgentMiddleware = AgentMiddleware + middleware_types.AgentState = AgentState + middleware_types.ContextT = context_t + middleware_types.ModelRequest = ModelRequest + middleware_types.ModelResponse = ModelResponse + middleware_types.PrivateStateAttr = object() + middleware_types.ResponseT = response_t + modules["langchain.tools"].ToolRuntime = ToolRuntime + modules["langchain_core.messages"].AIMessage = AIMessage + modules["langchain_core.messages"].ToolMessage = ToolMessage + modules["langchain_core.tools"].BaseTool = BaseTool + modules["langchain_core.tools"].StructuredTool = StructuredTool + modules[ + "langchain_core.utils.function_calling" + ].convert_to_openai_tool = convert_to_openai_tool + modules["langgraph.types"].Command = Command + modules["pydantic"].BaseModel = BaseModel + modules["pydantic"].Field = Field + + +def _load_module(path: Path) -> types.ModuleType: + _install_stubs() + spec = importlib.util.spec_from_file_location("progressive_tool_disclosure", path) + if spec is None or spec.loader is None: + raise AssertionError(f"could not load {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _fixture(module: types.ModuleType) -> tuple[Any, list[Any], BaseTool, BaseTool]: + middleware = module.ProgressiveToolDisclosureMiddleware() + weather = BaseTool("Weather_Forecast", "Get a five-day weather outlook") + database = BaseTool("query_database", "Search customer records by account name") + tools: list[Any] = [ + weather, + BaseTool("ls", "List files"), + database, + middleware.tools[0], + BaseTool("read_file", "Read a file"), + {"type": "provider-native"}, + ] + return middleware, tools, weather, database + + +def _visible_names(request: ModelRequest) -> list[str]: + return [tool.name for tool in request.tools if isinstance(tool, BaseTool)] + + +def _run_behavior(module: types.ModuleType) -> dict[str, Any]: + middleware, tools, weather, database = _fixture(module) + assert module.MAX_SEARCH_QUERY_LENGTH == 256 + provider_native = tools[-1] + original = list(tools) + captured: list[ModelRequest] = [] + middleware.wrap_model_call( + ModelRequest(tools, {}), + lambda request: captured.append(request) or ModelResponse(), + ) + assert _visible_names(captured[-1]) == ["ls", "search_tools", "read_file"] + assert captured[-1].tools[-1] is provider_native + assert tools == original + assert tools[0] is weather and tools[2] is database + + search_tool = middleware.tools[0] + assert search_tool.injected_args_keys == frozenset({"runtime"}) + by_name = search_tool.func(query="wEaThEr", runtime=ToolRuntime({}, tools=tools)) + assert by_name.update["discovered_tools"] == ["Weather_Forecast"] + assert "Weather_Forecast" in by_name.update["messages"][0].content + state = module._merge_discovered_tools(None, by_name.update["discovered_tools"]) + revealed = middleware._prepare_request( + ModelRequest(tools, {"discovered_tools": state}) + ) + assert weather in revealed.tools + + by_description = search_tool.func( + query="CUSTOMER RECORDS", + runtime=ToolRuntime({"discovered_tools": state}, tools=tools), + ) + assert by_description.update["discovered_tools"] == ["query_database"] + state = module._merge_discovered_tools( + state, by_description.update["discovered_tools"] + ) + assert state == ["Weather_Forecast", "query_database"] + cumulative = middleware._prepare_request( + ModelRequest(tools, {"discovered_tools": state}) + ) + assert weather in cumulative.tools and database in cumulative.tools + assert cumulative.tools[-1] is provider_native + + repeated = search_tool.func( + query="weather", + runtime=ToolRuntime({"discovered_tools": state}, tools=tools), + ) + assert repeated.update["discovered_tools"] == ["Weather_Forecast"] + assert "already available" in repeated.update["messages"][0].content + for query in ("not-a-capability", " "): + unmatched = search_tool.func( + query=query, + runtime=ToolRuntime({"discovered_tools": state}, tools=tools), + ) + assert "discovered_tools" not in unmatched.update + + async def exercise_async() -> list[str]: + async def handler(request: ModelRequest) -> ModelResponse: + captured.append(request) + return ModelResponse() + + await middleware.awrap_model_call( + ModelRequest(tools, {"discovered_tools": state}), + handler, + ) + return _visible_names(captured[-1]) + + async_names = asyncio.run(exercise_async()) + assert async_names == _visible_names(cumulative) + return { + "initial": _visible_names(captured[0]), + "discovered": state, + "async": async_names, + "max_query_length": module.MAX_SEARCH_QUERY_LENGTH, + "provider_native_preserved": captured[0].tools[-1] is provider_native, + } + + +def _run_overflow(module: types.ModuleType) -> dict[str, Any]: + middleware = module.ProgressiveToolDisclosureMiddleware() + description = "bulk capability " + ("🧰" * 1024) + bulk_tools = [BaseTool(f"bulk_{index:04d}", description) for index in range(1000)] + provider_native = {"type": "provider-native", "opaque": object()} + tools: list[Any] = [ + *bulk_tools, + BaseTool("ls", "List files"), + middleware.tools[0], + provider_native, + ] + search_tool = middleware.tools[0] + + first = search_tool.func( + query="bulk capability", runtime=ToolRuntime({}, tools=tools) + ) + reversed_result = search_tool.func( + query="bulk capability", runtime=ToolRuntime({}, tools=list(reversed(tools))) + ) + discovered = first.update["discovered_tools"] + expected_page = [f"bulk_{index:04d}" for index in range(module.MAX_SEARCH_RESULTS)] + content = first.update["messages"][0].content + assert discovered == expected_page + assert reversed_result.update["discovered_tools"] == expected_page + assert reversed_result.update["messages"][0].content == content + assert len(content.encode("utf-8")) <= module.MAX_SEARCH_OUTPUT_BYTES + assert "Search output truncated" in content + assert ( + len(module._bounded_description(description)) + == module.MAX_SEARCH_DESCRIPTION_CHARS + ) + first_state = module._merge_discovered_tools(None, discovered) + first_visible = middleware._prepare_request( + ModelRequest(tools, {"discovered_tools": first_state}) + ) + assert set(discovered).issubset(set(_visible_names(first_visible))) + + all_names = [tool.name for tool in bulk_tools] + bounded_state = module._merge_discovered_tools(None, all_names) + assert bounded_state == all_names[: module.MAX_DISCOVERED_TOOLS] + assert ( + module._discovered_state_bytes(bounded_state) + <= module.MAX_DISCOVERED_STATE_BYTES + ) + assert ( + module._merge_discovered_tools(None, list(reversed(all_names))) == bounded_state + ) + assert ( + module._merge_discovered_tools(all_names[:40], all_names[40:100]) + == module._merge_discovered_tools(all_names[40:100], all_names[:40]) + == bounded_state + ) + long_names = [f"long_{index:04d}_" + ("🧰" * 25) for index in range(64)] + long_state = module._merge_discovered_tools(None, long_names) + assert len(long_state) == module.MAX_DISCOVERED_TOOLS + assert ( + module._discovered_state_bytes(long_state) <= module.MAX_DISCOVERED_STATE_BYTES + ) + overlong_name = "🧰" * ((module.MAX_DISCOVERED_TOOL_NAME_BYTES // 4) + 1) + assert module._merge_discovered_tools(None, [overlong_name]) == [] + part_a, part_b, part_c = all_names[:50], all_names[50:100], all_names[100:150] + assert ( + module._merge_discovered_tools( + module._merge_discovered_tools(part_a, part_b), part_c + ) + == module._merge_discovered_tools( + part_a, module._merge_discovered_tools(part_b, part_c) + ) + == module._merge_discovered_tools(None, [*part_a, *part_b, *part_c]) + ) + varying_a = [f"b{index:02d}_" + ("x" * (index % 80)) for index in range(64)] + varying_b = ["z"] + varying_c = ["a"] + assert ( + module._merge_discovered_tools( + module._merge_discovered_tools(varying_a, varying_b), varying_c + ) + == module._merge_discovered_tools( + varying_a, module._merge_discovered_tools(varying_b, varying_c) + ) + == module._merge_discovered_tools(None, [*varying_a, *varying_b, *varying_c]) + ) + + prepared = middleware._prepare_request( + ModelRequest(tools, {"discovered_tools": all_names}) + ) + visible_schemas = [ + tool + for tool in prepared.tools + if isinstance(tool, BaseTool) and tool.name.startswith("bulk_") + ] + assert 0 < len(visible_schemas) < module.MAX_DISCOVERED_TOOLS + assert ( + sum(module._serialized_tool_schema_bytes(tool) or 0 for tool in visible_schemas) + <= module.MAX_VISIBLE_DISCOVERED_SCHEMA_BYTES + ) + reversed_prepared = middleware._prepare_request( + ModelRequest(list(reversed(tools)), {"discovered_tools": all_names}) + ) + assert sorted(_visible_names(prepared)) == sorted(_visible_names(reversed_prepared)) + assert prepared.tools[-1] is provider_native + initial = middleware._prepare_request(ModelRequest(tools, {})) + assert initial.tools[-1] is provider_native + + state_blocked = search_tool.func( + query="bulk_0999", + runtime=ToolRuntime( + {"discovered_tools": bounded_state}, + tools=tools, + ), + ) + assert "discovered_tools" not in state_blocked.update + assert ( + "thread discovery state is limited" + in state_blocked.update["messages"][0].content + ) + high_state = [f"z_current_{index:04d}" for index in range(64)] + earlier_state_tool = BaseTool("a_earlier", "earlier state candidate") + high_state_tools = [ + *[BaseTool(name, "existing") for name in high_state], + earlier_state_tool, + middleware.tools[0], + ] + earlier_state_blocked = search_tool.func( + query="a_earlier", + runtime=ToolRuntime( + {"discovered_tools": high_state}, + tools=high_state_tools, + ), + ) + assert "discovered_tools" not in earlier_state_blocked.update + assert ( + module._merge_discovered_tools( + high_state, earlier_state_blocked.update.get("discovered_tools") + ) + == high_state + ) + + schema_full_state = all_names[: len(visible_schemas)] + schema_blocked = search_tool.func( + query=all_names[len(visible_schemas)], + runtime=ToolRuntime( + {"discovered_tools": schema_full_state}, + tools=tools, + ), + ) + assert "discovered_tools" not in schema_blocked.update + assert ( + "discovered schemas are limited" in schema_blocked.update["messages"][0].content + ) + + earlier_schema = BaseTool("aaa_schema", description) + earlier_tools = [earlier_schema, *tools] + earlier_blocked = search_tool.func( + query="aaa_schema", + runtime=ToolRuntime( + {"discovered_tools": schema_full_state}, + tools=earlier_tools, + ), + ) + assert "discovered_tools" not in earlier_blocked.update + assert ( + "discovered schemas are limited" + in earlier_blocked.update["messages"][0].content + ) + assert set( + _visible_names( + middleware._prepare_request( + ModelRequest(earlier_tools, {"discovered_tools": schema_full_state}) + ) + ) + ) == set( + _visible_names( + middleware._prepare_request( + ModelRequest(tools, {"discovered_tools": schema_full_state}) + ) + ) + ) + + oversized_schema = BaseTool( + "oversized_schema", + "oversized capability", + { + "properties": { + "payload": {"const": "x" * module.MAX_SINGLE_TOOL_SCHEMA_BYTES} + }, + "type": "object", + }, + ) + overlong_tool = BaseTool(overlong_name, "overlong capability") + unserializable_schema = BaseTool( + "unserializable_schema", + "unserializable capability", + {"properties": {"payload": {"const": object()}}, "type": "object"}, + ) + ineligible_tools = [ + oversized_schema, + overlong_tool, + unserializable_schema, + middleware.tools[0], + provider_native, + ] + for query, name in ( + ("oversized capability", oversized_schema.name), + ("overlong capability", overlong_tool.name), + ("unserializable capability", unserializable_schema.name), + ): + omitted = search_tool.func( + query=query, + runtime=ToolRuntime({}, tools=ineligible_tools), + ) + assert "discovered_tools" not in omitted.update + assert "No hidden tools matched" in omitted.update["messages"][0].content + filtered = middleware._prepare_request( + ModelRequest(ineligible_tools, {"discovered_tools": [name]}) + ) + assert oversized_schema not in filtered.tools + assert overlong_tool not in filtered.tools + assert unserializable_schema not in filtered.tools + assert filtered.tools[-1] is provider_native + + oversized_core = BaseTool( + "ls", + "oversized core", + { + "properties": { + "payload": {"const": "x" * module.MAX_SINGLE_TOOL_SCHEMA_BYTES} + }, + "type": "object", + }, + ) + unserializable_core = BaseTool( + "read_file", + "unserializable core", + {"properties": {"payload": {"const": object()}}, "type": "object"}, + ) + core_request = middleware._prepare_request( + ModelRequest( + [oversized_core, unserializable_core, middleware.tools[0]], + {}, + ) + ) + assert core_request.tools[0] is oversized_core + assert core_request.tools[1] is unserializable_core + + duplicate_first = BaseTool("duplicate_probe", "first duplicate description") + duplicate_second = BaseTool("duplicate_probe", "second duplicate description") + duplicate_tools = [ + duplicate_first, + duplicate_second, + middleware.tools[0], + ] + duplicate_result = search_tool.func( + query="duplicate_probe", + runtime=ToolRuntime({}, tools=duplicate_tools), + ) + duplicate_content = duplicate_result.update["messages"][0].content + assert "first duplicate description" in duplicate_content + assert "second duplicate description" not in duplicate_content + duplicate_visible = middleware._prepare_request( + ModelRequest(duplicate_tools, {"discovered_tools": ["duplicate_probe"]}) + ) + assert duplicate_visible.tools[0] is duplicate_first + assert duplicate_second not in duplicate_visible.tools + + empty_base_tool = BaseTool("", "empty name") + empty_dict_tool = {"type": "function", "function": {"name": ""}} + empty_visible = middleware._prepare_request( + ModelRequest([empty_base_tool, empty_dict_tool, middleware.tools[0]], {}) + ) + assert empty_visible.tools[0] is empty_base_tool + assert empty_visible.tools[1] is empty_dict_tool + + concurrent_state = [f"base_{index:04d}" for index in range(63)] + concurrent_a = BaseTool("a_new", "concurrent capacity") + concurrent_z = BaseTool("z_new", "concurrent capacity") + concurrent_tools = [ + *[BaseTool(name, "existing") for name in concurrent_state], + concurrent_a, + concurrent_z, + middleware.tools[0], + ] + concurrent_results = [ + search_tool.func( + query=name, + runtime=ToolRuntime( + {"discovered_tools": concurrent_state}, + tools=concurrent_tools, + ), + ) + for name in ("a_new", "z_new") + ] + assert all( + "exposing" not in result.update["messages"][0].content + for result in concurrent_results + ) + concurrent_merged = module._merge_discovered_tools( + concurrent_results[0].update.get("discovered_tools"), + concurrent_results[1].update.get("discovered_tools"), + ) + concurrent_merged = module._merge_discovered_tools( + concurrent_state, concurrent_merged + ) + assert len(concurrent_merged) == module.MAX_DISCOVERED_TOOLS + concurrent_visible = middleware._prepare_request( + ModelRequest(concurrent_tools, {"discovered_tools": concurrent_merged}) + ) + assert set(_visible_names(concurrent_visible)).issuperset(concurrent_merged) + + return { + "core_schema_limits_exempt": True, + "description_chars": module.MAX_SEARCH_DESCRIPTION_CHARS, + "discovered_count": len(discovered), + "discovery_limit": module.MAX_DISCOVERED_TOOLS, + "discovery_name_bytes": module.MAX_DISCOVERED_TOOL_NAME_BYTES, + "discovery_state_bytes": module._discovered_state_bytes(long_state), + "discovery_state_bytes_limit": module.MAX_DISCOVERED_STATE_BYTES, + "duplicate_first_wins": duplicate_visible.tools[0] is duplicate_first, + "empty_names_preserved": empty_visible.tools[:2] + == [empty_base_tool, empty_dict_tool], + "long_state_count": len(long_state), + "output_bytes": len(content.encode("utf-8")), + "output_bytes_limit": module.MAX_SEARCH_OUTPUT_BYTES, + "oversized_schema_omitted": oversized_schema not in filtered.tools, + "provider_native_preserved": initial.tools[-1] is provider_native, + "result_limit": module.MAX_SEARCH_RESULTS, + "single_schema_bytes_limit": module.MAX_SINGLE_TOOL_SCHEMA_BYTES, + "state_count": len(bounded_state), + "search_to_request_consistent": set(discovered).issubset( + set(_visible_names(first_visible)) + ), + "reducer_associative": True, + "concurrent_response_bounded": True, + "sequential_visibility_monotonic": True, + "state_blocked": True, + "schema_blocked": True, + "visible_schema_bytes_limit": module.MAX_VISIBLE_DISCOVERED_SCHEMA_BYTES, + "visible_schema_count": len(visible_schemas), + } + + +def _run_persistence(module: types.ModuleType) -> dict[str, Any]: + first, tools, weather, _database = _fixture(module) + first._prepare_request(ModelRequest(tools, {"messages": ["before compaction"]})) + command = first.tools[0].func(query="weather", runtime=ToolRuntime({}, tools=tools)) + checkpoint = { + "messages": ["compacted summary"], + "discovered_tools": command.update["discovered_tools"], + } + + resumed = module.ProgressiveToolDisclosureMiddleware() + resumed_tools = [ + tool for tool in tools if getattr(tool, "name", None) != "search_tools" + ] + resumed_tools.insert(3, resumed.tools[0]) + visible = resumed._prepare_request(ModelRequest(resumed_tools, checkpoint)) + assert weather in visible.tools + unknown = resumed._prepare_request( + ModelRequest(resumed_tools, {"discovered_tools": ["missing_tool"]}) + ) + assert weather not in unknown.tools + assert "discovered_tools" in module.ProgressiveToolDisclosureState.__annotations__ + return {"resumed": _visible_names(visible), "unknown": _visible_names(unknown)} + + +def _run_isolation(module: types.ModuleType) -> dict[str, Any]: + middleware, tools, weather, _database = _fixture(module) + thread_a = middleware._prepare_request( + ModelRequest(tools, {"discovered_tools": ["Weather_Forecast"]}) + ) + thread_b = middleware._prepare_request(ModelRequest(tools, {})) + assert weather in thread_a.tools + assert weather not in thread_b.tools + subagent = module.ProgressiveToolDisclosureMiddleware() + assert subagent is not middleware + assert subagent.tools[0] is not middleware.tools[0] + return {"thread_a": _visible_names(thread_a), "thread_b": _visible_names(thread_b)} + + +def _run_namespace(module: types.ModuleType) -> dict[str, Any]: + class Info: + def __init__(self, name: str, tools: tuple[BaseTool, ...]) -> None: + self.name = name + self.tools = tools + + def collision( + tools: list[BaseTool], mcp_server_info: list[Info] | None = None + ) -> str: + try: + module.assert_unique_callable_tool_names(tools, mcp_server_info) + except RuntimeError as exc: + return str(exc) + raise AssertionError("ambiguous callable tool namespace was accepted") + + duplicate_regular = [ + BaseTool("shared_regular", "first implementation"), + BaseTool("shared_regular", "second implementation"), + ] + regular_mcp = [ + BaseTool("mcp_echo", "regular implementation"), + BaseTool("mcp_echo", "MCP implementation"), + ] + cross_mcp = [ + BaseTool("alpha_beta_echo", "first MCP implementation"), + BaseTool("alpha_beta_echo", "second MCP implementation"), + ] + safe_mcp = BaseTool("safe_echo", "one loaded MCP implementation") + module.assert_unique_callable_tool_names( + [safe_mcp], [Info("safe", (safe_mcp,))] + ) + + return { + "cross_mcp": collision( + cross_mcp, + [ + Info("alpha", (cross_mcp[0],)), + Info("alpha_beta", (cross_mcp[1],)), + ], + ), + "regular_mcp": collision( + regular_mcp, [Info("mcp", (regular_mcp[1],))] + ), + "regular_regular": collision(duplicate_regular), + "reserved_mcp": collision( + [BaseTool("search_tools")], + [Info("search", (BaseTool("search_tools"),))], + ), + "reserved_regular": collision([BaseTool("read_file")]), + "safe_mcp": True, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "scenario", + choices=("behavior", "overflow", "persistence", "isolation", "namespace"), + ) + parser.add_argument("module", type=Path) + args = parser.parse_args() + module = _load_module(args.module) + runners = { + "behavior": _run_behavior, + "overflow": _run_overflow, + "persistence": _run_persistence, + "isolation": _run_isolation, + "namespace": _run_namespace, + } + print(json.dumps(runners[args.scenario](module), sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index cf47c257453..4e968f79c74 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -36,6 +36,12 @@ const BASE_ENV: Record = { NEMOCLAW_WECHAT_CONFIG_B64: encodeJson({}), }; +const HERMES_STRUCTURED_TOOL_SEARCH = { + enabled: "on", + search_default_limit: 5, + max_search_limit: 20, +}; + const REMOTE_PLATFORM_TOOLSETS = [ "web", "browser", @@ -159,6 +165,10 @@ function copyConfigGeneratorFixture(fixtureRoot: string): string { path.join(fixtureRoot, "src", "lib", "messaging"), { recursive: true }, ); + fs.copyFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "tool-disclosure.ts"), + path.join(fixtureRoot, "src", "lib", "tool-disclosure.ts"), + ); return fixtureScriptPath; } @@ -235,6 +245,42 @@ describe("agents/hermes/generate-config.ts", () => { testTimeout(15_000), ); + it("emits the pinned Hermes native structured Tool Search contract", () => { + const { config } = runConfigScript(); + const configYaml = fs.readFileSync(path.join(tmpDir, ".hermes", "config.yaml"), "utf-8"); + + expect(config.tools?.tool_search).toEqual(HERMES_STRUCTURED_TOOL_SEARCH); + expect(config.tools?.toolSearch).toBeUndefined(); + expect(config.tools?.tool_search?.mode).toBeUndefined(); + expect(configYaml).toContain( + [ + "tools:", + " tool_search:", + " enabled: on", + " search_default_limit: 5", + " max_search_limit: 20", + ].join("\n"), + ); + expect(configYaml).not.toContain("toolSearch:"); + expect(configYaml).not.toContain("mode: tools"); + expect(configYaml).not.toContain("searchDefaultLimit:"); + expect(configYaml).not.toContain("maxSearchLimit:"); + }); + + it("restores direct tool exposure through the agent-neutral override", () => { + const { config } = runConfigScript({ NEMOCLAW_TOOL_DISCLOSURE: "direct" }); + expect(config.tools?.tool_search).toEqual({ + ...HERMES_STRUCTURED_TOOL_SEARCH, + enabled: "off", + }); + }); + + it("rejects unknown tool-disclosure modes", () => { + const result = runConfigScriptRaw({ NEMOCLAW_TOOL_DISCLOSURE: "sometimes" }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("NEMOCLAW_TOOL_DISCLOSURE must be progressive or direct"); + }); + it("generates API server config without messaging platform token blocks", () => { const { config, envFile } = runConfigScript(); @@ -244,6 +290,7 @@ describe("agents/hermes/generate-config.ts", () => { tool_progress: "all", interim_assistant_messages: true, }); + expect(config.tools?.tool_search).toEqual(HERMES_STRUCTURED_TOOL_SEARCH); expect(config.curator).toMatchObject({ enabled: true, interval_hours: 168, diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 3d1da6e55b2..aee01eb3ff7 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -48,6 +48,7 @@ const BASE_ENV: Record = { NEMOCLAW_REASONING: "false", NEMOCLAW_AGENT_TIMEOUT: "600", }; +const STRUCTURED_TOOL_SEARCH = { mode: "tools", searchDefaultLimit: 8, maxSearchLimit: 20 }; let tmpDir: string; @@ -782,9 +783,9 @@ describe("generate-openclaw-config.mts: config generation", () => { }); }); - it("enables native OpenClaw Tool Search by default", () => { + it("enables structured OpenClaw Tool Search by default", () => { const config = runConfigScript(); - expect(config.tools?.toolSearch).toBe(true); + expect(config.tools?.toolSearch).toEqual(STRUCTURED_TOOL_SEARCH); }); it("enables keyless web_fetch through the trusted env proxy by default", () => { @@ -798,7 +799,7 @@ describe("generate-openclaw-config.mts: config generation", () => { it("defaults enabled web search to Brave using the current plugin schema", () => { const config = runConfigScript({ NEMOCLAW_WEB_SEARCH_ENABLED: "1" }); - expect(config.tools?.toolSearch).toBe(true); + expect(config.tools?.toolSearch).toEqual(STRUCTURED_TOOL_SEARCH); // #5266: apiKey lives under plugins.entries.brave.config (not inline on // tools.web.search) so build-time `openclaw plugins install` validates. expect(config.tools?.web?.search).toEqual({ enabled: true, provider: "brave" }); @@ -811,7 +812,7 @@ describe("generate-openclaw-config.mts: config generation", () => { it("omits web search when env is not set", () => { const config = runConfigScript(); - expect(config.tools?.toolSearch).toBe(true); + expect(config.tools?.toolSearch).toEqual(STRUCTURED_TOOL_SEARCH); expect(config.tools?.web?.search).toBeUndefined(); }); @@ -1371,15 +1372,16 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(providerConfig.models[0].compat).toEqual({ supportsStore: false }); expect(config.plugins.entries["nemoclaw-kimi-inference-compat"]).toBeUndefined(); expect(config.plugins.load).toBeUndefined(); - expect(config.tools?.toolSearch).toBe(true); + expect(config.tools?.toolSearch).toEqual(STRUCTURED_TOOL_SEARCH); } }, 20_000); - - // #4780: Nemotron can generate invalid JS for OpenClaw's native - // `tool_search_code`. The Super and Ultra managed-inference manifests disable - // it so both models use the structured tool-calling surface they handle. - it("disables native OpenClaw Tool Search for Nemotron managed inference (#4780)", () => { - for (const model of ["nvidia/nemotron-3-super-120b-a12b", "nvidia/nvidia/nemotron-3-ultra"]) { + // #4780: keep false safeguards until live search can replace the direct-tool fallback. + it("keeps Tool Search disabled for Nemotron managed inference (#4780)", () => { + for (const model of [ + "nvidia/nemotron-3-super-120b-a12b", + "nvidia/nemotron-3-ultra-550b-a55b", + "nvidia/nvidia/nemotron-3-ultra", + ]) { const config = runConfigScript({ NEMOCLAW_MODEL: model, NEMOCLAW_PROVIDER_KEY: "inference", @@ -1387,12 +1389,10 @@ describe("generate-openclaw-config.mts: config generation", () => { NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", NEMOCLAW_INFERENCE_API: "openai-completions", }); - expect(config.tools?.toolSearch, model).toBe(false); } }); - - it("does not disable native Tool Search for Nemotron on non-matching routes (#4780)", () => { + it("keeps structured Tool Search for non-matching Nemotron routes (#4780)", () => { const cases = [ { NEMOCLAW_MODEL: "nvidia/nemotron-3-nano:30b" }, { NEMOCLAW_PROVIDER_KEY: "nvidia" }, @@ -1410,7 +1410,7 @@ describe("generate-openclaw-config.mts: config generation", () => { ...envCase, }); - expect(config.tools?.toolSearch).toBe(true); + expect(config.tools?.toolSearch).toEqual(STRUCTURED_TOOL_SEARCH); } }, 20_000); @@ -1653,13 +1653,13 @@ describe("generate-openclaw-config.mts: config generation", () => { agent: "openclaw", description: "Invalid tool override", match: { modelIds: ["test-model"] }, - effects: { openclawTools: { toolSearch: "false" } }, + effects: { openclawTools: { toolSearch: { mode: "tools" } } }, }, ); expectBuildConfigError( { NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: badToolRegistryDir }, - "effects.openclawTools.toolSearch must be a boolean", + "effects.openclawTools.toolSearch must be a boolean override", ); fs.rmSync(path.join(blueprintDir, "model-specific-setup", "openclaw", "bad-tool-effect.json")); diff --git a/test/generate-openclaw-tool-disclosure-config.test.ts b/test/generate-openclaw-tool-disclosure-config.test.ts new file mode 100644 index 00000000000..08b543784f3 --- /dev/null +++ b/test/generate-openclaw-tool-disclosure-config.test.ts @@ -0,0 +1,84 @@ +// 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 { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildConfig } from "../scripts/generate-openclaw-config.mts"; + +const BASE_ENV: Record = { + NEMOCLAW_MODEL: "test-model", + NEMOCLAW_PROVIDER_KEY: "test-provider", + NEMOCLAW_PRIMARY_MODEL_REF: "test-ref", + CHAT_UI_URL: "http://127.0.0.1:18789", + NEMOCLAW_INFERENCE_BASE_URL: "http://localhost:8080", + NEMOCLAW_INFERENCE_API: "openai", + NEMOCLAW_INFERENCE_COMPAT_B64: Buffer.from("{}").toString("base64"), + NEMOCLAW_PROXY_HOST: "10.200.0.1", + NEMOCLAW_PROXY_PORT: "3128", + NEMOCLAW_CONTEXT_WINDOW: "131072", + NEMOCLAW_MAX_TOKENS: "4096", + NEMOCLAW_REASONING: "false", + NEMOCLAW_AGENT_TIMEOUT: "600", +}; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-tool-disclosure-config-test-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("generate-openclaw-config.mts: tool disclosure", () => { + it("uses only OpenClaw's camel-case structured Tool Search key by default", () => { + const config = buildConfig(BASE_ENV); + + expect(config.tools?.toolSearch).toEqual({ + mode: "tools", + searchDefaultLimit: 8, + maxSearchLimit: 20, + }); + expect(config.tools?.tool_search).toBeUndefined(); + }); + + it("restores direct tool exposure through the agent-neutral override", () => { + const config = buildConfig({ ...BASE_ENV, NEMOCLAW_TOOL_DISCLOSURE: "direct" }); + + expect(config.tools?.toolSearch).toBe(false); + }); + + it("rejects unknown tool-disclosure modes", () => { + expect(() => buildConfig({ ...BASE_ENV, NEMOCLAW_TOOL_DISCLOSURE: "sometimes" })).toThrow( + "NEMOCLAW_TOOL_DISCLOSURE must be progressive or direct", + ); + }); + + it("does not let a model setup re-enable Tool Search over a direct request", () => { + const registryDir = path.join(tmpDir, "model-specific-setup"); + const manifestPath = path.join(registryDir, "openclaw", "tool-search-on.json"); + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync( + manifestPath, + JSON.stringify({ + id: "tool-search-on", + agent: "openclaw", + description: "Legacy code-mode override", + match: { modelIds: ["test-model"] }, + effects: { openclawTools: { toolSearch: true } }, + }), + ); + + const config = buildConfig({ + ...BASE_ENV, + NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR: registryDir, + NEMOCLAW_TOOL_DISCLOSURE: "direct", + }); + + expect(config.tools?.toolSearch).toBe(false); + }); +}); diff --git a/test/helpers/rebuild-flow-lifecycle-cases.ts b/test/helpers/rebuild-flow-lifecycle-cases.ts index 35143511be4..913db6d36e6 100644 --- a/test/helpers/rebuild-flow-lifecycle-cases.ts +++ b/test/helpers/rebuild-flow-lifecycle-cases.ts @@ -103,6 +103,41 @@ export function registerRebuildFlowLifecycleTests(): void { ); }); + it("changes tool disclosure through the MCP-preserving rebuild transaction", async () => { + const mcpEntry = { + server: "github", + providerName: "nemoclaw-mcp-alpha-github", + }; + const harness = createRebuildFlowHarness({ + sandboxEntry: { + toolDisclosure: "progressive", + mcp: { bridges: { github: mcpEntry } }, + }, + mcpPreparation: { + entries: [mcpEntry], + detachedProviderEntries: [mcpEntry], + scrubbedAdapterEntries: [mcpEntry], + }, + }); + + await expect( + harness.rebuildSandbox( + "alpha", + { yes: true, toolDisclosure: "direct" }, + { throwOnError: true }, + ), + ).resolves.toBeUndefined(); + + expect(harness.onboardSpy).toHaveBeenCalledWith( + expect.objectContaining({ toolDisclosure: "direct" }), + ); + expect(harness.session.toolDisclosure).toBe("direct"); + expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]); + for (const [, update] of harness.registryUpdateSpy.mock.calls) { + expect(update).not.toHaveProperty("toolDisclosure"); + } + }); + it("relocks as absent when registry cleanup throws after confirmed delete", async () => { const harness = createRebuildFlowHarness({ removeSandboxRegistryEntry: () => { diff --git a/test/helpers/rebuild-flow-recovery-cases.ts b/test/helpers/rebuild-flow-recovery-cases.ts index 070d7ddd999..062aa822a5a 100644 --- a/test/helpers/rebuild-flow-recovery-cases.ts +++ b/test/helpers/rebuild-flow-recovery-cases.ts @@ -182,6 +182,7 @@ export function registerRebuildFlowRecoveryTests(): void { const mcpEntry = { server: "github", providerName: "nemoclaw-mcp-alpha-github" }; const harness = createRebuildFlowHarness({ defaultSandbox: "alpha", + sandboxEntry: { toolDisclosure: "progressive" }, mcpPreparation: { entries: [mcpEntry], detachedProviderEntries: [mcpEntry], @@ -193,15 +194,54 @@ export function registerRebuildFlowRecoveryTests(): void { }); await expect( - harness.rebuildSandbox("alpha", ["--yes"], { - throwOnError: true, - recoveryManifest: makePreparedRecoveryManifest(), - }), + harness.rebuildSandbox( + "alpha", + { yes: true, toolDisclosure: "direct" }, + { + throwOnError: true, + recoveryManifest: makePreparedRecoveryManifest(), + }, + ), ).rejects.toThrow("Recreate failed"); expect(harness.restoreSandboxEntrySpy.mock.calls).toEqual([ - [expect.objectContaining({ name: "alpha" }), { reclaimDefault: "alpha" }], + [ + expect.objectContaining({ name: "alpha", toolDisclosure: "progressive" }), + { reclaimDefault: "alpha" }, + ], ]); + expect(harness.errorSpy).toHaveBeenCalledWith( + expect.stringContaining("rebuild --yes --tool-disclosure direct"), + ); + }); + + it("keeps the requested disclosure mode in a zero-MCP prepared-recovery retry", async () => { + const harness = createRebuildFlowHarness({ + defaultSandbox: "alpha", + sandboxEntry: { toolDisclosure: "progressive" }, + onboard: () => { + throw new Error("recreate failed"); + }, + }); + + await expect( + harness.rebuildSandbox( + "alpha", + { yes: true, toolDisclosure: "direct" }, + { + throwOnError: true, + recoveryManifest: makePreparedRecoveryManifest(), + }, + ), + ).rejects.toThrow("Recreate failed"); + + expect(harness.restoreSandboxEntrySpy).toHaveBeenCalledWith( + expect.objectContaining({ name: "alpha", toolDisclosure: "progressive" }), + { reclaimDefault: "alpha" }, + ); + expect(harness.errorSpy).toHaveBeenCalledWith( + expect.stringContaining("onboard --resume --tool-disclosure direct"), + ); }); it("blocks installer recovery when MCP post-restore verification is incomplete", async () => { diff --git a/test/helpers/rebuild-flow-target-image-cases.ts b/test/helpers/rebuild-flow-target-image-cases.ts index dd828484871..0829855e8a8 100644 --- a/test/helpers/rebuild-flow-target-image-cases.ts +++ b/test/helpers/rebuild-flow-target-image-cases.ts @@ -5,7 +5,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { createBuildContextVerifier } from "../../src/lib/actions/sandbox/rebuild-prepared-image-context"; +import { fingerprintBuildContext } from "../../src/lib/adapters/fs/build-context-fingerprint"; import { createRebuildFlowHarness, installRebuildFlowTestHooks, @@ -13,6 +15,70 @@ import { snapshotEnv, } from "./rebuild-flow-test-harness"; +type RetainedContextMutationPaths = { + preparedDir: string; + preparedDockerfile: string; + replacementDir: string; + movedPreparedDir: string; +}; + +type RetainedContextMutation = { + label: string; + arrange(paths: RetainedContextMutationPaths): void; + mutate(paths: RetainedContextMutationPaths): void; +}; + +const FIXED_CONTEXT_TIME = new Date("2026-01-01T00:00:00.000Z"); +const retainedContextMetadataMutations: RetainedContextMutation[] = [ + { + label: "file special bits change", + arrange: ({ preparedDockerfile }) => fs.chmodSync(preparedDockerfile, 0o755), + mutate: ({ preparedDockerfile }) => fs.chmodSync(preparedDockerfile, 0o4755), + }, + { + label: "independent files become hardlinks", + arrange: ({ preparedDir }) => { + const first = path.join(preparedDir, "first.txt"); + const second = path.join(preparedDir, "second.txt"); + fs.writeFileSync(first, "identical\n"); + fs.writeFileSync(second, "identical\n"); + fs.utimesSync(first, FIXED_CONTEXT_TIME, FIXED_CONTEXT_TIME); + fs.utimesSync(second, FIXED_CONTEXT_TIME, FIXED_CONTEXT_TIME); + fs.utimesSync(preparedDir, FIXED_CONTEXT_TIME, FIXED_CONTEXT_TIME); + }, + mutate: ({ preparedDir }) => { + const first = path.join(preparedDir, "first.txt"); + const second = path.join(preparedDir, "second.txt"); + fs.unlinkSync(second); + fs.linkSync(first, second); + fs.utimesSync(preparedDir, FIXED_CONTEXT_TIME, FIXED_CONTEXT_TIME); + }, + }, + { + label: "a file mtime alone changes", + arrange: ({ preparedDockerfile }) => + fs.utimesSync(preparedDockerfile, FIXED_CONTEXT_TIME, FIXED_CONTEXT_TIME), + mutate: ({ preparedDockerfile }) => + fs.utimesSync( + preparedDockerfile, + FIXED_CONTEXT_TIME, + new Date(FIXED_CONTEXT_TIME.getTime() + 1_000), + ), + }, + { + label: "the context root is retargeted through a symlink", + arrange: ({ preparedDockerfile, replacementDir }) => { + fs.mkdirSync(replacementDir); + fs.copyFileSync(preparedDockerfile, path.join(replacementDir, "Dockerfile")); + }, + mutate: ({ preparedDir, replacementDir, movedPreparedDir }) => { + fs.renameSync(preparedDir, movedPreparedDir); + fs.symlinkSync(replacementDir, preparedDir, "dir"); + fs.writeFileSync(path.join(replacementDir, "Dockerfile"), "FROM changed-target\n"); + }, + }, +]; + export function registerRebuildFlowTargetImageTests(): void { describe("rebuildSandbox flow: target image", () => { installRebuildFlowTestHooks(); @@ -43,6 +109,182 @@ export function registerRebuildFlowTargetImageTests(): void { expect(harness.onboardSpy).not.toHaveBeenCalled(); }); + it("recreates from the retained context after the source Dockerfile symlink changes", async () => { + const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-source-link-")); + const preparedDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-prepared-")); + const sourceDockerfile = path.join(sourceDir, "Dockerfile"); + const preparedDockerfile = path.join(preparedDir, "Dockerfile"); + fs.writeFileSync(path.join(sourceDir, "Dockerfile.safe"), "FROM scratch\n# safe\n"); + fs.writeFileSync(path.join(sourceDir, "Dockerfile.changed"), "FROM scratch\n# changed\n"); + fs.symlinkSync("Dockerfile.safe", sourceDockerfile); + fs.writeFileSync(preparedDockerfile, "FROM scratch\n# safe\n"); + const cleanupBuildCtx = vi.fn(() => { + fs.rmSync(preparedDir, { recursive: true, force: true }); + return true; + }); + const prepared = { + buildCtx: preparedDir, + stagedDockerfile: preparedDockerfile, + cleanupBuildCtx, + buildId: "source-link-prepared", + origin: "custom" as const, + contextFingerprint: fingerprintBuildContext(preparedDir), + verifyBuildCtx: createBuildContextVerifier( + preparedDir, + fingerprintBuildContext(preparedDir), + ), + rebuildTarget: { agentName: null, fromDockerfile: sourceDockerfile }, + }; + const harness = createRebuildFlowHarness({ + sandboxEntry: { fromDockerfile: sourceDockerfile }, + customImagePreflight: { + ok: true, + imageTag: "nemoclaw-rebuild-preflight:source-link", + prepared, + }, + beforeBackup: () => { + fs.unlinkSync(sourceDockerfile); + fs.symlinkSync("Dockerfile.changed", sourceDockerfile); + }, + onboard: (_session, options) => { + expect(options.fromDockerfile).toBe(sourceDockerfile); + expect(options.preparedImageRebuild?.buildContext).toBe(prepared); + expect(fs.readFileSync(sourceDockerfile, "utf8")).toContain("# changed"); + expect(fs.readFileSync(preparedDockerfile, "utf8")).toContain("# safe"); + }, + }); + + try { + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).resolves.toBeUndefined(); + + expect(harness.onboardSpy).toHaveBeenCalledWith( + expect.objectContaining({ + fromDockerfile: sourceDockerfile, + preparedImageRebuild: expect.objectContaining({ buildContext: prepared }), + }), + ); + expect(cleanupBuildCtx).toHaveBeenCalledOnce(); + } finally { + fs.rmSync(sourceDir, { recursive: true, force: true }); + fs.rmSync(preparedDir, { recursive: true, force: true }); + } + }); + + it("aborts before delete when the retained context changes after preflight", async () => { + const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-source-")); + const preparedDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-prepared-")); + const sourceDockerfile = path.join(sourceDir, "Dockerfile"); + const preparedDockerfile = path.join(preparedDir, "Dockerfile"); + fs.writeFileSync(sourceDockerfile, "FROM scratch\n# source\n"); + fs.writeFileSync(preparedDockerfile, "FROM scratch\n# prepared\n"); + const cleanupBuildCtx = vi.fn(() => { + fs.rmSync(preparedDir, { recursive: true, force: true }); + return true; + }); + const prepared = { + buildCtx: preparedDir, + stagedDockerfile: preparedDockerfile, + cleanupBuildCtx, + buildId: "mutated-prepared", + origin: "custom" as const, + contextFingerprint: fingerprintBuildContext(preparedDir), + verifyBuildCtx: createBuildContextVerifier( + preparedDir, + fingerprintBuildContext(preparedDir), + ), + rebuildTarget: { agentName: null, fromDockerfile: sourceDockerfile }, + }; + const harness = createRebuildFlowHarness({ + sandboxEntry: { fromDockerfile: sourceDockerfile }, + customImagePreflight: { + ok: true, + imageTag: "nemoclaw-rebuild-preflight:mutated", + prepared, + }, + beforeBackup: () => fs.writeFileSync(preparedDockerfile, "FROM scratch\n# changed\n"), + }); + + try { + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("Replacement sandbox image context changed before delete"); + + expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.anything(), + ); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + expect(cleanupBuildCtx).toHaveBeenCalledOnce(); + } finally { + fs.rmSync(sourceDir, { recursive: true, force: true }); + fs.rmSync(preparedDir, { recursive: true, force: true }); + } + }); + + it.runIf(process.platform !== "win32").each(retainedContextMetadataMutations)( + "aborts before delete when $label after preflight", + async ({ arrange, mutate, label }) => { + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-seal-")); + const sourceDir = path.join(testRoot, "source"); + const preparedDir = path.join(testRoot, "prepared"); + const replacementDir = path.join(testRoot, "replacement"); + const movedPreparedDir = path.join(testRoot, "prepared-moved"); + fs.mkdirSync(sourceDir); + fs.mkdirSync(preparedDir); + const sourceDockerfile = path.join(sourceDir, "Dockerfile"); + const preparedDockerfile = path.join(preparedDir, "Dockerfile"); + fs.writeFileSync(sourceDockerfile, "FROM scratch\n# source\n"); + fs.writeFileSync(preparedDockerfile, "FROM scratch\n# prepared\n"); + const mutationPaths = { + preparedDir, + preparedDockerfile, + replacementDir, + movedPreparedDir, + }; + arrange(mutationPaths); + const cleanupBuildCtx = vi.fn(() => { + fs.rmSync(preparedDir, { recursive: true, force: true }); + return true; + }); + const contextFingerprint = fingerprintBuildContext(preparedDir); + const prepared = { + buildCtx: preparedDir, + stagedDockerfile: preparedDockerfile, + cleanupBuildCtx, + buildId: `metadata-mutated-${label}`, + origin: "custom" as const, + contextFingerprint, + verifyBuildCtx: createBuildContextVerifier(preparedDir, contextFingerprint), + rebuildTarget: { agentName: null, fromDockerfile: sourceDockerfile }, + }; + const harness = createRebuildFlowHarness({ + sandboxEntry: { fromDockerfile: sourceDockerfile }, + customImagePreflight: { + ok: true, + imageTag: "nemoclaw-rebuild-preflight:metadata-mutated", + prepared, + }, + beforeBackup: () => mutate(mutationPaths), + }); + + try { + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("Replacement sandbox image context changed before delete"); + expect(harness.runOpenshellSpy).not.toHaveBeenCalledWith( + ["sandbox", "delete", "alpha"], + expect.anything(), + ); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + expect(cleanupBuildCtx).toHaveBeenCalledOnce(); + } finally { + fs.rmSync(testRoot, { recursive: true, force: true }); + } + }, + ); + it("rebuilds a known-remote target even when the session belongs to another sandbox (#5735)", async () => { const restoreEnv = snapshotEnv(["NVIDIA_INFERENCE_API_KEY"]); process.env.NVIDIA_INFERENCE_API_KEY = "nvapi-key"; // pass credential preflight diff --git a/test/helpers/rebuild-flow-test-harness.ts b/test/helpers/rebuild-flow-test-harness.ts index 911e33198ac..d6d08db2942 100644 --- a/test/helpers/rebuild-flow-test-harness.ts +++ b/test/helpers/rebuild-flow-test-harness.ts @@ -1,9 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, vi } from "vitest"; import { makePreparedRecoveryManifest } from "../../src/lib/actions/sandbox/rebuild-flow-test-fixtures"; +import type { RebuildRecreateOnboardOpts } from "../../src/lib/actions/sandbox/rebuild-gpu-opt-out"; import { createRebuildFlowSession, installTerminalStepFailureMock, @@ -20,6 +24,7 @@ const requireDist = createRequire( const rebuildModulePath = "./rebuild.js"; requireDist(rebuildModulePath); delete require.cache[requireDist.resolve(rebuildModulePath)]; +const harnessTempDirs: string[] = []; export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): RebuildFlowHarness { delete require.cache[requireDist.resolve(rebuildModulePath)]; @@ -46,6 +51,8 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const gatewayState = requireDist("./gateway-state.js"); const rebuildFlowHelpers = requireDist("./rebuild-flow-helpers.js"); const rebuildCustomImagePreflight = requireDist("./rebuild-custom-image-preflight.js"); + const rebuildPreparedImageContext = requireDist("./rebuild-prepared-image-context.js"); + const buildContextFingerprint = requireDist("../../adapters/fs/build-context-fingerprint.js"); const rebuildUsageNotice = requireDist("./rebuild-usage-notice.js"); const rebuildShields = requireDist("./rebuild-shields.js"); const nim = requireDist("../../inference/nim.js"); @@ -89,8 +96,40 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): const ensureTargetGatewaySpy = vi .spyOn(rebuildFlowHelpers, "ensureRebuildTargetGatewaySelected") .mockResolvedValue(true); + const preparedBuildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-flow-image-")); + harnessTempDirs.push(preparedBuildCtx); + const preparedDockerfile = path.join(preparedBuildCtx, "Dockerfile"); + fs.writeFileSync(preparedDockerfile, "FROM scratch\n"); + const rebuildAgent = + typeof overrides.sandboxEntry?.agent === "string" ? overrides.sandboxEntry.agent : null; + const fromDockerfile = + typeof overrides.sandboxEntry?.fromDockerfile === "string" + ? path.resolve(overrides.sandboxEntry.fromDockerfile) + : null; + const defaultImagePreflight = { + ok: true as const, + imageTag: "nemoclaw-rebuild-preflight:test", + prepared: { + buildCtx: preparedBuildCtx, + stagedDockerfile: preparedDockerfile, + cleanupBuildCtx: () => { + fs.rmSync(preparedBuildCtx, { recursive: true, force: true }); + return true; + }, + buildId: "rebuild-flow-prepared", + contextFingerprint: buildContextFingerprint.fingerprintBuildContext(preparedBuildCtx), + verifyBuildCtx: rebuildPreparedImageContext.createBuildContextVerifier( + preparedBuildCtx, + buildContextFingerprint.fingerprintBuildContext(preparedBuildCtx), + ), + rebuildTarget: { + agentName: rebuildAgent && rebuildAgent !== "openclaw" ? rebuildAgent : null, + fromDockerfile, + }, + }, + }; vi.spyOn(rebuildCustomImagePreflight, "preflightRebuildImage").mockResolvedValue( - overrides.customImagePreflight ?? { ok: true, imageTag: null }, + overrides.customImagePreflight ?? defaultImagePreflight, ); vi.spyOn(rebuildUsageNotice, "ensureRebuildUsageNoticeAccepted").mockResolvedValue(true); const warnUnpreservedUserManagedFilesSpy = vi @@ -177,18 +216,23 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): window.relocked = true; return true; }); - const backupSandboxStateSpy = vi.spyOn(sandboxState, "backupSandboxState").mockReturnValue({ - success: true, - backedUpDirs: ["workspace"], - backedUpFiles: ["user.md"], - failedDirs: [], - failedFiles: [], - manifest: { - backupPath: "/tmp/nemoclaw-rebuild-backup", - timestamp: "2026-06-01T00:00:00.000Z", - policyPresets: overrides.backupPolicyPresets ?? ["npm", "bad", "throw"], - }, - }); + const backupSandboxStateSpy = vi + .spyOn(sandboxState, "backupSandboxState") + .mockImplementation(() => { + overrides.beforeBackup?.(); + return { + success: true, + backedUpDirs: ["workspace"], + backedUpFiles: ["user.md"], + failedDirs: [], + failedFiles: [], + manifest: { + backupPath: "/tmp/nemoclaw-rebuild-backup", + timestamp: "2026-06-01T00:00:00.000Z", + policyPresets: overrides.backupPolicyPresets ?? ["npm", "bad", "throw"], + }, + }; + }); vi.spyOn(sandboxState, "validateRebuildRecoveryManifest").mockImplementation( (...args: unknown[]) => { const manifest = args[2] as Record; @@ -225,9 +269,12 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): .mockImplementation(overrides.removeSandboxRegistryEntry ?? (() => undefined)); vi.spyOn(nim, "stopNimContainer").mockImplementation(() => undefined); vi.spyOn(nim, "stopNimContainerByName").mockImplementation(() => undefined); - const onboardSpy = vi.spyOn(onboardMod, "onboard").mockImplementation(async () => { - await overrides.onboard?.(session); - }); + const onboardSpy = vi + .spyOn(onboardMod, "onboard") + .mockImplementation(async (...args: unknown[]) => { + const options = args[0] as RebuildRecreateOnboardOpts; + await overrides.onboard?.(session, options); + }); vi.spyOn(onboardMod, "preflightAuthoritativeRebuildTarget").mockResolvedValue(undefined); const ensureValidatedBraveSearchCredentialSpy = vi .spyOn(onboardMod, "ensureValidatedWebSearchCredential") @@ -327,6 +374,9 @@ export function installRebuildFlowTestHooks(): void { afterEach(() => { vi.restoreAllMocks(); delete require.cache[requireDist.resolve(rebuildModulePath)]; + for (const dir of harnessTempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } if (originalSandboxName === undefined) { delete process.env.NEMOCLAW_SANDBOX_NAME; } else { diff --git a/test/helpers/rebuild-flow-test-support.ts b/test/helpers/rebuild-flow-test-support.ts index 30968656e92..504e3068127 100644 --- a/test/helpers/rebuild-flow-test-support.ts +++ b/test/helpers/rebuild-flow-test-support.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { type MockInstance, vi } from "vitest"; +import type { RebuildImagePreflightResult } from "../../src/lib/actions/sandbox/rebuild-custom-image-preflight"; +import type { RebuildRecreateOnboardOpts } from "../../src/lib/actions/sandbox/rebuild-gpu-opt-out"; export type RebuildSandbox = typeof import("../../src/lib/actions/sandbox/rebuild")["rebuildSandbox"]; @@ -31,7 +33,11 @@ export type RebuildFlowOverrides = { overrideEnvVar: string | null; }; executeSandboxCommand?: () => { status: number; stdout: string; stderr: string } | null; - onboard?: (session: RebuildFlowSession) => Promise | void; + onboard?: ( + session: RebuildFlowSession, + options: RebuildRecreateOnboardOpts, + ) => Promise | void; + beforeBackup?: () => void; repairMutableConfigPerms?: () => | { applied: false; skipReason: "agent" | "locked" | "unreadable"; reason: string } | { applied: true; verified: boolean; errors: string[] }; @@ -73,7 +79,7 @@ export type RebuildFlowOverrides = { ensureValidatedWebSearchCredential?: () => Promise; hermesCredentialKeys?: string[] | null; hermesProviderExists?: boolean; - customImagePreflight?: { ok: true; imageTag: string | null } | { ok: false; detail: string }; + customImagePreflight?: RebuildImagePreflightResult; removeSandboxRegistryEntry?: () => void; clearShieldsState?: () => void; }; diff --git a/test/helpers/rebuild-managed-image-preflight-harness.ts b/test/helpers/rebuild-managed-image-preflight-harness.ts index ffc5f981cb4..54bc720ea9a 100644 --- a/test/helpers/rebuild-managed-image-preflight-harness.ts +++ b/test/helpers/rebuild-managed-image-preflight-harness.ts @@ -36,6 +36,7 @@ export function dcodeInput( provider: "compatible-endpoint", preferredInferenceApi: "openai-completions", compatibleEndpointReasoning: "false", + toolDisclosure: "progressive", webSearchConfig: null, sandboxGpuConfig: { mode: "0", @@ -49,7 +50,9 @@ export function dcodeInput( }; } -export async function createPreparedDcodeImageFixture() { +export async function createPreparedDcodeImageFixture( + overrides: Partial = {}, +) { const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-context-")); const buildCtx = path.join(testRoot, "context"); fs.mkdirSync(buildCtx); @@ -57,7 +60,10 @@ export async function createPreparedDcodeImageFixture() { const originalDockerfile = path.join(testRoot, "Dockerfile.original"); const replacementDockerfile = path.join(testRoot, "Dockerfile.replacement"); fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + const stableDockerfileTime = new Date("2026-01-01T00:00:00.000Z"); + fs.utimesSync(stagedDockerfile, stableDockerfileTime, stableDockerfileTime); fs.writeFileSync(replacementDockerfile, "FROM attacker-controlled\n"); + fs.utimesSync(buildCtx, stableDockerfileTime, stableDockerfileTime); const cleanupBuildCtx = vi.fn(() => { fs.rmSync(testRoot, { recursive: true, force: true }); return true; @@ -74,7 +80,7 @@ export async function createPreparedDcodeImageFixture() { })); const buildImage = vi.fn(() => ({ status: 0 }) as never); const removeImage = vi.fn(() => ({ status: 0 }) as never); - const result = await prepareManagedDcodeRebuildImage(dcodeInput(), { + const result = await prepareManagedDcodeRebuildImage(dcodeInput(overrides), { stageBuildContext, prepareDockerfilePatch, buildImage, @@ -87,6 +93,7 @@ export async function createPreparedDcodeImageFixture() { stagedDockerfile, originalDockerfile, replacementDockerfile, + stableDockerfileTime, cleanupBuildCtx, stageBuildContext, prepareDockerfilePatch, diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index 189ba7de80f..4154a45e996 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -1138,6 +1138,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { providerKey: "custom", upstreamProvider: "nemoclaw-inference", inferenceApi: "", + toolDisclosure: "progressive" as const, webSearchProvider: null, messagingCredentialPlaceholders: [], managedToolGateways: { brokerEnabled: false, presets: [] }, @@ -1184,6 +1185,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { providerKey: "custom", upstreamProvider: "nemoclaw-inference", inferenceApi: "", + toolDisclosure: "progressive" as const, webSearchProvider: null, messagingCredentialPlaceholders: [], managedToolGateways: { brokerEnabled: false, presets: [] }, diff --git a/test/langchain-deepagents-code-direct-module-patch.test.ts b/test/langchain-deepagents-code-direct-module-patch.test.ts index 6803533dda1..f14b4f79e86 100644 --- a/test/langchain-deepagents-code-direct-module-patch.test.ts +++ b/test/langchain-deepagents-code-direct-module-patch.test.ts @@ -9,6 +9,42 @@ import { describe, expect, it } from "vitest"; const agentDir = path.join(process.cwd(), "agents", "langchain-deepagents-code"); const patcher = path.join(agentDir, "patch-managed-deepagents-code.py"); +const progressiveDisclosureHarness = path.join( + process.cwd(), + "test", + "fixtures", + "deepagents-progressive-disclosure-harness.py", +); +const DARWIN_FCNTL_FIXTURE_MARKER = "# NemoClaw test-only Darwin fcntl seal constants."; + +function addDarwinFcntlSealConstants( + helper: string, + platform: NodeJS.Platform = process.platform, +): string { + const shouldPatch = platform === "darwin" && !helper.includes(DARWIN_FCNTL_FIXTURE_MARKER); + const patched = helper.replace( + "import fcntl\n", + `import fcntl + +${DARWIN_FCNTL_FIXTURE_MARKER} +for _name, _value in ( + ("F_ADD_SEALS", 1033), + ("F_GET_SEALS", 1034), + ("F_SEAL_SEAL", 0x0001), + ("F_SEAL_SHRINK", 0x0002), + ("F_SEAL_GROW", 0x0004), + ("F_SEAL_WRITE", 0x0008), +): + if not hasattr(fcntl, _name): + setattr(fcntl, _name, _value) +`, + ); + expect( + !shouldPatch || patched !== helper, + "Darwin fcntl seal shim injection point not found in helper module", + ).toBe(true); + return shouldPatch ? patched : helper; +} function writeFixtureFile(root: string, relativePath: string, content: string): void { const target = path.join(root, relativePath); @@ -556,8 +592,7 @@ function patchFixture(tempDir: string): void { }); const managedBaseUrlFile = path.join(tempDir, "managed-inference-base-url"); const helperPath = path.join(tempDir, "deepagents_code", "_nemoclaw_managed.py"); - const helper = fs - .readFileSync(helperPath, "utf8") + const helper = addDarwinFcntlSealConstants(fs.readFileSync(helperPath, "utf8")) .replace( '"/usr/local/share/nemoclaw/dcode-inference-base-url"', JSON.stringify(managedBaseUrlFile), @@ -567,6 +602,12 @@ function patchFixture(tempDir: string): void { } describe("LangChain Deep Agents Code managed package patch", () => { + it("fails fast when the Darwin fcntl seal injection anchor is missing", () => { + expect(() => addDarwinFcntlSealConstants("from pathlib import Path\n", "darwin")).toThrow( + "Darwin fcntl seal shim injection point not found in helper module", + ); + }); + it("patches every 0.1.30 mutation and credential boundary idempotently", () => { const tempDir = createPackageFixture(); patchFixture(tempDir); @@ -761,8 +802,9 @@ 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])", - "snapshot = managed.managed_mcp_config_path()", - "print(managed.managed_mcp_config_bytes(snapshot).decode() if snapshot else 'absent', end='')", + "snapshot = managed.managed_mcp_config_path() if sys.platform == 'linux' else None", + "canonical = managed.managed_mcp_config_bytes(snapshot) if snapshot else managed._canonicalize_managed_mcp_config(managed._read_managed_mcp_config() or b'')", + "print(canonical.decode() if canonical else 'absent', end='')", ].join("; "), configPath, ], @@ -900,29 +942,31 @@ describe("LangChain Deep Agents Code managed package patch", () => { 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", + it.runIf(process.platform === "linux")( + "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 }); + }; + for (const snapshotKind of ["sealed-memfd", "anonymous-otmpfile"] as const) { + fs.writeFileSync(configPath, `${JSON.stringify(managedConfig)}\n`, { mode: 0o600 }); - const result = spawnSync( - "python3", - [ - "-c", - ` + const result = spawnSync( + "python3", + [ + "-c", + ` import asyncio import errno import fcntl @@ -1038,28 +1082,29 @@ print(json.dumps({ "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"); - } - }); + 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(); @@ -1082,9 +1127,20 @@ print(json.dumps({ ); const validation = ` import asyncio +import importlib.util import os +import sys from pathlib import Path +spec = importlib.util.spec_from_file_location( + "progressive_disclosure_harness", + ${JSON.stringify(progressiveDisclosureHarness)}, +) +assert spec is not None and spec.loader is not None +progressive_disclosure_harness = importlib.util.module_from_spec(spec) +spec.loader.exec_module(progressive_disclosure_harness) +progressive_disclosure_harness._install_stubs() + from deepagents_code import agent, app, auth_store, config, hooks, main as dcode_main, model_config, non_interactive, server, subagents, update_check from deepagents_code import _nemoclaw_managed from deepagents_code import config_manifest @@ -1265,17 +1321,26 @@ async def validate(): assert headless_kwargs["interpreter_ptc"] is None 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)}) + if sys.platform == "linux": + _nemoclaw_managed._MCP_CONFIG_FILE = Path(${JSON.stringify(managedMcpPath)}) + else: + _nemoclaw_managed._MCP_CONFIG_FILE = Path(${JSON.stringify( + path.join(tempDir, "absent-managed-mcp.json"), + )}) _nemoclaw_managed._MANAGED_MCP_FD = _nemoclaw_managed._MANAGED_MCP_BINDING = None _nemoclaw_managed._MANAGED_MCP_READY = False managed_args = dcode_main.parse_args() 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 + if sys.platform == "linux": + 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 + else: + assert snapshot_mcp_path is None + assert managed_args.no_mcp is True assert managed_args.trust_project_mcp is False managed_headless_kwargs = await non_interactive.run_non_interactive( "message", @@ -1285,7 +1350,7 @@ async def validate(): trust_project_mcp=True, ) assert managed_headless_kwargs["mcp_config_path"] == snapshot_mcp_path - assert managed_headless_kwargs["no_mcp"] is False + assert managed_headless_kwargs["no_mcp"] is (sys.platform != "linux") assert managed_headless_kwargs["trust_project_mcp"] is False assert model_config.ModelConfig().get_class_path("openai") is None managed_kwargs = config._get_provider_kwargs("openai") diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index 3faec57c3c8..ae2a736652d 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -341,6 +341,18 @@ describe("LangChain Deep Agents Code image contracts", () => { expect(dockerfile).toContain( "rm -f /usr/local/bin/dcode /usr/local/bin/deepagents-code /opt/venv/bin/dcode /opt/venv/bin/deepagents-code", ); + expect(dockerfile).toContain( + "COPY agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py", + ); + expect(dockerfile).toContain( + "python3 /opt/nemoclaw-deepagents-code/validate-progressive-tool-disclosure.py", + ); + expect(dockerfile).toContain( + "rm -f /opt/nemoclaw-deepagents-code/validate-progressive-tool-disclosure.py", + ); + expect(dockerfile).toContain("ARG NEMOCLAW_TOOL_DISCLOSURE=progressive"); + expect(dockerfile).toContain("NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}"); + expect(dockerfile).toContain("progressive|direct)"); expect(launcher).toContain('exec "$MANAGED_DCODE_WRAPPER" "$@"'); expect(policy).not.toContain("/usr/local/bin/dcode.real"); expect(policy).not.toContain("dcode.upstream"); diff --git a/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts b/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts new file mode 100644 index 00000000000..ce2127c206f --- /dev/null +++ b/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts @@ -0,0 +1,606 @@ +// 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 repoRoot = path.resolve(import.meta.dirname, ".."); +const agentDir = path.join(repoRoot, "agents", "langchain-deepagents-code"); +const middlewarePath = path.join(agentDir, "progressive_tool_disclosure.py"); +const patcherPath = path.join(agentDir, "patch-managed-deepagents-code.py"); +const harnessPath = path.join( + repoRoot, + "test", + "fixtures", + "deepagents-progressive-disclosure-harness.py", +); + +const MAIN_ANCHOR = " args = parser.parse_args()\n"; +const ENTRYPOINT_ANCHOR = "from deepagents_code.main import cli_main\n"; +const HARDENING_MARKER = "NemoClaw-managed Deep Agents Code hardening v2."; +const DISCLOSURE_MARKER = "NemoClaw-managed progressive tool disclosure."; + +const PACKAGE_SOURCES: Record = { + "__init__.py": `"""Deep Agents Code 0.1.30 test package."""`, + "__main__.py": `from deepagents_code.main import cli_main + +if __name__ == "__main__": + cli_main() +`, + "main.py": `from __future__ import annotations + +import os +from types import SimpleNamespace + +class Parser: + def parse_args(self): + return SimpleNamespace(command=None) + + def error(self, message): + raise RuntimeError(message) + +parser = Parser() + +def parse_args(): + args = parser.parse_args() + return args + +def cli_main(): + return parse_args() +`, + "app.py": fs.readFileSync( + path.join(repoRoot, "test", "fixtures", "langchain-deepagents-code", "app.py"), + "utf8", + ), + "auth_store.py": `from __future__ import annotations + +class StoredCredential: pass +class WriteOutcome: pass + +def load_credentials(): return {} +def set_stored_key(*args, **kwargs): return WriteOutcome() +`, + "config.py": `from __future__ import annotations + +import os +from typing import Any +from urllib.parse import urlparse + +_dotenv_loaded_values = {} + +def _get_provider_kwargs(provider, *, model_name=None): return {} +def _load_dotenv(*, start_path=None, refresh_loaded=False): return False +def _parse_interpreter_ptc(raw): return raw +def _preview_dotenv_environ(*, start_path=None): return {} +def _tracing_enabled(): return False +`, + "model_config.py": `from __future__ import annotations + +class ModelConfigError(RuntimeError): pass + +class ModelConfig: + @classmethod + def load(cls): return cls() + def get_class_path(self, provider_name): return None +`, + "agent.py": `from __future__ import annotations + +def create_deep_agent(*args, **kwargs): + del args + main = list(kwargs.get("middleware") or ()) + subagents = [ + list(subagent.get("middleware") or ()) + for subagent in kwargs.get("subagents") or () + ] + return main, subagents + +def _resolve_ptc_option(*args, **kwargs): return None +def load_async_subagents(config_path=None): return [] + +def create_cli_agent(model, assistant_id, *args, **kwargs): + del model, assistant_id, args + kwargs.pop("mcp_server_info", None) + kwargs.pop("rubric_model", None) + kwargs.pop("async_subagents", None) + return create_deep_agent( + middleware=[], + subagents=[{"name": "first", "middleware": []}, {"name": "second", "middleware": []}], + **kwargs, + ) +`, + "update_check.py": `from __future__ import annotations + +async def _run_install_subprocess(*args, **kwargs): return True, "spawned" +def set_auto_update(enabled): return enabled +async def _one(): return await _run_install_subprocess("one") +async def _two(): return await _run_install_subprocess("two") +async def _three(): return await _run_install_subprocess("three") +async def _four(): return await _run_install_subprocess("four") +async def _five(): return await _run_install_subprocess("five") +`, + "integrations/__init__.py": `"""Test integrations."""`, + "integrations/openai_codex.py": `from __future__ import annotations + +from pathlib import Path + +class CodexAuthStatus: + def __init__(self, *, logged_in, store_path): + self.logged_in = logged_in + self.store_path = store_path + +def default_store_path(): return Path("/sandbox/.deepagents/.state/chatgpt-auth.json") +def get_status(*, store_path=None): return CodexAuthStatus(logged_in=False, store_path=store_path) +async def run_browser_login(*args, **kwargs): return get_status() +def build_chat_model(*args, **kwargs): return object() +`, + "widgets/__init__.py": `"""Test widgets."""`, + "widgets/auth.py": `from __future__ import annotations + +class Static: + def __init__(self, value): self.value = value + +class AuthResult: + CANCELLED = "cancelled" + +class AuthPromptScreen: + def compose(self): return [] + def on_mount(self): pass + +class AuthManagerScreen: + def compose(self): return [] + def on_mount(self): pass +`, + "widgets/codex_auth.py": `from __future__ import annotations + +class Static: + def __init__(self, value): self.value = value + +class CodexAuthScreen: + def compose(self): return [] + def on_mount(self): pass +`, + "widgets/model_selector.py": `from __future__ import annotations + +class ModelSelectorScreen: + def _select_with_auth_check(self, model_spec, provider): pass +`, + "widgets/approval.py": `from __future__ import annotations + +class ApprovalMenu: + def _handle_selection(self, option, *, reject_message=None): pass +`, + "server.py": fs.readFileSync( + path.join(repoRoot, "test", "fixtures", "langchain-deepagents-code", "server.py"), + "utf8", + ), + "_server_config.py": `from __future__ import annotations + +from pathlib import Path + +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()) +`, + "mcp_tools.py": fs.readFileSync( + path.join(repoRoot, "test", "fixtures", "langchain-deepagents-code", "mcp_tools.py"), + "utf8", + ), + "subagents.py": `from __future__ import annotations + +def list_subagents(*args, **kwargs): return [] +`, + "hooks.py": `from __future__ import annotations + +from typing import Any + +_hooks_config = None + +def _load_hooks(): return [] +def _run_single_hook(command, event, payload_bytes): return None +`, + "non_interactive.py": `from __future__ import annotations + +async def run_non_interactive(*args, **kwargs): return kwargs +async def _run_startup_command(command, console, *, quiet): return command +`, +}; + +interface PatchFixture { + root: string; + packageDir: string; + entrypointPath: string; + mainPath: string; + agentPath: string; + modulePath: string; + helperPath: string; + sourcePaths: string[]; +} + +function writeFixtureFile(root: string, relativePath: string, content: string): string { + const target = path.join(root, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, `${content.trim()}\n`, "utf8"); + return target; +} + +function makePatchFixture(version = "0.1.30"): PatchFixture { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-disclosure-")); + const packageDir = path.join(root, "deepagents_code"); + const sourcePaths = Object.entries(PACKAGE_SOURCES).map(([relativePath, source]) => + writeFixtureFile(packageDir, relativePath, source), + ); + writeFixtureFile( + root, + `deepagents_code-${version}.dist-info/METADATA`, + `Metadata-Version: 2.1\nName: deepagents-code\nVersion: ${version}`, + ); + const entrypointPath = path.join(packageDir, "__main__.py"); + const mainPath = path.join(packageDir, "main.py"); + const agentPath = path.join(packageDir, "agent.py"); + const modulePath = path.join(packageDir, "progressive_tool_disclosure.py"); + const helperPath = path.join(packageDir, "_nemoclaw_managed.py"); + return { + root, + packageDir, + entrypointPath, + mainPath, + agentPath, + modulePath, + helperPath, + sourcePaths, + }; +} + +function runPatcher(fixture: PatchFixture) { + return spawnSync("python3", [patcherPath], { + encoding: "utf8", + env: { PATH: process.env.PATH, PYTHONPATH: fixture.root }, + }); +} + +function snapshot(paths: string[]): Record { + return Object.fromEntries(paths.map((file) => [file, fs.readFileSync(file, "utf8")])); +} + +function runWiring(fixture: PatchFixture): Record { + const script = `import importlib +import importlib.util +import json +import os +import sys + +spec = importlib.util.spec_from_file_location("disclosure_harness", ${JSON.stringify(harnessPath)}) +harness = importlib.util.module_from_spec(spec) +spec.loader.exec_module(harness) +harness._install_stubs() +sys.path.insert(0, ${JSON.stringify(fixture.root)}) +agent = importlib.import_module("deepagents_code.agent") +middleware = importlib.import_module("deepagents_code.progressive_tool_disclosure") + +class Info: + def __init__(self, tools, name="fixture"): + self.tools = tools + self.name = name + +class NamedTool: + def __init__(self, name): + self.name = name + +def counts(result): + main, subagents = result + middleware_type = middleware.ProgressiveToolDisclosureMiddleware + instances = [item for item in main if isinstance(item, middleware_type)] + instances.extend( + item for stack in subagents for item in stack if isinstance(item, middleware_type) + ) + return len(instances), len({id(item) for item in instances}) + +os.environ.pop("NEMOCLAW_TOOL_DISCLOSURE", None) +no_mcp = counts(agent.create_cli_agent(None, "assistant")) +empty_mcp = counts(agent.create_cli_agent(None, "assistant", mcp_server_info=[Info(())])) +active = counts(agent.create_cli_agent(None, "assistant", mcp_server_info=[Info(("mcp_echo",))])) +os.environ["NEMOCLAW_TOOL_DISCLOSURE"] = "direct" +direct = counts(agent.create_cli_agent(None, "assistant", mcp_server_info=[Info(("mcp_echo",))])) + +original_factory = agent._nemoclaw_original_create_cli_agent +reached_original = [] + +def forbidden_original(*args, **kwargs): + del args, kwargs + reached_original.append("called") + raise AssertionError("callable namespace validation ran too late") + +def reject(tools, info=()): + try: + agent.create_cli_agent( + None, + "assistant", + tools=tools, + mcp_server_info=list(info), + ) + except RuntimeError as exc: + return str(exc) + raise AssertionError("ambiguous callable tool namespace was accepted") + +agent._nemoclaw_original_create_cli_agent = forbidden_original +try: + os.environ["NEMOCLAW_TOOL_DISCLOSURE"] = "progressive" + progressive_collisions = { + "regular_regular": reject([NamedTool("duplicate"), NamedTool("duplicate")]), + "regular_mcp": reject( + [NamedTool("mcp_echo"), NamedTool("mcp_echo")], + [Info(("mcp_echo",), name="mcp")], + ), + "cross_mcp": reject( + [NamedTool("alpha_beta_echo"), NamedTool("alpha_beta_echo")], + [ + Info(("alpha_beta_echo",), name="alpha"), + Info(("alpha_beta_echo",), name="alpha_beta"), + ], + ), + "reserved_regular": reject([NamedTool("read_file")]), + "reserved_mcp": reject( + [NamedTool("search_tools")], + [Info(("search_tools",), name="search")], + ), + } + os.environ["NEMOCLAW_TOOL_DISCLOSURE"] = "direct" + direct_collisions = { + "duplicate": reject([NamedTool("direct_dup"), NamedTool("direct_dup")]), + "reserved": reject([NamedTool("execute")]), + } +finally: + agent._nemoclaw_original_create_cli_agent = original_factory + +os.environ["NEMOCLAW_TOOL_DISCLOSURE"] = "invalid" +try: + agent.create_cli_agent(None, "assistant", mcp_server_info=[Info(("mcp_echo",))]) +except RuntimeError as exc: + invalid = str(exc) +else: + raise AssertionError("invalid disclosure mode was accepted") + +print(json.dumps({ + "no_mcp": no_mcp, + "empty_mcp": empty_mcp, + "active": active, + "progressive_collisions": progressive_collisions, + "direct_collisions": direct_collisions, + "reached_original": reached_original, + "direct": direct, + "invalid": invalid, +})) +`; + const result = spawnSync("python3", ["-c", script], { + encoding: "utf8", + env: { PATH: process.env.PATH, PYTHONPATH: fixture.root }, + }); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout) as Record; +} + +function runHarness( + scenario: "behavior" | "overflow" | "persistence" | "isolation" | "namespace", + target = middlewarePath, +) { + const result = spawnSync("python3", [harnessPath, scenario, target], { encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout) as Record; +} + +describe("Deep Agents progressive tool disclosure", () => { + it("keeps only core tools visible and discovers name/description matches cumulatively", () => { + const result = runHarness("behavior"); + expect(result.initial).toEqual(["ls", "search_tools", "read_file"]); + expect(result.discovered).toEqual(["Weather_Forecast", "query_database"]); + expect(result.async).toEqual([ + "Weather_Forecast", + "ls", + "query_database", + "search_tools", + "read_file", + ]); + expect(result.max_query_length).toBe(256); + expect(result.provider_native_preserved).toBe(true); + }); + + it("bounds broad catalog output, persisted discovery, and visible schemas deterministically", () => { + const result = runHarness("overflow"); + expect(result.result_limit).toBe(20); + expect(result.description_chars).toBe(256); + expect(result.output_bytes_limit).toBe(8192); + expect(result.output_bytes).toBeLessThanOrEqual(8192); + expect(result.discovered_count).toBe(20); + expect(result.discovery_limit).toBe(64); + expect(result.discovery_name_bytes).toBe(120); + expect(result.discovery_state_bytes_limit).toBe(8192); + expect(result.discovery_state_bytes).toBeLessThanOrEqual(8192); + expect(result.long_state_count).toBe(64); + expect(result.state_count).toBe(64); + expect(result.single_schema_bytes_limit).toBe(16384); + expect(result.visible_schema_bytes_limit).toBe(131072); + expect(result.visible_schema_count).toBeGreaterThan(0); + expect(result.visible_schema_count).toBeLessThan(64); + expect(result.oversized_schema_omitted).toBe(true); + expect(result.state_blocked).toBe(true); + expect(result.schema_blocked).toBe(true); + expect(result.search_to_request_consistent).toBe(true); + expect(result.core_schema_limits_exempt).toBe(true); + expect(result.reducer_associative).toBe(true); + expect(result.concurrent_response_bounded).toBe(true); + expect(result.sequential_visibility_monotonic).toBe(true); + expect(result.duplicate_first_wins).toBe(true); + expect(result.empty_names_preserved).toBe(true); + expect(result.provider_native_preserved).toBe(true); + }); + + it("restores discovered tools after compaction and session reconstruction", () => { + const result = runHarness("persistence"); + expect(result.resumed).toContain("Weather_Forecast"); + expect(result.unknown).not.toContain("Weather_Forecast"); + }); + + it("isolates graph threads and local-subagent middleware instances", () => { + const result = runHarness("isolation"); + expect(result.thread_a).toContain("Weather_Forecast"); + expect(result.thread_b).not.toContain("Weather_Forecast"); + }); + + it("rejects duplicate callable names and non-managed reserved-name owners", () => { + const result = runHarness("namespace"); + expect(result.safe_mcp).toBe(true); + expect(result.regular_regular).toContain("multiple registered implementations"); + expect(result.regular_mcp).toContain("MCP metadata owners"); + expect(result.cross_mcp).toContain("multiple MCP owners"); + expect(result.reserved_regular).toContain("non-managed owner of reserved name 'read_file'"); + expect(result.reserved_mcp).toContain("non-managed owner of reserved name 'search_tools'"); + }); +}); + +describe("Deep Agents 0.1.30 progressive-disclosure build patch", () => { + it("patches the complete package and isolated main/subagent wiring idempotently", () => { + const fixture = makePatchFixture(); + const first = runPatcher(fixture); + expect(first.status, first.stderr).toBe(0); + + const managedPaths = [...fixture.sourcePaths, fixture.modulePath, fixture.helperPath]; + const firstBytes = snapshot(managedPaths); + const second = runPatcher(fixture); + expect(second.status, second.stderr).toBe(0); + expect(snapshot(managedPaths)).toEqual(firstBytes); + + for (const file of fixture.sourcePaths.filter( + (sourcePath) => !sourcePath.endsWith("/__init__.py"), + )) { + expect( + firstBytes[file].match(new RegExp(HARDENING_MARKER.replaceAll(".", "\\."), "g")), + ).toHaveLength(1); + } + expect( + firstBytes[fixture.agentPath].match(/NemoClaw-managed progressive tool disclosure\./g), + ).toHaveLength(1); + expect( + firstBytes[fixture.agentPath].match(/ProgressiveToolDisclosureMiddleware\(\)/g), + ).toHaveLength(2); + expect(firstBytes[fixture.modulePath]).toBe(fs.readFileSync(middlewarePath, "utf8")); + + const wiring = runWiring(fixture); + expect(wiring).toMatchObject({ + no_mcp: [0, 0], + empty_mcp: [0, 0], + active: [3, 3], + direct: [0, 0], + reached_original: [], + invalid: "NEMOCLAW_TOOL_DISCLOSURE must be 'progressive' or 'direct'", + }); + expect(wiring.progressive_collisions).toEqual({ + regular_regular: expect.stringContaining("multiple registered implementations"), + regular_mcp: expect.stringContaining("MCP metadata owners"), + cross_mcp: expect.stringContaining("multiple MCP owners"), + reserved_regular: expect.stringContaining("non-managed owner of reserved name 'read_file'"), + reserved_mcp: expect.stringContaining("non-managed owner of reserved name 'search_tools'"), + }); + expect(wiring.direct_collisions).toEqual({ + duplicate: expect.stringContaining("multiple registered implementations"), + reserved: expect.stringContaining("non-managed owner of reserved name 'execute'"), + }); + }); + + it("fails closed on the pinned package version before changing source", () => { + const fixture = makePatchFixture("0.1.31"); + const before = snapshot(fixture.sourcePaths); + const result = runPatcher(fixture); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Expected deepagents-code==0.1.30"); + expect(snapshot(fixture.sourcePaths)).toEqual(before); + expect(fs.existsSync(fixture.modulePath)).toBe(false); + }); + + it.each([ + ["parser", "mainPath", MAIN_ANCHOR], + ["entrypoint", "entrypointPath", ENTRYPOINT_ANCHOR], + ] as const)("fails closed when the exact %s anchor is missing or duplicated", (label, pathKey, anchor) => { + for (const mode of ["missing", "duplicate"] as const) { + const fixture = makePatchFixture(); + const target = fixture[pathKey]; + const original = fs.readFileSync(target, "utf8"); + fs.writeFileSync( + target, + mode === "missing" + ? original.replace(anchor, "") + : original.replace(anchor, anchor + anchor), + "utf8", + ); + const before = snapshot(fixture.sourcePaths); + const result = runPatcher(fixture); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain(`Expected one Deep Agents Code ${label} marker`); + expect(snapshot(fixture.sourcePaths)).toEqual(before); + expect(fs.existsSync(fixture.modulePath)).toBe(false); + } + }); + + it("fails closed when the required progressive agent source shape drifts", () => { + const fixture = makePatchFixture(); + const original = fs.readFileSync(fixture.agentPath, "utf8"); + fs.writeFileSync( + fixture.agentPath, + original.replace("def create_cli_agent(", "def renamed_create_cli_agent("), + "utf8", + ); + const before = snapshot(fixture.sourcePaths); + const result = runPatcher(fixture); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Required upstream functions missing"); + expect(result.stderr).toContain("create_cli_agent"); + expect(snapshot(fixture.sourcePaths)).toEqual(before); + expect(fs.existsSync(fixture.modulePath)).toBe(false); + }); + + it("rejects a partial progressive sentinel without changing package source", () => { + const fixture = makePatchFixture(); + fs.appendFileSync(fixture.agentPath, `\n# ${DISCLOSURE_MARKER}\n`, "utf8"); + const before = snapshot(fixture.sourcePaths); + const result = runPatcher(fixture); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("progressive-disclosure patch is partial"); + expect(snapshot(fixture.sourcePaths)).toEqual(before); + expect(fs.existsSync(fixture.modulePath)).toBe(false); + }); + + it("rejects a partial package install with the middleware missing", () => { + const fixture = makePatchFixture(); + const first = runPatcher(fixture); + expect(first.status, first.stderr).toBe(0); + fs.rmSync(fixture.modulePath); + const before = snapshot([...fixture.sourcePaths, fixture.helperPath]); + + const result = runPatcher(fixture); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Managed package patch is partial: middleware is missing"); + expect(snapshot([...fixture.sourcePaths, fixture.helperPath])).toEqual(before); + expect(fs.existsSync(fixture.modulePath)).toBe(false); + }); + + it("refuses to overwrite a conflicting installed middleware module", () => { + const fixture = makePatchFixture(); + fs.writeFileSync(fixture.modulePath, "# unexpected module\n", "utf8"); + const before = snapshot(fixture.sourcePaths); + const result = runPatcher(fixture); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Refusing to overwrite unexpected middleware"); + expect(snapshot(fixture.sourcePaths)).toEqual(before); + expect(fs.readFileSync(fixture.modulePath, "utf8")).toBe("# unexpected module\n"); + }); +}); diff --git a/test/mcp-bridge-servers.test.ts b/test/mcp-bridge-servers.test.ts index 0e2f1fff8b6..ec47bb2bf50 100644 --- a/test/mcp-bridge-servers.test.ts +++ b/test/mcp-bridge-servers.test.ts @@ -20,6 +20,14 @@ import { } from "./e2e/live/mcp-bridge-servers"; const servers: StartedHttpServer[] = []; +type CompatibleToolCallResponse = { + choices: Array<{ + message: { + content?: unknown; + tool_calls: Array<{ function: { name: string; arguments: string } }>; + }; + }>; +}; const tlsDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-fixture-tls-")); execFileSync( "openssl", @@ -147,6 +155,107 @@ describe("authenticated MCP live fixtures", () => { } }); + it("omits failed cloudflared child output from diagnostics", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cloudflared-redaction-")); + const cloudflared = path.join(directory, "cloudflared"); + const boundaryUrl = "HTTPS://boundary-user:boundary-password@boundary-proxy.example.test:9443/"; + const diagnosticSuffix = [ + "", + "proxy HTTPS://proxy-user:proxy-password@proxy.example.test:8443 failed", + "fallback socks5://socks-user:socks-password@socks.example.test:1080 failed", + "PASSWORD=tunnel-password-value", + "token: eyJhbGciOiJIUzI1NiJ9.tunnel-payload", + "", + ].join("\n"); + const boundaryPaddingBytes = + 32 * 1024 + "HTTPS://".length - boundaryUrl.length - diagnosticSuffix.length; + fs.writeFileSync( + cloudflared, + [ + "#!/bin/sh", + `printf '%s' '${boundaryUrl}' >&2`, + `dd if=/dev/zero bs=${boundaryPaddingBytes} count=1 2>/dev/null | tr '\\000' x >&2`, + `printf '%s' '${diagnosticSuffix}' >&2`, + "exit 1", + "", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + let failure: unknown; + try { + await startPublicMcpHttpsTunnel({ + cloudflaredBin: cloudflared, + cleanup: { add: vi.fn() }, + label: "redaction fixture", + server: { port: 43123, close: async () => {} }, + }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(Error); + const message = failure instanceof Error ? failure.message : String(failure); + expect(message).toContain("cloudflared child output omitted from diagnostics"); + expect(message).not.toContain("boundary-proxy.example.test:9443"); + expect(message).not.toContain("proxy.example.test:8443"); + expect(message).not.toContain("socks.example.test:1080"); + expect(message).not.toContain("boundary-user"); + expect(message).not.toContain("boundary-password"); + expect(message).not.toContain("proxy-user"); + expect(message).not.toContain("proxy-password"); + expect(message).not.toContain("socks-user"); + expect(message).not.toContain("socks-password"); + expect(message).not.toContain("tunnel-password-value"); + expect(message).not.toContain("tunnel-payload"); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + + it("omits a Slack credential split across cloudflared data events", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cloudflared-chunks-")); + const cloudflared = path.join(directory, "cloudflared"); + const credentialPrefix = ["xoxb", "1234567890"].join("-"); + const credentialTail = "-1234567890123-abcdefghijklmnopqrstuvwxyz"; + fs.writeFileSync( + cloudflared, + [ + "#!/bin/sh", + `printf '%s' '${credentialPrefix}' >&2`, + "sleep 1", + `printf '%s\\n' '${credentialTail}' >&2`, + "exit 1", + "", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + let failure: unknown; + try { + await startPublicMcpHttpsTunnel({ + cloudflaredBin: cloudflared, + cleanup: { add: vi.fn() }, + label: "chunked redaction fixture", + server: { port: 43123, close: async () => {} }, + }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(Error); + const message = failure instanceof Error ? failure.message : String(failure); + expect(message).toContain("cloudflared child output omitted from diagnostics"); + expect(message).not.toContain(credentialPrefix); + expect(message).not.toContain(credentialTail); + expect(message).not.toContain(`${credentialPrefix}${credentialTail}`); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + it("implements stateless Streamable HTTP and validates the tool challenge", async () => { const secret = "fixture-secret"; const challenge = "fixture-challenge"; @@ -422,7 +531,6 @@ describe("authenticated MCP live fixtures", () => { model: "mock/model", toolChallenge: "deferred-fixture", toolResultToken: resultToken, - toolNames: ["mcp_fake_fake_echo"], deferredToolName: "mcp_fake_fake_echo", }); servers.push(server); @@ -431,29 +539,73 @@ describe("authenticated MCP live fixtures", () => { authorization: "Bearer compatible-key", "content-type": "application/json", }; + const bridgeTools = ["tool_search", "tool_describe", "tool_call"].map((name) => ({ + type: "function", + function: { name, parameters: {} }, + })); - const first = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify({ - model: "mock/model", - messages: [{ role: "user", content: "use the deferred tool" }], - tools: [ - { - type: "function", - function: { name: "tool_call", parameters: {} }, - }, - ], - }), + const call = async ( + messages: Array<{ role: string; content: string; tool_call_id?: string }>, + ) => + (await ( + await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ model: "mock/model", messages, tools: bridgeTools }), + }) + ).json()) as CompatibleToolCallResponse; + const searchBody = await call([{ role: "user", content: "use the deferred tool" }]); + expect(searchBody.choices[0].message.tool_calls[0]).toMatchObject({ + function: { + name: "tool_search", + arguments: JSON.stringify({ query: "mcp_fake_fake_echo" }), + }, }); - const firstBody = (await first.json()) as { - choices: Array<{ - message: { - tool_calls: Array<{ function: { name: string; arguments: string } }>; - }; - }>; + const missedSearch = await call([ + { + role: "tool", + tool_call_id: "call_hermes_tool_search", + content: '{"matches":[{"name":"some_other_tool"}]}', + }, + ]); + expect(missedSearch).toMatchObject({ + choices: [ + { message: { content: expect.stringContaining("did not return the deferred target") } }, + ], + }); + const searchResult = { + role: "tool", + tool_call_id: "call_hermes_tool_search", + content: '{"matches":[{"name":"mcp_fake_fake_echo"}]}', }; - expect(firstBody.choices[0].message.tool_calls[0]).toMatchObject({ + const describeBody = await call([searchResult]); + expect(describeBody.choices[0].message.tool_calls[0]).toMatchObject({ + function: { + name: "tool_describe", + arguments: JSON.stringify({ name: "mcp_fake_fake_echo" }), + }, + }); + const wrongDescription = await call([ + searchResult, + { + role: "tool", + tool_call_id: "call_hermes_tool_describe", + content: '{"name":"mcp_fake_fake_echo","parameters":{}}', + }, + ]); + expect(wrongDescription).toMatchObject({ + choices: [ + { message: { content: expect.stringContaining("did not return the deferred schema") } }, + ], + }); + const descriptionResult = { + role: "tool", + tool_call_id: "call_hermes_tool_describe", + content: + '{"name":"mcp_fake_fake_echo","parameters":{"properties":{"challenge":{"type":"string"}}}}', + }; + const callBody = await call([searchResult, descriptionResult]); + expect(callBody.choices[0].message.tool_calls[0]).toMatchObject({ function: { name: "tool_call", arguments: JSON.stringify({ @@ -462,24 +614,156 @@ describe("authenticated MCP live fixtures", () => { }), }, }); - expect(JSON.stringify(firstBody)).not.toContain(resultToken); + expect(JSON.stringify(callBody)).not.toContain(resultToken); - const final = await fetch(url, { + const finalBody = await call([ + searchResult, + descriptionResult, + { + role: "tool", + tool_call_id: "call_hermes_tool_call", + content: JSON.stringify({ result: resultToken }), + }, + ]); + expect(finalBody).toMatchObject({ + choices: [{ message: { content: resultToken } }], + }); + }); + + it("fails closed when a Hermes deferred tool leaks into the model registry", async () => { + const server = await startCompatibleMock({ + apiKey: "compatible-key", + model: "mock/model", + toolChallenge: "leak-fixture", + deferredToolName: "mcp_fake_fake_echo", + }); + servers.push(server); + const response = await fetch(`http://127.0.0.1:${server.port}/v1/chat/completions`, { method: "POST", - headers, + headers: { + authorization: "Bearer compatible-key", + "content-type": "application/json", + }, body: JSON.stringify({ - model: "mock/model", - messages: [{ role: "tool", content: JSON.stringify({ result: resultToken }) }], - tools: [ - { - type: "function", - function: { name: "tool_call", parameters: {} }, - }, - ], + messages: [{ role: "user", content: "use the deferred tool" }], + tools: ["tool_search", "tool_describe", "tool_call", "mcp_fake_fake_echo"].map((name) => ({ + type: "function", + function: { name, parameters: {} }, + })), }), }); - expect(await final.json()).toMatchObject({ - choices: [{ message: { content: resultToken } }], + expect(await response.json()).toMatchObject({ + choices: [ + { + message: { + content: expect.stringContaining("deferred target mcp_fake_fake_echo leaked"), + }, + }, + ], + }); + }); + + it("requires Deep Agents search_tools before exposing the matching MCP tool", async () => { + const resultToken = "MCP_AUTH_REWRITE_OK::progressive-fixture"; + const server = await startCompatibleMock({ + apiKey: "compatible-key", + model: "mock/model", + toolChallenge: "progressive-fixture", + toolResultToken: resultToken, + progressiveToolSearch: { + toolName: "fake_fake_echo", + query: "AuThEnTiCaTeD McP", + }, + }); + servers.push(server); + const url = `http://127.0.0.1:${server.port}/v1/chat/completions`; + const headers = { + authorization: "Bearer compatible-key", + "content-type": "application/json", + }; + const post = async ( + messages: Array<{ role: string; content: string; tool_call_id?: string }>, + tools: string[], + ) => + (await ( + await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + messages, + tools: tools.map((name) => ({ type: "function", function: { name, parameters: {} } })), + }), + }) + ).json()) as CompatibleToolCallResponse; + + const searchBody = await post([{ role: "user", content: "use MCP" }], ["search_tools", "ls"]); + expect(searchBody.choices[0].message.tool_calls[0]).toMatchObject({ + function: { + name: "search_tools", + arguments: JSON.stringify({ query: "AuThEnTiCaTeD McP" }), + }, + }); + const missedSearch = await post( + [ + { + role: "tool", + tool_call_id: "call_progressive_tool_search", + content: "No hidden tools matched", + }, + ], + ["search_tools", "ls"], + ); + expect(missedSearch).toMatchObject({ + choices: [{ message: { content: expect.stringContaining("did not return the expected") } }], + }); + const legacySearch = await post( + [ + { + role: "tool", + tool_call_id: "call_progressive_tool_search", + content: "Discovered fake_fake_echo", + }, + ], + ["search_tools", "ls", "fake_fake_echo"], + ); + expect(legacySearch).toMatchObject({ + choices: [{ message: { content: expect.stringContaining("did not return the expected") } }], + }); + const searchResult = { + role: "tool", + tool_call_id: "call_progressive_tool_search", + content: + "Found 1 matching hidden tool(s); returning 1 bounded discovery candidate(s) " + + "(per-search limit 20):\n- fake_fake_echo: Authenticated MCP tool", + }; + const callBody = await post([searchResult], ["search_tools", "ls", "fake_fake_echo"]); + expect(callBody.choices[0].message.tool_calls[0]).toMatchObject({ + function: { + name: "fake_fake_echo", + arguments: JSON.stringify({ challenge: "progressive-fixture" }), + }, + }); + const finalBody = await post( + [ + searchResult, + { role: "tool", tool_call_id: "call_progressive_mcp_proof", content: resultToken }, + ], + ["search_tools", "ls", "fake_fake_echo"], + ); + expect(finalBody).toMatchObject({ choices: [{ message: { content: resultToken } }] }); + + const leaked = await post( + [{ role: "user", content: "use MCP" }], + ["search_tools", "fake_fake_echo"], + ); + expect(leaked).toMatchObject({ + choices: [ + { + message: { + content: expect.stringContaining("visible before search_tools"), + }, + }, + ], }); }); }); diff --git a/test/onboard-custom-dockerfile.test.ts b/test/onboard-custom-dockerfile.test.ts index ee48f54ca35..3feda4419cb 100644 --- a/test/onboard-custom-dockerfile.test.ts +++ b/test/onboard-custom-dockerfile.test.ts @@ -142,7 +142,9 @@ describe("onboard custom Dockerfile", () => { "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_TOOL_DISCLOSURE=progressive", "ARG NEMOCLAW_BUILD_ID=default", + "ENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}", "RUN echo done", ].join("\n"), ); @@ -333,6 +335,109 @@ const { createSandbox } = require(${onboardPath}); }, ); + it("rejects an invalid tool-disclosure contract before mutating a live or stale sandbox", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-from-contract-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "contract-preflight.js"); + const outcomePath = path.join(tmpDir, "outcome.json"); + const customDockerfile = path.join(tmpDir, "Dockerfile.custom"); + const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); + const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); + const registryPath = JSON.stringify(path.join(repoRoot, "src", "lib", "state", "registry.ts")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + fs.writeFileSync(customDockerfile, "FROM scratch\n"); + + const script = String.raw` +const fs = require("node:fs"); +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const outcomePath = ${JSON.stringify(outcomePath)}; +const customDockerfile = ${JSON.stringify(customDockerfile)}; +const destructive = []; +const sandboxLive = process.env.SANDBOX_LIVE === "1"; +const capture = (command) => { + const text = Array.isArray(command) ? command.join(" ") : String(command); + if (/sandbox get my-assistant/.test(text)) return sandboxLive ? "my-assistant Ready" : ""; + if (/sandbox list/.test(text)) return sandboxLive ? "my-assistant Ready" : ""; + if (/forward list/.test(text)) return ""; + return ""; +}; +runner.runCapture = capture; +runner.runCaptureOpenshell = capture; +runner.run = (command) => { + const text = Array.isArray(command) ? command.join(" ") : String(command); + if (/sandbox (?:delete|create|rebuild)/.test(text)) destructive.push(text); + return { status: 0, stdout: "", stderr: "" }; +}; +runner.runOpenshell = runner.run; + +registry.registerSandbox({ + name: "my-assistant", + agent: "openclaw", + model: "gpt-5.4", + provider: "openai-api", + fromDockerfile: customDockerfile, + toolDisclosure: "progressive", +}); +const originalRemove = registry.removeSandbox; +registry.removeSandbox = (...args) => { + destructive.push("registry remove " + String(args[0])); + return originalRemove(...args); +}; + +const errors = []; +console.error = (...args) => errors.push(args.join(" ")); +const originalExit = process.exit; +process.exit = (code) => { + fs.writeFileSync(outcomePath, JSON.stringify({ code, destructive, errors })); + originalExit(code); +}; + +const { createSandbox } = require(${onboardPath}); +createSandbox( + null, + "gpt-5.4", + "openai-api", + null, + "my-assistant", + null, + null, + customDockerfile, +).catch((error) => { + errors.push(String(error)); + fs.writeFileSync(outcomePath, JSON.stringify({ code: 1, destructive, errors })); + originalExit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + for (const sandboxLive of ["1", "0"]) { + fs.rmSync(outcomePath, { force: true }); + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_RECREATE_SANDBOX: "1", + SANDBOX_LIVE: sandboxLive, + }, + }); + + assert.equal(result.status, 1, result.stderr); + assert.ok(fs.existsSync(outcomePath), result.stderr); + const outcome = JSON.parse(fs.readFileSync(outcomePath, "utf8")); + assert.deepEqual(outcome.destructive, []); + assert.match(outcome.errors.join("\n"), /tool-disclosure contract is invalid/); + } + }); + it("exits with an error when the --from Dockerfile path does not exist", async () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-from-missing-")); @@ -479,7 +584,14 @@ const { createSandbox } = require(${onboardPath}); const ignoredDir = path.join(tmpDir, "node_modules", "pkg"); fs.mkdirSync(ignoredDir, { recursive: true }); - fs.writeFileSync(path.join(ignoredDir, "Dockerfile"), "FROM ubuntu:22.04\n"); + fs.writeFileSync( + path.join(ignoredDir, "Dockerfile"), + [ + "FROM ubuntu:22.04", + "ARG NEMOCLAW_TOOL_DISCLOSURE=progressive", + "ENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}", + ].join("\n"), + ); fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755, @@ -546,7 +658,14 @@ const { createSandbox } = require(${onboardPath}); const customBuildDir = path.join(tmpDir, "custom-image"); fs.mkdirSync(customBuildDir, { recursive: true }); - fs.writeFileSync(path.join(customBuildDir, "Dockerfile"), "FROM ubuntu:22.04\n"); + fs.writeFileSync( + path.join(customBuildDir, "Dockerfile"), + [ + "FROM ubuntu:22.04", + "ARG NEMOCLAW_TOOL_DISCLOSURE=progressive", + "ENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}", + ].join("\n"), + ); fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755, diff --git a/test/onboard-installer-restore-intent.test.ts b/test/onboard-installer-restore-intent.test.ts index ef1fb0d5515..b80e9af1d60 100644 --- a/test/onboard-installer-restore-intent.test.ts +++ b/test/onboard-installer-restore-intent.test.ts @@ -73,7 +73,11 @@ runner.runCapture = (command) => { } return ""; }; -registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); +registry.getSandbox = () => ({ + name: "my-assistant", + gpuEnabled: false, + toolDisclosure: "progressive", +}); registry.registerSandbox = () => true; registry.updateSandbox = () => true; registry.setDefault = () => true; @@ -263,7 +267,11 @@ runner.runCapture = (command) => { if (_n(command).includes("sandbox list")) return "my-assistant NotReady"; return ""; }; -registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); +registry.getSandbox = () => ({ + name: "my-assistant", + gpuEnabled: false, + toolDisclosure: "progressive", +}); sandboxState.getLatestBackup = () => { throw new Error("unexpected getLatestBackup without installer restore intent"); }; diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 07a3b3024cd..445df67d002 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -320,7 +320,8 @@ const { createSandbox, setupMessagingChannels } = require(${onboardPath}); fs.mkdirSync(fakeBin, { recursive: true }); fs.mkdirSync(customBuildDir, { recursive: true }); - fs.writeFileSync(customDockerfilePath, "FROM scratch\nARG NEMOCLAW_MESSAGING_PLAN_B64=\n"); + // biome-ignore format: keep this legacy test within its file-size budget. + fs.writeFileSync(customDockerfilePath, "FROM scratch\nARG NEMOCLAW_MESSAGING_PLAN_B64=\nARG NEMOCLAW_TOOL_DISCLOSURE=progressive\nENV NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}\n"); fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755, }); @@ -1232,8 +1233,7 @@ runner.runCapture = (command) => { if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running\nmy-assistant 127.0.0.1 8642 12346 running"; return ""; }; -registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); - +registry.getSandbox = () => ({ name: "my-assistant", toolDisclosure: "progressive" }); const { createSandbox } = require(${onboardPath}); (async () => { diff --git a/test/onboard-prepared-build-context.test.ts b/test/onboard-prepared-build-context.test.ts index faf77ed4114..864600a8331 100644 --- a/test/onboard-prepared-build-context.test.ts +++ b/test/onboard-prepared-build-context.test.ts @@ -188,6 +188,7 @@ const { createSandbox } = require(${onboardPath}); null, [], null, + null, preparedBuildContext, ); } catch (error) { diff --git a/test/onboard-terminal-dashboard.test.ts b/test/onboard-terminal-dashboard.test.ts index e5ab8297403..64bbe393500 100644 --- a/test/onboard-terminal-dashboard.test.ts +++ b/test/onboard-terminal-dashboard.test.ts @@ -88,7 +88,13 @@ runner.runCapture = (command) => { registry.getSandbox = () => scenario === "reuse" - ? { name: sandboxName, gpuEnabled: false, agent: "langchain-deepagents-code", dashboardPort: 18789 } + ? { + name: sandboxName, + gpuEnabled: false, + agent: "langchain-deepagents-code", + dashboardPort: 18789, + toolDisclosure: "progressive", + } : null; registry.registerSandbox = (entry) => { registerCalls.push(entry); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 8f4e748cf2b..56b0b8dca2c 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -3225,7 +3225,7 @@ runner.runCapture = (command) => { if (_n(command).includes("sandbox list")) return "my-assistant NotReady"; return ""; }; -registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); +registry.getSandbox = () => ({ name: "my-assistant", toolDisclosure: "progressive" }); childProcess.spawn = () => { throw new Error("unexpected sandbox create"); }; @@ -3961,7 +3961,7 @@ runner.runCapture = (command) => { if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; -registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); +registry.getSandbox = () => ({ name: "my-assistant", toolDisclosure: "progressive" }); // Mock prompt to return "y" (reuse) credentials.prompt = async () => "y"; @@ -4096,7 +4096,7 @@ runner.runCapture = (command) => { } return ""; }; -registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); +registry.getSandbox = () => ({ name: "my-assistant", toolDisclosure: "progressive" }); registry.registerSandbox = () => true; registry.updateSandbox = () => true; registry.setDefault = () => true; @@ -4221,7 +4221,7 @@ runner.runCapture = (command) => { } return ""; }; -registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); +registry.getSandbox = () => ({ name: "my-assistant", toolDisclosure: "progressive" }); registry.registerSandbox = () => true; registry.updateSandbox = () => true; registry.setDefault = () => true; @@ -4472,7 +4472,7 @@ runner.runCapture = (command) => { if (_n(command).includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; return ""; }; -registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); +registry.getSandbox = () => ({ name: "my-assistant", toolDisclosure: "progressive" }); childProcess.spawn = (...args) => { const child = new EventEmitter(); diff --git a/test/openclaw-tool-search-runtime-validator.test.ts b/test/openclaw-tool-search-runtime-validator.test.ts new file mode 100644 index 00000000000..4ab007d8d1d --- /dev/null +++ b/test/openclaw-tool-search-runtime-validator.test.ts @@ -0,0 +1,276 @@ +// 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 { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { validateOpenClawToolSearchRuntime } from "../scripts/validate-openclaw-tool-search.mts"; + +const EXPECTED_VERSION = "2026.5.27"; +const PROGRESSIVE_CONFIG = { + tools: { + toolSearch: { + mode: "tools", + searchDefaultLimit: 8, + maxSearchLimit: 20, + }, + }, +}; + +const RUNTIME_FIXTURE_SOURCE = String.raw` +const CONTROL_NAMES = new Set(["tool_search_code", "tool_search", "tool_describe", "tool_call"]); + +function readConfig(config) { + return config && config.tools ? config.tools.toolSearch : undefined; +} + +function resolveToolSearchConfig(config) { + const raw = readConfig(config); + if (raw === false) { + return { + enabled: false, + mode: "tools", + searchDefaultLimit: 8, + maxSearchLimit: 20, + }; + } + const value = raw && typeof raw === "object" ? raw : {}; + return { + enabled: Object.keys(value).length > 0, + mode: value.mode === "tools" ? "tools" : "code", + searchDefaultLimit: value.searchDefaultLimit || 8, + maxSearchLimit: value.maxSearchLimit || 20, + }; +} + +function payload(value) { + return { + content: [{ type: "text", text: JSON.stringify(value) }], + details: value, + }; +} + +function catalogEntry(tool) { + return { + id: "openclaw:core:" + tool.name, + name: tool.name, + label: tool.label, + description: tool.description || "", + parameters: tool.parameters, + tool, + }; +} + +function findEntry(catalogRef, id) { + const entries = catalogRef.current || []; + const entry = entries.find((candidate) => candidate.id === id || candidate.name === id); + if (!entry) throw new Error("Unknown tool id: " + id); + return entry; +} + +function createOpenClawCodingTools(options) { + const config = resolveToolSearchConfig(options && options.config); + if (!options || options.includeToolSearchControls !== true || !config.enabled) return []; + const catalogRef = options.toolSearchCatalogRef; + return [ + { + name: "tool_search_code", + execute: async () => payload({ mode: "code" }), + }, + { + name: "tool_search", + execute: async (_id, args) => { + const query = String(args.query || "").toLowerCase(); + const matches = (catalogRef.current || []) + .filter((entry) => + (entry.name + " " + entry.label + " " + entry.description) + .toLowerCase() + .includes(query), + ) + .slice(0, args.limit || config.searchDefaultLimit) + .map(({ tool, parameters, ...entry }) => entry); + return payload(matches); + }, + }, + { + name: "tool_describe", + execute: async (_id, args) => { + const entry = findEntry(catalogRef, args.id); + return payload({ + id: entry.id, + name: entry.name, + label: entry.label, + description: entry.description, + parameters: entry.parameters, + }); + }, + }, + { + name: "tool_call", + execute: async (toolCallId, args, signal, onUpdate) => { + const entry = findEntry(catalogRef, args.id); + const result = await entry.tool.execute(toolCallId, args.args || {}, signal, onUpdate); + return payload({ + tool: { id: entry.id, name: entry.name }, + result, + }); + }, + }, + ]; +} + +function applyToolSearchCatalog(params) { + const config = resolveToolSearchConfig(params.config); + if (!config.enabled) { + return { + tools: params.tools, + compacted: false, + catalogToolCount: 0, + catalogRegistered: false, + }; + } + const visibleNames = + config.mode === "tools" + ? new Set(["tool_search", "tool_describe", "tool_call"]) + : new Set(["tool_search_code"]); + const catalog = params.tools + .filter((tool) => !CONTROL_NAMES.has(tool.name)) + .map((tool) => catalogEntry(tool)); + params.catalogRef.current = catalog; + return { + tools: params.tools.filter((tool) => visibleNames.has(tool.name)), + compacted: catalog.length > 0, + catalogToolCount: catalog.length, + catalogRegistered: true, + }; +} + +export { + resolveToolSearchConfig as _, + createOpenClawCodingTools as t, + applyToolSearchCatalog as p, +}; +`; + +interface FixtureOptions { + config?: unknown; + source?: string; + version?: string; + secondSource?: string; +} + +let tmpDir: string; +let fixtureNumber = 0; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-tool-search-validator-test-")); + fixtureNumber = 0; +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeFixture(options: FixtureOptions = {}) { + const root = path.join(tmpDir, `fixture-${fixtureNumber++}`); + const distDir = path.join(root, "dist"); + const configPath = path.join(root, "openclaw.json"); + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ type: "module", version: options.version ?? EXPECTED_VERSION }), + ); + const runtimeSources: ReadonlyArray = [ + ["pi-tools-fixture.js", options.source ?? RUNTIME_FIXTURE_SOURCE], + ...(options.secondSource === undefined + ? [] + : [["pi-tools-second.js", options.secondSource] as const]), + ]; + for (const [name, source] of runtimeSources) { + fs.writeFileSync(path.join(distDir, name), source); + } + fs.writeFileSync(configPath, JSON.stringify(options.config ?? PROGRESSIVE_CONFIG)); + return { distDir, configPath }; +} + +async function validateFixture( + fixture: ReturnType, + expectedMode: "progressive" | "direct", + expectedVersion = EXPECTED_VERSION, +) { + return validateOpenClawToolSearchRuntime({ + ...fixture, + expectedMode, + expectedVersion, + }); +} + +describe("OpenClaw Tool Search pinned-runtime validator", () => { + it("proves structured progressive search, describe, and call through compiled aliases", async () => { + const result = await validateFixture(writeFixture(), "progressive"); + + expect(result.version).toBe(EXPECTED_VERSION); + expect(result.expectedMode).toBe("progressive"); + expect(result.runtimeModulePath).toMatch(/pi-tools-fixture\.js$/); + expect(result.visibleToolNames.sort()).toEqual(["tool_call", "tool_describe", "tool_search"]); + }); + + it("proves direct mode preserves the hidden probe without search controls", async () => { + const fixture = writeFixture({ config: { tools: { toolSearch: false } } }); + const result = await validateFixture(fixture, "direct"); + + expect(result.visibleToolNames).toEqual(["nemoclaw_runtime_validator_probe"]); + }); + + it("fails closed when package metadata does not match the expected pin", async () => { + const fixture = writeFixture({ version: "2026.5.28" }); + + await expect(validateFixture(fixture, "progressive")).rejects.toThrow( + /version mismatch.*expected 2026\.5\.27, found 2026\.5\.28/, + ); + }); + + it("fails closed when the compiled source shape or export aliases drift", async () => { + const missingFunction = writeFixture({ + source: RUNTIME_FIXTURE_SOURCE.replace( + "function applyToolSearchCatalog(params)", + "function renamedApplyToolSearchCatalog(params)", + ), + }); + await expect(validateFixture(missingFunction, "progressive")).rejects.toThrow( + /expected exactly one pi-tools-.*found 0/, + ); + + const missingExport = writeFixture({ + source: RUNTIME_FIXTURE_SOURCE.replace(" applyToolSearchCatalog as p,\n", ""), + }); + await expect(validateFixture(missingExport, "progressive")).rejects.toThrow( + /does not export compiled function applyToolSearchCatalog/, + ); + + const duplicate = writeFixture({ secondSource: RUNTIME_FIXTURE_SOURCE }); + await expect(validateFixture(duplicate, "progressive")).rejects.toThrow( + /expected exactly one pi-tools-.*found 2/, + ); + }); + + it("fails closed for non-exact progressive and direct generated config", async () => { + const wrongProgressive = writeFixture({ + config: { + tools: { + toolSearch: { mode: "tools", searchDefaultLimit: 7, maxSearchLimit: 20 }, + }, + }, + }); + await expect(validateFixture(wrongProgressive, "progressive")).rejects.toThrow( + /must set tools\.toolSearch to exactly/, + ); + + const wrongDirect = writeFixture(); + await expect(validateFixture(wrongDirect, "direct")).rejects.toThrow( + /must set tools\.toolSearch to false/, + ); + }); +}); diff --git a/test/registry.test.ts b/test/registry.test.ts index 0ab05f4e2b1..6784d2e7ce1 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -93,16 +93,27 @@ describe("registry", () => { registry.registerSandbox({ name: "alpha", webSearchEnabled: true, + toolDisclosure: "direct", fromDockerfile: "/tmp/Dockerfile.custom", hermesAuthMethod: "oauth", }); expect(registry.getSandbox("alpha")).toMatchObject({ webSearchEnabled: true, + toolDisclosure: "direct", fromDockerfile: "/tmp/Dockerfile.custom", hermesAuthMethod: "oauth", }); }); + it("preserves missing tool-disclosure state on reconstructed legacy rows", () => { + registry.registerSandbox({ name: "legacy" }); + + const entry = registry.getSandbox("legacy"); + const data = JSON.parse(fs.readFileSync(regFile, "utf-8")); + expect(entry.toolDisclosure).toBeUndefined(); + expect(data.sandboxes.legacy.toolDisclosure).toBeUndefined(); + }); + it("stores normalized compatible-endpoint reasoning state", () => { registry.registerSandbox({ name: "alpha", diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index dbe8eb33b98..dc90bb29fc9 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -80,6 +80,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "openclaw-config-guard.py")); writeFixture(path.join("scripts", "codex-acp-wrapper.sh")); writeFixture(path.join("scripts", "generate-openclaw-config.mts")); + writeFixture(path.join("scripts", "validate-openclaw-tool-search.mts")); writeFixture( path.join("scripts", "checks", "verify-openshell-policy-boundary-dependencies.mts"), ); @@ -95,6 +96,7 @@ describe("sandbox build context staging", () => { writeFixture( path.join("src", "lib", "messaging", "channels", "fixture", "hooks", "example.ts"), ); + writeFixture(path.join("src", "lib", "tool-disclosure.ts")); writeFixture(path.join("scripts", "patch-openclaw-tool-catalog.js")); writeFixture(path.join("scripts", "patch-openclaw-chat-send.js")); } @@ -156,6 +158,10 @@ describe("sandbox build context staging", () => { ); } + function expectStagedToolDisclosureContract(buildCtx: string) { + expect(fs.existsSync(path.join(buildCtx, "src", "lib", "tool-disclosure.ts"))).toBe(true); + } + it("normalizes copied blueprint modes with chmod a+rX semantics", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-context-unit-")); const blueprintDir = path.join(tmpDir, "nemoclaw-blueprint"); @@ -197,6 +203,7 @@ describe("sandbox build context staging", () => { const { buildCtx } = stageOptimizedSandboxBuildContext(sourceRoot, tmpDir); expectStagedBlueprintModes(buildCtx); expectStagedMcporterRuntime(buildCtx); + expectStagedToolDisclosureContract(buildCtx); } finally { fs.rmSync(sourceRoot, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -226,6 +233,7 @@ describe("sandbox build context staging", () => { const { buildCtx } = stageLegacySandboxBuildContext(sourceRoot, tmpDir); expectStagedBlueprintModes(buildCtx); expectStagedMcporterRuntime(buildCtx); + expectStagedToolDisclosureContract(buildCtx); } finally { fs.rmSync(sourceRoot, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -300,6 +308,9 @@ describe("sandbox build context staging", () => { expect(fs.existsSync(path.join(buildCtx, "scripts", "generate-openclaw-config.mts"))).toBe( true, ); + expect( + fs.existsSync(path.join(buildCtx, "scripts", "validate-openclaw-tool-search.mts")), + ).toBe(true); expect( fs.existsSync( path.join( diff --git a/test/sandbox-provisioning-helper-permissions.test.ts b/test/sandbox-provisioning-helper-permissions.test.ts index f2481852b51..36cda3481df 100644 --- a/test/sandbox-provisioning-helper-permissions.test.ts +++ b/test/sandbox-provisioning-helper-permissions.test.ts @@ -104,6 +104,8 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () const localSrc = path.join(tmp, "src"); const localScripts = path.join(tmp, "scripts"); const generatorPath = path.join(localScripts, "generate-openclaw-config.mts"); + const toolSearchValidatorPath = path.join(localScripts, "validate-openclaw-tool-search.mts"); + const toolDisclosurePath = path.join(localSrc, "lib", "tool-disclosure.ts"); const applierPath = path.join( localSrc, "lib", @@ -144,6 +146,8 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () path.join(localLib, "clean_runtime_shell_env_shim.py"), path.join(localLib, "normalize_mutable_config_perms.py"), generatorPath, + toolSearchValidatorPath, + toolDisclosurePath, applierPath, messagingHookPath, path.join(localLib, "ws-proxy-fix.js"), @@ -177,6 +181,8 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () expect(result.status, result.stderr).toBe(0); expect((fs.statSync(generatorPath).mode & 0o777).toString(8)).toBe("755"); + expect((fs.statSync(toolSearchValidatorPath).mode & 0o777).toString(8)).toBe("755"); + expect((fs.statSync(toolDisclosurePath).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(applierPath).mode & 0o777).toString(8)).toBe("755"); expect((fs.statSync(messagingHookPath).mode & 0o777).toString(8)).toBe("644"); expect(