diff --git a/.github/actions/resolve-hermes-base-image/action.yaml b/.github/actions/resolve-hermes-base-image/action.yaml index 9578d55471f..fd967e27ad5 100644 --- a/.github/actions/resolve-hermes-base-image/action.yaml +++ b/.github/actions/resolve-hermes-base-image/action.yaml @@ -26,6 +26,19 @@ runs: [[ -n "$have" ]] && [[ "$(printf '%s\n%s\n' "$min_glibc" "$have" | sort -V | head -n 1)" == "$min_glibc" ]] } + layout_ok() { + local ref="$1" + docker run --rm --entrypoint sh "$ref" -lc ' + set -eu + for path in /sandbox/.openclaw /sandbox/.hermes-data; do + if [ -e "$path" ] || [ -L "$path" ]; then + echo "Hermes base image contains retired sandbox state: $path" >&2 + exit 1 + fi + done + ' + } + try_image() { local ref="$1" version digest_ref if ! docker pull "$ref" >/dev/null 2>&1; then @@ -36,6 +49,10 @@ runs: echo "::warning::Hermes sandbox base image ${ref} has glibc ${version:-unknown}; need >= ${min_glibc}" return 1 fi + if ! layout_ok "$ref"; then + echo "::warning::Hermes sandbox base image ${ref} contains retired sandbox state; trying another candidate" + return 1 + fi digest_ref="$(docker image inspect "$ref" --format '{{range .RepoDigests}}{{println .}}{{end}}' | grep -F -m 1 "${image}@sha256:" || true)" if [[ -z "$digest_ref" ]]; then echo "::warning::Hermes sandbox base image ${ref} did not expose an immutable GHCR repo digest (may be a fresh tag); building locally" @@ -64,4 +81,8 @@ runs: echo "::error::Local Hermes sandbox base image has glibc ${version:-unknown}; need >= ${min_glibc}" exit 1 fi + if ! layout_ok nemoclaw-hermes-base-local; then + echo "::error::Local Hermes sandbox base image contains retired sandbox state" + exit 1 + fi echo "HERMES_BASE_IMAGE=nemoclaw-hermes-base-local" >> "$GITHUB_ENV" diff --git a/.github/workflows/pr-self-hosted.yaml b/.github/workflows/pr-self-hosted.yaml index e18d9c45fc0..bdeaae6cb0d 100644 --- a/.github/workflows/pr-self-hosted.yaml +++ b/.github/workflows/pr-self-hosted.yaml @@ -87,31 +87,6 @@ jobs: - name: Build sandbox test image on arm64 run: docker build -f test/Dockerfile.sandbox --build-arg BASE_IMAGE=nemoclaw-production-arm64 -t nemoclaw-sandbox-test-arm64 . - build-hermes-stale-openclaw-image: - runs-on: linux-amd64-cpu4 - timeout-minutes: 30 - steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - persist-credentials: false - - - name: Resolve Hermes base image - uses: ./.github/actions/resolve-hermes-base-image - - - name: Verify Hermes stale OpenClaw final-image cleanup - env: - NEMOCLAW_HERMES_BASE_IMAGE: ${{ env.HERMES_BASE_IMAGE }} - run: bash scripts/verify-hermes-stale-openclaw-image.sh - - - name: Upload Hermes stale OpenClaw image log on failure - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: hermes-stale-openclaw-image-log - path: /tmp/nemoclaw-hermes-stale-openclaw-image.log - if-no-files-found: ignore - test-e2e-sandbox: runs-on: linux-amd64-cpu4 timeout-minutes: 15 diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 80376a9c504..44428d8a7f5 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -6,13 +6,10 @@ # Layers PR-specific code (plugin, config, startup script) on top of the # pre-built Hermes base image. Mirrors the OpenClaw Dockerfile structure. -ARG NEMOCLAW_STALE_OPENCLAW_BASE_DIGEST=sha256:60333c1982ad855d55887b4488e867eb343f3930a30aa8e0268e5397fc6f2926 -ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@${NEMOCLAW_STALE_OPENCLAW_BASE_DIGEST} +ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:8dad3b989a9ed1e601743310b97be21be5f59f89f7913a47d04f3ec3c40b8ce6 # hadolint ignore=DL3006 FROM ${BASE_IMAGE} -ARG BASE_IMAGE -ARG NEMOCLAW_STALE_OPENCLAW_BASE_DIGEST # Keep the final image contract explicit even when the published base image # changes independently of this Dockerfile. @@ -253,72 +250,35 @@ ENV HERMES_ENVIRONMENT_HINT="You are running inside an NVIDIA OpenShell sandbox, # hadolint ignore=DL3002 USER root -# Flatten stale published base images that still contain /sandbox/.openclaw from -# older Hermes base layouts or the old .hermes-data symlink bridge. Dockerfile.base -# is the source of truth for new bases; this final-image repair only removes -# invalid state baked into already-published bases. -# NEMOCLAW_STALE_OPENCLAW_BASE_DIGEST is the removal trigger: update or remove -# this cleanup when the Dockerfile's default pinned Hermes base digest advances -# past the stale layout. Official GHCR bases must be digest-pinned while this -# temporary repair exists; local and verifier-built bases are intentionally -# exempt because their digest is not an upstream publication boundary. OpenShell -# starts the sandbox as the sandbox user, so runtime migration cannot rely on -# root privileges inside the pod. Regression coverage lives in -# scripts/verify-hermes-stale-openclaw-image.sh. -# REMOVAL_CHECKLIST: -# 1. BASE_IMAGE defaults to a digest newer than NEMOCLAW_STALE_OPENCLAW_BASE_DIGEST. -# 2. Supported GHCR Hermes base tags resolve to post-stale-layout digests. -# 3. Hermes stale-layout verifier coverage is kept or intentionally retired. -# 4. Remove this ARG and cleanup block in the same PR that advances the default. -# Refuse symlinked stale state before rm -rf so a compromised base cannot point -# cleanup outside the Hermes sandbox root. -# Docker image layers cannot persist bind mounts at /sandbox/.openclaw, so the -# build-time traversal threat here is a symlinked path baked into the base layer. +# Flatten stale published base images that still contain the old .hermes-data +# symlink bridge. The base resolver owns published-image provenance and exports +# immutable digests; this final-image boundary owns layout validation so local, +# rebuilt, and caller-selected bases remain supported. Retired OpenClaw state +# must never enter a Hermes final image. OpenShell starts the sandbox as the +# sandbox user, so the .hermes-data migration cannot rely on root privileges +# inside the pod. # hadolint ignore=DL4006 RUN set -eu; \ - stale_base_digest="${NEMOCLAW_STALE_OPENCLAW_BASE_DIGEST:?}"; \ - case "${BASE_IMAGE:-}" in \ - ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:*) \ - if [ "${BASE_IMAGE##*@}" != "$stale_base_digest" ]; then \ - echo "ERROR: remove stale Hermes .openclaw cleanup or update the tracked stale-base digest; BASE_IMAGE=$BASE_IMAGE tracked=$stale_base_digest" >&2; \ - exit 1; \ - fi; \ - ;; \ - ghcr.io/nvidia/nemoclaw/hermes-sandbox-base:*) \ - echo "ERROR: use an immutable Hermes base digest while stale .openclaw cleanup is present; BASE_IMAGE=$BASE_IMAGE" >&2; \ - exit 1; \ - ;; \ - nemoclaw-hermes-base-local | nemoclaw-hermes-stale-openclaw-dir-base:* | nemoclaw-hermes-stale-openclaw-link-base:*) \ - ;; \ - *) \ - echo "ERROR: unsupported Hermes BASE_IMAGE while stale .openclaw cleanup is present; BASE_IMAGE=${BASE_IMAGE:-unset}" >&2; \ - exit 1; \ - ;; \ - esac; \ config_dir=/sandbox/.hermes; \ data_dir=/sandbox/.hermes-data; \ openclaw_dir=/sandbox/.openclaw; \ - mkdir -p "$config_dir"; \ - if [ -L "$openclaw_dir" ]; then \ - echo "ERROR: refusing Hermes layout cleanup because $openclaw_dir is a symlink" >&2; \ - exit 1; \ - fi; \ - rm -rf "$openclaw_dir"; \ if [ -e "$openclaw_dir" ] || [ -L "$openclaw_dir" ]; then \ - echo "ERROR: stale OpenClaw state dir still exists after Hermes cleanup: $openclaw_dir" >&2; \ + echo "ERROR: Hermes base image contains retired OpenClaw state: $openclaw_dir" >&2; \ exit 1; \ fi; \ + mkdir -p "$config_dir"; \ if [ -L "$data_dir" ]; then \ echo "ERROR: refusing legacy layout cleanup because $data_dir is a symlink" >&2; \ exit 1; \ fi; \ if [ -d "$data_dir" ]; then \ + legacy_link="$(find "$data_dir" -type l -print -quit)"; \ + if [ -n "$legacy_link" ]; then \ + echo "ERROR: refusing legacy layout cleanup because $legacy_link is a symlink" >&2; \ + exit 1; \ + fi; \ for entry in "$data_dir"/*; do \ [ -e "$entry" ] || [ -L "$entry" ] || continue; \ - if [ -L "$entry" ]; then \ - echo "ERROR: refusing legacy layout cleanup because $entry is a symlink" >&2; \ - exit 1; \ - fi; \ name="$(basename "$entry")"; \ target="$config_dir/$name"; \ if [ -L "$target" ]; then \ diff --git a/scripts/verify-hermes-stale-openclaw-image.sh b/scripts/verify-hermes-stale-openclaw-image.sh deleted file mode 100755 index 8cbb935696d..00000000000 --- a/scripts/verify-hermes-stale-openclaw-image.sh +++ /dev/null @@ -1,190 +0,0 @@ -#!/bin/bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -set -euo pipefail - -# Bash is required for process-substitution log mirroring below and for the -# validation-only test path. -LOG_PATH="${NEMOCLAW_HERMES_STALE_OPENCLAW_IMAGE_LOG:-/tmp/nemoclaw-hermes-stale-openclaw-image.log}" -: >"$LOG_PATH" -# The mirrored log is intentionally limited to this verifier's command stream. -# require_safe_image_ref runs before any Docker build and only allows local test -# tags or immutable GHCR digest refs, so credential-bearing image URLs are not -# accepted into the build/log path. -exec > >(tee -a "$LOG_PATH") 2>&1 - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -pass() { echo -e "${GREEN}[PASS]${NC} $1"; } -info() { echo -e "${YELLOW}[INFO]${NC} $1"; } -fail() { - echo -e "${RED}[FAIL]${NC} $1" >&2 - exit 1 -} - -require_docker() { - command -v docker >/dev/null 2>&1 || fail "docker is required" - docker info >/dev/null 2>&1 || fail "docker daemon is not available" -} - -require_safe_image_ref() { - local ref="$1" - if [ -z "$ref" ]; then - fail "set NEMOCLAW_HERMES_BASE_IMAGE or HERMES_BASE_IMAGE to the resolved Hermes base image" - fi - case "$ref" in - *[[:space:]]* | *[\;\`\"\']* | *\\*) - fail "Hermes base image ref contains unsafe characters: $ref" - ;; - esac - if [[ "$ref" == nemoclaw-hermes-base-local ]] \ - || [[ "$ref" == nemoclaw-hermes-stale-openclaw-dir-base:* ]] \ - || [[ "$ref" == nemoclaw-hermes-stale-openclaw-link-base:* ]]; then - return 0 - fi - if [[ "$ref" =~ ^ghcr\.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:[a-f0-9]{64}$ ]]; then - return 0 - fi - if [[ "$ref" == ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:* ]]; then - fail "Hermes base image ref has an invalid sha256 digest: $ref" - fi - if [[ "$ref" == ghcr.io/nvidia/nemoclaw/hermes-sandbox-base:* ]]; then - fail "Hermes base image ref must be an immutable digest while stale .openclaw cleanup is present: $ref" - fi - if [[ "$ref" == ghcr.io/nvidia/nemoclaw/hermes-sandbox-base* ]]; then - fail "Hermes base image ref is not an allowed Hermes base form: $ref" - fi - fail "Hermes base image ref is outside the allowed Hermes base images: $ref" -} - -verify_dockerfile_base_digest_contract() { - local dockerfile="${REPO_ROOT}/agents/hermes/Dockerfile" - # This is a Dockerfile contract check. Semantic cleanup proof comes from - # building synthetic stale directory and symlink bases from the resolved digest - # below; mutable GHCR tag lineage checks are intentionally rejected while this - # temporary cleanup exists. - # shellcheck disable=SC2016 # literal Dockerfile ARG reference, not shell expansion - grep -Fx 'ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@${NEMOCLAW_STALE_OPENCLAW_BASE_DIGEST}' "$dockerfile" >/dev/null \ - || fail "Hermes Dockerfile must single-source BASE_IMAGE from NEMOCLAW_STALE_OPENCLAW_BASE_DIGEST" - grep -Eq '^ARG NEMOCLAW_STALE_OPENCLAW_BASE_DIGEST=sha256:[a-f0-9]{64}$' "$dockerfile" \ - || fail "Hermes Dockerfile must define a pinned stale OpenClaw base digest" - if grep -Eq '^ARG BASE_IMAGE=ghcr[.]io/nvidia/nemoclaw/hermes-sandbox-base@sha256:' "$dockerfile"; then - fail "Hermes Dockerfile must not hard-code the stale digest in BASE_IMAGE" - fi -} - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -RUN_ID="${GITHUB_RUN_ID:-local}-$$" -STALE_DIR_BASE="nemoclaw-hermes-stale-openclaw-dir-base:${RUN_ID}" -STALE_DIR_IMAGE="nemoclaw-hermes-stale-openclaw-dir:${RUN_ID}" -STALE_LINK_BASE="nemoclaw-hermes-stale-openclaw-link-base:${RUN_ID}" -STALE_LINK_IMAGE="nemoclaw-hermes-stale-openclaw-link:${RUN_ID}" -SYMLINK_BUILD_LOG="" -CLEANUP_DOCKER_IMAGES=0 - -cleanup() { - if [ -n "$SYMLINK_BUILD_LOG" ]; then - rm -f "$SYMLINK_BUILD_LOG" - fi - if [ "$CLEANUP_DOCKER_IMAGES" = "1" ]; then - docker rmi -f "$STALE_DIR_IMAGE" "$STALE_DIR_BASE" "$STALE_LINK_BASE" "$STALE_LINK_IMAGE" \ - >/dev/null 2>&1 || true - fi -} -trap cleanup EXIT - -build_stale_dir_base() { - info "Building stale Hermes base with /sandbox/.openclaw directory from ${BASE_IMAGE}" - docker build -f - --build-arg "BASE_IMAGE=${BASE_IMAGE}" -t "$STALE_DIR_BASE" "$REPO_ROOT" <<'DOCKERFILE' || fail "failed to build stale-directory Hermes base" -ARG BASE_IMAGE -FROM ${BASE_IMAGE} -USER root -RUN rm -rf /sandbox/.openclaw \ - && mkdir -p /sandbox/.openclaw \ - && printf '{}\n' > /sandbox/.openclaw/openclaw.json -DOCKERFILE -} - -build_stale_link_base() { - info "Building stale Hermes base with symlinked /sandbox/.openclaw from ${BASE_IMAGE}" - docker build -f - --build-arg "BASE_IMAGE=${BASE_IMAGE}" -t "$STALE_LINK_BASE" "$REPO_ROOT" <<'DOCKERFILE' || fail "failed to build stale-symlink Hermes base" -ARG BASE_IMAGE -FROM ${BASE_IMAGE} -USER root -RUN rm -rf /sandbox/.openclaw \ - && mkdir -p /tmp/stale-openclaw-target \ - && printf 'keep\n' > /tmp/stale-openclaw-target/sentinel \ - && ln -s /tmp/stale-openclaw-target /sandbox/.openclaw -DOCKERFILE -} - -verify_stale_dir_final_image() { - info "Building Hermes final image from stale-directory base" - docker build -f "${REPO_ROOT}/agents/hermes/Dockerfile" \ - --build-arg "BASE_IMAGE=${STALE_DIR_BASE}" \ - -t "$STALE_DIR_IMAGE" \ - "$REPO_ROOT" \ - || fail "Hermes final image did not build from stale-directory base" - - docker run --rm --entrypoint sh "$STALE_DIR_IMAGE" -lc ' - set -eu - test ! -e /sandbox/.openclaw - test "$(stat -c "%a" /sandbox/.hermes/runtime)" = "2770" - test "$(readlink /sandbox/.hermes/gateway_state.json)" = "runtime/gateway_state.json" - ' || fail "stale-directory Hermes final image layout assertions failed" - pass "Hermes final image removes stale /sandbox/.openclaw directory" -} - -verify_stale_link_final_image_fails() { - info "Building Hermes final image from stale-symlink base; this must fail closed" - set +e - docker build -f "${REPO_ROOT}/agents/hermes/Dockerfile" \ - --build-arg "BASE_IMAGE=${STALE_LINK_BASE}" \ - -t "$STALE_LINK_IMAGE" \ - "$REPO_ROOT" \ - >"$SYMLINK_BUILD_LOG" 2>&1 - local status="$?" - set -e - - if [ "$status" -eq 0 ]; then - cat "$SYMLINK_BUILD_LOG" >&2 - fail "Hermes final image unexpectedly built from stale-symlink base" - fi - grep -F ".openclaw is a symlink" "$SYMLINK_BUILD_LOG" >/dev/null \ - || { - cat "$SYMLINK_BUILD_LOG" >&2 - fail "symlink failure did not mention .openclaw" - } - docker run --rm --entrypoint sh "$STALE_LINK_BASE" -lc \ - 'test "$(cat /tmp/stale-openclaw-target/sentinel)" = "keep"' \ - || fail "stale-symlink base sentinel was not preserved" - pass "Hermes final image refuses symlinked stale /sandbox/.openclaw" -} - -main() { - if [ "${1:-}" = "--validate-ref-only" ]; then - require_safe_image_ref "${2:-}" - pass "Hermes base image ref is allowed" - return 0 - fi - - BASE_IMAGE="${NEMOCLAW_HERMES_BASE_IMAGE:-${HERMES_BASE_IMAGE:-}}" - require_safe_image_ref "$BASE_IMAGE" - verify_dockerfile_base_digest_contract - require_docker - SYMLINK_BUILD_LOG="$(mktemp -t nemoclaw-hermes-stale-openclaw-build.XXXXXX.log)" - CLEANUP_DOCKER_IMAGES=1 - # Keep the two build/proof paths sequential so Docker logs remain attributable - # and the self-hosted daemon is not competing for image layers. - build_stale_dir_base - verify_stale_dir_final_image - build_stale_link_base - verify_stale_link_final_image_fails -} - -main "$@" diff --git a/test/e2e/test-hermes-sandbox-secret-boundary.sh b/test/e2e/test-hermes-sandbox-secret-boundary.sh index 7849a00ad08..d84d3a683d9 100755 --- a/test/e2e/test-hermes-sandbox-secret-boundary.sh +++ b/test/e2e/test-hermes-sandbox-secret-boundary.sh @@ -207,6 +207,10 @@ def parse_platform_toolsets(text: str) -> dict[str, list[str]]: env_path = Path("/sandbox/.hermes/.env") config_path = Path("/sandbox/.hermes/config.yaml") +for retired_path in (Path("/sandbox/.openclaw"), Path("/sandbox/.hermes-data")): + if retired_path.exists() or retired_path.is_symlink(): + print(f"retired Hermes sandbox state is present: {retired_path}", file=sys.stderr) + sys.exit(1) if env_path.is_symlink(): print(f"{env_path} is a symlink", file=sys.stderr) sys.exit(1) diff --git a/test/helpers/hermes-dockerfile-run.ts b/test/helpers/hermes-dockerfile-run.ts index 35aa444e97b..4f8881c6fc5 100644 --- a/test/helpers/hermes-dockerfile-run.ts +++ b/test/helpers/hermes-dockerfile-run.ts @@ -5,45 +5,6 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -const ROOT = path.resolve(import.meta.dirname, "..", ".."); -const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); - -export function hermesStaleOpenclawBaseDigest(): string { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); - const match = dockerfile.match( - /^ARG NEMOCLAW_STALE_OPENCLAW_BASE_DIGEST=(sha256:[a-f0-9]{64})$/m, - ); - if (!match) { - throw new Error("Expected pinned Hermes stale OpenClaw base digest"); - } - return match[1]; -} - -export function hermesDockerShellPrelude(): string { - return [ - "set -euo pipefail", - "export BASE_IMAGE=${BASE_IMAGE:-nemoclaw-hermes-base-local}", - `export NEMOCLAW_STALE_OPENCLAW_BASE_DIGEST=${hermesStaleOpenclawBaseDigest()}`, - ].join("; "); -} - -export function precreateHermesStaleOpenclawLayout( - layout: boolean | "symlink", - openclawDir: string, - staleOpenclawTarget: string, -): void { - if (layout === true) { - fs.mkdirSync(openclawDir, { recursive: true }); - fs.writeFileSync(path.join(openclawDir, "openclaw.json"), "{}\n"); - return; - } - if (layout === "symlink") { - fs.mkdirSync(staleOpenclawTarget, { recursive: true }); - fs.writeFileSync(path.join(staleOpenclawTarget, "sentinel"), "keep\n"); - fs.symlinkSync(staleOpenclawTarget, openclawDir, "dir"); - } -} - export function dockerRunCommandBetween( dockerfile: string, startMarker: string, @@ -74,39 +35,13 @@ export function dockerRunCommandBetween( .replace(/\\\n/g, " "); } -export function dockerRunCommandContaining(dockerfile: string, signature: string): string { - const signatureIndex = dockerfile.indexOf(signature); - if (signatureIndex === -1) { - throw new Error(`Expected Dockerfile RUN signature: ${signature}`); - } - const previousRunIndex = dockerfile.lastIndexOf("\nRUN ", signatureIndex); - const runIndex = - previousRunIndex === -1 && dockerfile.startsWith("RUN ") ? 0 : previousRunIndex + 1; - if (runIndex <= 0 && !dockerfile.startsWith("RUN ")) { - throw new Error(`Expected RUN instruction before ${signature}`); - } - const linesAfterRun = dockerfile.slice(runIndex).split("\n"); - const endIndex = linesAfterRun.findIndex((line) => !line.trimEnd().endsWith("\\")); - if (endIndex === -1) { - throw new Error(`Expected complete RUN instruction containing ${signature}`); - } - return linesAfterRun - .slice(0, endIndex + 1) - .join("\n") - .trim() - .replace(/^RUN\s+/, "") - .replace(/\\\n/g, " "); -} - export function runDockerShell(command: string, sandboxRoot: string) { const logPath = path.join(sandboxRoot, "calls.log"); fs.rmSync(logPath, { force: true }); const rewritten = command.replaceAll("/sandbox", sandboxRoot); const script = [ "#!/usr/bin/env bash", - // Extracted RUN snippets execute as shell, not docker builds; export mirrors the ARG value - // so unit tests stay daemon-free while the real verifier covers --build-arg behavior. - hermesDockerShellPrelude(), + "set -euo pipefail", `call_log=${JSON.stringify(logPath)}`, 'chown() { printf "chown %s\\n" "$*" >> "$call_log"; }', rewritten, diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts new file mode 100644 index 00000000000..866de3cd894 --- /dev/null +++ b/test/hermes-final-image-layout.test.ts @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { dockerRunCommandBetween, runDockerShell } from "./helpers/hermes-dockerfile-run"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); + +type LegacyDataFixture = + | "none" + | "content" + | "directory-symlink" + | "entry-symlink" + | "nested-symlink"; +type OpenClawFixture = "none" | "directory" | "symlink"; + +interface FixturePaths { + hermesDir: string; + legacyDataDir: string; + legacyTarget: string; + openclawDir: string; + openclawTarget: string; +} + +const legacyDataSetups = { + none: () => undefined, + content: ({ hermesDir, legacyDataDir }: FixturePaths) => { + fs.mkdirSync(path.join(legacyDataDir, "sessions"), { recursive: true }); + fs.writeFileSync(path.join(legacyDataDir, "sessions", "legacy.json"), "{}\n"); + fs.writeFileSync(path.join(legacyDataDir, "legacy.txt"), "legacy\n"); + fs.symlinkSync(path.join(legacyDataDir, "sessions"), path.join(hermesDir, "sessions")); + fs.symlinkSync(path.join(legacyDataDir, "legacy.txt"), path.join(hermesDir, "legacy.txt")); + fs.mkdirSync(path.join(hermesDir, "profiles"), { recursive: true }); + fs.symlinkSync( + path.join(legacyDataDir, "sessions"), + path.join(hermesDir, "profiles", "legacy-sessions"), + ); + }, + "directory-symlink": ({ legacyDataDir, legacyTarget }: FixturePaths) => { + fs.mkdirSync(legacyTarget, { recursive: true }); + fs.writeFileSync(path.join(legacyTarget, "sentinel"), "keep\n"); + fs.symlinkSync(legacyTarget, legacyDataDir, "dir"); + }, + "entry-symlink": ({ legacyDataDir, legacyTarget }: FixturePaths) => { + fs.mkdirSync(legacyDataDir, { recursive: true }); + fs.writeFileSync(legacyTarget, "keep\n"); + fs.symlinkSync(legacyTarget, path.join(legacyDataDir, "linked-entry")); + }, + "nested-symlink": ({ legacyDataDir, legacyTarget }: FixturePaths) => { + fs.mkdirSync(path.join(legacyDataDir, "sessions"), { recursive: true }); + fs.writeFileSync(legacyTarget, "keep\n"); + fs.symlinkSync(legacyTarget, path.join(legacyDataDir, "sessions", "linked-entry")); + }, +} satisfies Record void>; + +const openclawSetups = { + none: () => undefined, + directory: ({ openclawDir }: FixturePaths) => { + fs.mkdirSync(openclawDir, { recursive: true }); + fs.writeFileSync(path.join(openclawDir, "openclaw.json"), "{}\n"); + }, + symlink: ({ openclawDir, openclawTarget }: FixturePaths) => { + fs.mkdirSync(openclawTarget, { recursive: true }); + fs.writeFileSync(path.join(openclawTarget, "sentinel"), "keep\n"); + fs.symlinkSync(openclawTarget, openclawDir, "dir"); + }, +} satisfies Record void>; + +function readText(filePath: string): string { + return fs.readFileSync(filePath, "utf-8"); +} + +function runFinalLayout({ + legacyData = "none", + openclaw = "none", +}: { + legacyData?: LegacyDataFixture; + openclaw?: OpenClawFixture; +} = {}) { + const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-final-layout-")); + const sandboxRoot = path.join(tmp, "sandbox"); + const hermesDir = path.join(sandboxRoot, ".hermes"); + const legacyDataDir = path.join(sandboxRoot, ".hermes-data"); + const legacyTarget = path.join(tmp, "legacy-target"); + const openclawDir = path.join(sandboxRoot, ".openclaw"); + const openclawTarget = path.join(tmp, "openclaw-target"); + + fs.mkdirSync(hermesDir, { recursive: true }); + fs.writeFileSync(path.join(hermesDir, "config.yaml"), "model: test\n"); + fs.writeFileSync(path.join(hermesDir, ".env"), "TOKEN=test\n"); + + const fixturePaths = { hermesDir, legacyDataDir, legacyTarget, openclawDir, openclawTarget }; + legacyDataSetups[legacyData](fixturePaths); + openclawSetups[openclaw](fixturePaths); + + const layoutCommand = dockerRunCommandBetween( + dockerfile, + "# Flatten stale published base images", + "# Pin config hash at build time", + ).replaceAll("/root/.cache/pip", path.join(tmp, "root-cache", "pip")); + const { result } = runDockerShell(layoutCommand, sandboxRoot); + return { hermesDir, legacyTarget, openclawTarget, result, sandboxRoot, tmp }; +} + +describe("Hermes final image layout", () => { + it("rejects retired OpenClaw state represented as a directory", () => { + const run = runFinalLayout({ openclaw: "directory" }); + try { + expect(run.result.status).toBe(1); + expect(run.result.stderr).toContain("contains retired OpenClaw state"); + } finally { + fs.rmSync(run.tmp, { recursive: true, force: true }); + } + }); + + it("rejects retired OpenClaw state represented as a symlink without following it", () => { + const run = runFinalLayout({ openclaw: "symlink" }); + try { + expect(run.result.status).toBe(1); + expect(run.result.stderr).toContain("contains retired OpenClaw state"); + expect(readText(path.join(run.openclawTarget, "sentinel"))).toBe("keep\n"); + } finally { + fs.rmSync(run.tmp, { recursive: true, force: true }); + } + }); + + it("migrates legacy data into the current state directory", () => { + const run = runFinalLayout({ legacyData: "content" }); + try { + expect(run.result.status).toBe(0); + expect( + fs.lstatSync(path.join(run.sandboxRoot, ".hermes-data"), { throwIfNoEntry: false }), + ).toBeUndefined(); + expect(fs.lstatSync(path.join(run.hermesDir, "sessions")).isDirectory()).toBe(true); + expect(readText(path.join(run.hermesDir, "sessions", "legacy.json"))).toBe("{}\n"); + expect(fs.lstatSync(path.join(run.hermesDir, "legacy.txt")).isSymbolicLink()).toBe(false); + expect(readText(path.join(run.hermesDir, "legacy.txt"))).toBe("legacy\n"); + const nested = path.join(run.hermesDir, "profiles", "legacy-sessions"); + expect(fs.lstatSync(nested).isDirectory()).toBe(true); + expect(readText(path.join(nested, "legacy.json"))).toBe("{}\n"); + } finally { + fs.rmSync(run.tmp, { recursive: true, force: true }); + } + }); + + it.each([ + "directory-symlink", + "entry-symlink", + "nested-symlink", + ] as const)("refuses a legacy data %s before migration", (legacyData) => { + const run = runFinalLayout({ legacyData }); + try { + expect(run.result.status).toBe(1); + expect(run.result.stderr).toContain("refusing legacy layout cleanup"); + const sentinel = + legacyData === "directory-symlink" + ? path.join(run.legacyTarget, "sentinel") + : run.legacyTarget; + expect(readText(sentinel)).toBe("keep\n"); + } finally { + fs.rmSync(run.tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/test/hermes-stale-openclaw-guard.test.ts b/test/hermes-stale-openclaw-guard.test.ts deleted file mode 100644 index 82ebf4595b0..00000000000 --- a/test/hermes-stale-openclaw-guard.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { - dockerRunCommandContaining, - hermesStaleOpenclawBaseDigest, - runDockerShell, -} from "./helpers/hermes-dockerfile-run"; - -const ROOT = path.resolve(import.meta.dirname, ".."); -const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); -const VERIFY_SCRIPT = path.join(ROOT, "scripts", "verify-hermes-stale-openclaw-image.sh"); -const STALE_DIGEST = hermesStaleOpenclawBaseDigest(); -const DIFFERENT_DIGEST = `sha256:${"0".repeat(64)}`; -const STALE_CLEANUP_SIGNATURE = 'stale_base_digest="${NEMOCLAW_STALE_OPENCLAW_BASE_DIGEST:?}"'; - -describe("Hermes stale OpenClaw guardrails", () => { - it("Hermes stale cleanup digest guard fails when the default pinned GHCR base digest changes", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); - const cleanupCommand = dockerRunCommandContaining(dockerfile, STALE_CLEANUP_SIGNATURE); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-stale-digest-")); - const sandboxRoot = path.join(tmp, "sandbox"); - fs.mkdirSync(sandboxRoot, { recursive: true }); - - try { - const { result } = runDockerShell( - [ - `BASE_IMAGE=${JSON.stringify(`ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@${DIFFERENT_DIGEST}`)}`, - `NEMOCLAW_STALE_OPENCLAW_BASE_DIGEST=${JSON.stringify(STALE_DIGEST)}`, - cleanupCommand, - ].join("; "), - sandboxRoot, - ); - expect(result.status).toBe(1); - expect(result.stderr).toContain("remove stale Hermes .openclaw cleanup or update"); - expect(result.stderr).toContain(DIFFERENT_DIGEST); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("Hermes stale cleanup rejects unsupported non-GHCR base images", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); - const cleanupCommand = dockerRunCommandContaining(dockerfile, STALE_CLEANUP_SIGNATURE); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-stale-unsupported-base-")); - const sandboxRoot = path.join(tmp, "sandbox"); - fs.mkdirSync(sandboxRoot, { recursive: true }); - - try { - const { result } = runDockerShell( - `BASE_IMAGE=localhost:5000/evil/hermes-base:latest; ${cleanupCommand}`, - sandboxRoot, - ); - expect(result.status).toBe(1); - expect(result.stderr).toContain( - "unsupported Hermes BASE_IMAGE while stale .openclaw cleanup is present", - ); - expect(result.stderr).toContain("localhost:5000/evil/hermes-base:latest"); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("Hermes stale cleanup succeeds for a non-symlink stale OpenClaw directory", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-stale-success-")); - const cleanupCommand = dockerRunCommandContaining( - dockerfile, - STALE_CLEANUP_SIGNATURE, - ).replaceAll("/root/.cache/pip", path.join(tmp, "root-cache", "pip")); - const sandboxRoot = path.join(tmp, "sandbox"); - const hermesDir = path.join(sandboxRoot, ".hermes"); - const legacyDataDir = path.join(sandboxRoot, ".hermes-data"); - const openclawDir = path.join(sandboxRoot, ".openclaw"); - fs.mkdirSync(openclawDir, { recursive: true }); - fs.mkdirSync(hermesDir, { recursive: true }); - fs.mkdirSync(path.join(legacyDataDir, "sessions"), { recursive: true }); - fs.writeFileSync(path.join(openclawDir, "openclaw.json"), "{}\n"); - fs.writeFileSync(path.join(legacyDataDir, "sessions", "legacy.json"), "{}\n"); - fs.writeFileSync(path.join(legacyDataDir, "legacy.txt"), "legacy\n"); - fs.writeFileSync(path.join(hermesDir, "config.yaml"), "model: test\n", { mode: 0o600 }); - fs.writeFileSync(path.join(hermesDir, ".env"), "TOKEN=test\n", { mode: 0o600 }); - fs.symlinkSync(path.join(legacyDataDir, "sessions"), path.join(hermesDir, "sessions")); - fs.symlinkSync(path.join(legacyDataDir, "legacy.txt"), path.join(hermesDir, "legacy.txt")); - - try { - const { result } = runDockerShell(cleanupCommand, sandboxRoot); - expect(result.status, result.stderr).toBe(0); - expect(result.stderr).toBe(""); - expect(fs.existsSync(openclawDir)).toBe(false); - expect(fs.existsSync(legacyDataDir)).toBe(false); - expect(fs.lstatSync(path.join(hermesDir, "sessions")).isDirectory()).toBe(true); - expect(fs.readFileSync(path.join(hermesDir, "sessions", "legacy.json"), "utf-8")).toBe( - "{}\n", - ); - expect(fs.lstatSync(path.join(hermesDir, "legacy.txt")).isSymbolicLink()).toBe(false); - expect(fs.readFileSync(path.join(hermesDir, "legacy.txt"), "utf-8")).toBe("legacy\n"); - expect(fs.lstatSync(path.join(hermesDir, "gateway_state.json")).isSymbolicLink()).toBe(true); - expect(fs.readlinkSync(path.join(hermesDir, "gateway_state.json"))).toBe( - "runtime/gateway_state.json", - ); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("Hermes stale OpenClaw verifier allows local verifier base refs without docker", () => { - const allowedRefs = [ - "nemoclaw-hermes-base-local", - "nemoclaw-hermes-stale-openclaw-dir-base:test", - "nemoclaw-hermes-stale-openclaw-link-base:test", - ]; - - for (const ref of allowedRefs) { - const result = spawnSync("bash", [VERIFY_SCRIPT, "--validate-ref-only", ref], { - encoding: "utf-8", - timeout: 5000, - }); - expect(result.status, `${ref}\n${result.stdout}\n${result.stderr}`).toBe(0); - expect(result.stdout).toContain("Hermes base image ref is allowed"); - } - }); - - it("Hermes stale OpenClaw verifier rejects unsafe base image refs before docker build", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-ref-guard-")); - const fakeBin = path.join(tmp, "bin"); - const dockerLog = path.join(tmp, "docker-called.log"); - const unsafeRefs = [ - "", - "ghcr.io/nvidia/nemoclaw/hermes-sandbox-base @sha256:bad", - 'ghcr.io/nvidia/nemoclaw/hermes-sandbox-base"bad', - "ghcr.io/nvidia/nemoclaw/hermes-sandbox-base`id`", - "ghcr.io/nvidia/nemoclaw/hermes-sandbox-base;bad", - "ghcr.io/nvidia/nemoclaw/hermes-sandbox-base\\bad", - "localhost:5000/evil", - "malicious:tag", - "ghcr.io/evil/image@sha256:deadbeef", - "ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:invalid", - "ghcr.io/nvidia/nemoclaw/hermes-sandbox-base:latest", - ]; - fs.mkdirSync(fakeBin); - fs.writeFileSync( - path.join(fakeBin, "docker"), - '#!/usr/bin/env bash\nprintf \'docker %s\\n\' "$*" >> "$NEMOCLAW_FAKE_DOCKER_LOG"\nexit 99\n', - { mode: 0o700 }, - ); - - try { - for (const [index, ref] of unsafeRefs.entries()) { - fs.rmSync(dockerLog, { force: true }); - // Prepend a fake docker binary so this validation-only test fails if docker is reached. - const result = spawnSync("bash", [VERIFY_SCRIPT], { - encoding: "utf-8", - env: { - ...process.env, - PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`, - HERMES_BASE_IMAGE: "", - NEMOCLAW_FAKE_DOCKER_LOG: dockerLog, - NEMOCLAW_HERMES_BASE_IMAGE: ref, - NEMOCLAW_HERMES_STALE_OPENCLAW_IMAGE_LOG: path.join(tmp, `script-${index}.log`), - }, - timeout: 5000, - }); - expect(result.status, ref).not.toBe(0); - expect(`${result.stdout}\n${result.stderr}`, ref).toMatch( - /Hermes base image ref|set NEMOCLAW_HERMES_BASE_IMAGE/, - ); - expect(fs.existsSync(dockerLog), ref).toBe(false); - } - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); -}); diff --git a/test/pr-workflow-contract.test.ts b/test/pr-workflow-contract.test.ts index 296735a3625..be68a9bdddd 100644 --- a/test/pr-workflow-contract.test.ts +++ b/test/pr-workflow-contract.test.ts @@ -135,7 +135,6 @@ function codeFilterMatchesChangedPaths(workflow: CiWorkflow, paths: string[]): b describe("pull request and main workflow contracts", () => { const prWorkflow = readYaml(".github/workflows/pr.yaml"); - const prSelfHostedWorkflow = readYaml(".github/workflows/pr-self-hosted.yaml"); const mainWorkflow = readYaml(".github/workflows/main.yaml"); const prekConfig = readYaml(".pre-commit-config.yaml"); const sharedActions = { @@ -675,23 +674,12 @@ describe("pull request and main workflow contracts", () => { expect(mainWorkflow.jobs["sandbox-images-and-e2e"].needs).toBe("checks"); }); - it("runs Hermes stale OpenClaw image validation in self-hosted PR CI", () => { - const job = prSelfHostedWorkflow.jobs["build-hermes-stale-openclaw-image"]; - const checkout = requiredWorkflowStep(job, "Checkout"); - const runs = stepRuns(job).join("\n"); - - expect(job["runs-on"]).toBe("linux-amd64-cpu4"); - expect(job["timeout-minutes"]).toBe(30); - expect(checkout.with?.["persist-credentials"]).toBe(false); - expect(stepUses(job)).toContain("./.github/actions/resolve-hermes-base-image"); - expect(runs).toContain("bash scripts/verify-hermes-stale-openclaw-image.sh"); - }); - it("exports immutable GHCR digests from the Hermes base resolver", () => { const runs = stepRuns(resolveHermesBaseAction).join("\n"); expect(runs).toContain("docker image inspect"); expect(runs).toContain("${image}@sha256:"); + expect(runs).toContain("layout_ok"); expect(runs).toContain("HERMES_BASE_IMAGE=${digest_ref}"); expect(runs).toContain("HERMES_BASE_IMAGE=nemoclaw-hermes-base-local"); }); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index c889cdf72e8..d6744989b84 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -16,10 +16,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { - hermesDockerShellPrelude, - precreateHermesStaleOpenclawLayout, -} from "./helpers/hermes-dockerfile-run"; const ROOT = path.resolve(import.meta.dirname, ".."); const DOCKERFILE = path.join(ROOT, "Dockerfile"); @@ -100,7 +96,7 @@ function runDockerShell(command: string, sandboxRoot: string) { const rewritten = command.replaceAll("/sandbox", sandboxRoot); const script = [ "#!/usr/bin/env bash", - hermesDockerShellPrelude(), + "set -euo pipefail", `call_log=${JSON.stringify(logPath)}`, 'chown() { printf "chown %s\\n" "$*" >> "$call_log"; }', rewritten, @@ -1292,10 +1288,7 @@ describe("Hermes sandbox provisioning", () => { dockerfilePath: string, startMarker: string, endMarker: string, - { - precreateConfig = false, - precreateStaleOpenclaw = false, - }: { precreateConfig?: boolean; precreateStaleOpenclaw?: boolean | "symlink" } = {}, + { precreateConfig = false }: { precreateConfig?: boolean } = {}, ) { const dockerfile = fs.readFileSync(dockerfilePath, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-layout-")); @@ -1306,9 +1299,6 @@ describe("Hermes sandbox provisioning", () => { fs.writeFileSync(path.join(hermesDir, "config.yaml"), "model: test\n"); fs.writeFileSync(path.join(hermesDir, ".env"), "TOKEN=test\n"); } - const openclawDir = path.join(sandboxRoot, ".openclaw"); - const staleOpenclawTarget = path.join(tmp, "stale-openclaw-target"); - precreateHermesStaleOpenclawLayout(precreateStaleOpenclaw, openclawDir, staleOpenclawTarget); const command = dockerRunCommandBetween(dockerfile, startMarker, endMarker).replaceAll( "/root/.cache/pip", path.join(tmp, "root-cache", "pip"), @@ -1418,23 +1408,6 @@ describe("Hermes sandbox provisioning", () => { fs.rmSync(tmp, { recursive: true, force: true }); } }); - it("refuses symlinked stale OpenClaw state during Hermes final image cleanup", () => { - const run = runHermesLayoutBlock( - HERMES_DOCKERFILE, - "# Flatten stale published base images", - "# Pin config hash at build time", - { precreateConfig: true, precreateStaleOpenclaw: "symlink" }, - ); - try { - expect(run.result.status).toBe(1); - expect(run.result.stderr).toContain(".openclaw is a symlink"); - const sentinel = path.join(run.tmp, "stale-openclaw-target", "sentinel"); - const sentinelContent = fs.readFileSync(sentinel, "utf-8"); - expect(sentinelContent).toBe("keep\n"); - } finally { - fs.rmSync(run.tmp, { recursive: true, force: true }); - } - }); it("grants the Hermes gateway group write access to runtime state directories", () => { const runs = [ runHermesLayoutBlock( @@ -1446,7 +1419,7 @@ describe("Hermes sandbox provisioning", () => { HERMES_DOCKERFILE, "# Flatten stale published base images", "# Pin config hash at build time", - { precreateConfig: true, precreateStaleOpenclaw: true }, + { precreateConfig: true }, ), ]; try { @@ -1477,7 +1450,6 @@ describe("Hermes sandbox provisioning", () => { expect((fs.statSync(path.join(hermesDir, "runtime")).mode & 0o7777).toString(8)).toBe( "2770", ); - expect(fs.existsSync(path.join(run.sandboxRoot, ".openclaw"))).toBe(false); expect(fs.readlinkSync(path.join(hermesDir, "gateway_state.json"))).toBe( "runtime/gateway_state.json", );