1use crate::node_controller::{G4aMessage, OperatorLink};
6use gate4agent_node_protocol::{
7 read_json_frame_limited_body_timeout, write_json_frame_limited, FrameError,
8};
9use gate4agent_node_wire::{local_hmac_sha256, proofs_match, random_nonce, OwnerOnlyLocalListener};
10use hatchery_c2_protocol::{
11 c2_auth_transcript, c2_bound_auth_transcript, ArchitectureId, BUILD_STAMP, C2AuthDirection,
12 C2ClientAuthentication, C2ClientFrame, C2ClientHello, C2ControlCompatibilitySupport, C2Hello,
13 C2RelayFailure, C2RelayFailureCode, C2ReplyEnvelope, C2RequestEnvelope, C2ServerChallenge,
14 C2ServerFrame, C2Topology, CapabilityId, ClientCompatibilityOffer, HostDescriptor,
15 NegotiatedC2ControlCompatibility, NodeFreshness, NodeId, NodeTransportState, ObservedNode,
16 OperatingSystemId, PathEncoding, PathSemantics, PathStyle, RoutedNodeRequest, RoutedNodeResponse,
17 StatusResponse, C2_ACP_CONTROL_CAPABILITY, C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
18 C2_AGENT_STREAM_EVENTS_CAPABILITY, C2_API_VERSION, C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
19 C2_COMPATIBILITY_METADATA_CAPABILITY, C2_CONTROL_DETAIL_CAPABILITY,
20 C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY, C2_GIT_READ_CAPABILITY,
21 C2_HARNESS_MCP_READ_PROXY_CAPABILITY, C2_HISTORY_CONTEXT_PACK_CAPABILITY,
22 C2_HOST_DIRECTORY_BROWSE_CAPABILITY, C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
23 C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY, C2_NATIVE_SESSION_CATALOG_CAPABILITY,
24 C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY, C2_NATIVE_SESSION_INDEX_CAPABILITY,
25 C2_NATIVE_SESSION_PREVIEW_CAPABILITY, C2_OPAQUE_UNIX_PATH_CAPABILITY,
26 C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, C2_PROVIDER_ID_OPEN_CAPABILITY,
27 C2_PROVIDER_RUNTIME_STATUS_CAPABILITY, C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
28 C2_REPOSITORY_PATH_CAPABILITY, C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
29 C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY, C2_SESSION_TASK_CORRELATION_CAPABILITY,
30 C2_SPAWN_PROFILE_REVISION_CAPABILITY, C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
31 C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY, C2_TERMINAL_FRAME_EVENTS_CAPABILITY,
32 C2_WORKSPACE_ENTRY_CREATE_CAPABILITY, C2_WORKSPACE_FILE_READ_CAPABILITY,
33 C2_WORKSPACE_FILE_WRITE_CAPABILITY, C2_WORKTREE_SELECTION_CAPABILITY,
34 MAX_C2_AUTH_FRAME_BYTES, MAX_C2_CLIENT_FRAME_BYTES, MAX_C2_HELLO_FRAME_BYTES,
35 MAX_C2_SERVER_FRAME_BYTES,
36};
37use std::collections::BTreeMap;
38use std::io;
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::{Arc, Mutex};
41use std::time::Duration;
42use tokio::sync::{watch, Semaphore};
43#[cfg(test)]
44use tokio::sync::mpsc;
45use tokio::task::JoinSet;
46use tokio::time::timeout;
47
48const AUTH_DEADLINE: Duration = Duration::from_secs(5);
49const REQUEST_DEADLINE: Duration = Duration::from_secs(60);
50
51#[derive(Clone)]
53pub struct NodeOperatorMesh {
54 inner: Arc<Mutex<BTreeMap<String, OperatorLink>>>,
55 cursors: Arc<Mutex<BTreeMap<String, hatchery_c2_protocol::NodeCursor>>>,
56 topology: watch::Sender<Arc<StatusResponse>>,
57}
58
59impl NodeOperatorMesh {
60 pub fn new() -> Self {
61 let initial = Arc::new(StatusResponse {
62 api_version: C2_API_VERSION,
63 ready: true,
64 observed_at_unix_ms: unix_ms(),
65 nodes: BTreeMap::new(),
66 });
67 let (topology, _) = watch::channel(initial);
68 Self {
69 inner: Arc::new(Mutex::new(BTreeMap::new())),
70 cursors: Arc::new(Mutex::new(BTreeMap::new())),
71 topology,
72 }
73 }
74
75 pub fn subscribe(&self) -> watch::Receiver<Arc<StatusResponse>> {
76 self.topology.subscribe()
77 }
78
79 pub fn register(&self, node_id: impl Into<String>, link: OperatorLink) {
80 let id = node_id.into();
81 let _ = NodeId::new(id.clone()).expect("operator mesh node id");
82 self.inner.lock().expect("mesh").insert(id.clone(), link);
83 self.publish_topology();
84 tracing::info!(%id, "core operator mesh: node registered");
85 }
86
87 pub fn unregister(&self, node_id: &str) {
88 self.inner.lock().expect("mesh").remove(node_id);
89 self.cursors.lock().expect("cursors").remove(node_id);
90 self.publish_topology();
91 }
92
93 pub fn get(&self, node_id: &str) -> Option<OperatorLink> {
94 self.inner.lock().expect("mesh").get(node_id).cloned()
95 }
96
97 pub fn set_cursor(&self, node_id: &str, cursor: hatchery_c2_protocol::NodeCursor) {
99 self.cursors.lock().expect("cursors").insert(node_id.to_owned(), cursor);
100 self.publish_topology();
101 }
102
103 fn publish_topology(&self) {
104 let ids: Vec<String> = self.inner.lock().expect("mesh").keys().cloned().collect();
105 let cursors = self.cursors.lock().expect("cursors").clone();
106 let mut nodes = BTreeMap::new();
107 for id in &ids {
108 let node_id = NodeId::new(id.clone()).expect("registered ids are valid");
109 nodes.insert(
110 node_id,
111 ObservedNode {
112 endpoint: "drain".into(),
113 transport_label: "drain".into(),
114 transport: NodeTransportState::Online,
115 freshness: NodeFreshness::Fresh,
116 cursor: cursors.get(id).cloned(),
117 inventory: None,
118 last_attempt_unix_ms: None,
119 last_success_unix_ms: Some(unix_ms()),
120 consecutive_failures: 0,
121 last_error: None,
122 gaps: Vec::new(),
123 gaps_truncated: 0,
124 },
125 );
126 }
127 let status = Arc::new(StatusResponse {
128 api_version: C2_API_VERSION,
129 ready: true,
130 observed_at_unix_ms: unix_ms(),
131 nodes,
132 });
133 self.topology.send_replace(status);
134 }
135}
136
137fn unix_ms() -> u64 {
138 std::time::SystemTime::now()
139 .duration_since(std::time::UNIX_EPOCH)
140 .unwrap_or_default()
141 .as_millis() as u64
142}
143
144pub async fn serve_local(
146 endpoint: impl Into<String>,
147 token: impl Into<String>,
148 mesh: NodeOperatorMesh,
149 mut shutdown: watch::Receiver<bool>,
150) -> io::Result<()> {
151 let endpoint = endpoint.into();
152 let token = token.into();
153 let mut listener = OwnerOnlyLocalListener::bind(&endpoint).await?;
154 tracing::info!(%endpoint, "core operator control listening");
155 let preauth = Arc::new(Semaphore::new(4));
156 let authenticated = Arc::new(Semaphore::new(1));
157 let next_id = Arc::new(AtomicU64::new(1));
158 let mut connections = JoinSet::new();
159 loop {
160 let permit = tokio::select! {
161 p = Arc::clone(&preauth).acquire_owned() => p.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "preauth closed"))?,
162 changed = shutdown.changed() => {
163 if changed.is_err() || *shutdown.borrow() { break; }
164 continue;
165 }
166 };
167 let stream = tokio::select! {
168 a = listener.accept() => a?,
169 changed = shutdown.changed() => {
170 if changed.is_err() || *shutdown.borrow() { break; }
171 continue;
172 }
173 };
174 let token = token.clone();
175 let mesh = mesh.clone();
176 let authenticated = Arc::clone(&authenticated);
177 let next_id = Arc::clone(&next_id);
178 let sd = shutdown.clone();
179 connections.spawn(async move {
180 let _ = serve_connection(stream, permit, authenticated, next_id, &token, mesh, sd).await;
181 });
182 while connections.try_join_next().is_some() {}
183 }
184 connections.shutdown().await;
185 Ok(())
186}
187
188async fn serve_connection<S>(
189 mut pipe: S,
190 preauth: tokio::sync::OwnedSemaphorePermit,
191 authenticated: Arc<Semaphore>,
192 connection_ids: Arc<AtomicU64>,
193 token: &str,
194 mesh: NodeOperatorMesh,
195 mut shutdown: watch::Receiver<bool>,
196) -> Result<(), FrameError>
197where
198 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
199{
200 let hello = timeout(AUTH_DEADLINE, read_client_frame(&mut pipe, MAX_C2_AUTH_FRAME_BYTES))
201 .await.map_err(|_| FrameError::PrefixTimedOut)??;
202 let C2ClientFrame::Hello(hello) = hello else { return Ok(()) };
203 if hello.build_stamp != BUILD_STAMP {
204 let _ = timeout(AUTH_DEADLINE, write_json_frame_limited(
205 &mut pipe,
206 &C2ServerFrame::Rejected(fail(C2RelayFailureCode::BuildStampMismatch, "build stamp mismatch")),
207 MAX_C2_AUTH_FRAME_BYTES,
208 )).await;
209 return Ok(());
210 }
211 let negotiated = negotiate(&hello)?;
212 let compatibility = hello.compatibility.as_ref().map(|_| negotiated.clone());
213 let server_nonce = random_nonce().map_err(|e| FrameError::Io(io::Error::other(e)))?;
214 let auth_compat = hello.compatibility.as_ref().zip(compatibility.as_ref());
215 let server_proof = c2_proof(token, C2AuthDirection::Server, &hello.client_nonce, &server_nonce, auth_compat)?;
216 timeout(AUTH_DEADLINE, write_json_frame_limited(
217 &mut pipe,
218 &C2ServerFrame::Challenge(C2ServerChallenge {
219 build_stamp: BUILD_STAMP.to_owned(),
220 server_nonce,
221 server_proof,
222 compatibility: compatibility.clone(),
223 }),
224 MAX_C2_AUTH_FRAME_BYTES,
225 )).await.map_err(|_| FrameError::BodyTimedOut { length: 0 })??;
226
227 let authentication = timeout(AUTH_DEADLINE, read_client_frame(&mut pipe, MAX_C2_AUTH_FRAME_BYTES))
228 .await.map_err(|_| FrameError::PrefixTimedOut)??;
229 let C2ClientFrame::Authenticate(C2ClientAuthentication { client_proof }) = authentication else {
230 return Ok(());
231 };
232 let expected = c2_proof(token, C2AuthDirection::Client, &hello.client_nonce, &server_nonce, auth_compat)?;
233 if !proofs_match(&client_proof, &expected) {
234 return Ok(());
235 }
236 drop(preauth);
237
238 let Ok(_op) = authenticated.try_acquire_owned() else {
239 let _ = write_json_frame_limited(
240 &mut pipe,
241 &C2ServerFrame::Rejected(fail(C2RelayFailureCode::OperatorAlreadyConnected, "operator already connected")),
242 MAX_C2_SERVER_FRAME_BYTES,
243 ).await;
244 return Ok(());
245 };
246
247 let connection_id = connection_ids.fetch_add(1, Ordering::AcqRel);
248 let mut status_rx = mesh.subscribe();
249 let hello_status = status_rx.borrow().as_ref().clone();
250 timeout(AUTH_DEADLINE, write_json_frame_limited(
251 &mut pipe,
252 &C2ServerFrame::Hello(C2Hello {
253 build_stamp: BUILD_STAMP.to_owned(),
254 connection_id,
255 status: hello_status,
256 compatibility,
257 }),
258 MAX_C2_HELLO_FRAME_BYTES,
259 )).await.map_err(|_| FrameError::BodyTimedOut { length: 0 })??;
260
261 let (mut reader, mut writer) = tokio::io::split(pipe);
262 let mut last_topology = C2Topology::from_status(status_rx.borrow().as_ref());
263 tracing::info!(connection_id, "core operator control opened");
264
265 loop {
266 tokio::select! {
267 _ = shutdown.changed() => {
268 if *shutdown.borrow() { break; }
269 }
270 changed = status_rx.changed() => {
271 if changed.is_err() { break; }
272 let status = status_rx.borrow().as_ref().clone();
273 let topology = C2Topology::from_status(&status);
274 if topology != last_topology {
275 last_topology = topology.clone();
276 if write_json_frame_limited(&mut writer, &C2ServerFrame::Topology(topology), MAX_C2_SERVER_FRAME_BYTES).await.is_err() {
277 break;
278 }
279 }
280 }
281 frame = read_client_frame(&mut reader, MAX_C2_CLIENT_FRAME_BYTES) => {
282 match frame {
283 Ok(C2ClientFrame::Request(envelope)) => {
284 let reply = dispatch_request(&mesh, envelope).await;
285 if write_json_frame_limited(&mut writer, &C2ServerFrame::Reply(reply), MAX_C2_SERVER_FRAME_BYTES).await.is_err() {
286 break;
287 }
288 }
289 Ok(_) | Err(_) => break,
290 }
291 }
292 }
293 }
294 Ok(())
295}
296
297async fn dispatch_request(mesh: &NodeOperatorMesh, envelope: C2RequestEnvelope) -> C2ReplyEnvelope {
298 let node_id = envelope.request.route.node_id.as_str().to_owned();
299 let Some(link) = mesh.get(&node_id) else {
300 return C2ReplyEnvelope {
301 request_id: envelope.request_id,
302 result: Err(fail(C2RelayFailureCode::NodeOffline, "node not on drain")),
303 };
304 };
305 let routed = RoutedNodeRequest {
306 route: envelope.request.route.clone(),
307 request: envelope.request.request.clone(),
308 };
309 match link.request(&routed, REQUEST_DEADLINE).await {
310 Ok(G4aMessage::Response(response)) => C2ReplyEnvelope {
311 request_id: envelope.request_id,
312 result: Ok(response),
313 },
314 Ok(G4aMessage::Json(v)) => {
315 match serde_json::from_value::<RoutedNodeResponse>(v) {
317 Ok(response) => C2ReplyEnvelope { request_id: envelope.request_id, result: Ok(response) },
318 Err(_) => C2ReplyEnvelope {
319 request_id: envelope.request_id,
320 result: Err(fail(C2RelayFailureCode::RequestForbidden, "node reply was not a g4a response")),
321 },
322 }
323 }
324 Ok(_) => C2ReplyEnvelope {
325 request_id: envelope.request_id,
326 result: Err(fail(C2RelayFailureCode::RequestForbidden, "unexpected g4a reply kind")),
327 },
328 Err(_) => C2ReplyEnvelope {
329 request_id: envelope.request_id,
330 result: Err(fail(C2RelayFailureCode::RelayBusy, "node did not answer")),
331 },
332 }
333}
334
335fn fail(code: C2RelayFailureCode, message: &str) -> C2RelayFailure {
336 C2RelayFailure { code, message: message.to_owned(), current_incarnation_id: None }
337}
338
339fn negotiate(hello: &C2ClientHello) -> Result<NegotiatedC2ControlCompatibility, FrameError> {
340 let support = C2ControlCompatibilitySupport {
341 build_stamp: BUILD_STAMP.to_owned(),
342 capabilities: vec![
343 CapabilityId::new(C2_COMPATIBILITY_METADATA_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
344 CapabilityId::new(C2_OPAQUE_UNIX_PATH_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
345 CapabilityId::new(C2_REPOSITORY_PATH_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
346 CapabilityId::new(C2_WORKSPACE_FILE_READ_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
347 CapabilityId::new(C2_WORKSPACE_FILE_WRITE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
348 CapabilityId::new(C2_WORKSPACE_ENTRY_CREATE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
349 CapabilityId::new(C2_GIT_READ_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
350 CapabilityId::new(C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
351 CapabilityId::new(C2_PROVIDER_RUNTIME_STATUS_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
352 CapabilityId::new(C2_PROVIDER_ID_OPEN_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
353 CapabilityId::new(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
354 CapabilityId::new(C2_SPAWN_PROFILE_REVISION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
355 CapabilityId::new(C2_TERMINAL_FRAME_EVENTS_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
356 CapabilityId::new(C2_AGENT_STREAM_EVENTS_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
357 CapabilityId::new(C2_WORKTREE_SELECTION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
358 CapabilityId::new(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
359 CapabilityId::new(C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
360 CapabilityId::new(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
361 CapabilityId::new(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
362 CapabilityId::new(C2_HISTORY_CONTEXT_PACK_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
363 CapabilityId::new(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
364 CapabilityId::new(C2_NATIVE_SESSION_CATALOG_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
365 CapabilityId::new(C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
366 CapabilityId::new(C2_NATIVE_SESSION_INDEX_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
367 CapabilityId::new(C2_NATIVE_SESSION_PREVIEW_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
368 CapabilityId::new(C2_HOST_DIRECTORY_BROWSE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
369 CapabilityId::new(C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
370 CapabilityId::new(C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
371 CapabilityId::new(C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
372 CapabilityId::new(C2_SESSION_TASK_CORRELATION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
373 CapabilityId::new(C2_CONTROL_DETAIL_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
374 CapabilityId::new(C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
375 CapabilityId::new(C2_HARNESS_MCP_READ_PROXY_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
376 CapabilityId::new(C2_ACP_CONTROL_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
377 ],
378 host: HostDescriptor {
379 operating_system: OperatingSystemId::new(std::env::consts::OS)
380 .map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
381 architecture: ArchitectureId::new(std::env::consts::ARCH)
382 .map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
383 },
384 path_semantics: PathSemantics {
385 style: if cfg!(windows) { PathStyle::Windows } else { PathStyle::Posix },
386 encoding: if cfg!(windows) { PathEncoding::Utf8 } else { PathEncoding::UnixBytes },
387 },
388 };
389 support.negotiate(hello).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))
390}
391
392fn c2_proof(
393 token: &str,
394 direction: C2AuthDirection,
395 client_nonce: &[u8; 32],
396 server_nonce: &[u8; 32],
397 compatibility: Option<(&ClientCompatibilityOffer, &NegotiatedC2ControlCompatibility)>,
398) -> Result<[u8; 32], FrameError> {
399 let transcript = match compatibility {
400 Some((offer, selected)) => c2_bound_auth_transcript(direction, client_nonce, server_nonce, offer, selected)
401 .map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
402 None => c2_auth_transcript(direction, client_nonce, server_nonce),
403 };
404 local_hmac_sha256(token.as_bytes(), &transcript).map_err(|e| FrameError::Io(io::Error::other(e)))
405}
406
407async fn read_client_frame<S: tokio::io::AsyncRead + Unpin>(
408 stream: &mut S,
409 max: usize,
410) -> Result<C2ClientFrame, FrameError> {
411 read_json_frame_limited_body_timeout(stream, max, AUTH_DEADLINE).await
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use hatchery_c2_client::connect_local;
418 use std::time::Duration;
419
420 #[tokio::test]
421 async fn clients_connect_to_core_operator_not_c2() {
422 let root = tempfile::tempdir().unwrap();
423 let sockdir = root.path().join("sock");
424 #[cfg(unix)]
425 {
426 use std::os::unix::fs::DirBuilderExt;
427 std::fs::DirBuilder::new().mode(0o700).create(&sockdir).unwrap();
428 }
429 #[cfg(not(unix))]
430 std::fs::create_dir_all(&sockdir).unwrap();
431 let endpoint = sockdir.join("core.sock");
432 let mesh = NodeOperatorMesh::new();
433 let (events_tx, _events_rx) = mpsc::unbounded_channel();
434 let (link, _outbound) = OperatorLink::new(Some(events_tx));
435 mesh.register("unix-local", link);
439
440 let (stop, rx) = watch::channel(false);
441 let endpoint_s = endpoint.to_string_lossy().into_owned();
442 let mesh2 = mesh.clone();
443 tokio::spawn(async move { serve_local(endpoint_s, "core-op-token-0123456789", mesh2, rx).await });
444 mesh.register("unix-local", {
446 let (events_tx, _) = mpsc::unbounded_channel();
447 OperatorLink::new(Some(events_tx)).0
448 });
449 tokio::time::sleep(Duration::from_millis(80)).await;
450
451 let (control, _ev) = tokio::time::timeout(
452 Duration::from_secs(5),
453 connect_local(endpoint.to_str().unwrap(), "core-op-token-0123456789"),
454 )
455 .await
456 .expect("timeout")
457 .expect("connect");
458 assert_eq!(control.hello().build_stamp, BUILD_STAMP);
459 let hello_nodes: Vec<_> = control.hello().status.nodes.keys().map(|k| k.as_str().to_owned()).collect();
460 assert!(hello_nodes.iter().any(|n| n == "unix-local"), "hello status nodes={hello_nodes:?}");
461 let topo = control.current_topology();
462 assert!(!topo.nodes.is_empty() || !hello_nodes.is_empty(), "topology={:?} hello={hello_nodes:?}", topo.nodes);
463 let _ = stop.send(true);
464 }
465}