From 0d953e6eecb749c15bd1f6e93257f1d023838f32 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 22 May 2026 11:07:00 -0700 Subject: [PATCH 1/2] fix(images): prefer versioned sandbox base images --- .../resolve-sandbox-base-image/action.yaml | 20 +++++++ .github/workflows/base-image.yaml | 4 ++ src/lib/sandbox-base-image.test.ts | 29 ++++++++++ src/lib/sandbox-base-image.ts | 55 ++++++++++++++++++- 4 files changed, 106 insertions(+), 2 deletions(-) diff --git a/.github/actions/resolve-sandbox-base-image/action.yaml b/.github/actions/resolve-sandbox-base-image/action.yaml index e2c163aeb7..f3eac959c2 100644 --- a/.github/actions/resolve-sandbox-base-image/action.yaml +++ b/.github/actions/resolve-sandbox-base-image/action.yaml @@ -16,6 +16,16 @@ runs: min_glibc="2.39" base_inputs=(Dockerfile.base nemoclaw-blueprint/blueprint.yaml) + normalize_version_tag() { + local raw="${1:-}" version + raw="${raw#refs/tags/}" + raw="${raw#release/}" + [[ -n "$raw" && "$raw" != "latest" ]] || return 1 + version="${raw#v}" + [[ "$version" =~ ^[0-9]+(\.[0-9]+){1,3}([-.][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]] || return 1 + printf 'v%s\n' "$version" + } + glibc_version() { docker run --rm --entrypoint /usr/bin/ldd "$1" --version 2>/dev/null \ | sed -nE 's/.*GLIBC ([0-9]+\.[0-9]+).*/\1/p; s/.* ([0-9]+\.[0-9]+)$/\1/p' \ @@ -69,6 +79,16 @@ runs: } candidates=() + if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]] && tag="$(normalize_version_tag "${GITHUB_REF_NAME:-}")"; then + candidates+=("${image}:${tag}") + fi + exact_tag="$(git describe --tags --exact-match --match 'v*' HEAD 2>/dev/null || true)" + if tag="$(normalize_version_tag "$exact_tag")"; then + [[ -n "$tag" ]] && candidates+=("${image}:${tag}") + fi + if [[ -f .version ]] && tag="$(normalize_version_tag "$(cat .version)")"; then + candidates+=("${image}:${tag}") + fi if [[ -n "${GITHUB_SHA:-}" ]]; then candidates+=("${image}:${GITHUB_SHA:0:8}" "${image}:${GITHUB_SHA:0:7}") fi diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 183921fc0c..7cddf77efd 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -16,6 +16,8 @@ name: Images / Base Images on: push: branches: [main] + tags: + - "v*" paths: - "Dockerfile.base" - "agents/hermes/Dockerfile.base" @@ -71,6 +73,7 @@ jobs: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=ref,event=tag type=sha,prefix=,format=short - name: Validate OpenClaw version input @@ -121,6 +124,7 @@ jobs: images: ${{ env.REGISTRY }}/nvidia/nemoclaw/hermes-sandbox-base tags: | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=ref,event=tag type=sha,prefix=,format=short - name: Build and push diff --git a/src/lib/sandbox-base-image.test.ts b/src/lib/sandbox-base-image.test.ts index c0c5585d2e..c88f841473 100644 --- a/src/lib/sandbox-base-image.test.ts +++ b/src/lib/sandbox-base-image.test.ts @@ -11,6 +11,7 @@ import { baseImageInputsChangedSinceMain, formatBuildFailureDiagnostics, getSourceShortShaTags, + getVersionedBaseImageTags, parseGlibcVersion, versionGte, } from "../../dist/lib/sandbox-base-image"; @@ -96,6 +97,34 @@ describe("sandbox base image helpers", () => { expect(tags).toEqual(["1e94f2e2", "1e94f2e"]); }); + it("derives versioned sandbox-base tags from pinned install refs", () => { + const tags = getVersionedBaseImageTags("/definitely/not/a/git/repo", { + NEMOCLAW_INSTALL_REF: "v0.0.31", + NEMOCLAW_INSTALL_TAG: "latest", + GITHUB_SHA: "1e94f2e207c5456ebc35e2bd5bb380d4430292c6", + } as NodeJS.ProcessEnv); + expect(tags).toEqual(["v0.0.31"]); + }); + + it("normalizes .version files to release image tags", () => { + const root = createGitFixture(); + writeFixture(root, ".version", "0.0.50\n"); + const tags = getVersionedBaseImageTags(root, {} as NodeJS.ProcessEnv); + expect(tags).toEqual(["v0.0.50"]); + }); + + it("uses exact git release tags but ignores non-release refs", () => { + const root = createGitFixture(); + git(root, ["tag", "v0.0.42"]); + expect(getVersionedBaseImageTags(root, gitEnv)).toEqual(["v0.0.42"]); + + git(root, ["switch", "-c", "feature"]); + writeFixture(root, "src/other.ts", "export const value = 42;\n"); + git(root, ["add", "src/other.ts"]); + git(root, ["commit", "-m", "move off tag"]); + expect(getVersionedBaseImageTags(root, gitEnv)).toEqual([]); + }); + it("surfaces stderr build diagnostics on failure (#3584)", () => { const output = formatBuildFailureDiagnostics({ stderr: "the --mount option requires BuildKit", diff --git a/src/lib/sandbox-base-image.ts b/src/lib/sandbox-base-image.ts index 2f8fa5251e..47ab375167 100644 --- a/src/lib/sandbox-base-image.ts +++ b/src/lib/sandbox-base-image.ts @@ -2,9 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; -import { ROOT, redact } from "./runner"; import { dockerBuild, dockerCapture, @@ -12,6 +12,7 @@ import { dockerImageInspectFormat, dockerPull, } from "./adapters/docker"; +import { ROOT, redact } from "./runner"; export const OPENCLAW_SANDBOX_BASE_IMAGE = "ghcr.io/nvidia/nemoclaw/sandbox-base"; export const SANDBOX_BASE_TAG = "latest"; @@ -32,7 +33,7 @@ type ResolveBaseImageOptions = { export type SandboxBaseImageResolution = { ref: string; digest: string | null; - source: "override" | "source-sha" | "latest" | "local"; + source: "override" | "version-tag" | "source-sha" | "latest" | "local"; glibcVersion: string | null; }; @@ -117,6 +118,50 @@ export function getSourceShortShaTags(rootDir = ROOT, env: NodeJS.ProcessEnv = p return Array.from(new Set(values)); } +function normalizeVersionTag(value: string | null | undefined): string | null { + const raw = String(value || "").trim(); + if (!raw || raw === "latest") return null; + const withoutPrefix = raw.replace(/^refs\/tags\//, "").replace(/^release\//, ""); + const version = withoutPrefix.startsWith("v") ? withoutPrefix.slice(1) : withoutPrefix; + if (!/^[0-9]+(?:\.[0-9]+){1,3}(?:[-.][0-9A-Za-z][0-9A-Za-z.-]*)?$/.test(version)) { + return null; + } + return `v${version}`; +} + +function gitExactVersionTag(rootDir: string, env: NodeJS.ProcessEnv = process.env): string | null { + const git = spawnSync("git", ["-C", rootDir, "describe", "--tags", "--exact-match", "--match", "v*"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5_000, + env, + }); + return git.status === 0 ? normalizeVersionTag(git.stdout) : null; +} + +function versionFileTag(rootDir: string): string | null { + try { + return normalizeVersionTag(fs.readFileSync(path.join(rootDir, ".version"), "utf-8")); + } catch { + return null; + } +} + +export function getVersionedBaseImageTags( + rootDir = ROOT, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const values = [ + env.NEMOCLAW_SANDBOX_BASE_VERSION_TAG, + env.NEMOCLAW_INSTALL_REF, + env.NEMOCLAW_INSTALL_TAG, + env.GITHUB_REF_TYPE === "tag" ? env.GITHUB_REF_NAME : null, + gitExactVersionTag(rootDir, env), + versionFileTag(rootDir), + ]; + return Array.from(new Set(values.map((value) => normalizeVersionTag(value)).filter(Boolean))) as string[]; +} + function gitStatus(rootDir: string, args: string[], env: NodeJS.ProcessEnv = process.env): number | null { const git = spawnSync("git", ["-C", rootDir, ...args], { encoding: "utf-8", @@ -348,6 +393,12 @@ export function resolveSandboxBaseImage( if (resolved) return resolved; if (!options.requireOpenshellSandboxAbi) return null; } else { + for (const tag of getVersionedBaseImageTags(options.rootDir || ROOT, env)) { + const imageRef = `${options.imageName}:${tag}`; + const resolved = resolvePulledCandidate(options.imageName, imageRef, "version-tag", options); + if (resolved) return resolved; + } + for (const tag of getSourceShortShaTags(options.rootDir || ROOT, env)) { const imageRef = `${options.imageName}:${tag}`; const resolved = resolvePulledCandidate(options.imageName, imageRef, "source-sha", options); From e54ca5a03952cbfbf9d60c31c980b2222e2ba01e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 22 May 2026 13:34:00 -0700 Subject: [PATCH 2/2] fix(inference): bound docker runtime probes --- src/lib/inference/local.ts | 7 ++++++- src/lib/onboard/local-inference-topology.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/lib/inference/local.ts b/src/lib/inference/local.ts index de047af204..f0f15d261a 100644 --- a/src/lib/inference/local.ts +++ b/src/lib/inference/local.ts @@ -11,6 +11,7 @@ import os from "node:os"; import nodePath from "node:path"; import type { CurlProbeResult } from "../adapters/http/probe"; import { runCurlProbe } from "../adapters/http/probe"; +import type { ContainerRuntime } from "../platform"; import type { CaptureResult } from "../runner"; import { buildSubprocessEnv } from "../subprocess-env"; @@ -23,6 +24,8 @@ const { containerCanReachHostLoopback, inferContainerRuntime, isWsl } = require( const { dockerInfo } = require("../adapters/docker/info"); const { detectNvidiaPlatform } = require("./nim"); +const DOCKER_INFO_RUNTIME_PROBE_TIMEOUT_MS = 1500; + /** * Port containers use to reach Ollama. Returns the raw Ollama port when the * container can reach the host's 127.0.0.1 directly (Docker Desktop on WSL), @@ -32,7 +35,9 @@ const { detectNvidiaPlatform } = require("./nim"); let _ollamaContainerPort: number | null = null; export function getOllamaContainerPort(): number { if (_ollamaContainerPort !== null) return _ollamaContainerPort; - const runtime = inferContainerRuntime(dockerInfo({ ignoreError: true })); + const runtime = inferContainerRuntime( + dockerInfo({ ignoreError: true, timeout: DOCKER_INFO_RUNTIME_PROBE_TIMEOUT_MS }), + ) as ContainerRuntime; _ollamaContainerPort = containerCanReachHostLoopback(runtime) ? OLLAMA_PORT : OLLAMA_PROXY_PORT; return _ollamaContainerPort; } diff --git a/src/lib/onboard/local-inference-topology.ts b/src/lib/onboard/local-inference-topology.ts index 85a76256b8..03320c90bb 100644 --- a/src/lib/onboard/local-inference-topology.ts +++ b/src/lib/onboard/local-inference-topology.ts @@ -3,14 +3,18 @@ import { dockerInfo } from "../adapters/docker/info"; import { - containerCanReachHostLoopback, type ContainerRuntime, + containerCanReachHostLoopback, inferContainerRuntime, } from "../platform"; import { ensureOllamaLoopbackSystemdOverride } from "./ollama-systemd"; +const DOCKER_INFO_RUNTIME_PROBE_TIMEOUT_MS = 1500; + export function getContainerRuntime(): ContainerRuntime { - return inferContainerRuntime(dockerInfo({ ignoreError: true })); + return inferContainerRuntime( + dockerInfo({ ignoreError: true, timeout: DOCKER_INFO_RUNTIME_PROBE_TIMEOUT_MS }), + ); } // True when the sandbox container needs the local Ollama auth proxy in front