diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index ce912e8aa20..9c0e68eb9f0 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -62,6 +62,7 @@ ARG NEMOCLAW_UPSTREAM_PROVIDER=nvidia ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1 ARG NEMOCLAW_INFERENCE_API=openai-completions ARG NEMOCLAW_TOOL_DISCLOSURE=progressive +ARG NEMOCLAW_DCODE_AUTO_APPROVAL=disabled ARG NEMOCLAW_BUILD_ID=default ARG NEMOCLAW_DARWIN_VM_COMPAT=0 ARG NEMOCLAW_PROXY_HOST=10.200.0.1 @@ -70,6 +71,10 @@ ARG NEMOCLAW_PROXY_PORT=3128 RUN case "$NEMOCLAW_TOOL_DISCLOSURE" in \ progressive|direct) ;; \ *) echo "ERROR: NEMOCLAW_TOOL_DISCLOSURE must be progressive or direct" >&2; exit 1 ;; \ + esac \ + && case "$NEMOCLAW_DCODE_AUTO_APPROVAL" in \ + disabled|thread-opt-in) ;; \ + *) echo "ERROR: NEMOCLAW_DCODE_AUTO_APPROVAL must be disabled or thread-opt-in" >&2; exit 1 ;; \ esac # The launcher and startup script read these root-owned files instead of @@ -81,8 +86,9 @@ RUN install -d -m 0755 /usr/local/share/nemoclaw \ && printf '%s\n' "$NEMOCLAW_PROXY_HOST" > /usr/local/share/nemoclaw/dcode-proxy-host \ && printf '%s\n' "$NEMOCLAW_PROXY_PORT" > /usr/local/share/nemoclaw/dcode-proxy-port \ && printf '%s\n' "$NEMOCLAW_INFERENCE_BASE_URL" > /usr/local/share/nemoclaw/dcode-inference-base-url \ - && chown root:root /usr/local/share/nemoclaw/dcode-proxy-host /usr/local/share/nemoclaw/dcode-proxy-port /usr/local/share/nemoclaw/dcode-inference-base-url \ - && chmod 0444 /usr/local/share/nemoclaw/dcode-proxy-host /usr/local/share/nemoclaw/dcode-proxy-port /usr/local/share/nemoclaw/dcode-inference-base-url \ + && printf '%s\n' "$NEMOCLAW_DCODE_AUTO_APPROVAL" > /usr/local/share/nemoclaw/dcode-auto-approval \ + && chown root:root /usr/local/share/nemoclaw/dcode-proxy-host /usr/local/share/nemoclaw/dcode-proxy-port /usr/local/share/nemoclaw/dcode-inference-base-url /usr/local/share/nemoclaw/dcode-auto-approval \ + && chmod 0444 /usr/local/share/nemoclaw/dcode-proxy-host /usr/local/share/nemoclaw/dcode-proxy-port /usr/local/share/nemoclaw/dcode-inference-base-url /usr/local/share/nemoclaw/dcode-auto-approval \ && empty_prompt_log="$(mktemp)" \ && if timeout 10 /usr/local/bin/dcode -n "" >"$empty_prompt_log" 2>&1; then empty_prompt_status=0; else empty_prompt_status=$?; fi \ && test "$empty_prompt_status" -eq 2 \ diff --git a/agents/langchain-deepagents-code/dcode-launcher.sh b/agents/langchain-deepagents-code/dcode-launcher.sh index 5c1943df698..d707730f531 100755 --- a/agents/langchain-deepagents-code/dcode-launcher.sh +++ b/agents/langchain-deepagents-code/dcode-launcher.sh @@ -6,6 +6,10 @@ set -euo pipefail unset BASH_ENV ENV +while IFS= read -r _nemoclaw_auto_approval_env; do + unset "$_nemoclaw_auto_approval_env" +done < <(compgen -A variable NEMOCLAW_DCODE_AUTO_APPROVAL || true) +unset _nemoclaw_auto_approval_env readonly MANAGED_DCODE_WRAPPER="/usr/local/lib/nemoclaw/dcode-wrapper.sh" readonly MANAGED_OBSERVABILITY_MARKER="/tmp/nemoclaw-observability-enabled" diff --git a/agents/langchain-deepagents-code/dcode-wrapper.sh b/agents/langchain-deepagents-code/dcode-wrapper.sh index 97f1ea511b6..7689533d0b8 100755 --- a/agents/langchain-deepagents-code/dcode-wrapper.sh +++ b/agents/langchain-deepagents-code/dcode-wrapper.sh @@ -12,6 +12,10 @@ if [ "${1:-}" = "--nemoclaw-mcp-capability" ] && [ "$#" -eq 1 ]; then fi unset BASH_ENV ENV OPENAI_PROXY +while IFS= read -r _nemoclaw_auto_approval_env; do + unset "$_nemoclaw_auto_approval_env" +done < <(compgen -A variable NEMOCLAW_DCODE_AUTO_APPROVAL || true) +unset _nemoclaw_auto_approval_env export HOME=/sandbox export PATH="/usr/local/bin:/opt/venv/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin" @@ -40,6 +44,49 @@ readonly DEEPAGENTS_CONFIG_FILE="/sandbox/.deepagents/config.toml" readonly OPENSHELL_TLS_KEY_PATH="/etc/openshell/tls/client/tls.key" readonly DEEPAGENTS_AUTH_FILE="/sandbox/.deepagents/.state/auth.json" readonly DEEPAGENTS_CODEX_AUTH_FILE="/sandbox/.deepagents/.state/chatgpt-auth.json" +readonly MANAGED_DCODE_AUTO_APPROVAL_FILE="/usr/local/share/nemoclaw/dcode-auto-approval" +readonly MANAGED_DCODE_AUTO_APPROVAL_OWNER_UID=0 + +managed_auto_approval_file_metadata() { + local file="$1" + local metadata + if metadata="$(stat -c '%u:%a:%s' "$file" 2>/dev/null)"; then + printf '%s' "$metadata" + else + stat -f '%u:%Lp:%z' "$file" 2>/dev/null + fi +} + +read_managed_auto_approval_mode() { + local file="$MANAGED_DCODE_AUTO_APPROVAL_FILE" + local metadata + if [ ! -f "$file" ] || [ -L "$file" ] || [ ! -r "$file" ]; then + printf '%s' 'disabled' + return 0 + fi + metadata="$(managed_auto_approval_file_metadata "$file")" || { + printf '%s' 'disabled' + return 0 + } + case "$metadata" in + "${MANAGED_DCODE_AUTO_APPROVAL_OWNER_UID}:444:9") + if cmp -s -- "$file" <(printf '%s\n' 'disabled'); then + printf '%s' 'disabled' + return 0 + fi + ;; + "${MANAGED_DCODE_AUTO_APPROVAL_OWNER_UID}:444:14") + if cmp -s -- "$file" <(printf '%s\n' 'thread-opt-in'); then + printf '%s' 'thread-opt-in' + return 0 + fi + ;; + esac + printf '%s' 'disabled' +} + +MANAGED_DCODE_AUTO_APPROVAL_MODE="$(read_managed_auto_approval_mode)" +readonly MANAGED_DCODE_AUTO_APPROVAL_MODE run_dcode() { unset PYTHONHOME PYTHONPATH @@ -789,7 +836,9 @@ for arg in "$@"; do reject_managed_override "interpreter posture" "$arg" ;; -y | --auto-a | --auto-ap | --auto-app | --auto-appr | --auto-appro | --auto-approv | --auto-approve) - reject_managed_override "tool approval posture" "$arg" + if [ "$MANAGED_DCODE_AUTO_APPROVAL_MODE" != "thread-opt-in" ]; then + reject_managed_override "tool approval posture" "$arg" + fi ;; --acp) reject_managed_override "ACP approval posture" "$arg" diff --git a/agents/langchain-deepagents-code/managed-dcode-runtime.py b/agents/langchain-deepagents-code/managed-dcode-runtime.py index 2da6f2e515e..22a16c125a2 100644 --- a/agents/langchain-deepagents-code/managed-dcode-runtime.py +++ b/agents/langchain-deepagents-code/managed-dcode-runtime.py @@ -13,6 +13,7 @@ import os import re import stat +import sys from pathlib import Path from urllib.parse import urlparse, urlsplit @@ -23,6 +24,15 @@ _INFERENCE_BASE_URL_FILE = Path( "/usr/local/share/nemoclaw/dcode-inference-base-url" ) +_AUTO_APPROVAL_FILE = Path( + "/usr/local/share/nemoclaw/dcode-auto-approval" +) +_AUTO_APPROVAL_DISABLED = "disabled" +_AUTO_APPROVAL_THREAD_OPT_IN = "thread-opt-in" +_AUTO_APPROVAL_CONTENTS = { + b"disabled\n": _AUTO_APPROVAL_DISABLED, + b"thread-opt-in\n": _AUTO_APPROVAL_THREAD_OPT_IN, +} _MANAGED_FILE_OWNER_UID = 0 _CREDENTIAL_NAME = re.compile( r"(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASS|CREDENTIAL)$", @@ -864,6 +874,71 @@ def managed_inference_base_url() -> str: return value +def _disabled_auto_approval(reason: str) -> str: + if os.environ.get("NEMOCLAW_DEBUG") == "1": + print( + f"NemoClaw managed auto-approval disabled: {reason}", + file=sys.stderr, + ) + return _AUTO_APPROVAL_DISABLED + + +def managed_auto_approval_mode() -> str: + """Return the trusted managed auto-approval mode, failing closed.""" + # The image build owns this file, but runtime must tolerate missing or + # malformed image state and fail closed. Keep this check until sandbox + # images are immutable end to end; direct-module tests pin rejected shapes. + path = _AUTO_APPROVAL_FILE + try: + if path.is_symlink(): + return _disabled_auto_approval("capability path is a symlink") + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(path, flags) + except OSError: + return _disabled_auto_approval("capability file is missing or unreadable") + + try: + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != _MANAGED_FILE_OWNER_UID + or stat.S_IMODE(metadata.st_mode) != 0o444 + or metadata.st_size not in { + len(content) for content in _AUTO_APPROVAL_CONTENTS + } + ): + return _disabled_auto_approval("capability metadata is unsafe") + + chunks: list[bytes] = [] + remaining = metadata.st_size + while remaining: + chunk = os.read(descriptor, remaining) + if not chunk: + return _disabled_auto_approval("capability file was truncated") + chunks.append(chunk) + remaining -= len(chunk) + if os.read(descriptor, 1): + return _disabled_auto_approval("capability file changed while reading") + except OSError: + return _disabled_auto_approval("capability file read failed") + finally: + try: + os.close(descriptor) + except OSError: + # Cleanup cannot weaken the fail-closed capability result. + pass + + return _AUTO_APPROVAL_CONTENTS.get(b"".join(chunks)) or _disabled_auto_approval( + "capability contents are invalid" + ) + + +def managed_auto_approval_enabled() -> bool: + """Return whether thread-scoped auto-approval may be explicitly enabled.""" + return managed_auto_approval_mode() == _AUTO_APPROVAL_THREAD_OPT_IN + + def managed_display_provider(adapter_provider: object) -> str: """Return the provider label to show for the managed inference adapter. diff --git a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py index f75a019af69..924e9a17fc7 100644 --- a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py +++ b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py @@ -85,6 +85,14 @@ os.environ.pop("PYTHONPATH", None) os.environ.pop("OPENAI_PROXY", None) + from deepagents_code._nemoclaw_managed import ( + assert_safe_runtime as _nemoclaw_assert_safe_runtime, + managed_auto_approval_enabled as _nemoclaw_managed_auto_approval_enabled, + managed_mcp_config_path as _nemoclaw_managed_mcp_config_path, + ) + + nemoclaw_auto_approval_enabled = _nemoclaw_managed_auto_approval_enabled() + blocked_command = getattr(args, "command", None) if blocked_command == "mcp": parser.error("MCP commands are disabled in NemoClaw-managed Deep Agents Code sandboxes") @@ -108,7 +116,7 @@ parser.error("--interpreter-tools is disabled in NemoClaw-managed Deep Agents Code sandboxes") if getattr(args, "interpreter", None) is True: parser.error("--interpreter is disabled in NemoClaw-managed Deep Agents Code sandboxes") - if getattr(args, "auto_approve", False): + if getattr(args, "auto_approve", False) and not nemoclaw_auto_approval_enabled: parser.error("--auto-approve is disabled in NemoClaw-managed Deep Agents Code sandboxes") if getattr(args, "acp", False): parser.error("--acp is disabled in NemoClaw-managed Deep Agents Code sandboxes") @@ -121,11 +129,6 @@ args.sandbox_snapshot_name = None if hasattr(args, "sandbox_setup"): args.sandbox_setup = None - from deepagents_code._nemoclaw_managed import ( - assert_safe_runtime as _nemoclaw_assert_safe_runtime, - managed_mcp_config_path as _nemoclaw_managed_mcp_config_path, - ) - # Load only NemoClaw's dedicated projection. The helper canonicalizes it # into a process-local integrity-bound snapshot; user/project discovery is # disabled separately in the patched MCP loader. @@ -143,7 +146,7 @@ args.interpreter = False if hasattr(args, "interpreter_tools"): args.interpreter_tools = None - if hasattr(args, "auto_approve"): + if hasattr(args, "auto_approve") and not nemoclaw_auto_approval_enabled: args.auto_approve = False if hasattr(args, "rubric_model"): args.rubric_model = None @@ -153,6 +156,17 @@ args.startup_cmd = None _nemoclaw_assert_safe_runtime() + if ( + getattr(args, "auto_approve", False) + and nemoclaw_auto_approval_enabled + and not getattr(args, "non_interactive_message", None) + ): + print( + "WARNING: Auto-approval is enabled for this thread. Tool calls, " + "including shell commands, may execute without further confirmation " + "inside the sandbox.", + file=sys.stderr, + ) ''' APP_PATCH = r''' @@ -162,13 +176,35 @@ "NemoClaw manages credentials, dependencies, updates, and MCP for this " "sandbox. Use NemoClaw policy/configuration on the host instead." ) +_NEMOCLAW_AUTO_APPROVAL_DISABLED_MESSAGE = ( + "Auto-approval is disabled in NemoClaw-managed sandboxes." +) +_NEMOCLAW_AUTO_APPROVAL_WARNING = ( + "Auto-approval is enabled for this thread. Tool calls, including shell " + "commands, may execute without further confirmation inside the sandbox." +) _nemoclaw_original_handle_command = DeepAgentsApp._handle_command +_nemoclaw_original_resume_thread = DeepAgentsApp._resume_thread +_nemoclaw_original_restart_server_for_agent_swap = ( + DeepAgentsApp._restart_server_for_agent_swap +) _nemoclaw_original_switch_model = DeepAgentsApp._switch_model +_nemoclaw_original_on_auto_approve_enabled = ( + DeepAgentsApp._on_auto_approve_enabled +) +_nemoclaw_original_action_toggle_auto_approve = ( + DeepAgentsApp.action_toggle_auto_approve +) _nemoclaw_original_absolutize_launch_relative_path = ( DeepAgentsApp._absolutize_launch_relative_path ) +async def _nemoclaw_run_thread_transition(self, operation, *args) -> None: + _nemoclaw_reset_thread_auto_approval(self) + await operation(self, *args) + + async def _nemoclaw_handle_command(self, command: str) -> None: normalized = command.lower().strip() tokens = normalized.split() @@ -186,7 +222,26 @@ async def _nemoclaw_handle_command(self, command: str) -> None: await self._mount_message(UserMessage(command)) await self._mount_message(AppMessage(_NEMOCLAW_MANAGED_UI_MESSAGE)) return - await _nemoclaw_original_handle_command(self, command) + if normalized not in {"/clear", "/force-clear"}: + await _nemoclaw_original_handle_command(self, command) + return + await _nemoclaw_run_thread_transition( + self, _nemoclaw_original_handle_command, command + ) + + +async def _nemoclaw_resume_thread(self, thread_id: str) -> None: + await _nemoclaw_run_thread_transition( + self, _nemoclaw_original_resume_thread, thread_id + ) + + +async def _nemoclaw_restart_server_for_agent_swap( + self, agent_name: str +) -> None: + await _nemoclaw_run_thread_transition( + self, _nemoclaw_original_restart_server_for_agent_swap, agent_name + ) async def _nemoclaw_switch_model( @@ -253,19 +308,57 @@ async def _nemoclaw_block_auto_update(self) -> None: self.notify(_NEMOCLAW_MANAGED_UI_MESSAGE, severity="warning", markup=False) -async def _nemoclaw_block_auto_approve(self) -> None: +def _nemoclaw_auto_approval_is_allowed() -> bool: + from deepagents_code._nemoclaw_managed import managed_auto_approval_enabled + + return managed_auto_approval_enabled() + + +def _nemoclaw_reset_thread_auto_approval(self) -> None: self._auto_approve = False if getattr(self, "_status_bar", None) is not None: self._status_bar.set_auto_approve(enabled=False) if getattr(self, "_session_state", None) is not None: self._session_state.auto_approve = False + self._session_state.approval_mode_key = None + + +async def _nemoclaw_block_auto_approve(self) -> None: + _nemoclaw_reset_thread_auto_approval(self) self.notify( - "Auto-approval is disabled in NemoClaw-managed sandboxes.", + _NEMOCLAW_AUTO_APPROVAL_DISABLED_MESSAGE, severity="warning", markup=False, ) +def _nemoclaw_notify_auto_approval_warning(self) -> None: + self.notify( + _NEMOCLAW_AUTO_APPROVAL_WARNING, + severity="warning", + markup=False, + ) + + +async def _nemoclaw_on_auto_approve_enabled(self) -> None: + if not _nemoclaw_auto_approval_is_allowed(): + await _nemoclaw_block_auto_approve(self) + return + await _nemoclaw_original_on_auto_approve_enabled(self) + if getattr(self, "_auto_approve", False): + _nemoclaw_notify_auto_approval_warning(self) + + +async def _nemoclaw_action_toggle_auto_approve(self) -> None: + if not _nemoclaw_auto_approval_is_allowed(): + await _nemoclaw_block_auto_approve(self) + return + was_enabled = bool(getattr(self, "_auto_approve", False)) + await _nemoclaw_original_action_toggle_auto_approve(self) + if not was_enabled and getattr(self, "_auto_approve", False): + _nemoclaw_notify_auto_approval_warning(self) + + async def _nemoclaw_block_rubric_model(self, model_spec: str | None) -> None: self._rubric_model = None if getattr(self, "_server_kwargs", None) is not None: @@ -332,6 +425,10 @@ def _nemoclaw_block_mcp_login(self, server_name: str) -> None: DeepAgentsApp._handle_command = _nemoclaw_handle_command +DeepAgentsApp._resume_thread = _nemoclaw_resume_thread +DeepAgentsApp._restart_server_for_agent_swap = ( + _nemoclaw_restart_server_for_agent_swap +) DeepAgentsApp._switch_model = _nemoclaw_switch_model DeepAgentsApp._absolutize_launch_relative_path = staticmethod( _nemoclaw_absolutize_launch_relative_path @@ -342,8 +439,10 @@ def _nemoclaw_block_mcp_login(self, server_name: str) -> None: DeepAgentsApp._install_extra = _nemoclaw_block_install_extra DeepAgentsApp._handle_install_package = _nemoclaw_block_install_package DeepAgentsApp._handle_auto_update_toggle = _nemoclaw_block_auto_update -DeepAgentsApp._on_auto_approve_enabled = _nemoclaw_block_auto_approve -DeepAgentsApp.action_toggle_auto_approve = _nemoclaw_block_auto_approve +DeepAgentsApp._on_auto_approve_enabled = _nemoclaw_on_auto_approve_enabled +DeepAgentsApp.action_toggle_auto_approve = ( + _nemoclaw_action_toggle_auto_approve +) DeepAgentsApp._set_rubric_model = _nemoclaw_block_rubric_model DeepAgentsApp._prompt_launch_tavily = _nemoclaw_skip_launch_tavily DeepAgentsApp._prompt_launch_dependencies_then_model = _nemoclaw_skip_launch_model @@ -653,16 +752,26 @@ async def _run_startup_command(command, console, *, quiet: bool) -> None: APPROVAL_PATCH = r''' # NemoClaw-managed Deep Agents Code hardening v2. +_NEMOCLAW_AUTO_APPROVAL_DISABLED_MESSAGE = ( + "Auto-approval is disabled in NemoClaw-managed sandboxes." +) _nemoclaw_original_approval_selection = ApprovalMenu._handle_selection def _nemoclaw_handle_approval_selection( self, option: int, *, reject_message: str | None = None ) -> None: - """Refuse the thread-wide auto-approval choice without approving this batch.""" + """Gate the thread-wide auto-approval choice on the managed capability.""" if option == 1: + from deepagents_code._nemoclaw_managed import managed_auto_approval_enabled + + if managed_auto_approval_enabled(): + _nemoclaw_original_approval_selection( + self, option, reject_message=reject_message + ) + return self.app.notify( - "Auto-approval is disabled in NemoClaw-managed sandboxes.", + _NEMOCLAW_AUTO_APPROVAL_DISABLED_MESSAGE, severity="warning", markup=False, ) @@ -1161,6 +1270,10 @@ def main() -> None: else "" ) analytics_guard = 'os.environ["LANGGRAPH_CLI_NO_ANALYTICS"] = "1"' + auto_approval_guards = ( + "def managed_auto_approval_mode() -> str:", + "def managed_auto_approval_enabled() -> bool:", + ) if ( PATCH_MARKER not in helper_source or sum( @@ -1168,6 +1281,14 @@ def main() -> None: for line in helper_source.splitlines() ) != 1 + or any( + sum( + line.strip() == guard + for line in helper_source.splitlines() + ) + != 1 + for guard in auto_approval_guards + ) ): raise RuntimeError( "Managed package patch is partial: helper is missing or stale" @@ -1189,6 +1310,8 @@ def main() -> None: for name, patch in ( ("entrypoint", ENTRYPOINT_PATCH), ("main", MAIN_PATCH), + ("app", APP_PATCH), + ("approval", APPROVAL_PATCH), ("agent", AGENT_PATCH), ("status", STATUS_PATCH), ("welcome", WELCOME_PATCH), @@ -1229,6 +1352,8 @@ def main() -> None: "_handle_command", "_handle_install_command", "_handle_install_package", + "_restart_server_for_agent_swap", + "_resume_thread", "_handle_update_action", "_handle_update_command", "_install_extra", diff --git a/agents/langchain-deepagents-code/start.sh b/agents/langchain-deepagents-code/start.sh index 3be24805038..31c56b571cf 100755 --- a/agents/langchain-deepagents-code/start.sh +++ b/agents/langchain-deepagents-code/start.sh @@ -6,6 +6,10 @@ set -euo pipefail unset BASH_ENV ENV +while IFS= read -r _nemoclaw_auto_approval_env; do + unset "$_nemoclaw_auto_approval_env" +done < <(compgen -A variable NEMOCLAW_DCODE_AUTO_APPROVAL || true) +unset _nemoclaw_auto_approval_env export HOME=/sandbox export PATH="/usr/local/bin:/opt/venv/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin" diff --git a/docs/about/release-notes.mdx b/docs/about/release-notes.mdx index 348d0bda886..27846913903 100644 --- a/docs/about/release-notes.mdx +++ b/docs/about/release-notes.mdx @@ -16,6 +16,17 @@ NVIDIA NemoClaw is available in early preview starting March 16, 2026. Use this page to track the highlights of the latest release. For more detailed release notes, refer to the [NemoClaw GitHub announcements](https://github.com/NVIDIA/NemoClaw/discussions/categories/announcements?discussions_q=is%3Aopen+category%3AAnnouncements). +## Unreleased + +The next NemoClaw release adds an optional thread-scoped auto-approval capability for managed LangChain Deep Agents Code sandboxes. + +- Deep Agents interactive sessions keep auto-approval disabled by default. + Operators can enable the `thread-opt-in` capability through a named transactional rebuild, after which each thread must still activate it through the TUI approval choice or `dcode -y`. + Active state resets for a new process or thread, `/clear`, thread switching, and agent switching. + The root-owned capability fails closed on invalid state, ambient environment values cannot enable it, and OpenShell policy, credential, inference, MCP, filesystem, and process controls remain in force. + Headless `dcode -n` remains a separate automation boundary. + For more information, refer to [Quickstart with LangChain Deep Agents Code](/user-guide/deepagents/get-started/quickstart), [Security Best Practices](../security/best-practices), [Model Capability Audit](../inference/model-capability-audit), and [NemoClaw CLI Commands Reference](../reference/commands). + ## v0.0.77 NemoClaw v0.0.77 hardens LangChain Deep Agents Code packaging, tracing, and guided setup. The release publishes and validates the current Deep Agents sandbox base image, reduces telemetry credential exposure, reports the upstream provider selected during onboarding, and reuses a reviewed local credential form in starter prompts. diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index 6c0261c04c8..6cba0e788f3 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -125,13 +125,45 @@ Stdio commands, extra headers, raw credentials, and unrelated top-level configur For authenticated MCP setup and credential rotation, see [Set Up MCP Servers](../manage-sandboxes/set-up-mcp-servers). This isolated-mode guarantee applies to those managed launchers, not arbitrary Python commands in the sandbox. -Interactive shell execution and other destructive tools remain behind human-in-the-loop approval prompts. -Thread-wide auto-approval and shell allow-list auto-approval are disabled. +Interactive shell execution and other destructive tools remain behind human-in-the-loop approval prompts by default. +Thread-wide auto-approval is unavailable unless you enable the managed thread opt-in capability, and shell allow-list auto-approval remains disabled. Headless `dcode -n` is an explicit automation boundary. It has no approval UI and automatically approves non-shell tool requests, including file writes and edits. The managed headless path still disables shell execution, startup commands, interpreter tool calling, executable hooks, unmanaged MCP configuration, nested remote sandboxes, remote async subagents, and alternate model routes. Use the interactive TUI when you need to inspect each destructive tool request before it runs. +### Configure Thread Auto-Approval + +Managed Deep Agents sandboxes keep interactive thread auto-approval disabled by default. +In this mode, the TUI auto-approval choice and `dcode -y` fail closed. + +Enable the capability for a named sandbox through a transactional rebuild because NemoClaw bakes the capability into the managed image. + +```bash +nemo-deepagents rebuild --dcode-auto-approval thread-opt-in --yes +``` + +The `thread-opt-in` setting grants permission to activate auto-approval, but it does not activate auto-approval by itself. +For each thread, select **Auto-approve for this thread** in the approval menu or start that `dcode` process with `dcode -y`. +The TUI shows the upstream active-state indicator and a warning while the current thread can run tool calls, including shell commands, without further confirmation. + +NemoClaw resets the active state when you start a new `dcode` process, run `/clear` or `/force-clear`, switch or resume a different thread, or switch agents. +You must opt in again after each reset. +The host-side status command reports the configured capability, not whether one live TUI thread currently has auto-approval active. + +```bash +nemo-deepagents status +``` + +Thread auto-approval does not bypass OpenShell network policy, credential isolation, the managed inference route, managed MCP validation, or the other Deep Agents runtime restrictions described above. +Headless `dcode -n` remains a separate automation boundary with non-shell auto-approval and managed shell execution disabled. + +Return the sandbox to the default posture with another transactional rebuild. + +```bash +nemo-deepagents rebuild --dcode-auto-approval disabled --yes +``` + To confirm which sandbox a session is in, run the identity command: ```bash diff --git a/docs/inference/model-capability-audit.mdx b/docs/inference/model-capability-audit.mdx index 66fcb44d2ae..cc7ce79ad2d 100644 --- a/docs/inference/model-capability-audit.mdx +++ b/docs/inference/model-capability-audit.mdx @@ -69,9 +69,23 @@ Rows can remain `degraded`, `blocked`, or `not-yet-run` when a scenario cannot b | 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, 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. | -| Deep Agents path | `dcode status` reports the managed route, interactive `dcode` can complete a terminal task with approval prompts intact, and headless `dcode -n` can complete a bounded non-shell task while preserving the managed Chat Completions route. Keep interactive approval evidence separate from headless auto-approval evidence. | +| Deep Agents path | `dcode status` reports the managed route, interactive `dcode` can complete a terminal task with approval prompts intact, and headless `dcode -n` can complete a bounded non-shell task while preserving the managed Chat Completions route. When testing the optional `thread-opt-in` capability, record the configured host mode separately from the active TUI thread state, prove explicit activation and thread-boundary reset behavior, and rerun the policy and credential boundary checks. Keep default interactive, opted-in interactive, and headless evidence separate. | | 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. | +## Deep Agents Approval Evidence + +Use separate evidence for the default interactive posture, the optional thread-scoped capability, and headless automation. +Do not infer the active state of a TUI thread from the host-side configured mode. + +| Evidence case | Required checks | +|---|---| +| Default-disabled interactive | Host status reports `disabled`, the TUI auto-approval choice and `dcode -y` fail closed, and ordinary interactive tool requests still prompt. | +| Capability configuration | A named rebuild with `--dcode-auto-approval thread-opt-in` succeeds, host status reports `thread-opt-in`, and the operation records the exact NemoClaw commit and Deep Agents Code version. | +| Explicit thread activation | The operator selects **Auto-approve for this thread** or launches `dcode -y`, the TUI shows the active-state indicator and warning, and more than one tool call completes without another prompt in that same thread. | +| Thread reset | A new process, `/clear`, `/force-clear`, a thread switch or resume, and an agent switch each return to manual approval before another explicit opt-in. | +| Residual controls | Enabled-mode evidence repeats network-policy denial and credential non-disclosure checks and confirms that managed inference, MCP, filesystem, and process restrictions remain active. | +| Headless separation | `dcode -n` evidence remains in its own row and records non-shell auto-approval plus disabled shell execution without attributing that behavior to `thread-opt-in`. | + ## Audit Matrix These seed rows come from current repo source files, not from live benchmark claims. @@ -91,7 +105,7 @@ When importing a completed row from an issue comment, preserve the exact commit | 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. | 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`. | -| Deep Agents interactive `dcode` | NVIDIA Endpoints | `nvidia/nemotron-3-super-120b-a12b` | Managed `inference.local` OpenAI-compatible Chat Completions with `use_responses_api = false`. | `not-yet-run` | Add interactive terminal transcript, status output, and route evidence before changing state. | Managed `/sandbox/.deepagents/config.toml` forces the OpenAI-compatible route and disables Responses API for `dcode`. | Verify a terminal task with approval prompts intact and no provider credential in sandbox-visible files. | `agents/langchain-deepagents-code/generate-config.ts`, `docs/get-started/quickstart-langchain-deepagents-code`. | +| Deep Agents interactive `dcode` | NVIDIA Endpoints | `nvidia/nemotron-3-super-120b-a12b` | Managed `inference.local` OpenAI-compatible Chat Completions with `use_responses_api = false`. | `not-yet-run` | Add separate default-disabled and thread-opt-in terminal transcripts, host status output, reset evidence, and route evidence before changing state. | Managed `/sandbox/.deepagents/config.toml` forces the OpenAI-compatible route and disables Responses API for `dcode`; optional `thread-opt-in` remains a per-thread approval affordance. | Verify a terminal task with approval prompts intact, then separately verify explicit thread activation, reset behavior, policy enforcement, and no provider credential in sandbox-visible files. | `agents/langchain-deepagents-code/generate-config.ts`, `docs/get-started/quickstart-langchain-deepagents-code`. | | Deep Agents headless `dcode -n` | NVIDIA Endpoints | `nvidia/nemotron-3-super-120b-a12b` | Managed `inference.local` OpenAI-compatible Chat Completions with `use_responses_api = false`. | `not-yet-run` | Add headless command transcript and status output before changing state. | Headless mode has no approval UI and auto-approves non-shell tools while managed shell execution remains disabled. | Verify a bounded non-shell task and record the approval boundary separately from interactive evidence. | `agents/langchain-deepagents-code/dcode-wrapper.sh`, `docs/security/best-practices`. | ## Completed Row Template diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index d2f19e3c395..cd29d34f801 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1078,7 +1078,7 @@ Use this form when you care about a specific sandbox's live OpenShell state, age Do not pass a sandbox name to `$$nemoclaw status`; that command is the global all-sandbox/service overview. Pass `--json` to emit a structured per-sandbox report instead of the text renderer. -The JSON output includes at least `schemaVersion`, `name`, `found`, `model`, `provider`, `phase`, `gatewayState`, `inferenceHealth`, `rpcIssue`, `hostGpuDetected`, `sandboxGpuEnabled`, `sandboxGpuMode`, `sandboxGpuDevice`, `openshellDriver`, `openshellVersion`, `policies`, `failureLayer`, `terminalRuntimeHealth`, and `dockerPaused`. +The JSON output includes at least `schemaVersion`, `name`, `found`, `agent`, `agentDisplayName`, `agentRuntime`, `dcodeAutoApprovalMode`, `model`, `provider`, `phase`, `gatewayState`, `inferenceHealth`, `rpcIssue`, `hostGpuDetected`, `sandboxGpuEnabled`, `sandboxGpuMode`, `sandboxGpuDevice`, `openshellDriver`, `openshellVersion`, `policies`, `failureLayer`, `terminalRuntimeHealth`, and `dockerPaused`. `openshellDriver` and `openshellVersion` are always strings (falling back to `"unknown"` when the registry has no value), so consumers can rely on `typeof` checks. `failureLayer` is `null` when no preflight failure was detected and otherwise one of `docker_unreachable`, `sandbox_container_stopped`, or `sandbox_dashboard_port_conflict`; when set, `inferenceHealth` is suppressed to `null` so automation does not see a stale remote-provider healthy status during a local outage. `dockerPaused` is `true` when NemoClaw detects that the Docker-driver sandbox container is paused. @@ -1088,6 +1088,14 @@ If the counter records an OOM kill, text output prints `Runtime health: degraded The command exits non-zero when the sandbox is missing locally, the gateway state is not `present`, the gateway reports a schema/protobuf mismatch (mirrored as `rpcIssue`), `failureLayer` is non-null, the authoritative in-sandbox inference route fails or cannot be probed, or a terminal runtime sandbox reports a recorded OOM kill. The alias form `$$nemoclaw status --json` requires the sandbox to be registered locally; the canonical form `$$nemoclaw sandbox status --json` is the one to use from automation that may run against an unknown sandbox name, since it still emits a JSON document with `found: false` instead of a text error. + + +For a Deep Agents sandbox, text output includes `DCode auto-approval capability: disabled` or `DCode auto-approval capability: thread-opt-in`. +JSON output reports the same configured value in `dcodeAutoApprovalMode`. +This value does not attest that auto-approval is active in any live TUI thread. + + + ```bash $$nemoclaw my-assistant status $$nemoclaw my-assistant status --json @@ -2318,11 +2326,12 @@ The replacement uses the recorded compatible-endpoint reasoning mode and web sea 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 rebuild preserves the recorded Deep Agents Code observability choice and matching local OTLP policy state unless `--observability` or `--no-observability` explicitly changes them. +A rebuild preserves the recorded Deep Agents Code auto-approval capability unless `--dcode-auto-approval` explicitly changes it. 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] [--tool-disclosure ] [--observability|--no-observability] +$$nemoclaw my-assistant rebuild [--yes|-y|--force] [--verbose|-v] [--tool-disclosure ] [--dcode-auto-approval ] [--observability|--no-observability] ``` | Flag | Description | @@ -2330,6 +2339,7 @@ $$nemoclaw my-assistant rebuild [--yes|-y|--force] [--verbose|-v] [--tool-disclo | `--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. | +| `--dcode-auto-approval ` | Change the managed Deep Agents Code thread auto-approval capability. `thread-opt-in` is accepted only for managed Deep Agents Code sandboxes and is rejected for other agents or custom images. Enabling prints a warning, and either value requires sandbox recreation. | | `--observability`, `--no-observability` | Enable or disable managed trace export for a LangChain Deep Agents Code sandbox during the transactional rebuild. This path preserves managed MCP providers and adapter state. | If another terminal has an active SSH session to the sandbox, `rebuild` prints an active-session warning and requires confirmation before destroying the sandbox. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index ab8c60c394a..57208bc72e0 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -512,6 +512,33 @@ If the container runtime restricts `ulimit` modification, the entrypoint logs a +### Deep Agents Thread Auto-Approval Capability + +Managed Deep Agents sandboxes separate the host-configured capability from the active approval state of each TUI thread. +The default `disabled` mode keeps the TUI auto-approval choice and `dcode -y` unavailable. +An operator can select `thread-opt-in` through a named transactional rebuild, but each thread still requires an explicit TUI choice or `dcode -y` invocation before tool calls run without further confirmation. + +NemoClaw stores the configured mode in a root-owned, mode `0444` image file rather than trusting runtime environment variables. +A missing, malformed, symlinked, writable, unreadable, or incorrectly owned capability file resolves to `disabled`. +Ambient `NEMOCLAW_DCODE_AUTO_APPROVAL*` values cannot enable the capability. + +When a thread activates auto-approval, the TUI shows the upstream active-state indicator and prints a warning. +NemoClaw clears that active state for a new process, `/clear`, `/force-clear`, a thread switch or resume, and an agent switch. +The host-side `status` command reports only the configured capability because it does not attest to the current state of a specific TUI thread. + +| Aspect | Detail | +|---|---| +| Default | `disabled`. Interactive tool calls keep their approval prompts, and thread auto-approval cannot be selected. | +| What you can change | Use `--dcode-auto-approval thread-opt-in` during a named managed Deep Agents rebuild. Use `--dcode-auto-approval disabled` in another rebuild to revoke the capability. | +| Risk if enabled | A prompt injection, untrusted repository, or mistaken plan can cause tool calls, including shell commands, to run without another human confirmation for the rest of the active thread. | +| Remaining controls | OpenShell egress policy, credential isolation and rewriting, the managed inference route, managed MCP validation, filesystem and process controls, and the other managed runtime restrictions remain active. | +| Recommendation | Keep `disabled` for sensitive or unfamiliar work. Enable `thread-opt-in` only for bounded tasks in a reviewed workspace, watch the active-state indicator, and start a new thread or rebuild with `disabled` when unattended tool execution is no longer acceptable. | + + +Treat `thread-opt-in` as permission for unattended shell execution inside the sandbox, not as a policy bypass. +The sandbox boundary limits where commands run and what external resources they can reach, but it does not make an automatically approved command harmless to writable workspace data. + + ### Headless Deep Agents Approval Boundary Interactive `dcode` sessions keep destructive tools behind the Deep Agents Code approval UI. @@ -521,7 +548,7 @@ The managed headless path automatically approves non-shell tool requests such as | Aspect | Detail | |---|---| | Default | Interactive `dcode` prompts for destructive tools. Headless `dcode -n` auto-approves non-shell tools and keeps shell execution disabled. | -| What you can change | Choose interactive `dcode` when you need to review tool calls. Use `dcode -n` only for tasks where unattended file edits are acceptable. | +| What you can change | Choose interactive `dcode` when you need to review tool calls. Use `dcode -n` only for tasks where unattended file edits are acceptable. The interactive `thread-opt-in` capability does not change this headless boundary. | | Risk if relaxed | Treating headless mode like an interactive approval session can let file edits happen without a human prompt. | | Recommendation | Use the interactive TUI for sensitive repositories or destructive tasks. Reserve `dcode -n` for bounded automation with a reviewed workspace and policy. | diff --git a/src/commands/sandbox/oclif-command-adapters.test.ts b/src/commands/sandbox/oclif-command-adapters.test.ts index 5a9b1825451..71218f4d0d1 100644 --- a/src/commands/sandbox/oclif-command-adapters.test.ts +++ b/src/commands/sandbox/oclif-command-adapters.test.ts @@ -119,7 +119,15 @@ describe("sandbox oclif command adapters", () => { await ConnectCliCommand.run(["alpha", "--probe-only"], rootDir); await DestroyCliCommand.run(["alpha", "--yes"], rootDir); await RebuildCliCommand.run( - ["alpha", "--force", "--verbose", "--tool-disclosure", "direct"], + [ + "alpha", + "--force", + "--verbose", + "--tool-disclosure", + "direct", + "--dcode-auto-approval", + "thread-opt-in", + ], rootDir, ); await RebuildCliCommand.run(["dcode", "--yes", "--no-observability"], rootDir); @@ -128,12 +136,14 @@ describe("sandbox oclif command adapters", () => { expect(mocks.connectSandbox).toHaveBeenCalledWith("alpha", { probeOnly: true }); expect(mocks.destroySandbox).toHaveBeenCalledWith("alpha", { force: false, yes: true }); expect(mocks.rebuildSandbox).toHaveBeenCalledWith("alpha", { + dcodeAutoApprovalMode: "thread-opt-in", force: true, toolDisclosure: "direct", verbose: true, yes: false, }); expect(mocks.rebuildSandbox).toHaveBeenCalledWith("dcode", { + dcodeAutoApprovalMode: undefined, force: false, observabilityEnabled: false, toolDisclosure: undefined, @@ -238,6 +248,7 @@ describe("sandbox oclif command adapters", () => { expect(RebuildCliCommand.id).toBe("sandbox:rebuild"); expect(usage(RebuildCliCommand)).toContain("[--yes|-y|--force]"); expect(usage(RebuildCliCommand)).toContain("[--tool-disclosure ]"); + expect(usage(RebuildCliCommand)).toContain("[--dcode-auto-approval ]"); expect(usage(RebuildCliCommand)).toContain("[--observability|--no-observability]"); expect(SandboxPolicyListCommand.id).toBe("sandbox:policy:list"); expect(SandboxChannelsListCommand.id).toBe("sandbox:channels:list"); diff --git a/src/commands/sandbox/rebuild.ts b/src/commands/sandbox/rebuild.ts index 01224aaa538..fb29502350a 100644 --- a/src/commands/sandbox/rebuild.ts +++ b/src/commands/sandbox/rebuild.ts @@ -6,6 +6,10 @@ 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 { + DCODE_AUTO_APPROVAL_MODES, + type DcodeAutoApprovalMode, +} from "../../lib/onboard/dcode-auto-approval"; import { TOOL_DISCLOSURE_VALUES, type ToolDisclosure } from "../../lib/tool-disclosure"; export default class RebuildCliCommand extends NemoClawCommand { @@ -14,12 +18,13 @@ export default class RebuildCliCommand extends NemoClawCommand { 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] [--tool-disclosure ] [--observability|--no-observability]", + " [--yes|-y|--force] [--verbose|-v] [--tool-disclosure ] [--dcode-auto-approval ] [--observability|--no-observability]", ]; static examples = [ "<%= config.bin %> sandbox rebuild alpha", "<%= config.bin %> sandbox rebuild alpha --yes --verbose", "<%= config.bin %> sandbox rebuild alpha --yes --tool-disclosure direct", + "<%= config.bin %> sandbox rebuild my-dcode --dcode-auto-approval thread-opt-in", "<%= config.bin %> sandbox rebuild my-dcode --yes --observability", ]; static args = { @@ -33,6 +38,10 @@ export default class RebuildCliCommand extends NemoClawCommand { description: "Change the sandbox tool-disclosure mode during the transactional rebuild", options: [...TOOL_DISCLOSURE_VALUES], }), + "dcode-auto-approval": Flags.string({ + description: "Change managed Deep Agents Code thread auto-approval during rebuild", + options: [...DCODE_AUTO_APPROVAL_MODES], + }), observability: Flags.boolean({ allowNo: true, description: "Change managed Deep Agents Code trace export during the transactional rebuild", @@ -42,6 +51,8 @@ export default class RebuildCliCommand extends NemoClawCommand { public async run(): Promise { const { args, flags } = await this.parse(RebuildCliCommand); await rebuildSandbox(args.sandboxName, { + dcodeAutoApprovalMode: + (flags["dcode-auto-approval"] as DcodeAutoApprovalMode | undefined) ?? undefined, force: flags.force === true, ...(flags.observability === undefined ? {} : { observabilityEnabled: flags.observability }), toolDisclosure: (flags["tool-disclosure"] as ToolDisclosure | undefined) ?? undefined, 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 94645d640c6..3c60d85cfc1 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-mutation-edge.test.ts @@ -31,9 +31,13 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { configureDcodeSession(harness); await expect( - harness.rebuildSandbox("alpha", ["--yes", "--tool-disclosure", "direct"], { - throwOnError: true, - }), + harness.rebuildSandbox( + "alpha", + ["--yes", "--tool-disclosure", "direct", "--dcode-auto-approval", "thread-opt-in"], + { + throwOnError: true, + }, + ), ).resolves.toBeUndefined(); expect(harness.preflightDcodeRouteSpy).toHaveBeenCalledTimes(4); @@ -41,6 +45,7 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { expect(harness.prepareManagedDcodeRebuildImageSpy).toHaveBeenCalledWith( expect.objectContaining({ compatibleEndpointReasoning: null, + dcodeAutoApprovalMode: "thread-opt-in", toolDisclosure: "direct", webSearchConfig: null, }), @@ -48,9 +53,12 @@ describe("rebuildSandbox DCode flow: mutation edge", () => { expect(harness.onboardSpy).toHaveBeenCalledWith( expect.objectContaining({ agent: "langchain-deepagents-code", + dcodeAutoApprovalMode: "thread-opt-in", + dcodeAutoApprovalRequestedExplicitly: true, toolDisclosure: "direct", preparedDcodeRebuild: expect.objectContaining({ buildContext: harness.preparedDcodeBuildContext, + dcodeAutoApprovalMode: "thread-opt-in", 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 0529b01d8cd..1a0a4cb0e58 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-orchestrator.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-orchestrator.test.ts @@ -50,6 +50,7 @@ describe("DCode rebuild orchestrator", () => { {} as RebuildResumeConfig, null, "progressive", + "disabled", false, 19_080, baseImageOptions, @@ -87,7 +88,7 @@ describe("DCode rebuild orchestrator", () => { const resolutionHint = { key: "sandbox-alpha" } as SandboxBaseImageResolutionMetadata; await expect( - orchestrator.prepareImage(resumeConfig, null, "progressive", false, 19_080, { + orchestrator.prepareImage(resumeConfig, null, "progressive", "thread-opt-in", false, 19_080, { resolutionHint, forceBaseImageRefresh: true, }), @@ -100,6 +101,7 @@ describe("DCode rebuild orchestrator", () => { resumeConfig, webSearchConfig: null, toolDisclosure: "progressive", + dcodeAutoApprovalMode: "thread-opt-in", 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 604b2eb1104..ca0e3d4f2fa 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-orchestrator.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { WebSearchConfig } from "../../inference/web-search"; +import type { DcodeAutoApprovalMode } from "../../onboard/dcode-auto-approval"; import type { Session } from "../../state/onboard-session"; import type { ToolDisclosure } from "../../tool-disclosure"; import { @@ -50,6 +51,7 @@ export type DcodeRebuildOrchestrator = { resumeConfig: RebuildResumeConfig, webSearchConfig: WebSearchConfig | null, toolDisclosure: ToolDisclosure, + dcodeAutoApprovalMode: DcodeAutoApprovalMode, skipLiveRoute: boolean, gatewayPort: number, baseImageOptions?: RebuildAgentBaseImageOptions, @@ -57,12 +59,14 @@ export type DcodeRebuildOrchestrator = { revalidateBeforeDelete( resumeConfig: RebuildResumeConfig, toolDisclosure: ToolDisclosure, + dcodeAutoApprovalMode: DcodeAutoApprovalMode, skipLiveRoute: boolean, gatewayPort: number, ): Promise; checkAtDeleteEdge( resumeConfig: RebuildResumeConfig, toolDisclosure: ToolDisclosure, + dcodeAutoApprovalMode: DcodeAutoApprovalMode, skipLiveRoute: boolean, gatewayPort: number, ): Promise<{ ok: true } | { ok: false; message: string; code?: number }>; @@ -137,6 +141,7 @@ export function createDcodeRebuildOrchestrator( resumeConfig, webSearchConfig, toolDisclosure, + dcodeAutoApprovalMode, skipLiveRoute, gatewayPort, baseImageOptions, @@ -151,6 +156,7 @@ export function createDcodeRebuildOrchestrator( resumeConfig, webSearchConfig, toolDisclosure, + dcodeAutoApprovalMode, skipLiveRoute, gatewayPort, log, @@ -164,7 +170,13 @@ export function createDcodeRebuildOrchestrator( scope.adopt(replacement); return true; }), - revalidateBeforeDelete: (resumeConfig, toolDisclosure, skipLiveRoute, gatewayPort) => + revalidateBeforeDelete: ( + resumeConfig, + toolDisclosure, + dcodeAutoApprovalMode, + skipLiveRoute, + gatewayPort, + ) => run(async () => { if (!scope.enabled) return true; const replacement = scope.preparedReplacement; @@ -174,6 +186,7 @@ export function createDcodeRebuildOrchestrator( entry, resumeConfig, toolDisclosure, + dcodeAutoApprovalMode, skipLiveRoute, gatewayPort, log, @@ -182,7 +195,13 @@ export function createDcodeRebuildOrchestrator( replacement, }); }), - checkAtDeleteEdge: async (resumeConfig, toolDisclosure, skipLiveRoute, gatewayPort) => { + checkAtDeleteEdge: async ( + resumeConfig, + toolDisclosure, + dcodeAutoApprovalMode, + skipLiveRoute, + gatewayPort, + ) => { if (!scope.enabled) return { ok: true }; const replacement = scope.preparedReplacement; if (!replacement) { @@ -197,6 +216,7 @@ export function createDcodeRebuildOrchestrator( entry, resumeConfig, toolDisclosure, + dcodeAutoApprovalMode, 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 2e6bd347291..91affb1c1a5 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 @@ -46,6 +46,7 @@ describe("rebuildSandbox DCode flow: pre-delete drift", () => { ambient: { presentVars: [], agentMismatch: null }, }, toolDisclosure: "direct", + dcodeAutoApprovalMode: "disabled", skipLiveRoute: true, gatewayPort: 8080, log: vi.fn(), @@ -57,6 +58,7 @@ describe("rebuildSandbox DCode flow: pre-delete drift", () => { buildContext: {} as never, gatewayName: "nemoclaw", toolDisclosure: "progressive", + dcodeAutoApprovalMode: "disabled", verify, dispose, }, @@ -68,6 +70,56 @@ describe("rebuildSandbox DCode flow: pre-delete drift", () => { expect(dispose).not.toHaveBeenCalled(); }); + it("rejects prepared-image DCode auto-approval drift before gateway or mutation work (#6478)", async () => { + const checkGatewaySchema = vi.fn(() => true); + const verify = 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, + registryInferenceRoute: null, + ambient: { presentVars: [], agentMismatch: null }, + }, + toolDisclosure: "progressive", + dcodeAutoApprovalMode: "thread-opt-in", + skipLiveRoute: true, + gatewayPort: 8080, + log: vi.fn(), + bail: (message): never => { + throw new Error(message); + }, + checkGatewaySchema, + replacement: { + buildContext: {} as never, + gatewayName: "nemoclaw", + toolDisclosure: "progressive", + dcodeAutoApprovalMode: "disabled", + verify, + dispose: vi.fn(() => true), + }, + }), + ).rejects.toThrow("prepared DCode auto-approval mode changed before deletion"); + + expect(checkGatewaySchema).not.toHaveBeenCalled(); + expect(verify).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.test.ts b/src/lib/actions/sandbox/rebuild-dcode-preflight.test.ts index f845e48b041..1a4dc190d9b 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-preflight.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-preflight.test.ts @@ -13,11 +13,124 @@ import { restoreRebuildFlowTestEnvironment, snapshotEnv, } from "../../../../test/helpers/rebuild-flow-harness"; +import { resolveRebuildDurableConfig } from "./rebuild-durable-config"; describe("rebuildSandbox DCode flow: preflight", () => { beforeEach(resetRebuildFlowTestEnvironment); afterEach(restoreRebuildFlowTestEnvironment); + it.each([ + ["defaults legacy state to disabled", undefined, undefined, "disabled", null], + ["uses recorded state", "thread-opt-in", undefined, "thread-opt-in", null], + ["applies an explicit override", "disabled", "thread-opt-in", "thread-opt-in", null], + [ + "does not let an explicit override mask corrupt state", + "always", + "thread-opt-in", + "thread-opt-in", + "recorded dcodeAutoApprovalMode value must be disabled or thread-opt-in", + ], + ] as const)("resolves durable DCode mode: %s (#6478)", (_label, recorded, requested, expected, error) => { + const config = resolveRebuildDurableConfig( + "alpha", + { + name: "alpha", + agent: "langchain-deepagents-code", + nemoclawVersion: "0.1.0", + ...(recorded !== undefined ? { dcodeAutoApprovalMode: recorded as never } : {}), + }, + null, + undefined, + undefined, + false, + requested, + ); + + expect(config.dcodeAutoApprovalMode).toBe(expected); + expect(config.dcodeAutoApprovalModeError).toBe(error); + }); + + it("rejects a DCode auto-approval override for unsupported agents before mutation (#6478)", async () => { + const harness = createRebuildFlowHarness({ + agentName: "openclaw", + sandboxEntry: { name: "alpha", agent: "openclaw", nemoclawVersion: "0.1.0" }, + }); + + await expect( + harness.rebuildSandbox("alpha", ["--yes", "--dcode-auto-approval", "thread-opt-in"], { + throwOnError: true, + }), + ).rejects.toThrow("Unsupported rebuild DCode auto-approval override"); + + expect(harness.registryUpdateSpy).not.toHaveBeenCalled(); + expect(harness.prepareManagedDcodeRebuildImageSpy).not.toHaveBeenCalled(); + expectNoDcodeMutation(harness); + }); + + it("rejects recorded DCode auto-approval on an unsupported agent before mutation (#6478)", async () => { + const harness = createRebuildFlowHarness({ + agentName: "openclaw", + sandboxEntry: { + name: "alpha", + agent: "openclaw", + dcodeAutoApprovalMode: "thread-opt-in", + nemoclawVersion: "0.1.0", + }, + }); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("incompatible with the sandbox agent"); + + expect(harness.registryUpdateSpy).not.toHaveBeenCalled(); + expect(harness.prepareManagedDcodeRebuildImageSpy).not.toHaveBeenCalled(); + expectNoDcodeMutation(harness); + }); + + it("allows an explicit disabled rebuild to repair unsupported recorded state (#6478)", async () => { + const harness = createRebuildFlowHarness({ + agentName: "openclaw", + sandboxEntry: { + name: "alpha", + agent: "openclaw", + dcodeAutoApprovalMode: "thread-opt-in", + nemoclawVersion: "0.1.0", + }, + }); + + await expect( + harness.rebuildSandbox("alpha", ["--yes", "--dcode-auto-approval", "disabled"], { + throwOnError: true, + }), + ).resolves.toBeUndefined(); + + expect(harness.onboardSpy).toHaveBeenCalledWith( + expect.objectContaining({ + dcodeAutoApprovalMode: "disabled", + dcodeAutoApprovalRequestedExplicitly: true, + }), + ); + }); + + it("rejects an invalid durable DCode auto-approval mode before mutation (#6478)", async () => { + const harness = createRebuildFlowHarness({ + agentName: "langchain-deepagents-code", + sandboxEntry: { + ...makeDcodeSandboxEntry(), + dcodeAutoApprovalMode: "always", + }, + }); + configureDcodeSession(harness); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("Recorded DCode auto-approval state is invalid"); + + expect(harness.registryUpdateSpy).not.toHaveBeenCalled(); + expect(harness.prepareManagedDcodeRebuildImageSpy).not.toHaveBeenCalled(); + expectNoDcodeMutation(harness); + }); + it("rejects a stored DCode route failure before any rebuild mutation (#6195)", async () => { const harness = createRebuildFlowHarness({ agentName: "langchain-deepagents-code", diff --git a/src/lib/actions/sandbox/rebuild-dcode-preflight.ts b/src/lib/actions/sandbox/rebuild-dcode-preflight.ts index 782673a5169..9d9e8181854 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-preflight.ts @@ -10,6 +10,7 @@ import { RD as _RD, R } from "../../cli/terminal-style"; import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; import * as nim from "../../inference/nim"; import type { WebSearchConfig } from "../../inference/web-search"; +import type { DcodeAutoApprovalMode } from "../../onboard/dcode-auto-approval"; import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; import { getResumeSandboxGpuOverrides, @@ -48,6 +49,7 @@ export type PreparedDcodeReplacement = { readonly buildContext: PreparedDcodeRebuildImage; readonly gatewayName: string; readonly toolDisclosure: ToolDisclosure; + readonly dcodeAutoApprovalMode: DcodeAutoApprovalMode; dispose(): boolean; verify(): boolean; }; @@ -57,6 +59,7 @@ export type DcodeReplacementPreflightInput = { entry: RebuildSandboxEntry; resumeConfig: RebuildResumeConfig; toolDisclosure: ToolDisclosure; + dcodeAutoApprovalMode: DcodeAutoApprovalMode; skipLiveRoute: boolean; /** Authoritative persisted gateway port carried by the rebuild target. */ gatewayPort?: number; @@ -390,6 +393,7 @@ export async function prepareDcodeReplacementBeforeMutation( compatibleEndpointReasoning: resumeConfig.compatibleEndpointReasoning, webSearchConfig, toolDisclosure: input.toolDisclosure, + dcodeAutoApprovalMode: input.dcodeAutoApprovalMode, sandboxGpuConfig, gatewayPort, }), @@ -413,6 +417,7 @@ export async function prepareDcodeReplacementBeforeMutation( buildContext: preparedBuildContext, gatewayName: target.gatewayName, toolDisclosure: input.toolDisclosure, + dcodeAutoApprovalMode: input.dcodeAutoApprovalMode, dispose: () => disposePreparation(preparedBuildContext, preparedBase), verify: () => verifyPreparedDcodeRebuildImage(preparedBuildContext) && preparedBase.verify(), }; @@ -436,6 +441,9 @@ export async function revalidateDcodeReplacementAtMutationEdge( if (replacement.toolDisclosure !== input.toolDisclosure) { fail("the prepared DCode tool-disclosure mode changed before deletion", bail); } + if (replacement.dcodeAutoApprovalMode !== input.dcodeAutoApprovalMode) { + fail("the prepared DCode auto-approval mode changed before deletion", bail); + } if (!(await ensureDcodeRebuildTargetGatewaySelected(sandboxName, entry, log, bail))) { return false; } diff --git a/src/lib/actions/sandbox/rebuild-durable-config.ts b/src/lib/actions/sandbox/rebuild-durable-config.ts index aab05d8037d..32124704a22 100644 --- a/src/lib/actions/sandbox/rebuild-durable-config.ts +++ b/src/lib/actions/sandbox/rebuild-durable-config.ts @@ -20,6 +20,11 @@ import { type WebSearchProvider, webSearchProviderForConfig, } from "../../inference/web-search"; +import { + type DcodeAutoApprovalMode, + invalidRecordedDcodeAutoApprovalMode, + normalizeDcodeAutoApprovalMode, +} from "../../onboard/dcode-auto-approval"; import { resolveHermesDashboardOnboardState } from "../../onboard/hermes-dashboard"; import { hasInvalidSessionToolDisclosure, type Session } from "../../state/onboard-session"; import { @@ -33,6 +38,8 @@ import type { RebuildSandboxEntry } from "./rebuild-flow-helpers"; import type { RebuildResumeConfig } from "./rebuild-resume-config"; export type RebuildDurableConfig = { + dcodeAutoApprovalMode: DcodeAutoApprovalMode; + dcodeAutoApprovalModeError: string | null; fromDockerfile: string | null; fromDockerfileError: string | null; hermesAuthMethod: "oauth" | "api_key" | null; @@ -127,6 +134,7 @@ export function resolveRebuildDurableConfig( }, requestedToolDisclosure?: ToolDisclosure, allowLegacyManagedImageRecovery = false, + requestedDcodeAutoApprovalMode?: DcodeAutoApprovalMode, ): RebuildDurableConfig { const matchingSession = session?.sandboxName === sandboxName && @@ -199,6 +207,14 @@ export function resolveRebuildDurableConfig( requestedToolDisclosure ?? normalizeToolDisclosure(recordedToolDisclosure) ?? DEFAULT_TOOL_DISCLOSURE; + const recordedDcodeAutoApprovalMode = entry.dcodeAutoApprovalMode; + const dcodeAutoApprovalModeError = invalidRecordedDcodeAutoApprovalMode( + recordedDcodeAutoApprovalMode, + ) + ? "recorded dcodeAutoApprovalMode value must be disabled or thread-opt-in" + : null; + const dcodeAutoApprovalMode = + requestedDcodeAutoApprovalMode ?? normalizeDcodeAutoApprovalMode(recordedDcodeAutoApprovalMode); const recordedFromDockerfile: unknown = entry.fromDockerfile !== undefined ? entry.fromDockerfile @@ -234,6 +250,8 @@ export function resolveRebuildDurableConfig( : null; return { + dcodeAutoApprovalMode, + dcodeAutoApprovalModeError, fromDockerfile: typeof recordedFromDockerfile === "string" && recordedFromDockerfile ? recordedFromDockerfile @@ -277,10 +295,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. + // toolDisclosure and dcodeAutoApprovalMode are intentionally absent: this + // preflight update still describes the running old image. Replacement + // onboarding commits requested modes only after creation succeeds; retry + // rollback keeps the old registry values if recreation fails. return { provider: resume.provider, model: resume.model, 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 88f5f6e380b..7294c76d84a 100644 --- a/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts +++ b/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts @@ -177,6 +177,23 @@ describe("buildRebuildRecreateOnboardOpts", () => { }); }); + it("carries recorded DCode auto-approval into authoritative recreation (#6478)", () => { + const opts = buildRebuildRecreateOnboardOpts({ + sb: { + dashboardPort: 0, + gatewayName: "nemoclaw", + dcodeAutoApprovalMode: "thread-opt-in", + }, + rebuildAgent: "langchain-deepagents-code", + storedFromDockerfile: null, + autoYes: true, + usageNoticeAccepted: true, + }); + + expect(opts.dcodeAutoApprovalMode).toBe("thread-opt-in"); + expect(opts.dcodeAutoApprovalRequestedExplicitly).toBe(false); + }); + it("carries an explicit direct tool-disclosure selection into inner onboard", () => { const opts = buildRebuildRecreateOnboardOpts({ ...baseArgs, @@ -306,6 +323,7 @@ describe("buildRebuildRecreateOnboardOpts", () => { cleanupBuildCtx: () => true, origin: "generated" as const, }, + dcodeAutoApprovalMode: "disabled" as const, gatewayName: "nemoclaw", }; const opts = buildRebuildRecreateOnboardOpts({ diff --git a/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts b/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts index 90e4a602f24..0814bdc5521 100644 --- a/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts +++ b/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts @@ -3,6 +3,10 @@ import { loadAgent } from "../../agent/defs"; import { shouldManageDashboardForAgent } from "../../onboard/dashboard-runtime"; +import { + type DcodeAutoApprovalMode, + normalizeDcodeAutoApprovalMode, +} from "../../onboard/dcode-auto-approval"; import { resolveGatewayPortFromName, resolveSandboxGatewayName, @@ -30,6 +34,7 @@ export type RebuildGpuOptOutEntry = { gatewayName?: string | null; gatewayPort?: number | null; toolDisclosure?: ToolDisclosure; + dcodeAutoApprovalMode?: DcodeAutoApprovalMode; observabilityEnabled?: boolean; policyTier?: string | null; }; @@ -104,6 +109,9 @@ export type RebuildRecreateOnboardOpts = { preparedImageRebuild?: PreparedImageRebuildHandoff; autoYes: boolean; toolDisclosure: ToolDisclosure; + dcodeAutoApprovalMode: DcodeAutoApprovalMode; + /** Whether the rebuild command explicitly overrode recorded DCode auto-approval state. */ + dcodeAutoApprovalRequestedExplicitly: boolean; observabilityEnabled: boolean; /** Whether the rebuild command explicitly overrode the recorded observability state. */ observabilityRequestedExplicitly: boolean; @@ -169,6 +177,8 @@ export function buildRebuildRecreateOnboardOpts(args: { ...(args.preparedDcodeRebuild ? { preparedDcodeRebuild: args.preparedDcodeRebuild } : {}), autoYes: args.autoYes, toolDisclosure: toolDisclosureOrDefault(args.sb?.toolDisclosure), + dcodeAutoApprovalMode: normalizeDcodeAutoApprovalMode(args.sb?.dcodeAutoApprovalMode), + dcodeAutoApprovalRequestedExplicitly: false, observabilityEnabled: args.sb?.observabilityEnabled === true, observabilityRequestedExplicitly: false, policyTier: rawPolicyTier, diff --git a/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts b/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts index a57dede558f..5d8b067e13e 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-configuration.test.ts @@ -67,6 +67,45 @@ describe("managed DCode rebuild image configuration", () => { } }); + it("binds DCode auto-approval mode into the prepared image configuration (#6478)", async () => { + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-auto-approval-")); + const stagedDockerfile = path.join(testRoot, "Dockerfile"); + fs.writeFileSync(stagedDockerfile, "FROM scratch\n"); + const prepareDockerfilePatch = vi.fn(async () => ({ + buildId: "dcode-auto-approval", + resolvedBaseImage: null, + })); + + try { + const result = await prepareManagedDcodeRebuildImage( + dcodeInput({ dcodeAutoApprovalMode: "thread-opt-in" }), + { + stageBuildContext: () => ({ + buildCtx: testRoot, + stagedDockerfile, + origin: "generated" as const, + cleanupBuildCtx: () => { + fs.rmSync(testRoot, { recursive: true, force: true }); + return true; + }, + }), + prepareDockerfilePatch, + buildImage: () => ({ status: 0 }) as never, + removeImage: () => ({ status: 0 }) as never, + }, + ); + + expect(result.ok).toBe(true); + expect(prepareDockerfilePatch).toHaveBeenCalledWith( + expect.objectContaining({ dcodeAutoApprovalMode: "thread-opt-in" }), + ); + expect(expectPreparedImage(result).dcodeAutoApprovalMode).toBe("thread-opt-in"); + disposePreparedDcodeRebuildImage(expectPreparedImage(result)); + } finally { + fs.rmSync(testRoot, { recursive: true, force: true }); + } + }); + it("defaults missing compatible-endpoint reasoning without borrowing ambient state (#6195)", async () => { const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-rebuild-reasoning-")); const stagedDockerfile = path.join(buildCtx, "Dockerfile"); diff --git a/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts b/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts index dc62de1a09c..8e5ccaf174f 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-preflight.ts @@ -10,6 +10,7 @@ import { createAgentSandbox } from "../../agent/onboard"; import { GATEWAY_PORT } from "../../core/ports"; import type { WebSearchConfig } from "../../inference/web-search"; import { stageCreateSandboxBuildContext } from "../../onboard/build-context-stage"; +import type { DcodeAutoApprovalMode } from "../../onboard/dcode-auto-approval"; import { prepareSandboxDockerfilePatch } from "../../onboard/sandbox-dockerfile-patch-flow"; import type { SandboxGpuConfig } from "../../onboard/sandbox-gpu-mode"; import { ROOT, redact } from "../../runner"; @@ -36,6 +37,7 @@ export type ManagedDcodeRebuildImageInput = { compatibleEndpointReasoning: "true" | "false" | null; webSearchConfig: WebSearchConfig | null; toolDisclosure: ToolDisclosure; + dcodeAutoApprovalMode: DcodeAutoApprovalMode; sandboxGpuConfig: SandboxGpuConfig; gatewayPort?: number; }; @@ -49,6 +51,7 @@ export type ManagedDcodeRebuildImageDeps = { }; export type PreparedDcodeRebuildImage = FingerprintedPreparedBuildContext & { + dcodeAutoApprovalMode: DcodeAutoApprovalMode; dockerGpuPatchNetwork: string | null; }; @@ -147,6 +150,7 @@ export async function prepareManagedDcodeRebuildImage( preferredInferenceApi: input.preferredInferenceApi, webSearchConfig: input.webSearchConfig, toolDisclosure: input.toolDisclosure, + dcodeAutoApprovalMode: input.dcodeAutoApprovalMode, hermesToolGateways: [], sandboxGpuConfig: input.sandboxGpuConfig, gatewayPort: input.gatewayPort ?? GATEWAY_PORT, @@ -174,6 +178,7 @@ export async function prepareManagedDcodeRebuildImage( cleanupBuildCtx: cleanupBuildContext, buildId, contextFingerprint, + dcodeAutoApprovalMode: input.dcodeAutoApprovalMode, verifyBuildCtx: createBuildContextVerifier(staged.buildCtx, contextFingerprint), dockerGpuPatchNetwork: process.env.NEMOCLAW_DOCKER_GPU_PATCH_NETWORK || null, }, diff --git a/src/lib/actions/sandbox/rebuild-mcp-phase.test.ts b/src/lib/actions/sandbox/rebuild-mcp-phase.test.ts index cc1ed1f2719..bba0ec96d55 100644 --- a/src/lib/actions/sandbox/rebuild-mcp-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-mcp-phase.test.ts @@ -65,4 +65,32 @@ describe("MCP rebuild retry guidance", () => { expect(command).not.toContain("--observability"); expect(command).not.toContain("--no-observability"); }); + + it.each([ + "disabled", + "thread-opt-in", + ] as const)("preserves an explicit DCode auto-approval=%s override", (mode) => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + + printMcpRebuildRetryCommand("alpha", [{} as never], "progressive", undefined, { + mode, + requestedExplicitly: true, + }); + + expect(error.mock.calls.flat().join("\n")).toContain( + `nemoclaw alpha rebuild --yes --tool-disclosure progressive --dcode-auto-approval ${mode}`, + ); + }); + + it("keeps inherited DCode auto-approval state implicit on retry", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + + printMcpRebuildRetryCommand("alpha", [{} as never], "progressive", undefined, { + mode: "thread-opt-in", + requestedExplicitly: false, + }); + + const command = error.mock.calls.flat().find((line) => line.includes("rebuild --yes")); + expect(command).not.toContain("--dcode-auto-approval"); + }); }); diff --git a/src/lib/actions/sandbox/rebuild-mcp-phase.ts b/src/lib/actions/sandbox/rebuild-mcp-phase.ts index 74b5844d4fd..ca1b9f4b099 100644 --- a/src/lib/actions/sandbox/rebuild-mcp-phase.ts +++ b/src/lib/actions/sandbox/rebuild-mcp-phase.ts @@ -3,6 +3,7 @@ import { CLI_NAME } from "../../cli/branding"; import { G, R, YW } from "../../cli/terminal-style"; +import type { DcodeAutoApprovalMode } from "../../onboard/dcode-auto-approval"; import { explicitObservabilityFlag } from "../../onboard/observability-command-flag"; import * as registry from "../../state/registry"; import type { ToolDisclosure } from "../../tool-disclosure"; @@ -73,15 +74,22 @@ export function printMcpRebuildRetryCommand( entries: McpRebuildPreparation["entries"], toolDisclosure?: ToolDisclosure, observability?: { enabled: boolean; requestedExplicitly: boolean }, + dcodeAutoApproval?: { + mode: DcodeAutoApprovalMode; + requestedExplicitly: boolean; + }, ): void { const observabilityFlag = observability ? explicitObservabilityFlag(observability.enabled, observability.requestedExplicitly) : null; const observabilityArg = observabilityFlag ? ` ${observabilityFlag}` : ""; + const dcodeAutoApprovalArg = dcodeAutoApproval?.requestedExplicitly + ? ` --dcode-auto-approval ${dcodeAutoApproval.mode}` + : ""; if (entries.length > 0) { const disclosureArg = toolDisclosure ? ` --tool-disclosure ${toolDisclosure}` : ""; console.error( - ` 2. Run: ${CLI_NAME} ${sandboxName} rebuild --yes${disclosureArg}${observabilityArg}`, + ` 2. Run: ${CLI_NAME} ${sandboxName} rebuild --yes${disclosureArg}${observabilityArg}${dcodeAutoApprovalArg}`, ); console.error( ` This will recreate sandbox '${sandboxName}' and restore its MCP bridges.`, @@ -89,7 +97,9 @@ export function printMcpRebuildRetryCommand( return; } const disclosureArg = toolDisclosure ? ` --tool-disclosure ${toolDisclosure}` : ""; - console.error(` 2. Run: ${CLI_NAME} onboard --resume${disclosureArg}${observabilityArg}`); + console.error( + ` 2. Run: ${CLI_NAME} onboard --resume${disclosureArg}${observabilityArg}${dcodeAutoApprovalArg}`, + ); 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 200a24767de..0dddcd70702 100644 --- a/src/lib/actions/sandbox/rebuild-pipeline.ts +++ b/src/lib/actions/sandbox/rebuild-pipeline.ts @@ -190,6 +190,7 @@ async function rebuildSandboxUnlocked( !(await dcodePreflight.revalidateBeforeDelete( resumeConfig, durableConfig.toolDisclosure, + durableConfig.dcodeAutoApprovalMode, recoveryRecreate, recreateOptions.targetGatewayPort, )) @@ -232,6 +233,7 @@ async function rebuildSandboxUnlocked( return dcodePreflight.checkAtDeleteEdge( resumeConfig, durableConfig.toolDisclosure, + durableConfig.dcodeAutoApprovalMode, recoveryRecreate, recreateOptions.targetGatewayPort, ); diff --git a/src/lib/actions/sandbox/rebuild-preflight-confirmation.test.ts b/src/lib/actions/sandbox/rebuild-preflight-confirmation.test.ts index 04ce99bffc3..b842451388a 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-confirmation.test.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-confirmation.test.ts @@ -57,6 +57,36 @@ describe("rebuild confirmation", () => { await expect(confirmSandboxRebuildIfNeeded(true, 3, prompt)).resolves.toBe(true); expect(prompt).not.toHaveBeenCalled(); }); + + it("warns before the generic rebuild confirmation when thread auto-approval is enabled (#6478)", async () => { + const prompt = vi.fn(async () => "n"); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + await expect(confirmSandboxRebuildIfNeeded(false, 0, prompt, "thread-opt-in")).resolves.toBe( + false, + ); + + const output = log.mock.calls.flat().join("\n"); + expect(output).toContain("thread auto-approval will be enabled"); + expect(output).toContain( + "Tool calls, including shell commands, may execute without further confirmation inside OpenShell", + ); + expect(output.indexOf("thread auto-approval will be enabled")).toBeLessThan( + output.indexOf("This will:"), + ); + }); + + it("prints the auto-approval warning even when --yes skips the prompt (#6478)", async () => { + const prompt = vi.fn(async () => "n"); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + await expect(confirmSandboxRebuildIfNeeded(true, 0, prompt, "thread-opt-in")).resolves.toBe( + true, + ); + + expect(log.mock.calls.flat().join("\n")).toContain("including shell commands"); + expect(prompt).not.toHaveBeenCalled(); + }); }); describe("createRebuildCommandContext bail behaviour (#6376)", () => { diff --git a/src/lib/actions/sandbox/rebuild-preflight-confirmation.ts b/src/lib/actions/sandbox/rebuild-preflight-confirmation.ts index 863fb78529a..32681c85997 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-confirmation.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-confirmation.ts @@ -9,6 +9,7 @@ import { normalizeRebuildSandboxOptions, type RebuildSandboxOptions, } from "../../domain/lifecycle/options"; +import type { DcodeAutoApprovalMode } from "../../onboard/dcode-auto-approval"; import * as sandboxVersion from "../../sandbox/version"; import { redact } from "../../security/redact"; import { @@ -29,6 +30,7 @@ export function createRebuildCommandContext( bail: RebuildBail; log: RebuildLog; requestedToolDisclosure: ToolDisclosure | undefined; + requestedDcodeAutoApprovalMode: DcodeAutoApprovalMode | undefined; requestedObservabilityEnabled: boolean | undefined; skipConfirm: boolean; } { @@ -40,6 +42,7 @@ export function createRebuildCommandContext( console.error(` ${D}[rebuild ${new Date().toISOString()}] ${redact(message)}${R}`) : () => {}, requestedToolDisclosure: normalized.toolDisclosure, + requestedDcodeAutoApprovalMode: normalized.dcodeAutoApprovalMode, requestedObservabilityEnabled: normalized.observabilityEnabled, skipConfirm: normalized.yes === true || normalized.force === true, bail: opts.throwOnError @@ -91,7 +94,15 @@ export async function confirmSandboxRebuildIfNeeded( skipConfirm: boolean, activeSessionCount: number, prompt: typeof askPrompt = askPrompt, + requestedDcodeAutoApprovalMode?: DcodeAutoApprovalMode, ): Promise { + if (requestedDcodeAutoApprovalMode === "thread-opt-in") { + console.log(` ${YW}Warning: Deep Agents Code thread auto-approval will be enabled.${R}`); + console.log( + " Tool calls, including shell commands, may execute without further confirmation inside OpenShell.", + ); + console.log(""); + } if (skipConfirm) return true; if (activeSessionCount > 0) { const plural = activeSessionCount > 1 ? "sessions" : "session"; @@ -145,6 +156,7 @@ export async function confirmRebuildIntent( skipConfirm: boolean, activeSessionCount: number, bail: RebuildBail, + requestedDcodeAutoApprovalMode?: DcodeAutoApprovalMode, ): Promise { const versionCheck = sandboxVersion.checkAgentVersion(sandboxName); console.log(""); @@ -156,7 +168,16 @@ export async function confirmRebuildIntent( console.log(` Target: ${agentName} v${versionCheck.expectedVersion}`); } console.log(""); - if (!(await confirmSandboxRebuildIfNeeded(skipConfirm, activeSessionCount))) return null; + if ( + !(await confirmSandboxRebuildIfNeeded( + skipConfirm, + activeSessionCount, + askPrompt, + requestedDcodeAutoApprovalMode, + )) + ) { + return null; + } await ensureRebuildUsageNoticeOrBail(bail); return versionCheck; } diff --git a/src/lib/actions/sandbox/rebuild-preflight-phase.ts b/src/lib/actions/sandbox/rebuild-preflight-phase.ts index 58ccd1e3370..e01b33e2408 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-phase.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-phase.ts @@ -4,6 +4,8 @@ import type { RebuildSandboxOptions } from "../../domain/lifecycle/options"; import type { SandboxMessagingPlan } from "../../messaging"; import { hydrateCredentialEnv } from "../../onboard/credential-env"; +import { DCODE_AUTO_APPROVAL_FEATURE } from "../../onboard/dcode-auto-approval"; +import { managedSandboxFeatureIssue } from "../../onboard/managed-sandbox-feature"; import type { RebuildManifest } from "../../state/sandbox"; import { assertMcpDestroyNotPending } from "./mcp-bridge-state"; import { @@ -79,8 +81,14 @@ export async function runRebuildPreflightPhase( options: string[] | RebuildSandboxOptions = {}, opts: RebuildSandboxExecutionOptions = {}, ): Promise { - const { log, bail, requestedToolDisclosure, requestedObservabilityEnabled, skipConfirm } = - createRebuildCommandContext(options, opts); + const { + log, + bail, + requestedToolDisclosure, + requestedDcodeAutoApprovalMode, + requestedObservabilityEnabled, + skipConfirm, + } = createRebuildCommandContext(options, opts); const activeSessionCount = countActiveSandboxSessionsForRebuild(sandboxName); const sandboxEntry = getRebuildSandboxEntryOrBail(sandboxName, bail); if (!sandboxEntry) return null; @@ -110,6 +118,29 @@ export async function runRebuildPreflightPhase( if (!isSingleAgentRebuildSupported(sandboxEntry, bail)) return null; const rebuildAgent = sandboxEntry.agent || null; + const dcodeAutoApprovalIssue = managedSandboxFeatureIssue(DCODE_AUTO_APPROVAL_FEATURE, { + agent: rebuildAgent, + requested: requestedDcodeAutoApprovalMode, + registryValue: sandboxEntry.dcodeAutoApprovalMode, + }); + if (dcodeAutoApprovalIssue === "unsupported-request") { + printRebuildPreflightFailure( + "the DCode auto-approval override is supported only for managed LangChain Deep Agents Code sandboxes.", + "Remove --dcode-auto-approval or select a managed Deep Agents Code sandbox.", + "Unsupported rebuild DCode auto-approval override", + bail, + ); + return null; + } + if (dcodeAutoApprovalIssue === "recorded-state-on-unsupported-agent") { + printRebuildPreflightFailure( + "recorded DCode auto-approval is enabled for a sandbox whose agent does not support it.", + "Pass --dcode-auto-approval disabled to clear the incompatible state during rebuild.", + "Recorded DCode auto-approval state is incompatible with the sandbox agent", + bail, + ); + return null; + } if (requestedObservabilityEnabled !== undefined && !isDcodeRebuildAgent(rebuildAgent)) { printRebuildPreflightFailure( "the observability override is supported only for managed LangChain Deep Agents Code sandboxes.", @@ -125,7 +156,14 @@ export async function runRebuildPreflightPhase( isDcodeRebuildAgent(rebuildAgent) || checkRebuildGatewaySchemaPreflight(sandboxName, sandboxEntry, bail), confirmIntent: () => - confirmRebuildIntent(sandboxName, agentName, skipConfirm, activeSessionCount, bail), + confirmRebuildIntent( + sandboxName, + agentName, + skipConfirm, + activeSessionCount, + bail, + requestedDcodeAutoApprovalMode, + ), }); if (!versionCheck) return null; const expectedSandboxEntry = expectedRebuildEntryAfterVersionCheck( @@ -165,6 +203,7 @@ export async function runRebuildPreflightPhase( // succeeded, matching the previous `skipConfirm || confirmed` contract. autoYes: true, requestedToolDisclosure, + requestedDcodeAutoApprovalMode, requestedObservabilityEnabled, allowLegacyManagedImageRecovery, // A validated prepared backup is the only path allowed to reconstruct @@ -186,6 +225,7 @@ export async function runRebuildPreflightPhase( preparedTarget.targetConfig.resumeConfig, preparedTarget.targetConfig.durableConfig.webSearchConfig, preparedTarget.targetConfig.durableConfig.toolDisclosure, + preparedTarget.targetConfig.durableConfig.dcodeAutoApprovalMode, recoveryRecreate, preparedTarget.recreateOptions.targetGatewayPort, ); diff --git a/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts b/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts index b5e778517e7..de52c49d90a 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-target-phase.ts @@ -4,6 +4,7 @@ import { CLI_NAME } from "../../cli/branding"; import type { SandboxMessagingPlan } from "../../messaging"; import { isSandboxBaseImageRefreshRequested } from "../../onboard/base-image-resolution-flow"; +import type { DcodeAutoApprovalMode } from "../../onboard/dcode-auto-approval"; import { createRebuildProviderReconfigureHandoff } from "../../onboard/rebuild-route-handoff"; import { readSandboxBaseImageResolutionMetadata } from "../../sandbox-base-image"; import * as registry from "../../state/registry"; @@ -50,6 +51,7 @@ export async function prepareRebuildTargetPreflights(args: { rebuildAgent: string | null; autoYes: boolean; requestedToolDisclosure?: ToolDisclosure; + requestedDcodeAutoApprovalMode?: DcodeAutoApprovalMode; requestedObservabilityEnabled?: boolean; allowLegacyManagedImageRecovery?: boolean; preparedBackupRecovery?: boolean; @@ -62,6 +64,7 @@ export async function prepareRebuildTargetPreflights(args: { rebuildAgent, autoYes, requestedToolDisclosure, + requestedDcodeAutoApprovalMode, requestedObservabilityEnabled, allowLegacyManagedImageRecovery, preparedBackupRecovery, @@ -80,6 +83,7 @@ export async function prepareRebuildTargetPreflights(args: { bail, requestedToolDisclosure, allowLegacyManagedImageRecovery, + requestedDcodeAutoApprovalMode, ); if (!targetConfig) return null; const { resumeConfig, durableConfig, credentialEnv, fromDockerfile } = targetConfig; @@ -100,6 +104,9 @@ export async function prepareRebuildTargetPreflights(args: { // 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; + recreateOptions.dcodeAutoApprovalMode = durableConfig.dcodeAutoApprovalMode; + recreateOptions.dcodeAutoApprovalRequestedExplicitly = + requestedDcodeAutoApprovalMode !== undefined; recreateOptions.observabilityEnabled = requestedObservabilityEnabled ?? recreateOptions.observabilityEnabled; recreateOptions.observabilityRequestedExplicitly = requestedObservabilityEnabled !== undefined; diff --git a/src/lib/actions/sandbox/rebuild-recreate-observability.test.ts b/src/lib/actions/sandbox/rebuild-recreate-observability.test.ts index 426d5fbc98c..181f20c54f1 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-observability.test.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-observability.test.ts @@ -15,6 +15,8 @@ import type { RebuildResumeConfig } from "./rebuild-resume-config"; const DCODE_AGENT = "langchain-deepagents-code"; const durableConfig: RebuildDurableConfig = { + dcodeAutoApprovalMode: "disabled", + dcodeAutoApprovalModeError: null, fromDockerfile: null, fromDockerfileError: null, hermesAuthMethod: null, @@ -55,6 +57,8 @@ const recreateOptions: RebuildRecreateOnboardOpts = { onboardLockAlreadyHeld: true, autoYes: true, toolDisclosure: "progressive", + dcodeAutoApprovalMode: "disabled", + dcodeAutoApprovalRequestedExplicitly: false, observabilityEnabled: true, observabilityRequestedExplicitly: true, policyTier: "restricted", diff --git a/src/lib/actions/sandbox/rebuild-recreate-phase.ts b/src/lib/actions/sandbox/rebuild-recreate-phase.ts index c81eaa74750..576e0e61ba6 100644 --- a/src/lib/actions/sandbox/rebuild-recreate-phase.ts +++ b/src/lib/actions/sandbox/rebuild-recreate-phase.ts @@ -235,6 +235,10 @@ export async function runRebuildRecreatePhase(input: RebuildRecreatePhaseInput): enabled: recreateOptions.observabilityEnabled, requestedExplicitly: recreateOptions.observabilityRequestedExplicitly, }, + { + mode: recreateOptions.dcodeAutoApprovalMode, + requestedExplicitly: recreateOptions.dcodeAutoApprovalRequestedExplicitly, + }, ); if (backupManifest) { console.error(" 3. Then restore your workspace state:"); diff --git a/src/lib/actions/sandbox/rebuild-target-config.ts b/src/lib/actions/sandbox/rebuild-target-config.ts index 25fba86a965..f711ee23f1b 100644 --- a/src/lib/actions/sandbox/rebuild-target-config.ts +++ b/src/lib/actions/sandbox/rebuild-target-config.ts @@ -3,6 +3,7 @@ import { loadAgent } from "../../agent/defs"; import { webSearchProviderForConfig } from "../../inference/web-search"; +import type { DcodeAutoApprovalMode } from "../../onboard/dcode-auto-approval"; import type { Session } from "../../state/onboard-session"; import * as onboardSession from "../../state/onboard-session"; import type { ToolDisclosure } from "../../tool-disclosure"; @@ -80,6 +81,15 @@ function validateRebuildDurableConfig( ); return false; } + if (durableConfig.dcodeAutoApprovalModeError) { + printRebuildPreflightFailure( + "recorded DCode auto-approval state is invalid.", + durableConfig.dcodeAutoApprovalModeError, + "Recorded DCode auto-approval state is invalid", + bail, + ); + return false; + } if (durableConfig.fromDockerfileError) { printRebuildPreflightFailure( "recorded custom Dockerfile is invalid.", @@ -114,6 +124,7 @@ export function prepareRebuildTargetConfig( bail: RebuildBail, requestedToolDisclosure?: ToolDisclosure, allowLegacyManagedImageRecovery = false, + requestedDcodeAutoApprovalMode?: DcodeAutoApprovalMode, ): RebuildTargetConfig | null { const resumeConfig = prepareRebuildResumeConfig(sandboxName, sb, rebuildAgent, log, bail); if (!resumeConfig) return null; @@ -129,6 +140,7 @@ export function prepareRebuildTargetConfig( }, requestedToolDisclosure, allowLegacyManagedImageRecovery, + requestedDcodeAutoApprovalMode, ); if (!validateRebuildDurableConfig(durableConfig, resumeConfig, bail)) return null; if (isDcodeRebuildAgent(rebuildAgent) && durableConfig.fromDockerfile) { diff --git a/src/lib/actions/sandbox/status-flow.test.ts b/src/lib/actions/sandbox/status-flow.test.ts index 00566a19adf..5dae9c4dbc1 100644 --- a/src/lib/actions/sandbox/status-flow.test.ts +++ b/src/lib/actions/sandbox/status-flow.test.ts @@ -144,6 +144,7 @@ describe("showSandboxStatus flow", () => { sandboxEntry: { agent: "langchain-deepagents-code", agentVersion: null, + dcodeAutoApprovalMode: "thread-opt-in", }, }); @@ -151,6 +152,7 @@ describe("showSandboxStatus flow", () => { const output = harness.logSpy.mock.calls.map((call) => String(call[0])).join("\n"); expect(output).toContain("Harness: LangChain Deep Agents Code (terminal)"); + expect(output).toContain("DCode auto-approval capability: thread-opt-in"); expect(output).toContain("Agent: LangChain Deep Agents Code v0.1.0"); expect(output).toContain("Update:"); expect(output).toContain("Run `nemoclaw alpha rebuild` to upgrade"); diff --git a/src/lib/actions/sandbox/status-snapshot.ts b/src/lib/actions/sandbox/status-snapshot.ts index 0f7a9438d74..8092073765a 100644 --- a/src/lib/actions/sandbox/status-snapshot.ts +++ b/src/lib/actions/sandbox/status-snapshot.ts @@ -14,6 +14,10 @@ import { type ProviderHealthStatus, probeProviderHealth, } from "../../inference/health"; +import { + type DcodeAutoApprovalMode, + normalizeDcodeAutoApprovalMode, +} from "../../onboard/dcode-auto-approval"; import { redact } from "../../security/redact"; import { parseSandboxPhase } from "../../state/gateway"; import * as registry from "../../state/registry"; @@ -123,6 +127,7 @@ export interface SandboxStatusReport { agent: string; agentDisplayName: string; agentRuntime: "gateway" | "terminal" | "unknown"; + dcodeAutoApprovalMode: DcodeAutoApprovalMode | null; agentLoadError?: string; model: string; provider: string; @@ -169,6 +174,13 @@ export interface SandboxStatusAgentInfo { agentDefinition: AgentDefinition | null; } +export function resolveSandboxStatusDcodeAutoApprovalMode( + sandbox: registry.SandboxEntry | null, +): DcodeAutoApprovalMode | null { + if (sandbox?.agent !== "langchain-deepagents-code") return null; + return normalizeDcodeAutoApprovalMode(sandbox.dcodeAutoApprovalMode); +} + export function resolveSandboxStatusAgent(agentName = "openclaw"): SandboxStatusAgentInfo { let agentDisplayName = agentName === "openclaw" ? "OpenClaw" : agentName; let agentRuntime: SandboxStatusAgentInfo["agentRuntime"] = "gateway"; @@ -376,6 +388,7 @@ async function buildSandboxStatusReport( agent: agent.agentName, agentDisplayName: agent.agentDisplayName, agentRuntime: agent.agentRuntime, + dcodeAutoApprovalMode: resolveSandboxStatusDcodeAutoApprovalMode(sb), ...(agent.agentLoadError ? { agentLoadError: agent.agentLoadError } : {}), model: currentModel, provider: currentProvider, diff --git a/src/lib/actions/sandbox/status-text.ts b/src/lib/actions/sandbox/status-text.ts index c64f0c43ed3..6a99d3495f4 100644 --- a/src/lib/actions/sandbox/status-text.ts +++ b/src/lib/actions/sandbox/status-text.ts @@ -19,6 +19,7 @@ import type { SandboxGatewayState } from "./gateway-state"; import { isSandboxGatewayRunningForStatus } from "./process-recovery"; import { isInferenceHealthFailing, + resolveSandboxStatusDcodeAutoApprovalMode, type SandboxStatusAgentInfo, type SandboxStatusSnapshot, } from "./status-snapshot"; @@ -169,8 +170,12 @@ function printTerminalHarness(context: SandboxStatusTextContext): number | null } function printAgentHarness(context: SandboxStatusTextContext): number | null { - const { statusAgent } = context; + const { sb, statusAgent } = context; console.log(` Harness: ${statusAgent.agentDisplayName} (${statusAgent.agentRuntime})`); + const dcodeAutoApprovalMode = resolveSandboxStatusDcodeAutoApprovalMode(sb); + if (dcodeAutoApprovalMode) { + console.log(` DCode auto-approval capability: ${dcodeAutoApprovalMode}`); + } if (statusAgent.agentLoadError) { console.log(` Agent load error: ${statusAgent.agentLoadError}`); } diff --git a/src/lib/actions/sandbox/status.test.ts b/src/lib/actions/sandbox/status.test.ts index 6040daa6929..223817d7c90 100644 --- a/src/lib/actions/sandbox/status.test.ts +++ b/src/lib/actions/sandbox/status.test.ts @@ -7,12 +7,57 @@ import { classifySandboxContainerFailureForStatus, classifySandboxStatusPreflightFailure, getSandboxStatusInferenceHealth, + getSandboxStatusReport, isDockerDaemonUnreachableForStatus, maybeGetSandboxStatusInferenceHealth, + resolveSandboxStatusDcodeAutoApprovalMode, sandboxGpuProofStatusSuffix, sandboxGpuProofUnverified, } from "./status"; +describe("sandbox status DCode auto-approval (#6478)", () => { + it("defaults legacy DCode entries to disabled", () => { + expect( + resolveSandboxStatusDcodeAutoApprovalMode({ + name: "dcode", + agent: "langchain-deepagents-code", + } as never), + ).toBe("disabled"); + }); + + it("projects effective DCode mode into JSON while using null for other agents", async () => { + const missingLookup = async () => ({ state: "missing" as const, output: "not found" }); + const legacyDcode = await getSandboxStatusReport("dcode", { + getSandbox: () => ({ name: "dcode", agent: "langchain-deepagents-code" }) as never, + reconcile: missingLookup, + }); + const openclaw = await getSandboxStatusReport("openclaw", { + getSandbox: () => ({ name: "openclaw", agent: "openclaw" }) as never, + reconcile: missingLookup, + }); + + expect(legacyDcode.dcodeAutoApprovalMode).toBe("disabled"); + expect(openclaw.dcodeAutoApprovalMode).toBeNull(); + }); + + it("reports the recorded DCode mode and omits it for other agents", () => { + expect( + resolveSandboxStatusDcodeAutoApprovalMode({ + name: "dcode", + agent: "langchain-deepagents-code", + dcodeAutoApprovalMode: "thread-opt-in", + } as never), + ).toBe("thread-opt-in"); + expect( + resolveSandboxStatusDcodeAutoApprovalMode({ + name: "openclaw", + agent: "openclaw", + dcodeAutoApprovalMode: "thread-opt-in", + } as never), + ).toBeNull(); + }); +}); + describe("sandbox status inference health", () => { it("passes the current model with the current provider", () => { let observed: { provider: string; options?: ProviderHealthProbeOptions } | null = null; diff --git a/src/lib/actions/sandbox/status.ts b/src/lib/actions/sandbox/status.ts index 34f9ad39010..4715caf25e4 100644 --- a/src/lib/actions/sandbox/status.ts +++ b/src/lib/actions/sandbox/status.ts @@ -40,6 +40,7 @@ export { getSandboxStatusReport, isInferenceHealthFailing, maybeGetSandboxStatusInferenceHealth, + resolveSandboxStatusDcodeAutoApprovalMode, type SandboxStatusReport, type SandboxStatusSnapshot, } from "./status-snapshot"; diff --git a/src/lib/domain/lifecycle/options.test.ts b/src/lib/domain/lifecycle/options.test.ts index da488ac2d06..a58fcd489ad 100644 --- a/src/lib/domain/lifecycle/options.test.ts +++ b/src/lib/domain/lifecycle/options.test.ts @@ -119,8 +119,14 @@ describe("lifecycle option normalization", () => { it("preserves typed rebuild options and still accepts compatibility argv", () => { expect( - normalizeRebuildSandboxOptions({ toolDisclosure: "direct", verbose: true, yes: true }), + normalizeRebuildSandboxOptions({ + dcodeAutoApprovalMode: "thread-opt-in", + toolDisclosure: "direct", + verbose: true, + yes: true, + }), ).toEqual({ + dcodeAutoApprovalMode: "thread-opt-in", toolDisclosure: "direct", verbose: true, yes: true, @@ -136,6 +142,13 @@ describe("lifecycle option normalization", () => { expect(normalizeRebuildSandboxOptions(["--tool-disclosure=direct"]).toolDisclosure).toBe( "direct", ); + expect( + normalizeRebuildSandboxOptions(["--dcode-auto-approval", "thread-opt-in"]) + .dcodeAutoApprovalMode, + ).toBe("thread-opt-in"); + expect( + normalizeRebuildSandboxOptions(["--dcode-auto-approval=disabled"]).dcodeAutoApprovalMode, + ).toBe("disabled"); expect(normalizeRebuildSandboxOptions(["--observability"]).observabilityEnabled).toBe(true); expect(normalizeRebuildSandboxOptions(["--no-observability"]).observabilityEnabled).toBe(false); expect( @@ -155,6 +168,15 @@ describe("lifecycle option normalization", () => { expect(() => normalizeRebuildSandboxOptions(["--tool-disclosure="])).toThrow( /progressive, direct/, ); + expect(() => normalizeRebuildSandboxOptions(["--dcode-auto-approval", "always"])).toThrow( + /disabled, thread-opt-in/, + ); + expect(() => normalizeRebuildSandboxOptions(["--dcode-auto-approval"])).toThrow( + /disabled, thread-opt-in/, + ); + expect(() => normalizeRebuildSandboxOptions(["--dcode-auto-approval="])).toThrow( + /disabled, thread-opt-in/, + ); }); 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 db94f243c01..b5b7cd3e891 100644 --- a/src/lib/domain/lifecycle/options.ts +++ b/src/lib/domain/lifecycle/options.ts @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + DCODE_AUTO_APPROVAL_MODES, + type DcodeAutoApprovalMode, + isDcodeAutoApprovalMode, +} from "../../onboard/dcode-auto-approval"; import { normalizeToolDisclosure, TOOL_DISCLOSURE_VALUES, @@ -32,6 +37,7 @@ function readCleanupGatewayEnv(): boolean | undefined { } export interface RebuildSandboxOptions { + dcodeAutoApprovalMode?: DcodeAutoApprovalMode; force?: boolean; observabilityEnabled?: boolean; toolDisclosure?: ToolDisclosure; @@ -77,6 +83,7 @@ export function normalizeDestroySandboxOptions( export function normalizeRebuildSandboxOptions( options: string[] | RebuildSandboxOptions = {}, ): RebuildSandboxOptions { + let rawDcodeAutoApprovalMode: unknown; let rawToolDisclosure: unknown; if (Array.isArray(options)) { const observabilityIndex = options.lastIndexOf("--observability"); @@ -94,7 +101,26 @@ export function normalizeRebuildSandboxOptions( if (toolDisclosureFlagProvided && !toolDisclosure) { throw new Error(`--tool-disclosure must be one of: ${TOOL_DISCLOSURE_VALUES.join(", ")}.`); } + const dcodeAutoApprovalSplitIndex = options.lastIndexOf("--dcode-auto-approval"); + const dcodeAutoApprovalInline = [...options] + .reverse() + .find((value) => value.startsWith("--dcode-auto-approval=")); + const dcodeAutoApprovalFlagProvided = + dcodeAutoApprovalSplitIndex >= 0 || dcodeAutoApprovalInline !== undefined; + rawDcodeAutoApprovalMode = + dcodeAutoApprovalSplitIndex >= 0 + ? options[dcodeAutoApprovalSplitIndex + 1] + : dcodeAutoApprovalInline?.slice("--dcode-auto-approval=".length); + const dcodeAutoApprovalMode = isDcodeAutoApprovalMode(rawDcodeAutoApprovalMode) + ? rawDcodeAutoApprovalMode + : undefined; + if (dcodeAutoApprovalFlagProvided && !dcodeAutoApprovalMode) { + throw new Error( + `--dcode-auto-approval must be one of: ${DCODE_AUTO_APPROVAL_MODES.join(", ")}.`, + ); + } return { + ...(dcodeAutoApprovalMode ? { dcodeAutoApprovalMode } : {}), force: options.includes("--force"), ...(observabilityEnabled === undefined ? {} : { observabilityEnabled }), ...(toolDisclosure ? { toolDisclosure } : {}), @@ -102,12 +128,25 @@ export function normalizeRebuildSandboxOptions( yes: options.includes("--yes"), }; } + rawDcodeAutoApprovalMode = options.dcodeAutoApprovalMode; + const dcodeAutoApprovalMode = isDcodeAutoApprovalMode(rawDcodeAutoApprovalMode) + ? rawDcodeAutoApprovalMode + : undefined; + if (rawDcodeAutoApprovalMode !== undefined && !dcodeAutoApprovalMode) { + throw new Error( + `dcodeAutoApprovalMode must be one of: ${DCODE_AUTO_APPROVAL_MODES.join(", ")}.`, + ); + } 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 } : {}) }; + return { + ...options, + ...(dcodeAutoApprovalMode ? { dcodeAutoApprovalMode } : {}), + ...(toolDisclosure ? { toolDisclosure } : {}), + }; } export function normalizeGarbageCollectImagesOptions( diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index b77f442080e..9c348d06862 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -34,6 +34,7 @@ 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 runtimeControlFlow: typeof import("./onboard/runtime-control-flow") = require("./onboard/runtime-control-flow"); +const dcodeAutoApprovalFlow: typeof import("./onboard/dcode-auto-approval") = require("./onboard/dcode-auto-approval"); const observabilityPolicy: typeof import("./onboard/observability-policy-presets") = require("./onboard/observability-policy-presets"); const observabilityCommandFlag: typeof import("./onboard/observability-command-flag") = require("./onboard/observability-command-flag"); const inferenceRouteHelpers: typeof import("./onboard/inference-route") = require("./onboard/inference-route"); @@ -2377,17 +2378,10 @@ async function createSandboxWithBaseImageResolution( // 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); - if (liveExists && isManagedDcodeAgent && !existingEntry) { - console.error( - ` Sandbox '${sandboxName}' is live but missing its NemoClaw registry record; refusing unverified DCode reuse or recreation.`, - ); - console.error( - " Choose a different sandbox name, or remove the orphan explicitly with OpenShell.", - ); - process.exit(1); - } // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. const observabilityDrift = observabilityPolicy.hasRegisteredDcodeObservabilityDrift(liveExists, isManagedDcodeAgent, existingEntry, createIntent?.observabilityEnabled); + // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. + const dcodeAutoApprovalPlan = dcodeAutoApprovalFlow.prepareDcodeAutoApprovalCreatePlan({ sandboxName, liveExists, managedDcodeAgent: isManagedDcodeAgent, registryEntry: existingEntry, requestedMode: createIntent?.dcodeAutoApprovalMode }, { error: console.error, exitProcess: (code) => process.exit(code) }); // #4614: capture default AFTER prune so a stale registry row isn't read as a live sandbox. const sandboxWasLiveDefault = liveExists && wasSandboxDefault(registry.getDefault(), sandboxName); @@ -2483,7 +2477,8 @@ async function createSandboxWithBaseImageResolution( !hermesToolGatewayDrift && !hermesDashboardDrift && !toolDisclosureMigrationNeeded && - !observabilityDrift + !observabilityDrift && + !dcodeAutoApprovalPlan.hasDrift ) { // Guard against reusing a CPU-only sandbox when GPU passthrough is enabled. // Placed before the non-interactive / interactive split so all reuse @@ -2638,6 +2633,8 @@ async function createSandboxWithBaseImageResolution( note(` Sandbox '${sandboxName}' exists — recreating to apply Hermes dashboard settings.`); } else if (observabilityDrift) { note(` Sandbox '${sandboxName}' exists — recreating to apply observability settings.`); + } else if (dcodeAutoApprovalPlan.hasDrift) { + note(` Sandbox '${sandboxName}' exists — recreating to apply DCode auto-approval settings.`); } else if (toolDisclosureMigrationNote) { note(toolDisclosureMigrationNote); } else if (credentialRotation.changed) { @@ -2655,7 +2652,7 @@ async function createSandboxWithBaseImageResolution( ` Sandbox '${sandboxName}' has managed MCP servers. Refusing the generic onboard recreation path.`, ); console.error( - ` Run \`${cliName()} ${sandboxName} rebuild --yes --tool-disclosure ${effectiveToolDisclosure}${explicitObservability ? ` ${explicitObservability}` : ""}\` so MCP providers and adapter state are preserved transactionally.`, + ` Run \`${cliName()} ${sandboxName} rebuild --yes --tool-disclosure ${effectiveToolDisclosure}${explicitObservability ? ` ${explicitObservability}` : ""}${dcodeAutoApprovalPlan.rebuildFlag}\` so MCP providers and adapter state are preserved transactionally.`, ); process.exit(1); } @@ -2805,6 +2802,7 @@ async function createSandboxWithBaseImageResolution( preferredInferenceApi, webSearchConfig, toolDisclosure: effectiveToolDisclosure, + ...(isManagedDcodeAgent ? { dcodeAutoApprovalMode: dcodeAutoApprovalPlan.mode } : {}), hermesToolGateways, sandboxGpuConfig: effectiveSandboxGpuConfig, ...baseImageResolutionFlow.getBaseImageResolutionPatchOptions(baseImageResolutionContext), @@ -3015,6 +3013,7 @@ async function createSandboxWithBaseImageResolution( appliedPolicies: initialSandboxPolicy.appliedPresets, toolDisclosure: effectiveToolDisclosure, observabilityEnabled: createIntent?.observabilityEnabled === true, + ...(isManagedDcodeAgent ? { dcodeAutoApprovalMode: dcodeAutoApprovalPlan.mode } : {}), policyTier: resolvedCreatePolicyTier, // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. ...sandboxRegistration.creationFidelity(webSearchConfig, fromDockerfile, normalizeHermesAuthMethod(hermesAuthMethod)), @@ -4511,6 +4510,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { sandbox: { resumeAgentChanged, requestedObservabilityEnabled: runtimeControlRequests.requestedObservabilityEnabled, + requestedDcodeAutoApprovalMode: runtimeControlRequests.requestedDcodeAutoApprovalMode, authoritativePolicyTier: opts.authoritativeResumeConfig === true ? (opts.policyTier ?? null) : null, controlUiPort: _preflightDashboardPort, diff --git a/src/lib/onboard/dcode-auto-approval.test.ts b/src/lib/onboard/dcode-auto-approval.test.ts new file mode 100644 index 00000000000..205d3e51e37 --- /dev/null +++ b/src/lib/onboard/dcode-auto-approval.test.ts @@ -0,0 +1,108 @@ +// 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 { + DCODE_AUTO_APPROVAL_BUILD_ARG, + DCODE_AUTO_APPROVAL_FEATURE, + DEFAULT_DCODE_AUTO_APPROVAL_MODE, + hasDcodeAutoApprovalDrift, + invalidRecordedDcodeAutoApprovalMode, + normalizeDcodeAutoApprovalMode, + prepareDcodeAutoApprovalCreatePlan, +} from "./dcode-auto-approval"; + +describe("DCode auto-approval capability", () => { + it("defaults missing and malformed input to the closed mode (#6478)", () => { + expect(DEFAULT_DCODE_AUTO_APPROVAL_MODE).toBe("disabled"); + expect(DCODE_AUTO_APPROVAL_BUILD_ARG).toBe("NEMOCLAW_DCODE_AUTO_APPROVAL"); + expect(normalizeDcodeAutoApprovalMode(undefined)).toBe("disabled"); + expect(normalizeDcodeAutoApprovalMode("THREAD-OPT-IN")).toBe("disabled"); + expect(normalizeDcodeAutoApprovalMode("thread-opt-in")).toBe("thread-opt-in"); + expect(invalidRecordedDcodeAutoApprovalMode(undefined)).toBe(false); + expect(invalidRecordedDcodeAutoApprovalMode("always")).toBe(true); + }); + + it("is enabled only for thread opt-in on Deep Agents Code (#6478)", () => { + expect(DCODE_AUTO_APPROVAL_FEATURE.supportsAgent("langchain-deepagents-code")).toBe(true); + expect(DCODE_AUTO_APPROVAL_FEATURE.supportsAgent("hermes")).toBe(false); + expect(DCODE_AUTO_APPROVAL_FEATURE.isEnabled("disabled")).toBe(false); + expect(DCODE_AUTO_APPROVAL_FEATURE.isEnabled("thread-opt-in")).toBe(true); + }); + + it("treats missing legacy state as disabled without forcing migration (#6478)", () => { + expect( + hasDcodeAutoApprovalDrift({ + liveExists: true, + managedDcodeAgent: true, + hasRegistryEntry: true, + recordedDcodeAutoApprovalMode: undefined, + requestedDcodeAutoApprovalMode: "disabled", + }), + ).toBe(false); + expect( + hasDcodeAutoApprovalDrift({ + liveExists: true, + managedDcodeAgent: true, + hasRegistryEntry: true, + recordedDcodeAutoApprovalMode: undefined, + requestedDcodeAutoApprovalMode: "thread-opt-in", + }), + ).toBe(true); + }); + + it("marks malformed recorded state as drift without ever enabling it (#6478)", () => { + expect( + hasDcodeAutoApprovalDrift({ + liveExists: true, + managedDcodeAgent: true, + hasRegistryEntry: true, + recordedDcodeAutoApprovalMode: "always", + requestedDcodeAutoApprovalMode: "thread-opt-in", + }), + ).toBe(true); + expect(normalizeDcodeAutoApprovalMode("always")).toBe("disabled"); + }); + + it("prepares the managed create projection and rebuild flag (#6478)", () => { + expect( + prepareDcodeAutoApprovalCreatePlan({ + sandboxName: "alpha", + liveExists: true, + managedDcodeAgent: true, + registryEntry: { dcodeAutoApprovalMode: "disabled" }, + requestedMode: "thread-opt-in", + }), + ).toEqual({ + mode: "thread-opt-in", + hasDrift: true, + rebuildFlag: " --dcode-auto-approval thread-opt-in", + }); + }); + + it.each([ + ["orphaned", null, "missing its NemoClaw registry record"], + ["malformed", { dcodeAutoApprovalMode: "always" }, "mode is invalid"], + ])("rejects %s create state before mutation (#6478)", (_label, registryEntry, message) => { + const error = vi.fn(); + expect(() => + prepareDcodeAutoApprovalCreatePlan( + { + sandboxName: "alpha", + liveExists: true, + managedDcodeAgent: true, + registryEntry, + requestedMode: "thread-opt-in", + }, + { + error, + exitProcess: vi.fn(() => { + throw new Error("exit 1"); + }), + }, + ), + ).toThrow("exit 1"); + expect(error.mock.calls.flat().join("\n")).toContain(message); + }); +}); diff --git a/src/lib/onboard/dcode-auto-approval.ts b/src/lib/onboard/dcode-auto-approval.ts new file mode 100644 index 00000000000..914bc3d89f5 --- /dev/null +++ b/src/lib/onboard/dcode-auto-approval.ts @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + type ManagedSandboxFeature, + managedSandboxFeatureHasDrift, + resolveManagedSandboxFeature, +} from "./managed-sandbox-feature"; +import { DCODE_AGENT_NAME, isDcodeAgent } from "./observability-policy-presets"; + +export const DCODE_AUTO_APPROVAL_MODES = ["disabled", "thread-opt-in"] as const; +export type DcodeAutoApprovalMode = (typeof DCODE_AUTO_APPROVAL_MODES)[number]; + +export const DEFAULT_DCODE_AUTO_APPROVAL_MODE: DcodeAutoApprovalMode = "disabled"; +export const DCODE_AUTO_APPROVAL_BUILD_ARG = "NEMOCLAW_DCODE_AUTO_APPROVAL"; + +export function isDcodeAutoApprovalMode(value: unknown): value is DcodeAutoApprovalMode { + return value === "disabled" || value === "thread-opt-in"; +} + +/** Normalize untrusted input to the closed, non-auto-approving posture. */ +export function normalizeDcodeAutoApprovalMode(value: unknown): DcodeAutoApprovalMode { + return isDcodeAutoApprovalMode(value) ? value : DEFAULT_DCODE_AUTO_APPROVAL_MODE; +} + +/** Missing legacy state is valid and means disabled; any other unknown value is malformed. */ +export function invalidRecordedDcodeAutoApprovalMode(value: unknown): boolean { + return value !== undefined && value !== null && !isDcodeAutoApprovalMode(value); +} + +export const DCODE_AUTO_APPROVAL_FEATURE: ManagedSandboxFeature = { + id: "dcode-auto-approval", + defaultValue: DEFAULT_DCODE_AUTO_APPROVAL_MODE, + isValue: isDcodeAutoApprovalMode, + isEnabled: (value) => value === "thread-opt-in", + supportsAgent: isDcodeAgent, +}; + +export function resolveDcodeAutoApprovalRequest(input: { + agent: string | null | undefined; + requestedMode: DcodeAutoApprovalMode | null | undefined; + recordedMode: unknown; +}): { mode: DcodeAutoApprovalMode; error: string | null } { + if (invalidRecordedDcodeAutoApprovalMode(input.recordedMode)) { + return { + mode: DEFAULT_DCODE_AUTO_APPROVAL_MODE, + error: + " Recorded DCode auto-approval mode is invalid. Refusing to enable or reuse the sandbox; repair the recorded state to 'disabled' before retrying.", + }; + } + const resolution = resolveManagedSandboxFeature(DCODE_AUTO_APPROVAL_FEATURE, { + agent: input.agent, + requested: input.requestedMode, + registryValue: isDcodeAutoApprovalMode(input.recordedMode) ? input.recordedMode : null, + }); + const error = + resolution.issue === "unsupported-request" + ? " --dcode-auto-approval thread-opt-in is supported only with the managed --agent langchain-deepagents-code image." + : resolution.issue === "recorded-state-on-unsupported-agent" + ? " Recorded DCode auto-approval belongs to the existing Deep Agents Code sandbox. Pass --dcode-auto-approval disabled explicitly when switching agents." + : null; + return { mode: resolution.value, error }; +} + +export function hasDcodeAutoApprovalDrift(options: { + liveExists: boolean; + managedDcodeAgent: boolean; + hasRegistryEntry: boolean; + recordedDcodeAutoApprovalMode: unknown; + requestedDcodeAutoApprovalMode: unknown; +}): boolean { + if (invalidRecordedDcodeAutoApprovalMode(options.recordedDcodeAutoApprovalMode)) { + return true; + } + return managedSandboxFeatureHasDrift(DCODE_AUTO_APPROVAL_FEATURE, { + liveExists: options.liveExists, + hasRegistryEntry: options.hasRegistryEntry, + agent: options.managedDcodeAgent ? DCODE_AGENT_NAME : null, + // A legacy managed image has the same closed behavior as an explicit + // disabled mode, so absence alone does not force a migration rebuild. + recordedValue: normalizeDcodeAutoApprovalMode(options.recordedDcodeAutoApprovalMode), + desiredValue: normalizeDcodeAutoApprovalMode(options.requestedDcodeAutoApprovalMode), + }); +} + +export function hasRegisteredDcodeAutoApprovalDrift( + liveExists: boolean, + managedDcodeAgent: boolean, + registryEntry: { dcodeAutoApprovalMode?: unknown } | null, + requestedDcodeAutoApprovalMode: unknown, +): boolean { + return hasDcodeAutoApprovalDrift({ + liveExists, + managedDcodeAgent, + hasRegistryEntry: registryEntry !== null, + recordedDcodeAutoApprovalMode: registryEntry?.dcodeAutoApprovalMode, + requestedDcodeAutoApprovalMode, + }); +} + +export function prepareDcodeAutoApprovalCreatePlan( + input: { + sandboxName: string; + liveExists: boolean; + managedDcodeAgent: boolean; + registryEntry: { dcodeAutoApprovalMode?: unknown } | null; + requestedMode: unknown; + }, + deps: { error(message: string): void; exitProcess(code: number): never } = { + error: console.error, + exitProcess: (code) => process.exit(code), + }, +): { mode: DcodeAutoApprovalMode; hasDrift: boolean; rebuildFlag: string } { + if (input.liveExists && input.managedDcodeAgent && !input.registryEntry) { + deps.error( + ` Sandbox '${input.sandboxName}' is live but missing its NemoClaw registry record; refusing unverified DCode reuse or recreation.`, + ); + deps.error( + " Choose a different sandbox name, or remove the orphan explicitly with OpenShell.", + ); + deps.exitProcess(1); + } + if (invalidRecordedDcodeAutoApprovalMode(input.registryEntry?.dcodeAutoApprovalMode)) { + deps.error( + " Recorded DCode auto-approval mode is invalid. Refusing to enable or reuse the sandbox; repair the recorded state to 'disabled' before retrying.", + ); + deps.exitProcess(1); + } + const mode = normalizeDcodeAutoApprovalMode(input.requestedMode); + return { + mode, + hasDrift: hasRegisteredDcodeAutoApprovalDrift( + input.liveExists, + input.managedDcodeAgent, + input.registryEntry, + mode, + ), + rebuildFlag: input.managedDcodeAgent ? ` --dcode-auto-approval ${mode}` : "", + }; +} diff --git a/src/lib/onboard/dockerfile-patch-dcode-auto-approval.test.ts b/src/lib/onboard/dockerfile-patch-dcode-auto-approval.test.ts new file mode 100644 index 00000000000..57d2ab36daa --- /dev/null +++ b/src/lib/onboard/dockerfile-patch-dcode-auto-approval.test.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { patchDcodeAutoApprovalDockerArg } from "./dockerfile-patch"; + +describe("DCode auto-approval Dockerfile patch", () => { + it("rewrites the single exact managed build argument (#6478)", () => { + expect( + patchDcodeAutoApprovalDockerArg( + "FROM scratch\nARG NEMOCLAW_DCODE_AUTO_APPROVAL=disabled\n", + "thread-opt-in", + ), + ).toBe("FROM scratch\nARG NEMOCLAW_DCODE_AUTO_APPROVAL=thread-opt-in\n"); + expect( + patchDcodeAutoApprovalDockerArg( + "ARG NEMOCLAW_DCODE_AUTO_APPROVAL=disabled # stale comment\n", + "thread-opt-in", + ), + ).toBe("ARG NEMOCLAW_DCODE_AUTO_APPROVAL=thread-opt-in\n"); + }); + + it.each([ + ["a missing instruction", "FROM scratch\n"], + [ + "duplicate instructions", + "ARG NEMOCLAW_DCODE_AUTO_APPROVAL=disabled\nARG NEMOCLAW_DCODE_AUTO_APPROVAL=disabled\n", + ], + ["a commented instruction", "# ARG NEMOCLAW_DCODE_AUTO_APPROVAL=disabled\n"], + ])("fails closed for %s (#6478)", (_label, dockerfile) => { + expect(() => patchDcodeAutoApprovalDockerArg(dockerfile, "thread-opt-in")).toThrow( + "exactly one ARG NEMOCLAW_DCODE_AUTO_APPROVAL", + ); + }); +}); diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 70e26e8217f..9d9f7e9de7a 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -19,6 +19,11 @@ import { normalizeToolDisclosure, type ToolDisclosure, } from "../tool-disclosure"; +import { + DCODE_AUTO_APPROVAL_BUILD_ARG, + type DcodeAutoApprovalMode, + isDcodeAutoApprovalMode, +} from "./dcode-auto-approval"; import { dockerfileInstructions, readDockerfilePatchSnapshot, @@ -53,6 +58,24 @@ export interface PatchStagedDockerfileOptions { toolDisclosure?: ToolDisclosure; requireToolDisclosureContract?: boolean; baseImageResolutionMetadata?: SandboxBaseImageResolutionMetadata | null; + dcodeAutoApprovalMode?: DcodeAutoApprovalMode; +} + +export function patchDcodeAutoApprovalDockerArg( + dockerfile: string, + mode: DcodeAutoApprovalMode, +): string { + if (!isDcodeAutoApprovalMode(mode)) { + throw new Error("Invalid DCode auto-approval mode; refusing to patch the Dockerfile."); + } + const instruction = new RegExp(`^ARG ${DCODE_AUTO_APPROVAL_BUILD_ARG}=[^\\r\\n]*$`, "gm"); + const matches = dockerfile.match(instruction) ?? []; + if (matches.length !== 1) { + throw new Error( + `Dockerfile must contain exactly one ARG ${DCODE_AUTO_APPROVAL_BUILD_ARG}=... instruction; found ${matches.length}.`, + ); + } + return dockerfile.replace(instruction, `ARG ${DCODE_AUTO_APPROVAL_BUILD_ARG}=${mode}`); } export function isValidProxyHost(value: string): boolean { @@ -101,6 +124,9 @@ export function patchStagedDockerfile( if (toolDisclosureInstruction) { dockerfile = `${dockerfile.slice(0, toolDisclosureInstruction.start)}ARG NEMOCLAW_TOOL_DISCLOSURE=${sanitizeDockerArg(toolDisclosure)}${dockerfile.slice(toolDisclosureInstruction.end)}`; } + if (options.dcodeAutoApprovalMode !== undefined) { + dockerfile = patchDcodeAutoApprovalDockerArg(dockerfile, options.dcodeAutoApprovalMode); + } // 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. diff --git a/src/lib/onboard/machine/core-flow-phases.ts b/src/lib/onboard/machine/core-flow-phases.ts index a97edef2ca8..957807b4d3d 100644 --- a/src/lib/onboard/machine/core-flow-phases.ts +++ b/src/lib/onboard/machine/core-flow-phases.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { WebSearchConfig } from "../../inference/web-search"; +import type { DcodeAutoApprovalMode } from "../dcode-auto-approval"; import { mergeProviderModelSelectedContext, mergeSandboxCreatedContext, @@ -35,6 +36,7 @@ export interface CoreOnboardFlowPhaseOptions< sandbox: { resumeAgentChanged: boolean; requestedObservabilityEnabled?: boolean | null; + requestedDcodeAutoApprovalMode?: DcodeAutoApprovalMode | null; authoritativePolicyTier?: string | null; controlUiPort: number | null; rootDir: string; @@ -115,6 +117,7 @@ export function createCoreOnboardFlowPhases< authoritativePolicyTier: options.sandbox.authoritativePolicyTier, resumeAgentChanged: options.sandbox.resumeAgentChanged, requestedObservabilityEnabled: options.sandbox.requestedObservabilityEnabled, + requestedDcodeAutoApprovalMode: options.sandbox.requestedDcodeAutoApprovalMode, session: context.session, sandboxName: context.sandboxName, model: context.model, diff --git a/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts b/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts index 4c960aa65b9..08ebc4de889 100644 --- a/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts +++ b/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts @@ -3,6 +3,11 @@ import type { Session } from "../../../state/onboard-session"; import type { SandboxEntry } from "../../../state/registry"; +import { + type DcodeAutoApprovalMode, + hasDcodeAutoApprovalDrift, + resolveDcodeAutoApprovalRequest, +} from "../../dcode-auto-approval"; import { usesManagedDcodeIdentity } from "../../dcode-selection-drift"; import type { SandboxResumeDecision } from "./sandbox-resume"; @@ -27,6 +32,7 @@ interface SelectionOptions { interface ResumeOptions extends SelectionOptions { readonly resume: boolean; readonly preferredInferenceApi: string | null; + readonly requestedDcodeAutoApprovalMode?: DcodeAutoApprovalMode | null; } interface ResumeState { @@ -38,6 +44,28 @@ function agentName(agent: Agent): string | null | undefined { return (agent as { name?: string } | null | undefined)?.name; } +export function resolveAutoApprovalMode( + options: SelectionOptions & { + readonly requestedDcodeAutoApprovalMode?: DcodeAutoApprovalMode | null; + }, + sandboxName: string | null, + deps: Pick & { + getSandboxRegistryEntry(name: string): SandboxEntry | null; + }, +): DcodeAutoApprovalMode { + const registryEntry = sandboxName ? deps.getSandboxRegistryEntry(sandboxName) : null; + const resolution = resolveDcodeAutoApprovalRequest({ + agent: usesManagedDcodeIdentity(agentName(options.agent), options.fromDockerfile) + ? agentName(options.agent) + : null, + requestedMode: options.requestedDcodeAutoApprovalMode, + recordedMode: registryEntry?.dcodeAutoApprovalMode, + }); + if (!resolution.error) return resolution.mode; + deps.error(resolution.error); + return deps.exitProcess(1); +} + export function preserveManagedDcodeRegistryEntry( options: SelectionOptions, decision: SandboxResumeDecision, @@ -57,9 +85,17 @@ export function resolveSignals( state: ResumeState, sandboxReuseState: string, registryEntry: SandboxEntry | null, + dcodeAutoApprovalMode: DcodeAutoApprovalMode, deps: Deps, -): { inferenceSelectionChanged: boolean } { +): { inferenceSelectionChanged: boolean; dcodeAutoApprovalChanged: boolean } { const sandboxName = state.sandboxName; + const dcodeAutoApprovalChanged = hasDcodeAutoApprovalDrift({ + liveExists: sandboxReuseState === "ready", + managedDcodeAgent: usesManagedDcodeIdentity(agentName(options.agent), options.fromDockerfile), + hasRegistryEntry: registryEntry !== null, + recordedDcodeAutoApprovalMode: registryEntry?.dcodeAutoApprovalMode, + requestedDcodeAutoApprovalMode: dcodeAutoApprovalMode, + }); if ( !options.resume || state.session?.steps?.sandbox?.status !== "complete" || @@ -67,7 +103,7 @@ export function resolveSignals( !usesManagedDcodeIdentity(agentName(options.agent), options.fromDockerfile) || sandboxReuseState !== "ready" ) { - return { inferenceSelectionChanged: false }; + return { inferenceSelectionChanged: false, dcodeAutoApprovalChanged }; } if (!registryEntry) { deps.error( @@ -81,7 +117,10 @@ export function resolveSignals( options.model, options.preferredInferenceApi, ); - return { inferenceSelectionChanged: Boolean(drift.changed || drift.unknown) }; + return { + inferenceSelectionChanged: Boolean(drift.changed || drift.unknown), + dcodeAutoApprovalChanged, + }; } export function selectionFidelity( diff --git a/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts b/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts index 9008ea4c47b..cb2b51ba130 100644 --- a/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; -import { createSession } from "../../../state/onboard-session"; +import { createSession, type Session } from "../../../state/onboard-session"; import type { SandboxEntry } from "../../../state/registry"; import { handleSandboxState } from "./sandbox"; import { baseOptions, createDeps } from "./sandbox-test-fixtures"; @@ -49,6 +49,121 @@ function dcodeOptions(deps: ReturnType["deps"]) { } describe("handleSandboxState live DCode selection", () => { + it("carries durable observability intent in the sandbox create intent", async () => { + const session = createSession({ + observabilityEnabled: true, + observabilityRequestedExplicitly: true, + }); + const { deps, calls } = createDeps({ + updateSession: vi.fn((mutator: (value: Session) => Session | void) => { + return mutator(session) ?? session; + }), + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + agent: { name: "langchain-deepagents-code" }, + }); + + expect(calls.createSandbox.mock.calls[0]?.at(-1)).toEqual({ + recreate: false, + toolDisclosure: "progressive", + observabilityEnabled: true, + observabilityRequestedExplicitly: true, + dcodeAutoApprovalMode: "disabled", + }); + }); + + it("carries authoritative thread opt-in in the create intent (#6478)", async () => { + const session = createSession(); + const { deps, calls } = createDeps(); + + await handleSandboxState({ + ...baseOptions(deps, session), + agent: { name: "langchain-deepagents-code" }, + requestedDcodeAutoApprovalMode: "thread-opt-in", + }); + + expect(calls.createSandbox.mock.calls[0]?.at(-1)).toMatchObject({ + dcodeAutoApprovalMode: "thread-opt-in", + }); + }); + + it("recreates a ready DCode sandbox when the image-baked mode changes (#6478)", async () => { + const session = completedSession(); + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: (name: string) => ({ + ...dcodeRegistryEntry(name), + dcodeAutoApprovalMode: "disabled", + }), + updateSession: vi.fn((mutator: (value: Session) => Session | void) => { + return mutator(session) ?? session; + }), + }); + + await handleSandboxState({ + ...dcodeOptions(deps), + requestedDcodeAutoApprovalMode: "thread-opt-in", + }); + + expect(calls.createSandbox.mock.calls[0]?.at(-1)).toMatchObject({ + recreate: true, + dcodeAutoApprovalMode: "thread-opt-in", + }); + expect(calls.note).toHaveBeenCalledWith( + " [resume] DCode auto-approval capability changed; recreating sandbox.", + ); + }); + + it("repairs a not-ready DCode sandbox before recreating for mode drift (#6478)", async () => { + const session = completedSession(); + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "not_ready", + getSandboxRegistryEntry: (name: string) => ({ + ...dcodeRegistryEntry(name), + dcodeAutoApprovalMode: "disabled", + }), + updateSession: vi.fn((mutator: (value: Session) => Session | void) => { + return mutator(session) ?? session; + }), + }); + + await handleSandboxState({ + ...dcodeOptions(deps), + requestedDcodeAutoApprovalMode: "thread-opt-in", + }); + + expect(calls.repairSandbox).toHaveBeenCalledWith("saved"); + expect(calls.repairEvent).toHaveBeenCalledWith("state.repair.completed", { + state: "sandbox", + metadata: { repair: "recorded-sandbox-cleanup", sandboxName: "saved" }, + }); + expect(calls.createSandbox.mock.calls[0]?.at(-1)).toMatchObject({ + recreate: true, + dcodeAutoApprovalMode: "thread-opt-in", + }); + }); + + it("rejects malformed recorded DCode auto-approval state (#6478)", async () => { + const { deps, calls } = createDeps({ + getSandboxRegistryEntry: (name: string) => ({ + ...dcodeRegistryEntry(name), + dcodeAutoApprovalMode: "always" as never, + }), + }); + + await expect( + handleSandboxState({ + ...baseOptions(deps), + agent: { name: "langchain-deepagents-code" }, + sandboxName: "saved", + }), + ).rejects.toThrow("exit 1"); + expect(calls.error).toHaveBeenCalledWith(expect.stringContaining("mode is invalid")); + expect(calls.createSandbox).not.toHaveBeenCalled(); + }); + it.each([ ["changed", { changed: true, unknown: false }], ["unreadable", { changed: false, unknown: true }], @@ -72,6 +187,7 @@ describe("handleSandboxState live DCode selection", () => { recreate: true, toolDisclosure: "progressive", observabilityEnabled: false, + dcodeAutoApprovalMode: "disabled", }); expect(calls.removeSandbox).not.toHaveBeenCalled(); }); @@ -91,6 +207,7 @@ describe("handleSandboxState live DCode selection", () => { recreate: true, toolDisclosure: "progressive", observabilityEnabled: false, + dcodeAutoApprovalMode: "disabled", }); }); diff --git a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts index 37c857934c6..28a98810171 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts @@ -39,6 +39,7 @@ describe("decideSandboxResume", () => { ["messaging", { messagingChannelConfigChanged: true }, true], ["Hermes tool gateway", { hermesToolGatewayConfigChanged: true }, true], ["observability", { observabilityChanged: true }, false], + ["DCode auto-approval", { dcodeAutoApprovalChanged: true }, false], ["tool disclosure migration", { toolDisclosureMigrationNeeded: true }, false], ["tool disclosure", { toolDisclosureChanged: true }, false], ["live DCode inference selection", { inferenceSelectionChanged: true }, false], @@ -127,6 +128,17 @@ describe("decideSandboxResume", () => { }); }); + it("repairs a not-ready sandbox before recreating for DCode auto-approval drift", () => { + expect( + decideSandboxResume( + resumeSignals({ + sandboxReuseState: "not_ready", + dcodeAutoApprovalChanged: true, + }), + ), + ).toEqual({ kind: "repair-and-recreate" }); + }); + it("creates without resume-specific cleanup when the step is incomplete", () => { expect( decideSandboxResume( diff --git a/src/lib/onboard/machine/handlers/sandbox-resume.ts b/src/lib/onboard/machine/handlers/sandbox-resume.ts index 7ea64ce9e69..73d95259294 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.ts @@ -1,6 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + type WebSearchConfig, + webSearchEnvFor, + webSearchLabelFor, + webSearchProviderForConfig, +} from "../../../inference/web-search"; import type { Session } from "../../../state/onboard-session"; import type { SandboxEntry } from "../../../state/registry"; import { normalizeToolDisclosure, toolDisclosureOrDefault } from "../../../tool-disclosure"; @@ -16,6 +22,7 @@ export interface SandboxResumeSignals { readonly messagingChannelConfigChanged: boolean; readonly hermesToolGatewayConfigChanged: boolean; readonly observabilityChanged?: boolean; + readonly dcodeAutoApprovalChanged?: boolean; readonly toolDisclosureMigrationNeeded: boolean; readonly toolDisclosureChanged: boolean; readonly inferenceSelectionChanged: boolean; @@ -83,6 +90,30 @@ export type SandboxResumeDecision = } | { readonly kind: "repair-and-recreate" }; +export function mcpRegistryRemovalBlockReason( + decision: SandboxResumeDecision, + sandboxName: string | null, + webSearchConfig: WebSearchConfig | null, + getSandboxRegistryEntry: (sandboxName: string) => SandboxEntry | null, +): string | null { + if (decision.kind !== "recreate" || !decision.removeRegistryEntry || !sandboxName) return null; + const mcpState = getSandboxRegistryEntry(sandboxName)?.mcp; + if (!mcpState) return null; + + const selectedProvider = webSearchConfig ? webSearchProviderForConfig(webSearchConfig) : null; + if (selectedProvider) { + const credentialEnv = webSearchEnvFor(selectedProvider); + const collidingBridge = Object.values(mcpState.bridges).find((entry) => + entry.env.includes(credentialEnv), + ); + if (collidingBridge) { + return ` Cannot enable ${webSearchLabelFor(selectedProvider)}: MCP server '${collidingBridge.server}' already owns ${credentialEnv}. Use a distinct credential name.`; + } + } + + return ` Sandbox '${sandboxName}' has managed MCP state. Use the transactional rebuild command before changing settings that recreate the sandbox.`; +} + export interface SandboxResumeDeps { note(message: string): void; removeSandboxFromRegistry(sandboxName: string): void; @@ -107,6 +138,7 @@ function canReuseSandbox(signals: SandboxResumeSignals): boolean { !signals.messagingChannelConfigChanged && !signals.hermesToolGatewayConfigChanged && !signals.observabilityChanged && + !signals.dcodeAutoApprovalChanged && !signals.toolDisclosureMigrationNeeded && !signals.toolDisclosureChanged && signals.sandboxReuseState === "ready" @@ -200,6 +232,14 @@ function runtimeConfigurationResumeDecision( removeRegistryEntry: false, }; } + if (signals.dcodeAutoApprovalChanged && signals.sandboxReuseState !== "not_ready") { + return { + kind: "recreate", + note: " [resume] DCode auto-approval capability changed; recreating sandbox.", + // Preserve registry-only fidelity until createSandbox captures it. + removeRegistryEntry: false, + }; + } return null; } diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 7d6f227a119..7bff515e3c6 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -70,7 +70,11 @@ describe("handleSandboxState", () => { null, [], null, - { recreate: false, toolDisclosure: "progressive", observabilityEnabled: false }, + { + recreate: false, + toolDisclosure: "progressive", + observabilityEnabled: false, + }, ); expect(calls.updateSandbox).toHaveBeenCalledWith( "my-assistant", @@ -112,30 +116,6 @@ describe("handleSandboxState", () => { expect(result.webSearchConfig).toBeNull(); }); - it("carries durable observability intent in the sandbox create intent", async () => { - const session = createSession({ - observabilityEnabled: true, - observabilityRequestedExplicitly: true, - }); - const { deps, calls } = createDeps({ - updateSession: vi.fn((mutator: (value: Session) => Session | void) => { - return mutator(session) ?? session; - }), - }); - - await handleSandboxState({ - ...baseOptions(deps, session), - agent: { name: "langchain-deepagents-code" }, - }); - - expect(calls.createSandbox.mock.calls[0]?.at(-1)).toEqual({ - recreate: false, - toolDisclosure: "progressive", - observabilityEnabled: true, - observabilityRequestedExplicitly: true, - }); - }); - it("carries an authoritative rebuild tier in the sandbox create intent", async () => { const { deps, calls } = createDeps(); @@ -449,7 +429,11 @@ describe("handleSandboxState", () => { null, ["nous-audio"], null, - { recreate: false, toolDisclosure: "progressive", observabilityEnabled: false }, + { + recreate: false, + toolDisclosure: "progressive", + observabilityEnabled: false, + }, ); expect(result.hermesToolGateways).toEqual(["nous-audio"]); expect(calls.note).toHaveBeenCalledWith( @@ -554,7 +538,11 @@ describe("handleSandboxState", () => { null, [], null, - { recreate: true, toolDisclosure: "progressive", observabilityEnabled: false }, + { + recreate: true, + toolDisclosure: "progressive", + observabilityEnabled: false, + }, ); }); @@ -757,7 +745,11 @@ describe("handleSandboxState", () => { null, [], null, - { recreate: true, toolDisclosure: "progressive", observabilityEnabled: false }, + { + recreate: true, + toolDisclosure: "progressive", + observabilityEnabled: false, + }, ); expect(result.webSearchConfigChanged).toBe(true); }); @@ -871,7 +863,11 @@ describe("handleSandboxState", () => { null, [], null, - { recreate: true, toolDisclosure: "progressive", observabilityEnabled: false }, + { + recreate: true, + toolDisclosure: "progressive", + observabilityEnabled: false, + }, ); expect(result.webSearchConfig).toBeNull(); }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 019a603b476..58f4bfe3772 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -10,7 +10,6 @@ import { type WebSearchConfig as SharedWebSearchConfig, WEB_SEARCH_PROVIDER_ENV, webSearchConfigsEqual, - webSearchEnvFor, webSearchLabelFor, webSearchProviderForConfig, } from "../../../inference/web-search"; @@ -19,6 +18,10 @@ import type { HermesAuthMethod, Session, SessionUpdates } from "../../../state/o import type { SandboxEntry } from "../../../state/registry"; import { getSandboxEntryInference } from "../../../state/registry-entry-view"; import { toolDisclosureOrDefault } from "../../../tool-disclosure"; +import { + type DcodeAutoApprovalMode, + DEFAULT_DCODE_AUTO_APPROVAL_MODE, +} from "../../dcode-auto-approval"; import { resolveSandboxGatewayName } from "../../gateway-binding"; import { type ManagedSandboxFeatureIssue, @@ -39,6 +42,7 @@ import { applySandboxResumeDecision, decideSandboxResume, hasHermesCompatibleAnthropicInferenceRouteDrift, + mcpRegistryRemovalBlockReason, resolveToolDisclosureResumeSignals, type SandboxResumeDecision, } from "./sandbox-resume"; @@ -59,6 +63,7 @@ export interface SandboxStateOptions< authoritativePolicyTier?: string | null; resumeAgentChanged: boolean; requestedObservabilityEnabled?: boolean | null; + requestedDcodeAutoApprovalMode?: DcodeAutoApprovalMode | null; gatewayName: string; session: Session | null; sandboxName: string | null; @@ -260,32 +265,6 @@ function effectiveHermesToolGatewaysForWebSearch( type SandboxCreationDecision = Exclude; -function mcpRegistryRemovalBlockReason( - decision: SandboxCreationDecision, - sandboxName: string | null, - webSearchConfig: SharedWebSearchConfig | null, - getSandboxRegistryEntry: (sandboxName: string) => SandboxEntry | null, -): string | null { - if (decision.kind !== "recreate") return null; - if (!decision.removeRegistryEntry) return null; - if (!sandboxName) return null; - const mcpState = getSandboxRegistryEntry(sandboxName)?.mcp; - if (!mcpState) return null; - - const selectedProvider = webSearchConfig ? webSearchProviderForConfig(webSearchConfig) : null; - if (selectedProvider) { - const credentialEnv = webSearchEnvFor(selectedProvider); - const collidingBridge = Object.values(mcpState.bridges).find((entry) => - entry.env.includes(credentialEnv), - ); - if (collidingBridge) { - return ` Cannot enable ${webSearchLabelFor(selectedProvider)}: MCP server '${collidingBridge.server}' already owns ${credentialEnv}. Use a distinct credential name.`; - } - } - - return ` Sandbox '${sandboxName}' has managed MCP state. Use the transactional rebuild command before changing settings that recreate the sandbox.`; -} - function observabilityRequestValidationError( issue: ManagedSandboxFeatureIssue | null, ): string | null { @@ -306,6 +285,8 @@ class SandboxStateFlow< SandboxGpuConfig, ResourceProfile, > { + private dcodeAutoApprovalMode: DcodeAutoApprovalMode = DEFAULT_DCODE_AUTO_APPROVAL_MODE; + constructor( private readonly options: SandboxStateOptions< Gpu, @@ -421,6 +402,7 @@ class SandboxStateFlow< state, sandboxReuseState, registryEntry, + this.dcodeAutoApprovalMode, this.deps, ); const decision = decideSandboxResume({ @@ -683,6 +665,10 @@ class SandboxStateFlow< ...(state.session?.observabilityRequestedExplicitly === true ? { observabilityRequestedExplicitly: true as const } : {}), + ...(!this.options.fromDockerfile && + isDcodeAgent((this.options.agent as { name?: string } | null)?.name) + ? { dcodeAutoApprovalMode: this.dcodeAutoApprovalMode } + : {}), ...(this.options.authoritativePolicyTier ? { policyTier: this.options.authoritativePolicyTier } : {}), @@ -807,6 +793,11 @@ class SandboxStateFlow< } async run(): Promise> { + this.dcodeAutoApprovalMode = dcodeResume.resolveAutoApprovalMode( + this.options, + this.options.sandboxName, + this.deps, + ); const initialState = this.applyObservabilityRequest(this.prepareWebSearchSupport()); const decision = this.resolveResumeDecision(initialState); const completedState = diff --git a/src/lib/onboard/prepared-dcode-rebuild.test.ts b/src/lib/onboard/prepared-dcode-rebuild.test.ts index a72da809b33..1fa1ebb2b8c 100644 --- a/src/lib/onboard/prepared-dcode-rebuild.test.ts +++ b/src/lib/onboard/prepared-dcode-rebuild.test.ts @@ -31,9 +31,11 @@ const preparedOptions: PreparedDcodeRebuildOptions = { resume: true, recreateSandbox: true, agent: dcodeAgent.name, + dcodeAutoApprovalMode: "disabled", preparedDcodeRebuild: { buildContext: preparedBuildContext, gatewayName: " nemoclaw ", + dcodeAutoApprovalMode: "disabled", }, }; const preparedImageBuildContext: PreparedSandboxBuildContext = { @@ -167,6 +169,15 @@ describe("prepared DCode rebuild adapter", () => { expect(ordinaryEnv.OPENSHELL_GATEWAY).toBeUndefined(); }); + it("rejects a prepared handoff when its auto-approval mode changed (#6478)", () => { + expect(() => + createPreparedDcodeRebuildRuntime( + { ...preparedOptions, dcodeAutoApprovalMode: "thread-opt-in" }, + "nemoclaw", + ), + ).toThrow(/auto-approval mode does not match/); + }); + it("rejects malformed or mismatched gateway names", () => { const malformed = { ...preparedOptions, diff --git a/src/lib/onboard/prepared-dcode-rebuild.ts b/src/lib/onboard/prepared-dcode-rebuild.ts index 1c95fb00023..15e23cdf731 100644 --- a/src/lib/onboard/prepared-dcode-rebuild.ts +++ b/src/lib/onboard/prepared-dcode-rebuild.ts @@ -11,6 +11,7 @@ import type { CreateSandboxBuildContextResult, PreparedSandboxBuildContext, } from "./build-context-stage"; +import { type DcodeAutoApprovalMode, normalizeDcodeAutoApprovalMode } from "./dcode-auto-approval"; import type { PrepareSandboxDockerfilePatchInput, SandboxDockerfilePatchResult, @@ -27,6 +28,7 @@ type CreateAgentSandbox = CreateSandboxBuildContextInput["createAgentSandbox"]; export interface PreparedDcodeRebuildHandoff { buildContext: PreparedSandboxBuildContext; gatewayName: string; + dcodeAutoApprovalMode: DcodeAutoApprovalMode; } export interface PreparedImageRebuildHandoff { @@ -41,6 +43,7 @@ export interface PreparedDcodeRebuildOptions { onboardLockAlreadyHeld?: boolean; agent?: string | null; fromDockerfile?: string | null; + dcodeAutoApprovalMode?: DcodeAutoApprovalMode | null; preparedDcodeRebuild?: PreparedDcodeRebuildHandoff; preparedImageRebuild?: PreparedImageRebuildHandoff; } @@ -139,6 +142,15 @@ export function createPreparedDcodeRebuildRuntime( ) { throw new Error("A prepared DCode rebuild can only be used by DCode resume recreation."); } + if ( + preparedDcode && + preparedDcode.dcodeAutoApprovalMode !== + normalizeDcodeAutoApprovalMode(options.dcodeAutoApprovalMode) + ) { + throw new Error( + "Prepared DCode rebuild auto-approval mode does not match the authoritative onboard request.", + ); + } if ( preparedImage && (options.resume !== true || diff --git a/src/lib/onboard/runtime-control-flow.test.ts b/src/lib/onboard/runtime-control-flow.test.ts index c3e8b7b2ecd..5cdc6f4d68a 100644 --- a/src/lib/onboard/runtime-control-flow.test.ts +++ b/src/lib/onboard/runtime-control-flow.test.ts @@ -20,15 +20,18 @@ describe("onboard runtime control flow", () => { applyOnboardRuntimeControlRequests({ toolDisclosure: "direct", observabilityEnabled: true, + dcodeAutoApprovalMode: "thread-opt-in", }), ).toEqual({ requestedToolDisclosure: "direct", requestedObservabilityEnabled: true, + requestedDcodeAutoApprovalMode: "thread-opt-in", }); delete process.env.NEMOCLAW_TOOL_DISCLOSURE; expect(applyOnboardRuntimeControlRequests({})).toEqual({ requestedToolDisclosure: null, requestedObservabilityEnabled: null, + requestedDcodeAutoApprovalMode: null, }); }); @@ -41,6 +44,7 @@ describe("onboard runtime control flow", () => { ).toEqual({ requestedToolDisclosure: null, requestedObservabilityEnabled: null, + requestedDcodeAutoApprovalMode: null, }); }); diff --git a/src/lib/onboard/runtime-control-flow.ts b/src/lib/onboard/runtime-control-flow.ts index 48dd0ca1bec..2cbe1fa9dd0 100644 --- a/src/lib/onboard/runtime-control-flow.ts +++ b/src/lib/onboard/runtime-control-flow.ts @@ -4,6 +4,7 @@ import { type Session, updateSession } from "../state/onboard-session"; import { clearAgentScopedResumeState } from "./agent-resume-state"; import { setOnboardBrandingAgent } from "./branding"; +import { isDcodeAutoApprovalMode } from "./dcode-auto-approval"; import { managedSandboxFeatureIssue } from "./managed-sandbox-feature"; import { stopTrackedModelRouterForAgentChange } from "./model-router-process"; import { DCODE_OBSERVABILITY_FEATURE } from "./observability-policy-presets"; @@ -31,7 +32,10 @@ type SelectedAgentTransitionOverrides = Partial, ) { const observabilityIsExplicit = opts.observabilityRequestedExplicitly !== false; @@ -41,6 +45,9 @@ export function applyOnboardRuntimeControlRequests( observabilityIsExplicit && typeof opts.observabilityEnabled === "boolean" ? opts.observabilityEnabled : null, + requestedDcodeAutoApprovalMode: isDcodeAutoApprovalMode(opts.dcodeAutoApprovalMode) + ? opts.dcodeAutoApprovalMode + : null, }; } diff --git a/src/lib/onboard/sandbox-create-launch-observability.test.ts b/src/lib/onboard/sandbox-create-launch-observability.test.ts index 1419c169e09..6f237c5782b 100644 --- a/src/lib/onboard/sandbox-create-launch-observability.test.ts +++ b/src/lib/onboard/sandbox-create-launch-observability.test.ts @@ -57,4 +57,22 @@ describe("prepareSandboxCreateLaunch observability", () => { ); expect(render("hermes", true).envArgs).not.toContain("NEMOCLAW_OBSERVABILITY=1"); }); + + it("never trusts ambient DCode auto-approval as sandbox runtime input (#6478)", () => { + const result = prepareSandboxCreateLaunch({ + agent: { name: "langchain-deepagents-code" } as any, + chatUiUrl: "", + createArgs: [], + env: { NEMOCLAW_DCODE_AUTO_APPROVAL: "thread-opt-in" }, + extraPlaceholderKeys: [], + getDashboardForwardPort: vi.fn(() => "0"), + hermesDashboardState: disabledHermesDashboardState, + manageDashboard: false, + openshellShellCommand: (args) => args.join(" "), + buildEnv: () => ({}), + }); + + expect(result.envArgs.join("\n")).not.toContain("NEMOCLAW_DCODE_AUTO_APPROVAL"); + expect(result.sandboxStartupCommand.join("\n")).not.toContain("NEMOCLAW_DCODE_AUTO_APPROVAL"); + }); }); diff --git a/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts b/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts index 2d795af83db..e378a09b08d 100644 --- a/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts +++ b/src/lib/onboard/sandbox-dockerfile-patch-flow.test.ts @@ -206,6 +206,35 @@ describe("prepareSandboxDockerfilePatch", () => { }); }); + it("forwards the DCode auto-approval mode only as a Dockerfile patch option (#6478)", async () => { + const patchStagedDockerfile = vi.fn(); + await prepareSandboxDockerfilePatch({ + agent: { name: "langchain-deepagents-code" } as any, + fromDockerfile: null, + sandboxBaseImage: "ghcr.io/nvidia/nemoclaw/sandbox-base", + sandboxBaseTag: "latest", + stagedDockerfile: "/tmp/Dockerfile", + model: "model-a", + chatUiUrl: "", + provider: null, + preferredInferenceApi: null, + webSearchConfig: null, + dcodeAutoApprovalMode: "thread-opt-in", + hermesToolGateways: [], + sandboxGpuConfig, + deps: { + isLinuxDockerDriverGatewayEnabled: vi.fn(() => false), + enforceDockerGpuPatchPreserveNetwork: vi.fn(async () => false), + patchStagedDockerfile, + now: () => 1, + }, + }); + + expect(patchStagedDockerfile.mock.calls[0]?.[11]).toMatchObject({ + dcodeAutoApprovalMode: "thread-opt-in", + }); + }); + it("resolves the base image when an agent uses a custom Dockerfile", async () => { const pullAndResolveBaseImageDigest = vi.fn(() => ({ digest: "sha256:customagent", diff --git a/src/lib/onboard/sandbox-dockerfile-patch-flow.ts b/src/lib/onboard/sandbox-dockerfile-patch-flow.ts index 8b1c0985dc9..0d8b1afc2d3 100644 --- a/src/lib/onboard/sandbox-dockerfile-patch-flow.ts +++ b/src/lib/onboard/sandbox-dockerfile-patch-flow.ts @@ -8,6 +8,7 @@ import { type SandboxBaseImageResolutionMetadata, } from "../sandbox-base-image"; import { DEFAULT_TOOL_DISCLOSURE, type ToolDisclosure } from "../tool-disclosure"; +import type { DcodeAutoApprovalMode } from "./dcode-auto-approval"; import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; type DockerRunResult = { status: number | null }; @@ -40,6 +41,7 @@ export type PrepareSandboxDockerfilePatchInput = { preferredInferenceApi: string | null; webSearchConfig: WebSearchConfig | null; toolDisclosure?: ToolDisclosure; + dcodeAutoApprovalMode?: DcodeAutoApprovalMode; hermesToolGateways: string[]; sandboxGpuConfig: SandboxGpuConfig; resolutionHint?: SandboxBaseImageResolutionMetadata | null; @@ -104,6 +106,7 @@ export async function prepareSandboxDockerfilePatch({ preferredInferenceApi, webSearchConfig, toolDisclosure = DEFAULT_TOOL_DISCLOSURE, + dcodeAutoApprovalMode, hermesToolGateways, sandboxGpuConfig, resolutionHint = null, @@ -188,6 +191,7 @@ export async function prepareSandboxDockerfilePatch({ return { buildIdPolicy, toolDisclosure, + ...(dcodeAutoApprovalMode ? { dcodeAutoApprovalMode } : {}), requireToolDisclosureContract: Boolean(fromDockerfile), ...(metadata ? { baseImageResolutionMetadata: metadata } : {}), }; diff --git a/src/lib/onboard/sandbox-registration.test.ts b/src/lib/onboard/sandbox-registration.test.ts index 58aa8353bc3..e135fa21512 100644 --- a/src/lib/onboard/sandbox-registration.test.ts +++ b/src/lib/onboard/sandbox-registration.test.ts @@ -52,6 +52,7 @@ describe("buildCreatedSandboxRegistryEntry", () => { openclawImagePluginInstalls, appliedPolicies: ["discord", "slack"], observabilityEnabled: true, + dcodeAutoApprovalMode: "thread-opt-in", policyTier: "restricted", webSearchEnabled: true, fromDockerfile: "/tmp/Dockerfile.custom", @@ -79,6 +80,7 @@ describe("buildCreatedSandboxRegistryEntry", () => { policies: ["discord", "slack"], toolDisclosure: "progressive", observabilityEnabled: true, + dcodeAutoApprovalMode: "thread-opt-in", policyTier: "restricted", webSearchEnabled: true, fromDockerfile: "/tmp/Dockerfile.custom", @@ -161,6 +163,7 @@ describe("buildCreatedSandboxRegistryEntry", () => { expect(entry.hermesAuthMethod).toBeNull(); expect(entry.toolDisclosure).toBe("progressive"); expect(entry.observabilityEnabled).toBe(false); + expect(entry.dcodeAutoApprovalMode).toBeUndefined(); }); it("carries a durable MCP rebuild manifest into the replacement registry entry", () => { diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index 612345506ec..a5e9b666033 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -10,6 +10,7 @@ import type { OpenClawImagePluginInstall } from "../state/openclaw-plugin-restor import type { SandboxEntry, SandboxMcpState, SandboxMessagingState } from "../state/registry"; import * as registry from "../state/registry"; import { DEFAULT_TOOL_DISCLOSURE, type ToolDisclosure } from "../tool-disclosure"; +import type { DcodeAutoApprovalMode } from "./dcode-auto-approval"; import { getHermesDashboardRegistryFields, type HermesDashboardOnboardState, @@ -39,6 +40,7 @@ export interface CreatedSandboxRegistryEntryInput { appliedPolicies: string[]; toolDisclosure?: ToolDisclosure; observabilityEnabled?: boolean; + dcodeAutoApprovalMode?: DcodeAutoApprovalMode; policyTier?: SandboxEntry["policyTier"]; webSearchEnabled?: boolean; webSearchProvider?: SandboxEntry["webSearchProvider"]; @@ -126,6 +128,9 @@ export function buildCreatedSandboxRegistryEntry( policies: input.appliedPolicies, toolDisclosure: input.toolDisclosure ?? DEFAULT_TOOL_DISCLOSURE, observabilityEnabled: input.observabilityEnabled === true, + ...(input.dcodeAutoApprovalMode !== undefined + ? { dcodeAutoApprovalMode: input.dcodeAutoApprovalMode } + : {}), ...(input.policyTier !== undefined ? { policyTier: input.policyTier } : {}), webSearchEnabled: input.webSearchEnabled === true, webSearchProvider: diff --git a/src/lib/onboard/types.ts b/src/lib/onboard/types.ts index 5c066707bc8..1895c2551d0 100644 --- a/src/lib/onboard/types.ts +++ b/src/lib/onboard/types.ts @@ -59,6 +59,7 @@ export interface SandboxCreateIntent { readonly observabilityEnabled: boolean; /** Present only when the operator explicitly selected observability on or off. */ readonly observabilityRequestedExplicitly?: true; + readonly dcodeAutoApprovalMode?: import("./dcode-auto-approval").DcodeAutoApprovalMode; /** Internal authoritative rebuild tier used before replacement registration completes. */ readonly policyTier?: string | null; } @@ -93,6 +94,7 @@ export type OnboardOptions = { observabilityEnabled?: boolean | null; /** Internal provenance for an authoritative observability value. */ observabilityRequestedExplicitly?: boolean; + dcodeAutoApprovalMode?: import("./dcode-auto-approval").DcodeAutoApprovalMode | null; /** Internal authoritative rebuild tier; never exposed as an onboard CLI option. */ policyTier?: string | null; controlUiPort?: number | null; diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 6a7b33694c9..a3647e77486 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -35,6 +35,10 @@ export { } from "./registry-entry-view"; import type { WebSearchProvider } from "../inference/web-search"; +import { + type DcodeAutoApprovalMode, + isDcodeAutoApprovalMode, +} from "../onboard/dcode-auto-approval"; import { cloneSandboxMessagingState, getConfiguredMessagingChannels as getRegistryConfiguredMessagingChannels, @@ -108,6 +112,8 @@ export interface SandboxEntry extends Partial { toolDisclosure?: ToolDisclosure; /** Enables backend-neutral trace export to the fixed local OTLP collector boundary. */ observabilityEnabled?: boolean; + /** Image-baked permission to expose DCode's per-thread auto-approval opt-in. */ + dcodeAutoApprovalMode?: DcodeAutoApprovalMode; /** Durable provider identity for enabled managed web search. */ webSearchProvider?: WebSearchProvider | null; agent?: string | null; @@ -496,6 +502,9 @@ export function registerSandbox(entry: SandboxEntry): void { toolDisclosure: normalizeToolDisclosure(entry.toolDisclosure) ?? undefined, observabilityEnabled: typeof entry.observabilityEnabled === "boolean" ? entry.observabilityEnabled : undefined, + dcodeAutoApprovalMode: isDcodeAutoApprovalMode(entry.dcodeAutoApprovalMode) + ? entry.dcodeAutoApprovalMode + : undefined, webSearchProvider: entry.webSearchEnabled === true && (entry.webSearchProvider === "brave" || entry.webSearchProvider === "tavily") diff --git a/test/e2e/e2e-cloud-experimental/checks/12-deepagents-code-thread-auto-approval.sh b/test/e2e/e2e-cloud-experimental/checks/12-deepagents-code-thread-auto-approval.sh new file mode 100755 index 00000000000..16e0d731c7b --- /dev/null +++ b/test/e2e/e2e-cloud-experimental/checks/12-deepagents-code-thread-auto-approval.sh @@ -0,0 +1,325 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Case: managed Deep Agents Code thread-scoped auto-approval (#6478). +# +# This check starts from the typed target's default-disabled DCode sandbox, +# enables the root-owned capability through NemoClaw's named rebuild surface, +# selects the upstream "Auto-approve for this thread" action in a real TUI, +# and proves that a new thread returns to manual approval. It then reruns the +# established network and credential boundary checks in the enabled posture. + +set -euo pipefail + +SANDBOX_NAME="${SANDBOX_NAME:-${NEMOCLAW_SANDBOX_NAME:-}}" +REPO="${REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)}" +CLI="${NEMOCLAW_CLI_BIN:-${REPO}/bin/nemoclaw.js}" +PREFIX="12-deepagents-code-thread-auto-approval" +TUI_TIMEOUT="${DEEPAGENTS_AUTORUN_TIMEOUT:-420}" +CAPABILITY_FILE="/usr/local/share/nemoclaw/dcode-auto-approval" +NETWORK_BOUNDARY_CHECK="${REPO}/test/e2e/e2e-cloud-experimental/checks/06-deepagents-code-python-egress.sh" +CREDENTIAL_BOUNDARY_CHECK="${REPO}/test/e2e/e2e-cloud-experimental/checks/08-deepagents-code-secret-boundary.sh" +SHELL_ROUND_ONE="/sandbox/.nemoclaw-e2e-autorun-shell-1" +WRITE_ROUND="/sandbox/.nemoclaw-e2e-autorun-write" +SHELL_ROUND_THREE="/sandbox/.nemoclaw-e2e-autorun-shell-3" +RESET_ROUND="/sandbox/.nemoclaw-e2e-autorun-reset-must-not-run" + +fail() { + printf '%s: FAIL: %s\n' "$PREFIX" "$1" >&2 + exit 1 +} + +pass() { + printf '%s: OK (%s)\n' "$PREFIX" "$1" +} + +info() { + printf '%s: %s\n' "$PREFIX" "$1" +} + +is_positive_integer() { + [[ "$1" =~ ^[1-9][0-9]*$ ]] +} + +sandbox_exec() { + openshell sandbox exec --name "$SANDBOX_NAME" -- bash -c "$1" 2>&1 +} + +is_default_auto_approval_denial() { + local exit_code="$1" + local output + output="$(cat)" + [ "$exit_code" -eq 2 ] \ + && printf '%s\n' "$output" | grep -Fq "NemoClaw manages Deep Agents Code tool approval posture" +} + +assert_capability_projection() { + local expected_mode="$1" + local expected_size + case "$expected_mode" in + disabled) expected_size=9 ;; + thread-opt-in) expected_size=14 ;; + *) fail "unsupported expected capability mode '$expected_mode'" ;; + esac + + local expected_metadata remote_command projection_output + expected_metadata="0:0:444:${expected_size}" + remote_command="set -euo pipefail; file=${CAPABILITY_FILE@Q}; test -f \"\$file\"; test ! -L \"\$file\"; test \"\$(stat -c '%u:%g:%a:%s' \"\$file\")\" = ${expected_metadata@Q}; test \"\$(cat \"\$file\")\" = ${expected_mode@Q}; /opt/venv/bin/python3 -I -c 'from deepagents_code._nemoclaw_managed import managed_auto_approval_mode; print(managed_auto_approval_mode())'" + projection_output="$(sandbox_exec "$remote_command")" \ + || fail "trusted capability projection is not root-owned, read-only, and exact: $projection_output" + [ "$projection_output" = "$expected_mode" ] \ + || fail "managed runtime resolved capability '$projection_output' instead of '$expected_mode'" +} + +assert_status_mode() { + local expected_mode="$1" + local status_json + status_json="$("$CLI" "$SANDBOX_NAME" status --json)" \ + || fail "nemoclaw status failed while checking '$expected_mode'" + STATUS_JSON="$status_json" EXPECTED_MODE="$expected_mode" SANDBOX_NAME="$SANDBOX_NAME" node -e ' +const status = JSON.parse(process.env.STATUS_JSON); +if (status.name !== process.env.SANDBOX_NAME || + status.agent !== "langchain-deepagents-code" || + status.dcodeAutoApprovalMode !== process.env.EXPECTED_MODE) process.exit(1); +' || fail "nemoclaw status did not report DCode auto-approval capability '$expected_mode'" +} + +assert_default_denial_ignores_ambient_override() { + local output status + set +e + output="$( + sandbox_exec \ + "env NEMOCLAW_DCODE_AUTO_APPROVAL=thread-opt-in timeout 20 /usr/local/bin/dcode --auto-approve --help" + )" + status=$? + set -e + if ! printf '%s\n' "$output" | is_default_auto_approval_denial "$status"; then + fail "default-disabled dcode accepted --auto-approve or lacked managed denial evidence" + fi +} + +run_autorun_tui() { + local marker_file="$1" + local first_prompt reset_prompt + first_prompt="Use tools in exactly four sequential rounds, waiting for each result before starting the next. Round 1: use the shell execute tool to run printf and write shell-round-1 followed by a newline to ${SHELL_ROUND_ONE}. Round 2: use the non-shell write_file tool to write write-round-2 followed by a newline to ${WRITE_ROUND}. Round 3: use the shell execute tool to run printf and write shell-round-3 followed by a newline to ${SHELL_ROUND_THREE}. Round 4: use the non-shell read_file tool to read all three files and verify their exact contents. Do not combine rounds or substitute shell for write_file or read_file. After all four rounds succeed, reply with exactly the concatenation of NEMOCLAW_AUTORUN_ and COMPLETE." + reset_prompt="Use the shell execute tool once to write reset-should-not-run followed by a newline to ${RESET_ROUND}, then report completion." + + env \ + NEMOCLAW_AUTORUN_EXPECT_MARKERS="$marker_file" \ + NEMOCLAW_AUTORUN_FIRST_PROMPT="$first_prompt" \ + NEMOCLAW_AUTORUN_RESET_PROMPT="$reset_prompt" \ + NEMOCLAW_AUTORUN_SANDBOX_NAME="$SANDBOX_NAME" \ + NEMOCLAW_AUTORUN_TUI_TIMEOUT="$TUI_TIMEOUT" \ + expect <<'EXPECT' +set timeout $env(NEMOCLAW_AUTORUN_TUI_TIMEOUT) +set sandbox $env(NEMOCLAW_AUTORUN_SANDBOX_NAME) +set first_prompt $env(NEMOCLAW_AUTORUN_FIRST_PROMPT) +set reset_prompt $env(NEMOCLAW_AUTORUN_RESET_PROMPT) +set markers $env(NEMOCLAW_AUTORUN_EXPECT_MARKERS) +log_user 0 + +proc append_marker {markers marker} { + set fh [open $markers a] + puts $fh $marker + close $fh +} + +proc submit_text {text delay_ms} { + foreach char [split $text ""] { + send -- $char + after $delay_ms + } + after 300 + send -- "\r" +} + +proc abort_tui {markers marker code} { + append_marker $markers $marker + catch {send -- "\003"} + after 200 + catch {send -- "\003"} + exit $code +} + +set remote_script {cd /sandbox && /usr/local/bin/dcode -m "$1"; status=$?; printf "\nNEMOCLAW_AUTORUN_TUI_EXIT:%s\n" "$status"} +set cmd [list openshell sandbox exec --name $sandbox --tty -- env HOME=/sandbox TERM=xterm-256color bash -lc $remote_script nemoclaw-e2e $first_prompt] +spawn {*}$cmd + +expect { + -nocase -re {auto-approve for this thread} { + append_marker $markers "NEMOCLAW_AUTORUN_APPROVAL_MENU" + send -- "a" + } + timeout { abort_tui $markers "NEMOCLAW_AUTORUN_TIMEOUT_APPROVAL_MENU" 20 } + eof { abort_tui $markers "NEMOCLAW_AUTORUN_EOF_APPROVAL_MENU" 21 } +} + +expect { + -nocase -re {auto-approval is enabled} { + append_marker $markers "NEMOCLAW_AUTORUN_WARNING" + } + timeout { abort_tui $markers "NEMOCLAW_AUTORUN_TIMEOUT_WARNING" 22 } + eof { abort_tui $markers "NEMOCLAW_AUTORUN_EOF_WARNING" 23 } +} + +expect { + -re {NEMOCLAW_AUTORUN_COMPLETE} { + append_marker $markers "NEMOCLAW_AUTORUN_WORKFLOW_COMPLETE" + } + timeout { abort_tui $markers "NEMOCLAW_AUTORUN_TIMEOUT_WORKFLOW" 24 } + eof { abort_tui $markers "NEMOCLAW_AUTORUN_EOF_WORKFLOW" 25 } +} + +after 1000 +submit_text "/clear" 100 +expect { + -nocase -re {started new thread:} { + append_marker $markers "NEMOCLAW_AUTORUN_NEW_THREAD" + } + timeout { abort_tui $markers "NEMOCLAW_AUTORUN_TIMEOUT_NEW_THREAD" 26 } + eof { abort_tui $markers "NEMOCLAW_AUTORUN_EOF_NEW_THREAD" 27 } +} + +after 500 +submit_text $reset_prompt 5 +expect { + -nocase -re {auto-approve for this thread} { + append_marker $markers "NEMOCLAW_AUTORUN_MANUAL_APPROVAL_RESTORED" + send -- "n" + } + timeout { abort_tui $markers "NEMOCLAW_AUTORUN_TIMEOUT_MANUAL_APPROVAL" 28 } + eof { abort_tui $markers "NEMOCLAW_AUTORUN_EOF_MANUAL_APPROVAL" 29 } +} + +after 700 +submit_text "/quit" 100 +set timeout 30 +expect { + -re {NEMOCLAW_AUTORUN_TUI_EXIT:([0-9]+)} { + append_marker $markers "NEMOCLAW_AUTORUN_TUI_EXIT:$expect_out(1,string)" + exit 0 + } + timeout { + append_marker $markers "NEMOCLAW_AUTORUN_TUI_EXIT_TIMEOUT" + catch {send -- "\003"} + exit 30 + } + eof { + append_marker $markers "NEMOCLAW_AUTORUN_TUI_EOF_BEFORE_EXIT" + exit 31 + } +} +EXPECT +} + +assert_autorun_evidence() { + local marker_file="$1" + local marker + for marker in \ + NEMOCLAW_AUTORUN_APPROVAL_MENU \ + NEMOCLAW_AUTORUN_WARNING \ + NEMOCLAW_AUTORUN_WORKFLOW_COMPLETE \ + NEMOCLAW_AUTORUN_NEW_THREAD \ + NEMOCLAW_AUTORUN_MANUAL_APPROVAL_RESTORED; do + grep -Fxq "$marker" "$marker_file" || fail "TUI evidence marker is missing: $marker" + done + grep -Eq '^NEMOCLAW_AUTORUN_TUI_EXIT:(0|130)$' "$marker_file" \ + || fail "DCode TUI did not exit cleanly after the thread reset proof: $(tr '\n' ' ' <"$marker_file")" + + local file_output + file_output="$( + sandbox_exec \ + "set -e; printf '%s\\n' shell-round-1 | cmp -s - ${SHELL_ROUND_ONE@Q}; printf '%s\\n' write-round-2 | cmp -s - ${WRITE_ROUND@Q}; printf '%s\\n' shell-round-3 | cmp -s - ${SHELL_ROUND_THREE@Q}; test ! -e ${RESET_ROUND@Q}; printf '%s\\n' NEMOCLAW_AUTORUN_FILES_VERIFIED" + )" || fail "autorun output files or reset-thread denial evidence are invalid: $file_output" + [ "$file_output" = "NEMOCLAW_AUTORUN_FILES_VERIFIED" ] \ + || fail "autorun file verification marker is missing" +} + +run_boundary_check() { + local label="$1" + local script_path="$2" + local output + output="$(env SANDBOX_NAME="$SANDBOX_NAME" NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" REPO="$REPO" bash "$script_path" 2>&1)" \ + || fail "$label failed with thread-opt-in enabled: $output" + if printf '%s\n' "$output" | grep -Eq '(^|[[:space:]])SKIP([[:space:]]|:)'; then + fail "$label skipped with thread-opt-in enabled: $output" + fi + pass "$label remains enforced with thread-opt-in enabled" +} + +cleanup_probe_files() { + sandbox_exec \ + "rm -f ${SHELL_ROUND_ONE@Q} ${WRITE_ROUND@Q} ${SHELL_ROUND_THREE@Q} ${RESET_ROUND@Q}" \ + >/dev/null 2>&1 || true +} + +main() { + [ -n "$SANDBOX_NAME" ] || fail "sandbox name is required" + [ -x "$CLI" ] || fail "NemoClaw CLI is not executable at $CLI" + [ -x "$NETWORK_BOUNDARY_CHECK" ] || fail "network boundary check is not executable" + [ -x "$CREDENTIAL_BOUNDARY_CHECK" ] || fail "credential boundary check is not executable" + command -v expect >/dev/null 2>&1 || fail "expect is required for the DCode autorun TUI check" + command -v node >/dev/null 2>&1 || fail "node is required to inspect status JSON" + is_positive_integer "$TUI_TIMEOUT" \ + || fail "DEEPAGENTS_AUTORUN_TIMEOUT must be a positive integer" + + # The generic cloud-onboard target runs shared checks against OpenClaw. Typed + # DCode targets reject this SKIP through the required-check wrapper. + if ! sandbox_exec "test -d /sandbox/.deepagents && command -v dcode >/dev/null 2>&1" >/dev/null; then + printf '%s: SKIP: sandbox %q is not a Deep Agents Code sandbox\n' "$PREFIX" "$SANDBOX_NAME" + exit 0 + fi + + trap cleanup_probe_files EXIT + cleanup_probe_files + + assert_capability_projection disabled + assert_status_mode disabled + assert_default_denial_ignores_ambient_override + pass "fresh sandbox denies auto-approval by trusted default and ignores ambient overrides" + + local rebuild_output + info "Enabling thread-opt-in through the named sandbox rebuild interface" + rebuild_output="$( + "$CLI" "$SANDBOX_NAME" rebuild --yes \ + --dcode-auto-approval thread-opt-in 2>&1 + )" || fail "named sandbox rebuild could not enable thread-opt-in: $rebuild_output" + + assert_capability_projection thread-opt-in + assert_status_mode thread-opt-in + pass "named sandbox rebuild projects and reports thread-opt-in" + + local capture_dir marker_file + capture_dir="$(mktemp -d "${TMPDIR:-/tmp}/${PREFIX}.XXXXXX")" + marker_file="${capture_dir}/markers.log" + : >"$marker_file" + # Raw PTY bytes are intentionally neither logged nor persisted. The marker + # file contains only fixed, non-secret phase names and is deleted below. + if ! run_autorun_tui "$marker_file"; then + fail "finite DCode autorun TUI harness failed: $(tr '\n' ' ' <"$marker_file")" + fi + assert_autorun_evidence "$marker_file" + rm -rf "$capture_dir" + pass "approval-menu opt-in autoruns repeated shell and non-shell rounds only for the current thread" + + run_boundary_check "OpenShell network policy boundary" "$NETWORK_BOUNDARY_CHECK" + run_boundary_check "managed credential boundary" "$CREDENTIAL_BOUNDARY_CHECK" + + info "Disabling thread-opt-in through the named sandbox rebuild interface" + rebuild_output="$( + "$CLI" "$SANDBOX_NAME" rebuild --yes \ + --dcode-auto-approval disabled 2>&1 + )" || fail "named sandbox rebuild could not disable thread-opt-in: $rebuild_output" + + assert_capability_projection disabled + assert_status_mode disabled + assert_default_denial_ignores_ambient_override + pass "named sandbox rebuild restores trusted default denial" + + printf '%s: 6 passed, 0 failed\n' "$PREFIX" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/test/e2e/live/cloud-experimental-check-list.ts b/test/e2e/live/cloud-experimental-check-list.ts index 6d083e33205..21a6cd86aca 100644 --- a/test/e2e/live/cloud-experimental-check-list.ts +++ b/test/e2e/live/cloud-experimental-check-list.ts @@ -5,6 +5,8 @@ export const DEEPAGENTS_FRESH_REONBOARD_CHECK = "test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh"; export const DEEPAGENTS_OBSERVABILITY_CHECK = "test/e2e/e2e-cloud-experimental/checks/11-deepagents-code-observability.sh"; +export const DEEPAGENTS_THREAD_AUTO_APPROVAL_CHECK = + "test/e2e/e2e-cloud-experimental/checks/12-deepagents-code-thread-auto-approval.sh"; export const DEEPAGENTS_CLOUD_EXPERIMENTAL_CHECKS = [ "test/e2e/e2e-cloud-experimental/checks/03-deepagents-code-nemotron-ultra-profile.sh", @@ -16,6 +18,7 @@ export const DEEPAGENTS_CLOUD_EXPERIMENTAL_CHECKS = [ "test/e2e/e2e-cloud-experimental/checks/09-deepagents-code-tavily-opt-in.sh", "test/e2e/e2e-cloud-experimental/checks/10-deepagents-code-tui-startup.sh", DEEPAGENTS_OBSERVABILITY_CHECK, + DEEPAGENTS_THREAD_AUTO_APPROVAL_CHECK, ] as const; export function cloudExperimentalChecksForOnboarding( diff --git a/test/e2e/live/cloud-experimental-checks.ts b/test/e2e/live/cloud-experimental-checks.ts index 9f590e9202a..688dbf0bcbc 100644 --- a/test/e2e/live/cloud-experimental-checks.ts +++ b/test/e2e/live/cloud-experimental-checks.ts @@ -12,12 +12,14 @@ import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { DEEPAGENTS_FRESH_REONBOARD_CHECK, DEEPAGENTS_OBSERVABILITY_CHECK, + DEEPAGENTS_THREAD_AUTO_APPROVAL_CHECK, } from "./cloud-experimental-check-list.ts"; const REQUIRED_CHECK_SKIP_PATTERN = /(^|\n).*\bSKIP\b/i; const DEFAULT_CHECK_TIMEOUT_MS = 180_000; const FRESH_REONBOARD_TIMEOUT_MS = 15 * 60_000; const OBSERVABILITY_TIMEOUT_MS = 8 * 60_000; +const THREAD_AUTO_APPROVAL_TIMEOUT_MS = 35 * 60_000; export type CloudExperimentalChecksEvidence = { targetId: string; @@ -89,6 +91,9 @@ export function assertRequiredCloudExperimentalResult( export function cloudExperimentalCheckTimeoutMs(scriptPath: string): number { if (scriptPath === DEEPAGENTS_FRESH_REONBOARD_CHECK) return FRESH_REONBOARD_TIMEOUT_MS; if (scriptPath === DEEPAGENTS_OBSERVABILITY_CHECK) return OBSERVABILITY_TIMEOUT_MS; + if (scriptPath === DEEPAGENTS_THREAD_AUTO_APPROVAL_CHECK) { + return THREAD_AUTO_APPROVAL_TIMEOUT_MS; + } return DEFAULT_CHECK_TIMEOUT_MS; } diff --git a/test/e2e/live/deepagents-observability-contract.ts b/test/e2e/live/deepagents-observability-contract.ts index 8772601796e..af13736e581 100644 --- a/test/e2e/live/deepagents-observability-contract.ts +++ b/test/e2e/live/deepagents-observability-contract.ts @@ -23,6 +23,7 @@ const OUTPUT_ATTRIBUTE_KEYS = ["output.value", "llm.output_messages"] as const; const TOOL_INPUT_ATTRIBUTE_KEYS = ["tool.parameters", "input.value"] as const; const CONFIRMED_EXEC_HINT = /^[a-z][a-z0-9-]*: recent network policy denial detected(?: for [^\r\n]+)? inside sandbox '[a-zA-Z0-9][a-zA-Z0-9_-]*'\.$/mu; +const EMBEDDED_STRUCTURED_PROXY_JSON_RE = /\{[^{}\r\n]{1,4094}\}/gu; export type LlmTraceExpectation = { label: string; promptMarker: string; @@ -152,7 +153,14 @@ export function assertDeepAgentsTraceContract( } export function hasConfirmedOpenShellPolicyDenial(output: string): boolean { - return output.split(/\r?\n/u).some(isPolicyDenialLine) || CONFIRMED_EXEC_HINT.test(output); + if (output.split(/\r?\n/u).some(isPolicyDenialLine) || CONFIRMED_EXEC_HINT.test(output)) { + return true; + } + // curl writes the response body to stdout and its error to stderr. Once the + // E2E harness merges those descriptors, the exact OpenShell JSON object can + // land between two curl error fragments. Keep this fallback bounded to one + // flat object and delegate the payload validation to the production parser. + return (output.match(EMBEDDED_STRUCTURED_PROXY_JSON_RE) ?? []).some(isPolicyDenialLine); } export function observabilityPresetState(output: string): string { diff --git a/test/e2e/live/onboard-repair.test.ts b/test/e2e/live/onboard-repair.test.ts index 733baf376a3..7b352ba0e9e 100644 --- a/test/e2e/live/onboard-repair.test.ts +++ b/test/e2e/live/onboard-repair.test.ts @@ -126,7 +126,10 @@ test("onboard repair resumes missing sandbox and rejects conflicting resume inpu skip(`Docker is required: ${resultText(docker)}`); } - const fake = await startFakeOpenAiCompatibleServer(); + const fake = await startFakeOpenAiCompatibleServer({ + host: "0.0.0.0", + publicHost: "host.openshell.internal", + }); cleanupRegistry.add("close fake OpenAI-compatible endpoint", async () => fake.close()); cleanupRegistry.add("remove repair sandboxes", () => cleanup(host, sandbox)); await cleanup(host, sandbox); diff --git a/test/e2e/support/deepagents-observability-contract.test.ts b/test/e2e/support/deepagents-observability-contract.test.ts index 64b7a20aa64..1eef481cd5d 100644 --- a/test/e2e/support/deepagents-observability-contract.test.ts +++ b/test/e2e/support/deepagents-observability-contract.test.ts @@ -237,6 +237,11 @@ describe("Deep Agents observability policy proof", () => { 'proxy: {"error":"policy_denied","detail":"CONNECT example.com:443 not allowed by any policy"}', ), ).toBe(true); + expect( + hasConfirmedOpenShellPolicyDenial( + 'curl: (22) Th{"detail":"POST host.openshell.internal:4318/v1/traces not permitted by policy","error":"policy_denied"}e requested URL returned error: 403', + ), + ).toBe(true); expect( hasConfirmedOpenShellPolicyDenial( "nemoclaw: recent network policy denial detected for example.com:443 inside sandbox 'dcode-test'.", diff --git a/test/e2e/support/platform-parity-cloud-experimental.test.ts b/test/e2e/support/platform-parity-cloud-experimental.test.ts index 706a9e633e1..4d2e79c5d1b 100644 --- a/test/e2e/support/platform-parity-cloud-experimental.test.ts +++ b/test/e2e/support/platform-parity-cloud-experimental.test.ts @@ -299,6 +299,15 @@ describe("P0-E cloud-experimental parity guardrails", () => { expect(result.stdout).toContain("NO_NEWLINE_IN_COMMAND"); }); + it("keeps the managed DCode thread-auto-approval live check valid Bash (#6478)", () => { + const scriptPath = path.join( + process.cwd(), + "test/e2e/e2e-cloud-experimental/checks/12-deepagents-code-thread-auto-approval.sh", + ); + const result = spawnSync("bash", ["-n", scriptPath], { encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + }); + it("registers executable Deep Agents cloud-experimental checks", () => { expect(DEEPAGENTS_CLOUD_EXPERIMENTAL_CHECKS).toEqual([ "test/e2e/e2e-cloud-experimental/checks/03-deepagents-code-nemotron-ultra-profile.sh", @@ -310,6 +319,7 @@ describe("P0-E cloud-experimental parity guardrails", () => { "test/e2e/e2e-cloud-experimental/checks/09-deepagents-code-tavily-opt-in.sh", "test/e2e/e2e-cloud-experimental/checks/10-deepagents-code-tui-startup.sh", "test/e2e/e2e-cloud-experimental/checks/11-deepagents-code-observability.sh", + "test/e2e/e2e-cloud-experimental/checks/12-deepagents-code-thread-auto-approval.sh", ]); for (const scriptPath of DEEPAGENTS_CLOUD_EXPERIMENTAL_CHECKS) { @@ -355,6 +365,11 @@ describe("P0-E cloud-experimental parity guardrails", () => { "test/e2e/e2e-cloud-experimental/checks/11-deepagents-code-observability.sh", ), ).toBe(8 * 60_000); + expect( + cloudExperimentalCheckTimeoutMs( + "test/e2e/e2e-cloud-experimental/checks/12-deepagents-code-thread-auto-approval.sh", + ), + ).toBe(35 * 60_000); }); it("documents Deep Agents check scripts in generated launch/QA evidence", () => { diff --git a/test/helpers/langchain-deepagents-code-patch-fixture.ts b/test/helpers/langchain-deepagents-code-patch-fixture.ts index e010b08bbe9..ce933576a94 100644 --- a/test/helpers/langchain-deepagents-code-patch-fixture.ts +++ b/test/helpers/langchain-deepagents-code-patch-fixture.ts @@ -12,6 +12,17 @@ export const agentDir = path.join(process.cwd(), "agents", "langchain-deepagents export const patcher = path.join(agentDir, "patch-managed-deepagents-code.py"); const packageFixtureDirs = new Set(); +export function managedAutoApprovalPath(root: string): string { + return path.join(root, "managed-auto-approval"); +} + +export function writeManagedAutoApproval(root: string, content: string, mode = 0o444): string { + const capabilityPath = managedAutoApprovalPath(root); + fs.writeFileSync(capabilityPath, content, { mode }); + fs.chmodSync(capabilityPath, mode); + return capabilityPath; +} + export function writeFixtureFile(root: string, relativePath: string, content: string): void { const target = path.join(root, relativePath); fs.mkdirSync(path.dirname(target), { recursive: true }); @@ -93,7 +104,7 @@ def parse_args(): def cli_main(): - parse_args() + args = parse_args() tracing_flags = ( "DEEPAGENTS_CODE_LANGSMITH_TRACING", "DEEPAGENTS_CODE_LANGSMITH_TRACING_V2", @@ -108,7 +119,7 @@ def cli_main(): assert all(os.environ.get(name) == "false" for name in tracing_flags) assert os.environ["LANGGRAPH_CLI_NO_ANALYTICS"] == "1" assert os.environ["HOME"] == "/sandbox" - print("managed-posture-ok") + print(f"managed-posture-ok auto_approve={args.auto_approve}") `, ); writeFixtureFile( @@ -123,14 +134,91 @@ def should_run_onboarding(state_dir=None): return True `, ); - writeFixtureFile( - packageDir, - "app.py", - fs.readFileSync( + const appFixture = fs + .readFileSync( path.join(process.cwd(), "test", "fixtures", "langchain-deepagents-code", "app.py"), "utf8", - ), - ); + ) + .replace( + "class DeepAgentsApp:\n", + `class _StatusBar: + def __init__(self): + self.auto_approve = True + + def set_auto_approve(self, *, enabled): + self.auto_approve = enabled + + +class _SessionState: + def __init__(self): + self.thread_id = "thread-1" + self.auto_approve = True + self.approval_mode_key = "approval/thread-1" + + +class DeepAgentsApp: +`, + ) + .replace( + " self._auto_approve = True\n self._status_bar = None\n self._session_state = None\n", + ` self._auto_approve = True + self._status_bar = _StatusBar() + self._session_state = _SessionState() + self._agent = object() + self._assistant_id = "agent-1" + self.resume_should_fail = False + self.resume_should_fail_after_reset = False + self.agent_swap_should_fail = False + self.agent_swap_should_fail_after_reset = False + self.clear_should_fail_early = False + self.clear_should_fail_after_reset = False +`, + ) + .replace( + " async def _on_auto_approve_enabled(self):\n self._auto_approve = True\n\n async def action_toggle_auto_approve(self):\n self._auto_approve = not self._auto_approve\n", + ` async def _on_auto_approve_enabled(self): + self._auto_approve = True + self._status_bar.set_auto_approve(enabled=True) + self._session_state.auto_approve = True + + async def action_toggle_auto_approve(self): + self._auto_approve = not self._auto_approve + self._status_bar.set_auto_approve(enabled=self._auto_approve) + self._session_state.auto_approve = self._auto_approve + + async def _resume_thread(self, thread_id): + if self.resume_should_fail: + return + previous_thread_id = self._session_state.thread_id + self._session_state.thread_id = thread_id + if self.resume_should_fail_after_reset: + self._session_state.thread_id = previous_thread_id + raise RuntimeError("resume failed after reset") + + async def _restart_server_for_agent_swap(self, agent_name): + if self.agent_swap_should_fail: + return + self._session_state.thread_id = f"{self._session_state.thread_id}-swap" + if self.agent_swap_should_fail_after_reset: + self._agent = None + raise RuntimeError("agent swap failed after reset") + self._assistant_id = agent_name + self._agent = object() +`, + ) + .replace( + " async def _handle_command(self, command):\n self.original_commands.append(command)\n", + ` async def _handle_command(self, command): + self.original_commands.append(command) + if command.lower().strip() in {"/clear", "/force-clear"}: + if self.clear_should_fail_early: + raise RuntimeError("clear failed before reset") + self._session_state.thread_id = f"{self._session_state.thread_id}-clear" + if self.clear_should_fail_after_reset: + raise RuntimeError("clear failed after reset") +`, + ); + writeFixtureFile(packageDir, "app.py", appFixture); writeFixtureFile( packageDir, "auth_store.py", @@ -625,6 +713,10 @@ export function patchFixture(tempDir: string): void { '"/usr/local/share/nemoclaw/dcode-inference-base-url"', JSON.stringify(managedBaseUrlFile), ) + .replace( + '"/usr/local/share/nemoclaw/dcode-auto-approval"', + JSON.stringify(managedAutoApprovalPath(tempDir)), + ) .replace("_MANAGED_FILE_OWNER_UID = 0", `_MANAGED_FILE_OWNER_UID = ${process.getuid?.() ?? 0}`); fs.writeFileSync(helperPath, helper, "utf8"); } diff --git a/test/helpers/rebuild-managed-image-preflight-harness.ts b/test/helpers/rebuild-managed-image-preflight-harness.ts index 54bc720ea9a..2222cb7277c 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", + dcodeAutoApprovalMode: "disabled", toolDisclosure: "progressive", webSearchConfig: null, sandboxGpuConfig: { diff --git a/test/langchain-deepagents-code-auto-approval-image.test.ts b/test/langchain-deepagents-code-auto-approval-image.test.ts new file mode 100644 index 00000000000..8e97b296617 --- /dev/null +++ b/test/langchain-deepagents-code-auto-approval-image.test.ts @@ -0,0 +1,83 @@ +// 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"; +import { describe, expect, it } from "vitest"; + +import { makeStartScriptFixture } from "./support/dcode-start-script-fixture.ts"; + +const repoRoot = path.resolve(import.meta.dirname, ".."); +const agentDir = path.join(repoRoot, "agents", "langchain-deepagents-code"); + +function readAgentFile(name: string): string { + return fs.readFileSync(path.join(agentDir, name), "utf8"); +} + +describe("LangChain Deep Agents Code auto-approval image contracts", () => { + it("bakes an exact root-owned capability without env trust (#6478)", () => { + const dockerfile = readAgentFile("Dockerfile"); + const launcher = readAgentFile("dcode-launcher.sh"); + const start = readAgentFile("start.sh"); + const wrapper = readAgentFile("dcode-wrapper.sh"); + const runtime = readAgentFile("managed-dcode-runtime.py"); + + expect(dockerfile).toContain("ARG NEMOCLAW_DCODE_AUTO_APPROVAL=disabled"); + expect(dockerfile).toContain("disabled|thread-opt-in)"); + expect(dockerfile).toContain( + `printf '%s\\n' "$NEMOCLAW_DCODE_AUTO_APPROVAL" > /usr/local/share/nemoclaw/dcode-auto-approval`, + ); + expect(dockerfile).toContain( + "chown root:root /usr/local/share/nemoclaw/dcode-proxy-host /usr/local/share/nemoclaw/dcode-proxy-port /usr/local/share/nemoclaw/dcode-inference-base-url /usr/local/share/nemoclaw/dcode-auto-approval", + ); + expect(dockerfile).toContain( + "chmod 0444 /usr/local/share/nemoclaw/dcode-proxy-host /usr/local/share/nemoclaw/dcode-proxy-port /usr/local/share/nemoclaw/dcode-inference-base-url /usr/local/share/nemoclaw/dcode-auto-approval", + ); + const envBlock = dockerfile.slice(dockerfile.indexOf("ENV HOME=")); + expect(envBlock).not.toContain("NEMOCLAW_DCODE_AUTO_APPROVAL"); + + for (const source of [launcher, start, wrapper]) { + expect(source).toContain("compgen -A variable NEMOCLAW_DCODE_AUTO_APPROVAL"); + } + expect(start).not.toContain("write_export_if_set NEMOCLAW_DCODE_AUTO_APPROVAL"); + expect(wrapper).toContain( + 'readonly MANAGED_DCODE_AUTO_APPROVAL_FILE="/usr/local/share/nemoclaw/dcode-auto-approval"', + ); + expect(runtime).toContain( + '_AUTO_APPROVAL_FILE = Path(\n "/usr/local/share/nemoclaw/dcode-auto-approval"\n)', + ); + expect(runtime).toContain("def managed_auto_approval_mode() -> str:"); + expect(runtime).toContain("def managed_auto_approval_enabled() -> bool:"); + }); + + it("strips ambient hints without serializing them into shell state (#6478)", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-auto-env-")); + try { + const { envFile, scriptPath } = makeStartScriptFixture(tempDir); + const output = execFileSync( + "bash", + [ + scriptPath, + "sh", + "-c", + `printf '%s,%s' "\${NEMOCLAW_DCODE_AUTO_APPROVAL-unset}" "\${NEMOCLAW_DCODE_AUTO_APPROVAL_ENABLED-unset}"`, + ], + { + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + NEMOCLAW_DCODE_AUTO_APPROVAL: "thread-opt-in", + NEMOCLAW_DCODE_AUTO_APPROVAL_ENABLED: "1", + }, + encoding: "utf8", + }, + ); + + expect(output).toBe("unset,unset"); + expect(fs.readFileSync(envFile, "utf8")).not.toContain("NEMOCLAW_DCODE_AUTO_APPROVAL"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/langchain-deepagents-code-direct-module-patch.test.ts b/test/langchain-deepagents-code-direct-module-patch.test.ts index 9f2f53faa92..2a4af1afa88 100644 --- a/test/langchain-deepagents-code-direct-module-patch.test.ts +++ b/test/langchain-deepagents-code-direct-module-patch.test.ts @@ -9,8 +9,10 @@ import { addDarwinFcntlSealConstants } from "./helpers/darwin-fcntl-seal-fixture import { cleanupPackageFixtures, createPackageFixture, + managedAutoApprovalPath, patcher, patchFixture, + writeManagedAutoApproval, } from "./helpers/langchain-deepagents-code-patch-fixture"; const progressiveDisclosureHarness = path.join( @@ -101,6 +103,8 @@ describe("LangChain Deep Agents Code managed package patch", () => { ], ["server override", "client/launch/server.py", 'env["LANGGRAPH_CLI_NO_ANALYTICS"] = "1"'], ["server", "client/launch/server.py", "env = _nemoclaw_original_build_server_env()"], + ["app", "app.py", "_nemoclaw_original_on_auto_approve_enabled"], + ["approval", "tui/widgets/approval.py", "if managed_auto_approval_enabled():"], ])("rejects a fully marked package with a corrupt %s patch", (boundary, relativePath, anchor) => { const tempDir = createPackageFixture(); patchFixture(tempDir); @@ -118,11 +122,13 @@ describe("LangChain Deep Agents Code managed package patch", () => { expect(fs.readFileSync(target, "utf8")).toBe(corrupted); }); - it("rejects a fully marked package with a stale managed analytics guard", () => { + it.each([ + ['os.environ["LANGGRAPH_CLI_NO_ANALYTICS"] = "1"'], + ["def managed_auto_approval_enabled() -> bool:"], + ])("rejects a fully marked package with a stale managed helper guard: %s", (anchor) => { const tempDir = createPackageFixture(); patchFixture(tempDir); const target = path.join(tempDir, "deepagents_code", "_nemoclaw_managed.py"); - const anchor = 'os.environ["LANGGRAPH_CLI_NO_ANALYTICS"] = "1"'; const corrupted = fs.readFileSync(target, "utf8").replace(anchor, `${anchor} # stale`); fs.writeFileSync(target, corrupted, "utf8"); @@ -211,6 +217,100 @@ else: expect(`${result.stdout}\n${result.stderr}`).toContain("disabled in NemoClaw-managed"); }); + it.each([ + ["-y"], + ["--auto-approve"], + ])("preserves explicit direct-module auto-approval in thread-opt-in mode: %s (#6478)", (...args) => { + const tempDir = createPackageFixture(); + patchFixture(tempDir); + writeManagedAutoApproval(tempDir, "thread-opt-in\n"); + const result = spawnSync("python3", ["-m", "deepagents_code", ...args], { + env: { + PATH: process.env.PATH, + PYTHONPATH: tempDir, + NEMOCLAW_DCODE_AUTO_APPROVAL: "disabled", + }, + encoding: "utf8", + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("managed-posture-ok auto_approve=True"); + expect(result.stderr).toContain("Auto-approval is enabled for this thread"); + expect(result.stderr).toContain("shell commands"); + }); + + it("validates exact trusted auto-approval state and otherwise fails closed (#6478)", () => { + const tempDir = createPackageFixture(); + patchFixture(tempDir); + const capabilityPath = managedAutoApprovalPath(tempDir); + const validation = ` +import os +from pathlib import Path + +from deepagents_code import _nemoclaw_managed as managed + +path = Path(${JSON.stringify(capabilityPath)}) + +def check(expected_mode, expected_enabled): + assert managed.managed_auto_approval_mode() == expected_mode + assert managed.managed_auto_approval_enabled() is expected_enabled + +check("disabled", False) +for content, expected_mode, expected_enabled in ( + (b"disabled\\n", "disabled", False), + (b"thread-opt-in\\n", "thread-opt-in", True), + (b"thread-opt-in", "disabled", False), + (b"thread-opt-in\\n\\n", "disabled", False), + (b"thread-opt-in\\x00", "disabled", False), + (b"enabled\\n", "disabled", False), +): + path.unlink(missing_ok=True) + path.write_bytes(content) + path.chmod(0o444) + check(expected_mode, expected_enabled) + +path.chmod(0o644) +check("disabled", False) +path.write_bytes(b"thread-opt-in\\n") +path.chmod(0o444) +trusted_owner = managed._MANAGED_FILE_OWNER_UID +managed._MANAGED_FILE_OWNER_UID = trusted_owner + 1 +try: + check("disabled", False) +finally: + managed._MANAGED_FILE_OWNER_UID = trusted_owner +path.unlink() +target = path.with_name(f"{path.name}-target") +target.write_bytes(b"thread-opt-in\\n") +target.chmod(0o444) +path.symlink_to(target) +check("disabled", False) +path.unlink() + +real_open = managed.os.open +def unreadable(*args, **kwargs): + raise PermissionError("unreadable") +managed.os.open = unreadable +try: + check("disabled", False) +finally: + managed.os.open = real_open + +os.environ["NEMOCLAW_DCODE_AUTO_APPROVAL"] = "thread-opt-in" +os.environ["NEMOCLAW_DCODE_AUTO_APPROVAL_ENABLED"] = "1" +check("disabled", False) +`; + const result = spawnSync("python3", ["-c", validation], { + env: { NEMOCLAW_DEBUG: "1", PATH: process.env.PATH, PYTHONPATH: tempDir }, + encoding: "utf8", + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toContain("NemoClaw managed auto-approval disabled:"); + expect(result.stderr).toContain("capability metadata is unsafe"); + expect(result.stderr).toContain("capability contents are invalid"); + }); + it("preserves ordinary direct-module and read-only tools execution", () => { const tempDir = createPackageFixture(); patchFixture(tempDir); @@ -705,11 +805,19 @@ async def validate(): ) assert instance.original_switch_kwargs is None instance._auto_approve = True + instance._status_bar.set_auto_approve(enabled=True) + instance._session_state.auto_approve = True await instance._on_auto_approve_enabled() assert instance._auto_approve is False + assert instance._status_bar.auto_approve is False + assert instance._session_state.auto_approve is False instance._auto_approve = True + instance._status_bar.set_auto_approve(enabled=True) + instance._session_state.auto_approve = True await instance.action_toggle_auto_approve() assert instance._auto_approve is False + assert instance._status_bar.auto_approve is False + assert instance._session_state.auto_approve is False await instance._set_rubric_model("anthropic:test") assert instance._rubric_model is None assert instance._server_kwargs["rubric_model"] is None @@ -1020,6 +1128,206 @@ print("managed-boundaries-ok") expect(output).toContain("managed-boundaries-ok"); }); + it("enables warned thread-scoped approval and resets it at thread boundaries (#6478)", () => { + const tempDir = createPackageFixture(); + patchFixture(tempDir); + writeManagedAutoApproval(tempDir, "thread-opt-in\n"); + const validation = ` +import asyncio +import importlib.util +import sys + +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 _nemoclaw_managed, agent, app, main as dcode_main +from deepagents_code.client import non_interactive +from deepagents_code.tui.widgets.approval import ApprovalMenu + +WARNING = "Tool calls, including shell commands, may execute without further confirmation" + +def set_auto(instance, enabled): + instance._auto_approve = enabled + instance._status_bar.set_auto_approve(enabled=enabled) + instance._session_state.auto_approve = enabled + +def assert_auto(instance, enabled): + assert instance._auto_approve is enabled + assert instance._status_bar.auto_approve is enabled + assert instance._session_state.auto_approve is enabled + +def assert_reset(instance): + assert_auto(instance, False) + assert instance._session_state.approval_mode_key is None + +async def validate(): + assert _nemoclaw_managed.managed_auto_approval_mode() == "thread-opt-in" + assert _nemoclaw_managed.managed_auto_approval_enabled() is True + original_argv = sys.argv + sys.argv = ["dcode"] + assert dcode_main.parse_args().auto_approve is False + sys.argv = ["dcode", "-n", "message", "--auto-approve"] + assert dcode_main.parse_args().auto_approve is True + sys.argv = original_argv + assert agent._resolve_ptc_option( + ["execute"], tools=[], acknowledge_unsafe=True, auto_approve=True + ) is None + headless_kwargs = await non_interactive.run_non_interactive( + "message", + "assistant", + startup_cmd="touch /tmp/unsafe", + model_params={"api_key": "secret"}, + sandbox_type="modal", + mcp_config_path="mcp.json", + no_mcp=False, + trust_project_mcp=True, + enable_interpreter=True, + interpreter_ptc=["execute"], + rubric_model="anthropic:attacker", + ) + assert headless_kwargs["startup_cmd"] is None + assert headless_kwargs["model_params"] is None + assert headless_kwargs["sandbox_type"] == "none" + assert headless_kwargs["mcp_config_path"] is None + assert headless_kwargs["no_mcp"] is True + assert headless_kwargs["trust_project_mcp"] is False + assert headless_kwargs["enable_interpreter"] is False + assert headless_kwargs["interpreter_ptc"] is None + assert headless_kwargs["rubric_model"] is None + instance = app.DeepAgentsApp() + + set_auto(instance, False) + await instance._on_auto_approve_enabled() + assert_auto(instance, True) + assert WARNING in instance.notifications[-1][0] + + set_auto(instance, False) + warning_count = len(instance.notifications) + await instance.action_toggle_auto_approve() + assert_auto(instance, True) + assert len(instance.notifications) == warning_count + 1 + assert WARNING in instance.notifications[-1][0] + await instance.action_toggle_auto_approve() + assert_auto(instance, False) + assert len(instance.notifications) == warning_count + 1 + + approval = ApprovalMenu() + approval._handle_selection(1) + assert approval.decisions == [("auto_approve_all", None)] + assert approval.notifications == [] + + set_auto(instance, True) + previous_thread = instance._session_state.thread_id + await instance._handle_command("/clear") + assert instance._session_state.thread_id != previous_thread + assert_reset(instance) + + set_auto(instance, True) + previous_thread = instance._session_state.thread_id + await instance._handle_command("/force-clear") + assert instance._session_state.thread_id != previous_thread + assert_reset(instance) + + set_auto(instance, True) + previous_thread = instance._session_state.thread_id + instance.clear_should_fail_early = True + try: + await instance._handle_command("/clear") + except RuntimeError: + pass + else: + raise AssertionError("early clear failure was not raised") + assert instance._session_state.thread_id == previous_thread + assert_reset(instance) + + instance.clear_should_fail_early = False + instance.clear_should_fail_after_reset = True + try: + await instance._handle_command("/clear") + except RuntimeError: + pass + else: + raise AssertionError("post-reset clear failure was not raised") + assert instance._session_state.thread_id != previous_thread + assert_reset(instance) + instance.clear_should_fail_after_reset = False + + set_auto(instance, True) + previous_thread = instance._session_state.thread_id + instance.resume_should_fail = True + await instance._resume_thread("thread-failed") + assert instance._session_state.thread_id == previous_thread + assert_reset(instance) + + instance.resume_should_fail = False + instance.resume_should_fail_after_reset = True + try: + await instance._resume_thread("thread-reset-then-failed") + except RuntimeError: + pass + else: + raise AssertionError("post-reset resume failure was not raised") + assert instance._session_state.thread_id == previous_thread + assert_reset(instance) + + set_auto(instance, True) + instance._session_state.approval_mode_key = "approval/thread-reset-then-failed" + instance.resume_should_fail_after_reset = False + await instance._resume_thread("thread-2") + assert instance._session_state.thread_id == "thread-2" + assert_reset(instance) + + set_auto(instance, True) + previous_thread = instance._session_state.thread_id + instance.agent_swap_should_fail = True + await instance._restart_server_for_agent_swap("agent-failed") + assert instance._session_state.thread_id == previous_thread + assert_reset(instance) + + set_auto(instance, True) + instance._session_state.thread_id = None + await instance._restart_server_for_agent_swap("agent-none") + assert instance._session_state.thread_id is None + assert_reset(instance) + + instance.agent_swap_should_fail = False + instance.agent_swap_should_fail_after_reset = True + try: + await instance._restart_server_for_agent_swap("agent-restart-failed") + except RuntimeError: + pass + else: + raise AssertionError("post-reset agent swap failure was not raised") + assert instance._session_state.thread_id is not None + assert_reset(instance) + + set_auto(instance, True) + previous_thread = instance._session_state.thread_id + instance.agent_swap_should_fail_after_reset = False + instance.agent_swap_should_fail = False + await instance._restart_server_for_agent_swap("agent-2") + assert instance._session_state.thread_id != previous_thread + assert instance._assistant_id == "agent-2" + assert_reset(instance) + +asyncio.run(validate()) +print("managed-auto-approval-ok") +`; + const result = spawnSync("python3", ["-c", validation], { + env: { PATH: process.env.PATH, PYTHONPATH: tempDir }, + encoding: "utf8", + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("managed-auto-approval-ok"); + }); + it("fails closed when the installed version or required source shape drifts", () => { const wrongVersion = createPackageFixture("0.1.31"); const versionResult = spawnSync("python3", [patcher], { diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index 2f33ccee52d..79bf33de8f9 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -598,9 +598,9 @@ describe("LangChain Deep Agents Code image contracts", () => { "test/e2e/e2e-cloud-experimental/checks/09-deepagents-code-tavily-opt-in.sh", "test/e2e/e2e-cloud-experimental/checks/10-deepagents-code-tui-startup.sh", "test/e2e/e2e-cloud-experimental/checks/11-deepagents-code-observability.sh", + "test/e2e/e2e-cloud-experimental/checks/12-deepagents-code-thread-auto-approval.sh", ]); }); - it("ships a headless inference acceptance check for Deep Agents Code", () => { const headlessCheck = fs.readFileSync(headlessCheckPath, "utf8"); for (const expected of [ diff --git a/test/langchain-deepagents-code-managed-entrypoints.test.ts b/test/langchain-deepagents-code-managed-entrypoints.test.ts index da58f8ef92b..544e51c6f71 100644 --- a/test/langchain-deepagents-code-managed-entrypoints.test.ts +++ b/test/langchain-deepagents-code-managed-entrypoints.test.ts @@ -31,9 +31,21 @@ const MANAGED_MCP_VALIDATOR_INVOCATION = [ ')"', ].join("\n"); -function makeWrapperFixture(tempDir: string): { wrapperPath: string; ranMarker: string } { +function writeAutoApprovalCapability(path: string, content?: string): void { + const configuredContents = content === undefined ? [] : [content]; + for (const configuredContent of configuredContents) { + fs.writeFileSync(path, configuredContent, { mode: 0o444 }); + fs.chmodSync(path, 0o444); + } +} + +function makeWrapperFixture( + tempDir: string, + autoApprovalContent?: string, +): { wrapperPath: string; ranMarker: string; autoApprovalPath: string } { const wrapperPath = path.join(tempDir, "dcode-wrapper.sh"); const ranMarker = path.join(tempDir, "dcode-ran"); + const autoApprovalPath = path.join(tempDir, "dcode-auto-approval"); const envFile = path.join(tempDir, ".env"); const authFile = path.join(tempDir, "auth.json"); const codexAuthFile = path.join(tempDir, "chatgpt-auth.json"); @@ -55,14 +67,23 @@ function makeWrapperFixture(tempDir: string): { wrapperPath: string; ranMarker: 'readonly DEEPAGENTS_CODEX_AUTH_FILE="/sandbox/.deepagents/.state/chatgpt-auth.json"', `readonly DEEPAGENTS_CODEX_AUTH_FILE="${codexAuthFile}"`, ) + .replace( + 'readonly MANAGED_DCODE_AUTO_APPROVAL_FILE="/usr/local/share/nemoclaw/dcode-auto-approval"', + `readonly MANAGED_DCODE_AUTO_APPROVAL_FILE="${autoApprovalPath}"`, + ) + .replace( + "readonly MANAGED_DCODE_AUTO_APPROVAL_OWNER_UID=0", + `readonly MANAGED_DCODE_AUTO_APPROVAL_OWNER_UID=${process.getuid?.() ?? 0}`, + ) .replace('/opt/venv/bin/python3 -I - "$auth_file"', 'python3 -I - "$auth_file"') .replace( "exec /opt/venv/bin/python3 -I -m deepagents_code", `touch "${ranMarker}"; printf 'dcode-tracing=%s,%s,%s,%s,%s,%s,%s,%s,%s analytics=%s openai-proxy=%s\\n' "$DEEPAGENTS_CODE_LANGSMITH_TRACING" "$DEEPAGENTS_CODE_LANGSMITH_TRACING_V2" "$DEEPAGENTS_CODE_LANGCHAIN_TRACING" "$DEEPAGENTS_CODE_LANGCHAIN_TRACING_V2" "$LANGSMITH_TRACING" "$LANGSMITH_TRACING_V2" "$LANGCHAIN_TRACING" "$LANGCHAIN_TRACING_V2" "$OTEL_ENABLED" "$LANGGRAPH_CLI_NO_ANALYTICS" "\${OPENAI_PROXY-__unset__}"; exit 0; : /opt/venv/bin/python3 -I -m deepagents_code`, ); fs.writeFileSync(envFile, "", "utf8"); + writeAutoApprovalCapability(autoApprovalPath, autoApprovalContent); fs.writeFileSync(wrapperPath, fixture, { mode: 0o755 }); - return { wrapperPath, ranMarker }; + return { wrapperPath, ranMarker, autoApprovalPath }; } describe("LangChain Deep Agents Code managed entrypoints", () => { @@ -180,6 +201,105 @@ describe("LangChain Deep Agents Code managed entrypoints", () => { expect(fs.existsSync(ranMarker)).toBe(false); }); + it.each([ + "-y", + "--auto-a", + "--auto-ap", + "--auto-app", + "--auto-appr", + "--auto-appro", + "--auto-approv", + "--auto-approve", + ])("allows explicit thread auto-approval through %s only in thread-opt-in mode (#6478)", (arg) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-auto-opt-in-")); + const { wrapperPath, ranMarker } = makeWrapperFixture(tempDir, "thread-opt-in\n"); + const result = spawnSync("bash", [wrapperPath, arg], { + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + NEMOCLAW_DCODE_AUTO_APPROVAL: "disabled", + }, + encoding: "utf8", + }); + + expect(result.status, result.stderr).toBe(0); + expect(fs.existsSync(ranMarker)).toBe(true); + }); + + it("keeps non-interactive argument scanning fail-closed around auto-approval (#6478)", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-auto-headless-")); + const { wrapperPath, ranMarker } = makeWrapperFixture(tempDir, "thread-opt-in\n"); + const enabled = spawnSync("bash", [wrapperPath, "-n", "hi", "--auto-approve"], { + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + encoding: "utf8", + }); + + expect(enabled.status, enabled.stderr).toBe(0); + expect(fs.existsSync(ranMarker)).toBe(true); + + const disabledTempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-dcode-auto-headless-disabled-"), + ); + const disabledFixture = makeWrapperFixture(disabledTempDir); + const disabled = spawnSync( + "bash", + [disabledFixture.wrapperPath, "-n", "hi", "--auto-approve"], + { + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + encoding: "utf8", + }, + ); + + expect(disabled.status).not.toBe(0); + expect(disabled.stderr).toContain("tool approval"); + expect(fs.existsSync(disabledFixture.ranMarker)).toBe(false); + }); + + it("fails closed for ambient, malformed, symlinked, and writable auto-approval state (#6478)", () => { + const cases = [ + { label: "ambient only", prepare: (_path: string) => undefined }, + { + label: "malformed", + prepare: (capabilityPath: string) => { + fs.writeFileSync(capabilityPath, "thread-opt-in"); + fs.chmodSync(capabilityPath, 0o444); + }, + }, + { + label: "writable", + prepare: (capabilityPath: string) => { + fs.writeFileSync(capabilityPath, "thread-opt-in\n"); + fs.chmodSync(capabilityPath, 0o644); + }, + }, + { + label: "symlinked", + prepare: (capabilityPath: string) => { + const target = `${capabilityPath}-target`; + fs.writeFileSync(target, "thread-opt-in\n", { mode: 0o444 }); + fs.symlinkSync(target, capabilityPath); + }, + }, + ]; + + for (const { label, prepare } of cases) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-auto-unsafe-")); + const { wrapperPath, ranMarker, autoApprovalPath } = makeWrapperFixture(tempDir); + prepare(autoApprovalPath); + const result = spawnSync("bash", [wrapperPath, "-y"], { + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + NEMOCLAW_DCODE_AUTO_APPROVAL: "thread-opt-in", + NEMOCLAW_DCODE_AUTO_APPROVAL_ENABLED: "1", + }, + encoding: "utf8", + }); + + expect(result.status, `${label}: ${result.stderr}`).not.toBe(0); + expect(result.stderr).toContain("tool approval posture"); + expect(fs.existsSync(ranMarker)).toBe(false); + } + }); + it("removes an inherited OpenAI-specific proxy before the managed package starts", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-openai-proxy-")); const { wrapperPath } = makeWrapperFixture(tempDir); diff --git a/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts b/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts index ef403638a01..ae7809ff49f 100644 --- a/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts +++ b/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts @@ -53,10 +53,22 @@ def parse_args(): def cli_main(): return parse_args() `, - "app.py": fs.readFileSync( - path.join(repoRoot, "test", "fixtures", "langchain-deepagents-code", "app.py"), - "utf8", - ), + "app.py": fs + .readFileSync( + path.join(repoRoot, "test", "fixtures", "langchain-deepagents-code", "app.py"), + "utf8", + ) + .replace( + " async def _switch_model(self, model_spec, **kwargs):\n", + ` async def _resume_thread(self, thread_id): + del thread_id + + async def _restart_server_for_agent_swap(self, agent_name): + del agent_name + + async def _switch_model(self, model_spec, **kwargs): +`, + ), "auth_store.py": `from __future__ import annotations class StoredCredential: pass diff --git a/test/onboard-prepared-gateway-handoff.test.ts b/test/onboard-prepared-gateway-handoff.test.ts index 7f1fec4d8a9..2c5b1e3e97c 100644 --- a/test/onboard-prepared-gateway-handoff.test.ts +++ b/test/onboard-prepared-gateway-handoff.test.ts @@ -78,7 +78,11 @@ const options = scenario === "prepared" ...common, resume: true, recreateSandbox: true, - preparedDcodeRebuild: { buildContext: preparedBuildContext, gatewayName: "nemoclaw" }, + preparedDcodeRebuild: { + buildContext: preparedBuildContext, + gatewayName: "nemoclaw", + dcodeAutoApprovalMode: "disabled", + }, } : scenario === "mismatch" ? { @@ -88,6 +92,7 @@ const options = scenario === "prepared" preparedDcodeRebuild: { buildContext: preparedBuildContext, gatewayName: "nemoclaw-18080", + dcodeAutoApprovalMode: "disabled", }, } : { ...common, fresh: true, sandboxName: "ordinary-dcode" }; diff --git a/test/onboard-terminal-dashboard.test.ts b/test/onboard-terminal-dashboard.test.ts index bfdcfe1d777..54b58375205 100644 --- a/test/onboard-terminal-dashboard.test.ts +++ b/test/onboard-terminal-dashboard.test.ts @@ -63,7 +63,10 @@ const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, "") agentOnboard.createAgentSandbox = () => { const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-terminal-agent-")); const stagedDockerfile = path.join(buildCtx, "Dockerfile"); - fs.writeFileSync(stagedDockerfile, "FROM scratch\nCMD [\"/bin/sh\"]\n"); + fs.writeFileSync( + stagedDockerfile, + "FROM scratch\nARG NEMOCLAW_DCODE_AUTO_APPROVAL=disabled\nCMD [\"/bin/sh\"]\n", + ); return { buildCtx, stagedDockerfile }; }; diff --git a/test/support/status-flow-test-harness.ts b/test/support/status-flow-test-harness.ts index e166e687446..a5d73c2d32b 100644 --- a/test/support/status-flow-test-harness.ts +++ b/test/support/status-flow-test-harness.ts @@ -60,6 +60,7 @@ export type StatusFlowHarnessOptions = { sandboxEntry?: Partial> & { agent?: string | null; agentVersion?: string | null; + dcodeAutoApprovalMode?: "disabled" | "thread-opt-in"; }; shieldsPosture?: { mode: "locked" | "mutable_default" | "mutable";