From 3b4daa8404b8b7d71e7205202d3ff6ec89fb5829 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Tue, 2 Jun 2026 13:48:14 -0700 Subject: [PATCH 1/8] fix(sandbox): refresh plugin registry after gateway start to recover non-bundled plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under GPU sandbox onboard, OpenClaw's policy-change registry regen rebuilds plugins[] from bundled extensions only, dropping path-origin (nemoclaw) and npm-origin (openclaw-weixin) entries from the runtime registry view. Their installRecords survive on disk, but the runtime view forgets them — so the /nemoclaw slash command is unreachable in the TUI and `openclaw plugins inspect nemoclaw` returns "Plugin not found". Reproduced today byte-for-byte on a fresh Brev T4 GPU instance, matching wangericnv's evidence on a 0690 Spark (NVIDIA/NemoClaw#2021). Run `openclaw plugins registry --refresh` as the sandbox user after the gateway has started. Backgrounded so the gateway-wait loop is not blocked; PID is tracked in SANDBOX_CHILD_PIDS so SIGTERM still reaps it. Failure is non-fatal so the gateway can still serve other plugins. Runs once per cold start, so it also covers later policy mutations that re-trigger the regen. This is a temporary workaround. The permanent fix is upstream in OpenClaw's regen logic (openclaw/openclaw#89606) — once that lands, this block should be removed. Fixes #2021 Signed-off-by: Charan Jagwani --- scripts/nemoclaw-start.sh | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 1c33dc94507..0b09a81e2de 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3117,6 +3117,27 @@ 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. +# This is a temporary workaround; root fix is upstream (openclaw/openclaw#89606). +( + for _ in 1 2 3 4 5 6 7 8 9 10; do + if "$OPENCLAW" gateway status >/dev/null 2>&1; then break; fi + sleep 1 + done + "${STEP_DOWN_PREFIX_SANDBOX[@]}" env HOME=/sandbox \ + "$OPENCLAW" plugins registry --refresh \ + >/tmp/nemoclaw-plugin-refresh.log 2>&1 || true +) & +PLUGIN_REFRESH_PID=$! + # 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. @@ -3124,6 +3145,7 @@ 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 From 05b88331e3403c148961356db1227543b10b8ff2 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Tue, 2 Jun 2026 13:55:17 -0700 Subject: [PATCH 2/8] test(sandbox): cover post-gateway-start plugin registry refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the workaround block from scripts/nemoclaw-start.sh and drives it under bash with a stub openclaw binary. Verifies: 1. The refresh fires once `openclaw gateway status` reports ready. 2. The refresh runs with HOME=/sandbox even when the parent shell has HOME=/root — protects against the root-mode install bug where the plugin lands in /root/.openclaw/extensions/ instead of /sandbox/.openclaw/extensions/ and silently fails to repopulate the runtime plugins[]. 3. PLUGIN_REFRESH_PID is captured and appended to SANDBOX_CHILD_PIDS so SIGTERM cleanup reaps the backgrounded subshell. 4. The loop waits across multiple `gateway status` failures before refreshing — simulates cold-start where the gateway needs a few seconds to start serving. Behavior-shaped (not source-text); source-shape budget stays at 0. Signed-off-by: Charan Jagwani --- test/nemoclaw-start-plugin-refresh.test.ts | 177 +++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 test/nemoclaw-start-plugin-refresh.test.ts diff --git a/test/nemoclaw-start-plugin-refresh.test.ts b/test/nemoclaw-start-plugin-refresh.test.ts new file mode 100644 index 00000000000..cbd20bfbf90 --- /dev/null +++ b/test/nemoclaw-start-plugin-refresh.test.ts @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; + +const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); + +// Extract the post-gateway-start plugin-refresh block from the production +// entrypoint, including the SANDBOX_CHILD_PIDS tracking so the test can +// verify PLUGIN_REFRESH_PID is appended for SIGTERM cleanup. These anchors +// span the full workaround block for #2021 / openclaw/openclaw#89606. +function extractRefreshBlock(): string { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const start = src.indexOf("\nstart_auto_pair\n"); + const end = src.indexOf("SANDBOX_WAIT_PID=", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error( + "Expected plugin-refresh + PID-tracking block between start_auto_pair and SANDBOX_WAIT_PID in scripts/nemoclaw-start.sh", + ); + } + return src.slice(start, end); +} + +// Drive the refresh block end-to-end with stubs for `openclaw` and the +// step-down prefix. Returns the temp dir so the caller can inspect the +// stub log and the refresh status sentinel. +function runRefreshBlock(opts: { gatewayReadyAfter: number } = { gatewayReadyAfter: 1 }): { + result: ReturnType; + refreshLog: string; + envLog: string; + callLog: string; + tmpDir: string; +} { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-plugin-refresh-")); + + const stubBin = path.join(tmpDir, "openclaw"); + const callLog = path.join(tmpDir, "calls.log"); + const envLog = path.join(tmpDir, "env.log"); + const refreshLog = path.join(tmpDir, "refresh.txt"); + const readyCounter = path.join(tmpDir, "ready-counter"); + + // Stub `openclaw`: counts `gateway status` calls and only succeeds after + // `gatewayReadyAfter` invocations. Records every other invocation + + // critical env vars (HOME, USER) so the test can verify them. + fs.writeFileSync( + stubBin, + [ + "#!/usr/bin/env bash", + `echo "$@" >> ${JSON.stringify(callLog)}`, + `if [ "$1" = "gateway" ] && [ "$2" = "status" ]; then`, + ` count=$(cat ${JSON.stringify(readyCounter)} 2>/dev/null || echo 0)`, + ` count=$((count + 1))`, + ` printf '%s' "$count" > ${JSON.stringify(readyCounter)}`, + ` if [ "$count" -ge ${opts.gatewayReadyAfter} ]; then exit 0; else exit 1; fi`, + "fi", + `if [ "$1" = "plugins" ] && [ "$2" = "registry" ] && [ "$3" = "--refresh" ]; then`, + ` printf 'HOME=%s\\nUSER=%s\\n' "$HOME" "$(id -un)" > ${JSON.stringify(envLog)}`, + ` printf 'refreshed' > ${JSON.stringify(refreshLog)}`, + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const block = extractRefreshBlock().replace( + "/tmp/nemoclaw-plugin-refresh.log", + path.join(tmpDir, "production-log.log"), + ); + + // Wrap the block with a sandbox-shaped harness: + // - OPENCLAW= so the block invokes our stub + // - STEP_DOWN_PREFIX_SANDBOX=() empty array (no privilege drop in tests) + // - After spawning, the script PRINTS PLUGIN_REFRESH_PID then waits on it, + // so the test can verify both that PLUGIN_REFRESH_PID is set AND that + // the backgrounded refresh actually fired. + const wrapper = [ + "#!/usr/bin/env bash", + // -e/-u stripped: the production script is invoked by Docker entrypoint with + // a fully populated env where ${empty_arr[@]} is safe on Linux bash 5; macOS + // bash 3.2 (CI darwin runner) treats ${empty_arr[@]} as unbound. We want to + // test the block's behavior, not bash-version env strictness quirks. + "set -o pipefail", + `OPENCLAW=${JSON.stringify(stubBin)}`, + "STEP_DOWN_PREFIX_SANDBOX=()", + // Stubs for variables the extracted block references that are set + // earlier in the production script. + "AUTO_PAIR_PID=", + "GATEWAY_LOG_TAIL_PID=", + "GATEWAY_LOG_PERSIST_PID=", + "GATEWAY_PID=0", + block, + "# Surface PLUGIN_REFRESH_PID + tracked SANDBOX_CHILD_PIDS for the test", + 'printf "PLUGIN_REFRESH_PID=%s\\n" "$PLUGIN_REFRESH_PID"', + 'printf "SANDBOX_CHILD_PIDS=%s\\n" "${SANDBOX_CHILD_PIDS[*]}"', + "# Wait for the backgrounded subshell to complete before exiting", + 'wait "$PLUGIN_REFRESH_PID" 2>/dev/null || true', + ].join("\n"); + + const script = path.join(tmpDir, "run.sh"); + fs.writeFileSync(script, wrapper, { mode: 0o755 }); + + const result = spawnSync("bash", [script], { + encoding: "utf-8", + timeout: 30000, + env: { ...process.env, HOME: "/root", USER: "root" }, // adversarial: parent has wrong HOME + }); + + return { result, refreshLog, envLog, callLog, tmpDir }; +} + +describe("plugin registry refresh workaround (#2021, openclaw/openclaw#89606)", () => { + it("invokes `openclaw plugins registry --refresh` once the gateway reports ready", () => { + const { result, refreshLog, callLog, tmpDir } = runRefreshBlock(); + try { + expect(result.status, `script failed: ${result.stderr}`).toBe(0); + expect(fs.readFileSync(refreshLog, "utf-8")).toBe("refreshed"); + const calls = fs.readFileSync(callLog, "utf-8"); + expect(calls).toMatch(/^gateway status$/m); + expect(calls).toMatch(/^plugins registry --refresh$/m); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("forces HOME=/sandbox even when parent env has HOME=/root", () => { + // The bug class this protects against: running as root with HOME=/root + // installs to /root/.openclaw/extensions and does NOT repopulate the + // runtime plugins[]. The block must override the inherited HOME. + const { result, envLog, tmpDir } = runRefreshBlock(); + try { + expect(result.status).toBe(0); + const envCapture = fs.readFileSync(envLog, "utf-8"); + expect(envCapture).toContain("HOME=/sandbox"); + expect(envCapture).not.toContain("HOME=/root"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("captures PLUGIN_REFRESH_PID and appends it to SANDBOX_CHILD_PIDS", () => { + // SIGTERM cleanup walks SANDBOX_CHILD_PIDS; the refresh subshell must + // be reaped or it can outlive the sandbox container by ~10s. + const { result, tmpDir } = runRefreshBlock(); + try { + expect(result.status).toBe(0); + const pid = result.stdout.match(/^PLUGIN_REFRESH_PID=(\d+)$/m)?.[1]; + expect(pid).toBeDefined(); + expect(Number(pid)).toBeGreaterThan(0); + const tracked = result.stdout.match(/^SANDBOX_CHILD_PIDS=(.+)$/m)?.[1] ?? ""; + expect(tracked.split(/\s+/)).toContain(pid); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("waits for the gateway through several `gateway status` failures before refreshing", () => { + // Simulates the real cold-start condition where the gateway needs a few + // seconds to start serving. The loop must keep trying, then refresh once + // ready. Setting readiness at the 3rd probe checks the loop is actually + // looping rather than refreshing on the first iteration regardless. + const { result, refreshLog, callLog, tmpDir } = runRefreshBlock({ gatewayReadyAfter: 3 }); + try { + expect(result.status).toBe(0); + expect(fs.readFileSync(refreshLog, "utf-8")).toBe("refreshed"); + const calls = fs.readFileSync(callLog, "utf-8"); + const probeCount = calls.split("\n").filter((l) => l === "gateway status").length; + expect(probeCount).toBeGreaterThanOrEqual(3); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); From bbe49e42c868eae8e934329cb45132f3ab90a8b1 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 2 Jun 2026 14:36:23 -0700 Subject: [PATCH 3/8] fix(sandbox): harden plugin registry refresh --- scripts/lib/sandbox-init.sh | 32 ++++++++-- scripts/nemoclaw-start.sh | 64 ++++++++++++++++--- test/e2e/test-full-e2e.sh | 32 ++++++++++ test/nemoclaw-start-plugin-refresh.test.ts | 74 ++++++++++++++++++---- test/nemoclaw-start.test.ts | 3 + test/sandbox-init.test.ts | 17 ++++- 6 files changed, 196 insertions(+), 26 deletions(-) diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index 54edab75337..3b09e6141ae 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -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 root 644 root all 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) @@ -119,11 +120,24 @@ 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 opened by the root entrypoint before privilege drop, so reject + # symlinks/non-regular files and require root ownership when root validates it. + for f in /tmp/gateway.log /tmp/auto-pair.log /tmp/nemoclaw-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 @@ -131,6 +145,16 @@ validate_tmp_permissions() { failed=1 fi ;; + */nemoclaw-plugin-refresh.log) + if [ "$perms" != "600" ] && [ "$perms" != "644" ]; then + echo "[SECURITY] $f has unexpected permissions: mode=$perms (expected 600 or 644)" >&2 + failed=1 + fi + if [ "$(id -u)" -eq 0 ] && [ "$owner" != "root" ]; then + echo "[SECURITY] $f has unsafe owner: owner=$owner (expected root)" >&2 + failed=1 + fi + ;; *) if [ "$perms" != "600" ]; then echo "[SECURITY] $f has unexpected permissions: mode=$perms (expected 600)" >&2 diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 0b09a81e2de..74c822060b5 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2773,6 +2773,53 @@ setup_auth_profile_as_sandbox() { harden_auth_profiles } +PLUGIN_REFRESH_LOG="/tmp/nemoclaw-plugin-refresh.log" + +prepare_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 + : >"$PLUGIN_REFRESH_LOG" + if [ "$(id -u)" -eq 0 ]; then + chown root:root "$PLUGIN_REFRESH_LOG" + chmod 644 "$PLUGIN_REFRESH_LOG" + else + chmod 600 "$PLUGIN_REFRESH_LOG" 2>/dev/null || true + fi +} + +start_plugin_registry_refresh() { + ( + local ready=0 + for _ in 1 2 3 4 5 6 7 8 9 10; do + if "$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 + return 0 + fi + if [ "$(id -u)" -eq 0 ]; then + "${STEP_DOWN_PREFIX_SANDBOX[@]}" env HOME=/sandbox \ + "$OPENCLAW" plugins registry --refresh \ + >"$PLUGIN_REFRESH_LOG" 2>&1 || true + else + env HOME=/sandbox \ + "$OPENCLAW" plugins registry --refresh \ + >"$PLUGIN_REFRESH_LOG" 2>&1 || true + fi + ) & + PLUGIN_REFRESH_PID=$! +} + # ── Main ───────────────────────────────────────────────────────── # Migrate legacy symlink layout before anything else reads .openclaw @@ -2870,6 +2917,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 @@ -2887,6 +2936,7 @@ 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. @@ -2894,6 +2944,7 @@ if [ "$(id -u)" -ne 0 ]; then [ -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 @@ -2998,6 +3049,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 @@ -3127,16 +3180,7 @@ start_auto_pair # A `plugins registry --refresh` repopulates plugins[] from installRecords. # Backgrounded so the gateway-wait loop is unblocked; failure is non-fatal. # This is a temporary workaround; root fix is upstream (openclaw/openclaw#89606). -( - for _ in 1 2 3 4 5 6 7 8 9 10; do - if "$OPENCLAW" gateway status >/dev/null 2>&1; then break; fi - sleep 1 - done - "${STEP_DOWN_PREFIX_SANDBOX[@]}" env HOME=/sandbox \ - "$OPENCLAW" plugins registry --refresh \ - >/tmp/nemoclaw-plugin-refresh.log 2>&1 || true -) & -PLUGIN_REFRESH_PID=$! +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 diff --git a/test/e2e/test-full-e2e.sh b/test/e2e/test-full-e2e.sh index f8685b1181b..19fd3dab7a2 100755 --- a/test/e2e/test-full-e2e.sh +++ b/test/e2e/test-full-e2e.sh @@ -263,6 +263,38 @@ 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. +info "[PLUGIN] verifying NemoClaw plugin registry entry 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}" \ + "HOME=/sandbox openclaw plugins inspect nemoclaw >/tmp/nemoclaw-e2e-plugin-inspect.log 2>&1 && HOME=/sandbox openclaw nemoclaw --help >/tmp/nemoclaw-e2e-plugin-help.log 2>&1 && 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 and command help is available" +else + fail "NemoClaw OpenClaw plugin registry/help check failed: ${plugin_check_output:0:300}" +fi + # ══════════════════════════════════════════════════════════════════ # Phase 4: Live inference — the real proof # ══════════════════════════════════════════════════════════════════ diff --git a/test/nemoclaw-start-plugin-refresh.test.ts b/test/nemoclaw-start-plugin-refresh.test.ts index cbd20bfbf90..4dfd1bcc534 100644 --- a/test/nemoclaw-start-plugin-refresh.test.ts +++ b/test/nemoclaw-start-plugin-refresh.test.ts @@ -9,6 +9,24 @@ import { describe, expect, it } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); +function extractShellFunction(src: string, name: string): string { + const header = `${name}() {`; + const start = src.indexOf(header); + if (start === -1) { + throw new Error(`Expected ${name} in scripts/nemoclaw-start.sh`); + } + const bodyStart = start + header.length; + const lines = src.slice(bodyStart).split(/(?<=\n)/); + let offset = 0; + for (const line of lines) { + if (line.replace(/\r?\n$/, "") === "}") { + return `${name}() {${src.slice(bodyStart, bodyStart + offset)}\n}`; + } + offset += line.length; + } + throw new Error(`Expected closing brace for ${name} in scripts/nemoclaw-start.sh`); +} + // Extract the post-gateway-start plugin-refresh block from the production // entrypoint, including the SANDBOX_CHILD_PIDS tracking so the test can // verify PLUGIN_REFRESH_PID is appended for SIGTERM cleanup. These anchors @@ -22,13 +40,17 @@ function extractRefreshBlock(): string { "Expected plugin-refresh + PID-tracking block between start_auto_pair and SANDBOX_WAIT_PID in scripts/nemoclaw-start.sh", ); } - return src.slice(start, end); + return [extractShellFunction(src, "start_plugin_registry_refresh"), src.slice(start, end)].join( + "\n", + ); } // Drive the refresh block end-to-end with stubs for `openclaw` and the // step-down prefix. Returns the temp dir so the caller can inspect the // stub log and the refresh status sentinel. -function runRefreshBlock(opts: { gatewayReadyAfter: number } = { gatewayReadyAfter: 1 }): { +function runRefreshBlock( + opts: { gatewayReadyAfter: number; rootMode?: boolean } = { gatewayReadyAfter: 1, rootMode: true }, +): { result: ReturnType; refreshLog: string; envLog: string; @@ -58,7 +80,7 @@ function runRefreshBlock(opts: { gatewayReadyAfter: number } = { gatewayReadyAft ` if [ "$count" -ge ${opts.gatewayReadyAfter} ]; then exit 0; else exit 1; fi`, "fi", `if [ "$1" = "plugins" ] && [ "$2" = "registry" ] && [ "$3" = "--refresh" ]; then`, - ` printf 'HOME=%s\\nUSER=%s\\n' "$HOME" "$(id -un)" > ${JSON.stringify(envLog)}`, + ` printf 'HOME=%s\\nSTEP_DOWN_USER=%s\\nUSER=%s\\n' "$HOME" "\${STEP_DOWN_USER:-}" "$(id -un)" > ${JSON.stringify(envLog)}`, ` printf 'refreshed' > ${JSON.stringify(refreshLog)}`, " exit 0", "fi", @@ -67,14 +89,11 @@ function runRefreshBlock(opts: { gatewayReadyAfter: number } = { gatewayReadyAft { mode: 0o755 }, ); - const block = extractRefreshBlock().replace( - "/tmp/nemoclaw-plugin-refresh.log", - path.join(tmpDir, "production-log.log"), - ); + const block = extractRefreshBlock(); // Wrap the block with a sandbox-shaped harness: // - OPENCLAW= so the block invokes our stub - // - STEP_DOWN_PREFIX_SANDBOX=() empty array (no privilege drop in tests) + // - STEP_DOWN_PREFIX_SANDBOX marks the privilege-drop boundary in root-mode tests // - After spawning, the script PRINTS PLUGIN_REFRESH_PID then waits on it, // so the test can verify both that PLUGIN_REFRESH_PID is set AND that // the backgrounded refresh actually fired. @@ -86,7 +105,12 @@ function runRefreshBlock(opts: { gatewayReadyAfter: number } = { gatewayReadyAft // test the block's behavior, not bash-version env strictness quirks. "set -o pipefail", `OPENCLAW=${JSON.stringify(stubBin)}`, - "STEP_DOWN_PREFIX_SANDBOX=()", + `PLUGIN_REFRESH_LOG=${JSON.stringify(path.join(tmpDir, "production-log.log"))}`, + opts.rootMode !== false + ? 'id() { if [ "${1:-}" = "-u" ]; then printf "0"; else command id "$@"; fi; }' + : 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', + "sleep() { :; }", + "STEP_DOWN_PREFIX_SANDBOX=(env STEP_DOWN_USER=sandbox)", // Stubs for variables the extracted block references that are set // earlier in the production script. "AUTO_PAIR_PID=", @@ -142,16 +166,44 @@ describe("plugin registry refresh workaround (#2021, openclaw/openclaw#89606)", } }); + it("uses the sandbox step-down prefix when launched from the root entrypoint path", () => { + const { result, envLog, tmpDir } = runRefreshBlock(); + try { + expect(result.status).toBe(0); + const envCapture = fs.readFileSync(envLog, "utf-8"); + expect(envCapture).toContain("STEP_DOWN_USER=sandbox"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("skips the refresh when the gateway never reports ready", () => { + const { result, refreshLog, callLog, tmpDir } = runRefreshBlock({ gatewayReadyAfter: 99 }); + try { + expect(result.status).toBe(0); + expect(fs.existsSync(refreshLog)).toBe(false); + const calls = fs.readFileSync(callLog, "utf-8"); + const probeCount = calls.split("\n").filter((l) => l === "gateway status").length; + expect(probeCount).toBe(10); + expect(calls).not.toMatch(/^plugins registry --refresh$/m); + expect(result.stderr).toContain("gateway did not become ready"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("captures PLUGIN_REFRESH_PID and appends it to SANDBOX_CHILD_PIDS", () => { // SIGTERM cleanup walks SANDBOX_CHILD_PIDS; the refresh subshell must // be reaped or it can outlive the sandbox container by ~10s. const { result, tmpDir } = runRefreshBlock(); try { expect(result.status).toBe(0); - const pid = result.stdout.match(/^PLUGIN_REFRESH_PID=(\d+)$/m)?.[1]; + const stdout = + typeof result.stdout === "string" ? result.stdout : result.stdout.toString("utf8"); + const pid = stdout.match(/^PLUGIN_REFRESH_PID=(\d+)$/m)?.[1]; expect(pid).toBeDefined(); expect(Number(pid)).toBeGreaterThan(0); - const tracked = result.stdout.match(/^SANDBOX_CHILD_PIDS=(.+)$/m)?.[1] ?? ""; + const tracked = stdout.match(/^SANDBOX_CHILD_PIDS=(.+)$/m)?.[1] ?? ""; expect(tracked.split(/\s+/)).toContain(pid); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 2f2f4563cb5..3b88ac3690b 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -3204,6 +3204,7 @@ describe("Telegram diagnostics (#2766)", () => { const preloadPath = path.join(tmpDir, "telegram-diagnostics.js"); const gatewayLog = path.join(tmpDir, "gateway.log"); const autoPairLog = path.join(tmpDir, "auto-pair.log"); + const pluginRefreshLog = path.join(tmpDir, "nemoclaw-plugin-refresh.log"); const scriptPath = path.join(tmpDir, "run.sh"); fs.writeFileSync(configPath, '{"channels":{"telegram":{}}}\n'); fs.writeFileSync( @@ -3242,6 +3243,8 @@ describe("Telegram diagnostics (#2766)", () => { 'harden_auth_profiles() { :; }', 'run_step_down_as_sandbox() { :; }', 'setup_auth_profile_as_sandbox() { :; }', + `PLUGIN_REFRESH_LOG=${JSON.stringify(pluginRefreshLog)}`, + extractShellFunctionFromSource(src, "prepare_plugin_refresh_log"), 'chown() { :; }', 'chown_tree_no_symlink_follow() { :; }', 'start_persistent_gateway_log_mirror() { :; }', diff --git a/test/sandbox-init.test.ts b/test/sandbox-init.test.ts index b9ee593cf0f..368c1b814d5 100644 --- a/test/sandbox-init.test.ts +++ b/test/sandbox-init.test.ts @@ -195,7 +195,12 @@ EOF describe("validate_tmp_permissions", () => { let workDir: string; let tmpBackups: Record; - const TMP_ARTIFACTS = ["/tmp/nemoclaw-proxy-env.sh", "/tmp/gateway.log", "/tmp/auto-pair.log"]; + const TMP_ARTIFACTS = [ + "/tmp/nemoclaw-proxy-env.sh", + "/tmp/gateway.log", + "/tmp/auto-pair.log", + "/tmp/nemoclaw-plugin-refresh.log", + ]; beforeEach(() => { workDir = mkdtempSync(join(tmpdir(), "sandbox-init-validate-")); @@ -237,6 +242,16 @@ EOF echo "PASSED" `); }); + + it("rejects a symlinked plugin refresh log", () => { + const target = join(workDir, "plugin-refresh-target.log"); + writeFileSync(target, "do not truncate"); + symlinkSync(target, "/tmp/nemoclaw-plugin-refresh.log"); + + const { stderr } = runWithLib("validate_tmp_permissions", { expectFail: true }); + expect(stderr).toContain("/tmp/nemoclaw-plugin-refresh.log is a symlink"); + expect(readFileSync(target, "utf-8")).toBe("do not truncate"); + }); }); describe("verify_config_integrity", () => { From 1a69371f588daed7058f041d47ccf76ca6d146f4 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 2 Jun 2026 14:55:06 -0700 Subject: [PATCH 4/8] test(sandbox): align plugin refresh harnesses --- test/e2e-gateway-isolation.sh | 8 +++++--- test/nemoclaw-start.test.ts | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index 25ce2678a68..850fa077441 100755 --- a/test/e2e-gateway-isolation.sh +++ b/test/e2e-gateway-isolation.sh @@ -513,8 +513,10 @@ 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. Match the + # function definition exactly so later calls to apply_model_override in the + # entrypoint main path do not start a second sed range. + source <(sed -n "/^apply_model_override() {/,/^}/p" /usr/local/bin/nemoclaw-start) export NEMOCLAW_MODEL_OVERRIDE="test/override-model" apply_model_override python3 -c " @@ -544,7 +546,7 @@ 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) + source <(sed -n "/^apply_model_override() {/,/^}/p" /usr/local/bin/nemoclaw-start) 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\"])") diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 3b88ac3690b..f5da8df4495 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -2174,6 +2174,7 @@ describe("nemoclaw-start gateway launch signal handling", () => { '_DASHBOARD_PORT="19000"', "start_persistent_gateway_log_mirror() { sleep 30 & GATEWAY_LOG_PERSIST_PID=$!; }", "start_auto_pair() { sleep 30 & AUTO_PAIR_PID=$!; }", + "start_plugin_registry_refresh() { :; }", "cleanup_on_signal() { :; }", // STEP_DOWN_PREFIX_* are normally populated by init_step_down_prefixes // in sandbox-init.sh; the launch block uses STEP_DOWN_PREFIX_GATEWAY From 66a257d7edcb7c5eda32cc075fd9bdc25eb543f1 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 2 Jun 2026 17:01:56 -0700 Subject: [PATCH 5/8] test(sandbox): assert plugin refresh log setup --- test/nemoclaw-start.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index f5da8df4495..c870db4f6e1 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -3275,8 +3275,19 @@ describe("Telegram diagnostics (#2766)", () => { const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); const preloadExists = fs.existsSync(preloadPath); const preloadMode = preloadExists ? (fs.statSync(preloadPath).mode & 0o777).toString(8) : ""; + const pluginRefreshLogExists = fs.existsSync(pluginRefreshLog); + const pluginRefreshLogMode = pluginRefreshLogExists + ? (fs.statSync(pluginRefreshLog).mode & 0o777).toString(8) + : ""; fs.rmSync(tmpDir, { recursive: true, force: true }); - return { result, preloadExists, preloadMode, preloadPath }; + return { + result, + preloadExists, + preloadMode, + preloadPath, + pluginRefreshLogExists, + pluginRefreshLogMode, + }; } it("installs a Telegram diagnostics preload only when Telegram is configured", () => { @@ -3495,6 +3506,8 @@ process.stderr.write('FailoverError: token=123456:LATER\\n'); expect(setup.result.stdout).toContain("ORDER:configure"); expect(setup.result.stdout).toContain("VALIDATE:"); expect(setup.result.stdout).toContain(setup.preloadPath); + expect(setup.pluginRefreshLogExists).toBe(true); + expect(setup.pluginRefreshLogMode).toBe(kind === "root" ? "644" : "600"); } }); From 92e4245e1aea16b1873b32a248b4db5276f82c0f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 3 Jun 2026 14:21:10 -0700 Subject: [PATCH 6/8] fix(sandbox): harden plugin refresh log --- scripts/lib/sandbox-init.sh | 14 ++-- scripts/nemoclaw-start.sh | 46 +++++++---- test/e2e-gateway-isolation.sh | 20 +++-- test/e2e/test-full-e2e.sh | 21 ++--- test/nemoclaw-start-plugin-refresh.test.ts | 92 ++++++++++++++++++++++ test/nemoclaw-start.test.ts | 2 +- test/sandbox-init.test.ts | 9 +++ 7 files changed, 168 insertions(+), 36 deletions(-) diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index 0a3c9a1c7d4..010cc1de84b 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -28,7 +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 root 644 root all no (OpenClaw refresh output) +# /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) @@ -121,8 +121,8 @@ validate_tmp_permissions() { # Restricted log files — gateway.log may be 600 (Hermes) or 644 (OpenClaw, # world-readable for diagnostics). auto-pair.log is 600. The plugin-refresh - # log is opened by the root entrypoint before privilege drop, so reject - # symlinks/non-regular files and require root ownership when root validates it. + # log is written after privilege drop as sandbox, so keep it private and + # reject symlinks/non-regular files before launching services. for f in /tmp/gateway.log /tmp/auto-pair.log /tmp/nemoclaw-plugin-refresh.log; do [ -e "$f" ] || [ -L "$f" ] || continue if [ -L "$f" ]; then @@ -146,12 +146,12 @@ validate_tmp_permissions() { fi ;; */nemoclaw-plugin-refresh.log) - if [ "$perms" != "600" ] && [ "$perms" != "644" ]; then - echo "[SECURITY] $f has unexpected permissions: mode=$perms (expected 600 or 644)" >&2 + if [ "$perms" != "600" ]; then + echo "[SECURITY] $f has unexpected permissions: mode=$perms (expected 600)" >&2 failed=1 fi - if [ "$(id -u)" -eq 0 ] && [ "$owner" != "root" ]; then - echo "[SECURITY] $f has unsafe owner: owner=$owner (expected root)" >&2 + if [ "$(id -u)" -eq 0 ] && [ "$owner" != "sandbox" ]; then + echo "[SECURITY] $f has unsafe owner: owner=$owner (expected sandbox)" >&2 failed=1 fi ;; diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 73e499c401a..f8602ecbf06 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2783,6 +2783,10 @@ setup_auth_profile_as_sandbox() { 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 @@ -2791,12 +2795,22 @@ prepare_plugin_refresh_log() { echo "[SECURITY] refusing to use non-regular plugin-refresh log: $PLUGIN_REFRESH_LOG" >&2 return 1 fi - : >"$PLUGIN_REFRESH_LOG" - if [ "$(id -u)" -eq 0 ]; then - chown root:root "$PLUGIN_REFRESH_LOG" - chmod 644 "$PLUGIN_REFRESH_LOG" - else - chmod 600 "$PLUGIN_REFRESH_LOG" 2>/dev/null || true + + # 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 } @@ -2812,16 +2826,16 @@ start_plugin_registry_refresh() { done if [ "$ready" -ne 1 ]; then echo "[plugin-refresh] gateway did not become ready; skipping registry refresh" >&2 - return 0 + exit 0 fi if [ "$(id -u)" -eq 0 ]; then - "${STEP_DOWN_PREFIX_SANDBOX[@]}" env HOME=/sandbox \ - "$OPENCLAW" plugins registry --refresh \ - >"$PLUGIN_REFRESH_LOG" 2>&1 || true + "${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 \ - "$OPENCLAW" plugins registry --refresh \ - >"$PLUGIN_REFRESH_LOG" 2>&1 || true + 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=$! @@ -3186,7 +3200,11 @@ start_auto_pair # `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. -# This is a temporary workaround; root fix is upstream (openclaw/openclaw#89606). +# Source boundary: the lossy policy-changed rebuild lives in OpenClaw's registry +# regeneration path, outside NemoClaw. NemoClaw can only heal the post-start +# registry from persisted installRecords until upstream preserves path/npm-origin +# plugins itself. 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 diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index 850fa077441..76d14d2d520 100755 --- a/test/e2e-gateway-isolation.sh +++ b/test/e2e-gateway-isolation.sh @@ -513,10 +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 function without running the full startup. Match the - # function definition exactly so later calls to apply_model_override in the - # entrypoint main path do not start a second sed range. - 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 " @@ -546,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\"])") diff --git a/test/e2e/test-full-e2e.sh b/test/e2e/test-full-e2e.sh index 19fd3dab7a2..18a08ba433a 100755 --- a/test/e2e/test-full-e2e.sh +++ b/test/e2e/test-full-e2e.sh @@ -267,22 +267,25 @@ fi # 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. -info "[PLUGIN] verifying NemoClaw plugin registry entry and command help..." +# interact with the sandbox. This non-interactive E2E cannot drive OpenClaw's +# terminal autocomplete directly, so it validates the runtime slash alias that +# the TUI consumes plus the direct command help path that fails when the plugin +# is missing from the refreshed registry. +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" +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" \ + 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}" \ - "HOME=/sandbox openclaw plugins inspect nemoclaw >/tmp/nemoclaw-e2e-plugin-inspect.log 2>&1 && HOME=/sandbox openclaw nemoclaw --help >/tmp/nemoclaw-e2e-plugin-help.log 2>&1 && printf 'plugin-ok'" \ + "HOME=/sandbox openclaw plugins inspect nemoclaw >/tmp/nemoclaw-e2e-plugin-inspect.log 2>&1 && HOME=/sandbox openclaw nemoclaw --help >/tmp/nemoclaw-e2e-plugin-help.log 2>&1 && grep -Eq '\"name\"[[:space:]]*:[[:space:]]*\"nemoclaw\"' /sandbox/.openclaw/extensions/nemoclaw/openclaw.plugin.json && grep -Eq '\"kind\"[[:space:]]*:[[:space:]]*\"runtime-slash\"' /sandbox/.openclaw/extensions/nemoclaw/openclaw.plugin.json && printf 'plugin-ok'" \ 2>&1) || true grep -Fq "plugin-ok" <<<"$plugin_check_output" && break [ "$plugin_attempt" -lt 5 ] && sleep 3 @@ -290,9 +293,9 @@ if openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null; then fi rm -f "$ssh_config" if grep -Fq "plugin-ok" <<<"$plugin_check_output"; then - pass "NemoClaw OpenClaw plugin is registered and command help is available" + pass "NemoClaw OpenClaw plugin is registered with runtime slash alias and command help" else - fail "NemoClaw OpenClaw plugin registry/help check failed: ${plugin_check_output:0:300}" + fail "NemoClaw OpenClaw plugin registry/slash-alias/help check failed: ${plugin_check_output:0:300}" fi # ══════════════════════════════════════════════════════════════════ diff --git a/test/nemoclaw-start-plugin-refresh.test.ts b/test/nemoclaw-start-plugin-refresh.test.ts index 4dfd1bcc534..7d4e935334e 100644 --- a/test/nemoclaw-start-plugin-refresh.test.ts +++ b/test/nemoclaw-start-plugin-refresh.test.ts @@ -137,6 +137,98 @@ function runRefreshBlock( return { result, refreshLog, envLog, callLog, tmpDir }; } +describe("plugin refresh log preparation", () => { + it("rejects a preexisting symlink without truncating its target", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-plugin-refresh-log-")); + try { + const refreshLog = path.join(tmpDir, "refresh.log"); + const sensitiveTarget = path.join(tmpDir, "sensitive.txt"); + fs.writeFileSync(sensitiveTarget, "do not truncate"); + fs.symlinkSync(sensitiveTarget, refreshLog); + + const script = path.join(tmpDir, "run.sh"); + fs.writeFileSync( + script, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `PLUGIN_REFRESH_LOG=${JSON.stringify(refreshLog)}`, + extractShellFunction(fs.readFileSync(START_SCRIPT, "utf-8"), "prepare_plugin_refresh_log"), + "prepare_plugin_refresh_log", + ].join("\n"), + { mode: 0o755 }, + ); + + const result = spawnSync("bash", [script], { encoding: "utf-8", timeout: 5000 }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("refusing to use symlinked plugin-refresh log"); + expect(fs.readFileSync(sensitiveTarget, "utf-8")).toBe("do not truncate"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("rejects a preexisting non-regular path", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-plugin-refresh-log-")); + try { + const refreshLog = path.join(tmpDir, "refresh.log"); + fs.mkdirSync(refreshLog); + + const script = path.join(tmpDir, "run.sh"); + fs.writeFileSync( + script, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `PLUGIN_REFRESH_LOG=${JSON.stringify(refreshLog)}`, + extractShellFunction(fs.readFileSync(START_SCRIPT, "utf-8"), "prepare_plugin_refresh_log"), + "prepare_plugin_refresh_log", + ].join("\n"), + { mode: 0o755 }, + ); + + const result = spawnSync("bash", [script], { encoding: "utf-8", timeout: 5000 }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("refusing to use non-regular plugin-refresh log"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("replaces a raced-in symlink atomically without touching the target", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-plugin-refresh-log-")); + try { + const refreshLog = path.join(tmpDir, "refresh.log"); + const sensitiveTarget = path.join(tmpDir, "sensitive.txt"); + fs.writeFileSync(sensitiveTarget, "do not truncate"); + + const script = path.join(tmpDir, "run.sh"); + fs.writeFileSync( + script, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `PLUGIN_REFRESH_LOG=${JSON.stringify(refreshLog)}`, + `RACE_TARGET=${JSON.stringify(sensitiveTarget)}`, + 'id() { if [ "${1:-}" = "-u" ]; then printf "0"; else command id "$@"; fi; }', + 'chown() { ln -sfn "$RACE_TARGET" "$PLUGIN_REFRESH_LOG"; return 0; }', + extractShellFunction(fs.readFileSync(START_SCRIPT, "utf-8"), "prepare_plugin_refresh_log"), + "prepare_plugin_refresh_log", + ].join("\n"), + { mode: 0o755 }, + ); + + const result = spawnSync("bash", [script], { encoding: "utf-8", timeout: 5000 }); + expect(result.status, `script failed: ${result.stderr}`).toBe(0); + expect(fs.lstatSync(refreshLog).isSymbolicLink()).toBe(false); + expect((fs.statSync(refreshLog).mode & 0o777).toString(8)).toBe("600"); + expect(fs.readFileSync(sensitiveTarget, "utf-8")).toBe("do not truncate"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + describe("plugin registry refresh workaround (#2021, openclaw/openclaw#89606)", () => { it("invokes `openclaw plugins registry --refresh` once the gateway reports ready", () => { const { result, refreshLog, callLog, tmpDir } = runRefreshBlock(); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 05e7ab540a9..6a862cc19bf 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -3507,7 +3507,7 @@ process.stderr.write('FailoverError: token=123456:LATER\\n'); expect(setup.result.stdout).toContain("VALIDATE:"); expect(setup.result.stdout).toContain(setup.preloadPath); expect(setup.pluginRefreshLogExists).toBe(true); - expect(setup.pluginRefreshLogMode).toBe(kind === "root" ? "644" : "600"); + expect(setup.pluginRefreshLogMode).toBe("600"); } }); diff --git a/test/sandbox-init.test.ts b/test/sandbox-init.test.ts index b36cddaf006..23c3884a3bf 100644 --- a/test/sandbox-init.test.ts +++ b/test/sandbox-init.test.ts @@ -252,6 +252,15 @@ EOF expect(stderr).toContain("/tmp/nemoclaw-plugin-refresh.log is a symlink"); expect(readFileSync(target, "utf-8")).toBe("do not truncate"); }); + + it("keeps the plugin refresh log private", () => { + writeFileSync("/tmp/nemoclaw-plugin-refresh.log", "refresh output"); + chmodSync("/tmp/nemoclaw-plugin-refresh.log", 0o644); + + const { stderr } = runWithLib("validate_tmp_permissions", { expectFail: true }); + expect(stderr).toContain("/tmp/nemoclaw-plugin-refresh.log has unexpected permissions"); + expect(stderr).toContain("expected 600"); + }); }); describe("verify_config_integrity", () => { From fa0e4de646cc320784ff9ef5107f16d3c1c20863 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 4 Jun 2026 12:48:42 -0700 Subject: [PATCH 7/8] fix(sandbox): run plugin refresh readiness probe as sandbox Signed-off-by: Carlos Villela --- scripts/lib/sandbox-init.sh | 7 ++- scripts/nemoclaw-start.sh | 17 ++++-- test/e2e/test-full-e2e.sh | 9 +-- test/nemoclaw-start-plugin-refresh.test.ts | 70 +++++++++++++++++++--- test/sandbox-init.test.ts | 22 ++++--- 5 files changed, 100 insertions(+), 25 deletions(-) diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index 010cc1de84b..773165b3372 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -122,8 +122,11 @@ validate_tmp_permissions() { # Restricted log files — gateway.log may be 600 (Hermes) or 644 (OpenClaw, # 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. - for f in /tmp/gateway.log /tmp/auto-pair.log /tmp/nemoclaw-plugin-refresh.log; do + # 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 diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 677c42c56bc..378f3ff65e4 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2918,7 +2918,12 @@ start_plugin_registry_refresh() { ( local ready=0 for _ in 1 2 3 4 5 6 7 8 9 10; do - if "$OPENCLAW" gateway status >/dev/null 2>&1; then + 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 @@ -3301,10 +3306,12 @@ start_auto_pair # 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 post-start -# registry from persisted installRecords until upstream preserves path/npm-origin -# plugins itself. Remove this workaround after openclaw/openclaw#89606 ships and -# the full onboard E2E still proves /nemoclaw registration without the refresh. +# 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 diff --git a/test/e2e/test-full-e2e.sh b/test/e2e/test-full-e2e.sh index 18a08ba433a..5273c7bc3e7 100755 --- a/test/e2e/test-full-e2e.sh +++ b/test/e2e/test-full-e2e.sh @@ -268,9 +268,10 @@ fi # 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, so it validates the runtime slash alias that -# the TUI consumes plus the direct command help path that fails when the plugin -# is missing from the refreshed registry. +# 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 plus the direct command help path that fails +# when the plugin is missing from the refreshed registry. info "[PLUGIN] verifying NemoClaw plugin registry entry, slash alias, and command help..." ssh_config="$(mktemp)" plugin_check_output="" @@ -285,7 +286,7 @@ if openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null; then -o ConnectTimeout=10 \ -o LogLevel=ERROR \ "openshell-${SANDBOX_NAME}" \ - "HOME=/sandbox openclaw plugins inspect nemoclaw >/tmp/nemoclaw-e2e-plugin-inspect.log 2>&1 && HOME=/sandbox openclaw nemoclaw --help >/tmp/nemoclaw-e2e-plugin-help.log 2>&1 && grep -Eq '\"name\"[[:space:]]*:[[:space:]]*\"nemoclaw\"' /sandbox/.openclaw/extensions/nemoclaw/openclaw.plugin.json && grep -Eq '\"kind\"[[:space:]]*:[[:space:]]*\"runtime-slash\"' /sandbox/.openclaw/extensions/nemoclaw/openclaw.plugin.json && printf 'plugin-ok'" \ + "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; then printf 'help failed: '; 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 diff --git a/test/nemoclaw-start-plugin-refresh.test.ts b/test/nemoclaw-start-plugin-refresh.test.ts index 7d4e935334e..f2c48020df8 100644 --- a/test/nemoclaw-start-plugin-refresh.test.ts +++ b/test/nemoclaw-start-plugin-refresh.test.ts @@ -55,6 +55,8 @@ function runRefreshBlock( refreshLog: string; envLog: string; callLog: string; + preRefreshState: string; + registryState: string; tmpDir: string; } { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-plugin-refresh-")); @@ -63,24 +65,49 @@ function runRefreshBlock( const callLog = path.join(tmpDir, "calls.log"); const envLog = path.join(tmpDir, "env.log"); const refreshLog = path.join(tmpDir, "refresh.txt"); + const preRefreshState = path.join(tmpDir, "registry-state.pre.txt"); + const registryState = path.join(tmpDir, "registry-state.txt"); const readyCounter = path.join(tmpDir, "ready-counter"); + fs.writeFileSync( + registryState, + [ + "installRecords:nemoclaw,stale-plugin", + "plugins:", + "slash:", + "allowedSlash:/nemoclaw", + "staleSlash:", + "", + ].join("\n"), + ); // Stub `openclaw`: counts `gateway status` calls and only succeeds after - // `gatewayReadyAfter` invocations. Records every other invocation + - // critical env vars (HOME, USER) so the test can verify them. + // `gatewayReadyAfter` invocations. Gateway readiness deliberately requires + // HOME=/sandbox, matching the sandbox config location and preventing the + // root-entrypoint regression where readiness probes inherited HOME=/root and + // skipped the refresh even though the gateway was running. fs.writeFileSync( stubBin, [ "#!/usr/bin/env bash", `echo "$@" >> ${JSON.stringify(callLog)}`, `if [ "$1" = "gateway" ] && [ "$2" = "status" ]; then`, + ` printf 'CALL=gateway status HOME=%s STEP_DOWN_USER=%s USER=%s\\n' "$HOME" "\${STEP_DOWN_USER:-}" "$(id -un)" >> ${JSON.stringify(envLog)}`, + ` [ "$HOME" = "/sandbox" ] || exit 1`, ` count=$(cat ${JSON.stringify(readyCounter)} 2>/dev/null || echo 0)`, ` count=$((count + 1))`, ` printf '%s' "$count" > ${JSON.stringify(readyCounter)}`, ` if [ "$count" -ge ${opts.gatewayReadyAfter} ]; then exit 0; else exit 1; fi`, "fi", `if [ "$1" = "plugins" ] && [ "$2" = "registry" ] && [ "$3" = "--refresh" ]; then`, - ` printf 'HOME=%s\\nSTEP_DOWN_USER=%s\\nUSER=%s\\n' "$HOME" "\${STEP_DOWN_USER:-}" "$(id -un)" > ${JSON.stringify(envLog)}`, + ` printf 'CALL=plugins registry --refresh HOME=%s STEP_DOWN_USER=%s USER=%s\\n' "$HOME" "\${STEP_DOWN_USER:-}" "$(id -un)" >> ${JSON.stringify(envLog)}`, + ` cp ${JSON.stringify(registryState)} ${JSON.stringify(preRefreshState)}`, + ` cat > ${JSON.stringify(registryState)} <<'REGISTRY_STATE'`, + "installRecords:nemoclaw,stale-plugin", + "plugins:nemoclaw", + "slash:/nemoclaw", + "allowedSlash:/nemoclaw", + "staleSlash:", + "REGISTRY_STATE", ` printf 'refreshed' > ${JSON.stringify(refreshLog)}`, " exit 0", "fi", @@ -134,7 +161,7 @@ function runRefreshBlock( env: { ...process.env, HOME: "/root", USER: "root" }, // adversarial: parent has wrong HOME }); - return { result, refreshLog, envLog, callLog, tmpDir }; + return { result, refreshLog, envLog, callLog, preRefreshState, registryState, tmpDir }; } describe("plugin refresh log preparation", () => { @@ -245,13 +272,15 @@ describe("plugin registry refresh workaround (#2021, openclaw/openclaw#89606)", it("forces HOME=/sandbox even when parent env has HOME=/root", () => { // The bug class this protects against: running as root with HOME=/root - // installs to /root/.openclaw/extensions and does NOT repopulate the - // runtime plugins[]. The block must override the inherited HOME. + // reads /root/.openclaw for gateway readiness and installs/refreshes under + // /root, which skips the refresh or fails to repopulate runtime plugins[]. + // Both the readiness probe and refresh must override the inherited HOME. const { result, envLog, tmpDir } = runRefreshBlock(); try { expect(result.status).toBe(0); const envCapture = fs.readFileSync(envLog, "utf-8"); - expect(envCapture).toContain("HOME=/sandbox"); + expect(envCapture).toMatch(/CALL=gateway status HOME=\/sandbox/m); + expect(envCapture).toMatch(/CALL=plugins registry --refresh HOME=\/sandbox/m); expect(envCapture).not.toContain("HOME=/root"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -269,6 +298,33 @@ describe("plugin registry refresh workaround (#2021, openclaw/openclaw#89606)", } }); + it("heals the installRecords-present/plugins-missing slash-router shape without enabling stale records", () => { + // Regression contract for #2021: the invalid OpenClaw state has persisted + // installRecords while the runtime plugins/slash-router view forgets the + // path-origin NemoClaw plugin after policy-changed regeneration. The real + // registry implementation is upstream; this harness captures the state + // boundary NemoClaw relies on and proves this startup hook runs the refresh + // that restores /nemoclaw without treating unrelated stale records as newly + // allowed slash commands. + const { result, preRefreshState, registryState, tmpDir } = runRefreshBlock(); + try { + expect(result.status).toBe(0); + const before = fs.readFileSync(preRefreshState, "utf-8"); + expect(before).toContain("installRecords:nemoclaw,stale-plugin"); + expect(before).toMatch(/^plugins:$/m); + expect(before).toMatch(/^slash:$/m); + + const after = fs.readFileSync(registryState, "utf-8"); + expect(after).toContain("plugins:nemoclaw"); + expect(after).toContain("slash:/nemoclaw"); + expect(after).toContain("allowedSlash:/nemoclaw"); + expect(after).toMatch(/^staleSlash:$/m); + expect(after).not.toContain("/stale-plugin"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("skips the refresh when the gateway never reports ready", () => { const { result, refreshLog, callLog, tmpDir } = runRefreshBlock({ gatewayReadyAfter: 99 }); try { diff --git a/test/sandbox-init.test.ts b/test/sandbox-init.test.ts index 23c3884a3bf..4ec62f88621 100644 --- a/test/sandbox-init.test.ts +++ b/test/sandbox-init.test.ts @@ -244,21 +244,29 @@ EOF }); it("rejects a symlinked plugin refresh log", () => { + const pluginRefreshLog = join(workDir, "nemoclaw-plugin-refresh.log"); const target = join(workDir, "plugin-refresh-target.log"); writeFileSync(target, "do not truncate"); - symlinkSync(target, "/tmp/nemoclaw-plugin-refresh.log"); + symlinkSync(target, pluginRefreshLog); - const { stderr } = runWithLib("validate_tmp_permissions", { expectFail: true }); - expect(stderr).toContain("/tmp/nemoclaw-plugin-refresh.log is a symlink"); + const { stderr } = runWithLib("validate_tmp_permissions", { + env: { PLUGIN_REFRESH_LOG: pluginRefreshLog }, + expectFail: true, + }); + expect(stderr).toContain(`${pluginRefreshLog} is a symlink`); expect(readFileSync(target, "utf-8")).toBe("do not truncate"); }); it("keeps the plugin refresh log private", () => { - writeFileSync("/tmp/nemoclaw-plugin-refresh.log", "refresh output"); - chmodSync("/tmp/nemoclaw-plugin-refresh.log", 0o644); + const pluginRefreshLog = join(workDir, "nemoclaw-plugin-refresh.log"); + writeFileSync(pluginRefreshLog, "refresh output"); + chmodSync(pluginRefreshLog, 0o644); - const { stderr } = runWithLib("validate_tmp_permissions", { expectFail: true }); - expect(stderr).toContain("/tmp/nemoclaw-plugin-refresh.log has unexpected permissions"); + const { stderr } = runWithLib("validate_tmp_permissions", { + env: { PLUGIN_REFRESH_LOG: pluginRefreshLog }, + expectFail: true, + }); + expect(stderr).toContain(`${pluginRefreshLog} has unexpected permissions`); expect(stderr).toContain("expected 600"); }); }); From 05783397a67e94b398cbb593f0f126ddbffa3586 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 4 Jun 2026 13:08:00 -0700 Subject: [PATCH 8/8] test(e2e): tolerate unrelated plugin help warnings Signed-off-by: Carlos Villela --- test/e2e/test-full-e2e.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/e2e/test-full-e2e.sh b/test/e2e/test-full-e2e.sh index 5273c7bc3e7..f824b1c2e13 100755 --- a/test/e2e/test-full-e2e.sh +++ b/test/e2e/test-full-e2e.sh @@ -270,8 +270,9 @@ fi # 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 plus the direct command help path that fails -# when the plugin is missing from the refreshed registry. +# 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="" @@ -286,7 +287,7 @@ if openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null; then -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; then printf 'help failed: '; 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'" \ + "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 @@ -294,7 +295,7 @@ if openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null; then fi rm -f "$ssh_config" if grep -Fq "plugin-ok" <<<"$plugin_check_output"; then - pass "NemoClaw OpenClaw plugin is registered with runtime slash alias and command help" + 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