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/Dockerfile b/Dockerfile
index 393a4ad57bc..f6ae9b23396 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
@@ -1659,7 +1658,9 @@ RUN case "$NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION" in \
&& command -v setpriv >/dev/null 2>&1
WORKDIR /sandbox
-RUN test "$(id -u sandbox):$(id -g sandbox):$(pwd)" = "998:998:/sandbox"
+RUN test "$(id -u sandbox):$(id -g sandbox):$(pwd)" = "998:998:/sandbox" \
+ && chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile \
+ && chmod 644 /sandbox/.bashrc /sandbox/.profile
USER sandbox
# Write openclaw.json with gateway config but WITHOUT the real auth token.
@@ -1935,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 \
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 4a9e73982da..fb891e24cd1 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 a6357eea038..f0df1123f02 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/full-e2e-cold-path-calibration.json b/ci/full-e2e-cold-path-calibration.json
index 3fabe53ee90..caef53c2bd7 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/extra-agents-validation.ts",
"src/lib/messaging",
diff --git a/ci/pi-agent-qualification-v1-linux-amd64.json b/ci/pi-agent-qualification-v1-linux-amd64.json
index c215d82e4d1..3d142d3af13 100644
--- a/ci/pi-agent-qualification-v1-linux-amd64.json
+++ b/ci/pi-agent-qualification-v1-linux-amd64.json
@@ -3,13 +3,13 @@
"agent": "pi",
"platform": "linux/amd64",
"image": "ghcr.io/nvidia/nemoclaw/pi-sandbox",
- "digest": "sha256:bf9d72ffc4dd36e9567aff047ec16c7434a53281310020dd4b12efb1b6ec59e3",
- "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:bf9d72ffc4dd36e9567aff047ec16c7434a53281310020dd4b12efb1b6ec59e3",
+ "digest": "sha256:32416a25b4adf069edf71d8b022ed5a3eb980cdb259e6580a43978414b228b6e",
+ "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:32416a25b4adf069edf71d8b022ed5a3eb980cdb259e6580a43978414b228b6e",
"source": {
"repository": "NVIDIA/NemoClaw",
- "revision": "7d588356f720c0c09e860e83baa359456a63410f",
+ "revision": "375c76a4646c8f4f9537ac9c18370e519354bbc7",
"release": "v0.1.0",
- "cohort": "ghrun-34045063725-1"
+ "cohort": "ghrun-34305816131-1"
},
"startupProfileContractVersion": 1,
"capabilityContractVersion": 1
diff --git a/ci/pi-agent-qualification-v1-linux-arm64.json b/ci/pi-agent-qualification-v1-linux-arm64.json
index 39ca5166948..bb6b8d52f00 100644
--- a/ci/pi-agent-qualification-v1-linux-arm64.json
+++ b/ci/pi-agent-qualification-v1-linux-arm64.json
@@ -3,13 +3,13 @@
"agent": "pi",
"platform": "linux/arm64",
"image": "ghcr.io/nvidia/nemoclaw/pi-sandbox",
- "digest": "sha256:396c89ada5657e8a3247e61d33357d764aba4901b8ac98320398e4e006c0afc6",
- "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:396c89ada5657e8a3247e61d33357d764aba4901b8ac98320398e4e006c0afc6",
+ "digest": "sha256:9bd80aa090f8bc51db8b1eead1a774d01d926ad0dc2d1ed629447db3f4dd7c72",
+ "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:9bd80aa090f8bc51db8b1eead1a774d01d926ad0dc2d1ed629447db3f4dd7c72",
"source": {
"repository": "NVIDIA/NemoClaw",
- "revision": "7d588356f720c0c09e860e83baa359456a63410f",
+ "revision": "375c76a4646c8f4f9537ac9c18370e519354bbc7",
"release": "v0.1.0",
- "cohort": "ghrun-34045063725-1"
+ "cohort": "ghrun-34305816131-1"
},
"startupProfileContractVersion": 1,
"capabilityContractVersion": 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/docs/deployment/sandbox-hardening.mdx b/docs/deployment/sandbox-hardening.mdx
index 2640c721333..1b345c12371 100644
--- a/docs/deployment/sandbox-hardening.mdx
+++ b/docs/deployment/sandbox-hardening.mdx
@@ -133,8 +133,9 @@ 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`.
+After updating NemoClaw, [rebuild existing sandboxes](../operate-sandboxes/recover-and-rebuild-sandboxes) to apply these profile permissions.
### 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..d5232e9840e 100644
--- a/docs/manage-sandboxes/run-deep-agents-code.mdx
+++ b/docs/manage-sandboxes/run-deep-agents-code.mdx
@@ -204,19 +204,16 @@ Stdio commands, extra headers, raw credentials, and unrelated top-level configur
For authenticated MCP setup and credential rotation, refer to [Add an MCP Server](../mcp-servers/add-an-mcp-server) and [Manage MCP Servers](../mcp-servers/manage-mcp-servers).
This isolated-mode guarantee applies to the managed launchers, not arbitrary Python commands in the sandbox.
-### Protect the Managed Login Profile
+
-Managed Deep Agents Code images reserve `/sandbox/.bash_profile` as the first Bash login profile for OpenShell command sessions.
-The file is `root:root` mode `0444`, and `/sandbox` is `root:sandbox` mode `1775`.
-The sticky directory keeps normal workspace writes available while preventing the `sandbox` user from deleting or replacing the root-owned profile.
+### Edit Personal Shell 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.
+You can edit personal shell files under `/sandbox`, including `.bashrc`, `.profile`, and `.bash_profile`.
+Ordinary login and interactive shells use your personal files.
-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/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 9af90d2d050..22d248a8468 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 -I - "$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/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/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/agent/candidate-authority.ts b/src/lib/agent/candidate-authority.ts
index 304f2361d8d..c284790d74b 100644
--- a/src/lib/agent/candidate-authority.ts
+++ b/src/lib/agent/candidate-authority.ts
@@ -13,8 +13,8 @@ export const CANDIDATE_QUALIFICATION_RECEIPT_DIGESTS: Readonly<
Record
> = Object.freeze({
pi: Object.freeze([
- "c5720a89713b45e7811350ca589f880458d2b5d8b6c05d04840122b0e0b79444",
- "2251b284f3564b3c55bf824bb2ae2153ef88e05e92909ad568b6cb91be7dbc56",
+ "147667683238332c62c6df3fb1e56056272f47cc7a64bd30fe50eee9bf041685",
+ "e3ed23b52ec8091cb30a4a04c53b2201ef8c1e0c34a58d7f46013108759dcb0e",
]),
});
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/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 () => {
diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts
index 2b5e743f6cf..83a887c73af 100644
--- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts
+++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts
@@ -76,7 +76,7 @@ const CANONICAL_POST_GENERATOR_INSTRUCTION_SHA256 = new Set([
"8f5966da093ef75cefd35c2b7f1361fbf5b32e63a4a8a34cb3ac7f76a1330e5e",
"c6b042ac2cc3d5570ae43f1e387a951cbe89fbb22e8cd9df486f99719bb32939",
"761ea4fbb0da5cf3390ee8f2e56f3703a7da88c96ac47fea89d0fff800f728ae",
- "968f9f68581b892dc5f3c48f7d4ee7bb9f3fd49e634e606abfccd35ae27204d4",
+ "bda3ce8d453fa143943eac44bea4288e44a3cdbf5e9e54a3da68154f06d08c3d",
// COPY --from=openclaw-runtime-payload / /
// The reviewed scratch payload has no /sandbox/.openclaw content, so this
// exact late copy preserves the generated remote-dashboard configuration.
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/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,
diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts
index d36d8f5ec1f..5630f7e2246 100644
--- a/src/lib/sandbox/build-context.ts
+++ b/src/lib/sandbox/build-context.ts
@@ -347,10 +347,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..a3aa6eac181 100644
--- a/test/agents/deepagents/dcode-login-profile.test.ts
+++ b/test/agents/deepagents/dcode-login-profile.test.ts
@@ -2,98 +2,50 @@
// 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 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",
+ '. agents/langchain-deepagents-code/dcode-login-profile.sh; printf \'%s\\n\' "$HOME" "${BASH_ENV-unset}" "${ENV-unset}"; ' +
+ command,
+ ],
+ {
+ encoding: "utf8",
+ cwd: repoRoot,
+ env: {
+ 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/agents/openclaw/runtime/pi-candidate-runtime-artifacts.test.ts b/test/agents/openclaw/runtime/pi-candidate-runtime-artifacts.test.ts
index 82195fe6539..514dd96820b 100644
--- a/test/agents/openclaw/runtime/pi-candidate-runtime-artifacts.test.ts
+++ b/test/agents/openclaw/runtime/pi-candidate-runtime-artifacts.test.ts
@@ -46,6 +46,9 @@ describe("Pi release cohort separation", () => {
describe("Pi candidate contract validation", () => {
it("accepts an exact candidate contract", () => {
const contract = validateCandidateContract(candidateContract(), "linux/amd64");
+ expect(contract.agent).toBe("pi");
+ expect(contract.platform).toBe("linux/amd64");
+ expect(contract.source.repository).toBe("NVIDIA/NemoClaw");
expect(contract.reference).toBe(`ghcr.io/nvidia/nemoclaw/pi-sandbox@${DIGEST}`);
});
diff --git a/test/automation/pull-requests/pr-risk-plan.test.ts b/test/automation/pull-requests/pr-risk-plan.test.ts
index 0c20bbf1aba..c79190d7099 100644
--- a/test/automation/pull-requests/pr-risk-plan.test.ts
+++ b/test/automation/pull-requests/pr-risk-plan.test.ts
@@ -1009,7 +1009,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/README.md b/test/e2e/README.md
index 2218866e37b..6109cffd242 100644
--- a/test/e2e/README.md
+++ b/test/e2e/README.md
@@ -1422,9 +1422,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 72ec2aa0ba9..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
@@ -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_PROFILE_MARKER="/tmp/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,45 @@ 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.
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="$(
+ "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' '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" -- \
+ /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 \
- "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
+ "/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" -- /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"
+ # 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 shells rewrote personal profiles"
+ 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/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/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/full-e2e.test.ts b/test/e2e/live/full-e2e.test.ts
index 49950d31841..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";
@@ -128,12 +129,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 +170,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",
@@ -390,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
@@ -423,7 +434,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",
+ ]
: []),
],
});
@@ -435,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(),
@@ -514,6 +528,24 @@ 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 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(
+ 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(
"bash",
[
@@ -523,7 +555,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 03809ccb80a..087624979c9 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,
@@ -323,9 +316,10 @@ test(
trustedSandboxShellScript(
String.raw`
printf '%s\n' '== pid 1 =='
- tr '\0' ' ' /dev/null || true
+ cat /proc/1/comm 2>&1 || 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=,comm= 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 =='
@@ -455,21 +449,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 '%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-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 +746,24 @@ test(
);
}
+ const personalProfiles = await sandbox.exec(
+ SANDBOX_NAME,
+ [
+ "/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(),
+ timeoutMs: 30_000,
+ },
+ );
+ expect(personalProfiles.exitCode, resultText(personalProfiles)).toBe(0);
+
const recoveredHealth = await host.command(
"curl",
["-sf", "--max-time", "10", HERMES_HOST_HEALTH_URL],
@@ -880,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 55cfe6421ea..c80e706552a 100644
--- a/test/e2e/live/pi-agent-qualification.test.ts
+++ b/test/e2e/live/pi-agent-qualification.test.ts
@@ -82,9 +82,10 @@ while (stack.length > 0 && files < 10000 && bytes < 32 * 1024 * 1024) {
}
}
const dockerSockets = ["/var/run/docker.sock", "/run/docker.sock"].filter((candidate) => fs.existsSync(candidate));
-const result = {upstreamCredentialNames, credentialFiles, dockerSockets, files, bytes};
+const rootProfileLoaded = fs.existsSync("/tmp/nemoclaw-e2e-root-profile-loaded");
+const result = {upstreamCredentialNames, credentialFiles, dockerSockets, rootProfileLoaded, files, bytes};
process.stdout.write(JSON.stringify(result) + "\n");
-process.exit(upstreamCredentialNames.length === 0 && credentialFiles.length === 0 && dockerSockets.length === 0 ? 0 : 1);
+process.exit(upstreamCredentialNames.length === 0 && credentialFiles.length === 0 && dockerSockets.length === 0 && !rootProfileLoaded ? 0 : 1);
`;
const NETWORK_DENIAL_PROBE = String.raw`
const timer = setTimeout(() => process.exit(2), 20000);
@@ -253,7 +254,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 +272,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",
],
@@ -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",
@@ -319,9 +320,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(
@@ -406,10 +406,33 @@ 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(
+ "set -euo pipefail; printf '%s\\n' '' 'export NEMOCLAW_E2E_PI_PROFILE=preserved' 'case \"$(id -u)\" in 0) touch /tmp/nemoclaw-e2e-root-profile-loaded ;; esac' | tee -a /sandbox/.bashrc /sandbox/.profile >/dev/null; sha256sum /sandbox/.bashrc /sandbox/.profile",
+ ),
+ { 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; : >> /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 },
+ );
+ 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/mock-parity.json b/test/e2e/mock-parity.json
index 69f4a227d70..383379f80a6 100644
--- a/test/e2e/mock-parity.json
+++ b/test/e2e/mock-parity.json
@@ -159,6 +159,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",
@@ -530,6 +531,7 @@
"fast": [
"src/lib/actions/sandbox/skill-install.test.ts",
"test/agents/hermes/hermes-image-build-probes.test.ts",
+ "test/agents/hermes/hermes-start.test.ts",
"test/e2e/support/hermes-cli-adapter-live.test.ts",
"test/e2e/support/managed-image-cohort-contract.test.ts",
"test/e2e/support/managed-image-receipt.test.ts",
@@ -537,6 +539,7 @@
"test/e2e/support/e2e-cleanup-resources.test.ts",
"test/e2e/support/e2e-clients.test.ts",
"test/e2e/support/inference-adapter.test.ts",
+ "test/e2e/support/security-posture.test.ts",
"test/e2e/support/workflow-plan.test.ts",
"test/helpers/e2e-answer-assertions.test.ts"
]
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/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();
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/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;
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 f066c72c5f6..c2958a7a9f7 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 1eb22f90922..8083eb35c65 100644
--- a/test/runtime/sandbox/sandbox-init.test.ts
+++ b/test/runtime/sandbox/sandbox-init.test.ts
@@ -129,46 +129,29 @@ function restoreTmpArtifacts(paths: string[], backups: Record):
describe("scripts/lib/sandbox-init.sh", () => {
describe("Python startup isolation", () => {
- it.each([
- {
- operation: "lock_rc_files",
- observe: (rcFile: string, _stdout: string) => lstatSync(rcFile).mode & 0o777,
- expected: 0o444,
- },
- {
- operation: "read_messaging_plan_channels",
- observe: (_rcFile: string, stdout: string) => stdout,
- expected: "telegram",
- },
- ])(
- "ignores inherited PYTHONPATH in $operation",
- ({ operation, observe, expected }) => {
- const workDir = mkdtempSync(join(tmpdir(), "sandbox-init-python-"));
- const sentinel = join(workDir, "sitecustomize-ran");
- const rcFile = join(workDir, ".bashrc");
- writeFileSync(rcFile, "# fixture\n", { mode: 0o600 });
- writeFileSync(
- join(workDir, "sitecustomize.py"),
- 'import os\nfrom pathlib import Path\nPath(os.environ["TEST_PYTHON_SENTINEL"]).write_text("executed")\n',
- );
- try {
- const result = runWithLib(`${operation} "$TEST_PYTHON_HOME"`, {
- env: {
- PYTHONPATH: workDir,
- TEST_PYTHON_HOME: workDir,
- TEST_PYTHON_SENTINEL: sentinel,
- NEMOCLAW_MESSAGING_PLAN_B64: Buffer.from(
- JSON.stringify({ channels: [{ channelId: "telegram", active: true }] }),
- ).toString("base64"),
- },
- });
- expect(existsSync(sentinel)).toBe(false);
- expect(observe(rcFile, result.stdout)).toBe(expected);
- } finally {
- rmSync(workDir, { recursive: true, force: true });
- }
- },
- );
+ it("ignores inherited PYTHONPATH in read_messaging_plan_channels", () => {
+ const workDir = mkdtempSync(join(tmpdir(), "sandbox-init-python-"));
+ const sentinel = join(workDir, "sitecustomize-ran");
+ writeFileSync(
+ join(workDir, "sitecustomize.py"),
+ 'import os\nfrom pathlib import Path\nPath(os.environ["TEST_PYTHON_SENTINEL"]).write_text("executed")\n',
+ );
+ try {
+ const result = runWithLib("read_messaging_plan_channels", {
+ env: {
+ PYTHONPATH: workDir,
+ TEST_PYTHON_SENTINEL: sentinel,
+ NEMOCLAW_MESSAGING_PLAN_B64: Buffer.from(
+ JSON.stringify({ channels: [{ channelId: "telegram", active: true }] }),
+ ).toString("base64"),
+ },
+ });
+ expect(existsSync(sentinel)).toBe(false);
+ expect(result.stdout).toBe("telegram");
+ } finally {
+ rmSync(workDir, { recursive: true, force: true });
+ }
+ });
});
describe("emit_sandbox_sourced_file", () => {
@@ -362,58 +345,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", () => {
@@ -1026,7 +957,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 77cb49af2a7..0fa287ecbaf 100644
--- a/test/runtime/sandbox/sandbox-provisioning-helper-permissions.test.ts
+++ b/test/runtime/sandbox/sandbox-provisioning-helper-permissions.test.ts
@@ -101,7 +101,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/runtime/sandbox/sandbox-provisioning.test.ts b/test/runtime/sandbox/sandbox-provisioning.test.ts
index 5b1f12a3bbb..aeb446503b8 100644
--- a/test/runtime/sandbox/sandbox-provisioning.test.ts
+++ b/test/runtime/sandbox/sandbox-provisioning.test.ts
@@ -828,7 +828,7 @@ describe("sandbox provisioning: unified .openclaw layout (#2227)", () => {
);
});
- it("provisions unified mutable .openclaw layout and clean trusted rc files", () => {
+ it("provisions unified mutable .openclaw layout and editable personal profiles", () => {
const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-layout-"));
const sandboxRoot = path.join(tmp, "sandbox");
@@ -875,12 +875,12 @@ describe("sandbox provisioning: unified .openclaw layout (#2227)", () => {
const content = fs.readFileSync(rcPath, "utf-8");
expect(content.toLowerCase()).not.toContain("proxy");
expect(content).not.toContain("/tmp/nemoclaw-proxy-env.sh");
- expect((fs.statSync(rcPath).mode & 0o777).toString(8)).toBe("444");
+ expect((fs.statSync(rcPath).mode & 0o777).toString(8)).toBe("644");
});
expect(rc.calls).toContain(
- `chown root:root ${path.join(sandboxRoot, ".bashrc")} ${path.join(sandboxRoot, ".profile")}`,
+ `chown sandbox:sandbox ${path.join(sandboxRoot, ".bashrc")} ${path.join(sandboxRoot, ".profile")}`,
);
- expect(rc.calls).not.toContain("sandbox:sandbox");
+ expect(rc.calls).not.toContainEqual(expect.stringContaining("chown root:root"));
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
@@ -1110,19 +1110,14 @@ describe("Hermes sandbox provisioning", () => {
}
const finalizeImageLayout = path.join(tmp, "finalize-image-layout.sh");
fs.copyFileSync(HERMES_FINALIZE_IMAGE_LAYOUT, finalizeImageLayout);
- const finalizeImageLayoutSha256 = dockerfile.match(
- /^ARG NEMOCLAW_HERMES_FINALIZE_IMAGE_LAYOUT_SHA256=([a-f0-9]{64})$/mu,
- )?.[1] ?? "";
+ const finalizeImageLayoutSha256 =
+ dockerfile.match(
+ /^ARG NEMOCLAW_HERMES_FINALIZE_IMAGE_LAYOUT_SHA256=([a-f0-9]{64})$/mu,
+ )?.[1] ?? "";
const command = dockerRunCommandBetween(dockerfile, startMarker, endMarker)
.replaceAll("/root/.cache/pip", path.join(tmp, "root-cache", "pip"))
- .replaceAll(
- "/opt/nemoclaw-hermes-config/finalize-image-layout.sh",
- finalizeImageLayout,
- )
- .replaceAll(
- "$NEMOCLAW_HERMES_FINALIZE_IMAGE_LAYOUT_SHA256",
- finalizeImageLayoutSha256,
- );
+ .replaceAll("/opt/nemoclaw-hermes-config/finalize-image-layout.sh", finalizeImageLayout)
+ .replaceAll("$NEMOCLAW_HERMES_FINALIZE_IMAGE_LAYOUT_SHA256", finalizeImageLayoutSha256);
const result = runDockerShell(command, sandboxRoot);
return { ...result, tmp, sandboxRoot };
}
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)"',
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 }}",