From 375c76a4646c8f4f9537ac9c18370e519354bbc7 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 8 Sep 2026 19:50:36 -0700 Subject: [PATCH 1/9] refactor(sandbox): normalize native diagnostics and shell profiles Signed-off-by: Aaron Erickson --- Dockerfile | 6 +- Dockerfile.base | 7 +- agents/hermes/Dockerfile | 4 +- agents/hermes/Dockerfile.base | 4 +- agents/hermes/start.sh | 6 +- agents/langchain-deepagents-code/Dockerfile | 10 +- .../langchain-deepagents-code/Dockerfile.base | 4 +- .../dcode-login-profile.sh | 18 +- agents/langchain-deepagents-code/start.sh | 45 +- agents/pi/Dockerfile | 2 + agents/pi/Dockerfile.base | 4 +- agents/pi/start.sh | 23 +- ci/test-file-size-budget.json | 2 +- scripts/lib/clean_runtime_shell_env_shim.py | 223 ---------- scripts/lib/sandbox-init.sh | 54 --- scripts/nemoclaw-start.sh | 65 +-- .../sandbox/terminal-runtime-health.test.ts | 2 +- .../sandbox/terminal-runtime-health.ts | 2 +- src/lib/sandbox/build-context.ts | 4 - src/lib/tunnel/sandbox-gateway-stop.test.ts | 1 + src/lib/tunnel/sandbox-gateway-stop.ts | 2 +- .../deepagents/dcode-login-profile.test.ts | 114 ++--- .../langchain-deepagents-code-image.test.ts | 26 -- test/agents/hermes/hermes-start.test.ts | 24 +- .../openclaw/runtime/nemoclaw-start.test.ts | 20 +- .../pull-requests/pr-risk-plan.test.ts | 2 +- .../04-deepagents-code-fresh-reonboard.sh | 74 ++-- test/e2e/fixtures/security-posture.ts | 29 +- test/e2e/lib/security-posture-assertions.sh | 214 ---------- test/e2e/live/hermes-e2e.test.ts | 30 +- test/e2e/live/pi-agent-qualification.test.ts | 22 +- test/e2e/support/security-posture.test.ts | 2 +- test/runtime/gateway/service-env.test.ts | 390 ------------------ .../clean-runtime-shell-env-shim.test.ts | 116 ------ .../sandbox/sandbox-build-context.test.ts | 1 - test/runtime/sandbox/sandbox-init.test.ts | 57 +-- ...ox-provisioning-helper-permissions.test.ts | 1 - test/support/dcode-start-script-fixture.ts | 17 - 38 files changed, 193 insertions(+), 1434 deletions(-) delete mode 100644 scripts/lib/clean_runtime_shell_env_shim.py delete mode 100755 test/e2e/lib/security-posture-assertions.sh delete mode 100644 test/runtime/sandbox/clean-runtime-shell-env-shim.test.ts diff --git a/Dockerfile b/Dockerfile index ba8598d0dab..253d391e675 100644 --- a/Dockerfile +++ b/Dockerfile @@ -579,7 +579,6 @@ COPY scripts/lib/entrypoint-env-wrapper.sh /usr/local/lib/nemoclaw/entrypoint-en COPY scripts/lib/gateway-supervisor.sh /usr/local/lib/nemoclaw/gateway-supervisor.sh COPY scripts/lib/sandbox-rlimits.sh /usr/local/lib/nemoclaw/sandbox-rlimits.sh COPY scripts/lib/openclaw_device_approval_policy.py /usr/local/lib/nemoclaw/openclaw_device_approval_policy.py -COPY scripts/lib/clean_runtime_shell_env_shim.py /usr/local/lib/nemoclaw/clean_runtime_shell_env_shim.py COPY scripts/lib/normalize_mutable_config_perms.py /usr/local/lib/nemoclaw/normalize_mutable_config_perms.py COPY scripts/lib/refresh-openclaw-wechat-placeholder.py /usr/local/lib/nemoclaw/refresh-openclaw-wechat-placeholder.py COPY scripts/openclaw-config-guard.py /usr/local/lib/nemoclaw/openclaw-config-guard.py @@ -1937,7 +1936,6 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \ /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh \ /usr/local/lib/nemoclaw/sandbox-rlimits.sh \ && chmod 644 /usr/local/lib/nemoclaw/openclaw_device_approval_policy.py \ - /usr/local/lib/nemoclaw/clean_runtime_shell_env_shim.py \ && chmod 555 /usr/local/lib/nemoclaw/normalize_mutable_config_perms.py \ && if [ -d /usr/local/lib/nemoclaw/preloads-compiled-channels ]; then \ find /usr/local/lib/nemoclaw/preloads-compiled-channels -path '*/runtime/*.js' -type f \ @@ -2272,7 +2270,9 @@ RUN sha256sum /sandbox/.openclaw/openclaw.json > /sandbox/.openclaw/.config-hash # renaming or deleting root-owned entries (blueprints/). # Ref: https://github.com/NVIDIA/NemoClaw/issues/804 # Ref: https://github.com/NVIDIA/NemoClaw/issues/1607 -RUN chown root:root /sandbox/.nemoclaw \ +RUN chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile \ + && chmod 644 /sandbox/.bashrc /sandbox/.profile \ + && chown root:root /sandbox/.nemoclaw \ && chmod 1755 /sandbox/.nemoclaw \ && chown -R root:root /sandbox/.nemoclaw/blueprints \ && chmod -R 755 /sandbox/.nemoclaw/blueprints \ diff --git a/Dockerfile.base b/Dockerfile.base index 71b6c683295..26308e02a30 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -340,8 +340,7 @@ RUN mkdir -p /sandbox/.openclaw/agents/main/agent \ COPY scripts/lib/sandbox-rlimits.sh /usr/local/lib/nemoclaw/sandbox-rlimits.sh # Pre-create shell init files for the sandbox user. Runtime environment hooks -# are installed system-wide below; user rc files stay clean and locked so -# per-user startup files are not part of the trust boundary. +# are installed system-wide below; personal files belong to the agent. # hadolint ignore=SC2028 RUN printf '%s\n' \ '# NemoClaw sandbox shell init' \ @@ -349,8 +348,8 @@ RUN printf '%s\n' \ && printf '%s\n' \ '# NemoClaw sandbox login init' \ > /sandbox/.profile \ - && chown root:root /sandbox/.bashrc /sandbox/.profile \ - && chmod 444 /sandbox/.bashrc /sandbox/.profile + && chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile \ + && chmod 644 /sandbox/.bashrc /sandbox/.profile # System-wide proxy hooks. The per-home rc files above only fire for shells # that find `~/.bashrc` / `~/.profile` (sandbox user, HOME=/sandbox). SSH diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 2158f240781..44bda53ce5f 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -1572,7 +1572,9 @@ RUN cp /etc/nemoclaw/hermes.config-hash /sandbox/.hermes/.config-hash \ # Keep the shared NemoClaw state root consistent across every shipped agent. # The root-owned sticky directory protects the managed-startup transaction # receipt while leaving the named plugin state directories sandbox-writable. -RUN chown root:root /sandbox/.nemoclaw \ +RUN chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile \ + && chmod 644 /sandbox/.bashrc /sandbox/.profile \ + && chown root:root /sandbox/.nemoclaw \ && chmod 1755 /sandbox/.nemoclaw \ && chown -R root:root /sandbox/.nemoclaw/blueprints \ && chmod -R 755 /sandbox/.nemoclaw/blueprints \ diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 93c26eed405..0c997f2715c 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -414,8 +414,8 @@ RUN printf '%s\n' \ '[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \ 'export PATH="/usr/local/bin:/opt/hermes/.venv/bin:${PATH}"' \ > /sandbox/.profile \ - && chown root:root /sandbox/.bashrc /sandbox/.profile \ - && chmod 444 /sandbox/.bashrc /sandbox/.profile + && chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile \ + && chmod 644 /sandbox/.bashrc /sandbox/.profile # Install Hermes Agent from the selected GitHub release. # The image prebakes only the extras selected for the managed Hermes image: diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index aff6c7effcd..f0f3eb6e4cf 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -2383,7 +2383,7 @@ TUIENVEOF # nemoclaw-configure-guard begin hermes() { case "$1" in - setup|doctor) + setup) echo "Error: 'hermes $1' cannot modify config inside the sandbox." >&2 echo "NemoClaw manages sandbox config from the host for integrity checks." >&2 echo "" >&2 @@ -2400,10 +2400,6 @@ GUARDENVEOF } write_runtime_shell_env -# SECURITY FIX: Lock .bashrc/.profile after all static shims are in place. -# Hermes connect sessions source the dynamic guard from /tmp/nemoclaw-proxy-env.sh -# so startup never needs to rewrite files directly under /sandbox after caps drop. -lock_rc_files "$_SANDBOX_HOME" # ── Legacy layout migration ────────────────────────────────────── path_has_immutable_bit() { diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index 5d14b60c9da..5c6da867c56 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -199,7 +199,7 @@ COPY agents/langchain-deepagents-code/validate-observability.py /opt/nemoclaw-de COPY agents/langchain-deepagents-code/validate-read-only-mcp-call.py /opt/nemoclaw-deepagents-code/validate-read-only-mcp-call.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-login-profile.sh /etc/profile.d/nemoclaw-dcode.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 @@ -224,7 +224,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/agents/langchain-deepagents-code/generate-config.ts /opt/nemoclaw-deepagents-code/src/lib/inference/managed-dcode/identity.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 /opt/nemoclaw-deepagents-code/validate-read-only-mcp-call.py /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh /usr/local/lib/nemoclaw/dcode-login-profile.sh /usr/local/lib/nemoclaw/nemoclaw_read_only_mcp.py \ + && chmod 444 /opt/nemoclaw-deepagents-code/generate-config.ts /opt/nemoclaw-deepagents-code/agents/langchain-deepagents-code/generate-config.ts /opt/nemoclaw-deepagents-code/src/lib/inference/managed-dcode/identity.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 /opt/nemoclaw-deepagents-code/validate-read-only-mcp-call.py /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh /etc/profile.d/nemoclaw-dcode.sh /usr/local/lib/nemoclaw/nemoclaw_read_only_mcp.py \ && 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 \ @@ -362,10 +362,8 @@ RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \ USER root 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 sandbox:sandbox /sandbox/.bashrc /sandbox/.profile \ + && chmod 644 /sandbox/.bashrc /sandbox/.profile \ && chown root:root /sandbox/.nemoclaw \ && chmod 1755 /sandbox/.nemoclaw \ && chown -R root:root /sandbox/.nemoclaw/blueprints \ diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index 58502e2a2e2..b205c6bfd4a 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -305,8 +305,8 @@ RUN printf '%s\n' \ 'export HOME=/sandbox' \ 'export PATH="/usr/local/bin:/opt/venv/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin"' \ > /sandbox/.profile \ - && chown root:root /sandbox/.bashrc /sandbox/.profile \ - && chmod 444 /sandbox/.bashrc /sandbox/.profile + && chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile \ + && chmod 644 /sandbox/.bashrc /sandbox/.profile COPY scripts/lib/sandbox-rlimits.sh /usr/local/lib/nemoclaw/sandbox-rlimits.sh diff --git a/agents/langchain-deepagents-code/dcode-login-profile.sh b/agents/langchain-deepagents-code/dcode-login-profile.sh index 69f0bc53613..ac6f8ad6799 100644 --- a/agents/langchain-deepagents-code/dcode-login-profile.sh +++ b/agents/langchain-deepagents-code/dcode-login-profile.sh @@ -2,19 +2,11 @@ # 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 +# OpenShell uses a login shell for managed probes. Select an image-owned home +# before Bash reads personal files; the managed launcher restores agent HOME. 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" + *"/usr/local/lib/nemoclaw/dcode-managed-exec"*) + unset BASH_ENV ENV + export HOME=/usr/local/lib/nemoclaw ;; esac diff --git a/agents/langchain-deepagents-code/start.sh b/agents/langchain-deepagents-code/start.sh index ea34c86e545..9e857c2ec0a 100755 --- a/agents/langchain-deepagents-code/start.sh +++ b/agents/langchain-deepagents-code/start.sh @@ -10,41 +10,6 @@ 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 @@ -69,19 +34,11 @@ 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 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. +# Root entrypoints hand off agent work without reading personal shell files. 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/agents/pi/Dockerfile b/agents/pi/Dockerfile index 9166857d2d5..9719437f401 100644 --- a/agents/pi/Dockerfile +++ b/agents/pi/Dockerfile @@ -236,6 +236,8 @@ RUN umask 077 \ USER root RUN chown root:sandbox /sandbox \ && chmod 1775 /sandbox \ + && chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile \ + && chmod 644 /sandbox/.bashrc /sandbox/.profile \ && test "$(stat -c '%U:%G:%a' /sandbox)" = 'root:sandbox:1775' \ && chown root:root /sandbox/.nemoclaw \ && chmod 1755 /sandbox/.nemoclaw \ diff --git a/agents/pi/Dockerfile.base b/agents/pi/Dockerfile.base index 44279ca94a6..8385bceb7d9 100644 --- a/agents/pi/Dockerfile.base +++ b/agents/pi/Dockerfile.base @@ -314,8 +314,8 @@ RUN printf '%s\n' \ 'export HOME=/sandbox' \ 'export PATH="/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin"' \ > /sandbox/.profile \ - && chown root:root /sandbox/.bashrc /sandbox/.profile \ - && chmod 444 /sandbox/.bashrc /sandbox/.profile + && chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile \ + && chmod 644 /sandbox/.bashrc /sandbox/.profile COPY scripts/lib/sandbox-rlimits.sh /usr/local/lib/nemoclaw/sandbox-rlimits.sh diff --git a/agents/pi/start.sh b/agents/pi/start.sh index 2e9823fb3bb..155e7a80bad 100755 --- a/agents/pi/start.sh +++ b/agents/pi/start.sh @@ -15,17 +15,6 @@ export HOME=/sandbox export PATH="/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin" readonly NEMOCLAW_PI_STATE_DIR="/sandbox/.pi/agent" -readonly NEMOCLAW_PI_SHELL_INIT_FILES=(/sandbox/.bashrc /sandbox/.profile) - -verify_pi_shell_init() { - local file - [ -d /sandbox ] && [ ! -L /sandbox ] || return 1 - for file in "${NEMOCLAW_PI_SHELL_INIT_FILES[@]}"; do - [ -f "$file" ] && [ ! -L "$file" ] || return 1 - [ "$(stat -c '%U:%G:%a' "$file" 2>/dev/null || true)" = "root:root:444" ] || return 1 - done -} - # managed-entrypoint-env-wrapper begin _NEMOCLAW_ENTRYPOINT_ENV_WRAPPER="/usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh" if [ ! -f "$_NEMOCLAW_ENTRYPOINT_ENV_WRAPPER" ]; then @@ -50,23 +39,13 @@ 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 every start -# can repair the protected workspace boundary and create the protected merged -# CA bundle before dropping to the sandbox user. A sandbox-user image verifies -# the image-baked boundary instead. +# Root startup prepares protected state and trust before dropping privileges. _NEMOCLAW_PI_DROP_PRIVILEGES=0 if [ "$(id -u)" -eq 0 ]; then - if ! verify_pi_shell_init; then - printf '%s\n' '[SECURITY] Managed Pi shell initialization files are missing or unsafe.' >&2 - exit 1 - fi chown root:sandbox /sandbox chmod 1775 /sandbox install -d -o sandbox -g sandbox -m 0700 "$NEMOCLAW_PI_STATE_DIR" _NEMOCLAW_PI_DROP_PRIVILEGES=1 -elif ! verify_pi_shell_init; then - printf '%s\n' '[SECURITY] Pi shell initialization files are not protected; rebuild this sandbox.' >&2 - exit 1 fi export PI_OFFLINE=1 diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 50644fdf2b7..78c1fde3221 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -7,7 +7,7 @@ "src/lib/onboard/preflight.test.ts": 1875, "test/generation/generate-openclaw-config.test.ts": 1898, "test/installer-integration/install-preflight.test.ts": 3025, - "test/agents/openclaw/runtime/nemoclaw-start.test.ts": 4377, + "test/agents/openclaw/runtime/nemoclaw-start.test.ts": 4359, "test/onboarding/onboard-messaging.test.ts": 1971, "test/onboarding/onboard-selection.test.ts": 4133 } diff --git a/scripts/lib/clean_runtime_shell_env_shim.py b/scripts/lib/clean_runtime_shell_env_shim.py deleted file mode 100644 index 99801512360..00000000000 --- a/scripts/lib/clean_runtime_shell_env_shim.py +++ /dev/null @@ -1,223 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -"""Remove the legacy runtime shell-env shim from a sandbox user's rc file. - -Older base images and earlier entrypoints wrote a two-line stanza into -.bashrc/.profile that sourced /tmp/nemoclaw-proxy-env.sh. The startup -entrypoint now exports those variables in-process, so the legacy stanza is -deleted before lock_rc_files makes the rc files read-only again. - -The script intentionally exits 0 in a small number of "leave-it-in-place" -cases that are not safe to rewrite from a non-root entrypoint: - -* The rc file is not owned by the current uid (e.g. root-owned .bashrc in a - non-root sandbox). Rewriting it would need CAP_FOWNER, which the entrypoint - no longer has after process-capability drops. The leftover stanza only - sources /tmp/nemoclaw-proxy-env.sh if that file exists; that file's - permissions are hardened elsewhere in the startup sequence. - -* The rc file contents are already clean (no shim line). - -Invocation (from nemoclaw-start.sh): - python3 clean_runtime_shell_env_shim.py - -Source-of-truth: this script is a backwards-compatibility skip path. The -invalid state it tolerates is "legacy base image planted a runtime shim into -an rc file owned by a different uid than the entrypoint currently runs as". -The preferred source boundary is the base image build: newer base images -either own the rc file as the entrypoint user or do not plant the shim at -all. Previously shipped sandboxes already have the mismatched-owner rc -files on disk; crashing the entrypoint with exit code 1 on them is strictly -worse than logging and skipping. Regression tests cover the direct fixture -skip path and the composed startup invariant asserting -/tmp/nemoclaw-proxy-env.sh stays mode 444. Removal condition: when no -supported release ships a base image that plants the legacy shim AND every -reachable sandbox has been rebuilt off a newer base image, drop the -mismatched-owner branch and have the script exit 1 on EPERM again. -""" - -import errno -import os -import stat -import sys -import tempfile - - -def same_file(left, right): - return left.st_dev == right.st_dev and left.st_ino == right.st_ino - - -def rewrite_open_rc_file(read_fd, original_stat, cleaned_lines, uid): - # The runtime test image can make /sandbox non-writable while leaving - # legacy shims in the rc files. In that case atomic rename into /sandbox - # fails, so rewrite the already-validated inode through /proc/self/fd - # instead. - final_mode = stat.S_IMODE(original_stat.st_mode) - if uid == 0: - os.fchown(read_fd, 0, 0) - os.fchmod(read_fd, 0o600) - write_fd = os.open( - f"/proc/self/fd/{read_fd}", - os.O_WRONLY | os.O_TRUNC | getattr(os, "O_CLOEXEC", 0), - ) - try: - if not same_file(original_stat, os.fstat(write_fd)): - raise RuntimeError("rc file descriptor target changed during cleanup") - with os.fdopen(write_fd, "w", encoding="utf-8", errors="surrogateescape") as handle: - write_fd = None - handle.writelines(cleaned_lines) - handle.flush() - os.fsync(handle.fileno()) - finally: - if write_fd is not None: - os.close(write_fd) - os.fchmod(read_fd, final_mode) - - -def rewrite_by_rename(rc_path, original_stat, cleaned_lines, uid, tmp_paths): - tmp_fd, tmp_path = tempfile.mkstemp(prefix="nemoclaw-rc-clean.", dir="/tmp", text=True) - tmp_paths.append(tmp_path) - with os.fdopen(tmp_fd, "w", encoding="utf-8", errors="surrogateescape") as handle: - handle.writelines(cleaned_lines) - handle.flush() - os.fsync(handle.fileno()) - if uid == 0: - os.chown(tmp_path, 0, 0) - # Mirror the original rc file's mode bits rather than fixing a permissive - # default. The pre-cleanup file's mode is the user-visible source of truth; - # widening it here would silently change rc file permissions. - os.chmod(tmp_path, stat.S_IMODE(original_stat.st_mode)) - os.replace(tmp_path, rc_path) - tmp_paths.pop() - - -def main(argv): - if len(argv) != 4: - print( - "[SECURITY] clean_runtime_shell_env_shim: expected ", - file=sys.stderr, - ) - return 1 - rc_path = argv[1] - shim = argv[2] - uid = int(argv[3]) - fd = None - tmp_paths = [] - - try: - flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) - try: - fd = os.open(rc_path, flags) - except OSError as exc: - if exc.errno == errno.ELOOP: - print( - f"[SECURITY] refusing symlinked rc file during cleanup: {rc_path}", - file=sys.stderr, - ) - else: - print( - f"[SECURITY] could not open rc file for cleanup: {rc_path}: {exc}", - file=sys.stderr, - ) - return 1 - - st = os.fstat(fd) - if not stat.S_ISREG(st.st_mode): - print( - f"[SECURITY] refusing non-regular rc file during cleanup: {rc_path}", - file=sys.stderr, - ) - return 1 - with os.fdopen(os.dup(fd), "r", encoding="utf-8", errors="surrogateescape") as handle: - lines = handle.readlines() - - cleaned = [] - index = 0 - while index < len(lines): - line = lines[index] - bare = line.rstrip("\n") - if bare == "# Source runtime proxy config": - if index + 1 < len(lines): - next_line = lines[index + 1] - next_bare = next_line.rstrip("\n") - if next_bare == shim or "/tmp/nemoclaw-proxy-env.sh" in next_line: - index += 2 - continue - cleaned.append(line) - cleaned.append(next_line) - index += 2 - continue - if bare == shim or "/tmp/nemoclaw-proxy-env.sh" in line: - index += 1 - continue - cleaned.append(line) - index += 1 - - if any( - line.rstrip("\n") == shim or "/tmp/nemoclaw-proxy-env.sh" in line - for line in cleaned - ): - print( - f"[SECURITY] runtime env shim still present after cleanup: {rc_path}", - file=sys.stderr, - ) - return 1 - if cleaned == lines: - return 0 - - # When the rc file is not owned by us (and we are not root) we cannot - # safely rewrite it: fchmod would raise EPERM without CAP_FOWNER, and - # the in-place reopen via /proc/self/fd would fail anyway. - # - # Threat model: the legacy shim line we would have removed is still an - # active trust-boundary hook. It sources /tmp/nemoclaw-proxy-env.sh on - # every shell start and pulls in the proxy and gateway-token exports - # from that file. That file is written exclusively via - # `emit_sandbox_sourced_file` in scripts/lib/sandbox-init.sh, which - # forces mode 444 (and root ownership when the entrypoint runs as - # root) before placing the file. The startup sequence validates that - # invariant via `validate_tmp_permissions` before launching services. - # The composed test in test/runtime/gateway/service-env.test.ts proves the file stays - # at mode 444 through this skip path. As long as the proxy-env file - # remains non-user-writable, the leftover shim does not widen the - # sandbox's trust boundary; crashing the container under errexit - # (which the original code did) was the strictly worse outcome. A - # later root-mode boot can finish the cleanup. - if uid != 0 and st.st_uid != uid: - print( - f"[SECURITY] skipping rc cleanup for {rc_path}: not owned by uid={uid} " - f"(file uid={st.st_uid}); legacy shim left in place", - file=sys.stderr, - ) - return 0 - - try: - rewrite_open_rc_file(fd, st, cleaned, uid) - except OSError as exc: - if exc.errno != errno.ENOENT: - raise - rewrite_by_rename(rc_path, st, cleaned, uid, tmp_paths) - except Exception as exc: - print( - f"[SECURITY] could not safely clean runtime env shim from {rc_path}: {exc}", - file=sys.stderr, - ) - return 1 - finally: - if fd is not None: - os.close(fd) - for tmp_path in tmp_paths: - try: - os.unlink(tmp_path) - except FileNotFoundError: - # The successful path in `rewrite_by_rename` removes the tmp - # path from `tmp_paths` before this finally block runs, so - # arriving here means the rename happened or the OS already - # reaped the file. Nothing left to clean up. - pass - - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index fc5721ae11a..281e6a76335 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -501,60 +501,6 @@ verify_config_integrity() { fi } -# ── RC file locking ────────────────────────────────────────────── -# Lock .bashrc and .profile to 444 after startup has written dynamic shell -# state to /tmp/nemoclaw-proxy-env.sh. This prevents the sandbox user from -# injecting code that runs on every `nemoclaw connect`. -# -# SECURITY: This fixes the Hermes vulnerability where .bashrc/.profile -# were never locked (unlike OpenClaw which had this via #2125). -# -# Usage: -# lock_rc_files /sandbox # locks /sandbox/.bashrc and /sandbox/.profile -lock_rc_files() { - local home_dir="$1" - - for rc_file in "${home_dir}/.bashrc" "${home_dir}/.profile"; do - if [ -L "$rc_file" ]; then - echo "[SECURITY] Refusing to lock symlinked rc file: ${rc_file}" >&2 - continue - fi - if [ -f "$rc_file" ]; then - if ! python3 - "$rc_file" "$(id -u)" <<'PY' 2>/dev/null; then -import errno -import os -import stat -import sys - -path, uid_text = sys.argv[1:3] -uid = int(uid_text) -flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) -try: - fd = os.open(path, flags) -except OSError as exc: - if exc.errno == errno.ELOOP: - print(f"[SECURITY] Refusing to lock symlinked rc file: {path}", file=sys.stderr) - else: - print(f"[SECURITY] Could not open rc file for locking: {path}: {exc}", file=sys.stderr) - sys.exit(1) - -try: - st = os.fstat(fd) - if not stat.S_ISREG(st.st_mode): - print(f"[SECURITY] Refusing to lock non-regular rc file: {path}", file=sys.stderr) - sys.exit(1) - if uid == 0: - os.fchown(fd, 0, 0) - os.fchmod(fd, 0o444) -finally: - os.close(fd) -PY - echo "[SECURITY] Could not lock ${rc_file} to 444 — continuing (best-effort, Landlock may enforce)" >&2 - fi - fi - done -} - # ── Cleanup / signal forwarding ────────────────────────────────── # Forward SIGTERM/SIGINT to child processes for graceful shutdown. # The entrypoint is PID 1 — without a trap, signals interrupt wait and diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index e361797c618..fbc5bf98954 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3505,7 +3505,6 @@ install_core_runtime_preloads || exit 1 # lowercase (no_proxy) over uppercase (NO_PROXY) when both are set. # curl/wget use uppercase. gRPC C-core uses lowercase. _RUNTIME_SHELL_ENV_FILE="/tmp/nemoclaw-proxy-env.sh" -_RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL_ENV_FILE}" write_runtime_shell_env() { _PROXY_ENV_FILE="/tmp/nemoclaw-proxy-env.sh" @@ -3632,9 +3631,8 @@ GATEWAYURLENVEOF # nemoclaw-configure-guard begin # #4538: a raw in-sandbox `openclaw doctor --fix` (run directly from a connect # shell, outside any NemoClaw wrapper command) tightens the mutable OpenClaw -# config tree back to single-user 700/600 — even when it exits nonzero (e.g. it -# hits EACCES on a root-locked shell init file). That blocks the gateway UID, -# a member of the sandbox group, from persisting config writes. Restore the +# config tree back to single-user 700/600, even after a failed command. This +# blocks the gateway UID, a sandbox group member, from persisting config. Restore the # setgid + group-writable contract (2770 dir / 660 config) after every openclaw # invocation routed through this guard, regardless of exit code. Best-effort and # idempotent: it skips a root-owned active config transaction and is a no-op @@ -4255,61 +4253,6 @@ GATEWAYTOKENENVEOF # populated as children start; cleanup refreshes and validates them before # signaling anything. -# Keep per-user rc files out of runtime proxy wiring. Older images and prior -# entrypoint versions wrote a two-line shim into .bashrc/.profile; remove that -# managed stanza before lock_rc_files makes the files read-only again. -# -# The Python body lives in scripts/lib/clean_runtime_shell_env_shim.py so it -# can be unit-tested with controlled rc fixtures. Installed location in the -# sandbox image: /usr/local/lib/nemoclaw/clean_runtime_shell_env_shim.py. -ensure_runtime_shell_env_shim() { - local failed=0 - local rc_file - # Resolution order is deliberately fixed: the immutable installed helper at - # /usr/local/lib/nemoclaw/ ALWAYS wins when present. That path is set up - # by the Dockerfile, chmod 644, root-owned (or build-time owned), and lives - # under a system directory the sandbox user cannot write to. We refuse to - # honour any environment-supplied override when that file is in place so a - # malicious envvar cannot swap in arbitrary Python. - # - # The NEMOCLAW_RC_CLEAN_SCRIPT override is consulted ONLY when the installed - # helper is missing — i.e. running the unit-test wrappers against the - # repository tree, where the script lives at scripts/lib/ instead. - # The final fallback resolves the script relative to nemoclaw-start.sh so - # `bash scripts/nemoclaw-start.sh` works out-of-the-box for ad-hoc dev runs. - local clean_script="/usr/local/lib/nemoclaw/clean_runtime_shell_env_shim.py" - if [ ! -f "$clean_script" ]; then - if [ -n "${NEMOCLAW_RC_CLEAN_SCRIPT:-}" ] && [ -f "${NEMOCLAW_RC_CLEAN_SCRIPT}" ]; then - clean_script="${NEMOCLAW_RC_CLEAN_SCRIPT}" - else - clean_script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/clean_runtime_shell_env_shim.py" - fi - fi - - for rc_file in "${_SANDBOX_HOME}/.bashrc" "${_SANDBOX_HOME}/.profile"; do - if [ -L "$rc_file" ]; then - echo "[SECURITY] refusing symlinked rc file: $rc_file" >&2 - failed=1 - continue - fi - if [ -e "$rc_file" ] && [ ! -f "$rc_file" ]; then - echo "[SECURITY] refusing non-regular rc file: $rc_file" >&2 - failed=1 - continue - fi - if [ ! -f "$rc_file" ]; then - continue - fi - - if ! command python3 "$clean_script" "$rc_file" "$_RUNTIME_SHELL_ENV_SHIM" "$(id -u)"; then - failed=1 - continue - fi - done - - return "$failed" -} - # ── Legacy layout migration ────────────────────────────────────── # Sandboxes created with the OLD base image have: # .openclaw/ containing symlinks → .openclaw-data/ @@ -5899,8 +5842,6 @@ if [ "$(id -u)" -ne 0 ]; then _nemoclaw_capture_epoch_realtime _NEMOCLAW_GATEWAY_TOKEN_FINISHED_EPOCH write_messaging_runtime_setup_plan write_runtime_shell_env - ensure_runtime_shell_env_shim - lock_rc_files "$_SANDBOX_HOME" || true # Apply manifest-declared runtime env aliases before any child inherits the # env. This covers both one-shot commands and the gateway launch. apply_messaging_runtime_env_aliases @@ -6054,8 +5995,6 @@ write_openclaw_config_baseline export_gateway_token write_messaging_runtime_setup_plan write_runtime_shell_env -ensure_runtime_shell_env_shim -lock_rc_files "$_SANDBOX_HOME" # Apply manifest-declared runtime env aliases before any child (the one-shot # "${NEMOCLAW_CMD[@]}" exec or the stepped-down gateway) inherits the env. # setpriv preserves the environment, so the export reaches the gateway user. diff --git a/src/lib/actions/sandbox/terminal-runtime-health.test.ts b/src/lib/actions/sandbox/terminal-runtime-health.test.ts index e8a67546df5..19bdbf15ef5 100644 --- a/src/lib/actions/sandbox/terminal-runtime-health.test.ts +++ b/src/lib/actions/sandbox/terminal-runtime-health.test.ts @@ -73,7 +73,7 @@ describe("probeTerminalRuntimeCgroupOom", () => { const args = calls[0] ?? []; // The sandbox exec transport runs the probe under the sandbox policy, which // denies /sys/fs/cgroup and hid every real OOM behind unavailable. - expect(args.slice(0, 4)).toEqual(["exec", "openshell-alpha", "sh", "-lc"]); + expect(args.slice(0, 4)).toEqual(["exec", "openshell-alpha", "sh", "-c"]); expect(args[4]).toContain("/sys/fs/cgroup/memory.events"); expect(args[4]).toContain("/sys/fs/cgroup/memory.oom_control"); expect(args[4]).toContain("/sys/fs/cgroup/memory/memory.oom_control"); diff --git a/src/lib/actions/sandbox/terminal-runtime-health.ts b/src/lib/actions/sandbox/terminal-runtime-health.ts index 1508fbc8bcf..1b86190cc8d 100644 --- a/src/lib/actions/sandbox/terminal-runtime-health.ts +++ b/src/lib/actions/sandbox/terminal-runtime-health.ts @@ -171,7 +171,7 @@ export function probeTerminalRuntimeCgroupOom( ); if (!containerName) return { kind: "unavailable", detail: "sandbox container owner unresolved" }; - const result = deps.run(["exec", containerName, "sh", "-lc", CGROUP_OOM_PROBE_SCRIPT]); + const result = deps.run(["exec", containerName, "sh", "-c", CGROUP_OOM_PROBE_SCRIPT]); if (result.error) return { kind: "unavailable", detail: result.error.message }; if (result.status !== 0) { const stderr = Buffer.isBuffer(result.stderr) diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index e0e96f7a55b..a1329a359c5 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -346,10 +346,6 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "lib", "openclaw_device_approval_policy.py"), path.join(stagedScriptsDir, "lib", "openclaw_device_approval_policy.py"), ); - fs.copyFileSync( - path.join(rootDir, "scripts", "lib", "clean_runtime_shell_env_shim.py"), - path.join(stagedScriptsDir, "lib", "clean_runtime_shell_env_shim.py"), - ); fs.copyFileSync( path.join(rootDir, "scripts", "lib", "normalize_mutable_config_perms.py"), path.join(stagedScriptsDir, "lib", "normalize_mutable_config_perms.py"), diff --git a/src/lib/tunnel/sandbox-gateway-stop.test.ts b/src/lib/tunnel/sandbox-gateway-stop.test.ts index 442a9dce648..ab77ae47832 100644 --- a/src/lib/tunnel/sandbox-gateway-stop.test.ts +++ b/src/lib/tunnel/sandbox-gateway-stop.test.ts @@ -91,6 +91,7 @@ describe("stopSandboxChannels", () => { expect(args).toEqual( expect.arrayContaining(["kubectl", "exec", "-n", "openshell", "-c", "agent"]), ); + expect(args.slice(-3, -1)).toEqual(["sh", "-c"]); const script = String(args.at(-1)); expect(script).toContain("ps -eo uid=,pid=,args="); expect(script).toContain("stat -Lc '%u'"); diff --git a/src/lib/tunnel/sandbox-gateway-stop.ts b/src/lib/tunnel/sandbox-gateway-stop.ts index 631a304ba8e..b81a503b523 100644 --- a/src/lib/tunnel/sandbox-gateway-stop.ts +++ b/src/lib/tunnel/sandbox-gateway-stop.ts @@ -180,7 +180,7 @@ function stopSandboxChannelsViaKubectl( pod, "--", "sh", - "-lc", + "-c", gatewayStopScript, ], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: 20000 }, diff --git a/test/agents/deepagents/dcode-login-profile.test.ts b/test/agents/deepagents/dcode-login-profile.test.ts index 0f397b84e1d..09ab540f79f 100644 --- a/test/agents/deepagents/dcode-login-profile.test.ts +++ b/test/agents/deepagents/dcode-login-profile.test.ts @@ -2,98 +2,54 @@ // 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"; +import { 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 sourcePath = path.resolve( + import.meta.dirname, + "../../../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 }; +// The live fresh-reonboard check owns Linux /etc/profile.d ordering against +// personal profiles. This test executes the hook's environment changes only. +function runHook(command: string) { + return spawnSync( + "/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + "-c", + '. "$1"; printf \'%s\\n\' "$HOME" "${BASH_ENV-unset}" "${ENV-unset}"; ' + command, + "dcode-login-hook-test", + sourcePath, + ], + { + encoding: "utf8", + env: { + PATH: process.env.PATH, + HOME: "/sandbox", + BASH_ENV: "/sandbox/.bashrc", + ENV: "/sandbox/.profile", + }, + }, + ); } -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, - }, - }, - ); +describe("managed DCode system login hook", () => { + it("selects the image-owned home and clears startup hooks for managed exec (#11256)", () => { + const result = runHook(": /usr/local/lib/nemoclaw/dcode-managed-exec"); expect(result.status).toBe(0); - expect(result.stdout).toBe("MANAGED_COMMAND_RAN\n"); + expect(result.stdout).toBe("/usr/local/lib/nemoclaw\nunset\nunset\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, - }, - }, - ); + it("leaves the agent's home and startup hooks unchanged for ordinary commands (#11256)", () => { + const result = runHook(":"); expect(result.status).toBe(0); - expect(result.stdout).toBe("preserved\n"); + expect(result.stdout).toBe("/sandbox\n/sandbox/.bashrc\n/sandbox/.profile\n"); expect(result.stderr).toBe(""); - expect(fs.existsSync(hookMarker)).toBe(false); - expect(fs.existsSync(fallbackMarker)).toBe(false); }); }); diff --git a/test/agents/deepagents/langchain-deepagents-code-image.test.ts b/test/agents/deepagents/langchain-deepagents-code-image.test.ts index 75dd46dac00..bfa342a4abe 100644 --- a/test/agents/deepagents/langchain-deepagents-code-image.test.ts +++ b/test/agents/deepagents/langchain-deepagents-code-image.test.ts @@ -320,32 +320,6 @@ 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/agents/hermes/hermes-start.test.ts b/test/agents/hermes/hermes-start.test.ts index 6d38e41a3a2..af341883645 100644 --- a/test/agents/hermes/hermes-start.test.ts +++ b/test/agents/hermes/hermes-start.test.ts @@ -759,9 +759,15 @@ function runRuntimeShellEnvBootstrap() { const caFile = path.join(tmpDir, "proxy ca.pem"); const hermesHome = path.join(tmpDir, ".hermes"); const scriptPath = path.join(tmpDir, "run.sh"); + const hermesPath = path.join(tmpDir, "hermes"); fs.mkdirSync(hermesHome, { recursive: true }); fs.writeFileSync(caFile, "ca"); + fs.writeFileSync( + hermesPath, + '#!/bin/sh\nprintf "arg:%s\\n" "$@"\nprintf "native diagnostic\\n" >&2\nexit 7\n', + { mode: 0o700 }, + ); const src = fs.readFileSync(START_SCRIPT, "utf-8"); fs.writeFileSync( @@ -799,8 +805,17 @@ function runRuntimeShellEnvBootstrap() { const guardResult = spawnSync("bash", ["-c", `. ${shellQuote(envFile)}; hermes setup`], { encoding: "utf-8", timeout: 5000, - env: { ...process.env, PATH: "/usr/bin:/bin" }, + env: { ...process.env, PATH: `${tmpDir}:/usr/bin:/bin` }, }); + const doctorResult = spawnSync( + "bash", + ["-c", `. ${shellQuote(envFile)}; hermes doctor --fix 'argument with spaces'`], + { + encoding: "utf-8", + timeout: 5000, + env: { ...process.env, PATH: `${tmpDir}:/usr/bin:/bin` }, + }, + ); const sourcedEnvResult = spawnSync( "bash", ["-c", `. ${shellQuote(envFile)}; printf '%s' "$SSL_CERT_FILE"`], @@ -817,6 +832,7 @@ function runRuntimeShellEnvBootstrap() { envFileContent, envFileMode, guardResult, + doctorResult, hermesHome, caFile, sourcedEnvResult, @@ -887,7 +903,7 @@ describe("agents/hermes/start.sh runtime shell env", () => { expect(preserved.stdout.trim()).toBe("/sandbox/.hermes/lazy-packages"); }); - it("puts the Hermes configure guard in the sourced proxy env file", () => { + it("passes native doctor through the runtime environment while denying setup", () => { const run = runRuntimeShellEnvBootstrap(); expect(run.result.status).toBe(0); @@ -909,9 +925,13 @@ describe("agents/hermes/start.sh runtime shell env", () => { expect(run.envFileContent).not.toContain(".profile"); expect(run.guardResult.status).toBe(1); + expect(run.guardResult.stdout).toBe(""); expect(run.guardResult.stderr).toContain( "Error: 'hermes setup' cannot modify config inside the sandbox.", ); + expect(run.doctorResult.status).toBe(7); + expect(run.doctorResult.stdout).toBe("arg:doctor\narg:--fix\narg:argument with spaces\n"); + expect(run.doctorResult.stderr).toBe("native diagnostic\n"); }); }); diff --git a/test/agents/openclaw/runtime/nemoclaw-start.test.ts b/test/agents/openclaw/runtime/nemoclaw-start.test.ts index c4826fbce9c..5181faa1571 100644 --- a/test/agents/openclaw/runtime/nemoclaw-start.test.ts +++ b/test/agents/openclaw/runtime/nemoclaw-start.test.ts @@ -3041,8 +3041,6 @@ describe("Telegram diagnostics (#2766)", () => { "export_gateway_token() { :; }", "write_messaging_runtime_setup_plan() { :; }", "write_runtime_shell_env() { :; }", - "ensure_runtime_shell_env_shim() { :; }", - "lock_rc_files() { :; }", "apply_messaging_runtime_env_aliases() { :; }", 'configure_messaging_channels() { echo "ORDER:configure"; }', `install_messaging_runtime_preloads() { : > ${JSON.stringify(preloadPath)}; chmod 444 ${JSON.stringify(preloadPath)}; }`, @@ -4237,10 +4235,8 @@ describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => it("runs the helper chain end-to-end against a simulated root entrypoint", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-direct-root-")); const configDir = path.join(tmpDir, "openclaw"); - const sandboxHome = path.join(tmpDir, "sandbox"); const proxyEnvFile = path.join(tmpDir, "nemoclaw-proxy-env.sh"); fs.mkdirSync(configDir, { recursive: true }); - fs.mkdirSync(sandboxHome, { recursive: true }); const configPath = path.join(configDir, "openclaw.json"); const hashPath = path.join(configDir, ".config-hash"); @@ -4251,11 +4247,6 @@ describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => fs.writeFileSync(hashPath, "placeholder\n"); fs.chmodSync(hashPath, 0o444); - const bashrcPath = path.join(sandboxHome, ".bashrc"); - const profilePath = path.join(sandboxHome, ".profile"); - fs.writeFileSync(bashrcPath, "# stub bashrc\n"); - fs.writeFileSync(profilePath, "# stub profile\n"); - const scriptPath = path.join(tmpDir, "run.sh"); const ensureHash = extractShellFunctionFromSource(src, "ensure_mutable_openclaw_config_hash") .replaceAll("/sandbox/.openclaw", configDir) @@ -4282,7 +4273,7 @@ describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => ); const exportToken = extractShellFunctionFromSource(src, "export_gateway_token"); const writeRuntimeStart = src.indexOf("write_runtime_shell_env() {"); - const writeRuntimeEnd = src.indexOf("\nensure_runtime_shell_env_shim() {", writeRuntimeStart); + const writeRuntimeEnd = src.indexOf("# cleanup_on_signal", writeRuntimeStart); if (writeRuntimeStart === -1 || writeRuntimeEnd === -1) { throw new Error("expected write_runtime_shell_env in scripts/nemoclaw-start.sh"); } @@ -4305,11 +4296,6 @@ describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => '_PROXY_URL=""', '_NO_PROXY_VAL=""', `STEP_DOWN_PREFIX_SANDBOX=(bash -c 'chmod 0660 ${JSON.stringify(hashPath)} 2>/dev/null; exec "$@"' sandbox-step-down)`, - "lock_rc_files() {", - ' for rc in "${1}/.bashrc" "${1}/.profile"; do', - ' [ -f "$rc" ] && chmod 0444 "$rc"', - " done", - "}", 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', "write_auth_profile() { :; }", "harden_auth_profiles() { :; }", @@ -4336,7 +4322,6 @@ describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => "prepare_gateway_token_for_current_command", "export_gateway_token", "write_runtime_shell_env", - `lock_rc_files ${JSON.stringify(sandboxHome)}`, "setup_auth_profile_as_sandbox", 'echo "CONTINUATION_REACHED"', ].join("\n"), @@ -4364,9 +4349,6 @@ describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => expect(proxyEnv).toMatch(/OPENCLAW_GATEWAY_TOKEN='[A-Za-z0-9_-]{20,}'/); expect(proxyEnv).toContain("export OPENCLAW_GATEWAY_TOKEN"); - expect((fs.statSync(bashrcPath).mode & 0o777).toString(8)).toBe("444"); - expect((fs.statSync(profilePath).mode & 0o777).toString(8)).toBe("444"); - const updatedConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")); expect(updatedConfig.gateway?.auth?.token).toMatch(/^[A-Za-z0-9_-]{20,}$/); expect(proxyEnv).toContain(`OPENCLAW_GATEWAY_TOKEN='${updatedConfig.gateway.auth.token}'`); diff --git a/test/automation/pull-requests/pr-risk-plan.test.ts b/test/automation/pull-requests/pr-risk-plan.test.ts index 99e9b0e55d7..4a150579c1f 100644 --- a/test/automation/pull-requests/pr-risk-plan.test.ts +++ b/test/automation/pull-requests/pr-risk-plan.test.ts @@ -1008,7 +1008,7 @@ describe("deterministic PR risk plan", () => { "tools/e2e/job-map.txt", "test/e2e/registry/runtime-support.ts", "test/e2e/risk-signal-reporter.ts", - "test/e2e/lib/security-posture-assertions.sh", + "test/e2e/fixtures/security-posture.ts", "test/e2e/lib/redact-text.py", "test/e2e/lib/fake-slack-api.cjs", "test/e2e/fixtures/runtime-input.txt", 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 72ec2aa0ba9..f192a0179ce 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 @@ -18,9 +18,10 @@ PREFIX="04-deepagents-code-fresh-reonboard" HOSTED_ENDPOINT="${NEMOCLAW_ENDPOINT_URL:-https://inference-api.nvidia.com/v1}" MODEL_SELECTOR="${REPO}/test/e2e/lib/select-authorized-chat-model.mts" CREDENTIAL_CANARY="nemoclaw-dcode-config-get-canary" -MANAGED_LOGIN_PROFILE="/sandbox/.bash_profile" +PERSONAL_LOGIN_PROFILE="/sandbox/.bash_profile" HOSTILE_LOGIN_FALLBACK="/sandbox/.bash_login" HOSTILE_PROFILE_MARKER="/sandbox/.nemoclaw-dcode-hostile-profile-loaded" +HOSTILE_SHELL_ENV="/sandbox/.nemoclaw-dcode-hostile-bash-env" fail() { printf '%s: FAIL: %s\n' "$PREFIX" "$1" >&2 @@ -35,11 +36,11 @@ sandbox_exec() { openshell sandbox exec --name "$SANDBOX_NAME" -- bash -c "$1" 2>&1 } -cleanup_hostile_login_fallback() { +cleanup_personal_profile_probe() { local resource_handle resource_handle="$(runtime_resource_handle)" || return 0 privileged_exec "$resource_handle" /bin/sh -c \ - "rm -f '$HOSTILE_LOGIN_FALLBACK' '$HOSTILE_PROFILE_MARKER'" \ + "rm -f '$PERSONAL_LOGIN_PROFILE' '$HOSTILE_LOGIN_FALLBACK' '$HOSTILE_PROFILE_MARKER' '$HOSTILE_SHELL_ENV'" \ >/dev/null 2>&1 || true } @@ -236,46 +237,41 @@ model_a="${model_a#openai:}" 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 +# Exercise the installed /etc/profile.d hook in real sandbox login shells. +# Managed probes must skip personal startup code; ordinary logins must read it. +trap cleanup_personal_profile_probe EXIT resource_handle="$(runtime_resource_handle)" || fail "could not resolve the DCode sandbox runtime resource" [ -n "$resource_handle" ] || fail "DCode sandbox runtime resource is empty" -managed_profile_state="$( +managed_hook_state="$( privileged_exec "$resource_handle" /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="$( - privileged_exec "$resource_handle" /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 + "stat -c '%U:%G:%a' /sandbox; stat -c '%U:%G:%a' /etc/profile.d/nemoclaw-dcode.sh" +)" || fail "could not inspect the managed DCode system hook" +expected_hook_state="$(printf '%s\n' root:sandbox:1775 root:root:444)" +[ "$managed_hook_state" = "$expected_hook_state" ] || fail "managed DCode system hook posture is unsafe: $managed_hook_state" + +for login_profile in "$PERSONAL_LOGIN_PROFILE" "$HOSTILE_LOGIN_FALLBACK"; do + profile_before="$(sandbox_exec "set -eu; test -w /sandbox/.bashrc; test -w /sandbox/.profile; printf '%s\n' 'touch $HOSTILE_PROFILE_MARKER' 'export NEMOCLAW_E2E_PERSONAL_PROFILE=loaded' > '$login_profile'; printf '%s\n' 'touch $HOSTILE_PROFILE_MARKER' > '$HOSTILE_SHELL_ENV'; sha256sum '$login_profile'")" \ + || fail "sandbox identity could not write its personal login profile" + managed_output="$( + openshell sandbox exec --name "$SANDBOX_NAME" -- \ + /usr/bin/env HOME=/sandbox BASH_ENV="$HOSTILE_SHELL_ENV" ENV="$HOSTILE_SHELL_ENV" \ + /bin/bash -lc '/usr/local/lib/nemoclaw/dcode-managed-exec /usr/bin/printf %s MANAGED_EXEC_OK' 2>&1 + )" || fail "managed exec failed with personal startup files present: $managed_output" + [ "$managed_output" = MANAGED_EXEC_OK ] || fail "managed exec output contains personal startup output: $managed_output" + privileged_exec "$resource_handle" /bin/sh -c "test ! -e '$HOSTILE_PROFILE_MARKER'" \ + || fail "personal login or BASH_ENV code ran before the managed exec" + + # shellcheck disable=SC2016 # Read the variable set by the sandbox's personal profile. + ordinary_output="$(openshell sandbox exec --name "$SANDBOX_NAME" -- /bin/bash -lc 'printf %s "$NEMOCLAW_E2E_PERSONAL_PROFILE"' 2>&1)" \ + || fail "ordinary login failed with a personal profile: $ordinary_output" + [ "$ordinary_output" = loaded ] || fail "ordinary login did not read its personal profile" + profile_after="$(sandbox_exec "set -eu; test -w '$login_profile'; sha256sum '$login_profile'")" \ + || fail "personal profile became unwritable after managed and ordinary commands" + [ "$profile_after" = "$profile_before" ] || fail "managed or ordinary login rewrote the personal profile" + cleanup_personal_profile_probe +done 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" +pass "system hook isolates managed exec while ordinary login preserves personal profiles" model_b="$( npx --no-install tsx "$MODEL_SELECTOR" \ diff --git a/test/e2e/fixtures/security-posture.ts b/test/e2e/fixtures/security-posture.ts index 0d1b1093629..64f0cc9bb37 100644 --- a/test/e2e/fixtures/security-posture.ts +++ b/test/e2e/fixtures/security-posture.ts @@ -51,7 +51,7 @@ export interface SplitProcessSecurityReport { export interface SecurityPostureSummary { configureGuard: true; hostNonRoot: true; - rcFilesLocked: true; + rcFilesMutable: true; runtimeProxyEnvLocked: true; splitProcess: { childSupervisor: ProcessSecurityIdentity; @@ -99,9 +99,10 @@ const OPENSHELL_SUPERVISOR_CAPABILITY_MASKS = Object.freeze({ }); function supervisorCapabilityMask(providerId: string): string { - const mask = OPENSHELL_SUPERVISOR_CAPABILITY_MASKS[ - providerId as keyof typeof OPENSHELL_SUPERVISOR_CAPABILITY_MASKS - ]; + const mask = + OPENSHELL_SUPERVISOR_CAPABILITY_MASKS[ + providerId as keyof typeof OPENSHELL_SUPERVISOR_CAPABILITY_MASKS + ]; if (!mask) { throw new Error(`security-posture has no reviewed capability mask for '${providerId}'`); } @@ -542,12 +543,9 @@ function validateSupervisor( } requireExactIds(process.status.uid, 0, "OpenShell supervisor Uid"); requireExactIds(process.status.gid, 0, "OpenShell supervisor Gid"); - requireExactSupplementaryGroups( - process.status.groups, - [0], - "OpenShell supervisor Groups", - [[0, sandboxGid]], - ); + requireExactSupplementaryGroups(process.status.groups, [0], "OpenShell supervisor Groups", [ + [0, sandboxGid], + ]); for (const field of ["capInh", "capPrm", "capEff", "capBnd", "capAmb"] as const) { requireCapabilityHex(process.status[field], `OpenShell supervisor ${field}`); } @@ -832,12 +830,15 @@ for f in /sandbox/.bashrc /sandbox/.profile; do test ! -L "$f" || { echo "SYMLINK $f"; bad=1; } set -- $(stat -c "%a %U:%G" "$f") echo "META $f $1 $2" - test "$1" = 444 || { echo "BAD_MODE $f $1"; bad=1; } - test "$2" = root:root || { echo "BAD_OWNER $f $2"; bad=1; } + test -w "$f" || { echo "NOT_WRITABLE $f"; bad=1; } + test "$2" = "$(id -un):$(id -gn)" || { echo "BAD_OWNER $f $2"; bad=1; } grep -Eq "nemoclaw-configure-guard|^(openclaw|hermes)\(\)" "$f" && { echo "INLINE_GUARD $f" bad=1 } + printf '\n# nemoclaw-e2e-personal-profile\n' >> "$f" && + cp "$f" "$f.nemoclaw-e2e" && mv "$f.nemoclaw-e2e" "$f" && + grep -qx '# nemoclaw-e2e-personal-profile' "$f" || { echo "EDIT_FAILED $f"; bad=1; } done exit "$bad" `), @@ -847,7 +848,7 @@ exit "$bad" timeoutMs: 30_000, }, ); - requireSuccess("locked sandbox rc files", rcFiles); + requireSuccess("agent-owned editable sandbox rc files", rcFiles); const functionName = agent === "hermes" ? "hermes" : "openclaw"; const guardArg = agent === "hermes" ? "setup" : "configure"; @@ -929,7 +930,7 @@ tail -n 20 "$log" return { configureGuard: true, hostNonRoot: true, - rcFilesLocked: true, + rcFilesMutable: true, runtimeProxyEnvLocked: true, splitProcess: { childSupervisor: selectNemoclawStartSupervisor(splitProcess.childSupervisors), diff --git a/test/e2e/lib/security-posture-assertions.sh b/test/e2e/lib/security-posture-assertions.sh deleted file mode 100755 index d4f73e0f681..00000000000 --- a/test/e2e/lib/security-posture-assertions.sh +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Shared assertions for full onboard tests that need to prove the Linux -# Docker-driver security posture that caught the Hermes rc-file startup bug. -# The caller provides the e2e `section`, `info`, `pass`, and `fail` functions. - -security_posture_sandbox_exec() { - local sandbox_name="$1" - local remote_cmd="$2" - openshell sandbox exec --name "$sandbox_name" -- sh -lc "$remote_cmd" 2>&1 -} - -security_posture_cap_absent() { - local cap_hex="$1" - local bit="$2" - local cap_name="$3" - local context="$4" - local cap_val - - cap_val=$((16#$cap_hex)) - if [ $(((cap_val >> bit) & 1)) -eq 0 ]; then - pass "${context}: ${cap_name} absent from CapBnd (0x${cap_hex})" - else - fail "${context}: ${cap_name} still present in CapBnd (0x${cap_hex})" - fi -} - -security_posture_assert_host_user() { - if [ "${NEMOCLAW_E2E_EXPECT_NON_ROOT_HOST:-}" != "1" ]; then - return 0 - fi - - local uid gid - uid="$(id -u)" - gid="$(id -g)" - if [ "$uid" -eq 0 ]; then - fail "Host test process is running as root; expected a non-root host user" - else - pass "Host test process is non-root (uid=${uid}, gid=${gid})" - fi -} - -security_posture_dangerous_caps_present() { - local cap_hex="$1" - local val entry bit name present_caps="" - - val=$((16#$cap_hex)) - for entry in \ - "21:CAP_SYS_ADMIN" \ - "19:CAP_SYS_PTRACE" \ - "13:CAP_NET_RAW" \ - "10:CAP_NET_BIND_SERVICE" \ - "1:CAP_DAC_OVERRIDE"; do - bit="${entry%%:*}" - name="${entry#*:}" - if [ $(((val >> bit) & 1)) -ne 0 ]; then - present_caps="${present_caps:+$present_caps,}$name" - fi - done - printf '%s\n' "$present_caps" -} - -security_posture_assert_entrypoint_process() { - local sandbox_name="$1" - local out cap_bnd cap_eff no_new_privs entry_uid present_caps - - out="$(security_posture_sandbox_exec "$sandbox_name" 'grep -E "^(Uid|Gid|CapBnd|CapEff|NoNewPrivs):" /proc/1/status 2>/dev/null || true')" || true - info "PID 1 status: ${out//$'\n'/; }" - entry_uid="$(printf '%s\n' "$out" | awk '/^Uid:/ { print $2; exit }')" - cap_bnd="$(printf '%s\n' "$out" | awk '/^CapBnd:/ { print $2; exit }')" - cap_eff="$(printf '%s\n' "$out" | awk '/^CapEff:/ { print $2; exit }')" - no_new_privs="$(printf '%s\n' "$out" | awk '/^NoNewPrivs:/ { print $2; exit }')" - - if [ "${NEMOCLAW_E2E_EXPECT_NON_ROOT_ENTRYPOINT:-}" = "1" ]; then - if [ -n "$entry_uid" ] && [ "$entry_uid" != "0" ]; then - pass "Entrypoint PID 1 is non-root inside the sandbox (uid=${entry_uid})" - else - fail "Entrypoint PID 1 expected non-root uid, got '${entry_uid:-}'" - fi - elif [ -n "$entry_uid" ]; then - info "Entrypoint PID 1 uid=${entry_uid}" - fi - - if [ -z "$cap_bnd" ]; then - fail "Could not capture PID 1 CapBnd from sandbox ${sandbox_name}: ${out:0:300}" - return 0 - fi - - if [ "${NEMOCLAW_E2E_EXPECT_DROPPED_BOUNDS:-}" = "1" ]; then - security_posture_cap_absent "$cap_bnd" 21 CAP_SYS_ADMIN "Entrypoint PID 1" - security_posture_cap_absent "$cap_bnd" 19 CAP_SYS_PTRACE "Entrypoint PID 1" - security_posture_cap_absent "$cap_bnd" 13 CAP_NET_RAW "Entrypoint PID 1" - security_posture_cap_absent "$cap_bnd" 10 CAP_NET_BIND_SERVICE "Entrypoint PID 1" - security_posture_cap_absent "$cap_bnd" 1 CAP_DAC_OVERRIDE "Entrypoint PID 1" - else - present_caps="$(security_posture_dangerous_caps_present "$cap_bnd")" - if [ -n "$present_caps" ]; then - info "Entrypoint PID 1 residual CapBnd dangerous caps: ${present_caps}" - else - pass "Entrypoint PID 1 dangerous caps are absent from CapBnd" - fi - fi - - if [ -n "$cap_eff" ]; then - present_caps="$(security_posture_dangerous_caps_present "$cap_eff")" - if [ -n "$present_caps" ]; then - info "Entrypoint PID 1 residual CapEff dangerous caps: ${present_caps}" - else - pass "Entrypoint PID 1 dangerous caps are absent from CapEff" - fi - fi - - if [ "${NEMOCLAW_E2E_EXPECT_NO_NEW_PRIVS:-}" = "1" ]; then - if [ "$no_new_privs" = "1" ]; then - pass "Entrypoint PID 1 has NoNewPrivs=1" - else - fail "Entrypoint PID 1 expected NoNewPrivs=1, got '${no_new_privs:-}'" - fi - elif [ -n "$no_new_privs" ]; then - info "Entrypoint PID 1 NoNewPrivs=${no_new_privs}" - fi -} - -security_posture_assert_rc_files() { - local sandbox_name="$1" - local out rc - - rc=0 - # shellcheck disable=SC2016 # Remote shell snippet; expansion must happen inside the sandbox. - out="$(security_posture_sandbox_exec "$sandbox_name" 'bad=0; for f in /sandbox/.bashrc /sandbox/.profile; do if [ ! -f "$f" ]; then echo "MISSING $f"; bad=1; continue; fi; if [ -L "$f" ]; then echo "SYMLINK $f"; bad=1; fi; meta=$(stat -c "%a %U:%G" "$f" 2>/dev/null || true); echo "META $f $meta"; set -- $meta; mode="${1:-}"; owner="${2:-}"; if [ "$mode" != "444" ]; then echo "BAD_MODE $f $mode"; bad=1; fi; if [ "$owner" != "root:root" ]; then echo "BAD_OWNER $f $owner"; bad=1; fi; if grep -Eq "nemoclaw-configure-guard|^(openclaw|hermes)\(\)" "$f" 2>/dev/null; then echo "INLINE_GUARD $f"; bad=1; fi; done; exit "$bad"')" || rc=$? - info "rc-file metadata: ${out//$'\n'/; }" - if [ "$rc" -eq 0 ]; then - pass "Sandbox rc files are static root-owned 444 shims without inline configure guards" - else - fail "Sandbox rc files are not locked/static as expected: ${out:0:500}" - fi -} - -security_posture_assert_proxy_env() { - local sandbox_name="$1" - local agent_name="$2" - local function_name guard_arg out rc allow_non_root_owner - - case "$agent_name" in - hermes) - function_name="hermes" - guard_arg="setup" - ;; - *) - function_name="openclaw" - guard_arg="configure" - ;; - esac - - allow_non_root_owner=0 - if [ "${NEMOCLAW_E2E_EXPECT_NON_ROOT_HOST:-}" = "1" ]; then - # OpenShell's non-root host posture creates the runtime proxy-env file - # after dropping to the sandbox user. Keep root ownership required in - # normal lanes, but accept current-user ownership for that explicit lane. - allow_non_root_owner=1 - fi - - rc=0 - out="$(security_posture_sandbox_exec "$sandbox_name" "f=/tmp/nemoclaw-proxy-env.sh; allow_non_root_owner=${allow_non_root_owner}; bad=0; if [ ! -f \"\$f\" ]; then echo MISSING_PROXY_ENV; exit 1; fi; if [ -L \"\$f\" ]; then echo SYMLINK_PROXY_ENV; bad=1; fi; meta=\$(stat -c \"%a %U:%G\" \"\$f\" 2>/dev/null || true); echo \"META \$f \$meta\"; set -- \$meta; mode=\"\${1:-}\"; owner=\"\${2:-}\"; current_owner=\"\$(id -un):\$(id -gn)\"; if [ \"\$mode\" != \"444\" ]; then echo \"BAD_PROXY_ENV_MODE \$mode\"; bad=1; fi; case \"\$owner\" in root:root) ;; \"\$current_owner\") if [ \"\$allow_non_root_owner\" = \"1\" ]; then echo \"NON_ROOT_PROXY_ENV_OWNER \$owner\"; else echo \"BAD_PROXY_ENV_OWNER \$owner\"; bad=1; fi ;; *) echo \"BAD_PROXY_ENV_OWNER \$owner\"; bad=1 ;; esac; grep -Fq '# nemoclaw-configure-guard begin' \"\$f\" || { echo MISSING_GUARD_BEGIN; bad=1; }; grep -Fq '${function_name}() {' \"\$f\" || { echo MISSING_AGENT_GUARD_FUNCTION; bad=1; }; grep -Fq '# nemoclaw-configure-guard end' \"\$f\" || { echo MISSING_GUARD_END; bad=1; }; exit \"\$bad\"")" || rc=$? - info "runtime proxy-env metadata: ${out//$'\n'/; }" - if [ "$rc" -eq 0 ]; then - pass "Runtime proxy env is mode 444 with an accepted owner and carries the ${function_name} configure guard" - else - fail "Runtime proxy env is not locked or missing guard content: ${out:0:500}" - fi - - rc=0 - out="$(security_posture_sandbox_exec "$sandbox_name" ". /tmp/nemoclaw-proxy-env.sh || { echo SOURCE_FAILED; exit 1; }; if ${function_name} ${guard_arg} >/tmp/nemoclaw-security-guard-probe.out 2>&1; then echo GUARD_DID_NOT_BLOCK; cat /tmp/nemoclaw-security-guard-probe.out; exit 1; fi; cat /tmp/nemoclaw-security-guard-probe.out; grep -q 'cannot modify config inside the sandbox' /tmp/nemoclaw-security-guard-probe.out || { echo GUARD_MESSAGE_MISSING; exit 1; }")" || rc=$? - info "configure guard probe: ${out//$'\n'/; }" - if [ "$rc" -eq 0 ]; then - pass "${function_name} ${guard_arg} is blocked by the runtime guard after sourcing proxy-env" - else - fail "Runtime configure guard did not behave as expected: ${out:0:500}" - fi -} - -security_posture_assert_start_log() { - local sandbox_name="$1" - local agent_name="$2" - local out rc launch_pattern - - case "$agent_name" in - hermes) launch_pattern='hermes gateway launched' ;; - *) launch_pattern='openclaw gateway launched' ;; - esac - - rc=0 - out="$(security_posture_sandbox_exec "$sandbox_name" "log=/tmp/nemoclaw-start.log; bad=0; [ -f \"\$log\" ] || { echo MISSING_START_LOG; exit 1; }; if ! grep -qi '${launch_pattern}' \"\$log\"; then echo MISSING_GATEWAY_LAUNCH_MARKER; bad=1; fi; if grep -E 'mktemp:.*(/sandbox/\\.\\.(bashrc|profile)\\.tmp|/sandbox/\\.nemoclaw.*tmp)|Permission denied.*(/sandbox/\\.bashrc|/sandbox/\\.profile)' \"\$log\"; then echo START_LOG_HAS_RC_WRITE_FAILURE; bad=1; fi; tail -n 20 \"\$log\"; exit \"\$bad\"")" || rc=$? - info "start log probe: ${out//$'\n'/; }" - if [ "$rc" -eq 0 ]; then - pass "Startup log has no rc-file mktemp/permission failure" - else - fail "Startup log shows the rc-file write failure class: ${out:0:500}" - fi -} - -security_posture_assertions_run() { - local sandbox_name="$1" - local agent_name="${2:-openclaw}" - - section "Security posture regression checks" - security_posture_assert_host_user - security_posture_assert_entrypoint_process "$sandbox_name" - security_posture_assert_rc_files "$sandbox_name" - security_posture_assert_proxy_env "$sandbox_name" "$agent_name" - security_posture_assert_start_log "$sandbox_name" "$agent_name" -} diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index 03809ccb80a..9ab5c8107cc 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -455,21 +455,28 @@ test( timeoutMs: 30_000, }); expect(hermesVersion.exitCode, resultText(hermesVersion)).toBe(0); - expect(resultText(hermesVersion)).not.toMatch(/MISSING|not found|No such file/i); - const configProbe = await sandbox.execShell( + // Observe the first native diagnostic before editing profiles or repairing + // anything. Other doctor findings remain visible for their owning issues. + const nativeDoctor = await sandbox.exec(SANDBOX_NAME, ["bash", "-lc", "hermes doctor"], { + artifactName: "phase-3-first-native-hermes-doctor", + env: commandEnv(), + timeoutMs: 180_000, + }); + expect(nativeDoctor.exitCode, resultText(nativeDoctor)).toBe(0); + + const profilesBeforeRecovery = await sandbox.execShell( SANDBOX_NAME, trustedSandboxShellScript( - "test -f /sandbox/.hermes/config.yaml && test -d /sandbox/.hermes && touch /sandbox/.hermes/test-write && rm -f /sandbox/.hermes/test-write && echo OK", + "set -eu; for f in /sandbox/.bashrc /sandbox/.profile; do printf '\\n# nemoclaw-e2e-profile-preserved\\n' >> \"$f\"; done; sha256sum /sandbox/.bashrc /sandbox/.profile > /tmp/nemoclaw-e2e-profiles.sha256", ), { - artifactName: "phase-3-hermes-config-state", + artifactName: "phase-3-personal-profiles-before-recovery", env: commandEnv(), timeoutMs: 30_000, }, ); - expect(configProbe.exitCode, resultText(configProbe)).toBe(0); - expect(configProbe.stdout).toContain("OK"); + expect(profilesBeforeRecovery.exitCode, resultText(profilesBeforeRecovery)).toBe(0); await assertHermesSkillLifecycle({ env: commandEnv(), @@ -745,6 +752,17 @@ test( ); } + const personalProfiles = await sandbox.exec( + SANDBOX_NAME, + ["/usr/bin/sha256sum", "-c", "/tmp/nemoclaw-e2e-profiles.sha256"], + { + artifactName: "phase-4-personal-profiles-after-recovery", + env: commandEnv(), + timeoutMs: 30_000, + }, + ); + expect(personalProfiles.exitCode, resultText(personalProfiles)).toBe(0); + const recoveredHealth = await host.command( "curl", ["-sf", "--max-time", "10", HERMES_HOST_HEALTH_URL], diff --git a/test/e2e/live/pi-agent-qualification.test.ts b/test/e2e/live/pi-agent-qualification.test.ts index 55cfe6421ea..c77a42a71bb 100644 --- a/test/e2e/live/pi-agent-qualification.test.ts +++ b/test/e2e/live/pi-agent-qualification.test.ts @@ -319,9 +319,8 @@ test( }); progress.phase("validate the exact Pi candidate receipt"); - expect(receipt.contract.agent).toBe("pi"); - expect(receipt.contract.platform).toBe(platform); - expect(receipt.contract.source.repository).toBe("NVIDIA/NemoClaw"); + // readPiQualificationReceipt already validates these fields through the + // managed-image contract parser; this lane proves source and runtime parity. const piDockerfiles = ["agents/pi/Dockerfile", "agents/pi/Dockerfile.base"]; const copiedSources = piDockerfiles.flatMap((dockerfile) => directDockerfileCopySources(path.join(REPO_ROOT, dockerfile), dockerfile).map( @@ -407,9 +406,26 @@ test( const rebuildProof = await runReadTask(artifacts, host, sandbox, env, "after-rebuild"); progress.phase("recover Pi after a gateway restart"); + const personalProfiles = await execPiShell( + sandbox, + trustedSandboxShellScript( + "set -eu; for f in /sandbox/.bashrc /sandbox/.profile; do printf '\\nexport NEMOCLAW_E2E_PI_PROFILE=preserved\\n' >> \"$f\"; done; sha256sum /sandbox/.bashrc /sandbox/.profile", + ), + { artifactName: "pi-personal-profiles-before-recovery", env, timeoutMs: 30_000 }, + ); + expect(personalProfiles.exitCode, resultText(personalProfiles)).toBe(0); await lifecycle.restartGatewayRuntime({ delayMs: 2_000, sandboxName: SANDBOX_NAME }); await lifecycle.waitForGatewayConnected({ attempts: 60, intervalMs: 5_000 }); const recoveryProof = await runReadTask(artifacts, host, sandbox, env, "after-recovery"); + const profilesAfterRecovery = await execPiShell( + sandbox, + trustedSandboxShellScript( + "set -eu; bash -lc 'test \"$NEMOCLAW_E2E_PI_PROFILE\" = preserved'; sha256sum /sandbox/.bashrc /sandbox/.profile", + ), + { artifactName: "pi-personal-profiles-after-recovery", env, timeoutMs: 30_000 }, + ); + expect(profilesAfterRecovery.exitCode, resultText(profilesAfterRecovery)).toBe(0); + expect(profilesAfterRecovery.stdout).toBe(personalProfiles.stdout); progress.phase("prove Pi policy and credential boundaries"); const security = await sandbox.exec(SANDBOX_NAME, ["node", "-e", SECURITY_PROBE], { diff --git a/test/e2e/support/security-posture.test.ts b/test/e2e/support/security-posture.test.ts index 4e2afe4168e..5e0b6034825 100644 --- a/test/e2e/support/security-posture.test.ts +++ b/test/e2e/support/security-posture.test.ts @@ -985,7 +985,7 @@ describe("security posture fixture", () => { expect(summary).toEqual({ configureGuard: true, hostNonRoot: true, - rcFilesLocked: true, + rcFilesMutable: true, runtimeProxyEnvLocked: true, splitProcess: { childSupervisor: directChildSupervisor, diff --git a/test/runtime/gateway/service-env.test.ts b/test/runtime/gateway/service-env.test.ts index 84900dfcf77..30e28439738 100644 --- a/test/runtime/gateway/service-env.test.ts +++ b/test/runtime/gateway/service-env.test.ts @@ -7,7 +7,6 @@ import { execSync, } from "node:child_process"; import { - chmodSync, existsSync, lstatSync, mkdirSync, @@ -32,12 +31,6 @@ const ENTRYPOINT_ENV_WRAPPER = join( "lib", "entrypoint-env-wrapper.sh", ); -const RC_CLEAN_SCRIPT = join(import.meta.dirname, "..", "..", "../scripts/lib/clean_runtime_shell_env_shim.py"); - -function rcShimWrapperHeader(): string { - return `export NEMOCLAW_RC_CLEAN_SCRIPT=${JSON.stringify(RC_CLEAN_SCRIPT)}`; -} - function extractRuntimeShellEnvSnippet() { const src = readFileSync(NEMOCLAW_START_SCRIPT, "utf-8"); const start = src.indexOf("write_runtime_shell_env() {"); @@ -74,19 +67,6 @@ function extractOpenClawBootstrapEnvSnippet() { return `${entrypoint}\n${src.slice(environmentStart, environmentEnd).trimEnd()}`; } -function extractRuntimeShellEnvShimSnippet() { - const src = readFileSync(NEMOCLAW_START_SCRIPT, "utf-8"); - const start = src.indexOf("ensure_runtime_shell_env_shim() {"); - const end = src.indexOf("# ── Legacy layout migration", start); - if (start === -1 || end === -1 || end <= start) { - throw new Error( - "Failed to extract ensure_runtime_shell_env_shim from scripts/nemoclaw-start.sh — " + - "the rc shim helper may have been moved or renamed", - ); - } - return `${src.slice(start, end).trimEnd()}\nensure_runtime_shell_env_shim`; -} - function extractToolRedirectsSnippet() { const src = readFileSync(NEMOCLAW_START_SCRIPT, "utf-8"); const start = src.indexOf("_TOOL_REDIRECTS=("); @@ -177,7 +157,6 @@ describe("service environment", () => { }); }); - describe("SANDBOX_NAME defaulting", () => { it("start-services.sh preserves existing SANDBOX_NAME", () => { const result = execSync( @@ -675,375 +654,6 @@ describe("service environment", () => { }, ); - it.each([".bashrc", ".profile"])( - "removes legacy proxy-env.sh source shims from sandbox user rc files [%s]", - (rcName) => { - const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-shim-test-")); - const proxyEnvPath = join(fakeHome, "proxy-env.sh"); - const tmpFile = join(fakeHome, "rc-shim-write-test.sh"); - try { - writeFileSync( - join(fakeHome, ".bashrc"), - [ - "# old bashrc", - "# Source runtime proxy config", - `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`, - "export PATH=/usr/local/bin:$PATH", - "", - ].join("\n"), - { mode: 0o644 }, - ); - writeFileSync( - join(fakeHome, ".profile"), - [ - "# old profile", - "# Source runtime proxy config", - `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`, - "umask 022", - "", - ].join("\n"), - { mode: 0o444 }, - ); - - const wrapper = [ - "#!/usr/bin/env bash", - `_SANDBOX_HOME=${JSON.stringify(fakeHome)}`, - `_RUNTIME_SHELL_ENV_FILE=${JSON.stringify(proxyEnvPath)}`, - '_RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL_ENV_FILE}"', - rcShimWrapperHeader(), - extractRuntimeShellEnvShimSnippet(), - "ensure_runtime_shell_env_shim", - ].join("\n"); - writeFileSync(tmpFile, wrapper, { mode: 0o700 }); - execFileSync("bash", [tmpFile], { encoding: "utf-8" }); - - const rcFile = readFileSync(join(fakeHome, rcName), "utf-8"); - expect(rcFile.toLowerCase()).not.toContain("proxy"); - expect(rcFile).not.toContain(proxyEnvPath); - expect(rcFile).toContain(rcName === ".bashrc" ? "export PATH" : "umask 022"); - } finally { - try { - unlinkSync(tmpFile); - } catch { - /* ignore */ - } - try { - rmSync(fakeHome, { recursive: true, force: true }); - } catch { - /* ignore */ - } - } - }, - ); - - it("does not follow pre-planted legacy rc cleanup temp symlinks", () => { - const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-shim-symlink-test-")); - const proxyEnvPath = join(fakeHome, "proxy-env.sh"); - const rcPath = join(fakeHome, ".bashrc"); - const sensitivePath = join(fakeHome, "sensitive"); - const tmpFile = join(fakeHome, "rc-shim-symlink-test.sh"); - try { - writeFileSync( - rcPath, - [ - "# old bashrc", - "# Source runtime proxy config", - `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`, - "export PATH=/usr/local/bin:$PATH", - "", - ].join("\n"), - { mode: 0o644 }, - ); - writeFileSync(sensitivePath, "SECRET\n", { mode: 0o600 }); - - const wrapper = [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `_SANDBOX_HOME=${JSON.stringify(fakeHome)}`, - `_RUNTIME_SHELL_ENV_FILE=${JSON.stringify(proxyEnvPath)}`, - '_RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL_ENV_FILE}"', - 'legacy_tmp="${_SANDBOX_HOME}/.bashrc.nemoclaw-clean.$$"', - `ln -s ${JSON.stringify(sensitivePath)} "$legacy_tmp"`, - rcShimWrapperHeader(), - extractRuntimeShellEnvShimSnippet(), - "ensure_runtime_shell_env_shim", - ].join("\n"); - writeFileSync(tmpFile, wrapper, { mode: 0o700 }); - execFileSync("bash", [tmpFile], { encoding: "utf-8" }); - - expect(readFileSync(sensitivePath, "utf-8")).toBe("SECRET\n"); - const rcFile = readFileSync(rcPath, "utf-8"); - expect(rcFile.toLowerCase()).not.toContain("proxy"); - expect(rcFile).not.toContain(proxyEnvPath); - expect(rcFile).toContain("export PATH"); - } finally { - try { - unlinkSync(tmpFile); - } catch { - /* ignore */ - } - try { - rmSync(fakeHome, { recursive: true, force: true }); - } catch { - /* ignore */ - } - } - }); - - it("cleans rc shims without shell chown/chmod on the rc path", () => { - const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-shim-no-path-chmod-test-")); - const proxyEnvPath = join(fakeHome, "proxy-env.sh"); - const rcPath = join(fakeHome, ".bashrc"); - const tmpFile = join(fakeHome, "rc-shim-no-path-chmod-test.sh"); - try { - writeFileSync( - rcPath, - [ - "# old bashrc", - "# Source runtime proxy config", - `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`, - "", - ].join("\n"), - { mode: 0o644 }, - ); - - const wrapper = [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `_SANDBOX_HOME=${JSON.stringify(fakeHome)}`, - `_RUNTIME_SHELL_ENV_FILE=${JSON.stringify(proxyEnvPath)}`, - '_RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL_ENV_FILE}"', - 'chown() { echo "unexpected chown $*" >&2; exit 42; }', - 'chmod() { echo "unexpected chmod $*" >&2; exit 43; }', - rcShimWrapperHeader(), - extractRuntimeShellEnvShimSnippet(), - ].join("\n"); - writeFileSync(tmpFile, wrapper, { mode: 0o700 }); - execFileSync("bash", [tmpFile], { encoding: "utf-8" }); - - const rcFile = readFileSync(rcPath, "utf-8"); - expect(rcFile.toLowerCase()).not.toContain("proxy"); - expect(rcFile).not.toContain(proxyEnvPath); - } finally { - try { - unlinkSync(tmpFile); - } catch { - /* ignore */ - } - try { - rmSync(fakeHome, { recursive: true, force: true }); - } catch { - /* ignore */ - } - } - }); - - it("does not rewrite locked clean rc files", () => { - const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-shim-clean-locked-test-")); - const proxyEnvPath = join(fakeHome, "proxy-env.sh"); - const rcPath = join(fakeHome, ".bashrc"); - const profilePath = join(fakeHome, ".profile"); - const tmpFile = join(tmpdir(), `rc-shim-clean-locked-test-${process.pid}.sh`); - try { - writeFileSync(rcPath, "# clean bashrc\n", { mode: 0o444 }); - writeFileSync(profilePath, "# clean profile\n", { mode: 0o444 }); - chmodSync(fakeHome, 0o555); - - const wrapper = [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `_SANDBOX_HOME=${JSON.stringify(fakeHome)}`, - `_RUNTIME_SHELL_ENV_FILE=${JSON.stringify(proxyEnvPath)}`, - '_RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL_ENV_FILE}"', - rcShimWrapperHeader(), - extractRuntimeShellEnvShimSnippet(), - "ensure_runtime_shell_env_shim", - ].join("\n"); - writeFileSync(tmpFile, wrapper, { mode: 0o700 }); - execFileSync("bash", [tmpFile], { encoding: "utf-8" }); - - expect(readFileSync(rcPath, "utf-8")).toBe("# clean bashrc\n"); - expect(readFileSync(profilePath, "utf-8")).toBe("# clean profile\n"); - } finally { - try { - chmodSync(fakeHome, 0o755); - } catch { - /* ignore */ - } - try { - unlinkSync(tmpFile); - } catch { - /* ignore */ - } - try { - rmSync(fakeHome, { recursive: true, force: true }); - } catch { - /* ignore */ - } - } - }); - - const itOnProcFd = existsSync("/proc/self/fd") ? it : it.skip; - itOnProcFd("removes legacy rc shims without directory write permission", () => { - const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-shim-unwritable-dir-test-")); - const proxyEnvPath = join(fakeHome, "proxy-env.sh"); - const rcPath = join(fakeHome, ".bashrc"); - const profilePath = join(fakeHome, ".profile"); - const tmpFile = join(tmpdir(), `rc-shim-unwritable-dir-test-${process.pid}.sh`); - try { - for (const rcPathToWrite of [rcPath, profilePath]) { - writeFileSync( - rcPathToWrite, - [ - "# old rc", - "# Source runtime proxy config", - `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`, - "export PATH=/usr/local/bin:$PATH", - "", - ].join("\n"), - { mode: 0o444 }, - ); - } - chmodSync(fakeHome, 0o555); - - const wrapper = [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `_SANDBOX_HOME=${JSON.stringify(fakeHome)}`, - `_RUNTIME_SHELL_ENV_FILE=${JSON.stringify(proxyEnvPath)}`, - '_RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL_ENV_FILE}"', - rcShimWrapperHeader(), - extractRuntimeShellEnvShimSnippet(), - "ensure_runtime_shell_env_shim", - ].join("\n"); - writeFileSync(tmpFile, wrapper, { mode: 0o700 }); - execFileSync("bash", [tmpFile], { encoding: "utf-8" }); - - for (const rcPathToRead of [rcPath, profilePath]) { - const rcFile = readFileSync(rcPathToRead, "utf-8"); - expect(rcFile.toLowerCase()).not.toContain("proxy"); - expect(rcFile).not.toContain(proxyEnvPath); - expect(rcFile).toContain("export PATH"); - } - } finally { - try { - chmodSync(fakeHome, 0o755); - } catch { - /* ignore */ - } - try { - unlinkSync(tmpFile); - } catch { - /* ignore */ - } - try { - rmSync(fakeHome, { recursive: true, force: true }); - } catch { - /* ignore */ - } - } - }); - - // Composed startup invariant: write_runtime_shell_env emits the proxy - // env file with mode 444, ensure_runtime_shell_env_shim then sees a - // foreign-owned .bashrc and must exit 0 (otherwise the entrypoint would - // terminate the container with exit code 1). The composed assertion - // proves the legacy trust-boundary file remains non-user-writable - // across the skip path. - it("composed startup leaves the proxy env file at mode 444 when the rc cleanup skips a foreign-owned rc file", () => { - const fakeDataDir = mkdtempSync(join(tmpdir(), "nemoclaw-rc-skip-composed-")); - const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-skip-home-")); - const proxyEnvPath = join(fakeDataDir, "proxy-env.sh"); - const rcPath = join(fakeHome, ".bashrc"); - const tmpFile = join(tmpdir(), `nemoclaw-rc-skip-composed-${process.pid}.sh`); - const isolatedSandboxInitPath = join(fakeDataDir, "sandbox-init.sh"); - const isolatedSandboxEnv = { - ...process.env, - ISOLATED_SANDBOX_INIT: isolatedSandboxInitPath, - NEMOCLAW_TEST_AUTO_PAIR_LOG: join(fakeDataDir, "auto-pair.log"), - NEMOCLAW_TEST_GATEWAY_LOG: join(fakeDataDir, "gateway.log"), - PLUGIN_REFRESH_LOG: join(fakeDataDir, "nemoclaw-plugin-refresh.log"), - }; - try { - const sandboxLibDir = join(import.meta.dirname, "..", "..", "../scripts/lib"); - const sandboxInitFixture = readFileSync(join(sandboxLibDir, "sandbox-init.sh"), "utf-8") - .replaceAll("/tmp/gateway.log", '"${NEMOCLAW_TEST_GATEWAY_LOG}"') - .replaceAll("/tmp/auto-pair.log", '"${NEMOCLAW_TEST_AUTO_PAIR_LOG}"'); - writeFileSync(isolatedSandboxInitPath, sandboxInitFixture, { mode: 0o600 }); - writeFileSync( - join(fakeDataDir, "sandbox-rlimits.sh"), - readFileSync(join(sandboxLibDir, "sandbox-rlimits.sh"), "utf-8"), - { mode: 0o600 }, - ); - - const shimLine = `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`; - const originalBashrc = [ - "# user-managed bashrc owned by a foreign uid (e.g. root)", - "# Source runtime proxy config", - shimLine, - "export PATH=/usr/local/bin:$PATH", - "", - ].join("\n"); - writeFileSync(rcPath, originalBashrc, { mode: 0o644 }); - - const persistBlock = extractRuntimeShellEnvSnippet() - .trimEnd() - .replaceAll("/tmp/nemoclaw-proxy-env.sh", proxyEnvPath); - // Foreign uid that does not match the test-runner's actual file owner. - // Overriding `id -u` for the bash function-level shim invocation is - // the cheapest way to drive the "uid != owner" branch without root. - const foreignUid = (process.getuid?.() ?? 1000) + 99999; - const wrapper = [ - "#!/usr/bin/env bash", - "set -euo pipefail", - 'source "$ISOLATED_SANDBOX_INIT"', - 'PROXY_HOST="10.200.0.1"', - 'PROXY_PORT="3128"', - '_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}"', - '_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}"', - "_TOOL_REDIRECTS=()", - `_AXIOS_FIX_SCRIPT="/nonexistent/axios-proxy-fix.js"`, - `_SANDBOX_HOME=${JSON.stringify(fakeHome)}`, - `_RUNTIME_SHELL_ENV_FILE=${JSON.stringify(proxyEnvPath)}`, - `_RUNTIME_SHELL_ENV_SHIM="[ -f \${_RUNTIME_SHELL_ENV_FILE} ] && . \${_RUNTIME_SHELL_ENV_FILE}"`, - rcShimWrapperHeader(), - // Override `id -u` BEFORE the entrypoint snippets are sourced so the - // function-shadow is in place when both write_runtime_shell_env and - // ensure_runtime_shell_env_shim consult `$(id -u)`. - `id() { case "\${1:-}" in -u) echo ${foreignUid};; *) command id "$@";; esac; }`, - "set +u", - persistBlock, - extractRuntimeShellEnvShimSnippet(), - "validate_tmp_permissions " + JSON.stringify(proxyEnvPath), - ].join("\n"); - writeFileSync(tmpFile, wrapper, { mode: 0o700 }); - const result = execFileSync("bash", [tmpFile], { - encoding: "utf-8", - env: isolatedSandboxEnv, - }); - - expect(result).not.toContain("[SECURITY] " + proxyEnvPath + " has unsafe permissions"); - - const finalMode = (lstatSync(proxyEnvPath).mode & 0o777).toString(8); - expect(finalMode).toBe("444"); - - const rcAfter = readFileSync(rcPath, "utf-8"); - expect(rcAfter).toBe(originalBashrc); - } finally { - try { - unlinkSync(tmpFile); - } catch { - /* ignore */ - } - try { - rmSync(fakeDataDir, { recursive: true, force: true }); - rmSync(fakeHome, { recursive: true, force: true }); - } catch { - /* ignore */ - } - } - }); - it("entrypoint overwrites proxy-env.sh cleanly on repeated invocations", () => { const fakeDataDir = join(tmpdir(), `nemoclaw-idempotent-test-${process.pid}`); mkdirSync(fakeDataDir, { recursive: true }); diff --git a/test/runtime/sandbox/clean-runtime-shell-env-shim.test.ts b/test/runtime/sandbox/clean-runtime-shell-env-shim.test.ts deleted file mode 100644 index db9d8983d5e..00000000000 --- a/test/runtime/sandbox/clean-runtime-shell-env-shim.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -// 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, beforeEach, describe, expect, it } from "vitest"; - -const CLEAN_SCRIPT = path.join( - import.meta.dirname, - "..", - "..", - "..", - "scripts", - "lib", - "clean_runtime_shell_env_shim.py", -); -const SHIM_TEXT = "[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh"; -const CURRENT_UID = process.getuid?.() ?? 0; - -function runScript(args: { rcPath: string; shim: string; uid: number }): { - status: number | null; - stderr: string; - stdout: string; -} { - const result = spawnSync("python3", [CLEAN_SCRIPT, args.rcPath, args.shim, String(args.uid)], { - encoding: "utf-8", - }); - return { - status: result.status, - stderr: result.stderr ?? "", - stdout: result.stdout ?? "", - }; -} - -describe("clean_runtime_shell_env_shim.py", () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rc-test-")); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it("removes the legacy two-line stanza when uid owns the rc file", () => { - const rcPath = path.join(tmpDir, ".bashrc"); - const before = `export FOO=1\n# Source runtime proxy config\n${SHIM_TEXT}\nexport BAR=2\n`; - fs.writeFileSync(rcPath, before, { mode: 0o644 }); - - const result = runScript({ rcPath, shim: SHIM_TEXT, uid: CURRENT_UID }); - - expect(result.status).toBe(0); - const after = fs.readFileSync(rcPath, "utf-8"); - expect(after).toBe("export FOO=1\nexport BAR=2\n"); - }); - - it("leaves the rc file untouched and exits 0 when the entrypoint uid does not own it", () => { - // When the entrypoint runs as a non-root uid against an rc file owned by - // a different uid (e.g. root-owned legacy .bashrc), the pre-fix cleanup - // raised EPERM under errexit and killed the container. The ownership - // guard now logs and exits 0 instead. - const rcPath = path.join(tmpDir, ".bashrc"); - const before = `# Source runtime proxy config\n${SHIM_TEXT}\nexport REAL_USER_LINE=keep\n`; - fs.writeFileSync(rcPath, before, { mode: 0o644 }); - - // Mismatched uid: pretend we are running as a foreign uid against a file - // owned by the test runner. Real container repro uses uid=1000 vs root. - const foreignUid = CURRENT_UID + 99999; - const result = runScript({ rcPath, shim: SHIM_TEXT, uid: foreignUid }); - - expect(result.status).toBe(0); - expect(result.stderr).toContain("[SECURITY] skipping rc cleanup"); - expect(result.stderr).toContain(`file uid=${CURRENT_UID}`); - expect(result.stderr).toContain(`uid=${foreignUid}`); - - const after = fs.readFileSync(rcPath, "utf-8"); - expect(after).toBe(before); - }); - - it("exits 0 without rewriting when the rc file is already clean", () => { - const rcPath = path.join(tmpDir, ".bashrc"); - const before = "export FOO=1\nexport BAR=2\n"; - fs.writeFileSync(rcPath, before, { mode: 0o644 }); - - const result = runScript({ rcPath, shim: SHIM_TEXT, uid: CURRENT_UID }); - - expect(result.status).toBe(0); - expect(fs.readFileSync(rcPath, "utf-8")).toBe(before); - }); - - it("refuses a symlinked rc file and exits 1", () => { - const realPath = path.join(tmpDir, "real"); - const linkPath = path.join(tmpDir, ".bashrc"); - fs.writeFileSync(realPath, "export FOO=1\n", { mode: 0o644 }); - fs.symlinkSync(realPath, linkPath); - - const result = runScript({ rcPath: linkPath, shim: SHIM_TEXT, uid: 0 }); - expect(result.status).toBe(1); - expect(result.stderr).toContain("refusing symlinked rc file"); - }); - - it("strips a bare shim line without the preceding comment", () => { - const rcPath = path.join(tmpDir, ".bashrc"); - const before = `export FOO=1\n${SHIM_TEXT}\nexport BAR=2\n`; - fs.writeFileSync(rcPath, before, { mode: 0o644 }); - - const result = runScript({ rcPath, shim: SHIM_TEXT, uid: CURRENT_UID }); - - expect(result.status).toBe(0); - expect(fs.readFileSync(rcPath, "utf-8")).toBe("export FOO=1\nexport BAR=2\n"); - }); -}); diff --git a/test/runtime/sandbox/sandbox-build-context.test.ts b/test/runtime/sandbox/sandbox-build-context.test.ts index b4dc2a1a1e5..d9751549153 100644 --- a/test/runtime/sandbox/sandbox-build-context.test.ts +++ b/test/runtime/sandbox/sandbox-build-context.test.ts @@ -247,7 +247,6 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "lib", "gateway-supervisor.sh")); writeFixture(path.join("scripts", "lib", "sandbox-rlimits.sh")); writeFixture(path.join("scripts", "lib", "openclaw_device_approval_policy.py")); - writeFixture(path.join("scripts", "lib", "clean_runtime_shell_env_shim.py")); writeFixture(path.join("scripts", "lib", "normalize_mutable_config_perms.py")); writeFixture(path.join("scripts", "lib", "refresh-openclaw-wechat-placeholder.py")); writeFixture( diff --git a/test/runtime/sandbox/sandbox-init.test.ts b/test/runtime/sandbox/sandbox-init.test.ts index cfd0555c2e9..7e9a5f9a9c5 100644 --- a/test/runtime/sandbox/sandbox-init.test.ts +++ b/test/runtime/sandbox/sandbox-init.test.ts @@ -319,58 +319,6 @@ EOF }); expect(stderr).toContain("integrity check FAILED"); }); - - }); - - describe("lock_rc_files", () => { - let workDir: string; - - beforeEach(() => { - workDir = mkdtempSync(join(tmpdir(), "sandbox-init-lock-")); - }); - - afterEach(() => { - // Need to make writable before cleanup - try { - chmodSync(join(workDir, ".bashrc"), 0o644); - } catch { - /* ignore */ - } - try { - chmodSync(join(workDir, ".profile"), 0o644); - } catch { - /* ignore */ - } - execFileSync("rm", ["-rf", workDir]); - }); - - it("sets .bashrc and .profile to 444", () => { - writeFileSync(join(workDir, ".bashrc"), "# bashrc"); - writeFileSync(join(workDir, ".profile"), "# profile"); - - runWithLib(`lock_rc_files ${JSON.stringify(workDir)}`); - - const bashrcPerms = getOctalPerms(join(workDir, ".bashrc")); - const profilePerms = getOctalPerms(join(workDir, ".profile")); - expect(bashrcPerms).toBe("444"); - expect(profilePerms).toBe("444"); - }); - - it("is a no-op when files do not exist", () => { - // Should not throw - runWithLib(`lock_rc_files ${JSON.stringify(workDir)}`); - }); - - it("refuses to chmod symlinked rc files", () => { - const target = join(workDir, "target"); - writeFileSync(target, "# target", { mode: 0o600 }); - symlinkSync(target, join(workDir, ".bashrc")); - - const { stdout } = runWithLib(`lock_rc_files ${JSON.stringify(workDir)} 2>&1`); - - expect(stdout).toContain("Refusing to lock symlinked rc file"); - expect(getOctalPerms(target)).toBe("600"); - }); }); describe("drop_capabilities", () => { @@ -983,7 +931,10 @@ EOF describe("both entrypoints source the shared library", () => { it("nemoclaw-start.sh sources sandbox-init.sh", () => { - const src = readFileSync(join(import.meta.dirname, "../../../scripts/nemoclaw-start.sh"), "utf-8"); + const src = readFileSync( + join(import.meta.dirname, "../../../scripts/nemoclaw-start.sh"), + "utf-8", + ); const start = src.indexOf("_SANDBOX_INIT="); // Bound the source block at the harden_resource_limits call line itself // (executable, stable) rather than a free-text comment that may be reworded. diff --git a/test/runtime/sandbox/sandbox-provisioning-helper-permissions.test.ts b/test/runtime/sandbox/sandbox-provisioning-helper-permissions.test.ts index 2705db9ab88..d9d47c92130 100644 --- a/test/runtime/sandbox/sandbox-provisioning-helper-permissions.test.ts +++ b/test/runtime/sandbox/sandbox-provisioning-helper-permissions.test.ts @@ -100,7 +100,6 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () configGuardPath, managedGatewayControlPath, path.join(localLib, "openclaw_device_approval_policy.py"), - path.join(localLib, "clean_runtime_shell_env_shim.py"), path.join(localLib, "normalize_mutable_config_perms.py"), generatorPath, toolSearchValidatorPath, diff --git a/test/support/dcode-start-script-fixture.ts b/test/support/dcode-start-script-fixture.ts index 18cc65ebb79..812676db9a8 100644 --- a/test/support/dcode-start-script-fixture.ts +++ b/test/support/dcode-start-script-fixture.ts @@ -105,25 +105,8 @@ 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)"', From 4650f9d1704e4f747b92cca48b90b512e604846e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 8 Sep 2026 21:41:23 -0700 Subject: [PATCH 2/9] test(sandbox): complete native profile qualification --- ci/full-e2e-cold-path-calibration.json | 1 - docs/deployment/sandbox-hardening.mdx | 4 +- .../manage-sandboxes/run-deep-agents-code.mdx | 15 +++----- .../04-deepagents-code-fresh-reonboard.sh | 15 ++++---- test/e2e/live/full-e2e.test.ts | 37 ++++++++++++++++--- test/e2e/live/hermes-e2e.test.ts | 11 +++++- test/e2e/live/pi-agent-qualification.test.ts | 2 +- test/e2e/mock-parity.json | 1 + .../sandbox/sandbox-provisioning.test.ts | 1 - 9 files changed, 58 insertions(+), 29 deletions(-) diff --git a/ci/full-e2e-cold-path-calibration.json b/ci/full-e2e-cold-path-calibration.json index bd5d380d3ab..4d2656086c5 100644 --- a/ci/full-e2e-cold-path-calibration.json +++ b/ci/full-e2e-cold-path-calibration.json @@ -181,7 +181,6 @@ "scripts/lib/gateway-supervisor.sh", "scripts/lib/sandbox-rlimits.sh", "scripts/lib/openclaw_device_approval_policy.py", - "scripts/lib/clean_runtime_shell_env_shim.py", "scripts/lib/normalize_mutable_config_perms.py", "src/lib/messaging", "src/lib/tool-disclosure.ts", diff --git a/docs/deployment/sandbox-hardening.mdx b/docs/deployment/sandbox-hardening.mdx index 2640c721333..7475f68f117 100644 --- a/docs/deployment/sandbox-hardening.mdx +++ b/docs/deployment/sandbox-hardening.mdx @@ -133,8 +133,8 @@ System paths remain read-only for these protections: - Agents cannot modify DNS resolution or TLS trust stores. - Agents cannot tamper with libraries or shell configuration outside `/sandbox`. -The image build pre-creates locked shell init files `.bashrc` and `.profile` without proxy entries. -System-wide shell hooks that read `/tmp/nemoclaw-proxy-env.sh` source the runtime proxy configuration. +Personal shell files `.bashrc` and `.profile` belong to the sandbox user and can be edited. +System-wide shell hooks load runtime proxy settings from `/tmp/nemoclaw-proxy-env.sh`, which retains mode `0444`. ### Landlock Kernel Requirements diff --git a/docs/manage-sandboxes/run-deep-agents-code.mdx b/docs/manage-sandboxes/run-deep-agents-code.mdx index 19b56d29633..cebce222576 100644 --- a/docs/manage-sandboxes/run-deep-agents-code.mdx +++ b/docs/manage-sandboxes/run-deep-agents-code.mdx @@ -206,17 +206,12 @@ This isolated-mode guarantee applies to the managed launchers, not arbitrary Pyt ### 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. +You can edit personal shell files under `/sandbox`, including `.bashrc`, `.profile`, and `.bash_profile`. +Ordinary login and interactive shells use your personal files. -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`. +Managed probes use `/etc/profile.d/nemoclaw-dcode.sh`, owned by `root:root` with mode `0444`. +The hook clears `BASH_ENV` and `ENV` and selects an image-owned home before Bash reads personal files. +The managed launcher restores `HOME=/sandbox` and reads proxy settings from image-owned files. 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. 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 f192a0179ce..011a64dc665 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 @@ -20,7 +20,7 @@ MODEL_SELECTOR="${REPO}/test/e2e/lib/select-authorized-chat-model.mts" CREDENTIAL_CANARY="nemoclaw-dcode-config-get-canary" PERSONAL_LOGIN_PROFILE="/sandbox/.bash_profile" HOSTILE_LOGIN_FALLBACK="/sandbox/.bash_login" -HOSTILE_PROFILE_MARKER="/sandbox/.nemoclaw-dcode-hostile-profile-loaded" +HOSTILE_PROFILE_MARKER="/tmp/nemoclaw-dcode-hostile-profile-loaded" HOSTILE_SHELL_ENV="/sandbox/.nemoclaw-dcode-hostile-bash-env" fail() { @@ -239,15 +239,15 @@ pass "initial live identity reports model A" # Exercise the installed /etc/profile.d hook in real sandbox login shells. # Managed probes must skip personal startup code; ordinary logins must read it. -trap cleanup_personal_profile_probe EXIT resource_handle="$(runtime_resource_handle)" || fail "could not resolve the DCode sandbox runtime resource" [ -n "$resource_handle" ] || fail "DCode sandbox runtime resource is empty" managed_hook_state="$( privileged_exec "$resource_handle" /bin/sh -c \ - "stat -c '%U:%G:%a' /sandbox; stat -c '%U:%G:%a' /etc/profile.d/nemoclaw-dcode.sh" -)" || fail "could not inspect the managed DCode system hook" + "set -eu; for f in '$PERSONAL_LOGIN_PROFILE' '$HOSTILE_LOGIN_FALLBACK' '$HOSTILE_PROFILE_MARKER' '$HOSTILE_SHELL_ENV'; do test ! -e \"\$f\"; test ! -L \"\$f\"; done; stat -c '%U:%G:%a' /sandbox; stat -c '%U:%G:%a' /etc/profile.d/nemoclaw-dcode.sh" +)" || fail "personal probe files already exist or the managed DCode system hook could not be inspected" expected_hook_state="$(printf '%s\n' root:sandbox:1775 root:root:444)" [ "$managed_hook_state" = "$expected_hook_state" ] || fail "managed DCode system hook posture is unsafe: $managed_hook_state" +trap cleanup_personal_profile_probe EXIT for login_profile in "$PERSONAL_LOGIN_PROFILE" "$HOSTILE_LOGIN_FALLBACK"; do profile_before="$(sandbox_exec "set -eu; test -w /sandbox/.bashrc; test -w /sandbox/.profile; printf '%s\n' 'touch $HOSTILE_PROFILE_MARKER' 'export NEMOCLAW_E2E_PERSONAL_PROFILE=loaded' > '$login_profile'; printf '%s\n' 'touch $HOSTILE_PROFILE_MARKER' > '$HOSTILE_SHELL_ENV'; sha256sum '$login_profile'")" \ @@ -258,11 +258,12 @@ for login_profile in "$PERSONAL_LOGIN_PROFILE" "$HOSTILE_LOGIN_FALLBACK"; do /bin/bash -lc '/usr/local/lib/nemoclaw/dcode-managed-exec /usr/bin/printf %s MANAGED_EXEC_OK' 2>&1 )" || fail "managed exec failed with personal startup files present: $managed_output" [ "$managed_output" = MANAGED_EXEC_OK ] || fail "managed exec output contains personal startup output: $managed_output" - privileged_exec "$resource_handle" /bin/sh -c "test ! -e '$HOSTILE_PROFILE_MARKER'" \ - || fail "personal login or BASH_ENV code ran before the managed exec" + privileged_exec "$resource_handle" /bin/sh -c \ + "/usr/bin/env HOME=/sandbox BASH_ENV='$HOSTILE_SHELL_ENV' ENV='$HOSTILE_SHELL_ENV' /usr/local/bin/nemoclaw-start /usr/bin/true && test ! -e '$HOSTILE_PROFILE_MARKER'" \ + || fail "managed exec or root entrypoint failed or read personal startup code" # shellcheck disable=SC2016 # Read the variable set by the sandbox's personal profile. - ordinary_output="$(openshell sandbox exec --name "$SANDBOX_NAME" -- /bin/bash -lc 'printf %s "$NEMOCLAW_E2E_PERSONAL_PROFILE"' 2>&1)" \ + ordinary_output="$(openshell sandbox exec --name "$SANDBOX_NAME" -- /usr/bin/env -u NEMOCLAW_E2E_PERSONAL_PROFILE /bin/bash -lc 'printf %s "$NEMOCLAW_E2E_PERSONAL_PROFILE"' 2>&1)" \ || fail "ordinary login failed with a personal profile: $ordinary_output" [ "$ordinary_output" = loaded ] || fail "ordinary login did not read its personal profile" profile_after="$(sandbox_exec "set -eu; test -w '$login_profile'; sha256sum '$login_profile'")" \ diff --git a/test/e2e/live/full-e2e.test.ts b/test/e2e/live/full-e2e.test.ts index 49950d31841..c3605b61a82 100644 --- a/test/e2e/live/full-e2e.test.ts +++ b/test/e2e/live/full-e2e.test.ts @@ -128,12 +128,18 @@ async function runOpenClawLaunchTurnAfterRecovery(input: { }): Promise { const stopGateway = await input.sandbox.execShell( SANDBOX_NAME, - trustedSandboxShellScript(GATEWAY_STOP_SCRIPT), + trustedSandboxShellScript(`set -eu +/usr/local/bin/openclaw completion --shell bash --write-state --install --yes +for f in /sandbox/.bashrc /sandbox/.profile; do + printf '%s\\n' 'export NEMOCLAW_E2E_PERSONAL_PROFILE=loaded' '[ "$(id -u)" -ne 0 ] || touch /tmp/nemoclaw-e2e-root-profile-loaded' >> "$f" +done +sha256sum /sandbox/.bashrc /sandbox/.profile > /tmp/nemoclaw-e2e-profiles.sha256 +${GATEWAY_STOP_SCRIPT}`), { artifactName: "phase-4-stop-openclaw-gateway-before-launch", env: env(), redactionValues: input.redactionValues, - timeoutMs: 30_000, + timeoutMs: 120_000, }, ); expect(stopGateway.exitCode, resultText(stopGateway)).toBe(0); @@ -163,7 +169,11 @@ async function runOpenClawLaunchTurnAfterRecovery(input: { SANDBOX_NAME, trustedSandboxShellScript( "test \"$(stat -c '%a %U:%G' /sandbox/.openclaw)\" = '2770 sandbox:sandbox' && " + - "test \"$(stat -c '%a %U:%G' /sandbox/.openclaw/openclaw.json)\" = '660 sandbox:sandbox'", + "test \"$(stat -c '%a %U:%G' /sandbox/.openclaw/openclaw.json)\" = '660 sandbox:sandbox' && " + + "/usr/bin/env -u NEMOCLAW_E2E_PERSONAL_PROFILE bash -lc 'test \"$NEMOCLAW_E2E_PERSONAL_PROFILE\" = loaded' && " + + "/usr/bin/env -u NEMOCLAW_E2E_PERSONAL_PROFILE bash -ic 'test \"$NEMOCLAW_E2E_PERSONAL_PROFILE\" = loaded' && " + + "/usr/bin/sha256sum -c /tmp/nemoclaw-e2e-profiles.sha256 && " + + "test ! -e /tmp/nemoclaw-e2e-root-profile-loaded", ), { artifactName: "phase-4-openclaw-launch-permissions", @@ -423,7 +433,9 @@ test("full e2e: install, onboard, inference, cli operations, and cleanup", { : []), "nemoclaw logs produces output and cleanup removes registry state", ...(securityPostureEnabled() - ? ["non-root host, locked rc/proxy files, configure guard, and clean startup log"] + ? [ + "non-root host, editable personal profiles, protected proxy files, configure guard, and clean startup log", + ] : []), ], }); @@ -514,6 +526,22 @@ test("full e2e: install, onboard, inference, cli operations, and cleanup", { : Promise.resolve()); progress.phase("validate CLI sandbox and policy state"); + const nativeDoctor = await sandbox.exec( + SANDBOX_NAME, + ["/usr/local/bin/openclaw", "doctor", "--lint", "--json"], + { artifactName: "phase-2-first-native-openclaw-doctor", env: env(), timeoutMs: 180_000 }, + ); + const doctorReport = JSON.parse(nativeDoctor.stdout); + // Exit 1 is a completed diagnostic with findings, including the state modes + // tracked separately in #11257. Preserve those findings in the raw artifact. + expect( + (nativeDoctor.exitCode === 0 || nativeDoctor.exitCode === 1) && + doctorReport.ok === (nativeDoctor.exitCode === 0) && + Number.isInteger(doctorReport.checksRun) && + doctorReport.checksRun > 0 && + Array.isArray(doctorReport.findings), + resultText(nativeDoctor), + ).toBe(true); const pathProbe = await host.command( "bash", [ @@ -523,7 +551,6 @@ test("full e2e: install, onboard, inference, cli operations, and cleanup", { { artifactName: "phase-2-path-probe", env: env(), timeoutMs: 60_000 }, ); expect(pathProbe.exitCode, resultText(pathProbe)).toBe(0); - expect(pathProbe.stdout).toContain("nemoclaw"); expect(pathProbe.stdout).toContain("openshell"); const list = await repoNemoclaw(host, ["list"], "phase-3-nemoclaw-list"); diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index 9ab5c8107cc..9fb1d1617b5 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -468,7 +468,7 @@ test( const profilesBeforeRecovery = await sandbox.execShell( SANDBOX_NAME, trustedSandboxShellScript( - "set -eu; for f in /sandbox/.bashrc /sandbox/.profile; do printf '\\n# nemoclaw-e2e-profile-preserved\\n' >> \"$f\"; done; sha256sum /sandbox/.bashrc /sandbox/.profile > /tmp/nemoclaw-e2e-profiles.sha256", + "set -eu; for f in /sandbox/.bashrc /sandbox/.profile; do printf '%s\\n' 'export NEMOCLAW_E2E_PERSONAL_PROFILE=loaded' '[ \"$(id -u)\" -ne 0 ] || touch /tmp/nemoclaw-e2e-root-profile-loaded' >> \"$f\"; done; sha256sum /sandbox/.bashrc /sandbox/.profile > /tmp/nemoclaw-e2e-profiles.sha256", ), { artifactName: "phase-3-personal-profiles-before-recovery", @@ -754,7 +754,14 @@ test( const personalProfiles = await sandbox.exec( SANDBOX_NAME, - ["/usr/bin/sha256sum", "-c", "/tmp/nemoclaw-e2e-profiles.sha256"], + [ + "/usr/bin/env", + "-u", + "NEMOCLAW_E2E_PERSONAL_PROFILE", + "bash", + "-lc", + 'test "$NEMOCLAW_E2E_PERSONAL_PROFILE" = loaded && /usr/bin/env -u NEMOCLAW_E2E_PERSONAL_PROFILE bash -ic \'test "$NEMOCLAW_E2E_PERSONAL_PROFILE" = loaded\' && /usr/bin/sha256sum -c /tmp/nemoclaw-e2e-profiles.sha256 && test ! -e /tmp/nemoclaw-e2e-root-profile-loaded', + ], { artifactName: "phase-4-personal-profiles-after-recovery", env: commandEnv(), diff --git a/test/e2e/live/pi-agent-qualification.test.ts b/test/e2e/live/pi-agent-qualification.test.ts index c77a42a71bb..f8a9a86c4fa 100644 --- a/test/e2e/live/pi-agent-qualification.test.ts +++ b/test/e2e/live/pi-agent-qualification.test.ts @@ -420,7 +420,7 @@ test( const profilesAfterRecovery = await execPiShell( sandbox, trustedSandboxShellScript( - "set -eu; bash -lc 'test \"$NEMOCLAW_E2E_PI_PROFILE\" = preserved'; sha256sum /sandbox/.bashrc /sandbox/.profile", + "set -eu; /usr/bin/env -u NEMOCLAW_E2E_PI_PROFILE bash -lc 'test \"$NEMOCLAW_E2E_PI_PROFILE\" = preserved'; sha256sum /sandbox/.bashrc /sandbox/.profile", ), { artifactName: "pi-personal-profiles-after-recovery", env, timeoutMs: 30_000 }, ); diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index b4b335421dd..a1e9de32a16 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -158,6 +158,7 @@ "test/e2e/live/full-e2e-workload-evidence.ts" ], "fast": [ + "test/runtime/sandbox/sandbox-provisioning.test.ts", "test/e2e/support/hosted-inference.test.ts", "test/e2e/support/full-e2e-inference-probe.test.ts", "test/e2e/support/launch-agent-turn.test.ts", diff --git a/test/runtime/sandbox/sandbox-provisioning.test.ts b/test/runtime/sandbox/sandbox-provisioning.test.ts index 8b62f9443b1..aeb446503b8 100644 --- a/test/runtime/sandbox/sandbox-provisioning.test.ts +++ b/test/runtime/sandbox/sandbox-provisioning.test.ts @@ -876,7 +876,6 @@ describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { expect(content.toLowerCase()).not.toContain("proxy"); expect(content).not.toContain("/tmp/nemoclaw-proxy-env.sh"); expect((fs.statSync(rcPath).mode & 0o777).toString(8)).toBe("644"); - fs.appendFileSync(rcPath, "\n# user setting\n"); }); expect(rc.calls).toContain( `chown sandbox:sandbox ${path.join(sandboxRoot, ".bashrc")} ${path.join(sandboxRoot, ".profile")}`, From b277ebff6bde9ccfa991db0d13553b02bf64a30a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 8 Sep 2026 22:35:56 -0700 Subject: [PATCH 3/9] ci(e2e): qualify focused agent tests with PR images --- .github/workflows/e2e.yaml | 3 +++ test/e2e/README.md | 9 ++++++--- .../checks/04-deepagents-code-fresh-reonboard.sh | 9 ++++++--- test/e2e/live/pi-agent-qualification.test.ts | 2 +- .../base-image-publication-workflow-boundary.test.ts | 12 ++++++++++++ tools/e2e/operations-workflow-boundary.mts | 5 +++++ 6 files changed, 33 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 04ee7d70595..cfbd6110507 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -291,6 +291,7 @@ jobs: - id: publication name: Select base and optional managed-image publication + if: ${{ inputs.pr_number == '' || steps.select_pr_source.outputs.selection == 'base-cohort' || inputs.jobs != '' || inputs.targets == '' || contains(inputs.targets, 'managed-image-') }} env: EXPECTED_SHA: ${{ steps.publication_mode.outputs.expected_sha }} GITHUB_TOKEN: ${{ github.token }} @@ -309,6 +310,7 @@ jobs: node --experimental-strip-types --no-warnings tools/e2e/base-image-publication.mts --wait-seconds "$wait_seconds" --poll-seconds 30 - name: Download immutable Deep Agents Code base contract + if: ${{ inputs.pr_number == '' || steps.select_pr_source.outputs.selection == 'base-cohort' || inputs.jobs != '' || inputs.targets == '' || contains(inputs.targets, 'managed-image-') }} env: GITHUB_TOKEN: ${{ github.token }} PUBLICATION_HEAD_SHA: ${{ steps.publication.outputs.head_sha }} @@ -318,6 +320,7 @@ jobs: - id: validate_dcode_base name: Validate immutable Deep Agents Code base + if: ${{ inputs.pr_number == '' || steps.select_pr_source.outputs.selection == 'base-cohort' || inputs.jobs != '' || inputs.targets == '' || contains(inputs.targets, 'managed-image-') }} env: PUBLICATION_HEAD_SHA: ${{ steps.publication.outputs.head_sha }} PUBLICATION_RUN_ATTEMPT: ${{ steps.publication.outputs.run_attempt }} diff --git a/test/e2e/README.md b/test/e2e/README.md index e95c6b3c2af..e4aa002c97a 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -1404,9 +1404,12 @@ It does not run GitHub's synthetic merge commit. Before candidate execution, the workflow uploads a `nemoclaw-e2e-dispatch-v2` receipt for the trusted manual run. The full-main `Release qualification` aggregate does not use this receipt. -The `base-image-publication` job selects the nearest fully successful base and managed-image publication on the PR base first-parent history. -It binds the selected run ID, attempt, revision, cohort contract artifact ID, and artifact digest before it emits `managed_image_revision`. -The job validates the complete three-agent, two-architecture cohort artifact and the immutable Deep Agents Code base artifact from that workflow attempt. +The `base-image-publication` job first resolves any authenticated PR managed-image catalog. +When a PR catalog is selected, explicit targets with no `jobs` selector and no `managed-image-` target use it without waiting for main's base images. +Other selections with a PR catalog retain the Deep Agents Code base prerequisite, including full runs and protected managed-image build targets. +Runs without a PR catalog require a trusted main base and managed-image publication; PR runs select the nearest fully successful publication on the PR base first-parent history. +For that publication, the job binds the run ID, attempt, revision, cohort artifact ID, and artifact digest before it emits `managed_image_revision`. +It validates the complete three-agent, two-architecture cohort artifact and the immutable Deep Agents Code base artifact from that workflow attempt. `generate-matrix` and every stock-onboarding job depend on this publication job, so incomplete publication creates no onboarding fanout. Direct `main` runs use the same publication workflow and artifact contract. 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 011a64dc665..dd67f0d89d3 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 @@ -250,7 +250,7 @@ expected_hook_state="$(printf '%s\n' root:sandbox:1775 root:root:444)" trap cleanup_personal_profile_probe EXIT for login_profile in "$PERSONAL_LOGIN_PROFILE" "$HOSTILE_LOGIN_FALLBACK"; do - profile_before="$(sandbox_exec "set -eu; test -w /sandbox/.bashrc; test -w /sandbox/.profile; printf '%s\n' 'touch $HOSTILE_PROFILE_MARKER' 'export NEMOCLAW_E2E_PERSONAL_PROFILE=loaded' > '$login_profile'; printf '%s\n' 'touch $HOSTILE_PROFILE_MARKER' > '$HOSTILE_SHELL_ENV'; sha256sum '$login_profile'")" \ + profile_before="$(sandbox_exec "set -eu; test -w /sandbox/.bashrc; test -w /sandbox/.profile; printf '%s\n' 'touch $HOSTILE_PROFILE_MARKER' 'export NEMOCLAW_E2E_PERSONAL_PROFILE=loaded' > '$login_profile'; printf '%s\n' 'export NEMOCLAW_E2E_PERSONAL_PROFILE=loaded' >> /sandbox/.bashrc; printf '%s\n' 'touch $HOSTILE_PROFILE_MARKER' > '$HOSTILE_SHELL_ENV'; sha256sum '$login_profile' /sandbox/.bashrc")" \ || fail "sandbox identity could not write its personal login profile" managed_output="$( openshell sandbox exec --name "$SANDBOX_NAME" -- \ @@ -266,9 +266,12 @@ for login_profile in "$PERSONAL_LOGIN_PROFILE" "$HOSTILE_LOGIN_FALLBACK"; do ordinary_output="$(openshell sandbox exec --name "$SANDBOX_NAME" -- /usr/bin/env -u NEMOCLAW_E2E_PERSONAL_PROFILE /bin/bash -lc 'printf %s "$NEMOCLAW_E2E_PERSONAL_PROFILE"' 2>&1)" \ || fail "ordinary login failed with a personal profile: $ordinary_output" [ "$ordinary_output" = loaded ] || fail "ordinary login did not read its personal profile" - profile_after="$(sandbox_exec "set -eu; test -w '$login_profile'; sha256sum '$login_profile'")" \ + # shellcheck disable=SC2016 # Read the variable set by the sandbox's personal profile. + openshell sandbox exec --name "$SANDBOX_NAME" -- /usr/bin/env -u NEMOCLAW_E2E_PERSONAL_PROFILE /bin/bash -ic 'test "$NEMOCLAW_E2E_PERSONAL_PROFILE" = loaded' \ + || fail "ordinary interactive shell did not read its personal profile" + profile_after="$(sandbox_exec "set -eu; test -w '$login_profile'; sha256sum '$login_profile' /sandbox/.bashrc")" \ || fail "personal profile became unwritable after managed and ordinary commands" - [ "$profile_after" = "$profile_before" ] || fail "managed or ordinary login rewrote the personal profile" + [ "$profile_after" = "$profile_before" ] || fail "managed or ordinary shells rewrote personal profiles" cleanup_personal_profile_probe done trap - EXIT diff --git a/test/e2e/live/pi-agent-qualification.test.ts b/test/e2e/live/pi-agent-qualification.test.ts index f8a9a86c4fa..5d3a9fce38f 100644 --- a/test/e2e/live/pi-agent-qualification.test.ts +++ b/test/e2e/live/pi-agent-qualification.test.ts @@ -420,7 +420,7 @@ test( const profilesAfterRecovery = await execPiShell( sandbox, trustedSandboxShellScript( - "set -eu; /usr/bin/env -u NEMOCLAW_E2E_PI_PROFILE bash -lc 'test \"$NEMOCLAW_E2E_PI_PROFILE\" = preserved'; sha256sum /sandbox/.bashrc /sandbox/.profile", + "set -eu; /usr/bin/env -u NEMOCLAW_E2E_PI_PROFILE bash -lc 'test \"$NEMOCLAW_E2E_PI_PROFILE\" = preserved'; /usr/bin/env -u NEMOCLAW_E2E_PI_PROFILE bash -ic 'test \"$NEMOCLAW_E2E_PI_PROFILE\" = preserved'; sha256sum /sandbox/.bashrc /sandbox/.profile", ), { artifactName: "pi-personal-profiles-after-recovery", env, timeoutMs: 30_000 }, ); diff --git a/test/e2e/support/base-image-publication-workflow-boundary.test.ts b/test/e2e/support/base-image-publication-workflow-boundary.test.ts index 5260d0ae127..df777e1ac3f 100644 --- a/test/e2e/support/base-image-publication-workflow-boundary.test.ts +++ b/test/e2e/support/base-image-publication-workflow-boundary.test.ts @@ -207,6 +207,18 @@ describe("base-image publication workflow boundary (#7372)", () => { ["Node pin", (value) => (gateSteps(value)[2].uses = "actions/setup-node@v6")], ["Node version", (value) => (gateSteps(value)[2].with!["node-version"] = 20)], ["verifier condition", (value) => (gateSteps(value)[3].if = "${{ always() }}")], + [ + "base publication selection condition", + (value) => (gateStep(value, "Select base and optional managed-image publication").if = "${{ false }}"), + ], + [ + "base contract download condition", + (value) => (gateStep(value, "Download immutable Deep Agents Code base contract").if = "${{ false }}"), + ], + [ + "base contract validation condition", + (value) => (gateStep(value, "Validate immutable Deep Agents Code base").if = "${{ false }}"), + ], ["verifier token", (value) => (gateSteps(value)[3].env!.GITHUB_TOKEN = "${{ secrets.TOKEN }}")], [ "verifier SHA", diff --git a/tools/e2e/operations-workflow-boundary.mts b/tools/e2e/operations-workflow-boundary.mts index c24d0c22dbb..330876e24c0 100644 --- a/tools/e2e/operations-workflow-boundary.mts +++ b/tools/e2e/operations-workflow-boundary.mts @@ -35,6 +35,8 @@ const COLD_ONBOARD_PERFORMANCE_EVIDENCE_PATH = "e2e-artifacts/live/${{ matrix.id }}/onboard-progress-budget.json"; const MANAGED_SOURCE_CONDITION = "${{ inputs.pr_number == '' || steps.select_pr_source.outputs.selection == 'base-cohort' }}"; +const BASE_PUBLICATION_CONDITION = + "${{ inputs.pr_number == '' || steps.select_pr_source.outputs.selection == 'base-cohort' || inputs.jobs != '' || inputs.targets == '' || contains(inputs.targets, 'managed-image-') }}"; const PR_MANAGED_IMAGE_RESOLVER_SCRIPT = [ "set -euo pipefail", @@ -764,6 +766,7 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): { id: "publication", name: "Select base and optional managed-image publication", + if: BASE_PUBLICATION_CONDITION, env: { EXPECTED_SHA: "${{ steps.publication_mode.outputs.expected_sha }}", GITHUB_TOKEN: "${{ github.token }}", @@ -789,6 +792,7 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): }, { name: "Download immutable Deep Agents Code base contract", + if: BASE_PUBLICATION_CONDITION, env: { GITHUB_TOKEN: "${{ github.token }}", PUBLICATION_HEAD_SHA: "${{ steps.publication.outputs.head_sha }}", @@ -800,6 +804,7 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): { id: "validate_dcode_base", name: "Validate immutable Deep Agents Code base", + if: BASE_PUBLICATION_CONDITION, env: { PUBLICATION_HEAD_SHA: "${{ steps.publication.outputs.head_sha }}", PUBLICATION_RUN_ATTEMPT: "${{ steps.publication.outputs.run_attempt }}", From b37d4879954f3030ce05909e6eee7187f186f6e5 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 9 Sep 2026 00:21:05 -0700 Subject: [PATCH 4/9] fix(sandbox): respect runtime ownership during qualification --- .../sandbox/connect-inference-route-probe.ts | 9 +-- src/lib/agent/terminal-smoke.ts | 10 +-- src/lib/onboard/gateway-reuse.test.ts | 17 ++++ src/lib/onboard/gateway-reuse.ts | 2 + .../checks/04-landlock-readonly.sh | 35 +++----- test/e2e/fixtures/phases/lifecycle.ts | 26 +++++- test/e2e/live/full-e2e.test.ts | 18 +++-- test/e2e/live/hermes-e2e.test.ts | 21 +---- test/e2e/live/pi-agent-qualification.test.ts | 1 + test/e2e/support/e2e-phase-lifecycle.test.ts | 79 ++++++++++++++++++- 10 files changed, 158 insertions(+), 60 deletions(-) diff --git a/src/lib/actions/sandbox/connect-inference-route-probe.ts b/src/lib/actions/sandbox/connect-inference-route-probe.ts index 3c5730c3939..2ebaff48fe5 100644 --- a/src/lib/actions/sandbox/connect-inference-route-probe.ts +++ b/src/lib/actions/sandbox/connect-inference-route-probe.ts @@ -39,15 +39,14 @@ export const INFERENCE_ROUTE_PROBE_SCRIPT = [ INFERENCE_ROUTE_PROBE_CORE_SCRIPT, ].join("; "); // 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 +// requested command (#8624; OpenShell#2668). DCode's image-owned system hook +// selects the managed home before reading personal files. Older images can 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 +// system-hook and hostile-profile tests cover startup and 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: diff --git a/src/lib/agent/terminal-smoke.ts b/src/lib/agent/terminal-smoke.ts index d57b630308b..c103e64ab41 100644 --- a/src/lib/agent/terminal-smoke.ts +++ b/src/lib/agent/terminal-smoke.ts @@ -42,9 +42,8 @@ function smokeRunner(shell: "sh -c" | "sh -lc" | "/bin/bash -lc"): string { * 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 + * NVIDIA/OpenShell#2668. DCode's image-owned system hook selects the managed + * home before reading personal files. Older images can 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, @@ -82,9 +81,8 @@ export function buildAgentSmokeArgs( command, ]; } - // Pi's login profile enforces an exact nproc limit, which Ubuntu /bin/sh - // cannot inspect. Keep the profile active, but run it with the Bash shell - // the Pi image provisions for this contract. + // Pi's system shell hooks enforce an exact nproc limit, which Ubuntu /bin/sh + // cannot inspect. Use the Bash shell the Pi image provisions. const shellPath = agent.name === "pi" ? "/bin/bash" : "/bin/sh"; const commandShell = agent.name === "pi" ? "/bin/bash -lc" : "sh -lc"; return [ diff --git a/src/lib/onboard/gateway-reuse.test.ts b/src/lib/onboard/gateway-reuse.test.ts index ddbcfedb2d0..e03c3ee0b77 100644 --- a/src/lib/onboard/gateway-reuse.test.ts +++ b/src/lib/onboard/gateway-reuse.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts"; +import * as gatewayEnv from "./docker-driver-gateway-env"; import { classifyDockerDriverNetworkInspection, createDockerDriverGatewayReuseApplication, @@ -244,6 +245,22 @@ function createDockerDriverReuseApplication( } describe("Docker-driver gateway reuse application", () => { + it("preserves provider-owned readiness without inspecting Docker", async () => { + vi.spyOn(gatewayEnv, "configuredRuntimeProviderOwnsHostReadiness").mockReturnValue(true); + const resolveOpenShellGatewayBinary = vi.fn(() => "/opt/openshell-gateway"); + const inspectDockerDriverNetwork = vi.fn(() => ({ kind: "inconclusive" as const })); + const application = createDockerDriverReuseApplication({ + resolveOpenShellGatewayBinary, + inspectDockerDriverNetwork, + }); + + await expect(application.refreshDockerDriverGatewayReuseState("healthy")).resolves.toBe( + "healthy", + ); + expect(resolveOpenShellGatewayBinary).not.toHaveBeenCalled(); + expect(inspectDockerDriverNetwork).not.toHaveBeenCalled(); + }); + it("keeps reuse state unchanged when Docker-driver inspection does not apply (#7695)", async () => { const isDockerDriverGatewayEnabled = vi.fn(() => false); const checkGatewayPortAvailable = vi.fn(async () => ({ ok: true })); diff --git a/src/lib/onboard/gateway-reuse.ts b/src/lib/onboard/gateway-reuse.ts index ed4e88a7980..8ba82bd07cd 100644 --- a/src/lib/onboard/gateway-reuse.ts +++ b/src/lib/onboard/gateway-reuse.ts @@ -12,6 +12,7 @@ import { shouldSelectNamedGatewayForReuse, } from "../state/gateway"; import * as dockerDriverGatewayLaunch from "./docker-driver-gateway-launch"; +import { configuredRuntimeProviderOwnsHostReadiness } from "./docker-driver-gateway-env"; import * as gatewayService from "./docker-driver-gateway-service"; import type { PortProbeResult } from "./preflight"; @@ -145,6 +146,7 @@ export function createDockerDriverGatewayReuseApplication( state: GatewayReuseState, ): Promise { if (!deps.isDockerDriverGatewayEnabled() || state !== "healthy") return state; + if (configuredRuntimeProviderOwnsHostReadiness()) return state; const gatewayBin = deps.resolveOpenShellGatewayBinary(); const baseDesiredEnv = deps.getDockerDriverGatewayEnv( diff --git a/test/e2e/e2e-cloud-experimental/checks/04-landlock-readonly.sh b/test/e2e/e2e-cloud-experimental/checks/04-landlock-readonly.sh index 9fa37f77f09..7db93aff1e1 100755 --- a/test/e2e/e2e-cloud-experimental/checks/04-landlock-readonly.sh +++ b/test/e2e/e2e-cloud-experimental/checks/04-landlock-readonly.sh @@ -6,8 +6,8 @@ # # These checks run INSIDE a real OpenShell sandbox where Landlock is active. # They verify that the kernel enforces the filesystem policy: /sandbox and -# /sandbox/.openclaw are writable (mutable default), trusted shell startup -# files remain read-only, system paths are read-only, and /tmp is writable. +# /sandbox/.openclaw are writable (mutable default), system paths are +# read-only, and /tmp is writable. # # The managed-image OpenClaw security E2E covers DAC enforcement but cannot # exercise Landlock. This script closes that gap. @@ -55,17 +55,8 @@ else fail_test "/sandbox is NOT writable under Landlock: $OUT" fi -# ── 2: Cannot modify trusted shell startup files ───────────────── -info "2. Cannot modify .bashrc/.profile (trusted startup snippets)" -OUT=$(sandbox_exec "echo '# test' >> /sandbox/.bashrc 2>&1 || echo BASHRC_BLOCKED; sed -i '/^# test$/d' /sandbox/.bashrc 2>/dev/null || true; echo '# test' >> /sandbox/.profile 2>&1 || echo PROFILE_BLOCKED; sed -i '/^# test$/d' /sandbox/.profile 2>/dev/null || true" || true) -if echo "$OUT" | grep -q "BASHRC_BLOCKED" && echo "$OUT" | grep -q "PROFILE_BLOCKED"; then - pass ".bashrc/.profile remain read-only while home is mutable" -else - fail_test ".bashrc/.profile should be read-only trusted startup files: $OUT" -fi - -# ── 3: CAN write to .openclaw (mutable default) ────────────────── -info "3. Can create files in .openclaw (mutable default)" +# ── 2: CAN write to .openclaw (mutable default) ────────────────── +info "2. Can create files in .openclaw (mutable default)" OUT=$(sandbox_exec "touch /sandbox/.openclaw/landlock-test && echo OK || echo FAILED" || true) if echo "$OUT" | grep -q "OK"; then pass ".openclaw dir is writable in mutable-default mode" @@ -73,8 +64,8 @@ else fail_test ".openclaw dir is NOT writable under Landlock: $OUT" fi -# ── 4: Cannot write to /usr (system path read-only) ────────────── -info "4. Cannot write to /usr (system path read-only)" +# ── 3: Cannot write to /usr (system path read-only) ────────────── +info "3. Cannot write to /usr (system path read-only)" OUT=$(sandbox_exec "touch /usr/landlock-test 2>&1 || echo BLOCKED" || true) if echo "$OUT" | grep -qi "BLOCKED\|Permission denied\|Read-only\|EACCES"; then pass "/usr is Landlock read-only" @@ -82,8 +73,8 @@ else fail_test "/usr is writable under Landlock: $OUT" fi -# ── 5: Cannot write to /etc (system path read-only) ────────────── -info "5. Cannot write to /etc (system path read-only)" +# ── 4: Cannot write to /etc (system path read-only) ────────────── +info "4. Cannot write to /etc (system path read-only)" OUT=$(sandbox_exec "touch /etc/landlock-test 2>&1 || echo BLOCKED" || true) if echo "$OUT" | grep -qi "BLOCKED\|Permission denied\|Read-only\|EACCES"; then pass "/etc is Landlock read-only" @@ -91,8 +82,8 @@ else fail_test "/etc is writable under Landlock: $OUT" fi -# ── 6: CAN write to .nemoclaw/state (Landlock read_write via parent) ─ -info "6. Can write to .nemoclaw/state (Landlock read_write)" +# ── 5: CAN write to .nemoclaw/state (Landlock read_write via parent) ─ +info "5. Can write to .nemoclaw/state (Landlock read_write)" OUT=$(sandbox_exec "touch /sandbox/.nemoclaw/state/landlock-test && echo OK || echo FAILED" || true) if echo "$OUT" | grep -q "OK"; then pass ".nemoclaw/state is writable under Landlock" @@ -100,8 +91,8 @@ else fail_test ".nemoclaw/state is NOT writable under Landlock: $OUT" fi -# ── 7: CAN write to /tmp (Landlock read_write) ─────────────────── -info "7. Can write to /tmp (Landlock read_write)" +# ── 6: CAN write to /tmp (Landlock read_write) ─────────────────── +info "6. Can write to /tmp (Landlock read_write)" OUT=$(sandbox_exec "touch /tmp/landlock-test && echo OK || echo FAILED" || true) if echo "$OUT" | grep -q "OK"; then pass "/tmp is writable under Landlock" @@ -110,7 +101,7 @@ else fi # ── Cleanup test artifacts ──────────────────────────────────────── -sandbox_exec "sed -i '/^# test$/d' /sandbox/.bashrc /sandbox/.profile 2>/dev/null || true; rm -f /sandbox/landlock-test /sandbox/.openclaw/landlock-test /sandbox/.nemoclaw/state/landlock-test /usr/landlock-test /etc/landlock-test /tmp/landlock-test 2>/dev/null" || true +sandbox_exec "rm -f /sandbox/landlock-test /sandbox/.openclaw/landlock-test /sandbox/.nemoclaw/state/landlock-test /usr/landlock-test /etc/landlock-test /tmp/landlock-test 2>/dev/null" || true # ── Summary ─────────────────────────────────────────────────────── printf '%s\n' "04-landlock-readonly: $PASSED passed, $FAILED failed" diff --git a/test/e2e/fixtures/phases/lifecycle.ts b/test/e2e/fixtures/phases/lifecycle.ts index f43eca397ba..900656c539d 100644 --- a/test/e2e/fixtures/phases/lifecycle.ts +++ b/test/e2e/fixtures/phases/lifecycle.ts @@ -1,6 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { fileURLToPath } from "node:url"; import { buildAvailabilityProbeEnv } from "../availability-env.ts"; @@ -246,6 +249,23 @@ export class LifecyclePhaseFixture { return this.runtimeProvider; } + trackInstallerGatewayUserService(): void { + const env = buildAvailabilityProbeEnv(); + const configured = env.XDG_CONFIG_HOME; + const configHome = configured && path.isAbsolute(configured) + ? configured : path.join(env.HOME ?? os.homedir(), ".config"); + const unit = path.join(configHome, "systemd", "user", "nemoclaw-openshell-gateway.service"); + try { + fs.lstatSync(unit); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + this.cleanup.add("lifecycle.remove-installer-gateway-user-service", () => + this.removeStagedOpenShellGatewayUserService(env), + ); + } + /** * Ensure OpenShell is installed and stage the OpenShell gateway user service * before onboarding. Onboarding must see the service so it writes the @@ -548,13 +568,15 @@ export class LifecyclePhaseFixture { return match[1] as UserServiceStageResult; } - private async removeStagedOpenShellGatewayUserService(): Promise { + private async removeStagedOpenShellGatewayUserService( + env = buildAvailabilityProbeEnv(), + ): Promise { const result = await this.host.command( "sh", ["-lc", buildOpenShellGatewayUserServiceRemovalScript()], { artifactName: "lifecycle-cleanup-gateway-user-service", - env: buildAvailabilityProbeEnv(), + env, timeoutMs: 120_000, }, ); diff --git a/test/e2e/live/full-e2e.test.ts b/test/e2e/live/full-e2e.test.ts index c3605b61a82..8b95a7f186c 100644 --- a/test/e2e/live/full-e2e.test.ts +++ b/test/e2e/live/full-e2e.test.ts @@ -50,6 +50,7 @@ import { import { readFullE2eColdWorkloadEvidence } from "./full-e2e-workload-evidence.ts"; import { runOpenClawLaunchReadinessLeaseTurns } from "./launch-agent-turn.ts"; import { bindApprovedPrBaseForBaseImageComparison } from "./pr-base-comparison.ts"; +import { parseOpenClawJsonDocuments } from "../../../src/lib/openclaw/agent-json-provenance.ts"; const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-full"; const FULL_E2E_TARGET_ID = process.env.E2E_TARGET_ID ?? "full-e2e"; @@ -400,7 +401,7 @@ test("full e2e: install, onboard, inference, cli operations, and cleanup", { "remove full-E2E sandbox", ], }, -}, async ({ artifacts, cleanup: cleanupRegistry, host, progress, sandbox, secrets, skip }) => { +}, async ({ artifacts, cleanup: cleanupRegistry, host, lifecycle, progress, sandbox, secrets, skip }) => { const hosted = requireHostedInferenceConfig(secrets); const portableHostedDescriptor = PORTABLE_PROFILE && !USE_PREINSTALLED_LAUNCHABLE @@ -447,6 +448,7 @@ test("full e2e: install, onboard, inference, cli operations, and cleanup", { skip, }); + !USE_PREINSTALLED_LAUNCHABLE && lifecycle.trackInstallerGatewayUserService(); cleanupRegistry.trackGateway(host, "nemoclaw", { artifactName: "cleanup-openshell-gateway-destroy", env: env(), @@ -531,15 +533,17 @@ test("full e2e: install, onboard, inference, cli operations, and cleanup", { ["/usr/local/bin/openclaw", "doctor", "--lint", "--json"], { artifactName: "phase-2-first-native-openclaw-doctor", env: env(), timeoutMs: 180_000 }, ); - const doctorReport = JSON.parse(nativeDoctor.stdout); + const doctorReports = parseOpenClawJsonDocuments(nativeDoctor.stdout); + const doctorReport = doctorReports[0] as Record | undefined; // Exit 1 is a completed diagnostic with findings, including the state modes // tracked separately in #11257. Preserve those findings in the raw artifact. expect( - (nativeDoctor.exitCode === 0 || nativeDoctor.exitCode === 1) && - doctorReport.ok === (nativeDoctor.exitCode === 0) && - Number.isInteger(doctorReport.checksRun) && - doctorReport.checksRun > 0 && - Array.isArray(doctorReport.findings), + doctorReports.length === 1 && + (nativeDoctor.exitCode === 0 || nativeDoctor.exitCode === 1) && + doctorReport?.ok === (nativeDoctor.exitCode === 0) && + Number.isInteger(doctorReport?.checksRun) && + Number(doctorReport?.checksRun) > 0 && + Array.isArray(doctorReport?.findings), resultText(nativeDoctor), ).toBe(true); const pathProbe = await host.command( diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index 9fb1d1617b5..9b55515b498 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -196,14 +196,6 @@ async function captureDiagnosticsBestEffort(run: () => Promise): Promis } } -async function postDestroyGatewayBestEffort(run: () => Promise): Promise { - try { - await run(); - } catch { - // The explicit sandbox-destroy assertion remains the primary phase-7 contract. - } -} - // source-shape-contract: security -- Live registry absence proves explicit destroy removes the sandbox record without trusting CLI output test( "hermes-e2e: install.sh onboards Hermes and proves health plus live inference", @@ -211,7 +203,7 @@ test( timeout: testTimeout(HERMES_E2E_TEST_TIMEOUT_MS), meta: { e2ePhases: HERMES_E2E_PHASES }, }, - async ({ artifacts, cleanup, host, inference, progress, runtimeProvider, sandbox }) => { + async ({ artifacts, cleanup, host, inference, lifecycle, progress, runtimeProvider, sandbox }) => { await artifacts.target.declare({ id: "hermes-e2e", boundary: `install.sh --non-interactive --fresh + Hermes sandbox runtime + ${inference.mode} inference adapter`, @@ -273,6 +265,7 @@ test( }; const cleanupEnv = commandEnv(); + lifecycle.trackInstallerGatewayUserService(); cleanup.trackGateway(host, "nemoclaw", { artifactName: "cleanup-openshell-gateway-destroy", env: cleanupEnv, @@ -325,7 +318,8 @@ test( printf '%s\n' '== pid 1 ==' tr '\0' ' ' /dev/null || true printf '\n%s\n' '== process tree ==' - ps -eo user=,pid=,ppid=,stat=,args= 2>&1 || true + ps -eo user=,pid=,ppid=,stat=,wchan:32=,etime=,args= 2>&1 || true + head -v -n 32 /sys/fs/cgroup/memory.current /sys/fs/cgroup/memory.peak /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory.events /sys/fs/cgroup/pids.current /sys/fs/cgroup/pids.max /sys/fs/cgroup/pids.events 2>&1 printf '%s\n' '== entrypoint log ==' tail -n 300 /tmp/nemoclaw-start.log 2>&1 || true printf '%s\n' '== gateway log ==' @@ -905,13 +899,6 @@ test( timeoutMs: 120_000, }); expect(destroy.exitCode, resultText(destroy)).toBe(0); - await postDestroyGatewayBestEffort(() => - sandbox.openshell(["gateway", "destroy", "-g", "nemoclaw"], { - artifactName: "phase-7-openshell-gateway-destroy", - env: commandEnv(), - timeoutMs: 60_000, - }), - ); expect( registryEntry(SANDBOX_NAME), `${SANDBOX_NAME} still in ${REGISTRY_FILE}`, diff --git a/test/e2e/live/pi-agent-qualification.test.ts b/test/e2e/live/pi-agent-qualification.test.ts index 5d3a9fce38f..04d8e1795fd 100644 --- a/test/e2e/live/pi-agent-qualification.test.ts +++ b/test/e2e/live/pi-agent-qualification.test.ts @@ -297,6 +297,7 @@ test( NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NEMOCLAW_AGENT: "pi", NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG: catalogPath, + NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG_JSON: "", NEMOCLAW_NON_INTERACTIVE: "1", NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME, OPENSHELL_DRIVERS: "docker", diff --git a/test/e2e/support/e2e-phase-lifecycle.test.ts b/test/e2e/support/e2e-phase-lifecycle.test.ts index f30bc116a11..38f406892c2 100644 --- a/test/e2e/support/e2e-phase-lifecycle.test.ts +++ b/test/e2e/support/e2e-phase-lifecycle.test.ts @@ -1,11 +1,12 @@ // 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, expectTypeOf, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, expectTypeOf, it } from "vitest"; import { type CommandRunner, @@ -137,6 +138,82 @@ function restoreEnv(name: string, value: string | undefined): void { Object.assign(process.env, value === undefined ? {} : { [name]: value }); } +describe("LifecyclePhaseFixture.trackInstallerGatewayUserService", () => { + let root: string, config: string, unit: string; + let previousConfig: string | undefined, previousPath: string | undefined; + let runner: FakeRunner, cleanup: FakeCleanup; + const marker = "# NEMOCLAW_MANAGED_OPENSHELL_GATEWAY=1\n"; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-service-cleanup-")); + config = path.join(root, "config"); + unit = path.join(config, "systemd", "user", "nemoclaw-openshell-gateway.service"); + previousConfig = process.env.XDG_CONFIG_HOME; + previousPath = process.env.PATH; + process.env.XDG_CONFIG_HOME = config; + process.env.PATH = `${root}:${previousPath ?? ""}`; + fs.mkdirSync(path.dirname(unit), { recursive: true }); + fs.writeFileSync(path.join(root, "systemctl"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + runner = new FakeRunner(); + cleanup = new FakeCleanup(); + runner.run = async (command, options) => { + runner.calls.push({ command: command.command, args: [...command.args], options }); + const result = spawnSync(command.command, ["-c", command.args[1]!], { + env: options?.env, + encoding: "utf8", + timeout: 10_000, + }); + return shellResult(result.status ?? 1, `${result.stdout ?? ""}${result.stderr ?? ""}`); + }; + }); + + afterEach(() => { + restoreEnv("XDG_CONFIG_HOME", previousConfig); + restoreEnv("PATH", previousPath); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("removes a newly installed service last using the captured environment", async () => { + fixture(runner, cleanup).trackInstallerGatewayUserService(); + expect(runner.calls).toHaveLength(0); + expect(cleanup.calls).toHaveLength(1); + fs.writeFileSync(unit, marker); + process.env.XDG_CONFIG_HOME = path.join(root, "changed-config"); + cleanup.add("sandbox", () => { + expect(fs.existsSync(unit)).toBe(true); + }); + await cleanup.calls[1]!.run(); + await cleanup.calls[0]!.run(); + expect(fs.existsSync(unit)).toBe(false); + expect(runner.calls[0]?.options?.env?.XDG_CONFIG_HOME).toBe(config); + }); + + it.each([ + ["file", () => fs.writeFileSync(unit, marker)], + ["directory", () => fs.mkdirSync(unit)], + ["dangling symlink", () => fs.symlinkSync("missing", unit)], + ] as const)("preserves a preexisting %s", (_kind, create) => { + create(); + fixture(runner, cleanup).trackInstallerGatewayUserService(); + expect(cleanup.calls).toHaveLength(0); + expect(fs.lstatSync(unit)).toBeTruthy(); + }); + + it("refuses a foreign replacement during deferred cleanup", async () => { + fixture(runner, cleanup).trackInstallerGatewayUserService(); + fs.writeFileSync(unit, "foreign"); + await expect(cleanup.calls[0]!.run()).rejects.toThrow(/Refusing to remove foreign/); + expect(fs.readFileSync(unit, "utf8")).toBe("foreign"); + }); + + it("propagates inspection errors other than absence", () => { + fs.rmSync(config, { recursive: true }); + fs.writeFileSync(config, "foreign"); + expect(() => fixture(runner, cleanup).trackInstallerGatewayUserService()).toThrow(/ENOTDIR/); + expect(cleanup.calls).toHaveLength(0); + }); +}); + describe("LifecyclePhaseFixture.preparePostReboot", () => { it("installs OpenShell and stages the gateway user service when openshell-gateway is unavailable", async () => { const runner = new FakeRunner(); From 53becbaf1046fc10337ddfa7de6c85b64adc39f1 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 9 Sep 2026 01:26:19 -0700 Subject: [PATCH 5/9] test(sandbox): verify Pi profiles survive startup and recovery --- docs/manage-sandboxes/run-deep-agents-code.mdx | 4 +++- test/e2e/live/pi-agent-qualification.test.ts | 13 +++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/manage-sandboxes/run-deep-agents-code.mdx b/docs/manage-sandboxes/run-deep-agents-code.mdx index cebce222576..d5232e9840e 100644 --- a/docs/manage-sandboxes/run-deep-agents-code.mdx +++ b/docs/manage-sandboxes/run-deep-agents-code.mdx @@ -204,7 +204,9 @@ 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 + + +### Edit Personal Shell Files You can edit personal shell files under `/sandbox`, including `.bashrc`, `.profile`, and `.bash_profile`. Ordinary login and interactive shells use your personal files. diff --git a/test/e2e/live/pi-agent-qualification.test.ts b/test/e2e/live/pi-agent-qualification.test.ts index 04d8e1795fd..349de1a009d 100644 --- a/test/e2e/live/pi-agent-qualification.test.ts +++ b/test/e2e/live/pi-agent-qualification.test.ts @@ -253,7 +253,6 @@ async function runInteractiveTask( await artifacts.writeText("pi-interactive-terminal.txt", result.output); expect(result.timedOut).toBe(false); expect(result.firedTriggers).toContain(token); - expect(result.output).toContain(token); expect(result.exitCode).toBe(0); } @@ -272,7 +271,7 @@ test( "onboard Pi without a Dockerfile build", "run headless and interactive Pi tasks", "rebuild Pi and preserve session state", - "recover Pi after a gateway restart", + "recover Pi after sandbox and gateway restarts", "prove Pi policy and credential boundaries", "destroy Pi and publish bounded evidence", ], @@ -406,7 +405,7 @@ test( expect(sessionsAfterRebuild).toBe(sessionsBeforeRebuild); const rebuildProof = await runReadTask(artifacts, host, sandbox, env, "after-rebuild"); - progress.phase("recover Pi after a gateway restart"); + progress.phase("recover Pi after sandbox and gateway restarts"); const personalProfiles = await execPiShell( sandbox, trustedSandboxShellScript( @@ -415,13 +414,19 @@ test( { artifactName: "pi-personal-profiles-before-recovery", env, timeoutMs: 30_000 }, ); expect(personalProfiles.exitCode, resultText(personalProfiles)).toBe(0); + const restart = await host.command( + "bash", + ["-ec", '"$1" "$2" stop; "$1" "$2" start', "pi-sandbox-restart", host.commandPath, SANDBOX_NAME], + { artifactName: "pi-sandbox-stop-start", env, timeoutMs: 6 * 60_000 }, + ); + expect(restart.exitCode, resultText(restart)).toBe(0); await lifecycle.restartGatewayRuntime({ delayMs: 2_000, sandboxName: SANDBOX_NAME }); await lifecycle.waitForGatewayConnected({ attempts: 60, intervalMs: 5_000 }); const recoveryProof = await runReadTask(artifacts, host, sandbox, env, "after-recovery"); const profilesAfterRecovery = await execPiShell( sandbox, trustedSandboxShellScript( - "set -eu; /usr/bin/env -u NEMOCLAW_E2E_PI_PROFILE bash -lc 'test \"$NEMOCLAW_E2E_PI_PROFILE\" = preserved'; /usr/bin/env -u NEMOCLAW_E2E_PI_PROFILE bash -ic 'test \"$NEMOCLAW_E2E_PI_PROFILE\" = preserved'; sha256sum /sandbox/.bashrc /sandbox/.profile", + "set -eu; : >> /sandbox/.bashrc; : >> /sandbox/.profile; /usr/bin/env -u NEMOCLAW_E2E_PI_PROFILE bash -lc 'test \"$NEMOCLAW_E2E_PI_PROFILE\" = preserved'; /usr/bin/env -u NEMOCLAW_E2E_PI_PROFILE bash -ic 'test \"$NEMOCLAW_E2E_PI_PROFILE\" = preserved'; sha256sum /sandbox/.bashrc /sandbox/.profile", ), { artifactName: "pi-personal-profiles-after-recovery", env, timeoutMs: 30_000 }, ); From 153b30a258530a00244a4b139df3c3aa36603af3 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 9 Sep 2026 03:19:38 -0700 Subject: [PATCH 6/9] test(onboard): isolate N1x provider rejection from host readiness --- src/lib/onboard/provider-menu.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/onboard/provider-menu.test.ts b/src/lib/onboard/provider-menu.test.ts index f7c00e19e47..27bb18f75a6 100644 --- a/src/lib/onboard/provider-menu.test.ts +++ b/src/lib/onboard/provider-menu.test.ts @@ -160,6 +160,10 @@ describe("buildInferenceProviderMenu", () => { experimental: true, isNonInteractive: () => true, getNonInteractiveProvider: () => "nim-local", + discoverManagedLlamaCppSelections: () => ({ + choices: [], + resolution: { kind: "rejected", reason: "No llama.cpp profile in this NIM fixture" }, + }), detectInferenceProviderHostState: () => makeHostState({ gpuNimCapable: true, From adf8e767469f4b57650378e21291c45b253477ea Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 9 Sep 2026 04:37:57 -0700 Subject: [PATCH 7/9] test(ollama): load command builder before timed assertions --- src/lib/inference/ollama/windows.test.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/lib/inference/ollama/windows.test.ts b/src/lib/inference/ollama/windows.test.ts index 6691cba5adf..bf2f3c797e6 100644 --- a/src/lib/inference/ollama/windows.test.ts +++ b/src/lib/inference/ollama/windows.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const require = createRequire(import.meta.url); const WINDOWS_DIST_PATH = require.resolve("./windows"); +const { buildWindowsOllamaInstallerCommand } = require(WINDOWS_DIST_PATH); const DOCKER_ADAPTER_PATH = require.resolve("../../adapters/docker/runtime"); const PLATFORM_PATH = require.resolve("../../platform"); const RUNNER_PATH = require.resolve("../../runner"); @@ -309,15 +310,9 @@ describe("Windows Ollama helper", () => { }); it("leaves the persistent installer binding under the mutation transaction", () => { - const { windows, restore } = loadWindowsOllamaWithMocks(vi.fn(), vi.fn()); - - try { - const installerCommand = windows.buildWindowsOllamaInstallerCommand(); - expect(installerCommand).toContain("$env:OLLAMA_HOST='127.0.0.1:11434'"); - expect(installerCommand).not.toContain("SetEnvironmentVariable('OLLAMA_HOST'"); - } finally { - restore(); - } + const installerCommand = buildWindowsOllamaInstallerCommand(); + expect(installerCommand).toContain("$env:OLLAMA_HOST='127.0.0.1:11434'"); + expect(installerCommand).not.toContain("SetEnvironmentVariable('OLLAMA_HOST'"); }); it("terminates the PowerShell wrapper when cancellation precedes the PID sentinel", async () => { From cdf1225e8e81dd1bfce6bd6cb2f26c4150597789 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 9 Sep 2026 05:29:45 -0700 Subject: [PATCH 8/9] docs(sandbox): clarify existing image profile updates --- docs/deployment/sandbox-hardening.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/deployment/sandbox-hardening.mdx b/docs/deployment/sandbox-hardening.mdx index 7475f68f117..1b345c12371 100644 --- a/docs/deployment/sandbox-hardening.mdx +++ b/docs/deployment/sandbox-hardening.mdx @@ -135,6 +135,7 @@ System paths remain read-only for these protections: Personal shell files `.bashrc` and `.profile` belong to the sandbox user and can be edited. System-wide shell hooks load runtime proxy settings from `/tmp/nemoclaw-proxy-env.sh`, which retains mode `0444`. +After updating NemoClaw, [rebuild existing sandboxes](../operate-sandboxes/recover-and-rebuild-sandboxes) to apply these profile permissions. ### Landlock Kernel Requirements From 7fc576c0702b1a5dd4752a98ea5414cacfd24b59 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 9 Sep 2026 06:14:37 -0700 Subject: [PATCH 9/9] test(onboard): isolate resume fixture from runtime preflight --- test/onboarding/onboard-inference-reconciliation.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/onboarding/onboard-inference-reconciliation.test.ts b/test/onboarding/onboard-inference-reconciliation.test.ts index 8adc8f63837..962025b39fc 100644 --- a/test/onboarding/onboard-inference-reconciliation.test.ts +++ b/test/onboarding/onboard-inference-reconciliation.test.ts @@ -210,8 +210,8 @@ describe("onboard helpers", () => { const preflightPath = JSON.stringify( path.join(repoRoot, "src", "lib", "onboard", "preflight.ts"), ); - const bridgeDnsPreflightPath = JSON.stringify( - path.join(repoRoot, "src", "lib", "onboard", "bridge-dns-preflight.ts"), + const runtimeEffectfulPreflightPath = JSON.stringify( + path.join(repoRoot, "src/lib/onboard/machine/runtime-effectful-preflight.ts"), ); fs.mkdirSync(fakeBin, { recursive: true }); @@ -250,8 +250,8 @@ preflight.assessHost = () => ({ nvidiaContainerToolkitInstalled: false, notes: [], }); -const bridgeDnsPreflight = require(${bridgeDnsPreflightPath}); -bridgeDnsPreflight.assertDockerBridgeAndContainerDnsHealthy = () => {}; +const runtimeEffectfulPreflight = require(${runtimeEffectfulPreflightPath}); +runtimeEffectfulPreflight.bindConfiguredRuntimeProviderHealth = () => () => {}; const preflightGatewayAuthority = require(${preflightGatewayAuthorityPath}); const createPreflightGatewayAuthority = preflightGatewayAuthority.createOnboardPreflightGatewayAuthority;