@@ -811,8 +811,10 @@ func (s *Server) handleGetRegisteredAgent(w http.ResponseWriter, r *http.Request
811811 writeJSON (w , http .StatusOK , sanitizeAgentForRead (agent , privileged ))
812812}
813813
814- // handleListRegisteredAgents handles GET /v1/agents.
815- // Lists all registered agents from offchain store.
814+ // handleListRegisteredAgents handles signed GET /v1/agents. After app-v23 it
815+ // returns only active ordinary canonical enrollments; the unsigned full-roster
816+ // oracle was removed because it bypassed caller-scoped recipient discovery and
817+ // exposed local RBAC/network topology.
816818func (s * Server ) handleListRegisteredAgents (w http.ResponseWriter , r * http.Request ) {
817819 if s .agentStore == nil {
818820 writeProblem (w , http .StatusServiceUnavailable , "Agent store unavailable" , "Agent store not configured." )
@@ -821,20 +823,31 @@ func (s *Server) handleListRegisteredAgents(w http.ResponseWriter, r *http.Reque
821823
822824 agents , err := s .agentStore .ListAgents (r .Context ())
823825 if err != nil {
824- writeProblem (w , http .StatusInternalServerError , "List error" , err . Error () )
826+ writeProblem (w , http .StatusInternalServerError , "List error" , "The agent roster could not be read." )
825827 return
826828 }
827829 if agents == nil {
828830 agents = make ([]* store.AgentEntry , 0 )
829831 }
830832
831- // /v1/agents is unauthenticated — never expose claim_token (a one-time
832- // credential exchangeable for the agent key seed) or per-agent ACL topology.
833+ callerID := middleware . ContextAgentID ( r . Context ())
834+ privileged := s . callerIsOperatorOrAdmin ( r . Context (), callerID )
833835 sanitized := make ([]* store.AgentEntry , 0 , len (agents ))
834836 for _ , a := range agents {
835837 if a == nil {
836838 continue
837839 }
840+ if s .isPostV23ForNextTx () {
841+ active , activeErr := s .appV23ActiveOrdinaryAgent (a .AgentID )
842+ if activeErr != nil {
843+ writeProblem (w , http .StatusServiceUnavailable , "Access control unavailable" ,
844+ "Current local enrollment state is unavailable." )
845+ return
846+ }
847+ if ! active {
848+ continue
849+ }
850+ }
838851 isRoot , rootErr := s .appV23IsRootIdentity (a .AgentID )
839852 if rootErr != nil {
840853 writeProblem (w , http .StatusServiceUnavailable , "Access control unavailable" ,
@@ -845,7 +858,10 @@ func (s *Server) handleListRegisteredAgents(w http.ResponseWriter, r *http.Reque
845858 continue
846859 }
847860 s .overlayOnChainAgentPolicyForRead (a )
848- sanitized = append (sanitized , sanitizeAgentForRead (a , false ))
861+ sanitized = append (sanitized , sanitizeAgentForRead (
862+ a ,
863+ privileged || callerID == a .AgentID ,
864+ ))
849865 }
850866
851867 writeJSON (w , http .StatusOK , map [string ]any {
@@ -858,6 +874,47 @@ type agentNameFinder interface {
858874 FindAgentsByName (ctx context.Context , name string , limit int ) ([]* store.AgentEntry , error )
859875}
860876
877+ type agentNamePageFinder interface {
878+ FindAgentsByNamePage (ctx context.Context , name string , limit , offset int ) ([]* store.AgentEntry , error )
879+ }
880+
881+ type agentLookupResult struct {
882+ * store.AgentEntry
883+ MatchKind string `json:"match_kind"`
884+ }
885+
886+ // equalAgentLookupField applies the lookup endpoint's documented comparison:
887+ // ASCII letters are case-insensitive while every non-ASCII byte retains its
888+ // registered casing. strings.EqualFold is deliberately too broad here.
889+ func equalAgentLookupField (left , right string ) bool {
890+ if len (left ) != len (right ) {
891+ return false
892+ }
893+ for i := range len (left ) {
894+ l , r := left [i ], right [i ]
895+ if l >= 'A' && l <= 'Z' {
896+ l += 'a' - 'A'
897+ }
898+ if r >= 'A' && r <= 'Z' {
899+ r += 'a' - 'A'
900+ }
901+ if l != r {
902+ return false
903+ }
904+ }
905+ return true
906+ }
907+
908+ func agentLookupMatchKind (query string , agent * store.AgentEntry ) string {
909+ if agent != nil &&
910+ (equalAgentLookupField (query , agent .Name ) ||
911+ equalAgentLookupField (query , agent .RegisteredName ) ||
912+ equalAgentLookupField (query , agent .Provider )) {
913+ return "exact"
914+ }
915+ return "substring"
916+ }
917+
861918// handleFindRegisteredAgents is the signed, bounded companion to the public
862919// roster endpoint. MCP recipient discovery must not fetch ListAgents merely to
863920// return at most 20 matches: that full endpoint computes every agent's derived
@@ -886,25 +943,69 @@ func (s *Server) handleFindRegisteredAgents(w http.ResponseWriter, r *http.Reque
886943 }
887944 limit = parsed
888945 }
889- agents , err := finder .FindAgentsByName (r .Context (), name , limit )
890- if err != nil {
891- writeProblem (w , http .StatusInternalServerError , "Lookup error" , err .Error ())
892- return
893- }
894- sanitized := make ([]* store.AgentEntry , 0 , len (agents ))
895- for _ , agent := range agents {
896- if agent != nil {
897- isRoot , rootErr := s .appV23IsRootIdentity (agent .AgentID )
898- if rootErr != nil {
946+ // SQL status is only a discovery projection after app-v23; canonical active
947+ // enrollment lives in Badger. Page the bounded SQL candidates until the
948+ // requested number of canonical recipients is found or the query is
949+ // exhausted. Applying the public limit before this filter lets 20 pending
950+ // self-registrations hide every later active match.
951+ const (
952+ candidatePageSize = 20
953+ maxCandidatePages = 256
954+ )
955+ pager , paged := s .agentStore .(agentNamePageFinder )
956+ sanitized := make ([]agentLookupResult , 0 , limit )
957+ seen := make (map [string ]struct {}, limit )
958+ for offset := 0 ; len (sanitized ) < limit ; {
959+ if paged && offset / candidatePageSize >= maxCandidatePages {
960+ writeProblem (w , http .StatusServiceUnavailable , "Lookup incomplete" ,
961+ "The bounded agent candidate scan was exhausted; narrow the name query." )
962+ return
963+ }
964+ var agents []* store.AgentEntry
965+ var err error
966+ if paged {
967+ agents , err = pager .FindAgentsByNamePage (
968+ r .Context (), name , candidatePageSize , offset ,
969+ )
970+ } else {
971+ // Compatibility for tests and third-party stores that implement the
972+ // original bounded finder but not the paged extension.
973+ agents , err = finder .FindAgentsByName (r .Context (), name , limit )
974+ }
975+ if err != nil {
976+ writeProblem (w , http .StatusInternalServerError , "Lookup error" ,
977+ "The agent directory could not be searched." )
978+ return
979+ }
980+ for _ , agent := range agents {
981+ if agent == nil {
982+ continue
983+ }
984+ if _ , duplicate := seen [agent .AgentID ]; duplicate {
985+ continue
986+ }
987+ seen [agent .AgentID ] = struct {}{}
988+ active , activeErr := s .appV23ActiveOrdinaryAgent (agent .AgentID )
989+ if activeErr != nil {
899990 writeProblem (w , http .StatusServiceUnavailable , "Access control unavailable" ,
900- "Current CEREBRUM Root state is unavailable." )
991+ "Current local enrollment state is unavailable." )
901992 return
902993 }
903- if isRoot {
994+ if ! active {
904995 continue
905996 }
906- sanitized = append (sanitized , sanitizeAgentForRead (agent , false ))
997+ sanitized = append (sanitized , agentLookupResult {
998+ AgentEntry : sanitizeAgentForRead (agent , false ),
999+ MatchKind : agentLookupMatchKind (name , agent ),
1000+ })
1001+ if len (sanitized ) == limit {
1002+ break
1003+ }
1004+ }
1005+ if ! paged || len (agents ) < candidatePageSize {
1006+ break
9071007 }
1008+ offset += len (agents )
9081009 }
9091010 writeJSON (w , http .StatusOK , map [string ]any {"agents" : sanitized , "total" : len (sanitized )})
9101011}
0 commit comments