@@ -29,6 +29,7 @@ import { after, before, test } from "node:test";
2929import { loadBiscuit } from "./biscuit.ts" ;
3030import { ROLE_NODE } from "./controlplane.ts" ;
3131import {
32+ AGENT_CARD_PATH ,
3233 HTTP_PROTOCOL ,
3334 a2aEndpoint ,
3435 admitIngress ,
@@ -37,6 +38,7 @@ import {
3738 httpRequestOverStream ,
3839 meshHTTPTarget ,
3940 meshURL ,
41+ rewriteAgentCard ,
4042 splitMeshURL ,
4143 type ProviderOptions ,
4244} from "./libp2p-http.ts" ;
@@ -55,7 +57,7 @@ let callerBiscuit: Uint8Array;
5557let guestBiscuit : Uint8Array ;
5658let backend : http . Server ;
5759let backendURL : string ;
58- const backendSeen : { method : string ; url : string ; peer : string | undefined ; body : string } [ ] = [ ] ;
60+ const backendSeen : { method : string ; url : string ; peer : string | undefined ; body : string ; encoding : string | undefined } [ ] = [ ] ;
5961const listenerSeen : { url : string ; peer : string | undefined ; biscuit : string | undefined } [ ] = [ ] ;
6062const authorized : string [ ] = [ ] ;
6163
@@ -98,14 +100,40 @@ function providerOptions(biscuit: Uint8Array): ProviderOptions {
98100 } ;
99101}
100102
103+ const STOCK_CARD = {
104+ name : "echo-agent" ,
105+ version : "1.0.0" ,
106+ capabilities : { streaming : true , pushNotifications : false } ,
107+ supportedInterfaces : [
108+ { url : "http://127.0.0.1:7777/" , protocolBinding : "JSONRPC" , protocolVersion : "1.0" } ,
109+ { url : "127.0.0.1:50051" , protocolBinding : "GRPC" , protocolVersion : "1.0" } ,
110+ ] ,
111+ signatures : [ { protected : "eyJhbGciOiJFUzI1NiJ9" , signature : "c3RhbGU" } ] ,
112+ skills : [ ] ,
113+ defaultInputModes : [ "text/plain" ] ,
114+ defaultOutputModes : [ "text/plain" ] ,
115+ } ;
116+
101117// Stands in for an A2A server beside the agent: echoes the request and, on
102118// /stream, answers with three SSE events as message/stream would.
103119function fakeA2AServer ( req : http . IncomingMessage , res : http . ServerResponse ) : void {
104120 let body = "" ;
105121 req . on ( "data" , ( c : Buffer ) => ( body += c . toString ( ) ) ) ;
106122 req . on ( "end" , ( ) => {
107- backendSeen . push ( { method : req . method ?? "" , url : req . url ?? "" , peer : req . headers [ "x-peer-id" ] as string | undefined , body } ) ;
123+ backendSeen . push ( { method : req . method ?? "" , url : req . url ?? "" , peer : req . headers [ "x-peer-id" ] as string | undefined , body, encoding : req . headers [ "accept-encoding" ] as string | undefined } ) ;
108124 assert . equal ( req . headers [ "x-sam-biscuit" ] , undefined , "biscuit leaked to the backend" ) ;
125+ if ( req . url === `/${ AGENT_CARD_PATH } ` && req . headers [ "x-card" ] === "missing" ) {
126+ res . writeHead ( 404 , { "content-type" : "text/plain" } ) ;
127+ res . end ( "no card" ) ;
128+ return ;
129+ }
130+ if ( req . url === `/${ AGENT_CARD_PATH } ` ) {
131+ const grpcOnly = req . headers [ "x-card" ] === "grpc-only" ;
132+ const supportedInterfaces = STOCK_CARD . supportedInterfaces . filter ( ( i ) => ! grpcOnly || i . protocolBinding === "GRPC" ) ;
133+ res . writeHead ( 200 , { "content-type" : "application/json" } ) ;
134+ res . end ( JSON . stringify ( { ...STOCK_CARD , supportedInterfaces } ) ) ;
135+ return ;
136+ }
109137 if ( req . url === "/stream" ) {
110138 res . writeHead ( 200 , { "content-type" : "text/event-stream" } ) ;
111139 let i = 0 ;
@@ -242,6 +270,49 @@ test("a fetch over the stream delivers an SSE body event by event", async () =>
242270 ) ;
243271} ) ;
244272
273+ test ( "the card of an agent behind a node comes back rewritten for the mesh, as sam-node serves it" , async ( ) => {
274+ const conn = await dial ( ) ;
275+ const base = meshURL ( agent . peerId . toString ( ) , "a2a://agent" ) ;
276+ const response = await fetchOverStream ( conn , callerBiscuit , new Request ( `${ base } /${ AGENT_CARD_PATH } ` ) ) ;
277+ assert . equal ( response . status , 200 ) ;
278+ assert . equal ( response . headers . get ( "content-type" ) , "application/json" ) ;
279+ const card = ( await response . json ( ) ) as Record < string , unknown > ;
280+ assert . deepEqual ( card . supportedInterfaces , [ { url : base , protocolBinding : "JSONRPC" , protocolVersion : "1.0" } ] ) ;
281+ assert . deepEqual ( card . capabilities , { streaming : true , pushNotifications : false } ) ;
282+ assert . equal ( "signatures" in card , false ) ;
283+ assert . equal ( card . name , "echo-agent" ) ;
284+ assert . deepEqual ( card . skills , [ ] ) ;
285+ assert . equal ( backendSeen [ backendSeen . length - 1 ] ?. url , `/${ AGENT_CARD_PATH } ` ) ;
286+
287+ // The bare service root serves the card too, the way a2a-go resolves a pathful base URL.
288+ assert . deepEqual ( await ( await fetchOverStream ( conn , callerBiscuit , new Request ( base ) ) ) . json ( ) , card ) ;
289+ assert . equal ( backendSeen [ backendSeen . length - 1 ] ?. url , `/${ AGENT_CARD_PATH } ` ) ;
290+ await fetchOverStream ( conn , callerBiscuit , new Request ( `${ base } /${ AGENT_CARD_PATH } ` , { headers : { "accept-encoding" : "x-test-only" } } ) ) ;
291+ assert . notEqual ( backendSeen [ backendSeen . length - 1 ] ?. encoding , "x-test-only" , "the client's accept-encoding reached the agent" ) ;
292+
293+ const missing = await fetchOverStream ( conn , callerBiscuit , new Request ( `${ base } /${ AGENT_CARD_PATH } ` , { headers : { "x-card" : "missing" } } ) ) ;
294+ assert . equal ( missing . status , 404 ) ;
295+ assert . equal ( await missing . text ( ) , "no card" ) ;
296+
297+ const raw = await httpRequestOverStream ( conn , callerBiscuit , "a2a://agent" , `/${ AGENT_CARD_PATH } ` ) ;
298+ assert . deepEqual ( JSON . parse ( raw . text ( ) ) , card ) ;
299+
300+ const refused = await fetchOverStream ( conn , callerBiscuit , new Request ( `${ base } /${ AGENT_CARD_PATH } ` , { headers : { "x-card" : "grpc-only" } } ) ) ;
301+ assert . equal ( refused . status , 502 ) ;
302+ assert . match ( await refused . text ( ) , / n o s u p p o r t e d i n t e r f a c e t h e m e s h c a n c a r r y / ) ;
303+
304+ const other = await fetchOverStream ( conn , callerBiscuit , new Request ( `${ base } /card` ) ) ;
305+ assert . deepEqual ( await other . json ( ) , { path : "/card" , echo : "" } ) ;
306+ } ) ;
307+
308+ test ( "rewriteAgentCard keeps only what the mesh carries" , ( ) => {
309+ assert . throws ( ( ) => rewriteAgentCard ( [ ] , "http://mesh/x" ) , / n o t a J S O N o b j e c t / ) ;
310+ assert . throws ( ( ) => rewriteAgentCard ( { supportedInterfaces : [ ] } , "http://mesh/x" ) , / n o s u p p o r t e d i n t e r f a c e / ) ;
311+ assert . deepEqual ( rewriteAgentCard ( { supportedInterfaces : [ { url : "x" , protocolBinding : "http+json" } ] } , "http://mesh/x" ) , {
312+ supportedInterfaces : [ { url : "http://mesh/x" , protocolBinding : "http+json" } ] ,
313+ } ) ;
314+ } ) ;
315+
245316test ( "a fetch handler sees the caller and the path, and its streaming body goes out as it is written" , async ( ) => {
246317 const conn = await dial ( handlerAgent ) ;
247318 const res = await httpRequestOverStream ( conn , callerBiscuit , "a2a://agent" , "/tasks?x=1" , { method : "POST" , headers : { "x-sam-biscuit" : "spoof" , "content-type" : "text/plain" } , body : "hello" } ) ;
0 commit comments