@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
package/CREDITS.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Credits
|
|
2
|
+
|
|
3
|
+
The `a2a` Package is original work by Michael Kinsy (MIT, see [LICENSE](LICENSE)). It is an adapter
|
|
4
|
+
between PiG and the Agent2Agent (A2A) protocol and contains no copied source. It **depends on** and
|
|
5
|
+
links the upstream Go SDK:
|
|
6
|
+
|
|
7
|
+
- **a2a-go**, the A2A Go SDK, `github.com/a2aproject/a2a-go/v2` **v2.6.0**
|
|
8
|
+
(commit `ebf17c56ef7e63c72883a45454a538bbc0df66b8`, tag `v2.6.0`), © The A2A Authors, Linux Foundation,
|
|
9
|
+
licensed under the **Apache License 2.0** ([port/a2a-go-LICENSE](port/a2a-go-LICENSE), unmodified).
|
|
10
|
+
<https://github.com/a2aproject/a2a-go>. The SDK implements the A2A protocol version 1.0
|
|
11
|
+
(<https://a2a-protocol.org>). It is used unmodified: `a2asrv` (server), `a2aclient` and
|
|
12
|
+
`a2aclient/agentcard` (client). Its transitive requirements (`github.com/google/uuid`,
|
|
13
|
+
`golang.org/x/mod`, `golang.org/x/sync`) are recorded in `extensions/a2a/go.mod` and `go.sum`.
|
|
14
|
+
A Piglet Binary that fuses this extension redistributes a2a-go, so the Apache-2.0 text must travel with it.
|
|
15
|
+
|
|
16
|
+
## Interoperability test target
|
|
17
|
+
|
|
18
|
+
The interop test drives **kagent**'s A2A server package (`go/adk/pkg/a2a/server`, Apache-2.0,
|
|
19
|
+
<https://github.com/kagent-dev/kagent>) from a kagent checkout you provide. `scripts/interop-kagent.mjs`
|
|
20
|
+
copies that package into a temporary directory at test time and changes one line (its readiness port);
|
|
21
|
+
nothing of kagent is committed here. `port/interop/kagent/main.go` is original glue code (an echo
|
|
22
|
+
executor around kagent's server).
|
|
23
|
+
|
|
24
|
+
## What is not here
|
|
25
|
+
|
|
26
|
+
No Kubernetes orchestration is ported. kagent is an A2A peer, not a dependency.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Kinsy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# a2a: PiG speaks A2A
|
|
2
|
+
|
|
3
|
+
Serve PiG tasks to [A2A](https://a2a-protocol.org) (Agent2Agent) agents, and call remote A2A agents from PiG.
|
|
4
|
+
Built on the upstream Go SDK [a2a-go](https://github.com/a2aproject/a2a-go) v2.6.0 (Apache-2.0, see
|
|
5
|
+
[CREDITS.md](CREDITS.md)), pinned to **A2A protocol 1.0**, JSON-RPC binding. It interoperates with
|
|
6
|
+
[kagent](https://github.com/kagent-dev/kagent), which speaks the same protocol through the same SDK; Kubernetes
|
|
7
|
+
orchestration is not ported.
|
|
8
|
+
|
|
9
|
+
**The listener is off unless you configure it.** Installing the Package opens no port. It registers three tools and one
|
|
10
|
+
command; the tools do nothing until a remote is configured.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pig install ./components/a2a # builds from source on first use (Go toolchain), or fuses into a Piglet Binary
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Original work (MIT) on a dependency (Apache-2.0). Go only, public PiG SDK, no Node. Not a published release.
|
|
17
|
+
|
|
18
|
+
## Serve PiG tasks
|
|
19
|
+
|
|
20
|
+
Create `$PIG_CODING_AGENT_DIR/a2a.json` (default `~/.pig/agent/a2a.json`; `PIG_A2A_CONFIG` names another file):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"listen": "127.0.0.1:8787",
|
|
25
|
+
"name": "my-pig",
|
|
26
|
+
"tokens": [
|
|
27
|
+
{"name": "ci", "tokenEnv": "A2A_TOKEN_CI", "tenant": "team-a"},
|
|
28
|
+
{"name": "alice", "tokenEnv": "A2A_TOKEN_ALICE"}
|
|
29
|
+
],
|
|
30
|
+
"worker": {"cwd": "/srv/project", "provider": "anthropic", "model": "claude-sonnet-4-5", "passEnv": ["ANTHROPIC_API_KEY"]},
|
|
31
|
+
"maxConcurrentTasks": 4,
|
|
32
|
+
"taskTimeoutSeconds": 900
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`PIG_A2A_LISTEN=host:port` and `pig --a2a-listen host:port` override `listen` (flag over environment over file).
|
|
37
|
+
Tokens are **only** named by environment variable; the file never holds a secret, and an inline `token` key is refused.
|
|
38
|
+
|
|
39
|
+
What the server does:
|
|
40
|
+
|
|
41
|
+
| | |
|
|
42
|
+
|---|---|
|
|
43
|
+
| Endpoint | JSON-RPC 2.0 at `/`, agent card at `/.well-known/agent-card.json` (public; declares the Bearer scheme). Streaming (`SendStreamingMessage`) works; push notifications, gRPC and REST do not. |
|
|
44
|
+
| Protocol | `A2A-Version: 1.0` is required on every call. Any other value, and a missing header (which the specification reads as 0.3), is answered `VERSION_NOT_SUPPORTED` before anything runs. |
|
|
45
|
+
| Tasks | Each A2A task is one turn of a PiG worker: a fresh `pig --mode rpc` process. Text goes in; assistant text streams out as artifact chunks; the task ends `completed`, `failed` or `canceled`. |
|
|
46
|
+
| Context | A `contextId` **is** a PiG session: a later task with the same `contextId` continues the conversation (the session file persists on disk). Tasks of one context run one at a time. |
|
|
47
|
+
| Cancellation | `CancelTask` sends the worker the RPC `abort`, waits for it, then kills the process group after `graceSeconds`. Closing the listener, a reload or quitting PiG cancels every running task. `taskTimeoutSeconds` fails a task that runs too long. |
|
|
48
|
+
| Authentication | Static bearer tokens (`Authorization: Bearer ...`, header only, compared in constant time on SHA-256 digests). No token, no work: a listener needs at least one, or `insecureNoAuth` on a loopback address. An `insecureNoAuth` listener answers only requests whose `Host` is a loopback name or address (`403` otherwise), so a web page that rebinds its own name to `127.0.0.1` cannot drive it. `tls.certFile`/`keyFile` serve HTTPS. |
|
|
49
|
+
| Tenants | Every token is a principal; tokens sharing a `tenant` share tasks and contexts, everything else is its own boundary. Task lists, `GetTask`, `CancelTask`, `SubscribeToTask` and follow-up messages are scoped to it, the session file is derived from `(principal, contextId)` so two tenants that pick the same `contextId` never share a conversation, and a request that names another tenant than its credential's is refused. This boundary is in the protocol only: every worker runs as the same operating-system user, so once you give workers file tools (below) a tenant can read the other tenants' session files. |
|
|
50
|
+
| Safety | The worker runs with **no tools** unless `worker.tools` names some (`pig --no-tools`), no extensions, skills or context files, an empty private working directory unless you set `worker.cwd`, and only an allowlisted environment (provider keys must be named in `passEnv`; the A2A tokens never reach it). It cannot start another listener. Requests are capped at 1 MiB, prompts at 256 KiB. Provider error text is never sent to the peer. |
|
|
51
|
+
|
|
52
|
+
Give the worker tools only with intent. PiG's file tools are **not confined** to `worker.cwd`: they take absolute paths and `~`.
|
|
53
|
+
`"worker": {"tools": ["read", "grep", "find", "ls"]}` lets every token holder read any file the operating-system user running PiG
|
|
54
|
+
can read, including PiG's own credentials (`auth.json`), SSH keys and other tenants' session files; adding `edit`, `write` or
|
|
55
|
+
`bash` lets them change or run anything that user can. Run a listener with tools as a dedicated user (or in a container) that
|
|
56
|
+
holds only the workspace and the provider key, one per trust boundary.
|
|
57
|
+
|
|
58
|
+
`worker.command` (or `PIG_A2A_PIG`) names the pig executable; the default is `pig` on `PATH`. In a Piglet Binary set it to the
|
|
59
|
+
Binary itself. A running server is shown by `/a2a`.
|
|
60
|
+
|
|
61
|
+
## Call remote agents
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"remotes": {
|
|
66
|
+
"kagent": {"url": "http://localhost:8083/api/a2a/kagent/helm-agent", "bearerTokenEnv": "KAGENT_TOKEN", "skipCard": true},
|
|
67
|
+
"review": {"url": "https://agents.example.com/review", "bearerTokenEnv": "REVIEW_TOKEN"}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
| Tool | Use |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `a2a_agents` | List the configured remotes with their cards (name, description, skills). |
|
|
75
|
+
| `a2a_send` `{agent, message, contextId?, taskId?}` | Send a task and wait; returns `state`, `taskId`, `contextId` and the reply. Streams progress. Cancelling the tool call cancels the remote task. |
|
|
76
|
+
| `a2a_task` `{agent, action: get|cancel|list, taskId?}` | Inspect or cancel remote tasks. |
|
|
77
|
+
|
|
78
|
+
`/a2a` shows the listener and the remotes. Credentials: `bearerTokenEnv`, or `headerEnv` (`{"X-Api-Key": "MY_KEY_VAR"}`), both
|
|
79
|
+
environment variable names; a missing variable is an error before any request. Credentials go **only** to the configured origin
|
|
80
|
+
(scheme and host; an `https` remote's token is never sent over `http`): a card that names another origin is refused unless `skipCard` is set (kagent's card carries an in-cluster URL, so use `skipCard` and the
|
|
81
|
+
routable URL). Redirects are not followed. Only A2A 1.0 JSON-RPC interfaces are used; a card that offers only 0.3 is refused.
|
|
82
|
+
Data you send to a remote leaves the machine.
|
|
83
|
+
|
|
84
|
+
## Layout
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
extensions/a2a/ the Go extension (package a2aext): config, auth, worker, executor, server, client, extension; tests
|
|
88
|
+
port/PORT.md contract table, gaps, host findings, proof results (this is an original adapter: there is no Pi oracle)
|
|
89
|
+
port/scenarios,golden host-level scenarios recorded from the port itself under PiG (self-recorded regression, not equivalence)
|
|
90
|
+
port/mutations.json 109 deliberate defects; every one is caught (port/mutation-run.txt)
|
|
91
|
+
port/interop/kagent/ kagent-compatible endpoint used by scripts/interop-kagent.mjs
|
|
92
|
+
port/red.txt the RED run of the tests on a stub
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Tests: `PIG_BIN=pig npm run test:go-ports` (unit and fake-host, `-race`); real PiG end to end:
|
|
96
|
+
`PIG_A2A_E2E_BIN=pig go test -run E2E` in `extensions/a2a` (temporary HOME, PIG_HOME and PIG_CODING_AGENT_DIR, a local fake
|
|
97
|
+
model server); the Piglet Binary hosting the listener: `PIG_A2A_BINARY=<binary built from piglets/a2a> go test -run Binary`; kagent: `KAGENT_GO_DIR=<kagent>/go PIG_BIN=pig node scripts/interop-kagent.mjs`.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
package a2aext
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"crypto/subtle"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"net"
|
|
10
|
+
"net/http"
|
|
11
|
+
"strings"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// Principal is an authenticated caller.
|
|
15
|
+
type Principal struct {
|
|
16
|
+
Name string
|
|
17
|
+
Tenant string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Key is the isolation key for tasks and sessions: every token of one tenant shares
|
|
21
|
+
// it, a token without a tenant is its own boundary, and the two namespaces cannot collide.
|
|
22
|
+
func (p Principal) Key() string {
|
|
23
|
+
if p.Tenant != "" {
|
|
24
|
+
return "tenant:" + p.Tenant
|
|
25
|
+
}
|
|
26
|
+
return "token:" + p.Name
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type tokenEntry struct {
|
|
30
|
+
digest [sha256.Size]byte
|
|
31
|
+
principal Principal
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Authenticator maps bearer tokens to principals.
|
|
35
|
+
type Authenticator struct {
|
|
36
|
+
entries []tokenEntry
|
|
37
|
+
insecure bool
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// NewAuthenticator builds an Authenticator. Tokens are read from the environment variables
|
|
41
|
+
// the configuration names; only their SHA-256 digests are kept.
|
|
42
|
+
func NewAuthenticator(tokens []TokenConfig, getenv func(string) string, insecureNoAuth bool) (*Authenticator, error) {
|
|
43
|
+
if insecureNoAuth {
|
|
44
|
+
return &Authenticator{insecure: true}, nil
|
|
45
|
+
}
|
|
46
|
+
if len(tokens) == 0 {
|
|
47
|
+
return nil, errors.New("a2a: no tokens configured; refusing to build an authenticator that accepts everyone or no one")
|
|
48
|
+
}
|
|
49
|
+
a := &Authenticator{}
|
|
50
|
+
for _, t := range tokens {
|
|
51
|
+
v := getenv(t.TokenEnv)
|
|
52
|
+
if v == "" {
|
|
53
|
+
return nil, fmt.Errorf("a2a: token %q: environment variable %s is not set", t.Name, t.TokenEnv)
|
|
54
|
+
}
|
|
55
|
+
a.entries = append(a.entries, tokenEntry{digest: sha256.Sum256([]byte(v)), principal: Principal{Name: t.Name, Tenant: t.Tenant}})
|
|
56
|
+
}
|
|
57
|
+
return a, nil
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Authenticate returns the caller's principal from the Authorization header. Tokens in
|
|
61
|
+
// URLs are never read: they end up in logs and proxies.
|
|
62
|
+
func (a *Authenticator) Authenticate(r *http.Request) (Principal, bool) {
|
|
63
|
+
if a.insecure {
|
|
64
|
+
return Principal{Name: "anonymous"}, true
|
|
65
|
+
}
|
|
66
|
+
fields := strings.Fields(r.Header.Get("Authorization"))
|
|
67
|
+
if len(fields) != 2 || !strings.EqualFold(fields[0], "bearer") {
|
|
68
|
+
return Principal{}, false
|
|
69
|
+
}
|
|
70
|
+
digest := sha256.Sum256([]byte(fields[1]))
|
|
71
|
+
var found Principal
|
|
72
|
+
ok := false
|
|
73
|
+
for _, e := range a.entries { // no early exit: the time does not depend on which token matched
|
|
74
|
+
if subtle.ConstantTimeCompare(digest[:], e.digest[:]) == 1 {
|
|
75
|
+
found, ok = e.principal, true
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return found, ok
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// loopbackHostHeader reports whether a Host header names a loopback address (localhost, 127.0.0.0/8, ::1).
|
|
82
|
+
func loopbackHostHeader(hostport string) bool {
|
|
83
|
+
host, _, err := net.SplitHostPort(hostport)
|
|
84
|
+
if err != nil {
|
|
85
|
+
host = strings.TrimSuffix(strings.TrimPrefix(hostport, "["), "]")
|
|
86
|
+
}
|
|
87
|
+
return isLoopbackHost(host)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type principalCtxKey struct{}
|
|
91
|
+
|
|
92
|
+
// PrincipalFrom returns the principal Wrap attached to ctx.
|
|
93
|
+
func PrincipalFrom(ctx context.Context) (Principal, bool) {
|
|
94
|
+
p, ok := ctx.Value(principalCtxKey{}).(Principal)
|
|
95
|
+
return p, ok
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Wrap rejects unauthenticated requests with 401 and a Bearer challenge, except those
|
|
99
|
+
// isPublic accepts (the agent card), and passes the principal down in the request context.
|
|
100
|
+
func (a *Authenticator) Wrap(next http.Handler, isPublic func(*http.Request) bool) http.Handler {
|
|
101
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
102
|
+
if a.insecure && !loopbackHostHeader(r.Host) {
|
|
103
|
+
// Without a credential, the Host header is what tells a local client from a web page that rebound
|
|
104
|
+
// its own name to 127.0.0.1 (DNS rebinding); such a page would be same-origin with the listener.
|
|
105
|
+
http.Error(w, "forbidden host", http.StatusForbidden)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if isPublic != nil && isPublic(r) {
|
|
109
|
+
next.ServeHTTP(w, r)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
p, ok := a.Authenticate(r)
|
|
113
|
+
if !ok {
|
|
114
|
+
w.Header().Set("WWW-Authenticate", "Bearer")
|
|
115
|
+
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalCtxKey{}, p)))
|
|
119
|
+
})
|
|
120
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
package a2aext
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"net/http/httptest"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
const (
|
|
10
|
+
tokenA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
11
|
+
tokenB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func testAuth(t *testing.T) *Authenticator {
|
|
15
|
+
t.Helper()
|
|
16
|
+
a, err := NewAuthenticator([]TokenConfig{
|
|
17
|
+
{Name: "alice", TokenEnv: "TOKEN_A", Tenant: "team-a"},
|
|
18
|
+
{Name: "bob", TokenEnv: "TOKEN_B"},
|
|
19
|
+
}, envFrom(map[string]string{"TOKEN_A": tokenA, "TOKEN_B": tokenB}), false)
|
|
20
|
+
if err != nil {
|
|
21
|
+
t.Fatal(err)
|
|
22
|
+
}
|
|
23
|
+
return a
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func request(header string) *http.Request {
|
|
27
|
+
r := httptest.NewRequest(http.MethodPost, "http://x/", nil)
|
|
28
|
+
if header != "" {
|
|
29
|
+
r.Header.Set("Authorization", header)
|
|
30
|
+
}
|
|
31
|
+
return r
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func TestAuthenticateBearer(t *testing.T) {
|
|
35
|
+
a := testAuth(t)
|
|
36
|
+
p, ok := a.Authenticate(request("Bearer " + tokenA))
|
|
37
|
+
if !ok || p.Name != "alice" || p.Tenant != "team-a" {
|
|
38
|
+
t.Fatalf("got %+v %v", p, ok)
|
|
39
|
+
}
|
|
40
|
+
p, ok = a.Authenticate(request("bearer " + tokenB))
|
|
41
|
+
if !ok || p.Name != "bob" {
|
|
42
|
+
t.Fatalf("scheme is case-insensitive: %+v %v", p, ok)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func TestAuthenticateRejects(t *testing.T) {
|
|
47
|
+
a := testAuth(t)
|
|
48
|
+
for name, h := range map[string]string{
|
|
49
|
+
"empty": "", "wrong": "Bearer " + tokenA + "x", "prefix": "Bearer " + tokenA[:10],
|
|
50
|
+
"basic": "Basic " + tokenA, "no scheme": tokenA, "bare bearer": "Bearer ", "two": "Bearer " + tokenA + " " + tokenB,
|
|
51
|
+
} {
|
|
52
|
+
if _, ok := a.Authenticate(request(h)); ok {
|
|
53
|
+
t.Errorf("%s: must not authenticate", name)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func TestTokenInQueryStringIsIgnored(t *testing.T) {
|
|
59
|
+
a := testAuth(t)
|
|
60
|
+
r := httptest.NewRequest(http.MethodPost, "http://x/?access_token="+tokenA, nil)
|
|
61
|
+
if _, ok := a.Authenticate(r); ok {
|
|
62
|
+
t.Fatal("tokens in URLs leak into logs; only the Authorization header counts")
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func TestWrapRejectsWith401AndChallenge(t *testing.T) {
|
|
67
|
+
called := false
|
|
68
|
+
h := testAuth(t).Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }), func(*http.Request) bool { return false })
|
|
69
|
+
rec := httptest.NewRecorder()
|
|
70
|
+
h.ServeHTTP(rec, request(""))
|
|
71
|
+
if rec.Code != http.StatusUnauthorized || called {
|
|
72
|
+
t.Fatalf("code %d called %v", rec.Code, called)
|
|
73
|
+
}
|
|
74
|
+
if got := rec.Header().Get("WWW-Authenticate"); got != "Bearer" {
|
|
75
|
+
t.Fatalf("WWW-Authenticate = %q", got)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func TestWrapPassesPrincipalAndPublicPaths(t *testing.T) {
|
|
80
|
+
var got Principal
|
|
81
|
+
h := testAuth(t).Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
82
|
+
got, _ = PrincipalFrom(r.Context())
|
|
83
|
+
}), func(r *http.Request) bool { return r.URL.Path == "/public" })
|
|
84
|
+
h.ServeHTTP(httptest.NewRecorder(), request("Bearer "+tokenA))
|
|
85
|
+
if got.Name != "alice" {
|
|
86
|
+
t.Fatalf("principal %+v", got)
|
|
87
|
+
}
|
|
88
|
+
got = Principal{}
|
|
89
|
+
rec := httptest.NewRecorder()
|
|
90
|
+
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://x/public", nil))
|
|
91
|
+
if rec.Code != http.StatusOK {
|
|
92
|
+
t.Fatalf("public path code %d", rec.Code)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func TestPrincipalKeysIsolate(t *testing.T) {
|
|
97
|
+
seen := map[string]Principal{}
|
|
98
|
+
for _, p := range []Principal{
|
|
99
|
+
{Name: "alice", Tenant: "team-a"}, {Name: "bob", Tenant: "team-a"}, {Name: "alice"}, {Name: "bob"}, {Name: "team-a"},
|
|
100
|
+
} {
|
|
101
|
+
k := p.Key()
|
|
102
|
+
if k == "" {
|
|
103
|
+
t.Fatalf("%+v has no key", p)
|
|
104
|
+
}
|
|
105
|
+
if other, dup := seen[k]; dup && !(other.Tenant != "" && p.Tenant == other.Tenant) {
|
|
106
|
+
t.Fatalf("%+v and %+v share key %q", p, other, k)
|
|
107
|
+
}
|
|
108
|
+
seen[k] = p
|
|
109
|
+
}
|
|
110
|
+
if (Principal{Name: "alice", Tenant: "t"}).Key() != (Principal{Name: "bob", Tenant: "t"}).Key() {
|
|
111
|
+
t.Fatal("two tokens of one tenant share tasks and contexts")
|
|
112
|
+
}
|
|
113
|
+
if (Principal{Name: "team-a"}).Key() == (Principal{Name: "x", Tenant: "team-a"}).Key() {
|
|
114
|
+
t.Fatal("a token named like a tenant must not collide with the tenant")
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func TestInsecureNoAuthGivesOneAnonymousPrincipal(t *testing.T) {
|
|
119
|
+
a, err := NewAuthenticator(nil, envFrom(nil), true)
|
|
120
|
+
if err != nil {
|
|
121
|
+
t.Fatal(err)
|
|
122
|
+
}
|
|
123
|
+
p, ok := a.Authenticate(request(""))
|
|
124
|
+
if !ok || p.Name != "anonymous" {
|
|
125
|
+
t.Fatalf("%+v %v", p, ok)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func TestNoTokensNoInsecureFlagIsAnError(t *testing.T) {
|
|
130
|
+
if _, err := NewAuthenticator(nil, envFrom(nil), false); err == nil {
|
|
131
|
+
t.Fatal("an authenticator with no tokens would reject everyone or accept everyone; refuse to build it")
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
package a2aext
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"net/http"
|
|
7
|
+
"net/http/httptest"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// A2A request handling without the network: the server's http.Handler driven directly, a worker that answers at
|
|
12
|
+
// once. This is what the listener adds per request (authentication, version check, JSON-RPC, task bookkeeping).
|
|
13
|
+
// The listener is off unless configured; nothing here runs at startup.
|
|
14
|
+
//
|
|
15
|
+
// go test -run xxx -bench . -benchmem
|
|
16
|
+
func benchServer(b *testing.B) *Server {
|
|
17
|
+
b.Helper()
|
|
18
|
+
s, err := NewServer(serverConfig(), &scriptedWorker{}, serverEnv)
|
|
19
|
+
if err != nil {
|
|
20
|
+
b.Fatal(err)
|
|
21
|
+
}
|
|
22
|
+
b.Cleanup(func() { _ = s.Shutdown(context.Background()) })
|
|
23
|
+
return s
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func BenchmarkSendMessage(b *testing.B) {
|
|
27
|
+
s := benchServer(b)
|
|
28
|
+
h := s.Handler()
|
|
29
|
+
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"hello there"}]}}}`)
|
|
30
|
+
b.ReportAllocs()
|
|
31
|
+
b.ResetTimer()
|
|
32
|
+
for i := 0; i < b.N; i++ {
|
|
33
|
+
r := httptest.NewRequest(http.MethodPost, "http://x/", bytes.NewReader(body))
|
|
34
|
+
r.Header.Set("Content-Type", "application/json")
|
|
35
|
+
r.Header.Set("Authorization", "Bearer "+tokenA)
|
|
36
|
+
r.Header.Set("A2A-Version", "1.0")
|
|
37
|
+
w := httptest.NewRecorder()
|
|
38
|
+
h.ServeHTTP(w, r)
|
|
39
|
+
if w.Code != http.StatusOK {
|
|
40
|
+
b.Fatalf("status %d: %s", w.Code, w.Body.String())
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func BenchmarkAuthenticate(b *testing.B) {
|
|
46
|
+
a, err := NewAuthenticator(serverConfig().Tokens, serverEnv, false)
|
|
47
|
+
if err != nil {
|
|
48
|
+
b.Fatal(err)
|
|
49
|
+
}
|
|
50
|
+
r := httptest.NewRequest(http.MethodPost, "http://x/", nil)
|
|
51
|
+
r.Header.Set("Authorization", "Bearer "+tokenB)
|
|
52
|
+
b.ReportAllocs()
|
|
53
|
+
for i := 0; i < b.N; i++ {
|
|
54
|
+
if _, ok := a.Authenticate(r); !ok {
|
|
55
|
+
b.Fatal("rejected")
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func BenchmarkAgentCard(b *testing.B) {
|
|
61
|
+
h := benchServer(b).Handler()
|
|
62
|
+
b.ReportAllocs()
|
|
63
|
+
for i := 0; i < b.N; i++ {
|
|
64
|
+
w := httptest.NewRecorder()
|
|
65
|
+
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://x/.well-known/agent-card.json", nil))
|
|
66
|
+
if w.Code != http.StatusOK {
|
|
67
|
+
b.Fatalf("status %d", w.Code)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|