From 4865a5c68b733c31db52446d72ce091a9a4aac40 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 3 Jun 2026 05:56:25 -0400 Subject: [PATCH 1/4] demo(authbridge): check python-keycloak in preflight (fail fast) make demo-echo previously failed at the late setup-keycloak step (after building images, overriding the operator, and deploying) when python-keycloak was missing. Move the check into preflight so it fails before any of that. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/demos/echo/Makefile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/authbridge/demos/echo/Makefile b/authbridge/demos/echo/Makefile index ac8e12664..bd1b13e88 100644 --- a/authbridge/demos/echo/Makefile +++ b/authbridge/demos/echo/Makefile @@ -94,6 +94,13 @@ preflight: ## Verify kagenti is installed + tools are available echo " Install: pip3 install --user pyyaml"; \ exit 1; \ } + @python3 -c 'import keycloak' 2>/dev/null || { \ + echo "ERROR: python-keycloak is required (used later by setup-keycloak)."; \ + echo " Install: pip install python-keycloak"; \ + echo " Checked here in preflight so demo-echo fails before the"; \ + echo " build/deploy steps rather than after them."; \ + exit 1; \ + } # ---------- Sidecar (authbridge proxy) ---------- From ab4af845e899cd696441bdb053df6044eb00e25f Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 3 Jun 2026 06:08:58 -0400 Subject: [PATCH 2/4] fix(abctl): correct pipeline divider row cell count (fixes startup panic) The inbound/outbound "(app)" divider row in the pipeline table carried 7 cells, but the table has only 6 columns (the WRITES column was removed earlier). bubbles' table.renderRow indexes columns by cell position, so it panicked ("index out of range [6] with length 6") whenever abctl rendered a pipeline. Trim the divider to 6 cells and add a regression test asserting every row matches the column count. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/abctl/tui/pipeline_pane.go | 7 +++- .../cmd/abctl/tui/pipeline_pane_test.go | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 authbridge/cmd/abctl/tui/pipeline_pane_test.go diff --git a/authbridge/cmd/abctl/tui/pipeline_pane.go b/authbridge/cmd/abctl/tui/pipeline_pane.go index 10c08b0a1..39d06b554 100644 --- a/authbridge/cmd/abctl/tui/pipeline_pane.go +++ b/authbridge/cmd/abctl/tui/pipeline_pane.go @@ -40,8 +40,11 @@ func (m *model) rebuildPipelineTable() { for _, p := range m.pipeline.Inbound { rows = append(rows, pipelineRow(p, counts[p.Name], m.pipeline.Inbound)) } - // Divider between inbound and outbound. - rows = append(rows, table.Row{"", "", "── (app) ──", "", "", "", ""}) + // Divider between inbound and outbound. Cell count MUST match the 6 + // columns defined in newPipelineTable (#, DIRECTION, PLUGIN, DEPS, BODY, + // EVENTS) — bubbles' table.renderRow indexes columns by cell position and + // panics on a mismatch. + rows = append(rows, table.Row{"", "", "── (app) ──", "", "", ""}) for _, p := range m.pipeline.Outbound { rows = append(rows, pipelineRow(p, counts[p.Name], m.pipeline.Outbound)) } diff --git a/authbridge/cmd/abctl/tui/pipeline_pane_test.go b/authbridge/cmd/abctl/tui/pipeline_pane_test.go new file mode 100644 index 000000000..d94c07862 --- /dev/null +++ b/authbridge/cmd/abctl/tui/pipeline_pane_test.go @@ -0,0 +1,38 @@ +package tui + +import ( + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/cmd/abctl/apiclient" +) + +// TestRebuildPipelineTable_AllRowsMatchColumnCount is a regression guard: +// every row added to the pipeline table — including the inbound/outbound +// "(app)" divider — must have exactly as many cells as the table has columns. +// The divider previously carried 7 cells against 6 columns, which made +// bubbles' table.renderRow panic ("index out of range [6] with length 6") +// the moment abctl rendered any pipeline. +func TestRebuildPipelineTable_AllRowsMatchColumnCount(t *testing.T) { + m := &model{ + pipeline: &apiclient.PipelineView{ + Inbound: []apiclient.PipelinePlugin{{Name: "jwt-validation", Direction: "inbound", Position: 1}}, + Outbound: []apiclient.PipelinePlugin{{Name: "token-exchange", Direction: "outbound", Position: 1}}, + }, + pipelineTbl: newPipelineTable(), + } + + // Must not panic: the buggy 7-cell divider panicked here via + // SetRows → UpdateViewport → renderRow. + m.rebuildPipelineTable() + + rows := m.pipelineTbl.Rows() + if len(rows) != 3 { // inbound plugin + divider + outbound plugin + t.Fatalf("rows = %d, want 3 (inbound + divider + outbound)", len(rows)) + } + const wantCells = 6 // #, DIRECTION, PLUGIN, DEPS, BODY, EVENTS + for i, r := range rows { + if len(r) != wantCells { + t.Errorf("row %d has %d cells, want %d (must match column count)", i, len(r), wantCells) + } + } +} From 36cde30a4c777fd07d8b24bbb068c5198839e2f9 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 3 Jun 2026 06:39:22 -0400 Subject: [PATCH 3/4] fix(abctl): scope event detail to the selected plugin The events list is one row per plugin invocation, but selecting a row rendered the whole SessionEvent (every plugin's invocation). Carry the selected invocation through to showDetail and render a copy scoped to that plugin's invocations and Plugins entry. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/abctl/tui/app.go | 6 +- authbridge/cmd/abctl/tui/detail_pane.go | 51 ++++++++++++++++- authbridge/cmd/abctl/tui/detail_pane_test.go | 60 ++++++++++++++++++++ authbridge/cmd/abctl/tui/events_pane.go | 13 +++++ authbridge/cmd/abctl/tui/keys.go | 4 +- 5 files changed, 129 insertions(+), 5 deletions(-) diff --git a/authbridge/cmd/abctl/tui/app.go b/authbridge/cmd/abctl/tui/app.go index 928e8f255..35f438f20 100644 --- a/authbridge/cmd/abctl/tui/app.go +++ b/authbridge/cmd/abctl/tui/app.go @@ -216,7 +216,11 @@ type model struct { catalogTbl table.Model detailVp viewport.Model detailEvent *pipeline.SessionEvent - detailPlugin *apiclient.PipelinePlugin + // detailInvocation is the plugin invocation selected in the events pane + // when the detail view was opened. Used to re-scope the event to that + // plugin on resize/re-render. nil means "whole event" (no invocation). + detailInvocation *pipeline.Invocation + detailPlugin *apiclient.PipelinePlugin filterInput textinput.Model // visibleRows holds the invocationRow spec for each rendered row in diff --git a/authbridge/cmd/abctl/tui/detail_pane.go b/authbridge/cmd/abctl/tui/detail_pane.go index 66dc40a9e..74f940490 100644 --- a/authbridge/cmd/abctl/tui/detail_pane.go +++ b/authbridge/cmd/abctl/tui/detail_pane.go @@ -21,9 +21,56 @@ import ( // When the event arrived over TLS (SessionEvent.TLS non-nil), a small // header block is prepended to the JSON so operators can see the // connection-level identity at a glance. Absent for plaintext events. -func (m *model) showDetail(e *pipeline.SessionEvent) { +// +// The events list is one row per plugin invocation, so showDetail takes the +// selected invocation and renders a copy of the event scoped to that plugin +// (see eventScopedToPlugin). A nil invocation renders the whole event. +// +// eventScopedToPlugin returns a shallow copy of e whose Invocations and +// per-plugin Plugins map are limited to the given plugin. Event-level context +// (protocol slot, identity) is preserved; filterForDetail still trims those by +// phase. Returns e unchanged when plugin is empty. +func eventScopedToPlugin(e *pipeline.SessionEvent, plugin string) *pipeline.SessionEvent { + if e == nil || plugin == "" { + return e + } + scoped := *e // shallow copy + if e.Invocations != nil { + scoped.Invocations = &pipeline.Invocations{ + Inbound: filterInvocationsByPlugin(e.Invocations.Inbound, plugin), + Outbound: filterInvocationsByPlugin(e.Invocations.Outbound, plugin), + } + } + if e.Plugins != nil { + if raw, ok := e.Plugins[plugin]; ok { + scoped.Plugins = map[string]json.RawMessage{plugin: raw} + } else { + scoped.Plugins = nil + } + } + return &scoped +} + +// filterInvocationsByPlugin returns the invocations in invs whose Plugin +// matches plugin, preserving order. Returns nil when none match. +func filterInvocationsByPlugin(invs []pipeline.Invocation, plugin string) []pipeline.Invocation { + var out []pipeline.Invocation + for _, iv := range invs { + if iv.Plugin == plugin { + out = append(out, iv) + } + } + return out +} + +func (m *model) showDetail(e *pipeline.SessionEvent, inv *pipeline.Invocation) { m.detailEvent = e - data, err := json.Marshal(e) + m.detailInvocation = inv + ev := e + if inv != nil { + ev = eventScopedToPlugin(e, inv.Plugin) + } + data, err := json.Marshal(ev) if err != nil { m.detailVp.SetContent("error marshaling event: " + err.Error()) return diff --git a/authbridge/cmd/abctl/tui/detail_pane_test.go b/authbridge/cmd/abctl/tui/detail_pane_test.go index e2a0541fd..48a55ac77 100644 --- a/authbridge/cmd/abctl/tui/detail_pane_test.go +++ b/authbridge/cmd/abctl/tui/detail_pane_test.go @@ -1,6 +1,7 @@ package tui import ( + "encoding/json" "strings" "testing" @@ -65,3 +66,62 @@ func TestTLSHeader_PeerOnly(t *testing.T) { t.Errorf("tlsHeader unexpectedly included version/cipher on peer-only state\ngot:\n%s", got) } } + +// eventScopedToPlugin must restrict both the Invocations slices and the +// per-plugin Plugins map to the selected plugin, and must NOT mutate the +// original event (the shallow copy aliases the slices/map otherwise). +func TestEventScopedToPlugin_FiltersToSelectedPlugin(t *testing.T) { + ev := &pipeline.SessionEvent{ + Invocations: &pipeline.Invocations{ + Inbound: []pipeline.Invocation{ + {Plugin: "jwt-validation", Action: pipeline.ActionAllow}, + {Plugin: "a2a-parser", Action: pipeline.ActionObserve}, + }, + }, + Plugins: map[string]json.RawMessage{ + "jwt-validation": json.RawMessage("{}"), + "a2a-parser": json.RawMessage("{}"), + }, + } + + scoped := eventScopedToPlugin(ev, "jwt-validation") + + if got := len(scoped.Invocations.Inbound); got != 1 { + t.Fatalf("scoped inbound invocations = %d, want 1", got) + } + if got := scoped.Invocations.Inbound[0].Plugin; got != "jwt-validation" { + t.Errorf("scoped invocation plugin = %q, want %q", got, "jwt-validation") + } + if got := len(scoped.Plugins); got != 1 { + t.Fatalf("scoped Plugins entries = %d, want 1", got) + } + if _, ok := scoped.Plugins["jwt-validation"]; !ok { + t.Errorf("scoped Plugins missing jwt-validation key: %v", scoped.Plugins) + } + if _, ok := scoped.Plugins["a2a-parser"]; ok { + t.Errorf("scoped Plugins unexpectedly retained a2a-parser") + } + + // Original event must be untouched (no aliasing of slice/map). + if got := len(ev.Invocations.Inbound); got != 2 { + t.Errorf("original inbound invocations mutated: = %d, want 2", got) + } + if got := len(ev.Plugins); got != 2 { + t.Errorf("original Plugins map mutated: = %d, want 2", got) + } +} + +// An empty plugin string means "no specific invocation" — the helper returns +// the event unchanged (same pointer) so old whole-event behavior is preserved. +func TestEventScopedToPlugin_EmptyPluginReturnsUnchanged(t *testing.T) { + ev := &pipeline.SessionEvent{ + Invocations: &pipeline.Invocations{ + Inbound: []pipeline.Invocation{ + {Plugin: "jwt-validation", Action: pipeline.ActionAllow}, + }, + }, + } + if got := eventScopedToPlugin(ev, ""); got != ev { + t.Errorf("eventScopedToPlugin(ev, \"\") = %p, want original %p", got, ev) + } +} diff --git a/authbridge/cmd/abctl/tui/events_pane.go b/authbridge/cmd/abctl/tui/events_pane.go index 015b775ff..f8e5c484d 100644 --- a/authbridge/cmd/abctl/tui/events_pane.go +++ b/authbridge/cmd/abctl/tui/events_pane.go @@ -184,6 +184,19 @@ func (m *model) selectedEvent() *pipeline.SessionEvent { return m.visibleRows[cur].event } +// selectedInvocation returns the plugin invocation for the highlighted events +// row, or nil (pseudo-row for an event with no invocations, or no rows). +func (m *model) selectedInvocation() *pipeline.Invocation { + if len(m.visibleRows) == 0 { + return nil + } + cur := m.eventsTbl.Cursor() + if cur < 0 || cur >= len(m.visibleRows) { + return nil + } + return m.visibleRows[cur].inv +} + // invocationRow is one table row — the cartesian product of SessionEvent // × Invocation. An event with N plugin invocations produces N rows; an // event with no invocations produces one row with an empty invocation. diff --git a/authbridge/cmd/abctl/tui/keys.go b/authbridge/cmd/abctl/tui/keys.go index b55f6a13f..d2bd60e04 100644 --- a/authbridge/cmd/abctl/tui/keys.go +++ b/authbridge/cmd/abctl/tui/keys.go @@ -207,7 +207,7 @@ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd { if ev == nil { return nil } - m.showDetail(ev) + m.showDetail(ev, m.selectedInvocation()) m.pane = paneDetail return nil case panePipeline: @@ -476,7 +476,7 @@ func (m *model) layout() { // Re-wrap the detail viewport to the new width so long JSON values // continue to fit after a terminal resize. if m.detailEvent != nil { - m.showDetail(m.detailEvent) + m.showDetail(m.detailEvent, m.detailInvocation) } } From b8301207b49b0e31d85894b9ad469c7839db152c Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 3 Jun 2026 06:57:28 -0400 Subject: [PATCH 4/4] =?UTF-8?q?fix(abctl):=20address=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20doc-comment=20attribution=20+=20install=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the merged doc block so showDetail keeps its own godoc and eventScopedToPlugin gets a one-liner (the comment had drifted onto the helper). Use the PEP 668-friendly 'pip3 install --user' form for the python-keycloak hints, matching the adjacent PyYAML check. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/abctl/tui/detail_pane.go | 33 ++++++++++++------------- authbridge/demos/echo/Makefile | 8 +++--- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/authbridge/cmd/abctl/tui/detail_pane.go b/authbridge/cmd/abctl/tui/detail_pane.go index 74f940490..ba5e78f79 100644 --- a/authbridge/cmd/abctl/tui/detail_pane.go +++ b/authbridge/cmd/abctl/tui/detail_pane.go @@ -9,23 +9,6 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" ) -// showDetail loads e into the detail viewport as colorized JSON and -// remembers the focused event so yank (y) can find it. -// -// Marshal with SessionEvent.MarshalJSON first (readable wire form — string -// enums, durationMs), then filter inference/mcp extensions so request -// events show only request-side fields and response events show only -// response-side fields (TUI readability only — wire format is unchanged, -// and yank still writes the full JSON). -// -// When the event arrived over TLS (SessionEvent.TLS non-nil), a small -// header block is prepended to the JSON so operators can see the -// connection-level identity at a glance. Absent for plaintext events. -// -// The events list is one row per plugin invocation, so showDetail takes the -// selected invocation and renders a copy of the event scoped to that plugin -// (see eventScopedToPlugin). A nil invocation renders the whole event. -// // eventScopedToPlugin returns a shallow copy of e whose Invocations and // per-plugin Plugins map are limited to the given plugin. Event-level context // (protocol slot, identity) is preserved; filterForDetail still trims those by @@ -63,6 +46,22 @@ func filterInvocationsByPlugin(invs []pipeline.Invocation, plugin string) []pipe return out } +// showDetail loads e into the detail viewport as colorized JSON and +// remembers the focused event so yank (y) can find it. +// +// Marshal with SessionEvent.MarshalJSON first (readable wire form — string +// enums, durationMs), then filter inference/mcp extensions so request +// events show only request-side fields and response events show only +// response-side fields (TUI readability only — wire format is unchanged, +// and yank still writes the full JSON). +// +// When the event arrived over TLS (SessionEvent.TLS non-nil), a small +// header block is prepended to the JSON so operators can see the +// connection-level identity at a glance. Absent for plaintext events. +// +// The events list is one row per plugin invocation, so showDetail takes the +// selected invocation and renders a copy of the event scoped to that plugin +// (see eventScopedToPlugin). A nil invocation renders the whole event. func (m *model) showDetail(e *pipeline.SessionEvent, inv *pipeline.Invocation) { m.detailEvent = e m.detailInvocation = inv diff --git a/authbridge/demos/echo/Makefile b/authbridge/demos/echo/Makefile index bd1b13e88..bc68eec8a 100644 --- a/authbridge/demos/echo/Makefile +++ b/authbridge/demos/echo/Makefile @@ -14,7 +14,7 @@ # Prerequisites: # - kagenti installed on a kind cluster (this is the operator-flow demo) # - kubectl, kind, podman/docker, python3 + PyYAML -# - python-keycloak (for setup-keycloak; pip install python-keycloak) +# - python-keycloak (for setup-keycloak; pip3 install --user python-keycloak) .PHONY: help preflight build-sidecar load-sidecar override-sidecar-image \ build-images load-images deploy wait-pods setup-keycloak \ @@ -96,7 +96,7 @@ preflight: ## Verify kagenti is installed + tools are available } @python3 -c 'import keycloak' 2>/dev/null || { \ echo "ERROR: python-keycloak is required (used later by setup-keycloak)."; \ - echo " Install: pip install python-keycloak"; \ + echo " Install: pip3 install --user python-keycloak"; \ echo " Checked here in preflight so demo-echo fails before the"; \ echo " build/deploy steps rather than after them."; \ exit 1; \ @@ -232,11 +232,11 @@ wait-pods: ## Wait for pods + operator-rendered authbridge ConfigMap # can't reach that, port-forward in another terminal: # kubectl port-forward service/keycloak-service -n keycloak 8080:8080 # and set KEYCLOAK_URL=http://localhost:8080. -# Requires the python-keycloak package: pip install python-keycloak +# Requires the python-keycloak package: pip3 install --user python-keycloak setup-keycloak: ## Configure Keycloak (echo-upstream client, scopes, demo users) @python3 -c 'import keycloak' 2>/dev/null || { \ echo "ERROR: python-keycloak is required for setup-keycloak."; \ - echo " Install: pip install python-keycloak"; \ + echo " Install: pip3 install --user python-keycloak"; \ exit 1; \ } python3 scripts/setup_keycloak.py --namespace $(NAMESPACE) --service-account $(AGENT_NAME)