diff --git a/api/agentmesh.pb.go b/api/agentmesh.pb.go index a5fae49cb..0a42d4214 100644 --- a/api/agentmesh.pb.go +++ b/api/agentmesh.pb.go @@ -384,7 +384,7 @@ func (x ExtProcProcessingMode_HeaderMode) Number() protoreflect.EnumNumber { // Deprecated: Use ExtProcProcessingMode_HeaderMode.Descriptor instead. func (ExtProcProcessingMode_HeaderMode) EnumDescriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{25, 0} + return file_api_agentmesh_proto_rawDescGZIP(), []int{30, 0} } type ExtProcProcessingMode_BodyMode int32 @@ -439,7 +439,7 @@ func (x ExtProcProcessingMode_BodyMode) Number() protoreflect.EnumNumber { // Deprecated: Use ExtProcProcessingMode_BodyMode.Descriptor instead. func (ExtProcProcessingMode_BodyMode) EnumDescriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{25, 1} + return file_api_agentmesh_proto_rawDescGZIP(), []int{30, 1} } type AuthFrame struct { @@ -1338,115 +1338,6 @@ func (x *DiscoveredProvider) GetSrvDescription() string { return "" } -// ServiceAnnounce is gossiped by a node on per-key topics (see -// DiscoveryTopic) while those topics have subscribers. It is a routing -// hint signed at the pubsub layer by the announcing peer: consumers use -// it for freshness and load awareness, never for authorization. -type ServiceAnnounce struct { - state protoimpl.MessageState `protogen:"open.v1"` - PeerId string `protobuf:"bytes,1,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` - Type ServiceType `protobuf:"varint,2,opt,name=type,proto3,enum=agentmesh.v1.ServiceType" json:"type,omitempty"` - ServiceName string `protobuf:"bytes,3,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` - // Routing keys served by this service: model IDs for inference, - // tool names for MCP. - Keys []string `protobuf:"bytes,4,rep,name=keys,proto3" json:"keys,omitempty"` - // Operator-declared labels (e.g. "region"). Operator claims always - // take precedence over runtime-derived values. - Labels map[string]string `protobuf:"bytes,5,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - // Runtime load hints; zero values mean unknown. - ActiveRequests uint32 `protobuf:"varint,6,opt,name=active_requests,json=activeRequests,proto3" json:"active_requests,omitempty"` - LatencyEwmaMs float64 `protobuf:"fixed64,7,opt,name=latency_ewma_ms,json=latencyEwmaMs,proto3" json:"latency_ewma_ms,omitempty"` - AnnounceTime *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=announce_time,json=announceTime,proto3" json:"announce_time,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ServiceAnnounce) Reset() { - *x = ServiceAnnounce{} - mi := &file_api_agentmesh_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ServiceAnnounce) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ServiceAnnounce) ProtoMessage() {} - -func (x *ServiceAnnounce) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[12] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ServiceAnnounce.ProtoReflect.Descriptor instead. -func (*ServiceAnnounce) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{12} -} - -func (x *ServiceAnnounce) GetPeerId() string { - if x != nil { - return x.PeerId - } - return "" -} - -func (x *ServiceAnnounce) GetType() ServiceType { - if x != nil { - return x.Type - } - return ServiceType_SERVICE_TYPE_UNSPECIFIED -} - -func (x *ServiceAnnounce) GetServiceName() string { - if x != nil { - return x.ServiceName - } - return "" -} - -func (x *ServiceAnnounce) GetKeys() []string { - if x != nil { - return x.Keys - } - return nil -} - -func (x *ServiceAnnounce) GetLabels() map[string]string { - if x != nil { - return x.Labels - } - return nil -} - -func (x *ServiceAnnounce) GetActiveRequests() uint32 { - if x != nil { - return x.ActiveRequests - } - return 0 -} - -func (x *ServiceAnnounce) GetLatencyEwmaMs() float64 { - if x != nil { - return x.LatencyEwmaMs - } - return 0 -} - -func (x *ServiceAnnounce) GetAnnounceTime() *timestamppb.Timestamp { - if x != nil { - return x.AnnounceTime - } - return nil -} - type ControlPlaneInfoResponse struct { state protoimpl.MessageState `protogen:"open.v1"` OidcIssuer string `protobuf:"bytes,1,opt,name=oidc_issuer,json=oidcIssuer,proto3" json:"oidc_issuer,omitempty"` @@ -1479,7 +1370,7 @@ type ControlPlaneInfoResponse struct { func (x *ControlPlaneInfoResponse) Reset() { *x = ControlPlaneInfoResponse{} - mi := &file_api_agentmesh_proto_msgTypes[13] + mi := &file_api_agentmesh_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1491,7 +1382,7 @@ func (x *ControlPlaneInfoResponse) String() string { func (*ControlPlaneInfoResponse) ProtoMessage() {} func (x *ControlPlaneInfoResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[13] + mi := &file_api_agentmesh_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1504,7 +1395,7 @@ func (x *ControlPlaneInfoResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ControlPlaneInfoResponse.ProtoReflect.Descriptor instead. func (*ControlPlaneInfoResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{13} + return file_api_agentmesh_proto_rawDescGZIP(), []int{12} } func (x *ControlPlaneInfoResponse) GetOidcIssuer() string { @@ -1561,9 +1452,9 @@ func (x *ControlPlaneInfoResponse) GetDirectory() *DirectoryParameters { // --dht-replication, --dht-reprovide-interval) and published on /info. type DirectoryParameters struct { state protoimpl.MessageState `protogen:"open.v1"` - // How many routers hold each record a member publishes and answer each - // lookup it makes: the routers closest to the key. A member holds a - // session with each of them besides the routers it is attached to. + // How many routers each record a member announces is placed on, the + // closest to its key, besides the router that took the announcement. + // Routers read it; a member asks the routers it holds. Replication int32 `protobuf:"varint,1,opt,name=replication,proto3" json:"replication,omitempty"` // How often a member re-announces each service it publishes. ReprovideInterval *durationpb.Duration `protobuf:"bytes,2,opt,name=reprovide_interval,json=reprovideInterval,proto3" json:"reprovide_interval,omitempty"` @@ -1578,7 +1469,7 @@ type DirectoryParameters struct { func (x *DirectoryParameters) Reset() { *x = DirectoryParameters{} - mi := &file_api_agentmesh_proto_msgTypes[14] + mi := &file_api_agentmesh_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1590,7 +1481,7 @@ func (x *DirectoryParameters) String() string { func (*DirectoryParameters) ProtoMessage() {} func (x *DirectoryParameters) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[14] + mi := &file_api_agentmesh_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1603,7 +1494,7 @@ func (x *DirectoryParameters) ProtoReflect() protoreflect.Message { // Deprecated: Use DirectoryParameters.ProtoReflect.Descriptor instead. func (*DirectoryParameters) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{14} + return file_api_agentmesh_proto_rawDescGZIP(), []int{13} } func (x *DirectoryParameters) GetReplication() int32 { @@ -1627,6 +1518,389 @@ func (x *DirectoryParameters) GetRecordTtl() *durationpb.Duration { return nil } +// DirectoryRequest is what a member asks a router it is attached to over +// /mesh/directory/1.0.0: one request per stream, answered with one +// DirectoryResponse. The member keeps no DHT of its own; the router runs +// the lookup across the routers' DHT and answers from it. A router sends +// the same message to another router to place a member's record on it. +type DirectoryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Request: + // + // *DirectoryRequest_Provide + // *DirectoryRequest_FindProviders + // *DirectoryRequest_FindPeer + Request isDirectoryRequest_Request `protobuf_oneof:"request"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DirectoryRequest) Reset() { + *x = DirectoryRequest{} + mi := &file_api_agentmesh_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DirectoryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DirectoryRequest) ProtoMessage() {} + +func (x *DirectoryRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_agentmesh_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DirectoryRequest.ProtoReflect.Descriptor instead. +func (*DirectoryRequest) Descriptor() ([]byte, []int) { + return file_api_agentmesh_proto_rawDescGZIP(), []int{14} +} + +func (x *DirectoryRequest) GetRequest() isDirectoryRequest_Request { + if x != nil { + return x.Request + } + return nil +} + +func (x *DirectoryRequest) GetProvide() *DirectoryProvide { + if x != nil { + if x, ok := x.Request.(*DirectoryRequest_Provide); ok { + return x.Provide + } + } + return nil +} + +func (x *DirectoryRequest) GetFindProviders() *DirectoryFindProviders { + if x != nil { + if x, ok := x.Request.(*DirectoryRequest_FindProviders); ok { + return x.FindProviders + } + } + return nil +} + +func (x *DirectoryRequest) GetFindPeer() *DirectoryFindPeer { + if x != nil { + if x, ok := x.Request.(*DirectoryRequest_FindPeer); ok { + return x.FindPeer + } + } + return nil +} + +type isDirectoryRequest_Request interface { + isDirectoryRequest_Request() +} + +type DirectoryRequest_Provide struct { + Provide *DirectoryProvide `protobuf:"bytes,1,opt,name=provide,proto3,oneof"` +} + +type DirectoryRequest_FindProviders struct { + FindProviders *DirectoryFindProviders `protobuf:"bytes,2,opt,name=find_providers,json=findProviders,proto3,oneof"` +} + +type DirectoryRequest_FindPeer struct { + FindPeer *DirectoryFindPeer `protobuf:"bytes,3,opt,name=find_peer,json=findPeer,proto3,oneof"` +} + +func (*DirectoryRequest_Provide) isDirectoryRequest_Request() {} + +func (*DirectoryRequest_FindProviders) isDirectoryRequest_Request() {} + +func (*DirectoryRequest_FindPeer) isDirectoryRequest_Request() {} + +// DirectoryProvide announces the sender as a provider of key, reached at +// addrs. A key is a sha256 multihash: of "mesh:service:[:]" for +// a service, of "mesh:peer:" for the member itself. provider is set +// only between routers, by the router that holds the member, and refused +// from anyone else: a member can only announce itself. +type DirectoryProvide struct { + state protoimpl.MessageState `protogen:"open.v1"` + Key []byte `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + Addrs []string `protobuf:"bytes,2,rep,name=addrs,proto3" json:"addrs,omitempty"` + Provider string `protobuf:"bytes,3,opt,name=provider,proto3" json:"provider,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DirectoryProvide) Reset() { + *x = DirectoryProvide{} + mi := &file_api_agentmesh_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DirectoryProvide) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DirectoryProvide) ProtoMessage() {} + +func (x *DirectoryProvide) ProtoReflect() protoreflect.Message { + mi := &file_api_agentmesh_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DirectoryProvide.ProtoReflect.Descriptor instead. +func (*DirectoryProvide) Descriptor() ([]byte, []int) { + return file_api_agentmesh_proto_rawDescGZIP(), []int{15} +} + +func (x *DirectoryProvide) GetKey() []byte { + if x != nil { + return x.Key + } + return nil +} + +func (x *DirectoryProvide) GetAddrs() []string { + if x != nil { + return x.Addrs + } + return nil +} + +func (x *DirectoryProvide) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +// DirectoryFindProviders asks for up to limit providers of key. +type DirectoryFindProviders struct { + state protoimpl.MessageState `protogen:"open.v1"` + Key []byte `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DirectoryFindProviders) Reset() { + *x = DirectoryFindProviders{} + mi := &file_api_agentmesh_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DirectoryFindProviders) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DirectoryFindProviders) ProtoMessage() {} + +func (x *DirectoryFindProviders) ProtoReflect() protoreflect.Message { + mi := &file_api_agentmesh_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DirectoryFindProviders.ProtoReflect.Descriptor instead. +func (*DirectoryFindProviders) Descriptor() ([]byte, []int) { + return file_api_agentmesh_proto_rawDescGZIP(), []int{16} +} + +func (x *DirectoryFindProviders) GetKey() []byte { + if x != nil { + return x.Key + } + return nil +} + +func (x *DirectoryFindProviders) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +// DirectoryFindPeer asks where peer_id is reached. +type DirectoryFindPeer struct { + state protoimpl.MessageState `protogen:"open.v1"` + PeerId string `protobuf:"bytes,1,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DirectoryFindPeer) Reset() { + *x = DirectoryFindPeer{} + mi := &file_api_agentmesh_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DirectoryFindPeer) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DirectoryFindPeer) ProtoMessage() {} + +func (x *DirectoryFindPeer) ProtoReflect() protoreflect.Message { + mi := &file_api_agentmesh_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DirectoryFindPeer.ProtoReflect.Descriptor instead. +func (*DirectoryFindPeer) Descriptor() ([]byte, []int) { + return file_api_agentmesh_proto_rawDescGZIP(), []int{17} +} + +func (x *DirectoryFindPeer) GetPeerId() string { + if x != nil { + return x.PeerId + } + return "" +} + +// DirectoryResponse answers one DirectoryRequest. providers answers +// find_providers; peer answers find_peer, unset when nobody knows the +// peer; error is set when the router could not run the request. +type DirectoryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Providers []*PeerRecord `protobuf:"bytes,1,rep,name=providers,proto3" json:"providers,omitempty"` + Peer *PeerRecord `protobuf:"bytes,2,opt,name=peer,proto3" json:"peer,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DirectoryResponse) Reset() { + *x = DirectoryResponse{} + mi := &file_api_agentmesh_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DirectoryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DirectoryResponse) ProtoMessage() {} + +func (x *DirectoryResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_agentmesh_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DirectoryResponse.ProtoReflect.Descriptor instead. +func (*DirectoryResponse) Descriptor() ([]byte, []int) { + return file_api_agentmesh_proto_rawDescGZIP(), []int{18} +} + +func (x *DirectoryResponse) GetProviders() []*PeerRecord { + if x != nil { + return x.Providers + } + return nil +} + +func (x *DirectoryResponse) GetPeer() *PeerRecord { + if x != nil { + return x.Peer + } + return nil +} + +func (x *DirectoryResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// PeerRecord is a peer and the addresses it is reached at, relayed ones +// included, each ending in its peer ID. +type PeerRecord struct { + state protoimpl.MessageState `protogen:"open.v1"` + PeerId string `protobuf:"bytes,1,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` + Addrs []string `protobuf:"bytes,2,rep,name=addrs,proto3" json:"addrs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PeerRecord) Reset() { + *x = PeerRecord{} + mi := &file_api_agentmesh_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PeerRecord) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PeerRecord) ProtoMessage() {} + +func (x *PeerRecord) ProtoReflect() protoreflect.Message { + mi := &file_api_agentmesh_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PeerRecord.ProtoReflect.Descriptor instead. +func (*PeerRecord) Descriptor() ([]byte, []int) { + return file_api_agentmesh_proto_rawDescGZIP(), []int{19} +} + +func (x *PeerRecord) GetPeerId() string { + if x != nil { + return x.PeerId + } + return "" +} + +func (x *PeerRecord) GetAddrs() []string { + if x != nil { + return x.Addrs + } + return nil +} + // RouterInfo is one router as the control plane lists it on /info. type RouterInfo struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1652,7 +1926,7 @@ type RouterInfo struct { func (x *RouterInfo) Reset() { *x = RouterInfo{} - mi := &file_api_agentmesh_proto_msgTypes[15] + mi := &file_api_agentmesh_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1664,7 +1938,7 @@ func (x *RouterInfo) String() string { func (*RouterInfo) ProtoMessage() {} func (x *RouterInfo) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[15] + mi := &file_api_agentmesh_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1677,7 +1951,7 @@ func (x *RouterInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use RouterInfo.ProtoReflect.Descriptor instead. func (*RouterInfo) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{15} + return file_api_agentmesh_proto_rawDescGZIP(), []int{20} } func (x *RouterInfo) GetPeerId() string { @@ -1745,7 +2019,7 @@ type RouterLeaseRequest struct { func (x *RouterLeaseRequest) Reset() { *x = RouterLeaseRequest{} - mi := &file_api_agentmesh_proto_msgTypes[16] + mi := &file_api_agentmesh_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1757,7 +2031,7 @@ func (x *RouterLeaseRequest) String() string { func (*RouterLeaseRequest) ProtoMessage() {} func (x *RouterLeaseRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[16] + mi := &file_api_agentmesh_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1770,7 +2044,7 @@ func (x *RouterLeaseRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RouterLeaseRequest.ProtoReflect.Descriptor instead. func (*RouterLeaseRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{16} + return file_api_agentmesh_proto_rawDescGZIP(), []int{21} } func (x *RouterLeaseRequest) GetPeerId() string { @@ -1854,7 +2128,7 @@ type RouterLeaseResponse struct { func (x *RouterLeaseResponse) Reset() { *x = RouterLeaseResponse{} - mi := &file_api_agentmesh_proto_msgTypes[17] + mi := &file_api_agentmesh_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1866,7 +2140,7 @@ func (x *RouterLeaseResponse) String() string { func (*RouterLeaseResponse) ProtoMessage() {} func (x *RouterLeaseResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[17] + mi := &file_api_agentmesh_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1879,7 +2153,7 @@ func (x *RouterLeaseResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RouterLeaseResponse.ProtoReflect.Descriptor instead. func (*RouterLeaseResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{17} + return file_api_agentmesh_proto_rawDescGZIP(), []int{22} } func (x *RouterLeaseResponse) GetSuccess() bool { @@ -1921,7 +2195,7 @@ type PolicyRole struct { func (x *PolicyRole) Reset() { *x = PolicyRole{} - mi := &file_api_agentmesh_proto_msgTypes[18] + mi := &file_api_agentmesh_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1933,7 +2207,7 @@ func (x *PolicyRole) String() string { func (*PolicyRole) ProtoMessage() {} func (x *PolicyRole) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[18] + mi := &file_api_agentmesh_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1946,7 +2220,7 @@ func (x *PolicyRole) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyRole.ProtoReflect.Descriptor instead. func (*PolicyRole) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{18} + return file_api_agentmesh_proto_rawDescGZIP(), []int{23} } func (x *PolicyRole) GetName() string { @@ -2013,7 +2287,7 @@ type HTTPGrant struct { func (x *HTTPGrant) Reset() { *x = HTTPGrant{} - mi := &file_api_agentmesh_proto_msgTypes[19] + mi := &file_api_agentmesh_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2025,7 +2299,7 @@ func (x *HTTPGrant) String() string { func (*HTTPGrant) ProtoMessage() {} func (x *HTTPGrant) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[19] + mi := &file_api_agentmesh_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2038,7 +2312,7 @@ func (x *HTTPGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use HTTPGrant.ProtoReflect.Descriptor instead. func (*HTTPGrant) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{19} + return file_api_agentmesh_proto_rawDescGZIP(), []int{24} } func (x *HTTPGrant) GetService() string { @@ -2106,7 +2380,7 @@ type EgressDestination struct { func (x *EgressDestination) Reset() { *x = EgressDestination{} - mi := &file_api_agentmesh_proto_msgTypes[20] + mi := &file_api_agentmesh_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2118,7 +2392,7 @@ func (x *EgressDestination) String() string { func (*EgressDestination) ProtoMessage() {} func (x *EgressDestination) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[20] + mi := &file_api_agentmesh_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2131,7 +2405,7 @@ func (x *EgressDestination) ProtoReflect() protoreflect.Message { // Deprecated: Use EgressDestination.ProtoReflect.Descriptor instead. func (*EgressDestination) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{20} + return file_api_agentmesh_proto_rawDescGZIP(), []int{25} } func (x *EgressDestination) GetName() string { @@ -2216,7 +2490,7 @@ type Inspection struct { func (x *Inspection) Reset() { *x = Inspection{} - mi := &file_api_agentmesh_proto_msgTypes[21] + mi := &file_api_agentmesh_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2228,7 +2502,7 @@ func (x *Inspection) String() string { func (*Inspection) ProtoMessage() {} func (x *Inspection) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[21] + mi := &file_api_agentmesh_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2241,7 +2515,7 @@ func (x *Inspection) ProtoReflect() protoreflect.Message { // Deprecated: Use Inspection.ProtoReflect.Descriptor instead. func (*Inspection) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{21} + return file_api_agentmesh_proto_rawDescGZIP(), []int{26} } func (x *Inspection) GetInspectors() []*Inspector { @@ -2264,7 +2538,7 @@ type Inspector struct { func (x *Inspector) Reset() { *x = Inspector{} - mi := &file_api_agentmesh_proto_msgTypes[22] + mi := &file_api_agentmesh_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2276,7 +2550,7 @@ func (x *Inspector) String() string { func (*Inspector) ProtoMessage() {} func (x *Inspector) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[22] + mi := &file_api_agentmesh_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2289,7 +2563,7 @@ func (x *Inspector) ProtoReflect() protoreflect.Message { // Deprecated: Use Inspector.ProtoReflect.Descriptor instead. func (*Inspector) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{22} + return file_api_agentmesh_proto_rawDescGZIP(), []int{27} } func (x *Inspector) GetKind() isInspector_Kind { @@ -2354,7 +2628,7 @@ type ModelArmor struct { func (x *ModelArmor) Reset() { *x = ModelArmor{} - mi := &file_api_agentmesh_proto_msgTypes[23] + mi := &file_api_agentmesh_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2366,7 +2640,7 @@ func (x *ModelArmor) String() string { func (*ModelArmor) ProtoMessage() {} func (x *ModelArmor) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[23] + mi := &file_api_agentmesh_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2379,7 +2653,7 @@ func (x *ModelArmor) ProtoReflect() protoreflect.Message { // Deprecated: Use ModelArmor.ProtoReflect.Descriptor instead. func (*ModelArmor) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{23} + return file_api_agentmesh_proto_rawDescGZIP(), []int{28} } func (x *ModelArmor) GetTemplate() string { @@ -2437,7 +2711,7 @@ type ExtProc struct { func (x *ExtProc) Reset() { *x = ExtProc{} - mi := &file_api_agentmesh_proto_msgTypes[24] + mi := &file_api_agentmesh_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2449,7 +2723,7 @@ func (x *ExtProc) String() string { func (*ExtProc) ProtoMessage() {} func (x *ExtProc) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[24] + mi := &file_api_agentmesh_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2462,7 +2736,7 @@ func (x *ExtProc) ProtoReflect() protoreflect.Message { // Deprecated: Use ExtProc.ProtoReflect.Descriptor instead. func (*ExtProc) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{24} + return file_api_agentmesh_proto_rawDescGZIP(), []int{29} } func (x *ExtProc) GetTarget() string { @@ -2535,7 +2809,7 @@ type ExtProcProcessingMode struct { func (x *ExtProcProcessingMode) Reset() { *x = ExtProcProcessingMode{} - mi := &file_api_agentmesh_proto_msgTypes[25] + mi := &file_api_agentmesh_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2547,7 +2821,7 @@ func (x *ExtProcProcessingMode) String() string { func (*ExtProcProcessingMode) ProtoMessage() {} func (x *ExtProcProcessingMode) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[25] + mi := &file_api_agentmesh_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2560,7 +2834,7 @@ func (x *ExtProcProcessingMode) ProtoReflect() protoreflect.Message { // Deprecated: Use ExtProcProcessingMode.ProtoReflect.Descriptor instead. func (*ExtProcProcessingMode) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{25} + return file_api_agentmesh_proto_rawDescGZIP(), []int{30} } func (x *ExtProcProcessingMode) GetRequestHeaderMode() ExtProcProcessingMode_HeaderMode { @@ -2620,7 +2894,7 @@ type CredentialBroker struct { func (x *CredentialBroker) Reset() { *x = CredentialBroker{} - mi := &file_api_agentmesh_proto_msgTypes[26] + mi := &file_api_agentmesh_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2632,7 +2906,7 @@ func (x *CredentialBroker) String() string { func (*CredentialBroker) ProtoMessage() {} func (x *CredentialBroker) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[26] + mi := &file_api_agentmesh_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2645,7 +2919,7 @@ func (x *CredentialBroker) ProtoReflect() protoreflect.Message { // Deprecated: Use CredentialBroker.ProtoReflect.Descriptor instead. func (*CredentialBroker) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{26} + return file_api_agentmesh_proto_rawDescGZIP(), []int{31} } func (x *CredentialBroker) GetKind() isCredentialBroker_Kind { @@ -2742,7 +3016,7 @@ type OIDCFederation struct { func (x *OIDCFederation) Reset() { *x = OIDCFederation{} - mi := &file_api_agentmesh_proto_msgTypes[27] + mi := &file_api_agentmesh_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2754,7 +3028,7 @@ func (x *OIDCFederation) String() string { func (*OIDCFederation) ProtoMessage() {} func (x *OIDCFederation) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[27] + mi := &file_api_agentmesh_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2767,7 +3041,7 @@ func (x *OIDCFederation) ProtoReflect() protoreflect.Message { // Deprecated: Use OIDCFederation.ProtoReflect.Descriptor instead. func (*OIDCFederation) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{27} + return file_api_agentmesh_proto_rawDescGZIP(), []int{32} } func (x *OIDCFederation) GetTokenEndpoint() string { @@ -2809,7 +3083,7 @@ type AWSAssumeRole struct { func (x *AWSAssumeRole) Reset() { *x = AWSAssumeRole{} - mi := &file_api_agentmesh_proto_msgTypes[28] + mi := &file_api_agentmesh_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2821,7 +3095,7 @@ func (x *AWSAssumeRole) String() string { func (*AWSAssumeRole) ProtoMessage() {} func (x *AWSAssumeRole) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[28] + mi := &file_api_agentmesh_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2834,7 +3108,7 @@ func (x *AWSAssumeRole) ProtoReflect() protoreflect.Message { // Deprecated: Use AWSAssumeRole.ProtoReflect.Descriptor instead. func (*AWSAssumeRole) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{28} + return file_api_agentmesh_proto_rawDescGZIP(), []int{33} } func (x *AWSAssumeRole) GetRoleArn() string { @@ -2860,7 +3134,7 @@ type PlatformIdentity struct { func (x *PlatformIdentity) Reset() { *x = PlatformIdentity{} - mi := &file_api_agentmesh_proto_msgTypes[29] + mi := &file_api_agentmesh_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2872,7 +3146,7 @@ func (x *PlatformIdentity) String() string { func (*PlatformIdentity) ProtoMessage() {} func (x *PlatformIdentity) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[29] + mi := &file_api_agentmesh_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2885,7 +3159,7 @@ func (x *PlatformIdentity) ProtoReflect() protoreflect.Message { // Deprecated: Use PlatformIdentity.ProtoReflect.Descriptor instead. func (*PlatformIdentity) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{29} + return file_api_agentmesh_proto_rawDescGZIP(), []int{34} } func (x *PlatformIdentity) GetScopes() []string { @@ -2905,7 +3179,7 @@ type PolicyBinding struct { func (x *PolicyBinding) Reset() { *x = PolicyBinding{} - mi := &file_api_agentmesh_proto_msgTypes[30] + mi := &file_api_agentmesh_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2917,7 +3191,7 @@ func (x *PolicyBinding) String() string { func (*PolicyBinding) ProtoMessage() {} func (x *PolicyBinding) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[30] + mi := &file_api_agentmesh_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2930,7 +3204,7 @@ func (x *PolicyBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyBinding.ProtoReflect.Descriptor instead. func (*PolicyBinding) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{30} + return file_api_agentmesh_proto_rawDescGZIP(), []int{35} } func (x *PolicyBinding) GetRole() string { @@ -2962,7 +3236,7 @@ type PolicyConfig struct { func (x *PolicyConfig) Reset() { *x = PolicyConfig{} - mi := &file_api_agentmesh_proto_msgTypes[31] + mi := &file_api_agentmesh_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2974,7 +3248,7 @@ func (x *PolicyConfig) String() string { func (*PolicyConfig) ProtoMessage() {} func (x *PolicyConfig) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[31] + mi := &file_api_agentmesh_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2987,7 +3261,7 @@ func (x *PolicyConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfig.ProtoReflect.Descriptor instead. func (*PolicyConfig) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{31} + return file_api_agentmesh_proto_rawDescGZIP(), []int{36} } func (x *PolicyConfig) GetRoles() []*PolicyRole { @@ -3019,7 +3293,7 @@ type PolicyConfigGetRequest struct { func (x *PolicyConfigGetRequest) Reset() { *x = PolicyConfigGetRequest{} - mi := &file_api_agentmesh_proto_msgTypes[32] + mi := &file_api_agentmesh_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3031,7 +3305,7 @@ func (x *PolicyConfigGetRequest) String() string { func (*PolicyConfigGetRequest) ProtoMessage() {} func (x *PolicyConfigGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[32] + mi := &file_api_agentmesh_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3044,7 +3318,7 @@ func (x *PolicyConfigGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfigGetRequest.ProtoReflect.Descriptor instead. func (*PolicyConfigGetRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{32} + return file_api_agentmesh_proto_rawDescGZIP(), []int{37} } // PolicyConfigGetResponse answers GET /policies for a mesh member holding a @@ -3060,7 +3334,7 @@ type PolicyConfigGetResponse struct { func (x *PolicyConfigGetResponse) Reset() { *x = PolicyConfigGetResponse{} - mi := &file_api_agentmesh_proto_msgTypes[33] + mi := &file_api_agentmesh_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3072,7 +3346,7 @@ func (x *PolicyConfigGetResponse) String() string { func (*PolicyConfigGetResponse) ProtoMessage() {} func (x *PolicyConfigGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[33] + mi := &file_api_agentmesh_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3085,7 +3359,7 @@ func (x *PolicyConfigGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfigGetResponse.ProtoReflect.Descriptor instead. func (*PolicyConfigGetResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{33} + return file_api_agentmesh_proto_rawDescGZIP(), []int{38} } func (x *PolicyConfigGetResponse) GetDatalogRules() []string { @@ -3105,7 +3379,7 @@ type PolicyConfigUpdateResponse struct { func (x *PolicyConfigUpdateResponse) Reset() { *x = PolicyConfigUpdateResponse{} - mi := &file_api_agentmesh_proto_msgTypes[34] + mi := &file_api_agentmesh_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3117,7 +3391,7 @@ func (x *PolicyConfigUpdateResponse) String() string { func (*PolicyConfigUpdateResponse) ProtoMessage() {} func (x *PolicyConfigUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[34] + mi := &file_api_agentmesh_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3130,7 +3404,7 @@ func (x *PolicyConfigUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfigUpdateResponse.ProtoReflect.Descriptor instead. func (*PolicyConfigUpdateResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{34} + return file_api_agentmesh_proto_rawDescGZIP(), []int{39} } func (x *PolicyConfigUpdateResponse) GetSuccess() bool { @@ -3155,7 +3429,7 @@ type EgressAssignmentsRequest struct { func (x *EgressAssignmentsRequest) Reset() { *x = EgressAssignmentsRequest{} - mi := &file_api_agentmesh_proto_msgTypes[35] + mi := &file_api_agentmesh_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3167,7 +3441,7 @@ func (x *EgressAssignmentsRequest) String() string { func (*EgressAssignmentsRequest) ProtoMessage() {} func (x *EgressAssignmentsRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[35] + mi := &file_api_agentmesh_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3180,7 +3454,7 @@ func (x *EgressAssignmentsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EgressAssignmentsRequest.ProtoReflect.Descriptor instead. func (*EgressAssignmentsRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{35} + return file_api_agentmesh_proto_rawDescGZIP(), []int{40} } // EgressAssignmentsResponse answers GET /egress for a mesh member holding a @@ -3196,7 +3470,7 @@ type EgressAssignmentsResponse struct { func (x *EgressAssignmentsResponse) Reset() { *x = EgressAssignmentsResponse{} - mi := &file_api_agentmesh_proto_msgTypes[36] + mi := &file_api_agentmesh_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3208,7 +3482,7 @@ func (x *EgressAssignmentsResponse) String() string { func (*EgressAssignmentsResponse) ProtoMessage() {} func (x *EgressAssignmentsResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[36] + mi := &file_api_agentmesh_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3221,7 +3495,7 @@ func (x *EgressAssignmentsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EgressAssignmentsResponse.ProtoReflect.Descriptor instead. func (*EgressAssignmentsResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{36} + return file_api_agentmesh_proto_rawDescGZIP(), []int{41} } func (x *EgressAssignmentsResponse) GetEgress() []*EgressDestination { @@ -3248,7 +3522,7 @@ type KeysResponse struct { func (x *KeysResponse) Reset() { *x = KeysResponse{} - mi := &file_api_agentmesh_proto_msgTypes[37] + mi := &file_api_agentmesh_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3260,7 +3534,7 @@ func (x *KeysResponse) String() string { func (*KeysResponse) ProtoMessage() {} func (x *KeysResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[37] + mi := &file_api_agentmesh_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3273,7 +3547,7 @@ func (x *KeysResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use KeysResponse.ProtoReflect.Descriptor instead. func (*KeysResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{37} + return file_api_agentmesh_proto_rawDescGZIP(), []int{42} } func (x *KeysResponse) GetPublicKeys() [][]byte { @@ -3326,7 +3600,7 @@ type TokenRefreshRequest struct { func (x *TokenRefreshRequest) Reset() { *x = TokenRefreshRequest{} - mi := &file_api_agentmesh_proto_msgTypes[38] + mi := &file_api_agentmesh_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3338,7 +3612,7 @@ func (x *TokenRefreshRequest) String() string { func (*TokenRefreshRequest) ProtoMessage() {} func (x *TokenRefreshRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[38] + mi := &file_api_agentmesh_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3351,7 +3625,7 @@ func (x *TokenRefreshRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRefreshRequest.ProtoReflect.Descriptor instead. func (*TokenRefreshRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{38} + return file_api_agentmesh_proto_rawDescGZIP(), []int{43} } func (x *TokenRefreshRequest) GetChallengeSignature() []byte { @@ -3393,7 +3667,7 @@ type TokenRefreshResponse struct { func (x *TokenRefreshResponse) Reset() { *x = TokenRefreshResponse{} - mi := &file_api_agentmesh_proto_msgTypes[39] + mi := &file_api_agentmesh_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3405,7 +3679,7 @@ func (x *TokenRefreshResponse) String() string { func (*TokenRefreshResponse) ProtoMessage() {} func (x *TokenRefreshResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[39] + mi := &file_api_agentmesh_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3418,7 +3692,7 @@ func (x *TokenRefreshResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRefreshResponse.ProtoReflect.Descriptor instead. func (*TokenRefreshResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{39} + return file_api_agentmesh_proto_rawDescGZIP(), []int{44} } func (x *TokenRefreshResponse) GetBiscuitToken() []byte { @@ -3455,7 +3729,7 @@ type NodeCatalogReport struct { func (x *NodeCatalogReport) Reset() { *x = NodeCatalogReport{} - mi := &file_api_agentmesh_proto_msgTypes[40] + mi := &file_api_agentmesh_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3467,7 +3741,7 @@ func (x *NodeCatalogReport) String() string { func (*NodeCatalogReport) ProtoMessage() {} func (x *NodeCatalogReport) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[40] + mi := &file_api_agentmesh_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3480,7 +3754,7 @@ func (x *NodeCatalogReport) ProtoReflect() protoreflect.Message { // Deprecated: Use NodeCatalogReport.ProtoReflect.Descriptor instead. func (*NodeCatalogReport) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{40} + return file_api_agentmesh_proto_rawDescGZIP(), []int{45} } func (x *NodeCatalogReport) GetServices() []*ServiceInfo { @@ -3499,7 +3773,7 @@ type TokenRevokeRequest struct { func (x *TokenRevokeRequest) Reset() { *x = TokenRevokeRequest{} - mi := &file_api_agentmesh_proto_msgTypes[41] + mi := &file_api_agentmesh_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3511,7 +3785,7 @@ func (x *TokenRevokeRequest) String() string { func (*TokenRevokeRequest) ProtoMessage() {} func (x *TokenRevokeRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[41] + mi := &file_api_agentmesh_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3524,7 +3798,7 @@ func (x *TokenRevokeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRevokeRequest.ProtoReflect.Descriptor instead. func (*TokenRevokeRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{41} + return file_api_agentmesh_proto_rawDescGZIP(), []int{46} } func (x *TokenRevokeRequest) GetPeerId() string { @@ -3544,7 +3818,7 @@ type TokenRevokeResponse struct { func (x *TokenRevokeResponse) Reset() { *x = TokenRevokeResponse{} - mi := &file_api_agentmesh_proto_msgTypes[42] + mi := &file_api_agentmesh_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3556,7 +3830,7 @@ func (x *TokenRevokeResponse) String() string { func (*TokenRevokeResponse) ProtoMessage() {} func (x *TokenRevokeResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[42] + mi := &file_api_agentmesh_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3569,7 +3843,7 @@ func (x *TokenRevokeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRevokeResponse.ProtoReflect.Descriptor instead. func (*TokenRevokeResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{42} + return file_api_agentmesh_proto_rawDescGZIP(), []int{47} } func (x *TokenRevokeResponse) GetSuccess() bool { @@ -3614,7 +3888,7 @@ type BootstrapTokenCreateRequest struct { func (x *BootstrapTokenCreateRequest) Reset() { *x = BootstrapTokenCreateRequest{} - mi := &file_api_agentmesh_proto_msgTypes[43] + mi := &file_api_agentmesh_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3626,7 +3900,7 @@ func (x *BootstrapTokenCreateRequest) String() string { func (*BootstrapTokenCreateRequest) ProtoMessage() {} func (x *BootstrapTokenCreateRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[43] + mi := &file_api_agentmesh_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3639,7 +3913,7 @@ func (x *BootstrapTokenCreateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BootstrapTokenCreateRequest.ProtoReflect.Descriptor instead. func (*BootstrapTokenCreateRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{43} + return file_api_agentmesh_proto_rawDescGZIP(), []int{48} } func (x *BootstrapTokenCreateRequest) GetRole() string { @@ -3700,7 +3974,7 @@ type BootstrapTokenCreateResponse struct { func (x *BootstrapTokenCreateResponse) Reset() { *x = BootstrapTokenCreateResponse{} - mi := &file_api_agentmesh_proto_msgTypes[44] + mi := &file_api_agentmesh_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3712,7 +3986,7 @@ func (x *BootstrapTokenCreateResponse) String() string { func (*BootstrapTokenCreateResponse) ProtoMessage() {} func (x *BootstrapTokenCreateResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[44] + mi := &file_api_agentmesh_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3725,7 +3999,7 @@ func (x *BootstrapTokenCreateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BootstrapTokenCreateResponse.ProtoReflect.Descriptor instead. func (*BootstrapTokenCreateResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{44} + return file_api_agentmesh_proto_rawDescGZIP(), []int{49} } func (x *BootstrapTokenCreateResponse) GetId() string { @@ -3784,7 +4058,7 @@ type BootstrapToken struct { func (x *BootstrapToken) Reset() { *x = BootstrapToken{} - mi := &file_api_agentmesh_proto_msgTypes[45] + mi := &file_api_agentmesh_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3796,7 +4070,7 @@ func (x *BootstrapToken) String() string { func (*BootstrapToken) ProtoMessage() {} func (x *BootstrapToken) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[45] + mi := &file_api_agentmesh_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3809,7 +4083,7 @@ func (x *BootstrapToken) ProtoReflect() protoreflect.Message { // Deprecated: Use BootstrapToken.ProtoReflect.Descriptor instead. func (*BootstrapToken) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{45} + return file_api_agentmesh_proto_rawDescGZIP(), []int{50} } func (x *BootstrapToken) GetId() string { @@ -3891,7 +4165,7 @@ type BootstrapTokenListResponse struct { func (x *BootstrapTokenListResponse) Reset() { *x = BootstrapTokenListResponse{} - mi := &file_api_agentmesh_proto_msgTypes[46] + mi := &file_api_agentmesh_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3903,7 +4177,7 @@ func (x *BootstrapTokenListResponse) String() string { func (*BootstrapTokenListResponse) ProtoMessage() {} func (x *BootstrapTokenListResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[46] + mi := &file_api_agentmesh_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3916,7 +4190,7 @@ func (x *BootstrapTokenListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BootstrapTokenListResponse.ProtoReflect.Descriptor instead. func (*BootstrapTokenListResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{46} + return file_api_agentmesh_proto_rawDescGZIP(), []int{51} } func (x *BootstrapTokenListResponse) GetTokens() []*BootstrapToken { @@ -3947,7 +4221,7 @@ type EnrollmentRequest struct { func (x *EnrollmentRequest) Reset() { *x = EnrollmentRequest{} - mi := &file_api_agentmesh_proto_msgTypes[47] + mi := &file_api_agentmesh_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3959,7 +4233,7 @@ func (x *EnrollmentRequest) String() string { func (*EnrollmentRequest) ProtoMessage() {} func (x *EnrollmentRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[47] + mi := &file_api_agentmesh_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3972,7 +4246,7 @@ func (x *EnrollmentRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EnrollmentRequest.ProtoReflect.Descriptor instead. func (*EnrollmentRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{47} + return file_api_agentmesh_proto_rawDescGZIP(), []int{52} } func (x *EnrollmentRequest) GetId() string { @@ -4040,7 +4314,7 @@ type EnrollmentRequestListResponse struct { func (x *EnrollmentRequestListResponse) Reset() { *x = EnrollmentRequestListResponse{} - mi := &file_api_agentmesh_proto_msgTypes[48] + mi := &file_api_agentmesh_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4052,7 +4326,7 @@ func (x *EnrollmentRequestListResponse) String() string { func (*EnrollmentRequestListResponse) ProtoMessage() {} func (x *EnrollmentRequestListResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[48] + mi := &file_api_agentmesh_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4065,7 +4339,7 @@ func (x *EnrollmentRequestListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EnrollmentRequestListResponse.ProtoReflect.Descriptor instead. func (*EnrollmentRequestListResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{48} + return file_api_agentmesh_proto_rawDescGZIP(), []int{53} } func (x *EnrollmentRequestListResponse) GetRequests() []*EnrollmentRequest { @@ -4091,7 +4365,7 @@ type User struct { func (x *User) Reset() { *x = User{} - mi := &file_api_agentmesh_proto_msgTypes[49] + mi := &file_api_agentmesh_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4103,7 +4377,7 @@ func (x *User) String() string { func (*User) ProtoMessage() {} func (x *User) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[49] + mi := &file_api_agentmesh_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4116,7 +4390,7 @@ func (x *User) ProtoReflect() protoreflect.Message { // Deprecated: Use User.ProtoReflect.Descriptor instead. func (*User) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{49} + return file_api_agentmesh_proto_rawDescGZIP(), []int{54} } func (x *User) GetId() string { @@ -4176,7 +4450,7 @@ type EnrolledNode struct { func (x *EnrolledNode) Reset() { *x = EnrolledNode{} - mi := &file_api_agentmesh_proto_msgTypes[50] + mi := &file_api_agentmesh_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4188,7 +4462,7 @@ func (x *EnrolledNode) String() string { func (*EnrolledNode) ProtoMessage() {} func (x *EnrolledNode) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[50] + mi := &file_api_agentmesh_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4201,7 +4475,7 @@ func (x *EnrolledNode) ProtoReflect() protoreflect.Message { // Deprecated: Use EnrolledNode.ProtoReflect.Descriptor instead. func (*EnrolledNode) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{50} + return file_api_agentmesh_proto_rawDescGZIP(), []int{55} } func (x *EnrolledNode) GetPeerId() string { @@ -4290,7 +4564,7 @@ type RouterLease struct { func (x *RouterLease) Reset() { *x = RouterLease{} - mi := &file_api_agentmesh_proto_msgTypes[51] + mi := &file_api_agentmesh_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4302,7 +4576,7 @@ func (x *RouterLease) String() string { func (*RouterLease) ProtoMessage() {} func (x *RouterLease) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[51] + mi := &file_api_agentmesh_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4315,7 +4589,7 @@ func (x *RouterLease) ProtoReflect() protoreflect.Message { // Deprecated: Use RouterLease.ProtoReflect.Descriptor instead. func (*RouterLease) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{51} + return file_api_agentmesh_proto_rawDescGZIP(), []int{56} } func (x *RouterLease) GetPeerId() string { @@ -4372,7 +4646,7 @@ type NodeServices struct { func (x *NodeServices) Reset() { *x = NodeServices{} - mi := &file_api_agentmesh_proto_msgTypes[52] + mi := &file_api_agentmesh_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4384,7 +4658,7 @@ func (x *NodeServices) String() string { func (*NodeServices) ProtoMessage() {} func (x *NodeServices) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[52] + mi := &file_api_agentmesh_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4397,7 +4671,7 @@ func (x *NodeServices) ProtoReflect() protoreflect.Message { // Deprecated: Use NodeServices.ProtoReflect.Descriptor instead. func (*NodeServices) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{52} + return file_api_agentmesh_proto_rawDescGZIP(), []int{57} } func (x *NodeServices) GetServices() []*ServiceInfo { @@ -4432,7 +4706,7 @@ type AdminStatusResponse struct { func (x *AdminStatusResponse) Reset() { *x = AdminStatusResponse{} - mi := &file_api_agentmesh_proto_msgTypes[53] + mi := &file_api_agentmesh_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4444,7 +4718,7 @@ func (x *AdminStatusResponse) String() string { func (*AdminStatusResponse) ProtoMessage() {} func (x *AdminStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[53] + mi := &file_api_agentmesh_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4457,7 +4731,7 @@ func (x *AdminStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AdminStatusResponse.ProtoReflect.Descriptor instead. func (*AdminStatusResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{53} + return file_api_agentmesh_proto_rawDescGZIP(), []int{58} } func (x *AdminStatusResponse) GetUsers() []*User { @@ -4525,7 +4799,7 @@ type UserStatusResponse struct { func (x *UserStatusResponse) Reset() { *x = UserStatusResponse{} - mi := &file_api_agentmesh_proto_msgTypes[54] + mi := &file_api_agentmesh_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4537,7 +4811,7 @@ func (x *UserStatusResponse) String() string { func (*UserStatusResponse) ProtoMessage() {} func (x *UserStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[54] + mi := &file_api_agentmesh_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4550,7 +4824,7 @@ func (x *UserStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UserStatusResponse.ProtoReflect.Descriptor instead. func (*UserStatusResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{54} + return file_api_agentmesh_proto_rawDescGZIP(), []int{59} } func (x *UserStatusResponse) GetUser() *User { @@ -4602,7 +4876,7 @@ type IdentityEvidenceResponse struct { func (x *IdentityEvidenceResponse) Reset() { *x = IdentityEvidenceResponse{} - mi := &file_api_agentmesh_proto_msgTypes[55] + mi := &file_api_agentmesh_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4614,7 +4888,7 @@ func (x *IdentityEvidenceResponse) String() string { func (*IdentityEvidenceResponse) ProtoMessage() {} func (x *IdentityEvidenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[55] + mi := &file_api_agentmesh_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4627,7 +4901,7 @@ func (x *IdentityEvidenceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use IdentityEvidenceResponse.ProtoReflect.Descriptor instead. func (*IdentityEvidenceResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{55} + return file_api_agentmesh_proto_rawDescGZIP(), []int{60} } func (x *IdentityEvidenceResponse) GetPeerId() string { @@ -4688,7 +4962,7 @@ type PeerEvidenceResponse struct { func (x *PeerEvidenceResponse) Reset() { *x = PeerEvidenceResponse{} - mi := &file_api_agentmesh_proto_msgTypes[56] + mi := &file_api_agentmesh_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4700,7 +4974,7 @@ func (x *PeerEvidenceResponse) String() string { func (*PeerEvidenceResponse) ProtoMessage() {} func (x *PeerEvidenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[56] + mi := &file_api_agentmesh_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4713,7 +4987,7 @@ func (x *PeerEvidenceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerEvidenceResponse.ProtoReflect.Descriptor instead. func (*PeerEvidenceResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{56} + return file_api_agentmesh_proto_rawDescGZIP(), []int{61} } func (x *PeerEvidenceResponse) GetPeerId() string { @@ -4798,7 +5072,7 @@ type MemberCredential struct { func (x *MemberCredential) Reset() { *x = MemberCredential{} - mi := &file_api_agentmesh_proto_msgTypes[57] + mi := &file_api_agentmesh_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4810,7 +5084,7 @@ func (x *MemberCredential) String() string { func (*MemberCredential) ProtoMessage() {} func (x *MemberCredential) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[57] + mi := &file_api_agentmesh_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4823,7 +5097,7 @@ func (x *MemberCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use MemberCredential.ProtoReflect.Descriptor instead. func (*MemberCredential) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{57} + return file_api_agentmesh_proto_rawDescGZIP(), []int{62} } func (x *MemberCredential) GetControlPlaneUrl() string { @@ -4888,7 +5162,7 @@ type TrustedSigningKey struct { func (x *TrustedSigningKey) Reset() { *x = TrustedSigningKey{} - mi := &file_api_agentmesh_proto_msgTypes[58] + mi := &file_api_agentmesh_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4900,7 +5174,7 @@ func (x *TrustedSigningKey) String() string { func (*TrustedSigningKey) ProtoMessage() {} func (x *TrustedSigningKey) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[58] + mi := &file_api_agentmesh_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4913,7 +5187,7 @@ func (x *TrustedSigningKey) ProtoReflect() protoreflect.Message { // Deprecated: Use TrustedSigningKey.ProtoReflect.Descriptor instead. func (*TrustedSigningKey) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{58} + return file_api_agentmesh_proto_rawDescGZIP(), []int{63} } func (x *TrustedSigningKey) GetPublicKey() []byte { @@ -4942,7 +5216,7 @@ type OIDCSession struct { func (x *OIDCSession) Reset() { *x = OIDCSession{} - mi := &file_api_agentmesh_proto_msgTypes[59] + mi := &file_api_agentmesh_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4954,7 +5228,7 @@ func (x *OIDCSession) String() string { func (*OIDCSession) ProtoMessage() {} func (x *OIDCSession) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[59] + mi := &file_api_agentmesh_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4967,7 +5241,7 @@ func (x *OIDCSession) ProtoReflect() protoreflect.Message { // Deprecated: Use OIDCSession.ProtoReflect.Descriptor instead. func (*OIDCSession) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{59} + return file_api_agentmesh_proto_rawDescGZIP(), []int{64} } func (x *OIDCSession) GetIssuer() string { @@ -5019,7 +5293,7 @@ type TaskAuthorizationRule struct { func (x *TaskAuthorizationRule) Reset() { *x = TaskAuthorizationRule{} - mi := &file_api_agentmesh_proto_msgTypes[60] + mi := &file_api_agentmesh_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5031,7 +5305,7 @@ func (x *TaskAuthorizationRule) String() string { func (*TaskAuthorizationRule) ProtoMessage() {} func (x *TaskAuthorizationRule) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[60] + mi := &file_api_agentmesh_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5044,7 +5318,7 @@ func (x *TaskAuthorizationRule) ProtoReflect() protoreflect.Message { // Deprecated: Use TaskAuthorizationRule.ProtoReflect.Descriptor instead. func (*TaskAuthorizationRule) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{60} + return file_api_agentmesh_proto_rawDescGZIP(), []int{65} } func (x *TaskAuthorizationRule) GetName() string { @@ -5096,7 +5370,7 @@ type TaskRule struct { func (x *TaskRule) Reset() { *x = TaskRule{} - mi := &file_api_agentmesh_proto_msgTypes[61] + mi := &file_api_agentmesh_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5108,7 +5382,7 @@ func (x *TaskRule) String() string { func (*TaskRule) ProtoMessage() {} func (x *TaskRule) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[61] + mi := &file_api_agentmesh_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5121,7 +5395,7 @@ func (x *TaskRule) ProtoReflect() protoreflect.Message { // Deprecated: Use TaskRule.ProtoReflect.Descriptor instead. func (*TaskRule) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{61} + return file_api_agentmesh_proto_rawDescGZIP(), []int{66} } func (x *TaskRule) GetDescription() string { @@ -5170,7 +5444,7 @@ type TaskOperation struct { func (x *TaskOperation) Reset() { *x = TaskOperation{} - mi := &file_api_agentmesh_proto_msgTypes[62] + mi := &file_api_agentmesh_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5182,7 +5456,7 @@ func (x *TaskOperation) String() string { func (*TaskOperation) ProtoMessage() {} func (x *TaskOperation) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[62] + mi := &file_api_agentmesh_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5195,7 +5469,7 @@ func (x *TaskOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use TaskOperation.ProtoReflect.Descriptor instead. func (*TaskOperation) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{62} + return file_api_agentmesh_proto_rawDescGZIP(), []int{67} } func (x *TaskOperation) GetAllowedTools() []string { @@ -5255,7 +5529,7 @@ type TokenExchangeRequest struct { func (x *TokenExchangeRequest) Reset() { *x = TokenExchangeRequest{} - mi := &file_api_agentmesh_proto_msgTypes[63] + mi := &file_api_agentmesh_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5267,7 +5541,7 @@ func (x *TokenExchangeRequest) String() string { func (*TokenExchangeRequest) ProtoMessage() {} func (x *TokenExchangeRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[63] + mi := &file_api_agentmesh_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5280,7 +5554,7 @@ func (x *TokenExchangeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenExchangeRequest.ProtoReflect.Descriptor instead. func (*TokenExchangeRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{63} + return file_api_agentmesh_proto_rawDescGZIP(), []int{68} } func (x *TokenExchangeRequest) GetSubjectToken() string { @@ -5330,7 +5604,7 @@ type TokenExchangeResponse struct { func (x *TokenExchangeResponse) Reset() { *x = TokenExchangeResponse{} - mi := &file_api_agentmesh_proto_msgTypes[64] + mi := &file_api_agentmesh_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5342,7 +5616,7 @@ func (x *TokenExchangeResponse) String() string { func (*TokenExchangeResponse) ProtoMessage() {} func (x *TokenExchangeResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[64] + mi := &file_api_agentmesh_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5355,7 +5629,7 @@ func (x *TokenExchangeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenExchangeResponse.ProtoReflect.Descriptor instead. func (*TokenExchangeResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{64} + return file_api_agentmesh_proto_rawDescGZIP(), []int{69} } func (x *TokenExchangeResponse) GetBiscuitToken() []byte { @@ -5411,7 +5685,7 @@ type STSTokenRequest struct { func (x *STSTokenRequest) Reset() { *x = STSTokenRequest{} - mi := &file_api_agentmesh_proto_msgTypes[65] + mi := &file_api_agentmesh_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5423,7 +5697,7 @@ func (x *STSTokenRequest) String() string { func (*STSTokenRequest) ProtoMessage() {} func (x *STSTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[65] + mi := &file_api_agentmesh_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5436,7 +5710,7 @@ func (x *STSTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use STSTokenRequest.ProtoReflect.Descriptor instead. func (*STSTokenRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{65} + return file_api_agentmesh_proto_rawDescGZIP(), []int{70} } func (x *STSTokenRequest) GetBiscuit() []byte { @@ -5487,7 +5761,7 @@ type STSTokenResponse struct { func (x *STSTokenResponse) Reset() { *x = STSTokenResponse{} - mi := &file_api_agentmesh_proto_msgTypes[66] + mi := &file_api_agentmesh_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5499,7 +5773,7 @@ func (x *STSTokenResponse) String() string { func (*STSTokenResponse) ProtoMessage() {} func (x *STSTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[66] + mi := &file_api_agentmesh_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5512,7 +5786,7 @@ func (x *STSTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use STSTokenResponse.ProtoReflect.Descriptor instead. func (*STSTokenResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{66} + return file_api_agentmesh_proto_rawDescGZIP(), []int{71} } func (x *STSTokenResponse) GetJwt() string { @@ -5563,7 +5837,7 @@ type RevocationsResponse struct { func (x *RevocationsResponse) Reset() { *x = RevocationsResponse{} - mi := &file_api_agentmesh_proto_msgTypes[67] + mi := &file_api_agentmesh_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5575,7 +5849,7 @@ func (x *RevocationsResponse) String() string { func (*RevocationsResponse) ProtoMessage() {} func (x *RevocationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[67] + mi := &file_api_agentmesh_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5588,7 +5862,7 @@ func (x *RevocationsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevocationsResponse.ProtoReflect.Descriptor instead. func (*RevocationsResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{67} + return file_api_agentmesh_proto_rawDescGZIP(), []int{72} } func (x *RevocationsResponse) GetRevocationIds() []string { @@ -5698,19 +5972,7 @@ const file_api_agentmesh_proto_rawDesc = "" + "\apeer_id\x18\x01 \x01(\tR\x06peerId\x12&\n" + "\x0flocal_proxy_url\x18\x02 \x01(\tR\rlocalProxyUrl\x12\x19\n" + "\bsrv_name\x18\x03 \x01(\tR\asrvName\x12'\n" + - "\x0fsrv_description\x18\x04 \x01(\tR\x0esrvDescription\"\xa0\x03\n" + - "\x0fServiceAnnounce\x12\x17\n" + - "\apeer_id\x18\x01 \x01(\tR\x06peerId\x12-\n" + - "\x04type\x18\x02 \x01(\x0e2\x19.agentmesh.v1.ServiceTypeR\x04type\x12!\n" + - "\fservice_name\x18\x03 \x01(\tR\vserviceName\x12\x12\n" + - "\x04keys\x18\x04 \x03(\tR\x04keys\x12A\n" + - "\x06labels\x18\x05 \x03(\v2).agentmesh.v1.ServiceAnnounce.LabelsEntryR\x06labels\x12'\n" + - "\x0factive_requests\x18\x06 \x01(\rR\x0eactiveRequests\x12&\n" + - "\x0flatency_ewma_ms\x18\a \x01(\x01R\rlatencyEwmaMs\x12?\n" + - "\rannounce_time\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\fannounceTime\x1a9\n" + - "\vLabelsEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xbc\x02\n" + + "\x0fsrv_description\x18\x04 \x01(\tR\x0esrvDescription\"\xbc\x02\n" + "\x18ControlPlaneInfoResponse\x12\x1f\n" + "\voidc_issuer\x18\x01 \x01(\tR\n" + "oidcIssuer\x12\x1b\n" + @@ -5724,7 +5986,29 @@ const file_api_agentmesh_proto_rawDesc = "" + "\vreplication\x18\x01 \x01(\x05R\vreplication\x12H\n" + "\x12reprovide_interval\x18\x02 \x01(\v2\x19.google.protobuf.DurationR\x11reprovideInterval\x128\n" + "\n" + - "record_ttl\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\trecordTtl\"\x89\x02\n" + + "record_ttl\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\trecordTtl\"\xe8\x01\n" + + "\x10DirectoryRequest\x12:\n" + + "\aprovide\x18\x01 \x01(\v2\x1e.agentmesh.v1.DirectoryProvideH\x00R\aprovide\x12M\n" + + "\x0efind_providers\x18\x02 \x01(\v2$.agentmesh.v1.DirectoryFindProvidersH\x00R\rfindProviders\x12>\n" + + "\tfind_peer\x18\x03 \x01(\v2\x1f.agentmesh.v1.DirectoryFindPeerH\x00R\bfindPeerB\t\n" + + "\arequest\"V\n" + + "\x10DirectoryProvide\x12\x10\n" + + "\x03key\x18\x01 \x01(\fR\x03key\x12\x14\n" + + "\x05addrs\x18\x02 \x03(\tR\x05addrs\x12\x1a\n" + + "\bprovider\x18\x03 \x01(\tR\bprovider\"@\n" + + "\x16DirectoryFindProviders\x12\x10\n" + + "\x03key\x18\x01 \x01(\fR\x03key\x12\x14\n" + + "\x05limit\x18\x02 \x01(\rR\x05limit\",\n" + + "\x11DirectoryFindPeer\x12\x17\n" + + "\apeer_id\x18\x01 \x01(\tR\x06peerId\"\x8f\x01\n" + + "\x11DirectoryResponse\x126\n" + + "\tproviders\x18\x01 \x03(\v2\x18.agentmesh.v1.PeerRecordR\tproviders\x12,\n" + + "\x04peer\x18\x02 \x01(\v2\x18.agentmesh.v1.PeerRecordR\x04peer\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\";\n" + + "\n" + + "PeerRecord\x12\x17\n" + + "\apeer_id\x18\x01 \x01(\tR\x06peerId\x12\x14\n" + + "\x05addrs\x18\x02 \x03(\tR\x05addrs\"\x89\x02\n" + "\n" + "RouterInfo\x12\x17\n" + "\apeer_id\x18\x01 \x01(\tR\x06peerId\x12\x1c\n" + @@ -6099,7 +6383,7 @@ func file_api_agentmesh_proto_rawDescGZIP() []byte { } var file_api_agentmesh_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_api_agentmesh_proto_msgTypes = make([]protoimpl.MessageInfo, 77) +var file_api_agentmesh_proto_msgTypes = make([]protoimpl.MessageInfo, 81) var file_api_agentmesh_proto_goTypes = []any{ (EnrollmentStatus)(0), // 0: agentmesh.v1.EnrollmentStatus (ServiceType)(0), // 1: agentmesh.v1.ServiceType @@ -6121,176 +6405,182 @@ var file_api_agentmesh_proto_goTypes = []any{ (*CommandBackend)(nil), // 17: agentmesh.v1.CommandBackend (*RegisterServiceRequest)(nil), // 18: agentmesh.v1.RegisterServiceRequest (*DiscoveredProvider)(nil), // 19: agentmesh.v1.DiscoveredProvider - (*ServiceAnnounce)(nil), // 20: agentmesh.v1.ServiceAnnounce - (*ControlPlaneInfoResponse)(nil), // 21: agentmesh.v1.ControlPlaneInfoResponse - (*DirectoryParameters)(nil), // 22: agentmesh.v1.DirectoryParameters - (*RouterInfo)(nil), // 23: agentmesh.v1.RouterInfo - (*RouterLeaseRequest)(nil), // 24: agentmesh.v1.RouterLeaseRequest - (*RouterLeaseResponse)(nil), // 25: agentmesh.v1.RouterLeaseResponse - (*PolicyRole)(nil), // 26: agentmesh.v1.PolicyRole - (*HTTPGrant)(nil), // 27: agentmesh.v1.HTTPGrant - (*EgressDestination)(nil), // 28: agentmesh.v1.EgressDestination - (*Inspection)(nil), // 29: agentmesh.v1.Inspection - (*Inspector)(nil), // 30: agentmesh.v1.Inspector - (*ModelArmor)(nil), // 31: agentmesh.v1.ModelArmor - (*ExtProc)(nil), // 32: agentmesh.v1.ExtProc - (*ExtProcProcessingMode)(nil), // 33: agentmesh.v1.ExtProcProcessingMode - (*CredentialBroker)(nil), // 34: agentmesh.v1.CredentialBroker - (*OIDCFederation)(nil), // 35: agentmesh.v1.OIDCFederation - (*AWSAssumeRole)(nil), // 36: agentmesh.v1.AWSAssumeRole - (*PlatformIdentity)(nil), // 37: agentmesh.v1.PlatformIdentity - (*PolicyBinding)(nil), // 38: agentmesh.v1.PolicyBinding - (*PolicyConfig)(nil), // 39: agentmesh.v1.PolicyConfig - (*PolicyConfigGetRequest)(nil), // 40: agentmesh.v1.PolicyConfigGetRequest - (*PolicyConfigGetResponse)(nil), // 41: agentmesh.v1.PolicyConfigGetResponse - (*PolicyConfigUpdateResponse)(nil), // 42: agentmesh.v1.PolicyConfigUpdateResponse - (*EgressAssignmentsRequest)(nil), // 43: agentmesh.v1.EgressAssignmentsRequest - (*EgressAssignmentsResponse)(nil), // 44: agentmesh.v1.EgressAssignmentsResponse - (*KeysResponse)(nil), // 45: agentmesh.v1.KeysResponse - (*TokenRefreshRequest)(nil), // 46: agentmesh.v1.TokenRefreshRequest - (*TokenRefreshResponse)(nil), // 47: agentmesh.v1.TokenRefreshResponse - (*NodeCatalogReport)(nil), // 48: agentmesh.v1.NodeCatalogReport - (*TokenRevokeRequest)(nil), // 49: agentmesh.v1.TokenRevokeRequest - (*TokenRevokeResponse)(nil), // 50: agentmesh.v1.TokenRevokeResponse - (*BootstrapTokenCreateRequest)(nil), // 51: agentmesh.v1.BootstrapTokenCreateRequest - (*BootstrapTokenCreateResponse)(nil), // 52: agentmesh.v1.BootstrapTokenCreateResponse - (*BootstrapToken)(nil), // 53: agentmesh.v1.BootstrapToken - (*BootstrapTokenListResponse)(nil), // 54: agentmesh.v1.BootstrapTokenListResponse - (*EnrollmentRequest)(nil), // 55: agentmesh.v1.EnrollmentRequest - (*EnrollmentRequestListResponse)(nil), // 56: agentmesh.v1.EnrollmentRequestListResponse - (*User)(nil), // 57: agentmesh.v1.User - (*EnrolledNode)(nil), // 58: agentmesh.v1.EnrolledNode - (*RouterLease)(nil), // 59: agentmesh.v1.RouterLease - (*NodeServices)(nil), // 60: agentmesh.v1.NodeServices - (*AdminStatusResponse)(nil), // 61: agentmesh.v1.AdminStatusResponse - (*UserStatusResponse)(nil), // 62: agentmesh.v1.UserStatusResponse - (*IdentityEvidenceResponse)(nil), // 63: agentmesh.v1.IdentityEvidenceResponse - (*PeerEvidenceResponse)(nil), // 64: agentmesh.v1.PeerEvidenceResponse - (*MemberCredential)(nil), // 65: agentmesh.v1.MemberCredential - (*TrustedSigningKey)(nil), // 66: agentmesh.v1.TrustedSigningKey - (*OIDCSession)(nil), // 67: agentmesh.v1.OIDCSession - (*TaskAuthorizationRule)(nil), // 68: agentmesh.v1.TaskAuthorizationRule - (*TaskRule)(nil), // 69: agentmesh.v1.TaskRule - (*TaskOperation)(nil), // 70: agentmesh.v1.TaskOperation - (*TokenExchangeRequest)(nil), // 71: agentmesh.v1.TokenExchangeRequest - (*TokenExchangeResponse)(nil), // 72: agentmesh.v1.TokenExchangeResponse - (*STSTokenRequest)(nil), // 73: agentmesh.v1.STSTokenRequest - (*STSTokenResponse)(nil), // 74: agentmesh.v1.STSTokenResponse - (*RevocationsResponse)(nil), // 75: agentmesh.v1.RevocationsResponse - nil, // 76: agentmesh.v1.EnrollRequest.LabelsEntry - nil, // 77: agentmesh.v1.BootstrapEnrollRequest.LabelsEntry - nil, // 78: agentmesh.v1.CommandBackend.EnvEntry - nil, // 79: agentmesh.v1.ServiceAnnounce.LabelsEntry - nil, // 80: agentmesh.v1.RouterInfo.LabelsEntry - nil, // 81: agentmesh.v1.EnrollmentRequest.LabelsEntry - nil, // 82: agentmesh.v1.EnrolledNode.LabelsEntry - nil, // 83: agentmesh.v1.AdminStatusResponse.NodeCatalogEntry - nil, // 84: agentmesh.v1.PeerEvidenceResponse.LabelsEntry - (*durationpb.Duration)(nil), // 85: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 86: google.protobuf.Timestamp + (*ControlPlaneInfoResponse)(nil), // 20: agentmesh.v1.ControlPlaneInfoResponse + (*DirectoryParameters)(nil), // 21: agentmesh.v1.DirectoryParameters + (*DirectoryRequest)(nil), // 22: agentmesh.v1.DirectoryRequest + (*DirectoryProvide)(nil), // 23: agentmesh.v1.DirectoryProvide + (*DirectoryFindProviders)(nil), // 24: agentmesh.v1.DirectoryFindProviders + (*DirectoryFindPeer)(nil), // 25: agentmesh.v1.DirectoryFindPeer + (*DirectoryResponse)(nil), // 26: agentmesh.v1.DirectoryResponse + (*PeerRecord)(nil), // 27: agentmesh.v1.PeerRecord + (*RouterInfo)(nil), // 28: agentmesh.v1.RouterInfo + (*RouterLeaseRequest)(nil), // 29: agentmesh.v1.RouterLeaseRequest + (*RouterLeaseResponse)(nil), // 30: agentmesh.v1.RouterLeaseResponse + (*PolicyRole)(nil), // 31: agentmesh.v1.PolicyRole + (*HTTPGrant)(nil), // 32: agentmesh.v1.HTTPGrant + (*EgressDestination)(nil), // 33: agentmesh.v1.EgressDestination + (*Inspection)(nil), // 34: agentmesh.v1.Inspection + (*Inspector)(nil), // 35: agentmesh.v1.Inspector + (*ModelArmor)(nil), // 36: agentmesh.v1.ModelArmor + (*ExtProc)(nil), // 37: agentmesh.v1.ExtProc + (*ExtProcProcessingMode)(nil), // 38: agentmesh.v1.ExtProcProcessingMode + (*CredentialBroker)(nil), // 39: agentmesh.v1.CredentialBroker + (*OIDCFederation)(nil), // 40: agentmesh.v1.OIDCFederation + (*AWSAssumeRole)(nil), // 41: agentmesh.v1.AWSAssumeRole + (*PlatformIdentity)(nil), // 42: agentmesh.v1.PlatformIdentity + (*PolicyBinding)(nil), // 43: agentmesh.v1.PolicyBinding + (*PolicyConfig)(nil), // 44: agentmesh.v1.PolicyConfig + (*PolicyConfigGetRequest)(nil), // 45: agentmesh.v1.PolicyConfigGetRequest + (*PolicyConfigGetResponse)(nil), // 46: agentmesh.v1.PolicyConfigGetResponse + (*PolicyConfigUpdateResponse)(nil), // 47: agentmesh.v1.PolicyConfigUpdateResponse + (*EgressAssignmentsRequest)(nil), // 48: agentmesh.v1.EgressAssignmentsRequest + (*EgressAssignmentsResponse)(nil), // 49: agentmesh.v1.EgressAssignmentsResponse + (*KeysResponse)(nil), // 50: agentmesh.v1.KeysResponse + (*TokenRefreshRequest)(nil), // 51: agentmesh.v1.TokenRefreshRequest + (*TokenRefreshResponse)(nil), // 52: agentmesh.v1.TokenRefreshResponse + (*NodeCatalogReport)(nil), // 53: agentmesh.v1.NodeCatalogReport + (*TokenRevokeRequest)(nil), // 54: agentmesh.v1.TokenRevokeRequest + (*TokenRevokeResponse)(nil), // 55: agentmesh.v1.TokenRevokeResponse + (*BootstrapTokenCreateRequest)(nil), // 56: agentmesh.v1.BootstrapTokenCreateRequest + (*BootstrapTokenCreateResponse)(nil), // 57: agentmesh.v1.BootstrapTokenCreateResponse + (*BootstrapToken)(nil), // 58: agentmesh.v1.BootstrapToken + (*BootstrapTokenListResponse)(nil), // 59: agentmesh.v1.BootstrapTokenListResponse + (*EnrollmentRequest)(nil), // 60: agentmesh.v1.EnrollmentRequest + (*EnrollmentRequestListResponse)(nil), // 61: agentmesh.v1.EnrollmentRequestListResponse + (*User)(nil), // 62: agentmesh.v1.User + (*EnrolledNode)(nil), // 63: agentmesh.v1.EnrolledNode + (*RouterLease)(nil), // 64: agentmesh.v1.RouterLease + (*NodeServices)(nil), // 65: agentmesh.v1.NodeServices + (*AdminStatusResponse)(nil), // 66: agentmesh.v1.AdminStatusResponse + (*UserStatusResponse)(nil), // 67: agentmesh.v1.UserStatusResponse + (*IdentityEvidenceResponse)(nil), // 68: agentmesh.v1.IdentityEvidenceResponse + (*PeerEvidenceResponse)(nil), // 69: agentmesh.v1.PeerEvidenceResponse + (*MemberCredential)(nil), // 70: agentmesh.v1.MemberCredential + (*TrustedSigningKey)(nil), // 71: agentmesh.v1.TrustedSigningKey + (*OIDCSession)(nil), // 72: agentmesh.v1.OIDCSession + (*TaskAuthorizationRule)(nil), // 73: agentmesh.v1.TaskAuthorizationRule + (*TaskRule)(nil), // 74: agentmesh.v1.TaskRule + (*TaskOperation)(nil), // 75: agentmesh.v1.TaskOperation + (*TokenExchangeRequest)(nil), // 76: agentmesh.v1.TokenExchangeRequest + (*TokenExchangeResponse)(nil), // 77: agentmesh.v1.TokenExchangeResponse + (*STSTokenRequest)(nil), // 78: agentmesh.v1.STSTokenRequest + (*STSTokenResponse)(nil), // 79: agentmesh.v1.STSTokenResponse + (*RevocationsResponse)(nil), // 80: agentmesh.v1.RevocationsResponse + nil, // 81: agentmesh.v1.EnrollRequest.LabelsEntry + nil, // 82: agentmesh.v1.BootstrapEnrollRequest.LabelsEntry + nil, // 83: agentmesh.v1.CommandBackend.EnvEntry + nil, // 84: agentmesh.v1.RouterInfo.LabelsEntry + nil, // 85: agentmesh.v1.EnrollmentRequest.LabelsEntry + nil, // 86: agentmesh.v1.EnrolledNode.LabelsEntry + nil, // 87: agentmesh.v1.AdminStatusResponse.NodeCatalogEntry + nil, // 88: agentmesh.v1.PeerEvidenceResponse.LabelsEntry + (*durationpb.Duration)(nil), // 89: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 90: google.protobuf.Timestamp } var file_api_agentmesh_proto_depIdxs = []int32{ 4, // 0: agentmesh.v1.RouterGoAway.reason:type_name -> agentmesh.v1.RouterGoAway.Reason - 85, // 1: agentmesh.v1.RouterGoAway.retry_after:type_name -> google.protobuf.Duration + 89, // 1: agentmesh.v1.RouterGoAway.retry_after:type_name -> google.protobuf.Duration 5, // 2: agentmesh.v1.MeshEvent.type:type_name -> agentmesh.v1.MeshEvent.Type - 86, // 3: agentmesh.v1.MeshEvent.event_time:type_name -> google.protobuf.Timestamp - 76, // 4: agentmesh.v1.EnrollRequest.labels:type_name -> agentmesh.v1.EnrollRequest.LabelsEntry - 86, // 5: agentmesh.v1.EnrollResponse.expire_time:type_name -> google.protobuf.Timestamp - 77, // 6: agentmesh.v1.BootstrapEnrollRequest.labels:type_name -> agentmesh.v1.BootstrapEnrollRequest.LabelsEntry + 90, // 3: agentmesh.v1.MeshEvent.event_time:type_name -> google.protobuf.Timestamp + 81, // 4: agentmesh.v1.EnrollRequest.labels:type_name -> agentmesh.v1.EnrollRequest.LabelsEntry + 90, // 5: agentmesh.v1.EnrollResponse.expire_time:type_name -> google.protobuf.Timestamp + 82, // 6: agentmesh.v1.BootstrapEnrollRequest.labels:type_name -> agentmesh.v1.BootstrapEnrollRequest.LabelsEntry 0, // 7: agentmesh.v1.BootstrapEnrollResponse.status:type_name -> agentmesh.v1.EnrollmentStatus - 86, // 8: agentmesh.v1.BootstrapEnrollResponse.expire_time:type_name -> google.protobuf.Timestamp + 90, // 8: agentmesh.v1.BootstrapEnrollResponse.expire_time:type_name -> google.protobuf.Timestamp 1, // 9: agentmesh.v1.ServiceInfo.type:type_name -> agentmesh.v1.ServiceType - 78, // 10: agentmesh.v1.CommandBackend.env:type_name -> agentmesh.v1.CommandBackend.EnvEntry + 83, // 10: agentmesh.v1.CommandBackend.env:type_name -> agentmesh.v1.CommandBackend.EnvEntry 16, // 11: agentmesh.v1.RegisterServiceRequest.service:type_name -> agentmesh.v1.ServiceInfo 17, // 12: agentmesh.v1.RegisterServiceRequest.command:type_name -> agentmesh.v1.CommandBackend - 1, // 13: agentmesh.v1.ServiceAnnounce.type:type_name -> agentmesh.v1.ServiceType - 79, // 14: agentmesh.v1.ServiceAnnounce.labels:type_name -> agentmesh.v1.ServiceAnnounce.LabelsEntry - 86, // 15: agentmesh.v1.ServiceAnnounce.announce_time:type_name -> google.protobuf.Timestamp - 23, // 16: agentmesh.v1.ControlPlaneInfoResponse.routers:type_name -> agentmesh.v1.RouterInfo - 22, // 17: agentmesh.v1.ControlPlaneInfoResponse.directory:type_name -> agentmesh.v1.DirectoryParameters - 85, // 18: agentmesh.v1.DirectoryParameters.reprovide_interval:type_name -> google.protobuf.Duration - 85, // 19: agentmesh.v1.DirectoryParameters.record_ttl:type_name -> google.protobuf.Duration - 80, // 20: agentmesh.v1.RouterInfo.labels:type_name -> agentmesh.v1.RouterInfo.LabelsEntry - 85, // 21: agentmesh.v1.RouterLeaseRequest.ttl:type_name -> google.protobuf.Duration - 86, // 22: agentmesh.v1.RouterLeaseResponse.expire_time:type_name -> google.protobuf.Timestamp - 27, // 23: agentmesh.v1.PolicyRole.http:type_name -> agentmesh.v1.HTTPGrant - 34, // 24: agentmesh.v1.EgressDestination.broker:type_name -> agentmesh.v1.CredentialBroker - 29, // 25: agentmesh.v1.EgressDestination.inspection:type_name -> agentmesh.v1.Inspection - 2, // 26: agentmesh.v1.EgressDestination.mode:type_name -> agentmesh.v1.EgressMode - 30, // 27: agentmesh.v1.Inspection.inspectors:type_name -> agentmesh.v1.Inspector - 31, // 28: agentmesh.v1.Inspector.model_armor:type_name -> agentmesh.v1.ModelArmor - 32, // 29: agentmesh.v1.Inspector.ext_proc:type_name -> agentmesh.v1.ExtProc - 3, // 30: agentmesh.v1.ModelArmor.response:type_name -> agentmesh.v1.ResponseInspection - 85, // 31: agentmesh.v1.ModelArmor.timeout:type_name -> google.protobuf.Duration - 33, // 32: agentmesh.v1.ExtProc.processing_mode:type_name -> agentmesh.v1.ExtProcProcessingMode - 85, // 33: agentmesh.v1.ExtProc.message_timeout:type_name -> google.protobuf.Duration - 6, // 34: agentmesh.v1.ExtProcProcessingMode.request_header_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode - 6, // 35: agentmesh.v1.ExtProcProcessingMode.response_header_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode - 7, // 36: agentmesh.v1.ExtProcProcessingMode.request_body_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.BodyMode - 7, // 37: agentmesh.v1.ExtProcProcessingMode.response_body_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.BodyMode - 6, // 38: agentmesh.v1.ExtProcProcessingMode.request_trailer_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode - 6, // 39: agentmesh.v1.ExtProcProcessingMode.response_trailer_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode - 35, // 40: agentmesh.v1.CredentialBroker.oidc_federation:type_name -> agentmesh.v1.OIDCFederation - 36, // 41: agentmesh.v1.CredentialBroker.aws_assume_role:type_name -> agentmesh.v1.AWSAssumeRole - 37, // 42: agentmesh.v1.CredentialBroker.platform_identity:type_name -> agentmesh.v1.PlatformIdentity - 26, // 43: agentmesh.v1.PolicyConfig.roles:type_name -> agentmesh.v1.PolicyRole - 38, // 44: agentmesh.v1.PolicyConfig.bindings:type_name -> agentmesh.v1.PolicyBinding - 28, // 45: agentmesh.v1.PolicyConfig.egress:type_name -> agentmesh.v1.EgressDestination - 28, // 46: agentmesh.v1.EgressAssignmentsResponse.egress:type_name -> agentmesh.v1.EgressDestination - 86, // 47: agentmesh.v1.KeysResponse.sign_time:type_name -> google.protobuf.Timestamp - 86, // 48: agentmesh.v1.TokenRefreshResponse.expire_time:type_name -> google.protobuf.Timestamp - 16, // 49: agentmesh.v1.NodeCatalogReport.services:type_name -> agentmesh.v1.ServiceInfo - 86, // 50: agentmesh.v1.BootstrapTokenCreateResponse.expire_time:type_name -> google.protobuf.Timestamp - 86, // 51: agentmesh.v1.BootstrapToken.create_time:type_name -> google.protobuf.Timestamp - 86, // 52: agentmesh.v1.BootstrapToken.expire_time:type_name -> google.protobuf.Timestamp - 86, // 53: agentmesh.v1.BootstrapToken.revoke_time:type_name -> google.protobuf.Timestamp - 53, // 54: agentmesh.v1.BootstrapTokenListResponse.tokens:type_name -> agentmesh.v1.BootstrapToken - 0, // 55: agentmesh.v1.EnrollmentRequest.status:type_name -> agentmesh.v1.EnrollmentStatus - 81, // 56: agentmesh.v1.EnrollmentRequest.labels:type_name -> agentmesh.v1.EnrollmentRequest.LabelsEntry - 86, // 57: agentmesh.v1.EnrollmentRequest.create_time:type_name -> google.protobuf.Timestamp - 86, // 58: agentmesh.v1.EnrollmentRequest.resolve_time:type_name -> google.protobuf.Timestamp - 55, // 59: agentmesh.v1.EnrollmentRequestListResponse.requests:type_name -> agentmesh.v1.EnrollmentRequest - 86, // 60: agentmesh.v1.User.create_time:type_name -> google.protobuf.Timestamp - 82, // 61: agentmesh.v1.EnrolledNode.labels:type_name -> agentmesh.v1.EnrolledNode.LabelsEntry - 86, // 62: agentmesh.v1.EnrolledNode.enroll_time:type_name -> google.protobuf.Timestamp - 86, // 63: agentmesh.v1.EnrolledNode.expire_time:type_name -> google.protobuf.Timestamp - 86, // 64: agentmesh.v1.RouterLease.last_renewal_time:type_name -> google.protobuf.Timestamp - 86, // 65: agentmesh.v1.RouterLease.expire_time:type_name -> google.protobuf.Timestamp - 16, // 66: agentmesh.v1.NodeServices.services:type_name -> agentmesh.v1.ServiceInfo - 86, // 67: agentmesh.v1.NodeServices.report_time:type_name -> google.protobuf.Timestamp - 57, // 68: agentmesh.v1.AdminStatusResponse.users:type_name -> agentmesh.v1.User - 59, // 69: agentmesh.v1.AdminStatusResponse.active_routers:type_name -> agentmesh.v1.RouterLease - 58, // 70: agentmesh.v1.AdminStatusResponse.enrolled_nodes:type_name -> agentmesh.v1.EnrolledNode - 55, // 71: agentmesh.v1.AdminStatusResponse.enrollment_requests:type_name -> agentmesh.v1.EnrollmentRequest - 53, // 72: agentmesh.v1.AdminStatusResponse.bootstrap_tokens:type_name -> agentmesh.v1.BootstrapToken - 39, // 73: agentmesh.v1.AdminStatusResponse.policy:type_name -> agentmesh.v1.PolicyConfig - 83, // 74: agentmesh.v1.AdminStatusResponse.node_catalog:type_name -> agentmesh.v1.AdminStatusResponse.NodeCatalogEntry - 57, // 75: agentmesh.v1.UserStatusResponse.user:type_name -> agentmesh.v1.User - 58, // 76: agentmesh.v1.UserStatusResponse.enrolled_nodes:type_name -> agentmesh.v1.EnrolledNode - 53, // 77: agentmesh.v1.UserStatusResponse.bootstrap_tokens:type_name -> agentmesh.v1.BootstrapToken - 59, // 78: agentmesh.v1.UserStatusResponse.active_routers:type_name -> agentmesh.v1.RouterLease - 39, // 79: agentmesh.v1.UserStatusResponse.policy:type_name -> agentmesh.v1.PolicyConfig - 86, // 80: agentmesh.v1.IdentityEvidenceResponse.biscuit_expire_time:type_name -> google.protobuf.Timestamp - 86, // 81: agentmesh.v1.IdentityEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp - 84, // 82: agentmesh.v1.PeerEvidenceResponse.labels:type_name -> agentmesh.v1.PeerEvidenceResponse.LabelsEntry - 86, // 83: agentmesh.v1.PeerEvidenceResponse.expire_time:type_name -> google.protobuf.Timestamp - 86, // 84: agentmesh.v1.PeerEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp - 86, // 85: agentmesh.v1.MemberCredential.expire_time:type_name -> google.protobuf.Timestamp - 66, // 86: agentmesh.v1.MemberCredential.trusted_keys:type_name -> agentmesh.v1.TrustedSigningKey - 67, // 87: agentmesh.v1.MemberCredential.oidc_session:type_name -> agentmesh.v1.OIDCSession - 86, // 88: agentmesh.v1.TrustedSigningKey.receive_time:type_name -> google.protobuf.Timestamp - 69, // 89: agentmesh.v1.TaskAuthorizationRule.rules:type_name -> agentmesh.v1.TaskRule - 86, // 90: agentmesh.v1.TaskAuthorizationRule.expire_time:type_name -> google.protobuf.Timestamp - 70, // 91: agentmesh.v1.TaskRule.operation:type_name -> agentmesh.v1.TaskOperation - 68, // 92: agentmesh.v1.TokenExchangeRequest.task_rule:type_name -> agentmesh.v1.TaskAuthorizationRule - 86, // 93: agentmesh.v1.TokenExchangeResponse.expire_time:type_name -> google.protobuf.Timestamp - 86, // 94: agentmesh.v1.STSTokenResponse.expire_time:type_name -> google.protobuf.Timestamp - 60, // 95: agentmesh.v1.AdminStatusResponse.NodeCatalogEntry.value:type_name -> agentmesh.v1.NodeServices - 96, // [96:96] is the sub-list for method output_type - 96, // [96:96] is the sub-list for method input_type - 96, // [96:96] is the sub-list for extension type_name - 96, // [96:96] is the sub-list for extension extendee - 0, // [0:96] is the sub-list for field type_name + 28, // 13: agentmesh.v1.ControlPlaneInfoResponse.routers:type_name -> agentmesh.v1.RouterInfo + 21, // 14: agentmesh.v1.ControlPlaneInfoResponse.directory:type_name -> agentmesh.v1.DirectoryParameters + 89, // 15: agentmesh.v1.DirectoryParameters.reprovide_interval:type_name -> google.protobuf.Duration + 89, // 16: agentmesh.v1.DirectoryParameters.record_ttl:type_name -> google.protobuf.Duration + 23, // 17: agentmesh.v1.DirectoryRequest.provide:type_name -> agentmesh.v1.DirectoryProvide + 24, // 18: agentmesh.v1.DirectoryRequest.find_providers:type_name -> agentmesh.v1.DirectoryFindProviders + 25, // 19: agentmesh.v1.DirectoryRequest.find_peer:type_name -> agentmesh.v1.DirectoryFindPeer + 27, // 20: agentmesh.v1.DirectoryResponse.providers:type_name -> agentmesh.v1.PeerRecord + 27, // 21: agentmesh.v1.DirectoryResponse.peer:type_name -> agentmesh.v1.PeerRecord + 84, // 22: agentmesh.v1.RouterInfo.labels:type_name -> agentmesh.v1.RouterInfo.LabelsEntry + 89, // 23: agentmesh.v1.RouterLeaseRequest.ttl:type_name -> google.protobuf.Duration + 90, // 24: agentmesh.v1.RouterLeaseResponse.expire_time:type_name -> google.protobuf.Timestamp + 32, // 25: agentmesh.v1.PolicyRole.http:type_name -> agentmesh.v1.HTTPGrant + 39, // 26: agentmesh.v1.EgressDestination.broker:type_name -> agentmesh.v1.CredentialBroker + 34, // 27: agentmesh.v1.EgressDestination.inspection:type_name -> agentmesh.v1.Inspection + 2, // 28: agentmesh.v1.EgressDestination.mode:type_name -> agentmesh.v1.EgressMode + 35, // 29: agentmesh.v1.Inspection.inspectors:type_name -> agentmesh.v1.Inspector + 36, // 30: agentmesh.v1.Inspector.model_armor:type_name -> agentmesh.v1.ModelArmor + 37, // 31: agentmesh.v1.Inspector.ext_proc:type_name -> agentmesh.v1.ExtProc + 3, // 32: agentmesh.v1.ModelArmor.response:type_name -> agentmesh.v1.ResponseInspection + 89, // 33: agentmesh.v1.ModelArmor.timeout:type_name -> google.protobuf.Duration + 38, // 34: agentmesh.v1.ExtProc.processing_mode:type_name -> agentmesh.v1.ExtProcProcessingMode + 89, // 35: agentmesh.v1.ExtProc.message_timeout:type_name -> google.protobuf.Duration + 6, // 36: agentmesh.v1.ExtProcProcessingMode.request_header_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode + 6, // 37: agentmesh.v1.ExtProcProcessingMode.response_header_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode + 7, // 38: agentmesh.v1.ExtProcProcessingMode.request_body_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.BodyMode + 7, // 39: agentmesh.v1.ExtProcProcessingMode.response_body_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.BodyMode + 6, // 40: agentmesh.v1.ExtProcProcessingMode.request_trailer_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode + 6, // 41: agentmesh.v1.ExtProcProcessingMode.response_trailer_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode + 40, // 42: agentmesh.v1.CredentialBroker.oidc_federation:type_name -> agentmesh.v1.OIDCFederation + 41, // 43: agentmesh.v1.CredentialBroker.aws_assume_role:type_name -> agentmesh.v1.AWSAssumeRole + 42, // 44: agentmesh.v1.CredentialBroker.platform_identity:type_name -> agentmesh.v1.PlatformIdentity + 31, // 45: agentmesh.v1.PolicyConfig.roles:type_name -> agentmesh.v1.PolicyRole + 43, // 46: agentmesh.v1.PolicyConfig.bindings:type_name -> agentmesh.v1.PolicyBinding + 33, // 47: agentmesh.v1.PolicyConfig.egress:type_name -> agentmesh.v1.EgressDestination + 33, // 48: agentmesh.v1.EgressAssignmentsResponse.egress:type_name -> agentmesh.v1.EgressDestination + 90, // 49: agentmesh.v1.KeysResponse.sign_time:type_name -> google.protobuf.Timestamp + 90, // 50: agentmesh.v1.TokenRefreshResponse.expire_time:type_name -> google.protobuf.Timestamp + 16, // 51: agentmesh.v1.NodeCatalogReport.services:type_name -> agentmesh.v1.ServiceInfo + 90, // 52: agentmesh.v1.BootstrapTokenCreateResponse.expire_time:type_name -> google.protobuf.Timestamp + 90, // 53: agentmesh.v1.BootstrapToken.create_time:type_name -> google.protobuf.Timestamp + 90, // 54: agentmesh.v1.BootstrapToken.expire_time:type_name -> google.protobuf.Timestamp + 90, // 55: agentmesh.v1.BootstrapToken.revoke_time:type_name -> google.protobuf.Timestamp + 58, // 56: agentmesh.v1.BootstrapTokenListResponse.tokens:type_name -> agentmesh.v1.BootstrapToken + 0, // 57: agentmesh.v1.EnrollmentRequest.status:type_name -> agentmesh.v1.EnrollmentStatus + 85, // 58: agentmesh.v1.EnrollmentRequest.labels:type_name -> agentmesh.v1.EnrollmentRequest.LabelsEntry + 90, // 59: agentmesh.v1.EnrollmentRequest.create_time:type_name -> google.protobuf.Timestamp + 90, // 60: agentmesh.v1.EnrollmentRequest.resolve_time:type_name -> google.protobuf.Timestamp + 60, // 61: agentmesh.v1.EnrollmentRequestListResponse.requests:type_name -> agentmesh.v1.EnrollmentRequest + 90, // 62: agentmesh.v1.User.create_time:type_name -> google.protobuf.Timestamp + 86, // 63: agentmesh.v1.EnrolledNode.labels:type_name -> agentmesh.v1.EnrolledNode.LabelsEntry + 90, // 64: agentmesh.v1.EnrolledNode.enroll_time:type_name -> google.protobuf.Timestamp + 90, // 65: agentmesh.v1.EnrolledNode.expire_time:type_name -> google.protobuf.Timestamp + 90, // 66: agentmesh.v1.RouterLease.last_renewal_time:type_name -> google.protobuf.Timestamp + 90, // 67: agentmesh.v1.RouterLease.expire_time:type_name -> google.protobuf.Timestamp + 16, // 68: agentmesh.v1.NodeServices.services:type_name -> agentmesh.v1.ServiceInfo + 90, // 69: agentmesh.v1.NodeServices.report_time:type_name -> google.protobuf.Timestamp + 62, // 70: agentmesh.v1.AdminStatusResponse.users:type_name -> agentmesh.v1.User + 64, // 71: agentmesh.v1.AdminStatusResponse.active_routers:type_name -> agentmesh.v1.RouterLease + 63, // 72: agentmesh.v1.AdminStatusResponse.enrolled_nodes:type_name -> agentmesh.v1.EnrolledNode + 60, // 73: agentmesh.v1.AdminStatusResponse.enrollment_requests:type_name -> agentmesh.v1.EnrollmentRequest + 58, // 74: agentmesh.v1.AdminStatusResponse.bootstrap_tokens:type_name -> agentmesh.v1.BootstrapToken + 44, // 75: agentmesh.v1.AdminStatusResponse.policy:type_name -> agentmesh.v1.PolicyConfig + 87, // 76: agentmesh.v1.AdminStatusResponse.node_catalog:type_name -> agentmesh.v1.AdminStatusResponse.NodeCatalogEntry + 62, // 77: agentmesh.v1.UserStatusResponse.user:type_name -> agentmesh.v1.User + 63, // 78: agentmesh.v1.UserStatusResponse.enrolled_nodes:type_name -> agentmesh.v1.EnrolledNode + 58, // 79: agentmesh.v1.UserStatusResponse.bootstrap_tokens:type_name -> agentmesh.v1.BootstrapToken + 64, // 80: agentmesh.v1.UserStatusResponse.active_routers:type_name -> agentmesh.v1.RouterLease + 44, // 81: agentmesh.v1.UserStatusResponse.policy:type_name -> agentmesh.v1.PolicyConfig + 90, // 82: agentmesh.v1.IdentityEvidenceResponse.biscuit_expire_time:type_name -> google.protobuf.Timestamp + 90, // 83: agentmesh.v1.IdentityEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp + 88, // 84: agentmesh.v1.PeerEvidenceResponse.labels:type_name -> agentmesh.v1.PeerEvidenceResponse.LabelsEntry + 90, // 85: agentmesh.v1.PeerEvidenceResponse.expire_time:type_name -> google.protobuf.Timestamp + 90, // 86: agentmesh.v1.PeerEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp + 90, // 87: agentmesh.v1.MemberCredential.expire_time:type_name -> google.protobuf.Timestamp + 71, // 88: agentmesh.v1.MemberCredential.trusted_keys:type_name -> agentmesh.v1.TrustedSigningKey + 72, // 89: agentmesh.v1.MemberCredential.oidc_session:type_name -> agentmesh.v1.OIDCSession + 90, // 90: agentmesh.v1.TrustedSigningKey.receive_time:type_name -> google.protobuf.Timestamp + 74, // 91: agentmesh.v1.TaskAuthorizationRule.rules:type_name -> agentmesh.v1.TaskRule + 90, // 92: agentmesh.v1.TaskAuthorizationRule.expire_time:type_name -> google.protobuf.Timestamp + 75, // 93: agentmesh.v1.TaskRule.operation:type_name -> agentmesh.v1.TaskOperation + 73, // 94: agentmesh.v1.TokenExchangeRequest.task_rule:type_name -> agentmesh.v1.TaskAuthorizationRule + 90, // 95: agentmesh.v1.TokenExchangeResponse.expire_time:type_name -> google.protobuf.Timestamp + 90, // 96: agentmesh.v1.STSTokenResponse.expire_time:type_name -> google.protobuf.Timestamp + 65, // 97: agentmesh.v1.AdminStatusResponse.NodeCatalogEntry.value:type_name -> agentmesh.v1.NodeServices + 98, // [98:98] is the sub-list for method output_type + 98, // [98:98] is the sub-list for method input_type + 98, // [98:98] is the sub-list for extension type_name + 98, // [98:98] is the sub-list for extension extendee + 0, // [0:98] is the sub-list for field type_name } func init() { file_api_agentmesh_proto_init() } @@ -6302,11 +6592,16 @@ func file_api_agentmesh_proto_init() { (*RegisterServiceRequest_TargetUrl)(nil), (*RegisterServiceRequest_Command)(nil), } - file_api_agentmesh_proto_msgTypes[22].OneofWrappers = []any{ + file_api_agentmesh_proto_msgTypes[14].OneofWrappers = []any{ + (*DirectoryRequest_Provide)(nil), + (*DirectoryRequest_FindProviders)(nil), + (*DirectoryRequest_FindPeer)(nil), + } + file_api_agentmesh_proto_msgTypes[27].OneofWrappers = []any{ (*Inspector_ModelArmor)(nil), (*Inspector_ExtProc)(nil), } - file_api_agentmesh_proto_msgTypes[26].OneofWrappers = []any{ + file_api_agentmesh_proto_msgTypes[31].OneofWrappers = []any{ (*CredentialBroker_StaticSecret)(nil), (*CredentialBroker_OidcFederation)(nil), (*CredentialBroker_AwsAssumeRole)(nil), @@ -6318,7 +6613,7 @@ func file_api_agentmesh_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_agentmesh_proto_rawDesc), len(file_api_agentmesh_proto_rawDesc)), NumEnums: 8, - NumMessages: 77, + NumMessages: 81, NumExtensions: 0, NumServices: 0, }, diff --git a/api/agentmesh.proto b/api/agentmesh.proto index ecf4e7f1f..e0a0f83ba 100644 --- a/api/agentmesh.proto +++ b/api/agentmesh.proto @@ -176,26 +176,6 @@ message DiscoveredProvider { string srv_description = 4; } -// ServiceAnnounce is gossiped by a node on per-key topics (see -// DiscoveryTopic) while those topics have subscribers. It is a routing -// hint signed at the pubsub layer by the announcing peer: consumers use -// it for freshness and load awareness, never for authorization. -message ServiceAnnounce { - string peer_id = 1; - ServiceType type = 2; - string service_name = 3; - // Routing keys served by this service: model IDs for inference, - // tool names for MCP. - repeated string keys = 4; - // Operator-declared labels (e.g. "region"). Operator claims always - // take precedence over runtime-derived values. - map labels = 5; - // Runtime load hints; zero values mean unknown. - uint32 active_requests = 6; - double latency_ewma_ms = 7; - google.protobuf.Timestamp announce_time = 8; -} - message ControlPlaneInfoResponse { string oidc_issuer = 1; string client_id = 2; @@ -227,9 +207,9 @@ message ControlPlaneInfoResponse { // once per mesh on the control plane (agentmesh-control-plane // --dht-replication, --dht-reprovide-interval) and published on /info. message DirectoryParameters { - // How many routers hold each record a member publishes and answer each - // lookup it makes: the routers closest to the key. A member holds a - // session with each of them besides the routers it is attached to. + // How many routers each record a member announces is placed on, the + // closest to its key, besides the router that took the announcement. + // Routers read it; a member asks the routers it holds. int32 replication = 1; // How often a member re-announces each service it publishes. google.protobuf.Duration reprovide_interval = 2; @@ -240,6 +220,57 @@ message DirectoryParameters { google.protobuf.Duration record_ttl = 3; } +// DirectoryRequest is what a member asks a router it is attached to over +// /mesh/directory/1.0.0: one request per stream, answered with one +// DirectoryResponse. The member keeps no DHT of its own; the router runs +// the lookup across the routers' DHT and answers from it. A router sends +// the same message to another router to place a member's record on it. +message DirectoryRequest { + oneof request { + DirectoryProvide provide = 1; + DirectoryFindProviders find_providers = 2; + DirectoryFindPeer find_peer = 3; + } +} + +// DirectoryProvide announces the sender as a provider of key, reached at +// addrs. A key is a sha256 multihash: of "mesh:service:[:]" for +// a service, of "mesh:peer:" for the member itself. provider is set +// only between routers, by the router that holds the member, and refused +// from anyone else: a member can only announce itself. +message DirectoryProvide { + bytes key = 1; + repeated string addrs = 2; + string provider = 3; +} + +// DirectoryFindProviders asks for up to limit providers of key. +message DirectoryFindProviders { + bytes key = 1; + uint32 limit = 2; +} + +// DirectoryFindPeer asks where peer_id is reached. +message DirectoryFindPeer { + string peer_id = 1; +} + +// DirectoryResponse answers one DirectoryRequest. providers answers +// find_providers; peer answers find_peer, unset when nobody knows the +// peer; error is set when the router could not run the request. +message DirectoryResponse { + repeated PeerRecord providers = 1; + PeerRecord peer = 2; + string error = 3; +} + +// PeerRecord is a peer and the addresses it is reached at, relayed ones +// included, each ending in its peer ID. +message PeerRecord { + string peer_id = 1; + repeated string addrs = 2; +} + // RouterInfo is one router as the control plane lists it on /info. message RouterInfo { string peer_id = 1; diff --git a/api/datalog.go b/api/datalog.go index 40eb618b4..9697b9fd5 100644 --- a/api/datalog.go +++ b/api/datalog.go @@ -319,8 +319,7 @@ const ( // attested with region="us-east-1" carries label("region", "us-east-1"), // satisfying `check if label("region", "us-east-1")` only — composition // across values is left entirely to the operator (attest as many labels - // as needed). Distinct from the unauthenticated gossip routing hint - // carried in ServiceAnnounce.labels. + // as needed). // Contains: biscuit.String(key), biscuit.String(value) // Example Datalog: check if label("region", "us-east-1") FactLabel = "label" diff --git a/api/directory_keys.go b/api/directory_keys.go new file mode 100644 index 000000000..ac923c530 --- /dev/null +++ b/api/directory_keys.go @@ -0,0 +1,73 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "crypto/sha256" + "fmt" + "strings" + + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/protocol" +) + +// DirectoryProtocolID is the libp2p protocol on which a member asks a +// router it is attached to for the directory: one DirectoryRequest per +// stream, one DirectoryResponse back. Routers place records on each other +// over it as well. +const DirectoryProtocolID protocol.ID = "/mesh/directory/1.0.0" + +// DirectoryKeyBytes is the length of a directory key: a sha256 multihash. +const DirectoryKeyBytes = 34 + +// DirectoryMaxAddrs bounds the addresses one record carries. +const DirectoryMaxAddrs = 32 + +// directoryKey is the sha256 multihash of a name: the two-byte multihash +// header (sha2-256, 32 bytes) and the digest. go-libp2p-kad-dht keys +// provider records by this multihash, so a record a member announces here +// is the record a router's DHT holds. +func directoryKey(name string) []byte { + sum := sha256.Sum256([]byte(name)) + return append([]byte{0x12, 0x20}, sum[:]...) +} + +// ServiceKey is the directory key of a service type, or of one named +// service of that type: "mesh:service:" or "mesh:service::". +func ServiceKey(t ServiceType, name string) ([]byte, error) { + typeStr, err := ServiceTypeToString(t) + if err != nil { + return nil, err + } + parts := []string{"mesh:service", typeStr} + if name != "" { + parts = append(parts, name) + } + return directoryKey(strings.Join(parts, ":")), nil +} + +// PeerKey is the directory key under which a member announces where it is +// reached: "mesh:peer:". A find_peer is a lookup of this key. +func PeerKey(p peer.ID) []byte { + return directoryKey("mesh:peer:" + p.String()) +} + +// ValidateDirectoryKey rejects anything but a sha256 multihash. +func ValidateDirectoryKey(key []byte) error { + if len(key) != DirectoryKeyBytes || key[0] != 0x12 || key[1] != 0x20 { + return fmt.Errorf("directory key must be a sha256 multihash of %d bytes, got %d", DirectoryKeyBytes, len(key)) + } + return nil +} diff --git a/api/directory_keys_test.go b/api/directory_keys_test.go new file mode 100644 index 000000000..4dd78499a --- /dev/null +++ b/api/directory_keys_test.go @@ -0,0 +1,70 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "bytes" + "encoding/hex" + "testing" + + "github.com/libp2p/go-libp2p/core/peer" +) + +// The keys are the sha256 multihash of a fixed name, so a record a member +// announces in any language lands where the others look it up. The vectors +// here are shared with the SDK tests. +func TestDirectoryKeys(t *testing.T) { + mcp, err := ServiceKey(ServiceType_SERVICE_TYPE_MCP, "") + if err != nil { + t.Fatal(err) + } + calc, err := ServiceKey(ServiceType_SERVICE_TYPE_MCP, "calculator") + if err != nil { + t.Fatal(err) + } + if bytes.Equal(mcp, calc) { + t.Fatal("a service type and one of its services share a key") + } + for name, key := range map[string][]byte{"mcp": mcp, "mcp:calculator": calc} { + if err := ValidateDirectoryKey(key); err != nil { + t.Errorf("%s: %v", name, err) + } + } + // sha256("mesh:service:mcp:calculator") behind the multihash header. + const want = "1220" + "e99b7ec759ced5276520910d75b1f6fe3a5baf5fe46655ac34957139e04a0859" + if got := hex.EncodeToString(calc); got != want { + t.Fatalf("calculator key = %s, want %s", got, want) + } + + if _, err := ServiceKey(ServiceType_SERVICE_TYPE_UNSPECIFIED, "x"); err == nil { + t.Error("an unspecified service type got a key") + } + + p, err := peer.Decode("12D3KooWA4Xop1JaT3MHxwYMkCepYsv4iPVopMXwCz5iHYdBfeSB") + if err != nil { + t.Fatal(err) + } + if err := ValidateDirectoryKey(PeerKey(p)); err != nil { + t.Error(err) + } + if bytes.Equal(PeerKey(p), mcp) { + t.Error("a peer key collides with a service key") + } + for _, bad := range [][]byte{nil, make([]byte, 33), append([]byte{0x11, 0x20}, make([]byte, 32)...)} { + if err := ValidateDirectoryKey(bad); err == nil { + t.Errorf("key %x accepted", bad) + } + } +} diff --git a/api/network.go b/api/network.go index 02a5a458a..ffc1d6126 100644 --- a/api/network.go +++ b/api/network.go @@ -15,8 +15,6 @@ package api import ( - "crypto/sha256" - "encoding/hex" "fmt" "regexp" "strconv" @@ -56,11 +54,6 @@ const ( // GossipControlPlaneSync is the GossipSub topic used by the control plane to sync cluster state. GossipControlPlaneSync = "/mesh/control-plane/sync/v1" - // DiscoveryTopicPrefix is the GossipSub topic namespace for interest-scoped - // service announcements (ServiceAnnounce messages). Full topics are built - // with DiscoveryTopic; the version segment allows wire evolution. - DiscoveryTopicPrefix = "/mesh/discovery/v1" - // DefaultAudience is the default audience string used in OIDC token validation. DefaultAudience = "agentmesh-audience" ) @@ -388,23 +381,6 @@ func ServiceTypeToString(t ServiceType) (string, error) { } } -// DiscoveryTopic returns the GossipSub topic for announcements about one -// routing key (a model ID for inference, a tool name for MCP). Keys are -// hashed so topic names stay bounded; consumers match exact keys from the -// ServiceAnnounce payload, so hash collisions only merge announcement -// streams, never routing decisions. -func DiscoveryTopic(t ServiceType, key string) (string, error) { - typeStr, err := ServiceTypeToString(t) - if err != nil { - return "", err - } - if key == "" { - return "", fmt.Errorf("discovery topic key cannot be empty") - } - sum := sha256.Sum256([]byte(key)) - return fmt.Sprintf("%s/%s/%s", DiscoveryTopicPrefix, typeStr, hex.EncodeToString(sum[:8])), nil -} - // ============================================================================ // Parsing & Routing Utilities // ============================================================================ diff --git a/api/validation.go b/api/validation.go index dfe67daaf..d9e7444a9 100644 --- a/api/validation.go +++ b/api/validation.go @@ -20,49 +20,6 @@ import ( "strings" ) -// Caps for ServiceAnnounce fields: announcements are unsolicited gossip, so -// receivers bound every dimension before processing. -const ( - MaxAnnounceKeys = 64 - MaxAnnounceLabels = 16 - MaxAnnounceStringLen = 256 -) - -// ValidateServiceAnnounce bounds and sanity-checks a gossiped announcement. -// Origin authenticity and freshness are the receiver's responsibility. -func ValidateServiceAnnounce(a *ServiceAnnounce) error { - if a == nil { - return fmt.Errorf("announce is nil") - } - if a.GetPeerId() == "" || len(a.GetPeerId()) > MaxAnnounceStringLen { - return fmt.Errorf("invalid peer_id") - } - if _, err := ServiceTypeToString(a.GetType()); err != nil { - return fmt.Errorf("invalid type: %w", err) - } - if a.GetServiceName() == "" || len(a.GetServiceName()) > MaxAnnounceStringLen { - return fmt.Errorf("invalid service_name") - } - if len(a.GetKeys()) == 0 || len(a.GetKeys()) > MaxAnnounceKeys { - return fmt.Errorf("keys count %d out of range [1, %d]", len(a.GetKeys()), MaxAnnounceKeys) - } - for _, k := range a.GetKeys() { - if k == "" || len(k) > MaxAnnounceStringLen { - return fmt.Errorf("invalid key %q", k) - } - } - if len(a.GetLabels()) > MaxAnnounceLabels { - return fmt.Errorf("labels count %d exceeds %d", len(a.GetLabels()), MaxAnnounceLabels) - } - if err := ValidateLabels(a.GetLabels()); err != nil { - return fmt.Errorf("invalid labels: %w", err) - } - if a.GetAnnounceTime() == nil || !a.GetAnnounceTime().IsValid() || a.GetAnnounceTime().AsTime().Unix() <= 0 { - return fmt.Errorf("missing or invalid announce_time") - } - return nil -} - // ValidateServiceFormat ensures the service string follows the explicit URI format. func ValidateServiceFormat(svc string) error { if svc == "*" { diff --git a/api/validation_test.go b/api/validation_test.go index a1b86dbb1..01dcb74a3 100644 --- a/api/validation_test.go +++ b/api/validation_test.go @@ -16,8 +16,6 @@ package api import ( "testing" - - "google.golang.org/protobuf/types/known/timestamppb" ) func TestValidateServiceFormat(t *testing.T) { @@ -83,32 +81,3 @@ func TestValidateTargetFormat(t *testing.T) { }) } } - -func TestValidateServiceAnnounce(t *testing.T) { - validAnnounce := func() *ServiceAnnounce { - return &ServiceAnnounce{ - PeerId: "12D3KooWA4Xop1JaT3MHxwYMkCepYsv4iPVopMXwCz5iHYdBfeSB", - Type: ServiceType_SERVICE_TYPE_MCP, - ServiceName: "weather", - Keys: []string{"get_weather"}, - Labels: map[string]string{"env": "prod"}, - AnnounceTime: timestamppb.Now(), - } - } - if err := ValidateServiceAnnounce(validAnnounce()); err != nil { - t.Fatalf("ValidateServiceAnnounce(valid) = %v", err) - } - - epochAnnounce := validAnnounce() - epochAnnounce.AnnounceTime.Seconds = 0 - epochAnnounce.AnnounceTime.Nanos = 0 - if err := ValidateServiceAnnounce(epochAnnounce); err == nil { - t.Fatal("expected epoch AnnounceTime to be rejected") - } - - badLabelAnnounce := validAnnounce() - badLabelAnnounce.Labels = map[string]string{"bad key!": "val"} - if err := ValidateServiceAnnounce(badLabelAnnounce); err == nil { - t.Fatal("expected invalid label key in ServiceAnnounce to be rejected") - } -} diff --git a/cmd/agentmesh-node/debug.go b/cmd/agentmesh-node/debug.go index a87d7bd0f..315fd315c 100644 --- a/cmd/agentmesh-node/debug.go +++ b/cmd/agentmesh-node/debug.go @@ -40,8 +40,8 @@ func newDebugCmd() *cobra.Command { debugCmd.PersistentFlags().StringVar(&socketPathFlag, "socket-path", "", "Unix socket of the running node (defaults to /"+node.DefaultSocketName+")") debugCmd.AddCommand( - newDebugGetCmd("mesh-info", "Show connected peers, the routers the node holds, and DHT size", "/debug/mesh-info"), - newDebugGetCmd("network-info", "Show listener and announced addresses", "/debug/network-info"), + newDebugGetCmd("mesh-info", "Show connected peers and the routers the node holds", "/debug/mesh-info"), + newDebugGetCmd("network-info", "Show listener, announced and directory addresses", "/debug/network-info"), newDebugGetCmd("token-info", "Show the local auth token's expiration and status", "/debug/token-info"), newDebugGetCmd("logs", "Show the last few lines of the node's log output", "/debug/logs"), ) diff --git a/cmd/agentmesh-router/main.go b/cmd/agentmesh-router/main.go index 5b423999e..ff801fb7c 100644 --- a/cmd/agentmesh-router/main.go +++ b/cmd/agentmesh-router/main.go @@ -17,6 +17,7 @@ package main import ( "context" "fmt" + "net" "os" "os/signal" "syscall" @@ -28,9 +29,27 @@ import ( "github.com/google/agentmesh/internal/router" "github.com/google/agentmesh/internal/version" golog "github.com/ipfs/go-log/v2" + madns "github.com/multiformats/go-multiaddr-dns" "github.com/spf13/cobra" ) +// The integration tests run a DNS server of their own for the dnsaddr +// addresses routers announce; the router resolves peer routers through it +// when told to, as agentmesh-node does. +func init() { + if dnsServer := os.Getenv("AGENTMESH_TEST_DNS_SERVER"); dnsServer != "" { + customResolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: 5 * time.Second} + return d.DialContext(ctx, "udp", dnsServer) + }, + } + net.DefaultResolver = customResolver + madns.DefaultResolver, _ = madns.NewResolver(madns.WithDefaultResolver(customResolver)) + } +} + var ( controlPlaneURL string insecureControlPlane bool diff --git a/development/kind/test-mesh-e2e.sh b/development/kind/test-mesh-e2e.sh index 873a36529..4b2517ebd 100755 --- a/development/kind/test-mesh-e2e.sh +++ b/development/kind/test-mesh-e2e.sh @@ -42,7 +42,7 @@ mcp() { ./bin/mcp-client -url "$URL" -token "devtoken" -timeout 20 "$@" 2>/dev/n echo "== get_mesh_info ==" info=$(mcp -tool get_mesh_info -args '{}') echo "$info" -echo "$info" | jq -e '.router_peer_id != "" and (.dht_size > 0)' >/dev/null \ +echo "$info" | jq -e '.router_peer_id != "" and (.attached_routers | length > 0)' >/dev/null \ || { echo "get_mesh_info assertion failed"; exit 1; } echo "== discover services / find calculator (poll) ==" diff --git a/go.mod b/go.mod index 45d084a08..58fcd9a7d 100644 --- a/go.mod +++ b/go.mod @@ -17,13 +17,13 @@ require ( github.com/libp2p/go-libp2p-gostream v0.6.0 github.com/libp2p/go-libp2p-http v0.5.0 github.com/libp2p/go-libp2p-kad-dht v0.42.2 + github.com/libp2p/go-libp2p-kbucket v0.9.0 github.com/libp2p/go-libp2p-pubsub v0.18.0 github.com/libp2p/go-msgio v0.3.0 github.com/mattn/go-isatty v0.0.24 github.com/modelcontextprotocol/go-sdk v1.8.0 github.com/multiformats/go-multiaddr v0.16.1 github.com/multiformats/go-multiaddr-dns v0.6.0 - github.com/multiformats/go-multihash v0.2.3 github.com/openai/openai-go v1.12.0 github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_model v0.6.3 @@ -80,7 +80,6 @@ require ( github.com/libp2p/go-cidranger v1.1.0 // indirect github.com/libp2p/go-flow-metrics v0.3.0 // indirect github.com/libp2p/go-libp2p-asn-util v0.4.1 // indirect - github.com/libp2p/go-libp2p-kbucket v0.9.0 // indirect github.com/libp2p/go-libp2p-record v0.3.1 // indirect github.com/libp2p/go-libp2p-routing-helpers v0.7.5 // indirect github.com/libp2p/go-netroute v0.4.0 // indirect @@ -96,6 +95,7 @@ require ( github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect github.com/multiformats/go-multibase v0.3.0 // indirect github.com/multiformats/go-multicodec v0.10.0 // indirect + github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-multistream v0.6.1 // indirect github.com/multiformats/go-varint v0.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/internal/node/auth_gate.go b/internal/node/auth_gate.go index baa410d78..ee35d8a14 100644 --- a/internal/node/auth_gate.go +++ b/internal/node/auth_gate.go @@ -107,6 +107,18 @@ func (n *AgentMeshNode) requireAuthenticated(next network.StreamHandler) network } } +// isCatalogRouter reports whether p is a router the control plane lists. +func (n *AgentMeshNode) isCatalogRouter(p peer.ID) bool { + n.mu.Lock() + defer n.mu.Unlock() + for _, c := range n.routerCatalog { + if c.ID == p { + return true + } + } + return false +} + // awaitPeerAuthenticated returns once p is authenticated, or with an error // when ctx ends, p's connections are gone, or authWait passes. func (n *AgentMeshNode) awaitPeerAuthenticated(ctx context.Context, p peer.ID) error { diff --git a/internal/node/debug_handlers.go b/internal/node/debug_handlers.go index f9b05cac2..075002407 100644 --- a/internal/node/debug_handlers.go +++ b/internal/node/debug_handlers.go @@ -109,8 +109,8 @@ func (n *AgentMeshNode) debugReady() error { return fmt.Errorf("node not initialized") case n.Host == nil: return fmt.Errorf("libp2p host not initialized") - case n.DHT == nil: - return fmt.Errorf("DHT not initialized") + case n.Directory == nil: + return fmt.Errorf("the directory is not up") case n.Store == nil: return fmt.Errorf("node store not initialized") } @@ -132,12 +132,8 @@ type meshInfoResponse struct { PeerID string `json:"peer_id"` ConnectedPeers []string `json:"connected_peers"` AttachedRouters []string `json:"attached_routers"` - // DirectoryRouters are the routers the node holds a session with for - // its DHT alone: the ones its keys landed on, not attached. - DirectoryRouters []string `json:"directory_routers"` - DHTSize int `json:"dht_size"` - RouterPeerID string `json:"router_peer_id"` - LocalAPISocket string `json:"local_api_socket,omitempty"` + RouterPeerID string `json:"router_peer_id"` + LocalAPISocket string `json:"local_api_socket,omitempty"` } type connectivityResponse struct { @@ -160,6 +156,16 @@ type tokenInfoResponse struct { type networkInfoResponse struct { ListenAddresses []string `json:"listen_addresses"` AnnouncedAddresses []string `json:"announced_addresses"` + // DirectoryAddresses is what the directory holds for this node: the + // addresses of the record it last announced, which follow + // AnnouncedAddresses at the next announcement pass. + DirectoryAddresses []string `json:"directory_addresses"` +} + +// recordAnnouncer is a directory that remembers the record it last +// announced. +type recordAnnouncer interface { + Announced() []multiaddr.Multiaddr } // peerAddrsResponse is what the DHT says about a peer: the addresses it @@ -194,21 +200,13 @@ func (n *AgentMeshNode) meshInfo() (*meshInfoResponse, error) { attachedRouters = append(attachedRouters, p.String()) } slices.Sort(attachedRouters) - directory := n.directoryRouters() - directoryRouters := make([]string, 0, len(directory)) - for _, p := range directory { - directoryRouters = append(directoryRouters, p.String()) - } - slices.Sort(directoryRouters) return &meshInfoResponse{ - PeerID: n.Host.ID().String(), - ConnectedPeers: connectedPeers, - AttachedRouters: attachedRouters, - DirectoryRouters: directoryRouters, - DHTSize: n.DHT.RoutingTable().Size(), - RouterPeerID: n.RouterPeerID.String(), - LocalAPISocket: n.BoundSocketPath, + PeerID: n.Host.ID().String(), + ConnectedPeers: connectedPeers, + AttachedRouters: attachedRouters, + RouterPeerID: n.RouterPeerID.String(), + LocalAPISocket: n.BoundSocketPath, }, nil } @@ -221,7 +219,7 @@ func (n *AgentMeshNode) peerAddrs(ctx context.Context, pid peer.ID) (*peerAddrsR } ctx, cancel := context.WithTimeout(ctx, dhtLookupTimeout) defer cancel() - info, err := n.DHT.FindPeer(ctx, pid) + info, err := n.Directory.FindPeer(ctx, pid) if err != nil { return nil, fmt.Errorf("find %s: %w", pid, err) } @@ -306,9 +304,17 @@ func (n *AgentMeshNode) networkInfo() networkInfoResponse { announcedAddrs = append(announcedAddrs, a.String()) } + directoryAddrs := []string{} + if d, ok := n.Directory.(recordAnnouncer); ok { + for _, a := range d.Announced() { + directoryAddrs = append(directoryAddrs, a.String()) + } + } + return networkInfoResponse{ ListenAddresses: listenAddrs, AnnouncedAddresses: announcedAddrs, + DirectoryAddresses: directoryAddrs, } } diff --git a/internal/node/dht.go b/internal/node/dht.go deleted file mode 100644 index 4add2d56d..000000000 --- a/internal/node/dht.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package node - -import ( - dht "github.com/libp2p/go-libp2p-kad-dht" - "github.com/libp2p/go-libp2p-kad-dht/records" - - "github.com/google/agentmesh/api" -) - -// dhtOptions configures the member's DHT. A member is a DHT client whatever -// its reachability: the routers are the directory, and a member that served -// it would sit in every other member's routing table and take their lookups -// on a 400-connection budget, answer whoever connects, and be trusted for -// unsigned provider records. A public member is still dialled directly; the -// role in the directory and the way it is reached are separate things. -// -// The table is not refreshed. kad-dht treats a table of ten peers or fewer -// as starved and, every two minutes, pings every entry and runs a lookup -// for the member's own ID against all of them; with the routers as the only -// servers that is a connection to every router the member is not attached -// to, closed by the router at its auth deadline and opened again two -// minutes later, for nothing the member needs: the servers are the routers -// the control plane lists, learned on attach and from closer-peer answers, -// and a router that leaves the catalog leaves the table (setRouterCatalog). -// Bootstrap still fills the table once at start. -// -// The record TTL and the replication factor are the mesh's -// (ControlPlaneInfoResponse.directory). The TTL is the same figure the -// routers keep a record for, so a record the member's own store holds and -// one a router holds expire alike. The replication factor r is kademlia's -// k on the member: a record is stored on the r routers closest to its key, -// a lookup asks those r, r at a time. The routers keep every router in -// their own tables (router.dhtOptions) and answer FIND_NODE with the -// closest routers to the key in order, so a member learns the r closest -// from one answer of a router it is attached to and reaches them in one -// hop. A member therefore holds sessions with its K attached routers plus -// the r closest to each key it publishes or looks up, whatever the number -// of routers; with the library's k of 20 that would be every router of a -// mesh with fewer than 20. -func dhtOptions(directory api.DirectorySettings) []dht.Option { - return []dht.Option{ - dht.Mode(dht.ModeClient), - dht.DisableAutoRefresh(), - dht.ProtocolPrefix(api.DHTProtocolPrefix), - dht.BucketSize(directory.Replication), - dht.Concurrency(directory.Replication), - dht.Resiliency(directory.Replication), - dht.ProviderManagerOpts(records.ProviderAddrTTL(directory.RecordTTL), records.ProvideValidity(directory.RecordTTL)), - } -} diff --git a/internal/node/dht_test.go b/internal/node/dht_test.go deleted file mode 100644 index 0b3b43e8d..000000000 --- a/internal/node/dht_test.go +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package node - -import ( - "context" - "fmt" - "slices" - "testing" - "time" - - "github.com/libp2p/go-libp2p" - dht "github.com/libp2p/go-libp2p-kad-dht" - "github.com/libp2p/go-libp2p/core/host" - "github.com/libp2p/go-libp2p/core/peer" - "github.com/libp2p/go-libp2p/core/protocol" - "github.com/libp2p/go-libp2p/core/test" - - "github.com/google/agentmesh/api" -) - -// TestMemberDHTStaysClientWhenPublic: a member whose reachability is public -// must not serve the DHT protocol, or it lands in every other member's -// routing table. The mode is the decision; the protocol list is what other -// members see of it. The control DHT, in the mode the node used before, -// shows that this host setup does flip a ModeAuto DHT to server. -func TestMemberDHTStaysClientWhenPublic(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - - newHost := func() host.Host { - h, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0"), libp2p.ForceReachabilityPublic()) - if err != nil { - t.Fatalf("libp2p.New: %v", err) - } - t.Cleanup(func() { _ = h.Close() }) - return h - } - serves := func(h host.Host) bool { - return slices.Contains(h.Mux().Protocols(), protocol.ID(api.DHTProtocolID)) - } - - control := newHost() - controlDHT, err := dht.New(control, dht.Mode(dht.ModeAuto), dht.ProtocolPrefix(api.DHTProtocolPrefix)) - if err != nil { - t.Fatalf("control dht.New: %v", err) - } - t.Cleanup(func() { _ = controlDHT.Close() }) - - member := newHost() - memberDHT, err := dht.New(member, dhtOptions(api.DefaultDirectorySettings())...) - if err != nil { - t.Fatalf("member dht.New: %v", err) - } - t.Cleanup(func() { _ = memberDHT.Close() }) - if err := memberDHT.Bootstrap(ctx); err != nil { - t.Fatalf("Bootstrap: %v", err) - } - - deadline := time.Now().Add(10 * time.Second) - for !serves(control) { - if time.Now().After(deadline) { - t.Fatalf("control DHT in ModeAuto on a public host never served %s; protocols: %v", api.DHTProtocolID, control.Mux().Protocols()) - } - time.Sleep(20 * time.Millisecond) - } - - if got := memberDHT.Mode(); got != dht.ModeClient { - t.Errorf("member DHT mode = %v, want %v", got, dht.ModeClient) - } - if serves(member) { - t.Errorf("member on a public host serves %s; protocols: %v", api.DHTProtocolID, member.Mux().Protocols()) - } -} - -// TestSetRouterCatalogPrunesDHTTable: with kad-dht's liveness pings off, the -// catalog is what takes a router out of the member's DHT table. -func TestSetRouterCatalogPrunesDHTTable(t *testing.T) { - h, err := libp2p.New(libp2p.NoListenAddrs) - if err != nil { - t.Fatalf("libp2p.New: %v", err) - } - t.Cleanup(func() { _ = h.Close() }) - kdht, err := dht.New(h, dhtOptions(api.DefaultDirectorySettings())...) - if err != nil { - t.Fatalf("dht.New: %v", err) - } - t.Cleanup(func() { _ = kdht.Close() }) - - listed, gone := test.RandPeerIDFatal(t), test.RandPeerIDFatal(t) - for _, p := range []peer.ID{listed, gone} { - if added, err := kdht.RoutingTable().TryAddPeer(p, false, false); err != nil || !added { - t.Fatalf("TryAddPeer(%s) = %v, %v; want added", p, added, err) - } - } - - n := &AgentMeshNode{config: Options{Routers: 2}, Host: h, DHT: kdht} - n.setRouterCatalog([]routerCandidate{{AddrInfo: peer.AddrInfo{ID: listed}}}) - - got := kdht.RoutingTable().ListPeers() - if len(got) != 1 || got[0] != listed { - t.Fatalf("routing table after the catalog dropped %s = %v, want [%s]", gone, got, listed) - } - - // An empty catalog says nothing about the routers and must not empty the table. - n.setRouterCatalog(nil) - if got := kdht.RoutingTable().ListPeers(); len(got) != 1 { - t.Fatalf("routing table after an empty catalog = %v, want [%s]", got, listed) - } -} - -// TestMemberDHTBucketHoldsTheReplicationFactor: the mesh's replication -// factor is kademlia's k on the member, and a bucket of its table holds -// that many. -func TestMemberDHTBucketHoldsTheReplicationFactor(t *testing.T) { - for _, r := range []int{2, api.DefaultDHTReplication, 5} { - t.Run(fmt.Sprintf("replication=%d", r), func(t *testing.T) { - h, err := libp2p.New(libp2p.NoListenAddrs) - if err != nil { - t.Fatalf("libp2p.New: %v", err) - } - t.Cleanup(func() { _ = h.Close() }) - settings := api.DefaultDirectorySettings() - settings.Replication = r - kdht, err := dht.New(h, dhtOptions(settings)...) - if err != nil { - t.Fatalf("dht.New: %v", err) - } - t.Cleanup(func() { _ = kdht.Close() }) - rt := kdht.RoutingTable() - added := 0 - for i := 0; i < r+1; i++ { - p, err := rt.GenRandPeerID(0) - if err != nil { - t.Fatalf("GenRandPeerID: %v", err) - } - // The last one is refused for capacity; any other refusal is a test bug. - if ok, err := rt.TryAddPeer(p, true, false); ok { - added++ - } else if added < r { - t.Fatalf("TryAddPeer #%d refused before the bucket was full: %v", i, err) - } - } - if added != r { - t.Fatalf("a bucket took %d peers, want %d", added, r) - } - }) - } -} diff --git a/internal/node/directory.go b/internal/node/directory.go deleted file mode 100644 index ec44351da..000000000 --- a/internal/node/directory.go +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package node - -import ( - "context" - - "github.com/libp2p/go-libp2p/core/peer" -) - -// authDirectoryRouter runs the credential handshake with a router this node -// just connected to without being attached to it. The DHT client opens such -// connections for a lookup or an announcement, to the routers kademlia puts -// closest to the key; left anonymous, the router closes them at its auth -// deadline a minute later and the next lookup opens them again. Handshaken, -// they are sessions: the router keeps them, counts them, and the node's -// lookups reuse them. The routers a node holds this way are its K attached -// ones plus those its keys land on, which is how a mesh with more routers -// spreads them; a node's own connection manager trims the idle ones first. -// Attached routers handshake on attach, and a connection that arrives while -// that handshake is in flight waits for it (authRouter). -func (n *AgentMeshNode) authDirectoryRouter(ctx context.Context, router peer.ID) { - if n.isAttached(router) || n.isAuthenticatedAndConnected(router) || !n.isCatalogRouter(router) { - return - } - go func() { - biscuitBytes, err := n.loadIdentityForAuth() - if err != nil { - logger.Debugf("[AuthN] Cannot authenticate to directory router %s without an identity: %v", router, err) - return - } - if err := n.authRouter(ctx, peer.AddrInfo{ID: router}, biscuitBytes); err != nil { - directoryHandshakesTotal.WithLabelValues("failed").Inc() - logger.Warnf("[AuthN] Directory router %s did not admit this node; the router will close the connection at its auth deadline: %v", router, err) - return - } - directoryHandshakesTotal.WithLabelValues("ok").Inc() - logger.Infof("[AuthN] Authenticated to directory router %s", router) - }() -} - -// isCatalogRouter reports whether p is a router the control plane lists. -func (n *AgentMeshNode) isCatalogRouter(p peer.ID) bool { - n.mu.Lock() - defer n.mu.Unlock() - for _, c := range n.routerCatalog { - if c.ID == p { - return true - } - } - return false -} - -// directoryRouters are the routers this node holds an authenticated session -// with for its DHT alone: connected and admitted, not attached. -func (n *AgentMeshNode) directoryRouters() []peer.ID { - attached := map[peer.ID]bool{} - n.mu.Lock() - for r := range n.attached { - attached[r] = true - } - n.mu.Unlock() - var out []peer.ID - for _, r := range n.authenticatedRouterIDs() { - if !attached[r] { - out = append(out, r) - } - } - return out -} - -// DirectoryRouters is how many routers this node holds for its DHT alone. -func (n *AgentMeshNode) DirectoryRouters() int { return len(n.directoryRouters()) } diff --git a/internal/node/directory_client.go b/internal/node/directory_client.go new file mode 100644 index 000000000..789038230 --- /dev/null +++ b/internal/node/directory_client.go @@ -0,0 +1,279 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package node + +import ( + "context" + "errors" + "fmt" + "slices" + "sync" + "time" + + "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/peerstore" + "github.com/libp2p/go-libp2p/core/routing" + "github.com/libp2p/go-msgio" + "github.com/multiformats/go-multiaddr" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "google.golang.org/protobuf/proto" + + "github.com/google/agentmesh/api" +) + +// Directory is the mesh's directory as a member sees it: where it +// announces what it serves and where it is reached, and where it looks +// peers and services up. The routers the member is attached to answer for +// it over api.DirectoryProtocolID; the member keeps no DHT of its own. +type Directory interface { + // Provide announces this member as a provider of key, at the + // addresses it announces, to a router it is attached to; the router + // keeps the record and places it on the routers closest to the key. + Provide(ctx context.Context, key []byte) error + // FindProviders is who provides key, up to limit, with their addresses. + FindProviders(ctx context.Context, key []byte, limit int) ([]peer.AddrInfo, error) + // FindPeer is where p is reached; routing.ErrNotFound when nobody knows. + FindPeer(ctx context.Context, p peer.ID) (peer.AddrInfo, error) +} + +const ( + // directoryRequestTimeout bounds one request to one router: the router's + // lookup across the mesh included, which is one round between routers + // that all know each other. + directoryRequestTimeout = 10 * time.Second + directoryMaxMessage = 64 * 1024 + directoryDefaultLimit = 20 +) + +var errNoDirectoryRouter = errors.New("no attached router to ask") + +var directoryRequestsTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "agentmesh_node_directory_requests_total", + Help: "Directory requests this node made to its routers, by operation and result; failed is a request no attached router answered", + }, + []string{"op", "result"}, +) + +var directoryRequestSeconds = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "agentmesh_node_directory_request_seconds", + Help: "Time a directory request took, the router's lookup included", + Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}, + }, + []string{"op"}, +) + +const ( + directoryOpProvide = "provide" + directoryOpFindProviders = "find_providers" + directoryOpFindPeer = "find_peer" + + directoryResultOK = "ok" + directoryResultEmpty = "empty" + directoryResultFailed = "failed" +) + +func init() { + for _, op := range []string{directoryOpProvide, directoryOpFindProviders, directoryOpFindPeer} { + for _, result := range []string{directoryResultOK, directoryResultEmpty, directoryResultFailed} { + directoryRequestsTotal.WithLabelValues(op, result) + } + } +} + +// routerDirectory asks the routers the node is attached to. A request goes +// to one of them; when it fails, the next is asked, so one router that is +// restarting costs a request a timeout and not an answer. +type routerDirectory struct { + host host.Host // the gated host: a stream to a router follows its handshake + routers func() []peer.ID + addrs func() []multiaddr.Multiaddr + + mu sync.Mutex + announced []multiaddr.Multiaddr // the addresses of the record last announced +} + +func (d *routerDirectory) Provide(ctx context.Context, key []byte) error { + addrs := d.addrs() + if len(addrs) > api.DirectoryMaxAddrs { + addrs = addrs[:api.DirectoryMaxAddrs] + } + rec := &api.DirectoryProvide{Key: key, Addrs: make([]string, 0, len(addrs))} + for _, a := range addrs { + rec.Addrs = append(rec.Addrs, a.String()) + } + if _, err := d.request(ctx, directoryOpProvide, &api.DirectoryRequest{Request: &api.DirectoryRequest_Provide{Provide: rec}}); err != nil { + return err + } + d.mu.Lock() + d.announced = addrs + d.mu.Unlock() + return nil +} + +// Announced is the addresses of the record this member last announced: +// what the directory holds for it, as against what it would announce now. +func (d *routerDirectory) Announced() []multiaddr.Multiaddr { + d.mu.Lock() + defer d.mu.Unlock() + return slices.Clone(d.announced) +} + +func (d *routerDirectory) FindProviders(ctx context.Context, key []byte, limit int) ([]peer.AddrInfo, error) { + if limit <= 0 { + limit = directoryDefaultLimit + } + resp, err := d.request(ctx, directoryOpFindProviders, &api.DirectoryRequest{Request: &api.DirectoryRequest_FindProviders{ + FindProviders: &api.DirectoryFindProviders{Key: key, Limit: uint32(limit)}, + }}) + if err != nil { + return nil, err + } + out := make([]peer.AddrInfo, 0, len(resp.GetProviders())) + for _, rec := range resp.GetProviders() { + info, err := addrInfoOf(rec) + if err != nil { + logger.Warnf("[Directory] Skipping a provider record the router sent: %v", err) + continue + } + out = append(out, info) + } + return out, nil +} + +func (d *routerDirectory) FindPeer(ctx context.Context, p peer.ID) (peer.AddrInfo, error) { + resp, err := d.request(ctx, directoryOpFindPeer, &api.DirectoryRequest{Request: &api.DirectoryRequest_FindPeer{ + FindPeer: &api.DirectoryFindPeer{PeerId: p.String()}, + }}) + if err != nil { + return peer.AddrInfo{}, err + } + if resp.GetPeer() == nil { + return peer.AddrInfo{ID: p}, routing.ErrNotFound + } + info, err := addrInfoOf(resp.GetPeer()) + if err != nil { + return peer.AddrInfo{ID: p}, err + } + if info.ID != p { + return peer.AddrInfo{ID: p}, fmt.Errorf("the router answered for %s", info.ID) + } + return info, nil +} + +// request sends req to the attached routers in turn until one answers. +func (d *routerDirectory) request(ctx context.Context, op string, req *api.DirectoryRequest) (*api.DirectoryResponse, error) { + start := time.Now() + defer func() { directoryRequestSeconds.WithLabelValues(op).Observe(time.Since(start).Seconds()) }() + data, err := proto.Marshal(req) + if err != nil { + return nil, err + } + routers := d.routers() + if len(routers) == 0 { + directoryRequestsTotal.WithLabelValues(op, directoryResultFailed).Inc() + logger.Warnf("[Directory] %s: %v", op, errNoDirectoryRouter) + return nil, errNoDirectoryRouter + } + var lastErr error + for _, router := range routers { + resp, err := d.ask(ctx, router, data) + if err == nil { + result := directoryResultOK + if op != directoryOpProvide && len(resp.GetProviders()) == 0 && resp.GetPeer() == nil { + result = directoryResultEmpty + } + directoryRequestsTotal.WithLabelValues(op, result).Inc() + return resp, nil + } + lastErr = err + logger.Warnf("[Directory] %s through router %s failed: %v", op, router, err) + if ctx.Err() != nil { + break + } + } + directoryRequestsTotal.WithLabelValues(op, directoryResultFailed).Inc() + return nil, fmt.Errorf("%s: no attached router answered: %w", op, lastErr) +} + +// ask sends one encoded request to one router and reads its answer. +func (d *routerDirectory) ask(ctx context.Context, router peer.ID, data []byte) (*api.DirectoryResponse, error) { + ctx, cancel := context.WithTimeout(ctx, directoryRequestTimeout) + defer cancel() + s, err := d.host.NewStream(ctx, router, api.DirectoryProtocolID) + if err != nil { + return nil, err + } + defer func() { _ = s.Close() }() + if deadline, ok := ctx.Deadline(); ok { + _ = s.SetDeadline(deadline) + } + if err := msgio.NewVarintWriter(s).WriteMsg(data); err != nil { + _ = s.Reset() + return nil, err + } + reader := msgio.NewVarintReaderSize(s, directoryMaxMessage) + frame, err := reader.ReadMsg() + if err != nil { + _ = s.Reset() + return nil, err + } + defer reader.ReleaseMsg(frame) + var resp api.DirectoryResponse + if err := proto.Unmarshal(frame, &resp); err != nil { + return nil, err + } + if resp.GetError() != "" { + return nil, errors.New(resp.GetError()) + } + return &resp, nil +} + +// addrInfoOf parses a record into a peer and its transport addresses, the +// peer suffix off, as the peerstore keeps them; an address naming another +// peer is the router's mistake and is dropped. +func addrInfoOf(rec *api.PeerRecord) (peer.AddrInfo, error) { + p, err := peer.Decode(rec.GetPeerId()) + if err != nil { + return peer.AddrInfo{}, fmt.Errorf("peer %q: %w", rec.GetPeerId(), err) + } + info := peer.AddrInfo{ID: p} + for _, a := range rec.GetAddrs() { + ma, err := multiaddr.NewMultiaddr(a) + if err != nil { + return peer.AddrInfo{}, fmt.Errorf("address %q of %s: %w", a, p, err) + } + transport, named := peer.SplitAddr(ma) + if named != "" && named != p { + continue + } + if transport != nil { + info.Addrs = append(info.Addrs, transport) + } + } + return info, nil +} + +// rememberAddrs keeps what a lookup learned for a dial that follows. +func rememberAddrs(ps peerstore.Peerstore, infos ...peer.AddrInfo) { + for _, info := range infos { + if len(info.Addrs) > 0 { + ps.AddAddrs(info.ID, info.Addrs, peerstore.TempAddrTTL) + } + } +} diff --git a/internal/node/directory_client_test.go b/internal/node/directory_client_test.go new file mode 100644 index 000000000..dc295d531 --- /dev/null +++ b/internal/node/directory_client_test.go @@ -0,0 +1,289 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package node + +import ( + "bytes" + "context" + "slices" + "sync" + "testing" + "time" + + "github.com/libp2p/go-libp2p" + "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/network" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/routing" + "github.com/libp2p/go-msgio" + "github.com/multiformats/go-multiaddr" + "google.golang.org/protobuf/proto" + + "github.com/google/agentmesh/api" +) + +// fakeDirectoryServer answers /mesh/directory on a test router's host the +// way a router does: records by key, a peer from the router's own peerstore +// or its peer-key record. It fails every request while failing is set. +type fakeDirectoryServer struct { + h host.Host + mu sync.Mutex + records map[string]map[peer.ID][]multiaddr.Multiaddr + provided [][]byte // keys announced here, in order + served int + failing bool +} + +func serveDirectory(h host.Host) *fakeDirectoryServer { + d := &fakeDirectoryServer{h: h, records: map[string]map[peer.ID][]multiaddr.Multiaddr{}} + h.SetStreamHandler(api.DirectoryProtocolID, d.handle) + return d +} + +func (d *fakeDirectoryServer) fail(failing bool) { + d.mu.Lock() + defer d.mu.Unlock() + d.failing = failing +} + +func (d *fakeDirectoryServer) servedCount() int { + d.mu.Lock() + defer d.mu.Unlock() + return d.served +} + +func (d *fakeDirectoryServer) providers(key []byte) []peer.ID { + d.mu.Lock() + defer d.mu.Unlock() + var out []peer.ID + for p := range d.records[string(key)] { + out = append(out, p) + } + return out +} + +// providedKeys is the keys announced here so far, in order. +func (d *fakeDirectoryServer) providedKeys() [][]byte { + d.mu.Lock() + defer d.mu.Unlock() + return slices.Clone(d.provided) +} + +// add puts a record in as if another router had placed it. +func (d *fakeDirectoryServer) add(key []byte, p peer.ID, addrs ...multiaddr.Multiaddr) { + d.mu.Lock() + defer d.mu.Unlock() + if d.records[string(key)] == nil { + d.records[string(key)] = map[peer.ID][]multiaddr.Multiaddr{} + } + d.records[string(key)][p] = addrs +} + +func (d *fakeDirectoryServer) handle(s network.Stream) { + defer func() { _ = s.Close() }() + frame, err := msgio.NewVarintReaderSize(s, directoryMaxMessage).ReadMsg() + if err != nil { + return + } + var req api.DirectoryRequest + if err := proto.Unmarshal(frame, &req); err != nil { + return + } + d.mu.Lock() + d.served++ + resp := &api.DirectoryResponse{} + switch { + case d.failing: + resp.Error = "the fake router is failing" + case req.GetProvide() != nil: + q := req.GetProvide() + var addrs []multiaddr.Multiaddr + for _, a := range q.GetAddrs() { + if ma, err := multiaddr.NewMultiaddr(a); err == nil { + if transport, _ := peer.SplitAddr(ma); transport != nil { + addrs = append(addrs, transport) + } + } + } + if d.records[string(q.GetKey())] == nil { + d.records[string(q.GetKey())] = map[peer.ID][]multiaddr.Multiaddr{} + } + d.records[string(q.GetKey())][s.Conn().RemotePeer()] = addrs + d.provided = append(d.provided, q.GetKey()) + case req.GetFindProviders() != nil: + for p, addrs := range d.records[string(req.GetFindProviders().GetKey())] { + resp.Providers = append(resp.Providers, recordOf(p, addrs)) + } + case req.GetFindPeer() != nil: + p, err := peer.Decode(req.GetFindPeer().GetPeerId()) + if err != nil { + resp.Error = err.Error() + break + } + if addrs := d.h.Peerstore().Addrs(p); len(addrs) > 0 { + resp.Peer = recordOf(p, addrs) + } else if addrs, ok := d.records[string(api.PeerKey(p))][p]; ok { + resp.Peer = recordOf(p, addrs) + } + } + d.mu.Unlock() + data, _ := proto.Marshal(resp) + _ = msgio.NewVarintWriter(s).WriteMsg(data) +} + +func recordOf(p peer.ID, addrs []multiaddr.Multiaddr) *api.PeerRecord { + rec := &api.PeerRecord{PeerId: p.String()} + for _, a := range addrs { + rec.Addrs = append(rec.Addrs, a.Encapsulate(multiaddr.StringCast("/p2p/"+p.String())).String()) + } + return rec +} + +// newDirectoryClient is a member host with a directory over the given +// router hosts, no handshake in between: the fake routers do not gate. +func newDirectoryClient(t *testing.T, routers ...host.Host) (host.Host, *routerDirectory) { + t.Helper() + h, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = h.Close() }) + ids := make([]peer.ID, 0, len(routers)) + for _, r := range routers { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := h.Connect(ctx, peer.AddrInfo{ID: r.ID(), Addrs: r.Addrs()}); err != nil { + cancel() + t.Fatal(err) + } + cancel() + ids = append(ids, r.ID()) + } + d := &routerDirectory{host: h, routers: func() []peer.ID { return ids }, addrs: h.Addrs} + return h, d +} + +// A member announces what it serves and where it is reached to a router it +// is attached to, and looks peers and services up there. A router that +// fails is skipped for the next one the member holds; a lookup nobody has +// a record for is an empty answer, and a peer nobody knows is not found. +func TestRouterDirectoryAsksTheAttachedRouters(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + r1 := newFakeRouter(t, "/ip4/127.0.0.1/tcp/0") + r2 := newFakeRouterWithKey(t, r1.cpPub, r1.cpPriv, "/ip4/127.0.0.1/tcp/0") + dir1, dir2 := serveDirectory(r1.h), serveDirectory(r2.h) + member, directory := newDirectoryClient(t, r1.h, r2.h) + key, err := api.ServiceKey(api.ServiceType_SERVICE_TYPE_MCP, "calculator") + if err != nil { + t.Fatal(err) + } + + if err := directory.Provide(ctx, key); err != nil { + t.Fatalf("Provide: %v", err) + } + if got := dir1.providers(key); len(got) != 1 || got[0] != member.ID() { + t.Fatalf("the first router holds %v, want the member's record", got) + } + if got := dir2.providers(key); len(got) != 0 { + t.Fatalf("the second router holds %v; a record goes to one router, which places it", got) + } + + found, err := directory.FindProviders(ctx, key, 0) + if err != nil { + t.Fatalf("FindProviders: %v", err) + } + if len(found) != 1 || found[0].ID != member.ID() || len(found[0].Addrs) == 0 { + t.Fatalf("FindProviders = %v, want the member with its addresses", found) + } + if _, named := peer.SplitAddr(found[0].Addrs[0].Encapsulate(multiaddr.StringCast("/p2p/" + member.ID().String()))); named != member.ID() { + t.Fatalf("addresses come back without the peer suffix: %v", found[0].Addrs) + } + + unknown, _ := api.ServiceKey(api.ServiceType_SERVICE_TYPE_MCP, "nobody") + if found, err := directory.FindProviders(ctx, unknown, 0); err != nil || len(found) != 0 { + t.Fatalf("FindProviders of a key nobody announced = %v, %v; want none and no error", found, err) + } + + stranger := newFakeRouter(t, "/ip4/127.0.0.1/tcp/0").h.ID() + if _, err := directory.FindPeer(ctx, stranger); err != routing.ErrNotFound { + t.Fatalf("FindPeer of a peer nobody knows: err = %v, want %v", err, routing.ErrNotFound) + } + info, err := directory.FindPeer(ctx, member.ID()) + if err != nil || info.ID != member.ID() || len(info.Addrs) == 0 { + t.Fatalf("FindPeer of the member = %v, %v; want it from the router's own connection", info, err) + } + + // The first router fails: the second answers, and the member is none the wiser. + dir1.fail(true) + dir2.add(key, member.ID(), member.Addrs()...) + before := dir2.servedCount() + if found, err := directory.FindProviders(ctx, key, 0); err != nil || len(found) != 1 { + t.Fatalf("FindProviders with the first router failing = %v, %v; want the second router's answer", found, err) + } + if got := dir2.servedCount(); got != before+1 { + t.Fatalf("the second router served %d requests, want %d: it must be asked when the first fails", got, before+1) + } + dir2.fail(true) + if _, err := directory.FindProviders(ctx, key, 0); err == nil { + t.Fatal("FindProviders succeeded with every router failing") + } +} + +// An announcement pass announces the node's services and then the node +// itself under its peer key, so a router that does not hold the node can +// still say where it is reached, and the pass is what the directory holds +// for the node: its last announced addresses are the ones the records +// carry, and a pass no router took leaves them as they were. +func TestAnnounceAllEndsWithThePeerRecord(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + router := newFakeRouter(t, "/ip4/127.0.0.1/tcp/0") + dir := serveDirectory(router.h) + member, directory := newDirectoryClient(t, router.h) + if got := directory.Announced(); len(got) != 0 { + t.Fatalf("Announced before any announcement = %v, want none", got) + } + node := &AgentMeshNode{Host: member, Directory: directory, services: NewServiceRegistry(directory, 0)} + if err := node.services.Register(ctx, newFakeSvc("calc", api.ServiceType_SERVICE_TYPE_MCP)); err != nil { + t.Fatalf("Register: %v", err) + } + registered := len(dir.providedKeys()) + + if withheld := node.announceAll(ctx); withheld != 0 { + t.Fatalf("announceAll withheld %d services, want 0", withheld) + } + nameKey, _ := api.ServiceKey(api.ServiceType_SERVICE_TYPE_MCP, "calc") + typeKey, _ := api.ServiceKey(api.ServiceType_SERVICE_TYPE_MCP, "") + pass := dir.providedKeys()[registered:] + if len(pass) != 3 || !bytes.Equal(pass[0], nameKey) || !bytes.Equal(pass[1], typeKey) || !bytes.Equal(pass[2], api.PeerKey(member.ID())) { + t.Fatalf("the pass announced %d keys, want the service by name, by type, then the peer key last", len(pass)) + } + if got := dir.providers(api.PeerKey(member.ID())); len(got) != 1 || got[0] != member.ID() { + t.Fatalf("the router holds %v under the peer key, want the member", got) + } + info, err := directory.FindPeer(ctx, member.ID()) + if err != nil || len(info.Addrs) != len(member.Addrs()) { + t.Fatalf("FindPeer = %v, %v; want the member at its %d addresses", info, err, len(member.Addrs())) + } + if got, want := directory.Announced(), member.Addrs(); !slices.EqualFunc(got, want, multiaddr.Multiaddr.Equal) { + t.Fatalf("Announced = %v, want the addresses the pass carried, %v", got, want) + } + + dir.fail(true) + node.announceAll(ctx) + if got, want := directory.Announced(), member.Addrs(); !slices.EqualFunc(got, want, multiaddr.Multiaddr.Equal) { + t.Fatalf("Announced after a pass no router took = %v, want the last announced %v", got, want) + } +} diff --git a/internal/node/directory_test.go b/internal/node/directory_test.go deleted file mode 100644 index 01dc190ff..000000000 --- a/internal/node/directory_test.go +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package node - -import ( - "context" - "testing" - "time" - - "github.com/libp2p/go-libp2p/core/peer" -) - -// TestConnectingToAnUnattachedRouterHandshakes: a connection the node opens -// to a catalog router it is not attached to, as its DHT does for a lookup, -// is handshaken once and counted as a directory session; the attached -// router's handshake is not repeated by it. -func TestConnectingToAnUnattachedRouterHandshakes(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - - r1 := newFakeRouter(t, "/ip4/127.0.0.1/tcp/0") - r2 := newFakeRouterWithKey(t, r1.cpPub, r1.cpPriv, "/ip4/127.0.0.1/tcp/0") - node := r1.startNode(t, ctx, append(r1.routerAddrs, r2.routerAddrs...), func(o *Options) { o.Routers = 1 }) - - attached := node.attachedRouters() - if len(attached) != 1 { - t.Fatalf("attached routers = %v, want one", attached) - } - other := r2 - if attached[0] == r2.h.ID() { - other = r1 - } - if got := node.DirectoryRouters(); got != 0 { - t.Fatalf("directory routers before any lookup = %d, want 0", got) - } - - // What the DHT client does when a key lands on the other router. - if err := node.Host.Connect(ctx, peer.AddrInfo{ID: other.h.ID(), Addrs: other.h.Addrs()}); err != nil { - t.Fatalf("Connect: %v", err) - } - deadline := time.Now().Add(5 * time.Second) - for !node.isAuthenticatedAndConnected(other.h.ID()) { - if time.Now().After(deadline) { - t.Fatalf("node did not authenticate to %s after connecting to it; handshakes seen there: %d", other.h.ID(), other.handshakes.Load()) - } - time.Sleep(20 * time.Millisecond) - } - if got := other.handshakes.Load(); got != 1 { - t.Errorf("unattached router saw %d handshakes, want 1", got) - } - if got := node.DirectoryRouters(); got != 1 { - t.Errorf("directory routers = %d, want 1", got) - } - if got := node.AttachedRouters(); got != 1 { - t.Errorf("attached routers after the directory handshake = %d, want 1", got) - } - if r1.handshakes.Load()+r2.handshakes.Load() != 2 { - t.Errorf("handshakes in all = %d, want 2: one per router", r1.handshakes.Load()+r2.handshakes.Load()) - } -} diff --git a/internal/node/discovery/announcer.go b/internal/node/discovery/announcer.go deleted file mode 100644 index fb1bc1c19..000000000 --- a/internal/node/discovery/announcer.go +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package discovery - -import ( - "context" - "math/rand/v2" - "sync" - "time" - - "github.com/google/agentmesh/api" - pubsub "github.com/libp2p/go-libp2p-pubsub" - "github.com/libp2p/go-libp2p/core/peer" - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/timestamppb" -) - -// Discovery is the node-facing handle: it announces local services -// (provider role) and maintains an interest-scoped view of remote -// providers (consumer role) over a shared set of gossip topics. -type Discovery struct { - ps *pubsub.PubSub - self peer.ID - tm *topicManager - - interval time.Duration - interestTTL time.Duration - staleAfter time.Duration - maxProviders int - - runCtx context.Context - - // announceTopics is only touched by the announce loop goroutine. - announceTopics map[string]*heldTopic - - viewMu sync.Mutex - interests map[string]*interest - providers map[string]Provider // keyed peer|type|service -} - -type heldTopic struct { - topic *pubsub.Topic - release func() -} - -type interest struct { - sub *pubsub.Subscription - release func() - expires time.Time -} - -// Option tweaks Discovery timing/bounds (used by tests). -type Option func(*Discovery) - -func WithIntervals(announce, interestTTL, staleAfter time.Duration) Option { - return func(d *Discovery) { - d.interval = announce - d.interestTTL = interestTTL - d.staleAfter = staleAfter - } -} - -func WithMaxProviders(n int) Option { - return func(d *Discovery) { d.maxProviders = n } -} - -func New(ps *pubsub.PubSub, self peer.ID, opts ...Option) *Discovery { - d := &Discovery{ - ps: ps, - self: self, - tm: newTopicManager(ps), - interval: DefaultAnnounceInterval, - interestTTL: DefaultInterestTTL, - staleAfter: DefaultStaleAfter, - maxProviders: DefaultMaxProviders, - announceTopics: map[string]*heldTopic{}, - interests: map[string]*interest{}, - providers: map[string]Provider{}, - } - for _, o := range opts { - o(d) - } - return d -} - -// Start runs the announce and interest-janitor loops until ctx is done. -// src may be nil for consume-only nodes. -func (d *Discovery) Start(ctx context.Context, src SourceFunc) { - d.runCtx = ctx - go d.janitorLoop(ctx) - if src != nil { - go d.announceLoop(ctx, src) - } -} - -func (d *Discovery) announceLoop(ctx context.Context, src SourceFunc) { - for { - // ±20% jitter to avoid mesh-wide synchronization. - jittered := d.interval + time.Duration((rand.Float64()-0.5)*0.4*float64(d.interval)) - select { - case <-ctx.Done(): - for _, held := range d.announceTopics { - held.release() - } - d.announceTopics = map[string]*heldTopic{} - return - case <-time.After(jittered): - d.announceOnce(ctx, src()) - } - } -} - -// announceOnce reconciles the joined topic set with the desired one and -// publishes on every desired topic that currently has subscribers. -func (d *Discovery) announceOnce(ctx context.Context, anns []Announcement) { - desired := map[string][][]byte{} - - for _, ann := range anns { - msg := &api.ServiceAnnounce{ - PeerId: d.self.String(), - Type: ann.Type, - ServiceName: ann.Name, - Keys: ann.Keys, - Labels: ann.Labels, - ActiveRequests: ann.Load.ActiveRequests, - LatencyEwmaMs: ann.Load.LatencyEWMAMs, - AnnounceTime: timestamppb.Now(), - } - if err := api.ValidateServiceAnnounce(msg); err != nil { - logger.Warnf("[Discovery] skipping invalid announcement for %q: %v", ann.Name, err) - continue - } - data, err := proto.Marshal(msg) - if err != nil { - continue - } - for _, key := range ann.Keys { - name, err := api.DiscoveryTopic(ann.Type, key) - if err != nil { - continue - } - desired[name] = append(desired[name], data) - } - } - - // Release topics no longer served. - for name, held := range d.announceTopics { - if _, ok := desired[name]; !ok { - held.release() - delete(d.announceTopics, name) - } - } - - for name, msgs := range desired { - held, ok := d.announceTopics[name] - if !ok { - topic, release, err := d.tm.acquire(name) - if err != nil { - logger.Warnf("[Discovery] join %s: %v", name, err) - continue - } - held = &heldTopic{topic: topic, release: release} - d.announceTopics[name] = held - } - // Publish-when-subscribed: silence costs nothing on an idle mesh. - if len(held.topic.ListPeers()) == 0 { - continue - } - for _, data := range msgs { - if err := held.topic.Publish(ctx, data); err != nil { - logger.Debugf("[Discovery] publish %s: %v", name, err) - } - } - } -} diff --git a/internal/node/discovery/discovery.go b/internal/node/discovery/discovery.go deleted file mode 100644 index d2e94cb1a..000000000 --- a/internal/node/discovery/discovery.go +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Package discovery provides interest-scoped service announcements over -// GossipSub. Providers announce routing keys (model IDs, tool names) on -// per-key topics only while those topics have subscribers; consumers -// subscribe only for keys they actively use. Cost therefore scales with -// interest, not mesh size. Announcements are routing hints authenticated by -// the pubsub layer's message signing — never authorization inputs. -package discovery - -import ( - "time" - - "github.com/google/agentmesh/api" - golog "github.com/ipfs/go-log/v2" -) - -var logger = golog.Logger("agentmesh-discovery") - -const ( - // DefaultAnnounceInterval is how often a provider publishes on topics - // with subscribers; each tick is jittered ±20%. - DefaultAnnounceInterval = 10 * time.Second - // DefaultInterestTTL is how long a consumer stays subscribed to a key's - // topic after the last Ensure call for it. - DefaultInterestTTL = 5 * time.Minute - // DefaultStaleAfter is the age beyond which a provider entry is no - // longer returned by Providers. - DefaultStaleAfter = 30 * time.Second - // DefaultMaxProviders bounds the consumer-side provider table. - DefaultMaxProviders = 1024 - // MaxProvidersPerSigner bounds how many entries one peer may hold in - // the table: entries are keyed by signer|type|service, so without this - // a single peer announcing many service names could evict everyone - // else's entries (the global cap evicts oldest, not loudest). - MaxProvidersPerSigner = 16 - // maxAnnounceSkew rejects announcements too old or too far in the - // future (replay/clock defense; pubsub dedup handles exact replays). - maxAnnounceSkew = 2 * time.Minute -) - -// Load carries a provider's runtime hints. Zero values mean unknown. -type Load struct { - ActiveRequests uint32 - LatencyEWMAMs float64 -} - -// Announcement is one service's advertised state, fed by the node. -type Announcement struct { - Type api.ServiceType - Name string - Keys []string - Labels map[string]string - Load Load -} - -// SourceFunc supplies the current local announcements on each tick. -type SourceFunc func() []Announcement - -// Provider is a remote service observed via gossip. -type Provider struct { - PeerID string - Type api.ServiceType - Service string - Keys []string - Labels map[string]string - Load Load - LastSeen time.Time -} - -// ServesKey reports whether the provider advertised the given routing key. -func (p Provider) ServesKey(key string) bool { - for _, k := range p.Keys { - if k == key { - return true - } - } - return false -} diff --git a/internal/node/discovery/discovery_test.go b/internal/node/discovery/discovery_test.go deleted file mode 100644 index 89035e971..000000000 --- a/internal/node/discovery/discovery_test.go +++ /dev/null @@ -1,371 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package discovery - -import ( - "context" - "crypto/rand" - "sync/atomic" - "testing" - "time" - - "github.com/google/agentmesh/api" - pubsub "github.com/libp2p/go-libp2p-pubsub" - pubsub_pb "github.com/libp2p/go-libp2p-pubsub/pb" - "github.com/libp2p/go-libp2p/core/crypto" - "github.com/libp2p/go-libp2p/core/peer" - mocknet "github.com/libp2p/go-libp2p/p2p/net/mock" - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/timestamppb" -) - -// newPair builds two connected in-memory hosts with gossipsub and Discovery. -func newPair(t *testing.T) (provider, consumer *Discovery) { - t.Helper() - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - - mn := mocknet.New() - h1, err := mn.GenPeer() - if err != nil { - t.Fatal(err) - } - h2, err := mn.GenPeer() - if err != nil { - t.Fatal(err) - } - if err := mn.LinkAll(); err != nil { - t.Fatal(err) - } - if err := mn.ConnectAllButSelf(); err != nil { - t.Fatal(err) - } - - ps1, err := pubsub.NewGossipSub(ctx, h1) - if err != nil { - t.Fatal(err) - } - ps2, err := pubsub.NewGossipSub(ctx, h2) - if err != nil { - t.Fatal(err) - } - - opts := []Option{WithIntervals(100*time.Millisecond, 5*time.Second, 2*time.Second)} - provider = New(ps1, h1.ID(), opts...) - consumer = New(ps2, h2.ID(), opts...) - return provider, consumer -} - -func TestAnnounceReachesInterestedConsumer(t *testing.T) { - provider, consumer := newPair(t) - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - - provider.Start(ctx, func() []Announcement { - return []Announcement{{ - Type: api.ServiceType_SERVICE_TYPE_INFERENCE, - Name: "llm", - Keys: []string{"model-a", "model-b"}, - Labels: map[string]string{"region": "eu"}, - Load: Load{ActiveRequests: 3}, - }} - }) - consumer.Start(ctx, nil) - - // Interest registered after the announcer is already running: the - // publish-when-subscribed gate must open once the subscription spreads. - consumer.Ensure(api.ServiceType_SERVICE_TYPE_INFERENCE, "model-a") - - deadline := time.Now().Add(10 * time.Second) - for time.Now().Before(deadline) { - if ps := consumer.Providers(api.ServiceType_SERVICE_TYPE_INFERENCE, "model-a"); len(ps) == 1 { - p := ps[0] - if p.Service != "llm" || p.Labels["region"] != "eu" || p.Load.ActiveRequests != 3 { - t.Fatalf("unexpected provider: %+v", p) - } - if !p.ServesKey("model-b") { - t.Fatalf("provider should carry all keys, got %v", p.Keys) - } - // Uninterested key on the same service: not subscribed, but the - // payload still resolves it once observed. - if ps2 := consumer.Providers(api.ServiceType_SERVICE_TYPE_INFERENCE, "model-b"); len(ps2) != 1 { - t.Fatalf("model-b should resolve from the same announcement, got %v", ps2) - } - // Wrong type must not match. - if ps3 := consumer.Providers(api.ServiceType_SERVICE_TYPE_MCP, "model-a"); len(ps3) != 0 { - t.Fatalf("MCP lookup should be empty, got %v", ps3) - } - return - } - time.Sleep(50 * time.Millisecond) - } - t.Fatal("timeout waiting for announcement to reach consumer") -} - -func TestProvidersGoStaleWithoutAnnouncements(t *testing.T) { - provider, consumer := newPair(t) - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - - var announcing atomic.Bool - announcing.Store(true) - provider.Start(ctx, func() []Announcement { - if !announcing.Load() { - return nil - } - return []Announcement{{ - Type: api.ServiceType_SERVICE_TYPE_INFERENCE, - Name: "llm", - Keys: []string{"model-a"}, - }} - }) - consumer.Start(ctx, nil) - consumer.Ensure(api.ServiceType_SERVICE_TYPE_INFERENCE, "model-a") - - deadline := time.Now().Add(10 * time.Second) - for time.Now().Before(deadline) { - if len(consumer.Providers(api.ServiceType_SERVICE_TYPE_INFERENCE, "model-a")) == 1 { - break - } - time.Sleep(50 * time.Millisecond) - } - - announcing.Store(false) - deadline = time.Now().Add(10 * time.Second) - for time.Now().Before(deadline) { - if len(consumer.Providers(api.ServiceType_SERVICE_TYPE_INFERENCE, "model-a")) == 0 { - return // went stale as expected - } - time.Sleep(100 * time.Millisecond) - } - t.Fatal("provider never went stale after announcements stopped") -} - -// testPeerID generates a peer identity for constructing raw messages. -func testPeerID(t *testing.T) peer.ID { - t.Helper() - priv, _, err := crypto.GenerateEd25519Key(rand.Reader) - if err != nil { - t.Fatal(err) - } - pid, err := peer.IDFromPrivateKey(priv) - if err != nil { - t.Fatal(err) - } - return pid -} - -func rawMessage(t *testing.T, from peer.ID, ann *api.ServiceAnnounce) *pubsub.Message { - t.Helper() - data, err := proto.Marshal(ann) - if err != nil { - t.Fatal(err) - } - return &pubsub.Message{Message: &pubsub_pb.Message{From: []byte(from), Data: data}} -} - -func TestObserveRejectsForgedAndStale(t *testing.T) { - d := New(nil, testPeerID(t)) - signer := testPeerID(t) - other := testPeerID(t) - - valid := &api.ServiceAnnounce{ - PeerId: signer.String(), - Type: api.ServiceType_SERVICE_TYPE_INFERENCE, - ServiceName: "llm", - Keys: []string{"m1"}, - AnnounceTime: timestamppb.Now(), - } - - tests := []struct { - name string - msg *pubsub.Message - want int - }{ - {"valid announce accepted", rawMessage(t, signer, valid), 1}, - {"peer_id not matching signer dropped", rawMessage(t, other, valid), 0}, - {"stale timestamp dropped", rawMessage(t, signer, &api.ServiceAnnounce{ - PeerId: signer.String(), Type: api.ServiceType_SERVICE_TYPE_INFERENCE, - ServiceName: "llm", Keys: []string{"m1"}, - AnnounceTime: timestamppb.New(time.Now().Add(-10 * time.Minute)), - }), 0}, - {"future timestamp dropped", rawMessage(t, signer, &api.ServiceAnnounce{ - PeerId: signer.String(), Type: api.ServiceType_SERVICE_TYPE_INFERENCE, - ServiceName: "llm", Keys: []string{"m1"}, - AnnounceTime: timestamppb.New(time.Now().Add(10 * time.Minute)), - }), 0}, - {"invalid payload dropped", &pubsub.Message{ - Message: &pubsub_pb.Message{From: []byte(signer), Data: []byte("junk")}, - }, 0}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - d.providers = map[string]Provider{} - d.observe(tt.msg) - if got := len(d.providers); got != tt.want { - t.Errorf("providers: got %d, want %d", got, tt.want) - } - }) - } -} - -func TestProviderTableIsBounded(t *testing.T) { - d := New(nil, testPeerID(t), WithMaxProviders(2)) - for range 3 { - signer := testPeerID(t) - d.observe(rawMessage(t, signer, &api.ServiceAnnounce{ - PeerId: signer.String(), Type: api.ServiceType_SERVICE_TYPE_INFERENCE, - ServiceName: "llm", Keys: []string{"m1"}, - AnnounceTime: timestamppb.Now(), - })) - } - if got := len(d.providers); got != 2 { - t.Fatalf("provider table size: got %d, want 2", got) - } -} - -// One peer announcing many distinct service names must not be able to push -// everyone else out of a full table: the global cap evicts the oldest entry, -// which is whoever announced least recently, not whoever announced most. -func TestProviderTableCapsEntriesPerSigner(t *testing.T) { - d := New(nil, testPeerID(t), WithMaxProviders(MaxProvidersPerSigner+4)) - honest := testPeerID(t) - d.observe(rawMessage(t, honest, &api.ServiceAnnounce{ - PeerId: honest.String(), Type: api.ServiceType_SERVICE_TYPE_INFERENCE, - ServiceName: "llm", Keys: []string{"m1"}, - AnnounceTime: timestamppb.Now(), - })) - - loud := testPeerID(t) - for i := range 3 * MaxProvidersPerSigner { - d.observe(rawMessage(t, loud, &api.ServiceAnnounce{ - PeerId: loud.String(), Type: api.ServiceType_SERVICE_TYPE_INFERENCE, - ServiceName: "svc-" + string(rune('a'+i%26)) + string(rune('a'+i/26)), Keys: []string{"m1"}, - AnnounceTime: timestamppb.Now(), - })) - } - - if got := d.countBySignerLocked(loud.String()); got != MaxProvidersPerSigner { - t.Errorf("loud signer holds %d entries, want exactly %d", got, MaxProvidersPerSigner) - } - if got := len(d.Providers(api.ServiceType_SERVICE_TYPE_INFERENCE, "m1")); got != MaxProvidersPerSigner+1 { - t.Errorf("providers for m1: got %d, want %d", got, MaxProvidersPerSigner+1) - } - if d.countBySignerLocked(honest.String()) != 1 { - t.Error("the honest signer's entry was evicted by another peer's announcements") - } -} - -func TestPeerLabels(t *testing.T) { - d := New(nil, testPeerID(t)) - signer := testPeerID(t) - d.observe(rawMessage(t, signer, &api.ServiceAnnounce{ - PeerId: signer.String(), Type: api.ServiceType_SERVICE_TYPE_MCP, - ServiceName: "reviewer", Keys: []string{"review_pr"}, - Labels: map[string]string{"region": "eu"}, - AnnounceTime: timestamppb.Now(), - })) - - if got := d.PeerLabels(signer.String()); got["region"] != "eu" { - t.Errorf("PeerLabels: got %v, want region=eu", got) - } - if got := d.PeerLabels("unknown-peer"); got != nil { - t.Errorf("PeerLabels for unknown peer: got %v, want nil", got) - } -} - -func TestValidateServiceAnnounceCaps(t *testing.T) { - base := func() *api.ServiceAnnounce { - return &api.ServiceAnnounce{ - PeerId: "12D3KooWTest", Type: api.ServiceType_SERVICE_TYPE_INFERENCE, - ServiceName: "llm", Keys: []string{"m1"}, AnnounceTime: timestamppb.Now(), - } - } - tooMany := make([]string, api.MaxAnnounceKeys+1) - for i := range tooMany { - tooMany[i] = "m" - } - - tests := []struct { - name string - mutate func(*api.ServiceAnnounce) - wantErr bool - }{ - {"valid", func(a *api.ServiceAnnounce) {}, false}, - {"no keys", func(a *api.ServiceAnnounce) { a.Keys = nil }, true}, - {"too many keys", func(a *api.ServiceAnnounce) { a.Keys = tooMany }, true}, - {"empty peer", func(a *api.ServiceAnnounce) { a.PeerId = "" }, true}, - {"unspecified type", func(a *api.ServiceAnnounce) { a.Type = api.ServiceType_SERVICE_TYPE_UNSPECIFIED }, true}, - {"no announce time", func(a *api.ServiceAnnounce) { a.AnnounceTime = nil }, true}, - {"oversized label", func(a *api.ServiceAnnounce) { - a.Labels = map[string]string{"k": string(make([]byte, api.MaxAnnounceStringLen+1))} - }, true}, - {"free-form region label", func(a *api.ServiceAnnounce) { - a.Labels = map[string]string{"region": "us-east-1"} - }, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - a := base() - tt.mutate(a) - if err := api.ValidateServiceAnnounce(a); (err != nil) != tt.wantErr { - t.Errorf("ValidateServiceAnnounce: err=%v, wantErr=%v", err, tt.wantErr) - } - }) - } -} - -func TestObserveKeysOnTheCanonicalPeerID(t *testing.T) { - d := New(nil, testPeerID(t)) - signer := testPeerID(t) - - alias := peer.ToCid(signer).String() - if alias == signer.String() { - t.Fatal("peer.ToCid did not produce a second spelling of the signer") - } - spellings := []string{signer.String(), alias} - for _, spelling := range spellings { - decoded, err := peer.Decode(spelling) - if err != nil || decoded != signer { - t.Fatalf("%q does not decode to the signer (err %v)", spelling, err) - } - } - - announce := func(spelling string) { - t.Helper() - d.observe(rawMessage(t, signer, &api.ServiceAnnounce{ - PeerId: spelling, Type: api.ServiceType_SERVICE_TYPE_INFERENCE, - ServiceName: "llm", Keys: []string{"m1"}, - Labels: map[string]string{"region": "eu"}, - AnnounceTime: timestamppb.Now(), - })) - } - - for _, spelling := range spellings { - announce(spelling) - if got := len(d.providers); got != 1 { - t.Fatalf("after announcing as %q: got %d providers, want 1: %+v", spelling, got, d.providers) - } - for key, p := range d.providers { - if p.PeerID != signer.String() { - t.Errorf("provider keyed %q carries peer id %q, want %q", key, p.PeerID, signer.String()) - } - } - } - - if got := d.PeerLabels(signer.String()); got["region"] != "eu" { - t.Errorf("PeerLabels under the canonical id: got %v, want region=eu", got) - } -} diff --git a/internal/node/discovery/topics.go b/internal/node/discovery/topics.go deleted file mode 100644 index 676cec80c..000000000 --- a/internal/node/discovery/topics.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package discovery - -import ( - "sync" - - pubsub "github.com/libp2p/go-libp2p-pubsub" -) - -// topicManager refcounts pubsub topic handles: gossipsub allows a single -// Join per topic per host, and the announcer and the view may need the -// same topic (a node can provide and consume the same key). -type topicManager struct { - ps *pubsub.PubSub - mu sync.Mutex - topics map[string]*topicRef -} - -type topicRef struct { - topic *pubsub.Topic - refs int -} - -func newTopicManager(ps *pubsub.PubSub) *topicManager { - return &topicManager{ps: ps, topics: map[string]*topicRef{}} -} - -// acquire joins the topic (or reuses the existing handle) and returns a -// release func. The topic is closed when the last holder releases it. -func (tm *topicManager) acquire(name string) (*pubsub.Topic, func(), error) { - tm.mu.Lock() - defer tm.mu.Unlock() - ref, ok := tm.topics[name] - if !ok { - topic, err := tm.ps.Join(name) - if err != nil { - return nil, nil, err - } - ref = &topicRef{topic: topic} - tm.topics[name] = ref - } - ref.refs++ - var once sync.Once - release := func() { - once.Do(func() { - tm.mu.Lock() - defer tm.mu.Unlock() - ref.refs-- - if ref.refs == 0 { - if err := ref.topic.Close(); err != nil { - logger.Debugf("[Discovery] closing topic %s: %v", name, err) - } - delete(tm.topics, name) - } - }) - } - return ref.topic, release, nil -} diff --git a/internal/node/discovery/view.go b/internal/node/discovery/view.go deleted file mode 100644 index 1c48e553c..000000000 --- a/internal/node/discovery/view.go +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package discovery - -import ( - "context" - "time" - - "github.com/google/agentmesh/api" - pubsub "github.com/libp2p/go-libp2p-pubsub" - "github.com/libp2p/go-libp2p/core/peer" - "google.golang.org/protobuf/proto" -) - -// Ensure registers interest in a routing key: it subscribes to the key's -// topic (if not already) and refreshes the interest TTL. Safe to call on -// every request for the key. -func (d *Discovery) Ensure(t api.ServiceType, key string) { - name, err := api.DiscoveryTopic(t, key) - if err != nil { - return - } - d.viewMu.Lock() - defer d.viewMu.Unlock() - if in, ok := d.interests[name]; ok { - in.expires = time.Now().Add(d.interestTTL) - return - } - topic, release, err := d.tm.acquire(name) - if err != nil { - logger.Warnf("[Discovery] join %s: %v", name, err) - return - } - sub, err := topic.Subscribe() - if err != nil { - release() - logger.Warnf("[Discovery] subscribe %s: %v", name, err) - return - } - d.interests[name] = &interest{ - sub: sub, - release: release, - expires: time.Now().Add(d.interestTTL), - } - ctx := d.runCtx - if ctx == nil { - ctx = context.Background() - } - go d.readLoop(ctx, sub) -} - -// Providers returns the fresh, non-self providers observed for a key. -func (d *Discovery) Providers(t api.ServiceType, key string) []Provider { - d.viewMu.Lock() - defer d.viewMu.Unlock() - cutoff := time.Now().Add(-d.staleAfter) - var out []Provider - for _, p := range d.providers { - if p.Type == t && p.LastSeen.After(cutoff) && p.ServesKey(key) { - out = append(out, p) - } - } - return out -} - -// PeerLabels returns the labels last observed for a peer, if any. Labels are -// node-level claims (e.g. region), so any fresh entry of the peer suffices. -func (d *Discovery) PeerLabels(peerID string) map[string]string { - d.viewMu.Lock() - defer d.viewMu.Unlock() - cutoff := time.Now().Add(-d.staleAfter) - for _, p := range d.providers { - if p.PeerID == peerID && p.LastSeen.After(cutoff) && len(p.Labels) > 0 { - return p.Labels - } - } - return nil -} - -// readLoop consumes one subscription until it is cancelled or ctx ends. -func (d *Discovery) readLoop(ctx context.Context, sub *pubsub.Subscription) { - for { - msg, err := sub.Next(ctx) - if err != nil { - return // subscription cancelled or ctx done - } - if msg.GetFrom() == d.self { - continue - } - d.observe(msg) - } -} - -// observe validates a gossiped announcement and records the provider. -func (d *Discovery) observe(msg *pubsub.Message) { - var ann api.ServiceAnnounce - if err := proto.Unmarshal(msg.Data, &ann); err != nil { - logger.Debugf("[Discovery] dropping undecodable announce from %s: %v", msg.GetFrom(), err) - return - } - if err := api.ValidateServiceAnnounce(&ann); err != nil { - logger.Debugf("[Discovery] dropping invalid announce from %s: %v", msg.GetFrom(), err) - return - } - // The claimed identity must be the pubsub message signer. - claimed, err := peer.Decode(ann.GetPeerId()) - if err != nil || claimed != msg.GetFrom() { - logger.Warnf("[Discovery] dropping announce with peer_id %q not matching signer %s", ann.GetPeerId(), msg.GetFrom()) - return - } - ts := ann.GetAnnounceTime().AsTime() - if age := time.Since(ts); age > maxAnnounceSkew || age < -maxAnnounceSkew { - logger.Debugf("[Discovery] dropping stale/future announce from %s (age %v)", msg.GetFrom(), time.Since(ts)) - return - } - - typeStr, _ := api.ServiceTypeToString(ann.GetType()) - signer := claimed.String() - entryKey := signer + "|" + typeStr + "|" + ann.GetServiceName() - - d.viewMu.Lock() - defer d.viewMu.Unlock() - if _, exists := d.providers[entryKey]; !exists { - // The signer's own oldest entry goes first: a peer that announces - // more than its share only ever displaces itself. - if d.countBySignerLocked(signer) >= MaxProvidersPerSigner { - d.evictOldestLocked(signer) - } else if len(d.providers) >= d.maxProviders { - d.evictOldestLocked("") - } - } - d.providers[entryKey] = Provider{ - PeerID: signer, - Type: ann.GetType(), - Service: ann.GetServiceName(), - Keys: ann.GetKeys(), - Labels: ann.GetLabels(), - Load: Load{ - ActiveRequests: ann.GetActiveRequests(), - LatencyEWMAMs: ann.GetLatencyEwmaMs(), - }, - LastSeen: time.Now(), - } -} - -func (d *Discovery) countBySignerLocked(signer string) int { - n := 0 - for _, p := range d.providers { - if p.PeerID == signer { - n++ - } - } - return n -} - -// evictOldestLocked removes the least recently seen entry, restricted to -// one signer's entries when signer is non-empty. -func (d *Discovery) evictOldestLocked(signer string) { - var oldestKey string - var oldest time.Time - for k, p := range d.providers { - if signer != "" && p.PeerID != signer { - continue - } - if oldestKey == "" || p.LastSeen.Before(oldest) { - oldestKey, oldest = k, p.LastSeen - } - } - if oldestKey != "" { - delete(d.providers, oldestKey) - } -} - -// janitorLoop drops expired interests and stale provider entries. -func (d *Discovery) janitorLoop(ctx context.Context) { - ticker := time.NewTicker(d.interestTTL / 4) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - d.viewMu.Lock() - for name, in := range d.interests { - in.sub.Cancel() - in.release() - delete(d.interests, name) - } - d.viewMu.Unlock() - return - case <-ticker.C: - now := time.Now() - d.viewMu.Lock() - for name, in := range d.interests { - if now.After(in.expires) { - in.sub.Cancel() - in.release() - delete(d.interests, name) - } - } - cutoff := now.Add(-2 * d.staleAfter) - for k, p := range d.providers { - if p.LastSeen.Before(cutoff) { - delete(d.providers, k) - } - } - d.viewMu.Unlock() - } - } -} diff --git a/internal/node/discovery_source.go b/internal/node/discovery_source.go deleted file mode 100644 index 9ff9b7029..000000000 --- a/internal/node/discovery_source.go +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package node - -import ( - "context" - "time" - - "github.com/google/agentmesh/api" - "github.com/google/agentmesh/internal/node/discovery" -) - -// discoverySourceProbeTimeout bounds each backend model probe per tick. -const discoverySourceProbeTimeout = 2 * time.Second - -// activeTracker is implemented by services that report in-flight load. -type activeTracker interface { - ActiveRequests() uint32 -} - -// toolLister is implemented by services that report served MCP tool names. -type toolLister interface { - Tools(ctx context.Context) ([]string, error) -} - -// capKeys bounds announced keys to the wire cap; keys are expected sorted so -// truncation is deterministic across ticks. -func capKeys(keys []string) []string { - if len(keys) > api.MaxAnnounceKeys { - return keys[:api.MaxAnnounceKeys] - } - return keys -} - -// discoverySource builds gossip announcements from the registered services: -// inference services announce model IDs, MCP services announce tool names. -func (n *AgentMeshNode) discoverySource() []discovery.Announcement { - labels := n.labels() - var out []discovery.Announcement - for _, info := range n.services.List(api.ServiceType_SERVICE_TYPE_UNSPECIFIED) { - svc, ok := n.services.Get(info.GetName()) - if !ok { - continue - } - ctx, cancel := context.WithTimeout(context.Background(), discoverySourceProbeTimeout) - keys, err := serviceKeys(ctx, svc, info.GetType()) - cancel() - if err != nil || len(keys) == 0 { - continue - } - ann := discovery.Announcement{ - Type: info.GetType(), - Name: info.GetName(), - Keys: capKeys(keys), - Labels: labels, - } - if tracker, ok := svc.(activeTracker); ok { - ann.Load.ActiveRequests = tracker.ActiveRequests() - } - out = append(out, ann) - } - return out -} - -// serviceKeys returns the routing keys a service serves, per its type. -func serviceKeys(ctx context.Context, svc Service, t api.ServiceType) ([]string, error) { - switch t { - case api.ServiceType_SERVICE_TYPE_INFERENCE: - if lister, ok := svc.(modelLister); ok { - return lister.Models(ctx) - } - case api.ServiceType_SERVICE_TYPE_MCP: - if lister, ok := svc.(toolLister); ok { - return lister.Tools(ctx) - } - case api.ServiceType_SERVICE_TYPE_EGRESS: - // The destination name is the only key: callers look a hostname up. - return []string{svc.Info().GetName()}, nil - } - return nil, nil -} diff --git a/internal/node/mcp.go b/internal/node/mcp.go index 70d83c52c..21435a6a0 100644 --- a/internal/node/mcp.go +++ b/internal/node/mcp.go @@ -434,8 +434,8 @@ func (n *AgentMeshNode) fetchRemoteServiceCatalog(ctx context.Context, peerID pe // and ensures relay circuits are available. This prevents dial backoff errors when the // relay is behind a load-balanced DNS address or when pods advertise internal IPs. func (n *AgentMeshNode) preparePeerAddrs(ctx context.Context, targetPeer peer.ID) { - if n.Host == nil || n.DHT == nil { - logger.Debugf("[Discovery] Host or DHT is nil") + if n.Host == nil || n.Directory == nil { + logger.Debugf("[Discovery] Host or directory is nil") return } logger.Debugf("[Discovery] connectedness for %s: %s (conns: %d)", targetPeer, n.Host.Network().Connectedness(targetPeer), len(n.Host.Network().ConnsToPeer(targetPeer))) @@ -467,28 +467,21 @@ func (n *AgentMeshNode) preparePeerAddrs(ctx context.Context, targetPeer peer.ID changed := false addrs := n.Host.Peerstore().Addrs(targetPeer) if len(addrs) == 0 { - logger.Debugf("[Discovery] No addresses in peerstore for %s, querying DHT...", targetPeer) + logger.Debugf("[Discovery] No addresses in peerstore for %s, asking the directory", targetPeer) findCtx, cancel := context.WithTimeout(ctx, dhtLookupTimeout) - addrInfo, err := n.DHT.FindPeer(findCtx, targetPeer) + addrInfo, err := n.Directory.FindPeer(findCtx, targetPeer) cancel() addrs = addrInfo.Addrs - if err != nil { - // kad-dht answers "not found" for a peer it could not dial during - // the lookup, which a peer reached only through a router this node - // has not handshaken with is; what the lookup learned is in the - // peerstore all the same. - addrs = n.Host.Peerstore().Addrs(targetPeer) - } switch { case len(addrs) > 0: changed = true peerLookupsTotal.WithLabelValues(lookupFound).Inc() case err != nil && !errors.Is(err, routing.ErrNotFound): peerLookupsTotal.WithLabelValues(lookupFailed).Inc() - logger.Warnf("[Discovery] DHT lookup for %s failed (routing table holds %d peers): %v", targetPeer, n.DHT.RoutingTable().Size(), err) + logger.Warnf("[Discovery] Directory lookup for %s failed: %v", targetPeer, err) default: peerLookupsTotal.WithLabelValues(lookupNotFound).Inc() - logger.Infof("[Discovery] DHT lookup found no address for %s (routing table holds %d peers); trying circuits through this node's routers", targetPeer, n.DHT.RoutingTable().Size()) + logger.Infof("[Discovery] The directory knows no address for %s; trying circuits through this node's routers", targetPeer) } } logger.Debugf("[Discovery] preparePeerAddrs for %s: found %d addrs in peerstore: %v", targetPeer, len(addrs), addrs) @@ -543,14 +536,14 @@ func (n *AgentMeshNode) preparePeerAddrs(ctx context.Context, targetPeer peer.ID } if len(n.Host.Peerstore().Addrs(relayID)) == 0 { - logger.Debugf("[Discovery] No addresses for relay %s, resolving via DHT...", relayID) + logger.Debugf("[Discovery] No addresses for relay %s, asking the directory", relayID) findCtx, cancel := context.WithTimeout(ctx, dhtLookupTimeout) - addrInfo, err := n.DHT.FindPeer(findCtx, relayID) + addrInfo, err := n.Directory.FindPeer(findCtx, relayID) cancel() if err != nil { - logger.Debugf("[Discovery] Failed to find relay %s on DHT: %v", relayID, err) + logger.Debugf("[Discovery] Relay %s not found in the directory: %v", relayID, err) } else { - n.Host.Peerstore().AddAddrs(relayID, addrInfo.Addrs, peerstore.TempAddrTTL) + rememberAddrs(n.Host.Peerstore(), addrInfo) } } diff --git a/internal/node/mcp_discovery_test.go b/internal/node/mcp_discovery_test.go deleted file mode 100644 index 55ce874d5..000000000 --- a/internal/node/mcp_discovery_test.go +++ /dev/null @@ -1,214 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package node - -import ( - "context" - "fmt" - "net/http/httptest" - "reflect" - "sort" - "testing" - - "github.com/google/agentmesh/api" - samdiscovery "github.com/google/agentmesh/internal/node/discovery" - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -func TestMCPService_Tools(t *testing.T) { - backend := httptest.NewServer(newFakeMCPHandler(t, []*mcp.Tool{ - {Name: "zeta", Description: "z", InputSchema: map[string]any{"type": "object"}}, - {Name: "alpha", Description: "a", InputSchema: map[string]any{"type": "object"}}, - })) - defer backend.Close() - - svc := &MCPService{baseService: baseService{ - info: &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_MCP, Name: "tools-svc"}, - backend: &api.RegisterServiceRequest_TargetUrl{TargetUrl: backend.URL}, - }} - if err := svc.Init(context.Background()); err != nil { - t.Fatalf("Init: %v", err) - } - - got, err := svc.Tools(context.Background()) - if err != nil { - t.Fatalf("Tools: %v", err) - } - if want := []string{"alpha", "zeta"}; !reflect.DeepEqual(got, want) { - t.Errorf("Tools: got %v, want %v (sorted)", got, want) - } - - // Cached: survives the backend going away. - backend.Close() - got, err = svc.Tools(context.Background()) - if err != nil || len(got) != 2 { - t.Errorf("cached Tools: got %v, err %v", got, err) - } -} - -// fakeToolService is a local MCP Service reporting served tool names. -type fakeToolService struct { - testService - tools []string - err error -} - -func (f *fakeToolService) Tools(_ context.Context) ([]string, error) { - return f.tools, f.err -} - -func TestDiscoverySource(t *testing.T) { - node := &AgentMeshNode{ - services: NewServiceRegistry(&fakeDHT{}, 0), - nodeConfig: &NodeConfigComplete{Labels: map[string]string{"region": "EU"}}, - } - ctx := context.Background() - - llm := newFakeModelService("llm", nil, "model-b", "model-a") - tools := &fakeToolService{ - testService: testService{info: &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_MCP, Name: "reviewer"}}, - tools: []string{"review_pr", "lint"}, - } - broken := &fakeToolService{ - testService: testService{info: &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_MCP, Name: "broken"}}, - err: fmt.Errorf("backend down"), - } - for _, svc := range []Service{llm, tools, broken} { - if err := node.services.Register(ctx, svc); err != nil { - t.Fatalf("Register: %v", err) - } - } - - anns := node.discoverySource() - if len(anns) != 2 { - t.Fatalf("announcements: got %d (%+v), want 2", len(anns), anns) - } - byName := map[string]samdiscovery.Announcement{} - for _, a := range anns { - byName[a.Name] = a - } - if a := byName["llm"]; a.Type != api.ServiceType_SERVICE_TYPE_INFERENCE || len(a.Keys) != 2 { - t.Errorf("llm announcement: %+v", a) - } - if a := byName["reviewer"]; a.Type != api.ServiceType_SERVICE_TYPE_MCP || len(a.Keys) != 2 { - t.Errorf("reviewer announcement: %+v", a) - } - for name, a := range byName { - if a.Labels["region"] != "EU" { - t.Errorf("%s labels: got %v, want region=EU", name, a.Labels) - } - } -} - -func TestCapKeys(t *testing.T) { - keys := make([]string, api.MaxAnnounceKeys+5) - for i := range keys { - keys[i] = fmt.Sprintf("k%03d", i) - } - sort.Strings(keys) - if got := capKeys(keys); len(got) != api.MaxAnnounceKeys { - t.Errorf("capKeys: got %d, want %d", len(got), api.MaxAnnounceKeys) - } - if got := capKeys(keys[:3]); len(got) != 3 { - t.Errorf("capKeys under cap: got %d, want 3", len(got)) - } -} - -func TestGossipToolRows(t *testing.T) { - provs := []samdiscovery.Provider{ - {PeerID: "peerA", Service: "reviewer", Labels: map[string]string{"region": "eu"}}, - {PeerID: "peerB", Service: "other"}, - } - rows := gossipToolRows(provs, "review_pr", "") - if len(rows) != 2 { - t.Fatalf("rows: got %+v, want 2", rows) - } - if rows[0].ToolName != "mcp://reviewer/review_pr" || rows[0].Labels["region"] != "eu" { - t.Errorf("row 0: %+v", rows[0]) - } - if len(rows[1].Labels) != 0 { - t.Errorf("row 1 should have no labels: %+v", rows[1]) - } - - filtered := gossipToolRows(provs, "review_pr", "mcp://reviewer") - if len(filtered) != 1 || filtered[0].PeerID != "peerA" { - t.Errorf("service filter: got %+v", filtered) - } -} - -// The tool schema documents service_name as a bare name ("code-reviewer") while -// tool names carry the mcp:// prefix. Matching the two literally made the -// documented spelling return nothing, which is indistinguishable from a mesh -// that has no such service. -func TestNormalizeServiceFilter(t *testing.T) { - for _, tc := range []struct { - name string - in string - want string - wantErr bool - }{ - {name: "empty is no filter", in: "", want: ""}, - {name: "bare name gains the scheme", in: "everything", want: "mcp://everything"}, - {name: "namespaced name is already canonical", in: "mcp://everything", want: "mcp://everything"}, - {name: "a non-MCP scheme is an error, not an empty result", in: "inference://vllm-tpu", wantErr: true}, - } { - t.Run(tc.name, func(t *testing.T) { - got, err := normalizeServiceFilter(tc.in) - if tc.wantErr { - if err == nil { - t.Fatalf("normalizeServiceFilter(%q): want error, got %q", tc.in, got) - } - return - } - if err != nil { - t.Fatalf("normalizeServiceFilter(%q): %v", tc.in, err) - } - if got != tc.want { - t.Errorf("normalizeServiceFilter(%q): got %q, want %q", tc.in, got, tc.want) - } - }) - } -} - -// Providers announce a service either bare or namespaced; both denote the same -// service and must match one canonical filter. -func TestGossipToolRows_MatchesEitherAnnouncedForm(t *testing.T) { - provs := []samdiscovery.Provider{ - {PeerID: "peerA", Service: "everything"}, - {PeerID: "peerB", Service: "mcp://everything"}, - {PeerID: "peerC", Service: "other"}, - } - rows := gossipToolRows(provs, "get-sum", "mcp://everything") - if len(rows) != 2 { - t.Fatalf("got %d rows, want 2: %+v", len(rows), rows) - } - for _, row := range rows { - if row.ToolName != "mcp://everything/get-sum" { - t.Errorf("got %q", row.ToolName) - } - } -} - -func TestFilterRowsByToolName(t *testing.T) { - rows := []remoteToolRow{ - {PeerID: "p1", ToolName: "mcp://svc/review_pr"}, - {PeerID: "p2", ToolName: "mcp://svc/lint"}, - {PeerID: "p3", Error: "unreachable"}, - } - got := filterRowsByToolName(rows, "review_pr") - if len(got) != 1 || got[0].PeerID != "p1" { - t.Errorf("filter: got %+v", got) - } -} diff --git a/internal/node/mcp_handlers.go b/internal/node/mcp_handlers.go index 0214b7af4..a6495d382 100644 --- a/internal/node/mcp_handlers.go +++ b/internal/node/mcp_handlers.go @@ -24,7 +24,6 @@ import ( "time" "github.com/google/agentmesh/api" - samdiscovery "github.com/google/agentmesh/internal/node/discovery" "github.com/libp2p/go-libp2p/core/peer" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -144,9 +143,8 @@ func (n *AgentMeshNode) handleGetMeshInfoRemote(ctx context.Context, req *mcp.Ca return nil, nil, err } responseBytes, err := json.Marshal(struct { - PeerID string `json:"peer_id"` - DHTSize int `json:"dht_size"` - }{PeerID: full.PeerID, DHTSize: full.DHTSize}) + PeerID string `json:"peer_id"` + }{PeerID: full.PeerID}) if err != nil { return nil, nil, err } @@ -234,19 +232,6 @@ func (n *AgentMeshNode) handleFindRemoteTools(ctx context.Context, req *mcp.Call var rows []remoteToolRow - // Exact-name lookup: gossip fast path, avoiding the catalog fan-out - // when interested announcements are already flowing. - if params.ToolName != "" && params.PeerID == "" && n.Discovery != nil { - n.Discovery.Ensure(api.ServiceType_SERVICE_TYPE_MCP, params.ToolName) - if provs := n.Discovery.Providers(api.ServiceType_SERVICE_TYPE_MCP, params.ToolName); len(provs) > 0 { - candidates := gossipToolRows(provs, params.ToolName, serviceFilter) - rows = n.verifyGossipToolRows(ctx, candidates) - if len(rows) > 0 { - return marshalToolRows(rows) - } - } - } - if params.PeerID != "" { pid, err := peer.Decode(params.PeerID) if err != nil { @@ -282,7 +267,6 @@ func (n *AgentMeshNode) handleFindRemoteTools(ctx context.Context, req *mcp.Call if params.ToolName != "" { rows = filterRowsByToolName(rows, params.ToolName) } - n.annotateToolLabels(rows) return marshalToolRows(rows) } @@ -320,119 +304,6 @@ func normalizeServiceFilter(name string) (string, error) { } } -// gossipToolRows builds result rows from gossip-observed providers of a tool. -// serviceNameFilter must already be canonical; see normalizeServiceFilter. -func gossipToolRows(provs []samdiscovery.Provider, toolName, serviceNameFilter string) []remoteToolRow { - var rows []remoteToolRow - for _, p := range provs { - name := p.Service - if !strings.Contains(name, "://") { - name = api.MCPServicePrefix + name - } - if serviceNameFilter != "" && name != serviceNameFilter { - continue - } - rows = append(rows, remoteToolRow{ - PeerID: p.PeerID, - ToolName: name + "/" + toolName, - Labels: p.Labels, - }) - } - return rows -} - -// verifyGossipToolRows treats unsolicited announcements as routing hints, not -// authorization evidence. Each candidate is confirmed through the existing -// authenticated MCP session and exact tools/list response before it is exposed. -func (n *AgentMeshNode) verifyGossipToolRows(ctx context.Context, candidates []remoteToolRow) []remoteToolRow { - return verifyGossipToolRowsWithFetcher(ctx, candidates, n.fetchRemoteToolDescription) -} - -const ( - gossipVerificationMaxConcurrent = 8 - gossipVerificationTimeout = 5 * time.Second -) - -func verifyGossipToolRowsWithFetcher( - ctx context.Context, - candidates []remoteToolRow, - fetchDescription func(context.Context, peer.ID, string) (*remoteToolDescription, error), -) []remoteToolRow { - if len(candidates) == 0 { - return nil - } - - type verificationResult struct { - row remoteToolRow - ok bool - } - results := make([]verificationResult, len(candidates)) - jobs := make(chan int) - workerCount := len(candidates) - if workerCount > gossipVerificationMaxConcurrent { - workerCount = gossipVerificationMaxConcurrent - } - - var wg sync.WaitGroup - for range workerCount { - wg.Add(1) - go func() { - defer wg.Done() - for { - select { - case <-ctx.Done(): - return - case index, ok := <-jobs: - if !ok { - return - } - candidate := candidates[index] - pid, err := peer.Decode(candidate.PeerID) - if err != nil { - logger.Debugf("[find_remote_tools] invalid gossip peer %q skipped: %v", candidate.PeerID, err) - continue - } - requestCtx, cancel := context.WithTimeout(ctx, gossipVerificationTimeout) - description, err := fetchDescription(requestCtx, pid, candidate.ToolName) - cancel() - if err != nil { - logger.Debugf("[find_remote_tools] unverified gossip candidate %s on %s skipped: %v", candidate.ToolName, pid, err) - continue - } - results[index] = verificationResult{ - ok: true, - row: remoteToolRow{ - PeerID: description.PeerID, - ToolName: description.ToolName, - Description: description.Description, - Labels: candidate.Labels, - }, - } - } - } - }() - } - -sendCandidates: - for index := range candidates { - select { - case jobs <- index: - case <-ctx.Done(): - break sendCandidates - } - } - close(jobs) - wg.Wait() - - rows := make([]remoteToolRow, 0, len(results)) - for _, result := range results { - if result.ok { - rows = append(rows, result.row) - } - } - return rows -} - // filterRowsByToolName keeps rows whose namespaced tool name ends in the // bare tool name; error rows (no tool listing) are dropped from targeted // lookups. @@ -449,22 +320,6 @@ func filterRowsByToolName(rows []remoteToolRow, toolName string) []remoteToolRow return out } -// annotateToolLabels fills in the operator-declared labels for peers the -// gossip view knows, when not already known from a fresher source. -func (n *AgentMeshNode) annotateToolLabels(rows []remoteToolRow) { - if n.Discovery == nil { - return - } - for i := range rows { - if len(rows[i].Labels) != 0 { - continue - } - if labels := n.Discovery.PeerLabels(rows[i].PeerID); labels != nil { - rows[i].Labels = labels - } - } -} - // fetchRemoteToolCatalogue gets the remote node's service catalogue, // then opens a separate libp2p stream to each MCP service to fetch its tools. // serviceNameFilter must already be canonical; see normalizeServiceFilter. diff --git a/internal/node/mcp_handlers_additional_test.go b/internal/node/mcp_handlers_additional_test.go index c9b919e61..0bf12edd1 100644 --- a/internal/node/mcp_handlers_additional_test.go +++ b/internal/node/mcp_handlers_additional_test.go @@ -76,8 +76,8 @@ func TestHandleGetMeshInfo(t *testing.T) { if _, ok := info["connected_peers"]; !ok { t.Errorf("missing connected_peers in mesh info") } - if _, ok := info["dht_size"]; !ok { - t.Errorf("missing dht_size in mesh info") + if _, ok := info["attached_routers"]; !ok { + t.Errorf("missing attached_routers in mesh info") } } diff --git a/internal/node/mcp_handlers_test.go b/internal/node/mcp_handlers_test.go index 91e4a29d2..5ecc9c818 100644 --- a/internal/node/mcp_handlers_test.go +++ b/internal/node/mcp_handlers_test.go @@ -19,13 +19,10 @@ import ( "crypto/ed25519" "crypto/rand" "encoding/json" - "errors" - "fmt" "io" "net/http" "net/http/httptest" "strings" - "sync/atomic" "testing" "time" @@ -531,140 +528,6 @@ func TestFetchRemoteToolCatalogue_AuthRejectedHidden(t *testing.T) { } } -func TestVerifyGossipToolRows_AuthenticatesEachServiceOnSamePeer(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - allowedSrv := httptest.NewServer(newFakeMCPHandler(t, []*mcp.Tool{ - {Name: "review_pr", Description: "authorized review", InputSchema: map[string]any{"type": "object"}}, - })) - defer allowedSrv.Close() - deniedSrv := httptest.NewServer(newFakeMCPHandler(t, []*mcp.Tool{ - {Name: "review_pr", Description: "unauthorized review", InputSchema: map[string]any{"type": "object"}}, - })) - defer deniedSrv.Close() - - nodeA, cleanupA := startBareNode(t, ctx) - defer cleanupA() - nodeB, cleanupB := startBareNode(t, ctx) - defer cleanupB() - - _, rootPriv := enrollUnderRoot(t, nodeA, nodeB) - if err := buildAndSaveCustomBiscuit(nodeA, rootPriv, []string{"mcp://allowed-reviewer"}); err != nil { - t.Fatalf("buildAndSaveCustomBiscuit: %v", err) - } - if err := nodeA.Host.Connect(ctx, peer.AddrInfo{ID: nodeB.Host.ID(), Addrs: nodeB.Host.Addrs()}); err != nil { - t.Fatal(err) - } - - for name, targetURL := range map[string]string{ - "allowed-reviewer": allowedSrv.URL, - "denied-reviewer": deniedSrv.URL, - } { - if err := nodeB.RegisterService(ctx, &api.RegisterServiceRequest{ - Service: &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_MCP, Name: name}, - Backend: &api.RegisterServiceRequest_TargetUrl{TargetUrl: targetURL}, - }); err != nil { - t.Fatalf("RegisterService %s: %v", name, err) - } - } - - peerID := nodeB.Host.ID().String() - rows := nodeA.verifyGossipToolRows(ctx, []remoteToolRow{ - {PeerID: peerID, ToolName: "mcp://allowed-reviewer/review_pr", Labels: map[string]string{"region": "us"}}, - {PeerID: peerID, ToolName: "mcp://denied-reviewer/review_pr", Labels: map[string]string{"region": "us"}}, - }) - - if len(rows) != 1 { - t.Fatalf("expected one authorized row, got %d: %+v", len(rows), rows) - } - if rows[0].ToolName != "mcp://allowed-reviewer/review_pr" { - t.Fatalf("unexpected verified tool: %+v", rows[0]) - } - if rows[0].Description != "authorized review" { - t.Errorf("Description = %q, want authorized review", rows[0].Description) - } - if rows[0].Labels["region"] != "us" { - t.Errorf("gossip routing labels were not preserved: %+v", rows[0].Labels) - } -} - -func TestVerifyGossipToolRows_UsesIndependentRequestTimeouts(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - _, publicKey, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) - if err != nil { - t.Fatal(err) - } - peerID, err := peer.IDFromPublicKey(publicKey) - if err != nil { - t.Fatal(err) - } - - candidates := make([]remoteToolRow, 0, gossipVerificationMaxConcurrent+1) - for i := range gossipVerificationMaxConcurrent { - candidates = append(candidates, remoteToolRow{ - PeerID: peerID.String(), - ToolName: fmt.Sprintf("mcp://slow-%d/review_pr", i), - }) - } - candidates = append(candidates, remoteToolRow{ - PeerID: peerID.String(), - ToolName: "mcp://fast/review_pr", - }) - - allSlowRequestsStarted := make(chan struct{}) - releaseSlowRequests := make(chan struct{}) - var slowRequestsStarted atomic.Int32 - go func() { - select { - case <-allSlowRequestsStarted: - case <-ctx.Done(): - return - } - select { - case <-time.After(500 * time.Millisecond): - close(releaseSlowRequests) - case <-ctx.Done(): - } - }() - - var fastRequestBudget time.Duration - fetchDescription := func(ctx context.Context, pid peer.ID, toolName string) (*remoteToolDescription, error) { - if toolName == "mcp://fast/review_pr" { - deadline, ok := ctx.Deadline() - if !ok { - return nil, errors.New("fast request has no deadline") - } - fastRequestBudget = time.Until(deadline) - return &remoteToolDescription{ - PeerID: pid.String(), - ToolName: toolName, - Description: "verified after delayed candidates", - }, nil - } - - if slowRequestsStarted.Add(1) == gossipVerificationMaxConcurrent { - close(allSlowRequestsStarted) - } - select { - case <-releaseSlowRequests: - return nil, errors.New("delayed candidate rejected") - case <-ctx.Done(): - return nil, ctx.Err() - } - } - - rows := verifyGossipToolRowsWithFetcher(ctx, candidates, fetchDescription) - if len(rows) != 1 || rows[0].ToolName != "mcp://fast/review_pr" { - t.Fatalf("expected only the final candidate to verify, got %+v", rows) - } - if minimumBudget := gossipVerificationTimeout - 250*time.Millisecond; fastRequestBudget < minimumBudget { - t.Fatalf("final candidate inherited an exhausted timeout: got %v, want at least %v", fastRequestBudget, minimumBudget) - } -} - func TestCallMCPTool_LabelEnforcement(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() diff --git a/internal/node/mcp_test.go b/internal/node/mcp_test.go index 70de3daf4..ca2f04f83 100644 --- a/internal/node/mcp_test.go +++ b/internal/node/mcp_test.go @@ -25,7 +25,6 @@ import ( "time" "github.com/google/agentmesh/api" - dht "github.com/libp2p/go-libp2p-kad-dht" "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/core/peerstore" mocknet "github.com/libp2p/go-libp2p/p2p/net/mock" @@ -88,16 +87,10 @@ func TestResolveRelayAddresses(t *testing.T) { t.Fatal(err) } - // Create a real DHT bound to the mock host - kdht, err := dht.New(localHost, dht.Mode(dht.ModeServer)) - if err != nil { - t.Fatal(err) - } - defer func() { _ = kdht.Close() }() - + // The directory is not asked: the addresses are in the peerstore. node := &AgentMeshNode{ Host: localHost, - DHT: kdht, + Directory: &routerDirectory{host: localHost, routers: func() []peer.ID { return nil }, addrs: localHost.Addrs}, BiscuitTimeout: 500 * time.Millisecond, } @@ -113,20 +106,9 @@ func TestResolveRelayAddresses(t *testing.T) { localHost.Peerstore().AddAddr(relayID, relayDirectAddr, peerstore.PermanentAddrTTL) relayHost.Peerstore().AddAddr(relayID, relayDirectAddr, peerstore.PermanentAddrTTL) - // Create DHT for relay - relayDHT, err := dht.New(relayHost, dht.Mode(dht.ModeServer)) - if err != nil { - t.Fatal(err) - } - defer func() { _ = relayDHT.Close() }() - _ = mn.LinkAll() _ = mn.ConnectAllButSelf() - // Wait for DHT - _, _ = kdht.RoutingTable().TryAddPeer(relayID, true, true) - _, _ = relayDHT.RoutingTable().TryAddPeer(localHost.ID(), true, true) - // Create a target node behind the relay targetHost, err := mn.GenPeer() if err != nil { @@ -171,11 +153,6 @@ func TestPreparePeerAddrsFallsBackToAuthenticatedRouters(t *testing.T) { if err != nil { t.Fatal(err) } - kdht, err := dht.New(localHost, dht.Mode(dht.ModeClient)) - if err != nil { - t.Fatal(err) - } - defer func() { _ = kdht.Close() }() admitted, err := mn.GenPeer() if err != nil { t.Fatal(err) @@ -195,7 +172,7 @@ func TestPreparePeerAddrsFallsBackToAuthenticatedRouters(t *testing.T) { node := &AgentMeshNode{ Host: localHost, - DHT: kdht, + Directory: &routerDirectory{host: localHost, routers: func() []peer.ID { return nil }, addrs: localHost.Addrs}, BiscuitTimeout: 500 * time.Millisecond, authenticatedRouters: map[peer.ID]bool{admitted.ID(): true, stale.ID(): true}, } diff --git a/internal/node/node.go b/internal/node/node.go index 5125d1597..b3a5d13a6 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -41,15 +41,12 @@ import ( "github.com/google/agentmesh/api" cpclient "github.com/google/agentmesh/internal/controlplane/client" "github.com/google/agentmesh/internal/identity" - samdiscovery "github.com/google/agentmesh/internal/node/discovery" "github.com/google/agentmesh/internal/p2plimits" "github.com/google/agentmesh/internal/ratelimit" lru "github.com/hashicorp/golang-lru/v2" - "github.com/ipfs/go-cid" golog "github.com/ipfs/go-log/v2" "github.com/libp2p/go-libp2p" gostream "github.com/libp2p/go-libp2p-gostream" - dht "github.com/libp2p/go-libp2p-kad-dht" pubsub "github.com/libp2p/go-libp2p-pubsub" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/event" @@ -149,9 +146,8 @@ func (n *AgentMeshNode) isAdmitted(p peer.ID) bool { type AgentMeshNode struct { config Options Host host.Host - DHT *dht.IpfsDHT + Directory Directory PubSub *pubsub.PubSub - Discovery *samdiscovery.Discovery Store *Store RouterPeerID peer.ID authenticatedRouters map[peer.ID]bool @@ -648,9 +644,6 @@ func (n *AgentMeshNode) Start(ctx context.Context) error { } logger.Debugf("[Discovery] Peer %s connected via relay/public IP (%s), marking private IP as failed", remotePeer, remoteAddr) } - if c.Stat().Direction == network.DirOutbound { - n.authDirectoryRouter(ctx, remotePeer) - } }, // The router drops us from its authenticated set the moment our last // connection to it closes, so a stale entry here would report a @@ -688,42 +681,34 @@ func (n *AgentMeshNode) Start(ctx context.Context) error { } } - // The DHT and gossip are served by, and serve, authenticated peers only. + // Gossip and the directory are served by, and serve, authenticated + // peers only. gated := &meshHost{Host: h, n: n} - // Initialize Rendezvous (DHT Client) - kdht, err := dht.New(gated, dhtOptions(n.directorySettings())...) - if err != nil { - return err - } - n.DHT = kdht + // The directory: the routers this node is attached to answer for it. + n.Directory = &routerDirectory{host: gated, routers: n.attachedRouters, addrs: h.Addrs} - n.services = NewServiceRegistry(n.DHT, n.config.BackendProbeTimeout) + n.services = NewServiceRegistry(n.Directory, n.config.BackendProbeTimeout) n.services.reprovideNow = n.triggerReprovide n.applyPendingEgress(ctx) - authenticated, err := n.admitByRouters(ctx) - if err != nil { + if _, err := n.admitByRouters(ctx); err != nil { return err } - if authenticated { - logger.Infof("[DHT] Bootstrapping DHT with connected router...") - if err := n.DHT.Bootstrap(ctx); err != nil { - logger.Warnf("[DHT] Failed to trigger DHT bootstrap: %v", err) - } - } - - // Initialize Gossipsub for control plane events. StrictSign is the + // Initialize pubsub for control plane events. StrictSign is the // library default; it is pinned because the event validator and the // per-author rate limit key on msg.GetFrom(), which only the signature // makes trustworthy. - ps, err := pubsub.NewGossipSub(ctx, gated, pubsub.WithMessageSignaturePolicy(pubsub.StrictSign)) + // A member is a leaf of the gossip: it speaks floodsub, so a router + // delivers every event to it outright and it takes no part in the + // routers' gossipsub mesh, its heartbeats, grafts and peer exchange. + ps, err := pubsub.NewFloodSub(ctx, gated, pubsub.WithMessageSignaturePolicy(pubsub.StrictSign)) if err != nil { return err } n.PubSub = ps - // Validate control-plane events before GossipSub accepts or re-forwards + // Validate control-plane events before pubsub accepts or re-forwards // them: one ed25519 verify per message, so a peer flooding the topic with // junk costs itself the verify and never reaches the subscriber or the // next hop. Without this the rate limit ran first, keyed on the @@ -733,10 +718,6 @@ func (n *AgentMeshNode) Start(ctx context.Context) error { return fmt.Errorf("register mesh event validator: %w", err) } - // Interest-scoped service announcements (provider + consumer roles). - n.Discovery = samdiscovery.New(ps, h.ID()) - n.Discovery.Start(ctx, n.discoverySource) - // Subscribe to local address updates to reprovide services and log sub, err := h.EventBus().Subscribe(new(event.EvtLocalAddressesUpdated)) if err == nil { @@ -817,9 +798,26 @@ func (n *AgentMeshNode) triggerReprovide() { } } -// startReprovideLoop re-announces the node's services every reprovide -// interval of the mesh, read at each cycle so a control plane that changed -// it is followed at the next one. +// announceAll is one announcement pass: the node's services, then the +// node itself under its peer key, so a lookup of the node through a router +// that does not hold it finds it at the addresses its records carry. The +// peer record goes last so that, once it names an address, every service +// record of the pass does too. It returns how many services were withheld +// because their backend did not answer. +func (n *AgentMeshNode) announceAll(ctx context.Context) int { + withheld := n.services.ReprovideAll(ctx) + provideCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := n.Directory.Provide(provideCtx, api.PeerKey(n.Host.ID())); err != nil && ctx.Err() == nil && !errors.Is(err, errNoDirectoryRouter) { + // With no router attached the directory client has said so already. + logger.Warnf("[Directory] Announcing where this node is reached: %v", err) + } + return withheld +} + +// startReprovideLoop re-announces the node's services and its own record +// every reprovide interval of the mesh, read at each cycle so a control +// plane that changed it is followed at the next one. func (n *AgentMeshNode) startReprovideLoop(ctx context.Context) { go func() { initialDelay := 5 * time.Second @@ -849,7 +847,7 @@ func (n *AgentMeshNode) startReprovideLoop(ctx context.Context) { interval := n.directorySettings().Reprovide next := interval - if n.services.ReprovideAll(ctx) > 0 { + if n.announceAll(ctx) > 0 { // A backend that is not answering yet is usually one that is // still starting. Waiting the full interval would leave it // undiscoverable for minutes after it came up. @@ -964,12 +962,8 @@ func (n *AgentMeshNode) startConnectionMonitor(ctx context.Context, bootstrapDur } if reconnected { - logger.Infof("[Monitor] Reconnected successfully. Bootstrapping DHT...") - if err := n.DHT.Bootstrap(ctx); err != nil { - logger.Warnf("[DHT] Failed to trigger DHT bootstrap on reconnect: %v", err) - } - logger.Infof("[Monitor] Reproviding services to DHT...") - n.services.ReprovideAll(ctx) + logger.Infof("[Monitor] Reconnected successfully. Announcing services again...") + n.announceAll(ctx) consecutiveFailures = 0 continue } @@ -985,24 +979,23 @@ func (n *AgentMeshNode) startConnectionMonitor(ctx context.Context, bootstrapDur } func (n *AgentMeshNode) RegisterStaticServices(ctx context.Context, services []api.ServiceConfig) error { - // Wait for node to be connected to a router or DHT to be ready - // This avoids failure if we try to register immediately after enrollment - // before the connection is established. + // A router has to hold the node before a service can be announced, so + // a registration right after enrollment waits for the first one. ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() timeout := time.After(10 * time.Second) -dhtLoop: +attached: for { select { case <-ctx.Done(): return ctx.Err() case <-timeout: - return fmt.Errorf("timeout waiting for DHT to be ready before registering static services") + return fmt.Errorf("timeout waiting for a router before registering static services") case <-ticker.C: - if n.IsConnected() || (n.DHT != nil && n.DHT.RoutingTable().Size() > 0) { - break dhtLoop + if n.IsConnected() { + break attached } } } @@ -1155,6 +1148,7 @@ func (n *AgentMeshNode) redialRouter(ctx context.Context, router peer.ID) { err := n.authRouter(ctx, peer.AddrInfo{ID: router, Addrs: addrs}, biscuitBytes) if err == nil { logger.Infof("[AuthN] Router %s re-admitted this node on redial %d", router, attempt) + // A router that restarted lost this node's records. n.triggerReprovide() return } @@ -2036,11 +2030,6 @@ func (n *AgentMeshNode) Teardown() error { n.services.TeardownAll() } var errs []error - if n.DHT != nil { - if err := n.DHT.Close(); err != nil { - errs = append(errs, err) - } - } if n.Host != nil { if err := n.Host.Close(); err != nil { errs = append(errs, err) @@ -2061,47 +2050,33 @@ const ( discoveryFanoutTimeout = 40 * time.Second ) -// findProvidersByCID is the shared DHT-lookup primitive; bounds the -// lookup so FindProvidersAsync's channel is guaranteed to close. -func (n *AgentMeshNode) findProvidersByCID(ctx context.Context, c cid.Cid) ([]peer.AddrInfo, error) { - if n.DHT == nil { - return nil, fmt.Errorf("DHT not initialized") +// findProviders asks the directory who provides key and keeps their +// addresses for the dials that follow. +func (n *AgentMeshNode) findProviders(ctx context.Context, key []byte) ([]peer.AddrInfo, error) { + if n.Directory == nil { + return nil, fmt.Errorf("the directory is not up") } lookupCtx, cancel := context.WithTimeout(ctx, dhtLookupTimeout) defer cancel() - // FindProvidersAsync can emit the same peer multiple times when the - // DHT walk converges from different paths; dedupe so downstream - // fan-out (e.g. discoverServicesByType) doesn't double-fetch. - providersMap := make(map[peer.ID]peer.AddrInfo) - limit := n.config.DHTLookupLimit - if limit <= 0 { - limit = 20 - } - for p := range n.DHT.FindProvidersAsync(lookupCtx, c, limit) { - providersMap[p.ID] = p - } - providers := make([]peer.AddrInfo, 0, len(providersMap)) - for _, p := range providersMap { - // Same reason as the discovery loop: a revoked peer's provider - // record outlives its ban, so without this every service lookup - // fans out a doomed dial and returns one fewer usable provider. + found, err := n.Directory.FindProviders(lookupCtx, key, n.config.DHTLookupLimit) + if errors.Is(err, errNoDirectoryRouter) { + // A member not yet on the mesh knows of nobody; the lookup is + // counted as failed, the caller sees an empty mesh. + return nil, nil + } + if err != nil { + return nil, err + } + providers := make([]peer.AddrInfo, 0, len(found)) + for _, p := range found { + // A revoked peer's record outlives its ban, so without this every + // service lookup fans out a doomed dial and returns one fewer + // usable provider. if n.peerIsRevoked(p.ID) { continue } - routerAddrsCount := 0 - if n.RouterPeerID != "" { - routerAddrsCount = len(n.Host.Peerstore().Addrs(n.RouterPeerID)) - } - logger.Infof("[Discovery] Evaluating relay for %s: RouterPeerID=%s, RouterAddrsCount=%d", p.ID, n.RouterPeerID, routerAddrsCount) - - for _, addr := range p.Addrs { - logger.Infof("[Discovery] Provider %s advertised address: %s", p.ID, addr) - } - - if len(p.Addrs) > 0 { - n.Host.Peerstore().AddAddrs(p.ID, p.Addrs, peerstore.TempAddrTTL) - } - + logger.Debugf("[Discovery] Provider %s is reached at %v", p.ID, p.Addrs) + rememberAddrs(n.Host.Peerstore(), p) providers = append(providers, p) } return providers, nil @@ -2109,20 +2084,20 @@ func (n *AgentMeshNode) findProvidersByCID(ctx context.Context, c cid.Cid) ([]pe // FindProvidersByName returns peers hosting a specific {type, name} service. func (n *AgentMeshNode) FindProvidersByName(ctx context.Context, serviceType api.ServiceType, serviceName string) ([]peer.AddrInfo, error) { - c, err := serviceNameToCID(serviceType, serviceName) + key, err := api.ServiceKey(serviceType, serviceName) if err != nil { return nil, err } - return n.findProvidersByCID(ctx, c) + return n.findProviders(ctx, key) } // FindProvidersByType returns peers hosting at least one service of the given type. func (n *AgentMeshNode) FindProvidersByType(ctx context.Context, serviceType api.ServiceType) ([]peer.AddrInfo, error) { - c, err := serviceTypeToCID(serviceType) + key, err := api.ServiceKey(serviceType, "") if err != nil { return nil, err } - return n.findProvidersByCID(ctx, c) + return n.findProviders(ctx, key) } // localProxyURL builds the loopback URL clients use to reach a remote service. diff --git a/internal/node/node_metrics.go b/internal/node/node_metrics.go index f8da526fd..747ec2e4e 100644 --- a/internal/node/node_metrics.go +++ b/internal/node/node_metrics.go @@ -101,17 +101,6 @@ var transitHandshakesTotal = promauto.NewCounterVec( []string{"router", "result"}, ) -// directoryHandshakesTotal counts the handshakes run with a router this node -// connected to for its DHT without being attached to it, so the router -// keeps the connection as a session; see authDirectoryRouter. -var directoryHandshakesTotal = promauto.NewCounterVec( - prometheus.CounterOpts{ - Name: "agentmesh_node_directory_handshakes_total", - Help: "Handshakes with a router this node connected to for a DHT lookup or announcement without being attached to it, by result", - }, - []string{"result"}, -) - // nodeStateCollector exports the node's own view of its mesh membership: the // same figures GET /debug/mesh-info and /debug/token-info answer on demand, // as gauges a scraper can keep. A node that runs unattended, such as a @@ -123,7 +112,6 @@ type nodeStateCollector struct { meshConnectedDesc *prometheus.Desc connectedDesc *prometheus.Desc authenticatedDesc *prometheus.Desc - dhtDesc *prometheus.Desc biscuitExpiryDesc *prometheus.Desc servicesDesc *prometheus.Desc routersDesc *prometheus.Desc @@ -149,10 +137,6 @@ func newNodeStateCollector(n *AgentMeshNode) *nodeStateCollector { "agentmesh_node_authenticated_peers", "Peers that have completed the mesh authentication handshake with this node", nil, nil), - dhtDesc: prometheus.NewDesc( - "agentmesh_node_dht_routing_table_size", - "Peers in the Kademlia routing table", - nil, nil), biscuitExpiryDesc: prometheus.NewDesc( "agentmesh_node_biscuit_expiry_timestamp_seconds", "Unix time the node's mesh credential expires", @@ -175,7 +159,7 @@ func newNodeStateCollector(n *AgentMeshNode) *nodeStateCollector { func (c *nodeStateCollector) Describe(ch chan<- *prometheus.Desc) { for _, d := range []*prometheus.Desc{ c.readyDesc, c.meshConnectedDesc, c.connectedDesc, c.authenticatedDesc, - c.dhtDesc, c.biscuitExpiryDesc, c.servicesDesc, c.routersDesc, c.routerCandsDesc, connsPerSourceIPDesc, + c.biscuitExpiryDesc, c.servicesDesc, c.routersDesc, c.routerCandsDesc, connsPerSourceIPDesc, } { ch <- d } @@ -199,7 +183,6 @@ func (c *nodeStateCollector) Collect(ch chan<- prometheus.Metric) { ch <- prometheus.MustNewConstMetric(c.routersDesc, prometheus.GaugeValue, float64(n.AttachedRouters()), "attached") ch <- prometheus.MustNewConstMetric(c.routersDesc, prometheus.GaugeValue, float64(n.wantRouters()), "wanted") - ch <- prometheus.MustNewConstMetric(c.routersDesc, prometheus.GaugeValue, float64(n.DirectoryRouters()), "directory") cands := n.routerCandidates() _, matching := selectRouters(cands, 0, n.config.RouterSelector, nil, nil) ch <- prometheus.MustNewConstMetric(c.routerCandsDesc, prometheus.GaugeValue, float64(len(cands)), "any") @@ -211,7 +194,6 @@ func (c *nodeStateCollector) Collect(ch chan<- prometheus.Metric) { return true }) ch <- prometheus.MustNewConstMetric(c.authenticatedDesc, prometheus.GaugeValue, float64(authenticated)) - ch <- prometheus.MustNewConstMetric(c.dhtDesc, prometheus.GaugeValue, float64(n.DHT.RoutingTable().Size())) if exp, err := n.Store.LoadIdentityExpiration(); err == nil && exp > 0 { ch <- prometheus.MustNewConstMetric(c.biscuitExpiryDesc, prometheus.GaugeValue, float64(exp)) diff --git a/internal/node/node_metrics_test.go b/internal/node/node_metrics_test.go index 022ca5338..cccd9d55b 100644 --- a/internal/node/node_metrics_test.go +++ b/internal/node/node_metrics_test.go @@ -23,7 +23,6 @@ import ( "time" "github.com/libp2p/go-libp2p" - dht "github.com/libp2p/go-libp2p-kad-dht" "github.com/libp2p/go-libp2p/core/peer" ) @@ -80,18 +79,12 @@ func TestNodeMetricsServer(t *testing.T) { t.Fatal(err) } defer func() { _ = host.Close() }() - kad, err := dht.New(host, dht.Mode(dht.ModeServer)) - if err != nil { - t.Fatal(err) - } - defer func() { _ = kad.Close() }() - expiry := time.Now().Add(time.Hour).Unix() if err := store.SaveIdentityExpiration(expiry); err != nil { t.Fatal(err) } node.Host = host - node.DHT = kad + node.Directory = &routerDirectory{host: host, routers: node.attachedRouters, addrs: host.Addrs} node.authPeers.Store(peer.ID("peer-a"), time.Now().Add(time.Hour)) node.authPeers.Store(peer.ID("peer-b"), time.Now().Add(time.Hour)) @@ -105,7 +98,6 @@ func TestNodeMetricsServer(t *testing.T) { "agentmesh_node_mesh_connected 0", "agentmesh_node_connected_peers 0", "agentmesh_node_authenticated_peers 2", - "agentmesh_node_dht_routing_table_size 0", "agentmesh_node_conns_per_source_ip_limit 100", `agentmesh_node_inbound_connections_refused_total{reason="per_ip_limit"} 0`, "agentmesh_node_biscuit_expiry_timestamp_seconds " + strconv.FormatFloat(float64(expiry), 'g', -1, 64), diff --git a/internal/node/node_test.go b/internal/node/node_test.go index 7606e3ce0..209c961f9 100644 --- a/internal/node/node_test.go +++ b/internal/node/node_test.go @@ -29,9 +29,7 @@ import ( "github.com/google/agentmesh/internal/ratelimit" lru "github.com/hashicorp/golang-lru/v2" "github.com/libp2p/go-libp2p" - dht "github.com/libp2p/go-libp2p-kad-dht" "github.com/libp2p/go-libp2p/core/crypto" - "github.com/libp2p/go-libp2p/core/host" "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/p2p/net/connmgr" "github.com/libp2p/go-msgio" @@ -808,63 +806,32 @@ func TestNewAgentMeshNode_DHTOptions(t *testing.T) { } } -// newDHTHost returns a host wired to a DHT on the /mesh prefix. Client mode -// keeps the host out of its peers' routing tables, so nothing dials it except -// the code under test. -func newDHTHost(t *testing.T, mode dht.ModeOpt) (host.Host, *dht.IpfsDHT) { - t.Helper() - h, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = h.Close() }) - d, err := dht.New(h, dht.Mode(mode), dht.ProtocolPrefix("/mesh")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = d.Close() }) - return h, d -} - -// waitForRoutingTable polls until the DHT has learned about a peer. -func waitForRoutingTable(t *testing.T, d *dht.IpfsDHT) { - t.Helper() - deadline := time.Now().Add(5 * time.Second) - for d.RoutingTable().Size() == 0 { - if time.Now().After(deadline) { - t.Fatalf("routing table still empty after 5s") - } - time.Sleep(20 * time.Millisecond) - } -} - func TestFindProvidersSkipsRevoked(t *testing.T) { ctx := context.Background() - hostA, dhtA := newDHTHost(t, dht.ModeServer) - hostB, dhtB := newDHTHost(t, dht.ModeServer) - if err := hostA.Connect(ctx, peer.AddrInfo{ID: hostB.ID(), Addrs: hostB.Addrs()}); err != nil { + router := newFakeRouter(t, "/ip4/127.0.0.1/tcp/0") + dir := serveDirectory(router.h) + hostA, directory := newDirectoryClient(t, router.h) + hostB, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0")) + if err != nil { t.Fatal(err) } - waitForRoutingTable(t, dhtA) - waitForRoutingTable(t, dhtB) + t.Cleanup(func() { _ = hostB.Close() }) - c, err := serviceNameToCID(api.ServiceType_SERVICE_TYPE_MCP, "revoked-svc") + key, err := api.ServiceKey(api.ServiceType_SERVICE_TYPE_MCP, "revoked-svc") if err != nil { t.Fatal(err) } - if err := dhtB.Provide(ctx, c, true); err != nil { - t.Fatal(err) - } + dir.add(key, hostB.ID(), hostB.Addrs()...) revokedCache, err := lru.New[string, int64](10) if err != nil { t.Fatal(err) } - node := &AgentMeshNode{Host: hostA, DHT: dhtA, revokedPeers: revokedCache} + node := &AgentMeshNode{Host: hostA, Directory: directory, revokedPeers: revokedCache} - // Positive control: the provider record is findable before the ban, so a - // later empty result is the guard and not a lookup that never worked. + // Positive control: the record is findable before the ban, so a later + // empty result is the guard and not a lookup that never worked. providers, err := node.FindProvidersByName(ctx, api.ServiceType_SERVICE_TYPE_MCP, "revoked-svc") if err != nil { t.Fatal(err) diff --git a/internal/node/openai_facade.go b/internal/node/openai_facade.go index ed1cf69f3..5c50f456d 100644 --- a/internal/node/openai_facade.go +++ b/internal/node/openai_facade.go @@ -123,37 +123,11 @@ func newOpenAIFacade(node *AgentMeshNode, egress http.Handler) *openAIFacade { remoteModels: func(ctx context.Context, peerID, srvName string) ([]string, error) { return fetchRemoteModels(ctx, node, client, peerID, srvName) }, - viewProviders: func(model string) []modelProvider { - if node.Discovery == nil { - return nil - } - var out []modelProvider - for _, p := range node.Discovery.Providers(api.ServiceType_SERVICE_TYPE_INFERENCE, model) { - out = append(out, modelProvider{ - peerID: p.PeerID, - service: p.Service, - labels: p.Labels, - active: p.Load.ActiveRequests, - }) - } - return out - }, - ensureInterest: func(model string) { - if node.Discovery != nil { - node.Discovery.Ensure(api.ServiceType_SERVICE_TYPE_INFERENCE, model) - } - }, isRevoked: func(peerID string) bool { return node.revokedPeers != nil && node.revokedPeers.Contains(peerID) }, localLabels: node.labels, egressFloor: node.egressFloor, - peerLabels: func(peerID string) map[string]string { - if node.Discovery == nil { - return nil - } - return node.Discovery.PeerLabels(peerID) - }, verifyPeerLabels: func(ctx context.Context, peerID string, required map[string]string) error { pid, err := peer.Decode(peerID) if err != nil { diff --git a/internal/node/peer_lookup_test.go b/internal/node/peer_lookup_test.go index 59c7b3601..be2ad0064 100644 --- a/internal/node/peer_lookup_test.go +++ b/internal/node/peer_lookup_test.go @@ -20,29 +20,20 @@ import ( "testing" "time" - "github.com/google/agentmesh/api" - dht "github.com/libp2p/go-libp2p-kad-dht" "github.com/libp2p/go-libp2p/core/test" "github.com/libp2p/go-libp2p/p2p/net/swarm" "github.com/multiformats/go-multiaddr" ) -// A peer reached only through a router this node has not handshaken with: -// the lookup learns the circuit address from a router that holds it, dials -// it to query the peer, and is refused, since that router has not admitted -// this node; kad-dht then answers "not found" and leaves the address in -// dial backoff. The node must take the address the lookup learned anyway, -// handshake with the router, and clear the backoff, or the call fails -// twice over for a peer that is one handshake away. +// A peer reached only through a router this node does not hold: the +// directory names the circuit through that router, and the node handshakes +// with it before dialing, since a relay opens a circuit only for a source it +// admitted. The address the lookup learned is kept, and nothing is left in +// dial backoff. func TestPreparePeerAddrsUsesWhatAFailedLookupLearned(t *testing.T) { held := newFakeRouter(t, "/ip4/127.0.0.1/tcp/0") - // The held router is a DHT server, as a router is, so it enters the - // node's routing table and answers its lookups. - heldDHT, err := dht.New(held.h, dht.Mode(dht.ModeServer), dht.ProtocolPrefix(api.DHTProtocolPrefix)) - if err != nil { - t.Fatal(err) - } - defer func() { _ = heldDHT.Close() }() + // The held router answers the node's directory requests, as a router does. + serveDirectory(held.h) other := newFakeRouterWithKey(t, held.cpPub, held.cpPriv, "/ip4/127.0.0.1/tcp/0") // The peer reserved with the other router, which recorded the circuit; @@ -54,13 +45,6 @@ func TestPreparePeerAddrsUsesWhatAFailedLookupLearned(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() node := held.startNode(t, ctx, held.routerAddrs) - for node.DHT.RoutingTable().Size() == 0 { - select { - case <-ctx.Done(): - t.Fatal("the held router never entered the node's routing table") - case <-time.After(20 * time.Millisecond): - } - } before := counterValue(t, peerLookupsTotal.WithLabelValues(lookupFound)) node.preparePeerAddrs(ctx, target) diff --git a/internal/node/routers.go b/internal/node/routers.go index b287c16d4..2e1cf4ead 100644 --- a/internal/node/routers.go +++ b/internal/node/routers.go @@ -162,30 +162,6 @@ func (n *AgentMeshNode) setRouterCatalog(cands []routerCandidate) { n.Host.Peerstore().AddAddrs(c.ID, c.Addrs, peerstore.PermanentAddrTTL) } } - n.pruneDHTTable(cands) -} - -// pruneDHTTable drops from the DHT routing table every peer the catalog -// does not list. The routers are the only DHT servers, so the catalog is -// the table's source of truth; kad-dht's own liveness pings are off -// (dhtOptions), and without this a router that left would stay in the -// table until a lookup failed against it. -func (n *AgentMeshNode) pruneDHTTable(cands []routerCandidate) { - if n.DHT == nil || len(cands) == 0 { - return - } - listed := make(map[peer.ID]bool, len(cands)) - for _, c := range cands { - listed[c.ID] = true - } - rt := n.DHT.RoutingTable() - for _, p := range rt.ListPeers() { - if listed[p] { - continue - } - rt.RemovePeer(p) - logger.Infof("[DHT] Dropped %s from the routing table: the control plane no longer lists it as a router", p) - } } func (n *AgentMeshNode) routerCandidates() []routerCandidate { @@ -267,6 +243,9 @@ func (n *AgentMeshNode) attach(router peer.ID) { n.connMgr.Protect(router, routerTag) } n.refreshRelays() + // A router to announce to: services registered before this one was + // held, as the egress assignments are at start, go to the directory now. + n.triggerReprovide() } // detach gives a router up: the node stops redialling it and lets the @@ -387,11 +366,7 @@ func (n *AgentMeshNode) topUpRouters(ctx context.Context) int { for range targets { <-results } - after := n.AttachedRouters() - if after > have { - n.triggerReprovide() - } - return after + return n.AttachedRouters() } // TopUp attaches to more routers from what the node knows and returns how diff --git a/internal/node/service.go b/internal/node/service.go index 12f0914b5..278a6cb09 100644 --- a/internal/node/service.go +++ b/internal/node/service.go @@ -25,8 +25,6 @@ import ( "strings" "github.com/google/agentmesh/api" - "github.com/ipfs/go-cid" - "github.com/multiformats/go-multihash" ) // Service is the contract the ServiceRegistry and ingress server use. @@ -184,29 +182,3 @@ func buildRegisterRequest(sCfg api.ServiceConfig) (*api.RegisterServiceRequest, } return req, nil } - -// serviceKeyToCID hashes "mesh:service[:part]..." into a DHT rendezvous CID. -func serviceKeyToCID(parts ...string) (cid.Cid, error) { - srvKey := strings.Join(append([]string{"mesh:service"}, parts...), ":") - hash, err := multihash.Sum([]byte(srvKey), multihash.SHA2_256, -1) - if err != nil { - return cid.Undef, err - } - return cid.NewCidV1(cid.Raw, hash), nil -} - -func serviceNameToCID(serviceType api.ServiceType, serviceName string) (cid.Cid, error) { - srvTypeStr, err := api.ServiceTypeToString(serviceType) - if err != nil { - return cid.Undef, err - } - return serviceKeyToCID(srvTypeStr, serviceName) -} - -func serviceTypeToCID(serviceType api.ServiceType) (cid.Cid, error) { - srvTypeStr, err := api.ServiceTypeToString(serviceType) - if err != nil { - return cid.Undef, err - } - return serviceKeyToCID(srvTypeStr) -} diff --git a/internal/node/service_registry.go b/internal/node/service_registry.go index 360ad97db..4bccb4f22 100644 --- a/internal/node/service_registry.go +++ b/internal/node/service_registry.go @@ -22,13 +22,12 @@ import ( "time" "github.com/google/agentmesh/api" - "github.com/ipfs/go-cid" ) -// dhtProvider is the narrow DHT surface ServiceRegistry depends on. -// Production wires this to *dht.IpfsDHT; tests use a fake. -type dhtProvider interface { - Provide(ctx context.Context, c cid.Cid, broadcast bool) error +// directoryProvider is the one directory call ServiceRegistry makes. +// Production wires this to the node's Directory; tests use a fake. +type directoryProvider interface { + Provide(ctx context.Context, key []byte) error } // backendProber is implemented by services that can be asked whether their @@ -70,7 +69,7 @@ func advertisable(ctx context.Context, svc Service, probeTimeout time.Duration) type ServiceRegistry struct { mu sync.RWMutex services map[string]Service - dht dhtProvider + dht directoryProvider // reprovideNow asks the node to run a reprovide cycle now, so a service // registered after the loop last ran does not wait a whole interval to be @@ -88,7 +87,7 @@ type ServiceRegistry struct { // defaultDHTProbeTimeout, so callers can pass an unset // Options.BackendProbeTimeout straight through without an explicit // zero-check. -func NewServiceRegistry(d dhtProvider, backendProbeTimeout time.Duration) *ServiceRegistry { +func NewServiceRegistry(d directoryProvider, backendProbeTimeout time.Duration) *ServiceRegistry { if backendProbeTimeout <= 0 { backendProbeTimeout = defaultDHTProbeTimeout } @@ -139,11 +138,11 @@ func (r *ServiceRegistry) Register(ctx context.Context, svc Service) error { return fmt.Errorf("init %s: %w", info.Name, err) } - srvNameCID, err := serviceNameToCID(info.Type, info.Name) + nameKey, err := api.ServiceKey(info.Type, info.Name) if err != nil { return err } - srvTypeCID, err := serviceTypeToCID(info.Type) + typeKey, err := api.ServiceKey(info.Type, "") if err != nil { return err } @@ -154,14 +153,14 @@ func (r *ServiceRegistry) Register(ctx context.Context, svc Service) error { } else { nameCtx, nameCancel := context.WithTimeout(ctx, 5*time.Second) defer nameCancel() - if err := r.dht.Provide(nameCtx, srvNameCID, true); err != nil { - logger.Warnf("[ServiceRegistry] DHT Provide (name) for %s: %v", info.Name, err) + if err := r.dht.Provide(nameCtx, nameKey); err != nil { + logger.Warnf("[ServiceRegistry] Announcing %s by name: %v", info.Name, err) } typeCtx, typeCancel := context.WithTimeout(ctx, 5*time.Second) defer typeCancel() - if err := r.dht.Provide(typeCtx, srvTypeCID, true); err != nil { - logger.Warnf("[ServiceRegistry] DHT Provide (type) for %s: %v", info.Name, err) + if err := r.dht.Provide(typeCtx, typeKey); err != nil { + logger.Warnf("[ServiceRegistry] Announcing %s by type: %v", info.Name, err) } } @@ -177,7 +176,7 @@ func (r *ServiceRegistry) Register(ctx context.Context, svc Service) error { } if probeErr == nil { - logger.Infof("[ServiceRegistry] Registered %s/%s (name CID: %s, type CID: %s)", info.Type, info.Name, srvNameCID, srvTypeCID) + logger.Infof("[ServiceRegistry] Registered and announced %s/%s", info.Type, info.Name) } else if r.reprovideNow != nil { r.reprovideNow() } @@ -297,19 +296,20 @@ Loop: // On shutdown every service fails this way, and saying so // would blame backends for the node stopping. if ctx.Err() == nil { - logger.Warnf("[ServiceRegistry] Withholding %s/%s from the DHT: backend did not answer: %v", info.Type, info.Name, err) + logger.Warnf("[ServiceRegistry] Withholding %s/%s from the directory: backend did not answer: %v", info.Type, info.Name, err) } return } - if srvNameCID, err := serviceNameToCID(info.Type, info.Name); err == nil { - nameCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - _ = r.dht.Provide(nameCtx, srvNameCID, true) - cancel() - } - if srvTypeCID, err := serviceTypeToCID(info.Type); err == nil { - typeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - _ = r.dht.Provide(typeCtx, srvTypeCID, true) + for _, name := range []string{info.Name, ""} { + key, err := api.ServiceKey(info.Type, name) + if err != nil { + continue + } + provideCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + if err := r.dht.Provide(provideCtx, key); err != nil && ctx.Err() == nil { + logger.Warnf("[ServiceRegistry] Announcing %s/%s again: %v", info.Type, info.Name, err) + } cancel() } }() diff --git a/internal/node/service_registry_test.go b/internal/node/service_registry_test.go index 463ba770c..9349e74f8 100644 --- a/internal/node/service_registry_test.go +++ b/internal/node/service_registry_test.go @@ -24,18 +24,17 @@ import ( "time" "github.com/google/agentmesh/api" - "github.com/ipfs/go-cid" ) type fakeDHT struct { - calls []cid.Cid + calls [][]byte failNth int count int32 } -func (f *fakeDHT) Provide(ctx context.Context, c cid.Cid, _ bool) error { +func (f *fakeDHT) Provide(ctx context.Context, key []byte) error { n := atomic.AddInt32(&f.count, 1) - f.calls = append(f.calls, c) + f.calls = append(f.calls, key) if f.failNth > 0 && int(n) == f.failNth { return errors.New("fake dht failure") } @@ -69,7 +68,7 @@ func newFakeSvc(name string, st api.ServiceType) *fakeService { } // newServiceRegistryForTest builds a registry against the fake DHT for tests. -func newServiceRegistryForTest(d dhtProvider) *ServiceRegistry { +func newServiceRegistryForTest(d directoryProvider) *ServiceRegistry { return &ServiceRegistry{ services: map[string]Service{}, dht: d, diff --git a/internal/node/sidecar_test.go b/internal/node/sidecar_test.go index 3419725b3..17b55e8a7 100644 --- a/internal/node/sidecar_test.go +++ b/internal/node/sidecar_test.go @@ -28,9 +28,7 @@ import ( "github.com/google/agentmesh/api" "github.com/libp2p/go-libp2p" - dht "github.com/libp2p/go-libp2p-kad-dht" - "github.com/libp2p/go-libp2p/core/host" "github.com/libp2p/go-libp2p/core/peer" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -519,35 +517,17 @@ func TestRegisterService(t *testing.T) { t.Fatal(err) } defer func() { _ = h.Close() }() - d, err := dht.New(h, dht.Mode(dht.ModeServer), dht.ProtocolPrefix("/mesh")) - if err != nil { - t.Fatal(err) - } - defer func() { _ = d.Close() }() - - // Create a second host to populate routing table - h2, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0")) - if err != nil { - t.Fatal(err) - } - defer func() { _ = h2.Close() }() - d2, err := dht.New(h2, dht.Mode(dht.ModeServer), dht.ProtocolPrefix("/mesh")) - if err != nil { + // The router the node announces to. + router := newFakeRouter(t, "/ip4/127.0.0.1/tcp/0") + serveDirectory(router.h) + if err := h.Connect(context.Background(), peer.AddrInfo{ID: router.h.ID(), Addrs: router.h.Addrs()}); err != nil { t.Fatal(err) } - defer func() { _ = d2.Close() }() - - err = h.Connect(context.Background(), peer.AddrInfo{ID: h2.ID(), Addrs: h2.Addrs()}) - if err != nil { - t.Fatal(err) - } - - // Wait for DHT to recognize the peer - time.Sleep(100 * time.Millisecond) + d := &routerDirectory{host: h, routers: func() []peer.ID { return []peer.ID{router.h.ID()} }, addrs: h.Addrs} node := &AgentMeshNode{BiscuitTimeout: 500 * time.Millisecond, - services: NewServiceRegistry(d, 0), - DHT: d, + services: NewServiceRegistry(d, 0), + Directory: d, } // MCPService.Init() opens a live session to the URL; serve a fake MCP backend. @@ -594,48 +574,32 @@ func TestHandleDiscoverService(t *testing.T) { t.Fatal(err) } defer func() { _ = h.Close() }() - d, err := dht.New(h, dht.Mode(dht.ModeServer), dht.ProtocolPrefix("/mesh")) - if err != nil { + router := newFakeRouter(t, "/ip4/127.0.0.1/tcp/0") + dir := serveDirectory(router.h) + if err := h.Connect(context.Background(), peer.AddrInfo{ID: router.h.ID(), Addrs: router.h.Addrs()}); err != nil { t.Fatal(err) } - defer func() { _ = d.Close() }() + d := &routerDirectory{host: h, routers: func() []peer.ID { return []peer.ID{router.h.ID()} }, addrs: h.Addrs} node := &AgentMeshNode{BiscuitTimeout: 500 * time.Millisecond, services: NewServiceRegistry(d, 0), - DHT: d, + Directory: d, Host: h, BoundHTTPAddr: "127.0.0.1:8080", } - // Register a service on another host to be discovered + // A service another member announced to the directory. h2, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0")) if err != nil { t.Fatal(err) } defer func() { _ = h2.Close() }() - d2, err := dht.New(h2, dht.Mode(dht.ModeServer), dht.ProtocolPrefix("/mesh")) - if err != nil { - t.Fatal(err) - } - defer func() { _ = d2.Close() }() - - err = h.Connect(context.Background(), peer.AddrInfo{ID: h2.ID(), Addrs: h2.Addrs()}) - if err != nil { - t.Fatal(err) - } - serviceInfo := &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_MCP, Name: "remote-service"} - c, err := serviceNameToCID(serviceInfo.Type, serviceInfo.Name) + key, err := api.ServiceKey(serviceInfo.Type, serviceInfo.Name) if err != nil { t.Fatal(err) } - // We don't strictly need Provide to succeed if we can mock the DHT lookup, - // but here we are using real DHT. If it fails because table is empty, - // we might need to ensure routing table is populated. - // Let's ignore the error for now to see if it works without it (maybe DHT cache works). - _ = d2.Provide(context.Background(), c, true) - - time.Sleep(100 * time.Millisecond) // Wait for DHT + dir.add(key, h2.ID(), h2.Addrs()...) req := httptest.NewRequest("GET", "/mesh/service/discover?type=mcp&name=remote-service", nil) rr := httptest.NewRecorder() @@ -711,70 +675,6 @@ func TestListLocalServices_TypeFilter(t *testing.T) { } } -func TestServiceTypeToCID_Properties(t *testing.T) { - mcp1, err := serviceTypeToCID(api.ServiceType_SERVICE_TYPE_MCP) - if err != nil { - t.Fatal(err) - } - mcp2, err := serviceTypeToCID(api.ServiceType_SERVICE_TYPE_MCP) - if err != nil { - t.Fatal(err) - } - if !mcp1.Equals(mcp2) { - t.Errorf("serviceTypeToCID is non-deterministic: %s vs %s", mcp1, mcp2) - } - - inf, err := serviceTypeToCID(api.ServiceType_SERVICE_TYPE_INFERENCE) - if err != nil { - t.Fatal(err) - } - if mcp1.Equals(inf) { - t.Errorf("expected distinct CIDs for distinct types, both = %s", mcp1) - } - - named, err := serviceNameToCID(api.ServiceType_SERVICE_TYPE_MCP, "some-service") - if err != nil { - t.Fatal(err) - } - if mcp1.Equals(named) { - t.Errorf("type-only CID collided with name-keyed CID: %s", mcp1) - } - - if _, err := serviceTypeToCID(api.ServiceType_SERVICE_TYPE_UNSPECIFIED); err == nil { - t.Errorf("expected error for unspecified type") - } -} - -// TestServiceKeyToCID_Equivalence pins the wire format: the public -// helpers must compose into the same CID as a direct call to the -// shared key builder, so optimizing the helpers later can't silently -// shift the DHT keys. -func TestServiceKeyToCID_Equivalence(t *testing.T) { - gotName, err := serviceNameToCID(api.ServiceType_SERVICE_TYPE_MCP, "svc") - if err != nil { - t.Fatal(err) - } - wantName, err := serviceKeyToCID("mcp", "svc") - if err != nil { - t.Fatal(err) - } - if !gotName.Equals(wantName) { - t.Errorf("serviceNameToCID != serviceKeyToCID: %s vs %s", gotName, wantName) - } - - gotType, err := serviceTypeToCID(api.ServiceType_SERVICE_TYPE_MCP) - if err != nil { - t.Fatal(err) - } - wantType, err := serviceKeyToCID("mcp") - if err != nil { - t.Fatal(err) - } - if !gotType.Equals(wantType) { - t.Errorf("serviceTypeToCID != serviceKeyToCID: %s vs %s", gotType, wantType) - } -} - func TestRegisterService_Validation(t *testing.T) { node := &AgentMeshNode{BiscuitTimeout: 500 * time.Millisecond, services: NewServiceRegistry(&fakeDHT{}, 0), @@ -850,60 +750,35 @@ func TestDiscoverService_Pagination(t *testing.T) { t.Fatal(err) } defer func() { _ = h.Close() }() - d, err := dht.New(h, dht.Mode(dht.ModeServer), dht.ProtocolPrefix("/mesh")) - if err != nil { + router := newFakeRouter(t, "/ip4/127.0.0.1/tcp/0") + dir := serveDirectory(router.h) + if err := h.Connect(context.Background(), peer.AddrInfo{ID: router.h.ID(), Addrs: router.h.Addrs()}); err != nil { t.Fatal(err) } - defer func() { _ = d.Close() }() + d := &routerDirectory{host: h, routers: func() []peer.ID { return []peer.ID{router.h.ID()} }, addrs: h.Addrs} node := &AgentMeshNode{ BiscuitTimeout: 500 * time.Millisecond, services: NewServiceRegistry(d, 0), - DHT: d, + Directory: d, Host: h, BoundHTTPAddr: "127.0.0.1:8080", } - var hosts []host.Host - var dhts []*dht.IpfsDHT - defer func() { - for _, hs := range hosts { - _ = hs.Close() - } - for _, dt := range dhts { - _ = dt.Close() - } - }() - serviceInfo := &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_MCP, Name: "paginated-service"} - c, err := serviceNameToCID(serviceInfo.Type, serviceInfo.Name) + key, err := api.ServiceKey(serviceInfo.Type, serviceInfo.Name) if err != nil { t.Fatal(err) } - for i := 0; i < 3; i++ { h2, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0")) if err != nil { t.Fatal(err) } - hosts = append(hosts, h2) - - d2, err := dht.New(h2, dht.Mode(dht.ModeServer), dht.ProtocolPrefix("/mesh")) - if err != nil { - t.Fatal(err) - } - dhts = append(dhts, d2) - - err = h.Connect(context.Background(), peer.AddrInfo{ID: h2.ID(), Addrs: h2.Addrs()}) - if err != nil { - t.Fatal(err) - } - - _ = d2.Provide(context.Background(), c, true) + defer func() { _ = h2.Close() }() + dir.add(key, h2.ID(), h2.Addrs()...) } - time.Sleep(200 * time.Millisecond) // Wait for DHT propagation - // 1. Query page 1 (limit=2, offset=0) req := httptest.NewRequest("GET", "/mesh/service/discover?type=mcp&name=paginated-service&limit=2&offset=0", nil) rr := httptest.NewRecorder() diff --git a/internal/router/auth_gate_test.go b/internal/router/auth_gate_test.go index 990d8045a..68e8df1b3 100644 --- a/internal/router/auth_gate_test.go +++ b/internal/router/auth_gate_test.go @@ -236,9 +236,15 @@ func TestRouterDHTServesAuthenticatedMembersAndTablesPeerRouters(t *testing.T) { t.Fatal("a server-mode peer that never authenticated entered the routing table") } r.authenticatedPeers.Store(other.ID(), time.Now().Add(time.Hour)) - r.admitPeerRouter(other.ID()) - if kdht.RoutingTable().Find(other.ID()) == "" { - t.Fatal("an authenticated peer router is not in the routing table") + // kad-dht handles the identify event on its own schedule and drops a + // peer its filter refused at that instant; the federation pass admits + // an authenticated router again on every tick, which this stands in for. + deadline := time.Now().Add(5 * time.Second) + for r.admitPeerRouter(other.ID()); kdht.RoutingTable().Find(other.ID()) == ""; r.admitPeerRouter(other.ID()) { + if time.Now().After(deadline) { + t.Fatal("an authenticated peer router is not in the routing table") + } + time.Sleep(20 * time.Millisecond) } r.forgetPeer(other.ID()) if kdht.RoutingTable().Find(other.ID()) != "" { diff --git a/internal/router/directory.go b/internal/router/directory.go new file mode 100644 index 000000000..78196e292 --- /dev/null +++ b/internal/router/directory.go @@ -0,0 +1,414 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package router + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/ipfs/go-cid" + kb "github.com/libp2p/go-libp2p-kbucket" + "github.com/libp2p/go-libp2p/core/network" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-msgio" + "github.com/multiformats/go-multiaddr" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "google.golang.org/protobuf/proto" + + "github.com/google/agentmesh/api" +) + +const ( + // directoryRequestTimeout bounds one request, the lookup across the + // routers included. A lookup on a mesh of routers that all know each + // other is one round; the bound is for a router that stopped answering. + directoryRequestTimeout = 20 * time.Second + // directoryMaxMessage bounds a request or response frame. + directoryMaxMessage = 64 * 1024 + // directoryDefaultLimit and directoryMaxLimit bound how many providers + // a find_providers returns. + directoryDefaultLimit = 20 + directoryMaxLimit = 100 +) + +const ( + directoryOpProvide = "provide" + directoryOpFindProviders = "find_providers" + directoryOpFindPeer = "find_peer" + directoryOpPlace = "place" + + directoryResultOK = "ok" + directoryResultEmpty = "empty" + directoryResultInvalid = "invalid" + directoryResultError = "error" +) + +var directoryRequestsTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "agentmesh_router_directory_requests_total", + Help: "Directory requests served to members and routers, by operation and result; place is a record another router put here", + }, + []string{"op", "result"}, +) + +var directoryRequestSeconds = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "agentmesh_router_directory_request_seconds", + Help: "Time to answer a directory request, the lookup across the routers included", + Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}, + }, + []string{"op"}, +) + +var directoryPlacementsTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "agentmesh_router_directory_placements_total", + Help: "Copies of a member's record this router placed on the routers closest to the key, by result", + }, + []string{"result"}, +) + +func init() { + for _, op := range []string{directoryOpProvide, directoryOpFindProviders, directoryOpFindPeer, directoryOpPlace} { + for _, result := range []string{directoryResultOK, directoryResultEmpty, directoryResultInvalid, directoryResultError} { + directoryRequestsTotal.WithLabelValues(op, result) + } + } + for _, result := range []string{directoryResultOK, directoryResultError} { + directoryPlacementsTotal.WithLabelValues(result) + } +} + +// handleDirectory serves one DirectoryRequest on a stream from a member +// or a router. It is registered through the auth gate, so the peer has +// authenticated by the time it runs. A member keeps no DHT: it announces +// its records here and the router keeps them and places them on the +// routers closest to the key; it looks keys up here and the router runs +// the lookup across the routers' DHT. +func (r *Router) handleDirectory(s network.Stream) { + defer func() { _ = s.Close() }() + remote := s.Conn().RemotePeer() + _ = s.SetDeadline(time.Now().Add(directoryRequestTimeout)) + ctx, cancel := context.WithTimeout(r.lifetime(), directoryRequestTimeout) + defer cancel() + + var req api.DirectoryRequest + reader := msgio.NewVarintReaderSize(s, directoryMaxMessage) + frame, err := reader.ReadMsg() + if err != nil { + logger.Debugf("[Directory] Request from %s not read: %v", remote, err) + _ = s.Reset() + return + } + err = proto.Unmarshal(frame, &req) + reader.ReleaseMsg(frame) + if err != nil { + logger.Warnf("[Directory] Request from %s not decoded: %v", remote, err) + directoryRequestsTotal.WithLabelValues("unknown", directoryResultInvalid).Inc() + _ = s.Reset() + return + } + + op, resp := r.serveDirectory(ctx, remote, &req) + data, err := proto.Marshal(resp) + if err != nil { + logger.Errorf("[Directory] Response to %s not encoded: %v", remote, err) + _ = s.Reset() + return + } + if err := msgio.NewVarintWriter(s).WriteMsg(data); err != nil { + logger.Debugf("[Directory] Response to %s (%s) not written: %v", remote, op, err) + _ = s.Reset() + } +} + +// serveDirectory answers one request and accounts for it. +func (r *Router) serveDirectory(ctx context.Context, remote peer.ID, req *api.DirectoryRequest) (string, *api.DirectoryResponse) { + start := time.Now() + var op string + var resp *api.DirectoryResponse + var err error + switch q := req.GetRequest().(type) { + case *api.DirectoryRequest_Provide: + op = directoryOpProvide + if q.Provide.GetProvider() != "" { + op = directoryOpPlace + } + resp, err = r.directoryProvide(ctx, remote, q.Provide) + case *api.DirectoryRequest_FindProviders: + op = directoryOpFindProviders + resp, err = r.directoryFindProviders(ctx, q.FindProviders) + case *api.DirectoryRequest_FindPeer: + op = directoryOpFindPeer + resp, err = r.directoryFindPeer(ctx, q.FindPeer) + default: + return "unknown", r.directoryRefused("unknown", remote, errInvalidDirectoryRequest) + } + directoryRequestSeconds.WithLabelValues(op).Observe(time.Since(start).Seconds()) + if err != nil { + return op, r.directoryRefused(op, remote, err) + } + result := directoryResultOK + if len(resp.GetProviders()) == 0 && resp.GetPeer() == nil && op != directoryOpProvide && op != directoryOpPlace { + result = directoryResultEmpty + } + directoryRequestsTotal.WithLabelValues(op, result).Inc() + return op, resp +} + +var errInvalidDirectoryRequest = errors.New("request is not provide, find_providers or find_peer") + +// directoryRefused answers a request the router did not run, and says why +// at the level the cause deserves: a malformed request is the peer's +// doing, a failed lookup is the mesh's. +func (r *Router) directoryRefused(op string, remote peer.ID, err error) *api.DirectoryResponse { + var invalid *directoryInvalidError + if errors.As(err, &invalid) { + directoryRequestsTotal.WithLabelValues(op, directoryResultInvalid).Inc() + logger.Warnf("[Directory] Refusing %s from %s: %v", op, remote, err) + } else { + directoryRequestsTotal.WithLabelValues(op, directoryResultError).Inc() + logger.Warnf("[Directory] %s from %s failed: %v", op, remote, err) + } + return &api.DirectoryResponse{Error: err.Error()} +} + +// directoryInvalidError is a request the peer got wrong. +type directoryInvalidError struct{ err error } + +func (e *directoryInvalidError) Error() string { return e.err.Error() } +func (e *directoryInvalidError) Unwrap() error { return e.err } + +func invalidDirectoryRequest(format string, args ...any) error { + return &directoryInvalidError{err: fmt.Errorf(format, args...)} +} + +// directoryProvide stores a record. From a member it is the member's own, +// kept here and placed on the routers closest to the key; from a router it +// is a member's record that router placed here, which only a router may do. +func (r *Router) directoryProvide(ctx context.Context, remote peer.ID, q *api.DirectoryProvide) (*api.DirectoryResponse, error) { + if err := api.ValidateDirectoryKey(q.GetKey()); err != nil { + return nil, invalidDirectoryRequest("%w", err) + } + provider := remote + placed := q.GetProvider() != "" + if placed { + if !r.isAuthenticatedPeerRouter(remote) { + return nil, invalidDirectoryRequest("only a router may name a provider; %s is not one", remote) + } + p, err := peer.Decode(q.GetProvider()) + if err != nil { + return nil, invalidDirectoryRequest("provider: %w", err) + } + provider = p + } + addrs, err := recordAddrs(provider, q.GetAddrs()) + if err != nil { + return nil, invalidDirectoryRequest("%w", err) + } + if r.DHT == nil { + return nil, errors.New("the directory is not up") + } + if err := r.DHT.ProviderStore().AddProvider(ctx, q.GetKey(), peer.AddrInfo{ID: provider, Addrs: addrs}); err != nil { + return nil, fmt.Errorf("store record: %w", err) + } + if !placed { + r.ensureFederated(ctx) + r.placeRecord(ctx, q.GetKey(), provider, q.GetAddrs()) + } + return &api.DirectoryResponse{}, nil +} + +// recordAddrs parses a record's addresses: at most DirectoryMaxAddrs, each +// with no peer suffix or the provider's own, stored without it as the +// peerstore keeps them. +func recordAddrs(provider peer.ID, raw []string) ([]multiaddr.Multiaddr, error) { + if len(raw) > api.DirectoryMaxAddrs { + return nil, fmt.Errorf("%d addresses, at most %d", len(raw), api.DirectoryMaxAddrs) + } + addrs := make([]multiaddr.Multiaddr, 0, len(raw)) + for _, a := range raw { + ma, err := multiaddr.NewMultiaddr(a) + if err != nil { + return nil, fmt.Errorf("address %q: %w", a, err) + } + transport, p := peer.SplitAddr(ma) + if p != "" && p != provider { + return nil, fmt.Errorf("address %q names %s, the record is %s's", a, p, provider) + } + if transport == nil { + return nil, fmt.Errorf("address %q has no transport", a) + } + addrs = append(addrs, transport) + } + return addrs, nil +} + +// placeRecord puts a member's record on the routers closest to the key, +// as many as the mesh's replication, over the directory protocol with the +// provider named. Every copy is attempted; a router that fails to take it +// is logged and counted, the member's own router keeps a copy regardless. +func (r *Router) placeRecord(ctx context.Context, key []byte, provider peer.ID, addrs []string) { + targets := r.DHT.RoutingTable().NearestPeers(kb.ConvertKey(string(key)), r.directory.Replication) + req := &api.DirectoryRequest{Request: &api.DirectoryRequest_Provide{Provide: &api.DirectoryProvide{ + Key: key, Addrs: addrs, Provider: provider.String(), + }}} + data, err := proto.Marshal(req) + if err != nil { + logger.Errorf("[Directory] Placement for %s not encoded: %v", provider, err) + return + } + var wg sync.WaitGroup + for _, target := range targets { + wg.Add(1) + go func(target peer.ID) { + defer wg.Done() + if err := r.sendDirectory(ctx, target, data); err != nil { + directoryPlacementsTotal.WithLabelValues(directoryResultError).Inc() + logger.Warnf("[Directory] Record of %s not placed on router %s: %v", provider, target, err) + return + } + directoryPlacementsTotal.WithLabelValues(directoryResultOK).Inc() + }(target) + } + wg.Wait() +} + +// sendDirectory sends one encoded request to a peer router and reads its +// answer, over the gated host so the router has authenticated first. +func (r *Router) sendDirectory(ctx context.Context, target peer.ID, data []byte) error { + s, err := r.gated.NewStream(ctx, target, api.DirectoryProtocolID) + if err != nil { + return err + } + defer func() { _ = s.Close() }() + if deadline, ok := ctx.Deadline(); ok { + _ = s.SetDeadline(deadline) + } + if err := msgio.NewVarintWriter(s).WriteMsg(data); err != nil { + _ = s.Reset() + return err + } + reader := msgio.NewVarintReaderSize(s, directoryMaxMessage) + frame, err := reader.ReadMsg() + if err != nil { + _ = s.Reset() + return err + } + defer reader.ReleaseMsg(frame) + var resp api.DirectoryResponse + if err := proto.Unmarshal(frame, &resp); err != nil { + return err + } + if resp.GetError() != "" { + return errors.New(resp.GetError()) + } + return nil +} + +// directoryFindProviders runs the lookup across the routers' DHT. +func (r *Router) directoryFindProviders(ctx context.Context, q *api.DirectoryFindProviders) (*api.DirectoryResponse, error) { + if err := api.ValidateDirectoryKey(q.GetKey()); err != nil { + return nil, invalidDirectoryRequest("%w", err) + } + limit := int(q.GetLimit()) + if limit <= 0 { + limit = directoryDefaultLimit + } + if limit > directoryMaxLimit { + limit = directoryMaxLimit + } + records, err := r.lookupProviders(ctx, q.GetKey(), limit) + if err != nil { + return nil, err + } + return &api.DirectoryResponse{Providers: records}, nil +} + +// lookupProviders is the lookup both find_providers and find_peer run. +func (r *Router) lookupProviders(ctx context.Context, key []byte, limit int) ([]*api.PeerRecord, error) { + if r.DHT == nil { + return nil, errors.New("the directory is not up") + } + r.ensureFederated(ctx) + records, err := r.findProviders(ctx, key, limit) + if err == nil && len(records) == 0 && r.refederateIfStale() { + // A router that joined since the last pass may hold the record. + records, err = r.findProviders(ctx, key, limit) + } + return records, err +} + +// findProviders runs one lookup across the routers' DHT. +func (r *Router) findProviders(ctx context.Context, key []byte, limit int) ([]*api.PeerRecord, error) { + c := cid.NewCidV1(cid.Raw, key) + records := make([]*api.PeerRecord, 0, limit) + seen := map[peer.ID]bool{} + for info := range r.DHT.FindProvidersAsync(ctx, c, limit) { + if seen[info.ID] { + continue + } + seen[info.ID] = true + records = append(records, peerRecord(info)) + if len(records) >= limit { + break + } + } + if err := ctx.Err(); err != nil && len(records) == 0 { + return nil, fmt.Errorf("lookup: %w", err) + } + return records, nil +} + +// directoryFindPeer answers where a peer is reached: from this router's +// own peerstore for a member attached here, from the record the member +// announced under its peer key otherwise. +func (r *Router) directoryFindPeer(ctx context.Context, q *api.DirectoryFindPeer) (*api.DirectoryResponse, error) { + p, err := peer.Decode(q.GetPeerId()) + if err != nil { + return nil, invalidDirectoryRequest("peer_id: %w", err) + } + if r.isPeerAuthenticated(p) && r.Host.Network().Connectedness(p) == network.Connected { + if addrs := r.Host.Peerstore().Addrs(p); len(addrs) > 0 { + return &api.DirectoryResponse{Peer: peerRecord(peer.AddrInfo{ID: p, Addrs: addrs})}, nil + } + } + records, err := r.lookupProviders(ctx, api.PeerKey(p), 1) + if err != nil { + return nil, err + } + for _, rec := range records { + if rec.GetPeerId() == p.String() { + return &api.DirectoryResponse{Peer: rec}, nil + } + } + return &api.DirectoryResponse{}, nil +} + +// peerRecord renders a peer and its addresses, each with the peer suffix. +func peerRecord(info peer.AddrInfo) *api.PeerRecord { + rec := &api.PeerRecord{PeerId: info.ID.String()} + for _, a := range info.Addrs { + if _, p := peer.SplitAddr(a); p == "" { + a = a.Encapsulate(multiaddr.StringCast("/p2p/" + info.ID.String())) + } + rec.Addrs = append(rec.Addrs, a.String()) + } + return rec +} diff --git a/internal/router/directory_test.go b/internal/router/directory_test.go new file mode 100644 index 000000000..effb2dfaa --- /dev/null +++ b/internal/router/directory_test.go @@ -0,0 +1,280 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package router + +import ( + "context" + "slices" + "testing" + "time" + + dht "github.com/libp2p/go-libp2p-kad-dht" + kb "github.com/libp2p/go-libp2p-kbucket" + "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-msgio" + dto "github.com/prometheus/client_model/go" + "google.golang.org/protobuf/proto" + + "github.com/google/agentmesh/api" +) + +// newDirectoryRouter is a router with its DHT and the directory handler +// up, as Start wires them, on a loopback host. +func newDirectoryRouter(t *testing.T) *Router { + t.Helper() + r, gated := newGatedRouter(t) + r.gated = gated + kdht, err := dht.New(gated, r.dhtOptions()...) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = kdht.Close() }) + r.DHT = kdht + gated.SetStreamHandler(api.DirectoryProtocolID, r.handleDirectory) + return r +} + +// federate connects two routers and has each admit the other as an +// authenticated peer router, as the handshake does. +func federate(t *testing.T, a, b *Router) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := a.Host.Connect(ctx, peer.AddrInfo{ID: b.Host.ID(), Addrs: b.Host.Addrs()}); err != nil { + t.Fatal(err) + } + for _, pair := range [][2]*Router{{a, b}, {b, a}} { + pair[0].authenticatedPeers.Store(pair[1].Host.ID(), time.Now().Add(time.Hour)) + pair[0].admitPeerRouter(pair[1].Host.ID()) + } +} + +// attach connects a member host to a router and marks it authenticated there. +func attach(t *testing.T, r *Router) host.Host { + t.Helper() + m := newClient(t, r) + r.authenticatedPeers.Store(m.ID(), time.Now().Add(time.Hour)) + return m +} + +// ask sends one request to a router and returns its answer. +func ask(t *testing.T, from host.Host, router peer.ID, req *api.DirectoryRequest) *api.DirectoryResponse { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + s, err := from.NewStream(ctx, router, api.DirectoryProtocolID) + if err != nil { + t.Fatalf("open directory stream: %v", err) + } + defer func() { _ = s.Close() }() + data, err := proto.Marshal(req) + if err != nil { + t.Fatal(err) + } + if err := msgio.NewVarintWriter(s).WriteMsg(data); err != nil { + t.Fatal(err) + } + frame, err := msgio.NewVarintReaderSize(s, directoryMaxMessage).ReadMsg() + if err != nil { + t.Fatalf("read directory answer: %v", err) + } + var resp api.DirectoryResponse + if err := proto.Unmarshal(frame, &resp); err != nil { + t.Fatal(err) + } + return &resp +} + +func provide(key []byte, addrs ...string) *api.DirectoryRequest { + return &api.DirectoryRequest{Request: &api.DirectoryRequest_Provide{Provide: &api.DirectoryProvide{Key: key, Addrs: addrs}}} +} + +func findProviders(key []byte) *api.DirectoryRequest { + return &api.DirectoryRequest{Request: &api.DirectoryRequest_FindProviders{FindProviders: &api.DirectoryFindProviders{Key: key}}} +} + +func findPeer(p peer.ID) *api.DirectoryRequest { + return &api.DirectoryRequest{Request: &api.DirectoryRequest_FindPeer{FindPeer: &api.DirectoryFindPeer{PeerId: p.String()}}} +} + +func directoryCount(t *testing.T, op, result string) float64 { + t.Helper() + var m dto.Metric + if err := directoryRequestsTotal.WithLabelValues(op, result).Write(&m); err != nil { + t.Fatal(err) + } + return m.GetCounter().GetValue() +} + +// A member announces a record to the router it is attached to; the router +// keeps it and places it on the routers closest to the key, as many as the +// mesh's replication. A member attached to any router then finds it: from +// that router's own store when it holds a copy, through the routers' DHT +// otherwise. A peer is found the same way, under its peer key, or from the +// router that holds it. +func TestDirectoryRecordsAreFoundFromAnyRouter(t *testing.T) { + a, b, c := newDirectoryRouter(t), newDirectoryRouter(t), newDirectoryRouter(t) + federate(t, a, b) + federate(t, a, c) + federate(t, b, c) + // One copy besides the member's own router, on the closest of the others. + a.directory.Replication = 1 + + member := attach(t, a) + key, err := api.ServiceKey(api.ServiceType_SERVICE_TYPE_MCP, "calculator") + if err != nil { + t.Fatal(err) + } + memberAddr := member.Addrs()[0].String() + "/p2p/" + member.ID().String() + if resp := ask(t, member, a.Host.ID(), provide(key, memberAddr)); resp.GetError() != "" { + t.Fatalf("provide refused: %s", resp.GetError()) + } + if resp := ask(t, member, a.Host.ID(), provide(api.PeerKey(member.ID()), memberAddr)); resp.GetError() != "" { + t.Fatalf("provide of the peer key refused: %s", resp.GetError()) + } + + closest := a.DHT.RoutingTable().NearestPeers(kb.ConvertKey(string(key)), 1) + if len(closest) != 1 { + t.Fatalf("closest routers to the key = %v, want one", closest) + } + holder, other := b, c + if closest[0] == c.Host.ID() { + holder, other = c, b + } + if provs, _ := holder.DHT.ProviderStore().GetProviders(context.Background(), key); len(provs) != 1 || provs[0].ID != member.ID() { + t.Fatalf("the closest router holds %v, want the member's record", provs) + } + if provs, _ := other.DHT.ProviderStore().GetProviders(context.Background(), key); len(provs) != 0 { + t.Fatalf("the other router holds %v, want nothing: replication is one", provs) + } + + for name, router := range map[string]*Router{"own router": a, "holder": holder, "other router, through the DHT": other} { + t.Run("find providers from the "+name, func(t *testing.T) { + asker := attach(t, router) + resp := ask(t, asker, router.Host.ID(), findProviders(key)) + if resp.GetError() != "" { + t.Fatalf("find_providers failed: %s", resp.GetError()) + } + if len(resp.GetProviders()) != 1 || resp.GetProviders()[0].GetPeerId() != member.ID().String() { + t.Fatalf("providers = %v, want the member", resp.GetProviders()) + } + if got := resp.GetProviders()[0].GetAddrs(); !slices.Contains(got, memberAddr) { + t.Fatalf("provider addrs = %v, want %s among them", got, memberAddr) + } + }) + } + + t.Run("find peer from the other router", func(t *testing.T) { + asker := attach(t, other) + resp := ask(t, asker, other.Host.ID(), findPeer(member.ID())) + if resp.GetError() != "" { + t.Fatalf("find_peer failed: %s", resp.GetError()) + } + if resp.GetPeer().GetPeerId() != member.ID().String() || !slices.Contains(resp.GetPeer().GetAddrs(), memberAddr) { + t.Fatalf("peer = %v, want the member at %s", resp.GetPeer(), memberAddr) + } + }) + + t.Run("find peer from its own router answers from the connection", func(t *testing.T) { + asker := attach(t, a) + resp := ask(t, asker, a.Host.ID(), findPeer(member.ID())) + if resp.GetPeer().GetPeerId() != member.ID().String() || len(resp.GetPeer().GetAddrs()) == 0 { + t.Fatalf("peer = %v, want the member with addresses", resp.GetPeer()) + } + }) + + t.Run("nobody is found under a key nobody announced", func(t *testing.T) { + before := directoryCount(t, directoryOpFindProviders, directoryResultEmpty) + unknown, _ := api.ServiceKey(api.ServiceType_SERVICE_TYPE_MCP, "nobody") + asker := attach(t, b) + resp := ask(t, asker, b.Host.ID(), findProviders(unknown)) + if resp.GetError() != "" || len(resp.GetProviders()) != 0 { + t.Fatalf("got %v, want an empty answer and no error", resp) + } + if got := directoryCount(t, directoryOpFindProviders, directoryResultEmpty); got != before+1 { + t.Fatalf("empty answers counted = %v, want %v", got, before+1) + } + if resp := ask(t, asker, b.Host.ID(), findPeer(asker.ID())); resp.GetPeer() != nil && len(resp.GetPeer().GetAddrs()) == 0 { + t.Fatalf("find_peer of a peer with no record = %v, want unset or its own connection", resp.GetPeer()) + } + }) +} + +// A member can only announce itself: a record naming another provider, an +// address of another peer, or a key that is not a sha256 multihash is +// refused and counted as invalid, and nothing is stored. +func TestDirectoryRefusesWhatAMemberMayNotAnnounce(t *testing.T) { + a := newDirectoryRouter(t) + member := attach(t, a) + stranger := newClient(t, a) + key, _ := api.ServiceKey(api.ServiceType_SERVICE_TYPE_MCP, "calculator") + + for name, req := range map[string]*api.DirectoryRequest{ + "another provider": {Request: &api.DirectoryRequest_Provide{Provide: &api.DirectoryProvide{Key: key, Provider: stranger.ID().String()}}}, + "another peer's address": provide(key, stranger.Addrs()[0].String()+"/p2p/"+stranger.ID().String()), + "a key that is not a multihash": provide([]byte("calculator")), + "a lookup of such a key": findProviders([]byte("calculator")), + "a peer id that is not one": findPeer(peer.ID("nobody")), + } { + t.Run(name, func(t *testing.T) { + op := directoryOpProvide + switch req.GetRequest().(type) { + case *api.DirectoryRequest_FindProviders: + op = directoryOpFindProviders + case *api.DirectoryRequest_FindPeer: + op = directoryOpFindPeer + } + if req.GetProvide().GetProvider() != "" { + op = directoryOpPlace + } + before := directoryCount(t, op, directoryResultInvalid) + resp := ask(t, member, a.Host.ID(), req) + if resp.GetError() == "" { + t.Fatalf("accepted; want a refusal") + } + if got := directoryCount(t, op, directoryResultInvalid); got != before+1 { + t.Fatalf("invalid %s counted = %v, want %v", op, got, before+1) + } + }) + } + if provs, _ := a.DHT.ProviderStore().GetProviders(context.Background(), key); len(provs) != 0 { + t.Fatalf("a refused record was stored: %v", provs) + } +} + +// A record placed by a router names its provider; the receiving router +// stores it under that provider, so a lookup there returns the member and +// not the router that placed it. +func TestDirectoryPlacementNamesTheProvider(t *testing.T) { + a, b := newDirectoryRouter(t), newDirectoryRouter(t) + federate(t, a, b) + member := attach(t, a) + key, _ := api.ServiceKey(api.ServiceType_SERVICE_TYPE_INFERENCE, "") + memberAddr := member.Addrs()[0].String() + "/p2p/" + member.ID().String() + if resp := ask(t, member, a.Host.ID(), provide(key, memberAddr)); resp.GetError() != "" { + t.Fatalf("provide refused: %s", resp.GetError()) + } + provs, err := b.DHT.ProviderStore().GetProviders(context.Background(), key) + if err != nil { + t.Fatal(err) + } + if len(provs) != 1 || provs[0].ID != member.ID() { + t.Fatalf("router b holds %v, want the member's record placed by router a", provs) + } + if len(provs[0].Addrs) == 0 || provs[0].Addrs[0].String() != member.Addrs()[0].String() { + t.Fatalf("router b holds addrs %v, want the member's %v without the peer suffix", provs[0].Addrs, member.Addrs()[0]) + } +} diff --git a/internal/router/resource_limits.go b/internal/router/resource_limits.go index 4dc6f1a7c..594f16841 100644 --- a/internal/router/resource_limits.go +++ b/internal/router/resource_limits.go @@ -38,7 +38,7 @@ import ( // discovery (libp2p_rcmgr_blocked_resources{scope="protocol"} counted them, // without saying which protocol). var memberProtocols = slices.Concat( - []protocol.ID{identify.ID, identify.IDPush, api.AuthProtocolID, api.DHTProtocolID}, + []protocol.ID{identify.ID, identify.IDPush, api.AuthProtocolID, api.DHTProtocolID, api.DirectoryProtocolID}, pubsub.GossipSubDefaultProtocols, ) diff --git a/internal/router/router.go b/internal/router/router.go index 42face055..b9822d818 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -338,6 +338,14 @@ type Router struct { // peerRouters is the other routers of the mesh, as /info lists them or as // a handshake proved them: held whatever the load and never sent away. peerRouters sync.Map + // gated is the host the DHT, gossip and the directory run on: it serves + // and reaches authenticated peers only (authGate). + gated *authGate + // listedRouters is the peer routers the control plane listed on the + // last federation pass (*[]peer.AddrInfo), for ensureFederated. + listedRouters atomic.Value + lastFederation atomic.Int64 + federateMu sync.Mutex // handshakeLimiter bounds /mesh/auth attempts per peer; any internet peer // can open those streams. handshakeLimiter *ratelimit.PeerRateLimiter @@ -505,8 +513,10 @@ func (r *Router) Start() (retErr error) { return err } r.Host = hostNode - // The DHT and gossip serve, and are served by, authenticated peers only. + // The DHT, gossip and the directory serve, and are served by, + // authenticated peers only. gated := &authGate{Host: hostNode, r: r} + r.gated = gated // Setup DHT kadDHT, err := dht.New(gated, r.dhtOptions()...) @@ -551,6 +561,9 @@ func (r *Router) Start() (retErr error) { // Setup authentication stream handler hostNode.SetStreamHandler(api.AuthProtocolID, recoverStreamHandler("AuthHandshake", r.HandleAuthHandshake)) + // The directory a member asks this router for, and routers place + // records on each other through. + gated.SetStreamHandler(api.DirectoryProtocolID, recoverStreamHandler("Directory", r.handleDirectory)) // Clean authenticated status on peer disconnection hostNode.Network().Notify(&network.NotifyBundle{ @@ -1299,17 +1312,30 @@ func (r *Router) reconcileBannedPeers(peerIDs []string, fetchedAt time.Time) { } } +// federationInterval is how often a router reads the control plane's list +// of routers and authenticates with the ones it has not; federationCatchUp +// is the interval while the list names a router it has not reached yet, +// which it does at start, when the other routers started at the same +// moment and leased after this one's first pass, and whenever a router +// joins. +const ( + federationInterval = 30 * time.Second + federationCatchUp = 2 * time.Second +) + func (r *Router) runFederationLoop() { defer r.wg.Done() - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - r.connectBootstrapRouters() - + timer := time.NewTimer(0) + defer timer.Stop() for { select { - case <-ticker.C: + case <-timer.C: r.connectBootstrapRouters() + next := federationInterval + if listed, authenticated := r.peerRouterCounts(); authenticated < listed { + next = federationCatchUp + } + timer.Reset(next) case <-r.ctx.Done(): return } @@ -1332,6 +1358,7 @@ func (r *Router) connectBootstrapRouters() { r.reconcileBannedPeers(info.GetBannedPeerIds(), fetchedAt) listed := map[peer.ID]bool{} + var listedInfos []peer.AddrInfo for _, addrStr := range info.RouterAddresses { ma, err := multiaddr.NewMultiaddr(addrStr) if err != nil { @@ -1354,17 +1381,94 @@ func (r *Router) connectBootstrapRouters() { continue } listed[pi.ID] = true + listedInfos = append(listedInfos, *pi) r.peerRouters.Store(pi.ID, true) r.Host.ConnManager().Protect(pi.ID, federationTag) if r.isPeerAuthenticated(pi.ID) { + // Already admitted; make sure the table has it. kad-dht + // drops a peer its filter refused, and a router that + // authenticated while that was being decided would + // otherwise stay out of the table until it authenticated + // again. + r.admitPeerRouter(pi.ID) continue } r.authenticatePeerRouter(*pi) } } + r.listedRouters.Store(&listedInfos) + r.lastFederation.Store(time.Now().UnixNano()) r.forgetUnlistedRouters(listed) } +// federationStaleAfter is how old the last federation pass may be before +// a directory lookup that found nothing runs one of its own: a router that +// leased since may hold the record. +const federationStaleAfter = time.Second + +// refederateIfStale runs a federation pass when the last one is older than +// federationStaleAfter, one at a time, and reports whether one ran. A +// lookup that found nothing calls it before trying once more. +func (r *Router) refederateIfStale() bool { + if time.Since(time.Unix(0, r.lastFederation.Load())) < federationStaleAfter { + return false + } + r.federateMu.Lock() + defer r.federateMu.Unlock() + if time.Since(time.Unix(0, r.lastFederation.Load())) < federationStaleAfter { + return true + } + logger.Infof("[Federation] A lookup found nothing; reading the control plane's router list again") + r.connectBootstrapRouters() + return true +} + +// ensureFederated authenticates with every listed peer router this router +// has not authenticated with yet, at once, and returns when they are done +// or ctx ends. The federation pass does the same every 30 seconds; a +// directory request runs it first, so a lookup right after a router +// started, or right after another router joined, reaches every router's +// records instead of the ones in a table that is still filling. One run +// at a time: a second caller waits for the first. +func (r *Router) ensureFederated(ctx context.Context) { + listed, _ := r.listedRouters.Load().(*[]peer.AddrInfo) + if listed == nil { + return + } + var missing []peer.AddrInfo + for _, pi := range *listed { + if !r.isPeerAuthenticated(pi.ID) { + missing = append(missing, pi) + } + } + if len(missing) == 0 { + return + } + r.federateMu.Lock() + defer r.federateMu.Unlock() + logger.Infof("[Federation] %d listed router(s) not authenticated yet; authenticating before serving the directory", len(missing)) + done := make(chan struct{}) + go func() { + defer close(done) + var wg sync.WaitGroup + for _, pi := range missing { + if r.isPeerAuthenticated(pi.ID) { + continue + } + wg.Add(1) + go func(pi peer.AddrInfo) { + defer wg.Done() + r.authenticatePeerRouter(pi) + }(pi) + } + wg.Wait() + }() + select { + case <-done: + case <-ctx.Done(): + } +} + // forgetUnlistedRouters drops the peer routers the control plane no longer // lists: a router that was replaced keeps its lease until it expires and // nothing else would ever remove it, so it would stay listed and protected diff --git a/mobile/agentmesh-node-app/android/app/src/main/kotlin/dev/sammesh/connect/SamFunctions.kt b/mobile/agentmesh-node-app/android/app/src/main/kotlin/dev/sammesh/connect/SamFunctions.kt index 4ae06119b..d5c3d5e1b 100644 --- a/mobile/agentmesh-node-app/android/app/src/main/kotlin/dev/sammesh/connect/SamFunctions.kt +++ b/mobile/agentmesh-node-app/android/app/src/main/kotlin/dev/sammesh/connect/SamFunctions.kt @@ -22,7 +22,7 @@ class SamFunctions { /** * Retrieves the current status and statistics of the Agent Mesh node. - * Returns a JSON string containing connected_peers, dht_size, and node_id. + * Returns a JSON string containing connected_peers, attached_routers, and node_id. */ @AppFunction(isDescribedByKDoc = true) suspend fun getMeshStatus(context: AppFunctionContext): String { diff --git a/mobile/agentmesh-node-app/lib/main.dart b/mobile/agentmesh-node-app/lib/main.dart index 240b1ecbc..bcffdf9b3 100644 --- a/mobile/agentmesh-node-app/lib/main.dart +++ b/mobile/agentmesh-node-app/lib/main.dart @@ -142,7 +142,7 @@ class _NodeControlPageState extends State { String _status = ''; String _nodeID = ''; int _connectedPeers = 0; - int _dhtSize = 0; + int _attachedRouters = 0; bool _loggingIn = false; bool _devicePollingActive = false; Timer? _pollingTimer; @@ -976,7 +976,7 @@ class _NodeControlPageState extends State { if (!mounted) return; setState(() { _connectedPeers = info['connected_peers'] ?? 0; - _dhtSize = info['dht_size'] ?? 0; + _attachedRouters = info['attached_routers'] ?? 0; if (info['node_id'] != null) { _nodeID = info['node_id']; } @@ -1106,7 +1106,7 @@ class _NodeControlPageState extends State { _running = false; _status = 'Stopped'; _connectedPeers = 0; - _dhtSize = 0; + _attachedRouters = 0; } }); } @@ -1752,9 +1752,9 @@ class _NodeControlPageState extends State { const SizedBox(width: 16), Expanded( child: _buildStatCard( - icon: Icons.storage, - title: 'DHT Size', - value: '$_dhtSize', + icon: Icons.hub, + title: 'Routers', + value: '$_attachedRouters', color: Colors.purple, ), ), diff --git a/mobile/agentmesh-node-ffi/ffi/ffi.go b/mobile/agentmesh-node-ffi/ffi/ffi.go index af04bb5ad..e1fdf41d3 100644 --- a/mobile/agentmesh-node-ffi/ffi/ffi.go +++ b/mobile/agentmesh-node-ffi/ffi/ffi.go @@ -513,15 +513,11 @@ func GetMeshInfo() string { } peers := activeNode.Host.Network().Peers() - dhtSize := 0 - if activeNode.DHT != nil && activeNode.DHT.RoutingTable() != nil { - dhtSize = activeNode.DHT.RoutingTable().Size() - } resData := map[string]any{ - "connected_peers": len(peers), - "dht_size": dhtSize, - "node_id": activeNode.Host.ID().String(), + "connected_peers": len(peers), + "attached_routers": activeNode.AttachedRouters(), + "node_id": activeNode.Host.ID().String(), } jsonBytes, err := json.Marshal(resData) diff --git a/sdk/README.md b/sdk/README.md index 90fc6f08f..e654e3cc1 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -23,7 +23,7 @@ An SDK member is an agent, not a service provider. It calls what the mesh offers: MCP tools, inference and A2A services that `agentmesh-node`s publish and the discovery table names. Inbound, it accepts one thing: A2A requests for itself, as `a2a://`, reached by peer ID through a router. It publishes -no service, writes no provider record and reports no catalog; a tool, a +no service, writes no directory record and reports no catalog; a tool, a model or a named agent that others should find by name runs behind a `agentmesh-node`. The reasons are in [Agents, not services](#agents-not-services). @@ -44,7 +44,7 @@ Milestones 1 to 5 are implemented and tested in both languages: | `/mesh/auth/1.0.0`, both sides; join = pick `k` routers (default 2) by selector, preference and load, handshake and check role | yes | yes | | Circuit relay v2 reservation on each held router; dial and accept through them | yes | yes | | `/mesh/goaway/1.0.0`: shun a draining or overloaded router for `retry_after` and replace it from the catalog; redial (`2s, 4s, 8s`) before failing over on a dropped connection | yes | yes | -| Service discovery in the mesh DHT (`/mesh/kad/1.0.0`) | yes | yes | +| Service discovery through a held router (`/mesh/directory/1.0.0`) | yes | yes | | `/mesh/mcp/1.0.0` client: list and call a provider's tools, or its catalog | yes | yes | | Caller-side label requirements on the provider's credential | yes | yes | | `/libp2p-http` client: call inference and A2A services on the mesh; response bodies stream | yes | yes | @@ -58,7 +58,7 @@ Milestones 1 to 5 are implemented and tested in both languages: | Published to a registry from the release workflow | npm `@agentmesh-p2p/sdk` | PyPI `agentmesh-p2p` | A member built this way is on the mesh and uses it in both directions: it -finds a service in the DHT and calls it through the router, verifying the +finds a service in the directory and calls it through the router, verifying the provider on every call, and it answers A2A requests for its own agent from a `agentmesh-node` or another SDK member that names it by peer ID, authorizing every caller with the same Datalog the Go node evaluates. It follows the @@ -125,14 +125,11 @@ pinned by a test: host dials such addresses by name (libp2p/py-libp2p#1549). It also dials `wss` with certificate verification off unless given a TLS context (libp2p/py-libp2p#1550); the host gives it the system roots. -- py-libp2p's Kademlia client takes a protocol prefix but its provider - lookups still speak `/ipfs/kad/1.0.0`, so it cannot reach the mesh DHT. - The Python SDK does a bounded GET_PROVIDERS walk itself on - `/mesh/kad/1.0.0`, seeded by the routers (`agent_mesh/discovery.py`). - js-libp2p's `@libp2p/kad-dht` takes the protocol and works as is. -- go-libp2p-kad-dht keys provider records by the multihash, not the CID. - `sdk/testdata/service_keys.json` pins the keys both SDKs derive against - `internal/node/service.go`. +- A member keeps no DHT. Both SDKs ask a router they hold over + `/mesh/directory/1.0.0` (`discovery.ts`, `agent_mesh/discovery.py`), one + `DirectoryRequest` per stream, and the router runs the lookup in the DHT + the routers keep among themselves. `sdk/testdata/service_keys.json` pins + the keys both SDKs derive against `api/directory_keys.go`. - go-libp2p-http is plain HTTP/1.1 on a stream, one request per stream, with `Host` set to the peer ID. The JS SDK bridges the stream to a Node duplex and runs Node's HTTP server and client on it; the Python SDK @@ -144,7 +141,7 @@ pinned by a test: `http://mesh/mesh////`, the shape of `agentmesh-node`'s egress proxy and of an agent card rewritten for the mesh; the host is ignored. - A member that publishes nothing has announced no address, so nothing in - the DHT or a router's peerstore names one. `agentmesh-node` dials + the directory names one. `agentmesh-node` dials `/p2p//p2p-circuit` for every router it authenticated with when it knows nothing else about a peer, as both SDKs do for a peer named by ID (`internal/node/mcp.go`, `preparePeerAddrs`). @@ -243,7 +240,7 @@ messages in `api/agentmesh.proto`. Bodies are capped at 1 MiB on both sides. not. Challenges are defined in `api/network.go`. It is the one instant on the wire that is an `int64`: it is the number in the signed text. Every other - instant (`expire_time`, `sign_time`, `event_time`, `announce_time`) is a + instant (`expire_time`, `sign_time`, `event_time`) is a `google.protobuf.Timestamp`, and a receiver rejects a message whose required instant is unset. - A bootstrap enrollment answers `PENDING` until an operator approves it, @@ -283,7 +280,8 @@ messages in `api/agentmesh.proto`. Bodies are capped at 1 MiB on both sides. - Muxer: yamux (libp2p default). - Relay: circuit relay v2, with the routers as static relays; hole punching is on. A node behind NAT is reached through a router. -- DHT: Kademlia with protocol prefix `/mesh`, mode auto. +- Directory: `/mesh/directory/1.0.0` to a router the session holds; a + member keeps no DHT and speaks no Kademlia. ### Streams @@ -334,15 +332,15 @@ length, then the bytes), with a 64 KiB cap on the first frame. (`2s, 4s, 8s`) and, if it does not come back or refuses auth, shuns it and tops up from the catalog. -### Discovery (`internal/node/service.go`) +### Discovery (`api/directory_keys.go`, `internal/node/directory_client.go`) -A provider announces a service as a DHT provider record for -`cidv1(raw, sha256("mesh:service:[:]"))`, once for the type and -once for the name (`` is `mcp`, `inference`, `a2a` or `egress`). A consumer -looks up providers for the same CID, dials one and opens `/mesh/mcp/1.0.0`. -Gossip topics under `/mesh/discovery/v1/...` carry `ServiceAnnounce` for -interest-scoped updates; the DHT is the source of truth. The SDKs look -records up and announce none. +A provider announces a service to a router it holds as a record under +the key `sha256("mesh:service:[:]")`, once for the type and once +for the name (`` is `mcp`, `inference`, `a2a` or `egress`), with the +addresses it is reached at; the router keeps the record and places it on +the routers closest to the key. A consumer asks a router it holds for the +providers of the same key, dials one and opens `/mesh/mcp/1.0.0`. The SDKs +look records up and announce none. A peer that announced nothing is still reachable by peer ID: every router relays for the peers it admitted, so a caller dials @@ -385,7 +383,7 @@ of it is derived in the SDK: An SDK member calls services and accepts A2A requests for its own agent. It does not publish services: no MCP server, no named inference or A2A -service, no DHT record, no catalog entry. The reasons: +service, no directory record, no catalog entry. The reasons: - **The agent is the principal; a service is infrastructure.** A harness (LangChain, Claude Code, a browser agent) is an MCP client. The tools it @@ -393,9 +391,9 @@ service, no DHT record, no catalog entry. The reasons: that must be reachable by name, a tool, a model, a named agent, is a deployment concern, and the sidecar model exists for it: the `agentmesh-node` beside it publishes the service and enforces the policy once, in Go. -- **Publishing is the expensive part.** Being a provider needs a DHT - provide loop, a catalog report and provider records that go stale on - every rollout. Accepting requests for the agent itself needs the relay +- **Publishing is the expensive part.** Being a provider needs an + announcement loop, a catalog report and records that go stale on every + rollout. Accepting requests for the agent itself needs the relay reservation, the auth handshake and the authorizer, all of which a member needs anyway to be verified by a `agentmesh-node` before it is called. - **One inbound protocol.** A2A is the protocol for talking to an agent, @@ -411,7 +409,7 @@ service, no DHT record, no catalog entry. The reasons: What an SDK agent is on the wire: a peer with a relay reservation on a router, answering `/mesh/auth/1.0.0` and `/libp2p-http` for `a2a://`, -with no DHT record and no catalog entry. To a `agentmesh-node` it is a peer whose +with no directory record and no catalog entry. To a `agentmesh-node` it is a peer whose address nobody announced, reached through the router that admitted both, and whose credential it verifies before forwarding `/mesh//a2a//...`. Routers, the control plane and the Datalog @@ -483,10 +481,8 @@ holds against the control plane's records. ### Milestone 3 — call tools (done) - Discovery: `session.discover("mcp://calc")`, or `(type, name)`, or a type - alone, looks the mesh DHT up for the service key - `internal/node/service.go` derives. JS: `@libp2p/kad-dht` in client mode - on `/mesh/kad/1.0.0`; Python: the SDK's own bounded GET_PROVIDERS walk on - the same protocol (see the facts above). + alone, asks a router the session holds, over `/mesh/directory/1.0.0`, + for the providers of the service key `api/directory_keys.go` derives. - Naming a peer: every call takes a `Peer`, which is a provider `discover` returned, a peer id, or a multiaddr. For the first two the SDK dials the advertised addresses and `/p2p-circuit/p2p/` through every @@ -521,7 +517,7 @@ holds against the control plane's records. it, reads its catalog, and is refused for missing labels, an untrusted provider, a forged caller credential and an unknown service. Integration: the mesh of `sdk_mesh_test.go` has an MCP backend behind the `agentmesh-node` - (`calc`, tool `add`); each SDK member discovers it in the DHT, lists and + (`calc`, tool `add`); each SDK member discovers it through its router, lists and calls `add` through the router, reads the node's catalog and is refused a service the node does not have. The runners took `discover`, `tools` and `call` commands for it. @@ -673,7 +669,7 @@ same commit as the Go components they talk to. - Rewriting the control plane, router or sandbox components; they stay Go. - Publishing services from an SDK: an MCP server, a named inference or A2A - service, a DHT record or a catalog entry. That is `agentmesh-node`'s job; see + service, a directory record or a catalog entry. That is `agentmesh-node`'s job; see [Agents, not services](#agents-not-services). - Any SDK-only wire protocol. If an SDK needs something the Go node does not speak, the Go node learns it first. @@ -750,7 +746,7 @@ one follows is named so a change on one side can be carried to the others. | `auth.ts` | `auth.py` | `/mesh/auth/1.0.0` on both sides, as `HandleAuthHandshake` | | — | `relay.py` | circuit relay v2 client (py-libp2p's cannot talk to go-libp2p) | | `session.ts` | `session.py` | `MeshSession`: join, relay reservation, refresh loop, control plane sync loop, gossip events, `acceptA2A()` / `accept_a2a()`, `fetch()` | -| `discovery.ts` | `discovery.py` | service keys and DHT lookups as `internal/node/service.go`; lookups only, no records | +| `discovery.ts` | `discovery.py` | service keys and directory requests to a held router, as `internal/node/directory_client.go`; lookups only, no records | | `mcp.ts` | `mcp_client.py` | MCP over `/mesh/mcp/1.0.0`, the client side of `internal/node/gate.go` | | `authorizer.ts` | `authorizer.py` | the provider authorizer, as `internal/node.(*AgentMeshNode).Authorize`, over the generated baseline and `datalog_rules` | | `libp2p-http.ts` | `libp2p_http.py` | `/libp2p-http` client (streaming) and the A2A ingress for the agent, as go-libp2p-http and `StartIngressServer`; mesh URLs | diff --git a/sdk/js/package-lock.json b/sdk/js/package-lock.json index abfb65f04..519ab307e 100644 --- a/sdk/js/package-lock.json +++ b/sdk/js/package-lock.json @@ -18,7 +18,6 @@ "@libp2p/gossipsub": "^17.1.2", "@libp2p/identify": "^4.1.14", "@libp2p/interface": "^3.3.0", - "@libp2p/kad-dht": "^16.4.5", "@libp2p/peer-id": "^6.0.15", "@libp2p/ping": "^3.1.13", "@libp2p/tcp": "^11.0.28", @@ -855,54 +854,6 @@ "uint8arrays": "^6.0.0" } }, - "node_modules/@libp2p/kad-dht": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/@libp2p/kad-dht/-/kad-dht-16.4.5.tgz", - "integrity": "sha512-Pxmk3pIfe5Ih1xr+CY1uzw8AXRY+2VI6p3iebM4ljueu+dovEPIahW8tq4F2iNHIQftuc9Svncw4JikwrU7I1w==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "@libp2p/crypto": "^5.1.23", - "@libp2p/interface": "^3.3.0", - "@libp2p/interface-internal": "^3.1.13", - "@libp2p/peer-collections": "^7.0.28", - "@libp2p/peer-id": "^6.0.15", - "@libp2p/ping": "^3.1.13", - "@libp2p/record": "^4.0.15", - "@libp2p/utils": "^7.4.1", - "@multiformats/multiaddr": "^13.0.3", - "@multiformats/multiaddr-matcher": "^3.0.2", - "any-signal": "^4.1.1", - "interface-datastore": "^10.0.1", - "it-all": "^3.0.9", - "it-drain": "^3.0.10", - "it-length": "^3.0.9", - "it-map": "^3.1.4", - "it-merge": "^3.0.12", - "it-parallel": "^3.0.13", - "it-pipe": "^3.0.1", - "it-pushable": "^3.2.3", - "it-take": "^3.0.9", - "main-event": "^1.0.1", - "multiformats": "^14.0.0", - "p-defer": "^4.0.1", - "p-event": "^7.0.0", - "progress-events": "^1.0.1", - "protons-runtime": "^7.0.0", - "race-signal": "^2.0.0", - "uint8-varint": "^3.0.0", - "uint8arraylist": "^3.0.2", - "uint8arrays": "^6.1.1" - } - }, - "node_modules/@libp2p/kad-dht/node_modules/uint8arraylist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/uint8arraylist/-/uint8arraylist-3.0.2.tgz", - "integrity": "sha512-LDVoq9BQaGJzGDUovEnoX6rpKCvnY/Jbtws4ikwnBzjRbq5qBAFpBZevUEbSmMM87aO0Sp+wOZy2ZXf5yODmXQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "uint8arrays": "^6.0.0" - } - }, "node_modules/@libp2p/logger": { "version": "6.2.13", "resolved": "https://registry.npmjs.org/@libp2p/logger/-/logger-6.2.13.tgz", @@ -1042,26 +993,6 @@ "uint8arrays": "^6.0.0" } }, - "node_modules/@libp2p/record": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@libp2p/record/-/record-4.0.15.tgz", - "integrity": "sha512-EaHFtQAlZuM9vCpd+0KnZhTVgV26Uzje7DkyUseGNKTy+Wit9Q94C/+wDThpZuRHnK1FH/CvPxJ4kLj7AoQ1WA==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "protons-runtime": "^7.0.0", - "uint8arraylist": "^3.0.2", - "uint8arrays": "^6.1.1" - } - }, - "node_modules/@libp2p/record/node_modules/uint8arraylist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/uint8arraylist/-/uint8arraylist-3.0.2.tgz", - "integrity": "sha512-LDVoq9BQaGJzGDUovEnoX6rpKCvnY/Jbtws4ikwnBzjRbq5qBAFpBZevUEbSmMM87aO0Sp+wOZy2ZXf5yODmXQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "uint8arrays": "^6.0.0" - } - }, "node_modules/@libp2p/tcp": { "version": "11.0.28", "resolved": "https://registry.npmjs.org/@libp2p/tcp/-/tcp-11.0.28.tgz", @@ -2806,12 +2737,6 @@ "it-peekable": "^3.0.0" } }, - "node_modules/it-length": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/it-length/-/it-length-3.0.11.tgz", - "integrity": "sha512-j0uukHdr3zoLm4dcozDoyv5khQrOI8dfXMSSEqaxorfOtleh1KwRVbdnTmzj6HVkHgTM1jcx+bhAcnTkbpHl+g==", - "license": "Apache-2.0 OR MIT" - }, "node_modules/it-length-prefixed": { "version": "11.0.1", "resolved": "https://registry.npmjs.org/it-length-prefixed/-/it-length-prefixed-11.0.1.tgz", diff --git a/sdk/js/package.json b/sdk/js/package.json index d6b8dfa85..e96b941f1 100644 --- a/sdk/js/package.json +++ b/sdk/js/package.json @@ -50,7 +50,6 @@ "@libp2p/gossipsub": "^17.1.2", "@libp2p/identify": "^4.1.14", "@libp2p/interface": "^3.3.0", - "@libp2p/kad-dht": "^16.4.5", "@libp2p/peer-id": "^6.0.15", "@libp2p/ping": "^3.1.13", "@libp2p/tcp": "^11.0.28", diff --git a/sdk/js/src/discovery.ts b/sdk/js/src/discovery.ts index 9a3669d03..466afec36 100644 --- a/sdk/js/src/discovery.ts +++ b/sdk/js/src/discovery.ts @@ -12,23 +12,32 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Service discovery as agentmesh-node does it (internal/node/service.go): a -// provider record in the mesh DHT under a CID derived from the service key. +// The mesh's directory as a member uses it, as agentmesh-node does +// (internal/node/directory_client.go): the member keeps no DHT and asks the +// routers it holds over /mesh/directory/1.0.0, one DirectoryRequest per +// stream, one DirectoryResponse back. A request goes to one held router and +// to the next when it fails. The routers run the lookup across their own +// DHT. A key is the sha256 multihash of "mesh:service:[:]" for +// a service and of "mesh:peer:" for a member. -import { CID } from "multiformats/cid"; -import * as raw from "multiformats/codecs/raw"; +import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; +import type { Connection } from "@libp2p/interface"; +import { lpStream } from "@libp2p/utils"; import { sha256 } from "multiformats/hashes/sha2"; +import { + DirectoryRequestSchema, + DirectoryResponseSchema, + type DirectoryRequest, + type DirectoryResponse, + type PeerRecord, +} from "./gen/agentmesh_pb.ts"; -/** The DHT protocol, go-libp2p-kad-dht with dht.ProtocolPrefix("/mesh"). */ -export const DHT_PROTOCOL = "/mesh/kad/1.0.0"; +/** api.DirectoryProtocolID. */ +export const DIRECTORY_PROTOCOL = "/mesh/directory/1.0.0"; -/** - * How many routers hold each record and answer each lookup when the control - * plane publishes no directory parameters on /info: api.DefaultDHTReplication. - * A member reads the mesh's value from /info (AgentMesh.dhtReplication) and - * runs its DHT client on it (createMeshHost), as agentmesh-node does. - */ -export const DEFAULT_DHT_REPLICATION = 3; +const REQUEST_TIMEOUT_MS = 10_000; +const MAX_DIRECTORY_MESSAGE_BYTES = 64 * 1024; +export const DEFAULT_LIMIT = 20; export type ServiceType = "mcp" | "inference" | "a2a" | "egress"; @@ -36,14 +45,23 @@ export function isServiceType(value: unknown): value is ServiceType { return value === "mcp" || value === "inference" || value === "a2a" || value === "egress"; } -/** The DHT key of a service: by type and name, or by type alone when name is omitted. */ -export async function serviceCID(type: ServiceType, name?: string): Promise { - const key = ["mesh:service", type, ...(name !== undefined && name !== "" ? [name] : [])].join(":"); - return CID.createV1(raw.code, await sha256.digest(new TextEncoder().encode(key))); +/** The sha256 multihash of a name: the two-byte header and the digest. */ +async function directoryKey(name: string): Promise { + return (await sha256.digest(new TextEncoder().encode(name))).bytes; +} + +/** The directory key of a service type, or of one named service of that type (api.ServiceKey). */ +export async function serviceKey(type: ServiceType, name?: string): Promise { + return directoryKey(["mesh:service", type, ...(name !== undefined && name !== "" ? [name] : [])].join(":")); +} + +/** The directory key a member announces where it is reached under (api.PeerKey). */ +export async function peerKey(peerId: string): Promise { + return directoryKey(`mesh:peer:${peerId}`); } /** Splits "mcp://calculator" into its type and name; "" is the node's own catalog. */ -export function parseServiceTarget(target: string): { type: ServiceType | "" ; name: string } { +export function parseServiceTarget(target: string): { type: ServiceType | ""; name: string } { if (target === "") { return { type: "", name: "" }; } @@ -53,3 +71,89 @@ export function parseServiceTarget(target: string): { type: ServiceType | "" ; n } return { type: m[1] as ServiceType, name: m[2] as string }; } + +/** No held router answered a directory request. */ +export class DirectoryError extends Error { + constructor(message: string) { + super(message); + this.name = "DirectoryError"; + } +} + +/** A router connection a request goes over. */ +export interface DirectoryRouter { + peerId: string; + connection(): Connection | undefined; +} + +/** One request to one router, answered or thrown. */ +async function ask(conn: Connection, req: DirectoryRequest, signal: AbortSignal): Promise { + const stream = await conn.newStream(DIRECTORY_PROTOCOL, { signal }); + try { + const lp = lpStream(stream, { maxDataLength: MAX_DIRECTORY_MESSAGE_BYTES }); + await lp.write(toBinary(DirectoryRequestSchema, req), { signal }); + const resp = fromBinary(DirectoryResponseSchema, (await lp.read({ signal })).subarray()); + if (resp.error !== "") { + throw new DirectoryError(resp.error); + } + return resp; + } finally { + await stream.close().catch(() => stream.abort(new Error("directory stream not closed"))); + } +} + +/** + * Sends req to the held routers in turn until one answers. A router that + * is restarting costs the request a timeout, not an answer. + */ +export async function request(routers: DirectoryRouter[], req: DirectoryRequest, signal?: AbortSignal): Promise { + let last: unknown; + let asked = 0; + for (const router of routers) { + const conn = router.connection(); + if (conn === undefined) { + continue; + } + asked++; + const bounded = signal === undefined ? AbortSignal.timeout(REQUEST_TIMEOUT_MS) : AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]); + try { + return await ask(conn, req, bounded); + } catch (err) { + last = err; + if (signal?.aborted === true) { + break; + } + } + } + if (asked === 0) { + throw new DirectoryError("no held router to ask"); + } + throw new DirectoryError(`no held router answered: ${last instanceof Error ? last.message : String(last)}`); +} + +/** + * Announces this member as a provider of key, reached at addrs, to a held + * router; the router keeps the record and places it on the routers closest + * to the key. + */ +export async function provide(routers: DirectoryRouter[], key: Uint8Array, addrs: string[], signal?: AbortSignal): Promise { + await request(routers, create(DirectoryRequestSchema, { request: { case: "provide", value: { key, addrs } } }), signal); +} + +/** Who provides key, up to limit, with their addresses; empty when nobody does. */ +export async function findProviders(routers: DirectoryRouter[], key: Uint8Array, limit = DEFAULT_LIMIT, signal?: AbortSignal): Promise { + const resp = await request(routers, create(DirectoryRequestSchema, { request: { case: "findProviders", value: { key, limit: Math.max(limit, 1) } } }), signal); + return resp.providers.slice(0, limit); +} + +/** + * Where a peer is reached: its addresses, each ending in its peer ID, + * relayed ones included. Undefined when nobody knows the peer. + */ +export async function findPeer(routers: DirectoryRouter[], peerId: string, signal?: AbortSignal): Promise { + const resp = await request(routers, create(DirectoryRequestSchema, { request: { case: "findPeer", value: { peerId } } }), signal); + if (resp.peer === undefined || resp.peer.peerId !== peerId) { + return undefined; + } + return resp.peer; +} diff --git a/sdk/js/src/gen/agentmesh_pb.ts b/sdk/js/src/gen/agentmesh_pb.ts index 75b3e8ce7..e8cbe917c 100644 --- a/sdk/js/src/gen/agentmesh_pb.ts +++ b/sdk/js/src/gen/agentmesh_pb.ts @@ -26,7 +26,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file agentmesh.proto. */ export const file_agentmesh: GenFile = /*@__PURE__*/ - fileDesc("Cg9hZ2VudG1lc2gucHJvdG8SDGFnZW50bWVzaC52MSI0CglBdXRoRnJhbWUSDwoHYmlzY3VpdBgBIAEoDBIWCg50YXJnZXRfc2VydmljZRgCIAEoCSI/CgxBdXRoUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCRIPCgdiaXNjdWl0GAMgASgMIrEBCgxSb3V0ZXJHb0F3YXkSMQoGcmVhc29uGAEgASgOMiEuYWdlbnRtZXNoLnYxLlJvdXRlckdvQXdheS5SZWFzb24SLgoLcmV0cnlfYWZ0ZXIYAiABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb24iPgoGUmVhc29uEhYKElJFQVNPTl9VTlNQRUNJRklFRBAAEgwKCERSQUlOSU5HEAESDgoKT1ZFUkxPQURFRBACItwBCglNZXNoRXZlbnQSKgoEdHlwZRgBIAEoDjIcLmFnZW50bWVzaC52MS5NZXNoRXZlbnQuVHlwZRIPCgdwZWVyX2lkGAIgASgJEi4KCmV2ZW50X3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDm5ld19wdWJsaWNfa2V5GAQgASgMEhEKCXNpZ25hdHVyZRgFIAEoDCI3CgRUeXBlEgoKBkJBTk5FRBAAEhAKDEtFWV9ST1RBVElPThABEhEKDVBPTElDWV9VUERBVEUQAiL5AQoNRW5yb2xsUmVxdWVzdBILCgNqd3QYASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjcKBmxhYmVscxgFIAMoCzInLmFnZW50bWVzaC52MS5FbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKrAQoORW5yb2xsUmVzcG9uc2USFQoNYmlzY3VpdF90b2tlbhgBIAEoDBIVCg1lcnJvcl9tZXNzYWdlGAIgASgJEiAKGGNvbnRyb2xfcGxhbmVfcHVibGljX2tleRgDIAEoDBIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEi8KC2V4cGlyZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKXAgoWQm9vdHN0cmFwRW5yb2xsUmVxdWVzdBIXCg9ib290c3RyYXBfdG9rZW4YASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEkAKBmxhYmVscxgFIAMoCzIwLmFnZW50bWVzaC52MS5Cb290c3RyYXBFbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKDAgoXQm9vdHN0cmFwRW5yb2xsUmVzcG9uc2USLgoGc3RhdHVzGAEgASgOMh4uYWdlbnRtZXNoLnYxLkVucm9sbG1lbnRTdGF0dXMSFQoNYmlzY3VpdF90b2tlbhgCIAEoDBIdChVwb2xsX2ludGVydmFsX3NlY29uZHMYAyABKAUSFQoNZXJyb3JfbWVzc2FnZRgEIAEoCRIgChhjb250cm9sX3BsYW5lX3B1YmxpY19rZXkYBSABKAwSGAoQcm91dGVyX2FkZHJlc3NlcxgGIAMoCRIvCgtleHBpcmVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiWQoLU2VydmljZUluZm8SJwoEdHlwZRgBIAEoDjIZLmFnZW50bWVzaC52MS5TZXJ2aWNlVHlwZRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJIoEBCg5Db21tYW5kQmFja2VuZBIPCgdjb21tYW5kGAEgAygJEjIKA2VudhgCIAMoCzIlLmFnZW50bWVzaC52MS5Db21tYW5kQmFja2VuZC5FbnZFbnRyeRoqCghFbnZFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIpYBChZSZWdpc3RlclNlcnZpY2VSZXF1ZXN0EioKB3NlcnZpY2UYASABKAsyGS5hZ2VudG1lc2gudjEuU2VydmljZUluZm8SFAoKdGFyZ2V0X3VybBgCIAEoCUgAEi8KB2NvbW1hbmQYAyABKAsyHC5hZ2VudG1lc2gudjEuQ29tbWFuZEJhY2tlbmRIAEIJCgdiYWNrZW5kImkKEkRpc2NvdmVyZWRQcm92aWRlchIPCgdwZWVyX2lkGAEgASgJEhcKD2xvY2FsX3Byb3h5X3VybBgCIAEoCRIQCghzcnZfbmFtZRgDIAEoCRIXCg9zcnZfZGVzY3JpcHRpb24YBCABKAkivgIKD1NlcnZpY2VBbm5vdW5jZRIPCgdwZWVyX2lkGAEgASgJEicKBHR5cGUYAiABKA4yGS5hZ2VudG1lc2gudjEuU2VydmljZVR5cGUSFAoMc2VydmljZV9uYW1lGAMgASgJEgwKBGtleXMYBCADKAkSOQoGbGFiZWxzGAUgAygLMikuYWdlbnRtZXNoLnYxLlNlcnZpY2VBbm5vdW5jZS5MYWJlbHNFbnRyeRIXCg9hY3RpdmVfcmVxdWVzdHMYBiABKA0SFwoPbGF0ZW5jeV9ld21hX21zGAcgASgBEjEKDWFubm91bmNlX3RpbWUYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wGi0KC0xhYmVsc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEi6AEKGENvbnRyb2xQbGFuZUluZm9SZXNwb25zZRITCgtvaWRjX2lzc3VlchgBIAEoCRIRCgljbGllbnRfaWQYAiABKAkSEAoIYXVkaWVuY2UYAyABKAkSGAoQcm91dGVyX2FkZHJlc3NlcxgEIAMoCRIXCg9iYW5uZWRfcGVlcl9pZHMYBSADKAkSKQoHcm91dGVycxgGIAMoCzIYLmFnZW50bWVzaC52MS5Sb3V0ZXJJbmZvEjQKCWRpcmVjdG9yeRgHIAEoCzIhLmFnZW50bWVzaC52MS5EaXJlY3RvcnlQYXJhbWV0ZXJzIpABChNEaXJlY3RvcnlQYXJhbWV0ZXJzEhMKC3JlcGxpY2F0aW9uGAEgASgFEjUKEnJlcHJvdmlkZV9pbnRlcnZhbBgCIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbhItCgpyZWNvcmRfdHRsGAMgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uIsQBCgpSb3V0ZXJJbmZvEg8KB3BlZXJfaWQYASABKAkSEQoJYWRkcmVzc2VzGAIgAygJEjQKBmxhYmVscxgDIAMoCzIkLmFnZW50bWVzaC52MS5Sb3V0ZXJJbmZvLkxhYmVsc0VudHJ5EhMKC2Nvbm5lY3Rpb25zGAQgASgFEhgKEGNvbm5lY3Rpb25fbGltaXQYBSABKAUaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKDAgoSUm91dGVyTGVhc2VSZXF1ZXN0Eg8KB3BlZXJfaWQYASABKAkSEQoJYWRkcmVzc2VzGAIgAygJEg8KB2Jpc2N1aXQYAyABKAwSFwoPY29ubmVjdGVkX3BlZXJzGAQgAygJEhAKCGRodF9zaXplGAUgASgFEhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwSJgoDdHRsGAggASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uEhMKC2Nvbm5lY3Rpb25zGAkgASgFEhgKEGNvbm5lY3Rpb25fbGltaXQYCiABKAUiZgoTUm91dGVyTGVhc2VSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJEi8KC2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKkAQoKUG9saWN5Um9sZRIMCgRuYW1lGAEgASgJEhcKD2FsbG93ZWRfdGFyZ2V0cxgCIAMoCRIYChBhbGxvd2VkX3NlcnZpY2VzGAMgAygJEhYKDmN1c3RvbV9kYXRhbG9nGAQgAygJEhYKDmFsbG93ZWRfbGFiZWxzGAUgAygJEiUKBGh0dHAYBiADKAsyFy5hZ2VudG1lc2gudjEuSFRUUEdyYW50IjwKCUhUVFBHcmFudBIPCgdzZXJ2aWNlGAEgASgJEg8KB21ldGhvZHMYAiADKAkSDQoFcGF0aHMYAyADKAkioQIKEUVncmVzc0Rlc3RpbmF0aW9uEgwKBG5hbWUYASABKAkSEgoKdGFyZ2V0X3VybBgCIAEoCRISCgpjcmVkZW50aWFsGAMgASgJEhEKCXNlcnZlZF9ieRgEIAMoCRIuCgZicm9rZXIYBSABKAsyHi5hZ2VudG1lc2gudjEuQ3JlZGVudGlhbEJyb2tlchIsCgppbnNwZWN0aW9uGAYgASgLMhguYWdlbnRtZXNoLnYxLkluc3BlY3Rpb24SJgoEbW9kZRgHIAEoDjIYLmFnZW50bWVzaC52MS5FZ3Jlc3NNb2RlEg0KBXBvcnRzGAggAygNEhUKDXByZXNlcnZlX2hvc3QYCSABKAgSFwoPZm9yd2FyZF9jb250ZXh0GAogASgIIjkKCkluc3BlY3Rpb24SKwoKaW5zcGVjdG9ycxgBIAMoCzIXLmFnZW50bWVzaC52MS5JbnNwZWN0b3IibwoJSW5zcGVjdG9yEi8KC21vZGVsX2FybW9yGAEgASgLMhguYWdlbnRtZXNoLnYxLk1vZGVsQXJtb3JIABIpCghleHRfcHJvYxgCIAEoCzIVLmFnZW50bWVzaC52MS5FeHRQcm9jSABCBgoEa2luZCKRAQoKTW9kZWxBcm1vchIQCgh0ZW1wbGF0ZRgBIAEoCRIyCghyZXNwb25zZRgCIAEoDjIgLmFnZW50bWVzaC52MS5SZXNwb25zZUluc3BlY3Rpb24SEQoJZmFpbF9vcGVuGAMgASgIEioKB3RpbWVvdXQYBCABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb24iiAIKB0V4dFByb2MSDgoGdGFyZ2V0GAEgASgJEgoKAmNhGAIgASgJEhoKEmNsaWVudF9jZXJ0aWZpY2F0ZRgDIAEoCRI8Cg9wcm9jZXNzaW5nX21vZGUYBCABKAsyIy5hZ2VudG1lc2gudjEuRXh0UHJvY1Byb2Nlc3NpbmdNb2RlEhsKE2FsbG93X21vZGVfb3ZlcnJpZGUYBSABKAgSMgoPbWVzc2FnZV90aW1lb3V0GAYgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uEhoKEmZhaWx1cmVfbW9kZV9hbGxvdxgHIAEoCBIaChJtYXhfYnVmZmVyZWRfYnl0ZXMYCCABKA0i/wQKFUV4dFByb2NQcm9jZXNzaW5nTW9kZRJLChNyZXF1ZXN0X2hlYWRlcl9tb2RlGAEgASgOMi4uYWdlbnRtZXNoLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZS5IZWFkZXJNb2RlEkwKFHJlc3BvbnNlX2hlYWRlcl9tb2RlGAIgASgOMi4uYWdlbnRtZXNoLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZS5IZWFkZXJNb2RlEkcKEXJlcXVlc3RfYm9keV9tb2RlGAMgASgOMiwuYWdlbnRtZXNoLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZS5Cb2R5TW9kZRJIChJyZXNwb25zZV9ib2R5X21vZGUYBCABKA4yLC5hZ2VudG1lc2gudjEuRXh0UHJvY1Byb2Nlc3NpbmdNb2RlLkJvZHlNb2RlEkwKFHJlcXVlc3RfdHJhaWxlcl9tb2RlGAUgASgOMi4uYWdlbnRtZXNoLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZS5IZWFkZXJNb2RlEk0KFXJlc3BvbnNlX3RyYWlsZXJfbW9kZRgGIAEoDjIuLmFnZW50bWVzaC52MS5FeHRQcm9jUHJvY2Vzc2luZ01vZGUuSGVhZGVyTW9kZSI5CgpIZWFkZXJNb2RlEhcKE0hFQURFUl9NT0RFX0RFRkFVTFQQABIICgRTRU5EEAESCAoEU0tJUBACImAKCEJvZHlNb2RlEggKBE5PTkUQABIMCghTVFJFQU1FRBABEgwKCEJVRkZFUkVEEAISFAoQQlVGRkVSRURfUEFSVElBTBADEhgKFEZVTExfRFVQTEVYX1NUUkVBTUVEEAQi4QEKEENyZWRlbnRpYWxCcm9rZXISFwoNc3RhdGljX3NlY3JldBgBIAEoCUgAEjcKD29pZGNfZmVkZXJhdGlvbhgCIAEoCzIcLmFnZW50bWVzaC52MS5PSURDRmVkZXJhdGlvbkgAEjYKD2F3c19hc3N1bWVfcm9sZRgDIAEoCzIbLmFnZW50bWVzaC52MS5BV1NBc3N1bWVSb2xlSAASOwoRcGxhdGZvcm1faWRlbnRpdHkYBCABKAsyHi5hZ2VudG1lc2gudjEuUGxhdGZvcm1JZGVudGl0eUgAQgYKBGtpbmQiXwoOT0lEQ0ZlZGVyYXRpb24SFgoOdG9rZW5fZW5kcG9pbnQYASABKAkSEAoIYXVkaWVuY2UYAiABKAkSEwoLaW1wZXJzb25hdGUYAyABKAkSDgoGc2NvcGVzGAQgAygJIjkKDUFXU0Fzc3VtZVJvbGUSEAoIcm9sZV9hcm4YASABKAkSFgoOc2Vzc2lvbl9wb2xpY3kYAiABKAkiIgoQUGxhdGZvcm1JZGVudGl0eRIOCgZzY29wZXMYASADKAkiLgoNUG9saWN5QmluZGluZxIMCgRyb2xlGAEgASgJEg8KB21lbWJlcnMYAiADKAkilwEKDFBvbGljeUNvbmZpZxInCgVyb2xlcxgBIAMoCzIYLmFnZW50bWVzaC52MS5Qb2xpY3lSb2xlEi0KCGJpbmRpbmdzGAIgAygLMhsuYWdlbnRtZXNoLnYxLlBvbGljeUJpbmRpbmcSLwoGZWdyZXNzGAMgAygLMh8uYWdlbnRtZXNoLnYxLkVncmVzc0Rlc3RpbmF0aW9uIhgKFlBvbGljeUNvbmZpZ0dldFJlcXVlc3QiMAoXUG9saWN5Q29uZmlnR2V0UmVzcG9uc2USFQoNZGF0YWxvZ19ydWxlcxgBIAMoCSI8ChpQb2xpY3lDb25maWdVcGRhdGVSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJIhoKGEVncmVzc0Fzc2lnbm1lbnRzUmVxdWVzdCJMChlFZ3Jlc3NBc3NpZ25tZW50c1Jlc3BvbnNlEi8KBmVncmVzcxgBIAMoCzIfLmFnZW50bWVzaC52MS5FZ3Jlc3NEZXN0aW5hdGlvbiJmCgxLZXlzUmVzcG9uc2USEwoLcHVibGljX2tleXMYASADKAwSLQoJc2lnbl90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpzaWduYXR1cmVzGAMgAygMImsKE1Rva2VuUmVmcmVzaFJlcXVlc3QSGwoTY2hhbGxlbmdlX3NpZ25hdHVyZRgBIAEoDBIZChFjaGFsbGVuZ2VfdW5peF9tcxgCIAEoAxIPCgdwZWVyX2lkGAMgASgJEgsKA2p3dBgEIAEoCSJ1ChRUb2tlblJlZnJlc2hSZXNwb25zZRIVCg1iaXNjdWl0X3Rva2VuGAEgASgMEi8KC2V4cGlyZV90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIVCg1lcnJvcl9tZXNzYWdlGAMgASgJIkAKEU5vZGVDYXRhbG9nUmVwb3J0EisKCHNlcnZpY2VzGAEgAygLMhkuYWdlbnRtZXNoLnYxLlNlcnZpY2VJbmZvIiUKElRva2VuUmV2b2tlUmVxdWVzdBIPCgdwZWVyX2lkGAEgASgJIjUKE1Rva2VuUmV2b2tlUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCSKWAQobQm9vdHN0cmFwVG9rZW5DcmVhdGVSZXF1ZXN0EgwKBHJvbGUYASABKAkSEAoIb3duZXJfaWQYAiABKAkSEQoJdHRsX2hvdXJzGAMgASgFEhIKCm1heF91c2FnZXMYBCABKAUSEwoLZGVzY3JpcHRpb24YBSABKAkSGwoTYXV0b25vbW91c19yZWNvdmVyeRgGIAEoCCKKAQocQm9vdHN0cmFwVG9rZW5DcmVhdGVSZXNwb25zZRIKCgJpZBgBIAEoCRINCgV0b2tlbhgCIAEoCRIMCgRyb2xlGAMgASgJEhAKCG93bmVyX2lkGAQgASgJEi8KC2V4cGlyZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKrAgoOQm9vdHN0cmFwVG9rZW4SCgoCaWQYASABKAkSDAoEcm9sZRgCIAEoCRIQCghvd25lcl9pZBgDIAEoCRISCgptYXhfdXNhZ2VzGAQgASgFEhQKDHVzYWdlc19jb3VudBgFIAEoBRITCgtkZXNjcmlwdGlvbhgGIAEoCRIvCgtjcmVhdGVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLZXhwaXJlX3RpbWUYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC3Jldm9rZV90aW1lGAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIbChNhdXRvbm9tb3VzX3JlY292ZXJ5GAogASgIIkoKGkJvb3RzdHJhcFRva2VuTGlzdFJlc3BvbnNlEiwKBnRva2VucxgBIAMoCzIcLmFnZW50bWVzaC52MS5Cb290c3RyYXBUb2tlbiLWAgoRRW5yb2xsbWVudFJlcXVlc3QSCgoCaWQYASABKAkSDwoHcGVlcl9pZBgCIAEoCRIQCgh0b2tlbl9pZBgDIAEoCRIuCgZzdGF0dXMYBCABKA4yHi5hZ2VudG1lc2gudjEuRW5yb2xsbWVudFN0YXR1cxI7CgZsYWJlbHMYBSADKAsyKy5hZ2VudG1lc2gudjEuRW5yb2xsbWVudFJlcXVlc3QuTGFiZWxzRW50cnkSLwoLY3JlYXRlX3RpbWUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjAKDHJlc29sdmVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASEwoLcmVzb2x2ZWRfYnkYCCABKAkaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASJSCh1FbnJvbGxtZW50UmVxdWVzdExpc3RSZXNwb25zZRIxCghyZXF1ZXN0cxgBIAMoCzIfLmFnZW50bWVzaC52MS5FbnJvbGxtZW50UmVxdWVzdCJwCgRVc2VyEgoKAmlkGAEgASgJEg4KBmlzc3VlchgCIAEoCRINCgVlbWFpbBgDIAEoCRIMCgRyb2xlGAQgASgJEi8KC2NyZWF0ZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCLjAgoMRW5yb2xsZWROb2RlEg8KB3BlZXJfaWQYASABKAkSDAoEcm9sZRgCIAEoCRIXCg9lbnJvbGxtZW50X3R5cGUYAyABKAkSEwoLY2xhaW1zX2pzb24YBCABKAkSEAoIb3duZXJfaWQYBSABKAkSNgoGbGFiZWxzGAYgAygLMiYuYWdlbnRtZXNoLnYxLkVucm9sbGVkTm9kZS5MYWJlbHNFbnRyeRIvCgtlbnJvbGxfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLZXhwaXJlX3RpbWUYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEg4KBmJhbm5lZBgJIAEoCBIbChNhdXRvbm9tb3VzX3JlY292ZXJ5GAogASgIGi0KC0xhYmVsc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEixAEKC1JvdXRlckxlYXNlEg8KB3BlZXJfaWQYASABKAkSEQoJYWRkcmVzc2VzGAIgAygJEjUKEWxhc3RfcmVuZXdhbF90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIvCgtleHBpcmVfdGltZRgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFwoPY29ubmVjdGVkX3BlZXJzGAUgAygJEhAKCGRodF9zaXplGAYgASgFImwKDE5vZGVTZXJ2aWNlcxIrCghzZXJ2aWNlcxgBIAMoCzIZLmFnZW50bWVzaC52MS5TZXJ2aWNlSW5mbxIvCgtyZXBvcnRfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAi2wMKE0FkbWluU3RhdHVzUmVzcG9uc2USIQoFdXNlcnMYASADKAsyEi5hZ2VudG1lc2gudjEuVXNlchIxCg5hY3RpdmVfcm91dGVycxgCIAMoCzIZLmFnZW50bWVzaC52MS5Sb3V0ZXJMZWFzZRIyCg5lbnJvbGxlZF9ub2RlcxgDIAMoCzIaLmFnZW50bWVzaC52MS5FbnJvbGxlZE5vZGUSPAoTZW5yb2xsbWVudF9yZXF1ZXN0cxgEIAMoCzIfLmFnZW50bWVzaC52MS5FbnJvbGxtZW50UmVxdWVzdBI2ChBib290c3RyYXBfdG9rZW5zGAUgAygLMhwuYWdlbnRtZXNoLnYxLkJvb3RzdHJhcFRva2VuEioKBnBvbGljeRgGIAEoCzIaLmFnZW50bWVzaC52MS5Qb2xpY3lDb25maWcSSAoMbm9kZV9jYXRhbG9nGAcgAygLMjIuYWdlbnRtZXNoLnYxLkFkbWluU3RhdHVzUmVzcG9uc2UuTm9kZUNhdGFsb2dFbnRyeRpOChBOb2RlQ2F0YWxvZ0VudHJ5EgsKA2tleRgBIAEoCRIpCgV2YWx1ZRgCIAEoCzIaLmFnZW50bWVzaC52MS5Ob2RlU2VydmljZXM6AjgBIoECChJVc2VyU3RhdHVzUmVzcG9uc2USIAoEdXNlchgBIAEoCzISLmFnZW50bWVzaC52MS5Vc2VyEjIKDmVucm9sbGVkX25vZGVzGAIgAygLMhouYWdlbnRtZXNoLnYxLkVucm9sbGVkTm9kZRI2ChBib290c3RyYXBfdG9rZW5zGAMgAygLMhwuYWdlbnRtZXNoLnYxLkJvb3RzdHJhcFRva2VuEjEKDmFjdGl2ZV9yb3V0ZXJzGAQgAygLMhkuYWdlbnRtZXNoLnYxLlJvdXRlckxlYXNlEioKBnBvbGljeRgFIAEoCzIaLmFnZW50bWVzaC52MS5Qb2xpY3lDb25maWci5AEKGElkZW50aXR5RXZpZGVuY2VSZXNwb25zZRIPCgdwZWVyX2lkGAEgASgJEg8KB2Jpc2N1aXQYAiABKAwSNwoTYmlzY3VpdF9leHBpcmVfdGltZRgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASGQoRY29udHJvbF9wbGFuZV91cmwYBCABKAkSIgoadHJ1c3RlZF9jb250cm9sX3BsYW5lX2tleXMYBSADKAwSLgoKY2hlY2tfdGltZRgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAixgIKFFBlZXJFdmlkZW5jZVJlc3BvbnNlEg8KB3BlZXJfaWQYASABKAkSDwoHYmlzY3VpdBgCIAEoDBIVCg12ZXJpZnlpbmdfa2V5GAMgASgMEg0KBXJvbGVzGAQgAygJEj4KBmxhYmVscxgFIAMoCzIuLmFnZW50bWVzaC52MS5QZWVyRXZpZGVuY2VSZXNwb25zZS5MYWJlbHNFbnRyeRIvCgtleHBpcmVfdGltZRgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFgoOcmV2b2NhdGlvbl9pZHMYByADKAkSLgoKY2hlY2tfdGltZRgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKMAgoQTWVtYmVyQ3JlZGVudGlhbBIZChFjb250cm9sX3BsYW5lX3VybBgBIAEoCRIPCgdiaXNjdWl0GAIgASgMEi8KC2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBI1Cgx0cnVzdGVkX2tleXMYBCADKAsyHy5hZ2VudG1lc2gudjEuVHJ1c3RlZFNpZ25pbmdLZXkSGQoRaXNzdWVkX3VuZGVyX2tleXMYBSADKAwSGAoQcm91dGVyX2FkZHJlc3NlcxgGIAMoCRIvCgxvaWRjX3Nlc3Npb24YByABKAsyGS5hZ2VudG1lc2gudjEuT0lEQ1Nlc3Npb24iWQoRVHJ1c3RlZFNpZ25pbmdLZXkSEgoKcHVibGljX2tleRgBIAEoDBIwCgxyZWNlaXZlX3RpbWUYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIlkKC09JRENTZXNzaW9uEg4KBmlzc3VlchgBIAEoCRIRCgljbGllbnRfaWQYAiABKAkSEAoIYXVkaWVuY2UYAyABKAkSFQoNcmVmcmVzaF90b2tlbhgEIAEoCSKTAQoVVGFza0F1dGhvcml6YXRpb25SdWxlEgwKBG5hbWUYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEiUKBXJ1bGVzGAMgAygLMhYuYWdlbnRtZXNoLnYxLlRhc2tSdWxlEi8KC2V4cGlyZV90aW1lGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKEAQoIVGFza1J1bGUSEwoLZGVzY3JpcHRpb24YASABKAkSGAoQYWxsb3dlZF9zZXJ2aWNlcxgCIAMoCRIuCglvcGVyYXRpb24YAyABKAsyGy5hZ2VudG1lc2gudjEuVGFza09wZXJhdGlvbhIZChFhbGxvd2VkX3Jlc291cmNlcxgEIAMoCSJzCg1UYXNrT3BlcmF0aW9uEhUKDWFsbG93ZWRfdG9vbHMYASADKAkSFwoPYWxsb3dlZF9tZXRob2RzGAIgAygJEhUKDWFsbG93ZWRfcGF0aHMYAyADKAkSGwoTYWxsb3dlZF9wZXJtaXNzaW9ucxgEIAMoCSKrAQoUVG9rZW5FeGNoYW5nZVJlcXVlc3QSFQoNc3ViamVjdF90b2tlbhgBIAEoCRI2Cgl0YXNrX3J1bGUYAiABKAsyIy5hZ2VudG1lc2gudjEuVGFza0F1dGhvcml6YXRpb25SdWxlEgwKBHNlYWwYAyABKAgSGQoRY2hhbGxlbmdlX3VuaXhfbXMYBCABKAMSGwoTY2hhbGxlbmdlX3NpZ25hdHVyZRgFIAEoDCJ/ChVUb2tlbkV4Y2hhbmdlUmVzcG9uc2USFQoNYmlzY3VpdF90b2tlbhgBIAEoDBIvCgtleHBpcmVfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASDQoFcm9sZXMYAyADKAkSDwoHc3ViamVjdBgEIAEoCSKBAQoPU1RTVG9rZW5SZXF1ZXN0Eg8KB2Jpc2N1aXQYASABKAwSEwoLZGVzdGluYXRpb24YAiABKAkSEAoIYXVkaWVuY2UYAyABKAkSGQoRY2hhbGxlbmdlX3VuaXhfbXMYBCABKAMSGwoTY2hhbGxlbmdlX3NpZ25hdHVyZRgFIAEoDCKDAQoQU1RTVG9rZW5SZXNwb25zZRILCgNqd3QYASABKAkSLwoLZXhwaXJlX3RpbWUYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEg8KB3N1YmplY3QYAyABKAkSDQoFcm9sZXMYBCADKAkSEQoJdGFza19uYW1lGAUgASgJIkYKE1Jldm9jYXRpb25zUmVzcG9uc2USFgoOcmV2b2NhdGlvbl9pZHMYASADKAkSFwoPYmFubmVkX3BlZXJfaWRzGAIgAygJKpQBChBFbnJvbGxtZW50U3RhdHVzEiEKHUVOUk9MTE1FTlRfU1RBVFVTX1VOU1BFQ0lGSUVEEAASHQoZRU5ST0xMTUVOVF9TVEFUVVNfUEVORElORxABEh4KGkVOUk9MTE1FTlRfU1RBVFVTX0FQUFJPVkVEEAISHgoaRU5ST0xMTUVOVF9TVEFUVVNfUkVKRUNURUQQAyqMAQoLU2VydmljZVR5cGUSHAoYU0VSVklDRV9UWVBFX1VOU1BFQ0lGSUVEEAASFAoQU0VSVklDRV9UWVBFX01DUBABEhoKFlNFUlZJQ0VfVFlQRV9JTkZFUkVOQ0UQAhIUChBTRVJWSUNFX1RZUEVfQTJBEAMSFwoTU0VSVklDRV9UWVBFX0VHUkVTUxAEKjcKCkVncmVzc01vZGUSFAoQRUdSRVNTX01PREVfSFRUUBAAEhMKD0VHUkVTU19NT0RFX1RDUBABKlwKElJlc3BvbnNlSW5zcGVjdGlvbhIgChxSRVNQT05TRV9JTlNQRUNUSU9OX0JVRkZFUkVEEAASJAogUkVTUE9OU0VfSU5TUEVDVElPTl9SRVFVRVNUX09OTFkQAUIhWh9naXRodWIuY29tL2dvb2dsZS9hZ2VudG1lc2gvYXBpYgZwcm90bzM", [file_google_protobuf_duration, file_google_protobuf_timestamp]); + fileDesc("Cg9hZ2VudG1lc2gucHJvdG8SDGFnZW50bWVzaC52MSI0CglBdXRoRnJhbWUSDwoHYmlzY3VpdBgBIAEoDBIWCg50YXJnZXRfc2VydmljZRgCIAEoCSI/CgxBdXRoUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCRIPCgdiaXNjdWl0GAMgASgMIrEBCgxSb3V0ZXJHb0F3YXkSMQoGcmVhc29uGAEgASgOMiEuYWdlbnRtZXNoLnYxLlJvdXRlckdvQXdheS5SZWFzb24SLgoLcmV0cnlfYWZ0ZXIYAiABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb24iPgoGUmVhc29uEhYKElJFQVNPTl9VTlNQRUNJRklFRBAAEgwKCERSQUlOSU5HEAESDgoKT1ZFUkxPQURFRBACItwBCglNZXNoRXZlbnQSKgoEdHlwZRgBIAEoDjIcLmFnZW50bWVzaC52MS5NZXNoRXZlbnQuVHlwZRIPCgdwZWVyX2lkGAIgASgJEi4KCmV2ZW50X3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDm5ld19wdWJsaWNfa2V5GAQgASgMEhEKCXNpZ25hdHVyZRgFIAEoDCI3CgRUeXBlEgoKBkJBTk5FRBAAEhAKDEtFWV9ST1RBVElPThABEhEKDVBPTElDWV9VUERBVEUQAiL5AQoNRW5yb2xsUmVxdWVzdBILCgNqd3QYASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjcKBmxhYmVscxgFIAMoCzInLmFnZW50bWVzaC52MS5FbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKrAQoORW5yb2xsUmVzcG9uc2USFQoNYmlzY3VpdF90b2tlbhgBIAEoDBIVCg1lcnJvcl9tZXNzYWdlGAIgASgJEiAKGGNvbnRyb2xfcGxhbmVfcHVibGljX2tleRgDIAEoDBIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEi8KC2V4cGlyZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKXAgoWQm9vdHN0cmFwRW5yb2xsUmVxdWVzdBIXCg9ib290c3RyYXBfdG9rZW4YASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEkAKBmxhYmVscxgFIAMoCzIwLmFnZW50bWVzaC52MS5Cb290c3RyYXBFbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKDAgoXQm9vdHN0cmFwRW5yb2xsUmVzcG9uc2USLgoGc3RhdHVzGAEgASgOMh4uYWdlbnRtZXNoLnYxLkVucm9sbG1lbnRTdGF0dXMSFQoNYmlzY3VpdF90b2tlbhgCIAEoDBIdChVwb2xsX2ludGVydmFsX3NlY29uZHMYAyABKAUSFQoNZXJyb3JfbWVzc2FnZRgEIAEoCRIgChhjb250cm9sX3BsYW5lX3B1YmxpY19rZXkYBSABKAwSGAoQcm91dGVyX2FkZHJlc3NlcxgGIAMoCRIvCgtleHBpcmVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiWQoLU2VydmljZUluZm8SJwoEdHlwZRgBIAEoDjIZLmFnZW50bWVzaC52MS5TZXJ2aWNlVHlwZRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJIoEBCg5Db21tYW5kQmFja2VuZBIPCgdjb21tYW5kGAEgAygJEjIKA2VudhgCIAMoCzIlLmFnZW50bWVzaC52MS5Db21tYW5kQmFja2VuZC5FbnZFbnRyeRoqCghFbnZFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIpYBChZSZWdpc3RlclNlcnZpY2VSZXF1ZXN0EioKB3NlcnZpY2UYASABKAsyGS5hZ2VudG1lc2gudjEuU2VydmljZUluZm8SFAoKdGFyZ2V0X3VybBgCIAEoCUgAEi8KB2NvbW1hbmQYAyABKAsyHC5hZ2VudG1lc2gudjEuQ29tbWFuZEJhY2tlbmRIAEIJCgdiYWNrZW5kImkKEkRpc2NvdmVyZWRQcm92aWRlchIPCgdwZWVyX2lkGAEgASgJEhcKD2xvY2FsX3Byb3h5X3VybBgCIAEoCRIQCghzcnZfbmFtZRgDIAEoCRIXCg9zcnZfZGVzY3JpcHRpb24YBCABKAki6AEKGENvbnRyb2xQbGFuZUluZm9SZXNwb25zZRITCgtvaWRjX2lzc3VlchgBIAEoCRIRCgljbGllbnRfaWQYAiABKAkSEAoIYXVkaWVuY2UYAyABKAkSGAoQcm91dGVyX2FkZHJlc3NlcxgEIAMoCRIXCg9iYW5uZWRfcGVlcl9pZHMYBSADKAkSKQoHcm91dGVycxgGIAMoCzIYLmFnZW50bWVzaC52MS5Sb3V0ZXJJbmZvEjQKCWRpcmVjdG9yeRgHIAEoCzIhLmFnZW50bWVzaC52MS5EaXJlY3RvcnlQYXJhbWV0ZXJzIpABChNEaXJlY3RvcnlQYXJhbWV0ZXJzEhMKC3JlcGxpY2F0aW9uGAEgASgFEjUKEnJlcHJvdmlkZV9pbnRlcnZhbBgCIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbhItCgpyZWNvcmRfdHRsGAMgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uIsYBChBEaXJlY3RvcnlSZXF1ZXN0EjEKB3Byb3ZpZGUYASABKAsyHi5hZ2VudG1lc2gudjEuRGlyZWN0b3J5UHJvdmlkZUgAEj4KDmZpbmRfcHJvdmlkZXJzGAIgASgLMiQuYWdlbnRtZXNoLnYxLkRpcmVjdG9yeUZpbmRQcm92aWRlcnNIABI0CglmaW5kX3BlZXIYAyABKAsyHy5hZ2VudG1lc2gudjEuRGlyZWN0b3J5RmluZFBlZXJIAEIJCgdyZXF1ZXN0IkAKEERpcmVjdG9yeVByb3ZpZGUSCwoDa2V5GAEgASgMEg0KBWFkZHJzGAIgAygJEhAKCHByb3ZpZGVyGAMgASgJIjQKFkRpcmVjdG9yeUZpbmRQcm92aWRlcnMSCwoDa2V5GAEgASgMEg0KBWxpbWl0GAIgASgNIiQKEURpcmVjdG9yeUZpbmRQZWVyEg8KB3BlZXJfaWQYASABKAkidwoRRGlyZWN0b3J5UmVzcG9uc2USKwoJcHJvdmlkZXJzGAEgAygLMhguYWdlbnRtZXNoLnYxLlBlZXJSZWNvcmQSJgoEcGVlchgCIAEoCzIYLmFnZW50bWVzaC52MS5QZWVyUmVjb3JkEg0KBWVycm9yGAMgASgJIiwKClBlZXJSZWNvcmQSDwoHcGVlcl9pZBgBIAEoCRINCgVhZGRycxgCIAMoCSLEAQoKUm91dGVySW5mbxIPCgdwZWVyX2lkGAEgASgJEhEKCWFkZHJlc3NlcxgCIAMoCRI0CgZsYWJlbHMYAyADKAsyJC5hZ2VudG1lc2gudjEuUm91dGVySW5mby5MYWJlbHNFbnRyeRITCgtjb25uZWN0aW9ucxgEIAEoBRIYChBjb25uZWN0aW9uX2xpbWl0GAUgASgFGi0KC0xhYmVsc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEigwIKElJvdXRlckxlYXNlUmVxdWVzdBIPCgdwZWVyX2lkGAEgASgJEhEKCWFkZHJlc3NlcxgCIAMoCRIPCgdiaXNjdWl0GAMgASgMEhcKD2Nvbm5lY3RlZF9wZWVycxgEIAMoCRIQCghkaHRfc2l6ZRgFIAEoBRIZChFjaGFsbGVuZ2VfdW5peF9tcxgGIAEoAxIbChNjaGFsbGVuZ2Vfc2lnbmF0dXJlGAcgASgMEiYKA3R0bBgIIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbhITCgtjb25uZWN0aW9ucxgJIAEoBRIYChBjb25uZWN0aW9uX2xpbWl0GAogASgFImYKE1JvdXRlckxlYXNlUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCRIvCgtleHBpcmVfdGltZRgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAipAEKClBvbGljeVJvbGUSDAoEbmFtZRgBIAEoCRIXCg9hbGxvd2VkX3RhcmdldHMYAiADKAkSGAoQYWxsb3dlZF9zZXJ2aWNlcxgDIAMoCRIWCg5jdXN0b21fZGF0YWxvZxgEIAMoCRIWCg5hbGxvd2VkX2xhYmVscxgFIAMoCRIlCgRodHRwGAYgAygLMhcuYWdlbnRtZXNoLnYxLkhUVFBHcmFudCI8CglIVFRQR3JhbnQSDwoHc2VydmljZRgBIAEoCRIPCgdtZXRob2RzGAIgAygJEg0KBXBhdGhzGAMgAygJIqECChFFZ3Jlc3NEZXN0aW5hdGlvbhIMCgRuYW1lGAEgASgJEhIKCnRhcmdldF91cmwYAiABKAkSEgoKY3JlZGVudGlhbBgDIAEoCRIRCglzZXJ2ZWRfYnkYBCADKAkSLgoGYnJva2VyGAUgASgLMh4uYWdlbnRtZXNoLnYxLkNyZWRlbnRpYWxCcm9rZXISLAoKaW5zcGVjdGlvbhgGIAEoCzIYLmFnZW50bWVzaC52MS5JbnNwZWN0aW9uEiYKBG1vZGUYByABKA4yGC5hZ2VudG1lc2gudjEuRWdyZXNzTW9kZRINCgVwb3J0cxgIIAMoDRIVCg1wcmVzZXJ2ZV9ob3N0GAkgASgIEhcKD2ZvcndhcmRfY29udGV4dBgKIAEoCCI5CgpJbnNwZWN0aW9uEisKCmluc3BlY3RvcnMYASADKAsyFy5hZ2VudG1lc2gudjEuSW5zcGVjdG9yIm8KCUluc3BlY3RvchIvCgttb2RlbF9hcm1vchgBIAEoCzIYLmFnZW50bWVzaC52MS5Nb2RlbEFybW9ySAASKQoIZXh0X3Byb2MYAiABKAsyFS5hZ2VudG1lc2gudjEuRXh0UHJvY0gAQgYKBGtpbmQikQEKCk1vZGVsQXJtb3ISEAoIdGVtcGxhdGUYASABKAkSMgoIcmVzcG9uc2UYAiABKA4yIC5hZ2VudG1lc2gudjEuUmVzcG9uc2VJbnNwZWN0aW9uEhEKCWZhaWxfb3BlbhgDIAEoCBIqCgd0aW1lb3V0GAQgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uIogCCgdFeHRQcm9jEg4KBnRhcmdldBgBIAEoCRIKCgJjYRgCIAEoCRIaChJjbGllbnRfY2VydGlmaWNhdGUYAyABKAkSPAoPcHJvY2Vzc2luZ19tb2RlGAQgASgLMiMuYWdlbnRtZXNoLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZRIbChNhbGxvd19tb2RlX292ZXJyaWRlGAUgASgIEjIKD21lc3NhZ2VfdGltZW91dBgGIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbhIaChJmYWlsdXJlX21vZGVfYWxsb3cYByABKAgSGgoSbWF4X2J1ZmZlcmVkX2J5dGVzGAggASgNIv8EChVFeHRQcm9jUHJvY2Vzc2luZ01vZGUSSwoTcmVxdWVzdF9oZWFkZXJfbW9kZRgBIAEoDjIuLmFnZW50bWVzaC52MS5FeHRQcm9jUHJvY2Vzc2luZ01vZGUuSGVhZGVyTW9kZRJMChRyZXNwb25zZV9oZWFkZXJfbW9kZRgCIAEoDjIuLmFnZW50bWVzaC52MS5FeHRQcm9jUHJvY2Vzc2luZ01vZGUuSGVhZGVyTW9kZRJHChFyZXF1ZXN0X2JvZHlfbW9kZRgDIAEoDjIsLmFnZW50bWVzaC52MS5FeHRQcm9jUHJvY2Vzc2luZ01vZGUuQm9keU1vZGUSSAoScmVzcG9uc2VfYm9keV9tb2RlGAQgASgOMiwuYWdlbnRtZXNoLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZS5Cb2R5TW9kZRJMChRyZXF1ZXN0X3RyYWlsZXJfbW9kZRgFIAEoDjIuLmFnZW50bWVzaC52MS5FeHRQcm9jUHJvY2Vzc2luZ01vZGUuSGVhZGVyTW9kZRJNChVyZXNwb25zZV90cmFpbGVyX21vZGUYBiABKA4yLi5hZ2VudG1lc2gudjEuRXh0UHJvY1Byb2Nlc3NpbmdNb2RlLkhlYWRlck1vZGUiOQoKSGVhZGVyTW9kZRIXChNIRUFERVJfTU9ERV9ERUZBVUxUEAASCAoEU0VORBABEggKBFNLSVAQAiJgCghCb2R5TW9kZRIICgROT05FEAASDAoIU1RSRUFNRUQQARIMCghCVUZGRVJFRBACEhQKEEJVRkZFUkVEX1BBUlRJQUwQAxIYChRGVUxMX0RVUExFWF9TVFJFQU1FRBAEIuEBChBDcmVkZW50aWFsQnJva2VyEhcKDXN0YXRpY19zZWNyZXQYASABKAlIABI3Cg9vaWRjX2ZlZGVyYXRpb24YAiABKAsyHC5hZ2VudG1lc2gudjEuT0lEQ0ZlZGVyYXRpb25IABI2Cg9hd3NfYXNzdW1lX3JvbGUYAyABKAsyGy5hZ2VudG1lc2gudjEuQVdTQXNzdW1lUm9sZUgAEjsKEXBsYXRmb3JtX2lkZW50aXR5GAQgASgLMh4uYWdlbnRtZXNoLnYxLlBsYXRmb3JtSWRlbnRpdHlIAEIGCgRraW5kIl8KDk9JRENGZWRlcmF0aW9uEhYKDnRva2VuX2VuZHBvaW50GAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEhMKC2ltcGVyc29uYXRlGAMgASgJEg4KBnNjb3BlcxgEIAMoCSI5Cg1BV1NBc3N1bWVSb2xlEhAKCHJvbGVfYXJuGAEgASgJEhYKDnNlc3Npb25fcG9saWN5GAIgASgJIiIKEFBsYXRmb3JtSWRlbnRpdHkSDgoGc2NvcGVzGAEgAygJIi4KDVBvbGljeUJpbmRpbmcSDAoEcm9sZRgBIAEoCRIPCgdtZW1iZXJzGAIgAygJIpcBCgxQb2xpY3lDb25maWcSJwoFcm9sZXMYASADKAsyGC5hZ2VudG1lc2gudjEuUG9saWN5Um9sZRItCghiaW5kaW5ncxgCIAMoCzIbLmFnZW50bWVzaC52MS5Qb2xpY3lCaW5kaW5nEi8KBmVncmVzcxgDIAMoCzIfLmFnZW50bWVzaC52MS5FZ3Jlc3NEZXN0aW5hdGlvbiIYChZQb2xpY3lDb25maWdHZXRSZXF1ZXN0IjAKF1BvbGljeUNvbmZpZ0dldFJlc3BvbnNlEhUKDWRhdGFsb2dfcnVsZXMYASADKAkiPAoaUG9saWN5Q29uZmlnVXBkYXRlUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCSIaChhFZ3Jlc3NBc3NpZ25tZW50c1JlcXVlc3QiTAoZRWdyZXNzQXNzaWdubWVudHNSZXNwb25zZRIvCgZlZ3Jlc3MYASADKAsyHy5hZ2VudG1lc2gudjEuRWdyZXNzRGVzdGluYXRpb24iZgoMS2V5c1Jlc3BvbnNlEhMKC3B1YmxpY19rZXlzGAEgAygMEi0KCXNpZ25fdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASEgoKc2lnbmF0dXJlcxgDIAMoDCJrChNUb2tlblJlZnJlc2hSZXF1ZXN0EhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYASABKAwSGQoRY2hhbGxlbmdlX3VuaXhfbXMYAiABKAMSDwoHcGVlcl9pZBgDIAEoCRILCgNqd3QYBCABKAkidQoUVG9rZW5SZWZyZXNoUmVzcG9uc2USFQoNYmlzY3VpdF90b2tlbhgBIAEoDBIvCgtleHBpcmVfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFQoNZXJyb3JfbWVzc2FnZRgDIAEoCSJAChFOb2RlQ2F0YWxvZ1JlcG9ydBIrCghzZXJ2aWNlcxgBIAMoCzIZLmFnZW50bWVzaC52MS5TZXJ2aWNlSW5mbyIlChJUb2tlblJldm9rZVJlcXVlc3QSDwoHcGVlcl9pZBgBIAEoCSI1ChNUb2tlblJldm9rZVJlc3BvbnNlEg8KB3N1Y2Nlc3MYASABKAgSDQoFZXJyb3IYAiABKAkilgEKG0Jvb3RzdHJhcFRva2VuQ3JlYXRlUmVxdWVzdBIMCgRyb2xlGAEgASgJEhAKCG93bmVyX2lkGAIgASgJEhEKCXR0bF9ob3VycxgDIAEoBRISCgptYXhfdXNhZ2VzGAQgASgFEhMKC2Rlc2NyaXB0aW9uGAUgASgJEhsKE2F1dG9ub21vdXNfcmVjb3ZlcnkYBiABKAgiigEKHEJvb3RzdHJhcFRva2VuQ3JlYXRlUmVzcG9uc2USCgoCaWQYASABKAkSDQoFdG9rZW4YAiABKAkSDAoEcm9sZRgDIAEoCRIQCghvd25lcl9pZBgEIAEoCRIvCgtleHBpcmVfdGltZRgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiqwIKDkJvb3RzdHJhcFRva2VuEgoKAmlkGAEgASgJEgwKBHJvbGUYAiABKAkSEAoIb3duZXJfaWQYAyABKAkSEgoKbWF4X3VzYWdlcxgEIAEoBRIUCgx1c2FnZXNfY291bnQYBSABKAUSEwoLZGVzY3JpcHRpb24YBiABKAkSLwoLY3JlYXRlX3RpbWUYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC2V4cGlyZV90aW1lGAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIvCgtyZXZva2VfdGltZRgJIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASGwoTYXV0b25vbW91c19yZWNvdmVyeRgKIAEoCCJKChpCb290c3RyYXBUb2tlbkxpc3RSZXNwb25zZRIsCgZ0b2tlbnMYASADKAsyHC5hZ2VudG1lc2gudjEuQm9vdHN0cmFwVG9rZW4i1gIKEUVucm9sbG1lbnRSZXF1ZXN0EgoKAmlkGAEgASgJEg8KB3BlZXJfaWQYAiABKAkSEAoIdG9rZW5faWQYAyABKAkSLgoGc3RhdHVzGAQgASgOMh4uYWdlbnRtZXNoLnYxLkVucm9sbG1lbnRTdGF0dXMSOwoGbGFiZWxzGAUgAygLMisuYWdlbnRtZXNoLnYxLkVucm9sbG1lbnRSZXF1ZXN0LkxhYmVsc0VudHJ5Ei8KC2NyZWF0ZV90aW1lGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIwCgxyZXNvbHZlX3RpbWUYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhMKC3Jlc29sdmVkX2J5GAggASgJGi0KC0xhYmVsc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiUgodRW5yb2xsbWVudFJlcXVlc3RMaXN0UmVzcG9uc2USMQoIcmVxdWVzdHMYASADKAsyHy5hZ2VudG1lc2gudjEuRW5yb2xsbWVudFJlcXVlc3QicAoEVXNlchIKCgJpZBgBIAEoCRIOCgZpc3N1ZXIYAiABKAkSDQoFZW1haWwYAyABKAkSDAoEcm9sZRgEIAEoCRIvCgtjcmVhdGVfdGltZRgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAi4wIKDEVucm9sbGVkTm9kZRIPCgdwZWVyX2lkGAEgASgJEgwKBHJvbGUYAiABKAkSFwoPZW5yb2xsbWVudF90eXBlGAMgASgJEhMKC2NsYWltc19qc29uGAQgASgJEhAKCG93bmVyX2lkGAUgASgJEjYKBmxhYmVscxgGIAMoCzImLmFnZW50bWVzaC52MS5FbnJvbGxlZE5vZGUuTGFiZWxzRW50cnkSLwoLZW5yb2xsX3RpbWUYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC2V4cGlyZV90aW1lGAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIOCgZiYW5uZWQYCSABKAgSGwoTYXV0b25vbW91c19yZWNvdmVyeRgKIAEoCBotCgtMYWJlbHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIsQBCgtSb3V0ZXJMZWFzZRIPCgdwZWVyX2lkGAEgASgJEhEKCWFkZHJlc3NlcxgCIAMoCRI1ChFsYXN0X3JlbmV3YWxfdGltZRgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLZXhwaXJlX3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhcKD2Nvbm5lY3RlZF9wZWVycxgFIAMoCRIQCghkaHRfc2l6ZRgGIAEoBSJsCgxOb2RlU2VydmljZXMSKwoIc2VydmljZXMYASADKAsyGS5hZ2VudG1lc2gudjEuU2VydmljZUluZm8SLwoLcmVwb3J0X3RpbWUYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wItsDChNBZG1pblN0YXR1c1Jlc3BvbnNlEiEKBXVzZXJzGAEgAygLMhIuYWdlbnRtZXNoLnYxLlVzZXISMQoOYWN0aXZlX3JvdXRlcnMYAiADKAsyGS5hZ2VudG1lc2gudjEuUm91dGVyTGVhc2USMgoOZW5yb2xsZWRfbm9kZXMYAyADKAsyGi5hZ2VudG1lc2gudjEuRW5yb2xsZWROb2RlEjwKE2Vucm9sbG1lbnRfcmVxdWVzdHMYBCADKAsyHy5hZ2VudG1lc2gudjEuRW5yb2xsbWVudFJlcXVlc3QSNgoQYm9vdHN0cmFwX3Rva2VucxgFIAMoCzIcLmFnZW50bWVzaC52MS5Cb290c3RyYXBUb2tlbhIqCgZwb2xpY3kYBiABKAsyGi5hZ2VudG1lc2gudjEuUG9saWN5Q29uZmlnEkgKDG5vZGVfY2F0YWxvZxgHIAMoCzIyLmFnZW50bWVzaC52MS5BZG1pblN0YXR1c1Jlc3BvbnNlLk5vZGVDYXRhbG9nRW50cnkaTgoQTm9kZUNhdGFsb2dFbnRyeRILCgNrZXkYASABKAkSKQoFdmFsdWUYAiABKAsyGi5hZ2VudG1lc2gudjEuTm9kZVNlcnZpY2VzOgI4ASKBAgoSVXNlclN0YXR1c1Jlc3BvbnNlEiAKBHVzZXIYASABKAsyEi5hZ2VudG1lc2gudjEuVXNlchIyCg5lbnJvbGxlZF9ub2RlcxgCIAMoCzIaLmFnZW50bWVzaC52MS5FbnJvbGxlZE5vZGUSNgoQYm9vdHN0cmFwX3Rva2VucxgDIAMoCzIcLmFnZW50bWVzaC52MS5Cb290c3RyYXBUb2tlbhIxCg5hY3RpdmVfcm91dGVycxgEIAMoCzIZLmFnZW50bWVzaC52MS5Sb3V0ZXJMZWFzZRIqCgZwb2xpY3kYBSABKAsyGi5hZ2VudG1lc2gudjEuUG9saWN5Q29uZmlnIuQBChhJZGVudGl0eUV2aWRlbmNlUmVzcG9uc2USDwoHcGVlcl9pZBgBIAEoCRIPCgdiaXNjdWl0GAIgASgMEjcKE2Jpc2N1aXRfZXhwaXJlX3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhkKEWNvbnRyb2xfcGxhbmVfdXJsGAQgASgJEiIKGnRydXN0ZWRfY29udHJvbF9wbGFuZV9rZXlzGAUgAygMEi4KCmNoZWNrX3RpbWUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIsYCChRQZWVyRXZpZGVuY2VSZXNwb25zZRIPCgdwZWVyX2lkGAEgASgJEg8KB2Jpc2N1aXQYAiABKAwSFQoNdmVyaWZ5aW5nX2tleRgDIAEoDBINCgVyb2xlcxgEIAMoCRI+CgZsYWJlbHMYBSADKAsyLi5hZ2VudG1lc2gudjEuUGVlckV2aWRlbmNlUmVzcG9uc2UuTGFiZWxzRW50cnkSLwoLZXhwaXJlX3RpbWUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDnJldm9jYXRpb25faWRzGAcgAygJEi4KCmNoZWNrX3RpbWUYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wGi0KC0xhYmVsc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEijAIKEE1lbWJlckNyZWRlbnRpYWwSGQoRY29udHJvbF9wbGFuZV91cmwYASABKAkSDwoHYmlzY3VpdBgCIAEoDBIvCgtleHBpcmVfdGltZRgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASNQoMdHJ1c3RlZF9rZXlzGAQgAygLMh8uYWdlbnRtZXNoLnYxLlRydXN0ZWRTaWduaW5nS2V5EhkKEWlzc3VlZF91bmRlcl9rZXlzGAUgAygMEhgKEHJvdXRlcl9hZGRyZXNzZXMYBiADKAkSLwoMb2lkY19zZXNzaW9uGAcgASgLMhkuYWdlbnRtZXNoLnYxLk9JRENTZXNzaW9uIlkKEVRydXN0ZWRTaWduaW5nS2V5EhIKCnB1YmxpY19rZXkYASABKAwSMAoMcmVjZWl2ZV90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJZCgtPSURDU2Vzc2lvbhIOCgZpc3N1ZXIYASABKAkSEQoJY2xpZW50X2lkGAIgASgJEhAKCGF1ZGllbmNlGAMgASgJEhUKDXJlZnJlc2hfdG9rZW4YBCABKAkikwEKFVRhc2tBdXRob3JpemF0aW9uUnVsZRIMCgRuYW1lGAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRIlCgVydWxlcxgDIAMoCzIWLmFnZW50bWVzaC52MS5UYXNrUnVsZRIvCgtleHBpcmVfdGltZRgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAihAEKCFRhc2tSdWxlEhMKC2Rlc2NyaXB0aW9uGAEgASgJEhgKEGFsbG93ZWRfc2VydmljZXMYAiADKAkSLgoJb3BlcmF0aW9uGAMgASgLMhsuYWdlbnRtZXNoLnYxLlRhc2tPcGVyYXRpb24SGQoRYWxsb3dlZF9yZXNvdXJjZXMYBCADKAkicwoNVGFza09wZXJhdGlvbhIVCg1hbGxvd2VkX3Rvb2xzGAEgAygJEhcKD2FsbG93ZWRfbWV0aG9kcxgCIAMoCRIVCg1hbGxvd2VkX3BhdGhzGAMgAygJEhsKE2FsbG93ZWRfcGVybWlzc2lvbnMYBCADKAkiqwEKFFRva2VuRXhjaGFuZ2VSZXF1ZXN0EhUKDXN1YmplY3RfdG9rZW4YASABKAkSNgoJdGFza19ydWxlGAIgASgLMiMuYWdlbnRtZXNoLnYxLlRhc2tBdXRob3JpemF0aW9uUnVsZRIMCgRzZWFsGAMgASgIEhkKEWNoYWxsZW5nZV91bml4X21zGAQgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYBSABKAwifwoVVG9rZW5FeGNoYW5nZVJlc3BvbnNlEhUKDWJpc2N1aXRfdG9rZW4YASABKAwSLwoLZXhwaXJlX3RpbWUYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEg0KBXJvbGVzGAMgAygJEg8KB3N1YmplY3QYBCABKAkigQEKD1NUU1Rva2VuUmVxdWVzdBIPCgdiaXNjdWl0GAEgASgMEhMKC2Rlc3RpbmF0aW9uGAIgASgJEhAKCGF1ZGllbmNlGAMgASgJEhkKEWNoYWxsZW5nZV91bml4X21zGAQgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYBSABKAwigwEKEFNUU1Rva2VuUmVzcG9uc2USCwoDand0GAEgASgJEi8KC2V4cGlyZV90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdzdWJqZWN0GAMgASgJEg0KBXJvbGVzGAQgAygJEhEKCXRhc2tfbmFtZRgFIAEoCSJGChNSZXZvY2F0aW9uc1Jlc3BvbnNlEhYKDnJldm9jYXRpb25faWRzGAEgAygJEhcKD2Jhbm5lZF9wZWVyX2lkcxgCIAMoCSqUAQoQRW5yb2xsbWVudFN0YXR1cxIhCh1FTlJPTExNRU5UX1NUQVRVU19VTlNQRUNJRklFRBAAEh0KGUVOUk9MTE1FTlRfU1RBVFVTX1BFTkRJTkcQARIeChpFTlJPTExNRU5UX1NUQVRVU19BUFBST1ZFRBACEh4KGkVOUk9MTE1FTlRfU1RBVFVTX1JFSkVDVEVEEAMqjAEKC1NlcnZpY2VUeXBlEhwKGFNFUlZJQ0VfVFlQRV9VTlNQRUNJRklFRBAAEhQKEFNFUlZJQ0VfVFlQRV9NQ1AQARIaChZTRVJWSUNFX1RZUEVfSU5GRVJFTkNFEAISFAoQU0VSVklDRV9UWVBFX0EyQRADEhcKE1NFUlZJQ0VfVFlQRV9FR1JFU1MQBCo3CgpFZ3Jlc3NNb2RlEhQKEEVHUkVTU19NT0RFX0hUVFAQABITCg9FR1JFU1NfTU9ERV9UQ1AQASpcChJSZXNwb25zZUluc3BlY3Rpb24SIAocUkVTUE9OU0VfSU5TUEVDVElPTl9CVUZGRVJFRBAAEiQKIFJFU1BPTlNFX0lOU1BFQ1RJT05fUkVRVUVTVF9PTkxZEAFCIVofZ2l0aHViLmNvbS9nb29nbGUvYWdlbnRtZXNoL2FwaWIGcHJvdG8z", [file_google_protobuf_duration, file_google_protobuf_timestamp]); /** * @generated from message agentmesh.v1.AuthFrame @@ -542,71 +542,6 @@ export type DiscoveredProvider = Message<"agentmesh.v1.DiscoveredProvider"> & { export const DiscoveredProviderSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agentmesh, 11); -/** - * ServiceAnnounce is gossiped by a node on per-key topics (see - * DiscoveryTopic) while those topics have subscribers. It is a routing - * hint signed at the pubsub layer by the announcing peer: consumers use - * it for freshness and load awareness, never for authorization. - * - * @generated from message agentmesh.v1.ServiceAnnounce - */ -export type ServiceAnnounce = Message<"agentmesh.v1.ServiceAnnounce"> & { - /** - * @generated from field: string peer_id = 1; - */ - peerId: string; - - /** - * @generated from field: agentmesh.v1.ServiceType type = 2; - */ - type: ServiceType; - - /** - * @generated from field: string service_name = 3; - */ - serviceName: string; - - /** - * Routing keys served by this service: model IDs for inference, - * tool names for MCP. - * - * @generated from field: repeated string keys = 4; - */ - keys: string[]; - - /** - * Operator-declared labels (e.g. "region"). Operator claims always - * take precedence over runtime-derived values. - * - * @generated from field: map labels = 5; - */ - labels: { [key: string]: string }; - - /** - * Runtime load hints; zero values mean unknown. - * - * @generated from field: uint32 active_requests = 6; - */ - activeRequests: number; - - /** - * @generated from field: double latency_ewma_ms = 7; - */ - latencyEwmaMs: number; - - /** - * @generated from field: google.protobuf.Timestamp announce_time = 8; - */ - announceTime?: Timestamp | undefined; -}; - -/** - * Describes the message agentmesh.v1.ServiceAnnounce. - * Use `create(ServiceAnnounceSchema)` to create a new message. - */ -export const ServiceAnnounceSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 12); - /** * @generated from message agentmesh.v1.ControlPlaneInfoResponse */ @@ -673,7 +608,7 @@ export type ControlPlaneInfoResponse = Message<"agentmesh.v1.ControlPlaneInfoRes * Use `create(ControlPlaneInfoResponseSchema)` to create a new message. */ export const ControlPlaneInfoResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 13); + messageDesc(file_agentmesh, 12); /** * DirectoryParameters are the settings of the DHT the routers serve, set @@ -684,9 +619,9 @@ export const ControlPlaneInfoResponseSchema: GenMessage & { /** - * How many routers hold each record a member publishes and answer each - * lookup it makes: the routers closest to the key. A member holds a - * session with each of them besides the routers it is attached to. + * How many routers each record a member announces is placed on, the + * closest to its key, besides the router that took the announcement. + * Routers read it; a member asks the routers it holds. * * @generated from field: int32 replication = 1; */ @@ -715,8 +650,181 @@ export type DirectoryParameters = Message<"agentmesh.v1.DirectoryParameters"> & * Use `create(DirectoryParametersSchema)` to create a new message. */ export const DirectoryParametersSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_agentmesh, 13); + +/** + * DirectoryRequest is what a member asks a router it is attached to over + * /mesh/directory/1.0.0: one request per stream, answered with one + * DirectoryResponse. The member keeps no DHT of its own; the router runs + * the lookup across the routers' DHT and answers from it. A router sends + * the same message to another router to place a member's record on it. + * + * @generated from message agentmesh.v1.DirectoryRequest + */ +export type DirectoryRequest = Message<"agentmesh.v1.DirectoryRequest"> & { + /** + * @generated from oneof agentmesh.v1.DirectoryRequest.request + */ + request: { + /** + * @generated from field: agentmesh.v1.DirectoryProvide provide = 1; + */ + value: DirectoryProvide; + case: "provide"; + } | { + /** + * @generated from field: agentmesh.v1.DirectoryFindProviders find_providers = 2; + */ + value: DirectoryFindProviders; + case: "findProviders"; + } | { + /** + * @generated from field: agentmesh.v1.DirectoryFindPeer find_peer = 3; + */ + value: DirectoryFindPeer; + case: "findPeer"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message agentmesh.v1.DirectoryRequest. + * Use `create(DirectoryRequestSchema)` to create a new message. + */ +export const DirectoryRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agentmesh, 14); +/** + * DirectoryProvide announces the sender as a provider of key, reached at + * addrs. A key is a sha256 multihash: of "mesh:service:[:]" for + * a service, of "mesh:peer:" for the member itself. provider is set + * only between routers, by the router that holds the member, and refused + * from anyone else: a member can only announce itself. + * + * @generated from message agentmesh.v1.DirectoryProvide + */ +export type DirectoryProvide = Message<"agentmesh.v1.DirectoryProvide"> & { + /** + * @generated from field: bytes key = 1; + */ + key: Uint8Array; + + /** + * @generated from field: repeated string addrs = 2; + */ + addrs: string[]; + + /** + * @generated from field: string provider = 3; + */ + provider: string; +}; + +/** + * Describes the message agentmesh.v1.DirectoryProvide. + * Use `create(DirectoryProvideSchema)` to create a new message. + */ +export const DirectoryProvideSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_agentmesh, 15); + +/** + * DirectoryFindProviders asks for up to limit providers of key. + * + * @generated from message agentmesh.v1.DirectoryFindProviders + */ +export type DirectoryFindProviders = Message<"agentmesh.v1.DirectoryFindProviders"> & { + /** + * @generated from field: bytes key = 1; + */ + key: Uint8Array; + + /** + * @generated from field: uint32 limit = 2; + */ + limit: number; +}; + +/** + * Describes the message agentmesh.v1.DirectoryFindProviders. + * Use `create(DirectoryFindProvidersSchema)` to create a new message. + */ +export const DirectoryFindProvidersSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_agentmesh, 16); + +/** + * DirectoryFindPeer asks where peer_id is reached. + * + * @generated from message agentmesh.v1.DirectoryFindPeer + */ +export type DirectoryFindPeer = Message<"agentmesh.v1.DirectoryFindPeer"> & { + /** + * @generated from field: string peer_id = 1; + */ + peerId: string; +}; + +/** + * Describes the message agentmesh.v1.DirectoryFindPeer. + * Use `create(DirectoryFindPeerSchema)` to create a new message. + */ +export const DirectoryFindPeerSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_agentmesh, 17); + +/** + * DirectoryResponse answers one DirectoryRequest. providers answers + * find_providers; peer answers find_peer, unset when nobody knows the + * peer; error is set when the router could not run the request. + * + * @generated from message agentmesh.v1.DirectoryResponse + */ +export type DirectoryResponse = Message<"agentmesh.v1.DirectoryResponse"> & { + /** + * @generated from field: repeated agentmesh.v1.PeerRecord providers = 1; + */ + providers: PeerRecord[]; + + /** + * @generated from field: agentmesh.v1.PeerRecord peer = 2; + */ + peer?: PeerRecord | undefined; + + /** + * @generated from field: string error = 3; + */ + error: string; +}; + +/** + * Describes the message agentmesh.v1.DirectoryResponse. + * Use `create(DirectoryResponseSchema)` to create a new message. + */ +export const DirectoryResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_agentmesh, 18); + +/** + * PeerRecord is a peer and the addresses it is reached at, relayed ones + * included, each ending in its peer ID. + * + * @generated from message agentmesh.v1.PeerRecord + */ +export type PeerRecord = Message<"agentmesh.v1.PeerRecord"> & { + /** + * @generated from field: string peer_id = 1; + */ + peerId: string; + + /** + * @generated from field: repeated string addrs = 2; + */ + addrs: string[]; +}; + +/** + * Describes the message agentmesh.v1.PeerRecord. + * Use `create(PeerRecordSchema)` to create a new message. + */ +export const PeerRecordSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_agentmesh, 19); + /** * RouterInfo is one router as the control plane lists it on /info. * @@ -768,7 +876,7 @@ export type RouterInfo = Message<"agentmesh.v1.RouterInfo"> & { * Use `create(RouterInfoSchema)` to create a new message. */ export const RouterInfoSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 15); + messageDesc(file_agentmesh, 20); /** * @generated from message agentmesh.v1.RouterLeaseRequest @@ -845,7 +953,7 @@ export type RouterLeaseRequest = Message<"agentmesh.v1.RouterLeaseRequest"> & { * Use `create(RouterLeaseRequestSchema)` to create a new message. */ export const RouterLeaseRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 16); + messageDesc(file_agentmesh, 21); /** * @generated from message agentmesh.v1.RouterLeaseResponse @@ -872,7 +980,7 @@ export type RouterLeaseResponse = Message<"agentmesh.v1.RouterLeaseResponse"> & * Use `create(RouterLeaseResponseSchema)` to create a new message. */ export const RouterLeaseResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 17); + messageDesc(file_agentmesh, 22); /** * @generated from message agentmesh.v1.PolicyRole @@ -920,7 +1028,7 @@ export type PolicyRole = Message<"agentmesh.v1.PolicyRole"> & { * Use `create(PolicyRoleSchema)` to create a new message. */ export const PolicyRoleSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 18); + messageDesc(file_agentmesh, 23); /** * HTTPGrant narrows one allowed_services entry to HTTP methods and paths. @@ -962,7 +1070,7 @@ export type HTTPGrant = Message<"agentmesh.v1.HTTPGrant"> & { * Use `create(HTTPGrantSchema)` to create a new message. */ export const HTTPGrantSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 19); + messageDesc(file_agentmesh, 24); /** * EgressDestination is a destination outside the mesh that selected nodes @@ -1061,7 +1169,7 @@ export type EgressDestination = Message<"agentmesh.v1.EgressDestination"> & { * Use `create(EgressDestinationSchema)` to create a new message. */ export const EgressDestinationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 20); + messageDesc(file_agentmesh, 25); /** * Inspection lists the inspectors the egress node runs, in order; the first @@ -1082,7 +1190,7 @@ export type Inspection = Message<"agentmesh.v1.Inspection"> & { * Use `create(InspectionSchema)` to create a new message. */ export const InspectionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 21); + messageDesc(file_agentmesh, 26); /** * @generated from message agentmesh.v1.Inspector @@ -1111,7 +1219,7 @@ export type Inspector = Message<"agentmesh.v1.Inspector"> & { * Use `create(InspectorSchema)` to create a new message. */ export const InspectorSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 22); + messageDesc(file_agentmesh, 27); /** * ModelArmor calls sanitizeUserPrompt / sanitizeModelResponse directly over @@ -1156,7 +1264,7 @@ export type ModelArmor = Message<"agentmesh.v1.ModelArmor"> & { * Use `create(ModelArmorSchema)` to create a new message. */ export const ModelArmorSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 23); + messageDesc(file_agentmesh, 28); /** * ExtProc runs an Envoy external processor (envoy.service.ext_proc.v3 @@ -1226,7 +1334,7 @@ export type ExtProc = Message<"agentmesh.v1.ExtProc"> & { * Use `create(ExtProcSchema)` to create a new message. */ export const ExtProcSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 24); + messageDesc(file_agentmesh, 29); /** * @generated from message agentmesh.v1.ExtProcProcessingMode @@ -1268,7 +1376,7 @@ export type ExtProcProcessingMode = Message<"agentmesh.v1.ExtProcProcessingMode" * Use `create(ExtProcProcessingModeSchema)` to create a new message. */ export const ExtProcProcessingModeSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 25); + messageDesc(file_agentmesh, 30); /** * @generated from enum agentmesh.v1.ExtProcProcessingMode.HeaderMode @@ -1294,7 +1402,7 @@ export enum ExtProcProcessingMode_HeaderMode { * Describes the enum agentmesh.v1.ExtProcProcessingMode.HeaderMode. */ export const ExtProcProcessingMode_HeaderModeSchema: GenEnum = /*@__PURE__*/ - enumDesc(file_agentmesh, 25, 0); + enumDesc(file_agentmesh, 30, 0); /** * @generated from enum agentmesh.v1.ExtProcProcessingMode.BodyMode @@ -1330,7 +1438,7 @@ export enum ExtProcProcessingMode_BodyMode { * Describes the enum agentmesh.v1.ExtProcProcessingMode.BodyMode. */ export const ExtProcProcessingMode_BodyModeSchema: GenEnum = /*@__PURE__*/ - enumDesc(file_agentmesh, 25, 1); + enumDesc(file_agentmesh, 30, 1); /** * @generated from message agentmesh.v1.CredentialBroker @@ -1376,7 +1484,7 @@ export type CredentialBroker = Message<"agentmesh.v1.CredentialBroker"> & { * Use `create(CredentialBrokerSchema)` to create a new message. */ export const CredentialBrokerSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 26); + messageDesc(file_agentmesh, 31); /** * OIDCFederation exchanges the control plane's JWT at a provider STS. @@ -1421,7 +1529,7 @@ export type OIDCFederation = Message<"agentmesh.v1.OIDCFederation"> & { * Use `create(OIDCFederationSchema)` to create a new message. */ export const OIDCFederationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 27); + messageDesc(file_agentmesh, 32); /** * @generated from message agentmesh.v1.AWSAssumeRole @@ -1445,7 +1553,7 @@ export type AWSAssumeRole = Message<"agentmesh.v1.AWSAssumeRole"> & { * Use `create(AWSAssumeRoleSchema)` to create a new message. */ export const AWSAssumeRoleSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 28); + messageDesc(file_agentmesh, 33); /** * @generated from message agentmesh.v1.PlatformIdentity @@ -1462,7 +1570,7 @@ export type PlatformIdentity = Message<"agentmesh.v1.PlatformIdentity"> & { * Use `create(PlatformIdentitySchema)` to create a new message. */ export const PlatformIdentitySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 29); + messageDesc(file_agentmesh, 34); /** * @generated from message agentmesh.v1.PolicyBinding @@ -1484,7 +1592,7 @@ export type PolicyBinding = Message<"agentmesh.v1.PolicyBinding"> & { * Use `create(PolicyBindingSchema)` to create a new message. */ export const PolicyBindingSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 30); + messageDesc(file_agentmesh, 35); /** * PolicyConfig is the mesh policy as the operator writes it: roles and @@ -1516,7 +1624,7 @@ export type PolicyConfig = Message<"agentmesh.v1.PolicyConfig"> & { * Use `create(PolicyConfigSchema)` to create a new message. */ export const PolicyConfigSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 31); + messageDesc(file_agentmesh, 36); /** * @generated from message agentmesh.v1.PolicyConfigGetRequest @@ -1529,7 +1637,7 @@ export type PolicyConfigGetRequest = Message<"agentmesh.v1.PolicyConfigGetReques * Use `create(PolicyConfigGetRequestSchema)` to create a new message. */ export const PolicyConfigGetRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 32); + messageDesc(file_agentmesh, 37); /** * PolicyConfigGetResponse answers GET /policies for a mesh member holding a @@ -1552,7 +1660,7 @@ export type PolicyConfigGetResponse = Message<"agentmesh.v1.PolicyConfigGetRespo * Use `create(PolicyConfigGetResponseSchema)` to create a new message. */ export const PolicyConfigGetResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 33); + messageDesc(file_agentmesh, 38); /** * @generated from message agentmesh.v1.PolicyConfigUpdateResponse @@ -1574,7 +1682,7 @@ export type PolicyConfigUpdateResponse = Message<"agentmesh.v1.PolicyConfigUpdat * Use `create(PolicyConfigUpdateResponseSchema)` to create a new message. */ export const PolicyConfigUpdateResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 34); + messageDesc(file_agentmesh, 39); /** * @generated from message agentmesh.v1.EgressAssignmentsRequest @@ -1587,7 +1695,7 @@ export type EgressAssignmentsRequest = Message<"agentmesh.v1.EgressAssignmentsRe * Use `create(EgressAssignmentsRequestSchema)` to create a new message. */ export const EgressAssignmentsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 35); + messageDesc(file_agentmesh, 40); /** * EgressAssignmentsResponse answers GET /egress for a mesh member holding a @@ -1609,7 +1717,7 @@ export type EgressAssignmentsResponse = Message<"agentmesh.v1.EgressAssignmentsR * Use `create(EgressAssignmentsResponseSchema)` to create a new message. */ export const EgressAssignmentsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 36); + messageDesc(file_agentmesh, 41); /** * @generated from message agentmesh.v1.KeysResponse @@ -1644,7 +1752,7 @@ export type KeysResponse = Message<"agentmesh.v1.KeysResponse"> & { * Use `create(KeysResponseSchema)` to create a new message. */ export const KeysResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 37); + messageDesc(file_agentmesh, 42); /** * @generated from message agentmesh.v1.TokenRefreshRequest @@ -1697,7 +1805,7 @@ export type TokenRefreshRequest = Message<"agentmesh.v1.TokenRefreshRequest"> & * Use `create(TokenRefreshRequestSchema)` to create a new message. */ export const TokenRefreshRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 38); + messageDesc(file_agentmesh, 43); /** * @generated from message agentmesh.v1.TokenRefreshResponse @@ -1724,7 +1832,7 @@ export type TokenRefreshResponse = Message<"agentmesh.v1.TokenRefreshResponse"> * Use `create(TokenRefreshResponseSchema)` to create a new message. */ export const TokenRefreshResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 39); + messageDesc(file_agentmesh, 44); /** * NodeCatalogReport is the body of POST /nodes/catalog: a node's @@ -1746,7 +1854,7 @@ export type NodeCatalogReport = Message<"agentmesh.v1.NodeCatalogReport"> & { * Use `create(NodeCatalogReportSchema)` to create a new message. */ export const NodeCatalogReportSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 40); + messageDesc(file_agentmesh, 45); /** * @generated from message agentmesh.v1.TokenRevokeRequest @@ -1763,7 +1871,7 @@ export type TokenRevokeRequest = Message<"agentmesh.v1.TokenRevokeRequest"> & { * Use `create(TokenRevokeRequestSchema)` to create a new message. */ export const TokenRevokeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 41); + messageDesc(file_agentmesh, 46); /** * @generated from message agentmesh.v1.TokenRevokeResponse @@ -1785,7 +1893,7 @@ export type TokenRevokeResponse = Message<"agentmesh.v1.TokenRevokeResponse"> & * Use `create(TokenRevokeResponseSchema)` to create a new message. */ export const TokenRevokeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 42); + messageDesc(file_agentmesh, 47); /** * BootstrapTokenCreateRequest is the body of POST /admin/bootstrap-tokens @@ -1848,7 +1956,7 @@ export type BootstrapTokenCreateRequest = Message<"agentmesh.v1.BootstrapTokenCr * Use `create(BootstrapTokenCreateRequestSchema)` to create a new message. */ export const BootstrapTokenCreateRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 43); + messageDesc(file_agentmesh, 48); /** * BootstrapTokenCreateResponse is returned (201) when a token is minted. @@ -1889,7 +1997,7 @@ export type BootstrapTokenCreateResponse = Message<"agentmesh.v1.BootstrapTokenC * Use `create(BootstrapTokenCreateResponseSchema)` to create a new message. */ export const BootstrapTokenCreateResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 44); + messageDesc(file_agentmesh, 49); /** * BootstrapToken is a minted token as operators list it. @@ -1956,7 +2064,7 @@ export type BootstrapToken = Message<"agentmesh.v1.BootstrapToken"> & { * Use `create(BootstrapTokenSchema)` to create a new message. */ export const BootstrapTokenSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 45); + messageDesc(file_agentmesh, 50); /** * @generated from message agentmesh.v1.BootstrapTokenListResponse @@ -1973,7 +2081,7 @@ export type BootstrapTokenListResponse = Message<"agentmesh.v1.BootstrapTokenLis * Use `create(BootstrapTokenListResponseSchema)` to create a new message. */ export const BootstrapTokenListResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 46); + messageDesc(file_agentmesh, 51); /** * EnrollmentRequest is a bootstrap enrollment awaiting or past an operator @@ -2034,7 +2142,7 @@ export type EnrollmentRequest = Message<"agentmesh.v1.EnrollmentRequest"> & { * Use `create(EnrollmentRequestSchema)` to create a new message. */ export const EnrollmentRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 47); + messageDesc(file_agentmesh, 52); /** * @generated from message agentmesh.v1.EnrollmentRequestListResponse @@ -2051,7 +2159,7 @@ export type EnrollmentRequestListResponse = Message<"agentmesh.v1.EnrollmentRequ * Use `create(EnrollmentRequestListResponseSchema)` to create a new message. */ export const EnrollmentRequestListResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 48); + messageDesc(file_agentmesh, 53); /** * User is a human identity known to the mesh. @@ -2094,7 +2202,7 @@ export type User = Message<"agentmesh.v1.User"> & { * Use `create(UserSchema)` to create a new message. */ export const UserSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 49); + messageDesc(file_agentmesh, 54); /** * EnrolledNode is a member's enrollment record without its credential. @@ -2164,7 +2272,7 @@ export type EnrolledNode = Message<"agentmesh.v1.EnrolledNode"> & { * Use `create(EnrolledNodeSchema)` to create a new message. */ export const EnrolledNodeSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 50); + messageDesc(file_agentmesh, 55); /** * RouterLease is a router's current registration with the control plane. @@ -2210,7 +2318,7 @@ export type RouterLease = Message<"agentmesh.v1.RouterLease"> & { * Use `create(RouterLeaseSchema)` to create a new message. */ export const RouterLeaseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 51); + messageDesc(file_agentmesh, 56); /** * NodeServices is the services one node last reported (see @@ -2235,7 +2343,7 @@ export type NodeServices = Message<"agentmesh.v1.NodeServices"> & { * Use `create(NodeServicesSchema)` to create a new message. */ export const NodeServicesSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 52); + messageDesc(file_agentmesh, 57); /** * AdminStatusResponse answers GET /admin/status: everything the console @@ -2287,7 +2395,7 @@ export type AdminStatusResponse = Message<"agentmesh.v1.AdminStatusResponse"> & * Use `create(AdminStatusResponseSchema)` to create a new message. */ export const AdminStatusResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 53); + messageDesc(file_agentmesh, 58); /** * UserStatusResponse answers GET /user/status: the caller and what it owns. @@ -2328,7 +2436,7 @@ export type UserStatusResponse = Message<"agentmesh.v1.UserStatusResponse"> & { * Use `create(UserStatusResponseSchema)` to create a new message. */ export const UserStatusResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 54); + messageDesc(file_agentmesh, 59); /** * @generated from message agentmesh.v1.IdentityEvidenceResponse @@ -2372,7 +2480,7 @@ export type IdentityEvidenceResponse = Message<"agentmesh.v1.IdentityEvidenceRes * Use `create(IdentityEvidenceResponseSchema)` to create a new message. */ export const IdentityEvidenceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 55); + messageDesc(file_agentmesh, 60); /** * @generated from message agentmesh.v1.PeerEvidenceResponse @@ -2428,7 +2536,7 @@ export type PeerEvidenceResponse = Message<"agentmesh.v1.PeerEvidenceResponse"> * Use `create(PeerEvidenceResponseSchema)` to create a new message. */ export const PeerEvidenceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 56); + messageDesc(file_agentmesh, 61); /** * @generated from message agentmesh.v1.MemberCredential @@ -2493,7 +2601,7 @@ export type MemberCredential = Message<"agentmesh.v1.MemberCredential"> & { * Use `create(MemberCredentialSchema)` to create a new message. */ export const MemberCredentialSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 57); + messageDesc(file_agentmesh, 62); /** * @generated from message agentmesh.v1.TrustedSigningKey @@ -2520,7 +2628,7 @@ export type TrustedSigningKey = Message<"agentmesh.v1.TrustedSigningKey"> & { * Use `create(TrustedSigningKeySchema)` to create a new message. */ export const TrustedSigningKeySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 58); + messageDesc(file_agentmesh, 63); /** * @generated from message agentmesh.v1.OIDCSession @@ -2552,7 +2660,7 @@ export type OIDCSession = Message<"agentmesh.v1.OIDCSession"> & { * Use `create(OIDCSessionSchema)` to create a new message. */ export const OIDCSessionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 59); + messageDesc(file_agentmesh, 64); /** * TaskAuthorizationRule narrows a credential's authority for a specific task or @@ -2596,7 +2704,7 @@ export type TaskAuthorizationRule = Message<"agentmesh.v1.TaskAuthorizationRule" * Use `create(TaskAuthorizationRuleSchema)` to create a new message. */ export const TaskAuthorizationRuleSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 60); + messageDesc(file_agentmesh, 65); /** * @generated from message agentmesh.v1.TaskRule @@ -2640,7 +2748,7 @@ export type TaskRule = Message<"agentmesh.v1.TaskRule"> & { * Use `create(TaskRuleSchema)` to create a new message. */ export const TaskRuleSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 61); + messageDesc(file_agentmesh, 66); /** * @generated from message agentmesh.v1.TaskOperation @@ -2682,7 +2790,7 @@ export type TaskOperation = Message<"agentmesh.v1.TaskOperation"> & { * Use `create(TaskOperationSchema)` to create a new message. */ export const TaskOperationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 62); + messageDesc(file_agentmesh, 67); /** * TokenExchangeRequest is the body of POST /token/exchange on the control @@ -2740,7 +2848,7 @@ export type TokenExchangeRequest = Message<"agentmesh.v1.TokenExchangeRequest"> * Use `create(TokenExchangeRequestSchema)` to create a new message. */ export const TokenExchangeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 63); + messageDesc(file_agentmesh, 68); /** * @generated from message agentmesh.v1.TokenExchangeResponse @@ -2772,7 +2880,7 @@ export type TokenExchangeResponse = Message<"agentmesh.v1.TokenExchangeResponse" * Use `create(TokenExchangeResponseSchema)` to create a new message. */ export const TokenExchangeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 64); + messageDesc(file_agentmesh, 69); /** * STSTokenRequest is the body of POST /sts/token on the control plane: an @@ -2826,7 +2934,7 @@ export type STSTokenRequest = Message<"agentmesh.v1.STSTokenRequest"> & { * Use `create(STSTokenRequestSchema)` to create a new message. */ export const STSTokenRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 65); + messageDesc(file_agentmesh, 70); /** * @generated from message agentmesh.v1.STSTokenResponse @@ -2863,7 +2971,7 @@ export type STSTokenResponse = Message<"agentmesh.v1.STSTokenResponse"> & { * Use `create(STSTokenResponseSchema)` to create a new message. */ export const STSTokenResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 66); + messageDesc(file_agentmesh, 71); /** * RevocationsResponse answers GET /revocations on the control plane: the set of @@ -2889,7 +2997,7 @@ export type RevocationsResponse = Message<"agentmesh.v1.RevocationsResponse"> & * Use `create(RevocationsResponseSchema)` to create a new message. */ export const RevocationsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 67); + messageDesc(file_agentmesh, 72); /** * @generated from enum agentmesh.v1.EnrollmentStatus diff --git a/sdk/js/src/host.ts b/sdk/js/src/host.ts index 388874e28..a16a60173 100644 --- a/sdk/js/src/host.ts +++ b/sdk/js/src/host.ts @@ -21,14 +21,12 @@ import { yamux } from "@chainsafe/libp2p-yamux"; import { circuitRelayTransport } from "@libp2p/circuit-relay-v2"; import { privateKeyFromProtobuf } from "@libp2p/crypto/keys"; -import { gossipsub, type GossipSub } from "@libp2p/gossipsub"; +import { gossipsub, type GossipSub, type GossipSubComponents } from "@libp2p/gossipsub"; import { identify, identifyPush } from "@libp2p/identify"; import type { Libp2p, PeerId } from "@libp2p/interface"; -import { kadDHT, passthroughMapper } from "@libp2p/kad-dht"; import { ping } from "@libp2p/ping"; import type { Multiaddr } from "@multiformats/multiaddr"; import { createLibp2p, type Libp2pOptions } from "libp2p"; -import { DEFAULT_DHT_REPLICATION, DHT_PROTOCOL } from "./discovery.ts"; import type { Identity } from "./identity.ts"; import { connectionEncrypters, transports } from "./platform/transports.ts"; @@ -47,14 +45,27 @@ export interface MeshHostOptions { banned?: { has(peerId: string): boolean }; /** The resolver for `/dnsaddr` and `/dns*` addresses; the system's by default. */ dns?: Libp2pOptions["dns"]; - /** - * The mesh's replication factor, from /info (AgentMesh.dhtReplication): - * kademlia's k on this member. A record lives on that many routers, the - * closest to its key, and a lookup asks those, that many at a time. The - * same figure on every member of a mesh, which is why it comes from the - * control plane; DEFAULT_DHT_REPLICATION when it published none. - */ - dhtReplication?: number; +} + +/** The floodsub protocol, the one a member speaks (see leafPubsub). */ +const FLOODSUB_PROTOCOL = "/floodsub/1.0.0"; + +/** + * A member is a leaf of the gossip: it speaks floodsub only, so a router + * delivers every control plane event to it outright and it takes no part + * in the routers' gossipsub mesh, its heartbeats, grafts and peer exchange. + * The gossipsub implementation serves as the floodsub one, told to register + * that protocol alone; the message envelope is the same. + */ +function leafPubsub(): (components: GossipSubComponents) => GossipSub { + // StrictSign, as every Go component pins it: the pubsub envelope is + // signed by the author, and the event inside by the control plane. + const build = gossipsub({ globalSignaturePolicy: "StrictSign", allowPublishToZeroTopicPeers: true, emitSelf: false }); + return (components) => { + const ps = build(components); + ps.protocols = [FLOODSUB_PROTOCOL]; + return ps; + }; } /** The services a mesh host runs; `services.pubsub` carries the control plane's events. */ @@ -63,7 +74,6 @@ export type MeshHost = Libp2p<{ pubsub: GossipSub }>; export async function createMeshHost(identity: Identity, options: MeshHostOptions = {}): Promise { const banned = options.banned ?? { has: () => false }; const denyBanned = (peerId: PeerId) => banned.has(peerId.toString()); - const replication = options.dhtReplication !== undefined && options.dhtReplication >= 1 ? Math.floor(options.dhtReplication) : DEFAULT_DHT_REPLICATION; return createLibp2p({ privateKey: privateKeyFromProtobuf(identity.toLibp2pPrivateKey()), addresses: { listen: options.listenAddrs ?? [] }, @@ -95,14 +105,8 @@ export async function createMeshHost(identity: Identity, options: MeshHostOption // them, where the member is reached now, as agentmesh-node does. identifyPush: identifyPush(), ping: ping(), - // A client of the mesh DHT: it looks providers up and does not hold - // records. Peers keep their private addresses; a mesh member often - // is one, and the router relays to it. - dht: kadDHT({ protocol: DHT_PROTOCOL, clientMode: true, peerInfoMapper: passthroughMapper, initialQuerySelfInterval: 20, kBucketSize: replication, alpha: replication }), // The control plane's events reach members through the routers. - // StrictSign, as every Go component pins it: the pubsub envelope is - // signed by the author, and the event inside by the control plane. - pubsub: gossipsub({ globalSignaturePolicy: "StrictSign", allowPublishToZeroTopicPeers: true, emitSelf: false }), + pubsub: leafPubsub(), }, }); } diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index fe4864790..947fd3baa 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -109,7 +109,7 @@ export { type GoAwayServerOptions, } from "./auth.ts"; export { createMeshHost, type MeshHost, type MeshHostOptions } from "./host.ts"; -export { DHT_PROTOCOL, isServiceType, parseServiceTarget, serviceCID, type ServiceType } from "./discovery.ts"; +export { DIRECTORY_PROTOCOL, DirectoryError, isServiceType, parseServiceTarget, peerKey, serviceKey, type ServiceType } from "./discovery.ts"; export { LabelsNotSatisfiedError, StreamTransport, openMCPSession, requireEgressLabels, requireLabels, type MCPSession, type MCPSessionOptions } from "./mcp.ts"; export { AuthorizationError, authorizeCaller, type AuthorizeRequest, type ProviderAuthorizerOptions } from "./authorizer.ts"; export { diff --git a/sdk/js/src/mcp.test.ts b/sdk/js/src/mcp.test.ts index 80ef7adc9..4dd9afc79 100644 --- a/sdk/js/src/mcp.test.ts +++ b/sdk/js/src/mcp.test.ts @@ -33,7 +33,7 @@ import { z } from "zod"; import { AuthRejectedError, MAX_AUTH_FRAME_BYTES, MCP_PROTOCOL } from "./auth.ts"; import { ROLE_ROUTER, loadBiscuit, verifyPeerBiscuit } from "./biscuit.ts"; import { ROLE_NODE } from "./controlplane.ts"; -import { parseServiceTarget, serviceCID } from "./discovery.ts"; +import { parseServiceTarget, serviceKey } from "./discovery.ts"; import { AuthFrameSchema, AuthResponseSchema } from "./gen/agentmesh_pb.ts"; import { Identity } from "./identity.ts"; import { LabelsNotSatisfiedError, StreamTransport, openMCPSession, requireEgressLabels, requireLabels } from "./mcp.ts"; @@ -119,14 +119,12 @@ function frame(target: string, biscuit = callerBiscuit): Uint8Array { return toBinary(AuthFrameSchema, create(AuthFrameSchema, { biscuit, targetService: target })); } -test("service keys match internal/node/service.go", async () => { +test("service keys match api.ServiceKey", async () => { const fixture = JSON.parse(readFileSync(new URL("../../testdata/service_keys.json", import.meta.url), "utf8")) as { services: Array<{ type: "mcp" | "inference" | "a2a"; name: string; cid: string; multihash: string }>; }; for (const s of fixture.services) { - const cid = await serviceCID(s.type, s.name); - assert.equal(cid.toString(), s.cid, `${s.type}:${s.name}`); - assert.equal(Buffer.from(cid.multihash.bytes).toString("hex"), s.multihash); + assert.equal(Buffer.from(await serviceKey(s.type, s.name)).toString("hex"), s.multihash, `${s.type}:${s.name}`); } assert.deepEqual(parseServiceTarget("mcp://calc"), { type: "mcp", name: "calc" }); assert.deepEqual(parseServiceTarget("egress://api.github.com"), { type: "egress", name: "api.github.com" }); diff --git a/sdk/js/src/mesh.ts b/sdk/js/src/mesh.ts index f1b9bc0e0..606ccc213 100644 --- a/sdk/js/src/mesh.ts +++ b/sdk/js/src/mesh.ts @@ -15,7 +15,6 @@ import type { MessageInitShape } from "@bufbuild/protobuf"; import { toHex } from "./bytes.ts"; import { ControlPlaneClient, KeysNotTrustedError, ROLE_NODE, type Enrollment } from "./controlplane.ts"; -import { DEFAULT_DHT_REPLICATION } from "./discovery.ts"; import { attenuateCredential, credentialFromJSON, @@ -145,7 +144,6 @@ export class AgentMesh { readonly controlPlane: ControlPlaneClient; #credential: MeshCredential; #routers: RouterInfo[] = []; - #dhtReplication = DEFAULT_DHT_REPLICATION; readonly #state: StateStore | undefined; readonly #jwtSource: (() => Promise) | undefined; // Refreshes run one after another, as agentmesh-node's refreshMu: the control @@ -180,15 +178,6 @@ export class AgentMesh { return this.#routers; } - /** - * How many routers hold each record and answer each lookup on this mesh, - * as /info last published it (ControlPlaneInfoResponse.directory); - * DEFAULT_DHT_REPLICATION until the control plane has answered or when it - * publishes none. - */ - get dhtReplication(): number { - return this.#dhtReplication; - } /** Returns a new MeshCredential with a tar_block appended offline in memory. */ attenuate(rule: MessageInitShape): Promise { @@ -419,9 +408,6 @@ export class AgentMesh { const info = await this.controlPlane.info(); routers = info.routers; this.#routers = info.routers; - if (info.directory !== undefined && info.directory.replication >= 1) { - this.#dhtReplication = info.directory.replication; - } const discovered = info.routerAddresses.length > 0 ? info.routerAddresses diff --git a/sdk/js/src/session.test.ts b/sdk/js/src/session.test.ts index 227d93141..543441275 100644 --- a/sdk/js/src/session.test.ts +++ b/sdk/js/src/session.test.ts @@ -28,6 +28,7 @@ import type { Libp2p } from "@libp2p/interface"; import { tcp } from "@libp2p/tcp"; import { tls } from "@libp2p/tls"; import { lpStream } from "@libp2p/utils"; +import { DIRECTORY_PROTOCOL } from "./discovery.ts"; import { multiaddr } from "@multiformats/multiaddr"; import { createLibp2p } from "libp2p"; import assert from "node:assert/strict"; @@ -38,7 +39,7 @@ import { after, before, test } from "node:test"; import { AUTH_HANDLER_OPTIONS, AUTH_PROTOCOL, AuthRejectedError, GOAWAY_MAX_RETRY_AFTER_MS, GOAWAY_PROTOCOL, ROUTER_SHUN_DURATION_MS, authenticateWithPeer, authStreamHandler, clampGoAwayRetryAfterMs } from "./auth.ts"; import { BiscuitVerificationError, ROLE_ROUTER, loadBiscuit, verifyPeerBiscuit } from "./biscuit.ts"; import { ROLE_NODE } from "./controlplane.ts"; -import { BootstrapEnrollRequestSchema, BootstrapEnrollResponseSchema, ControlPlaneInfoResponseSchema, EnrollmentStatus, KeysResponseSchema, AuthFrameSchema, RouterGoAwaySchema, RouterGoAway_Reason, RouterInfoSchema, TokenRefreshRequestSchema, TokenRefreshResponseSchema, type RouterInfo } from "./gen/agentmesh_pb.ts"; +import { BootstrapEnrollRequestSchema, BootstrapEnrollResponseSchema, ControlPlaneInfoResponseSchema, EnrollmentStatus, KeysResponseSchema, AuthFrameSchema, DirectoryRequestSchema, DirectoryResponseSchema, PeerRecordSchema, RouterGoAwaySchema, RouterGoAway_Reason, RouterInfoSchema, TokenRefreshRequestSchema, TokenRefreshResponseSchema, type RouterInfo } from "./gen/agentmesh_pb.ts"; import { Identity } from "./identity.ts"; import { HTTP_HANDLER_OPTIONS, HTTP_PROTOCOL, a2aEndpoint, httpIngressHandler } from "./libp2p-http.ts"; import { LabelsNotSatisfiedError } from "./mcp.ts"; @@ -405,13 +406,11 @@ test("a member on one router reaches a member on another", async () => { } }); -test("a connection to a listed router is authenticated, and released after a lookup", async () => { - // The DHT client connects to whichever listed router kademlia puts closest - // to a key. A router serves its DHT only to a member that authenticated - // on the connection, so the handshake runs on connection:open for any - // listed router, held or not; a router met this way is not held, and the - // connection is closed once the lookup ends. - const routerB = await createLibp2p({ +test("discovery asks a held router over the directory protocol and connects to no other", async () => { + // A member keeps no DHT: a lookup is one request to a router it holds, + // which answers from the routers' directory. A second listed router is + // never connected to for it. + const otherRouter = await createLibp2p({ addresses: { listen: ["/ip4/127.0.0.1/tcp/0"] }, transports: [tcp()], connectionEncrypters: [tls()], @@ -419,31 +418,39 @@ test("a connection to a listed router is authenticated, and released after a loo services: { identify: identify(), relay: circuitRelayServer() }, }); try { - const handshakesB: string[] = []; - await routerB.handle(AUTH_PROTOCOL, authStreamHandler({ ownBiscuit: () => mint(routerB.peerId.toString(), ROLE_ROUTER), trustedKeys: () => [cpKey], onAuthenticated: (p) => handshakesB.push(p) })); - const routerBAddr = (routerB.getMultiaddrs()[0] as ReturnType).toString(); - const mesh = await AgentMesh.enroll({ controlPlaneUrl: "http://127.0.0.1:1", bootstrapToken: "sbt", fetch: fakeControlPlane([routerAddr, routerBAddr]) }); + const otherAddr = (otherRouter.getMultiaddrs()[0] as ReturnType).toString(); + const provider = "12D3KooWA4Xop1JaT3MHxwYMkCepYsv4iPVopMXwCz5iHYdBfeSB"; + const asked: string[] = []; + await router.handle(DIRECTORY_PROTOCOL, (stream) => { + void (async () => { + const lp = lpStream(stream, { maxDataLength: 64 * 1024 }); + const req = fromBinary(DirectoryRequestSchema, (await lp.read()).subarray()); + asked.push(req.request.case ?? ""); + const resp = create(DirectoryResponseSchema, {}); + if (req.request.case === "findProviders") { + resp.providers = [create(PeerRecordSchema, { peerId: provider, addrs: [`${routerAddr}/p2p-circuit/p2p/${provider}`] })]; + } else if (req.request.case === "findPeer" && req.request.value.peerId === provider) { + resp.peer = create(PeerRecordSchema, { peerId: provider, addrs: [`${routerAddr}/p2p-circuit/p2p/${provider}`] }); + } + await lp.write(toBinary(DirectoryResponseSchema, resp)); + await stream.close(); + })(); + }); + const mesh = await AgentMesh.enroll({ controlPlaneUrl: "http://127.0.0.1:1", bootstrapToken: "sbt", fetch: fakeControlPlane([routerAddr, otherAddr]) }); const session = await mesh.join({ refreshLeadMs: 0, routerAddresses: [routerAddr], reserveRelay: false }); try { - assert.deepEqual(session.routers.map((r) => r.peerId), [router.peerId.toString()]); - - await session.node.dial(multiaddr(routerBAddr)); - const deadline = Date.now() + 10_000; - while (handshakesB.length === 0 && Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 20)); - } - assert.deepEqual(handshakesB, [mesh.peerId], "the member authenticated on connecting to a listed router"); - assert.deepEqual(session.routers.map((r) => r.peerId), [router.peerId.toString()], "a router met for a lookup is not held"); - - await session.discover("mcp", undefined, { timeoutMs: 500 }).catch(() => {}); + const found = await session.discover("mcp", undefined, { timeoutMs: 5_000 }); + assert.deepEqual(found, [{ peerId: provider, addrs: [`${routerAddr}/p2p-circuit/p2p/${provider}`] }]); + assert.deepEqual(session.dialTargets(provider).addrs.map(String), [`${routerAddr}/p2p-circuit/p2p/${provider}`], "the provider is reached through the held router"); + assert.deepEqual(asked, ["findProviders"]); const connected = session.node.getConnections().map((c) => c.remotePeer.toString()); - assert.ok(!connected.includes(routerB.peerId.toString()), "the lookup's connection to the router it does not hold is released"); - assert.ok(connected.includes(router.peerId.toString()), "the held router's connection stays"); + assert.ok(!connected.includes(otherRouter.peerId.toString()), "the member connected to a router it does not hold"); } finally { await session.close(); + await router.unhandle(DIRECTORY_PROTOCOL); } } finally { - await routerB.stop(); + await otherRouter.stop(); } }); diff --git a/sdk/js/src/session.ts b/sdk/js/src/session.ts index 63fc58529..3d426f637 100644 --- a/sdk/js/src/session.ts +++ b/sdk/js/src/session.ts @@ -32,8 +32,8 @@ import { ROLE_ROUTER, attenuateBiscuit, requireRole, sealBiscuit, type VerifiedB import { ROLE_NODE } from "./controlplane.ts"; import { encodeAuthFrame } from "./credential.ts"; import { canonicalPeerId } from "./identity.ts"; -import { isServiceType, parseServiceTarget, serviceCID } from "./discovery.ts"; -import type { RouterGoAway, RouterInfo, TaskAuthorizationRuleSchema } from "./gen/agentmesh_pb.ts"; +import { findPeer, findProviders, isServiceType, parseServiceTarget, serviceKey, type DirectoryRouter } from "./discovery.ts"; +import type { PeerRecord, RouterGoAway, RouterInfo, TaskAuthorizationRuleSchema } from "./gen/agentmesh_pb.ts"; import { createMeshHost, listenThroughRelay, type MeshHost, type MeshHostOptions, type RelayListener } from "./host.ts"; import { openMCPSession, requireEgressLabels, type MCPSession, type MCPSessionOptions } from "./mcp.ts"; import type { AgentMesh, ControlPlaneSync } from "./mesh.ts"; @@ -499,16 +499,12 @@ export class MeshSession { * A router forgets the admission with the connection it came on, and * js-libp2p reconnects to a relay on its own, so every new connection to a * router runs the handshake again: the relay's own renewal of the - * reservation then still passes the router's check. A router serves its - * DHT and gossip only to a member that authenticated on the connection, - * so a connection the DHT client opens to any router the control plane - * lists is authenticated the same way; the router holds the streams the - * client opened meanwhile and serves them once the handshake is done. + * reservation then still passes the router's check. */ #keepRouterAdmissions(): void { this.node.addEventListener("connection:open", (evt) => { const conn = evt.detail; - if (this.#closed || !this.#isRouter(conn.remotePeer.toString())) { + if (this.#closed || !this.routers.some((r) => r.peerId === conn.remotePeer.toString())) { return; } void this.#authenticateRouter(conn).catch(() => {}); @@ -535,26 +531,12 @@ export class MeshSession { return run; } - /** Whether peerId is a router this member holds or one the mesh lists for it. */ - #isRouter(peerId: string): boolean { - return ( - this.routers.some((r) => r.peerId === peerId) || - candidatesFromRouterInfos(this.mesh.routers, this.mesh.credential.routerAddresses).some((c) => c.peerId === peerId) - ); - } - - /** - * Closes the connections a lookup opened to routers this member does not - * hold: the member keeps sessions with the routers it holds and with no - * other, and the next lookup connects and authenticates again. - */ - async #releaseLookupRouters(): Promise { - for (const conn of this.node.getConnections()) { - const peerId = conn.remotePeer.toString(); - if (this.#isRouter(peerId) && !this.routers.some((r) => r.peerId === peerId)) { - await this.node.hangUp(conn.remotePeer).catch(() => {}); - } - } + /** The held routers as the directory client asks them, each with its open connection. */ + #directoryRouters(): DirectoryRouter[] { + return this.routers.map((r) => ({ + peerId: r.peerId, + connection: () => this.node.getConnections(peerIdFromString(r.peerId)).find((c) => c.status === "open"), + })); } /** @@ -758,24 +740,25 @@ export class MeshSession { } /** - * The addresses the routers' DHT knows for a peer, its relayed ones - * through routers this member has admitted by then: a relay opens a - * circuit only for a source it authenticated, so a router met this way is - * dialed and passed the handshake first, and joins the admitted set. + * The addresses the directory knows for a peer, its relayed ones through + * routers this member has admitted by then: a relay opens a circuit only + * for a source it authenticated, so a router met this way is dialed and + * passed the handshake first, and joins the admitted set. */ async #routedAddresses(peerId: string, signal?: AbortSignal): Promise { const lookup = signal ?? AbortSignal.timeout(PEER_ROUTING_TIMEOUT_MS); - let found: { multiaddrs: Multiaddr[] }; + let found: PeerRecord | undefined; try { - found = await this.node.peerRouting.findPeer(peerIdFromString(peerId), { signal: lookup }); + found = await findPeer(this.#directoryRouters(), peerId, lookup); } catch { return []; - } finally { - await this.#releaseLookupRouters(); + } + if (found === undefined) { + return []; } const out: Multiaddr[] = []; - for (const ma of found.multiaddrs) { - const text = ma.toString(); + for (const text of found.addrs) { + const ma = multiaddr(text); const circuit = text.indexOf("/p2p-circuit"); if (circuit === -1) { out.push(targetPeerOf(ma) === undefined ? ma.encapsulate(`/p2p/${peerId}`) : ma); @@ -841,53 +824,33 @@ export class MeshSession { } /** - * Looks the DHT up for peers offering a service: `"mcp://calc"`, the + * Asks the directory for peers offering a service: `"mcp://calc"`, the * same string callTool and request take, or a type alone (`"mcp"`) for - * every service of that type, or (type, name). Bounded by the timeout; - * the DHT walk itself is what agentmesh-node's discover does. + * every service of that type, or (type, name). A router this member holds + * answers; bounded by the timeout. */ async discover(service: string, name?: string, options: { timeoutMs?: number; limit?: number } = {}): Promise { const target = service.includes("://") ? parseServiceTarget(service) : { type: service, name }; if (!isServiceType(target.type)) { throw new Error(`service type must be mcp, inference, a2a or egress, got ${JSON.stringify(target.type)}`); } - const cid = await serviceCID(target.type, target.name); - const ac = new AbortController(); - const signal = AbortSignal.any([ac.signal, AbortSignal.timeout(options.timeoutMs ?? DISCOVERY_TIMEOUT_MS)]); - let settleTimer: ReturnType | undefined; + const key = await serviceKey(target.type, target.name); + const signal = AbortSignal.timeout(options.timeoutMs ?? DISCOVERY_TIMEOUT_MS); + const limit = options.limit ?? 20; const found = new Map(); - try { - for await (const provider of this.node.contentRouting.findProviders(cid, { signal })) { - const peerId = provider.id.toString(); - if (peerId === this.peerId) { - continue; - } - const entry = found.get(peerId) ?? { peerId, addrs: [] }; - for (const ma of provider.multiaddrs) { - const text = ma.toString(); - if (!entry.addrs.includes(text)) { - entry.addrs.push(text); - } - } - found.set(peerId, entry); - if (found.size >= (options.limit ?? 20)) { - break; - } - if (settleTimer === undefined) { - settleTimer = setTimeout(() => ac.abort(), 200); - settleTimer.unref?.(); - } + for (const record of await findProviders(this.#directoryRouters(), key, limit, signal)) { + if (record.peerId === this.peerId) { + continue; } - } catch (err) { - // The lookup ended on its deadline; what was found so far is the answer. - if (!(err instanceof Error && err.name === "TimeoutError") && !signal.aborted) { - throw err; + const entry = found.get(record.peerId) ?? { peerId: record.peerId, addrs: [] }; + for (const text of record.addrs) { + if (!entry.addrs.includes(text)) { + entry.addrs.push(text); + } } - } finally { - clearTimeout(settleTimer); - await this.#releaseLookupRouters(); + found.set(record.peerId, entry); } - return [...found.values()]; + return [...found.values()].slice(0, limit); } /** @@ -1409,7 +1372,7 @@ export async function joinMesh(mesh: AgentMesh, options: JoinOptions = {}): Prom } const banned = new BanSet(); - const node = await createMeshHost(mesh.identity, { ...options, banned, dhtReplication: mesh.dhtReplication }); + const node = await createMeshHost(mesh.identity, { ...options, banned }); const authenticatedPeers = new Map(); let session: MeshSession | undefined; try { diff --git a/sdk/js/src/sync.test.ts b/sdk/js/src/sync.test.ts index 63817e060..d9f24081d 100644 --- a/sdk/js/src/sync.test.ts +++ b/sdk/js/src/sync.test.ts @@ -35,7 +35,6 @@ import { AUTH_PROTOCOL, authenticateWithPeer, authStreamHandler } from "./auth.t import { ROLE_ROUTER, loadBiscuit, verifyPeerBiscuit } from "./biscuit.ts"; import { ROLE_NODE } from "./controlplane.ts"; import { credentialPredatesRotation } from "./credential.ts"; -import { DEFAULT_DHT_REPLICATION } from "./discovery.ts"; import { AuthFrameSchema, BootstrapEnrollRequestSchema, @@ -81,8 +80,6 @@ class SigningKey { class FakeControlPlane { keys: SigningKey[]; banned: string[] = []; - /** The replication /info publishes; undefined is a control plane that predates the field. */ - replication: number | undefined; refreshes = 0; constructor(initial: SigningKey) { this.keys = [initial]; @@ -124,7 +121,6 @@ class FakeControlPlane { create(ControlPlaneInfoResponseSchema, { routerAddresses: [routerAddr], bannedPeerIds: this.banned, - ...(this.replication !== undefined ? { directory: { replication: this.replication } } : {}), }), ), ); @@ -224,25 +220,6 @@ test("a mesh event verifies only under a trusted key and only when fresh", () => assert.equal(verifyMeshEvent(bannedNobody, [key.pub], now), undefined, "not a peer id"); }); -test("a pull takes the mesh's replication from /info, and the default when it publishes none", async () => { - const mesh = await AgentMesh.enroll({ controlPlaneUrl: "http://127.0.0.1:1", bootstrapToken: "sbt", fetch: cp.fetch }); - assert.equal(mesh.dhtReplication, DEFAULT_DHT_REPLICATION, "before any pull"); - cp.replication = 5; - try { - await mesh.syncControlPlane(); - assert.equal(mesh.dhtReplication, 5); - // A control plane that stops publishing it, or publishes nothing usable, leaves the last value. - cp.replication = 0; - await mesh.syncControlPlane(); - assert.equal(mesh.dhtReplication, 5); - cp.replication = undefined; - await mesh.syncControlPlane(); - assert.equal(mesh.dhtReplication, 5); - } finally { - cp.replication = undefined; - } -}); - test("a pull learns a key rotation and refreshes the credential under the new key", async () => { const mesh = await AgentMesh.enroll({ controlPlaneUrl: "http://127.0.0.1:1", bootstrapToken: "sbt", fetch: cp.fetch }); const session = await mesh.join({ refreshLeadMs: 0, controlPlaneSyncIntervalMs: 0 }); diff --git a/sdk/python/src/agent_mesh/__init__.py b/sdk/python/src/agent_mesh/__init__.py index 51d711255..6b85a6b91 100644 --- a/sdk/python/src/agent_mesh/__init__.py +++ b/sdk/python/src/agent_mesh/__init__.py @@ -54,7 +54,7 @@ verify_keys_response, ) from .credential import MeshCredential, decode_auth_response, encode_auth_frame -from .discovery import DHT_PROTOCOL, DiscoveredProvider, find_providers, parse_service_target, service_key +from .discovery import DIRECTORY_PROTOCOL, DiscoveredProvider, DirectoryError, find_providers, parse_service_target, peer_key, service_key from .httpx_transport import MESH_PATH_PREFIX, MeshTransport, split_mesh_url from .identity import Identity, canonical_peer_id, libp2p_public_key, peer_id_from_public_key, verify_ed25519 from .libp2p_http import ( @@ -125,7 +125,8 @@ "CredentialRetiredError", "DEFAULT_A2A_NAME", "DEFAULT_ROUTERS", - "DHT_PROTOCOL", + "DIRECTORY_PROTOCOL", + "DirectoryError", "DiscoveredProvider", "Enrollment", "EnrollmentRejectedError", @@ -207,6 +208,7 @@ "revocations_challenge", "seal_biscuit", "select_routers", + "peer_key", "service_key", "rewrite_agent_card", "split_mesh_url", diff --git a/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.py b/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.py index c64cd5dcb..38dbd2d44 100644 --- a/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.py +++ b/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.py @@ -15,7 +15,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0f\x61gentmesh.proto\x12\x0c\x61gentmesh.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"4\n\tAuthFrame\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x16\n\x0etarget_service\x18\x02 \x01(\t\"?\n\x0c\x41uthResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\"\xb1\x01\n\x0cRouterGoAway\x12\x31\n\x06reason\x18\x01 \x01(\x0e\x32!.agentmesh.v1.RouterGoAway.Reason\x12.\n\x0bretry_after\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration\">\n\x06Reason\x12\x16\n\x12REASON_UNSPECIFIED\x10\x00\x12\x0c\n\x08\x44RAINING\x10\x01\x12\x0e\n\nOVERLOADED\x10\x02\"\xdc\x01\n\tMeshEvent\x12*\n\x04type\x18\x01 \x01(\x0e\x32\x1c.agentmesh.v1.MeshEvent.Type\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12.\n\nevent_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0enew_public_key\x18\x04 \x01(\x0c\x12\x11\n\tsignature\x18\x05 \x01(\x0c\"7\n\x04Type\x12\n\n\x06\x42\x41NNED\x10\x00\x12\x10\n\x0cKEY_ROTATION\x10\x01\x12\x11\n\rPOLICY_UPDATE\x10\x02\"\xf9\x01\n\rEnrollRequest\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12\x37\n\x06labels\x18\x05 \x03(\x0b\x32\'.agentmesh.v1.EnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xab\x01\n\x0e\x45nrollResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x03 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x97\x02\n\x16\x42ootstrapEnrollRequest\x12\x17\n\x0f\x62ootstrap_token\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12@\n\x06labels\x18\x05 \x03(\x0b\x32\x30.agentmesh.v1.BootstrapEnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x83\x02\n\x17\x42ootstrapEnrollResponse\x12.\n\x06status\x18\x01 \x01(\x0e\x32\x1e.agentmesh.v1.EnrollmentStatus\x12\x15\n\rbiscuit_token\x18\x02 \x01(\x0c\x12\x1d\n\x15poll_interval_seconds\x18\x03 \x01(\x05\x12\x15\n\rerror_message\x18\x04 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x05 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"Y\n\x0bServiceInfo\x12\'\n\x04type\x18\x01 \x01(\x0e\x32\x19.agentmesh.v1.ServiceType\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\"\x81\x01\n\x0e\x43ommandBackend\x12\x0f\n\x07\x63ommand\x18\x01 \x03(\t\x12\x32\n\x03\x65nv\x18\x02 \x03(\x0b\x32%.agentmesh.v1.CommandBackend.EnvEntry\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x96\x01\n\x16RegisterServiceRequest\x12*\n\x07service\x18\x01 \x01(\x0b\x32\x19.agentmesh.v1.ServiceInfo\x12\x14\n\ntarget_url\x18\x02 \x01(\tH\x00\x12/\n\x07\x63ommand\x18\x03 \x01(\x0b\x32\x1c.agentmesh.v1.CommandBackendH\x00\x42\t\n\x07\x62\x61\x63kend\"i\n\x12\x44iscoveredProvider\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x17\n\x0flocal_proxy_url\x18\x02 \x01(\t\x12\x10\n\x08srv_name\x18\x03 \x01(\t\x12\x17\n\x0fsrv_description\x18\x04 \x01(\t\"\xbe\x02\n\x0fServiceAnnounce\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\'\n\x04type\x18\x02 \x01(\x0e\x32\x19.agentmesh.v1.ServiceType\x12\x14\n\x0cservice_name\x18\x03 \x01(\t\x12\x0c\n\x04keys\x18\x04 \x03(\t\x12\x39\n\x06labels\x18\x05 \x03(\x0b\x32).agentmesh.v1.ServiceAnnounce.LabelsEntry\x12\x17\n\x0f\x61\x63tive_requests\x18\x06 \x01(\r\x12\x17\n\x0flatency_ewma_ms\x18\x07 \x01(\x01\x12\x31\n\rannounce_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xe8\x01\n\x18\x43ontrolPlaneInfoResponse\x12\x13\n\x0boidc_issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x05 \x03(\t\x12)\n\x07routers\x18\x06 \x03(\x0b\x32\x18.agentmesh.v1.RouterInfo\x12\x34\n\tdirectory\x18\x07 \x01(\x0b\x32!.agentmesh.v1.DirectoryParameters\"\x90\x01\n\x13\x44irectoryParameters\x12\x13\n\x0breplication\x18\x01 \x01(\x05\x12\x35\n\x12reprovide_interval\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration\x12-\n\nrecord_ttl\x18\x03 \x01(\x0b\x32\x19.google.protobuf.Duration\"\xc4\x01\n\nRouterInfo\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x34\n\x06labels\x18\x03 \x03(\x0b\x32$.agentmesh.v1.RouterInfo.LabelsEntry\x12\x13\n\x0b\x63onnections\x18\x04 \x01(\x05\x12\x18\n\x10\x63onnection_limit\x18\x05 \x01(\x05\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x83\x02\n\x12RouterLeaseRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\x12\x17\n\x0f\x63onnected_peers\x18\x04 \x03(\t\x12\x10\n\x08\x64ht_size\x18\x05 \x01(\x05\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x12&\n\x03ttl\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x13\n\x0b\x63onnections\x18\t \x01(\x05\x12\x18\n\x10\x63onnection_limit\x18\n \x01(\x05\"f\n\x13RouterLeaseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xa4\x01\n\nPolicyRole\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0f\x61llowed_targets\x18\x02 \x03(\t\x12\x18\n\x10\x61llowed_services\x18\x03 \x03(\t\x12\x16\n\x0e\x63ustom_datalog\x18\x04 \x03(\t\x12\x16\n\x0e\x61llowed_labels\x18\x05 \x03(\t\x12%\n\x04http\x18\x06 \x03(\x0b\x32\x17.agentmesh.v1.HTTPGrant\"<\n\tHTTPGrant\x12\x0f\n\x07service\x18\x01 \x01(\t\x12\x0f\n\x07methods\x18\x02 \x03(\t\x12\r\n\x05paths\x18\x03 \x03(\t\"\xa1\x02\n\x11\x45gressDestination\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ntarget_url\x18\x02 \x01(\t\x12\x12\n\ncredential\x18\x03 \x01(\t\x12\x11\n\tserved_by\x18\x04 \x03(\t\x12.\n\x06\x62roker\x18\x05 \x01(\x0b\x32\x1e.agentmesh.v1.CredentialBroker\x12,\n\ninspection\x18\x06 \x01(\x0b\x32\x18.agentmesh.v1.Inspection\x12&\n\x04mode\x18\x07 \x01(\x0e\x32\x18.agentmesh.v1.EgressMode\x12\r\n\x05ports\x18\x08 \x03(\r\x12\x15\n\rpreserve_host\x18\t \x01(\x08\x12\x17\n\x0f\x66orward_context\x18\n \x01(\x08\"9\n\nInspection\x12+\n\ninspectors\x18\x01 \x03(\x0b\x32\x17.agentmesh.v1.Inspector\"o\n\tInspector\x12/\n\x0bmodel_armor\x18\x01 \x01(\x0b\x32\x18.agentmesh.v1.ModelArmorH\x00\x12)\n\x08\x65xt_proc\x18\x02 \x01(\x0b\x32\x15.agentmesh.v1.ExtProcH\x00\x42\x06\n\x04kind\"\x91\x01\n\nModelArmor\x12\x10\n\x08template\x18\x01 \x01(\t\x12\x32\n\x08response\x18\x02 \x01(\x0e\x32 .agentmesh.v1.ResponseInspection\x12\x11\n\tfail_open\x18\x03 \x01(\x08\x12*\n\x07timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\"\x88\x02\n\x07\x45xtProc\x12\x0e\n\x06target\x18\x01 \x01(\t\x12\n\n\x02\x63\x61\x18\x02 \x01(\t\x12\x1a\n\x12\x63lient_certificate\x18\x03 \x01(\t\x12<\n\x0fprocessing_mode\x18\x04 \x01(\x0b\x32#.agentmesh.v1.ExtProcProcessingMode\x12\x1b\n\x13\x61llow_mode_override\x18\x05 \x01(\x08\x12\x32\n\x0fmessage_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x1a\n\x12\x66\x61ilure_mode_allow\x18\x07 \x01(\x08\x12\x1a\n\x12max_buffered_bytes\x18\x08 \x01(\r\"\xff\x04\n\x15\x45xtProcProcessingMode\x12K\n\x13request_header_mode\x18\x01 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\x12L\n\x14response_header_mode\x18\x02 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\x12G\n\x11request_body_mode\x18\x03 \x01(\x0e\x32,.agentmesh.v1.ExtProcProcessingMode.BodyMode\x12H\n\x12response_body_mode\x18\x04 \x01(\x0e\x32,.agentmesh.v1.ExtProcProcessingMode.BodyMode\x12L\n\x14request_trailer_mode\x18\x05 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\x12M\n\x15response_trailer_mode\x18\x06 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\"9\n\nHeaderMode\x12\x17\n\x13HEADER_MODE_DEFAULT\x10\x00\x12\x08\n\x04SEND\x10\x01\x12\x08\n\x04SKIP\x10\x02\"`\n\x08\x42odyMode\x12\x08\n\x04NONE\x10\x00\x12\x0c\n\x08STREAMED\x10\x01\x12\x0c\n\x08\x42UFFERED\x10\x02\x12\x14\n\x10\x42UFFERED_PARTIAL\x10\x03\x12\x18\n\x14\x46ULL_DUPLEX_STREAMED\x10\x04\"\xe1\x01\n\x10\x43redentialBroker\x12\x17\n\rstatic_secret\x18\x01 \x01(\tH\x00\x12\x37\n\x0foidc_federation\x18\x02 \x01(\x0b\x32\x1c.agentmesh.v1.OIDCFederationH\x00\x12\x36\n\x0f\x61ws_assume_role\x18\x03 \x01(\x0b\x32\x1b.agentmesh.v1.AWSAssumeRoleH\x00\x12;\n\x11platform_identity\x18\x04 \x01(\x0b\x32\x1e.agentmesh.v1.PlatformIdentityH\x00\x42\x06\n\x04kind\"_\n\x0eOIDCFederation\x12\x16\n\x0etoken_endpoint\x18\x01 \x01(\t\x12\x10\n\x08\x61udience\x18\x02 \x01(\t\x12\x13\n\x0bimpersonate\x18\x03 \x01(\t\x12\x0e\n\x06scopes\x18\x04 \x03(\t\"9\n\rAWSAssumeRole\x12\x10\n\x08role_arn\x18\x01 \x01(\t\x12\x16\n\x0esession_policy\x18\x02 \x01(\t\"\"\n\x10PlatformIdentity\x12\x0e\n\x06scopes\x18\x01 \x03(\t\".\n\rPolicyBinding\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x0f\n\x07members\x18\x02 \x03(\t\"\x97\x01\n\x0cPolicyConfig\x12\'\n\x05roles\x18\x01 \x03(\x0b\x32\x18.agentmesh.v1.PolicyRole\x12-\n\x08\x62indings\x18\x02 \x03(\x0b\x32\x1b.agentmesh.v1.PolicyBinding\x12/\n\x06\x65gress\x18\x03 \x03(\x0b\x32\x1f.agentmesh.v1.EgressDestination\"\x18\n\x16PolicyConfigGetRequest\"0\n\x17PolicyConfigGetResponse\x12\x15\n\rdatalog_rules\x18\x01 \x03(\t\"<\n\x1aPolicyConfigUpdateResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\x1a\n\x18\x45gressAssignmentsRequest\"L\n\x19\x45gressAssignmentsResponse\x12/\n\x06\x65gress\x18\x01 \x03(\x0b\x32\x1f.agentmesh.v1.EgressDestination\"f\n\x0cKeysResponse\x12\x13\n\x0bpublic_keys\x18\x01 \x03(\x0c\x12-\n\tsign_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x12\n\nsignatures\x18\x03 \x03(\x0c\"k\n\x13TokenRefreshRequest\x12\x1b\n\x13\x63hallenge_signature\x18\x01 \x01(\x0c\x12\x19\n\x11\x63hallenge_unix_ms\x18\x02 \x01(\x03\x12\x0f\n\x07peer_id\x18\x03 \x01(\t\x12\x0b\n\x03jwt\x18\x04 \x01(\t\"u\n\x14TokenRefreshResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rerror_message\x18\x03 \x01(\t\"@\n\x11NodeCatalogReport\x12+\n\x08services\x18\x01 \x03(\x0b\x32\x19.agentmesh.v1.ServiceInfo\"%\n\x12TokenRevokeRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\"5\n\x13TokenRevokeResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\x96\x01\n\x1b\x42ootstrapTokenCreateRequest\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x10\n\x08owner_id\x18\x02 \x01(\t\x12\x11\n\tttl_hours\x18\x03 \x01(\x05\x12\x12\n\nmax_usages\x18\x04 \x01(\x05\x12\x13\n\x0b\x64\x65scription\x18\x05 \x01(\t\x12\x1b\n\x13\x61utonomous_recovery\x18\x06 \x01(\x08\"\x8a\x01\n\x1c\x42ootstrapTokenCreateResponse\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05token\x18\x02 \x01(\t\x12\x0c\n\x04role\x18\x03 \x01(\t\x12\x10\n\x08owner_id\x18\x04 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xab\x02\n\x0e\x42ootstrapToken\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04role\x18\x02 \x01(\t\x12\x10\n\x08owner_id\x18\x03 \x01(\t\x12\x12\n\nmax_usages\x18\x04 \x01(\x05\x12\x14\n\x0cusages_count\x18\x05 \x01(\x05\x12\x13\n\x0b\x64\x65scription\x18\x06 \x01(\t\x12/\n\x0b\x63reate_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x65xpire_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0brevoke_time\x18\t \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1b\n\x13\x61utonomous_recovery\x18\n \x01(\x08\"J\n\x1a\x42ootstrapTokenListResponse\x12,\n\x06tokens\x18\x01 \x03(\x0b\x32\x1c.agentmesh.v1.BootstrapToken\"\xd6\x02\n\x11\x45nrollmentRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x10\n\x08token_id\x18\x03 \x01(\t\x12.\n\x06status\x18\x04 \x01(\x0e\x32\x1e.agentmesh.v1.EnrollmentStatus\x12;\n\x06labels\x18\x05 \x03(\x0b\x32+.agentmesh.v1.EnrollmentRequest.LabelsEntry\x12/\n\x0b\x63reate_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x30\n\x0cresolve_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x13\n\x0bresolved_by\x18\x08 \x01(\t\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"R\n\x1d\x45nrollmentRequestListResponse\x12\x31\n\x08requests\x18\x01 \x03(\x0b\x32\x1f.agentmesh.v1.EnrollmentRequest\"p\n\x04User\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0e\n\x06issuer\x18\x02 \x01(\t\x12\r\n\x05\x65mail\x18\x03 \x01(\t\x12\x0c\n\x04role\x18\x04 \x01(\t\x12/\n\x0b\x63reate_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xe3\x02\n\x0c\x45nrolledNode\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0c\n\x04role\x18\x02 \x01(\t\x12\x17\n\x0f\x65nrollment_type\x18\x03 \x01(\t\x12\x13\n\x0b\x63laims_json\x18\x04 \x01(\t\x12\x10\n\x08owner_id\x18\x05 \x01(\t\x12\x36\n\x06labels\x18\x06 \x03(\x0b\x32&.agentmesh.v1.EnrolledNode.LabelsEntry\x12/\n\x0b\x65nroll_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x65xpire_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0e\n\x06\x62\x61nned\x18\t \x01(\x08\x12\x1b\n\x13\x61utonomous_recovery\x18\n \x01(\x08\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xc4\x01\n\x0bRouterLease\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x35\n\x11last_renewal_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x65xpire_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x17\n\x0f\x63onnected_peers\x18\x05 \x03(\t\x12\x10\n\x08\x64ht_size\x18\x06 \x01(\x05\"l\n\x0cNodeServices\x12+\n\x08services\x18\x01 \x03(\x0b\x32\x19.agentmesh.v1.ServiceInfo\x12/\n\x0breport_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdb\x03\n\x13\x41\x64minStatusResponse\x12!\n\x05users\x18\x01 \x03(\x0b\x32\x12.agentmesh.v1.User\x12\x31\n\x0e\x61\x63tive_routers\x18\x02 \x03(\x0b\x32\x19.agentmesh.v1.RouterLease\x12\x32\n\x0e\x65nrolled_nodes\x18\x03 \x03(\x0b\x32\x1a.agentmesh.v1.EnrolledNode\x12<\n\x13\x65nrollment_requests\x18\x04 \x03(\x0b\x32\x1f.agentmesh.v1.EnrollmentRequest\x12\x36\n\x10\x62ootstrap_tokens\x18\x05 \x03(\x0b\x32\x1c.agentmesh.v1.BootstrapToken\x12*\n\x06policy\x18\x06 \x01(\x0b\x32\x1a.agentmesh.v1.PolicyConfig\x12H\n\x0cnode_catalog\x18\x07 \x03(\x0b\x32\x32.agentmesh.v1.AdminStatusResponse.NodeCatalogEntry\x1aN\n\x10NodeCatalogEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.agentmesh.v1.NodeServices:\x02\x38\x01\"\x81\x02\n\x12UserStatusResponse\x12 \n\x04user\x18\x01 \x01(\x0b\x32\x12.agentmesh.v1.User\x12\x32\n\x0e\x65nrolled_nodes\x18\x02 \x03(\x0b\x32\x1a.agentmesh.v1.EnrolledNode\x12\x36\n\x10\x62ootstrap_tokens\x18\x03 \x03(\x0b\x32\x1c.agentmesh.v1.BootstrapToken\x12\x31\n\x0e\x61\x63tive_routers\x18\x04 \x03(\x0b\x32\x19.agentmesh.v1.RouterLease\x12*\n\x06policy\x18\x05 \x01(\x0b\x32\x1a.agentmesh.v1.PolicyConfig\"\xe4\x01\n\x18IdentityEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x37\n\x13\x62iscuit_expire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x19\n\x11\x63ontrol_plane_url\x18\x04 \x01(\t\x12\"\n\x1atrusted_control_plane_keys\x18\x05 \x03(\x0c\x12.\n\ncheck_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xc6\x02\n\x14PeerEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x15\n\rverifying_key\x18\x03 \x01(\x0c\x12\r\n\x05roles\x18\x04 \x03(\t\x12>\n\x06labels\x18\x05 \x03(\x0b\x32..agentmesh.v1.PeerEvidenceResponse.LabelsEntry\x12/\n\x0b\x65xpire_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0erevocation_ids\x18\x07 \x03(\t\x12.\n\ncheck_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x8c\x02\n\x10MemberCredential\x12\x19\n\x11\x63ontrol_plane_url\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x35\n\x0ctrusted_keys\x18\x04 \x03(\x0b\x32\x1f.agentmesh.v1.TrustedSigningKey\x12\x19\n\x11issued_under_keys\x18\x05 \x03(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12/\n\x0coidc_session\x18\x07 \x01(\x0b\x32\x19.agentmesh.v1.OIDCSession\"Y\n\x11TrustedSigningKey\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x30\n\x0creceive_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"Y\n\x0bOIDCSession\x12\x0e\n\x06issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x15\n\rrefresh_token\x18\x04 \x01(\t\"\x93\x01\n\x15TaskAuthorizationRule\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12%\n\x05rules\x18\x03 \x03(\x0b\x32\x16.agentmesh.v1.TaskRule\x12/\n\x0b\x65xpire_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x84\x01\n\x08TaskRule\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x18\n\x10\x61llowed_services\x18\x02 \x03(\t\x12.\n\toperation\x18\x03 \x01(\x0b\x32\x1b.agentmesh.v1.TaskOperation\x12\x19\n\x11\x61llowed_resources\x18\x04 \x03(\t\"s\n\rTaskOperation\x12\x15\n\rallowed_tools\x18\x01 \x03(\t\x12\x17\n\x0f\x61llowed_methods\x18\x02 \x03(\t\x12\x15\n\rallowed_paths\x18\x03 \x03(\t\x12\x1b\n\x13\x61llowed_permissions\x18\x04 \x03(\t\"\xab\x01\n\x14TokenExchangeRequest\x12\x15\n\rsubject_token\x18\x01 \x01(\t\x12\x36\n\ttask_rule\x18\x02 \x01(\x0b\x32#.agentmesh.v1.TaskAuthorizationRule\x12\x0c\n\x04seal\x18\x03 \x01(\x08\x12\x19\n\x11\x63hallenge_unix_ms\x18\x04 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x05 \x01(\x0c\"\x7f\n\x15TokenExchangeResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\r\n\x05roles\x18\x03 \x03(\t\x12\x0f\n\x07subject\x18\x04 \x01(\t\"\x81\x01\n\x0fSTSTokenRequest\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x13\n\x0b\x64\x65stination\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x19\n\x11\x63hallenge_unix_ms\x18\x04 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x05 \x01(\x0c\"\x83\x01\n\x10STSTokenResponse\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07subject\x18\x03 \x01(\t\x12\r\n\x05roles\x18\x04 \x03(\t\x12\x11\n\ttask_name\x18\x05 \x01(\t\"F\n\x13RevocationsResponse\x12\x16\n\x0erevocation_ids\x18\x01 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x02 \x03(\t*\x94\x01\n\x10\x45nrollmentStatus\x12!\n\x1d\x45NROLLMENT_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n\x19\x45NROLLMENT_STATUS_PENDING\x10\x01\x12\x1e\n\x1a\x45NROLLMENT_STATUS_APPROVED\x10\x02\x12\x1e\n\x1a\x45NROLLMENT_STATUS_REJECTED\x10\x03*\x8c\x01\n\x0bServiceType\x12\x1c\n\x18SERVICE_TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10SERVICE_TYPE_MCP\x10\x01\x12\x1a\n\x16SERVICE_TYPE_INFERENCE\x10\x02\x12\x14\n\x10SERVICE_TYPE_A2A\x10\x03\x12\x17\n\x13SERVICE_TYPE_EGRESS\x10\x04*7\n\nEgressMode\x12\x14\n\x10\x45GRESS_MODE_HTTP\x10\x00\x12\x13\n\x0f\x45GRESS_MODE_TCP\x10\x01*\\\n\x12ResponseInspection\x12 \n\x1cRESPONSE_INSPECTION_BUFFERED\x10\x00\x12$\n RESPONSE_INSPECTION_REQUEST_ONLY\x10\x01\x42!Z\x1fgithub.com/google/agentmesh/apib\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0f\x61gentmesh.proto\x12\x0c\x61gentmesh.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"4\n\tAuthFrame\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x16\n\x0etarget_service\x18\x02 \x01(\t\"?\n\x0c\x41uthResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\"\xb1\x01\n\x0cRouterGoAway\x12\x31\n\x06reason\x18\x01 \x01(\x0e\x32!.agentmesh.v1.RouterGoAway.Reason\x12.\n\x0bretry_after\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration\">\n\x06Reason\x12\x16\n\x12REASON_UNSPECIFIED\x10\x00\x12\x0c\n\x08\x44RAINING\x10\x01\x12\x0e\n\nOVERLOADED\x10\x02\"\xdc\x01\n\tMeshEvent\x12*\n\x04type\x18\x01 \x01(\x0e\x32\x1c.agentmesh.v1.MeshEvent.Type\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12.\n\nevent_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0enew_public_key\x18\x04 \x01(\x0c\x12\x11\n\tsignature\x18\x05 \x01(\x0c\"7\n\x04Type\x12\n\n\x06\x42\x41NNED\x10\x00\x12\x10\n\x0cKEY_ROTATION\x10\x01\x12\x11\n\rPOLICY_UPDATE\x10\x02\"\xf9\x01\n\rEnrollRequest\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12\x37\n\x06labels\x18\x05 \x03(\x0b\x32\'.agentmesh.v1.EnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xab\x01\n\x0e\x45nrollResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x03 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x97\x02\n\x16\x42ootstrapEnrollRequest\x12\x17\n\x0f\x62ootstrap_token\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12@\n\x06labels\x18\x05 \x03(\x0b\x32\x30.agentmesh.v1.BootstrapEnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x83\x02\n\x17\x42ootstrapEnrollResponse\x12.\n\x06status\x18\x01 \x01(\x0e\x32\x1e.agentmesh.v1.EnrollmentStatus\x12\x15\n\rbiscuit_token\x18\x02 \x01(\x0c\x12\x1d\n\x15poll_interval_seconds\x18\x03 \x01(\x05\x12\x15\n\rerror_message\x18\x04 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x05 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"Y\n\x0bServiceInfo\x12\'\n\x04type\x18\x01 \x01(\x0e\x32\x19.agentmesh.v1.ServiceType\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\"\x81\x01\n\x0e\x43ommandBackend\x12\x0f\n\x07\x63ommand\x18\x01 \x03(\t\x12\x32\n\x03\x65nv\x18\x02 \x03(\x0b\x32%.agentmesh.v1.CommandBackend.EnvEntry\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x96\x01\n\x16RegisterServiceRequest\x12*\n\x07service\x18\x01 \x01(\x0b\x32\x19.agentmesh.v1.ServiceInfo\x12\x14\n\ntarget_url\x18\x02 \x01(\tH\x00\x12/\n\x07\x63ommand\x18\x03 \x01(\x0b\x32\x1c.agentmesh.v1.CommandBackendH\x00\x42\t\n\x07\x62\x61\x63kend\"i\n\x12\x44iscoveredProvider\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x17\n\x0flocal_proxy_url\x18\x02 \x01(\t\x12\x10\n\x08srv_name\x18\x03 \x01(\t\x12\x17\n\x0fsrv_description\x18\x04 \x01(\t\"\xe8\x01\n\x18\x43ontrolPlaneInfoResponse\x12\x13\n\x0boidc_issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x05 \x03(\t\x12)\n\x07routers\x18\x06 \x03(\x0b\x32\x18.agentmesh.v1.RouterInfo\x12\x34\n\tdirectory\x18\x07 \x01(\x0b\x32!.agentmesh.v1.DirectoryParameters\"\x90\x01\n\x13\x44irectoryParameters\x12\x13\n\x0breplication\x18\x01 \x01(\x05\x12\x35\n\x12reprovide_interval\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration\x12-\n\nrecord_ttl\x18\x03 \x01(\x0b\x32\x19.google.protobuf.Duration\"\xc6\x01\n\x10\x44irectoryRequest\x12\x31\n\x07provide\x18\x01 \x01(\x0b\x32\x1e.agentmesh.v1.DirectoryProvideH\x00\x12>\n\x0e\x66ind_providers\x18\x02 \x01(\x0b\x32$.agentmesh.v1.DirectoryFindProvidersH\x00\x12\x34\n\tfind_peer\x18\x03 \x01(\x0b\x32\x1f.agentmesh.v1.DirectoryFindPeerH\x00\x42\t\n\x07request\"@\n\x10\x44irectoryProvide\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\r\n\x05\x61\x64\x64rs\x18\x02 \x03(\t\x12\x10\n\x08provider\x18\x03 \x01(\t\"4\n\x16\x44irectoryFindProviders\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\r\n\x05limit\x18\x02 \x01(\r\"$\n\x11\x44irectoryFindPeer\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\"w\n\x11\x44irectoryResponse\x12+\n\tproviders\x18\x01 \x03(\x0b\x32\x18.agentmesh.v1.PeerRecord\x12&\n\x04peer\x18\x02 \x01(\x0b\x32\x18.agentmesh.v1.PeerRecord\x12\r\n\x05\x65rror\x18\x03 \x01(\t\",\n\nPeerRecord\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\r\n\x05\x61\x64\x64rs\x18\x02 \x03(\t\"\xc4\x01\n\nRouterInfo\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x34\n\x06labels\x18\x03 \x03(\x0b\x32$.agentmesh.v1.RouterInfo.LabelsEntry\x12\x13\n\x0b\x63onnections\x18\x04 \x01(\x05\x12\x18\n\x10\x63onnection_limit\x18\x05 \x01(\x05\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x83\x02\n\x12RouterLeaseRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\x12\x17\n\x0f\x63onnected_peers\x18\x04 \x03(\t\x12\x10\n\x08\x64ht_size\x18\x05 \x01(\x05\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x12&\n\x03ttl\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x13\n\x0b\x63onnections\x18\t \x01(\x05\x12\x18\n\x10\x63onnection_limit\x18\n \x01(\x05\"f\n\x13RouterLeaseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xa4\x01\n\nPolicyRole\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0f\x61llowed_targets\x18\x02 \x03(\t\x12\x18\n\x10\x61llowed_services\x18\x03 \x03(\t\x12\x16\n\x0e\x63ustom_datalog\x18\x04 \x03(\t\x12\x16\n\x0e\x61llowed_labels\x18\x05 \x03(\t\x12%\n\x04http\x18\x06 \x03(\x0b\x32\x17.agentmesh.v1.HTTPGrant\"<\n\tHTTPGrant\x12\x0f\n\x07service\x18\x01 \x01(\t\x12\x0f\n\x07methods\x18\x02 \x03(\t\x12\r\n\x05paths\x18\x03 \x03(\t\"\xa1\x02\n\x11\x45gressDestination\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ntarget_url\x18\x02 \x01(\t\x12\x12\n\ncredential\x18\x03 \x01(\t\x12\x11\n\tserved_by\x18\x04 \x03(\t\x12.\n\x06\x62roker\x18\x05 \x01(\x0b\x32\x1e.agentmesh.v1.CredentialBroker\x12,\n\ninspection\x18\x06 \x01(\x0b\x32\x18.agentmesh.v1.Inspection\x12&\n\x04mode\x18\x07 \x01(\x0e\x32\x18.agentmesh.v1.EgressMode\x12\r\n\x05ports\x18\x08 \x03(\r\x12\x15\n\rpreserve_host\x18\t \x01(\x08\x12\x17\n\x0f\x66orward_context\x18\n \x01(\x08\"9\n\nInspection\x12+\n\ninspectors\x18\x01 \x03(\x0b\x32\x17.agentmesh.v1.Inspector\"o\n\tInspector\x12/\n\x0bmodel_armor\x18\x01 \x01(\x0b\x32\x18.agentmesh.v1.ModelArmorH\x00\x12)\n\x08\x65xt_proc\x18\x02 \x01(\x0b\x32\x15.agentmesh.v1.ExtProcH\x00\x42\x06\n\x04kind\"\x91\x01\n\nModelArmor\x12\x10\n\x08template\x18\x01 \x01(\t\x12\x32\n\x08response\x18\x02 \x01(\x0e\x32 .agentmesh.v1.ResponseInspection\x12\x11\n\tfail_open\x18\x03 \x01(\x08\x12*\n\x07timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\"\x88\x02\n\x07\x45xtProc\x12\x0e\n\x06target\x18\x01 \x01(\t\x12\n\n\x02\x63\x61\x18\x02 \x01(\t\x12\x1a\n\x12\x63lient_certificate\x18\x03 \x01(\t\x12<\n\x0fprocessing_mode\x18\x04 \x01(\x0b\x32#.agentmesh.v1.ExtProcProcessingMode\x12\x1b\n\x13\x61llow_mode_override\x18\x05 \x01(\x08\x12\x32\n\x0fmessage_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x1a\n\x12\x66\x61ilure_mode_allow\x18\x07 \x01(\x08\x12\x1a\n\x12max_buffered_bytes\x18\x08 \x01(\r\"\xff\x04\n\x15\x45xtProcProcessingMode\x12K\n\x13request_header_mode\x18\x01 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\x12L\n\x14response_header_mode\x18\x02 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\x12G\n\x11request_body_mode\x18\x03 \x01(\x0e\x32,.agentmesh.v1.ExtProcProcessingMode.BodyMode\x12H\n\x12response_body_mode\x18\x04 \x01(\x0e\x32,.agentmesh.v1.ExtProcProcessingMode.BodyMode\x12L\n\x14request_trailer_mode\x18\x05 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\x12M\n\x15response_trailer_mode\x18\x06 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\"9\n\nHeaderMode\x12\x17\n\x13HEADER_MODE_DEFAULT\x10\x00\x12\x08\n\x04SEND\x10\x01\x12\x08\n\x04SKIP\x10\x02\"`\n\x08\x42odyMode\x12\x08\n\x04NONE\x10\x00\x12\x0c\n\x08STREAMED\x10\x01\x12\x0c\n\x08\x42UFFERED\x10\x02\x12\x14\n\x10\x42UFFERED_PARTIAL\x10\x03\x12\x18\n\x14\x46ULL_DUPLEX_STREAMED\x10\x04\"\xe1\x01\n\x10\x43redentialBroker\x12\x17\n\rstatic_secret\x18\x01 \x01(\tH\x00\x12\x37\n\x0foidc_federation\x18\x02 \x01(\x0b\x32\x1c.agentmesh.v1.OIDCFederationH\x00\x12\x36\n\x0f\x61ws_assume_role\x18\x03 \x01(\x0b\x32\x1b.agentmesh.v1.AWSAssumeRoleH\x00\x12;\n\x11platform_identity\x18\x04 \x01(\x0b\x32\x1e.agentmesh.v1.PlatformIdentityH\x00\x42\x06\n\x04kind\"_\n\x0eOIDCFederation\x12\x16\n\x0etoken_endpoint\x18\x01 \x01(\t\x12\x10\n\x08\x61udience\x18\x02 \x01(\t\x12\x13\n\x0bimpersonate\x18\x03 \x01(\t\x12\x0e\n\x06scopes\x18\x04 \x03(\t\"9\n\rAWSAssumeRole\x12\x10\n\x08role_arn\x18\x01 \x01(\t\x12\x16\n\x0esession_policy\x18\x02 \x01(\t\"\"\n\x10PlatformIdentity\x12\x0e\n\x06scopes\x18\x01 \x03(\t\".\n\rPolicyBinding\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x0f\n\x07members\x18\x02 \x03(\t\"\x97\x01\n\x0cPolicyConfig\x12\'\n\x05roles\x18\x01 \x03(\x0b\x32\x18.agentmesh.v1.PolicyRole\x12-\n\x08\x62indings\x18\x02 \x03(\x0b\x32\x1b.agentmesh.v1.PolicyBinding\x12/\n\x06\x65gress\x18\x03 \x03(\x0b\x32\x1f.agentmesh.v1.EgressDestination\"\x18\n\x16PolicyConfigGetRequest\"0\n\x17PolicyConfigGetResponse\x12\x15\n\rdatalog_rules\x18\x01 \x03(\t\"<\n\x1aPolicyConfigUpdateResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\x1a\n\x18\x45gressAssignmentsRequest\"L\n\x19\x45gressAssignmentsResponse\x12/\n\x06\x65gress\x18\x01 \x03(\x0b\x32\x1f.agentmesh.v1.EgressDestination\"f\n\x0cKeysResponse\x12\x13\n\x0bpublic_keys\x18\x01 \x03(\x0c\x12-\n\tsign_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x12\n\nsignatures\x18\x03 \x03(\x0c\"k\n\x13TokenRefreshRequest\x12\x1b\n\x13\x63hallenge_signature\x18\x01 \x01(\x0c\x12\x19\n\x11\x63hallenge_unix_ms\x18\x02 \x01(\x03\x12\x0f\n\x07peer_id\x18\x03 \x01(\t\x12\x0b\n\x03jwt\x18\x04 \x01(\t\"u\n\x14TokenRefreshResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rerror_message\x18\x03 \x01(\t\"@\n\x11NodeCatalogReport\x12+\n\x08services\x18\x01 \x03(\x0b\x32\x19.agentmesh.v1.ServiceInfo\"%\n\x12TokenRevokeRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\"5\n\x13TokenRevokeResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\x96\x01\n\x1b\x42ootstrapTokenCreateRequest\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x10\n\x08owner_id\x18\x02 \x01(\t\x12\x11\n\tttl_hours\x18\x03 \x01(\x05\x12\x12\n\nmax_usages\x18\x04 \x01(\x05\x12\x13\n\x0b\x64\x65scription\x18\x05 \x01(\t\x12\x1b\n\x13\x61utonomous_recovery\x18\x06 \x01(\x08\"\x8a\x01\n\x1c\x42ootstrapTokenCreateResponse\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05token\x18\x02 \x01(\t\x12\x0c\n\x04role\x18\x03 \x01(\t\x12\x10\n\x08owner_id\x18\x04 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xab\x02\n\x0e\x42ootstrapToken\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04role\x18\x02 \x01(\t\x12\x10\n\x08owner_id\x18\x03 \x01(\t\x12\x12\n\nmax_usages\x18\x04 \x01(\x05\x12\x14\n\x0cusages_count\x18\x05 \x01(\x05\x12\x13\n\x0b\x64\x65scription\x18\x06 \x01(\t\x12/\n\x0b\x63reate_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x65xpire_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0brevoke_time\x18\t \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1b\n\x13\x61utonomous_recovery\x18\n \x01(\x08\"J\n\x1a\x42ootstrapTokenListResponse\x12,\n\x06tokens\x18\x01 \x03(\x0b\x32\x1c.agentmesh.v1.BootstrapToken\"\xd6\x02\n\x11\x45nrollmentRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x10\n\x08token_id\x18\x03 \x01(\t\x12.\n\x06status\x18\x04 \x01(\x0e\x32\x1e.agentmesh.v1.EnrollmentStatus\x12;\n\x06labels\x18\x05 \x03(\x0b\x32+.agentmesh.v1.EnrollmentRequest.LabelsEntry\x12/\n\x0b\x63reate_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x30\n\x0cresolve_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x13\n\x0bresolved_by\x18\x08 \x01(\t\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"R\n\x1d\x45nrollmentRequestListResponse\x12\x31\n\x08requests\x18\x01 \x03(\x0b\x32\x1f.agentmesh.v1.EnrollmentRequest\"p\n\x04User\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0e\n\x06issuer\x18\x02 \x01(\t\x12\r\n\x05\x65mail\x18\x03 \x01(\t\x12\x0c\n\x04role\x18\x04 \x01(\t\x12/\n\x0b\x63reate_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xe3\x02\n\x0c\x45nrolledNode\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0c\n\x04role\x18\x02 \x01(\t\x12\x17\n\x0f\x65nrollment_type\x18\x03 \x01(\t\x12\x13\n\x0b\x63laims_json\x18\x04 \x01(\t\x12\x10\n\x08owner_id\x18\x05 \x01(\t\x12\x36\n\x06labels\x18\x06 \x03(\x0b\x32&.agentmesh.v1.EnrolledNode.LabelsEntry\x12/\n\x0b\x65nroll_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x65xpire_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0e\n\x06\x62\x61nned\x18\t \x01(\x08\x12\x1b\n\x13\x61utonomous_recovery\x18\n \x01(\x08\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xc4\x01\n\x0bRouterLease\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x35\n\x11last_renewal_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x65xpire_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x17\n\x0f\x63onnected_peers\x18\x05 \x03(\t\x12\x10\n\x08\x64ht_size\x18\x06 \x01(\x05\"l\n\x0cNodeServices\x12+\n\x08services\x18\x01 \x03(\x0b\x32\x19.agentmesh.v1.ServiceInfo\x12/\n\x0breport_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdb\x03\n\x13\x41\x64minStatusResponse\x12!\n\x05users\x18\x01 \x03(\x0b\x32\x12.agentmesh.v1.User\x12\x31\n\x0e\x61\x63tive_routers\x18\x02 \x03(\x0b\x32\x19.agentmesh.v1.RouterLease\x12\x32\n\x0e\x65nrolled_nodes\x18\x03 \x03(\x0b\x32\x1a.agentmesh.v1.EnrolledNode\x12<\n\x13\x65nrollment_requests\x18\x04 \x03(\x0b\x32\x1f.agentmesh.v1.EnrollmentRequest\x12\x36\n\x10\x62ootstrap_tokens\x18\x05 \x03(\x0b\x32\x1c.agentmesh.v1.BootstrapToken\x12*\n\x06policy\x18\x06 \x01(\x0b\x32\x1a.agentmesh.v1.PolicyConfig\x12H\n\x0cnode_catalog\x18\x07 \x03(\x0b\x32\x32.agentmesh.v1.AdminStatusResponse.NodeCatalogEntry\x1aN\n\x10NodeCatalogEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.agentmesh.v1.NodeServices:\x02\x38\x01\"\x81\x02\n\x12UserStatusResponse\x12 \n\x04user\x18\x01 \x01(\x0b\x32\x12.agentmesh.v1.User\x12\x32\n\x0e\x65nrolled_nodes\x18\x02 \x03(\x0b\x32\x1a.agentmesh.v1.EnrolledNode\x12\x36\n\x10\x62ootstrap_tokens\x18\x03 \x03(\x0b\x32\x1c.agentmesh.v1.BootstrapToken\x12\x31\n\x0e\x61\x63tive_routers\x18\x04 \x03(\x0b\x32\x19.agentmesh.v1.RouterLease\x12*\n\x06policy\x18\x05 \x01(\x0b\x32\x1a.agentmesh.v1.PolicyConfig\"\xe4\x01\n\x18IdentityEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x37\n\x13\x62iscuit_expire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x19\n\x11\x63ontrol_plane_url\x18\x04 \x01(\t\x12\"\n\x1atrusted_control_plane_keys\x18\x05 \x03(\x0c\x12.\n\ncheck_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xc6\x02\n\x14PeerEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x15\n\rverifying_key\x18\x03 \x01(\x0c\x12\r\n\x05roles\x18\x04 \x03(\t\x12>\n\x06labels\x18\x05 \x03(\x0b\x32..agentmesh.v1.PeerEvidenceResponse.LabelsEntry\x12/\n\x0b\x65xpire_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0erevocation_ids\x18\x07 \x03(\t\x12.\n\ncheck_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x8c\x02\n\x10MemberCredential\x12\x19\n\x11\x63ontrol_plane_url\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x35\n\x0ctrusted_keys\x18\x04 \x03(\x0b\x32\x1f.agentmesh.v1.TrustedSigningKey\x12\x19\n\x11issued_under_keys\x18\x05 \x03(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12/\n\x0coidc_session\x18\x07 \x01(\x0b\x32\x19.agentmesh.v1.OIDCSession\"Y\n\x11TrustedSigningKey\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x30\n\x0creceive_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"Y\n\x0bOIDCSession\x12\x0e\n\x06issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x15\n\rrefresh_token\x18\x04 \x01(\t\"\x93\x01\n\x15TaskAuthorizationRule\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12%\n\x05rules\x18\x03 \x03(\x0b\x32\x16.agentmesh.v1.TaskRule\x12/\n\x0b\x65xpire_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x84\x01\n\x08TaskRule\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x18\n\x10\x61llowed_services\x18\x02 \x03(\t\x12.\n\toperation\x18\x03 \x01(\x0b\x32\x1b.agentmesh.v1.TaskOperation\x12\x19\n\x11\x61llowed_resources\x18\x04 \x03(\t\"s\n\rTaskOperation\x12\x15\n\rallowed_tools\x18\x01 \x03(\t\x12\x17\n\x0f\x61llowed_methods\x18\x02 \x03(\t\x12\x15\n\rallowed_paths\x18\x03 \x03(\t\x12\x1b\n\x13\x61llowed_permissions\x18\x04 \x03(\t\"\xab\x01\n\x14TokenExchangeRequest\x12\x15\n\rsubject_token\x18\x01 \x01(\t\x12\x36\n\ttask_rule\x18\x02 \x01(\x0b\x32#.agentmesh.v1.TaskAuthorizationRule\x12\x0c\n\x04seal\x18\x03 \x01(\x08\x12\x19\n\x11\x63hallenge_unix_ms\x18\x04 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x05 \x01(\x0c\"\x7f\n\x15TokenExchangeResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\r\n\x05roles\x18\x03 \x03(\t\x12\x0f\n\x07subject\x18\x04 \x01(\t\"\x81\x01\n\x0fSTSTokenRequest\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x13\n\x0b\x64\x65stination\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x19\n\x11\x63hallenge_unix_ms\x18\x04 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x05 \x01(\x0c\"\x83\x01\n\x10STSTokenResponse\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07subject\x18\x03 \x01(\t\x12\r\n\x05roles\x18\x04 \x03(\t\x12\x11\n\ttask_name\x18\x05 \x01(\t\"F\n\x13RevocationsResponse\x12\x16\n\x0erevocation_ids\x18\x01 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x02 \x03(\t*\x94\x01\n\x10\x45nrollmentStatus\x12!\n\x1d\x45NROLLMENT_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n\x19\x45NROLLMENT_STATUS_PENDING\x10\x01\x12\x1e\n\x1a\x45NROLLMENT_STATUS_APPROVED\x10\x02\x12\x1e\n\x1a\x45NROLLMENT_STATUS_REJECTED\x10\x03*\x8c\x01\n\x0bServiceType\x12\x1c\n\x18SERVICE_TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10SERVICE_TYPE_MCP\x10\x01\x12\x1a\n\x16SERVICE_TYPE_INFERENCE\x10\x02\x12\x14\n\x10SERVICE_TYPE_A2A\x10\x03\x12\x17\n\x13SERVICE_TYPE_EGRESS\x10\x04*7\n\nEgressMode\x12\x14\n\x10\x45GRESS_MODE_HTTP\x10\x00\x12\x13\n\x0f\x45GRESS_MODE_TCP\x10\x01*\\\n\x12ResponseInspection\x12 \n\x1cRESPONSE_INSPECTION_BUFFERED\x10\x00\x12$\n RESPONSE_INSPECTION_REQUEST_ONLY\x10\x01\x42!Z\x1fgithub.com/google/agentmesh/apib\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'agentmesh_pb2', globals()) @@ -29,8 +29,6 @@ _BOOTSTRAPENROLLREQUEST_LABELSENTRY._serialized_options = b'8\001' _COMMANDBACKEND_ENVENTRY._options = None _COMMANDBACKEND_ENVENTRY._serialized_options = b'8\001' - _SERVICEANNOUNCE_LABELSENTRY._options = None - _SERVICEANNOUNCE_LABELSENTRY._serialized_options = b'8\001' _ROUTERINFO_LABELSENTRY._options = None _ROUTERINFO_LABELSENTRY._serialized_options = b'8\001' _ENROLLMENTREQUEST_LABELSENTRY._options = None @@ -41,14 +39,14 @@ _ADMINSTATUSRESPONSE_NODECATALOGENTRY._serialized_options = b'8\001' _PEEREVIDENCERESPONSE_LABELSENTRY._options = None _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_options = b'8\001' - _ENROLLMENTSTATUS._serialized_start=11126 - _ENROLLMENTSTATUS._serialized_end=11274 - _SERVICETYPE._serialized_start=11277 - _SERVICETYPE._serialized_end=11417 - _EGRESSMODE._serialized_start=11419 - _EGRESSMODE._serialized_end=11474 - _RESPONSEINSPECTION._serialized_start=11476 - _RESPONSEINSPECTION._serialized_end=11568 + _ENROLLMENTSTATUS._serialized_start=11331 + _ENROLLMENTSTATUS._serialized_end=11479 + _SERVICETYPE._serialized_start=11482 + _SERVICETYPE._serialized_end=11622 + _EGRESSMODE._serialized_start=11624 + _EGRESSMODE._serialized_end=11679 + _RESPONSEINSPECTION._serialized_start=11681 + _RESPONSEINSPECTION._serialized_end=11773 _AUTHFRAME._serialized_start=98 _AUTHFRAME._serialized_end=150 _AUTHRESPONSE._serialized_start=152 @@ -83,132 +81,140 @@ _REGISTERSERVICEREQUEST._serialized_end=1964 _DISCOVEREDPROVIDER._serialized_start=1966 _DISCOVEREDPROVIDER._serialized_end=2071 - _SERVICEANNOUNCE._serialized_start=2074 - _SERVICEANNOUNCE._serialized_end=2392 - _SERVICEANNOUNCE_LABELSENTRY._serialized_start=825 - _SERVICEANNOUNCE_LABELSENTRY._serialized_end=870 - _CONTROLPLANEINFORESPONSE._serialized_start=2395 - _CONTROLPLANEINFORESPONSE._serialized_end=2627 - _DIRECTORYPARAMETERS._serialized_start=2630 - _DIRECTORYPARAMETERS._serialized_end=2774 - _ROUTERINFO._serialized_start=2777 - _ROUTERINFO._serialized_end=2973 + _CONTROLPLANEINFORESPONSE._serialized_start=2074 + _CONTROLPLANEINFORESPONSE._serialized_end=2306 + _DIRECTORYPARAMETERS._serialized_start=2309 + _DIRECTORYPARAMETERS._serialized_end=2453 + _DIRECTORYREQUEST._serialized_start=2456 + _DIRECTORYREQUEST._serialized_end=2654 + _DIRECTORYPROVIDE._serialized_start=2656 + _DIRECTORYPROVIDE._serialized_end=2720 + _DIRECTORYFINDPROVIDERS._serialized_start=2722 + _DIRECTORYFINDPROVIDERS._serialized_end=2774 + _DIRECTORYFINDPEER._serialized_start=2776 + _DIRECTORYFINDPEER._serialized_end=2812 + _DIRECTORYRESPONSE._serialized_start=2814 + _DIRECTORYRESPONSE._serialized_end=2933 + _PEERRECORD._serialized_start=2935 + _PEERRECORD._serialized_end=2979 + _ROUTERINFO._serialized_start=2982 + _ROUTERINFO._serialized_end=3178 _ROUTERINFO_LABELSENTRY._serialized_start=825 _ROUTERINFO_LABELSENTRY._serialized_end=870 - _ROUTERLEASEREQUEST._serialized_start=2976 - _ROUTERLEASEREQUEST._serialized_end=3235 - _ROUTERLEASERESPONSE._serialized_start=3237 - _ROUTERLEASERESPONSE._serialized_end=3339 - _POLICYROLE._serialized_start=3342 - _POLICYROLE._serialized_end=3506 - _HTTPGRANT._serialized_start=3508 - _HTTPGRANT._serialized_end=3568 - _EGRESSDESTINATION._serialized_start=3571 - _EGRESSDESTINATION._serialized_end=3860 - _INSPECTION._serialized_start=3862 - _INSPECTION._serialized_end=3919 - _INSPECTOR._serialized_start=3921 - _INSPECTOR._serialized_end=4032 - _MODELARMOR._serialized_start=4035 - _MODELARMOR._serialized_end=4180 - _EXTPROC._serialized_start=4183 - _EXTPROC._serialized_end=4447 - _EXTPROCPROCESSINGMODE._serialized_start=4450 - _EXTPROCPROCESSINGMODE._serialized_end=5089 - _EXTPROCPROCESSINGMODE_HEADERMODE._serialized_start=4934 - _EXTPROCPROCESSINGMODE_HEADERMODE._serialized_end=4991 - _EXTPROCPROCESSINGMODE_BODYMODE._serialized_start=4993 - _EXTPROCPROCESSINGMODE_BODYMODE._serialized_end=5089 - _CREDENTIALBROKER._serialized_start=5092 - _CREDENTIALBROKER._serialized_end=5317 - _OIDCFEDERATION._serialized_start=5319 - _OIDCFEDERATION._serialized_end=5414 - _AWSASSUMEROLE._serialized_start=5416 - _AWSASSUMEROLE._serialized_end=5473 - _PLATFORMIDENTITY._serialized_start=5475 - _PLATFORMIDENTITY._serialized_end=5509 - _POLICYBINDING._serialized_start=5511 - _POLICYBINDING._serialized_end=5557 - _POLICYCONFIG._serialized_start=5560 - _POLICYCONFIG._serialized_end=5711 - _POLICYCONFIGGETREQUEST._serialized_start=5713 - _POLICYCONFIGGETREQUEST._serialized_end=5737 - _POLICYCONFIGGETRESPONSE._serialized_start=5739 - _POLICYCONFIGGETRESPONSE._serialized_end=5787 - _POLICYCONFIGUPDATERESPONSE._serialized_start=5789 - _POLICYCONFIGUPDATERESPONSE._serialized_end=5849 - _EGRESSASSIGNMENTSREQUEST._serialized_start=5851 - _EGRESSASSIGNMENTSREQUEST._serialized_end=5877 - _EGRESSASSIGNMENTSRESPONSE._serialized_start=5879 - _EGRESSASSIGNMENTSRESPONSE._serialized_end=5955 - _KEYSRESPONSE._serialized_start=5957 - _KEYSRESPONSE._serialized_end=6059 - _TOKENREFRESHREQUEST._serialized_start=6061 - _TOKENREFRESHREQUEST._serialized_end=6168 - _TOKENREFRESHRESPONSE._serialized_start=6170 - _TOKENREFRESHRESPONSE._serialized_end=6287 - _NODECATALOGREPORT._serialized_start=6289 - _NODECATALOGREPORT._serialized_end=6353 - _TOKENREVOKEREQUEST._serialized_start=6355 - _TOKENREVOKEREQUEST._serialized_end=6392 - _TOKENREVOKERESPONSE._serialized_start=6394 - _TOKENREVOKERESPONSE._serialized_end=6447 - _BOOTSTRAPTOKENCREATEREQUEST._serialized_start=6450 - _BOOTSTRAPTOKENCREATEREQUEST._serialized_end=6600 - _BOOTSTRAPTOKENCREATERESPONSE._serialized_start=6603 - _BOOTSTRAPTOKENCREATERESPONSE._serialized_end=6741 - _BOOTSTRAPTOKEN._serialized_start=6744 - _BOOTSTRAPTOKEN._serialized_end=7043 - _BOOTSTRAPTOKENLISTRESPONSE._serialized_start=7045 - _BOOTSTRAPTOKENLISTRESPONSE._serialized_end=7119 - _ENROLLMENTREQUEST._serialized_start=7122 - _ENROLLMENTREQUEST._serialized_end=7464 + _ROUTERLEASEREQUEST._serialized_start=3181 + _ROUTERLEASEREQUEST._serialized_end=3440 + _ROUTERLEASERESPONSE._serialized_start=3442 + _ROUTERLEASERESPONSE._serialized_end=3544 + _POLICYROLE._serialized_start=3547 + _POLICYROLE._serialized_end=3711 + _HTTPGRANT._serialized_start=3713 + _HTTPGRANT._serialized_end=3773 + _EGRESSDESTINATION._serialized_start=3776 + _EGRESSDESTINATION._serialized_end=4065 + _INSPECTION._serialized_start=4067 + _INSPECTION._serialized_end=4124 + _INSPECTOR._serialized_start=4126 + _INSPECTOR._serialized_end=4237 + _MODELARMOR._serialized_start=4240 + _MODELARMOR._serialized_end=4385 + _EXTPROC._serialized_start=4388 + _EXTPROC._serialized_end=4652 + _EXTPROCPROCESSINGMODE._serialized_start=4655 + _EXTPROCPROCESSINGMODE._serialized_end=5294 + _EXTPROCPROCESSINGMODE_HEADERMODE._serialized_start=5139 + _EXTPROCPROCESSINGMODE_HEADERMODE._serialized_end=5196 + _EXTPROCPROCESSINGMODE_BODYMODE._serialized_start=5198 + _EXTPROCPROCESSINGMODE_BODYMODE._serialized_end=5294 + _CREDENTIALBROKER._serialized_start=5297 + _CREDENTIALBROKER._serialized_end=5522 + _OIDCFEDERATION._serialized_start=5524 + _OIDCFEDERATION._serialized_end=5619 + _AWSASSUMEROLE._serialized_start=5621 + _AWSASSUMEROLE._serialized_end=5678 + _PLATFORMIDENTITY._serialized_start=5680 + _PLATFORMIDENTITY._serialized_end=5714 + _POLICYBINDING._serialized_start=5716 + _POLICYBINDING._serialized_end=5762 + _POLICYCONFIG._serialized_start=5765 + _POLICYCONFIG._serialized_end=5916 + _POLICYCONFIGGETREQUEST._serialized_start=5918 + _POLICYCONFIGGETREQUEST._serialized_end=5942 + _POLICYCONFIGGETRESPONSE._serialized_start=5944 + _POLICYCONFIGGETRESPONSE._serialized_end=5992 + _POLICYCONFIGUPDATERESPONSE._serialized_start=5994 + _POLICYCONFIGUPDATERESPONSE._serialized_end=6054 + _EGRESSASSIGNMENTSREQUEST._serialized_start=6056 + _EGRESSASSIGNMENTSREQUEST._serialized_end=6082 + _EGRESSASSIGNMENTSRESPONSE._serialized_start=6084 + _EGRESSASSIGNMENTSRESPONSE._serialized_end=6160 + _KEYSRESPONSE._serialized_start=6162 + _KEYSRESPONSE._serialized_end=6264 + _TOKENREFRESHREQUEST._serialized_start=6266 + _TOKENREFRESHREQUEST._serialized_end=6373 + _TOKENREFRESHRESPONSE._serialized_start=6375 + _TOKENREFRESHRESPONSE._serialized_end=6492 + _NODECATALOGREPORT._serialized_start=6494 + _NODECATALOGREPORT._serialized_end=6558 + _TOKENREVOKEREQUEST._serialized_start=6560 + _TOKENREVOKEREQUEST._serialized_end=6597 + _TOKENREVOKERESPONSE._serialized_start=6599 + _TOKENREVOKERESPONSE._serialized_end=6652 + _BOOTSTRAPTOKENCREATEREQUEST._serialized_start=6655 + _BOOTSTRAPTOKENCREATEREQUEST._serialized_end=6805 + _BOOTSTRAPTOKENCREATERESPONSE._serialized_start=6808 + _BOOTSTRAPTOKENCREATERESPONSE._serialized_end=6946 + _BOOTSTRAPTOKEN._serialized_start=6949 + _BOOTSTRAPTOKEN._serialized_end=7248 + _BOOTSTRAPTOKENLISTRESPONSE._serialized_start=7250 + _BOOTSTRAPTOKENLISTRESPONSE._serialized_end=7324 + _ENROLLMENTREQUEST._serialized_start=7327 + _ENROLLMENTREQUEST._serialized_end=7669 _ENROLLMENTREQUEST_LABELSENTRY._serialized_start=825 _ENROLLMENTREQUEST_LABELSENTRY._serialized_end=870 - _ENROLLMENTREQUESTLISTRESPONSE._serialized_start=7466 - _ENROLLMENTREQUESTLISTRESPONSE._serialized_end=7548 - _USER._serialized_start=7550 - _USER._serialized_end=7662 - _ENROLLEDNODE._serialized_start=7665 - _ENROLLEDNODE._serialized_end=8020 + _ENROLLMENTREQUESTLISTRESPONSE._serialized_start=7671 + _ENROLLMENTREQUESTLISTRESPONSE._serialized_end=7753 + _USER._serialized_start=7755 + _USER._serialized_end=7867 + _ENROLLEDNODE._serialized_start=7870 + _ENROLLEDNODE._serialized_end=8225 _ENROLLEDNODE_LABELSENTRY._serialized_start=825 _ENROLLEDNODE_LABELSENTRY._serialized_end=870 - _ROUTERLEASE._serialized_start=8023 - _ROUTERLEASE._serialized_end=8219 - _NODESERVICES._serialized_start=8221 - _NODESERVICES._serialized_end=8329 - _ADMINSTATUSRESPONSE._serialized_start=8332 - _ADMINSTATUSRESPONSE._serialized_end=8807 - _ADMINSTATUSRESPONSE_NODECATALOGENTRY._serialized_start=8729 - _ADMINSTATUSRESPONSE_NODECATALOGENTRY._serialized_end=8807 - _USERSTATUSRESPONSE._serialized_start=8810 - _USERSTATUSRESPONSE._serialized_end=9067 - _IDENTITYEVIDENCERESPONSE._serialized_start=9070 - _IDENTITYEVIDENCERESPONSE._serialized_end=9298 - _PEEREVIDENCERESPONSE._serialized_start=9301 - _PEEREVIDENCERESPONSE._serialized_end=9627 + _ROUTERLEASE._serialized_start=8228 + _ROUTERLEASE._serialized_end=8424 + _NODESERVICES._serialized_start=8426 + _NODESERVICES._serialized_end=8534 + _ADMINSTATUSRESPONSE._serialized_start=8537 + _ADMINSTATUSRESPONSE._serialized_end=9012 + _ADMINSTATUSRESPONSE_NODECATALOGENTRY._serialized_start=8934 + _ADMINSTATUSRESPONSE_NODECATALOGENTRY._serialized_end=9012 + _USERSTATUSRESPONSE._serialized_start=9015 + _USERSTATUSRESPONSE._serialized_end=9272 + _IDENTITYEVIDENCERESPONSE._serialized_start=9275 + _IDENTITYEVIDENCERESPONSE._serialized_end=9503 + _PEEREVIDENCERESPONSE._serialized_start=9506 + _PEEREVIDENCERESPONSE._serialized_end=9832 _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_start=825 _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_end=870 - _MEMBERCREDENTIAL._serialized_start=9630 - _MEMBERCREDENTIAL._serialized_end=9898 - _TRUSTEDSIGNINGKEY._serialized_start=9900 - _TRUSTEDSIGNINGKEY._serialized_end=9989 - _OIDCSESSION._serialized_start=9991 - _OIDCSESSION._serialized_end=10080 - _TASKAUTHORIZATIONRULE._serialized_start=10083 - _TASKAUTHORIZATIONRULE._serialized_end=10230 - _TASKRULE._serialized_start=10233 - _TASKRULE._serialized_end=10365 - _TASKOPERATION._serialized_start=10367 - _TASKOPERATION._serialized_end=10482 - _TOKENEXCHANGEREQUEST._serialized_start=10485 - _TOKENEXCHANGEREQUEST._serialized_end=10656 - _TOKENEXCHANGERESPONSE._serialized_start=10658 - _TOKENEXCHANGERESPONSE._serialized_end=10785 - _STSTOKENREQUEST._serialized_start=10788 - _STSTOKENREQUEST._serialized_end=10917 - _STSTOKENRESPONSE._serialized_start=10920 - _STSTOKENRESPONSE._serialized_end=11051 - _REVOCATIONSRESPONSE._serialized_start=11053 - _REVOCATIONSRESPONSE._serialized_end=11123 + _MEMBERCREDENTIAL._serialized_start=9835 + _MEMBERCREDENTIAL._serialized_end=10103 + _TRUSTEDSIGNINGKEY._serialized_start=10105 + _TRUSTEDSIGNINGKEY._serialized_end=10194 + _OIDCSESSION._serialized_start=10196 + _OIDCSESSION._serialized_end=10285 + _TASKAUTHORIZATIONRULE._serialized_start=10288 + _TASKAUTHORIZATIONRULE._serialized_end=10435 + _TASKRULE._serialized_start=10438 + _TASKRULE._serialized_end=10570 + _TASKOPERATION._serialized_start=10572 + _TASKOPERATION._serialized_end=10687 + _TOKENEXCHANGEREQUEST._serialized_start=10690 + _TOKENEXCHANGEREQUEST._serialized_end=10861 + _TOKENEXCHANGERESPONSE._serialized_start=10863 + _TOKENEXCHANGERESPONSE._serialized_end=10990 + _STSTOKENREQUEST._serialized_start=10993 + _STSTOKENREQUEST._serialized_end=11122 + _STSTOKENRESPONSE._serialized_start=11125 + _STSTOKENRESPONSE._serialized_end=11256 + _REVOCATIONSRESPONSE._serialized_start=11258 + _REVOCATIONSRESPONSE._serialized_end=11328 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.pyi b/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.pyi index 2d228e22f..ca9ef4470 100644 --- a/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.pyi +++ b/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.pyi @@ -220,6 +220,20 @@ class CredentialBroker(_message.Message): static_secret: str def __init__(self, static_secret: _Optional[str] = ..., oidc_federation: _Optional[_Union[OIDCFederation, _Mapping]] = ..., aws_assume_role: _Optional[_Union[AWSAssumeRole, _Mapping]] = ..., platform_identity: _Optional[_Union[PlatformIdentity, _Mapping]] = ...) -> None: ... +class DirectoryFindPeer(_message.Message): + __slots__ = ["peer_id"] + PEER_ID_FIELD_NUMBER: _ClassVar[int] + peer_id: str + def __init__(self, peer_id: _Optional[str] = ...) -> None: ... + +class DirectoryFindProviders(_message.Message): + __slots__ = ["key", "limit"] + KEY_FIELD_NUMBER: _ClassVar[int] + LIMIT_FIELD_NUMBER: _ClassVar[int] + key: bytes + limit: int + def __init__(self, key: _Optional[bytes] = ..., limit: _Optional[int] = ...) -> None: ... + class DirectoryParameters(_message.Message): __slots__ = ["record_ttl", "replication", "reprovide_interval"] RECORD_TTL_FIELD_NUMBER: _ClassVar[int] @@ -230,6 +244,36 @@ class DirectoryParameters(_message.Message): reprovide_interval: _duration_pb2.Duration def __init__(self, replication: _Optional[int] = ..., reprovide_interval: _Optional[_Union[_duration_pb2.Duration, _Mapping]] = ..., record_ttl: _Optional[_Union[_duration_pb2.Duration, _Mapping]] = ...) -> None: ... +class DirectoryProvide(_message.Message): + __slots__ = ["addrs", "key", "provider"] + ADDRS_FIELD_NUMBER: _ClassVar[int] + KEY_FIELD_NUMBER: _ClassVar[int] + PROVIDER_FIELD_NUMBER: _ClassVar[int] + addrs: _containers.RepeatedScalarFieldContainer[str] + key: bytes + provider: str + def __init__(self, key: _Optional[bytes] = ..., addrs: _Optional[_Iterable[str]] = ..., provider: _Optional[str] = ...) -> None: ... + +class DirectoryRequest(_message.Message): + __slots__ = ["find_peer", "find_providers", "provide"] + FIND_PEER_FIELD_NUMBER: _ClassVar[int] + FIND_PROVIDERS_FIELD_NUMBER: _ClassVar[int] + PROVIDE_FIELD_NUMBER: _ClassVar[int] + find_peer: DirectoryFindPeer + find_providers: DirectoryFindProviders + provide: DirectoryProvide + def __init__(self, provide: _Optional[_Union[DirectoryProvide, _Mapping]] = ..., find_providers: _Optional[_Union[DirectoryFindProviders, _Mapping]] = ..., find_peer: _Optional[_Union[DirectoryFindPeer, _Mapping]] = ...) -> None: ... + +class DirectoryResponse(_message.Message): + __slots__ = ["error", "peer", "providers"] + ERROR_FIELD_NUMBER: _ClassVar[int] + PEER_FIELD_NUMBER: _ClassVar[int] + PROVIDERS_FIELD_NUMBER: _ClassVar[int] + error: str + peer: PeerRecord + providers: _containers.RepeatedCompositeFieldContainer[PeerRecord] + def __init__(self, providers: _Optional[_Iterable[_Union[PeerRecord, _Mapping]]] = ..., peer: _Optional[_Union[PeerRecord, _Mapping]] = ..., error: _Optional[str] = ...) -> None: ... + class DiscoveredProvider(_message.Message): __slots__ = ["local_proxy_url", "peer_id", "srv_description", "srv_name"] LOCAL_PROXY_URL_FIELD_NUMBER: _ClassVar[int] @@ -591,6 +635,14 @@ class PeerEvidenceResponse(_message.Message): verifying_key: bytes def __init__(self, peer_id: _Optional[str] = ..., biscuit: _Optional[bytes] = ..., verifying_key: _Optional[bytes] = ..., roles: _Optional[_Iterable[str]] = ..., labels: _Optional[_Mapping[str, str]] = ..., expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., revocation_ids: _Optional[_Iterable[str]] = ..., check_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... +class PeerRecord(_message.Message): + __slots__ = ["addrs", "peer_id"] + ADDRS_FIELD_NUMBER: _ClassVar[int] + PEER_ID_FIELD_NUMBER: _ClassVar[int] + addrs: _containers.RepeatedScalarFieldContainer[str] + peer_id: str + def __init__(self, peer_id: _Optional[str] = ..., addrs: _Optional[_Iterable[str]] = ...) -> None: ... + class PlatformIdentity(_message.Message): __slots__ = ["scopes"] SCOPES_FIELD_NUMBER: _ClassVar[int] @@ -779,33 +831,6 @@ class STSTokenResponse(_message.Message): task_name: str def __init__(self, jwt: _Optional[str] = ..., expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., subject: _Optional[str] = ..., roles: _Optional[_Iterable[str]] = ..., task_name: _Optional[str] = ...) -> None: ... -class ServiceAnnounce(_message.Message): - __slots__ = ["active_requests", "announce_time", "keys", "labels", "latency_ewma_ms", "peer_id", "service_name", "type"] - class LabelsEntry(_message.Message): - __slots__ = ["key", "value"] - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: str - def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... - ACTIVE_REQUESTS_FIELD_NUMBER: _ClassVar[int] - ANNOUNCE_TIME_FIELD_NUMBER: _ClassVar[int] - KEYS_FIELD_NUMBER: _ClassVar[int] - LABELS_FIELD_NUMBER: _ClassVar[int] - LATENCY_EWMA_MS_FIELD_NUMBER: _ClassVar[int] - PEER_ID_FIELD_NUMBER: _ClassVar[int] - SERVICE_NAME_FIELD_NUMBER: _ClassVar[int] - TYPE_FIELD_NUMBER: _ClassVar[int] - active_requests: int - announce_time: _timestamp_pb2.Timestamp - keys: _containers.RepeatedScalarFieldContainer[str] - labels: _containers.ScalarMap[str, str] - latency_ewma_ms: float - peer_id: str - service_name: str - type: ServiceType - def __init__(self, peer_id: _Optional[str] = ..., type: _Optional[_Union[ServiceType, str]] = ..., service_name: _Optional[str] = ..., keys: _Optional[_Iterable[str]] = ..., labels: _Optional[_Mapping[str, str]] = ..., active_requests: _Optional[int] = ..., latency_ewma_ms: _Optional[float] = ..., announce_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... - class ServiceInfo(_message.Message): __slots__ = ["description", "name", "type"] DESCRIPTION_FIELD_NUMBER: _ClassVar[int] diff --git a/sdk/python/src/agent_mesh/discovery.py b/sdk/python/src/agent_mesh/discovery.py index 272fa4ec2..06e9bb565 100644 --- a/sdk/python/src/agent_mesh/discovery.py +++ b/sdk/python/src/agent_mesh/discovery.py @@ -12,67 +12,73 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Service discovery as agentmesh-node does it (internal/node/service.go): a provider -record in the mesh DHT under a key derived from the service. The lookup is a -bounded Kademlia GET_PROVIDERS walk, and a peer lookup a FIND_NODE one, -spoken directly on go-libp2p-kad-dht's protocol: py-libp2p's DHT client -(0.8 included) hardcodes the /ipfs prefix and the mesh uses /mesh. An SDK -member only looks records up; it announces none.""" +"""The mesh's directory as a member uses it, as agentmesh-node does +(internal/node/directory_client.go): the member keeps no DHT and asks the +routers it holds over /mesh/directory/1.0.0, one DirectoryRequest per +stream, one DirectoryResponse back. A request goes to one held router and +to the next when it fails. The routers run the lookup across their own DHT. +A key is the sha256 multihash of "mesh:service:[:]" for a +service and of "mesh:peer:" for a member.""" from __future__ import annotations import hashlib import logging from dataclasses import dataclass, field -from typing import Awaitable, Callable, Iterable, Literal +from typing import Iterable, Literal import multiaddr import trio from libp2p.abc import IHost from libp2p.custom_types import TProtocol -from libp2p.kad_dht.pb import kademlia_pb2 as kad from libp2p.peer.id import ID -from libp2p.peer.peerinfo import PeerInfo from libp2p.utils.varint import encode_varint_prefixed +from ._proto import agentmesh_pb2 as pb from .auth import read_bounded_varint_prefixed_bytes -from .host import dial, open_stream +from .host import open_stream logger = logging.getLogger("agent_mesh") -# go-libp2p-kad-dht with dht.ProtocolPrefix("/mesh"). -DHT_PROTOCOL = TProtocol("/mesh/kad/1.0.0") +# api.DirectoryProtocolID. +DIRECTORY_PROTOCOL = TProtocol("/mesh/directory/1.0.0") ServiceType = Literal["mcp", "inference", "a2a", "egress"] -_QUERY_TIMEOUT = 5.0 -_MAX_ROUNDS = 3 -_MAX_PEERS_PER_ROUND = 8 -MAX_DHT_MESSAGE_BYTES = 256 * 1024 - -# How many routers hold each record and answer each lookup when the control -# plane publishes no directory parameters on /info: api.DefaultDHTReplication. -# A member reads the mesh's value from /info (Mesh.dht_replication) and -# follows that many of the closer routers an answer names, as agentmesh-node -# asks that many at a time. -DEFAULT_DHT_REPLICATION = 3 +_REQUEST_TIMEOUT = 10.0 +MAX_DIRECTORY_MESSAGE_BYTES = 64 * 1024 +DEFAULT_LIMIT = 20 @dataclass class DiscoveredProvider: - """A peer the DHT names as offering a service.""" + """A peer the directory names as offering a service.""" peer_id: str - # Addresses the provider advertised; may be empty when the record carried none. + # Addresses the provider is reached at, each ending in its peer ID; may + # be empty when the record carried none. addrs: list[str] = field(default_factory=list) +class DirectoryError(Exception): + """No held router answered a directory request.""" + + +def _key(name: str) -> bytes: + """The sha256 multihash of a name: the two-byte header and the digest.""" + return b"\x12\x20" + hashlib.sha256(name.encode()).digest() + + def service_key(service_type: ServiceType, name: str | None = None) -> bytes: - """The DHT key of a service: the sha256 multihash of "mesh:service:[:]". - go-libp2p-kad-dht keys provider records by the multihash, not the CID.""" + """The directory key of a service type, or of one named service of that + type: "mesh:service:" or "mesh:service::" (api.ServiceKey).""" parts = ["mesh:service", service_type] + ([name] if name else []) - digest = hashlib.sha256(":".join(parts).encode()).digest() - return b"\x12\x20" + digest + return _key(":".join(parts)) + + +def peer_key(peer_id: ID) -> bytes: + """The directory key a member announces where it is reached under (api.PeerKey).""" + return _key(f"mesh:peer:{peer_id}") def parse_service_target(target: str) -> tuple[str, str]: @@ -85,188 +91,72 @@ def parse_service_target(target: str) -> tuple[str, str]: return scheme, name -def _peer_infos(peers: Iterable[kad.Message.Peer]) -> list[PeerInfo]: - out = [] - for p in peers: - try: - addrs = [multiaddr.Multiaddr(a) for a in p.addrs] - out.append(PeerInfo(ID(p.id), addrs)) - except Exception: # noqa: BLE001 - a malformed entry from a peer is dropped, not fatal - continue - return out - - -async def _query(host: IHost, peer_id: ID, req: kad.Message) -> kad.Message | None: - """One request to a peer's DHT, answered or None: a peer that does not - serve the DHT, or does not answer in time, is skipped.""" - try: - stream = await open_stream(host, peer_id, DHT_PROTOCOL, _QUERY_TIMEOUT) - except Exception as err: # noqa: BLE001 - a peer that does not serve the DHT is skipped - logger.debug("dht: %s does not answer %s: %s", peer_id, DHT_PROTOCOL, err) - return None +async def _ask(host: IHost, router: ID, req: pb.DirectoryRequest) -> pb.DirectoryResponse: + """One request to one router, answered or raised.""" + stream = await open_stream(host, router, DIRECTORY_PROTOCOL, _REQUEST_TIMEOUT) try: - with trio.fail_after(_QUERY_TIMEOUT): + with trio.fail_after(_REQUEST_TIMEOUT): await stream.write(encode_varint_prefixed(req.SerializeToString())) - resp = kad.Message.FromString(await read_bounded_varint_prefixed_bytes(stream, MAX_DHT_MESSAGE_BYTES)) - except Exception as err: # noqa: BLE001 - logger.debug("dht: query to %s failed: %s", peer_id, err) - return None + resp = pb.DirectoryResponse.FromString(await read_bounded_varint_prefixed_bytes(stream, MAX_DIRECTORY_MESSAGE_BYTES)) finally: await stream.close() - return resp if resp.type == req.type else None - - -Admit = Callable[[ID], Awaitable[None]] -"""Authenticates this member with a router before it is queried: a router -serves its DHT to a peer only once that peer passed the handshake.""" - - -async def find_peer( - host: IHost, - target: ID, - seeds: Iterable[ID], - replication: int = DEFAULT_DHT_REPLICATION, - admit: Admit | None = None, -) -> list[multiaddr.Multiaddr]: - """The addresses the DHT knows for a peer, as go-libp2p-kad-dht's FindPeer - learns them: a router answers FIND_NODE for a peer it is connected to with - that peer and its addresses, its relayed one included, and names the - routers closer to it otherwise. Empty when nobody knows the peer.""" - found: list[multiaddr.Multiaddr] = [] - - def take(resp: kad.Message) -> bool: - for info in _peer_infos(resp.closerPeers): - if info.peer_id == target and info.addrs: - found.extend(info.addrs) - return True - return False - - await _walk(host, kad.Message(type=kad.Message.FIND_NODE, key=target.to_bytes()), seeds, take, skip={target}, replication=replication, admit=admit) - return found - - -async def find_providers( - host: IHost, - key: bytes, - seeds: Iterable[ID], - limit: int = 20, - replication: int = DEFAULT_DHT_REPLICATION, - admit: Admit | None = None, -) -> list[DiscoveredProvider]: - """Asks the seed peers (the routers we are connected to) for providers of - key and follows the closer peers they name for a few rounds.""" + if resp.error: + raise DirectoryError(resp.error) + return resp + + +async def request(host: IHost, routers: Iterable[ID], req: pb.DirectoryRequest) -> pb.DirectoryResponse: + """Sends req to the held routers in turn until one answers. A router that + is restarting costs the request a timeout, not an answer.""" + last: Exception | None = None + asked = 0 + for router in routers: + asked += 1 + try: + return await _ask(host, router, req) + except Exception as err: # noqa: BLE001 - the next router is asked + last = err + logger.warning("directory: request through router %s failed: %s", router, err) + if asked == 0: + raise DirectoryError("no held router to ask") + raise DirectoryError(f"no held router answered: {last}") + + +async def provide(host: IHost, routers: Iterable[ID], key: bytes, addrs: Iterable[multiaddr.Multiaddr]) -> None: + """Announces this member as a provider of key, reached at addrs, to a + held router; the router keeps the record and places it on the routers + closest to the key.""" + req = pb.DirectoryRequest(provide=pb.DirectoryProvide(key=key, addrs=[str(a) for a in addrs])) + await request(host, routers, req) + + +async def find_providers(host: IHost, routers: Iterable[ID], key: bytes, limit: int = DEFAULT_LIMIT) -> list[DiscoveredProvider]: + """Who provides key, up to limit, with their addresses; empty when nobody does.""" + req = pb.DirectoryRequest(find_providers=pb.DirectoryFindProviders(key=key, limit=max(limit, 1))) + resp = await request(host, routers, req) found: dict[str, DiscoveredProvider] = {} - self_id = host.get_id() - - def take(resp: kad.Message) -> bool: - for info in _peer_infos(resp.providerPeers): - if info.peer_id == self_id: - continue - entry = found.setdefault(str(info.peer_id), DiscoveredProvider(peer_id=str(info.peer_id))) - for ma in info.addrs: - if str(ma) not in entry.addrs: - entry.addrs.append(str(ma)) - return bool(found) - - await _walk(host, kad.Message(type=kad.Message.GET_PROVIDERS, key=key), seeds, take, replication=replication, admit=admit) + self_id = str(host.get_id()) + for rec in resp.providers: + if rec.peer_id == self_id: + continue + entry = found.setdefault(rec.peer_id, DiscoveredProvider(peer_id=rec.peer_id)) + for a in rec.addrs: + if a not in entry.addrs: + entry.addrs.append(a) return list(found.values())[:limit] -async def _walk( - host: IHost, - req: kad.Message, - seeds: Iterable[ID], - take: Callable[[kad.Message], bool], - skip: frozenset[ID] | set[ID] = frozenset(), - replication: int = DEFAULT_DHT_REPLICATION, - admit: Admit | None = None, -) -> None: - """The Kademlia walk both lookups share. A round asks its peers at once - and ends when an answer satisfies `take`; only otherwise are the closer - peers the answers named dialed, at once as well. A round thus costs one - query and one dial timeout, not one per peer: right after a rollout the - routers' tables still name the pods it replaced, and the control plane - may list a router this member cannot reach. A peer that did not answer - its dial is not dialed again. A router names the routers closest to the - key in order, and a record lives on the `replication` closest, so only - that many of them are followed from one round to the next. - - A router serves a query only from a member that authenticated with it, - so `admit` runs before each query. A router the walk connected to for - the query is disconnected when the walk ends: the member keeps sessions - with the routers it holds and with no other.""" - self_id = host.get_id() - held = set(host.get_connected_peers()) - try: - await _walk_rounds(host, req, seeds, take, skip, replication, admit, self_id) - finally: - for peer_id in set(host.get_connected_peers()) - held - {self_id}: - try: - await host.disconnect(peer_id) - except Exception as err: # noqa: BLE001 - already gone - logger.debug("dht: disconnect from %s after the walk: %s", peer_id, err) - - -async def _walk_rounds( - host: IHost, - req: kad.Message, - seeds: Iterable[ID], - take: Callable[[kad.Message], bool], - skip: frozenset[ID] | set[ID], - replication: int, - admit: Admit | None, - self_id: ID, -) -> None: - asked: set[ID] = set() - frontier: list[ID] = list(seeds) - fanout = min(max(replication, 1), _MAX_PEERS_PER_ROUND) - for _ in range(_MAX_ROUNDS): - batch = [p for p in frontier if p not in asked and p != self_id][:_MAX_PEERS_PER_ROUND] - if not batch: - return - asked.update(batch) - answers: list[kad.Message] = [] - - async def ask(peer_id: ID) -> None: - if admit is not None: - try: - await admit(peer_id) - except Exception as err: # noqa: BLE001 - a router that refuses us is skipped - logger.debug("dht: %s did not admit this member: %s", peer_id, err) - return - resp = await _query(host, peer_id, req) - if resp is not None: - answers.append(resp) - - async with trio.open_nursery() as nursery: - for peer_id in batch: - nursery.start_soon(ask, peer_id) - # Every answer is taken: the providers each router knows add up. - satisfied = False - for resp in answers: - satisfied = take(resp) or satisfied - if satisfied: - return - - closer: dict[ID, PeerInfo] = {} - for resp in answers: - for info in _peer_infos(resp.closerPeers): - if len(closer) >= fanout: - break - if info.peer_id not in asked and info.peer_id != self_id and info.peer_id not in skip: - closer.setdefault(info.peer_id, info) - connected = set(host.get_connected_peers()) - frontier = [p for p in closer if p in connected] - - async def reach(info: PeerInfo) -> None: - try: - await dial(host, info) - except Exception: # noqa: BLE001 - unreachable closer peers are skipped - asked.add(info.peer_id) - return - frontier.append(info.peer_id) - - async with trio.open_nursery() as nursery: - for peer_id, info in closer.items(): - if peer_id not in connected: - nursery.start_soon(reach, info) +async def find_peer(host: IHost, routers: Iterable[ID], target: ID) -> list[multiaddr.Multiaddr]: + """Where target is reached: its addresses, each ending in its peer ID, + relayed ones included. Empty when nobody knows the peer.""" + req = pb.DirectoryRequest(find_peer=pb.DirectoryFindPeer(peer_id=str(target))) + resp = await request(host, routers, req) + if not resp.HasField("peer") or resp.peer.peer_id != str(target): + return [] + out: list[multiaddr.Multiaddr] = [] + for a in resp.peer.addrs: + try: + out.append(multiaddr.Multiaddr(a)) + except Exception: # noqa: BLE001 - a malformed address from a router is dropped + logger.debug("directory: dropping address %r of %s", a, target) + return out diff --git a/sdk/python/src/agent_mesh/mesh.py b/sdk/python/src/agent_mesh/mesh.py index a71328379..392fed4a2 100644 --- a/sdk/python/src/agent_mesh/mesh.py +++ b/sdk/python/src/agent_mesh/mesh.py @@ -24,7 +24,6 @@ from ._proto import agentmesh_pb2 as pb from .controlplane import ROLE_NODE, ControlPlaneClient, Enrollment, KeysNotTrustedError, Transport from .credential import MeshCredential, encode_auth_frame -from .discovery import DEFAULT_DHT_REPLICATION from .identity import Identity _IDENTITY_FILE = "identity.key" @@ -99,7 +98,6 @@ def __init__( self.control_plane = control_plane self._credential = credential self._routers: list[pb.RouterInfo] = [] - self._dht_replication = DEFAULT_DHT_REPLICATION self._state_dir = state_dir self._jwt_source = jwt_source # refresh() and sync_control_plane() run in worker threads of the @@ -120,13 +118,6 @@ def routers(self) -> list[pb.RouterInfo]: """The router catalog (ControlPlaneInfoResponse.routers) from the most recent /info pull.""" return list(self._routers) - @property - def dht_replication(self) -> int: - """How many routers hold each record and answer each lookup on this mesh, - as /info last published it (ControlPlaneInfoResponse.directory); - DEFAULT_DHT_REPLICATION until the control plane has answered or when it - publishes none.""" - return self._dht_replication def attenuate(self, rule: pb.TaskAuthorizationRule) -> MeshCredential: """Returns a new MeshCredential with a tar_block appended offline in memory.""" @@ -326,8 +317,6 @@ def _sync_control_plane(self) -> ControlPlaneSync: info = self.control_plane.info() routers = list(info.routers) self._routers = routers - if info.HasField("directory") and info.directory.replication >= 1: - self._dht_replication = info.directory.replication if info.router_addresses: self._credential = replace(self._credential, router_addresses=list(info.router_addresses)) elif routers: diff --git a/sdk/python/src/agent_mesh/session.py b/sdk/python/src/agent_mesh/session.py index 6dc35aebe..73fda5a60 100644 --- a/sdk/python/src/agent_mesh/session.py +++ b/sdk/python/src/agent_mesh/session.py @@ -27,10 +27,8 @@ from libp2p.abc import IHost from libp2p.peer.id import ID from libp2p.peer.peerinfo import PeerInfo, info_from_p2p_addr -from libp2p.pubsub.gossipsub import PROTOCOL_ID as GOSSIPSUB_V10 -from libp2p.pubsub.gossipsub import PROTOCOL_ID_V11 as GOSSIPSUB_V11 -from libp2p.pubsub.gossipsub import PROTOCOL_ID_V12 as GOSSIPSUB_V12 -from libp2p.pubsub.gossipsub import GossipSub +from libp2p.pubsub.floodsub import PROTOCOL_ID as FLOODSUB +from libp2p.pubsub.floodsub import FloodSub from libp2p.pubsub.pubsub import Pubsub from libp2p.tools.anyio_service import background_trio_service from mcp import ClientSession @@ -125,13 +123,13 @@ def canonical_peer_ids(ids: Sequence[str]) -> list[str]: class _MeshsubNoise(logging.Filter): - """py-libp2p's pubsub opens a meshsub stream to every new peer and the host - logs an error for each one that does not answer. Peers that leave during - that exchange and members without pubsub are ordinary on the mesh.""" + """py-libp2p's pubsub opens a stream to every new peer and the host logs + an error for each one that does not answer. Peers that leave during that + exchange and members without pubsub are ordinary on the mesh.""" def filter(self, record: logging.LogRecord) -> bool: message = record.getMessage() - return not ("Failed to open stream" in message and "/meshsub/" in message) + return not ("Failed to open stream" in message and ("/meshsub/" in message or "/floodsub/" in message)) _MESHSUB_NOISE = _MeshsubNoise() @@ -577,15 +575,14 @@ def _unjoined_routers(self, target: ID) -> list[multiaddr.Multiaddr]: return out async def _routed_addresses(self, target: ID) -> tuple[list[multiaddr.Multiaddr], list[multiaddr.Multiaddr]]: - """What the routers' DHT knows for a peer: its direct addresses this + """What the directory knows for a peer: its direct addresses this host can dial, without the peer suffix, and the relays it reserved on that are not admitted routers of this member; those the caller tried already.""" direct: list[multiaddr.Multiaddr] = [] relays: list[multiaddr.Multiaddr] = [] - seeds = [ID.from_base58(r.peer_id) for r in self.routers] suffix = f"/p2p/{target}" - for ma in await find_peer(self.host, target, seeds, self.mesh.dht_replication, admit=self._admit_for_lookup): + for ma in await find_peer(self.host, self._directory_routers(), target): text = str(ma) if "/p2p-circuit" not in text: try: @@ -603,14 +600,9 @@ async def _routed_addresses(self, target: ID) -> tuple[list[multiaddr.Multiaddr] relays.append(relay_addr) return direct, relays - async def _admit_for_lookup(self, peer_id: ID) -> None: - """Runs the handshake with a router a lookup is about to query. A - router this member holds is authenticated already; any other serves - the query only once this member authenticated on the connection the - walk just opened, and the walk closes that connection when it ends.""" - if any(r.peer_id == str(peer_id) for r in self.routers): - return - await _authenticate_router(self.host, self.mesh, peer_id) + def _directory_routers(self) -> list[ID]: + """The routers this member holds, which answer its directory requests.""" + return [ID.from_base58(r.peer_id) for r in self.routers] async def _admit_router(self, addr: multiaddr.Multiaddr) -> AdmittedRouter: """Dials a router, runs the handshake and, its role verified, adds it @@ -650,15 +642,14 @@ async def authenticate(self, peer: Peer) -> VerifiedBiscuit: return await authenticate_with_peer(self.host, peer_id, self.mesh.auth_frame(), self.mesh.credential.control_plane_keys) async def discover(self, service: str, name: str | None = None, limit: int = 20) -> list[DiscoveredProvider]: - """Looks the DHT up for peers offering a service: "mcp://calc", the same - string call_tool and request take, or a type alone ("mcp") for every - service of that type, or (type, name). The routers we are connected to - seed the walk.""" + """Asks the directory for peers offering a service: "mcp://calc", the + same string call_tool and request take, or a type alone ("mcp") for + every service of that type, or (type, name). A router this member + holds answers.""" service_type, service_name = parse_service_target(service) if "://" in service else (service, name) if service_type not in ("mcp", "inference", "a2a", "egress"): raise ValueError(f"service type must be mcp, inference, a2a or egress, got {service_type!r}") - seeds = [ID.from_base58(r.peer_id) for r in self.routers] - return await find_providers(self.host, service_key(service_type, service_name), seeds, limit, self.mesh.dht_replication, admit=self._admit_for_lookup) + return await find_providers(self.host, self._directory_routers(), service_key(service_type, service_name), limit) def open_mcp( self, @@ -1089,13 +1080,15 @@ async def join_mesh( ) host.set_stream_handler(STOP_PROTOCOL, stop_stream_handler(host)) # Built before any connection: Pubsub learns of peers through a notifee it - # registers here. StrictSign, as every Go component pins it. - gossipsub = GossipSub(protocols=[GOSSIPSUB_V12, GOSSIPSUB_V11, GOSSIPSUB_V10], degree=6, degree_low=4, degree_high=12) - pubsub = Pubsub(host, gossipsub, strict_signing=True) + # registers here. StrictSign, as every Go component pins it. A member is + # a leaf of the gossip: it speaks floodsub, so a router delivers every + # event to it outright and it takes no part in the routers' gossipsub mesh. + floodsub = FloodSub(protocols=[FLOODSUB]) + pubsub = Pubsub(host, floodsub, strict_signing=True) logging.getLogger("libp2p.host.basic_host").addFilter(_MESHSUB_NOISE) try: - async with host.run(listen_addrs=listen), background_trio_service(pubsub), background_trio_service(gossipsub): + async with host.run(listen_addrs=listen), background_trio_service(pubsub): await pubsub.wait_until_ready() admitted = await _admit(host, mesh, ordered, reserve, target_count=target_count) async with trio.open_nursery() as nursery: diff --git a/sdk/python/tests/test_discovery.py b/sdk/python/tests/test_discovery.py index 9b7a27afa..9e9d5b713 100644 --- a/sdk/python/tests/test_discovery.py +++ b/sdk/python/tests/test_discovery.py @@ -12,34 +12,29 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""The DHT walks against a fake host: what they do with the closer peers a -router names. The wire format against a real router is covered by -tests/integration/sdk_mesh_test.go.""" +"""The directory client: a request goes to a held router, to the next when +that one fails, and the answer is what the router said.""" + +import hashlib -import multiaddr import pytest import trio import trio.testing -from libp2p.kad_dht.pb import kademlia_pb2 as kad from libp2p.peer.id import ID -from libp2p.peer.peerstore import PeerStore from libp2p.utils.varint import encode_varint_prefixed, read_varint_prefixed_bytes -from agent_mesh.discovery import DHT_PROTOCOL, _QUERY_TIMEOUT, find_peer, find_providers -from agent_mesh.host import DIAL_TIMEOUT +from agent_mesh._proto import agentmesh_pb2 as pb +from agent_mesh.discovery import DIRECTORY_PROTOCOL, _REQUEST_TIMEOUT, DirectoryError, find_peer, find_providers, peer_key, provide, service_key SELF = ID.from_base58("12D3KooWJoJPhXqLrGAcVsGSzHMzWrmoHCWoTzVSSiT3JfR7RBVo") ROUTER = ID.from_base58("12D3KooWG1pA6goegCncqwbZLSr8pnjUZ6JMAAe6SmnHTgUNCk88") ROUTER_B = ID.from_base58("12D3KooWBTdQ3QQZztZFaxQSTzJx5ZSbpgM8zfs43VYzBXAFkdZm") -DARK_ROUTER = ID.from_base58("12D3KooWGvdRCJLYATauVWfsieF2j3a2wXZoEQJUS2MsvRdDtgLM") TARGET = ID.from_base58("12D3KooWQYhTNQdmr3ArTeUHRYzFg94BKyTkoWBDWez9kSCVe2Xo") PROVIDER = ID.from_base58("12D3KooWPZCZBAZRx1TNpPt19sZ7PKcdce8rE7gNGwhzLJtWXU9u") -GONE_POD = ID.from_base58("12D3KooWEETCw8Eb2fGFfenvEEMWhvKCe9fThJeSG8oNGrpPGp1r") -GONE_POD_2 = ID.from_base58("12D3KooWKmPdCr3BcvPAsP5mAhraWvKwCBT3M58mWe5f7TrFHSRT") class _Stream: - """One kad request answered from a table, as a router's DHT would.""" + """One directory request answered by a function, as a router would.""" def __init__(self, answer): self._answer = answer @@ -51,7 +46,7 @@ async def write(self, data): async def read(self, n=None): if self._req: - req = kad.Message.FromString(await read_varint_prefixed_bytes(_Bytes(self._req))) + req = pb.DirectoryRequest.FromString(await read_varint_prefixed_bytes(_Bytes(self._req))) self._req = b"" self._resp = encode_varint_prefixed(self._answer(req).SerializeToString()) n = len(self._resp) if n is None else n @@ -73,160 +68,128 @@ async def read(self, n=None): class _Host: - """Connected to its routers; any other peer a router names never - answers a SYN.""" + """Connected to its routers, each answering with a function; a router + that is None never answers a stream open.""" def __init__(self, answers): self._answers = answers - self.connected = set(answers) - self.dials = [] - self.peerstore = PeerStore() + self.asked = [] def get_id(self): return SELF - def get_peerstore(self): - return self.peerstore - - def get_network(self): - return None - - def get_connected_peers(self): - return list(self.connected) - - async def connect(self, info): - self.dials.append(info.peer_id) - await trio.sleep_forever() - async def new_stream(self, peer_id, protocols): - assert protocols == [DHT_PROTOCOL] - assert peer_id in self.connected - return _Stream(self._answers[peer_id]) + assert protocols == [DIRECTORY_PROTOCOL] + self.asked.append(peer_id) + answer = self._answers[peer_id] + if answer is None: + await trio.sleep_forever() + return _Stream(answer) - async def disconnect(self, peer_id): - self.connected.discard(peer_id) +def _record(peer_id, *addrs): + return pb.PeerRecord(peer_id=str(peer_id), addrs=[f"{a}/p2p/{peer_id}" for a in addrs]) -def _peer(peer_id, *addrs): - return kad.Message.Peer(id=peer_id.to_bytes(), addrs=[multiaddr.Multiaddr(a).to_bytes() for a in addrs]) +def test_keys_are_the_sha256_multihash_of_the_name(): + """The same bytes api.ServiceKey and api.PeerKey produce, so a record a + Go member announced is found from Python and the other way round.""" + want = b"\x12\x20" + hashlib.sha256(b"mesh:service:mcp:calculator").digest() + assert service_key("mcp", "calculator") == want + assert service_key("mcp") == b"\x12\x20" + hashlib.sha256(b"mesh:service:mcp").digest() + assert peer_key(TARGET) == b"\x12\x20" + hashlib.sha256(f"mesh:peer:{TARGET}".encode()).digest() + assert service_key("mcp") != service_key("mcp", "calculator") -def _names_the_dark_router(req): - return kad.Message(type=req.type, closerPeers=[_peer(DARK_ROUTER, "/ip4/203.0.113.7/tcp/4501")]) +def test_find_providers_asks_one_held_router_and_takes_its_answer(): + def knows_provider(req): + assert req.WhichOneof("request") == "find_providers" + assert req.find_providers.key == service_key("mcp", "calculator") + return pb.DirectoryResponse(providers=[_record(PROVIDER, "/ip4/10.0.0.9/tcp/4001"), _record(SELF, "/ip4/10.0.0.1/tcp/1")]) -def test_find_providers_dials_an_unanswering_closer_peer_once(): - """A router the control plane lists may be dark from where a member runs - (a public address a network policy drops). Every router names it as - closer to every key; the walk dials it once, bounded, then moves on.""" - host = _Host({ROUTER: _names_the_dark_router}) + host = _Host({ROUTER: knows_provider, ROUTER_B: knows_provider}) async def main(): - started = trio.current_time() - assert await find_providers(host, b"key", [ROUTER]) == [] - assert host.dials == [DARK_ROUTER] - assert trio.current_time() - started == pytest.approx(DIAL_TIMEOUT) - - trio.run(main, clock=trio.testing.MockClock(autojump_threshold=0)) + found = await find_providers(host, [ROUTER, ROUTER_B], service_key("mcp", "calculator")) + assert [p.peer_id for p in found] == [str(PROVIDER)], "the member itself is left out" + assert found[0].addrs == [f"/ip4/10.0.0.9/tcp/4001/p2p/{PROVIDER}"] + assert host.asked == [ROUTER], "one router answers; the second is not asked" + trio.run(main) -def test_find_peer_dials_an_unanswering_closer_peer_once(): - host = _Host({ROUTER: _names_the_dark_router}) - - async def main(): - started = trio.current_time() - assert await find_peer(host, TARGET, [ROUTER]) == [] - assert host.dials == [DARK_ROUTER] - assert trio.current_time() - started == pytest.approx(DIAL_TIMEOUT) - - trio.run(main, clock=trio.testing.MockClock(autojump_threshold=0)) +def test_a_router_that_fails_is_skipped_for_the_next(): + """A router that does not answer costs the request one timeout; a router + that answers with an error is skipped at once. Only when every held + router fails does the request fail.""" -def test_find_peer_returns_the_target_a_router_knows(): - def knows_target(req): - return kad.Message( - type=req.type, - closerPeers=[_peer(TARGET, "/ip4/10.0.0.9/tcp/4001"), _peer(DARK_ROUTER, "/ip4/203.0.113.7/tcp/4501")], - ) + def errors(req): + return pb.DirectoryResponse(error="the directory is not up") - host = _Host({ROUTER: knows_target}) + def answers(req): + return pb.DirectoryResponse(providers=[_record(PROVIDER, "/ip4/10.0.0.9/tcp/4001")]) async def main(): - addrs = await find_peer(host, TARGET, [ROUTER]) - assert [str(a) for a in addrs] == ["/ip4/10.0.0.9/tcp/4001"] - assert host.dials == [] - - trio.run(main, clock=trio.testing.MockClock(autojump_threshold=0)) - + host = _Host({ROUTER: None, ROUTER_B: answers}) + started = trio.current_time() + found = await find_providers(host, [ROUTER, ROUTER_B], b"\x12\x20" + bytes(32)) + assert [p.peer_id for p in found] == [str(PROVIDER)] + assert host.asked == [ROUTER, ROUTER_B] + assert trio.current_time() - started == pytest.approx(_REQUEST_TIMEOUT) -def _after_a_rollout(req): - """A router's answer minutes after a deploy: the provider, and a table - that still names the pods the rollout replaced.""" - return kad.Message( - type=req.type, - providerPeers=[_peer(PROVIDER, "/ip4/10.84.3.81/tcp/5002")], - closerPeers=[ - _peer(GONE_POD, "/ip4/10.84.1.7/tcp/5002"), - _peer(GONE_POD_2, "/ip4/10.84.2.9/tcp/5002"), - _peer(DARK_ROUTER, "/ip4/203.0.113.7/tcp/4501"), - ], - ) + host = _Host({ROUTER: errors, ROUTER_B: answers}) + assert [p.peer_id for p in await find_providers(host, [ROUTER, ROUTER_B], b"\x12\x20" + bytes(32))] == [str(PROVIDER)] + host = _Host({ROUTER: errors, ROUTER_B: errors}) + with pytest.raises(DirectoryError, match="no held router answered"): + await find_providers(host, [ROUTER, ROUTER_B], b"\x12\x20" + bytes(32)) -def test_providers_in_hand_end_the_walk_before_any_closer_peer_is_dialed(): - """The probe runs right after every canary was rolled: the routers' - tables are full of addresses nobody answers on. The first round already - has the provider; nothing else is dialed.""" - host = _Host({ROUTER: _after_a_rollout, ROUTER_B: _after_a_rollout}) - - async def main(): - started = trio.current_time() - found = await find_providers(host, b"key", [ROUTER, ROUTER_B]) - assert [p.peer_id for p in found] == [str(PROVIDER)] - assert host.dials == [] - assert trio.current_time() - started == 0 + with pytest.raises(DirectoryError, match="no held router to ask"): + await find_providers(_Host({}), [], b"\x12\x20" + bytes(32)) trio.run(main, clock=trio.testing.MockClock(autojump_threshold=0)) -def test_stale_closer_peers_are_dialed_at_once_not_one_after_another(): - """When nobody has the record, the walk follows the closer peers. Three - that never answer cost one dial timeout together, not three.""" - - def knows_nothing(req): - return kad.Message(type=req.type, closerPeers=_after_a_rollout(req).closerPeers) +def test_find_peer_returns_the_addresses_the_router_knows_or_nothing(): + def knows_target(req): + assert req.WhichOneof("request") == "find_peer" + if req.find_peer.peer_id == str(TARGET): + return pb.DirectoryResponse(peer=_record(TARGET, "/ip4/10.0.0.9/tcp/4001", f"/ip4/10.0.0.2/tcp/4501/p2p/{ROUTER}/p2p-circuit")) + return pb.DirectoryResponse() - host = _Host({ROUTER: knows_nothing, ROUTER_B: knows_nothing}) + host = _Host({ROUTER: knows_target}) async def main(): - started = trio.current_time() - assert await find_providers(host, b"key", [ROUTER, ROUTER_B]) == [] - assert sorted(map(str, host.dials)) == sorted(map(str, [GONE_POD, GONE_POD_2, DARK_ROUTER])) - assert trio.current_time() - started == pytest.approx(DIAL_TIMEOUT) + addrs = await find_peer(host, [ROUTER], TARGET) + assert [str(a) for a in addrs] == [ + f"/ip4/10.0.0.9/tcp/4001/p2p/{TARGET}", + f"/ip4/10.0.0.2/tcp/4501/p2p/{ROUTER}/p2p-circuit/p2p/{TARGET}", + ] + assert await find_peer(host, [ROUTER], PROVIDER) == [] - trio.run(main, clock=trio.testing.MockClock(autojump_threshold=0)) + trio.run(main) -def test_the_routers_are_asked_at_once(): - """A router that takes its time to answer does not delay the others' - answers: the round lasts as long as the slowest query, bounded.""" +def test_provide_announces_this_member_at_its_addresses(): + seen = [] - class SlowHost(_Host): - async def new_stream(self, peer_id, protocols): - if peer_id == ROUTER_B: - await trio.sleep_forever() - return await super().new_stream(peer_id, protocols) + def takes(req): + assert req.WhichOneof("request") == "provide" + seen.append(req.provide) + return pb.DirectoryResponse() - host = SlowHost({ROUTER: _after_a_rollout, ROUTER_B: _after_a_rollout}) + host = _Host({ROUTER: takes}) async def main(): - started = trio.current_time() - found = await find_providers(host, b"key", [ROUTER, ROUTER_B]) - assert [p.peer_id for p in found] == [str(PROVIDER)] - # ROUTER_B's stream open ran to its timeout while ROUTER answered. - assert trio.current_time() - started == pytest.approx(_QUERY_TIMEOUT) + import multiaddr - trio.run(main, clock=trio.testing.MockClock(autojump_threshold=0)) + await provide(host, [ROUTER], peer_key(SELF), [multiaddr.Multiaddr("/ip4/10.0.0.1/tcp/1")]) + assert len(seen) == 1 + assert seen[0].key == peer_key(SELF) + assert list(seen[0].addrs) == ["/ip4/10.0.0.1/tcp/1"] + assert seen[0].provider == "", "a member names no provider; it announces itself" + + trio.run(main) def test_read_bounded_varint_prefixed_bytes_rejects_oversized_prefix_before_reading_payload(): @@ -253,78 +216,3 @@ async def main(): assert bomb.payload_reads == 0 trio.run(main) - - - -def test_the_walk_follows_only_the_replication_closest_routers(): - """A router names the routers closest to the key in order, as many as it - knows; a record lives on the mesh's replication of them, so only that - many are followed. With the default of three the fourth is never dialed; - with a replication of one, only the first.""" - - def knows_nothing(req): - return kad.Message( - type=req.type, - closerPeers=[ - _peer(GONE_POD, "/ip4/10.84.1.7/tcp/5002"), - _peer(GONE_POD_2, "/ip4/10.84.2.9/tcp/5002"), - _peer(DARK_ROUTER, "/ip4/203.0.113.7/tcp/4501"), - _peer(TARGET, "/ip4/10.0.0.9/tcp/4001"), - ], - ) - - async def main(replication, want): - host = _Host({ROUTER: knows_nothing, ROUTER_B: knows_nothing}) - assert await find_providers(host, b"key", [ROUTER, ROUTER_B], replication=replication) == [] - assert sorted(map(str, host.dials)) == sorted(map(str, want)) - - trio.run(main, 3, [GONE_POD, GONE_POD_2, DARK_ROUTER], clock=trio.testing.MockClock(autojump_threshold=0)) - trio.run(main, 1, [GONE_POD], clock=trio.testing.MockClock(autojump_threshold=0)) - - -def test_the_walk_authenticates_before_each_query_and_releases_what_it_connected(): - """A router serves a query only from a member that authenticated with it, - so `admit` runs for a router before it is queried: the held router and - the closer one the walk connects to for the record. That connection is - the walk's alone and is closed when the walk ends; the held router's - stays.""" - - class ReachableHost(_Host): - async def connect(self, info): - self.dials.append(info.peer_id) - self.connected.add(info.peer_id) - self._answers[info.peer_id] = lambda req: kad.Message(type=req.type, providerPeers=[_peer(PROVIDER, "/ip4/10.0.0.9/tcp/4001")]) - - def names_router_b(req): - return kad.Message(type=req.type, closerPeers=[_peer(ROUTER_B, "/ip4/10.0.0.2/tcp/4501")]) - - host = ReachableHost({ROUTER: names_router_b}) - admitted = [] - - async def admit(peer_id): - assert peer_id in host.connected, "admit runs on an open connection" - admitted.append(peer_id) - - async def main(): - found = await find_providers(host, b"key", [ROUTER], admit=admit) - assert [p.peer_id for p in found] == [str(PROVIDER)] - assert admitted == [ROUTER, ROUTER_B], "every router is admitted before it is queried, in walk order" - assert host.connected == {ROUTER}, "the router the walk connected to is released; the held one is kept" - - trio.run(main, clock=trio.testing.MockClock(autojump_threshold=0)) - - -def test_a_router_that_refuses_the_handshake_is_skipped(): - """A router that does not admit this member answers nothing; the walk - takes the other answers and moves on.""" - host = _Host({ROUTER: _after_a_rollout, ROUTER_B: _after_a_rollout}) - - async def admit(peer_id): - if peer_id == ROUTER_B: - raise RuntimeError("not admitted") - - async def main(): - found = await find_providers(host, b"key", [ROUTER, ROUTER_B], admit=admit) - assert [p.peer_id for p in found] == [str(PROVIDER)] - - trio.run(main, clock=trio.testing.MockClock(autojump_threshold=0)) diff --git a/sdk/python/tests/test_mcp.py b/sdk/python/tests/test_mcp.py index e454ab025..64966a767 100644 --- a/sdk/python/tests/test_mcp.py +++ b/sdk/python/tests/test_mcp.py @@ -153,7 +153,7 @@ def frame(biscuit: bytes, target: str) -> bytes: return pb.AuthFrame(biscuit=biscuit, target_service=target).SerializeToString() -def test_service_keys_match_internal_node_service(): +def test_service_keys_match_api_service_key(): for s in FIXTURE["services"]: assert service_key(s["type"], s["name"] or None).hex() == s["multihash"], f"{s['type']}:{s['name']}" assert parse_service_target("mcp://calc") == ("mcp", "calc") diff --git a/sdk/testdata/service_keys.json b/sdk/testdata/service_keys.json index 3064e3e6a..fcfae2ac8 100644 --- a/sdk/testdata/service_keys.json +++ b/sdk/testdata/service_keys.json @@ -1,5 +1,5 @@ { - "_comment": "Service discovery keys as internal/node/service.go derives them: cid = cidv1(raw, sha256(\"mesh:service:\u003ctype\u003e[:\u003cname\u003e]\")); go-libp2p-kad-dht keys the provider record by the multihash (hex).", + "_comment": "Service directory keys as api.ServiceKey derives them: multihash = sha256(\"mesh:service:[:]\") (hex); cid is the same key as cidv1(raw, multihash), the form the routers' DHT stores it under.", "services": [ { "type": "mcp", diff --git a/site/content/docs/concepts/architecture.md b/site/content/docs/concepts/architecture.md index 07ab7b0a8..3778514f3 100644 --- a/site/content/docs/concepts/architecture.md +++ b/site/content/docs/concepts/architecture.md @@ -62,11 +62,11 @@ covers all of that. ## Routers -`agentmesh-router` is a libp2p peer with a stable address. It runs the mesh's -Kademlia DHT (a distributed hash table), where nodes advertise their services -and look up who provides what. It also relays traffic for nodes that cannot -accept inbound -connections. A router has no policy of its own. It verifies every peer's +`agentmesh-router` is a libp2p peer with a stable address. It serves the +mesh's directory, where nodes advertise their services and look up who +provides what; the routers keep it as a Kademlia DHT (a distributed hash +table) among themselves. It also relays traffic for nodes that cannot +accept inbound connections. A router has no policy of its own. It verifies every peer's credential and refuses peers it cannot verify and peers that have been banned. @@ -88,7 +88,7 @@ component refers to the node by that ID. Towards the mesh, a node: - publishes the services declared in its configuration file (MCP servers, - OpenAI-compatible inference backends, A2A agents) to the DHT and, on + OpenAI-compatible inference backends, A2A agents) to the directory and, on request, to the control plane's catalog - accepts connections from other nodes, verifies their credential, evaluates policy for the requested service, and proxies the request to the local diff --git a/site/content/docs/concepts/networking.md b/site/content/docs/concepts/networking.md index c13c091e6..b204cb23e 100644 --- a/site/content/docs/concepts/networking.md +++ b/site/content/docs/concepts/networking.md @@ -43,11 +43,16 @@ The [node API reference](../../reference/node-api/) lists every route. ## Discovery -A node that publishes a service does two things. It records itself in the -Kademlia DHT, hosted by the routers, as a provider of that service name. It -also announces the service over GossipSub to nodes that have asked about that -name. A node that looks for a service asks the DHT for providers, and for -names it asked about before it already has the gossip announcements. +The routers keep the mesh's directory: a Kademlia DHT among themselves, +served to members over `/mesh/directory/1.0.0`. A node announces where it +is reached, and a node that publishes a service announces itself as a +provider of that service name, to a router it holds; the router keeps the +records and places each on the routers closest to its key. A node that +looks for a service, or for a peer, asks a router it holds, and the router +runs the lookup across the routers' DHT. A node keeps no DHT of its own and +connects to no router it does not hold. It re-announces every reprovide +interval of the mesh (`/info`), and a record a provider stopped +announcing leaves the directory after three of them. Before a node advertises a backend, it probes the backend (`--backend-probe-timeout`, 2 seconds by default). A backend that does not @@ -81,13 +86,17 @@ out what each connection carries and what a NAT or firewall sees. Routers are the fixed points. A node learns their addresses, labels and load from the control plane's `/info` at enrollment and again on a schedule, holds two of them with a relay reservation on each, and replaces -one that drops or that sends it away. A router that grants a reservation -records the member's address through itself, so a DHT lookup for the -member that reaches that router learns where it is reached; an SDK member -listens on nothing else. A call to a peer reached through a +one that drops or that sends it away. A router that holds a member answers +a lookup for it from what it sees of the member; any other router answers +from the record the member announced under its own peer ID, so a lookup +for the member learns where it is reached through any router. An SDK +member listens on nothing of its own and is reached through a router it +holds. A call to a peer reached through a third router goes through that router: the caller runs the credential handshake there first, since a router relays only between peers it has -verified. The DHT and the relay are closed to anyone who is not enrolled. +verified. The directory, the gossip and the relay are closed to anyone who +is not enrolled: a router serves nothing but the handshake to a peer that +has not passed it. When a node opens a stream to another node for a service request, the first message is an authentication frame with the caller's credential and the @@ -110,7 +119,9 @@ request. Traffic to the control plane is protobuf over HTTPS. Traffic between nodes is HTTP over libp2p streams for service requests, and protobuf frames for the -authentication handshake, the DHT and gossip. +authentication handshake, the directory and gossip. Members take the +control plane's events as leaves of the gossip, over floodsub: the routers +deliver every event to them, and run the gossip mesh among themselves. ## Inference routing diff --git a/site/content/docs/guides/connecting-agents.md b/site/content/docs/guides/connecting-agents.md index 50928def7..3687b8b38 100644 --- a/site/content/docs/guides/connecting-agents.md +++ b/site/content/docs/guides/connecting-agents.md @@ -40,7 +40,7 @@ Six tools: | Tool | Purpose | |---|---| -| `get_mesh_info` | Router, connected peers, DHT size, the local socket path. A quick health check. | +| `get_mesh_info` | The routers the node holds, its connected peers, the local socket path. A quick health check. | | `list_local_services` | The services this node publishes. | | `discover_remote_services` | The services other nodes publish, by type (`mcp`, `inference`, `a2a`) and optionally by name. Returns the hosting peer ID for each. | | `find_remote_tools` | The tools behind MCP services, across the mesh or limited to a peer, a service, or a tool name. | @@ -244,7 +244,7 @@ trust should not be able to reach the node's API at all. The the node to an address the host can reach (`--bind-addr 0.0.0.0:8080`) or forward the port. - **No remote tools**: check `get_mesh_info` first. An empty - `connected_peers` means the node has not reached a router. A `dht_size` of - zero means it has not found the mesh yet. + `attached_routers` means the node has not been admitted by a router yet, + and it has nobody to ask. - **A tool is listed with an `error`**: the provider's backend is not answering. Another peer that offers the same service name may work. diff --git a/site/content/docs/guides/scalability.md b/site/content/docs/guides/scalability.md index 314c34d6c..c75fdc91e 100644 --- a/site/content/docs/guides/scalability.md +++ b/site/content/docs/guides/scalability.md @@ -17,21 +17,21 @@ rollout of every router and both control plane replicas. A member is one `agentmesh-node` process. It holds a session with two routers (`--routers`), chosen from those the control plane lists, and a -relay reservation on each. Through those sessions it keeps a GossipSub -subscription for control plane events. Its DHT client reaches the -routers kademlia puts closest to each key it publishes or looks up, -three of them (the mesh's replication, `agentmesh-control-plane ---dht-replication`), named in one answer by a router it is attached to, -and runs the credential handshake with each, so those connections are -sessions as well: the router keeps them, and the next lookup or -announcement (every 5 minutes, the mesh's `--dht-reprovide-interval`) -reuses them. The client keeps no table maintenance of its own. A router -therefore carries sessions of two kinds, both counted by the watermarks: -the members attached to it, and the members whose keys land on it. -A member connects to another member only to call it, through that -member's router, where it runs the credential handshake before asking for -the circuit. At rest it uses about 55 MiB of memory, 300 goroutines and -20 file descriptors, and almost no CPU. +relay reservation on each. Those two sessions are everything the member +opens to the mesh at rest. Through them it receives the control plane's +events as a leaf of the gossip (floodsub: the routers deliver every +event to it, and it takes no part in the routers' gossip mesh), and it +uses the directory: it announces each service it publishes, and where it +is reached, to a router it holds, and asks a router it holds for +providers and peers. The router keeps the member's records, places each +on the routers closest to its key (three, the mesh's replication, +`agentmesh-control-plane --dht-replication`), and runs each lookup across +the routers' own DHT. The member keeps no DHT and connects to no router +it does not hold. It re-announces every 5 minutes (the mesh's +`--dht-reprovide-interval`). A member connects to another member only to +call it, through that member's router, where it runs the credential +handshake before asking for the circuit. At rest it uses about 55 MiB of +memory, 300 goroutines and 20 file descriptors, and almost no CPU. Joining is the expensive moment. The member enrolls at the control plane (one signed request, one database write, one credential minted), reads the @@ -81,8 +81,8 @@ dial tries every address at once. | Role | What it is for the network | What it opens | What opens to it | |---|---|---|---| -| Router | The dialable endpoint. Relay for members behind NAT, the DHT server every lookup reaches, the point of admission (credential handshake, relay ACL, bans), the hub GossipSub events pass through, and the record of which members hold a reservation on it. | Connections to the other routers (every pair), for the DHT and GossipSub. | Every member that holds it, every member's DHT client when it queries, the control plane's event publisher. | -| Member (`agentmesh-node`, an SDK) | A client of the routers. It announces where it is reached, looks peers and services up, verifies the peers it talks to and carries the data plane. It is a DHT client whatever its reachability: a public member is dialled directly, and still serves no lookups for others. | K connections to the routers it holds (`--routers`, 2), one to each other router it queries, one to each peer it reaches directly. | Peers that call it: directly when they can, through a router otherwise. | +| Router | The dialable endpoint. Relay for members behind NAT, the directory its members announce to and ask (served from the DHT the routers keep among themselves), the point of admission (credential handshake, relay ACL, bans), the hub GossipSub events pass through, and the record of which members hold a reservation on it. | Connections to the other routers (every pair), for the DHT and GossipSub. | Every member that holds it, the control plane's event publisher. | +| Member (`agentmesh-node`, an SDK) | A client of the routers. It announces where it is reached, looks peers and services up, verifies the peers it talks to and carries the data plane. It keeps no DHT and serves no lookups: the routers it holds answer for it. | K connections to the routers it holds (`--routers`, 2), one to each peer it reaches directly. | Peers that call it: directly when they can, through a router otherwise. | | Provider | A member that publishes a service: the fan-in point of the mesh. | As a member. | One relayed connection per caller, each a stream on its connection to a router. | | Control plane | Enrollment, the router catalog, keys, policy and leases, over HTTP. Its event publisher is a member that holds every router. | HTTP only, and the publisher's connections to the routers. | Members and routers, over HTTP. | @@ -94,14 +94,16 @@ dial tries every address at once. | `/mesh/auth/1.0.0` | member and router on connect and at renewal; caller and provider before a call | 1, short | the handshake | | `/libp2p/circuit/relay/0.2.0/hop` | member to router | 1 per reservation, renewed; 1 per relayed connection the member opens | the reservation; the relayed connection | | `/libp2p/circuit/relay/0.2.0/stop` | router to the member a caller reaches | 1 per relayed caller | the relayed connection | -| `/mesh/kad/1.0.0` | member to each router it queries | 1 request stream per router | while the member keeps the connection | -| `/meshsub/1.3.0` | every pair that runs GossipSub | 1 per direction | the connection; heartbeat every second | +| `/mesh/directory/1.0.0` | member to a router it holds | 1 per request, short | the request | +| `/mesh/kad/1.0.0` | router to router | 1 request stream per router | the connection | +| `/meshsub/1.3.0` | router to router | 1 per direction | the connection; heartbeat every second | +| `/floodsub/1.0.0` | member and router | 1 per direction | the connection | | `/libp2p/dcutr`, autonat v2 | the two ends of a relayed connection | 1, short | the hole punch | | `/mesh/mcp/1.0.0`, libp2p-http, `/mesh/goaway/1.0.0`, ping | caller and provider; router and member | 1 per call or session | the call | The resource manager budgets every protocol a member holds a stream on -for as long as it is connected (the DHT, GossipSub, the relay, and MCP on -a node) from the connection budget, so a router or a provider admits as +for as long as it is connected (gossip, the relay, and MCP on a node) or +opens per request (the directory) from the connection budget, so a router or a provider admits as many such streams as it admits connections; libp2p's own default for a protocol it was not told about is a few hundred streams in all, scaled from host memory. A refused stream is counted in @@ -158,19 +160,17 @@ NAT entry at each end. ### What a middlebox sees per member A member holds K long-lived connections to its routers, kept alive by -the transport, plus one to each router its keys land on, up to -the mesh's replication per key, opened at the first lookup or announcement -and kept: entries that do not churn, K + D of them for the D directory -routers the member uses. A member that reaches a peer directly holds one -entry per such peer, at both ends. A relayed connection adds none. +the transport, and no other to the mesh: entries that do not churn, K of +them. A member that reaches a peer directly holds one entry per such +peer, at both ends. A relayed connection adds none. A firewall between a member and the routers needs TCP (and UDP for QUIC, when the member is allowed to fall back to it) to the routers' ports outbound; nothing inbound, since every connection to a member arrives through a hub link it opened. A conntrack table on that path is -sized for the K long-lived entries per member plus the transient DHT -connections, and a NAT that times idle UDP entries out in less than the -QUIC keepalive (15 s) breaks a QUIC hub link; TCP first avoids it. +sized for the K long-lived entries per member, and a NAT that times idle +UDP entries out in less than the QUIC keepalive (15 s) breaks a QUIC hub +link; TCP first avoids it. ## What bounds the mesh @@ -181,7 +181,7 @@ them. | Bound | Where | Default | At the bound | |---|---|---|---| | Routers per member | `agentmesh-node --routers` | 2 | A member holds this many routers; one is enough to be on the mesh, the second covers a router restart. | -| Routers per key | `agentmesh-control-plane --dht-replication` | 3 | A record a member publishes lives on this many routers, the closest to its key, and a lookup asks them; the member holds a session with each. Published on `/info`, so every member of the mesh runs the same value. | +| Routers per key | `agentmesh-control-plane --dht-replication` | 3 | A record a member announces is kept by the router it announced to and placed on this many routers, the closest to its key, which is what survives routers going down. Published on `/info`, so every router of the mesh runs the same value. | | Connections per router | `agentmesh-router --high-watermark`, `--low-watermark` | 4000, 15% below (3400) | At the high mark of members the router sends members away, at random, down to the low mark; they attach to another router and stay off this one for five minutes. A mesh of N routers holds about N × 4000 / 2 members. | | Connections per source address | `agentmesh-router --conns-per-source-ip` | a quarter of the high mark (1000) | The router refuses the next connection from that address. Members behind one NAT or one cluster's egress share an address. | | Relay reservations per router | `agentmesh-router --relay-max-reservations` | the high mark (4000) | A member that cannot reserve is not reachable through that router. | @@ -192,29 +192,34 @@ them. | Connections per source address at a node | `agentmesh-node`, fixed | a quarter of the high mark (100) | The node refuses the next direct connection from that address, counted in `agentmesh_node_inbound_connections_refused_total`. A service called by a fleet behind one NAT or one cluster's egress sees them all from one address. A caller that arrives through a router is not charged to the router's address: it is bounded by the node's connection budget and per-peer scope, and at the router by the relay's per-peer and per-address budgets. | | Router lease | `agentmesh-control-plane --lease-duration`, `agentmesh-router --shutdown-lease-ttl` | 15m, 30s | A router that stops without the last lease stays listed for the lease duration. | -The first three rows are the model. A fleet of F members on N routers, -each member holding K of them, puts about F × K / N attached sessions on -each router, so adding a router adds members: the fleet a mesh holds is -N × H / K for a high watermark H. A member picks the routers with the -most room when it joins, so a new router fills from the members that -join after it and from those the full routers send away; the members -already placed do not move on their own. When a router stops, its -members spread over the N − 1 others, which is where the headroom in the -next section goes. - -The directory adds sessions of its own. A member also holds a session -with each router its keys land on: at most r (the mesh's replication) per -key it publishes or looks up, overlapping across keys, never more than -N − K in all. Call that D. On few routers D reaches N − K and every -member holds every router; on many, a member that publishes one service -and calls a few holds D of about r to 2r whatever N is. The keys are -spread over the routers by hash, so a router holds about F × (K + D) / N -sessions in all, and that is the figure the watermarks see: with 10,000 -members, K = 2 and D = 4, about 10,000 per router on six routers, 5000 -on twelve, 2000 on thirty. Adding routers divides both terms. A key that -every member publishes, such as a service type, lands on the same r -routers for all of them, and those r routers see a session from every -publisher. +The first two rows are the model. The formulas below use these letters: + +- F, the members in the fleet. +- N, the routers in the mesh. +- K, the routers a member holds (`--routers`, 2 by default). +- H, a router's high watermark (`--high-watermark`). +- r, the mesh's replication: the routers each record is placed on + (`--dht-replication` on the control plane, 3 by default). + +A fleet of F members on N routers, each member holding K of them, puts +about F × K / N sessions on each router, and that is the figure the +watermarks see: with 10,000 members and K = 2, about 3300 per router on +six routers, 1700 on twelve. Adding a router adds members: the fleet a +mesh holds is N × H / K. A member picks the routers with the most room +when it joins, so a new router fills from the members that join after it +and from those the full routers send away; the members already placed do +not move on their own. When a router stops, its members spread over the +N − 1 others, which is where the headroom in the next section goes. + +The directory costs a router no sessions. A member's announcements and +lookups travel on the sessions it already holds, and a router answers a +lookup from the DHT the routers keep among themselves, over the +connections they already hold to each other. What the directory costs +is records and requests: a router keeps the records of its own members +and r copies of records whose keys land on it, and runs one lookup +across the routers per request. A key that every member publishes, such +as a service type, lands on the same r routers for all of them; those r +routers hold a record per publisher and answer every lookup of it. What bounds the total is the router's libp2p connection cap, twice the high watermark: a router past it refuses the next connection, session or @@ -225,35 +230,28 @@ watermark and the cap. ## Sizing the routers Start from the fleet you expect, F members on N routers with `--routers` -at its default of 2 and the mesh's replication at its default of 3, and -size for the day -one router is down: - -- `--high-watermark` at least 1.2 × F × (K + D) / (N − 1), with D the - directory sessions a member holds: N − K on few routers, 3 to 6 on - many. Each router then holds F × (K + D) / N sessions in normal - running, with room for the members of a stopped router, and the - connection cap at twice the high mark holds what members open in - passing. 5000 members on four routers (D = 2) need 8000; 10,000 - members on twelve routers (D = 4) need 6600, on twenty (D = 5) need - 4500. The testnets deploy with 6000 (`ROUTER_HIGH_WATERMARK` in the - deploy workflow) on six routers (`ROUTER_REPLICAS`), which holds about - 4000 members that use the directory with one router out; 10,000 take - twelve routers. +at its default of 2, and size for the day one router is down: + +- `--high-watermark` at least 1.2 × F × K / (N − 1). Each router then + holds F × K / N sessions in normal running, with room for the members + of a stopped router, and the connection cap at twice the high mark + holds what members open in passing. 5000 members on four routers need + 4000; 10,000 members on six routers need 4800, on twelve 2200. The + testnets deploy with 6000 (`ROUTER_HIGH_WATERMARK` in the deploy + workflow) on six routers (`ROUTER_REPLICAS`), which holds 10,000 + members with one router out. - The three knobs, and what each buys. One is a member's own: `--routers` - (K) is what a router restart costs it; a member with two attached - routers stays on the mesh through one, and each one more is a session - more per member on some router. Two are the mesh's, set on the control - plane and read by every member and router from `/info`: - `--dht-replication` (r) is what losing routers costs the directory, a - record on three routers survives two of them down at once, and each - one more is a session more per key per member on the router the key - lands on; `--dht-reprovide-interval` is how soon a provider that - stopped without unpublishing leaves the directory, three intervals, - which is also how long routers keep a record, against the - announcements a minute the routers absorb, providers divided by the - interval. None of the three adds capacity: routers do, by dividing - every term above by N. + (K) is what a router restart costs it; a member with two routers stays + on the mesh through one, and each one more is a session more per member + on some router. Two are the mesh's, set on the control plane and read + by every router from `/info`: `--dht-replication` (r) is what losing + routers costs the directory, a record on three routers survives two of + them down at once, and each one more is a copy more of every record; + `--dht-reprovide-interval` is how soon a provider that stopped without + unpublishing leaves the directory, three intervals, which is also how + long routers keep a record, against the announcements a minute the + routers absorb, providers divided by the interval. None of the three + adds capacity: routers do, by dividing F × K by N. - `--low-watermark` at its default, 15% below the high mark. The gap is what one shedding moves to the other routers; a smaller gap sheds more often, a larger one moves more members at once. A router that sheds is @@ -293,13 +291,14 @@ others; the fix is a router or a higher watermark. The stream budgets follow the watermark per protocol, because a member holds a stream open to its router for as long as it is connected on each -of the DHT, GossipSub, and its relay reservation, and libp2p's default -for a protocol it was not told about is a few hundred streams in all, -scaled from host memory. A router that refuses a member's DHT stream -drops out of that member's routing table, and the member out of -discovery, with no error at the member; the router counts the refusal in -`agentmesh_router_streams_refused_total{protocol}` and logs it once a -minute per protocol. +of gossip and its relay reservation, and opens one per directory request, +and libp2p's default for a protocol it was not told about is a few +hundred streams in all, scaled from host memory. A router that refuses a +member's gossip stream leaves that member without the control plane's +events, and one that refuses its directory stream fails the member's +lookup, which the member retries on its other router; the router counts +the refusal in `agentmesh_router_streams_refused_total{protocol}` and +logs it once a minute per protocol. Routers connect to one another, every pair, to run the DHT and GossipSub across the mesh; a router never sheds those connections. That is N × (N − 1) @@ -370,8 +369,8 @@ from where it runs. Behind a NAT it is reached through a relay, which costs a reservation on the router and nothing else; a member with a public address may run `--reachability auto` and be dialled directly once AutoNAT has confirmed the address, which takes it off the relay. Reachability -decides how a member is reached and nothing else: it stays a DHT client, -so it never enters other members' routing tables or serves their lookups. +decides how a member is reached and nothing else: it keeps no DHT either +way, and serves no lookups. ## Sizing a service @@ -442,21 +441,22 @@ The router and the control plane serve Prometheus metrics on | `agentmesh_router_inbound_connections_refused_total` | Members behind one address have hit `--conns-per-source-ip`. | | `agentmesh_node_inbound_connections_refused_total{reason}` against `agentmesh_node_conns_per_source_ip_limit` | On a provider: direct callers it turned away before the handshake. `per_ip_limit` is a fleet behind one address, `rate_limit` a burst from one address, `scope_limit` more callers at once than the node's connection budget, relayed ones included. The caller sees the connection close before TLS, or `CONNECTION_REFUSED` over QUIC, and nothing more. | | `agentmesh_node_transit_handshakes_total{router,result}` | On a caller: handshakes with routers it did not hold, run to reach a peer through them. `router="unlisted"` is a router the node's catalog missed, which a member that started during a router rollout produces until its next sync; `result="failed"` rising is a router that refuses the node or is gone while peers still announce circuits through it. | -| `agentmesh_node_peer_lookups_total{result}` | On a caller: DHT lookups for a peer it held no address for. `not_found` is a peer the DHT does not carry, and the call falls back to circuits through the caller's own routers, which reach the peer only if it holds one of them; `failed` rising is the caller cut off from the DHT (`agentmesh_node_dht_routing_table_size` at 0). | +| `agentmesh_node_peer_lookups_total{result}` | On a caller: directory lookups for a peer it held no address for. `not_found` is a peer the directory does not carry, and the call falls back to circuits through the caller's own routers, which reach the peer only if it holds one of them; `failed` rising is no held router answering (`agentmesh_node_directory_requests_total{result="failed"}` rises with it). | | `agentmesh_node_provider_reach_total{result}` | On a caller: attempts to reach a provider for its credential before a call; every failure is one 502 at the caller. `relay_refused` is a router that has not admitted the caller or the provider, `dial_timeout` a provider gone or a relay connection at its stream limit (below), `no_address` a provider the directory does not carry. | | `agentmesh_router_busiest_connection_streams{transport}`, `agentmesh_node_busiest_connection_streams{transport}` | The most streams any one connection carries. Every relayed connection is one stream on the relay's connection to each end, and a QUIC connection admits 256 streams: a provider reached through one router over QUIC has at most 256 callers through it at once, and the 257th waits until one leaves, which it sees as a dial that times out. Both ends log the connection once a minute while it is within a tenth of the limit. A provider that expects more callers than that listens on TCP as well, or holds more routers. | | `libp2p_relaysvc_reservations_total{type="opened"}` minus `{type="closed"}`, against `agentmesh_router_relay_limit{limit="reservations"}` | Reservations in use against the budget. | | `libp2p_relaysvc_connection_rejections_total{reason="resource limit exceeded"}` | A service has more callers than `--relay-max-circuits`. | | `libp2p_rcmgr_blocked_resources` | libp2p refused a connection or stream on a scope. On a router this should stay flat. | | `agentmesh_router_memory_reservations_refused_total`, `agentmesh_node_memory_reservations_refused_total`, with `libp2p_rcmgr_memory{scope}` against `libp2p_rcmgr_limit{resource="memory",scope}` | The resource manager's memory budget on some scope is full. Over TCP every open stream holds a 256 KiB reservation, so this is the stream budget met from the other side: new streams and relayed connections are refused, with `RESOURCE_LIMIT_EXCEEDED` at the relay and nothing in the stream counters. The scope whose `libp2p_rcmgr_memory` is at its limit names the budget. | -| `agentmesh_router_streams_refused_total{scope,protocol}`, `agentmesh_node_streams_refused_total{scope,protocol}` | The streams behind that gauge, by protocol. `/mesh/kad/1.0.0` or `/meshsub/*` at the `protocol` scope on a router is members past the stream budget of a protocol, which keeps them out of discovery; `agentmesh_router_dht_routing_table_size` falls with it. | +| `agentmesh_router_streams_refused_total{scope,protocol}`, `agentmesh_node_streams_refused_total{scope,protocol}` | The streams behind that gauge, by protocol. `/mesh/directory/1.0.0` or `/floodsub/1.0.0` at the `protocol` scope on a router is members past the stream budget of a protocol: their lookups fail over to their other router, their events stop. `scope="unauthenticated"` is a stream from a peer that never passed the handshake. | | `agentmesh_router_auth_handshakes_total{result="read_failed"}` | Members that timed out mid-handshake: the router is slow, usually CPU. | | `agentmesh_router_draining` | The router is stopping. Exclude it from capacity sums. | | `agentmesh_control_plane_http_requests_total{code="429"}` | Members turned away by the enrollment limiter; they retry. | | `agentmesh_control_plane_http_request_duration_seconds` | The control plane's own latency; p99 above a second under a join burst means the database is the bound. | | `agentmesh_node_mesh_connected` | On every node: `0` for five minutes is a member off the mesh. | | `agentmesh_node_routers{state="attached"}` against `{state="wanted"}` | On every node: fewer attached than wanted for long is a member that cannot find a router with room, or one whose selector too few routers carry. `agentmesh_node_router_candidates{match="selector"}` is how many it could choose from. | -| `agentmesh_node_routers{state="directory"}` | On every node: the routers it holds a session with for its keys alone, the D of the model above. Summed over a fleet and divided by the routers, it is the share of each router's sessions that the directory costs; `agentmesh_node_directory_handshakes_total{result="failed"}` rising is a router that refuses members it is not attached to. | +| `agentmesh_router_directory_requests_total{op,result}`, `agentmesh_router_directory_request_seconds{op}` | On every router: the directory requests its members made, by operation (`provide`, `find_providers`, `find_peer`; `place` is a record another router put here) and result. `error` rising is a lookup across the routers failing, a peer router down or slow; the histogram is what a member waits for a lookup. `agentmesh_router_directory_placements_total{result="error"}` rising is a router that does not take the records placed on it. | +| `agentmesh_node_directory_requests_total{op,result}`, `agentmesh_node_directory_request_seconds{op}` | On every node: the same from the member's side. `failed` is a request no held router answered; `empty` a lookup nobody had a record for, which on a fresh mesh is ordinary. | An autoscaler for the routers has its inputs in the first two rows: scale out when the free capacity of the mesh is below what one router holds, so diff --git a/site/content/docs/preview/mobile.md b/site/content/docs/preview/mobile.md index a2eec96b9..640bfbf08 100644 --- a/site/content/docs/preview/mobile.md +++ b/site/content/docs/preview/mobile.md @@ -241,13 +241,14 @@ or shows an error, record the status text before retrying. 2. Allow up to a minute for the connection to settle. 3. Check that the dashboard says **Node is Running**, **Node ID** contains an identifier, and **Connected Peers** is greater than zero. -4. Record the **Node ID**, **Connected Peers**, and **DHT Size** values. - Peer counts can change. A particular DHT size is not a pass criterion. +4. Record the **Node ID**, **Connected Peers**, and **Routers** values. + Peer counts can change. **Routers** is how many routers the node holds, + two on a mesh with two or more. **Node is Running** alone does not prove a mesh connection. Report a peer count that remains zero, even if the app shows no error. -{{< figure src="../../../images/agentmesh-connect-running.png" alt="Agent Mesh Connect dashboard showing Node is Running, one connected peer, DHT size one, and a Node ID" caption="A connected node shows Node is Running, a Node ID, and at least one Connected Peer. Your Node ID and peer counts will differ from this example." width="320" >}} +{{< figure src="../../../images/agentmesh-connect-running.png" alt="Agent Mesh Connect dashboard showing Node is Running, one connected peer, one router, and a Node ID" caption="A connected node shows Node is Running, a Node ID, and at least one Connected Peer. Your Node ID and peer counts will differ from this example." width="320" >}} ### 5. Check restart and reconnection diff --git a/site/content/docs/reference/agentmesh-node.md b/site/content/docs/reference/agentmesh-node.md index 43958ed7e..84b50be86 100644 --- a/site/content/docs/reference/agentmesh-node.md +++ b/site/content/docs/reference/agentmesh-node.md @@ -115,8 +115,8 @@ explains how to enroll. | `--router-selector` | none | Attach only to routers whose credential attests this `key=value` label, repeatable, every pair required: `--router-selector region=eu`. The labels are the ones the control plane lists on `/info`, attested at the router's enrollment. A selector no router attests leaves the node retrying as if no router answered, with a warning naming the selector and the labels it saw. | | `--router-prefer` | none | Attach to routers attesting this `key=value` label before others, repeatable; a router attesting more of the pairs comes earlier, and among equals the one with the most room. `--router-prefer zone=eu-a` keeps a node on its zone's routers while they have capacity and lets it fall to the others. | | `--enable-relay` | `false` | Relay traffic for other peers. | -| `--discovery-interval` | `30s` | Delay before the first announcement of this node's services to the DHT, capped at 5 s; services are re-announced every reprovide interval of the mesh after that (`agentmesh-control-plane --dht-reprovide-interval`, 5 minutes by default). | -| `--dht-lookup-limit` | `20` | Providers to query per DHT lookup. | +| `--discovery-interval` | `30s` | Delay before the first announcement of this node's services to the directory, capped at 5 s; services are re-announced every reprovide interval of the mesh after that (`agentmesh-control-plane --dht-reprovide-interval`, 5 minutes by default). | +| `--dht-lookup-limit` | `20` | Providers a directory lookup asks for. | | `--discovery-concurrency` | `10` | Concurrent catalog fetches during discovery. | | `--backend-probe-timeout` | `2s` | How long a service backend may take to answer before the node declines to advertise it. Raise it for subprocesses that start slowly. | | `--secrets-dir` | `/etc/agentmesh/secrets` | Directory holding the credentials that the control plane's [egress destinations](../policy/#egress-destinations) name, one file per credential name. The platform puts the files there; the node reads a file on every request to the destination. | diff --git a/site/content/docs/reference/control-plane.md b/site/content/docs/reference/control-plane.md index c1da3caff..ffef9dd3a 100644 --- a/site/content/docs/reference/control-plane.md +++ b/site/content/docs/reference/control-plane.md @@ -37,7 +37,7 @@ agentmesh-control-plane admin unban --peer lift a ban | `--key-rotation-interval` | `24h` | How often a new signing key is generated. `0` disables rotation. | | `--key-grace-period` | `1h` | How long a rotated-out key stays accepted. Credentials signed by a retired key cannot be verified or refreshed. Nodes and routers must pull `/keys` well within this window (`agentmesh-node --control-plane-sync-interval`, `agentmesh-router --keys-sync-interval`). | | `--lease-duration` | `15m` | How long a router lease lasts without renewal. | -| `--dht-replication` | `3` | How many routers hold each service a member publishes and answer each lookup: the routers closest to the key. Every member holds a session with each of them besides its `--routers`, so the figure is a session more per key on every member for each replica, and a mesh with more routers spreads them. Raise it for records that must survive more routers being down at once. Published on `/info`; every member and router of the mesh runs on it. | +| `--dht-replication` | `3` | How many routers each record a member announces is placed on: the router it announced to keeps it, and places a copy on this many routers closest to the key. A record on three routers survives two of them down at once; each replica more is a copy more of every record. Members are not concerned: they ask the routers they hold. Published on `/info`; every router of the mesh runs on it. | | `--dht-reprovide-interval` | `5m` | How often a member re-announces each service it publishes. A provider that stopped without unpublishing stays listed for three intervals, which is how long routers keep a record after its last announcement; the mesh pays its providers divided by the interval in announcements a minute. Published on `/info`. | | `--node-retention` | `720h` (30 days) | How long the record of an enrolled node is kept after its session expires. Banned nodes are kept forever. `0` keeps every record. | | `--mesh-reconnect-interval` | `30s` | How often the event publisher re-reads the router leases and dials any router it is not connected to. On each router it runs the mesh handshake with a credential it mints for its own peer under the signing key, with the role `mesh:role:control-plane`, so the router holds its connection as a member's. | diff --git a/site/content/docs/reference/node-api.md b/site/content/docs/reference/node-api.md index 1dbb2a5d0..6a51f3f3a 100644 --- a/site/content/docs/reference/node-api.md +++ b/site/content/docs/reference/node-api.md @@ -147,10 +147,11 @@ tools fit together. The tools are: ### `get_mesh_info` -No parameters. Returns `router_peer_id`, `connected_peers`, `dht_size` and -`local_api_socket`. `connected_peers` and `dht_size` count different things -(transport connections and routing-table entries), so a small `dht_size` -next to a long peer list is normal. +No parameters. Returns `router_peer_id`, `connected_peers`, +`attached_routers` and `local_api_socket`. `attached_routers` are the +routers the node holds, which answer its directory requests; +`connected_peers` is every peer it has a connection with, the routers +among them. ### `list_local_services` @@ -377,6 +378,10 @@ material is meant for the node's owner and for auditors, not for agents. and `POST /debug/connect-peer` report and probe the node's view of the mesh for troubleshooting, and answer even while the mesh is unreachable. `mesh-info` lists the routers the node holds (`attached_routers`) next to -every peer it is connected to; `peer-addrs` is the DHT record of a peer as -this node reads it, looked up and not dialed. `agentmesh-node debug` on the -command line wraps them. Their output is not stable across releases. +every peer it is connected to; `network-info` lists the addresses the node +listens on, the ones it announces, and the ones in the records it last +announced to the directory (`directory_addresses`), which follow the +announced ones at the next announcement pass; `peer-addrs` is where the +directory says a peer is reached, looked up and not dialed. `agentmesh-node +debug` on the command line wraps them. Their output is not stable across +releases. diff --git a/site/content/docs/reference/router.md b/site/content/docs/reference/router.md index fadaed75a..1a78eeab1 100644 --- a/site/content/docs/reference/router.md +++ b/site/content/docs/reference/router.md @@ -5,7 +5,7 @@ weight: 3 --- `agentmesh-router` is a libp2p peer with a stable identity that new nodes connect -to first. It hosts the DHT, relays traffic between nodes that cannot reach +to first. It serves the directory, relays traffic between nodes that cannot reach each other, and forwards the control plane's signed events. It has no policy of its own. @@ -42,12 +42,12 @@ replica count. | `--lease-renew-interval` | `300s` | How often the lease is renewed. Must be well below the control plane's `--lease-duration`. | | `--allow-loopback` | `false` | Announce and accept loopback and link-local addresses. For a router and nodes on one host. | | `--conns-per-source-ip` | a quarter of `--high-watermark` (`1000`) | Inbound connections accepted per source address. One address can hold at most this share of the router's connection budget, so filling a router takes at least four addresses. Members behind a NAT or a cluster's SNAT share one address and each holds one connection per router, two while enrolling. | -| `--low-watermark`, `--high-watermark` | 15% below the high mark (`3400`), `4000` | How many members the router holds: peers that passed the handshake, other routers aside. At the high mark the router sends members away (see below) until it is back at the low mark, so the gap between the two is what one shedding moves; the default moves 15%. The same numbers bound connections: above the high mark the connection manager closes connections, oldest first among those that carry no session, then members; connections to the other routers are never closed. A connection without a session lasts at most `--auth-deadline`; a member attached elsewhere opens one to this router when it looks a peer or service up or re-announces a service, so those come and go with the mesh's activity; the connection manager leaves them alone while they are younger than its grace period, and libp2p's connection cap, twice the high mark, is what bounds the total (see the [scalability guide](../../guides/scalability/#what-bounds-the-mesh)). | +| `--low-watermark`, `--high-watermark` | 15% below the high mark (`3400`), `4000` | How many members the router holds: peers that passed the handshake, other routers aside. At the high mark the router sends members away (see below) until it is back at the low mark, so the gap between the two is what one shedding moves; the default moves 15%. The same numbers bound connections: above the high mark the connection manager closes connections, oldest first among those that carry no session, then members; connections to the other routers are never closed. A connection without a session lasts at most `--auth-deadline`; the connection manager leaves a connection alone while it is younger than its grace period, and libp2p's connection cap, twice the high mark, is what bounds the total (see the [scalability guide](../../guides/scalability/#what-bounds-the-mesh)). | | `--relay-limit-duration`, `--relay-limit-data` | `1h`, `0` | Caps on each relayed connection: lifetime, and bytes per direction (`512MiB`, `1GB`). The relay cuts the connection when either is reached. `0` means no limit. | | `--relay-max-reservations` | `--high-watermark` (`4000`) | Members that may hold a relay reservation on this router at once, which is how many members it can reach on behalf of callers. Per source address the cap is `--conns-per-source-ip`. | | `--relay-max-circuits` | `1024` | Relayed connections one peer may hold at once through this router, as caller or as destination. A service that many members call at once needs this many open circuits. | | `--shutdown-lease-ttl` | `30s` | Sent in the router's last lease when it stops: how long it expects to be away, which is how long the control plane keeps listing it. The default covers a pod that restarts in place. A router that is being removed for good can send `1s`. `0` sends nothing and the lease expires on the control plane's schedule. | -| `--auth-deadline` | `1m` | How long an inbound connection may stay open without its peer passing the handshake. A member handshakes the moment it connects; a member attached elsewhere whose DHT client came to query is done in less, as is the control plane's event publisher, which presents a credential of its own. What is left at the deadline is closed, so a connection without a session costs the router a minute, and the watermarks mean members. `0` keeps every connection. | +| `--auth-deadline` | `1m` | How long an inbound connection may stay open without its peer passing the handshake. A member handshakes the moment it connects, as does the control plane's event publisher, which presents a credential of its own. What is left at the deadline is closed, so a connection without a session costs the router a minute, and the watermarks mean members. `0` keeps every connection. | | `--metrics-addr` | off | Serve `/metrics`, `/healthz`, `/readyz` and `/debug/peer` without authentication on this address. `/readyz` returns `200` once the router is enrolled and the libp2p host is up. Keep this address separate from the libp2p ports and inside the cluster. | | `--pprof-addr` | off | Serve Go runtime profiles under `/debug/pprof/` on this address, for attributing memory or goroutines the metrics show as held. Unauthenticated, and a heap profile is a dump of process memory, which holds the router's key: bind it to loopback and reach it with `kubectl port-forward`. | | `--log-level` | `info` | `debug`, `info`, `warn`, `error`. | @@ -57,22 +57,29 @@ replica count. 1. Fetches `/keys` and enrolls. The credential carries `role("mesh:role:router")` and the relay right. -2. Starts the libp2p host, the DHT in server mode, the relay service and - GossipSub. +2. Starts the libp2p host, the DHT it shares with the other routers, the + directory it serves to its members, the relay service and GossipSub. 3. Registers a lease at `POST /routers/lease` with its announced addresses and renews it every `--lease-renew-interval`. The control plane lists the router on `/info` while the lease is live. 4. Runs the mutual credential handshake on every inbound connection and refuses peers whose credential does not verify and peers on the ban list. Until a peer passes it, the router serves it nothing but the handshake: - a DHT or gossip stream it opens waits, unread, and is served once the - peer is authenticated, or reset when the connection closes at - `--auth-deadline` first. A member's streams open on connect, a moment - before its handshake completes, which is why they wait instead of being - refused outright. The routing table admits authenticated peer routers and - nothing else, and the router queries no one else either. -5. Refreshes its own credential before it expires, like a node. -6. Every 5 seconds, compares the members it holds, the peers that passed + a directory, DHT or gossip stream it opens waits, unread, and is served + once the peer is authenticated, or reset when the connection closes at + `--auth-deadline` first. A member's gossip stream opens on connect, a + moment before its handshake completes, which is why it waits instead of + being refused outright. The DHT's routing table admits authenticated + peer routers and nothing else, and the router queries no one else. +5. Serves the directory on `/mesh/directory/1.0.0` to the members it + holds. A member announces each service it publishes and where it is + reached; the router keeps the record and places it on the routers + closest to the key, as many as the mesh's replication (`/info`). A + member asks for providers of a key or for a peer; the router runs the + lookup across the routers' DHT and answers. A member keeps no DHT and + connects to no router it does not hold. +6. Refreshes its own credential before it expires, like a node. +7. Every 5 seconds, compares the members it holds, the peers that passed the handshake, with `--high-watermark`. At the mark it picks members at random, as many as take it back to `--low-watermark`, and sends each a go-away on `/mesh/goaway/1.0.0` with the reason `OVERLOADED` and a @@ -80,7 +87,7 @@ replica count. another router at once and stays off this one for that time; one that does not is closed after a 5 second grace, like any other. Connections to the other routers are never shed: the DHT runs over them. -7. On `SIGTERM` or `SIGINT`, sends a last lease with `--shutdown-lease-ttl`, +8. On `SIGTERM` or `SIGINT`, sends a last lease with `--shutdown-lease-ttl`, sends every member a go-away with the reason `DRAINING` and that ttl as the retry time, and after the same grace closes its connections. The control plane lists the router for that long and no longer, so a router @@ -122,10 +129,12 @@ The metrics address exposes, next to the Go runtime metrics: | `agentmesh_router_relay_limit{limit}` | The relay budget in force: `reservations`, `reservations_per_ip` and `circuits_per_peer`. | | `agentmesh_router_connected_peers`, `agentmesh_router_authenticated_peers`, `agentmesh_router_banned_peers` | Peers in each state. | | `agentmesh_router_peer_routers{state}` | Other routers this one knows, from the control plane's list or a handshake that proved the router role: `listed`, and `authenticated` for those it currently holds a mutual authentication with. The two differ while a federation pass is pending or a peer router is down. | -| `agentmesh_router_dht_routing_table_size` | DHT routing table size. | +| `agentmesh_router_dht_routing_table_size` | Peer routers in the DHT's routing table: the authenticated peer routers, since nothing else enters it. | +| `agentmesh_router_directory_requests_total{op,result}`, `agentmesh_router_directory_request_seconds{op}` | Directory requests served, by operation (`provide`, `find_providers`, `find_peer`, and `place` for a record another router put here) and result (`ok`, `empty`, `invalid`, `error`), with the time each took. | +| `agentmesh_router_directory_placements_total{result}` | Copies of a member's record this router placed on the routers closest to the key. | | `agentmesh_router_auth_handshakes_total{result}`, `agentmesh_router_lease_renewals_total{result}` | Handshakes and lease renewals by outcome. | | `agentmesh_router_goaway_sent_total{reason,result}` | Members sent away, by reason (`DRAINING`, `OVERLOADED`) and outcome: `ok` when the member took the message, `unsupported` when it does not speak the protocol and was closed instead, `failed` when the stream broke. A rising `OVERLOADED` count on a router that is not draining is a fleet that has outgrown its routers. | -| `agentmesh_router_unauthenticated_connections_closed_total` | Connections closed at `--auth-deadline` without a handshake. Members handshake with every router they connect to, so a steady rate is members running a node release that predates that, and a burst is a scanner. | +| `agentmesh_router_unauthenticated_connections_closed_total` | Connections closed at `--auth-deadline` without a handshake. A member handshakes with a router the moment it connects, so a steady rate is members that cannot complete the handshake, and a burst is a scanner. | | `agentmesh_router_inbound_connections_refused_total` | Inbound connections refused by the per-address cap. | | `agentmesh_router_streams_refused_total{scope,protocol}` | Streams refused, by scope and protocol or service. `scope="unauthenticated"` is a stream from a peer that never passed the handshake, reset when its connection closed; a steady rate is a scanner, or a member whose handshake fails. The other scopes are the resource manager's: a count on `/mesh/kad/1.0.0` or a `/meshsub/*` protocol there is members the router keeps out of discovery; see the scalability guide. | | `agentmesh_router_busiest_connection_streams{transport}` | The most streams any one connection carries. The connection to a service the router relays for holds one stream per relayed caller, and QUIC admits 256 per connection; see the scalability guide. | @@ -140,6 +149,6 @@ on the same address are the probes to use in a pod spec. `GET /debug/peer?peer_id=` on the same address is the router's view of one peer: its connectedness, whether it is authenticated here and whether it is a peer router, its open connections, and the addresses the router holds -for it, which are the ones it hands out in a DHT answer. It is a diagnostic +for it, which are the ones it hands out in a directory answer. It is a diagnostic for an operator reading why a member cannot reach another, and its shape is not versioned. diff --git a/tests/integration/federation_test.go b/tests/integration/federation_test.go index aad111cbb..e023a3851 100644 --- a/tests/integration/federation_test.go +++ b/tests/integration/federation_test.go @@ -265,17 +265,20 @@ roles: // Search for the service from Node B t.Log("Searching for tool from Node B...") - searchCmd := exec.Command(clientBin, - "-url", fmt.Sprintf("http://127.0.0.1:%d/mcp", apiPortB), - "-token", "tokenB", - "-tool", "discover_remote_services", - "-args", `{"type": "mcp"}`, - ) + search := func() ([]byte, error) { + return exec.Command(clientBin, + "-url", fmt.Sprintf("http://127.0.0.1:%d/mcp", apiPortB), + "-token", "tokenB", + "-tool", "discover_remote_services", + "-args", `{"type": "mcp"}`, + ).Output() + } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() var found bool + attempts := 0 for { select { case <-ctx.Done(): @@ -283,9 +286,12 @@ roles: t.Logf("Node A Stderr: %s", nodeStderrA.String()) t.Logf("Node B Stdout: %s", nodeStdoutB.String()) t.Logf("Node B Stderr: %s", nodeStderrB.String()) + t.Logf("Router A Stderr: %s", stderrRouterA.String()) + t.Logf("Router B Stderr: %s", stderrRouterB.String()) t.Fatalf("timed out waiting to discover tool from Node B.") default: - out, err := searchCmd.Output() + attempts++ + out, err := search() if err == nil { if strings.Contains(string(out), "federated-tool") { found = true @@ -299,7 +305,7 @@ roles: } } - t.Log("Successfully discovered federated tool via relay!") + t.Logf("Successfully discovered federated tool via relay after %d attempt(s)!", attempts) // Assert datapath works via Node B's local Egress Proxy t.Log("Testing datapath from Node B to Node A...") diff --git a/tests/integration/minimal_helpers_test.go b/tests/integration/minimal_helpers_test.go index 5f359470f..7646c3962 100644 --- a/tests/integration/minimal_helpers_test.go +++ b/tests/integration/minimal_helpers_test.go @@ -42,7 +42,7 @@ import ( "github.com/biscuit-auth/biscuit-go/v2" "github.com/google/agentmesh/api" "github.com/libp2p/go-libp2p" - dht "github.com/libp2p/go-libp2p-kad-dht" + "github.com/libp2p/go-libp2p/core/host" "github.com/libp2p/go-libp2p/core/network" "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-msgio" @@ -278,10 +278,7 @@ func startMockRouterWithControlPlaneKey(t *testing.T) (peer.ID, string, ed25519. _ = writer.WriteMsg(respBytes) }) - kdht, err := dht.New(h, dht.Mode(dht.ModeServer), dht.ProtocolPrefix("/mesh")) - if err != nil { - t.Fatalf("failed to create DHT on mock router: %v", err) - } + h.SetStreamHandler(api.DirectoryProtocolID, newMockDirectory(h).handle) // Start HTTP server for enrollment @@ -321,7 +318,6 @@ func startMockRouterWithControlPlaneKey(t *testing.T) (peer.ID, string, ed25519. t.Cleanup(func() { httpServer.Close() - _ = kdht.Close() _ = h.Close() }) @@ -656,6 +652,8 @@ func extractPeerID(maddrStr string) string { return "" } +// waitForDHTReady returns once the node holds a router: the directory it +// announces to and looks peers up through. func waitForDHTReady(t *testing.T, clientBin string, apiPort int, token string) { t.Helper() deadline := time.Now().Add(45 * time.Second) @@ -669,14 +667,12 @@ func waitForDHTReady(t *testing.T, clientBin string, apiPort int, token string) for time.Now().Before(deadline) { cmd := exec.Command(clientBin, cmdArgs...) out, err := cmd.Output() - if err == nil { - if strings.Contains(string(out), `"dht_size":`) && !strings.Contains(string(out), `"dht_size":0`) { - return - } + if err == nil && strings.Contains(string(out), `"attached_routers":["`) { + return } time.Sleep(500 * time.Millisecond) } - t.Fatalf("DHT not ready on port %d", apiPort) + t.Fatalf("node on port %d holds no router", apiPort) } func waitForControlPlane(t *testing.T, port int) { @@ -846,3 +842,60 @@ func newBoundaryMCPHandler(t *testing.T) http.Handler { }) return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv }, nil) } + +// mockDirectory is the directory a mock router serves: records by key, a +// peer from the router's peerstore or its peer-key record. +type mockDirectory struct { + h host.Host + mu sync.Mutex + records map[string]map[peer.ID][]string +} + +func newMockDirectory(h host.Host) *mockDirectory { + return &mockDirectory{h: h, records: map[string]map[peer.ID][]string{}} +} + +func (d *mockDirectory) handle(s network.Stream) { + defer func() { _ = s.Close() }() + reader := msgio.NewVarintReaderSize(s, 1024*64) + msg, err := reader.ReadMsg() + if err != nil { + return + } + var req api.DirectoryRequest + err = proto.Unmarshal(msg, &req) + reader.ReleaseMsg(msg) + if err != nil { + return + } + resp := &api.DirectoryResponse{} + d.mu.Lock() + switch { + case req.GetProvide() != nil: + q := req.GetProvide() + if d.records[string(q.GetKey())] == nil { + d.records[string(q.GetKey())] = map[peer.ID][]string{} + } + d.records[string(q.GetKey())][s.Conn().RemotePeer()] = q.GetAddrs() + case req.GetFindProviders() != nil: + for p, addrs := range d.records[string(req.GetFindProviders().GetKey())] { + resp.Providers = append(resp.Providers, &api.PeerRecord{PeerId: p.String(), Addrs: addrs}) + } + case req.GetFindPeer() != nil: + if p, err := peer.Decode(req.GetFindPeer().GetPeerId()); err == nil { + var addrs []string + for _, a := range d.h.Peerstore().Addrs(p) { + addrs = append(addrs, a.String()+"/p2p/"+p.String()) + } + if len(addrs) == 0 { + addrs = d.records[string(api.PeerKey(p))][p] + } + if len(addrs) > 0 { + resp.Peer = &api.PeerRecord{PeerId: p.String(), Addrs: addrs} + } + } + } + d.mu.Unlock() + respBytes, _ := proto.Marshal(resp) + _ = msgio.NewVarintWriter(s).WriteMsg(respBytes) +} diff --git a/tests/integration/openai_facade_test.go b/tests/integration/openai_facade_test.go index 2c0e7053b..9ba7a2f04 100644 --- a/tests/integration/openai_facade_test.go +++ b/tests/integration/openai_facade_test.go @@ -192,7 +192,8 @@ func TestOpenAIFacadeCUJ(t *testing.T) { t.Log("OpenAI facade CUJ test passed.") } -// waitForDHTPeers polls get_mesh_info until the node sees DHT peers. +// waitForDHTPeers polls get_mesh_info until the node holds a router: the +// directory it announces to and looks peers up through. func waitForDHTPeers(t *testing.T, apiAddr string) { t.Helper() deadline := time.Now().Add(10 * time.Second) @@ -200,13 +201,13 @@ func waitForDHTPeers(t *testing.T, apiAddr string) { respData := callMCP(t, apiAddr, "get_mesh_info", map[string]any{}) var data map[string]any if err := json.Unmarshal([]byte(respData), &data); err == nil { - if dhtSize, _ := data["dht_size"].(float64); dhtSize > 0 { + if routers, _ := data["attached_routers"].([]any); len(routers) > 0 { return } } time.Sleep(500 * time.Millisecond) } - t.Fatal("DHT not ready (size 0)") + t.Fatal("the node holds no router") } // waitForFacadeModel polls the consumer's /v1/models until the model appears. diff --git a/tests/integration/rotation_test.go b/tests/integration/rotation_test.go index 59911ab15..9fd42c28f 100644 --- a/tests/integration/rotation_test.go +++ b/tests/integration/rotation_test.go @@ -214,7 +214,7 @@ roles: if err != nil { t.Fatalf("failed to query node: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) } - if !strings.Contains(stdout, "dht_size") { + if !strings.Contains(stdout, "attached_routers") { t.Fatalf("unexpected node response: %s", stdout) } } diff --git a/tests/integration/router_attachment_test.go b/tests/integration/router_attachment_test.go index fc3cc1801..0268c3d95 100644 --- a/tests/integration/router_attachment_test.go +++ b/tests/integration/router_attachment_test.go @@ -233,17 +233,30 @@ func peerIDStrings(ids ...peer.ID) []string { // addresses through, from its own debug endpoint. func announcedCircuitRelays(t *testing.T, n *backgroundNode) []string { t.Helper() - var info struct { - AnnouncedAddresses []string `json:"announced_addresses"` - } + return circuitRelaysOf(networkInfo(t, n).AnnouncedAddresses) +} + +// recordedCircuitRelays lists the relays in the records the node last +// announced to the directory: what a lookup of its services returns, as +// against announcedCircuitRelays, what it will announce at its next pass. +func recordedCircuitRelays(t *testing.T, n *backgroundNode) []string { + t.Helper() + return circuitRelaysOf(networkInfo(t, n).DirectoryAddresses) +} + +func networkInfo(t *testing.T, n *backgroundNode) (info struct { + AnnouncedAddresses []string `json:"announced_addresses"` + DirectoryAddresses []string `json:"directory_addresses"` +}) { + t.Helper() if err := json.Unmarshal([]byte(debugGetWithToken(t, n.apiAddr, n.token, "/debug/network-info")), &info); err != nil { t.Fatalf("decode network-info: %v", err) } - return circuitRelaysOf(info.AnnouncedAddresses) + return info } -// circuitRelaysInRecord lists the relays in a peer's DHT record as reader -// looks it up: the addresses a call from reader would choose among. +// circuitRelaysInRecord lists the relays in a peer's record as reader +// looks it up through a router it holds. func circuitRelaysInRecord(t *testing.T, reader *backgroundNode, target peer.ID) []string { t.Helper() req, err := http.NewRequest(http.MethodGet, "http://"+reader.apiAddr+"/debug/peer-addrs?peer_id="+target.String(), nil) @@ -736,21 +749,24 @@ func TestCallerTransitsTheRouterItShares(t *testing.T) { caller.waitForAPI(t) waitForEqual(t, "routers attached by the provider", func() []string { return attachedRouters(t, provider) }, peerIDStrings(routerA.peerID, routerB.peerID), 10*time.Second) waitForEqual(t, "routers attached by the caller", func() []string { return attachedRouters(t, caller) }, peerIDStrings(routerB.peerID, routerC.peerID), 10*time.Second) - // The provider's record must name both circuits, or the caller has only + // The provider's records must name both circuits, or the caller has only // router a to go through. A reservation on the second router comes a - // moment after the first, and the record follows it after a 2 s debounce; - // the caller's DHT lookup is what the call will read. + // moment after the first, and the records follow it at the next + // announcement pass, 2 s later. A router that holds the provider answers + // a lookup from what it sees of it, which is ahead of the records; the + // call reads the service record, so the wait is on what the provider + // announced, then on what the caller reads. waitForEqual(t, "routers the provider announces circuits through", func() []string { return announcedCircuitRelays(t, provider) }, peerIDStrings(routerA.peerID, routerB.peerID), 15*time.Second) - waitForEqual(t, "relays in the provider's DHT record as the caller reads it", func() []string { return circuitRelaysInRecord(t, caller, provider.peerID) }, + waitForEqual(t, "relays in the records the provider announced", func() []string { return recordedCircuitRelays(t, provider) }, + peerIDStrings(routerA.peerID, routerB.peerID), 15*time.Second) + waitForEqual(t, "relays in the provider's record as the caller reads it", func() []string { return circuitRelaysInRecord(t, caller, provider.peerID) }, peerIDStrings(routerA.peerID, routerB.peerID), 15*time.Second) callAcrossMesh(t, caller, "token-c", provider, "shared-tool") // The caller holds router b, which the provider's record names, so the - // call went through it and no handshake was run for transit. Router a - // may still hold the caller for its DHT: the provider's key lands on - // the closest routers, which on a mesh of three is all of them. + // call went through it and no handshake was run for transit. for _, kind := range []string{"listed", "unlisted"} { for _, result := range []string{"ok", "failed"} { series := fmt.Sprintf(`agentmesh_node_transit_handshakes_total{result="%s",router="%s"}`, result, kind) @@ -808,9 +824,10 @@ func TestCallerTransitsARouterItsCatalogMissed(t *testing.T) { waitForEqual(t, "routers attached by the provider", func() []string { return attachedRouters(t, provider) }, peerIDStrings(routerB.peerID), 10*time.Second) waitForEqual(t, "routers the provider announces circuits through", func() []string { return announcedCircuitRelays(t, provider) }, peerIDStrings(routerB.peerID), 15*time.Second) - // A peer lookup by ID has to connect the peer, and the only path is the - // circuit through router b, so the record is not readable from here - // before the handshake there; discovery by service name needs no dial. + // Router a does not hold the provider, so only the record the provider + // announced under its peer key can answer the caller's lookup of it. + waitForEqual(t, "relays in the provider's record as the caller reads it", func() []string { return circuitRelaysInRecord(t, caller, provider.peerID) }, + peerIDStrings(routerB.peerID), 15*time.Second) callAcrossMesh(t, caller, "token-c", provider, "late-tool") // Router b admitted the provider, router a, the control plane's publisher diff --git a/tests/integration/service_discovery_test.go b/tests/integration/service_discovery_test.go index 457a04bfc..22554861a 100644 --- a/tests/integration/service_discovery_test.go +++ b/tests/integration/service_discovery_test.go @@ -73,25 +73,8 @@ func TestServiceDiscovery(t *testing.T) { // We use the multiplexed HTTP address for MCP calls too! connectPeer(t, actualApiAddrB, addrA) - // Wait for DHT to have peers on Node A - t.Log("Waiting for DHT to have peers on Node A...") - deadline := time.Now().Add(10 * time.Second) - var dhtReady bool - for time.Now().Before(deadline) { - respData := callMCP(t, actualApiAddrA, "get_mesh_info", map[string]any{}) - var data map[string]any - if err := json.Unmarshal([]byte(respData), &data); err == nil { - dhtSize, _ := data["dht_size"].(float64) - if dhtSize > 0 { - dhtReady = true - break - } - } - time.Sleep(500 * time.Millisecond) - } - if !dhtReady { - t.Fatalf("DHT not ready on Node A (size 0)") - } + t.Log("Waiting for Node A to hold a router...") + waitForDHTPeers(t, actualApiAddrA) // Wait for DHT propagation t.Log("Waiting for DHT propagation...") @@ -166,25 +149,8 @@ func TestServiceDiscoveryStreaming(t *testing.T) { // Connect Node B to Node A connectPeer(t, actualApiAddrB, addrA) - // Wait for DHT to have peers on Node A - t.Log("Waiting for DHT to have peers on Node A...") - deadline := time.Now().Add(10 * time.Second) - var dhtReady bool - for time.Now().Before(deadline) { - respData := callMCP(t, actualApiAddrA, "get_mesh_info", map[string]any{}) - var data map[string]any - if err := json.Unmarshal([]byte(respData), &data); err == nil { - dhtSize, _ := data["dht_size"].(float64) - if dhtSize > 0 { - dhtReady = true - break - } - } - time.Sleep(500 * time.Millisecond) - } - if !dhtReady { - t.Fatalf("DHT not ready on Node A") - } + t.Log("Waiting for Node A to hold a router...") + waitForDHTPeers(t, actualApiAddrA) // Wait for DHT propagation t.Log("Waiting for DHT propagation...")