diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index 7e5890813fd..6750a71832a 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -74,13 +74,20 @@ RUN case "$NEMOCLAW_TOOL_DISCLOSURE" in \ # The launcher and startup script read these root-owned files instead of # trusting process-level environment overrides for inference routing. Invoking -# each launcher validates the build args before the image can complete. +# each launcher validates the build args before the image can complete. The +# empty-prompt probe also exercises the installed launcher -> wrapper chain so a +# stale or misassembled image cannot silently lose the public exit-code contract. 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 \ + && 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 \ + && test "$(cat "$empty_prompt_log")" = "NemoClaw: empty non-interactive prompt for -n; provide prompt text." \ + && rm -f "$empty_prompt_log" \ && /usr/local/bin/dcode --version \ && /usr/local/bin/dcode.real --version \ && /usr/local/bin/deepagents-code --version diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index 90bfe3fb96a..b7914991caf 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -27,6 +27,7 @@ runtime: smoke_commands: - "dcode --version" - "test -s /sandbox/.deepagents/config.toml && echo NEMOCLAW_DEEPAGENTS_CONFIG_OK" + - 'empty_prompt=; output="$(timeout 10 dcode -n "$empty_prompt" 2>&1)"; status=$?; [ "$status" -eq 2 ] && [ "$output" = "NemoClaw: empty non-interactive prompt for -n; provide prompt text." ] && echo NEMOCLAW_DCODE_EMPTY_PROMPT_OK' # ── Configuration ─────────────────────────────────────────────── config: diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index a9324b92266..52fc541c978 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -111,6 +111,7 @@ describe("agent definitions", () => { smoke_commands: [ "dcode --version", "test -s /sandbox/.deepagents/config.toml && echo NEMOCLAW_DEEPAGENTS_CONFIG_OK", + 'empty_prompt=; output="$(timeout 10 dcode -n "$empty_prompt" 2>&1)"; status=$?; [ "$status" -eq 2 ] && [ "$output" = "NemoClaw: empty non-interactive prompt for -n; provide prompt text." ] && echo NEMOCLAW_DCODE_EMPTY_PROMPT_OK', ], }); expect(deepAgentsCode.binary_path).toBe("/usr/local/bin/dcode"); diff --git a/src/lib/agent/onboard-terminal-fixtures.ts b/src/lib/agent/onboard-terminal-fixtures.ts index 28db43ae7fa..200131d3b2b 100644 --- a/src/lib/agent/onboard-terminal-fixtures.ts +++ b/src/lib/agent/onboard-terminal-fixtures.ts @@ -24,6 +24,9 @@ function recordDeepAgentsRuntimeCall( if (command.includes("dcode --version")) { return `dcode ${smokeVersion}\nNEMOCLAW_AGENT_SMOKE_EXIT:0`; } + if (command.includes("NEMOCLAW_DCODE_EMPTY_PROMPT_OK")) { + return "NEMOCLAW_DCODE_EMPTY_PROMPT_OK\nNEMOCLAW_AGENT_SMOKE_EXIT:0"; + } if (command.includes("/sandbox/.deepagents/config.toml")) { return "NEMOCLAW_DEEPAGENTS_CONFIG_OK\nNEMOCLAW_AGENT_SMOKE_EXIT:0"; } diff --git a/test/cli/connect-terminal-agent.test.ts b/test/cli/connect-terminal-agent.test.ts index 88b8d16d46d..1e6db629dc2 100644 --- a/test/cli/connect-terminal-agent.test.ts +++ b/test/cli/connect-terminal-agent.test.ts @@ -39,6 +39,7 @@ describe("CLI dispatch for terminal agents", () => { ' case "$cmd" in', ' *"dcode --version"*) echo "dcode 0.1.34"; echo "NEMOCLAW_AGENT_SMOKE_EXIT:0"; exit 0 ;;', ' *"config.toml"*) echo "NEMOCLAW_DEEPAGENTS_CONFIG_OK"; echo "NEMOCLAW_AGENT_SMOKE_EXIT:0"; exit 0 ;;', + ' *"NEMOCLAW_DCODE_EMPTY_PROMPT_OK"*) echo "NEMOCLAW_DCODE_EMPTY_PROMPT_OK"; echo "NEMOCLAW_AGENT_SMOKE_EXIT:0"; exit 0 ;;', " esac", "fi", "exit 0", @@ -62,6 +63,12 @@ describe("CLI dispatch for terminal agents", () => { call.includes("nemoclaw-agent-smoke test -s /sandbox/.deepagents/config.toml"), ), ).toBe(true); + expect( + calls.some( + (call) => + call.includes("nemoclaw-agent-smoke") && call.includes("NEMOCLAW_DCODE_EMPTY_PROMPT_OK"), + ), + ).toBe(true); expect(calls.some((call) => call.includes("OPENCLAW="))).toBe(false); expect(calls.some((call) => call.includes("curl -so"))).toBe(false); }); 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 bfb565b76fc..ed17fc09266 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 @@ -7,9 +7,10 @@ # Headless `dcode -n ""`, run inside a built Deep Agents Code sandbox, # must route through the managed https://inference.local/v1 endpoint using the # placeholder OpenAI-compatible key NemoClaw writes into config.toml. The login -# shell path must return PONG with exit 0; provider, connection, DNS, timeout, and -# ambiguous failures are not acceptable. No real provider/proxy credentials may -# appear in config.toml, .env, .mcp.json, /tmp/nemoclaw-proxy-env.sh, or output. +# shell path must reject an empty prompt with exit 2, then return PONG with exit +# 0 for a real prompt; provider, connection, DNS, timeout, and ambiguous failures +# are not acceptable. No real provider/proxy credentials may appear in +# config.toml, .env, .mcp.json, /tmp/nemoclaw-proxy-env.sh, or output. # Direct DNS/hosts resolution is intentionally not required: OpenShell's managed # proxy routes inference.local when the request follows the normalized path. # Keep these phases in one ordered acceptance check: the absent-DNS observation @@ -126,6 +127,10 @@ is_actionable_inference_error() { grep -Eiq 'API key|authentication|authorization|unauthorized|forbidden|rate[ -]?limit|quota|HTTP[[:space:]]*(401|403|404|429|5[0-9]{2})|status[[:space:]]*(401|403|404|429|5[0-9]{2})|(inference\.local|provider|model|NVIDIA|OpenAI).*(error|failed|failure|invalid|unavailable)|(error|failed|failure|invalid|unavailable).*(inference\.local|provider|model|NVIDIA|OpenAI)' } +is_empty_prompt_rejection() { + grep -Fxq 'NemoClaw: empty non-interactive prompt for -n; provide prompt text.' +} + # Route reachability is proved separately with /v1/models. This classifier has # the stronger #6191 acceptance contract: dcode itself must be usable and return # exit-zero PONG, so authentication, quota, provider, and model errors are @@ -201,6 +206,27 @@ main() { fail_test "managed dcode wrapper chain is missing or incomplete" fi + # The status expansion belongs to the remote login shell. + # shellcheck disable=SC2016 + empty_login_output="$(sandbox_login_exec 'timeout 10 dcode -n ""; status=$?; printf "\nNEMOCLAW_DCODE_EMPTY_EXIT:%s\n" "$status"' || true)" + empty_login_exit="$(printf '%s' "$empty_login_output" | sed -n 's/.*NEMOCLAW_DCODE_EMPTY_EXIT:\([0-9]\+\).*/\1/p' | tail -n1)" + if [ "$empty_login_exit" = "2" ] && printf '%s\n' "$empty_login_output" | is_empty_prompt_rejection; then + pass "login-shell dcode rejects an empty non-interactive prompt with exit 2" + else + fail_test "login-shell dcode did not reject an empty non-interactive prompt with exit 2 (exit ${empty_login_exit:-unknown})" + fi + + if empty_direct_output="$(sandbox_direct_dcode -n "")"; then + empty_direct_exit=0 + else + empty_direct_exit=$? + fi + if [ "$empty_direct_exit" = "2" ] && printf '%s\n' "$empty_direct_output" | is_empty_prompt_rejection; then + pass "direct-exec dcode rejects an empty non-interactive prompt with exit 2" + else + fail_test "direct-exec dcode did not reject an empty non-interactive prompt with exit 2 (exit ${empty_direct_exit})" + fi + # 1. config.toml points at the managed inference route, not a real provider host. config_output="$(sandbox_exec "cat /sandbox/.deepagents/config.toml 2>/dev/null" || true)" if printf '%s' "$config_output" | references_managed_inference_route; then @@ -289,6 +315,8 @@ DCODE_EXIT:${direct_exit}" leak_scan="$(sandbox_exec "$(sandbox_artifact_scan_command)" || true)" combined="${config_output} ${leak_scan} +${empty_login_output} +${empty_direct_output} ${dns_hosts_output} ${proxy_contract_output} ${route_output} diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index 2b237f38508..5808b13e55c 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -702,6 +702,9 @@ describe("LangChain Deep Agents Code image contracts", () => { '-- dcode "$@"', "sandbox_dcode_wrapper_contract", "NEMOCLAW_DCODE_WRAPPER_CHAIN_OK", + "NEMOCLAW_DCODE_EMPTY_EXIT", + "login-shell dcode rejects an empty non-interactive prompt with exit 2", + "direct-exec dcode rejects an empty non-interactive prompt with exit 2", "nemoclaw_connect_probe", "${NEMOCLAW_CLI_BIN:-${REPO:-.}/bin/nemoclaw.js}", "connect --probe-only 2>&1", diff --git a/test/langchain-deepagents-code-proxy-launcher.test.ts b/test/langchain-deepagents-code-proxy-launcher.test.ts index 7b82f479506..b38c806ad0d 100644 --- a/test/langchain-deepagents-code-proxy-launcher.test.ts +++ b/test/langchain-deepagents-code-proxy-launcher.test.ts @@ -134,6 +134,34 @@ function shellValidatorAccepts(source: string, name: string, value: string): boo } describe("Deep Agents Code direct-exec proxy launcher", () => { + 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 { + const launcherPath = path.join(tempDir, "dcode-launcher.sh"); + const wrapperPath = path.join(tempDir, "dcode-wrapper.sh"); + const launcher = replaceManagedProxyFileConstants( + readAgentFile("dcode-launcher.sh").replace( + 'readonly MANAGED_DCODE_WRAPPER="/usr/local/lib/nemoclaw/dcode-wrapper.sh"', + `readonly MANAGED_DCODE_WRAPPER="${wrapperPath}"`, + ), + tempDir, + ); + fs.writeFileSync(launcherPath, launcher, { mode: 0o755 }); + fs.writeFileSync(wrapperPath, readAgentFile("dcode-wrapper.sh"), { mode: 0o755 }); + writeManagedProxyFiles(tempDir, DEFAULT_MANAGED_PROXY); + + const result = runLauncher(launcherPath, ["-n", ""], {}); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + "NemoClaw: empty non-interactive prompt for -n; provide prompt text.\n", + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("ignores hostile PATH and BASH_ENV before launcher and entrypoint normalization", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-shell-entry-")); const launcherPath = makeLauncherProxyProbeFixture(tempDir);