Skip to content

Commit 72f9d38

Browse files
committed
Feat(abctl): one row per network message, show every message
The session timeline was confusing for two reasons: messages no plugin processed were invisible, and a message processed/skipped by N plugins showed as N rows (plus a separate row for a TLS-bridged call's CONNECT). Server (authlib): relax the four-clause append gate to unconditional at every accept-path emit site (forward-proxy request/response, transparent tunnel-open, reverse-proxy request/response/streaming) so every message the pipeline saw is recorded — including passthrough requests and generic responses (e.g. a 404) no plugin touched. The !skipped guards stay, so listener.skip_hosts traffic remains suppressed. Raise session.max_events default 100 -> 500 in all three binaries to absorb the ~2x volume. abctl: render one table.Row per SessionEvent. eventAction() folds a message's per-plugin invocations into one ACTION + PLUGIN cell, headlining the highest-ranked ENFORCED action — deny > modify > observe > allow > skip. observe outranks allow so a parser (which supplies METHOD) headlines over a gate that merely allowed; a skip-only or no-plugin message shows passthrough markers ("—") rather than crediting a plugin that declined to act. A shadow deny/modify never headlines over the action that really took effect (the pipeline enforces deny only when !Shadow); it is surfaced with a trailing "*" instead (e.g. "allow*"), or headlines alone ("deny*") when nothing enforced acted. The detail pane shows the whole event; a bridged row also folds the CONNECT tunnel's gate invocations into its ACTION and inactive-filter view. A TLS-bridge CONNECT tunnel folds into the decrypted inner request that follows it, so a bridged call is one request row + one response row, with a "tunnel:" summary in the detail pane. Two back-to-back passthrough CONNECTs to the same host are NOT folded (each is its own message). Event-level span glyphs in PHASE bracket each request/response exchange and nest outbound calls under the inbound request that caused them. The `s` key hides passthrough/skip-only messages (default off = show all). Replaces the per-invocation row model: drops flattenInvocations and the per-invocation pairing; the span-glyph rendering now operates on events, so a multi-plugin message no longer duplicates into one row per plugin. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
1 parent 564f862 commit 72f9d38

14 files changed

Lines changed: 1190 additions & 988 deletions

File tree

authbridge/authlib/config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ type SessionConfig struct {
198198
// "user didn't say" with "user said false" and silently flip the default.
199199
Enabled *bool `yaml:"enabled" json:"enabled"`
200200
TTL string `yaml:"ttl" json:"ttl"` // duration string; default: 30m
201-
MaxEvents int `yaml:"max_events" json:"max_events"` // max events per session; default: 100
201+
MaxEvents int `yaml:"max_events" json:"max_events"` // max events per session; default: 500
202202
MaxSessions int `yaml:"max_sessions" json:"max_sessions"` // max concurrent sessions; default: 100 (0 = unlimited)
203203
}
204204

authbridge/authlib/listener/forwardproxy/server.go

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -307,16 +307,14 @@ func (s *Server) serveOutbound(w http.ResponseWriter, r *http.Request, isBridge
307307
Identity: pipeline.SnapshotIdentity(pctx),
308308
Host: pctx.Host,
309309
}
310-
// Record whenever ANY protocol-or-plugin context is present —
311-
// MCP/Inference (parser-emitted), Invocations (gate plugins like
312-
// jwt-validation/token-exchange), or plugin-public Plugins
313-
// entries. Earlier the gate was just MCP||Inference; widening
314-
// it ensures auth-only outbound traffic and pure observability
315-
// events show up in abctl. Don't narrow this back without
316-
// understanding why each clause is necessary.
317-
if ev.MCP != nil || ev.Inference != nil || ev.Invocations != nil || plugins != nil {
318-
s.Sessions.Append(sid, ev)
319-
}
310+
// Record EVERY message that reaches the pipeline — even when no
311+
// plugin acted and no parser matched (Invocations/MCP/Inference all
312+
// nil). The session API is an observability surface; a request the
313+
// pipeline saw but no plugin touched is still a network message the
314+
// operator wants to see (it carries Host, and the paired response
315+
// carries StatusCode). skip_hosts traffic never reaches here (the
316+
// !skipped guard above), so it stays suppressed by design.
317+
s.Sessions.Append(sid, ev)
320318
}
321319

322320
newAuth := pctx.Headers.Get("Authorization")
@@ -519,11 +517,10 @@ func (s *Server) recordOutboundResponseEvent(pctx *pipeline.Context, statusCode
519517
Error: pipeline.DeriveError(pctx),
520518
Duration: pipeline.DurationSince(pctx.StartedAt),
521519
}
522-
// Same widened gate as the request side — see the request-phase
523-
// comment for why each clause matters.
524-
if ev.MCP != nil || ev.Inference != nil || ev.Invocations != nil || plugins != nil {
525-
s.Sessions.Append(sid, ev)
526-
}
520+
// Always record — see the request-phase comment. This is what surfaces
521+
// responses no plugin acted on (e.g. a generic 404), carrying StatusCode
522+
// + Error even with empty invocations.
523+
s.Sessions.Append(sid, ev)
527524
}
528525

529526
// isEventStream reports whether a Content-Type header value names the

authbridge/authlib/listener/forwardproxy/server_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,57 @@ func TestRecordOutboundReject_SkipsWithoutInvocations(t *testing.T) {
567567
}
568568
}
569569

570+
// TestForwardProxy_RecordsMessageWithNoPluginActivity locks the Part A
571+
// behavior: a request/response that no plugin acted on (empty pipeline,
572+
// no parser match) is still recorded as two session events so abctl can
573+
// show every network message — not just the ones a plugin touched. The
574+
// response event carries the upstream status even though Invocations is
575+
// nil.
576+
func TestForwardProxy_RecordsMessageWithNoPluginActivity(t *testing.T) {
577+
store := session.New(5*time.Minute, 100, 0)
578+
defer store.Close()
579+
580+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
581+
w.WriteHeader(http.StatusNotFound)
582+
}))
583+
defer backend.Close()
584+
585+
// Empty pipeline: zero plugins, so no Invocations are ever appended.
586+
p, err := pipeline.New([]pipeline.Plugin{})
587+
if err != nil {
588+
t.Fatal(err)
589+
}
590+
srv := &Server{OutboundPipeline: pipeline.NewHolder(p), Sessions: store, Client: http.DefaultClient}
591+
proxy := httptest.NewServer(srv.Handler())
592+
defer proxy.Close()
593+
594+
req, _ := http.NewRequest("GET", backend.URL+"/missing", nil)
595+
proxyClient := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(proxy.URL))}}
596+
resp, err := proxyClient.Do(req)
597+
if err != nil {
598+
t.Fatalf("request failed: %v", err)
599+
}
600+
resp.Body.Close()
601+
if resp.StatusCode != http.StatusNotFound {
602+
t.Errorf("status = %d, want 404", resp.StatusCode)
603+
}
604+
605+
v := store.View(session.DefaultSessionID)
606+
if v == nil || len(v.Events) != 2 {
607+
t.Fatalf("expected 2 events (request + response) with no plugin activity, got %+v", v)
608+
}
609+
reqEv, respEv := v.Events[0], v.Events[1]
610+
if reqEv.Phase != pipeline.SessionRequest || reqEv.Invocations != nil {
611+
t.Errorf("request event = phase %v invocations %+v, want SessionRequest / nil", reqEv.Phase, reqEv.Invocations)
612+
}
613+
if respEv.Phase != pipeline.SessionResponse || respEv.StatusCode != http.StatusNotFound {
614+
t.Errorf("response event = phase %v status %d, want SessionResponse / 404", respEv.Phase, respEv.StatusCode)
615+
}
616+
if respEv.Invocations != nil {
617+
t.Errorf("response event invocations = %+v, want nil (no plugin acted)", respEv.Invocations)
618+
}
619+
}
620+
570621
// schemeCapturePlugin captures pctx.Scheme for the scheme-wiring
571622
// test below.
572623
type schemeCapturePlugin struct {

authbridge/authlib/listener/forwardproxy/transparent.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,10 @@ func (s *Server) recordTunnelOpened(pctx *pipeline.Context) {
180180
Identity: pipeline.SnapshotIdentity(pctx),
181181
Host: pctx.Host,
182182
}
183-
if ev.Invocations != nil || plugins != nil {
184-
s.Sessions.Append(sid, ev)
185-
}
183+
// Always record the tunnel-open so passthrough/non-bridged tunnels (no
184+
// plugin activity) are still visible. For a TLS-bridged call abctl folds
185+
// this CONNECT event into the decrypted inner-request row.
186+
s.Sessions.Append(sid, ev)
186187
}
187188

188189
// tunnel bidirectionally copies between two connections until either side

authbridge/authlib/listener/reverseproxy/server.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,9 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
256256
// as denials already do via recordInboundReject). The A2A-specific session
257257
// rekey in modifyResponse stays A2A-gated.
258258
plugins := pipeline.SnapshotPlugins(pctx.Extensions.Custom)
259-
if s.Sessions != nil && (pctx.Extensions.A2A != nil || pctx.Extensions.Invocations != nil || plugins != nil) {
259+
// Record every inbound request the pipeline saw, even with no plugin
260+
// activity (skip_hosts is N/A inbound).
261+
if s.Sessions != nil {
260262
sid := inboundSessionID(pctx)
261263
// Snapshot-copy the protocol extension and use the shared helpers
262264
// for plugin invocations / observability / identity. Mirrors what
@@ -378,7 +380,8 @@ func (s *Server) modifyResponse(resp *http.Response) error {
378380
// pipeline saw at this point (may be empty for streamed bodies),
379381
// but the status code and plugin invocations are always meaningful.
380382
plugins := pipeline.SnapshotPlugins(pctx.Extensions.Custom)
381-
if s.Sessions != nil && (pctx.Extensions.A2A != nil || pctx.Extensions.Invocations != nil || plugins != nil) {
383+
// Always pair every inbound request with a response row (carries StatusCode).
384+
if s.Sessions != nil {
382385
sid := inboundSessionID(pctx)
383386
s.Sessions.Append(sid, pipeline.SessionEvent{
384387
At: time.Now(),
@@ -513,9 +516,8 @@ func (s *Server) recordInboundResponseEvent(pctx *pipeline.Context, statusCode i
513516
return
514517
}
515518
plugins := pipeline.SnapshotPlugins(pctx.Extensions.Custom)
516-
if !(pctx.Extensions.A2A != nil || pctx.Extensions.Invocations != nil || plugins != nil) {
517-
return
518-
}
519+
// Always record the streaming response (carries StatusCode), even with
520+
// no plugin activity.
519521
sid := inboundSessionID(pctx)
520522
// Rekey default → contextId mirroring the buffered path's behavior;
521523
// streaming A2A message/stream may discover the contextId mid-stream.

authbridge/cmd/abctl/tui/app.go

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -193,41 +193,43 @@ type model struct {
193193
filter string
194194
filtering bool
195195
paused bool
196-
// showSkips toggles whether Action=skip rows render in the events
197-
// table. False (default) hides them — most operators care about
198-
// allow/deny/modify/observe events. Toggle with `s`. hiddenSkips
199-
// is the count from the most recent rebuildEventsTable, surfaced
200-
// in the footer so a sparse-looking timeline doesn't read as
201-
// data loss.
202-
showSkips bool
203-
hiddenSkips int
204-
flash string
205-
flashUntil time.Time
206-
width, height int
196+
// hideInactive toggles whether passthrough / skip-only messages are
197+
// hidden from the events table. False (default) shows every message —
198+
// the operator asked to see all network traffic, processed or not.
199+
// Toggle with `s` to focus on plugin activity (deny/modify/allow/
200+
// observe). hiddenInactive is the count from the most recent
201+
// rebuildEventsTable, surfaced in the footer so a filtered timeline
202+
// doesn't read as data loss.
203+
hideInactive bool
204+
hiddenInactive int
205+
flash string
206+
flashUntil time.Time
207+
width, height int
207208
// bodyHeight is the inner height available to panes (terminal height
208209
// minus title + footer). Cached by layout() so rebuildEventsTable can
209210
// size the events table after accounting for the IDENTITY banner.
210211
bodyHeight int
211212

212213
// Panel components.
213-
sessionsTbl table.Model
214-
eventsTbl table.Model
215-
pipelineTbl table.Model
216-
catalogTbl table.Model
217-
detailVp viewport.Model
218-
detailEvent *pipeline.SessionEvent
219-
// detailInvocation is the plugin invocation selected in the events pane
220-
// when the detail view was opened. Used to re-scope the event to that
221-
// plugin on resize/re-render. nil means "whole event" (no invocation).
222-
detailInvocation *pipeline.Invocation
223-
detailPlugin *apiclient.PipelinePlugin
214+
sessionsTbl table.Model
215+
eventsTbl table.Model
216+
pipelineTbl table.Model
217+
catalogTbl table.Model
218+
detailVp viewport.Model
219+
detailEvent *pipeline.SessionEvent
220+
// detailRow is the full events-pane row (event + any folded CONNECT
221+
// tunnel) the detail view was opened on. Kept so layout() can re-render
222+
// the detail pane on resize without re-deriving the tunnel fold.
223+
detailRow eventRow
224+
detailPlugin *apiclient.PipelinePlugin
224225
filterInput textinput.Model
225226

226-
// visibleRows holds the invocationRow spec for each rendered row in
227-
// eventsTbl. Populated by rebuildEventsTable so selectedEvent can
228-
// return the (event, invocation) tuple the cursor is on without
229-
// re-walking the cache. Reset on every rebuild.
230-
visibleRows []invocationRow
227+
// visibleRows holds the eventRow for each rendered row in eventsTbl —
228+
// one per network message. Populated by rebuildEventsTable so
229+
// selectedEvent / selectedEventRow can return the message the cursor is
230+
// on (and any folded tunnel) without re-walking the cache. Reset on
231+
// every rebuild.
232+
visibleRows []eventRow
231233

232234
// pipeline is the fetched plugin composition. nil until the initial
233235
// GetPipeline response arrives; the pipeline pane shows "(loading…)"

authbridge/cmd/abctl/tui/detail_pane.go

Lines changed: 37 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -9,67 +9,30 @@ import (
99
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
1010
)
1111

12-
// eventScopedToPlugin returns a shallow copy of e whose Invocations and
13-
// per-plugin Plugins map are limited to the given plugin. Event-level context
14-
// (protocol slot, identity) is preserved; filterForDetail still trims those by
15-
// phase. Returns e unchanged when plugin is empty.
16-
func eventScopedToPlugin(e *pipeline.SessionEvent, plugin string) *pipeline.SessionEvent {
17-
if e == nil || plugin == "" {
18-
return e
19-
}
20-
scoped := *e // shallow copy
21-
if e.Invocations != nil {
22-
scoped.Invocations = &pipeline.Invocations{
23-
Inbound: filterInvocationsByPlugin(e.Invocations.Inbound, plugin),
24-
Outbound: filterInvocationsByPlugin(e.Invocations.Outbound, plugin),
25-
}
26-
}
27-
if e.Plugins != nil {
28-
if raw, ok := e.Plugins[plugin]; ok {
29-
scoped.Plugins = map[string]json.RawMessage{plugin: raw}
30-
} else {
31-
scoped.Plugins = nil
32-
}
33-
}
34-
return &scoped
35-
}
36-
37-
// filterInvocationsByPlugin returns the invocations in invs whose Plugin
38-
// matches plugin, preserving order. Returns nil when none match.
39-
func filterInvocationsByPlugin(invs []pipeline.Invocation, plugin string) []pipeline.Invocation {
40-
var out []pipeline.Invocation
41-
for _, iv := range invs {
42-
if iv.Plugin == plugin {
43-
out = append(out, iv)
44-
}
45-
}
46-
return out
47-
}
48-
49-
// showDetail loads e into the detail viewport as colorized JSON and
50-
// remembers the focused event so yank (y) can find it.
12+
// showDetail loads the row's event into the detail viewport as colorized
13+
// JSON and remembers the focused row so yank (y) can find the event and
14+
// layout() can re-render on resize.
5115
//
5216
// Marshal with SessionEvent.MarshalJSON first (readable wire form — string
5317
// enums, durationMs), then filter inference/mcp extensions so request
5418
// events show only request-side fields and response events show only
5519
// response-side fields (TUI readability only — wire format is unchanged,
5620
// and yank still writes the full JSON).
5721
//
58-
// When the event arrived over TLS (SessionEvent.TLS non-nil), a small
59-
// header block is prepended to the JSON so operators can see the
60-
// connection-level identity at a glance. Absent for plaintext events.
22+
// The whole event is rendered — all plugin invocations, both directions —
23+
// because the timeline is now one row per message; the per-plugin breakdown
24+
// lives here in the detail, not in separate rows.
6125
//
62-
// The events list is one row per plugin invocation, so showDetail takes the
63-
// selected invocation and renders a copy of the event scoped to that plugin
64-
// (see eventScopedToPlugin). A nil invocation renders the whole event.
65-
func (m *model) showDetail(e *pipeline.SessionEvent, inv *pipeline.Invocation) {
26+
// When a CONNECT tunnel was folded into this row (TLS bridge), a one-block
27+
// summary of the tunnel is prepended so the operator sees the bridged
28+
// origin and the connection-level decision without a separate row. When the
29+
// event arrived over TLS (SessionEvent.TLS non-nil), a small TLS header
30+
// block is prepended too. Both absent for plaintext, non-bridged events.
31+
func (m *model) showDetail(r eventRow) {
32+
e := r.event
33+
m.detailRow = r
6634
m.detailEvent = e
67-
m.detailInvocation = inv
68-
ev := e
69-
if inv != nil {
70-
ev = eventScopedToPlugin(e, inv.Plugin)
71-
}
72-
data, err := json.Marshal(ev)
35+
data, err := json.Marshal(e)
7336
if err != nil {
7437
m.detailVp.SetContent("error marshaling event: " + err.Error())
7538
return
@@ -81,13 +44,35 @@ func (m *model) showDetail(e *pipeline.SessionEvent, inv *pipeline.Invocation) {
8144
// content keeps its highlighting.
8245
content = ansi.Wrap(content, w, " -")
8346
}
47+
if r.tunnel != nil {
48+
content = tunnelHeader(r.tunnel) + "\n\n" + content
49+
}
8450
if header := tlsHeader(e.TLS); header != "" {
8551
content = header + "\n\n" + content
8652
}
8753
m.detailVp.SetContent(content)
8854
m.detailVp.GotoTop()
8955
}
9056

57+
// tunnelHeader summarizes the CONNECT tunnel folded into a TLS-bridged
58+
// request row: the bridged origin (host:port) and any gate invocations that
59+
// ran on the tunnel-open before the bytes were decrypted. Lets an operator
60+
// see the connection-level decision without a separate row.
61+
//
62+
// tunnel: CONNECT example.com:443 (TLS bridge)
63+
// jwt-validation skip (no_inbound_identity)
64+
func tunnelHeader(tunnel *pipeline.SessionEvent) string {
65+
var b strings.Builder
66+
b.WriteString(fmt.Sprintf("tunnel: CONNECT %s (TLS bridge)", tunnel.Host))
67+
for _, iv := range allInvocations(tunnel) {
68+
b.WriteString(fmt.Sprintf("\n %s %s", iv.Plugin, iv.Action))
69+
if iv.Reason != "" {
70+
b.WriteString(" (" + iv.Reason + ")")
71+
}
72+
}
73+
return b.String()
74+
}
75+
9176
// tlsHeader builds a one-block summary of the TLS connection state.
9277
// Returns the empty string when tls is nil (plaintext events) so the
9378
// caller can prepend unconditionally.

0 commit comments

Comments
 (0)