Merge branch 'fix/cu-download-page-20260913' into feat/pet-mode-20260913

This commit is contained in:
Hunter B committed 2026-09-13 15:25:46 -07:00
commit a0b81f619b
5 files changed
+170 -29

No files matched your search

+2 -1
View File
@@ -4,6 +4,7 @@ import type { LocalizedText } from "@/lib/content/vocabulary";
import { pickText } from "@/lib/i18n/dictionaries";
import { buildPageMetadata } from "@/lib/page-meta";
import { COMPUTER_USE_REPO, getComputerUseRelease } from "@/lib/computer-use-release";
import { getEnv } from "@/lib/kv";
export const revalidate = 300;
@@ -16,7 +17,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
export default async function ComputerUsePage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = (copy: LocalizedText) => pickText(copy, locale);
const release = await getComputerUseRelease();
const release = await getComputerUseRelease((await getEnv()).GITHUB_TOKEN);
return (
<div>
<section className="hero">
+13 -7
View File
@@ -7,15 +7,21 @@ export const alt = OG_ALT;
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
const mark = readFile(join(process.cwd(), "public/brand/mark.svg")).then((svg) =>
svg.toString().replace("currentColor", "#ffffff"),
);
const wordmark = readFile(join(process.cwd(), "public/brand/wordmark-inverted.svg")).then(
(svg) => svg.toString(),
);
// Read the brand SVGs when the image is generated, never at import time. This
// route is prerendered at build on Node, but its module is still evaluated
// inside the Cloudflare Worker whenever a page regenerates, and the Workers
// Node shim throws on fs.readFile. A top-level read here made every ISR
// regeneration on the site fail with a 500 and serve its build snapshot forever.
async function brandSvgs(): Promise<[string, string]> {
const [mark, wordmark] = await Promise.all([
readFile(join(process.cwd(), "public/brand/mark.svg")),
readFile(join(process.cwd(), "public/brand/wordmark-inverted.svg")),
]);
return [mark.toString().replace("currentColor", "#ffffff"), wordmark.toString()];
}
export default async function OpengraphImage() {
const [markSvg, wordmarkSvg] = await Promise.all([mark, wordmark]);
const [markSvg, wordmarkSvg] = await brandSvgs();
const markDataUrl = `data:image/svg+xml;base64,${Buffer.from(markSvg).toString("base64")}`;
const wordmarkDataUrl = `data:image/svg+xml;base64,${Buffer.from(wordmarkSvg).toString("base64")}`;
+88 -16
View File
@@ -11,12 +11,27 @@ const fixture = () => ({ tag_name: "v0.3.0", draft: false, prerelease: false,
const receipt = () => ({ version: "0.3.0", platform: "macos", arch: "universal", archive,
sha256, size: 80000000, notarized: true });
afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
const API_LATEST = "https://api.github.com/repos/Hmbown/codewhale-cu-plugin/releases/latest";
const WEB_RECEIPT = `${COMPUTER_USE_REPO}/releases/latest/download/release.json`;
const OBJECT_URL = "https://objects.githubusercontent.com/github-production-release-asset/1/release.json?X-Amz-Signature=x";
const status = (code: number) => new Response(null, { status: code });
const redirect = (location: string) => new Response(null, { status: 302, headers: { location } });
const stub = (...responses: unknown[]) => {
const fetcher = vi.fn();
for (const r of responses) {
if (r instanceof Error) fetcher.mockRejectedValueOnce(r); else fetcher.mockResolvedValueOnce(r);
}
vi.stubGlobal("fetch", fetcher);
return fetcher;
};
afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks(); });
describe("Computer Use download qualification", () => {
it("offers the exact archive when the release, receipt and GitHub digest agree", () => {
expect(qualifiedComputerUseRelease(fixture(), receipt())).toMatchObject({
status: "ready", version: "0.3.0", sha256, downloadUrl: asset(archive, 80000000).browser_download_url,
verification: "github-digest",
});
});
it.each([
@@ -36,29 +51,86 @@ describe("Computer Use download qualification", () => {
}
});
it("loads only the canonical release and its matching receipt", async () => {
const fetcher = vi.fn().mockResolvedValueOnce(Response.json(fixture())).mockResolvedValueOnce(Response.json(receipt()));
vi.stubGlobal("fetch", fetcher);
expect((await getComputerUseRelease()).status).toBe("ready");
expect(fetcher.mock.calls.map(c => c[0])).toEqual([
"https://api.github.com/repos/Hmbown/codewhale-cu-plugin/releases/latest",
`${COMPUTER_USE_REPO}/releases/download/v0.3.0/release.json`,
]);
const fetcher = stub(Response.json(fixture()), Response.json(receipt()));
expect(await getComputerUseRelease()).toMatchObject({ status: "ready", verification: "github-digest" });
expect(fetcher.mock.calls.map(c => c[0])).toEqual([API_LATEST, `${COMPUTER_USE_REPO}/releases/download/v0.3.0/release.json`]);
});
it("distinguishes no published installer from a failed availability check", async () => {
const fetcher = vi.fn().mockResolvedValueOnce(new Response(null, { status: 404 }))
.mockResolvedValueOnce(new Response(null, { status: 503 })).mockRejectedValueOnce(new Error("offline"));
vi.stubGlobal("fetch", fetcher);
it("sends the server-held token to the API exactly when one is passed, and never elsewhere", async () => {
const fetcher = stub(status(404), status(404), status(404));
await getComputerUseRelease("ghp_secret");
await getComputerUseRelease();
const headers = (call: number) => fetcher.mock.calls[call][1].headers as Record<string, string>;
expect(headers(0).Authorization).toBe("Bearer ghp_secret");
expect(headers(1)).not.toHaveProperty("Authorization");
await getComputerUseRelease("");
expect(headers(2)).not.toHaveProperty("Authorization");
});
it("reports no published installer when both the API and the release endpoint say so", async () => {
const fetcher = stub(status(404));
expect((await getComputerUseRelease()).status).toBe("pending");
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("falls back to the release web endpoint when the API refuses, and stays honest when that fails too", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
stub(status(403), status(404));
expect((await getComputerUseRelease()).status).toBe("pending");
expect(error).toHaveBeenCalledWith("computer-use release check", 403);
stub(status(503), new Error("offline"));
expect((await getComputerUseRelease("ghp_secret")).status).toBe("unavailable");
stub(new Error("offline"), status(404));
expect((await getComputerUseRelease("ghp_secret")).status).toBe("pending");
expect(error).toHaveBeenCalledWith("computer-use release check failed", "offline");
stub(status(403), status(500));
expect((await getComputerUseRelease("ghp_secret")).status).toBe("unavailable");
expect(error.mock.calls.flat().join(" ")).not.toContain("ghp_");
});
it("qualifies the download from the receipt when the API is unreachable", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const fetcher = stub(status(403), redirect(OBJECT_URL), Response.json(receipt()), redirect(OBJECT_URL));
expect(await getComputerUseRelease()).toEqual({
status: "ready", version: "0.3.0", sha256, size: 80000000, verification: "receipt",
url: `${COMPUTER_USE_REPO}/releases/tag/v0.3.0`,
downloadUrl: `${COMPUTER_USE_REPO}/releases/download/v0.3.0/${archive}`,
receiptUrl: `${COMPUTER_USE_REPO}/releases/download/v0.3.0/release.json`,
});
expect(fetcher.mock.calls.map(c => [c[0], c[1].method ?? "GET", c[1].redirect])).toEqual([
[API_LATEST, "GET", undefined], [WEB_RECEIPT, "GET", "manual"], [OBJECT_URL, "GET", "manual"],
[`${COMPUTER_USE_REPO}/releases/download/v0.3.0/${archive}`, "HEAD", "manual"],
]);
expect(fetcher.mock.calls.every(c => !("Authorization" in (c[1].headers ?? {})))).toBe(true);
});
it("accepts a directly served archive and a permanent redirect for the receipt", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
stub(status(403), new Response(null, { status: 301, headers: { location: OBJECT_URL } }), Response.json(receipt()), status(200));
expect(await getComputerUseRelease()).toMatchObject({ status: "ready", verification: "receipt" });
});
it.each([
["a disallowed host", redirect("https://example.com/release.json")],
["a redirect without a location", new Response(null, { status: 302 })],
["plain http", redirect("http://objects.githubusercontent.com/release.json")],
])("refuses a receipt redirect onto %s", async (_label, hop) => {
vi.spyOn(console, "error").mockImplementation(() => {});
const fetcher = stub(status(403), hop, Response.json(receipt()));
expect((await getComputerUseRelease()).status).toBe("unavailable");
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("withholds the fallback when the receipt is unqualified or the archive is not served", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
stub(status(403), redirect(OBJECT_URL), Response.json({ ...receipt(), notarized: false }));
expect((await getComputerUseRelease()).status).toBe("unavailable");
stub(status(403), redirect(OBJECT_URL), Response.json(receipt()), status(404));
expect((await getComputerUseRelease()).status).toBe("unavailable");
const fetcher = stub(status(403), redirect(OBJECT_URL), redirect(OBJECT_URL), redirect(OBJECT_URL), redirect(OBJECT_URL), redirect(OBJECT_URL));
expect((await getComputerUseRelease()).status).toBe("unavailable");
expect(fetcher).toHaveBeenCalledTimes(5);
});
it("bounds malformed or oversized responses and does not fetch an unqualified receipt", async () => {
const fetcher = vi.fn().mockResolvedValueOnce(new Response("x".repeat(128 * 1024 + 1)))
.mockResolvedValueOnce(Response.json({ ...fixture(), draft: true }));
vi.stubGlobal("fetch", fetcher);
vi.spyOn(console, "error").mockImplementation(() => {});
const fetcher = stub(new Response("x".repeat(128 * 1024 + 1)), new Error("offline"),
Response.json({ ...fixture(), draft: true }));
expect((await getComputerUseRelease()).status).toBe("unavailable");
expect((await getComputerUseRelease()).status).toBe("pending");
expect(fetcher).toHaveBeenCalledTimes(2);
expect(fetcher).toHaveBeenCalledTimes(3);
});
it("keeps production builds offline without claiming that a release is available", async () => {
vi.stubEnv("NEXT_PHASE", "phase-production-build");
+66 -4
View File
@@ -1,6 +1,8 @@
/** The helper has its own release lifecycle, independent of the Codewhale CLI. */
export const COMPUTER_USE_REPO = "https://github.com/Hmbown/codewhale-cu-plugin";
const API = "https://api.github.com/repos/Hmbown/codewhale-cu-plugin";
/** Hosts GitHub redirects release downloads through; anything else is refused. */
const RELEASE_HOSTS = new Set(["github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"]);
type RecordValue = Record<string, unknown>;
function record(value: unknown): RecordValue {
@@ -16,6 +18,8 @@ export type ComputerUseRelease = {
receiptUrl: string;
sha256: string;
size: number;
/** Which independent evidence confirmed the archive: GitHub's asset digest via the API, or the packager's receipt via the release web endpoint. */
verification: "github-digest" | "receipt";
} | { status: "pending" | "unavailable" };
function releaseAssets(value: unknown) {
@@ -58,6 +62,7 @@ export function qualifiedComputerUseRelease(release: unknown, value: unknown): C
url: `${COMPUTER_USE_REPO}/releases/tag/v${version}`,
downloadUrl: zip.browser_download_url as string,
receiptUrl: assets.receipt.browser_download_url as string,
verification: "github-digest",
};
}
@@ -78,17 +83,71 @@ async function boundedJson(response: Response, limit: number): Promise<unknown>
} finally { await reader.cancel(); reader.releaseLock(); }
}
export async function getComputerUseRelease(): Promise<ComputerUseRelease> {
const WEB_HEADERS = { "User-Agent": "codewhale-web" };
/** GET a release web endpoint, following at most three 302s and only onto GitHub's release hosts over https. */
async function fetchReleaseWeb(url: string): Promise<Response> {
for (let hops = 0; ; hops++) {
const response = await fetch(url, { redirect: "manual", headers: WEB_HEADERS, signal: AbortSignal.timeout(5000) });
if (![301, 302, 307, 308].includes(response.status)) return response;
await response.body?.cancel();
const location = response.headers.get("location");
if (!location) throw new Error("Release redirect without a location");
const target = new URL(location, url);
if (hops >= 3 || target.protocol !== "https:" || !RELEASE_HOSTS.has(target.hostname)) {
throw new Error(`Release redirect refused: ${target.hostname}`);
}
url = target.href;
}
}
/** Resolve the download without the GitHub API: read the latest receipt from the
* release web endpoint, then confirm GitHub serves the archive the receipt names. */
async function receiptQualifiedRelease(): Promise<ComputerUseRelease> {
try {
const response = await fetchReleaseWeb(`${COMPUTER_USE_REPO}/releases/latest/download/release.json`);
if (response.status === 404) return { status: "pending" };
if (!response.ok) return { status: "unavailable" };
const receipt = record(await boundedJson(response, 16 * 1024));
const version = typeof receipt.version === "string" && /^\d+\.\d+\.\d+$/.test(receipt.version) ? receipt.version : null;
const archive = `Codewhale-Computer-Use-${version}-macos-universal.zip`;
if (!version || receipt.platform !== "macos" || receipt.arch !== "universal" || receipt.notarized !== true
|| receipt.archive !== archive || typeof receipt.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(receipt.sha256)
|| !Number.isSafeInteger(receipt.size) || (receipt.size as number) <= 0
|| (receipt.size as number) > 256 * 1024 * 1024) return { status: "unavailable" };
const downloadUrl = `${COMPUTER_USE_REPO}/releases/download/v${version}/${archive}`;
const head = await fetch(downloadUrl, { method: "HEAD", redirect: "manual", headers: WEB_HEADERS, signal: AbortSignal.timeout(5000) });
await head.body?.cancel();
if (head.status !== 302 && head.status !== 200) return { status: "unavailable" };
return {
status: "ready", version, sha256: receipt.sha256, size: receipt.size as number,
url: `${COMPUTER_USE_REPO}/releases/tag/v${version}`, downloadUrl,
receiptUrl: `${COMPUTER_USE_REPO}/releases/download/v${version}/release.json`,
verification: "receipt",
};
} catch (error) {
console.error("computer-use release fallback failed", error instanceof Error ? error.message : String(error));
return { status: "unavailable" };
}
}
export async function getComputerUseRelease(token?: string): Promise<ComputerUseRelease> {
// Match the site's offline build policy; ISR resolves availability after deployment.
if (process.env.NEXT_PHASE === "phase-production-build") return { status: "unavailable" };
try {
const options = { next: { revalidate: 300 }, signal: AbortSignal.timeout(5000) };
const response = await fetch(`${API}/releases/latest`, {
...options,
headers: { Accept: "application/vnd.github+json", "User-Agent": "codewhale-web", "X-GitHub-Api-Version": "2022-11-28" },
headers: {
Accept: "application/vnd.github+json", "User-Agent": "codewhale-web", "X-GitHub-Api-Version": "2022-11-28",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (response.status === 404) return { status: "pending" };
if (!response.ok) return { status: "unavailable" };
if (!response.ok) {
console.error("computer-use release check", response.status);
return receiptQualifiedRelease();
}
const release = await boundedJson(response, 128 * 1024);
const assets = releaseAssets(release);
if (!assets) return { status: "pending" };
@@ -97,5 +156,8 @@ export async function getComputerUseRelease(): Promise<ComputerUseRelease> {
});
if (!receipt.ok) return { status: "unavailable" };
return qualifiedComputerUseRelease(release, await boundedJson(receipt, 16 * 1024));
} catch { return { status: "unavailable" }; }
} catch (error) {
console.error("computer-use release check failed", error instanceof Error ? error.message : String(error));
return receiptQualifiedRelease();
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ export const COMPUTER_USE_COPY = {
{ title: { en: "Open the app", zh: "打开应用" }, body: { en: "Expand the download, move Codewhale Computer Use to Applications, and open it. Choose Computer Use from the whale icon in your menu bar.", zh: "解压下载文件,将 Codewhale Computer Use 移入“应用程序”并打开。点击菜单栏中的鲸鱼图标,选择 Computer Use。" } },
{ title: { en: "Review permissions", zh: "检查权限" }, body: { en: "Use the setup buttons to open Accessibility and Screen Recording in System Settings. You choose which permissions to grant.", zh: "通过设置按钮打开系统设置中的“辅助功能”和“屏幕录制”。由你决定授予哪些权限。" } },
{ title: { en: "Run the background check", zh: "运行后台检查" }, body: { en: "The helper opens a disposable practice window, enters text, and captures that window. It checks whether the pointer or active app changed during the run.", zh: "助手会打开一个临时练习窗口,输入文本并截取该窗口,同时检查运行期间指针或前台应用是否发生变化。" } },
{ title: { en: "Connect it to Codewhale", zh: "连接 Codewhale" }, body: { en: "Review, trust, and enable Computer Use in Codewhale’s plugin marketplace. Use plugin 0.3.0 or later so local actions go through the helper’s Pause and Stop controls.", zh: "在 Codewhale 插件市场中审查、信任并启用 Computer Use。请使用 0.3.0 或更高版本,确保本地操作受助手的暂停和停止控制。" } },
{ title: { en: "Connect it to Codewhale", zh: "连接 Codewhale" }, body: { en: "Review, trust, and enable Computer Use in Codewhale’s plugin marketplace. Use plugin 0.3.1 or later so local actions go through the helper’s Pause and Stop controls.", zh: "在 Codewhale 插件市场中审查、信任并启用 Computer Use。请使用 0.3.1 或更高版本,确保本地操作受助手的暂停和停止控制。" } },
],
controlsTitle: { en: "Keep working. Keep control.", zh: "继续工作,掌握控制权。" },
controlsBody: { en: "Supported actions operate on the selected app in the background. Apps and gestures that need foreground control require your authorization. The menu shows the target and input mode; Pause suspends helper input, and Stop ends its existing sessions.", zh: "受支持的操作可在选定应用的后台完成。需要前台控制的应用或手势必须得到你的授权。菜单会显示目标应用与输入模式;Pause 暂停助手输入,Stop 结束助手的现有会话。" },