diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index c94c8b08006..1e20b3e3f88 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -35,6 +35,7 @@ COPY agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py /o COPY agents/langchain-deepagents-code/validate-observability.py /opt/nemoclaw-deepagents-code/validate-observability.py COPY agents/langchain-deepagents-code/dcode-wrapper.sh /usr/local/lib/nemoclaw/dcode-wrapper.sh COPY agents/langchain-deepagents-code/dcode-launcher.sh /usr/local/lib/nemoclaw/dcode-launcher.sh +COPY agents/langchain-deepagents-code/dcode-session-supervisor.py /usr/local/lib/nemoclaw/dcode-session-supervisor.py COPY agents/langchain-deepagents-code/start.sh /usr/local/bin/nemoclaw-start COPY nemoclaw-blueprint/ /opt/nemoclaw-blueprint/ # The first-party profile plugin uses Deep Agents' supported entry-point hook to @@ -49,7 +50,8 @@ COPY nemoclaw-blueprint/ /opt/nemoclaw-blueprint/ # removalCondition: remove when installation validates dependencies atomically. # hadolint ignore=DL4006 RUN chmod 444 /opt/nemoclaw-deepagents-code/generate-config.ts /opt/nemoclaw-deepagents-code/managed-dcode-runtime.py /opt/nemoclaw-deepagents-code/patch-managed-deepagents-code.py /opt/nemoclaw-deepagents-code/validate-nemotron-ultra-profile.py /opt/nemoclaw-deepagents-code/progressive_tool_disclosure.py /opt/nemoclaw-deepagents-code/nemoclaw_observability.py /opt/nemoclaw-deepagents-code/validate-progressive-tool-disclosure.py /opt/nemoclaw-deepagents-code/validate-observability.py \ - && chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/dcode-wrapper.sh /usr/local/lib/nemoclaw/dcode-launcher.sh \ + && chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/dcode-wrapper.sh /usr/local/lib/nemoclaw/dcode-launcher.sh /usr/local/lib/nemoclaw/dcode-session-supervisor.py \ + && test "$(stat -c '%u:%g:%a' /usr/local/lib/nemoclaw/dcode-session-supervisor.py)" = "0:0:755" \ && install -o root -g root -m 0755 /usr/local/lib/nemoclaw/dcode-launcher.sh /usr/local/lib/nemoclaw/dcode-managed-exec \ && test -f /usr/local/lib/nemoclaw/dcode-managed-exec \ && test ! -L /usr/local/lib/nemoclaw/dcode-managed-exec \ diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index 43b3df0b569..d89c5a29d26 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -17,7 +17,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3=3.13.5-1 \ python3-pip=25.1.1+dfsg-1 \ python3-venv=3.13.5-1 \ - curl=8.14.1-2+deb13u3 \ + curl=8.14.1-2+deb13u4 \ git=1:2.47.3-0+deb13u1 \ ca-certificates=20250419 \ iproute2=6.15.0-1 \ @@ -27,6 +27,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ e2fsprogs=1.47.2-3+b11 \ openssh-sftp-server=1:10.0p1-7+deb13u4 \ jq=1.7.1-6+deb13u2 \ + ripgrep=14.1.1-1+b4 \ vim-tiny=2:9.1.1230-2 \ && rm -rf /var/lib/apt/lists/* @@ -36,6 +37,12 @@ RUN groupadd -r sandbox \ && mkdir -p /sandbox/.nemoclaw \ /sandbox/.deepagents/.state \ /sandbox/.deepagents/skills \ + # Deep Agents Code owns the optional-name onboarding state, but managed + # terminals cannot answer that upstream first-run prompt before becoming + # usable. Preseed it here; the TUI startup E2E rejects pending onboarding + # and unexpected name prompts. Remove this when upstream supports a + # documented non-interactive managed-onboarding mode. + && printf '1\n' > /sandbox/.deepagents/.state/onboarding_complete \ && chown -R sandbox:sandbox /sandbox \ && chmod 2770 /sandbox/.deepagents \ && chmod 770 /sandbox/.deepagents/.state /sandbox/.deepagents/skills diff --git a/agents/langchain-deepagents-code/dcode-launcher.sh b/agents/langchain-deepagents-code/dcode-launcher.sh index e1d4918e498..429583fb6eb 100755 --- a/agents/langchain-deepagents-code/dcode-launcher.sh +++ b/agents/langchain-deepagents-code/dcode-launcher.sh @@ -14,6 +14,7 @@ unset _nemoclaw_auto_approval_env readonly MANAGED_DCODE_WRAPPER="/usr/local/lib/nemoclaw/dcode-wrapper.sh" readonly MANAGED_EXEC_LAUNCHER="/usr/local/lib/nemoclaw/dcode-managed-exec" readonly MANAGED_OBSERVABILITY_MARKER="/sandbox/.deepagents/.nemoclaw-observability-enabled" +readonly MANAGED_SESSION_SUPERVISOR="/usr/local/lib/nemoclaw/dcode-session-supervisor.py" export HOME=/sandbox export PATH="/usr/local/bin:/opt/venv/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin" @@ -159,4 +160,23 @@ if [ "$0" = "$MANAGED_EXEC_LAUNCHER" ]; then exec "$@" fi -exec "$MANAGED_DCODE_WRAPPER" "$@" +# Read-only managed identity commands never start DCode or LangGraph children. +# Keep onboard's live-route validation on the established wrapper path while +# supervising every command that can create a terminal-agent process tree. +case "${1:-}" in + status | whoami | identity | --version | -v | -V) exec "$MANAGED_DCODE_WRAPPER" "$@" ;; +esac + +# DCode's one-shot mode owns and cleans up its server lifecycle before exiting. +# Keep that established automation path outside the interactive-session +# supervisor; this also preserves the wrapper's exact parser diagnostics. +_nemoclaw_dcode_args=("$@") +for ((_nemoclaw_arg_index = 0; _nemoclaw_arg_index < ${#_nemoclaw_dcode_args[@]}; _nemoclaw_arg_index++)); do + _nemoclaw_arg="${_nemoclaw_dcode_args[_nemoclaw_arg_index]}" + case "$_nemoclaw_arg" in + -n | -n?* | --non-interactive | --non-interactive=*) exec "$MANAGED_DCODE_WRAPPER" "$@" ;; + esac +done +unset _nemoclaw_dcode_args _nemoclaw_arg_index _nemoclaw_arg + +exec /opt/venv/bin/python3 -I "$MANAGED_SESSION_SUPERVISOR" "$MANAGED_DCODE_WRAPPER" "$@" diff --git a/agents/langchain-deepagents-code/dcode-session-supervisor.py b/agents/langchain-deepagents-code/dcode-session-supervisor.py new file mode 100644 index 00000000000..7ef9d7e33e4 --- /dev/null +++ b/agents/langchain-deepagents-code/dcode-session-supervisor.py @@ -0,0 +1,185 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Reap processes started by one managed Deep Agents Code terminal session.""" + +from __future__ import annotations + +import ctypes +import errno +import os +import signal +import subprocess +import sys +import time +from collections.abc import Sequence +from pathlib import Path + +_PR_SET_CHILD_SUBREAPER = 36 +_TERM_GRACE_SECONDS = 3.0 +_KILL_GRACE_SECONDS = 1.0 +_POLL_SECONDS = 0.05 + + +def _enable_child_subreaper() -> None: + """Adopt orphaned LangGraph descendants when the DCode process exits.""" + libc = ctypes.CDLL(None, use_errno=True) + if libc.prctl(_PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) != 0: + error = ctypes.get_errno() + raise OSError(error, os.strerror(error)) + + +def _direct_children() -> set[int]: + children: set[int] = set() + try: + entries = os.scandir("/proc") + except OSError: + return children + with entries: + for entry in entries: + if not entry.name.isdecimal(): + continue + try: + stat = Path(f"/proc/{entry.name}/stat").read_text(encoding="utf-8") + closing = stat.rfind(")") + fields = stat[closing + 2 :].split() + if closing != -1 and len(fields) >= 2 and int(fields[1]) == os.getpid(): + children.add(int(entry.name)) + except (FileNotFoundError, PermissionError, ValueError, OSError): + continue + return children + + +def _reap_exited_children() -> None: + while True: + try: + pid, _status = os.waitpid(-1, os.WNOHANG) + except ChildProcessError: + return + except InterruptedError: + continue + if pid == 0: + return + + +def _signal_children(children: set[int], sig: signal.Signals) -> None: + for pid in children: + try: + os.kill(pid, sig) + except ProcessLookupError: + continue + except PermissionError: + print( + f"dcode: cannot signal managed session descendant pid={pid}", + file=sys.stderr, + ) + + +def _cleanup_adopted_descendants() -> None: + """Terminate and reap every descendant associated with this launch.""" + deadline = time.monotonic() + _TERM_GRACE_SECONDS + signaled: set[int] = set() + while True: + _reap_exited_children() + children = _direct_children() + if not children: + return + new_children = children - signaled + if new_children: + _signal_children(new_children, signal.SIGTERM) + signaled.update(new_children) + if time.monotonic() >= deadline: + _signal_children(children, signal.SIGKILL) + break + time.sleep(_POLL_SECONDS) + + kill_deadline = time.monotonic() + 1.0 + while time.monotonic() < kill_deadline: + _reap_exited_children() + children = _direct_children() + if not children: + return + _signal_children(children, signal.SIGKILL) + time.sleep(_POLL_SECONDS) + _reap_exited_children() + + +def _exit_code(returncode: int) -> int: + return returncode if returncode >= 0 else 128 + abs(returncode) + + +def _wait_after_disconnect(child: subprocess.Popen[bytes]) -> int: + """Bound shutdown even when the direct DCode child ignores disconnect.""" + try: + return child.wait(timeout=_TERM_GRACE_SECONDS) + except subprocess.TimeoutExpired: + child.terminate() + try: + return child.wait(timeout=_KILL_GRACE_SECONDS) + except subprocess.TimeoutExpired: + child.kill() + return child.wait() + + +def run(argv: Sequence[str]) -> int: + if not argv: + print("dcode session supervisor requires a command.", file=sys.stderr) + return 64 + if sys.platform != "linux": + print( + "dcode: session supervision requires a Linux OpenShell sandbox.", + file=sys.stderr, + ) + return 1 + + _enable_child_subreaper() + child: subprocess.Popen[bytes] | None = None + pending_signals: list[int] = [] + disconnect_received = False + + def forward(sig: int, _frame: object) -> None: + nonlocal disconnect_received + disconnect_received = True + if child is None: + pending_signals.append(sig) + return + try: + os.kill(child.pid, sig) + except (ProcessLookupError, PermissionError): + # The child may exit between signal delivery and this forwarding + # attempt; cleanup below still reaps any adopted descendants. + pass + + # Terminal-generated SIGINT already reaches every member of the foreground + # process group. Keep the supervisor alive to reap descendants without + # delivering a second Ctrl-C to DCode. OpenShell may target only the direct + # launcher for disconnect/termination signals, so those are forwarded. + signal.signal(signal.SIGINT, lambda _sig, _frame: None) + for sig in (signal.SIGHUP, signal.SIGTERM): + signal.signal(sig, forward) + + try: + child = subprocess.Popen(list(argv)) + for pending_signal in pending_signals: + forward(pending_signal, None) + while True: + try: + returncode = child.wait(timeout=_POLL_SECONDS) + break + except subprocess.TimeoutExpired: + if disconnect_received: + returncode = _wait_after_disconnect(child) + break + finally: + _cleanup_adopted_descendants() + return _exit_code(returncode) + + +if __name__ == "__main__": + try: + raise SystemExit(run(sys.argv[1:])) + except OSError as error: + if error.errno == errno.ENOSYS: + print("dcode: Linux child-subreaper support is unavailable.", file=sys.stderr) + else: + print(f"dcode: session supervisor failed: {error}", file=sys.stderr) + raise SystemExit(1) from error diff --git a/agents/langchain-deepagents-code/dcode-wrapper.sh b/agents/langchain-deepagents-code/dcode-wrapper.sh index d5a2e0d01c5..ed3f450dff4 100755 --- a/agents/langchain-deepagents-code/dcode-wrapper.sh +++ b/agents/langchain-deepagents-code/dcode-wrapper.sh @@ -802,8 +802,12 @@ print_identity() { model="$(terminal_safe_identity_value "$(toml_section_scalar models default)")" [ -n "$model" ] || model="$(terminal_safe_identity_value "$(toml_section_scalar models recent)")" endpoint="$(toml_section_scalar models.providers.openai base_url)" + [ -n "$endpoint" ] || endpoint="$(toml_section_scalar models.providers.openrouter base_url)" route="$(terminal_safe_identity_value "$(toml_provider_metadata route)")" provider="$(terminal_safe_identity_value "$(toml_provider_metadata provider)")" + case "$model" in + openrouter:*) provider="openrouter" ;; + esac [ -n "$endpoint" ] || endpoint="${OPENAI_BASE_URL:-}" endpoint="$(safe_endpoint_identity_value "$endpoint")" printf 'Sandbox: %s\n' "$sandbox_name" diff --git a/agents/langchain-deepagents-code/generate-config.ts b/agents/langchain-deepagents-code/generate-config.ts index 531dee50b18..279e63091b1 100644 --- a/agents/langchain-deepagents-code/generate-config.ts +++ b/agents/langchain-deepagents-code/generate-config.ts @@ -219,6 +219,10 @@ function buildConfig(settings: Settings): ManagedDeepAgentsConfig { "check = false", "auto_update = false", "", + "[warnings]", + "# Tavily is optional in managed sandboxes; surface errors only when web search is invoked.", + 'suppress = ["tavily"]', + "", ].join("\n"); return { text, provider, model, defaultModel }; } diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index d442fde1114..bde02ee2bc7 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -118,8 +118,8 @@ This agent-specific default does not change the shared Nemotron 3 Super default NemoClaw/OpenShell keeps real provider credentials in credential handling and does not write them into the Deep Agents config file. Deep Agents Code reaches `inference.local` through the managed OpenShell L7 proxy rather than direct sandbox DNS. The image launcher normalizes the runtime proxy environment for interactive, login-shell, and direct-exec paths and removes inherited proxy credentials and bypass entries before `dcode` starts. -Managed interactive sessions keep Deep Agents Code's optional first-run name prompt, skip its dependency and model selection screens, then open the TUI with the model selected during NemoClaw onboarding. -Press Enter at the name prompt to continue without setting a name. +Managed interactive sessions pre-complete Deep Agents Code's optional first-run onboarding, skip its dependency and model selection screens, then open the TUI with the model selected during NemoClaw onboarding. +The image includes `ripgrep`, and ordinary sessions suppress the optional Tavily warning unless web search is configured or invoked. @@ -151,6 +151,9 @@ dcode -n "Summarize this repository" ``` The managed `dcode`, `dcode.real`, and `deepagents-code` launchers use `/opt/venv/bin/python3 -I` to run the pinned package with an isolated import path and `HOME=/sandbox`. +For interactive sessions, each launcher supervises its own process descendants so terminal exit or disconnect terminates the associated LangGraph server tree without affecting another session. +After a disconnect, the supervisor uses bounded grace periods before it kills unresponsive processes from that session. +The supervisor runs inside the Linux OpenShell sandbox and fails closed if invoked outside Linux; the host operating system does not change this sandbox guarantee. They disable Deep Agents Code package update checks and the LangGraph server version check, block CLI and TUI update/install commands, and disable nested remote sandbox providers, remote async subagents, MCP commands and project auto-loading, startup commands, executable hooks, ACP mode, interpreter tool calling, shell allow-list overrides, native LangSmith tracing, and ambient OpenTelemetry exporter configuration. The managed model constructor accepts only Deep Agents Code's `openai` provider path and reads its endpoint from a root-owned image file. It supplies the non-secret gateway placeholder key and ignores mutable provider classes, credentials, endpoints, and constructor parameters in Deep Agents Code config. diff --git a/test/dcode-session-supervisor.test.ts b/test/dcode-session-supervisor.test.ts new file mode 100644 index 00000000000..8816b15a35e --- /dev/null +++ b/test/dcode-session-supervisor.test.ts @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const supervisor = path.join( + process.cwd(), + "agents", + "langchain-deepagents-code", + "dcode-session-supervisor.py", +); +const canRun = process.platform === "linux" && spawnSync("python3", ["--version"]).status === 0; + +describe("managed DCode session supervisor platform boundary", () => { + it("fails closed instead of silently bypassing supervision outside Linux", () => { + const probe = [ + "import importlib.util", + "spec = importlib.util.spec_from_file_location('supervisor', " + + JSON.stringify(supervisor) + + ")", + "module = importlib.util.module_from_spec(spec)", + "spec.loader.exec_module(module)", + "module.sys.platform = 'darwin'", + "module.os.execvp = lambda *_args: (_ for _ in ()).throw(RuntimeError('child executed'))", + "raise SystemExit(module.run(['/fake/dcode']))", + ].join("\n"); + const result = spawnSync("python3", ["-c", probe], { encoding: "utf8" }); + + expect(result.status).toBe(1); + expect(result.stderr).toBe("dcode: session supervision requires a Linux OpenShell sandbox.\n"); + }); + + it("switches to bounded waiting when disconnect arrives after child spawn", () => { + const probe = [ + "import importlib.util", + "import os", + "import signal", + `spec = importlib.util.spec_from_file_location('supervisor', ${JSON.stringify(supervisor)})`, + "module = importlib.util.module_from_spec(spec)", + "spec.loader.exec_module(module)", + "module.sys.platform = 'linux'", + "module._enable_child_subreaper = lambda: None", + "module._cleanup_adopted_descendants = lambda: None", + "real_kill = os.kill", + "forwarded = []", + "module.os.kill = lambda pid, sig: forwarded.append((pid, sig))", + "class FakeChild:", + " pid = 4242", + " waits = 0", + " def __init__(self, _argv): pass", + " def wait(self, timeout=None):", + " if self.waits == 0:", + " self.waits += 1", + " real_kill(os.getpid(), signal.SIGHUP)", + " raise module.subprocess.TimeoutExpired(['/fake/dcode'], timeout)", + " return 0", + "module.subprocess.Popen = FakeChild", + "status = module.run(['/fake/dcode'])", + "expected = [(4242, signal.SIGHUP)]", + "raise SystemExit(0 if status == 0 and forwarded == expected else 1)", + ].join("\n"); + const result = spawnSync("python3", ["-c", probe], { encoding: "utf8" }); + + expect(result.status, result.stderr).toBe(0); + }); +}); + +describe.runIf(canRun)("managed DCode session supervisor", () => { + it("queues rapid pre-spawn disconnect signals and forwards them in order", () => { + const probe = [ + "import importlib.util", + "import os", + "import signal", + "import sys", + `spec = importlib.util.spec_from_file_location('supervisor', ${JSON.stringify(supervisor)})`, + "module = importlib.util.module_from_spec(spec)", + "spec.loader.exec_module(module)", + "forwarded = []", + "real_kill = os.kill", + "module._enable_child_subreaper = lambda: None", + "module._cleanup_adopted_descendants = lambda: None", + "module.os.kill = lambda pid, sig: forwarded.append((pid, sig))", + "class FakeChild:", + " pid = 4242", + " def __init__(self, _argv):", + " real_kill(os.getpid(), signal.SIGHUP)", + " real_kill(os.getpid(), signal.SIGTERM)", + " def wait(self, timeout=None):", + " return 0", + "module.subprocess.Popen = FakeChild", + "status = module.run(['/fake/dcode'])", + "expected = [(4242, signal.SIGHUP), (4242, signal.SIGTERM)]", + "raise SystemExit(0 if status == 0 and forwarded == expected else 1)", + ].join("\n"); + + const result = spawnSync("python3", ["-c", probe], { encoding: "utf8" }); + + expect(result.status, result.stderr).toBe(0); + }); + + it("preserves the DCode exit code after session cleanup", () => { + const result = spawnSync("python3", [supervisor, "/bin/sh", "-c", "exit 7"], { + encoding: "utf8", + }); + + expect(result.status, result.stderr).toBe(7); + }); + + it("preserves the managed empty-prompt exit contract through the supervisor", () => { + const diagnostic = "NemoClaw: empty non-interactive prompt for -n; provide prompt text."; + const result = spawnSync( + "python3", + [supervisor, "/bin/sh", "-c", `printf '%s\\n' ${JSON.stringify(diagnostic)} >&2; exit 2`], + { encoding: "utf8" }, + ); + + expect(result.status).toBe(2); + expect(result.stderr).toBe(`${diagnostic}\n`); + }); + + it("terminates an orphaned LangGraph-like descendant when its session exits (#6678)", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-supervisor-")); + const pidFile = path.join(dir, "descendant.pid"); + const child = path.join(dir, "session.py"); + fs.writeFileSync( + child, + [ + "import pathlib", + "import subprocess", + "import sys", + "descendant = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'])", + `pathlib.Path(${JSON.stringify(pidFile)}).write_text(str(descendant.pid), encoding='utf-8')`, + ].join("\n"), + ); + + try { + const result = spawnSync("python3", [supervisor, "python3", child], { + encoding: "utf8", + timeout: 10_000, + }); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + const descendantPid = Number(fs.readFileSync(pidFile, "utf8")); + expect(Number.isSafeInteger(descendantPid)).toBe(true); + expect(() => process.kill(descendantPid, 0)).toThrow( + expect.objectContaining({ code: "ESRCH" }), + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("bounds disconnect cleanup when the direct child ignores signals (#6678)", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-disconnect-")); + const pidFile = path.join(dir, "processes.pid"); + const session = path.join(dir, "session.py"); + const harness = path.join(dir, "harness.py"); + fs.writeFileSync( + session, + [ + "import os", + "import pathlib", + "import signal", + "import subprocess", + "import sys", + "import time", + "signal.signal(signal.SIGHUP, signal.SIG_IGN)", + "signal.signal(signal.SIGTERM, signal.SIG_IGN)", + "descendant = subprocess.Popen([sys.executable, '-c', 'import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)'])", + `pathlib.Path(${JSON.stringify(pidFile)}).write_text(f'{os.getpid()}\\n{descendant.pid}\\n', encoding='utf-8')`, + "time.sleep(30)", + ].join("\n"), + ); + fs.writeFileSync( + harness, + [ + "import os", + "import pathlib", + "import signal", + "import subprocess", + "import sys", + "import time", + `pid_file = pathlib.Path(${JSON.stringify(pidFile)})`, + `supervisor = subprocess.Popen([sys.executable, ${JSON.stringify(supervisor)}, sys.executable, ${JSON.stringify(session)}])`, + "deadline = time.monotonic() + 5", + "while not pid_file.exists() and time.monotonic() < deadline:", + " time.sleep(0.05)", + "if not pid_file.exists():", + " supervisor.kill()", + " raise SystemExit('session did not publish process ids')", + "pids = [int(value) for value in pid_file.read_text(encoding='utf-8').splitlines()]", + "os.kill(supervisor.pid, signal.SIGHUP)", + "supervisor.wait(timeout=10)", + "for pid in pids:", + " try:", + " os.kill(pid, 0)", + " except ProcessLookupError:", + " continue", + " raise SystemExit(f'process still alive: {pid}')", + ].join("\n"), + ); + + try { + const result = spawnSync("python3", [harness], { encoding: "utf8", timeout: 15_000 }); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/dcode-wrapper-identity.test.ts b/test/dcode-wrapper-identity.test.ts index 61691862d0b..a4f3dbfd55a 100644 --- a/test/dcode-wrapper-identity.test.ts +++ b/test/dcode-wrapper-identity.test.ts @@ -157,6 +157,24 @@ describe.skipIf(!canRun)( }); }); + it("reports native OpenRouter identity for a managed OpenRouter config (#6678)", () => { + withTempDir((dir) => { + const config = SAMPLE_CONFIG.replace( + "upstream provider: nvidia-prod", + "upstream provider: openrouter-api", + ) + .replace('default = "openai:demo-model"', 'default = "openrouter:demo-model"') + .replace("[models.providers.openai]", "[models.providers.openrouter]"); + const run = runBashWrapper(buildFixture(dir, config), ["status"], {}); + + expect(run.status).toBe(0); + expect(run.stdout).toContain("Provider: openrouter"); + expect(run.stdout).toContain("Model: openrouter:demo-model"); + expect(run.stdout).toContain("Endpoint: https://inference.local/v1"); + expect(run.stdout).not.toContain("Provider: openrouter-api"); + }); + }); + it("uses the upstream default agent when configured preferences are stale", () => { withTempDir((dir) => { const run = runBashWrapper(buildFixture(dir, SAMPLE_CONFIG), ["status"], {}); diff --git a/test/deepagents-code-tui-startup-check.test.ts b/test/deepagents-code-tui-startup-check.test.ts index 9c2c05ac48b..bf7333ea3da 100644 --- a/test/deepagents-code-tui-startup-check.test.ts +++ b/test/deepagents-code-tui-startup-check.test.ts @@ -57,9 +57,10 @@ function secretFixture(...parts: string[]): string { return parts.join(""); } -type TuiExpectEvent = "eof" | "exit" | "firstRun" | "namePrompt" | "ready" | "timeout"; +type TuiExpectEvent = "composer" | "eof" | "exit" | "firstRun" | "namePrompt" | "ready" | "timeout"; const tclEventLiterals: Record = { + composer: "{composer}", eof: "{eof}", exit: "{exit}", firstRun: "{firstRun}", @@ -113,6 +114,10 @@ proc expect {branches} { set event [lindex $::fake_events 0] set ::fake_events [lrange $::fake_events 1 end] switch -- $event { + composer { + set branch_index [lsearch -exact $branches {$composer_pattern}] + set ::expect_out(0,string) "> dcode v0.1.34" + } namePrompt { set branch_index [lsearch -exact $branches {$name_prompt_pattern}] set ::expect_out(0,string) "What should Deep Agents call you" @@ -170,6 +175,7 @@ proc exit {{code 0}} { ...process.env, NEMOCLAW_TUI_CAPTURE: capture, NEMOCLAW_TUI_CLOSE_AFTER_FIRST_CTRL_C: options.closeAfterFirstCtrlC ? "1" : "0", + NEMOCLAW_TUI_COMPOSER_PATTERN: "(dcode[^\\r\\n]*v0\\.1\\.34)", NEMOCLAW_TUI_EXPECT_NAME_PROMPT: options.expectNamePrompt === false ? "0" : "1", NEMOCLAW_TUI_MARKERS: markers, NEMOCLAW_TUI_FIRST_RUN_PATTERN: "(choose a recommended model)", @@ -245,7 +251,7 @@ describe("Deep Agents Code TUI startup check helpers", () => { it("fails closed without package-manager operations when expect is unavailable", () => { const result = runTuiStartupCheckHelperResult( [ - "sandbox_exec() { printf 'NEMOCLAW_DCODE_PROBE:deepagents\\nNEMOCLAW_DCODE_ONBOARDING:pending\\n'; }", + "sandbox_exec() { printf 'NEMOCLAW_DCODE_PROBE:deepagents\\nNEMOCLAW_DCODE_ONBOARDING:complete\\n'; }", 'command() { if [ "$1" = -v ] && [ "${2:-}" = expect ]; then return 1; fi; builtin command "$@"; }', "main", ].join("; "), @@ -258,6 +264,20 @@ describe("Deep Agents Code TUI startup check helpers", () => { expect(tuiStartupCheckSource).not.toMatch(/\b(?:sudo|apt-get)\b/u); }); + it("rejects managed images that still require first-run onboarding (#6678)", () => { + const result = runTuiStartupCheckHelperResult( + [ + "sandbox_exec() { printf 'NEMOCLAW_DCODE_PROBE:deepagents\\nNEMOCLAW_DCODE_ONBOARDING:pending\\n'; }", + "main", + ].join("; "), + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "managed Deep Agents Code first-run onboarding is still pending", + ); + }); + it("matches the pinned Select Agent modal without accepting startup-only text", () => { const readiness = (capture: string) => runTuiStartupCheckHelper( @@ -293,7 +313,7 @@ describe("Deep Agents Code TUI startup check helpers", () => { expect(isFirstRun("What would you like to build?")).toBe("other"); }); - it("matches the name prompt pattern that managed DCode allows on first run", () => { + it("matches the name prompt pattern that managed DCode rejects as unexpected first-run UX", () => { const isNamePrompt = (capture: string) => runTuiStartupCheckHelper( 'if printf "%s" "$CAPTURE" | grep -Eiq "$TUI_NAME_PROMPT_PATTERN"; then printf name-prompt; else printf other; fi', @@ -319,7 +339,13 @@ describe("Deep Agents Code TUI startup check helpers", () => { expect(markerText).not.toContain("NEMOCLAW_TUI_READY"); }); - itWithTclsh("allows the first-run name prompt and proceeds to ready state", () => { + // Invalid state: an already-deployed image lacks onboarding_complete. + // Source boundary: Dockerfile.base now creates the marker for every new image. + // Why retained here: existing sandboxes can still run an older image until rebuilt. + // Regression: "rejects managed images that still require first-run onboarding" fails + // closed for current images. Remove this diagnostic after rebuild telemetry shows + // no managed DCode sandboxes remain on pre-marker images. + itWithTclsh("can diagnose a legacy image that still presents the first-run name prompt", () => { const { markerText, result, traceText } = runTuiExpectStateMachine( ["namePrompt", "ready", "exit"], { closeAfterFirstCtrlC: true }, @@ -345,13 +371,17 @@ describe("Deep Agents Code TUI startup check helpers", () => { }); itWithTclsh("captures a clean exit when dcode closes after the first Ctrl-C (tclsh)", () => { - const { markerText, result, traceText } = runTuiExpectStateMachine(["ready", "exit"], { - closeAfterFirstCtrlC: true, - expectNamePrompt: false, - }); + const { markerText, result, traceText } = runTuiExpectStateMachine( + ["composer", "ready", "exit"], + { + closeAfterFirstCtrlC: true, + expectNamePrompt: false, + }, + ); expect(result.status, result.stderr).toBe(0); expect(traceText).toBe("2f,61,67,65,6e,74,73,0d,1b,03"); + expect(markerText).toContain("NEMOCLAW_TUI_COMPOSER_READY"); expect(markerText).toContain("NEMOCLAW_TUI_READY"); expect(markerText).toContain("NEMOCLAW_TUI_EXIT_CAPTURED:0"); }); @@ -383,30 +413,51 @@ describe("Deep Agents Code TUI startup check helpers", () => { it("preserves TUI lifecycle markers in the sanitized capture artifact", () => { const captureDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-tui-markers-")); const sanitizedCapture = path.join(captureDir, "10-deepagents-code-tui-startup.sanitized.log"); + const repeatedSanitizedCapture = path.join( + captureDir, + "10-deepagents-code-tui-startup.repeat.sanitized.log", + ); + const processCounts = path.join(captureDir, "process-counts.txt"); + fs.writeFileSync(processCounts, "0\n1\n0\n1\n0\n"); try { const result = runTuiStartupCheckHelperResult( [ - "sandbox_exec() { printf 'NEMOCLAW_DCODE_PROBE:deepagents\\nNEMOCLAW_DCODE_ONBOARDING:pending\\n'; }", + "sandbox_exec() { printf 'NEMOCLAW_DCODE_PROBE:deepagents\\nNEMOCLAW_DCODE_ONBOARDING:complete\\n'; }", "ensure_expect_available() { return 0; }", + "dcode_process_count() {", + ' value="$(sed -n "1p" "$COUNT_FILE")"', + ' sed "1d" "$COUNT_FILE" >"$COUNT_FILE.next"', + ' mv -- "$COUNT_FILE.next" "$COUNT_FILE"', + ' printf "NEMOCLAW_DCODE_PROCESS_COUNT:%s\\n" "$value"', + "}", + "sleep() { :; }", "run_tui_expect() {", ' printf "Select Agent\\nNEMOCLAW_TUI_READY\\nNEMOCLAW_TUI_EXIT_CAPTURED:130\\n" >>"$2"', " return 0", "}", "main", ].join("\n"), - { DEEPAGENTS_TUI_CAPTURE_DIR: captureDir }, + { COUNT_FILE: processCounts, DEEPAGENTS_TUI_CAPTURE_DIR: captureDir }, ); const sanitizedText = fs.readFileSync(sanitizedCapture, "utf8"); + const repeatedSanitizedText = fs.readFileSync(repeatedSanitizedCapture, "utf8"); expect(result.status).toBe(0); - expect(result.stdout).toContain("finite expect harness reached startup and observed exit"); + expect(result.stdout).toContain( + "session 1: finite expect harness reached startup and observed exit", + ); expect(result.stdout).toContain( "dcode TUI reached the main composer and opened Select Agent", ); expect(result.stdout).toContain("dcode TUI exited cleanly after Ctrl-C (exit 130)"); expect(sanitizedText).toContain("NEMOCLAW_TUI_READY"); expect(sanitizedText).toContain("NEMOCLAW_TUI_EXIT_CAPTURED:130"); + expect(repeatedSanitizedText).toContain("NEMOCLAW_TUI_READY"); + expect(result.stdout).toContain( + "session 2: DCode/LangGraph process count returned to baseline", + ); + expect(fs.readFileSync(processCounts, "utf8")).toBe(""); } finally { fs.rmSync(captureDir, { force: true, recursive: true }); } @@ -418,8 +469,10 @@ describe("Deep Agents Code TUI startup check helpers", () => { try { const result = runTuiStartupCheckHelperResult( [ - "sandbox_exec() { printf 'NEMOCLAW_DCODE_PROBE:deepagents\\nNEMOCLAW_DCODE_ONBOARDING:pending\\n'; }", + "sandbox_exec() { printf 'NEMOCLAW_DCODE_PROBE:deepagents\\nNEMOCLAW_DCODE_ONBOARDING:complete\\n'; }", "ensure_expect_available() { return 0; }", + "dcode_process_count() { printf 'NEMOCLAW_DCODE_PROCESS_COUNT:0\\n'; }", + "wait_for_dcode_process_baseline() { return 0; }", "run_tui_expect() {", ' printf "NEMOCLAW_TUI_EOF_BEFORE_READY\\n" >>"$2"', " return 21", @@ -430,7 +483,7 @@ describe("Deep Agents Code TUI startup check helpers", () => { ); expect(result.status).toBe(1); - expect(result.stderr).toContain("finite expect harness exited 21"); + expect(result.stderr).toContain("session 1: finite expect harness exited 21"); expect(result.stderr).toContain("sanitized capture excerpt (last 20000 bytes)"); expect(result.stderr).toContain("NEMOCLAW_TUI_EOF_BEFORE_READY"); } finally { diff --git a/test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh b/test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh index ae31bc17cab..87550e1f56d 100755 --- a/test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh +++ b/test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh @@ -118,7 +118,7 @@ initial_model, target_model = sys.argv[1:] text = path.read_text(encoding="utf-8") config = tomllib.loads(text) provider = config["models"]["providers"]["openai"] -assert set(config) == {"models", "update", "ui", "threads"} +assert set(config) == {"models", "update", "ui", "threads", "warnings"} assert config["models"]["default"] == f"openai:{target_model}" assert provider["models"] == [target_model] assert provider["api_key_env"] == "DEEPAGENTS_CODE_OPENAI_API_KEY" @@ -126,6 +126,7 @@ assert provider["base_url"] == "https://inference.local/v1" assert config["update"] == {"check": False, "auto_update": False} assert config["ui"] == {"show_scrollbar": True, "show_url_open_toast": False} assert config["threads"] == {"relative_time": False, "sort_order": "created_at"} +assert config["warnings"] == {"suppress": ["tavily"]} assert initial_model not in text for forbidden in ( "compatible-anthropic-endpoint", diff --git a/test/e2e/e2e-cloud-experimental/checks/07-deepagents-code-headless-inference.sh b/test/e2e/e2e-cloud-experimental/checks/07-deepagents-code-headless-inference.sh index 8f5434e1371..0e34f9887c4 100755 --- a/test/e2e/e2e-cloud-experimental/checks/07-deepagents-code-headless-inference.sh +++ b/test/e2e/e2e-cloud-experimental/checks/07-deepagents-code-headless-inference.sh @@ -146,6 +146,13 @@ references_managed_placeholder_key() { grep -Eq 'api_key_env[[:space:]]*=[[:space:]]*"DEEPAGENTS_CODE_OPENAI_API_KEY"' } +uses_native_openrouter_config() { + local config + config="$(cat)" + printf '%s\n' "$config" | grep -Eq '^default[[:space:]]*=[[:space:]]*"openrouter:[^"]+"' \ + && printf '%s\n' "$config" | grep -Fxq '[models.providers.openrouter]' +} + is_local_execution_failure() { grep -Eiq '(^|[[:space:]])(usage:|Traceback|SyntaxError|ImportError|ModuleNotFoundError|No module named|command not found|No such file or directory|Permission denied|invalid option)([[:space:]]|$)|DCODE_EXIT:12[67]' } @@ -307,6 +314,20 @@ main() { else fail_test "config.toml does not use the managed placeholder API key env reference (captured config redacted from log)" fi + if printf '%s\n' "$config_output" | uses_native_openrouter_config; then + native_openrouter=1 + openrouter_identity_output="$(sandbox_direct_dcode identity || true)" + if printf '%s\n' "$openrouter_identity_output" | grep -Fxq "Provider: openrouter" \ + && printf '%s\n' "$openrouter_identity_output" | grep -Eq '^Model:[[:space:]]+openrouter:' \ + && printf '%s\n' "$openrouter_identity_output" | grep -Fxq "Endpoint: https://inference.local/v1"; then + pass "installed dcode identity reports the native managed OpenRouter route" + else + fail_test "installed dcode identity does not report native OpenRouter consistently" + fi + else + native_openrouter=0 + openrouter_identity_output="" + fi # 2. Record whether direct DNS/hosts is absent. When it is, the following # login, direct-exec, and connect successes prove they do not depend on it; @@ -352,6 +373,14 @@ main() { dcode_exit="$(printf '%s' "$headless_output" | sed -n 's/.*DCODE_EXIT:\([0-9]\+\).*/\1/p' | tail -n1)" if classification="$(classify_headless_output "${dcode_exit:-unknown}" "$headless_output")"; then pass "login-shell dcode -n reached managed inference with ${classification} (exit ${dcode_exit:-unknown}; direct DNS/hosts ${direct_dns_state})" + if [ "$native_openrouter" -eq 1 ]; then + if printf '%s\n' "$headless_output" | grep -Fq "Usage Stats" \ + && printf '%s\n' "$headless_output" | grep -Eiq '(^|[[:space:]])openrouter([[:space:]]|$)'; then + pass "headless usage output reports the native OpenRouter provider" + else + fail_test "headless usage output does not report the native OpenRouter provider" + fi + fi else fail_test "login-shell dcode -n did not exit 0 with PONG (${classification}, exit ${dcode_exit:-unknown})" fi @@ -382,6 +411,7 @@ DCODE_EXIT:${direct_exit}" # 8. No real secrets in managed config, runtime env files, artifacts, logs, or captured output. leak_scan="$(sandbox_exec "$(sandbox_artifact_scan_command)" || true)" combined="${config_output} +${openrouter_identity_output} ${leak_scan} ${entrypoint_rlimit_output} ${login_rlimit_output} diff --git a/test/e2e/e2e-cloud-experimental/checks/10-deepagents-code-tui-startup.sh b/test/e2e/e2e-cloud-experimental/checks/10-deepagents-code-tui-startup.sh index 14592deb042..e8b5a64fe7c 100755 --- a/test/e2e/e2e-cloud-experimental/checks/10-deepagents-code-tui-startup.sh +++ b/test/e2e/e2e-cloud-experimental/checks/10-deepagents-code-tui-startup.sh @@ -4,9 +4,10 @@ # # Case: Deep Agents Code interactive TUI startup (#5620). # -# This live check runs against a real Deep Agents Code sandbox. It proves the -# interactive `dcode` TUI starts in a PTY, opens and closes the `/agents` modal, -# exits after Ctrl-C, and leaves only sanitized, secret-free capture artifacts. +# This live check runs twice against a real Deep Agents Code sandbox. It proves +# the interactive `dcode` TUI starts in a PTY, opens and closes the `/agents` +# modal, exits after Ctrl-C without leaking DCode/LangGraph processes, and +# leaves only sanitized, secret-free capture artifacts. # # shellcheck disable=SC2016 # expect(1) Tcl: $env(...) and {...} are Tcl/sh expansion, not bash expansion. @@ -16,6 +17,7 @@ set -euo pipefail SANDBOX_NAME="${SANDBOX_NAME:-${NEMOCLAW_SANDBOX_NAME:-e2e-cloud-onboard}}" PREFIX="10-deepagents-code-tui-startup" TUI_TIMEOUT="${DEEPAGENTS_TUI_TIMEOUT:-90}" +PROCESS_CLEANUP_TIMEOUT=20 # Shell-only live check fallback for remote e2e hosts; Vitest parity coverage in # test/deepagents-code-tui-startup-check.test.ts pins this to secret-patterns.ts. SECRET_PATTERN='(?:nvapi-[A-Za-z0-9_-]{10,}|nvcf-[A-Za-z0-9_-]{10,}|ghp_[A-Za-z0-9_-]{10,}|github_pat_[A-Za-z0-9_]{30,}|sk-proj-[A-Za-z0-9_-]{10,}|sk-ant-[A-Za-z0-9_-]{10,}|sk-[A-Za-z0-9_-]{20,}|(?:xox[bpas]|xapp)-[A-Za-z0-9-]{10,}|A(?:K|S)IA[A-Z0-9]{16}|hf_[A-Za-z0-9]{10,}|glpat-[A-Za-z0-9_-]{10,}|gsk_[A-Za-z0-9]{10,}|pypi-[A-Za-z0-9_-]{10,}|\bbot[0-9]{8,10}:[A-Za-z0-9_-]{35}\b|\b[0-9]{8,10}:[A-Za-z0-9_-]{35}\b|\b[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b|tvly-[A-Za-z0-9_-]{10,}|lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*)' @@ -24,6 +26,9 @@ CONTEXT_SECRET_VALUE_PATTERN='[A-Za-z0-9_.+\/=-]{10,}' # proves input reached the main composer after optional onboarding; headless # check 07 independently owns backend readiness and inference acceptance. TUI_READY_PATTERN='(select agent)' +# Wait for the pinned main TUI banner before sending `/agents`; a fixed delay +# can race startup and submit `agents` as an ordinary chat prompt instead. +TUI_COMPOSER_PATTERN='(dcode[^\r\n]*v0\.1\.34)' # NemoClaw configures DCode's model and managed provider before launch, so # the model picker is a regression. The name prompt is allowed on first run. TUI_FIRST_RUN_PATTERN='(choose a recommended model)' @@ -49,6 +54,28 @@ is_positive_integer() { [[ "$1" =~ ^[1-9][0-9]*$ ]] } +dcode_process_count() { + sandbox_exec 'self=$$; parent=$PPID; count=0; for proc_dir in /proc/[0-9]*; do pid=${proc_dir##*/}; case " $self $parent " in *" $pid "*) continue ;; esac; [ -r "$proc_dir/cmdline" ] || continue; cmdline=$(tr "\000" " " <"$proc_dir/cmdline" 2>/dev/null) || continue; case "${cmdline,,}" in *dcode-session-supervisor* | *deepagents_code* | *langgraph* | */opt/venv/bin/dcode*) count=$((count + 1)) ;; esac; done; printf "NEMOCLAW_DCODE_PROCESS_COUNT:%s\n" "$count"' +} + +wait_for_dcode_process_baseline() { + local baseline="$1" + local deadline=$((SECONDS + PROCESS_CLEANUP_TIMEOUT)) + local count output + while :; do + output="$(dcode_process_count)" || return 1 + count="$(sed -n 's/^NEMOCLAW_DCODE_PROCESS_COUNT:\([0-9][0-9]*\)$/\1/p' <<<"$output" | tail -n1)" + [[ "$count" =~ ^[0-9]+$ ]] || return 1 + if [ "$count" -le "$baseline" ]; then + return 0 + fi + if [ "$SECONDS" -ge "$deadline" ]; then + return 1 + fi + sleep 1 + done +} + ensure_expect_available() { # The Deep Agents Code TUI proof is a PTY contract, so expect(1) is a # required host dependency. Privileged installation belongs to the reviewed @@ -142,6 +169,7 @@ run_tui_expect() { local expect_name_prompt="$3" env \ NEMOCLAW_TUI_CAPTURE="$raw_capture_file" \ + NEMOCLAW_TUI_COMPOSER_PATTERN="$TUI_COMPOSER_PATTERN" \ NEMOCLAW_TUI_MARKERS="$marker_capture_file" \ NEMOCLAW_TUI_FIRST_RUN_PATTERN="$TUI_FIRST_RUN_PATTERN" \ NEMOCLAW_TUI_NAME_PROMPT_PATTERN="$TUI_NAME_PROMPT_PATTERN" \ @@ -153,6 +181,7 @@ run_tui_expect() { set timeout $env(NEMOCLAW_TUI_TIMEOUT) set sandbox $env(NEMOCLAW_TUI_SANDBOX_NAME) set capture $env(NEMOCLAW_TUI_CAPTURE) +set composer_pattern $env(NEMOCLAW_TUI_COMPOSER_PATTERN) set markers $env(NEMOCLAW_TUI_MARKERS) set first_run_pattern $env(NEMOCLAW_TUI_FIRST_RUN_PATTERN) set name_prompt_pattern $env(NEMOCLAW_TUI_NAME_PROMPT_PATTERN) @@ -219,8 +248,39 @@ if {$expect_name_prompt eq "1"} { } } else { append_marker $markers "NEMOCLAW_TUI_NO_NAME_PROMPT" - after 1000 - submit_agents $markers + set timeout $env(NEMOCLAW_TUI_TIMEOUT) + expect { + -nocase -re $composer_pattern { + append_marker $markers "$expect_out(0,string)" + append_marker $markers "NEMOCLAW_TUI_COMPOSER_READY" + submit_agents $markers + } + -nocase -re $name_prompt_pattern { + append_marker $markers "$expect_out(0,string)" + append_marker $markers "NEMOCLAW_TUI_UNEXPECTED_NAME_PROMPT" + puts "\nNEMOCLAW_TUI_UNEXPECTED_NAME_PROMPT" + send -- "\003" + exit 25 + } + -nocase -re $first_run_pattern { + append_marker $markers "$expect_out(0,string)" + append_marker $markers "NEMOCLAW_TUI_UNEXPECTED_FIRST_RUN" + puts "\nNEMOCLAW_TUI_UNEXPECTED_FIRST_RUN" + send -- "\003" + exit 24 + } + timeout { + append_marker $markers "NEMOCLAW_TUI_TIMEOUT" + puts "\nNEMOCLAW_TUI_TIMEOUT" + send -- "\003" + exit 20 + } + eof { + append_marker $markers "NEMOCLAW_TUI_EOF_BEFORE_READY" + puts "\nNEMOCLAW_TUI_EOF_BEFORE_READY" + exit 21 + } + } } set ready_match "" @@ -337,7 +397,11 @@ main() { exit 1 fi case "$probe_output" in - *NEMOCLAW_DCODE_PROBE:deepagents*NEMOCLAW_DCODE_ONBOARDING:pending*) expect_name_prompt=1 ;; + *NEMOCLAW_DCODE_PROBE:deepagents*NEMOCLAW_DCODE_ONBOARDING:pending*) + fail_test "managed Deep Agents Code first-run onboarding is still pending" + printf '%s\n' "${PREFIX}: $PASSED passed, $FAILED failed" + exit 1 + ;; *NEMOCLAW_DCODE_PROBE:deepagents*NEMOCLAW_DCODE_ONBOARDING:complete*) expect_name_prompt=0 ;; *NEMOCLAW_DCODE_PROBE:other*) info "SKIP: sandbox '${SANDBOX_NAME}' is not a Deep Agents Code sandbox" @@ -358,67 +422,94 @@ main() { local capture_dir raw_capture_file marker_capture_file expect_log_file combined_capture_file plain_capture_file capture_dir="$(make_capture_dir)" - raw_capture_file="${capture_dir}/${PREFIX}.raw.log" - marker_capture_file="${capture_dir}/${PREFIX}.markers.log" - expect_log_file="${capture_dir}/${PREFIX}.expect.log" - combined_capture_file="${capture_dir}/${PREFIX}.combined.log" - plain_capture_file="${capture_dir}/${PREFIX}.sanitized.log" - SENSITIVE_CAPTURE_FILES=( - "$raw_capture_file" - "$marker_capture_file" - "$expect_log_file" - "$combined_capture_file" - ) - : >"$raw_capture_file" - : >"$marker_capture_file" - : >"$expect_log_file" - - info "Running Deep Agents Code TUI startup check in sandbox: $SANDBOX_NAME" - info "Capture directory: $capture_dir" + # The typed target may inherit agent processes from earlier checks, so the + # acceptance contract is no increase from one recorded baseline. The small + # local polling helper is reused for both sessions; separate capture names + # retain per-session evidence instead of overwriting the first failure. + local baseline_output baseline_process_count + if ! baseline_output="$(dcode_process_count)"; then + fail_test "unable to record the baseline DCode/LangGraph process count" + printf '%s\n' "${PREFIX}: $PASSED passed, $FAILED failed" + exit 1 + fi + baseline_process_count="$(sed -n 's/^NEMOCLAW_DCODE_PROCESS_COUNT:\([0-9][0-9]*\)$/\1/p' <<<"$baseline_output" | tail -n1)" + if [[ ! "$baseline_process_count" =~ ^[0-9]+$ ]]; then + fail_test "unable to record the baseline DCode/LangGraph process count" + printf '%s\n' "${PREFIX}: $PASSED passed, $FAILED failed" + exit 1 + fi - local expect_rc - set +e - run_tui_expect "$raw_capture_file" "$marker_capture_file" "$expect_name_prompt" >"$expect_log_file" 2>&1 - expect_rc=$? - set -e + info "Running two Deep Agents Code TUI sessions in sandbox: $SANDBOX_NAME" + info "Capture directory: $capture_dir" - cat "$raw_capture_file" "$expect_log_file" "$marker_capture_file" >"$combined_capture_file" - strip_terminal_control_sequences <"$combined_capture_file" >"$plain_capture_file" - local secret_detected=0 - if contains_secret <"$plain_capture_file"; then - secret_detected=1 - if ! redact_secrets_in_file "$plain_capture_file"; then - : + local session_index suffix expect_rc secret_detected + for session_index in 1 2; do + suffix="" + [ "$session_index" -eq 2 ] && suffix=".repeat" + raw_capture_file="${capture_dir}/${PREFIX}${suffix}.raw.log" + marker_capture_file="${capture_dir}/${PREFIX}${suffix}.markers.log" + expect_log_file="${capture_dir}/${PREFIX}${suffix}.expect.log" + combined_capture_file="${capture_dir}/${PREFIX}${suffix}.combined.log" + plain_capture_file="${capture_dir}/${PREFIX}${suffix}.sanitized.log" + SENSITIVE_CAPTURE_FILES+=( + "$raw_capture_file" + "$marker_capture_file" + "$expect_log_file" + "$combined_capture_file" + ) + : >"$raw_capture_file" + : >"$marker_capture_file" + : >"$expect_log_file" + + set +e + run_tui_expect "$raw_capture_file" "$marker_capture_file" "$expect_name_prompt" >"$expect_log_file" 2>&1 + expect_rc=$? + set -e + + cat "$raw_capture_file" "$expect_log_file" "$marker_capture_file" >"$combined_capture_file" + strip_terminal_control_sequences <"$combined_capture_file" >"$plain_capture_file" + secret_detected=0 + if contains_secret <"$plain_capture_file"; then + secret_detected=1 + if ! redact_secrets_in_file "$plain_capture_file"; then + : + fi + if [ -e "$plain_capture_file" ] && contains_secret <"$plain_capture_file"; then + fail_test "session ${session_index}: secret-shaped value remained after redacting sanitized TUI capture" + fi fi - if [ -e "$plain_capture_file" ] && contains_secret <"$plain_capture_file"; then - fail_test "secret-shaped value remained after redacting sanitized TUI capture" + cleanup_sensitive_captures + + if [ "$expect_rc" -eq 0 ]; then + pass "session ${session_index}: finite expect harness reached startup and observed exit" + else + fail_test "session ${session_index}: finite expect harness exited ${expect_rc}" + print_sanitized_capture_excerpt "$plain_capture_file" fi - fi - cleanup_sensitive_captures - if [ "$expect_rc" -eq 0 ]; then - pass "finite expect harness reached startup and observed exit" - else - fail_test "finite expect harness exited ${expect_rc}" - print_sanitized_capture_excerpt "$plain_capture_file" - fi + if grep -q "NEMOCLAW_TUI_READY" "$plain_capture_file" && is_tui_ready_capture <"$plain_capture_file"; then + pass "session ${session_index}: dcode TUI reached the main composer and opened Select Agent" + else + fail_test "session ${session_index}: dcode TUI Select Agent readiness marker missing from capture" + fi - if grep -q "NEMOCLAW_TUI_READY" "$plain_capture_file" && is_tui_ready_capture <"$plain_capture_file"; then - pass "dcode TUI reached the main composer and opened Select Agent" - else - fail_test "dcode TUI Select Agent readiness marker missing from capture" - fi + assert_clean_exit_code "$plain_capture_file" - assert_clean_exit_code "$plain_capture_file" + if [ "$secret_detected" -eq 1 ]; then + fail_test "session ${session_index}: secret-shaped value found in sanitized TUI capture" + else + pass "session ${session_index}: sanitized TUI capture does not contain secret-shaped values" + fi - if [ "$secret_detected" -eq 1 ]; then - fail_test "secret-shaped value found in sanitized TUI capture" - else - pass "sanitized TUI capture does not contain secret-shaped values" - fi + if wait_for_dcode_process_baseline "$baseline_process_count"; then + pass "session ${session_index}: DCode/LangGraph process count returned to baseline" + else + fail_test "session ${session_index}: DCode/LangGraph process count remained above baseline after ${PROCESS_CLEANUP_TIMEOUT}s" + fi + info "sanitized capture: ${plain_capture_file}" + done printf '%s\n' "${PREFIX}: $PASSED passed, $FAILED failed" - info "sanitized capture: ${plain_capture_file}" [ "$FAILED" -eq 0 ] || exit 1 } diff --git a/test/langchain-deepagents-code-config.test.ts b/test/langchain-deepagents-code-config.test.ts index d9fe7f503f1..a9bedcf9982 100644 --- a/test/langchain-deepagents-code-config.test.ts +++ b/test/langchain-deepagents-code-config.test.ts @@ -73,6 +73,8 @@ describe("LangChain Deep Agents Code config generator", () => { expect(config).not.toContain("force_nonempty_content"); expect(config).toContain("check = false"); expect(config).toContain("auto_update = false"); + expect(config).toContain("[warnings]"); + expect(config).toContain('suppress = ["tavily"]'); expect(config).not.toMatch(/NVIDIA_API_KEY|OPENAI_API_KEY=|sk-/); }); diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index 8ffecdd3a4d..bd7efbb4706 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -248,6 +248,7 @@ describe("LangChain Deep Agents Code image contracts", () => { } for (const s of [ "managed-dcode-runtime.py", + "dcode-session-supervisor.py", "nemoclaw_observability.py", "patch-managed-deepagents-code.py", "validate-nemotron-ultra-profile.py", @@ -258,6 +259,8 @@ describe("LangChain Deep Agents Code image contracts", () => { "install -m 0755 /usr/local/lib/nemoclaw/dcode-launcher.sh /usr/local/bin/dcode.real", "install -m 0755 /usr/local/lib/nemoclaw/dcode-launcher.sh /usr/local/bin/deepagents-code", "install -o root -g root -m 0755 /usr/local/lib/nemoclaw/dcode-launcher.sh /usr/local/lib/nemoclaw/dcode-managed-exec", + "COPY agents/langchain-deepagents-code/dcode-session-supervisor.py /usr/local/lib/nemoclaw/dcode-session-supervisor.py", + `test "$(stat -c '%u:%g:%a' /usr/local/lib/nemoclaw/dcode-session-supervisor.py)" = "0:0:755"`, "test -f /usr/local/lib/nemoclaw/dcode-managed-exec", "test ! -L /usr/local/lib/nemoclaw/dcode-managed-exec", `test "$(stat -c '%u:%g:%a' /usr/local/lib/nemoclaw/dcode-managed-exec)" = "0:0:755"`, @@ -305,7 +308,15 @@ describe("LangChain Deep Agents Code image contracts", () => { expect(dockerfile).toContain("ARG NEMOCLAW_TOOL_DISCLOSURE=progressive"); expect(dockerfile).toContain("NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE}"); expect(dockerfile).toContain("progressive|direct)"); - expect(launcher).toContain('exec "$MANAGED_DCODE_WRAPPER" "$@"'); + expect(launcher).toContain( + 'exec /opt/venv/bin/python3 -I "$MANAGED_SESSION_SUPERVISOR" "$MANAGED_DCODE_WRAPPER" "$@"', + ); + expect(launcher).toContain( + 'readonly MANAGED_SESSION_SUPERVISOR="/usr/local/lib/nemoclaw/dcode-session-supervisor.py"', + ); + expect(launcher).toContain( + 'status | whoami | identity | --version | -v | -V) exec "$MANAGED_DCODE_WRAPPER" "$@"', + ); expect(launcher).toContain("harden_resource_limits"); expect(launcher).toContain("refusing to launch dcode unhardened"); expect(policy).not.toContain("/usr/local/bin/dcode.real"); @@ -375,6 +386,15 @@ describe("LangChain Deep Agents Code image contracts", () => { expect(pathContractFiles).not.toContain('PATH="/usr/local/bin:${PATH}"'); }); + it("preseeds managed first-run state and a usable ripgrep binary (#6678)", () => { + const baseDockerfile = readAgentFile("Dockerfile.base"); + + expect(baseDockerfile).toContain("ripgrep=14.1.1-1+b4"); + expect(baseDockerfile).toContain( + "printf '1\\n' > /sandbox/.deepagents/.state/onboarding_complete", + ); + }); + it("keeps optional service egress out of the default policy and requires Landlock", () => { const policy = readAgentFile("policy-additions.yaml"); expect(policy).not.toContain("api.tavily.com"); @@ -571,7 +591,9 @@ describe("LangChain Deep Agents Code image contracts", () => { "redact_secrets_in_file", "trap cleanup_sensitive_captures EXIT", "cleanup_sensitive_captures", - "${PREFIX}.sanitized.log", + "${PREFIX}${suffix}.sanitized.log", + "for session_index in 1 2", + 'wait_for_dcode_process_baseline "$baseline_process_count"', "secret-shaped value found in sanitized TUI capture", "nvapi-", "sk-", diff --git a/test/langchain-deepagents-code-proxy-launcher.test.ts b/test/langchain-deepagents-code-proxy-launcher.test.ts index b74bea1924c..e97066b0650 100644 --- a/test/langchain-deepagents-code-proxy-launcher.test.ts +++ b/test/langchain-deepagents-code-proxy-launcher.test.ts @@ -56,6 +56,10 @@ function replaceManagedProxyFileConstants(source: string, tempDir: string): stri "utf8", ); return source + .replace( + 'exec /opt/venv/bin/python3 -I "$MANAGED_SESSION_SUPERVISOR" "$MANAGED_DCODE_WRAPPER" "$@"', + 'exec "$MANAGED_DCODE_WRAPPER" "$@"', + ) .replace("/usr/local/lib/nemoclaw/sandbox-rlimits.sh", rlimitLib) .replace( 'readonly MANAGED_PROXY_HOST_FILE="/usr/local/share/nemoclaw/dcode-proxy-host"', @@ -146,6 +150,30 @@ function shellValidatorAccepts(source: string, name: string, value: string): boo } describe("Deep Agents Code direct-exec proxy launcher", () => { + it("keeps read-only identity commands outside the session supervisor", () => { + const launcher = readAgentFile("dcode-launcher.sh"); + const directIdentity = + 'status | whoami | identity | --version | -v | -V) exec "$MANAGED_DCODE_WRAPPER" "$@"'; + const supervisedSession = + 'exec /opt/venv/bin/python3 -I "$MANAGED_SESSION_SUPERVISOR" "$MANAGED_DCODE_WRAPPER" "$@"'; + + expect(launcher).toContain(directIdentity); + expect(launcher.indexOf(directIdentity)).toBeLessThan(launcher.indexOf(supervisedSession)); + }); + + it("keeps one-shot non-interactive sessions outside the interactive supervisor", () => { + const launcher = readAgentFile("dcode-launcher.sh"); + const nonInteractiveBypass = + '-n | -n?* | --non-interactive | --non-interactive=*) exec "$MANAGED_DCODE_WRAPPER" "$@" ;;'; + const supervisedSession = + 'exec /opt/venv/bin/python3 -I "$MANAGED_SESSION_SUPERVISOR" "$MANAGED_DCODE_WRAPPER" "$@"'; + + expect(launcher).toContain(nonInteractiveBypass); + expect(launcher.indexOf(nonInteractiveBypass)).toBeLessThan( + launcher.indexOf(supervisedSession), + ); + }); + it("preserves the empty-prompt failure through the installed launcher chain (#6440)", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-empty-prompt-")); try {