[object Object]

← back to Cli Printing Press

fix(cli): drive browser-sniff http_transport from HAR HTTP-version distribution (#1540)

833213ff69318e47017d87d31030d4e0a08b6e12 · 2026-05-16 16:25:04 -0700 · Trevin Chow

* fix(cli): drive browser-sniff http_transport from HAR HTTP-version distribution

Pre-fix the browser-sniff parser wrote spec.http_transport = browser-chrome-h3
whenever reachability.mode was browser_clearance_http and the bare
browser-chrome enum whenever mode was browser_http, regardless of the HTTP
version the HAR actually recorded. Origins that serve H/2 but not H/3 (most
Cloudflare and Next.js + Vercel SaaS today) shipped CLIs that failed their
first live call with CRYPTO_ERROR 0x128 (remote): tls: handshake failure.

Adds a new browser-chrome-h2 enum + UsesBrowserHTTP2Transport predicate.
Captures HAR httpVersion on every entry, computes a per-target distribution
in TrafficAnalysisSummary, and maps the majority version to the matching
transport: H/3 -> browser-chrome-h3, H/2 -> browser-chrome-h2, H/1.1 ->
browser-chrome (no version force). Empty distribution falls back to bare
browser-chrome so the runtime negotiates rather than guesses.

The bare browser-chrome enum now means "no version force"; the implicit
HTTP/2 force from the pre-fix template requires opt-in via browser-chrome-h2.
EffectiveHTTPTransport's default-sniffed and browser-auth-for-HTML branches
return browser-chrome-h2 instead of bare browser-chrome so the implicit H/2
force is preserved for shipped CLIs that did not go through browser-sniff.

ApplyReachabilityDefaults now runs before applyHTTPTransportDefault so the
HAR-driven mapping wins for browser_http / browser_clearance_http modes.
browser_required intentionally leaves HTTPTransport empty since the operator
must drive a real browser.

Closes #1387

* fix(cli): allow --transport browser-chrome-h2 via the CLI flag

normalizeHTTPTransport's CLI-flag allow-list was the one place that did
not learn about the new browser-chrome-h2 enum. Operators passing
--transport browser-chrome-h2 hit the validator's reject path even
though the value is accepted by spec.Validate, catalog.Validate, and the
generator's enum predicates. Add it to the allow-list and extend the
error message and the existing test to cover the new value.

Refs #1387

* fix(cli): preserve implicit H/2 force on protection-driven transport path

applyHTTPTransportDefault writes spec.HTTPTransportBrowserChrome (bare,
no force after the template change) whenever trafficAnalysisRecommendsBrowserTransport
fires for non-reachability-mode signals: Cloudflare/DataDome/Akamai/PerimeterX
protections, html_scrape protocol, generic browser/scrape hints. Pre-PR the
template's else branch unconditionally emitted ForceHTTP2() for bare
browser-chrome; post-PR it emits neither, so specs flagged via those
heuristics lost their implicit H/2 force.

Switch this fallback to browser-chrome-h2 so the generated client still
forces HTTP/2 in the absence of HAR HTTP-version data. The reachability-
driven paths (browser_http / browser_clearance_http) keep using the HAR
distribution via ApplyReachabilityDefaults; explicit H/3 hints still win
over the protection default.

Refs #1387

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Files touched

Diff

commit 833213ff69318e47017d87d31030d4e0a08b6e12
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat May 16 16:25:04 2026 -0700

    fix(cli): drive browser-sniff http_transport from HAR HTTP-version distribution (#1540)
    
    * fix(cli): drive browser-sniff http_transport from HAR HTTP-version distribution
    
    Pre-fix the browser-sniff parser wrote spec.http_transport = browser-chrome-h3
    whenever reachability.mode was browser_clearance_http and the bare
    browser-chrome enum whenever mode was browser_http, regardless of the HTTP
    version the HAR actually recorded. Origins that serve H/2 but not H/3 (most
    Cloudflare and Next.js + Vercel SaaS today) shipped CLIs that failed their
    first live call with CRYPTO_ERROR 0x128 (remote): tls: handshake failure.
    
    Adds a new browser-chrome-h2 enum + UsesBrowserHTTP2Transport predicate.
    Captures HAR httpVersion on every entry, computes a per-target distribution
    in TrafficAnalysisSummary, and maps the majority version to the matching
    transport: H/3 -> browser-chrome-h3, H/2 -> browser-chrome-h2, H/1.1 ->
    browser-chrome (no version force). Empty distribution falls back to bare
    browser-chrome so the runtime negotiates rather than guesses.
    
    The bare browser-chrome enum now means "no version force"; the implicit
    HTTP/2 force from the pre-fix template requires opt-in via browser-chrome-h2.
    EffectiveHTTPTransport's default-sniffed and browser-auth-for-HTML branches
    return browser-chrome-h2 instead of bare browser-chrome so the implicit H/2
    force is preserved for shipped CLIs that did not go through browser-sniff.
    
    ApplyReachabilityDefaults now runs before applyHTTPTransportDefault so the
    HAR-driven mapping wins for browser_http / browser_clearance_http modes.
    browser_required intentionally leaves HTTPTransport empty since the operator
    must drive a real browser.
    
    Closes #1387
    
    * fix(cli): allow --transport browser-chrome-h2 via the CLI flag
    
    normalizeHTTPTransport's CLI-flag allow-list was the one place that did
    not learn about the new browser-chrome-h2 enum. Operators passing
    --transport browser-chrome-h2 hit the validator's reject path even
    though the value is accepted by spec.Validate, catalog.Validate, and the
    generator's enum predicates. Add it to the allow-list and extend the
    error message and the existing test to cover the new value.
    
    Refs #1387
    
    * fix(cli): preserve implicit H/2 force on protection-driven transport path
    
    applyHTTPTransportDefault writes spec.HTTPTransportBrowserChrome (bare,
    no force after the template change) whenever trafficAnalysisRecommendsBrowserTransport
    fires for non-reachability-mode signals: Cloudflare/DataDome/Akamai/PerimeterX
    protections, html_scrape protocol, generic browser/scrape hints. Pre-PR the
    template's else branch unconditionally emitted ForceHTTP2() for bare
    browser-chrome; post-PR it emits neither, so specs flagged via those
    heuristics lost their implicit H/2 force.
    
    Switch this fallback to browser-chrome-h2 so the generated client still
    forces HTTP/2 in the absence of HAR HTTP-version data. The reachability-
    driven paths (browser_http / browser_clearance_http) keep using the HAR
    distribution via ApplyReachabilityDefaults; explicit H/3 hints still win
    over the protection default.
    
    Refs #1387
    
    ---------
    
    Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
 internal/browsersniff/analysis.go                  |  36 +++--
 internal/browsersniff/analysis_test.go             |  10 +-
 internal/browsersniff/http_version.go              |  66 ++++++++
 internal/browsersniff/http_version_test.go         | 179 +++++++++++++++++++++
 internal/browsersniff/parser.go                    |  14 ++
 internal/browsersniff/reachability.go              |  15 +-
 internal/browsersniff/types.go                     |  17 +-
 internal/catalog/catalog.go                        |   5 +-
 internal/cli/generate_test.go                      |  59 ++++++-
 internal/cli/root.go                               |  24 ++-
 internal/generator/generator_test.go               |  75 +++++++++
 internal/generator/templates/client.go.tmpl        |   2 +-
 internal/spec/spec.go                              |  30 ++--
 internal/spec/spec_test.go                         |  26 ++-
 .../browser-sniff-sample/traffic-analysis.json     |   4 +
 15 files changed, 510 insertions(+), 52 deletions(-)

diff --git a/internal/browsersniff/analysis.go b/internal/browsersniff/analysis.go
index 2fb14d87..f914266c 100644
--- a/internal/browsersniff/analysis.go
+++ b/internal/browsersniff/analysis.go
@@ -134,14 +134,15 @@ func unmarshalGenerationHints(data []byte) ([]string, error) {
 }
 
 type TrafficAnalysisSummary struct {
-	TargetURL        string         `json:"target_url,omitempty"`
-	CapturedAt       string         `json:"captured_at,omitempty"`
-	EntryCount       int            `json:"entry_count"`
-	APIEntryCount    int            `json:"api_entry_count"`
-	NoiseEntryCount  int            `json:"noise_entry_count"`
-	HostDistribution map[string]int `json:"host_distribution,omitempty"`
-	TimeStart        string         `json:"time_start,omitempty"`
-	TimeEnd          string         `json:"time_end,omitempty"`
+	TargetURL               string         `json:"target_url,omitempty"`
+	CapturedAt              string         `json:"captured_at,omitempty"`
+	EntryCount              int            `json:"entry_count"`
+	APIEntryCount           int            `json:"api_entry_count"`
+	NoiseEntryCount         int            `json:"noise_entry_count"`
+	HostDistribution        map[string]int `json:"host_distribution,omitempty"`
+	HTTPVersionDistribution map[string]int `json:"http_version_distribution,omitempty"`
+	TimeStart               string         `json:"time_start,omitempty"`
+	TimeEnd                 string         `json:"time_end,omitempty"`
 }
 
 // EvidenceRef cites a piece of evidence for an observation. Two flavors:
@@ -599,12 +600,13 @@ func entryClassificationKey(entry EnrichedEntry) string {
 
 func buildTrafficSummary(capture *EnrichedCapture, apiEntries []EnrichedEntry, noiseEntries []EnrichedEntry) TrafficAnalysisSummary {
 	summary := TrafficAnalysisSummary{
-		TargetURL:        capture.TargetURL,
-		CapturedAt:       capture.CapturedAt,
-		EntryCount:       len(capture.Entries),
-		APIEntryCount:    len(apiEntries),
-		NoiseEntryCount:  len(noiseEntries),
-		HostDistribution: map[string]int{},
+		TargetURL:               capture.TargetURL,
+		CapturedAt:              capture.CapturedAt,
+		EntryCount:              len(capture.Entries),
+		APIEntryCount:           len(apiEntries),
+		NoiseEntryCount:         len(noiseEntries),
+		HostDistribution:        map[string]int{},
+		HTTPVersionDistribution: map[string]int{},
 	}
 	var start *time.Time
 	var end *time.Time
@@ -613,6 +615,9 @@ func buildTrafficSummary(capture *EnrichedCapture, apiEntries []EnrichedEntry, n
 		if host != "" {
 			summary.HostDistribution[host]++
 		}
+		if v := NormalizeHTTPVersion(entry.HTTPVersion); v != "" {
+			summary.HTTPVersionDistribution[v]++
+		}
 		parsed, ok := parseEntryTime(entry.StartedDateTime)
 		if !ok {
 			continue
@@ -629,6 +634,9 @@ func buildTrafficSummary(capture *EnrichedCapture, apiEntries []EnrichedEntry, n
 	if len(summary.HostDistribution) == 0 {
 		summary.HostDistribution = nil
 	}
+	if len(summary.HTTPVersionDistribution) == 0 {
+		summary.HTTPVersionDistribution = nil
+	}
 	if start != nil {
 		summary.TimeStart = start.Format(time.RFC3339Nano)
 	}
diff --git a/internal/browsersniff/analysis_test.go b/internal/browsersniff/analysis_test.go
index e55feb0b..1988fe0e 100644
--- a/internal/browsersniff/analysis_test.go
+++ b/internal/browsersniff/analysis_test.go
@@ -518,7 +518,10 @@ func TestApplyReachabilityDefaultsAddsBrowserClearanceCookieAuth(t *testing.T) {
 		Resources: map[string]spec.Resource{"posts": {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/posts"}}}},
 	}
 	analysis := &TrafficAnalysis{
-		Summary: TrafficAnalysisSummary{TargetURL: "https://www.producthunt.com"},
+		Summary: TrafficAnalysisSummary{
+			TargetURL:               "https://www.producthunt.com",
+			HTTPVersionDistribution: map[string]int{"h3": 12},
+		},
 		Reachability: &ReachabilityAnalysis{
 			Mode:       "browser_clearance_http",
 			Confidence: 0.9,
@@ -551,7 +554,10 @@ func TestApplyReachabilityDefaultsDoesNotRequireProofWithoutValidationPath(t *te
 		}}}},
 	}
 	analysis := &TrafficAnalysis{
-		Summary: TrafficAnalysisSummary{TargetURL: "https://www.producthunt.com"},
+		Summary: TrafficAnalysisSummary{
+			TargetURL:               "https://www.producthunt.com",
+			HTTPVersionDistribution: map[string]int{"h3": 12},
+		},
 		Reachability: &ReachabilityAnalysis{
 			Mode:       "browser_clearance_http",
 			Confidence: 0.9,
diff --git a/internal/browsersniff/http_version.go b/internal/browsersniff/http_version.go
new file mode 100644
index 00000000..60b93dd3
--- /dev/null
+++ b/internal/browsersniff/http_version.go
@@ -0,0 +1,66 @@
+package browsersniff
+
+import (
+	"strings"
+
+	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+)
+
+// NormalizeHTTPVersion canonicalizes HAR-recorded httpVersion strings to
+// a fixed set of labels: "h3", "h2", "http/1.1", or "" when the input
+// is empty or unrecognized. HAR exporters spell HTTP/2 as "h2",
+// "HTTP/2", "h2c", or even "HTTP/2.0"; HTTP/3 appears as "h3" or
+// "HTTP/3". Normalizing here keeps the distribution map's keys stable
+// regardless of which browser captured the trace.
+func NormalizeHTTPVersion(raw string) string {
+	v := strings.ToLower(strings.TrimSpace(raw))
+	if v == "" {
+		return ""
+	}
+	switch {
+	case v == "h3", strings.HasPrefix(v, "http/3"):
+		return "h3"
+	case v == "h2", v == "h2c", strings.HasPrefix(v, "http/2"):
+		return "h2"
+	case strings.HasPrefix(v, "http/1.1"):
+		return "http/1.1"
+	}
+	return ""
+}
+
+// browserTransportFromHARVersion picks the HTTPTransport enum value
+// from the HAR's HTTP-version distribution. The most-frequent recognized
+// version wins; ties go to the higher-version (H/3 > H/2 > H/1.1) so a
+// 50/50 H/2-vs-H/3 origin biases toward the stricter wire protocol the
+// origin already proved it can serve. An empty distribution returns
+// HTTPTransportBrowserChrome (no version force) so callers fall back to
+// Chrome's own version negotiation rather than guessing.
+func browserTransportFromHARVersion(dist map[string]int) string {
+	if len(dist) == 0 {
+		return spec.HTTPTransportBrowserChrome
+	}
+	// Iterate in descending priority order; the first version with a
+	// strict majority wins. When two versions tie for the lead, the
+	// higher version wins by virtue of being checked first.
+	type bucket struct {
+		key       string
+		transport string
+	}
+	priority := []bucket{
+		{"h3", spec.HTTPTransportBrowserChromeH3},
+		{"h2", spec.HTTPTransportBrowserChromeH2},
+		{"http/1.1", spec.HTTPTransportBrowserChrome},
+	}
+	best := bucket{transport: spec.HTTPTransportBrowserChrome}
+	bestCount := 0
+	for _, b := range priority {
+		if c := dist[b.key]; c > bestCount {
+			best = b
+			bestCount = c
+		}
+	}
+	if bestCount == 0 {
+		return spec.HTTPTransportBrowserChrome
+	}
+	return best.transport
+}
diff --git a/internal/browsersniff/http_version_test.go b/internal/browsersniff/http_version_test.go
new file mode 100644
index 00000000..7d34efaa
--- /dev/null
+++ b/internal/browsersniff/http_version_test.go
@@ -0,0 +1,179 @@
+package browsersniff
+
+import (
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+)
+
+func TestNormalizeHTTPVersion(t *testing.T) {
+	t.Parallel()
+	cases := []struct {
+		in   string
+		want string
+	}{
+		{"HTTP/3", "h3"},
+		{"http/3.0", "h3"},
+		{"h3", "h3"},
+		{"HTTP/2", "h2"},
+		{"http/2.0", "h2"},
+		{"h2", "h2"},
+		{"h2c", "h2"},
+		{"HTTP/1.1", "http/1.1"},
+		{"http/1.1", "http/1.1"},
+		{"", ""},
+		{"unknown", ""},
+		{"HTTP/0.9", ""},
+		{"  HTTP/2  ", "h2"},
+	}
+	for _, c := range cases {
+		got := NormalizeHTTPVersion(c.in)
+		if got != c.want {
+			t.Errorf("NormalizeHTTPVersion(%q) = %q, want %q", c.in, got, c.want)
+		}
+	}
+}
+
+func TestBrowserTransportFromHARVersion_PicksMajority(t *testing.T) {
+	t.Parallel()
+	cases := []struct {
+		name string
+		dist map[string]int
+		want string
+	}{
+		{
+			name: "h3 majority",
+			dist: map[string]int{"h3": 20, "h2": 3},
+			want: spec.HTTPTransportBrowserChromeH3,
+		},
+		{
+			name: "h2 majority",
+			dist: map[string]int{"h2": 18, "h3": 2, "http/1.1": 1},
+			want: spec.HTTPTransportBrowserChromeH2,
+		},
+		{
+			name: "h1 only",
+			dist: map[string]int{"http/1.1": 5},
+			want: spec.HTTPTransportBrowserChrome,
+		},
+		{
+			name: "empty falls back to no-force browser-chrome",
+			dist: map[string]int{},
+			want: spec.HTTPTransportBrowserChrome,
+		},
+		{
+			name: "nil distribution falls back to no-force browser-chrome",
+			dist: nil,
+			want: spec.HTTPTransportBrowserChrome,
+		},
+		{
+			name: "tie between h3 and h2 prefers h3",
+			dist: map[string]int{"h3": 5, "h2": 5},
+			want: spec.HTTPTransportBrowserChromeH3,
+		},
+		{
+			name: "unrecognized keys ignored",
+			dist: map[string]int{"spdy": 99},
+			want: spec.HTTPTransportBrowserChrome,
+		},
+	}
+	for _, c := range cases {
+		got := browserTransportFromHARVersion(c.dist)
+		if got != c.want {
+			t.Errorf("%s: browserTransportFromHARVersion(%v) = %q, want %q", c.name, c.dist, got, c.want)
+		}
+	}
+}
+
+func TestApplyReachabilityDefaults_HARVersionDrivesTransport(t *testing.T) {
+	t.Parallel()
+	cases := []struct {
+		name string
+		mode string
+		dist map[string]int
+		want string
+	}{
+		{"clearance + h3", "browser_clearance_http", map[string]int{"h3": 9}, spec.HTTPTransportBrowserChromeH3},
+		{"clearance + h2", "browser_clearance_http", map[string]int{"h2": 9}, spec.HTTPTransportBrowserChromeH2},
+		{"clearance + h1", "browser_clearance_http", map[string]int{"http/1.1": 5}, spec.HTTPTransportBrowserChrome},
+		{"browser_http + h2", "browser_http", map[string]int{"h2": 4}, spec.HTTPTransportBrowserChromeH2},
+		{"empty distribution + clearance", "browser_clearance_http", nil, spec.HTTPTransportBrowserChrome},
+	}
+	for _, c := range cases {
+		apiSpec := &spec.APISpec{
+			Name:    c.name,
+			BaseURL: "https://www.example.com",
+			Auth:    spec.AuthConfig{Type: "none"},
+			Resources: map[string]spec.Resource{
+				"posts": {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/posts"}}},
+			},
+		}
+		analysis := &TrafficAnalysis{
+			Summary: TrafficAnalysisSummary{
+				TargetURL:               "https://www.example.com",
+				HTTPVersionDistribution: c.dist,
+			},
+			Reachability: &ReachabilityAnalysis{Mode: c.mode, Confidence: 0.9},
+		}
+		ApplyReachabilityDefaults(apiSpec, analysis)
+		if apiSpec.HTTPTransport != c.want {
+			t.Errorf("%s: HTTPTransport = %q, want %q", c.name, apiSpec.HTTPTransport, c.want)
+		}
+	}
+}
+
+// TestApplyReachabilityDefaults_BrowserRequiredLeavesEmpty pins the
+// invariant that browser_required mode does not set HTTPTransport even
+// when a HAR distribution is present. browser_required means the
+// operator must drive a real browser; the surf client is not used and
+// emitting a transport would mislead downstream code.
+func TestApplyReachabilityDefaults_BrowserRequiredLeavesEmpty(t *testing.T) {
+	t.Parallel()
+	apiSpec := &spec.APISpec{
+		Name:    "browserrequired",
+		BaseURL: "https://www.example.com",
+		Auth:    spec.AuthConfig{Type: "none"},
+		Resources: map[string]spec.Resource{
+			"posts": {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/posts"}}},
+		},
+	}
+	analysis := &TrafficAnalysis{
+		Summary: TrafficAnalysisSummary{
+			TargetURL:               "https://www.example.com",
+			HTTPVersionDistribution: map[string]int{"h3": 10},
+		},
+		Reachability: &ReachabilityAnalysis{Mode: "browser_required", Confidence: 0.9},
+	}
+	ApplyReachabilityDefaults(apiSpec, analysis)
+	if apiSpec.HTTPTransport != "" {
+		t.Errorf("browser_required must leave HTTPTransport empty regardless of HAR; got %q", apiSpec.HTTPTransport)
+	}
+}
+
+// TestApplyReachabilityDefaults_PreservesExplicitTransport keeps the
+// caller's explicit choice when HTTPTransport is already set, so an
+// operator who hand-tunes the spec is not overridden by the HAR
+// majority. Matches the old switch semantics.
+func TestApplyReachabilityDefaults_PreservesExplicitTransport(t *testing.T) {
+	t.Parallel()
+	apiSpec := &spec.APISpec{
+		Name:          "preserve",
+		BaseURL:       "https://www.example.com",
+		Auth:          spec.AuthConfig{Type: "none"},
+		HTTPTransport: spec.HTTPTransportBrowserChromeH3,
+		Resources: map[string]spec.Resource{
+			"posts": {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/posts"}}},
+		},
+	}
+	analysis := &TrafficAnalysis{
+		Summary: TrafficAnalysisSummary{
+			TargetURL:               "https://www.example.com",
+			HTTPVersionDistribution: map[string]int{"h2": 99},
+		},
+		Reachability: &ReachabilityAnalysis{Mode: "browser_clearance_http", Confidence: 0.9},
+	}
+	ApplyReachabilityDefaults(apiSpec, analysis)
+	if apiSpec.HTTPTransport != spec.HTTPTransportBrowserChromeH3 {
+		t.Errorf("explicit HTTPTransport must be preserved; got %q", apiSpec.HTTPTransport)
+	}
+}
diff --git a/internal/browsersniff/parser.go b/internal/browsersniff/parser.go
index fccf509e..f6f82899 100644
--- a/internal/browsersniff/parser.go
+++ b/internal/browsersniff/parser.go
@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"os"
+	"strings"
 )
 
 func ParseHAR(path string) (*HAR, error) {
@@ -56,6 +57,7 @@ func convertHAREntry(entry HAREntry) EnrichedEntry {
 	return EnrichedEntry{
 		Method:              entry.Request.Method,
 		URL:                 entry.Request.URL,
+		HTTPVersion:         pickHARHTTPVersion(entry),
 		StartedDateTime:     entry.StartedDateTime,
 		DurationMS:          entry.Time,
 		RequestBody:         requestBody,
@@ -66,3 +68,15 @@ func convertHAREntry(entry HAREntry) EnrichedEntry {
 		ResponseHeaders:     responseHeaders,
 	}
 }
+
+// pickHARHTTPVersion returns the response httpVersion when present,
+// falling back to the request httpVersion. HAR exporters vary: Chrome
+// fills both, some intermediaries fill only one. The response wire
+// version is the more accurate signal for "what the origin spoke" so
+// it wins when both are set.
+func pickHARHTTPVersion(entry HAREntry) string {
+	if v := strings.TrimSpace(entry.Response.HTTPVersion); v != "" {
+		return v
+	}
+	return strings.TrimSpace(entry.Request.HTTPVersion)
+}
diff --git a/internal/browsersniff/reachability.go b/internal/browsersniff/reachability.go
index 9f3bc7e9..9a69b9a6 100644
--- a/internal/browsersniff/reachability.go
+++ b/internal/browsersniff/reachability.go
@@ -17,14 +17,15 @@ func ApplyReachabilityDefaults(apiSpec *spec.APISpec, analysis *TrafficAnalysis)
 		applyHTMLScrapeExtractionDefaults(apiSpec, analysis.Reachability.HTMLExtractSignature)
 	}
 
-	if analysis.Reachability.Mode == "browser_http" || analysis.Reachability.Mode == "browser_clearance_http" || analysis.Reachability.Mode == "browser_required" {
+	// browser_required is the "no Go-client replay possible" mode -- the
+	// operator must drive a real browser. It intentionally leaves
+	// HTTPTransport empty so downstream code does not try to wire a surf
+	// client. The HAR-driven transport mapping applies only to modes
+	// where the captured traffic is replayable.
+	switch analysis.Reachability.Mode {
+	case "browser_http", "browser_clearance_http":
 		if apiSpec.HTTPTransport == "" {
-			switch analysis.Reachability.Mode {
-			case "browser_clearance_http":
-				apiSpec.HTTPTransport = spec.HTTPTransportBrowserChromeH3
-			case "browser_http":
-				apiSpec.HTTPTransport = spec.HTTPTransportBrowserChrome
-			}
+			apiSpec.HTTPTransport = browserTransportFromHARVersion(analysis.Summary.HTTPVersionDistribution)
 		}
 	}
 
diff --git a/internal/browsersniff/types.go b/internal/browsersniff/types.go
index edee1caf..0ecc0fd5 100644
--- a/internal/browsersniff/types.go
+++ b/internal/browsersniff/types.go
@@ -16,10 +16,11 @@ type HAREntry struct {
 }
 
 type HARRequest struct {
-	Method   string       `json:"method"`
-	URL      string       `json:"url"`
-	Headers  []HARHeader  `json:"headers"`
-	PostData *HARPostData `json:"postData,omitempty"`
+	Method      string       `json:"method"`
+	URL         string       `json:"url"`
+	HTTPVersion string       `json:"httpVersion,omitempty"`
+	Headers     []HARHeader  `json:"headers"`
+	PostData    *HARPostData `json:"postData,omitempty"`
 }
 
 type HARPostData struct {
@@ -28,9 +29,10 @@ type HARPostData struct {
 }
 
 type HARResponse struct {
-	Status  int                `json:"status"`
-	Headers []HARHeader        `json:"headers,omitempty"`
-	Content HARResponseContent `json:"content"`
+	Status      int                `json:"status"`
+	HTTPVersion string             `json:"httpVersion,omitempty"`
+	Headers     []HARHeader        `json:"headers,omitempty"`
+	Content     HARResponseContent `json:"content"`
 }
 
 type HARResponseContent struct {
@@ -64,6 +66,7 @@ type AuthCapture struct {
 type EnrichedEntry struct {
 	Method              string            `json:"method"`
 	URL                 string            `json:"url"`
+	HTTPVersion         string            `json:"http_version,omitempty"`
 	StartedDateTime     string            `json:"started_date_time,omitempty"`
 	DurationMS          float64           `json:"duration_ms,omitempty"`
 	RequestBody         string            `json:"request_body"`
diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
index f2e4d82d..6f3be7ff 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -72,7 +72,8 @@ var validClientPatterns = map[string]struct{}{
 var validHTTPTransports = map[string]struct{}{
 	"standard":          {}, // Plain net/http, default for official APIs
 	"browser-http":      {}, // Plain net/http with HTTP/2 disabled for browser-facing websites
-	"browser-chrome":    {}, // Browser-compatible transport for web-discovered/non-official APIs
+	"browser-chrome":    {}, // Browser-compatible transport for web-discovered/non-official APIs (no version force; Chrome negotiates)
+	"browser-chrome-h2": {}, // Chrome-compatible HTTP transport forced through HTTP/2
 	"browser-chrome-h3": {}, // Chrome-compatible HTTP transport forced through HTTP/3
 }
 
@@ -333,7 +334,7 @@ func (e *Entry) Validate() error {
 	}
 	if e.HTTPTransport != "" {
 		if _, ok := validHTTPTransports[e.HTTPTransport]; !ok {
-			return fmt.Errorf("http_transport must be one of: standard, browser-http, browser-chrome, browser-chrome-h3")
+			return fmt.Errorf("http_transport must be one of: standard, browser-http, browser-chrome, browser-chrome-h2, browser-chrome-h3")
 		}
 	}
 	if e.BaseURL != "" && !strings.HasPrefix(e.BaseURL, "https://") {
diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index 680e9bea..17f6453c 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -11,6 +11,7 @@ import (
 	"testing"
 
 	catalogfs "github.com/mvanhorn/cli-printing-press/v4/catalog"
+	"github.com/mvanhorn/cli-printing-press/v4/internal/browsersniff"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/catalog"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/catalogmeta"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/pipeline"
@@ -714,7 +715,8 @@ resources:
   "summary": {
     "target_url": "https://www.producthunt.com",
     "entry_count": 1,
-    "api_entry_count": 1
+    "api_entry_count": 1,
+    "http_version_distribution": {"h2": 9}
   },
   "reachability": {
     "mode": "browser_clearance_http",
@@ -753,6 +755,9 @@ resources:
 	require.NoError(t, err)
 	assert.NotContains(t, string(clientGo), `req.Header.Set("User-Agent"`)
 	assert.Contains(t, string(clientGo), `"github.com/enetx/surf"`)
+	// HAR distribution declares H/2 majority -> ForceHTTP2 is emitted and
+	// ForceHTTP3 is not. With an empty distribution the bare browser-chrome
+	// enum would emit neither; the fixture above pins the HAR-driven path.
 	assert.Contains(t, string(clientGo), "ForceHTTP2()")
 	assert.NotContains(t, string(clientGo), "ForceHTTP3()")
 	assert.NotContains(t, string(clientGo), "runBrowserUseFetch")
@@ -807,7 +812,8 @@ resources:
   "summary": {
     "target_url": "https://www.producthunt.com",
     "entry_count": 1,
-    "api_entry_count": 1
+    "api_entry_count": 1,
+    "http_version_distribution": {"h2": 9}
   },
   "reachability": {
     "mode": "browser_clearance_http",
@@ -831,6 +837,7 @@ resources:
 	clientGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
 	require.NoError(t, err)
 	assert.Contains(t, string(clientGo), `"github.com/enetx/surf"`)
+	// HAR distribution declares H/2 majority -> ForceHTTP2 emits.
 	assert.Contains(t, string(clientGo), "ForceHTTP2()")
 	assert.NotContains(t, string(clientGo), "ForceHTTP3()")
 	assert.NotContains(t, string(clientGo), "runBrowserUseFetch")
@@ -1525,6 +1532,10 @@ func TestNormalizeHTTPTransportAllowsBrowserChromeH3(t *testing.T) {
 	require.NoError(t, err)
 	assert.Equal(t, spec.HTTPTransportBrowserHTTP, got)
 
+	got, err = normalizeHTTPTransport(spec.HTTPTransportBrowserChromeH2)
+	require.NoError(t, err)
+	assert.Equal(t, spec.HTTPTransportBrowserChromeH2, got)
+
 	got, err = normalizeHTTPTransport(spec.HTTPTransportBrowserChromeH3)
 	require.NoError(t, err)
 	assert.Equal(t, spec.HTTPTransportBrowserChromeH3, got)
@@ -1532,10 +1543,54 @@ func TestNormalizeHTTPTransportAllowsBrowserChromeH3(t *testing.T) {
 	_, err = normalizeHTTPTransport("browser-chrome-http3")
 	require.ErrorContains(t, err, "browser-chrome-h3")
 
+	_, err = normalizeHTTPTransport("browser-chrome-http2")
+	require.ErrorContains(t, err, "browser-chrome-h2")
+
 	_, err = normalizeHTTPTransport("browser-runtime")
 	require.ErrorContains(t, err, "--transport must be one of")
 }
 
+// TestApplyHTTPTransportDefaultPreservesH2ForProtectionPath pins the
+// protection-driven branch (Cloudflare/DataDome/html_scrape/browser
+// hints without a reachability mode). Pre-fix the template's else
+// branch unconditionally emitted ForceHTTP2 for bare browser-chrome;
+// post-fix the bare enum means "no force" so this path must opt into
+// the -h2 variant explicitly to keep shipped CLIs on these origins
+// behaving identically.
+func TestApplyHTTPTransportDefaultPreservesH2ForProtectionPath(t *testing.T) {
+	t.Parallel()
+	apiSpec := &spec.APISpec{
+		Name:    "cloudflareapp",
+		BaseURL: "https://www.example.com",
+		Auth:    spec.AuthConfig{Type: "none"},
+	}
+	analysis := &browsersniff.TrafficAnalysis{
+		Protections: []browsersniff.ProtectionObservation{{Label: "cloudflare"}},
+	}
+	applyHTTPTransportDefault(apiSpec, analysis)
+	assert.Equal(t, spec.HTTPTransportBrowserChromeH2, apiSpec.HTTPTransport,
+		"protection-driven path must write browser-chrome-h2 so the generated client still forces HTTP/2")
+}
+
+// TestApplyHTTPTransportDefaultExplicitH3WinsOverProtection pins that
+// an explicit H/3 hint still wins over the protection heuristic when
+// both fire; the H/3 force preempts the H/2 fallback.
+func TestApplyHTTPTransportDefaultExplicitH3WinsOverProtection(t *testing.T) {
+	t.Parallel()
+	apiSpec := &spec.APISpec{
+		Name:    "cloudflareh3app",
+		BaseURL: "https://www.example.com",
+		Auth:    spec.AuthConfig{Type: "none"},
+	}
+	analysis := &browsersniff.TrafficAnalysis{
+		Protections:     []browsersniff.ProtectionObservation{{Label: "cloudflare"}},
+		GenerationHints: []string{"prefer_http3"},
+	}
+	applyHTTPTransportDefault(apiSpec, analysis)
+	assert.Equal(t, spec.HTTPTransportBrowserChromeH3, apiSpec.HTTPTransport,
+		"explicit H/3 hint must beat the protection-driven H/2 default")
+}
+
 func TestGenerateCmdInfersTrafficAnalysisForSniffedSpec(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 3afcff02..00962bcf 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -495,8 +495,17 @@ func runGenerateProject(apiSpec *spec.APISpec, absOut string, opts generateProje
 	if opts.rejectUnshippablePageContextTraffic && trafficAnalysisRequiresUnshippablePageContext(trafficAnalysis) {
 		return nil, false, &ExitError{Code: ExitInputError, Err: fmt.Errorf("traffic analysis says this target requires live browser page-context execution; persistent browser transport is not a shippable printed CLI runtime. Re-run discovery for a Surf/direct/browser-clearance replayable surface instead")}
 	}
-	applyHTTPTransportDefault(apiSpec, trafficAnalysis)
+	// ApplyReachabilityDefaults runs first so its HAR-driven HTTP-version
+	// mapping wins for browser_http / browser_clearance_http modes.
+	// applyHTTPTransportDefault then fills the cases reachability does
+	// not cover (no reachability section, hint-only signals, browser_required)
+	// because its own no-op-when-set guard short-circuits in the populated
+	// case. The two functions cover disjoint reachability modes, so the
+	// short-circuit is the only thing keeping a write-write conflict
+	// impossible today; preserve that invariant if either function's
+	// mode coverage widens.
 	browsersniff.ApplyReachabilityDefaults(apiSpec, trafficAnalysis)
+	applyHTTPTransportDefault(apiSpec, trafficAnalysis)
 	gen.TrafficAnalysis = trafficAnalysis
 	if err := gen.Generate(); err != nil {
 		return nil, false, &ExitError{Code: ExitGenerationError, Err: fmt.Errorf("generating project: %w", err)}
@@ -561,10 +570,10 @@ func normalizeClientPattern(value string) (string, error) {
 
 func normalizeHTTPTransport(value string) (string, error) {
 	switch value {
-	case "", spec.HTTPTransportStandard, spec.HTTPTransportBrowserHTTP, spec.HTTPTransportBrowserChrome, spec.HTTPTransportBrowserChromeH3:
+	case "", spec.HTTPTransportStandard, spec.HTTPTransportBrowserHTTP, spec.HTTPTransportBrowserChrome, spec.HTTPTransportBrowserChromeH2, spec.HTTPTransportBrowserChromeH3:
 		return value, nil
 	default:
-		return "", fmt.Errorf("--transport must be one of: standard, browser-http, browser-chrome, browser-chrome-h3 (got %q)", value)
+		return "", fmt.Errorf("--transport must be one of: standard, browser-http, browser-chrome, browser-chrome-h2, browser-chrome-h3 (got %q)", value)
 	}
 }
 
@@ -596,7 +605,14 @@ func applyHTTPTransportDefault(apiSpec *spec.APISpec, analysis *browsersniff.Tra
 		return
 	}
 	if trafficAnalysisRecommendsBrowserTransport(analysis) {
-		apiSpec.HTTPTransport = spec.HTTPTransportBrowserChrome
+		// Surface the implicit H/2 force the pre-template-change else-branch
+		// provided. ApplyReachabilityDefaults handles the browser_http /
+		// browser_clearance_http modes with HAR-driven precision; everything
+		// this branch covers (Cloudflare/DataDome/Akamai protections, html_scrape
+		// protocol, generic browser/scrape hints) lacks HAR HTTP-version data,
+		// so default to -h2 instead of bare browser-chrome (no force) to keep
+		// shipped CLIs on origins these heuristics flag behaving identically.
+		apiSpec.HTTPTransport = spec.HTTPTransportBrowserChromeH2
 	}
 }
 
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index b469ffa2..e669ef5b 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -1701,6 +1701,81 @@ func TestGenerateBrowserChromeH3Transport(t *testing.T) {
 	runGoCommand(t, outputDir, "test", "./internal/client")
 }
 
+// TestGenerateBrowserChromeH2Transport pins the explicit
+// browser-chrome-h2 enum: the client emits ForceHTTP2() and no
+// ForceHTTP3(). Separate from the bare browser-chrome case (no version
+// force) so a future refactor cannot collapse the two without a failing
+// test.
+func TestGenerateBrowserChromeH2Transport(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:          "websurfaceh2",
+		Version:       "0.1.0",
+		BaseURL:       "https://www.example.com",
+		HTTPTransport: spec.HTTPTransportBrowserChromeH2,
+		Auth:          spec.AuthConfig{Type: "none"},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/websurfaceh2-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"posts": {
+				Description: "Browse posts",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/", Description: "List posts"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "websurfaceh2-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	clientGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(clientGo), `"github.com/enetx/surf"`)
+	assert.Contains(t, string(clientGo), "ForceHTTP2()")
+	assert.NotContains(t, string(clientGo), "ForceHTTP3()")
+}
+
+// TestGenerateBrowserChromeNoVersionForce pins the bare browser-chrome
+// enum (no -h2 / -h3 suffix): the surf client is used but no
+// ForceHTTPN() call is emitted, so Chrome's negotiated version wins.
+// Operators who want an explicit H/2 force must set browser-chrome-h2.
+func TestGenerateBrowserChromeNoVersionForce(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:          "websurfacenoforce",
+		Version:       "0.1.0",
+		BaseURL:       "https://www.example.com",
+		HTTPTransport: spec.HTTPTransportBrowserChrome,
+		Auth:          spec.AuthConfig{Type: "none"},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/websurfacenoforce-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"posts": {
+				Description: "Browse posts",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/", Description: "List posts"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "websurfacenoforce-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	clientGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(clientGo), `"github.com/enetx/surf"`)
+	assert.NotContains(t, string(clientGo), "ForceHTTP2()")
+	assert.NotContains(t, string(clientGo), "ForceHTTP3()")
+}
+
 func TestGenerateBrowserHTTPTransportDisablesHTTP2(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 77b75d82..497bb71d 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -284,7 +284,7 @@ func newHTTPClient(timeout time.Duration, jar http.CookieJar) *http.Client {
 		Timeout(timeout)
 {{- if .UsesBrowserHTTP3Transport}}
 	builder = builder.ForceHTTP3()
-{{- else}}
+{{- else if .UsesBrowserHTTP2Transport}}
 	builder = builder.ForceHTTP2()
 {{- end}}
 	if jar == nil {
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 360536e1..7a66a156 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -27,7 +27,8 @@ const (
 const (
 	HTTPTransportStandard        = "standard"          // default for official API clients
 	HTTPTransportBrowserHTTP     = "browser-http"      // stdlib transport with HTTP/2 disabled for browser-facing web surfaces
-	HTTPTransportBrowserChrome   = "browser-chrome"    // Chrome-impersonated transport for browser-facing web surfaces
+	HTTPTransportBrowserChrome   = "browser-chrome"    // Chrome-impersonated transport for browser-facing web surfaces (no version force; Chrome negotiates)
+	HTTPTransportBrowserChromeH2 = "browser-chrome-h2" // Chrome-impersonated transport forced through HTTP/2 for origins that serve H/2 but not H/3
 	HTTPTransportBrowserChromeH3 = "browser-chrome-h3" // Chrome-impersonated transport forced through HTTP/3 for stricter bot screens
 )
 
@@ -144,7 +145,7 @@ type APISpec struct {
 	Kind                        string              `yaml:"kind,omitempty" json:"kind,omitempty"`                     // "rest" (default) or "synthetic" — synthetic CLIs aggregate multiple sources beyond the spec; dogfood's path-validity check is relaxed accordingly
 	SpecSource                  string              `yaml:"spec_source,omitempty" json:"spec_source,omitempty"`       // official, community, sniffed, docs — affects generated client defaults
 	ClientPattern               string              `yaml:"client_pattern,omitempty" json:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
-	HTTPTransport               string              `yaml:"http_transport,omitempty" json:"http_transport,omitempty"` // standard (default for official APIs), browser-http, browser-chrome, or browser-chrome-h3
+	HTTPTransport               string              `yaml:"http_transport,omitempty" json:"http_transport,omitempty"` // standard (default for official APIs), browser-http, browser-chrome, browser-chrome-h2, or browser-chrome-h3
 	HealthCheckPath             string              `yaml:"health_check_path,omitempty" json:"health_check_path,omitempty"`
 	ProxyRoutes                 map[string]string   `yaml:"proxy_routes,omitempty" json:"proxy_routes,omitempty"`    // path prefix → service name for proxy-envelope routing
 	BearerRefresh               BearerRefreshConfig `yaml:"bearer_refresh,omitempty" json:"bearer_refresh,omitzero"` // live-source metadata for rotating public client bearer tokens
@@ -372,15 +373,22 @@ func (s *APISpec) EffectiveHTTPTransport() string {
 		return HTTPTransportStandard
 	}
 	switch s.HTTPTransport {
-	case HTTPTransportStandard, HTTPTransportBrowserHTTP, HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH3:
+	case HTTPTransportStandard, HTTPTransportBrowserHTTP, HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH2, HTTPTransportBrowserChromeH3:
 		return s.HTTPTransport
 	}
+	// Defaults map to the explicit -h2 variant. The bare "browser-chrome"
+	// enum means "no version force"; default-sniffed and
+	// browser-auth-for-HTML specs surface their H/2 force through the
+	// explicit "-h2" enum so the spec field always names the wire
+	// protocol the runtime will pick. browser-sniff overrides this with
+	// HAR-driven mapping in ApplyReachabilityDefaults before
+	// EffectiveHTTPTransport runs.
 	if s.usesBrowserAuthForHTML() {
-		return HTTPTransportBrowserChrome
+		return HTTPTransportBrowserChromeH2
 	}
 	switch s.SpecSource {
 	case "community", "sniffed":
-		return HTTPTransportBrowserChrome
+		return HTTPTransportBrowserChromeH2
 	default:
 		return HTTPTransportStandard
 	}
@@ -397,7 +405,7 @@ func (s *APISpec) usesBrowserAuthForHTML() bool {
 
 func (s *APISpec) UsesBrowserHTTPTransport() bool {
 	switch s.EffectiveHTTPTransport() {
-	case HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH3:
+	case HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH2, HTTPTransportBrowserChromeH3:
 		return true
 	default:
 		return false
@@ -408,13 +416,17 @@ func (s *APISpec) UsesBrowserHTTP3Transport() bool {
 	return s.EffectiveHTTPTransport() == HTTPTransportBrowserChromeH3
 }
 
+func (s *APISpec) UsesBrowserHTTP2Transport() bool {
+	return s.EffectiveHTTPTransport() == HTTPTransportBrowserChromeH2
+}
+
 func (s *APISpec) UsesHTTP2DisabledTransport() bool {
 	return s.EffectiveHTTPTransport() == HTTPTransportBrowserHTTP
 }
 
 func (s *APISpec) UsesBrowserManagedUserAgent() bool {
 	switch s.EffectiveHTTPTransport() {
-	case HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH3:
+	case HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH2, HTTPTransportBrowserChromeH3:
 		return true
 	default:
 		return false
@@ -1986,9 +1998,9 @@ func (s *APISpec) Validate() error {
 		return fmt.Errorf("at least one resource is required")
 	}
 	switch s.HTTPTransport {
-	case "", HTTPTransportStandard, HTTPTransportBrowserHTTP, HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH3:
+	case "", HTTPTransportStandard, HTTPTransportBrowserHTTP, HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH2, HTTPTransportBrowserChromeH3:
 	default:
-		return fmt.Errorf("http_transport must be one of: standard, browser-http, browser-chrome, browser-chrome-h3")
+		return fmt.Errorf("http_transport must be one of: standard, browser-http, browser-chrome, browser-chrome-h2, browser-chrome-h3")
 	}
 	if err := validateExtraCommands(s.ExtraCommands); err != nil {
 		return err
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index f59984be..ff3ca40d 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -2681,7 +2681,7 @@ func TestHTTPTransportValidationAndDefaults(t *testing.T) {
 
 	sniffed := base
 	sniffed.SpecSource = "sniffed"
-	assert.Equal(t, HTTPTransportBrowserChrome, sniffed.EffectiveHTTPTransport())
+	assert.Equal(t, HTTPTransportBrowserChromeH2, sniffed.EffectiveHTTPTransport())
 	require.NoError(t, sniffed.Validate())
 
 	browserHTTP := base
@@ -2694,13 +2694,31 @@ func TestHTTPTransportValidationAndDefaults(t *testing.T) {
 
 	community := base
 	community.SpecSource = "community"
-	assert.Equal(t, HTTPTransportBrowserChrome, community.EffectiveHTTPTransport())
+	assert.Equal(t, HTTPTransportBrowserChromeH2, community.EffectiveHTTPTransport())
+
+	browserChrome := base
+	browserChrome.HTTPTransport = HTTPTransportBrowserChrome
+	assert.Equal(t, HTTPTransportBrowserChrome, browserChrome.EffectiveHTTPTransport())
+	assert.True(t, browserChrome.UsesBrowserHTTPTransport())
+	assert.False(t, browserChrome.UsesBrowserHTTP2Transport())
+	assert.False(t, browserChrome.UsesBrowserHTTP3Transport())
+	require.NoError(t, browserChrome.Validate())
+
+	h2 := base
+	h2.HTTPTransport = HTTPTransportBrowserChromeH2
+	assert.Equal(t, HTTPTransportBrowserChromeH2, h2.EffectiveHTTPTransport())
+	assert.True(t, h2.UsesBrowserHTTPTransport())
+	assert.True(t, h2.UsesBrowserHTTP2Transport())
+	assert.False(t, h2.UsesBrowserHTTP3Transport())
+	assert.True(t, h2.UsesBrowserManagedUserAgent())
+	require.NoError(t, h2.Validate())
 
 	h3 := base
 	h3.HTTPTransport = HTTPTransportBrowserChromeH3
 	assert.Equal(t, HTTPTransportBrowserChromeH3, h3.EffectiveHTTPTransport())
 	assert.True(t, h3.UsesBrowserHTTPTransport())
 	assert.True(t, h3.UsesBrowserHTTP3Transport())
+	assert.False(t, h3.UsesBrowserHTTP2Transport())
 	assert.True(t, h3.UsesBrowserManagedUserAgent())
 	require.NoError(t, h3.Validate())
 
@@ -2718,11 +2736,11 @@ func TestHTTPTransportValidationAndDefaults(t *testing.T) {
 			},
 		},
 	}
-	assert.Equal(t, HTTPTransportBrowserChrome, cookieHTML.EffectiveHTTPTransport())
+	assert.Equal(t, HTTPTransportBrowserChromeH2, cookieHTML.EffectiveHTTPTransport())
 
 	composedHTML := cookieHTML
 	composedHTML.Auth.Type = "composed"
-	assert.Equal(t, HTTPTransportBrowserChrome, composedHTML.EffectiveHTTPTransport())
+	assert.Equal(t, HTTPTransportBrowserChromeH2, composedHTML.EffectiveHTTPTransport())
 
 	jsonCookie := cookieHTML
 	jsonCookie.Resources = map[string]Resource{
diff --git a/testdata/golden/expected/browser-sniff-sample/traffic-analysis.json b/testdata/golden/expected/browser-sniff-sample/traffic-analysis.json
index 0a2baff6..4f397bb9 100644
--- a/testdata/golden/expected/browser-sniff-sample/traffic-analysis.json
+++ b/testdata/golden/expected/browser-sniff-sample/traffic-analysis.json
@@ -235,6 +235,10 @@
       "data": 1,
       "httpbin.org": 4
     },
+    "http_version_distribution": {
+      "h2": 4,
+      "http/1.1": 1
+    },
     "noise_entry_count": 2,
     "target_url": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwcHgiICBoZWlnaHQ9IjIwMHB4IiAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2aWV3Qm94PSIwIDAgMTAwIDEwMCIgcHJlc2VydmVBc3BlY3RSYXRpbz0ieE1pZFlNaWQiIGNsYXNzPSJsZHMtcm9sbGluZyIgc3R5bGU9ImJhY2tncm91bmQtaW1hZ2U6IG5vbmU7IGJhY2tncm91bmQtcG9zaXRpb246IGluaXRpYWwgaW5pdGlhbDsgYmFja2dyb3VuZC1yZXBlYXQ6IGluaXRpYWwgaW5pdGlhbDsiPjxjaXJjbGUgY3g9IjUwIiBjeT0iNTAiIGZpbGw9Im5vbmUiIG5nLWF0dHItc3Ryb2tlPSJ7e2NvbmZpZy5jb2xvcn19IiBuZy1hdHRyLXN0cm9rZS13aWR0aD0ie3tjb25maWcud2lkdGh9fSIgbmctYXR0ci1yPSJ7e2NvbmZpZy5yYWRpdXN9fSIgbmctYXR0ci1zdHJva2UtZGFzaGFycmF5PSJ7e2NvbmZpZy5kYXNoYXJyYXl9fSIgc3Ryb2tlPSIjNTU1NTU1IiBzdHJva2Utd2lkdGg9IjEwIiByPSIzNSIgc3Ryb2tlLWRhc2hhcnJheT0iMTY0LjkzMzYxNDMxMzQ2NDE1IDU2Ljk3Nzg3MTQzNzgyMTM4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIHR5cGU9InJvdGF0ZSIgY2FsY01vZGU9ImxpbmVhciIgdmFsdWVzPSIwIDUwIDUwOzM2MCA1MCA1MCIga2V5VGltZXM9IjA7MSIgZHVyPSIxcyIgYmVnaW49IjBzIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSI+PC9hbmltYXRlVHJhbnNmb3JtPjwvY2lyY2xlPjwvc3ZnPgo=",
     "time_end": "2026-03-28T22:47:12.3217152Z",

← 63f745b8 fix(skills): split SQL example out of go-fenced block in NUL  ·  back to Cli Printing Press  ·  fix(ci): cancel-in-progress=false so cancelled check_runs do 80ba507e →