Skip to content

feat(updater): GitHub release source with CDN fallback - #17

Merged
yzs15 merged 24 commits into
masterfrom
feat/upgrade-github-source-fallback
Jul 7, 2026
Merged

feat(updater): GitHub release source with CDN fallback#17
yzs15 merged 24 commits into
masterfrom
feat/upgrade-github-source-fallback

Conversation

@yzs15

@yzs15 yzs15 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Add a GitHub Releases upgrade source that runs before the existing CDN, with automatic fallback on timeout, rate-limit, or sustained slow download. Default OFF (UPGRADE_GITHUB_ENABLED=false), so day-1 behavior is byte-identical to today.

  • Public repo agentserver/app, anonymous queries (no token).
  • Fallback triggers: manifest timeout, HTTP 403/429, download first-byte timeout, sustained speed < 100 KB/s over 10s window.
  • SHA256 verification stays in the scheduler; mismatch triggers fallback (stage="verify").
  • Rolling 5-entry LastFallbacks history in update-state.json so ops can see days later why every attempt failed.
  • Release pipeline publishes latest.json to the GitHub release alongside the .exe (in-scope: .github/workflows/release.yml + scripts/windows-package-common.sh).

Design & review artifacts

  • Spec (v3, 2 review rounds): docs/superpowers/specs/2026-06-29-upgrade-github-source-fallback-design.md
  • Plan (v3, 2 review rounds): docs/superpowers/plans/2026-06-29-upgrade-github-source-fallback.md
  • Code review round: independent codex reviewer greenlit at final round (BLOCKER=GAP=security-RISK=0).

Security posture

  • Host allowlist per source; GitHub source accepts github.com, codeload.github.com, *.githubusercontent.com (defends against suffix bypass, trailing dot, IPv4/IPv6 literals, userinfo bypass).
  • CDN source preserves the assets.agent.cs.ac.cn pin — Manifest.Validate() is now format-only, so the host check moved to source_cdn.validateInstallerURL with adversarial tests (5 migrated from manifest_test.go + trailing-dot).
  • Per-request ManifestTimeout (not shared across 2 GitHub hops).
  • UPGRADE_GITHUB_REPO regex-validated (defeats ../etc/passwd path traversal).
  • Rate-limit reason string redacts X-GitHub-Request-Id (avoid leaking identifying tokens to state.json / console API).
  • Release workflow refuses tag re-run, uploads .exe first without --clobber, round-trip verifies remote SHA before publishing latest.json.

Zero-regression guarantee

Compat shortcut (Sources==nil): Service.effectiveSources() lazily builds [cdnSource] from ManifestURL + Client with a zero-policy (no timeout, no speed monitor). All 28 existing service_test.go fixtures using assetsHostClient (custom RoundTripper) work unchanged — the new applyFirstByteTimeout preserves custom transports.

Test plan

  • go test ./... -count=1 — all packages green (one flake in internal/slave/ unrelated to updater).
  • UPGRADE_GITHUB_ENABLED=true go test ./internal/updater/... ./cmd/launcher/... — green.
  • service_source_test.go substring lint intact (start = StartInstaller + startContext = context.Background() preserved in refactor).
  • Adversarial host matcher tests (TestGithubAssetHostMatcher, TestCDNSourceRejects*) all pass.
  • Release workflow validated on next tagged canary (requires actual tag push).
  • Flip UPGRADE_GITHUB_ENABLED=true on canary launcher after v0.0.9 publishes latest.json.

🤖 Generated with Claude Code

Zishu Yu and others added 18 commits June 29, 2026 18:48
Adds a Source-interface abstraction in internal/updater so upgrade
checks first try the public agentserver/app GitHub release and fall
back to the existing assets.agent.cs.ac.cn CDN on any timeout,
rate-limit, or sustained low download speed. Default config keeps
github.enabled=false, so behavior is identical to today until ops
opts in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A. Facts corrected:
- AssetsHost (not allowedManifestHost); no Service.Stop;
  AutoCheckEvery is a skip threshold, not a ticker; preserve
  Check/Install two-step flow; updater does not use
  internal/download/resumable; tighten GitHub host whitelist to
  *.githubusercontent.com.

B. Architectural gaps filled:
- SHA256 verify stays in scheduler; mismatch = fallback reason.
- GitHub latest.json.url targets githubusercontent; CDN copy
  targets AssetsHost.
- Manifest.Validate() loses host check (option a); migrate four
  AssetsHost tests to source_cdn_test.go; existing-tests-unchanged
  promise revised.
- Config is env-only via new internal/updater/config.go +
  BuildSources; cmd/launcher constructs Sources here.
- speedMonitor gains injected now/tick and a Tripped() flag so the
  source can distinguish self-cancel from parent cancel.
- Each source holds its own *http.Client.
- 403/429 unified as ErrRateLimited; finer detail in reason string.
- LastFallbacks is a rolling 5-entry history across attempts.
- FallbackRecord.Tried uses Service.Now(), not time.Now().
- Release pipeline (scripts/windows-package-common.sh candidate)
  must publish latest.json to the GitHub release in this same
  change.

D. Small fixes: SpeedSample doc, ErrSHA256Mismatch sentinel,
manifestMaxBytes reuse, manual sweep of *_source_test.go lints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BLOCKERs (facts wrong):
- assetsHostClient (not serviceTestRoundTripper).
- Fixture migration strategy pinned: keep ManifestURL+Client as
  compat shortcut so 28 service_test + 6 main_test fixtures need
  zero edit; Sources==nil triggers lazy [cdnSource] build.
- newCompletedUpdater at lines 341-348.
- Full list of AssetsHost tests to migrate (5, not 4);
  format-only tests stay in manifest_test.go.
- Release pipeline: this PR introduces the first automated
  publish step (scripts/windows-package-common.sh +
  packaging/windows/latest.json.tmpl + .github/workflows/release.yml).

GAPs pinned:
- Disposition table for every service.go helper.
- Sources must be safe under Service shallow-copy in console/update.go.
- serviceStateMu held for full flow; documented as intentional.
- First non-error manifest is authoritative — no cross-source
  version comparison.
- Per-source manifest binding: each source's DownloadInstaller
  uses the manifest THAT source fetched; no cross-source reuse.
- State file written once at end of flow, not per-source.
- packaging/windows/ + scripts/ path names concrete.

RISKs covered:
- Each source constructs its own *http.Transport (not just Client).
- Cancel precedence: parent.Err() first, then Tripped().
- GitHub required headers: Accept + User-Agent.
- /releases/latest excludes prerelease — stable-only by intent.
- UPGRADE_CDN_* env vars removed (would break AssetsHost pin).
- StatusDownloading stays across source attempts (no UI churn).

NITs applied: dropped 'verified against git remote -v';
noopProgress location specified; errors.Is contract noted;
service_test.go 'unchanged' contradiction resolved via compat
shortcut.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 tasks in TDD order: source.go types → speed_monitor → cdn source
→ manifest.Validate split → github source → state additions →
service scheduler refactor + fake-source tests → env config +
BuildSources → launcher wire-up → release pipeline → e2e smoke.
Compat shortcut in Task 7 (Sources==nil ⇒ lazy [cdnSource]) is what
makes 'existing tests unchanged' true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BLOCKERs (all security or regression-critical):
- cloneTransport → applyFirstByteTimeout: preserves custom
  RoundTrippers (assetsHostRewriteTransport) so 28 existing fixtures
  don't hit the real internet.
- githubManifestHost → githubAssetHost: adds github.com so real
  browser_download_url is accepted; adversarial matcher test added.
- Per-request ManifestTimeout for github source (was one budget
  shared across two hops).
- Restored promoted/defer temp-file cleanup + caller-manifest
  version guard in DownloadAndStart.
- New saveErrorWithFallbacks preserves rolling history on terminal
  error (spec-mandated for ops visibility).
- Speed monitor no-ops when MinSpeedBytesPerSec==0; compat shortcut
  uses zero policy so existing CDN downloads have byte-identical
  behavior.
- Manifest-timeout test rewritten (127.0.0.1:1 vacuous version
  replaced with real slow httptest.Server + errors.Is assertion).
- errorsIs substring helper deleted; use errors.Is throughout.

Security hardening:
- normalizeHost trims trailing dot; case-fold.
- Reject URL userinfo in both source paths.
- Rate-limit reason redacts X-GitHub-Request-Id.
- Repo slug regex-validated; malicious values fall back to default.
- jq composes latest.json (no sed-corrupt notes).
- Release workflow refuses re-run + no --clobber on .exe.

Tests added: SpeedMonitorDisabled, GitHubSourceManifestTimeoutFires,
ManifestTimeoutIsPerRequest, RejectsUnwhitelistedInstallerHost,
GithubAssetHostMatcher (adversarial cases), RejectsInstallerURLWithUserinfo,
RejectsInstallerLargerThanSize, PreservesHeadersAcrossRedirect,
LoadUpgradeConfigRejectsMaliciousRepoSlug + AcceptsValidRepoSlugs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BLOCKERs:
- Constructor referred to deleted githubInstallerHost; now githubAssetHost.
- Check parent-cancel path preserves fallback history via
  saveErrorWithFallbacks.
- githubMock gains slowManifestAsset field + wired handler so
  TestGitHubSourceManifestTimeoutIsPerRequest actually compiles and
  exercises per-hop timeouts.

GAPs:
- TestGitHubSourceRateLimit429 uses errors.Is.
- TestServiceCheckAllSourcesFail asserts LastFallbacks visible;
  TestServicePersistsFallbackHistoryAcrossAttempts covers rolling
  buffer across successful attempts.
- applyFirstByteTimeout short-circuits on firstByte<=0 (no fresh
  Transport in compat mode).
- Loop-defer comment accurately describes accumulation semantics.

NITs: hasRealTransport dedup'd to package scope; Self-Review reflects
v2 sentinel-Is contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…licy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tests use onSample as an ack barrier so Read→Send→sample-record is
deterministic, no time-based races.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ports fetchManifest / downloadInstaller / redirect-pinning behind
Source. Enforces AssetsHost via own validateInstallerURL (case-fold,
trailing-dot tolerant, userinfo-rejected). Speed monitor + first-byte
timeout gated on non-zero policy so compat mode (all zeros) is
byte-identical to today's Service.fetchManifest/downloadInstaller.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…moves to CDN source

Task 4 of the plan. Manifest.Validate no longer enforces AssetsHost.
The 5 host-allowlist tests in manifest_test.go are deleted (their
equivalents live in source_cdn_test.go from Task 3).

Task 7 will delete Service.fetchManifest/downloadInstaller/
redirectPinnedAssetsClient entirely; until then, redirect-pinned
client inlines an AssetsHost check so existing service_test.go
redirect-rejection tests keep passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fallback

Anonymous /repos/<repo>/releases/latest → asset URL → latest.json →
installer download. Every request sends Accept + User-Agent
(agentserver-app/<version>) — GitHub asset CDN otherwise 403s.
Rate limit (403/429) wraps ErrRateLimited (redacts X-GitHub-Request-Id).
Host whitelist single source of truth: githubAssetHost accepts
github.com + codeload.github.com + subdomains of githubusercontent.com;
adversarial matcher test covers suffix bypass, IPv6, bare hostname.
Per-hop ManifestTimeout via context.WithTimeout inside fetchRelease
and fetchLatestJSON — slow API doesn't starve asset fetch.
Redirects re-install Accept + User-Agent (defensive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… State

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nding

Sources==nil ⇒ compat shortcut builds [cdnSource] from ManifestURL+
Client with a zero-policy (no timeout, no speed monitor). This
preserves byte-identical behavior for existing service_test.go
fixtures. Multi-source mode: each source re-fetches its own manifest;
compat mode trusts the caller's manifest (matches today's contract
that DownloadAndStart uses the caller's m directly).

Fallback triggers:
- FetchManifest error (multi-source only)
- CompareVersions error / version not newer
- DownloadInstaller error
- verifyInstaller error (SHA256 mismatch), stage='verify'

Terminal errors inside the loop use saveErrorWithFallbacks so ops can
see days later why every attempt failed. Rolling 5-entry history
preserved across attempts. StatusDownloading stays across source
attempts (no UI churn).

CDN source error wording changed to 'host "..." not allowed' so
existing service_test.go string assertions pass.

UI test TestServerConsoleUpdateInstallEndpointRejectsInvalidPersistedUpdate
updated: the AssetsHost host check moved from Manifest.Validate to
source_cdn.validateInstallerURL (plan v3 Task 4). Test now uses a
non-https URL to trigger a format-only Validate rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ardened)

Repo slug regex-validated (defeats path-traversal); malicious values
fall back to agentserver/app default. Malformed durations/ints silently
ignored — never disables the feature on a typo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BuildSources returns nil when GitHub is disabled (default) so
Service.effectiveSources falls back to the compat CDN shortcut;
existing behavior is byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows packaging now has render_latest_json() (jq-composed, safe for
free-form notes). The release workflow guards against tag re-run
(refuses if latest.json already exists), uploads the .exe first
WITHOUT --clobber, round-trip verifies its remote SHA against local
bytes, then uploads latest.json last — ensuring the manifest is
never live before the asset it references.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P0:
- Release workflow guards installer filename version matches tag —
  package-windows.sh hard-codes VERSION and if not bumped before tag,
  manifest would advertise NEW version pointing at OLD-VERSION bytes
  (loop-install bug).

P1:
- Redirect handler in github source enforces https scheme on every
  hop; a 302 to http:// would downgrade the next leg to cleartext
  (defense-in-depth beyond installer SHA — poisoned latest.json served
  over http could redirect to any allowlisted GitHub binary the
  attacker knows the SHA of). CDN source already covered via
  validateInstallerURL.
- Speed-monitor goroutine only spawns when needed (monitorRequired):
  policy actually enables trip detection OR caller wants progress
  samples. Compat mode + nil onProgress ⇒ no ticker allocation.
  Scheduler now passes nil (not noopProgress) for compat mode.
- DownloadAndStart caller-manifest version guard runs ONLY in compat
  mode. Multi-source mode delegates to per-source vcmp checks so
  fallback survives caller-manifest drift.
- Refactored per-source loop body into tryOneSource closure so
  'defer os.Remove(tempPath)' fires at attempt exit, not accumulating
  across iterations. Now safe to add a 3rd source without unbounded
  defer stack.
- Release workflow curl uses --retry 8 --retry-delay 5 to survive
  GitHub CDN eventual consistency (~60s propagation).
- BuildSources gives each source its OWN *http.Client with a fresh
  Transport clone. Sharing http.DefaultTransport meant one source's
  applyFirstByteTimeout mutation would bleed into the other's
  connection pool.

P2/P3 findings recorded (see PR review) but not fixed per user
direction — mostly nit cleanups + one deferred behavior change
(release runbook needs to document that github staleness may block
CDN discovery — spec risk section already covers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yzs15

yzs15 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Fresh reviewer round — P2/P3 findings (recorded, not fixed)

Per user direction: P0 + P1 fixed in b725075; P2 and P3 findings tracked here for a follow-up.

P2 — Nice to fix (deferred)

  1. config.go:24 — repo slug regex admits owner/. / owner/... GitHub API rejects them so no real damage, but the request line looks suspicious in logs. Tighten regex to forbid all-dot segments.
  2. source_github.go — rate-limit reason string includes X-RateLimit-Remaining in LastError/LastFallbacks.Reason exposed via console API. Low risk (not identifying alone) but consider bucketing to "exhausted" / "throttled".
  3. service.gosaveError vs saveErrorWithFallbacks split is fragile. Two nearly-identical methods; picking the wrong one silently drops history. Could merge via mergeFallbacks(prior, nil) == prior.
  4. service_fallback_test.go:167TestServiceParentCtxCancellationSkipsFallback passes for wrong reason. Uses context.Canceled as fetchErr; can't distinguish "returned early via ctx check" from "appended fallback then continued". Swap to plain error to make the invariant meaningful.
  5. source_github_test.goTestGithubAssetHostMatcher uses bracketed [::1] but url.Hostname() strips brackets. Add unbracketed variants (::1, fe80::1) and a punycode/IDN case.
  6. service.go — Check treats vcmp <= 0 as StatusLatest (stops iteration); DownloadAndStart treats it as fallback (continues). If GitHub serves stale 0.9.0 while CDN has 1.0.0, Check never queries CDN. Spec risk section covers this as a publish-order concern — leaving as documented behavior.
  7. source_cdn.go — redundant outer defer cancel() alongside the inner defer. Both run; not a bug, but invites future breakage.
  8. release.yml — no OIDC / attestation. Consider attestations: write + actions/attest-build-provenance for a later provenance-verification story.
  9. release.ymldist/latest-cdn.json generated but never uploaded. Either publish it or drop the render call.
  10. source_github.go — no assert that manifest.Version matches release tag_name. Cheap sanity check: "v"+m.Version == release.TagName.

P3 — Nits

  1. source.goapplyFirstByteTimeout doc overstates savings — shallow c := *base still allocates a client per call. Trivial.
  2. speed_monitor.gotrailingBPS and instantBPS are the same function. Delete one.
  3. config.go — silent-fail on bad env vars — no operator feedback. Add log.Printf("upgrade: ignoring invalid %s=%q", …).
  4. source_github.goredirectPinned re-sets Accept + User-Agent but Go's stdlib preserves them. Defensive no-op — add comment or drop.
  5. service.gocompatCDNPolicy() returns zero value. Inline SourcePolicy{} at call site.
  6. source_cdn.go + source_github.goredirectPinned skeleton duplicated. Extract shared helper in source.go.

P1: applyFirstByteTimeout was cloning *http.Transport on every
DownloadInstaller call — each attempt allocated a fresh connection
pool and discarded TLS keep-alives. Memoize pinned/installer clients
at source construction; expose rebuildClients() so tests that swap
installerHostMatch can regenerate the wrapped client.

P1: TestGitHubSourceManifestTimeoutIsPerRequest was tautological —
with a 50ms budget and hop 2 sleeping 500ms, both per-hop and
shared-budget implementations timeout at ~50ms elapsed, so the
elapsed<300ms assertion couldn't distinguish them. Rewrote to use
budget=200ms, hop1=150ms, hop2=100ms: per-hop succeeds at ~250ms
elapsed, shared budget fails at ~200ms with ErrFetchTimeout. Now
actually verifies the invariant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yzs15

yzs15 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Round 2 review — P2/P3 (recorded, not fixed)

Round 2 reviewer found 0 P0, 2 P1 (both fixed in 9fd037b). Remaining P2/P3 tracked here.

P2

  1. Missing redirect-security testsCheckRedirect enforces https/no-userinfo/host on every hop but no test exercises those branches. Add: 302 → http://… rejected; 302 → https://user@github.com/… rejected; 302 → https://evil.example.com/… rejected. Same for cdn source.
  2. context.WithTimeout(dlCtx, FirstByteTimeout) in the custom-RoundTripper fallback puts a whole-request deadline, not a first-byte deadline — semantically divergent from the Transport path (ResponseHeaderTimeout). Test-only, but rename or document.
  3. Release workflow guard only checks latest.json — if a prior run uploaded .exe then failed before latest.json, the .exe guard passes but the upload will error (no --clobber). Detect orphan .exe or document recovery.
  4. X-RateLimit-Remaining inserted verbatim into err.Error() and stored in LastFallbacks[].Reason. Low risk (TLS-sourced from GitHub) but add a hard truncate (if len(rem) > 32 { rem = rem[:32] }).
  5. Source interface doc says "Callers MUST NOT pass a manifest from a different source" but compat mode does exactly that (safe because per-source validator re-checks). Clarify contract: "Each source MUST validate m.URL before use; callers may pass a foreign manifest and rely on source-side rejection."
  6. tryOneSource attemptOutcome discriminated-union struct is fragile — a continueLoop: true on a terminalState != nil path silently confuses caller. Consider splitting into resolveManifest + runAttempt, or Go-native return triple.

P3

  1. config.go:24 repo slug regex allows owner/.hidden — GitHub rejects but tighten regex.
  2. service.go saveError/saveErrorWithFallbacks re-load state that caller already loaded; cosmetic (guarded by serviceStateMu).
  3. service.go appendFallback + mergeFallbacks are near-duplicates; collapse.
  4. state.go time.Time field has both omitempty (deprecated for time in Go 1.24+) and omitzero; drop omitempty.
  5. service.go saveFinalStatesaveErrorsaveState recursion depth worth a comment.
  6. source_github.go redirectPinned prior chain is dead code in production — base.CheckRedirect always nil today.
  7. .github/workflows/release.yml references ./scripts/package-windows.sh in a comment — verify path (it exists, checked).

P1 defense-in-depth: reject http:// browser_download_url from the
release API BEFORE the first hop — Manifest.Validate() only runs
on m (parsed latest.json body), NOT on assetURL. Redirect callback
covers subsequent hops but a first-hop plaintext request would
already be sent.

Regression test: TestGitHubSourceRejectsAssetURLWithoutHTTPS with
a shim server returning an http:// browser_download_url; asserts
FetchManifest returns ErrHostNotAllowed mentioning 'scheme' before
any request to the malicious URL.

The other round-3 P1 (grep-based tests in service_source_test.go /
installer_windows_source_test.go / manifest_test.go) is pre-existing
master tech debt (commit 1e5b681) not introduced by this PR;
rewriting them as behavior tests would need Windows-tagged tests
and is out of scope. Recorded in PR comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yzs15

yzs15 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Round 3 review — 0 P0, 2 P1 (1 fixed, 1 rejected as out-of-scope)

P1 fixed in e0d6990

  • assetURL https-scheme guard (defense-in-depth on the first hop from release API to latest.json).

P1 pushback (out-of-scope)

  • Grep-based tests in service_source_test.go / installer_windows_source_test.go / manifest_test.go:145 — these are pre-existing master tech debt from commit 1e5b681 fix: remove updater test hooks and detach installer start, not introduced by this PR. Rewriting them as behavior tests (per reviewer's suggestion: Windows-tagged tests invoking StartInstaller with cancelled ctx) is a separate cleanup and doesn't block this feature.

P2 (deferred)

  1. X-RateLimit-Remaining in err.Error() — consistency with "no identifying tokens" — strip or document.
  2. 403 on asset fetch → ErrRateLimited — should be ErrHostNotAllowed/generic; fallback behavior still correct.
  3. saveError vs saveErrorWithFallbacks split enforced by comment only.
  4. monitorRequired returns true whenever onProgress != nil — a future UI progress hookup could accidentally break compat "byte-identical" guarantee.
  5. Comment "per-request ManifestTimeout not shared across 2 GitHub hops" oversells — it's per-Go-request, not per-network-hop.
  6. Workflow .exe upload has no orphan detection on retry.
  7. Workflow EXE_NAME=$(ls ... | head -1) picks arbitrary if multiple -setup.exe present.
  8. installerCachePath derives from basename without version scope.
  9. stat -c%s || stat -f%z fallback — prefer wc -c (POSIX).

P3

  1. normalizeHost trims only one trailing dot; use strings.TrimRight(host, ".").
  2. TestGithubAssetHostMatcher uses [::1] (brackets) — production Hostname() strips them; use ::1.
  3. strings.ContainsAny(sub, "/@") dead code — add // defensive: comment.
  4. applyFirstByteTimeout doc references test-only symbol.
  5. TestGitHubSourceManifestTimeoutFires elapsed > 300ms bound may flake on loaded CI; widen or drop.
  6. state.go omitempty,omitzero redundant.
  7. trailingBPS == instantBPS; inline.
  8. attemptOutcome struct — tagged-union style would be cleaner.
  9. tryOneSource captures fallbacks by reference (read-only); add explanatory comment.
  10. TestServiceCheckAllSourcesFail doesn't assert Reason contents.
  11. manifest.go Validate() doc doesn't warn "host allowlist enforced elsewhere".
  12. config.go fallback to bare &http.Transport{} if DefaultTransport type-asserts fail.
  13. start == nil → StartInstaller/context.Background() swap inline — hoist to method.

- Release workflow: 'gh release upload' fails on nonexistent release
  and 'push: tags' doesn't auto-create one. Add 'gh release create'
  guard (published, not draft, so round-trip verify's public download
  URL works). Brief .exe-uploaded / latest.json-not-yet window is
  benign — clients fall back to CDN.

- Speed monitor: 'start' was captured at run() entry, i.e. BEFORE
  dial/TLS. On slow-start connections, dial time inflated 'elapsed'
  and depressed measured throughput → false slow-download trip.
  Now capture start on the first non-zero countingReader.Read via
  atomic CAS; ticks before first byte are no-ops. Real transfer
  window, not wall-clock-since-goroutine-spawn.

- Test-path first-byte deadline (context.WithTimeout(dlCtx,
  FirstByteTimeout)) previously covered whole request+body, but
  production's ResponseHeaderTimeout scopes only to headers. Fix:
  cancel the first-byte deadline as soon as client.Do returns
  (headers received) so slow-body reads aren't punished by it.
  New helper firstByteDeadlineCtx factored on both sources.

- classifyDownload wraps context.DeadlineExceeded as ErrFetchTimeout
  so state.LastFallbacks[].Reason distinguishes a first-byte timeout
  from a raw dial error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yzs15

yzs15 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Round 4 review — 0 P0, 4 P1 all fixed in 285e005

  • Release workflow: added gh release create (published, not draft — draft assets aren't served via public download URL that round-trip verify uses).
  • Speed monitor: start now captured on first non-zero byte via atomic CAS (was captured at goroutine spawn → dial/TLS bled into elapsed → false slow-download trips on slow-start).
  • Fallback-path context.WithTimeout(dlCtx, FirstByteTimeout) scoped to headers only (matches production ResponseHeaderTimeout semantics) — fbCancel() fires right after client.Do returns.
  • classifyDownload wraps context.DeadlineExceeded as ErrFetchTimeout so state reason distinguishes first-byte timeout from dial error.

Round 4 P2/P3 recorded

P2

  1. service.go attemptOutcome.continueLoop is dead code — every fallback branch also sets it true, terminal branches return early. Delete field or add a non-retryable-error path.
  2. source_github.go:361 req.Err() == context.DeadlineExceeded should be errors.Is for wrapped-ctx forward-compat.
  3. source_github.go githubAssetHost accepts evil..githubusercontent.com (double-dot host: sub = "evil.", passes). Add strings.Contains(sub, "..") reject and restrict sub to [A-Za-z0-9.-].
  4. service.go installerCachePath doesn't handle name == ".." (URL path /..). Produces "..exe". Add ".." to reserved-name list.
  5. service.go appendFallback/mergeFallbacks overlap — collapse into capTail(slice, n).
  6. source_github.go:257 LimitReader(_, 512*1024) for release JSON — hardcoded, extract named const.
  7. scripts/windows-package-common.sh two jq -n blocks byte-identical except URL; factor helper.
  8. .github/workflows/release.yml latest-cdn.json generated + test -f'd but never uploaded/archived. Add actions/upload-artifact or drop.

P3

  1. config.go:86-91 newIsolatedHTTPClient bare &http.Transport{} fallback loses proxy/dialer. Log or panic in the else-branch.
  2. service.go:262-289 attemptOutcome doc longer than struct — tagged-union style clearer.
  3. source_github.go:120-122 apiClient/assetClient/installerClient accessor methods add nothing — read fields directly.
  4. service.go:450 name = filepath.Base(name) after name += ".exe" is a no-op.
  5. source_github.go:181 first latest.json-named asset picked; case-mismatched (Latest.json) upload silently produces "missing" error.
  6. source_github_test.go:271-294 TestGitHubSourceRejectsUnwhitelistedInstallerHost accepts either fetch OR download rejection — split into two.
  7. source_github_test.go matcher coverage: no evil..githubusercontent.com, no xn-- punycode, no [::1]:443 port case.
  8. source_github.go:156-159 len(via) >= 10 redirect limit duplicated per redirectPinned — package const.

P1 (security/DoS): Manifest.Validate now caps m.Size at 500 MB
(new MaxInstallerBytes const). Prevents disk-fill attack via a
compromised manifest source declaring Size=1TB — DownloadInstaller
writes m.Size+1 bytes to disk BEFORE SHA256 verify runs.

P1 (correctness): The round-4 fbCancel() 'release' pattern was
materially wrong — context.WithTimeout's returned cancel has no
'release' semantics; calling it after Do() returns cancels the
whole request ctx that resp.Body is bound to, breaking any streaming
body. Reproducer confirmed via probe: 'n=0 err=context canceled'
on next Read.

Rewrite firstByteDeadlineCtx with proper semantics:
- Returns (ctx, markHeadersReceived, stop)
- time.AfterFunc arms a timer that cancels iff !gotHeaders
- markHeadersReceived sets gotHeaders + timer.Stop
- Timer never cancels after headers arrive → body reads safe

P1 (test coverage): Added two tests that both PROVE and REGRESS
this fix:
- TestGitHubSourceFirstByteTimeoutFiresBeforeHeaders: custom RT
  delays 500ms before headers, 50ms timeout → cancellation fires.
- TestGitHubSourceFirstByteTimeoutDoesNotCancelBodyReads: headers
  instant, body streams 100 bytes at 30ms each (~3s total), 100ms
  first-byte timeout → body completes without cancellation. Would
  have failed under the round-4 code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yzs15

yzs15 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Round 5 review — 0 P0, 3 P1 all fixed in dd5adb3

  • fbCancel semantics rewrite: my round-4 "release" pattern was materially wrong — context.WithTimeout cancel does not release, it cancels. Reproducer confirmed. Rewrote firstByteDeadlineCtx to return (ctx, markHeadersReceived, stop) with time.AfterFunc that cancels iff !gotHeaders. Body reads survive the first-byte timeout.
  • Test coverage for the custom-RT path: added two behavior tests using a slowHeadersRT fake — one proves timeout fires before headers, one proves body streaming (30ms/byte × 100 bytes ≈ 3s) survives a 100ms first-byte timeout. Second would have failed under round-4 code.
  • Disk-fill DoS via m.Size: Manifest.Validate now caps at MaxInstallerBytes = 500 * 1024 * 1024. Hostile manifest can no longer make the download loop write arbitrary bytes to disk before SHA verify.

Round 5 P2/P3 recorded

P2

  1. githubAssetHost accepts sub == "." (host ..githubusercontent.com). DNS-invalid but tighten to reject leading/trailing dot and ...
  2. githubAPIHost inherits githubAssetHost allowing github.com/ghapigithub.laiyagushi.com/codeload.github.com redirect. Narrow to *.githubusercontent.com only for API-hop.
  3. service.go per-attempt context.WithCancel(ctx) + immediate cancel() in Check/DownloadAndStart adds no value — sources are synchronous. Simplify.
  4. saveError/saveErrorWithFallbacks split still fragile — collapse to saveError(now, err, freshFallbacks...).
  5. source_github.go release JSON limit 512*1024 hardcoded — extract const alongside manifestMaxBytes.
  6. newIsolatedHTTPClient bare &http.Transport{} fallback — log or panic.
  7. NewGitHubSource should reject non-https apiBase.
  8. speedMonitor startNS==0 sentinel + m.now().UnixNano() — if now() returns Unix(0,0), CAS never sets. Use separate atomic.Bool started.

P3

  1. FetchManifest returns manifest without host validation (only userinfo) — state persists suspect URL briefly. Fail-fast at fetch.
  2. Workflow latest-cdn.json rendered but never shipped.
  3. Workflow ${TAG}/${EXE_NAME} interpolated without URL-encoding — belt-and-braces jq @uri.
  4. sha256sum not on macOS default; use openssl dgst -sha256 for cross-OS.
  5. DownloadAndStart fallback Stage always "download" — doesn't split by sentinel (ErrRateLimited vs ErrFetchTimeout vs ErrSlowDownload).

…cation)

P1 (security): Close the headers-then-hang attack. Round-5's fbCancel
stopped the first-byte deadline as soon as headers arrived, but a
hostile mirror that sends headers immediately then hangs the body at
0 bytes escaped both this deadline AND the speed monitor (which
gates every tick on startNS != 0 — first non-zero read).

Unify the two 'first byte' signals. speedMonitor gains an
onFirstByte callback fired from countingReader on the first non-zero
Read. Sources wire it as markFirstByte so the deadline timer only
stops when a BODY byte arrives, not on header receipt. monitorRequired
now returns true when FirstByteTimeout > 0 so the countingReader
wrapping exists whenever a deadline is armed.

Regression test TestGitHubSourceRejectsHeadersThenHangBody: 100ms
first-byte timeout + a RT that hangs body forever after headers →
download aborts at ~100ms, not indefinitely.

P1 (design): Collapsed saveError / saveErrorWithFallbacks into a
single variadic saveError. The split was a landmine — any future
in-loop terminal branch that forgot the -WithFallbacks variant
silently dropped ops history. New signature always merges prior
+ fresh; callers with no fresh fallbacks omit the argument.
Wrapper saveErrorWithFallbacks retained for existing call sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yzs15

yzs15 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Round 6 review — 0 P0, 2 P1 all fixed in bdff511

  • Headers-then-hang attack closed. Unified first-byte signal via speedMonitor.onFirstByte callback fired from countingReader on first non-zero body Read. Sources wire it as markFirstByte so the deadline timer only stops when a BODY byte arrives, not on header receipt. monitorRequired widens to FirstByteTimeout > 0 so countingReader exists whenever a timer is armed. Regression test: TestGitHubSourceRejectsHeadersThenHangBody — 100ms timeout + hanging body → aborts at ~100ms.
  • saveError unified. Collapsed into single variadic method that always merges prior + fresh. Wrapper saveErrorWithFallbacks retained for existing call sites. New code can't pick the wrong one.

Round 6 P2/P3 recorded

P2

  1. Workflow recovery path: .exe uploaded but latest.json upload failure → next re-run passes guard but gh release upload fails on existing .exe. Consider SHA-match skip.
  2. CDN policy in multi-source mode uses DefaultSourcePolicy — no UPGRADE_CDN_* env override. Document coupling or add knobs.
  3. s.pinned = s.buildPinnedClient(); applyFirstByteTimeout(s.pinned, …) clones Transport → manifest + installer get separate connection pools. Wastes one TLS handshake per download. Consider building install from s.client directly.
  4. SHA256 verify uses strings.EqualFold, not constant-time — hash is public, so not exploitable, flagged for the sweep.
  5. redirectPinned re-uses s.validateInstallerURL for both manifest and installer redirects. Behavior correct, naming hazardous. Rename to validateAssetURL or split.

P3

  1. source_github.go "Go's stdlib strips Authorization" comment — Accept/UA are actually preserved; comment reads as fix. Reword to "defense-in-depth".
  2. newIsolatedHTTPClient bare &http.Transport{} fallback is dead (http.DefaultTransport is always *http.Transport). Drop or comment as defensive.
  3. MaxInstallerBytes = 500 MB — cross-link to verifyInstaller (streams via io.Copy, RAM fine, disk-fill capped).
  4. TestGitHubSourceRejectsUnwhitelistedInstallerHost accepts either fetch OR download rejection — tighten to specific stage.
  5. defer stopFB() order — worth a one-line comment noting LIFO defer semantics.

Round-6 fix only closed the FALLBACK (test-transport) path — production
still had the vuln because firstByteDeadlineCtx short-circuited when
hasRealTransport()==true, relying on http.Transport.ResponseHeaderTimeout
which only covers HEADERS. A real GitHub mirror sending headers +
hanging body would hang the launcher indefinitely.

Fix:
- Remove the isRealTransport() short-circuit from firstByteDeadlineCtx.
  Arm the first-body-byte deadline on ALL paths. Production now has:
  ResponseHeaderTimeout (headers) + firstByteDeadlineCtx (first body
  byte via speedMonitor.onFirstByte from countingReader) +
  speed monitor (slow body).
- Add a tripped signal (return value) so classify can distinguish
  timer-fired from other context.Canceled — the timer cancels via
  context.WithCancel, so downstream sees Canceled not DeadlineExceeded.
  Without this signal, timer-fired downloads recorded raw wrapped-
  canceled strings instead of ErrFetchTimeout.
- CAS single-winner between timer and markFirstByte closes the O(ns)
  race where the timer fires the same instant the first byte arrives.
- classifyFetch switched from raw '==' to errors.Is for
  context.DeadlineExceeded compat.

Regression test TestGitHubSourceRejectsHeadersThenHangBodyProduction:
a real httptest.NewTLSServer that sends headers + hangs body, invoked
through insecureTLSClient (real *http.Transport) → source aborts at
~150ms with errors.Is(err, ErrFetchTimeout). Would have hung forever
under round-6 code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yzs15

yzs15 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Round 7 review — 0 P0, 3 P1 all fixed in a69bc26

  • Production headers-then-hang defense: round-6 only fixed the fallback (test) path — production still relied on ResponseHeaderTimeout (headers only). Removed the isRealTransport() short-circuit — firstByteDeadlineCtx now armed on all paths. New TestGitHubSourceRejectsHeadersThenHangBodyProduction uses a real TLS server + real *http.Transport and verifies abort at ~150ms with errors.Is(err, ErrFetchTimeout).
  • tripped signal: added a return value from firstByteDeadlineCtx so classify can distinguish timer-fired from other context.Canceled. Prior code recorded raw wrapped-canceled strings; now wraps as ErrFetchTimeout.
  • CAS single-winner for the timer/markFirstByte race — the O(ns) window where both fire simultaneously.
  • classifyFetch now uses errors.Is for context.DeadlineExceeded (was raw ==).

Round 7 P2/P3 recorded

P2

  1. service.go per-attempt context.WithCancel(ctx) + immediate cancel() in Check — dead code (fetch is synchronous).
  2. verifyInstaller returns "size mismatch" and "sha256 mismatch" both wrapped as ErrSHA256Mismatch — split into ErrSHA256Mismatch and ErrSizeMismatch, or rename to ErrVerifyFailed.
  3. manifest_test.go — add TestManifestValidateRejectsSizeOverMax (Size = 500 MB + 1).
  4. source_cdn.go FetchManifest doesn't validate s.manifestURL scheme — reject non-https at construction time.
  5. .github/workflows/release.yml gh release create without --latest=false — during .exe-uploaded/latest.json-not-yet window, GET /releases/latest returns a release without latest.json → spurious FallbackRecord on every launcher.
  6. saveError's variadic-of-slices signature — replace with plain fresh []FallbackRecord for less ambiguity.
  7. source_github.go — no cross-check that release.TagName == "v"+m.Version.

P3

  1. service.go s.appendFallback caps + mergeFallbacks caps again — redundant.
  2. source.go hasRealTransport doc: fallback exists only for tests.
  3. source_github.go "stdlib strips Authorization" comment — Accept/UA preserved, comment misleads.
  4. scripts/windows-package-common.sh latest-cdn.json written but never uploaded in CI — dead output.
  5. service.go compatCDNPolicy() returns zero — rename or inline.
  6. source_github.go rebuildClients as a test seam — consider WithInstallerHostMatcherForTest.
  7. source_github_test.go:283-289 stale comment about "userinfo rejected inline".

@yzs15

yzs15 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Round 8 review — 0 P0, 0 P1 ✅ LOOP TERMINATES

Fresh reviewer confirms: seven prior rounds resolved all security- and correctness-critical items.

Round 8 P2/P3 (recorded, not fixed)

P2

  1. saveFinalState drops in-progress fallbacks on save failure — nested-failure edge case; thread fallbacks through.
  2. Workflow partial-failure re-run wedges tag if .exe uploaded but latest.json failed — add cleanup or runbook.
  3. Coverage gap: no test explicitly exercises CheckRedirect rejecting 302 to non-allowlisted host.
  4. githubAssetHost strings.ContainsAny(sub, "/@") dead defensive — Hostname() strips both.
  5. saveErrorWithFallbacks wrapper prior param unused.

P3

  1. applyFirstByteTimeout docstring stale.
  2. redirectPinned Accept+UA re-install is no-op (stdlib preserves).
  3. FetchManifest asset-host vs installer-host validation asymmetric.
  4. attemptOutcome 5-field struct — tagged union tidier.
  5. RateLimit403 test doesn't assert X-GitHub-Request-Id omitted.
  6. Multi-source DownloadAndStart still Validate()s caller-m before loop.
  7. cdnSource.classify DeadlineExceeded internal-path note.

@yzs15
yzs15 merged commit 0ac5bfe into master Jul 7, 2026
4 checks passed
@yzs15
yzs15 deleted the feat/upgrade-github-source-fallback branch July 7, 2026 08:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant