diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index cee4466..ddf17c3 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -642,6 +642,64 @@ func TestConfigApplied(t *testing.T) { } } +func TestInsecureCredentialWarning(t *testing.T) { + t.Parallel() + url := startTestServer(t) + + tests := []struct { + name string + args []string + wantWarn bool + }{ + { + name: "insecure with --auth warns", + args: []string{"send", "-a", url, "--insecure", "--auth", "Bearer secret-token", "hi"}, + wantWarn: true, + }, + { + name: "insecure with an authorization --svc-param warns", + args: []string{"send", "-a", url, "--insecure", "--svc-param", "Authorization=Bearer secret-token", "hi"}, + wantWarn: true, + }, + { + name: "json output mode does not suppress the warning", + args: []string{"send", "-a", url, "--insecure", "--auth", "Bearer secret-token", "-o", "json", "hi"}, + wantWarn: true, + }, + { + name: "insecure with a non-credential svc-param does not warn", + args: []string{"send", "-a", url, "--insecure", "--svc-param", "X-Trace=abc", "hi"}, + wantWarn: false, + }, + { + name: "credential without insecure does not warn", + args: []string{"send", "-a", url, "--auth", "Bearer secret-token", "hi"}, + wantWarn: false, + }, + { + name: "insecure without a credential does not warn", + args: []string{"send", "-a", url, "--insecure", "hi"}, + wantWarn: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, stderr, err := runCMDCapturingStderr(t, tt.args...) + if err != nil { + t.Fatalf("runCMDCapturingStderr(%q) error = %v", strings.Join(tt.args, " "), err) + } + if gotWarn := strings.Contains(stderr, insecureCredentialWarning); gotWarn != tt.wantWarn { + t.Fatalf("stderr contains insecure-credential warning = %v, want %v; stderr = %q", gotWarn, tt.wantWarn, stderr) + } + if strings.Contains(stderr, "secret-token") { + t.Fatalf("stderr leaked the credential value: %q", stderr) + } + }) + } +} + func startTestServer(t *testing.T) string { t.Helper() return startTestServerWith(t, a2a.AgentCapabilities{Streaming: true}) @@ -751,6 +809,20 @@ func runCMDWithConfig(t *testing.T, deps deps, args ...string) (string, error) { return buf.String(), err } +func runCMDCapturingStderr(t *testing.T, args ...string) (stdout, stderr string, err error) { + t.Helper() + var out, errBuf bytes.Buffer + cfg := &globalConfig{ + Printer: output.NewPrinter(&out, output.ModeText), + svcParams: &flagparse.ServiceParams{}, + errOut: &errBuf, + } + root := newRootCmd(cfg, deps{poller: polling.Stream, cfgLoader: clicfg.LoadEmpty}) + root.SetArgs(args) + err = root.Execute() + return out.String(), errBuf.String(), err +} + type legacyExecutor struct{} func (e *legacyExecutor) Execute(ctx context.Context, reqCtx *a2asrvv0.RequestContext, queue eventqueue.Queue) error { diff --git a/internal/cli/client.go b/internal/cli/client.go index 3bc4736..55b0955 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -41,7 +41,17 @@ var compatCardResolver = func() *agentcard.Resolver { return resolver }() +const insecureCredentialWarning = "warning: sending a credential with TLS verification disabled (--insecure); the token may be exposed." + +// warnInsecureCredential warns once when a credential is sent with --insecure. +func warnInsecureCredential(cfg *globalConfig) { + if cfg.insecureGRPC && cfg.svcParams.HasCredential() { + _, _ = fmt.Fprintln(cfg.stderr(), insecureCredentialWarning) + } +} + func newAgentClient(ctx context.Context, cfg *globalConfig, extraOpts ...a2aclient.FactoryOption) (*a2aclient.Client, error) { + warnInsecureCredential(cfg) switch { case cfg.url != "" && cfg.agentCard.IsSet(): return nil, fmt.Errorf("--endpoint and --agent-card are mutually exclusive") diff --git a/internal/cli/root.go b/internal/cli/root.go index f990d7c..4faa648 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -17,6 +17,7 @@ package cli import ( "fmt" + "io" "os" "time" @@ -57,13 +58,21 @@ type globalConfig struct { configPath string bindings []clicfg.FlagBinding + errOut io.Writer *output.Printer } +func (g *globalConfig) stderr() io.Writer { + if g.errOut != nil { + return g.errOut + } + return os.Stderr +} + func (g *globalConfig) logf(format string, args ...any) { if g.verbose { - _, _ = fmt.Fprintf(os.Stderr, "# "+format+"\n", args...) + _, _ = fmt.Fprintf(g.stderr(), "# "+format+"\n", args...) } } diff --git a/internal/flagparse/svcparams.go b/internal/flagparse/svcparams.go index 1426445..6618286 100644 --- a/internal/flagparse/svcparams.go +++ b/internal/flagparse/svcparams.go @@ -67,6 +67,19 @@ func (s *ServiceParams) Auth() string { return s.auth } +// HasCredential reports whether --auth or an Authorization --svc-param is set. +func (s *ServiceParams) HasCredential() bool { + if s.auth != "" { + return true + } + for _, e := range s.entries { + if strings.EqualFold(e.key, "Authorization") { + return true + } + } + return false +} + type svcParamValue struct{ s *ServiceParams } func (v *svcParamValue) Set(kv string) error { diff --git a/internal/output/output.go b/internal/output/output.go index 14bf4b7..344c218 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -300,14 +300,8 @@ func partsText(parts a2a.ContentParts) string { sb.WriteString(t) continue } - if u := p.URL(); u != "" { - sb.WriteString("[file: ") - sb.WriteString(string(u)) - sb.WriteString("]") - continue - } - if p.Raw() != nil { - fmt.Fprintf(&sb, "[binary %d bytes]", len(p.Raw())) + if p.URL() != "" || p.Raw() != nil { + sb.WriteString(filePartText(p)) continue } if p.Data() != nil { @@ -323,6 +317,43 @@ func partsText(parts a2a.ContentParts) string { return sb.String() } +// filePartText renders a file part by name, media type and size or URL. +func filePartText(p *a2a.Part) string { + var sb strings.Builder + sb.WriteString("file:") + if p.Filename != "" { + sb.WriteString(" ") + sb.WriteString(p.Filename) + } + var attrs []string + if p.MediaType != "" { + attrs = append(attrs, p.MediaType) + } + if raw := p.Raw(); raw != nil { + attrs = append(attrs, formatSize(len(raw))) + } + if u := p.URL(); u != "" { + attrs = append(attrs, string(u)) + } + if len(attrs) > 0 { + fmt.Fprintf(&sb, " (%s)", strings.Join(attrs, ", ")) + } + return sb.String() +} + +func formatSize(n int) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for size := int64(n) / unit; size >= unit; size /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} + func shortState(state a2a.TaskState) string { if name, ok := taskStateNames[state]; ok { return name diff --git a/internal/output/output_test.go b/internal/output/output_test.go new file mode 100644 index 0000000..396bc34 --- /dev/null +++ b/internal/output/output_test.go @@ -0,0 +1,69 @@ +// 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 output + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/a2aproject/a2a-go/v2/a2a" +) + +func TestMessageTextFileParts(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + part *a2a.Part + want string + }{ + { + name: "named and typed bytes", + part: &a2a.Part{Content: a2a.Raw([]byte("sixteen bytes!!!")), Filename: "report.txt", MediaType: "text/plain"}, + want: "file: report.txt (text/plain, 16 B)", + }, + { + name: "unnamed bytes still show size", + part: &a2a.Part{Content: a2a.Raw([]byte("sixteen bytes!!!"))}, + want: "file: (16 B)", + }, + { + name: "typed bytes without a name still show type and size", + part: &a2a.Part{Content: a2a.Raw([]byte("sixteen bytes!!!")), MediaType: "application/octet-stream"}, + want: "file: (application/octet-stream, 16 B)", + }, + { + name: "url with name and type", + part: &a2a.Part{Content: a2a.URL("https://example.com/report.pdf"), Filename: "report.pdf", MediaType: "application/pdf"}, + want: "file: report.pdf (application/pdf, https://example.com/report.pdf)", + }, + { + name: "url without name or type", + part: &a2a.Part{Content: a2a.URL("https://example.com/blob")}, + want: "file: (https://example.com/blob)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := MessageText(&a2a.Message{Parts: a2a.ContentParts{tt.part}}) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Fatalf("MessageText() wrong result (-want +got) diff = %s", diff) + } + }) + } +}