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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions scripts/lib/sandbox-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ _SANDBOX_INIT_LOADED=1
# /tmp/nemoclaw-proxy-env.sh root 444 root sandbox YES (/etc shell hooks)
# /tmp/gateway.log gateway 644 gateway all no (world-readable for diagnostics)
# /tmp/auto-pair.log sandbox 600 sandbox sandbox no
# /tmp/nemoclaw-plugin-refresh.log sandbox 600 sandbox sandbox no (OpenClaw refresh output)
# /tmp/.npm-cache/ sandbox 755 sandbox sandbox no (tool data)
# /tmp/.cache/ sandbox 755 sandbox sandbox no (tool data)
# /tmp/.config/ sandbox 755 sandbox sandbox no (tool data)
Expand Down Expand Up @@ -119,18 +120,44 @@ validate_tmp_permissions() {
done

# Restricted log files — gateway.log may be 600 (Hermes) or 644 (OpenClaw,
# world-readable for diagnostics). auto-pair.log is 600.
for f in /tmp/gateway.log /tmp/auto-pair.log; do
[ -f "$f" ] || continue
local perms
# world-readable for diagnostics). auto-pair.log is 600. The plugin-refresh
# log is written after privilege drop as sandbox, so keep it private and
# reject symlinks/non-regular files before launching services. OpenClaw's
# entrypoint sets PLUGIN_REFRESH_LOG; shared tests can override it while
# production keeps the fixed /tmp path.
local plugin_refresh_log="${PLUGIN_REFRESH_LOG:-/tmp/nemoclaw-plugin-refresh.log}"
for f in /tmp/gateway.log /tmp/auto-pair.log "$plugin_refresh_log"; do
[ -e "$f" ] || [ -L "$f" ] || continue
if [ -L "$f" ]; then
echo "[SECURITY] $f is a symlink (expected regular log file)" >&2
failed=1
continue
fi
if [ ! -f "$f" ]; then
echo "[SECURITY] $f is not a regular file" >&2
failed=1
continue
fi
local perms owner
perms="$(stat -c '%a' "$f" 2>/dev/null || stat -f '%Lp' "$f" 2>/dev/null || echo "unknown")"
owner="$(stat -c '%U' "$f" 2>/dev/null || stat -f '%Su' "$f" 2>/dev/null || echo "unknown")"
case "$f" in
*/gateway.log)
if [ "$perms" != "600" ] && [ "$perms" != "644" ]; then
echo "[SECURITY] $f has unexpected permissions: mode=$perms (expected 600 or 644)" >&2
failed=1
fi
;;
*/nemoclaw-plugin-refresh.log)
if [ "$perms" != "600" ]; then
echo "[SECURITY] $f has unexpected permissions: mode=$perms (expected 600)" >&2
failed=1
fi
if [ "$(id -u)" -eq 0 ] && [ "$owner" != "sandbox" ]; then
echo "[SECURITY] $f has unsafe owner: owner=$owner (expected sandbox)" >&2
failed=1
fi
;;
*)
if [ "$perms" != "600" ]; then
echo "[SECURITY] $f has unexpected permissions: mode=$perms (expected 600)" >&2
Expand Down
91 changes: 91 additions & 0 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2880,6 +2880,72 @@ setup_auth_profile_as_sandbox() {
harden_auth_profiles
}

PLUGIN_REFRESH_LOG="/tmp/nemoclaw-plugin-refresh.log"

prepare_plugin_refresh_log() {
local dir base tmp
dir="$(dirname "$PLUGIN_REFRESH_LOG")"
base="$(basename "$PLUGIN_REFRESH_LOG")"

if [ -L "$PLUGIN_REFRESH_LOG" ]; then
echo "[SECURITY] refusing to use symlinked plugin-refresh log: $PLUGIN_REFRESH_LOG" >&2
return 1
fi
if [ -e "$PLUGIN_REFRESH_LOG" ] && [ ! -f "$PLUGIN_REFRESH_LOG" ]; then
echo "[SECURITY] refusing to use non-regular plugin-refresh log: $PLUGIN_REFRESH_LOG" >&2
return 1
fi

# Create the log through a same-directory temp file and rename it into place.
# Root never opens the sandbox-controlled final /tmp path, and the refresh
# command below performs its redirection after dropping to the sandbox user.
tmp="$(mktemp "${dir}/.${base}.tmp.XXXXXX")" || return 1
if [ "$(id -u)" -eq 0 ] && ! chown sandbox:sandbox "$tmp"; then
rm -f "$tmp"
return 1
fi
if ! chmod 600 "$tmp"; then
rm -f "$tmp"
return 1
fi
if ! mv -f "$tmp" "$PLUGIN_REFRESH_LOG"; then
rm -f "$tmp"
return 1
fi
}

start_plugin_registry_refresh() {
(
local ready=0
for _ in 1 2 3 4 5 6 7 8 9 10; do
if [ "$(id -u)" -eq 0 ]; then
if "${STEP_DOWN_PREFIX_SANDBOX[@]}" env HOME=/sandbox "$OPENCLAW" gateway status >/dev/null 2>&1; then
ready=1
break
fi
elif env HOME=/sandbox "$OPENCLAW" gateway status >/dev/null 2>&1; then
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "[plugin-refresh] gateway did not become ready; skipping registry refresh" >&2
exit 0
fi
if [ "$(id -u)" -eq 0 ]; then
"${STEP_DOWN_PREFIX_SANDBOX[@]}" env HOME=/sandbox PLUGIN_REFRESH_LOG="$PLUGIN_REFRESH_LOG" \
sh -c "exec \"\$@\" >\"\$PLUGIN_REFRESH_LOG\" 2>&1" sh \
"$OPENCLAW" plugins registry --refresh || true
else
env HOME=/sandbox PLUGIN_REFRESH_LOG="$PLUGIN_REFRESH_LOG" \
sh -c "exec \"\$@\" >\"\$PLUGIN_REFRESH_LOG\" 2>&1" sh \
"$OPENCLAW" plugins registry --refresh || true
fi
) &
PLUGIN_REFRESH_PID=$!
}

# ── Main ─────────────────────────────────────────────────────────

# Migrate legacy symlink layout before anything else reads .openclaw
Expand Down Expand Up @@ -2977,6 +3043,8 @@ if [ "$(id -u)" -ne 0 ]; then
touch /tmp/auto-pair.log
chmod 600 /tmp/auto-pair.log

prepare_plugin_refresh_log || exit 1

# Defence-in-depth: verify /tmp file permissions before launching services.
# Pass the HTTP proxy-fix path so it is validated alongside proxy-env.sh
# (both are trust-boundary files; tampering would let the sandbox user
Expand All @@ -2994,13 +3062,15 @@ if [ "$(id -u)" -ne 0 ]; then
# Persistent mirror: see root-mode block for rationale.
start_persistent_gateway_log_mirror || exit 1
start_auto_pair
start_plugin_registry_refresh
# NOTE: PIDs are collected after launch; a signal arriving between trap
# registration and the final append is a small race window (same as before
# the shared-library refactor). Acceptable for entrypoint-level cleanup.
SANDBOX_CHILD_PIDS=("$GATEWAY_PID")
[ -n "${AUTO_PAIR_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$AUTO_PAIR_PID")
[ -n "${GATEWAY_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_TAIL_PID")
[ -n "${GATEWAY_LOG_PERSIST_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_PERSIST_PID")
[ -n "${PLUGIN_REFRESH_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$PLUGIN_REFRESH_PID")
# shellcheck disable=SC2034 # read by cleanup_on_signal from sandbox-init.sh
SANDBOX_WAIT_PID="$GATEWAY_PID"
trap cleanup_on_signal SIGTERM SIGINT
Expand Down Expand Up @@ -3105,6 +3175,8 @@ touch /tmp/auto-pair.log
chown sandbox:sandbox /tmp/auto-pair.log
chmod 600 /tmp/auto-pair.log

prepare_plugin_refresh_log || exit 1

# Provision per-agent workspaces for multi-agent OpenClaw deployments.
#
# OpenClaw can be configured with multiple named agents (agents.defaults.workspace
Expand Down Expand Up @@ -3224,13 +3296,32 @@ GATEWAY_LOG_TAIL_PID=$!
start_persistent_gateway_log_mirror || exit 1

start_auto_pair

# Re-register non-bundled plugins after the gateway's first policy-changed
# regen. Under GPU sandbox onboard, OpenClaw rebuilds plugins[] from bundled
# extensions only and drops path/npm-origin entries like the NemoClaw plugin
# and the WeChat plugin. Their installRecords survive on disk, but the runtime
# registry forgets them — so `/nemoclaw` is unreachable in the TUI and
# `openclaw plugins inspect nemoclaw` says "Plugin not found" (#2021).
# A `plugins registry --refresh` repopulates plugins[] from installRecords.
# Backgrounded so the gateway-wait loop is unblocked; failure is non-fatal.
# Source boundary: the lossy policy-changed rebuild lives in OpenClaw's registry
# regeneration path, outside NemoClaw. NemoClaw can only heal the initial
# post-start registry from persisted installRecords until upstream preserves
# path/npm-origin plugins itself. Later runtime policy mutations are owned by
# OpenClaw's upstream fix, not by this one-shot startup workaround. Remove this
# workaround after openclaw/openclaw#89606 ships and the full onboard E2E still
# proves /nemoclaw registration without the refresh.
start_plugin_registry_refresh

# NOTE: PIDs are collected after launch; a signal arriving between trap
# registration and the final append is a small race window (same as before
# the shared-library refactor). Acceptable for entrypoint-level cleanup.
SANDBOX_CHILD_PIDS=("$GATEWAY_PID")
[ -n "${AUTO_PAIR_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$AUTO_PAIR_PID")
[ -n "${GATEWAY_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_TAIL_PID")
[ -n "${GATEWAY_LOG_PERSIST_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_PERSIST_PID")
[ -n "${PLUGIN_REFRESH_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$PLUGIN_REFRESH_PID")
# shellcheck disable=SC2034 # read by cleanup_on_signal from sandbox-init.sh
SANDBOX_WAIT_PID="$GATEWAY_PID"
trap cleanup_on_signal SIGTERM SIGINT
Expand Down
18 changes: 15 additions & 3 deletions test/e2e-gateway-isolation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -513,8 +513,15 @@ fi
info "28. NEMOCLAW_MODEL_OVERRIDE patches openclaw.json"
OUT=$(docker run --rm -e NEMOCLAW_MODEL_OVERRIDE="test/override-model" \
--entrypoint "" "$IMAGE" bash -c '
# Source the entrypoint functions without running the full startup
source <(sed -n "/^apply_model_override/,/^}/p" /usr/local/bin/nemoclaw-start)
# Source the entrypoint function without running the full startup. Keep the
# extraction whitespace-tolerant and fail closed if the function cannot be
# found, instead of sourcing an empty snippet.
APPLY_MODEL_OVERRIDE_SNIPPET=$(sed -n "/^[[:space:]]*apply_model_override[[:space:]]*()[[:space:]]*{/,/^[[:space:]]*}[[:space:]]*$/p" /usr/local/bin/nemoclaw-start)
if [ -z "$APPLY_MODEL_OVERRIDE_SNIPPET" ]; then
echo "EXTRACT_FAIL apply_model_override"
exit 1
fi
source /dev/stdin <<<"$APPLY_MODEL_OVERRIDE_SNIPPET"
export NEMOCLAW_MODEL_OVERRIDE="test/override-model"
apply_model_override
python3 -c "
Expand Down Expand Up @@ -544,7 +551,12 @@ fi

info "29. No override when NEMOCLAW_MODEL_OVERRIDE is unset"
OUT=$(docker run --rm --entrypoint "" "$IMAGE" bash -c '
source <(sed -n "/^apply_model_override/,/^}/p" /usr/local/bin/nemoclaw-start)
APPLY_MODEL_OVERRIDE_SNIPPET=$(sed -n "/^[[:space:]]*apply_model_override[[:space:]]*()[[:space:]]*{/,/^[[:space:]]*}[[:space:]]*$/p" /usr/local/bin/nemoclaw-start)
if [ -z "$APPLY_MODEL_OVERRIDE_SNIPPET" ]; then
echo "EXTRACT_FAIL apply_model_override"
exit 1
fi
source /dev/stdin <<<"$APPLY_MODEL_OVERRIDE_SNIPPET"
ORIGINAL=$(python3 -c "import json; print(json.load(open(\"/sandbox/.openclaw/openclaw.json\"))[\"agents\"][\"defaults\"][\"model\"][\"primary\"])")
apply_model_override
AFTER=$(python3 -c "import json; print(json.load(open(\"/sandbox/.openclaw/openclaw.json\"))[\"agents\"][\"defaults\"][\"model\"][\"primary\"])")
Expand Down
37 changes: 37 additions & 0 deletions test/e2e/test-full-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,43 @@ else
fail "openshell policy get failed: ${policy_output:0:200}"
fi

# 3e: NemoClaw plugin remains registered after gateway policy initialization.
# Regression coverage for #2021: OpenClaw's policy-changed registry rebuild can
# drop path/npm-origin plugins from plugins[], which removes the /nemoclaw TUI
# command surface. The startup refresh should restore the registry before users
# interact with the sandbox. This non-interactive E2E cannot drive OpenClaw's
# terminal autocomplete directly; the interactive TUI/chat surface is owned by
# the openclaw-tui-chat-correlation-e2e scenario. Here we validate the runtime
# slash alias that the TUI consumes. The direct command help path is also
# probed, but only a NemoClaw-specific missing-command failure is fatal because
# OpenClaw can exit non-zero for unrelated plugin config warnings.
info "[PLUGIN] verifying NemoClaw plugin registry entry, slash alias, and command help..."
ssh_config="$(mktemp)"
plugin_check_output=""
plugin_check_timeout_cmd=()
command -v timeout >/dev/null 2>&1 && plugin_check_timeout_cmd=(timeout 90)
command -v gtimeout >/dev/null 2>&1 && plugin_check_timeout_cmd=(gtimeout 90)
if openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null; then
for plugin_attempt in 1 2 3 4 5; do
plugin_check_output=$("${plugin_check_timeout_cmd[@]}" ssh -F "$ssh_config" \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=10 \
-o LogLevel=ERROR \
"openshell-${SANDBOX_NAME}" \
"inspect_log=/tmp/nemoclaw-e2e-plugin-inspect.log; help_log=/tmp/nemoclaw-e2e-plugin-help.log; manifest=/sandbox/.openclaw/extensions/nemoclaw/openclaw.plugin.json; if ! HOME=/sandbox openclaw plugins inspect nemoclaw >\"\$inspect_log\" 2>&1; then printf 'inspect failed: '; head -c 600 \"\$inspect_log\"; exit 1; fi; if ! HOME=/sandbox openclaw nemoclaw --help >\"\$help_log\" 2>&1 && grep -Eiq '(nemoclaw|/nemoclaw).*(not found|not installed)|not found.*(nemoclaw|/nemoclaw)' \"\$help_log\"; then printf 'help missing nemoclaw: '; head -c 600 \"\$help_log\"; exit 1; fi; if ! grep -Eq '\"name\"[[:space:]]*:[[:space:]]*\"nemoclaw\"' \"\$manifest\"; then printf 'manifest missing nemoclaw name'; exit 1; fi; if ! grep -Eq '\"kind\"[[:space:]]*:[[:space:]]*\"runtime-slash\"' \"\$manifest\"; then printf 'manifest missing runtime-slash alias'; exit 1; fi; printf 'plugin-ok'" \
2>&1) || true
grep -Fq "plugin-ok" <<<"$plugin_check_output" && break
[ "$plugin_attempt" -lt 5 ] && sleep 3
done
fi
rm -f "$ssh_config"
if grep -Fq "plugin-ok" <<<"$plugin_check_output"; then
pass "NemoClaw OpenClaw plugin is registered with runtime slash alias"
else
fail "NemoClaw OpenClaw plugin registry/slash-alias/help check failed: ${plugin_check_output:0:300}"
fi

# ══════════════════════════════════════════════════════════════════
# Phase 4: Live inference — the real proof
# ══════════════════════════════════════════════════════════════════
Expand Down
Loading
Loading