Skip to content

Commit 09e6e9e

Browse files
authored
Merge pull request #361 from aojea/samone
sambox: an agent serves only itself, over A2A, contracted statically
2 parents de96818 + d1e8726 commit 09e6e9e

32 files changed

Lines changed: 956 additions & 1434 deletions

.github/k8s/sam-box-canary-template.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ spec:
147147
echo "the sandbox's interfaces (expect lo and tun0 only): $(ip -o link show | cut -d: -f2 | tr -d ' ' | paste -sd,)"
148148
while true; do
149149
echo "allowlisted destination (expect 200): $(curl -s -o /dev/null -w '%{http_code}' http://example.com/)"
150-
echo "the node's own API (expect 403): $(curl -s -o /dev/null -w '%{http_code}' http://mesh.sam.alt/sam/service/register)"
150+
echo "the node's own API (expect 403): $(curl -s -o /dev/null -w '%{http_code}' http://mesh.sam.alt/sam/service/discover)"
151151
curl -s -o /dev/null http://blocked.example/ && echo "unlisted destination was NOT refused" || echo "unlisted destination refused (expected)"
152152
sleep 30
153153
done

cmd/sam-box/main.go

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ func main() {
5151
logLevel string
5252

5353
agentIngressSocket string
54+
ingressListen string
5455
)
5556

5657
rootCmd := &cobra.Command{
@@ -78,12 +79,12 @@ func main() {
7879
if err := verifyBundleCredential(cmd.Context(), bundlePath, issuer, audience, insecure); err != nil {
7980
return err
8081
}
81-
ingress, err := resolveIngress(bundlePath, sidecarSocket, agentIngressSocket)
82+
ingress, err := resolveIngress(bundlePath, ingressListen, agentIngressSocket)
8283
if err != nil {
8384
return err
8485
}
8586
if ingress != nil {
86-
defer ingress.Close(context.WithoutCancel(cmd.Context()))
87+
defer ingress.Close()
8788
}
8889

8990
listener, err := sambox.ListenSandboxSocket(sandboxSocket)
@@ -107,7 +108,6 @@ func main() {
107108
Router: &sambox.Router{Egress: egress},
108109
SidecarSocket: sidecarSocket,
109110
AgentID: agentID,
110-
Ingress: ingress,
111111
},
112112
}
113113

@@ -136,6 +136,7 @@ func main() {
136136
runCmd.Flags().BoolVar(&insecure, "insecure-unverified-bundle", false, "Trust the bundle's declared identity without a credential to back it, letting whoever can write the file decide which agent this sandbox is")
137137
runCmd.Flags().StringVar(&metricsAddr, "metrics-addr", "", "Serve unauthenticated Prometheus metrics on this address, e.g. 127.0.0.1:9600; off by default")
138138
runCmd.Flags().StringVar(&agentIngressSocket, "agent-ingress-socket", "", "Path to the sandbox's reverse channel, served by nano-init --ingress-socket; required to reach an agent that serves the mesh, because an isolated sandbox cannot be dialled")
139+
runCmd.Flags().StringVar(&ingressListen, "ingress-listen", "127.0.0.1:7080", "Stable address the gateway's mesh-facing ingress listens on; the node's configuration declares services with this address as their backend")
139140
runCmd.Flags().StringVar(&logLevel, "log-level", "info", "Log level (debug, info, warn, error)")
140141
for _, required := range []string{"socket", "sidecar-socket"} {
141142
if err := runCmd.MarkFlagRequired(required); err != nil {
@@ -222,31 +223,39 @@ func verifyBundleCredential(ctx context.Context, bundlePath, issuer, audience st
222223

223224
// resolveIngress prepares what the agent is permitted to serve. Nil means
224225
// nothing, which is the case for a sandbox that only calls out.
225-
func resolveIngress(bundlePath, sidecarSocket, agentIngressSocket string) (*sambox.IngressManager, error) {
226+
func resolveIngress(bundlePath, ingressListen, agentIngressSocket string) (*sambox.IngressManager, error) {
226227
if bundlePath == "" {
227228
return nil, nil
228229
}
229230
bundle, err := sambox.LoadAgentBundle(bundlePath)
230231
if err != nil {
231232
return nil, err
232233
}
233-
if len(bundle.Ingress) == 0 {
234+
if bundle.Serves == nil {
234235
return nil, nil
235236
}
236237
if agentIngressSocket == "" {
237238
// Refused rather than degraded. Without a channel into the sandbox the
238239
// only address left is one in this process's network namespace, which
239240
// is the pod's: the node's API and every sidecar are on that loopback,
240241
// and the port would be the agent's to choose.
241-
return nil, fmt.Errorf("agent %s may serve %d mesh service(s), but --agent-ingress-socket is not set. "+
242+
return nil, fmt.Errorf("agent %s serves a2a://%s, but --agent-ingress-socket is not set. "+
242243
"Point it at the path nano-init --ingress-socket serves; without it there is no way into the "+
243244
"sandbox, and delivering to this process's own network namespace would reach the gateway's "+
244-
"neighbours instead of the agent", bundle.Agent.ID, len(bundle.Ingress))
245-
}
246-
logger.Infof("Agent %s may serve %d mesh service(s) once it announces them", bundle.Agent.ID, len(bundle.Ingress))
247-
return &sambox.IngressManager{
248-
SidecarSocket: sidecarSocket,
249-
Allowed: bundle.Ingress,
250-
AgentSocket: agentIngressSocket,
251-
}, nil
245+
"neighbours instead of the agent", bundle.Agent.ID, bundle.Serves.Name)
246+
}
247+
manager := &sambox.IngressManager{
248+
ListenAddr: ingressListen,
249+
Serves: *bundle.Serves,
250+
AgentSocket: agentIngressSocket,
251+
}
252+
// The routes are the bundle's contract and exist from startup; the node's
253+
// config declares services backed by this address, and its backend probe
254+
// fails until the agent actually binds its contracted port.
255+
addr, err := manager.Start()
256+
if err != nil {
257+
return nil, fmt.Errorf("serving ingress on %s: %w", ingressListen, err)
258+
}
259+
logger.Infof("Agent %s serves a2a://%s; ingress at http://%s", bundle.Agent.ID, bundle.Serves.Name, addr)
260+
return manager, nil
252261
}

internal/node/a2a_service.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,36 @@ func (s *A2AService) Init(ctx context.Context) error {
5656
return nil
5757
}
5858

59+
// Probe asks the backend for its agent card, which is the protocol's own
60+
// definition of ready: an A2A agent is up exactly when it serves its card.
61+
// Gating advertisement on it lets a service be declared before its agent is
62+
// (a sandbox that has not bound its port yet probes as down and stays out of
63+
// discovery). Deliberately not cached, like the MCP probe.
64+
func (s *A2AService) Probe(ctx context.Context) error {
65+
target, ok := s.backend.(*api.RegisterServiceRequest_TargetUrl)
66+
if !ok {
67+
return fmt.Errorf("a2a service %q has no URL backend to probe", s.info.GetName())
68+
}
69+
cardURL := strings.TrimSuffix(target.TargetUrl, "/") + "/.well-known/agent-card.json"
70+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cardURL, nil)
71+
if err != nil {
72+
return err
73+
}
74+
resp, err := http.DefaultClient.Do(req)
75+
if err != nil {
76+
return fmt.Errorf("fetch agent card of %q: %w", s.info.GetName(), err)
77+
}
78+
defer func() { _ = resp.Body.Close() }()
79+
if resp.StatusCode != http.StatusOK {
80+
return fmt.Errorf("agent card of %q: %s", s.info.GetName(), resp.Status)
81+
}
82+
var card map[string]any
83+
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&card); err != nil {
84+
return fmt.Errorf("agent card of %q is not JSON: %w", s.info.GetName(), err)
85+
}
86+
return nil
87+
}
88+
5989
// a2aEgressGate runs the caller-side A2A checks on a raw egress request:
6090
// the fail-closed labels gate. On refusal it writes the HTTP error itself
6191
// and returns ok=false.

internal/node/a2a_service_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,57 @@ func TestNewServiceFromRequestA2A(t *testing.T) {
6565
}
6666
}
6767

68+
// TestA2AServiceProbe pins advertisement gating on the agent card: an a2a
69+
// service may be declared before its agent is up (a sandbox that has not
70+
// bound its port), and must probe as down until the card is served.
71+
func TestA2AServiceProbe(t *testing.T) {
72+
newSvc := func(target string) *A2AService {
73+
return &A2AService{baseService: baseService{
74+
info: &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_A2A, Name: "agent"},
75+
backend: &api.RegisterServiceRequest_TargetUrl{TargetUrl: target},
76+
}}
77+
}
78+
79+
t.Run("card served means ready", func(t *testing.T) {
80+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
81+
if r.URL.Path != "/.well-known/agent-card.json" {
82+
http.NotFound(w, r)
83+
return
84+
}
85+
_, _ = w.Write([]byte(`{"name":"agent","version":"1.0.0"}`))
86+
}))
87+
defer backend.Close()
88+
if err := newSvc(backend.URL).Probe(context.Background()); err != nil {
89+
t.Fatalf("Probe with a served card: %v", err)
90+
}
91+
})
92+
93+
t.Run("no card means not ready", func(t *testing.T) {
94+
backend := httptest.NewServer(http.NotFoundHandler())
95+
defer backend.Close()
96+
if err := newSvc(backend.URL).Probe(context.Background()); err == nil {
97+
t.Fatal("Probe must fail while the card is not served")
98+
}
99+
})
100+
101+
t.Run("dead backend means not ready", func(t *testing.T) {
102+
// A declared sandbox service whose agent never bound its port.
103+
if err := newSvc("http://127.0.0.1:1").Probe(context.Background()); err == nil {
104+
t.Fatal("Probe must fail when nothing listens")
105+
}
106+
})
107+
108+
t.Run("non-json card means not ready", func(t *testing.T) {
109+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
110+
_, _ = w.Write([]byte("<html>login page</html>"))
111+
}))
112+
defer backend.Close()
113+
if err := newSvc(backend.URL).Probe(context.Background()); err == nil {
114+
t.Fatal("Probe must fail on a non-JSON card")
115+
}
116+
})
117+
}
118+
68119
func TestA2AEgressHookNonA2APassthrough(t *testing.T) {
69120
rec := httptest.NewRecorder()
70121
req := httptest.NewRequest("GET", "/sam/12D3KooWpeer/mcp/svc/foo", nil)

internal/node/node.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1583,6 +1583,15 @@ func (n *SamNode) HandleAuthHandshake(s network.Stream) {
15831583
}
15841584

15851585
func (n *SamNode) RegisterService(ctx context.Context, req *api.RegisterServiceRequest) error {
1586+
if req.Service == nil {
1587+
return fmt.Errorf("service field is required")
1588+
}
1589+
if req.Service.Name == "" || req.Service.Type == api.ServiceType_SERVICE_TYPE_UNSPECIFIED {
1590+
return fmt.Errorf("service name and type are required")
1591+
}
1592+
if req.Backend == nil {
1593+
return fmt.Errorf("service backend is required")
1594+
}
15861595
svc, err := NewServiceFromRequest(req)
15871596
if err != nil {
15881597
return err

internal/node/sidecar.go

Lines changed: 4 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import (
2323
"encoding/base64"
2424
"encoding/json"
2525
"fmt"
26-
"io"
2726
"net"
2827
"net/http"
2928
"net/http/httputil"
@@ -38,7 +37,6 @@ import (
3837
"github.com/libp2p/go-libp2p/core/network"
3938
"github.com/libp2p/go-libp2p/core/peer"
4039
"github.com/prometheus/client_golang/prometheus/promhttp"
41-
"google.golang.org/protobuf/encoding/protojson"
4240
)
4341

4442
// StartSidecarServer serves the node's local API on a TCP address, on a Unix
@@ -57,12 +55,10 @@ func StartSidecarServer(node *SamNode, addr, socketPath, token, certFile, keyFil
5755

5856
// Protected endpoints. allowAuthorizationFallback=true is safe here: none of
5957
// these ever forward the inbound Authorization header to another service.
60-
mux.Handle("/sam/service/register", withAuth(token, true, withMeshConnection(node, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
61-
handleRegisterService(node, w, r)
62-
}))))
63-
mux.Handle("/sam/service/unregister", withAuth(token, true, withMeshConnection(node, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
64-
handleUnregisterService(node, w, r)
65-
}))))
58+
// Services are declared in the node's configuration and registered at
59+
// startup; there is deliberately no runtime registration surface, so no
60+
// credential held by an agent can point the mesh at a new backend or
61+
// withdraw a sibling service.
6662
mux.Handle("/sam/service/discover", withAuth(token, true, withMeshConnection(node, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6763
handleDiscoverService(node, w, r)
6864
}))))
@@ -448,90 +444,10 @@ func constantTimeEqual(got, want string) bool {
448444
return subtle.ConstantTimeCompare(gotHash[:], wantHash[:]) == 1
449445
}
450446

451-
type ServiceRequest struct {
452-
ServiceName string `json:"service_name"`
453-
}
454-
455447
// maxRequestBodyBytes caps request bodies read into memory to guard
456448
// against memory-exhaustion from oversized payloads.
457449
const maxRequestBodyBytes = 1 << 20 // 1 MiB
458450

459-
func handleRegisterService(node *SamNode, w http.ResponseWriter, r *http.Request) {
460-
if r.Method != http.MethodPost {
461-
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
462-
return
463-
}
464-
465-
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
466-
body, err := io.ReadAll(r.Body)
467-
if err != nil {
468-
http.Error(w, "Failed to read request body", http.StatusInternalServerError)
469-
return
470-
}
471-
_ = r.Body.Close()
472-
473-
var req api.RegisterServiceRequest
474-
if err := protojson.Unmarshal(body, &req); err != nil {
475-
http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest)
476-
return
477-
}
478-
479-
if req.Service == nil {
480-
http.Error(w, "service field is required", http.StatusBadRequest)
481-
return
482-
}
483-
484-
if req.Service.Name == "" || req.Service.Type == api.ServiceType_SERVICE_TYPE_UNSPECIFIED {
485-
http.Error(w, "name and type are required", http.StatusBadRequest)
486-
return
487-
}
488-
489-
if req.Backend == nil {
490-
http.Error(w, "backend is required", http.StatusBadRequest)
491-
return
492-
}
493-
494-
if err := node.RegisterService(r.Context(), &req); err != nil {
495-
logger.Errorf("Failed to register service: %v", err)
496-
http.Error(w, fmt.Sprintf("Failed to register service: %v", err), http.StatusInternalServerError)
497-
return
498-
}
499-
500-
w.WriteHeader(http.StatusOK)
501-
if _, err := w.Write([]byte("Service registered")); err != nil {
502-
logger.Errorf("Failed to write response: %v", err)
503-
}
504-
}
505-
506-
func handleUnregisterService(node *SamNode, w http.ResponseWriter, r *http.Request) {
507-
if r.Method != http.MethodPost {
508-
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
509-
return
510-
}
511-
512-
var req api.ServiceInfo
513-
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
514-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
515-
http.Error(w, "Invalid request body", http.StatusBadRequest)
516-
return
517-
}
518-
519-
if req.Name == "" {
520-
http.Error(w, "name is required", http.StatusBadRequest)
521-
return
522-
}
523-
524-
if err := node.UnregisterService(r.Context(), req.Name); err != nil {
525-
http.Error(w, fmt.Sprintf("Failed to unregister service: %v", err), http.StatusInternalServerError)
526-
return
527-
}
528-
529-
w.WriteHeader(http.StatusOK)
530-
if _, err := w.Write([]byte("Service unregistered")); err != nil {
531-
logger.Errorf("Failed to write response: %v", err)
532-
}
533-
}
534-
535451
func handleDiscoverService(node *SamNode, w http.ResponseWriter, r *http.Request) {
536452
if r.Method != http.MethodGet {
537453
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)

0 commit comments

Comments
 (0)