From 921a594b8e78c7bf272bf381466a314a42f13d65 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 11 Nov 2026 14:25:12 -0800 Subject: [PATCH 1/2] feat(desktop): downloads from a configured public GitHub releases repo SELFHOST_DESKTOP_RELEASES_REPO (owner/repository, unset by default) turns on GET /api/v1/desktop/releases: the newest non-draft release in the downloads card's manifest shape, for installs without the signed Desktop feed (a local Kind stack publishes its Desktop to a public GitHub releases repo). - Fixed hosts: only https://api.github.com/repos///releases (the list and each sidecar's API asset URL, one redirect allowed only to *.githubusercontent.com). No token, size caps, 8s deadline, 5-minute cache (failures included), release:null instead of 5xx. - Assets need a matching .sha256 sidecar from the same release (and agree with GitHub's digest when reported); download URLs are kept only on github.com / objects.githubusercontent.com under ///releases/download/. - Unset: 404 and no change for anyone else. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 + README.md | 1 + docs/desktop-feed.md | 33 +++++ openapi.yaml | 14 ++ src/app.ts | 16 +++ src/config.ts | 10 ++ src/desktop-releases.ts | 242 ++++++++++++++++++++++++++++++++++ test/desktop-releases.test.ts | 182 +++++++++++++++++++++++++ test/sessions.test.ts | 35 ++++- 9 files changed, 539 insertions(+), 1 deletion(-) create mode 100644 src/desktop-releases.ts create mode 100644 test/desktop-releases.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fa579b5..dc9fddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ assigned at release. ## Unreleased +- Added: `SELFHOST_DESKTOP_RELEASES_REPO` (owner/repository, unset by default) + and `GET /api/v1/desktop/releases`: the latest release of a public GitHub + releases repo, in the downloads card's manifest shape, with checksums from + each asset's `.sha256` sidecar. Fixed GitHub hosts only, bounded, cached 5 + minutes, `release: null` on any upstream failure. For installs without the + signed Desktop feed. See docs/desktop-feed.md, "GitHub releases source". + - Added: events shim, a webhook inbox that wakes an agent, behind a new `events_shim` feature (off by default). `POST /api/v1/events-shim/shims` (signed-in person, 20 per person per hour) returns an MCP URL, webhook URLs diff --git a/README.md b/README.md index 8a8380f..6a6fa70 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ is explicit. History is served only by the sessions pipeline: see | `SELFHOST_PERSONAL_ORG_ON_SIGNIN` | Multi mode only, default `true`: a first sign-in with no invite, membership or assigned domain creates a personal organization. The bootstrap bearer can override it at runtime via `PATCH /api/v1/installation/settings` | | `SELFHOST_SELF_SERVE_ORGANIZATIONS` | Multi mode only, default `true`: signed-in people may create organizations. Overridable the same way | | `SELFHOST_INVITE_TTL_HOURS` | Invite link lifetime, default 168 (7 days), 1-720 | +| `SELFHOST_DESKTOP_RELEASES_REPO` | Optional `owner/repository` of a public GitHub releases repo the downloads card shows when the signed Desktop feed has nothing. Unset: `GET /api/v1/desktop/releases` is 404. See [docs/desktop-feed.md](docs/desktop-feed.md#github-releases-source) | | `SELFHOST_FEATURES` | Comma list of optional components to serve: `flows`, `relayfile`, `chief`, `usage`, `launch`, `usage_people`, `events_shim`. Unset selects `usage,usage_people`; `""` selects nothing. Unselected routes answer 403 `feature_not_enabled` (`events_shim` routes answer 404). See [docs/features.md](docs/features.md) | | `SELFHOST_EVENTS_SHIM_RELAY_ORIGINS` | Optional comma list of https origins (no path) an events-shim inbox may forward wake posts to (`link_relay_wake`). Default: the `RELAYCAST_PUBLIC_URL` origin. Set it, e.g. to `https://cast.agentrelay.com`, when your agents' Agent Relay uses another Relaycast. See [docs/events-shim.md](docs/events-shim.md) | | `SELFHOST_FACTORY_ENABLED` | Optional `true`/`false`, default `false`. Factory sessions: when off every Factory route is 404 and the worker does not start. See [docs/factory-sessions.md](docs/factory-sessions.md) | diff --git a/docs/desktop-feed.md b/docs/desktop-feed.md index dbf0044..044f7c4 100644 --- a/docs/desktop-feed.md +++ b/docs/desktop-feed.md @@ -767,3 +767,36 @@ endpoints now exist (see "Implementation"); the remaining items still hold. I-JSON rule (it states it in prose only), the check precedence, the quota arithmetic or the Range arithmetic. Those are in this document and in the reference functions in `test/desktop-feed-contract.test.ts`. + +## GitHub releases source + +An install without the signed feed (for example a local Kind stack whose +Desktop is published to a public GitHub releases repository) can still show +downloads. Set `SELFHOST_DESKTOP_RELEASES_REPO=/` +(`^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$`). Unset, `GET /api/v1/desktop/releases` +answers 404 and nothing changes. + +`GET /api/v1/desktop/releases` (any signed-in member) returns +`{ source: { kind: "github", repo, releasesUrl }, release }`. `release` is the +newest non-draft release (pre-releases included) in the downloads card's +manifest shape (`version`, `tag`, `prerelease`, `published_at`, `assets`), or +`null` when there is none or GitHub cannot be read. Each asset carries the +card's `platform`, `arch`, `kind` and macOS `os_floor`, parsed from +`Desktop----[-probe|-Setup].`; anything else is +skipped. + +- Control calls only `https://api.github.com/repos///releases`: + the release list, and each checksum sidecar through its API asset URL, whose + single redirect may go only to a `*.githubusercontent.com` host. No token. + The list is capped at 2 MiB, a sidecar at 1 KiB, the whole read at 8 + seconds; the result (also a failure) is cached for 5 minutes. +- An asset is served only with a `.sha256` sidecar from the same release + whose digest (and file name, when present) matches; if GitHub reports its + own `sha256:` digest for the asset, it must agree. +- `url` is the asset's `browser_download_url`, kept only when it is + `https://github.com` or `https://objects.githubusercontent.com` under + `///releases/download/`. + +The checksums give integrity, not publisher authenticity: they come from the +same release. The dashboard labels this source as GitHub releases, not the +signed Desktop feed, and uses it only when the signed feed has no profiles. diff --git a/openapi.yaml b/openapi.yaml index 524518a..e32c5e0 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1603,6 +1603,20 @@ paths: responses: "200": { description: "`{ events: [{ viewerUserId, viewerName, subjectUserId, subjectName, days, page, createdAt }] }`" } "403": { description: "`forbidden` or `feature_not_enabled`" } + /api/v1/desktop/releases: + get: + operationId: getDesktopGitHubReleases + security: [{ bearerAuth: [] }, { cookieAuth: [] }] + description: | + The latest release of the public GitHub releases repo in + SELFHOST_DESKTOP_RELEASES_REPO, in the downloads card's manifest shape, + with sidecar checksums (docs/desktop-feed.md, "GitHub releases + source"). Any signed-in member. 404 when the setting is unset. + `release` is null when there is none or GitHub cannot be read. + responses: + "200": { description: "`{ source: { kind: github, repo, releasesUrl }, release: null | { version, tag, prerelease, published_at, assets[] } }`" } + "401": { description: Missing, invalid, or expired user token } + "404": { description: No releases repo configured } /api/v1/analytics/events: post: operationId: captureClientAnalyticsEvents diff --git a/src/app.ts b/src/app.ts index ac9b731..be50562 100644 --- a/src/app.ts +++ b/src/app.ts @@ -25,6 +25,7 @@ import { FlowRunRelayfileCredentials } from "./flows/relayfile-credentials.js"; import { handleSessionRoutes } from "./sessions/routes.js"; import { UsageRangeError, usageDays, usageReport } from "./usage/routes.js"; import { UsageStore } from "./usage/store.js"; +import { GitHubDesktopReleases } from "./desktop-releases.js"; import { listReadAudit, parseReadAuditQuery, readAuditCsv, ReadAuditQueryError } from "./mcp/read-audit.js"; import { PeopleInputError, PersonNotFoundError, peopleAudit, peopleReport, personSessions, recordPeopleView, UsagePeopleStore, @@ -403,6 +404,8 @@ export function createApp(input: { factory?: FactoryModule; /** Internal dormant Capy send resolver; server wiring requires a complete runtime. */ capy?: McpContext["capy"]; + /** Test hook: the GitHub fetch behind GET /api/v1/desktop/releases. */ + desktopReleasesFetch?: typeof fetch; /** Desktop feed overrides for tests; the feed itself is on only when config.desktopFeed is set. */ desktopFeed?: { signer?: DesktopFeedSigner; @@ -427,6 +430,9 @@ export function createApp(input: { const features = input.features ?? NO_FEATURES; // Usage page counters: written only while the `usage` feature is selected. const usage = new UsageStore(store.database, () => input.now?.() ?? new Date()); + const desktopReleases = config.desktopReleasesRepo + ? new GitHubDesktopReleases(config.desktopReleasesRepo, input.desktopReleasesFetch ?? globalThis.fetch) + : null; const people = new UsagePeopleStore(store.database, () => input.now?.() ?? new Date()); // Counters never fail or slow a request; each is written only while its feature is on. const recordUsage = (write: () => Promise): void => { @@ -1839,6 +1845,16 @@ setTimeout(poll,${Math.max(1, started.intervalSeconds) * 1_000}); return; } + // Desktop downloads from a configured public GitHub releases repo + // (docs/desktop-feed.md, "GitHub releases source"). Unset: 404 like any + // unknown path. Any signed-in member, like the downloads card. + if (path === "/api/v1/desktop/releases" && method === "GET" && desktopReleases) { + const auth = await authenticate(request); + if (auth.kind !== "user") throw new HttpError(401, "unauthorized", "User token required"); + json(response, 200, await desktopReleases.latest(), true); + return; + } + // Usage page People drill-down (docs/usage.md, "People"): owner-only, per member. // routeMatch would turn an absent optional group into "undefined". const peopleRoute = /^\/api\/v1\/organizations\/([^/]+)\/usage\/people(?:\/(audit|[^/]+\/sessions))?$/.exec(path); diff --git a/src/config.ts b/src/config.ts index c6ce0b6..cae37d9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,8 @@ import { strictRawPublicKey, } from "./acquisition/assertion.js"; +import { loadDesktopReleasesRepo } from "./desktop-releases.js"; + export type SelfhostConfig = { databaseUrl: string; publicBaseUrl: string; @@ -81,6 +83,12 @@ export type SelfhostConfig = { * and every feed route answers 404 like any unknown path. */ desktopFeed?: DesktopFeedConfig | null; + /** + * SELFHOST_DESKTOP_RELEASES_REPO (owner/repository): a public GitHub releases + * repo whose latest release the downloads card shows when the signed feed + * has nothing. Null or absent: GET /api/v1/desktop/releases answers 404. + */ + desktopReleasesRepo?: string | null; /** Direct-to-S3 session uploads (docs/sessions-read-api.md). null: the upload-credentials route answers 404. */ historyUpload?: HistoryUploadConfig | null; /** @@ -768,6 +776,7 @@ function loadAdminConfig(env: Env, publicBaseUrl: string): { dashboardUrl: string; operatorOAuthApps: Record; desktopFeed: DesktopFeedConfig | null; + desktopReleasesRepo: string | null; } { const internalServiceToken = readSecret(env, "SELFHOST_INTERNAL_TOKEN") ?? null; if (internalServiceToken !== null && Buffer.byteLength(internalServiceToken) < 32) { @@ -787,6 +796,7 @@ function loadAdminConfig(env: Env, publicBaseUrl: string): { dashboardUrl: absoluteHttpUrl(env, "SELFHOST_DASHBOARD_URL", `${publicBaseUrl}/`), operatorOAuthApps: loadOperatorOAuthApps(env), desktopFeed: loadDesktopFeedConfig(env), + desktopReleasesRepo: loadDesktopReleasesRepo(env), }; } diff --git a/src/desktop-releases.ts b/src/desktop-releases.ts new file mode 100644 index 0000000..b96d274 --- /dev/null +++ b/src/desktop-releases.ts @@ -0,0 +1,242 @@ +// Desktop downloads from a public GitHub releases repository +// (docs/desktop-feed.md, "GitHub releases source"). Off unless +// SELFHOST_DESKTOP_RELEASES_REPO is set; then GET /api/v1/desktop/releases +// serves the latest release in the downloads card's manifest shape. +// +// Bounds: Control calls only https://api.github.com/repos///releases +// (the list, and each checksum sidecar through its API asset URL, whose one +// redirect may go only to a *.githubusercontent.com host). No token. Every +// response is size-capped and the whole read has a deadline. Results are +// cached for five minutes. Any upstream failure is `release: null`, never 5xx. +// +// Integrity, not authenticity: a sidecar `.sha256` comes from the same +// release as the file it describes, so it detects corruption, not a hostile +// publisher. The signed Desktop feed remains the authenticated source. + +export const RELEASES_REPO = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/; +const API = "https://api.github.com"; +const CACHE_MS = 5 * 60_000; +const DEADLINE_MS = 8_000; +const MAX_LIST_BYTES = 2 * 1024 * 1024; +const MAX_SIDECAR_BYTES = 1024; +const MAX_ASSETS = 64; +const SIDECAR_CONCURRENCY = 6; +const SIDECAR_HOST = /^(?:[a-z0-9-]+\.)*githubusercontent\.com$/; +const SHA256 = /^[0-9a-f]{64}$/; + +export type ReleaseAsset = { + id: string; + name: string; + platform: "macOS" | "Windows" | "Linux"; + arch: "arm64" | "x64"; + os_floor?: number; + kind: "image" | "setup" | "zip" | "deb" | "tarball" | "probe"; + size: number; + sha256: string; + url: string; +}; + +export type DesktopReleases = { + source: { kind: "github"; repo: string; releasesUrl: string }; + release: null | { + version: string; + tag: string; + prerelease: boolean; + published_at: string; + assets: ReleaseAsset[]; + }; +}; + +/** Validates SELFHOST_DESKTOP_RELEASES_REPO; unset or empty is null. */ +export function loadDesktopReleasesRepo(env: Record): string | null { + const raw = env.SELFHOST_DESKTOP_RELEASES_REPO?.trim(); + if (!raw) return null; + if (!RELEASES_REPO.test(raw) || raw.split("/").some((part) => part === "." || part === "..")) { + throw new Error("SELFHOST_DESKTOP_RELEASES_REPO must be owner/repository"); + } + return raw; +} + +/** + * Maps a release asset name to the card's fields, or null when it is not a + * Desktop installer: `Desktop----[-probe|-Setup].`. + */ +export function describeAsset(name: string): Omit | null { + const match = /-(macOS(\d{2})?|Linux|Windows)-(arm64|x64)(-probe|-Setup)?\.(dmg|deb|tar\.gz|exe|zip)$/.exec(name); + if (!match || !name.startsWith("Desktop-")) return null; + const [, os, floor, arch, suffix, ext] = match; + const platform = (os!.startsWith("macOS") ? "macOS" : os) as ReleaseAsset["platform"]; + const kind: ReleaseAsset["kind"] | null = suffix === "-probe" + ? (ext === "tar.gz" || ext === "zip" ? "probe" : null) + : platform === "macOS" ? (ext === "dmg" ? "image" : null) + : platform === "Windows" ? (ext === "exe" && suffix === "-Setup" ? "setup" : ext === "zip" ? "zip" : null) + : ext === "deb" ? "deb" : ext === "tar.gz" ? "tarball" : null; + if (!kind) return null; + return { platform, arch: arch as ReleaseAsset["arch"], kind, ...(floor ? { os_floor: Number(floor) } : {}) }; +} + +/** Only a direct release download of this repository may reach the browser. */ +export function allowedDownloadUrl(repo: string, value: unknown): string | null { + if (typeof value !== "string") return null; + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + if (url.protocol !== "https:" || url.username || url.password || url.port || url.search || url.hash) return null; + if (url.hostname !== "github.com" && url.hostname !== "objects.githubusercontent.com") return null; + if (!url.pathname.startsWith(`/${repo}/releases/download/`)) return null; + return url.toString(); +} + +class Deadline extends Error {} + +async function boundedText(response: Response, limit: number): Promise { + const declared = Number(response.headers.get("content-length") ?? "0"); + if (declared > limit) throw new Error("too large"); + const reader = response.body?.getReader(); + if (!reader) return ""; + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > limit) { + await reader.cancel().catch(() => {}); + throw new Error("too large"); + } + chunks.push(value); + } + return Buffer.concat(chunks).toString("utf8"); +} + +type ApiAsset = { id: number; name: string; size: number; url: string; browser_download_url: string; digest?: string | null }; +type ApiRelease = { tag_name: string; draft: boolean; prerelease: boolean; published_at: string | null; assets: ApiAsset[] }; + +function isApiRelease(value: unknown): value is ApiRelease { + const row = value as ApiRelease | null; + return !!row && typeof row === "object" && typeof row.tag_name === "string" && typeof row.draft === "boolean" && + typeof row.prerelease === "boolean" && Array.isArray(row.assets); +} + +export class GitHubDesktopReleases { + #cache: { expiresAt: number; value: DesktopReleases } | null = null; + #inflight: Promise | null = null; + + constructor( + readonly repo: string, + private readonly fetchImpl: typeof fetch = globalThis.fetch, + private readonly now: () => number = Date.now, + ) {} + + private empty(): DesktopReleases { + return { source: { kind: "github", repo: this.repo, releasesUrl: `https://github.com/${this.repo}/releases` }, release: null }; + } + + async latest(): Promise { + if (this.#cache && this.#cache.expiresAt > this.now()) return this.#cache.value; + this.#inflight ??= this.load().finally(() => { + this.#inflight = null; + }); + return this.#inflight; + } + + private async load(): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Deadline()), DEADLINE_MS); + let value: DesktopReleases; + try { + value = await this.read(controller.signal); + } catch { + value = this.empty(); + } finally { + clearTimeout(timer); + } + // Failures are cached too, so an outage costs one call per five minutes. + this.#cache = { expiresAt: this.now() + CACHE_MS, value }; + return value; + } + + private async api(path: string, signal: AbortSignal, accept: string): Promise { + const url = new URL(`${API}/repos/${this.repo}/releases${path}`); + if (url.origin !== API || !url.pathname.startsWith(`/repos/${this.repo}/releases`)) throw new Error("bad path"); + return this.fetchImpl(url, { + redirect: "manual", + signal, + headers: { accept, "user-agent": "agent-relay-selfhost", "x-github-api-version": "2022-11-28" }, + }); + } + + private async read(signal: AbortSignal): Promise { + const list = await this.api("?per_page=10", signal, "application/vnd.github+json"); + if (list.status !== 200) { + await list.body?.cancel().catch(() => {}); + throw new Error(`list ${list.status}`); + } + const releases: unknown = JSON.parse(await boundedText(list, MAX_LIST_BYTES)); + if (!Array.isArray(releases)) throw new Error("list shape"); + // Newest first; drafts are never public, but skip them defensively. + const release = releases.find((row): row is ApiRelease => isApiRelease(row) && !row.draft); + if (!release) return this.empty(); + const byName = new Map(release.assets.slice(0, MAX_ASSETS * 2).map((asset) => [asset.name, asset])); + const candidates = [...byName.values()].flatMap((asset) => { + const described = describeAsset(asset.name); + const url = allowedDownloadUrl(this.repo, asset.browser_download_url); + const sidecar = byName.get(`${asset.name}.sha256`); + return described && url && sidecar && Number.isSafeInteger(asset.size) && asset.size > 0 + ? [{ asset, described, url, sidecar }] : []; + }).slice(0, MAX_ASSETS); + const assets: ReleaseAsset[] = []; + let next = 0; + await Promise.all(Array.from({ length: Math.min(SIDECAR_CONCURRENCY, candidates.length) }, async () => { + while (next < candidates.length) { + const { asset, described, url, sidecar } = candidates[next++]!; + const sha256 = await this.sidecar(sidecar, asset.name, signal).catch((error: unknown) => { + if (error instanceof Deadline || signal.aborted) throw error; + return null; + }); + if (!sha256) continue; + // GitHub's own digest, when present, must agree with the sidecar. + if (typeof asset.digest === "string" && asset.digest.startsWith("sha256:") && asset.digest.slice(7) !== sha256) continue; + assets.push({ id: asset.name, name: asset.name, ...described, size: asset.size, sha256, url }); + } + })); + assets.sort((a, b) => a.name.localeCompare(b.name)); + return { + source: this.empty().source, + release: { + version: release.tag_name.replace(/^v/, ""), + tag: release.tag_name, + prerelease: release.prerelease, + published_at: release.published_at ?? "", + assets, + }, + }; + } + + /** The 64-hex digest from `.sha256` (` ` or just ``). */ + private async sidecar(sidecar: ApiAsset, name: string, signal: AbortSignal): Promise { + if (!Number.isSafeInteger(sidecar.id) || sidecar.size > MAX_SIDECAR_BYTES) return null; + const first = await this.api(`/assets/${sidecar.id}`, signal, "application/octet-stream"); + let response = first; + if (first.status === 301 || first.status === 302 || first.status === 307 || first.status === 308) { + await first.body?.cancel().catch(() => {}); + const location = first.headers.get("location"); + const target = location ? new URL(location, API) : null; + if (!target || target.protocol !== "https:" || !SIDECAR_HOST.test(target.hostname)) return null; + response = await this.fetchImpl(target, { redirect: "error", signal, headers: { "user-agent": "agent-relay-selfhost" } }); + } + if (response.status !== 200) { + await response.body?.cancel().catch(() => {}); + return null; + } + const text = (await boundedText(response, MAX_SIDECAR_BYTES)).trim(); + const [digest, file] = text.split(/\s+/); + const hex = digest?.toLowerCase() ?? ""; + if (!SHA256.test(hex)) return null; + if (file && file.replace(/^\*/, "") !== name) return null; + return hex; + } +} diff --git a/test/desktop-releases.test.ts b/test/desktop-releases.test.ts new file mode 100644 index 0000000..00308a5 --- /dev/null +++ b/test/desktop-releases.test.ts @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + allowedDownloadUrl, + describeAsset, + GitHubDesktopReleases, + loadDesktopReleasesRepo, +} from "../src/desktop-releases.js"; + +const REPO = "AgentWorkforce/relay-desktop-kind-releases"; +const TAG = "v2026.11.1-beta.6"; +const hex = (seed: string) => seed.repeat(64).slice(0, 64); + +test("SELFHOST_DESKTOP_RELEASES_REPO: owner/repository only", () => { + assert.equal(loadDesktopReleasesRepo({}), null); + assert.equal(loadDesktopReleasesRepo({ SELFHOST_DESKTOP_RELEASES_REPO: " " }), null); + assert.equal(loadDesktopReleasesRepo({ SELFHOST_DESKTOP_RELEASES_REPO: REPO }), REPO); + for (const bad of ["owner", "owner/repo/x", "https://github.com/o/r", "o/..", "o r/x", "o/r?x", "../r"]) { + assert.throws(() => loadDesktopReleasesRepo({ SELFHOST_DESKTOP_RELEASES_REPO: bad }), /owner\/repository/, bad); + } +}); + +test("asset names map to the card's platform, arch, kind and macOS floor", () => { + const name = (rest: string) => `Desktop-q7m2k9-2026.11.1-beta.6-${rest}`; + assert.deepEqual(describeAsset(name("macOS-arm64.dmg")), { platform: "macOS", arch: "arm64", kind: "image" }); + assert.deepEqual(describeAsset(name("macOS15-x64.dmg")), { platform: "macOS", arch: "x64", kind: "image", os_floor: 15 }); + assert.deepEqual(describeAsset(name("Windows-arm64-Setup.exe")), { platform: "Windows", arch: "arm64", kind: "setup" }); + assert.deepEqual(describeAsset(name("Windows-x64.zip")), { platform: "Windows", arch: "x64", kind: "zip" }); + assert.deepEqual(describeAsset(name("Linux-x64.deb")), { platform: "Linux", arch: "x64", kind: "deb" }); + assert.deepEqual(describeAsset(name("Linux-arm64.tar.gz")), { platform: "Linux", arch: "arm64", kind: "tarball" }); + assert.deepEqual(describeAsset(name("Linux-x64-probe.tar.gz")), { platform: "Linux", arch: "x64", kind: "probe" }); + assert.deepEqual(describeAsset(name("Windows-arm64-probe.zip")), { platform: "Windows", arch: "arm64", kind: "probe" }); + for (const other of [name("Linux-x64.deb.sha256"), name("macOS-arm64.zip"), name("Windows-x64.exe"), "notes.txt", "Other-macOS-arm64.dmg"]) { + assert.equal(describeAsset(other), null, other); + } +}); + +test("only this repository's release downloads on GitHub hosts reach the browser", () => { + const good = `https://github.com/${REPO}/releases/download/${TAG}/a.dmg`; + assert.equal(allowedDownloadUrl(REPO, good), good); + assert.equal(allowedDownloadUrl(REPO, `https://objects.githubusercontent.com/${REPO}/releases/download/${TAG}/a.dmg`), + `https://objects.githubusercontent.com/${REPO}/releases/download/${TAG}/a.dmg`); + for (const bad of [ + `http://github.com/${REPO}/releases/download/${TAG}/a.dmg`, + `https://evil.example/${REPO}/releases/download/${TAG}/a.dmg`, + `https://github.com/other/repo/releases/download/${TAG}/a.dmg`, + `https://github.com/${REPO}/archive/a.dmg`, + `https://github.com:8443/${REPO}/releases/download/${TAG}/a.dmg`, + `https://user@github.com/${REPO}/releases/download/${TAG}/a.dmg`, + `https://github.com/${REPO}/releases/download/${TAG}/a.dmg?x=1`, + 42, + ]) { + assert.equal(allowedDownloadUrl(REPO, bad), null, String(bad)); + } +}); + +type Fake = { calls: string[]; fetch: typeof fetch }; + +function fakeGitHub(options: { + assets?: Array<{ name: string; size?: number; digest?: string; browser?: string }>; + sidecars?: Record; + redirectHost?: string; + listStatus?: number; + releases?: unknown[]; +} = {}): Fake { + const calls: string[] = []; + const assets = (options.assets ?? []).map((asset, index) => ({ + id: index + 1, + name: asset.name, + size: asset.size ?? (asset.name.endsWith(".sha256") ? 120 : 1000), + url: `https://api.github.com/repos/${REPO}/releases/assets/${index + 1}`, + browser_download_url: asset.browser ?? `https://github.com/${REPO}/releases/download/${TAG}/${asset.name}`, + ...(asset.digest ? { digest: asset.digest } : {}), + })); + const releases = options.releases ?? [ + { tag_name: "v9.9.9-draft", draft: true, prerelease: false, published_at: null, assets: [] }, + { tag_name: TAG, draft: false, prerelease: true, published_at: "2026-11-11T19:21:31Z", assets }, + ]; + const fetchImpl = (async (input: string | URL) => { + const url = new URL(String(input)); + calls.push(url.toString()); + if (url.origin === "https://api.github.com" && url.pathname === `/repos/${REPO}/releases`) { + return new Response(JSON.stringify(releases), { status: options.listStatus ?? 200 }); + } + const asset = /\/assets\/(\d+)$/.exec(url.pathname); + if (url.origin === "https://api.github.com" && asset) { + const id = Number(asset[1]); + return new Response(null, { + status: 302, + headers: { location: `https://${options.redirectHost ?? "objects.githubusercontent.com"}/blob/${id}` }, + }); + } + const blob = /^\/blob\/(\d+)$/.exec(url.pathname); + if (blob) { + const name = assets[Number(blob[1]) - 1]!.name; + const body = options.sidecars?.[name]; + return body === undefined ? new Response("missing", { status: 404 }) : new Response(body, { status: 200 }); + } + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + return { calls, fetch: fetchImpl }; +} + +const DMG = "Desktop-q7m2k9-2026.11.1-beta.6-macOS-arm64.dmg"; +const DEB = "Desktop-q7m2k9-2026.11.1-beta.6-Linux-x64.deb"; +const EXE = "Desktop-q7m2k9-2026.11.1-beta.6-Windows-x64-Setup.exe"; + +test("latest release: mapped installers with sidecar checksums, newest non-draft, prerelease kept", async () => { + const github = fakeGitHub({ + assets: [ + { name: DMG }, { name: `${DMG}.sha256` }, + { name: DEB, digest: `sha256:${hex("b")}` }, { name: `${DEB}.sha256` }, + { name: EXE }, // no sidecar: dropped + { name: "notes.txt" }, + ], + sidecars: { [`${DMG}.sha256`]: `${hex("a")} ${DMG}\n`, [`${DEB}.sha256`]: `${hex("b")}\n` }, + }); + const releases = new GitHubDesktopReleases(REPO, github.fetch); + const result = await releases.latest(); + assert.deepEqual(result.source, { kind: "github", repo: REPO, releasesUrl: `https://github.com/${REPO}/releases` }); + assert.equal(result.release?.version, "2026.11.1-beta.6"); + assert.equal(result.release?.tag, TAG); + assert.equal(result.release?.prerelease, true); + assert.deepEqual(result.release?.assets, [ + { id: DEB, name: DEB, platform: "Linux", arch: "x64", kind: "deb", size: 1000, sha256: hex("b"), + url: `https://github.com/${REPO}/releases/download/${TAG}/${DEB}` }, + { id: DMG, name: DMG, platform: "macOS", arch: "arm64", kind: "image", size: 1000, sha256: hex("a"), + url: `https://github.com/${REPO}/releases/download/${TAG}/${DMG}` }, + ]); + // Only the API list, API asset URLs and the githubusercontent redirect targets. + for (const call of github.calls) { + const host = new URL(call).hostname; + assert.ok(host === "api.github.com" || host.endsWith("githubusercontent.com"), call); + if (host === "api.github.com") assert.ok(new URL(call).pathname.startsWith(`/repos/${REPO}/releases`), call); + } + // Cached: a second read makes no call. + const before = github.calls.length; + await releases.latest(); + assert.equal(github.calls.length, before); +}); + +test("checksum mismatches, foreign download URLs and off-host redirects drop the asset", async () => { + const mismatch = fakeGitHub({ + assets: [{ name: DMG, digest: `sha256:${hex("c")}` }, { name: `${DMG}.sha256` }], + sidecars: { [`${DMG}.sha256`]: hex("a") }, + }); + assert.deepEqual((await new GitHubDesktopReleases(REPO, mismatch.fetch).latest()).release?.assets, []); + const wrongFile = fakeGitHub({ + assets: [{ name: DMG }, { name: `${DMG}.sha256` }], + sidecars: { [`${DMG}.sha256`]: `${hex("a")} other.dmg` }, + }); + assert.deepEqual((await new GitHubDesktopReleases(REPO, wrongFile.fetch).latest()).release?.assets, []); + const foreign = fakeGitHub({ + assets: [{ name: DMG, browser: `https://evil.example/${REPO}/releases/download/${TAG}/${DMG}` }, { name: `${DMG}.sha256` }], + sidecars: { [`${DMG}.sha256`]: hex("a") }, + }); + assert.deepEqual((await new GitHubDesktopReleases(REPO, foreign.fetch).latest()).release?.assets, []); + const offHost = fakeGitHub({ + assets: [{ name: DMG }, { name: `${DMG}.sha256` }], + sidecars: { [`${DMG}.sha256`]: hex("a") }, + redirectHost: "evil.example", + }); + assert.deepEqual((await new GitHubDesktopReleases(REPO, offHost.fetch).latest()).release?.assets, []); + assert.ok(offHost.calls.every((call) => !call.includes("evil.example"))); +}); + +test("an upstream failure is release null (cached), never an error", async () => { + const down = fakeGitHub({ listStatus: 503 }); + let now = 1_000; + const releases = new GitHubDesktopReleases(REPO, down.fetch, () => now); + assert.equal((await releases.latest()).release, null); + assert.equal(down.calls.length, 1); + await releases.latest(); + assert.equal(down.calls.length, 1); + now += 5 * 60_000 + 1; + await releases.latest(); + assert.equal(down.calls.length, 2); + const throwing = new GitHubDesktopReleases(REPO, (async () => { throw new Error("offline"); }) as typeof fetch); + assert.equal((await throwing.latest()).release, null); + const empty = fakeGitHub({ releases: [] }); + assert.equal((await new GitHubDesktopReleases(REPO, empty.fetch).latest()).release, null); +}); diff --git a/test/sessions.test.ts b/test/sessions.test.ts index d23ecdf..39573d9 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -299,7 +299,10 @@ async function startHistory(t: TestContext) { async function setup( t: TestContext, now = new Date("2026-11-02T20:00:00.000Z"), - options: { agentTokens?: boolean; relaycastFetch?: typeof fetch; features?: SelectedFeatures } = {}, + options: { + agentTokens?: boolean; relaycastFetch?: typeof fetch; features?: SelectedFeatures; + desktopReleasesRepo?: string; desktopReleasesFetch?: typeof fetch; + } = {}, ) { const historyOrigin = "https://history.selfhosted.test"; const history = await startHistory(t); @@ -322,10 +325,12 @@ async function setup( oauthRedirectUrl: "https://control.internal.example/api/v1/integrations/oauth/callback", dashboardUrl: "https://control.internal.example/", sessionSearchAgentTokens: options.agentTokens, + desktopReleasesRepo: options.desktopReleasesRepo ?? null, }; const store = new ControlPlaneStore(database, config.encryptionKeyring, 0); const control = createServer(createApp({ config, store, now: () => now, messagingFetch: options.relaycastFetch, features: options.features, + desktopReleasesFetch: options.desktopReleasesFetch, })); const origin = await listen(control); t.after(async () => { @@ -1785,3 +1790,31 @@ test("usage people: a workspace that ends on a full 200-session page still pages assert.equal(new Set(seen).size, 230); assert.deepEqual(seen.slice(199, 201), ["a199", "b0"]); }); + +test("desktop releases: 404 unless a repo is configured; any signed-in member reads it", async (t) => { + const off = await setup(t); + const offToken = await off.login(off.memberId, off.alpha); + assert.equal((await call(off.origin, "/cloud/api/v1/desktop/releases", { token: offToken })).status, 404); + + const calls: string[] = []; + const env = await setup(t, undefined, { + desktopReleasesRepo: "AgentWorkforce/relay-desktop-kind-releases", + desktopReleasesFetch: (async (input: string | URL) => { + calls.push(String(input)); + return new Response("[]", { status: 200 }); + }) as typeof fetch, + }); + assert.equal((await call(env.origin, "/cloud/api/v1/desktop/releases")).status, 401); + const member = await env.login(env.memberId, env.alpha); + const result = await call(env.origin, "/cloud/api/v1/desktop/releases", { token: member }); + assert.equal(result.status, 200); + assert.equal(result.headers.get("cache-control"), "no-store"); + assert.deepEqual(result.body, { + source: { + kind: "github", repo: "AgentWorkforce/relay-desktop-kind-releases", + releasesUrl: "https://github.com/AgentWorkforce/relay-desktop-kind-releases/releases", + }, + release: null, + }); + assert.deepEqual(calls, ["https://api.github.com/repos/AgentWorkforce/relay-desktop-kind-releases/releases?per_page=10"]); +}); From 0ff71c83260cff359566e49d4e633a2c8a34d7ad Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 11 Nov 2026 14:39:02 -0800 Subject: [PATCH 2/2] fix(desktop): stay inside GitHub's quota; outages keep the last good release - The release list is a conditional request (ETag); a 304 does not count against the unauthenticated quota. Verified sidecar digests are kept per asset id + updated_at, so unchanged assets are never re-read. - A sidecar read failure makes the release unavailable (not smaller); any failure serves the last complete answer, else release:null. - owner/repository is compared case-insensitively in download URLs. - An over-declared response body is cancelled before it is refused. Co-Authored-By: Claude Opus 5.5 --- src/desktop-releases.ts | 100 ++++++++++++++++++++++++++-------- test/desktop-releases.test.ts | 66 ++++++++++++++++++++-- 2 files changed, 138 insertions(+), 28 deletions(-) diff --git a/src/desktop-releases.ts b/src/desktop-releases.ts index b96d274..83ba5fd 100644 --- a/src/desktop-releases.ts +++ b/src/desktop-releases.ts @@ -86,7 +86,10 @@ export function allowedDownloadUrl(repo: string, value: unknown): string | null } if (url.protocol !== "https:" || url.username || url.password || url.port || url.search || url.hash) return null; if (url.hostname !== "github.com" && url.hostname !== "objects.githubusercontent.com") return null; - if (!url.pathname.startsWith(`/${repo}/releases/download/`)) return null; + // GitHub treats owner/repository case-insensitively; its URLs use the + // canonical spelling, which may differ from the configured one. + const prefix = `/${repo}/releases/download/`; + if (url.pathname.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) return null; return url.toString(); } @@ -94,7 +97,10 @@ class Deadline extends Error {} async function boundedText(response: Response, limit: number): Promise { const declared = Number(response.headers.get("content-length") ?? "0"); - if (declared > limit) throw new Error("too large"); + if (declared > limit) { + await response.body?.cancel().catch(() => {}); + throw new Error("too large"); + } const reader = response.body?.getReader(); if (!reader) return ""; const chunks: Uint8Array[] = []; @@ -112,7 +118,10 @@ async function boundedText(response: Response, limit: number): Promise { return Buffer.concat(chunks).toString("utf8"); } -type ApiAsset = { id: number; name: string; size: number; url: string; browser_download_url: string; digest?: string | null }; +type ApiAsset = { + id: number; name: string; size: number; url: string; browser_download_url: string; + digest?: string | null; updated_at?: string; +}; type ApiRelease = { tag_name: string; draft: boolean; prerelease: boolean; published_at: string | null; assets: ApiAsset[] }; function isApiRelease(value: unknown): value is ApiRelease { @@ -121,9 +130,21 @@ function isApiRelease(value: unknown): value is ApiRelease { typeof row.prerelease === "boolean" && Array.isArray(row.assets); } +/** A sidecar could not be read (transport, status, size): the release is unavailable, not smaller. */ +class SidecarUnavailable extends Error {} + export class GitHubDesktopReleases { #cache: { expiresAt: number; value: DesktopReleases } | null = null; #inflight: Promise | null = null; + /** The last complete answer, served again when a refresh fails (stale beats empty). */ + #lastGood: DesktopReleases | null = null; + /** The release list's ETag: a 304 reply does not count against GitHub's unauthenticated quota. */ + #etag: string | null = null; + /** + * Verified sidecar digests by `:`: a re-upload + * changes both, so an unchanged asset is never re-read. Bounded. + */ + readonly #digests = new Map(); constructor( readonly repo: string, @@ -149,8 +170,10 @@ export class GitHubDesktopReleases { let value: DesktopReleases; try { value = await this.read(controller.signal); + if (value.release) this.#lastGood = value; } catch { - value = this.empty(); + // An outage (or the quota) serves the last complete answer, else null. + value = this.#lastGood ?? this.empty(); } finally { clearTimeout(timer); } @@ -159,22 +182,32 @@ export class GitHubDesktopReleases { return value; } - private async api(path: string, signal: AbortSignal, accept: string): Promise { + private async api(path: string, signal: AbortSignal, accept: string, etag?: string | null): Promise { const url = new URL(`${API}/repos/${this.repo}/releases${path}`); if (url.origin !== API || !url.pathname.startsWith(`/repos/${this.repo}/releases`)) throw new Error("bad path"); return this.fetchImpl(url, { redirect: "manual", signal, - headers: { accept, "user-agent": "agent-relay-selfhost", "x-github-api-version": "2022-11-28" }, + headers: { + accept, + "user-agent": "agent-relay-selfhost", + "x-github-api-version": "2022-11-28", + ...(etag ? { "if-none-match": etag } : {}), + }, }); } private async read(signal: AbortSignal): Promise { - const list = await this.api("?per_page=10", signal, "application/vnd.github+json"); + const list = await this.api("?per_page=10", signal, "application/vnd.github+json", this.#lastGood ? this.#etag : null); + if (list.status === 304 && this.#lastGood) { + await list.body?.cancel().catch(() => {}); + return this.#lastGood; + } if (list.status !== 200) { await list.body?.cancel().catch(() => {}); throw new Error(`list ${list.status}`); } + const etag = list.headers.get("etag"); const releases: unknown = JSON.parse(await boundedText(list, MAX_LIST_BYTES)); if (!Array.isArray(releases)) throw new Error("list shape"); // Newest first; drafts are never public, but skip them defensively. @@ -193,10 +226,17 @@ export class GitHubDesktopReleases { await Promise.all(Array.from({ length: Math.min(SIDECAR_CONCURRENCY, candidates.length) }, async () => { while (next < candidates.length) { const { asset, described, url, sidecar } = candidates[next++]!; - const sha256 = await this.sidecar(sidecar, asset.name, signal).catch((error: unknown) => { - if (error instanceof Deadline || signal.aborted) throw error; - return null; - }); + const key = `${sidecar.id}:${sidecar.updated_at ?? ""}`; + let sha256 = this.#digests.get(key) ?? null; + if (!sha256) { + // Throws SidecarUnavailable on any read failure: the whole release is + // then unavailable rather than silently missing installers. + sha256 = await this.sidecar(sidecar, asset.name, signal); + if (sha256) { + if (this.#digests.size >= MAX_ASSETS * 4) this.#digests.clear(); + this.#digests.set(key, sha256); + } + } if (!sha256) continue; // GitHub's own digest, when present, must agree with the sidecar. if (typeof asset.digest === "string" && asset.digest.startsWith("sha256:") && asset.digest.slice(7) !== sha256) continue; @@ -204,6 +244,7 @@ export class GitHubDesktopReleases { } })); assets.sort((a, b) => a.name.localeCompare(b.name)); + this.#etag = etag; return { source: this.empty().source, release: { @@ -217,22 +258,33 @@ export class GitHubDesktopReleases { } /** The 64-hex digest from `.sha256` (` ` or just ``). */ + /** + * The digest, or null when the sidecar's content does not verify this asset + * (that asset is dropped). Throws SidecarUnavailable when it cannot be read. + */ private async sidecar(sidecar: ApiAsset, name: string, signal: AbortSignal): Promise { if (!Number.isSafeInteger(sidecar.id) || sidecar.size > MAX_SIDECAR_BYTES) return null; - const first = await this.api(`/assets/${sidecar.id}`, signal, "application/octet-stream"); - let response = first; - if (first.status === 301 || first.status === 302 || first.status === 307 || first.status === 308) { - await first.body?.cancel().catch(() => {}); - const location = first.headers.get("location"); - const target = location ? new URL(location, API) : null; - if (!target || target.protocol !== "https:" || !SIDECAR_HOST.test(target.hostname)) return null; - response = await this.fetchImpl(target, { redirect: "error", signal, headers: { "user-agent": "agent-relay-selfhost" } }); - } - if (response.status !== 200) { - await response.body?.cancel().catch(() => {}); - return null; + let text: string; + try { + const first = await this.api(`/assets/${sidecar.id}`, signal, "application/octet-stream"); + let response = first; + if (first.status === 301 || first.status === 302 || first.status === 307 || first.status === 308) { + await first.body?.cancel().catch(() => {}); + const location = first.headers.get("location"); + const target = location ? new URL(location, API) : null; + // An off-host redirect is a refusal of this asset, not an outage. + if (!target || target.protocol !== "https:" || !SIDECAR_HOST.test(target.hostname)) return null; + response = await this.fetchImpl(target, { redirect: "error", signal, headers: { "user-agent": "agent-relay-selfhost" } }); + } + if (response.status !== 200) { + await response.body?.cancel().catch(() => {}); + throw new SidecarUnavailable(`sidecar ${response.status}`); + } + text = (await boundedText(response, MAX_SIDECAR_BYTES)).trim(); + } catch (error) { + if (error instanceof SidecarUnavailable) throw error; + throw new SidecarUnavailable(error instanceof Error ? error.message : "sidecar"); } - const text = (await boundedText(response, MAX_SIDECAR_BYTES)).trim(); const [digest, file] = text.split(/\s+/); const hex = digest?.toLowerCase() ?? ""; if (!SHA256.test(hex)) return null; diff --git a/test/desktop-releases.test.ts b/test/desktop-releases.test.ts index 00308a5..aa4b924 100644 --- a/test/desktop-releases.test.ts +++ b/test/desktop-releases.test.ts @@ -62,7 +62,8 @@ function fakeGitHub(options: { redirectHost?: string; listStatus?: number; releases?: unknown[]; -} = {}): Fake { + etag?: string; +} = {}): Fake & { options: typeof options } { const calls: string[] = []; const assets = (options.assets ?? []).map((asset, index) => ({ id: index + 1, @@ -70,17 +71,21 @@ function fakeGitHub(options: { size: asset.size ?? (asset.name.endsWith(".sha256") ? 120 : 1000), url: `https://api.github.com/repos/${REPO}/releases/assets/${index + 1}`, browser_download_url: asset.browser ?? `https://github.com/${REPO}/releases/download/${TAG}/${asset.name}`, + updated_at: "2026-11-11T19:21:31Z", ...(asset.digest ? { digest: asset.digest } : {}), })); const releases = options.releases ?? [ { tag_name: "v9.9.9-draft", draft: true, prerelease: false, published_at: null, assets: [] }, { tag_name: TAG, draft: false, prerelease: true, published_at: "2026-11-11T19:21:31Z", assets }, ]; - const fetchImpl = (async (input: string | URL) => { + const fetchImpl = (async (input: string | URL, init?: RequestInit) => { const url = new URL(String(input)); calls.push(url.toString()); if (url.origin === "https://api.github.com" && url.pathname === `/repos/${REPO}/releases`) { - return new Response(JSON.stringify(releases), { status: options.listStatus ?? 200 }); + const sent = new Headers(init?.headers).get("if-none-match"); + const etag = options.etag ?? '"e1"'; + if (sent && sent === etag && !options.listStatus) return new Response(null, { status: 304 }); + return new Response(JSON.stringify(releases), { status: options.listStatus ?? 200, headers: { etag } }); } const asset = /\/assets\/(\d+)$/.exec(url.pathname); if (url.origin === "https://api.github.com" && asset) { @@ -98,7 +103,7 @@ function fakeGitHub(options: { } return new Response("unexpected", { status: 500 }); }) as typeof fetch; - return { calls, fetch: fetchImpl }; + return { calls, fetch: fetchImpl, options }; } const DMG = "Desktop-q7m2k9-2026.11.1-beta.6-macOS-arm64.dmg"; @@ -180,3 +185,56 @@ test("an upstream failure is release null (cached), never an error", async () => const empty = fakeGitHub({ releases: [] }); assert.equal((await new GitHubDesktopReleases(REPO, empty.fetch).latest()).release, null); }); + +test("configured repo casing may differ from GitHub's canonical download URLs", async () => { + const github = fakeGitHub({ assets: [{ name: DMG }, { name: `${DMG}.sha256` }], sidecars: { [`${DMG}.sha256`]: hex("a") } }); + const lower = REPO.toLowerCase(); + const fetchLower = (async (input: string | URL, init?: RequestInit) => + github.fetch(String(input).replace(`/repos/${lower}/`, `/repos/${REPO}/`), init)) as typeof fetch; + const result = await new GitHubDesktopReleases(lower, fetchLower).latest(); + assert.equal(result.release?.assets.length, 1); + assert.equal(result.release?.assets[0]!.url, `https://github.com/${REPO}/releases/download/${TAG}/${DMG}`); +}); + +test("a checksum outage makes the release unavailable, not smaller; the last good answer is kept", async () => { + const outage = fakeGitHub({ assets: [{ name: DMG }, { name: `${DMG}.sha256` }], sidecars: {} }); + assert.equal((await new GitHubDesktopReleases(REPO, outage.fetch).latest()).release, null); + + const github = fakeGitHub({ assets: [{ name: DMG }, { name: `${DMG}.sha256` }], sidecars: { [`${DMG}.sha256`]: hex("a") } }); + let now = 0; + const releases = new GitHubDesktopReleases(REPO, github.fetch, () => now); + const good = await releases.latest(); + assert.equal(good.release?.assets.length, 1); + github.options.listStatus = 403; // e.g. the unauthenticated quota + now += 5 * 60_000 + 1; + assert.deepEqual(await releases.latest(), good); +}); + +test("refreshes stay inside GitHub's unauthenticated quota: 304 lists and remembered checksums", async () => { + const names = [ + "Linux-x64.deb", "Linux-arm64.deb", "Linux-x64.tar.gz", "Linux-arm64.tar.gz", + "macOS-arm64.dmg", "macOS-x64.dmg", "Windows-x64-Setup.exe", "Windows-arm64-Setup.exe", + ].map((rest) => `Desktop-q7m2k9-2026.11.1-beta.6-${rest}`); + const github = fakeGitHub({ + assets: names.flatMap((name) => [{ name }, { name: `${name}.sha256` }]), + sidecars: Object.fromEntries(names.map((name, index) => [`${name}.sha256`, hex(String(index))])), + }); + let now = 0; + const releases = new GitHubDesktopReleases(REPO, github.fetch, () => now); + assert.equal((await releases.latest()).release?.assets.length, 8); + const api = (from: number) => github.calls.slice(from).filter((call) => new URL(call).hostname === "api.github.com"); + // A full hour of refreshes (12 cache misses): one conditional list each, answered 304. + let mark = github.calls.length; + for (let tick = 0; tick < 12; tick += 1) { + now += 5 * 60_000 + 1; + assert.equal((await releases.latest()).release?.assets.length, 8); + } + assert.equal(api(mark).length, 12); + assert.ok(api(mark).every((call) => new URL(call).pathname === `/repos/${REPO}/releases`)); + // A changed list (new ETag) with the same assets re-reads only the list. + github.options.etag = '"e2"'; + mark = github.calls.length; + now += 5 * 60_000 + 1; + assert.equal((await releases.latest()).release?.assets.length, 8); + assert.equal(github.calls.length - mark, 1); +});