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
13 changes: 13 additions & 0 deletions cmd/agents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,10 @@ func (c *command) showAgent(cmd *cobra.Command, name string) error {
fmt.Println(c.renderer.Field("Model", agent.Model))
}

if agent.Auth != nil {
fmt.Println(c.renderer.Field("Auth", describeAuth(*agent.Auth)))
}

if len(agent.Environment) > 0 {
fmt.Println()
fmt.Println(c.renderer.Title("Environment Variables"))
Expand All @@ -822,6 +826,15 @@ func (c *command) showAgent(cmd *cobra.Command, name string) error {
return nil
}

// describeAuth names an agent's authentication mode and the environment
// variable its secret is read from. The secret itself is never loaded here.
func describeAuth(auth config.AgentAuth) string {
if auth.OIDC != nil {
return fmt.Sprintf("OIDC client %s (secret from %s)", auth.OIDC.ClientID, auth.OIDC.ClientSecretEnv)
}
return fmt.Sprintf("bearer token from %s", auth.TokenEnv)
}

func (c *command) initAgents(cmd *cobra.Command, _ []string) error {
path, err := agentsConfigPath(cmd)
if err != nil {
Expand Down
22 changes: 22 additions & 0 deletions cmd/agents/agents_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,25 @@ func TestMarkdownAgentMode(t *testing.T) {
})
}
}

func TestDescribeAuth_NamesTheVariableNotTheSecret(t *testing.T) {
t.Setenv("RESEARCH_TOKEN", "s3cret-token")
t.Setenv("BILLING_CLIENT_SECRET", "s3cret-client")

bearer := describeAuth(config.AgentAuth{TokenEnv: "RESEARCH_TOKEN"})
oidc := describeAuth(config.AgentAuth{OIDC: &config.AgentOIDC{
IssuerURL: "https://idp.example.com/realms/agents",
ClientID: "infer",
ClientSecretEnv: "BILLING_CLIENT_SECRET",
}})

if bearer != "bearer token from RESEARCH_TOKEN" {
t.Errorf("bearer description = %q", bearer)
}
if !strings.Contains(oidc, "BILLING_CLIENT_SECRET") || !strings.Contains(oidc, "infer") {
t.Errorf("oidc description = %q", oidc)
}
if strings.Contains(bearer+oidc, "s3cret") {
t.Errorf("descriptions leak a secret: %q %q", bearer, oidc)
}
}
47 changes: 47 additions & 0 deletions config/agents.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"cmp"
"net/url"
"os"
"path/filepath"
Expand All @@ -26,6 +27,25 @@ type AgentEntry struct {
Run bool `yaml:"run" mapstructure:"run"`
Model string `yaml:"model,omitempty" mapstructure:"model,omitempty"`
Environment map[string]string `yaml:"environment,omitempty" mapstructure:"environment,omitempty"`
Auth *AgentAuth `yaml:"auth,omitempty" mapstructure:"auth,omitempty"`
}

// AgentAuth says how to authenticate to an agent: a static bearer token or an
// OIDC client-credentials grant. Secrets are named by environment variable and
// never stored, so agents.yaml stays safe to print and to rewrite.
type AgentAuth struct {
TokenEnv string `yaml:"token_env,omitempty" mapstructure:"token_env,omitempty"`
OIDC *AgentOIDC `yaml:"oidc,omitempty" mapstructure:"oidc,omitempty"`
}

// AgentOIDC is the client of a client-credentials grant. Where to get the token
// is declared by the agent card's security schemes. IssuerURL is optional: the
// card may then only point at that issuer, and it is used when the card declares none.
type AgentOIDC struct {
ClientID string `yaml:"client_id" mapstructure:"client_id"`
ClientSecretEnv string `yaml:"client_secret_env" mapstructure:"client_secret_env"`
Audience string `yaml:"audience,omitempty" mapstructure:"audience,omitempty"`
IssuerURL string `yaml:"issuer_url,omitempty" mapstructure:"issuer_url,omitempty"`
}

// DefaultAgentsConfig returns a default agents configuration
Expand Down Expand Up @@ -148,6 +168,33 @@ func (c *AgentsConfig) DeleteEntry(name string) error {
// ListEntries implements CollectionConfig.
func (c *AgentsConfig) ListEntries() []AgentEntry { return c.Agents }

// EntryForURL returns the configured agent served from the origin of rawURL.
// The whole origin is compared, so an agent's credentials never reach another
// service on the same host.
func (c *AgentsConfig) EntryForURL(rawURL string) (AgentEntry, bool) {
origin := URLOrigin(rawURL)
if origin == "" {
return AgentEntry{}, false
}
i := slices.IndexFunc(c.Agents, func(agent AgentEntry) bool { return URLOrigin(agent.URL) == origin })
if i < 0 {
return AgentEntry{}, false
}
return c.Agents[i], true
}

// URLOrigin returns the scheme, host and port of rawURL in a comparable form,
// with the scheme's default port filled in. It returns "" for a URL without a host.
func URLOrigin(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil || u.Hostname() == "" {
return ""
}
scheme := strings.ToLower(u.Scheme)
port := cmp.Or(u.Port(), map[string]string{"http": "80", "https": "443"}[scheme])
return scheme + "://" + strings.ToLower(u.Hostname()) + ":" + port
}

// GetAgentURLs returns URLs of all configured agents.
func GetAgentURLs(path string) ([]string, error) {
cfg, err := LoadAgents(path)
Expand Down
70 changes: 70 additions & 0 deletions config/agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,3 +423,73 @@ func TestIsLocalA2AAgent(t *testing.T) {
})
}
}

func TestEntryForURL_MatchesTheWholeOrigin(t *testing.T) {
cfg := &config.AgentsConfig{Agents: []config.AgentEntry{
{Name: "research", URL: "https://Research.example.com/a2a"},
{Name: "local", URL: "http://localhost:8080"},
}}

tests := []struct {
name string
url string
want string
}{
{"same origin, other path", "https://research.example.com/.well-known/agent-card.json", "research"},
{"default port spelled out", "https://research.example.com:443", "research"},
{"same host, other port", "https://research.example.com:8443", ""},
{"same host, other scheme", "http://research.example.com", ""},
{"explicit port", "http://localhost:8080/a2a", "local"},
{"other port on localhost", "http://localhost:9090", ""},
{"not a URL", "research", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
agent, ok := cfg.EntryForURL(tt.url)
require.Equal(t, tt.want != "", ok)
require.Equal(t, tt.want, agent.Name)
})
}
}

// TestLoadAgents_AuthNamesVariablesNotSecrets: a loaded and rewritten
// agents.yaml only ever holds the variable names, never the secrets behind them.
func TestLoadAgents_AuthNamesVariablesNotSecrets(t *testing.T) {
t.Setenv("RESEARCH_TOKEN", "s3cret-token")
t.Setenv("BILLING_CLIENT_SECRET", "s3cret-client")
path := filepath.Join(t.TempDir(), "agents.yaml")
require.NoError(t, os.WriteFile(path, []byte(`agents:
- name: research
url: https://research.example.com
auth:
token_env: RESEARCH_TOKEN
- name: billing
url: https://billing.example.com
auth:
oidc:
issuer_url: https://idp.example.com/realms/agents
client_id: infer
client_secret_env: BILLING_CLIENT_SECRET
audience: billing-agent
`), 0o600))

cfg, err := config.LoadAgents(path)
require.NoError(t, err)
research, err := cfg.ReadEntry("research")
require.NoError(t, err)
require.Equal(t, &config.AgentAuth{TokenEnv: "RESEARCH_TOKEN"}, research.Auth)
billing, err := cfg.ReadEntry("billing")
require.NoError(t, err)
require.Equal(t, &config.AgentOIDC{
IssuerURL: "https://idp.example.com/realms/agents",
ClientID: "infer",
ClientSecretEnv: "BILLING_CLIENT_SECRET",
Audience: "billing-agent",
}, billing.Auth.OIDC)

require.NoError(t, cfg.DeleteEntry("research"))
rewritten, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(rewritten), "client_secret_env: BILLING_CLIENT_SECRET")
require.NotContains(t, string(rewritten), "s3cret")
}
11 changes: 11 additions & 0 deletions docs/a2a-connections.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,16 @@ a2a:
- Tools validate required parameters before execution
- Invalid configurations result in clear error messages

### Authentication

- An agent entry in `agents.yaml` can carry an `auth` block with a static bearer token or an OIDC
client-credentials grant. See [Authentication](agents-configuration.md#authentication).
- Secrets are read from environment variables named in the block. They are never stored in `agents.yaml` and
never shown in logs, tool results or `infer agents list`.
- Credentials are sent only to the origin of the agent they are configured for.
- 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.

### Network Security

- A2A connections require proper URL validation
Expand Down Expand Up @@ -299,6 +309,7 @@ Tools provide descriptive error messages:
- "A2A connections are disabled in configuration"
- "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`)

## Troubleshooting

Expand Down
77 changes: 77 additions & 0 deletions docs/agents-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ agents:
model: provider/model-name # Optional: AI model to use (e.g., deepseek/deepseek-v4-pro)
environment: # Optional: Environment variables for the agent
KEY: VALUE
auth: # Optional: Credentials sent to the agent (see Authentication)
token_env: AGENT_TOKEN
```

### Field Descriptions
Expand All @@ -48,6 +50,8 @@ agents:
When `agent.model` is empty too, no model is passed and the agent starts on its own default.
- **environment**: Key-value pairs of environment variables to pass to the agent when running locally.
Supports environment variable substitution using `$VAR` or `${VAR}` syntax.
- **auth**: Credentials the CLI sends to the agent, a static bearer token or an OIDC client-credentials grant.
It names the environment variables that hold the secrets. See [Authentication](#authentication).

## CLI Commands

Expand Down Expand Up @@ -168,6 +172,79 @@ infer agents remove code-reviewer
infer agents remove project-helper --project
```

## Authentication

An agent that protects its `/a2a` endpoint needs credentials. Add an `auth` block to the agent's entry and the
CLI sends an `Authorization: Bearer` header on every request to that agent: the agent card fetch, task
submission, background task polling, task cancellation and the liveness probe.

The `auth` block never holds a secret. It names the environment variable the secret is read from, so
`agents.yaml` stays safe to commit and `infer agents list` and `infer agents show` only ever print the variable name.

### Static Bearer Token

For an ADK agent started with `A2A_AUTH_TOKEN`:

```yaml
agents:
- name: research
url: https://research.example.com
auth:
token_env: RESEARCH_AGENT_TOKEN
```

```bash
export RESEARCH_AGENT_TOKEN=...
```

### OIDC Client Credentials

For an ADK agent started with `A2A_AUTH_ENABLED`, `A2A_AUTH_ISSUER_URL`, `A2A_AUTH_CLIENT_ID` and `A2A_AUTH_AUDIENCE`:

```yaml
agents:
- name: billing
url: https://billing.example.com
auth:
oidc:
client_id: infer
client_secret_env: BILLING_AGENT_CLIENT_SECRET
audience: billing-agent # Optional
issuer_url: https://idp.example.com/realms/agents # Optional, see below
```

You do not configure where the token comes from. As the A2A protocol defines, the agent declares it in the
`securitySchemes` of its agent card, and the CLI reads it from there:

- an `openIdConnect` scheme gives the OpenID Connect discovery URL, whose document names the token endpoint
- an `oauth2` scheme with a `clientCredentials` flow gives the token URL directly

The scopes the card's `securityRequirements` list for that scheme are requested with the token. The CLI fetches
a token with the client-credentials grant, reuses it across requests and refreshes it shortly before it expires.

`issuer_url` is optional and does two things:

- **Pin.** The client secret is sent to the token endpoint the card names. With `issuer_url` set, the CLI refuses
a card that points anywhere outside that issuer, so a tampered card cannot collect the secret.
- **Fallback.** An agent whose card declares no security scheme cannot say where its tokens come from. The CLI
then discovers the token endpoint from `issuer_url`. Without it, such an agent fails with an authentication error.

The [a2a-auth example](../examples/a2a-auth/) runs one agent behind a bearer token and one behind OIDC with
Keycloak, with a mock model and no API key.

### Notes

- Set either `token_env` or `oidc`, not both.
- Reference the variable by name (`token_env: RESEARCH_AGENT_TOKEN`). Do not write `${RESEARCH_AGENT_TOKEN}`
here - substitution would put the secret itself in the field.
- Credentials are matched by origin (scheme, host and port) of the agent's `url` and are only sent there. A
redirect to another origin or another service on the same host does not receive them.
- The credentials also apply when `INFER_A2A_AGENTS` supplies the agent list, as long as `agents.yaml` has an
entry with the same origin.
- A missing variable, a failed token request or a `401`/`403` from the agent is reported to the model as
`Authentication failed for A2A agent "<name>": ...`. A background task whose polling is rejected ends as failed.
- There are no `infer agents add` flags for `auth` yet - edit `agents.yaml` directly.

## Environment Variable Substitution

The agents configuration supports environment variable substitution, allowing you to reference environment
Expand Down
1 change: 1 addition & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Most examples ship a Docker Compose file:
| [postgres-storage](../examples/postgres-storage/) | Persisting conversations to PostgreSQL |
| [a2a-traces](../examples/a2a-traces/) | End-to-end OpenTelemetry traces between the CLI and an A2A agent |
| [a2a-gateway](../examples/a2a-gateway/) | Every A2A agent behind the gateway, addressed by tenant, with a mock model and no API key |
| [a2a-auth](../examples/a2a-auth/) | A2A agents behind a bearer token and behind OIDC with Keycloak, with a mock model and no API key |

There is also an [examples/kubernetes](../examples/kubernetes/) manifest set for running the gateway and an
agent on a cluster.
Expand Down
14 changes: 14 additions & 0 deletions examples/a2a-auth/.infer/agents.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
agents:
- name: token-agent
url: http://localhost:8091
run: false
auth:
token_env: TOKEN_AGENT_TOKEN
- name: oidc-agent
url: http://localhost:8092
run: false
auth:
oidc:
client_id: infer
client_secret_env: OIDC_AGENT_CLIENT_SECRET
Loading
Loading