From 788516ffb2b2753c636ed5c2c8486eb42644837c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 5 Aug 2026 12:03:06 -0700 Subject: [PATCH 1/3] feat(onboard): define capability provisioning schemas Signed-off-by: Apurv Kumaria --- .../capability-provisioning/contract.test.ts | 121 +++++++ .../capability-provisioning/contract.ts | 328 ++++++++++++++++++ 2 files changed, 449 insertions(+) create mode 100644 src/lib/onboard/capability-provisioning/contract.test.ts create mode 100644 src/lib/onboard/capability-provisioning/contract.ts diff --git a/src/lib/onboard/capability-provisioning/contract.test.ts b/src/lib/onboard/capability-provisioning/contract.test.ts new file mode 100644 index 0000000000..69c94e25e2 --- /dev/null +++ b/src/lib/onboard/capability-provisioning/contract.test.ts @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + CAPABILITY_CATALOG_SCHEMA_VERSION, + CAPABILITY_MANIFEST_SCHEMA_VERSION, + CapabilityProvisioningContractError, + parseCapabilityCatalogV1, + parseCapabilityManifestV1, +} from "./contract"; + +const RUNTIME_DIGEST = "a".repeat(64); +const TOOL_DIGEST = "b".repeat(64); + +function manifest(capabilities: Array<{ id: string; version: string | null }> = []) { + return { + schemaVersion: CAPABILITY_MANIFEST_SCHEMA_VERSION, + agent: "hermes", + capabilities, + }; +} + +function catalog() { + return { + schemaVersion: CAPABILITY_CATALOG_SCHEMA_VERSION, + capabilities: [ + { + id: "rust-runtime", + displayName: "Rust runtime", + kind: "runtime", + version: "1.90.0", + agents: ["openclaw", "hermes", "langchain-deepagents-code"], + requires: [], + policyPresets: [], + artifacts: [ + { + platform: "linux/amd64", + reference: `oci://ghcr.io/nvidia/nemoclaw-capabilities/rust-runtime@sha256:${RUNTIME_DIGEST}`, + installPrefix: "/opt/nemoclaw/capabilities/rust-runtime", + pathEntries: ["bin"], + }, + { + platform: "linux/arm64", + reference: `oci://ghcr.io/nvidia/nemoclaw-capabilities/rust-runtime@sha256:${TOOL_DIGEST}`, + installPrefix: "/opt/nemoclaw/capabilities/rust-runtime", + pathEntries: ["bin"], + }, + ], + }, + { + id: "switchyard", + displayName: "Switchyard", + kind: "tool", + version: "2.4.1", + agents: ["hermes"], + requires: ["rust-runtime"], + policyPresets: ["switchyard"], + artifacts: [ + { + platform: "linux/amd64", + reference: `oci://ghcr.io/nvidia/nemoclaw-capabilities/switchyard@sha256:${TOOL_DIGEST}`, + installPrefix: "/opt/nemoclaw/capabilities/switchyard", + pathEntries: ["bin"], + }, + ], + }, + ], + }; +} + +describe("capability provisioning contracts", () => { + it("rejects duplicate capability requests", () => { + expect(() => + parseCapabilityManifestV1( + manifest([ + { id: "switchyard", version: null }, + { id: "switchyard", version: null }, + ]), + ), + ).toThrow(/must not contain duplicates/u); + }); + + it("rejects mutable artifacts, arbitrary destinations, and executable instructions", () => { + const mutable = catalog(); + mutable.capabilities[0]!.artifacts[0]!.reference = + "oci://ghcr.io/nvidia/nemoclaw-capabilities/rust-runtime:latest"; + expect(() => parseCapabilityCatalogV1(mutable)).toThrow(/reference has an unsupported format/u); + + const destination = catalog(); + destination.capabilities[0]!.artifacts[0]!.installPrefix = "/usr/local"; + expect(() => parseCapabilityCatalogV1(destination)).toThrow( + /installPrefix has an unsupported format/u, + ); + + const executable = catalog() as unknown as { capabilities: Array> }; + executable.capabilities[0]!.command = "apt-get install rustc"; + expect(() => parseCapabilityCatalogV1(executable)).toThrow(/must contain exactly/u); + }); + + it("rejects catalog dependency gaps and cycles", () => { + const missing = catalog(); + missing.capabilities[1]!.requires = ["missing-runtime"]; + expect(() => parseCapabilityCatalogV1(missing)).toThrow(/requires unknown capability/u); + + const cyclic = catalog(); + cyclic.capabilities[0]!.requires = ["switchyard"]; + expect(() => parseCapabilityCatalogV1(cyclic)).toThrow(/dependency cycle/u); + }); + + it("rejects inherited objects and malformed relative path entries", () => { + const inherited = Object.create({ schemaVersion: CAPABILITY_MANIFEST_SCHEMA_VERSION }); + inherited.agent = "hermes"; + inherited.capabilities = []; + expect(() => parseCapabilityManifestV1(inherited)).toThrow(CapabilityProvisioningContractError); + + const traversal = catalog(); + traversal.capabilities[0]!.artifacts[0]!.pathEntries = ["../bin"]; + expect(() => parseCapabilityCatalogV1(traversal)).toThrow(/pathEntries\[0\]/u); + }); +}); diff --git a/src/lib/onboard/capability-provisioning/contract.ts b/src/lib/onboard/capability-provisioning/contract.ts new file mode 100644 index 0000000000..a02e196381 --- /dev/null +++ b/src/lib/onboard/capability-provisioning/contract.ts @@ -0,0 +1,328 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; + +export const CAPABILITY_MANIFEST_SCHEMA_VERSION = 1 as const; +export const CAPABILITY_CATALOG_SCHEMA_VERSION = 1 as const; +export const CAPABILITY_BOM_SCHEMA_VERSION = 1 as const; + +export const CAPABILITY_AGENTS = ["openclaw", "hermes", "langchain-deepagents-code"] as const; +export const CAPABILITY_PLATFORMS = ["linux/amd64", "linux/arm64", "windows/x64"] as const; +export const CAPABILITY_KINDS = ["runtime", "skill", "tool"] as const; + +export type CapabilityAgent = (typeof CAPABILITY_AGENTS)[number]; +export type CapabilityPlatform = (typeof CAPABILITY_PLATFORMS)[number]; +export type CapabilityKind = (typeof CAPABILITY_KINDS)[number]; + +export interface CapabilityManifestV1 { + readonly schemaVersion: typeof CAPABILITY_MANIFEST_SCHEMA_VERSION; + readonly agent: CapabilityAgent; + readonly capabilities: readonly { + readonly id: string; + readonly version: string | null; + }[]; +} + +export interface CapabilityCatalogArtifactV1 { + readonly platform: CapabilityPlatform; + readonly reference: `oci://${string}@sha256:${string}`; + readonly installPrefix: `/opt/nemoclaw/capabilities/${string}`; + readonly pathEntries: readonly string[]; +} + +export interface CapabilityCatalogEntryV1 { + readonly id: string; + readonly displayName: string; + readonly kind: CapabilityKind; + readonly version: string; + readonly agents: readonly CapabilityAgent[]; + readonly requires: readonly string[]; + readonly policyPresets: readonly string[]; + readonly artifacts: readonly CapabilityCatalogArtifactV1[]; +} + +export interface CapabilityCatalogV1 { + readonly schemaVersion: typeof CAPABILITY_CATALOG_SCHEMA_VERSION; + readonly capabilities: readonly CapabilityCatalogEntryV1[]; +} + +export interface CapabilityBillOfMaterialsItemV1 { + readonly id: string; + readonly displayName: string; + readonly kind: CapabilityKind; + readonly version: string; + readonly requested: boolean; + readonly requires: readonly string[]; + readonly policyPresets: readonly string[]; + readonly artifact: CapabilityCatalogArtifactV1; +} + +export interface CapabilityBillOfMaterialsV1 { + readonly schemaVersion: typeof CAPABILITY_BOM_SCHEMA_VERSION; + readonly agent: CapabilityAgent; + readonly platform: CapabilityPlatform; + readonly capabilities: readonly CapabilityBillOfMaterialsItemV1[]; + readonly fingerprint: `sha256:${string}`; +} + +const IDENTIFIER_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u; +const VERSION_PATTERN = /^[0-9]+(?:\.[0-9]+){1,3}(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?$/u; +const OCI_REFERENCE_PATTERN = + /^oci:\/\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?\/[a-z0-9]+(?:[._\/-][a-z0-9]+)*@sha256:[0-9a-f]{64}$/u; +const INSTALL_PREFIX_PATTERN = + /^\/opt\/nemoclaw\/capabilities\/([a-z][a-z0-9]*(?:[._-][a-z0-9]+)*)$/u; +const RELATIVE_PATH_PATTERN = /^(?!\/)(?!.*(?:^|\/)\.\.?(?:\/|$))[A-Za-z0-9._/+@-]+$/u; +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/u; +const MAX_MANIFEST_CAPABILITIES = 128; +const MAX_CATALOG_CAPABILITIES = 512; +const MAX_ENTRY_LIST_ITEMS = 128; +const MAX_STRING_BYTES = 512; + +export class CapabilityProvisioningContractError extends Error { + constructor(message: string) { + super(`Invalid capability provisioning contract: ${message}`); + this.name = "CapabilityProvisioningContractError"; + } +} + +function isRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function requireRecord(value: unknown, field: string): Record { + if (!isRecord(value)) throw new CapabilityProvisioningContractError(`${field} must be an object`); + return value; +} + +function requireExactKeys( + value: Record, + expected: readonly string[], + field: string, +): void { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw new CapabilityProvisioningContractError( + `${field} must contain exactly: ${wanted.join(", ")}`, + ); + } +} + +function requireString(value: unknown, field: string): string { + if ( + typeof value !== "string" || + value.length === 0 || + Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES || + CONTROL_CHARACTER_PATTERN.test(value) + ) { + throw new CapabilityProvisioningContractError(`${field} must be a bounded printable string`); + } + return value; +} + +function requirePattern(value: unknown, pattern: RegExp, field: string): string { + const text = requireString(value, field); + if (!pattern.test(text)) { + throw new CapabilityProvisioningContractError(`${field} has an unsupported format`); + } + return text; +} + +function requireLiteral(value: unknown, expected: T, field: string): T { + if (value !== expected) { + throw new CapabilityProvisioningContractError(`${field} must be ${JSON.stringify(expected)}`); + } + return expected; +} + +function requireLiteralFrom( + value: unknown, + allowed: readonly T[], + field: string, +): T { + if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) { + throw new CapabilityProvisioningContractError(`${field} must be one of: ${allowed.join(", ")}`); + } + return value as T; +} + +function requireArray(value: unknown, field: string, limit: number): readonly unknown[] { + if (!Array.isArray(value) || value.length > limit) { + throw new CapabilityProvisioningContractError( + `${field} must be an array with at most ${limit} items`, + ); + } + return value; +} + +function requireUniqueStrings( + value: unknown, + field: string, + pattern: RegExp = IDENTIFIER_PATTERN, +): readonly string[] { + const result = requireArray(value, field, MAX_ENTRY_LIST_ITEMS).map((item, index) => + requirePattern(item, pattern, `${field}[${index}]`), + ); + if (new Set(result).size !== result.length) { + throw new CapabilityProvisioningContractError(`${field} must not contain duplicates`); + } + return result; +} + +function parseArtifact(value: unknown, field: string): CapabilityCatalogArtifactV1 { + const artifact = requireRecord(value, field); + requireExactKeys(artifact, ["installPrefix", "pathEntries", "platform", "reference"], field); + const platform = requireLiteralFrom(artifact.platform, CAPABILITY_PLATFORMS, `${field}.platform`); + const reference = requirePattern( + artifact.reference, + OCI_REFERENCE_PATTERN, + `${field}.reference`, + ) as CapabilityCatalogArtifactV1["reference"]; + const installPrefix = requirePattern( + artifact.installPrefix, + INSTALL_PREFIX_PATTERN, + `${field}.installPrefix`, + ) as CapabilityCatalogArtifactV1["installPrefix"]; + const pathEntries = requireUniqueStrings( + artifact.pathEntries, + `${field}.pathEntries`, + RELATIVE_PATH_PATTERN, + ); + return { platform, reference, installPrefix, pathEntries }; +} + +export function parseCapabilityManifestV1(value: unknown): CapabilityManifestV1 { + const manifest = requireRecord(value, "manifest"); + requireExactKeys(manifest, ["agent", "capabilities", "schemaVersion"], "manifest"); + requireLiteral( + manifest.schemaVersion, + CAPABILITY_MANIFEST_SCHEMA_VERSION, + "manifest.schemaVersion", + ); + const agent = requireLiteralFrom(manifest.agent, CAPABILITY_AGENTS, "manifest.agent"); + const capabilities = requireArray( + manifest.capabilities, + "manifest.capabilities", + MAX_MANIFEST_CAPABILITIES, + ).map((value, index) => { + const request = requireRecord(value, `manifest.capabilities[${index}]`); + requireExactKeys(request, ["id", "version"], `manifest.capabilities[${index}]`); + return { + id: requirePattern(request.id, IDENTIFIER_PATTERN, `manifest.capabilities[${index}].id`), + version: + request.version === null + ? null + : requirePattern( + request.version, + VERSION_PATTERN, + `manifest.capabilities[${index}].version`, + ), + }; + }); + if (new Set(capabilities.map(({ id }) => id)).size !== capabilities.length) { + throw new CapabilityProvisioningContractError( + "manifest.capabilities must not contain duplicates", + ); + } + return { schemaVersion: CAPABILITY_MANIFEST_SCHEMA_VERSION, agent, capabilities }; +} + +export function parseCapabilityCatalogV1(value: unknown): CapabilityCatalogV1 { + const catalog = requireRecord(value, "catalog"); + requireExactKeys(catalog, ["capabilities", "schemaVersion"], "catalog"); + requireLiteral(catalog.schemaVersion, CAPABILITY_CATALOG_SCHEMA_VERSION, "catalog.schemaVersion"); + const capabilities = requireArray( + catalog.capabilities, + "catalog.capabilities", + MAX_CATALOG_CAPABILITIES, + ).map((value, index): CapabilityCatalogEntryV1 => { + const field = `catalog.capabilities[${index}]`; + const entry = requireRecord(value, field); + requireExactKeys( + entry, + ["agents", "artifacts", "displayName", "id", "kind", "policyPresets", "requires", "version"], + field, + ); + const id = requirePattern(entry.id, IDENTIFIER_PATTERN, `${field}.id`); + const agents = requireArray(entry.agents, `${field}.agents`, CAPABILITY_AGENTS.length).map( + (agent, agentIndex) => + requireLiteralFrom(agent, CAPABILITY_AGENTS, `${field}.agents[${agentIndex}]`), + ); + if (agents.length === 0 || new Set(agents).size !== agents.length) { + throw new CapabilityProvisioningContractError(`${field}.agents must be nonempty and unique`); + } + const artifacts = requireArray( + entry.artifacts, + `${field}.artifacts`, + CAPABILITY_PLATFORMS.length, + ).map((artifact, artifactIndex) => + parseArtifact(artifact, `${field}.artifacts[${artifactIndex}]`), + ); + if ( + artifacts.length === 0 || + new Set(artifacts.map(({ platform }) => platform)).size !== artifacts.length + ) { + throw new CapabilityProvisioningContractError( + `${field}.artifacts must be nonempty with unique platforms`, + ); + } + if (artifacts.some(({ installPrefix }) => installPrefix.split("/").at(-1) !== id)) { + throw new CapabilityProvisioningContractError( + `${field}.artifacts install prefixes must end with the capability id`, + ); + } + const requires = requireUniqueStrings(entry.requires, `${field}.requires`); + if (requires.includes(id)) { + throw new CapabilityProvisioningContractError(`${field}.requires must not reference itself`); + } + return { + id, + displayName: requireString(entry.displayName, `${field}.displayName`), + kind: requireLiteralFrom(entry.kind, CAPABILITY_KINDS, `${field}.kind`), + version: requirePattern(entry.version, VERSION_PATTERN, `${field}.version`), + agents, + requires, + policyPresets: requireUniqueStrings(entry.policyPresets, `${field}.policyPresets`), + artifacts, + }; + }); + if (new Set(capabilities.map(({ id }) => id)).size !== capabilities.length) { + throw new CapabilityProvisioningContractError("catalog.capabilities must have unique ids"); + } + const ids = new Set(capabilities.map(({ id }) => id)); + for (const entry of capabilities) { + for (const dependency of entry.requires) { + if (!ids.has(dependency)) { + throw new CapabilityProvisioningContractError( + `catalog capability '${entry.id}' requires unknown capability '${dependency}'`, + ); + } + } + } + const visited = new Set(); + const visiting = new Set(); + const entries = new Map(capabilities.map((entry) => [entry.id, entry])); + const visit = (id: string): void => { + if (visited.has(id)) return; + if (visiting.has(id)) { + throw new CapabilityProvisioningContractError( + `catalog capability dependency cycle includes '${id}'`, + ); + } + visiting.add(id); + for (const dependency of entries.get(id)?.requires ?? []) visit(dependency); + visiting.delete(id); + visited.add(id); + }; + for (const id of ids) visit(id); + return { schemaVersion: CAPABILITY_CATALOG_SCHEMA_VERSION, capabilities }; +} + +export function capabilityBomFingerprint( + value: Omit, +): `sha256:${string}` { + return `sha256:${createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex")}`; +} From 0b020769b24a94e2d1252bb2cceb284d61a46f83 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 5 Aug 2026 12:19:47 -0700 Subject: [PATCH 2/3] feat(onboard): resolve portable capability BOMs Signed-off-by: Apurv Kumaria --- .../onboard/capability-provisioning/README.md | 12 ++ .../capability-provisioning/resolver.test.ts | 128 ++++++++++++++++++ .../capability-provisioning/resolver.ts | 114 ++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 src/lib/onboard/capability-provisioning/README.md create mode 100644 src/lib/onboard/capability-provisioning/resolver.test.ts create mode 100644 src/lib/onboard/capability-provisioning/resolver.ts diff --git a/src/lib/onboard/capability-provisioning/README.md b/src/lib/onboard/capability-provisioning/README.md new file mode 100644 index 0000000000..04967b851f --- /dev/null +++ b/src/lib/onboard/capability-provisioning/README.md @@ -0,0 +1,12 @@ + + +# Capability provisioning contract + +This internal, dormant contract replaces Dockerfile-shaped customization intent with a portable capability manifest. A trusted catalog resolves requested tools, runtimes, and skills into an exact, secret-free bill of materials (BOM) for one agent and platform. + +The first slice intentionally has no onboarding caller and no runtime-provider implementation. It accepts only digest-pinned OCI artifacts, fixed managed install prefixes, relative `PATH` entries, named policy presets, and catalog-owned dependencies. It does not accept package-manager commands, shell scripts, mutable image tags, credentials, arbitrary destinations, or provider identities. + +A later runtime-provider facet will consume the resolved BOM through explicit supported or unsupported declarations. Docker and candidate Podman must implement and qualify the same facet before the feature becomes user-visible. An MXC-style provider must remain representable without adding a central runtime switch. diff --git a/src/lib/onboard/capability-provisioning/resolver.test.ts b/src/lib/onboard/capability-provisioning/resolver.test.ts new file mode 100644 index 0000000000..92d94dce0b --- /dev/null +++ b/src/lib/onboard/capability-provisioning/resolver.test.ts @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + CAPABILITY_BOM_SCHEMA_VERSION, + CAPABILITY_CATALOG_SCHEMA_VERSION, + CAPABILITY_MANIFEST_SCHEMA_VERSION, +} from "./contract"; +import { resolveCapabilityBillOfMaterials } from "./resolver"; + +const RUNTIME_DIGEST = "a".repeat(64); +const TOOL_DIGEST = "b".repeat(64); + +function manifest(capabilities: Array<{ id: string; version: string | null }> = []) { + return { + schemaVersion: CAPABILITY_MANIFEST_SCHEMA_VERSION, + agent: "hermes", + capabilities, + }; +} + +function catalog() { + return { + schemaVersion: CAPABILITY_CATALOG_SCHEMA_VERSION, + capabilities: [ + { + id: "rust-runtime", + displayName: "Rust runtime", + kind: "runtime", + version: "1.90.0", + agents: ["openclaw", "hermes", "langchain-deepagents-code"], + requires: [], + policyPresets: [], + artifacts: [ + { + platform: "linux/amd64", + reference: `oci://ghcr.io/nvidia/nemoclaw-capabilities/rust-runtime@sha256:${RUNTIME_DIGEST}`, + installPrefix: "/opt/nemoclaw/capabilities/rust-runtime", + pathEntries: ["bin"], + }, + { + platform: "linux/arm64", + reference: `oci://ghcr.io/nvidia/nemoclaw-capabilities/rust-runtime@sha256:${TOOL_DIGEST}`, + installPrefix: "/opt/nemoclaw/capabilities/rust-runtime", + pathEntries: ["bin"], + }, + ], + }, + { + id: "switchyard", + displayName: "Switchyard", + kind: "tool", + version: "2.4.1", + agents: ["hermes"], + requires: ["rust-runtime"], + policyPresets: ["switchyard"], + artifacts: [ + { + platform: "linux/amd64", + reference: `oci://ghcr.io/nvidia/nemoclaw-capabilities/switchyard@sha256:${TOOL_DIGEST}`, + installPrefix: "/opt/nemoclaw/capabilities/switchyard", + pathEntries: ["bin"], + }, + ], + }, + ], + }; +} + +describe("capability bill of materials resolution", () => { + it("resolves requested capabilities and dependencies into one deterministic BOM", () => { + const input = manifest([{ id: "switchyard", version: "2.4.1" }]); + const first = resolveCapabilityBillOfMaterials({ + manifest: input, + catalog: catalog(), + platform: "linux/amd64", + }); + const second = resolveCapabilityBillOfMaterials({ + manifest: input, + catalog: catalog(), + platform: "linux/amd64", + }); + + expect(first).toEqual(second); + expect(first.schemaVersion).toBe(CAPABILITY_BOM_SCHEMA_VERSION); + expect(first.fingerprint).toMatch(/^sha256:[0-9a-f]{64}$/u); + expect(first.capabilities.map(({ id, requested }) => ({ id, requested }))).toEqual([ + { id: "rust-runtime", requested: false }, + { id: "switchyard", requested: true }, + ]); + expect(JSON.stringify(first)).not.toMatch(/docker|podman|command|credential|secret/iu); + }); + + it("rejects unknown and version-incompatible requests", () => { + expect(() => + resolveCapabilityBillOfMaterials({ + manifest: manifest([{ id: "unknown", version: null }]), + catalog: catalog(), + platform: "linux/amd64", + }), + ).toThrow(/unknown capability 'unknown'/u); + expect(() => + resolveCapabilityBillOfMaterials({ + manifest: manifest([{ id: "switchyard", version: "2.5.0" }]), + catalog: catalog(), + platform: "linux/amd64", + }), + ).toThrow(/catalog provides '2.4.1'/u); + }); + + it("fails closed for unsupported agents and platforms", () => { + expect(() => + resolveCapabilityBillOfMaterials({ + manifest: { ...manifest([{ id: "switchyard", version: null }]), agent: "openclaw" }, + catalog: catalog(), + platform: "linux/amd64", + }), + ).toThrow(/does not support agent 'openclaw'/u); + expect(() => + resolveCapabilityBillOfMaterials({ + manifest: manifest([{ id: "switchyard", version: null }]), + catalog: catalog(), + platform: "linux/arm64", + }), + ).toThrow(/does not support platform 'linux\/arm64'/u); + }); +}); diff --git a/src/lib/onboard/capability-provisioning/resolver.ts b/src/lib/onboard/capability-provisioning/resolver.ts new file mode 100644 index 0000000000..c855ce87f1 --- /dev/null +++ b/src/lib/onboard/capability-provisioning/resolver.ts @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + CAPABILITY_BOM_SCHEMA_VERSION, + CAPABILITY_PLATFORMS, + type CapabilityBillOfMaterialsItemV1, + type CapabilityBillOfMaterialsV1, + type CapabilityCatalogEntryV1, + type CapabilityPlatform, + CapabilityProvisioningContractError, + capabilityBomFingerprint, + parseCapabilityCatalogV1, + parseCapabilityManifestV1, +} from "./contract"; + +export interface ResolveCapabilityBillOfMaterialsInput { + readonly manifest: unknown; + readonly catalog: unknown; + readonly platform: CapabilityPlatform; +} + +export function resolveCapabilityBillOfMaterials({ + manifest: manifestInput, + catalog: catalogInput, + platform, +}: ResolveCapabilityBillOfMaterialsInput): CapabilityBillOfMaterialsV1 { + if (!(CAPABILITY_PLATFORMS as readonly string[]).includes(platform)) { + throw new CapabilityProvisioningContractError( + `requested platform must be one of: ${CAPABILITY_PLATFORMS.join(", ")}`, + ); + } + const manifest = parseCapabilityManifestV1(manifestInput); + const catalog = parseCapabilityCatalogV1(catalogInput); + const entries = new Map(catalog.capabilities.map((entry) => [entry.id, entry])); + const requested = new Set(manifest.capabilities.map(({ id }) => id)); + const resolving = new Set(); + const resolved = new Map(); + + const visit = (id: string): void => { + if (resolved.has(id)) return; + if (resolving.has(id)) { + throw new CapabilityProvisioningContractError( + `catalog capability dependency cycle includes '${id}'`, + ); + } + const entry = entries.get(id); + if (entry === undefined) { + throw new CapabilityProvisioningContractError(`manifest requests unknown capability '${id}'`); + } + const request = manifest.capabilities.find((candidate) => candidate.id === id); + if ( + request?.version !== null && + request?.version !== undefined && + request.version !== entry.version + ) { + throw new CapabilityProvisioningContractError( + `manifest capability '${id}' requires version '${request.version}', catalog provides '${entry.version}'`, + ); + } + if (!entry.agents.includes(manifest.agent)) { + throw new CapabilityProvisioningContractError( + `capability '${id}' does not support agent '${manifest.agent}'`, + ); + } + const artifact = entry.artifacts.find((candidate) => candidate.platform === platform); + if (artifact === undefined) { + throw new CapabilityProvisioningContractError( + `capability '${id}' does not support platform '${platform}'`, + ); + } + resolving.add(id); + for (const dependency of [...entry.requires].sort()) visit(dependency); + resolving.delete(id); + resolved.set(id, toBomItem(entry, artifact, requested.has(id))); + }; + + for (const request of [...manifest.capabilities].sort((left, right) => + left.id.localeCompare(right.id), + )) { + visit(request.id); + } + + const capabilities = Object.freeze( + [...resolved.values()].sort((left, right) => left.id.localeCompare(right.id)), + ); + const unsigned = { + schemaVersion: CAPABILITY_BOM_SCHEMA_VERSION, + agent: manifest.agent, + platform, + capabilities, + } as const; + return Object.freeze({ ...unsigned, fingerprint: capabilityBomFingerprint(unsigned) }); +} + +function toBomItem( + entry: CapabilityCatalogEntryV1, + artifact: CapabilityCatalogEntryV1["artifacts"][number], + requested: boolean, +): CapabilityBillOfMaterialsItemV1 { + return Object.freeze({ + id: entry.id, + displayName: entry.displayName, + kind: entry.kind, + version: entry.version, + requested, + requires: Object.freeze([...entry.requires].sort()), + policyPresets: Object.freeze([...entry.policyPresets].sort()), + artifact: Object.freeze({ + ...artifact, + pathEntries: Object.freeze([...artifact.pathEntries]), + }), + }); +} From 780f3db7c1eed19c81bdd90cbb2200c4b1e12758 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 5 Aug 2026 13:16:50 -0700 Subject: [PATCH 3/3] fix(onboard): stabilize capability BOM ordering Signed-off-by: Apurv Kumaria --- .../onboard/capability-provisioning/README.md | 2 +- .../capability-provisioning/resolver.test.ts | 38 +++++++++++++++++++ .../capability-provisioning/resolver.ts | 10 ++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard/capability-provisioning/README.md b/src/lib/onboard/capability-provisioning/README.md index 04967b851f..c2fac17909 100644 --- a/src/lib/onboard/capability-provisioning/README.md +++ b/src/lib/onboard/capability-provisioning/README.md @@ -9,4 +9,4 @@ This internal, dormant contract replaces Dockerfile-shaped customization intent The first slice intentionally has no onboarding caller and no runtime-provider implementation. It accepts only digest-pinned OCI artifacts, fixed managed install prefixes, relative `PATH` entries, named policy presets, and catalog-owned dependencies. It does not accept package-manager commands, shell scripts, mutable image tags, credentials, arbitrary destinations, or provider identities. -A later runtime-provider facet will consume the resolved BOM through explicit supported or unsupported declarations. Docker and candidate Podman must implement and qualify the same facet before the feature becomes user-visible. An MXC-style provider must remain representable without adding a central runtime switch. +A later runtime-provider facet will consume the resolved BOM through explicit support declarations tracked in [#7744](https://github.com/NVIDIA/NemoClaw/issues/7744). Docker and Podman must install the same BOM for every supported agent and platform in that issue's qualification matrix. A socket-free MXC-style fixture must accept the same BOM shape, declare unsupported installation with an actionable reason, and require no central provider switch. The feature remains dormant until these contracts and the protected E2E matrix pass. diff --git a/src/lib/onboard/capability-provisioning/resolver.test.ts b/src/lib/onboard/capability-provisioning/resolver.test.ts index 92d94dce0b..1c95f64c7c 100644 --- a/src/lib/onboard/capability-provisioning/resolver.test.ts +++ b/src/lib/onboard/capability-provisioning/resolver.test.ts @@ -92,6 +92,44 @@ describe("capability bill of materials resolution", () => { expect(JSON.stringify(first)).not.toMatch(/docker|podman|command|credential|secret/iu); }); + it("orders punctuation-bearing capability IDs by code units across equivalent inputs", () => { + const capabilityIds = ["tool-1", "tool.1", "tool_1"]; + const capabilities = capabilityIds.map((id) => ({ + id, + displayName: id, + kind: "tool", + version: "1.0.0", + agents: ["hermes"], + requires: [], + policyPresets: [], + artifacts: [ + { + platform: "linux/amd64", + reference: `oci://ghcr.io/nvidia/nemoclaw-capabilities/${id}@sha256:${TOOL_DIGEST}`, + installPrefix: `/opt/nemoclaw/capabilities/${id}`, + pathEntries: ["bin"], + }, + ], + })); + const first = resolveCapabilityBillOfMaterials({ + manifest: manifest(capabilityIds.map((id) => ({ id, version: null }))), + catalog: { schemaVersion: CAPABILITY_CATALOG_SCHEMA_VERSION, capabilities }, + platform: "linux/amd64", + }); + const second = resolveCapabilityBillOfMaterials({ + manifest: manifest([...capabilityIds].reverse().map((id) => ({ id, version: null }))), + catalog: { + schemaVersion: CAPABILITY_CATALOG_SCHEMA_VERSION, + capabilities: [...capabilities].reverse(), + }, + platform: "linux/amd64", + }); + + expect(first).toEqual(second); + expect(first.fingerprint).toBe(second.fingerprint); + expect(first.capabilities.map(({ id }) => id)).toEqual(["tool-1", "tool.1", "tool_1"]); + }); + it("rejects unknown and version-incompatible requests", () => { expect(() => resolveCapabilityBillOfMaterials({ diff --git a/src/lib/onboard/capability-provisioning/resolver.ts b/src/lib/onboard/capability-provisioning/resolver.ts index c855ce87f1..5527a915ca 100644 --- a/src/lib/onboard/capability-provisioning/resolver.ts +++ b/src/lib/onboard/capability-provisioning/resolver.ts @@ -76,13 +76,13 @@ export function resolveCapabilityBillOfMaterials({ }; for (const request of [...manifest.capabilities].sort((left, right) => - left.id.localeCompare(right.id), + compareCapabilityIds(left.id, right.id), )) { visit(request.id); } const capabilities = Object.freeze( - [...resolved.values()].sort((left, right) => left.id.localeCompare(right.id)), + [...resolved.values()].sort((left, right) => compareCapabilityIds(left.id, right.id)), ); const unsigned = { schemaVersion: CAPABILITY_BOM_SCHEMA_VERSION, @@ -93,6 +93,12 @@ export function resolveCapabilityBillOfMaterials({ return Object.freeze({ ...unsigned, fingerprint: capabilityBomFingerprint(unsigned) }); } +function compareCapabilityIds(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + function toBomItem( entry: CapabilityCatalogEntryV1, artifact: CapabilityCatalogEntryV1["artifacts"][number],