diff --git a/cmd/agents/agents.go b/cmd/agents/agents.go index d988a83f9..92bbcaaef 100644 --- a/cmd/agents/agents.go +++ b/cmd/agents/agents.go @@ -19,6 +19,7 @@ import ( container "github.com/inference-gateway/cli/internal/container" containerruntime "github.com/inference-gateway/cli/internal/platform/container" a2a "github.com/inference-gateway/cli/internal/protocols/a2a" + a2ainfra "github.com/inference-gateway/cli/internal/protocols/a2a/infrastructure" ) type command struct { @@ -635,6 +636,8 @@ func (c *command) agentsStatus(cmd *cobra.Command, args []string) error { } } + gateway := c.state.Config().Gateway + a2ainfra.UseGatewayCredential(gateway.URL, gateway.APIKey) report := a2a.ProbeAgents(context.Background(), local, external) format, _ := cmd.Flags().GetString("format") diff --git a/docs/a2a-connections.md b/docs/a2a-connections.md index 0afec2a8d..08144a809 100644 --- a/docs/a2a-connections.md +++ b/docs/a2a-connections.md @@ -263,6 +263,24 @@ a2a: - For OIDC the token endpoint is read from the `securitySchemes` of the agent card. An optional `issuer_url` pins the issuer the card may point at and is used when the card declares none. +#### Authenticating to the gateway + +With `AUTH_ENABLED` the Inference Gateway requires a bearer token on `POST /a2a`, the same token it requires for +inference. When an agent URL is the configured `gateway.url`, `infer` sends `gateway.api_key` as that token on +every A2A request to it. + +```bash +export INFER_GATEWAY_URL=https://gateway.example.com +export INFER_GATEWAY_API_KEY="$ACCESS_TOKEN" +export INFER_A2A_AGENTS=https://gateway.example.com +``` + +- The gateway token is sent to the gateway's origin only, never to another agent. +- An `auth` block for the gateway in `agents.yaml` takes precedence over `gateway.api_key`. Use `auth.oidc` + there for long sessions, since it refreshes the token and `gateway.api_key` is a fixed value. +- A guardrail policy that blocks an A2A request reaches the model as a tool error with the policy's message. +- With `gateway.run` and a port the CLI reassigned, the match still uses the configured `gateway.url`. + ### Network Security - A2A connections require proper URL validation @@ -310,6 +328,7 @@ Tools provide descriptive error messages: - "agent_url parameter is required and must be a string" - "A2A task submission failed: [specific error]" - "Authentication failed for A2A agent \"[name]\": [reason]" - missing or rejected credentials (`401`/`403`) +- "The request was refused by a guardrail policy: [policy message]" - a gateway guardrail blocked the request (`403`) ## Troubleshooting diff --git a/internal/container/container.go b/internal/container/container.go index b3ae8700c..923df3af3 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -214,6 +214,8 @@ func NewServiceContainer(cfg *config.Config) *ServiceContainer { cfg.SetConfigDir(config.ResolveConfigDir()) + a2ainfra.UseGatewayCredential(cfg.Gateway.URL, cfg.Gateway.APIKey) + if cfg.Gateway.Mock { container.startMockGateway() } diff --git a/internal/protocols/a2a/README.md b/internal/protocols/a2a/README.md index a51ea738a..dbddfb5f2 100644 --- a/internal/protocols/a2a/README.md +++ b/internal/protocols/a2a/README.md @@ -18,6 +18,10 @@ and the tools return normal tool results. grant with the secrets read from the environment. The token endpoint of the grant comes from the security schemes on the agent card. `infrastructure.NewClient` adds them to every request to that agent's origin, and a rejection reaches the model as an authentication failure naming the agent. +- The gateway is an agent too. An agent URL on the origin of `gateway.url` gets `gateway.api_key` as its bearer + token, unless `agents.yaml` gives it an `auth` block. The composition root hands the pair over with + `infrastructure.UseGatewayCredential`. A guardrail refusal from the gateway, a 403 with a JSON-RPC error, + reaches the model with the policy's message. - `ReconcileAgents` brings a running chat in line with an edited `agents.yaml` on `/reload`. It reports the difference as `AgentChanges`. A removed agent is announced as `AgentStateRemoved` on the status stream once its goroutines have exited, and the TUI readiness drops it. diff --git a/internal/protocols/a2a/failure.go b/internal/protocols/a2a/failure.go index 1f8b4cd34..815df4a9e 100644 --- a/internal/protocols/a2a/failure.go +++ b/internal/protocols/a2a/failure.go @@ -9,10 +9,11 @@ import ( a2ainfra "github.com/inference-gateway/cli/internal/protocols/a2a/infrastructure" ) -// requestFailure describes a failed request to an agent for the model. An -// authentication failure is named as one, anything else keeps the action and its error. +// requestFailure describes a failed request to an agent for the model. A policy +// refusal or an authentication failure is named as one, anything else keeps +// the action and its error. func requestFailure(agentURL, action string, err error) string { - if message, ok := a2ainfra.AuthFailure(agentURL, err); ok { + if message, ok := a2ainfra.Rejection(agentURL, err); ok { return message } return fmt.Sprintf("%s: %v", action, err) diff --git a/internal/protocols/a2a/infrastructure/auth.go b/internal/protocols/a2a/infrastructure/auth.go index 404493de5..c014a3904 100644 --- a/internal/protocols/a2a/infrastructure/auth.go +++ b/internal/protocols/a2a/infrastructure/auth.go @@ -57,11 +57,54 @@ func AuthFailure(agentURL string, err error) (string, bool) { } rejected := &AuthError{ Agent: agentDisplayName(agentURL), - Reason: fmt.Sprintf("the agent rejected the request with status %s, check its auth settings in agents.yaml", match[1]), + Reason: fmt.Sprintf("the agent rejected the request with status %s, check %s", match[1], credentialSetting(agentURL)), } return rejected.Error(), true } +// credentialSetting names the setting that holds the credentials sent to agentURL. +func credentialSetting(agentURL string) string { + if agent, ok := configuredAgent(agentURL); (!ok || agent.Auth == nil) && isGateway(agentURL) { + return "gateway.api_key" + } + return "its auth settings in agents.yaml" +} + +// refusedByPolicy matches the ADK client's error for a 403 response and captures its body. +var refusedByPolicy = regexp.MustCompile(`unexpected status code[^:]*: 403, body: (\{.*\})`) + +// PolicyRefusal describes err for the model when the gateway's guardrails +// refused the request: a 403 whose body is a JSON-RPC error. The message +// carries the policy's own words. +func PolicyRefusal(err error) (string, bool) { + if err == nil { + return "", false + } + match := refusedByPolicy.FindStringSubmatch(err.Error()) + if match == nil { + return "", false + } + var envelope struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal([]byte(match[1]), &envelope) != nil || envelope.Error.Message == "" { + return "", false + } + return "The request was refused by a guardrail policy: " + envelope.Error.Message, true +} + +// Rejection describes err for the model when the agent turned the request +// down for good, so retrying is pointless: a policy refusal or an +// authentication failure. +func Rejection(agentURL string, err error) (string, bool) { + if message, ok := PolicyRefusal(err); ok { + return message, true + } + return AuthFailure(agentURL, err) +} + func agentDisplayName(agentURL string) string { if agent, ok := configuredAgent(agentURL); ok { return agent.Name @@ -80,30 +123,54 @@ func configuredAgent(agentURL string) (config.AgentEntry, bool) { return agents.EntryForURL(agentURL) } +// gatewayCredential is the bearer token the gateway expects, and the origin it belongs to. +// ponytail: one process-wide value set at startup. Pass it through NewClient +// if clients ever need different gateways. +var gatewayCredential struct { + origin string + token string +} + +// UseGatewayCredential makes apiKey the bearer token of A2A requests to the +// gateway at gatewayURL, the token the gateway also expects on inference. +func UseGatewayCredential(gatewayURL, apiKey string) { + gatewayCredential.origin = config.URLOrigin(gatewayURL) + gatewayCredential.token = apiKey +} + // authTransport adds an agent's credentials to the requests sent to its // origin. Requests to any other origin, such as a redirect target, pass // through untouched so a credential never leaves its agent. type authTransport struct { base http.RoundTripper - agent config.AgentEntry origin string + token func() (string, error) } -// newAuthTransport returns the transport for the agent at agentURL: one that -// authenticates when agents.yaml gives the agent credentials, the default otherwise. +// newAuthTransport returns the transport for the agent at agentURL. It +// authenticates with the credentials agents.yaml gives the agent, or with the +// gateway credential when the agent is the gateway. Any other agent gets the default. func newAuthTransport(agentURL string) http.RoundTripper { - agent, ok := configuredAgent(agentURL) - if !ok || agent.Auth == nil { - return http.DefaultTransport + origin := config.URLOrigin(agentURL) + if agent, ok := configuredAgent(agentURL); ok && agent.Auth != nil { + return authTransport{base: http.DefaultTransport, origin: origin, token: func() (string, error) { return bearerToken(agent) }} } - return authTransport{base: http.DefaultTransport, agent: agent, origin: config.URLOrigin(agentURL)} + if token := gatewayCredential.token; token != "" && isGateway(agentURL) { + return authTransport{base: http.DefaultTransport, origin: origin, token: func() (string, error) { return token, nil }} + } + return http.DefaultTransport +} + +func isGateway(agentURL string) bool { + origin := config.URLOrigin(agentURL) + return origin != "" && origin == gatewayCredential.origin } func (t authTransport) RoundTrip(req *http.Request) (*http.Response, error) { if config.URLOrigin(req.URL.String()) != t.origin { return t.base.RoundTrip(req) } - token, err := bearerToken(t.agent) + token, err := t.token() if err != nil { if req.Body != nil { _ = req.Body.Close() diff --git a/internal/protocols/a2a/infrastructure/auth_test.go b/internal/protocols/a2a/infrastructure/auth_test.go index 48645f224..e3046ab51 100644 --- a/internal/protocols/a2a/infrastructure/auth_test.go +++ b/internal/protocols/a2a/infrastructure/auth_test.go @@ -331,3 +331,139 @@ func fetchTask(t *testing.T, c client.A2AClient) error { _, err := c.GetTask(t.Context(), adk.GetTaskRequest{ID: "t1"}) return err } + +// useGatewayCredential makes key the credential of the gateway at gatewayURL for one test. +func useGatewayCredential(t *testing.T, gatewayURL, key string) { + t.Helper() + UseGatewayCredential(gatewayURL, key) + t.Cleanup(func() { UseGatewayCredential("", "") }) +} + +func TestNewClient_SendsGatewayKeyOnEveryRequestType(t *testing.T) { + requests := map[string]func(t *testing.T, c client.A2AClient) error{ + "agent card": fetchCard, + "send message": func(t *testing.T, c client.A2AClient) error { + _, err := c.SendTask(t.Context(), adk.SendMessageRequest{}) + return err + }, + "get task": fetchTask, + "cancel task": func(t *testing.T, c client.A2AClient) error { + _, err := c.CancelTask(t.Context(), adk.CancelTaskRequest{ID: "t1"}) + return err + }, + } + for name, send := range requests { + t.Run(name, func(t *testing.T) { + gateway := &authRecorder{} + srv := httptest.NewServer(gateway) + defer srv.Close() + writeAgentsYAML(t, "agents: []\n") + useGatewayCredential(t, srv.URL+"/v1", "gateway-key") + + if err := send(t, NewClient(srv.URL)); err != nil { + t.Fatalf("request failed: %v", err) + } + if got := gateway.headers(); len(got) != 1 || got[0] != "Bearer gateway-key" { + t.Fatalf("Authorization headers = %q, want one Bearer gateway-key", got) + } + }) + } +} + +func TestNewClient_KeepsGatewayKeyOnTheGateway(t *testing.T) { + other := &authRecorder{} + otherSrv := httptest.NewServer(other) + defer otherSrv.Close() + gateway := httptest.NewServer(http.RedirectHandler(otherSrv.URL+"/.well-known/agent-card.json", http.StatusFound)) + defer gateway.Close() + writeAgentsYAML(t, "agents: []\n") + useGatewayCredential(t, gateway.URL, "gateway-key") + + if _, err := NewClient(gateway.URL).GetAgentCard(t.Context()); err != nil { + t.Fatalf("redirected card fetch: %v", err) + } + if _, err := NewClient(otherSrv.URL).GetAgentCard(t.Context()); err != nil { + t.Fatalf("other agent card fetch: %v", err) + } + for _, header := range other.headers() { + if header != "" { + t.Fatalf("another origin received Authorization %q", header) + } + } +} + +func TestNewClient_AgentCredentialsBeatTheGatewayKey(t *testing.T) { + gateway := &authRecorder{} + srv := httptest.NewServer(gateway) + defer srv.Close() + writeAgentsYAML(t, fmt.Sprintf("agents:\n - name: gateway\n url: %s\n auth:\n token_env: GATEWAY_AGENT_TOKEN\n", srv.URL)) + t.Setenv("GATEWAY_AGENT_TOKEN", "agent-token") + useGatewayCredential(t, srv.URL, "gateway-key") + + if err := fetchTask(t, NewClient(srv.URL)); err != nil { + t.Fatalf("request failed: %v", err) + } + if got := gateway.headers(); len(got) != 1 || got[0] != "Bearer agent-token" { + t.Fatalf("Authorization headers = %q, want one Bearer agent-token", got) + } +} + +// TestRejection_ReadsTheGatewayResponses pins the ADK error text Rejection +// reads a gateway 401 and a guardrail 403 from. +func TestRejection_ReadsTheGatewayResponses(t *testing.T) { + tests := []struct { + name string + status int + body string + want []string + wantAbsent string + }{ + { + name: "401 is an authentication failure without the body", + status: http.StatusUnauthorized, + body: `{"error":"unauthorized body-marker"}`, + want: []string{"Authentication failed", "401", "gateway.api_key"}, + wantAbsent: "body-marker", + }, + { + name: "403 with a JSON-RPC error carries the policy message", + status: http.StatusForbidden, + body: `{"jsonrpc":"2.0","id":"1","error":{"code":-32001,"message":"request contains sensitive payment information"}}`, + want: []string{"refused by a guardrail policy", "request contains sensitive payment information"}, + wantAbsent: "Authentication failed", + }, + { + name: "403 without a JSON-RPC error stays an authentication failure", + status: http.StatusForbidden, + body: `{"error":"forbidden body-marker"}`, + want: []string{"Authentication failed", "403"}, + wantAbsent: "body-marker", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, tt.body, tt.status) + })) + defer srv.Close() + writeAgentsYAML(t, "agents: []\n") + useGatewayCredential(t, srv.URL, "gateway-key") + + message, rejected := Rejection(srv.URL, fetchTask(t, NewClient(srv.URL))) + + if !rejected { + t.Fatal("Rejection did not recognise the response") + } + for _, want := range tt.want { + if !strings.Contains(message, want) { + t.Errorf("message %q does not contain %q", message, want) + } + } + for _, leaked := range []string{"gateway-key", tt.wantAbsent} { + if strings.Contains(message, leaked) { + t.Errorf("message %q contains %q", message, leaked) + } + } + }) + } +} diff --git a/internal/protocols/a2a/status.go b/internal/protocols/a2a/status.go index 0ea127272..aa3a0ddf9 100644 --- a/internal/protocols/a2a/status.go +++ b/internal/protocols/a2a/status.go @@ -65,7 +65,7 @@ func probeAgentCard(ctx context.Context, status *AgentStatus) { if _, err := a2ainfra.NewClient(status.URL).GetAgentCard(checkCtx); err != nil { status.State = a2adomain.AgentStateFailed.String() status.Error = err.Error() - if message, rejected := a2ainfra.AuthFailure(status.URL, err); rejected { + if message, rejected := a2ainfra.Rejection(status.URL, err); rejected { status.Error = message } return diff --git a/internal/protocols/a2a/submit_task.go b/internal/protocols/a2a/submit_task.go index 9dfa82afe..a8b07043c 100644 --- a/internal/protocols/a2a/submit_task.go +++ b/internal/protocols/a2a/submit_task.go @@ -335,7 +335,7 @@ func (t *SubmitTaskTool) runA2APolling( pollAttempt, currentInterval, time.Since(state.StartedAt)) currentTask, err := t.queryTask(ctx, adkClient, taskID) - if message, rejected := a2ainfra.AuthFailure(agentURL, err); rejected { + if message, rejected := a2ainfra.Rejection(agentURL, err); rejected { return authFailedResult(agentURL, taskID, state.ContextID, message) } if err != nil || currentTask == nil { @@ -359,8 +359,8 @@ func (t *SubmitTaskTool) runA2APolling( } } -// authFailedResult ends the polling of a task whose agent no longer accepts -// the credentials, since retrying would be rejected the same way forever. +// authFailedResult ends the polling of a task whose agent rejects the +// request for good, since retrying would be rejected the same way forever. func authFailedResult(agentURL, taskID, contextID, message string) agentdomain.ToolExecutionResult { return agentdomain.ToolExecutionResult{ ToolName: ToolSubmitTask,