Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/agents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
19 changes: 19 additions & 0 deletions docs/a2a-connections.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions internal/container/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
4 changes: 4 additions & 0 deletions internal/protocols/a2a/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions internal/protocols/a2a/failure.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
85 changes: 76 additions & 9 deletions internal/protocols/a2a/infrastructure/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
136 changes: 136 additions & 0 deletions internal/protocols/a2a/infrastructure/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
})
}
}
2 changes: 1 addition & 1 deletion internal/protocols/a2a/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions internal/protocols/a2a/submit_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
Loading