@@ -88,18 +88,16 @@ pub fn discovery_dirs() -> Vec<PathBuf> {
8888 . collect ( )
8989}
9090
91- /// Probe a Unix socket to check if it hosts a JSON-RPC primal.
92- ///
93- /// Sends `lifecycle.status` and parses the response for name and capabilities.
94- pub fn probe_socket ( path : & Path ) -> Option < PrimalEndpoint > {
91+ /// Send a single JSON-RPC request on a fresh connection and return the parsed response.
92+ fn rpc_probe ( path : & Path , method : & str ) -> Option < serde_json:: Value > {
9593 let stream = UnixStream :: connect ( path) . ok ( ) ?;
9694 let probe = Duration :: from_millis ( crate :: tolerances:: PROBE_TIMEOUT_MS ) ;
9795 stream. set_read_timeout ( Some ( probe) ) . ok ( ) ?;
9896 stream. set_write_timeout ( Some ( probe) ) . ok ( ) ?;
9997
10098 let request = serde_json:: json!( {
10199 "jsonrpc" : "2.0" ,
102- "method" : "lifecycle.status" ,
100+ "method" : method ,
103101 "id" : 1
104102 } ) ;
105103
@@ -113,7 +111,28 @@ pub fn probe_socket(path: &Path) -> Option<PrimalEndpoint> {
113111 let mut response = String :: new ( ) ;
114112 reader. read_line ( & mut response) . ok ( ) ?;
115113
116- let parsed: serde_json:: Value = serde_json:: from_str ( & response) . ok ( ) ?;
114+ serde_json:: from_str ( & response) . ok ( )
115+ }
116+
117+ /// Probe a Unix socket to check if it hosts a JSON-RPC primal.
118+ ///
119+ /// Tries `lifecycle.status` first (biomeOS standard), then falls back to
120+ /// `health.check` + `capabilities.list` (BearDog/Songbird convention).
121+ /// Any responsive primal also gets `health.check` and `system.ping`
122+ /// auto-registered since reachability implies ping-ability.
123+ pub fn probe_socket ( path : & Path ) -> Option < PrimalEndpoint > {
124+ // Strategy 1: lifecycle.status (biomeOS standard — name + capabilities in one call)
125+ if let Some ( ep) = probe_lifecycle_status ( path) {
126+ return Some ( ep) ;
127+ }
128+
129+ // Strategy 2: health.check for identity, capabilities.list for capabilities
130+ probe_health_then_capabilities ( path)
131+ }
132+
133+ /// Try `lifecycle.status` — the original biomeOS-standard probe.
134+ fn probe_lifecycle_status ( path : & Path ) -> Option < PrimalEndpoint > {
135+ let parsed = rpc_probe ( path, "lifecycle.status" ) ?;
117136 let result = parsed. get ( "result" ) ?;
118137
119138 let name = result
@@ -122,7 +141,8 @@ pub fn probe_socket(path: &Path) -> Option<PrimalEndpoint> {
122141 . unwrap_or ( "unknown" )
123142 . to_owned ( ) ;
124143
125- let capabilities = extract_capabilities ( result) ;
144+ let mut capabilities = extract_capabilities ( result) ;
145+ inject_base_capabilities ( & mut capabilities) ;
126146
127147 Some ( PrimalEndpoint {
128148 socket : path. to_owned ( ) ,
@@ -131,6 +151,132 @@ pub fn probe_socket(path: &Path) -> Option<PrimalEndpoint> {
131151 } )
132152}
133153
154+ /// Fallback probe: `health.check` for primal name, `capabilities.list` for capabilities.
155+ fn probe_health_then_capabilities ( path : & Path ) -> Option < PrimalEndpoint > {
156+ let health = rpc_probe ( path, "health.check" ) ?;
157+ let health_result = health. get ( "result" ) ?;
158+
159+ let name = health_result
160+ . get ( "primal" )
161+ . or_else ( || health_result. get ( "name" ) )
162+ . and_then ( serde_json:: Value :: as_str)
163+ . unwrap_or ( "unknown" )
164+ . to_owned ( ) ;
165+
166+ let mut capabilities = Vec :: new ( ) ;
167+
168+ if let Some ( caps_resp) = rpc_probe ( path, "capabilities.list" ) {
169+ if let Some ( result) = caps_resp. get ( "result" ) {
170+ capabilities = extract_capabilities_from_any ( result) ;
171+ }
172+ }
173+
174+ inject_base_capabilities ( & mut capabilities) ;
175+
176+ Some ( PrimalEndpoint {
177+ socket : path. to_owned ( ) ,
178+ name,
179+ capabilities,
180+ } )
181+ }
182+
183+ /// Extract capabilities from any known primal response format.
184+ ///
185+ /// Handles 6 formats observed across the ecosystem:
186+ ///
187+ /// - **Format A**: Flat string array `["cap1", "cap2"]`
188+ /// - **Format B**: Object array with `name` `[{"name": "cap1"}]`
189+ /// - **Format C**: Nested wrapper `{"capabilities": [...]}`
190+ /// - **Format D**: Double-nested `{"capabilities": {"capabilities": [...]}}`
191+ /// - **Format E**: BearDog `provided_capabilities` `[{"type": "crypto", "methods": [...]}]`
192+ /// - **Format F**: Top-level flat array (Songbird)
193+ fn extract_capabilities_from_any ( value : & serde_json:: Value ) -> Vec < String > {
194+ // Format F: top-level array (Songbird capabilities.list returns a direct array)
195+ if let Some ( arr) = value. as_array ( ) {
196+ return extract_from_array ( arr) ;
197+ }
198+
199+ let mut caps = Vec :: new ( ) ;
200+
201+ // Format E: BearDog provided_capabilities
202+ if let Some ( provided) = value
203+ . get ( "provided_capabilities" )
204+ . and_then ( serde_json:: Value :: as_array)
205+ {
206+ for entry in provided {
207+ if let Some ( cap_type) = entry. get ( "type" ) . and_then ( serde_json:: Value :: as_str) {
208+ caps. push ( cap_type. to_owned ( ) ) ;
209+ if let Some ( methods) = entry. get ( "methods" ) . and_then ( serde_json:: Value :: as_array) {
210+ for m in methods {
211+ if let Some ( method_name) = m. as_str ( ) {
212+ caps. push ( format ! ( "{cap_type}.{method_name}" ) ) ;
213+ }
214+ }
215+ }
216+ }
217+ }
218+ if !caps. is_empty ( ) {
219+ generate_semantic_aliases ( & mut caps) ;
220+ return caps;
221+ }
222+ }
223+
224+ // Formats A-D: delegate to existing extractor
225+ let extracted = extract_capabilities ( value) ;
226+ if !extracted. is_empty ( ) {
227+ return extracted;
228+ }
229+
230+ caps
231+ }
232+
233+ /// Generate well-known semantic aliases from capability types.
234+ ///
235+ /// When a primal advertises `crypto` with method-level capabilities like
236+ /// `crypto.blake3_hash`, also register `crypto.hash` since the primal
237+ /// likely implements the generic hash dispatcher.
238+ fn generate_semantic_aliases ( caps : & mut Vec < String > ) {
239+ let has = |name : & str , list : & [ String ] | list. iter ( ) . any ( |c| c == name) ;
240+
241+ let snapshot = caps. clone ( ) ;
242+ let mut additions = Vec :: new ( ) ;
243+
244+ if has ( "crypto" , & snapshot) && !has ( "crypto.hash" , & snapshot) {
245+ additions. push ( "crypto.hash" . to_owned ( ) ) ;
246+ }
247+ if has ( "crypto" , & snapshot) && !has ( "crypto.encrypt" , & snapshot) {
248+ if has ( "crypto.chacha20_poly1305_encrypt" , & snapshot) {
249+ additions. push ( "crypto.encrypt" . to_owned ( ) ) ;
250+ }
251+ }
252+ if has ( "crypto" , & snapshot) && !has ( "crypto.decrypt" , & snapshot) {
253+ if has ( "crypto.chacha20_poly1305_decrypt" , & snapshot) {
254+ additions. push ( "crypto.decrypt" . to_owned ( ) ) ;
255+ }
256+ }
257+ if has ( "crypto" , & snapshot) && !has ( "crypto.sign" , & snapshot) {
258+ if has ( "crypto.sign_ed25519" , & snapshot) {
259+ additions. push ( "crypto.sign" . to_owned ( ) ) ;
260+ }
261+ }
262+ if has ( "crypto" , & snapshot) && !has ( "crypto.verify" , & snapshot) {
263+ if has ( "crypto.verify_ed25519" , & snapshot) {
264+ additions. push ( "crypto.verify" . to_owned ( ) ) ;
265+ }
266+ }
267+
268+ caps. extend ( additions) ;
269+ }
270+
271+ /// Auto-register base capabilities for any responsive primal.
272+ fn inject_base_capabilities ( caps : & mut Vec < String > ) {
273+ for base in [ "system.ping" , "health.check" , "health.liveness" ] {
274+ if !caps. iter ( ) . any ( |c| c == base) {
275+ caps. push ( base. to_owned ( ) ) ;
276+ }
277+ }
278+ }
279+
134280/// Extract capabilities from a `lifecycle.status` or `capability.list` response.
135281///
136282/// Handles all 4 formats observed across the ecosystem (airSpring v0.8.7,
@@ -400,6 +546,71 @@ mod tests {
400546 assert_eq ! ( caps, vec![ "valid" , "also_valid" ] ) ;
401547 }
402548
549+ #[ test]
550+ fn extract_format_e_beardog_provided_capabilities ( ) {
551+ let result = serde_json:: json!( {
552+ "provided_capabilities" : [
553+ {
554+ "type" : "crypto" ,
555+ "version" : "1.0" ,
556+ "methods" : [ "blake3_hash" , "hmac_sha256" , "chacha20_poly1305_encrypt" ,
557+ "chacha20_poly1305_decrypt" , "sign_ed25519" , "verify_ed25519" ]
558+ } ,
559+ {
560+ "type" : "security" ,
561+ "methods" : [ "evaluate" , "lineage" ]
562+ }
563+ ]
564+ } ) ;
565+ let caps = extract_capabilities_from_any ( & result) ;
566+ assert ! ( caps. contains( & "crypto" . to_owned( ) ) ) ;
567+ assert ! ( caps. contains( & "crypto.blake3_hash" . to_owned( ) ) ) ;
568+ assert ! ( caps. contains( & "crypto.hash" . to_owned( ) ) , "semantic alias" ) ;
569+ assert ! ( caps. contains( & "crypto.encrypt" . to_owned( ) ) , "semantic alias" ) ;
570+ assert ! ( caps. contains( & "crypto.sign" . to_owned( ) ) , "semantic alias" ) ;
571+ assert ! ( caps. contains( & "security" . to_owned( ) ) ) ;
572+ assert ! ( caps. contains( & "security.evaluate" . to_owned( ) ) ) ;
573+ }
574+
575+ #[ test]
576+ fn extract_format_f_songbird_flat_array ( ) {
577+ let result = serde_json:: json!( [
578+ "network.discovery" ,
579+ "network.federation" ,
580+ "ipc.jsonrpc" ,
581+ "crypto.delegate"
582+ ] ) ;
583+ let caps = extract_capabilities_from_any ( & result) ;
584+ assert_eq ! (
585+ caps,
586+ vec![
587+ "network.discovery" ,
588+ "network.federation" ,
589+ "ipc.jsonrpc" ,
590+ "crypto.delegate"
591+ ]
592+ ) ;
593+ }
594+
595+ #[ test]
596+ fn inject_base_capabilities_adds_ping ( ) {
597+ let mut caps = vec ! [ "crypto" . to_owned( ) ] ;
598+ inject_base_capabilities ( & mut caps) ;
599+ assert ! ( caps. contains( & "system.ping" . to_owned( ) ) ) ;
600+ assert ! ( caps. contains( & "health.check" . to_owned( ) ) ) ;
601+ assert ! ( caps. contains( & "health.liveness" . to_owned( ) ) ) ;
602+ }
603+
604+ #[ test]
605+ fn inject_base_capabilities_does_not_duplicate ( ) {
606+ let mut caps = vec ! [ "system.ping" . to_owned( ) , "health.check" . to_owned( ) ] ;
607+ inject_base_capabilities ( & mut caps) ;
608+ assert_eq ! (
609+ caps. iter( ) . filter( |c| c. as_str( ) == "system.ping" ) . count( ) ,
610+ 1
611+ ) ;
612+ }
613+
403614 // ── Proptest fuzz (airSpring v0.8.7 pattern) ────────────────────
404615
405616 mod proptest_fuzz {
0 commit comments