Skip to content
Merged
29 changes: 29 additions & 0 deletions internal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ These apply to every client-mode command. Each command selects the agent it talk
| `--output <fmt>` | `-o` | Output format: `text` (default), `json` (indented), or `jsonl` (one compact JSON object per line). |
| `--svc-param <k=v>` | | Service parameter (repeatable). The chosen transport defines how it's passed. Split on the first `=`. |
| `--auth <creds>` | | Shorthand for `--svc-param "Authorization=<creds>"`. |
| `--credential-helper <spec>` | | Credential helper returning the headers for an agent: `[scope=]path`. Repeatable. Not read from the local `.env`. See [Credentials](#credentials). |
| `--unix-socket <path>` | | Send every HTTP request through the Unix socket at `<path>`, whatever the URL's host. Applies to the card request and the REST and JSON-RPC transports, not gRPC. Not read from the local `.env`. |
| `--tenant <id>` | | Tenant identifier. Passed on every request. |
| `--timeout <dur>` | | Request timeout. Default `30s`. |
| `--verbose` | `-v` | Verbose output to stderr. |
Expand Down Expand Up @@ -293,6 +295,33 @@ Streams events to stdout until the task reaches a terminal state. Output format

---

## Credentials

A credential helper is a program that returns the HTTP headers to send to an agent. a2a implements the [Bazel credential helper protocol](https://github.com/EngFlow/credential-helper-spec), so helpers written for Bazel work unchanged: a2a runs `<command> get`, writes `{"uri": "<agent URL>"}` to its stdin, and reads `{"headers": {"Authorization": ["Bearer …"]}, "expires": "<RFC 3339>"}` from its stdout.

List helpers under `credential-helper` in a config file:

```yaml
# ~/.config/a2a-cli/config.yaml
credential-helper:
- command: [a2a-serve, credential] # every host
- scope: "*.corp.example.com"
command: [/usr/local/bin/corp-helper]
```

`--credential-helper` and `A2ACLI_CREDENTIAL_HELPER` take `[scope=]path`, like Bazel's `--credential_helper`: a path with no arguments. A scope is a host without a port (`example.com`), or `*.example.com` for the domain and its subdomains. Helpers are chosen per request: the card request uses the helper for the card reference as given (`--agent-card` or the positional URL), and A2A calls use the helper for the interface URL the client connects to. That URL is also the `uri` the helper receives. Unlike Bazel, which sends the full URL of each request, a2a does not append the `/.well-known/agent-card.json` path or the A2A method path. The helper with the most specific matching scope runs; among equal scopes the last one wins. Its headers are sent unless `--auth` or `--svc-param` set the same header. a2a runs a helper once per URL and reuses its headers until they expire. `--timeout` bounds each run. A helper that exits non-zero fails the command; a helper with no credentials for the URL prints `{}`.

a2a executes the configured helper command, so `credential-helper` and `--unix-socket` are not read from the local `.env` found by walking up from the current directory: a `.env` in a cloned repository could otherwise run a command or redirect requests. a2a ignores them there with a warning. Set them with the flag, the environment, `~/.config/a2a-cli/config.yaml`, or a file passed with `--config`.

### `auth get` - Inspect credentials

```bash
a2a auth get https://agent.example.com # header names, values redacted
a2a auth get https://agent.example.com --reveal # header values
```

---

## Server Mode

`a2a server` starts an A2A-compliant server backed by one of three modes.
Expand Down
28 changes: 28 additions & 0 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright 2026 The A2A Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cli

import (
"github.com/spf13/cobra"
)

func newAuthCmd(cfg *globalConfig) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Manage the credentials sent to agents",
}
cmd.AddCommand(newAuthGetCmd(cfg))
return cmd
}
112 changes: 112 additions & 0 deletions internal/cli/auth_get.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright 2026 The A2A Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cli

import (
"context"
"fmt"
"slices"
"strings"

"github.com/spf13/cobra"

"github.com/a2aproject/a2a-cli/internal/clierr"
"github.com/a2aproject/a2a-cli/internal/credhelper"
"github.com/a2aproject/a2a-cli/internal/flagparse"
)

func newAuthGetCmd(cfg *globalConfig) *cobra.Command {
var reveal bool
cmd := &cobra.Command{
Use: "get <agent-url>",
Short: "Print the headers the credential helper returns for an agent",
Long: `Print the headers the credential helper returns for an agent.

A credential helper follows the Bazel credential helper protocol:
https://github.com/EngFlow/credential-helper-spec. --credential-helper takes
"[scope=]path", where scope is a host such as example.com, or *.example.com
for a domain and its subdomains. Config files list {scope, command} entries
under credential-helper. The most specific helper for the agent's host runs.`,
Example: ` a2a auth get https://agent.example.com
a2a auth get https://agent.example.com --reveal`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var ref flagparse.URLOrPath
if err := ref.Set(args[0]); err != nil {
return err
}
target := ref.URL()
if !isHTTPURL(target) {
return clierr.Usage(fmt.Sprintf("auth get needs an http(s) agent URL, got %q", args[0]))
}
if cfg.credentials == nil {
return fmt.Errorf("no credential helper configured")
}
h, ok := credhelper.Select(cfg.credentials.Helpers, target)
if !ok {
return fmt.Errorf("no credential helper for %s", target)
}
ctx, cancel := context.WithTimeout(cmd.Context(), cfg.timeout)
defer cancel()
resp, err := h.Get(ctx, target)
if err != nil {
return err
}
headers := resp.Headers
if !reveal {
headers = redactHeaders(headers)
}
if cfg.IsJSON() {
return cfg.PrintJSON(map[string]any{"url": target, "helper": h.Spec, "headers": headers, "expires": resp.Expires})
}
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
slices.Sort(names)
rows := make([][]string, 0, len(names))
for _, name := range names {
rows = append(rows, []string{name, strings.Join(headers[name], ", ")})
}
if _, err := fmt.Fprintf(cfg.Out, "helper: %s\n", h.Spec); err != nil {
return err
}
if resp.Expires != "" {
if _, err := fmt.Fprintf(cfg.Out, "expires: %s\n", resp.Expires); err != nil {
return err
}
}
if len(rows) == 0 {
_, err := fmt.Fprintln(cfg.Out, "no headers")
return err
}
return cfg.PrintTable([]string{"HEADER", "VALUE"}, rows)
},
}
cmd.Flags().BoolVar(&reveal, "reveal", false, "Print header values instead of redacting them")
return cmd
}

func redactHeaders(headers map[string][]string) map[string][]string {
out := make(map[string][]string, len(headers))
for name, values := range headers {
redacted := make([]string, len(values))
for i := range values {
redacted[i] = "<redacted>"
}
out[name] = redacted
}
return out
}
12 changes: 11 additions & 1 deletion internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,11 @@ func TestInsecureCredentialWarning(t *testing.T) {
args: []string{"send", "-a", url, "--insecure", "hi"},
wantWarn: false,
},
{
name: "insecure with a credential helper warns",
args: []string{"send", "-a", url, "--insecure", "--credential-helper", credentialHelper(t), "hi"},
wantWarn: true,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -1297,14 +1302,19 @@ func runCMDWithConfig(t *testing.T, deps deps, args ...string) (string, error) {
}

func runCMDCapturingStderr(t *testing.T, args ...string) (stdout, stderr string, err error) {
t.Helper()
return runCMDCapturingStderrWithConfig(t, deps{poller: polling.Stream, cfgLoader: clicfg.LoadEmpty}, args...)
}

func runCMDCapturingStderrWithConfig(t *testing.T, deps deps, args ...string) (stdout, stderr string, err error) {
t.Helper()
var out, errBuf bytes.Buffer
cfg := &globalConfig{
Printer: output.NewPrinter(&out, output.ModeText),
svcParams: &flagparse.ServiceParams{},
errOut: &errBuf,
}
root := mustNewRoot(t, cfg, deps{poller: polling.Stream, cfgLoader: clicfg.LoadEmpty})
root := mustNewRoot(t, cfg, deps)
root.SetArgs(args)
err = root.Execute()
return out.String(), errBuf.String(), err
Expand Down
10 changes: 10 additions & 0 deletions internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,13 @@ func resolveCard(ctx context.Context, cfg *globalConfig, ref string) (*a2a.Agent
for k, vals := range cfg.svcParams.Params() {
opts = append(opts, agentcard.WithRequestHeader(k, strings.Join(vals, ", ")))
}
headers, err := cfg.cardHeaders(ctx, ref)
if err != nil {
return nil, err
}
for k, vals := range headers {
opts = append(opts, agentcard.WithRequestHeader(k, strings.Join(vals, ", ")))
}
cfg.logf("resolving agent card from %s", ref)
card, err := cardResolver(cfg).Resolve(ctx, ref, opts...)
if err != nil {
Expand Down Expand Up @@ -159,6 +166,9 @@ func clientFactoryOpts(cfg *globalConfig) []a2aclient.FactoryOption {
// total timeout so the --timeout context deadline is what bounds a request.
httpClient := &http.Client{Transport: cfg.httpTransport}
opts := []a2aclient.FactoryOption{a2aclient.WithDefaultsDisabled()}
if cfg.credentials != nil {
opts = append(opts, cfg.credentialInterceptor())
}
if cfg.a2aVersion == "" || cfg.a2aVersion == "1.0" {
opts = append(
opts,
Expand Down
87 changes: 87 additions & 0 deletions internal/cli/credentials.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright 2026 The A2A Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cli

import (
"context"
"fmt"
"net/url"
"strings"
"sync"

"github.com/a2aproject/a2a-go/v2/a2aclient"

"github.com/a2aproject/a2a-cli/internal/credhelper"
"github.com/a2aproject/a2a-cli/internal/unixsocket"
)

// initHTTPTransport routes HTTP requests through --unix-socket, if set.
func (g *globalConfig) initHTTPTransport() {
if g.unixSocket != "" {
g.httpTransport = unixsocket.Transport(g.unixSocket)
}
}

// initCredentials sets up --credential-helper. Helpers run only when a
// request is made to a host in their scope.
func (g *globalConfig) initCredentials() {
if helpers := g.credentialHelpers.Helpers(); len(helpers) > 0 {
g.credentials = &credhelper.Source{Helpers: helpers, Logf: g.logf}
}
}

// cardHeaders returns the helper headers for the card request to ref that
// --auth and --svc-param don't already set.
func (g *globalConfig) cardHeaders(ctx context.Context, ref string) (map[string][]string, error) {
if g.credentials == nil || !isHTTPURL(ref) {
return nil, nil
}
_, resp, err := g.credentials.Headers(ctx, ref)
if err != nil || resp == nil {
return nil, err
}
explicit := g.svcParams.Params()
headers := map[string][]string{}
for name, values := range resp.Headers {
if len(explicit.Get(name)) == 0 {
headers[name] = values
}
}
return headers, nil
}

// credentialInterceptor adds the helper headers to every A2A call, chosen
// by the host of the interface the client is connected to.
func (g *globalConfig) credentialInterceptor() a2aclient.FactoryOption {
var warn sync.Once
return a2aclient.WithCallInterceptors(&credhelper.Interceptor{
Source: g.credentials,
OnApply: func(headers map[string][]string) {
if !g.insecureGRPC {
return
}
for name := range headers {
if strings.EqualFold(name, "Authorization") {
warn.Do(func() { _, _ = fmt.Fprintln(g.stderr(), insecureCredentialWarning) })
}
}
},
})
}

func isHTTPURL(raw string) bool {
u, err := url.Parse(raw)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
Loading
Loading