Disclosure: I'm the developer of Relaymote, and this post is published from my account. It was drafted and published by an AI agent working on my behalf from the project's source code. Relaymote is unofficial; not affiliated with or endorsed by Anthropic.

Relaymote is an open-source (MIT) companion that mirrors Claude Code sessions from a PC to a phone browser. Its Windows install keeps itself current from GitHub Releases: every few hours the daemon asks which release is the latest, and if it is newer, downloads the installer, verifies it and runs it when nothing is busy.

"Asks which release is the latest" meant one call to api.github.com/repos/<owner>/<repo>/releases/latest. Unauthenticated, that API allows 60 requests an hour per IP address, and that budget is shared with every other script on the machine that talks to the API without a token. One evening another tool on the same PC polled it in a loop, spent the hour's budget, and for about twenty minutes the update check got HTTP 403 and did nothing.

The fix is small, and the interesting part is not the fallback itself but what it is allowed to change.

Where the latest tag is available without the API

https://github.com/<owner>/<repo>/releases/latest is an ordinary web page, and it answers with a redirect to the newest release's tag page:

HTTP/1.1 302 Found Location: https://github.com/<owner>/<repo>/releases/tag/v0.2.182 

You do not need the body, only the Location header. So the fallback is one request that is never followed:

function latestFromPage(repo) { return new Promise((resolve, reject) => { const req = https.get(`https://github.com/${repo}/releases/latest`, { headers: { 'User-Agent': 'my-updater/' + current() }, timeout: 30000 }, res => { res.resume(); // discard the body; we only want the header const m = /\/releases\/tag\/v?([0-9][0-9A-Za-z.\-]*)$/.exec(String(res.headers.location || '')); if ([301, 302, 303, 307, 308].includes(res.statusCode) && m) return resolve(m[1]); reject(new Error(`no release redirect (HTTP ${res.statusCode})`)); }); req.on('timeout', () => req.destroy(new Error('timed out'))); req.on('error', reject); }); } 

Three details matter:

  • res.resume() drains the response so the socket is released, even though nothing reads it.
  • The regex is anchored to the end of the redirect and requires a digit first. A redirect somewhere else (a login page, a changed URL scheme) is an error, not a version.
  • Any status that is not a redirect is an error. If GitHub ever serves the page directly instead, the updater reports it rather than guessing.

Building the asset URLs from the tag

The API response also listed each asset's download URL. Without it, the URLs have to be constructed, which works because release downloads follow a fixed pattern:

https://github.com/<owner>/<repo>/releases/download/v<version>/<file> 

The updater already knew which file it wants for each kind of install, so it builds exactly three URLs: the installer, SHA256SUMS.txt and SHA256SUMS.txt.sig.

} catch (e) { out.error = e.message; if (/^HTTP (403|429)\b/.test(e.message)) { // rate limited, nothing else try { const latest = await latestFromPage(s.repo); const base = `https://github.com/${s.repo}/releases/download/v${latest}/`; const names = [assetName(kind, latest), 'SHA256SUMS.txt', 'SHA256SUMS.txt.sig'].filter(Boolean); Object.assign(out, { ok: true, latest, newer: cmpVersion(latest, out.current) > 0, assets: Object.fromEntries(names.map(n => [n, base + n])), via: 'release page (API limited)', }); delete out.error; } catch (e2) { out.error += '; release page: ' + e2.message; } } } 

Only 403 and 429 trigger it. A 404 (wrong repository), a DNS failure or a timeout still fail the check, because those are not "the API said no for now", and hiding them would make a broken configuration look healthy.

When both routes fail, the error keeps both reasons (HTTP 403; release page: no release redirect (HTTP 200)), so a log line explains the whole story.

Why the fallback does not weaken anything

The tempting worry is: the API is the "proper" source, and now the version comes from scraping a redirect. Is that less trustworthy?

It is not, because neither source was ever trusted with the installer's integrity. The download step does the same thing whichever route found the version:

  1. Fetch SHA256SUMS.txt and SHA256SUMS.txt.sig.
  2. Verify the signature with an Ed25519 public key that is compiled into the app. The release workflow signs with the matching private key, held as a CI secret. No valid signature, no install.
  3. Find the installer's line in that signed file. Not listed, no install.
  4. Download the installer and compare its SHA-256 with the signed line. Mismatch, delete the partial file, no install.
if (!verifySums(sums, sig, key)) return { ok: false, error: 'SHA256SUMS.txt signature does not verify with the release key: refused' }; const want = listedHash(sums, name); if (!want) return { ok: false, error: `${name} is not listed in the signed SHA256SUMS.txt: refused` }; 

So the worst a wrong version number can do is point at a release whose files fail those checks, and the update is refused. The redirect only answers "which version should I look at?", never "is this file safe to run?". That separation is what made the fallback a ten-line change rather than a security review.

If your updater trusts the API response for anything more than discovery (an asset URL from a third-party mirror, a hash from the same JSON), a fallback like this would be a downgrade. Put the trust in a signature first; then the discovery step can be as simple as you like.

Tests

Four checks were added, with https.get stubbed:

  • API returns 403, the page redirects to v9.9.9: the check succeeds, reports 9.9.9 as newer and records via: 'release page (API limited)'.
  • In the same case, the checksum and signature URLs point at /releases/download/v9.9.9/, and the release page was actually requested.
  • API returns 500: the check fails exactly as before, with no fallback.
  • API returns 403, the page returns 200 with no redirect: the check fails, and the error names both reasons.

What I would do differently from the start

  • Cache the last answer. An update check every few hours does not need a fresh API call when the last one was minutes ago; the budget belongs to the whole machine, not to your process.
  • Treat a shared rate limit as an expected state, not an error. It is not your bug, but it is your outage if the only route is the API.
  • Keep discovery and verification in separate functions. Here, that is why the fallback could not touch the part that matters.

The code is lib/updater.js (check, latestFromPage, fetchVerified) and test/updater.js in the repository; the change shipped in 0.2.182.

Relaymote is unofficial; not affiliated with or endorsed by Anthropic.