@pi-in-go/pigpen-a2a 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CREDITS.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +97 -0
  4. package/extensions/a2a/auth.go +120 -0
  5. package/extensions/a2a/auth_test.go +133 -0
  6. package/extensions/a2a/bench_test.go +70 -0
  7. package/extensions/a2a/binary_test.go +225 -0
  8. package/extensions/a2a/cancelqueued_test.go +142 -0
  9. package/extensions/a2a/card_cache_test.go +42 -0
  10. package/extensions/a2a/client.go +499 -0
  11. package/extensions/a2a/client_test.go +402 -0
  12. package/extensions/a2a/config.go +282 -0
  13. package/extensions/a2a/config_test.go +194 -0
  14. package/extensions/a2a/e2e_test.go +311 -0
  15. package/extensions/a2a/executor.go +318 -0
  16. package/extensions/a2a/extension.go +382 -0
  17. package/extensions/a2a/extension_test.go +396 -0
  18. package/extensions/a2a/fakehost_test.go +548 -0
  19. package/extensions/a2a/fakepig_test.go +212 -0
  20. package/extensions/a2a/gaps_test.go +59 -0
  21. package/extensions/a2a/go.mod +15 -0
  22. package/extensions/a2a/go.sum +14 -0
  23. package/extensions/a2a/interop_test.go +163 -0
  24. package/extensions/a2a/procattr_other.go +25 -0
  25. package/extensions/a2a/procattr_windows.go +26 -0
  26. package/extensions/a2a/resubscribe_test.go +113 -0
  27. package/extensions/a2a/review_test.go +293 -0
  28. package/extensions/a2a/server.go +267 -0
  29. package/extensions/a2a/server_test.go +792 -0
  30. package/extensions/a2a/survivors_test.go +302 -0
  31. package/extensions/a2a/worker.go +388 -0
  32. package/extensions/a2a/worker_test.go +257 -0
  33. package/package.json +41 -0
  34. package/port/PORT.md +126 -0
  35. package/port/a2a-go-LICENSE +201 -0
  36. package/port/golden/flag-without-auth-refused.jsonl +5 -0
  37. package/port/golden/listener-off.jsonl +4 -0
  38. package/port/golden/send-missing-message.jsonl +18 -0
  39. package/port/golden/send-without-remotes.jsonl +18 -0
  40. package/port/golden/task-unknown-action.jsonl +18 -0
  41. package/port/golden/tools-visible-to-model.jsonl +11 -0
  42. package/port/interop/kagent/main.go +73 -0
  43. package/port/mutation-run.txt +222 -0
  44. package/port/mutations.json +656 -0
  45. package/port/red.txt +105 -0
  46. package/port/scenarios/flag-without-auth-refused.json +8 -0
  47. package/port/scenarios/listener-off.json +7 -0
  48. package/port/scenarios/send-missing-message.json +11 -0
  49. package/port/scenarios/send-without-remotes.json +11 -0
  50. package/port/scenarios/task-unknown-action.json +11 -0
  51. package/port/scenarios/tools-visible-to-model.json +8 -0
  52. package/provenance.json +18 -0
@@ -0,0 +1,402 @@
1
+ package a2aext
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "iter"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "strings"
10
+ "sync"
11
+ "sync/atomic"
12
+ "testing"
13
+ "time"
14
+
15
+ "github.com/a2aproject/a2a-go/v2/a2a"
16
+ "github.com/a2aproject/a2a-go/v2/a2asrv"
17
+ )
18
+
19
+ // fakeRemote is an A2A 1.0 agent built directly on the upstream server SDK (not on this
20
+ // package's Server), so the client is tested against an independent implementation.
21
+ type fakeRemote struct {
22
+ srv *httptest.Server
23
+ mu sync.Mutex
24
+ headers []http.Header
25
+ cancels atomic.Int32
26
+ execute func(ctx context.Context, ec *a2asrv.ExecutorContext, yield func(a2a.Event, error) bool)
27
+ cardPath bool
28
+ interface_ []*a2a.AgentInterface
29
+ requireAuth string
30
+ }
31
+
32
+ // remoteUser makes every caller the same authenticated user: the upstream in-memory task store
33
+ // (and so ListTasks) needs one.
34
+ type remoteUser struct {
35
+ a2asrv.PassthroughCallInterceptor
36
+ }
37
+
38
+ func (remoteUser) Before(ctx context.Context, cc *a2asrv.CallContext, _ *a2asrv.Request) (context.Context, any, error) {
39
+ cc.User = a2asrv.NewAuthenticatedUser("fake-user", nil)
40
+ return ctx, nil, nil
41
+ }
42
+
43
+ type remoteExec struct{ r *fakeRemote }
44
+
45
+ func (e remoteExec) Execute(ctx context.Context, ec *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] {
46
+ return func(yield func(a2a.Event, error) bool) { e.r.execute(ctx, ec, yield) }
47
+ }
48
+
49
+ func (e remoteExec) Cancel(ctx context.Context, ec *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] {
50
+ e.r.cancels.Add(1)
51
+ return func(yield func(a2a.Event, error) bool) {
52
+ yield(a2a.NewStatusUpdateEvent(ec, a2a.TaskStateCanceled, nil), nil)
53
+ }
54
+ }
55
+
56
+ func completeWith(text string) func(context.Context, *a2asrv.ExecutorContext, func(a2a.Event, error) bool) {
57
+ return func(ctx context.Context, ec *a2asrv.ExecutorContext, yield func(a2a.Event, error) bool) {
58
+ if ec.StoredTask == nil && !yield(a2a.NewSubmittedTask(ec, ec.Message), nil) {
59
+ return
60
+ }
61
+ if !yield(a2a.NewStatusUpdateEvent(ec, a2a.TaskStateWorking, nil), nil) {
62
+ return
63
+ }
64
+ if !yield(a2a.NewArtifactEvent(ec, a2a.NewTextPart(text)), nil) {
65
+ return
66
+ }
67
+ yield(a2a.NewStatusUpdateEvent(ec, a2a.TaskStateCompleted, nil), nil)
68
+ }
69
+ }
70
+
71
+ func newFakeRemote(t *testing.T) *fakeRemote {
72
+ t.Helper()
73
+ r := &fakeRemote{execute: completeWith("remote says hi")}
74
+ handler := a2asrv.NewHandler(remoteExec{r}, a2asrv.WithCallInterceptors(remoteUser{}))
75
+ mux := http.NewServeMux()
76
+ mux.Handle("/rpc", a2asrv.NewJSONRPCHandler(handler))
77
+ mux.HandleFunc(a2asrv.WellKnownAgentCardPath, func(w http.ResponseWriter, req *http.Request) {
78
+ card := &a2a.AgentCard{
79
+ Name: "fake-remote", Description: "a fake remote agent", Version: "9",
80
+ SupportedInterfaces: r.interface_, DefaultInputModes: []string{"text/plain"}, DefaultOutputModes: []string{"text/plain"},
81
+ Skills: []a2a.AgentSkill{{ID: "chat", Name: "Chat", Description: "chats", Tags: []string{"chat"}}},
82
+ Capabilities: a2a.AgentCapabilities{Streaming: true},
83
+ }
84
+ a2asrv.NewStaticAgentCardHandler(card).ServeHTTP(w, req)
85
+ })
86
+ r.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
87
+ r.mu.Lock()
88
+ r.headers = append(r.headers, req.Header.Clone())
89
+ r.mu.Unlock()
90
+ if r.requireAuth != "" && req.URL.Path == "/rpc" && req.Header.Get("Authorization") != "Bearer "+r.requireAuth {
91
+ w.Header().Set("WWW-Authenticate", "Bearer")
92
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
93
+ return
94
+ }
95
+ mux.ServeHTTP(w, req)
96
+ }))
97
+ r.interface_ = []*a2a.AgentInterface{a2a.NewAgentInterface(r.srv.URL+"/rpc", a2a.TransportProtocolJSONRPC)}
98
+ t.Cleanup(r.srv.Close)
99
+ return r
100
+ }
101
+
102
+ func (r *fakeRemote) lastRPCHeader() http.Header {
103
+ r.mu.Lock()
104
+ defer r.mu.Unlock()
105
+ for i := len(r.headers) - 1; i >= 0; i-- {
106
+ if r.headers[i].Get("Content-Type") != "" && strings.Contains(r.headers[i].Get("Content-Type"), "json") && r.headers[i].Get("A2A-Version") != "" {
107
+ return r.headers[i]
108
+ }
109
+ }
110
+ return nil
111
+ }
112
+
113
+ func remotes(t *testing.T, r *fakeRemote, extra func(*RemoteAgent), env map[string]string) *Remotes {
114
+ t.Helper()
115
+ ra := RemoteAgent{URL: r.srv.URL}
116
+ if extra != nil {
117
+ extra(&ra)
118
+ }
119
+ return NewRemotes(map[string]RemoteAgent{"peer": ra}, envFrom(env))
120
+ }
121
+
122
+ func TestRemotesNamesAreSorted(t *testing.T) {
123
+ rs := NewRemotes(map[string]RemoteAgent{"b": {URL: "http://b"}, "a": {URL: "http://a"}}, envFrom(nil))
124
+ if got := strings.Join(rs.Names(), ","); got != "a,b" {
125
+ t.Fatalf("names %q", got)
126
+ }
127
+ }
128
+
129
+ func TestCardResolvesAndSummarises(t *testing.T) {
130
+ r := newFakeRemote(t)
131
+ card, err := remotes(t, r, nil, nil).Card(context.Background(), "peer")
132
+ if err != nil {
133
+ t.Fatal(err)
134
+ }
135
+ if card.Name != "fake-remote" || len(card.Skills) != 1 {
136
+ t.Fatalf("%+v", card)
137
+ }
138
+ }
139
+
140
+ func TestSendReturnsCompletedTaskText(t *testing.T) {
141
+ r := newFakeRemote(t)
142
+ var progress []string
143
+ sum, err := remotes(t, r, nil, nil).Send(context.Background(), SendArgs{Agent: "peer", Message: "hello"}, func(s string) { progress = append(progress, s) })
144
+ if err != nil {
145
+ t.Fatal(err)
146
+ }
147
+ if sum.State != "completed" || !sum.Terminal || sum.Text != "remote says hi" || sum.TaskID == "" || sum.ContextID == "" {
148
+ t.Fatalf("%+v", sum)
149
+ }
150
+ if strings.Join(progress, "") != "remote says hi" {
151
+ t.Fatalf("progress %q", progress)
152
+ }
153
+ }
154
+
155
+ func TestSendReusesContextAndSendsProtocolVersion(t *testing.T) {
156
+ r := newFakeRemote(t)
157
+ var seen atomic.Value
158
+ r.execute = func(ctx context.Context, ec *a2asrv.ExecutorContext, yield func(a2a.Event, error) bool) {
159
+ seen.Store(ec.ContextID)
160
+ completeWith("ok")(ctx, ec, yield)
161
+ }
162
+ rs := remotes(t, r, nil, nil)
163
+ if _, err := rs.Send(context.Background(), SendArgs{Agent: "peer", Message: "hello", ContextID: "ctx-77"}, nil); err != nil {
164
+ t.Fatal(err)
165
+ }
166
+ if seen.Load() != "ctx-77" {
167
+ t.Fatalf("remote saw context %v", seen.Load())
168
+ }
169
+ if v := r.lastRPCHeader().Get("A2A-Version"); v != "1.0" {
170
+ t.Fatalf("A2A-Version %q: the protocol version is pinned to 1.0", v)
171
+ }
172
+ }
173
+
174
+ func TestBearerAndCustomHeadersComeFromEnvironment(t *testing.T) {
175
+ r := newFakeRemote(t)
176
+ r.requireAuth = "remote-secret-value"
177
+ rs := remotes(t, r, func(ra *RemoteAgent) {
178
+ ra.BearerTokenEnv = "REMOTE_TOKEN"
179
+ ra.HeaderEnv = map[string]string{"X-Api-Key": "REMOTE_KEY"}
180
+ }, map[string]string{"REMOTE_TOKEN": "remote-secret-value", "REMOTE_KEY": "key-123"})
181
+ if _, err := rs.Send(context.Background(), SendArgs{Agent: "peer", Message: "hello"}, nil); err != nil {
182
+ t.Fatal(err)
183
+ }
184
+ if got := r.lastRPCHeader().Get("X-Api-Key"); got != "key-123" {
185
+ t.Fatalf("custom header %q", got)
186
+ }
187
+ }
188
+
189
+ func TestMissingTokenEnvironmentIsAnErrorBeforeAnyRequest(t *testing.T) {
190
+ r := newFakeRemote(t)
191
+ rs := remotes(t, r, func(ra *RemoteAgent) { ra.BearerTokenEnv = "REMOTE_TOKEN" }, nil)
192
+ _, err := rs.Send(context.Background(), SendArgs{Agent: "peer", Message: "hello"}, nil)
193
+ if err == nil || !strings.Contains(err.Error(), "REMOTE_TOKEN") {
194
+ t.Fatalf("want an error naming the variable: %v", err)
195
+ }
196
+ r.mu.Lock()
197
+ n := len(r.headers)
198
+ r.mu.Unlock()
199
+ if n != 0 {
200
+ t.Fatalf("%d requests were sent without credentials", n)
201
+ }
202
+ }
203
+
204
+ func TestRemoteAuthFailureDoesNotEchoTheToken(t *testing.T) {
205
+ r := newFakeRemote(t)
206
+ r.requireAuth = "the-right-one"
207
+ rs := remotes(t, r, func(ra *RemoteAgent) { ra.BearerTokenEnv = "REMOTE_TOKEN" }, map[string]string{"REMOTE_TOKEN": "wrong-secret-token"})
208
+ _, err := rs.Send(context.Background(), SendArgs{Agent: "peer", Message: "hello"}, nil)
209
+ if err == nil {
210
+ t.Fatal("want an error")
211
+ }
212
+ if strings.Contains(err.Error(), "wrong-secret-token") || strings.Contains(err.Error(), "the-right-one") {
213
+ t.Fatalf("token in error: %v", err)
214
+ }
215
+ }
216
+
217
+ func TestCardWithoutAProtocol10InterfaceIsRefused(t *testing.T) {
218
+ r := newFakeRemote(t)
219
+ old := a2a.NewAgentInterface(r.srv.URL+"/rpc", a2a.TransportProtocolJSONRPC)
220
+ old.ProtocolVersion = "0.3"
221
+ r.interface_ = []*a2a.AgentInterface{old}
222
+ _, err := remotes(t, r, nil, nil).Send(context.Background(), SendArgs{Agent: "peer", Message: "hello"}, nil)
223
+ if err == nil || !strings.Contains(err.Error(), "1.0") {
224
+ t.Fatalf("want an error naming the pinned version: %v", err)
225
+ }
226
+ }
227
+
228
+ func TestSkipCardUsesTheEndpointDirectly(t *testing.T) {
229
+ r := newFakeRemote(t)
230
+ r.interface_ = nil // the card is unusable, like kagent's in-cluster URL seen from outside
231
+ rs := remotes(t, r, func(ra *RemoteAgent) { ra.URL, ra.SkipCard = r.srv.URL+"/rpc", true }, nil)
232
+ sum, err := rs.Send(context.Background(), SendArgs{Agent: "peer", Message: "hello"}, nil)
233
+ if err != nil || sum.State != "completed" {
234
+ t.Fatalf("%+v %v", sum, err)
235
+ }
236
+ }
237
+
238
+ func TestUnknownAgentNamesTheKnownOnes(t *testing.T) {
239
+ r := newFakeRemote(t)
240
+ _, err := remotes(t, r, nil, nil).Send(context.Background(), SendArgs{Agent: "nope", Message: "hello"}, nil)
241
+ if err == nil || !strings.Contains(err.Error(), "peer") {
242
+ t.Fatalf("%v", err)
243
+ }
244
+ }
245
+
246
+ func TestFailedAndInputRequiredStates(t *testing.T) {
247
+ r := newFakeRemote(t)
248
+ r.execute = func(ctx context.Context, ec *a2asrv.ExecutorContext, yield func(a2a.Event, error) bool) {
249
+ yield(a2a.NewSubmittedTask(ec, ec.Message), nil)
250
+ msg := a2a.NewMessageForTask(a2a.MessageRoleAgent, ec, a2a.NewTextPart("which file?"))
251
+ yield(a2a.NewStatusUpdateEvent(ec, a2a.TaskStateInputRequired, msg), nil)
252
+ }
253
+ sum, err := remotes(t, r, nil, nil).Send(context.Background(), SendArgs{Agent: "peer", Message: "do it"}, nil)
254
+ if err != nil {
255
+ t.Fatal(err)
256
+ }
257
+ if sum.State != "input-required" || sum.Terminal || sum.Text != "which file?" {
258
+ t.Fatalf("%+v", sum)
259
+ }
260
+ r.execute = func(ctx context.Context, ec *a2asrv.ExecutorContext, yield func(a2a.Event, error) bool) {
261
+ yield(a2a.NewSubmittedTask(ec, ec.Message), nil)
262
+ yield(a2a.NewStatusUpdateEvent(ec, a2a.TaskStateFailed, a2a.NewMessageForTask(a2a.MessageRoleAgent, ec, a2a.NewTextPart("it broke"))), nil)
263
+ }
264
+ sum, err = remotes(t, r, nil, nil).Send(context.Background(), SendArgs{Agent: "peer", Message: "do it"}, nil)
265
+ if err != nil || sum.State != "failed" || !sum.Terminal || sum.Text != "it broke" {
266
+ t.Fatalf("%+v %v", sum, err)
267
+ }
268
+ }
269
+
270
+ func TestCancellingTheCallCancelsTheRemoteTask(t *testing.T) {
271
+ r := newFakeRemote(t)
272
+ r.execute = func(ctx context.Context, ec *a2asrv.ExecutorContext, yield func(a2a.Event, error) bool) {
273
+ if !yield(a2a.NewSubmittedTask(ec, ec.Message), nil) {
274
+ return
275
+ }
276
+ yield(a2a.NewStatusUpdateEvent(ec, a2a.TaskStateWorking, nil), nil)
277
+ yield(a2a.NewArtifactEvent(ec, a2a.NewTextPart("partial")), nil)
278
+ <-ctx.Done()
279
+ }
280
+ rs := remotes(t, r, nil, nil)
281
+ ctx, cancel := context.WithCancel(context.Background())
282
+ got := make(chan struct{})
283
+ var once sync.Once
284
+ errc := make(chan error, 1)
285
+ go func() {
286
+ _, err := rs.Send(ctx, SendArgs{Agent: "peer", Message: "long job"}, func(string) { once.Do(func() { close(got) }) })
287
+ errc <- err
288
+ }()
289
+ waitChan(t, got, "the first streamed chunk")
290
+ cancel()
291
+ select {
292
+ case err := <-errc:
293
+ if !errors.Is(err, context.Canceled) {
294
+ t.Fatalf("want context.Canceled, got %v", err)
295
+ }
296
+ case <-time.After(10 * time.Second):
297
+ t.Fatal("Send did not return")
298
+ }
299
+ waitFor(t, func() bool { return r.cancels.Load() == 1 }, "the remote CancelTask call")
300
+ }
301
+
302
+ func TestGetAndCancelTask(t *testing.T) {
303
+ r := newFakeRemote(t)
304
+ rs := remotes(t, r, nil, nil)
305
+ sum, err := rs.Send(context.Background(), SendArgs{Agent: "peer", Message: "hello"}, nil)
306
+ if err != nil {
307
+ t.Fatal(err)
308
+ }
309
+ got, err := rs.GetTask(context.Background(), "peer", sum.TaskID)
310
+ if err != nil || got.State != "completed" || got.Text != "remote says hi" {
311
+ t.Fatalf("%+v %v", got, err)
312
+ }
313
+ r.execute = func(ctx context.Context, ec *a2asrv.ExecutorContext, yield func(a2a.Event, error) bool) {
314
+ yield(a2a.NewSubmittedTask(ec, ec.Message), nil)
315
+ yield(a2a.NewStatusUpdateEvent(ec, a2a.TaskStateWorking, nil), nil)
316
+ <-ctx.Done()
317
+ }
318
+ // A task that is still running can be cancelled by id.
319
+ idc := make(chan string, 1)
320
+ go func() {
321
+ _, _ = rs.Send(context.Background(), SendArgs{Agent: "peer", Message: "again"}, func(string) {})
322
+ }()
323
+ go func() {
324
+ for i := 0; i < 500; i++ {
325
+ list, err := rs.list(context.Background(), "peer")
326
+ if err == nil && len(list) > 0 {
327
+ for _, s := range list {
328
+ if s.State == "working" {
329
+ idc <- s.TaskID
330
+ return
331
+ }
332
+ }
333
+ }
334
+ time.Sleep(10 * time.Millisecond)
335
+ }
336
+ }()
337
+ var id string
338
+ select {
339
+ case id = <-idc:
340
+ case <-time.After(10 * time.Second):
341
+ t.Fatal("no working task")
342
+ }
343
+ c, err := rs.CancelTask(context.Background(), "peer", id)
344
+ if err != nil || c.State != "canceled" {
345
+ t.Fatalf("%+v %v", c, err)
346
+ }
347
+ }
348
+
349
+ func TestSendHonoursTheConfiguredTimeout(t *testing.T) {
350
+ r := newFakeRemote(t)
351
+ r.execute = func(ctx context.Context, ec *a2asrv.ExecutorContext, yield func(a2a.Event, error) bool) {
352
+ yield(a2a.NewSubmittedTask(ec, ec.Message), nil)
353
+ <-ctx.Done()
354
+ }
355
+ rs := remotes(t, r, func(ra *RemoteAgent) { ra.TimeoutSeconds = 1 }, nil)
356
+ start := time.Now()
357
+ _, err := rs.Send(context.Background(), SendArgs{Agent: "peer", Message: "hang"}, nil)
358
+ if err == nil || time.Since(start) > 8*time.Second {
359
+ t.Fatalf("err %v after %v", err, time.Since(start))
360
+ }
361
+ }
362
+
363
+ func TestCredentialsAreNotSentToAHostTheCardNamed(t *testing.T) {
364
+ evil := newFakeRemote(t) // stands in for an attacker's server named in an untrusted card
365
+ honest := newFakeRemote(t)
366
+ honest.interface_ = []*a2a.AgentInterface{a2a.NewAgentInterface(evil.srv.URL+"/rpc", a2a.TransportProtocolJSONRPC)}
367
+ rs := remotes(t, honest, func(ra *RemoteAgent) { ra.BearerTokenEnv = "REMOTE_TOKEN" }, map[string]string{"REMOTE_TOKEN": "remote-secret-value"})
368
+ _, err := rs.Send(context.Background(), SendArgs{Agent: "peer", Message: "hello"}, nil)
369
+ if err == nil || !strings.Contains(err.Error(), "skipCard") {
370
+ t.Fatalf("want an error that points at skipCard: %v", err)
371
+ }
372
+ evil.mu.Lock()
373
+ n := len(evil.headers)
374
+ evil.mu.Unlock()
375
+ if n != 0 {
376
+ t.Fatalf("%d requests reached the host the card named, with credentials configured", n)
377
+ }
378
+ }
379
+
380
+ func TestSendToATerminalTaskStillReportsItsState(t *testing.T) {
381
+ r := newFakeRemote(t)
382
+ rs := remotes(t, r, nil, nil)
383
+ first, err := rs.Send(context.Background(), SendArgs{Agent: "peer", Message: "one"}, nil)
384
+ if err != nil {
385
+ t.Fatal(err)
386
+ }
387
+ // A2A refuses a message for a task that is already terminal; the tool must surface that, not hide it.
388
+ _, err = rs.Send(context.Background(), SendArgs{Agent: "peer", Message: "two", TaskID: first.TaskID, ContextID: first.ContextID}, nil)
389
+ if err == nil {
390
+ t.Fatal("want the remote's refusal")
391
+ }
392
+ }
393
+
394
+ func TestRemotesNamesAreSortedForManyNames(t *testing.T) { // names-unsorted: two names can pass by luck
395
+ cfg := map[string]RemoteAgent{}
396
+ for _, n := range []string{"f", "c", "a", "e", "b", "d", "h", "g"} {
397
+ cfg[n] = RemoteAgent{URL: "http://" + n}
398
+ }
399
+ if got := strings.Join(NewRemotes(cfg, envFrom(nil)).Names(), ""); got != "abcdefgh" {
400
+ t.Fatalf("names %q", got)
401
+ }
402
+ }
@@ -0,0 +1,282 @@
1
+ package a2aext
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "net"
9
+ "net/url"
10
+ "os"
11
+ "path/filepath"
12
+ "regexp"
13
+ "strconv"
14
+ "strings"
15
+ )
16
+
17
+ // Config is the a2a extension's configuration. The listener is off unless Listen is set.
18
+ //
19
+ // Secrets are never stored here: tokens and remote credentials are named by the
20
+ // environment variable that holds them.
21
+ type Config struct {
22
+ // Listen is the address of the A2A server ("127.0.0.1:8787"). Empty means off.
23
+ Listen string `json:"listen"`
24
+ // ExternalURL is the base URL peers reach the listener at (behind a proxy). Default: derived from Listen.
25
+ ExternalURL string `json:"externalUrl"`
26
+ Name string `json:"name"`
27
+ Description string `json:"description"`
28
+ // StateDir holds per-principal PiG session files. Default: <config home>/a2a.
29
+ StateDir string `json:"stateDir"`
30
+ // InsecureNoAuth serves without tokens; only allowed on a loopback address.
31
+ InsecureNoAuth bool `json:"insecureNoAuth"`
32
+ Tokens []TokenConfig `json:"tokens"`
33
+ TLS *TLSConfig `json:"tls"`
34
+ Worker WorkerConfig `json:"worker"`
35
+ MaxConcurrentTasks int `json:"maxConcurrentTasks"`
36
+ TaskTimeoutSeconds int `json:"taskTimeoutSeconds"`
37
+ Remotes map[string]RemoteAgent `json:"remotes"`
38
+ }
39
+
40
+ // TokenConfig names a bearer token by the environment variable that holds it.
41
+ type TokenConfig struct {
42
+ // Name identifies the caller in logs and in task ownership.
43
+ Name string `json:"name"`
44
+ // TokenEnv is the environment variable holding the token (at least 16 characters).
45
+ TokenEnv string `json:"tokenEnv"`
46
+ // Tenant, when set, is the tenant boundary this token belongs to. Tokens of one tenant share tasks and contexts.
47
+ Tenant string `json:"tenant"`
48
+ }
49
+
50
+ // TLSConfig holds certificate file paths for the listener.
51
+ type TLSConfig struct {
52
+ CertFile string `json:"certFile"`
53
+ KeyFile string `json:"keyFile"`
54
+ }
55
+
56
+ // WorkerConfig says how a task turn runs PiG.
57
+ type WorkerConfig struct {
58
+ // Command is the pig executable. Default: $PIG_A2A_PIG, then "pig" on PATH.
59
+ Command string `json:"command"`
60
+ // Args are extra arguments appended to the fixed ones.
61
+ Args []string `json:"args"`
62
+ // Cwd is the worker's working directory. Default: the session's directory.
63
+ Cwd string `json:"cwd"`
64
+ // Tools is the tool allowlist. Default: none (pig --no-tools). PiG's file tools take absolute paths, so any
65
+ // tool named here reaches every file the account can, not only Cwd.
66
+ Tools []string `json:"tools"`
67
+ Provider string `json:"provider"`
68
+ Model string `json:"model"`
69
+ // PassEnv names extra environment variables to hand to the worker (for example a provider API key).
70
+ PassEnv []string `json:"passEnv"`
71
+ GraceSeconds int `json:"graceSeconds"`
72
+ }
73
+
74
+ // RemoteAgent is an A2A agent this extension can call.
75
+ type RemoteAgent struct {
76
+ URL string `json:"url"`
77
+ // BearerTokenEnv names the environment variable holding a bearer token.
78
+ BearerTokenEnv string `json:"bearerTokenEnv"`
79
+ // HeaderEnv maps a header name to the environment variable holding its value.
80
+ HeaderEnv map[string]string `json:"headerEnv"`
81
+ // SkipCard uses URL as the JSON-RPC endpoint without fetching the agent card (kagent's card URL is in-cluster).
82
+ SkipCard bool `json:"skipCard"`
83
+ TimeoutSeconds int `json:"timeoutSeconds"`
84
+ }
85
+
86
+ // LoadOptions are the inputs of LoadConfig.
87
+ type LoadOptions struct {
88
+ ConfigHome string
89
+ Getenv func(string) string
90
+ FlagListen string
91
+ }
92
+
93
+ const (
94
+ defaultMaxConcurrent = 4
95
+ defaultTaskTimeout = 900
96
+ defaultRemoteTimeout = 300
97
+ defaultGraceSeconds = 5
98
+ minTokenLength = 16
99
+ )
100
+
101
+ var (
102
+ identRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
103
+ envRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
104
+ headerRE = regexp.MustCompile("^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
105
+ )
106
+
107
+ // Enabled reports whether the listener is configured.
108
+ func (c Config) Enabled() bool { return c.Listen != "" }
109
+
110
+ // LoadConfig reads the configuration file, then the environment and the flag
111
+ // (flag over environment over file). It validates the result.
112
+ //
113
+ // Environment: PIG_A2A_CONFIG (file path), PIG_A2A_LISTEN, PIG_A2A_WORKER=1
114
+ // (set on worker children, which never listen).
115
+ func LoadConfig(o LoadOptions) (Config, error) {
116
+ getenv := o.Getenv
117
+ if getenv == nil {
118
+ getenv = os.Getenv
119
+ }
120
+ path, explicit := getenv("PIG_A2A_CONFIG"), true
121
+ if path == "" {
122
+ explicit = false
123
+ if o.ConfigHome != "" {
124
+ path = filepath.Join(o.ConfigHome, "a2a.json")
125
+ }
126
+ }
127
+ var cfg Config
128
+ if path != "" {
129
+ raw, err := os.ReadFile(path)
130
+ switch {
131
+ case err == nil:
132
+ dec := json.NewDecoder(bytes.NewReader(raw))
133
+ dec.DisallowUnknownFields()
134
+ if err := dec.Decode(&cfg); err != nil {
135
+ return Config{}, fmt.Errorf("a2a: %s: %w (secrets belong in environment variables named by tokenEnv, bearerTokenEnv or headerEnv)", path, err)
136
+ }
137
+ case errors.Is(err, os.ErrNotExist) && !explicit:
138
+ default:
139
+ return Config{}, fmt.Errorf("a2a: read %s: %w", path, err)
140
+ }
141
+ }
142
+ if v := getenv("PIG_A2A_LISTEN"); v != "" {
143
+ cfg.Listen = v
144
+ }
145
+ if o.FlagListen != "" {
146
+ cfg.Listen = o.FlagListen
147
+ }
148
+ if getenv("PIG_A2A_WORKER") == "1" {
149
+ // A worker child is a PiG the server started for one task; it must not start a second server.
150
+ cfg.Listen = ""
151
+ }
152
+ if cfg.StateDir == "" && o.ConfigHome != "" {
153
+ cfg.StateDir = filepath.Join(o.ConfigHome, "a2a")
154
+ }
155
+ cfg.applyDefaults()
156
+ if err := cfg.validate(getenv); err != nil {
157
+ return Config{}, err
158
+ }
159
+ return cfg, nil
160
+ }
161
+
162
+ func (c *Config) applyDefaults() {
163
+ if c.Name == "" {
164
+ c.Name = "PiG"
165
+ }
166
+ if c.Description == "" {
167
+ c.Description = "A PiG coding agent serving tasks over A2A."
168
+ }
169
+ if c.MaxConcurrentTasks <= 0 {
170
+ c.MaxConcurrentTasks = defaultMaxConcurrent
171
+ }
172
+ if c.TaskTimeoutSeconds <= 0 {
173
+ c.TaskTimeoutSeconds = defaultTaskTimeout
174
+ }
175
+ if c.Worker.GraceSeconds <= 0 {
176
+ c.Worker.GraceSeconds = defaultGraceSeconds
177
+ }
178
+ for name, r := range c.Remotes {
179
+ if r.TimeoutSeconds <= 0 {
180
+ r.TimeoutSeconds = defaultRemoteTimeout
181
+ }
182
+ c.Remotes[name] = r
183
+ }
184
+ }
185
+
186
+ // Validate checks the configuration without reading the environment for secrets.
187
+ func (c Config) Validate() error { return c.validate(func(string) string { return "" }) }
188
+
189
+ func isLoopbackHost(host string) bool {
190
+ if strings.EqualFold(host, "localhost") {
191
+ return true
192
+ }
193
+ ip := net.ParseIP(host)
194
+ return ip != nil && ip.IsLoopback()
195
+ }
196
+
197
+ func (c Config) validate(getenv func(string) string) error {
198
+ if c.Listen != "" {
199
+ host, port, err := net.SplitHostPort(c.Listen)
200
+ if err != nil {
201
+ return fmt.Errorf("a2a: listen %q is not host:port: %w", c.Listen, err)
202
+ }
203
+ if n, err := strconv.Atoi(port); err != nil || n < 0 || n > 65535 {
204
+ return fmt.Errorf("a2a: listen port %q is not a port number", port)
205
+ }
206
+ switch {
207
+ case c.InsecureNoAuth && len(c.Tokens) > 0:
208
+ return errors.New("a2a: insecureNoAuth and tokens are both set; remove one")
209
+ case c.InsecureNoAuth && !isLoopbackHost(host):
210
+ return fmt.Errorf("a2a: insecureNoAuth is only allowed on a loopback address, not %q", host)
211
+ case !c.InsecureNoAuth && len(c.Tokens) == 0:
212
+ return errors.New("a2a: a listener needs at least one entry in tokens (or insecureNoAuth on a loopback address)")
213
+ }
214
+ if err := c.validateTokens(getenv); err != nil {
215
+ return err
216
+ }
217
+ if c.TLS != nil && (c.TLS.CertFile == "" || c.TLS.KeyFile == "") {
218
+ return errors.New("a2a: tls needs both certFile and keyFile")
219
+ }
220
+ if c.ExternalURL != "" {
221
+ if err := checkHTTPURL(c.ExternalURL); err != nil {
222
+ return fmt.Errorf("a2a: externalUrl: %w", err)
223
+ }
224
+ }
225
+ }
226
+ for name, r := range c.Remotes {
227
+ if !identRE.MatchString(name) {
228
+ return fmt.Errorf("a2a: remote name %q must match %s", name, identRE)
229
+ }
230
+ if err := checkHTTPURL(r.URL); err != nil {
231
+ return fmt.Errorf("a2a: remote %q: %w", name, err)
232
+ }
233
+ if r.BearerTokenEnv != "" && !envRE.MatchString(r.BearerTokenEnv) {
234
+ return fmt.Errorf("a2a: remote %q: bearerTokenEnv %q is not an environment variable name", name, r.BearerTokenEnv)
235
+ }
236
+ for header, env := range r.HeaderEnv {
237
+ if !headerRE.MatchString(header) || !envRE.MatchString(env) {
238
+ return fmt.Errorf("a2a: remote %q: headerEnv entry %q: %q is not a valid header name or variable name", name, header, env)
239
+ }
240
+ }
241
+ }
242
+ return nil
243
+ }
244
+
245
+ func (c Config) validateTokens(getenv func(string) string) error {
246
+ names, envs, values := map[string]bool{}, map[string]bool{}, map[string]bool{}
247
+ for i, t := range c.Tokens {
248
+ if !identRE.MatchString(t.Name) {
249
+ return fmt.Errorf("a2a: tokens[%d].name %q must match %s", i, t.Name, identRE)
250
+ }
251
+ if t.Tenant != "" && !identRE.MatchString(t.Tenant) {
252
+ return fmt.Errorf("a2a: tokens[%d].tenant %q must match %s", i, t.Tenant, identRE)
253
+ }
254
+ if !envRE.MatchString(t.TokenEnv) {
255
+ return fmt.Errorf("a2a: tokens[%d].tokenEnv %q is not an environment variable name", i, t.TokenEnv)
256
+ }
257
+ if names[t.Name] || envs[t.TokenEnv] {
258
+ return fmt.Errorf("a2a: tokens[%d]: name %q or variable %s is used twice", i, t.Name, t.TokenEnv)
259
+ }
260
+ names[t.Name], envs[t.TokenEnv] = true, true
261
+ v := getenv(t.TokenEnv)
262
+ if v == "" {
263
+ return fmt.Errorf("a2a: token %q: environment variable %s is not set", t.Name, t.TokenEnv)
264
+ }
265
+ if len(v) < minTokenLength {
266
+ return fmt.Errorf("a2a: token %q (%s) is shorter than %d characters", t.Name, t.TokenEnv, minTokenLength)
267
+ }
268
+ if values[v] {
269
+ return fmt.Errorf("a2a: token %q has the same value as another token", t.Name)
270
+ }
271
+ values[v] = true
272
+ }
273
+ return nil
274
+ }
275
+
276
+ func checkHTTPURL(raw string) error {
277
+ u, err := url.Parse(raw)
278
+ if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
279
+ return fmt.Errorf("%q must be an http or https URL", raw)
280
+ }
281
+ return nil
282
+ }