diff --git a/internal/README.md b/internal/README.md index 07738f0..92c01b5 100644 --- a/internal/README.md +++ b/internal/README.md @@ -37,6 +37,8 @@ These apply to every client-mode command. Each command selects the agent it talk | `--output ` | `-o` | Output format: `text` (default), `json` (indented), or `jsonl` (one compact JSON object per line). | | `--svc-param ` | | Service parameter (repeatable). The chosen transport defines how it's passed. Split on the first `=`. | | `--auth ` | | Shorthand for `--svc-param "Authorization="`. | +| `--credential-helper ` | | Credential helper returning the headers for an agent: `[scope=]path`. Repeatable. Not read from the local `.env`. See [Credentials](#credentials). | +| `--unix-socket ` | | Send every HTTP request through the Unix socket at ``, 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 ` | | Tenant identifier. Passed on every request. | | `--timeout ` | | Request timeout. Default `30s`. | | `--verbose` | `-v` | Verbose output to stderr. | @@ -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 ` get`, writes `{"uri": ""}` to its stdin, and reads `{"headers": {"Authorization": ["Bearer …"]}, "expires": ""}` 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. diff --git a/internal/cli/auth.go b/internal/cli/auth.go new file mode 100644 index 0000000..19e151d --- /dev/null +++ b/internal/cli/auth.go @@ -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 +} diff --git a/internal/cli/auth_get.go b/internal/cli/auth_get.go new file mode 100644 index 0000000..5843609 --- /dev/null +++ b/internal/cli/auth_get.go @@ -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 ", + 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] = "" + } + out[name] = redacted + } + return out +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 19d3d69..5871cb1 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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 { @@ -1297,6 +1302,11 @@ 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{ @@ -1304,7 +1314,7 @@ func runCMDCapturingStderr(t *testing.T, args ...string) (stdout, stderr string, 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 diff --git a/internal/cli/client.go b/internal/cli/client.go index 3158a44..6a88570 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -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 { @@ -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, diff --git a/internal/cli/credentials.go b/internal/cli/credentials.go new file mode 100644 index 0000000..6b3e956 --- /dev/null +++ b/internal/cli/credentials.go @@ -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 != "" +} diff --git a/internal/cli/credentials_test.go b/internal/cli/credentials_test.go new file mode 100644 index 0000000..d672ba0 --- /dev/null +++ b/internal/cli/credentials_test.go @@ -0,0 +1,433 @@ +// 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" + "encoding/json" + "fmt" + "iter" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/google/go-cmp/cmp" + + "github.com/a2aproject/a2a-cli/internal/clicfg" + "github.com/a2aproject/a2a-cli/internal/clierr" + "github.com/a2aproject/a2a-cli/internal/localsrv" +) + +type recordedRequest struct { + Path string + Auth string +} + +// authRecorder records the path and Authorization header of every request. +type authRecorder struct { + mu sync.Mutex + seen []recordedRequest +} + +func (r *authRecorder) wrap(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + r.mu.Lock() + r.seen = append(r.seen, recordedRequest{Path: req.URL.Path, Auth: req.Header.Get("Authorization")}) + r.mu.Unlock() + h.ServeHTTP(w, req) + }) +} + +func (r *authRecorder) all() []recordedRequest { + r.mu.Lock() + defer r.mu.Unlock() + return append([]recordedRequest(nil), r.seen...) +} + +func startAuthRecordingServer(t *testing.T) (string, *authRecorder) { + t.Helper() + return startAuthRecordingServerWithInterface(t, func(serverURL string) string { return serverURL }) +} + +// startAuthRecordingServerWithInterface serves a card advertising the +// interface URL that iface derives from the server URL. +func startAuthRecordingServerWithInterface(t *testing.T, iface func(serverURL string) string) (string, *authRecorder) { + t.Helper() + capabilities := a2a.AgentCapabilities{Streaming: true} + handler := a2asrv.NewHandler(localsrv.NewEchoExecutor(), a2asrv.WithCapabilityChecks(&capabilities)) + mux := http.NewServeMux() + mux.Handle("/", a2asrv.NewRESTHandler(handler)) + rec := &authRecorder{} + server := httptest.NewServer(rec.wrap(mux)) + t.Cleanup(server.Close) + mux.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewStaticAgentCardHandler(newAgentCard(iface(server.URL), capabilities))) + return server.URL, rec +} + +// credentialHelperPath is a helper that answers "get" with the Authorization +// header "Bearer ". TestMain writes it before tests run in parallel: +// executing a file written while other tests fork can fail with ETXTBSY +// (golang/go#22315). +var credentialHelperPath string + +func TestMain(m *testing.M) { + os.Exit(runTests(m)) +} + +func runTests(m *testing.M) int { + dir, err := os.MkdirTemp("", "a2a-cli-test") + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + defer func() { + if err := os.RemoveAll(dir); err != nil { + fmt.Fprintln(os.Stderr, err) + } + }() + credentialHelperPath = filepath.Join(dir, "helper") + script := `#!/bin/sh +[ "$1" = get ] || exit 2 +uri=$(sed 's/.*"uri":"\([^"]*\)".*/\1/') +printf '{"headers":{"Authorization":["Bearer %s"]}}' "$uri" +` + if err := os.WriteFile(credentialHelperPath, []byte(script), 0o700); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + return m.Run() +} + +func credentialHelper(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("helper scripts need a POSIX shell") + } + return credentialHelperPath +} + +func TestCredentialHelper(t *testing.T) { + t.Parallel() + helper := credentialHelper(t) + tests := []struct { + name string + args func(url string) []string + wantAuth func(url string) string + wantErr string + }{ + { + name: "send", + args: func(url string) []string { return []string{"send", "-a", url, "--credential-helper", helper, "hello"} }, + wantAuth: func(url string) string { return "Bearer " + url }, + }, + { + name: "card get positional", + args: func(url string) []string { return []string{"card", "get", url, "--credential-helper", helper} }, + wantAuth: func(url string) string { return "Bearer " + url }, + }, + { + name: "scoped to the agent host", + args: func(url string) []string { + return []string{"send", "-a", url, "--credential-helper", "127.0.0.1=" + helper, "hello"} + }, + wantAuth: func(url string) string { return "Bearer " + url }, + }, + { + name: "scoped to another host", + args: func(url string) []string { + return []string{"send", "-a", url, "--credential-helper", "other.example.com=" + helper, "hello"} + }, + wantAuth: func(string) string { return "" }, + }, + { + name: "explicit auth wins", + args: func(url string) []string { + return []string{"send", "-a", url, "--credential-helper", helper, "--auth", "Bearer flag", "hello"} + }, + wantAuth: func(string) string { return "Bearer flag" }, + }, + { + name: "failing helper", + args: func(url string) []string { return []string{"send", "-a", url, "--credential-helper", "false", "hello"} }, + wantErr: "credential helper", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + url, rec := startAuthRecordingServer(t) + args := tc.args(url) + _, err := runCMD(t, args...) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("a2a %s error = %v, want %q", strings.Join(args, " "), err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("a2a %s error = %v", strings.Join(args, " "), err) + } + seen := rec.all() + if len(seen) == 0 { + t.Fatalf("a2a %s sent no requests", strings.Join(args, " ")) + } + for _, got := range seen { + if want := tc.wantAuth(url); got.Auth != want { + t.Fatalf("a2a %s Authorization for %s = %q, want %q", strings.Join(args, " "), got.Path, got.Auth, want) + } + } + }) + } +} + +func TestCredentialHelperFromConfigFile(t *testing.T) { + t.Parallel() + url, rec := startAuthRecordingServer(t) + dir := t.TempDir() + userPath := filepath.Join(dir, "config.yaml") + yaml := fmt.Sprintf("credential-helper:\n - scope: 127.0.0.1\n command: [sh, %q]\n", credentialHelper(t)) + if err := os.WriteFile(userPath, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + loader := cfgLoaderFunc(func(opts clicfg.LoadOpts) (*clicfg.Store, error) { + opts.WorkingDir = dir + opts.GlobalPath = filepath.Join(dir, "none.env") + opts.UserPath = userPath + opts.LookupEnv = func(string) (string, bool) { return "", false } + return clicfg.Load(opts) + }) + args := []string{"send", "-a", url, "hello"} + if _, err := runCMDWithConfig(t, deps{cfgLoader: loader}, args...); err != nil { + t.Fatalf("a2a %s error = %v", strings.Join(args, " "), err) + } + want := []recordedRequest{ + {Path: a2asrv.WellKnownAgentCardPath, Auth: "Bearer " + url}, + {Path: "/message:send", Auth: "Bearer " + url}, + } + if diff := cmp.Diff(want, rec.all()); diff != "" { + t.Fatalf("a2a %s wrong result (-want +got) diff = %s", strings.Join(args, " "), diff) + } +} + +func TestCredentialHelperScopedToRequestHost(t *testing.T) { + t.Parallel() + helper := credentialHelper(t) + url, rec := startAuthRecordingServerWithInterface(t, func(serverURL string) string { + return strings.Replace(serverURL, "127.0.0.1", "localhost", 1) + }) + args := []string{"send", "-a", url, "--credential-helper", "127.0.0.1=" + helper, "hello"} + if _, err := runCMD(t, args...); err != nil { + t.Fatalf("a2a %s error = %v", strings.Join(args, " "), err) + } + want := []recordedRequest{ + {Path: a2asrv.WellKnownAgentCardPath, Auth: "Bearer " + url}, + {Path: "/message:send", Auth: ""}, + } + if diff := cmp.Diff(want, rec.all()); diff != "" { + t.Fatalf("a2a %s wrong result (-want +got) diff = %s", strings.Join(args, " "), diff) + } +} + +func TestCredentialHelperOnlyRunsForRequests(t *testing.T) { + t.Parallel() + tests := []struct { + name string + args []string + }{ + {name: "config list", args: []string{"config", "list", "-a", "https://agent.example.com", "--credential-helper", "false"}}, + {name: "transport list", args: []string{"transport", "list", "-a", "https://agent.example.com", "--credential-helper", "false"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if _, err := runCMD(t, tc.args...); err != nil { + t.Fatalf("a2a %s error = %v", strings.Join(tc.args, " "), err) + } + }) + } +} + +func TestCredentialHelperInvalidSpec(t *testing.T) { + t.Parallel() + args := []string{"send", "-a", "https://agent.example.com", "--credential-helper", "example.com=", "hello"} + _, err := runCMD(t, args...) + if ce := clierr.Classify(err); ce.Code != clierr.CodeUsage { + t.Fatalf("a2a %s error = %v, want a usage error", strings.Join(args, " "), err) + } +} + +func TestCredentialFlagsIgnoredFromDiscoveredEnv(t *testing.T) { + t.Parallel() + tests := []struct { + name string + env string + flag string + }{ + {name: "credential helper", env: "A2ACLI_CREDENTIAL_HELPER=false", flag: "credential-helper"}, + {name: "unix socket", env: "A2ACLI_UNIX_SOCKET=/nonexistent/a2a.sock", flag: "unix-socket"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + url, rec := startAuthRecordingServer(t) + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + if err := os.WriteFile(envPath, []byte(tc.env+"\n"), 0o600); err != nil { + t.Fatal(err) + } + loader := cfgLoaderFunc(func(opts clicfg.LoadOpts) (*clicfg.Store, error) { + opts.WorkingDir = dir + opts.GlobalPath = filepath.Join(dir, "none.env") + opts.UserPath = filepath.Join(dir, "none.yaml") + opts.LookupEnv = func(string) (string, bool) { return "", false } + return clicfg.Load(opts) + }) + args := []string{"card", "get", url} + _, stderr, err := runCMDCapturingStderrWithConfig(t, deps{cfgLoader: loader}, args...) + if err != nil { + t.Fatalf("a2a %s error = %v", strings.Join(args, " "), err) + } + if want := "warning: ignoring " + tc.flag + " from " + envPath; !strings.Contains(stderr, want) { + t.Fatalf("a2a %s stderr = %q, want %q", strings.Join(args, " "), stderr, want) + } + if len(rec.all()) == 0 { + t.Fatalf("a2a %s sent no requests", strings.Join(args, " ")) + } + }) + } +} + +func TestAuthGet(t *testing.T) { + t.Parallel() + helper := credentialHelper(t) + tests := []struct { + name string + args []string + want map[string]any + wantErr string + }{ + { + name: "redacted", + args: []string{"auth", "get", "https://agent.example.com", "--credential-helper", helper, "-o", "json"}, + want: map[string]any{"url": "https://agent.example.com", "helper": helper, "headers": map[string]any{"Authorization": []any{""}}, "expires": ""}, + }, + { + name: "revealed", + args: []string{"auth", "get", "https://agent.example.com", "--credential-helper", helper, "--reveal", "-o", "json"}, + want: map[string]any{"url": "https://agent.example.com", "helper": helper, "headers": map[string]any{"Authorization": []any{"Bearer https://agent.example.com"}}, "expires": ""}, + }, + { + name: "ignores the helper of the configured agent", + args: []string{"auth", "get", "https://other.example.com", "-a", "https://agent.example.com", "--credential-helper", "agent.example.com=false", "--credential-helper", "other.example.com=" + helper, "-o", "json"}, + want: map[string]any{"url": "https://other.example.com", "helper": "other.example.com=" + helper, "headers": map[string]any{"Authorization": []any{""}}, "expires": ""}, + }, + { + name: "local file", + args: []string{"auth", "get", "./card.json", "--credential-helper", helper}, + wantErr: "http(s) agent URL", + }, + { + name: "no helper for the host", + args: []string{"auth", "get", "https://agent.example.com", "--credential-helper", "other.example.com=" + helper}, + wantErr: "no credential helper", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + out, err := runCMD(t, tc.args...) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("a2a %s error = %v, want %q", strings.Join(tc.args, " "), err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("a2a %s error = %v", strings.Join(tc.args, " "), err) + } + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", out, err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("a2a %s wrong result (-want +got) diff = %s", strings.Join(tc.args, " "), diff) + } + }) + } +} + +func TestSendOverUnixSocket(t *testing.T) { + t.Parallel() + dir, err := os.MkdirTemp("", "a2a") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.RemoveAll(dir); err != nil { + t.Errorf("removing %s: %v", dir, err) + } + }) + sock := filepath.Join(dir, "a2a.sock") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + const base = "http://agent.invalid" + mux := http.NewServeMux() + mux.Handle("/.well-known/agent-card.json", a2asrv.NewStaticAgentCardHandler(&a2a.AgentCard{ + Name: "socket", + SupportedInterfaces: []*a2a.AgentInterface{a2a.NewAgentInterface(base, a2a.TransportProtocolHTTPJSON)}, + Capabilities: a2a.AgentCapabilities{Streaming: true}, + })) + mux.Handle("/", a2asrv.NewRESTHandler(a2asrv.NewHandler( + a2asrv.AgentExecutorFunc(func(ctx context.Context, ec *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + yield(a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart("over the socket")), nil) + } + }), + ))) + server := &httptest.Server{Listener: ln, Config: &http.Server{Handler: mux}} + server.Start() + t.Cleanup(server.Close) + + tests := []struct { + name string + args []string + }{ + {name: "agent card", args: []string{"-a", base}}, + {name: "endpoint", args: []string{"-e", base, "--transport", "rest"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + args := append(append([]string{"send", "--unix-socket", sock}, tc.args...), "hello") + out, err := runCMD(t, args...) + if err != nil { + t.Fatalf("a2a %s error = %v", strings.Join(args, " "), err) + } + if !strings.Contains(out, "over the socket") { + t.Fatalf("a2a %s = %q, want the agent's answer", strings.Join(args, " "), out) + } + }) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 33d96c7..beab8c1 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -30,6 +30,7 @@ import ( "github.com/a2aproject/a2a-cli/internal/clicfg" "github.com/a2aproject/a2a-cli/internal/clierr" "github.com/a2aproject/a2a-cli/internal/commandplugin" + "github.com/a2aproject/a2a-cli/internal/credhelper" "github.com/a2aproject/a2a-cli/internal/flagparse" "github.com/a2aproject/a2a-cli/internal/output" "github.com/a2aproject/a2a-cli/internal/polling" @@ -74,8 +75,12 @@ type globalConfig struct { timeout time.Duration verbose bool insecureGRPC bool + unixSocket string configPath string + credentialHelpers credhelper.Flag + credentials *credhelper.Source + pluginsEnabled bool bindings []clicfg.FlagBinding @@ -167,6 +172,16 @@ func newRootCmd(cfg *globalConfig, deps deps) (*cobra.Command, error) { } cfg.bindings = bindings cfg.store = store + for _, b := range bindings { + if b.Ignored == "" { + continue + } + if _, err := fmt.Fprintf(cfg.stderr(), "warning: ignoring %s from %s; pass --config %s to use it\n", b.Name, b.Ignored, b.Ignored); err != nil { + return err + } + } + cfg.initHTTPTransport() + cfg.initCredentials() switch output.Mode(cfg.output) { case output.ModeText, output.ModeJson, output.ModeJSONL: @@ -190,7 +205,14 @@ func newRootCmd(cfg *globalConfig, deps deps) (*cobra.Command, error) { pf.DurationVar(&cfg.timeout, "timeout", 30*time.Second, "Request timeout") pf.BoolVarP(&cfg.verbose, "verbose", "v", false, "Verbose output to stderr") pf.BoolVar(&cfg.insecureGRPC, "insecure", false, "Use insecure (plaintext) gRPC transport credentials") + pf.StringVar(&cfg.unixSocket, "unix-socket", "", "Send every HTTP request through the Unix socket at this path") + pf.Var(&cfg.credentialHelpers, "credential-helper", "Credential helper returning the headers for an agent: [scope=]path (repeatable; see a2a auth get --help)") pf.StringVar(&cfg.configPath, "config", "", "Load configuration from an explicit file (.yaml, .json, or .env) in place of the local .env") + for _, name := range []string{"unix-socket", "credential-helper"} { + if err := pf.SetAnnotation(name, clicfg.AnnotationNotFromDiscoveredFile, []string{"true"}); err != nil { + return nil, err + } + } cmd.AddCommand( newCardCmd(cfg), @@ -198,6 +220,7 @@ func newRootCmd(cfg *globalConfig, deps deps) (*cobra.Command, error) { newChatCmd(cfg, deps), newTaskCmd(cfg), newConfigCmd(cfg), + newAuthCmd(cfg), newServeCmd(cfg), newTransportCmd(cfg), newPluginCmd(cfg), diff --git a/internal/clicfg/binder.go b/internal/clicfg/binder.go index 608e4da..2ccac31 100644 --- a/internal/clicfg/binder.go +++ b/internal/clicfg/binder.go @@ -26,6 +26,11 @@ import ( const envPrefix = "A2ACLI_" +// AnnotationNotFromDiscoveredFile is a flag annotation that makes [Bind] +// ignore a value from a .env found by walking up from the working directory, +// for flags that a cloned repository must not be able to set. +const AnnotationNotFromDiscoveredFile = "a2a-cli/not-from-discovered-file" + // FlagBinding records how a single flag was resolved. type FlagBinding struct { Name string @@ -34,6 +39,9 @@ type FlagBinding struct { Source string Path string Sensitive bool + // Ignored is the path of a discovered .env whose value for the flag was + // ignored because of [AnnotationNotFromDiscoveredFile]. + Ignored string } // Bind fills every eligible flag that was not set on the command line from @@ -68,6 +76,10 @@ func Bind(flags *pflag.FlagSet, store *Store) ([]FlagBinding, error) { } value, source, ok := store.LookupFlag(binding.Name, binding.EnvVar) + if ok && source.Discovered && hasAnnotation(f, AnnotationNotFromDiscoveredFile) { + binding.Ignored = source.Path + value, source, ok = store.LookupFlagSkipDiscovered(binding.Name, binding.EnvVar) + } if !ok { binding.Source = "default" binding.Value = flagValueString(f) @@ -95,6 +107,9 @@ func Bind(flags *pflag.FlagSet, store *Store) ([]FlagBinding, error) { value = redactServiceParams(value) } binding.Value = formatValue(value) + if _, ok := f.Value.(ConfigValue); ok { + binding.Value = flagValueString(f) + } bindings = append(bindings, binding) }) @@ -141,7 +156,18 @@ func flagValueString(f *pflag.Flag) string { return f.Value.String() } +// ConfigValue is a flag value that reads configuration values itself, such +// as lists of objects that have no string form. +type ConfigValue interface { + // SetConfig sets the value from a raw config value: a string from an + // environment variable or .env, or a decoded YAML or JSON value. + SetConfig(raw any) error +} + func setFlagValue(f *pflag.Flag, raw any) error { + if cv, ok := f.Value.(ConfigValue); ok { + return cv.SetConfig(raw) + } if slice, ok := toStringSlice(raw); ok { if sv, ok := f.Value.(pflag.SliceValue); ok { return sv.Replace(slice) @@ -171,6 +197,11 @@ func setFlagValue(f *pflag.Flag, raw any) error { } } +func hasAnnotation(f *pflag.Flag, key string) bool { + _, ok := f.Annotations[key] + return ok +} + func flagToEnvVar(name string) string { return envPrefix + strings.ToUpper(strings.ReplaceAll(name, "-", "_")) } diff --git a/internal/clicfg/binder_test.go b/internal/clicfg/binder_test.go index 7b64b92..c97a201 100644 --- a/internal/clicfg/binder_test.go +++ b/internal/clicfg/binder_test.go @@ -22,6 +22,7 @@ import ( "testing" "github.com/a2aproject/a2a-cli/internal/clierr" + "github.com/a2aproject/a2a-cli/internal/credhelper" "github.com/google/go-cmp/cmp" "github.com/spf13/pflag" ) @@ -78,6 +79,8 @@ func TestApplyConfig(t *testing.T) { vals["agent-card"] = fs.String("agent-card", "", "") vals["tenant"] = fs.String("tenant", "", "") fs.StringArray("transport", nil, "") + fs.Var(&credhelper.Flag{}, "credential-helper", "") + fs.Lookup("credential-helper").Annotations = map[string][]string{AnnotationNotFromDiscoveredFile: {"true"}} fs.Bool("insecure", false, "") fs.Bool("stream", false, "") fs.String("config", "", "") @@ -147,6 +150,89 @@ func TestApplyConfig(t *testing.T) { } }) + t.Run("keeps a credential helper with commas as one entry", func(t *testing.T) { + t.Parallel() + fs, _ := newFlags() + s := store(t, map[string]string{"A2ACLI_CREDENTIAL_HELPER": "/opt/helper,v2"}) + + if _, err := Bind(fs, s); err != nil { + t.Fatalf("applyConfig() error = %v", err) + } + got := fs.Lookup("credential-helper").Value.(pflag.SliceValue).GetSlice() + if diff := cmp.Diff([]string{"/opt/helper,v2"}, got); diff != "" { + t.Fatalf("credential-helper wrong result (-want +got) diff = %s", diff) + } + }) + + t.Run("ignores a protected flag from a discovered .env", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + user string + want []string + }{ + {name: "unset", want: []string{}}, + {name: "falls back to the user config", user: "credential-helper:\n - command: [user-helper, --flag]\n", want: []string{"user-helper --flag"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + fs, _ := newFlags() + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + writeDotenv(t, envPath, map[string]string{"A2ACLI_CREDENTIAL_HELPER": "helper"}) + userPath := filepath.Join(dir, "config.yaml") + if tc.user != "" { + if err := os.WriteFile(userPath, []byte(tc.user), 0o600); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", userPath, err) + } + } + s, err := Load(LoadOpts{WorkingDir: dir, GlobalPath: filepath.Join(dir, "none.env"), UserPath: userPath, LookupEnv: makeEnv(nil)}) + if err != nil { + t.Fatalf("config.Load() error = %v", err) + } + + bindings, err := Bind(fs, s) + if err != nil { + t.Fatalf("Bind() error = %v", err) + } + got := fs.Lookup("credential-helper").Value.(pflag.SliceValue).GetSlice() + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("credential-helper wrong result (-want +got) diff = %s", diff) + } + var ignored string + for _, b := range bindings { + if b.Name == "credential-helper" { + ignored = b.Ignored + } + } + if ignored != envPath { + t.Fatalf("Bind() credential-helper Ignored = %q, want %q", ignored, envPath) + } + }) + } + }) + + t.Run("reads a protected flag from an explicit --config", func(t *testing.T) { + t.Parallel() + fs, _ := newFlags() + dir := t.TempDir() + cfgPath := filepath.Join(dir, ".env") + writeDotenv(t, cfgPath, map[string]string{"A2ACLI_CREDENTIAL_HELPER": "helper"}) + s, err := Load(LoadOpts{ConfigPath: cfgPath, WorkingDir: dir, GlobalPath: filepath.Join(dir, "none.env"), LookupEnv: makeEnv(nil)}) + if err != nil { + t.Fatalf("config.Load() error = %v", err) + } + + if _, err := Bind(fs, s); err != nil { + t.Fatalf("Bind() error = %v", err) + } + got := fs.Lookup("credential-helper").Value.(pflag.SliceValue).GetSlice() + if diff := cmp.Diff([]string{"helper"}, got); diff != "" { + t.Fatalf("credential-helper wrong result (-want +got) diff = %s", diff) + } + }) + t.Run("excluded flags are never read from config", func(t *testing.T) { t.Parallel() fs, _ := newFlags() @@ -580,10 +666,11 @@ tenant: yaml-tenant got[r.Name] = r.Source } want := map[string]string{ - "agent-card": "env", - "tenant": "flag", - "transport": "default", - "insecure": "default", + "agent-card": "env", + "tenant": "flag", + "transport": "default", + "credential-helper": "default", + "insecure": "default", } if diff := cmp.Diff(want, got); diff != "" { t.Fatalf("resolution sources wrong result (-want +got) diff = %s", diff) diff --git a/internal/clicfg/loader.go b/internal/clicfg/loader.go index 2e21492..467ce6a 100644 --- a/internal/clicfg/loader.go +++ b/internal/clicfg/loader.go @@ -105,7 +105,7 @@ func loadLocal(opts LoadOpts) (*loadedFile, error) { if err != nil { return nil, err } - return &loadedFile{path: path, values: toStringAnyMap(values)}, nil + return &loadedFile{path: path, values: toStringAnyMap(values), discovered: true}, nil } func loadGlobal(opts LoadOpts) (*loadedFile, error) { diff --git a/internal/clicfg/store.go b/internal/clicfg/store.go index ac67ce4..cb4700c 100644 --- a/internal/clicfg/store.go +++ b/internal/clicfg/store.go @@ -40,6 +40,9 @@ type Source struct { Kind SourceKind // Path is set for file-backed sources. Path string + // Discovered is set when the file is a .env found by walking up from the + // working directory rather than one named by --config. + Discovered bool } // String implements Stringer. @@ -85,14 +88,24 @@ func (s *Store) lookupValue(key string) (any, Source, bool) { // LookupFlag returns the configuration value for a flag, checking environment variables // by envVar and config files by flag name or envVar. func (s *Store) LookupFlag(name, envVar string) (any, Source, bool) { + return s.lookupFlag(name, envVar, false) +} + +// LookupFlagSkipDiscovered is [Store.LookupFlag] without a .env found by +// walking up from the working directory. +func (s *Store) LookupFlagSkipDiscovered(name, envVar string) (any, Source, bool) { + return s.lookupFlag(name, envVar, true) +} + +func (s *Store) lookupFlag(name, envVar string, skipDiscovered bool) (any, Source, bool) { if envVar != "" { if v, ok := s.lookupEnv(envVar); ok { return v, Source{Kind: SourceEnv}, true } } - if s.local != nil { + if s.local != nil && !(skipDiscovered && s.local.discovered) { if v, ok := s.local.lookup(name, envVar); ok { - return v, Source{Kind: SourceLocalFile, Path: s.local.path}, true + return v, Source{Kind: SourceLocalFile, Path: s.local.path, Discovered: s.local.discovered}, true } } if s.user != nil { @@ -109,8 +122,9 @@ func (s *Store) LookupFlag(name, envVar string) (any, Source, bool) { } type loadedFile struct { - path string - values map[string]any + path string + values map[string]any + discovered bool } func (f *loadedFile) lookup(name, envVar string) (any, bool) { diff --git a/internal/credhelper/credhelper.go b/internal/credhelper/credhelper.go new file mode 100644 index 0000000..b62fe52 --- /dev/null +++ b/internal/credhelper/credhelper.go @@ -0,0 +1,182 @@ +// 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 credhelper runs credential helpers: programs that return the HTTP +// headers to send to a URI. It implements the Bazel credential helper protocol +// (https://github.com/EngFlow/credential-helper-spec), so helpers written for +// Bazel work unchanged. +package credhelper + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/url" + "os/exec" + "strings" + "time" +) + +// Helper is a configured credential helper. +type Helper struct { + // Spec is the configured value, for messages. + Spec string + // Scope is the host the helper applies to: "example.com", "*.example.com" + // for the domain and its subdomains, or empty for every host. + Scope string + // Command runs the helper; "get" is appended to it. + Command []string +} + +// Response is what a helper prints for a "get" request. +type Response struct { + Headers map[string][]string `json:"headers"` + // Expires is when the headers stop being valid, in RFC 3339. + Expires string `json:"expires,omitempty"` +} + +type request struct { + URI string `json:"uri"` +} + +// Parse parses "[scope=]path", the form Bazel's --credential_helper takes. +// The scope is the text before the first '=' when it looks like a host +// pattern. +func Parse(spec string) (Helper, error) { + h := Helper{Spec: spec} + path := strings.TrimSpace(spec) + if scope, rest, ok := strings.Cut(path, "="); ok && isScope(scope) { + if err := checkScope(scope); err != nil { + return Helper{}, fmt.Errorf("credential helper %q: %w", spec, err) + } + h.Scope, path = strings.ToLower(scope), strings.TrimSpace(rest) + } + if path == "" { + return Helper{}, fmt.Errorf("credential helper %q: no path", spec) + } + h.Command = []string{path} + return h, nil +} + +// Entry is a credential helper from a config file. +type Entry struct { + Scope string `json:"scope"` + Command []string `json:"command"` +} + +// Helper validates e. +func (e Entry) Helper() (Helper, error) { + spec := strings.Join(e.Command, " ") + if e.Scope != "" { + spec = e.Scope + "=" + spec + } + if len(e.Command) == 0 || e.Command[0] == "" { + return Helper{}, fmt.Errorf("credential helper %q: no command", spec) + } + if e.Scope != "" { + if !isScope(e.Scope) { + return Helper{}, fmt.Errorf("credential helper %q: scope %q must be a host or *.domain", spec, e.Scope) + } + if err := checkScope(e.Scope); err != nil { + return Helper{}, fmt.Errorf("credential helper %q: %w", spec, err) + } + } + return Helper{Spec: spec, Scope: strings.ToLower(e.Scope), Command: e.Command}, nil +} + +func checkScope(scope string) error { + if strings.Contains(scope, ":") { + return fmt.Errorf("scope %q must be a host without a port", scope) + } + return nil +} + +func isScope(s string) bool { + if s == "" || strings.ContainsAny(s, " \t/\\\"'") { + return false + } + host := strings.TrimPrefix(s, "*.") + return host != "" && !strings.Contains(host, "*") +} + +// Select returns the helper for rawURL: the one with the most specific +// matching scope, an exact host before a wildcard and a longer wildcard +// before a shorter one, with unscoped helpers last. Among equally specific +// helpers the last one wins, so a later setting overrides an earlier one. +func Select(helpers []Helper, rawURL string) (Helper, bool) { + u, err := url.Parse(rawURL) + if err != nil || u.Hostname() == "" { + return Helper{}, false + } + host := strings.ToLower(u.Hostname()) + best, bestRank := Helper{}, -1 + for _, h := range helpers { + rank := matchRank(h.Scope, host) + if rank >= bestRank && rank >= 0 { + best, bestRank = h, rank + } + } + return best, bestRank >= 0 +} + +// matchRank is -1 when scope doesn't match host, and higher for more +// specific matches. +func matchRank(scope, host string) int { + switch { + case scope == "": + return 0 + case scope == host: + return 1 << 20 + case strings.HasPrefix(scope, "*."): + domain := scope[2:] + if host == domain || strings.HasSuffix(host, "."+domain) { + return len(domain) + } + } + return -1 +} + +// Get runs the helper for uri. ctx bounds the helper's run. +func (h Helper) Get(ctx context.Context, uri string) (*Response, error) { + in, err := json.Marshal(request{URI: uri}) + if err != nil { + return nil, err + } + cmd := exec.CommandContext(ctx, h.Command[0], append(h.Command[1:], "get")...) + cmd.Stdin = bytes.NewReader(in) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + if msg := strings.TrimSpace(stderr.String()); msg != "" { + return nil, fmt.Errorf("credential helper %q: %w: %s", h.Spec, err, msg) + } + return nil, fmt.Errorf("credential helper %q: %w", h.Spec, err) + } + var resp Response + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + return nil, fmt.Errorf("credential helper %q: invalid response: %w", h.Spec, err) + } + if resp.Expires != "" { + if _, err := time.Parse(time.RFC3339, resp.Expires); err != nil { + return nil, fmt.Errorf("credential helper %q: invalid expires: %w", h.Spec, err) + } + } + for name := range resp.Headers { + if name == "" { + return nil, fmt.Errorf("credential helper %q: empty header name", h.Spec) + } + } + return &resp, nil +} diff --git a/internal/credhelper/credhelper_test.go b/internal/credhelper/credhelper_test.go new file mode 100644 index 0000000..418a976 --- /dev/null +++ b/internal/credhelper/credhelper_test.go @@ -0,0 +1,251 @@ +// 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 credhelper + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/google/go-cmp/cmp" +) + +func TestParse(t *testing.T) { + t.Parallel() + tests := []struct { + spec string + want Helper + wantErr bool + }{ + {spec: "/usr/bin/helper", want: Helper{Command: []string{"/usr/bin/helper"}}}, + {spec: "example.com=/usr/bin/helper", want: Helper{Scope: "example.com", Command: []string{"/usr/bin/helper"}}}, + {spec: "*.Example.com=helper", want: Helper{Scope: "*.example.com", Command: []string{"helper"}}}, + {spec: "/opt/my helper", want: Helper{Command: []string{"/opt/my helper"}}}, + {spec: "/opt/x=y/helper", want: Helper{Command: []string{"/opt/x=y/helper"}}}, + {spec: "example.com=", wantErr: true}, + {spec: "localhost:8080=helper", wantErr: true}, + {spec: "", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.spec, func(t *testing.T) { + t.Parallel() + got, err := Parse(tc.spec) + if (err != nil) != tc.wantErr { + t.Fatalf("Parse(%q) error = %v, want error %v", tc.spec, err, tc.wantErr) + } + if tc.wantErr { + return + } + tc.want.Spec = tc.spec + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("Parse(%q) wrong result (-want +got) diff = %s", tc.spec, diff) + } + }) + } +} + +func TestSelect(t *testing.T) { + t.Parallel() + all := []string{ + "default", + "*.example.com=wildcard", + "*.dev.example.com=dev-wildcard", + "api.example.com=exact", + "api.example.com=exact-override", + } + tests := []struct { + specs []string + url string + want string + wantOK bool + }{ + {specs: all, url: "https://api.example.com/agent", want: "exact-override", wantOK: true}, + {specs: all, url: "https://API.example.com:8443", want: "exact-override", wantOK: true}, + {specs: all, url: "https://foo.dev.example.com", want: "dev-wildcard", wantOK: true}, + {specs: all, url: "https://foo.example.com", want: "wildcard", wantOK: true}, + {specs: all, url: "https://example.com", want: "wildcard", wantOK: true}, + {specs: all, url: "https://other.org", want: "default", wantOK: true}, + {specs: []string{"example.com=helper"}, url: "https://other.org", wantOK: false}, + } + for _, tc := range tests { + t.Run(tc.url, func(t *testing.T) { + t.Parallel() + var helpers []Helper + for _, spec := range tc.specs { + h, err := Parse(spec) + if err != nil { + t.Fatalf("Parse(%q) error = %v", spec, err) + } + helpers = append(helpers, h) + } + got, ok := Select(helpers, tc.url) + if ok != tc.wantOK { + t.Fatalf("Select(%q) ok = %v, want %v", tc.url, ok, tc.wantOK) + } + if ok && got.Command[0] != tc.want { + t.Fatalf("Select(%q) = %v, want %s", tc.url, got.Command, tc.want) + } + }) + } +} + +// writeHelper returns a helper running script with sh. Executing a file +// written while parallel tests fork can fail with ETXTBSY (golang/go#22315). +func writeHelper(t *testing.T, script string) Helper { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("helper scripts need a POSIX shell") + } + path := filepath.Join(t.TempDir(), "helper") + if err := os.WriteFile(path, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + return Helper{Spec: path, Command: []string{"sh", path}} +} + +func TestGet(t *testing.T) { + t.Parallel() + tests := []struct { + name string + script string + want *Response + wantErr string + }{ + { + name: "headers for the uri", + script: `[ "$1" = get ] || exit 2; uri=$(sed 's/.*"uri":"\([^"]*\)".*/\1/'); printf '{"headers":{"Authorization":["Bearer %s"]},"expires":"2030-01-02T03:04:05Z"}' "$uri"`, + want: &Response{Headers: map[string][]string{"Authorization": {"Bearer https://agent.example.com"}}, Expires: "2030-01-02T03:04:05Z"}, + }, + {name: "no headers", script: `cat >/dev/null; echo '{}'`, want: &Response{}}, + {name: "failure", script: `echo "no token" >&2; exit 1`, wantErr: "no token"}, + {name: "invalid json", script: `echo nope`, wantErr: "invalid response"}, + {name: "invalid expires", script: `echo '{"expires":"tomorrow"}'`, wantErr: "invalid expires"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + h := writeHelper(t, tc.script) + got, err := h.Get(t.Context(), "https://agent.example.com") + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("Get() error = %v, want %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("Get() wrong result (-want +got) diff = %s", diff) + } + }) + } +} + +func TestSourceHeaders(t *testing.T) { + t.Parallel() + tests := []struct { + name string + expires string + uris []string + wantRuns int + }{ + {name: "reused for the same uri", uris: []string{"https://a.example.com", "https://a.example.com"}, wantRuns: 1}, + {name: "reused until expiry", expires: "2999-01-01T00:00:00Z", uris: []string{"https://a.example.com", "https://a.example.com"}, wantRuns: 1}, + {name: "refreshed after expiry", expires: "2000-01-01T00:00:00Z", uris: []string{"https://a.example.com", "https://a.example.com"}, wantRuns: 2}, + {name: "run per uri", uris: []string{"https://a.example.com", "https://a.example.com/agent"}, wantRuns: 2}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + runs := filepath.Join(t.TempDir(), "runs") + h := writeHelper(t, `echo run >>`+runs+`; printf '{"headers":{"X":["v"]},"expires":"%s"}' "`+tc.expires+`"`) + src := &Source{Helpers: []Helper{h}} + for _, uri := range tc.uris { + _, resp, err := src.Headers(t.Context(), uri) + if err != nil { + t.Fatalf("Source.Headers(%q) error = %v", uri, err) + } + if diff := cmp.Diff(map[string][]string{"X": {"v"}}, resp.Headers); diff != "" { + t.Fatalf("Source.Headers(%q) wrong result (-want +got) diff = %s", uri, diff) + } + } + out, err := os.ReadFile(runs) + if err != nil { + t.Fatalf("os.ReadFile() error = %v", err) + } + if got := strings.Count(string(out), "run"); got != tc.wantRuns { + t.Fatalf("helper runs = %d, want %d", got, tc.wantRuns) + } + }) + } +} + +func TestInterceptor(t *testing.T) { + t.Parallel() + helper := writeHelper(t, `uri=$(sed 's/.*"uri":"\([^"]*\)".*/\1/'); printf '{"headers":{"Authorization":["Bearer %s"]}}' "$uri"`) + tests := []struct { + name string + scope string + baseURL string + params a2aclient.ServiceParams + want a2aclient.ServiceParams + }{ + { + name: "adds the headers for the interface url", + baseURL: "https://agent.example.com/a2a", + params: a2aclient.ServiceParams{}, + want: a2aclient.ServiceParams{"authorization": {"Bearer https://agent.example.com/a2a"}}, + }, + { + name: "keeps an explicit service param", + baseURL: "https://agent.example.com", + params: a2aclient.ServiceParams{"authorization": {"Bearer flag"}}, + want: a2aclient.ServiceParams{"authorization": {"Bearer flag"}}, + }, + { + name: "skips a host outside the scope", + scope: "agent.example.com", + baseURL: "https://other.example.com", + params: a2aclient.ServiceParams{}, + want: a2aclient.ServiceParams{}, + }, + { + name: "matches a grpc address without a scheme", + scope: "agent.example.com", + baseURL: "agent.example.com:443", + params: a2aclient.ServiceParams{}, + want: a2aclient.ServiceParams{"authorization": {"Bearer https://agent.example.com:443"}}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + h := helper + h.Scope = tc.scope + i := &Interceptor{Source: &Source{Helpers: []Helper{h}}} + req := &a2aclient.Request{BaseURL: tc.baseURL, ServiceParams: tc.params} + if _, _, err := i.Before(t.Context(), req); err != nil { + t.Fatalf("Interceptor.Before() error = %v", err) + } + if diff := cmp.Diff(tc.want, req.ServiceParams); diff != "" { + t.Fatalf("Interceptor.Before() wrong result (-want +got) diff = %s", diff) + } + }) + } +} diff --git a/internal/credhelper/flag.go b/internal/credhelper/flag.go new file mode 100644 index 0000000..a8cfa36 --- /dev/null +++ b/internal/credhelper/flag.go @@ -0,0 +1,121 @@ +// 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 credhelper + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +// Flag is a repeatable flag of credential helpers. On the command line and +// in environment variables each value is "[scope=]path"; config files list +// [Entry] objects. +type Flag struct { + helpers []Helper +} + +// Helpers returns the configured helpers in order. +func (f *Flag) Helpers() []Helper { + return f.helpers +} + +// String implements [pflag.Value]. +func (f *Flag) String() string { + return strings.Join(f.GetSlice(), ",") +} + +// Set implements [pflag.Value]. +func (f *Flag) Set(spec string) error { + return f.Append(spec) +} + +// Type implements [pflag.Value]. +func (f *Flag) Type() string { + return "helper" +} + +// Append implements [pflag.SliceValue]. +func (f *Flag) Append(spec string) error { + h, err := Parse(spec) + if err != nil { + return err + } + f.helpers = append(f.helpers, h) + return nil +} + +// Replace implements [pflag.SliceValue]. +func (f *Flag) Replace(specs []string) error { + helpers := make([]Helper, 0, len(specs)) + for _, spec := range specs { + h, err := Parse(spec) + if err != nil { + return err + } + helpers = append(helpers, h) + } + f.helpers = helpers + return nil +} + +// GetSlice implements [pflag.SliceValue]. +func (f *Flag) GetSlice() []string { + specs := make([]string, len(f.helpers)) + for i, h := range f.helpers { + specs[i] = h.Spec + } + return specs +} + +// SetConfig sets the helpers from a configuration value: one "[scope=]path" +// string, or a list of [Entry] objects. +func (f *Flag) SetConfig(raw any) error { + if s, ok := raw.(string); ok { + return f.Replace([]string{s}) + } + items, ok := raw.([]any) + if !ok { + return fmt.Errorf("want a list of credential helpers, got %T", raw) + } + helpers := make([]Helper, 0, len(items)) + for _, item := range items { + h, err := parseConfigItem(item) + if err != nil { + return err + } + helpers = append(helpers, h) + } + f.helpers = helpers + return nil +} + +func parseConfigItem(item any) (Helper, error) { + if _, ok := item.(map[string]any); !ok { + return Helper{}, fmt.Errorf("credential helper %v: want {scope, command}", item) + } + data, err := json.Marshal(item) + if err != nil { + return Helper{}, fmt.Errorf("credential helper %v: %w", item, err) + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + var e Entry + if err := dec.Decode(&e); err != nil { + return Helper{}, fmt.Errorf("credential helper %s: want {scope, command}: %w", data, err) + } + return e.Helper() +} diff --git a/internal/credhelper/flag_test.go b/internal/credhelper/flag_test.go new file mode 100644 index 0000000..2cb02e2 --- /dev/null +++ b/internal/credhelper/flag_test.go @@ -0,0 +1,70 @@ +// 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 credhelper + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestFlagSetConfig(t *testing.T) { + t.Parallel() + tests := []struct { + name string + raw any + want []Helper + wantErr bool + }{ + { + name: "path string", + raw: "example.com=/usr/bin/helper", + want: []Helper{{Spec: "example.com=/usr/bin/helper", Scope: "example.com", Command: []string{"/usr/bin/helper"}}}, + }, + { + name: "entries", + raw: []any{ + map[string]any{"command": []any{"helper", "--flag"}}, + map[string]any{"scope": "*.Example.com", "command": []any{"corp-helper"}}, + }, + want: []Helper{ + {Spec: "helper --flag", Command: []string{"helper", "--flag"}}, + {Spec: "*.Example.com=corp-helper", Scope: "*.example.com", Command: []string{"corp-helper"}}, + }, + }, + {name: "string entry", raw: []any{"/usr/bin/helper"}, wantErr: true}, + {name: "unknown field", raw: []any{map[string]any{"cmd": []any{"helper"}}}, wantErr: true}, + {name: "no command", raw: []any{map[string]any{"scope": "example.com"}}, wantErr: true}, + {name: "scope with a port", raw: []any{map[string]any{"scope": "example.com:443", "command": []any{"helper"}}}, wantErr: true}, + {name: "scope with a path", raw: []any{map[string]any{"scope": "example.com/a2a", "command": []any{"helper"}}}, wantErr: true}, + {name: "map", raw: map[string]any{"command": []any{"helper"}}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var f Flag + err := f.SetConfig(tc.raw) + if (err != nil) != tc.wantErr { + t.Fatalf("Flag.SetConfig(%v) error = %v, want error %v", tc.raw, err, tc.wantErr) + } + if tc.wantErr { + return + } + if diff := cmp.Diff(tc.want, f.Helpers()); diff != "" { + t.Fatalf("Flag.SetConfig(%v) wrong result (-want +got) diff = %s", tc.raw, diff) + } + }) + } +} diff --git a/internal/credhelper/source.go b/internal/credhelper/source.go new file mode 100644 index 0000000..f340b1d --- /dev/null +++ b/internal/credhelper/source.go @@ -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 credhelper + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/a2aproject/a2a-go/v2/a2aclient" +) + +// Source runs the helper selected for each URI and reuses its headers for +// that URI until they expire. +type Source struct { + Helpers []Helper + // Logf, if set, is called before a helper runs. + Logf func(format string, args ...any) + + mu sync.Mutex + cache map[string]*Response +} + +// Headers returns the selected helper and its response for uri, or a nil +// response when no helper's scope matches. +func (s *Source) Headers(ctx context.Context, uri string) (Helper, *Response, error) { + h, ok := Select(s.Helpers, uri) + if !ok { + return Helper{}, nil, nil + } + s.mu.Lock() + defer s.mu.Unlock() + if resp, ok := s.cache[uri]; ok && !expired(resp) { + return h, resp, nil + } + if s.Logf != nil { + s.Logf("asking credential helper %q for %s", h.Spec, uri) + } + resp, err := h.Get(ctx, uri) + if err != nil { + return h, nil, fmt.Errorf("getting credentials for %s: %w", uri, err) + } + if s.cache == nil { + s.cache = map[string]*Response{} + } + s.cache[uri] = resp + return h, resp, nil +} + +func expired(resp *Response) bool { + if resp.Expires == "" { + return false + } + t, err := time.Parse(time.RFC3339, resp.Expires) + return err != nil || !time.Now().Before(t) +} + +// Interceptor adds the headers from the helper for each call's interface URL +// as service parameters, unless the call already carries the same parameter. +// The helper receives the interface URL rather than the URL of the method, +// which the interceptor does not see. +type Interceptor struct { + a2aclient.PassthroughInterceptor + Source *Source + // OnApply, if set, is called with the headers added to a call. + OnApply func(headers map[string][]string) +} + +var _ a2aclient.CallInterceptor = (*Interceptor)(nil) + +// Before implements [a2aclient.CallInterceptor]. +func (i *Interceptor) Before(ctx context.Context, req *a2aclient.Request) (context.Context, any, error) { + _, resp, err := i.Source.Headers(ctx, interfaceURI(req.BaseURL)) + if err != nil || resp == nil { + return ctx, nil, err + } + applied := map[string][]string{} + for name, values := range resp.Headers { + if len(req.ServiceParams.Get(name)) > 0 { + continue + } + req.ServiceParams.Append(name, values...) + applied[name] = values + } + if i.OnApply != nil && len(applied) > 0 { + i.OnApply(applied) + } + return ctx, nil, nil +} + +// interfaceURI gives gRPC "host:port" addresses a scheme, so that helpers +// receive a URI and the scope is matched against the host. +func interfaceURI(baseURL string) string { + if strings.Contains(baseURL, "://") { + return baseURL + } + return "https://" + baseURL +} diff --git a/internal/unixsocket/unixsocket.go b/internal/unixsocket/unixsocket.go new file mode 100644 index 0000000..50cde5c --- /dev/null +++ b/internal/unixsocket/unixsocket.go @@ -0,0 +1,34 @@ +// 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 unixsocket routes HTTP requests through a Unix socket. +package unixsocket + +import ( + "context" + "net" + "net/http" +) + +// Transport returns an HTTP transport that sends every request through the +// socket at path, bypassing any proxy. +func Transport(path string) *http.Transport { + t := http.DefaultTransport.(*http.Transport).Clone() + t.Proxy = nil + var d net.Dialer + t.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + return d.DialContext(ctx, "unix", path) + } + return t +} diff --git a/internal/unixsocket/unixsocket_test.go b/internal/unixsocket/unixsocket_test.go new file mode 100644 index 0000000..9e98bdc --- /dev/null +++ b/internal/unixsocket/unixsocket_test.go @@ -0,0 +1,74 @@ +// 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 unixsocket + +import ( + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestTransport(t *testing.T) { + t.Parallel() + // t.TempDir paths can exceed the Unix socket path length limit. + dir, err := os.MkdirTemp("", "a2a") + if err != nil { + t.Fatalf("os.MkdirTemp() error = %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(dir); err != nil { + t.Errorf("os.RemoveAll(%q) error = %v", dir, err) + } + }) + sock := filepath.Join(dir, "a2a.sock") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatalf("net.Listen(%q) error = %v", sock, err) + } + server := &httptest.Server{Listener: ln, Config: &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := io.WriteString(w, r.Host); err != nil { + t.Errorf("io.WriteString() error = %v", err) + } + })}} + server.Start() + t.Cleanup(server.Close) + + client := &http.Client{Transport: Transport(sock)} + for _, host := range []string{"agent.invalid", "other.invalid:8080"} { + t.Run(host, func(t *testing.T) { + t.Parallel() + resp, err := client.Get("http://" + host + "/") + if err != nil { + t.Fatalf("client.Get(%q) error = %v", host, err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + t.Errorf("resp.Body.Close() error = %v", err) + } + }() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("io.ReadAll() error = %v", err) + } + if got := string(body); got != host { + t.Fatalf("client.Get(%q) Host = %q, want %q", host, got, host) + } + }) + } +}