From f9b4742fa5bc4654567bfc5c68131be0cbfd37f2 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 6 Aug 2026 01:04:39 +0200 Subject: [PATCH 1/4] feat: verify desktop release manifest --- lib/githubApi.js | 29 +++++++++++++ pages/download.js | 40 +++++++++++++++++ tests/desktopRelease.test.js | 48 +++++++++++++++++++++ utils/desktopRelease.js | 84 ++++++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+) diff --git a/lib/githubApi.js b/lib/githubApi.js index b986af31..2628b76f 100644 --- a/lib/githubApi.js +++ b/lib/githubApi.js @@ -34,6 +34,19 @@ async function fetchGitHubJson(pathname) { return response.json() } +async function fetchDesktopReleaseManifest(assetUrl) { + const expectedPrefix = + 'https://github.com/OpenAdaptAI/openadapt-desktop/releases/download/' + if (typeof assetUrl !== 'string' || !assetUrl.startsWith(expectedPrefix)) { + throw new Error('Desktop release manifest URL is outside the release repository') + } + const response = await fetch(assetUrl, { headers: githubHeaders() }) + if (!response.ok) { + throw new Error(`Desktop release manifest returned ${response.status}`) + } + return response.json() +} + /** * Fetch current star/fork social proof and throw on failure. * Server-side cache/fallback policy belongs to the caller. @@ -99,8 +112,10 @@ export async function getOpenIssuesByLabel(repository, label) { export async function getDesktopRelease() { const { DESKTOP_REPO, + DESKTOP_RELEASE_MANIFEST, desktopReleaseLifecycle, selectDesktopRelease, + validateDesktopReleaseManifest, } = await import('../utils/desktopRelease') try { const releases = await fetchGitHubJson( @@ -110,11 +125,25 @@ export async function getDesktopRelease() { if (!selected) { return { release: null, fetchFailed: false } } + const manifestAsset = selected.assets.find( + (asset) => asset.name === DESKTOP_RELEASE_MANIFEST + ) + const manifestData = await fetchDesktopReleaseManifest( + manifestAsset?.browser_download_url + ) + const manifest = validateDesktopReleaseManifest(selected, manifestData) + if (!manifest) { + throw new Error('Desktop release manifest does not match its release') + } return { release: { tag_name: selected.tag_name || null, name: selected.name || null, lifecycle: desktopReleaseLifecycle(selected), + manifest: { + ...manifest, + browser_download_url: manifestAsset.browser_download_url, + }, assets: (selected.assets || []) .filter( (asset) => diff --git a/pages/download.js b/pages/download.js index 2a81929e..1809a6f6 100644 --- a/pages/download.js +++ b/pages/download.js @@ -95,6 +95,7 @@ export default function DownloadPage({ release, fetchFailed }) { [assets, lifecycle] ) const checksumAsset = assets.find((asset) => asset.name === 'SHA256SUMS') + const releaseManifest = release?.manifest || null return (
@@ -328,6 +329,45 @@ export default function DownloadPage({ release, fetchFailed }) { {version ? ` (${version})` : ''}. Choose the one that matches your machine.

+ {releaseManifest && ( +
+

+ Verified release manifest +

+

+ This release binds {releaseManifest.artifactCount}{' '} + installer files and its CycloneDX SBOM to exact + SHA-256 values. Build source:{' '} + + {releaseManifest.sourceCommit.slice(0, 12)} + + . +

+
+ + Release manifest + + + CycloneDX SBOM + +
+
+ )}
{platformDownloads.map(({ platform, asset }) => (
({ @@ -42,6 +43,9 @@ function release(lifecycle, version, publishedAt) { const names = [ ...binaryNames(lifecycle, version), ...(lifecycle === 'Beta' ? betaMetadataNames(version) : []), + ...(lifecycle === 'Beta' + ? ['openadapt-desktop-release-manifest.json'] + : []), 'SHA256SUMS', ] return { @@ -64,6 +68,50 @@ test('accepts a complete Beta set only with checksums and per-platform provenanc assert.equal(isCompleteDesktopRelease(candidate), false) }) +test('requires the release manifest for every complete Beta set', () => { + const candidate = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + candidate.assets = candidate.assets.filter( + (asset) => asset.name !== 'openadapt-desktop-release-manifest.json' + ) + assert.equal(isCompleteDesktopRelease(candidate), false) +}) + +test('validates and binds the fetched release manifest to GitHub assets', () => { + const candidate = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + const sbomName = 'OpenAdapt-Desktop-desktop-v0.7.0.cyclonedx.json' + candidate.assets.push(url(sbomName)) + const manifest = { + schema_version: 1, + lifecycle: 'Beta', + native_tag: candidate.tag_name, + native_version: '0.7.0', + source_commit: 'a'.repeat(40), + artifacts: binaryNames('Beta', '0.7.0').map((name) => ({ + name, + platform: name.includes('-macos-') + ? 'macos' + : name.includes('-windows-') + ? 'windows' + : 'linux', + architecture: name.includes('-arm64-') ? 'arm64' : 'x86_64', + signing: name.includes('-adhoc.') + ? 'adhoc' + : name.includes('-windows-') || name.includes('-linux-') + ? 'unsigned' + : 'adhoc', + sha256: 'b'.repeat(64), + })), + sbom: { name: sbomName, format: 'CycloneDX', sha256: 'c'.repeat(64) }, + } + const validated = validateDesktopReleaseManifest(candidate, manifest) + assert.equal(validated.artifactCount, 6) + assert.equal(validated.sourceCommit, 'a'.repeat(40)) + assert.equal(validated.sbom.name, sbomName) + + manifest.artifacts[1] = { ...manifest.artifacts[0] } + assert.equal(validateDesktopReleaseManifest(candidate, manifest), null) +}) + test('keeps complete legacy Experimental sets discoverable during transition', () => { const candidate = release( 'Experimental', diff --git a/utils/desktopRelease.js b/utils/desktopRelease.js index b8e5d817..ecf73b0c 100644 --- a/utils/desktopRelease.js +++ b/utils/desktopRelease.js @@ -86,6 +86,8 @@ const BETA_PROVENANCE_ASSETS = [ /^-windows-x86_64-(?:unsigned|authenticode)-metadata\.json$/i, /^-linux-x86_64-unsigned-metadata\.json$/i, ] +export const DESKTOP_RELEASE_MANIFEST = + 'openadapt-desktop-release-manifest.json' function hasDownloadUrl(asset) { return Boolean( @@ -128,12 +130,16 @@ function isCompleteDesktopReleaseForLifecycle(release, lifecycle) { const expectedPrefix = `OpenAdapt-Desktop-${RELEASE_ASSET_FAMILIES[lifecycle]}-v${version}-` const assets = release.assets.filter(hasDownloadUrl) const hasChecksums = assets.some((asset) => asset.name === 'SHA256SUMS') + const hasReleaseManifest = assets.some( + (asset) => asset.name === DESKTOP_RELEASE_MANIFEST + ) const required = lifecycle === 'beta' ? [...REQUIRED_ASSETS, ...BETA_PROVENANCE_ASSETS] : REQUIRED_ASSETS return ( hasChecksums && + (lifecycle !== 'beta' || hasReleaseManifest) && required.every((pattern) => assets.some( (asset) => @@ -146,6 +152,84 @@ function isCompleteDesktopReleaseForLifecycle(release, lifecycle) { ) } +export function validateDesktopReleaseManifest(release, manifest) { + if ( + !release || + !manifest || + manifest.schema_version !== 1 || + manifest.lifecycle !== 'Beta' || + manifest.native_tag !== release.tag_name || + manifest.native_version !== release.tag_name?.slice('desktop-v'.length) || + !/^[0-9a-f]{40}$/.test(manifest.source_commit || '') || + !Array.isArray(manifest.artifacts) + ) { + return null + } + const releaseAssets = new Map( + (release.assets || []).filter(hasDownloadUrl).map((asset) => [asset.name, asset]) + ) + const expectedInstallerNames = new Set( + [...releaseAssets.keys()].filter((name) => + DESKTOP_PLATFORMS.some((platform) => platform.match(name)) + ) + ) + const observed = new Set() + for (const artifact of manifest.artifacts) { + const expectedPlatform = artifact?.name?.includes('-macos-') + ? 'macos' + : artifact?.name?.includes('-windows-') + ? 'windows' + : artifact?.name?.includes('-linux-') + ? 'linux' + : null + const expectedArchitecture = artifact?.name?.includes('-arm64-') + ? 'arm64' + : artifact?.name?.includes('-x86_64-') + ? 'x86_64' + : null + const expectedSigning = artifact?.name?.match( + /-(adhoc|developer-id-notarized|unsigned|authenticode)(?:\.|-nsis-setup\.exe$)/ + )?.[1] + if ( + !artifact || + typeof artifact.name !== 'string' || + observed.has(artifact.name) || + !expectedInstallerNames.has(artifact.name) || + !/^[0-9a-f]{64}$/.test(artifact.sha256 || '') || + artifact.platform !== expectedPlatform || + artifact.architecture !== expectedArchitecture || + artifact.signing !== expectedSigning + ) { + return null + } + observed.add(artifact.name) + } + if ( + observed.size !== expectedInstallerNames.size || + [...expectedInstallerNames].some((name) => !observed.has(name)) + ) { + return null + } + const expectedSbomName = `OpenAdapt-Desktop-${release.tag_name}.cyclonedx.json` + if ( + manifest.sbom?.name !== expectedSbomName || + manifest.sbom?.format !== 'CycloneDX' || + !/^[0-9a-f]{64}$/.test(manifest.sbom?.sha256 || '') || + !releaseAssets.has(expectedSbomName) + ) { + return null + } + return { + sourceCommit: manifest.source_commit, + artifactCount: observed.size, + sbom: { + ...manifest.sbom, + browser_download_url: + releaseAssets.get(expectedSbomName).browser_download_url, + }, + } +} + export function desktopReleaseLifecycle(release) { return ( RELEASE_LIFECYCLES.find((lifecycle) => From 158b0525dc9574fb13a12ea6496e737e83b3e5ee Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 6 Aug 2026 01:24:41 +0200 Subject: [PATCH 2/4] fix: bind release manifest to checksums --- lib/githubApi.js | 32 +++++++++++++++++++++++------ tests/desktopRelease.test.js | 40 ++++++++++++++++++++++++++++++++++++ utils/desktopRelease.js | 38 ++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 6 deletions(-) diff --git a/lib/githubApi.js b/lib/githubApi.js index 2628b76f..b15a465a 100644 --- a/lib/githubApi.js +++ b/lib/githubApi.js @@ -34,17 +34,17 @@ async function fetchGitHubJson(pathname) { return response.json() } -async function fetchDesktopReleaseManifest(assetUrl) { +async function fetchDesktopReleaseAssetText(assetUrl) { const expectedPrefix = 'https://github.com/OpenAdaptAI/openadapt-desktop/releases/download/' if (typeof assetUrl !== 'string' || !assetUrl.startsWith(expectedPrefix)) { - throw new Error('Desktop release manifest URL is outside the release repository') + throw new Error('Desktop release asset URL is outside the release repository') } const response = await fetch(assetUrl, { headers: githubHeaders() }) if (!response.ok) { - throw new Error(`Desktop release manifest returned ${response.status}`) + throw new Error(`Desktop release asset returned ${response.status}`) } - return response.json() + return response.text() } /** @@ -115,6 +115,7 @@ export async function getDesktopRelease() { DESKTOP_RELEASE_MANIFEST, desktopReleaseLifecycle, selectDesktopRelease, + validateDesktopReleaseChecksums, validateDesktopReleaseManifest, } = await import('../utils/desktopRelease') try { @@ -128,11 +129,30 @@ export async function getDesktopRelease() { const manifestAsset = selected.assets.find( (asset) => asset.name === DESKTOP_RELEASE_MANIFEST ) - const manifestData = await fetchDesktopReleaseManifest( + const checksumAsset = selected.assets.find( + (asset) => asset.name === 'SHA256SUMS' + ) + const manifestText = await fetchDesktopReleaseAssetText( manifestAsset?.browser_download_url ) + const checksumText = await fetchDesktopReleaseAssetText( + checksumAsset?.browser_download_url + ) + const manifestData = JSON.parse(manifestText) const manifest = validateDesktopReleaseManifest(selected, manifestData) - if (!manifest) { + const { createHash } = await import('node:crypto') + const manifestDigest = createHash('sha256') + .update(manifestText) + .digest('hex') + if ( + !manifest || + !validateDesktopReleaseChecksums( + selected, + manifestData, + checksumText, + manifestDigest + ) + ) { throw new Error('Desktop release manifest does not match its release') } return { diff --git a/tests/desktopRelease.test.js b/tests/desktopRelease.test.js index 422fd8c5..01cc912b 100644 --- a/tests/desktopRelease.test.js +++ b/tests/desktopRelease.test.js @@ -1,5 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import { @@ -8,6 +9,7 @@ import { desktopReleaseLifecycle, isCompleteDesktopRelease, selectDesktopRelease, + validateDesktopReleaseChecksums, validateDesktopReleaseManifest, } from '../utils/desktopRelease.js' @@ -108,6 +110,44 @@ test('validates and binds the fetched release manifest to GitHub assets', () => assert.equal(validated.sourceCommit, 'a'.repeat(40)) assert.equal(validated.sbom.name, sbomName) + const manifestText = JSON.stringify(manifest) + const manifestDigest = createHash('sha256').update(manifestText).digest('hex') + const checksumEntries = [ + ...candidate.assets + .filter((asset) => asset.name !== 'SHA256SUMS') + .map((asset) => { + const described = manifest.artifacts.find( + (artifact) => artifact.name === asset.name + ) + const digest = + asset.name === 'openadapt-desktop-release-manifest.json' + ? manifestDigest + : asset.name === sbomName + ? manifest.sbom.sha256 + : described?.sha256 || 'd'.repeat(64) + return `${digest} ${asset.name}` + }), + ].join('\n') + assert.equal( + validateDesktopReleaseChecksums( + candidate, + manifest, + checksumEntries, + manifestDigest + ), + true + ) + + assert.equal( + validateDesktopReleaseChecksums( + candidate, + manifest, + checksumEntries.replace(manifestDigest, '0'.repeat(64)), + manifestDigest + ), + false + ) + manifest.artifacts[1] = { ...manifest.artifacts[0] } assert.equal(validateDesktopReleaseManifest(candidate, manifest), null) }) diff --git a/utils/desktopRelease.js b/utils/desktopRelease.js index ecf73b0c..9366ac26 100644 --- a/utils/desktopRelease.js +++ b/utils/desktopRelease.js @@ -230,6 +230,44 @@ export function validateDesktopReleaseManifest(release, manifest) { } } +export function validateDesktopReleaseChecksums( + release, + manifest, + checksumText, + manifestDigest +) { + if ( + !/^[0-9a-f]{64}$/.test(manifestDigest || '') || + typeof checksumText !== 'string' + ) { + return false + } + const checksums = new Map() + for (const line of checksumText.split('\n').filter(Boolean)) { + const match = line.match(/^([0-9a-f]{64}) ([^/\\]+)$/) + if (!match || checksums.has(match[2])) return false + checksums.set(match[2], match[1]) + } + const expectedNames = new Set( + (release.assets || []) + .filter(hasDownloadUrl) + .map((asset) => asset.name) + .filter((name) => name !== 'SHA256SUMS') + ) + if ( + checksums.size !== expectedNames.size || + [...expectedNames].some((name) => !checksums.has(name)) || + checksums.get(DESKTOP_RELEASE_MANIFEST) !== manifestDigest + ) { + return false + } + return ( + manifest.artifacts.every( + (artifact) => checksums.get(artifact.name) === artifact.sha256 + ) && checksums.get(manifest.sbom.name) === manifest.sbom.sha256 + ) +} + export function desktopReleaseLifecycle(release) { return ( RELEASE_LIFECYCLES.find((lifecycle) => From 8d9ef2b7206366ae2116c660909dc9aed902fdfa Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 6 Aug 2026 01:37:22 +0200 Subject: [PATCH 3/4] fix: preserve pre-manifest beta fallback --- lib/githubApi.js | 44 +++++++++++++++++++++++++----------- tests/desktopRelease.test.js | 3 +++ utils/desktopRelease.js | 35 ++++++++++++++++++++++------ 3 files changed, 62 insertions(+), 20 deletions(-) diff --git a/lib/githubApi.js b/lib/githubApi.js index b15a465a..9002d252 100644 --- a/lib/githubApi.js +++ b/lib/githubApi.js @@ -114,6 +114,7 @@ export async function getDesktopRelease() { DESKTOP_REPO, DESKTOP_RELEASE_MANIFEST, desktopReleaseLifecycle, + isLegacyBetaDesktopRelease, selectDesktopRelease, validateDesktopReleaseChecksums, validateDesktopReleaseManifest, @@ -126,6 +127,34 @@ export async function getDesktopRelease() { if (!selected) { return { release: null, fetchFailed: false } } + const strictLifecycle = desktopReleaseLifecycle(selected) + const lifecycle = + strictLifecycle || + (isLegacyBetaDesktopRelease(selected) ? 'beta' : null) + const assets = (selected.assets || []) + .filter( + (asset) => + asset && + typeof asset.name === 'string' && + typeof asset.browser_download_url === 'string' + ) + .map((asset) => ({ + name: asset.name, + size: typeof asset.size === 'number' ? asset.size : null, + browser_download_url: asset.browser_download_url, + })) + if (strictLifecycle !== 'beta') { + return { + release: { + tag_name: selected.tag_name || null, + name: selected.name || null, + lifecycle, + manifest: null, + assets, + }, + fetchFailed: false, + } + } const manifestAsset = selected.assets.find( (asset) => asset.name === DESKTOP_RELEASE_MANIFEST ) @@ -159,23 +188,12 @@ export async function getDesktopRelease() { release: { tag_name: selected.tag_name || null, name: selected.name || null, - lifecycle: desktopReleaseLifecycle(selected), + lifecycle, manifest: { ...manifest, browser_download_url: manifestAsset.browser_download_url, }, - assets: (selected.assets || []) - .filter( - (asset) => - asset && - typeof asset.name === 'string' && - typeof asset.browser_download_url === 'string' - ) - .map((asset) => ({ - name: asset.name, - size: typeof asset.size === 'number' ? asset.size : null, - browser_download_url: asset.browser_download_url, - })), + assets, }, fetchFailed: false, } diff --git a/tests/desktopRelease.test.js b/tests/desktopRelease.test.js index 01cc912b..d5002694 100644 --- a/tests/desktopRelease.test.js +++ b/tests/desktopRelease.test.js @@ -8,6 +8,7 @@ import { DESKTOP_PLATFORMS, desktopReleaseLifecycle, isCompleteDesktopRelease, + isLegacyBetaDesktopRelease, selectDesktopRelease, validateDesktopReleaseChecksums, validateDesktopReleaseManifest, @@ -76,6 +77,8 @@ test('requires the release manifest for every complete Beta set', () => { (asset) => asset.name !== 'openadapt-desktop-release-manifest.json' ) assert.equal(isCompleteDesktopRelease(candidate), false) + assert.equal(isLegacyBetaDesktopRelease(candidate), true) + assert.equal(selectDesktopRelease([candidate]), candidate) }) test('validates and binds the fetched release manifest to GitHub assets', () => { diff --git a/utils/desktopRelease.js b/utils/desktopRelease.js index 9366ac26..d6d710a9 100644 --- a/utils/desktopRelease.js +++ b/utils/desktopRelease.js @@ -115,7 +115,11 @@ export function assetForPlatform(assets, platform, preferredLifecycle = null) { })[0] } -function isCompleteDesktopReleaseForLifecycle(release, lifecycle) { +function isCompleteDesktopReleaseForLifecycle( + release, + lifecycle, + requireBetaManifest = true +) { if ( !release || release.draft || @@ -139,7 +143,7 @@ function isCompleteDesktopReleaseForLifecycle(release, lifecycle) { : REQUIRED_ASSETS return ( hasChecksums && - (lifecycle !== 'beta' || hasReleaseManifest) && + (lifecycle !== 'beta' || !requireBetaManifest || hasReleaseManifest) && required.every((pattern) => assets.some( (asset) => @@ -152,6 +156,17 @@ function isCompleteDesktopReleaseForLifecycle(release, lifecycle) { ) } +export function isLegacyBetaDesktopRelease(release) { + return ( + isCompleteDesktopReleaseForLifecycle(release, 'beta', false) && + !release.assets.some( + (asset) => + hasDownloadUrl(asset) && + asset.name === DESKTOP_RELEASE_MANIFEST + ) + ) +} + export function validateDesktopReleaseManifest(release, manifest) { if ( !release || @@ -283,16 +298,22 @@ export function isCompleteDesktopRelease(release) { export function selectDesktopRelease(releases) { if (!Array.isArray(releases)) return null const complete = releases.filter(isCompleteDesktopRelease) - if (complete.length === 0) return null // Once a complete Beta exists, legacy Experimental compatibility releases - // can never become primary again—even if one is published later. This - // makes the lifecycle transition monotonic while retaining the newest - // complete Experimental release as a fallback before Beta is available. + // and pre-manifest Beta releases can never become primary again. This + // makes the manifest transition monotonic while retaining the published + // installer set until the first manifest-backed Beta is available. const beta = complete.filter( (release) => desktopReleaseLifecycle(release) === 'beta' ) - const candidates = beta.length > 0 ? beta : complete + const candidates = + beta.length > 0 + ? beta + : [ + ...complete, + ...releases.filter(isLegacyBetaDesktopRelease), + ] + if (candidates.length === 0) return null // The GitHub endpoint is normally newest-first, but select by publication // metadata so a stable release interleaved in the response or a changed From 119853cb0e6f5e3a28185f1ea62e48a38dc9426b Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 6 Aug 2026 02:00:46 +0200 Subject: [PATCH 4/4] fix: harden desktop release manifest selection --- lib/githubApi.js | 127 ++++++++++------- tests/desktopRelease.test.js | 262 ++++++++++++++++++++++++++++------- utils/desktopRelease.js | 172 ++++++++++++++++------- 3 files changed, 417 insertions(+), 144 deletions(-) diff --git a/lib/githubApi.js b/lib/githubApi.js index 9002d252..715e6928 100644 --- a/lib/githubApi.js +++ b/lib/githubApi.js @@ -47,6 +47,16 @@ async function fetchDesktopReleaseAssetText(assetUrl) { return response.text() } +async function fetchDesktopTagCommit(tag) { + const data = await fetchGitHubJson( + `/repos/OpenAdaptAI/openadapt-desktop/commits/${encodeURIComponent(tag)}` + ) + if (!data || !/^[0-9a-f]{40}$/.test(data.sha || '')) { + throw new Error(`Desktop tag ${tag} did not resolve to one commit`) + } + return data.sha +} + /** * Fetch current star/fork social proof and throw on failure. * Server-side cache/fallback policy belongs to the caller. @@ -109,24 +119,23 @@ export async function getOpenIssuesByLabel(repository, label) { * "open releases on GitHub" fallback), false when GitHub answered but no * complete prerelease exists yet. */ -export async function getDesktopRelease() { +export async function resolveDesktopRelease( + releases, + { + fetchAssetText = fetchDesktopReleaseAssetText, + fetchTagCommit = fetchDesktopTagCommit, + } = {} +) { const { - DESKTOP_REPO, DESKTOP_RELEASE_MANIFEST, + desktopReleaseCandidates, desktopReleaseLifecycle, isLegacyBetaDesktopRelease, - selectDesktopRelease, validateDesktopReleaseChecksums, validateDesktopReleaseManifest, - } = await import('../utils/desktopRelease') - try { - const releases = await fetchGitHubJson( - `/repos/${DESKTOP_REPO}/releases?per_page=20` - ) - const selected = selectDesktopRelease(releases) - if (!selected) { - return { release: null, fetchFailed: false } - } + } = await import('../utils/desktopRelease.js') + const { createHash } = await import('node:crypto') + for (const selected of desktopReleaseCandidates(releases)) { const strictLifecycle = desktopReleaseLifecycle(selected) const lifecycle = strictLifecycle || @@ -155,48 +164,68 @@ export async function getDesktopRelease() { fetchFailed: false, } } - const manifestAsset = selected.assets.find( - (asset) => asset.name === DESKTOP_RELEASE_MANIFEST - ) - const checksumAsset = selected.assets.find( - (asset) => asset.name === 'SHA256SUMS' - ) - const manifestText = await fetchDesktopReleaseAssetText( - manifestAsset?.browser_download_url - ) - const checksumText = await fetchDesktopReleaseAssetText( - checksumAsset?.browser_download_url - ) - const manifestData = JSON.parse(manifestText) - const manifest = validateDesktopReleaseManifest(selected, manifestData) - const { createHash } = await import('node:crypto') - const manifestDigest = createHash('sha256') - .update(manifestText) - .digest('hex') - if ( - !manifest || - !validateDesktopReleaseChecksums( + try { + const manifestAsset = selected.assets.find( + (asset) => asset.name === DESKTOP_RELEASE_MANIFEST + ) + const checksumAsset = selected.assets.find( + (asset) => asset.name === 'SHA256SUMS' + ) + const [manifestText, checksumText, tagSourceCommit] = + await Promise.all([ + fetchAssetText(manifestAsset?.browser_download_url), + fetchAssetText(checksumAsset?.browser_download_url), + fetchTagCommit(selected.tag_name), + ]) + const manifestData = JSON.parse(manifestText) + const manifest = validateDesktopReleaseManifest( selected, manifestData, - checksumText, - manifestDigest + tagSourceCommit ) - ) { - throw new Error('Desktop release manifest does not match its release') - } - return { - release: { - tag_name: selected.tag_name || null, - name: selected.name || null, - lifecycle, - manifest: { - ...manifest, - browser_download_url: manifestAsset.browser_download_url, + const manifestDigest = createHash('sha256') + .update(manifestText) + .digest('hex') + if ( + !manifest || + !validateDesktopReleaseChecksums( + selected, + manifestData, + checksumText, + manifestDigest + ) + ) { + continue + } + return { + release: { + tag_name: selected.tag_name || null, + name: selected.name || null, + lifecycle, + manifest: { + ...manifest, + browser_download_url: + manifestAsset.browser_download_url, + }, + assets, }, - assets, - }, - fetchFailed: false, + fetchFailed: false, + } + } catch (err) { + // A malformed or unavailable candidate does not hide an older + // valid strict Beta or the verified transition fallback. } + } + return { release: null, fetchFailed: false } +} + +export async function getDesktopRelease() { + const { DESKTOP_REPO } = await import('../utils/desktopRelease.js') + try { + const releases = await fetchGitHubJson( + `/repos/${DESKTOP_REPO}/releases?per_page=20` + ) + return await resolveDesktopRelease(releases) } catch (err) { return { release: null, fetchFailed: true } } diff --git a/tests/desktopRelease.test.js b/tests/desktopRelease.test.js index d5002694..a9e4276a 100644 --- a/tests/desktopRelease.test.js +++ b/tests/desktopRelease.test.js @@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs' import { assetForPlatform, + desktopReleaseCandidates, DESKTOP_PLATFORMS, desktopReleaseLifecycle, isCompleteDesktopRelease, @@ -13,11 +14,12 @@ import { validateDesktopReleaseChecksums, validateDesktopReleaseManifest, } from '../utils/desktopRelease.js' +import { resolveDesktopRelease } from '../lib/githubApi.js' -const url = (name) => ({ +const url = (name, tag = 'test') => ({ name, size: 1024, - browser_download_url: `https://github.com/OpenAdaptAI/openadapt-desktop/releases/download/test/${name}`, + browser_download_url: `https://github.com/OpenAdaptAI/openadapt-desktop/releases/download/${tag}/${name}`, }) function binaryNames(lifecycle, version = '0.7.0') { @@ -43,23 +45,81 @@ function betaMetadataNames(version = '0.7.0') { } function release(lifecycle, version, publishedAt) { + const tag = `desktop-v${version}` const names = [ ...binaryNames(lifecycle, version), ...(lifecycle === 'Beta' ? betaMetadataNames(version) : []), + ...(lifecycle === 'Beta' + ? [`OpenAdapt-Desktop-${tag}.cyclonedx.json`] + : []), ...(lifecycle === 'Beta' ? ['openadapt-desktop-release-manifest.json'] : []), 'SHA256SUMS', ] return { - tag_name: `desktop-v${version}`, + tag_name: tag, prerelease: true, draft: false, published_at: publishedAt, - assets: names.map(url), + assets: names.map((name) => url(name, tag)), + } +} + +function manifestFor(candidate, sourceCommit = 'a'.repeat(40)) { + const version = candidate.tag_name.slice('desktop-v'.length) + const sbomName = `OpenAdapt-Desktop-${candidate.tag_name}.cyclonedx.json` + return { + schema_version: 1, + lifecycle: 'Beta', + native_tag: candidate.tag_name, + native_version: version, + source_commit: sourceCommit, + verification: { + github_artifact_attestation: 'required', + installer_smoke: 'install, launch, and uninstall', + sha256_manifest: 'SHA256SUMS', + }, + artifacts: binaryNames('Beta', version).map((name) => ({ + name, + platform: name.includes('-macos-') + ? 'macos' + : name.includes('-windows-') + ? 'windows' + : 'linux', + architecture: name.includes('-arm64-') ? 'arm64' : 'x86_64', + signing: name.includes('-adhoc.') + ? 'adhoc' + : name.includes('-windows-') || name.includes('-linux-') + ? 'unsigned' + : 'adhoc', + sha256: 'b'.repeat(64), + })), + sbom: { name: sbomName, format: 'CycloneDX', sha256: 'c'.repeat(64) }, } } +function checksumsFor(candidate, manifest, manifestText) { + const manifestDigest = createHash('sha256') + .update(manifestText) + .digest('hex') + return candidate.assets + .filter((asset) => asset.name !== 'SHA256SUMS') + .map((asset) => { + const described = manifest.artifacts.find( + (artifact) => artifact.name === asset.name + ) + const digest = + asset.name === 'openadapt-desktop-release-manifest.json' + ? manifestDigest + : asset.name === manifest.sbom.name + ? manifest.sbom.sha256 + : described?.sha256 || 'd'.repeat(64) + return `${digest} ${asset.name}` + }) + .join('\n') +} + test('accepts a complete Beta set only with checksums and per-platform provenance', () => { const candidate = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') assert.equal(isCompleteDesktopRelease(candidate), true) @@ -83,54 +143,19 @@ test('requires the release manifest for every complete Beta set', () => { test('validates and binds the fetched release manifest to GitHub assets', () => { const candidate = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') - const sbomName = 'OpenAdapt-Desktop-desktop-v0.7.0.cyclonedx.json' - candidate.assets.push(url(sbomName)) - const manifest = { - schema_version: 1, - lifecycle: 'Beta', - native_tag: candidate.tag_name, - native_version: '0.7.0', - source_commit: 'a'.repeat(40), - artifacts: binaryNames('Beta', '0.7.0').map((name) => ({ - name, - platform: name.includes('-macos-') - ? 'macos' - : name.includes('-windows-') - ? 'windows' - : 'linux', - architecture: name.includes('-arm64-') ? 'arm64' : 'x86_64', - signing: name.includes('-adhoc.') - ? 'adhoc' - : name.includes('-windows-') || name.includes('-linux-') - ? 'unsigned' - : 'adhoc', - sha256: 'b'.repeat(64), - })), - sbom: { name: sbomName, format: 'CycloneDX', sha256: 'c'.repeat(64) }, - } - const validated = validateDesktopReleaseManifest(candidate, manifest) + const manifest = manifestFor(candidate) + const validated = validateDesktopReleaseManifest( + candidate, + manifest, + 'a'.repeat(40) + ) assert.equal(validated.artifactCount, 6) assert.equal(validated.sourceCommit, 'a'.repeat(40)) - assert.equal(validated.sbom.name, sbomName) + assert.equal(validated.sbom.name, manifest.sbom.name) const manifestText = JSON.stringify(manifest) const manifestDigest = createHash('sha256').update(manifestText).digest('hex') - const checksumEntries = [ - ...candidate.assets - .filter((asset) => asset.name !== 'SHA256SUMS') - .map((asset) => { - const described = manifest.artifacts.find( - (artifact) => artifact.name === asset.name - ) - const digest = - asset.name === 'openadapt-desktop-release-manifest.json' - ? manifestDigest - : asset.name === sbomName - ? manifest.sbom.sha256 - : described?.sha256 || 'd'.repeat(64) - return `${digest} ${asset.name}` - }), - ].join('\n') + const checksumEntries = checksumsFor(candidate, manifest, manifestText) assert.equal( validateDesktopReleaseChecksums( candidate, @@ -152,7 +177,68 @@ test('validates and binds the fetched release manifest to GitHub assets', () => ) manifest.artifacts[1] = { ...manifest.artifacts[0] } - assert.equal(validateDesktopReleaseManifest(candidate, manifest), null) + assert.equal( + validateDesktopReleaseManifest(candidate, manifest, 'a'.repeat(40)), + null + ) +}) + +test('rejects duplicate, extra, and combined-signing Beta asset sets', () => { + const duplicate = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + duplicate.assets.push({ ...duplicate.assets[0] }) + assert.equal(isCompleteDesktopRelease(duplicate), false) + + const extra = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + extra.assets.push(url('bogus-checksum-asset.bin', extra.tag_name)) + const extraManifest = manifestFor(extra) + const extraManifestText = JSON.stringify(extraManifest) + const extraDigest = createHash('sha256') + .update(extraManifestText) + .digest('hex') + assert.equal( + validateDesktopReleaseManifest(extra, extraManifest, 'a'.repeat(40)), + null + ) + assert.equal( + validateDesktopReleaseChecksums( + extra, + extraManifest, + checksumsFor(extra, extraManifest, extraManifestText), + extraDigest + ), + false + ) + + const combined = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + const prefix = 'OpenAdapt-Desktop-Beta-v0.7.0-windows-x86_64' + combined.assets.push( + url(`${prefix}-authenticode.msi`, combined.tag_name), + url(`${prefix}-authenticode-nsis-setup.exe`, combined.tag_name), + url(`${prefix}-authenticode-metadata.json`, combined.tag_name) + ) + assert.equal(isCompleteDesktopRelease(combined), false) + assert.equal( + validateDesktopReleaseManifest( + combined, + manifestFor(combined), + 'a'.repeat(40) + ), + null + ) +}) + +test('requires the manifest source commit and verification contract to match', () => { + const candidate = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + const manifest = manifestFor(candidate) + assert.equal( + validateDesktopReleaseManifest(candidate, manifest, 'f'.repeat(40)), + null + ) + manifest.verification.installer_smoke = 'started once' + assert.equal( + validateDesktopReleaseManifest(candidate, manifest, 'a'.repeat(40)), + null + ) }) test('keeps complete legacy Experimental sets discoverable during transition', () => { @@ -246,6 +332,86 @@ test('a complete Beta remains primary when a legacy release is newer', () => { assert.equal(selectDesktopRelease([laterLegacy, beta]), beta) }) +test('a pre-manifest Beta remains primary when Experimental is newer', () => { + const beta = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + beta.assets = beta.assets.filter( + (asset) => asset.name !== 'openadapt-desktop-release-manifest.json' + ) + const experimental = release( + 'Experimental', + '0.8.0', + '2026-07-22T12:00:00Z' + ) + assert.deepEqual(desktopReleaseCandidates([experimental, beta]), [ + beta, + experimental, + ]) + assert.equal(selectDesktopRelease([experimental, beta]), beta) +}) + +test('a malformed newest strict Beta falls back to the next valid Beta', async () => { + const current = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + const malformed = release('Beta', '0.8.0', '2026-07-22T12:00:00Z') + const manifests = new Map( + [current, malformed].map((candidate) => { + const manifest = manifestFor(candidate) + if (candidate === malformed) { + manifest.native_tag = 'desktop-v9.9.9' + } + const text = JSON.stringify(manifest) + return [ + candidate.tag_name, + { + manifest, + text, + checksums: checksumsFor(candidate, manifest, text), + }, + ] + }) + ) + const fetchAssetText = async (assetUrl) => { + const tag = [...manifests.keys()].find((value) => + assetUrl.includes(`/download/${value}/`) + ) + if (assetUrl.endsWith('/openadapt-desktop-release-manifest.json')) { + return manifests.get(tag).text + } + if (assetUrl.endsWith('/SHA256SUMS')) { + return manifests.get(tag).checksums + } + throw new Error('unexpected asset') + } + const result = await resolveDesktopRelease([malformed, current], { + fetchAssetText, + fetchTagCommit: async () => 'a'.repeat(40), + }) + assert.equal(result.fetchFailed, false) + assert.equal(result.release.tag_name, current.tag_name) + assert.equal(result.release.manifest.sourceCommit, 'a'.repeat(40)) +}) + +test('a malformed strict Beta preserves the current pre-manifest Beta', async () => { + const current = release('Beta', '0.7.0', '2026-07-21T12:00:00Z') + current.assets = current.assets.filter( + (asset) => asset.name !== 'openadapt-desktop-release-manifest.json' + ) + const malformed = release('Beta', '0.8.0', '2026-07-22T12:00:00Z') + const manifest = manifestFor(malformed) + manifest.source_commit = 'f'.repeat(40) + const manifestText = JSON.stringify(manifest) + const result = await resolveDesktopRelease([malformed, current], { + fetchAssetText: async (assetUrl) => + assetUrl.endsWith('/SHA256SUMS') + ? checksumsFor(malformed, manifest, manifestText) + : manifestText, + fetchTagCommit: async () => 'a'.repeat(40), + }) + assert.equal(result.fetchFailed, false) + assert.equal(result.release.tag_name, current.tag_name) + assert.equal(result.release.lifecycle, 'beta') + assert.equal(result.release.manifest, null) +}) + test('platform selection stays in the chosen release lifecycle', () => { const assets = [ ...release('Experimental', '0.6.2', '2026-07-20T12:00:00Z').assets, diff --git a/utils/desktopRelease.js b/utils/desktopRelease.js index d6d710a9..27d5359b 100644 --- a/utils/desktopRelease.js +++ b/utils/desktopRelease.js @@ -80,15 +80,26 @@ const REQUIRED_ASSETS = [ // build. Existing Experimental releases predate that contract and remain // discoverable during the transition, but a new Beta set is never accepted // without all four metadata records and the checksum manifest. -const BETA_PROVENANCE_ASSETS = [ - /^-macos-arm64-(?:adhoc|developer-id-notarized)-metadata\.json$/i, - /^-macos-x86_64-(?:adhoc|developer-id-notarized)-metadata\.json$/i, - /^-windows-x86_64-(?:unsigned|authenticode)-metadata\.json$/i, - /^-linux-x86_64-unsigned-metadata\.json$/i, -] export const DESKTOP_RELEASE_MANIFEST = 'openadapt-desktop-release-manifest.json' +const RELEASE_VERIFICATION = { + sha256_manifest: 'SHA256SUMS', + github_artifact_attestation: 'required', + installer_smoke: 'install, launch, and uninstall', +} + +function hasExactVerification(value) { + return ( + value && + Object.keys(value).sort().join(',') === + 'github_artifact_attestation,installer_smoke,sha256_manifest' && + Object.entries(RELEASE_VERIFICATION).every( + ([key, expected]) => value[key] === expected + ) + ) +} + function hasDownloadUrl(asset) { return Boolean( asset && @@ -98,6 +109,65 @@ function hasDownloadUrl(asset) { ) } +function exactBetaAssetNames(release, requireManifest) { + if ( + !release || + release.draft || + release.prerelease !== true || + !DESKTOP_TAG.test(release.tag_name || '') || + !Array.isArray(release.assets) || + !release.assets.every(hasDownloadUrl) + ) { + return null + } + const names = release.assets.map((asset) => asset.name) + const observed = new Set(names) + if (observed.size !== names.length) return null + + const version = release.tag_name.slice('desktop-v'.length) + const prefix = `OpenAdapt-Desktop-Beta-v${version}` + const oneMode = (modes, nameForMode) => { + const matches = modes.filter((mode) => observed.has(nameForMode(mode))) + return matches.length === 1 ? matches[0] : null + } + const macosArmMode = oneMode( + ['adhoc', 'developer-id-notarized'], + (mode) => `${prefix}-macos-arm64-${mode}.dmg` + ) + const macosX64Mode = oneMode( + ['adhoc', 'developer-id-notarized'], + (mode) => `${prefix}-macos-x86_64-${mode}.dmg` + ) + const windowsMode = oneMode( + ['unsigned', 'authenticode'], + (mode) => `${prefix}-windows-x86_64-${mode}.msi` + ) + if (!macosArmMode || !macosX64Mode || !windowsMode) return null + + const expected = new Set([ + `${prefix}-macos-arm64-${macosArmMode}.dmg`, + `${prefix}-macos-arm64-${macosArmMode}-metadata.json`, + `${prefix}-macos-x86_64-${macosX64Mode}.dmg`, + `${prefix}-macos-x86_64-${macosX64Mode}-metadata.json`, + `${prefix}-windows-x86_64-${windowsMode}.msi`, + `${prefix}-windows-x86_64-${windowsMode}-nsis-setup.exe`, + `${prefix}-windows-x86_64-${windowsMode}-metadata.json`, + `${prefix}-linux-x86_64-unsigned.AppImage`, + `${prefix}-linux-x86_64-unsigned.deb`, + `${prefix}-linux-x86_64-unsigned-metadata.json`, + `OpenAdapt-Desktop-${release.tag_name}.cyclonedx.json`, + 'SHA256SUMS', + ...(requireManifest ? [DESKTOP_RELEASE_MANIFEST] : []), + ]) + if ( + expected.size !== observed.size || + [...expected].some((name) => !observed.has(name)) + ) { + return null + } + return expected +} + export function assetForPlatform(assets, platform, preferredLifecycle = null) { return assets .filter(hasDownloadUrl) @@ -130,20 +200,17 @@ function isCompleteDesktopReleaseForLifecycle( return false } + if (lifecycle === 'beta') { + return exactBetaAssetNames(release, requireBetaManifest) !== null + } + const version = release.tag_name.slice('desktop-v'.length) const expectedPrefix = `OpenAdapt-Desktop-${RELEASE_ASSET_FAMILIES[lifecycle]}-v${version}-` const assets = release.assets.filter(hasDownloadUrl) const hasChecksums = assets.some((asset) => asset.name === 'SHA256SUMS') - const hasReleaseManifest = assets.some( - (asset) => asset.name === DESKTOP_RELEASE_MANIFEST - ) - const required = - lifecycle === 'beta' - ? [...REQUIRED_ASSETS, ...BETA_PROVENANCE_ASSETS] - : REQUIRED_ASSETS + const required = REQUIRED_ASSETS return ( hasChecksums && - (lifecycle !== 'beta' || !requireBetaManifest || hasReleaseManifest) && required.every((pattern) => assets.some( (asset) => @@ -167,7 +234,11 @@ export function isLegacyBetaDesktopRelease(release) { ) } -export function validateDesktopReleaseManifest(release, manifest) { +export function validateDesktopReleaseManifest( + release, + manifest, + tagSourceCommit +) { if ( !release || !manifest || @@ -175,11 +246,14 @@ export function validateDesktopReleaseManifest(release, manifest) { manifest.lifecycle !== 'Beta' || manifest.native_tag !== release.tag_name || manifest.native_version !== release.tag_name?.slice('desktop-v'.length) || - !/^[0-9a-f]{40}$/.test(manifest.source_commit || '') || + !/^[0-9a-f]{40}$/.test(tagSourceCommit || '') || + manifest.source_commit !== tagSourceCommit || + !hasExactVerification(manifest.verification) || !Array.isArray(manifest.artifacts) ) { return null } + if (!exactBetaAssetNames(release, true)) return null const releaseAssets = new Map( (release.assets || []).filter(hasDownloadUrl).map((asset) => [asset.name, asset]) ) @@ -227,6 +301,8 @@ export function validateDesktopReleaseManifest(release, manifest) { } const expectedSbomName = `OpenAdapt-Desktop-${release.tag_name}.cyclonedx.json` if ( + !manifest.sbom || + Object.keys(manifest.sbom).sort().join(',') !== 'format,name,sha256' || manifest.sbom?.name !== expectedSbomName || manifest.sbom?.format !== 'CycloneDX' || !/^[0-9a-f]{64}$/.test(manifest.sbom?.sha256 || '') || @@ -252,6 +328,7 @@ export function validateDesktopReleaseChecksums( manifestDigest ) { if ( + !exactBetaAssetNames(release, true) || !/^[0-9a-f]{64}$/.test(manifestDigest || '') || typeof checksumText !== 'string' ) { @@ -296,46 +373,47 @@ export function isCompleteDesktopRelease(release) { } export function selectDesktopRelease(releases) { - if (!Array.isArray(releases)) return null - const complete = releases.filter(isCompleteDesktopRelease) - - // Once a complete Beta exists, legacy Experimental compatibility releases - // and pre-manifest Beta releases can never become primary again. This - // makes the manifest transition monotonic while retaining the published - // installer set until the first manifest-backed Beta is available. - const beta = complete.filter( - (release) => desktopReleaseLifecycle(release) === 'beta' - ) - const candidates = - beta.length > 0 - ? beta - : [ - ...complete, - ...releases.filter(isLegacyBetaDesktopRelease), - ] - if (candidates.length === 0) return null + return desktopReleaseCandidates(releases)[0] || null +} - // The GitHub endpoint is normally newest-first, but select by publication - // metadata so a stable release interleaved in the response or a changed - // API ordering cannot make the download page advertise an older desktop - // prerelease. - return candidates.reduce((latest, candidate) => { +function newestFirst(candidates) { + return [...candidates].sort((left, right) => { const latestTime = Date.parse( - latest.published_at || latest.created_at || '' + left.published_at || left.created_at || '' ) const candidateTime = Date.parse( - candidate.published_at || candidate.created_at || '' + right.published_at || right.created_at || '' ) - if ( - Number.isFinite(candidateTime) && - (!Number.isFinite(latestTime) || candidateTime > latestTime) - ) { - return candidate + if (!Number.isFinite(latestTime) && !Number.isFinite(candidateTime)) { + return 0 } - return latest + if (!Number.isFinite(latestTime)) return 1 + if (!Number.isFinite(candidateTime)) return -1 + return candidateTime - latestTime }) } +export function desktopReleaseCandidates(releases) { + if (!Array.isArray(releases)) return [] + const strictBeta = releases.filter( + (release) => desktopReleaseLifecycle(release) === 'beta' + ) + const legacyBeta = releases.filter(isLegacyBetaDesktopRelease) + const experimental = releases.filter( + (release) => desktopReleaseLifecycle(release) === 'experimental' + ) + + // Lifecycle priority is independent of publication time. A legacy Beta is + // therefore never replaced by a newer Experimental release. Every strict + // Beta remains ahead of both transition fallbacks, so the server can try + // the next strict candidate if the newest manifest content is malformed. + return [ + ...newestFirst(strictBeta), + ...newestFirst(legacyBeta), + ...newestFirst(experimental), + ] +} + // Compatibility exports for consumers that still use the old names. Their // behavior intentionally includes both Beta and legacy Experimental releases, // avoiding a flag day while callers migrate to the lifecycle-neutral names.