diff --git a/AGENTS.md b/AGENTS.md index 802bac3c..1cbbe4ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,8 @@ must be labeled as roadmap on the pages. - by default, changes in this repository do not preserve backward compatibility - do not keep legacy aliases, deprecated field names, old route shapes, old module IDs, old CLI flags, or old API-visible IDs unless the change request explicitly requires compatibility - when renaming or reshaping behavior, update the code, tests, `README.md`, `docs/architecture/architecture-overview.md`, `examples/Caddyfile.example`, this file, and the nearest nested `AGENTS.md` to describe only the current behavior unless compatibility is explicitly required +- Reusable command implementation rules and the exact F1 surface list live in + `pkg/distribution/AGENTS.md`. - protected Enterprise SPI and reusable distribution entry points follow the compatibility policy in [`docs/design/enterprise-extension-contract.md`](docs/design/enterprise-extension-contract.md) @@ -88,6 +90,8 @@ go test ./path/to/package -run TestName -v Notes: - `make build` builds `agw` from `cmd/agw/main.go`, `agwd` from `cmd/agwd/main.go`, and `agwctl` from `cmd/agwctl/main.go`. +- The three mains own explicit linked-module imports and delegate to `pkg/distribution/caddycmd`, `daemoncmd`, and `ctlcmd`. Command construction must keep flag state local; dotenv, signals, and process exit belong to execution. +- Both server mains pass `distribution.CommunityRuntime()`; CLI passes `CommunityValidation()`. These supply validated manifest metadata, with explicitly injected attribution producers/allowlists and per-lifecycle public sink factories excluded from serialization. Keep declarations aligned with main imports. - The resulting binary is a standard Caddy binary with custom modules compiled in, so normal Caddy subcommands such as `run`, `reload`, `validate`, and `hash-password` work. ## Core Modules @@ -168,7 +172,7 @@ authentication is delegated to the HTTP mount layer such as Caddy Deep per-subsystem rules live in nested `AGENTS.md` files next to the code. Read the applicable subsystem file before changing either its runtime package or a cross-cutting adapter for it (for example `caddy/admin`, -`caddy/dispatcher`, `standalone/server`, `pkg/gatewaybundle`, or `cmd/agwctl`): +`caddy/dispatcher`, `standalone/server`, `pkg/gatewaybundle`, or `pkg/distribution/ctlcmd`): - `pkg/gateway/` — runtime route/VirtualKey/provider resolution (`AgentGateway`) and the public route-model packages (`llmroute`, `modelcatalog`, `mcproute`, `agentroute`, `virtualkey`) → `pkg/gateway/AGENTS.md` - `pkg/acp/` — native ACP runtime (`codex`, `opencode`): pooling, sessions, permissions, transcripts → `pkg/acp/AGENTS.md` @@ -176,10 +180,14 @@ or a cross-cutting adapter for it (for example `caddy/admin`, - `pkg/llm/` — provider interface/registry, built-in providers, and the `einomodel` eino bridge → `pkg/llm/AGENTS.md` - `pkg/mcp/` — MCP service runtime and the `einotool` eino bridge → `pkg/mcp/AGENTS.md` - `pkg/a2a/` — A2A Protocol 1.0 JSON-RPC (Path B translating client/backend and Path A governed native proxy are implemented) → `pkg/a2a/AGENTS.md` -- `pkg/configstore/` — generic config store/backends (persisted backend: `sqlite`; stores `providers`, `credentials`, `routes`, `mcp_services`, `agents`, `virtual_keys`, `managed_models`) → `pkg/configstore/AGENTS.md` +- `pkg/configstore/` — generic config store/backends (persisted backend: `sqlite`; stores `providers`, `credentials`, `routes`, `mcp_services`, `agents`, `virtual_keys`, `managed_models`, `extensions`) → `pkg/configstore/AGENTS.md` - `pkg/agent/` — agent control plane (`pkg/agent/runtime` contracts, `Agent` model, route/service → agent index) → `pkg/agent/AGENTS.md`; builtin eino ADK host → `pkg/agent/builtin/AGENTS.md` - `internal/observability/` — usage events, event pipeline, OTLP export, `einotap` → `internal/observability/AGENTS.md` - `pkg/dispatcher/llmapi/` — LLM protocol handler runtimes; see "Protocol handler modules" above +- `pkg/gateway/virtualkey/limiter/` — candidate protected backend SPI, generation + fences, strict selection and reusable suites → its nested `AGENTS.md` +- `pkg/extension/` — linked registry, bounded envelopes and prepared generations → `pkg/extension/AGENTS.md` +- `pkg/attribution/` and `pkg/event/` — trusted bounded context and sanitized public sink transport → their nested `AGENTS.md` files - `caddy/gateway/` — Caddy app wiring and global `agent_gateway` Caddyfile parsing; see "Caddy app" above Cross-cutting invariants: @@ -221,6 +229,16 @@ The main config lives in the global `agent_gateway` block. Important current directives: +- internal extensions are independent of Caddyfile and use ConfigStore as their sole configuration source; manage them through Admin API or Bundle apply. Caddyfile/Caddy JSON and standalone static Bundles reject extension configuration +- `online.agentguide.community.limiter` schema 1 selects a linked backend using + `backend_module_id`, `backend_type`, `timeout_ms` (1–1000, default 100) and + `failure_mode` (default `closed`). Defaults use memory; alternate providers must + be configured dependencies. Exhaustion is 429, failure is bounded 503, explicit + open mode is degraded; cancellation and retired-policy errors never fail open, + and consumes are not retried +- limiter generations preserve unchanged dimensions and unrelated refresh state; + key policy/backend changes retire state. Quota storage survives connection-handle + refresh; request admission always uses the current prepared handle - `provider_types` is startup-only provider type availability; when omitted all registered provider types are enabled - providers use `provider_type ` - LLM routes use `protocol `, MCP routes use `protocol mcp`, and Agent ingress routes use `protocol agent` or `protocol a2a` (HTTP Agent native Path A; see `docs/design/http-agent-runtime.md`) @@ -233,11 +251,19 @@ Implemented families: - LLM: `/admin/llm/providers/...`, `/admin/llm/provider_types` (read-only listing), `/admin/llm/api_handler_types`, `/admin/llm/routes/...`, `/admin/llm/models/providers/{provider_id}/discovered`, `/admin/llm/models/providers/{provider_id}/refresh`, `/admin/llm/models/managed/...` - `/admin/virtual_keys/...`, `/admin/credentials/...` +- Contributed Admin subroutes are declared by `Factory.AdminRoutes` and handled + by prepared `AdminContributor` resources only under `/admin/extensions//...`; + module roots and all other Community families are reserved. All Admin requests + share a full-body 4 MiB boundary (413), reject compression (415), and inherit + mount authentication, CORS, logging and generic JSON panic recovery. Module + event publishers capture trusted request identity; Publish contexts cannot + replace the event actor or correlation. +- `GET /admin/manifest` and `GET /admin/extensions`, `GET|PUT|DELETE /admin/extensions/{module_id}` (reference-preserving module configuration) - MCP: `/admin/mcp/services/...`, `/admin/mcp/routes/...`, `/admin/mcp/runtime/...` (discovery, execution, dispatcher runtime inspection) - ACP: `/admin/acp/runtime/...` (native diagnostics keyed by `agent_id`) - builtin: `/admin/builtin/runtime/...` - agents: `/admin/agents/...` plus unified route CRUD under `/admin/agents/routes` -- metrics: summaries (with pipeline health counters), per-protocol LLM/MCP/ACP timeseries and breakdowns, recent interaction events, and the Prometheus exposition endpoint `GET /admin/metrics/prometheus` +- metrics: summaries (with combined drop/failure, rejected-event and public sink health counters), per-protocol LLM/MCP/ACP timeseries and breakdowns, recent interaction events, and the Prometheus exposition endpoint `GET /admin/metrics/prometheus` Stubbed families currently return `501 Not Implemented`: @@ -249,6 +275,10 @@ Stubbed families currently return `501 Not Implemented`: - `docs/architecture/architecture-overview.md`: current broader architecture - `docs/design/enterprise-extension-contract.md`: protected external SPI, distribution assembly, and cross-repository compatibility rules +- `docs/guides/custom-distribution.md`: external assembly and validation workflow +- `docs/releases/v0.7.0.md`: exact designated surfaces and release gates +- `docs/plans/v0.7.0-enterprise-extension-foundation.md`: active paired plan + for reusable distribution entry points and the first protected proof SPI - `examples/Caddyfile.example`: working reference config - `cmd/agw/main.go`: the definitive list of linked modules diff --git a/Makefile b/Makefile index 38546763..c006964e 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Makefile for Caddy LLM Router -.PHONY: all build build-agw build-agwd build-agwctl build-xcaddy clean deps fmt verify-release smoke-release +.PHONY: all build build-agw build-agwd build-agwctl build-xcaddy clean deps fmt verify-release smoke-release test-extension-consumer test-extension-consumer-candidate test-extension-consumer-release # Binary names BINARY_NAME=agw @@ -65,3 +65,17 @@ verify-release: # Exercise the built binaries and both HTTP Agent ingress paths as processes. smoke-release: build AGW_RELEASE_SMOKE=1 $(GOTEST) ./tests/release -run TestReleaseProcesses -v + +# Build the independent Go-module entries against this checkout using a temporary +# replacement only; no go.work or replace directive is checked in. +test-extension-consumer: + ./scripts/test-extension-consumer + +# Immutable candidate proof; commits must be recorded before invoking this gate. +test-extension-consumer-candidate: + @test -n "$(COMMUNITY_COMMIT)" || (echo 'set COMMUNITY_COMMIT to an exact commit' >&2; exit 2) + ./scripts/test-extension-consumer candidate "$(COMMUNITY_COMMIT)" + +# Fresh caches, published dependency, no checkout replacement or workspace. +test-extension-consumer-release: + ./scripts/test-extension-consumer release diff --git a/README.md b/README.md index fd37676c..15fd88db 100644 --- a/README.md +++ b/README.md @@ -156,8 +156,11 @@ virtualKeys: ``` `rate_limits` is optional. It independently limits LLM, MCP, and agent ingress -for a VirtualKey with in-memory token buckets; `agent` covers common Agent -turns and native A2A POSTs. Exceeded requests return `429` with `Retry-After`. +for a VirtualKey through the configured limiter backend (process-local memory +by default); `agent` covers common Agent +turns and native A2A POSTs. Exceeded requests return `429` with `Retry-After`. Backend failures/timeouts +default to `503 limiter_unavailable`; explicit fail-open is reported as degraded. +Admission is never retried automatically. Set the admin Basic Auth credentials for agwctl as an environment variable, then apply the bundle: @@ -593,3 +596,125 @@ go test ./pkg/llm/provider/... ./agw adapt --config ./Caddyfile ./agw run --config ./Caddyfile ``` + +Reusable distribution entries live in `pkg/distribution/caddycmd`, +`pkg/distribution/daemoncmd`, and `pkg/distribution/ctlcmd`. Separate binaries +keep their own explicit factory/module imports and supply `distribution.Assembly` +metadata. `daemoncmd.NewCommand` and `ctlcmd.NewCommand` create independent Cobra +commands; their `Run` helpers load dotenv and return an exit code for main. +`caddycmd.New(...).Run()` delegates process behavior to upstream Caddy. +Public metrics options use `distribution.MetricsConfig` and `OTLPConfig`. +`Assembly.Registry()` validates explicitly linked declarations; metadata never +dynamically loads module code. + +See the [custom distribution guide](docs/guides/custom-distribution.md) for assembly, +configuration, Admin, attribution and backend integration. The +[v0.7 release notes](docs/releases/v0.7.0.md) enumerate designated compatibility +surfaces and the remaining published-tag gates. + +Run `make test-extension-consumer` to build and test the separate-module consumer +against this checkout; the same gate is included in `make verify-release`. + +All Admin routes share a 4 MiB received-body limit. The gateway checks the full +body before dispatching a write, including chunked requests. Larger bodies return +JSON HTTP 413; compressed bodies return HTTP 415. Bundle apply uses this limit +per serialized resource, not per local Bundle file. Panic responses use generic +JSON HTTP 500 diagnostics. + +### Linked extensions + +`agw manifest`, `agwd manifest`, and `agwctl manifest` show deterministic linked +metadata. The CLI reports validation capabilities; `agwctl manifest --remote` +queries the server runtime manifest through the authenticated Admin mount. + +Separately compiled distributions declare descriptors and configuration +factories through `distribution.Assembly`. Gateway Bundle `extensions` use +`module_id`, `schema_version`, and module-owned JSON `config`. Unknown modules +fail validation/startup. Community binaries have no commercial factories. +`GET /admin/extensions` and `GET|PUT|DELETE /admin/extensions/{module_id}` manage +persisted envelopes in the existing ConfigStore. Caddyfile and standalone static +Bundles do not configure internal extensions; use Admin API or Bundle apply. +Apply handles extensions in dependency order before other resources and reports +partial results when an extension fails. Export retains configuration and secret references. + +Secret fields declared by a factory accept only `{"$secret_ref":"env:NAME"}`. +Local validation never resolves these tokens. Server preparation resolves them; +persisted/exported JSON retains the token. Preparation/persistence failure leaves +the current generation active. See the +[extension contract](docs/design/enterprise-extension-contract.md#113-configuration-ownership-mutation-and-secrets). + + +### Contributed Admin routes + +Runtime factories may declare `extension.Factory.AdminRoutes`. Their prepared +resources implement `extension.AdminContributor`; modules need the `admin` role. +Routes belong exclusively to `/admin/extensions//...`; the module root +remains Community envelope CRUD. Patterns accept literals and single-segment +`{name}` wildcards. Duplicate or overlapping method/path declarations fail +assembly with `route_conflict`; GET also owns HEAD. OPTIONS is shared preflight. + +Handlers receive an owned reference-preserving config, logger, attribution accessor +and owner-scoped public mutation publisher through `AdminDependencies`. The +publisher captures trusted request identity; a module-supplied context cannot +replace event attribution. Handlers use +`WriteAdminError` for bounded JSON errors. No gateway, store or credentials are +injected. Both server modes keep the outer Admin authentication, CORS, logging, +panic recovery and full-body boundary. Module health appears in `/admin/health`; +generation leases drain handlers before resource retirement, with bounded request +cancellation and reverse dependency shutdown. Unconfigured modules return 503. + +### Limiter backend extension + +`pkg/gateway/virtualkey/limiter` is the candidate protected domain SPI. +A separately linked module implements `LimiterProvider` on its prepared resource +and returns a `LimiterBackend` for a declared type. The Community selection +extension has schema 1 and defaults to local memory, 100 ms, and fail-closed: + +```yaml +extensions: + - module_id: online.agentguide.community.limiter + schema_version: 1 + config: + backend_module_id: example.organization.limiter + backend_type: redis + timeout_ms: 100 + failure_mode: closed +``` + +This example requires the named backend module to be linked and configured in +the same Bundle; Community does not ship a production Redis backend. Unknown, +unconfigured or unavailable selections fail startup/apply. `timeout_ms` accepts +1–1000 and is capped by the caller deadline. With no limit for a dimension, +admission is unlimited and never calls the backend. Caller cancellation and +shutdown and retired-policy errors never fail open. Set `failure_mode: open` only for intentional degraded +admission on backend failure or timeout. + +Policies have opaque per-key/dimension generations. Unchanged policies preserve +tokens across metadata writes and unrelated extension refreshes; changed dimensions, +key deletion/reset and backend switches retire state. A switch resets full buckets +without transferring tokens. Quota state must survive replacement of prepared +connection handles; retired generations must stay fenced against late operations. +Public mutation events report `state_reset`; admission events distinguish allow, +deny and degraded. Prometheus exposes `agentgateway_limiter_admissions_total` +with only dimension, outcome and bounded backend type labels. + +See the [limiter contract](docs/design/enterprise-extension-contract.md#116-limiter-backend-semantics) +and reusable `limiter/contracttest.Limiter` and `LimiterRefill` suites. Released-tag +protection and paired repository evidence remain release gates. + +### Trusted attribution and public event sinks + +Custom distributions can inject `Assembly.AttributionProducer`, exact namespaced +`AttributeKeys`, and linked-module `EventSinks`. A validated VirtualKey supplies +the default subject; a trusted producer can supply subject/tenant. Client tenant +headers are ignored. `pkg/attribution` exposes bounded context values with defensive +copies, while runtime route/Agent/run/trace correlation comes from the gateway. + +Public sinks receive only typed `limiter.admission` and `extension.mutation` +envelopes from the existing pipeline. Config, secret references, resolved values, +prompts and arbitrary error messages are excluded. Delivery uses isolated bounded +queues, FIFO flush and deadline-aware shutdown. Overflow drops the newest event; +delivery is best-effort without retry or replay. `GET /admin/metrics` includes +`pipeline.rejected_events` and per-sink `pipeline.public_sinks` health/counters. +See the [attribution/event contract](docs/design/enterprise-extension-contract.md#114-attribution-and-public-events) +for bounds, lifecycle semantics and candidate protected symbols. diff --git a/caddy/gateway/app.go b/caddy/gateway/app.go index 283726c0..9c4d2e39 100644 --- a/caddy/gateway/app.go +++ b/caddy/gateway/app.go @@ -3,6 +3,9 @@ package gateway import ( "context" "fmt" + "github.com/agent-guide/agent-gateway/internal/distributionmetrics" + "github.com/agent-guide/agent-gateway/internal/distributionruntime" + "github.com/agent-guide/agent-gateway/pkg/distribution" "strings" "github.com/caddyserver/caddy/v2" @@ -47,7 +50,7 @@ type App struct { LLMRoutes []routecore.AgentRouteConfig `json:"llm_routes,omitempty"` // Metrics configures observability retention, agent-chain governance, and // optional OTLP span export of usage events. - Metrics usage.Config `json:"metrics,omitzero"` + Metrics distribution.MetricsConfig `json:"metrics,omitzero"` logger *zap.Logger credentialMgr *credential.Manager @@ -67,7 +70,17 @@ func (App) CaddyModule() caddy.ModuleInfo { } // Provision sets up the app. -func (a *App) Provision(ctx caddy.Context) error { +func (a *App) Provision(ctx caddy.Context) (provisionErr error) { + defer func() { + if provisionErr != nil { + if a.agentGateway != nil { + a.agentGateway.Close() + } + if a.usageService != nil { + _ = a.usageService.Close() + } + } + }() a.logger = ctx.Logger(a) a.agentGateway = runtimegateway.NewAgentGateway() @@ -75,6 +88,14 @@ func (a *App) Provision(ctx caddy.Context) error { return fmt.Errorf("init config store: %w", err) } a.provisionUsageService() + assembly := distributionruntime.Current() + policy, err := assembly.AttributionPolicy() + if err != nil { + return err + } + if err := a.usageService.ConfigurePublic(policy, assembly.EventSinks); err != nil { + return err + } // Provider type availability is startup-only. Always reconfigure so a // reload that omits provider_types resets to "all enabled" instead of // inheriting the previous process state. @@ -111,7 +132,12 @@ func (a *App) Provision(ctx caddy.Context) error { if err := a.credentialMgr.Load(ctx); err != nil { return fmt.Errorf("load credentials: %w", err) } + registry, err := assembly.Registry() + if err != nil { + return err + } if err := a.agentGateway.Bootstrap(ctx, runtimegateway.BootstrapOptions{ + ExtensionRegistry: registry, Distribution: assembly, EventPublisher: a.usageService, StaticLLMRoutes: a.LLMRoutes, StaticProviders: a.providers, ConfigStoreBackend: a.configBackend, @@ -121,7 +147,7 @@ func (a *App) Provision(ctx caddy.Context) error { UsageQuery: a.usageService.Query(), UsageStats: a.usageService, UsagePrometheus: a.usageService.Prometheus(), - UsageConfig: a.Metrics.Normalized(), + UsageConfig: distributionmetrics.From(a.Metrics).Normalized(), Logger: a.logger, }); err != nil { return fmt.Errorf("configure agent gateway: %w", err) @@ -196,18 +222,18 @@ func (a *App) provisionUsageService() { einotap.Register() dbProvider, ok := a.configBackend.(usage.SQLDBProvider) if !ok { - a.usageService = usage.NewUsageService(nil, nil) + a.usageService = usage.NewUsageService(pipeline.NewEventPipeline(4096), nil) return } - sink, query, err := pipeline.NewSQLiteSink(dbProvider, a.Metrics) + sink, query, err := pipeline.NewSQLiteSink(dbProvider, distributionmetrics.From(a.Metrics)) if err != nil { a.logger.Warn("usage metrics sqlite sink unavailable", zap.Error(err)) - a.usageService = usage.NewUsageService(nil, nil) + a.usageService = usage.NewUsageService(pipeline.NewEventPipeline(4096), nil) return } promSink := pipeline.NewPrometheusSink() sinks := []pipeline.Sink{sink, promSink} - if otlp := a.Metrics.Normalized().OTLP; otlp.Enabled() { + if otlp := distributionmetrics.From(a.Metrics).Normalized().OTLP; otlp.Enabled() { exporter, err := otelexport.New(context.Background(), otlp) if err != nil { a.logger.Warn("otlp usage exporter unavailable", zap.Error(err)) @@ -297,7 +323,13 @@ func (a *App) provisionProviders(_ caddy.Context) error { // Interface guards var ( - _ caddy.App = (*App)(nil) - _ caddy.Provisioner = (*App)(nil) - _ caddy.Validator = (*App)(nil) + _ caddy.App = (*App)(nil) + _ caddy.Provisioner = (*App)(nil) + _ caddy.Validator = (*App)(nil) + _ caddy.CleanerUpper = (*App)(nil) ) + +// Cleanup also handles Caddy validation and failed provisioning. +func (a *App) Cleanup() error { + return a.Stop() +} diff --git a/caddy/gateway/app_test.go b/caddy/gateway/app_test.go index 53a366dc..de43ed4e 100644 --- a/caddy/gateway/app_test.go +++ b/caddy/gateway/app_test.go @@ -3,6 +3,9 @@ package gateway import ( "context" "errors" + "github.com/caddyserver/caddy/v2" + "strings" + "testing" "github.com/agent-guide/agent-gateway/pkg/configstore" configstoreschema "github.com/agent-guide/agent-gateway/pkg/configstore/schema" @@ -89,3 +92,12 @@ func (s *testProvisionVirtualKeyStore) GetByIndex(_ context.Context, indexName s } return nil, configstore.ErrNotFound } + +func TestCaddyJSONRejectsExtensionConfiguration(t *testing.T) { + for _, raw := range []string{`{"extensions":[]}`, `{"extensions":[{"module_id":"example.fixture","schema_version":1,"config":{}}]}`} { + var app App + if err := caddy.StrictUnmarshalJSON([]byte(raw), &app); err == nil || !strings.Contains(err.Error(), "extensions") { + t.Fatalf("expected removed field rejection: %v", err) + } + } +} diff --git a/caddy/gateway/caddyfile.go b/caddy/gateway/caddyfile.go index a4bab215..13f4eb9d 100644 --- a/caddy/gateway/caddyfile.go +++ b/caddy/gateway/caddyfile.go @@ -1,6 +1,7 @@ package gateway import ( + "github.com/agent-guide/agent-gateway/internal/distributionmetrics" "strconv" "strings" @@ -195,7 +196,7 @@ func parseMetricsOTLP(seg *caddyfile.Dispenser, app *App) error { return seg.Errf("unknown otlp subdirective: %s", seg.Val()) } } - if err := app.Metrics.OTLP.Validate(); err != nil { + if err := distributionmetrics.From(app.Metrics).OTLP.Validate(); err != nil { return seg.Err(err.Error()) } return nil diff --git a/caddy/gateway/caddyfile_test.go b/caddy/gateway/caddyfile_test.go index 8bb21065..ceef2521 100644 --- a/caddy/gateway/caddyfile_test.go +++ b/caddy/gateway/caddyfile_test.go @@ -307,3 +307,11 @@ func TestParseAppRejectsVirtualKeyDirective(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestCaddyfileRejectsExtensionConfiguration(t *testing.T) { + d := caddyfile.NewTestDispenser("agent_gateway {\n extension example.fixture.secret 1 /does/not/exist.json\n}") + _, err := parseApp(d, nil) + if err == nil || !strings.Contains(err.Error(), "unknown subdirective: extension") { + t.Fatalf("expected extension rejection, got %v", err) + } +} diff --git a/caddy/gateway/public_event_cleanup_test.go b/caddy/gateway/public_event_cleanup_test.go new file mode 100644 index 00000000..364825ae --- /dev/null +++ b/caddy/gateway/public_event_cleanup_test.go @@ -0,0 +1,72 @@ +package gateway + +import ( + "context" + "encoding/json" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/agent-guide/agent-gateway/internal/distributionruntime" + "github.com/agent-guide/agent-gateway/internal/observability/pipeline" + "github.com/agent-guide/agent-gateway/internal/observability/usage" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/caddyserver/caddy/v2" +) + +type cleanupSink struct { + flushes atomic.Int32 + closes atomic.Int32 +} + +func (*cleanupSink) Write(context.Context, event.Envelope) error { return nil } +func (s *cleanupSink) Flush(context.Context) error { s.flushes.Add(1); return nil } +func (s *cleanupSink) Close(context.Context) error { s.closes.Add(1); return nil } + +func TestCaddyValidationClosesProvisionedPublicSink(t *testing.T) { + previous := distributionruntime.Current() + t.Cleanup(func() { + if err := distributionruntime.Install(previous); err != nil { + t.Error(err) + } + }) + sink := new(cleanupSink) + var creates atomic.Int32 + a := distribution.CommunityRuntime() + a.EventSinks = []event.Registration{{ModuleID: "online.agentguide.community.gateway", New: func(context.Context) (event.Sink, error) { creates.Add(1); return sink, nil }}} + if err := distributionruntime.Install(a); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(map[string]any{"config_store": map[string]any{"sqlite": map[string]string{"sqlite_path": filepath.Join(t.TempDir(), "config.db")}}}) + if err != nil { + t.Fatal(err) + } + cfg := &caddy.Config{Admin: &caddy.AdminConfig{Disabled: true}, AppsRaw: caddy.ModuleMap{"agent_gateway": raw}} + if err := caddy.Validate(cfg); err != nil { + t.Fatal(err) + } + if creates.Load() != 1 || sink.flushes.Load() != 1 || sink.closes.Load() != 1 { + t.Fatalf("validate lifecycle: creates=%d flushes=%d closes=%d", creates.Load(), sink.flushes.Load(), sink.closes.Load()) + } +} + +func TestCleanupAfterStopDoesNotClosePublicSinkTwice(t *testing.T) { + sink := new(cleanupSink) + svc := usage.NewUsageService(pipeline.NewEventPipeline(1), nil) + if err := svc.ConfigurePublic(attribution.Policy{}, []event.Registration{{ModuleID: "online.agentguide.community.gateway", New: func(context.Context) (event.Sink, error) { return sink, nil }}}); err != nil { + t.Fatal(err) + } + a := &App{usageService: svc} + t.Cleanup(func() { _ = a.Cleanup() }) + if err := a.Stop(); err != nil { + t.Fatal(err) + } + if err := a.Cleanup(); err != nil { + t.Fatal(err) + } + if sink.flushes.Load() != 1 || sink.closes.Load() != 1 { + t.Fatalf("repeated teardown: flushes=%d closes=%d", sink.flushes.Load(), sink.closes.Load()) + } +} diff --git a/cmd/agw/main.go b/cmd/agw/main.go index b8af6331..2c25e1e9 100644 --- a/cmd/agw/main.go +++ b/cmd/agw/main.go @@ -1,7 +1,8 @@ package main import ( - caddycmd "github.com/caddyserver/caddy/v2/cmd" + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/distribution/caddycmd" // Standard Caddy modules _ "github.com/caddyserver/caddy/v2/modules/standard" @@ -35,5 +36,5 @@ import ( ) func main() { - caddycmd.Main() + caddycmd.New(distribution.CommunityRuntime()).Run() } diff --git a/cmd/agwctl/cmd_caddy.go b/cmd/agwctl/cmd_caddy.go deleted file mode 100644 index 78e18836..00000000 --- a/cmd/agwctl/cmd_caddy.go +++ /dev/null @@ -1,306 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "strings" - - "github.com/agent-guide/agent-gateway/internal/agwctl/caddyadminclient" - "github.com/spf13/cobra" -) - -// ── caddy ───────────────────────────────────────────────────────────────────── - -var caddyCmd = &cobra.Command{ - Use: "caddy", - Short: "Manage Caddy servers and routes via the Caddy admin API", - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if cmd.Flags().Changed("admin-addr") { - return fmt.Errorf("--admin-addr configures the Agent Gateway admin API; use --addr for the Caddy admin API") - } - return nil - }, -} - -var rejectedCaddyAdminAddr string - -// ── caddy server ────────────────────────────────────────────────────────────── - -var caddyServerCmd = &cobra.Command{ - Use: "server", - Short: "Manage Caddy HTTP servers", -} - -var caddyServerListCmd = &cobra.Command{ - Use: "list", - Short: "List all Caddy HTTP servers", - RunE: func(cmd *cobra.Command, args []string) error { - mgr := caddyadminclient.NewManager(globalCaddyAdmin) - servers, err := mgr.ListServers(context.Background()) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(servers) - } - printCaddyServersTable(servers) - return nil - }, -} - -var caddyServerGetCmd = &cobra.Command{ - Use: "get ", - Short: "Get a Caddy HTTP server", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - mgr := caddyadminclient.NewManager(globalCaddyAdmin) - srv, err := mgr.GetServer(context.Background(), args[0]) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(srv) - } - printCaddyServersTable([]*caddyadminclient.ServerResponse{srv}) - return nil - }, -} - -var ( - serverCreateID string - serverCreateListen []string - serverCreateTLSAuto bool -) - -var caddyServerCreateCmd = &cobra.Command{ - Use: "create", - Short: "Create a new Caddy HTTP server", - RunE: func(cmd *cobra.Command, args []string) error { - if serverCreateID == "" { - return fmt.Errorf("--id is required") - } - if len(serverCreateListen) == 0 { - return fmt.Errorf("--listen is required") - } - mgr := caddyadminclient.NewManager(globalCaddyAdmin) - req := &caddyadminclient.ServerRequest{ - ID: serverCreateID, - Listen: serverCreateListen, - } - if serverCreateTLSAuto { - req.TLS = &caddyadminclient.TLSConf{Auto: true} - } - if err := mgr.CreateServer(context.Background(), req); err != nil { - return err - } - srv, err := mgr.GetServer(context.Background(), serverCreateID) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(srv) - } - printCaddyServersTable([]*caddyadminclient.ServerResponse{srv}) - return nil - }, -} - -var ( - serverUpdateListen []string - serverUpdateTLSAuto bool -) - -var caddyServerUpdateCmd = &cobra.Command{ - Use: "update ", - Short: "Update an existing Caddy HTTP server", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - if len(serverUpdateListen) == 0 { - return fmt.Errorf("--listen is required") - } - mgr := caddyadminclient.NewManager(globalCaddyAdmin) - req := &caddyadminclient.ServerRequest{ - ID: args[0], - Listen: serverUpdateListen, - } - if serverUpdateTLSAuto { - req.TLS = &caddyadminclient.TLSConf{Auto: true} - } - if err := mgr.UpdateServer(context.Background(), req); err != nil { - return err - } - srv, err := mgr.GetServer(context.Background(), args[0]) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(srv) - } - printCaddyServersTable([]*caddyadminclient.ServerResponse{srv}) - return nil - }, -} - -var caddyServerDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a Caddy HTTP server", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - mgr := caddyadminclient.NewManager(globalCaddyAdmin) - if err := mgr.DeleteServer(context.Background(), args[0]); err != nil { - return err - } - fmt.Fprintf(os.Stdout, "deleted server %q\n", args[0]) - return nil - }, -} - -// ── caddy route ─────────────────────────────────────────────────────────────── - -var caddyRouteCmd = &cobra.Command{ - Use: "route", - Short: "Manage Caddy HTTP routes within a server", -} - -var caddyRouteListCmd = &cobra.Command{ - Use: "list ", - Short: "List routes in a Caddy server", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - mgr := caddyadminclient.NewManager(globalCaddyAdmin) - routes, err := mgr.ListRoutes(context.Background(), args[0]) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(routes) - } - printCaddyRoutesTable(routes) - return nil - }, -} - -var ( - routeAddID string - routeAddOrder int - routeAddPaths []string - routeAddHosts []string - routeAddHandlers []string -) - -var caddyRouteAddCmd = &cobra.Command{ - Use: "add ", - Short: "Add a route to a Caddy server", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - if routeAddID == "" { - return fmt.Errorf("--id is required") - } - handlers, err := parseHandlers(routeAddHandlers) - if err != nil { - return err - } - mgr := caddyadminclient.NewManager(globalCaddyAdmin) - req := &caddyadminclient.RouteRequest{ - ID: routeAddID, - Order: routeAddOrder, - Match: caddyadminclient.MatchConf{Paths: routeAddPaths, Hosts: routeAddHosts}, - Handlers: handlers, - } - if err := mgr.AddRoute(context.Background(), args[0], req); err != nil { - return err - } - routes, err := mgr.ListRoutes(context.Background(), args[0]) - if err != nil { - return err - } - for _, r := range routes { - if r.ID == routeAddID { - if outputFormat == "json" { - return printJSON(r) - } - printCaddyRoutesTable([]*caddyadminclient.RouteResponse{r}) - return nil - } - } - fmt.Fprintf(os.Stdout, "added route %q\n", routeAddID) - return nil - }, -} - -var caddyRouteDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a route from a Caddy server", - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - mgr := caddyadminclient.NewManager(globalCaddyAdmin) - if err := mgr.DeleteRoute(context.Background(), args[0], args[1]); err != nil { - return err - } - fmt.Fprintf(os.Stdout, "deleted route %q from server %q\n", args[1], args[0]) - return nil - }, -} - -// parseHandlers parses handler descriptors of the form "type[:key=value,...]". -// Supported shorthand types: agent_route_dispatcher, admin, reverse_proxy, file_server. -// Examples: -// -// agent_route_dispatcher:apis=openai,anthropic -// reverse_proxy:upstream=localhost:9000 -// file_server:root=/var/www -// admin -func parseHandlers(specs []string) ([]caddyadminclient.HandlerConf, error) { - handlers := make([]caddyadminclient.HandlerConf, 0, len(specs)) - for _, spec := range specs { - htype, params, _ := strings.Cut(spec, ":") - conf := caddyadminclient.HandlerConf{Type: htype} - if params != "" { - for _, kv := range strings.Split(params, ",") { - k, v, _ := strings.Cut(kv, "=") - switch k { - case "apis": - conf.APIs = strings.Split(v, ";") - case "upstream": - conf.Upstream = v - case "root": - conf.Root = v - default: - return nil, fmt.Errorf("unknown handler param %q in %q", k, spec) - } - } - } - handlers = append(handlers, conf) - } - return handlers, nil -} - -func init() { - caddyCmd.PersistentFlags().StringVar(&globalCaddyAdmin, "addr", envOr("CADDY_ADMIN_ADDR", "http://localhost:2019"), "Caddy admin API address") - // Shadow the root gateway flag so the former Caddy alias fails explicitly - // instead of silently changing which admin endpoint it configures. - caddyCmd.PersistentFlags().StringVar(&rejectedCaddyAdminAddr, "admin-addr", "", "") - _ = caddyCmd.PersistentFlags().MarkHidden("admin-addr") - - // server create flags - caddyServerCreateCmd.Flags().StringVar(&serverCreateID, "id", "", "server ID (required)") - caddyServerCreateCmd.Flags().StringArrayVar(&serverCreateListen, "listen", nil, "listen addresses (repeatable)") - caddyServerCreateCmd.Flags().BoolVar(&serverCreateTLSAuto, "tls-auto", false, "enable automatic TLS") - - // server update flags - caddyServerUpdateCmd.Flags().StringArrayVar(&serverUpdateListen, "listen", nil, "listen addresses (repeatable)") - caddyServerUpdateCmd.Flags().BoolVar(&serverUpdateTLSAuto, "tls-auto", false, "enable automatic TLS") - - // route add flags - caddyRouteAddCmd.Flags().StringVar(&routeAddID, "id", "", "route ID (required)") - caddyRouteAddCmd.Flags().IntVar(&routeAddOrder, "order", 0, "insertion position (0 = first)") - caddyRouteAddCmd.Flags().StringArrayVar(&routeAddPaths, "path", nil, "match path (repeatable)") - caddyRouteAddCmd.Flags().StringArrayVar(&routeAddHosts, "host", nil, "match host (repeatable)") - caddyRouteAddCmd.Flags().StringArrayVar(&routeAddHandlers, "handler", nil, `handler spec: type[:key=val,...] (repeatable)`) - - caddyServerCmd.AddCommand(caddyServerListCmd, caddyServerGetCmd, caddyServerCreateCmd, caddyServerUpdateCmd, caddyServerDeleteCmd) - caddyRouteCmd.AddCommand(caddyRouteListCmd, caddyRouteAddCmd, caddyRouteDeleteCmd) - caddyCmd.AddCommand(caddyServerCmd, caddyRouteCmd) - rootCmd.AddCommand(caddyCmd) -} diff --git a/cmd/agwctl/cmd_gateway.go b/cmd/agwctl/cmd_gateway.go deleted file mode 100644 index f1e97252..00000000 --- a/cmd/agwctl/cmd_gateway.go +++ /dev/null @@ -1,953 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/agent-guide/agent-gateway/pkg/adminclient" - "github.com/agent-guide/agent-gateway/pkg/gatewaybundle" - "github.com/spf13/cobra" - - _ "github.com/agent-guide/agent-gateway/pkg/dispatcher/llmapi/anthropic" - _ "github.com/agent-guide/agent-gateway/pkg/dispatcher/llmapi/cc" - _ "github.com/agent-guide/agent-gateway/pkg/dispatcher/llmapi/openai" - _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/anthropic" - _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/anthropicbase" - _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/claudecode" - _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/codex" - _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/deepseek" - _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/gemini" - _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/grok" - _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/ollama" - _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/openai" - _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/openrouter" - _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/qwen" - _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/zhipu" -) - -var ( - gwAdminBasicAuth string - gwAdminHeaders []string - - gatewayBundleFile string - gatewayExportFile string - - gatewayManagedModelProviderID string - gatewayCredentialType string - gatewayCredentialProviderType string - gatewayCredentialProviderID string - gatewayMCPRuntimeRouteID string - gatewayMCPToolArguments string - gatewayMCPPromptArguments string -) - -var gatewayValidateCmd = &cobra.Command{ - Use: "validate", - Short: "Validate a gateway bundle YAML file locally", - RunE: func(cmd *cobra.Command, args []string) error { - if gatewayBundleFile == "" { - return fmt.Errorf("--file is required") - } - bundle, err := gatewaybundle.LoadFile(gatewayBundleFile) - if err != nil { - return err - } - if err := bundle.ValidateForConfigStore(); err != nil { - return err - } - if outputFormat == "json" { - return printJSON(map[string]any{ - "status": "ok", - "file": gatewayBundleFile, - "counts": map[string]int{ - "providers": len(bundle.Providers), - "managed_models": len(bundle.ManagedModels), - "llm_routes": len(bundle.LLMRoutes), - "virtual_keys": len(bundle.VirtualKeys), - "mcp_services": len(bundle.MCPServices), - "mcp_routes": len(bundle.MCPRoutes), - "agent_routes": len(bundle.AgentRoutes), - "agents": len(bundle.Agents), - }, - }) - } - fmt.Fprintf(cmd.OutOrStdout(), "gateway bundle is valid: %s\n", gatewayBundleFile) - return nil - }, -} - -var gatewayApplyCmd = &cobra.Command{ - Use: "apply", - Short: "Apply a gateway bundle YAML file to the remote admin API", - RunE: func(cmd *cobra.Command, args []string) error { - return runGatewayApply(context.Background(), gatewayBundleFile) - }, -} - -var gatewayExportCmd = &cobra.Command{ - Use: "export", - Short: "Export gateway objects from the remote admin API as bundle YAML", - RunE: func(cmd *cobra.Command, args []string) error { - return runGatewayExport(context.Background(), gatewayExportFile) - }, -} - -// ── gateway provider ───────────────────────────────────────────────────────── - -var gatewayProviderCmd = &cobra.Command{ - Use: "provider", - Short: "Manage gateway providers", -} - -var gatewayProviderListCmd = &cobra.Command{ - Use: "list", - Short: "List all providers", - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListProviders(context.Background(), adminclient.ProviderListOptions{}) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayProvidersTable(items) - return nil - }, -} - -var gatewayProviderGetCmd = &cobra.Command{ - Use: "get ", - Short: "Get one provider", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().GetProvider(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) - }, -} - -var gatewayProviderDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a provider", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().DeleteProvider(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(resp) - }, -} - -var gatewayProviderEnableCmd = &cobra.Command{ - Use: "enable ", - Short: "Enable a provider", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().EnableProvider(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) - }, -} - -var gatewayProviderDisableCmd = &cobra.Command{ - Use: "disable ", - Short: "Disable a provider", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().DisableProvider(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) - }, -} - -// ── gateway llm route ───────────────────────────────────────────────────────── - -var gatewayLLMRouteCmd = &cobra.Command{ - Use: "llm-route", - Short: "Manage gateway LLM routes", -} - -var gatewayLLMRouteListCmd = &cobra.Command{ - Use: "list", - Short: "List all LLM routes", - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListLLMRoutes(context.Background(), adminclient.LLMRouteListOptions{}) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayLLMRoutesTable(items) - return nil - }, -} - -var gatewayLLMRouteGetCmd = &cobra.Command{ - Use: "get ", - Short: "Get one LLM route", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().GetLLMRoute(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) - }, -} - -var gatewayLLMRouteDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete an LLM route", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().DeleteLLMRoute(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(resp) - }, -} - -var gatewayLLMRouteEnableCmd = &cobra.Command{ - Use: "enable ", - Short: "Enable an LLM route", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().EnableLLMRoute(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) - }, -} - -var gatewayLLMRouteDisableCmd = &cobra.Command{ - Use: "disable ", - Short: "Disable an LLM route", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().DisableLLMRoute(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) - }, -} - -// ── gateway mcp service ────────────────────────────────────────────────────── - -var gatewayMCPServiceCmd = &cobra.Command{ - Use: "mcp-service", - Short: "Manage gateway MCP services", -} - -var gatewayMCPServiceListCmd = &cobra.Command{ - Use: "list", - Short: "List all MCP services", - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListMCPServices(context.Background()) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayMCPServicesTable(items) - return nil - }, -} - -var gatewayMCPServiceGetCmd = &cobra.Command{ - Use: "get ", - Short: "Get one MCP service", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().GetMCPService(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) - }, -} - -var gatewayMCPServiceDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete an MCP service", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().DeleteMCPService(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(resp) - }, -} - -var gatewayMCPServiceSessionCmd = &cobra.Command{ - Use: "session ", - Short: "Get the active gateway session for an MCP service", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().GetMCPServiceSession(context.Background(), args[0]) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(item) - } - printGatewayMCPSessionTable(item) - return nil - }, -} - -var gatewayMCPServiceCapabilitiesCmd = &cobra.Command{ - Use: "capabilities ", - Short: "Get the upstream initialize payload for an MCP service", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().GetMCPServiceCapabilities(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) - }, -} - -var gatewayMCPServiceToolsCmd = &cobra.Command{ - Use: "tools ", - Short: "List tools exposed by an MCP service", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListMCPServiceTools(context.Background(), args[0]) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayMCPToolsTable(items) - return nil - }, -} - -var gatewayMCPServiceToolCallCmd = &cobra.Command{ - Use: "tool-call ", - Short: "Call a tool exposed by an MCP service", - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - arguments, err := parseOptionalJSONObjectFlag(gatewayMCPToolArguments, "--arguments") - if err != nil { - return err - } - item, err := newGatewayClient().CallMCPServiceTool(context.Background(), args[0], adminclient.MCPToolCallRequest{ - Name: args[1], - Arguments: arguments, - }) - if err != nil { - return err - } - return printJSON(item) - }, -} - -var gatewayMCPServiceResourcesCmd = &cobra.Command{ - Use: "resources ", - Short: "List resources exposed by an MCP service", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListMCPServiceResources(context.Background(), args[0]) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayMCPResourcesTable(items) - return nil - }, -} - -var gatewayMCPServiceResourceTemplatesCmd = &cobra.Command{ - Use: "resource-templates ", - Short: "List resource templates exposed by an MCP service", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListMCPServiceResourceTemplates(context.Background(), args[0]) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayMCPResourceTemplatesTable(items) - return nil - }, -} - -var gatewayMCPServiceResourceReadCmd = &cobra.Command{ - Use: "resource-read ", - Short: "Read one resource exposed by an MCP service", - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().ReadMCPServiceResource(context.Background(), args[0], adminclient.MCPResourceReadRequest{ - URI: args[1], - }) - if err != nil { - return err - } - return printJSON(item) - }, -} - -var gatewayMCPServicePromptsCmd = &cobra.Command{ - Use: "prompts ", - Short: "List prompts exposed by an MCP service", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListMCPServicePrompts(context.Background(), args[0]) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayMCPPromptsTable(items) - return nil - }, -} - -var gatewayMCPServicePromptGetCmd = &cobra.Command{ - Use: "prompt-get ", - Short: "Get one prompt from an MCP service", - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - arguments, err := parseOptionalJSONObjectFlag(gatewayMCPPromptArguments, "--arguments") - if err != nil { - return err - } - item, err := newGatewayClient().GetMCPServicePrompt(context.Background(), args[0], adminclient.MCPPromptGetRequest{ - Name: args[1], - Arguments: arguments, - }) - if err != nil { - return err - } - return printJSON(item) - }, -} - -// ── gateway mcp route ──────────────────────────────────────────────────────── - -var gatewayMCPRouteCmd = &cobra.Command{ - Use: "mcp-route", - Short: "Manage gateway MCP routes", -} - -var gatewayMCPRouteListCmd = &cobra.Command{ - Use: "list", - Short: "List all MCP routes", - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListMCPRoutes(context.Background()) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayMCPRoutesTable(items) - return nil - }, -} - -var gatewayMCPRouteGetCmd = &cobra.Command{ - Use: "get ", - Short: "Get one MCP route", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().GetMCPRoute(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) - }, -} - -var gatewayMCPRouteDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete an MCP route", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().DeleteMCPRoute(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(resp) - }, -} - -// ── gateway mcp runtime ────────────────────────────────────────────────────── - -var gatewayMCPRuntimeCmd = &cobra.Command{ - Use: "mcp-runtime", - Short: "Inspect gateway MCP runtime state", -} - -var gatewayMCPRuntimeGetCmd = &cobra.Command{ - Use: "get", - Short: "Get the MCP runtime overview", - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().GetMCPRuntime(context.Background()) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(item) - } - printGatewayMCPRuntimeOverview(item) - return nil - }, -} - -var gatewayMCPRuntimeInFlightCmd = &cobra.Command{ - Use: "inflight", - Short: "List in-flight MCP requests", - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListMCPRuntimeInFlight(context.Background()) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayMCPRuntimeInFlightTable(items) - return nil - }, -} - -var gatewayMCPRuntimeProgressCmd = &cobra.Command{ - Use: "progress", - Short: "List MCP progress notifications", - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListMCPRuntimeProgress(context.Background()) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayMCPRuntimeProgressTable(items) - return nil - }, -} - -var gatewayMCPRuntimeHistoryCmd = &cobra.Command{ - Use: "history", - Short: "List completed MCP request history", - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListMCPRuntimeHistory(context.Background(), gatewayMCPRuntimeRouteID) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayMCPRuntimeHistoryTable(items) - return nil - }, -} - -// ── gateway virtualkey ─────────────────────────────────────────────────────── - -var gatewayVirtualKeyCmd = &cobra.Command{ - Use: "virtualkey", - Short: "Manage gateway virtual keys", -} - -var gatewayVirtualKeyListCmd = &cobra.Command{ - Use: "list", - Short: "List all virtual keys", - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListVirtualKeys(context.Background(), adminclient.VirtualKeyListOptions{}) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayVirtualKeysTable(items) - return nil - }, -} - -var gatewayVirtualKeyGetCmd = &cobra.Command{ - Use: "get ", - Short: "Get one virtual key by id", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().GetVirtualKey(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) - }, -} - -var gatewayVirtualKeyDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a virtual key by id", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().DeleteVirtualKey(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(resp) - }, -} - -var gatewayVirtualKeyEnableCmd = &cobra.Command{ - Use: "enable ", - Short: "Enable a virtual key by id", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().EnableVirtualKey(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) - }, -} - -var gatewayVirtualKeyDisableCmd = &cobra.Command{ - Use: "disable ", - Short: "Disable a virtual key by id", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().DisableVirtualKey(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) - }, -} - -// ── gateway credential ──────────────────────────────────────────────────────── - -var gatewayCredentialCmd = &cobra.Command{ - Use: "credential", - Short: "Manage gateway credentials", -} - -// ── gateway models ─────────────────────────────────────────────────────────── - -var gatewayModelsCmd = &cobra.Command{ - Use: "model", - Short: "Manage gateway models", -} - -// ── gateway provider types ─────────────────────────────────────────────────── - -var gatewayProviderTypesCmd = &cobra.Command{ - Use: "provider-type", - Short: "Inspect gateway provider types", -} - -var gatewayProviderTypesListCmd = &cobra.Command{ - Use: "list", - Short: "List provider types known to the gateway runtime", - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListProviderTypes(context.Background()) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayProviderTypesTable(items) - return nil - }, -} - -// ── gateway llm api handler types ──────────────────────────────────────────── - -var gatewayLLMAPIHandlerTypesCmd = &cobra.Command{ - Use: "llm-api-handler-type", - Short: "Manage gateway LLM API handler types", -} - -var gatewayLLMAPIHandlerTypesListCmd = &cobra.Command{ - Use: "list", - Short: "List LLM API handler types known to the gateway runtime", - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListLLMAPIHandlerTypes(context.Background()) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayLLMAPIHandlerTypesTable(items) - return nil - }, -} - -var gatewayModelsDiscoveredCmd = &cobra.Command{ - Use: "discovered", - Short: "Manage discovered provider models", -} - -var gatewayModelsDiscoveredListCmd = &cobra.Command{ - Use: "list ", - Short: "List discovered models for one provider", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListDiscoveredModels(context.Background(), args[0]) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayDiscoveredModelsTable(items) - return nil - }, -} - -var gatewayModelsDiscoveredRefreshCmd = &cobra.Command{ - Use: "refresh ", - Short: "Refresh discovered models for one provider", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().RefreshProviderModels(context.Background(), args[0]) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(resp) - } - printGatewayDiscoveredModelsTable(resp.Items) - return nil - }, -} - -var gatewayModelsManagedCmd = &cobra.Command{ - Use: "managed", - Short: "Manage managed model overlays", -} - -var gatewayModelsManagedListCmd = &cobra.Command{ - Use: "list", - Short: "List managed models", - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListManagedModels(context.Background(), adminclient.ManagedModelListOptions{ - ProviderID: gatewayManagedModelProviderID, - }) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayManagedModelsTable(items) - return nil - }, -} - -var gatewayModelsManagedGetCmd = &cobra.Command{ - Use: "get ", - Short: "Get one managed model overlay", - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().GetManagedModel(context.Background(), args[0], args[1]) - if err != nil { - return err - } - return printJSON(item) - }, -} - -var gatewayModelsManagedDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a managed model overlay", - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().DeleteManagedModel(context.Background(), args[0], args[1]) - if err != nil { - return err - } - return printJSON(resp) - }, -} - -var gatewayCredentialListCmd = &cobra.Command{ - Use: "list", - Short: "List all credentials", - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListCredentials(context.Background(), adminclient.CredentialListOptions{ - Type: gatewayCredentialType, - ProviderType: gatewayCredentialProviderType, - ProviderID: gatewayCredentialProviderID, - }) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayCredentialsTable(items) - return nil - }, -} - -var gatewayCredentialGetCmd = &cobra.Command{ - Use: "get ", - Short: "Get one credential", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().GetCredential(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) - }, -} - -var gatewayCredentialDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete a credential", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().DeleteCredential(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(resp) - }, -} - -func newGatewayClient() *adminclient.Client { - return adminclient.New(adminclient.Config{ - BaseURL: globalGatewayAddr, - BasicAuth: gwAdminBasicAuth, - Headers: gwAdminHeaders, - }) -} - -func parseOptionalJSONObjectFlag(raw string, flagName string) (map[string]any, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil, nil - } - var payload map[string]any - if err := json.Unmarshal([]byte(raw), &payload); err != nil { - return nil, fmt.Errorf("%s must be a JSON object: %w", flagName, err) - } - if payload == nil { - return nil, fmt.Errorf("%s must be a JSON object", flagName) - } - return payload, nil -} - -func init() { - gatewayModelsManagedListCmd.Flags().StringVar(&gatewayManagedModelProviderID, "provider-id", "", "filter managed models by provider ID") - gatewayCredentialListCmd.Flags().StringVar(&gatewayCredentialType, "type", "", "filter credentials by type (api_key or oauth_token)") - gatewayCredentialListCmd.Flags().StringVar(&gatewayCredentialProviderType, "provider-type", "", "filter credentials by provider type") - gatewayCredentialListCmd.Flags().StringVar(&gatewayCredentialProviderID, "provider-id", "", "filter credentials by provider ID") - gatewayMCPRuntimeHistoryCmd.Flags().StringVar(&gatewayMCPRuntimeRouteID, "route-id", "", "filter completed MCP requests by route ID") - gatewayMCPServiceToolCallCmd.Flags().StringVar(&gatewayMCPToolArguments, "arguments", "", "JSON object passed as tool arguments") - gatewayMCPServicePromptGetCmd.Flags().StringVar(&gatewayMCPPromptArguments, "arguments", "", "JSON object passed as prompt arguments") - gatewayValidateCmd.Flags().StringVarP(&gatewayBundleFile, "file", "f", "", "path to gateway bundle YAML file") - gatewayApplyCmd.Flags().StringVarP(&gatewayBundleFile, "file", "f", "", "path to gateway bundle YAML file") - gatewayExportCmd.Flags().StringVarP(&gatewayExportFile, "file", "f", "", "write bundle YAML to this file instead of stdout") - - gatewayProviderCmd.AddCommand( - gatewayProviderListCmd, - gatewayProviderGetCmd, - gatewayProviderDeleteCmd, - gatewayProviderEnableCmd, - gatewayProviderDisableCmd, - ) - gatewayLLMRouteCmd.AddCommand( - gatewayLLMRouteListCmd, - gatewayLLMRouteGetCmd, - gatewayLLMRouteDeleteCmd, - gatewayLLMRouteEnableCmd, - gatewayLLMRouteDisableCmd, - ) - gatewayMCPServiceCmd.AddCommand( - gatewayMCPServiceListCmd, - gatewayMCPServiceGetCmd, - gatewayMCPServiceDeleteCmd, - gatewayMCPServiceSessionCmd, - gatewayMCPServiceCapabilitiesCmd, - gatewayMCPServiceToolsCmd, - gatewayMCPServiceToolCallCmd, - gatewayMCPServiceResourcesCmd, - gatewayMCPServiceResourceTemplatesCmd, - gatewayMCPServiceResourceReadCmd, - gatewayMCPServicePromptsCmd, - gatewayMCPServicePromptGetCmd, - ) - gatewayMCPRouteCmd.AddCommand( - gatewayMCPRouteListCmd, - gatewayMCPRouteGetCmd, - gatewayMCPRouteDeleteCmd, - ) - gatewayMCPRuntimeCmd.AddCommand( - gatewayMCPRuntimeGetCmd, - gatewayMCPRuntimeInFlightCmd, - gatewayMCPRuntimeProgressCmd, - gatewayMCPRuntimeHistoryCmd, - ) - gatewayVirtualKeyCmd.AddCommand( - gatewayVirtualKeyListCmd, - gatewayVirtualKeyGetCmd, - gatewayVirtualKeyDeleteCmd, - gatewayVirtualKeyEnableCmd, - gatewayVirtualKeyDisableCmd, - ) - gatewayCredentialCmd.AddCommand( - gatewayCredentialListCmd, - gatewayCredentialGetCmd, - gatewayCredentialDeleteCmd, - ) - gatewayModelsDiscoveredCmd.AddCommand( - gatewayModelsDiscoveredListCmd, - gatewayModelsDiscoveredRefreshCmd, - ) - gatewayModelsManagedCmd.AddCommand( - gatewayModelsManagedListCmd, - gatewayModelsManagedGetCmd, - gatewayModelsManagedDeleteCmd, - ) - gatewayModelsCmd.AddCommand(gatewayModelsDiscoveredCmd, gatewayModelsManagedCmd) - gatewayProviderTypesCmd.AddCommand(gatewayProviderTypesListCmd) - gatewayLLMAPIHandlerTypesCmd.AddCommand( - gatewayLLMAPIHandlerTypesListCmd, - ) - rootCmd.AddCommand( - gatewayValidateCmd, - gatewayApplyCmd, - gatewayExportCmd, - gatewayProviderCmd, - gatewayLLMRouteCmd, - gatewayMCPServiceCmd, - gatewayMCPRouteCmd, - gatewayMCPRuntimeCmd, - gatewayVirtualKeyCmd, - gatewayCredentialCmd, - gatewayProviderTypesCmd, - gatewayLLMAPIHandlerTypesCmd, - gatewayModelsCmd, - ) -} diff --git a/cmd/agwctl/cmd_gateway_acp.go b/cmd/agwctl/cmd_gateway_acp.go deleted file mode 100644 index 9540d77e..00000000 --- a/cmd/agwctl/cmd_gateway_acp.go +++ /dev/null @@ -1,55 +0,0 @@ -package main - -import ( - "context" - - "github.com/spf13/cobra" -) - -var gatewayACPRuntimeCmd = &cobra.Command{Use: "acp-runtime", Short: "Inspect gateway ACP runtime state"} - -var gatewayACPRuntimeGetCmd = &cobra.Command{ - Use: "get", Short: "Get the ACP runtime overview", - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().GetACPRuntime(context.Background()) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(item) - } - printGatewayACPRuntimeOverview(item) - return nil - }, -} - -var gatewayACPRuntimeInFlightCmd = &cobra.Command{ - Use: "inflight", Short: "List in-flight ACP turns", - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListACPRuntimeInFlight(context.Background()) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayACPInFlightTable(items) - return nil - }, -} - -var gatewayACPRuntimeCloseThreadCmd = &cobra.Command{ - Use: "close-thread ", Short: "Close pooled instances for one ACP Agent thread", Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().CloseACPThread(context.Background(), args[0], args[1]) - if err != nil { - return err - } - return printJSON(resp) - }, -} - -func init() { - gatewayACPRuntimeCmd.AddCommand(gatewayACPRuntimeGetCmd, gatewayACPRuntimeInFlightCmd, gatewayACPRuntimeCloseThreadCmd) - rootCmd.AddCommand(gatewayACPRuntimeCmd) -} diff --git a/cmd/agwctl/cmd_gateway_agent.go b/cmd/agwctl/cmd_gateway_agent.go deleted file mode 100644 index a4168b95..00000000 --- a/cmd/agwctl/cmd_gateway_agent.go +++ /dev/null @@ -1,232 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - - agentruntime "github.com/agent-guide/agent-gateway/pkg/agent/runtime" - - "github.com/spf13/cobra" -) - -// ── gateway agent ──────────────────────────────────────────────────────────── -// -// Reads and lifecycle deletes only. Agents are created/updated through -// `agwctl apply` like every other gateway-bundle object. - -var gatewayAgentCmd = &cobra.Command{ - Use: "agent", - Short: "Manage gateway agents", -} - -var gatewayAgentListCmd = &cobra.Command{ - Use: "list", - Short: "List all agents", - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListAgents(context.Background()) - if err != nil { - return err - } - if outputFormat == "json" { - return printJSON(items) - } - printGatewayAgentsTable(items) - return nil - }, -} - -var gatewayAgentGetCmd = &cobra.Command{ - Use: "get ", - Short: "Get one agent", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().GetAgent(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) - }, -} - -var gatewayAgentDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete an agent after its AgentRoutes are removed", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().DeleteAgent(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(resp) - }, -} - -var gatewayAgentWorkspaceCmd = &cobra.Command{ - Use: "workspace ", - Short: "Get the aggregated workspace summary for an agent", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().GetAgentWorkspace(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(resp) - }, -} - -var gatewayAgentActivityCmd = &cobra.Command{ - Use: "activity ", - Short: "Get recent activity for an agent", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().GetAgentActivity(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(resp) - }, -} - -var gatewayAgentUsageCmd = &cobra.Command{ - Use: "usage ", - Short: "Get usage summary for an agent", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().GetAgentUsage(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(resp) - }, -} - -var gatewayAgentInteractionsCmd = &cobra.Command{ - Use: "interactions ", - Short: "Get interaction events for an agent", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().GetAgentInteractions(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(resp) - }, -} - -var gatewayAgentResourcesCmd = &cobra.Command{ - Use: "resources ", - Short: "Get linked resources for an agent", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().GetAgentResources(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(resp) - }, -} - -var gatewayAgentHealthCmd = &cobra.Command{ - Use: "health ", - Short: "Get shallow health for an agent", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().GetAgentHealth(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(resp) - }, -} - -var agentCancelMode string -var agentPermissionOutcome string -var agentPermissionOptionID string -var agentPermissionDecisions string -var agentSessionCWD string -var agentSessionCursor string - -var gatewayAgentCapabilitiesCmd = rawAgentReadCommand("capabilities ", "Get runtime capabilities for an agent", func(ctx context.Context, id string) (json.RawMessage, error) { - return newGatewayClient().GetAgentCapabilities(ctx, id) -}) -var gatewayAgentRunsCmd = rawAgentReadCommand("runs ", "List active and retained runs for an agent", func(ctx context.Context, id string) (json.RawMessage, error) { - return newGatewayClient().ListAgentRuns(ctx, id) -}) -var gatewayAgentPermissionsCmd = rawAgentReadCommand("permissions ", "List pending permissions for an agent", func(ctx context.Context, id string) (json.RawMessage, error) { - return newGatewayClient().ListAgentPermissions(ctx, id) -}) - -var gatewayAgentCancelCmd = &cobra.Command{Use: "cancel ", Short: "Cancel one exact agent run", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().CancelAgentRun(context.Background(), args[0], args[1], agentruntime.CancelMode(agentCancelMode)) - if err != nil { - return err - } - return printJSON(resp) -}} -var gatewayAgentDecideCmd = &cobra.Command{Use: "decide ", Short: "Resolve one pending agent permission", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - decision := agentruntime.PermissionDecision{RequestID: args[1], Outcome: agentPermissionOutcome, OptionID: agentPermissionOptionID} - if agentPermissionDecisions != "" { - if err := json.Unmarshal([]byte(agentPermissionDecisions), &decision.Decisions); err != nil { - return fmt.Errorf("decode --decisions: %w", err) - } - } - resp, err := newGatewayClient().ResolveAgentPermission(context.Background(), args[0], args[1], decision) - if err != nil { - return err - } - return printJSON(resp) -}} -var gatewayAgentSessionsCmd = &cobra.Command{Use: "sessions ", Short: "List sessions exposed by an agent runtime", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().ListAgentSessions(context.Background(), args[0], agentSessionCWD, agentSessionCursor) - if err != nil { - return err - } - return printJSON(resp) -}} -var gatewayAgentTranscriptCmd = &cobra.Command{Use: "transcript ", Short: "Load a session transcript exposed by an agent runtime", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - resp, err := newGatewayClient().GetAgentTranscript(context.Background(), args[0], args[1], agentSessionCWD) - if err != nil { - return err - } - return printJSON(resp) -}} - -func rawAgentReadCommand(use, short string, read func(context.Context, string) (json.RawMessage, error)) *cobra.Command { - return &cobra.Command{Use: use, Short: short, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - resp, err := read(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(resp) - }} -} - -func init() { - gatewayAgentCancelCmd.Flags().StringVar(&agentCancelMode, "mode", "force", "cancel mode: force or graceful") - gatewayAgentDecideCmd.Flags().StringVar(&agentPermissionOutcome, "outcome", "", "runtime-native common outcome") - gatewayAgentDecideCmd.Flags().StringVar(&agentPermissionOptionID, "option-id", "", "ACP option id") - gatewayAgentDecideCmd.Flags().StringVar(&agentPermissionDecisions, "decisions", "", "builtin action decisions as JSON array") - gatewayAgentSessionsCmd.Flags().StringVar(&agentSessionCWD, "cwd", "", "session cwd filter") - gatewayAgentSessionsCmd.Flags().StringVar(&agentSessionCursor, "cursor", "", "session page cursor") - gatewayAgentTranscriptCmd.Flags().StringVar(&agentSessionCWD, "cwd", "", "session cwd") - gatewayAgentCmd.AddCommand( - gatewayAgentListCmd, - gatewayAgentGetCmd, - gatewayAgentDeleteCmd, - gatewayAgentWorkspaceCmd, - gatewayAgentActivityCmd, - gatewayAgentUsageCmd, - gatewayAgentInteractionsCmd, - gatewayAgentResourcesCmd, - gatewayAgentHealthCmd, - gatewayAgentCapabilitiesCmd, - gatewayAgentRunsCmd, - gatewayAgentCancelCmd, - gatewayAgentPermissionsCmd, - gatewayAgentDecideCmd, - gatewayAgentSessionsCmd, - gatewayAgentTranscriptCmd, - ) - rootCmd.AddCommand(gatewayAgentCmd) -} diff --git a/cmd/agwctl/cmd_gateway_agent_route.go b/cmd/agwctl/cmd_gateway_agent_route.go deleted file mode 100644 index ed81f2a3..00000000 --- a/cmd/agwctl/cmd_gateway_agent_route.go +++ /dev/null @@ -1,88 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "os" - - "github.com/agent-guide/agent-gateway/pkg/gateway/agentroute" - "github.com/spf13/cobra" - "gopkg.in/yaml.v3" -) - -var gatewayAgentRouteCmd = &cobra.Command{Use: "agent-route", Short: "Manage unified Agent ingress routes"} -var gatewayAgentRouteFile string -var gatewayAgentRouteListCmd = &cobra.Command{Use: "list", Short: "List Agent routes", RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListAgentRoutes(context.Background()) - if err != nil { - return err - } - return printJSON(items) -}} -var gatewayAgentRouteGetCmd = &cobra.Command{Use: "get ", Short: "Get one Agent route", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().GetAgentRoute(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) -}} -var gatewayAgentRouteDeleteCmd = &cobra.Command{Use: "delete ", Short: "Delete one Agent route", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - item, err := newGatewayClient().DeleteAgentRoute(context.Background(), args[0]) - if err != nil { - return err - } - return printJSON(item) -}} -var gatewayAgentRouteCreateCmd = &cobra.Command{Use: "create", Short: "Create an Agent route from --file", RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadAgentRouteFile() - if err != nil { - return err - } - item, err := newGatewayClient().CreateAgentRoute(context.Background(), cfg) - if err != nil { - return err - } - return printJSON(item) -}} -var gatewayAgentRouteUpdateCmd = &cobra.Command{Use: "update ", Short: "Update an Agent route from --file", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadAgentRouteFile() - if err != nil { - return err - } - item, err := newGatewayClient().UpdateAgentRoute(context.Background(), args[0], cfg) - if err != nil { - return err - } - return printJSON(item) -}} - -func loadAgentRouteFile() (agentroute.AgentRouteConfig, error) { - if gatewayAgentRouteFile == "" { - return agentroute.AgentRouteConfig{}, fmt.Errorf("--file is required") - } - data, err := os.ReadFile(gatewayAgentRouteFile) - if err != nil { - return agentroute.AgentRouteConfig{}, err - } - var raw any - if err := yaml.Unmarshal(data, &raw); err != nil { - return agentroute.AgentRouteConfig{}, err - } - data, err = json.Marshal(raw) - if err != nil { - return agentroute.AgentRouteConfig{}, err - } - var cfg agentroute.AgentRouteConfig - if err := json.Unmarshal(data, &cfg); err != nil { - return cfg, err - } - return cfg, nil -} - -func init() { - gatewayAgentRouteCreateCmd.Flags().StringVarP(&gatewayAgentRouteFile, "file", "f", "", "Agent route YAML or JSON") - gatewayAgentRouteUpdateCmd.Flags().StringVarP(&gatewayAgentRouteFile, "file", "f", "", "Agent route YAML or JSON") - gatewayAgentRouteCmd.AddCommand(gatewayAgentRouteListCmd, gatewayAgentRouteGetCmd, gatewayAgentRouteCreateCmd, gatewayAgentRouteUpdateCmd, gatewayAgentRouteDeleteCmd) - rootCmd.AddCommand(gatewayAgentRouteCmd) -} diff --git a/cmd/agwctl/cmd_gateway_builtin.go b/cmd/agwctl/cmd_gateway_builtin.go deleted file mode 100644 index fee6d6d7..00000000 --- a/cmd/agwctl/cmd_gateway_builtin.go +++ /dev/null @@ -1,36 +0,0 @@ -package main - -import ( - "context" - - "github.com/spf13/cobra" -) - -var gatewayBuiltinRuntimeCmd = &cobra.Command{Use: "builtin-runtime", Short: "Inspect the builtin ADK host runtime"} - -var gatewayBuiltinRuntimeGetCmd = &cobra.Command{ - Use: "get", Short: "Get builtin materialization and runtime diagnostics", - RunE: func(cmd *cobra.Command, args []string) error { - view, err := newGatewayClient().GetBuiltinRuntime(context.Background()) - if err != nil { - return err - } - return printJSON(view) - }, -} - -var gatewayBuiltinRuntimeInFlightCmd = &cobra.Command{ - Use: "inflight", Short: "List in-flight builtin turns", - RunE: func(cmd *cobra.Command, args []string) error { - items, err := newGatewayClient().ListBuiltinInFlight(context.Background()) - if err != nil { - return err - } - return printJSON(items) - }, -} - -func init() { - gatewayBuiltinRuntimeCmd.AddCommand(gatewayBuiltinRuntimeGetCmd, gatewayBuiltinRuntimeInFlightCmd) - rootCmd.AddCommand(gatewayBuiltinRuntimeCmd) -} diff --git a/cmd/agwctl/cmd_root.go b/cmd/agwctl/cmd_root.go deleted file mode 100644 index e740156f..00000000 --- a/cmd/agwctl/cmd_root.go +++ /dev/null @@ -1,22 +0,0 @@ -package main - -import ( - "github.com/spf13/cobra" -) - -var ( - globalCaddyAdmin string - globalGatewayAddr string -) - -var rootCmd = &cobra.Command{ - Use: "agwctl", - Short: "Manage Agent Gateway control-plane resources", -} - -func init() { - initOutputFlag() - rootCmd.PersistentFlags().StringVar(&globalGatewayAddr, "admin-addr", envOr("AGW_ADMIN_ADDR", "http://localhost:8019"), "agent-gateway admin API address") - rootCmd.PersistentFlags().StringVar(&gwAdminBasicAuth, "admin-basic-auth", envOr("AGW_ADMIN_BASIC_AUTH", ""), "gateway admin Basic Auth request credentials as username:password") - rootCmd.PersistentFlags().StringArrayVar(&gwAdminHeaders, "admin-header", nil, "extra admin API header as 'Name: value'; repeat to send multiple headers") -} diff --git a/cmd/agwctl/gateway_bundle_apply_test.go b/cmd/agwctl/gateway_bundle_apply_test.go deleted file mode 100644 index 5e7f9752..00000000 --- a/cmd/agwctl/gateway_bundle_apply_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/agent-guide/agent-gateway/pkg/adminclient" - "github.com/agent-guide/agent-gateway/pkg/gateway/agentroute" - "github.com/agent-guide/agent-gateway/pkg/gatewaybundle" -) - -func TestApplyAgentRoutesStripsExportedManagedTimestamps(t *testing.T) { - created := false - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/admin/agents/routes": - _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}}) - case r.Method == http.MethodPost && r.URL.Path == "/admin/agents/routes": - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatal(err) - } - if _, ok := body["created_at"]; ok { - t.Fatalf("create body retained created_at: %#v", body) - } - if _, ok := body["updated_at"]; ok { - t.Fatalf("create body retained updated_at: %#v", body) - } - created = true - w.WriteHeader(http.StatusCreated) - body["source"] = "store" - body["read_only"] = false - _ = json.NewEncoder(w).Encode(body) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - })) - defer srv.Close() - - now := time.Now().UTC() - bundle := &gatewaybundle.GatewayBundle{AgentRoutes: []agentroute.AgentRouteConfig{{ - AgentRouteBaseConfig: agentroute.AgentRouteBaseConfig{ - ID: "agent:reviewer:review", MatchPolicy: agentroute.RouteMatch{PathPrefix: "/review"}, - CreatedAt: now, UpdatedAt: now, - }, - AgentID: "reviewer", - }}} - client := adminclient.New(adminclient.Config{BaseURL: srv.URL}) - var applyErr error - if err := applyAgentRoutes(context.Background(), client, bundle, func(_, _, _ string, err error) { applyErr = err }); err != nil { - t.Fatal(err) - } - if applyErr != nil { - t.Fatal(applyErr) - } - if !created { - t.Fatal("AgentRoute create was not called") - } -} diff --git a/cmd/agwctl/main.go b/cmd/agwctl/main.go index a5a50d93..392a977e 100644 --- a/cmd/agwctl/main.go +++ b/cmd/agwctl/main.go @@ -1,13 +1,24 @@ package main import ( - "fmt" + _ "github.com/agent-guide/agent-gateway/pkg/dispatcher/llmapi/anthropic" + _ "github.com/agent-guide/agent-gateway/pkg/dispatcher/llmapi/cc" + _ "github.com/agent-guide/agent-gateway/pkg/dispatcher/llmapi/openai" + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/distribution/ctlcmd" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/anthropic" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/anthropicbase" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/claudecode" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/codex" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/deepseek" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/gemini" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/grok" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/ollama" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/openai" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/openrouter" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/qwen" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/zhipu" "os" ) -func main() { - if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} +func main() { os.Exit(ctlcmd.Run(ctlcmd.Options{Assembly: distribution.CommunityValidation()})) } diff --git a/cmd/agwd/main.go b/cmd/agwd/main.go index 6b185c88..bf7f5778 100644 --- a/cmd/agwd/main.go +++ b/cmd/agwd/main.go @@ -1,22 +1,10 @@ package main import ( - "context" - "errors" - "fmt" - "os" - "os/signal" - "syscall" - - "github.com/agent-guide/agent-gateway/standalone/server" - "github.com/joho/godotenv" - "github.com/spf13/cobra" - - // ACP agents register runtime factories through init. _ "github.com/agent-guide/agent-gateway/pkg/acp/agent/codex" _ "github.com/agent-guide/agent-gateway/pkg/acp/agent/opencode" - - // LLM providers register runtime factories through init. + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/distribution/daemoncmd" _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/anthropic" _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/anthropicbase" _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/claudecode" @@ -29,65 +17,7 @@ import ( _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/openrouter" _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/qwen" _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/zhipu" + "os" ) -func main() { - if err := loadRuntimeEnv(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - - var opts server.Options - rootCmd := &cobra.Command{ - Use: "agwd", - Short: "Standalone Agent Gateway daemon", - RunE: func(cmd *cobra.Command, args []string) error { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - return server.Run(ctx, opts) - }, - } - rootCmd.Flags().StringVar(&opts.Addr, "addr", "127.0.0.1:8080", "LLM gateway listen address") - rootCmd.Flags().StringVar(&opts.AdminAddr, "admin-addr", "localhost:8019", "Admin API listen address") - rootCmd.Flags().StringVar(&opts.AdminBasicAuthHash, "admin-basic-auth-hash", os.Getenv("AGW_ADMIN_BASIC_AUTH_HASH"), "admin Basic Auth configuration as username:bcrypt-hash") - rootCmd.Flags().StringVar(&opts.ConfigStorePath, "config-store", "./data/configstore.db", "SQLite config store file") - rootCmd.Flags().StringVar(&opts.StaticConfigPath, "static-config", "", "gateway bundle YAML file loaded as read-only static configuration") - rootCmd.Flags().StringArrayVar(&opts.ProviderTypes, "provider-type", nil, "provider type enabled for this process; repeat to allow multiple types") - rootCmd.Flags().StringVar(&opts.CredentialRefreshCommand, "credential-refresh-command", "", "external credential refresh executable (default: agw-auth)") - rootCmd.Flags().StringArrayVar(&opts.CredentialRefreshArgs, "credential-refresh-arg", nil, "static argument for the credential refresh executable; repeat as needed (default: refresh)") - rootCmd.Flags().IntVar(&opts.Metrics.RetentionDays, "metrics-retention-days", 30, "retention window in days for persisted usage events") - rootCmd.Flags().IntVar(&opts.Metrics.MaxAgentDepth, "max-agent-depth", 0, "maximum inbound X-Agent-Depth allowed before rejecting a request; 0 disables enforcement") - rootCmd.Flags().StringVar(&opts.Metrics.OTLP.Endpoint, "metrics-otlp-endpoint", "", "OTLP collector endpoint (host:port or URL) for exporting usage events as spans; empty disables export") - rootCmd.Flags().StringVar(&opts.Metrics.OTLP.Protocol, "metrics-otlp-protocol", "grpc", "OTLP transport protocol: grpc or http") - rootCmd.Flags().BoolVar(&opts.Metrics.OTLP.Insecure, "metrics-otlp-insecure", false, "disable TLS for the OTLP exporter") - rootCmd.Flags().StringToStringVar(&opts.Metrics.OTLP.Headers, "metrics-otlp-header", nil, "header sent with every OTLP export request as name=value; repeat for multiple headers") - rootCmd.Flags().StringVar(&opts.Metrics.OTLP.ServiceName, "metrics-otlp-service-name", "", "OTel resource service.name for exported spans (default agent-gateway)") - rootCmd.Flags().BoolVar(&opts.Metrics.OTLP.Components, "metrics-otlp-components", false, "additionally export one span per eino chat-model component call, nested under the interaction span") - - if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func loadRuntimeEnv() error { - for _, path := range []string{".env.local", ".env"} { - if err := loadOptionalEnvFile(path); err != nil { - return err - } - } - return nil -} - -func loadOptionalEnvFile(path string) error { - if _, err := os.Stat(path); err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return fmt.Errorf("stat %s: %w", path, err) - } - if err := godotenv.Load(path); err != nil { - return fmt.Errorf("load %s: %w", path, err) - } - return nil -} +func main() { os.Exit(daemoncmd.Run(daemoncmd.Options{Assembly: distribution.CommunityRuntime()})) } diff --git a/docs/README.md b/docs/README.md index faca0ce0..f151f48b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,6 +40,9 @@ Category index: Primary detailed documents: +- [guides/custom-distribution.md](guides/custom-distribution.md): assemble and verify independent distributions +- [releases/v0.7.0.md](releases/v0.7.0.md): extension foundation candidate, designated symbols and release sequence + - [architecture/architecture-overview.md](architecture/architecture-overview.md): current repository architecture overview - [architecture/configstore-architecture.md](architecture/configstore-architecture.md): ConfigStore architecture and persistence contract - [architecture/mcp-architecture.md](architecture/mcp-architecture.md): current MCP gateway architecture @@ -53,8 +56,7 @@ Primary detailed documents: - [reference/acp-technical-spec.md](reference/acp-technical-spec.md): ACP service, route, runtime, and event specification - [releases/v0.6.1.md](releases/v0.6.1.md): v0.6.1 stabilization, local A2A fixture, release gates, and SQLite upgrade coverage - [releases/v0.6.0.md](releases/v0.6.0.md): v0.6.0 user-visible changes, compatibility notes, and limits -- [plans/v0.6.1-stabilization.md](plans/v0.6.1-stabilization.md): v0.6.1 release verification, A2A example, smoke, and SQLite upgrade plan -- [plans/v0.7.0-community-guardrails.md](plans/v0.7.0-community-guardrails.md): v0.7.0 Community Guardrails delivery plan +- [plans/v0.7.0-enterprise-extension-foundation.md](plans/v0.7.0-enterprise-extension-foundation.md): ConfigStore-only extension foundation and v0.7.0 release closure - [design/agents-control-plane.md](design/agents-control-plane.md): shared Agent identity, resources, runtime contracts, and the external Business Workflow boundary - [design/request-pipeline.md](design/request-pipeline.md): synchronous Gateway Request Pipelines and upper-layer durable orchestration through Temporal or another external engine - [design/builtin-agent-runtime.md](design/builtin-agent-runtime.md): builtin ADK host decisions for schema, lifecycle, and permissions diff --git a/docs/architecture/architecture-overview.md b/docs/architecture/architecture-overview.md index 2fec4af4..96ddff30 100644 --- a/docs/architecture/architecture-overview.md +++ b/docs/architecture/architecture-overview.md @@ -85,6 +85,20 @@ authoritative runtime design. The `caddy/gateway.App` type is the root Caddy app module with module ID `agent_gateway`. The standalone daemon in `standalone/server/` assembles the same core runtime services without depending on a Caddy app lifecycle. +The thin `cmd/agw`, `cmd/agwd`, and `cmd/agwctl` mains retain edition-specific +imports and delegate to public entries under `pkg/distribution/{caddycmd,daemoncmd,ctlcmd}`. +Both server mains supply `distribution.CommunityRuntime()` metadata, while CLI +supplies its validation projection. These snapshots describe explicit linking; +validated registries and runtime manifest queries are still planned. +Daemon and CLI constructors create fresh Cobra trees without loading dotenv. +Their process runners load `.env.local` before `.env` with shell values taking +precedence. Daemon signal handling is installed only when serving. Caddy delegates +its command construction, signal handling and process exit to upstream Caddy. +CLI command implementations and instance state live in `pkg/distribution/ctlcmd`. +Both server assemblies accept public `distribution.MetricsConfig` values and +adapt them to internal usage configuration during bootstrap. + + Its responsibilities are: - Provision the configured config store @@ -557,3 +571,102 @@ An ACP Agent owns its execution config under `Agent.runtime.acp`, and `agent_id` directly owns the ACP process pool, sessions, permissions, runtime diagnostics, and attribution. Native `/admin/acp/runtime` diagnostics remain Agent-keyed. + +## Linked module configuration + +Distribution entries freeze `Assembly.Modules` and separately linked +`Assembly.Factories` into an immutable extension registry. All three binaries +expose `manifest`; the CLI projects validation roles, and both servers expose +`GET /admin/manifest` beneath the Admin mount. Caddy receives process-owned +assembly from its executable entry; standalone passes it explicitly. + +`pkg/extension.Manager` loads envelopes only from the schema-bound `extensions` +store on the existing backend. Internal extensions are independent of Caddyfile; +Caddyfile configures the Caddy host and bootstrap connections. Admin API and +Bundle apply manage extension configuration; Caddy JSON and standalone static +Bundles reject extension configuration. Mutations validate and prepare one +complete dependency generation, persist the target envelope, and publish in +memory. Leases retain old resources until drain or the five-second retirement +budget. Config, manifests and Admin responses preserve secret-reference tokens. +Only preparation receives resolved values. `pkg/admin` owns envelope CRUD and +assembles immutable `Factory.AdminRoutes` under each literal module namespace. +Prepared resources implement `AdminContributor` and receive only cloned config, +logger, attribution access and owner-scoped event publication bound to the +original trusted request identity. The handler holds +a generation lease, linking request cancellation to resource retirement. All +Admin routes share full-body 4 MiB checks (413), reject compression (415), and +use generic JSON panic responses. Outer deployment authentication, CORS and +logging apply equally to contributed routes in Caddy and standalone mode. +Module health uses the same extension manager; shutdown drains/cancels handlers +before resource closure in reverse dependency order. + + +## Trusted attribution and public events + +Both runtime assemblies inject exact `AttributeKeys`, an optional trusted +`AttributionProducer`, and linked-module `EventSinks` through `distribution.Assembly`. +They are code-owned callbacks and values, excluded from manifests/configuration. +A fresh sink factory runs for each server lifecycle. Unknown or duplicate sink +owners fail assembly; failed initialization disposes previously created sinks. + +`pkg/attribution` copies bounded subject, tenant and correlation values into and +out of request context. A validated VirtualKey establishes `virtualkey:` as +the default subject and supplies no tenant. The dispatcher stamps selected +route/Agent and normalized trace/span/parent identities; runtime dimension changes +update run and child correlation while retaining trusted identity. Raw client +identity/tenant headers have no effect. Producer errors, panics and invalid optional +values fall back to core correlation rather than exposing their diagnostics. + +`pkg/event` contains only v1 `limiter.admission` and `extension.mutation` DTOs. +Extension PUT/DELETE success follows persistence/publication; failure contains +an enumerated reason. Configured limiter admission emits allow/deny/degraded, with bounded unavailable +reasons and actual backend owner IDs. Policy/backend resets emit `state_reset`. +Prompts, arbitrary diagnostics, configs, credentials and secret-reference tokens +have no DTO field. Validation includes the 16 KiB encoded JSON bound. + +The existing observability pipeline publishes those DTOs through one bounded +transport worker per external sink; internal SQLite/Prometheus/OTLP usage stays +on the existing accounting path. Slow/failing external sinks are isolated. Queue +full drops the newest event; accepted events are FIFO, without retries or replay. +Flush inserts a barrier; Close rejects new events, drains and flushes/closes once +with a shared five-second maximum budget. That deadline cancels active callbacks; +a stuck callback is unhealthy and cannot hold process teardown past the deadline. +No goroutine is created per production write. Metrics include rejection counts and +sink health in registration order, along with combined drop/failure counters. +Standalone and Caddy report sink shutdown failures through their execution lifecycle. + + +### VirtualKey admission backends + +The dispatcher performs one context-bound admission after VirtualKey validation +and interaction setup, before LLM/MCP/Agent protocol dispatch. The domain-owned +`pkg/gateway/virtualkey/limiter` SPI receives value policies and opaque per-key, +per-dimension generations, never bearer values. Memory is the Community default. +The schema-1 `online.agentguide.community.limiter` envelope selects a linked, +configured `LimiterProvider` dependency; preparation resolves its backend type +and validates health before activation. Both Caddy and standalone use this wiring. + +Admission uses a current generation lease and prepared connection handle. Key +metadata writes, unchanged dimensions and unrelated extension refreshes preserve +quota identity; changed policies, key deletion/reset and backend authority/config +changes retire it. StatefulResource reconciles only unpublished candidate identity, +with monotonic Snapshot.Sequence fencing. New authorities start full buckets; +quota storage survives connection-handle replacement. No request reads storage, +discovers a module, resolves its factory or retries uncertain consumption. + +The default 100 ms budget accepts 1–1000 ms and is capped by the request deadline. +Exhaustion is 429 with rounded-up Retry-After; failures/timeouts are bounded +503 limiter_unavailable. Explicit open mode allows degraded traffic; caller and +retirement cancellation, retired-policy errors or shutdown cannot bypass a hard limit. Unlimited +classes skip backend calls. Admission counters use only dimension, outcome and +bounded backend type; external event sinks receive sanitized admission/reset DTOs +through the existing pipeline. Gateway shutdown stops/cancels admissions and +shares its five-second deadline with extension resource retirement. + + +The external assembly workflow is documented in the +[custom distribution guide](../guides/custom-distribution.md). The +[v0.7 release notes](../releases/v0.7.0.md) enumerate the designated compatibility +symbols. Linked configuration and Admin lifecycle alone do not add data-plane +interception; additional domain seams and their ownership are +[Proposed](../design/enterprise-extension-contract.md#12-domain-extension-coverage-and-ownership). diff --git a/docs/architecture/configstore-architecture.md b/docs/architecture/configstore-architecture.md index 6e275c48..e4e7ad33 100644 --- a/docs/architecture/configstore-architecture.md +++ b/docs/architecture/configstore-architecture.md @@ -243,6 +243,7 @@ const ( StoreManagedModels = "managed_models" StoreMCPServices = "mcp_services" StoreAgents = "agents" + StoreExtensions = "extensions" ) ``` @@ -365,6 +366,22 @@ store.Delete(ctx, providerID, upstreamModel) - timestamped: yes - decoded type: `*agent.Agent` +### 6.8 Extensions + +- schema variable: `schema.ExtensionSchema` +- store name and table: `extensions` +- primary key: `module_id` +- data column: `config` +- timestamped: no +- decoded type: `*extension.Envelope` + +Only dynamic envelopes are persisted. Static envelopes are startup-owned and +merged by `extension.Manager`; duplicates fail startup. The codec bounds JSON +shape and size; the linked registry performs schema/dependency/secret checks. +Tokens remain in storage, and runtime preparation receives resolved values +separately. A module mutation prepares before writing and publishes only after +the store succeeds. No separate backend or configuration authority is introduced. + ## 7. Backend Opening and Registration ### 7.1 Runtime Opening @@ -596,6 +613,7 @@ During provisioning: - virtual keys: `schema.StoreVirtualKeys` - providers: `schema.StoreProviders` - managed models: `schema.StoreManagedModels` +- extension envelopes: `schema.StoreExtensions` via the internal persistence adapter The gateway combines these dynamic stores with static Caddyfile-provided providers, routes, and models during bootstrap. diff --git a/docs/design/builtin-agent-runtime.md b/docs/design/builtin-agent-runtime.md index 95bf6129..34ff9152 100644 --- a/docs/design/builtin-agent-runtime.md +++ b/docs/design/builtin-agent-runtime.md @@ -211,7 +211,7 @@ Definitions cover parameterizable structure. An agent that needs custom Go logic (bespoke tools, custom graph nodes) uses the repository's established extension pattern instead: implement a builtin-agent factory SPI, register it (mirroring `provider.RegisterProviderFactory`), and blank-import the package in -`cmd/agw/main.go`, `cmd/agwd/main.go`, and `cmd/agwctl/cmd_gateway.go` (agwctl +`cmd/agw/main.go`, `cmd/agwd/main.go`, and `cmd/agwctl/main.go` (agwctl needs it so bundle `validate`/`apply` can check the factory name). The definition selects it with `topology.kind = "custom"` plus a `factory` name; validation rejects a factory name absent from the linked registry — the same diff --git a/docs/design/enterprise-extension-contract.md b/docs/design/enterprise-extension-contract.md index e35ebe7a..a907ebc2 100644 --- a/docs/design/enterprise-extension-contract.md +++ b/docs/design/enterprise-extension-contract.md @@ -1,6 +1,11 @@ # Enterprise Extension Contract -Capability status: **Implemented**. +Capability status: **Implemented** for the repository ownership and protection +policy. Reusable entries and their external-consumer suite are **Implemented** +along with linked registries, manifests, configuration generations and public +attribution/events. Contributed Admin routes and the limiter SPI are **Implemented** +as candidate contracts. +Protection requires a released tag containing the designated APIs and tests. Individual extension surfaces are protected only when explicitly designated by released code and contract tests. @@ -20,6 +25,13 @@ only when its package documentation explicitly designates it as an Enterprise SPI or reusable distribution entry point and a released Community tag contains the corresponding contract tests. +The active v0.7 plan applies this contract to a common extension foundation: +reusable distribution entry points, a linked capability manifest, bounded +registered configuration, Admin route composition, public attribution/event +contracts, and the first domain-owned proof SPI (`LimiterBackend`). Guardrails +and other domain SPIs remain separate vertical slices and are not prerequisites +for that foundation. + ## 2. Repository and dependency direction The integration model is one-way Open Core composition: @@ -51,6 +63,26 @@ The following rules are mandatory: `replace` directives, floating branches, and a checked-in `go.work` are not release inputs. +### 2.1 Foundation versus domain capabilities + +The shared foundation solves composition, not product policy. It has four +layers: + +1. reusable entry packages and a compile-time linked capability manifest; +2. bounded module identity/configuration and Admin composition contracts; +3. trusted attribution and deliberately public event DTO boundaries; +4. reusable external-consumer contract suites and compatibility evidence. + +Domain behavior remains in narrow SPIs introduced with real consumers. A +limiter backend, Guardrail check, identity resolver, policy evaluator, secret +resolver, retriever, and ConfigStore creator do not implement one generic +interface and do not gain access to the complete gateway. Shipping one of them +does not make the others Enterprise-ready. + +The common foundation must be useful to Community custom distributions. It may +not contain private module IDs, commercial defaults, entitlement decisions, or +configuration that Community cannot validate and execute. + ## 3. Protected public surfaces Community does not preserve backward compatibility by default. The only @@ -93,6 +125,13 @@ ConfigStore creator, agent runtime, identity or policy evaluator, event sink, secret resolver, and Admin API route contributor. This list is not a commitment to implement all seams or a statement of Enterprise product scope. +`LimiterBackend` is the v0.7 proof slice because the current in-process +VirtualKey limiter provides an independently useful reference implementation +and its request-bound failure semantics exercise the distribution, manifest, +configuration, Admin, attribution, and event boundaries. This choice does not +make limiting the universal extension abstraction and does not make a +production distributed backend part of Community. + Registries exposed to external repositories must follow consistent failure semantics: @@ -127,6 +166,12 @@ Community and Enterprise assembly should be checked using code-generated or code-owned manifests. Documentation must not copy the complete blank-import list or become a second source of truth. +The manifest reports what is linked, not what is licensed for a customer or +healthy at a particular instant. Serialized entries contain only bounded stable +module identity, version, schema version, and capability metadata. Runtime +factory pointers, secrets, tenant data, and entitlement state never appear in +the manifest. + A package does not qualify as a reusable distribution entry point merely because its import path is public. Before designation, its exported configuration, constructors, callbacks, and return types must be usable from a @@ -152,6 +197,21 @@ Community first defines a deliberately stable public DTO and an adapter between that DTO and the internal event model. Public event evolution and redaction rules are reviewed independently from the internal observability model. +The v0.7 shared attribution contract defines optional fields for authenticated +subject, tenant, route, Agent, run, VirtualKey, trace, span, and parent +correlation, plus bounded namespaced attributes with an explicit allowlist and +size limits. Identity fields are populated only when established by an existing +trusted boundary or a future designated resolver, and correlation fields remain +absent when the corresponding runtime does not provide them. A caller-provided +tenant header is not trusted by itself. The foundation transports attribution; +it does not implement enterprise authentication, tenant storage, or +authorization. + +The v0.7 public event envelope covers only event families needed to prove the +foundation. Guardrail decisions, financial metering, compliance audit, and +policy decisions receive versioned public DTOs with their own vertical slices; +the foundation does not predeclare empty product interfaces for them. + ## 7. Runtime invariants Enterprise extensions must preserve the Community runtime invariants: @@ -210,3 +270,573 @@ licensing, customers, or delivery dates. - [Gateway Bundle YAML](gateway-bundle-yaml.md) - [Request Pipeline](request-pipeline.md) - [Observability](observability.md) + +## 11. v0.7 foundation contract decisions + +These decisions fix the implementation target. Entry construction, public +metrics values, linked registries/manifests, configuration and attribution/events +and Admin contribution/limiter contracts are implemented as candidates. +Consumer evidence is a candidate gate, not a released-tag +or paired-private-repository Enterprise-ready declaration. Changes to the target +must update this design and the active plan together. + +### 11.1 Package ownership and public boundary + +| Public package | Owner and intended protected surface | +| --- | --- | +| `pkg/distribution/caddycmd` | Caddy command construction/run adapter and assembly options | +| `pkg/distribution/daemoncmd` | Standalone command construction/run adapter and assembly options | +| `pkg/distribution/ctlcmd` | Management command construction/run adapter and local validation options | +| `pkg/distribution` | Edition metadata, public metrics configuration, and explicit assembly options | +| `pkg/extension` | Linked module descriptors, registry, manifest, configuration envelope and prepared snapshot lifecycle | +| `pkg/attribution` | Immutable request attribution values and context accessors | +| `pkg/event` | Versioned public envelopes, sink lifecycle and bounded delivery options | +| `pkg/extension` (Admin types) | Route declarations and narrow contribution dependencies | +| `pkg/gateway/virtualkey/limiter` | Backend, factory, policy snapshot, admission and lifecycle contracts | +| `pkg/extension/contracttest` | Registry/configuration, attribution, event and Admin body suites | +| `pkg/gateway/virtualkey/limiter/contracttest` | Backend lifecycle and admission suite | + +Only symbols named in landed package documentation become protected. The table +does not protect all exports in those packages. Existing `standalone/server`, +`caddy/gateway`, `pkg/admin`, and `pkg/gateway` remain implementation surfaces +unless separately designated. + +The new entry options use public value types; adapters translate public metrics +options into `usage.Config` internally. External assembly does not construct an +internal observer, query service or pipeline. Public signatures and their +transitive required types must expose no Community `internal/` package. Internal +implementation imports are allowed. + +Command construction creates fresh command/flag state per invocation. Loading +`.env.local` then `.env`, signal ownership and `os.Exit` occur in execution, +never on importing a reusable package. Preserve current defaults, environment +precedence, flags, stdout/stderr and exit codes. Final main packages retain +edition-specific imports. Caddy continues to delegate standard command behavior +to Caddy rather than maintaining a second command implementation. + +The landed reusable entry surface is `distribution.Edition`, `Assembly`, +`Assembly.Clone`, `MetricsConfig`, `OTLPConfig`; `caddycmd.Command`, `New`, +`Command.Assembly`, `Command.Run`; `daemoncmd.Options`, `ServerOptions`, +`NewCommand`, `Run`; and `ctlcmd.Options`, `NewCommand`, `Run`. +`extension.Descriptor` supplies the metadata value carried by Assembly. +Caddy `New` owns a defensive metadata snapshot; `Run` delegates the fresh command +tree and process lifecycle to upstream `Main`, including `os.Exit`. Daemon/CLI +`Run` return exit codes for main to use. Their constructors accept public values, +read current environment defaults, and leave dotenv loading to execution. + +Both Community server mains pass `CommunityRuntime()` metadata, CLI passes +`CommunityValidation()`, and module imports remain explicit in mains. The entries retain +metadata snapshots in the Caddy adapter and daemon/CLI command annotations. +Annotations and Community metadata helpers are not independently protected APIs. +`Assembly.Factories` holds executable registrations separately from descriptors; +`Assembly.Registry`, `Assembly.Manifest` and `caddycmd.Command.Manifest` are +additional designated entry methods. `Assembly.AttributionPolicy` validates the +exact attribute allowlist; `AttributeKeys`, `AttributionProducer` and `EventSinks` +are explicitly injected assembly values, excluded from serialized manifests. +Each sink registration names a linked module and constructs a fresh sink for +each server lifecycle, including Caddy reloads. Duplicate/unlinked/nil sink +registrations and invalid transport options fail assembly. Assembly clone owns +the key and registration slices; factories remain module-owned callbacks. Registries own factory metadata copies, +reject undeclared factory IDs, and support schema-only CLI registrations. +`manifest` in each binary serializes canonical metadata; `agwctl manifest --remote` +and protected `GET /admin/manifest` report frozen server metadata. +The separate `tests/extensionconsumer` module builds all three entries and walks +their required public type closure. `make test-extension-consumer` runs this +candidate gate with a temporary replacement and `GOWORK=off`; no local +replacement or workspace file is committed. Release protection still requires +the tag-level gates in section 9. + +### 11.2 Identity, manifest and registration + +Capability status: **Implemented**, with candidate consumer contracts. The exact +extension symbol list is in `pkg/extension/doc.go`; reusable suite functions are +`contracttest.Registry` and `contracttest.Configuration`. Other package exports +are not implicitly protected. + +Module and capability IDs use lower-case reverse-domain names, at most 128 ASCII +bytes, with nonempty dot-separated segments containing letters, digits or +hyphens. They are distinct from existing Caddy module IDs and provider type names; +no runtime identifier is renamed to satisfy this metadata format. Community IDs +use `online.agentguide.community.*`; the independent fixture uses +`example.fixture.*`. Private IDs belong only in the private repository. + +Module versions are semantic-version strings. Schema versions are positive +integers; unsupported versions fail without implicit migration. Manifests sort +modules by ID and capabilities/dependencies lexically, reject duplicates, and +serialize roles from the fixed set `runtime`, `validation`, `admin`, `spi`. +Bound each descriptor to 64 capabilities, 64 dependencies and 64 KiB serialized +JSON; bound an assembled manifest to 256 modules. Exceeding these limits fails +assembly explicitly. +Runtime factories are held separately from serialized descriptors. Both server +entries receive the same runtime descriptors; CLI entries receive the declared +validation projection, not a claim to execute server modules. + +Registry assembly returns typed errors with stable codes `invalid_registration`, +`duplicate_module`, `unknown_module`, `missing_dependency`, `dependency_cycle`, +and `schema_mismatch`. Dependency resolution is deterministic and topological; +simultaneously ready modules sort by ID, including newly unblocked modules. +Freeze the registry before serving. Factories are never invoked +by a manifest query or request-time directory lookup. + +### 11.3 Configuration ownership, mutation and secrets + +The configuration principle in section 11.3.1 is implemented. Internal extension +configuration is independent of Caddyfile and owned solely by ConfigStore. + +Capability status: **Implemented**, with candidate consumer contracts. Registries +validate locally without resolving secrets, and Manager prepares resources under +a separate generation lifetime. Prepare receives only declared dependencies. +The current implementation re-prepares configured modules for each mutation; +resource identity across unrelated mutations is not promised. Module owners +that require preserved domain state must own/ref-count that state independently +of preparation handles. The limiter contract below retains its explicit +unchanged-policy semantics. + +The extension CRUD boundary has its own full-body and compression checks. The +shared policy covering every Community and contributed Admin route, public +envelope mutation events and contributed subroutes are implemented as candidate +contracts. Section 11.5 defines Admin ownership and body policy; released-tag +protection still requires the published-tag and paired-repository gates. +Envelope CRUD is Community-owned. + +Add `extensions` to GatewayBundle as a list of envelopes containing `module_id`, +`schema_version` and `config` (`json.RawMessage`). One envelope per module is +allowed. The common decoder rejects duplicate JSON object keys before secret classification +or environment expansion. Module-owned strict decoding validates its config; this envelope cannot +override providers, routes or protocol fields. Limit the list to 64 entries, +each config to 256 KiB of encoded JSON and nesting to 32 levels. Unknown configured +modules fail local validation when unavailable locally, and always fail on the +server when not linked. CLI validation cannot authorize server execution. + +Persist dynamic envelopes in a schema-bound `extensions` store in the existing +ConfigStore backend. Manager startup loads only persisted envelopes. Caddyfile, +Caddy app JSON and standalone static Bundles cannot supply extension envelopes; +invalid static inputs fail rather than being ignored or imported into the store. +Envelopes have no static/dynamic ownership field. + +Community owns `GET /admin/extensions` and +`GET|PUT|DELETE /admin/extensions/{module_id}`. List/get return reference-preserving +envelopes. PUT performs server validation, dependency checking and bounded +resource preparation before persistence. Preparation cannot mutate live state. +Persist the candidate, then atomically publish its prepared snapshot; publication +is infallible. Persistence failure disposes prepared resources and leaves the +old snapshot active. Serialize dependent mutations, and publish one generation +covering all affected dependencies. Requests retain their admitted generation; +retire resources after its in-flight users drain, with bounded shutdown. +Preparation and health calls receive contexts with a five-second maximum budget, +capped by the caller's deadline. Snapshot publication performs no I/O. Factories +must support concurrent snapshot reads and honor lifecycle cancellation. Module +callback errors and panics are converted to bounded codes. A callback that ignores +cancellation is nonconforming; the process can report failure at its deadline but +cannot force arbitrary module code to terminate. +DELETE rejects a module still required by another configured envelope with 409. + +Bundle apply first applies extensions in dependency order, then follows the +existing resource order. It stops before dependent resources if extension apply +fails. The whole Bundle remains a sequence of resource mutations, with explicit +partial-result reporting and no cross-resource rollback promise. Export includes +the persisted extension envelopes and their secret references. Existing +Bundle ownership semantics remain in force; no second configuration authority +or event store is introduced. + +Factories declare secret fields using exact JSON Pointers. At those locations, +only an object of the form `{"$secret_ref":"env:NAME"}` is accepted for a secret; +literal secrets and undeclared `$secret_ref` objects are rejected. `NAME` follows +the existing environment-variable name grammar. v0.7 supports environment +references only; it adds no SecretResolver SPI. Walk and classify references +before generic `${NAME}` expansion. Local validation checks syntax but does not +resolve references; server preparation resolves from the server environment. +Missing references fail preparation with a bounded code and field path, never +the value. Persisted/exported config retains the reference object, including +when the environment variable is set. Resolved values exist only in owned +runtime resources; they never enter DTOs, events, diagnostics or manifests. +Environment rotation requires explicit reapply and creates a generation. + +#### 11.3.1 Principle: host-independent extension configuration + +Capability status: **Implemented**, with candidate contracts. Published-tag +protection remains pending the release and paired-consumer gates. + +Caddyfile owns Caddy host configuration: listeners, TLS, HTTP middleware, +Caddy module wiring, Admin mounting and gateway bootstrap connections. The +project's internal extension registry, configuration and lifecycle are independent +of Caddyfile and use the same runtime in Caddy and standalone distributions. +Compile-linked Caddy modules and internal extension envelopes are separate +contracts; a Caddy module may adapt the host to the shared runtime. + +Internal extension configuration has one serving source of truth: the existing +ConfigStore. Admin API creates, updates and deletes envelopes. Gateway Bundle +is a declarative apply/export format: CLI apply sends extension mutations through +the same Admin API, and export reads persisted reference-preserving envelopes. +Local Bundle validation does not activate resources. A Bundle file is not a +second, startup-owned extension configuration source. + +At startup, the extension Manager reads persisted envelopes and prepares the +serving generation. Subsequent API mutations retain dependency validation, +prepare-before-persist, atomic generation publication and bounded retirement. +There are no per-request ConfigStore reads. Secret references remain persisted +as references; environment rotation requires explicit reapply. Caddyfile reload +does not define or overwrite extension envelopes, and Caddy adaptation does +not import extension configuration into the store. + +Under this principle, neither Caddyfile/Caddy JSON nor standalone static Bundles +or public distribution bootstrap options supply static extension envelopes. +Configuration attempting those paths must fail explicitly rather than be ignored +or automatically persisted. Compiled registrations, manifests, schema validation, +Admin contributions and domain SPIs continue to exist. The ConfigStore backend +and its connection remain bootstrap concerns; the principle does not require +SQLite specifically or introduce a new store. + +The decision is limited to internal `extensions` configuration. It does not +remove Caddy modules or change current provider, route or other resource +configuration ownership. Static extension inputs and their ownership handling have been removed. +The active foundation plan records candidate evidence and remaining release work. + +### 11.4 Attribution and public events + +Capability status: **Implemented**, with candidate external-consumer contracts. +Exact candidate symbol lists live in `pkg/attribution` and `pkg/event` package +comments. Public suites are `contracttest.Attribution`, `contracttest.EventSink` +and `contracttest.CheckSinkCancellation`. Released-tag and paired-repository +protection still require section 9's gates. Admin contribution hooks and alternate +limiter backends are owned by their later contracts. + + +The existing validated VirtualKey boundary establishes subject as +`virtualkey:` and sets VirtualKey ID. It supplies no tenant. An explicitly +injected trusted attribution producer may provide subject/tenant; it is not +selected from client headers and receives no general gateway service locator. +Mount authentication alone does not synthesize a subject unless a trusted +adapter supplies it. Route/Agent/run fields come from the selected runtime; +trace/span/parent come from the existing validated tracing lifecycle. Missing +fields remain absent. A tenant header has no effect. + +Identity/correlation fields are bounded to 256 UTF-8 bytes each (trace IDs retain +their existing format). Attributes are string values with exact keys allowlisted +by assembly; keys must begin with a linked module's ID plus `/`. Limit to 16 +attributes, 128 bytes per key, 256 bytes per value and 4 KiB total. Copy values +on context insertion and retrieval. Invalid producer values return a typed +error; optional attribution must not replace valid core correlation or leak an +unbounded value into events. + +The v1 event envelope contains schema version, event ID, family, UTC timestamp, +attribution, bounded outcome/reason codes and a typed family DTO. Protect only +`limiter.admission` (dimension, allow/deny/degraded outcome, retry-after and backend +ID) and `extension.mutation` (module ID, operation and success/failure outcome) +in this slice. Successful contribution mutations use the latter hook after +commit; failed mutations emit a bounded failure code. The mutation DTO permits a +boolean `state_reset` for a committed limiter policy/backend reset. Neither DTO +admits arbitrary +maps, error strings, prompts, tool arguments, bearer keys, secret references or +resolved values. IDs remain event fields, never metric labels. Encoded envelopes +are at most 16 KiB; reject invalid events before enqueue and count rejection. + +The existing pipeline remains the single event authority. Domain adapters +construct allowlisted DTOs and its public publishing boundary validates them +before per-sink enqueue; it never forwards internal usage objects. No new +usage-accounting store or replay path is created. Each external sink has +a bounded queue (default 256 events, configurable 1–4096), one worker and FIFO +delivery for accepted events. Ingress never waits on a sink. Queue overflow drops +the newest event for that sink and increments its drop counter. Delivery is +best-effort, without retry or durable replay; a sink error increments its failure +counter and does not fail admission or another sink. + +Sink `Write`, `Flush` and `Close` receive contexts. Default write budget is one +second; flush/close share a shutdown budget of five seconds, bounded by the +caller's deadline. Sinks must honor cancellation; no unbounded goroutine is +created per write to hide a non-cooperative sink. Flush waits for events accepted +before its barrier. Shutdown rejects new events, drains accepted events within +budget, counts undelivered queued events, and closes exactly once. Concurrent +flush/close and repeated close are defined and tested. Process teardown returns +at its deadline even if an invalid sink remains stuck; that violation is reported +as unhealthy and must fail its contract suite. No parallel usage-accounting +pipeline is created. Only a delivery worker, plus fixed shutdown coordination, +is created per sink. Flush control barriers have separately bounded capacity +and cannot consume event capacity. A shorter concurrent Close caller does not +shorten the first caller's shared drain budget. Shutdown cancellation reaches +an already-running Write or Flush. A callback failure or panic becomes a bounded +`sink_failure` or `sink_panic` code, with no callback diagnostic content. + +The existing metrics summary exposes combined drop/failure counters plus +`pipeline.rejected_events` and `pipeline.public_sinks` health/counters in assembly +registration order. Successful extension PUT/DELETE events follow persistence +and snapshot publication; failed mutations expose only allowlisted reason codes. +Committed mutation events are enqueued before releasing the mutation gate and +notifying shutdown waiters, preserving commit order through pipeline admission. +Publication holds no manager mutex and performs no sink I/O. +Current Community in-process limiting emits allow/deny events for configured +policies. Unlimited admission emits no limiter event. Trusted subject/tenant and attributes +survive nested runtime correlation updates; selected route/Agent/run and validated +trace identities replace producer-supplied core fields. Invalid optional producer +values or panics are ignored in favor of bounded runtime correlation and the +validated VirtualKey subject fallback. The boundary omits existing identifiers +that exceed public bounds. Mount auth alone still adds no subject or tenant. + +The public `event.Delivery` implements sink transport and its contract fixtures; +server adapters construct it only through their existing pipeline. It is not an +independent event producer or an alternate usage-accounting authority. Legacy +internal usage sinks retain their serial processing semantics; the pipeline closes +those sinks once and bounds the wait without introducing a goroutine per write. +Standalone reports a sink shutdown failure as an execution error; Caddy returns it +from app Stop. + +### 11.5 Admin ownership and body policy + +Capability status: **Implemented**, with candidate contracts. Released-tag +protection still requires the published-tag and paired-repository gates. +`Factory.AdminRoutes` declares immutable `AdminRoute` method/path pairs; the +registration descriptor supplies their owner and must include the `admin` role. +`Registry.AdminRoutes` and `Manager.AdminRoutes` project `OwnedAdminRoute` values. +Prepared resources implement `AdminContributor.ServeAdminHTTP`; missing handlers +fail preparation before publication. `AdminDependencies` carries a cloned envelope, +logger, attribution accessor and owner-scoped event publisher. `WriteAdminError` +encodes stable, bounded codes. Exact symbols are designated in `pkg/extension/doc.go`. + +Supported patterns consist of literal segments and single-segment `{name}` +wildcards; host patterns, subtree patterns, multi-segment wildcards, trailing +slashes and module roots are rejected. Supported methods are GET, HEAD, POST, +PUT, PATCH and DELETE. GET also owns HEAD. OPTIONS belongs to shared CORS +preflight. Even containment overlaps are conflicts when methods intersect. +The literal owner namespace separates contributions from every Community family +and the AgentRoute mux; no module wildcard can cross that boundary. The whole +immutable declaration set is checked at registry construction before handlers +are registered, without using a ServeMux panic as validation. + +Admin dispatch retains the active generation lease for the entire call. The +handler context combines request cancellation with the generation lifetime; +retirement first drains leases, then cancels at its five-second deadline and +closes resources in reverse dependency order. Resource preparation and health +hooks reuse F2; there is no parallel contributor lifecycle. Unconfigured owners +respond with `extension_unavailable`/503. Trusted attribution is installed by the +same explicit producer used on data-plane ingress, without treating headers or +mount authentication alone as identity. The publisher accepts only the owning +module's typed `extension.mutation` events, stamps trusted request attribution, +and delegates validation/delivery to the existing pipeline. `Manager.AdminPublisher` +binds the validated owner during HTTP assembly and returns a request binder. +The HTTP adapter invokes that binder with the trusted request context without +registry/config reads. Each resulting publisher captures that request's identity; +module-supplied publication contexts affect cancellation/delivery only, never +actor identity or correlation. Publication snapshots the injected pipeline under +only the manager mutex and never takes the Gateway lock, so a draining callback +cannot deadlock Gateway shutdown. + +Contributions own only `/admin/extensions//...`; Community envelope +CRUD owns the module root. Reserve all other Community prefixes, including +`/admin/memory`. Route declarations specify method, pattern and owner. Reject +duplicate, overlapping or ambiguous patterns at assembly with typed +`route_conflict`, including conflicts across the existing AgentRoute mux boundary. +Check the entire declaration set before registering handlers; do not expose a +ServeMux panic as the public conflict API. + +Dependencies consist of immutable module config, logger, attribution access, +public event publisher and module-owned health/lifecycle hooks. They do not expose +AgentGateway, raw ConfigStore, credentials or unrestricted route replacement. +Contributed routes run through the existing Admin mount authentication, CORS, +panic recovery and logging. Initialization failure prevents startup; readiness +reports module health. Stop admitting requests before draining handlers and +closing module resources in reverse dependency order. + +All Admin request bodies are limited to 4 MiB (4,194,304 bytes), measured as +received bytes; compressed request bodies are unsupported and return 415. The +shared boundary reads at most limit+1 before invoking a mutation handler, including +unknown-length/chunked bodies, so an oversized trailing body cannot commit a +mutation first. Oversize returns 413 with exactly +`{"error":"request body exceeds 4194304 bytes"}` and no payload echo. Error JSON +is bounded to 1 KiB with stable codes in new extension errors; existing supported +success payloads and existing error shapes remain unless explicitly changed. +Panic recovery emits a generic bounded JSON 500. The same policy covers existing +Community and contributed routes. Bundle apply is per-object HTTP: the 4 MiB limit +applies to each serialized resource, not to the total local YAML file. + +### 11.6 Limiter backend semantics + +**Implemented candidate.** The domain owns +`pkg/gateway/virtualkey/limiter`; `doc.go` lists the exact candidate protected +symbols: `Dimension` and its constants, `Policy`, `Clock`, `LimiterRequest`, +`LimiterDecision`, `LimiterBackend`, `LimiterProvider`, `Config`, `ModuleID`, +`MemoryBackendType`, `Factory`, `NewMemoryBackend`, reference backend lifecycle +methods and the bounded unavailable/retired sentinels. Backend provider lookup +runs during module preparation. The prepared selection retains only typed backend +handles and immutable settings; the request path acquires the current generation +and never resolves factories or reads ConfigStore. + +`LimiterBackend.Admit(ctx, request)` consumes atomically; `Retire(ctx, generation)` +fences late operations; `Health(ctx)` and `Close(ctx)` use the normal prepared +resource lifecycle. Request policy fields are values, with no bearer credential. +Reference bucket initialization is lazy; policy/configuration publication is +owned by the VirtualKey/extension managers. Each admission uses the current +prepared connection handle while retaining an unchanged quota generation. +Generation-scoped storage must survive connection-handle refresh. A production +remote operation may consume before losing its reply; the gateway never retries. +The SPI does not promise exactly-once remote delivery or token transfer and +includes no production distributed implementation. + +`extension.StatefulResource.StateResetComparedTo(ctx, previous)` lets an unpublished +candidate retain an unchanged domain identity before persistence. It cannot +mutate the previous/live resource. Removal or changed identity marks the committed +mutation's `state_reset`; failed preparation/persistence never reports a successful +reset. `Snapshot.Sequence()` provides a manager-local monotonic generation fence +with possible gaps from failed candidates. This preserves policy identity while +all prepared handles still close once according to the normal generation lifecycle. + +The backend receives a context, immutable policy generation, VirtualKey ID and +one of `llm`, `mcp`, `agent`. Admission is one atomic consume-or-deny operation +per key/dimension, consuming exactly one request token on allow and none on deny. +No multi-dimension transaction or global ordering is promised. Retry-after is a +nonnegative duration, rounded up to at least one second when writing a denied +HTTP response. The gateway exposes only bounded diagnostic codes, regardless of backend callback +diagnostics. + +Keep the existing process-local backend as the default. Configure an alternate +backend through its registered extension and the Community-owned envelope +`online.agentguide.community.limiter`, schema 1. Its config contains +`backend_module_id`, `backend_type`, `timeout_ms` and `failure_mode` +(`closed` or `open`); defaults select the linked in-process implementation, +100 ms and `closed`. This selection declares a dependency on the selected backend +module and is prepared with that module in the same mutation generation; +never fall back to a local bucket silently. Admission defaults to a 100 ms +budget, configurable 1 ms–1 s and capped by the request deadline. Caller +cancellation terminates admission without fail-open handling. A retired-generation +admission must return `ErrLimiterRetired` (possibly wrapped); this fence is never +treated as backend unavailability and never fails open. On timeout or +backend failure, a configured hard limit defaults to fail-closed (HTTP 503 with +a bounded `limiter_unavailable` error); explicit fail-open permits the request +with a degraded event/counter. Exhaustion remains HTTP 429. No configured policy +means unlimited and does not call the backend. + +An uncertain remote operation may have consumed a token: the gateway never +automatically retries admission. Backend implementations must document this +possibility and honor cancellation. Health cannot bypass a configured hard limit. +Initialize and prepare policy snapshots before activation, publish atomically, +and reject unknown or unavailable selected backends at startup/mutation. + +Policy generations scope all bucket operations. Existing admitted requests may +finish against their generation; new requests use the committed generation. +Update/delete/reset retires affected bucket state. Unchanged policies preserve +their buckets; changed policies start full buckets, matching current Community +behavior. A backend switch resets state; +v0.7 promises no cross-backend token transfer. Report resets through the +`extension.mutation` DTO's `state_reset` field. Remote implementations must fence +retired generations so late operations cannot recreate active old buckets. +Shutdown stops new admissions, drains/cancels in-flight calls and closes once +within the common five-second module budget. Counters label only dimension, +outcome and bounded backend type, never VirtualKey, route, tenant or subject IDs. +Backend type uses a 32-character identifier, with at most 32 distinct type labels +per gateway and `other` overflow. `limiter/contracttest.Limiter` and `LimiterRefill` +run identically against Community memory and an independent fixture backend. +Real Caddy/agwd consumers prove refresh preservation, switches, 429/503, ambiguous +consumption, timeout, explicit degradation, reset events, bounded labels and shutdown. + +### 11.7 Evidence and release sequence + +Create `tests/extensionconsumer` as a separate Go module in F1 and build all +three consumer commands immediately. Development tests may inject a temporary +absolute replacement for the checked-out Community tree; do not commit that +replacement or a `go.work`. The fixture is independently maintained test code, +not a copy of Community commands or a mock of their assembly. + +Grow its evidence with each surface: manifest projection, reference-preserving +config, protected Admin routing, attribution/events and the deterministic limiter. +Run both Community and fixture backends through the public limiter suite, +including race, cancellation and ambiguous-error cases. Compare process flags, +environment precedence, exit codes, cancellation, streaming and usage evidence +before removing old command assembly. Perform a transitive public-signature +check and private-import isolation check, not merely a text search of main files. + +Candidate compatibility validates an exact commit; Community release gates do +not require the not-yet-existing release tag. After Community tagging, private +release gates consume that tag with `GOWORK=off` and no local replacement. +Private-repository evidence is a separate required deliverable and cannot be +claimed from Community fixture results. Publish the exact protected symbol list +only after these gates pass. + + +### External compatibility verification + +Public `pkg/distribution/contracttest.Distribution` executes external Caddy, +standalone and CLI binaries using distribution-owned fixture inputs. It checks +canonical manifests, CLI apply/export reference configuration, shared Admin +mount/body behavior, selected-backend admission, OpenAI/Anthropic/cc batch and +SSE responses, upstream cancellation, MCP tool execution, persisted LLM/MCP +usage, sanitized event delivery and clean shutdown. The separate-module consumer +and paired repository instantiate the same helper with independently owned +fixtures. `pkg/extension/contracttest.SecretReferences` verifies local validation +with environment values absent/present and manager/store token preservation. +These are reusable test helpers, not new protected production interfaces. + +Checkout verification uses a temporary modfile replacement with `GOWORK=off`. +Exact-candidate verification uses a Git archive of the recorded commit; mutable +local edits cannot influence that evidence. Released verification uses the +checked-in published tag with fresh module/build caches and no replacement or +workspace. A candidate compatibility success never substitutes for tag-level +protection. The paired repository owns its precise candidate/tag compatibility +manifest and CI evidence; Community records only its public integration seam. + + +## 12. Domain extension coverage and ownership + +Capability status: **Proposed** for the additional domain seams in this section. +The foundation above is implemented; registration, configuration, Admin routes +and lifecycle do not grant a module arbitrary access to the request pipeline. +Only the designated limiter contract is the domain proof for v0.7. Exported +provider, protocol, route-policy, credential and ConfigStore APIs are not +implicitly protected by the foundation. + +| Domain | Existing foundation or runtime | Additional domain contract requiring its own vertical slice | +| --- | --- | --- | +| Providers and protocols | Explicit linked factories, Caddy adapters and shared protocol cores | Reviewed factory/capability contracts with protocol conformance; no forced migration of all Caddy modules | +| Model availability and routing | Logical candidates, priority/weighted/auto selection, bounded fallback, credential feedback | Candidate health, circuit breaking, selection and complete attempt feedback | +| Identity and tenancy | Trusted bounded attribution producer and context | Authentication resolver, tenant isolation and ownership of storage/query/cache boundaries | +| Authorization and policy | VirtualKey admission and route restrictions | Subject/resource/action decisions, ordering and explicit enforcement/failure semantics | +| Guardrails and DLP | Proposed Guardrails design | Content inspection hooks, streaming handling, redaction and fail-closed contracts | +| Quotas, budgets and billing | Atomic request-rate limiter | Concurrency, token/cost reservation, final settlement, cancellation and ambiguous charge handling | +| Audit and usage export | Two sanitized best-effort operational event families; internal usage pipeline | Reviewed usage/decision DTOs, durable delivery, retention and replay when required | +| Secrets and credentials | Reference-preserving env secret fields; credential manager and external refresher | Secret resolver/refresh provider, rotation and cache/expiry/failure contracts | +| Storage and cluster configuration | Existing ConfigStore and immutable manager snapshots | Backend/migration contracts, versioned distribution and replica consistency | +| Memory, retrieval and response cache | Separate proposed memory/pipeline designs | Tenant-scoped retrieval/cache operations, invalidation, lifecycle and content policy | +| MCP and Agent runtimes | Existing MCP service and registered Agent backends | Domain-specific backend/tool policy contracts and protocol/security conformance | + +A domain slice must define a Community reference implementation, the exact +protected symbols, configuration and dependency ownership, injection points in +both server modes, error/timeout/cancellation semantics, observability and an +independent consumer contract. A generic middleware hook or gateway service +locator does not substitute for this work. Entitlement remains distribution-owned; +durable business workflow execution remains outside the gateway. + +### 12.1 Model high availability boundary + +Model availability is a gateway responsibility. Basic local fallback, deadlines, +cancellation and local health/circuit behavior belong in Community. Separately +compiled modules may supply shared health or tenant SLA, region and cost selection +policy once the domain seam exists. These are proposed capabilities, not v0.7 +protected symbols or implemented enterprise features. + +Current logical-model routing filters managed-model/provider availability, +generic capabilities and atomic protocol requirements before built-in selection. +`RoutedProvider` tracks tried candidates and credentials, limits model reselection, +and reports managed-credential outcomes. Its stream fallback covers opening the +upstream stream; returning a reader does not establish successful stream +completion. Some providers also retry non-stream calls independently. These +mechanisms do not constitute a shared attempt budget or a candidate circuit +breaker. Direct-provider routes do not perform cross-model fallback. + +A future selection seam may choose only among core-approved candidates; it must +not bypass model enablement, protocol compatibility, authentication, admission +or credential boundaries. The core owns execution and the common deadline and +attempt budget across provider retries, credential changes and candidate changes. +It also owns cancellation, stream response commitment and usage accounting. + +A complete availability contract must distinguish credential failures from +provider/model failures, report stream completion/read failures, fence stale +health observations and define state preservation across configuration changes. +Retry decisions must account for unknown upstream execution and duplicate cost. +After output is committed to the client, failures end that stream; transparent +splicing of a different model response is not permitted. First-byte/first-chunk +and client-commit boundaries must be specified before pre-output fallback is +expanded. Concurrent hedging requires its own cost, cancellation and accounting +contract and is not part of the initial availability seam. + +Attempt telemetry must separate one client interaction from its upstream +attempts, use bounded outcomes and low-cardinality metrics, and avoid prompts, +secrets and arbitrary upstream errors. The current public event families cannot +carry new attempt or usage DTOs without a separately reviewed schema addition. diff --git a/docs/design/gateway-bundle-yaml.md b/docs/design/gateway-bundle-yaml.md index c3d48a51..f59a373a 100644 --- a/docs/design/gateway-bundle-yaml.md +++ b/docs/design/gateway-bundle-yaml.md @@ -102,6 +102,7 @@ Configuration-type objects are: - `mcpRoutes` - `agents` - `agentRoutes` +- `extensions` These objects are expected to converge on one declarative workflow centered on gateway bundle YAML. @@ -250,6 +251,7 @@ The current implemented bundle path covers: - `mcpRoutes` - `agents` - `agentRoutes` +- `extensions` The current implemented bundle path does not yet cover: @@ -268,14 +270,28 @@ Current responsibilities of that package: - define the `GatewayBundle` data model - load bundle YAML from file - decode YAML through a normalized intermediate form -- expand environment variables in scalar strings +- expand environment variables in non-extension scalar strings during decoding +- preserve extension envelopes for linked-registry expansion and validation - serialize bundle data back to YAML Implementation detail: -- the loader follows `YAML -> generic object -> env expansion -> JSON -> typed runtime structs` +`LoadFile` delegates to `DecodeYAML`. Decoding follows +`YAML -> normalized generic object -> env expansion outside extensions -> JSON -> typed GatewayBundle`. +The decoder temporarily separates the complete `extensions` section from ordinary +`${NAME}` expansion, then restores it unchanged before typed decoding. It does +not invoke module factories or classify their secret fields. -This avoids introducing a second field-tagging strategy across the existing runtime object model and lets the bundle reuse current `json` field names. +Linked extension handling happens later in `GatewayBundle.ValidateExtensions`: +`Registry.ExpandConfig` classifies factory-declared secret references before +expanding ordinary `${NAME}` strings inside module configuration, preserving +reference objects. `Registry.Validate` then checks linked schemas and dependencies. +Missing ordinary extension environment variables and invalid secret fields fail +this validation stage, rather than `LoadFile`. Resolving the referenced secret +value remains a separate server preparation step. + +Typed decoding reuses current `json` field names rather than introducing another +field-tagging strategy. ## 10. Validation Model @@ -298,9 +314,21 @@ Current validation includes: - AgentRoute references to Agents inside the bundle - route normalization and route definition validation through existing route helpers -Validation is implemented in `pkg/gatewaybundle.Validate()`. +`GatewayBundle.ValidateWithRegistry(ctx, registry)` in `pkg/gatewaybundle` is +the dynamic validation entry used by `agwctl validate` and `apply`. It first runs +`ValidateExtensions`, including secret classification, ordinary extension +environment expansion, linked module/schema checks and dependency validation, +then validates the other resources. Static startup uses +`ValidateForStaticConfig`, which rejects non-empty `extensions` before resource +validation. Internal extensions are configured only through ConfigStore workflows. + +The registry-free `Validate` and `ValidateForConfigStore` methods reject +non-empty `extensions` with +`extensions require linked registry validation`; they cannot validate an external +module's envelope. Callers must provide the distribution's linked registry. -Validation errors are aggregated so users can see multiple file problems in one run. +Resource validation errors are aggregated so users can see multiple file problems +in one run. Extension validation returns its error before those resource checks. ## 11. Apply Semantics @@ -315,14 +343,15 @@ Current behavior: Current object application order: -1. `providers` -2. `managedModels` -3. `llmRoutes` -4. `mcpServices` -5. `mcpRoutes` -6. `agents` -7. `agentRoutes` -8. `virtualKeys` +1. `extensions` in declared dependency order +2. `providers` +3. `managedModels` +4. `llmRoutes` +5. `mcpServices` +6. `mcpRoutes` +7. `agents` +8. `agentRoutes` +9. `virtualKeys` Current non-behavior: @@ -344,7 +373,7 @@ Current non-behavior: Current behavior: - export providers, managed models, LLM routes, virtual keys, MCP - services/routes, and Agents/AgentRoutes + services/routes, Agents/AgentRoutes and reference-preserving extension envelopes - omit Admin API read-only/source wrapper fields from the re-apply file - emit a re-usable bundle shape intended for later `validate` and `apply` @@ -364,7 +393,9 @@ Current standalone integration does the following: - validate the bundle locally - instantiate static providers from bundle provider configs - register static provider API-key credentials into the shared credential manager -- pass static providers, managed models, and routes into `gateway.BootstrapOptions` +- pass static providers and direct-provider routes into runtime bootstrap +- load extension envelopes from ConfigStore independently of the static Bundle +- reject static extensions, managed models, VirtualKeys and logical-model routes The shared schema contains more dynamic object families than standalone static loading supports. Standalone validates its own narrower startup boundary; the @@ -375,7 +406,7 @@ The resulting runtime behavior matches the existing static-object model: - static providers are read-only - static routes are read-only -- static managed models participate in model catalog reads +- extensions use ConfigStore ownership and the prepared-generation manager in both server modes ## 14. Secrets Handling @@ -394,7 +425,13 @@ Current behavior: - if a referenced environment variable is missing, bundle loading fails -The design intentionally keeps secret indirection minimal in the current implementation. +Extension factory-declared secret fields are classified before ordinary string +expansion and accept only `{"$secret_ref":"env:NAME"}`. Local validation is +syntax-only; runtime preparation resolves separately. Stored/exported envelopes +retain tokens rather than resolved values. Literal secrets and undeclared +reference objects fail. Extension preparation/persistence is failure-isolated, +but Bundle apply is not a whole-Bundle transaction. See the +[extension contract](enterprise-extension-contract.md#113-configuration-ownership-mutation-and-secrets). ## 15. Error Model diff --git a/docs/design/route-target-policy.md b/docs/design/route-target-policy.md index 2f054b23..84509c4a 100644 --- a/docs/design/route-target-policy.md +++ b/docs/design/route-target-policy.md @@ -1,508 +1,100 @@ -# Route Target Policy Architecture And Technical Specification - -Capability status: **Implemented**. - -## 1. Scope - -This document defines the current route target policy architecture in `agent-gateway`. - -It is a runtime design and technical specification document, not a proposal or migration plan. It describes the implemented route target policy model, validation rules, runtime resolution behavior, credential scheduling semantics, and fallback behavior used by the gateway today. - -The current Go module path is: - -- `github.com/agent-guide/agent-gateway` - -## 2. Architectural Position - -Route target policy is the control layer between route matching and provider execution. - -The relevant package ownership is: - -- `pkg/gateway/llmroute` - - owns route data model, target policy normalization, validation, and target resolution -- `pkg/gateway` - - owns `RoutedProvider`, provider resolution, credential scheduling, and fallback execution -- `pkg/gateway/modelcatalog` - - owns managed model overlays and discovered provider model facts -- `pkg/credential` - - owns credential persistence, expiry detection, external refresh transport, and scheduling primitives -- `pkg/dispatcher` - - owns protocol parsing and protocol-specific response generation - -The route target policy decides which concrete upstream binding may be used for a request. `RoutedProvider` executes that decision and handles bounded fallback when configured. - -## 3. Design Overview - -The current implementation uses a concrete route target policy struct: - -- `route.AgentRoute` -- `route.RouteTargetPolicy` - -It does not use a Go interface-backed policy object at runtime. - -Current route target policy supports two effective modes: - -- `direct-provider` -- `logical-model` - -The effective mode is determined by `RouteTargetPolicy.PolicyKind()` after normalization. - -## 4. Route Data Model - -The route runtime type is `pkg/gateway/llmroute.AgentRoute`. - -Target selection is represented by `pkg/gateway/llmroute.RouteTargetPolicy`. - -The fields that define target behavior are: - -- `type` -- `provider_id` -- `models` -- `default_model` -- `model_selector_strategy` -- `credential_selector` -- `credential_scope_order` -- `credential_type_order` -- `fallback` -- `model_targets` -- `provider_target` - -One compatibility layer exists in the current implementation: - -- `provider_id` and `provider_target.provider_id` normalize to the same effective provider target - -New code and new documentation must use the normalized shape: - -- `target_policy.provider_target.provider_id` -- `target_policy.model_targets` -- `target_policy.default_model` - -## 5. Policy Kinds - -Current route target policy kinds are: - -- `direct-provider` -- `logical-model` - -### 5.1 Direct-Provider Mode - -Direct-provider mode is active when the normalized target policy contains a provider target. - -Effective route behavior: - -- one provider is selected explicitly -- the request `model` is treated as the upstream model name -- no route-local logical model resolution is performed -- credential scheduling still runs through the route target policy - -This mode is the simple pass-through path for static forwarding. - -### 5.2 Logical-Model Mode - -Logical-model mode is active when the normalized target policy contains model targets and no effective direct provider target. - -Effective route behavior: - -- the request `model` is interpreted as a route-visible logical model name -- the route-visible logical model name maps to one candidate set -- one concrete `(provider_id, upstream_model)` candidate is selected from that set -- the upstream request model is rewritten before provider execution - -If the request omits `model`, `target_policy.default_model` is used when configured. - -## 6. Normalization Rules - -`RouteTargetPolicy.Normalize()` applies the following runtime normalization: - -- trims and synchronizes `provider_id` and `provider_target.provider_id` -- infers `type` when omitted - -This means the route target policy keeps one normalized logical-model shape at runtime: `model_targets`. - -## 7. Logical Model Target Structure - -Logical-model routing uses `RouteModelTarget`. - -`RouteModelTarget` contains: - -- `name` -- `strategy` -- `default_candidate` -- `candidates` - -Each `RouteModelCandidate` contains: - -- `provider_id` -- `upstream_model` -- `weight` -- `priority` -- `default` - -The route-visible model name is `RouteModelTarget.Name`. Each target name defines one candidate pool. - -## 8. Credential Policy Structure - -Credential selection is part of route target policy, not protocol handler behavior. - -Current credential selector values: - -- `round_robin` -- `fill_first` - -Current credential scope values: - -- `model_custom` -- `provider_id` - -Current credential type values: - -- `api_key` -- `oauth_token` - -These values are stored on `RouteTargetPolicy` as: - -- `credential_selector` -- `credential_scope_order` -- `credential_type_order` - -## 9. Validation Rules - -The validation entrypoint is `route.AgentRoute.ValidateDefinition()`. - -### 9.1 Common Rules - -Every route must satisfy these common rules: - -- `id` must be set -- `llm_api` must be set -- `credential_selector`, when set, must be valid -- `credential_scope_order` must contain only supported scope values -- `credential_scope_order` must not contain duplicates -- `credential_type_order` must not be empty -- `credential_type_order` must contain only supported credential type values -- `credential_type_order` must not contain duplicates - -### 9.2 Direct-Provider Rules - -In direct-provider mode: - -- `target_policy.provider_id` must be set after normalization -- `credential_scope_order` must not contain `model_custom` - -The route validation path accepts direct-provider mode as the effective mode whenever a provider target is configured, even if model-target fields are also present in legacy or mixed input. - -That precedence is part of the current compatibility behavior. - -### 9.3 Logical-Model Rules - -In logical-model mode: - -- `target_policy.model_targets` must be non-empty after normalization -- every target must have a unique `name` -- every target must define at least one candidate -- every candidate must define both `provider_id` and `upstream_model` -- duplicate `(provider_id, upstream_model)` candidates within one target are rejected -- `default_model`, when set, must reference an existing target name -- `model_selector_strategy` must be one of: - - `auto` - - `weighted` - - `priority` -- `fallback.max_num` must be between `0` and `5` - -## 10. Runtime Resolution Contract - -The target resolution entrypoint is `route.AgentRoute.ResolveTarget(...)`. - -Request-side requirements are represented by `route.RequestRequirements`. - -Current request requirement fields: - -- `model` -- `require_streaming` -- `require_tools` -- `require_vision` -- `require_embeddings` -- `excluded_candidates` - -Current `ResolvedTarget` fields: - -- `logical_model` -- `model` -- `provider_id` -- `provider_type` -- `upstream_model` -- `credential_scope` -- `capabilities` - -`ResolvedTarget` is concrete and execution-oriented. It represents one selected binding, not the full route policy. - -## 11. Direct-Provider Resolution - -In direct-provider mode, `ResolveTarget(...)`: - -- resolves the configured provider ID -- derives `credential_scope` as `ProviderIDCredentialScope(provider_id)` -- treats the request `model` as the upstream model -- does not perform logical-model admission checks -- returns one concrete `ResolvedTarget` - -This mode is provider-explicit and does not depend on managed model enablement. - -## 12. Logical-Model Resolution - -In logical-model mode, `ResolveTarget(...)`: - -- interprets the request `model` as the route model name -- falls back to `target_policy.default_model` when the request omits `model` -- rejects the request if no route model is available -- rejects the request if the requested route model is not allowed on the matched route -- loads all configured candidates for that route model -- excludes request-scoped rejected candidates from consideration -- reads managed model state from the model catalog -- drops candidates whose managed model record is missing or disabled -- drops candidates whose provider config is unavailable or disabled -- drops candidates that do not satisfy request capability requirements -- selects one candidate according to `model_selector_strategy` - -If no eligible candidate remains, the resolver returns a gateway error indicating that the route model has no eligible bindings. - -## 13. Candidate Selection Semantics - -The current candidate selector is implemented in `pkg/gateway/llmroute/resolve.go`. - -Supported selector strategies: - -- `priority` -- `weighted` -- `auto` - -### 13.1 `priority` - -Selection behavior: - -- higher `priority` wins -- `weight` is ignored -- stable config order breaks ties - -### 13.2 `weighted` - -Selection behavior: - -- all candidates participate in one weighted draw -- candidates with `weight <= 0` contribute zero weight -- if total weight is zero or negative, the first candidate in config order is selected - -### 13.3 `auto` - -Selection behavior: - -- candidates are first filtered to the highest `priority` tier -- if that tier has any positive weights, weighted selection runs within the tier -- if the best tier has no positive weight, the first candidate in tier order is selected - -## 14. Capability Filtering - -Logical-model candidate filtering uses per-model capability requirements from `RequestRequirements`. - -Current capability gates are: - -- streaming -- tools -- vision -- embeddings - -Capability evaluation is performed against the candidate's effective `provider.ModelCapabilities`, which come from the model catalog's resolved managed model view. - -## 15. Credential Scope Expansion - -`RoutedProvider` expands credential scopes from `ResolvedTarget` and route policy. - -Current scope expansion rules: - -- `model_custom` - - uses `ResolvedTarget.CredentialScope` when present -- `provider_id` - - uses `credential.ProviderIDCredentialScope(target.ProviderID)` - -Scope expansion preserves the route-configured order. Empty scopes are skipped. - -## 16. Credential Type Ordering - -Within each expanded scope, `RoutedProvider` checks credential types in the configured `credential_type_order`. - -Current credential type handling: - -- `api_key` -- `oauth_token` - -If an OAuth token credential is selected, the credential manager may invoke -the configured external refresh command before execution proceeds. - -## 17. RoutedProvider Responsibilities - -The runtime execution object is `pkg/gateway.RoutedProvider`. - -Current responsibilities: - -- resolve one concrete route target -- resolve the concrete provider instance -- select credentials through the scheduler -- inject selected credentials into request context -- rewrite the request model to the selected upstream model -- execute chat and responses API calls -- mark credential scheduling results -- apply bounded model fallback - -`RoutedProvider` implements: - -- `Chat(...)` -- `StreamChat(...)` -- `CreateResponses(...)` -- `StreamResponses(...)` -- `ListModels(...)` - -## 18. Fallback Semantics - -Fallback is request-scoped and bounded. - -Current execution state tracks: - -- tried candidates -- tried credentials -- model fallback count - -Current fallback behavior: - -- fallback is active only for logical-model routes with `target_policy.fallback.enabled` -- `fallback.max_num` defines the maximum number of model reselections -- missing credentials for one candidate reject that candidate for the current request -- credential rejection consumes candidate availability but does not change route policy -- provider execution failures may trigger model reselection when classified as retryable at the model level - -The current implementation does not expose a separate credential-reselection action enum. It supports: - -- stop -- reselect model - -## 19. Failure Classification - -`RoutedProvider.classifyFailure(...)` currently maps failures into two runtime actions: - -- stop -- reselect model - -Current retryable model-fallback statuses are: - -- `429 Too Many Requests` -- `502 Bad Gateway` -- `503 Service Unavailable` -- `504 Gateway Timeout` -- any `5xx` status not otherwise handled - -All other failures stop request execution immediately. - -This is the current normative behavior for route-level target fallback. - -## 20. Scheduler Result Marking - -After each execution attempt, `RoutedProvider.markResult(...)` records a scheduler result when a concrete managed credential was used. - -Recorded scheduler result fields include: - -- credential ID -- upstream model -- success or failure -- HTTP-derived error metadata when execution failed - -This keeps credential scheduling feedback in the gateway runtime, not in protocol adapters. - -## 21. Caddyfile Configuration Shape - -In the current Caddy-based runtime, route target policy is expressed through route-level target directives, not through a nested `target_policy { ... }` block. - -Current supported directives: - -- `target provider ` -- `target model [weight ] [priority ] [strategy ] [default]` - -Direct-provider example: +# Route Target Policy + +Capability status: **Implemented** for the runtime described here. This package +is not designated as a protected Enterprise SPI. Additional model-availability +contracts are [Proposed](enterprise-extension-contract.md#121-model-high-availability-boundary). + +## Ownership and representation + +`pkg/gateway/llmroute` owns `LLMRouteConfig`, `LLMRoute` and the +`RouteTargetPolicy` Go interface. Its current concrete implementations are +`RouteDirectProviderPolicy` and `RouteLogicalModelTargetPolicy`. Common credential +settings live in `RouteTargetPolicyCommon`. `pkg/gateway.RoutedProvider` owns +execution, credential selection and bounded fallback. `modelcatalog` owns +managed-model facts; `pkg/credential` owns credential persistence, refresh and +scheduler state. Protocol handlers own parsing and response serialization. + +Stored route JSON is a configuration object with `kind: llm`, `protocol`, +`match_policy`, `auth_policy` and `target_policy`. Policy JSON uses `type` to +identify `direct-provider` or `logical-model`; it does not serialize a Go +interface implementation name. Direct targets use +`target_policy.provider_target.provider_id`. Logical targets use +`target_policy.model_targets`, `default_model`, `model_selector_strategy` and +`fallback`. See the [logical routing guide](../guides/logical-model-routing.md) +for the complete dynamic configuration workflow. + +## Resolution + +Direct-provider policies select one provider. The request model is the upstream +model, with the provider default used when it is empty. Atomic protocol +requirements are checked against provider-type capabilities. This path does not +require a managed logical-model record or provide cross-model fallback. + +Logical-model policies interpret the request model as a route-visible target +name, or use `default_model` when absent. Resolution reads manager snapshots, +excludes candidates already rejected for the request, requires enabled managed +models and providers, and filters generic streaming/tools/vision/embeddings +capabilities and atomic protocol features. No eligible binding is an error. + +Selection is implemented in `pkg/gateway/llmroute/policy.go`: + +- `priority`: highest priority, with stable configuration order breaking ties; +- `weighted`: positive weights participate in a weighted draw; no positive + total selects the first eligible candidate; +- `auto`: highest priority tier, with weighted selection within that tier when + it has positive weights, otherwise the first candidate in the tier. + +The resolved target identifies one provider, upstream model, optional logical +model, credential scope and capabilities. `ExecuteChat` and `ExecuteStreamChat` +clone the provider-facing request and its protocol state, rewrite only the +attempt-local model, and preserve the caller's model/protocol state. The ordinary +`Chat` and `StreamChat` methods delegate to these enriched paths. + +`CreateResponses` and `StreamResponses` also clone and rewrite the request for +provider execution, but after a successful call or stream opening they write +that clone's model back to the caller's `ResponsesRequest.Model`. Responses +callers must retain the client model separately if they need it after execution; +the Chat request-isolation guarantee does not apply to this field. + +## Credentials and fallback + +Credential selectors are `round_robin` and `fill_first`; scopes are +`model_custom` and `provider_id`; types are `api_key` and `oauth_token`. Scope +and type ordering belong to route policy. The credential manager may refresh an +expiring OAuth credential through the configured external command before use. + +`RoutedProvider` tracks tried candidates, managed credentials and provider-config +authentication attempts. When a retryable execution fails, available managed +credentials can be tried on the same binding. Candidate advancement requires a +logical-model policy with enabled fallback and remaining `fallback.max_num` +(maximum five reselections). Credential unavailability may also advance the +candidate; target/provider resolution errors return immediately. Failure +classification permits model reselection for 429 and 5xx errors; other classified +statuses stop execution. These bounds count model reselections, not a common +budget for every credential attempt and provider-internal retry. + +Scheduler feedback records the return of the provider call for a concrete +managed credential. For streams that is stream opening, not final consumption. +Once a stream reader is returned, later read failures do not re-enter the +fallback loop. A complete health/circuit/attempt contract is separate work. + +## Static and dynamic configuration + +Static Caddyfile and startup Bundle LLM routes must use direct-provider targets. +Logical-model routes and managed models are dynamic resources, configured through +the Admin API or `agwctl apply`. Static `target model` and +`target_policy logical-model` are rejected. ```caddy route openai-direct { - llm_api openai - path_prefix / + protocol openai + path_prefix /v1 require_virtual_key target provider openai-main } ``` -Logical-model example: - -```caddy -route openai-chat { - llm_api openai - path_prefix / - require_virtual_key - target model chat-default openai-main gpt-4.1 weight 100 priority 100 default - target model chat-default openai-backup gpt-4.1 weight 20 priority 100 - target model chat-default openrouter-main openai/gpt-4.1 weight 10 priority 80 -} -``` - -The Caddyfile adapter compiles these directives into the normalized `RouteTargetPolicy` shape. - -## 22. JSON Storage Shape - -Persisted route JSON stores target policy as a concrete object, not as an interface envelope. - -Current stored route payloads use fields such as: - -- `target_policy.type` -- `target_policy.provider_target.provider_id` -- `target_policy.model_targets` -- `target_policy.default_model` -- `target_policy.model_selector_strategy` -- `target_policy.credential_selector` -- `target_policy.credential_scope_order` -- `target_policy.credential_type_order` -- `target_policy.fallback` - -Compatibility fields may still appear in stored route payloads, but runtime normalization defines the effective policy. - -## 23. Naming Rules - -Use the following terms consistently: - -- `agent-gateway` - - project and module name -- `RouteTargetPolicy` - - concrete target policy struct -- `ResolvedTarget` - - one concrete selected execution target -- `RoutedProvider` - - route-aware provider execution runtime -- `direct-provider` - - explicit provider forwarding mode -- `logical-model` - - route model selection mode - -Do not reintroduce: - -- legacy repository naming -- interface-based target policy terminology unless the code actually adopts that model -- configuration examples that rely on a non-existent `target_policy { ... }` Caddyfile block - -## 24. Summary - -The current route target policy architecture in `agent-gateway` is a concrete, normalized route policy model with two effective target modes. - -Its stable design rules are: - -- route target policy is owned by `pkg/gateway/llmroute` -- execution and fallback are owned by `pkg/gateway.RoutedProvider` -- direct-provider mode forwards explicitly to one provider -- logical-model mode resolves one route-visible model name to one concrete provider/model binding -- credential scope ordering and source ordering are part of route target policy -- fallback is bounded and request-scoped - -This is the current `agent-gateway` architecture and the normative technical specification for route target policy behavior in the repository. +The Caddyfile also accepts `target_policy direct-provider { ... }`; consult the +[current syntax reference](../reference/caddyfile-reference.md) and +[working example](../../examples/Caddyfile.example). Configuration examples must +use current protocol and route shapes. diff --git a/docs/development/README.md b/docs/development/README.md index b9583061..9e9ba6e5 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -15,7 +15,8 @@ make verify-release ``` This checks tracked Go files outside `third_party/` with `gofmt -l`, verifies -modules, runs the normal and race test suites, runs `go vet`, and builds +modules, runs the normal and race test suites, runs the independent extension +consumer race/build/process contracts, runs `go vet`, and builds `agw`, `agwd`, and `agwctl`. The race suite temporarily excludes only `pkg/llm/provider/anthropic` because the pinned `eino-ext/claude` dependency has a known race in `ChatModel.Stream`. @@ -38,3 +39,43 @@ exercises common `/turn` plus native A2A Card, JSON, and SSE traffic, waits for the matching persisted interaction, and then checks standalone `agwd` readiness. Child processes and temporary SQLite files are cleaned up on every exit path. + + +## External distribution contracts + +```sh +make test-extension-consumer +make test-extension-consumer-candidate COMMUNITY_COMMIT= +# After publishing the dependency tag declared by the consumer: +make test-extension-consumer-release +``` + +The first command checks the working checkout using a temporary modfile. The +candidate command archives the exact Git commit, so local edits cannot change +its evidence. Release mode downloads the consumer's pinned tag with fresh +module/build caches, disables workspaces and rejects checked-in replacements. +Candidate proof never satisfies the published-tag gate. + +A separately maintained repository can instantiate public +`pkg/extension/contracttest`, `pkg/gateway/virtualkey/limiter/contracttest` and +`pkg/distribution/contracttest`. The integrated process suite takes external +assemblies, envelopes, command paths, a read-only POST probe and a JSONL sink +path variable. It runs both server modes, CLI configuration round trips, shared +Admin authentication/body policy, selected-backend admission, OpenAI/Anthropic/cc +batch/SSE, upstream cancellation, MCP tool execution, persisted LLM/MCP usage, +sanitized events and shutdown. The process helpers are test infrastructure, +not an additional production compatibility promise. + +`AGW_RELEASE_BIN_DIR` lets the existing Agent/A2A release smoke exercise built +external entries named `agw`, `agwd` and `agwctl`, retaining the same harness. + +## Extension foundation release closure + +The [v0.7 release notes](../releases/v0.7.0.md) enumerate the exact designated +production surfaces. Verify both Community gates on the clean candidate, then +run the separate consumer's immutable candidate gate and the paired repository +candidate gate against that full commit. Publish Community before resolving the +released dependency in either consumer. Record the fresh-cache release results +and checksums; only then retire the paired execution plans and their indexes. +Local tags, temporary replacements and successful candidate builds do not prove +that the released dependency is downloadable. diff --git a/docs/guides/README.md b/docs/guides/README.md index e76ae5a8..a9132f16 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -13,3 +13,5 @@ Current pages: - [http-agents.md](http-agents.md) - [providers.md](providers.md) - [routes.md](routes.md) + +- [custom-distribution.md](custom-distribution.md) diff --git a/docs/guides/bundle-yaml.md b/docs/guides/bundle-yaml.md index bca2d0e9..322faf63 100644 --- a/docs/guides/bundle-yaml.md +++ b/docs/guides/bundle-yaml.md @@ -13,6 +13,7 @@ Bundle YAML is the declarative configuration format for gateway objects such as: - VirtualKeys - MCP services - MCP routes +- registered extension envelopes It is intentionally for configuration objects, not every runtime operation. @@ -152,8 +153,7 @@ virtualKeys: ``` Each configured rate and burst must be greater than zero. Omitted dimensions -are unlimited. `agent` supplies the policy for two independent runtime buckets, -ACP and builtin. +are unlimited. `agent` supplies the unified Agent ingress bucket. ## Schema Notes @@ -172,6 +172,7 @@ Common top-level sections: - `mcpRoutes` - `agents` - `agentRoutes` +- `extensions` Managed credentials are not included in bundle apply/export. Treat an exported bundle as gateway configuration, not as a complete backup: back up and restore @@ -186,3 +187,30 @@ Some older example files in the repository still use older field names in parts - [../reference/agwctl-reference.md](../reference/agwctl-reference.md) - [../design/gateway-bundle-yaml.md](../design/gateway-bundle-yaml.md) - [logical-model-routing.md](logical-model-routing.md) + +## Linked extension configuration + +```yaml +extensions: + - module_id: online.agentguide.community.limiter + schema_version: 1 + config: + timeout_ms: 100 + failure_mode: closed +``` + +Each envelope belongs to a linked module and its schema. Unknown modules fail +local and server validation. All extension envelopes use the existing ConfigStore +and `/admin/extensions/{module_id}`. Use Bundle apply to persist configuration; +`agwd --static-config` rejects non-empty extensions. Caddyfile and Caddy JSON +also reject extension configuration. +Apply orders extensions by declared dependencies before other resources, stops +on extension failure and reports partial results. It has no whole-Bundle rollback. +Each serialized resource shares the Admin 4 MiB received-body boundary. + +Factory-declared secret fields use `{"$secret_ref":"env:NAME"}`. They are +classified before ordinary `${NAME}` expansion. Local validation checks syntax +without resolving the reference; server preparation receives resolved secrets +separately. Storage and export preserve the reference token. Preparation or +persistence failure leaves the serving generation active. See the +[custom distribution guide](custom-distribution.md) for module assembly. diff --git a/docs/guides/custom-distribution.md b/docs/guides/custom-distribution.md new file mode 100644 index 00000000..7d0e33ac --- /dev/null +++ b/docs/guides/custom-distribution.md @@ -0,0 +1,113 @@ +# Build a Custom Distribution + +Use a separate Go module that imports public Community packages. Keep explicit +module/provider/runtime imports in your three `package main` entries. The +[separate-module consumer](../../tests/extensionconsumer/) demonstrates a complete +assembly; its command imports are examples of the baseline linkage to retain. + +## Assemble the commands + +Supply one runtime `distribution.Assembly` to both server entries. Build the CLI +assembly as a validation-role projection: retain only modules that declare +`validation`, preserving their IDs, versions, schemas and dependencies, and set +each retained descriptor's roles to `["validation"]`. Keep the corresponding +local schema factories; omit runtime-only Prepare/Admin callbacks and sink +construction. The CLI must not advertise `runtime`, `admin` or `spi` roles. + +Capability declarations must describe local validation too. For the Community +gateway descriptor, `distribution.CommunityValidation()` replaces the runtime +capabilities with `online.agentguide.community.bundle-validation`; use this +baseline rather than copying `CommunityRuntime()` unchanged. Apply the same +ownership rule to your modules' capability metadata. See the consumer's separate +`Runtime()` and `Validation()` assemblies for the exact projection. + +```go +// Caddy main, after explicit linked-module imports: +func main() { caddycmd.New(runtimeAssembly()).Run() } + +// Standalone main: +func main() { + os.Exit(daemoncmd.Run(daemoncmd.Options{Assembly: runtimeAssembly()})) +} + +// Management CLI main: +func main() { + os.Exit(ctlcmd.Run(ctlcmd.Options{Assembly: validationAssembly()})) +} +``` + +The functions above are distribution-owned assembly builders. Declare linked +module descriptors and factories explicitly; use `Assembly.Registry()` to check +them. Metadata does not load code. Constructors keep mutable command flags local; +execution owns dotenv, signals and process exit behavior. Use the public +`MetricsConfig`/`OTLPConfig` values for metrics configuration. + +## Register configuration and resources + +A descriptor identifies the module, schema and roles. A separate factory owns +strict validation, secret field declarations, configured dependencies and +preparation. Runtime resources implement Health and Close. Validation callbacks +must not resolve secrets; preparation receives reference-preserving JSON, +resolved secrets separately and only declared dependency resources. + +Configure a linked module through a Bundle envelope: + +```yaml +extensions: + - module_id: example.organization.module + schema_version: 1 + config: + message: hello +``` + +Replace the example schema with the linked factory's actual fields. Unknown +modules fail validation. Secret fields declared by that factory accept only +`{"$secret_ref":"env:NAME"}`; persisted and exported configuration retains the +token. Use `agwctl validate`, `apply` and `export` as described in the +[Bundle guide](bundle-yaml.md). Apply performs extension writes in dependency +order and reports partial results; it is not a whole-Bundle transaction. + +Configuration uses `/admin/extensions/{module_id}` and the existing ConfigStore. +Bundle apply writes through that API. Caddyfile/Caddy JSON and standalone static +Bundles do not configure internal extensions. Mutations prepare and persist before publishing +one generation. Request code holds a snapshot lease; resources honor +cancellation and retire in reverse dependency order. Preserve domain state +explicitly when unrelated writes replace prepared handles. + +## Contribute Admin routes and request services + +Declare `Factory.AdminRoutes` under `/admin/extensions//...` and +implement `AdminContributor` on the prepared resource. Module root CRUD and all +Community namespaces are reserved. Use the supplied dependencies and +`WriteAdminError`; retain the mount authentication and shared 4 MiB body boundary. +Request attribution and the module-scoped publisher are supplied by assembly. + +To supply VirtualKey admission, implement `LimiterProvider` on a prepared module +resource and return its `LimiterBackend`. Configure that module and the +`online.agentguide.community.limiter` selection envelope together. See the +[limiter configuration example](../../README.md#limiter-backend-extension). +Registration alone does not install request interception; each runtime domain +needs its own injection contract. + +## Add attribution and event delivery + +Inject trusted attribution producers and exact namespaced attribute allowlists +through assembly. Raw caller headers do not establish trusted identity. Sink +registrations construct a fresh sink for every server lifecycle and receive only +sanitized typed operational events. Write/Flush/Close honor context cancellation; +delivery is bounded, asynchronous, best effort and has no retry or durable replay. + +## Verify the distribution + +Instantiate `pkg/extension/contracttest` (including its Attribution and EventSink +suites), `pkg/gateway/virtualkey/limiter/contracttest` and +`pkg/distribution/contracttest` with independent module implementations. The +integrated suite accepts three built binaries, runtime/validation assemblies, +envelopes, a read-only POST probe and a JSONL event-file variable. Check public +signature closure, private-import isolation and baseline module imports too. + +Compare `agw manifest` and `agwd manifest`; inspect the CLI validation subset with +`agwctl manifest` and the server projection with `agwctl manifest --remote`. +Use the [development verification workflow](../development/README.md#external-distribution-contracts) +for exact-commit and published-tag checks. Release builds consume a pinned +published dependency without checked-in replacements or workspaces. diff --git a/docs/plans/README.md b/docs/plans/README.md index 4ee8891c..2c9ffeec 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -21,8 +21,6 @@ Lifecycle convention: Current plans: -- [v0.6.1-stabilization.md](v0.6.1-stabilization.md): patch-release - verification, a self-contained A2A example, process smoke coverage, and - SQLite upgrade tests -- [v0.7.0-community-guardrails.md](v0.7.0-community-guardrails.md): first - Community Guardrails implementation slice for provider-facing chat requests +- [v0.7.0-enterprise-extension-foundation.md](v0.7.0-enterprise-extension-foundation.md): + published-tag acceptance and paired release closure for the implemented + ConfigStore-only extension foundation diff --git a/docs/plans/v0.6.1-stabilization.md b/docs/plans/v0.6.1-stabilization.md deleted file mode 100644 index c1f875af..00000000 --- a/docs/plans/v0.6.1-stabilization.md +++ /dev/null @@ -1,227 +0,0 @@ -# v0.6.1 Stabilization Plan - -## 0. Plan Contract - -This plan targets the `v0.6.1` patch line. Its objective is to prove and -package the behavior shipped in `v0.6.0`, not to add another runtime, -protocol, persistence model, or policy subsystem. - -The baseline was verified at annotated tag `v0.6.0`, commit -`339b15afda5ea7e611de255091bdc6884d52c0e1`. When this plan and the code -disagree, the code wins and this plan must be corrected before implementation -continues. - -Implementation rules: - -- each work package lands as a reviewable commit with its own verification; -- runtime behavior changes are limited to defects demonstrated by a regression - test; -- no compatibility alias or legacy route shape is introduced; -- permanent behavior belongs in guides, reference, architecture, and release - notes, not in this plan; -- `v0.6.1` is not tagged until every release gate in §7 passes on the final - commit. - -## 1. Scope - -The patch release delivers four outcomes: - -1. one reproducible release-verification entry point shared by developers and - CI; -2. a self-contained local A2A Protocol 1.0 example for the HTTP Agent quick - start; -3. process-level smoke coverage for the shipped binaries and both HTTP Agent - ingress paths; -4. explicit fresh-database and pre-v0.6 SQLite schema-upgrade coverage. - -Any newly discovered correctness or security defect may be fixed in this line -only with a focused regression test and release-note entry. - -## 2. Non-Goals - -`v0.6.1` does not add: - -- A2A v0.3, REST, gRPC, push webhooks, or extended-card support; -- durable or cross-replica HTTP Agent task bindings; -- new Guardrails, Memory, Pipeline, Workflow, or callback-identity behavior; -- reverse translation of ACP or builtin Agents to native A2A; -- a second release mechanism or generated compatibility aliases. - -## 3. Work Package S1 — Release Verification Parity - -Create one repository-owned verification entry point, exposed through the -Makefile or a small checked-in script, that normalizes and performs the release -checks currently split between `docs/releases/v0.6.0.md` and CI: - -```text -tracked-Go-file formatting check with gofmt -l (non-mutating) -go mod verify -go test ./... -race test, excluding only pkg/llm/provider/anthropic -go vet ./... -build agw, agwd, and agwctl -``` - -Requirements: - -- CI and the release guide invoke the same entry point or the same documented - command sequence; they must not maintain divergent package exclusions. -- The shared entry point owns the exact formatting file set: tracked Go files - excluding `third_party/**`. CI and release documentation invoke that entry - point instead of reproducing the file-selection or exclusion rules. -- Formatting verification reports a dirty file without rewriting the tree. -- The Anthropic race exclusion retains an adjacent explanation and is removed - when the pinned upstream dependency no longer requires it. -- Build output names remain `agw`, `agwd`, and `agwctl`. -- A successful verification leaves the Git worktree clean. - -Verification: - -- run the entry point locally from a clean checkout; -- run the GitHub Actions workflow on Linux; -- deliberately format-drift a tracked Go fixture, prove the formatting gate - fails without rewriting that file, and restore the fixture after the check. - -## 4. Work Package S2 — Self-Contained A2A Example - -Add a minimal example A2A Protocol 1.0 JSON-RPC service under `examples/` that -can be started with repository tooling and no vendor account. It is a protocol -fixture for onboarding and smoke tests, not a production Agent SDK. - -Required behavior: - -- serve an Agent Card with a same-origin `JSONRPC` interface and protocol - version `1.0`; -- accept `A2A-Version: 1.0`; -- implement deterministic `SendMessage` and `SendStreamingMessage` responses: - the synchronous method returns either an agent-authored `Message` or a - completed `Task`, and the streaming method ends with a terminal status update - before closing; -- return a stable A2A `contextId` that the gateway can bind to its - caller-visible `session_id`, so the common `/turn` continuation can be - demonstrated without exposing the remote identifier as the client contract; -- expose a deterministic health/readiness signal for the smoke harness; -- bind loopback by default and print no credentials or request bodies; -- shut down cleanly when the test harness exits. - -The example need not implement the full six-method native-ingress allowlist. -Unsupported methods return a protocol-correct JSON-RPC error and are documented -as example limitations. - -Documentation changes: - -- update the root HTTP Agent quick start and - `docs/getting-started/quickstart-http-agent.md` with an optional local-example - path; -- keep the existing bring-your-own A2A service path; -- state clearly that the fixture is for development and verification only. - -Verification: - -- Card parsing and interface selection use the real `pkg/a2a/card` code; -- JSON and SSE responses use the real Path B client and Path A proxy tests; -- the example starts on an ephemeral or explicitly supplied loopback port. - -## 5. Work Package S3 — Process-Level Release Smoke Test - -Add a deterministic smoke harness that builds and exercises the binaries as -separate processes. It must use temporary directories, loopback listeners, and -bounded startup/exit deadlines, and must clean up child processes on success, -failure, or interruption. - -The primary scenario is: - -1. start the example A2A service; -2. start `agw` with a temporary SQLite config store and the Agent dispatcher - enabled; -3. apply one HTTP Agent, one `protocol=agent` route, one `protocol=a2a` route, - and one VirtualKey through `agwctl`; -4. verify readiness through the Admin API or `agwctl`; -5. send one common `/turn` request and observe a terminal common Agent SSE - event; -6. fetch the rewritten public Agent Card; -7. send one native `SendMessage` request and one native - `SendStreamingMessage` request; -8. verify the ingress VirtualKey is absent southbound; -9. poll the unified interaction query by stable trace and route dimensions with - a bounded deadline until the matching A2A usage event is persisted; -10. terminate all processes and verify clean exit. - -The standalone daemon receives a narrower smoke scenario: start `agwd` from a -temporary static configuration, prove its Admin/readiness surface is usable, -and stop it cleanly. The test is not required to duplicate every Caddy-path -assertion. - -Requirements: - -- no fixed developer-machine paths or globally reserved ports; -- no outbound network dependency; -- no fixed sleeps used as readiness or persistence guarantees; use bounded - polling for process readiness and asynchronous usage-event persistence; -- failure output includes the relevant child-process logs without exposing - secrets; -- the harness is runnable locally and in GitHub Actions. - -## 6. Work Package S4 — SQLite Upgrade Coverage - -Pin both database initialization paths in automated tests: - -### S4.1 Fresh database - -- initialize an empty SQLite database through the production schema path; -- assert all five usage tables and required indexes exist; -- write and query an A2A interaction through the production sink/query APIs. - -### S4.2 Pre-v0.6 database - -- construct a minimal pre-v0.6 schema fixture without - `a2a_usage_events`; -- retain representative existing LLM, MCP, ACP, and builtin rows; -- start the production migration path; -- assert existing rows are unchanged and the A2A table/indexes are added; -- write an A2A event and verify the SQLite summary, recent-event, and unified - interaction queries can read it; -- independently pass a representative A2A interaction event through the OTLP - adapter and verify the resulting span projection. - -The fixture should be expressed as reviewable schema/test setup rather than a -committed opaque database binary. The test must not depend on running an old -gateway executable. - -## 7. Release Gates - -The final `v0.6.1` candidate must satisfy all of the following: - -- the shared release-verification entry point passes; -- GitHub CI is green on the exact candidate commit; -- the process smoke test passes on Linux from a clean checkout; -- fresh and pre-v0.6 SQLite tests pass; -- the HTTP Agent quick start succeeds against the local example exactly as - written; -- `git diff --check` passes and the worktree is clean; -- no new external network dependency is required by tests; -- `docs/releases/v0.6.1.md` records fixes, verification, and any remaining - limitations; -- website changes, if any, are applied to both English and Chinese pages. - -## 8. Documentation Impact - -Expected permanent-document changes: - -- `README.md` — optional local HTTP Agent example path; -- `docs/getting-started/quickstart-http-agent.md` — self-contained walkthrough; -- `docs/development/README.md` — release verification and smoke commands; -- `.github/workflows/ci.yml` — shared verification/smoke invocation; -- `docs/releases/v0.6.1.md` and `docs/releases/README.md` — delivered outcome. - -Architecture and design documents change only if a verified runtime defect -changes a durable contract. Test mechanics and completed checklist state do not -move into those documents. - -## 9. Deletion Condition - -Delete this plan when `v0.6.1` is tagged and all work packages are either -delivered or explicitly removed from the release scope, permanent docs describe -the resulting behavior, `docs/releases/v0.6.1.md` records the outcome, and the -plan's entries have been removed from both `docs/README.md` and -`docs/plans/README.md`. Git history remains the implementation archive. diff --git a/docs/plans/v0.7.0-community-guardrails.md b/docs/plans/v0.7.0-community-guardrails.md deleted file mode 100644 index 0fd1ea91..00000000 --- a/docs/plans/v0.7.0-community-guardrails.md +++ /dev/null @@ -1,390 +0,0 @@ -# v0.7.0 Community Guardrails Plan - -## 0. Plan Contract - -This plan delivers the first implementation slice of the durable decisions in -[`../design/guardrails.md`](../design/guardrails.md). It targets `v0.7.0` and -is intentionally narrower than the full design. - -The planning baseline is annotated tag `v0.6.0`, commit -`339b15afda5ea7e611de255091bdc6884d52c0e1`. Implementation starts from the -final `v0.6.1` tree and must re-audit package names, interfaces, and insertion -points before the first code commit. When this plan, the design, and the code -disagree, the code establishes current behavior, the design establishes the -durable intended contract, and this execution plan must be corrected. - -The release objective is: - -> provide a useful Community guardrail enforcement kernel for the shared -> provider-facing chat path, with safe rewriting, auditable decisions, and -> complete configuration and management surfaces. - -Each numbered work package is independently reviewable and must keep the full -tree buildable and tested. - -## 1. Committed v0.7.0 Scope - -The release covers the normalized provider-facing `provider.ChatRequest` used -by: - -- OpenAI Chat Completions ingress; -- Anthropic Messages ingress; -- the Claude Code-compatible `cc` profile; -- builtin Agent model calls that resolve through `RoutedProvider`. - -It delivers: - -- a runtime-neutral Guardrail engine and checker registry; -- `allow`, `redact`, and `block` decisions; -- `monitor` and `enforce` policy modes; -- explicit `fail_open` and `fail_closed` checker-failure behavior; -- bounded execution, timeout, cancellation, and panic containment; -- lossless clone-and-rewrite validation for supported chat content; -- a deterministic baseline sensitive-data checker; -- the `topic_filter` reference checker and generic internal-LLM checker - dependency with structured-verdict validation; -- provider `trust_zone` classification and fail-closed validation of every - provider candidate reachable through an internal checker route; -- immutable policy snapshots and route-policy reference validation; -- Gateway Bundle, Admin API, and `agwctl` management surfaces; -- sanitized decision events, SQLite retention/query support, bounded - Prometheus metrics, and OTLP correlation; -- exact capability metadata for supported and unsupported operations. - -## 2. Explicit Non-Goals - -The following remain outside `v0.7.0` even though the design leaves extension -points for them: - -- response or partial-stream inspection; -- OpenAI Responses and embeddings request inspection; -- MCP operation inspection; -- unified Agent `/turn` input inspection; -- native A2A Path A body inspection or rewriting; -- MCP-service trust-zone classification and the global - `require_guardrails_for_external` gate, which require the broader MCP and - operation coverage defined by the design and must not ship as a partial - LLM-only gate; -- prompt-injection products, proprietary DLP/entity packs, policy-as-code, - organization inheritance, or a false-positive review console; -- ACP process sandboxing, filesystem controls, or network egress firewalling; -- durable human review, Memory, Gateway Request Pipelines, or business - Workflows; -- storing raw prompts, matched values, rewritten payloads, checker output, or - secrets in decision events. - -An operation configured to require Guardrails but not present in the published -coverage matrix fails closed. A route without a Guardrail policy preserves its -v0.6 behavior. - -## 3. Work Package G0 — Contract Freeze And Dependency Audit - -Before adding runtime code: - -- re-audit `RoutedProvider`, route snapshots, protocol error mapping, the usage - event pipeline, Gateway Bundle, Admin registration, and CLI command assembly; -- finalize the persisted policy/check shape and route-policy reference shape - defined by the design; -- define stable bounded reason codes and checker error classes; -- define the exact supported chat segment kinds and redaction behavior; -- define provider `trust_zone` normalization and checker-route validation over - direct targets and every logical-model fallback candidate; -- define protocol-specific pre-stream block mapping for OpenAI, Anthropic, and - `cc` without adding a synthetic SSE stream; -- define the dispatcher coverage gate for explicitly protected but unsupported - operations, including OpenAI Responses and local `count_tokens`; -- define how internal LLM checkers call an LLM route without recursive - self-evaluation or a config dependency cycle; -- identify which Guardrail interfaces are protected Community extension - surfaces under `enterprise-extension-contract.md`. - -Deliverables: - -- focused amendments to `docs/design/guardrails.md` only for unresolved durable - decisions; -- a capability matrix used by runtime and Admin surfaces; -- contract tests for reason-code bounds, decision normalization, and unsupported - operation behavior. - -No implementation checklist or milestone state is added to the design page. - -## 4. Work Package G1 — Core Engine And Checker SPI - -Create the runtime-neutral core, expected under `pkg/guardrail/`, without a -dependency on Caddy, dispatcher packages, provider implementations, MCP, ACP, -or `pkg/agent`. - -Core responsibilities: - -- immutable `Input`, `Segment`, `Policy`, `Check`, `Verdict`, and `Decision` - value contracts; -- deterministic policy/check ordering and verdict combination; -- checker registry and factory validation; -- monitor/enforce projection of effective versus proposed action; -- per-check and total evaluation deadlines; -- context cancellation and bounded concurrency; -- panic recovery with bounded error classification; -- explicit fail-open/fail-closed behavior; -- no mutation of caller-owned input. - -Tests must cover ordering, conflicting verdicts, redaction composition, -timeouts, cancellation, panic containment, registry duplication, failure mode, -and race safety. - -## 5. Work Package G2 — Policy, Trust Zones, And Runtime Snapshots - -Make Guardrail policies first-class configuration objects: - -- register the config-store schema and SQLite persistence; -- implement CRUD with create/update/delete reference guards; -- add a manager that validates the complete reference graph before publishing - an immutable snapshot; -- add the durable optional `guardrail_policy_id` binding to the shared - `routecore.AgentRouteConfig` envelope, but accept a non-empty binding in this - release only on eligible LLM routes; -- reject a non-empty policy binding on MCP routes, unified Agent routes, native - A2A routes, and any other operation family outside the published v0.7 - coverage matrix during route create/update and bundle/static validation; - an unsupported binding must never be stored and silently ignored; -- validate checker-route dependencies and reject direct or indirect cycles; -- add `internal | external | unclassified` to provider configuration, normalize - an omitted value to `unclassified`, and carry it through Admin, bundle, - static configuration, and provider snapshots; -- require every direct or logical-model provider candidate reachable through - an internal LLM checker route to be `internal`, including fallback - candidates; reject `external` and `unclassified` dependencies in both - `monitor` and `enforce` modes; -- ensure request hot paths never read ConfigStore; -- refresh only affected policy/route snapshots on mutation. - -Gateway Bundle must support validate, apply, and export for policies and their -route bindings. Credentials and checker secrets remain credential references; -they are never embedded in exported policy YAML. - -Verification includes fresh-store migration, referential integrity, atomic -publication, failed-update rollback, concurrent readers, bundle round-trip, -deletion guards, omitted trust-zone normalization, rejection of policy bindings -on uncovered route kinds/protocols, and rejection when any checker-route -fallback candidate is not internal. - -## 6. Work Package G3 — Community Checkers - -### G3.1 Deterministic sensitive-data checker - -Implement a bounded baseline checker suitable for Community use: - -- operate only on policy-selected normalized user-content segments; -- register the stable checker kind `pii_scan`; -- ship a documented, deterministic set of named common sensitive-value - categories; -- support operator-supplied regular expressions with explicit count, pattern - length, match-count, input-size, and execution bounds; -- return locations and replacement intent without exposing matched values in - logs or events; -- support allow, redact through irreversible mask replacement, and block; never - retain enough replacement metadata to reconstruct the matched value; -- optionally invoke the same bounded internal-LLM checker dependency used by - model-backed checks, with the G3.2 trust-zone and verdict constraints; -- validate overlap and replacement boundaries before applying redaction; -- handle Unicode and structured chat parts without corrupting non-text parts. - -### G3.2 Topic filter and generic internal-LLM checker dependency - -- resolve an operator-selected LLM route through a narrow dependency supplied - to the checker; -- implement the Community `topic_filter` structured verdict and bounded - confidence/reason-code contract; -- accept protected content only after the policy manager has proved that every - provider candidate reachable through the checker route is `internal`; -- require a versioned structured verdict schema; -- reject malformed, oversized, or unknown verdicts; -- enforce its own timeout and token/input bounds; -- prevent recursive Guardrail evaluation and dependency cycles; -- never treat model prose as an executable policy or replacement payload. - -The LLM checker is optional. A deployment using only deterministic checks must -remain fully functional. `monitor` mode uses the same trust-zone validation as -`enforce`; it never sends content to an external or unclassified dependency. - -## 7. Work Package G4 — Typed Chat Inspection And Safe Rewrite - -Build the `provider.ChatRequest` inspection adapter: - -- clone every supported request without sharing mutable content with the - caller; -- project only policy-declared user-content segments; -- preserve roles, ordering, tool-call identity, tool results, images, and - protocol state not selected for inspection; -- apply redaction only to validated text ranges; -- reject a rewrite that cannot be represented losslessly; -- publish precise coverage metadata for unsupported part/state combinations. - -Tests use request fixtures from OpenAI, Anthropic, `cc`, tool-use continuation, -multimodal, and builtin model-call paths. Round-trip tests prove that an -`allow` decision is byte/field neutral at the normalized provider contract and -that redaction changes only approved text. - -## 8. Work Package G5 — Coverage Gate And RoutedProvider Enforcement - -Before provider or local execution, add a dispatcher coverage gate for routes -with an explicit Guardrail policy: - -- supported chat operations proceed to the `RoutedProvider` evaluation hook; -- unsupported OpenAI Responses operations fail closed before - `CreateResponses` or `StreamResponses` can select or call a provider; -- unsupported local operations such as `cc` `count_tokens` fail closed before - local execution when the route explicitly binds a Guardrail policy; -- without an explicit Guardrail policy, local and provider operations preserve - their v0.6 behavior; -- the deferred global external-target gate does not implicitly classify a - local, no-egress operation. - -Insert evaluation at the start of `pkg/gateway.RoutedProvider.ExecuteChat` and -`ExecuteStreamChat`, after attached request requirements have been validated -and before `executeWithFallback` can select a credential or invoke a provider. -The compatibility `Chat` and `StreamChat` wrappers must continue to delegate to -those guarded execution methods rather than introducing a second hook. - -Required invariants: - -- evaluate the cloned request once per routed execution, not once per provider - fallback candidate; multiple model executions within one Agent turn, - including later tool-result continuations, are evaluated independently; -- reuse the accepted or rewritten request for every fallback attempt; -- do not select a credential or call a protected provider after a block; -- preserve existing candidate filtering, credential scheduling, retry, native - relay, trace, and usage behavior; -- builtin `Generate`, `Stream`, and `WithTools` model calls reach the same hook; -- a route without a bound policy incurs only a snapshot lookup and preserves - existing behavior; -- checker cancellation follows client/request cancellation. - -The typed block error must map through the existing pre-stream JSON error path -for OpenAI, Anthropic, and `cc`. No handler starts an SSE response solely to -report a Guardrail rejection. - -End-to-end tests cover batch and streaming requests, fallback, monitor mode, -enforce allow/redact/block, checker failure modes, builtin nested model calls, -and proof that blocked traffic never reaches the upstream server. - -## 9. Work Package G6 — Admin, Bundle, And CLI Surfaces - -Implement the management contract from the design: - -- policy CRUD under `/admin/guardrails/policies`; -- provider trust-zone configuration and validation through the existing - provider Admin and bundle surfaces; -- capability and sanitized recent-decision inspection under - `/admin/guardrails/runtime/...`; -- Gateway Bundle policy/binding validation, apply, and export; -- `agwctl guardrails` list/get/capabilities/decisions/validate commands; -- declarative mutation remains through `agwctl apply`; -- `cmd/agw`, `cmd/agwd`, and `cmd/agwctl` blank-import the same Community checker - registry; local `agwctl apply` and `agwctl guardrails validate` recognize the - same built-in checker kinds exposed by the Caddy and standalone assemblies. - -Admin responses distinguish invalid policy, missing dependency, dependency -cycle, unsupported operation, checker unavailable, and snapshot-not-ready -without returning raw inspected content. - -## 10. Work Package G7 — Decision Observability - -Emit one sanitized decision event for each evaluated operation through the -existing asynchronous event pipeline. - -Persist and export only bounded metadata defined by the design, including: - -- interaction trace/span linkage and available route, Agent, run, and - VirtualKey attribution; -- policy ID/version/mode; -- protocol and operation; -- proposed and effective action; -- egress-performed flag; -- bounded reason code; -- per-check kind, outcome, latency, and bounded error class. - -Add: - -- a dedicated SQLite event table, migration, retention, and bounded queries; -- Admin recent-decision queries; -- OTLP span/event projection; -- Prometheus counters and latency histograms using bounded labels only; -- pipeline drop/failure coverage consistent with existing usage events. - -Tests prove that prompts, matches, replacements, checker responses, -credentials, and arbitrary error strings cannot reach storage, metrics labels, -or exported attributes. - -## 11. Work Package G8 — Documentation And Release Closure - -After runtime behavior is complete: - -- re-scope `docs/design/guardrails.md` so its page-level capability status and - coverage matrix describe the delivered contract; keep undelivered extensions - explicitly separated as Proposed rather than marking the full original scope - Implemented; -- add current runtime flow to architecture documentation; -- add a first-run Guardrails guide and configuration/reference pages; -- update bundle, Admin API, CLI, README, root/nested `AGENTS.md`, and example - configuration; -- update both language versions of every affected website page; -- add `docs/releases/v0.7.0.md` with compatibility notes, coverage matrix, - limitations, and verification evidence; -- remove claims that imply response, MCP, Agent, or native A2A inspection when - those paths remain unsupported. - -## 12. Acceptance Gates - -The release candidate must satisfy: - -- the shared non-mutating formatting check reports no tracked Go files that - require formatting; -- `go mod verify`, `go test ./...`, the repository race suite, `go vet ./...`, - and `make build` pass; -- policy/check/config managers pass concurrent refresh and race tests; -- no ConfigStore read occurs on the request hot path; -- allow/redact/block and monitor/enforce/fail-open/fail-closed each have unit - and end-to-end coverage; -- every supported protocol returns its native pre-stream block shape; -- a blocked request provably makes zero protected upstream calls; -- fallback evaluates once and reuses one validated rewrite; -- every provider candidate reachable through an internal checker route is - classified `internal`, including monitor mode and fallback candidates; -- route and bundle validation reject non-empty policy bindings on MCP, Agent, - native A2A, and every other operation outside the v0.7 coverage matrix; -- builtin nested and resumed model calls reach the same enforcement hook; -- explicitly protected Responses and local operations fail closed before - provider or local execution and appear accurately in capability metadata; -- decision storage/export contains no inspected content or unbounded labels; -- bundle round-trip, Admin API, `agwctl`, Caddy, and standalone modes agree; -- `agw`, `agwd`, and `agwctl` expose or validate the same Community checker - registry; -- upgrade tests preserve existing configuration and usage data; -- all permanent docs and paired website translations match the delivered - coverage. - -## 13. Recommended Commit Boundaries - -Use at least these semantic commits; split further when reviewability requires: - -1. `feat(guardrail): add bounded evaluation core` -2. `feat(guardrail): persist policies and publish snapshots` -3. `feat(guardrail): add community checkers` -4. `feat(guardrail): inspect and rewrite chat requests` -5. `feat(gateway): enforce guardrails in routed providers` -6. `feat(admin): expose guardrail management` -7. `feat(observability): persist guardrail decisions` -8. `test(guardrail): close end-to-end coverage` -9. `docs(guardrail): publish v0.7 operator contracts` - -Every commit must pass targeted tests; commits that change shared request or -event contracts also run the full suite before landing. - -## 14. Deletion Condition - -Delete this plan after `v0.7.0` is tagged, every committed scope item is -delivered or explicitly re-homed into a future Proposed design, and permanent -design, architecture, guide, reference, `AGENTS.md`, website, and release-note -documents describe the resulting behavior. Remove the plan's entries from both -`docs/README.md` and `docs/plans/README.md` as part of deletion. Git history is -the implementation record. diff --git a/docs/plans/v0.7.0-enterprise-extension-foundation.md b/docs/plans/v0.7.0-enterprise-extension-foundation.md new file mode 100644 index 00000000..4c3a825b --- /dev/null +++ b/docs/plans/v0.7.0-enterprise-extension-foundation.md @@ -0,0 +1,95 @@ +# v0.7.0 Extension Foundation — Release Closure + +## Scope and current state + +The F0–F6 foundation implementation and F7 permanent integration documentation +are complete as candidates. Implementation history remains in Git; this active +plan now tracks release acceptance. The confirmed ConfigStore-only extension +configuration principle is implemented: API/Bundle apply manage persisted +envelopes, and static extension configuration paths have been removed. +It does not add model high availability or other domain products to v0.7. + +Permanent sources of truth: + +- [Enterprise Extension Contract](../design/enterprise-extension-contract.md): + ownership, lifecycle, protected designation and proposed domain coverage; +- [architecture overview](../architecture/architecture-overview.md): current wiring; +- [custom distribution guide](../guides/custom-distribution.md): external assembly; +- [Bundle guide](../guides/bundle-yaml.md), [Admin reference](../reference/admin-api-reference.md), + [CLI reference](../reference/agwctl-reference.md) and + [Caddyfile reference](../reference/caddyfile-reference.md): configuration operations; +- [v0.7 release notes](../releases/v0.7.0.md): exact designated symbols, migration, + limitations and verification sequence; +- package documentation and root/nested `AGENTS.md`: implementation invariants. + +The paired repository owns its three entries, non-commercial fixture, +compatibility manifest, integration documents and release build. Its dependency +already declares the target `v0.7.0` with no checked-in replacement/workspace; +resolved released-tag checksums and acceptance remain pending publication. + +## Candidate evidence and reproducibility + +Earlier candidate gates passed before local history was consolidated. Their +squashed commit IDs are not release evidence that a fresh clone can reproduce. +The reachable `4ff2478` contains the same Git tree as the previously verified +candidate `40f1538`: full verification, smoke, immutable consumer and paired +consumer gates passed for that tree on 2026-10-13. This is historical content +verification, not acceptance of the final release revision. + +Release preparation also sets Community edition and built-in module diagnostic +versions to `0.7.0`, updates contributor gate documentation and retires the +completed v0.6.1 plan. Run all gates again on the final clean revision: + +```sh +make verify-release +make smoke-release +make test-extension-consumer-candidate COMMUNITY_COMMIT="$(git rev-parse HEAD)" +# In the paired repository, with the same full Community revision: +./scripts/verify-community candidate ../agent-gateway +``` + +Record the exact revision and results in the release pull request and paired +compatibility manifest. The pull request and main CI provide remotely reachable +revision evidence; local backup refs are not required for reproduction. + +## ConfigStore-only configuration + +The implemented principle is recorded in +[Enterprise Extension Contract, section 11.3.1](../design/enterprise-extension-contract.md#1131-principle-host-independent-extension-configuration). +Caddyfile owns Caddy host wiring; internal extensions use only ConfigStore in +both server modes. API and Bundle apply retain dependency validation, failure +isolation, secret-reference preservation and generation leases. Static extension +inputs, source metadata and static ownership conflicts have been removed. +Provider, route and other resource configuration ownership is unchanged. + +Process contracts configure extensions through API/Bundle apply and restart +both hosts to verify persisted envelopes and prepared Admin resources. +Static-input rejection, dependency and mutation failure isolation, +protocol/stream/cancellation/usage and sink lifecycle checks remain candidate +proof until the published dependency gates pass. + +## Remaining release work, in dependency order + +1. Select the exact clean release commit; both repository-owned Community gates + and remote CI must pass for that source. Publish the Community `v0.7.0` tag. +2. Run `make test-extension-consumer-release` after publication. Its fresh caches, + pinned published dependency and `GOWORK=off` must succeed without replacements. +3. In the paired repository, resolve the published tag into `go.mod`/`go.sum`, + run `scripts/verify-community release` from fresh caches and record actual + tag/commit, command, toolchain and result in its compatibility manifest. +4. Verify paired release CI for that dependency, then declare only the designated + package/symbol/tag surfaces protected. No repository-wide Enterprise-ready claim. +5. Delete both foundation plans and remove their index entries after permanent + documents and manifests describe the accepted release. + +The remote has no `v0.7.0` tag as checked on 2026-10-13. Local tags and candidate +replacements cannot satisfy published dependency acceptance. Neither a hosted +release nor a remote CI result is inferred from local checks. Commercial module, +license, SBOM and customer delivery gates remain independently owned. + +## Deletion condition + +Delete this plan only after the Community tag is published, both consumer +published-tag gates pass and the paired compatibility manifest records the +accepted dependency. Remove its entries from `docs/README.md` and +`docs/plans/README.md`; Git history retains implementation and acceptance work. diff --git a/docs/reference/admin-api-reference.md b/docs/reference/admin-api-reference.md index 8faef1f1..2be00223 100644 --- a/docs/reference/admin-api-reference.md +++ b/docs/reference/admin-api-reference.md @@ -2,6 +2,37 @@ All endpoints below are under the path where `agent_gateway_admin` is mounted. The Admin API does not implement login or sessions; protect this mount with Caddy `basic_auth`, mTLS, a reverse proxy authenticator, or the standalone daemon's `--admin-basic-auth-hash` wrapper. +All routes read and check the complete body before dispatch, up to 4,194,304 +received bytes. Oversize requests (including chunked bodies and trailing bytes) +return HTTP 413 with `{"error":"request body exceeds 4194304 bytes"}`. Compressed +bodies return HTTP 415. Bundle apply sends one resource per request, so this limit +applies to each resource. Panic responses use generic JSON HTTP 500 errors. + +Linked modules may contribute routes only under +`/admin/extensions//...`. Module roots remain envelope CRUD, and all +other Community prefixes are reserved. Contributions share the authentication +mount, CORS and body policy. Unconfigured module resources return HTTP 503 with +`extension_unavailable`. Module readiness is included in `/admin/health`. + +## Manifest and extension configuration + +- `GET /admin/manifest`: deterministic linked runtime manifest. +- `GET /admin/extensions`: persisted envelope list in `items`. +- `GET /admin/extensions/{module_id}`: one reference-preserving envelope. +- `PUT /admin/extensions/{module_id}`: strict envelope body with matching + `module_id`, `schema_version` and module-owned `config`; returns the committed + envelope. +- `DELETE /admin/extensions/{module_id}`: deletes persisted configuration and + returns `status: deleted` and `id`. + +Extensions are owned solely by ConfigStore; configure them through this API or +Bundle apply. Static extension configuration is unsupported. Envelopes contain no +`source` ownership field. Missing envelopes return 404. Unknown +module/schema or invalid config errors return bounded 400 diagnostics. Missing +or cyclic dependencies return 409; preparation, secret resolution, persistence +or lifecycle unavailability returns 503. Rejected writes retain the active +generation. Secret values never appear in these envelope responses. + ## Health - `GET /admin/health` @@ -131,10 +162,21 @@ VirtualKey notes: - `key` is the bearer credential value clients must send on requests - `created_at` and `updated_at` are server-managed fields - optional `rate_limits.llm`, `rate_limits.mcp`, and `rate_limits.agent` entries - enforce in-memory request token buckets; both values must be greater than zero -- `agent` applies to independent ACP and builtin buckets; omitted entries are + enforce request token buckets through the selected backend (local memory by + default); both values must be greater than zero +- `agent` applies to unified Agent ingress, including ACP, builtin and HTTP/A2A; omitted entries are unlimited -- exceeded requests return `429 Too Many Requests` and `Retry-After` +- exceeded requests return `429 Too Many Requests` and rounded-up `Retry-After` +- backend failure/timeout returns bounded `503 {"error":"limiter_unavailable"}`; + explicit `failure_mode: open` permits degraded admission; cancellation never does +- configure the linked backend with schema-1 extension + `online.agentguide.community.limiter`: `backend_module_id`, `backend_type`, + `timeout_ms` (1–1000; default 100), `failure_mode` (`closed` default or `open`); + `{}` selects Community memory. An external backend must also be configured +- unchanged dimensions retain tokens; changed policies or backend switches reset + without transfer. Events report `state_reset`; admission is never retried +- `GET /admin/metrics/prometheus` includes `agentgateway_limiter_admissions_total` + with `dimension`, `outcome` and bounded `backend_type` labels ## Upstream Credentials diff --git a/docs/reference/agwctl-reference.md b/docs/reference/agwctl-reference.md index 5242300c..6a75ffd2 100644 --- a/docs/reference/agwctl-reference.md +++ b/docs/reference/agwctl-reference.md @@ -128,3 +128,22 @@ diagnostic commands do not provide a second session-keyed cancellation path. - [admin-api-reference.md](admin-api-reference.md) - [acp-api.md](acp-api.md) - [../design/gateway-bundle-yaml.md](../design/gateway-bundle-yaml.md) + +## Linked manifests and extension envelopes + +```sh +./agwctl manifest +./agwctl --admin-addr http://localhost:8019 --admin-basic-auth admin:your-password manifest --remote +``` + +Local output describes linked validation metadata; remote output is the server +runtime manifest. `agw manifest` and `agwd manifest` describe their linked runtime +metadata without starting a server. Metadata is deterministic and contains no +factory callbacks, resolved secrets or entitlement state. + +Bundle `extensions` use `module_id`, `schema_version` and module-owned `config`. +Validate uses local linked schemas; apply performs dependency-ordered extension +writes before other objects, reports partial results and stops on extension +failure. Export includes persisted extension envelopes and retains secret reference +tokens in reference-preserving configuration. The server is the final validator. +See the [Bundle guide](../guides/bundle-yaml.md#linked-extension-configuration). diff --git a/docs/reference/caddyfile-reference.md b/docs/reference/caddyfile-reference.md index 9ba9687d..6ccfd543 100644 --- a/docs/reference/caddyfile-reference.md +++ b/docs/reference/caddyfile-reference.md @@ -221,3 +221,12 @@ If a route sets `require_virtual_key`, create VirtualKeys through the Admin API - [../guides/admin-auth.md](../guides/admin-auth.md) - [runtime-modes.md](runtime-modes.md) - [admin-api-reference.md](admin-api-reference.md) + +## Internal extensions configuration + +Caddyfile configures the Caddy host, module wiring and gateway bootstrap +connections. Internal extensions are managed independently through +`/admin/extensions/{module_id}` or Gateway Bundle apply and persisted in ConfigStore. +Caddyfile has no extension configuration directive; Caddy JSON also rejects an +`extensions` field on `agent_gateway`. Contributed Admin paths inherit the +configured `agent_gateway_admin` mount and its authentication. diff --git a/docs/releases/README.md b/docs/releases/README.md index 016420e2..7c5327b1 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -20,3 +20,5 @@ Current release notes: - [v0.6.1.md](v0.6.1.md) - [v0.6.0.md](v0.6.0.md) + +- [v0.7.0.md](v0.7.0.md) — unreleased extension foundation candidate diff --git a/docs/releases/v0.7.0.md b/docs/releases/v0.7.0.md new file mode 100644 index 00000000..03722d6f --- /dev/null +++ b/docs/releases/v0.7.0.md @@ -0,0 +1,121 @@ +# v0.7.0 + +Release status: **Unreleased candidate**. This document is the source for the +hosted release description when v0.7.0 is published. Candidate contract evidence +does not establish published-tag compatibility. + +v0.7.0 adds a reusable extension foundation for independently compiled +distributions while preserving Community gateway protocol ownership. It does +not deliver commercial modules, enterprise IAM or a general plugin platform. + +## Added and changed + +- Public Caddy, standalone and CLI entry packages with independent command state, + explicit linked-module assembly and public metrics options. +- Deterministic runtime/validation manifests, local `manifest` commands and + authenticated `/admin/manifest` queries. Community edition and built-in module + metadata report version `0.7.0`; these diagnostic values do not grant entitlements + or establish released-tag compatibility. +- Registered extension envelopes owned solely by the existing ConfigStore, + dynamic `/admin/extensions/{module_id}` CRUD, dependency-ordered CLI apply and + reference-preserving export. Strict local validation is separate from server + preparation; failed preparation/persistence preserves the serving generation. +- Trusted bounded attribution for identity and runtime correlation, plus two + sanitized public operational event families with isolated bounded sink queues, + cancellation, flush/shutdown behavior and health/drop/failure reporting. +- Module-owned Admin subroutes under `/admin/extensions//...`, with + generation leases and the same mount authentication and middleware as Community. +- VirtualKey limiter backend selection for LLM/MCP/Agent admission, atomic consume, + generation fencing, secure failure defaults and preserved unchanged policy state. +- Public contract suites and a separate-module consumer that builds all three + entries and exercises both server modes. Independently maintained consumers + can run the same contracts without importing Community internals. + +## Designated compatibility surfaces + +Only the following symbols and their documented exported value/callback fields +are designated for protection once the containing tag and consumer gates pass: + +| Package | Designated symbols | +| --- | --- | +| `pkg/distribution` | `Edition`, `Assembly`, `Assembly.Clone/Registry/Manifest/AttributionPolicy`, `MetricsConfig`, `OTLPConfig` | +| `pkg/distribution/caddycmd` | `Command`, `New`, `Command.Assembly/Manifest/Run` | +| `pkg/distribution/daemoncmd` | `Options`, `ServerOptions`, `NewCommand`, `Run` | +| `pkg/distribution/ctlcmd` | `Options`, `NewCommand`, `Run` | +| `pkg/extension` values | `Descriptor`, `Error` (`Code`, `ModuleID`, `Field`, `Error`), `Manifest`, `Registration`, `Factory`, `Resource`, `StatefulResource`, `Envelope`, `PreparedConfig`, `Snapshot`, `Lease`, `Persistence`, `Registry`, `Manager` | +| `pkg/extension` operations | `NewRegistry`, `NewManager`, `Registry.Manifest/Validate/ExpandConfig`, `Snapshot.Config/Resource/Context/Sequence`, `Lease.Release`, `Manager.Acquire/List/Manifest/Put/Delete/Health/Close/SetPublisher` | +| `pkg/extension` Admin | `AdminRoute`, `OwnedAdminRoute`, `AdminDependencies`, `AdminContributor`, `WriteAdminError`, `Registry.AdminRoutes`, `Manager.AdminRoutes/AdminPublisher` | +| `pkg/attribution` | `Value`, `Policy`, `NewPolicy`, `Policy.Validate`, `WithContext`, `FromContext`, `WithCorrelation`, `Producer`, `Error` | +| `pkg/event` | `Envelope`, `LimiterAdmission`, `ExtensionMutation`, `Sink`, `Publisher`, `Validate`, `Error`, `Registration`, `Options`, `Stats`, `Delivery`, `NewDelivery`, `Delivery.Publish/Flush/Close/Stats`, `SchemaVersion`, `MaxEnvelopeBytes` | +| `pkg/gateway/virtualkey/limiter` | `Dimension` and its LLM/MCP/Agent constants, `Policy`, `Clock`, `LimiterRequest`, `LimiterDecision`, `LimiterBackend`, `LimiterProvider`, `Config`, `ModuleID`, `MemoryBackendType`, `Factory`, `NewMemoryBackend`, `MemoryBackend.Admit/Retire/Health/Close`, `ErrLimiterUnavailable`, `ErrLimiterRetired` | + +The exact contracts live in package documentation and the +[Enterprise Extension Contract](../design/enterprise-extension-contract.md). +Other exported helpers, Community metadata builders, limiter `Selection`, +provider/protocol/route/credential/ConfigStore registries and APIs, +`internal/observability` types and test helpers are not additional protected +production APIs. Protection is package/symbol/tag-specific, not repository-wide. + +## Compatibility and migration + +All Admin routes now validate the complete received body before dispatch. Bodies +over 4 MiB return bounded JSON 413; compressed bodies return 415. Bundle apply +has a per-resource HTTP boundary, not a total local YAML-file boundary. Generic +panic responses return JSON 500 without arbitrary panic diagnostics. + +Internal extension configuration is independent of Caddyfile. Configure it through +Admin API or Bundle apply. Caddyfile's extension directive and the Caddy app JSON +`extensions` field have been removed; standalone static Bundles reject non-empty +`extensions`. Move those envelopes into a Bundle and apply them through an +authenticated Admin mount. Startup/reload never automatically imports them into +the store. `extension.NewManager` now accepts only context, registry and +persistence; `BootstrapOptions.StaticExtensions`, `Envelope.Source` and +`GatewayBundle.ValidateForStaticConfigWithRegistry` have been removed from the +candidate APIs. Other resource families retain their existing configuration rules. + +Configuration references use declared `{"$secret_ref":"env:NAME"}` fields; +literal secrets and undeclared reference objects fail. Local validation checks +syntax without requiring the environment variable. Server preparation resolves +separately, retaining reference tokens in storage/export. + +The built-in limiter remains the default. Selection schema 1 accepts a linked, +configured backend, `timeout_ms` from 1–1000 (default 100), and `failure_mode` +(default `closed`). Exhaustion returns 429, backend failure returns bounded 503, +and explicit `open` mode permits degraded admission. Cancellation, shutdown and +retired policy errors never fail open. Ambiguous consumes are never retried. +Backend or policy changes reset/fence affected generations; unchanged dimensions +and unrelated prepared-handle refreshes preserve quota state. + +## Limits + +Only `limiter.admission` and `extension.mutation` public events are designated; +they are bounded to 16 KiB and delivered best effort, with no durable audit, +replay or full usage-export contract. Attribution does not implement IAM or +tenant isolation. The reference limiter is process-local; no production Redis +backend is supplied. Registration does not grant general request interception. + +Guardrails, policy/identity/secret/retrieval SPIs, model health/circuit/selection +extensions, token/cost budgets, memory and durable workflows are not delivered +by this release. Existing logical-model fallback remains available with its +current stream-opening and credential semantics. See the +[domain ownership matrix](../design/enterprise-extension-contract.md#12-domain-extension-coverage-and-ownership). + +The root race gate retains the known upstream `eino-ext/claude` streaming race +exclusion for `pkg/llm/provider/anthropic`. + +## Verification and release sequence + +Before tagging, run on the exact clean Community candidate: + +```sh +make verify-release +make smoke-release +make test-extension-consumer-candidate COMMUNITY_COMMIT= +``` + +The paired consumer then runs its exact-commit candidate gate. After publishing +the Community tag, run `make test-extension-consumer-release` with fresh caches; +the paired repository pins the released dependency, resolves its checksums and +runs its own fresh-cache release gate. Record the results in the paired +compatibility manifest before claiming published-tag protection. Candidate +results cannot replace these final online checks. diff --git a/examples/Caddyfile.example b/examples/Caddyfile.example index 32230054..42ac7992 100644 --- a/examples/Caddyfile.example +++ b/examples/Caddyfile.example @@ -27,6 +27,15 @@ path ./data/configstore.db } + # Optional limiter selection envelope (schema 1). With no envelope, + # Community memory uses a 100 ms budget and fail-closed behavior. + # Configure envelopes through Admin API or agwctl apply; Caddyfile only wires the host. + # For an external backend, set backend_module_id/backend_type and configure + # its linked dependency too. Community includes no production Redis backend. + # Use a Bundle extensions entry for online.agentguide.community.limiter. + # Exhaustion returns 429; failures return bounded 503. Explicit open mode + # is degraded; cancellation and retired policies never fail open. No retries. + # External command for request-time OAuth token refresh. Defaults to # agw-auth; set an absolute path when it is not available on PATH. credential_refresh_command agw-auth refresh @@ -174,6 +183,10 @@ http://localhost:8019 { # Admin routes + # All Admin bodies are fully checked before dispatch: 4 MiB maximum (413), + # compressed bodies unsupported (415). Linked module Admin subroutes under + # /admin/extensions//... inherit this mount's authentication. + # Module mutation events capture trusted request identity at Admin dispatch. route /admin/* { # Exempt the public health probe and credential-free CORS preflight. @needauth { @@ -206,3 +219,6 @@ http://127.0.0.1:8080 { agent } } + +# Internal extension configuration is persisted through Admin API / Bundle apply. +# Community rejects modules absent from its linked registry. diff --git a/internal/distributionmetrics/metrics.go b/internal/distributionmetrics/metrics.go new file mode 100644 index 00000000..135f33f9 --- /dev/null +++ b/internal/distributionmetrics/metrics.go @@ -0,0 +1,14 @@ +// Package distributionmetrics adapts public entry values to internal usage wiring. +package distributionmetrics + +import ( + "github.com/agent-guide/agent-gateway/internal/observability/usage" + "github.com/agent-guide/agent-gateway/pkg/distribution" + "maps" +) + +func From(c distribution.MetricsConfig) usage.Config { + return usage.Config{RetentionDays: c.RetentionDays, MaxAgentDepth: c.MaxAgentDepth, OTLP: usage.OTLPConfig{ + Endpoint: c.OTLP.Endpoint, Protocol: c.OTLP.Protocol, Insecure: c.OTLP.Insecure, Headers: maps.Clone(c.OTLP.Headers), ServiceName: c.OTLP.ServiceName, TimeoutSeconds: c.OTLP.TimeoutSeconds, Components: c.OTLP.Components, + }} +} diff --git a/internal/distributionruntime/assembly.go b/internal/distributionruntime/assembly.go new file mode 100644 index 00000000..b3472e1b --- /dev/null +++ b/internal/distributionruntime/assembly.go @@ -0,0 +1,33 @@ +// Package distributionruntime carries the process-scoped Caddy assembly. +// Installation occurs only in the executable entry, never via configuration. +// Standalone and CLI pass their assembly explicitly without using this slot. +package distributionruntime + +import ( + "github.com/agent-guide/agent-gateway/pkg/distribution" + "sync" +) + +var slot struct { + sync.RWMutex + assembly *distribution.Assembly +} + +func Install(a distribution.Assembly) error { + if _, err := a.Registry(); err != nil { + return err + } + a = a.Clone() + slot.Lock() + defer slot.Unlock() + slot.assembly = &a + return nil +} +func Current() distribution.Assembly { + slot.RLock() + defer slot.RUnlock() + if slot.assembly == nil { + return distribution.CommunityRuntime() + } + return slot.assembly.Clone() +} diff --git a/internal/extensionstore/store.go b/internal/extensionstore/store.go new file mode 100644 index 00000000..e4ba097b --- /dev/null +++ b/internal/extensionstore/store.go @@ -0,0 +1,46 @@ +// Package extensionstore adapts the existing generic ConfigStore, keeping its +// implementation types out of the protected extension lifecycle contract. +package extensionstore + +import ( + "context" + "fmt" + "github.com/agent-guide/agent-gateway/pkg/configstore" + "github.com/agent-guide/agent-gateway/pkg/configstore/schema" + "github.com/agent-guide/agent-gateway/pkg/extension" +) + +type Store struct{ store configstore.ConfigStore } + +func New(backend configstore.ConfigStoreBackend) (*Store, error) { + s, err := backend.Get(schema.StoreExtensions) + if err != nil { + return nil, err + } + return &Store{s}, nil +} +func (s *Store) List(ctx context.Context) ([]extension.Envelope, error) { + items, err := s.store.List(ctx) + if err != nil { + return nil, err + } + out := make([]extension.Envelope, 0, len(items)) + for _, item := range items { + switch e := item.(type) { + case *extension.Envelope: + out = append(out, *e) + case extension.Envelope: + out = append(out, e) + default: + return nil, fmt.Errorf("invalid stored extension") + } + } + return out, nil +} +func (s *Store) Put(ctx context.Context, e extension.Envelope, create bool) error { + if create { + return s.store.Create(ctx, &e) + } + return s.store.Update(ctx, &e) +} +func (s *Store) Delete(ctx context.Context, id string) error { return s.store.Delete(ctx, id) } diff --git a/internal/extensionstore/store_test.go b/internal/extensionstore/store_test.go new file mode 100644 index 00000000..235b8a40 --- /dev/null +++ b/internal/extensionstore/store_test.go @@ -0,0 +1,52 @@ +package extensionstore + +import ( + "context" + "encoding/json" + "github.com/agent-guide/agent-gateway/pkg/configstore" + "github.com/agent-guide/agent-gateway/pkg/configstore/schema" + sqlite "github.com/agent-guide/agent-gateway/pkg/configstore/sqlite" + "github.com/agent-guide/agent-gateway/pkg/extension" + "go.uber.org/zap" + "path/filepath" + "strings" + "testing" +) + +func TestSQLiteEnvelopeRoundTrip(t *testing.T) { + ctx := context.Background() + backend, err := configstore.OpenBackend(ctx, "sqlite", sqlite.Config{SQLitePath: filepath.Join(t.TempDir(), "config.db")}, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + if err = schema.RegisterDefaultStores(backend); err != nil { + t.Fatal(err) + } + store, err := New(backend) + if err != nil { + t.Fatal(err) + } + e := extension.Envelope{ModuleID: "example.fixture.secret", SchemaVersion: 1, Config: json.RawMessage(`{"token":{"$secret_ref":"env:SQLITE_F2_SECRET"}}`)} + t.Setenv("SQLITE_F2_SECRET", "private-value") + if err = store.Put(ctx, e, true); err != nil { + t.Fatal(err) + } + items, err := store.List(ctx) + if err != nil || len(items) != 1 { + t.Fatal(err) + } + raw, _ := json.Marshal(items) + if strings.Contains(string(raw), "private-value") { + t.Fatalf("persisted runtime value: %s", raw) + } + if err = store.Put(ctx, e, false); err != nil { + t.Fatal(err) + } + if err = store.Delete(ctx, e.ModuleID); err != nil { + t.Fatal(err) + } + items, err = store.List(ctx) + if err != nil || len(items) != 0 { + t.Fatal(err) + } +} diff --git a/internal/observability/AGENTS.md b/internal/observability/AGENTS.md index 0ecbeaa8..87007f4a 100644 --- a/internal/observability/AGENTS.md +++ b/internal/observability/AGENTS.md @@ -45,3 +45,17 @@ operational descriptions in the architecture documentation rather than here. `standalone/server/server.go`. - `metrics.max_agent_depth` rejects ingress when `X-Agent-Depth` reaches the configured limit; zero disables the gate. + +## Public event boundary + +The existing pipeline is the single authority for public DTO publishing and +usage accounting. Public sinks receive only `pkg/event` envelopes, never `any` +usage rows. Each sink has one bounded worker; production writes never allocate +one goroutine per callback. Slow/erroring sinks are isolated from each other and +usage accounting. Counts and health remain observable in metrics summaries. +Write/Flush/Close honor contexts; in-flight flush inherits the shutdown deadline. +Close stops publication, drains and closes once; concurrent shorter waiters do not +change the first caller's common budget. Contract probes reject stalled sinks. +Runtime dimension updates carry bounded attribution without replacing trusted +subject/tenant and allowlisted attributes. Keep arbitrary client identity headers +out of trusted context and all secret/config/content fields out of public DTOs. diff --git a/internal/observability/pipeline/pipeline.go b/internal/observability/pipeline/pipeline.go index d6644b31..107cfc7e 100644 --- a/internal/observability/pipeline/pipeline.go +++ b/internal/observability/pipeline/pipeline.go @@ -1,8 +1,12 @@ package pipeline import ( + "context" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/event" "sync" "sync/atomic" + "time" ) type Sink interface { @@ -17,8 +21,13 @@ type EventPipeline struct { closed chan struct{} closeMu sync.RWMutex closing bool + public []*event.Delivery + policy attribution.Policy + shutdownDone chan struct{} + shutdownErr error dropped atomic.Uint64 writeFailures atomic.Uint64 + rejected atomic.Uint64 } func NewEventPipeline(size int, sinks ...Sink) *EventPipeline { @@ -64,38 +73,180 @@ func (p *EventPipeline) DroppedEvents() uint64 { if p == nil { return 0 } - return p.dropped.Load() + total := p.dropped.Load() + for _, s := range p.PublicStats() { + total += s.Dropped + } + return total } func (p *EventPipeline) WriteFailures() uint64 { if p == nil { return 0 } - return p.writeFailures.Load() + total := p.writeFailures.Load() + for _, s := range p.PublicStats() { + total += s.Failures + } + return total +} + +// ConfigurePublic installs per-sink transports before the pipeline serves. +func (p *EventPipeline) ConfigurePublic(policy attribution.Policy, sinks []event.Registration) error { + p.closeMu.Lock() + defer p.closeMu.Unlock() + if p.closing || len(p.public) > 0 { + return &event.Error{Code: "invalid_sink_options"} + } + p.policy = policy + cleanup := func(s event.Sink) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if s != nil { + if d, err := event.NewDelivery(s, event.Options{Policy: policy}); err == nil { + _ = d.Close(ctx) + } + } + for _, d := range p.public { + _ = d.Close(ctx) + } + p.public = nil + } + for _, r := range sinks { + // Validate options before constructing a resource so no rejected registration + // can strand an initialized sink outside a bounded lifecycle worker. + if r.New == nil || r.QueueSize < 0 || r.QueueSize > 4096 || r.WriteBudget < 0 || r.WriteBudget > time.Second { + cleanup(nil) + return &event.Error{Code: "invalid_sink_options"} + } + sink, err := createSink(r) + if err != nil { + cleanup(sink) + return &event.Error{Code: "sink_initialization_failed"} + } + d, err := event.NewDelivery(sink, event.Options{QueueSize: r.QueueSize, WriteBudget: r.WriteBudget, Policy: policy}) + if err != nil { + cleanup(sink) + return err + } + p.public = append(p.public, d) + } + return nil +} +func createSink(r event.Registration) (s event.Sink, err error) { + defer func() { + if recover() != nil { + err = &event.Error{Code: "sink_initialization_failed"} + } + }() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + s, err = r.New(ctx) + if err == nil && ctx.Err() != nil { + err = ctx.Err() + } + return } -func (p *EventPipeline) Close() error { +// Publish is the public boundary of the same authority that owns usage events. +// It only accepts the two explicit sanitized DTO families, never internal usage. +func (p *EventPipeline) Publish(ctx context.Context, e event.Envelope) bool { + if p == nil { + return false + } + p.closeMu.RLock() + defer p.closeMu.RUnlock() + if event.Validate(e, p.policy) != nil { + p.rejected.Add(1) + return false + } + if p.closing { + return false + } + accepted := false + for _, d := range p.public { + if d.Publish(ctx, e) { + accepted = true + } + } + return accepted +} +func (p *EventPipeline) PublicStats() []event.Stats { + p.closeMu.RLock() + defer p.closeMu.RUnlock() + out := make([]event.Stats, len(p.public)) + for i, d := range p.public { + out[i] = d.Stats() + } + return out +} +func (p *EventPipeline) RejectedEvents() uint64 { return p.rejected.Load() } +func (p *EventPipeline) Close() error { return p.CloseContext(context.Background()) } + +// CloseContext bounds both legacy drain and the public sink lifecycle. Legacy +// callbacks cannot be interrupted, but a stuck callback cannot block teardown. +func (p *EventPipeline) CloseContext(ctx context.Context) error { + caller := ctx if p == nil { return nil } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() p.Start() p.closeMu.Lock() - defer p.closeMu.Unlock() if !p.closing { p.closing = true close(p.ch) + p.shutdownDone = make(chan struct{}) + deadline, _ := ctx.Deadline() + shutdown, stop := context.WithDeadline(caller, deadline) + go func() { defer stop(); p.shutdown(shutdown) }() } - <-p.closed - var err error - for _, sink := range p.sinks { - if sink == nil { - continue + done := p.shutdownDone + p.closeMu.Unlock() + select { + case <-done: + p.closeMu.RLock() + defer p.closeMu.RUnlock() + return p.shutdownErr + case <-ctx.Done(): + return ctx.Err() + } +} +func (p *EventPipeline) shutdown(ctx context.Context) { + results := make(chan error, len(p.public)+1) + // One fixed lifecycle goroutine for the legacy pipeline, never one per write. + go func() { + <-p.closed + var err error + for _, sink := range p.sinks { + if sink != nil { + if e := sink.Close(); err == nil { + err = e + } + } } - if closeErr := sink.Close(); err == nil && closeErr != nil { - err = closeErr + results <- err + }() + for _, d := range p.public { + go func() { results <- d.Close(ctx) }() + } + var err error + for i := 0; i < len(p.public)+1; i++ { + select { + case e := <-results: + if err == nil { + err = e + } + case <-ctx.Done(): + err = ctx.Err() + i = len(p.public) + 1 } } - return err + p.closeMu.Lock() + p.shutdownErr = err + p.closeMu.Unlock() + close(p.shutdownDone) } func (p *EventPipeline) run() { diff --git a/internal/observability/pipeline/public_test.go b/internal/observability/pipeline/public_test.go new file mode 100644 index 00000000..9e39263b --- /dev/null +++ b/internal/observability/pipeline/public_test.go @@ -0,0 +1,93 @@ +package pipeline + +import ( + "context" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "sync/atomic" + "testing" + "time" +) + +type publicSink struct { + writes atomic.Int32 + closes atomic.Int32 + blocked bool +} + +func (s *publicSink) Write(ctx context.Context, e event.Envelope) error { + if s.blocked { + <-ctx.Done() + return ctx.Err() + } + s.writes.Add(1) + return nil +} +func (s *publicSink) Flush(ctx context.Context) error { return ctx.Err() } +func (s *publicSink) Close(ctx context.Context) error { s.closes.Add(1); return nil } +func TestPublicSinkIsolationAndProjection(t *testing.T) { + slow, fast := &publicSink{blocked: true}, &publicSink{} + p := NewEventPipeline(1) + if err := p.ConfigurePublic(attribution.Policy{}, []event.Registration{{New: func(context.Context) (event.Sink, error) { return slow, nil }}, {New: func(context.Context) (event.Sink, error) { return fast, nil }}}); err != nil { + t.Fatal(err) + } + e := event.Envelope{SchemaVersion: 1, ID: "test", Timestamp: time.Now().UTC(), Family: "extension.mutation", Outcome: "success", Mutation: &event.ExtensionMutation{ModuleID: "example.fixture", Operation: "put"}} + if !p.Publish(context.Background(), e) { + t.Fatal("not accepted") + } + deadline := time.Now().Add(time.Second) + for fast.writes.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if fast.writes.Load() != 1 { + t.Fatal("slow sink blocked another") + } + e.Reason = "arbitrary prompt" + if p.Publish(context.Background(), e) || p.RejectedEvents() != 1 { + t.Fatal("projection boundary") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + _ = p.CloseContext(ctx) + _ = p.Close() + if fast.closes.Load() != 1 { + t.Fatal("not closed once") + } +} + +type stalledLegacySink struct { + entered chan struct{} + release chan struct{} + closed chan struct{} + closes atomic.Int32 +} + +func (s *stalledLegacySink) Write(any) error { close(s.entered); <-s.release; return nil } +func (s *stalledLegacySink) Close() error { + if s.closes.Add(1) == 1 { + close(s.closed) + } + return nil +} +func TestLegacyStallBoundedAndClosedOnce(t *testing.T) { + s := &stalledLegacySink{entered: make(chan struct{}), release: make(chan struct{}), closed: make(chan struct{})} + p := NewEventPipeline(1, s) + p.Start() + p.Enqueue("event") + <-s.entered + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + if p.CloseContext(ctx) == nil { + t.Fatal("legacy stall not bounded") + } + close(s.release) + select { + case <-s.closed: + case <-time.After(time.Second): + t.Fatal("legacy cleanup abandoned") + } + _ = p.Close() + if s.closes.Load() != 1 { + t.Fatal("legacy close repeated") + } +} diff --git a/internal/observability/usage/correlation.go b/internal/observability/usage/correlation.go new file mode 100644 index 00000000..cfb82792 --- /dev/null +++ b/internal/observability/usage/correlation.go @@ -0,0 +1,29 @@ +package usage + +import ( + "github.com/agent-guide/agent-gateway/pkg/attribution" + "unicode/utf8" +) + +// CorrelationFromDimensions projects runtime identity into the bounded public +// context. Invalid individual identifiers are absent; other valid correlation +// still advances. The original dimensions remain unchanged for usage accounting. +func CorrelationFromDimensions(d InteractionDimensions) attribution.Value { + bounded := func(s string) string { + if len(s) > 256 || !utf8.ValidString(s) { + return "" + } + return s + } + v := attribution.Value{RouteID: bounded(d.RouteID), AgentID: bounded(d.AgentID), RunID: bounded(d.RunID), VirtualKeyID: bounded(d.VirtualKeyID)} + if ValidTraceID(d.TraceID) { + v.TraceID = d.TraceID + } + if ValidSpanID(d.SpanID) { + v.SpanID = d.SpanID + } + if ValidSpanID(d.ParentSpanID) { + v.ParentSpanID = d.ParentSpanID + } + return v +} diff --git a/internal/observability/usage/observer.go b/internal/observability/usage/observer.go index ad571917..0bb41b1a 100644 --- a/internal/observability/usage/observer.go +++ b/internal/observability/usage/observer.go @@ -2,6 +2,7 @@ package usage import ( "context" + "github.com/agent-guide/agent-gateway/pkg/attribution" "sync" "time" @@ -55,6 +56,9 @@ func ContextWithDimensions(ctx context.Context, dims InteractionDimensions) cont if ctx == nil { ctx = context.Background() } + if next, err := attribution.WithCorrelation(ctx, CorrelationFromDimensions(dims)); err == nil { + ctx = next + } return context.WithValue(ctx, dimsContextKey{}, dims) } @@ -117,7 +121,7 @@ func (o Observer) Begin(ctx context.Context, dims InteractionDimensions) (Intera RunID: dims.RunID, }, } - ctx = context.WithValue(ctx, dimsContextKey{}, dims) + ctx = ContextWithDimensions(ctx, dims) return span, ContextWithSpan(ctx, span) } diff --git a/internal/observability/usage/public_attribution_test.go b/internal/observability/usage/public_attribution_test.go new file mode 100644 index 00000000..32adb7fa --- /dev/null +++ b/internal/observability/usage/public_attribution_test.go @@ -0,0 +1,49 @@ +package usage + +import ( + "context" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "strings" + "testing" +) + +func TestRuntimeDimensionsPropagatePublicCorrelationAndRetainTrustedIdentity(t *testing.T) { + p, err := attribution.NewPolicy([]string{"example.fixture"}, []string{"example.fixture/region"}) + if err != nil { + t.Fatal(err) + } + ctx, err := attribution.WithContext(context.Background(), attribution.Value{Subject: "trusted-subject", Tenant: "trusted-tenant", Attributes: map[string]string{"example.fixture/region": "east"}}, p) + if err != nil { + t.Fatal(err) + } + d := InteractionDimensions{RouteID: "nested-route", AgentID: "agent", RunID: "run", VirtualKeyID: "key", TraceID: strings.Repeat("1", 32), SpanID: strings.Repeat("2", 16), ParentSpanID: strings.Repeat("3", 16)} + _, ctx = NewObserver(&InMemorySink{}).Begin(ctx, d) + v, _ := attribution.FromContext(ctx) + if v.Subject != "trusted-subject" || v.Tenant != "trusted-tenant" || v.Attributes["example.fixture/region"] != "east" || v.RouteID != d.RouteID || v.AgentID != d.AgentID || v.RunID != d.RunID || v.VirtualKeyID != d.VirtualKeyID || v.TraceID != d.TraceID || v.SpanID != d.SpanID || v.ParentSpanID != d.ParentSpanID { + t.Fatalf("public correlation %#v", v) + } + ctx = ContextWithDimensions(ctx, InteractionDimensions{RouteID: "direct-route"}) + v, _ = attribution.FromContext(ctx) + if v.AgentID != "" || v.RunID != "" || v.ParentSpanID != "" || v.RouteID != "direct-route" || v.Tenant != "trusted-tenant" { + t.Fatalf("explicit absence %#v", v) + } +} + +func TestInvalidRuntimeFieldDoesNotRetainStalePublicCorrelation(t *testing.T) { + for _, bad := range []string{strings.Repeat("x", 257), string([]byte{0xff})} { + ctx, err := attribution.WithContext(context.Background(), attribution.Value{Subject: "trusted", Tenant: "tenant", RouteID: "old-route", AgentID: "old-agent", RunID: "old-run", TraceID: strings.Repeat("1", 32)}, attribution.Policy{}) + if err != nil { + t.Fatal(err) + } + d := InteractionDimensions{RouteID: bad, AgentID: "new-agent", RunID: "new-run", TraceID: strings.Repeat("2", 32), SpanID: strings.Repeat("3", 16), ParentSpanID: "invalid-parent"} + ctx = ContextWithDimensions(ctx, d) + v, _ := attribution.FromContext(ctx) + if v.RouteID != "" || v.AgentID != d.AgentID || v.RunID != d.RunID || v.TraceID != d.TraceID || v.SpanID != d.SpanID || v.ParentSpanID != "" || v.Subject != "trusted" || v.Tenant != "tenant" { + t.Fatalf("stale public correlation %+v", v) + } + raw, _ := DimensionsFromContext(ctx) + if raw.RouteID != bad { + t.Fatal("public bounds changed usage accounting") + } + } +} diff --git a/internal/observability/usage/service.go b/internal/observability/usage/service.go index 2eb53a4c..a966e458 100644 --- a/internal/observability/usage/service.go +++ b/internal/observability/usage/service.go @@ -1,6 +1,11 @@ package usage -import "sync/atomic" +import ( + "context" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "sync/atomic" +) type Config struct { RetentionDays int `json:"retention_days,omitempty"` @@ -256,3 +261,48 @@ type A2ASummary struct { FailureCount int64 `json:"failure_count"` AvgLatencyMS int64 `json:"avg_latency_ms"` } + +// Publish projects only public DTOs through the existing pipeline authority. +func (s *UsageService) Publish(ctx context.Context, e event.Envelope) bool { + if s == nil { + return false + } + p, ok := s.pipeline.(event.Publisher) + return ok && p.Publish(ctx, e) +} +func (s *UsageService) CloseContext(ctx context.Context) error { + if s == nil || s.pipeline == nil { + return nil + } + if p, ok := s.pipeline.(interface{ CloseContext(context.Context) error }); ok { + return p.CloseContext(ctx) + } + return s.pipeline.Close() +} + +func (s *UsageService) ConfigurePublic(p attribution.Policy, r []event.Registration) error { + if pipeline, ok := s.pipeline.(interface { + ConfigurePublic(attribution.Policy, []event.Registration) error + }); ok { + return pipeline.ConfigurePublic(p, r) + } + return &event.Error{Code: "sink_initialization_failed"} +} +func (s *UsageService) RejectedEvents() uint64 { + if s == nil { + return 0 + } + if p, ok := s.pipeline.(interface{ RejectedEvents() uint64 }); ok { + return p.RejectedEvents() + } + return 0 +} +func (s *UsageService) PublicStats() []event.Stats { + if s == nil { + return nil + } + if p, ok := s.pipeline.(interface{ PublicStats() []event.Stats }); ok { + return p.PublicStats() + } + return nil +} diff --git a/pkg/admin/body_test.go b/pkg/admin/body_test.go new file mode 100644 index 00000000..6059e349 --- /dev/null +++ b/pkg/admin/body_test.go @@ -0,0 +1,106 @@ +package admin + +import ( + "github.com/agent-guide/agent-gateway/pkg/llm/provider" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestAdminBodyBoundaryBeforeMutation(t *testing.T) { + h := NewHandler(nil, nil) + calls := 0 + h.mux.HandleFunc("POST /admin/body-test", func(w http.ResponseWriter, r *http.Request) { + calls++ + raw, _ := io.ReadAll(r.Body) + if len(raw) != MaxRequestBodyBytes { + t.Errorf("body size %d", len(raw)) + } + w.WriteHeader(http.StatusCreated) + }) + for _, unknown := range []bool{false, true} { + for _, n := range []int{MaxRequestBodyBytes, MaxRequestBodyBytes + 1} { + req := httptest.NewRequest("POST", "/admin/body-test", strings.NewReader("{}"+strings.Repeat(" ", n-2))) + if unknown { + req.ContentLength = -1 + req.TransferEncoding = []string{"chunked"} + } + before := calls + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if n > MaxRequestBodyBytes { + if rr.Code != 413 || rr.Body.String() != "{\"error\":\"request body exceeds 4194304 bytes\"}\n" || calls != before { + t.Fatalf("oversize: %d %s calls=%d", rr.Code, rr.Body.String(), calls) + } + } else if rr.Code != 201 || calls != before+1 { + t.Fatalf("supported: %d", rr.Code) + } + } + } + req := httptest.NewRequest("POST", "/admin/body-test", strings.NewReader("{}")) + req.Header.Set("Content-Encoding", "gzip") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != 415 || calls != 2 { + t.Fatalf("compressed: %d calls=%d", rr.Code, calls) + } +} + +func TestAdminPanicJSON(t *testing.T) { + core, logs := observer.New(zap.ErrorLevel) + h := NewHandler(nil, zap.New(core)) + h.mux.HandleFunc("GET /admin/panic-test", func(http.ResponseWriter, *http.Request) { panic("private diagnostic") }) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest("GET", "/admin/panic-test", nil)) + if rr.Code != 500 || rr.Body.String() != "{\"error\":\"internal server error\"}\n" || rr.Header().Get("Content-Type") != "application/json" { + t.Fatalf("%d %s", rr.Code, rr.Body.String()) + } + entries := logs.FilterMessage("admin request panicked").All() + if len(entries) != 1 || entries[0].ContextMap()["panic"] != "private diagnostic" { + t.Fatalf("missing operator panic diagnostic: %+v", entries) + } + +} + +func TestCommunityBodyBoundaryLeavesStoreUnchanged(t *testing.T) { + store := &testProviderConfigStore{items: map[string]*provider.ProviderConfig{}} + gateway := newTestAgentGateway(&testConfigStore{providerStore: store}, nil, nil, nil, nil) + defer gateway.Close() + h := NewHandler(gateway, nil) + payload := `{"id":"body-provider","provider_type":"openai","base_url":"https://example.com"}` + for _, unknown := range []bool{false, true} { + req := httptest.NewRequest("POST", "/admin/llm/providers", strings.NewReader(payload+strings.Repeat(" ", MaxRequestBodyBytes))) + if unknown { + req.ContentLength = -1 + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != 413 || len(store.items) != 0 { + t.Fatalf("oversized write: %d %#v", rr.Code, store.items) + } + } + req := httptest.NewRequest("POST", "/admin/llm/providers", strings.NewReader(payload)) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != 201 || len(store.items) != 1 { + t.Fatalf("supported write: %d %s", rr.Code, rr.Body.String()) + } +} + +func TestAdminRejectsCompressionInRepeatedHeaders(t *testing.T) { + h := NewHandler(nil, nil) + called := false + h.mux.HandleFunc("POST /admin/compression-test", func(http.ResponseWriter, *http.Request) { called = true }) + req := httptest.NewRequest("POST", "/admin/compression-test", strings.NewReader(`{}`)) + req.Header.Add("Content-Encoding", "identity") + req.Header.Add("Content-Encoding", "gzip") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != 415 || called { + t.Fatalf("repeated compressed encoding admitted: %d", rr.Code) + } +} diff --git a/pkg/admin/contributions.go b/pkg/admin/contributions.go new file mode 100644 index 00000000..b9b9104a --- /dev/null +++ b/pkg/admin/contributions.go @@ -0,0 +1,42 @@ +package admin + +import ( + "context" + "net/http" + + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/extension" + "go.uber.org/zap" +) + +func (h *Handler) contributedHandler(route extension.OwnedAdminRoute) http.HandlerFunc { + bindPublisher := h.extensions.AdminPublisher(route.ModuleID) + return func(w http.ResponseWriter, r *http.Request) { + lease, err := h.extensions.Acquire() + if err != nil { + extensionError(w, err) + return + } + defer lease.Release() + resource, ok := lease.Snapshot.Resource(route.ModuleID) + if !ok { + extension.WriteAdminError(w, 503, "extension_unavailable") + return + } + contributor, ok := resource.(extension.AdminContributor) + if !ok { + extension.WriteAdminError(w, 503, "extension_unavailable") + return + } + config, _ := lease.Snapshot.Config(route.ModuleID) + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + stop := context.AfterFunc(lease.Snapshot.Context(), cancel) + defer stop() + deps := extension.AdminDependencies{ + Config: config, Logger: h.logger.With(zap.String("module_id", route.ModuleID)), + Attribution: attribution.FromContext, Events: bindPublisher(ctx), + } + contributor.ServeAdminHTTP(w, r.WithContext(ctx), deps) + } +} diff --git a/pkg/admin/contributions_test.go b/pkg/admin/contributions_test.go new file mode 100644 index 00000000..ae6f47cf --- /dev/null +++ b/pkg/admin/contributions_test.go @@ -0,0 +1,250 @@ +package admin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/configstore" + "github.com/agent-guide/agent-gateway/pkg/configstore/schema" + configsqlite "github.com/agent-guide/agent-gateway/pkg/configstore/sqlite" + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/agent-guide/agent-gateway/pkg/extension" + "github.com/agent-guide/agent-gateway/pkg/gateway" +) + +const adminTestID = "example.admin" + +type contributedResource struct { + handle func(http.ResponseWriter, *http.Request, extension.AdminDependencies) + unhealthy atomic.Bool + closed atomic.Bool +} + +func (r *contributedResource) Health(context.Context) error { + if r.unhealthy.Load() { + return errors.New("private diagnostic") + } + return nil +} +func (r *contributedResource) Close(context.Context) error { r.closed.Store(true); return nil } +func (r *contributedResource) ServeAdminHTTP(w http.ResponseWriter, req *http.Request, d extension.AdminDependencies) { + r.handle(w, req, d) +} + +type adminPublisher struct{ events []event.Envelope } + +func (p *adminPublisher) Publish(_ context.Context, e event.Envelope) bool { + p.events = append(p.events, e) + return true +} + +func newContributedAdmin(t *testing.T, resource *contributedResource, publisher event.Publisher) *Handler { + t.Helper() + a := distribution.Assembly{Modules: []extension.Descriptor{{ID: adminTestID, Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime", "admin"}}}, Factories: map[string]*extension.Factory{adminTestID: {AdminRoutes: []extension.AdminRoute{{Method: "POST", Path: "/admin/extensions/" + adminTestID + "/items/{id}"}}, Validate: func(_ context.Context, b json.RawMessage) (json.RawMessage, error) { return b, nil }, Prepare: func(context.Context, extension.PreparedConfig, extension.Snapshot) (extension.Resource, error) { + return resource, nil + }}}, AttributionProducer: func(context.Context) (attribution.Value, error) { + return attribution.Value{Subject: "operator", Tenant: "org"}, nil + }} + registry, err := a.Registry() + if err != nil { + t.Fatal(err) + } + backend, err := configstore.OpenBackend(t.Context(), "sqlite", configsqlite.Config{SQLitePath: t.TempDir() + "/config.db"}, nil) + if err != nil { + t.Fatal(err) + } + if err = schema.RegisterDefaultStores(backend); err != nil { + t.Fatal(err) + } + store, err := backend.Get(schema.StoreExtensions) + if err != nil { + t.Fatal(err) + } + if err = store.Create(t.Context(), &extension.Envelope{ModuleID: adminTestID, SchemaVersion: 1, Config: json.RawMessage(`{"message":"hello"}`)}); err != nil { + t.Fatal(err) + } + g := gateway.NewAgentGateway() + if err = g.Bootstrap(context.Background(), gateway.BootstrapOptions{Distribution: a, ExtensionRegistry: registry, ConfigStoreBackend: backend, EventPublisher: publisher}); err != nil { + t.Fatal(err) + } + t.Cleanup(g.Close) + return NewHandler(g, nil) +} + +func TestContributedAdminBoundaryAndDependencies(t *testing.T) { + calls := 0 + publisher := &adminPublisher{} + resource := &contributedResource{handle: func(w http.ResponseWriter, r *http.Request, d extension.AdminDependencies) { + calls++ + if r.PathValue("id") != "item" || r.Pattern != "POST /admin/extensions/example.admin/items/{id}" { + t.Errorf("routing: %s %s", r.Pattern, r.PathValue("id")) + } + v, ok := d.Attribution(r.Context()) + if !ok || v.Subject != "operator" || v.Tenant != "org" { + t.Errorf("attribution: %+v", v) + } + if d.Logger == nil || string(d.Config.Config) != `{"message":"hello"}` { + t.Errorf("dependencies: %+v", d) + } + d.Config.Config[0] = 'x' + e := event.Envelope{SchemaVersion: 1, ID: "mutation", Family: "extension.mutation", Timestamp: time.Now().UTC(), Outcome: "success", Mutation: &event.ExtensionMutation{ModuleID: adminTestID, Operation: "reset"}, Attribution: attribution.Value{Subject: "forged"}} + if !d.Events.Publish(r.Context(), e) { + t.Error("event rejected") + } + forged, err := attribution.WithContext(r.Context(), attribution.Value{Subject: "impersonated", Tenant: "fake-tenant", RouteID: "fake-route"}, attribution.Policy{}) + if err != nil { + t.Fatal(err) + } + if !d.Events.Publish(forged, e) { + t.Error("forged-context publication rejected instead of being stamped") + } + if !d.Events.Publish(context.Background(), e) { + t.Error("background publication rejected instead of being stamped") + } + e.Mutation = &event.ExtensionMutation{ModuleID: "example.other", Operation: "reset"} + if d.Events.Publish(r.Context(), e) { + t.Error("foreign mutation accepted") + } + w.WriteHeader(201) + }} + h := newContributedAdmin(t, resource, publisher) + path := "/admin/extensions/" + adminTestID + "/items/item" + for _, unknown := range []bool{false, true} { + req := httptest.NewRequest("POST", path, strings.NewReader("{}"+strings.Repeat(" ", MaxRequestBodyBytes))) + if unknown { + req.ContentLength = -1 + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != 413 || calls != 0 { + t.Fatalf("oversized contribution: %d calls=%d", rr.Code, calls) + } + } + for range 2 { + req := httptest.NewRequest("POST", path, strings.NewReader("{}")) + req.Header.Set("Origin", "https://admin.example") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != 201 || rr.Header().Get("Access-Control-Allow-Origin") != "https://admin.example" { + t.Fatalf("supported: %d", rr.Code) + } + } + if len(publisher.events) != 6 { + t.Fatalf("event attribution: %+v", publisher.events) + } + for _, e := range publisher.events { + if e.Attribution.Subject != "operator" || e.Attribution.Tenant != "org" || e.Attribution.RouteID != "" { + t.Fatalf("module replaced trusted actor: %+v", e.Attribution) + } + } + resource.unhealthy.Store(true) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest("GET", "/admin/health", nil)) + if rr.Code != 503 || strings.Contains(rr.Body.String(), "private") { + t.Fatalf("health: %d %s", rr.Code, rr.Body.String()) + } + resource.unhealthy.Store(false) + resource.handle = func(http.ResponseWriter, *http.Request, extension.AdminDependencies) { panic("private panic") } + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest("POST", path, nil)) + if rr.Code != 500 || strings.Contains(rr.Body.String(), "private") { + t.Fatalf("panic: %d %s", rr.Code, rr.Body.String()) + } +} + +func TestContributedAdminShutdownCancelsAndDrains(t *testing.T) { + entered, finished := make(chan struct{}), make(chan struct{}) + resource := &contributedResource{handle: func(w http.ResponseWriter, r *http.Request, _ extension.AdminDependencies) { + close(entered) + <-r.Context().Done() + if resourceClosed := r.Context().Err(); resourceClosed == nil { + t.Error("context not canceled") + } + extension.WriteAdminError(w, 503, "closed") + }} + h := newContributedAdmin(t, resource, nil) + go func() { + defer close(finished) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", "/admin/extensions/example.admin/items/item", nil)) + }() + <-entered + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + _ = h.extensions.Close(ctx) + select { + case <-finished: + case <-time.After(time.Second): + t.Fatal("handler not canceled at retirement deadline") + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest("POST", "/admin/extensions/example.admin/items/item", nil)) + if rr.Code != 503 { + t.Fatalf("new admission after close: %d", rr.Code) + } + deadline := time.Now().Add(time.Second) + for !resource.closed.Load() && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if !resource.closed.Load() { + t.Fatal("resource did not close") + } +} + +func TestContributedEventPublicationDuringGatewayClose(t *testing.T) { + entered, emit, finished := make(chan struct{}), make(chan struct{}), make(chan struct{}) + publisher := &adminPublisher{} + resource := &contributedResource{handle: func(w http.ResponseWriter, r *http.Request, d extension.AdminDependencies) { + close(entered) + <-emit + if !d.Events.Publish(r.Context(), event.Envelope{Family: "extension.mutation", Mutation: &event.ExtensionMutation{ModuleID: adminTestID}}) { + t.Error("event rejected during drain") + } + w.WriteHeader(200) + }} + h := newContributedAdmin(t, resource, publisher) + manager := h.extensions + go func() { + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", "/admin/extensions/example.admin/items/item", nil)) + close(finished) + }() + <-entered + closed := make(chan struct{}) + go func() { h.gateway.Close(); close(closed) }() + // Waiting until admissions close proves Reset owns the gateway lock and is + // draining our live handler, rather than merely winning a scheduling race. + deadline := time.Now().Add(time.Second) + for { + lease, err := manager.Acquire() + if err != nil { + break + } + lease.Release() + if time.Now().After(deadline) { + t.Fatal("shutdown never stopped admissions") + } + time.Sleep(time.Millisecond) + } + close(emit) + select { + case <-finished: + case <-time.After(time.Second): + t.Error("event publisher blocked on gateway shutdown lock") + } + select { + case <-closed: + case <-time.After(6 * time.Second): + t.Fatal("gateway did not drain") + } + if len(publisher.events) != 1 { + t.Fatalf("events: %d", len(publisher.events)) + } +} diff --git a/pkg/admin/extensions.go b/pkg/admin/extensions.go new file mode 100644 index 00000000..653fe4dd --- /dev/null +++ b/pkg/admin/extensions.go @@ -0,0 +1,129 @@ +package admin + +import ( + "bytes" + "encoding/json" + "errors" + "github.com/agent-guide/agent-gateway/pkg/extension" + "io" + "net/http" + "regexp" +) + +func extensionJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +var extensionDiagnostic = regexp.MustCompile(`^[A-Za-z0-9_./~-]{0,128}$`) + +func extensionError(w http.ResponseWriter, err error) { + var e *extension.Error + status := http.StatusBadRequest + if !errors.As(err, &e) { + e = &extension.Error{Code: "extension_unavailable"} + status = http.StatusServiceUnavailable + } + owned := *e + owned.ModuleID = safeExtensionID(owned.ModuleID) + if !extensionDiagnostic.MatchString(owned.Field) { + owned.Field = "config" + } + if !extensionDiagnostic.MatchString(owned.Code) { + owned.Code = "extension_unavailable" + } + e = &owned + switch e.Code { + case "not_found": + status = http.StatusNotFound + case "missing_dependency", "dependency_cycle": + status = http.StatusConflict + case "prepare_failed", "unhealthy", "secret_unavailable", "persistence_failed", "closed": + status = http.StatusServiceUnavailable + } + extensionJSON(w, status, map[string]any{"error": e.Code, "code": e.Code, "module_id": e.ModuleID, "field": e.Field}) +} +func (h *Handler) handleManifest(w http.ResponseWriter, r *http.Request) { + if h.gateway == nil { + extensionError(w, errors.New("unavailable")) + return + } + m, err := h.gateway.DistributionManifest() + if err != nil { + extensionError(w, err) + return + } + extensionJSON(w, http.StatusOK, m) +} +func (h *Handler) handleListExtensions(w http.ResponseWriter, r *http.Request) { + if h.extensions == nil { + extensionError(w, errors.New("unavailable")) + return + } + extensionJSON(w, http.StatusOK, map[string]any{"items": h.extensions.List()}) +} +func (h *Handler) handleGetExtension(w http.ResponseWriter, r *http.Request) { + if h.extensions == nil { + extensionError(w, errors.New("unavailable")) + return + } + id := r.PathValue("module_id") + for _, e := range h.extensions.List() { + if e.ModuleID == id { + extensionJSON(w, http.StatusOK, e) + return + } + } + extensionError(w, &extension.Error{Code: "not_found", ModuleID: safeExtensionID(id)}) +} +func safeExtensionID(id string) string { + if extension.ValidID(id) { + return id + } + return "" +} +func (h *Handler) handlePutExtension(w http.ResponseWriter, r *http.Request) { + if h.extensions == nil { + extensionError(w, errors.New("unavailable")) + return + } + raw, err := io.ReadAll(r.Body) + if err != nil { + extensionError(w, &extension.Error{Code: "invalid_config"}) + return + } + var e extension.Envelope + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err = dec.Decode(&e); err != nil { + extensionError(w, &extension.Error{Code: "invalid_config"}) + return + } + var trailing any + if dec.Decode(&trailing) != io.EOF { + extensionError(w, &extension.Error{Code: "invalid_config"}) + return + } + if e.ModuleID != r.PathValue("module_id") { + extensionError(w, &extension.Error{Code: "invalid_config", Field: "module_id"}) + return + } + if err = h.extensions.Put(r.Context(), e); err != nil { + extensionError(w, err) + return + } + h.handleGetExtension(w, r) +} +func (h *Handler) handleDeleteExtension(w http.ResponseWriter, r *http.Request) { + if h.extensions == nil { + extensionError(w, errors.New("unavailable")) + return + } + id := r.PathValue("module_id") + if err := h.extensions.Delete(r.Context(), id); err != nil { + extensionError(w, err) + return + } + extensionJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id}) +} diff --git a/pkg/admin/handler.go b/pkg/admin/handler.go index 46ff0d7f..8fd44cce 100644 --- a/pkg/admin/handler.go +++ b/pkg/admin/handler.go @@ -1,7 +1,10 @@ package admin import ( + "bytes" "fmt" + "github.com/agent-guide/agent-gateway/pkg/extension" + "io" "net/http" "strings" @@ -26,8 +29,13 @@ import ( "go.uber.org/zap" ) +// MaxRequestBodyBytes is the shared received-byte limit for every Admin route. +const MaxRequestBodyBytes = 4 * 1024 * 1024 + // Handler handles Admin API requests under /admin/. type Handler struct { + gateway *gateway.AgentGateway + extensions *extension.Manager credentialManager *credential.Manager configStoreBackend configstore.ConfigStoreBackend routeConfigManager *routecore.AgentRouteConfigManager @@ -107,6 +115,7 @@ func NewHandler(agentGateway *gateway.AgentGateway, logger *zap.Logger) *Handler } h := &Handler{ + gateway: agentGateway, credentialManager: credentialMgr, configStoreBackend: configStoreBackend, routeConfigManager: routeConfigManager, @@ -130,6 +139,9 @@ func NewHandler(agentGateway *gateway.AgentGateway, logger *zap.Logger) *Handler usagePrometheus: usagePrometheus, logger: logger, } + if agentGateway != nil { + h.extensions = agentGateway.Extensions() + } h.mux = http.NewServeMux() h.agentRoutesMux = http.NewServeMux() // Keep AgentRoute patterns separate: Go's ServeMux considers @@ -145,6 +157,11 @@ func NewHandler(agentGateway *gateway.AgentGateway, logger *zap.Logger) *Handler h.mux.HandleFunc(route.Method+" "+route.Path, route.Handler) } } + if h.extensions != nil { + for _, route := range h.extensions.AdminRoutes() { + h.mux.HandleFunc(route.Method+" "+route.Path, h.contributedHandler(route)) + } + } return h } @@ -154,21 +171,46 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer func() { if recovered := recover(); recovered != nil { httplog.Error(h.logger, "admin request panicked", r, http.StatusInternalServerError, nil, zap.Any("panic", recovered)) - http.Error(rr, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + extensionJSON(rr, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) } httplog.ResponseError(h.logger, "admin request failed", r, rr) }() if origin := r.Header.Get("Origin"); origin != "" { rr.Header().Set("Access-Control-Allow-Origin", origin) - rr.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + rr.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS") rr.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") rr.Header().Set("Access-Control-Max-Age", "86400") } + + // Read the entire bounded body before dispatch. A decoder may otherwise + // commit a valid leading object before noticing oversized trailing bytes. + encoding := strings.TrimSpace(strings.Join(r.Header.Values("Content-Encoding"), ",")) + if encoding != "" && !strings.EqualFold(encoding, "identity") { + extensionJSON(rr, http.StatusUnsupportedMediaType, map[string]string{"error": "compressed request bodies are unsupported"}) + return + } + if r.Body != nil { + body := r.Body + defer body.Close() + raw, err := io.ReadAll(io.LimitReader(body, MaxRequestBodyBytes+1)) + if len(raw) > MaxRequestBodyBytes { + extensionJSON(rr, http.StatusRequestEntityTooLarge, map[string]string{"error": "request body exceeds 4194304 bytes"}) + return + } + if err != nil { + extensionJSON(rr, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + r.Body = io.NopCloser(bytes.NewReader(raw)) + } if r.Method == "OPTIONS" { rr.WriteHeader(http.StatusNoContent) return } + if h.gateway != nil { + r = r.WithContext(h.gateway.AttributionContext(r.Context(), usage.InteractionDimensions{})) + } if r.URL.Path == "/admin/agents/routes" || strings.HasPrefix(r.URL.Path, "/admin/agents/routes/") { h.agentRoutesMux.ServeHTTP(rr, r) return diff --git a/pkg/admin/limiter_metrics.go b/pkg/admin/limiter_metrics.go new file mode 100644 index 00000000..d8849417 --- /dev/null +++ b/pkg/admin/limiter_metrics.go @@ -0,0 +1,35 @@ +package admin + +import ( + "fmt" + vk "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey" + "sort" + "strings" +) + +func (h *Handler) limiterPrometheus() string { + if h.virtualKeyManager == nil { + return "" + } + counters := h.virtualKeyManager.LimiterCounters() + labels := make([]vk.LimiterLabels, 0, len(counters)) + for k := range counters { + labels = append(labels, k) + } + sort.Slice(labels, func(i, j int) bool { + a, b := labels[i], labels[j] + if a.Dimension != b.Dimension { + return a.Dimension < b.Dimension + } + if a.Outcome != b.Outcome { + return a.Outcome < b.Outcome + } + return a.BackendType < b.BackendType + }) + var out strings.Builder + out.WriteString("# HELP agentgateway_limiter_admissions_total Limiter outcomes by dimension and bounded backend type.\n# TYPE agentgateway_limiter_admissions_total counter\n") + for _, l := range labels { + fmt.Fprintf(&out, "agentgateway_limiter_admissions_total{dimension=%q,outcome=%q,backend_type=%q} %d\n", l.Dimension, l.Outcome, l.BackendType, counters[l]) + } + return out.String() +} diff --git a/pkg/admin/public_event_stats_test.go b/pkg/admin/public_event_stats_test.go new file mode 100644 index 00000000..052ac77d --- /dev/null +++ b/pkg/admin/public_event_stats_test.go @@ -0,0 +1,30 @@ +package admin + +import ( + "context" + "github.com/agent-guide/agent-gateway/internal/observability/pipeline" + "github.com/agent-guide/agent-gateway/internal/observability/usage" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "testing" +) + +type statsSink struct{} + +func (statsSink) Write(context.Context, event.Envelope) error { return nil } +func (statsSink) Flush(context.Context) error { return nil } +func (statsSink) Close(context.Context) error { return nil } +func TestPublicEventHealthVisibleInMetrics(t *testing.T) { + p := pipeline.NewEventPipeline(1) + if err := p.ConfigurePublic(attribution.Policy{}, []event.Registration{{New: func(context.Context) (event.Sink, error) { return statsSink{}, nil }}}); err != nil { + t.Fatal(err) + } + defer p.Close() + svc := usage.NewUsageService(p, nil) + svc.Publish(context.Background(), event.Envelope{}) + h := &Handler{usageStats: svc} + stats := h.pipelineStats() + if stats.RejectedEvents != 1 || len(stats.PublicSinks) != 1 || !stats.PublicSinks[0].Healthy { + t.Fatal(stats) + } +} diff --git a/pkg/admin/routes.go b/pkg/admin/routes.go index 4f287775..4c57b0bf 100644 --- a/pkg/admin/routes.go +++ b/pkg/admin/routes.go @@ -3,6 +3,7 @@ package admin import ( "errors" "fmt" + "github.com/agent-guide/agent-gateway/pkg/event" "net/http" "strconv" "strings" @@ -52,6 +53,11 @@ type Route struct { // Routes returns all admin API routes. func (h *Handler) Routes() []Route { return []Route{ + {Method: http.MethodGet, Path: "/admin/manifest", Handler: h.handleManifest}, + {Method: http.MethodGet, Path: "/admin/extensions", Handler: h.handleListExtensions}, + {Method: http.MethodGet, Path: "/admin/extensions/{module_id}", Handler: h.handleGetExtension}, + {Method: http.MethodPut, Path: "/admin/extensions/{module_id}", Handler: h.handlePutExtension}, + {Method: http.MethodDelete, Path: "/admin/extensions/{module_id}", Handler: h.handleDeleteExtension}, // Health — public {Method: http.MethodGet, Path: "/admin/health", Handler: h.handleHealth}, @@ -182,6 +188,12 @@ func (h *Handler) Routes() []Route { } func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) { + if h.extensions != nil { + if err := h.extensions.Health(r.Context()); err != nil { + extensionError(w, err) + return + } + } _ = httpjson.Write(w, http.StatusOK, map[string]string{"status": "ok"}) } @@ -681,18 +693,28 @@ type metricsResponse struct { } type pipelineStats struct { - DroppedEvents uint64 `json:"dropped_events"` - WriteFailures uint64 `json:"write_failures"` + RejectedEvents uint64 `json:"rejected_events"` + PublicSinks []event.Stats `json:"public_sinks,omitempty"` + DroppedEvents uint64 `json:"dropped_events"` + WriteFailures uint64 `json:"write_failures"` } func (h *Handler) pipelineStats() pipelineStats { if h.usageStats == nil { return pipelineStats{} } - return pipelineStats{ + stats := pipelineStats{ DroppedEvents: h.usageStats.DroppedEvents(), WriteFailures: h.usageStats.WriteFailures(), } + if source, ok := h.usageStats.(interface { + RejectedEvents() uint64 + PublicStats() []event.Stats + }); ok { + stats.RejectedEvents = source.RejectedEvents() + stats.PublicSinks = source.PublicStats() + } + return stats } func (h *Handler) handlePrometheusMetrics(w http.ResponseWriter, r *http.Request) { @@ -702,7 +724,7 @@ func (h *Handler) handlePrometheusMetrics(w http.ResponseWriter, r *http.Request } w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(usage.RenderPrometheus(snap, h.usageStats))) + _, _ = w.Write([]byte(usage.RenderPrometheus(snap, h.usageStats) + h.limiterPrometheus())) } func (h *Handler) handleMetrics(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/adminclient/extensions.go b/pkg/adminclient/extensions.go new file mode 100644 index 00000000..d1a05978 --- /dev/null +++ b/pkg/adminclient/extensions.go @@ -0,0 +1,33 @@ +package adminclient + +import ( + "context" + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/extension" + "net/http" + "net/url" +) + +func (c *Client) Manifest(ctx context.Context) (*distribution.Assembly, error) { + var out distribution.Assembly + err := c.do(ctx, http.MethodGet, "/admin/manifest", nil, &out, true, http.StatusOK) + return &out, err +} +func (c *Client) ListExtensions(ctx context.Context) ([]extension.Envelope, error) { + var out itemsResponse[extension.Envelope] + err := c.do(ctx, http.MethodGet, "/admin/extensions", nil, &out, true, http.StatusOK) + return out.Items, err +} +func (c *Client) GetExtension(ctx context.Context, id string) (*extension.Envelope, error) { + var out extension.Envelope + err := c.do(ctx, http.MethodGet, "/admin/extensions/"+url.PathEscape(id), nil, &out, true, http.StatusOK) + return &out, err +} +func (c *Client) PutExtension(ctx context.Context, e extension.Envelope) (*extension.Envelope, error) { + var out extension.Envelope + err := c.do(ctx, http.MethodPut, "/admin/extensions/"+url.PathEscape(e.ModuleID), e, &out, true, http.StatusOK) + return &out, err +} +func (c *Client) DeleteExtension(ctx context.Context, id string) error { + return c.do(ctx, http.MethodDelete, "/admin/extensions/"+url.PathEscape(id), nil, nil, true, http.StatusOK) +} diff --git a/pkg/agent/builtin/AGENTS.md b/pkg/agent/builtin/AGENTS.md index 1546fe41..4b1e5ffa 100644 --- a/pkg/agent/builtin/AGENTS.md +++ b/pkg/agent/builtin/AGENTS.md @@ -51,4 +51,4 @@ runtime structure belongs in `docs/architecture/architecture-overview.md`. `tool_call`, `usage`, `permission`, `done`, `error`. - Register custom Go agents with `builtin.RegisterFactory`. Blank-import each factory package in `cmd/agw/main.go`, `cmd/agwd/main.go`, and - `cmd/agwctl/cmd_gateway.go`. + `cmd/agwctl/main.go`. diff --git a/pkg/agent/builtin/factory.go b/pkg/agent/builtin/factory.go index d80e58bf..0c0ff0d8 100644 --- a/pkg/agent/builtin/factory.go +++ b/pkg/agent/builtin/factory.go @@ -23,7 +23,7 @@ type FactoryDeps struct { // escape hatch for agents that need custom Go logic // (docs/design/agents-control-plane.md §5.7.3): implement the factory, // register it, and blank-import the package in cmd/agw/main.go, -// cmd/agwd/main.go, and cmd/agwctl/cmd_gateway.go. +// cmd/agwd/main.go, and cmd/agwctl/main.go. type Factory func(ctx context.Context, deps FactoryDeps, def *agent.BuiltinRuntime) (adk.Agent, error) var ( diff --git a/pkg/agent/runtime/runs_m3_test.go b/pkg/agent/runtime/runs_m3_test.go index f162fe8e..400895ec 100644 --- a/pkg/agent/runtime/runs_m3_test.go +++ b/pkg/agent/runtime/runs_m3_test.go @@ -170,20 +170,20 @@ func TestPermissionBrokerLookupRetainsClaimedCorrelation(t *testing.T) { func TestPermissionBrokerExpiryClaimsFailClosed(t *testing.T) { now := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC) - var nowNanos atomic.Int64 - nowNanos.Store(now.UnixNano()) - b := NewPermissionBroker() + // Isolate read and claim semantics from the background expiry scheduler. + // Automatic cleanup is covered by TestPermissionBrokerOwnsAutomaticExpiryCleanup. + b := &PermissionBroker{ + pending: map[string]*permissionEntry{}, claimed: map[string]claimedPermission{}, expired: map[string]expiredPermission{}, + resolvers: map[string]ContinuationResolver{}, now: func() time.Time { return now }, + wake: make(chan struct{}, 1), done: make(chan struct{}), + } t.Cleanup(func() { b.Close(WithPermissionSource(context.Background(), "test_cleanup")) }) - b.mu.Lock() - b.now = func() time.Time { return time.Unix(0, nowNanos.Load()).UTC() } - b.mu.Unlock() binding := &testContinuation{} _, err := b.Register(PendingPermission{RequestID: "perm-exp", AgentID: "a", RuntimeType: "builtin", RunID: "run-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ExpiresAt: now.Add(time.Minute)}, "cont-exp", binding) if err != nil { t.Fatal(err) } now = now.Add(2 * time.Minute) - nowNanos.Store(now.UnixNano()) if got := b.List("a"); len(got) != 0 { t.Fatalf("expired list=%+v", got) } diff --git a/pkg/attribution/AGENTS.md b/pkg/attribution/AGENTS.md new file mode 100644 index 00000000..67b7b8e9 --- /dev/null +++ b/pkg/attribution/AGENTS.md @@ -0,0 +1,13 @@ +# Attribution context + +Read design section 11.4 before changes. Values are bounded, UTF-8 validated and +copied on context insertion/retrieval. Attributes require exact assembly keys +owned by linked modules. This package authenticates nobody; raw headers never +establish subject/tenant. Runtime correlation updates retain trusted identity and +attributes. Public signatures expose no internal types. Run attribution/event, +dispatcher and external-consumer race contracts when changing this boundary. + +Designated candidate symbols: Value, Policy, NewPolicy, Policy.Validate, +WithContext, FromContext, WithCorrelation, Producer and Error. Published-tag +protection follows the enterprise extension contract; other exports do not +expand that designation. diff --git a/pkg/attribution/attribution.go b/pkg/attribution/attribution.go new file mode 100644 index 00000000..e0e9c6aa --- /dev/null +++ b/pkg/attribution/attribution.go @@ -0,0 +1,134 @@ +// Package attribution transports bounded trusted identity and runtime correlation. +// Value, Policy, NewPolicy, Policy.Validate, WithContext, FromContext, WithCorrelation, Producer +// and Error are candidate protected surfaces. This package performs no authentication. +package attribution + +import ( + "context" + "maps" + "strings" + "unicode/utf8" +) + +type Value struct { + Subject string `json:"subject,omitempty"` + Tenant string `json:"tenant,omitempty"` + RouteID string `json:"route_id,omitempty"` + AgentID string `json:"agent_id,omitempty"` + RunID string `json:"run_id,omitempty"` + VirtualKeyID string `json:"virtual_key_id,omitempty"` + TraceID string `json:"trace_id,omitempty"` + SpanID string `json:"span_id,omitempty"` + ParentSpanID string `json:"parent_span_id,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` +} + +// Producer is explicitly injected by a trusted distribution, never chosen by a header. +// It may establish subject/tenant and allowlisted attributes; core correlation wins. +type Producer func(context.Context) (Value, error) + +type Error struct{ Field string } + +func (e *Error) Error() string { return "invalid_attribution: " + e.Field } + +type Policy struct{ allowed map[string]bool } + +// NewPolicy requires exact attribute keys owned by an explicitly linked module. +func NewPolicy(modules, keys []string) (Policy, error) { + p := Policy{allowed: map[string]bool{}} + linked := map[string]bool{} + for _, m := range modules { + linked[m] = true + } + for _, k := range keys { + owner, suffix, ok := strings.Cut(k, "/") + if !ok || !linked[owner] || suffix == "" || len(k) > 128 || !utf8.ValidString(k) || p.allowed[k] { + return Policy{}, &Error{Field: "attribute_key"} + } + p.allowed[k] = true + } + return p, nil +} +func (p Policy) Validate(v Value) error { + fields := []struct{ name, value string }{{"subject", v.Subject}, {"tenant", v.Tenant}, {"route_id", v.RouteID}, {"agent_id", v.AgentID}, {"run_id", v.RunID}, {"virtual_key_id", v.VirtualKeyID}, {"trace_id", v.TraceID}, {"span_id", v.SpanID}, {"parent_span_id", v.ParentSpanID}} + for _, f := range fields { + if len(f.value) > 256 || !utf8.ValidString(f.value) { + return &Error{Field: f.name} + } + } + for _, f := range []struct { + name, value string + length int + }{{"trace_id", v.TraceID, 32}, {"span_id", v.SpanID, 16}, {"parent_span_id", v.ParentSpanID, 16}} { + if f.value != "" && !validID(f.value, f.length) { + return &Error{Field: f.name} + } + } + if len(v.Attributes) > 16 { + return &Error{Field: "attributes"} + } + total := 0 + for k, x := range v.Attributes { + if !p.allowed[k] || len(k) > 128 || len(x) > 256 || !utf8.ValidString(x) { + return &Error{Field: "attributes"} + } + total += len(k) + len(x) + } + if total > 4096 { + return &Error{Field: "attributes"} + } + return nil +} +func validID(s string, n int) bool { + if len(s) != n || strings.Trim(s, "0") == "" { + return false + } + for _, c := range s { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + return false + } + } + return true +} + +type contextKey struct{} +type entry struct { + value Value + policy Policy +} + +func WithContext(ctx context.Context, v Value, p Policy) (context.Context, error) { + if err := p.Validate(v); err != nil { + return ctx, err + } + if ctx == nil { + ctx = context.Background() + } + v.Attributes = maps.Clone(v.Attributes) + return context.WithValue(ctx, contextKey{}, entry{v, p}), nil +} +func FromContext(ctx context.Context) (Value, bool) { + if ctx == nil { + return Value{}, false + } + e, ok := ctx.Value(contextKey{}).(entry) + v := e.value + v.Attributes = maps.Clone(v.Attributes) + return v, ok +} + +// WithCorrelation replaces runtime-owned correlation while preserving trusted +// subject/tenant and attributes. It uses the policy stored by WithContext. +func WithCorrelation(ctx context.Context, core Value) (context.Context, error) { + if ctx == nil { + return ctx, nil + } + old, ok := ctx.Value(contextKey{}).(entry) + if !ok { + return ctx, nil + } + core.Subject = old.value.Subject + core.Tenant = old.value.Tenant + core.Attributes = old.value.Attributes + return WithContext(ctx, core, old.policy) +} diff --git a/pkg/attribution/attribution_test.go b/pkg/attribution/attribution_test.go new file mode 100644 index 00000000..a2c20397 --- /dev/null +++ b/pkg/attribution/attribution_test.go @@ -0,0 +1,37 @@ +package attribution + +import ( + "context" + "strings" + "testing" +) + +func TestBoundsAndOwnership(t *testing.T) { + p, err := NewPolicy([]string{"example.fixture"}, []string{"example.fixture/region"}) + if err != nil { + t.Fatal(err) + } + v := Value{Subject: "subject", Tenant: "tenant", RouteID: "route", AgentID: "agent", RunID: "run", VirtualKeyID: "key", TraceID: strings.Repeat("1", 32), SpanID: strings.Repeat("2", 16), ParentSpanID: strings.Repeat("3", 16), Attributes: map[string]string{"example.fixture/region": "east"}} + ctx, err := WithContext(context.Background(), v, p) + if err != nil { + t.Fatal(err) + } + v.Attributes["example.fixture/region"] = "mutated" + got, ok := FromContext(ctx) + if !ok || got.Attributes["example.fixture/region"] != "east" { + t.Fatal(got) + } + got.Attributes["example.fixture/region"] = "again" + got, _ = FromContext(ctx) + if got.Attributes["example.fixture/region"] != "east" { + t.Fatal("retrieval aliases") + } + if _, ok := FromContext(context.Background()); ok { + t.Fatal("absence") + } + for _, bad := range []Value{{Subject: strings.Repeat("x", 257)}, {TraceID: strings.Repeat("0", 32)}, {Attributes: map[string]string{"example.fixture/unlisted": "x"}}, {Attributes: map[string]string{"example.fixture/region": strings.Repeat("x", 257)}}} { + if p.Validate(bad) == nil { + t.Fatal("accepted invalid attribution") + } + } +} diff --git a/pkg/configstore/AGENTS.md b/pkg/configstore/AGENTS.md index b4c817b8..2f185f23 100644 --- a/pkg/configstore/AGENTS.md +++ b/pkg/configstore/AGENTS.md @@ -25,6 +25,7 @@ Current store names: - `agents` - `virtual_keys` - `managed_models` +- `extensions` — reference-preserving module envelopes; the sole source of internal extension configuration Current persisted backend: diff --git a/pkg/configstore/schema/extensions.go b/pkg/configstore/schema/extensions.go new file mode 100644 index 00000000..7a1d727f --- /dev/null +++ b/pkg/configstore/schema/extensions.go @@ -0,0 +1,37 @@ +package schema + +import ( + "encoding/json" + "fmt" + "github.com/agent-guide/agent-gateway/pkg/configstore" + "github.com/agent-guide/agent-gateway/pkg/extension" +) + +var ExtensionSchema = configstore.StoreSchema{ + Name: StoreExtensions, Kind: "extension", Table: "extensions", PrimaryKeyColumns: []string{"module_id"}, DataColumn: "config", Timestamped: false, + Codec: typedJSONCodec{kind: "extension", decode: func(data []byte) (any, error) { + var e extension.Envelope + if err := json.Unmarshal(data, &e); err != nil { + return nil, err + } + return &e, nil + }, validate: func(obj any) error { + var e extension.Envelope + switch v := unwrapConfigObject(obj).(type) { + case extension.Envelope: + e = v + case *extension.Envelope: + if v == nil { + return fmt.Errorf("nil extension") + } + e = *v + default: + return fmt.Errorf("invalid extension object") + } + if !extension.ValidID(e.ModuleID) || e.SchemaVersion <= 0 { + return fmt.Errorf("invalid stored extension envelope") + } + _, err := extension.DecodeObject(e.Config) + return err + }}, Metadata: configstore.MetadataFuncs{PrimaryKeyFunc: primaryKeyFromStringFields("ModuleID")}, +} diff --git a/pkg/configstore/schema/schemas.go b/pkg/configstore/schema/schemas.go index a847fcc4..043179fc 100644 --- a/pkg/configstore/schema/schemas.go +++ b/pkg/configstore/schema/schemas.go @@ -22,6 +22,7 @@ func RegisterDefaultStores(backend configstore.ConfigStoreBackend) error { return fmt.Errorf("config store backend is nil") } schemas := []configstore.StoreSchema{ + ExtensionSchema, ProviderConfigSchema, CredentialSchema, RouteSchema, diff --git a/pkg/configstore/schema/storenames.go b/pkg/configstore/schema/storenames.go index 0a4c76ff..7f7dee2e 100644 --- a/pkg/configstore/schema/storenames.go +++ b/pkg/configstore/schema/storenames.go @@ -1,6 +1,7 @@ package schema const ( + StoreExtensions = "extensions" StoreProviders = "providers" StoreCredentials = "credentials" StoreRoutes = "routes" diff --git a/pkg/dispatcher/attribution_test.go b/pkg/dispatcher/attribution_test.go new file mode 100644 index 00000000..4f1e6d5c --- /dev/null +++ b/pkg/dispatcher/attribution_test.go @@ -0,0 +1,42 @@ +package dispatcher + +import ( + "context" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/gateway" + "github.com/agent-guide/agent-gateway/pkg/gateway/llmroute" + "github.com/agent-guide/agent-gateway/pkg/llm/provider" + "net/http" + "net/http/httptest" + "testing" +) + +type attributionAPI struct { + localLLMApiHandler + v attribution.Value +} + +func (h *attributionAPI) ServeLLMApi(w http.ResponseWriter, r *http.Request, _ provider.Provider, _ *PreparedLLMApiRequest) error { + h.v, _ = attribution.FromContext(r.Context()) + w.WriteHeader(200) + return nil +} +func TestCallerTenantHeaderIgnoredWithNoopObserver(t *testing.T) { + g := gateway.NewAgentGateway() + if err := g.Bootstrap(context.Background(), gateway.BootstrapOptions{StaticLLMRoutes: mustRouteConfigs(t, []llmroute.LLMRoute{{AgentRouteConfig: llmroute.AgentRouteConfig{ID: "local", Protocol: "local-stub", MatchPolicy: llmroute.RouteMatchPolicy{PathPrefix: "/"}}, TargetPolicy: &llmroute.RouteDirectProviderPolicy{ProviderTarget: llmroute.DirectProviderTarget{ProviderID: "unused"}}}})}); err != nil { + t.Fatal(err) + } + defer g.Close() + api := &attributionAPI{} + h := NewHandler(g, map[string]LLMApiHandler{"local-stub": api}, nil, HandlerOptions{}) + req := httptest.NewRequest("POST", "/test", nil) + req.Header.Set("X-Tenant-ID", "forged") + req.Header.Set("X-Subject-ID", "forged") + rec := httptest.NewRecorder() + if err := h.Dispatch(rec, req, nil); err != nil { + t.Fatal(err) + } + if rec.Code != 200 || api.v.Tenant != "" || api.v.Subject != "" || api.v.RouteID != "local" || api.v.TraceID == "" || api.v.SpanID == "" { + t.Fatalf("status %d attribution %#v", rec.Code, api.v) + } +} diff --git a/pkg/dispatcher/handler.go b/pkg/dispatcher/handler.go index 382351ba..4fd23218 100644 --- a/pkg/dispatcher/handler.go +++ b/pkg/dispatcher/handler.go @@ -2,10 +2,15 @@ package dispatcher import ( "context" + "errors" "fmt" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/google/uuid" "net/http" "strconv" "strings" + "time" "github.com/agent-guide/agent-gateway/internal/httpcapture" "github.com/agent-guide/agent-gateway/internal/httpjson" @@ -141,6 +146,7 @@ func (h *Handler) Dispatch(w http.ResponseWriter, r *http.Request, next NextHand virtualKeyID = virtualKey.ID } dims := h.routeInteractionDimensions(cfg, traceCtx, virtualKeyID) + r = r.WithContext(h.gateway.AttributionContext(r.Context(), dims)) span, spanCtx := h.gateway.UsageObserver().Begin(r.Context(), dims) // Outbound trace/depth propagation must not depend on whether the configured // observer stores dimensions in its returned context (the noop observer does @@ -163,9 +169,25 @@ func (h *Handler) Dispatch(w http.ResponseWriter, r *http.Request, next NextHand if err != nil { return WriteDispatchError(h.logger, string(cfg.Protocol), cfg.ID, "", http.StatusServiceUnavailable, rec, r, "rate limit admission", "route kind is not configured", err) } - admission, err := h.gateway.VirtualKeyManager().Admit(*virtualKey, dimension) + admission, err := h.gateway.VirtualKeyManager().Admit(r.Context(), *virtualKey, dimension) + if admission.RequestsPerMinute > 0 { + v, _ := attribution.FromContext(r.Context()) + outcome, reason := "allow", "" + if err != nil { + outcome, reason = "deny", "limiter_unavailable" + } else if admission.Degraded { + outcome, reason = "degraded", "limiter_unavailable" + } else if !admission.Allowed { + outcome, reason = "deny", "rate_limited" + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + reason = "canceled" + } + h.gateway.PublishEvent(r.Context(), event.Envelope{SchemaVersion: 1, ID: uuid.NewString(), Family: "limiter.admission", Timestamp: time.Now().UTC(), Attribution: v, Outcome: outcome, Reason: reason, Limiter: &event.LimiterAdmission{Dimension: string(dimension), RetryAfter: time.Duration(admission.RetryAfterSeconds) * time.Second, BackendID: admission.BackendID}}) + } if err != nil { - return WriteDispatchError(h.logger, string(cfg.Protocol), cfg.ID, "", http.StatusInternalServerError, rec, r, "rate limit admission", "failed to apply virtual key rate limit", err) + span.AddAnnotation("error_type", "limiter_unavailable") + return httpjson.Error(rec, http.StatusServiceUnavailable, "limiter_unavailable") } if !admission.Allowed { span.AddAnnotation("error_type", "rate_limited") diff --git a/pkg/dispatcher/limiter_event_test.go b/pkg/dispatcher/limiter_event_test.go new file mode 100644 index 00000000..f9ae8b08 --- /dev/null +++ b/pkg/dispatcher/limiter_event_test.go @@ -0,0 +1,85 @@ +package dispatcher + +import ( + "context" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/agent-guide/agent-gateway/pkg/gateway" + "github.com/agent-guide/agent-gateway/pkg/gateway/llmroute" + "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey" + "net/http" + "net/http/httptest" + "testing" +) + +type admissionPublisher struct{ events []event.Envelope } + +func (p *admissionPublisher) Publish(_ context.Context, e event.Envelope) bool { + p.events = append(p.events, e) + return true +} +func TestConfiguredLimiterPublishesAllowDenyAndUnlimitedPublishesNothing(t *testing.T) { + for _, limited := range []bool{true, false} { + name := "unlimited" + var limits *virtualkey.VirtualKeyRateLimits + if limited { + name = "limited" + limits = &virtualkey.VirtualKeyRateLimits{LLM: &virtualkey.RateLimit{RequestsPerMinute: 1, Burst: 1}} + } + t.Run(name, func(t *testing.T) { + p := &admissionPublisher{} + g := gateway.NewAgentGateway() + if err := g.Bootstrap(context.Background(), gateway.BootstrapOptions{ + EventPublisher: p, + ConfigStoreBackend: &testConfigStoreBackend{store: singleVirtualKeyStore{keyID: "vk-1", key: "fixture-bearer", route: "broad-route", rateLimits: limits}}, + StaticLLMRoutes: mustRouteConfigs(t, []llmroute.LLMRoute{{AgentRouteConfig: llmroute.AgentRouteConfig{ID: "broad-route", Protocol: "local-stub", MatchPolicy: llmroute.RouteMatchPolicy{PathPrefix: "/"}, AuthPolicy: llmroute.RouteAuthPolicy{RequireVirtualKey: true}}, TargetPolicy: &llmroute.RouteDirectProviderPolicy{ProviderTarget: llmroute.DirectProviderTarget{ProviderID: "unused"}}}}), + }); err != nil { + t.Fatal(err) + } + defer g.Close() + h := NewHandler(g, map[string]LLMApiHandler{"local-stub": &localLLMApiHandler{}}, nil, HandlerOptions{}) + for i := 0; i < 2; i++ { + req := admitRequest("fixture-bearer") + req.Header.Set("X-Tenant-ID", "forged") + req.Header.Set("traceparent", "00-11111111111111111111111111111111-2222222222222222-01") + rec := httptest.NewRecorder() + if err := h.Dispatch(rec, req, nil); err != nil { + t.Fatal(err) + } + want := http.StatusOK + if limited && i == 1 { + want = http.StatusTooManyRequests + } + if rec.Code != want { + t.Fatalf("request %d status %d want %d", i, rec.Code, want) + } + if !limited { + if len(p.events) != 0 { + t.Fatal("unlimited admission emitted limiter event") + } + continue + } + if len(p.events) != i+1 { + t.Fatalf("events %d want %d", len(p.events), i+1) + } + e := p.events[i] + if err := event.Validate(e, attribution.Policy{}); err != nil { + t.Fatal(err) + } + outcome, reason := "allow", "" + if i == 1 { + outcome, reason = "deny", "rate_limited" + } + if e.Family != "limiter.admission" || e.Outcome != outcome || e.Reason != reason || e.Limiter.Dimension != "llm" || e.Limiter.BackendID != "online.agentguide.community.limiter" { + t.Fatalf("invalid admission DTO %+v", e) + } + if e.Attribution.Subject != "virtualkey:vk-1" || e.Attribution.VirtualKeyID != "vk-1" || e.Attribution.RouteID != "broad-route" || e.Attribution.Tenant != "" || e.Attribution.TraceID != "11111111111111111111111111111111" || e.Attribution.SpanID != rec.Header().Get("X-Span-ID") || e.Attribution.ParentSpanID != "2222222222222222" { + t.Fatalf("invalid trusted attribution %+v", e.Attribution) + } + if i == 0 && e.Limiter.RetryAfter != 0 || i == 1 && e.Limiter.RetryAfter <= 0 { + t.Fatal("retry-after does not match outcome") + } + } + }) + } +} diff --git a/pkg/distribution/AGENTS.md b/pkg/distribution/AGENTS.md new file mode 100644 index 00000000..5bba1da1 --- /dev/null +++ b/pkg/distribution/AGENTS.md @@ -0,0 +1,45 @@ +# Distribution entry packages + +The root rules apply. These packages own reusable command construction and +execution, while `cmd/agw`, `cmd/agwd`, and `cmd/agwctl` own edition-specific +imports. Read `pkg/gateway/AGENTS.md` before changing command behavior involving +routes, Bundle workflows or server adapters. + +The designated reusable surfaces are: + +- `distribution.Edition`, `Assembly` and `Assembly.Clone`, `Assembly.Registry`, `Assembly.Manifest`, `Assembly.AttributionPolicy`, `MetricsConfig`, `OTLPConfig`; +- `caddycmd.Command`, `New`, `Command.Assembly`, `Command.Manifest`, `Command.Run`; +- `daemoncmd.Options`, `ServerOptions`, `NewCommand`, `Run`; +- `ctlcmd.Options`, `NewCommand`, `Run`. + +Their release protection follows `docs/design/enterprise-extension-contract.md`. +Candidate consumer evidence does not declare the repository or an unpublished +tag Enterprise-ready. Community metadata helpers and command annotations are +assembly implementation details. `Assembly.Factories` binds declared module IDs +to separately held callbacks; registries reject undeclared or nil factory entries. +Read `pkg/extension/AGENTS.md` before changing registration or config behavior. +Manifest queries never invoke factories or reconstruct a serving registry. + +Constructors create independent flags and own copies of mutable options. Do not +load dotenv, install signals, or call `os.Exit` during import or construction. +Daemon/CLI `Run` return process exit codes; Caddy's process adapter delegates to +upstream `Main` including its exit behavior. Keep output on each Cobra tree's +writers and avoid package-global flags, output state or command pointers. + +Public signatures and their required transitive types must not expose Community +`internal/` types. Public metrics values translate through the internal adapter. +Run `make test-extension-consumer` when changing this boundary; root `go test +./...` alone cannot discover the separately rooted consumer module. + +`AttributeKeys`, `AttributionProducer` and `EventSinks` are explicit code-owned +runtime inputs, excluded from manifests. Clone owns mutable slices; callbacks +remain distribution-owned. Sink factories create fresh instances for each server +lifecycle, never share closed sinks across Caddy reloads. Sink owners must be +linked and unique, and transport options must pass assembly validation. + +`contracttest` is reusable test infrastructure, not another production SPI. +Its Distribution suite runs independently built entries in both server modes, +CLI apply/export, shared Admin protection, selected-backend admission, all three +LLM ingress profiles, SSE cancellation and persisted usage. Keep its provider +stub on loopback and its event checks content-free. External distributions own +fixture configuration, probe routes, sinks and backends. diff --git a/pkg/distribution/assembly.go b/pkg/distribution/assembly.go new file mode 100644 index 00000000..2cb740cc --- /dev/null +++ b/pkg/distribution/assembly.go @@ -0,0 +1,157 @@ +// Package distribution supplies public value options for reusable binary entries. +// Edition, Assembly, Assembly.Clone, MetricsConfig and OTLPConfig are designated +// reusable entry values; release protection follows the enterprise extension contract. +// Assembly.Registry and Assembly.Manifest freeze linked registrations and metadata. +// Assembly.AttributionPolicy validates the assembly-owned attribute allowlist. +package distribution + +import ( + "encoding/json" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/agent-guide/agent-gateway/pkg/extension" + "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey/limiter" + "github.com/spf13/cobra" + "slices" + "time" +) + +// Edition is distribution-owned identification, never an entitlement decision. +type Edition struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` +} + +// Assembly records explicitly linked modules. Metadata does not link factories; +// main must import the implementations it declares. Factories are explicit +// executable registrations and never appear in serialized metadata. +type Assembly struct { + Edition Edition `json:"edition"` + Modules []extension.Descriptor `json:"modules,omitempty"` + Factories map[string]*extension.Factory `json:"-"` + AttributeKeys []string `json:"-"` + AttributionProducer attribution.Producer `json:"-"` + EventSinks []event.Registration `json:"-"` +} + +// Clone takes ownership of a metadata snapshot without retaining caller slices. +func (a Assembly) Clone() Assembly { + a.AttributeKeys = slices.Clone(a.AttributeKeys) + a.EventSinks = slices.Clone(a.EventSinks) + var factories map[string]*extension.Factory + if a.Factories != nil { + factories = make(map[string]*extension.Factory, len(a.Factories)) + } + for id, f := range a.Factories { + if f != nil { + owned := *f + owned.AdminRoutes = slices.Clone(f.AdminRoutes) + owned.SecretFields = slices.Clone(f.SecretFields) + factories[id] = &owned + } else { + factories[id] = nil + } + } + a.Factories = factories + a.Modules = slices.Clone(a.Modules) + for i := range a.Modules { + m := &a.Modules[i] + m.Capabilities = slices.Clone(m.Capabilities) + m.Dependencies = slices.Clone(m.Dependencies) + m.Roles = slices.Clone(m.Roles) + } + return a +} + +// Annotate attaches a serialized snapshot to a command, without process globals. +// Annotations are diagnostic transport; serving uses the validated registry. +func Annotate(cmd *cobra.Command, a Assembly) { + data, _ := json.Marshal(a.Clone()) + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations["agent-gateway.assembly"] = string(data) +} + +// CommunityRuntime is the metadata shared by both Community server entries. +func CommunityRuntime() Assembly { + const prefix = "online.agentguide.community." + const version = "0.7.0" + a := Assembly{Edition: Edition{Name: "community", Version: version}, Factories: map[string]*extension.Factory{limiter.ModuleID: limiter.Factory(true)}} + a.Modules = append(a.Modules, extension.Descriptor{ID: limiter.ModuleID, Version: version, SchemaVersion: 1, Capabilities: []string{prefix + "limiter"}, Roles: []string{"runtime", "validation", "spi"}}) + a.Modules = append(a.Modules, extension.Descriptor{ID: prefix + "gateway", Version: version, SchemaVersion: 1, Capabilities: []string{prefix + "llm", prefix + "mcp", prefix + "agent", prefix + "metrics", prefix + "admin"}, Roles: []string{"runtime", "validation", "admin"}}) + for _, name := range []string{"anthropic", "anthropicbase", "claudecode", "codex", "deepseek", "gemini", "grok", "ollama", "openai", "openrouter", "qwen", "zhipu"} { + a.Modules = append(a.Modules, extension.Descriptor{ID: prefix + "provider." + name, Version: version, SchemaVersion: 1, Capabilities: []string{prefix + "provider." + name}, Roles: []string{"runtime", "validation"}}) + } + for _, name := range []string{"codex", "opencode"} { + a.Modules = append(a.Modules, extension.Descriptor{ID: prefix + "acp." + name, Version: version, SchemaVersion: 1, Capabilities: []string{prefix + "acp." + name}, Roles: []string{"runtime"}}) + } + return a +} + +// CommunityValidation declares CLI factories; it does not claim server execution. +func CommunityValidation() Assembly { + a := CommunityRuntime() + a.Factories[limiter.ModuleID] = limiter.Factory(false) + modules := a.Modules[:0] + for _, m := range a.Modules { + if slices.Contains(m.Roles, "validation") { + m.Roles = []string{"validation"} + if m.ID == "online.agentguide.community.gateway" { + m.Capabilities = []string{"online.agentguide.community.bundle-validation"} + } + modules = append(modules, m) + } + } + a.Modules = modules + return a +} + +// Registry freezes the explicitly linked metadata and configuration factories. +// Factory IDs must be declared in Modules; no implicit registration is allowed. +func (a Assembly) Registry() (*extension.Registry, error) { + if _, err := a.AttributionPolicy(); err != nil { + return nil, err + } + sinkIDs := map[string]bool{} + linked := map[string]bool{} + for _, m := range a.Modules { + linked[m.ID] = true + } + for _, r := range a.EventSinks { + if !linked[r.ModuleID] || r.New == nil || sinkIDs[r.ModuleID] || r.QueueSize < 0 || r.QueueSize > 4096 || r.WriteBudget < 0 || r.WriteBudget > time.Second { + return nil, &event.Error{Code: "invalid_sink_registration"} + } + sinkIDs[r.ModuleID] = true + } + registrations := make([]extension.Registration, 0, len(a.Modules)) + seen := map[string]bool{} + for _, d := range a.Modules { + seen[d.ID] = true + registrations = append(registrations, extension.Registration{Descriptor: d, Factory: a.Factories[d.ID]}) + } + for id, f := range a.Factories { + if !seen[id] || f == nil { + return nil, &extension.Error{Code: "invalid_registration", Field: "factories"} + } + } + return extension.NewRegistry(registrations...) +} + +// Manifest returns a canonical, pointer-free assembly for serialization. +func (a Assembly) Manifest() (Assembly, error) { + r, err := a.Registry() + if err != nil { + return Assembly{}, err + } + return Assembly{Edition: a.Edition, Modules: r.Manifest().Modules}, nil +} + +// AttributionPolicy validates the exact assembly-owned attribute allowlist. +func (a Assembly) AttributionPolicy() (attribution.Policy, error) { + modules := make([]string, len(a.Modules)) + for i, m := range a.Modules { + modules[i] = m.ID + } + return attribution.NewPolicy(modules, a.AttributeKeys) +} diff --git a/pkg/distribution/caddycmd/command.go b/pkg/distribution/caddycmd/command.go new file mode 100644 index 00000000..36a74666 --- /dev/null +++ b/pkg/distribution/caddycmd/command.go @@ -0,0 +1,47 @@ +// Package caddycmd adapts the upstream Caddy process entry. The designated +// reusable surface, protected when released with its consumer contracts, is: +// Command, New, (*Command).Assembly and (*Command).Run. Run is process-scoped; +// Caddy owns os.Args, signals, output and os.Exit, exactly as in Community main. +package caddycmd + +import ( + "encoding/json" + "fmt" + "github.com/agent-guide/agent-gateway/internal/distributionruntime" + "github.com/agent-guide/agent-gateway/pkg/distribution" + upstream "github.com/caddyserver/caddy/v2/cmd" + "github.com/spf13/cobra" + "os" +) + +// Command holds a distribution snapshot. Standard modules remain main imports. +type Command struct{ assembly distribution.Assembly } + +// New constructs a fresh metadata snapshot without executing Caddy. +func New(a distribution.Assembly) *Command { return &Command{assembly: a.Clone()} } + +// Assembly returns a defensive copy of the explicitly linked metadata. +func (c *Command) Assembly() distribution.Assembly { return c.assembly.Clone() } + +// Run delegates command construction, lifecycle and exit codes to upstream. +func (c *Command) Run() { + if err := distributionruntime.Install(c.assembly); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + upstream.Main() +} + +// Manifest validates and returns canonical, independent metadata. +func (c *Command) Manifest() (distribution.Assembly, error) { return c.assembly.Manifest() } +func init() { + upstream.RegisterCommand(upstream.Command{Name: "manifest", Short: "Show linked Agent Gateway capabilities", CobraFunc: func(cmd *cobra.Command) { + cmd.RunE = func(cmd *cobra.Command, args []string) error { + manifest, err := distributionruntime.Current().Manifest() + if err != nil { + return err + } + return json.NewEncoder(cmd.OutOrStdout()).Encode(manifest) + } + }}) +} diff --git a/pkg/distribution/contracttest/process.go b/pkg/distribution/contracttest/process.go new file mode 100644 index 00000000..79174d3f --- /dev/null +++ b/pkg/distribution/contracttest/process.go @@ -0,0 +1,570 @@ +// Package contracttest exercises independently built distribution binaries. +// It is test infrastructure, not an additional protected production SPI. +package contracttest + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "gopkg.in/yaml.v3" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/agent-guide/agent-gateway/pkg/extension" + extcontract "github.com/agent-guide/agent-gateway/pkg/extension/contracttest" + "golang.org/x/crypto/bcrypt" +) + +// ProcessFixture supplies external assembly inputs. Binaries are named agw, +// agwd and agwctl; callers may give private commands those names in a temp dir. +// EventFileEnv selects an independently owned JSONL sink. ProbePath identifies +// a read-only contributed POST accepting {}. Envelopes must select a backend. +type ProcessFixture struct { + BinDir string + Runtime distribution.Assembly + Validation distribution.Assembly + Envelopes []extension.Envelope + ProbePath string + EventFileEnv string +} + +// Distribution runs both real server modes, CLI apply/export, authenticated +// Admin/body contracts, bounded admission, protocol/stream/cancellation and +// persisted usage checks. It requires only public packages and loopback I/O. +func Distribution(t *testing.T, f ProcessFixture) { + t.Helper() + if f.BinDir == "" || f.ProbePath == "" || f.EventFileEnv == "" || len(f.Envelopes) == 0 { + t.Fatal("incomplete process fixture") + } + policy, err := f.Runtime.AttributionPolicy() + if err != nil { + t.Fatal(err) + } + runtime, err := f.Runtime.Manifest() + if err != nil { + t.Fatal(err) + } + validation, err := f.Validation.Manifest() + if err != nil { + t.Fatal(err) + } + run := func(t *testing.T, dir, binary string, args ...string) []byte { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, filepath.Join(f.BinDir, binary), args...) + cmd.Dir = dir + // The process contract must not inherit a developer's dotenv defaults. + cmd.Env = cleanEnv(f.EventFileEnv) + raw, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s: %v\n%s", binary, err, raw) + } + return raw + } + for _, binary := range []string{"agw", "agwd", "agwctl"} { + raw := run(t, t.TempDir(), binary, "manifest") + var got distribution.Assembly + if json.Unmarshal(raw, &got) != nil { + t.Fatalf("invalid %s manifest", binary) + } + want := runtime + if binary == "agwctl" { + want = validation + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("%s manifest mismatch", binary) + } + } + for _, binary := range []string{"agw", "agwd"} { + t.Run(binary, func(t *testing.T) { + work := t.TempDir() + events := filepath.Join(work, "events.jsonl") + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := l.Addr().String() + _ = l.Close() + hash, err := bcrypt.GenerateFromPassword([]byte("contract-pass"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + configPath := filepath.Join(work, "config.json") + db := filepath.Join(work, "config.db") + args := []string{"--addr", addr, "--admin-addr", addr, "--config-store", db, "--admin-basic-auth-hash", "contract:" + string(hash)} + if binary == "agw" { + config := map[string]any{"admin": map[string]any{"disabled": true}, "apps": map[string]any{ + "agent_gateway": map[string]any{"config_store": map[string]any{"sqlite": map[string]string{"sqlite_path": db}}}, + "http": map[string]any{"servers": map[string]any{"proof": map[string]any{"listen": []string{addr}, "routes": []any{ + map[string]any{"match": []any{map[string]any{"path": []string{"/admin/*"}}}, "handle": []any{map[string]any{"handler": "authentication", "providers": map[string]any{"http_basic": map[string]any{"accounts": []any{map[string]string{"username": "contract", "password": string(hash)}}}}}, map[string]string{"handler": "agent_gateway_admin"}}}, + map[string]any{"handle": []any{map[string]any{"handler": "agent_route_dispatcher", "mcp": true, "api_handlers": map[string]any{"openai": map[string]any{}, "anthropic": map[string]any{}, "cc": map[string]any{}}}}}, + }}}}, + }} + raw, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(configPath, raw, 0600); err != nil { + t.Fatal(err) + } + args = []string{"run", "--config", configPath} + } + logPath := filepath.Join(work, "server.log") + log, err := os.Create(logPath) + if err != nil { + t.Fatal(err) + } + defer log.Close() + cmd := exec.Command(filepath.Join(f.BinDir, binary), args...) + cmd.Dir = work + cmd.Env = append(cleanEnv(f.EventFileEnv), f.EventFileEnv+"="+events) + cmd.Stdout = log + cmd.Stderr = log + if err = cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + stopped := false + t.Cleanup(func() { + if !stopped { + _ = cmd.Process.Kill() + <-done + } + if t.Failed() { + raw, _ := os.ReadFile(logPath) + t.Logf("server log: %s", raw) + } + }) + client := &http.Client{Timeout: 5 * time.Second} + base := "http://" + addr + send := func(req *http.Request) (*http.Response, error) { + req.SetBasicAuth("contract", "contract-pass") + return client.Do(req) + } + poll(t, func() bool { + req, _ := http.NewRequest("GET", base+"/admin/manifest", nil) + resp, err := send(req) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == 200 + }) + call := func(method, path, body string, status int) []byte { + t.Helper() + req, err := http.NewRequest(method, base+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := send(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != status { + t.Fatalf("%s %s: %d %s", method, path, resp.StatusCode, raw) + } + return raw + } + var got distribution.Assembly + if json.Unmarshal(call("GET", "/admin/manifest", "", 200), &got) != nil || !reflect.DeepEqual(got, runtime) { + t.Fatal("serving manifest mismatch") + } + canceled := make(chan struct{}, 1) + var upstreamCalls atomic.Int64 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + var req struct { + Stream bool `json:"stream"` + Messages []struct { + Content any `json:"content"` + } `json:"messages"` + } + if json.NewDecoder(r.Body).Decode(&req) != nil { + http.Error(w, "invalid request", 400) + return + } + if !req.Stream { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"id":"proof","object":"chat.completion","model":"proof","choices":[{"index":0,"message":{"role":"assistant","content":"proof-response"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`) + return + } + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, "data: {\"id\":\"proof\",\"object\":\"chat.completion.chunk\",\"model\":\"proof\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"proof-response\"}}]}\n\n") + w.(http.Flusher).Flush() + raw, _ := json.Marshal(req.Messages) + if bytes.Contains(raw, []byte("cancel-proof")) { + <-r.Context().Done() + select { + case canceled <- struct{}{}: + default: + } + return + } + io.WriteString(w, "data: {\"id\":\"proof\",\"object\":\"chat.completion.chunk\",\"model\":\"proof\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2,\"total_tokens\":5}}\n\ndata: [DONE]\n\n") + })) + defer upstream.Close() + mcpUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var message struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + if json.NewDecoder(r.Body).Decode(&message) != nil { + http.Error(w, "invalid MCP request", 400) + return + } + w.Header().Set("Content-Type", "application/json") + var result any + switch message.Method { + case "initialize": + result = map[string]any{"protocolVersion": "2025-11-25", "capabilities": map[string]any{}, "serverInfo": map[string]string{"name": "proof-mcp", "version": "1"}} + w.Header().Set("MCP-Session-Id", "proof-session") + case "tools/list": + result = map[string]any{"tools": []any{map[string]any{"name": "echo", "inputSchema": map[string]string{"type": "object"}}}} + case "tools/call": + result = map[string]any{"content": []any{map[string]string{"type": "text", "text": "proof-tool-result"}}} + default: + w.WriteHeader(202) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": message.ID, "result": result}) + })) + defer mcpUpstream.Close() + routes := []any{} + ids := []string{"proof-mcp"} + for _, protocol := range []string{"openai", "anthropic", "cc"} { + id := "proof-" + protocol + ids = append(ids, id) + routes = append(routes, map[string]any{"id": id, "protocol": protocol, "match_policy": map[string]string{"path_prefix": "/" + id}, "auth_policy": map[string]bool{"require_virtual_key": true}, "target_policy": map[string]any{"provider_target": map[string]string{"provider_id": "proof-provider"}}}) + } + bundle := map[string]any{"apiVersion": "gateway.agw/v1alpha1", "kind": "GatewayBundle", "extensions": f.Envelopes, "providers": []any{map[string]any{"id": "proof-provider", "provider_type": "openai", "base_url": upstream.URL + "/v1", "api_key": "proof-upstream-secret"}}, "llmRoutes": routes, "virtualKeys": []any{map[string]any{"id": "proof-key", "allowed_route_ids": ids, "rate_limits": map[string]any{"llm": map[string]int{"requests_per_minute": 1, "burst": 16}}}}} + bundle["mcpServices"] = []any{map[string]any{"id": "proof-mcp-service", "name": "Proof MCP service", "transport": "streamable_http", "url": mcpUpstream.URL}} + bundle["mcpRoutes"] = []any{map[string]any{"id": "proof-mcp", "service_id": "proof-mcp-service", "match_policy": map[string]string{"path_prefix": "/proof-mcp"}, "auth_policy": map[string]bool{"require_virtual_key": true}}} + raw, err := json.Marshal(bundle) + if err != nil { + t.Fatal(err) + } + bundlePath := filepath.Join(work, "bundle.json") + if err = os.WriteFile(bundlePath, raw, 0600); err != nil { + t.Fatal(err) + } + cliArgs := []string{"--admin-addr", base, "--admin-basic-auth", "contract:contract-pass"} + run(t, work, "agwctl", append(append([]string{}, cliArgs...), "validate", "-f", bundlePath)...) + run(t, work, "agwctl", append(append([]string{}, cliArgs...), "apply", "-f", bundlePath)...) + exported := run(t, work, "agwctl", append(append([]string{}, cliArgs...), "export")...) + var exportedBundle struct { + Extensions []struct { + ModuleID string `yaml:"module_id"` + SchemaVersion int `yaml:"schema_version"` + Config map[string]any `yaml:"config"` + } `yaml:"extensions"` + } + if yaml.Unmarshal(exported, &exportedBundle) != nil { + t.Fatal("invalid exported Bundle") + } + registry, err := f.Runtime.Registry() + if err != nil { + t.Fatal(err) + } + expected, err := registry.Validate(t.Context(), f.Envelopes) + if err != nil { + t.Fatal(err) + } + if len(exportedBundle.Extensions) != len(expected) { + t.Fatal("export lost module") + } + for _, e := range expected { + found := false + for _, actual := range exportedBundle.Extensions { + if actual.ModuleID == e.ModuleID { + var config map[string]any + actualJSON, _ := json.Marshal(actual.Config) + expectedJSON, _ := json.Marshal(json.RawMessage(e.Config)) + _ = json.Unmarshal(actualJSON, &config) + var expectedConfig map[string]any + _ = json.Unmarshal(expectedJSON, &expectedConfig) + if actual.SchemaVersion != e.SchemaVersion || !reflect.DeepEqual(config, expectedConfig) { + t.Fatal("export changed reference configuration") + } + found = true + } + } + if !found { + t.Fatal("export lost module") + } + } + resp, err := client.Post(base+f.ProbePath, "application/json", strings.NewReader(`{}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 401 { + t.Fatal("contributor bypassed authentication") + } + probeBefore := call("POST", f.ProbePath, `{}`, 200) + extcontract.AdminBody(t, extcontract.AdminBodyFixture{URL: base + "/admin/llm/providers", Method: "POST", Body: []byte(`{"id":"body-policy","provider_type":"openai","base_url":"http://127.0.0.1"}`), SuccessStatus: 201, Send: send, CheckUnchanged: func(t *testing.T) { call("GET", "/admin/llm/providers/body-policy", "", 404) }}) + extcontract.AdminBody(t, extcontract.AdminBodyFixture{URL: base + f.ProbePath, Method: "POST", Body: []byte(`{}`), SuccessStatus: 200, Send: send, CheckUnchanged: func(t *testing.T) { + if !bytes.Equal(probeBefore, call("POST", f.ProbePath, `{}`, 200)) { + t.Fatal("rejected body changed probe") + } + }}) + // Both hosts must recover configured resources from ConfigStore without + // receiving extension envelopes in their startup configuration. + if err = cmd.Process.Signal(os.Interrupt); err != nil { + t.Fatal(err) + } + select { + case err = <-done: + stopped = true + if err != nil { + t.Fatal(err) + } + case <-time.After(10 * time.Second): + t.Fatal("restart shutdown deadline") + } + cmd = exec.Command(filepath.Join(f.BinDir, binary), args...) + cmd.Dir = work + cmd.Env = append(cleanEnv(f.EventFileEnv), f.EventFileEnv+"="+events) + cmd.Stdout, cmd.Stderr = log, log + if err = cmd.Start(); err != nil { + t.Fatal(err) + } + stopped = false + go func() { done <- cmd.Wait() }() + poll(t, func() bool { + req, _ := http.NewRequest("GET", base+"/admin/manifest", nil) + resp, err := send(req) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == 200 + }) + if !bytes.Equal(probeBefore, call("POST", f.ProbePath, `{}`, 200)) { + t.Fatal("persisted extension resource not restored after restart") + } + for _, e := range expected { + raw := call("GET", "/admin/extensions/"+e.ModuleID, "", 200) + var restored extension.Envelope + if json.Unmarshal(raw, &restored) != nil { + t.Fatal("invalid restored envelope") + } + var actualConfig, expectedConfig any + _ = json.Unmarshal(restored.Config, &actualConfig) + _ = json.Unmarshal(e.Config, &expectedConfig) + if restored.ModuleID != e.ModuleID || restored.SchemaVersion != e.SchemaVersion || !reflect.DeepEqual(actualConfig, expectedConfig) { + t.Fatal("persisted extension config changed after restart") + } + } + var key struct { + Key string `json:"key"` + } + if json.Unmarshal(call("GET", "/admin/virtual_keys/proof-key", "", 200), &key) != nil || key.Key == "" { + t.Fatal("missing VirtualKey") + } + request := func(protocol string, stream bool, message string) *http.Request { + path := "/proof-" + protocol + "/v1/messages" + body := map[string]any{"model": "proof", "max_tokens": 16, "stream": stream, "messages": []any{map[string]string{"role": "user", "content": message}}} + if protocol == "openai" { + path = "/proof-openai/v1/chat/completions" + } + raw, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", base+path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+key.Key) + req.Header.Set("X-Tenant-ID", "untrusted-tenant") + return req + } + mcpReq, _ := http.NewRequest("POST", base+"/proof-mcp", strings.NewReader(`{"jsonrpc":"2.0","id":"proof-call","method":"tools/call","params":{"name":"echo","arguments":{}}}`)) + mcpReq.Header.Set("Authorization", "Bearer "+key.Key) + mcpReq.Header.Set("Content-Type", "application/json") + mcpResp, err := client.Do(mcpReq) + if err != nil { + t.Fatal(err) + } + mcpRaw, err := io.ReadAll(mcpResp.Body) + mcpResp.Body.Close() + if err != nil || mcpResp.StatusCode != 200 || !json.Valid(mcpRaw) || !bytes.Contains(mcpRaw, []byte("proof-tool-result")) { + t.Fatalf("MCP tool execution: %d %s %v", mcpResp.StatusCode, mcpRaw, err) + } + poll(t, func() bool { + raw := call("GET", "/admin/metrics/mcp/events?service_id=proof-mcp-service", "", 200) + return bytes.Contains(raw, []byte(`"tool_name":"echo"`)) && bytes.Contains(raw, []byte(`"success":true`)) + }) + for _, protocol := range []string{"openai", "anthropic", "cc"} { + for _, stream := range []bool{false, true} { + resp, err := client.Do(request(protocol, stream, "hello")) + if err != nil { + t.Fatal(err) + } + raw, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil || resp.StatusCode != 200 || !bytes.Contains(raw, []byte("proof-response")) { + t.Fatalf("%s stream=%v: %d %s %v", protocol, stream, resp.StatusCode, raw, err) + } + if stream { + terminal := "event: message_stop" + if protocol == "openai" { + terminal = "data: [DONE]" + } + if !bytes.Contains(raw, []byte(terminal)) { + t.Fatalf("missing %s terminal", protocol) + } + } else if !json.Valid(raw) { + t.Fatal("invalid protocol response") + } + } + } + req := request("openai", true, "cancel-proof") + ctx, cancel := context.WithCancel(t.Context()) + resp, err = client.Do(req.WithContext(ctx)) + if err != nil { + cancel() + t.Fatal(err) + } + reader := bufio.NewReader(resp.Body) + if _, err = reader.ReadString('\n'); err != nil { + cancel() + resp.Body.Close() + t.Fatal(err) + } + cancel() + resp.Body.Close() + select { + case <-canceled: + case <-time.After(5 * time.Second): + t.Fatal("upstream stream was not canceled") + } + // Six completed requests account for 30 tokens; cancellation must finalize + // its interaction without creating an extra completed upstream invocation. + poll(t, func() bool { + raw := call("GET", "/admin/metrics/llm/events?limit=100&provider_id=proof-provider", "", 200) + var view struct { + Items []map[string]any `json:"items"` + } + if json.Unmarshal(raw, &view) != nil { + t.Fatal("invalid usage response") + } + completed := map[string]int{} + canceledCount := 0 + for _, item := range view.Items { + success := item["success"] == float64(1) || item["success"] == true + if success && item["input_tokens"] == float64(3) && item["output_tokens"] == float64(2) { + protocol, _ := item["llm_api"].(string) + completed[protocol]++ + } + if item["usage_finalized"] == false && item["input_tokens"] == float64(0) && item["output_tokens"] == float64(0) { + canceledCount++ + } + } + return len(view.Items) == 7 && completed["openai"] == 2 && completed["anthropic"] == 2 && completed["cc"] == 2 && canceledCount == 1 + }) + // Exhaust the fixed burst; rejection occurs before any upstream execution. + for i := 0; i < 9; i++ { + resp, err := client.Do(request("openai", false, "hello")) + if err != nil { + t.Fatal(err) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("premature limit: %d", resp.StatusCode) + } + } + callsBeforeDenial := upstreamCalls.Load() + resp, err = client.Do(request("openai", false, "hello")) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 429 || resp.Header.Get("Retry-After") == "" || upstreamCalls.Load() != callsBeforeDenial { + t.Fatal("missing bounded admission rejection") + } + if err = cmd.Process.Signal(os.Interrupt); err != nil { + t.Fatal(err) + } + select { + case err = <-done: + stopped = true + if err != nil { + t.Fatal(err) + } + case <-time.After(10 * time.Second): + t.Fatal("shutdown deadline") + } + raw, err = os.ReadFile(events) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(raw, []byte("proof-upstream-secret")) || bytes.Contains(raw, []byte("proof-response")) || bytes.Contains(raw, []byte(key.Key)) || bytes.Contains(raw, []byte("untrusted-tenant")) { + t.Fatal("public event leaked private content") + } + seenAdmission, seenMutation := false, false + expectedBackend := "" + for _, e := range f.Envelopes { + if e.ModuleID == "online.agentguide.community.limiter" { + var cfg struct { + BackendModuleID string `json:"backend_module_id"` + } + _ = json.Unmarshal(e.Config, &cfg) + expectedBackend = cfg.BackendModuleID + } + } + scanner := bufio.NewScanner(bytes.NewReader(raw)) + for scanner.Scan() { + var e event.Envelope + if json.Unmarshal(scanner.Bytes(), &e) != nil || event.Validate(e, policy) != nil { + t.Fatal("invalid sanitized event") + } + seenAdmission = seenAdmission || (e.Family == "limiter.admission" && e.Limiter.BackendID == expectedBackend) + seenMutation = seenMutation || e.Family == "extension.mutation" + } + if scanner.Err() != nil || !seenAdmission || !seenMutation { + t.Fatal("missing flushed event families") + } + }) + } +} + +func cleanEnv(eventEnv string) []string { + var result []string + for _, entry := range os.Environ() { + if strings.HasPrefix(entry, "AGW_ADMIN_") || strings.HasPrefix(entry, eventEnv+"=") { + continue + } + result = append(result, entry) + } + return result +} +func poll(t *testing.T, check func() bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if check() { + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatal(fmt.Sprintf("process condition did not pass before %s", deadline.Format(time.RFC3339))) +} diff --git a/cmd/agwctl/a_env.go b/pkg/distribution/ctlcmd/a_env.go similarity index 66% rename from cmd/agwctl/a_env.go rename to pkg/distribution/ctlcmd/a_env.go index 671d1c2f..7b6ea9ef 100644 --- a/cmd/agwctl/a_env.go +++ b/pkg/distribution/ctlcmd/a_env.go @@ -1,4 +1,4 @@ -package main +package ctlcmd import ( "errors" @@ -8,16 +8,6 @@ import ( "github.com/joho/godotenv" ) -// init runs before any cmd_*.go init() because file names beginning with "a" -// sort earlier than "c". This guarantees that envOr() calls in flag defaults -// already see the values from .env.local / .env. -func init() { - if err := loadRuntimeEnv(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v diff --git a/pkg/distribution/ctlcmd/cmd_caddy.go b/pkg/distribution/ctlcmd/cmd_caddy.go new file mode 100644 index 00000000..5b1360c6 --- /dev/null +++ b/pkg/distribution/ctlcmd/cmd_caddy.go @@ -0,0 +1,248 @@ +package ctlcmd + +import ( + "fmt" + "strings" + + "github.com/agent-guide/agent-gateway/internal/agwctl/caddyadminclient" + "github.com/spf13/cobra" +) + +func parseHandlers(specs []string) ([]caddyadminclient.HandlerConf, error) { + handlers := make([]caddyadminclient.HandlerConf, 0, len(specs)) + for _, spec := range specs { + htype, params, _ := strings.Cut(spec, ":") + conf := caddyadminclient.HandlerConf{Type: htype} + if params != "" { + for _, kv := range strings.Split(params, ",") { + k, v, _ := strings.Cut(kv, "=") + switch k { + case "apis": + conf.APIs = strings.Split(v, ";") + case "upstream": + conf.Upstream = v + case "root": + conf.Root = v + default: + return nil, fmt.Errorf("unknown handler param %q in %q", k, spec) + } + } + } + handlers = append(handlers, conf) + } + return handlers, nil +} +func (s *commandState) initCaddy() { + s.caddyCmd = &cobra.Command{ + Use: "caddy", + Short: "Manage Caddy servers and routes via the Caddy admin API", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("admin-addr") { + return fmt.Errorf("--admin-addr configures the Agent Gateway admin API; use --addr for the Caddy admin API") + } + return nil + }, + } + s.caddyServerCmd = &cobra.Command{ + Use: "server", + Short: "Manage Caddy HTTP servers", + } + s.caddyServerListCmd = &cobra.Command{ + Use: "list", + Short: "List all Caddy HTTP servers", + RunE: func(cmd *cobra.Command, args []string) error { + mgr := caddyadminclient.NewManager(s.globalCaddyAdmin) + servers, err := mgr.ListServers(cmd.Context()) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(servers) + } + s.printCaddyServersTable(servers) + return nil + }, + } + s.caddyServerGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a Caddy HTTP server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + mgr := caddyadminclient.NewManager(s.globalCaddyAdmin) + srv, err := mgr.GetServer(cmd.Context(), args[0]) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(srv) + } + s.printCaddyServersTable([]*caddyadminclient.ServerResponse{srv}) + return nil + }, + } + s.caddyServerCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new Caddy HTTP server", + RunE: func(cmd *cobra.Command, args []string) error { + if s.serverCreateID == "" { + return fmt.Errorf("--id is required") + } + if len(s.serverCreateListen) == 0 { + return fmt.Errorf("--listen is required") + } + mgr := caddyadminclient.NewManager(s.globalCaddyAdmin) + req := &caddyadminclient.ServerRequest{ + ID: s.serverCreateID, + Listen: s.serverCreateListen, + } + if s.serverCreateTLSAuto { + req.TLS = &caddyadminclient.TLSConf{Auto: true} + } + if err := mgr.CreateServer(cmd.Context(), req); err != nil { + return err + } + srv, err := mgr.GetServer(cmd.Context(), s.serverCreateID) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(srv) + } + s.printCaddyServersTable([]*caddyadminclient.ServerResponse{srv}) + return nil + }, + } + s.caddyServerUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an existing Caddy HTTP server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(s.serverUpdateListen) == 0 { + return fmt.Errorf("--listen is required") + } + mgr := caddyadminclient.NewManager(s.globalCaddyAdmin) + req := &caddyadminclient.ServerRequest{ + ID: args[0], + Listen: s.serverUpdateListen, + } + if s.serverUpdateTLSAuto { + req.TLS = &caddyadminclient.TLSConf{Auto: true} + } + if err := mgr.UpdateServer(cmd.Context(), req); err != nil { + return err + } + srv, err := mgr.GetServer(cmd.Context(), args[0]) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(srv) + } + s.printCaddyServersTable([]*caddyadminclient.ServerResponse{srv}) + return nil + }, + } + s.caddyServerDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a Caddy HTTP server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + mgr := caddyadminclient.NewManager(s.globalCaddyAdmin) + if err := mgr.DeleteServer(cmd.Context(), args[0]); err != nil { + return err + } + fmt.Fprintf(s.rootCmd.OutOrStdout(), "deleted server %q\n", args[0]) + return nil + }, + } + s.caddyRouteCmd = &cobra.Command{ + Use: "route", + Short: "Manage Caddy HTTP routes within a server", + } + s.caddyRouteListCmd = &cobra.Command{ + Use: "list ", + Short: "List routes in a Caddy server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + mgr := caddyadminclient.NewManager(s.globalCaddyAdmin) + routes, err := mgr.ListRoutes(cmd.Context(), args[0]) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(routes) + } + s.printCaddyRoutesTable(routes) + return nil + }, + } + s.caddyRouteAddCmd = &cobra.Command{ + Use: "add ", + Short: "Add a route to a Caddy server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if s.routeAddID == "" { + return fmt.Errorf("--id is required") + } + handlers, err := parseHandlers(s.routeAddHandlers) + if err != nil { + return err + } + mgr := caddyadminclient.NewManager(s.globalCaddyAdmin) + req := &caddyadminclient.RouteRequest{ + ID: s.routeAddID, + Order: s.routeAddOrder, + Match: caddyadminclient.MatchConf{Paths: s.routeAddPaths, Hosts: s.routeAddHosts}, + Handlers: handlers, + } + if err := mgr.AddRoute(cmd.Context(), args[0], req); err != nil { + return err + } + routes, err := mgr.ListRoutes(cmd.Context(), args[0]) + if err != nil { + return err + } + for _, r := range routes { + if r.ID == s.routeAddID { + if s.outputFormat == "json" { + return s.printJSON(r) + } + s.printCaddyRoutesTable([]*caddyadminclient.RouteResponse{r}) + return nil + } + } + fmt.Fprintf(s.rootCmd.OutOrStdout(), "added route %q\n", s.routeAddID) + return nil + }, + } + s.caddyRouteDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a route from a Caddy server", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + mgr := caddyadminclient.NewManager(s.globalCaddyAdmin) + if err := mgr.DeleteRoute(cmd.Context(), args[0], args[1]); err != nil { + return err + } + fmt.Fprintf(s.rootCmd.OutOrStdout(), "deleted route %q from server %q\n", args[1], args[0]) + return nil + }, + } + s.caddyCmd.PersistentFlags().StringVar(&s.globalCaddyAdmin, "addr", envOr("CADDY_ADMIN_ADDR", "http://localhost:2019"), "Caddy admin API address") + s.caddyCmd.PersistentFlags().StringVar(&s.rejectedCaddyAdminAddr, "admin-addr", "", "") + _ = s.caddyCmd.PersistentFlags().MarkHidden("admin-addr") + s.caddyServerCreateCmd.Flags().StringVar(&s.serverCreateID, "id", "", "server ID (required)") + s.caddyServerCreateCmd.Flags().StringArrayVar(&s.serverCreateListen, "listen", nil, "listen addresses (repeatable)") + s.caddyServerCreateCmd.Flags().BoolVar(&s.serverCreateTLSAuto, "tls-auto", false, "enable automatic TLS") + s.caddyServerUpdateCmd.Flags().StringArrayVar(&s.serverUpdateListen, "listen", nil, "listen addresses (repeatable)") + s.caddyServerUpdateCmd.Flags().BoolVar(&s.serverUpdateTLSAuto, "tls-auto", false, "enable automatic TLS") + s.caddyRouteAddCmd.Flags().StringVar(&s.routeAddID, "id", "", "route ID (required)") + s.caddyRouteAddCmd.Flags().IntVar(&s.routeAddOrder, "order", 0, "insertion position (0 = first)") + s.caddyRouteAddCmd.Flags().StringArrayVar(&s.routeAddPaths, "path", nil, "match path (repeatable)") + s.caddyRouteAddCmd.Flags().StringArrayVar(&s.routeAddHosts, "host", nil, "match host (repeatable)") + s.caddyRouteAddCmd.Flags().StringArrayVar(&s.routeAddHandlers, "handler", nil, `handler spec: type[:key=val,...] (repeatable)`) + s.caddyServerCmd.AddCommand(s.caddyServerListCmd, s.caddyServerGetCmd, s.caddyServerCreateCmd, s.caddyServerUpdateCmd, s.caddyServerDeleteCmd) + s.caddyRouteCmd.AddCommand(s.caddyRouteListCmd, s.caddyRouteAddCmd, s.caddyRouteDeleteCmd) + s.caddyCmd.AddCommand(s.caddyServerCmd, s.caddyRouteCmd) + s.rootCmd.AddCommand(s.caddyCmd) +} diff --git a/pkg/distribution/ctlcmd/cmd_gateway.go b/pkg/distribution/ctlcmd/cmd_gateway.go new file mode 100644 index 00000000..edf7a361 --- /dev/null +++ b/pkg/distribution/ctlcmd/cmd_gateway.go @@ -0,0 +1,772 @@ +package ctlcmd + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/agent-guide/agent-gateway/pkg/adminclient" + "github.com/agent-guide/agent-gateway/pkg/gatewaybundle" + "github.com/spf13/cobra" +) + +func (s *commandState) newGatewayClient() *adminclient.Client { + return adminclient.New(adminclient.Config{ + BaseURL: s.globalGatewayAddr, + BasicAuth: s.gwAdminBasicAuth, + Headers: s.gwAdminHeaders, + }) +} + +func parseOptionalJSONObjectFlag(raw string, flagName string) (map[string]any, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var payload map[string]any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, fmt.Errorf("%s must be a JSON object: %w", flagName, err) + } + if payload == nil { + return nil, fmt.Errorf("%s must be a JSON object", flagName) + } + return payload, nil +} +func (s *commandState) initGateway() { + s.gatewayValidateCmd = &cobra.Command{ + Use: "validate", + Short: "Validate a gateway bundle YAML file locally", + RunE: func(cmd *cobra.Command, args []string) error { + if s.gatewayBundleFile == "" { + return fmt.Errorf("--file is required") + } + bundle, err := gatewaybundle.LoadFile(s.gatewayBundleFile) + if err != nil { + return err + } + if err := bundle.ValidateWithRegistry(cmd.Context(), s.extensionRegistry); err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(map[string]any{ + "status": "ok", + "file": s.gatewayBundleFile, + "counts": map[string]int{ + "providers": len(bundle.Providers), + "managed_models": len(bundle.ManagedModels), + "llm_routes": len(bundle.LLMRoutes), + "virtual_keys": len(bundle.VirtualKeys), + "mcp_services": len(bundle.MCPServices), + "mcp_routes": len(bundle.MCPRoutes), + "agent_routes": len(bundle.AgentRoutes), + "agents": len(bundle.Agents), + }, + }) + } + fmt.Fprintf(cmd.OutOrStdout(), "gateway bundle is valid: %s\n", s.gatewayBundleFile) + return nil + }, + } + s.gatewayApplyCmd = &cobra.Command{ + Use: "apply", + Short: "Apply a gateway bundle YAML file to the remote admin API", + RunE: func(cmd *cobra.Command, args []string) error { + return s.runGatewayApply(cmd.Context(), s.gatewayBundleFile) + }, + } + s.gatewayExportCmd = &cobra.Command{ + Use: "export", + Short: "Export gateway objects from the remote admin API as bundle YAML", + RunE: func(cmd *cobra.Command, args []string) error { + return s.runGatewayExport(cmd.Context(), s.gatewayExportFile) + }, + } + s.gatewayProviderCmd = &cobra.Command{ + Use: "provider", + Short: "Manage gateway providers", + } + s.gatewayProviderListCmd = &cobra.Command{ + Use: "list", + Short: "List all providers", + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListProviders(cmd.Context(), adminclient.ProviderListOptions{}) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayProvidersTable(items) + return nil + }, + } + s.gatewayProviderGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get one provider", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().GetProvider(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayProviderDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a provider", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().DeleteProvider(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(resp) + }, + } + s.gatewayProviderEnableCmd = &cobra.Command{ + Use: "enable ", + Short: "Enable a provider", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().EnableProvider(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayProviderDisableCmd = &cobra.Command{ + Use: "disable ", + Short: "Disable a provider", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().DisableProvider(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayLLMRouteCmd = &cobra.Command{ + Use: "llm-route", + Short: "Manage gateway LLM routes", + } + s.gatewayLLMRouteListCmd = &cobra.Command{ + Use: "list", + Short: "List all LLM routes", + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListLLMRoutes(cmd.Context(), adminclient.LLMRouteListOptions{}) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayLLMRoutesTable(items) + return nil + }, + } + s.gatewayLLMRouteGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get one LLM route", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().GetLLMRoute(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayLLMRouteDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an LLM route", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().DeleteLLMRoute(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(resp) + }, + } + s.gatewayLLMRouteEnableCmd = &cobra.Command{ + Use: "enable ", + Short: "Enable an LLM route", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().EnableLLMRoute(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayLLMRouteDisableCmd = &cobra.Command{ + Use: "disable ", + Short: "Disable an LLM route", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().DisableLLMRoute(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayMCPServiceCmd = &cobra.Command{ + Use: "mcp-service", + Short: "Manage gateway MCP services", + } + s.gatewayMCPServiceListCmd = &cobra.Command{ + Use: "list", + Short: "List all MCP services", + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListMCPServices(cmd.Context()) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayMCPServicesTable(items) + return nil + }, + } + s.gatewayMCPServiceGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get one MCP service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().GetMCPService(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayMCPServiceDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an MCP service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().DeleteMCPService(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(resp) + }, + } + s.gatewayMCPServiceSessionCmd = &cobra.Command{ + Use: "session ", + Short: "Get the active gateway session for an MCP service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().GetMCPServiceSession(cmd.Context(), args[0]) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(item) + } + s.printGatewayMCPSessionTable(item) + return nil + }, + } + s.gatewayMCPServiceCapabilitiesCmd = &cobra.Command{ + Use: "capabilities ", + Short: "Get the upstream initialize payload for an MCP service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().GetMCPServiceCapabilities(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayMCPServiceToolsCmd = &cobra.Command{ + Use: "tools ", + Short: "List tools exposed by an MCP service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListMCPServiceTools(cmd.Context(), args[0]) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayMCPToolsTable(items) + return nil + }, + } + s.gatewayMCPServiceToolCallCmd = &cobra.Command{ + Use: "tool-call ", + Short: "Call a tool exposed by an MCP service", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + arguments, err := parseOptionalJSONObjectFlag(s.gatewayMCPToolArguments, "--arguments") + if err != nil { + return err + } + item, err := s.newGatewayClient().CallMCPServiceTool(cmd.Context(), args[0], adminclient.MCPToolCallRequest{ + Name: args[1], + Arguments: arguments, + }) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayMCPServiceResourcesCmd = &cobra.Command{ + Use: "resources ", + Short: "List resources exposed by an MCP service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListMCPServiceResources(cmd.Context(), args[0]) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayMCPResourcesTable(items) + return nil + }, + } + s.gatewayMCPServiceResourceTemplatesCmd = &cobra.Command{ + Use: "resource-templates ", + Short: "List resource templates exposed by an MCP service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListMCPServiceResourceTemplates(cmd.Context(), args[0]) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayMCPResourceTemplatesTable(items) + return nil + }, + } + s.gatewayMCPServiceResourceReadCmd = &cobra.Command{ + Use: "resource-read ", + Short: "Read one resource exposed by an MCP service", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().ReadMCPServiceResource(cmd.Context(), args[0], adminclient.MCPResourceReadRequest{ + URI: args[1], + }) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayMCPServicePromptsCmd = &cobra.Command{ + Use: "prompts ", + Short: "List prompts exposed by an MCP service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListMCPServicePrompts(cmd.Context(), args[0]) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayMCPPromptsTable(items) + return nil + }, + } + s.gatewayMCPServicePromptGetCmd = &cobra.Command{ + Use: "prompt-get ", + Short: "Get one prompt from an MCP service", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + arguments, err := parseOptionalJSONObjectFlag(s.gatewayMCPPromptArguments, "--arguments") + if err != nil { + return err + } + item, err := s.newGatewayClient().GetMCPServicePrompt(cmd.Context(), args[0], adminclient.MCPPromptGetRequest{ + Name: args[1], + Arguments: arguments, + }) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayMCPRouteCmd = &cobra.Command{ + Use: "mcp-route", + Short: "Manage gateway MCP routes", + } + s.gatewayMCPRouteListCmd = &cobra.Command{ + Use: "list", + Short: "List all MCP routes", + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListMCPRoutes(cmd.Context()) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayMCPRoutesTable(items) + return nil + }, + } + s.gatewayMCPRouteGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get one MCP route", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().GetMCPRoute(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayMCPRouteDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an MCP route", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().DeleteMCPRoute(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(resp) + }, + } + s.gatewayMCPRuntimeCmd = &cobra.Command{ + Use: "mcp-runtime", + Short: "Inspect gateway MCP runtime state", + } + s.gatewayMCPRuntimeGetCmd = &cobra.Command{ + Use: "get", + Short: "Get the MCP runtime overview", + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().GetMCPRuntime(cmd.Context()) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(item) + } + s.printGatewayMCPRuntimeOverview(item) + return nil + }, + } + s.gatewayMCPRuntimeInFlightCmd = &cobra.Command{ + Use: "inflight", + Short: "List in-flight MCP requests", + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListMCPRuntimeInFlight(cmd.Context()) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayMCPRuntimeInFlightTable(items) + return nil + }, + } + s.gatewayMCPRuntimeProgressCmd = &cobra.Command{ + Use: "progress", + Short: "List MCP progress notifications", + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListMCPRuntimeProgress(cmd.Context()) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayMCPRuntimeProgressTable(items) + return nil + }, + } + s.gatewayMCPRuntimeHistoryCmd = &cobra.Command{ + Use: "history", + Short: "List completed MCP request history", + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListMCPRuntimeHistory(cmd.Context(), s.gatewayMCPRuntimeRouteID) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayMCPRuntimeHistoryTable(items) + return nil + }, + } + s.gatewayVirtualKeyCmd = &cobra.Command{ + Use: "virtualkey", + Short: "Manage gateway virtual keys", + } + s.gatewayVirtualKeyListCmd = &cobra.Command{ + Use: "list", + Short: "List all virtual keys", + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListVirtualKeys(cmd.Context(), adminclient.VirtualKeyListOptions{}) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayVirtualKeysTable(items) + return nil + }, + } + s.gatewayVirtualKeyGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get one virtual key by id", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().GetVirtualKey(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayVirtualKeyDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a virtual key by id", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().DeleteVirtualKey(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(resp) + }, + } + s.gatewayVirtualKeyEnableCmd = &cobra.Command{ + Use: "enable ", + Short: "Enable a virtual key by id", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().EnableVirtualKey(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayVirtualKeyDisableCmd = &cobra.Command{ + Use: "disable ", + Short: "Disable a virtual key by id", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().DisableVirtualKey(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayCredentialCmd = &cobra.Command{ + Use: "credential", + Short: "Manage gateway credentials", + } + s.gatewayModelsCmd = &cobra.Command{ + Use: "model", + Short: "Manage gateway models", + } + s.gatewayProviderTypesCmd = &cobra.Command{ + Use: "provider-type", + Short: "Inspect gateway provider types", + } + s.gatewayProviderTypesListCmd = &cobra.Command{ + Use: "list", + Short: "List provider types known to the gateway runtime", + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListProviderTypes(cmd.Context()) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayProviderTypesTable(items) + return nil + }, + } + s.gatewayLLMAPIHandlerTypesCmd = &cobra.Command{ + Use: "llm-api-handler-type", + Short: "Manage gateway LLM API handler types", + } + s.gatewayLLMAPIHandlerTypesListCmd = &cobra.Command{ + Use: "list", + Short: "List LLM API handler types known to the gateway runtime", + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListLLMAPIHandlerTypes(cmd.Context()) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayLLMAPIHandlerTypesTable(items) + return nil + }, + } + s.gatewayModelsDiscoveredCmd = &cobra.Command{ + Use: "discovered", + Short: "Manage discovered provider models", + } + s.gatewayModelsDiscoveredListCmd = &cobra.Command{ + Use: "list ", + Short: "List discovered models for one provider", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListDiscoveredModels(cmd.Context(), args[0]) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayDiscoveredModelsTable(items) + return nil + }, + } + s.gatewayModelsDiscoveredRefreshCmd = &cobra.Command{ + Use: "refresh ", + Short: "Refresh discovered models for one provider", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().RefreshProviderModels(cmd.Context(), args[0]) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(resp) + } + s.printGatewayDiscoveredModelsTable(resp.Items) + return nil + }, + } + s.gatewayModelsManagedCmd = &cobra.Command{ + Use: "managed", + Short: "Manage managed model overlays", + } + s.gatewayModelsManagedListCmd = &cobra.Command{ + Use: "list", + Short: "List managed models", + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListManagedModels(cmd.Context(), adminclient.ManagedModelListOptions{ + ProviderID: s.gatewayManagedModelProviderID, + }) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayManagedModelsTable(items) + return nil + }, + } + s.gatewayModelsManagedGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get one managed model overlay", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().GetManagedModel(cmd.Context(), args[0], args[1]) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayModelsManagedDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a managed model overlay", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().DeleteManagedModel(cmd.Context(), args[0], args[1]) + if err != nil { + return err + } + return s.printJSON(resp) + }, + } + s.gatewayCredentialListCmd = &cobra.Command{ + Use: "list", + Short: "List all credentials", + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListCredentials(cmd.Context(), adminclient.CredentialListOptions{ + Type: s.gatewayCredentialType, + ProviderType: s.gatewayCredentialProviderType, + ProviderID: s.gatewayCredentialProviderID, + }) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayCredentialsTable(items) + return nil + }, + } + s.gatewayCredentialGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get one credential", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().GetCredential(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayCredentialDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a credential", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().DeleteCredential(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(resp) + }, + } + s.gatewayModelsManagedListCmd.Flags().StringVar(&s.gatewayManagedModelProviderID, "provider-id", "", "filter managed models by provider ID") + s.gatewayCredentialListCmd.Flags().StringVar(&s.gatewayCredentialType, "type", "", "filter credentials by type (api_key or oauth_token)") + s.gatewayCredentialListCmd.Flags().StringVar(&s.gatewayCredentialProviderType, "provider-type", "", "filter credentials by provider type") + s.gatewayCredentialListCmd.Flags().StringVar(&s.gatewayCredentialProviderID, "provider-id", "", "filter credentials by provider ID") + s.gatewayMCPRuntimeHistoryCmd.Flags().StringVar(&s.gatewayMCPRuntimeRouteID, "route-id", "", "filter completed MCP requests by route ID") + s.gatewayMCPServiceToolCallCmd.Flags().StringVar(&s.gatewayMCPToolArguments, "arguments", "", "JSON object passed as tool arguments") + s.gatewayMCPServicePromptGetCmd.Flags().StringVar(&s.gatewayMCPPromptArguments, "arguments", "", "JSON object passed as prompt arguments") + s.gatewayValidateCmd.Flags().StringVarP(&s.gatewayBundleFile, "file", "f", "", "path to gateway bundle YAML file") + s.gatewayApplyCmd.Flags().StringVarP(&s.gatewayBundleFile, "file", "f", "", "path to gateway bundle YAML file") + s.gatewayExportCmd.Flags().StringVarP(&s.gatewayExportFile, "file", "f", "", "write bundle YAML to this file instead of stdout") + s.gatewayProviderCmd.AddCommand(s.gatewayProviderListCmd, s.gatewayProviderGetCmd, s.gatewayProviderDeleteCmd, s.gatewayProviderEnableCmd, s.gatewayProviderDisableCmd) + s.gatewayLLMRouteCmd.AddCommand(s.gatewayLLMRouteListCmd, s.gatewayLLMRouteGetCmd, s.gatewayLLMRouteDeleteCmd, s.gatewayLLMRouteEnableCmd, s.gatewayLLMRouteDisableCmd) + s.gatewayMCPServiceCmd.AddCommand(s.gatewayMCPServiceListCmd, s.gatewayMCPServiceGetCmd, s.gatewayMCPServiceDeleteCmd, s.gatewayMCPServiceSessionCmd, s.gatewayMCPServiceCapabilitiesCmd, s.gatewayMCPServiceToolsCmd, s.gatewayMCPServiceToolCallCmd, s.gatewayMCPServiceResourcesCmd, s.gatewayMCPServiceResourceTemplatesCmd, s.gatewayMCPServiceResourceReadCmd, s.gatewayMCPServicePromptsCmd, s.gatewayMCPServicePromptGetCmd) + s.gatewayMCPRouteCmd.AddCommand(s.gatewayMCPRouteListCmd, s.gatewayMCPRouteGetCmd, s.gatewayMCPRouteDeleteCmd) + s.gatewayMCPRuntimeCmd.AddCommand(s.gatewayMCPRuntimeGetCmd, s.gatewayMCPRuntimeInFlightCmd, s.gatewayMCPRuntimeProgressCmd, s.gatewayMCPRuntimeHistoryCmd) + s.gatewayVirtualKeyCmd.AddCommand(s.gatewayVirtualKeyListCmd, s.gatewayVirtualKeyGetCmd, s.gatewayVirtualKeyDeleteCmd, s.gatewayVirtualKeyEnableCmd, s.gatewayVirtualKeyDisableCmd) + s.gatewayCredentialCmd.AddCommand(s.gatewayCredentialListCmd, s.gatewayCredentialGetCmd, s.gatewayCredentialDeleteCmd) + s.gatewayModelsDiscoveredCmd.AddCommand(s.gatewayModelsDiscoveredListCmd, s.gatewayModelsDiscoveredRefreshCmd) + s.gatewayModelsManagedCmd.AddCommand(s.gatewayModelsManagedListCmd, s.gatewayModelsManagedGetCmd, s.gatewayModelsManagedDeleteCmd) + s.gatewayModelsCmd.AddCommand(s.gatewayModelsDiscoveredCmd, s.gatewayModelsManagedCmd) + s.gatewayProviderTypesCmd.AddCommand(s.gatewayProviderTypesListCmd) + s.gatewayLLMAPIHandlerTypesCmd.AddCommand(s.gatewayLLMAPIHandlerTypesListCmd) + s.rootCmd.AddCommand(s.gatewayValidateCmd, s.gatewayApplyCmd, s.gatewayExportCmd, s.gatewayProviderCmd, s.gatewayLLMRouteCmd, s.gatewayMCPServiceCmd, s.gatewayMCPRouteCmd, s.gatewayMCPRuntimeCmd, s.gatewayVirtualKeyCmd, s.gatewayCredentialCmd, s.gatewayProviderTypesCmd, s.gatewayLLMAPIHandlerTypesCmd, s.gatewayModelsCmd) +} diff --git a/pkg/distribution/ctlcmd/cmd_gateway_acp.go b/pkg/distribution/ctlcmd/cmd_gateway_acp.go new file mode 100644 index 00000000..1c59f316 --- /dev/null +++ b/pkg/distribution/ctlcmd/cmd_gateway_acp.go @@ -0,0 +1,49 @@ +package ctlcmd + +import ( + "github.com/spf13/cobra" +) + +func (s *commandState) initACP() { + s.gatewayACPRuntimeCmd = &cobra.Command{Use: "acp-runtime", Short: "Inspect gateway ACP runtime state"} + s.gatewayACPRuntimeGetCmd = &cobra.Command{ + Use: "get", Short: "Get the ACP runtime overview", + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().GetACPRuntime(cmd.Context()) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(item) + } + s.printGatewayACPRuntimeOverview(item) + return nil + }, + } + s.gatewayACPRuntimeInFlightCmd = &cobra.Command{ + Use: "inflight", Short: "List in-flight ACP turns", + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListACPRuntimeInFlight(cmd.Context()) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayACPInFlightTable(items) + return nil + }, + } + s.gatewayACPRuntimeCloseThreadCmd = &cobra.Command{ + Use: "close-thread ", Short: "Close pooled instances for one ACP Agent thread", Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().CloseACPThread(cmd.Context(), args[0], args[1]) + if err != nil { + return err + } + return s.printJSON(resp) + }, + } + s.gatewayACPRuntimeCmd.AddCommand(s.gatewayACPRuntimeGetCmd, s.gatewayACPRuntimeInFlightCmd, s.gatewayACPRuntimeCloseThreadCmd) + s.rootCmd.AddCommand(s.gatewayACPRuntimeCmd) +} diff --git a/pkg/distribution/ctlcmd/cmd_gateway_agent.go b/pkg/distribution/ctlcmd/cmd_gateway_agent.go new file mode 100644 index 00000000..911a27dd --- /dev/null +++ b/pkg/distribution/ctlcmd/cmd_gateway_agent.go @@ -0,0 +1,190 @@ +package ctlcmd + +import ( + "context" + "encoding/json" + "fmt" + + agentruntime "github.com/agent-guide/agent-gateway/pkg/agent/runtime" + + "github.com/spf13/cobra" +) + +func (s *commandState) rawAgentReadCommand(use, short string, read func(context.Context, string) (json.RawMessage, error)) *cobra.Command { + return &cobra.Command{Use: use, Short: short, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + resp, err := read(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(resp) + }} +} +func (s *commandState) initAgent() { + s.gatewayAgentCmd = &cobra.Command{ + Use: "agent", + Short: "Manage gateway agents", + } + s.gatewayAgentListCmd = &cobra.Command{ + Use: "list", + Short: "List all agents", + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListAgents(cmd.Context()) + if err != nil { + return err + } + if s.outputFormat == "json" { + return s.printJSON(items) + } + s.printGatewayAgentsTable(items) + return nil + }, + } + s.gatewayAgentGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get one agent", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().GetAgent(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }, + } + s.gatewayAgentDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an agent after its AgentRoutes are removed", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().DeleteAgent(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(resp) + }, + } + s.gatewayAgentWorkspaceCmd = &cobra.Command{ + Use: "workspace ", + Short: "Get the aggregated workspace summary for an agent", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().GetAgentWorkspace(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(resp) + }, + } + s.gatewayAgentActivityCmd = &cobra.Command{ + Use: "activity ", + Short: "Get recent activity for an agent", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().GetAgentActivity(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(resp) + }, + } + s.gatewayAgentUsageCmd = &cobra.Command{ + Use: "usage ", + Short: "Get usage summary for an agent", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().GetAgentUsage(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(resp) + }, + } + s.gatewayAgentInteractionsCmd = &cobra.Command{ + Use: "interactions ", + Short: "Get interaction events for an agent", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().GetAgentInteractions(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(resp) + }, + } + s.gatewayAgentResourcesCmd = &cobra.Command{ + Use: "resources ", + Short: "Get linked resources for an agent", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().GetAgentResources(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(resp) + }, + } + s.gatewayAgentHealthCmd = &cobra.Command{ + Use: "health ", + Short: "Get shallow health for an agent", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().GetAgentHealth(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(resp) + }, + } + s.gatewayAgentCapabilitiesCmd = s.rawAgentReadCommand("capabilities ", "Get runtime capabilities for an agent", func(ctx context.Context, id string) (json.RawMessage, error) { + return s.newGatewayClient().GetAgentCapabilities(ctx, id) + }) + s.gatewayAgentRunsCmd = s.rawAgentReadCommand("runs ", "List active and retained runs for an agent", func(ctx context.Context, id string) (json.RawMessage, error) { + return s.newGatewayClient().ListAgentRuns(ctx, id) + }) + s.gatewayAgentPermissionsCmd = s.rawAgentReadCommand("permissions ", "List pending permissions for an agent", func(ctx context.Context, id string) (json.RawMessage, error) { + return s.newGatewayClient().ListAgentPermissions(ctx, id) + }) + s.gatewayAgentCancelCmd = &cobra.Command{Use: "cancel ", Short: "Cancel one exact agent run", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().CancelAgentRun(cmd.Context(), args[0], args[1], agentruntime.CancelMode(s.agentCancelMode)) + if err != nil { + return err + } + return s.printJSON(resp) + }} + s.gatewayAgentDecideCmd = &cobra.Command{Use: "decide ", Short: "Resolve one pending agent permission", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { + decision := agentruntime.PermissionDecision{RequestID: args[1], Outcome: s.agentPermissionOutcome, OptionID: s.agentPermissionOptionID} + if s.agentPermissionDecisions != "" { + if err := json.Unmarshal([]byte(s.agentPermissionDecisions), &decision.Decisions); err != nil { + return fmt.Errorf("decode --decisions: %w", err) + } + } + resp, err := s.newGatewayClient().ResolveAgentPermission(cmd.Context(), args[0], args[1], decision) + if err != nil { + return err + } + return s.printJSON(resp) + }} + s.gatewayAgentSessionsCmd = &cobra.Command{Use: "sessions ", Short: "List sessions exposed by an agent runtime", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().ListAgentSessions(cmd.Context(), args[0], s.agentSessionCWD, s.agentSessionCursor) + if err != nil { + return err + } + return s.printJSON(resp) + }} + s.gatewayAgentTranscriptCmd = &cobra.Command{Use: "transcript ", Short: "Load a session transcript exposed by an agent runtime", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { + resp, err := s.newGatewayClient().GetAgentTranscript(cmd.Context(), args[0], args[1], s.agentSessionCWD) + if err != nil { + return err + } + return s.printJSON(resp) + }} + s.gatewayAgentCancelCmd.Flags().StringVar(&s.agentCancelMode, "mode", "force", "cancel mode: force or graceful") + s.gatewayAgentDecideCmd.Flags().StringVar(&s.agentPermissionOutcome, "outcome", "", "runtime-native common outcome") + s.gatewayAgentDecideCmd.Flags().StringVar(&s.agentPermissionOptionID, "option-id", "", "ACP option id") + s.gatewayAgentDecideCmd.Flags().StringVar(&s.agentPermissionDecisions, "decisions", "", "builtin action decisions as JSON array") + s.gatewayAgentSessionsCmd.Flags().StringVar(&s.agentSessionCWD, "cwd", "", "session cwd filter") + s.gatewayAgentSessionsCmd.Flags().StringVar(&s.agentSessionCursor, "cursor", "", "session page cursor") + s.gatewayAgentTranscriptCmd.Flags().StringVar(&s.agentSessionCWD, "cwd", "", "session cwd") + s.gatewayAgentCmd.AddCommand(s.gatewayAgentListCmd, s.gatewayAgentGetCmd, s.gatewayAgentDeleteCmd, s.gatewayAgentWorkspaceCmd, s.gatewayAgentActivityCmd, s.gatewayAgentUsageCmd, s.gatewayAgentInteractionsCmd, s.gatewayAgentResourcesCmd, s.gatewayAgentHealthCmd, s.gatewayAgentCapabilitiesCmd, s.gatewayAgentRunsCmd, s.gatewayAgentCancelCmd, s.gatewayAgentPermissionsCmd, s.gatewayAgentDecideCmd, s.gatewayAgentSessionsCmd, s.gatewayAgentTranscriptCmd) + s.rootCmd.AddCommand(s.gatewayAgentCmd) +} diff --git a/pkg/distribution/ctlcmd/cmd_gateway_agent_route.go b/pkg/distribution/ctlcmd/cmd_gateway_agent_route.go new file mode 100644 index 00000000..11443716 --- /dev/null +++ b/pkg/distribution/ctlcmd/cmd_gateway_agent_route.go @@ -0,0 +1,84 @@ +package ctlcmd + +import ( + "encoding/json" + "fmt" + "gopkg.in/yaml.v3" + "os" + + "github.com/agent-guide/agent-gateway/pkg/gateway/agentroute" + "github.com/spf13/cobra" +) + +func (s *commandState) loadAgentRouteFile() (agentroute.AgentRouteConfig, error) { + if s.gatewayAgentRouteFile == "" { + return agentroute.AgentRouteConfig{}, fmt.Errorf("--file is required") + } + data, err := os.ReadFile(s.gatewayAgentRouteFile) + if err != nil { + return agentroute.AgentRouteConfig{}, err + } + var raw any + if err := yaml.Unmarshal(data, &raw); err != nil { + return agentroute.AgentRouteConfig{}, err + } + data, err = json.Marshal(raw) + if err != nil { + return agentroute.AgentRouteConfig{}, err + } + var cfg agentroute.AgentRouteConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return cfg, err + } + return cfg, nil +} +func (s *commandState) initAgentRoute() { + s.gatewayAgentRouteCmd = &cobra.Command{Use: "agent-route", Short: "Manage unified Agent ingress routes"} + s.gatewayAgentRouteListCmd = &cobra.Command{Use: "list", Short: "List Agent routes", RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListAgentRoutes(cmd.Context()) + if err != nil { + return err + } + return s.printJSON(items) + }} + s.gatewayAgentRouteGetCmd = &cobra.Command{Use: "get ", Short: "Get one Agent route", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().GetAgentRoute(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }} + s.gatewayAgentRouteDeleteCmd = &cobra.Command{Use: "delete ", Short: "Delete one Agent route", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + item, err := s.newGatewayClient().DeleteAgentRoute(cmd.Context(), args[0]) + if err != nil { + return err + } + return s.printJSON(item) + }} + s.gatewayAgentRouteCreateCmd = &cobra.Command{Use: "create", Short: "Create an Agent route from --file", RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := s.loadAgentRouteFile() + if err != nil { + return err + } + item, err := s.newGatewayClient().CreateAgentRoute(cmd.Context(), cfg) + if err != nil { + return err + } + return s.printJSON(item) + }} + s.gatewayAgentRouteUpdateCmd = &cobra.Command{Use: "update ", Short: "Update an Agent route from --file", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := s.loadAgentRouteFile() + if err != nil { + return err + } + item, err := s.newGatewayClient().UpdateAgentRoute(cmd.Context(), args[0], cfg) + if err != nil { + return err + } + return s.printJSON(item) + }} + s.gatewayAgentRouteCreateCmd.Flags().StringVarP(&s.gatewayAgentRouteFile, "file", "f", "", "Agent route YAML or JSON") + s.gatewayAgentRouteUpdateCmd.Flags().StringVarP(&s.gatewayAgentRouteFile, "file", "f", "", "Agent route YAML or JSON") + s.gatewayAgentRouteCmd.AddCommand(s.gatewayAgentRouteListCmd, s.gatewayAgentRouteGetCmd, s.gatewayAgentRouteCreateCmd, s.gatewayAgentRouteUpdateCmd, s.gatewayAgentRouteDeleteCmd) + s.rootCmd.AddCommand(s.gatewayAgentRouteCmd) +} diff --git a/cmd/agwctl/cmd_gateway_agent_route_test.go b/pkg/distribution/ctlcmd/cmd_gateway_agent_route_test.go similarity index 99% rename from cmd/agwctl/cmd_gateway_agent_route_test.go rename to pkg/distribution/ctlcmd/cmd_gateway_agent_route_test.go index 9702b9e7..5b2382e7 100644 --- a/cmd/agwctl/cmd_gateway_agent_route_test.go +++ b/pkg/distribution/ctlcmd/cmd_gateway_agent_route_test.go @@ -1,4 +1,4 @@ -package main +package ctlcmd import ( "encoding/json" diff --git a/pkg/distribution/ctlcmd/cmd_gateway_builtin.go b/pkg/distribution/ctlcmd/cmd_gateway_builtin.go new file mode 100644 index 00000000..008874e3 --- /dev/null +++ b/pkg/distribution/ctlcmd/cmd_gateway_builtin.go @@ -0,0 +1,31 @@ +package ctlcmd + +import ( + "github.com/spf13/cobra" +) + +func (s *commandState) initBuiltin() { + s.gatewayBuiltinRuntimeCmd = &cobra.Command{Use: "builtin-runtime", Short: "Inspect the builtin ADK host runtime"} + s.gatewayBuiltinRuntimeGetCmd = &cobra.Command{ + Use: "get", Short: "Get builtin materialization and runtime diagnostics", + RunE: func(cmd *cobra.Command, args []string) error { + view, err := s.newGatewayClient().GetBuiltinRuntime(cmd.Context()) + if err != nil { + return err + } + return s.printJSON(view) + }, + } + s.gatewayBuiltinRuntimeInFlightCmd = &cobra.Command{ + Use: "inflight", Short: "List in-flight builtin turns", + RunE: func(cmd *cobra.Command, args []string) error { + items, err := s.newGatewayClient().ListBuiltinInFlight(cmd.Context()) + if err != nil { + return err + } + return s.printJSON(items) + }, + } + s.gatewayBuiltinRuntimeCmd.AddCommand(s.gatewayBuiltinRuntimeGetCmd, s.gatewayBuiltinRuntimeInFlightCmd) + s.rootCmd.AddCommand(s.gatewayBuiltinRuntimeCmd) +} diff --git a/cmd/agwctl/cmd_gateway_test.go b/pkg/distribution/ctlcmd/cmd_gateway_test.go similarity index 97% rename from cmd/agwctl/cmd_gateway_test.go rename to pkg/distribution/ctlcmd/cmd_gateway_test.go index abbc832f..e4f9783e 100644 --- a/cmd/agwctl/cmd_gateway_test.go +++ b/pkg/distribution/ctlcmd/cmd_gateway_test.go @@ -1,4 +1,4 @@ -package main +package ctlcmd import ( "bytes" @@ -1217,6 +1217,8 @@ providers: func TestGatewayExportCommand(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { + case "/admin/extensions": + _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}}) case "/admin/auth/login": _ = json.NewEncoder(w).Encode(map[string]string{ "token": "test-token", @@ -1330,6 +1332,8 @@ func TestGatewayExportThenValidateRoundTrip(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { + case "/admin/extensions": + _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}}) case "/admin/auth/login": _ = json.NewEncoder(w).Encode(map[string]string{ "token": "test-token", @@ -1425,6 +1429,8 @@ func TestGatewayExportThenValidateRoundTrip(t *testing.T) { func TestGatewayExportCommandIncludesMCPServicesAndRoutes(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { + case "/admin/extensions": + _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}}) case "/admin/auth/login": _ = json.NewEncoder(w).Encode(map[string]string{"token": "test-token", "username": "admin"}) case "/admin/llm/provider_types": @@ -1595,59 +1601,11 @@ mcpRoutes: func executeAGWCTL(t *testing.T, args ...string) (string, string, error) { t.Helper() - - oldArgs := os.Args - oldStdout := os.Stdout - oldStderr := os.Stderr - oldOutputFormat := outputFormat - oldGatewayExportFile := gatewayExportFile - oldGatewayBundleFile := gatewayBundleFile - defer func() { - os.Args = oldArgs - os.Stdout = oldStdout - os.Stderr = oldStderr - outputFormat = oldOutputFormat - gatewayExportFile = oldGatewayExportFile - gatewayBundleFile = oldGatewayBundleFile - rootCmd.SetArgs(nil) - rootCmd.SetOut(oldStdout) - rootCmd.SetErr(oldStderr) - }() - - stdoutR, stdoutW, err := os.Pipe() - if err != nil { - t.Fatalf("stdout pipe: %v", err) - } - stderrR, stderrW, err := os.Pipe() - if err != nil { - t.Fatalf("stderr pipe: %v", err) - } - - var stdoutBuf bytes.Buffer - var stderrBuf bytes.Buffer - stdoutDone := make(chan struct{}) - stderrDone := make(chan struct{}) - go func() { - _, _ = io.Copy(&stdoutBuf, stdoutR) - close(stdoutDone) - }() - go func() { - _, _ = io.Copy(&stderrBuf, stderrR) - close(stderrDone) - }() - - os.Stdout = stdoutW - os.Stderr = stderrW - rootCmd.SetOut(stdoutW) - rootCmd.SetErr(stderrW) - rootCmd.SetArgs(args) - - runErr := rootCmd.Execute() - - _ = stdoutW.Close() - _ = stderrW.Close() - <-stdoutDone - <-stderrDone - - return stdoutBuf.String(), stderrBuf.String(), runErr + cmd := NewCommand(Options{}) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.String(), stderr.String(), err } diff --git a/cmd/agwctl/cmd_output.go b/pkg/distribution/ctlcmd/cmd_output.go similarity index 69% rename from cmd/agwctl/cmd_output.go rename to pkg/distribution/ctlcmd/cmd_output.go index f9b5eda8..10f17b5e 100644 --- a/cmd/agwctl/cmd_output.go +++ b/pkg/distribution/ctlcmd/cmd_output.go @@ -1,9 +1,8 @@ -package main +package ctlcmd import ( "encoding/json" "fmt" - "os" "strings" "text/tabwriter" "time" @@ -13,20 +12,16 @@ import ( llmroutepkg "github.com/agent-guide/agent-gateway/pkg/gateway/llmroute" ) -var outputFormat string - -func initOutputFlag() { - rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "table", "output format: table or json") +func (s *commandState) initOutputFlag() { + s.rootCmd.PersistentFlags().StringVarP(&s.outputFormat, "output", "o", "table", "output format: table or json") } -// newTabWriter returns a tabwriter suitable for aligned CLI output. -func newTabWriter() *tabwriter.Writer { - return tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) +func (s *commandState) newTabWriter() *tabwriter.Writer { + return tabwriter.NewWriter(s.rootCmd.OutOrStdout(), 0, 0, 2, ' ', 0) } -// printTable writes headers and rows to stdout using tabwriter. -func printTable(headers []string, rows [][]string) { - w := newTabWriter() +func (s *commandState) printTable(headers []string, rows [][]string) { + w := s.newTabWriter() fmt.Fprintln(w, strings.Join(headers, "\t")) sep := make([]string, len(headers)) for i, h := range headers { @@ -39,14 +34,12 @@ func printTable(headers []string, rows [][]string) { w.Flush() } -// printJSON pretty-prints v as JSON to stdout. -func printJSON(v any) error { - enc := json.NewEncoder(os.Stdout) +func (s *commandState) printJSON(v any) error { + enc := json.NewEncoder(s.rootCmd.OutOrStdout()) enc.SetIndent("", " ") return enc.Encode(v) } -// boolStr returns "yes" or "no" for boolean values. func boolStr(b bool) string { if b { return "yes" @@ -54,7 +47,6 @@ func boolStr(b bool) string { return "no" } -// dash returns s if non-empty, otherwise "-". func dash(s string) string { if s == "" { return "-" @@ -62,15 +54,12 @@ func dash(s string) string { return s } -// joinOrDash joins a string slice with sep; returns "-" if empty. func joinOrDash(parts []string, sep string) string { s := strings.Join(parts, sep) return dash(s) } -// ── Caddy table formatters ──────────────────────────────────────────────────── - -func printCaddyServersTable(servers []*caddyadminclient.ServerResponse) { +func (s *commandState) printCaddyServersTable(servers []*caddyadminclient.ServerResponse) { headers := []string{"ID", "LISTEN", "READ-ONLY", "SOURCE", "PUBLIC-URL"} rows := make([][]string, 0, len(servers)) for _, s := range servers { @@ -82,10 +71,10 @@ func printCaddyServersTable(servers []*caddyadminclient.ServerResponse) { dash(s.PublicURL), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printCaddyRoutesTable(routes []*caddyadminclient.RouteResponse) { +func (s *commandState) printCaddyRoutesTable(routes []*caddyadminclient.RouteResponse) { headers := []string{"ID", "ORDER", "PATHS", "HOSTS", "HANDLERS"} rows := make([][]string, 0, len(routes)) for _, r := range routes { @@ -101,7 +90,7 @@ func printCaddyRoutesTable(routes []*caddyadminclient.RouteResponse) { joinOrDash(handlerDescs, " "), }) } - printTable(headers, rows) + s.printTable(headers, rows) } func describeHandler(h caddyadminclient.HandlerConf) string { @@ -126,11 +115,7 @@ func describeHandler(h caddyadminclient.HandlerConf) string { } } -// ── Gateway table formatters ────────────────────────────────────────────────── - -// printGatewayProvidersTable renders a list of ProviderView items. -// Fields: id, provider_type, default_model, disabled, source. -func printGatewayProvidersTable(items []adminclient.Provider) { +func (s *commandState) printGatewayProvidersTable(items []adminclient.Provider) { headers := []string{"ID", "TYPE", "DEFAULT-MODEL", "DISABLED", "SOURCE"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -142,12 +127,10 @@ func printGatewayProvidersTable(items []adminclient.Provider) { dash(item.Source), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -// printGatewayLLMRoutesTable renders a list of LLMRouteView items. -// Fields: id, protocol, path_prefix (from match), disabled, target, source. -func printGatewayLLMRoutesTable(items []adminclient.LLMRoute) { +func (s *commandState) printGatewayLLMRoutesTable(items []adminclient.LLMRoute) { headers := []string{"ID", "PROTOCOL", "PATH-PREFIX", "DISABLED", "TARGET", "SOURCE"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -160,10 +143,10 @@ func printGatewayLLMRoutesTable(items []adminclient.LLMRoute) { dash(item.Source), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayMCPServicesTable(items []adminclient.MCPServiceView) { +func (s *commandState) printGatewayMCPServicesTable(items []adminclient.MCPServiceView) { headers := []string{"ID", "NAME", "TRANSPORT", "ENDPOINT", "DISABLED", "SOURCE"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -180,10 +163,10 @@ func printGatewayMCPServicesTable(items []adminclient.MCPServiceView) { dash(item.Source), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayMCPSessionTable(item *adminclient.MCPServiceSessionView) { +func (s *commandState) printGatewayMCPSessionTable(item *adminclient.MCPServiceSessionView) { headers := []string{"SERVICE-ID", "SESSION-ID", "UPSTREAM-SESSION-ID", "TRANSPORT", "STATE", "CREATED-AT", "LAST-USED-AT"} rows := [][]string{} if item != nil { @@ -197,10 +180,10 @@ func printGatewayMCPSessionTable(item *adminclient.MCPServiceSessionView) { dash(formatTimestamp(item.LastUsedAt)), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayMCPToolsTable(items []adminclient.MCPTool) { +func (s *commandState) printGatewayMCPToolsTable(items []adminclient.MCPTool) { headers := []string{"NAME", "DESCRIPTION"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -209,10 +192,10 @@ func printGatewayMCPToolsTable(items []adminclient.MCPTool) { dash(item.Description), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayMCPResourcesTable(items []adminclient.MCPResource) { +func (s *commandState) printGatewayMCPResourcesTable(items []adminclient.MCPResource) { headers := []string{"URI", "NAME", "MIME-TYPE", "DESCRIPTION"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -223,10 +206,10 @@ func printGatewayMCPResourcesTable(items []adminclient.MCPResource) { dash(item.Description), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayMCPResourceTemplatesTable(items []adminclient.MCPResourceTemplate) { +func (s *commandState) printGatewayMCPResourceTemplatesTable(items []adminclient.MCPResourceTemplate) { headers := []string{"NAME", "TITLE", "URI-TEMPLATE", "MIME-TYPE", "DESCRIPTION"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -238,10 +221,10 @@ func printGatewayMCPResourceTemplatesTable(items []adminclient.MCPResourceTempla dash(item.Description), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayMCPPromptsTable(items []adminclient.MCPPrompt) { +func (s *commandState) printGatewayMCPPromptsTable(items []adminclient.MCPPrompt) { headers := []string{"NAME", "DESCRIPTION"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -250,10 +233,10 @@ func printGatewayMCPPromptsTable(items []adminclient.MCPPrompt) { dash(item.Description), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayMCPRoutesTable(items []adminclient.MCPRouteView) { +func (s *commandState) printGatewayMCPRoutesTable(items []adminclient.MCPRouteView) { headers := []string{"ID", "PATH-PREFIX", "SERVICE-ID", "VIRTUALKEY", "DISABLED", "SOURCE"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -266,22 +249,22 @@ func printGatewayMCPRoutesTable(items []adminclient.MCPRouteView) { dash(item.Source), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayMCPRuntimeOverview(runtime *adminclient.MCPRuntimeView) { +func (s *commandState) printGatewayMCPRuntimeOverview(runtime *adminclient.MCPRuntimeView) { if runtime == nil { - printGatewayMCPRuntimeInFlightTable(nil) - fmt.Fprintln(os.Stdout) - printGatewayMCPRuntimeProgressTable(nil) + s.printGatewayMCPRuntimeInFlightTable(nil) + fmt.Fprintln(s.rootCmd.OutOrStdout()) + s.printGatewayMCPRuntimeProgressTable(nil) return } - printGatewayMCPRuntimeInFlightTable(runtime.InFlight) - fmt.Fprintln(os.Stdout) - printGatewayMCPRuntimeProgressTable(runtime.Progress) + s.printGatewayMCPRuntimeInFlightTable(runtime.InFlight) + fmt.Fprintln(s.rootCmd.OutOrStdout()) + s.printGatewayMCPRuntimeProgressTable(runtime.Progress) } -func printGatewayMCPRuntimeInFlightTable(items []adminclient.MCPRuntimeInFlightRequest) { +func (s *commandState) printGatewayMCPRuntimeInFlightTable(items []adminclient.MCPRuntimeInFlightRequest) { headers := []string{"ROUTE-ID", "REQUEST-ID", "METHOD", "STARTED-AT", "PROGRESS-TOKEN", "CANCELLED"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -294,10 +277,10 @@ func printGatewayMCPRuntimeInFlightTable(items []adminclient.MCPRuntimeInFlightR boolStr(!item.CancelledAt.IsZero()), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayMCPRuntimeProgressTable(items []adminclient.MCPRuntimeProgressNotification) { +func (s *commandState) printGatewayMCPRuntimeProgressTable(items []adminclient.MCPRuntimeProgressNotification) { headers := []string{"ROUTE-ID", "REQUEST-ID", "METHOD", "PROGRESS", "TOTAL", "MESSAGE", "UPDATED-AT"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -315,10 +298,10 @@ func printGatewayMCPRuntimeProgressTable(items []adminclient.MCPRuntimeProgressN dash(formatTimestamp(item.UpdatedAt)), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayMCPRuntimeHistoryTable(items []adminclient.MCPRuntimeCompletedRequest) { +func (s *commandState) printGatewayMCPRuntimeHistoryTable(items []adminclient.MCPRuntimeCompletedRequest) { headers := []string{"ROUTE-ID", "REQUEST-ID", "METHOD", "STARTED-AT", "COMPLETED-AT", "CANCELLED", "ERROR"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -332,10 +315,10 @@ func printGatewayMCPRuntimeHistoryTable(items []adminclient.MCPRuntimeCompletedR dash(item.Error), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayAgentsTable(items []adminclient.AgentView) { +func (s *commandState) printGatewayAgentsTable(items []adminclient.AgentView) { headers := []string{"ID", "NAME", "RUNTIME", "RUNTIME-STATE", "EXECUTABLE", "RUNTIME-TARGET", "DISABLED", "SOURCE"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -364,30 +347,30 @@ func printGatewayAgentsTable(items []adminclient.AgentView) { dash(item.Source), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayACPRuntimeOverview(runtime *adminclient.ACPRuntimeView) { +func (s *commandState) printGatewayACPRuntimeOverview(runtime *adminclient.ACPRuntimeView) { if runtime == nil { runtime = &adminclient.ACPRuntimeView{} } - printGatewayACPInFlightTable(runtime.InFlight) - fmt.Fprintln(os.Stdout) - printGatewayACPInstancesTable(runtime.Instances) - fmt.Fprintln(os.Stdout) - printGatewayACPPendingPermissionsTable(runtime.PendingPermissions) + s.printGatewayACPInFlightTable(runtime.InFlight) + fmt.Fprintln(s.rootCmd.OutOrStdout()) + s.printGatewayACPInstancesTable(runtime.Instances) + fmt.Fprintln(s.rootCmd.OutOrStdout()) + s.printGatewayACPPendingPermissionsTable(runtime.PendingPermissions) } -func printGatewayACPInFlightTable(items []adminclient.ACPInFlightTurn) { +func (s *commandState) printGatewayACPInFlightTable(items []adminclient.ACPInFlightTurn) { headers := []string{"SCOPE"} rows := make([][]string, 0, len(items)) for _, item := range items { rows = append(rows, []string{dash(item.Scope)}) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayACPInstancesTable(items []adminclient.ACPPooledInstanceInfo) { +func (s *commandState) printGatewayACPInstancesTable(items []adminclient.ACPPooledInstanceInfo) { headers := []string{"SCOPE", "SESSION-ID", "ALIVE", "ACTIVE", "LAST-USED"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -399,10 +382,10 @@ func printGatewayACPInstancesTable(items []adminclient.ACPPooledInstanceInfo) { dash(formatTimestamp(item.LastUsed)), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayACPPendingPermissionsTable(items []adminclient.ACPPendingPermissionInfo) { +func (s *commandState) printGatewayACPPendingPermissionsTable(items []adminclient.ACPPendingPermissionInfo) { headers := []string{"REQUEST-ID", "AGENT-ID", "SESSION-ID", "CREATED-AT"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -413,12 +396,10 @@ func printGatewayACPPendingPermissionsTable(items []adminclient.ACPPendingPermis dash(formatTimestamp(item.CreatedAt)), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -// printGatewayVirtualKeysTable renders a list of VirtualKeyView items. -// Fields: id, key (truncated), tag, disabled, allowed_route_ids, rate_limits, source. -func printGatewayVirtualKeysTable(items []adminclient.VirtualKey) { +func (s *commandState) printGatewayVirtualKeysTable(items []adminclient.VirtualKey) { headers := []string{"ID", "KEY", "TAG", "DISABLED", "ALLOWED-ROUTES", "RATE-LIMITS", "SOURCE"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -448,12 +429,10 @@ func printGatewayVirtualKeysTable(items []adminclient.VirtualKey) { dash(item.Source), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -// printGatewayCredentialsTable renders a list of CredentialView items. -// Fields: id, provider_id, provider_type, label, disabled, unavailable. -func printGatewayCredentialsTable(items []adminclient.Credential) { +func (s *commandState) printGatewayCredentialsTable(items []adminclient.Credential) { headers := []string{"ID", "PROVIDER-ID", "PROVIDER-TYPE", "TYPE", "LABEL", "DISABLED", "UNAVAILABLE"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -467,10 +446,10 @@ func printGatewayCredentialsTable(items []adminclient.Credential) { boolStr(item.Unavailable), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayDiscoveredModelsTable(items []adminclient.DiscoveredModel) { +func (s *commandState) printGatewayDiscoveredModelsTable(items []adminclient.DiscoveredModel) { headers := []string{"PROVIDER-ID", "UPSTREAM-MODEL", "TYPE", "STATUS", "DISPLAY-NAME", "LAST-ERROR"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -483,10 +462,10 @@ func printGatewayDiscoveredModelsTable(items []adminclient.DiscoveredModel) { dash(item.LastError), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayManagedModelsTable(items []adminclient.ManagedModel) { +func (s *commandState) printGatewayManagedModelsTable(items []adminclient.ManagedModel) { headers := []string{"PROVIDER-ID", "UPSTREAM-MODEL", "ENABLED", "SCOPE", "TYPE", "SNAPSHOT"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -499,10 +478,10 @@ func printGatewayManagedModelsTable(items []adminclient.ManagedModel) { dash(string(item.SnapshotState)), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayProviderTypesTable(items []adminclient.ProviderType) { +func (s *commandState) printGatewayProviderTypesTable(items []adminclient.ProviderType) { headers := []string{"PROVIDER-TYPE", "ENABLED"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -511,10 +490,10 @@ func printGatewayProviderTypesTable(items []adminclient.ProviderType) { boolStr(item.Enabled), }) } - printTable(headers, rows) + s.printTable(headers, rows) } -func printGatewayLLMAPIHandlerTypesTable(items []adminclient.LLMAPIHandlerType) { +func (s *commandState) printGatewayLLMAPIHandlerTypesTable(items []adminclient.LLMAPIHandlerType) { headers := []string{"HANDLER-TYPE"} rows := make([][]string, 0, len(items)) for _, item := range items { @@ -522,7 +501,7 @@ func printGatewayLLMAPIHandlerTypesTable(items []adminclient.LLMAPIHandlerType) dash(item.LLMApiHandlerType), }) } - printTable(headers, rows) + s.printTable(headers, rows) } func formatTimestamp(ts time.Time) string { diff --git a/pkg/distribution/ctlcmd/cmd_root.go b/pkg/distribution/ctlcmd/cmd_root.go new file mode 100644 index 00000000..69248756 --- /dev/null +++ b/pkg/distribution/ctlcmd/cmd_root.go @@ -0,0 +1,16 @@ +package ctlcmd + +import ( + "github.com/spf13/cobra" +) + +func (s *commandState) initRoot() { + s.rootCmd = &cobra.Command{ + Use: "agwctl", + Short: "Manage Agent Gateway control-plane resources", + } + s.initOutputFlag() + s.rootCmd.PersistentFlags().StringVar(&s.globalGatewayAddr, "admin-addr", envOr("AGW_ADMIN_ADDR", "http://localhost:8019"), "agent-gateway admin API address") + s.rootCmd.PersistentFlags().StringVar(&s.gwAdminBasicAuth, "admin-basic-auth", envOr("AGW_ADMIN_BASIC_AUTH", ""), "gateway admin Basic Auth request credentials as username:password") + s.rootCmd.PersistentFlags().StringArrayVar(&s.gwAdminHeaders, "admin-header", nil, "extra admin API header as 'Name: value'; repeat to send multiple headers") +} diff --git a/pkg/distribution/ctlcmd/command.go b/pkg/distribution/ctlcmd/command.go new file mode 100644 index 00000000..0f637507 --- /dev/null +++ b/pkg/distribution/ctlcmd/command.go @@ -0,0 +1,67 @@ +// Package ctlcmd constructs management CLI commands without import-time process +// side effects. Options, NewCommand and Run are the designated reusable entry +// surface; release protection follows docs/design/enterprise-extension-contract.md. +package ctlcmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/spf13/cobra" +) + +// Options supplies distribution metadata; main owns linked validation factories. +type Options struct{ Assembly distribution.Assembly } + +// NewCommand creates fresh commands, flags and output state. It reads existing +// environment defaults but does not load dotenv files or exit the process. +func NewCommand(opts Options) *cobra.Command { + a := opts.Assembly.Clone() + registry, assemblyErr := a.Registry() + s := &commandState{extensionRegistry: registry} + s.initRoot() + s.rootCmd.PersistentPreRunE = func(*cobra.Command, []string) error { return assemblyErr } + remote := false + manifestCmd := &cobra.Command{Use: "manifest", Short: "Show linked validation capabilities", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + if remote { + m, err := s.newGatewayClient().Manifest(cmd.Context()) + if err != nil { + return err + } + return json.NewEncoder(cmd.OutOrStdout()).Encode(m) + } + m, err := a.Manifest() + if err != nil { + return err + } + return json.NewEncoder(cmd.OutOrStdout()).Encode(m) + }} + manifestCmd.Flags().BoolVar(&remote, "remote", false, "query the server runtime manifest") + s.rootCmd.AddCommand(manifestCmd) + s.initCaddy() + s.initGateway() + s.initACP() + s.initAgent() + s.initAgentRoute() + s.initBuiltin() + distribution.Annotate(s.rootCmd, opts.Assembly) + return s.rootCmd +} + +// Run loads .env.local then .env, executes a fresh command, and returns an exit +// code. It uses os.Args/stdout/stderr; only main calls os.Exit. +func Run(opts Options) int { + if err := loadRuntimeEnv(); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + cmd := NewCommand(opts) + if err := cmd.ExecuteContext(context.Background()); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 +} diff --git a/cmd/agwctl/gateway_bundle_apply.go b/pkg/distribution/ctlcmd/gateway_bundle_apply.go similarity index 90% rename from cmd/agwctl/gateway_bundle_apply.go rename to pkg/distribution/ctlcmd/gateway_bundle_apply.go index fc73c007..03c736c5 100644 --- a/cmd/agwctl/gateway_bundle_apply.go +++ b/pkg/distribution/ctlcmd/gateway_bundle_apply.go @@ -1,8 +1,9 @@ -package main +package ctlcmd import ( "context" "fmt" + "github.com/agent-guide/agent-gateway/pkg/extension" "reflect" "sort" "strings" @@ -41,7 +42,7 @@ type gatewayApplySummaryCounts struct { Error int `json:"error"` } -func runGatewayApply(ctx context.Context, path string) error { +func (s *commandState) runGatewayApply(ctx context.Context, path string) error { if path == "" { return fmt.Errorf("--file is required") } @@ -49,11 +50,11 @@ func runGatewayApply(ctx context.Context, path string) error { if err != nil { return err } - if err := bundle.ValidateForConfigStore(); err != nil { + if err := bundle.ValidateWithRegistry(ctx, s.extensionRegistry); err != nil { return err } - client := newGatewayClient() + client := s.newGatewayClient() summary := gatewayApplySummary{ File: path, Status: "ok", @@ -80,6 +81,16 @@ func runGatewayApply(ctx context.Context, path string) error { summary.Actions = append(summary.Actions, item) } + if err := applyExtensions(ctx, client, bundle, record); err != nil { + if s.outputFormat == "json" { + if printErr := s.printJSON(summary); printErr != nil { + return printErr + } + } else { + s.printGatewayApplySummary(summary) + } + return err + } if err := applyProviders(ctx, client, bundle, record); err != nil { return err } @@ -105,12 +116,12 @@ func runGatewayApply(ctx context.Context, path string) error { return err } - if outputFormat == "json" { - if err := printJSON(summary); err != nil { + if s.outputFormat == "json" { + if err := s.printJSON(summary); err != nil { return err } } else { - printGatewayApplySummary(summary) + s.printGatewayApplySummary(summary) } if len(applyErrs) > 0 { return fmt.Errorf("apply finished with %d error(s)", len(applyErrs)) @@ -634,19 +645,52 @@ func managedModelKey(providerID, upstreamModel string) string { return strings.TrimSpace(providerID) + "/" + strings.TrimSpace(upstreamModel) } -func printGatewayApplySummary(summary gatewayApplySummary) { - fmt.Fprintf(rootCmd.OutOrStdout(), "apply: %s\n", summary.File) +func (s *commandState) printGatewayApplySummary(summary gatewayApplySummary) { + fmt.Fprintf(s.rootCmd.OutOrStdout(), "apply: %s\n", summary.File) for _, action := range summary.Actions { if action.Error != "" { - fmt.Fprintf(rootCmd.OutOrStdout(), " %s %s %s: %s\n", action.Action, action.Kind, action.ID, action.Error) + fmt.Fprintf(s.rootCmd.OutOrStdout(), " %s %s %s: %s\n", action.Action, action.Kind, action.ID, action.Error) continue } - fmt.Fprintf(rootCmd.OutOrStdout(), " %s %s %s\n", action.Action, action.Kind, action.ID) + fmt.Fprintf(s.rootCmd.OutOrStdout(), " %s %s %s\n", action.Action, action.Kind, action.ID) } - fmt.Fprintf(rootCmd.OutOrStdout(), "summary: create=%d update=%d skip=%d error=%d\n", + fmt.Fprintf(s.rootCmd.OutOrStdout(), "summary: create=%d update=%d skip=%d error=%d\n", summary.Counts.Create, summary.Counts.Update, summary.Counts.Skip, summary.Counts.Error, ) } + +func applyExtensions(ctx context.Context, client *adminclient.Client, bundle *gatewaybundle.GatewayBundle, record func(string, string, string, error)) error { + if len(bundle.Extensions) == 0 { + return nil + } + current, err := client.ListExtensions(ctx) + if err != nil { + record("extension", "", "error", err) + return err + } + byID := map[string]extension.Envelope{} + for _, e := range current { + byID[e.ModuleID] = e + } + for _, e := range bundle.Extensions { + action := "create" + if old, ok := byID[e.ModuleID]; ok { + action = "update" + want, wantErr := extension.DecodeObject(e.Config) + have, haveErr := extension.DecodeObject(old.Config) + if wantErr == nil && haveErr == nil && e.SchemaVersion == old.SchemaVersion && reflect.DeepEqual(want, have) { + record("extension", e.ModuleID, "skip", nil) + continue + } + } + if _, err := client.PutExtension(ctx, e); err != nil { + record("extension", e.ModuleID, "error", err) + return fmt.Errorf("extension apply stopped after partial mutations: %w", err) + } + record("extension", e.ModuleID, action, nil) + } + return nil +} diff --git a/pkg/distribution/ctlcmd/gateway_bundle_apply_test.go b/pkg/distribution/ctlcmd/gateway_bundle_apply_test.go new file mode 100644 index 00000000..0e0714e5 --- /dev/null +++ b/pkg/distribution/ctlcmd/gateway_bundle_apply_test.go @@ -0,0 +1,115 @@ +package ctlcmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/agent-guide/agent-gateway/pkg/adminclient" + "github.com/agent-guide/agent-gateway/pkg/extension" + "github.com/agent-guide/agent-gateway/pkg/gateway/agentroute" + "github.com/agent-guide/agent-gateway/pkg/gatewaybundle" +) + +func TestApplyAgentRoutesStripsExportedManagedTimestamps(t *testing.T) { + created := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/admin/agents/routes": + _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}}) + case r.Method == http.MethodPost && r.URL.Path == "/admin/agents/routes": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if _, ok := body["created_at"]; ok { + t.Fatalf("create body retained created_at: %#v", body) + } + if _, ok := body["updated_at"]; ok { + t.Fatalf("create body retained updated_at: %#v", body) + } + created = true + w.WriteHeader(http.StatusCreated) + body["source"] = "store" + body["read_only"] = false + _ = json.NewEncoder(w).Encode(body) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + now := time.Now().UTC() + bundle := &gatewaybundle.GatewayBundle{AgentRoutes: []agentroute.AgentRouteConfig{{ + AgentRouteBaseConfig: agentroute.AgentRouteBaseConfig{ + ID: "agent:reviewer:review", MatchPolicy: agentroute.RouteMatch{PathPrefix: "/review"}, + CreatedAt: now, UpdatedAt: now, + }, + AgentID: "reviewer", + }}} + client := adminclient.New(adminclient.Config{BaseURL: srv.URL}) + var applyErr error + if err := applyAgentRoutes(context.Background(), client, bundle, func(_, _, _ string, err error) { applyErr = err }); err != nil { + t.Fatal(err) + } + if applyErr != nil { + t.Fatal(applyErr) + } + if !created { + t.Fatal("AgentRoute create was not called") + } +} + +func TestApplyExtensionsPreservesLargeIntegerChanges(t *testing.T) { + for _, tc := range []struct{ name, stored, desired, action string }{ + {"different", `{"epoch":9007199254740992}`, `{"epoch":9007199254740993}`, "update"}, + {"negative", `{"epoch":-9223372036854775808}`, `{"epoch":-9223372036854775807}`, "update"}, + {"unchanged", `{"epoch":9007199254740993}`, `{"epoch":9007199254740993}`, "skip"}, + } { + t.Run(tc.name, func(t *testing.T) { + id := "example.fixture.numeric" + writes := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/admin/extensions" { + _ = json.NewEncoder(w).Encode(map[string]any{"items": []extension.Envelope{{ModuleID: id, SchemaVersion: 1, Config: json.RawMessage(tc.stored)}}}) + return + } + if r.Method == http.MethodPut && r.URL.Path == "/admin/extensions/"+id { + writes++ + var e extension.Envelope + if err := json.NewDecoder(r.Body).Decode(&e); err != nil { + t.Error(err) + } + if string(e.Config) != tc.desired { + t.Errorf("changed numeric payload: %s", e.Config) + } + _ = json.NewEncoder(w).Encode(e) + return + } + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + bundle := &gatewaybundle.GatewayBundle{Extensions: []extension.Envelope{{ModuleID: id, SchemaVersion: 1, Config: json.RawMessage(tc.desired)}}} + action := "" + err := applyExtensions(context.Background(), adminclient.New(adminclient.Config{BaseURL: srv.URL}), bundle, func(_, _, a string, err error) { + if err != nil { + t.Error(err) + } + action = a + }) + if err != nil { + t.Fatal(err) + } + if action != tc.action { + t.Fatalf("action=%s want %s", action, tc.action) + } + if (writes == 1) != (tc.action == "update") { + t.Fatalf("writes=%d", writes) + } + }) + } +} diff --git a/cmd/agwctl/gateway_bundle_export.go b/pkg/distribution/ctlcmd/gateway_bundle_export.go similarity index 85% rename from cmd/agwctl/gateway_bundle_export.go rename to pkg/distribution/ctlcmd/gateway_bundle_export.go index d729e5f3..a9e7926a 100644 --- a/cmd/agwctl/gateway_bundle_export.go +++ b/pkg/distribution/ctlcmd/gateway_bundle_export.go @@ -1,8 +1,9 @@ -package main +package ctlcmd import ( "context" "fmt" + "io" "os" "sort" @@ -10,9 +11,13 @@ import ( "github.com/agent-guide/agent-gateway/pkg/gatewaybundle" ) -func runGatewayExport(ctx context.Context, path string) error { - client := newGatewayClient() +func (s *commandState) runGatewayExport(ctx context.Context, path string) error { + client := s.newGatewayClient() + extensions, err := client.ListExtensions(ctx) + if err != nil { + return err + } providers, err := client.ListProviders(ctx, adminclient.ProviderListOptions{}) if err != nil { return err @@ -47,6 +52,7 @@ func runGatewayExport(ctx context.Context, path string) error { } bundle := &gatewaybundle.GatewayBundle{ + Extensions: extensions, APIVersion: gatewaybundle.APIVersionV1Alpha1, Kind: gatewaybundle.KindGatewayBundle, } @@ -89,23 +95,24 @@ func runGatewayExport(ctx context.Context, path string) error { return err } if path == "" { - _, err = cmdOrStdout().Write(yamlBytes) + _, err = s.cmdOrStdout().Write(yamlBytes) return err } if err := os.WriteFile(path, yamlBytes, 0o644); err != nil { return fmt.Errorf("write %s: %w", path, err) } - if outputFormat == "json" { - return printJSON(map[string]any{ + if s.outputFormat == "json" { + return s.printJSON(map[string]any{ "status": "ok", "file": path, }) } - fmt.Fprintf(cmdOrStdout(), "gateway bundle exported: %s\n", path) + fmt.Fprintf(s.cmdOrStdout(), "gateway bundle exported: %s\n", path) return nil } func sortGatewayBundle(bundle *gatewaybundle.GatewayBundle) { + sort.Slice(bundle.Extensions, func(i, j int) bool { return bundle.Extensions[i].ModuleID < bundle.Extensions[j].ModuleID }) sort.Slice(bundle.Providers, func(i, j int) bool { return bundle.Providers[i].Id < bundle.Providers[j].Id }) @@ -135,6 +142,6 @@ func sortGatewayBundle(bundle *gatewaybundle.GatewayBundle) { }) } -func cmdOrStdout() *os.File { - return os.Stdout +func (s *commandState) cmdOrStdout() io.Writer { + return s.rootCmd.OutOrStdout() } diff --git a/pkg/distribution/ctlcmd/instance_test.go b/pkg/distribution/ctlcmd/instance_test.go new file mode 100644 index 00000000..10872d87 --- /dev/null +++ b/pkg/distribution/ctlcmd/instance_test.go @@ -0,0 +1,51 @@ +package ctlcmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +func TestIndependentCommandsCanExecuteConcurrently(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/admin/credentials" { + t.Errorf("path = %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]string{{"id": "test-credential", "type": "oauth_token"}}}) + })) + defer srv.Close() + var wg sync.WaitGroup + for _, format := range []string{"table", "json"} { + cmd := NewCommand(Options{}) + var out, stderr bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--admin-addr", srv.URL, "-o", format, "credential", "list"}) + wg.Add(1) + go func() { + defer wg.Done() + if err := cmd.Execute(); err != nil { + t.Errorf("execute: %v: %s", err, &stderr) + return + } + if !strings.Contains(out.String(), "test-credential") { + t.Errorf("missing credential in %s", &out) + } + if json.Valid(out.Bytes()) != (format == "json") { + t.Errorf("%s output leaked other instance state: %s", format, &out) + } + }() + } + wg.Wait() + fresh := NewCommand(Options{}) + if got, _ := fresh.PersistentFlags().GetString("output"); got != "table" { + t.Fatalf("fresh output=%q", got) + } + if fresh.PersistentFlags().Changed("admin-addr") { + t.Fatal("fresh command retained Changed flag") + } +} diff --git a/pkg/distribution/ctlcmd/link_test.go b/pkg/distribution/ctlcmd/link_test.go new file mode 100644 index 00000000..c9984b08 --- /dev/null +++ b/pkg/distribution/ctlcmd/link_test.go @@ -0,0 +1,19 @@ +package ctlcmd + +import ( + _ "github.com/agent-guide/agent-gateway/pkg/dispatcher/llmapi/anthropic" + _ "github.com/agent-guide/agent-gateway/pkg/dispatcher/llmapi/cc" + _ "github.com/agent-guide/agent-gateway/pkg/dispatcher/llmapi/openai" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/anthropic" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/anthropicbase" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/claudecode" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/codex" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/deepseek" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/gemini" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/grok" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/ollama" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/openai" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/openrouter" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/qwen" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/zhipu" +) diff --git a/cmd/agwctl/main_test.go b/pkg/distribution/ctlcmd/main_test.go similarity index 97% rename from cmd/agwctl/main_test.go rename to pkg/distribution/ctlcmd/main_test.go index 772197f4..111c318f 100644 --- a/cmd/agwctl/main_test.go +++ b/pkg/distribution/ctlcmd/main_test.go @@ -1,4 +1,4 @@ -package main +package ctlcmd import ( "os" @@ -23,7 +23,7 @@ func TestRootCommandExposesGatewayResourcesDirectly(t *testing.T) { "responses": {}, } - for _, cmd := range rootCmd.Commands() { + for _, cmd := range NewCommand(Options{}).Commands() { if _, ok := direct[cmd.Name()]; ok { direct[cmd.Name()] = true } diff --git a/pkg/distribution/ctlcmd/state.go b/pkg/distribution/ctlcmd/state.go new file mode 100644 index 00000000..b53f2eab --- /dev/null +++ b/pkg/distribution/ctlcmd/state.go @@ -0,0 +1,141 @@ +package ctlcmd + +import "github.com/spf13/cobra" +import "github.com/agent-guide/agent-gateway/pkg/extension" + +type commandState struct { + extensionRegistry *extension.Registry + caddyCmd *cobra.Command + rejectedCaddyAdminAddr string + caddyServerCmd *cobra.Command + caddyServerListCmd *cobra.Command + caddyServerGetCmd *cobra.Command + serverCreateID string + serverCreateListen []string + serverCreateTLSAuto bool + caddyServerCreateCmd *cobra.Command + serverUpdateListen []string + serverUpdateTLSAuto bool + caddyServerUpdateCmd *cobra.Command + caddyServerDeleteCmd *cobra.Command + caddyRouteCmd *cobra.Command + caddyRouteListCmd *cobra.Command + routeAddID string + routeAddOrder int + routeAddPaths []string + routeAddHosts []string + routeAddHandlers []string + caddyRouteAddCmd *cobra.Command + caddyRouteDeleteCmd *cobra.Command + gwAdminBasicAuth string + gwAdminHeaders []string + gatewayBundleFile string + gatewayExportFile string + gatewayManagedModelProviderID string + gatewayCredentialType string + gatewayCredentialProviderType string + gatewayCredentialProviderID string + gatewayMCPRuntimeRouteID string + gatewayMCPToolArguments string + gatewayMCPPromptArguments string + gatewayValidateCmd *cobra.Command + gatewayApplyCmd *cobra.Command + gatewayExportCmd *cobra.Command + gatewayProviderCmd *cobra.Command + gatewayProviderListCmd *cobra.Command + gatewayProviderGetCmd *cobra.Command + gatewayProviderDeleteCmd *cobra.Command + gatewayProviderEnableCmd *cobra.Command + gatewayProviderDisableCmd *cobra.Command + gatewayLLMRouteCmd *cobra.Command + gatewayLLMRouteListCmd *cobra.Command + gatewayLLMRouteGetCmd *cobra.Command + gatewayLLMRouteDeleteCmd *cobra.Command + gatewayLLMRouteEnableCmd *cobra.Command + gatewayLLMRouteDisableCmd *cobra.Command + gatewayMCPServiceCmd *cobra.Command + gatewayMCPServiceListCmd *cobra.Command + gatewayMCPServiceGetCmd *cobra.Command + gatewayMCPServiceDeleteCmd *cobra.Command + gatewayMCPServiceSessionCmd *cobra.Command + gatewayMCPServiceCapabilitiesCmd *cobra.Command + gatewayMCPServiceToolsCmd *cobra.Command + gatewayMCPServiceToolCallCmd *cobra.Command + gatewayMCPServiceResourcesCmd *cobra.Command + gatewayMCPServiceResourceTemplatesCmd *cobra.Command + gatewayMCPServiceResourceReadCmd *cobra.Command + gatewayMCPServicePromptsCmd *cobra.Command + gatewayMCPServicePromptGetCmd *cobra.Command + gatewayMCPRouteCmd *cobra.Command + gatewayMCPRouteListCmd *cobra.Command + gatewayMCPRouteGetCmd *cobra.Command + gatewayMCPRouteDeleteCmd *cobra.Command + gatewayMCPRuntimeCmd *cobra.Command + gatewayMCPRuntimeGetCmd *cobra.Command + gatewayMCPRuntimeInFlightCmd *cobra.Command + gatewayMCPRuntimeProgressCmd *cobra.Command + gatewayMCPRuntimeHistoryCmd *cobra.Command + gatewayVirtualKeyCmd *cobra.Command + gatewayVirtualKeyListCmd *cobra.Command + gatewayVirtualKeyGetCmd *cobra.Command + gatewayVirtualKeyDeleteCmd *cobra.Command + gatewayVirtualKeyEnableCmd *cobra.Command + gatewayVirtualKeyDisableCmd *cobra.Command + gatewayCredentialCmd *cobra.Command + gatewayModelsCmd *cobra.Command + gatewayProviderTypesCmd *cobra.Command + gatewayProviderTypesListCmd *cobra.Command + gatewayLLMAPIHandlerTypesCmd *cobra.Command + gatewayLLMAPIHandlerTypesListCmd *cobra.Command + gatewayModelsDiscoveredCmd *cobra.Command + gatewayModelsDiscoveredListCmd *cobra.Command + gatewayModelsDiscoveredRefreshCmd *cobra.Command + gatewayModelsManagedCmd *cobra.Command + gatewayModelsManagedListCmd *cobra.Command + gatewayModelsManagedGetCmd *cobra.Command + gatewayModelsManagedDeleteCmd *cobra.Command + gatewayCredentialListCmd *cobra.Command + gatewayCredentialGetCmd *cobra.Command + gatewayCredentialDeleteCmd *cobra.Command + gatewayACPRuntimeCmd *cobra.Command + gatewayACPRuntimeGetCmd *cobra.Command + gatewayACPRuntimeInFlightCmd *cobra.Command + gatewayACPRuntimeCloseThreadCmd *cobra.Command + gatewayAgentCmd *cobra.Command + gatewayAgentListCmd *cobra.Command + gatewayAgentGetCmd *cobra.Command + gatewayAgentDeleteCmd *cobra.Command + gatewayAgentWorkspaceCmd *cobra.Command + gatewayAgentActivityCmd *cobra.Command + gatewayAgentUsageCmd *cobra.Command + gatewayAgentInteractionsCmd *cobra.Command + gatewayAgentResourcesCmd *cobra.Command + gatewayAgentHealthCmd *cobra.Command + agentCancelMode string + agentPermissionOutcome string + agentPermissionOptionID string + agentPermissionDecisions string + agentSessionCWD string + agentSessionCursor string + gatewayAgentCapabilitiesCmd *cobra.Command + gatewayAgentRunsCmd *cobra.Command + gatewayAgentPermissionsCmd *cobra.Command + gatewayAgentCancelCmd *cobra.Command + gatewayAgentDecideCmd *cobra.Command + gatewayAgentSessionsCmd *cobra.Command + gatewayAgentTranscriptCmd *cobra.Command + gatewayAgentRouteCmd *cobra.Command + gatewayAgentRouteFile string + gatewayAgentRouteListCmd *cobra.Command + gatewayAgentRouteGetCmd *cobra.Command + gatewayAgentRouteDeleteCmd *cobra.Command + gatewayAgentRouteCreateCmd *cobra.Command + gatewayAgentRouteUpdateCmd *cobra.Command + gatewayBuiltinRuntimeCmd *cobra.Command + gatewayBuiltinRuntimeGetCmd *cobra.Command + gatewayBuiltinRuntimeInFlightCmd *cobra.Command + outputFormat string + globalCaddyAdmin string + globalGatewayAddr string + rootCmd *cobra.Command +} diff --git a/pkg/distribution/daemoncmd/command.go b/pkg/distribution/daemoncmd/command.go new file mode 100644 index 00000000..2dac46f4 --- /dev/null +++ b/pkg/distribution/daemoncmd/command.go @@ -0,0 +1,140 @@ +// Package daemoncmd constructs standalone daemon commands. Options, +// ServerOptions, NewCommand and Run are the designated reusable entry surface; +// release protection follows docs/design/enterprise-extension-contract.md. +package daemoncmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "os" + "os/signal" + "slices" + "syscall" + + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/standalone/server" + "github.com/joho/godotenv" + "github.com/spf13/cobra" +) + +// Options contains public server configuration and distribution metadata. +type Options struct { + Assembly distribution.Assembly + Server ServerOptions +} + +// ServerOptions contains only public configuration values. +type ServerOptions struct { + Addr string + AdminAddr string + ConfigStorePath string + StaticConfigPath string + ProviderTypes []string + CredentialRefreshCommand string + CredentialRefreshArgs []string + AdminBasicAuthHash string + Metrics distribution.MetricsConfig +} + +// NewCommand creates fresh flags without dotenv or signal side effects. +func NewCommand(assembly Options) *cobra.Command { + assembly.Assembly = assembly.Assembly.Clone() + registry, assemblyErr := assembly.Assembly.Registry() + opts := assembly.Server + opts.ProviderTypes = slices.Clone(opts.ProviderTypes) + opts.CredentialRefreshArgs = slices.Clone(opts.CredentialRefreshArgs) + opts.Metrics.OTLP.Headers = maps.Clone(opts.Metrics.OTLP.Headers) + if opts.Addr == "" { + opts.Addr = "127.0.0.1:8080" + } + if opts.AdminAddr == "" { + opts.AdminAddr = "localhost:8019" + } + if opts.ConfigStorePath == "" { + opts.ConfigStorePath = "./data/configstore.db" + } + if opts.AdminBasicAuthHash == "" { + opts.AdminBasicAuthHash = os.Getenv("AGW_ADMIN_BASIC_AUTH_HASH") + } + if opts.Metrics.RetentionDays == 0 { + opts.Metrics.RetentionDays = 30 + } + if opts.Metrics.OTLP.Protocol == "" { + opts.Metrics.OTLP.Protocol = "grpc" + } + rootCmd := &cobra.Command{ + Use: "agwd", + Short: "Standalone Agent Gateway daemon", + PersistentPreRunE: func(*cobra.Command, []string) error { return assemblyErr }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + return server.Run(ctx, server.Options{ + Registry: registry, Assembly: assembly.Assembly, Addr: opts.Addr, AdminAddr: opts.AdminAddr, ConfigStorePath: opts.ConfigStorePath, StaticConfigPath: opts.StaticConfigPath, ProviderTypes: opts.ProviderTypes, CredentialRefreshCommand: opts.CredentialRefreshCommand, CredentialRefreshArgs: opts.CredentialRefreshArgs, AdminBasicAuthHash: opts.AdminBasicAuthHash, Metrics: opts.Metrics, + }) + }, + } + rootCmd.Flags().StringVar(&opts.Addr, "addr", opts.Addr, "LLM gateway listen address") + rootCmd.Flags().StringVar(&opts.AdminAddr, "admin-addr", opts.AdminAddr, "Admin API listen address") + rootCmd.Flags().StringVar(&opts.AdminBasicAuthHash, "admin-basic-auth-hash", opts.AdminBasicAuthHash, "admin Basic Auth configuration as username:bcrypt-hash") + rootCmd.Flags().StringVar(&opts.ConfigStorePath, "config-store", opts.ConfigStorePath, "SQLite config store file") + rootCmd.Flags().StringVar(&opts.StaticConfigPath, "static-config", opts.StaticConfigPath, "gateway bundle YAML file loaded as read-only static configuration") + rootCmd.Flags().StringArrayVar(&opts.ProviderTypes, "provider-type", opts.ProviderTypes, "provider type enabled for this process; repeat to allow multiple types") + rootCmd.Flags().StringVar(&opts.CredentialRefreshCommand, "credential-refresh-command", opts.CredentialRefreshCommand, "external credential refresh executable (default: agw-auth)") + rootCmd.Flags().StringArrayVar(&opts.CredentialRefreshArgs, "credential-refresh-arg", opts.CredentialRefreshArgs, "static argument for the credential refresh executable; repeat as needed (default: refresh)") + rootCmd.Flags().IntVar(&opts.Metrics.RetentionDays, "metrics-retention-days", opts.Metrics.RetentionDays, "retention window in days for persisted usage events") + rootCmd.Flags().IntVar(&opts.Metrics.MaxAgentDepth, "max-agent-depth", opts.Metrics.MaxAgentDepth, "maximum inbound X-Agent-Depth allowed before rejecting a request; 0 disables enforcement") + rootCmd.Flags().StringVar(&opts.Metrics.OTLP.Endpoint, "metrics-otlp-endpoint", opts.Metrics.OTLP.Endpoint, "OTLP collector endpoint (host:port or URL) for exporting usage events as spans; empty disables export") + rootCmd.Flags().StringVar(&opts.Metrics.OTLP.Protocol, "metrics-otlp-protocol", opts.Metrics.OTLP.Protocol, "OTLP transport protocol: grpc or http") + rootCmd.Flags().BoolVar(&opts.Metrics.OTLP.Insecure, "metrics-otlp-insecure", opts.Metrics.OTLP.Insecure, "disable TLS for the OTLP exporter") + rootCmd.Flags().StringToStringVar(&opts.Metrics.OTLP.Headers, "metrics-otlp-header", opts.Metrics.OTLP.Headers, "header sent with every OTLP export request as name=value; repeat for multiple headers") + rootCmd.Flags().StringVar(&opts.Metrics.OTLP.ServiceName, "metrics-otlp-service-name", opts.Metrics.OTLP.ServiceName, "OTel resource service.name for exported spans (default agent-gateway)") + rootCmd.Flags().BoolVar(&opts.Metrics.OTLP.Components, "metrics-otlp-components", opts.Metrics.OTLP.Components, "additionally export one span per eino chat-model component call, nested under the interaction span") + + rootCmd.AddCommand(&cobra.Command{Use: "manifest", Short: "Show linked capabilities", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + m, err := assembly.Assembly.Manifest() + if err != nil { + return err + } + return json.NewEncoder(cmd.OutOrStdout()).Encode(m) + }}) + distribution.Annotate(rootCmd, assembly.Assembly) + return rootCmd +} + +// Run loads dotenv, executes the daemon and returns the original process exit code. +func Run(opts Options) int { + if err := loadRuntimeEnv(); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + if err := NewCommand(opts).ExecuteContext(context.Background()); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 +} +func loadRuntimeEnv() error { + for _, path := range []string{".env.local", ".env"} { + if err := loadOptionalEnvFile(path); err != nil { + return err + } + } + return nil +} + +func loadOptionalEnvFile(path string) error { + if _, err := os.Stat(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("stat %s: %w", path, err) + } + if err := godotenv.Load(path); err != nil { + return fmt.Errorf("load %s: %w", path, err) + } + return nil +} diff --git a/pkg/distribution/daemoncmd/instance_test.go b/pkg/distribution/daemoncmd/instance_test.go new file mode 100644 index 00000000..e9f94289 --- /dev/null +++ b/pkg/distribution/daemoncmd/instance_test.go @@ -0,0 +1,25 @@ +package daemoncmd + +import ( + "github.com/agent-guide/agent-gateway/pkg/distribution" + "testing" +) + +func TestNewCommandOwnsOptionsAndFlags(t *testing.T) { + opts := Options{Server: ServerOptions{ProviderTypes: []string{"openai"}, Metrics: distribution.MetricsConfig{OTLP: distribution.OTLPConfig{Headers: map[string]string{"key": "original"}}}}} + a, b := NewCommand(opts), NewCommand(opts) + opts.Server.ProviderTypes[0] = "mutated" + opts.Server.Metrics.OTLP.Headers["key"] = "mutated" + if got, _ := a.Flags().GetStringArray("provider-type"); len(got) != 1 || got[0] != "openai" { + t.Fatalf("caller slice retained: %v", got) + } + if got, _ := a.Flags().GetStringToString("metrics-otlp-header"); got["key"] != "original" { + t.Fatalf("caller map retained: %v", got) + } + if err := a.Flags().Set("addr", "127.0.0.1:9001"); err != nil { + t.Fatal(err) + } + if got, _ := b.Flags().GetString("addr"); got != "127.0.0.1:8080" { + t.Fatalf("shared flags: %q", got) + } +} diff --git a/cmd/agwd/main_test.go b/pkg/distribution/daemoncmd/main_test.go similarity index 98% rename from cmd/agwd/main_test.go rename to pkg/distribution/daemoncmd/main_test.go index 18df1631..c3f67b94 100644 --- a/cmd/agwd/main_test.go +++ b/pkg/distribution/daemoncmd/main_test.go @@ -1,4 +1,4 @@ -package main +package daemoncmd import ( "os" diff --git a/pkg/distribution/events_test.go b/pkg/distribution/events_test.go new file mode 100644 index 00000000..1d1ce833 --- /dev/null +++ b/pkg/distribution/events_test.go @@ -0,0 +1,57 @@ +package distribution + +import ( + "context" + "encoding/json" + "github.com/agent-guide/agent-gateway/pkg/event" + "testing" + "time" +) + +func TestPublicAssemblyValidationOwnershipAndManifestIsolation(t *testing.T) { + a := CommunityRuntime() + a.AttributeKeys = []string{"online.agentguide.community.gateway/region"} + invoked := false + a.EventSinks = []event.Registration{{ModuleID: "online.agentguide.community.gateway", New: func(context.Context) (event.Sink, error) { invoked = true; return nil, nil }}} + clone := a.Clone() + if _, err := clone.Registry(); err != nil { + t.Fatal(err) + } + manifest, err := clone.Manifest() + if err != nil { + t.Fatal(err) + } + if invoked { + t.Fatal("manifest/validation initialized a sink") + } + encoded, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + var decoded Assembly + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if decoded.EventSinks != nil || decoded.AttributeKeys != nil || decoded.AttributionProducer != nil { + t.Fatal("runtime callbacks entered manifest") + } + a.EventSinks[0].ModuleID = "changed" + a.AttributeKeys[0] = "changed" + if clone.EventSinks[0].ModuleID != "online.agentguide.community.gateway" || clone.AttributeKeys[0] != "online.agentguide.community.gateway/region" { + t.Fatal("mutable assembly ownership") + } + for _, edit := range []func(*Assembly){ + func(a *Assembly) { a.AttributeKeys = []string{"unlinked.owner/region"} }, + func(a *Assembly) { a.EventSinks[0].ModuleID = "unlinked.owner" }, + func(a *Assembly) { a.EventSinks = append(a.EventSinks, a.EventSinks[0]) }, + func(a *Assembly) { a.EventSinks[0].New = nil }, + func(a *Assembly) { a.EventSinks[0].QueueSize = 4097 }, + func(a *Assembly) { a.EventSinks[0].WriteBudget = 2 * time.Second }, + } { + bad := clone.Clone() + edit(&bad) + if _, err := bad.Registry(); err == nil { + t.Fatal("invalid explicit assembly accepted") + } + } +} diff --git a/pkg/distribution/metrics.go b/pkg/distribution/metrics.go new file mode 100644 index 00000000..2f1ae62e --- /dev/null +++ b/pkg/distribution/metrics.go @@ -0,0 +1,19 @@ +package distribution + +// MetricsConfig is the public metrics configuration value for entry options. +type MetricsConfig struct { + RetentionDays int `json:"retention_days,omitempty"` + MaxAgentDepth int `json:"max_agent_depth,omitempty"` + OTLP OTLPConfig `json:"otlp,omitzero"` +} + +// OTLPConfig configures the existing usage exporter. Empty Endpoint disables it. +type OTLPConfig struct { + Endpoint string `json:"endpoint,omitempty"` + Protocol string `json:"protocol,omitempty"` + Insecure bool `json:"insecure,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + ServiceName string `json:"service_name,omitempty"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` + Components bool `json:"components,omitempty"` +} diff --git a/pkg/event/AGENTS.md b/pkg/event/AGENTS.md new file mode 100644 index 00000000..23d428d0 --- /dev/null +++ b/pkg/event/AGENTS.md @@ -0,0 +1,18 @@ +# Public event transport + +Read design section 11.4 before changes. Only typed limiter admission and +extension mutation DTOs are permitted; no arbitrary maps, diagnostics, prompts, +configs, credentials or secrets. Validate before enqueue, including encoded size. +Delivery is transport owned by the existing pipeline, not another event authority. +One worker per sink, bounded event and barrier queues, nonblocking publication, +drop-newest overflow, no retry/replay. Callbacks are serial, panic-isolated and +receive cancellation. Shutdown cancellation reaches active Write/Flush; Close +runs once within the first caller's common deadline. Shorter concurrent waiters +cannot abort shared drain. Never hide a stuck sink with a goroutine per write. +Run event/pipeline race tests and public external sink conformance suites. + +Designated candidate symbols: Envelope, LimiterAdmission, ExtensionMutation, +Sink, Publisher, Validate, Error, Registration, Options, Stats, Delivery, +NewDelivery, Delivery.Publish/Flush/Close/Stats, SchemaVersion and +MaxEnvelopeBytes. Published-tag protection follows the enterprise extension +contract; test helpers do not add a production SPI. diff --git a/pkg/event/delivery.go b/pkg/event/delivery.go new file mode 100644 index 00000000..041a02d8 --- /dev/null +++ b/pkg/event/delivery.go @@ -0,0 +1,304 @@ +package event + +import ( + "context" + "errors" + "maps" + "reflect" + "sync" + "time" + + "github.com/agent-guide/agent-gateway/pkg/attribution" +) + +// Options bounds one sink's transport. Zero values select the contract defaults. +// Policy must be the assembly-owned attribution allowlist. +type Options struct { + QueueSize int + WriteBudget time.Duration + Policy attribution.Policy +} +type Stats struct { + Accepted uint64 `json:"accepted"` + Delivered uint64 `json:"delivered"` + Dropped uint64 `json:"dropped"` + Rejected uint64 `json:"rejected"` + Failures uint64 `json:"failures"` + Healthy bool `json:"healthy"` +} +type item struct { + event *Envelope + barrier chan error + ctx context.Context +} + +// Delivery is a single worker owned by the existing gateway pipeline. Publish +// never waits for I/O. Sink callbacks execute serially; errors are never retried. +type Delivery struct { + mu sync.Mutex + sink Sink + options Options + queue []item + queuedEvents int + queuedBarriers int + wake chan struct{} + changed chan struct{} + done chan struct{} + closing bool + shutdown context.Context + life context.Context + cancel context.CancelFunc + stats Stats + closeErr error +} + +func NewDelivery(s Sink, o Options) (*Delivery, error) { + if s == nil || (reflect.ValueOf(s).Kind() == reflect.Pointer && reflect.ValueOf(s).IsNil()) { + return nil, &Error{Code: "invalid_sink"} + } + if o.QueueSize == 0 { + o.QueueSize = 256 + } + if o.WriteBudget == 0 { + o.WriteBudget = time.Second + } + if o.QueueSize < 1 || o.QueueSize > 4096 || o.WriteBudget <= 0 || o.WriteBudget > time.Second { + return nil, &Error{Code: "invalid_sink_options"} + } + life, cancel := context.WithCancel(context.Background()) + d := &Delivery{sink: s, options: o, wake: make(chan struct{}, 1), changed: make(chan struct{}), done: make(chan struct{}), life: life, cancel: cancel, stats: Stats{Healthy: true}} + go d.run() + return d, nil +} +func (d *Delivery) signal() { + close(d.changed) + d.changed = make(chan struct{}) + select { + case d.wake <- struct{}{}: + default: + } +} +func clone(e Envelope) Envelope { + e.Attribution.Attributes = maps.Clone(e.Attribution.Attributes) + if e.Limiter != nil { + x := *e.Limiter + e.Limiter = &x + } + if e.Mutation != nil { + x := *e.Mutation + e.Mutation = &x + } + return e +} +func (d *Delivery) Publish(ctx context.Context, e Envelope) bool { + if err := Validate(e, d.options.Policy); err != nil { + d.mu.Lock() + d.stats.Rejected++ + d.mu.Unlock() + return false + } + e = clone(e) + d.mu.Lock() + defer d.mu.Unlock() + if d.closing || d.queuedEvents >= d.options.QueueSize { + d.stats.Dropped++ + return false + } + d.queue = append(d.queue, item{event: &e}) + d.queuedEvents++ + d.stats.Accepted++ + d.signal() + return true +} + +// Flush inserts a FIFO barrier and waits only for events accepted before it. +// It never calls the sink concurrently with Write or Close. +func (d *Delivery) Flush(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + b := make(chan error, 1) + for { + d.mu.Lock() + if d.closing { + d.mu.Unlock() + return &Error{Code: "closed"} + } + if d.queuedBarriers < d.options.QueueSize { + d.queue = append(d.queue, item{barrier: b, ctx: ctx}) + d.queuedBarriers++ + d.signal() + d.mu.Unlock() + break + } + changed := d.changed + d.mu.Unlock() + select { + case <-changed: + case <-ctx.Done(): + return ctx.Err() + } + } + select { + case err := <-b: + return err + case <-ctx.Done(): + return ctx.Err() + } +} +func (d *Delivery) Stats() Stats { d.mu.Lock(); defer d.mu.Unlock(); return d.stats } + +// Close rejects new events immediately. The first call owns the shared shutdown +// budget; concurrent calls wait with their own (possibly shorter) deadline. +func (d *Delivery) Close(ctx context.Context) error { + caller := ctx + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + d.mu.Lock() + if !d.closing { + d.closing = true + // Own the deadline independently of this call's deferred cancellation. + deadline, _ := ctx.Deadline() + shutdown, stop := context.WithDeadline(caller, deadline) + d.shutdown = shutdown + go func() { + select { + case <-shutdown.Done(): + d.cancel() + case <-d.done: + } + stop() + }() + d.signal() + } + d.mu.Unlock() + select { + case <-d.done: + d.mu.Lock() + defer d.mu.Unlock() + return d.closeErr + case <-ctx.Done(): + d.mu.Lock() + deadline, _ := d.shutdown.Deadline() + if d.shutdown.Err() != nil || !time.Now().Before(deadline) { + d.cancel() + d.stats.Healthy = false + d.dropQueued() + } + d.mu.Unlock() + return ctx.Err() + } +} +func (d *Delivery) dropQueued() { + for _, x := range d.queue { + if x.event != nil { + d.stats.Dropped++ + } else { + x.barrier <- &Error{Code: "closed"} + } + } + d.queue = nil + d.queuedEvents = 0 + d.queuedBarriers = 0 + d.signal() +} +func safe(call func() error) (err error) { + defer func() { + if recover() != nil { + err = &Error{Code: "sink_panic"} + } + }() + err = call() + switch { + case err == nil: + return nil + case errors.Is(err, context.Canceled): + return context.Canceled + case errors.Is(err, context.DeadlineExceeded): + return context.DeadlineExceeded + default: + return &Error{Code: "sink_failure"} + } +} +func (d *Delivery) failure(err error) { + if err != nil { + d.mu.Lock() + d.stats.Failures++ + d.stats.Healthy = false + d.mu.Unlock() + } +} +func (d *Delivery) run() { + defer close(d.done) + defer d.cancel() + for { + d.mu.Lock() + if d.closing && d.shutdown.Err() != nil { + d.dropQueued() + } + if len(d.queue) == 0 { + if d.closing { + ctx := d.shutdown + d.mu.Unlock() + err := safe(func() error { return d.sink.Flush(ctx) }) + if err == nil && ctx.Err() != nil { + err = ctx.Err() + } + d.failure(err) + cerr := safe(func() error { return d.sink.Close(ctx) }) + d.failure(cerr) + if err == nil { + err = cerr + } + d.mu.Lock() + d.closeErr = err + d.mu.Unlock() + return + } + d.mu.Unlock() + <-d.wake + continue + } + x := d.queue[0] + d.queue = d.queue[1:] + if x.event != nil { + d.queuedEvents-- + } else { + d.queuedBarriers-- + } + d.signal() + d.mu.Unlock() + if x.event != nil { + ctx, cancel := context.WithTimeout(d.life, d.options.WriteBudget) + err := safe(func() error { return d.sink.Write(ctx, *x.event) }) + if err == nil && ctx.Err() != nil { + err = ctx.Err() + } + cancel() + d.failure(err) + d.mu.Lock() + if err == nil { + d.stats.Delivered++ + } + d.mu.Unlock() + } else { + if err := x.ctx.Err(); err != nil { + x.barrier <- err + continue + } + ctx, cancel := context.WithCancel(x.ctx) + stop := context.AfterFunc(d.life, cancel) + err := safe(func() error { return d.sink.Flush(ctx) }) + if err == nil && ctx.Err() != nil { + err = ctx.Err() + } + stop() + cancel() + // A caller abandoning its barrier does not imply a faulty sink. Shared + // shutdown cancellation remains a lifecycle failure and is observable. + if x.ctx.Err() == nil || !errors.Is(err, x.ctx.Err()) || d.life.Err() != nil { + d.failure(err) + } + x.barrier <- err + } + } +} diff --git a/pkg/event/delivery_test.go b/pkg/event/delivery_test.go new file mode 100644 index 00000000..4c804fc8 --- /dev/null +++ b/pkg/event/delivery_test.go @@ -0,0 +1,347 @@ +package event + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type testSink struct { + mu sync.Mutex + ids []string + block chan struct{} + entered chan struct{} + once sync.Once + fail bool + closes atomic.Int32 +} + +func (s *testSink) Write(ctx context.Context, e Envelope) error { + if s.entered != nil { + s.once.Do(func() { close(s.entered) }) + } + if s.block != nil { + select { + case <-s.block: + case <-ctx.Done(): + return ctx.Err() + } + } + s.mu.Lock() + s.ids = append(s.ids, e.ID) + s.mu.Unlock() + if s.fail { + return errors.New("private diagnostic") + } + return nil +} +func (s *testSink) Flush(ctx context.Context) error { return ctx.Err() } +func (s *testSink) Close(ctx context.Context) error { s.closes.Add(1); return ctx.Err() } +func fixture(id string) Envelope { + return Envelope{SchemaVersion: 1, ID: id, Timestamp: time.Now().UTC(), Family: "extension.mutation", Outcome: "success", Mutation: &ExtensionMutation{ModuleID: "example.fixture", Operation: "put"}} +} +func TestFIFOAndCloseOnce(t *testing.T) { + s := &testSink{} + d, err := NewDelivery(s, Options{}) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"a", "b", "c"} { + if !d.Publish(context.Background(), fixture(id)) { + t.Fatal("drop") + } + } + if err := d.Flush(context.Background()); err != nil { + t.Fatal(err) + } + s.mu.Lock() + if len(s.ids) != 3 || s.ids[0] != "a" || s.ids[2] != "c" { + t.Fatal(s.ids) + } + s.mu.Unlock() + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if err := d.Close(context.Background()); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + if s.closes.Load() != 1 || d.Publish(context.Background(), fixture("late")) { + t.Fatal("close lifecycle") + } +} +func TestOverflowCancellationAndIsolation(t *testing.T) { + slow := &testSink{block: make(chan struct{}), entered: make(chan struct{})} + d, _ := NewDelivery(slow, Options{QueueSize: 1}) + d.Publish(context.Background(), fixture("active")) + <-slow.entered + if !d.Publish(context.Background(), fixture("queued")) || d.Publish(context.Background(), fixture("drop")) { + t.Fatal("queue bounds") + } + fast := &testSink{fail: true} + other, _ := NewDelivery(fast, Options{}) + other.Publish(context.Background(), fixture("independent")) + _ = other.Flush(context.Background()) + if other.Stats().Failures != 1 { + t.Fatal(other.Stats()) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + start := time.Now() + if d.Close(ctx) == nil || time.Since(start) > time.Second { + t.Fatal("shutdown budget") + } + close(slow.block) + _ = d.Close(context.Background()) + _ = other.Close(context.Background()) + if d.Stats().Dropped < 2 { + t.Fatal(d.Stats()) + } +} +func TestRejectBeforeQueueAndCopy(t *testing.T) { + s := &testSink{block: make(chan struct{}), entered: make(chan struct{})} + d, _ := NewDelivery(s, Options{}) + e := fixture("first") + d.Publish(context.Background(), e) + <-s.entered + bad := fixture("bad") + bad.Reason = "raw error" + if d.Publish(context.Background(), bad) { + t.Fatal("invalid event") + } + e = fixture("owned") + d.Publish(context.Background(), e) + e.Mutation.ModuleID = "mutated" + close(s.block) + if err := d.Close(context.Background()); err != nil { + t.Fatal(err) + } + if d.Stats().Rejected != 1 || d.Stats().Delivered != 2 { + t.Fatal(d.Stats()) + } +} + +func TestConcurrentShortCloseDoesNotAbortSharedDrain(t *testing.T) { + s := &testSink{block: make(chan struct{}), entered: make(chan struct{})} + d, _ := NewDelivery(s, Options{}) + d.Publish(context.Background(), fixture("active")) + <-s.entered + d.Publish(context.Background(), fixture("queued")) + result := make(chan error, 1) + go func() { result <- d.Close(context.Background()) }() + for { + d.mu.Lock() + closing := d.closing + d.mu.Unlock() + if closing { + break + } + time.Sleep(time.Millisecond) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + if d.Close(ctx) == nil { + t.Fatal("short caller should time out") + } + if d.Stats().Dropped != 0 || !d.Stats().Healthy { + t.Fatal("short waiter changed shared drain") + } + close(s.block) + if err := <-result; err != nil { + t.Fatal(err) + } + if d.Stats().Delivered != 2 { + t.Fatal(d.Stats()) + } +} + +type stuckSink struct { + testSink + release chan struct{} +} + +func (s *stuckSink) Write(context.Context, Envelope) error { + s.once.Do(func() { close(s.entered) }) + <-s.release + return nil +} +func TestNonCooperativeSinkCannotBlockTeardown(t *testing.T) { + s := &stuckSink{testSink: testSink{entered: make(chan struct{})}, release: make(chan struct{})} + d, _ := NewDelivery(s, Options{}) + d.Publish(context.Background(), fixture("stuck")) + <-s.entered + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + if d.Close(ctx) == nil || d.Stats().Healthy { + t.Fatal("noncooperative sink not reported") + } + close(s.release) + _ = d.Close(context.Background()) + if s.closes.Load() != 1 { + t.Fatal("close count") + } +} + +type blockingFlushSink struct { + testSink + flushing chan struct{} + stopped chan struct{} +} + +func (s *blockingFlushSink) Flush(ctx context.Context) error { + s.once.Do(func() { close(s.flushing) }) + <-ctx.Done() + select { + case s.stopped <- struct{}{}: + default: + } + return ctx.Err() +} +func TestShutdownCancelsInFlightFlush(t *testing.T) { + s := &blockingFlushSink{flushing: make(chan struct{}), stopped: make(chan struct{}, 2)} + d, _ := NewDelivery(s, Options{}) + result := make(chan error, 1) + go func() { result <- d.Flush(context.Background()) }() + <-s.flushing + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + _ = d.Close(ctx) + select { + case <-s.stopped: + case <-time.After(time.Second): + t.Fatal("flush did not receive shutdown cancellation") + } + if <-result == nil { + t.Fatal("flush cancellation hidden") + } + _ = d.Close(context.Background()) +} +func TestFlushBarrierDoesNotConsumeEventCapacity(t *testing.T) { + s := &testSink{block: make(chan struct{}), entered: make(chan struct{})} + d, _ := NewDelivery(s, Options{QueueSize: 1}) + d.Publish(context.Background(), fixture("active")) + <-s.entered + done := make(chan error, 1) + go func() { done <- d.Flush(context.Background()) }() + for { + d.mu.Lock() + n := d.queuedBarriers + d.mu.Unlock() + if n == 1 { + break + } + time.Sleep(time.Millisecond) + } + if !d.Publish(context.Background(), fixture("after-barrier")) { + t.Fatal("control barrier consumed event capacity") + } + close(s.block) + if err := <-done; err != nil { + t.Fatal(err) + } + if err := d.Close(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestCloseHonorsCallerCancellationWithoutDeadline(t *testing.T) { + s := &testSink{block: make(chan struct{}), entered: make(chan struct{})} + d, _ := NewDelivery(s, Options{}) + d.Publish(context.Background(), fixture("active")) + <-s.entered + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { result <- d.Close(ctx) }() + for { + d.mu.Lock() + closing := d.closing + d.mu.Unlock() + if closing { + break + } + time.Sleep(time.Millisecond) + } + cancel() + if err := <-result; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + select { + case <-d.done: + case <-time.After(100 * time.Millisecond): + t.Fatal("caller cancellation was reduced to a deadline") + } + if d.Stats().Healthy { + t.Fatal("canceled lifecycle not reported") + } +} + +type diagnosticSink struct { + testSink + flushErr, closeErr error +} + +func (s *diagnosticSink) Flush(context.Context) error { return s.flushErr } +func (s *diagnosticSink) Close(context.Context) error { return s.closeErr } +func TestWrappedCancellationDiagnosticsCannotEscape(t *testing.T) { + for _, sentinel := range []error{context.Canceled, context.DeadlineExceeded} { + for _, phase := range []string{"flush", "close"} { + t.Run(phase+"/"+sentinel.Error(), func(t *testing.T) { + wrapped := fmt.Errorf("fixture-private-diagnostic: %w", sentinel) + s := &diagnosticSink{} + if phase == "flush" { + s.flushErr = wrapped + } else { + s.closeErr = wrapped + } + d, err := NewDelivery(s, Options{}) + if err != nil { + t.Fatal(err) + } + defer d.Close(context.Background()) + if phase == "flush" { + err = d.Flush(context.Background()) + } else { + err = d.Close(context.Background()) + } + if !errors.Is(err, sentinel) || strings.Contains(err.Error(), "fixture-private") || err.Error() != sentinel.Error() { + t.Fatalf("callback diagnostic escaped: %v", err) + } + }) + } + } +} + +type countedFlushSink struct { + testSink + flushes atomic.Int32 +} + +func (s *countedFlushSink) Flush(ctx context.Context) error { s.flushes.Add(1); return ctx.Err() } +func TestAbandonedFlushDoesNotPoisonSinkHealth(t *testing.T) { + s := &countedFlushSink{testSink: testSink{block: make(chan struct{}), entered: make(chan struct{})}} + d, _ := NewDelivery(s, Options{}) + defer d.Close(context.Background()) + d.Publish(context.Background(), fixture("active")) + <-s.entered + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) + defer cancel() + if err := d.Flush(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + close(s.block) + if err := d.Flush(context.Background()); err != nil { + t.Fatal(err) + } + if s.flushes.Load() != 1 || d.Stats().Failures != 0 || !d.Stats().Healthy { + t.Fatalf("abandoned barrier changed sink health: flushes %d stats %+v", s.flushes.Load(), d.Stats()) + } +} diff --git a/pkg/event/event.go b/pkg/event/event.go new file mode 100644 index 00000000..0751242a --- /dev/null +++ b/pkg/event/event.go @@ -0,0 +1,102 @@ +// Package event defines sanitized v1 DTOs and bounded sink transport. It is not +// a usage store or a second event authority. Envelope, LimiterAdmission, +// ExtensionMutation, Sink, Publisher, Validate and Error are candidate protected +// surfaces. Registration, Options, Stats, Delivery, NewDelivery and +// Delivery.Publish/Flush/Close/Stats plus SchemaVersion and MaxEnvelopeBytes +// are also designated candidate transport surfaces. Released protection requires +// the enterprise extension contract's published-tag and consumer gates. +package event + +import ( + "context" + "encoding/json" + "regexp" + "time" + + "github.com/agent-guide/agent-gateway/pkg/attribution" +) + +const MaxEnvelopeBytes = 16 * 1024 +const SchemaVersion = 1 + +type Envelope struct { + SchemaVersion int `json:"schema_version"` + ID string `json:"event_id"` + Family string `json:"family"` + Timestamp time.Time `json:"timestamp"` + Attribution attribution.Value `json:"attribution"` + Outcome string `json:"outcome"` + Reason string `json:"reason,omitempty"` + Limiter *LimiterAdmission `json:"limiter_admission,omitempty"` + Mutation *ExtensionMutation `json:"extension_mutation,omitempty"` +} +type LimiterAdmission struct { + Dimension string `json:"dimension"` + RetryAfter time.Duration `json:"retry_after_ns"` + BackendID string `json:"backend_id"` +} +type ExtensionMutation struct { + ModuleID string `json:"module_id"` + Operation string `json:"operation"` + StateReset bool `json:"state_reset,omitempty"` +} +type Sink interface { + Write(context.Context, Envelope) error + Flush(context.Context) error + Close(context.Context) error +} +type Publisher interface { + Publish(context.Context, Envelope) bool +} +type Error struct{ Code string } + +func (e *Error) Error() string { return e.Code } + +var moduleID = regexp.MustCompile(`^[a-z0-9-]+(\.[a-z0-9-]+)+$`) +var eventID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}$`) +var reasons = map[string]bool{"": true, "rate_limited": true, "limiter_unavailable": true, "canceled": true, "invalid_config": true, "unknown_module": true, "schema_mismatch": true, "missing_dependency": true, "dependency_cycle": true, "prepare_failed": true, "persistence_failed": true, "closed": true, "unhealthy": true, "mutation_failed": true} + +func Validate(e Envelope, p attribution.Policy) error { + invalid := func() error { return &Error{Code: "invalid_event"} } + if e.SchemaVersion != 1 || !eventID.MatchString(e.ID) || e.Timestamp.IsZero() || e.Timestamp.Location() != time.UTC || !reasons[e.Reason] { + return invalid() + } + if err := p.Validate(e.Attribution); err != nil { + return invalid() + } + switch e.Family { + case "limiter.admission": + if e.Limiter == nil || e.Mutation != nil || !(e.Outcome == "allow" || e.Outcome == "deny" || e.Outcome == "degraded") { + return invalid() + } + x := e.Limiter + if !(x.Dimension == "llm" || x.Dimension == "mcp" || x.Dimension == "agent") || x.RetryAfter < 0 || len(x.BackendID) > 128 || !moduleID.MatchString(x.BackendID) { + return invalid() + } + case "extension.mutation": + if e.Mutation == nil || e.Limiter != nil || !(e.Outcome == "success" || e.Outcome == "failure") { + return invalid() + } + x := e.Mutation + if len(x.ModuleID) > 128 || !moduleID.MatchString(x.ModuleID) || !(x.Operation == "put" || x.Operation == "delete" || x.Operation == "reset") { + return invalid() + } + default: + return invalid() + } + b, err := json.Marshal(e) + if err != nil || len(b) > MaxEnvelopeBytes { + return &Error{Code: "event_too_large"} + } + return nil +} + +// Registration is an explicit assembly-owned sink factory. New must honor its +// context and return a fresh sink per server lifecycle (including Caddy reload). +// ModuleID must name a linked descriptor. Callbacks never enter the manifest. +type Registration struct { + ModuleID string + New func(context.Context) (Sink, error) + QueueSize int + WriteBudget time.Duration +} diff --git a/pkg/event/event_test.go b/pkg/event/event_test.go new file mode 100644 index 00000000..8b564468 --- /dev/null +++ b/pkg/event/event_test.go @@ -0,0 +1,49 @@ +package event + +import ( + "errors" + "fmt" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "strings" + "testing" + "time" +) + +func TestDTOBoundary(t *testing.T) { + e := Envelope{SchemaVersion: 1, ID: "test", Timestamp: time.Now().UTC(), Family: "extension.mutation", Outcome: "success", Mutation: &ExtensionMutation{ModuleID: "example.fixture", Operation: "put"}} + if err := Validate(e, attribution.Policy{}); err != nil { + t.Fatal(err) + } + for _, change := range []func(*Envelope){func(e *Envelope) { e.Reason = "secret-value" }, func(e *Envelope) { e.Mutation.ModuleID = strings.Repeat("x", 129) }, func(e *Envelope) { e.Limiter = &LimiterAdmission{} }, func(e *Envelope) { e.Family = "usage" }, func(e *Envelope) { e.Attribution.Attributes = map[string]string{"unlisted": "prompt"} }} { + copy := e + x := *e.Mutation + copy.Mutation = &x + change(©) + if Validate(copy, attribution.Policy{}) == nil { + t.Fatal("invalid DTO accepted") + } + } +} + +func TestEncodedBoundsIncludeEscaping(t *testing.T) { + keys := make([]string, 16) + attrs := map[string]string{} + for i := range keys { + keys[i] = fmt.Sprintf("example.fixture/key%d", i) + attrs[keys[i]] = strings.Repeat("\x00", 200) + } + p, err := attribution.NewPolicy([]string{"example.fixture"}, keys) + if err != nil { + t.Fatal(err) + } + e := fixture("bounded") + e.Attribution.Attributes = attrs + if err := p.Validate(e.Attribution); err != nil { + t.Fatal(err) + } + err = Validate(e, p) + var bounded *Error + if !errors.As(err, &bounded) || bounded.Code != "event_too_large" { + t.Fatalf("JSON escape bound: %v", err) + } +} diff --git a/pkg/event/identity_test.go b/pkg/event/identity_test.go new file mode 100644 index 00000000..d2d0b79e --- /dev/null +++ b/pkg/event/identity_test.go @@ -0,0 +1,31 @@ +package event_test + +import ( + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/agent-guide/agent-gateway/pkg/extension" + "testing" + "time" +) + +func TestEventsAcceptRegistryModuleIdentityGrammar(t *testing.T) { + for _, id := range []string{"example.-fixture", "example.fixture-", "-.fixture", "example.-", "example.fixture"} { + t.Run(id, func(t *testing.T) { + _, err := extension.NewRegistry(extension.Registration{Descriptor: extension.Descriptor{ID: id, Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime"}}}) + if err != nil { + t.Fatal(err) + } + e := event.Envelope{SchemaVersion: 1, ID: "test", Timestamp: time.Now().UTC(), Family: "extension.mutation", Outcome: "success", Mutation: &event.ExtensionMutation{ModuleID: id, Operation: "put"}} + if err := event.Validate(e, attribution.Policy{}); err != nil { + t.Fatalf("registry identity rejected in mutation: %v", err) + } + e.Family = "limiter.admission" + e.Outcome = "allow" + e.Mutation = nil + e.Limiter = &event.LimiterAdmission{BackendID: id, Dimension: "llm"} + if err := event.Validate(e, attribution.Policy{}); err != nil { + t.Fatalf("registry identity rejected in admission: %v", err) + } + }) + } +} diff --git a/pkg/extension/AGENTS.md b/pkg/extension/AGENTS.md new file mode 100644 index 00000000..c69532b2 --- /dev/null +++ b/pkg/extension/AGENTS.md @@ -0,0 +1,58 @@ +# Extension foundation + +This package owns explicit linked metadata, registry validation, bounded module +configuration and prepared snapshot lifecycle. Read the root rules and design +section 11.2–11.3 before changing it. No dynamic code loading, entitlement state, +service locator, private implementation imports or request-time storage reads. + +Factories are separate from serialized descriptors. A registry is immutable. +Metadata-only modules cannot accept envelopes. Validate is syntax/default/schema +validation without secret resolution. Factory secret fields use exact JSON +Pointers; duplicate JSON keys fail before module validation or expansion. Literal secrets and undeclared reference objects fail before ordinary +environment expansion. Factory diagnostics never become API errors. + +Manager startup loads only persisted envelopes; no static extension input or +source/ownership field exists. Caddy and standalone share this runtime. Bundle +apply and Admin API mutate the existing ConfigStore; Caddyfile owns host wiring. + +Mutations serialize, prepare dependencies before dependents, persist, and publish +one complete generation. Preparation cannot mutate active resources. Snapshot +read handles are concurrency-safe; leases must be released after requests finish. +Close stops admissions and cancels mutations before waiting for their gate. +Already committed writes finalize into an immediately retiring generation. +Prepare/Health/Close honor cancellation; resources close in reverse dependency +order. New generations currently prepare every configured module, so callbacks +must not assume unrelated updates preserve resource identity. Shared request +policy/bucket preservation belongs to its domain owner, especially the F5 limiter. + +Candidate surfaces are documented in doc.go and guarded by consumer contract +suites. Released-tag protection follows enterprise-extension-contract.md. +Run extension race tests, Bundle/SQLite tests and make test-extension-consumer. + +PUT/DELETE emits sanitized public mutation events through the injected existing +pipeline publisher after commit, or a bounded reason on failure. Events contain +no module config, secret references/values or arbitrary callback diagnostics. +Committed mutation publication completes before releasing the mutation gate or +notifying shutdown waiters. The publisher only enqueues; no manager mutex is +held and no sink callback runs on this path. + +Admin factories declare immutable method/path pairs with the `admin` role. +Only literal owner namespaces `/admin/extensions//...` are allowed; +module root CRUD and all Community prefixes (including the AgentRoute mux) are +reserved. Single-segment wildcards are supported; overlaps (including GET/HEAD) +fail registry assembly with `route_conflict`. Prepared resources must implement +AdminContributor. Handlers retain a generation lease, honor request/retirement +cancellation and use WriteAdminError. Dependencies contain owned reference config, +logger, attribution access and owner-scoped typed mutation events. Bind event +identity to the captured trusted request context, never a module-supplied Publish +context; the owner binder is resolved only during HTTP assembly. Never inject gateway, +raw store or resolved secrets. Read design section 11.5 and run Admin race and +external consumer/process contracts when changing this seam. + +StatefulResource reconciles unpublished domain identity against the immutable +previous handle before persistence. Never mutate previous/live state. This is not +resource reuse: all handles still prepare/close once per generation. Removed or +changed state reports state_reset only after successful commit. Snapshot.Sequence +is a manager-local monotonic fence; failed candidates may leave gaps. Limiter +selection keeps unchanged policy identity across refreshes, including timeout or +failure-mode changes; backend authority/config changes reset identity. diff --git a/pkg/extension/admin.go b/pkg/extension/admin.go new file mode 100644 index 00000000..b1dc7a48 --- /dev/null +++ b/pkg/extension/admin.go @@ -0,0 +1,168 @@ +package extension + +import ( + "context" + "encoding/json" + "net/http" + "regexp" + "slices" + "strings" + + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "go.uber.org/zap" +) + +// AdminRoute declares a method/path owned by its registration's module. Paths +// are absolute, with literal segments or single-segment {name} wildcards. +// Subtree, host, multi-segment wildcard and root-envelope routes are unsupported. +// GET also owns HEAD. Overlapping declarations are rejected, even if one would +// be more specific in net/http's mux. +type AdminRoute struct { + Method string + Path string +} + +// AdminDependencies is request-scoped. Config is an owned, reference-preserving +// copy, never resolved secrets. Events accepts only this owner's typed mutation +// events and stamps the current trusted attribution. No gateway/store is exposed. +type AdminDependencies struct { + Config Envelope + Logger *zap.Logger + Attribution func(context.Context) (attribution.Value, bool) + Events event.Publisher +} + +// AdminContributor is implemented by a prepared Resource whose factory declares +// AdminRoutes. Handlers use r.Pattern/PathValue to dispatch; they must honor request +// cancellation, be concurrency-safe and use WriteAdminError for bounded errors. +// Health and Close are inherited from Resource. The generation lease covers the +// whole handler call and its context is canceled on bounded generation retirement. +type AdminContributor interface { + ServeAdminHTTP(http.ResponseWriter, *http.Request, AdminDependencies) +} + +// OwnedAdminRoute is the immutable registry projection used at Admin assembly. +type OwnedAdminRoute struct { + ModuleID string + AdminRoute +} + +var adminLiteral = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) +var adminWildcard = regexp.MustCompile(`^\{[A-Za-z_][A-Za-z0-9_]*\}$`) +var adminCode = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + +func validateAdminRoutes(id string, routes []AdminRoute) error { + if len(routes) > 64 { + return failure("invalid_registration", id, "admin_routes") + } + for i, r := range routes { + if !slices.Contains([]string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, r.Method) { + return failure("invalid_registration", id, "admin_routes") + } + prefix := "/admin/extensions/" + id + "/" + if len(r.Path) > 1024 || !strings.HasPrefix(r.Path, prefix) || r.Method == "OPTIONS" { + // OPTIONS remains the shared mount's CORS preflight operation. + return failure("route_conflict", id, "admin_routes") + } + names := map[string]bool{} + for _, segment := range strings.Split(strings.TrimPrefix(r.Path, prefix), "/") { + if adminWildcard.MatchString(segment) { + if names[segment] { + return failure("route_conflict", id, "admin_routes") + } + names[segment] = true + } else if !adminLiteral.MatchString(segment) || segment == "." || segment == ".." { + return failure("route_conflict", id, "admin_routes") + } + } + for _, previous := range routes[:i] { + if adminRoutesOverlap(previous, r) { + return failure("route_conflict", id, "admin_routes") + } + } + } + return nil +} +func adminRoutesOverlap(a, b AdminRoute) bool { + methods := a.Method == b.Method || a.Method == "GET" && b.Method == "HEAD" || a.Method == "HEAD" && b.Method == "GET" + if !methods { + return false + } + as, bs := strings.Split(a.Path, "/"), strings.Split(b.Path, "/") + if len(as) != len(bs) { + return false + } + for i := range as { + if as[i] != bs[i] && !adminWildcard.MatchString(as[i]) && !adminWildcard.MatchString(bs[i]) { + return false + } + } + return true +} + +// AdminRoutes returns copies of the complete, validated declaration set. The +// literal module namespace makes contributions disjoint from all Community +// route families, including the separate AgentRoute mux and envelope roots. +func (r *Registry) AdminRoutes() []OwnedAdminRoute { + var out []OwnedAdminRoute + if r != nil { + for _, id := range r.order { + if f := r.entries[id].Factory; f != nil { + for _, route := range f.AdminRoutes { + out = append(out, OwnedAdminRoute{ModuleID: id, AdminRoute: route}) + } + } + } + } + return out +} + +// AdminRoutes snapshots the linked registry declarations once during HTTP assembly. +func (m *Manager) AdminRoutes() []OwnedAdminRoute { return m.registry.AdminRoutes() } + +// WriteAdminError writes bounded JSON without arbitrary callback diagnostics. +func WriteAdminError(w http.ResponseWriter, status int, code string) { + if status < 400 || status > 599 { + status = http.StatusInternalServerError + } + if !adminCode.MatchString(code) { + code = "extension_unavailable" + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": code, "code": code}) +} + +// AdminPublisher binds a validated owner once during HTTP assembly and returns +// a request binder. Each invocation captures the trusted request context without +// registry/configuration reads. Publish's context controls cancellation/delivery +// only: a contributor cannot replace the captured request actor or correlation. +func (m *Manager) AdminPublisher(id string) func(context.Context) event.Publisher { + reg, ok := m.registry.entries[id] + if !ok || reg.Factory == nil || len(reg.Factory.AdminRoutes) == 0 { + return nil + } + return func(trusted context.Context) event.Publisher { + return adminPublisher{manager: m, owner: id, trusted: trusted} + } +} + +type adminPublisher struct { + manager *Manager + owner string + trusted context.Context +} + +func (p adminPublisher) Publish(ctx context.Context, e event.Envelope) bool { + if e.Family != "extension.mutation" || e.Mutation == nil || e.Mutation.ModuleID != p.owner { + return false + } + // FromContext returns a fresh defensive copy for every event, even if a + // downstream publisher mutates the value it receives. + e.Attribution, _ = attribution.FromContext(p.trusted) + p.manager.mu.Lock() + publisher := p.manager.publisher + p.manager.mu.Unlock() + return publisher != nil && publisher.Publish(ctx, e) +} diff --git a/pkg/extension/admin_test.go b/pkg/extension/admin_test.go new file mode 100644 index 00000000..da7a11f0 --- /dev/null +++ b/pkg/extension/admin_test.go @@ -0,0 +1,148 @@ +package extension + +import ( + "context" + "encoding/json" + "errors" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func adminRegistration(routes ...AdminRoute) Registration { + return Registration{Descriptor: Descriptor{ID: "example.admin", Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime", "admin"}}, Factory: &Factory{AdminRoutes: routes, Validate: func(_ context.Context, raw json.RawMessage) (json.RawMessage, error) { return raw, nil }, Prepare: func(context.Context, PreparedConfig, Snapshot) (Resource, error) { return &adminResource{}, nil }}} +} + +type adminResource struct{} + +func (*adminResource) Health(context.Context) error { return nil } +func (*adminResource) Close(context.Context) error { return nil } +func (*adminResource) ServeAdminHTTP(http.ResponseWriter, *http.Request, AdminDependencies) {} + +type healthOnlyResource struct{} + +func (*healthOnlyResource) Health(context.Context) error { return nil } +func (*healthOnlyResource) Close(context.Context) error { return nil } + +func TestAdminOwnershipAndConflict(t *testing.T) { + prefix := "/admin/extensions/example.admin/" + for _, routes := range [][]AdminRoute{ + {{"GET", "/admin/memory/config"}}, {{"POST", "/admin/agents/routes"}}, {{"GET", "/admin/extensions/example.other/status"}}, {{"GET", strings.TrimSuffix(prefix, "/")}}, + {{"GET", prefix + "status"}, {"GET", prefix + "status"}}, {{"GET", prefix + "{id}"}, {"GET", prefix + "status"}}, {{"GET", prefix + "{a}/value"}, {"GET", prefix + "key/{b}"}}, {{"GET", prefix + "status"}, {"HEAD", prefix + "status"}}, + {{"GET", prefix + "{id}/{id}"}}, {{"GET", prefix + "{rest...}"}}, {{"GET", prefix + "a/../b"}}, {{"GET", prefix + ""}}, {{"GET", prefix + "a/"}}, {{"OPTIONS", prefix + "status"}}, + } { + reg := adminRegistration(routes...) + _, err := NewRegistry(reg) + var e *Error + if !errors.As(err, &e) || e.Code != "route_conflict" { + t.Fatalf("accepted/unsafe conflict %#v: %v", routes, err) + } + } + reg := adminRegistration(AdminRoute{"GET", prefix + "items/{id}"}, AdminRoute{"PUT", prefix + "items/{id}"}, AdminRoute{"GET", prefix + "status"}) + r, err := NewRegistry(reg) + if err != nil { + t.Fatal(err) + } + reg.Factory.AdminRoutes[0].Path = "mutated" + routes := r.AdminRoutes() + routes[0].Path = "mutated" + if r.AdminRoutes()[0].Path != prefix+"items/{id}" { + t.Fatal("declarations alias caller") + } + // The complete declaration set registers without a public ServeMux panic. + mux := http.NewServeMux() + for _, route := range r.AdminRoutes() { + mux.HandleFunc(route.Method+" "+route.Path, func(http.ResponseWriter, *http.Request) {}) + } +} + +func TestAdminMissingContributorFailsPreparation(t *testing.T) { + reg := adminRegistration(AdminRoute{"GET", "/admin/extensions/example.admin/status"}) + reg.Factory.Prepare = func(context.Context, PreparedConfig, Snapshot) (Resource, error) { return &healthOnlyResource{}, nil } + r, err := NewRegistry(reg) + if err != nil { + t.Fatal(err) + } + _, err = NewManager(context.Background(), r, &memoryPersistence{items: []Envelope{{ModuleID: reg.Descriptor.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)}}}) + var e *Error + if !errors.As(err, &e) || e.Code != "prepare_failed" || e.Field != "admin_routes" { + t.Fatalf("missing contributor: %v", err) + } +} + +func TestAdminErrorBound(t *testing.T) { + rr := httptest.NewRecorder() + WriteAdminError(rr, 400, strings.Repeat("secret", 500)) + if rr.Code != 400 || rr.Body.Len() > 1024 || strings.Contains(rr.Body.String(), "secret") { + t.Fatalf("unbounded error: %s", rr.Body.String()) + } +} + +type modifyingAdminSinkPublisher struct { + t *testing.T + want attribution.Value + delivery context.Context + count int +} + +func (p *modifyingAdminSinkPublisher) Publish(ctx context.Context, e event.Envelope) bool { + p.t.Helper() + if ctx != p.delivery { + p.t.Error("publication discarded delivery context") + } + if e.Attribution.Subject != p.want.Subject || e.Attribution.Tenant != p.want.Tenant || e.Attribution.RouteID != p.want.RouteID || e.Attribution.Attributes["example.admin/region"] != "original" { + p.t.Fatalf("replaced request identity: %+v", e.Attribution) + } + // Downstream code cannot corrupt the captured identity for the next event. + e.Attribution.Attributes["example.admin/region"] = "modified" + p.count++ + return true +} +func TestAdminPublisherBindsEachRequestAndCopiesAttribution(t *testing.T) { + reg := adminRegistration(AdminRoute{"POST", "/admin/extensions/example.admin/reset"}) + registry, err := NewRegistry(reg) + if err != nil { + t.Fatal(err) + } + manager, err := NewManager(context.Background(), registry, nil) + if err != nil { + t.Fatal(err) + } + defer manager.Close(context.Background()) + policy, err := attribution.NewPolicy([]string{"example.admin"}, []string{"example.admin/region"}) + if err != nil { + t.Fatal(err) + } + fake, err := attribution.WithContext(context.Background(), attribution.Value{Subject: "forged", Tenant: "forged-tenant", RouteID: "forged-route"}, attribution.Policy{}) + if err != nil { + t.Fatal(err) + } + delivery, cancel := context.WithCancel(fake) + defer cancel() + downstream := &modifyingAdminSinkPublisher{t: t, delivery: delivery} + manager.SetPublisher(downstream) + bind := manager.AdminPublisher("example.admin") + if bind == nil || manager.AdminPublisher("example.other") != nil { + t.Fatal("owner binding") + } + for _, actor := range []string{"first", "second"} { + value := attribution.Value{Subject: actor, Tenant: actor + "-tenant", RouteID: actor + "-route", Attributes: map[string]string{"example.admin/region": "original"}} + trusted, err := attribution.WithContext(context.Background(), value, policy) + if err != nil { + t.Fatal(err) + } + scoped := bind(trusted) + downstream.want = value + for range 2 { + if !scoped.Publish(delivery, event.Envelope{Family: "extension.mutation", Attribution: attribution.Value{Subject: "forged-envelope"}, Mutation: &event.ExtensionMutation{ModuleID: "example.admin", Operation: "reset"}}) { + t.Fatal("owner publication failed") + } + } + } + if downstream.count != 4 { + t.Fatalf("publications: %d", downstream.count) + } +} diff --git a/pkg/extension/callback.go b/pkg/extension/callback.go new file mode 100644 index 00000000..f80040c8 --- /dev/null +++ b/pkg/extension/callback.go @@ -0,0 +1,22 @@ +package extension + +import "context" + +// Module panic values and arbitrary callback diagnostics never cross the public +// boundary, including resource teardown running outside an HTTP handler. +func callback[T any](fn func() (T, error)) (value T, err error) { + defer func() { + if recover() != nil { + err = failure("callback_failed", "", "") + } + }() + return fn() +} +func closeResource(ctx context.Context, r Resource) error { + _, err := callback(func() (struct{}, error) { return struct{}{}, r.Close(ctx) }) + return err +} +func healthResource(ctx context.Context, r Resource) error { + _, err := callback(func() (struct{}, error) { return struct{}{}, r.Health(ctx) }) + return err +} diff --git a/pkg/extension/config.go b/pkg/extension/config.go new file mode 100644 index 00000000..fdb18323 --- /dev/null +++ b/pkg/extension/config.go @@ -0,0 +1,274 @@ +package extension + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "regexp" + "slices" + "strings" +) + +// Envelope retains ConfigStore-owned module JSON and unresolved secret references. +type Envelope struct { + ModuleID string `json:"module_id"` + SchemaVersion int `json:"schema_version"` + Config json.RawMessage `json:"config"` +} + +// PreparedConfig carries tokens separately from resolved secrets. Only Prepare +// receives Secrets; they must never be copied into envelopes or diagnostics. +type PreparedConfig struct { + Envelope Envelope + Secrets map[string]string +} + +func cloneEnvelope(e Envelope) Envelope { e.Config = slices.Clone(e.Config); return e } +func validPointer(p string) bool { + if len(p) > 256 || !strings.HasPrefix(p, "/") { + return false + } + for i := 0; i < len(p); i++ { + if p[i] == '~' { + if i+1 == len(p) || p[i+1] != '0' && p[i+1] != '1' { + return false + } + i++ + } + } + return true +} + +var envName = regexp.MustCompile(`^env:[A-Za-z_][A-Za-z0-9_]*$`) + +// DecodeObject enforces the common size/depth boundary before module decoding. +func DecodeObject(raw json.RawMessage) (any, error) { + if len(raw) == 0 || len(raw) > 256*1024 { + return nil, failure("invalid_config", "", "config") + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var decode func(int) (any, error) + decode = func(depth int) (any, error) { + if depth > 32 { + return nil, failure("invalid_config", "", "config") + } + token, err := dec.Token() + if err != nil { + return nil, failure("invalid_config", "", "config") + } + switch token { + case json.Delim('{'): + object := map[string]any{} + for dec.More() { + keyToken, err := dec.Token() + if err != nil { + return nil, failure("invalid_config", "", "config") + } + key, ok := keyToken.(string) + if !ok { + return nil, failure("invalid_config", "", "config") + } + if _, exists := object[key]; exists { + return nil, failure("invalid_config", "", "config") + } + child, err := decode(depth + 1) + if err != nil { + return nil, err + } + object[key] = child + } + if end, err := dec.Token(); err != nil || end != json.Delim('}') { + return nil, failure("invalid_config", "", "config") + } + return object, nil + case json.Delim('['): + array := []any{} + for dec.More() { + child, err := decode(depth + 1) + if err != nil { + return nil, err + } + array = append(array, child) + } + if end, err := dec.Token(); err != nil || end != json.Delim(']') { + return nil, failure("invalid_config", "", "config") + } + return array, nil + default: + return token, nil + } + } + value, err := decode(0) + if err != nil { + return nil, err + } + if _, ok := value.(map[string]any); !ok { + return nil, failure("invalid_config", "", "config") + } + var trailing any + if dec.Decode(&trailing) != io.EOF { + return nil, failure("invalid_config", "", "config") + } + return value, nil +} +func escapePointer(s string) string { + return strings.ReplaceAll(strings.ReplaceAll(s, "~", "~0"), "/", "~1") +} +func secretRefs(raw json.RawMessage, fields []string) (map[string]string, error) { + value, err := DecodeObject(raw) + if err != nil { + return nil, err + } + refs := map[string]string{} + var walk func(any, string) error + walk = func(v any, path string) error { + if slices.Contains(fields, path) { + obj, ok := v.(map[string]any) + if !ok || len(obj) != 1 { + return failure("invalid_secret_ref", "", path) + } + token, ok := obj["$secret_ref"].(string) + if !ok || !envName.MatchString(token) { + return failure("invalid_secret_ref", "", path) + } + refs[path] = token + return nil + } + switch x := v.(type) { + case map[string]any: + if _, ok := x["$secret_ref"]; ok { + return failure("invalid_secret_ref", "", boundedPath(path)) + } + for k, c := range x { + if err := walk(c, path+"/"+escapePointer(k)); err != nil { + return err + } + } + case []any: + for i, c := range x { + if err := walk(c, path+"/"+jsonIndex(i)); err != nil { + return err + } + } + } + return nil + } + if err := walk(value, ""); err != nil { + return nil, err + } + return refs, nil +} +func boundedPath(s string) string { + if len(s) > 256 { + return "config" + } + return s +} +func jsonIndex(i int) string { b, _ := json.Marshal(i); return string(b) } + +// Validate validates locally without resolving environment references, applies +// defaults, and returns dependency order for configured envelopes. +func (r *Registry) Validate(ctx context.Context, input []Envelope) ([]Envelope, error) { + return r.validate(ctx, input, nil) +} +func (r *Registry) validate(ctx context.Context, input []Envelope, graph map[string][]string) ([]Envelope, error) { + if r == nil { + return nil, failure("unknown_module", "", "") + } + if len(input) > 64 { + return nil, failure("invalid_config", "", "extensions") + } + configs := map[string]Envelope{} + extra := map[string][]string{} + for _, e := range input { + if !ValidID(e.ModuleID) { + return nil, failure("invalid_config", "", "module_id") + } + reg, ok := r.entries[e.ModuleID] + if !ok || reg.Factory == nil { + return nil, failure("unknown_module", e.ModuleID, "") + } + if _, ok := configs[e.ModuleID]; ok { + return nil, failure("duplicate_module", e.ModuleID, "") + } + if e.SchemaVersion != reg.Descriptor.SchemaVersion { + return nil, failure("schema_mismatch", e.ModuleID, "") + } + refs, err := secretRefs(e.Config, reg.Factory.SecretFields) + if err != nil { + return nil, err + } + normalized, err := callback(func() (json.RawMessage, error) { return reg.Factory.Validate(ctx, slices.Clone(e.Config)) }) + if err != nil { + return nil, failure("invalid_config", e.ModuleID, "config") + } + after, err := secretRefs(normalized, reg.Factory.SecretFields) + if err != nil { + return nil, err + } + if len(refs) != len(after) { + return nil, failure("invalid_secret_ref", e.ModuleID, "config") + } + for p, ref := range refs { + if after[p] != ref { + return nil, failure("invalid_secret_ref", e.ModuleID, p) + } + } + e.Config = slices.Clone(normalized) + configs[e.ModuleID] = e + if reg.Factory.Dependencies != nil { + deps, err := callback(func() ([]string, error) { return reg.Factory.Dependencies(ctx, slices.Clone(e.Config)) }) + if err != nil || len(deps) > 64 { + return nil, failure("invalid_config", e.ModuleID, "dependencies") + } + for _, dep := range deps { + if !ValidID(dep) { + return nil, failure("invalid_config", e.ModuleID, "dependencies") + } + } + extra[e.ModuleID] = deps + } + } + order, err := r.topological(extra) + if err != nil { + return nil, err + } + for id, e := range configs { + deps := append(slices.Clone(r.entries[id].Descriptor.Dependencies), extra[id]...) + if graph != nil { + graph[id] = slices.Clone(deps) + } + for _, dep := range deps { + if r.entries[dep].Factory != nil { + if _, ok := configs[dep]; !ok { + return nil, failure("missing_dependency", e.ModuleID, "extensions") + } + } + } + } + out := make([]Envelope, 0, len(input)) + for _, id := range order { + if e, ok := configs[id]; ok { + out = append(out, e) + } + } + return out, nil +} +func resolve(e Envelope, f *Factory) (PreparedConfig, error) { + refs, err := secretRefs(e.Config, f.SecretFields) + if err != nil { + return PreparedConfig{}, err + } + cfg := PreparedConfig{Envelope: cloneEnvelope(e), Secrets: map[string]string{}} + for p, token := range refs { + value, ok := os.LookupEnv(strings.TrimPrefix(token, "env:")) + if !ok { + return PreparedConfig{}, failure("secret_unavailable", e.ModuleID, p) + } + cfg.Secrets[p] = value + } + return cfg, nil +} diff --git a/pkg/extension/contracttest/admin.go b/pkg/extension/contracttest/admin.go new file mode 100644 index 00000000..226f3d86 --- /dev/null +++ b/pkg/extension/contracttest/admin.go @@ -0,0 +1,92 @@ +package contracttest + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +// AdminBody checks the shared HTTP body/CORS contract through a real mount. +// Send adds deployment credentials and performs the request. URL must identify +// a write accepting Body with SuccessStatus. CheckUnchanged verifies the domain +// state after each rejected request; no mutation may occur on trailing oversize. +type AdminBodyFixture struct { + URL string + Method string + Body []byte + SuccessStatus int + Send func(*http.Request) (*http.Response, error) + CheckUnchanged func(*testing.T) +} + +func AdminBody(t *testing.T, f AdminBodyFixture) { + t.Helper() + const limit = 4194304 + if len(f.Body) > limit || f.Send == nil || f.CheckUnchanged == nil { + t.Fatal("invalid Admin body fixture") + } + for _, tc := range []struct { + name string + unknown bool + encoding string + oversize bool + status int + }{ + {name: "known_length_oversize", oversize: true, status: 413}, + {name: "chunked_oversize", unknown: true, oversize: true, status: 413}, + {name: "compressed", encoding: "gzip", status: 415}, + } { + t.Run(tc.name, func(t *testing.T) { + body := bytes.Clone(f.Body) + if tc.oversize { + body = append(body, bytes.Repeat([]byte(" "), limit+1-len(body))...) + } + req, err := http.NewRequest(f.Method, f.URL, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if tc.unknown { + req.ContentLength = -1 + } + if tc.encoding != "" { + req.Header.Set("Content-Encoding", tc.encoding) + } + req.Header.Set("Origin", "https://admin.example") + resp, err := f.Send(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1025)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != tc.status || len(raw) > 1024 || !json.Valid(raw) { + t.Fatalf("boundary: %d %s", resp.StatusCode, raw) + } + if tc.oversize && strings.TrimSpace(string(raw)) != `{"error":"request body exceeds 4194304 bytes"}` { + t.Fatalf("oversize diagnostic: %s", raw) + } + if resp.Header.Get("Access-Control-Allow-Origin") != "https://admin.example" { + t.Fatal("missing shared CORS") + } + f.CheckUnchanged(t) + }) + } + req, err := http.NewRequest(f.Method, f.URL, bytes.NewReader(f.Body)) + if err != nil { + t.Fatal(err) + } + resp, err := f.Send(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != f.SuccessStatus { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + t.Fatalf("supported write: %d %s", resp.StatusCode, raw) + } +} diff --git a/pkg/extension/contracttest/contracts.go b/pkg/extension/contracttest/contracts.go new file mode 100644 index 00000000..3ebdf8a8 --- /dev/null +++ b/pkg/extension/contracttest/contracts.go @@ -0,0 +1,163 @@ +// Package contracttest supplies reusable candidate extension contract suites. +package contracttest + +import ( + "context" + "encoding/json" + "errors" + "github.com/agent-guide/agent-gateway/pkg/extension" + "reflect" + "slices" + "sync" + "testing" +) + +// Registry checks deterministic, pointer-free projection and ownership isolation. +func Registry(t *testing.T, registrations []extension.Registration) { + t.Helper() + t.Run("registry", func(t *testing.T) { + r, err := extension.NewRegistry(registrations...) + if err != nil { + t.Fatal(err) + } + reversed := slices.Clone(registrations) + slices.Reverse(reversed) + other, err := extension.NewRegistry(reversed...) + if err != nil { + t.Fatal(err) + } + first, _ := json.Marshal(r.Manifest()) + second, _ := json.Marshal(other.Manifest()) + if string(first) != string(second) { + t.Fatal("nondeterministic manifest") + } + m := r.Manifest() + if len(m.Modules) > 0 { + m.Modules[0].ID = "modified" + again, _ := json.Marshal(r.Manifest()) + if string(first) != string(again) { + t.Fatal("manifest aliases registry") + } + } + if len(registrations) > 0 { + _, err = extension.NewRegistry(append(slices.Clone(registrations), registrations[0])...) + var e *extension.Error + if !errors.As(err, &e) || e.Code != "duplicate_module" { + t.Fatalf("duplicate: %v", err) + } + } + _, err = extension.NewRegistry(extension.Registration{}) + if err == nil { + t.Fatal("empty registration accepted") + } + }) +} + +type persistence struct { + items []extension.Envelope + fail bool +} + +func (p *persistence) List(context.Context) ([]extension.Envelope, error) { return p.items, nil } +func (p *persistence) Put(_ context.Context, e extension.Envelope, _ bool) error { + if p.fail { + return errors.New("private storage diagnostic") + } + for i, old := range p.items { + if old.ModuleID == e.ModuleID { + p.items[i] = e + return nil + } + } + p.items = append(p.items, e) + return nil +} +func (p *persistence) Delete(_ context.Context, id string) error { + if p.fail { + return errors.New("private storage diagnostic") + } + for i, e := range p.items { + if e.ModuleID == id { + p.items = append(p.items[:i], p.items[i+1:]...) + break + } + } + return nil +} + +// Configuration expects a complete valid runtime configuration and any required +// environment references supplied by the caller. It checks local validation, +// persistence failure isolation, concurrent reads and repeated lifecycle close. +func Configuration(t *testing.T, registrations []extension.Registration, input []extension.Envelope) { + t.Helper() + t.Run("configuration", func(t *testing.T) { + if len(input) == 0 { + t.Fatal("at least one configured module required") + } + r, err := extension.NewRegistry(registrations...) + if err != nil { + t.Fatal(err) + } + ordered, err := r.Validate(context.Background(), input) + if err != nil { + t.Fatal(err) + } + bad := ordered[0] + bad.SchemaVersion++ + if _, err = r.Validate(context.Background(), []extension.Envelope{bad}); err == nil { + t.Fatal("schema mismatch accepted") + } + store := &persistence{items: slices.Clone(ordered)} + m, err := extension.NewManager(context.Background(), r, store) + if err != nil { + t.Fatal(err) + } + defer m.Close(context.Background()) + before := m.List() + store.fail = true + if err = m.Put(context.Background(), ordered[0]); err == nil { + t.Fatal("failed persistence accepted") + } + store.fail = false + if !reflect.DeepEqual(before, m.List()) { + t.Fatal("failed persistence published") + } + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + for range 20 { + lease, err := m.Acquire() + if err != nil { + t.Error(err) + return + } + for _, e := range ordered { + if _, ok := lease.Snapshot.Resource(e.ModuleID); !ok { + t.Error("incomplete generation") + } + } + lease.Release() + lease.Release() + } + }() + } + if err = m.Put(context.Background(), ordered[0]); err != nil { + t.Fatal(err) + } + wg.Wait() + if err = m.Health(context.Background()); err != nil { + t.Fatal(err) + } + if err = m.Close(context.Background()); err != nil { + t.Fatal(err) + } + if err = m.Close(context.Background()); err != nil { + t.Fatal(err) + } + if _, err = m.Acquire(); err == nil { + t.Fatal("admission after close") + } + }) +} diff --git a/pkg/extension/contracttest/events.go b/pkg/extension/contracttest/events.go new file mode 100644 index 00000000..d621405b --- /dev/null +++ b/pkg/extension/contracttest/events.go @@ -0,0 +1,172 @@ +package contracttest + +import ( + "context" + "errors" + "fmt" + "reflect" + "sync" + "testing" + "time" + + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/event" +) + +// Attribution checks every field, explicit absence and defensive context copies. +// The caller supplies its assembly policy and one valid populated fixture. +func Attribution(t *testing.T, p attribution.Policy, v attribution.Value) { + t.Helper() + ctx, err := attribution.WithContext(context.Background(), v, p) + if err != nil { + t.Fatal(err) + } + got, ok := attribution.FromContext(ctx) + if !ok || !reflect.DeepEqual(v, got) { + t.Fatal("attribution propagation") + } + expected, _ := attribution.FromContext(ctx) + for key := range got.Attributes { + got.Attributes[key] = "changed" + } + again, _ := attribution.FromContext(ctx) + if !reflect.DeepEqual(expected, again) { + t.Fatal("retrieval ownership") + } + for key := range v.Attributes { + v.Attributes[key] = "changed" + } + again, _ = attribution.FromContext(ctx) + if !reflect.DeepEqual(expected, again) { + t.Fatal("insertion ownership") + } + if _, ok := attribution.FromContext(context.Background()); ok { + t.Fatal("absence") + } + empty, err := attribution.WithContext(context.Background(), attribution.Value{}, p) + if err != nil { + t.Fatal(err) + } + x, ok := attribution.FromContext(empty) + if !ok || !reflect.DeepEqual(x, attribution.Value{}) { + t.Fatal("empty fields synthesized") + } +} + +// CheckSinkCancellation invokes each lifecycle operation with an already +// canceled context. A stalled callback returns sink_nonconforming at the caller +// deadline. Test goroutines are probes, never the production delivery model. +func CheckSinkCancellation(ctx context.Context, s event.Sink) error { + canceled, cancel := context.WithCancel(context.Background()) + cancel() + e := eventFixture("probe") + for _, call := range []func() error{func() error { return s.Write(canceled, e) }, func() error { return s.Flush(canceled) }, func() error { return s.Close(canceled) }} { + result := make(chan error, 1) + go func() { + defer func() { + if recover() != nil { + result <- &event.Error{Code: "sink_nonconforming"} + } + }() + result <- call() + }() + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + return &event.Error{Code: "sink_nonconforming"} + } + case <-ctx.Done(): + return &event.Error{Code: "sink_nonconforming"} + } + } + return nil +} + +// EventSink proves cancellation plus FIFO/barrier and exactly-once concurrent +// close using independently owned sink implementations. Factories must return +// a fresh sink for each fixture. +func EventSink(t *testing.T, newSink func(context.Context) (event.Sink, error)) { + t.Helper() + t.Run("cancellation", func(t *testing.T) { + s, err := newSink(context.Background()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + defer cancel() + if err := CheckSinkCancellation(ctx, s); err != nil { + t.Fatal(err) + } + // A rejected canceled Close must not prevent later normal cleanup. + if err := s.Close(context.Background()); err != nil { + t.Fatal(err) + } + }) + t.Run("delivery", func(t *testing.T) { + s, err := newSink(context.Background()) + if err != nil { + t.Fatal(err) + } + probe := &sinkProbe{sink: s} + d, err := event.NewDelivery(probe, event.Options{}) + if err != nil { + t.Fatal(err) + } + defer d.Close(context.Background()) + for i := 0; i < 8; i++ { + if !d.Publish(context.Background(), eventFixture(fmt.Sprint(i))) { + t.Fatal("unexpected drop") + } + } + if err := d.Flush(context.Background()); err != nil { + t.Fatal(err) + } + probe.mu.Lock() + for i, id := range probe.ids { + if id != fmt.Sprint(i) { + t.Fatal("non-FIFO delivery") + } + } + n := len(probe.ids) + probe.mu.Unlock() + if n != 8 { + t.Fatal("flush passed accepted events") + } + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if err := d.Close(context.Background()); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + if probe.closes != 1 { + t.Fatal("close not exactly once") + } + }) +} +func eventFixture(id string) event.Envelope { + return event.Envelope{SchemaVersion: 1, ID: id, Timestamp: time.Now().UTC(), Family: "extension.mutation", Outcome: "success", Mutation: &event.ExtensionMutation{ModuleID: "example.fixture", Operation: "put"}} +} + +type sinkProbe struct { + mu sync.Mutex + sink event.Sink + ids []string + closes int +} + +func (s *sinkProbe) Write(ctx context.Context, e event.Envelope) error { + err := s.sink.Write(ctx, e) + if err == nil { + s.mu.Lock() + s.ids = append(s.ids, e.ID) + s.mu.Unlock() + } + return err +} +func (s *sinkProbe) Flush(ctx context.Context) error { return s.sink.Flush(ctx) } +func (s *sinkProbe) Close(ctx context.Context) error { s.closes++; return s.sink.Close(ctx) } diff --git a/pkg/extension/contracttest/secrets.go b/pkg/extension/contracttest/secrets.go new file mode 100644 index 00000000..6e45c4e5 --- /dev/null +++ b/pkg/extension/contracttest/secrets.go @@ -0,0 +1,70 @@ +package contracttest + +import ( + "bytes" + "context" + "encoding/json" + "os" + "reflect" + "testing" + + "github.com/agent-guide/agent-gateway/pkg/extension" +) + +// SecretReferences checks syntax-only validation with an absent/present env +// variable and reference-preserving manager/store round trips. Input must use a +// declared env reference to variable; factories own resolution assertions. +func SecretReferences(t *testing.T, registrations []extension.Registration, input []extension.Envelope, variable, secret string) { + t.Helper() + r, err := extension.NewRegistry(registrations...) + if err != nil { + t.Fatal(err) + } + // Setenv registers restoration and rejects parallel use before unsetting. + t.Setenv(variable, "") + if err := os.Unsetenv(variable); err != nil { + t.Fatal(err) + } + absent, err := r.Validate(t.Context(), input) + if err != nil { + t.Fatal(err) + } + t.Setenv(variable, secret) + present, err := r.Validate(t.Context(), input) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(absent, present) { + t.Fatal("environment changed syntax-only validation") + } + before, err := json.Marshal(present) + if err != nil { + t.Fatal(err) + } + if len(input) == 0 || secret == "" || !bytes.Contains(before, []byte("env:"+variable)) || bytes.Contains(before, []byte(secret)) { + t.Fatal("invalid secret-reference fixture") + } + store := &persistence{items: present} + m, err := extension.NewManager(t.Context(), r, store) + if err != nil { + t.Fatal(err) + } + defer m.Close(context.Background()) + for _, e := range present { + if err = m.Put(t.Context(), e); err != nil { + t.Fatal(err) + } + } + listed := m.List() + after, err := json.Marshal(listed) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("manager changed secret reference") + } + persisted, err := json.Marshal(store.items) + if err != nil || !bytes.Equal(before, persisted) { + t.Fatal("store changed secret reference") + } +} diff --git a/pkg/extension/contracttest/secrets_test.go b/pkg/extension/contracttest/secrets_test.go new file mode 100644 index 00000000..06ca3d73 --- /dev/null +++ b/pkg/extension/contracttest/secrets_test.go @@ -0,0 +1,43 @@ +package contracttest + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/agent-guide/agent-gateway/pkg/extension" +) + +func TestSecretReferencesExercisesAbsenceAndRestoresEnvironment(t *testing.T) { + const variable = "AGW_SECRET_REFERENCE_CONTRACT_REGRESSION" + const secret = "resolved-contract-value" + t.Setenv(variable, "original") + var present []bool + var values []string + f := &extension.Factory{SecretFields: []string{"/token"}, Validate: func(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + value, ok := os.LookupEnv(variable) + present = append(present, ok) + values = append(values, value) + return raw, ctx.Err() + }, Prepare: func(ctx context.Context, c extension.PreparedConfig, _ extension.Snapshot) (extension.Resource, error) { + if c.Secrets["/token"] != secret { + t.Error("secret resolution failed") + } + return secretTestResource{}, ctx.Err() + }} + regs := []extension.Registration{{Descriptor: extension.Descriptor{ID: "example.secret.contract", Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime", "validation"}}, Factory: f}} + input := []extension.Envelope{{ModuleID: "example.secret.contract", SchemaVersion: 1, Config: json.RawMessage(`{"token":{"$secret_ref":"env:AGW_SECRET_REFERENCE_CONTRACT_REGRESSION"}}`)}} + t.Run("contract", func(t *testing.T) { SecretReferences(t, regs, input, variable, secret) }) + if len(present) < 2 || present[0] || !present[1] || values[1] != secret { + t.Fatalf("absent/present phases: %v %v", present, values) + } + if value, ok := os.LookupEnv(variable); !ok || value != "original" { + t.Fatal("contract did not restore caller environment") + } +} + +type secretTestResource struct{} + +func (secretTestResource) Health(ctx context.Context) error { return ctx.Err() } +func (secretTestResource) Close(ctx context.Context) error { return ctx.Err() } diff --git a/pkg/extension/descriptor.go b/pkg/extension/descriptor.go new file mode 100644 index 00000000..eae304b3 --- /dev/null +++ b/pkg/extension/descriptor.go @@ -0,0 +1,12 @@ +package extension + +// Descriptor describes a module explicitly linked by a distribution's main. +// It contains no factory pointers, secrets, entitlement or customer state. +type Descriptor struct { + ID string `json:"id"` + Version string `json:"version"` + SchemaVersion int `json:"schema_version"` + Capabilities []string `json:"capabilities,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` + Roles []string `json:"roles,omitempty"` +} diff --git a/pkg/extension/doc.go b/pkg/extension/doc.go new file mode 100644 index 00000000..2d25e780 --- /dev/null +++ b/pkg/extension/doc.go @@ -0,0 +1,39 @@ +// Package extension implements an explicit, immutable linked registry and +// reference-preserving module configuration. It never loads runtime code. +// +// The candidate protected value contracts are Descriptor, Error (Code, ModuleID, +// Field and Error), Manifest, Registration, Factory, Resource, Envelope, +// PreparedConfig, Snapshot, Lease, Persistence, Registry and Manager. Their +// exported fields and callback signatures form part of their contracts. +// The designated functions/methods are NewRegistry, NewManager; +// Registry.Manifest, Registry.Validate, Registry.ExpandConfig; +// Snapshot.Config, Snapshot.Resource, Snapshot.Context; Lease.Release; +// Manager.Acquire, Manager.List, Manager.Manifest, Manager.Put, Manager.Delete, +// Manager.Health, Manager.Close and Manager.SetPublisher. Other exported helpers are not designated. +// F5 designates StatefulResource and Snapshot.Sequence. Reconciliation only +// changes unpublished candidate identity; reset metadata is emitted after commit. +// F4 also designates AdminRoute, OwnedAdminRoute, AdminDependencies, +// AdminContributor, WriteAdminError, Registry.AdminRoutes, Manager.AdminRoutes and Manager.AdminPublisher. +// Factory.AdminRoutes is an immutable declaration set; only prepared resources +// handle requests, with leases and the same generation lifecycle. +// Release protection requires a published tag with the consumer contract gates +// described in docs/design/enterprise-extension-contract.md. +// +// A metadata-only registration has no Factory and cannot accept configuration. +// Validation-only factories may omit Prepare; a server rejects their envelopes. +// Validate and Dependencies receive a context and must perform strict module +// schema checks without resolving references or exposing arbitrary diagnostics. +// Prepare receives reference-preserving JSON, a separate resolved-secret map, +// and only declared dependency resources prepared for the candidate generation. +// Preparation never mutates the live generation. Its context has a five-second +// maximum; Snapshot.Context is the separate resource lifetime. +// +// Successful persistence precedes infallible publication. Requests retain their +// generation with Acquire/Release. Retirement closes resources in reverse order +// after drain or deadline. Close stops admissions and cancels ongoing preparation +// before waiting for a mutation. Already committed writes finalize and retire +// their generation without reopening admissions. Close shares its bounded deadline +// with all generations. Lifecycle errors and panics report bounded diagnostics. +// Callbacks must honor cancellation; a deadline cannot make invalid module code +// terminate. Such code is nonconforming and Close reports failure. +package extension diff --git a/pkg/extension/environment.go b/pkg/extension/environment.go new file mode 100644 index 00000000..27791bba --- /dev/null +++ b/pkg/extension/environment.go @@ -0,0 +1,82 @@ +package extension + +import ( + "encoding/json" + "os" + "regexp" + "strings" +) + +var envExpansion = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) + +// ExpandConfig classifies every declared reference before expanding ordinary +// ${NAME} values. Reference objects survive unchanged, set or unset. This is +// for local declarative files, not server secret resolution. +func (r *Registry) ExpandConfig(input []Envelope) ([]Envelope, error) { + if r == nil { + return nil, failure("unknown_module", "", "") + } + if len(input) > 64 { + return nil, failure("invalid_config", "", "extensions") + } + out := make([]Envelope, 0, len(input)) + for _, e := range input { + reg, ok := r.entries[e.ModuleID] + if !ok || reg.Factory == nil { + return nil, failure("unknown_module", safeID(e.ModuleID), "") + } + if _, err := secretRefs(e.Config, reg.Factory.SecretFields); err != nil { + return nil, err + } + value, err := DecodeObject(e.Config) + if err != nil { + return nil, err + } + var walk func(any) (any, error) + walk = func(v any) (any, error) { + switch x := v.(type) { + case map[string]any: + if _, ok := x["$secret_ref"]; ok { + return x, nil + } + for k, c := range x { + next, err := walk(c) + if err != nil { + return nil, err + } + x[k] = next + } + case []any: + for i, c := range x { + next, err := walk(c) + if err != nil { + return nil, err + } + x[i] = next + } + case string: + for _, match := range envExpansion.FindAllStringSubmatch(x, -1) { + if _, ok := os.LookupEnv(match[1]); !ok { + return nil, failure("environment_unavailable", e.ModuleID, "config") + } + } + return envExpansion.ReplaceAllStringFunc(x, func(token string) string { + name := strings.TrimSuffix(strings.TrimPrefix(token, "${"), "}") + return os.Getenv(name) + }), nil + } + return v, nil + } + expanded, err := walk(value) + if err != nil { + return nil, err + } + raw, err := json.Marshal(expanded) + if err != nil { + return nil, failure("invalid_config", e.ModuleID, "config") + } + e.Config = raw + out = append(out, e) + } + return out, nil +} diff --git a/pkg/extension/manager.go b/pkg/extension/manager.go new file mode 100644 index 00000000..a45453b0 --- /dev/null +++ b/pkg/extension/manager.go @@ -0,0 +1,505 @@ +package extension + +import ( + "context" + "errors" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/google/uuid" + "sync" + "time" +) + +const LifecycleBudget = 5 * time.Second + +// Persistence is implemented by the existing schema-bound ConfigStore adapter. +// Operations must honor context; failed writes must leave persisted state intact. +type Persistence interface { + List(context.Context) ([]Envelope, error) + Put(context.Context, Envelope, bool) error + Delete(context.Context, string) error +} + +// Manager serializes mutations and publishes complete immutable generations. +// It never resolves registries or reads storage during snapshot acquisition. +type Manager struct { + sequence uint64 + publisher event.Publisher + registry *Registry + store Persistence + gate chan struct{} + mu sync.Mutex + current *generation + closed bool + unhealthy bool + retired []*generation + activeCancel context.CancelFunc + activeDone chan struct{} + shutdownContext context.Context +} +type generation struct { + stateReset bool + snapshot Snapshot + order []Envelope + cancel context.CancelFunc + refs int + retiring bool + drained chan struct{} + done chan struct{} + retireCancel context.CancelFunc +} + +// Lease retains an admitted generation until Release. Release is idempotent. +type Lease struct { + Snapshot Snapshot + m *Manager + g *generation + once sync.Once +} + +func (l *Lease) Release() { + if l == nil { + return + } + l.once.Do(func() { + l.m.mu.Lock() + defer l.m.mu.Unlock() + l.g.refs-- + if l.g.retiring && l.g.refs == 0 { + close(l.g.drained) + } + }) +} +func budget(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, LifecycleBudget) +} + +// NewManager loads persisted configuration and prepares a complete generation +// before returning. Startup failure closes all resources already prepared. +func NewManager(ctx context.Context, r *Registry, store Persistence) (*Manager, error) { + if r == nil { + return nil, failure("invalid_registration", "", "registry") + } + m := &Manager{registry: r, store: store, gate: make(chan struct{}, 1)} + m.gate <- struct{}{} + var effective []Envelope + if store != nil { + dynamic, err := store.List(ctx) + if err != nil { + return nil, failure("persistence_failed", "", "") + } + for _, e := range dynamic { + e = cloneEnvelope(e) + effective = append(effective, e) + } + } + g, err := m.prepare(ctx, effective) + if err != nil { + return nil, err + } + m.current = g + return m, nil +} +func (m *Manager) lock(ctx context.Context) (context.Context, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-m.gate: + } + m.mu.Lock() + if m.closed || ctx.Err() != nil { + m.mu.Unlock() + m.gate <- struct{}{} + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, failure("closed", "", "") + } + active, cancel := context.WithCancel(ctx) + m.activeCancel = cancel + m.activeDone = make(chan struct{}) + m.mu.Unlock() + return active, nil +} +func (m *Manager) unlock() { + m.mu.Lock() + m.activeCancel() + m.activeCancel = nil + close(m.activeDone) + m.activeDone = nil + m.mu.Unlock() + m.gate <- struct{}{} +} +func (m *Manager) prepare(ctx context.Context, envelopes []Envelope) (*generation, error) { + ctx, stop := budget(ctx) + defer stop() + graph := map[string][]string{} + ordered, err := m.registry.validate(ctx, envelopes, graph) + if err != nil { + return nil, err + } + m.sequence++ + life, cancel := context.WithCancel(context.Background()) + g := &generation{snapshot: Snapshot{sequence: m.sequence, configs: map[string]Envelope{}, resources: map[string]Resource{}, ctx: life}, order: ordered, cancel: cancel, drained: make(chan struct{}), done: make(chan struct{})} + fail := func(err error) (*generation, error) { g.cancel(); m.dispose(g); return nil, err } + for _, e := range ordered { + if ctx.Err() != nil { + return fail(failure("prepare_failed", e.ModuleID, "deadline")) + } + f := m.registry.entries[e.ModuleID].Factory + if f.Prepare == nil { + return fail(failure("unknown_module", e.ModuleID, "runtime")) + } + cfg, err := resolve(e, f) + if err != nil { + return fail(err) + } + // Each callback receives owned metadata; Snapshot copies its maps so a + // preparation cannot observe later additions by the assembly loop. + deps := Snapshot{configs: map[string]Envelope{}, resources: map[string]Resource{}, ctx: life} + for _, id := range graph[e.ModuleID] { + if c, ok := g.snapshot.configs[id]; ok { + deps.configs[id] = cloneEnvelope(c) + } + if r, ok := g.snapshot.resources[id]; ok { + deps.resources[id] = r + } + } + resource, err := callback(func() (Resource, error) { return f.Prepare(ctx, cfg, deps) }) + if !nilResource(resource) { + g.snapshot.resources[e.ModuleID] = resource + } + if err != nil || nilResource(resource) || ctx.Err() != nil { + return fail(failure("prepare_failed", e.ModuleID, "")) + } + if len(f.AdminRoutes) > 0 { + if _, ok := resource.(AdminContributor); !ok { + return fail(failure("prepare_failed", e.ModuleID, "admin_routes")) + } + } + if err := healthResource(ctx, resource); err != nil || ctx.Err() != nil { + return fail(failure("unhealthy", e.ModuleID, "")) + } + g.snapshot.configs[e.ModuleID] = cloneEnvelope(e) + } + if ctx.Err() != nil { + return fail(failure("prepare_failed", "", "deadline")) + } + m.mu.Lock() + previous := m.current + m.mu.Unlock() + if previous != nil { + for id, r := range g.snapshot.resources { + if state, ok := r.(StatefulResource); ok { + reset, err := callback(func() (bool, error) { return state.StateResetComparedTo(ctx, previous.snapshot.resources[id]) }) + if err != nil { + return fail(failure("prepare_failed", id, "state")) + } + g.stateReset = g.stateReset || reset + } + } + for id, r := range previous.snapshot.resources { + if _, ok := r.(StatefulResource); ok && g.snapshot.resources[id] == nil { + g.stateReset = true + } + } + } + return g, nil +} +func (m *Manager) dispose(g *generation) { + parent := context.Background() + m.mu.Lock() + if m.shutdownContext != nil { + parent = m.shutdownContext + } + m.mu.Unlock() + ctx, stop := budget(parent) + defer stop() + g.cancel() + for i := len(g.order) - 1; i >= 0; i-- { + if r := g.snapshot.resources[g.order[i].ModuleID]; !nilResource(r) { + if err := closeResource(ctx, r); err != nil { + m.mu.Lock() + m.unhealthy = true + m.mu.Unlock() + } + } + } +} +func (m *Manager) publish(g *generation) { + m.mu.Lock() + old := m.current + m.current = g + m.retireLocked(old, context.Background()) + if m.closed { + m.retireLocked(g, m.shutdownContext) + } + m.mu.Unlock() +} +func (m *Manager) retireLocked(g *generation, parent context.Context) { + if g == nil || g.retiring { + return + } + g.retiring = true + if g.refs == 0 { + close(g.drained) + } + pending := m.retired[:0] + for _, retired := range m.retired { + select { + case <-retired.done: + default: + pending = append(pending, retired) + } + } + m.retired = append(pending, g) + ctx, stop := budget(parent) + g.retireCancel = stop + go func() { + defer stop() + select { + case <-g.drained: + case <-ctx.Done(): + } + g.cancel() + for i := len(g.order) - 1; i >= 0; i-- { + if r := g.snapshot.resources[g.order[i].ModuleID]; !nilResource(r) { + if err := closeResource(ctx, r); err != nil { + m.mu.Lock() + m.unhealthy = true + m.mu.Unlock() + } + } + } + close(g.done) + }() +} + +// Acquire performs only generation ownership bookkeeping. No registry/storage +// reads or callback invocations occur on this path. +func (m *Manager) Acquire() (*Lease, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.closed { + return nil, failure("closed", "", "") + } + g := m.current + g.refs++ + return &Lease{Snapshot: g.snapshot, m: m, g: g}, nil +} +func (m *Manager) List() []Envelope { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]Envelope, 0, len(m.current.order)) + for _, e := range m.current.order { + out = append(out, cloneEnvelope(e)) + } + return out +} +func (m *Manager) Manifest() Manifest { return m.registry.Manifest() } + +// Put prepares a complete dependency generation, persists only the changed +// envelope, then performs an infallible in-memory publication. +func (m *Manager) Put(ctx context.Context, e Envelope) (result error) { + active, err := m.lock(ctx) + if err != nil { + m.emitMutation(ctx, e.ModuleID, "put", err) + return err + } + ctx = active + defer m.unlock() + // Publish before releasing the mutation gate and notifying shutdown waiters. + // The publisher enqueues without sink I/O; no manager mutex is held. + stateReset := false + defer func() { m.emitMutation(ctx, e.ModuleID, "put", result, stateReset) }() + if !ValidID(e.ModuleID) { + return failure("invalid_config", "", "module_id") + } + input := m.List() + found := false + for i, old := range input { + if old.ModuleID == e.ModuleID { + input[i] = cloneEnvelope(e) + found = true + } + } + if !found { + input = append(input, cloneEnvelope(e)) + } + g, err := m.prepare(ctx, input) + if err != nil { + return err + } + desired := g.snapshot.configs[e.ModuleID] + if ctx.Err() != nil { + m.dispose(g) + return ctx.Err() + } + if m.store == nil || m.store.Put(ctx, desired, !found) != nil { + m.dispose(g) + return failure("persistence_failed", e.ModuleID, "") + } + m.publish(g) + stateReset = g.stateReset + return nil +} + +// Delete fails when any configured module still depends on the removed one. +func (m *Manager) Delete(ctx context.Context, id string) (result error) { + active, err := m.lock(ctx) + if err != nil { + m.emitMutation(ctx, id, "delete", err) + return err + } + ctx = active + defer m.unlock() + // Publish before releasing the mutation gate and notifying shutdown waiters. + // The publisher enqueues without sink I/O; no manager mutex is held. + stateReset := false + defer func() { m.emitMutation(ctx, id, "delete", result, stateReset) }() + if !ValidID(id) { + return failure("invalid_config", "", "module_id") + } + input := m.List() + found := false + candidate := []Envelope{} + for _, e := range input { + if e.ModuleID == id { + found = true + } else { + candidate = append(candidate, e) + } + } + if !found { + return failure("not_found", id, "") + } + g, err := m.prepare(ctx, candidate) + if err != nil { + return err + } + if ctx.Err() != nil { + m.dispose(g) + return ctx.Err() + } + if m.store == nil || m.store.Delete(ctx, id) != nil { + m.dispose(g) + return failure("persistence_failed", id, "") + } + m.publish(g) + stateReset = g.stateReset + return nil +} + +// Health checks the admitted generation with the common bounded budget. +func (m *Manager) Health(ctx context.Context) error { + ctx, stop := budget(ctx) + defer stop() + m.mu.Lock() + unhealthy := m.unhealthy + m.mu.Unlock() + if unhealthy { + return failure("unhealthy", "", "lifecycle") + } + lease, err := m.Acquire() + if err != nil { + return err + } + defer lease.Release() + for _, e := range lease.g.order { + if r, ok := lease.Snapshot.Resource(e.ModuleID); ok { + if err := healthResource(ctx, r); err != nil { + return failure("unhealthy", e.ModuleID, "") + } + } + } + return ctx.Err() +} + +// Close stops admissions, retires all generations, and waits within caller and +// five-second deadlines. Module cancellation violations return deadline errors. +func (m *Manager) Close(ctx context.Context) error { + ctx, stop := budget(ctx) + defer stop() + // Stop admissions and cancel preparation before waiting for a mutator. A + // successful write already in flight still finalizes its committed generation, + // which publish immediately retires instead of admitting new readers. + m.mu.Lock() + if !m.closed { + m.closed = true + m.shutdownContext = ctx + } + if m.activeCancel != nil { + m.activeCancel() + } + activeDone := m.activeDone + m.retireLocked(m.current, m.shutdownContext) + m.mu.Unlock() + cancelRetirement := func() { + m.mu.Lock() + defer m.mu.Unlock() + for _, g := range m.retired { + g.retireCancel() + } + } + stopCancellation := context.AfterFunc(ctx, cancelRetirement) + defer stopCancellation() + if activeDone != nil { + select { + case <-activeDone: + case <-ctx.Done(): + cancelRetirement() + return ctx.Err() + } + } + m.mu.Lock() + retired := append([]*generation(nil), m.retired...) + m.mu.Unlock() + for _, g := range retired { + select { + case <-g.done: + case <-ctx.Done(): + cancelRetirement() + return ctx.Err() + } + } + if ctx.Err() != nil { + return ctx.Err() + } + m.mu.Lock() + unhealthy := m.unhealthy + m.mu.Unlock() + if unhealthy { + return failure("unhealthy", "", "lifecycle") + } + return nil +} + +// SetPublisher binds the existing pipeline authority during bootstrap. +func (m *Manager) SetPublisher(p event.Publisher) { m.mu.Lock(); m.publisher = p; m.mu.Unlock() } +func (m *Manager) emitMutation(ctx context.Context, id, operation string, err error, reset ...bool) { + if !ValidID(id) { + return + } + m.mu.Lock() + p := m.publisher + m.mu.Unlock() + if p == nil { + return + } + outcome, reason := "success", "" + if err != nil { + outcome, reason = "failure", "mutation_failed" + var e *Error + if errors.As(err, &e) { + switch e.Code { + case "invalid_config", "unknown_module", "schema_mismatch", "missing_dependency", "dependency_cycle", "prepare_failed", "persistence_failed", "closed", "unhealthy": + reason = e.Code + } + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + reason = "canceled" + } + } + v, _ := attribution.FromContext(ctx) + p.Publish(ctx, event.Envelope{SchemaVersion: 1, ID: uuid.NewString(), Family: "extension.mutation", Timestamp: time.Now().UTC(), Attribution: v, Outcome: outcome, Reason: reason, Mutation: &event.ExtensionMutation{ModuleID: id, Operation: operation, StateReset: err == nil && len(reset) > 0 && reset[0]}}) +} diff --git a/pkg/extension/manager_test.go b/pkg/extension/manager_test.go new file mode 100644 index 00000000..205d9ffe --- /dev/null +++ b/pkg/extension/manager_test.go @@ -0,0 +1,373 @@ +package extension + +import ( + "context" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +type memoryPersistence struct { + items []Envelope + fail bool +} + +func (s *memoryPersistence) List(context.Context) ([]Envelope, error) { return s.items, nil } +func (s *memoryPersistence) Put(_ context.Context, e Envelope, create bool) error { + if s.fail { + return errors.New("storage secret") + } + for i, old := range s.items { + if old.ModuleID == e.ModuleID { + s.items[i] = e + return nil + } + } + s.items = append(s.items, e) + return nil +} +func (s *memoryPersistence) Delete(_ context.Context, id string) error { + if s.fail { + return errors.New("storage secret") + } + for i, e := range s.items { + if e.ModuleID == id { + s.items = append(s.items[:i], s.items[i+1:]...) + break + } + } + return nil +} + +type countedResource struct{ closed atomic.Int32 } + +func (r *countedResource) Health(context.Context) error { return nil } +func (r *countedResource) Close(context.Context) error { r.closed.Add(1); return nil } +func TestMutationPublicationAndRetirement(t *testing.T) { + d := descriptor("example.test.lifecycle") + f := factory() + resources := []*countedResource{} + f.Prepare = func(_ context.Context, c PreparedConfig, _ Snapshot) (Resource, error) { + if string(c.Envelope.Config) == `{"fail":true}` { + return nil, errors.New("private") + } + r := &countedResource{} + resources = append(resources, r) + return r, nil + } + registry, _ := NewRegistry(Registration{d, f}) + store := &memoryPersistence{} + m, err := NewManager(context.Background(), registry, store) + if err != nil { + t.Fatal(err) + } + defer m.Close(context.Background()) + e := Envelope{ModuleID: d.ID, SchemaVersion: 1, Config: json.RawMessage(`{"n":1}`)} + if err = m.Put(context.Background(), e); err != nil { + t.Fatal(err) + } + lease, _ := m.Acquire() + old, _ := lease.Snapshot.Resource(d.ID) + e.Config = json.RawMessage(`{"fail":true}`) + code(t, m.Put(context.Background(), e), "prepare_failed") + if string(m.List()[0].Config) != `{"n":1}` { + t.Fatal("failed preparation published") + } + e.Config = json.RawMessage(`{"n":2}`) + store.fail = true + code(t, m.Put(context.Background(), e), "persistence_failed") + store.fail = false + if string(m.List()[0].Config) != `{"n":1}` || resources[1].closed.Load() != 1 { + t.Fatal("persistence failure leaked or published") + } + if err = m.Put(context.Background(), e); err != nil { + t.Fatal(err) + } + if old.(*countedResource).closed.Load() != 0 { + t.Fatal("leased generation retired") + } + lease.Release() + lease.Release() + deadline := time.After(time.Second) + for old.(*countedResource).closed.Load() == 0 { + select { + case <-deadline: + t.Fatal("not retired") + default: + time.Sleep(time.Millisecond) + } + } + if err = m.Close(context.Background()); err != nil { + t.Fatal(err) + } + if err = m.Close(context.Background()); err != nil { + t.Fatal(err) + } + _, err = m.Acquire() + code(t, err, "closed") +} +func TestPersistedDependencyAndCancellation(t *testing.T) { + a, b := descriptor("example.test.a"), descriptor("example.test.b") + b.Dependencies = []string{a.ID} + r, _ := NewRegistry(Registration{a, factory()}, Registration{b, factory()}) + store := &memoryPersistence{items: []Envelope{{ModuleID: a.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)}, {ModuleID: b.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)}}} + m, err := NewManager(context.Background(), r, store) + if err != nil { + t.Fatal(err) + } + defer m.Close(context.Background()) + code(t, m.Delete(context.Background(), a.ID), "missing_dependency") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err = m.Put(ctx, store.items[0]); err == nil { + t.Fatal("cancellation ignored") + } +} + +func TestConcurrentLeasesAndMutations(t *testing.T) { + d := descriptor("example.test.concurrent") + r, _ := NewRegistry(Registration{d, factory()}) + store := &memoryPersistence{} + m, err := NewManager(context.Background(), r, store) + if err != nil { + t.Fatal(err) + } + defer m.Close(context.Background()) + e := Envelope{ModuleID: d.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)} + if err = m.Put(context.Background(), e); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + for range 50 { + lease, err := m.Acquire() + if err != nil { + t.Error(err) + return + } + cfg, ok := lease.Snapshot.Config(d.ID) + if !ok || string(cfg.Config) != "{}" { + t.Error("mixed generation") + } + lease.Release() + } + }() + } + for range 10 { + if err = m.Put(context.Background(), e); err != nil { + t.Fatal(err) + } + } + wg.Wait() +} +func TestCloseDeadlineCancelsLeasedGeneration(t *testing.T) { + d := descriptor("example.test.deadline") + r, _ := NewRegistry(Registration{d, factory()}) + store := &memoryPersistence{items: []Envelope{{ModuleID: d.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)}}} + m, err := NewManager(context.Background(), r, store) + if err != nil { + t.Fatal(err) + } + lease, _ := m.Acquire() + defer lease.Release() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if err = m.Close(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("got %v", err) + } + select { + case <-lease.Snapshot.Context().Done(): + case <-time.After(time.Second): + t.Fatal("generation lifetime was not canceled") + } +} +func TestValidationOnlyFactory(t *testing.T) { + d := descriptor("example.test.schema") + d.Roles = []string{"validation"} + f := factory() + f.Prepare = nil + r, err := NewRegistry(Registration{d, f}) + if err != nil { + t.Fatal(err) + } + e := Envelope{ModuleID: d.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)} + if _, err = r.Validate(context.Background(), []Envelope{e}); err != nil { + t.Fatal(err) + } + _, err = NewManager(context.Background(), r, &memoryPersistence{items: []Envelope{e}}) + code(t, err, "unknown_module") +} + +type panicResource struct{} + +func (panicResource) Health(context.Context) error { return nil } +func (panicResource) Close(context.Context) error { panic("private-secret-panic") } +func TestCallbackPanicIsBounded(t *testing.T) { + d := descriptor("example.test.panic") + f := factory() + f.Validate = func(context.Context, json.RawMessage) (json.RawMessage, error) { panic("private-secret-panic") } + r, _ := NewRegistry(Registration{d, f}) + e := Envelope{ModuleID: d.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)} + _, err := r.Validate(context.Background(), []Envelope{e}) + code(t, err, "invalid_config") + f = factory() + f.Prepare = func(context.Context, PreparedConfig, Snapshot) (Resource, error) { panic("private-secret-panic") } + r, _ = NewRegistry(Registration{d, f}) + _, err = NewManager(context.Background(), r, &memoryPersistence{items: []Envelope{e}}) + code(t, err, "prepare_failed") + f.Prepare = func(context.Context, PreparedConfig, Snapshot) (Resource, error) { return panicResource{}, nil } + r, _ = NewRegistry(Registration{d, f}) + m, err := NewManager(context.Background(), r, &memoryPersistence{items: []Envelope{e}}) + if err != nil { + t.Fatal(err) + } + code(t, m.Close(context.Background()), "unhealthy") +} +func TestCloseCapsPreviouslyRetiredGenerations(t *testing.T) { + d := descriptor("example.test.olddeadline") + r, _ := NewRegistry(Registration{d, factory()}) + e := Envelope{ModuleID: d.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)} + store := &memoryPersistence{items: []Envelope{e}} + m, err := NewManager(context.Background(), r, store) + if err != nil { + t.Fatal(err) + } + old, _ := m.Acquire() + defer old.Release() + if err = m.Put(context.Background(), e); err != nil { + t.Fatal(err) + } + current, _ := m.Acquire() + defer current.Release() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if err = m.Close(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("got %v", err) + } + for _, lease := range []*Lease{old, current} { + select { + case <-lease.Snapshot.Context().Done(): + case <-time.After(time.Second): + t.Fatal("retired generation ignored shutdown deadline") + } + } +} + +func TestPrepareOnlyReceivesDeclaredDependencies(t *testing.T) { + a, b, c := descriptor("example.test.a"), descriptor("example.test.b"), descriptor("example.test.c") + c.Dependencies = []string{a.ID} + f := factory() + f.Prepare = func(_ context.Context, _ PreparedConfig, dependencies Snapshot) (Resource, error) { + if _, ok := dependencies.Resource(a.ID); !ok { + t.Error("declared dependency missing") + } + if _, ok := dependencies.Resource(b.ID); ok { + t.Error("undeclared resource exposed") + } + return &testResource{}, nil + } + r, err := NewRegistry(Registration{a, factory()}, Registration{b, factory()}, Registration{c, f}) + if err != nil { + t.Fatal(err) + } + store := &memoryPersistence{} + for _, d := range []Descriptor{a, b, c} { + store.items = append(store.items, Envelope{ModuleID: d.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)}) + } + m, err := NewManager(context.Background(), r, store) + if err != nil { + t.Fatal(err) + } + defer m.Close(context.Background()) +} + +func TestCloseCancelsMutationBeforeWaitingForGate(t *testing.T) { + d := descriptor("example.test.closing-mutation") + entered := make(chan struct{}) + f := factory() + f.Prepare = func(ctx context.Context, _ PreparedConfig, _ Snapshot) (Resource, error) { + close(entered) + <-ctx.Done() + return nil, ctx.Err() + } + r, err := NewRegistry(Registration{d, f}) + if err != nil { + t.Fatal(err) + } + store := &memoryPersistence{} + m, err := NewManager(context.Background(), r, store) + if err != nil { + t.Fatal(err) + } + mutation := make(chan error, 1) + go func() { + mutation <- m.Put(context.Background(), Envelope{ModuleID: d.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)}) + }() + <-entered + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err = m.Close(ctx); err != nil { + t.Fatal(err) + } + if err = <-mutation; err == nil { + t.Fatal("preparation committed after close") + } + _, err = m.Acquire() + code(t, err, "closed") + if len(store.items) != 0 || len(m.List()) != 0 { + t.Fatal("canceled mutation persisted or published") + } + if err = m.Close(context.Background()); err != nil { + t.Fatal(err) + } +} + +type committedPersistence struct { + memoryPersistence + committed chan struct{} +} + +func (s *committedPersistence) Put(ctx context.Context, e Envelope, create bool) error { + if err := s.memoryPersistence.Put(ctx, e, create); err != nil { + return err + } + close(s.committed) + <-ctx.Done() + return nil // commit already succeeded before cancellation +} +func TestCloseRetiresAWriteCommittedBeforeCancellation(t *testing.T) { + d := descriptor("example.test.committed-close") + resource := &countedResource{} + f := factory() + f.Prepare = func(context.Context, PreparedConfig, Snapshot) (Resource, error) { return resource, nil } + r, _ := NewRegistry(Registration{d, f}) + store := &committedPersistence{committed: make(chan struct{})} + m, err := NewManager(context.Background(), r, store) + if err != nil { + t.Fatal(err) + } + mutation := make(chan error, 1) + go func() { + mutation <- m.Put(context.Background(), Envelope{ModuleID: d.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)}) + }() + <-store.committed + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err = m.Close(ctx); err != nil { + t.Fatal(err) + } + if err = <-mutation; err != nil { + t.Fatal("successful commit must finalize", err) + } + _, err = m.Acquire() + code(t, err, "closed") + if len(store.items) != 1 || len(m.List()) != 1 || resource.closed.Load() != 1 { + t.Fatal("committed generation lost or leaked at close") + } +} diff --git a/pkg/extension/mutation_event_test.go b/pkg/extension/mutation_event_test.go new file mode 100644 index 00000000..9d96b7f8 --- /dev/null +++ b/pkg/extension/mutation_event_test.go @@ -0,0 +1,147 @@ +package extension + +import ( + "context" + "encoding/json" + "github.com/agent-guide/agent-gateway/pkg/event" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type capturePublisher struct { + events []event.Envelope + m *Manager + t *testing.T +} + +func (p *capturePublisher) Publish(_ context.Context, e event.Envelope) bool { + if e.Outcome == "success" && e.Mutation.Operation == "put" && len(p.m.List()) != 1 { + p.t.Fatal("published before commit") + } + p.events = append(p.events, e) + return true +} +func TestMutationEventsAfterCommitAndSanitizedFailure(t *testing.T) { + d := descriptor("example.fixture") + f := factory() + f.Prepare = func(context.Context, PreparedConfig, Snapshot) (Resource, error) { return &countedResource{}, nil } + r, _ := NewRegistry(Registration{d, f}) + store := &memoryPersistence{} + m, err := NewManager(context.Background(), r, store) + if err != nil { + t.Fatal(err) + } + defer m.Close(context.Background()) + p := &capturePublisher{m: m, t: t} + m.SetPublisher(p) + e := Envelope{ModuleID: d.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)} + if err := m.Put(context.Background(), e); err != nil { + t.Fatal(err) + } + store.fail = true + _ = m.Put(context.Background(), e) + if len(p.events) != 2 || p.events[0].Outcome != "success" || p.events[1].Reason != "persistence_failed" { + t.Fatal(p.events) + } + raw, _ := json.Marshal(p.events) + if strings.Contains(string(raw), "storage secret") { + t.Fatal("diagnostic escaped") + } + store.fail = false + if err := m.Delete(context.Background(), d.ID); err != nil { + t.Fatal(err) + } + if p.events[2].Mutation.Operation != "delete" || p.events[2].Outcome != "success" { + t.Fatal(p.events) + } +} + +// Pause the first publication before accepting it, exposing whether another +// mutation or shutdown can overtake that committed event. +type pausedMutationPublisher struct { + entered chan struct{} + release chan struct{} + first atomic.Bool + mu sync.Mutex + events []event.Envelope +} + +func (p *pausedMutationPublisher) Publish(_ context.Context, e event.Envelope) bool { + if p.first.CompareAndSwap(false, true) { + close(p.entered) + <-p.release + } + p.mu.Lock() + p.events = append(p.events, e) + p.mu.Unlock() + return true +} +func TestMutationPublicationCompletesBeforeNextMutationOrShutdown(t *testing.T) { + for _, next := range []string{"delete", "close"} { + t.Run(next, func(t *testing.T) { + d := descriptor("example.fixture") + f := factory() + f.Prepare = func(context.Context, PreparedConfig, Snapshot) (Resource, error) { return &countedResource{}, nil } + r, err := NewRegistry(Registration{d, f}) + if err != nil { + t.Fatal(err) + } + m, err := NewManager(context.Background(), r, &memoryPersistence{}) + if err != nil { + t.Fatal(err) + } + p := &pausedMutationPublisher{entered: make(chan struct{}), release: make(chan struct{})} + var release sync.Once + unblock := func() { release.Do(func() { close(p.release) }) } + defer func() { unblock(); _ = m.Close(context.Background()) }() + m.SetPublisher(p) + putDone := make(chan error, 1) + go func() { + putDone <- m.Put(context.Background(), Envelope{ModuleID: d.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)}) + }() + select { + case <-p.entered: + case <-time.After(time.Second): + t.Fatal("put did not reach publication") + } + if len(m.List()) != 1 { + t.Fatal("publication precedes snapshot commit") + } + nextDone := make(chan error, 1) + go func() { + if next == "delete" { + nextDone <- m.Delete(context.Background(), d.ID) + } else { + nextDone <- m.Close(context.Background()) + } + }() + select { + case err := <-nextDone: + t.Fatalf("%s overtook pending committed publication: %v", next, err) + case <-time.After(50 * time.Millisecond): + } + unblock() + for _, done := range []chan error{putDone, nextDone} { + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("mutation/shutdown did not finish") + } + } + p.mu.Lock() + defer p.mu.Unlock() + if len(p.events) == 0 || p.events[0].Outcome != "success" || p.events[0].Mutation.Operation != "put" { + t.Fatalf("committed event missing or reordered: %+v", p.events) + } + if next == "delete" && (len(p.events) != 2 || p.events[1].Mutation.Operation != "delete" || p.events[1].Outcome != "success") { + t.Fatalf("mutation commit order lost: %+v", p.events) + } + }) + } +} diff --git a/pkg/extension/registry.go b/pkg/extension/registry.go new file mode 100644 index 00000000..12b3b90f --- /dev/null +++ b/pkg/extension/registry.go @@ -0,0 +1,248 @@ +package extension + +import ( + "context" + "encoding/json" + "reflect" + "regexp" + "slices" + "sort" + "strings" +) + +// Error carries bounded, secret-free diagnostics. Codes are stable; Error never +// incorporates errors returned by module callbacks. +type Error struct { + Code string `json:"code"` + ModuleID string `json:"module_id,omitempty"` + Field string `json:"field,omitempty"` +} + +func (e *Error) Error() string { return e.Code + ": " + e.ModuleID + " " + e.Field } +func failure(code, id, field string) error { return &Error{code, id, field} } + +// Manifest is a deterministic metadata projection, with no executable pointers. +type Manifest struct { + Modules []Descriptor `json:"modules"` +} + +// Registration binds a descriptor to optional configuration behavior. A nil +// Factory is valid for metadata-only modules; their envelopes are rejected. +type Registration struct { + Descriptor Descriptor + Factory *Factory +} + +// Factory validates reference-preserving JSON and prepares owned resources. +// Callbacks must honor cancellation and must never mutate a live snapshot. +// Validate may apply defaults, but must retain every declared secret reference. +type Factory struct { + AdminRoutes []AdminRoute + SecretFields []string + Validate func(context.Context, json.RawMessage) (json.RawMessage, error) + Dependencies func(context.Context, json.RawMessage) ([]string, error) + Prepare func(context.Context, PreparedConfig, Snapshot) (Resource, error) +} + +// Resource belongs to a generation. All reads must be concurrency-safe and +// Health/Close must honor their context. Close is called once per preparation. +type Resource interface { + Health(context.Context) error + Close(context.Context) error +} + +// StatefulResource reconciles candidate domain state before persistence. It may +// copy an identity from previous into the unpublished receiver, but must never +// change previous or any active state. true reports a reset on successful commit. +// Removal of such a resource also reports a reset. Panics fail preparation. +type StatefulResource interface { + StateResetComparedTo(context.Context, Resource) (bool, error) +} + +// Registry is immutable after construction and owns all descriptor slices. +type Registry struct { + entries map[string]Registration + order []string +} + +var idPattern = regexp.MustCompile(`^[a-z0-9-]+(\.[a-z0-9-]+)+$`) +var versionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) + +func ValidID(s string) bool { return len(s) <= 128 && idPattern.MatchString(s) } +func cloneDescriptor(d Descriptor) Descriptor { + d.Capabilities = slices.Clone(d.Capabilities) + d.Dependencies = slices.Clone(d.Dependencies) + d.Roles = slices.Clone(d.Roles) + return d +} +func sortedUnique(values []string, valid func(string) bool) ([]string, bool) { + out := slices.Clone(values) + sort.Strings(out) + for i, v := range out { + if !valid(v) || i > 0 && out[i-1] == v { + return nil, false + } + } + return out, true +} + +// NewRegistry validates all registrations and resolves dependencies before any +// callback executes. Empty registries are valid; nil registrations are not. +func NewRegistry(registrations ...Registration) (*Registry, error) { + if len(registrations) > 256 { + return nil, failure("invalid_registration", "", "modules") + } + r := &Registry{entries: map[string]Registration{}} + for _, reg := range registrations { + d := cloneDescriptor(reg.Descriptor) + bad := func(field string) (*Registry, error) { + return nil, failure("invalid_registration", safeID(d.ID), field) + } + if !ValidID(d.ID) { + return bad("id") + } + if !versionPattern.MatchString(d.Version) || len(d.Version) > 128 { + return bad("version") + } + // Semver numeric prerelease identifiers cannot contain leading zeros. + versionCore, _, _ := strings.Cut(d.Version, "+") + if _, pre, ok := strings.Cut(versionCore, "-"); ok { + for _, p := range strings.Split(pre, ".") { + if regexp.MustCompile(`^[0-9]+$`).MatchString(p) && len(p) > 1 && p[0] == '0' { + return bad("version") + } + } + } + + if d.SchemaVersion <= 0 { + return bad("schema_version") + } + if len(d.Capabilities) > 64 || len(d.Dependencies) > 64 { + return bad("metadata") + } + var ok bool + if d.Capabilities, ok = sortedUnique(d.Capabilities, ValidID); !ok { + return bad("capabilities") + } + if d.Dependencies, ok = sortedUnique(d.Dependencies, ValidID); !ok { + return bad("dependencies") + } + if d.Roles, ok = sortedUnique(d.Roles, func(s string) bool { return slices.Contains([]string{"runtime", "validation", "admin", "spi"}, s) }); !ok || len(d.Roles) == 0 { + return bad("roles") + } + encoded, _ := json.Marshal(d) + if len(encoded) > 64*1024 { + return bad("metadata") + } + if _, exists := r.entries[d.ID]; exists { + return nil, failure("duplicate_module", d.ID, "") + } + reg.Descriptor = d + if reg.Factory != nil { + f := *reg.Factory + f.AdminRoutes = slices.Clone(f.AdminRoutes) + if err := validateAdminRoutes(d.ID, f.AdminRoutes); err != nil { + return nil, err + } + if len(f.AdminRoutes) > 0 && (!slices.Contains(d.Roles, "admin") || f.Prepare == nil) { + return bad("admin_routes") + } + f.SecretFields = slices.Clone(f.SecretFields) + if f.Validate == nil || (f.Prepare == nil && !slices.Equal(d.Roles, []string{"validation"})) || len(f.SecretFields) > 64 { + return bad("factory") + } + if f.SecretFields, ok = sortedUnique(f.SecretFields, validPointer); !ok { + return bad("secret_fields") + } + reg.Factory = &f + } + r.entries[d.ID] = reg + } + order, err := r.topological(nil) + if err != nil { + return nil, err + } + r.order = order + return r, nil +} +func safeID(id string) string { + if ValidID(id) { + return id + } + return "" +} +func nilResource(r Resource) bool { + if r == nil { + return true + } + v := reflect.ValueOf(r) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + return v.IsNil() + } + return false +} + +// Manifest returns independent, sorted metadata, never invokes factories. +func (r *Registry) Manifest() Manifest { + m := Manifest{Modules: []Descriptor{}} + if r == nil { + return m + } + for _, reg := range r.entries { + m.Modules = append(m.Modules, cloneDescriptor(reg.Descriptor)) + } + sort.Slice(m.Modules, func(i, j int) bool { return m.Modules[i].ID < m.Modules[j].ID }) + return m +} +func (r *Registry) topological(extra map[string][]string) ([]string, error) { + ids := make([]string, 0, len(r.entries)) + for id := range r.entries { + ids = append(ids, id) + } + sort.Strings(ids) + incoming := map[string]int{} + dependents := map[string][]string{} + for _, id := range ids { + deps := slices.Clone(r.entries[id].Descriptor.Dependencies) + deps = append(deps, extra[id]...) + sort.Strings(deps) + deps = slices.Compact(deps) + incoming[id] = len(deps) + for _, dep := range deps { + if _, ok := r.entries[dep]; !ok { + return nil, failure("missing_dependency", id, "dependencies") + } + dependents[dep] = append(dependents[dep], id) + } + } + ready := []string{} + for _, id := range ids { + if incoming[id] == 0 { + ready = append(ready, id) + } + } + order := make([]string, 0, len(ids)) + for len(ready) > 0 { + // Re-sort after dependencies complete: newly ready modules participate in + // the same lexical tie rule as those already waiting. + sort.Strings(ready) + id := ready[0] + ready = ready[1:] + order = append(order, id) + for _, dependent := range dependents[id] { + incoming[dependent]-- + if incoming[dependent] == 0 { + ready = append(ready, dependent) + } + } + } + if len(order) != len(ids) { + for _, id := range ids { + if incoming[id] > 0 { + return nil, failure("dependency_cycle", id, "") + } + } + } + return order, nil +} diff --git a/pkg/extension/registry_test.go b/pkg/extension/registry_test.go new file mode 100644 index 00000000..b62759f2 --- /dev/null +++ b/pkg/extension/registry_test.go @@ -0,0 +1,246 @@ +package extension + +import ( + "context" + "encoding/json" + "errors" + "os" + "slices" + "strings" + "testing" +) + +func descriptor(id string) Descriptor { + return Descriptor{ID: id, Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime", "validation"}} +} +func factory() *Factory { + return &Factory{Validate: func(_ context.Context, b json.RawMessage) (json.RawMessage, error) { return b, nil }, Prepare: func(context.Context, PreparedConfig, Snapshot) (Resource, error) { return &testResource{}, nil }} +} + +type testResource struct{} + +func (*testResource) Health(context.Context) error { return nil } +func (*testResource) Close(context.Context) error { return nil } +func code(t *testing.T, err error, want string) { + t.Helper() + var e *Error + if !errors.As(err, &e) || e.Code != want { + t.Fatalf("got %v, want %s", err, want) + } +} +func TestRegistryContracts(t *testing.T) { + a, b := descriptor("example.test.a"), descriptor("example.test.b") + b.Dependencies = []string{a.ID} + r, err := NewRegistry(Registration{b, nil}, Registration{a, nil}) + if err != nil { + t.Fatal(err) + } + if r.Manifest().Modules[0].ID != a.ID { + t.Fatal("unordered") + } + b.Dependencies[0] = "changed" + m := r.Manifest() + m.Modules[0].Roles[0] = "changed" + if r.Manifest().Modules[0].Roles[0] == "changed" { + t.Fatal("aliased") + } + _, err = NewRegistry(Registration{a, nil}, Registration{a, nil}) + code(t, err, "duplicate_module") + _, err = NewRegistry(Registration{}) + code(t, err, "invalid_registration") + b = descriptor("example.test.b") + b.Dependencies = []string{a.ID} + _, err = NewRegistry(Registration{b, nil}) + code(t, err, "missing_dependency") + a.Dependencies = []string{b.ID} + _, err = NewRegistry(Registration{a, nil}, Registration{b, nil}) + code(t, err, "dependency_cycle") + empty, err := NewRegistry() + if err != nil || len(empty.Manifest().Modules) != 0 { + t.Fatal(err) + } +} +func TestConfigSecretsAndBounds(t *testing.T) { + d := descriptor("example.test.secret") + f := factory() + f.SecretFields = []string{"/token"} + r, err := NewRegistry(Registration{d, f}) + if err != nil { + t.Fatal(err) + } + e := Envelope{ModuleID: d.ID, SchemaVersion: 1, Config: json.RawMessage(`{"token":{"$secret_ref":"env:EXT_TEST_SECRET"}}`)} + t.Setenv("EXT_TEST_SECRET", "do-not-export") + out, err := r.Validate(context.Background(), []Envelope{e}) + if err != nil { + t.Fatal(err) + } + if string(out[0].Config) != string(e.Config) { + t.Fatal("token lost") + } + cfg, err := resolve(e, f) + if err != nil || cfg.Secrets["/token"] != "do-not-export" { + t.Fatal(err) + } + for _, raw := range []string{`{"token":"literal"}`, `{"other":{"$secret_ref":"env:EXT_TEST_SECRET"}}`, `{"token":{"$secret_ref":"${EXT_TEST_SECRET}"}}`} { + e.Config = json.RawMessage(raw) + _, err = r.Validate(context.Background(), []Envelope{e}) + code(t, err, "invalid_secret_ref") + } + e.Config = json.RawMessage(`{}`) + e.SchemaVersion = 2 + _, err = r.Validate(context.Background(), []Envelope{e}) + code(t, err, "schema_mismatch") + e.SchemaVersion = 1 + e.ModuleID = "example.test.unknown" + _, err = r.Validate(context.Background(), []Envelope{e}) + code(t, err, "unknown_module") + raw := "{}" + for range 34 { + raw = `{"x":` + raw + `}` + } + _, err = DecodeObject(json.RawMessage(raw)) + code(t, err, "invalid_config") + f.Validate = func(context.Context, json.RawMessage) (json.RawMessage, error) { + return nil, errors.New("secret-value") + } + r, _ = NewRegistry(Registration{d, f}) + e.ModuleID = d.ID + _, err = r.Validate(context.Background(), []Envelope{e}) + code(t, err, "invalid_config") + if err.Error() == "secret-value" { + t.Fatal("leak") + } +} + +func TestRegistrationAndConfigurationLimits(t *testing.T) { + d := descriptor("example.test.bounds") + for _, mutate := range []func(*Descriptor){func(d *Descriptor) { d.SchemaVersion = 0 }, func(d *Descriptor) { d.Version = "1.0.0-01" }, func(d *Descriptor) { d.Roles = []string{"unknown"} }, func(d *Descriptor) { d.Capabilities = []string{"example.test.same", "example.test.same"} }, func(d *Descriptor) { d.Dependencies = make([]string, 65) }} { + bad := d + mutate(&bad) + _, err := NewRegistry(Registration{bad, nil}) + code(t, err, "invalid_registration") + } + build := d + build.Version = "1.0.0+build-01" + if _, err := NewRegistry(Registration{build, nil}); err != nil { + t.Fatal(err) + } + _, err := NewRegistry(make([]Registration, 257)...) + code(t, err, "invalid_registration") + r, _ := NewRegistry(Registration{d, factory()}) + e := Envelope{ModuleID: d.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)} + _, err = r.Validate(context.Background(), make([]Envelope, 65)) + code(t, err, "invalid_config") + _, err = r.Validate(context.Background(), []Envelope{e, e}) + code(t, err, "duplicate_module") + large := `{"x":"` + strings.Repeat("x", 256*1024) + `"}` + e.Config = json.RawMessage(large) + _, err = r.Validate(context.Background(), []Envelope{e}) + code(t, err, "invalid_config") + e.Config = json.RawMessage(`{"token":{"$secret_ref":"env:EXT_F2_UNSET_REQUIRED"}}`) + f := factory() + f.SecretFields = []string{"/token"} + r, _ = NewRegistry(Registration{d, f}) + t.Setenv("EXT_F2_UNSET_REQUIRED", "temporary") + _ = os.Unsetenv("EXT_F2_UNSET_REQUIRED") + if _, err = r.Validate(context.Background(), []Envelope{e}); err != nil { + t.Fatal("local validation resolved reference:", err) + } + _, err = resolve(e, f) + code(t, err, "secret_unavailable") +} +func TestConfigSelectedDependencies(t *testing.T) { + a, b := descriptor("example.test.selected-a"), descriptor("example.test.selected-b") + f := factory() + f.Dependencies = func(ctx context.Context, _ json.RawMessage) ([]string, error) { + if _, ok := ctx.Deadline(); !ok { + t.Error("dependency validation budget missing") + } + return []string{b.ID}, nil + } + r, err := NewRegistry(Registration{a, f}, Registration{b, factory()}) + if err != nil { + t.Fatal(err) + } + e := Envelope{ModuleID: a.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)} + _, err = NewManager(context.Background(), r, &memoryPersistence{items: []Envelope{e}}) + code(t, err, "missing_dependency") + e2 := Envelope{ModuleID: b.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)} + m, err := NewManager(context.Background(), r, &memoryPersistence{items: []Envelope{e, e2}}) + if err != nil { + t.Fatal(err) + } + defer m.Close(context.Background()) + if m.List()[0].ModuleID != b.ID { + t.Fatal("selected dependency order ignored") + } +} + +func TestDuplicateJSONKeysCannotHideSecrets(t *testing.T) { + d := descriptor("example.test.duplicates") + f := factory() + f.SecretFields = []string{"/token"} + r, err := NewRegistry(Registration{d, f}) + if err != nil { + t.Fatal(err) + } + for _, raw := range []string{ + `{"token":"literal-secret","token":{"$secret_ref":"env:REVIEW_TOKEN"}}`, + `{"other":{"$secret_ref":"env:REVIEW_TOKEN"},"other":"plain"}`, + `{"token":{"$secret_ref":"env:FIRST","$secret_ref":"env:SECOND"}}`, + `{"nested":[{"x":1,"x":2}]}`, + } { + e := Envelope{ModuleID: d.ID, SchemaVersion: 1, Config: json.RawMessage(raw)} + _, err = r.Validate(context.Background(), []Envelope{e}) + code(t, err, "invalid_config") + _, err = r.ExpandConfig([]Envelope{e}) + code(t, err, "invalid_config") + if strings.Contains(err.Error(), "literal-secret") { + t.Fatal("literal echoed in diagnostic") + } + } +} + +func TestTopologicalTiesUseLexicalReadyOrder(t *testing.T) { + a, b, z := descriptor("example.test.a"), descriptor("example.test.b"), descriptor("example.test.z") + a.Dependencies = []string{z.ID} + registrations := []Registration{{a, factory()}, {b, factory()}, {z, factory()}} + r, err := NewRegistry(registrations...) + if err != nil { + t.Fatal(err) + } + input := []Envelope{{ModuleID: a.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)}, {ModuleID: b.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)}, {ModuleID: z.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)}} + want := []string{b.ID, z.ID, a.ID} + for range 2 { + out, err := r.Validate(context.Background(), input) + if err != nil { + t.Fatal(err) + } + ids := []string{} + for _, e := range out { + ids = append(ids, e.ModuleID) + } + if !slices.Equal(ids, want) { + t.Fatalf("order=%v want=%v", ids, want) + } + slices.Reverse(input) + } + // A newly unblocked module must also outrank an already-ready later ID. + b.Dependencies = nil + a.Dependencies = []string{b.ID} + r, err = NewRegistry(Registration{a, factory()}, Registration{b, factory()}, Registration{z, factory()}) + if err != nil { + t.Fatal(err) + } + out, err := r.Validate(context.Background(), input) + if err != nil { + t.Fatal(err) + } + ids := []string{} + for _, e := range out { + ids = append(ids, e.ModuleID) + } + if !slices.Equal(ids, []string{b.ID, a.ID, z.ID}) { + t.Fatalf("newly ready order=%v", ids) + } +} diff --git a/pkg/extension/snapshot.go b/pkg/extension/snapshot.go new file mode 100644 index 00000000..e6eada32 --- /dev/null +++ b/pkg/extension/snapshot.go @@ -0,0 +1,29 @@ +package extension + +import "context" + +// Snapshot gives module preparation immutable access to already prepared +// dependencies. Resource values are module-owned concurrency-safe handles. +type Snapshot struct { + sequence uint64 + ctx context.Context + configs map[string]Envelope + resources map[string]Resource +} + +func (s Snapshot) Config(id string) (Envelope, bool) { + e, ok := s.configs[id] + return cloneEnvelope(e), ok +} +func (s Snapshot) Resource(id string) (Resource, bool) { r, ok := s.resources[id]; return r, ok } + +// Context is the generation lifetime, canceled after retirement drains or times out. +func (s Snapshot) Context() context.Context { + if s.ctx == nil { + return context.Background() + } + return s.ctx +} + +// Sequence is a manager-local monotonic generation fence, never a storage ID. +func (s Snapshot) Sequence() uint64 { return s.sequence } diff --git a/pkg/gateway/AGENTS.md b/pkg/gateway/AGENTS.md index 90cd792a..cc5c7799 100644 --- a/pkg/gateway/AGENTS.md +++ b/pkg/gateway/AGENTS.md @@ -231,14 +231,32 @@ Current shape: - `VirtualKey.ID` is required and is the management/storage primary key - `VirtualKey.Key` is the bearer key value used at request time and is generated by the gateway - Caddyfile and standalone static bundle config do not support static virtual keys; create them through the Admin API -- optional `rate_limits` policies enforce in-memory request token buckets for +- optional `rate_limits` policies enforce request token buckets through the domain-owned LimiterBackend for LLM, MCP, and unified agent ingress. Admission runs centrally after VirtualKey validation and interaction-span setup, before protocol dispatch. Limiter - state is process-local, keyed by VirtualKey ID plus route kind, and is removed - on key update/delete/reset. Internal builtin LLM/MCP calls do not consume + defaults to process-local memory. Immutable per-key/dimension generations fence + update/delete/reset; unchanged dimensions retain tokens. Alternate backends are + selected through the schema-1 `online.agentguide.community.limiter` extension. + Failures default to HTTP 503; explicit fail-open is degraded, exhaustion is 429. + Context budgets default to 100 ms (1–1000 ms); cancellation never fails open, + and uncertain operations are never retried. Prepared backend handles must + preserve generation-scoped quota state across unrelated extension refreshes. Internal builtin LLM/MCP calls do not consume ingress buckets unless they re-enter the dispatcher. The gateway accepts a VirtualKey from either: - `Authorization: Bearer ` - `x-api-key: ` + +## Extension assembly + +Caddy and standalone supply an immutable linked registry to Bootstrap. The +`extensions` manager owns reference-preserving ConfigStore envelopes and +prepares full dependency generations before persistence/publication. Requests +using module resources retain a `Lease` until completion; never read ConfigStore +or resolve factories in a request path. Read `pkg/extension/AGENTS.md` before +changing this wiring. + +Run virtualkey/extension/dispatcher/Admin race tests and the independent consumer +limiter suite when changing admission. Counters label only dimension/outcome and +bounded backend type, never identities. Backend switching resets without transfer. diff --git a/pkg/gateway/agentgateway.go b/pkg/gateway/agentgateway.go index b6549996..a84d28ce 100644 --- a/pkg/gateway/agentgateway.go +++ b/pkg/gateway/agentgateway.go @@ -3,6 +3,11 @@ package gateway import ( "context" "fmt" + "github.com/agent-guide/agent-gateway/internal/extensionstore" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/agent-guide/agent-gateway/pkg/extension" "net/http" "sync" @@ -31,6 +36,9 @@ import ( ) type BootstrapOptions struct { + EventPublisher event.Publisher + ExtensionRegistry *extension.Registry + Distribution distribution.Assembly StaticLLMRoutes []routecore.AgentRouteConfig StaticMCPRoutes []routecore.AgentRouteConfig StaticAgentRoutes []routecore.AgentRouteConfig @@ -57,6 +65,8 @@ type BootstrapOptions struct { type AgentGateway struct { mu sync.RWMutex + extensions *extension.Manager + distribution distribution.Assembly configured bool configStoreBackend configstore.ConfigStoreBackend routeConfigManager *routecore.AgentRouteConfigManager @@ -77,6 +87,9 @@ type AgentGateway struct { runtimeRegistry *agentruntime.Registry runRegistry *agentruntime.RunRegistry permissionBroker *agentruntime.PermissionBroker + attributionProducer attribution.Producer + attributionPolicy attribution.Policy + eventPublisher event.Publisher usageObserver usage.InteractionObserver usageQuery usage.QueryService usageStats usage.RuntimeStats @@ -100,6 +113,28 @@ func (g *AgentGateway) Bootstrap(ctx context.Context, opts BootstrapOptions) err g.mu.Lock() defer g.mu.Unlock() + var extensionManager *extension.Manager + if opts.ExtensionRegistry != nil { + var store extension.Persistence + if opts.ConfigStoreBackend != nil { + adapter, err := extensionstore.New(opts.ConfigStoreBackend) + if err != nil { + return err + } + store = adapter + } + manager, err := extension.NewManager(ctx, opts.ExtensionRegistry, store) + if err != nil { + return err + } + manager.SetPublisher(opts.EventPublisher) + extensionManager = manager + defer func() { + if extensionManager != nil { + _ = extensionManager.Close(context.Background()) + } + }() + } g.configureConfigStoreBackend(opts.ConfigStoreBackend) staticRoutes := make([]routecore.AgentRouteConfig, 0, len(opts.StaticLLMRoutes)+len(opts.StaticMCPRoutes)+len(opts.StaticAgentRoutes)) staticRoutes = append(staticRoutes, opts.StaticLLMRoutes...) @@ -118,6 +153,7 @@ func (g *AgentGateway) Bootstrap(ctx context.Context, opts BootstrapOptions) err if err := g.configureVirtualKeyManager(ctx, opts.ConfigStoreBackend); err != nil { return err } + g.virtualKeyManager.SetPublisher(opts.EventPublisher) if err := g.configureProviderResolver(ctx, opts.ConfigStoreBackend, opts.StaticProviders); err != nil { return err } @@ -128,6 +164,9 @@ func (g *AgentGateway) Bootstrap(ctx context.Context, opts BootstrapOptions) err } else { g.usageObserver = usage.NoopObserver{} } + g.attributionPolicy, _ = opts.Distribution.AttributionPolicy() + g.attributionProducer = opts.Distribution.AttributionProducer + g.eventPublisher = opts.EventPublisher g.usageQuery = opts.UsageQuery g.usageStats = opts.UsageStats g.usagePrometheus = opts.UsagePrometheus @@ -190,11 +229,20 @@ func (g *AgentGateway) Bootstrap(ctx context.Context, opts BootstrapOptions) err g.credentialManager.AddListener(g.httpRuntimeManager) } } + if extensionManager != nil { + g.extensions = extensionManager + g.virtualKeyManager.SetExtensions(extensionManager) + + g.distribution = distribution.Assembly{Edition: opts.Distribution.Edition, Modules: opts.ExtensionRegistry.Manifest().Modules} + extensionManager = nil + } g.configured = true return nil } func (g *AgentGateway) Reset() { + shutdown, cancelShutdown := context.WithTimeout(context.Background(), extension.LifecycleBudget) + defer cancelShutdown() g.mu.Lock() defer g.mu.Unlock() @@ -212,6 +260,14 @@ func (g *AgentGateway) Reset() { } } + if g.virtualKeyManager != nil { + _ = g.virtualKeyManager.Close(shutdown) + } + if g.extensions != nil { + _ = g.extensions.Close(shutdown) + g.extensions = nil + } + g.distribution = distribution.Assembly{} g.configured = false g.configStoreBackend = nil g.routeConfigManager = nil @@ -696,3 +752,60 @@ func (g *AgentGateway) configureModelCatalog(ctx context.Context, configStoreBac g.modelCatalog = modelcatalog.NewService(modelStore, g.providerManager, logger) return nil } + +func (g *AgentGateway) Extensions() *extension.Manager { + g.mu.RLock() + defer g.mu.RUnlock() + return g.extensions +} +func (g *AgentGateway) DistributionManifest() (distribution.Assembly, error) { + g.mu.RLock() + defer g.mu.RUnlock() + return g.distribution.Clone(), nil +} + +// AttributionContext combines explicitly injected trusted identity with runtime +// correlation. Optional producer errors/panics never replace valid core fields. +func (g *AgentGateway) AttributionContext(ctx context.Context, d usage.InteractionDimensions) context.Context { + g.mu.RLock() + producer, policy := g.attributionProducer, g.attributionPolicy + g.mu.RUnlock() + v := usage.CorrelationFromDimensions(d) + if producer != nil { + trusted, err := produceAttribution(ctx, producer) + trusted.RouteID = "" + trusted.AgentID = "" + trusted.RunID = "" + trusted.VirtualKeyID = "" + trusted.TraceID = "" + trusted.SpanID = "" + trusted.ParentSpanID = "" + if err == nil && policy.Validate(trusted) == nil { + v.Subject = trusted.Subject + v.Tenant = trusted.Tenant + v.Attributes = trusted.Attributes + } + } + if v.Subject == "" && v.VirtualKeyID != "" && len("virtualkey:"+v.VirtualKeyID) <= 256 { + v.Subject = "virtualkey:" + v.VirtualKeyID + } + next, err := attribution.WithContext(ctx, v, policy) + if err != nil { + return ctx + } + return next +} +func produceAttribution(ctx context.Context, p attribution.Producer) (v attribution.Value, err error) { + defer func() { + if recover() != nil { + err = &attribution.Error{Field: "producer"} + } + }() + return p(ctx) +} +func (g *AgentGateway) PublishEvent(ctx context.Context, e event.Envelope) bool { + g.mu.RLock() + p := g.eventPublisher + g.mu.RUnlock() + return p != nil && p.Publish(ctx, e) +} diff --git a/pkg/gateway/attribution_test.go b/pkg/gateway/attribution_test.go new file mode 100644 index 00000000..d345093e --- /dev/null +++ b/pkg/gateway/attribution_test.go @@ -0,0 +1,47 @@ +package gateway + +import ( + "context" + "github.com/agent-guide/agent-gateway/internal/observability/usage" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "strings" + "testing" +) + +func TestTrustedAttributionCoreAndFallback(t *testing.T) { + g := NewAgentGateway() + g.attributionPolicy, _ = attribution.NewPolicy([]string{"example.fixture"}, []string{"example.fixture/region"}) + d := usage.InteractionDimensions{RouteID: "route", AgentID: "agent", RunID: "run", VirtualKeyID: "key", TraceID: strings.Repeat("1", 32), SpanID: strings.Repeat("2", 16), ParentSpanID: strings.Repeat("3", 16)} + v, _ := attribution.FromContext(g.AttributionContext(context.Background(), d)) + if v.Subject != "virtualkey:key" || v.Tenant != "" || v.AgentID != "agent" || v.RunID != "run" { + t.Fatal(v) + } + g.attributionProducer = func(context.Context) (attribution.Value, error) { + return attribution.Value{Subject: "trusted", Tenant: "tenant", RouteID: "forged", TraceID: "bad", Attributes: map[string]string{"example.fixture/region": "east"}}, nil + } + v, _ = attribution.FromContext(g.AttributionContext(context.Background(), d)) + if v.Subject != "trusted" || v.Tenant != "tenant" || v.RouteID != "route" || v.TraceID != d.TraceID { + t.Fatal(v) + } + g.attributionProducer = func(context.Context) (attribution.Value, error) { + return attribution.Value{Subject: strings.Repeat("x", 257), Tenant: "untrusted"}, nil + } + v, _ = attribution.FromContext(g.AttributionContext(context.Background(), d)) + if v.Subject != "virtualkey:key" || v.Tenant != "" || v.TraceID != d.TraceID { + t.Fatal(v) + } + g.attributionProducer = func(context.Context) (attribution.Value, error) { panic("private") } + v, _ = attribution.FromContext(g.AttributionContext(context.Background(), d)) + if v.Subject != "virtualkey:key" { + t.Fatal(v) + } +} + +func TestIngressPublicCorrelationBoundsApplyPerField(t *testing.T) { + g := NewAgentGateway() + d := usage.InteractionDimensions{RouteID: strings.Repeat("x", 257), AgentID: "agent", RunID: "run", VirtualKeyID: "key", TraceID: strings.Repeat("1", 32), SpanID: strings.Repeat("2", 16)} + v, ok := attribution.FromContext(g.AttributionContext(context.Background(), d)) + if !ok || v.RouteID != "" || v.AgentID != "agent" || v.RunID != "run" || v.Subject != "virtualkey:key" || v.TraceID != d.TraceID { + t.Fatal(v) + } +} diff --git a/pkg/gateway/extensions_test.go b/pkg/gateway/extensions_test.go new file mode 100644 index 00000000..cadaa6c8 --- /dev/null +++ b/pkg/gateway/extensions_test.go @@ -0,0 +1,52 @@ +package gateway + +import ( + "context" + "encoding/json" + "github.com/agent-guide/agent-gateway/pkg/configstore" + "github.com/agent-guide/agent-gateway/pkg/configstore/schema" + configsqlite "github.com/agent-guide/agent-gateway/pkg/configstore/sqlite" + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/extension" + "sync/atomic" + "testing" +) + +type bootstrapExtensionResource struct{ closes atomic.Int32 } + +func (*bootstrapExtensionResource) Health(context.Context) error { return nil } +func (r *bootstrapExtensionResource) Close(context.Context) error { r.closes.Add(1); return nil } +func TestFailedBootstrapClosesPreparedExtension(t *testing.T) { + resource := &bootstrapExtensionResource{} + d := extension.Descriptor{ID: "example.fixture.bootstrap", Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime"}} + registry, err := extension.NewRegistry(extension.Registration{Descriptor: d, Factory: &extension.Factory{Validate: func(_ context.Context, b json.RawMessage) (json.RawMessage, error) { return b, nil }, Prepare: func(context.Context, extension.PreparedConfig, extension.Snapshot) (extension.Resource, error) { + return resource, nil + }}}) + if err != nil { + t.Fatal(err) + } + g := NewAgentGateway() + backend, err := configstore.OpenBackend(t.Context(), "sqlite", configsqlite.Config{SQLitePath: t.TempDir() + "/config.db"}, nil) + if err != nil { + t.Fatal(err) + } + // Only extensions exist, so later route bootstrap must fail. + if err = backend.Register(schema.StoreExtensions, schema.ExtensionSchema); err != nil { + t.Fatal(err) + } + store, err := backend.Get(schema.StoreExtensions) + if err != nil { + t.Fatal(err) + } + if err = store.Create(t.Context(), &extension.Envelope{ModuleID: d.ID, SchemaVersion: 1, Config: json.RawMessage(`{}`)}); err != nil { + t.Fatal(err) + } + err = g.Bootstrap(context.Background(), BootstrapOptions{ExtensionRegistry: registry, Distribution: distribution.Assembly{Modules: []extension.Descriptor{d}}, ConfigStoreBackend: backend}) + if err == nil { + t.Fatal("bootstrap should fail after preparation") + } + if resource.closes.Load() != 1 || g.Extensions() != nil { + t.Fatal("failed bootstrap leaked extension state") + } + g.Close() +} diff --git a/pkg/gateway/m7_removal_test.go b/pkg/gateway/m7_removal_test.go index 56dff1a0..0e069193 100644 --- a/pkg/gateway/m7_removal_test.go +++ b/pkg/gateway/m7_removal_test.go @@ -71,7 +71,8 @@ func TestM7RemovedRuntimeSpecificSource(t *testing.T) { } rel, _ := filepath.Rel(root, path) if entry.IsDir() { - if rel == ".git" || rel == "vendor" || strings.HasPrefix(rel, "vendor"+string(filepath.Separator)) { + // Hidden tool/worktree directories are not active Go source. + if path != root && (strings.HasPrefix(entry.Name(), ".") || entry.Name() == "vendor") { return filepath.SkipDir } return nil diff --git a/pkg/gateway/virtualkey/admission.go b/pkg/gateway/virtualkey/admission.go new file mode 100644 index 00000000..77d0b606 --- /dev/null +++ b/pkg/gateway/virtualkey/admission.go @@ -0,0 +1,319 @@ +package virtualkey + +import ( + "context" + "errors" + "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey/limiter" + "math" + "sync" + "time" + + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/agent-guide/agent-gateway/pkg/extension" + "github.com/google/uuid" +) + +type policyState struct { + request limiter.LimiterRequest + backend limiter.LimiterBackend +} +type limiterController struct { + mu sync.Mutex + memory *limiter.MemoryBackend + extensions *extension.Manager + states map[limiterKey]policyState + policies map[string]*VirtualKeyRateLimits + deleted map[string]bool + identity string + sequence uint64 + closed bool + life context.Context + cancel context.CancelFunc + active sync.WaitGroup + closeOnce sync.Once + closeDone chan struct{} + closeErr error + publisher event.Publisher + types map[string]bool + counts map[LimiterLabels]uint64 +} + +// LimiterLabels contain bounded dimension, outcome and backend type only. +type LimiterLabels struct{ Dimension, Outcome, BackendType string } + +func newLimiterController(clock Clock) *limiterController { + ctx, cancel := context.WithCancel(context.Background()) + return &limiterController{memory: limiter.NewMemoryBackend(clock), states: map[limiterKey]policyState{}, policies: map[string]*VirtualKeyRateLimits{}, deleted: map[string]bool{}, life: ctx, cancel: cancel, types: map[string]bool{}, closeDone: make(chan struct{}), counts: map[LimiterLabels]uint64{}} +} +func (c *limiterController) selection() (*limiter.Selection, *extension.Lease, error) { + if c.extensions == nil { + return limiter.DefaultSelection(), nil, nil + } + lease, err := c.extensions.Acquire() + if err != nil { + return nil, nil, limiter.ErrLimiterUnavailable + } + r, ok := lease.Snapshot.Resource(limiter.ModuleID) + if !ok { + return limiter.DefaultSelection(), lease, nil + } + s, ok := r.(*limiter.Selection) + if !ok { + lease.Release() + return nil, nil, limiter.ErrLimiterUnavailable + } + return s, lease, nil +} +func backendCall[T any](fn func() (T, error)) (v T, err error) { + defer func() { + if recover() != nil { + err = limiter.ErrLimiterUnavailable + } + }() + return fn() +} +func retireStates(ctx context.Context, states []policyState) { + for _, s := range states { + _, _ = backendCall(func() (bool, error) { return false, s.backend.Retire(ctx, s.request.Generation) }) + } +} +func (c *limiterController) remove(ctx context.Context, id string, policy *VirtualKeyRateLimits, deleted bool) { + selection, lease, _ := c.selection() + if lease != nil { + defer lease.Release() + } + c.mu.Lock() + c.policies[id] = cloneRateLimits(policy) + c.deleted[id] = deleted + retired := []policyState{} + for k, s := range c.states { + next, _ := rateLimitForDimension(policy, k.dimension) + if k.virtualKeyID == id && (deleted || next == nil || s.request.Limit != (limiter.Policy{RequestsPerMinute: next.RequestsPerMinute, Burst: next.Burst})) { + if selection != nil && selection.Identity() == c.identity { + b := selection.Backend() + if b == nil { + b = c.memory + } + s.backend = b + } + retired = append(retired, s) + delete(c.states, k) + } + } + c.mu.Unlock() + bounded, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + retireStates(bounded, retired) + if len(retired) > 0 { + c.emitReset(ctx) + } +} +func (c *limiterController) remember(key VirtualKey) { + c.mu.Lock() + defer c.mu.Unlock() + if _, exists := c.policies[key.ID]; !exists && !c.deleted[key.ID] { + c.policies[key.ID] = cloneRateLimits(key.RateLimits) + } +} +func (c *limiterController) admit(ctx context.Context, key VirtualKey, dim RateLimitDimension) (result Admission, err error) { + if ctx.Err() != nil { + return result, ctx.Err() + } + s, lease, err := c.selection() + if err != nil { + return result, err + } + if lease != nil { + defer lease.Release() + } + c.mu.Lock() + if c.closed || c.deleted[key.ID] { + c.mu.Unlock() + return result, limiter.ErrLimiterUnavailable + } + limits := key.RateLimits + if p, ok := c.policies[key.ID]; ok { + limits = p + } + limit, err := rateLimitForDimension(limits, dim) + if err != nil || key.ID == "" { + c.mu.Unlock() + return result, limiter.ErrLimiterUnavailable + } + result.BackendID = s.Configuration().BackendModuleID + result.BackendType = s.Configuration().BackendType + if limit == nil { + c.mu.Unlock() + result.Allowed = true + return result, nil + } + result.RequestsPerMinute = limit.RequestsPerMinute + result.Burst = limit.Burst + sequence := uint64(0) + if lease != nil { + sequence = lease.Snapshot.Sequence() + } + if c.identity != s.Identity() && sequence < c.sequence { + c.mu.Unlock() + return result, limiter.ErrLimiterUnavailable + } + retired := []policyState{} + if c.identity != s.Identity() { + for k, state := range c.states { + retired = append(retired, state) + delete(c.states, k) + } + c.identity = s.Identity() + } + c.sequence = max(c.sequence, sequence) + k := limiterKey{virtualKeyID: key.ID, dimension: dim} + state, ok := c.states[k] + backend := s.Backend() + if backend == nil { + backend = c.memory + } + if ok { + state.backend = backend + c.states[k] = state + } + if !ok || state.request.Limit != (limiter.Policy{RequestsPerMinute: limit.RequestsPerMinute, Burst: limit.Burst}) { + if ok { + retired = append(retired, state) + } + state = policyState{backend: backend, request: limiter.LimiterRequest{Generation: uuid.NewString(), VirtualKeyID: key.ID, Dimension: limiter.Dimension(dim), Limit: limiter.Policy{RequestsPerMinute: limit.RequestsPerMinute, Burst: limit.Burst}}} + c.states[k] = state + } + c.active.Add(1) + c.mu.Unlock() + defer c.active.Done() + operation, cancel := context.WithCancel(ctx) + defer cancel() + stopLife := context.AfterFunc(c.life, cancel) + defer stopLife() + if lease != nil { + stop := context.AfterFunc(lease.Snapshot.Context(), cancel) + defer stop() + } + // Retirement has its own cleanup budget; it must not consume the fresh + // generation's admission budget. Both phases still obey caller and lifecycle + // cancellation, and execute under the same retained extension lease. + retirement, stopRetirement := context.WithTimeout(operation, 100*time.Millisecond) + retireStates(retirement, retired) + stopRetirement() + bounded, stopAdmission := context.WithTimeout(operation, s.Budget()) + defer stopAdmission() + decision, callErr := backendCall(func() (limiter.LimiterDecision, error) { return state.backend.Admit(bounded, state.request) }) + if ctx.Err() != nil { + err = ctx.Err() + } else if c.life.Err() != nil { + err = limiter.ErrLimiterUnavailable + } else if lease != nil && lease.Snapshot.Context().Err() != nil { + err = context.Canceled + } else if errors.Is(callErr, limiter.ErrLimiterRetired) { + err = limiter.ErrLimiterRetired + } else if bounded.Err() != nil || callErr != nil || decision.RetryAfter < 0 { + err = limiter.ErrLimiterUnavailable + } else { + result.Allowed = decision.Allowed + if !result.Allowed { + result.RetryAfterSeconds = max(1, int(math.Ceil(decision.RetryAfter.Seconds()))) + } + } + outcome := "allow" + if err != nil { + outcome = "unavailable" + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, limiter.ErrLimiterRetired) && s.Configuration().FailureMode == "open" && c.life.Err() == nil { + result.Allowed = true + result.Degraded = true + err = nil + outcome = "degraded" + } + } else if !result.Allowed { + outcome = "deny" + } + c.mu.Lock() + labelType := result.BackendType + if !c.types[labelType] && len(c.types) >= 32 { + labelType = "other" + } else { + c.types[labelType] = true + } + c.counts[LimiterLabels{Dimension: string(dim), Outcome: outcome, BackendType: labelType}]++ + c.mu.Unlock() + return result, err +} +func (c *limiterController) reset() { + c.mu.Lock() + retired := []policyState{} + for _, s := range c.states { + retired = append(retired, s) + } + c.states = map[limiterKey]policyState{} + c.policies = map[string]*VirtualKeyRateLimits{} + c.deleted = map[string]bool{} + c.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + retireStates(ctx, retired) + if len(retired) > 0 { + c.emitReset(ctx) + } +} +func (c *limiterController) close(ctx context.Context) error { + c.closeOnce.Do(func() { + c.mu.Lock() + c.closed = true + c.cancel() + c.mu.Unlock() + budget, cancel := context.WithTimeout(ctx, extension.LifecycleBudget) + go func() { + defer close(c.closeDone) + defer cancel() + done := make(chan struct{}) + go func() { c.active.Wait(); close(done) }() + select { + case <-done: + case <-budget.Done(): + c.closeErr = budget.Err() + } + if err := c.memory.Close(budget); c.closeErr == nil { + c.closeErr = err + } + }() + }) + select { + case <-c.closeDone: + return c.closeErr + case <-ctx.Done(): + return ctx.Err() + } +} + +// SetExtensions binds the already assembled extension manager during bootstrap. +func (m *VirtualKeyManager) SetExtensions(e *extension.Manager) { m.limiters.extensions = e } + +// LimiterCounters returns an owned low-cardinality snapshot. +func (m *VirtualKeyManager) LimiterCounters() map[LimiterLabels]uint64 { + m.limiters.mu.Lock() + defer m.limiters.mu.Unlock() + out := map[LimiterLabels]uint64{} + for k, v := range m.limiters.counts { + out[k] = v + } + return out +} + +// Close stops new admission and shares the caller's deadline with active calls. +func (m *VirtualKeyManager) Close(ctx context.Context) error { return m.limiters.close(ctx) } + +func (c *limiterController) emitReset(ctx context.Context) { + if c.publisher == nil { + return + } + v, _ := attribution.FromContext(ctx) + c.publisher.Publish(ctx, event.Envelope{SchemaVersion: 1, ID: uuid.NewString(), Family: "extension.mutation", Timestamp: time.Now().UTC(), Attribution: v, Outcome: "success", Mutation: &event.ExtensionMutation{ModuleID: limiter.ModuleID, Operation: "reset", StateReset: true}}) +} + +// SetPublisher injects the existing event authority at bootstrap. +func (m *VirtualKeyManager) SetPublisher(p event.Publisher) { m.limiters.publisher = p } diff --git a/pkg/gateway/virtualkey/admission_test.go b/pkg/gateway/virtualkey/admission_test.go new file mode 100644 index 00000000..5df277c6 --- /dev/null +++ b/pkg/gateway/virtualkey/admission_test.go @@ -0,0 +1,417 @@ +package virtualkey + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey/limiter" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/agent-guide/agent-gateway/pkg/extension" +) + +type testBackend struct { + limiter.LimiterBackend + calls atomic.Int64 + retireWait atomic.Bool + retireEntered chan struct{} + retireRelease chan struct{} + mode string + entered chan struct{} + release chan struct{} +} + +func (b *testBackend) Retire(ctx context.Context, generation string) error { + if b.retireEntered != nil { + close(b.retireEntered) + select { + case <-b.retireRelease: + case <-ctx.Done(): + return ctx.Err() + } + } + if err := b.LimiterBackend.Retire(ctx, generation); err != nil { + return err + } + if b.retireWait.Load() { + <-ctx.Done() + return ctx.Err() + } + return nil +} + +func TestPolicyCleanupDoesNotBlockUnrelatedAuthentication(t *testing.T) { + for _, operation := range []string{"delete", "reset"} { + t.Run(operation, func(t *testing.T) { + _, ext, b := admissionManager(t, "", "closed") + m := NewVirtualKeyManager(&testManagedVirtualKeyStore{items: map[string]*VirtualKey{}}) + m.SetExtensions(ext) + t.Cleanup(func() { _ = m.Close(context.Background()) }) + key := hardKey() + key.Key = "limited-secret" + if err := m.Create(t.Context(), key); err != nil { + t.Fatal(err) + } + other := VirtualKey{ID: "other", Key: "other-secret"} + if err := m.Create(t.Context(), other); err != nil { + t.Fatal(err) + } + other, err := m.GetByID(t.Context(), other.ID) + if err != nil { + t.Fatal(err) + } + if _, err := m.Admit(t.Context(), key, RateLimitDimensionLLM); err != nil { + t.Fatal(err) + } + b.retireEntered = make(chan struct{}) + b.retireRelease = make(chan struct{}) + release := sync.OnceFunc(func() { close(b.retireRelease) }) + t.Cleanup(release) + done := make(chan error, 1) + go func() { + if operation == "delete" { + done <- m.Delete(t.Context(), key.ID) + } else { + m.Reset() + done <- nil + } + }() + <-b.retireEntered + lookup := make(chan error, 1) + go func() { _, err := m.GetByKey(t.Context(), other.Key); lookup <- err }() + select { + case err := <-lookup: + if err != nil { + t.Fatal(err) + } + case <-time.After(50 * time.Millisecond): + release() + <-done + <-lookup + t.Fatal("unrelated authentication blocked by retirement callback") + } + release() + if err := <-done; err != nil { + t.Fatal(err) + } + }) + } +} + +func (b *testBackend) Admit(ctx context.Context, r limiter.LimiterRequest) (limiter.LimiterDecision, error) { + b.calls.Add(1) + switch b.mode { + case "retired": + return limiter.LimiterDecision{}, fmt.Errorf("private wrapped diagnostic: %w", limiter.ErrLimiterRetired) + case "retirement_race": + close(b.entered) + select { + case <-b.release: + case <-ctx.Done(): + return limiter.LimiterDecision{}, ctx.Err() + } + return b.LimiterBackend.Admit(ctx, r) + case "error": + return limiter.LimiterDecision{}, errors.New("private diagnostic secret") + case "panic": + panic("secret") + case "wait": + <-ctx.Done() + return limiter.LimiterDecision{}, ctx.Err() + case "ambiguous": + _, _ = b.LimiterBackend.Admit(ctx, r) + return limiter.LimiterDecision{}, errors.New("consumed but response lost") + } + return b.LimiterBackend.Admit(ctx, r) +} + +type testProvider struct{ *testBackend } + +func (p *testProvider) LimiterBackend(name string) (limiter.LimiterBackend, error) { + if name != "fixture" { + return nil, limiter.ErrLimiterUnavailable + } + return p.testBackend, nil +} +func (p *testProvider) Close(context.Context) error { return nil } // Test owns shared durable generation storage. +func admissionManager(t *testing.T, mode, failure string, timeout ...int) (*VirtualKeyManager, *extension.Manager, *testBackend) { + t.Helper() + b := &testBackend{LimiterBackend: limiter.NewMemoryBackend(nil), mode: mode} + r, err := extension.NewRegistry(extension.Registration{Descriptor: extension.Descriptor{ID: limiter.ModuleID, Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime", "validation", "spi"}}, Factory: limiter.Factory(true)}, extension.Registration{Descriptor: extension.Descriptor{ID: "example.fixture.limiter", Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime", "validation", "spi"}}, Factory: &extension.Factory{Validate: func(_ context.Context, r json.RawMessage) (json.RawMessage, error) { return r, nil }, Prepare: func(context.Context, extension.PreparedConfig, extension.Snapshot) (extension.Resource, error) { + return &testProvider{b}, nil + }}}) + if err != nil { + t.Fatal(err) + } + timeoutMS := 10 + if len(timeout) > 0 { + timeoutMS = timeout[0] + } + selected, _ := json.Marshal(limiter.Config{BackendModuleID: "example.fixture.limiter", BackendType: "fixture", TimeoutMS: timeoutMS, FailureMode: failure}) + e, err := extension.NewManager(t.Context(), r, admissionStore([]extension.Envelope{{ModuleID: "example.fixture.limiter", SchemaVersion: 1, Config: json.RawMessage(`{}`)}, {ModuleID: limiter.ModuleID, SchemaVersion: 1, Config: selected}})) + if err != nil { + t.Fatal(err) + } + m := NewVirtualKeyManager(nil) + m.SetExtensions(e) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = m.Close(ctx) + _ = e.Close(ctx) + _ = b.LimiterBackend.Close(ctx) + }) + return m, e, b +} +func hardKey() VirtualKey { + return VirtualKey{ID: "key", RateLimits: &VirtualKeyRateLimits{LLM: &RateLimit{RequestsPerMinute: 1, Burst: 1}}} +} +func TestAdmissionFailureModesAndNoRetries(t *testing.T) { + for _, mode := range []string{"error", "panic", "wait", "ambiguous"} { + for _, failure := range []string{"closed", "open"} { + t.Run(mode+"/"+failure, func(t *testing.T) { + m, _, b := admissionManager(t, mode, failure) + result, err := m.Admit(t.Context(), hardKey(), RateLimitDimensionLLM) + if failure == "closed" { + if !errors.Is(err, limiter.ErrLimiterUnavailable) || result.Allowed { + t.Fatalf("closed %+v %v", result, err) + } + } else if err != nil || !result.Allowed || !result.Degraded { + t.Fatalf("open %+v %v", result, err) + } + if b.calls.Load() != 1 { + t.Fatal("uncertain operation retried") + } + if err != nil && err.Error() != "limiter_unavailable" { + t.Fatal("diagnostic escaped", err) + } + }) + } + } +} +func TestAdmissionCancellationNeverFailsOpenAndUnlimitedSkipsBackend(t *testing.T) { + m, _, b := admissionManager(t, "wait", "open") + ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond) + defer cancel() + if result, err := m.Admit(ctx, hardKey(), RateLimitDimensionLLM); !errors.Is(err, context.DeadlineExceeded) || result.Allowed { + t.Fatalf("caller deadline %+v %v", result, err) + } + calls := b.calls.Load() + if result, err := m.Admit(t.Context(), VirtualKey{ID: "unlimited"}, RateLimitDimensionLLM); err != nil || !result.Allowed { + t.Fatal(result, err) + } + if b.calls.Load() != calls { + t.Fatal("unlimited called backend") + } +} +func TestAdmissionPolicyFencesAndUnchangedDimensions(t *testing.T) { + store := &testManagedVirtualKeyStore{items: map[string]*VirtualKey{}} + m := NewVirtualKeyManager(store) + key := hardKey() + key.Key = "secret" + key.RateLimits.MCP = &RateLimit{RequestsPerMinute: 1, Burst: 1} + if err := m.Create(t.Context(), key); err != nil { + t.Fatal(err) + } + for _, dim := range []RateLimitDimension{RateLimitDimensionLLM, RateLimitDimensionMCP} { + if d, e := m.Admit(t.Context(), key, dim); e != nil || !d.Allowed { + t.Fatal(d, e) + } + } + old := m.limiters.states[limiterKey{key.ID, RateLimitDimensionLLM}] + key.RateLimits.LLM = &RateLimit{RequestsPerMinute: 1, Burst: 2} + if err := m.Update(t.Context(), key.ID, key); err != nil { + t.Fatal(err) + } + if _, err := m.limiters.memory.Admit(t.Context(), old.request); !errors.Is(err, limiter.ErrLimiterRetired) { + t.Fatal("stale generation revived", err) + } + if d, e := m.Admit(t.Context(), key, RateLimitDimensionMCP); e != nil || d.Allowed { + t.Fatal("unchanged MCP reset", d, e) + } + if d, e := m.Admit(t.Context(), key, RateLimitDimensionLLM); e != nil || !d.Allowed { + t.Fatal("changed policy not full", d, e) + } + if err := m.Delete(t.Context(), key.ID); err != nil { + t.Fatal(err) + } + if d, e := m.Admit(t.Context(), key, RateLimitDimensionLLM); e == nil || d.Allowed { + t.Fatal("deleted key revived", d, e) + } +} +func TestCloseDuringAdmissionAndRepeatedClose(t *testing.T) { + m, _, b := admissionManager(t, "wait", "open") + done := make(chan error, 1) + go func() { _, err := m.Admit(t.Context(), hardKey(), RateLimitDimensionLLM); done <- err }() + deadline := time.After(time.Second) + for b.calls.Load() == 0 { + select { + case <-deadline: + t.Fatal("admission did not start") + default: + time.Sleep(time.Millisecond) + } + } + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + if err := m.Close(ctx); err != nil { + t.Fatal(err) + } + if err := <-done; err == nil { + t.Fatal("shutdown failed open") + } + if err := m.Close(ctx); err != nil { + t.Fatal(err) + } + if _, err := m.Admit(ctx, hardKey(), RateLimitDimensionLLM); err == nil { + t.Fatal("admitted after close") + } +} +func TestLimiterSelectionStrictValidation(t *testing.T) { + for _, raw := range []string{`{"timeout_ms":0}`, `{"timeout_ms":1001}`, `{"failure_mode":"fallback"}`, `{"backend_type":"attacker\"label"}`, `{"extra":1}`, `{"backend_module_id":"unknown"}`, `{"backend_type":"redis"}`, `{"timeout_ms":1,"timeout_ms":2}`} { + if _, err := limiter.Factory(false).Validate(t.Context(), json.RawMessage(raw)); err == nil { + t.Fatal("accepted", raw) + } + } + raw, err := limiter.Factory(false).Validate(t.Context(), json.RawMessage(`{}`)) + var cfg limiter.Config + _ = json.Unmarshal(raw, &cfg) + if err != nil || cfg.TimeoutMS != 100 || cfg.FailureMode != "closed" { + t.Fatal(cfg, err) + } +} + +// Concurrent store calls are deliberately allowed by the test backend; the +// manager must serialize the full persist/cache/policy publication operation. +type concurrentPolicyStore struct { + *testManagedVirtualKeyStore + mu sync.Mutex +} + +func (s *concurrentPolicyStore) Update(ctx context.Context, obj any) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.testManagedVirtualKeyStore.Update(ctx, obj) +} +func TestConcurrentPolicyWritesMatchPersistedAdmissionSnapshot(t *testing.T) { + store := &concurrentPolicyStore{testManagedVirtualKeyStore: &testManagedVirtualKeyStore{items: map[string]*VirtualKey{}}} + m := NewVirtualKeyManager(store) + key := hardKey() + key.Key = "secret" + if err := m.Create(t.Context(), key); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for i := 1; i <= 64; i++ { + wg.Add(1) + go func() { + defer wg.Done() + next := key + next.RateLimits = &VirtualKeyRateLimits{LLM: &RateLimit{RequestsPerMinute: i, Burst: i}} + if err := m.Update(t.Context(), key.ID, next); err != nil { + t.Error(err) + } + }() + } + for range 64 { + wg.Add(1) + go func() { defer wg.Done(); _, _ = m.Admit(t.Context(), key, RateLimitDimensionLLM) }() + } + wg.Wait() + current, err := m.GetByID(t.Context(), key.ID) + if err != nil { + t.Fatal(err) + } + stored, _ := store.Get(t.Context(), key.ID) + if !rateLimitsEqual(current.RateLimits, stored.(*VirtualKey).RateLimits) { + t.Fatal("cache differs from persisted policy") + } + m.limiters.mu.Lock() + policy := cloneRateLimits(m.limiters.policies[key.ID]) + m.limiters.mu.Unlock() + if !rateLimitsEqual(current.RateLimits, policy) { + t.Fatalf("admission snapshot differs: stored=%+v admission=%+v", current.RateLimits.LLM, policy.LLM) + } +} + +func TestRetiredGenerationNeverFailsOpen(t *testing.T) { + for _, failure := range []string{"closed", "open"} { + t.Run(failure, func(t *testing.T) { + m, _, b := admissionManager(t, "retired", failure) + result, err := m.Admit(t.Context(), hardKey(), RateLimitDimensionLLM) + if result.Allowed || result.Degraded || !errors.Is(err, limiter.ErrLimiterRetired) { + t.Fatalf("retired generation %+v %v", result, err) + } + if err.Error() != limiter.ErrLimiterRetired.Error() { + t.Fatal("private wrapped error escaped", err) + } + if b.calls.Load() != 1 { + t.Fatal("retired admission retried") + } + }) + } +} +func TestPolicyMutationFencesInflightAdmissionWithFailOpen(t *testing.T) { + for _, operation := range []string{"update", "delete"} { + t.Run(operation, func(t *testing.T) { + _, extensions, b := admissionManager(t, "retirement_race", "open", 1000) + b.entered = make(chan struct{}) + b.release = make(chan struct{}) + release := sync.OnceFunc(func() { close(b.release) }) + defer release() + store := &testManagedVirtualKeyStore{items: map[string]*VirtualKey{}} + m := NewVirtualKeyManager(store) + m.SetExtensions(extensions) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = m.Close(ctx) + }) + key := hardKey() + key.Key = "secret" + if err := m.Create(t.Context(), key); err != nil { + t.Fatal(err) + } + type outcome struct { + admission Admission + err error + } + done := make(chan outcome, 1) + go func() { a, e := m.Admit(t.Context(), key, RateLimitDimensionLLM); done <- outcome{a, e} }() + select { + case <-b.entered: + case <-time.After(time.Second): + t.Fatal("admission did not start") + } + if operation == "update" { + key.RateLimits = &VirtualKeyRateLimits{LLM: &RateLimit{RequestsPerMinute: 1, Burst: 2}} + if err := m.Update(t.Context(), key.ID, key); err != nil { + t.Fatal(err) + } + } else if err := m.Delete(t.Context(), key.ID); err != nil { + t.Fatal(err) + } + release() + select { + case result := <-done: + if result.admission.Allowed || result.admission.Degraded || !errors.Is(result.err, limiter.ErrLimiterRetired) { + t.Fatalf("mutation failed open: %+v %v", result.admission, result.err) + } + case <-time.After(time.Second): + t.Fatal("admission did not finish") + } + }) + } +} + +func admissionStore(envelopes []extension.Envelope) selectionStore { + s := selectionStore{} + for _, e := range envelopes { + s[e.ModuleID] = e + } + return s +} diff --git a/pkg/gateway/virtualkey/limiter/AGENTS.md b/pkg/gateway/virtualkey/limiter/AGENTS.md new file mode 100644 index 00000000..9cb4c59c --- /dev/null +++ b/pkg/gateway/virtualkey/limiter/AGENTS.md @@ -0,0 +1,20 @@ +# Protected limiter seam + +Read the parent/root rules and enterprise-extension-contract.md section 11.6. +This package owns the public admission SPI; it must not import its parent +virtualkey package, dispatcher, distribution, gateway, or internal observability. +Domain IDs/policies contain no bearer credentials. Contract diagnostics are +bounded by the owner; never forward callback strings to HTTP/events. + +Prepare selected backends before extension activation. Retain immutable policy +identity across unchanged settings and unrelated generations. Module connection +handles are generation-owned; quota storage is policy-generation-owned and must +survive their replacement. Retire permanently fences old operations. Never retry +uncertain consumes or silently switch to local buckets. Default 100 ms/closed; +caller cancellation, retirement cancellation and ErrLimiterRetired never fail open. +Retired admissions return the sentinel (possibly wrapped); conformance checks it. Memory is the +Community default, distributed production storage is out of scope. + +Run the same public contracttest.Limiter suite for memory and the independent +consumer backend, plus parent/extension/dispatcher race tests and both real server +process tests. The exact candidate protected symbol list lives in doc.go. diff --git a/pkg/gateway/virtualkey/limiter/backend.go b/pkg/gateway/virtualkey/limiter/backend.go new file mode 100644 index 00000000..c90c04b7 --- /dev/null +++ b/pkg/gateway/virtualkey/limiter/backend.go @@ -0,0 +1,161 @@ +package limiter + +import ( + "context" + "errors" + "math" + "sync" + "time" + + "golang.org/x/time/rate" +) + +// Dimension is one ingress traffic class. +type Dimension string + +const ( + DimensionLLM Dimension = "llm" + DimensionMCP Dimension = "mcp" + DimensionAgent Dimension = "agent" +) + +// Policy contains validated positive request-token limits. +type Policy struct { + RequestsPerMinute int + Burst int +} + +// Clock supports deterministic backend contract fixtures. +type Clock interface{ Now() time.Time } +type systemClock struct{} + +func (systemClock) Now() time.Time { return time.Now() } + +// LimiterRequest is an immutable policy snapshot. Generation is an opaque, +// globally unique policy identity, never a bearer key. An unchanged policy keeps +// its generation across extension refreshes. Backends must preserve its state. +type LimiterRequest struct { + Generation string + VirtualKeyID string + Dimension Dimension + Limit Policy +} + +// LimiterDecision reports one atomic consume-or-deny operation. A failure may +// have consumed a token; the caller must never retry it automatically. +type LimiterDecision struct { + Allowed bool + RetryAfter time.Duration +} + +// LimiterBackend is the candidate protected domain SPI. All methods honor +// context; diagnostics are replaced by bounded gateway codes. Retire fences a +// generation permanently, including late operations. Admit on a retired +// generation must return ErrLimiterRetired (possibly wrapped); it never fails open. +// Generation-scoped storage +// survives replacement of a module's prepared connection/resource handles. +// Close stops admissions, cancels/drains work, and runs once per owned handle. +// Backend factories initialize connections before extension publication. +type LimiterBackend interface { + Admit(context.Context, LimiterRequest) (LimiterDecision, error) + Retire(context.Context, string) error + Health(context.Context) error + Close(context.Context) error +} + +// LimiterProvider is implemented by a linked extension's prepared resource. +// Selection resolves this interface only during preparation, never per request. +type LimiterProvider interface { + LimiterBackend(string) (LimiterBackend, error) +} + +var ErrLimiterUnavailable = errors.New("limiter_unavailable") +var ErrLimiterRetired = errors.New("limiter_retired") + +// MemoryBackend is the Community process-local reference backend. Its lifetime +// is one gateway; it is not a distributed quota authority. +type MemoryBackend struct { + mu sync.Mutex + clock Clock + closed bool + buckets map[string]*memoryBucket + retired map[string]bool +} +type memoryBucket struct { + request LimiterRequest + limiter *rate.Limiter +} + +func NewMemoryBackend(clock Clock) *MemoryBackend { + if clock == nil { + clock = systemClock{} + } + return &MemoryBackend{clock: clock, buckets: map[string]*memoryBucket{}, retired: map[string]bool{}} +} +func (b *MemoryBackend) Admit(ctx context.Context, r LimiterRequest) (LimiterDecision, error) { + if err := ctx.Err(); err != nil { + return LimiterDecision{}, err + } + if r.Generation == "" || r.VirtualKeyID == "" || r.Limit.RequestsPerMinute <= 0 || r.Limit.Burst <= 0 { + return LimiterDecision{}, ErrLimiterUnavailable + } + switch r.Dimension { + case DimensionLLM, DimensionMCP, DimensionAgent: + default: + return LimiterDecision{}, ErrLimiterUnavailable + } + b.mu.Lock() + defer b.mu.Unlock() + if err := ctx.Err(); err != nil { + return LimiterDecision{}, err + } + if b.closed { + return LimiterDecision{}, ErrLimiterUnavailable + } + if b.retired[r.Generation] { + return LimiterDecision{}, ErrLimiterRetired + } + bucket := b.buckets[r.Generation] + if bucket == nil { + bucket = &memoryBucket{request: r, limiter: rate.NewLimiter(rate.Limit(float64(r.Limit.RequestsPerMinute)/60), r.Limit.Burst)} + b.buckets[r.Generation] = bucket + } else if bucket.request != r { + return LimiterDecision{}, ErrLimiterUnavailable + } + now := b.clock.Now() + if bucket.limiter.AllowN(now, 1) { + return LimiterDecision{Allowed: true}, nil + } + seconds := math.Max(0, 1-bucket.limiter.TokensAt(now)) / (float64(r.Limit.RequestsPerMinute) / 60) + // Keep conversion bounded even for extreme policies supplied by a backend user. + retry := time.Duration(math.Ceil(seconds * float64(time.Second))) + return LimiterDecision{RetryAfter: max(time.Nanosecond, retry)}, nil +} +func (b *MemoryBackend) Retire(ctx context.Context, g string) error { + if err := ctx.Err(); err != nil { + return err + } + b.mu.Lock() + defer b.mu.Unlock() + b.retired[g] = true + delete(b.buckets, g) + return nil +} +func (b *MemoryBackend) Health(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return ErrLimiterUnavailable + } + return nil +} +func (b *MemoryBackend) Close(ctx context.Context) error { + b.mu.Lock() + defer b.mu.Unlock() + b.closed = true + b.buckets = map[string]*memoryBucket{} + return ctx.Err() +} diff --git a/pkg/gateway/virtualkey/limiter/backend_test.go b/pkg/gateway/virtualkey/limiter/backend_test.go new file mode 100644 index 00000000..90dcc1a0 --- /dev/null +++ b/pkg/gateway/virtualkey/limiter/backend_test.go @@ -0,0 +1,18 @@ +package limiter_test + +import ( + "context" + vk "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey/limiter" + "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey/limiter/contracttest" + "testing" +) + +func TestMemoryBackendContract(t *testing.T) { + contracttest.Limiter(t, func(context.Context) (vk.LimiterBackend, error) { return vk.NewMemoryBackend(nil), nil }) +} + +func TestMemoryBackendRefillContract(t *testing.T) { + contracttest.LimiterRefill(t, func(_ context.Context, clock vk.Clock) (vk.LimiterBackend, error) { + return vk.NewMemoryBackend(clock), nil + }) +} diff --git a/pkg/gateway/virtualkey/limiter/contracttest/limiter.go b/pkg/gateway/virtualkey/limiter/contracttest/limiter.go new file mode 100644 index 00000000..fd96ef86 --- /dev/null +++ b/pkg/gateway/virtualkey/limiter/contracttest/limiter.go @@ -0,0 +1,137 @@ +// Package contracttest provides reusable protected limiter conformance suites. +package contracttest + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + vk "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey/limiter" +) + +// Limiter exercises atomicity, isolation, non-consuming denials, retry metadata, +// cancellation, permanent retirement, health and close on independently owned backends. +// New must return a fresh backend and honor its context. +func Limiter(t *testing.T, newBackend func(context.Context) (vk.LimiterBackend, error)) { + t.Helper() + b, err := newBackend(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := b.Close(ctx); err != nil { + t.Error(err) + } + }) + if err := b.Health(t.Context()); err != nil { + t.Fatal(err) + } + var allowed atomic.Int64 + r := vk.LimiterRequest{Generation: "burst", VirtualKeyID: "key", Dimension: vk.DimensionLLM, Limit: vk.Policy{RequestsPerMinute: 1, Burst: 8}} + var wg sync.WaitGroup + for range 64 { + wg.Add(1) + go func() { + defer wg.Done() + d, e := b.Admit(t.Context(), r) + if e != nil { + t.Error(e) + } + if d.Allowed { + allowed.Add(1) + } else if d.RetryAfter <= 0 { + t.Error("missing retry-after") + } + }() + } + wg.Wait() + if allowed.Load() != 8 { + t.Fatalf("atomic burst=%d", allowed.Load()) + } + for _, dim := range []vk.Dimension{vk.DimensionLLM, vk.DimensionMCP, vk.DimensionAgent} { + isolated := r + isolated.Generation = fmt.Sprint(dim) + isolated.Dimension = dim + if d, e := b.Admit(t.Context(), isolated); e != nil || !d.Allowed { + t.Fatalf("dimension %s: %+v %v", dim, d, e) + } + } + canceled, cancel := context.WithCancel(t.Context()) + cancel() + r.Generation = "canceled" + if _, e := b.Admit(canceled, r); e == nil { + t.Fatal("cancellation ignored") + } + if d, e := b.Admit(t.Context(), r); e != nil || !d.Allowed { + t.Fatal("canceled admission consumed token", e) + } + if err := b.Retire(t.Context(), r.Generation); err != nil { + t.Fatal(err) + } + for range 5 { + if _, e := b.Admit(t.Context(), r); !errors.Is(e, vk.ErrLimiterRetired) { + t.Fatalf("retired generation must return ErrLimiterRetired: %v", e) + } + } + expired, cancelExpired := context.WithDeadline(t.Context(), time.Now().Add(-time.Second)) + defer cancelExpired() + if _, err := b.Admit(expired, r); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("deadline ignored", err) + } + if err := b.Close(t.Context()); err != nil { + t.Fatal(err) + } + if _, e := b.Admit(t.Context(), vk.LimiterRequest{Generation: "after-close", VirtualKeyID: "key", Dimension: vk.DimensionLLM, Limit: r.Limit}); e == nil { + t.Fatal("closed backend admitted") + } +} + +// LimiterRefill verifies that repeated/concurrent denials never reserve future +// tokens. Both implementations receive the same deterministic clock. +func LimiterRefill(t *testing.T, newBackend func(context.Context, vk.Clock) (vk.LimiterBackend, error)) { + t.Helper() + clock := &fixtureClock{now: time.Unix(1000, 0)} + b, err := newBackend(t.Context(), clock) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = b.Close(context.Background()) }) + r := vk.LimiterRequest{Generation: "refill", VirtualKeyID: "key", Dimension: vk.DimensionMCP, Limit: vk.Policy{RequestsPerMinute: 60, Burst: 1}} + if d, e := b.Admit(t.Context(), r); e != nil || !d.Allowed { + t.Fatal(d, e) + } + var wg sync.WaitGroup + for range 64 { + wg.Add(1) + go func() { + defer wg.Done() + d, e := b.Admit(t.Context(), r) + if e != nil || d.Allowed || d.RetryAfter != time.Second { + t.Errorf("denial %+v %v", d, e) + } + }() + } + wg.Wait() + clock.advance(time.Second) + if d, e := b.Admit(t.Context(), r); e != nil || !d.Allowed { + t.Fatal("denials consumed future refill", d, e) + } +} + +type fixtureClock struct { + mu sync.Mutex + now time.Time +} + +func (c *fixtureClock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.now } +func (c *fixtureClock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) +} diff --git a/pkg/gateway/virtualkey/limiter/doc.go b/pkg/gateway/virtualkey/limiter/doc.go new file mode 100644 index 00000000..faab5383 --- /dev/null +++ b/pkg/gateway/virtualkey/limiter/doc.go @@ -0,0 +1,20 @@ +// Package limiter owns the candidate protected VirtualKey admission SPI. +// The designated surfaces are Dimension and its LLM/MCP/Agent constants, +// Policy, Clock, LimiterRequest, LimiterDecision, LimiterBackend, LimiterProvider, +// Config, ModuleID, MemoryBackendType, Factory, NewMemoryBackend and the +// MemoryBackend Admit/Retire/Health/Close methods, ErrLimiterUnavailable and +// ErrLimiterRetired. Their exported fields and method signatures are protected +// candidates; released protection requires the published-tag consumer gates. +// Selection and its accessors are Community assembly implementation surfaces. +// +// Policies and globally unique generations are committed before admission. +// One call atomically consumes one token or denies without consumption; errors +// can be ambiguous and are never retried by the gateway. Cancellation and bounded +// deadlines are mandatory. Retire permanently fences generations, even when a +// late operation arrives; Admit returns ErrLimiterRetired (possibly wrapped), +// which never triggers fail-open. Generation-scoped quota storage survives replacement +// of prepared backend connection handles on unrelated extension mutations. +// A switch of backend authority/config resets generations; no token transfer is +// promised. Prepare initializes handles, Health diagnoses, Close stops and drains +// once under the extension manager's common five-second lifecycle budget. +package limiter diff --git a/pkg/gateway/virtualkey/limiter/selection.go b/pkg/gateway/virtualkey/limiter/selection.go new file mode 100644 index 00000000..43b64a9b --- /dev/null +++ b/pkg/gateway/virtualkey/limiter/selection.go @@ -0,0 +1,160 @@ +package limiter + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "reflect" + "regexp" + "time" + + "github.com/agent-guide/agent-gateway/pkg/extension" + "github.com/google/uuid" +) + +const ModuleID = "online.agentguide.community.limiter" +const MemoryBackendType = "memory" + +// Config is the Community-owned selection envelope (schema 1). +// No policy bypass is implicit: failure_mode defaults to closed. +type Config struct { + BackendModuleID string `json:"backend_module_id"` + BackendType string `json:"backend_type"` + TimeoutMS int `json:"timeout_ms"` + FailureMode string `json:"failure_mode"` +} + +var backendTypePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,31}$`) + +func decodeConfig(raw json.RawMessage) (Config, error) { + c := Config{BackendModuleID: ModuleID, BackendType: MemoryBackendType, TimeoutMS: 100, FailureMode: "closed"} + if _, err := extension.DecodeObject(raw); err != nil { + return c, ErrLimiterUnavailable + } + d := json.NewDecoder(bytes.NewReader(raw)) + d.DisallowUnknownFields() + if err := d.Decode(&c); err != nil { + return c, ErrLimiterUnavailable + } + if d.Decode(new(any)) != io.EOF || !extension.ValidID(c.BackendModuleID) || !backendTypePattern.MatchString(c.BackendType) || c.TimeoutMS < 1 || c.TimeoutMS > 1000 || (c.FailureMode != "closed" && c.FailureMode != "open") || c.BackendModuleID == ModuleID && c.BackendType != MemoryBackendType { + return c, ErrLimiterUnavailable + } + return c, nil +} + +// Factory provides strict syntax/dependency validation and prepares the +// selected linked backend. runtime=false is the schema-only CLI projection. +func Factory(runtime bool) *extension.Factory { + f := &extension.Factory{ + Validate: func(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + c, e := decodeConfig(raw) + if e != nil { + return nil, e + } + return json.Marshal(c) + }, + Dependencies: func(ctx context.Context, raw json.RawMessage) ([]string, error) { + c, e := decodeConfig(raw) + if e != nil { + return nil, e + } + if c.BackendModuleID == ModuleID { + return nil, nil + } + return []string{c.BackendModuleID}, nil + }, + } + if runtime { + f.Prepare = func(ctx context.Context, cfg extension.PreparedConfig, deps extension.Snapshot) (extension.Resource, error) { + c, err := decodeConfig(cfg.Envelope.Config) + if err != nil { + return nil, err + } + s := &Selection{config: c, identity: uuid.NewString()} + // Timeout and failure handling do not reset tokens. Backend configuration and + // type identify bucket authority; changes reset without attempting transfer. + input := []byte(c.BackendModuleID + "/" + c.BackendType) + if c.BackendModuleID != ModuleID { + r, ok := deps.Resource(c.BackendModuleID) + if !ok { + return nil, ErrLimiterUnavailable + } + p, ok := r.(LimiterProvider) + if !ok { + return nil, ErrLimiterUnavailable + } + s.backend, err = p.LimiterBackend(c.BackendType) + if err != nil || nilBackend(s.backend) { + return nil, ErrLimiterUnavailable + } + e, _ := deps.Config(c.BackendModuleID) + input = append(input, e.Config...) + if err := s.backend.Health(ctx); err != nil { + return nil, ErrLimiterUnavailable + } + } + s.fingerprint = sha256.Sum256(input) + return s, nil + } + } + return f +} +func nilBackend(b LimiterBackend) bool { + if b == nil { + return true + } + v := reflect.ValueOf(b) + switch v.Kind() { + case reflect.Pointer, reflect.Interface, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan: + return v.IsNil() + } + return false +} + +type Selection struct { + config Config + backend LimiterBackend + fingerprint [32]byte + identity string +} + +func (s *Selection) Health(ctx context.Context) error { + if s.backend != nil { + return s.backend.Health(ctx) + } + return ctx.Err() +} +func (s *Selection) Close(context.Context) error { return nil } // Dependency owns its backend handle. +func (s *Selection) StateResetComparedTo(ctx context.Context, previous extension.Resource) (bool, error) { + if ctx.Err() != nil { + return false, ctx.Err() + } + old, ok := previous.(*Selection) + if !ok || s.fingerprint != old.fingerprint { + return true, nil + } + s.identity = old.identity // Only the unpublished candidate is changed. + return false, nil +} +func (s *Selection) String() string { + return fmt.Sprint(s.config.BackendModuleID, "/", s.config.BackendType) +} +func (s *Selection) budget() time.Duration { + return time.Duration(s.config.TimeoutMS) * time.Millisecond +} + +// Selection is Community runtime wiring, not an extension implementation API. +// It provides an immutable prepared backend selection to the VirtualKey owner. +func (s *Selection) Configuration() Config { return s.config } +func (s *Selection) Backend() LimiterBackend { return s.backend } +func (s *Selection) Identity() string { return s.identity } +func (s *Selection) Budget() time.Duration { return s.budget() } +func DefaultSelection() *Selection { + return &Selection{identity: "local", config: Config{BackendModuleID: ModuleID, BackendType: MemoryBackendType, TimeoutMS: 100, FailureMode: "closed"}} +} diff --git a/pkg/gateway/virtualkey/manager.go b/pkg/gateway/virtualkey/manager.go index 54da9e6c..f3674bb5 100644 --- a/pkg/gateway/virtualkey/manager.go +++ b/pkg/gateway/virtualkey/manager.go @@ -24,10 +24,11 @@ type VirtualKeyManager struct { base *configmgr.BaseConfigManager[VirtualKey] store configstore.ConfigStore - mu sync.RWMutex + mu sync.RWMutex + mutations sync.Mutex dynamicKeyIndex map[string]string - limiters *limiterRegistry + limiters *limiterController } func NewVirtualKeyManager(store configstore.ConfigStore) *VirtualKeyManager { @@ -75,16 +76,18 @@ func NewVirtualKeyManagerWithClock(store configstore.ConfigStore, clock Clock) * }), store: store, dynamicKeyIndex: map[string]string{}, - limiters: newLimiterRegistry(clock), + limiters: newLimiterController(clock), } } func (m *VirtualKeyManager) Reset() { + m.mutations.Lock() + defer m.mutations.Unlock() m.base.Reset() m.mu.Lock() - defer m.mu.Unlock() m.dynamicKeyIndex = map[string]string{} + m.mu.Unlock() m.limiters.reset() } @@ -122,7 +125,9 @@ func (m *VirtualKeyManager) GetByKey(ctx context.Context, key string) (VirtualKe return VirtualKey{}, err } m.base.Cache(virtualKey) + m.limiters.remember(virtualKey) m.cacheDynamicKey(virtualKey.ID, virtualKey.Key) + m.limiters.remember(virtualKey) return cloneVirtualKey(virtualKey), nil } @@ -142,6 +147,7 @@ func (m *VirtualKeyManager) GetByID(ctx context.Context, id string) (VirtualKey, return VirtualKey{}, fmt.Errorf("load virtual key %q: %w", id, err) } m.cacheDynamicKey(virtualKey.ID, virtualKey.Key) + m.limiters.remember(virtualKey) return cloneVirtualKey(virtualKey), nil } @@ -156,6 +162,9 @@ func (m *VirtualKeyManager) List(ctx context.Context, opts VirtualKeyListOptions cached[key.ID] = key } m.cacheDynamicKeys(cached) + for _, key := range keys { + m.limiters.remember(key) + } for i := range keys { keys[i] = cloneVirtualKey(keys[i]) } @@ -163,14 +172,19 @@ func (m *VirtualKeyManager) List(ctx context.Context, opts VirtualKeyListOptions } func (m *VirtualKeyManager) Create(ctx context.Context, key VirtualKey) error { + m.mutations.Lock() + defer m.mutations.Unlock() if err := m.base.Create(ctx, key); err != nil { return err } m.cacheDynamicKey(key.ID, key.Key) + m.limiters.remove(ctx, key.ID, key.RateLimits, false) return nil } func (m *VirtualKeyManager) Update(ctx context.Context, id string, key VirtualKey) error { + m.mutations.Lock() + defer m.mutations.Unlock() if id == "" { return fmt.Errorf("id is required") } @@ -189,16 +203,16 @@ func (m *VirtualKeyManager) Update(ctx context.Context, id string, key VirtualKe key.UpdatedAt = time.Now().UTC() m.cacheDynamicKey(key.ID, key.Key) if !rateLimitsEqual(current.RateLimits, key.RateLimits) { - m.limiters.remove(id) + m.limiters.remove(ctx, id, key.RateLimits, false) } return nil } -func (m *VirtualKeyManager) Admit(key VirtualKey, dimension RateLimitDimension) (Admission, error) { +func (m *VirtualKeyManager) Admit(ctx context.Context, key VirtualKey, dimension RateLimitDimension) (Admission, error) { if m == nil || m.limiters == nil { return Admission{}, fmt.Errorf("virtual key limiter is not configured") } - return m.limiters.admit(key, dimension) + return m.limiters.admit(ctx, key, dimension) } type storedVirtualKey struct { @@ -215,6 +229,8 @@ func (k storedVirtualKey) ConfigStoreTag() string { } func (m *VirtualKeyManager) Delete(ctx context.Context, id string) error { + m.mutations.Lock() + defer m.mutations.Unlock() if id == "" { return fmt.Errorf("id is required") } @@ -228,9 +244,9 @@ func (m *VirtualKeyManager) Delete(ctx context.Context, id string) error { } m.mu.Lock() - defer m.mu.Unlock() delete(m.dynamicKeyIndex, current.Key) - m.limiters.remove(id) + m.mu.Unlock() + m.limiters.remove(ctx, id, nil, true) return nil } diff --git a/pkg/gateway/virtualkey/manager_test.go b/pkg/gateway/virtualkey/manager_test.go index 3c32ef46..9f528387 100644 --- a/pkg/gateway/virtualkey/manager_test.go +++ b/pkg/gateway/virtualkey/manager_test.go @@ -3,6 +3,7 @@ package virtualkey import ( "context" "errors" + "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey/limiter" "sync" "sync/atomic" "testing" @@ -245,22 +246,22 @@ func TestVirtualKeyRateLimiterDimensionsAndRefill(t *testing.T) { RateLimitDimensionLLM, RateLimitDimensionMCP, RateLimitDimensionAgent, } { - got, err := manager.Admit(key, dimension) + got, err := manager.Admit(context.Background(), key, dimension) if err != nil || !got.Allowed { t.Fatalf("first Admit(%s) = %+v, %v", dimension, got, err) } - got, err = manager.Admit(key, dimension) + got, err = manager.Admit(context.Background(), key, dimension) if err != nil || got.Allowed || got.RetryAfterSeconds != 1 { t.Fatalf("second Admit(%s) = %+v, %v", dimension, got, err) } for range 10 { - if denied, denyErr := manager.Admit(key, dimension); denyErr != nil || denied.Allowed { + if denied, denyErr := manager.Admit(context.Background(), key, dimension); denyErr != nil || denied.Allowed { t.Fatalf("repeated denied Admit(%s) = %+v, %v", dimension, denied, denyErr) } } } clock.Advance(time.Second) - got, err := manager.Admit(key, RateLimitDimensionLLM) + got, err := manager.Admit(context.Background(), key, RateLimitDimensionLLM) if err != nil || !got.Allowed { t.Fatalf("Admit after refill = %+v, %v", got, err) } @@ -268,13 +269,13 @@ func TestVirtualKeyRateLimiterDimensionsAndRefill(t *testing.T) { func TestVirtualKeyRateLimiterIsolationAndUnlimited(t *testing.T) { manager := NewVirtualKeyManager(nil) - unlimited, err := manager.Admit(VirtualKey{ID: "unlimited"}, RateLimitDimensionLLM) + unlimited, err := manager.Admit(context.Background(), VirtualKey{ID: "unlimited"}, RateLimitDimensionLLM) if err != nil || !unlimited.Allowed { t.Fatalf("unlimited Admit = %+v, %v", unlimited, err) } policy := &VirtualKeyRateLimits{LLM: &RateLimit{RequestsPerMinute: 1, Burst: 1}} for _, id := range []string{"vk-a", "vk-b"} { - got, err := manager.Admit(VirtualKey{ID: id, RateLimits: policy}, RateLimitDimensionLLM) + got, err := manager.Admit(context.Background(), VirtualKey{ID: id, RateLimits: policy}, RateLimitDimensionLLM) if err != nil || !got.Allowed { t.Fatalf("first Admit(%s) = %+v, %v", id, got, err) } @@ -295,7 +296,7 @@ func TestVirtualKeyRateLimiterConcurrentBurst(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - got, err := manager.Admit(key, RateLimitDimensionLLM) + got, err := manager.Admit(context.Background(), key, RateLimitDimensionLLM) if err != nil { t.Errorf("Admit returned error: %v", err) return @@ -321,10 +322,10 @@ func TestVirtualKeyRateLimiterUpdateDeleteAndResetRemoveBuckets(t *testing.T) { if err := manager.Create(context.Background(), key); err != nil { t.Fatal(err) } - if got, _ := manager.Admit(key, RateLimitDimensionLLM); !got.Allowed { + if got, _ := manager.Admit(context.Background(), key, RateLimitDimensionLLM); !got.Allowed { t.Fatal("initial admission denied") } - if got, _ := manager.Admit(key, RateLimitDimensionLLM); got.Allowed { + if got, _ := manager.Admit(context.Background(), key, RateLimitDimensionLLM); got.Allowed { t.Fatal("second admission allowed") } key.RateLimits.LLM.Burst = 2 @@ -336,20 +337,20 @@ func TestVirtualKeyRateLimiterUpdateDeleteAndResetRemoveBuckets(t *testing.T) { t.Fatal(err) } for range 2 { - if got, _ := manager.Admit(updated, RateLimitDimensionLLM); !got.Allowed { + if got, _ := manager.Admit(context.Background(), updated, RateLimitDimensionLLM); !got.Allowed { t.Fatal("updated bucket did not reset to new burst") } } if err := manager.Delete(context.Background(), key.ID); err != nil { t.Fatal(err) } - if len(manager.limiters.buckets) != 0 { - t.Fatalf("buckets after delete = %d, want 0", len(manager.limiters.buckets)) + if len(manager.limiters.states) != 0 { + t.Fatalf("buckets after delete = %d, want 0", len(manager.limiters.states)) } - manager.limiters.buckets[limiterKey{virtualKeyID: "other", dimension: RateLimitDimensionLLM}] = &limiterBucket{} + manager.limiters.states[limiterKey{virtualKeyID: "other", dimension: RateLimitDimensionLLM}] = policyState{backend: manager.limiters.memory, request: limiter.LimiterRequest{Generation: "other"}} manager.Reset() - if len(manager.limiters.buckets) != 0 { - t.Fatalf("buckets after reset = %d, want 0", len(manager.limiters.buckets)) + if len(manager.limiters.states) != 0 { + t.Fatalf("buckets after reset = %d, want 0", len(manager.limiters.states)) } } @@ -367,21 +368,21 @@ func TestVirtualKeyRateLimiterAgentDimensionDoesNotConsumeLLMOrMCP(t *testing.T) }, } - if got, _ := manager.Admit(key, RateLimitDimensionAgent); !got.Allowed { + if got, _ := manager.Admit(context.Background(), key, RateLimitDimensionAgent); !got.Allowed { t.Fatal("agent admission denied on first turn") } - if got, _ := manager.Admit(key, RateLimitDimensionAgent); got.Allowed { + if got, _ := manager.Admit(context.Background(), key, RateLimitDimensionAgent); got.Allowed { t.Fatal("second agent admission allowed; agent bucket should be exhausted") } // LLM and MCP ingress are unaffected by the agent admission. Internal calls // likewise spend no ingress tokens unless they re-enter the dispatcher. for _, dimension := range []RateLimitDimension{RateLimitDimensionLLM, RateLimitDimensionMCP} { - if got, _ := manager.Admit(key, dimension); !got.Allowed { + if got, _ := manager.Admit(context.Background(), key, dimension); !got.Allowed { t.Fatalf("ingress Admit(%s) denied; agent turn leaked into ingress", dimension) } } - if got, _ := manager.Admit(key, RateLimitDimensionAgent); got.Allowed { + if got, _ := manager.Admit(context.Background(), key, RateLimitDimensionAgent); got.Allowed { t.Fatal("agent admission allowed after ingress traffic; buckets cross-contaminated") } } @@ -399,7 +400,7 @@ func TestVirtualKeyRateLimiterConcurrentDenialsDoNotConsumeTokens(t *testing.T) } // Spend the single burst token. - if got, _ := manager.Admit(key, RateLimitDimensionLLM); !got.Allowed { + if got, _ := manager.Admit(context.Background(), key, RateLimitDimensionLLM); !got.Allowed { t.Fatal("initial admission denied") } @@ -410,7 +411,7 @@ func TestVirtualKeyRateLimiterConcurrentDenialsDoNotConsumeTokens(t *testing.T) wg.Add(1) go func() { defer wg.Done() - got, _ := manager.Admit(key, RateLimitDimensionLLM) + got, _ := manager.Admit(context.Background(), key, RateLimitDimensionLLM) if got.Allowed { t.Errorf("concurrent denial was allowed") return @@ -426,10 +427,10 @@ func TestVirtualKeyRateLimiterConcurrentDenialsDoNotConsumeTokens(t *testing.T) // Advancing the clock by exactly one refill interval must yield exactly one // token — no more, because the 50 denials consumed none. clock.Advance(time.Second) - if got, _ := manager.Admit(key, RateLimitDimensionLLM); !got.Allowed { + if got, _ := manager.Admit(context.Background(), key, RateLimitDimensionLLM); !got.Allowed { t.Fatal("admission after refill denied") } - if got, _ := manager.Admit(key, RateLimitDimensionLLM); got.Allowed { + if got, _ := manager.Admit(context.Background(), key, RateLimitDimensionLLM); got.Allowed { t.Fatal("second admission after one-token refill allowed; denials leaked capacity") } } diff --git a/pkg/gateway/virtualkey/ratelimit.go b/pkg/gateway/virtualkey/ratelimit.go index 70d68d2e..e8548236 100644 --- a/pkg/gateway/virtualkey/ratelimit.go +++ b/pkg/gateway/virtualkey/ratelimit.go @@ -2,11 +2,7 @@ package virtualkey import ( "fmt" - "math" - "sync" "time" - - "golang.org/x/time/rate" ) type RateLimitDimension string @@ -27,6 +23,9 @@ type systemClock struct{} func (systemClock) Now() time.Time { return time.Now() } type Admission struct { + BackendID string + BackendType string + Degraded bool Allowed bool RetryAfterSeconds int RequestsPerMinute int @@ -38,78 +37,12 @@ type limiterKey struct { dimension RateLimitDimension } -type limiterBucket struct { - limiter *rate.Limiter - refillRate rate.Limit - requestsPerMinute int - burst int -} - -type limiterRegistry struct { - mu sync.RWMutex - clock Clock - buckets map[limiterKey]*limiterBucket -} - -func newLimiterRegistry(clock Clock) *limiterRegistry { - if clock == nil { - clock = systemClock{} - } - return &limiterRegistry{clock: clock, buckets: map[limiterKey]*limiterBucket{}} -} - -func (r *limiterRegistry) admit(key VirtualKey, dimension RateLimitDimension) (Admission, error) { - if key.ID == "" { - return Admission{}, fmt.Errorf("virtual key id is required") - } - // Rate-limit policies are validated at create/update time, so the hot path - // does not re-validate. A nil rate limit for the matched dimension means the - // traffic class is unlimited. - limit, err := rateLimitForDimension(key.RateLimits, dimension) - if err != nil { - return Admission{}, err - } - if limit == nil { - return Admission{Allowed: true}, nil - } - - registryKey := limiterKey{virtualKeyID: key.ID, dimension: dimension} - r.mu.RLock() - bucket := r.buckets[registryKey] - r.mu.RUnlock() - if bucket == nil || bucket.requestsPerMinute != limit.RequestsPerMinute || bucket.burst != limit.Burst { - r.mu.Lock() - bucket = r.buckets[registryKey] - if bucket == nil || bucket.requestsPerMinute != limit.RequestsPerMinute || bucket.burst != limit.Burst { - refillRate := rate.Limit(float64(limit.RequestsPerMinute) / 60.0) - bucket = &limiterBucket{ - limiter: rate.NewLimiter(refillRate, limit.Burst), - refillRate: refillRate, - requestsPerMinute: limit.RequestsPerMinute, - burst: limit.Burst, - } - r.buckets[registryKey] = bucket - } - r.mu.Unlock() - } - - now := r.clock.Now() - result := Admission{ - Allowed: bucket.limiter.AllowN(now, 1), - RequestsPerMinute: bucket.requestsPerMinute, - Burst: bucket.burst, - } - if result.Allowed { - return result, nil - } - tokens := bucket.limiter.TokensAt(now) - missing := math.Max(0, 1-tokens) - seconds := missing / float64(bucket.refillRate) - result.RetryAfterSeconds = max(1, int(math.Ceil(seconds))) - return result, nil -} - func rateLimitForDimension(limits *VirtualKeyRateLimits, dimension RateLimitDimension) (*RateLimit, error) { + switch dimension { + case RateLimitDimensionLLM, RateLimitDimensionMCP, RateLimitDimensionAgent: + default: + return nil, fmt.Errorf("unsupported rate limit dimension") + } if limits == nil { return nil, nil } @@ -124,19 +57,3 @@ func rateLimitForDimension(limits *VirtualKeyRateLimits, dimension RateLimitDime return nil, fmt.Errorf("unsupported rate limit dimension %q", dimension) } } - -func (r *limiterRegistry) remove(virtualKeyID string) { - r.mu.Lock() - defer r.mu.Unlock() - for key := range r.buckets { - if key.virtualKeyID == virtualKeyID { - delete(r.buckets, key) - } - } -} - -func (r *limiterRegistry) reset() { - r.mu.Lock() - defer r.mu.Unlock() - r.buckets = map[limiterKey]*limiterBucket{} -} diff --git a/pkg/gateway/virtualkey/selection_test.go b/pkg/gateway/virtualkey/selection_test.go new file mode 100644 index 00000000..caaeb0eb --- /dev/null +++ b/pkg/gateway/virtualkey/selection_test.go @@ -0,0 +1,169 @@ +package virtualkey + +import ( + "context" + "encoding/json" + "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey/limiter" + "sync" + "testing" + "time" + + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/agent-guide/agent-gateway/pkg/extension" +) + +type selectionStore map[string]extension.Envelope + +func (s selectionStore) List(context.Context) ([]extension.Envelope, error) { + var out []extension.Envelope + for _, e := range s { + out = append(out, e) + } + return out, nil +} +func (s selectionStore) Put(_ context.Context, e extension.Envelope, _ bool) error { + s[e.ModuleID] = e + return nil +} +func (s selectionStore) Delete(_ context.Context, id string) error { delete(s, id); return nil } + +type selectionEvents struct { + mu sync.Mutex + events []event.Envelope +} + +func (s *selectionEvents) Publish(_ context.Context, e event.Envelope) bool { + s.mu.Lock() + defer s.mu.Unlock() + s.events = append(s.events, e) + return true +} +func (s *selectionEvents) lastReset() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.events[len(s.events)-1].Mutation.StateReset +} +func TestSelectionRefreshSwitchAndFailureIsolation(t *testing.T) { + b := &testBackend{LimiterBackend: limiter.NewMemoryBackend(nil)} + generic := &extension.Factory{Validate: func(_ context.Context, r json.RawMessage) (json.RawMessage, error) { return r, nil }, Prepare: func(context.Context, extension.PreparedConfig, extension.Snapshot) (extension.Resource, error) { + return &testProvider{b}, nil + }} + r, err := extension.NewRegistry( + extension.Registration{Descriptor: extension.Descriptor{ID: limiter.ModuleID, Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime"}}, Factory: limiter.Factory(true)}, + extension.Registration{Descriptor: extension.Descriptor{ID: "example.fixture.limiter", Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime"}}, Factory: generic}, + extension.Registration{Descriptor: extension.Descriptor{ID: "example.fixture.unrelated", Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime"}}, Factory: generic}) + if err != nil { + t.Fatal(err) + } + ext, err := extension.NewManager(t.Context(), r, selectionStore{}) + if err != nil { + t.Fatal(err) + } + events := &selectionEvents{} + ext.SetPublisher(events) + m := NewVirtualKeyManager(nil) + m.SetExtensions(ext) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = m.Close(ctx) + _ = ext.Close(ctx) + }) + put := func(id, raw string) { + t.Helper() + if err := ext.Put(t.Context(), extension.Envelope{ModuleID: id, SchemaVersion: 1, Config: json.RawMessage(raw)}); err != nil { + t.Fatal(err) + } + } + admit := func(want bool) { + t.Helper() + d, e := m.Admit(t.Context(), hardKey(), RateLimitDimensionLLM) + if e != nil || d.Allowed != want { + t.Fatalf("admission=%+v %v want %v", d, e, want) + } + } + admit(true) + admit(false) + put("example.fixture.limiter", `{}`) + put(limiter.ModuleID, `{"backend_module_id":"example.fixture.limiter","backend_type":"fixture"}`) + if !events.lastReset() { + t.Fatal("switch missing state_reset") + } + admit(true) + admit(false) + old := m.limiters.states[limiterKey{"key", RateLimitDimensionLLM}].request + put("example.fixture.unrelated", `{}`) + if events.lastReset() { + t.Fatal("unrelated refresh reset") + } + admit(false) + put(limiter.ModuleID, `{"backend_module_id":"example.fixture.limiter","backend_type":"fixture","timeout_ms":20,"failure_mode":"open"}`) + if events.lastReset() { + t.Fatal("timeout/failure policy reset") + } + admit(false) + if err := ext.Put(t.Context(), extension.Envelope{ModuleID: limiter.ModuleID, SchemaVersion: 1, Config: json.RawMessage(`{"backend_module_id":"example.fixture.limiter","backend_type":"unknown"}`)}); err == nil { + t.Fatal("unavailable backend accepted") + } + admit(false) + // A switch away and back resets even if no request used the intermediate backend. + if err := ext.Delete(t.Context(), limiter.ModuleID); err != nil { + t.Fatal(err) + } + if !events.lastReset() { + t.Fatal("delete missing reset") + } + put(limiter.ModuleID, `{"backend_module_id":"example.fixture.limiter","backend_type":"fixture"}`) + admit(true) + if _, err := b.LimiterBackend.Admit(t.Context(), old); err == nil { + t.Fatal("old remote generation revived") + } + put("example.fixture.limiter", `{"namespace":"new"}`) + if !events.lastReset() { + t.Fatal("backend config change missing reset") + } + admit(true) +} + +func TestSlowRetirementDoesNotExhaustFreshAdmissionBudget(t *testing.T) { + for _, failure := range []string{"closed", "open"} { + t.Run(failure, func(t *testing.T) { + b := &testBackend{LimiterBackend: limiter.NewMemoryBackend(nil)} + factory := &extension.Factory{Validate: func(_ context.Context, raw json.RawMessage) (json.RawMessage, error) { return raw, nil }, Prepare: func(context.Context, extension.PreparedConfig, extension.Snapshot) (extension.Resource, error) { + return &testProvider{b}, nil + }} + r, err := extension.NewRegistry( + extension.Registration{Descriptor: extension.Descriptor{ID: limiter.ModuleID, Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime"}}, Factory: limiter.Factory(true)}, + extension.Registration{Descriptor: extension.Descriptor{ID: "example.fixture.limiter", Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime"}}, Factory: factory}) + if err != nil { + t.Fatal(err) + } + ext, err := extension.NewManager(t.Context(), r, selectionStore{}) + if err != nil { + t.Fatal(err) + } + m := NewVirtualKeyManager(nil) + m.SetExtensions(ext) + t.Cleanup(func() { _ = m.Close(context.Background()); _ = ext.Close(context.Background()) }) + put := func(id, raw string) { + t.Helper() + if err := ext.Put(t.Context(), extension.Envelope{ModuleID: id, SchemaVersion: 1, Config: json.RawMessage(raw)}); err != nil { + t.Fatal(err) + } + } + put("example.fixture.limiter", `{}`) + put(limiter.ModuleID, `{"backend_module_id":"example.fixture.limiter","backend_type":"fixture","timeout_ms":10,"failure_mode":"`+failure+`"}`) + if d, err := m.Admit(t.Context(), hardKey(), RateLimitDimensionLLM); err != nil || !d.Allowed { + t.Fatal(d, err) + } + b.retireWait.Store(true) + put("example.fixture.limiter", `{"namespace":"new"}`) + if d, err := m.Admit(t.Context(), hardKey(), RateLimitDimensionLLM); err != nil || !d.Allowed || d.Degraded { + t.Fatalf("fresh bucket must consume normally after slow retirement: %+v %v", d, err) + } + if d, err := m.Admit(t.Context(), hardKey(), RateLimitDimensionLLM); err != nil || d.Allowed { + t.Fatalf("fresh burst must already be consumed: %+v %v", d, err) + } + }) + } +} diff --git a/pkg/gatewaybundle/bundle.go b/pkg/gatewaybundle/bundle.go index d47e01bc..b837d76f 100644 --- a/pkg/gatewaybundle/bundle.go +++ b/pkg/gatewaybundle/bundle.go @@ -1,8 +1,11 @@ package gatewaybundle import ( + "bytes" + "context" "encoding/json" "fmt" + "github.com/agent-guide/agent-gateway/pkg/extension" "os" "regexp" "sort" @@ -29,6 +32,7 @@ const ( var envVarPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) type GatewayBundle struct { + Extensions []extension.Envelope `json:"extensions,omitempty"` APIVersion string `json:"apiVersion"` Kind string `json:"kind"` Providers []provider.ProviderConfig `json:"providers,omitempty"` @@ -70,11 +74,20 @@ func DecodeYAML(data []byte) (*GatewayBundle, error) { return nil, fmt.Errorf("decode gateway bundle yaml: %w", err) } - expanded, err := expandEnvValue(normalizeYAMLValue(raw)) + normalized := normalizeYAMLValue(raw) + var extensions any + if root, ok := normalized.(map[string]any); ok { + extensions = root["extensions"] + delete(root, "extensions") + } + expanded, err := expandEnvValue(normalized) if err != nil { return nil, err } if root, ok := expanded.(map[string]any); ok { + if extensions != nil { + root["extensions"] = extensions + } if _, exists := root["providerTypes"]; exists { return nil, fmt.Errorf("providerTypes is not supported in GatewayBundle; configure provider types at gateway startup") } @@ -129,11 +142,13 @@ func EncodeYAML(bundle *GatewayBundle) ([]byte, error) { return nil, fmt.Errorf("encode gateway bundle json: %w", err) } var raw any - if err := json.Unmarshal(jsonBytes, &raw); err != nil { + decoder := json.NewDecoder(bytes.NewReader(jsonBytes)) + decoder.UseNumber() + if err := decoder.Decode(&raw); err != nil { return nil, fmt.Errorf("decode gateway bundle json: %w", err) } removeManagedTimestamps(raw) - yamlBytes, err := yaml.Marshal(raw) + yamlBytes, err := yaml.Marshal(yamlNumbers(raw)) if err != nil { return nil, fmt.Errorf("encode gateway bundle yaml: %w", err) } @@ -159,14 +174,20 @@ func removeManagedTimestamps(value any) { } func (b *GatewayBundle) Validate() error { - return b.validate(false) + return b.validateWithExtensions(false) } func (b *GatewayBundle) ValidateForConfigStore() error { - return b.validate(true) + return b.validateWithExtensions(true) } func (b *GatewayBundle) ValidateForStaticConfig() error { + if b != nil && len(b.Extensions) > 0 { + return fmt.Errorf("extensions are not supported in static config; use the Admin API or agwctl apply") + } + return b.validateStatic() +} +func (b *GatewayBundle) validateStatic() error { if err := b.validate(false); err != nil { return err } @@ -188,6 +209,20 @@ func (b *GatewayBundle) ValidateForStaticConfig() error { return nil } +func (b *GatewayBundle) validateWithExtensions(configStore bool) error { + if b != nil && len(b.Extensions) > 0 { + return fmt.Errorf("extensions require linked registry validation") + } + return b.validate(configStore) +} + +// ValidateWithRegistry validates resources and extension configuration with explicitly linked schemas. +func (b *GatewayBundle) ValidateWithRegistry(ctx context.Context, r *extension.Registry) error { + if err := b.ValidateExtensions(ctx, r); err != nil { + return err + } + return b.validate(true) +} func (b *GatewayBundle) validate(_ bool) error { if b == nil { return fmt.Errorf("gateway bundle is required") @@ -664,3 +699,47 @@ func expandEnvString(s string) (string, error) { return values[match[1]] }), nil } + +// ValidateExtensions applies the linked local schema projection without resolving secrets. +func (b *GatewayBundle) ValidateExtensions(ctx context.Context, r *extension.Registry) error { + if b == nil { + return fmt.Errorf("gateway bundle is required") + } + expanded, err := r.ExpandConfig(b.Extensions) + if err != nil { + return err + } + out, err := r.Validate(ctx, expanded) + if err != nil { + return err + } + b.Extensions = out + return nil +} + +// yamlNumber emits the original JSON number as a numeric YAML scalar rather +// than allowing a float64 conversion or quoting json.Number as a string. +type yamlNumber string + +func (n yamlNumber) MarshalYAML() (any, error) { + tag := "!!int" + if strings.ContainsAny(string(n), ".eE") { + tag = "!!float" + } + return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: string(n)}, nil +} +func yamlNumbers(v any) any { + switch x := v.(type) { + case json.Number: + return yamlNumber(x.String()) + case map[string]any: + for key, child := range x { + x[key] = yamlNumbers(child) + } + case []any: + for i, child := range x { + x[i] = yamlNumbers(child) + } + } + return v +} diff --git a/pkg/gatewaybundle/extensions_test.go b/pkg/gatewaybundle/extensions_test.go new file mode 100644 index 00000000..63b403c3 --- /dev/null +++ b/pkg/gatewaybundle/extensions_test.go @@ -0,0 +1,88 @@ +package gatewaybundle + +import ( + "context" + "encoding/json" + "github.com/agent-guide/agent-gateway/pkg/extension" + "reflect" + "strings" + "testing" +) + +type resource struct{} + +func (resource) Health(context.Context) error { return nil } +func (resource) Close(context.Context) error { return nil } +func TestExtensionReferenceRoundTrip(t *testing.T) { + r, err := extension.NewRegistry(extension.Registration{Descriptor: extension.Descriptor{ID: "example.fixture.secret", Version: "1.0.0", SchemaVersion: 1, Roles: []string{"validation"}}, Factory: &extension.Factory{SecretFields: []string{"/token"}, Validate: func(_ context.Context, b json.RawMessage) (json.RawMessage, error) { return b, nil }, Prepare: func(context.Context, extension.PreparedConfig, extension.Snapshot) (extension.Resource, error) { + return resource{}, nil + }}}) + if err != nil { + t.Fatal(err) + } + t.Setenv("BUNDLE_F2_SECRET", "never-serialize") + t.Setenv("BUNDLE_F2_HOST", "localhost") + bundle, err := DecodeYAML([]byte("apiVersion: gateway.agw/v1alpha1\nkind: GatewayBundle\nextensions:\n- module_id: example.fixture.secret\n schema_version: 1\n config:\n token: {\"$secret_ref\": \"env:BUNDLE_F2_SECRET\"}\n host: '${BUNDLE_F2_HOST}'\n")) + if err != nil { + t.Fatal(err) + } + if err = bundle.ValidateWithRegistry(context.Background(), r); err != nil { + t.Fatal(err) + } + encoded, err := EncodeYAML(bundle) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "never-serialize") || !strings.Contains(string(encoded), "env:BUNDLE_F2_SECRET") || !strings.Contains(string(encoded), "localhost") { + t.Fatalf("bad export: %s", encoded) + } + if err = bundle.Validate(); err == nil { + t.Fatal("validated config without registry") + } + bundle.Extensions[0].Config = json.RawMessage(`{"token":"${BUNDLE_F2_SECRET}"}`) + if err = bundle.ValidateExtensions(context.Background(), r); err == nil { + t.Fatal("expanded literal secret accepted") + } +} + +func TestExtensionIntegerExportRoundTrip(t *testing.T) { + raw := json.RawMessage(`{"positive":9007199254740993,"negative":-9223372036854775807,"signed_max":9223372036854775807,"unsigned_max":18446744073709551615,"nested":[9007199254740993],"fraction":1.25}`) + original := &GatewayBundle{APIVersion: APIVersionV1Alpha1, Kind: KindGatewayBundle, Extensions: []extension.Envelope{{ModuleID: "example.fixture.numeric", SchemaVersion: 1, Config: raw}}} + encoded, err := EncodeYAML(original) + if err != nil { + t.Fatal(err) + } + for _, value := range []string{"9007199254740993", "-9223372036854775807", "9223372036854775807", "18446744073709551615"} { + if !strings.Contains(string(encoded), value) { + t.Fatalf("number lost in export: %s", encoded) + } + } + decoded, err := DecodeYAML(encoded) + if err != nil { + t.Fatal(err) + } + want, err := extension.DecodeObject(raw) + if err != nil { + t.Fatal(err) + } + got, err := extension.DecodeObject(decoded.Extensions[0].Config) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(want, got) { + t.Fatalf("numeric config changed: %s", decoded.Extensions[0].Config) + } +} + +func TestStaticBundleRejectsExtensionsBeforeSchemaValidation(t *testing.T) { + for _, id := range []string{"example.fixture.secret", "example.unlinked"} { + b := &GatewayBundle{APIVersion: APIVersionV1Alpha1, Kind: KindGatewayBundle, Extensions: []extension.Envelope{{ModuleID: id, SchemaVersion: 1, Config: json.RawMessage(`{}`)}}} + if err := b.ValidateForStaticConfig(); err == nil || !strings.Contains(err.Error(), "extensions are not supported in static config") { + t.Fatalf("expected static rejection: %v", err) + } + } + b := &GatewayBundle{APIVersion: APIVersionV1Alpha1, Kind: KindGatewayBundle, Extensions: []extension.Envelope{}} + if err := b.ValidateForStaticConfig(); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/llm/AGENTS.md b/pkg/llm/AGENTS.md index 7746c39a..694026ec 100644 --- a/pkg/llm/AGENTS.md +++ b/pkg/llm/AGENTS.md @@ -61,7 +61,7 @@ Provider registration rules: and its atomic feature set through `provider.RegisterProviderTypeCapabilities(...)` when route candidate filtering must happen before provider construction or credential selection -- add a blank import for the runtime provider package in `cmd/agw/main.go`, `cmd/agwd/main.go`, and `cmd/agwctl/cmd_gateway.go`; agwctl needs it because `validate`/`apply` check `provider_type` against the locally linked provider registry +- add a blank import for the runtime provider package in `cmd/agw/main.go`, `cmd/agwd/main.go`, and `cmd/agwctl/main.go`; agwctl needs it because `validate`/`apply` check `provider_type` against the locally linked provider registry ## Native protocol state diff --git a/scripts/test-extension-consumer b/scripts/test-extension-consumer new file mode 100755 index 00000000..71709b53 --- /dev/null +++ b/scripts/test-extension-consumer @@ -0,0 +1,60 @@ +#!/bin/sh +# Development gate against this checkout. Local replacements exist only in a +# temporary modfile, never in the checked-in consumer go.mod or a go.work. +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +work=$(mktemp -d "${TMPDIR:-/tmp}/agw-extension-consumer.XXXXXX") +trap 'rm -rf "$work"' EXIT HUP INT TERM +cp "$repo/tests/extensionconsumer/go.mod" "$work/go.mod" +cp "$repo/tests/extensionconsumer/go.sum" "$work/go.sum" +export GOWORK=off +case "${1:-checkout}" in + checkout) + if [ "$#" -ne 0 ]; then echo "usage: $0 [candidate COMMIT | release]" >&2; exit 2; fi + go mod edit -modfile="$work/go.mod" -replace="github.com/agent-guide/agent-gateway=$repo" + ;; + candidate) + if [ "$#" -ne 2 ]; then echo "usage: $0 candidate COMMIT" >&2; exit 2; fi + commit=$(git -C "$repo" rev-parse --verify "$2^{commit}") + mkdir "$work/community" + git -C "$repo" archive --format=tar -o "$work/community.tar" "$commit" + tar -xf "$work/community.tar" -C "$work/community" + echo "Candidate Community commit: $commit (immutable archive, non-release proof)" + "$work/community/scripts/test-extension-consumer" + exit 0 + ;; + release) + if [ "$#" -ne 1 ]; then echo "usage: $0 release" >&2; exit 2; fi + cd "$repo/tests/extensionconsumer" + python3 - <<'PYTAG' +import json, pathlib, re, subprocess +m = json.loads(subprocess.check_output(['go', 'mod', 'edit', '-json'])) +versions = [r['Version'] for r in m['Require'] if r['Path'] == 'github.com/agent-guide/agent-gateway'] +if m.get('Replace') or pathlib.Path('go.work').exists() or len(versions) != 1 or not re.fullmatch(r'v\d+\.\d+\.\d+(?:-(?:alpha|beta|rc)\.\d+)?', versions[0]): + raise SystemExit('release requires one published Community tag, no replacements or workspace') +PYTAG + export GOMODCACHE="$work/modules" + export GOCACHE="$work/build-cache" + go mod download -modfile="$work/go.mod" + echo "Released Community dependency: $(go list -modfile="$work/go.mod" -mod=readonly -m github.com/agent-guide/agent-gateway)" + ;; + *) echo "usage: $0 [candidate COMMIT | release]" >&2; exit 2 ;; +esac +cd "$repo/tests/extensionconsumer" +go test -modfile="$work/go.mod" -mod=readonly -race ./... +for command in agw agwd agwctl; do + go build -modfile="$work/go.mod" -mod=readonly -o "$work/bin/$command" "./cmd/$command" +done +AGW_DISTRIBUTION_BIN_DIR="$work/bin" go test -modfile="$work/go.mod" -mod=readonly -race -run TestDistributionProcessContract -count=1 -v . +cd "$repo" +go list -deps -f '{{.ImportPath}}' ./cmd/... > "$work/community-deps" +if grep -E '^github.com/agent-guide/agent-gateway-enterprise(/|$)' "$work/community-deps"; then + echo 'Community commands link private Enterprise code' >&2 + exit 1 +else + import_status=$? + if [ "$import_status" -ne 1 ]; then + exit "$import_status" + fi +fi +AGW_DISTRIBUTION_BIN_DIR="$work/bin" go test ./tests/distribution -count=1 -v diff --git a/scripts/verify-release b/scripts/verify-release index 641516b8..9f6f9545 100755 --- a/scripts/verify-release +++ b/scripts/verify-release @@ -27,6 +27,7 @@ case "${1:-all}" in format_check go mod verify go test ./... + ./scripts/test-extension-consumer race_check go vet ./... make build diff --git a/standalone/server/extensions_test.go b/standalone/server/extensions_test.go new file mode 100644 index 00000000..060c5c66 --- /dev/null +++ b/standalone/server/extensions_test.go @@ -0,0 +1,98 @@ +package server + +import ( + "context" + "encoding/json" + "github.com/agent-guide/agent-gateway/pkg/admin" + "github.com/agent-guide/agent-gateway/pkg/adminclient" + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/extension" + "go.uber.org/zap" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +type extensionResource struct{ value string } + +func (*extensionResource) Health(context.Context) error { return nil } +func (*extensionResource) Close(context.Context) error { return nil } +func extensionAssembly() distribution.Assembly { + a := distribution.CommunityRuntime() + a.Modules = append(a.Modules, extension.Descriptor{ID: "example.fixture.secret", Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime", "validation"}}) + a.Factories = map[string]*extension.Factory{"example.fixture.secret": {SecretFields: []string{"/token"}, Validate: func(_ context.Context, raw json.RawMessage) (json.RawMessage, error) { return raw, nil }, Prepare: func(_ context.Context, c extension.PreparedConfig, _ extension.Snapshot) (extension.Resource, error) { + return &extensionResource{value: c.Secrets["/token"]}, nil + }}} + return a +} +func TestExtensionAdminAndStartup(t *testing.T) { + ctx := context.Background() + a := extensionAssembly() + r, err := a.Registry() + if err != nil { + t.Fatal(err) + } + g, svc, err := bootstrapGateway(ctx, Options{Assembly: a, Registry: r, ConfigStorePath: filepath.Join(t.TempDir(), "db.sqlite")}, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer g.Close() + defer svc.Close() + srv := httptest.NewServer(admin.NewHandler(g, zap.NewNop())) + defer srv.Close() + client := adminclient.New(adminclient.Config{BaseURL: srv.URL}) + manifest, err := client.Manifest(ctx) + if err != nil || len(manifest.Modules) != len(a.Modules) { + t.Fatal(err) + } + e := extension.Envelope{ModuleID: "example.fixture.secret", SchemaVersion: 1, Config: json.RawMessage(`{"token":{"$secret_ref":"env:STANDALONE_F2_TEST_TOKEN"}}`)} + t.Setenv("STANDALONE_F2_TEST_TOKEN", "do-not-export") + if _, err = client.PutExtension(ctx, e); err != nil { + t.Fatal(err) + } + lease, err := g.Extensions().Acquire() + if err != nil { + t.Fatal(err) + } + res, _ := lease.Snapshot.Resource(e.ModuleID) + if res.(*extensionResource).value != "do-not-export" { + t.Fatal("resolution missing") + } + lease.Release() + exported, err := client.ListExtensions(ctx) + if err != nil || len(exported) != 1 { + t.Fatal(err) + } + raw, _ := json.Marshal(exported) + if strings.Contains(string(raw), "do-not-export") { + t.Fatalf("bad envelope: %s", raw) + } + _ = os.Unsetenv("STANDALONE_F2_TEST_TOKEN") + if _, err = client.PutExtension(ctx, e); err == nil || strings.Contains(err.Error(), "do-not-export") { + t.Fatal("missing reference accepted or leaked") + } + if len(g.Extensions().List()) != 1 { + t.Fatal("failed apply removed old state") + } + if err = client.DeleteExtension(ctx, e.ModuleID); err != nil { + t.Fatal(err) + } + e.ModuleID = "example.fixture.unknown" + if _, err = client.PutExtension(ctx, e); err == nil { + t.Fatal("unknown accepted") + } +} + +func TestStaticBundleCannotConfigureExtensions(t *testing.T) { + path := filepath.Join(t.TempDir(), "static.yaml") + raw := []byte("apiVersion: gateway.agw/v1alpha1\nkind: GatewayBundle\nextensions:\n- module_id: example.fixture.secret\n schema_version: 1\n config: {}\n") + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + _, err := loadStaticConfig(t.Context(), Options{StaticConfigPath: path, Assembly: extensionAssembly()}) + if err == nil || !strings.Contains(err.Error(), "extensions are not supported in static config") { + t.Fatalf("expected static extension rejection, got %v", err) + } +} diff --git a/standalone/server/public_event_test.go b/standalone/server/public_event_test.go new file mode 100644 index 00000000..0b452489 --- /dev/null +++ b/standalone/server/public_event_test.go @@ -0,0 +1,60 @@ +package server + +import ( + "context" + "errors" + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "net" + "net/http" + "path/filepath" + "strings" + "testing" + "time" +) + +type failingCloseSink struct{} + +func (failingCloseSink) Write(context.Context, event.Envelope) error { return nil } +func (failingCloseSink) Flush(context.Context) error { return nil } +func (failingCloseSink) Close(context.Context) error { return errors.New("private-sink-error") } +func TestRunReportsPublicSinkShutdownFailure(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := listener.Addr().String() + _ = listener.Close() + a := distribution.CommunityRuntime() + a.EventSinks = []event.Registration{{ModuleID: "online.agentguide.community.gateway", New: func(context.Context) (event.Sink, error) { return failingCloseSink{}, nil }}} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + path := filepath.Join(t.TempDir(), "config.db") + done := make(chan error, 1) + go func() { done <- Run(ctx, Options{Assembly: a, Addr: addr, AdminAddr: addr, ConfigStorePath: path}) }() + client := &http.Client{Timeout: 100 * time.Millisecond} + deadline := time.After(5 * time.Second) + for { + resp, err := client.Get("http://" + addr + "/admin/manifest") + if err == nil { + _ = resp.Body.Close() + break + } + select { + case err := <-done: + t.Fatal(err) + case <-deadline: + t.Fatal("server readiness") + case <-time.After(10 * time.Millisecond): + } + } + cancel() + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "sink_failure") || strings.Contains(err.Error(), "private-sink-error") { + t.Fatalf("shutdown result %v", err) + } + case <-time.After(time.Second): + t.Fatal("shutdown timeout") + } +} diff --git a/standalone/server/server.go b/standalone/server/server.go index f6134c6b..810dc266 100644 --- a/standalone/server/server.go +++ b/standalone/server/server.go @@ -5,6 +5,9 @@ import ( "crypto/subtle" "errors" "fmt" + "github.com/agent-guide/agent-gateway/internal/distributionmetrics" + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/extension" "net" "net/http" "strings" @@ -36,6 +39,8 @@ import ( const shutdownTimeout = 10 * time.Second type Options struct { + Registry *extension.Registry + Assembly distribution.Assembly Addr string AdminAddr string ConfigStorePath string @@ -44,7 +49,7 @@ type Options struct { CredentialRefreshCommand string CredentialRefreshArgs []string AdminBasicAuthHash string - Metrics usage.Config + Metrics distribution.MetricsConfig } func (o *Options) setDefaults() { @@ -65,7 +70,7 @@ func (o *Options) setDefaults() { } } -func Run(ctx context.Context, opts Options) error { +func Run(ctx context.Context, opts Options) (result error) { opts.setDefaults() if err := configureProviderTypes(opts.ProviderTypes); err != nil { return err @@ -81,12 +86,16 @@ func Run(ctx context.Context, opts Options) error { if err != nil { return err } - defer agentGateway.Close() if usageService != nil { usageService.Start() - defer func() { _ = usageService.Close() }() + defer func() { + if err := usageService.Close(); err != nil { + result = errors.Join(result, fmt.Errorf("close event pipeline: %w", err)) + } + }() } + defer agentGateway.Close() adminHandler, err := protectAdminHandler(admin.NewHandler(agentGateway, logger.Named("admin")), opts.AdminBasicAuthHash) if err != nil { return fmt.Errorf("configure admin auth: %w", err) @@ -165,6 +174,18 @@ func newDispatchHandler(agentGateway *gateway.AgentGateway, logger *zap.Logger) } func bootstrapGateway(ctx context.Context, opts Options, logger *zap.Logger) (*gateway.AgentGateway, *usage.UsageService, error) { + if opts.Registry == nil { + a := opts.Assembly + if a.Modules == nil { + a = distribution.CommunityRuntime() + opts.Assembly = a + } + r, err := a.Registry() + if err != nil { + return nil, nil, err + } + opts.Registry = r + } staticConfig, err := loadStaticConfig(ctx, opts) if err != nil { return nil, nil, err @@ -177,7 +198,20 @@ func bootstrapGateway(ctx context.Context, opts Options, logger *zap.Logger) (*g if err := schema.RegisterDefaultStores(configstoreBackend); err != nil { return nil, nil, err } - usageService := newUsageService(configstoreBackend, logger, opts.Metrics) + usageService := newUsageService(configstoreBackend, logger, distributionmetrics.From(opts.Metrics)) + bootstrapped := false + defer func() { + if !bootstrapped { + _ = usageService.Close() + } + }() + policy, err := opts.Assembly.AttributionPolicy() + if err != nil { + return nil, nil, err + } + if err := usageService.ConfigurePublic(policy, opts.Assembly.EventSinks); err != nil { + return nil, nil, err + } credentialStore, err := configstoreBackend.Get(schema.StoreCredentials) if err != nil { @@ -195,6 +229,7 @@ func bootstrapGateway(ctx context.Context, opts Options, logger *zap.Logger) (*g } agentGateway := gateway.NewAgentGateway() if err := agentGateway.Bootstrap(ctx, gateway.BootstrapOptions{ + ExtensionRegistry: opts.Registry, Distribution: opts.Assembly, EventPublisher: usageService, StaticLLMRoutes: staticConfig.LLMRoutes, StaticProviders: staticConfig.Providers, ConfigStoreBackend: configstoreBackend, @@ -204,9 +239,10 @@ func bootstrapGateway(ctx context.Context, opts Options, logger *zap.Logger) (*g UsageQuery: usageService.Query(), UsageStats: usageService, UsagePrometheus: usageService.Prometheus(), - UsageConfig: opts.Metrics.Normalized(), + UsageConfig: distributionmetrics.From(opts.Metrics).Normalized(), Logger: logger, }); err != nil { + agentGateway.Close() return nil, nil, fmt.Errorf("bootstrap agent gateway: %w", err) } if attribution := usageService.Attribution(); attribution != nil { @@ -214,6 +250,7 @@ func bootstrapGateway(ctx context.Context, opts Options, logger *zap.Logger) (*g attribution.Set(manager) } } + bootstrapped = true return agentGateway, usageService, nil } @@ -221,14 +258,14 @@ func newUsageService(backend configstore.ConfigStoreBackend, logger *zap.Logger, einotap.Register() dbProvider, ok := backend.(usage.SQLDBProvider) if !ok { - return usage.NewUsageService(nil, nil) + return usage.NewUsageService(pipeline.NewEventPipeline(4096), nil) } sink, query, err := pipeline.NewSQLiteSink(dbProvider, cfg) if err != nil { if logger != nil { logger.Warn("usage metrics sqlite sink unavailable", zap.Error(err)) } - return usage.NewUsageService(nil, nil) + return usage.NewUsageService(pipeline.NewEventPipeline(4096), nil) } promSink := pipeline.NewPrometheusSink() sinks := []pipeline.Sink{sink, promSink} @@ -261,7 +298,6 @@ type staticConfig struct { } func loadStaticConfig(ctx context.Context, opts Options) (*staticConfig, error) { - _ = ctx cfg := &staticConfig{ Providers: map[string]provider.Provider{}, } diff --git a/tests/distribution/extensions_test.go b/tests/distribution/extensions_test.go new file mode 100644 index 00000000..2acfb226 --- /dev/null +++ b/tests/distribution/extensions_test.go @@ -0,0 +1,383 @@ +package distribution_test + +import ( + "bytes" + "context" + "encoding/json" + "github.com/agent-guide/agent-gateway/pkg/adminclient" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/agent-guide/agent-gateway/pkg/extension" + "github.com/agent-guide/agent-gateway/pkg/extension/contracttest" + "golang.org/x/crypto/bcrypt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "syscall" + "testing" + "time" +) + +func TestExtensionProcesses(t *testing.T) { + dir := os.Getenv("AGW_DISTRIBUTION_BIN_DIR") + if dir == "" { + t.Skip("consumer binaries required") + } + dir, err := filepath.Abs(dir) + if err != nil { + t.Fatal(err) + } + raw, err := exec.Command(filepath.Join(dir, "agw"), "manifest").Output() + if err != nil { + t.Fatal(err) + } + var linked distribution.Assembly + if err = json.Unmarshal(raw, &linked); err != nil { + t.Fatal(err) + } + fixture := false + for _, d := range linked.Modules { + if d.ID == "example.fixture.mutable" { + fixture = true + } + } + if !fixture { + t.Skip("external configuration fixture is not linked") + } + for _, binary := range []string{"agw", "agwd"} { + t.Run(binary, func(t *testing.T) { + work := t.TempDir() + eventPath := filepath.Join(work, "events.jsonl") + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := listener.Addr().String() + _ = listener.Close() + initial := extension.Envelope{ModuleID: "example.fixture.entries", SchemaVersion: 1, Config: json.RawMessage(`{"message":"persisted","token":{"$secret_ref":"env:AGW_FIXTURE_SECRET"}}`)} + db := filepath.Join(work, "config.db") + path := filepath.Join(work, "config.json") + hash, err := bcrypt.GenerateFromPassword([]byte("fixture-pass"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + args := []string{"--addr", addr, "--admin-addr", addr, "--admin-basic-auth-hash", "fixture:" + string(hash), "--config-store", db, "--static-config", path} + var config any = map[string]any{"apiVersion": "gateway.agw/v1alpha1", "kind": "GatewayBundle"} + if binary == "agw" { + args = []string{"run", "--config", path} + config = map[string]any{"admin": map[string]any{"disabled": true}, "apps": map[string]any{ + "agent_gateway": map[string]any{"config_store": map[string]any{"sqlite": map[string]string{"sqlite_path": db}}}, + "http": map[string]any{"servers": map[string]any{"test": map[string]any{"listen": []string{addr}, "routes": []any{ + map[string]any{"match": []any{map[string]any{"path": []string{"/admin/*"}}}, "handle": []any{map[string]any{"handler": "authentication", "providers": map[string]any{"http_basic": map[string]any{"accounts": []any{map[string]string{"username": "fixture", "password": string(hash)}}}}}, map[string]string{"handler": "agent_gateway_admin"}}}, + map[string]any{"handle": []any{map[string]any{"handler": "agent_route_dispatcher", "api_handlers": map[string]any{"cc": map[string]any{}}}}}, + }}}}, + }} + } + encoded, _ := json.Marshal(config) + if err = os.WriteFile(path, encoded, 0600); err != nil { + t.Fatal(err) + } + logPath := filepath.Join(work, "process.log") + log, err := os.Create(logPath) + if err != nil { + t.Fatal(err) + } + defer log.Close() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + command := exec.CommandContext(ctx, filepath.Join(dir, binary), args...) + command.Dir = work + for _, entry := range os.Environ() { + if !strings.HasPrefix(entry, "AGW_FIXTURE_EVENT_FILE=") && !strings.HasPrefix(entry, "AGW_FIXTURE_SECRET=") && !strings.HasPrefix(entry, "AGW_ADMIN_BASIC_AUTH_HASH=") { + command.Env = append(command.Env, entry) + } + } + command.Env = append(command.Env, "AGW_FIXTURE_SECRET=process-secret-never-export", "AGW_ADMIN_BASIC_AUTH_HASH=", "AGW_FIXTURE_EVENT_FILE="+eventPath) + command.Stdout = log + command.Stderr = log + if err = command.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- command.Wait() }() + finished := false + defer func() { + if !finished { + _ = command.Process.Kill() + <-done + } + }() + client := adminclient.New(adminclient.Config{BaseURL: "http://" + addr, BasicAuth: "fixture:fixture-pass", HTTPClient: &http.Client{Timeout: time.Second}}) + deadline := time.After(10 * time.Second) + var manifest *distribution.Assembly + for { + manifest, err = client.Manifest(ctx) + if err == nil { + break + } + select { + case err := <-done: + finished = true + logs, _ := os.ReadFile(logPath) + t.Fatalf("startup: %v %s", err, logs) + case <-deadline: + t.Fatal("startup timeout") + case <-time.After(25 * time.Millisecond): + } + } + if !reflect.DeepEqual(linked, *manifest) { + t.Fatal("Caddy/standalone manifest mismatch") + } + + if _, err = client.PutExtension(ctx, initial); err != nil { + t.Fatal(err) + } + httpClient := &http.Client{Timeout: 3 * time.Second} + send := func(req *http.Request) (*http.Response, error) { + req.SetBasicAuth("fixture", "fixture-pass") + return httpClient.Do(req) + } + moduleURL := "http://" + addr + "/admin/extensions/" + initial.ModuleID + unauth, err := httpClient.Get(moduleURL + "/status") + if err != nil { + t.Fatal(err) + } + unauth.Body.Close() + if unauth.StatusCode != 401 { + t.Fatalf("contributor bypassed mount authentication: %d", unauth.StatusCode) + } + status := func() struct { + Message string `json:"message"` + Resets int64 `json:"resets"` + } { + req, _ := http.NewRequest("GET", moduleURL+"/status", nil) + resp, err := send(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var value struct { + Message string `json:"message"` + Resets int64 `json:"resets"` + } + if resp.StatusCode != 200 { + t.Fatalf("contributor status: %d", resp.StatusCode) + } + if err = json.NewDecoder(resp.Body).Decode(&value); err != nil { + t.Fatal(err) + } + if value.Message != "persisted" { + t.Fatalf("prepared config missing: %+v", value) + } + return value + } + before := status() + contracttest.AdminBody(t, contracttest.AdminBodyFixture{URL: moduleURL + "/reset", Method: "POST", Body: []byte(`{}`), SuccessStatus: 200, Send: send, CheckUnchanged: func(t *testing.T) { + if status().Resets != before.Resets { + t.Fatal("oversized contributed write committed") + } + }}) + if status().Resets != before.Resets+1 { + t.Fatal("supported contributed mutation missing") + } + contracttest.AdminBody(t, contracttest.AdminBodyFixture{URL: "http://" + addr + "/admin/llm/providers", Method: "POST", Body: []byte(`{"id":"body-proof","provider_type":"openai","base_url":"https://example.com"}`), SuccessStatus: 201, Send: send, CheckUnchanged: func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/admin/llm/providers/body-proof", nil) + resp, err := send(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 404 { + t.Fatalf("oversized Community write committed: %d", resp.StatusCode) + } + }}) + current, err := client.GetExtension(ctx, initial.ModuleID) + if err != nil || !bytes.Equal(current.Config, initial.Config) { + t.Fatalf("persisted config: %v %+v", err, current) + } + if _, err = client.PutExtension(ctx, initial); err != nil { + t.Fatal(err) + } + unknown := initial + unknown.ModuleID = "example.fixture.unlinked" + if _, err = client.PutExtension(ctx, unknown); err == nil { + t.Fatal("unknown module accepted") + } + dynamic := initial + dynamic.ModuleID = "example.fixture.mutable" + dynamic.Config = json.RawMessage(`{"message":"dynamic","token":{"$secret_ref":"env:AGW_FIXTURE_SECRET"}}`) + if _, err = client.PutExtension(ctx, dynamic); err != nil { + t.Fatal(err) + } + checkDynamicRoute := func(wantStatus int, wantMessage string) { + t.Helper() + req, _ := http.NewRequest("GET", "http://"+addr+"/admin/extensions/"+dynamic.ModuleID+"/status", nil) + resp, err := send(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != wantStatus || (wantMessage != "" && !bytes.Contains(body, []byte(wantMessage))) { + t.Fatalf("dynamic route generation: %d %s", resp.StatusCode, body) + } + } + checkDynamicRoute(200, "dynamic") + applyPath := filepath.Join(work, "apply.yaml") + apply := `apiVersion: gateway.agw/v1alpha1 +kind: GatewayBundle +extensions: +- module_id: example.fixture.entries + schema_version: 1 + config: + message: persisted + token: {"$secret_ref": "env:AGW_FIXTURE_SECRET"} +- module_id: example.fixture.mutable + schema_version: 1 + config: + message: applied + token: {"$secret_ref": "env:AGW_FIXTURE_SECRET"} +` + if err = os.WriteFile(applyPath, []byte(apply), 0600); err != nil { + t.Fatal(err) + } + runCLI := func(args ...string) []byte { + t.Helper() + cmd := exec.CommandContext(ctx, filepath.Join(dir, "agwctl"), append([]string{"--admin-addr", "http://" + addr, "--admin-basic-auth", "fixture:fixture-pass"}, args...)...) + cmd.Dir = work + result, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("CLI %v: %v %s", args, err, result) + } + return result + } + runCLI("validate", "--file", applyPath) + runCLI("apply", "--file", applyPath) + exported := runCLI("export") + if strings.Contains(string(exported), "process-secret-never-export") || !strings.Contains(string(exported), "env:AGW_FIXTURE_SECRET") || strings.Contains(string(exported), "source:") { + t.Fatalf("unsafe or incomplete export: %s", exported) + } + if err = command.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatal(err) + } + select { + case err = <-done: + finished = true + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("restart shutdown timeout") + } + processEnv := command.Env + command = exec.CommandContext(ctx, filepath.Join(dir, binary), args...) + command.Dir = work + command.Env = processEnv + command.Stdout, command.Stderr = log, log + if err = command.Start(); err != nil { + t.Fatal(err) + } + finished = false + go func() { done <- command.Wait() }() + restartDeadline := time.After(10 * time.Second) + for { + if _, err = client.Manifest(ctx); err == nil { + break + } + select { + case err = <-done: + finished = true + t.Fatalf("restart: %v", err) + case <-restartDeadline: + t.Fatal("restart startup timeout") + case <-time.After(25 * time.Millisecond): + } + } + if status().Message != "persisted" { + t.Fatal("initial extension not restored") + } + current, err = client.GetExtension(ctx, dynamic.ModuleID) + if err != nil || !strings.Contains(string(current.Config), "applied") { + t.Fatalf("apply missing: %v", err) + } + checkDynamicRoute(200, "applied") + if err = client.DeleteExtension(ctx, initial.ModuleID); err == nil { + t.Fatal("required dependency deleted") + } + if err = client.DeleteExtension(ctx, dynamic.ModuleID); err != nil { + t.Fatal(err) + } + checkDynamicRoute(503, "extension_unavailable") + checkLimiterProcess(t, ctx, "http://"+addr, client, send) + if err = command.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + finished = true + if err != nil { + logs, _ := os.ReadFile(logPath) + t.Fatalf("shutdown: %v %s", err, logs) + } + case <-ctx.Done(): + t.Fatal("shutdown timeout") + } + + raw, err := os.ReadFile(eventPath) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{"process-secret-never-export", "env:AGW_FIXTURE_SECRET", "$secret_ref", "\"message\"", "\"token\""} { + if bytes.Contains(raw, []byte(secret)) { + t.Fatal("private configuration escaped public DTO") + } + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + seen := map[string]bool{} + count := 0 + limiterSeen := map[string]bool{} + resetSeen := false + for { + var e event.Envelope + err := decoder.Decode(&e) + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + if err := event.Validate(e, attribution.Policy{}); err != nil { + t.Fatal(err) + } + if e.Family == "limiter.admission" { + limiterSeen[e.Outcome+"/"+e.Reason] = true + if e.Attribution.Tenant != "" || e.Attribution.VirtualKeyID != "limiter-proof" || e.Attribution.RouteID != "limiter-proof" { + t.Fatalf("admission attribution %+v", e.Attribution) + } + continue + } + if e.Family != "extension.mutation" { + t.Fatal("unexpected public family") + } + resetSeen = resetSeen || e.Mutation.StateReset + seen[e.Outcome+"/"+e.Mutation.Operation] = true + count++ + } + if !resetSeen || !limiterSeen["allow/"] || !limiterSeen["deny/rate_limited"] || !limiterSeen["deny/limiter_unavailable"] || !limiterSeen["degraded/limiter_unavailable"] { + t.Fatalf("limiter event evidence %v reset=%v", limiterSeen, resetSeen) + } + if count < 4 || !seen["success/put"] || !seen["failure/put"] || !seen["success/delete"] || !seen["failure/delete"] || !seen["success/reset"] { + t.Fatalf("event lifecycle evidence %d %v", count, seen) + } + }) + } +} diff --git a/tests/distribution/limiter_test.go b/tests/distribution/limiter_test.go new file mode 100644 index 00000000..4dcd2e74 --- /dev/null +++ b/tests/distribution/limiter_test.go @@ -0,0 +1,122 @@ +package distribution_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "github.com/agent-guide/agent-gateway/pkg/adminclient" + "github.com/agent-guide/agent-gateway/pkg/extension" + "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey" + "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey/limiter" +) + +func checkLimiterProcess(t *testing.T, ctx context.Context, base string, client *adminclient.Client, send func(*http.Request) (*http.Response, error)) { + t.Helper() + adminWrite := func(method, path, body string, want int) []byte { + t.Helper() + req, _ := http.NewRequestWithContext(ctx, method, base+path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := send(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != want { + t.Fatalf("%s %s: %d %s", method, path, resp.StatusCode, raw) + } + return raw + } + adminWrite("POST", "/admin/llm/routes", `{"id":"limiter-proof","kind":"llm","protocol":"cc","auth_policy":{"require_virtual_key":true},"match_policy":{"path_prefix":"/limiter-proof"},"target_policy":{"provider_target":{"provider_id":"body-proof"}}}`, 201) + keyRaw := adminWrite("POST", "/admin/virtual_keys", `{"id":"limiter-proof","allowed_route_ids":["limiter-proof"],"rate_limits":{"llm":{"requests_per_minute":1,"burst":1}}}`, 201) + var key virtualkey.VirtualKey + if json.Unmarshal(keyRaw, &key) != nil || key.Key == "" { + t.Fatal("key not generated") + } + put := func(id, raw string) { + t.Helper() + if _, err := client.PutExtension(ctx, extension.Envelope{ModuleID: id, SchemaVersion: 1, Config: json.RawMessage(raw)}); err != nil { + t.Fatal(err) + } + } + put("example.fixture.limiter", `{}`) + selection := `{"backend_module_id":"example.fixture.limiter","backend_type":"fixture","timeout_ms":10,"failure_mode":"closed"}` + put(limiter.ModuleID, selection) + dataClient := &http.Client{Timeout: time.Second} + admit := func(want int) { + t.Helper() + req, _ := http.NewRequestWithContext(ctx, "POST", base+"/limiter-proof/v1/messages/count_tokens", strings.NewReader(`{"model":"proof","messages":[{"role":"user","content":"hello"}]}`)) + req.Header.Set("Authorization", "Bearer "+key.Key) + req.Header.Set("X-Tenant-ID", "forged") + resp, err := dataClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != want { + t.Fatalf("admission status=%d want=%d body=%s", resp.StatusCode, want, raw) + } + if want == 429 { + n, err := strconv.Atoi(resp.Header.Get("Retry-After")) + if err != nil || n < 1 { + t.Fatal("invalid retry-after") + } + } + if want == 503 && (!bytes.Contains(raw, []byte(`"error":"limiter_unavailable"`)) || len(raw) > 1024 || bytes.Contains(raw, []byte("private"))) { + t.Fatal("unbounded backend diagnostic", string(raw)) + } + } + admit(200) + admit(429) + put("example.fixture.mutable", `{"message":"unrelated-refresh"}`) + admit(429) + // Metadata-only key writes preserve its consumed bucket. + adminWrite("PUT", "/admin/virtual_keys/limiter-proof", `{"tag":"updated","allowed_route_ids":["limiter-proof"],"rate_limits":{"llm":{"requests_per_minute":1,"burst":1}}}`, 200) + admit(429) + if _, err := client.PutExtension(ctx, extension.Envelope{ModuleID: limiter.ModuleID, SchemaVersion: 1, Config: json.RawMessage(`{"backend_module_id":"example.fixture.limiter","backend_type":"unavailable"}`)}); err == nil { + t.Fatal("unavailable selection published") + } + admit(429) + adminWrite("PUT", "/admin/virtual_keys/limiter-proof", `{"allowed_route_ids":["limiter-proof"],"rate_limits":{"llm":{"requests_per_minute":1,"burst":2}}}`, 200) + admit(200) + admit(200) + admit(429) + put("example.fixture.limiter", `{"mode":"error"}`) + admit(503) + put(limiter.ModuleID, strings.Replace(selection, `"closed"`, `"open"`, 1)) + admit(200) + put("example.fixture.limiter", `{"mode":"timeout"}`) + put(limiter.ModuleID, selection) + started := time.Now() + admit(503) + if time.Since(started) > 500*time.Millisecond { + t.Fatal("backend exceeded admission budget") + } + put(limiter.ModuleID, strings.Replace(selection, `"closed"`, `"open"`, 1)) + admit(200) + put("example.fixture.limiter", `{"mode":"ambiguous"}`) + put(limiter.ModuleID, selection) + admit(503) + admit(503) + admit(429) + // Switching to the local authority resets state without transferring tokens. + put(limiter.ModuleID, `{}`) + admit(200) + admit(200) + admit(429) + metrics := string(adminWrite("GET", "/admin/metrics/prometheus", "", 200)) + if !strings.Contains(metrics, `outcome="degraded",backend_type="fixture"`) || strings.Contains(metrics, `virtual_key_id=`) || strings.Contains(metrics, `tenant=`) { + t.Fatal("limiter counter labels", metrics) + } +} diff --git a/tests/distribution/process_test.go b/tests/distribution/process_test.go new file mode 100644 index 00000000..4e4cccdc --- /dev/null +++ b/tests/distribution/process_test.go @@ -0,0 +1,215 @@ +package distribution_test + +import ( + "bytes" + "context" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +// Set AGW_DISTRIBUTION_BIN_DIR to test already-built binaries without rebuilding +// them in each package test. This suite also runs against the external consumer. +func TestCommandProcesses(t *testing.T) { + dir := os.Getenv("AGW_DISTRIBUTION_BIN_DIR") + if dir == "" { + t.Skip("set AGW_DISTRIBUTION_BIN_DIR to built agw, agwd and agwctl") + } + dir, err := filepath.Abs(dir) + if err != nil { + t.Fatal(err) + } + for _, bin := range []string{"agw", "agwd", "agwctl"} { + t.Run(bin, func(t *testing.T) { + for _, tc := range []struct { + args []string + code int + text string + }{ + {[]string{"--help"}, 0, "Usage:"}, + {[]string{"--definitely-invalid-flag"}, 1, "unknown flag"}, + } { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, filepath.Join(dir, bin), tc.args...) + cmd.Dir = t.TempDir() + var out, stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + err := cmd.Run() + code := 0 + if err != nil { + ee, ok := err.(*exec.ExitError) + if !ok { + t.Fatal(err) + } + code = ee.ExitCode() + } + if code != tc.code { + t.Fatalf("%v: exit=%d, want %d; stderr=%s", tc.args, code, tc.code, &stderr) + } + if !strings.Contains(out.String()+stderr.String(), tc.text) { + t.Fatalf("%v: missing %q in stdout=%s stderr=%s", tc.args, tc.text, &out, &stderr) + } + if tc.code != 0 && out.Len() != 0 { + t.Fatalf("failure unexpectedly wrote stdout: %s", &out) + } + } + }) + } +} + +func TestDotenvProcessPrecedence(t *testing.T) { + dir := os.Getenv("AGW_DISTRIBUTION_BIN_DIR") + if dir == "" { + t.Skip("set AGW_DISTRIBUTION_BIN_DIR") + } + dir, err := filepath.Abs(dir) + if err != nil { + t.Fatal(err) + } + for _, bin := range []string{"agwd", "agwctl"} { + t.Run(bin, func(t *testing.T) { + work := t.TempDir() + for file, contents := range map[string]string{ + ".env.local": "AGW_ADMIN_ADDR=http://local:1234\nAGW_ADMIN_BASIC_AUTH_HASH=local:hash\n", + ".env": "AGW_ADMIN_ADDR=http://dotenv:2345\nAGW_ADMIN_BASIC_AUTH_HASH=dotenv:hash\n", + } { + if err := os.WriteFile(filepath.Join(work, file), []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + for _, shell := range []bool{false, true} { + cmd := exec.Command(filepath.Join(dir, bin), "--help") + cmd.Dir = work + for _, e := range os.Environ() { + if !strings.HasPrefix(e, "AGW_ADMIN_ADDR=") && !strings.HasPrefix(e, "AGW_ADMIN_BASIC_AUTH_HASH=") { + cmd.Env = append(cmd.Env, e) + } + } + want := "http://local:1234" + if bin == "agwd" { + want = "local:hash" + } + if shell { + cmd.Env = append(cmd.Env, "AGW_ADMIN_ADDR=http://shell:3456", "AGW_ADMIN_BASIC_AUTH_HASH=shell:hash") + want = "http://shell:3456" + if bin == "agwd" { + want = "shell:hash" + } + } + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("help: %v: %s", err, out) + } + if !strings.Contains(string(out), want) { + t.Fatalf("want %q in %s", want, out) + } + } + if err := os.WriteFile(filepath.Join(work, ".env.local"), []byte("invalid='unterminated"), 0600); err != nil { + t.Fatal(err) + } + cmd := exec.Command(filepath.Join(dir, bin), "--help") + cmd.Dir = work + var out, stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil || out.Len() != 0 || !strings.Contains(stderr.String(), "load .env.local:") { + t.Fatalf("malformed dotenv: err=%v stdout=%s stderr=%s", err, &out, &stderr) + } + }) + } +} + +func TestServerSignals(t *testing.T) { + dir := os.Getenv("AGW_DISTRIBUTION_BIN_DIR") + if dir == "" { + t.Skip("set AGW_DISTRIBUTION_BIN_DIR") + } + dir, err := filepath.Abs(dir) + if err != nil { + t.Fatal(err) + } + // A caller's daemon auth configuration must not configure this anonymous fixture. + t.Setenv("AGW_ADMIN_BASIC_AUTH_HASH", "review-invalid-parent-auth") + for _, bin := range []string{"agw", "agwd"} { + for _, sig := range []os.Signal{os.Interrupt, syscall.SIGTERM} { + t.Run(fmt.Sprintf("%s/%s", bin, sig), func(t *testing.T) { + work := t.TempDir() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := listener.Addr().String() + listener.Close() + args := []string{"--addr", addr, "--admin-addr", addr, "--admin-basic-auth-hash", "", "--config-store", filepath.Join(work, "config.db")} + readyURL := "http://" + addr + "/admin/virtual_keys" + if bin == "agw" { + config := fmt.Sprintf(`{"admin":{"disabled":true},"apps":{"http":{"servers":{"test":{"listen":[%q],"routes":[{"handle":[{"handler":"static_response","body":"ready"}]}]}}}}}`, addr) + path := filepath.Join(work, "caddy.json") + if err := os.WriteFile(path, []byte(config), 0600); err != nil { + t.Fatal(err) + } + args = []string{"run", "--config", path} + readyURL = "http://" + addr + "/" + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, filepath.Join(dir, bin), args...) + cmd.Dir = work + var out, stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + defer func() { _ = cmd.Process.Kill() }() + client := http.Client{Timeout: 200 * time.Millisecond} + deadline := time.After(10 * time.Second) + ready := false + for !ready { + res, err := client.Get(readyURL) + if err == nil { + res.Body.Close() + ready = res.StatusCode == http.StatusOK + } + if ready { + break + } + select { + case err := <-done: + t.Fatalf("exited before ready: %v: %s", err, &stderr) + case <-deadline: + _ = cmd.Process.Kill() + <-done + t.Fatalf("startup timeout: %s", &stderr) + case <-time.After(25 * time.Millisecond): + } + } + if err := cmd.Process.Signal(sig); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatalf("signal shutdown: %v: %s", err, &stderr) + } + case <-ctx.Done(): + _ = cmd.Process.Kill() + <-done + t.Fatalf("shutdown deadline: %s", &stderr) + } + }) + } + } +} diff --git a/tests/extensionconsumer/README.md b/tests/extensionconsumer/README.md new file mode 100644 index 00000000..0748b533 --- /dev/null +++ b/tests/extensionconsumer/README.md @@ -0,0 +1,70 @@ +# External distribution consumer + +This is a separate Go module, not a package in the Community module. Its three +thin mains own their imports and construct public Caddy, daemon and CLI entries. +It declares fixture edition metadata and two independently owned modules with +strict validation schemas, secret-reference fields, dependency declarations and +prepared runtime resources. CLI factories contain only validation callbacks. + +From the repository root, run: + +```sh +make test-extension-consumer +``` + +The gate disables workspace resolution (`GOWORK=off`) and creates a temporary +modfile with a local replacement to the current Community checkout. It runs +external signature/assembly tests with the race detector, builds all three +binaries, checks Community dependencies for the known private module path, and +runs help, invalid-flag, dotenv and SIGINT/SIGTERM process contracts. It also +starts both server modes, checks manifest parity, ConfigStore-only envelopes and restart recovery, +dynamic PUT/apply/export/delete and token preservation without setting the +secret environment variable in the CLI process. The temporary +replacement and build outputs are removed on exit. Neither `go.work` nor a local +replacement belongs in this module's checked-in files. + +The checked-in dependency declares the target Community release `v0.7.0`. +Until that tag is published, run the development gate above, which tests the +current checkout through a temporary replacement. The fixture cannot pin the +hash of the same commit that contains its go.mod. This is checkout-based +development evidence, not a released-tag or paired-private-repository gate. +Run `make test-extension-consumer-release` after publishing the dependency tag; +this uses fresh caches without a replacement. Before release, +`make test-extension-consumer-candidate COMMUNITY_COMMIT=` archives +the committed candidate rather than consuming uncommitted worktree changes. + +`entry_test.go` walks the transitive public signature types, verifies defensive +metadata snapshots and the CLI validation projection, rejects direct imports +of Community `internal/` and `cmd/` packages, and verifies construction does not +load dotenv files. `extension_test.go` runs the public registry/configuration +contract suites, checks canonical manifest projections and factory ownership. +`events_test.go` runs public attribution and independent sink conformance suites, +including a stalled implementation that must fail. The process suite uses actual +consumer binaries and an independently implemented JSONL sink to prove mutation +success/failure, secret/config redaction and shutdown drain in both server modes. +Runtime factories also declare namespaced status/reset Admin routes, handled by +independently prepared resources through public AdminDependencies. The process +suite protects both modes with Basic Auth, rejects anonymous contribution calls, +and runs the reusable AdminBody suite against Community and contributed writes: +known-length/chunked oversize and compression fail before mutation, supported +writes retain shared CORS, and Bundle apply/export still work. It proves the +contributed reset publishes an owner-scoped public event. The independent limiter uses its own atomic quota store, not the Community +implementation. `limiter_test.go` runs both implementations through identical +public admission/lifecycle and deterministic refill suites, and checks connection +refresh preserves quota state. Public signature checks include limiter/StatefulResource. +Both real server processes apply the selected backend, admit local count_tokens +traffic without upstream I/O, verify unchanged/changed policy state, reject unavailable +selections, switch authority, exercise timeout/ambiguous consumption and explicit +fail-open, inspect bounded Prometheus labels and flush admission/reset events. + +The F6 integrated process contract lives in the public test package +`pkg/distribution/contracttest`, so separately maintained repositories can run +it against their own entries without copying a Community harness. It checks +three command manifests, both server modes, CLI validate/apply/export with +configuration preservation, authenticated read-only probes and Community/body +contracts, OpenAI/Anthropic/cc batch and SSE responses, upstream cancellation, +MCP tool execution and persisted LLM/MCP usage, selected-backend events, rejection before +upstream I/O, and shutdown drain. `contracttest.SecretReferences` separately +checks syntax-only validation and manager/store reference preservation with the +environment absent and present. These helpers are test infrastructure; they do +not add protected production APIs. diff --git a/tests/extensionconsumer/assembly.go b/tests/extensionconsumer/assembly.go new file mode 100644 index 00000000..7af6e07d --- /dev/null +++ b/tests/extensionconsumer/assembly.go @@ -0,0 +1,36 @@ +// Package extensionconsumer is an independently compiled distribution fixture. +package extensionconsumer + +import ( + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/agent-guide/agent-gateway/pkg/extension" +) + +func Runtime() distribution.Assembly { + a := distribution.CommunityRuntime() + a.Edition = distribution.Edition{Name: "fixture", Version: "1.0.0"} + a.Modules = append(a.Modules, extension.Descriptor{ID: "example.fixture.entries", Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime", "validation", "admin"}}) + a.Modules = append(a.Modules, extension.Descriptor{ID: "example.fixture.mutable", Version: "1.0.0", SchemaVersion: 1, Dependencies: []string{"example.fixture.entries"}, Roles: []string{"runtime", "validation", "admin"}}) + a.Factories["example.fixture.entries"] = fixtureFactory(true) + a.Factories["example.fixture.mutable"] = fixtureFactory(true) + for _, id := range []string{"example.fixture.entries", "example.fixture.mutable"} { + f := a.Factories[id] + f.AdminRoutes = []extension.AdminRoute{{Method: "GET", Path: "/admin/extensions/" + id + "/status"}, {Method: "POST", Path: "/admin/extensions/" + id + "/reset"}, {Method: "POST", Path: "/admin/extensions/" + id + "/probe"}} + } + a.Modules = append(a.Modules, extension.Descriptor{ID: fixtureLimiterID, Version: "1.0.0", SchemaVersion: 1, Roles: []string{"runtime", "validation", "spi"}}) + a.Factories[fixtureLimiterID] = fixtureLimiterFactory(true) + a.EventSinks = []event.Registration{{ModuleID: "example.fixture.entries", New: newFixtureSink}} + return a +} +func Validation() distribution.Assembly { + a := distribution.CommunityValidation() + a.Edition = distribution.Edition{Name: "fixture", Version: "1.0.0"} + a.Modules = append(a.Modules, extension.Descriptor{ID: "example.fixture.entries", Version: "1.0.0", SchemaVersion: 1, Roles: []string{"validation"}}) + a.Modules = append(a.Modules, extension.Descriptor{ID: "example.fixture.mutable", Version: "1.0.0", SchemaVersion: 1, Dependencies: []string{"example.fixture.entries"}, Roles: []string{"validation"}}) + a.Factories["example.fixture.entries"] = fixtureFactory(false) + a.Factories["example.fixture.mutable"] = fixtureFactory(false) + a.Modules = append(a.Modules, extension.Descriptor{ID: fixtureLimiterID, Version: "1.0.0", SchemaVersion: 1, Roles: []string{"validation"}}) + a.Factories[fixtureLimiterID] = fixtureLimiterFactory(false) + return a +} diff --git a/tests/extensionconsumer/cmd/agw/main.go b/tests/extensionconsumer/cmd/agw/main.go new file mode 100644 index 00000000..0948c546 --- /dev/null +++ b/tests/extensionconsumer/cmd/agw/main.go @@ -0,0 +1,30 @@ +package main + +import ( + fixture "example.com/extensionconsumer" + _ "github.com/agent-guide/agent-gateway/caddy/admin" + _ "github.com/agent-guide/agent-gateway/caddy/configstore/sqlite" + _ "github.com/agent-guide/agent-gateway/caddy/dispatcher" + _ "github.com/agent-guide/agent-gateway/caddy/dispatcher/llmapi/anthropic" + _ "github.com/agent-guide/agent-gateway/caddy/dispatcher/llmapi/cc" + _ "github.com/agent-guide/agent-gateway/caddy/dispatcher/llmapi/openai" + _ "github.com/agent-guide/agent-gateway/caddy/gateway" + _ "github.com/agent-guide/agent-gateway/pkg/acp/agent/codex" + _ "github.com/agent-guide/agent-gateway/pkg/acp/agent/opencode" + "github.com/agent-guide/agent-gateway/pkg/distribution/caddycmd" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/anthropic" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/anthropicbase" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/claudecode" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/codex" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/deepseek" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/gemini" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/grok" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/ollama" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/openai" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/openrouter" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/qwen" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/zhipu" + _ "github.com/caddyserver/caddy/v2/modules/standard" +) + +func main() { caddycmd.New(fixture.Runtime()).Run() } diff --git a/tests/extensionconsumer/cmd/agwctl/main.go b/tests/extensionconsumer/cmd/agwctl/main.go new file mode 100644 index 00000000..5c9b6b60 --- /dev/null +++ b/tests/extensionconsumer/cmd/agwctl/main.go @@ -0,0 +1,24 @@ +package main + +import ( + fixture "example.com/extensionconsumer" + _ "github.com/agent-guide/agent-gateway/pkg/dispatcher/llmapi/anthropic" + _ "github.com/agent-guide/agent-gateway/pkg/dispatcher/llmapi/cc" + _ "github.com/agent-guide/agent-gateway/pkg/dispatcher/llmapi/openai" + "github.com/agent-guide/agent-gateway/pkg/distribution/ctlcmd" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/anthropic" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/anthropicbase" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/claudecode" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/codex" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/deepseek" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/gemini" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/grok" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/ollama" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/openai" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/openrouter" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/qwen" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/zhipu" + "os" +) + +func main() { os.Exit(ctlcmd.Run(ctlcmd.Options{Assembly: fixture.Validation()})) } diff --git a/tests/extensionconsumer/cmd/agwd/main.go b/tests/extensionconsumer/cmd/agwd/main.go new file mode 100644 index 00000000..40c349f4 --- /dev/null +++ b/tests/extensionconsumer/cmd/agwd/main.go @@ -0,0 +1,23 @@ +package main + +import ( + fixture "example.com/extensionconsumer" + _ "github.com/agent-guide/agent-gateway/pkg/acp/agent/codex" + _ "github.com/agent-guide/agent-gateway/pkg/acp/agent/opencode" + "github.com/agent-guide/agent-gateway/pkg/distribution/daemoncmd" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/anthropic" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/anthropicbase" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/claudecode" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/codex" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/deepseek" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/gemini" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/grok" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/ollama" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/openai" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/openrouter" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/qwen" + _ "github.com/agent-guide/agent-gateway/pkg/llm/provider/zhipu" + "os" +) + +func main() { os.Exit(daemoncmd.Run(daemoncmd.Options{Assembly: fixture.Runtime()})) } diff --git a/tests/extensionconsumer/entry_test.go b/tests/extensionconsumer/entry_test.go new file mode 100644 index 00000000..673e6206 --- /dev/null +++ b/tests/extensionconsumer/entry_test.go @@ -0,0 +1,145 @@ +package extensionconsumer + +import ( + "encoding/json" + "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey/limiter" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/distribution/caddycmd" + "github.com/agent-guide/agent-gateway/pkg/distribution/ctlcmd" + "github.com/agent-guide/agent-gateway/pkg/distribution/daemoncmd" + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/agent-guide/agent-gateway/pkg/extension" +) + +func TestExternalAssemblyAndDefensiveCopies(t *testing.T) { + a := Runtime() + caddy := caddycmd.New(a) + daemon := daemoncmd.NewCommand(daemoncmd.Options{Assembly: a}) + var got distribution.Assembly + if err := json.Unmarshal([]byte(daemon.Annotations["agent-gateway.assembly"]), &got); err != nil { + t.Fatal(err) + } + aSnapshot := caddy.Assembly() + aSnapshot.Factories = nil + aSnapshot.EventSinks = nil + if !reflect.DeepEqual(aSnapshot, got) { + t.Fatal("server entries differ") + } + a.Modules[0].Capabilities[0] = "changed" + snapshot := caddy.Assembly() + snapshot.Modules[0].Roles[0] = "changed" + aSnapshot = caddy.Assembly() + aSnapshot.Factories = nil + aSnapshot.EventSinks = nil + if !reflect.DeepEqual(aSnapshot, got) { + t.Fatal("assembly retains caller slices") + } + cli := ctlcmd.NewCommand(ctlcmd.Options{Assembly: Validation()}) + if err := json.Unmarshal([]byte(cli.Annotations["agent-gateway.assembly"]), &got); err != nil { + t.Fatal(err) + } + if got.Edition.Name != "fixture" { + t.Fatalf("edition=%+v", got.Edition) + } + for _, m := range got.Modules { + if !reflect.DeepEqual(m.Roles, []string{"validation"}) { + t.Fatalf("CLI claims runtime role: %+v", m) + } + } +} + +// Walk the public signature closure, including nested callback types, exported +// fields and methods. Merely compiling main does not detect hidden internal types. +func TestRequiredPublicSignaturesExposeNoCommunityInternalTypes(t *testing.T) { + seen := map[reflect.Type]bool{} + var check func(reflect.Type) + check = func(typ reflect.Type) { + if typ == nil || seen[typ] { + return + } + seen[typ] = true + if strings.HasPrefix(typ.PkgPath(), "github.com/agent-guide/agent-gateway/internal/") { + t.Errorf("public signature exposes %s", typ) + } + switch typ.Kind() { + case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Chan: + check(typ.Elem()) + case reflect.Map: + check(typ.Key()) + check(typ.Elem()) + case reflect.Func: + for i := 0; i < typ.NumIn(); i++ { + check(typ.In(i)) + } + for i := 0; i < typ.NumOut(); i++ { + check(typ.Out(i)) + } + case reflect.Struct: + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + if f.IsExported() { + check(f.Type) + } + } + } + for i := 0; i < typ.NumMethod(); i++ { + check(typ.Method(i).Type) + } + } + for _, v := range []any{(*limiter.LimiterBackend)(nil), (*limiter.LimiterProvider)(nil), limiter.LimiterRequest{}, limiter.LimiterDecision{}, limiter.Config{}, limiter.Policy{}, limiter.NewMemoryBackend, limiter.Factory, (*extension.StatefulResource)(nil), caddycmd.New, (*caddycmd.Command).Assembly, (*caddycmd.Command).Run, daemoncmd.NewCommand, daemoncmd.Run, ctlcmd.NewCommand, ctlcmd.Run, distribution.CommunityRuntime, distribution.CommunityValidation, distribution.Assembly{}, daemoncmd.Options{}, ctlcmd.Options{}, distribution.MetricsConfig{}, extension.NewRegistry, extension.NewManager, extension.Envelope{}, extension.Factory{}, extension.Snapshot{}, extension.Lease{}, extension.Error{}, extension.AdminDependencies{}, extension.AdminRoute{}, extension.OwnedAdminRoute{}, extension.WriteAdminError, (*extension.AdminContributor)(nil), event.NewDelivery, event.Validate, event.Envelope{}, event.Registration{}, event.Options{}, event.Stats{}, attribution.NewPolicy, attribution.WithContext, attribution.FromContext, attribution.WithCorrelation, attribution.Producer(nil)} { + check(reflect.TypeOf(v)) + } +} + +func TestConstructionDoesNotLoadDotenv(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv("AGW_ADMIN_ADDR", "http://shell:1234") + if err := os.WriteFile(".env.local", []byte("invalid='unterminated"), 0600); err != nil { + t.Fatal(err) + } + _ = caddycmd.New(Runtime()) + _ = daemoncmd.NewCommand(daemoncmd.Options{Assembly: Runtime()}) + cli := ctlcmd.NewCommand(ctlcmd.Options{Assembly: Validation()}) + if got, _ := cli.PersistentFlags().GetString("admin-addr"); got != "http://shell:1234" { + t.Fatalf("construction changed environment defaults: %q", got) + } +} + +func TestConsumerSourceImportsOnlyPublicCommunityPackages(t *testing.T) { + err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + f, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, imp := range f.Imports { + name, err := strconv.Unquote(imp.Path.Value) + if err != nil { + return err + } + if strings.HasPrefix(name, "github.com/agent-guide/agent-gateway/internal/") || strings.HasPrefix(name, "github.com/agent-guide/agent-gateway/cmd/") || strings.HasPrefix(name, "github.com/agent-guide/agent-gateway-enterprise") { + t.Errorf("%s imports forbidden %s", path, name) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/tests/extensionconsumer/events.go b/tests/extensionconsumer/events.go new file mode 100644 index 00000000..b6279083 --- /dev/null +++ b/tests/extensionconsumer/events.go @@ -0,0 +1,65 @@ +package extensionconsumer + +import ( + "context" + "encoding/json" + "os" + "sync" + + "github.com/agent-guide/agent-gateway/pkg/event" +) + +// The independently owned sink optionally records process evidence. Its path is +// fixture-only process input; it is not a Community configuration field. +type fixtureSink struct { + mu sync.Mutex + file *os.File + closed bool +} + +func newFixtureSink(ctx context.Context) (event.Sink, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + s := &fixtureSink{} + if path := os.Getenv("AGW_FIXTURE_EVENT_FILE"); path != "" { + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return nil, err + } + s.file = f + } + return s, nil +} +func (s *fixtureSink) Write(ctx context.Context, e event.Envelope) error { + if ctx.Err() != nil { + return ctx.Err() + } + s.mu.Lock() + defer s.mu.Unlock() + if s.file != nil { + return json.NewEncoder(s.file).Encode(e) + } + return nil +} +func (s *fixtureSink) Flush(ctx context.Context) error { + if ctx.Err() != nil { + return ctx.Err() + } + return nil +} +func (s *fixtureSink) Close(ctx context.Context) error { + if ctx.Err() != nil { + return ctx.Err() + } + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil + } + s.closed = true + if s.file != nil { + return s.file.Close() + } + return nil +} diff --git a/tests/extensionconsumer/events_test.go b/tests/extensionconsumer/events_test.go new file mode 100644 index 00000000..e0df9f80 --- /dev/null +++ b/tests/extensionconsumer/events_test.go @@ -0,0 +1,36 @@ +package extensionconsumer + +import ( + "context" + "github.com/agent-guide/agent-gateway/pkg/attribution" + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/agent-guide/agent-gateway/pkg/extension/contracttest" + "strings" + "testing" + "time" +) + +func TestPublicAttributionAndEventContracts(t *testing.T) { + t.Setenv("AGW_FIXTURE_EVENT_FILE", "") + p, err := attribution.NewPolicy([]string{"example.fixture.entries"}, []string{"example.fixture.entries/region"}) + if err != nil { + t.Fatal(err) + } + contracttest.Attribution(t, p, attribution.Value{Subject: "subject", Tenant: "tenant", RouteID: "route", AgentID: "agent", RunID: "run", VirtualKeyID: "key", TraceID: strings.Repeat("1", 32), SpanID: strings.Repeat("2", 16), ParentSpanID: strings.Repeat("3", 16), Attributes: map[string]string{"example.fixture.entries/region": "east"}}) + contracttest.EventSink(t, newFixtureSink) +} + +type nonconformingSink struct{ release chan struct{} } + +func (s *nonconformingSink) Write(context.Context, event.Envelope) error { <-s.release; return nil } +func (s *nonconformingSink) Flush(context.Context) error { return nil } +func (s *nonconformingSink) Close(context.Context) error { return nil } +func TestStalledSinkFailsConformance(t *testing.T) { + s := &nonconformingSink{release: make(chan struct{})} + defer close(s.release) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if contracttest.CheckSinkCancellation(ctx, s) == nil { + t.Fatal("stalled sink passed conformance") + } +} diff --git a/tests/extensionconsumer/extension_test.go b/tests/extensionconsumer/extension_test.go new file mode 100644 index 00000000..9ca465ba --- /dev/null +++ b/tests/extensionconsumer/extension_test.go @@ -0,0 +1,102 @@ +package extensionconsumer + +import ( + "bytes" + "encoding/json" + "errors" + "github.com/agent-guide/agent-gateway/pkg/distribution" + "github.com/agent-guide/agent-gateway/pkg/distribution/caddycmd" + "github.com/agent-guide/agent-gateway/pkg/distribution/ctlcmd" + "github.com/agent-guide/agent-gateway/pkg/distribution/daemoncmd" + "github.com/agent-guide/agent-gateway/pkg/extension" + "github.com/agent-guide/agent-gateway/pkg/extension/contracttest" + "reflect" + "testing" +) + +func TestProtectedExtensionContracts(t *testing.T) { + a := Runtime() + var regs []extension.Registration + for _, d := range a.Modules { + regs = append(regs, extension.Registration{Descriptor: d, Factory: a.Factories[d.ID]}) + } + t.Setenv("AGW_FIXTURE_SECRET", "fixture-secret-never-export") + contracttest.Registry(t, regs) + contracttest.Configuration(t, regs, []extension.Envelope{{ModuleID: "example.fixture.entries", SchemaVersion: 1, Config: json.RawMessage(`{"message":"hello","token":{"$secret_ref":"env:AGW_FIXTURE_SECRET"}}`)}}) +} +func TestManifestProjectionAndOwnedFactories(t *testing.T) { + a := Runtime() + manifest, err := caddycmd.New(a).Manifest() + if err != nil { + t.Fatal(err) + } + command := daemoncmd.NewCommand(daemoncmd.Options{Assembly: a}) + var out bytes.Buffer + command.SetOut(&out) + command.SetArgs([]string{"manifest"}) + if err = command.Execute(); err != nil { + t.Fatal(err) + } + var got distribution.Assembly + if err = json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(manifest, got) { + t.Fatal("server projections differ") + } + copy := a.Clone() + copy.Factories["example.fixture.entries"].SecretFields[0] = "/changed" + if a.Factories["example.fixture.entries"].SecretFields[0] != "/token" { + t.Fatal("factory alias") + } + cli := ctlcmd.NewCommand(ctlcmd.Options{Assembly: Validation()}) + out.Reset() + cli.SetOut(&out) + cli.SetArgs([]string{"manifest"}) + if err = cli.Execute(); err != nil { + t.Fatal(err) + } + if err = json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatal(err) + } + for _, d := range got.Modules { + if !reflect.DeepEqual(d.Roles, []string{"validation"}) { + t.Fatal("CLI claims server execution") + } + } +} + +func TestAdminDeclarationsOwnedAndConflicts(t *testing.T) { + a := Runtime() + r, err := a.Registry() + if err != nil { + t.Fatal(err) + } + if len(r.AdminRoutes()) != 6 { + t.Fatalf("routes: %+v", r.AdminRoutes()) + } + cloned := a.Clone() + cloned.Factories["example.fixture.entries"].AdminRoutes[0].Path = "/admin/memory/config" + if a.Factories["example.fixture.entries"].AdminRoutes[0].Path == "/admin/memory/config" { + t.Fatal("declarations alias assembly") + } + for _, path := range []string{"/admin/agents/routes", "/admin/agents/{id}/workspace", "/admin/extensions/example.fixture.mutable/status", "/admin/extensions/example.fixture.entries", "/admin/extensions/example.fixture.entries/{name}"} { + copy := a.Clone() + f := copy.Factories["example.fixture.entries"] + f.AdminRoutes = append(f.AdminRoutes, extension.AdminRoute{Method: "GET", Path: path}) + _, err = copy.Registry() + var e *extension.Error + if !errors.As(err, &e) || e.Code != "route_conflict" { + t.Fatalf("conflict %s: %v", path, err) + } + } +} + +func TestSecretReferenceContract(t *testing.T) { + a := Runtime() + var regs []extension.Registration + for _, d := range a.Modules { + regs = append(regs, extension.Registration{Descriptor: d, Factory: a.Factories[d.ID]}) + } + contracttest.SecretReferences(t, regs, []extension.Envelope{{ModuleID: "example.fixture.entries", SchemaVersion: 1, Config: json.RawMessage(`{"message":"reference-proof","token":{"$secret_ref":"env:AGW_CONTRACT_SECRET"}}`)}}, "AGW_CONTRACT_SECRET", "resolved-secret-never-serialized") +} diff --git a/tests/extensionconsumer/go.mod b/tests/extensionconsumer/go.mod new file mode 100644 index 00000000..77f5fc8e --- /dev/null +++ b/tests/extensionconsumer/go.mod @@ -0,0 +1,244 @@ +module example.com/extensionconsumer + +go 1.25.5 + +require ( + github.com/agent-guide/agent-gateway v0.7.0 + github.com/caddyserver/caddy/v2 v2.10.2 +) + +require ( + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go v0.120.0 // indirect + cloud.google.com/go/auth v0.16.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + dario.cat/mergo v1.0.1 // indirect + filippo.io/edwards25519 v1.1.0 // indirect + github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/KimMachineGun/automemlimit v0.7.4 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/Microsoft/go-winio v0.6.0 // indirect + github.com/a2aproject/a2a-go/v2 v2.5.0 // indirect + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/anthropics/anthropic-sdk-go v1.56.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect + github.com/aws/aws-sdk-go-v2 v1.36.4 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect + github.com/aws/aws-sdk-go-v2/config v1.29.16 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.69 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.16 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.25.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.21 // indirect + github.com/aws/smithy-go v1.22.2 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect + github.com/buger/jsonparser v1.1.2 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/caddyserver/certmagic v0.24.0 // indirect + github.com/caddyserver/zerossl v0.1.3 // indirect + github.com/ccoveille/go-safecast v1.6.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.2 // indirect + github.com/cespare/xxhash v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chzyer/readline v1.5.1 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/cloudwego/eino v0.9.15 // indirect + github.com/cloudwego/eino-ext/components/model/claude v0.1.25 // indirect + github.com/cloudwego/eino-ext/components/model/deepseek v0.1.7 // indirect + github.com/cloudwego/eino-ext/components/model/gemini v0.1.33 // indirect + github.com/cloudwego/eino-ext/components/model/ollama v0.1.9 // indirect + github.com/cloudwego/eino-ext/components/model/openai v0.1.13 // indirect + github.com/cloudwego/eino-ext/components/model/openrouter v0.1.10 // indirect + github.com/cloudwego/eino-ext/components/model/qwen v0.1.9 // indirect + github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect + github.com/cohesion-org/deepseek-go v1.3.4 // indirect + github.com/coreos/go-oidc/v3 v3.14.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/dgraph-io/badger v1.6.2 // indirect + github.com/dgraph-io/badger/v2 v2.2007.4 // indirect + github.com/dgraph-io/ristretto v0.2.0 // indirect + github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/eino-contrib/jsonschema v1.0.3 // indirect + github.com/eino-contrib/ollama v0.1.0 // indirect + github.com/evanphx/json-patch v0.5.2 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/francoispqt/gojay v1.2.13 // indirect + github.com/fxamacker/cbor/v2 v2.8.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.10.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/glebarez/sqlite v1.11.0 // indirect + github.com/go-chi/chi/v5 v5.2.2 // indirect + github.com/go-jose/go-jose/v3 v3.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/cel-go v0.26.0 // indirect + github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-tpm v0.9.5 // indirect + github.com/google/go-tspi v0.3.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.14.2 // indirect + github.com/goph/emperror v0.17.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.14.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/libdns/libdns v1.1.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/manifoldco/promptui v0.9.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/meguminnnnnnnnn/go-openai v0.1.2 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/mholt/acmez/v3 v3.1.2 // indirect + github.com/miekg/dns v1.1.63 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/nikolalohinski/gonja v1.5.3 // indirect + github.com/ollama/ollama v0.6.5 // indirect + github.com/pb33f/ordered-map/v2 v2.3.1 // indirect + github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.23.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/slackhq/nebula v1.9.5 // indirect + github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect + github.com/smallstep/certificates v0.28.4 // indirect + github.com/smallstep/cli-utils v0.12.1 // indirect + github.com/smallstep/go-attestation v0.4.4-0.20241119153605-2306d5b464ca // indirect + github.com/smallstep/linkedca v0.23.0 // indirect + github.com/smallstep/nosql v0.7.0 // indirect + github.com/smallstep/pkcs7 v0.2.1 // indirect + github.com/smallstep/scep v0.0.0-20240926084937-8cf1ca453101 // indirect + github.com/smallstep/truststore v0.13.0 // indirect + github.com/spf13/cast v1.7.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect + github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + github.com/urfave/cli v1.22.17 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/yargevad/filepathx v1.0.0 // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.etcd.io/bbolt v1.3.10 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/contrib/propagators/autoprop v0.62.0 // indirect + go.opentelemetry.io/contrib/propagators/aws v1.37.0 // indirect + go.opentelemetry.io/contrib/propagators/b3 v1.37.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.37.0 // indirect + go.opentelemetry.io/contrib/propagators/ot v1.37.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.0 // indirect + go.step.sm/crypto v0.67.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.uber.org/mock v0.5.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect + golang.org/x/arch v0.15.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/crypto/x509roots/fallback v0.0.0-20250305170421-49bf5b80c810 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.44.0 // indirect + google.golang.org/api v0.240.0 // indirect + google.golang.org/genai v1.51.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect + google.golang.org/grpc v1.82.1 // indirect + google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/gorm v1.31.1 // indirect + howett.net/plist v1.0.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.46.1 // indirect +) diff --git a/tests/extensionconsumer/go.sum b/tests/extensionconsumer/go.sum new file mode 100644 index 00000000..236a465a --- /dev/null +++ b/tests/extensionconsumer/go.sum @@ -0,0 +1,901 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo= +cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA= +cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q= +cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= +cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= +cloud.google.com/go/kms v1.22.0 h1:dBRIj7+GDeeEvatJeTB19oYZNV0aj6wEqSIT/7gLqtk= +cloud.google.com/go/kms v1.22.0/go.mod h1:U7mf8Sva5jpOb4bxYZdtw/9zsbIjrklYwPcvMk34AL8= +cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= +cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= +dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= +dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= +dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= +github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M= +github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/KimMachineGun/automemlimit v0.7.4 h1:UY7QYOIfrr3wjjOAqahFmC3IaQCLWvur9nmfIn6LnWk= +github.com/KimMachineGun/automemlimit v0.7.4/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= +github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= +github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/a2aproject/a2a-go/v2 v2.5.0 h1:ZdcFoxv+nZTUV0i2ue5hES76YCANFPG9vjqd7vK8yWM= +github.com/a2aproject/a2a-go/v2 v2.5.0/go.mod h1:NcRp/ZHxgMzDj12/BteIC2gOjljuEBKaGRfEdJ2lNSI= +github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/anthropics/anthropic-sdk-go v1.56.0 h1:idVU14wOZ06D0GBNEvuhn927xXmBVEquo0469iDwLsc= +github.com/anthropics/anthropic-sdk-go v1.56.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw= +github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= +github.com/aws/aws-sdk-go-v2 v1.36.4 h1:GySzjhVvx0ERP6eyfAbAuAXLtAda5TEy19E5q5W8I9E= +github.com/aws/aws-sdk-go-v2 v1.36.4/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM= +github.com/aws/aws-sdk-go-v2/config v1.29.16 h1:XkruGnXX1nEZ+Nyo9v84TzsX+nj86icbFAeust6uo8A= +github.com/aws/aws-sdk-go-v2/config v1.29.16/go.mod h1:uCW7PNjGwZ5cOGZ5jr8vCWrYkGIhPoTNV23Q/tpHKzg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.69 h1:8B8ZQboRc3uaIKjshve/XlvJ570R7BKNy3gftSbS178= +github.com/aws/aws-sdk-go-v2/credentials v1.17.69/go.mod h1:gPME6I8grR1jCqBFEGthULiolzf/Sexq/Wy42ibKK9c= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.31 h1:oQWSGexYasNpYp4epLGZxxjsDo8BMBh6iNWkTXQvkwk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.31/go.mod h1:nc332eGUU+djP3vrMI6blS0woaCfHTe3KiSQUVTMRq0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.35 h1:o1v1VFfPcDVlK3ll1L5xHsaQAFdNtZ5GXnNR7SwueC4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.35/go.mod h1:rZUQNYMNG+8uZxz9FOerQJ+FceCiodXvixpeRtdESrU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.35 h1:R5b82ubO2NntENm3SAm0ADME+H630HomNJdgv+yZ3xw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.35/go.mod h1:FuA+nmgMRfkzVKYDNEqQadvEMxtxl9+RLT9ribCwEMs= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.16 h1:/ldKrPPXTC421bTNWrUIpq3CxwHwRI/kpc+jPUTJocM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.16/go.mod h1:5vkf/Ws0/wgIMJDQbjI4p2op86hNW6Hie5QtebrDgT8= +github.com/aws/aws-sdk-go-v2/service/kms v1.41.0 h1:2jKyib9msVrAVn+lngwlSplG13RpUZmzVte2yDao5nc= +github.com/aws/aws-sdk-go-v2/service/kms v1.41.0/go.mod h1:RyhzxkWGcfixlkieewzpO3D4P4fTMxhIDqDZWsh0u/4= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.4 h1:EU58LP8ozQDVroOEyAfcq0cGc5R/FTZjVoYJ6tvby3w= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.4/go.mod h1:CrtOgCcysxMvrCoHnvNAD7PHWclmoFG78Q2xLK0KKcs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.2 h1:XB4z0hbQtpmBnb1FQYvKaCM7UsS6Y/u8jVBwIUGeCTk= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.2/go.mod h1:hwRpqkRxnQ58J9blRDrB4IanlXCpcKmsC83EhG77upg= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.21 h1:nyLjs8sYJShFYj6aiyjCBI3EcLn1udWrQTjEF+SOXB0= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.21/go.mod h1:EhdxtZ+g84MSGrSrHzZiUm9PYiZkrADNja15wtRJSJo= +github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= +github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= +github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0= +github.com/bytedance/mockey v1.3.0/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/caddyserver/caddy/v2 v2.10.2 h1:g/gTYjGMD0dec+UgMw8SnfmJ3I9+M2TdvoRL/Ovu6U8= +github.com/caddyserver/caddy/v2 v2.10.2/go.mod h1:TXLQHx+ev4HDpkO6PnVVHUbL6OXt6Dfe7VcIBdQnPL0= +github.com/caddyserver/certmagic v0.24.0 h1:EfXTWpxHAUKgDfOj6MHImJN8Jm4AMFfMT6ITuKhrDF0= +github.com/caddyserver/certmagic v0.24.0/go.mod h1:xPT7dC1DuHHnS2yuEQCEyks+b89sUkMENh8dJF+InLE= +github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= +github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/ccoveille/go-safecast v1.6.1 h1:Nb9WMDR8PqhnKCVs2sCB+OqhohwO5qaXtCviZkIff5Q= +github.com/ccoveille/go-safecast v1.6.1/go.mod h1:QqwNjxQ7DAqY0C721OIO9InMk9zCwcsO7tnRuHytad8= +github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= +github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cloudwego/eino v0.9.15 h1:F+7uXeZYbJm30a/kaC93Mj6H9K2zx4thQaQP695NggE= +github.com/cloudwego/eino v0.9.15/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ= +github.com/cloudwego/eino-ext/components/model/claude v0.1.25 h1:ZMJZmIWv3KZXPoVzYRiQB4/n9TktqnTBzu0nrxKv+G4= +github.com/cloudwego/eino-ext/components/model/claude v0.1.25/go.mod h1:2N/8/BccxDYp8ilOcucPSzigX9zm4SVFSlbE8zdvdSU= +github.com/cloudwego/eino-ext/components/model/deepseek v0.1.7 h1:V+u0hA/E4E/CMABqkR3D0IOM/RxtKhN/HyMXMWm+Xto= +github.com/cloudwego/eino-ext/components/model/deepseek v0.1.7/go.mod h1:YVjkAAxwLqk/dyA7AfVksaxPMG7snikx0hjje8iIZBE= +github.com/cloudwego/eino-ext/components/model/gemini v0.1.33 h1:LnzDQ3Psf0cPxvR5Ui070x9EPWyszIEifEHq7AJw8tw= +github.com/cloudwego/eino-ext/components/model/gemini v0.1.33/go.mod h1:snXILkr06Zr2jm6WlqcWeytwiCramhNVPfxASgbjH40= +github.com/cloudwego/eino-ext/components/model/ollama v0.1.9 h1:+eZbquy5lF3WHvK9+T7UUqI0CTRqDEniP7fzL85lJuk= +github.com/cloudwego/eino-ext/components/model/ollama v0.1.9/go.mod h1:C3rf3yy2nEoXFP/CQJne4gbiu1pREKplHKmFlhuOzPE= +github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQrMHcfvuWNklEC3tpm3XHejdozt9vM= +github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ= +github.com/cloudwego/eino-ext/components/model/openrouter v0.1.10 h1:i5YdCW8omvObZhyLGqL8ia5TC/pr3WipB4xQw9oN09Q= +github.com/cloudwego/eino-ext/components/model/openrouter v0.1.10/go.mod h1:WRc1OIXVaf2USal59xrIt+05TUCu9P1c8MQh0FcWG8c= +github.com/cloudwego/eino-ext/components/model/qwen v0.1.9 h1:xCz/mp43JeWqupjPR3zLRArmwC6P29/6lTwbwh1yzYM= +github.com/cloudwego/eino-ext/components/model/qwen v0.1.9/go.mod h1:slTGTuhzkzhNavf+1UtUg1FvUSA31iNAF+rq1mT4SnI= +github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI= +github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/cohesion-org/deepseek-go v1.3.4 h1:YQ0Fg6eXj9ImVh9VkCIx2gA7pa/kMNJ5RLr9no13QaQ= +github.com/cohesion-org/deepseek-go v1.3.4/go.mod h1:bOVyKj38r90UEYZFrmJOzJKPxuAh8sIzHOCnLOpiXeI= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= +github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger v1.6.2 h1:mNw0qs90GVgGGWylh0umH5iag1j6n/PeJtNvL6KY/x8= +github.com/dgraph-io/badger v1.6.2/go.mod h1:JW2yswe3V058sS0kZ2h/AXeDSqFjxnZcRrVH//y2UQE= +github.com/dgraph-io/badger/v2 v2.2007.4 h1:TRWBQg8UrlUhaFdco01nO2uXwzKS7zd+HVdwV/GHc4o= +github.com/dgraph-io/badger/v2 v2.2007.4/go.mod h1:vSw/ax2qojzbN6eXHIx6KPKtCSHJN/Uz0X0VPruTIhk= +github.com/dgraph-io/ristretto v0.0.2/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= +github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0= +github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= +github.com/eino-contrib/ollama v0.1.0 h1:z1NaMdKW6X1ftP8g5xGGR5zDRPUtuTKFq35vBQgxsN4= +github.com/eino-contrib/ollama v0.1.0/go.mod h1:mYsQ7b3DeqY8bHPuD3MZJYTqkgyL6LoemxoP/B7ZNhA= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= +github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= +github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= +github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= +github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745 h1:heyoXNxkRT155x4jTAiSv5BVSVkueifPUm+Q8LUXMRo= +github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745/go.mod h1:zN0wUQgV9LjwLZeFHnrAbQi8hzMVvEWePyk+MhPOk7k= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= +github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.4.5 h1:3fhthtyMDbIZFR5/0y1hvUoZ1Kf4i1eZ7C73R4Pvd+k= +github.com/google/go-tpm-tools v0.4.5/go.mod h1:ktjTNq8yZFD6TzdBFefUfen96rF3NpYwpSb2d8bc+Y8= +github.com/google/go-tspi v0.3.0 h1:ADtq8RKfP+jrTyIWIZDIYcKOMecRqNJFOew2IT0Inus= +github.com/google/go-tspi v0.3.0/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5by8CiHI= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= +github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= +github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= +github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18= +github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= +github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/libdns/libdns v1.1.0 h1:9ze/tWvt7Df6sbhOJRB8jT33GHEHpEQXdtkE3hPthbU= +github.com/libdns/libdns v1.1.0/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA= +github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mholt/acmez/v3 v3.1.2 h1:auob8J/0FhmdClQicvJvuDavgd5ezwLBfKuYmynhYzc= +github.com/mholt/acmez/v3 v3.1.2/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= +github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= +github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= +github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= +github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= +github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= +github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= +github.com/ollama/ollama v0.6.5 h1:vXKkVX57ql/1ZzMw4SVK866Qfd6pjwEcITVyEpF0QXQ= +github.com/ollama/ollama v0.6.5/go.mod h1:pGgtoNyc9DdM6oZI6yMfI6jTk2Eh4c36c2GpfQCH7PY= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= +github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= +github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/peterbourgon/diskv/v3 v3.0.1 h1:x06SQA46+PKIUftmEujdwSEpIx8kR+M9eLYsUxeYveU= +github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/schollz/jsonstore v1.1.0 h1:WZBDjgezFS34CHI+myb4s8GGpir3UMpy7vWoCeO0n6E= +github.com/schollz/jsonstore v1.1.0/go.mod h1:15c6+9guw8vDRyozGjN3FoILt0wpruJk9Pi66vjaZfg= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= +github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= +github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= +github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= +github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= +github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= +github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI= +github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= +github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= +github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg= +github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw= +github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y= +github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= +github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ= +github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I= +github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0= +github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= +github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk= +github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= +github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/slackhq/nebula v1.9.5 h1:ZrxcvP/lxwFglaijmiwXLuCSkybZMJnqSYI1S8DtGnY= +github.com/slackhq/nebula v1.9.5/go.mod h1:1+4q4wd3dDAjO8rKCttSb9JIVbklQhuJiBp5I0lbIsQ= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg= +github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY= +github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc= +github.com/smallstep/certificates v0.28.4 h1:JTU6/A5Xes6m+OsR6fw1RACSA362vJc9SOFVG7poBEw= +github.com/smallstep/certificates v0.28.4/go.mod h1:LUqo+7mKZE7FZldlTb0zhU4A0bq4G4+akieFMcTaWvA= +github.com/smallstep/cli-utils v0.12.1 h1:D9QvfbFqiKq3snGZ2xDcXEFrdFJ1mQfPHZMq/leerpE= +github.com/smallstep/cli-utils v0.12.1/go.mod h1:skV2Neg8qjiKPu2fphM89H9bIxNpKiiRTnX9Q6Lc+20= +github.com/smallstep/go-attestation v0.4.4-0.20241119153605-2306d5b464ca h1:VX8L0r8vybH0bPeaIxh4NQzafKQiqvlOn8pmOXbFLO4= +github.com/smallstep/go-attestation v0.4.4-0.20241119153605-2306d5b464ca/go.mod h1:vNAduivU014fubg6ewygkAvQC0IQVXqdc8vaGl/0er4= +github.com/smallstep/linkedca v0.23.0 h1:5W/7EudlK1HcCIdZM68dJlZ7orqCCCyv6bm2l/0JmLU= +github.com/smallstep/linkedca v0.23.0/go.mod h1:7cyRM9soAYySg9ag65QwytcgGOM+4gOlkJ/YA58A9E8= +github.com/smallstep/nosql v0.7.0 h1:YiWC9ZAHcrLCrayfaF+QJUv16I2bZ7KdLC3RpJcnAnE= +github.com/smallstep/nosql v0.7.0/go.mod h1:H5VnKMCbeq9QA6SRY5iqPylfxLfYcLwvUff3onQ8+HU= +github.com/smallstep/pkcs7 v0.0.0-20240911091500-b1cae6277023/go.mod h1:CM5KrX7rxWgwDdMj9yef/pJB2OPgy/56z4IEx2UIbpc= +github.com/smallstep/pkcs7 v0.2.1 h1:6Kfzr/QizdIuB6LSv8y1LJdZ3aPSfTNhTLqAx9CTLfA= +github.com/smallstep/pkcs7 v0.2.1/go.mod h1:RcXHsMfL+BzH8tRhmrF1NkkpebKpq3JEM66cOFxanf0= +github.com/smallstep/scep v0.0.0-20240926084937-8cf1ca453101 h1:LyZqn24/ZiVg8v9Hq07K6mx6RqPtpDeK+De5vf4QEY4= +github.com/smallstep/scep v0.0.0-20240926084937-8cf1ca453101/go.mod h1:EuKQjYGQwhUa1mgD21zxIgOgUYLsqikJmvxNscxpS/Y= +github.com/smallstep/truststore v0.13.0 h1:90if9htAOblavbMeWlqNLnO9bsjjgVv2hQeQJCi/py4= +github.com/smallstep/truststore v0.13.0/go.mod h1:3tmMp2aLKZ/OA/jnFUB0cYPcho402UG2knuJoPh4j7A= +github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY= +github.com/smarty/assertions v1.16.0/go.mod h1:duaaFdCS0K9dnoM50iyek/eYINOZ64gbh1Xlf6LG7AI= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= +github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI= +github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53 h1:uxMgm0C+EjytfAqyfBG55ZONKQ7mvd7x4YYCWsf8QHQ= +github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53/go.mod h1:kNGUQ3VESx3VZwRwA9MSCUegIl6+saPL8Noq82ozCaU= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= +github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= +github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= +github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= +github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= +github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= +go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ= +go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/contrib/propagators/autoprop v0.62.0 h1:1+EHlhAe/tukctfePZRrDruB9vn7MdwyC+rf36nUSPM= +go.opentelemetry.io/contrib/propagators/autoprop v0.62.0/go.mod h1:skzESZBY3IYcqJgImc+fwXQWflvVe+jZxoA/uw60NaI= +go.opentelemetry.io/contrib/propagators/aws v1.37.0 h1:cp8AFiM/qjBm10C/ATIRnEDXpD5MBknrA0ANw4T2/ss= +go.opentelemetry.io/contrib/propagators/aws v1.37.0/go.mod h1:Cy8Hk2E2iSGEbsLnPUdeigrexaAOAGIAmBFK919EQs0= +go.opentelemetry.io/contrib/propagators/b3 v1.37.0 h1:0aGKdIuVhy5l4GClAjl72ntkZJhijf2wg1S7b5oLoYA= +go.opentelemetry.io/contrib/propagators/b3 v1.37.0/go.mod h1:nhyrxEJEOQdwR15zXrCKI6+cJK60PXAkJ/jRyfhr2mg= +go.opentelemetry.io/contrib/propagators/jaeger v1.37.0 h1:pW+qDVo0jB0rLsNeaP85xLuz20cvsECUcN7TE+D8YTM= +go.opentelemetry.io/contrib/propagators/jaeger v1.37.0/go.mod h1:x7bd+t034hxLTve1hF9Yn9qQJlO/pP8H5pWIt7+gsFM= +go.opentelemetry.io/contrib/propagators/ot v1.37.0 h1:tVjnBF6EiTDMXoq2Xuc2vK0I7MTbEs05II/0j9mMK+E= +go.opentelemetry.io/contrib/propagators/ot v1.37.0/go.mod h1:MQjyNXtxAC8PGN9gzPtO4GY5zuP+RI3XX53uWbCTvEQ= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 h1:bDMKF3RUSxshZ5OjOTi8rsHGaPKsAt76FaqgvIUySLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0/go.mod h1:dDT67G/IkA46Mr2l9Uj7HsQVwsjASyV9SjGofsiUZDA= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= +go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= +go.step.sm/crypto v0.67.0 h1:1km9LmxMKG/p+mKa1R4luPN04vlJYnRLlLQrWv7egGU= +go.step.sm/crypto v0.67.0/go.mod h1:+AoDpB0mZxbW/PmOXuwkPSpXRgaUaoIK+/Wx/HGgtAU= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= +go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= +golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw= +golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= +golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto/x509roots/fallback v0.0.0-20250305170421-49bf5b80c810 h1:V5+zy0jmgNYmK1uW/sPpBw8ioFvalrhaUrYWmu1Fpe4= +golang.org/x/crypto/x509roots/fallback v0.0.0-20250305170421-49bf5b80c810/go.mod h1:lxN5T34bK4Z/i6cMaU7frUU57VkDXFD4Kamfl/cp9oU= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= +google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU= +google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genai v1.51.0 h1:IZGuUqgfx40INv3hLFGCbOSGp0qFqm7LVmDghzNIYqg= +google.golang.org/genai v1.51.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= +google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk= +google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo= +google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= +sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= diff --git a/tests/extensionconsumer/limiter.go b/tests/extensionconsumer/limiter.go new file mode 100644 index 00000000..e5a223c4 --- /dev/null +++ b/tests/extensionconsumer/limiter.go @@ -0,0 +1,178 @@ +package extensionconsumer + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "math" + "sync" + "time" + + "github.com/agent-guide/agent-gateway/pkg/extension" + "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey/limiter" +) + +const fixtureLimiterID = "example.fixture.limiter" + +type limiterSettings struct { + Namespace string `json:"namespace"` + Mode string `json:"mode"` +} + +// This independently implemented store models shared remote quota storage. +// Prepared connection handles close independently; generations remain fenced. +type quotaStorage struct { + mu sync.Mutex + buckets map[string]*quotaBucket + retired map[string]bool +} +type quotaBucket struct { + request limiter.LimiterRequest + tokens float64 + last time.Time +} +type fixtureLimiter struct { + clock limiter.Clock + storage *quotaStorage + settings limiterSettings + mu sync.Mutex + closed bool + life context.Context + cancel context.CancelFunc +} + +func newQuotaStorage() *quotaStorage { + return "aStorage{buckets: map[string]*quotaBucket{}, retired: map[string]bool{}} +} +func newFixtureLimiter(storage *quotaStorage, settings limiterSettings) *fixtureLimiter { + ctx, cancel := context.WithCancel(context.Background()) + return &fixtureLimiter{storage: storage, settings: settings, life: ctx, cancel: cancel} +} +func (b *fixtureLimiter) Health(ctx context.Context) error { + if ctx.Err() != nil { + return ctx.Err() + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return errors.New("closed fixture backend") + } + return nil +} +func (b *fixtureLimiter) Close(ctx context.Context) error { + b.mu.Lock() + b.closed = true + b.cancel() + b.mu.Unlock() + return ctx.Err() +} +func (b *fixtureLimiter) LimiterBackend(name string) (limiter.LimiterBackend, error) { + if name != "fixture" { + return nil, errors.New("unknown backend") + } + return b, nil +} +func (b *fixtureLimiter) Retire(ctx context.Context, g string) error { + if ctx.Err() != nil { + return ctx.Err() + } + b.storage.mu.Lock() + defer b.storage.mu.Unlock() + key := b.settings.Namespace + "/" + g + b.storage.retired[key] = true + delete(b.storage.buckets, key) + return nil +} +func (b *fixtureLimiter) Admit(ctx context.Context, r limiter.LimiterRequest) (limiter.LimiterDecision, error) { + if err := b.Health(ctx); err != nil { + return limiter.LimiterDecision{}, err + } + switch b.settings.Mode { + case "error": + return limiter.LimiterDecision{}, errors.New("fixture private backend failure") + case "timeout": + select { + case <-ctx.Done(): + return limiter.LimiterDecision{}, ctx.Err() + case <-b.life.Done(): + return limiter.LimiterDecision{}, errors.New("closed") + } + } + if r.Generation == "" || r.VirtualKeyID == "" || r.Limit.RequestsPerMinute <= 0 || r.Limit.Burst <= 0 { + return limiter.LimiterDecision{}, errors.New("invalid policy") + } + switch r.Dimension { + case limiter.DimensionLLM, limiter.DimensionMCP, limiter.DimensionAgent: + default: + return limiter.LimiterDecision{}, errors.New("invalid dimension") + } + b.storage.mu.Lock() + defer b.storage.mu.Unlock() + if ctx.Err() != nil { + return limiter.LimiterDecision{}, ctx.Err() + } + if b.life.Err() != nil { + return limiter.LimiterDecision{}, errors.New("closed") + } + key := b.settings.Namespace + "/" + r.Generation + if b.storage.retired[key] { + return limiter.LimiterDecision{}, limiter.ErrLimiterRetired + } + now := time.Now() + if b.clock != nil { + now = b.clock.Now() + } + bucket := b.storage.buckets[key] + if bucket == nil { + bucket = "aBucket{request: r, tokens: float64(r.Limit.Burst), last: now} + b.storage.buckets[key] = bucket + } else if bucket.request != r { + return limiter.LimiterDecision{}, errors.New("policy changed within generation") + } + refill := float64(r.Limit.RequestsPerMinute) / 60 + bucket.tokens = math.Min(float64(r.Limit.Burst), bucket.tokens+now.Sub(bucket.last).Seconds()*refill) + bucket.last = now + if bucket.tokens < 1 { + return limiter.LimiterDecision{RetryAfter: time.Duration(math.Ceil((1 - bucket.tokens) / refill * float64(time.Second)))}, nil + } + bucket.tokens-- + if b.settings.Mode == "ambiguous" { + return limiter.LimiterDecision{}, errors.New("remote consumed; reply lost") + } + return limiter.LimiterDecision{Allowed: true}, nil +} +func fixtureLimiterFactory(runtime bool) *extension.Factory { + storage := newQuotaStorage() + f := &extension.Factory{Validate: func(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + d := json.NewDecoder(bytes.NewReader(raw)) + d.DisallowUnknownFields() + c := limiterSettings{Namespace: "default", Mode: "healthy"} + if d.Decode(&c) != nil || d.Decode(new(any)) != io.EOF || c.Namespace == "" || len(c.Namespace) > 64 { + return nil, errors.New("invalid limiter configuration") + } + switch c.Mode { + case "healthy", "error", "timeout", "ambiguous": + default: + return nil, errors.New("invalid mode") + } + return json.Marshal(c) + }} + if runtime { + f.Prepare = func(ctx context.Context, c extension.PreparedConfig, _ extension.Snapshot) (extension.Resource, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + var settings limiterSettings + if json.Unmarshal(c.Envelope.Config, &settings) != nil { + return nil, errors.New("invalid config") + } + return newFixtureLimiter(storage, settings), nil + } + } + return f +} diff --git a/tests/extensionconsumer/limiter_test.go b/tests/extensionconsumer/limiter_test.go new file mode 100644 index 00000000..64191b2e --- /dev/null +++ b/tests/extensionconsumer/limiter_test.go @@ -0,0 +1,52 @@ +package extensionconsumer + +import ( + "context" + "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey/limiter" + "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey/limiter/contracttest" + "testing" +) + +func TestIndependentLimiterBackendContract(t *testing.T) { + contracttest.Limiter(t, func(context.Context) (limiter.LimiterBackend, error) { + return newFixtureLimiter(newQuotaStorage(), limiterSettings{Namespace: "contract", Mode: "healthy"}), nil + }) +} +func TestCommunityLimiterBackendContract(t *testing.T) { + contracttest.Limiter(t, func(context.Context) (limiter.LimiterBackend, error) { return limiter.NewMemoryBackend(nil), nil }) +} +func TestIndependentPreparedHandlesPreserveState(t *testing.T) { + storage := newQuotaStorage() + a := newFixtureLimiter(storage, limiterSettings{Namespace: "shared"}) + b := newFixtureLimiter(storage, limiterSettings{Namespace: "shared"}) + t.Cleanup(func() { _ = a.Close(t.Context()); _ = b.Close(t.Context()) }) + r := limiter.LimiterRequest{Generation: "unchanged", VirtualKeyID: "key", Dimension: limiter.DimensionAgent, Limit: limiter.Policy{RequestsPerMinute: 1, Burst: 1}} + if d, e := a.Admit(t.Context(), r); e != nil || !d.Allowed { + t.Fatal(d, e) + } + if e := a.Close(t.Context()); e != nil { + t.Fatal(e) + } + if d, e := b.Admit(t.Context(), r); e != nil || d.Allowed { + t.Fatal("refresh reset tokens", d, e) + } + if e := b.Retire(t.Context(), r.Generation); e != nil { + t.Fatal(e) + } + if _, e := b.Admit(t.Context(), r); e == nil { + t.Fatal("retired state recreated") + } +} + +func TestIndependentLimiterRefillContract(t *testing.T) { + contracttest.LimiterRefill(t, func(_ context.Context, clock limiter.Clock) (limiter.LimiterBackend, error) { + b := newFixtureLimiter(newQuotaStorage(), limiterSettings{Namespace: "refill", Mode: "healthy"}) + b.clock = clock + return b, nil + }) +} +func TestCommunityLimiterRefillContract(t *testing.T) { + contracttest.LimiterRefill(t, func(_ context.Context, clock limiter.Clock) (limiter.LimiterBackend, error) { + return limiter.NewMemoryBackend(clock), nil + }) +} diff --git a/tests/extensionconsumer/module.go b/tests/extensionconsumer/module.go new file mode 100644 index 00000000..11a88834 --- /dev/null +++ b/tests/extensionconsumer/module.go @@ -0,0 +1,100 @@ +package extensionconsumer + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "github.com/agent-guide/agent-gateway/pkg/event" + "github.com/agent-guide/agent-gateway/pkg/extension" + "github.com/google/uuid" + "io" + "net/http" + "strings" + "sync/atomic" + "time" +) + +// The fixture owns schema and resources independently of Community runtime code. +type moduleConfig struct { + Message string `json:"message"` + Token json.RawMessage `json:"token,omitempty"` +} +type moduleResource struct { + Message string + secret string + closed atomic.Bool + resets atomic.Int64 +} + +func (r *moduleResource) Health(ctx context.Context) error { + if ctx.Err() != nil { + return ctx.Err() + } + if r.closed.Load() { + return errors.New("closed") + } + return nil +} +func (r *moduleResource) Close(ctx context.Context) error { r.closed.Store(true); return nil } +func fixtureFactory(runtime bool) *extension.Factory { + f := &extension.Factory{SecretFields: []string{"/token"}, Validate: func(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + var cfg moduleConfig + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&cfg); err != nil { + return nil, errors.New("invalid config") + } + var trailing any + if dec.Decode(&trailing) != io.EOF || len(cfg.Message) > 128 || cfg.Message == "" { + return nil, errors.New("invalid message") + } + return json.Marshal(cfg) + }} + if runtime { + // Each prepared module owns one immutable declaration set and its own handles. + f.Prepare = func(ctx context.Context, c extension.PreparedConfig, _ extension.Snapshot) (extension.Resource, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + var cfg moduleConfig + if err := json.Unmarshal(c.Envelope.Config, &cfg); err != nil { + return nil, err + } + return &moduleResource{Message: cfg.Message, secret: c.Secrets["/token"]}, nil + } + } + return f +} + +func (r *moduleResource) ServeAdminHTTP(w http.ResponseWriter, req *http.Request, d extension.AdminDependencies) { + if req.Context().Err() != nil { + extension.WriteAdminError(w, 503, "closed") + return + } + switch req.Method { + case "GET": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"message": r.Message, "resets": r.resets.Load()}) + case "POST": + var body struct{} + if json.NewDecoder(req.Body).Decode(&body) != nil { + extension.WriteAdminError(w, 400, "invalid_config") + return + } + if strings.HasSuffix(req.URL.Path, "/probe") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"message": r.Message}) + return + } + r.resets.Add(1) + d.Events.Publish(req.Context(), event.Envelope{SchemaVersion: 1, ID: uuid.NewString(), Family: "extension.mutation", Timestamp: time.Now().UTC(), Outcome: "success", Mutation: &event.ExtensionMutation{ModuleID: d.Config.ModuleID, Operation: "reset"}}) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "reset"}) + default: + extension.WriteAdminError(w, 405, "method_not_allowed") + } +} diff --git a/tests/extensionconsumer/process_test.go b/tests/extensionconsumer/process_test.go new file mode 100644 index 00000000..632772ea --- /dev/null +++ b/tests/extensionconsumer/process_test.go @@ -0,0 +1,27 @@ +package extensionconsumer + +import ( + "encoding/json" + "os" + "testing" + + "github.com/agent-guide/agent-gateway/pkg/distribution/contracttest" + "github.com/agent-guide/agent-gateway/pkg/extension" + "github.com/agent-guide/agent-gateway/pkg/gateway/virtualkey/limiter" +) + +func TestDistributionProcessContract(t *testing.T) { + dir := os.Getenv("AGW_DISTRIBUTION_BIN_DIR") + if dir == "" { + t.Skip("run make test-extension-consumer") + } + contracttest.Distribution(t, contracttest.ProcessFixture{ + BinDir: dir, Runtime: Runtime(), Validation: Validation(), + ProbePath: "/admin/extensions/example.fixture.entries/probe", EventFileEnv: "AGW_FIXTURE_EVENT_FILE", + Envelopes: []extension.Envelope{ + {ModuleID: "example.fixture.entries", SchemaVersion: 1, Config: json.RawMessage(`{"message":"process-proof"}`)}, + {ModuleID: fixtureLimiterID, SchemaVersion: 1, Config: json.RawMessage(`{"namespace":"process","mode":"healthy"}`)}, + {ModuleID: limiter.ModuleID, SchemaVersion: 1, Config: json.RawMessage(`{"backend_module_id":"example.fixture.limiter","backend_type":"fixture"}`)}, + }, + }) +} diff --git a/tests/release/smoke_test.go b/tests/release/smoke_test.go index dfbd922a..7b66afd4 100644 --- a/tests/release/smoke_test.go +++ b/tests/release/smoke_test.go @@ -33,9 +33,17 @@ func TestReleaseProcesses(t *testing.T) { t.Skip("set AGW_RELEASE_SMOKE=1 or run make smoke-release") } repo := repositoryRoot(t) + binDir := os.Getenv("AGW_RELEASE_BIN_DIR") + if binDir == "" { + binDir = repo + } + binDir, err := filepath.Abs(binDir) + if err != nil { + t.Fatal(err) + } temp := t.TempDir() for _, binary := range []string{"agw", "agwd", "agwctl"} { - if _, err := os.Stat(filepath.Join(repo, binary)); err != nil { + if _, err := os.Stat(filepath.Join(binDir, binary)); err != nil { t.Fatalf("%s is not built; run make smoke-release: %v", binary, err) } } @@ -97,7 +105,7 @@ http://127.0.0.1:%d { // immediately before the child starts. This guarantees distinct ports and // minimizes the unavoidable handoff window for a process-level smoke test. gatewayPorts.release(t) - agw := startChild(t, repo, filepath.Join(temp, "agw.log"), filepath.Join(repo, "agw"), + agw := startChild(t, repo, filepath.Join(temp, "agw.log"), filepath.Join(binDir, "agw"), "run", "--config", caddyfile, "--adapter", "caddyfile") agw.name = "agw" children = append(children, agw) @@ -141,7 +149,7 @@ virtualKeys: - id: smoke-key allowed_route_ids: [smoke-turn, smoke-a2a] `, exampleURL)) - agwctl := filepath.Join(repo, "agwctl") + agwctl := filepath.Join(binDir, "agwctl") runCommand(t, repo, agwctl, "--admin-addr", adminURL, "validate", "-f", bundlePath) runCommand(t, repo, agwctl, "--admin-addr", adminURL, "apply", "-f", bundlePath) keyOutput := runCommand(t, repo, agwctl, "--output", "json", "--admin-addr", adminURL, "virtualkey", "get", "smoke-key") @@ -183,7 +191,7 @@ virtualKeys: staticPath := filepath.Join(temp, "standalone.yaml") writeFile(t, staticPath, "apiVersion: gateway.agw/v1alpha1\nkind: GatewayBundle\n") standalonePorts.release(t) - agwd := startChild(t, repo, filepath.Join(temp, "agwd.log"), filepath.Join(repo, "agwd"), + agwd := startChild(t, repo, filepath.Join(temp, "agwd.log"), filepath.Join(binDir, "agwd"), "--addr", fmt.Sprintf("127.0.0.1:%d", standalonePort), "--admin-addr", fmt.Sprintf("127.0.0.1:%d", standaloneAdminPort), "--config-store", filepath.Join(temp, "standalone.db"),