Skip to main content

hatchery_server/
operator_control.rs

1//! Core operator control (A2): same client wire as the former C2 control socket,
2//! but requests go through [`crate::node_controller::OperatorLink`] over the drain.
3//! C2 never sees this socket and never parses g4a.
4
5use 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_core::{
11    c2_auth_transcript, c2_bound_auth_transcript, ArchitectureId, BUILD_STAMP, BrowserClientCommand,
12    BrowserStreamEvent, C2AuthDirection,
13    C2ClientAuthentication, C2ClientFrame, C2ClientHello, C2ControlCompatibilitySupport, C2Hello,
14    C2RelayFailure, C2RelayFailureCode, C2ReplyEnvelope, C2RequestEnvelope, C2ServerChallenge,
15    C2ServerFrame, C2Topology, CapabilityId, ClientCompatibilityOffer, HostDescriptor,
16    NegotiatedC2ControlCompatibility, NodeFreshness, NodeId, NodeTransportState, ObservedNode,
17    OperatingSystemId, PathEncoding, PathSemantics, PathStyle, RoutedNodeRequest, RoutedNodeResponse,
18    StatusResponse, C2_ACP_CONTROL_CAPABILITY, C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
19    C2_AGENT_STREAM_EVENTS_CAPABILITY, C2_API_VERSION, C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
20    C2_COMPATIBILITY_METADATA_CAPABILITY, C2_CONTROL_DETAIL_CAPABILITY,
21    C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY, C2_GIT_READ_CAPABILITY,
22    C2_HARNESS_MCP_READ_PROXY_CAPABILITY, C2_HISTORY_CONTEXT_PACK_CAPABILITY,
23    C2_HOST_DIRECTORY_BROWSE_CAPABILITY, C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
24    C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY, C2_NATIVE_SESSION_CATALOG_CAPABILITY,
25    C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY, C2_NATIVE_SESSION_INDEX_CAPABILITY,
26    C2_NATIVE_SESSION_PREVIEW_CAPABILITY, C2_OPAQUE_UNIX_PATH_CAPABILITY,
27    C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, C2_PROVIDER_ID_OPEN_CAPABILITY,
28    C2_PROVIDER_RUNTIME_STATUS_CAPABILITY, C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
29    C2_REPOSITORY_PATH_CAPABILITY, C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
30    C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY, C2_SESSION_TASK_CORRELATION_CAPABILITY,
31    C2_SPAWN_PROFILE_REVISION_CAPABILITY, C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
32    C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY, C2_TERMINAL_FRAME_EVENTS_CAPABILITY,
33    C2_WORKSPACE_ENTRY_CREATE_CAPABILITY, C2_WORKSPACE_FILE_READ_CAPABILITY,
34    C2_WORKSPACE_FILE_WRITE_CAPABILITY, C2_WORKTREE_SELECTION_CAPABILITY,
35    MAX_C2_AUTH_FRAME_BYTES, MAX_C2_CLIENT_FRAME_BYTES, MAX_C2_HELLO_FRAME_BYTES,
36    MAX_C2_SERVER_FRAME_BYTES,
37};
38use std::collections::BTreeMap;
39use std::io;
40use std::sync::atomic::{AtomicU64, Ordering};
41use std::sync::{Arc, Mutex};
42use std::time::Duration;
43use tokio::sync::{broadcast, watch, Semaphore};
44#[cfg(test)]
45use tokio::sync::mpsc;
46use tokio::task::JoinSet;
47use tokio::time::timeout;
48
49const AUTH_DEADLINE: Duration = Duration::from_secs(5);
50const REQUEST_DEADLINE: Duration = Duration::from_secs(60);
51
52/// Live node control sessions on core (one OperatorLink per node).
53#[derive(Clone)]
54pub struct NodeOperatorMesh {
55    inner: Arc<Mutex<BTreeMap<String, OperatorLink>>>,
56    cursors: Arc<Mutex<BTreeMap<String, hatchery_core::NodeCursor>>>,
57    machines: Arc<Mutex<BTreeMap<String, hatchery_core::MachineEnvironment>>>,
58    topology: watch::Sender<Arc<StatusResponse>>,
59    /// Fan-out of node-initiated g4a events (PTY frames, etc.) to every operator client.
60    events: broadcast::Sender<hatchery_core::RoutedNodeEvent>,
61    /// Fan-out of dig2browser frames / lifecycle events.
62    browser: broadcast::Sender<BrowserStreamEvent>,
63    last_event_seq: Arc<Mutex<BTreeMap<(String, hatchery_core::NodeIncarnationId), u64>>>,
64}
65
66impl NodeOperatorMesh {
67    pub fn new() -> Self {
68        let initial = Arc::new(StatusResponse {
69            api_version: C2_API_VERSION,
70            ready: true,
71            observed_at_unix_ms: unix_ms(),
72            nodes: BTreeMap::new(),
73        });
74        let (topology, _) = watch::channel(initial);
75        let (events, _) = broadcast::channel(4096);
76        let (browser, _) = broadcast::channel(512);
77        Self {
78            inner: Arc::new(Mutex::new(BTreeMap::new())),
79            cursors: Arc::new(Mutex::new(BTreeMap::new())),
80            machines: Arc::new(Mutex::new(BTreeMap::new())),
81            topology,
82            events,
83            browser,
84            last_event_seq: Arc::new(Mutex::new(BTreeMap::new())),
85        }
86    }
87
88    pub fn subscribe(&self) -> watch::Receiver<Arc<StatusResponse>> {
89        self.topology.subscribe()
90    }
91
92    pub fn register(&self, node_id: impl Into<String>, link: OperatorLink) {
93        let id = node_id.into();
94        let _ = NodeId::new(id.clone()).expect("operator mesh node id");
95        self.inner.lock().expect("mesh").insert(id.clone(), link);
96        self.publish_topology();
97        tracing::info!(%id, "core operator mesh: node registered");
98    }
99
100    pub fn unregister(&self, node_id: &str) {
101        self.inner.lock().expect("mesh").remove(node_id);
102        self.cursors.lock().expect("cursors").remove(node_id);
103        self.machines.lock().expect("machines").remove(node_id);
104        self.publish_topology();
105    }
106
107    pub fn get(&self, node_id: &str) -> Option<OperatorLink> {
108        self.inner.lock().expect("mesh").get(node_id).cloned()
109    }
110
111    /// Publish the node's incarnation (from Snapshot / bridge hello) into operator topology.
112    pub fn set_cursor(&self, node_id: &str, cursor: hatchery_core::NodeCursor) {
113        self.cursors.lock().expect("cursors").insert(node_id.to_owned(), cursor);
114        self.publish_topology();
115    }
116
117    pub fn set_machine(&self, node_id: &str, machine: hatchery_core::MachineEnvironment) {
118        self.machines.lock().expect("machines").insert(node_id.to_owned(), machine);
119        self.publish_topology();
120    }
121
122    /// Publish a node-initiated event to every connected operator client.
123    pub fn publish_event(&self, event: hatchery_core::RoutedNodeEvent) {
124        if matches!(event.event, hatchery_core::C2NodeEvent::TerminalFrame { .. }) {
125            if let Some(at) = PTY_PROBE.lock().unwrap_or_else(|p| p.into_inner()).take() {
126                tracing::debug!(target: "pty_lat", core_us = at.elapsed().as_micros() as u64, "core: pty input enqueued to echo frame published");
127            }
128        }
129        // Gap diagnostics: operator clients rely on contiguous node event sequences.
130        {
131            let mut last = self.last_event_seq.lock().expect("last seq");
132            let key = (event.node_id.as_str().to_owned(), event.cursor.incarnation_id);
133            let prev = last.insert(key, event.cursor.sequence);
134            if let Some(prev) = prev {
135                if event.cursor.sequence > prev + 1 {
136                    tracing::info!(node = %event.node_id, prev, next = event.cursor.sequence, kind = format!("{:?}", event.event).split(|c: char| !c.is_alphanumeric()).next().unwrap_or("").to_owned(), "node event sequence gap");
137                }
138            }
139        }
140        // Keep topology cursor fresh from live traffic.
141        self.set_cursor(
142            event.node_id.as_str(),
143            hatchery_core::NodeCursor {
144                incarnation_id: event.cursor.incarnation_id,
145                sequence: event.cursor.sequence,
146            },
147        );
148        let _ = self.events.send(event);
149    }
150
151    pub fn subscribe_events(&self) -> broadcast::Receiver<hatchery_core::RoutedNodeEvent> {
152        self.events.subscribe()
153    }
154
155    pub fn publish_browser(&self, event: BrowserStreamEvent) {
156        let _ = self.browser.send(event);
157    }
158
159    pub fn subscribe_browser(&self) -> broadcast::Receiver<BrowserStreamEvent> {
160        self.browser.subscribe()
161    }
162
163    fn publish_topology(&self) {
164        let ids: Vec<String> = self.inner.lock().expect("mesh").keys().cloned().collect();
165        let cursors = self.cursors.lock().expect("cursors").clone();
166        let machines = self.machines.lock().expect("machines").clone();
167        let mut nodes = BTreeMap::new();
168        for id in &ids {
169            let node_id = NodeId::new(id.clone()).expect("registered ids are valid");
170            nodes.insert(
171                node_id,
172                ObservedNode {
173                    endpoint: "drain".into(),
174                    transport_label: "drain".into(),
175                    transport: NodeTransportState::Online,
176                    freshness: NodeFreshness::Fresh,
177                    cursor: cursors.get(id).cloned(),
178                    inventory: None,
179                    machine: machines.get(id).cloned(),
180                    last_attempt_unix_ms: None,
181                    last_success_unix_ms: Some(unix_ms()),
182                    consecutive_failures: 0,
183                    last_error: None,
184                    gaps: Vec::new(),
185                    gaps_truncated: 0,
186                },
187            );
188        }
189        let status = Arc::new(StatusResponse {
190            api_version: C2_API_VERSION,
191            ready: true,
192            observed_at_unix_ms: unix_ms(),
193            nodes,
194        });
195        self.topology.send_replace(status);
196    }
197}
198
199fn unix_ms() -> u64 {
200    std::time::SystemTime::now()
201        .duration_since(std::time::UNIX_EPOCH)
202        .unwrap_or_default()
203        .as_millis() as u64
204}
205
206/// Serve the operator control wire on an owner-only local endpoint (core, not C2).
207pub async fn serve_local(
208    endpoint: impl Into<String>,
209    token: impl Into<String>,
210    mesh: NodeOperatorMesh,
211    mut shutdown: watch::Receiver<bool>,
212) -> io::Result<()> {
213    let endpoint = endpoint.into();
214    let token = token.into();
215    let mut listener = OwnerOnlyLocalListener::bind(&endpoint).await?;
216    tracing::info!(%endpoint, "core operator control listening");
217    // Many HQ term views each dial their own operator control (plus the
218    // topology watcher). Cap high enough for 100 parallel HQ PTYs with headroom
219    // for browser / one-shot dials; reject only true overload.
220    let preauth = Arc::new(Semaphore::new(32));
221    let authenticated = Arc::new(Semaphore::new(128));
222    let next_id = Arc::new(AtomicU64::new(1));
223    let mut connections = JoinSet::new();
224    loop {
225        let permit = tokio::select! {
226            p = Arc::clone(&preauth).acquire_owned() => p.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "preauth closed"))?,
227            changed = shutdown.changed() => {
228                if changed.is_err() || *shutdown.borrow() { break; }
229                continue;
230            }
231        };
232        let stream = tokio::select! {
233            a = listener.accept() => a?,
234            changed = shutdown.changed() => {
235                if changed.is_err() || *shutdown.borrow() { break; }
236                continue;
237            }
238        };
239        let token = token.clone();
240        let mesh = mesh.clone();
241        let authenticated = Arc::clone(&authenticated);
242        let next_id = Arc::clone(&next_id);
243        let sd = shutdown.clone();
244        connections.spawn(async move {
245            let _ = serve_connection(stream, permit, authenticated, next_id, &token, mesh, sd).await;
246        });
247        while connections.try_join_next().is_some() {}
248    }
249    connections.shutdown().await;
250    Ok(())
251}
252
253async fn serve_connection<S>(
254    mut pipe: S,
255    preauth: tokio::sync::OwnedSemaphorePermit,
256    authenticated: Arc<Semaphore>,
257    connection_ids: Arc<AtomicU64>,
258    token: &str,
259    mesh: NodeOperatorMesh,
260    mut shutdown: watch::Receiver<bool>,
261) -> Result<(), FrameError>
262where
263    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
264{
265    let hello = timeout(AUTH_DEADLINE, read_client_frame(&mut pipe, MAX_C2_AUTH_FRAME_BYTES))
266        .await.map_err(|_| FrameError::PrefixTimedOut)??;
267    let C2ClientFrame::Hello(hello) = hello else { return Ok(()) };
268    if hello.build_stamp != BUILD_STAMP {
269        let _ = timeout(AUTH_DEADLINE, write_json_frame_limited(
270            &mut pipe,
271            &C2ServerFrame::Rejected(fail(C2RelayFailureCode::BuildStampMismatch, "build stamp mismatch")),
272            MAX_C2_AUTH_FRAME_BYTES,
273        )).await;
274        return Ok(());
275    }
276    let negotiated = negotiate(&hello)?;
277    let compatibility = hello.compatibility.as_ref().map(|_| negotiated.clone());
278    let server_nonce = random_nonce().map_err(|e| FrameError::Io(io::Error::other(e)))?;
279    let auth_compat = hello.compatibility.as_ref().zip(compatibility.as_ref());
280    let server_proof = c2_proof(token, C2AuthDirection::Server, &hello.client_nonce, &server_nonce, auth_compat)?;
281    timeout(AUTH_DEADLINE, write_json_frame_limited(
282        &mut pipe,
283        &C2ServerFrame::Challenge(C2ServerChallenge {
284            build_stamp: BUILD_STAMP.to_owned(),
285            server_nonce,
286            server_proof,
287            compatibility: compatibility.clone(),
288        }),
289        MAX_C2_AUTH_FRAME_BYTES,
290    )).await.map_err(|_| FrameError::BodyTimedOut { length: 0 })??;
291
292    let authentication = timeout(AUTH_DEADLINE, read_client_frame(&mut pipe, MAX_C2_AUTH_FRAME_BYTES))
293        .await.map_err(|_| FrameError::PrefixTimedOut)??;
294    let C2ClientFrame::Authenticate(C2ClientAuthentication { client_proof }) = authentication else {
295        return Ok(());
296    };
297    let expected = c2_proof(token, C2AuthDirection::Client, &hello.client_nonce, &server_nonce, auth_compat)?;
298    if !proofs_match(&client_proof, &expected) {
299        return Ok(());
300    }
301    drop(preauth);
302
303    let Ok(_op) = authenticated.try_acquire_owned() else {
304        let _ = write_json_frame_limited(
305            &mut pipe,
306            &C2ServerFrame::Rejected(fail(C2RelayFailureCode::OperatorAlreadyConnected, "operator connection limit reached")),
307            MAX_C2_SERVER_FRAME_BYTES,
308        ).await;
309        return Ok(());
310    };
311
312    let connection_id = connection_ids.fetch_add(1, Ordering::AcqRel);
313    let mut status_rx = mesh.subscribe();
314    let mut events_rx = mesh.subscribe_events();
315    let mut browser_rx = mesh.subscribe_browser();
316    let hello_status = status_rx.borrow().as_ref().clone();
317    timeout(AUTH_DEADLINE, write_json_frame_limited(
318        &mut pipe,
319        &C2ServerFrame::Hello(C2Hello {
320            build_stamp: BUILD_STAMP.to_owned(),
321            connection_id,
322            status: hello_status,
323            compatibility,
324        }),
325        MAX_C2_HELLO_FRAME_BYTES,
326    )).await.map_err(|_| FrameError::BodyTimedOut { length: 0 })??;
327
328    let (mut reader, mut writer) = tokio::io::split(pipe);
329    // read_client_frame is not cancellation-safe: inside select! a busy event/browser
330    // branch could drop a half-read frame, desync the stream and close the operator
331    // connection. Read frames on their own task and hand complete ones over.
332    let (frame_tx, mut frame_rx) = tokio::sync::mpsc::channel(512);
333    // Requests and browser ops run on their own tasks so a slow node RPC never
334    // stalls frame/event delivery on this connection; replies come back here.
335    let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<C2ServerFrame>();
336    let reader_task = tokio::spawn(async move {
337        loop {
338            let frame = read_client_frame(&mut reader, MAX_C2_CLIENT_FRAME_BYTES).await;
339            let stop = frame.is_err();
340            if frame_tx.send(frame).await.is_err() || stop {
341                break;
342            }
343        }
344    });
345    let mut last_topology = C2Topology::from_status(status_rx.borrow().as_ref());
346    tracing::info!(connection_id, "core operator control opened");
347
348    loop {
349        tokio::select! {
350            _ = shutdown.changed() => {
351                if *shutdown.borrow() { break; }
352            }
353            changed = status_rx.changed() => {
354                if changed.is_err() { break; }
355                let status = status_rx.borrow().as_ref().clone();
356                let topology = C2Topology::from_status(&status);
357                if topology != last_topology {
358                    last_topology = topology.clone();
359                    if write_json_frame_limited(&mut writer, &C2ServerFrame::Topology(topology), MAX_C2_SERVER_FRAME_BYTES).await.is_err() {
360                        break;
361                    }
362                }
363            }
364            event = events_rx.recv() => {
365                match event {
366                    Ok(routed) => {
367                        if write_json_frame_limited(
368                            &mut writer,
369                            &C2ServerFrame::Event(routed),
370                            MAX_C2_SERVER_FRAME_BYTES,
371                        ).await.is_err() {
372                            break;
373                        }
374                    }
375                    Err(broadcast::error::RecvError::Lagged(n)) => {
376                        tracing::warn!(skipped = n, "operator client lagged; node events dropped");
377                        continue;
378                    }
379                    Err(broadcast::error::RecvError::Closed) => break,
380                }
381            }
382            browser = browser_rx.recv() => {
383                match browser {
384                    Ok(ev) => {
385                        if write_json_frame_limited(
386                            &mut writer,
387                            &C2ServerFrame::Browser(ev),
388                            MAX_C2_SERVER_FRAME_BYTES,
389                        ).await.is_err() {
390                            break;
391                        }
392                    }
393                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
394                    Err(broadcast::error::RecvError::Closed) => break,
395                }
396            }
397            out = out_rx.recv() => {
398                let Some(out) = out else { break };
399                if write_json_frame_limited(&mut writer, &out, MAX_C2_SERVER_FRAME_BYTES).await.is_err() {
400                    break;
401                }
402            }
403            frame = frame_rx.recv() => {
404                let Some(frame) = frame else { break };
405                match frame {
406                    Ok(C2ClientFrame::Request(envelope)) => {
407                        // Enqueue toward the node right here (keeps PTY byte order),
408                        // then wait for the reply off-loop (never blocks events).
409                        let wait = start_request(&mesh, envelope).await;
410                        let out = out_tx.clone();
411                        tokio::spawn(async move {
412                            let _ = out.send(C2ServerFrame::Reply(wait.await));
413                        });
414                    }
415                    Ok(C2ClientFrame::Browser(cmd)) => {
416                        // Browser ops are acked via Browser stream events (Error on failure).
417                        let wait = start_browser(&mesh, cmd).await;
418                        let out = out_tx.clone();
419                        tokio::spawn(async move {
420                            if let Some(ev) = wait.await {
421                                let _ = out.send(C2ServerFrame::Browser(ev));
422                            }
423                        });
424                    }
425                    Ok(_) | Err(_) => break,
426                }
427            }
428        }
429    }
430    reader_task.abort();
431    Ok(())
432}
433
434
435/// Ordered enqueue of a browser op; the future waits for the node's reply.
436async fn start_browser(
437    mesh: &NodeOperatorMesh,
438    cmd: BrowserClientCommand,
439) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<BrowserStreamEvent>> + Send>> {
440    let session = match &cmd.op {
441        hatchery_core::BrowserClientOp::Start { session, .. }
442        | hatchery_core::BrowserClientOp::Input { session, .. }
443        | hatchery_core::BrowserClientOp::Stop { session }
444        | hatchery_core::BrowserClientOp::LocalServerStart { session, .. }
445        | hatchery_core::BrowserClientOp::LocalServerStop { session, .. }
446        | hatchery_core::BrowserClientOp::LocalServerList { session } => session.clone(),
447    };
448    let is_local_server = matches!(
449        &cmd.op,
450        hatchery_core::BrowserClientOp::LocalServerStart { .. }
451            | hatchery_core::BrowserClientOp::LocalServerStop { .. }
452            | hatchery_core::BrowserClientOp::LocalServerList { .. }
453    );
454    let link = { let guard = mesh.inner.lock().expect("mesh"); guard.get(&cmd.node_id).cloned() };
455    let node_id = cmd.node_id.clone();
456    let Some(link) = link else {
457        return Box::pin(async move { Some(BrowserStreamEvent::Error { node_id, session, message: "node is not registered on core mesh".into() }) });
458    };
459    let sent = link.browser_op_pipelined(&cmd.op, Duration::from_secs(60)).await;
460    Box::pin(async move {
461        match sent {
462            Err(e) => Some(BrowserStreamEvent::Error { node_id, session, message: format!("{e:?}") }),
463            Ok(wait) => match wait.await {
464                Ok(reply) if is_local_server => Some(BrowserStreamEvent::LocalServer {
465                    node_id,
466                    session: reply.session,
467                    ok: reply.ok,
468                    message: reply.message,
469                    body: reply.body,
470                }),
471                Ok(reply) if reply.ok => None,
472                Ok(reply) => Some(BrowserStreamEvent::Error { node_id, session: reply.session, message: reply.message }),
473                Err(e) => Some(BrowserStreamEvent::Error { node_id, session, message: format!("{e:?}") }),
474            },
475        }
476    })
477}
478
479
480#[allow(dead_code)]
481async fn dispatch_browser(
482    mesh: &NodeOperatorMesh,
483    cmd: BrowserClientCommand,
484) -> Option<BrowserStreamEvent> {
485    let link = {
486        let guard = mesh.inner.lock().expect("mesh");
487        guard.get(&cmd.node_id).cloned()
488    };
489    let Some(link) = link else {
490        return Some(BrowserStreamEvent::Error {
491            node_id: cmd.node_id,
492            session: match &cmd.op {
493                hatchery_core::BrowserClientOp::Start { session, .. }
494                | hatchery_core::BrowserClientOp::Input { session, .. }
495                | hatchery_core::BrowserClientOp::Stop { session }
496                | hatchery_core::BrowserClientOp::LocalServerStart { session, .. }
497                | hatchery_core::BrowserClientOp::LocalServerStop { session, .. }
498                | hatchery_core::BrowserClientOp::LocalServerList { session } => session.clone(),
499            },
500            message: "node is not registered on core mesh".into(),
501        });
502    };
503    match link.browser_op(&cmd.op, Duration::from_secs(60)).await {
504        Ok(reply) if reply.ok => None,
505        Ok(reply) => Some(BrowserStreamEvent::Error {
506            node_id: cmd.node_id,
507            session: reply.session,
508            message: reply.message,
509        }),
510        Err(err) => Some(BrowserStreamEvent::Error {
511            node_id: cmd.node_id,
512            session: match &cmd.op {
513                hatchery_core::BrowserClientOp::Start { session, .. }
514                | hatchery_core::BrowserClientOp::Input { session, .. }
515                | hatchery_core::BrowserClientOp::Stop { session }
516                | hatchery_core::BrowserClientOp::LocalServerStart { session, .. }
517                | hatchery_core::BrowserClientOp::LocalServerStop { session, .. }
518                | hatchery_core::BrowserClientOp::LocalServerList { session } => session.clone(),
519            },
520            message: err.to_string(),
521        }),
522    }
523}
524
525#[allow(dead_code)]
526async fn dispatch_request(mesh: &NodeOperatorMesh, envelope: C2RequestEnvelope) -> C2ReplyEnvelope {
527    let node_id = envelope.request.route.node_id.as_str().to_owned();
528    let cid = envelope.correlation_id.clone();
529    let Some(link) = mesh.get(&node_id) else {
530        return C2ReplyEnvelope {
531            request_id: envelope.request_id,
532            result: Err(fail(C2RelayFailureCode::NodeOffline, "node not on drain")),
533            correlation_id: cid,
534        };
535    };
536    let routed = RoutedNodeRequest {
537        route: envelope.request.route.clone(),
538        request: envelope.request.request.clone(),
539    };
540    let result = match link.request_pipelined(&routed, REQUEST_DEADLINE).await {
541        Ok(wait) => wait.await,
542        Err(e) => Err(e),
543    };
544    finish_request(mesh, &node_id, envelope.request_id, result, cid)
545}
546
547/// Enqueue a request on the node link in arrival order; the returned future
548/// only waits for the reply (so PTY bytes are pipelined, never reordered).
549/// PTY latency probe: first TerminalBytes enqueued at core -> next terminal frame published.
550static PTY_PROBE: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
551
552async fn start_request(
553    mesh: &NodeOperatorMesh,
554    envelope: C2RequestEnvelope,
555) -> std::pin::Pin<Box<dyn std::future::Future<Output = C2ReplyEnvelope> + Send>> {
556    let correlation_id = envelope.correlation_id.clone();
557    let req_kind = match &envelope.request.request {
558        gate4agent_node_protocol::NodeRequest::Spawn { provider, .. } => {
559            tracing::info!(
560                cid = correlation_id.as_deref().unwrap_or("-"),
561                node = %envelope.request.route.node_id,
562                %provider,
563                "operator spawn forward",
564            );
565            "spawn"
566        }
567        gate4agent_node_protocol::NodeRequest::Resize { .. } => "resize",
568        gate4agent_node_protocol::NodeRequest::Stop { .. } => "stop",
569        gate4agent_node_protocol::NodeRequest::TerminalBytes { .. } => "terminal_bytes",
570        _ => "other",
571    };
572    if req_kind == "terminal_bytes" {
573        let mut g = PTY_PROBE.lock().unwrap_or_else(|p| p.into_inner());
574        if g.is_none() {
575            *g = Some(std::time::Instant::now());
576        }
577        tracing::debug!(target: "pty_lat", "core: pty input enqueued toward node");
578    }
579    let node_id = envelope.request.route.node_id.as_str().to_owned();
580    let request_id = envelope.request_id;
581    let Some(link) = mesh.get(&node_id) else {
582        let cid = correlation_id.clone();
583        return Box::pin(async move {
584            C2ReplyEnvelope {
585                request_id,
586                result: Err(fail(C2RelayFailureCode::NodeOffline, "node not on drain")),
587                correlation_id: cid,
588            }
589        });
590    };
591    let routed = RoutedNodeRequest {
592        route: envelope.request.route.clone(),
593        request: envelope.request.request.clone(),
594    };
595    let sent = link.request_pipelined(&routed, REQUEST_DEADLINE).await;
596    let mesh = mesh.clone();
597    let cid = correlation_id.clone();
598    Box::pin(async move {
599        let result = match sent {
600            Ok(wait) => wait.await,
601            Err(e) => Err(e),
602        };
603        if matches!(req_kind, "spawn" | "resize" | "stop") {
604            tracing::info!(
605                cid = cid.as_deref().unwrap_or("-"),
606                node = %node_id,
607                kind = req_kind,
608                ok = result.is_ok(),
609                "operator request settled",
610            );
611        }
612        finish_request(&mesh, &node_id, request_id, result, cid)
613    })
614}
615
616fn finish_request(
617    mesh: &NodeOperatorMesh,
618    node_id: &str,
619    request_id: hatchery_core::C2RequestId,
620    result: Result<G4aMessage, crate::node_controller::OperatorError>,
621    correlation_id: Option<String>,
622) -> C2ReplyEnvelope {
623    let node_id = node_id.to_owned();
624    match result {
625        Ok(G4aMessage::Response(response)) => {
626            mesh.set_cursor(
627                &node_id,
628                hatchery_core::NodeCursor {
629                    incarnation_id: response.incarnation_id,
630                    sequence: 0,
631                },
632            );
633            if let Ok(hatchery_core::C2NodeResponse::Snapshot { snapshot, .. }) = &response.response {
634                if let Some(machine) = snapshot.machine.clone() {
635                    mesh.set_machine(&node_id, machine);
636                }
637            }
638            C2ReplyEnvelope {
639                request_id,
640                result: Ok(response),
641                correlation_id,
642            }
643        }
644        Ok(G4aMessage::Json(v)) => match serde_json::from_value::<RoutedNodeResponse>(v) {
645            Ok(response) => {
646                mesh.set_cursor(
647                    &node_id,
648                    hatchery_core::NodeCursor {
649                        incarnation_id: response.incarnation_id,
650                        sequence: 0,
651                    },
652                );
653                C2ReplyEnvelope {
654                    request_id,
655                    result: Ok(response),
656                    correlation_id,
657                }
658            }
659            Err(_) => C2ReplyEnvelope {
660                request_id,
661                result: Err(fail(
662                    C2RelayFailureCode::RequestForbidden,
663                    "node reply was not a g4a response",
664                )),
665                correlation_id,
666            },
667        },
668        Ok(_) => C2ReplyEnvelope {
669            request_id,
670            result: Err(fail(
671                C2RelayFailureCode::RequestForbidden,
672                "unexpected g4a reply kind",
673            )),
674            correlation_id,
675        },
676        Err(_) => C2ReplyEnvelope {
677            request_id,
678            result: Err(fail(C2RelayFailureCode::RelayBusy, "node did not answer")),
679            correlation_id,
680        },
681    }
682}
683
684fn fail(code: C2RelayFailureCode, message: &str) -> C2RelayFailure {
685    C2RelayFailure { code, message: message.to_owned(), current_incarnation_id: None }
686}
687
688fn negotiate(hello: &C2ClientHello) -> Result<NegotiatedC2ControlCompatibility, FrameError> {
689    let support = C2ControlCompatibilitySupport {
690        build_stamp: BUILD_STAMP.to_owned(),
691        capabilities: vec![
692            CapabilityId::new(C2_COMPATIBILITY_METADATA_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
693            CapabilityId::new(C2_OPAQUE_UNIX_PATH_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
694            CapabilityId::new(C2_REPOSITORY_PATH_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
695            CapabilityId::new(C2_WORKSPACE_FILE_READ_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
696            CapabilityId::new(C2_WORKSPACE_FILE_WRITE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
697            CapabilityId::new(C2_WORKSPACE_ENTRY_CREATE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
698            CapabilityId::new(C2_GIT_READ_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
699            CapabilityId::new(C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
700            CapabilityId::new(C2_PROVIDER_RUNTIME_STATUS_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
701            CapabilityId::new(C2_PROVIDER_ID_OPEN_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
702            CapabilityId::new(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
703            CapabilityId::new(C2_SPAWN_PROFILE_REVISION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
704            CapabilityId::new(C2_TERMINAL_FRAME_EVENTS_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
705            CapabilityId::new(C2_AGENT_STREAM_EVENTS_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
706            CapabilityId::new(C2_WORKTREE_SELECTION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
707            CapabilityId::new(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
708            CapabilityId::new(C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
709            CapabilityId::new(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
710            CapabilityId::new(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
711            CapabilityId::new(C2_HISTORY_CONTEXT_PACK_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
712            CapabilityId::new(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
713            CapabilityId::new(C2_NATIVE_SESSION_CATALOG_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
714            CapabilityId::new(C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
715            CapabilityId::new(C2_NATIVE_SESSION_INDEX_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
716            CapabilityId::new(C2_NATIVE_SESSION_PREVIEW_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
717            CapabilityId::new(C2_HOST_DIRECTORY_BROWSE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
718            CapabilityId::new(C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
719            CapabilityId::new(C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
720            CapabilityId::new(C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
721            CapabilityId::new(C2_SESSION_TASK_CORRELATION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
722            CapabilityId::new(C2_CONTROL_DETAIL_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
723            CapabilityId::new(C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
724            CapabilityId::new(C2_HARNESS_MCP_READ_PROXY_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
725            CapabilityId::new(C2_ACP_CONTROL_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
726        ],
727        host: HostDescriptor {
728            operating_system: OperatingSystemId::new(std::env::consts::OS)
729                .map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
730            architecture: ArchitectureId::new(std::env::consts::ARCH)
731                .map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
732        },
733        path_semantics: PathSemantics {
734            style: if cfg!(windows) { PathStyle::Windows } else { PathStyle::Posix },
735            encoding: if cfg!(windows) { PathEncoding::Utf8 } else { PathEncoding::UnixBytes },
736        },
737    };
738    support.negotiate(hello).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))
739}
740
741fn c2_proof(
742    token: &str,
743    direction: C2AuthDirection,
744    client_nonce: &[u8; 32],
745    server_nonce: &[u8; 32],
746    compatibility: Option<(&ClientCompatibilityOffer, &NegotiatedC2ControlCompatibility)>,
747) -> Result<[u8; 32], FrameError> {
748    let transcript = match compatibility {
749        Some((offer, selected)) => c2_bound_auth_transcript(direction, client_nonce, server_nonce, offer, selected)
750            .map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
751        None => c2_auth_transcript(direction, client_nonce, server_nonce),
752    };
753    local_hmac_sha256(token.as_bytes(), &transcript).map_err(|e| FrameError::Io(io::Error::other(e)))
754}
755
756async fn read_client_frame<S: tokio::io::AsyncRead + Unpin>(
757    stream: &mut S,
758    max: usize,
759) -> Result<C2ClientFrame, FrameError> {
760    read_json_frame_limited_body_timeout(stream, max, AUTH_DEADLINE).await
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use hatchery_c2_client::connect_local;
767        use std::time::Duration;
768
769    #[tokio::test]
770    async fn clients_connect_to_core_operator_not_c2() {
771        let root = tempfile::tempdir().unwrap();
772        let sockdir = root.path().join("sock");
773        #[cfg(unix)]
774        {
775            use std::os::unix::fs::DirBuilderExt;
776            std::fs::DirBuilder::new().mode(0o700).create(&sockdir).unwrap();
777        }
778        #[cfg(not(unix))]
779        std::fs::create_dir_all(&sockdir).unwrap();
780        let endpoint = sockdir.join("core.sock");
781        let mesh = NodeOperatorMesh::new();
782        let (events_tx, _events_rx) = mpsc::unbounded_channel();
783        let (link, _outbound) = OperatorLink::new(Some(events_tx));
784        // Fake node that answers inventory-ish JSON as a RoutedNodeResponse shape via G4aMessage::Json
785        // Register link; dispatch will wait — spawn a responder on a drain-like loop using the outbound isn't
786        // wired here. Instead register nothing and expect NodeOffline.
787        mesh.register("unix-local", link);
788
789        let (stop, rx) = watch::channel(false);
790        let endpoint_s = endpoint.to_string_lossy().into_owned();
791        let mesh2 = mesh.clone();
792        tokio::spawn(async move { serve_local(endpoint_s, "core-op-token-0123456789", mesh2, rx).await });
793        // Ensure topology is published after the server subscribed.
794        mesh.register("unix-local", {
795            let (events_tx, _) = mpsc::unbounded_channel();
796            OperatorLink::new(Some(events_tx)).0
797        });
798        tokio::time::sleep(Duration::from_millis(80)).await;
799
800        let (control, _ev) = tokio::time::timeout(
801            Duration::from_secs(5),
802            connect_local(endpoint.to_str().unwrap(), "core-op-token-0123456789"),
803        )
804        .await
805        .expect("timeout")
806        .expect("connect");
807        assert_eq!(control.hello().build_stamp, BUILD_STAMP);
808        let hello_nodes: Vec<_> = control.hello().status.nodes.keys().map(|k| k.as_str().to_owned()).collect();
809        assert!(hello_nodes.iter().any(|n| n == "unix-local"), "hello status nodes={hello_nodes:?}");
810        let topo = control.current_topology();
811        assert!(!topo.nodes.is_empty() || !hello_nodes.is_empty(), "topology={:?} hello={hello_nodes:?}", topo.nodes);
812        let _ = stop.send(true);
813    }
814}