Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion agents/langchain-deepagents-code/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions agents/langchain-deepagents-code/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/lib/agent/defs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
3 changes: 3 additions & 0 deletions src/lib/agent/onboard-terminal-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
7 changes: 7 additions & 0 deletions test/cli/connect-terminal-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
# Headless `dcode -n "<prompt>"`, 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
3 changes: 3 additions & 0 deletions test/langchain-deepagents-code-image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions test/langchain-deepagents-code-proxy-launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading