feat(updater): GitHub release source with CDN fallback - #17
Merged
Conversation
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>
…de-github-source-fallback
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>
Collaborator
Author
Fresh reviewer round — P2/P3 findings (recorded, not fixed)Per user direction: P0 + P1 fixed in P2 — Nice to fix (deferred)
P3 — Nits
|
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>
Collaborator
Author
Round 2 review — P2/P3 (recorded, not fixed)Round 2 reviewer found 0 P0, 2 P1 (both fixed in P2
P3
|
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>
Collaborator
Author
Round 3 review — 0 P0, 2 P1 (1 fixed, 1 rejected as out-of-scope)P1 fixed in
|
- 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>
Collaborator
Author
Round 4 review — 0 P0, 4 P1 all fixed in
|
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>
Collaborator
Author
Round 5 review — 0 P0, 3 P1 all fixed in
|
…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>
Collaborator
Author
Round 6 review — 0 P0, 2 P1 all fixed in
|
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>
Collaborator
Author
Round 7 review — 0 P0, 3 P1 all fixed in
|
Collaborator
Author
Round 8 review — 0 P0, 0 P1 ✅ LOOP TERMINATESFresh reviewer confirms: seven prior rounds resolved all security- and correctness-critical items. Round 8 P2/P3 (recorded, not fixed)P2
P3
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.agentserver/app, anonymous queries (no token).stage="verify").LastFallbackshistory inupdate-state.jsonso ops can see days later why every attempt failed.latest.jsonto the GitHub release alongside the.exe(in-scope:.github/workflows/release.yml+scripts/windows-package-common.sh).Design & review artifacts
docs/superpowers/specs/2026-06-29-upgrade-github-source-fallback-design.mddocs/superpowers/plans/2026-06-29-upgrade-github-source-fallback.mdSecurity posture
github.com,codeload.github.com,*.githubusercontent.com(defends against suffix bypass, trailing dot, IPv4/IPv6 literals, userinfo bypass).assets.agent.cs.ac.cnpin —Manifest.Validate()is now format-only, so the host check moved tosource_cdn.validateInstallerURLwith adversarial tests (5 migrated frommanifest_test.go+ trailing-dot).ManifestTimeout(not shared across 2 GitHub hops).UPGRADE_GITHUB_REPOregex-validated (defeats../etc/passwdpath traversal).X-GitHub-Request-Id(avoid leaking identifying tokens to state.json / console API)..exefirst without--clobber, round-trip verifies remote SHA before publishinglatest.json.Zero-regression guarantee
Compat shortcut (
Sources==nil):Service.effectiveSources()lazily builds[cdnSource]fromManifestURL + Clientwith a zero-policy (no timeout, no speed monitor). All 28 existingservice_test.gofixtures usingassetsHostClient(customRoundTripper) work unchanged — the newapplyFirstByteTimeoutpreserves custom transports.Test plan
go test ./... -count=1— all packages green (one flake ininternal/slave/unrelated to updater).UPGRADE_GITHUB_ENABLED=true go test ./internal/updater/... ./cmd/launcher/...— green.service_source_test.gosubstring lint intact (start = StartInstaller+startContext = context.Background()preserved in refactor).TestGithubAssetHostMatcher,TestCDNSourceRejects*) all pass.UPGRADE_GITHUB_ENABLED=trueon canary launcher after v0.0.9 publisheslatest.json.🤖 Generated with Claude Code