diff --git a/.github/workflows/refresh-runtime-feed.yml b/.github/workflows/refresh-runtime-feed.yml index 1bb85f2..c5efc42 100644 --- a/.github/workflows/refresh-runtime-feed.yml +++ b/.github/workflows/refresh-runtime-feed.yml @@ -35,13 +35,18 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.REFRESH_TOKEN || github.token }} run: | - for channel in stable dev; do - node scripts/runtime-feed-generate.mjs \ - --channel "$channel" \ - --previous "v2/runtime/${channel}/runtime-release-feed.signed.json" \ - --out ".runtime-feed/${channel}.candidate.json" \ - --bundles-out ".runtime-feed/${channel}.bundles.json" \ - --bundle-dir ".runtime-feed/bundles" + # v2 is frozen for Runtime 0.9.1 and earlier. v3 is the same feed + # plus runtimes that only Runtime 0.10 and later understand. + for version in 2 3; do + for channel in stable dev; do + node scripts/runtime-feed-generate.mjs \ + --feed-version "$version" \ + --channel "$channel" \ + --previous "v${version}/runtime/${channel}/runtime-release-feed.signed.json" \ + --out ".runtime-feed/v${version}-${channel}.candidate.json" \ + --bundles-out ".runtime-feed/v${version}-${channel}.bundles.json" \ + --bundle-dir ".runtime-feed/bundles" + done done - name: Publish new immutable bundles @@ -55,28 +60,34 @@ jobs: MSTY_NEXUS_RUNTIME_RELEASE_FEED_KEY_ID: ${{ vars.MSTY_NEXUS_RUNTIME_RELEASE_FEED_KEY_ID || 'msty-runtime-feed-1' }} run: | test -n "$MSTY_NEXUS_RUNTIME_RELEASE_FEED_PRIVATE_KEY" - for channel in stable dev; do - candidate=".runtime-feed/${channel}.candidate.json" - if [[ ! -f "$candidate" ]]; then - continue - fi - output="v2/runtime/${channel}/runtime-release-feed.signed.json" - mkdir -p "$(dirname "$output")" - node scripts/runtime-feed-sign.mjs \ - --input "$candidate" \ - --out "$output" \ - --key-id "$MSTY_NEXUS_RUNTIME_RELEASE_FEED_KEY_ID" - node scripts/runtime-feed-verify.mjs "$output" "$channel" + for version in 2 3; do + for channel in stable dev; do + candidate=".runtime-feed/v${version}-${channel}.candidate.json" + if [[ ! -f "$candidate" ]]; then + continue + fi + output="v${version}/runtime/${channel}/runtime-release-feed.signed.json" + mkdir -p "$(dirname "$output")" + node scripts/runtime-feed-sign.mjs \ + --feed-version "$version" \ + --input "$candidate" \ + --out "$output" \ + --key-id "$MSTY_NEXUS_RUNTIME_RELEASE_FEED_KEY_ID" + node scripts/runtime-feed-verify.mjs "$output" "$channel" "$version" + done done - name: Commit changed feeds run: | - if [[ -z "$(git status --porcelain -- v2/runtime)" ]]; then + if [[ -z "$(git status --porcelain -- v2/runtime v3/runtime)" ]]; then echo "nothing to publish" exit 0 fi git config user.name "runtime-catalog[bot]" git config user.email "noreply@cloudstack.llc" git add v2/runtime + if [[ -d v3/runtime ]]; then + git add v3/runtime + fi git commit -m "chore: refresh runtime release feeds" git push diff --git a/README.md b/README.md index 9c1b7b4..389be17 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ https://raw.githubusercontent.com/cloudstack-llc/model-catalog/main/v1/smart-rou | `v1/featured-models.json` | Curated local models worth downloading, grouped into collections | Curated; resolved against Ollama and Hugging Face | Daily | | `v1/smart-route-starters.json` | Model-neutral Smart Route starters, grouped by use | Curated | On change | | `v2/runtime/{stable,dev}/runtime-release-feed.signed.json` | Signed Msty Nexus Runtime releases | Ollama, llama.cpp, and Msty MLX metadata | 4 hours | +| `v3/runtime/{stable,dev}/runtime-release-feed.signed.json` | The same feed for Runtime 0.10 and later, plus newer runtimes | Same as `v2`, plus the Msty image runtime | 4 hours | # Token pricing @@ -82,6 +83,19 @@ https://raw.githubusercontent.com/cloudstack-llc/catalogs/main/v2/runtime/stable https://raw.githubusercontent.com/cloudstack-llc/catalogs/main/v2/runtime/dev/runtime-release-feed.signed.json ``` +The same feed is published at two paths, `v2/runtime` and `v3/runtime`, with +the same schema and signing key. + +- `v2` is read by Msty Nexus Runtime 0.9.1 and earlier. Those versions reject + the whole feed when it has a runtime, a field, a backend or an archive + format they do not know. So `v2` never changes shape. It gets only new + versions of the runtimes it lists today. +- `v3` is read by Runtime 0.10 and later, which skip entries they do not + know. It has everything in `v2`, plus the Msty image runtime (`images`) + once that runtime is published. +- Rule: new runtimes, fields and artifact kinds go to `v3` only. The `v2` + generator and verifier refuse any other runtime. + They are not mirrored to R2. The signature is the trust boundary; the hosting origin supplies bytes but cannot authorize a runtime artifact. diff --git a/scripts/runtime-feed-generate.mjs b/scripts/runtime-feed-generate.mjs index 9cbcb5d..1308042 100644 --- a/scripts/runtime-feed-generate.mjs +++ b/scripts/runtime-feed-generate.mjs @@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url"; import { buildRuntimeFeed, createGitHubClient, + parseFeedVersion, serializeFeed, } from "./runtime-feed-lib.mjs"; @@ -112,6 +113,9 @@ async function previousFeed(path) { async function main() { const argv = process.argv.slice(2); const channel = required(option(argv, "--channel", "stable"), "--channel"); + // 2 (default) is the frozen feed for Runtime 0.9.1 and earlier; 3 may also + // list newer runtimes. See FEED_VERSIONS in runtime-feed-lib.mjs. + const feedVersion = parseFeedVersion(required(option(argv, "--feed-version", "2"), "--feed-version")); const out = resolve(repoRoot, required(option(argv, "--out"), "--out")); const bundlesOut = resolve(repoRoot, required(option(argv, "--bundles-out"), "--bundles-out")); const bundleDir = resolve(repoRoot, required(option(argv, "--bundle-dir"), "--bundle-dir")); @@ -166,13 +170,13 @@ async function main() { }; }; - const feed = await buildRuntimeFeed({ github, resolveBundle, channel }); + const feed = await buildRuntimeFeed({ github, resolveBundle, channel, feedVersion }); const previous = await previousFeed(previousPath); if ( previous !== undefined && JSON.stringify(materialReleases(previous)) === JSON.stringify(materialReleases(feed)) ) { - console.log(`unchanged: runtime feed ${channel}`); + console.log(`unchanged: runtime feed v${feedVersion} ${channel}`); return; } diff --git a/scripts/runtime-feed-lib.mjs b/scripts/runtime-feed-lib.mjs index 69b9277..e3b248b 100644 --- a/scripts/runtime-feed-lib.mjs +++ b/scripts/runtime-feed-lib.mjs @@ -20,6 +20,28 @@ export const DEFAULT_FOUNDATIONMODELS_ASSET_URL = // rejects a whole feed that names a runtime it does not know, and 0.5.x // still reads the stable feed. Add "stable" only after 0.5.x is retired. export const FOUNDATIONMODELS_CHANNELS = new Set(["dev"]); +export const DEFAULT_IMAGES_ASSET_URL = + "https://nexus-assets.msty.ai/msty-nexus/runtime/images/latest/msty-nexus-images.tgz"; + +// The feed is published at two paths with the same schema and signing key. +// v2 is read by Msty Nexus Runtime 0.9.1 and earlier, which reject the whole +// feed for a runtime, a field, a backend or an archive format they do not +// know. So v2 is frozen: only new versions of the runtimes below go there. +// v3 is read by Runtime 0.10 and later, which skip entries they do not know. +// New runtimes, fields and artifact kinds go to v3 only. +export const FEED_VERSIONS = [2, 3]; +const V2_RUNTIME_IDS = ["ollama", "llamacpp", "mlx", "foundationmodels"]; +const V3_ONLY_RUNTIME_IDS = ["images"]; + +export function parseFeedVersion(value) { + const version = Number(value); + invariant(FEED_VERSIONS.includes(version), `runtime feed version must be one of ${FEED_VERSIONS.join(", ")}`); + return version; +} + +function allowedRuntimeIds(feedVersion) { + return parseFeedVersion(feedVersion) === 2 ? V2_RUNTIME_IDS : [...V2_RUNTIME_IDS, ...V3_ONLY_RUNTIME_IDS]; +} const GITHUB_API = "https://api.github.com"; const GITHUB_DIGEST = /^sha256:([0-9a-f]{64})$/; @@ -416,6 +438,57 @@ async function buildFoundationModelsRelease(fetchImpl, channel, publishedAt, ass }; } +// The image runtime follows the Foundation Models asset contract, because MLX +// itself has no dev lane in the feed: the stable channel reads latest/ and +// points at the immutable / copy, and the dev channel reads the dev +// publish (latest/dev/), which has no versioned copy and is pinned by SHA-256. +// Returns null only when the manifest is not published yet (HTTP 404). Any +// other problem fails the refresh. +async function buildImagesRelease(fetchImpl, channel, publishedAt, assetURL) { + const parsed = new URL(assetURL); + invariant(parsed.protocol === "https:" && parsed.search === "" && parsed.hash === "", "Image runtime asset URL must be immutable HTTPS metadata input"); + invariant(parsed.pathname.endsWith("/images/latest/msty-nexus-images.tgz"), "Image runtime asset URL must be the latest msty-nexus-images.tgz alias"); + const dev = channel === "dev"; + if (dev) assetURL = assetURL.replace("/images/latest/", "/images/latest/dev/"); + const prefix = assetURL.slice(0, -"msty-nexus-images.tgz".length); + const probe = await fetchImpl(`${prefix}runtime-manifest.json`, { method: "HEAD", redirect: "follow" }); + if (probe.status === 404) return null; + const manifestResponse = await fetchWithRetry(`${prefix}runtime-manifest.json`, { fetchImpl }); + const manifest = object(await manifestResponse.json(), "Image runtime manifest"); + invariant( + manifest.object === "msty.nexus.images_runtime" && manifest.runtimeId === "images", + "Image runtime manifest identity is invalid", + ); + const version = string(manifest.runtimeVersion, "Image runtime version"); + invariant(SEMVER.test(version), "Image runtime version must be semantic"); + const checksumResponse = await fetchWithRetry(`${prefix}checksums.txt`, { fetchImpl }); + const checksumText = await checksumResponse.text(); + const checksumLine = checksumText.split(/\r?\n/).find((line) => line.trim().endsWith("msty-nexus-images.tgz")); + const sha256 = String(checksumLine ?? "").trim().split(/\s+/)[0]?.toLowerCase(); + invariant(SHA256.test(sha256), "Image runtime checksum file did not contain a valid archive SHA-256"); + const versionedURL = dev ? assetURL : assetURL.replace("/images/latest/", `/images/${version}/`); + const head = await fetchWithRetry(versionedURL, { fetchImpl, method: "HEAD" }); + const sizeBytes = Number(head.headers.get("content-length")); + integer(sizeBytes, "Image runtime artifact content-length"); + return { + runtimeId: "images", + channel, + runtimeVersion: version, + publishedAt, + source: { type: "msty_asset", url: versionedURL }, + artifacts: [{ + os: "darwin", + arch: "arm64", + backend: "metal", + url: versionedURL, + sha256, + sizeBytes, + archiveFormat: "tar.gz", + executablePath: `msty-nexus-images_${version}_darwin_arm64/msty-nexus-images`, + }], + }; +} + async function buildMLXRelease(fetchImpl, channel, publishedAt, mlxAssetURL) { const prefix = mlxMetadataPrefix(mlxAssetURL); const manifestResponse = await fetchWithRetry(`${prefix}runtime-manifest.json`, { fetchImpl }); @@ -461,7 +534,10 @@ export async function buildRuntimeFeed({ mlxAssetURL = DEFAULT_MLX_ASSET_URL, foundationModelsAssetURL = DEFAULT_FOUNDATIONMODELS_ASSET_URL, foundationModelsChannels = FOUNDATIONMODELS_CHANNELS, + feedVersion = 2, + imagesAssetURL = DEFAULT_IMAGES_ASSET_URL, }) { + feedVersion = parseFeedVersion(feedVersion); invariant(channel === "stable" || channel === "dev", "runtime feed channel must be stable or dev"); invariant(typeof github === "function", "GitHub client is required"); invariant(typeof resolveBundle === "function", "runtime bundle resolver is required"); @@ -474,23 +550,29 @@ export async function buildRuntimeFeed({ const foundationModels = await buildFoundationModelsRelease(fetchImpl, channel, publishedAt, foundationModelsAssetURL); if (foundationModels) releases.push(foundationModels); } + // v2 never asks for the image runtime, so it cannot enter that feed. + if (feedVersion >= 3) { + const images = await buildImagesRelease(fetchImpl, channel, publishedAt, imagesAssetURL); + if (images) releases.push(images); + } const feed = { schemaVersion: FEED_SCHEMA_VERSION, generatedAt: publishedAt, releases }; - validateRuntimeFeed(feed, channel); + validateRuntimeFeed(feed, channel, { feedVersion }); return feed; } -export function validateRuntimeFeed(feed, expectedChannel) { +export function validateRuntimeFeed(feed, expectedChannel, { feedVersion = 2 } = {}) { + const allowed = allowedRuntimeIds(feedVersion); object(feed, "feed"); invariant(feed.schemaVersion === FEED_SCHEMA_VERSION, `feed.schemaVersion must be ${FEED_SCHEMA_VERSION}`); invariant( - Array.isArray(feed.releases) && (feed.releases.length === 3 || feed.releases.length === 4), - "feed must contain the three required runtime releases and at most Apple Foundation Models", + Array.isArray(feed.releases) && feed.releases.length >= 3 && feed.releases.length <= allowed.length, + `v${feedVersion} feed must contain the three required runtime releases and at most ${allowed.length - 3} optional ones`, ); const byID = new Map(); for (const [releaseIndex, releaseValue] of feed.releases.entries()) { const release = object(releaseValue, `feed.releases[${releaseIndex}]`); const runtimeId = string(release.runtimeId, `feed.releases[${releaseIndex}].runtimeId`); - invariant(["ollama", "llamacpp", "mlx", "foundationmodels"].includes(runtimeId), `unsupported runtime ${runtimeId}`); + invariant(allowed.includes(runtimeId), `unsupported runtime ${runtimeId} in a v${feedVersion} feed`); invariant( runtimeId !== "foundationmodels" || FOUNDATIONMODELS_CHANNELS.has(expectedChannel), `foundationmodels is not enabled for the ${expectedChannel} channel`, @@ -562,9 +644,9 @@ function publicKeyFromRaw(value) { return createPublicKey({ key: Buffer.concat([SPKI_ED25519_PREFIX, raw]), format: "der", type: "spki" }); } -export function signRuntimeFeed(feed, privateKeyBase64, keyId = DEFAULT_KEY_ID) { +export function signRuntimeFeed(feed, privateKeyBase64, keyId = DEFAULT_KEY_ID, { feedVersion = 2 } = {}) { invariant(KEY_ID.test(keyId), "runtime feed signing key ID is invalid"); - validateRuntimeFeed(feed, feed.releases?.[0]?.channel); + validateRuntimeFeed(feed, feed.releases?.[0]?.channel, { feedVersion }); const feedJSON = serializeFeed(feed); const signature = { algorithm: SIGNATURE_ALGORITHM, @@ -574,12 +656,12 @@ export function signRuntimeFeed(feed, privateKeyBase64, keyId = DEFAULT_KEY_ID) return `{"schemaVersion":${SIGNED_FEED_SCHEMA_VERSION},"feed":${feedJSON},"signature":${JSON.stringify(signature)}}\n`; } -export function verifySignedRuntimeFeed(text, publicKeyBase64 = DEFAULT_PUBLIC_KEY, expectedChannel) { +export function verifySignedRuntimeFeed(text, publicKeyBase64 = DEFAULT_PUBLIC_KEY, expectedChannel, { feedVersion = 2 } = {}) { const envelope = object(JSON.parse(text), "signed feed"); invariant(envelope.schemaVersion === SIGNED_FEED_SCHEMA_VERSION, `signed feed schemaVersion must be ${SIGNED_FEED_SCHEMA_VERSION}`); invariant(envelope.signature?.algorithm === SIGNATURE_ALGORITHM, "signed feed algorithm must be ed25519"); invariant(KEY_ID.test(String(envelope.signature?.keyId ?? "")), "signed feed key ID is invalid"); - validateRuntimeFeed(envelope.feed, expectedChannel ?? envelope.feed?.releases?.[0]?.channel); + validateRuntimeFeed(envelope.feed, expectedChannel ?? envelope.feed?.releases?.[0]?.channel, { feedVersion }); const signature = Buffer.from(String(envelope.signature?.value ?? ""), "base64"); invariant(signature.length === 64, "signed feed signature must decode to 64 bytes"); const canonical = `{"schemaVersion":${SIGNED_FEED_SCHEMA_VERSION},"feed":${serializeFeed(envelope.feed)},"signature":${JSON.stringify(envelope.signature)}}`; diff --git a/scripts/runtime-feed-sign.mjs b/scripts/runtime-feed-sign.mjs index 8134bf1..91f215a 100644 --- a/scripts/runtime-feed-sign.mjs +++ b/scripts/runtime-feed-sign.mjs @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { DEFAULT_KEY_ID, + parseFeedVersion, signRuntimeFeed, } from "./runtime-feed-lib.mjs"; @@ -28,6 +29,7 @@ const keyEnvironment = required( ); const key = required(process.env[keyEnvironment], keyEnvironment); const keyId = required(option(argv, "--key-id", DEFAULT_KEY_ID), "--key-id"); +const feedVersion = parseFeedVersion(required(option(argv, "--feed-version", "2"), "--feed-version")); const feed = JSON.parse(await readFile(input, "utf8")); -await writeFile(out, signRuntimeFeed(feed, key, keyId), "utf8"); +await writeFile(out, signRuntimeFeed(feed, key, keyId, { feedVersion }), "utf8"); console.log(`signed ${input} as ${out}`); diff --git a/scripts/runtime-feed-verify-all.mjs b/scripts/runtime-feed-verify-all.mjs index f0db025..8c7c7ea 100644 --- a/scripts/runtime-feed-verify-all.mjs +++ b/scripts/runtime-feed-verify-all.mjs @@ -2,23 +2,28 @@ import { readdir, readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { verifySignedRuntimeFeed } from "./runtime-feed-lib.mjs"; +import { FEED_VERSIONS, verifySignedRuntimeFeed } from "./runtime-feed-lib.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const root = resolve(repoRoot, "v2/runtime"); -let channels; -try { - channels = await readdir(root, { withFileTypes: true }); -} catch (error) { - if (error.code !== "ENOENT") throw error; - channels = []; -} +// Each feed version is checked against its own runtime allowlist, so a runtime +// that only v3 may list fails here if it reaches v2. A version directory that +// does not exist yet (v3 before its first refresh) is skipped. let checked = 0; -for (const entry of channels) { - if (!entry.isDirectory()) continue; - const path = resolve(root, entry.name, "runtime-release-feed.signed.json"); - verifySignedRuntimeFeed(await readFile(path, "utf8"), undefined, entry.name); - checked += 1; +for (const feedVersion of FEED_VERSIONS) { + const root = resolve(repoRoot, `v${feedVersion}/runtime`); + let channels; + try { + channels = await readdir(root, { withFileTypes: true }); + } catch (error) { + if (error.code !== "ENOENT") throw error; + channels = []; + } + for (const entry of channels) { + if (!entry.isDirectory()) continue; + const path = resolve(root, entry.name, "runtime-release-feed.signed.json"); + verifySignedRuntimeFeed(await readFile(path, "utf8"), undefined, entry.name, { feedVersion }); + checked += 1; + } } console.log(`ok: ${checked} committed runtime release feeds`); diff --git a/scripts/runtime-feed-verify.mjs b/scripts/runtime-feed-verify.mjs index 4a84c27..f79f33f 100644 --- a/scripts/runtime-feed-verify.mjs +++ b/scripts/runtime-feed-verify.mjs @@ -4,12 +4,16 @@ import { fileURLToPath } from "node:url"; import { DEFAULT_PUBLIC_KEY, + parseFeedVersion, verifySignedRuntimeFeed, } from "./runtime-feed-lib.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const path = resolve(repoRoot, process.argv[2] ?? "v2/runtime/stable/runtime-release-feed.signed.json"); const channel = process.argv[3] ?? "stable"; +// Third argument: 2 (default) allows only the runtimes of the frozen v2 feed, +// 3 also allows the runtimes that only v3 may list. +const feedVersion = parseFeedVersion(process.argv[4] ?? "2"); const publicKey = process.env.MSTY_NEXUS_RUNTIME_RELEASE_FEED_PUBLIC_KEY ?? DEFAULT_PUBLIC_KEY; -const envelope = verifySignedRuntimeFeed(await readFile(path, "utf8"), publicKey, channel); -console.log(`ok: ${channel} feed with ${envelope.feed.releases.length} runtime releases`); +const envelope = verifySignedRuntimeFeed(await readFile(path, "utf8"), publicKey, channel, { feedVersion }); +console.log(`ok: v${feedVersion} ${channel} feed with ${envelope.feed.releases.length} runtime releases`); diff --git a/scripts/runtime-feed.test.mjs b/scripts/runtime-feed.test.mjs index e4a2e31..f38b602 100644 --- a/scripts/runtime-feed.test.mjs +++ b/scripts/runtime-feed.test.mjs @@ -72,8 +72,24 @@ function fixtureGitHub() { }; } -function fixtureFetch({ foundationModelsPublished = false } = {}) { +function fixtureFetch({ foundationModelsPublished = false, images = "missing", imagesStatus = 500 } = {}) { return async (url, options = {}) => { + if (url.includes("/images/")) { + if (images === "missing") return new Response(null, { status: 404 }); + if (images === "error") return new Response(null, { status: imagesStatus }); + if (url.endsWith("runtime-manifest.json")) { + const manifest = images === "malformed" + ? { object: "msty.nexus.mlx_runtime", runtimeId: "images", runtimeVersion: "0.1.0" } + : { object: "msty.nexus.images_runtime", runtimeId: "images", runtimeVersion: "0.1.0" }; + return new Response(options.method === "HEAD" ? null : JSON.stringify(manifest)); + } + if (url.endsWith("checksums.txt")) { + return new Response(images === "no-checksum" ? `${sha("f")} other.tgz\n` : `${sha("f")} msty-nexus-images.tgz\n`); + } + if (options.method === "HEAD" && url.endsWith("msty-nexus-images.tgz")) { + return new Response(null, { headers: { "content-length": "41000000" } }); + } + } if (url.includes("/foundationmodels/")) { if (!foundationModelsPublished) return new Response(null, { status: 404 }); if (url.endsWith("runtime-manifest.json")) { @@ -202,3 +218,92 @@ test("lists Apple Foundation Models only on enabled channels once published", as for (const release of tampered.releases) release.channel = "stable"; assert.throws(() => validateRuntimeFeed(tampered, "stable"), /not enabled for the stable channel/); }); + +const build = (options) => buildRuntimeFeed({ + github: fixtureGitHub(), + resolveBundle: bundle, + ...options, +}); +const runtimeIds = (feed) => feed.releases.map((release) => release.runtimeId); + +test("v2 never lists the image runtime, even when it is published", async () => { + for (const channel of ["stable", "dev"]) { + const published = fixtureFetch({ foundationModelsPublished: true, images: "published" }); + const fetchImpl = async (url, options) => { + assert.equal(url.includes("/images/"), false, "v2 must not ask for the image runtime"); + return published(url, options); + }; + const v2 = await build({ fetchImpl, channel }); + assert.equal(runtimeIds(v2).includes("images"), false); + + // Without the image runtime, v3 has the same entries as v2. + const v3 = await build({ fetchImpl: fixtureFetch({ foundationModelsPublished: true }), channel, feedVersion: 3 }); + assert.deepEqual(v3.releases.map(({ publishedAt: _, ...rest }) => rest), v2.releases.map(({ publishedAt: _, ...rest }) => rest)); + } +}); + +test("the v2 validator refuses a feed that lists the image runtime", async () => { + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const seed = privateKey.export({ format: "der", type: "pkcs8" }).subarray(-32).toString("base64"); + const publicRaw = publicKey.export({ format: "der", type: "spki" }).subarray(-32).toString("base64"); + const v3 = await build({ fetchImpl: fixtureFetch({ images: "published" }), feedVersion: 3 }); + + assert.throws(() => validateRuntimeFeed(v3, "stable"), /unsupported runtime images in a v2 feed/); + assert.throws(() => signRuntimeFeed(v3, seed, "test-key"), /unsupported runtime images in a v2 feed/); + const signed = signRuntimeFeed(v3, seed, "test-key", { feedVersion: 3 }); + assert.throws(() => verifySignedRuntimeFeed(signed, publicRaw, "stable"), /unsupported runtime images in a v2 feed/); + assert.equal(verifySignedRuntimeFeed(signed, publicRaw, "stable", { feedVersion: 3 }).feed.releases.length, 4); + + const unknown = structuredClone(v3); + unknown.releases[3].runtimeId = "whisper"; + assert.throws(() => validateRuntimeFeed(unknown, "stable", { feedVersion: 3 }), /unsupported runtime whisper/); + await assert.rejects(build({ fetchImpl: fixtureFetch(), feedVersion: 4 }), /runtime feed version/); +}); + +test("v3 lists the image runtime when its manifest exists", async () => { + const stable = await build({ fetchImpl: fixtureFetch({ images: "published" }), feedVersion: 3 }); + assert.deepEqual(runtimeIds(stable), ["ollama", "llamacpp", "mlx", "images"]); + // The stable channel points at the immutable versioned copy. + const url = "https://nexus-assets.msty.ai/msty-nexus/runtime/images/0.1.0/msty-nexus-images.tgz"; + assert.deepEqual(stable.releases[3], { + runtimeId: "images", + channel: "stable", + runtimeVersion: "0.1.0", + publishedAt: stable.generatedAt, + source: { type: "msty_asset", url }, + artifacts: [{ + os: "darwin", + arch: "arm64", + backend: "metal", + url, + sha256: sha("f"), + sizeBytes: 41000000, + archiveFormat: "tar.gz", + executablePath: "msty-nexus-images_0.1.0_darwin_arm64/msty-nexus-images", + }], + }); + + // The dev channel lists the dev publish, pinned by SHA-256. + const dev = await build({ fetchImpl: fixtureFetch({ images: "published" }), channel: "dev", feedVersion: 3 }); + const images = dev.releases.find((release) => release.runtimeId === "images"); + assert.equal(images.artifacts[0].url, "https://nexus-assets.msty.ai/msty-nexus/runtime/images/latest/dev/msty-nexus-images.tgz"); + assert.equal(images.source.url, images.artifacts[0].url); +}); + +test("v3 leaves the image runtime out only when its manifest is missing", async () => { + const missing = await build({ fetchImpl: fixtureFetch(), feedVersion: 3 }); + assert.deepEqual(runtimeIds(missing), ["ollama", "llamacpp", "mlx"]); + + // Published to the dev prefix only: dev lists it, stable does not. + const published = fixtureFetch({ images: "published" }); + const devOnly = async (url, options) => + url.includes("/images/") && !url.includes("/images/latest/dev/") + ? new Response(null, { status: 404 }) + : published(url, options); + assert.equal(runtimeIds(await build({ fetchImpl: devOnly, channel: "dev", feedVersion: 3 })).includes("images"), true); + assert.equal(runtimeIds(await build({ fetchImpl: devOnly, channel: "stable", feedVersion: 3 })).includes("images"), false); + + await assert.rejects(build({ fetchImpl: fixtureFetch({ images: "malformed" }), feedVersion: 3 }), /Image runtime manifest identity is invalid/); + await assert.rejects(build({ fetchImpl: fixtureFetch({ images: "no-checksum" }), feedVersion: 3 }), /Image runtime checksum file/); + await assert.rejects(build({ fetchImpl: fixtureFetch({ images: "error", imagesStatus: 403 }), feedVersion: 3 }), /HTTP 403/); +});