@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.
- package/CREDITS.md +26 -0
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/extensions/a2a/auth.go +120 -0
- package/extensions/a2a/auth_test.go +133 -0
- package/extensions/a2a/bench_test.go +70 -0
- package/extensions/a2a/binary_test.go +225 -0
- package/extensions/a2a/cancelqueued_test.go +142 -0
- package/extensions/a2a/card_cache_test.go +42 -0
- package/extensions/a2a/client.go +499 -0
- package/extensions/a2a/client_test.go +402 -0
- package/extensions/a2a/config.go +282 -0
- package/extensions/a2a/config_test.go +194 -0
- package/extensions/a2a/e2e_test.go +311 -0
- package/extensions/a2a/executor.go +318 -0
- package/extensions/a2a/extension.go +382 -0
- package/extensions/a2a/extension_test.go +396 -0
- package/extensions/a2a/fakehost_test.go +548 -0
- package/extensions/a2a/fakepig_test.go +212 -0
- package/extensions/a2a/gaps_test.go +59 -0
- package/extensions/a2a/go.mod +15 -0
- package/extensions/a2a/go.sum +14 -0
- package/extensions/a2a/interop_test.go +163 -0
- package/extensions/a2a/procattr_other.go +25 -0
- package/extensions/a2a/procattr_windows.go +26 -0
- package/extensions/a2a/resubscribe_test.go +113 -0
- package/extensions/a2a/review_test.go +293 -0
- package/extensions/a2a/server.go +267 -0
- package/extensions/a2a/server_test.go +792 -0
- package/extensions/a2a/survivors_test.go +302 -0
- package/extensions/a2a/worker.go +388 -0
- package/extensions/a2a/worker_test.go +257 -0
- package/package.json +41 -0
- package/port/PORT.md +126 -0
- package/port/a2a-go-LICENSE +201 -0
- package/port/golden/flag-without-auth-refused.jsonl +5 -0
- package/port/golden/listener-off.jsonl +4 -0
- package/port/golden/send-missing-message.jsonl +18 -0
- package/port/golden/send-without-remotes.jsonl +18 -0
- package/port/golden/task-unknown-action.jsonl +18 -0
- package/port/golden/tools-visible-to-model.jsonl +11 -0
- package/port/interop/kagent/main.go +73 -0
- package/port/mutation-run.txt +222 -0
- package/port/mutations.json +656 -0
- package/port/red.txt +105 -0
- package/port/scenarios/flag-without-auth-refused.json +8 -0
- package/port/scenarios/listener-off.json +7 -0
- package/port/scenarios/send-missing-message.json +11 -0
- package/port/scenarios/send-without-remotes.json +11 -0
- package/port/scenarios/task-unknown-action.json +11 -0
- package/port/scenarios/tools-visible-to-model.json +8 -0
- 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
|
+
}
|