Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions authbridge/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,8 +408,8 @@ When `session.enabled` is true (default) and `listener.session_api_addr` is non-
| `GET /v1/sessions` | `application/json` | List active sessions: `{sessions: [{id, createdAt, updatedAt, eventCount, active}]}`. |
| `GET /v1/sessions/{id}` | `application/json` | Full snapshot of one session's events. 404 if unknown/expired. |
| `GET /v1/events` | `text/event-stream` | SSE stream of new events. Optional `?session=<id>` filters to one session. Heartbeat every 30s. |
| `GET /v1/pipeline` | `application/json` | Active pipeline composition: `{inbound: [...], outbound: [...]}`. Each plugin entry carries `name`, `direction`, `position`, `bodyAccess`, `writes`, `reads`, plus the static metadata (`requires`, `requiresAny`, `after`, `claims`, `description`) and runtime `config` when present. abctl renders this as the Pipeline pane. |
| `GET /v1/plugins` | `application/json` | Catalog of every registered plugin (whether or not in the active pipeline): `{plugins: [{name, requires, requiresAny, after, claims, description, ...}]}`. abctl renders this as the Catalog pane (`P` key). 404s when the binary's session API was constructed without `WithCatalog`. |
| `GET /v1/pipeline` | `application/json` | Active pipeline composition: `{inbound: [...], outbound: [...]}`. Each plugin entry carries `name`, `direction`, `position`, `readsBody`, plus the static metadata (`requires`, `requiresAny`, `description`) and runtime `config` when present. abctl renders this as the Pipeline pane. |
| `GET /v1/plugins` | `application/json` | Catalog of every registered plugin (whether or not in the active pipeline): `{plugins: [{name, requires, requiresAny, description, ...}]}`. abctl renders this as the Catalog pane (`P` key). 404s when the binary's session API was constructed without `WithCatalog`. |
| `GET /healthz` | text | Liveness probe. |

### Quick examples
Expand Down
29 changes: 0 additions & 29 deletions authbridge/authlib/contracts/claims.go

This file was deleted.

2 changes: 1 addition & 1 deletion authbridge/authlib/listener/extproc/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ type bodyRecorderPlugin struct {

func (p *bodyRecorderPlugin) Name() string { return "body-recorder" }
func (p *bodyRecorderPlugin) Capabilities() pipeline.PluginCapabilities {
return pipeline.PluginCapabilities{BodyAccess: true}
return pipeline.PluginCapabilities{ReadsBody: true}
}
func (p *bodyRecorderPlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action {
p.receivedBody = pctx.Body
Expand Down
2 changes: 1 addition & 1 deletion authbridge/authlib/listener/forwardproxy/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ type bodyRecorderPlugin struct {

func (p *bodyRecorderPlugin) Name() string { return "body-recorder" }
func (p *bodyRecorderPlugin) Capabilities() pipeline.PluginCapabilities {
return pipeline.PluginCapabilities{BodyAccess: true}
return pipeline.PluginCapabilities{ReadsBody: true}
}
func (p *bodyRecorderPlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action {
p.receivedBody = pctx.Body
Expand Down
2 changes: 1 addition & 1 deletion authbridge/authlib/listener/reverseproxy/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ type bodyRecorderPlugin struct {

func (p *bodyRecorderPlugin) Name() string { return "body-recorder" }
func (p *bodyRecorderPlugin) Capabilities() pipeline.PluginCapabilities {
return pipeline.PluginCapabilities{BodyAccess: true}
return pipeline.PluginCapabilities{ReadsBody: true}
}
func (p *bodyRecorderPlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action {
p.receivedBody = pctx.Body
Expand Down
10 changes: 2 additions & 8 deletions authbridge/authlib/pipeline/bodymutation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,15 @@ import (
"testing"
)

// TestCapabilities_Normalize covers the compatibility rules: deprecated
// BodyAccess folds into ReadsBody, and WritesBody auto-promotes to
// ReadsBody so a mutator always satisfies the "must have read" invariant.
// TestCapabilities_Normalize: WritesBody auto-promotes to ReadsBody so a
// mutator always satisfies the "must have read" invariant.
func TestCapabilities_Normalize(t *testing.T) {
tests := []struct {
name string
in PluginCapabilities
wantReads bool
wantWrites bool
}{
{
name: "BodyAccess alias folds into ReadsBody",
in: PluginCapabilities{BodyAccess: true},
wantReads: true,
},
{
name: "WritesBody implies ReadsBody",
in: PluginCapabilities{WritesBody: true},
Expand Down
10 changes: 5 additions & 5 deletions authbridge/authlib/pipeline/configured_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,19 @@ func TestConfiguredPluginRawConfig(t *testing.T) {
func TestConfiguredPluginPassesThroughPluginMethods(t *testing.T) {
fake := &fakePlugin{
name: "jwt-validation",
caps: PluginCapabilities{Reads: []string{"a"}, Writes: []string{"security"}},
caps: PluginCapabilities{ReadsBody: true, Description: "test plugin"},
}
cp := WrapConfigured(fake, json.RawMessage(`{}`))

if cp.Name() != "jwt-validation" {
t.Fatalf("Name pass-through broken: %q", cp.Name())
}
caps := cp.Capabilities()
if len(caps.Reads) != 1 || caps.Reads[0] != "a" {
t.Fatalf("Capabilities pass-through broken: %+v", caps)
if !caps.ReadsBody {
t.Fatalf("Capabilities pass-through broken: ReadsBody should be true, got %+v", caps)
}
if len(caps.Writes) != 1 || caps.Writes[0] != "security" {
t.Fatalf("Capabilities pass-through broken: %+v", caps)
if caps.Description != "test plugin" {
t.Fatalf("Capabilities pass-through broken: Description mismatch, got %+v", caps)
}
cp.OnRequest(context.Background(), nil)
cp.OnResponse(context.Background(), nil)
Expand Down
2 changes: 1 addition & 1 deletion authbridge/authlib/pipeline/holder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type holderTestPlugin struct {

func (p *holderTestPlugin) Name() string { return p.name }
func (p *holderTestPlugin) Capabilities() PluginCapabilities {
return PluginCapabilities{BodyAccess: p.needsBody}
return PluginCapabilities{ReadsBody: p.needsBody}
}
func (p *holderTestPlugin) OnRequest(_ context.Context, _ *Context) Action {
p.runCount.Add(1)
Expand Down
72 changes: 11 additions & 61 deletions authbridge/authlib/pipeline/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,33 +28,14 @@ type Pipeline struct {
finishTimeout time.Duration
}

// defaultSlots lists the built-in extension slot names.
var defaultSlots = map[string]bool{
"mcp": true,
"a2a": true,
"security": true,
"delegation": true,
"inference": true,
"custom": true,
}

// Option configures pipeline construction.
type Option func(*options)

type options struct {
extraSlots []string
policies []ErrorPolicy
finishTimeout time.Duration
}

// WithSlots registers additional valid extension slot names beyond the built-in set.
// Use this when a bridge plugin (e.g., CPEX) produces extensions not in the default set.
func WithSlots(slots ...string) Option {
return func(o *options) {
o.extraSlots = append(o.extraSlots, slots...)
}
}

// WithFinishTimeout overrides the per-plugin OnFinish timeout. Each
// plugin's OnFinish runs under a fresh ctx derived from
// context.Background() with this timeout applied; a zero or negative
Expand All @@ -79,21 +60,13 @@ func WithPolicies(policies ...ErrorPolicy) Option {
}
}

// New creates a Pipeline from the given plugins after validating capability wiring.
// Returns an error if any plugin declares a read on a slot that no earlier plugin writes.
// New creates a Pipeline from the given plugins after validating body-access rules.
func New(plugins []Plugin, opts ...Option) (*Pipeline, error) {
var o options
for _, opt := range opts {
opt(&o)
}
validSlots := make(map[string]bool, len(defaultSlots)+len(o.extraSlots))
for k, v := range defaultSlots {
validSlots[k] = v
}
for _, s := range o.extraSlots {
validSlots[s] = true
}
if err := validateCapabilities(plugins, validSlots); err != nil {
if err := validateCapabilities(plugins); err != nil {
return nil, err
}
if len(o.policies) > len(plugins) {
Expand Down Expand Up @@ -318,8 +291,6 @@ func (p *Pipeline) NotReadyPlugin() string {

// NeedsBody returns true if any plugin in the pipeline needs the body
// buffered — either to read it (ReadsBody) or to mutate it (WritesBody).
// Normalize() folds the deprecated BodyAccess alias into ReadsBody, so
// both legacy and modern plugins are covered by the single check.
func (p *Pipeline) NeedsBody() bool {
for _, plugin := range p.plugins {
caps := plugin.Capabilities().Normalize()
Expand Down Expand Up @@ -501,44 +472,23 @@ func (p *Pipeline) dispatchFinish(parent context.Context, name string, f Finishe
f.OnFinish(ctx, pctx)
}

// validateCapabilities checks that every slot a plugin reads has been written
// by an earlier plugin in the chain, and applies the body-mutation rules:
// - At most one WritesBody plugin per pipeline (direction-scoped).
// Mutation ordering would otherwise be ambiguous; downstream readers
// can't tell which version they're seeing.
// - A body mutator must not run before a body reader. Readers that
// declared ReadsBody expect to see the original bytes; placing a
// mutator earlier would silently change what they observe.
func validateCapabilities(plugins []Plugin, validSlots map[string]bool) error {
written := make(map[string]bool)
var mutatorName string // set once the first WritesBody plugin is seen
var readerAfterMutator string // non-empty if a ReadsBody plugin follows the mutator
// validateCapabilities enforces body-mutation ordering rules:
// - At most one WritesBody plugin per pipeline — mutation ordering would
// otherwise be ambiguous; downstream readers can't tell which version
// they're seeing.
// - A body reader (ReadsBody) must not follow a body mutator (WritesBody) —
// the reader would silently see mutated bytes instead of the originals.
func validateCapabilities(plugins []Plugin) error {
var mutatorName string
var readerAfterMutator string
for _, plugin := range plugins {
caps := plugin.Capabilities().Normalize()
for _, slot := range caps.Reads {
if !validSlots[slot] {
return fmt.Errorf("plugin %q declares read on unknown slot %q", plugin.Name(), slot)
}
if !written[slot] {
return fmt.Errorf("plugin %q reads slot %q but no earlier plugin writes it", plugin.Name(), slot)
}
}
for _, slot := range caps.Writes {
if !validSlots[slot] {
return fmt.Errorf("plugin %q declares write on unknown slot %q", plugin.Name(), slot)
}
written[slot] = true
}
if caps.WritesBody {
if mutatorName != "" {
return fmt.Errorf("pipeline: two plugins declare WritesBody: %q and %q — mutation ordering would be ambiguous; at most one body mutator per pipeline is allowed", mutatorName, plugin.Name())
}
mutatorName = plugin.Name()
} else if caps.ReadsBody && mutatorName != "" && readerAfterMutator == "" {
// ReadsBody-only plugin running AFTER a WritesBody plugin
// would see the mutated bytes, which surprises the reader.
// Stash the first occurrence; validated below so the error
// names both plugins involved.
readerAfterMutator = plugin.Name()
}
}
Expand Down
115 changes: 2 additions & 113 deletions authbridge/authlib/pipeline/pipeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ func TestPipelineRun_Sequential(t *testing.T) {
}
p2 := &stubPlugin{
name: "second",
caps: PluginCapabilities{Reads: []string{"custom"}},
onReq: func(_ context.Context, pctx *Context) Action {
order = append(order, "second")
if pctx.Extensions.Custom["key"] != "value" {
Expand All @@ -63,7 +62,6 @@ func TestPipelineRun_Sequential(t *testing.T) {
return Action{Type: Continue}
},
}
p1.caps = PluginCapabilities{Writes: []string{"custom"}}

pipe, err := New([]Plugin{p1, p2})
if err != nil {
Expand Down Expand Up @@ -183,74 +181,6 @@ func TestPipelineRunResponse_Reject(t *testing.T) {
}
}

func TestNew_ValidCapabilities(t *testing.T) {
plugins := []Plugin{
&stubPlugin{
name: "writer",
caps: PluginCapabilities{Writes: []string{"mcp"}},
},
&stubPlugin{
name: "reader",
caps: PluginCapabilities{Reads: []string{"mcp"}},
},
}
_, err := New(plugins)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
}

func TestNew_InvalidCapabilities_ReadBeforeWrite(t *testing.T) {
plugins := []Plugin{
&stubPlugin{
name: "reader",
caps: PluginCapabilities{Reads: []string{"mcp"}},
},
&stubPlugin{
name: "writer",
caps: PluginCapabilities{Writes: []string{"mcp"}},
},
}
_, err := New(plugins)
if err == nil {
t.Fatal("expected error for read-before-write, got nil")
}
}

func TestNew_InvalidCapabilities_UnknownSlot(t *testing.T) {
plugins := []Plugin{
&stubPlugin{
name: "bad-reader",
caps: PluginCapabilities{Reads: []string{"nonexistent"}},
},
}
_, err := New(plugins)
if err == nil {
t.Fatal("expected error for unknown slot, got nil")
}
}

func TestNew_MultipleWriters(t *testing.T) {
plugins := []Plugin{
&stubPlugin{
name: "writer-1",
caps: PluginCapabilities{Writes: []string{"security"}},
},
&stubPlugin{
name: "writer-2",
caps: PluginCapabilities{Writes: []string{"security"}},
},
&stubPlugin{
name: "reader",
caps: PluginCapabilities{Reads: []string{"security"}},
},
}
_, err := New(plugins)
if err != nil {
t.Errorf("multiple writers should be valid, got: %v", err)
}
}

func TestNew_NoCapabilities(t *testing.T) {
plugins := []Plugin{
&stubPlugin{name: "simple"},
Expand All @@ -261,47 +191,6 @@ func TestNew_NoCapabilities(t *testing.T) {
}
}

func TestNew_CustomSlot(t *testing.T) {
plugins := []Plugin{
&stubPlugin{
name: "custom-writer",
caps: PluginCapabilities{Writes: []string{"custom"}},
},
&stubPlugin{
name: "custom-reader",
caps: PluginCapabilities{Reads: []string{"custom"}},
},
}
_, err := New(plugins)
if err != nil {
t.Errorf("custom slot should be valid, got: %v", err)
}
}

func TestNew_WithSlots(t *testing.T) {
plugins := []Plugin{
&stubPlugin{
name: "cpex-bridge",
caps: PluginCapabilities{Writes: []string{"cpex.completion"}},
},
&stubPlugin{
name: "consumer",
caps: PluginCapabilities{Reads: []string{"cpex.completion"}},
},
}
// Without WithSlots, this should fail
_, err := New(plugins)
if err == nil {
t.Fatal("expected error for unregistered slot without WithSlots")
}

// With WithSlots, this should succeed
_, err = New(plugins, WithSlots("cpex.completion"))
if err != nil {
t.Errorf("expected no error with WithSlots, got: %v", err)
}
}

func TestDelegationExtension_AppendHop(t *testing.T) {
d := &DelegationExtension{}

Expand Down Expand Up @@ -379,8 +268,8 @@ func TestPipelineRun_ContextCancellation(t *testing.T) {
}

func TestPipeline_Plugins_ReturnsCopy(t *testing.T) {
a := &stubPlugin{name: "a", caps: PluginCapabilities{Writes: []string{"custom"}}}
b := &stubPlugin{name: "b", caps: PluginCapabilities{Reads: []string{"custom"}}}
a := &stubPlugin{name: "a"}
b := &stubPlugin{name: "b"}
p, err := New([]Plugin{a, b})
if err != nil {
t.Fatalf("New: %v", err)
Expand Down
Loading
Loading