diff --git a/apps/marketing/src/content/docs/getting-started/installation.md b/apps/marketing/src/content/docs/getting-started/installation.md index 51328f7bd..27a4e1736 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` (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 +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..6b9a1c085 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. 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` 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), 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? 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..d44ff4754 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -204,15 +204,87 @@ 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. + 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 not defined GH_TOKEN_VAL ( + 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!"" + ) 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 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 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!" + ) 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 2a6ec7bda..a1fbda7ba 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -99,7 +99,53 @@ 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)) { + # --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. 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 { + $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 + } + } + # 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 if (-not $latestTag) { diff --git a/scripts/install.sh b/scripts/install.sh index f2384bc0f..7468cee5b 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -287,7 +287,48 @@ 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 + # --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. 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" + _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 + 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 a064b517e..daf8350a1 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -1274,6 +1274,148 @@ 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 "); + // 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"'); + + // 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 _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; $apiUrl = $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="'); + }); + + 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. 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: 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="'); + }); }); describe("PlannotatorConfig schema", () => {