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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/actions/resolve-sandbox-base-image/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/base-image.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ name: Images / Base Images
on:
push:
branches: [main]
tags:
- "v*"
paths:
- "Dockerfile.base"
- "agents/hermes/Dockerfile.base"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/lib/inference/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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),
Expand All @@ -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;
}
Expand Down
8 changes: 6 additions & 2 deletions src/lib/onboard/local-inference-topology.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/lib/sandbox-base-image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
baseImageInputsChangedSinceMain,
formatBuildFailureDiagnostics,
getSourceShortShaTags,
getVersionedBaseImageTags,
parseGlibcVersion,
versionGte,
} from "../../dist/lib/sandbox-base-image";
Expand Down Expand Up @@ -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",
Expand Down
55 changes: 53 additions & 2 deletions src/lib/sandbox-base-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@
// 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,
dockerImageInspect,
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";
Expand All @@ -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;
};

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down
Loading