返回 CodeWhale
computer-use-release.ts
根目录 / web / lib / computer-use-release.ts
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
193 lines TYPESCRIPT