diff --git a/README.md b/README.md index 64993df..1aa8313 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,26 @@ The specification organizes CLI capabilities into three cumulative tiers. We wil * **Tier 3 (Advanced & Ergonomics):** Advanced tooling — interactive terminal chat, push-notification webhook receivers, extended card verification, and mTLS / OpenID Connect authentication. +## Custom Transport Plugins + +The CLI speaks JSON-RPC, REST and gRPC out of the box. Additional transport +bindings can be added **without recompiling** by dropping an +`a2a-transport-` binary on your `PATH`. The CLI launches the plugin as a +local proxy that speaks a standard A2A binding and forwards to the custom +protocol, so `--transport ` works uniformly for built-ins and plugins: + +```console +$ a2a transport list +$ a2a send --transport slimrpc --endpoint slim://agents.example/agent "hello" +``` + +Authoring a plugin in Go is a few lines with the +[`devkit/clitransport`](./devkit/clitransport) package — you provide an +`a2aclient.Transport`, it produces a CLI-compatible plugin. See the +**[transport plugin guide](./docs/transport-plugins.md)** and the runnable +**[echo plugin example](./examples/a2a-transport-echo)**. + + ## How to Contribute & Provide Feedback We invite review and input from engineers and the broader community: diff --git a/devkit/clitransport/clitransport.go b/devkit/clitransport/clitransport.go new file mode 100644 index 0000000..3e907e5 --- /dev/null +++ b/devkit/clitransport/clitransport.go @@ -0,0 +1,178 @@ +// 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 clitransport + +import ( + "bufio" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/signal" + "syscall" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" +) + +// IO is used to redirect Run stdio. +type IO struct { + // Out is where transport plugin writes handshake on start. + Out io.Writer + // Out is where transport plugin writes debug logs and errors. + Err io.Writer +} + +// Config describes a transport plugin. +type Config struct { + // Name is the transport name. + Name string + // Version is the plugin's own version, reported by "info". + Version string + // Description is an optional human-readable summary reported by "info". + Description string + // Binding is the loopback binding the proxy serves. Defaults to [a2a.TransportProtocolGRPC]. + Binding a2a.TransportProtocol + // NewTransport builds the custom client transport for the given upstream endpoint. + NewTransport func(ctx context.Context, endpoint string) (a2aclient.Transport, error) +} + +// Main runs the plugin using os.Args and exits the process with an appropriate +// status code. It is the intended entrypoint for a plugin's func main. +func Main(cfg Config) { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + + go func() { //stdin pipe closed + defer stop() + _, _ = io.Copy(io.Discard, bufio.NewReader(os.Stdin)) + }() + + defer stop() + + if err := Run(ctx, cfg, os.Args[1:], &IO{Out: os.Stdout, Err: os.Stderr}); err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", cfg.Name, err) + os.Exit(1) + } +} + +// Run executes a single plugin invocation exposed separately from [Main]. +func Run(ctx context.Context, cfg Config, args []string, fds *IO) error { + if cfg.Name == "" { + return fmt.Errorf("clitransport: Config.Name is required") + } + if cfg.NewTransport == nil { + return fmt.Errorf("clitransport: Config.NewTransport is required") + } + if len(args) == 0 { + return fmt.Errorf("expected a subcommand: %s or %s", SubcommandServe, SubcommandInfo) + } + if cfg.Binding == "" { + cfg.Binding = a2a.TransportProtocolGRPC + } + if fds == nil { + fds = &IO{} + } + if fds.Out == nil { + fds.Out = io.Discard + } + if fds.Err == nil { + fds.Err = io.Discard + } + + switch args[0] { + case SubcommandServe: + srv, err := runServe(ctx, cfg, args[1:], fds) + if err != nil { + if aerr := announce(fds.Out, Handshake{Success: false, Error: err.Error()}); aerr != nil { + return fmt.Errorf("%w (and failed to announce: %v)", err, aerr) + } + return err + } + return srv.await() + + case SubcommandInfo: + enc := json.NewEncoder(fds.Out) + return enc.Encode(Info{ + Name: cfg.Name, + Version: cfg.Version, + Description: cfg.Description, + Protocol: a2a.Version, + Binding: cfg.Binding, + }) + + default: + return fmt.Errorf("unknown subcommand %q (want %s or %s)", args[0], SubcommandServe, SubcommandInfo) + } +} + +func runServe(ctx context.Context, cfg Config, args []string, fds *IO) (*server, error) { + fs := flag.NewFlagSet(SubcommandServe, flag.ContinueOnError) + fs.SetOutput(fds.Err) + + endpoint := fs.String("endpoint", "", "Upstream endpoint URL to proxy to") + bind := fs.String("bind", string(cfg.Binding), "Loopback binding to serve: grpc, jsonrpc or http+json") + if err := fs.Parse(args); err != nil { + return nil, err + } + + return serveProxy(ctx, cfg, *endpoint, a2a.TransportProtocol(*bind), fds) +} + +func serveProxy(ctx context.Context, cfg Config, endpoint string, binding a2a.TransportProtocol, proc *IO) (*server, error) { + if endpoint == "" { + return nil, fmt.Errorf("--endpoint is required") + } + + transport, err := cfg.NewTransport(ctx, endpoint) + if err != nil { + return nil, fmt.Errorf("creating upstream transport: %w", err) + } + destroy := func() { + if derr := transport.Destroy(); derr != nil { + _, _ = fmt.Fprintf(proc.Err, "closing upstream transport: %v\n", derr) + } + } + + token := a2a.NewContextID() + handler := newTransportHandler(transport, token) + srv, err := newServer(binding, token, handler, cleanupFunc(destroy)) + if err != nil { + destroy() + return nil, err + } + + if err := srv.start(ctx); err != nil { + srv.cleanup() + return nil, fmt.Errorf("server could not start: %w", err) + } + + if err := announce(proc.Out, Handshake{Success: true, Endpoint: srv.body}); err != nil { + srv.stop() + return nil, fmt.Errorf("writing handshake: %w", err) + } + + return srv, nil +} + +func announce(stdout io.Writer, hs Handshake) error { + data, err := json.Marshal(hs) + if err != nil { + return err + } + _, err = stdout.Write(append(data, '\n')) + return err +} diff --git a/devkit/clitransport/clitransport_test.go b/devkit/clitransport/clitransport_test.go new file mode 100644 index 0000000..e73ef4c --- /dev/null +++ b/devkit/clitransport/clitransport_test.go @@ -0,0 +1,379 @@ +// 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 clitransport + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" +) + +func TestRunInfo(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + cfg := Config{Name: "demo", Version: "2.1.0", Description: "demo plugin", NewTransport: newRecordingTransport} + fds := &IO{Out: &buf} + if err := Run(context.Background(), cfg, []string{SubcommandInfo}, fds); err != nil { + t.Fatalf("Run(info) error = %v", err) + } + + var got Info + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("json.Unmarshal(info) error = %v", err) + } + want := Info{Name: "demo", Version: "2.1.0", Description: "demo plugin", Protocol: a2a.Version, Binding: a2a.TransportProtocolGRPC} + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("Run(info) wrong result (-want +got) diff = %s", diff) + } +} + +func TestRunValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg Config + args []string + }{ + {"missing name", Config{NewTransport: newRecordingTransport}, []string{SubcommandInfo}}, + {"missing transport factory", Config{Name: "demo"}, []string{SubcommandInfo}}, + {"no subcommand", Config{Name: "demo", NewTransport: newRecordingTransport}, nil}, + {"unknown subcommand", Config{Name: "demo", NewTransport: newRecordingTransport}, []string{"bogus"}}, + {"serve without endpoint", Config{Name: "demo", NewTransport: newRecordingTransport}, []string{SubcommandServe}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := Run(context.Background(), tt.cfg, tt.args, nil); err == nil { + t.Fatalf("Run(%v) error = nil, want error", tt.args) + } + }) + } +} + +func TestServeRoundTrip(t *testing.T) { + t.Parallel() + + for _, binding := range []string{string(a2a.TransportProtocolJSONRPC), string(a2a.TransportProtocolHTTPJSON)} { + t.Run(binding, func(t *testing.T) { + t.Parallel() + rec := &recordingTransport{} + hs, stop := startPlugin(t, Config{ + Name: "demo", + Version: "1.0.0", + NewTransport: func(context.Context, string) (a2aclient.Transport, error) { + return rec, nil + }, + }, binding) + defer stop() + + client := newTokenClient(t, hs) + defer func() { _ = client.Destroy() }() + + params := a2aclient.ServiceParams{"authorization": {"Bearer secret"}} + msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("ping")) + result, err := client.SendMessage(t.Context(), params, &a2a.SendMessageRequest{Message: msg}) + if err != nil { + t.Fatalf("client.SendMessage() error = %v", err) + } + task, ok := result.(*a2a.Task) + if !ok { + t.Fatalf("client.SendMessage() result type = %T, want *a2a.Task", result) + } + if got := artifactText(task); got != "ping" { + t.Fatalf("client.SendMessage() echoed = %q, want %q", got, "ping") + } + if got := rec.lastAuth(); got != "Bearer secret" { + t.Fatalf("upstream transport saw authorization = %q, want %q", got, "Bearer secret") + } + }) + } +} + +func TestServeKeepsTransportAliveUntilShutdown(t *testing.T) { + t.Parallel() + + rec := &recordingTransport{} + hs, stop := startPlugin(t, Config{ + Name: "demo", + Version: "1.0.0", + NewTransport: func(context.Context, string) (a2aclient.Transport, error) { + return rec, nil + }, + }, string(a2a.TransportProtocolJSONRPC)) + defer stop() + + client := newTokenClient(t, hs) + defer func() { _ = client.Destroy() }() + + msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("ping")) + if _, err := client.SendMessage(t.Context(), nil, &a2a.SendMessageRequest{Message: msg}); err != nil { + t.Fatalf("client.SendMessage() error = %v", err) + } + if rec.servedWhileDestroyed.Load() { + t.Fatal("upstream transport was destroyed before it served a request") + } + if got := rec.destroys.Load(); got != 0 { + t.Fatalf("upstream transport Destroy() = %d calls while serving, want 0", got) + } + + stop() + + if got := rec.destroys.Load(); got != 1 { + t.Fatalf("upstream transport Destroy() = %d calls after shutdown, want 1", got) + } +} + +func TestServeReportsStartupFailure(t *testing.T) { + t.Parallel() + + cfg := Config{ + Name: "demo", + NewTransport: func(context.Context, string) (a2aclient.Transport, error) { + return nil, fmt.Errorf("boom") + }, + } + + var out bytes.Buffer + ios := &IO{Out: &out} + err := Run(context.Background(), cfg, []string{SubcommandServe, "--endpoint", "test://upstream"}, ios) + if err == nil { + t.Fatal("Run(serve) error = nil, want a startup failure") + } + + var hs Handshake + if uerr := json.Unmarshal(out.Bytes(), &hs); uerr != nil { + t.Fatalf("json.Unmarshal(handshake) error = %v (raw=%q)", uerr, out.String()) + } + if hs.Success { + t.Fatalf("handshake Success = true, want false (raw=%q)", out.String()) + } + if hs.Endpoint != nil { + t.Fatalf("handshake Payload = %+v, want nil on failure", hs.Endpoint) + } + if !strings.Contains(hs.Error, "boom") { + t.Fatalf("handshake Error = %q, want it to mention the upstream failure", hs.Error) + } +} + +func TestServeRejectsMissingToken(t *testing.T) { + t.Parallel() + + hs, stop := startPlugin(t, Config{ + Name: "demo", + NewTransport: newRecordingTransport, + }, string(a2a.TransportProtocolJSONRPC)) + defer stop() + + // Trust the certificate but omit the per-launch token: the proxy must reject + // the request for the missing token rather than fail the TLS handshake. + httpClient := &http.Client{Timeout: 5 * time.Second, Transport: trustingTransport(t, hs.CertPEM)} + client := a2aclient.NewJSONRPCTransport(hs.Address, httpClient) + defer func() { _ = client.Destroy() }() + + msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("ping")) + if _, err := client.SendMessage(t.Context(), nil, &a2a.SendMessageRequest{Message: msg}); err == nil { + t.Fatal("SendMessage() without token error = nil, want a rejection") + } +} + +func TestServeAdvertisesCertificate(t *testing.T) { + t.Parallel() + + bindings := []string{ + string(a2a.TransportProtocolJSONRPC), + string(a2a.TransportProtocolHTTPJSON), + string(a2a.TransportProtocolGRPC), + } + for _, binding := range bindings { + t.Run(binding, func(t *testing.T) { + t.Parallel() + hs, stop := startPlugin(t, Config{Name: "demo", NewTransport: newRecordingTransport}, binding) + defer stop() + + if hs.CertPEM == "" { + t.Fatal("handshake CertPEM = empty, want a per-launch certificate") + } + if _, err := ClientTLSConfig([]byte(hs.CertPEM)); err != nil { + t.Fatalf("ClientTLSConfig(handshake cert) error = %v", err) + } + }) + } +} + +// startPlugin runs a devkit plugin in serve mode in-process and returns its +// handshake payload plus a stop function that requests shutdown. +func startPlugin(t *testing.T, cfg Config, binding string) (Endpoint, func()) { + t.Helper() + + stdoutR, stdoutW := io.Pipe() + _, stdinW := io.Pipe() + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan struct{}) + go func() { + defer close(done) + ios := &IO{Out: stdoutW} + _ = Run(ctx, cfg, []string{SubcommandServe, "--endpoint", "test://upstream", "--bind", binding}, ios) + }() + + line, err := bufio.NewReader(stdoutR).ReadString('\n') + if err != nil { + cancel() + t.Fatalf("reading handshake error = %v", err) + } + var hs Handshake + if err := json.Unmarshal([]byte(line), &hs); err != nil { + cancel() + t.Fatalf("json.Unmarshal(handshake) error = %v", err) + } + if !hs.Success || hs.Endpoint == nil { + cancel() + t.Fatalf("plugin handshake unsuccessful: %+v", hs) + } + + stop := func() { + cancel() + _ = stdinW.Close() + _ = stdoutR.Close() + <-done + } + return *hs.Endpoint, stop +} + +// newTokenClient builds a client transport for the HTTP bindings that trusts the +// per-launch certificate and stamps the per-launch token on every request, +// mirroring what the host CLI does. +func newTokenClient(t *testing.T, hs Endpoint) a2aclient.Transport { + t.Helper() + httpClient := &http.Client{ + Timeout: 5 * time.Second, + Transport: &testTokenRT{base: trustingTransport(t, hs.CertPEM), token: hs.Token}, + } + switch hs.Binding { + case a2a.TransportProtocolJSONRPC: + return a2aclient.NewJSONRPCTransport(hs.Address, httpClient) + case a2a.TransportProtocolHTTPJSON: + u, err := url.Parse(hs.Address) + if err != nil { + t.Fatalf("url.Parse(%q) error = %v", hs.Address, err) + } + return a2aclient.NewRESTTransport(u, httpClient) + default: + t.Fatalf("newTokenClient: unsupported binding %q", hs.Binding) + return nil + } +} + +// trustingTransport returns an HTTP transport that pins the plugin's per-launch +// certificate, matching how the host dials the loopback proxy over TLS. +func trustingTransport(t *testing.T, certPEM string) http.RoundTripper { + t.Helper() + tlsConfig, err := ClientTLSConfig([]byte(certPEM)) + if err != nil { + t.Fatalf("ClientTLSConfig() error = %v", err) + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = tlsConfig + return transport +} + +type testTokenRT struct { + base http.RoundTripper + token string +} + +func (rt *testTokenRT) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.Header.Set(TokenSvcParam, rt.token) + return rt.base.RoundTrip(clone) +} + +func artifactText(task *a2a.Task) string { + var sb bytes.Buffer + for _, art := range task.Artifacts { + for _, part := range art.Parts { + sb.WriteString(part.Text()) + } + } + return sb.String() +} + +// recordingTransport is a fake upstream transport that echoes the message and +// records the service params it observed. +type recordingTransport struct { + a2aclient.Transport + mu sync.Mutex + authSeen string + destroyed atomic.Bool + destroys atomic.Int32 + servedWhileDestroyed atomic.Bool +} + +func newRecordingTransport(context.Context, string) (a2aclient.Transport, error) { + return &recordingTransport{}, nil +} + +func (r *recordingTransport) lastAuth() string { + r.mu.Lock() + defer r.mu.Unlock() + return r.authSeen +} + +func (r *recordingTransport) SendMessage(_ context.Context, params a2aclient.ServiceParams, req *a2a.SendMessageRequest) (a2a.SendMessageResult, error) { + if r.destroyed.Load() { + r.servedWhileDestroyed.Store(true) + } + + r.mu.Lock() + if auth := params.Get("authorization"); len(auth) > 0 { + r.authSeen = auth[0] + } + r.mu.Unlock() + var text strings.Builder + if req.Message != nil { + for _, p := range req.Message.Parts { + text.WriteString(p.Text()) + } + } + return &a2a.Task{ + ID: a2a.NewTaskID(), + ContextID: a2a.NewContextID(), + Status: a2a.TaskStatus{State: a2a.TaskStateCompleted}, + Artifacts: []*a2a.Artifact{{ID: a2a.NewArtifactID(), Parts: a2a.ContentParts{a2a.NewTextPart(text.String())}}}, + }, nil +} + +func (r *recordingTransport) Destroy() error { + r.destroys.Add(1) + r.destroyed.Store(true) + return nil +} diff --git a/devkit/clitransport/doc.go b/devkit/clitransport/doc.go new file mode 100644 index 0000000..f9ab53f --- /dev/null +++ b/devkit/clitransport/doc.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 clitransport is a helper for authoring A2A CLI transport plugins. +// +// A transport plugin is a standalone binary named "a2a-transport-" discoverable +// through PATH. When the a2a CLI needs a transport it does not implement natively, it +// launches the plugin as a subprocess. The plugin starts a loopback proxy server that speaks +// a standard A2A binding (jsonrpc, rest or grpc) and forwards every request through the +// custom binding transport. +// +// A minimal plugin looks like: +// +// func main() { +// clitransport.Main(clitransport.Config{ +// Name: "carrier-pigeon", +// Version: "1.0.0", +// NewTransport: func(ctx context.Context, endpoint string) (a2aclient.Transport, error) { +// return pigeon.NewSender(endpoint) +// }, +// }) +// } +package clitransport diff --git a/devkit/clitransport/handler.go b/devkit/clitransport/handler.go new file mode 100644 index 0000000..1ae6d5e --- /dev/null +++ b/devkit/clitransport/handler.go @@ -0,0 +1,155 @@ +// 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 clitransport + +import ( + "context" + "crypto/subtle" + "iter" + "strings" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2asrv" +) + +// transportHandler adapts an [a2aclient.Transport] into an [a2asrv.RequestHandler]. +type transportHandler struct { + transport a2aclient.Transport + token string +} + +var _ a2asrv.RequestHandler = (*transportHandler)(nil) + +func newTransportHandler(t a2aclient.Transport, token string) *transportHandler { + return &transportHandler{transport: t, token: token} +} + +func (h *transportHandler) GetTask(ctx context.Context, req *a2a.GetTaskRequest) (*a2a.Task, error) { + params, err := h.authorize(ctx) + if err != nil { + return nil, err + } + return h.transport.GetTask(ctx, params, req) +} + +func (h *transportHandler) ListTasks(ctx context.Context, req *a2a.ListTasksRequest) (*a2a.ListTasksResponse, error) { + params, err := h.authorize(ctx) + if err != nil { + return nil, err + } + return h.transport.ListTasks(ctx, params, req) +} + +func (h *transportHandler) CancelTask(ctx context.Context, req *a2a.CancelTaskRequest) (*a2a.Task, error) { + params, err := h.authorize(ctx) + if err != nil { + return nil, err + } + return h.transport.CancelTask(ctx, params, req) +} + +func (h *transportHandler) SendMessage(ctx context.Context, req *a2a.SendMessageRequest) (a2a.SendMessageResult, error) { + params, err := h.authorize(ctx) + if err != nil { + return nil, err + } + return h.transport.SendMessage(ctx, params, req) +} + +func (h *transportHandler) SubscribeToTask(ctx context.Context, req *a2a.SubscribeToTaskRequest) iter.Seq2[a2a.Event, error] { + params, err := h.authorize(ctx) + if err != nil { + return errorEvents(err) + } + return h.transport.SubscribeToTask(ctx, params, req) +} + +func (h *transportHandler) SendStreamingMessage(ctx context.Context, req *a2a.SendMessageRequest) iter.Seq2[a2a.Event, error] { + params, err := h.authorize(ctx) + if err != nil { + return errorEvents(err) + } + return h.transport.SendStreamingMessage(ctx, params, req) +} + +func (h *transportHandler) GetTaskPushConfig(ctx context.Context, req *a2a.GetTaskPushConfigRequest) (*a2a.PushConfig, error) { + params, err := h.authorize(ctx) + if err != nil { + return nil, err + } + return h.transport.GetTaskPushConfig(ctx, params, req) +} + +func (h *transportHandler) ListTaskPushConfigs(ctx context.Context, req *a2a.ListTaskPushConfigRequest) (*a2a.ListTaskPushConfigResponse, error) { + params, err := h.authorize(ctx) + if err != nil { + return nil, err + } + configs, err := h.transport.ListTaskPushConfigs(ctx, params, req) + if err != nil { + return nil, err + } + return &a2a.ListTaskPushConfigResponse{Configs: configs}, nil +} + +func (h *transportHandler) CreateTaskPushConfig(ctx context.Context, req *a2a.PushConfig) (*a2a.PushConfig, error) { + params, err := h.authorize(ctx) + if err != nil { + return nil, err + } + return h.transport.CreateTaskPushConfig(ctx, params, req) +} + +func (h *transportHandler) DeleteTaskPushConfig(ctx context.Context, req *a2a.DeleteTaskPushConfigRequest) error { + params, err := h.authorize(ctx) + if err != nil { + return err + } + return h.transport.DeleteTaskPushConfig(ctx, params, req) +} + +func (h *transportHandler) GetExtendedAgentCard(ctx context.Context, req *a2a.GetExtendedAgentCardRequest) (*a2a.AgentCard, error) { + params, err := h.authorize(ctx) + if err != nil { + return nil, err + } + return h.transport.GetExtendedAgentCard(ctx, params, req) +} + +func (h *transportHandler) authorize(ctx context.Context) (a2aclient.ServiceParams, error) { + callCtx, ok := a2asrv.CallContextFrom(ctx) + if !ok { + return nil, a2a.ErrUnauthenticated + } + got, _ := callCtx.ServiceParams().Get(TokenSvcParam) + if len(got) != 1 || subtle.ConstantTimeCompare([]byte(got[0]), []byte(h.token)) != 1 { + return nil, a2a.ErrUnauthenticated + } + params := a2aclient.ServiceParams{} + for k, v := range callCtx.ServiceParams().List() { + if strings.EqualFold(k, TokenSvcParam) { + continue + } + params[k] = v + } + return params, nil +} + +func errorEvents(err error) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + yield(nil, err) + } +} diff --git a/devkit/clitransport/server.go b/devkit/clitransport/server.go new file mode 100644 index 0000000..83c1e62 --- /dev/null +++ b/devkit/clitransport/server.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 clitransport + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "sync" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/health" + healthpb "google.golang.org/grpc/health/grpc_health_v1" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" + "github.com/a2aproject/a2a-go/v2/a2asrv" +) + +const ( + healthReadyTimeout = 10 * time.Second + healthProbeTimeout = 2 * time.Second + healthRetryInterval = 25 * time.Millisecond +) + +type cleanupFunc func() + +type serverCore struct { + address string + serveFunc func(ctx context.Context) error + healthcheckFunc func(ctx context.Context) error +} + +type server struct { + body *Endpoint + core *serverCore + cancel context.CancelFunc + serveErr error + serveDone chan struct{} + cleanupFunc cleanupFunc + cleanupOnce sync.Once +} + +func newServer(binding a2a.TransportProtocol, token string, handler a2asrv.RequestHandler, cleanup cleanupFunc) (*server, error) { + tlsSetup, err := genLoopbackTLSSetup() + if err != nil { + return nil, fmt.Errorf("generating loopback certificate: %w", err) + } + + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("listen: %w", err) + } + + var srvCore *serverCore + switch binding { + case a2a.TransportProtocolHTTPJSON, a2a.TransportProtocolJSONRPC: + core, err := newHTTPServerCore(lis, binding, handler, tlsSetup) + if err != nil { + return nil, err + } + srvCore = core + case a2a.TransportProtocolGRPC: + core, err := newGRPCServerCore(lis, handler, tlsSetup) + if err != nil { + return nil, err + } + srvCore = core + default: + return nil, fmt.Errorf("unsupported binding %q (want %s, %s or %s)", binding, a2a.TransportProtocolGRPC, a2a.TransportProtocolHTTPJSON, a2a.TransportProtocolJSONRPC) + } + + return &server{ + core: srvCore, + cleanupFunc: cleanup, + serveDone: make(chan struct{}), + body: &Endpoint{ + Address: srvCore.address, + Binding: binding, + Protocol: a2a.Version, + Token: token, + CertPEM: string(tlsSetup.certPEM), + }, + }, nil +} + +func newHTTPServerCore(lis net.Listener, binding a2a.TransportProtocol, handler a2asrv.RequestHandler, tlsSetup *tlsSetup) (*serverCore, error) { + mux := http.NewServeMux() + switch binding { + case a2a.TransportProtocolHTTPJSON: + mux.Handle("/", a2asrv.NewRESTHandler(handler)) + case a2a.TransportProtocolJSONRPC: + mux.Handle("/", a2asrv.NewJSONRPCHandler(handler)) + default: + return nil, fmt.Errorf("unexpected binding %q (want %s or %s)", binding, a2a.TransportProtocolHTTPJSON, a2a.TransportProtocolJSONRPC) + } + + srv := http.Server{ + Handler: mux, + TLSConfig: &tls.Config{Certificates: []tls.Certificate{tlsSetup.serverCert}}, + } + + healthClient := &http.Client{Transport: &http.Transport{TLSClientConfig: tlsSetup.clientConf}} + address := "https://" + lis.Addr().String() + return &serverCore{ + address: address, + + serveFunc: func(ctx context.Context) error { + go func() { + <-ctx.Done() + _ = srv.Shutdown(context.Background()) + }() + if err := srv.ServeTLS(lis, "", ""); err != nil && err != http.ErrServerClosed { + return err + } + return nil + }, + + healthcheckFunc: func(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, address+a2asrv.WellKnownAgentCardPath, nil) + if err != nil { + return err + } + resp, err := healthClient.Do(req) + if err != nil { + return err + } + return resp.Body.Close() + }, + }, nil +} + +func newGRPCServerCore(lis net.Listener, handler a2asrv.RequestHandler, tlsSetup *tlsSetup) (*serverCore, error) { + serverTLS := &tls.Config{Certificates: []tls.Certificate{tlsSetup.serverCert}} + grpcSrv := grpc.NewServer(grpc.Creds(credentials.NewTLS(serverTLS))) + a2agrpc.NewHandler(handler).RegisterWith(grpcSrv) + + healthSrv := health.NewServer() + healthpb.RegisterHealthServer(grpcSrv, healthSrv) + healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) + + address := lis.Addr().String() + return &serverCore{ + address: address, + + serveFunc: func(ctx context.Context) error { + go func() { + <-ctx.Done() + grpcSrv.GracefulStop() + }() + return grpcSrv.Serve(lis) + }, + + healthcheckFunc: func(ctx context.Context) error { + conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(credentials.NewTLS(tlsSetup.clientConf))) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + _, err = healthpb.NewHealthClient(conn).Check(ctx, &healthpb.HealthCheckRequest{}, grpc.WaitForReady(true)) + return err + }, + }, nil +} + +// start runs the server in the background and blocks until it becomes healthy. +// The server keeps running until ctx is cancelled or [server.stop] is called. +func (s *server) start(ctx context.Context) error { + serveCtx, cancel := context.WithCancel(ctx) + s.cancel = cancel + go func() { + s.serveErr = s.core.serveFunc(serveCtx) + close(s.serveDone) + }() + + if err := s.waitHealthy(ctx); err != nil { + cancel() + <-s.serveDone + return err + } + + return nil +} + +func (s *server) await() error { + <-s.serveDone + s.cleanup() + return s.serveErr +} + +func (s *server) stop() { + s.cancel() + <-s.serveDone + s.cleanup() +} + +func (s *server) cleanup() { + if s.cleanupFunc != nil { + s.cleanupOnce.Do(s.cleanupFunc) + } +} + +func (s *server) waitHealthy(ctx context.Context) error { + deadline := time.NewTimer(healthReadyTimeout) + defer deadline.Stop() + + var lastErr error + for { + select { + case <-s.serveDone: + return fmt.Errorf("server exited before becoming ready: %w", s.serveErr) + default: + } + + probeCtx, cancel := context.WithTimeout(ctx, healthProbeTimeout) + lastErr = s.core.healthcheckFunc(probeCtx) + cancel() + if lastErr == nil { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + + case <-deadline.C: + return fmt.Errorf("server did not become ready within %s: %w", healthReadyTimeout, lastErr) + + case <-s.serveDone: + return fmt.Errorf("server exited before becoming ready: %w", s.serveErr) + + case <-time.After(healthRetryInterval): + } + } +} diff --git a/devkit/clitransport/tls.go b/devkit/clitransport/tls.go new file mode 100644 index 0000000..3723bc2 --- /dev/null +++ b/devkit/clitransport/tls.go @@ -0,0 +1,102 @@ +// 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 clitransport + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "time" +) + +const certValidity = 24 * time.Hour + +type tlsSetup struct { + serverCert tls.Certificate + clientConf *tls.Config + certPEM []byte +} + +// ClientTLSConfig builds a TLS client configuration that trusts only the given +// PEM-encoded certificate. +func ClientTLSConfig(certPEM []byte) (*tls.Config, error) { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(certPEM) { + return nil, fmt.Errorf("no valid certificate found in PEM block") + } + return &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, nil +} + +// genLoopbackTLSCert mints an ephemeral, self-signed certificate for the loopback +// proxy server. It returns the certificate to serve with and its PEM encoding to +// advertise to the host in the handshake. +func genLoopbackTLSSetup() (*tlsSetup, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating key: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("generating serial number: %w", err) + } + + now := time.Now() + template := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "a2a-transport-plugin"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(certValidity), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + } + + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + return nil, fmt.Errorf("creating certificate: %w", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, fmt.Errorf("marshaling key: %w", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return nil, fmt.Errorf("building key pair: %w", err) + } + clientConf, err := ClientTLSConfig(certPEM) + if err != nil { + return nil, err + } + return &tlsSetup{ + serverCert: cert, + certPEM: certPEM, + clientConf: clientConf, + }, nil +} diff --git a/devkit/clitransport/wire.go b/devkit/clitransport/wire.go new file mode 100644 index 0000000..bc03d61 --- /dev/null +++ b/devkit/clitransport/wire.go @@ -0,0 +1,72 @@ +// 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 clitransport + +import "github.com/a2aproject/a2a-go/v2/a2a" + +// Plugin subcommands a plugin binary must implement. +const ( + // SubcommandServe starts the loopback proxy server, effectively opening a transport. + SubcommandServe = "serve" + // SubcommandInfo prints an [Info] JSON document describing the plugin. + SubcommandInfo = "info" +) + +// TokenSvcParam is the service parameter carrying the per-launch shared secret for the +// jsonrpc and rest bindings. The host sets it on every request; the plugin +// rejects requests without a matching value. +const TokenSvcParam = "A2A-Plugin-Token" + +// Handshake is the single JSON line a plugin prints to stdout after it has +// attempted to start its loopback proxy server. +type Handshake struct { + // Success reports whether the proxy server started and passed its readiness check. + Success bool `json:"success"` + // Error describes why the plugin failed to start. Empty on success. + Error string `json:"error,omitempty"` + // Endpoint carries the connection details. Non-nil only on success. + Endpoint *Endpoint `json:"payload,omitempty"` +} + +// Endpoint carries the details the host needs to connect to a plugin's +// loopback proxy server. +type Endpoint struct { + // Address is the loopback address of the proxy server. + Address string `json:"address"` + // Binding names the standard A2A transport binding the proxy speaks. + Binding a2a.TransportProtocol `json:"binding"` + // Protocol is the A2A protocol version the proxy exposes (e.g. "1.0"). + Protocol a2a.ProtocolVersion `json:"protocol"` + // Token is the per-launch shared secret the host must present on every call. + Token string `json:"token"` + // CertPEM is the PEM-encoded, self-signed certificate the proxy server + // presents for TLS. When non-empty, the host connects over TLS and pins this + // certificate as the only trusted root. + CertPEM string `json:"certPem,omitempty"` +} + +// Info describes a transport plugin. It is printed by the "info" subcommand. +type Info struct { + // Name is the transport name. + Name string `json:"name"` + // Version is the plugin's own version string. + Version string `json:"version"` + // Description is a short human-readable summary of the transport. + Description string `json:"description,omitempty"` + // Protocol is the A2A protocol version the plugin targets (e.g. "1.0"). + Protocol a2a.ProtocolVersion `json:"protocol,omitempty"` + // Binding is the default loopback binding the plugin serves. + Binding a2a.TransportProtocol `json:"binding,omitempty"` +} diff --git a/docs/transport-plugins.md b/docs/transport-plugins.md new file mode 100644 index 0000000..172af5c --- /dev/null +++ b/docs/transport-plugins.md @@ -0,0 +1,217 @@ +# Custom transport plugins + +The `a2a` CLI speaks JSON-RPC, REST and gRPC natively. Any other transport +binding (a proprietary message bus, a SLIM variant, WebSockets, …) can be added +**without recompiling the CLI** by installing a *transport plugin* binary on your +`PATH`. + +A plugin is a small **local proxy**: the CLI launches it as a subprocess, the +plugin stands up a loopback A2A server that speaks one of the standard bindings, +and it forwards every request to the custom upstream. + +``` + a2a send --transport slimrpc --endpoint slim://agents.example/agent "hi" + │ + │ 1. discover a2a-transport-slimrpc on PATH + │ 2. launch: a2a-transport-slimrpc serve --endpoint slim://agents.example/agent + ▼ + ┌─────────────────────────┐ standard A2A (jsonrpc/rest/grpc) on 127.0.0.1 + │ a2a (host CLI) │ ────────────────┐ + └─────────────────────────┘ │ + ▼ + ┌─────────────────────────────────┐ + │ a2a-transport-slimrpc (plugin) │ + │ loopback A2A server ──► SLIM │──► upstream agent + └─────────────────────────────────┘ +``` + +## Using a plugin + +1. Put the plugin binary on your `PATH`, named `a2a-transport-`. +2. List what's installed: + + ```console + $ a2a transport list + NAME VERSION PROTOCOL DESCRIPTION PATH + echo 1.0.0 1.0 Echoes the caller's message… /usr/local/bin/a2a-transport-echo + ``` + +3. Use it like any built-in transport, either explicitly: + + ```console + $ a2a send --transport echo --endpoint echo://demo "hello" + ``` + + or automatically, when an agent card advertises a `protocolBinding` that + matches an installed plugin: + + ```console + $ a2a send -a https://agents.example.com "hello" # card says binding "echo" → plugin used + ``` + +If no plugin matches an explicitly requested `--transport`, the CLI errors and +lists the plugins it did find. + +> **Trust:** a transport plugin is an executable you install yourself. Treat it +> like any other binary on your `PATH` — only install plugins you trust. The CLI +> does not sandbox plugins; it only isolates them in a subprocess and secures the +> loopback channel with per-session TLS and a per-launch token (see below). + +## Writing a plugin in Go (recommended) + +Use the devkit at +[`github.com/a2aproject/a2a-cli/devkit/clitransport`](../devkit/clitransport). +You supply an [`a2aclient.Transport`](https://pkg.go.dev/github.com/a2aproject/a2a-go/v2/a2aclient#Transport) +that implements your custom protocol; the devkit turns it into a CLI-compatible +plugin binary — subcommand parsing, the loopback server, the handshake, the +token check and graceful shutdown are all handled for you. + +```go +package main + +import ( + "context" + + "github.com/a2aproject/a2a-cli/devkit/clitransport" + "github.com/a2aproject/a2a-go/v2/a2aclient" +) + +func main() { + clitransport.Main(clitransport.Config{ + Name: "slimrpc", + Version: "1.0.0", + Description: "SLIM RPC transport for A2A", + NewTransport: func(ctx context.Context, endpoint string) (a2aclient.Transport, error) { + // Return your custom client transport, connected to `endpoint`. + return slim.Dial(ctx, endpoint) + }, + }) +} +``` + +Build it with the required name and drop it on your `PATH`: + +```console +$ go build -o a2a-transport-slimrpc . +$ mv a2a-transport-slimrpc ~/bin/ # somewhere on PATH +``` + +A complete, runnable example lives in +[`examples/a2a-transport-echo`](../examples/a2a-transport-echo). + +## The plugin contract (any language) + +The devkit is the easy path, but the contract is deliberately simple so a plugin +can be written in any language that can run an HTTP or gRPC server. A plugin +binary named `a2a-transport-` must implement two subcommands. + +### `info` + +Print a single JSON object to stdout and exit 0: + +```json +{"name":"slimrpc","version":"1.0.0","description":"SLIM RPC transport","protocol":"1.0","binding":"jsonrpc"} +``` + +Used by `a2a transport list`. + +### `serve --endpoint ` + +1. Start an A2A server on a loopback address using one of the standard bindings + (`jsonrpc`, `rest`, or `grpc`), backed by your custom transport. +2. Generate a random per-launch **token**. +3. **Secure the channel with TLS (recommended).** Mint an ephemeral, self-signed + certificate valid for loopback (`127.0.0.1`/`::1`/`localhost`), serve TLS with + it, and advertise it as `certPem` in the handshake. The host pins that exact + certificate as its only trusted root, so there is no CA or on-disk key to + manage. TLS is optional: omit `certPem` to serve plaintext (the token still + authenticates the caller), which keeps very simple non-Go plugins easy to + write. The devkit always does TLS for you. +4. **Confirm the server is actually accepting requests** (a readiness probe) + before announcing — the devkit gates the handshake on an HTTPS GET to the card + path for the HTTP bindings and on the standard gRPC health service for `grpc`. +5. Print exactly one JSON line to stdout — the **handshake envelope** — then keep + serving. On success: + + ```json + {"success":true,"endpoint":{"address":"127.0.0.1:53821","binding":"GRPC","protocol":"1.0","token":"9f3c…","certPem":"-----BEGIN CERTIFICATE-----\n…\n-----END CERTIFICATE-----\n"}} + ``` + + and on a startup failure (so the host can report a clean error instead of a + dropped connection): + + ```json + {"success":false,"error":"creating upstream transport: dial slim://…: connection refused"} + ``` + + The `endpoint` fields are: + * `address` — where the host connects. A full `https://…` URL (or `http://…` + when serving plaintext) for `jsonrpc` and `rest`; a `host:port` dial target + for `grpc`. + * `binding` — the standard binding your server speaks. + * `protocol` — the A2A protocol version (e.g. `1.0`). + * `token` — the shared secret for this launch. + * `certPem` — the PEM-encoded certificate the host must pin to dial over TLS. + Omit (or leave empty) to serve plaintext. +6. On **every** request, the host presents the token as the `A2A-Plugin-Token` + service parameter (an HTTP header for `jsonrpc`/`rest`, gRPC metadata for + `grpc`). Reject any request whose token does not match, and do **not** forward + the token upstream. +7. Other request service parameters (e.g. `Authorization`) should be forwarded to + the upstream as your protocol requires. +8. Shut down cleanly when **stdin reaches EOF** (the host closes it to stop you) + or on `SIGTERM`/`SIGINT`. + +Anything the plugin writes to stderr is surfaced to the user for debugging. + +### Minimal non-Go example (`info` in shell) + +The `info` half of the contract is trivial in any language. Here it is in POSIX +shell: + +```sh +#!/bin/sh +# a2a-transport-demo +case "$1" in + info) + printf '%s\n' '{"name":"demo","version":"0.1.0","binding":"JSONRPC","protocol":"1.0"}' + ;; + serve) + # Start your A2A JSON-RPC server on 127.0.0.1:PORT, confirm it is accepting + # requests, then announce the handshake envelope. This stub serves plaintext + # (no certPem), so the token alone authenticates the caller; add a certPem + # field and serve https:// to secure the channel with TLS: + # printf '%s\n' '{"success":true,"payload":{"address":"http://127.0.0.1:PORT","binding":"JSONRPC","protocol":"1.0","token":"'"$TOKEN"'"}}' + # and keep serving until stdin closes. On failure, announce instead: + # printf '%s\n' '{"success":false,"error":"could not reach upstream"}' + echo "serve not implemented in this stub" >&2 + printf '%s\n' '{"success":false,"error":"serve not implemented in this stub"}' + exit 1 + ;; +esac +``` + +The `serve` half needs a real A2A server for your chosen binding. In Python, for +example, you can build the JSON-RPC/REST server with the +[A2A Python SDK](https://github.com/a2aproject/a2a-python) and print the same +handshake line. In Go, the devkit does all of this for you. + +## How the host selects a plugin + +`internal/flagparse` keeps `rest`, `jsonrpc` and `grpc` as built-in aliases and +treats any other `--transport` value as a custom binding +(`a2a.TransportProtocol` is explicitly not an enum in the A2A spec). When a +client is built: + +* **`--endpoint` mode** — an explicitly named custom transport must resolve to a + plugin, otherwise the CLI errors. +* **card mode** — the CLI registers a plugin for each custom `protocolBinding` + the card advertises (and for any custom `--transport` preference). Card + bindings with no installed plugin are skipped so other interfaces can still be + tried. + +The registered factory (`internal/transportplugin`) launches the plugin, reads +the handshake, and builds a built-in transport pointed at the loopback address — +pinning the handshake's `certPem` as the sole trusted root when the plugin serves +TLS — with the token injected on every call. When the CLI is done, closing the +client tears the subprocess down. diff --git a/examples/a2a-transport-echo/README.md b/examples/a2a-transport-echo/README.md new file mode 100644 index 0000000..332afab --- /dev/null +++ b/examples/a2a-transport-echo/README.md @@ -0,0 +1,43 @@ +# a2a-transport-echo + +A reference [A2A CLI transport plugin](../../docs/transport-plugins.md) built with +the [`clitransport` devkit](../../devkit/clitransport). + +It implements a custom `a2aclient.Transport` that does not talk to any real +upstream — it simply echoes the caller's message back as a completed task. It +exists to demonstrate the plugin contract end to end. + +## Build and install + +```console +$ go build -o a2a-transport-echo . +$ mv a2a-transport-echo ~/bin/ # anywhere on your PATH +``` + +## Try it + +```console +$ a2a transport list +NAME VERSION PROTOCOL DESCRIPTION PATH +echo 1.0.0 1.0 Echoes the caller's message back as a completed task …/a2a-transport-echo + +$ a2a send --transport echo --endpoint echo://demo "hello there" +Task: … +Status: completed +Artifacts: + […] hello there + +$ a2a send --transport echo --endpoint echo://demo --stream -o json "stream me" +{ "task": { … "state": "TASK_STATE_SUBMITTED" } } +{ "statusUpdate": { … "state": "TASK_STATE_WORKING" } } +{ "artifactUpdate": { … "text": "stream me" … } } +{ "statusUpdate": { … "state": "TASK_STATE_COMPLETED" } } +``` + +## What to look at + +* [`main.go`](main.go) — wires the plugin with `clitransport.Main`. +* [`echo.go`](echo.go) — the custom `a2aclient.Transport` implementation. + +Everything else — subcommand parsing, the loopback server, the handshake, the +per-launch token and graceful shutdown — is provided by the devkit. diff --git a/examples/a2a-transport-echo/echo.go b/examples/a2a-transport-echo/echo.go new file mode 100644 index 0000000..f841606 --- /dev/null +++ b/examples/a2a-transport-echo/echo.go @@ -0,0 +1,180 @@ +// 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 main + +import ( + "context" + "iter" + "strings" + "sync" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" +) + +// echoTransport is a custom a2aclient.Transport that fabricates responses +// locally instead of talking to a remote agent. It shows the shape of a real +// plugin transport without requiring any network dependency. +type echoTransport struct { + endpoint string + + mu sync.Mutex + tasks map[a2a.TaskID]*a2a.Task +} + +var _ a2aclient.Transport = (*echoTransport)(nil) + +func newEchoTransport(endpoint string) *echoTransport { + return &echoTransport{endpoint: endpoint, tasks: map[a2a.TaskID]*a2a.Task{}} +} + +func (t *echoTransport) SendMessage(_ context.Context, _ a2aclient.ServiceParams, req *a2a.SendMessageRequest) (a2a.SendMessageResult, error) { + task := t.completedTask(req.Message) + t.mu.Lock() + t.tasks[task.ID] = task + t.mu.Unlock() + return task, nil +} + +func (t *echoTransport) SendStreamingMessage(_ context.Context, _ a2aclient.ServiceParams, req *a2a.SendMessageRequest) iter.Seq2[a2a.Event, error] { + task := t.completedTask(req.Message) + t.mu.Lock() + t.tasks[task.ID] = task + t.mu.Unlock() + + text := messageText(req.Message) + return func(yield func(a2a.Event, error) bool) { + submitted := &a2a.Task{ID: task.ID, ContextID: task.ContextID, Status: a2a.TaskStatus{State: a2a.TaskStateSubmitted}} + if !yield(submitted, nil) { + return + } + if !yield(a2a.NewStatusUpdateEvent(task, a2a.TaskStateWorking, nil), nil) { + return + } + artifact := a2a.NewArtifactEvent(task, a2a.NewTextPart(text)) + artifact.LastChunk = true + if !yield(artifact, nil) { + return + } + yield(a2a.NewStatusUpdateEvent(task, a2a.TaskStateCompleted, nil), nil) + } +} + +func (t *echoTransport) GetTask(_ context.Context, _ a2aclient.ServiceParams, req *a2a.GetTaskRequest) (*a2a.Task, error) { + t.mu.Lock() + defer t.mu.Unlock() + task, ok := t.tasks[req.ID] + if !ok { + return nil, a2a.ErrTaskNotFound + } + return task, nil +} + +func (t *echoTransport) ListTasks(_ context.Context, _ a2aclient.ServiceParams, _ *a2a.ListTasksRequest) (*a2a.ListTasksResponse, error) { + t.mu.Lock() + defer t.mu.Unlock() + tasks := make([]*a2a.Task, 0, len(t.tasks)) + for _, task := range t.tasks { + tasks = append(tasks, task) + } + return &a2a.ListTasksResponse{Tasks: tasks}, nil +} + +func (t *echoTransport) CancelTask(_ context.Context, _ a2aclient.ServiceParams, req *a2a.CancelTaskRequest) (*a2a.Task, error) { + t.mu.Lock() + defer t.mu.Unlock() + task, ok := t.tasks[req.ID] + if !ok { + return nil, a2a.ErrTaskNotFound + } + task.Status.State = a2a.TaskStateCanceled + return task, nil +} + +func (t *echoTransport) SubscribeToTask(_ context.Context, _ a2aclient.ServiceParams, req *a2a.SubscribeToTaskRequest) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + t.mu.Lock() + task, ok := t.tasks[req.ID] + t.mu.Unlock() + if !ok { + yield(nil, a2a.ErrTaskNotFound) + return + } + yield(a2a.NewStatusUpdateEvent(task, task.Status.State, nil), nil) + } +} + +func (t *echoTransport) GetTaskPushConfig(context.Context, a2aclient.ServiceParams, *a2a.GetTaskPushConfigRequest) (*a2a.PushConfig, error) { + return nil, a2a.ErrPushNotificationNotSupported +} + +func (t *echoTransport) ListTaskPushConfigs(context.Context, a2aclient.ServiceParams, *a2a.ListTaskPushConfigRequest) ([]*a2a.PushConfig, error) { + return nil, a2a.ErrPushNotificationNotSupported +} + +func (t *echoTransport) CreateTaskPushConfig(context.Context, a2aclient.ServiceParams, *a2a.PushConfig) (*a2a.PushConfig, error) { + return nil, a2a.ErrPushNotificationNotSupported +} + +func (t *echoTransport) DeleteTaskPushConfig(context.Context, a2aclient.ServiceParams, *a2a.DeleteTaskPushConfigRequest) error { + return a2a.ErrPushNotificationNotSupported +} + +func (t *echoTransport) GetExtendedAgentCard(context.Context, a2aclient.ServiceParams, *a2a.GetExtendedAgentCardRequest) (*a2a.AgentCard, error) { + return &a2a.AgentCard{ + Name: "Echo (via echo transport plugin)", + Description: "Echoes messages back; proxied from " + t.endpoint, + Version: "1.0.0", + Capabilities: a2a.AgentCapabilities{Streaming: true}, + DefaultInputModes: []string{"text"}, + DefaultOutputModes: []string{"text"}, + Skills: []a2a.AgentSkill{}, + }, nil +} + +func (t *echoTransport) Destroy() error { + return nil +} + +func (t *echoTransport) completedTask(msg *a2a.Message) *a2a.Task { + text := messageText(msg) + task := &a2a.Task{ + ID: a2a.NewTaskID(), + ContextID: a2a.NewContextID(), + Status: a2a.TaskStatus{State: a2a.TaskStateCompleted}, + Artifacts: []*a2a.Artifact{{ + ID: a2a.NewArtifactID(), + Parts: a2a.ContentParts{a2a.NewTextPart(text)}, + }}, + } + if msg != nil { + task.History = []*a2a.Message{msg} + } + return task +} + +func messageText(msg *a2a.Message) string { + if msg == nil { + return "" + } + var sb strings.Builder + for i, part := range msg.Parts { + if i > 0 { + sb.WriteString(" ") + } + sb.WriteString(part.Text()) + } + return sb.String() +} diff --git a/examples/a2a-transport-echo/main.go b/examples/a2a-transport-echo/main.go new file mode 100644 index 0000000..41eecf8 --- /dev/null +++ b/examples/a2a-transport-echo/main.go @@ -0,0 +1,42 @@ +// 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. + +// Command a2a-transport-echo is a reference A2A CLI transport plugin. +// +// It demonstrates the plugin contract using the devkit: the custom transport +// does not talk to any real upstream, it simply echoes the caller's message +// back as a completed task. Install it by putting the built binary on PATH and +// run, for example: +// +// a2a send --transport echo --endpoint echo://demo "hello there" +// a2a transport list +package main + +import ( + "context" + + "github.com/a2aproject/a2a-cli/devkit/clitransport" + "github.com/a2aproject/a2a-go/v2/a2aclient" +) + +func main() { + clitransport.Main(clitransport.Config{ + Name: "echo", + Version: "1.0.0", + Description: "Echoes the caller's message back as a completed task", + NewTransport: func(_ context.Context, endpoint string) (a2aclient.Transport, error) { + return newEchoTransport(endpoint), nil + }, + }) +} diff --git a/internal/README.md b/internal/README.md index fc407c2..c110c86 100644 --- a/internal/README.md +++ b/internal/README.md @@ -311,3 +311,21 @@ StatusUpdate: completed All commands support `-o json` for machine-readable output, emitting raw protocol objects. Text mode is the default, meant for reading in a terminal. + +## Custom Transport Plugins + +The CLI speaks JSON-RPC, REST and gRPC out of the box. Additional transport +bindings can be added **without recompiling** by dropping an `a2a-transport-` +binary on your `PATH`. The CLI launches the plugin as a local proxy that speaks a +standard A2A binding and forwards to the custom protocol. + +```console +$ a2a transport list +$ a2a send --transport slimrpc --endpoint slim://agents.example/agent "hello" +``` + +Authoring a plugin in Go is a few lines with the +[`devkit/clitransport`](./devkit/clitransport) package — you provide an +`a2aclient.Transport`, it produces a CLI-compatible plugin. See the +**[transport plugin guide](./docs/transport-plugins.md)** and the runnable +**[echo plugin example](./examples/a2a-transport-echo)**. \ No newline at end of file diff --git a/internal/cli/client.go b/internal/cli/client.go index 5de692c..3bc4736 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -26,6 +26,7 @@ import ( "google.golang.org/grpc/credentials/insecure" "github.com/a2aproject/a2a-cli/internal/flagparse" + "github.com/a2aproject/a2a-cli/internal/transportplugin" "github.com/a2aproject/a2a-go/v2/a2a" "github.com/a2aproject/a2a-go/v2/a2aclient" "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" @@ -64,17 +65,27 @@ func newClientFromEndpoint(ctx context.Context, cfg *globalConfig, ref string, e if protocol == a2a.TransportProtocolGRPC { endpointURL = stripHTTPScheme(ref) } + cfg.logf("connecting directly to %s via %s (skipping card resolution)", endpointURL, protocol) + factoryOpts := append(clientFactoryOpts(cfg), extraOpts...) + if !transportplugin.IsBuiltin(protocol) { + pluginOpt, err := transportplugin.Load(protocol) + if err != nil { + return nil, err + } + factoryOpts = append(factoryOpts, pluginOpt) + } + endpoint := a2a.NewAgentInterface(endpointURL, protocol) - client, err := a2aclient.NewFromEndpoints(ctx, []*a2a.AgentInterface{endpoint}, append(clientFactoryOpts(cfg), extraOpts...)...) + client, err := a2aclient.NewFromEndpoints(ctx, []*a2a.AgentInterface{endpoint}, factoryOpts...) return client, hintInsecure(err) } // newClientFromCard resolves the Agent Card and builds a client for it, honoring // --transport as an ordered client preference over the card's declared interfaces. func newClientFromCard(ctx context.Context, cfg *globalConfig, ref string, extraOpts ...a2aclient.FactoryOption) (*a2aclient.Client, error) { - protos, err := flagparse.Transports(cfg.transports) + transportPrefs, err := flagparse.Transports(cfg.transports) if err != nil { return nil, err } @@ -91,8 +102,15 @@ func newClientFromCard(ctx context.Context, cfg *globalConfig, ref string, extra } factoryOpts := append(clientFactoryOpts(cfg), extraOpts...) - if len(protos) > 0 { - factoryOpts = append(factoryOpts, a2aclient.WithConfig(a2aclient.Config{PreferredTransports: protos})) + pluginOpts, err := transportplugin.LoadForCard(card) + if err != nil { + return nil, err + } + factoryOpts = append(factoryOpts, pluginOpts...) + if len(transportPrefs) > 0 { + factoryOpts = append(factoryOpts, a2aclient.WithConfig( + a2aclient.Config{PreferredTransports: transportPrefs}, + )) } cfg.logf("creating client for %s", card.Name) client, err := a2aclient.NewFromCard(ctx, card, factoryOpts...) diff --git a/internal/cli/root.go b/internal/cli/root.go index 196d3f8..f990d7c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -115,7 +115,7 @@ func newRootCmd(cfg *globalConfig, deps deps) *cobra.Command { pf.StringVarP(&cfg.output, "output", "o", "text", "Output format: text, json") pf.VarP(&cfg.agentCard, "agent-card", "a", "Agent Card reference: host/origin, full card URL, or local file path") pf.StringVarP(&cfg.url, "endpoint", "e", "", "Agent interface URL for a direct connection; skips card resolution and requires a single --transport flag") - pf.StringArrayVar(&cfg.transports, "transport", nil, "Transport preference: rest, jsonrpc, grpc (repeatable, highest preference first)") + pf.StringArrayVar(&cfg.transports, "transport", nil, "Transport preference: rest, jsonrpc, grpc, or an installed plugin name (repeatable, highest preference first)") cfg.svcParams.Attach(pf) pf.StringVar(&cfg.tenant, "tenant", "", "Tenant identifier") pf.DurationVar(&cfg.timeout, "timeout", 30*time.Second, "Request timeout") @@ -129,6 +129,7 @@ func newRootCmd(cfg *globalConfig, deps deps) *cobra.Command { newTaskCmd(cfg), newConfigCmd(cfg), newServeCmd(cfg), + newTransportCmd(cfg), newVersionCmd(cfg), ) diff --git a/internal/cli/serve.go b/internal/cli/serve.go index f1aa6dd..5574f4d 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -25,6 +25,7 @@ import ( "github.com/a2aproject/a2a-cli/internal/flagparse" "github.com/a2aproject/a2a-cli/internal/localsrv" + "github.com/a2aproject/a2a-cli/internal/transportplugin" "github.com/a2aproject/a2a-go/v2/a2a" "github.com/a2aproject/a2a-go/v2/a2aclient" ) @@ -54,7 +55,6 @@ func newServeCmd(cfg *globalConfig) *cobra.Command { if protocol != "latest" && protocol != "0.3" { return fmt.Errorf("--protocol must be %q or %q", "latest", "0.3") } - modes := 0 if echo { modes++ @@ -71,27 +71,25 @@ func newServeCmd(cfg *globalConfig) *cobra.Command { if modes == 0 { return fmt.Errorf("specify --echo, --proxy , or --exec ") } - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", host, port)) if err != nil { return fmt.Errorf("listen: %w", err) } - transport, err := flagparse.SingleTransport([]string{serveTransport}) if err != nil { return err } + if !transportplugin.IsBuiltin(transport) { + return fmt.Errorf("serve %q transport not supported", transport) + } sc := localsrv.Config{ - Listener: listener, - Logger: cfg.logf, - + Listener: listener, + Logger: cfg.logf, ProtocolVersion: a2a.ProtocolVersion(protocol), CardCompat: cardCompat, Quiet: quiet, - CardParams: localsrv.CardParams{ AgentName: name, AgentDesc: desc, @@ -103,7 +101,6 @@ func newServeCmd(cfg *globalConfig) *cobra.Command { if sc.AdvertiseAddress == "" { sc.AdvertiseAddress = listener.Addr().String() } - switch { case echo: return localsrv.ServeEcho(ctx, sc) diff --git a/internal/cli/transport.go b/internal/cli/transport.go new file mode 100644 index 0000000..62b4273 --- /dev/null +++ b/internal/cli/transport.go @@ -0,0 +1,30 @@ +// 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 newTransportCmd(cfg *globalConfig) *cobra.Command { + cmd := &cobra.Command{ + Use: "transport", + Short: "Work with transport plugins", + } + cmd.AddCommand( + newTransportListCmd(cfg), + ) + return cmd +} diff --git a/internal/cli/transport_list.go b/internal/cli/transport_list.go new file mode 100644 index 0000000..de98f9f --- /dev/null +++ b/internal/cli/transport_list.go @@ -0,0 +1,99 @@ +// 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 ( + "fmt" + "io" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/a2aproject/a2a-cli/internal/output" + "github.com/a2aproject/a2a-cli/internal/transportplugin" +) + +// transportEntry is the JSON/text view of a discovered transport plugin. +type transportEntry struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + Protocol string `json:"protocol,omitempty"` + Description string `json:"description,omitempty"` + Path string `json:"path"` + Error string `json:"error,omitempty"` +} + +func newTransportListCmd(cfg *globalConfig) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List installed transport plugins discovered on PATH", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + entries := collectTransportEntries(cmd) + if cfg.Mode == output.ModeJson { + return cfg.PrintJSON(entries) + } + return printTransportTable(cfg.Out, entries) + }, + } + return cmd +} + +func collectTransportEntries(cmd *cobra.Command) []transportEntry { + discovered := transportplugin.List(cmd.Context()) + entries := make([]transportEntry, 0, len(discovered)) + for _, d := range discovered { + entry := transportEntry{Name: d.Name, Path: d.Path} + switch { + case d.InfoErr != nil: + entry.Error = d.InfoErr.Error() + case d.Info != nil: + entry.Version = d.Info.Version + entry.Protocol = string(d.Info.Protocol) + entry.Description = d.Info.Description + } + entries = append(entries, entry) + } + return entries +} + +func printTransportTable(out io.Writer, entries []transportEntry) error { + if len(entries) == 0 { + _, err := io.WriteString(out, "No transport plugins found on PATH.\nInstall one by placing an \"a2a-transport-\" binary on your PATH.\n") + return err + } + + tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) + if _, err := io.WriteString(tw, "NAME\tVERSION\tPROTOCOL\tDESCRIPTION\tPATH\n"); err != nil { + return err + } + for _, e := range entries { + desc := e.Description + if e.Error != "" { + desc = "(error: " + e.Error + ")" + } + if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", e.Name, dashIfEmpty(e.Version), dashIfEmpty(e.Protocol), dashIfEmpty(desc), e.Path); err != nil { + return err + } + } + return tw.Flush() +} + +func dashIfEmpty(s string) string { + if s == "" { + return "-" + } + return s +} diff --git a/internal/cli/transport_test.go b/internal/cli/transport_test.go new file mode 100644 index 0000000..1c620f9 --- /dev/null +++ b/internal/cli/transport_test.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 ( + "encoding/json" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/a2aproject/a2a-cli/internal/testutil" + "github.com/a2aproject/a2a-go/v2/a2a" +) + +func TestTransportListNoPlugins(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + out := mustRunCMD(t, "transport", "list") + if !strings.Contains(out, "No transport plugins found") { + t.Fatalf("transport list with empty PATH = %q, want a 'no plugins' message", out) + } +} + +func TestTransportPluginIntegration(t *testing.T) { + if testing.Short() { + t.Skip("skipping plugin subprocess integration test in -short mode") + } + + binDir := t.TempDir() + buildEchoPlugin(t, binDir) + t.Setenv("PATH", binDir) + + t.Run("transport list shows the plugin", func(t *testing.T) { + out := mustRunCMD(t, "transport", "list", "-o", "json") + var entries []transportEntry + if err := json.Unmarshal([]byte(out), &entries); err != nil { + t.Fatalf("json.Unmarshal(transport list) error = %v", err) + } + if len(entries) != 1 || entries[0].Name != "echo" { + t.Fatalf("transport list = %+v, want a single 'echo' entry", entries) + } + if entries[0].Version != "1.0.0" { + t.Fatalf("transport list echo.Version = %q, want %q", entries[0].Version, "1.0.0") + } + }) + + t.Run("send proxies through the plugin", func(t *testing.T) { + out := mustRunCMD(t, "send", "--transport", "echo", "--endpoint", "echo://demo", "-o", "json", "hello plugin") + var task a2a.Task + if err := json.Unmarshal([]byte(out), &task); err != nil { + t.Fatalf("json.Unmarshal(send output) error = %v", err) + } + if got := testutil.AllArtifactText(&task); got != "hello plugin" { + t.Fatalf("send via echo plugin artifact text = %q, want %q", got, "hello plugin") + } + }) + + t.Run("streaming proxies through the plugin", func(t *testing.T) { + out := mustRunCMD(t, "send", "--transport", "echo", "--endpoint", "echo://demo", "--stream", "-o", "json", "stream me") + dec := json.NewDecoder(strings.NewReader(out)) + events := 0 + for dec.More() { + var sr a2a.StreamResponse + if err := dec.Decode(&sr); err != nil { + t.Fatalf("json.Decode(event %d) error = %v", events, err) + } + events++ + } + if events <= 1 { + t.Fatalf("send --stream via echo plugin produced %d events, want > 1", events) + } + }) + + t.Run("unknown transport reports available plugins", func(t *testing.T) { + _, err := runCMD(t, "send", "--transport", "missing", "--endpoint", "x://y", "hi") + if err == nil { + t.Fatal("send --transport missing error = nil, want error") + } + if !strings.Contains(err.Error(), "a2a-transport-missing") || !strings.Contains(err.Error(), "echo") { + t.Fatalf("send --transport missing error = %v, want it to mention the expected binary and available plugins", err) + } + }) +} + +// buildEchoPlugin compiles the example echo plugin into dir as +// "a2a-transport-echo" so it is discoverable on PATH. +func buildEchoPlugin(t *testing.T, dir string) { + t.Helper() + moduleRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolving module root error = %v", err) + } + out := filepath.Join(dir, "a2a-transport-echo") + cmd := exec.Command("go", "build", "-o", out, "./examples/a2a-transport-echo") + cmd.Dir = moduleRoot + if combined, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("building echo plugin error = %v\n%s", err, combined) + } +} diff --git a/internal/flagparse/transports.go b/internal/flagparse/transports.go index c4c940b..6dac123 100644 --- a/internal/flagparse/transports.go +++ b/internal/flagparse/transports.go @@ -46,20 +46,25 @@ func SingleTransport(ss []string) (a2a.TransportProtocol, error) { return "", err } if len(protos) != 1 { - return "", fmt.Errorf("exactly one --transport is required (rest, jsonrpc, or grpc)") + return "", fmt.Errorf("exactly one --transport is required (rest, jsonrpc, grpc, or a plugin name)") } return protos[0], nil } +// parseTransport resolves a transport alias. The built-in aliases map to their +// canonical protocols; any other non-empty value is treated as a custom +// (plugin) transport binding and returned verbatim. func parseTransport(s string) (a2a.TransportProtocol, error) { switch strings.ToLower(s) { + case "": + return "", fmt.Errorf("empty --transport value") case "rest": - return a2a.TransportProtocolHTTPJSON, nil + return a2a.TransportProtocol(a2a.TransportProtocolHTTPJSON), nil case "jsonrpc": - return a2a.TransportProtocolJSONRPC, nil + return a2a.TransportProtocol(a2a.TransportProtocolJSONRPC), nil case "grpc": - return a2a.TransportProtocolGRPC, nil + return a2a.TransportProtocol(a2a.TransportProtocolGRPC), nil default: - return "", fmt.Errorf("unknown transport %q (use rest, jsonrpc, or grpc)", s) + return a2a.TransportProtocol(s), nil } } diff --git a/internal/transportplugin/discover.go b/internal/transportplugin/discover.go new file mode 100644 index 0000000..640c93c --- /dev/null +++ b/internal/transportplugin/discover.go @@ -0,0 +1,126 @@ +// 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 transportplugin + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/a2aproject/a2a-cli/devkit/clitransport" +) + +const binaryPrefix = "a2a-transport-" + +// Discovered describes a transport plugin binary found on PATH. +type Discovered struct { + // Name is the transport name (the binary suffix after the prefix). + Name string + // Path is the absolute path to the plugin binary. + Path string + // Info holds the plugin's self-reported metadata. It is nil when the plugin + // could not be queried; InfoErr then explains why. + Info *clitransport.Info + // InfoErr records a failure to query the plugin's "info" subcommand. + InfoErr error +} + +// List discovers every transport plugin on PATH and queries each one's info document. +func List(ctx context.Context) []Discovered { + out := discover() + for i := range out { + info, err := QueryInfo(ctx, out[i].Path) + if err != nil { + out[i].InfoErr = err + continue + } + out[i].Info = info + } + return out +} + +// QueryInfo runs the plugin's "info" subcommand and decodes the result. +func QueryInfo(ctx context.Context, binary string) (*clitransport.Info, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binary, clitransport.SubcommandInfo) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("running %q %s: %w", binary, clitransport.SubcommandInfo, err) + } + var info clitransport.Info + if err := json.Unmarshal(out, &info); err != nil { + return nil, fmt.Errorf("parsing info from %q: %w", binary, err) + } + return &info, nil +} + +func discover() []Discovered { + seen := map[string]string{} + var names []string + + pathDirs := filepath.SplitList(os.Getenv("PATH")) + for _, dir := range pathDirs { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, entry := range entries { + base := entry.Name() + if !strings.HasPrefix(base, binaryPrefix) { + continue + } + name := strings.TrimPrefix(base, binaryPrefix) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + fullPath := filepath.Join(dir, base) + if !isExecutable(fullPath) { + continue + } + seen[name] = fullPath + names = append(names, name) + } + } + + sort.Strings(names) + out := make([]Discovered, 0, len(names)) + for _, name := range names { + out = append(out, Discovered{Name: name, Path: seen[name]}) + } + return out +} + +func isExecutable(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + return info.Mode()&0b001001001 != 0 +} + +func binaryName(name string) string { + return binaryPrefix + strings.ToLower(name) +} diff --git a/internal/transportplugin/doc.go b/internal/transportplugin/doc.go new file mode 100644 index 0000000..d6e997f --- /dev/null +++ b/internal/transportplugin/doc.go @@ -0,0 +1,21 @@ +// 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 transportplugin discovers and drives external A2A transport plugins. +// +// A transport plugin is a binary named "a2a-transport-" on PATH. The host +// launches it as a subprocess proxy that speaks a standard A2A binding and +// forwards to a custom upstream protocol. See the devkit package +// github.com/a2aproject/a2a-cli/devkit/clitransport for authoring plugins. +package transportplugin diff --git a/internal/transportplugin/launch.go b/internal/transportplugin/launch.go new file mode 100644 index 0000000..b2c0128 --- /dev/null +++ b/internal/transportplugin/launch.go @@ -0,0 +1,141 @@ +// 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 transportplugin + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "time" + + "github.com/a2aproject/a2a-cli/devkit/clitransport" +) + +const handshakeTimeout = 15 * time.Second +const gracefulStopTimeout = 5 * time.Second + +type execLauncher struct{} + +var _ launcher = execLauncher{} + +func (execLauncher) launch(ctx context.Context, binary, endpoint string) (*session, error) { + cmd := exec.Command(binary, clitransport.SubcommandServe, "--endpoint", endpoint) + cmd.Stderr = os.Stderr + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("stdout pipe: %w", err) + } + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, fmt.Errorf("stdin pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("starting plugin: %w", err) + } + + sess := &session{ + closeFunc: func() error { + _ = stdin.Close() + + done := make(chan error, 1) + go func() { + cause := cmd.Wait() + var exitErr *exec.ExitError + if errors.As(cause, &exitErr) { + // the code is run on transport.Destroy() which is called after CLI command + // handler finished. the only thing we're interesting in reporting to client at + // this point is that a child process (plugin) wasn't terminated successfully + cause = nil + } + done <- cause + }() + + timer := time.NewTimer(gracefulStopTimeout) + defer timer.Stop() + + select { + case err := <-done: + return err + + case <-timer.C: + _ = cmd.Process.Kill() + return <-done + } + }, + } + + hs, err := readHandshake(ctx, stdout) + if err != nil { + _ = sess.close() + return nil, err + } + sess.handshake = hs + + // drain any further stdout so the plugin never blocks on a full pipe. + go func() { _, _ = io.Copy(io.Discard, stdout) }() + + return sess, nil +} + +func readHandshake(ctx context.Context, r io.Reader) (*clitransport.Endpoint, error) { + type result struct { + body *clitransport.Endpoint + err error + } + ch := make(chan result, 1) + go func() { + line, err := bufio.NewReader(r).ReadString('\n') + if err != nil && line == "" { + ch <- result{err: fmt.Errorf("reading handshake: %w", err)} + return + } + var hs clitransport.Handshake + if err := json.Unmarshal([]byte(line), &hs); err != nil { + ch <- result{err: fmt.Errorf("parsing handshake %q: %w", line, err)} + return + } + if !hs.Success { + msg := hs.Error + if msg == "" { + msg = "unknown error" + } + ch <- result{err: fmt.Errorf("plugin failed to start: %s", msg)} + return + } + if hs.Endpoint == nil || hs.Endpoint.Address == "" || hs.Endpoint.Binding == "" { + ch <- result{err: fmt.Errorf("plugin handshake missing connection details: %q", line)} + return + } + ch <- result{body: hs.Endpoint} + }() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + + case <-time.After(handshakeTimeout): + return nil, fmt.Errorf("timed out waiting for plugin handshake after %s", handshakeTimeout) + + case res := <-ch: + return res.body, res.err + } +} diff --git a/internal/transportplugin/plugin.go b/internal/transportplugin/plugin.go new file mode 100644 index 0000000..5165c42 --- /dev/null +++ b/internal/transportplugin/plugin.go @@ -0,0 +1,76 @@ +// 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 transportplugin + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" +) + +// IsBuiltin returns true if protocol is supported by the SDK natively. +func IsBuiltin(protocol a2a.TransportProtocol) bool { + switch protocol { + case a2a.TransportProtocolHTTPJSON, a2a.TransportProtocolJSONRPC, a2a.TransportProtocolGRPC: + return true + default: + return false + } +} + +// Load loads a transport plugin as client factory option or returns +// an error if plugin is unavailable. +func Load(protocol a2a.TransportProtocol) (a2aclient.FactoryOption, error) { + binary, err := findPlugin(string(protocol)) + if err != nil { + return nil, err + } + return a2aclient.WithTransport(protocol, newPluginTransportFactory(binary, nil)), nil +} + +// LoadForCard loads transport plugin for every custom transport listed in the card if +// a corresponding plugin can be found. +func LoadForCard(card *a2a.AgentCard) ([]a2aclient.FactoryOption, error) { + seen := map[a2a.TransportProtocol]bool{} + for _, iface := range card.SupportedInterfaces { + seen[iface.ProtocolBinding] = true + } + var opts []a2aclient.FactoryOption + for p := range seen { + opt, err := Load(p) + if err != nil { + continue + } + opts = append(opts, opt) + } + + return opts, nil +} + +func findPlugin(name string) (string, error) { + path, err := exec.LookPath(binaryName(name)) + if err != nil { + var available []string + for _, d := range discover() { + available = append(available, d.Name) + } + availableStr := strings.Join(available, ", ") + return "", fmt.Errorf("no transport plugin %q found on PATH (expected a %q binary); available plugins: [%s]", name, binaryName(name), availableStr) + } + return path, nil +} diff --git a/internal/transportplugin/plugin_test.go b/internal/transportplugin/plugin_test.go new file mode 100644 index 0000000..d499477 --- /dev/null +++ b/internal/transportplugin/plugin_test.go @@ -0,0 +1,117 @@ +// 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 transportplugin + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func TestDiscover(t *testing.T) { + dir := t.TempDir() + writeExecutable(t, filepath.Join(dir, "a2a-transport-foo"), "#!/bin/sh\n") + writeExecutable(t, filepath.Join(dir, "a2a-transport-bar"), "#!/bin/sh\n") + t.Setenv("PATH", dir) + + t.Run("finds installed plugin", func(t *testing.T) { + got, err := findPlugin("foo") + if err != nil { + t.Fatalf("Discover(foo) error = %v", err) + } + if want := filepath.Join(dir, "a2a-transport-foo"); got != want { + t.Fatalf("Discover(foo) = %q, want %q", got, want) + } + }) + + t.Run("normalizes case to binary suffix", func(t *testing.T) { + got, err := findPlugin("FOO") + if err != nil { + t.Fatalf("Discover(FOO) error = %v", err) + } + if want := filepath.Join(dir, "a2a-transport-foo"); got != want { + t.Fatalf("Discover(FOO) = %q, want %q", got, want) + } + }) + + t.Run("missing plugin lists available", func(t *testing.T) { + _, err := findPlugin("missing") + if err == nil { + t.Fatal("Discover(missing) error = nil, want error") + } + for _, want := range []string{"a2a-transport-missing", "bar", "foo"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("Discover(missing) error = %v, want it to contain %q", err, want) + } + } + }) +} + +func TestListQueriesInfo(t *testing.T) { + dir := t.TempDir() + writeExecutable(t, filepath.Join(dir, "a2a-transport-good"), infoScript(`{"name":"good","version":"1.2.3","protocol":"1.0","description":"a good plugin"}`)) + writeExecutable(t, filepath.Join(dir, "a2a-transport-broken"), "#!/bin/sh\nexit 3\n") + t.Setenv("PATH", dir) + + got := List(t.Context()) + if len(got) != 2 { + t.Fatalf("List() returned %d plugins, want 2", len(got)) + } + + byName := map[string]Discovered{} + for _, d := range got { + byName[d.Name] = d + } + + good, ok := byName["good"] + if !ok { + t.Fatalf("List() missing 'good' plugin, got %+v", got) + } + if good.InfoErr != nil { + t.Fatalf("List() good.InfoErr = %v, want nil", good.InfoErr) + } + wantInfo := struct { + Name, Version, Protocol, Description string + }{"good", "1.2.3", "1.0", "a good plugin"} + gotInfo := struct { + Name, Version, Protocol, Description string + }{good.Info.Name, good.Info.Version, string(good.Info.Protocol), good.Info.Description} + if diff := cmp.Diff(wantInfo, gotInfo, cmpopts.EquateEmpty()); diff != "" { + t.Fatalf("List() good info wrong result (-want +got) diff = %s", diff) + } + + broken, ok := byName["broken"] + if !ok { + t.Fatalf("List() missing 'broken' plugin, got %+v", got) + } + if broken.InfoErr == nil { + t.Fatal("List() broken.InfoErr = nil, want an error") + } +} + +func writeExecutable(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", path, err) + } +} + +func infoScript(json string) string { + return "#!/bin/sh\nif [ \"$1\" = \"info\" ]; then\n printf '%s\\n' '" + json + "'\nfi\n" +} diff --git a/internal/transportplugin/transport.go b/internal/transportplugin/transport.go new file mode 100644 index 0000000..268d3d7 --- /dev/null +++ b/internal/transportplugin/transport.go @@ -0,0 +1,193 @@ +// 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 transportplugin + +import ( + "context" + "fmt" + "iter" + "maps" + "net/http" + "net/url" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + + "github.com/a2aproject/a2a-cli/devkit/clitransport" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + a2agrpc "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" +) + +type session struct { + handshake *clitransport.Endpoint + closeFunc func() error +} + +func (s *session) close() error { + if s.closeFunc != nil { + return s.closeFunc() + } + return nil +} + +type launcher interface { + launch(ctx context.Context, binary, endpoint string) (*session, error) +} + +func newPluginTransportFactory(binary string, launcher launcher) a2aclient.TransportFactory { + if launcher == nil { + launcher = execLauncher{} + } + return a2aclient.TransportFactoryFn(func(ctx context.Context, _ *a2a.AgentCard, iface *a2a.AgentInterface) (a2aclient.Transport, error) { + session, err := launcher.launch(ctx, binary, iface.URL) + if err != nil { + return nil, fmt.Errorf("launching transport plugin %q: %w", binary, err) + } + base, err := buildBaseTransport(session.handshake) + if err != nil { + _ = session.close() + return nil, err + } + return &proxyTransport{base: base, session: session}, nil + }) +} + +func buildBaseTransport(hs *clitransport.Endpoint) (a2aclient.Transport, error) { + switch hs.Binding { + case a2a.TransportProtocolJSONRPC: + client, err := httpClient(hs.CertPEM) + if err != nil { + return nil, err + } + return a2aclient.NewJSONRPCTransport(hs.Address, client), nil + + case a2a.TransportProtocolHTTPJSON: + u, err := url.Parse(hs.Address) + if err != nil { + return nil, fmt.Errorf("failed to parse endpoint URL: %w", err) + } + client, err := httpClient(hs.CertPEM) + if err != nil { + return nil, err + } + return a2aclient.NewRESTTransport(u, client), nil + + case a2a.TransportProtocolGRPC: + creds, err := grpcCreds(hs.CertPEM) + if err != nil { + return nil, err + } + conn, err := grpc.NewClient(hs.Address, grpc.WithTransportCredentials(creds)) + if err != nil { + return nil, fmt.Errorf("dialing plugin gRPC server %q: %w", hs.Address, err) + } + return a2agrpc.NewGRPCTransport(conn), nil + + default: + return nil, fmt.Errorf("plugin advertised unsupported binding %q", hs.Binding) + } +} + +// httpClient returns an HTTP client that trusts only the plugin's per-launch certificate. +func httpClient(certPEM string) (*http.Client, error) { + if certPEM == "" { + return nil, nil + } + tlsConfig, err := clitransport.ClientTLSConfig([]byte(certPEM)) + if err != nil { + return nil, err + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = tlsConfig + return &http.Client{Transport: transport}, nil +} + +// grpcCreds returns transport credentials trusting only the plugin's per-launch certificate. +func grpcCreds(certPEM string) (credentials.TransportCredentials, error) { + if certPEM == "" { + return insecure.NewCredentials(), nil + } + tlsConfig, err := clitransport.ClientTLSConfig([]byte(certPEM)) + if err != nil { + return nil, err + } + return credentials.NewTLS(tlsConfig), nil +} + +type proxyTransport struct { + base a2aclient.Transport + session *session +} + +func (pt *proxyTransport) GetTask(ctx context.Context, sp a2aclient.ServiceParams, req *a2a.GetTaskRequest) (*a2a.Task, error) { + return pt.base.GetTask(ctx, pt.withToken(sp), req) +} + +func (pt *proxyTransport) ListTasks(ctx context.Context, sp a2aclient.ServiceParams, req *a2a.ListTasksRequest) (*a2a.ListTasksResponse, error) { + return pt.base.ListTasks(ctx, pt.withToken(sp), req) +} + +func (pt *proxyTransport) CancelTask(ctx context.Context, sp a2aclient.ServiceParams, req *a2a.CancelTaskRequest) (*a2a.Task, error) { + return pt.base.CancelTask(ctx, pt.withToken(sp), req) +} + +func (pt *proxyTransport) SendMessage(ctx context.Context, sp a2aclient.ServiceParams, req *a2a.SendMessageRequest) (a2a.SendMessageResult, error) { + return pt.base.SendMessage(ctx, pt.withToken(sp), req) +} + +func (pt *proxyTransport) SubscribeToTask(ctx context.Context, sp a2aclient.ServiceParams, req *a2a.SubscribeToTaskRequest) iter.Seq2[a2a.Event, error] { + return pt.base.SubscribeToTask(ctx, pt.withToken(sp), req) +} + +func (pt *proxyTransport) SendStreamingMessage(ctx context.Context, sp a2aclient.ServiceParams, req *a2a.SendMessageRequest) iter.Seq2[a2a.Event, error] { + return pt.base.SendStreamingMessage(ctx, pt.withToken(sp), req) +} + +func (pt *proxyTransport) GetTaskPushConfig(ctx context.Context, sp a2aclient.ServiceParams, req *a2a.GetTaskPushConfigRequest) (*a2a.PushConfig, error) { + return pt.base.GetTaskPushConfig(ctx, pt.withToken(sp), req) +} + +func (pt *proxyTransport) ListTaskPushConfigs(ctx context.Context, sp a2aclient.ServiceParams, req *a2a.ListTaskPushConfigRequest) ([]*a2a.PushConfig, error) { + return pt.base.ListTaskPushConfigs(ctx, pt.withToken(sp), req) +} + +func (pt *proxyTransport) CreateTaskPushConfig(ctx context.Context, sp a2aclient.ServiceParams, req *a2a.PushConfig) (*a2a.PushConfig, error) { + return pt.base.CreateTaskPushConfig(ctx, pt.withToken(sp), req) +} + +func (pt *proxyTransport) DeleteTaskPushConfig(ctx context.Context, sp a2aclient.ServiceParams, req *a2a.DeleteTaskPushConfigRequest) error { + return pt.base.DeleteTaskPushConfig(ctx, pt.withToken(sp), req) +} + +func (pt *proxyTransport) GetExtendedAgentCard(ctx context.Context, sp a2aclient.ServiceParams, req *a2a.GetExtendedAgentCardRequest) (*a2a.AgentCard, error) { + return pt.base.GetExtendedAgentCard(ctx, pt.withToken(sp), req) +} + +func (pt *proxyTransport) Destroy() error { + err := pt.base.Destroy() + if cerr := pt.session.close(); cerr != nil && err == nil { + err = cerr + } + return err +} + +func (pt *proxyTransport) withToken(sp a2aclient.ServiceParams) a2aclient.ServiceParams { + params := make(a2aclient.ServiceParams, len(sp)+1) + maps.Copy(params, sp) + params.Append(clitransport.TokenSvcParam, pt.session.handshake.Token) + return params +} diff --git a/internal/transportplugin/transport_test.go b/internal/transportplugin/transport_test.go new file mode 100644 index 0000000..836107e --- /dev/null +++ b/internal/transportplugin/transport_test.go @@ -0,0 +1,226 @@ +// 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 transportplugin + +import ( + "bufio" + "context" + "encoding/json" + "io" + "strings" + "sync" + "testing" + + "github.com/a2aproject/a2a-cli/devkit/clitransport" + "github.com/a2aproject/a2a-cli/internal/testutil" + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" +) + +func TestFactoryProxiesThroughPlugin(t *testing.T) { + t.Parallel() + + for _, binding := range []a2a.TransportProtocol{a2a.TransportProtocolJSONRPC, a2a.TransportProtocolHTTPJSON, a2a.TransportProtocolGRPC} { + t.Run(string(binding), func(t *testing.T) { + t.Parallel() + + upstream := &echoUpstream{} + hs, stopServer := startDevkitServer(t, binding, upstream) + + isClosed := false + session := &session{handshake: hs, closeFunc: func() error { + isClosed = true + stopServer() + return nil + }} + factory := newPluginTransportFactory("a2a-transport-fake", fakeLauncher{session: session}) + + transport, err := factory.Create(t.Context(), nil, a2a.NewAgentInterface("fake://upstream", binding)) + if err != nil { + t.Fatalf("factory.Create() error = %v", err) + } + + params := a2aclient.ServiceParams{"authorization": {"Bearer secret"}} + msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("ping")) + result, err := transport.SendMessage(t.Context(), params, &a2a.SendMessageRequest{Message: msg}) + if err != nil { + t.Fatalf("transport.SendMessage() error = %v", err) + } + task, ok := result.(*a2a.Task) + if !ok { + t.Fatalf("transport.SendMessage() result type = %T, want *a2a.Task", result) + } + if got := testutil.AllArtifactText(task); got != "ping" { + t.Fatalf("transport.SendMessage() echoed = %q, want %q", got, "ping") + } + if got := upstream.lastAuth(); got != "Bearer secret" { + t.Fatalf("upstream authorization = %q, want %q", got, "Bearer secret") + } + if got := upstream.tokenSeen(); got { + t.Fatal("upstream saw the loopback plugin token; it must not be forwarded upstream") + } + + if err := transport.Destroy(); err != nil { + t.Fatalf("transport.Destroy() error = %v", err) + } + if !isClosed { + t.Fatal("transport.Destroy() did not close the plugin session") + } + }) + } +} + +func TestBaseTransportCredentialSelection(t *testing.T) { + t.Parallel() + + t.Run("http uses default client without cert", func(t *testing.T) { + t.Parallel() + client, err := httpClient("") + if err != nil { + t.Fatalf("httpClient(empty) error = %v", err) + } + if client != nil { + t.Fatalf("httpClient(empty) = %v, want nil (built-in default client)", client) + } + }) + + t.Run("http rejects invalid cert", func(t *testing.T) { + t.Parallel() + if _, err := httpClient("not-a-pem-cert"); err == nil { + t.Fatal("httpClient(invalid) error = nil, want error") + } + }) + + t.Run("grpc uses insecure creds without cert", func(t *testing.T) { + t.Parallel() + creds, err := grpcCreds("") + if err != nil { + t.Fatalf("grpcCreds(empty) error = %v", err) + } + if got := creds.Info().SecurityProtocol; got != "insecure" { + t.Fatalf("grpcCreds(empty) security = %q, want %q", got, "insecure") + } + }) + + t.Run("grpc rejects invalid cert", func(t *testing.T) { + t.Parallel() + if _, err := grpcCreds("not-a-pem-cert"); err == nil { + t.Fatal("grpcCreds(invalid) error = nil, want error") + } + }) +} + +// startDevkitServer runs a real devkit plugin proxy in-process for the given +// binding and returns its handshake payload plus a stop function. +func startDevkitServer(t *testing.T, binding a2a.TransportProtocol, upstream a2aclient.Transport) (*clitransport.Endpoint, func()) { + t.Helper() + + stdoutR, stdoutW := io.Pipe() + _, stdinW := io.Pipe() + ctx, cancel := context.WithCancel(context.Background()) + + cfg := clitransport.Config{ + Name: "fake", + Version: "1.0.0", + NewTransport: func(context.Context, string) (a2aclient.Transport, error) { + return upstream, nil + }, + } + + done := make(chan struct{}) + go func() { + defer close(done) + ios := &clitransport.IO{Out: stdoutW} + _ = clitransport.Run(ctx, cfg, []string{clitransport.SubcommandServe, "--endpoint", "fake://upstream", "--bind", string(binding)}, ios) + }() + + line, err := bufio.NewReader(stdoutR).ReadString('\n') + if err != nil { + cancel() + t.Fatalf("reading handshake error = %v", err) + } + var hs clitransport.Handshake + if err := json.Unmarshal([]byte(line), &hs); err != nil { + cancel() + t.Fatalf("json.Unmarshal(handshake) error = %v", err) + } + if !hs.Success || hs.Endpoint == nil { + cancel() + t.Fatalf("plugin handshake unsuccessful: %+v", hs) + } + + stop := func() { + cancel() + _ = stdinW.Close() + _ = stdoutR.Close() + <-done + } + return hs.Endpoint, stop +} + +type fakeLauncher struct { + session *session +} + +func (l fakeLauncher) launch(context.Context, string, string) (*session, error) { + return l.session, nil +} + +// echoUpstream is a fake custom transport that echoes messages and records the +// service params it observed. +type echoUpstream struct { + a2aclient.Transport + mu sync.Mutex + auth string + token bool +} + +func (u *echoUpstream) lastAuth() string { + u.mu.Lock() + defer u.mu.Unlock() + return u.auth +} + +func (u *echoUpstream) tokenSeen() bool { + u.mu.Lock() + defer u.mu.Unlock() + return u.token +} + +func (u *echoUpstream) SendMessage(_ context.Context, params a2aclient.ServiceParams, req *a2a.SendMessageRequest) (a2a.SendMessageResult, error) { + u.mu.Lock() + if auth := params.Get("authorization"); len(auth) > 0 { + u.auth = auth[0] + } + if tok := params.Get(clitransport.TokenSvcParam); len(tok) > 0 { + u.token = true + } + u.mu.Unlock() + + var text strings.Builder + if req.Message != nil { + for _, p := range req.Message.Parts { + text.WriteString(p.Text()) + } + } + return &a2a.Task{ + ID: a2a.NewTaskID(), + ContextID: a2a.NewContextID(), + Status: a2a.TaskStatus{State: a2a.TaskStateCompleted}, + Artifacts: []*a2a.Artifact{{ID: a2a.NewArtifactID(), Parts: a2a.ContentParts{a2a.NewTextPart(text.String())}}}, + }, nil +} + +func (u *echoUpstream) Destroy() error { return nil }