Skip to content

Commit ae97f2a

Browse files
committed
sambox: an agent serves only itself, over A2A, contracted statically
Runtime service declaration is gone from the sandbox boundary. The bundle now contracts at most one thing - the agent's own a2a service name and the sandbox port it must bind (like $PORT on a serverless runtime) - and the gateway routes it from startup, never talking to the node. The operator declares the a2a:// service in the node's configuration with the gateway's stable --ingress-listen address as its backend. Everything that made runtime declaration feel necessary lives at its proper layer instead: capabilities and dynamic behaviour are the agent card and A2A traffic inside the route; readiness is the node's new card-fetch probe (an A2A agent is up exactly when it serves its card), so a declared-but-unbound sandbox stays out of discovery and a moved agent re-points via probe-gated advertisement rather than re-registration. Tools (mcp://) and models (inference://) remain operator node services, never agent ingress. The CUJ now runs the stock a2a-go SDK on both ends: an unmodified agent server in the sandbox and an unmodified client bootstrapping from the mesh-regenerated card.
1 parent 9850256 commit ae97f2a

9 files changed

Lines changed: 328 additions & 518 deletions

File tree

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/sambox/bundle.go

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,22 +39,26 @@ type AgentBundle struct {
3939
Agent AgentIdentity `yaml:"agent"`
4040
Egress BundleEgress `yaml:"egress"`
4141

42-
// Ingress is what this agent is permitted to serve, not what it is
43-
// currently serving. The agent says when it is ready, and on which port,
44-
// through the gateway's ingress endpoint; the platform decides only which
45-
// names it may claim.
46-
Ingress []BundleIngress `yaml:"ingress"`
42+
// Serves is the one mesh service this agent provides: itself, as an A2A
43+
// agent. The name is the platform's grant and the port is its contract
44+
// with the agent (like $PORT on a serverless runtime); the agent binds it
45+
// when ready, and everything else about serving -- capabilities, skills,
46+
// negotiation -- lives on the agent's own card, inside the A2A protocol.
47+
// Tools (mcp://) and models (inference://) are operator workloads declared
48+
// in a node's configuration, never agent ingress.
49+
Serves *BundleServes `yaml:"serves,omitempty"`
4750

4851
// egress is the compiled form of Egress.Allow, built during loading so a
4952
// malformed allowlist fails at startup rather than on an agent's first
5053
// request.
5154
egress *EgressPolicy
5255
}
5356

54-
// BundleIngress is one mesh service the agent may advertise.
55-
type BundleIngress struct {
57+
// BundleServes contracts the agent's own a2a service: its mesh name and the
58+
// sandbox port it must bind.
59+
type BundleServes struct {
5660
Name string `yaml:"name"`
57-
Type string `yaml:"type"`
61+
Port int `yaml:"port"`
5862
}
5963

6064
// AgentIdentity names the principal the gateway asserts for this sandbox.
@@ -106,12 +110,12 @@ func LoadAgentBundle(path string) (*AgentBundle, error) {
106110
}
107111
bundle.egress = policy
108112

109-
for i, ingress := range bundle.Ingress {
110-
if _, err := api.ParseServiceType(ingress.Type); err != nil {
111-
return nil, fmt.Errorf("agent bundle %s: ingress %d: %w", path, i, err)
113+
if bundle.Serves != nil {
114+
if err := api.ValidateServiceFormat("a2a://" + bundle.Serves.Name); err != nil {
115+
return nil, fmt.Errorf("agent bundle %s: serves: %w", path, err)
112116
}
113-
if err := api.ValidateServiceFormat(ingress.Type + "://" + ingress.Name); err != nil {
114-
return nil, fmt.Errorf("agent bundle %s: ingress %d: %w", path, i, err)
117+
if bundle.Serves.Port < 1 || bundle.Serves.Port > 65535 {
118+
return nil, fmt.Errorf("agent bundle %s: serves: port %d is not a port", path, bundle.Serves.Port)
115119
}
116120
}
117121

internal/sambox/dial.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,6 @@ type AgentDialer struct {
4646
// unidentified, and mesh policy sees only the node it came through.
4747
AgentID string
4848

49-
// Ingress serves what the agent is permitted to advertise. Nil means the
50-
// agent may serve nothing, which is the case for a sandbox that only calls
51-
// out.
52-
Ingress *IngressManager
53-
5449
// DialContext opens external destinations. Nil uses a plain net.Dialer;
5550
// tests and future egress interception replace it.
5651
DialContext func(ctx context.Context, network, address string) (net.Conn, error)

internal/sambox/entrypoint.go

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,9 @@ import (
4343
// Discovery is not on the list even though agents need it: it is already
4444
// available through MCP as find_remote_tools and discover_remote_services, so
4545
// exposing /sam/service/discover as well would widen the surface without adding
46-
// a capability. Registration is not on the list at all — an agent that could
47-
// register would advertise itself into the mesh under the node's identity, and
48-
// choose the target_url the mesh then routes to. What an agent may serve is
49-
// declared by the platform in its bundle, and announced through the gateway's
50-
// own /ingress endpoint, which never reaches the node.
46+
// a capability. Serving is not on the list at all — what an agent serves is
47+
// declared by the platform in its bundle and by the operator in the node's
48+
// configuration, and the agent's only part is binding its contracted port.
5149
func agentMayReach(path string) bool {
5250
switch path {
5351
case "/v1/models", "/v1/chat/completions", "/v1/completions":
@@ -56,9 +54,6 @@ func agentMayReach(path string) bool {
5654
return path == "/mcp" || strings.HasPrefix(path, "/mcp/")
5755
}
5856

59-
// ingressPath is served by the gateway itself rather than proxied.
60-
const ingressPath = "/ingress"
61-
6257
// dialMeshEntrypoint returns a connection serving the agent-facing surface.
6358
func (d *AgentDialer) dialMeshEntrypoint() (net.Conn, error) {
6459
if d.SidecarSocket == "" {
@@ -78,22 +73,9 @@ func (d *AgentDialer) entrypointHandler() http.Handler {
7873
Transport: d.sidecarTransport(),
7974
}
8075

81-
var ingress http.Handler
82-
if d.Ingress != nil {
83-
ingress = d.Ingress.Handler()
84-
}
85-
8676
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87-
if r.URL.Path == ingressPath {
88-
if ingress == nil {
89-
http.Error(w, "this agent was granted nothing to serve", http.StatusForbidden)
90-
return
91-
}
92-
ingress.ServeHTTP(w, r)
93-
return
94-
}
9577
if !agentMayReach(r.URL.Path) {
96-
http.Error(w, "the mesh entrypoint serves /v1, /mcp and /ingress only", http.StatusForbidden)
78+
http.Error(w, "the mesh entrypoint serves /v1 and /mcp only", http.StatusForbidden)
9779
return
9880
}
9981
proxy.ServeHTTP(w, r)

0 commit comments

Comments
 (0)