From c90ea14d1273e2fbf39f977e94f41a348711068c Mon Sep 17 00:00:00 2001 From: Ashok Gelal Date: Wed, 11 Nov 2026 08:47:38 -0800 Subject: [PATCH] feat: publish a v3 runtime feed that can list the image runtime Runtime 0.9.1 and earlier reject the whole v2 feed for a runtime they do not know, so v2 stays frozen. v3 has the same entries plus the image runtime once its manifest is published. The v2 generator and verifier refuse any other runtime. --- .github/workflows/refresh-runtime-feed.yml | 51 ++++++---- README.md | 14 +++ scripts/runtime-feed-generate.mjs | 8 +- scripts/runtime-feed-lib.mjs | 100 +++++++++++++++++-- scripts/runtime-feed-sign.mjs | 4 +- scripts/runtime-feed-verify-all.mjs | 33 ++++--- scripts/runtime-feed-verify.mjs | 8 +- scripts/runtime-feed.test.mjs | 107 ++++++++++++++++++++- 8 files changed, 276 insertions(+), 49 deletions(-) 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/); +});