From c7090b7f53fcf7e13d80c2ce8c5f403e3cdbe65c Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 4 Jun 2026 11:49:23 +0000 Subject: [PATCH 1/6] fix(onboard): pin OpenClaw home/state/workspace env in sandbox Signed-off-by: Tinson Lai --- src/lib/onboard.ts | 17 +++++++++++++++++ test/onboard.test.ts | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index b287a2b5567..1b93923283e 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3563,6 +3563,23 @@ async function createSandbox( // 18789 and the gateway listens on the wrong port. (#2267, #1925) const effectiveDashboardPort = getDashboardForwardPort(chatUiUrl); envArgs.push(formatEnvAssignment("NEMOCLAW_DASHBOARD_PORT", effectiveDashboardPort)); + // Pin OpenClaw's home, state, and default workspace dirs inside the + // sandbox so `openclaw skills install` and `openclaw skills list` resolve + // the same paths regardless of the sandbox HOME the base image was built + // with. Without this, the upstream skill loader can fall back to a + // hardcoded DEFAULT_AGENT_WORKSPACE_DIR that drifts from where install + // wrote, leaving workspace-installed skills invisible to `skills list`. + // Tracks NVIDIA/NemoClaw#4709; upstream fixes openclaw/openclaw#90089 and + // openclaw/openclaw#89767. Remove once the bundled OpenClaw absorbs both. + // The OPENCLAW_* env vars are namespace-prefixed and ignored by non-OpenClaw + // agents (e.g. Hermes), so injecting them unconditionally is safe. + const openclawConfigDir = agent?.configPaths?.dir || "/sandbox/.openclaw"; + const openclawHomeDir = path.posix.dirname(openclawConfigDir); + envArgs.push(formatEnvAssignment("OPENCLAW_HOME", openclawHomeDir)); + envArgs.push(formatEnvAssignment("OPENCLAW_STATE_DIR", openclawConfigDir)); + envArgs.push( + formatEnvAssignment("OPENCLAW_WORKSPACE_DIR", `${openclawConfigDir}/workspace`), + ); onboardHermesDashboard.appendHermesDashboardEnvArgs( envArgs, hermesDashboardState, diff --git a/test/onboard.test.ts b/test/onboard.test.ts index d78dd358bd3..d206cc7f7ae 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -3133,6 +3133,17 @@ const { createSandbox } = require(${onboardPath}); assert.match(createCommand.command, /NEMOCLAW_DASHBOARD_PORT=19000/); assert.match(createCommand.command, /HTTP_PROXY=http:\/\/127\.0\.0\.1:8888/); assert.match(createCommand.command, /HTTPS_PROXY=http:\/\/127\.0\.0\.1:8888/); + // #4709: OpenClaw home/state/workspace dirs must be pinned in the sandbox + // env so `openclaw skills install` and `openclaw skills list` resolve the + // same paths. Without this, the upstream skill loader can fall back to a + // hardcoded DEFAULT_AGENT_WORKSPACE_DIR that drifts from the install path + // and hides workspace-installed skills from `skills list`. + assert.match(createCommand.command, /OPENCLAW_HOME=\/sandbox(?:\s|$)/); + assert.match(createCommand.command, /OPENCLAW_STATE_DIR=\/sandbox\/\.openclaw(?:\s|$)/); + assert.match( + createCommand.command, + /OPENCLAW_WORKSPACE_DIR=\/sandbox\/\.openclaw\/workspace(?:\s|$)/, + ); const noProxyMatch = createCommand.command.match(/(?:^|\s)NO_PROXY=([^\s]+)/); assert.ok(noProxyMatch, `expected NO_PROXY in sandbox create command:\n${createCommand.command}`); const noProxyEntries = noProxyMatch[1].split(","); From 7fea05a76ca95fad969acc482341edbc8227e589 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 4 Jun 2026 12:22:30 +0000 Subject: [PATCH 2/6] refactor(onboard): extract OpenClaw runtime env helper, include OPENCLAW_WORKSPACE_DIR in runtime shell rc Signed-off-by: Tinson Lai --- scripts/nemoclaw-start.sh | 2 +- src/lib/onboard.ts | 18 +------- src/lib/onboard/openclaw-runtime-env.test.ts | 46 ++++++++++++++++++++ src/lib/onboard/openclaw-runtime-env.ts | 19 ++++++++ test/onboard.test.ts | 6 +-- 5 files changed, 70 insertions(+), 21 deletions(-) create mode 100644 src/lib/onboard/openclaw-runtime-env.test.ts create mode 100644 src/lib/onboard/openclaw-runtime-env.ts diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 114320b4bdf..65e4228740f 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1994,7 +1994,7 @@ export https_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" PROXYEOF local _openclaw_env_name _openclaw_env_value _escaped_openclaw_env_value - for _openclaw_env_name in OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH OPENCLAW_OAUTH_DIR; do + for _openclaw_env_name in OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH OPENCLAW_OAUTH_DIR OPENCLAW_WORKSPACE_DIR; do _openclaw_env_value="${!_openclaw_env_name:-}" [ -n "$_openclaw_env_value" ] || continue _escaped_openclaw_env_value="$(printf '%s' "$_openclaw_env_value" | sed "s/'/'\\\\''/g")" diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 1b93923283e..b6c1201441c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3563,23 +3563,7 @@ async function createSandbox( // 18789 and the gateway listens on the wrong port. (#2267, #1925) const effectiveDashboardPort = getDashboardForwardPort(chatUiUrl); envArgs.push(formatEnvAssignment("NEMOCLAW_DASHBOARD_PORT", effectiveDashboardPort)); - // Pin OpenClaw's home, state, and default workspace dirs inside the - // sandbox so `openclaw skills install` and `openclaw skills list` resolve - // the same paths regardless of the sandbox HOME the base image was built - // with. Without this, the upstream skill loader can fall back to a - // hardcoded DEFAULT_AGENT_WORKSPACE_DIR that drifts from where install - // wrote, leaving workspace-installed skills invisible to `skills list`. - // Tracks NVIDIA/NemoClaw#4709; upstream fixes openclaw/openclaw#90089 and - // openclaw/openclaw#89767. Remove once the bundled OpenClaw absorbs both. - // The OPENCLAW_* env vars are namespace-prefixed and ignored by non-OpenClaw - // agents (e.g. Hermes), so injecting them unconditionally is safe. - const openclawConfigDir = agent?.configPaths?.dir || "/sandbox/.openclaw"; - const openclawHomeDir = path.posix.dirname(openclawConfigDir); - envArgs.push(formatEnvAssignment("OPENCLAW_HOME", openclawHomeDir)); - envArgs.push(formatEnvAssignment("OPENCLAW_STATE_DIR", openclawConfigDir)); - envArgs.push( - formatEnvAssignment("OPENCLAW_WORKSPACE_DIR", `${openclawConfigDir}/workspace`), - ); + require("./onboard/openclaw-runtime-env").appendOpenClawRuntimeEnvArgs(envArgs, agent); onboardHermesDashboard.appendHermesDashboardEnvArgs( envArgs, hermesDashboardState, diff --git a/src/lib/onboard/openclaw-runtime-env.test.ts b/src/lib/onboard/openclaw-runtime-env.test.ts new file mode 100644 index 00000000000..302cae587da --- /dev/null +++ b/src/lib/onboard/openclaw-runtime-env.test.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { appendOpenClawRuntimeEnvArgs } from "./openclaw-runtime-env"; + +describe("appendOpenClawRuntimeEnvArgs", () => { + it("pins HOME, STATE_DIR, and WORKSPACE_DIR for the default OpenClaw config dir", () => { + const envArgs: string[] = []; + appendOpenClawRuntimeEnvArgs(envArgs, null); + expect(envArgs).toEqual([ + "OPENCLAW_HOME=/sandbox", + "OPENCLAW_STATE_DIR=/sandbox/.openclaw", + "OPENCLAW_WORKSPACE_DIR=/sandbox/.openclaw/workspace", + ]); + }); + + it("derives the env values from agent.configPaths.dir when supplied", () => { + const envArgs: string[] = []; + appendOpenClawRuntimeEnvArgs(envArgs, { + configPaths: { dir: "/srv/agent-root/.openclaw" }, + }); + expect(envArgs).toEqual([ + "OPENCLAW_HOME=/srv/agent-root", + "OPENCLAW_STATE_DIR=/srv/agent-root/.openclaw", + "OPENCLAW_WORKSPACE_DIR=/srv/agent-root/.openclaw/workspace", + ]); + }); + + it("falls back to the default dir when the agent omits configPaths", () => { + const envArgs: string[] = []; + appendOpenClawRuntimeEnvArgs(envArgs, { configPaths: undefined }); + expect(envArgs).toEqual([ + "OPENCLAW_HOME=/sandbox", + "OPENCLAW_STATE_DIR=/sandbox/.openclaw", + "OPENCLAW_WORKSPACE_DIR=/sandbox/.openclaw/workspace", + ]); + }); + + it("appends to an existing envArgs array without dropping prior entries", () => { + const envArgs = ["CHAT_UI_URL=http://127.0.0.1:18789"]; + appendOpenClawRuntimeEnvArgs(envArgs, null); + expect(envArgs[0]).toBe("CHAT_UI_URL=http://127.0.0.1:18789"); + expect(envArgs).toHaveLength(4); + }); +}); diff --git a/src/lib/onboard/openclaw-runtime-env.ts b/src/lib/onboard/openclaw-runtime-env.ts new file mode 100644 index 00000000000..5b8ceaeb090 --- /dev/null +++ b/src/lib/onboard/openclaw-runtime-env.ts @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; +import { formatEnvAssignment } from "../core/url-utils"; + +const DEFAULT_OPENCLAW_CONFIG_DIR = "/sandbox/.openclaw"; + +type AgentLike = { + readonly configPaths?: { readonly dir?: string }; +} | null; + +export function appendOpenClawRuntimeEnvArgs(envArgs: string[], agent: AgentLike): void { + const configDir = agent?.configPaths?.dir || DEFAULT_OPENCLAW_CONFIG_DIR; + const homeDir = path.posix.dirname(configDir); + envArgs.push(formatEnvAssignment("OPENCLAW_HOME", homeDir)); + envArgs.push(formatEnvAssignment("OPENCLAW_STATE_DIR", configDir)); + envArgs.push(formatEnvAssignment("OPENCLAW_WORKSPACE_DIR", `${configDir}/workspace`)); +} diff --git a/test/onboard.test.ts b/test/onboard.test.ts index d206cc7f7ae..2ff4027a6f2 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -3133,9 +3133,9 @@ const { createSandbox } = require(${onboardPath}); assert.match(createCommand.command, /NEMOCLAW_DASHBOARD_PORT=19000/); assert.match(createCommand.command, /HTTP_PROXY=http:\/\/127\.0\.0\.1:8888/); assert.match(createCommand.command, /HTTPS_PROXY=http:\/\/127\.0\.0\.1:8888/); - // #4709: OpenClaw home/state/workspace dirs must be pinned in the sandbox - // env so `openclaw skills install` and `openclaw skills list` resolve the - // same paths. Without this, the upstream skill loader can fall back to a + // OpenClaw home/state/workspace dirs must be pinned in the sandbox env so + // `openclaw skills install` and `openclaw skills list` resolve the same + // paths. Without this, the upstream skill loader can fall back to a // hardcoded DEFAULT_AGENT_WORKSPACE_DIR that drifts from the install path // and hides workspace-installed skills from `skills list`. assert.match(createCommand.command, /OPENCLAW_HOME=\/sandbox(?:\s|$)/); From dc68db72a9f5af903fe6ff29d6058cde67cc8204 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 4 Jun 2026 12:36:58 +0000 Subject: [PATCH 3/6] fix(onboard): gate OpenClaw runtime env injection to OpenClaw agent, collapse Hermes dashboard call to keep onboard.ts net-neutral Signed-off-by: Tinson Lai --- src/lib/onboard.ts | 6 +----- src/lib/onboard/openclaw-runtime-env.test.ts | 16 +++++++++++++--- src/lib/onboard/openclaw-runtime-env.ts | 6 ++++++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index b6c1201441c..181decec821 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3564,11 +3564,7 @@ async function createSandbox( const effectiveDashboardPort = getDashboardForwardPort(chatUiUrl); envArgs.push(formatEnvAssignment("NEMOCLAW_DASHBOARD_PORT", effectiveDashboardPort)); require("./onboard/openclaw-runtime-env").appendOpenClawRuntimeEnvArgs(envArgs, agent); - onboardHermesDashboard.appendHermesDashboardEnvArgs( - envArgs, - hermesDashboardState, - formatEnvAssignment, - ); + onboardHermesDashboard.appendHermesDashboardEnvArgs(envArgs, hermesDashboardState, formatEnvAssignment); require("./onboard/host-proxy-env").appendHostProxyEnvArgs(envArgs); // Propagate NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT to the runtime // sandbox container. patchStagedDockerfile() already substitutes them diff --git a/src/lib/onboard/openclaw-runtime-env.test.ts b/src/lib/onboard/openclaw-runtime-env.test.ts index 302cae587da..b66dac65925 100644 --- a/src/lib/onboard/openclaw-runtime-env.test.ts +++ b/src/lib/onboard/openclaw-runtime-env.test.ts @@ -15,9 +15,10 @@ describe("appendOpenClawRuntimeEnvArgs", () => { ]); }); - it("derives the env values from agent.configPaths.dir when supplied", () => { + it("derives the env values from agent.configPaths.dir when supplied for an OpenClaw agent", () => { const envArgs: string[] = []; appendOpenClawRuntimeEnvArgs(envArgs, { + name: "openclaw", configPaths: { dir: "/srv/agent-root/.openclaw" }, }); expect(envArgs).toEqual([ @@ -27,9 +28,9 @@ describe("appendOpenClawRuntimeEnvArgs", () => { ]); }); - it("falls back to the default dir when the agent omits configPaths", () => { + it("falls back to the default dir when the OpenClaw agent omits configPaths", () => { const envArgs: string[] = []; - appendOpenClawRuntimeEnvArgs(envArgs, { configPaths: undefined }); + appendOpenClawRuntimeEnvArgs(envArgs, { name: "openclaw", configPaths: undefined }); expect(envArgs).toEqual([ "OPENCLAW_HOME=/sandbox", "OPENCLAW_STATE_DIR=/sandbox/.openclaw", @@ -43,4 +44,13 @@ describe("appendOpenClawRuntimeEnvArgs", () => { expect(envArgs[0]).toBe("CHAT_UI_URL=http://127.0.0.1:18789"); expect(envArgs).toHaveLength(4); }); + + it("skips injection for non-OpenClaw agents so OPENCLAW_* state cannot leak across agent runtimes", () => { + const envArgs: string[] = []; + appendOpenClawRuntimeEnvArgs(envArgs, { + name: "hermes", + configPaths: { dir: "/sandbox/.hermes" }, + }); + expect(envArgs).toEqual([]); + }); }); diff --git a/src/lib/onboard/openclaw-runtime-env.ts b/src/lib/onboard/openclaw-runtime-env.ts index 5b8ceaeb090..843ad14b1c1 100644 --- a/src/lib/onboard/openclaw-runtime-env.ts +++ b/src/lib/onboard/openclaw-runtime-env.ts @@ -7,10 +7,16 @@ import { formatEnvAssignment } from "../core/url-utils"; const DEFAULT_OPENCLAW_CONFIG_DIR = "/sandbox/.openclaw"; type AgentLike = { + readonly name?: string; readonly configPaths?: { readonly dir?: string }; } | null; +function isOpenClawAgent(agent: AgentLike): boolean { + return !agent || agent.name === "openclaw"; +} + export function appendOpenClawRuntimeEnvArgs(envArgs: string[], agent: AgentLike): void { + if (!isOpenClawAgent(agent)) return; const configDir = agent?.configPaths?.dir || DEFAULT_OPENCLAW_CONFIG_DIR; const homeDir = path.posix.dirname(configDir); envArgs.push(formatEnvAssignment("OPENCLAW_HOME", homeDir)); From 0cae0e2d6d284a547f0d6b07466e565cf92027f9 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 4 Jun 2026 12:54:30 +0000 Subject: [PATCH 4/6] test(e2e): add openclaw-skill-cli-e2e exercising direct CLI install + list roundtrip Signed-off-by: Tinson Lai --- .github/workflows/nightly-e2e.yaml | 20 +- test/e2e/test-openclaw-skill-cli-e2e.sh | 251 ++++++++++++++++++++++++ 2 files changed, 270 insertions(+), 1 deletion(-) create mode 100755 test/e2e/test-openclaw-skill-cli-e2e.sh diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index 5badfde8d8b..e31a6d64cf7 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -91,7 +91,7 @@ on: description: >- Comma-separated job names to run (empty = all). Valid: cloud-e2e, cloud-onboard-e2e, cloud-inference-e2e, - skill-agent-e2e, docs-validation-e2e, messaging-providers-e2e, + skill-agent-e2e, openclaw-skill-cli-e2e, docs-validation-e2e, messaging-providers-e2e, openclaw-slack-pairing-e2e, openclaw-tui-chat-correlation-e2e, issue-3600-gpu-proof-optional-e2e, @@ -232,6 +232,21 @@ jobs: env_json: '{"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE":"1","NEMOCLAW_NON_INTERACTIVE":"1","NEMOCLAW_RECREATE_SANDBOX":"1","NEMOCLAW_SANDBOX_NAME":"e2e-skill-agent"}' nvidia_api_key: true secrets: *nightly-e2e-default-secrets + openclaw-skill-cli-e2e: + if: >- + github.repository == 'NVIDIA/NemoClaw' && (github.event_name != 'workflow_dispatch' || + inputs.jobs == '' || + contains(format(',{0},', inputs.jobs), ',openclaw-skill-cli-e2e,')) + uses: ./.github/workflows/e2e-script.yaml + with: + ref: ${{ inputs.target_ref || github.ref }} + script: test/e2e/test-openclaw-skill-cli-e2e.sh + timeout_minutes: 25 + artifact_name: "install-log-openclaw-skill-cli" + artifact_path: "/tmp/nemoclaw-e2e-openclaw-skill-cli-install.log" + env_json: '{"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE":"1","NEMOCLAW_NON_INTERACTIVE":"1","NEMOCLAW_RECREATE_SANDBOX":"1","NEMOCLAW_SANDBOX_NAME":"e2e-openclaw-skill-cli"}' + nvidia_api_key: true + secrets: *nightly-e2e-default-secrets docs-validation-e2e: if: >- github.repository == 'NVIDIA/NemoClaw' && @@ -1899,6 +1914,7 @@ jobs: cloud-onboard-e2e, cloud-inference-e2e, skill-agent-e2e, + openclaw-skill-cli-e2e, docs-validation-e2e, messaging-providers-e2e, openclaw-slack-pairing-e2e, @@ -2007,6 +2023,7 @@ jobs: cloud-onboard-e2e, cloud-inference-e2e, skill-agent-e2e, + openclaw-skill-cli-e2e, docs-validation-e2e, messaging-providers-e2e, openclaw-slack-pairing-e2e, @@ -2172,6 +2189,7 @@ jobs: cloud-onboard-e2e, cloud-inference-e2e, skill-agent-e2e, + openclaw-skill-cli-e2e, docs-validation-e2e, messaging-providers-e2e, openclaw-slack-pairing-e2e, diff --git a/test/e2e/test-openclaw-skill-cli-e2e.sh b/test/e2e/test-openclaw-skill-cli-e2e.sh new file mode 100755 index 00000000000..0cdebe18de2 --- /dev/null +++ b/test/e2e/test-openclaw-skill-cli-e2e.sh @@ -0,0 +1,251 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# OpenClaw skills install/list E2E — direct CLI roundtrip inside sandbox. +# +# Reproduces the bug shape from NVIDIA/NemoClaw#4709: when a user runs +# `openclaw skills install ` directly inside a NemoClaw sandbox, the +# installed skill must show up in `openclaw skills list` output. The mitigation +# in src/lib/onboard.ts + scripts/nemoclaw-start.sh pins OPENCLAW_HOME, +# OPENCLAW_STATE_DIR, and OPENCLAW_WORKSPACE_DIR so install and list resolve +# the same workspace dir. +# +# Unlike test-skill-agent-e2e.sh, this script does NOT exercise the agent — +# it exercises the CLI contract only, so it has no LLM dependency and no +# retry/fuzzy-match logic. +# +# Prerequisites: +# - Docker running +# - NVIDIA_API_KEY set (needed to onboard the sandbox) +# - NEMOCLAW_NON_INTERACTIVE=1, NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 +# +# Environment: +# NEMOCLAW_SANDBOX_NAME — sandbox name (default: e2e-openclaw-skill-cli) +# NEMOCLAW_RECREATE_SANDBOX=1 — recreate if exists +# +# Usage: +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ +# NVIDIA_API_KEY=nvapi-... bash test/e2e/test-openclaw-skill-cli-e2e.sh + +# shellcheck disable=SC2317 +set -uo pipefail + +PASS=0 +FAIL=0 +TOTAL=0 + +pass() { + ((PASS++)) + ((TOTAL++)) + printf '\033[32m PASS: %s\033[0m\n' "$1" +} +fail() { + ((FAIL++)) + ((TOTAL++)) + printf '\033[31m FAIL: %s\033[0m\n' "$1" +} +section() { + echo "" + printf '\033[1;36m=== %s ===\033[0m\n' "$1" +} +info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } + +# ── Repo root ── +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_candidate="$(cd "${_script_dir}/../.." && pwd)" +if [ -d /workspace ] && [ -f /workspace/package.json ] && [ -d /workspace/test/e2e ]; then + REPO="/workspace" +elif [ -f "${_candidate}/package.json" ] && [ -d "${_candidate}/test/e2e" ]; then + REPO="${_candidate}" +else + echo "ERROR: Cannot find repo root." + exit 1 +fi +unset _script_dir _candidate + +E2E_DIR="$(cd "$(dirname "$0")" && pwd)" +SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-openclaw-skill-cli}" +SKILL_ID="openclaw-skill-cli-fixture" +SKILL_DESCRIPTION="E2E fixture proving openclaw skills install + list roundtrip" + +# Source shared teardown helper +# shellcheck source=test/e2e/lib/sandbox-teardown.sh +. "${E2E_DIR}/lib/sandbox-teardown.sh" +register_sandbox_for_teardown "$SANDBOX_NAME" + +# ══════════════════════════════════════════════════════════════════════ +# Phase 1: Install + Prerequisites +# ══════════════════════════════════════════════════════════════════════ +section "Phase 1: Install + Prerequisites" + +if ! docker info >/dev/null 2>&1; then + fail "Docker is not running" + exit 1 +fi +pass "Docker is running" + +if [ -z "${NVIDIA_API_KEY:-}" ] || [[ "${NVIDIA_API_KEY}" != nvapi-* ]]; then + fail "NVIDIA_API_KEY not set or invalid" + exit 1 +fi +pass "NVIDIA_API_KEY is set" + +cd "$REPO" || { + fail "Could not cd to repo root" + exit 1 +} + +export NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" +export NEMOCLAW_RECREATE_SANDBOX="${NEMOCLAW_RECREATE_SANDBOX:-1}" + +info "Installing NemoClaw via install.sh --non-interactive..." +INSTALL_LOG="/tmp/nemoclaw-e2e-openclaw-skill-cli-install.log" +bash install.sh --non-interactive --yes-i-accept-third-party-software >"$INSTALL_LOG" 2>&1 & +install_pid=$! +tail -f "$INSTALL_LOG" --pid=$install_pid 2>/dev/null & +tail_pid=$! +wait "$install_pid" +install_exit=$? +kill "$tail_pid" 2>/dev/null || true +wait "$tail_pid" 2>/dev/null || true + +if [ -f "$HOME/.bashrc" ]; then + # shellcheck source=/dev/null + source "$HOME/.bashrc" 2>/dev/null || true +fi +export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" +# shellcheck source=/dev/null +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" +[ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]] && export PATH="$HOME/.local/bin:$PATH" + +if [ "$install_exit" -ne 0 ]; then + fail "install.sh failed (exit $install_exit)" + tail -30 "$INSTALL_LOG" + exit 1 +fi +pass "NemoClaw installed" + +command -v openshell >/dev/null 2>&1 || { + fail "openshell not on PATH" + exit 1 +} +pass "openshell on PATH" + +# ══════════════════════════════════════════════════════════════════════ +# Phase 2: Pre-flight — verify the OPENCLAW_* runtime env pins reach +# the sandbox's runtime shell rc. Drift here means the workaround in +# src/lib/onboard.ts never propagates past `nemoclaw-start` and +# `openclaw skills list` will fall back to a hardcoded default workspace. +# ══════════════════════════════════════════════════════════════════════ +section "Phase 2: Pre-flight runtime env propagation check" + +set +e +# Single-quote the inner script so the OPENCLAW_* variables expand inside the +# sandbox shell, not on the host. +# shellcheck disable=SC2016 +env_check_out=$(openshell sandbox exec --name "$SANDBOX_NAME" -- sh -lc 'printf "OPENCLAW_HOME=%s\nOPENCLAW_STATE_DIR=%s\nOPENCLAW_WORKSPACE_DIR=%s\n" "${OPENCLAW_HOME:-}" "${OPENCLAW_STATE_DIR:-}" "${OPENCLAW_WORKSPACE_DIR:-}"' 2>&1) +env_check_rc=$? +set -uo pipefail + +if [ "$env_check_rc" -ne 0 ]; then + fail "Failed to read OPENCLAW_* env vars from sandbox runtime shell (exit ${env_check_rc})" + printf '%s\n' "$env_check_out" + exit 1 +fi + +for required_var in OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_WORKSPACE_DIR; do + if ! printf '%s\n' "$env_check_out" | grep -Eq "^${required_var}=.+"; then + fail "${required_var} not exported in sandbox runtime shell" + printf '%s\n' "$env_check_out" + exit 1 + fi +done +pass "OPENCLAW_HOME, OPENCLAW_STATE_DIR, and OPENCLAW_WORKSPACE_DIR are exported in sandbox runtime shell" + +# ══════════════════════════════════════════════════════════════════════ +# Phase 3: Write a skill fixture into the sandbox under /tmp and install +# it through the OpenClaw CLI from a non-managed source path. +# ══════════════════════════════════════════════════════════════════════ +section "Phase 3: Install skill via 'openclaw skills install ' inside sandbox" + +remote_skill_dir="/tmp/${SKILL_ID}" +write_skill_cmd="rm -rf $(printf "%q" "$remote_skill_dir") && mkdir -p $(printf "%q" "$remote_skill_dir") && cat > $(printf "%q" "${remote_skill_dir}/SKILL.md") <<'SKILLEOF' +--- +name: \"${SKILL_ID}\" +description: \"${SKILL_DESCRIPTION}\" +--- + +# OpenClaw skill CLI roundtrip fixture + +Written by test/e2e/test-openclaw-skill-cli-e2e.sh. +SKILLEOF" + +set +e +write_out=$(openshell sandbox exec --name "$SANDBOX_NAME" -- sh -lc "$write_skill_cmd" 2>&1) +write_rc=$? +set -uo pipefail +if [ "$write_rc" -ne 0 ]; then + fail "Failed to write skill fixture into sandbox (exit ${write_rc})" + printf '%s\n' "$write_out" + exit 1 +fi +pass "Wrote skill fixture into sandbox at ${remote_skill_dir}" + +set +e +install_out=$(openshell sandbox exec --name "$SANDBOX_NAME" -- sh -lc "openclaw skills install $(printf "%q" "$remote_skill_dir")" 2>&1) +install_rc=$? +set -uo pipefail +if [ "$install_rc" -ne 0 ]; then + fail "openclaw skills install failed (exit ${install_rc})" + printf '%s\n' "$install_out" + exit 1 +fi +pass "openclaw skills install completed (exit 0)" +info "install output:" +printf '%s\n' "$install_out" + +# ══════════════════════════════════════════════════════════════════════ +# Phase 4: List skills via 'openclaw skills list --json' and assert the +# installed fixture is enumerated. This is the contract NVIDIA/NemoClaw#4709 +# reported as broken; passing here proves the host/runtime env pins close +# the gap until the upstream openclaw/openclaw fixes ship. +# ══════════════════════════════════════════════════════════════════════ +section "Phase 4: Verify 'openclaw skills list' surfaces the installed skill" + +set +e +list_out=$(openshell sandbox exec --name "$SANDBOX_NAME" -- sh -lc 'openclaw skills list --json' 2>&1) +list_rc=$? +set -uo pipefail +if [ "$list_rc" -ne 0 ]; then + fail "openclaw skills list --json failed (exit ${list_rc})" + printf '%s\n' "$list_out" + exit 1 +fi +pass "openclaw skills list --json completed (exit 0)" + +if ! printf '%s' "$list_out" | grep -Fq "\"${SKILL_ID}\""; then + fail "Installed skill '${SKILL_ID}' did not appear in 'openclaw skills list --json' output (NVIDIA/NemoClaw#4709 regression)" + printf '%s\n' "$list_out" | tail -c 8000 + exit 1 +fi +pass "Installed skill '${SKILL_ID}' is enumerated by 'openclaw skills list --json'" + +# ══════════════════════════════════════════════════════════════════════ +# Summary +# ══════════════════════════════════════════════════════════════════════ +echo "" +echo "========================================" +echo " OpenClaw skill CLI E2E Results:" +echo " Passed: $PASS" +echo " Failed: $FAIL" +echo " Total: $TOTAL" +echo "========================================" + +if [ "$FAIL" -eq 0 ]; then + printf '\033[1;32m\n OpenClaw skill CLI E2E PASSED.\033[0m\n' + exit 0 +else + printf '\033[1;31m\n %d test(s) failed.\033[0m\n' "$FAIL" + exit 1 +fi From 3f8463fc6a3d34e21dedf2216933be989713d3cc Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 4 Jun 2026 13:11:52 +0000 Subject: [PATCH 5/6] fix(e2e): base64-encode skill payload to bypass openshell sandbox exec arg newline rejection Signed-off-by: Tinson Lai --- test/e2e/test-openclaw-skill-cli-e2e.sh | 26 +++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/test/e2e/test-openclaw-skill-cli-e2e.sh b/test/e2e/test-openclaw-skill-cli-e2e.sh index 0cdebe18de2..a09cbdc1d87 100755 --- a/test/e2e/test-openclaw-skill-cli-e2e.sh +++ b/test/e2e/test-openclaw-skill-cli-e2e.sh @@ -170,16 +170,22 @@ pass "OPENCLAW_HOME, OPENCLAW_STATE_DIR, and OPENCLAW_WORKSPACE_DIR are exported section "Phase 3: Install skill via 'openclaw skills install ' inside sandbox" remote_skill_dir="/tmp/${SKILL_ID}" -write_skill_cmd="rm -rf $(printf "%q" "$remote_skill_dir") && mkdir -p $(printf "%q" "$remote_skill_dir") && cat > $(printf "%q" "${remote_skill_dir}/SKILL.md") <<'SKILLEOF' ---- -name: \"${SKILL_ID}\" -description: \"${SKILL_DESCRIPTION}\" ---- - -# OpenClaw skill CLI roundtrip fixture - -Written by test/e2e/test-openclaw-skill-cli-e2e.sh. -SKILLEOF" +# openshell sandbox exec rejects command arguments that contain newlines or CRs +# ("InvalidArgument: command argument N contains newline or carriage return +# characters"), so the SKILL.md payload is base64-encoded on the host and decoded +# inside the sandbox. The encoder uses base64 -w0 (or tr -d) so the encoded +# payload is itself single-line. +skill_payload=$(printf '%s\n' \ + "---" \ + "name: \"${SKILL_ID}\"" \ + "description: \"${SKILL_DESCRIPTION}\"" \ + "---" \ + "" \ + "# OpenClaw skill CLI roundtrip fixture" \ + "" \ + "Written by test/e2e/test-openclaw-skill-cli-e2e.sh.") +skill_payload_b64=$(printf '%s' "$skill_payload" | base64 | tr -d '\n') +write_skill_cmd="rm -rf $(printf "%q" "$remote_skill_dir") && mkdir -p $(printf "%q" "$remote_skill_dir") && printf '%s' '${skill_payload_b64}' | base64 -d > $(printf "%q" "${remote_skill_dir}/SKILL.md")" set +e write_out=$(openshell sandbox exec --name "$SANDBOX_NAME" -- sh -lc "$write_skill_cmd" 2>&1) From 80c586eebfe463fc9697dfe884b449ab115b25c7 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 4 Jun 2026 13:36:02 +0000 Subject: [PATCH 6/6] test(e2e): extend openclaw-skill-cli-e2e with disk path + info + check phases, strip issue refs from comments Signed-off-by: Tinson Lai --- test/e2e/test-openclaw-skill-cli-e2e.sh | 103 +++++++++++++++++++++--- 1 file changed, 93 insertions(+), 10 deletions(-) diff --git a/test/e2e/test-openclaw-skill-cli-e2e.sh b/test/e2e/test-openclaw-skill-cli-e2e.sh index a09cbdc1d87..77fae6d9820 100755 --- a/test/e2e/test-openclaw-skill-cli-e2e.sh +++ b/test/e2e/test-openclaw-skill-cli-e2e.sh @@ -4,10 +4,9 @@ # # OpenClaw skills install/list E2E — direct CLI roundtrip inside sandbox. # -# Reproduces the bug shape from NVIDIA/NemoClaw#4709: when a user runs -# `openclaw skills install ` directly inside a NemoClaw sandbox, the -# installed skill must show up in `openclaw skills list` output. The mitigation -# in src/lib/onboard.ts + scripts/nemoclaw-start.sh pins OPENCLAW_HOME, +# Asserts that when a user runs `openclaw skills install ` directly +# inside a NemoClaw sandbox, the installed skill is enumerated by +# `openclaw skills list`. The sandbox onboard flow pins OPENCLAW_HOME, # OPENCLAW_STATE_DIR, and OPENCLAW_WORKSPACE_DIR so install and list resolve # the same workspace dir. # @@ -212,12 +211,31 @@ info "install output:" printf '%s\n' "$install_out" # ══════════════════════════════════════════════════════════════════════ -# Phase 4: List skills via 'openclaw skills list --json' and assert the -# installed fixture is enumerated. This is the contract NVIDIA/NemoClaw#4709 -# reported as broken; passing here proves the host/runtime env pins close -# the gap until the upstream openclaw/openclaw fixes ship. +# Phase 4: Disk verification — install must land under the workspace dir +# the runtime env pin advertises, NOT under the managed dir or a host +# fallback. The reporter's repro on disk was ls /sandbox/.openclaw/workspace/skills/. # ══════════════════════════════════════════════════════════════════════ -section "Phase 4: Verify 'openclaw skills list' surfaces the installed skill" +section "Phase 4: Verify install landed under \${OPENCLAW_WORKSPACE_DIR}/skills/" + +expected_disk_path="/sandbox/.openclaw/workspace/skills/${SKILL_ID}/SKILL.md" +set +e +disk_out=$(openshell sandbox exec --name "$SANDBOX_NAME" -- sh -lc "ls -1 \"\${OPENCLAW_WORKSPACE_DIR}/skills/${SKILL_ID}/\" 2>&1 ; test -f \"\${OPENCLAW_WORKSPACE_DIR}/skills/${SKILL_ID}/SKILL.md\" && echo SKILL_MD_PRESENT" 2>&1) +disk_rc=$? +set -uo pipefail +if [ "$disk_rc" -ne 0 ] || ! printf '%s' "$disk_out" | grep -Fq "SKILL_MD_PRESENT"; then + fail "Installed skill not present at \${OPENCLAW_WORKSPACE_DIR}/skills/${SKILL_ID}/SKILL.md (expected ${expected_disk_path})" + printf '%s\n' "$disk_out" + exit 1 +fi +pass "SKILL.md present on disk at \${OPENCLAW_WORKSPACE_DIR}/skills/${SKILL_ID}/" + +# ══════════════════════════════════════════════════════════════════════ +# Phase 5: List skills via 'openclaw skills list --json' and assert the +# installed fixture is enumerated. This is the contract the issue reports as +# broken when the runtime env pin is missing; passing here proves the +# install path and the list path agree on the workspace dir. +# ══════════════════════════════════════════════════════════════════════ +section "Phase 5: Verify 'openclaw skills list' surfaces the installed skill" set +e list_out=$(openshell sandbox exec --name "$SANDBOX_NAME" -- sh -lc 'openclaw skills list --json' 2>&1) @@ -231,12 +249,77 @@ fi pass "openclaw skills list --json completed (exit 0)" if ! printf '%s' "$list_out" | grep -Fq "\"${SKILL_ID}\""; then - fail "Installed skill '${SKILL_ID}' did not appear in 'openclaw skills list --json' output (NVIDIA/NemoClaw#4709 regression)" + fail "Installed skill '${SKILL_ID}' did not appear in 'openclaw skills list --json' output" printf '%s\n' "$list_out" | tail -c 8000 exit 1 fi pass "Installed skill '${SKILL_ID}' is enumerated by 'openclaw skills list --json'" +# Assert the list entry's source labels it as openclaw-workspace (not +# openclaw-managed or openclaw-extra) so we know the skill came from the +# workspace install path and not a fallback location. +if ! printf '%s' "$list_out" | grep -Fq "openclaw-workspace"; then + fail "Expected at least one entry with source 'openclaw-workspace' in 'openclaw skills list --json' output" + printf '%s\n' "$list_out" | tail -c 8000 + exit 1 +fi +pass "list output includes an entry with source 'openclaw-workspace'" + +# ══════════════════════════════════════════════════════════════════════ +# Phase 6: 'openclaw skills info ' must resolve the same skill that +# install wrote and report its on-disk location. This catches drift +# between the install resolver and the per-skill info resolver. +# ══════════════════════════════════════════════════════════════════════ +section "Phase 6: Verify 'openclaw skills info ${SKILL_ID}' resolves the workspace path" + +set +e +info_out=$(openshell sandbox exec --name "$SANDBOX_NAME" -- sh -lc "openclaw skills info $(printf "%q" "$SKILL_ID") --json" 2>&1) +info_rc=$? +set -uo pipefail +if [ "$info_rc" -ne 0 ]; then + fail "openclaw skills info ${SKILL_ID} --json failed (exit ${info_rc})" + printf '%s\n' "$info_out" + exit 1 +fi +pass "openclaw skills info ${SKILL_ID} --json completed (exit 0)" + +if ! printf '%s' "$info_out" | grep -Fq "${SKILL_ID}"; then + fail "'openclaw skills info' output did not include the skill id" + printf '%s\n' "$info_out" | tail -c 8000 + exit 1 +fi +if ! printf '%s' "$info_out" | grep -Fq "/.openclaw/workspace/skills/${SKILL_ID}"; then + fail "'openclaw skills info' did not report the workspace install path" + printf '%s\n' "$info_out" | tail -c 8000 + exit 1 +fi +pass "'openclaw skills info' reports the skill at the workspace install path" + +# ══════════════════════════════════════════════════════════════════════ +# Phase 7: 'openclaw skills check' is the eligibility report users run to +# diagnose missing skills. The installed fixture must appear there too so +# users do not see a partial view of their workspace. +# ══════════════════════════════════════════════════════════════════════ +section "Phase 7: Verify 'openclaw skills check' includes the installed skill" + +set +e +check_out=$(openshell sandbox exec --name "$SANDBOX_NAME" -- sh -lc 'openclaw skills check --json' 2>&1) +check_rc=$? +set -uo pipefail +if [ "$check_rc" -ne 0 ]; then + fail "openclaw skills check --json failed (exit ${check_rc})" + printf '%s\n' "$check_out" + exit 1 +fi +pass "openclaw skills check --json completed (exit 0)" + +if ! printf '%s' "$check_out" | grep -Fq "\"${SKILL_ID}\""; then + fail "Installed skill '${SKILL_ID}' did not appear in 'openclaw skills check --json' output" + printf '%s\n' "$check_out" | tail -c 8000 + exit 1 +fi +pass "Installed skill '${SKILL_ID}' is enumerated by 'openclaw skills check --json'" + # ══════════════════════════════════════════════════════════════════════ # Summary # ══════════════════════════════════════════════════════════════════════