From 80752a633b5dc2a7ee734446a70aad1dfa2f2837 Mon Sep 17 00:00:00 2001 From: tbontb-iaq Date: Thu, 30 Jul 2026 23:02:07 +0800 Subject: [PATCH 1/3] fix(install): authenticate api.github.com call to avoid 60/hr rate limit All three installers called api.github.com/repos/.../releases/latest unauthenticated to resolve the latest release tag. GitHub caps unauthenticated REST API requests at 60/hour per source IP (shared across NAT/CGNAT/corporate proxy users), causing opaque 'Failed to fetch latest version' errors. Each installer now detects a token (precedence: GITHUB_TOKEN env > GH_TOKEN env > 'gh auth token') and attaches it as an Authorization: Bearer header to ONLY the version-resolution API call. Falls back to anonymous (unchanged behavior) when no token is available. Release downloads and git clone are left unauthenticated - they are not subject to the 60/hr REST limit. Token variables are cleared from memory immediately after the API call (defense in depth). install.ps1 wraps the API call in try/catch to surface a friendlier error instead of a raw terminating exception under $ErrorActionPreference=Stop. Docs updated with a rate-limited-IP install section and a troubleshooting entry. Parity + negative-assertion tests added. Closes #1156 --- .../docs/getting-started/installation.md | 16 +++++ .../content/docs/guides/troubleshooting.md | 17 ++++++ scripts/install.cmd | 23 +++++++- scripts/install.ps1 | 22 ++++++- scripts/install.sh | 21 ++++++- scripts/install.test.ts | 59 +++++++++++++++++++ 6 files changed, 155 insertions(+), 3 deletions(-) diff --git a/apps/marketing/src/content/docs/getting-started/installation.md b/apps/marketing/src/content/docs/getting-started/installation.md index 51328f7bd..d79d634f7 100644 --- a/apps/marketing/src/content/docs/getting-started/installation.md +++ b/apps/marketing/src/content/docs/getting-started/installation.md @@ -83,6 +83,22 @@ For `curl … | bash` pipelines you can set `PLANNOTATOR_MINIMAL=1` in the envir +
+Installing behind a rate-limited IP + +The installer queries the GitHub API (`api.github.com`) to resolve the latest release tag. Unauthenticated API requests are capped at **60 per hour per source IP**, so installs can fail on shared egress IPs (corporate proxies, NAT/CGNAT, CI runners) or when retrying/debugging within the same hour, with an opaque `Failed to fetch latest version` error. + +If you hit this, export a token before running the installer - it reads `GITHUB_TOKEN`, then `GH_TOKEN`, and falls back to `gh auth token` when the `gh` CLI is authenticated (raising the limit to 5000/hour): + +```bash +export GITHUB_TOKEN=ghp_xxx +curl -fsSL https://plannotator.ai/install.sh | bash +``` + +Or authenticate once with `gh auth login` - no env var needed, the installer picks the token up automatically. Only the version-resolution API call is authenticated; release downloads and `git clone` are unaffected. See [Troubleshooting](/docs/guides/troubleshooting/) for details. + +
+ Every release includes SHA256 checksums (verified automatically) and optional [SLSA build provenance](/docs/reference/verifying-your-install/) attestations. ## Claude Code diff --git a/apps/marketing/src/content/docs/guides/troubleshooting.md b/apps/marketing/src/content/docs/guides/troubleshooting.md index d4acdf75a..2978d8b33 100644 --- a/apps/marketing/src/content/docs/guides/troubleshooting.md +++ b/apps/marketing/src/content/docs/guides/troubleshooting.md @@ -6,6 +6,23 @@ sidebar: section: "Guides" --- +## Installer fails with "Failed to fetch latest version" + +The installer queries the GitHub API (`api.github.com`) to resolve the latest release tag. Unauthenticated API requests are capped at **60 per hour per source IP** - not per user - so the install fails (with `Failed to fetch latest version` on macOS/Linux/WSL, `Failed to get latest version` on Windows) on shared egress IPs (corporate proxies, NAT/CGNAT, CI runners) or when retrying within the same hour. + +Provide a token and the installer attaches it to that API call automatically (raising the limit to 5000/hour). It reads, in order: + +1. `GITHUB_TOKEN` env var +2. `GH_TOKEN` env var +3. `gh auth token` (when the `gh` CLI is installed and authenticated) + +```bash +export GITHUB_TOKEN=ghp_xxx +curl -fsSL https://plannotator.ai/install.sh | bash +``` + +Or run `gh auth login` once - no env var needed. To pin a specific version instead (which skips the API call entirely), use `--version vX.Y.Z`. Only the version-resolution call is authenticated; release downloads and `git clone` are unaffected. See [issue #1156](https://github.com/backnotprop/plannotator/issues/1156). + ## Lost a Plannotator tab? If you accidentally close a Plannotator browser tab, the server is still running in the background. You can find and reopen it: diff --git a/scripts/install.cmd b/scripts/install.cmd index dd64a0b54..ca6b2f9a2 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -204,11 +204,32 @@ REM Get version to install if /i "!VERSION!"=="latest" ( echo Fetching latest version... + REM api.github.com caps unauthenticated requests at 60/hour per source IP, + REM which fails installs behind shared egress IPs (NAT/CGNAT/corporate + REM proxies) and during repeated/debug runs within an hour. Attach an + REM Authorization header when a token is available (raises the limit to + REM 5000/hour); when none is found, fall back to anonymous (unchanged + REM behavior). Precedence matches `gh`: GITHUB_TOKEN > GH_TOKEN > gh auth token. + set "GH_TOKEN_VAL=" + if defined GITHUB_TOKEN set "GH_TOKEN_VAL=%GITHUB_TOKEN%" + if not defined GH_TOKEN_VAL if defined GH_TOKEN set "GH_TOKEN_VAL=%GH_TOKEN%" + if not defined GH_TOKEN_VAL ( + for /f "delims=" %%i in ('gh auth token 2^>nul') do set "GH_TOKEN_VAL=%%i" + ) + if defined GH_TOKEN_VAL ( + set "GH_AUTH_HEADER=-H "Authorization: Bearer !GH_TOKEN_VAL!"" + ) else ( + set "GH_AUTH_HEADER=" + ) + REM Download release info to a randomized temp file so concurrent REM invocations don't collide and a same-user pre-placed symlink at REM a predictable path can't redirect curl's output. set "RELEASE_JSON=%TEMP%\plannotator-release-%RANDOM%.json" - curl -fsSL "https://api.github.com/repos/!REPO!/releases/latest" -o "!RELEASE_JSON!" + curl -fsSL !GH_AUTH_HEADER! "https://api.github.com/repos/!REPO!/releases/latest" -o "!RELEASE_JSON!" + REM Clear token from memory - not needed for release downloads or git clone. + set "GH_TOKEN_VAL=" + set "GH_AUTH_HEADER=" if !ERRORLEVEL! neq 0 ( echo Failed to get latest version >&2 exit /b 1 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 2a6ec7bda..8f5ab26c6 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -99,7 +99,27 @@ foreach ($oldPath in $oldLocations) { if ($Version -eq "latest") { Write-Host "Fetching latest version..." - $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/latest" + + # api.github.com caps unauthenticated requests at 60/hour per source IP, + # which fails installs behind shared egress IPs (NAT/CGNAT/corporate + # proxies) and during repeated/debug runs within an hour. Attach an + # Authorization header when a token is available (raises the limit to + # 5000/hour); when none is found, fall back to anonymous (unchanged + # behavior). Precedence matches `gh`: GITHUB_TOKEN > GH_TOKEN > gh auth token. + $ghToken = $env:GITHUB_TOKEN + if (-not $ghToken) { $ghToken = $env:GH_TOKEN } + if (-not $ghToken -and (Get-Command gh -ErrorAction SilentlyContinue)) { + try { $ghToken = (gh auth token 2>$null) } catch { } + } + $ghHeaders = if ($ghToken) { @{ Authorization = "Bearer $ghToken" } } else { @{} } + try { + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/latest" -Headers $ghHeaders + } catch { + Write-Error "Failed to fetch latest version (the GitHub API may be rate-limiting your IP; see https://github.com/backnotprop/plannotator/issues/1156)" + exit 1 + } + # Clear token from memory - not needed for release downloads or git clone. + $ghToken = $null; $ghHeaders = $null $latestTag = $release.tag_name if (-not $latestTag) { diff --git a/scripts/install.sh b/scripts/install.sh index f2384bc0f..42ba751c1 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -287,7 +287,26 @@ fi if [ "$VERSION" = "latest" ]; then echo "Fetching latest version..." - latest_tag=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4) + + # api.github.com caps unauthenticated requests at 60/hour per source IP, + # which fails installs behind shared egress IPs (NAT/CGNAT/corporate + # proxies) and during repeated/debug runs within an hour. Attach an + # Authorization header when a token is available (raises the limit to + # 5000/hour); when none is found, fall back to anonymous (unchanged + # behavior). Precedence matches `gh`: GITHUB_TOKEN > GH_TOKEN > gh auth token. + GH_AUTH_HEADER=() + if [ -n "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then + GH_AUTH_HEADER=(-H "Authorization: Bearer ${GITHUB_TOKEN:-${GH_TOKEN}}") + elif command -v gh >/dev/null 2>&1; then + if _gh_token="$(gh auth token 2>/dev/null)" && [ -n "$_gh_token" ]; then + GH_AUTH_HEADER=(-H "Authorization: Bearer ${_gh_token}") + fi + fi + latest_tag=$(curl -fsSL "${GH_AUTH_HEADER[@]}" "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4) + # Clear the token from memory immediately after use - it is not needed + # for the release downloads or git clone (which are not rate-limited at + # 60/hr and don't need auth). Defense in depth against env leakage. + unset _gh_token GH_AUTH_HEADER if [ -z "$latest_tag" ]; then echo "Failed to fetch latest version" >&2 diff --git a/scripts/install.test.ts b/scripts/install.test.ts index a064b517e..877a8c274 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -1274,6 +1274,65 @@ describe("install shared behavior", () => { expect(guardIdx).toBeGreaterThan(-1); expect(execIdx).toBeGreaterThan(guardIdx); }); + + test("all installers authenticate the api.github.com version-resolution call", () => { + // api.github.com caps unauthenticated requests at 60/hour per source IP + // (not per user), which fails installs behind shared egress IPs + // (NAT/CGNAT/corporate proxies) and during repeated/debug runs within an + // hour. Each installer must attach an Authorization header to the + // releases/latest call when a token is available, using the same + // precedence across platforms: GITHUB_TOKEN > GH_TOKEN > `gh auth token`. + // When no token is found it must fall back to anonymous (unchanged + // behavior). See backnotprop/plannotator#1156. + const cmdScript = readScript("install.cmd"); + + // Shared precedence + bearer scheme across all three installers. + for (const [name, script] of [["install.sh", sh], ["install.ps1", ps], ["install.cmd", cmdScript]] as const) { + expect(script, `${name} should read GITHUB_TOKEN`).toContain("GITHUB_TOKEN"); + expect(script, `${name} should read GH_TOKEN`).toContain("GH_TOKEN"); + expect(script, `${name} should use a Bearer scheme`).toContain("Bearer "); + expect(script, `${name} should fall back to gh auth token`).toContain("gh auth token"); + } + + // install.sh: header array splatted into the api curl. + expect(sh).toContain("GH_AUTH_HEADER=()"); + expect(sh).toContain('"${GH_AUTH_HEADER[@]}" "https://api.github.com/repos/${REPO}/releases/latest"'); + + // install.ps1: hashtable passed via -Headers to Invoke-RestMethod. + expect(ps).toContain('$ghHeaders = if ($ghToken) { @{ Authorization = "Bearer $ghToken" } } else { @{} }'); + expect(ps).toContain("-Headers $ghHeaders"); + + // install.cmd: arg splatted into the api curl via delayed expansion. + expect(cmdScript).toContain('set "GH_AUTH_HEADER=-H "Authorization: Bearer !GH_TOKEN_VAL!""'); + expect(cmdScript).toContain('curl -fsSL !GH_AUTH_HEADER! "https://api.github.com/repos/!REPO!/releases/latest"'); + }); + + test("auth header is used ONLY on the api.github.com call, never on downloads", () => { + // The 60/hr REST limit applies only to api.github.com. Release-asset + // downloads (github.com/.../releases/download) are not subject to it, + // and authing those would expose the token in argv/process list for the + // full duration of a large binary download. Tripwire: if a future edit + // splats the auth header into any download curl, these fail. See + // backnotprop/plannotator#1156. + const cmdScript = readScript("install.cmd"); + + // install.sh: binary download and checksum download must NOT carry the + // auth header array. Token vars cleared right after the api call so + // they don't linger for the download phase. + expect(sh).not.toContain('curl -fsSL "${GH_AUTH_HEADER[@]}" -o "$tmp_file"'); + expect(sh).not.toContain('curl -fsSL "${GH_AUTH_HEADER[@]}" "$checksum_url"'); + expect(sh).toContain("unset _gh_token GH_AUTH_HEADER"); + + // install.ps1: binary download must NOT carry $ghHeaders, which is nulled. + expect(ps).not.toContain('Invoke-WebRequest -Uri $binaryUrl -OutFile $tmpFile -Headers $ghHeaders'); + expect(ps).toContain('$ghToken = $null; $ghHeaders = $null'); + + // install.cmd: binary download must NOT carry !GH_AUTH_HEADER!. Token + // vars cleared after the api call. + expect(cmdScript).not.toContain('curl -fsSL !GH_AUTH_HEADER! "!BINARY_URL!"'); + expect(cmdScript).toContain('set "GH_TOKEN_VAL="'); + expect(cmdScript).toContain('set "GH_AUTH_HEADER="'); + }); }); describe("PlannotatorConfig schema", () => { From 96fe40f6c36e3188893e7826e0551fd75cf3ba4c Mon Sep 17 00:00:00 2001 From: tbontb-iaq Date: Thu, 30 Jul 2026 23:09:31 +0800 Subject: [PATCH 2/3] fix(install): retry anonymously when authenticated api call fails Addresses review feedback on #1157: a stale/revoked token (expired GITHUB_TOKEN lingering in CI images, dotfiles, direnv) gets a 401 and would break an install that works fine anonymously today. Each installer now retries the api.github.com version-resolution call without the auth header when the authenticated attempt fails: - install.sh: `|| true` + `[ -z "$latest_tag" ]` gate - install.ps1: try/catch, retries with no -Headers on catch - install.cmd: ERRORLEVEL check, retries without !GH_AUTH_HEADER! A bad token costs one extra request; nobody is worse off than today. New test asserts the retry path exists in all three installers (89 pass). Also corrects the AI disclosure: the assistant was opencode, not Claude. --- scripts/install.cmd | 9 +++++++++ scripts/install.ps1 | 17 +++++++++++++---- scripts/install.sh | 16 +++++++++++----- scripts/install.test.ts | 34 +++++++++++++++++++++++++++++++--- 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/scripts/install.cmd b/scripts/install.cmd index ca6b2f9a2..23d452ee1 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -227,6 +227,15 @@ if /i "!VERSION!"=="latest" ( REM a predictable path can't redirect curl's output. set "RELEASE_JSON=%TEMP%\plannotator-release-%RANDOM%.json" curl -fsSL !GH_AUTH_HEADER! "https://api.github.com/repos/!REPO!/releases/latest" -o "!RELEASE_JSON!" + REM A stale/revoked token (expired GITHUB_TOKEN lingering in CI images, + REM dotfiles, direnv) gets a 401 here and would break an install that + REM works fine anonymously today. If the authenticated call failed and + REM we had a token, retry once without the header so a bad token costs + REM one extra request but never blocks an otherwise-working install. + REM See backnotprop/plannotator#1157. + if !ERRORLEVEL! neq 0 if defined GH_AUTH_HEADER ( + curl -fsSL "https://api.github.com/repos/!REPO!/releases/latest" -o "!RELEASE_JSON!" + ) REM Clear token from memory - not needed for release downloads or git clone. set "GH_TOKEN_VAL=" set "GH_AUTH_HEADER=" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 8f5ab26c6..114b7b4b9 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -112,14 +112,23 @@ if ($Version -eq "latest") { try { $ghToken = (gh auth token 2>$null) } catch { } } $ghHeaders = if ($ghToken) { @{ Authorization = "Bearer $ghToken" } } else { @{} } + # A stale/revoked token (expired GITHUB_TOKEN lingering in CI images, + # dotfiles, direnv) gets a 401 here and would break an install that + # works fine anonymously today. If the authenticated call fails, retry + # once without the header so a bad token costs one extra request but + # never blocks an otherwise-working install. See backnotprop/plannotator#1157. + $apiUrl = "https://api.github.com/repos/$repo/releases/latest" try { - $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/latest" -Headers $ghHeaders + $release = Invoke-RestMethod -Uri $apiUrl -Headers $ghHeaders } catch { - Write-Error "Failed to fetch latest version (the GitHub API may be rate-limiting your IP; see https://github.com/backnotprop/plannotator/issues/1156)" - exit 1 + if ($ghHeaders.Count -eq 0) { + Write-Error "Failed to fetch latest version (the GitHub API may be rate-limiting your IP; see https://github.com/backnotprop/plannotator/issues/1156)" + exit 1 + } + $release = Invoke-RestMethod -Uri $apiUrl } # Clear token from memory - not needed for release downloads or git clone. - $ghToken = $null; $ghHeaders = $null + $ghToken = $null; $ghHeaders = $null; $apiUrl = $null $latestTag = $release.tag_name if (-not $latestTag) { diff --git a/scripts/install.sh b/scripts/install.sh index 42ba751c1..be58af5bf 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -302,11 +302,17 @@ if [ "$VERSION" = "latest" ]; then GH_AUTH_HEADER=(-H "Authorization: Bearer ${_gh_token}") fi fi - latest_tag=$(curl -fsSL "${GH_AUTH_HEADER[@]}" "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4) - # Clear the token from memory immediately after use - it is not needed - # for the release downloads or git clone (which are not rate-limited at - # 60/hr and don't need auth). Defense in depth against env leakage. - unset _gh_token GH_AUTH_HEADER + # A stale/revoked token (expired GITHUB_TOKEN lingering in CI images, + # dotfiles, direnv) gets a 401 here and would break an install that + # works fine anonymously today. If the authenticated call fails, retry + # once without the header so a bad token costs one extra request but + # never blocks an otherwise-working install. See backnotprop/plannotator#1157. + _api_url="https://api.github.com/repos/${REPO}/releases/latest" + latest_tag=$(curl -fsSL "${GH_AUTH_HEADER[@]}" "$_api_url" | grep '"tag_name"' | cut -d'"' -f4) || true + if [ -z "$latest_tag" ] && [ ${#GH_AUTH_HEADER[@]} -gt 0 ]; then + latest_tag=$(curl -fsSL "$_api_url" | grep '"tag_name"' | cut -d'"' -f4) + fi + unset _gh_token GH_AUTH_HEADER _api_url if [ -z "$latest_tag" ]; then echo "Failed to fetch latest version" >&2 diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 877a8c274..60aaa2942 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -1296,7 +1296,7 @@ describe("install shared behavior", () => { // install.sh: header array splatted into the api curl. expect(sh).toContain("GH_AUTH_HEADER=()"); - expect(sh).toContain('"${GH_AUTH_HEADER[@]}" "https://api.github.com/repos/${REPO}/releases/latest"'); + expect(sh).toContain('"${GH_AUTH_HEADER[@]}" "$_api_url"'); // install.ps1: hashtable passed via -Headers to Invoke-RestMethod. expect(ps).toContain('$ghHeaders = if ($ghToken) { @{ Authorization = "Bearer $ghToken" } } else { @{} }'); @@ -1321,11 +1321,11 @@ describe("install shared behavior", () => { // they don't linger for the download phase. expect(sh).not.toContain('curl -fsSL "${GH_AUTH_HEADER[@]}" -o "$tmp_file"'); expect(sh).not.toContain('curl -fsSL "${GH_AUTH_HEADER[@]}" "$checksum_url"'); - expect(sh).toContain("unset _gh_token GH_AUTH_HEADER"); + expect(sh).toContain("unset _gh_token GH_AUTH_HEADER _api_url"); // install.ps1: binary download must NOT carry $ghHeaders, which is nulled. expect(ps).not.toContain('Invoke-WebRequest -Uri $binaryUrl -OutFile $tmpFile -Headers $ghHeaders'); - expect(ps).toContain('$ghToken = $null; $ghHeaders = $null'); + expect(ps).toContain('$ghToken = $null; $ghHeaders = $null; $apiUrl = $null'); // install.cmd: binary download must NOT carry !GH_AUTH_HEADER!. Token // vars cleared after the api call. @@ -1333,6 +1333,34 @@ describe("install shared behavior", () => { expect(cmdScript).toContain('set "GH_TOKEN_VAL="'); expect(cmdScript).toContain('set "GH_AUTH_HEADER="'); }); + + test("all installers retry anonymously if the authenticated api call fails", () => { + // A stale/revoked token (expired GITHUB_TOKEN lingering in CI images, + // dotfiles, direnv) gets a 401 and would break an install that works + // fine anonymously today. Each installer must retry the api.github.com + // call without the auth header when the authenticated attempt fails, + // so a bad token costs one extra request but never blocks an otherwise + // working install. See backnotprop/plannotator#1157 (review feedback). + const cmdScript = readScript("install.cmd"); + + // install.sh: `|| true` swallows the authed curl failure, then a + // `[ -z "$latest_tag" ]` gate retries without the header array. + expect(sh).toContain('|| true'); + expect(sh).toContain('if [ -z "$latest_tag" ] && [ ${#GH_AUTH_HEADER[@]} -gt 0 ]; then'); + expect(sh).toContain('latest_tag=$(curl -fsSL "$_api_url" | grep'); + + // install.ps1: try/catch around Invoke-RestMethod; on catch, when a + // header was present, retry with no -Headers. + expect(ps).toContain('} catch {'); + expect(ps).toContain("if ($ghHeaders.Count -eq 0)"); + expect(ps).toContain("Write-Error"); + expect(ps).toContain("$release = Invoke-RestMethod -Uri $apiUrl"); + + // install.cmd: ERRORLEVEL check after the authed curl; when a header + // was set, retry without it. + expect(cmdScript).toContain('if !ERRORLEVEL! neq 0 if defined GH_AUTH_HEADER ('); + expect(cmdScript).toContain('curl -fsSL "https://api.github.com/repos/!REPO!/releases/latest" -o "!RELEASE_JSON!"'); + }); }); describe("PlannotatorConfig schema", () => { From 02a0dddc9392d0a4b2cde559606c1837e6c9928e Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 30 Jul 2026 10:23:10 -0700 Subject: [PATCH 3/3] fix(install): address second-review findings Errorlevel check adjacency in install.cmd, delayed-expansion token reads, token charset guard, PATH-resolved gh with --hostname github.com, 401-only anonymous retry in install.sh and install.ps1, preserved original errors in install.ps1, honest cleanup comments, non-vacuous test assertions, and doc corrections (1000/hr Actions token, zero-scope guidance, -Version for PowerShell). --- .../docs/getting-started/installation.md | 2 +- .../content/docs/guides/troubleshooting.md | 6 +- scripts/install.cmd | 56 +++++++++-- scripts/install.ps1 | 33 +++++-- scripts/install.sh | 32 ++++-- scripts/install.test.ts | 97 +++++++++++++++---- 6 files changed, 178 insertions(+), 48 deletions(-) diff --git a/apps/marketing/src/content/docs/getting-started/installation.md b/apps/marketing/src/content/docs/getting-started/installation.md index d79d634f7..27a4e1736 100644 --- a/apps/marketing/src/content/docs/getting-started/installation.md +++ b/apps/marketing/src/content/docs/getting-started/installation.md @@ -88,7 +88,7 @@ For `curl … | bash` pipelines you can set `PLANNOTATOR_MINIMAL=1` in the envir The installer queries the GitHub API (`api.github.com`) to resolve the latest release tag. Unauthenticated API requests are capped at **60 per hour per source IP**, so installs can fail on shared egress IPs (corporate proxies, NAT/CGNAT, CI runners) or when retrying/debugging within the same hour, with an opaque `Failed to fetch latest version` error. -If you hit this, export a token before running the installer - it reads `GITHUB_TOKEN`, then `GH_TOKEN`, and falls back to `gh auth token` when the `gh` CLI is authenticated (raising the limit to 5000/hour): +If you hit this, export a token before running the installer - it reads `GITHUB_TOKEN`, then `GH_TOKEN`, and falls back to `gh auth token` (github.com credentials only) when the `gh` CLI is authenticated. A personal access token raises the limit to 5,000/hour; the built-in `GITHUB_TOKEN` in GitHub Actions gets 1,000/hour per repository. The repository is public, so a token with no scopes is sufficient - prefer a fine-grained or zero-scope token over a broad classic PAT: ```bash export GITHUB_TOKEN=ghp_xxx diff --git a/apps/marketing/src/content/docs/guides/troubleshooting.md b/apps/marketing/src/content/docs/guides/troubleshooting.md index 2978d8b33..6b9a1c085 100644 --- a/apps/marketing/src/content/docs/guides/troubleshooting.md +++ b/apps/marketing/src/content/docs/guides/troubleshooting.md @@ -10,18 +10,18 @@ section: "Guides" The installer queries the GitHub API (`api.github.com`) to resolve the latest release tag. Unauthenticated API requests are capped at **60 per hour per source IP** - not per user - so the install fails (with `Failed to fetch latest version` on macOS/Linux/WSL, `Failed to get latest version` on Windows) on shared egress IPs (corporate proxies, NAT/CGNAT, CI runners) or when retrying within the same hour. -Provide a token and the installer attaches it to that API call automatically (raising the limit to 5000/hour). It reads, in order: +Provide a token and the installer attaches it to that API call automatically. A personal access token raises the limit to 5,000/hour; the built-in `GITHUB_TOKEN` in GitHub Actions gets 1,000/hour per repository. The repository is public, so a token with no scopes is sufficient - prefer a fine-grained or zero-scope token over a broad classic PAT. The installer reads, in order: 1. `GITHUB_TOKEN` env var 2. `GH_TOKEN` env var -3. `gh auth token` (when the `gh` CLI is installed and authenticated) +3. `gh auth token` for github.com (when the `gh` CLI is installed and authenticated; a GitHub Enterprise default host is never used for this call) ```bash export GITHUB_TOKEN=ghp_xxx curl -fsSL https://plannotator.ai/install.sh | bash ``` -Or run `gh auth login` once - no env var needed. To pin a specific version instead (which skips the API call entirely), use `--version vX.Y.Z`. Only the version-resolution call is authenticated; release downloads and `git clone` are unaffected. See [issue #1156](https://github.com/backnotprop/plannotator/issues/1156). +Or run `gh auth login` once - no env var needed. To pin a specific version instead (which skips the API call entirely), pass `--version vX.Y.Z` on macOS/Linux (`curl -fsSL https://plannotator.ai/install.sh | bash -s -- --version vX.Y.Z`) or `-Version vX.Y.Z` with the PowerShell installer. Only the version-resolution call is authenticated; release downloads and `git clone` are unaffected. See [issue #1156](https://github.com/backnotprop/plannotator/issues/1156). ## Lost a Plannotator tab? diff --git a/scripts/install.cmd b/scripts/install.cmd index 23d452ee1..d44ff4754 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -210,11 +210,44 @@ if /i "!VERSION!"=="latest" ( REM Authorization header when a token is available (raises the limit to REM 5000/hour); when none is found, fall back to anonymous (unchanged REM behavior). Precedence matches `gh`: GITHUB_TOKEN > GH_TOKEN > gh auth token. + REM Read the env vars via delayed expansion, never percent expansion, so + REM the value is not re-parsed by cmd's phase-1 expansion - a value + REM containing metacharacters or exclamation marks can neither inject + REM commands nor be corrupted here. set "GH_TOKEN_VAL=" - if defined GITHUB_TOKEN set "GH_TOKEN_VAL=%GITHUB_TOKEN%" - if not defined GH_TOKEN_VAL if defined GH_TOKEN set "GH_TOKEN_VAL=%GH_TOKEN%" + if defined GITHUB_TOKEN set "GH_TOKEN_VAL=!GITHUB_TOKEN!" + if not defined GH_TOKEN_VAL if defined GH_TOKEN set "GH_TOKEN_VAL=!GH_TOKEN!" if not defined GH_TOKEN_VAL ( - for /f "delims=" %%i in ('gh auth token 2^>nul') do set "GH_TOKEN_VAL=%%i" + REM Resolve gh via `where` and invoke the absolute path so the for /f + REM command line never runs a bare `gh` name, which cmd would resolve + REM from the current directory first. (`where` itself also searches + REM the CWD first - accepted limitation, documented here.) + REM --hostname github.com scopes the fallback to github.com + REM credentials, so a gh setup whose default host is a GitHub + REM Enterprise server never leaks a GHES token to api.github.com. On + REM an ancient gh without the flag, stderr is swallowed and we fall + REM back to anonymous. + set "GH_EXE=" + for /f "delims=" %%g in ('where gh 2^>nul') do if not defined GH_EXE set "GH_EXE=%%g" + if defined GH_EXE ( + for /f "delims=" %%i in ('"!GH_EXE!" auth token --hostname github.com 2^>nul') do set "GH_TOKEN_VAL=%%i" + ) + set "GH_EXE=" + ) + REM Charset allowlist: GitHub tokens are [A-Za-z0-9_] (plus - to be + REM safe). Strip every allowed character (cmd substitution is + REM case-insensitive, covering A-Z too); anything left over means an + REM unexpected character - a quote in particular could break out of the + REM quoted Authorization header on the curl line below - so drop the + REM token and go anonymous. Done with pure delayed-expansion + REM substitutions: a findstr pipe cannot be used because delayed + REM expansion does not survive into pipe children, and writing the token + REM to a temp file for findstr would leak the secret to disk. + if defined GH_TOKEN_VAL ( + set "TOKEN_RESIDUE=!GH_TOKEN_VAL!" + for %%c in (a b c d e f g h i j k l m n o p q r s t u v w x y z 0 1 2 3 4 5 6 7 8 9 _ -) do if defined TOKEN_RESIDUE set "TOKEN_RESIDUE=!TOKEN_RESIDUE:%%c=!" + if defined TOKEN_RESIDUE set "GH_TOKEN_VAL=" + set "TOKEN_RESIDUE=" ) if defined GH_TOKEN_VAL ( set "GH_AUTH_HEADER=-H "Authorization: Bearer !GH_TOKEN_VAL!"" @@ -232,17 +265,26 @@ if /i "!VERSION!"=="latest" ( REM works fine anonymously today. If the authenticated call failed and REM we had a token, retry once without the header so a bad token costs REM one extra request but never blocks an otherwise-working install. - REM See backnotprop/plannotator#1157. + REM Note: install.sh / install.ps1 inspect the HTTP status and retry + REM only on 401; capturing the status portably in batch is not worth the + REM complexity, so cmd retries on any failure when a token was used - an + REM accepted cmd-only compromise. See backnotprop/plannotator#1157. + REM Both ERRORLEVEL reads below sit immediately adjacent to the curl + REM they test (only REM lines and a no-op if in between); the token + REM clears deliberately come AFTER the failure check because `set` can + REM disturb ERRORLEVEL. if !ERRORLEVEL! neq 0 if defined GH_AUTH_HEADER ( curl -fsSL "https://api.github.com/repos/!REPO!/releases/latest" -o "!RELEASE_JSON!" ) - REM Clear token from memory - not needed for release downloads or git clone. - set "GH_TOKEN_VAL=" - set "GH_AUTH_HEADER=" if !ERRORLEVEL! neq 0 ( echo Failed to get latest version >&2 exit /b 1 ) + REM Drop the local token copies; downloads and git clone are anonymous. + REM GITHUB_TOKEN / GH_TOKEN themselves remain in the environment exactly + REM as the user set them. + set "GH_TOKEN_VAL=" + set "GH_AUTH_HEADER=" REM Extract tag_name from JSON for /f "tokens=2 delims=:," %%i in ('findstr /c:"\"tag_name\"" "!RELEASE_JSON!"') do ( diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 114b7b4b9..a1fbda7ba 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -109,25 +109,42 @@ if ($Version -eq "latest") { $ghToken = $env:GITHUB_TOKEN if (-not $ghToken) { $ghToken = $env:GH_TOKEN } if (-not $ghToken -and (Get-Command gh -ErrorAction SilentlyContinue)) { - try { $ghToken = (gh auth token 2>$null) } catch { } + # --hostname github.com scopes the fallback to github.com credentials, + # so a gh setup whose default host is a GitHub Enterprise server never + # leaks a GHES token to api.github.com. On an ancient gh without the + # flag, stderr is swallowed and we fall back to anonymous. + try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { } } $ghHeaders = if ($ghToken) { @{ Authorization = "Bearer $ghToken" } } else { @{} } # A stale/revoked token (expired GITHUB_TOKEN lingering in CI images, # dotfiles, direnv) gets a 401 here and would break an install that - # works fine anonymously today. If the authenticated call fails, retry - # once without the header so a bad token costs one extra request but - # never blocks an otherwise-working install. See backnotprop/plannotator#1157. + # works fine anonymously today. Retry anonymously ONLY on HTTP 401: + # requests carrying invalid credentials count against the anonymous + # 60/hour per-IP pool, so a blind retry on any failure would double the + # burn, and network failures gain nothing from a second attempt. The + # [int] cast handles both Windows PowerShell 5.1 (HttpWebResponse enum) + # and PowerShell 7 (HttpResponseMessage); the inner try guards a null + # Response (e.g. DNS failure). See backnotprop/plannotator#1157. $apiUrl = "https://api.github.com/repos/$repo/releases/latest" try { $release = Invoke-RestMethod -Uri $apiUrl -Headers $ghHeaders } catch { - if ($ghHeaders.Count -eq 0) { - Write-Error "Failed to fetch latest version (the GitHub API may be rate-limiting your IP; see https://github.com/backnotprop/plannotator/issues/1156)" + $status = $null + try { $status = [int]$_.Exception.Response.StatusCode } catch { } + if ($ghHeaders.Count -gt 0 -and $status -eq 401) { + try { + $release = Invoke-RestMethod -Uri $apiUrl + } catch { + Write-Error "Failed to fetch latest version: $($_.Exception.Message)" + exit 1 + } + } else { + Write-Error "Failed to fetch latest version: $($_.Exception.Message) (if this is HTTP 403, the GitHub API may be rate-limiting your IP; see https://github.com/backnotprop/plannotator/issues/1156)" exit 1 } - $release = Invoke-RestMethod -Uri $apiUrl } - # Clear token from memory - not needed for release downloads or git clone. + # Drop the local token copies; GITHUB_TOKEN / GH_TOKEN themselves remain + # in the environment exactly as the user set them. $ghToken = $null; $ghHeaders = $null; $apiUrl = $null $latestTag = $release.tag_name diff --git a/scripts/install.sh b/scripts/install.sh index be58af5bf..7468cee5b 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -298,21 +298,37 @@ if [ "$VERSION" = "latest" ]; then if [ -n "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then GH_AUTH_HEADER=(-H "Authorization: Bearer ${GITHUB_TOKEN:-${GH_TOKEN}}") elif command -v gh >/dev/null 2>&1; then - if _gh_token="$(gh auth token 2>/dev/null)" && [ -n "$_gh_token" ]; then + # --hostname github.com scopes the fallback to github.com credentials, + # so a gh setup whose default host is a GitHub Enterprise server never + # leaks a GHES token to api.github.com. On an ancient gh without the + # flag, stderr is swallowed and we fall back to anonymous. + if _gh_token="$(gh auth token --hostname github.com 2>/dev/null)" && [ -n "$_gh_token" ]; then GH_AUTH_HEADER=(-H "Authorization: Bearer ${_gh_token}") fi fi # A stale/revoked token (expired GITHUB_TOKEN lingering in CI images, # dotfiles, direnv) gets a 401 here and would break an install that - # works fine anonymously today. If the authenticated call fails, retry - # once without the header so a bad token costs one extra request but - # never blocks an otherwise-working install. See backnotprop/plannotator#1157. + # works fine anonymously today. Retry anonymously ONLY on HTTP 401: + # requests carrying invalid credentials count against the anonymous + # 60/hour per-IP pool, so a blind retry on any failure would double the + # burn, and network failures gain nothing from a second attempt. + # Note: no -f here, so a 401 body doesn't abort curl before -w prints + # the status code. See backnotprop/plannotator#1157. _api_url="https://api.github.com/repos/${REPO}/releases/latest" - latest_tag=$(curl -fsSL "${GH_AUTH_HEADER[@]}" "$_api_url" | grep '"tag_name"' | cut -d'"' -f4) || true - if [ -z "$latest_tag" ] && [ ${#GH_AUTH_HEADER[@]} -gt 0 ]; then - latest_tag=$(curl -fsSL "$_api_url" | grep '"tag_name"' | cut -d'"' -f4) + _api_body=$(curl -sSL -w '\n%{http_code}' "${GH_AUTH_HEADER[@]}" "$_api_url" 2>/dev/null) || true + _api_code="${_api_body##*$'\n'}" + if [ "$_api_code" = "401" ] && [ ${#GH_AUTH_HEADER[@]} -gt 0 ]; then + _api_body=$(curl -sSL -w '\n%{http_code}' "$_api_url" 2>/dev/null) || true + _api_code="${_api_body##*$'\n'}" fi - unset _gh_token GH_AUTH_HEADER _api_url + if [ "$_api_code" = "200" ]; then + latest_tag=$(printf '%s' "$_api_body" | grep '"tag_name"' | cut -d'"' -f4) + else + latest_tag="" + fi + # Drop the local token copies; GITHUB_TOKEN / GH_TOKEN themselves remain + # in the environment exactly as the user set them. + unset _gh_token GH_AUTH_HEADER _api_url _api_body _api_code if [ -z "$latest_tag" ]; then echo "Failed to fetch latest version" >&2 diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 60aaa2942..daf8350a1 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -1291,9 +1291,31 @@ describe("install shared behavior", () => { expect(script, `${name} should read GITHUB_TOKEN`).toContain("GITHUB_TOKEN"); expect(script, `${name} should read GH_TOKEN`).toContain("GH_TOKEN"); expect(script, `${name} should use a Bearer scheme`).toContain("Bearer "); - expect(script, `${name} should fall back to gh auth token`).toContain("gh auth token"); + // The gh fallback is pinned to github.com so a gh setup whose default + // host is a GitHub Enterprise server never leaks a GHES token to + // api.github.com. (cmd invokes gh via a where-resolved absolute path, + // so match on the flag-bearing suffix common to all three.) + expect(script, `${name} should scope the gh fallback to github.com`).toContain( + "auth token --hostname github.com", + ); } + // Precedence pin: GITHUB_TOKEN is consulted before GH_TOKEN in every + // installer (matching gh's own precedence). + expect(sh).toContain("${GITHUB_TOKEN:-${GH_TOKEN:-}}"); + const psGithubRead = ps.indexOf("$ghToken = $env:GITHUB_TOKEN"); + const psGhRead = ps.indexOf("{ $ghToken = $env:GH_TOKEN }"); + expect(psGithubRead).toBeGreaterThan(0); + expect(psGhRead).toBeGreaterThan(psGithubRead); + const cmdGithubRead = cmdScript.indexOf('set "GH_TOKEN_VAL=!GITHUB_TOKEN!"'); + const cmdGhRead = cmdScript.indexOf('set "GH_TOKEN_VAL=!GH_TOKEN!"'); + expect(cmdGithubRead).toBeGreaterThan(0); + expect(cmdGhRead).toBeGreaterThan(cmdGithubRead); + // The percent-expansion reads are injection vectors (cmd's phase-1 % + // expansion re-parses metacharacters in the value) and must stay gone. + expect(cmdScript).not.toContain('set "GH_TOKEN_VAL=%GITHUB_TOKEN%"'); + expect(cmdScript).not.toContain('set "GH_TOKEN_VAL=%GH_TOKEN%"'); + // install.sh: header array splatted into the api curl. expect(sh).toContain("GH_AUTH_HEADER=()"); expect(sh).toContain('"${GH_AUTH_HEADER[@]}" "$_api_url"'); @@ -1334,32 +1356,65 @@ describe("install shared behavior", () => { expect(cmdScript).toContain('set "GH_AUTH_HEADER="'); }); - test("all installers retry anonymously if the authenticated api call fails", () => { + test("sh and ps1 retry anonymously ONLY on HTTP 401; cmd retry is token-gated", () => { // A stale/revoked token (expired GITHUB_TOKEN lingering in CI images, // dotfiles, direnv) gets a 401 and would break an install that works - // fine anonymously today. Each installer must retry the api.github.com - // call without the auth header when the authenticated attempt fails, - // so a bad token costs one extra request but never blocks an otherwise - // working install. See backnotprop/plannotator#1157 (review feedback). + // fine anonymously today. install.sh and install.ps1 inspect the HTTP + // status and retry WITHOUT the header only on 401: requests carrying + // invalid credentials count against the anonymous 60/hour per-IP pool, + // so a blind retry on any failure would double the burn, and network + // failures gain nothing from a second attempt. install.cmd keeps + // retry-on-any-failure (portable status capture in batch is not worth + // the complexity) but only when a token was actually used. + // See backnotprop/plannotator#1157 (second review). const cmdScript = readScript("install.cmd"); - // install.sh: `|| true` swallows the authed curl failure, then a - // `[ -z "$latest_tag" ]` gate retries without the header array. - expect(sh).toContain('|| true'); - expect(sh).toContain('if [ -z "$latest_tag" ] && [ ${#GH_AUTH_HEADER[@]} -gt 0 ]; then'); - expect(sh).toContain('latest_tag=$(curl -fsSL "$_api_url" | grep'); - - // install.ps1: try/catch around Invoke-RestMethod; on catch, when a - // header was present, retry with no -Headers. - expect(ps).toContain('} catch {'); - expect(ps).toContain("if ($ghHeaders.Count -eq 0)"); - expect(ps).toContain("Write-Error"); - expect(ps).toContain("$release = Invoke-RestMethod -Uri $apiUrl"); - - // install.cmd: ERRORLEVEL check after the authed curl; when a header - // was set, retry without it. + // install.sh: status captured via curl -w; anonymous retry is gated on + // a 401 AND a previously attached header. + expect(sh).toContain("curl -sSL -w '\\n%{http_code}'"); + expect(sh).toContain('if [ "$_api_code" = "401" ] && [ ${#GH_AUTH_HEADER[@]} -gt 0 ]; then'); + // The anonymous retry line drops the header array entirely (and is not + // a substring of the authenticated call). + expect(sh).toContain( + "_api_body=$(curl -sSL -w '\\n%{http_code}' \"$_api_url\" 2>/dev/null) || true", + ); + // The tag is only parsed out of a 200 response. + expect(sh).toContain('if [ "$_api_code" = "200" ]; then'); + + // install.ps1: the catch inspects the response status; the anonymous + // retry requires a token AND a 401, and both terminal error paths + // surface the original exception message. + expect(ps).toContain("$status = [int]$_.Exception.Response.StatusCode"); + expect(ps).toContain("if ($ghHeaders.Count -gt 0 -and $status -eq 401) {"); + expect(ps).toContain('Failed to fetch latest version: $($_.Exception.Message)'); + + // install.cmd: retry only when a token was present, with the anonymous + // retry curl adjacent to the first curl's ERRORLEVEL read. expect(cmdScript).toContain('if !ERRORLEVEL! neq 0 if defined GH_AUTH_HEADER ('); expect(cmdScript).toContain('curl -fsSL "https://api.github.com/repos/!REPO!/releases/latest" -o "!RELEASE_JSON!"'); + // F7 pin: the failure check must appear BEFORE the token clears in file + // order - `set` can disturb ERRORLEVEL, so every ERRORLEVEL read stays + // immediately adjacent to the curl it tests. + const cmdFailureCheck = cmdScript.indexOf("Failed to get latest version"); + const cmdTokenClear = cmdScript.lastIndexOf('set "GH_TOKEN_VAL="'); + const cmdHeaderClear = cmdScript.lastIndexOf('set "GH_AUTH_HEADER="'); + expect(cmdFailureCheck).toBeGreaterThan(0); + expect(cmdTokenClear).toBeGreaterThan(cmdFailureCheck); + expect(cmdHeaderClear).toBeGreaterThan(cmdFailureCheck); + }); + + test("install.cmd hardens the gh fallback and token value (second review)", () => { + const cmdScript = readScript("install.cmd"); + // gh is resolved via `where` and invoked by absolute path, so the for /f + // command line never runs a bare `gh` name that cmd would resolve from + // the current directory first. + expect(cmdScript).toContain("('where gh 2^>nul')"); + expect(cmdScript).toContain('"!GH_EXE!" auth token --hostname github.com'); + expect(cmdScript).not.toContain("('gh auth token"); + // Charset allowlist: a token containing anything outside [A-Za-z0-9_-] + // (a quote in particular could break out of the quoted Authorization + // header on the curl line) is dropped and the call goes anonymous. + expect(cmdScript).toContain('if defined TOKEN_RESIDUE set "GH_TOKEN_VAL="'); }); });