From fc97d4f2001567f313527751b002e4e1a85d4688 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 6 Jul 2026 13:59:26 -0600 Subject: [PATCH 1/2] feat: add ConfigRequest.Capabilities for client capability negotiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Capabilities []string field to ConfigRequest and the first capability constant, CapabilityNonSelectableOutbounds ("non_selectable_outbounds"): the client advertises that it honors ConfigResponse.NonSelectableOutbounds so the server can gate infrastructure outbounds (e.g. a proxyless download_detour) on the capability instead of sniffing the client version. Future optional outbounds reuse the same signal — no new token per feature. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WGpKfgkbfdJwwsTaouMDoR --- types.go | 20 ++++++++++++++++++-- types_test.go | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/types.go b/types.go index ee35bf2..eba4074 100644 --- a/types.go +++ b/types.go @@ -21,6 +21,19 @@ const ( UNBOUNDED = "unbounded" ) +// Client capabilities, advertised in ConfigRequest.Capabilities, let the server +// enable optional behavior per-client — capability negotiation instead of +// version sniffing. +const ( + // CapabilityNonSelectableOutbounds: the client honors + // ConfigResponse.NonSelectableOutbounds (merges those outbounds into its box + // config but keeps them out of the proxy-selection groups). The server gates + // infrastructure outbounds like the proxyless download_detour on this, so an + // older client can't surface one as a selectable proxy and route traffic + // through it. + CapabilityNonSelectableOutbounds = "non_selectable_outbounds" +) + type ServerLocation struct { Country string `json:"country,omitempty"` City string `json:"city,omitempty"` @@ -120,6 +133,9 @@ type ConfigRequest struct { Backend string `json:"backend,omitempty"` Locale string `json:"locale,omitempty"` Protocols []string `json:"protocols,omitempty"` - MetricsOptedIn bool `json:"metrics_opted_in,omitempty"` - Version string `json:"version,omitempty"` + // Capabilities advertises optional client behaviors the server can gate on + // (see the Capability* consts), e.g. honoring NonSelectableOutbounds. + Capabilities []string `json:"capabilities,omitempty"` + MetricsOptedIn bool `json:"metrics_opted_in,omitempty"` + Version string `json:"version,omitempty"` } diff --git a/types_test.go b/types_test.go index 96ac310..85ca2d9 100644 --- a/types_test.go +++ b/types_test.go @@ -149,6 +149,28 @@ func TestNonSelectableOutboundsRoundTrip(t *testing.T) { } } +func TestCapabilitiesRoundTrip(t *testing.T) { + original := ConfigRequest{ + Capabilities: []string{CapabilityNonSelectableOutbounds}, + } + + data, err := json.Marshal(original) + assert.NoError(t, err) + assert.Contains(t, string(data), `"capabilities"`) + + var deserialized ConfigRequest + err = json.Unmarshal(data, &deserialized) + assert.NoError(t, err) + assert.Equal(t, []string{CapabilityNonSelectableOutbounds}, deserialized.Capabilities) + + // Both nil and a non-nil empty slice should be omitted from JSON. + for _, empty := range []ConfigRequest{{}, {Capabilities: []string{}}} { + data, err = json.Marshal(empty) + assert.NoError(t, err) + assert.NotContains(t, string(data), "capabilities") + } +} + func TestConfigResponseDefaultValues(t *testing.T) { resp := ConfigResponse{} if len(resp.Servers) != 0 { From de5009fbf8e27df91daed08777f688c3d26de480 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 6 Jul 2026 14:16:48 -0600 Subject: [PATCH 2/2] test: match the quoted JSON key in the capabilities omit assertion Use `"capabilities"` (with quotes) so the omit check can't false-positive on a bare substring in a value, consistent with the positive assertion. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WGpKfgkbfdJwwsTaouMDoR --- types_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types_test.go b/types_test.go index 85ca2d9..4d01f66 100644 --- a/types_test.go +++ b/types_test.go @@ -167,7 +167,7 @@ func TestCapabilitiesRoundTrip(t *testing.T) { for _, empty := range []ConfigRequest{{}, {Capabilities: []string{}}} { data, err = json.Marshal(empty) assert.NoError(t, err) - assert.NotContains(t, string(data), "capabilities") + assert.NotContains(t, string(data), `"capabilities"`) } }