Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1d23c36
docs: Propose directional body capabilities and tool-prune plugin
huang195 Sep 1, 2026
39326de
docs: Add plugin metrics channel to the tool-prune proposal
huang195 Sep 1, 2026
6ca3fbb
refactor(authbridge): Rename WritesBody to WritesRequestBody
huang195 Sep 2, 2026
6440927
feat(authbridge): Split body-write capability by direction
huang195 Sep 2, 2026
90bd2fa
feat(authbridge): Add a plugin metrics channel surfaced in abctl
huang195 Sep 2, 2026
356809e
feat(authbridge): Add the tool-prune plugin and abctl tools scan
huang195 Sep 2, 2026
7a5e532
fix(authbridge): Never prune a tool that tool_choice forces
huang195 Sep 2, 2026
4847688
feat(authbridge): Stamp a request id on session events
huang195 Sep 2, 2026
9340821
docs(authbridge): Correct stale body-capability references
huang195 Sep 2, 2026
4ccbaa2
feat(authbridge): Surface the provider's error type on 4xx/5xx
huang195 Sep 2, 2026
e9bd614
test(abctl): Pin event pairing against a real captured trace
huang195 Sep 2, 2026
dc58213
refactor(authbridge): Make the remove list the only switch for tool-p…
huang195 Sep 2, 2026
ae3f0a3
fix(authbridge): Make tool-prune's silence diagnosable
huang195 Sep 2, 2026
f33d066
fix(abctl): Refresh plugin metrics instead of freezing them at connect
huang195 Sep 2, 2026
1c9bdfa
feat(authbridge): Cost tool-prune's saving per prompt-cache tier
huang195 Sep 2, 2026
cc90c02
feat(authbridge): Price tool-prune's saving per model
huang195 Sep 2, 2026
7269213
refactor(abctl): Drop the request/response bracket glyphs
huang195 Sep 2, 2026
74b8cba
docs: Add a laptop quickstart for cutting Claude Code token cost
huang195 Sep 2, 2026
581ce7e
docs: Remove one gateway's negotiated rates from the plugin docs
huang195 Sep 2, 2026
48d78f5
feat(authbridge): Ship default per-model rates so cost works unconfig…
huang195 Sep 2, 2026
834c51a
feat: Show per-request token and cost saving in abctl's TOKENS column
huang195 Sep 2, 2026
1fc8cf6
fix(abctl): Attribute the prune saving to the request, not the response
huang195 Sep 2, 2026
d85b4e3
fix: Address code review on the tool-prune series
huang195 Sep 2, 2026
1a71a91
fix: Address review pass 2 — six blockers on tool-prune
huang195 Sep 2, 2026
a4128d2
fix: Address review pass 3 — accounting, predicate split, drift guard
huang195 Sep 2, 2026
c4fd112
feat: Key tool-prune pricing by model family, not version
huang195 Sep 3, 2026
7d63fba
feat: Accept tool-prune pricing per million tokens
huang195 Sep 3, 2026
0096fa0
fix: Address review findings — observe-mode accounting, id collisions…
huang195 Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
context: ./authbridge
dockerfile: cmd/authbridge-proxy/Dockerfile
build_args: |
GO_BUILD_TAGS=exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker
GO_BUILD_TAGS=exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker,exclude_plugin_toolprune

# AuthBridge proxy-sidecar CPEX image — authbridge-proxy built
# with -tags cpex (links libcpex_ffi.a from a pinned CPEX
Expand Down Expand Up @@ -119,7 +119,7 @@
# Always use the computed tag
type=raw,value=${{ steps.tag.outputs.tag }}
# Add 'latest' tag for version tags, workflow_dispatch, and pushes to main
type=raw,value=latest,enable=${{ (github.ref_type == 'tag' && startsWith(github.ref_name, 'v')) || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }}

Check warning on line 122 in .github/workflows/build.yaml

View workflow job for this annotation

GitHub Actions / YAML Lint

122:151 [line-length] line too long (189 > 150 characters)

# 6b. Resolve build-args. authbridge-cpex needs CPEX_FFI_VERSION
# (the release tag) and CPEX_FFI_ABI (the FFI ABI integer the
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ jobs:
run: |
TAGS="exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser"
TAGS="$TAGS,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker"
TAGS="$TAGS,exclude_plugin_toolprune"
go build -v -tags "$TAGS" ./...
go test -v -race -cover -tags "$TAGS" ./...

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ cd authbridge && podman build -f cmd/authbridge-proxy/Dockerfile -t authbridge:l
cd authbridge && podman build -f cmd/authbridge-envoy/Dockerfile -t authbridge-envoy:latest .
# authbridge-lite: same proxy Dockerfile, built with exclude_plugin_* tags (auth-only)
cd authbridge && podman build -f cmd/authbridge-proxy/Dockerfile \
--build-arg GO_BUILD_TAGS="exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker" \
--build-arg GO_BUILD_TAGS="exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker,exclude_plugin_toolprune" \
-t authbridge-lite:latest .
```

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ Watch an AI agent's traffic — its model, tool, and agent-to-agent calls — de

Its calls stream into `abctl`, decrypted and parsed.

## Cut Claude Code token cost on your laptop

Already using Claude Code? Cortex can strip the tool definitions your agent never
calls out of every request. Measured over 99 requests in one session: **4–20% of
the prompt billed per turn, median 6%**. The share is highest early — the removed
bytes are a fixed size, so as the conversation grows they shrink as a fraction of
it — and depends on how many of the tools you actually use. Four steps, about two minutes:
**[Cut Claude Code token cost](./authbridge/docs/laptop-token-savings.md)**.

## Running on Kubernetes

In a cluster, Cortex sidecars are injected automatically by the [operator](https://github.com/rossoctl/operator), with Keycloak + SPIFFE/SPIRE for identity and token exchange. Start with the end-to-end **[Weather Agent walkthrough](./authbridge/demos/weather-agent/demo-ui.md)** (or the [`abctl` version](./authbridge/demos/weather-agent/demo-with-abctl.md)); see the [demos index](./authbridge/demos/README.md) and the [architecture reference](./authbridge/README.md) for all modes and details.
Expand Down
22 changes: 20 additions & 2 deletions authbridge/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ binaries with shared auth logic in `authlib/`:

- `cmd/authbridge-proxy/` — proxy-sidecar mode (default). HTTP forward + reverse
proxies. Compiles in every plugin by default (jwt-validation, token-exchange,
a2a-parser, mcp-parser, inference-parser, opa, sparc, ibac, token-broker).
a2a-parser, mcp-parser, inference-parser, opa, sparc, ibac, token-broker,
tool-prune).
**Every** plugin is excludable via `-tags exclude_plugin_<name>` — one
`plugins_<name>.go` file per plugin, gated by `//go:build !exclude_plugin_<name>`;
`main.go` imports no plugin package directly. **Exception:** `context-guru` is
Expand Down Expand Up @@ -154,6 +155,23 @@ wants to register.
- `authlib/pipeline/` -- Plugin interface + lifecycle (`Configurable`, `Initializer`, `Shutdowner`); see [`docs/framework-architecture.md`](docs/framework-architecture.md)
- `authlib/plugins/` -- The concrete plugins + registry; see [`docs/plugin-reference.md`](docs/plugin-reference.md) for the per-plugin config convention

**Directional body capabilities.** `PluginCapabilities` declares body writes
per direction: `WritesRequestBody` (calls `pctx.SetBody`) and
`WritesResponseBody` (calls `pctx.SetResponseBody`). `WritesResponseBody` is the
SSE streaming predicate — both proxy listeners fall back from incremental relay
to the buffered path only when some plugin declares it. A request-only mutator
(`tool-prune`, `context-guru`) therefore keeps streaming, because requests are
never streamed in the first place. `pipeline.New` allows at most one mutator per
direction, and no mutator of either direction may precede a `ReadsBody`-only
plugin. See [`docs/plugin-reference.md`](docs/plugin-reference.md#capability-fields).

**Plugin metrics.** Plugins that implement `pipeline.MetricsProvider` have their
counters surfaced on `GET /v1/pipeline` and rendered in abctl's plugin pane.
Optional interfaces are not promoted through `configuredPlugin`'s embedded
`Plugin`, so a new one must be forwarded there explicitly or it is invisible for
every plugin that has config. Counters are per-process and reset on restart
**and on config hot-reload**.

**Plugin classification.** Protocol parsers (`mcp-parser`, `a2a-parser`, `inference-parser`) populate an `IsAction bool` field on their respective extensions to classify each request as either a user-meaningful action or protocol mechanics. Default-false means "not classified as action" — guardrails treat it as bypass. Parsers explicitly set `IsAction = true` for the small set of action methods (`tools/call` / `prompts/get` / `resources/read` for MCP; `message/send` / `message/stream` for A2A; every populated case for inference). Guardrails (`ibac` today; future rate limiters, audit loggers, etc.) read the aggregated verdict via `pctx.Classification()` which returns `(anyAction, anyBypass)`. A defense-in-depth guardrail skips on `anyBypass`, passes through on `!anyAction` (no parser claimed this traffic), and judges only when `anyAction && !anyBypass`. This puts the protocol-specific bypass-vs-action vocabulary in each parser — adding a new guardrail or new protocol does not multiply work at the guardrail layer. See [`docs/plugin-reference.md` "Classifying requests"](docs/plugin-reference.md#classifying-requests-as-actions-vs-protocol-mechanics) for the contract.

### init-iptables.sh
Expand Down Expand Up @@ -377,7 +395,7 @@ podman build -f cmd/authbridge-proxy/Dockerfile -t authbridge:latest . # p
podman build -f cmd/authbridge-envoy/Dockerfile -t authbridge-envoy:latest . # envoy-sidecar
# authbridge-lite: the proxy Dockerfile built with exclude_plugin_* tags (auth-only)
podman build -f cmd/authbridge-proxy/Dockerfile \
--build-arg GO_BUILD_TAGS="exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker" \
--build-arg GO_BUILD_TAGS="exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker,exclude_plugin_toolprune" \
-t authbridge-lite:latest .
kind load docker-image authbridge:latest --name rossoctl
kind load docker-image authbridge-envoy:latest --name rossoctl
Expand Down
4 changes: 2 additions & 2 deletions authbridge/authlib/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ require (
github.com/open-policy-agent/opa v1.20.1
github.com/rossoctl/context-guru v0.1.0
github.com/spiffe/go-spiffe/v2 v2.8.1
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
golang.org/x/net v0.58.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
Expand Down Expand Up @@ -90,10 +92,8 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/tchap/go-patricia/v2 v2.3.3 // indirect
github.com/tetratelabs/wazero v1.12.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tiktoken-go/tokenizer v0.7.0 // indirect
github.com/tree-sitter/go-tree-sitter v0.25.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
Expand Down
8 changes: 7 additions & 1 deletion authbridge/authlib/listener/extproc/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ func (s *Server) recordInboundSession(pctx *pipeline.Context) {
At: time.Now(),
Direction: pipeline.Inbound,
Phase: pipeline.SessionRequest,
RequestID: pctx.RequestID(),
A2A: pipeline.SnapshotA2A(pctx.Extensions.A2A),
Invocations: pipeline.SnapshotInvocations(pctx.Extensions.Invocations, pipeline.InvocationPhaseRequest),
Plugins: plugins,
Expand Down Expand Up @@ -279,6 +280,7 @@ func (s *Server) recordInboundReject(pctx *pipeline.Context, action pipeline.Act
At: time.Now(),
Direction: pipeline.Inbound,
Phase: pipeline.SessionDenied,
RequestID: pctx.RequestID(),
Invocations: pipeline.SnapshotInvocations(pctx.Extensions.Invocations, pipeline.InvocationPhaseRequest),
Plugins: pipeline.SnapshotPlugins(pctx.Extensions.Custom),
Identity: pipeline.SnapshotIdentity(pctx),
Expand Down Expand Up @@ -332,6 +334,7 @@ func (s *Server) recordOutboundReject(pctx *pipeline.Context, action pipeline.Ac
At: time.Now(),
Direction: pipeline.Outbound,
Phase: pipeline.SessionDenied,
RequestID: pctx.RequestID(),
Invocations: pipeline.SnapshotInvocations(pctx.Extensions.Invocations, pipeline.InvocationPhaseRequest),
Plugins: pipeline.SnapshotPlugins(pctx.Extensions.Custom),
Identity: pipeline.SnapshotIdentity(pctx),
Expand Down Expand Up @@ -371,6 +374,7 @@ func (s *Server) recordInboundResponseSession(pctx *pipeline.Context) {
At: time.Now(),
Direction: pipeline.Inbound,
Phase: pipeline.SessionResponse,
RequestID: pctx.RequestID(),
A2A: pipeline.SnapshotA2A(pctx.Extensions.A2A),
Invocations: pipeline.SnapshotInvocations(pctx.Extensions.Invocations, pipeline.InvocationPhaseResponse),
Plugins: plugins,
Expand Down Expand Up @@ -399,6 +403,7 @@ func (s *Server) recordOutboundResponseSession(pctx *pipeline.Context) {
At: time.Now(),
Direction: pipeline.Outbound,
Phase: pipeline.SessionResponse,
RequestID: pctx.RequestID(),
MCP: pipeline.SnapshotMCP(pctx.Extensions.MCP),
Inference: pipeline.SnapshotInference(pctx.Extensions.Inference),
Invocations: pipeline.SnapshotInvocations(pctx.Extensions.Invocations, pipeline.InvocationPhaseResponse),
Expand Down Expand Up @@ -446,6 +451,7 @@ func (s *Server) recordOutboundSession(pctx *pipeline.Context) {
At: time.Now(),
Direction: pipeline.Outbound,
Phase: pipeline.SessionRequest,
RequestID: pctx.RequestID(),
MCP: pipeline.SnapshotMCP(pctx.Extensions.MCP),
Inference: pipeline.SnapshotInference(pctx.Extensions.Inference),
Invocations: pipeline.SnapshotInvocations(pctx.Extensions.Invocations, pipeline.InvocationPhaseRequest),
Expand Down Expand Up @@ -657,7 +663,7 @@ func (s *Server) handleResponseBody(ctx context.Context, body []byte, pctx *pipe
s.recordOutboundResponseSession(pctx)
}

// A plugin that declared WritesBody: true and called pctx.SetResponseBody
// A plugin that declared WritesResponseBody: true and called pctx.SetResponseBody
// flips the ResponseBodyMutated flag. Emit the replacement bytes via
// BodyMutation so Envoy rewrites the downstream response; otherwise
// pass through with no mutation. The flag avoids the O(n) string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ type responseMutator struct{ newBody []byte }

func (*responseMutator) Name() string { return "response-mutator" }
func (*responseMutator) Capabilities() pipeline.PluginCapabilities {
return pipeline.PluginCapabilities{WritesBody: true}
return pipeline.PluginCapabilities{WritesResponseBody: true}
}
func (*responseMutator) OnRequest(context.Context, *pipeline.Context) pipeline.Action {
return pipeline.Action{Type: pipeline.Continue}
Expand Down
6 changes: 3 additions & 3 deletions authbridge/authlib/listener/extproc/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ func (p *bodyRecorderPlugin) OnResponse(_ context.Context, _ *pipeline.Context)
return pipeline.Action{Type: pipeline.Continue}
}

// bodyMutatorPlugin declares WritesBody and rewrites pctx.Body via
// bodyMutatorPlugin declares WritesRequestBody and rewrites pctx.Body via
// SetBody. Used to assert extproc emits a BodyMutation on the wire
// when a plugin rewrites the request body.
type bodyMutatorPlugin struct {
Expand All @@ -407,7 +407,7 @@ type bodyMutatorPlugin struct {

func (p *bodyMutatorPlugin) Name() string { return "body-mutator" }
func (p *bodyMutatorPlugin) Capabilities() pipeline.PluginCapabilities {
return pipeline.PluginCapabilities{WritesBody: true}
return pipeline.PluginCapabilities{WritesRequestBody: true}
}
func (p *bodyMutatorPlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action {
pctx.SetBody(p.newBody)
Expand All @@ -417,7 +417,7 @@ func (p *bodyMutatorPlugin) OnResponse(_ context.Context, _ *pipeline.Context) p
return pipeline.Action{Type: pipeline.Continue}
}

// TestExtProc_RequestBodyMutation_Inbound: a WritesBody plugin must
// TestExtProc_RequestBodyMutation_Inbound: a WritesRequestBody plugin must
// produce a RequestBody ProcessingResponse carrying BodyMutation with
// the new bytes, and the header mutation must request content-encoding
// be removed.
Expand Down
151 changes: 151 additions & 0 deletions authbridge/authlib/listener/forwardproxy/bridgehealth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package forwardproxy

import (
"strings"
"sync"
"testing"

"github.com/rossoctl/cortex/authbridge/authlib/tlsbridge"
)

// TestNoteBridgeAttempt_WarnsOnlyWhenNothingIsEverDecrypted covers the failure
// that looks like a plugin bug: the bridge is on, the client does not trust its
// CA, so every HTTPS request opens an opaque tunnel and every body-reading
// plugin correctly does nothing. Nothing errors — the only symptom is silence.
//
// Driven through noteBridgeAttempt, not noteTunnel: the trigger is a CONNECT the
// bridge actually tried to decrypt. See TestNoteTunnel_PassthroughNeverWarns.
func TestNoteBridgeAttempt_WarnsOnlyWhenNothingIsEverDecrypted(t *testing.T) {
tests := []struct {
name string
bridge *tlsbridge.Engine
tunnels int
bridged uint64
wantWarns int
}{
{
name: "bridge disabled: never warn, tunnels are the expected behaviour",
bridge: nil,
tunnels: 50,
wantWarns: 0,
},
{
name: "below threshold: a few attempts are normal (startup races)",
bridge: &tlsbridge.Engine{},
tunnels: tunnelWarnThreshold - 1,
wantWarns: 0,
},
{
name: "attempts but something was decrypted: bridge is working",
bridge: &tlsbridge.Engine{},
tunnels: 50,
bridged: 1,
wantWarns: 0,
},
{
name: "many attempts, nothing decrypted: warn",
bridge: &tlsbridge.Engine{},
tunnels: tunnelWarnThreshold,
wantWarns: 1,
},
{
name: "and only once, however much traffic follows",
bridge: &tlsbridge.Engine{},
tunnels: 200,
wantWarns: 1,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := &Server{TLSBridge: tc.bridge}
s.bridgedRequests.Store(tc.bridged)
var warns int
// bridgeWarnOnce is the mechanism under test; count how many times
// the guarded block would run by observing the sync.Once directly.
for i := 0; i < tc.tunnels; i++ {
before := s.warnFired()
s.noteBridgeAttempt()
if !before && s.warnFired() {
warns++
}
}
if warns != tc.wantWarns {
t.Errorf("warned %d times, want %d", warns, tc.wantWarns)
}
})
}
}

// TestCaFileHint_NamesTheAbsolutePath: a relative path in the fix hint is only
// correct for someone standing in the directory --demo was launched from, which
// is precisely how the trust anchor gets mismatched in the first place.
func TestCaFileHint_NamesTheAbsolutePath(t *testing.T) {
s := &Server{TLSBridge: &tlsbridge.Engine{CAFile: "/abs/cortex-ca/ca.crt"}}
if got := s.caFileHint(); got != "/abs/cortex-ca/ca.crt" {
t.Errorf("caFileHint() = %q", got)
}
// Degrade to a placeholder rather than an empty string, so the log line
// still reads as an instruction.
bare := &Server{TLSBridge: &tlsbridge.Engine{}}
if got := bare.caFileHint(); !strings.Contains(got, "ca.crt") {
t.Errorf("caFileHint() = %q, want something naming ca.crt", got)
}
}

// TestNoteTunnel_ConcurrentIsRaceFree: tunnels open on many goroutines.
func TestNoteTunnel_ConcurrentIsRaceFree(t *testing.T) {
s := &Server{TLSBridge: &tlsbridge.Engine{}}
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 64; j++ {
s.noteTunnel()
}
}()
}
wg.Wait()
if got := s.tunnelsOpened.Load(); got != 16*64 {
t.Errorf("tunnelsOpened = %d, want %d", got, 16*64)
}
}

// TestNoteTunnel_PassthroughNeverWarns is the regression this split exists for.
// A CONNECT to a host in TLSBridge.Skip, or one classification chose to pass
// through, is intentional — it is not evidence of a broken CA. Counting those
// let a correctly-configured proxy cry wolf, and because the warning is
// once-only, the false positive then MASKED the real failure if it came later.
func TestNoteTunnel_PassthroughNeverWarns(t *testing.T) {
s := &Server{TLSBridge: &tlsbridge.Engine{}}
for i := 0; i < tunnelWarnThreshold*20; i++ {
s.noteTunnel()
}
if s.warnFired() {
t.Error("warned about intentional passthrough tunnels")
}
// The warning must still be available afterwards for a genuine failure —
// i.e. the sync.Once was not burned by the passthrough traffic above.
for i := 0; i < tunnelWarnThreshold; i++ {
s.noteBridgeAttempt()
}
if !s.warnFired() {
t.Error("real bridge failure did not warn after passthrough traffic")
}
}

// TestNoteBridgeHandshakeFailure_WarnsImmediately: a refused forged certificate
// is proof, so it must not wait for a threshold. It especially must not, because
// the refusal adds the host to Skip — later requests never reach
// noteBridgeAttempt, so the threshold alone would never be crossed.
func TestNoteBridgeHandshakeFailure_WarnsImmediately(t *testing.T) {
s := &Server{TLSBridge: &tlsbridge.Engine{}}
s.noteBridgeAttempt() // one attempt, well below threshold
if s.warnFired() {
t.Fatal("warned on a single attempt")
}
s.noteBridgeHandshakeFailure()
if !s.warnFired() {
t.Error("a rejected bridge certificate did not warn")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func TestForwardProxy_SSE_StreamsWithoutResponder(t *testing.T) {
store := session.New(5*time.Minute, 100, 0)
defer store.Close()

// Empty pipeline: HasStreamingResponders()==false and WritesBody()==false,
// Empty pipeline: HasStreamingResponders()==false and WritesRequestBody()==false,
// so serveOutbound routes to streamPassthrough — the reporter's plain-proxy
// shape (their only outbound plugin, token-exchange, is likewise not a
// StreamingResponder).
Expand Down
Loading
Loading