diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index fd72bb1bf2d..5322b77f188 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -111,8 +111,8 @@ jobs: CANDIDATE_SHA: ${{ github.event.pull_request.head.sha }} # Retains the reviewed discovery-permission repair and the current # managed-image security inventory. The previous staging source pinned - # Vim 9.2.0782, which cannot satisfy the candidate's 9.2.0858 contract. - STAGING_QA_SOURCE_SHA: af2a73f0d6ce8f08a2975560f376470387c535d0 + # libssh2 nemoclaw1, which cannot satisfy the candidate's nemoclaw2 contract. + STAGING_QA_SOURCE_SHA: ce96811ddb418ad01c040521a1fe912b5bcb405e STAGING_QA_BASE_IMAGE: nemoclaw-deepagents-code-base:staging-31396519688 STAGING_QA_FINAL_IMAGE: nemoclaw-managed-pr/langchain-deepagents-code-staging-qa steps: diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index b7ddc58347c..464063da726 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -193,6 +193,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-login-profile.sh /usr/local/lib/nemoclaw/dcode-login-profile.sh COPY agents/langchain-deepagents-code/dcode-session-supervisor.py /usr/local/lib/nemoclaw/dcode-session-supervisor.py COPY scripts/lib/entrypoint-env-wrapper.sh /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh COPY agents/langchain-deepagents-code/start.sh /usr/local/bin/nemoclaw-start @@ -217,7 +218,7 @@ RUN test -f /usr/local/bin/nemoclaw-managed-bootstrap \ && test -f /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh \ && test ! -L /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh \ && test "$(stat -c '%u:%g:%a' /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh)" = '0:0:444' \ - && 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 /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh \ + && 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 /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh /usr/local/lib/nemoclaw/dcode-login-profile.sh \ && chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-managed-startup-hold /usr/local/bin/nemoclaw-managed-bootstrap /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 \ @@ -350,7 +351,13 @@ RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \ && chmod 660 /sandbox/.deepagents/config.toml USER root -RUN chown root:root /sandbox/.nemoclaw \ +RUN chown root:sandbox /sandbox \ + && chmod 1775 /sandbox \ + && install -o root -g root -m 0444 /usr/local/lib/nemoclaw/dcode-login-profile.sh /sandbox/.bash_profile \ + && test "$(stat -c '%U:%G:%a' /sandbox)" = 'root:sandbox:1775' \ + && test "$(stat -c '%U:%G:%a' /sandbox/.bash_profile)" = 'root:root:444' \ + && cmp -s /usr/local/lib/nemoclaw/dcode-login-profile.sh /sandbox/.bash_profile \ + && chown root:root /sandbox/.nemoclaw \ && chmod 1755 /sandbox/.nemoclaw \ && chown -R root:root /sandbox/.nemoclaw/blueprints \ && chmod -R 755 /sandbox/.nemoclaw/blueprints \ @@ -425,7 +432,7 @@ RUN set -eu; \ test -z "$(dpkg --audit)" # End completed-image security package verification. -ARG NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=sandbox +ARG NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root RUN case "$NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER" in \ root|sandbox) ;; \ *) echo "ERROR: NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER must be root or sandbox" >&2; exit 1 ;; \ diff --git a/agents/langchain-deepagents-code/dcode-login-profile.sh b/agents/langchain-deepagents-code/dcode-login-profile.sh new file mode 100644 index 00000000000..69f0bc53613 --- /dev/null +++ b/agents/langchain-deepagents-code/dcode-login-profile.sh @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# shellcheck shell=bash + +# OpenShell starts command-bearing sandbox sessions with `bash -lc` and sets +# HOME to the writable workspace before Bash reads its first login file. Keep +# this first-match profile root-owned so sandbox code cannot run before a +# NemoClaw-managed DCode probe. Ordinary login commands retain the established +# runtime environment; the managed launcher rebuilds that environment from +# image-owned inputs and must not source the sandbox-user-owned convenience +# file first. +unset BASH_ENV ENV +case "${BASH_EXECUTION_STRING:-}" in + *"/usr/local/lib/nemoclaw/dcode-managed-exec"*) ;; + *) + [ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh + export HOME=/sandbox + export PATH="/usr/local/bin:/opt/venv/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin" + ;; +esac diff --git a/agents/langchain-deepagents-code/start.sh b/agents/langchain-deepagents-code/start.sh index 6dfac535c4f..aa4b368a97d 100755 --- a/agents/langchain-deepagents-code/start.sh +++ b/agents/langchain-deepagents-code/start.sh @@ -10,6 +10,41 @@ unset BASH_ENV ENV export HOME=/sandbox export PATH="/usr/local/bin:/opt/venv/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin" +readonly NEMOCLAW_DCODE_LOGIN_PROFILE_SOURCE="/usr/local/lib/nemoclaw/dcode-login-profile.sh" + +verify_dcode_login_profile() { + [ -d /sandbox ] \ + && [ ! -L /sandbox ] \ + && [ -f "$NEMOCLAW_DCODE_LOGIN_PROFILE_SOURCE" ] \ + && [ ! -L "$NEMOCLAW_DCODE_LOGIN_PROFILE_SOURCE" ] \ + && [ "$(stat -c '%U:%G:%a' "$NEMOCLAW_DCODE_LOGIN_PROFILE_SOURCE" 2>/dev/null || true)" = "root:root:444" ] \ + && [ ! -L /sandbox/.bash_profile ] \ + && [ "$(stat -c '%U:%G:%a' /sandbox 2>/dev/null || true)" = "root:sandbox:1775" ] \ + && [ "$(stat -c '%U:%G:%a' /sandbox/.bash_profile 2>/dev/null || true)" = "root:root:444" ] \ + && cmp -s "$NEMOCLAW_DCODE_LOGIN_PROFILE_SOURCE" /sandbox/.bash_profile +} + +protect_dcode_login_profile() { + local source_metadata + source_metadata="$(stat -c '%U:%G:%a' "$NEMOCLAW_DCODE_LOGIN_PROFILE_SOURCE" 2>/dev/null || true)" + if [ ! -f "$NEMOCLAW_DCODE_LOGIN_PROFILE_SOURCE" ] \ + || [ -L "$NEMOCLAW_DCODE_LOGIN_PROFILE_SOURCE" ] \ + || [ "$source_metadata" != "root:root:444" ]; then + printf '%s\n' '[SECURITY] Managed DCode login profile is missing or unsafe.' >&2 + exit 1 + fi + + chown root:sandbox /sandbox + chmod 1775 /sandbox + rm -f -- /sandbox/.bash_profile + install -o root -g root -m 0444 \ + "$NEMOCLAW_DCODE_LOGIN_PROFILE_SOURCE" /sandbox/.bash_profile + if ! verify_dcode_login_profile; then + printf '%s\n' '[SECURITY] Could not protect the managed DCode login profile.' >&2 + exit 1 + fi +} + # managed-entrypoint-env-wrapper begin _NEMOCLAW_ENTRYPOINT_ENV_WRAPPER="/usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh" if [ ! -f "$_NEMOCLAW_ENTRYPOINT_ENV_WRAPPER" ]; then @@ -34,13 +69,19 @@ unset NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGC NEMOCLAW_ENTRYPOINT_NORMALIZED_ARGV \ unset -f nemoclaw_normalize_entrypoint_env_wrapper # managed-entrypoint-env-wrapper end -# The published managed image uses uid 0 as its OCI entry user so it can accept -# a future profile. Without one, drop immediately and execute the byte-for-byte -# legacy sandbox-user path. Ordinary non-managed builds still start as sandbox. +# The published managed image uses uid 0 as its OCI entry user so every start +# can repair the protected login-profile boundary before immediately dropping +# to the legacy sandbox-user path. A sandbox-user image still verifies the +# image-baked boundary before continuing. if [ "$(id -u)" -eq 0 ]; then + protect_dcode_login_profile exec /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ /usr/local/bin/nemoclaw-start "$@" fi +if ! verify_dcode_login_profile; then + printf '%s\n' '[SECURITY] DCode login profile is not protected; rebuild this sandbox.' >&2 + exit 1 +fi while IFS= read -r _nemoclaw_auto_approval_env; do unset "$_nemoclaw_auto_approval_env" diff --git a/docs/manage-sandboxes/run-deep-agents-code.mdx b/docs/manage-sandboxes/run-deep-agents-code.mdx index 30b4ef399e7..a3dacb21077 100644 --- a/docs/manage-sandboxes/run-deep-agents-code.mdx +++ b/docs/manage-sandboxes/run-deep-agents-code.mdx @@ -144,6 +144,23 @@ Stdio commands, extra headers, raw credentials, and unrelated top-level configur For authenticated MCP setup and credential rotation, refer to [Add an MCP Server](../mcp-servers/add-an-mcp-server) and [Manage MCP Servers](../mcp-servers/manage-mcp-servers). This isolated-mode guarantee applies to the managed launchers, not arbitrary Python commands in the sandbox. +### Protect the Managed Login Profile + +Managed Deep Agents Code images reserve `/sandbox/.bash_profile` as the first Bash login profile for OpenShell command sessions. +The file is `root:root` mode `0444`, and `/sandbox` is `root:sandbox` mode `1775`. +The sticky directory keeps normal workspace writes available while preventing the `sandbox` user from deleting or replacing the root-owned profile. + +At each container start, the root entrypoint restores and verifies the profile before it changes to the `sandbox` user. +If a sandbox-user start cannot verify the profile, it stops and tells you to rebuild the sandbox. + +For NemoClaw-managed route and terminal probes, the profile clears `BASH_ENV` and `ENV` and skips `/tmp/nemoclaw-proxy-env.sh`. +This prevents sandbox startup code from running before the managed probe. +Ordinary login commands continue to load the credential-free runtime environment, and interactive `.bashrc` behavior does not change. +Do not edit or replace `/sandbox/.bash_profile`. + +Existing Deep Agents Code sandboxes retain their previous image until you rebuild them. +After you update NemoClaw, finish active tasks and follow [Recover and Rebuild Sandboxes](recover-and-rebuild-sandboxes) to replace each image. + ## Choose an Approval Boundary Interactive shell execution and other destructive tools remain behind human-in-the-loop approval prompts by default. diff --git a/src/lib/actions/sandbox/connect-inference-route-probe.test.ts b/src/lib/actions/sandbox/connect-inference-route-probe.test.ts index 208edaf2a27..0e3e7e45703 100644 --- a/src/lib/actions/sandbox/connect-inference-route-probe.test.ts +++ b/src/lib/actions/sandbox/connect-inference-route-probe.test.ts @@ -18,7 +18,7 @@ import { } from "./connect-inference-route-probe"; describe("sandbox connect inference route probe argv", () => { - it("uses the managed DCode proxy boundary without a login shell (#6191)", () => { + it("uses the managed DCode proxy boundary without adding a login shell (#6191)", () => { const args = buildSandboxInferenceRouteProbeArgs("deep-code", { name: "langchain-deepagents-code", }); @@ -99,7 +99,7 @@ describe("sandbox connect inference route probe argv", () => { it.each([ "OK 200", "BROKEN 503", - ])("does not run hostile DCode startup or curl config for a %s spoof (#6192)", (spoof) => { + ])("managed launcher does not run hostile DCode startup or curl config for a %s spoof (#6192)", (spoof) => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-probe-")); const profileMarker = path.join(home, "profile-ran"); try { diff --git a/src/lib/actions/sandbox/connect-inference-route-probe.ts b/src/lib/actions/sandbox/connect-inference-route-probe.ts index 29e97d7da17..909c8806c3e 100644 --- a/src/lib/actions/sandbox/connect-inference-route-probe.ts +++ b/src/lib/actions/sandbox/connect-inference-route-probe.ts @@ -38,16 +38,18 @@ export const INFERENCE_ROUTE_PROBE_SCRIPT = [ INFERENCE_ROUTE_CA_VALIDATION, INFERENCE_ROUTE_PROBE_CORE_SCRIPT, ].join("; "); -// Invalid state: a DCode login shell runs sandbox-user startup files before the -// probe, so every inherited output descriptor is attacker-writable evidence. -// Source boundary: the image-baked launcher reconstructs the managed proxy from -// root-owned, mode-0444 files and execs a command without loading user profiles. -// Source-fix constraint: raw OpenShell exec does not inherit the entrypoint's -// trusted proxy contract, while a login shell cannot provide an output trust -// boundary. Regression: hostile-profile tests assert that no startup file or -// inherited descriptor can emit probe evidence. Removal condition: use a raw -// probe only when OpenShell provides the same trusted proxy environment to every -// sandbox exec process without shell startup. +// Invalid state: OpenShell starts sandbox exec through a login shell before the +// requested command (#8624; OpenShell#2668). Rebuilt DCode images reserve that +// shell's first-match profile as a root-owned file which skips sandbox startup +// state for the image-baked launcher. Older images can still emit output and +// create side effects before this probe begins. The launcher reconstructs the +// managed proxy from root-owned, mode-0444 files without adding another +// profile-sourcing shell, and the parser rejects inherited stderr or extra +// stdout so startup output cannot become accepted probe evidence. Regression: +// protected- and hostile-profile tests cover both image generations plus +// inherited descriptors. Removal condition: use a raw probe only when OpenShell +// provides both a non-login exec path and the trusted proxy environment to every +// sandbox exec process. // This separate regular-file install is intentionally absent from older images: // a newer CLI probing one fails before the stateful entrypoint or dcode wrapper // can run, so version skew cannot mutate observability state. @@ -97,9 +99,9 @@ export function buildSandboxInferenceRouteProbeArgs( "--env", "ENV=", "--", - // The trusted launcher ignores ambient proxy overrides and does not - // source sandbox-user startup files or rewrite persistent runtime - // state before executing this probe. + // The trusted launcher ignores ambient proxy overrides and does not add + // another startup-file read or rewrite persistent runtime state. The + // OpenShell transport-level login shell remains tracked in OpenShell#2668. DCODE_MANAGED_EXEC_LAUNCHER, "/bin/sh", "-c", diff --git a/src/lib/actions/sandbox/terminal-connect-probe.test.ts b/src/lib/actions/sandbox/terminal-connect-probe.test.ts index 694fb5820f3..f9400ea6fe1 100644 --- a/src/lib/actions/sandbox/terminal-connect-probe.test.ts +++ b/src/lib/actions/sandbox/terminal-connect-probe.test.ts @@ -87,7 +87,10 @@ describe("terminal-agent connect inference route", () => { }); it("lets dcode continue to terminal smoke checks when its route probe is inconclusive (#6191)", () => { - const capture = vi.fn(() => "dcode 0.1.12\nNEMOCLAW_AGENT_SMOKE_EXIT:0\n"); + const capture = vi.fn(() => ({ + status: 0, + output: "NEMOCLAW_AGENT_SMOKE_BEGIN\ndcode 0.1.12\nNEMOCLAW_AGENT_SMOKE_EXIT:0\n", + })); const ensureInferenceRoute = vi.fn(() => ({ routeHealthy: null })); expect(() => @@ -107,4 +110,31 @@ describe("terminal-agent connect inference route", () => { " Probe complete: LangChain Deep Agents Code terminal smoke checks passed (dcode).", ); }); + + it("fails dcode connect when a hostile profile forges markers before a nonzero exit (#8624)", () => { + const capture = vi.fn(() => ({ + status: 97, + output: "NEMOCLAW_AGENT_SMOKE_BEGIN\nNEMOCLAW_AGENT_SMOKE_EXIT:0\n", + })); + const ensureInferenceRoute = vi.fn(() => ({ routeHealthy: true })); + + expect(() => + runTerminalAgentConnectProbe({ + agent: dcodeAgent, + agentName: "LangChain Deep Agents Code", + capture: capture as never, + ensureInferenceRoute, + sandboxName: "deep-code", + }), + ).toThrow("process.exit(1)"); + + expect(capture).toHaveBeenCalledOnce(); + expect(errorSpy).toHaveBeenCalledWith( + " Probe failed: LangChain Deep Agents Code terminal smoke command failed: dcode --version", + ); + expect(logSpy).not.toHaveBeenCalledWith( + expect.stringContaining("terminal smoke checks passed"), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); }); diff --git a/src/lib/agent/onboard-terminal-fixtures.test.ts b/src/lib/agent/onboard-terminal-fixtures.test.ts index 4153ee9f0af..70d7800a4ca 100644 --- a/src/lib/agent/onboard-terminal-fixtures.test.ts +++ b/src/lib/agent/onboard-terminal-fixtures.test.ts @@ -152,7 +152,9 @@ describe("Deep Agents Code terminal onboard fixtures", () => { calls, ); - expect(output).toBe("NEMOCLAW_DEEPAGENTS_CONFIG_OK\nNEMOCLAW_AGENT_SMOKE_EXIT:0"); + expect(output).toBe( + "NEMOCLAW_AGENT_SMOKE_BEGIN\nNEMOCLAW_DEEPAGENTS_CONFIG_OK\nNEMOCLAW_AGENT_SMOKE_EXIT:0", + ); }); it("can model a nonzero terminal smoke command", () => { diff --git a/src/lib/agent/onboard-terminal-fixtures.ts b/src/lib/agent/onboard-terminal-fixtures.ts index 200131d3b2b..5a5115f0ebf 100644 --- a/src/lib/agent/onboard-terminal-fixtures.ts +++ b/src/lib/agent/onboard-terminal-fixtures.ts @@ -22,13 +22,13 @@ function recordDeepAgentsRuntimeCall( return probeOutput; } if (command.includes("dcode --version")) { - return `dcode ${smokeVersion}\nNEMOCLAW_AGENT_SMOKE_EXIT:0`; + return `NEMOCLAW_AGENT_SMOKE_BEGIN\ndcode ${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"; + return "NEMOCLAW_AGENT_SMOKE_BEGIN\nNEMOCLAW_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"; + return "NEMOCLAW_AGENT_SMOKE_BEGIN\nNEMOCLAW_DEEPAGENTS_CONFIG_OK\nNEMOCLAW_AGENT_SMOKE_EXIT:0"; } return ""; } @@ -62,5 +62,5 @@ export function recordFailingDeepAgentsSmokeCall(args: string[]): string { const command = args.slice(args.indexOf("--") + 1).at(-1) ?? ""; return command.includes("NEMOCLAW_AGENT_BINARY_CHECK") ? "NEMOCLAW_AGENT_BINARY_CHECK:ok" - : "dcode provider route failed\nNEMOCLAW_AGENT_SMOKE_EXIT:42"; + : "NEMOCLAW_AGENT_SMOKE_BEGIN\ndcode provider route failed\nNEMOCLAW_AGENT_SMOKE_EXIT:42"; } diff --git a/src/lib/agent/onboard-terminal.test.ts b/src/lib/agent/onboard-terminal.test.ts index 7617dc574c5..50ea79cac25 100644 --- a/src/lib/agent/onboard-terminal.test.ts +++ b/src/lib/agent/onboard-terminal.test.ts @@ -22,10 +22,15 @@ function makeDeepAgentsCodeAgent(): AgentDefinition { function createAgentSetupContext( runCaptureOpenshell: RunCaptureOpenshell = vi.fn((_args: string[]) => ""), + captureOpenshell: NonNullable = vi.fn((args, opts) => ({ + status: 0, + output: runCaptureOpenshell(args, opts) ?? "", + })), ) { return { step: vi.fn((_current: number, _total: number, _message: string) => undefined), runCaptureOpenshell, + captureOpenshell, openshellShellCommand: vi.fn(() => "openshell sandbox connect deepagents-code"), openshellBinary: "/usr/bin/openshell", startRecordedStep: vi.fn(async (_stepName: string, _updates: Record) => { @@ -306,4 +311,35 @@ describe("Deep Agents Code terminal onboard acceptance", () => { "NEMOCLAW_AGENT_SMOKE_EXIT:42", ); }); + + it("rejects forged onboarding smoke markers when OpenShell exits nonzero (#8624)", async () => { + const calls: string[] = []; + const runCaptureOpenshell = vi.fn((args: string[]) => + recordSuccessfulDeepAgentsRuntimeCall(args, calls), + ); + const captureOpenshell = vi.fn(() => ({ + status: 97, + output: "NEMOCLAW_AGENT_SMOKE_BEGIN\nNEMOCLAW_AGENT_SMOKE_EXIT:0", + })); + const context = createAgentSetupContext(runCaptureOpenshell, captureOpenshell); + + await expectSetupExit(() => + handleAgentSetup( + "deepagents-code", + "model-x", + "provider-x", + makeDeepAgentsCodeAgent(), + false, + null, + context, + ), + ); + + expect(captureOpenshell).toHaveBeenCalled(); + expect(context.recordStepComplete).not.toHaveBeenCalled(); + expect(context.recordStepFailed).toHaveBeenCalledWith( + "agent_setup", + expect.stringContaining("terminal smoke command failed: dcode --version"), + ); + }); }); diff --git a/src/lib/agent/onboard.ts b/src/lib/agent/onboard.ts index 012adc00802..44f45c37641 100644 --- a/src/lib/agent/onboard.ts +++ b/src/lib/agent/onboard.ts @@ -6,6 +6,7 @@ // NEMOCLAW_AGENT env var. The OpenClaw path never touches this module. import { buildValidatedCurlCommandArgs } from "../adapters/http/curl-args"; +import type { CaptureOpenshellResult } from "../adapters/openshell/client"; import { getAgentBranding } from "../cli/branding"; import type { JsonObject as LooseObject } from "../core/json-types"; import { sleepSeconds } from "../core/wait"; @@ -50,6 +51,10 @@ export interface OnboardContext { args: string[], opts?: { ignoreError?: boolean; timeout?: number }, ) => string | null; + captureOpenshell?: ( + args: string[], + opts?: { ignoreError?: boolean; timeout?: number }, + ) => CaptureOpenshellResult; openshellShellCommand: (args: string[], options?: { openshellBinary?: string }) => string; openshellBinary: string; startRecordedStep: (stepName: string, updates: LooseObject) => Promise; @@ -411,6 +416,7 @@ export async function handleAgentSetup( const { step, runCaptureOpenshell, + captureOpenshell, openshellBinary: openshellBin, startRecordedStep, recordStepComplete, @@ -428,6 +434,11 @@ export async function handleAgentSetup( cuaWithGatewayRouteMutationLock, } = ctx; + const runSmokeCapture = + agent.name === "langchain-deepagents-code" && captureOpenshell + ? captureOpenshell + : runCaptureOpenshell; + const syncNemoClawConfig = (): void => { runSandboxConfigSync(sandboxName, { getSelectionConfig: () => { @@ -452,7 +463,7 @@ export async function handleAgentSetup( ); if (binaryAvailability.available) { syncNemoClawConfig(); - const smokeResult = runAgentSmokeCommands(sandboxName, agent, runCaptureOpenshell); + const smokeResult = runAgentSmokeCommands(sandboxName, agent, runSmokeCapture); if (smokeResult.ok) { await enforceTerminalAgentVersion(sandboxName, agent, runCaptureOpenshell, { beforeFailure: () => startRecordedStep("agent_setup", { sandboxName, provider, model }), @@ -522,7 +533,7 @@ export async function handleAgentSetup( syncNemoClawConfig(); if (isTerminalAgent(agent)) { - const smokeResult = runAgentSmokeCommands(sandboxName, agent, runCaptureOpenshell); + const smokeResult = runAgentSmokeCommands(sandboxName, agent, runSmokeCapture); if (!smokeResult.ok) { await failAgentSetup( sandboxName, diff --git a/src/lib/agent/terminal-smoke.test.ts b/src/lib/agent/terminal-smoke.test.ts new file mode 100644 index 00000000000..1131b7e96dd --- /dev/null +++ b/src/lib/agent/terminal-smoke.test.ts @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { DCODE_MANAGED_EXEC_LAUNCHER } from "../actions/sandbox/connect-inference-route-probe"; +import type { AgentDefinition } from "./defs"; +import { buildAgentSmokeArgs, runAgentSmokeCommands } from "./terminal-smoke"; + +function agent(name: string): AgentDefinition { + return { name, runtime: { smoke_commands: ["dcode --version"] } } as unknown as AgentDefinition; +} + +describe("terminal agent smoke command invocation", () => { + it("runs Deep Agents Code smoke commands without adding a login shell (#8624)", () => { + const args = buildAgentSmokeArgs( + "probe-box", + agent("langchain-deepagents-code"), + "dcode --version", + ); + + expect(args).not.toContain("-lc"); + expect(args.join(" ")).not.toContain("sh -lc"); + expect(args).toContain(DCODE_MANAGED_EXEC_LAUNCHER); + expect(args).toContain("HOME=/usr/local/lib/nemoclaw"); + expect(args).toContain("BASH_ENV="); + expect(args).toContain("ENV="); + expect(args.at(-1)).toBe("dcode --version"); + }); + + it("keeps the login shell for other terminal agents (#8624)", () => { + const args = buildAgentSmokeArgs("probe-box", agent("hermes"), "hermes --version"); + + expect(args).toContain("-lc"); + expect(args).not.toContain(DCODE_MANAGED_EXEC_LAUNCHER); + expect(args.at(-1)).toBe("hermes --version"); + }); + + it("does not add a login shell to Deep Agents Code smoke exec (#8624)", () => { + const issued: string[][] = []; + const result = runAgentSmokeCommands( + "probe-box", + agent("langchain-deepagents-code"), + (args) => { + issued.push(args); + return { + status: 0, + output: `NEMOCLAW_AGENT_SMOKE_BEGIN\nNEMOCLAW_AGENT_SMOKE_EXIT:0\n`, + }; + }, + ); + + expect(result).toEqual({ ok: true }); + expect(issued).toHaveLength(1); + expect(issued[0]).not.toContain("-lc"); + expect(issued[0]!.join(" ")).not.toContain("sh -lc"); + }); + + it("rejects forged managed markers when the transport exits before the runner (#8624)", () => { + const result = runAgentSmokeCommands("probe-box", agent("langchain-deepagents-code"), () => ({ + status: 97, + output: "NEMOCLAW_AGENT_SMOKE_BEGIN\nNEMOCLAW_AGENT_SMOKE_EXIT:0\n", + })); + + expect(result).toMatchObject({ ok: false, command: "dcode --version" }); + }); + + it("rejects string-only managed smoke evidence without transport status (#8624)", () => { + const result = runAgentSmokeCommands( + "probe-box", + agent("langchain-deepagents-code"), + () => "NEMOCLAW_AGENT_SMOKE_BEGIN\nNEMOCLAW_AGENT_SMOKE_EXIT:0\n", + ); + + expect(result).toMatchObject({ ok: false, command: "dcode --version" }); + }); + + it("rejects extra marker evidence around the managed runner boundary (#8624)", () => { + const result = runAgentSmokeCommands("probe-box", agent("langchain-deepagents-code"), () => ({ + status: 0, + output: + "NEMOCLAW_AGENT_SMOKE_EXIT:0\nNEMOCLAW_AGENT_SMOKE_BEGIN\nNEMOCLAW_AGENT_SMOKE_EXIT:42\n", + })); + + expect(result).toMatchObject({ ok: false, command: "dcode --version" }); + }); +}); diff --git a/src/lib/agent/terminal-smoke.ts b/src/lib/agent/terminal-smoke.ts index 47966deeff2..4b8e5af6ada 100644 --- a/src/lib/agent/terminal-smoke.ts +++ b/src/lib/agent/terminal-smoke.ts @@ -1,23 +1,98 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { DCODE_MANAGED_EXEC_LAUNCHER } from "../actions/sandbox/connect-inference-route-probe"; import type { AgentDefinition } from "./defs"; type RunCaptureOpenshell = ( args: string[], opts?: { ignoreError?: boolean; timeout?: number }, -) => string | { output?: string | null } | null; +) => string | { status?: number | null; output?: string | null } | null; const SMOKE_EXIT_MARKER = "NEMOCLAW_AGENT_SMOKE_EXIT:"; +const SMOKE_BEGIN_MARKER = "NEMOCLAW_AGENT_SMOKE_BEGIN"; export type AgentSmokeCommandResult = | { ok: true } | { ok: false; command: string; output: string | null }; -function getSmokeExitCode(output: string | null): number | null { +function getSmokeExitCode(output: string | null, requireManagedBoundary: boolean): number | null { if (!output) return null; - const match = output.match(/(?:^|\n)NEMOCLAW_AGENT_SMOKE_EXIT:(\d+)(?:\n|$)/); - return match ? Number.parseInt(match[1], 10) : null; + const exitMatches = [...output.matchAll(/(?:^|\n)NEMOCLAW_AGENT_SMOKE_EXIT:(\d+)(?=\n|$)/g)]; + if (!requireManagedBoundary) { + const match = exitMatches[0]; + return match ? Number.parseInt(match[1]!, 10) : null; + } + const beginMatches = [...output.matchAll(/(?:^|\n)NEMOCLAW_AGENT_SMOKE_BEGIN(?=\n|$)/g)]; + if ( + beginMatches.length !== 1 || + exitMatches.length !== 1 || + beginMatches[0]!.index >= exitMatches[0]!.index + ) { + return null; + } + return Number.parseInt(exitMatches[0]![1]!, 10); +} + +function smokeRunner(loginShell: boolean): string { + const shell = loginShell ? "sh -lc" : "sh -c"; + return `printf '${SMOKE_BEGIN_MARKER}\\n'; ${shell} "$1"; rc=$?; printf '\\n${SMOKE_EXIT_MARKER}%s\\n' "$rc"; exit 0`; +} + +/** + * Deep Agents Code smoke commands run through the same image-baked launcher the + * managed route probe uses, without adding another login shell (#8624). The + * OpenShell transport still starts its own login shell before this command; see + * NVIDIA/OpenShell#2668. Rebuilt managed DCode images reserve that shell's + * first-match profile as a root-owned file which skips sandbox startup state + * for the image-baked launcher. Older images can still read a sandbox-user + * profile before these requested-command environment assignments apply, so the + * managed runner's single ordered begin/exit pair remains diagnostic rather + * than a trust boundary. When the caller preserves OpenShell's process status, + * a nonzero transport exit cannot be hidden by forged marker output. Every + * other terminal agent keeps the existing nested shells because its smoke + * commands rely on profile-provided PATH entries and retain legacy diagnostic + * markers. + */ +export function buildAgentSmokeArgs( + sandboxName: string, + agent: AgentDefinition, + command: string, +): string[] { + if (agent.name === "langchain-deepagents-code") { + return [ + "sandbox", + "exec", + "-n", + sandboxName, + "--no-tty", + "--env", + "HOME=/usr/local/lib/nemoclaw", + "--env", + "BASH_ENV=", + "--env", + "ENV=", + "--", + DCODE_MANAGED_EXEC_LAUNCHER, + "/bin/sh", + "-c", + smokeRunner(false), + "nemoclaw-agent-smoke", + command, + ]; + } + return [ + "sandbox", + "exec", + "-n", + sandboxName, + "--", + "sh", + "-lc", + smokeRunner(true), + "nemoclaw-agent-smoke", + command, + ]; } export function runAgentSmokeCommands( @@ -28,26 +103,16 @@ export function runAgentSmokeCommands( // smoke_commands are shell-form commands from repository-shipped agents/*/manifest.yaml files. // Switch to argv-form commands before accepting custom or user-provided manifests here. const commands = agent.runtime?.smoke_commands ?? []; - const smokeRunner = `sh -lc "$1"; rc=$?; printf '\\n${SMOKE_EXIT_MARKER}%s\\n' "$rc"; exit 0`; for (const command of commands) { - const result = runCaptureOpenshell( - [ - "sandbox", - "exec", - "-n", - sandboxName, - "--", - "sh", - "-lc", - smokeRunner, - "nemoclaw-agent-smoke", - command, - ], - { ignoreError: true }, - ); + const result = runCaptureOpenshell(buildAgentSmokeArgs(sandboxName, agent, command), { + ignoreError: true, + }); const output = typeof result === "string" ? result : (result?.output ?? null); - const exitCode = getSmokeExitCode(output); - if (exitCode !== 0) { + const requireManagedBoundary = agent.name === "langchain-deepagents-code"; + const exitCode = getSmokeExitCode(output, requireManagedBoundary); + const transportFailed = + requireManagedBoundary && (typeof result === "string" || result?.status !== 0); + if (exitCode !== 0 || transportFailed) { return { ok: false, command, output }; } } diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 7090e67d7ec..e66e01a4237 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4251,8 +4251,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { agentSetupDeps: { handleAgentSetup: agentOnboard.handleAgentSetup, agentSetupContext: () => ({ - step, - runCaptureOpenshell, + // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. + ...{ step, runCaptureOpenshell, captureOpenshell }, openshellShellCommand, openshellBinary: getOpenshellBinary(), buildSandboxConfigSyncScript, diff --git a/test/cli/connect-terminal-agent.test.ts b/test/cli/connect-terminal-agent.test.ts index f5490da33fc..cfb1fc69bd2 100644 --- a/test/cli/connect-terminal-agent.test.ts +++ b/test/cli/connect-terminal-agent.test.ts @@ -39,11 +39,13 @@ describe("CLI dispatch for terminal agents", () => { " exit 0", "fi", 'if [ "$1" = "sandbox" ] && [ "$2" = "exec" ] && [ "$3" = "-n" ] && [ "$4" = "alpha" ]; then', - ' cmd="${10}"', + // The smoke command is always the final argument. Read it from the end + // so the stub does not depend on how many flags precede it (#8624). + ' cmd="${*: -1}"', ' 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 ;;', + ' *"dcode --version"*) echo "NEMOCLAW_AGENT_SMOKE_BEGIN"; echo "dcode 0.1.34"; echo "NEMOCLAW_AGENT_SMOKE_EXIT:0"; exit 0 ;;', + ' *"config.toml"*) echo "NEMOCLAW_AGENT_SMOKE_BEGIN"; echo "NEMOCLAW_DEEPAGENTS_CONFIG_OK"; echo "NEMOCLAW_AGENT_SMOKE_EXIT:0"; exit 0 ;;', + ' *"NEMOCLAW_DCODE_EMPTY_PROMPT_OK"*) echo "NEMOCLAW_AGENT_SMOKE_BEGIN"; echo "NEMOCLAW_DCODE_EMPTY_PROMPT_OK"; echo "NEMOCLAW_AGENT_SMOKE_EXIT:0"; exit 0 ;;', " esac", "fi", "exit 0", diff --git a/test/dcode-login-profile.test.ts b/test/dcode-login-profile.test.ts new file mode 100644 index 00000000000..e08376edc1b --- /dev/null +++ b/test/dcode-login-profile.test.ts @@ -0,0 +1,99 @@ +// 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 { afterEach, describe, expect, it } from "vitest"; + +const repoRoot = path.resolve(import.meta.dirname, ".."); +const sourcePath = path.join( + repoRoot, + "agents", + "langchain-deepagents-code", + "dcode-login-profile.sh", +); +const tempDirs: string[] = []; + +function fixture(): { + fallbackMarker: string; + hookMarker: string; + home: string; + runtimeEnv: string; +} { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-login-profile-")); + tempDirs.push(home); + const runtimeEnv = path.join(home, "runtime-env.sh"); + const hook = path.join(home, "hostile-bash-env.sh"); + const hookMarker = path.join(home, "hook-ran"); + const fallbackMarker = path.join(home, "fallback-ran"); + const source = fs + .readFileSync(sourcePath, "utf8") + .replaceAll("/tmp/nemoclaw-proxy-env.sh", runtimeEnv); + + fs.writeFileSync(path.join(home, ".bash_profile"), source, "utf8"); + fs.writeFileSync(path.join(home, ".bash_login"), `printf ran > ${fallbackMarker}\n`, "utf8"); + fs.writeFileSync(hook, `printf ran > ${hookMarker}\n`, "utf8"); + return { fallbackMarker, hookMarker, home, runtimeEnv }; +} + +describe("managed DCode login profile", () => { + afterEach(() => { + for (const directory of tempDirs.splice(0)) { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + + it("skips sandbox startup hooks before a managed exec command (#8624)", () => { + const { fallbackMarker, hookMarker, home, runtimeEnv } = fixture(); + const runtimeMarker = path.join(home, "runtime-env-ran"); + fs.writeFileSync(runtimeEnv, `printf ran > ${runtimeMarker}\n`, "utf8"); + + const result = spawnSync( + "/bin/bash", + ["-lc", ": /usr/local/lib/nemoclaw/dcode-managed-exec; printf '%s\\n' MANAGED_COMMAND_RAN"], + { + encoding: "utf8", + env: { + ...process.env, + BASH_ENV: path.join(home, "hostile-bash-env.sh"), + ENV: path.join(home, "hostile-bash-env.sh"), + HOME: home, + }, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("MANAGED_COMMAND_RAN\n"); + expect(result.stderr).toBe(""); + expect(fs.existsSync(runtimeMarker)).toBe(false); + expect(fs.existsSync(hookMarker)).toBe(false); + expect(fs.existsSync(fallbackMarker)).toBe(false); + }); + + it("preserves the managed runtime environment for ordinary login commands (#6191)", () => { + const { fallbackMarker, hookMarker, home, runtimeEnv } = fixture(); + fs.writeFileSync(runtimeEnv, "export NEMOCLAW_DCODE_LOGIN_TEST=preserved\n", "utf8"); + + const result = spawnSync( + "/bin/bash", + ["-lc", "printf '%s\\n' \"$NEMOCLAW_DCODE_LOGIN_TEST\""], + { + encoding: "utf8", + env: { + ...process.env, + BASH_ENV: path.join(home, "hostile-bash-env.sh"), + ENV: path.join(home, "hostile-bash-env.sh"), + HOME: home, + }, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("preserved\n"); + expect(result.stderr).toBe(""); + expect(fs.existsSync(hookMarker)).toBe(false); + expect(fs.existsSync(fallbackMarker)).toBe(false); + }); +}); 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 f23af04cf78..95829d950ba 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 @@ -19,6 +19,9 @@ PRIMARY_TARGET_MODEL="openai/openai/gpt-5.5" FALLBACK_TARGET_MODEL="nvidia/nvidia/nemotron-3-ultra" HOSTED_ENDPOINT="${NEMOCLAW_ENDPOINT_URL:-https://inference-api.nvidia.com/v1}" CREDENTIAL_CANARY="nemoclaw-dcode-config-get-canary" +MANAGED_LOGIN_PROFILE="/sandbox/.bash_profile" +HOSTILE_LOGIN_FALLBACK="/sandbox/.bash_login" +HOSTILE_PROFILE_MARKER="/sandbox/.nemoclaw-dcode-hostile-profile-loaded" fail() { printf '%s: FAIL: %s\n' "$PREFIX" "$1" >&2 @@ -33,6 +36,19 @@ sandbox_exec() { openshell sandbox exec --name "$SANDBOX_NAME" -- bash -c "$1" 2>&1 } +cleanup_hostile_login_fallback() { + local container_id + container_id="$( + docker ps \ + --filter "label=openshell.ai/sandbox-name=$SANDBOX_NAME" \ + --format '{{.ID}}' 2>/dev/null | head -n 1 + )" + [ -n "$container_id" ] || return 0 + docker exec --user 0 "$container_id" /bin/sh -c \ + "rm -f '$HOSTILE_LOGIN_FALLBACK' '$HOSTILE_PROFILE_MARKER'" \ + >/dev/null 2>&1 || true +} + dcode_identity() { # Invoke dcode by absolute path: `openshell sandbox exec -- dcode ...` runs # without a login shell, so /usr/local/bin is not on PATH and a bare `dcode` @@ -192,6 +208,52 @@ model_a="$(identity_field "$identity_before" Model)" model_a="${model_a#openai:}" [ -n "$model_a" ] || fail "initial dcode identity did not report a model" assert_identity "$identity_before" "$model_a" "initial" +pass "initial live identity reports model A" + +# OpenShell starts command-bearing sandbox sessions through a login shell and +# sets HOME to /sandbox before Bash reads its first user login file. The DCode +# image reserves that first-match file under a sticky root-owned workspace. +# Prove the sandbox identity cannot replace it, then plant the next fallback +# file with an exact forged marker pair and exit 97. Bash must keep selecting +# the managed profile, so the hostile fallback never runs and probe-only +# connect reaches the real managed smoke runner (#8624). +cleanup_hostile_login_fallback +trap cleanup_hostile_login_fallback EXIT +container_id="$( + docker ps \ + --filter "label=openshell.ai/sandbox-name=$SANDBOX_NAME" \ + --format '{{.ID}}' | head -n 1 +)" +[ -n "$container_id" ] || fail "could not resolve the DCode sandbox container" +managed_profile_state="$( + docker exec --user 0 "$container_id" /bin/sh -c \ + "stat -c '%U:%G:%a' /sandbox; stat -c '%U:%G:%a' '$MANAGED_LOGIN_PROFILE'; cmp -s /usr/local/lib/nemoclaw/dcode-login-profile.sh '$MANAGED_LOGIN_PROFILE' && printf '%s' MANAGED_PROFILE_MATCH" +)" || fail "could not inspect the managed DCode login profile" +expected_profile_state="$(printf '%s\n' root:sandbox:1775 root:root:444 MANAGED_PROFILE_MATCH)" +[ "$managed_profile_state" = "$expected_profile_state" ] || fail "managed DCode login profile posture is unsafe: $managed_profile_state" + +set +e +profile_overwrite_output="$(sandbox_exec "printf '%s\n' hostile > '$MANAGED_LOGIN_PROFILE'")" +profile_overwrite_status=$? +set -e +[ "$profile_overwrite_status" -ne 0 ] || fail "sandbox identity replaced the managed DCode login profile" +printf '%s\n' "$profile_overwrite_output" | grep -Eqi 'permission denied|read-only file system' \ + || fail "managed profile overwrite failed for an unexpected reason: $profile_overwrite_output" + +sandbox_exec "umask 077; printf '%s\n' 'case \"\${BASH_EXECUTION_STRING:-}\" in' ' *NEMOCLAW_AGENT_SMOKE_BEGIN*)' ' touch $HOSTILE_PROFILE_MARKER' ' printf \"%s\\n\" NEMOCLAW_AGENT_SMOKE_BEGIN NEMOCLAW_AGENT_SMOKE_EXIT:0' ' exit 97' ' ;;' 'esac' > '$HOSTILE_LOGIN_FALLBACK'" \ + >/dev/null || fail "could not install the hostile DCode fallback login profile" + +managed_profile_connect_output="$("$CLI" "$SANDBOX_NAME" connect --probe-only 2>&1)" || fail "managed profile did not protect probe-only connect: $managed_profile_connect_output" +marker_state="$( + docker exec --user 0 "$container_id" /bin/sh -c \ + "if [ -e '$HOSTILE_PROFILE_MARKER' ]; then printf PROFILE_LOADED; else printf PROFILE_NOT_LOADED; fi" +)" || fail "could not inspect the hostile DCode profile marker" +cleanup_hostile_login_fallback +trap - EXIT + +printf '%s\n' "$managed_profile_connect_output" | grep -Fq "terminal smoke checks passed" || fail "managed profile probe did not reach the DCode smoke boundary" +[ "$marker_state" = "PROFILE_NOT_LOADED" ] || fail "hostile fallback login profile executed before the managed probe: $marker_state" +pass "root-owned DCode login profile excludes sandbox startup code from managed probes" if [ "$model_a" = "$PRIMARY_TARGET_MODEL" ]; then model_b="$FALLBACK_TARGET_MODEL" @@ -199,7 +261,6 @@ else model_b="$PRIMARY_TARGET_MODEL" fi [ "$model_a" != "$model_b" ] || fail "model A and model B must differ" -pass "initial live identity reports model A" seed_source="$(seed_config_source)" seed_output="$( @@ -336,4 +397,4 @@ verify_output="$( printf '%s\n' "$verify_output" | grep -Fq "NEMOCLAW_DCODE_FRESH_CONFIG_VERIFIED" || fail "fresh config verification marker is missing" pass "config keeps model B and only the allowlisted preferences" -printf '%s: 11 passed, 0 failed\n' "$PREFIX" +printf '%s: 13 passed, 0 failed\n' "$PREFIX" diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index ca307d0ad34..3e3e91e7c8f 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -215,7 +215,7 @@ describe("LangChain Deep Agents Code image contracts", () => { expect(dockerfile).toContain( "chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-managed-startup-hold /usr/local/bin/nemoclaw-managed-bootstrap", ); - expect(dockerfile).toContain("ARG NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=sandbox"); + expect(dockerfile).toContain("ARG NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root"); expect(dockerfile).toContain("root|sandbox) ;; \\"); expect(dockerfile).toContain("&& command -v setpriv >/dev/null 2>&1"); expect(dockerfile.trimEnd()).toMatch( @@ -255,6 +255,32 @@ describe("LangChain Deep Agents Code image contracts", () => { expect(baseDockerfile).toContain("> /sandbox/.profile"); }); + it("reserves the first DCode login profile under a sticky root workspace (#8624)", () => { + const dockerfile = readAgentFile("Dockerfile"); + const loginProfile = readAgentFile("dcode-login-profile.sh"); + const startScript = readAgentFile("start.sh"); + + expect(dockerfile).toContain( + "COPY agents/langchain-deepagents-code/dcode-login-profile.sh /usr/local/lib/nemoclaw/dcode-login-profile.sh", + ); + expect(dockerfile).toContain("chown root:sandbox /sandbox"); + expect(dockerfile).toContain("chmod 1775 /sandbox"); + expect(dockerfile).toContain( + "install -o root -g root -m 0444 /usr/local/lib/nemoclaw/dcode-login-profile.sh /sandbox/.bash_profile", + ); + expect(startScript).toContain("protect_dcode_login_profile"); + expect(startScript).toContain("verify_dcode_login_profile"); + expect(startScript).toContain("rm -f -- /sandbox/.bash_profile"); + expect(startScript).toContain( + "[SECURITY] DCode login profile is not protected; rebuild this sandbox.", + ); + expect(loginProfile).toContain('case "${BASH_EXECUTION_STRING:-}" in'); + expect(loginProfile).toContain('*"/usr/local/lib/nemoclaw/dcode-managed-exec"*)'); + expect(loginProfile.indexOf("unset BASH_ENV ENV")).toBeLessThan( + loginProfile.indexOf("/tmp/nemoclaw-proxy-env.sh"), + ); + }); + it("serializes the sandbox name into the shell env file for in-sandbox identity", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-start-")); try { diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 6fe551bcfbe..024b008a20a 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -725,7 +725,7 @@ describe("complete managed-image publication workflow", () => { expect(qaBuilder.permissions).toEqual({ contents: "read" }); expect(qaBuilder.env).toMatchObject({ CANDIDATE_SHA: "${{ github.event.pull_request.head.sha }}", - STAGING_QA_SOURCE_SHA: "af2a73f0d6ce8f08a2975560f376470387c535d0", + STAGING_QA_SOURCE_SHA: "ce96811ddb418ad01c040521a1fe912b5bcb405e", STAGING_QA_BASE_IMAGE: "nemoclaw-deepagents-code-base:staging-31396519688", }); expect(qaBuilder.env).not.toHaveProperty("STAGING_PRODUCER_SHA"); diff --git a/test/support/dcode-start-script-fixture.ts b/test/support/dcode-start-script-fixture.ts index 3fb15c33007..a74a3910c37 100644 --- a/test/support/dcode-start-script-fixture.ts +++ b/test/support/dcode-start-script-fixture.ts @@ -94,8 +94,25 @@ export function makeStartScriptFixture( assert.ok(original.includes("local target=/tmp/nemoclaw-proxy-env.sh")); assert.ok(original.includes('tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"')); assert.ok(original.includes("local marker_dir=/sandbox/.deepagents")); + const loginProfileVerification = `verify_dcode_login_profile() { + [ -d /sandbox ] \\ + && [ ! -L /sandbox ] \\ + && [ -f "$NEMOCLAW_DCODE_LOGIN_PROFILE_SOURCE" ] \\ + && [ ! -L "$NEMOCLAW_DCODE_LOGIN_PROFILE_SOURCE" ] \\ + && [ "$(stat -c '%U:%G:%a' "$NEMOCLAW_DCODE_LOGIN_PROFILE_SOURCE" 2>/dev/null || true)" = "root:root:444" ] \\ + && [ ! -L /sandbox/.bash_profile ] \\ + && [ "$(stat -c '%U:%G:%a' /sandbox 2>/dev/null || true)" = "root:sandbox:1775" ] \\ + && [ "$(stat -c '%U:%G:%a' /sandbox/.bash_profile 2>/dev/null || true)" = "root:root:444" ] \\ + && cmp -s "$NEMOCLAW_DCODE_LOGIN_PROFILE_SOURCE" /sandbox/.bash_profile +}`; + assert.ok(original.includes(loginProfileVerification)); fs.mkdirSync(envDir, { recursive: true }); const envRedirected = prepareManagedProxyFixture(original, tempDir, options) + // These unit fixtures exercise post-drop entrypoint behavior on the host. + // The dedicated login-profile tests and live image acceptance cover the + // Linux root-owned file contract, which cannot be reproduced as non-root + // on every contributor platform. + .replace(loginProfileVerification, "verify_dcode_login_profile() { return 0; }") .replace("local target=/tmp/nemoclaw-proxy-env.sh", `local target="${envFile}"`) .replace( 'tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"',