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
15 changes: 13 additions & 2 deletions nemoclaw-blueprint/blueprint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
version: "0.1.0"
min_openshell_version: "0.0.24"
min_openclaw_version: "2026.3.0"
digest: "" # Computed at release time
# Mirrors the components.sandbox.image manifest digest below. Lets a
# downstream consumer (or release tooling) verify the blueprint declares
# a specific sandbox image without parsing the components tree, and
# blocks the trivially-bypassable case where someone bumps the pinned
# image but forgets the top-level field. Release tooling should rewrite
# both fields together. See #1438.
digest: "sha256:b3d832b596ab6b7184a9dcb4ae93337ca32851a4f93b00765cc12de26baa3a9a"

profiles:
- default
Expand All @@ -18,7 +24,12 @@ description: |

components:
sandbox:
image: "ghcr.io/nvidia/openshell-community/sandboxes/openclaw:latest"
# Pinned to a specific image digest so a future registry compromise or an
# accidental :latest force-push cannot silently swap the sandbox image.
# Ref: #1438. The digest below was the resolved sha256 of the :latest tag
# at the time of this commit; release tooling should bump it together with
# the top-level `digest:` field on every release.
image: "ghcr.io/nvidia/openshell-community/sandboxes/openclaw@sha256:b3d832b596ab6b7184a9dcb4ae93337ca32851a4f93b00765cc12de26baa3a9a"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
name: "openclaw"
forward_ports:
- 18789
Expand Down
45 changes: 45 additions & 0 deletions test/validate-blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,51 @@ describe("blueprint.yaml", () => {
expect(Object.keys(defined!).length).toBeGreaterThan(0);
});

it("regression #1438: sandbox image is pinned by digest, not by mutable tag", () => {
// The blueprint MUST NOT pull a sandbox image by a mutable tag like
// ":latest" — a registry compromise or accidental force-push could
// silently swap the image. Pin via @sha256:... so the image cannot
// change without a corresponding blueprint update.
const sandbox = (bp.components as Record<string, unknown> | undefined)?.sandbox as
| { image?: unknown }
| undefined;
const image = typeof sandbox?.image === "string" ? sandbox.image : "";
expect(image.length).toBeGreaterThan(0);
expect(image).toContain("@sha256:");
// Belt and braces: explicitly forbid the ":latest" tag form even if the
// image string has been rearranged.
expect(image).not.toMatch(/:latest$/);
expect(image).not.toMatch(/:latest@/);
// The digest itself must be a 64-hex sha256.
const digestMatch = image.match(/@sha256:([0-9a-f]{64})$/);
expect(digestMatch).not.toBeNull();
});

it("regression #1438: top-level digest field is populated and matches the image digest", () => {
// The top-level `digest:` field at the top of blueprint.yaml is
// documented as "Computed at release time" and was empty on main,
// which left blueprint-level integrity unverifiable. Mirror the
// sandbox image manifest digest into the top-level field so any
// consumer can read a single field to know what's pinned, and so
// a future contributor can't bump one without bumping the other.
const topLevelDigest = typeof bp.digest === "string" ? bp.digest : "";
expect(topLevelDigest.length).toBeGreaterThan(0);
// Must be a sha256:<64-hex> string.
expect(topLevelDigest).toMatch(/^sha256:[0-9a-f]{64}$/);

const sandbox = (bp.components as Record<string, unknown> | undefined)?.sandbox as
| { image?: unknown }
| undefined;
const image = typeof sandbox?.image === "string" ? sandbox.image : "";
const imageDigestMatch = image.match(/@sha256:([0-9a-f]{64})$/);
expect(imageDigestMatch).not.toBeNull();
const imageDigest = `sha256:${imageDigestMatch?.[1] ?? ""}`;

// The two digests must agree. If a future bump touches one but not
// the other, this assertion catches it before merge.
expect(topLevelDigest).toBe(imageDigest);
});

for (const name of declared) {
describe(`profile '${name}'`, () => {
it("has a definition", () => {
Expand Down
Loading