@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,293 @@
1
+ package a2aext
2
+
3
+ // Review findings (rev-pigpen-a2a). Each test names the finding it pins.
4
+
5
+ import (
6
+ "bytes"
7
+ "context"
8
+ "encoding/json"
9
+ "fmt"
10
+ "net"
11
+ "net/http"
12
+ "net/http/httptest"
13
+ "os"
14
+ "path/filepath"
15
+ "strings"
16
+ "sync"
17
+ "testing"
18
+ "time"
19
+
20
+ "github.com/a2aproject/a2a-go/v2/a2a"
21
+ )
22
+
23
+ // H1: PiG's read, grep, find and ls tools accept absolute paths, so a "read-only" worker can read every file
24
+ // the account can: PiG's credentials, SSH keys, other tenants' session files. The worker therefore gets no tools
25
+ // unless the operator names them.
26
+ func TestReview_WorkerHasNoToolsUnlessConfigured(t *testing.T) {
27
+ dir := t.TempDir()
28
+ writeConfig(t, dir, `{"listen":"127.0.0.1:5555","insecureNoAuth":true}`)
29
+ cfg, err := LoadConfig(LoadOptions{ConfigHome: dir, Getenv: envFrom(nil)})
30
+ if err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ if len(cfg.Worker.Tools) != 0 {
34
+ t.Fatalf("default worker tools = %v; PiG's file tools are not confined to the workspace, so the default must be none", cfg.Worker.Tools)
35
+ }
36
+ w, err := NewProcessWorker(cfg.Worker, t.TempDir(), envFrom(nil))
37
+ if err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ args := w.args(Turn{Principal: alice, ContextID: "c", Prompt: "p"})
41
+ if !contains(args, "--no-tools") || contains(args, "--tools") {
42
+ t.Fatalf("a worker without configured tools must run pig --no-tools: %v", args)
43
+ }
44
+ w, err = NewProcessWorker(WorkerConfig{Tools: []string{"read"}}, t.TempDir(), envFrom(nil))
45
+ if err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ args = w.args(Turn{Principal: alice, ContextID: "c", Prompt: "p"})
49
+ if argValue(args, "--tools") != "read" || contains(args, "--no-tools") {
50
+ t.Fatalf("configured tools must be passed as the allowlist: %v", args)
51
+ }
52
+ }
53
+
54
+ // H1 end to end with a real pig: a worker configured only by a2a.json (no tools named) must not hand a peer the
55
+ // contents of a file outside its workspace.
56
+ func TestE2E_DefaultWorkerCannotReadFilesOutsideItsWorkspace(t *testing.T) {
57
+ bin := os.Getenv("PIG_A2A_E2E_BIN")
58
+ if bin == "" {
59
+ t.Skip("set PIG_A2A_E2E_BIN to a pig (or Piglet Binary) executable to run the real-PiG end-to-end tests")
60
+ }
61
+ home := t.TempDir()
62
+ agent := filepath.Join(home, "pig", "agent")
63
+ if err := os.MkdirAll(agent, 0o700); err != nil {
64
+ t.Fatal(err)
65
+ }
66
+ llm := startE2ELLM(t)
67
+ models := map[string]any{"providers": map[string]any{"e2e": map[string]any{
68
+ "baseUrl": llm.baseURL(), "api": "openai-completions", "apiKey": "e2e-key",
69
+ "models": []any{map[string]any{"id": "e2e-1", "name": "e2e-1", "reasoning": false, "input": []string{"text"},
70
+ "contextWindow": 100000, "maxTokens": 4096, "cost": map[string]any{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}}},
71
+ }}}
72
+ b, _ := json.Marshal(models)
73
+ if err := os.WriteFile(filepath.Join(agent, "models.json"), b, 0o600); err != nil {
74
+ t.Fatal(err)
75
+ }
76
+ // A stand-in for the operator's credential store, in the temporary home (never the real one).
77
+ secret := filepath.Join(agent, "operator-secret.json")
78
+ const canary = "CANARY-7f3a-operator-credential"
79
+ if err := os.WriteFile(secret, []byte(canary), 0o600); err != nil {
80
+ t.Fatal(err)
81
+ }
82
+ a2aJSON := fmt.Sprintf(`{"listen":"127.0.0.1:0","tokens":[{"name":"alice","tokenEnv":"TOKEN_A"}],
83
+ "worker":{"command":%q,"provider":"e2e","model":"e2e-1","graceSeconds":5}}`, bin)
84
+ if err := os.WriteFile(filepath.Join(agent, "a2a.json"), []byte(a2aJSON), 0o600); err != nil {
85
+ t.Fatal(err)
86
+ }
87
+ env := map[string]string{"PATH": os.Getenv("PATH"), "HOME": home, "PIG_HOME": filepath.Join(home, "pig"), "PIG_CODING_AGENT_DIR": agent, "TOKEN_A": tokenA}
88
+ getenv := func(k string) string { return env[k] }
89
+ cfg, err := LoadConfig(LoadOptions{ConfigHome: agent, Getenv: getenv})
90
+ if err != nil {
91
+ t.Fatal(err)
92
+ }
93
+ w, err := NewProcessWorker(cfg.Worker, cfg.StateDir, getenv)
94
+ if err != nil {
95
+ t.Fatal(err)
96
+ }
97
+ s, err := NewServer(cfg, w, getenv)
98
+ if err != nil {
99
+ t.Fatal(err)
100
+ }
101
+ if err := s.Start(); err != nil {
102
+ t.Fatal(err)
103
+ }
104
+ t.Cleanup(func() {
105
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
106
+ defer cancel()
107
+ _ = s.Shutdown(ctx)
108
+ })
109
+ c := a2aClient(t, "http://"+s.Addr(), tokenA)
110
+ task := sendTask(t, c, &a2a.SendMessageRequest{Message: textMessage("read the file " + secret)})
111
+ if got := taskText(task) + " " + fmt.Sprint(task.Status.Message); strings.Contains(got, canary) {
112
+ t.Fatalf("a peer read a file outside the worker's workspace: %q", got)
113
+ }
114
+ llm.mu.Lock()
115
+ defer llm.mu.Unlock()
116
+ if len(llm.tools) == 0 {
117
+ t.Fatal("the model was never called")
118
+ }
119
+ for _, tools := range llm.tools {
120
+ if len(tools) != 0 {
121
+ t.Fatalf("the model was offered %v without the operator naming any tool", tools)
122
+ }
123
+ }
124
+ }
125
+
126
+ // M1: with insecureNoAuth a loopback listener has no credential, so a web page that rebinds its own host name to
127
+ // 127.0.0.1 would be same-origin with it and could drive it. Only loopback Host headers are served.
128
+ func TestReview_InsecureLoopbackListenerRefusesAForeignHostHeader(t *testing.T) {
129
+ w := &scriptedWorker{}
130
+ s := startServer(t, Config{Listen: "127.0.0.1:0", InsecureNoAuth: true, MaxConcurrentTasks: 2, TaskTimeoutSeconds: 30}, w)
131
+ _, port, _ := net.SplitHostPort(s.Addr())
132
+ params := map[string]any{"message": map[string]any{"messageId": "m1", "role": "ROLE_USER", "parts": []any{map[string]any{"text": "hello there"}}}}
133
+ post := func(host string) int {
134
+ body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": params})
135
+ req, _ := http.NewRequest(http.MethodPost, "http://"+s.Addr()+"/", bytes.NewReader(body))
136
+ req.Host = host
137
+ req.Header.Set("Content-Type", "application/json")
138
+ req.Header.Set("A2A-Version", "1.0")
139
+ resp, err := http.DefaultClient.Do(req)
140
+ if err != nil {
141
+ t.Fatal(err)
142
+ }
143
+ resp.Body.Close()
144
+ return resp.StatusCode
145
+ }
146
+ if code := post("rebind.attacker.example:" + port); code != http.StatusForbidden {
147
+ t.Fatalf("a foreign Host on an unauthenticated listener got %d, want 403", code)
148
+ }
149
+ if n := len(w.Turns()); n != 0 {
150
+ t.Fatalf("the worker ran %d time(s) for a foreign Host", n)
151
+ }
152
+ for _, host := range []string{"127.0.0.1:" + port, "localhost:" + port, "[::1]:" + port} {
153
+ if code := post(host); code != http.StatusOK {
154
+ t.Fatalf("loopback Host %s got %d", host, code)
155
+ }
156
+ }
157
+ }
158
+
159
+ // M1: the Host check is only for the unauthenticated mode; a token already proves the caller.
160
+ func TestReview_TokenListenerServesAnyHostHeader(t *testing.T) {
161
+ s := startServer(t, serverConfig(), &scriptedWorker{})
162
+ body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "ListTasks", "params": map[string]any{}})
163
+ req, _ := http.NewRequest(http.MethodPost, "http://"+s.Addr()+"/", bytes.NewReader(body))
164
+ req.Host = "pig.example.com"
165
+ req.Header.Set("Authorization", "Bearer "+tokenA)
166
+ req.Header.Set("A2A-Version", "1.0")
167
+ resp, err := http.DefaultClient.Do(req)
168
+ if err != nil {
169
+ t.Fatal(err)
170
+ }
171
+ resp.Body.Close()
172
+ if resp.StatusCode != http.StatusOK {
173
+ t.Fatalf("a token caller behind a proxy got %d", resp.StatusCode)
174
+ }
175
+ }
176
+
177
+ // M2: credentials are pinned to the configured origin, scheme included: an https remote's token must never go
178
+ // over plain http to the same host (a card, or a redirect target, naming http://).
179
+ func TestReview_CredentialsAreNotDowngradedToPlainHTTP(t *testing.T) {
180
+ var mu sync.Mutex
181
+ var got []string
182
+ plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
183
+ mu.Lock()
184
+ got = append(got, r.Header.Get("Authorization"))
185
+ mu.Unlock()
186
+ }))
187
+ defer plain.Close()
188
+ host := strings.TrimPrefix(plain.URL, "http://")
189
+ rs := NewRemotes(map[string]RemoteAgent{"peer": {URL: "https://" + host, BearerTokenEnv: "T", TimeoutSeconds: 5}}, envFrom(map[string]string{"T": "secret-value"}))
190
+ hc, err := rs.httpClient("peer", rs.cfg["peer"])
191
+ if err != nil {
192
+ t.Fatal(err)
193
+ }
194
+ req, _ := http.NewRequest(http.MethodGet, plain.URL+"/rpc", nil)
195
+ if resp, err := hc.Transport.RoundTrip(req); err == nil {
196
+ resp.Body.Close()
197
+ t.Fatal("the transport sent an https remote's credentials over http")
198
+ }
199
+ mu.Lock()
200
+ defer mu.Unlock()
201
+ if len(got) != 0 {
202
+ t.Fatalf("credentials reached the plain-http port: %q", got)
203
+ }
204
+
205
+ card := &a2a.AgentCard{SupportedInterfaces: []*a2a.AgentInterface{a2a.NewAgentInterface("http://"+host+"/rpc", a2a.TransportProtocolJSONRPC)}}
206
+ if _, err := pinnedInterface("peer", rs.cfg["peer"], card); err == nil {
207
+ t.Fatal("a card that downgrades an https remote to http must be refused while credentials are configured")
208
+ }
209
+ same := &a2a.AgentCard{SupportedInterfaces: []*a2a.AgentInterface{a2a.NewAgentInterface("https://"+host+"/rpc", a2a.TransportProtocolJSONRPC)}}
210
+ if _, err := pinnedInterface("peer", rs.cfg["peer"], same); err != nil {
211
+ t.Fatalf("the configured origin must be accepted: %v", err)
212
+ }
213
+ }
214
+
215
+ // L1 (mutation survivor): the per-context lock is per principal, so two tenants that pick the same contextId do
216
+ // not queue behind each other.
217
+ func TestReview_SameContextIDInTwoTenantsRunsConcurrently(t *testing.T) {
218
+ var w *scriptedWorker
219
+ w = &scriptedWorker{run: func(ctx context.Context, tn Turn, up func(Update)) (Result, error) {
220
+ // Wait (bounded) for the other tenant's turn to be running too; a shared lock would keep it queued.
221
+ deadline := time.Now().Add(3 * time.Second)
222
+ for w.running.Load() < 2 && time.Now().Before(deadline) {
223
+ time.Sleep(10 * time.Millisecond)
224
+ }
225
+ return Result{Text: "ok"}, nil
226
+ }}
227
+ s := startServer(t, serverConfig(), w)
228
+ var wg sync.WaitGroup
229
+ for _, token := range []string{tokenA, tokenB} {
230
+ c := a2aClient(t, "http://"+s.Addr(), token)
231
+ wg.Add(1)
232
+ go func() {
233
+ defer wg.Done()
234
+ m := textMessage("same id")
235
+ m.ContextID = "shared-name"
236
+ _, _ = c.SendMessage(context.Background(), &a2a.SendMessageRequest{Message: m})
237
+ }()
238
+ }
239
+ wg.Wait()
240
+ if w.maxRun.Load() != 2 {
241
+ t.Fatalf("two tenants' contexts must not share a lock: max concurrent %d", w.maxRun.Load())
242
+ }
243
+ }
244
+
245
+ // L2 (mutation survivor): a process the worker started and left behind is killed after a normal turn.
246
+ func TestReview_WorkerKillsWhatATurnLeftBehind(t *testing.T) {
247
+ w, logPath, _ := newTestWorker(t)
248
+ res, err := w.Run(context.Background(), Turn{Principal: alice, ContextID: "c", TaskID: "t", Prompt: "ORPHAN please"}, func(Update) {})
249
+ if err != nil || res.Failure != "" {
250
+ t.Fatalf("%+v %v", res, err)
251
+ }
252
+ l := readFakeLog(t, logPath)
253
+ if l.ChildPID == 0 {
254
+ t.Fatal("the fake pig did not start a child")
255
+ }
256
+ waitFor(t, func() bool { return !processAlive(l.ChildPID) }, "the left-behind child to be killed")
257
+ }
258
+
259
+ // H1 inside the Piglet Binary: the Binary hosts the listener from an a2a.json that names no tools; a peer asking its
260
+ // worker (the same Binary) to read a file outside the workspace gets nothing from it.
261
+ func TestBinary_DefaultWorkerCannotReadFilesOutsideItsWorkspace(t *testing.T) {
262
+ bin := os.Getenv("PIG_A2A_BINARY")
263
+ if bin == "" {
264
+ t.Skip("set PIG_A2A_BINARY to the Piglet Binary built from piglets/a2a to run the Binary proof")
265
+ }
266
+ const canary = "CANARY-51c2-operator-credential"
267
+ var secret string
268
+ env, work, port, llm := binaryHome(t, bin, func(port int, work string) string {
269
+ secret = filepath.Join(filepath.Dir(work), "pig", "agent", "operator-secret.json")
270
+ return fmt.Sprintf(`{"listen":"127.0.0.1:%d","tokens":[{"name":"alice","tokenEnv":"A2A_TOKEN_A"}],
271
+ "worker":{"command":%q,"cwd":%q,"provider":"e2e","model":"e2e-1"}}`, port, bin, work)
272
+ })
273
+ if err := os.WriteFile(secret, []byte(canary), 0o600); err != nil {
274
+ t.Fatal(err)
275
+ }
276
+ startBinaryHost(t, bin, env, work)
277
+ addr := fmt.Sprintf("127.0.0.1:%d", port)
278
+ if !waitDial(t, addr, true, 60*time.Second) {
279
+ t.Fatal("the Binary never opened the configured A2A listener")
280
+ }
281
+ c := a2aClient(t, "http://"+addr, tokenA)
282
+ task := sendTask(t, c, &a2a.SendMessageRequest{Message: textMessage("read the file " + secret)})
283
+ if got := taskText(task) + " " + fmt.Sprint(task.Status.Message); strings.Contains(got, canary) {
284
+ t.Fatalf("a peer read a file outside the worker's workspace through the Binary: %q", got)
285
+ }
286
+ llm.mu.Lock()
287
+ defer llm.mu.Unlock()
288
+ for _, tools := range llm.tools {
289
+ if len(tools) != 0 {
290
+ t.Fatalf("the Binary's worker offered %v without a2a.json naming any tool", tools)
291
+ }
292
+ }
293
+ }
@@ -0,0 +1,267 @@
1
+ package a2aext
2
+
3
+ import (
4
+ "context"
5
+ "crypto/tls"
6
+ "errors"
7
+ "fmt"
8
+ "io"
9
+ "log"
10
+ "log/slog"
11
+ "net"
12
+ "net/http"
13
+ "strings"
14
+ "sync"
15
+ "time"
16
+
17
+ "github.com/a2aproject/a2a-go/v2/a2a"
18
+ "github.com/a2aproject/a2a-go/v2/a2asrv"
19
+ "github.com/a2aproject/a2a-go/v2/a2asrv/taskstore"
20
+ )
21
+
22
+ // ProtocolVersion is the only A2A protocol version served and spoken.
23
+ const ProtocolVersion = a2a.Version
24
+
25
+ const (
26
+ maxBodyBytes = 1 << 20
27
+ agentVersion = "0.1.0"
28
+ shutdownTimeout = 20 * time.Second
29
+ )
30
+
31
+ // Server is the A2A listener.
32
+ type Server struct {
33
+ cfg Config
34
+ auth *Authenticator
35
+ exec *executor
36
+ handler http.Handler
37
+ cancel context.CancelFunc
38
+
39
+ mu sync.Mutex
40
+ srv *http.Server
41
+ ln net.Listener
42
+ served chan struct{} // closed when the Serve goroutine has returned
43
+ logs []string
44
+
45
+ // The Agent Card is the same document for every request, so its handler (which holds the encoded card) is
46
+ // kept for as long as the advertised URL is the same.
47
+ cardMu sync.Mutex
48
+ cardURL string
49
+ cardHandler http.Handler
50
+ }
51
+
52
+ // NewServer builds the server. It does not listen until Start.
53
+ func NewServer(cfg Config, worker Worker, getenv func(string) string) (*Server, error) {
54
+ if getenv == nil {
55
+ getenv = func(string) string { return "" }
56
+ }
57
+ cfg.applyDefaults()
58
+ if err := cfg.validate(getenv); err != nil {
59
+ return nil, err
60
+ }
61
+ if worker == nil {
62
+ return nil, errors.New("a2a: server needs a worker")
63
+ }
64
+ auth, err := NewAuthenticator(cfg.Tokens, getenv, cfg.InsecureNoAuth)
65
+ if err != nil {
66
+ return nil, err
67
+ }
68
+ base, cancel := context.WithCancel(context.Background())
69
+ s := &Server{cfg: cfg, auth: auth, cancel: cancel}
70
+ s.exec = newExecutor(base, worker, cfg.MaxConcurrentTasks, time.Duration(cfg.TaskTimeoutSeconds)*time.Second, s.logf)
71
+
72
+ // The store is ours so that guard can ask it who owns a task: a2a-go's SubscribeToTask attaches to a live
73
+ // task's event queue without consulting the store, so without this check any authenticated caller who
74
+ // knew a task id could read another tenant's live stream (found by TestSubscribeToALiveTaskDeliversTheRest).
75
+ store := taskstore.NewInMemory(&taskstore.InMemoryStoreConfig{Authenticator: a2asrv.NewTaskStoreAuthenticator()})
76
+ handler := a2asrv.NewHandler(s.exec,
77
+ a2asrv.WithTaskStore(store),
78
+ a2asrv.WithCallInterceptors(guard{store: store}),
79
+ // The a2a-go logger writes to stderr, which is PiG's terminal when the extension is fused.
80
+ a2asrv.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
81
+ )
82
+ mux := http.NewServeMux()
83
+ mux.Handle("POST /{$}", http.MaxBytesHandler(a2asrv.NewJSONRPCHandler(handler), maxBodyBytes))
84
+ mux.HandleFunc(a2asrv.WellKnownAgentCardPath, s.serveCard)
85
+ s.handler = auth.Wrap(mux, func(r *http.Request) bool { return r.URL.Path == a2asrv.WellKnownAgentCardPath })
86
+ return s, nil
87
+ }
88
+
89
+ func (s *Server) logf(format string, args ...any) {
90
+ s.mu.Lock()
91
+ defer s.mu.Unlock()
92
+ s.logs = append(s.logs, fmt.Sprintf(format, args...))
93
+ if len(s.logs) > 50 {
94
+ s.logs = s.logs[len(s.logs)-50:]
95
+ }
96
+ }
97
+
98
+ // RecentLogs returns the last server messages (task failures, listener errors).
99
+ func (s *Server) RecentLogs() []string {
100
+ s.mu.Lock()
101
+ defer s.mu.Unlock()
102
+ return append([]string(nil), s.logs...)
103
+ }
104
+
105
+ // Handler returns the HTTP handler (authentication included).
106
+ func (s *Server) Handler() http.Handler { return s.handler }
107
+
108
+ func (s *Server) baseURL() string {
109
+ if s.cfg.ExternalURL != "" {
110
+ return strings.TrimRight(s.cfg.ExternalURL, "/")
111
+ }
112
+ scheme := "http"
113
+ if s.cfg.TLS != nil {
114
+ scheme = "https"
115
+ }
116
+ return scheme + "://" + s.Addr()
117
+ }
118
+
119
+ func (s *Server) card() *a2a.AgentCard {
120
+ return &a2a.AgentCard{
121
+ Name: s.cfg.Name, Description: s.cfg.Description, Version: agentVersion,
122
+ SupportedInterfaces: []*a2a.AgentInterface{a2a.NewAgentInterface(s.baseURL(), a2a.TransportProtocolJSONRPC)},
123
+ Capabilities: a2a.AgentCapabilities{Streaming: true},
124
+ DefaultInputModes: []string{"text/plain"}, DefaultOutputModes: []string{"text/plain"},
125
+ Skills: []a2a.AgentSkill{{
126
+ ID: "pig-task", Name: "PiG task",
127
+ Description: "Runs a task with a PiG coding agent. Each contextId is one PiG session; a later task in the same context continues it.",
128
+ Tags: []string{"coding", "pig"}, Examples: []string{"Summarise the layout of the repository."},
129
+ InputModes: []string{"text/plain"}, OutputModes: []string{"text/plain"},
130
+ }},
131
+ SecuritySchemes: a2a.NamedSecuritySchemes{"bearer": a2a.HTTPAuthSecurityScheme{Scheme: "Bearer", Description: "Static bearer token issued by the operator."}},
132
+ SecurityRequirements: a2a.SecurityRequirementsOptions{
133
+ a2a.SecurityRequirements{"bearer": a2a.SecuritySchemeScopes{}},
134
+ },
135
+ }
136
+ }
137
+
138
+ func (s *Server) serveCard(w http.ResponseWriter, r *http.Request) {
139
+ url := s.baseURL()
140
+ s.cardMu.Lock()
141
+ if s.cardHandler == nil || s.cardURL != url {
142
+ s.cardHandler, s.cardURL = a2asrv.NewStaticAgentCardHandler(s.card()), url
143
+ }
144
+ h := s.cardHandler
145
+ s.cardMu.Unlock()
146
+ h.ServeHTTP(w, r)
147
+ }
148
+
149
+ // Start listens and serves in the background.
150
+ func (s *Server) Start() error {
151
+ s.mu.Lock()
152
+ defer s.mu.Unlock()
153
+ if s.srv != nil {
154
+ return errors.New("a2a: server already started")
155
+ }
156
+ ln, err := net.Listen("tcp", s.cfg.Listen)
157
+ if err != nil {
158
+ return fmt.Errorf("a2a: listen %s: %w", s.cfg.Listen, err)
159
+ }
160
+ // The default error log writes to stderr, which is PiG's terminal when the extension is fused.
161
+ srv := &http.Server{Handler: s.handler, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 2 * time.Minute,
162
+ ErrorLog: log.New(io.Discard, "", 0)}
163
+ if s.cfg.TLS != nil {
164
+ cert, err := tls.LoadX509KeyPair(s.cfg.TLS.CertFile, s.cfg.TLS.KeyFile)
165
+ if err != nil {
166
+ _ = ln.Close()
167
+ return fmt.Errorf("a2a: load TLS key pair: %w", err)
168
+ }
169
+ srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}
170
+ ln = tls.NewListener(ln, srv.TLSConfig)
171
+ }
172
+ s.srv, s.ln = srv, ln
173
+ s.served = make(chan struct{})
174
+ go func() {
175
+ defer close(s.served)
176
+ if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
177
+ s.logf("listener stopped: %v", err)
178
+ }
179
+ }()
180
+ return nil
181
+ }
182
+
183
+ // Addr is the bound address, or "" before Start.
184
+ func (s *Server) Addr() string {
185
+ s.mu.Lock()
186
+ defer s.mu.Unlock()
187
+ if s.ln == nil {
188
+ return ""
189
+ }
190
+ return s.ln.Addr().String()
191
+ }
192
+
193
+ // ActiveTasks counts tasks that are queued or running.
194
+ func (s *Server) ActiveTasks() int { return int(s.exec.active.Load()) }
195
+
196
+ // Shutdown stops accepting connections, cancels running turns and waits for their workers.
197
+ func (s *Server) Shutdown(ctx context.Context) error {
198
+ s.mu.Lock()
199
+ srv, ln, served := s.srv, s.ln, s.served
200
+ s.mu.Unlock()
201
+ s.cancel()
202
+ var errs []error
203
+ if err := s.exec.shutdown(ctx); err != nil {
204
+ errs = append(errs, err)
205
+ }
206
+ if srv != nil {
207
+ // Streams end once their tasks stop; close whatever is left.
208
+ sctx, cancel := context.WithTimeout(ctx, shutdownTimeout)
209
+ defer cancel()
210
+ if err := srv.Shutdown(sctx); err != nil {
211
+ _ = srv.Close()
212
+ }
213
+ // http.Server closes only the listeners its Serve goroutine has already registered. If Shutdown wins the
214
+ // race with that goroutine's start, the port would stay open until it ran, so close it here as well
215
+ // (a second close is harmless) and wait for Serve to return.
216
+ _ = ln.Close()
217
+ <-served
218
+ }
219
+ return errors.Join(errs...)
220
+ }
221
+
222
+ // guard is the call interceptor that enforces the pinned protocol version, attaches the
223
+ // authenticated principal and keeps callers inside their tenant.
224
+ type guard struct {
225
+ store taskstore.Store
226
+ a2asrv.PassthroughCallInterceptor
227
+ }
228
+
229
+ func (g guard) Before(ctx context.Context, callCtx *a2asrv.CallContext, req *a2asrv.Request) (context.Context, any, error) {
230
+ if v, ok := callCtx.ServiceParams().Get(a2a.SvcParamVersion); !ok || !versionsOK(v) {
231
+ return ctx, nil, a2a.ErrVersionNotSupported
232
+ }
233
+ p, ok := PrincipalFrom(ctx)
234
+ if !ok {
235
+ return ctx, nil, a2a.ErrUnauthenticated
236
+ }
237
+ if t := callCtx.Tenant(); t != "" && t != p.Tenant {
238
+ return ctx, nil, a2a.ErrUnauthorized
239
+ }
240
+ callCtx.User = a2asrv.NewAuthenticatedUser(p.Key(), map[string]any{"name": p.Name, "tenant": p.Tenant})
241
+ if sub, ok := req.Payload.(*a2a.SubscribeToTaskRequest); ok && sub != nil && g.store != nil {
242
+ // Ownership is masked as "not found", as the spec's task lookups are (§3.3.2).
243
+ if _, err := g.store.Get(ctx, sub.ID); err != nil {
244
+ return ctx, nil, a2a.ErrTaskNotFound
245
+ }
246
+ }
247
+ return ctx, nil, nil
248
+ }
249
+
250
+ // versionsOK accepts the header when every value it carries is 1.0. kagent's client sends the
251
+ // header twice (the SDK's own and its static-header interceptor's), which HTTP folds into "1.0, 1.0".
252
+ func versionsOK(values []string) bool {
253
+ n := 0
254
+ for _, v := range values {
255
+ for _, one := range strings.Split(v, ",") {
256
+ if !versionOK(strings.TrimSpace(one)) {
257
+ return false
258
+ }
259
+ n++
260
+ }
261
+ }
262
+ return n > 0
263
+ }
264
+
265
+ func versionOK(v string) bool {
266
+ return v == string(ProtocolVersion) || strings.HasPrefix(v, string(ProtocolVersion)+".")
267
+ }