| 1 | /** The helper has its own release lifecycle, independent of the Codewhale CLI. */ |
| 2 | export const COMPUTER_USE_REPO = "https://github.com/Hmbown/codewhale-cu-plugin"; |
| 3 | const API = "https://api.github.com/repos/Hmbown/codewhale-cu-plugin"; |
| 4 | /** Hosts GitHub redirects release downloads through; anything else is refused. */ |
| 5 | const RELEASE_HOSTS = new Set(["github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"]); |
| 6 | |
| 7 | type RecordValue = Record<string, unknown>; |
| 8 | function record(value: unknown): RecordValue { |
| 9 | return value !== null && typeof value === "object" && !Array.isArray(value) |
| 10 | ? value as RecordValue : {}; |
| 11 | } |
| 12 | |
| 13 | export type ComputerUseRelease = { |
| 14 | status: "ready"; |
| 15 | version: string; |
| 16 | url: string; |
| 17 | downloadUrl: string; |
| 18 | receiptUrl: string; |
| 19 | sha256: string; |
| 20 | size: number; |
| 21 | /** Which independent evidence confirmed the archive: GitHub's asset digest via the API, or the packager's receipt via the release web endpoint. */ |
| 22 | verification: "github-digest" | "receipt"; |
| 23 | /** The notarized drag-to-Applications disk image, when the release carries one that its receipt qualifies. */ |
| 24 | dmg?: { downloadUrl: string; size: number; sha256: string }; |
| 25 | } | { status: "pending" | "unavailable" }; |
| 26 | |
| 27 | const IMAGE_LIMIT = 256 * 1024 * 1024; |
| 28 | const imageName = (version: string) => `Codewhale-Computer-Use-${version}-macos-universal.dmg`; |
| 29 | /** The receipt's disk-image entry, or null when absent or malformed. Absence is allowed; a malformed entry withholds the image only. */ |
| 30 | function receiptImage(receipt: RecordValue, version: string) { |
| 31 | if (!("dmg" in receipt)) return null; |
| 32 | const image = record(receipt.dmg); |
| 33 | return image.archive === imageName(version) && image.notarized === true |
| 34 | && typeof image.sha256 === "string" && /^[0-9a-f]{64}$/.test(image.sha256) |
| 35 | && Number.isSafeInteger(image.size) && (image.size as number) > 0 && (image.size as number) <= IMAGE_LIMIT |
| 36 | ? { sha256: image.sha256, size: image.size as number } : null; |
| 37 | } |
| 38 | |
| 39 | function releaseAssets(value: unknown) { |
| 40 | const release = record(value); |
| 41 | const version = typeof release.tag_name === "string" |
| 42 | ? /^v(\d+\.\d+\.\d+)$/.exec(release.tag_name)?.[1] : undefined; |
| 43 | if (!version || release.draft !== false || release.prerelease !== false |
| 44 | || typeof release.published_at !== "string" || !Number.isFinite(Date.parse(release.published_at)) |
| 45 | || release.html_url !== `${COMPUTER_USE_REPO}/releases/tag/v${version}` |
| 46 | || !Array.isArray(release.assets)) return null; |
| 47 | const assets = release.assets.map(record); |
| 48 | const archive = `Codewhale-Computer-Use-${version}-macos-universal.zip`; |
| 49 | const asset = (name: string) => { |
| 50 | const matches = assets.filter(a => a.name === name); |
| 51 | const a = matches[0]; |
| 52 | return matches.length === 1 && a.state === "uploaded" |
| 53 | && a.browser_download_url === `${COMPUTER_USE_REPO}/releases/download/v${version}/${name}` ? a : null; |
| 54 | }; |
| 55 | const zip = asset(archive), receipt = asset("release.json"); |
| 56 | if (!zip || !receipt || !Number.isSafeInteger(zip.size) |
| 57 | || (zip.size as number) <= 0 || (zip.size as number) > 256 * 1024 * 1024 |
| 58 | || !Number.isSafeInteger(receipt.size) || (receipt.size as number) <= 0 |
| 59 | || (receipt.size as number) > 16 * 1024 |
| 60 | || typeof zip.digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(zip.digest)) return null; |
| 61 | const image = asset(imageName(version)); |
| 62 | const dmg = image && Number.isSafeInteger(image.size) && (image.size as number) > 0 && (image.size as number) <= IMAGE_LIMIT |
| 63 | && typeof image.digest === "string" && /^sha256:[0-9a-f]{64}$/.test(image.digest) ? image : null; |
| 64 | return { version, archive, zip, receipt, dmg }; |
| 65 | } |
| 66 | |
| 67 | /** A tag alone is not a download. Require the packager's qualification receipt |
| 68 | * to match the uploaded archive and GitHub's independently computed digest. */ |
| 69 | export function qualifiedComputerUseRelease(release: unknown, value: unknown): ComputerUseRelease { |
| 70 | const assets = releaseAssets(release), receipt = record(value); |
| 71 | if (!assets) return { status: "pending" }; |
| 72 | const { version, archive, zip } = assets; |
| 73 | const sha256 = (zip.digest as string).slice(7); |
| 74 | if (receipt.version !== version || receipt.archive !== archive || receipt.platform !== "macos" |
| 75 | || receipt.arch !== "universal" || receipt.notarized !== true |
| 76 | || receipt.sha256 !== sha256 || receipt.size !== zip.size) return { status: "pending" }; |
| 77 | // The image is offered only when the receipt and GitHub's digest agree on it; otherwise the archive alone is offered. |
| 78 | const image = receiptImage(receipt, version); |
| 79 | const dmg = assets.dmg && image && (assets.dmg.digest as string).slice(7) === image.sha256 && assets.dmg.size === image.size |
| 80 | ? { downloadUrl: assets.dmg.browser_download_url as string, size: image.size, sha256: image.sha256 } : undefined; |
| 81 | return { |
| 82 | status: "ready", version, sha256, size: zip.size as number, |
| 83 | url: `${COMPUTER_USE_REPO}/releases/tag/v${version}`, |
| 84 | downloadUrl: zip.browser_download_url as string, |
| 85 | receiptUrl: assets.receipt.browser_download_url as string, |
| 86 | verification: "github-digest", |
| 87 | ...(dmg ? { dmg } : {}), |
| 88 | }; |
| 89 | } |
| 90 | |
| 91 | async function boundedJson(response: Response, limit: number): Promise<unknown> { |
| 92 | if (!response.body) throw new Error("Missing release response"); |
| 93 | const reader = response.body.getReader(); |
| 94 | const decoder = new TextDecoder(); |
| 95 | let length = 0, text = ""; |
| 96 | try { |
| 97 | for (;;) { |
| 98 | const { done, value } = await reader.read(); |
| 99 | if (done) break; |
| 100 | length += value.byteLength; |
| 101 | if (length > limit) throw new Error("Release response exceeds size limit"); |
| 102 | text += decoder.decode(value, { stream: true }); |
| 103 | } |
| 104 | return JSON.parse(text + decoder.decode()); |
| 105 | } finally { await reader.cancel(); reader.releaseLock(); } |
| 106 | } |
| 107 | |
| 108 | const WEB_HEADERS = { "User-Agent": "codewhale-web" }; |
| 109 | |
| 110 | /** GET a release web endpoint, following at most three 302s and only onto GitHub's release hosts over https. */ |
| 111 | async function fetchReleaseWeb(url: string): Promise<Response> { |
| 112 | for (let hops = 0; ; hops++) { |
| 113 | const response = await fetch(url, { redirect: "manual", headers: WEB_HEADERS, signal: AbortSignal.timeout(5000) }); |
| 114 | if (![301, 302, 307, 308].includes(response.status)) return response; |
| 115 | await response.body?.cancel(); |
| 116 | const location = response.headers.get("location"); |
| 117 | if (!location) throw new Error("Release redirect without a location"); |
| 118 | const target = new URL(location, url); |
| 119 | if (hops >= 3 || target.protocol !== "https:" || !RELEASE_HOSTS.has(target.hostname)) { |
| 120 | throw new Error(`Release redirect refused: ${target.hostname}`); |
| 121 | } |
| 122 | url = target.href; |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | /** Resolve the download without the GitHub API: read the latest receipt from the |
| 127 | * release web endpoint, then confirm GitHub serves the archive the receipt names. */ |
| 128 | async function receiptQualifiedRelease(): Promise<ComputerUseRelease> { |
| 129 | try { |
| 130 | const response = await fetchReleaseWeb(`${COMPUTER_USE_REPO}/releases/latest/download/release.json`); |
| 131 | if (response.status === 404) return { status: "pending" }; |
| 132 | if (!response.ok) return { status: "unavailable" }; |
| 133 | const receipt = record(await boundedJson(response, 16 * 1024)); |
| 134 | const version = typeof receipt.version === "string" && /^\d+\.\d+\.\d+$/.test(receipt.version) ? receipt.version : null; |
| 135 | const archive = `Codewhale-Computer-Use-${version}-macos-universal.zip`; |
| 136 | if (!version || receipt.platform !== "macos" || receipt.arch !== "universal" || receipt.notarized !== true |
| 137 | || receipt.archive !== archive || typeof receipt.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(receipt.sha256) |
| 138 | || !Number.isSafeInteger(receipt.size) || (receipt.size as number) <= 0 |
| 139 | || (receipt.size as number) > 256 * 1024 * 1024) return { status: "unavailable" }; |
| 140 | const served = async (name: string) => { |
| 141 | const head = await fetch(`${COMPUTER_USE_REPO}/releases/download/v${version}/${name}`, { method: "HEAD", redirect: "manual", headers: WEB_HEADERS, signal: AbortSignal.timeout(5000) }); |
| 142 | await head.body?.cancel(); |
| 143 | return head.status === 302 || head.status === 200; |
| 144 | }; |
| 145 | const downloadUrl = `${COMPUTER_USE_REPO}/releases/download/v${version}/${archive}`; |
| 146 | if (!(await served(archive))) return { status: "unavailable" }; |
| 147 | const image = receiptImage(receipt, version); |
| 148 | const dmg = image && (await served(imageName(version))) |
| 149 | ? { downloadUrl: `${COMPUTER_USE_REPO}/releases/download/v${version}/${imageName(version)}`, ...image } : undefined; |
| 150 | return { |
| 151 | status: "ready", version, sha256: receipt.sha256, size: receipt.size as number, |
| 152 | url: `${COMPUTER_USE_REPO}/releases/tag/v${version}`, downloadUrl, |
| 153 | receiptUrl: `${COMPUTER_USE_REPO}/releases/download/v${version}/release.json`, |
| 154 | verification: "receipt", |
| 155 | ...(dmg ? { dmg } : {}), |
| 156 | }; |
| 157 | } catch (error) { |
| 158 | console.error("computer-use release fallback failed", error instanceof Error ? error.message : String(error)); |
| 159 | return { status: "unavailable" }; |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | export async function getComputerUseRelease(token?: string): Promise<ComputerUseRelease> { |
| 164 | // Match the site's offline build policy; ISR resolves availability after deployment. |
| 165 | if (process.env.NEXT_PHASE === "phase-production-build") return { status: "unavailable" }; |
| 166 | try { |
| 167 | const options = { next: { revalidate: 300 }, signal: AbortSignal.timeout(5000) }; |
| 168 | const response = await fetch(`${API}/releases/latest`, { |
| 169 | ...options, |
| 170 | headers: { |
| 171 | Accept: "application/vnd.github+json", "User-Agent": "codewhale-web", "X-GitHub-Api-Version": "2022-11-28", |
| 172 | ...(token ? { Authorization: `Bearer ${token}` } : {}), |
| 173 | }, |
| 174 | }); |
| 175 | if (response.status === 404) return { status: "pending" }; |
| 176 | if (!response.ok) { |
| 177 | console.error("computer-use release check", response.status); |
| 178 | return receiptQualifiedRelease(); |
| 179 | } |
| 180 | const release = await boundedJson(response, 128 * 1024); |
| 181 | const assets = releaseAssets(release); |
| 182 | if (!assets) return { status: "pending" }; |
| 183 | const receipt = await fetch(assets.receipt.browser_download_url as string, { |
| 184 | next: { revalidate: 300 }, signal: AbortSignal.timeout(5000), |
| 185 | }); |
| 186 | if (!receipt.ok) return { status: "unavailable" }; |
| 187 | return qualifiedComputerUseRelease(release, await boundedJson(receipt, 16 * 1024)); |
| 188 | } catch (error) { |
| 189 | console.error("computer-use release check failed", error instanceof Error ? error.message : String(error)); |
| 190 | return receiptQualifiedRelease(); |
| 191 | } |
| 192 | } |
| 193 |