Skip to main content

hatchery_c2_client/
runtime.rs

1use hatchery_core::{BrowserClientCommand, BrowserStreamEvent, 
2    c2_auth_transcript, c2_bound_auth_transcript, provider_id_is_legacy, C2AuthDirection, C2ClientAuthentication, C2ClientFrame,
3    C2ClientHello, C2Hello, C2NodeEvent, C2NodeResponse, C2RelayFailure,
4    C2RelayFailureCode,
5    C2RequestEnvelope, C2RequestId, C2ServerFrame, C2Topology, CapabilityId,
6    ClientCompatibilityOffer, NegotiatedC2ControlCompatibility, NodeRequest, NodeRoute,
7    RoutedNodeEvent, RoutedNodeRequest, RoutedNodeResponse,
8    BUILD_STAMP, C2_COMPATIBILITY_METADATA_CAPABILITY,
9    C2_OPAQUE_UNIX_PATH_CAPABILITY, C2_REPOSITORY_PATH_CAPABILITY,
10    C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
11    C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
12    C2_HISTORY_CONTEXT_PACK_CAPABILITY,
13    C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14    C2_NATIVE_SESSION_CATALOG_CAPABILITY,
15    C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
16    C2_NATIVE_SESSION_INDEX_CAPABILITY, C2_NATIVE_SESSION_PREVIEW_CAPABILITY,
17    C2_HOST_DIRECTORY_BROWSE_CAPABILITY,
18    C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
19    C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
20    C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
21    C2_CONTROL_DETAIL_CAPABILITY, C2_SESSION_TASK_CORRELATION_CAPABILITY,
22    C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
23    C2_HARNESS_MCP_READ_PROXY_CAPABILITY,
24    C2_PROVIDER_ID_OPEN_CAPABILITY,
25    C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
26    C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
27    C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, C2_PROVIDER_RUNTIME_STATUS_CAPABILITY,
28    C2_SPAWN_PROFILE_REVISION_CAPABILITY,
29    C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
30    C2_TERMINAL_FRAME_EVENTS_CAPABILITY,
31    C2_AGENT_STREAM_EVENTS_CAPABILITY,
32    C2_ACP_CONTROL_CAPABILITY,
33    C2_GIT_READ_CAPABILITY, C2_WORKSPACE_FILE_READ_CAPABILITY,
34    C2_WORKSPACE_FILE_WRITE_CAPABILITY,
35    C2_WORKSPACE_ENTRY_CREATE_CAPABILITY,
36    C2_WORKTREE_SELECTION_CAPABILITY,
37    C2_AUTH_NONCE_BYTES, MAX_C2_AUTH_FRAME_BYTES, MAX_C2_CLIENT_FRAME_BYTES, MAX_C2_HELLO_FRAME_BYTES,
38    MAX_C2_SERVER_FRAME_BYTES,
39};
40use gate4agent_node_protocol::{
41    read_json_frame_limited_body_timeout, write_json_frame_limited, FrameError,
42};
43use gate4agent_node_wire::{
44    connect_local_stream, local_hmac_sha256, proofs_match, random_nonce,
45};
46use std::collections::BTreeMap;
47use std::io;
48use std::sync::Arc;
49use std::time::{Duration, SystemTime, UNIX_EPOCH};
50use thiserror::Error;
51use tokio::sync::{mpsc, oneshot, watch};
52use tokio::time::{timeout, timeout_at, Instant};
53
54const AUTH_DEADLINE: Duration = Duration::from_secs(5);
55const HELLO_DEADLINE: Duration = Duration::from_secs(10);
56const FRAME_BODY_DEADLINE: Duration = Duration::from_secs(5);
57const RELAY_REPLY_HEADROOM: Duration = Duration::from_secs(5);
58const CONTROLLER_ACQUIRE_RELAY_DEADLINE: Duration = Duration::from_secs(5);
59const WORKSPACE_ENTRY_CREATE_RELAY_DEADLINE: Duration = Duration::from_secs(10);
60const NATIVE_SESSION_RELAY_DEADLINE: Duration = Duration::from_secs(35);
61const COMMAND_CAPACITY: usize = 64;
62const INBOUND_CAPACITY: usize = 2;
63const WRITER_CAPACITY: usize = 64;
64/// Buffer for the primary node-event stream (control, terminal, and
65/// negotiated agent-stream text deltas). Sized so
66/// normal live-session bursts buffer instead of tearing the connection
67/// down; teardown (`RegularEventBackpressure`) stays the last resort for a
68/// consumer that is genuinely stuck, not merely bursty.
69pub(crate) const EVENT_CAPACITY: usize = 1024;
70pub(crate) const HARNESS_MCP_EVENT_CAPACITY: usize = 128;
71const OPAQUE_UNIX_PATH_NOT_NEGOTIATED: &str =
72    "opaque Unix paths require negotiated C2 capability";
73const REPOSITORY_PATH_NOT_NEGOTIATED: &str =
74    "tagged repository paths require negotiated C2 capability";
75const WORKSPACE_FILE_READ_NOT_NEGOTIATED: &str =
76    "workspace file reads require negotiated C2 capability";
77const WORKSPACE_ENTRY_CREATE_NOT_NEGOTIATED: &str =
78    "workspace entry creation requires negotiated C2 capability";
79const OPEN_PROVIDER_ID_NOT_NEGOTIATED: &str =
80    "open provider IDs require negotiated C2 capability";
81const SPAWN_SPEC_NOT_NEGOTIATED: &str =
82    "spawn spec defaults/overrides require negotiated C2 capability";
83const SPAWN_PROFILE_REVISION_NOT_NEGOTIATED: &str =
84    "spawn profile revisions require negotiated C2 capability";
85const TERMINAL_FRAME_EVENTS_NOT_NEGOTIATED: &str =
86    "terminal frame events require negotiated C2 capability";
87const AGENT_STREAM_EVENTS_NOT_NEGOTIATED: &str =
88    "agent stream events require negotiated C2 capability";
89const ACP_CONTROL_NOT_NEGOTIATED: &str =
90    "ACP control verbs require negotiated C2 capability";
91const WORKTREE_SELECTION_NOT_NEGOTIATED: &str =
92    "worktree selection requires negotiated C2 capability";
93const MANAGED_WORKTREE_LIFECYCLE_NOT_NEGOTIATED: &str =
94    "managed worktree lifecycle requires negotiated C2 capability";
95const MANAGED_WORKTREE_SPAWN_V2_NOT_NEGOTIATED: &str =
96    "managed worktree V2 spawn requires negotiated C2 capability";
97const CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED: &str =
98    "child environment profiles require negotiated C2 capability";
99const SESSION_BUNDLE_MATERIALIZATION_NOT_NEGOTIATED: &str =
100    "session bundle materialization requires negotiated C2 capability";
101const HISTORY_CONTEXT_PACK_NOT_NEGOTIATED: &str =
102    "history context packs require negotiated C2 capability";
103const SESSION_RECORD_CONTEXT_EXPORT_NOT_NEGOTIATED: &str =
104    "session-record context export requires negotiated C2 capability";
105const HOST_DIRECTORY_BROWSE_NOT_NEGOTIATED: &str =
106    "host directory browsing requires negotiated C2 capability";
107const STANDALONE_WORKSPACE_LIFECYCLE_NOT_NEGOTIATED: &str =
108    "standalone workspace lifecycle requires negotiated C2 capability";
109const PROVIDER_SESSION_REFERENCE_INDEX_NOT_NEGOTIATED: &str =
110    "provider session reference index requires negotiated C2 capability";
111const INVALID_HISTORY_CONTEXT_PACK_REQUEST: &str =
112    "invalid history context pack request";
113const SESSION_TASK_CORRELATION_NOT_NEGOTIATED: &str =
114    "session task correlation requires negotiated C2 capability";
115const CONTROL_DETAIL_NOT_NEGOTIATED: &str =
116    "control detail requires negotiated C2 capability";
117
118#[derive(Clone, Copy, Default)]
119struct NegotiatedPathCapabilities {
120    opaque_host_paths: bool,
121    repository_paths: bool,
122    workspace_file_read: bool,
123    workspace_file_write: bool,
124    workspace_entry_create: bool,
125    git_read: bool,
126    host_directory_browse: bool,
127    standalone_workspace_lifecycle: bool,
128    provider_session_reference_index: bool,
129    provider_ids_open: bool,
130    spawn_spec_defaults_overrides: bool,
131    spawn_profile_revision: bool,
132    worktree_selection: bool,
133    managed_worktree_lifecycle: bool,
134    managed_worktree_spawn_v2: bool,
135    child_environment_profile: bool,
136    session_bundle_materialization: bool,
137    history_context_pack: bool,
138    session_record_context_export: bool,
139    native_session_catalog: bool,
140    native_session_catalog_paging: bool,
141    native_session_index: bool,
142    native_session_preview: bool,
143    terminal_frame_events: bool,
144    agent_stream_events: bool,
145    agent_progress_snapshot: bool,
146    session_task_correlation: bool,
147    control_detail: bool,
148    delivery_bundle_v2_stage_commit: bool,
149    harness_mcp_read_proxy: bool,
150    acp_control: bool,
151}
152
153#[derive(Clone)]
154pub struct C2ControlHandle {
155    commands: mpsc::Sender<ControlCommand>,
156    browser_commands: mpsc::Sender<BrowserControlCommand>,
157    hello: Arc<C2Hello>,
158    topology: watch::Receiver<Arc<C2Topology>>,
159    terminal_frame_events: bool,
160}
161
162struct BrowserControlCommand {
163    cmd: BrowserClientCommand,
164    reply: oneshot::Sender<Result<(), C2ControlError>>,
165}
166
167impl C2ControlHandle {
168    pub fn hello(&self) -> &C2Hello { &self.hello }
169
170    pub fn current_topology(&self) -> Arc<C2Topology> { Arc::clone(&*self.topology.borrow()) }
171
172    pub fn subscribe_topology(&self) -> watch::Receiver<Arc<C2Topology>> {
173        self.topology.clone()
174    }
175
176    pub fn terminal_frame_events_enabled(&self) -> bool { self.terminal_frame_events }
177
178    pub async fn request(
179        &self,
180        route: NodeRoute,
181        request: NodeRequest,
182    ) -> Result<RoutedNodeResponse, C2ControlError> {
183        self.request_with_correlation(route, request, None).await
184    }
185
186    /// Like [`Self::request`], attaching an opaque HQ correlation id to the
187    /// operator-wire envelope (logged on core; echoed on the reply).
188    pub async fn request_with_correlation(
189        &self,
190        route: NodeRoute,
191        request: NodeRequest,
192        correlation_id: Option<String>,
193    ) -> Result<RoutedNodeResponse, C2ControlError> {
194        reject_unnegotiated_outbound_path(
195            &request,
196            negotiated_path_capabilities(self.hello.compatibility.as_ref()),
197        )?;
198        let deadline = control_request_deadline(&request);
199        let (reply_tx, reply_rx) = oneshot::channel();
200        timeout(deadline, async {
201            self.commands.send(ControlCommand {
202                route,
203                request,
204                correlation_id,
205                reply: reply_tx,
206            })
207                .await.map_err(|_| C2ControlError::Closed)?;
208            reply_rx.await.map_err(|_| C2ControlError::Closed)?
209        }).await.map_err(|_| C2ControlError::Closed)?
210    }
211
212    /// Validates and synchronously enqueues one typed request. The returned
213    /// non-cloneable waiter cannot enqueue or replay the request again.
214    /// Start / drive / stop a dig2browser session on a node (A2 restream sidecar).
215    pub async fn browser_command(
216        &self,
217        cmd: BrowserClientCommand,
218    ) -> Result<(), C2ControlError> {
219        let (reply_tx, reply_rx) = oneshot::channel();
220        timeout(Duration::from_secs(60), async {
221            self.browser_commands
222                .send(BrowserControlCommand { cmd, reply: reply_tx })
223                .await
224                .map_err(|_| C2ControlError::Closed)?;
225            reply_rx.await.map_err(|_| C2ControlError::Closed)?
226        })
227        .await
228        .map_err(|_| C2ControlError::Closed)?
229    }
230
231    pub fn start_request(
232        &self,
233        route: NodeRoute,
234        request: NodeRequest,
235    ) -> Result<C2PendingRequest, C2ControlError> {
236        start_control_request(
237            &self.commands,
238            negotiated_path_capabilities(self.hello.compatibility.as_ref()),
239            route,
240            request,
241        )
242    }
243}
244
245pub struct C2PendingRequest {
246    reply: oneshot::Receiver<Result<RoutedNodeResponse, C2ControlError>>,
247    deadline: Instant,
248}
249
250impl C2PendingRequest {
251    pub async fn finish(self) -> Result<RoutedNodeResponse, C2ControlError> {
252        timeout_at(self.deadline, self.reply)
253            .await
254            .map_err(|_| C2ControlError::Closed)?
255            .map_err(|_| C2ControlError::Closed)?
256    }
257}
258
259fn start_control_request(
260    commands: &mpsc::Sender<ControlCommand>,
261    capabilities: NegotiatedPathCapabilities,
262    route: NodeRoute,
263    request: NodeRequest,
264) -> Result<C2PendingRequest, C2ControlError> {
265        reject_unnegotiated_outbound_path(
266            &request,
267            capabilities,
268        )?;
269        let deadline_at = Instant::now() + control_request_deadline(&request);
270        let (reply_tx, reply_rx) = oneshot::channel();
271        commands.try_send(ControlCommand { route, request, correlation_id: None, reply: reply_tx })
272            .map_err(|error| match error {
273                mpsc::error::TrySendError::Full(_) => C2ControlError::QueueFull,
274                mpsc::error::TrySendError::Closed(_) => C2ControlError::Closed,
275            })?;
276        Ok(C2PendingRequest {
277            reply: reply_rx,
278            deadline: deadline_at,
279        })
280}
281
282fn control_request_deadline(request: &NodeRequest) -> Duration {
283    let relay_deadline = match request {
284        NodeRequest::Snapshot
285        | NodeRequest::Resync { .. }
286        | NodeRequest::ArmHarnessMcpReservation { .. }
287        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
288        | NodeRequest::ActivateHarnessMcpReservation { .. }
289        | NodeRequest::AbortHarnessMcpReservation { .. }
290        | NodeRequest::PutHarnessMcpReplyChunk { .. }
291        | NodeRequest::RejectHarnessMcpCall { .. }
292        | NodeRequest::BeginDeliveryStage { .. }
293        | NodeRequest::PutDeliveryBlobChunk { .. }
294        | NodeRequest::CommitDeliveryStage { .. }
295        | NodeRequest::AbortDeliveryStage { .. }
296        | NodeRequest::BrowseHostDirectories { .. }
297        | NodeRequest::InspectWorkspace { .. }
298        | NodeRequest::ReadWorkspaceFile { .. }
299        | NodeRequest::WriteWorkspaceFile { .. }
300        | NodeRequest::ReadGitHistory { .. }
301        | NodeRequest::ReadGitDiff { .. }
302        | NodeRequest::AcquireController { .. }
303        | NodeRequest::ReleaseController
304        | NodeRequest::RenameSessionRecord { .. }
305        | NodeRequest::SetSessionTask { .. }
306        | NodeRequest::ForgetSessionRecord { .. } => Duration::from_secs(5),
307        NodeRequest::CreateWorkspaceFile { .. }
308        | NodeRequest::CreateWorkspaceDirectory { .. } => {
309            CONTROLLER_ACQUIRE_RELAY_DEADLINE
310                .saturating_add(WORKSPACE_ENTRY_CREATE_RELAY_DEADLINE)
311        }
312        NodeRequest::CatalogNativeSessions { .. }
313        | NodeRequest::PageNativeSessions { .. } => NATIVE_SESSION_RELAY_DEADLINE,
314        NodeRequest::PreviewNativeSession { .. }
315        | NodeRequest::IndexNativeSession { .. }
316        | NodeRequest::PreviewSessionRecord { .. } => NATIVE_SESSION_RELAY_DEADLINE,
317        NodeRequest::ExportContextPackForSessionRecord { .. } => Duration::from_secs(45),
318        NodeRequest::CreateWorktree { .. }
319        | NodeRequest::CreateStandaloneWorkspace { .. }
320        | NodeRequest::RemoveWorktree { .. }
321        | NodeRequest::CleanupManagedWorktree { .. } => Duration::from_secs(240),
322        // HQ PTY spawn over restream can exceed 20s under load (controller
323        // acquire + bridge queue + Claude/Codex cold start). 15+5 headroom
324        // timed out the owner path at ~20s while the node still accepted.
325        NodeRequest::Spawn { .. }
326        | NodeRequest::SpawnWithOptions { .. }
327        | NodeRequest::Resume { .. }
328        | NodeRequest::ResumeSessionRecord { .. } => Duration::from_secs(90),
329        NodeRequest::Stop { .. } => Duration::from_secs(15),
330        NodeRequest::SpawnSpec { spec } =>
331            Duration::from_millis(spec.deadline_ms.get()) + RELAY_REPLY_HEADROOM,
332        NodeRequest::SpawnManagedWorktree { request } =>
333            Duration::from_millis(request.spawn_spec.deadline_ms.get())
334                + RELAY_REPLY_HEADROOM,
335        NodeRequest::SpawnManagedWorktreeV2 { request } =>
336            Duration::from_millis(request.spawn_spec.deadline_ms.get())
337                + RELAY_REPLY_HEADROOM,
338        NodeRequest::ResumeSessionRecord { .. } => Duration::from_secs(35),
339        _ => Duration::from_secs(10),
340    };
341    relay_deadline + RELAY_REPLY_HEADROOM
342}
343
344pub struct C2EventReceiver {
345    events: mpsc::Receiver<RoutedNodeEvent>,
346    harness_mcp_events: mpsc::Receiver<RoutedNodeEvent>,
347    browser_events: mpsc::Receiver<BrowserStreamEvent>,
348}
349
350impl C2EventReceiver {
351    pub async fn recv(&mut self) -> Option<RoutedNodeEvent> {
352        loop {
353            if self.harness_mcp_events.is_closed() && self.harness_mcp_events.is_empty() {
354                return self.events.recv().await;
355            }
356            if self.events.is_closed() && self.events.is_empty() {
357                return self.harness_mcp_events.recv().await;
358            }
359            tokio::select! {
360                biased;
361                event = self.harness_mcp_events.recv() => {
362                    if event.is_some() { return event; }
363                }
364                event = self.events.recv() => {
365                    if event.is_some() { return event; }
366                }
367            }
368        }
369    }
370
371    /// Splits into two independent mutable borrows, one per channel, so a
372    /// caller can race both in a single `tokio::select!` (the reconnect
373    /// bridge's own `pump_one_connection`). Two separate `&mut self` async
374    /// accessor methods (one per channel) cannot be used together in one
375    /// `select!`: the future each returns borrows the whole receiver for
376    /// its lifetime, and the borrow checker rejects two such futures
377    /// coexisting even though the methods would only touch disjoint
378    /// fields. Splitting once, up front, hands out two genuinely disjoint
379    /// borrows instead.
380    pub(crate) fn split_mut(
381        &mut self,
382    ) -> (
383        &mut mpsc::Receiver<RoutedNodeEvent>,
384        &mut mpsc::Receiver<RoutedNodeEvent>,
385        &mut mpsc::Receiver<BrowserStreamEvent>,
386    ) {
387        (
388            &mut self.events,
389            &mut self.harness_mcp_events,
390            &mut self.browser_events,
391        )
392    }
393}
394
395struct EventDelivery {
396    regular: mpsc::Sender<RoutedNodeEvent>,
397    harness_mcp: mpsc::Sender<RoutedNodeEvent>,
398    browser: mpsc::Sender<BrowserStreamEvent>,
399}
400
401impl From<mpsc::Sender<RoutedNodeEvent>> for EventDelivery {
402    fn from(regular: mpsc::Sender<RoutedNodeEvent>) -> Self {
403        let (browser, _) = mpsc::channel(1);
404        Self { harness_mcp: regular.clone(), regular, browser }
405    }
406}
407
408struct ControlCommand {
409    route: NodeRoute,
410    request: NodeRequest,
411    correlation_id: Option<String>,
412    reply: oneshot::Sender<Result<RoutedNodeResponse, C2ControlError>>,
413}
414
415/// Recovers a `BuildStampMismatch` relay rejection's [`C2ControlError::
416/// BuildStampMismatch`] shape from the frame `gate4agent-c2`'s own
417/// pre-handshake refusal actually sends. `C2RelayFailureCode::
418/// BuildStampMismatch` itself carries nothing -- both stamps travel only in
419/// `failure.message` (`"build stamp mismatch: local=<s> remote=<s>"`,
420/// produced by this exact tree's own `gate4agent-c2`), so the peer's stamp
421/// is recovered by parsing that self-authored, fixed-format text rather
422/// than inventing a second wire shape for the same two values. Any other
423/// code, or a `BuildStampMismatch` whose message does not parse (never
424/// happens against a peer built from this tree, but never trusted blindly
425/// either), falls back to the generic [`C2ControlError::Relay`].
426fn map_relay_rejection(failure: C2RelayFailure) -> C2ControlError {
427    if failure.code != C2RelayFailureCode::BuildStampMismatch {
428        return C2ControlError::Relay(failure);
429    }
430    match failure.message.split_whitespace().find_map(|token| token.strip_prefix("remote=")) {
431        Some(remote) => C2ControlError::BuildStampMismatch {
432            local: BUILD_STAMP.to_owned(),
433            remote: remote.to_owned(),
434        },
435        None => C2ControlError::Relay(failure),
436    }
437}
438
439enum OwnerInput {
440    Frame(C2ServerFrame),
441    Closed,
442}
443
444pub async fn connect_local(
445    endpoint: &str,
446    token: &str,
447) -> Result<(C2ControlHandle, C2EventReceiver), C2ControlError> {
448    validate_endpoint(endpoint)?;
449    validate_token(token)?;
450    let pipe = connect_local_stream(endpoint).await?;
451    connect_control_stream(pipe, token).await
452}
453
454/// Dials the C2 control frame protocol over TCP.
455///
456/// This is the HQ path after the kernel WireGuard tunnel is up: `address`
457/// is `c2_tunnel_ip:control_port`, not a public endpoint and not a node.
458/// The unix validator ([`validate_endpoint`]) does not accept this form.
459pub async fn connect_tunnel_tcp(
460    address: std::net::SocketAddr,
461    token: &str,
462) -> Result<(C2ControlHandle, C2EventReceiver), C2ControlError> {
463    validate_tunnel_control_target(address)?;
464    validate_token(token)?;
465    let stream = tokio::net::TcpStream::connect(address).await?;
466    let _ = stream.set_nodelay(true);
467    connect_control_stream(stream, token).await
468}
469
470async fn connect_control_stream<S>(
471    mut pipe: S,
472    token: &str,
473) -> Result<(C2ControlHandle, C2EventReceiver), C2ControlError>
474where
475    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
476{
477    let client_nonce = random_nonce().map_err(C2ControlError::Authentication)?;
478    let compatibility_offer = client_compatibility_offer()?;
479    timeout(AUTH_DEADLINE, write_json_frame_limited(
480        &mut pipe,
481        &C2ClientFrame::Hello(C2ClientHello::negotiating(
482            client_nonce,
483            compatibility_offer.clone(),
484        )),
485        MAX_C2_AUTH_FRAME_BYTES,
486    )).await.map_err(|_| C2ControlError::AuthenticationTimedOut)??;
487    let challenge = timeout(AUTH_DEADLINE, read_server_frame(&mut pipe, MAX_C2_AUTH_FRAME_BYTES))
488        .await.map_err(|_| C2ControlError::AuthenticationTimedOut)??;
489    let challenge = match challenge {
490        C2ServerFrame::Challenge(challenge) => challenge,
491        C2ServerFrame::Rejected(failure) => return Err(map_relay_rejection(failure)),
492        _ => return Err(C2ControlError::Protocol(
493            "C2 did not return an authentication challenge".to_owned(),
494        )),
495    };
496    if challenge.build_stamp != BUILD_STAMP {
497        return Err(C2ControlError::BuildStampMismatch {
498            local: BUILD_STAMP.to_owned(),
499            remote: challenge.build_stamp.clone(),
500        });
501    }
502    let selected = challenge.compatibility.as_ref().ok_or_else(|| {
503        C2ControlError::Protocol(
504            "C2 omitted the required authenticated compatibility selection".to_owned(),
505        )
506    })?;
507    validate_selected_compatibility(
508        &compatibility_offer,
509        Some(selected),
510    )?;
511    let expected_server = c2_proof(
512        token,
513        C2AuthDirection::Server,
514        &client_nonce,
515        &challenge.server_nonce,
516        Some((&compatibility_offer, selected)),
517    )?;
518    if !proofs_match(&challenge.server_proof, &expected_server) {
519        return Err(C2ControlError::Authentication("C2 server proof mismatch".to_owned()));
520    }
521    let client_proof = c2_proof(
522        token,
523        C2AuthDirection::Client,
524        &client_nonce,
525        &challenge.server_nonce,
526        Some((&compatibility_offer, selected)),
527    )?;
528    timeout(AUTH_DEADLINE, write_json_frame_limited(
529        &mut pipe,
530        &C2ClientFrame::Authenticate(C2ClientAuthentication { client_proof }),
531        MAX_C2_AUTH_FRAME_BYTES,
532    )).await.map_err(|_| C2ControlError::AuthenticationTimedOut)??;
533    let hello = timeout(HELLO_DEADLINE, read_server_frame(&mut pipe, MAX_C2_HELLO_FRAME_BYTES))
534        .await.map_err(|_| C2ControlError::AuthenticationTimedOut)??;
535    let hello = match hello {
536        C2ServerFrame::Hello(hello) if hello.build_stamp == BUILD_STAMP => hello,
537        C2ServerFrame::Rejected(failure) =>
538            return Err(map_relay_rejection(failure)),
539        C2ServerFrame::Hello(hello) =>
540            return Err(C2ControlError::BuildStampMismatch {
541                local: BUILD_STAMP.to_owned(),
542                remote: hello.build_stamp.clone(),
543            }),
544        _ => return Err(C2ControlError::Protocol("C2 did not return hello".to_owned())),
545    };
546    if hello.compatibility.is_none() {
547        return Err(C2ControlError::Protocol(
548            "C2 omitted the authenticated compatibility selection from hello".to_owned(),
549        ));
550    }
551    validate_selected_compatibility(&compatibility_offer, hello.compatibility.as_ref())?;
552    if hello.compatibility != challenge.compatibility {
553        return Err(C2ControlError::Protocol(
554            "C2 compatibility selection changed after authentication".to_owned(),
555        ));
556    }
557    let path_capabilities = negotiated_path_capabilities(hello.compatibility.as_ref());
558    if !path_capabilities.provider_ids_open && status_has_open_provider_id(&hello.status) {
559        return Err(C2ControlError::Protocol(
560            OPEN_PROVIDER_ID_NOT_NEGOTIATED.to_owned(),
561        ));
562    }
563    if !path_capabilities.managed_worktree_lifecycle
564        && status_has_managed_worktree(&hello.status)
565    {
566        return Err(C2ControlError::Protocol(
567            MANAGED_WORKTREE_LIFECYCLE_NOT_NEGOTIATED.to_owned(),
568        ));
569    }
570    if !path_capabilities.worktree_selection
571        && status_has_managed_worktree(&hello.status)
572    {
573        return Err(C2ControlError::Protocol(
574            WORKTREE_SELECTION_NOT_NEGOTIATED.to_owned(),
575        ));
576    }
577    if !path_capabilities.child_environment_profile
578        && status_has_child_environment_profile(&hello.status)
579    {
580        return Err(C2ControlError::Protocol(
581            CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED.to_owned(),
582        ));
583    }
584    if !path_capabilities.session_bundle_materialization
585        && status_has_session_bundle_materialization(&hello.status)
586    {
587        return Err(C2ControlError::Protocol(
588            SESSION_BUNDLE_MATERIALIZATION_NOT_NEGOTIATED.to_owned(),
589        ));
590    }
591
592    let (reader, writer) = tokio::io::split(pipe);
593    let (commands_tx, commands_rx) = mpsc::channel(COMMAND_CAPACITY);
594    let (browser_commands_tx, browser_commands_rx) = mpsc::channel(16);
595    let (events_tx, events_rx) = mpsc::channel(EVENT_CAPACITY);
596    let (browser_events_tx, browser_events_rx) = mpsc::channel::<BrowserStreamEvent>(64);
597    let (harness_mcp_events_tx, harness_mcp_events_rx) =
598        mpsc::channel(HARNESS_MCP_EVENT_CAPACITY);
599    let initial_topology = Arc::new(C2Topology::from_status(&hello.status));
600    let (topology_tx, topology_rx) = watch::channel(initial_topology);
601    let (writer_tx, writer_rx) = mpsc::channel(WRITER_CAPACITY);
602    let (owner_tx, owner_rx) = mpsc::channel(INBOUND_CAPACITY);
603    let reader_task = tokio::spawn(control_reader(reader, owner_tx.clone()));
604    let writer_task = tokio::spawn(control_writer(writer, writer_rx, owner_tx));
605    tokio::spawn(async move {
606        control_owner(
607            commands_rx,
608            browser_commands_rx,
609            EventDelivery { regular: events_tx, harness_mcp: harness_mcp_events_tx, browser: browser_events_tx },
610            topology_tx,
611            writer_tx,
612            owner_rx,
613            path_capabilities,
614        ).await;
615        reader_task.abort();
616        writer_task.abort();
617    });
618    Ok((C2ControlHandle {
619        commands: commands_tx,
620        browser_commands: browser_commands_tx,
621        hello: Arc::new(hello),
622        topology: topology_rx,
623        terminal_frame_events: path_capabilities.terminal_frame_events,
624    }, C2EventReceiver {
625        events: events_rx,
626        harness_mcp_events: harness_mcp_events_rx,
627        browser_events: browser_events_rx,
628    }))
629}
630
631pub(crate) fn client_compatibility_offer() -> Result<ClientCompatibilityOffer, C2ControlError> {
632    Ok(ClientCompatibilityOffer {
633        build_stamp: BUILD_STAMP.to_owned(),
634        capabilities: vec![
635            CapabilityId::new(C2_COMPATIBILITY_METADATA_CAPABILITY)
636                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
637            CapabilityId::new(C2_OPAQUE_UNIX_PATH_CAPABILITY)
638                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
639            CapabilityId::new(C2_REPOSITORY_PATH_CAPABILITY)
640                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
641            CapabilityId::new(C2_WORKSPACE_FILE_READ_CAPABILITY)
642                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
643            CapabilityId::new(C2_WORKSPACE_FILE_WRITE_CAPABILITY)
644                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
645            CapabilityId::new(C2_WORKSPACE_ENTRY_CREATE_CAPABILITY)
646                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
647            CapabilityId::new(C2_GIT_READ_CAPABILITY)
648                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
649            CapabilityId::new(C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY)
650                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
651            CapabilityId::new(C2_PROVIDER_RUNTIME_STATUS_CAPABILITY)
652                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
653            CapabilityId::new(C2_PROVIDER_ID_OPEN_CAPABILITY)
654                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
655            CapabilityId::new(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY)
656                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
657            CapabilityId::new(C2_SPAWN_PROFILE_REVISION_CAPABILITY)
658                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
659            CapabilityId::new(C2_TERMINAL_FRAME_EVENTS_CAPABILITY)
660                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
661            CapabilityId::new(C2_AGENT_STREAM_EVENTS_CAPABILITY)
662                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
663            CapabilityId::new(C2_WORKTREE_SELECTION_CAPABILITY)
664                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
665            CapabilityId::new(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY)
666                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
667            CapabilityId::new(C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY)
668                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
669            CapabilityId::new(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY)
670                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
671            CapabilityId::new(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY)
672                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
673            CapabilityId::new(C2_HISTORY_CONTEXT_PACK_CAPABILITY)
674                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
675            CapabilityId::new(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY)
676                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
677            CapabilityId::new(C2_NATIVE_SESSION_CATALOG_CAPABILITY)
678                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
679            CapabilityId::new(C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY)
680                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
681            CapabilityId::new(C2_NATIVE_SESSION_INDEX_CAPABILITY)
682                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
683            CapabilityId::new(C2_NATIVE_SESSION_PREVIEW_CAPABILITY)
684                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
685            CapabilityId::new(C2_HOST_DIRECTORY_BROWSE_CAPABILITY)
686                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
687            CapabilityId::new(C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
688                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
689            CapabilityId::new(C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY)
690                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
691            CapabilityId::new(C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY)
692                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
693            CapabilityId::new(C2_SESSION_TASK_CORRELATION_CAPABILITY)
694                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
695            CapabilityId::new(C2_CONTROL_DETAIL_CAPABILITY)
696                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
697            CapabilityId::new(C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
698                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
699            CapabilityId::new(C2_HARNESS_MCP_READ_PROXY_CAPABILITY)
700                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
701            CapabilityId::new(C2_ACP_CONTROL_CAPABILITY)
702                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
703        ],
704        state_schema: None,
705    })
706}
707
708fn validate_selected_compatibility(
709    offer: &ClientCompatibilityOffer,
710    selected: Option<&NegotiatedC2ControlCompatibility>,
711) -> Result<(), C2ControlError> {
712    let Some(selected) = selected else {
713        return Err(C2ControlError::Protocol(
714            "C2 omitted the required authenticated compatibility selection".to_owned(),
715        ));
716    };
717    if selected.build_stamp != offer.build_stamp {
718        return Err(C2ControlError::BuildStampMismatch {
719            local: offer.build_stamp.clone(),
720            remote: selected.build_stamp.clone(),
721        });
722    }
723    if selected
724        .capabilities
725        .iter()
726        .any(|capability| !offer.capabilities.contains(capability))
727    {
728        return Err(C2ControlError::Protocol(
729            "C2 selected a capability outside the client offer".to_owned(),
730        ));
731    }
732    if !selected
733        .capabilities
734        .iter()
735        .any(|capability| capability.as_str() == C2_COMPATIBILITY_METADATA_CAPABILITY)
736    {
737        return Err(C2ControlError::Protocol(
738            "C2 omitted the required compatibility metadata capability".to_owned(),
739        ));
740    }
741    Ok(())
742}
743
744fn negotiated_path_capabilities(
745    selected: Option<&NegotiatedC2ControlCompatibility>,
746) -> NegotiatedPathCapabilities {
747    let selected_has = |expected| {
748        selected.is_some_and(|selected| {
749            selected
750                .capabilities
751                .iter()
752                .any(|capability| capability.as_str() == expected)
753        })
754    };
755    NegotiatedPathCapabilities {
756        opaque_host_paths: selected_has(C2_OPAQUE_UNIX_PATH_CAPABILITY),
757        repository_paths: selected_has(C2_REPOSITORY_PATH_CAPABILITY),
758        workspace_file_read: selected_has(C2_WORKSPACE_FILE_READ_CAPABILITY),
759        workspace_file_write: selected_has(C2_WORKSPACE_FILE_WRITE_CAPABILITY),
760        workspace_entry_create: selected_has(C2_WORKSPACE_ENTRY_CREATE_CAPABILITY),
761        git_read: selected_has(C2_GIT_READ_CAPABILITY),
762        host_directory_browse: selected_has(C2_HOST_DIRECTORY_BROWSE_CAPABILITY),
763        standalone_workspace_lifecycle:
764            selected_has(C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY),
765        provider_session_reference_index:
766            selected_has(C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY),
767        provider_ids_open: selected_has(C2_PROVIDER_ID_OPEN_CAPABILITY),
768        spawn_spec_defaults_overrides:
769            selected_has(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY),
770        spawn_profile_revision: selected_has(C2_SPAWN_PROFILE_REVISION_CAPABILITY),
771        worktree_selection: selected_has(C2_WORKTREE_SELECTION_CAPABILITY),
772        managed_worktree_lifecycle:
773            selected_has(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY),
774        managed_worktree_spawn_v2:
775            selected_has(C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY),
776        child_environment_profile:
777            selected_has(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY),
778        session_bundle_materialization:
779            selected_has(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY),
780        history_context_pack: selected_has(C2_HISTORY_CONTEXT_PACK_CAPABILITY),
781        session_record_context_export:
782            selected_has(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY),
783        native_session_catalog: selected_has(C2_NATIVE_SESSION_CATALOG_CAPABILITY),
784        native_session_catalog_paging:
785            selected_has(C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY),
786        native_session_index: selected_has(C2_NATIVE_SESSION_INDEX_CAPABILITY),
787        native_session_preview: selected_has(C2_NATIVE_SESSION_PREVIEW_CAPABILITY),
788        terminal_frame_events: selected_has(C2_TERMINAL_FRAME_EVENTS_CAPABILITY),
789        agent_stream_events: selected_has(C2_AGENT_STREAM_EVENTS_CAPABILITY),
790        agent_progress_snapshot: selected_has(C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY),
791        session_task_correlation: selected_has(C2_SESSION_TASK_CORRELATION_CAPABILITY),
792        control_detail: selected_has(C2_CONTROL_DETAIL_CAPABILITY),
793        delivery_bundle_v2_stage_commit:
794            selected_has(C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY),
795        harness_mcp_read_proxy: selected_has(C2_HARNESS_MCP_READ_PROXY_CAPABILITY),
796        acp_control: selected_has(C2_ACP_CONTROL_CAPABILITY),
797    }
798}
799
800/// Names the capability a refused request required. Every capability with a
801/// dedicated arm here names itself by its own message; a capability that
802/// falls to the final arm is one `NodeRequest::required_capability()`
803/// returned but this build's `NegotiatedPathCapabilities` has no dedicated
804/// field for yet -- it must refuse under ITS OWN name, never borrow a
805/// sibling's. That exact misattribution once sent a live investigation into
806/// the wrong crate: a refused `acp-control-v1` request was reported as a
807/// refused workspace file read, because the fallback arm always named
808/// `C2_WORKSPACE_FILE_READ_CAPABILITY` regardless of what was actually
809/// missing.
810fn unnegotiated_capability_refusal(capability: Option<&str>) -> String {
811    match capability {
812        Some(C2_ACP_CONTROL_CAPABILITY) => ACP_CONTROL_NOT_NEGOTIATED.to_owned(),
813        Some(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY) => {
814            SPAWN_SPEC_NOT_NEGOTIATED.to_owned()
815        }
816        Some(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY) => {
817            MANAGED_WORKTREE_LIFECYCLE_NOT_NEGOTIATED.to_owned()
818        }
819        Some(C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY) => {
820            MANAGED_WORKTREE_SPAWN_V2_NOT_NEGOTIATED.to_owned()
821        }
822        Some(C2_HISTORY_CONTEXT_PACK_CAPABILITY) => {
823            HISTORY_CONTEXT_PACK_NOT_NEGOTIATED.to_owned()
824        }
825        Some(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY) => {
826            SESSION_RECORD_CONTEXT_EXPORT_NOT_NEGOTIATED.to_owned()
827        }
828        Some(C2_HOST_DIRECTORY_BROWSE_CAPABILITY) => {
829            HOST_DIRECTORY_BROWSE_NOT_NEGOTIATED.to_owned()
830        }
831        Some(C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY) => {
832            STANDALONE_WORKSPACE_LIFECYCLE_NOT_NEGOTIATED.to_owned()
833        }
834        Some(C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY) => {
835            PROVIDER_SESSION_REFERENCE_INDEX_NOT_NEGOTIATED.to_owned()
836        }
837        Some(C2_WORKSPACE_ENTRY_CREATE_CAPABILITY) => {
838            WORKSPACE_ENTRY_CREATE_NOT_NEGOTIATED.to_owned()
839        }
840        Some(C2_NATIVE_SESSION_INDEX_CAPABILITY) => {
841            "native session index capability was not negotiated".to_owned()
842        }
843        Some(C2_SESSION_TASK_CORRELATION_CAPABILITY) => {
844            SESSION_TASK_CORRELATION_NOT_NEGOTIATED.to_owned()
845        }
846        Some(C2_CONTROL_DETAIL_CAPABILITY) => CONTROL_DETAIL_NOT_NEGOTIATED.to_owned(),
847        Some(C2_HARNESS_MCP_READ_PROXY_CAPABILITY) => {
848            "harness MCP read proxy capability was not negotiated".to_owned()
849        }
850        Some(C2_WORKSPACE_FILE_READ_CAPABILITY) => {
851            WORKSPACE_FILE_READ_NOT_NEGOTIATED.to_owned()
852        }
853        Some(unrecognized) => format!(
854            "C2 control capability \"{unrecognized}\" is required but was not negotiated",
855        ),
856        // `required_capability_available` is only `false` when
857        // `required_capability()` returned `Some(..)`; this arm is
858        // unreachable in practice and carries no capability to name.
859        None => "a required C2 control capability was not negotiated".to_owned(),
860    }
861}
862
863fn reject_unnegotiated_outbound_path(
864    request: &NodeRequest,
865    capabilities: NegotiatedPathCapabilities,
866) -> Result<(), C2ControlError> {
867    let now_unix_ms = current_unix_ms()?;
868    if !request.harness_mcp_contract_is_valid_at(now_unix_ms) {
869        return Err(C2ControlError::Protocol(
870            "invalid harness MCP proxy request".to_owned(),
871        ));
872    }
873    if !request.history_context_pack_contract_is_valid() {
874        return Err(C2ControlError::Protocol(
875            INVALID_HISTORY_CONTEXT_PACK_REQUEST.to_owned(),
876        ));
877    }
878    if !request.native_session_catalog_contract_is_valid() {
879        return Err(C2ControlError::Protocol(
880            "invalid native session catalog request".to_owned(),
881        ));
882    }
883    if !request.native_session_preview_contract_is_valid() {
884        return Err(C2ControlError::Protocol(
885            "invalid native session preview request".to_owned(),
886        ));
887    }
888    if matches!(request, NodeRequest::IndexNativeSession { selection, .. }
889        if selection.route.scope
890            != gate4agent_node_protocol::NativeSessionCatalogScope::Workspace)
891    {
892        return Err(C2ControlError::Protocol(
893            "external native sessions must be registered as workspaces before indexing"
894                .to_owned(),
895        ));
896    }
897    if !capabilities.opaque_host_paths && node_request_has_unix_bytes(request) {
898        return Err(C2ControlError::Protocol(
899            OPAQUE_UNIX_PATH_NOT_NEGOTIATED.to_owned(),
900        ));
901    }
902    if !capabilities.provider_ids_open
903        && (matches!(request, NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } if !provider_id_is_legacy(provider))
904            || matches!(request, NodeRequest::IndexProviderSession { provider, .. } if !provider_id_is_legacy(provider))
905            || matches!(request, NodeRequest::CatalogNativeSessions { route, .. } if !provider_id_is_legacy(&route.provider))
906            || matches!(request, NodeRequest::PageNativeSessions { route, .. } if !provider_id_is_legacy(&route.provider))
907            || matches!(request, NodeRequest::PreviewNativeSession { selection, .. } if !provider_id_is_legacy(&selection.route.provider))
908            || matches!(request, NodeRequest::IndexNativeSession { selection, .. } if !provider_id_is_legacy(&selection.route.provider))
909            || spawn_spec_requires_open_provider_capability(request)
910            || matches!(request, NodeRequest::ForgetContextPack { .. }))
911    {
912        return Err(C2ControlError::Protocol(
913            OPEN_PROVIDER_ID_NOT_NEGOTIATED.to_owned(),
914        ));
915    }
916    let required_capability_available = match request.required_capability() {
917        None => true,
918        Some(C2_WORKSPACE_FILE_READ_CAPABILITY) => capabilities.workspace_file_read,
919        Some(C2_WORKSPACE_FILE_WRITE_CAPABILITY) => capabilities.workspace_file_write,
920        Some(C2_WORKSPACE_ENTRY_CREATE_CAPABILITY) => capabilities.workspace_entry_create,
921        Some(C2_GIT_READ_CAPABILITY) => capabilities.git_read,
922        Some(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY) => {
923            capabilities.spawn_spec_defaults_overrides
924        }
925        Some(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY) => {
926            capabilities.managed_worktree_lifecycle
927        }
928        Some(C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY) => {
929            capabilities.managed_worktree_spawn_v2
930        }
931        Some(C2_HISTORY_CONTEXT_PACK_CAPABILITY) => capabilities.history_context_pack,
932        Some(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY) => {
933            capabilities.session_record_context_export
934        }
935        Some(C2_NATIVE_SESSION_CATALOG_CAPABILITY) => capabilities.native_session_catalog,
936        Some(C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY) => {
937            capabilities.native_session_catalog_paging
938        }
939        Some(C2_NATIVE_SESSION_INDEX_CAPABILITY) => capabilities.native_session_index,
940        Some(C2_NATIVE_SESSION_PREVIEW_CAPABILITY) => capabilities.native_session_preview,
941        Some(C2_HOST_DIRECTORY_BROWSE_CAPABILITY) => capabilities.host_directory_browse,
942        Some(C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY) => {
943            capabilities.standalone_workspace_lifecycle
944        }
945        Some(C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY) => {
946            capabilities.provider_session_reference_index
947        }
948        Some(C2_SESSION_TASK_CORRELATION_CAPABILITY) => {
949            capabilities.session_task_correlation
950        }
951        Some(C2_CONTROL_DETAIL_CAPABILITY) => capabilities.control_detail,
952        Some(C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY) => {
953            capabilities.delivery_bundle_v2_stage_commit
954        }
955        Some(C2_HARNESS_MCP_READ_PROXY_CAPABILITY) => capabilities.harness_mcp_read_proxy,
956        Some(C2_ACP_CONTROL_CAPABILITY) => capabilities.acp_control,
957        Some(_) => false,
958    };
959    if !required_capability_available {
960        return Err(C2ControlError::Protocol(unnegotiated_capability_refusal(
961            request.required_capability(),
962        )));
963    }
964    if request.requires_spawn_spec_defaults_overrides_capability()
965        && !capabilities.spawn_spec_defaults_overrides
966    {
967        return Err(C2ControlError::Protocol(
968            SPAWN_SPEC_NOT_NEGOTIATED.to_owned(),
969        ));
970    }
971    if request.requires_spawn_profile_revision_capability()
972        && !capabilities.spawn_profile_revision
973    {
974        return Err(C2ControlError::Protocol(
975            SPAWN_PROFILE_REVISION_NOT_NEGOTIATED.to_owned(),
976        ));
977    }
978    if request.requires_worktree_selection_capability()
979        && !capabilities.worktree_selection
980    {
981        return Err(C2ControlError::Protocol(
982            WORKTREE_SELECTION_NOT_NEGOTIATED.to_owned(),
983        ));
984    }
985    if c2_request_requires_child_environment_profile_capability(request)
986        && !capabilities.child_environment_profile
987    {
988        return Err(C2ControlError::Protocol(
989            CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED.to_owned(),
990        ));
991    }
992    if request.requires_session_bundle_materialization_capability()
993        && !capabilities.session_bundle_materialization
994    {
995        return Err(C2ControlError::Protocol(
996            SESSION_BUNDLE_MATERIALIZATION_NOT_NEGOTIATED.to_owned(),
997        ));
998    }
999    if request.requires_history_context_pack_capability()
1000        && !capabilities.history_context_pack
1001    {
1002        return Err(C2ControlError::Protocol(
1003            HISTORY_CONTEXT_PACK_NOT_NEGOTIATED.to_owned(),
1004        ));
1005    }
1006    if !capabilities.repository_paths && node_request_has_unix_repository_path(request) {
1007        return Err(C2ControlError::Protocol(
1008            REPOSITORY_PATH_NOT_NEGOTIATED.to_owned(),
1009        ));
1010    }
1011    Ok(())
1012}
1013
1014fn current_unix_ms() -> Result<u64, C2ControlError> {
1015    SystemTime::now()
1016        .duration_since(UNIX_EPOCH)
1017        .map_err(|_| C2ControlError::Protocol("system clock precedes Unix epoch".to_owned()))?
1018        .as_millis()
1019        .try_into()
1020        .map_err(|_| C2ControlError::Protocol("system clock exceeds protocol range".to_owned()))
1021}
1022
1023fn c2_request_requires_child_environment_profile_capability(request: &NodeRequest) -> bool {
1024    let spec = match request {
1025        NodeRequest::SpawnSpec { spec } => spec,
1026        NodeRequest::SpawnManagedWorktree { request } => &request.spawn_spec,
1027        NodeRequest::SpawnManagedWorktreeV2 { request } => &request.spawn_spec,
1028        _ => return false,
1029    };
1030    !matches!(
1031        &spec.overrides.environment_profile_id,
1032        gate4agent_node_protocol::SpawnOverride::Clear
1033    )
1034}
1035
1036fn spawn_spec_requires_open_provider_capability(request: &NodeRequest) -> bool {
1037    let spec = match request {
1038        NodeRequest::SpawnSpec { spec } => spec,
1039        NodeRequest::SpawnManagedWorktree { request } => &request.spawn_spec,
1040        NodeRequest::SpawnManagedWorktreeV2 { request } => &request.spawn_spec,
1041        _ => return false,
1042    };
1043    match &spec.overrides.provider {
1044        gate4agent_node_protocol::SpawnOverride::Set { value } => {
1045            !provider_id_is_legacy(value)
1046        }
1047        gate4agent_node_protocol::SpawnOverride::Inherit
1048        | gate4agent_node_protocol::SpawnOverride::Clear => true,
1049    }
1050}
1051
1052fn node_request_has_unix_repository_path(request: &NodeRequest) -> bool {
1053    match request {
1054        NodeRequest::ReadWorkspaceFile { path, .. }
1055        | NodeRequest::WriteWorkspaceFile { path, .. }
1056        | NodeRequest::CreateWorkspaceFile { path, .. }
1057        | NodeRequest::CreateWorkspaceDirectory { path, .. } => path.as_unix_bytes().is_some(),
1058        NodeRequest::ReadGitDiff { request, .. } => request
1059            .path
1060            .as_ref()
1061            .is_some_and(|path| path.as_unix_bytes().is_some()),
1062        NodeRequest::Snapshot
1063        | NodeRequest::Resync { .. }
1064        | NodeRequest::ArmHarnessMcpReservation { .. }
1065        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
1066        | NodeRequest::ActivateHarnessMcpReservation { .. }
1067        | NodeRequest::AbortHarnessMcpReservation { .. }
1068        | NodeRequest::PutHarnessMcpReplyChunk { .. }
1069        | NodeRequest::RejectHarnessMcpCall { .. }
1070        | NodeRequest::BeginDeliveryStage { .. }
1071        | NodeRequest::PutDeliveryBlobChunk { .. }
1072        | NodeRequest::CommitDeliveryStage { .. }
1073        | NodeRequest::AbortDeliveryStage { .. }
1074        | NodeRequest::BrowseHostDirectories { .. }
1075        | NodeRequest::InspectWorkspace { .. }
1076        | NodeRequest::ReadGitHistory { .. }
1077        | NodeRequest::AcquireController { .. }
1078        | NodeRequest::ReleaseController
1079        | NodeRequest::RegisterWorkspace { .. }
1080        | NodeRequest::CreateStandaloneWorkspace { .. }
1081        | NodeRequest::UnregisterWorkspace { .. }
1082        | NodeRequest::CreateWorktree { .. }
1083        | NodeRequest::RemoveWorktree { .. }
1084        | NodeRequest::Spawn { .. }
1085        | NodeRequest::SpawnWithOptions { .. }
1086        | NodeRequest::SpawnSpec { .. }
1087        | NodeRequest::SpawnManagedWorktree { .. }
1088        | NodeRequest::SpawnManagedWorktreeV2 { .. }
1089        | NodeRequest::CleanupManagedWorktree { .. }
1090        | NodeRequest::Resume { .. }
1091        | NodeRequest::RenameSessionRecord { .. }
1092        | NodeRequest::SetSessionTask { .. }
1093        | NodeRequest::IndexProviderSession { .. }
1094        | NodeRequest::IndexNativeSession { .. }
1095        | NodeRequest::ResumeSessionRecord { .. }
1096        | NodeRequest::ForgetSessionRecord { .. }
1097        | NodeRequest::CatalogNativeSessions { .. }
1098        | NodeRequest::PageNativeSessions { .. }
1099        | NodeRequest::PreviewNativeSession { .. }
1100        | NodeRequest::PreviewSessionRecord { .. }
1101        | NodeRequest::DiscoverHistory { .. }
1102        | NodeRequest::LoadHistory { .. }
1103        | NodeRequest::ExportContextPackForSessionRecord { .. }
1104        | NodeRequest::ExportContextPack { .. }
1105        | NodeRequest::ForgetContextPack { .. }
1106        | NodeRequest::ResolveDurableContextPack { .. }
1107        | NodeRequest::ReadContextPack { .. }
1108        | NodeRequest::Prompt { .. }
1109        | NodeRequest::Paste { .. }
1110        | NodeRequest::Input { .. }
1111        | NodeRequest::TerminalBytes { .. }
1112        | NodeRequest::TerminalControl { .. }
1113        | NodeRequest::Resize { .. }
1114        | NodeRequest::Interrupt { .. }
1115        | NodeRequest::Stop { .. }
1116        | NodeRequest::Remove { .. }
1117        | NodeRequest::ResolveInteraction { .. }
1118        | NodeRequest::SetSessionMode { .. }
1119        | NodeRequest::SetSessionConfigOption { .. }
1120        | NodeRequest::SetSessionModel { .. }
1121        | NodeRequest::Shutdown => false,
1122    }
1123}
1124
1125fn node_request_has_unix_bytes(request: &NodeRequest) -> bool {
1126    match request {
1127        NodeRequest::RegisterWorkspace { root, .. }
1128        | NodeRequest::CreateStandaloneWorkspace { root, .. } => {
1129            root.as_unix_bytes().is_some()
1130        }
1131        NodeRequest::BrowseHostDirectories { directory, after } => {
1132            directory.as_ref().is_some_and(|path| path.as_unix_bytes().is_some())
1133                || after.as_ref().is_some_and(|path| path.as_unix_bytes().is_some())
1134        }
1135        NodeRequest::CreateWorktree { target_root, .. }
1136        | NodeRequest::RemoveWorktree { target_root, .. } => {
1137            target_root.as_unix_bytes().is_some()
1138        }
1139        NodeRequest::Snapshot
1140        | NodeRequest::Resync { .. }
1141        | NodeRequest::ArmHarnessMcpReservation { .. }
1142        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
1143        | NodeRequest::ActivateHarnessMcpReservation { .. }
1144        | NodeRequest::AbortHarnessMcpReservation { .. }
1145        | NodeRequest::PutHarnessMcpReplyChunk { .. }
1146        | NodeRequest::RejectHarnessMcpCall { .. }
1147        | NodeRequest::InspectWorkspace { .. }
1148        | NodeRequest::ReadWorkspaceFile { .. }
1149        | NodeRequest::WriteWorkspaceFile { .. }
1150        | NodeRequest::CreateWorkspaceFile { .. }
1151        | NodeRequest::CreateWorkspaceDirectory { .. }
1152        | NodeRequest::ReadGitHistory { .. }
1153        | NodeRequest::ReadGitDiff { .. }
1154        | NodeRequest::BeginDeliveryStage { .. }
1155        | NodeRequest::PutDeliveryBlobChunk { .. }
1156        | NodeRequest::CommitDeliveryStage { .. }
1157        | NodeRequest::AbortDeliveryStage { .. }
1158        | NodeRequest::AcquireController { .. }
1159        | NodeRequest::ReleaseController
1160        | NodeRequest::UnregisterWorkspace { .. }
1161        | NodeRequest::Spawn { .. }
1162        | NodeRequest::SpawnWithOptions { .. }
1163        | NodeRequest::SpawnSpec { .. }
1164        | NodeRequest::SpawnManagedWorktree { .. }
1165        | NodeRequest::SpawnManagedWorktreeV2 { .. }
1166        | NodeRequest::CleanupManagedWorktree { .. }
1167        | NodeRequest::Resume { .. }
1168        | NodeRequest::RenameSessionRecord { .. }
1169        | NodeRequest::SetSessionTask { .. }
1170        | NodeRequest::IndexProviderSession { .. }
1171        | NodeRequest::IndexNativeSession { .. }
1172        | NodeRequest::ResumeSessionRecord { .. }
1173        | NodeRequest::ForgetSessionRecord { .. }
1174        | NodeRequest::CatalogNativeSessions { .. }
1175        | NodeRequest::PageNativeSessions { .. }
1176        | NodeRequest::PreviewNativeSession { .. }
1177        | NodeRequest::PreviewSessionRecord { .. }
1178        | NodeRequest::DiscoverHistory { .. }
1179        | NodeRequest::LoadHistory { .. }
1180        | NodeRequest::ExportContextPackForSessionRecord { .. }
1181        | NodeRequest::ExportContextPack { .. }
1182        | NodeRequest::ForgetContextPack { .. }
1183        | NodeRequest::ResolveDurableContextPack { .. }
1184        | NodeRequest::ReadContextPack { .. }
1185        | NodeRequest::Prompt { .. }
1186        | NodeRequest::Paste { .. }
1187        | NodeRequest::Input { .. }
1188        | NodeRequest::TerminalBytes { .. }
1189        | NodeRequest::TerminalControl { .. }
1190        | NodeRequest::Resize { .. }
1191        | NodeRequest::Interrupt { .. }
1192        | NodeRequest::Stop { .. }
1193        | NodeRequest::Remove { .. }
1194        | NodeRequest::ResolveInteraction { .. }
1195        | NodeRequest::SetSessionMode { .. }
1196        | NodeRequest::SetSessionConfigOption { .. }
1197        | NodeRequest::SetSessionModel { .. }
1198        | NodeRequest::Shutdown => false,
1199    }
1200}
1201
1202fn routed_response_has_unix_bytes(response: &RoutedNodeResponse) -> bool {
1203    response
1204        .response
1205        .as_ref()
1206        .is_ok_and(c2_node_response_has_unix_bytes)
1207}
1208
1209fn routed_response_has_terminal_frame_event(response: &RoutedNodeResponse) -> bool {
1210    response
1211        .response
1212        .as_ref()
1213        .is_ok_and(c2_node_response_has_terminal_frame_event)
1214}
1215
1216fn routed_response_has_agent_stream_event(response: &RoutedNodeResponse) -> bool {
1217    response
1218        .response
1219        .as_ref()
1220        .is_ok_and(c2_node_response_has_agent_stream_event)
1221}
1222
1223fn routed_response_has_agent_progress(response: &RoutedNodeResponse) -> bool {
1224    response.response.as_ref().is_ok_and(|response| match response {
1225        C2NodeResponse::Snapshot { snapshot, .. }
1226        | C2NodeResponse::Resync { snapshot, .. } => !snapshot.agent_progress.is_empty(),
1227        _ => false,
1228    })
1229}
1230
1231fn routed_response_requires_history_context_pack(response: &RoutedNodeResponse) -> bool {
1232    match &response.response {
1233        Ok(response) => response.requires_history_context_pack_capability(),
1234        Err(failure) => matches!(
1235            failure.code,
1236            gate4agent_node_protocol::NodeFailureCode::UnknownContextPack
1237                | gate4agent_node_protocol::NodeFailureCode::ContextPackBusy
1238                | gate4agent_node_protocol::NodeFailureCode::ContextPackMaterializationFailed
1239        ),
1240    }
1241}
1242
1243fn routed_response_requires_native_session_catalog(response: &RoutedNodeResponse) -> bool {
1244    response.response.as_ref().is_ok_and(
1245        C2NodeResponse::requires_native_session_catalog_capability,
1246    )
1247}
1248
1249fn routed_response_requires_native_session_catalog_paging(response: &RoutedNodeResponse) -> bool {
1250    match &response.response {
1251        Ok(response) => response.requires_native_session_catalog_paging_capability(),
1252        Err(failure) => failure.requires_native_session_catalog_paging_capability(),
1253    }
1254}
1255
1256fn routed_response_requires_native_session_preview(response: &RoutedNodeResponse) -> bool {
1257    response.response.as_ref().is_ok_and(
1258        C2NodeResponse::requires_native_session_preview_capability,
1259    )
1260}
1261
1262fn routed_response_requires_native_session_index(response: &RoutedNodeResponse) -> bool {
1263    response.response.as_ref().is_ok_and(
1264        C2NodeResponse::requires_native_session_index_capability,
1265    )
1266}
1267
1268fn routed_response_requires_harness_mcp_proxy(response: &RoutedNodeResponse) -> bool {
1269    match &response.response {
1270        Ok(response) => response.requires_harness_mcp_proxy_capability(),
1271        Err(failure) => failure.requires_harness_mcp_proxy_capability(),
1272    }
1273}
1274
1275fn validate_harness_mcp_response(
1276    expected: &NodeRequest,
1277    response: &Result<RoutedNodeResponse, C2RelayFailure>,
1278) -> Result<(), &'static str> {
1279    let Ok(routed) = response else { return Ok(()); };
1280    use C2NodeResponse as Response;
1281    use NodeRequest as Request;
1282    let valid = match (expected, &routed.response) {
1283        (Request::ArmHarnessMcpReservation { reservation_id, activation_digest, expires_at_unix_ms, .. },
1284            Ok(Response::Armed { reservation_id: echoed_id, activation_digest: echoed_digest, expires_at_unix_ms: echoed_expiry })) =>
1285            reservation_id == echoed_id && activation_digest == echoed_digest
1286                && expires_at_unix_ms == echoed_expiry,
1287        (Request::SpawnSpecWithHarnessMcp { reservation_id, activation_digest, .. },
1288            Ok(Response::Spawned { reservation_id: echoed_id, activation_digest: echoed_digest, receipt })) =>
1289            reservation_id == echoed_id && activation_digest == echoed_digest
1290                && receipt.harness_mcp_proxy.as_ref().is_some_and(|proxy| {
1291                    &proxy.reservation_id == reservation_id
1292                        && &proxy.activation_digest == activation_digest
1293                }),
1294        (Request::ActivateHarnessMcpReservation { reservation_id, activation_digest, record_id, session },
1295            Ok(Response::Activated { reservation_id: echoed_id, activation_digest: echoed_digest, record_id: echoed_record, session: echoed_session })) =>
1296            reservation_id == echoed_id && activation_digest == echoed_digest
1297                && record_id == echoed_record && session == echoed_session,
1298        (Request::AbortHarnessMcpReservation { reservation_id, activation_digest },
1299            Ok(Response::Aborted { reservation_id: echoed_id, activation_digest: echoed_digest })) =>
1300            reservation_id == echoed_id && activation_digest == echoed_digest,
1301        (Request::PutHarnessMcpReplyChunk { reservation_id, activation_digest, record_id, session, call_id, offset, final_chunk, chunk_hex },
1302            Ok(Response::ReplyChunkAccepted { reservation_id: echoed_id, activation_digest: echoed_digest, record_id: echoed_record, session: echoed_session, call_id: echoed_call, next_offset, completed })) =>
1303            reservation_id == echoed_id && activation_digest == echoed_digest
1304                && record_id == echoed_record && session == echoed_session && call_id == echoed_call
1305                && offset.checked_add(u32::try_from(chunk_hex.raw_len()).unwrap_or(u32::MAX))
1306                    == Some(*next_offset) && completed == final_chunk,
1307        (Request::RejectHarnessMcpCall { reservation_id, activation_digest, record_id, session, call_id, .. },
1308            Ok(Response::CallRejected { reservation_id: echoed_id, activation_digest: echoed_digest, record_id: echoed_record, session: echoed_session, call_id: echoed_call })) =>
1309            reservation_id == echoed_id && activation_digest == echoed_digest
1310                && record_id == echoed_record && session == echoed_session && call_id == echoed_call,
1311        (request, Err(_)) if request.required_capability()
1312            == Some(C2_HARNESS_MCP_READ_PROXY_CAPABILITY) => true,
1313        (request, Ok(_)) if request.required_capability()
1314            == Some(C2_HARNESS_MCP_READ_PROXY_CAPABILITY) => false,
1315        (_, Ok(response)) if response.requires_harness_mcp_proxy_capability() => false,
1316        _ => true,
1317    };
1318    if valid { Ok(()) } else { Err("C2 harness MCP response does not match the routed request") }
1319}
1320
1321fn validate_provider_session_index_response(
1322    expected: &NodeRequest,
1323    response: &Result<RoutedNodeResponse, C2RelayFailure>,
1324) -> Result<(), &'static str> {
1325    let Ok(routed) = response else {
1326        return Ok(());
1327    };
1328    match (expected, &routed.response) {
1329        (
1330            NodeRequest::IndexProviderSession {
1331                workspace_id,
1332                provider,
1333                ..
1334            },
1335            Ok(C2NodeResponse::ProviderSessionIndexed { record }),
1336        ) if &record.workspace_id == workspace_id && &record.provider == provider => Ok(()),
1337        (NodeRequest::IndexProviderSession { .. }, Err(_)) => Ok(()),
1338        (NodeRequest::IndexProviderSession { .. }, Ok(_)) => {
1339            Err("C2 provider session index response does not match the routed request")
1340        }
1341        (_, Ok(C2NodeResponse::ProviderSessionIndexed { .. })) => {
1342            Err("C2 returned an unexpected provider session index response")
1343        }
1344        _ => Ok(()),
1345    }
1346}
1347
1348fn validate_session_record_context_export_response(
1349    expected: &NodeRequest,
1350    response: &Result<RoutedNodeResponse, C2RelayFailure>,
1351) -> Result<(), &'static str> {
1352    let Ok(routed) = response else {
1353        return Ok(());
1354    };
1355    match (expected, &routed.response) {
1356        (
1357            NodeRequest::ExportContextPackForSessionRecord { record_id, session },
1358            Ok(C2NodeResponse::ContextPackForSessionRecordExported {
1359                record_id: echoed_record_id,
1360                session: echoed_session,
1361                context,
1362            }),
1363        ) if record_id == echoed_record_id && session == echoed_session && context.is_valid() => {
1364            Ok(())
1365        }
1366        (NodeRequest::ExportContextPackForSessionRecord { .. }, Err(_)) => Ok(()),
1367        (NodeRequest::ExportContextPackForSessionRecord { .. }, Ok(_)) => Err(
1368            "C2 session-record context export response does not match the routed request",
1369        ),
1370        (_, Ok(C2NodeResponse::ContextPackForSessionRecordExported { .. })) => Err(
1371            "C2 returned an unexpected session-record context export response",
1372        ),
1373        _ => Ok(()),
1374    }
1375}
1376
1377fn validate_native_session_response(
1378    expected: &NodeRequest,
1379    response: &Result<RoutedNodeResponse, C2RelayFailure>,
1380) -> Result<(), &'static str> {
1381    let Ok(routed) = response else {
1382        return Ok(());
1383    };
1384    match (expected, &routed.response) {
1385        (
1386            NodeRequest::CatalogNativeSessions { route, .. },
1387            Ok(response @ C2NodeResponse::NativeSessionsCataloged {
1388                route: echoed_route,
1389                ..
1390            }),
1391        ) if echoed_route == route && response.native_session_catalog_contract_is_valid() => {
1392            Ok(())
1393        }
1394        (
1395            NodeRequest::PageNativeSessions {
1396                route,
1397                window,
1398                catalog_revision,
1399                ..
1400            },
1401            Ok(response @ C2NodeResponse::NativeSessionsPaged {
1402                route: echoed_route,
1403                page,
1404            }),
1405        ) if echoed_route == route
1406            && page.window == *window
1407            && page.revision == *catalog_revision
1408            && response.native_session_catalog_contract_is_valid() => Ok(()),
1409        (
1410            NodeRequest::PreviewNativeSession { selection, .. },
1411            Ok(response @ C2NodeResponse::NativeSessionPreviewed {
1412                selection: echoed_selection,
1413                ..
1414            }),
1415        ) if echoed_selection == selection
1416            && response.native_session_preview_contract_is_valid() => Ok(()),
1417        (
1418            NodeRequest::IndexNativeSession { selection, .. },
1419            Ok(response @ C2NodeResponse::NativeSessionIndexed {
1420                selection: echoed_selection,
1421                record,
1422            }),
1423        ) if echoed_selection == selection
1424            && selection.route.scope
1425                == gate4agent_node_protocol::NativeSessionCatalogScope::Workspace
1426            && selection.route.workspace_id.as_ref() == Some(&record.workspace_id)
1427            && selection.route.provider == record.provider
1428            && response.native_session_index_contract_is_valid() => Ok(()),
1429        (
1430            NodeRequest::CatalogNativeSessions { .. }
1431            | NodeRequest::PageNativeSessions { .. }
1432            | NodeRequest::PreviewNativeSession { .. }
1433            | NodeRequest::IndexNativeSession { .. },
1434            Err(_),
1435        ) => Ok(()),
1436        (
1437            NodeRequest::CatalogNativeSessions { .. }
1438            | NodeRequest::PageNativeSessions { .. }
1439            | NodeRequest::PreviewNativeSession { .. }
1440            | NodeRequest::IndexNativeSession { .. },
1441            Ok(_),
1442        ) => Err("C2 native session response does not match the routed request"),
1443        (
1444            _,
1445            Ok(
1446                C2NodeResponse::NativeSessionsCataloged { .. }
1447                | C2NodeResponse::NativeSessionsPaged { .. }
1448                | C2NodeResponse::NativeSessionPreviewed { .. }
1449                | C2NodeResponse::NativeSessionIndexed { .. },
1450            ),
1451        ) => Err("C2 returned an unexpected native session response"),
1452        _ => Ok(()),
1453    }
1454}
1455
1456fn validate_workspace_content_response(
1457    expected: &NodeRequest,
1458    response: &Result<RoutedNodeResponse, C2RelayFailure>,
1459) -> Result<(), &'static str> {
1460    let Ok(routed) = response else {
1461        return Ok(());
1462    };
1463    match (expected, &routed.response) {
1464        (NodeRequest::ReadWorkspaceFile { workspace_id, path }, Ok(C2NodeResponse::WorkspaceFileRead { file }))
1465            if &file.workspace_id == workspace_id && &file.path == path => Ok(()),
1466        (NodeRequest::WriteWorkspaceFile { workspace_id, path, text, .. }, Ok(C2NodeResponse::WorkspaceFileWritten { file }))
1467            if &file.workspace_id == workspace_id
1468                && &file.path == path
1469                && matches!(
1470                    &file.content,
1471                    gate4agent_node_protocol::WorkspaceFileContent::Utf8 {
1472                        text: written,
1473                        byte_len,
1474                    } if written == text
1475                        && u32::try_from(text.len()).ok() == Some(*byte_len)
1476                ) => Ok(()),
1477        (NodeRequest::CreateWorkspaceFile { workspace_id, path }, Ok(C2NodeResponse::WorkspaceFileCreated { file }))
1478            if &file.workspace_id == workspace_id
1479                && &file.path == path
1480                && file.revision.is_some()
1481                && matches!(
1482                    &file.content,
1483                    gate4agent_node_protocol::WorkspaceFileContent::Utf8 {
1484                        text,
1485                        byte_len: 0,
1486                    } if text.is_empty()
1487                ) => Ok(()),
1488        (
1489            NodeRequest::CreateWorkspaceDirectory { workspace_id, path },
1490            Ok(C2NodeResponse::WorkspaceDirectoryCreated {
1491                workspace_id: actual_workspace_id,
1492                entry,
1493            }),
1494        ) if actual_workspace_id == workspace_id
1495            && &entry.relative_path == path
1496            && entry.kind == gate4agent_node_protocol::WorkspaceEntryKind::Directory => Ok(()),
1497        (NodeRequest::ReadGitHistory { workspace_id, .. }, Ok(C2NodeResponse::GitHistoryRead { workspace_id: actual, .. }))
1498            if actual == workspace_id => Ok(()),
1499        (NodeRequest::ReadGitDiff { workspace_id, request }, Ok(C2NodeResponse::GitDiffRead { workspace_id: actual, diff }))
1500            if actual == workspace_id && diff.mode == request.mode && diff.path == request.path => Ok(()),
1501        (NodeRequest::ReadWorkspaceFile { .. }
1502            | NodeRequest::WriteWorkspaceFile { .. }
1503            | NodeRequest::CreateWorkspaceFile { .. }
1504            | NodeRequest::CreateWorkspaceDirectory { .. }
1505            | NodeRequest::ReadGitHistory { .. }
1506            | NodeRequest::ReadGitDiff { .. }, Err(_)) => Ok(()),
1507        (NodeRequest::ReadWorkspaceFile { .. }
1508            | NodeRequest::WriteWorkspaceFile { .. }
1509            | NodeRequest::CreateWorkspaceFile { .. }
1510            | NodeRequest::CreateWorkspaceDirectory { .. }
1511            | NodeRequest::ReadGitHistory { .. }
1512            | NodeRequest::ReadGitDiff { .. }, Ok(_)) => {
1513                Err("C2 workspace content response does not match the routed request")
1514            }
1515        (_, Ok(C2NodeResponse::WorkspaceFileRead { .. }
1516            | C2NodeResponse::WorkspaceFileWritten { .. }
1517            | C2NodeResponse::WorkspaceFileCreated { .. }
1518            | C2NodeResponse::WorkspaceDirectoryCreated { .. }
1519            | C2NodeResponse::GitHistoryRead { .. }
1520            | C2NodeResponse::GitDiffRead { .. })) => {
1521                Err("C2 returned an unexpected workspace content response")
1522            }
1523        _ => Ok(()),
1524    }
1525}
1526
1527fn validate_session_task_response(
1528    expected: &NodeRequest,
1529    response: &Result<RoutedNodeResponse, C2RelayFailure>,
1530) -> Result<(), &'static str> {
1531    let Ok(routed) = response else { return Ok(()); };
1532    match (expected, &routed.response) {
1533        (NodeRequest::SetSessionTask { .. }, Err(_)) => Ok(()),
1534        (
1535            NodeRequest::SetSessionTask {
1536                record_id,
1537                expected_revision,
1538                target,
1539            },
1540            Ok(C2NodeResponse::SessionRecordUpdated { record }),
1541        ) if c2_session_task_record_matches(record, record_id, *expected_revision, target) => Ok(()),
1542        (NodeRequest::SetSessionTask { .. }, Ok(_)) => {
1543            Err("C2 session task response does not match the routed request")
1544        }
1545        _ => Ok(()),
1546    }
1547}
1548
1549fn validate_managed_worktree_spawn_v2_response(
1550    expected: &NodeRequest,
1551    response: &Result<RoutedNodeResponse, C2RelayFailure>,
1552) -> Result<(), &'static str> {
1553    let Ok(routed) = response else {
1554        return Ok(());
1555    };
1556    match (expected, &routed.response) {
1557        (
1558            NodeRequest::SpawnManagedWorktreeV2 { request },
1559            Ok(C2NodeResponse::ManagedWorktreeSpawnAccepted { receipt }),
1560        ) if receipt.spawn.incarnation_id == routed.incarnation_id
1561            && receipt.spawn.target.node_id == request.spawn_spec.target.node_id
1562            && receipt.spawn.target.workspace_id == request.spawn_spec.target.workspace_id
1563            && receipt.spawn.target.worktree_id.as_ref() == Some(&receipt.lease.workspace_id)
1564            && receipt.spawn.session.workspace_id == receipt.lease.workspace_id
1565            && receipt.spawn.profile_id == request.spawn_spec.profile_id
1566            && receipt.spawn.profile_revision == request.spawn_spec.expected_profile_revision
1567            && receipt.spawn.idempotency_key == request.spawn_spec.idempotency_key
1568            && receipt.lease.source_workspace_id == request.spawn_spec.target.workspace_id
1569            && receipt.lease.profile_id == request.worktree_profile_id
1570            && receipt.lease.profile_revision == request.expected_profile_revision => Ok(()),
1571        (NodeRequest::SpawnManagedWorktreeV2 { .. }, Err(_)) => Ok(()),
1572        (NodeRequest::SpawnManagedWorktreeV2 { .. }, Ok(_)) => {
1573            Err("C2 managed worktree V2 response does not match the routed request")
1574        }
1575        _ => Ok(()),
1576    }
1577}
1578
1579fn c2_session_task_record_matches(
1580    record: &hatchery_core::C2ManagedSessionRecord,
1581    record_id: &gate4agent_node_protocol::SessionRecordId,
1582    expected_revision: u64,
1583    target: &gate4agent_node_protocol::SessionTaskTargetV1,
1584) -> bool {
1585    if &record.record_id != record_id { return false; }
1586    let next_revision = expected_revision.checked_add(1);
1587    match target {
1588        gate4agent_node_protocol::SessionTaskTargetV1::New => record.task_binding.as_ref()
1589            .is_some_and(|binding| Some(binding.revision) == next_revision && binding.task_id.is_some()),
1590        gate4agent_node_protocol::SessionTaskTargetV1::Existing { task_id } => record.task_binding.as_ref()
1591            .is_some_and(|binding| (binding.revision == expected_revision || Some(binding.revision) == next_revision)
1592                && binding.task_id.as_ref() == Some(task_id)),
1593        gate4agent_node_protocol::SessionTaskTargetV1::Clear => match &record.task_binding {
1594            None => expected_revision == 0,
1595            Some(binding) => binding.task_id.is_none()
1596                && (binding.revision == expected_revision || Some(binding.revision) == next_revision),
1597        },
1598    }
1599}
1600
1601fn c2_node_response_has_terminal_frame_event(response: &C2NodeResponse) -> bool {
1602    match response {
1603        C2NodeResponse::Resync { events, .. } => events
1604            .iter()
1605            .any(|event| c2_node_event_is_terminal_frame(&event.event)),
1606        C2NodeResponse::Snapshot { .. }
1607        | C2NodeResponse::Armed { .. }
1608        | C2NodeResponse::Spawned { .. }
1609        | C2NodeResponse::Activated { .. }
1610        | C2NodeResponse::Aborted { .. }
1611        | C2NodeResponse::ReplyChunkAccepted { .. }
1612        | C2NodeResponse::CallRejected { .. }
1613        | C2NodeResponse::DeliveryStageBegun { .. }
1614        | C2NodeResponse::DeliveryBlobChunkAccepted { .. }
1615        | C2NodeResponse::DeliveryCommitted { .. }
1616        | C2NodeResponse::DeliveryStageAborted { .. }
1617        | C2NodeResponse::WorkspaceInspected { .. }
1618        | C2NodeResponse::HostDirectoriesBrowsed { .. }
1619        | C2NodeResponse::WorkspaceFileRead { .. }
1620        | C2NodeResponse::WorkspaceFileWritten { .. }
1621        | C2NodeResponse::WorkspaceFileCreated { .. }
1622        | C2NodeResponse::WorkspaceDirectoryCreated { .. }
1623        | C2NodeResponse::GitHistoryRead { .. }
1624        | C2NodeResponse::GitDiffRead { .. }
1625        | C2NodeResponse::Controller { .. }
1626        | C2NodeResponse::SpawnAccepted { .. }
1627        | C2NodeResponse::SpawnSpecAccepted { .. }
1628        | C2NodeResponse::ManagedWorktreeSpawnAccepted { .. }
1629        | C2NodeResponse::ManagedWorktreeCleanup { .. }
1630        | C2NodeResponse::SessionRecordUpdated { .. }
1631        | C2NodeResponse::ProviderSessionIndexed { .. }
1632        | C2NodeResponse::NativeSessionIndexed { .. }
1633        | C2NodeResponse::SessionRecordResumed { .. }
1634        | C2NodeResponse::SessionRecordForgotten { .. }
1635        | C2NodeResponse::NativeSessionsCataloged { .. }
1636        | C2NodeResponse::NativeSessionsPaged { .. }
1637        | C2NodeResponse::NativeSessionPreviewed { .. }
1638        | C2NodeResponse::SessionRecordPreviewed { .. }
1639        | C2NodeResponse::HistoryDiscovered { .. }
1640        | C2NodeResponse::HistoryLoaded { .. }
1641        | C2NodeResponse::ContextPackForSessionRecordExported { .. }
1642        | C2NodeResponse::ContextPackExported { .. }
1643        | C2NodeResponse::ContextPackForgotten { .. }
1644        | C2NodeResponse::DurableContextPackResolved { .. }
1645        | C2NodeResponse::ContextPackBytesRead { .. }
1646        | C2NodeResponse::WorkspaceRegistered { .. }
1647        | C2NodeResponse::StandaloneWorkspaceCreated { .. }
1648        | C2NodeResponse::WorkspaceUnregistered { .. }
1649        | C2NodeResponse::WorktreeCreated { .. }
1650        | C2NodeResponse::WorktreeRemoved { .. }
1651        | C2NodeResponse::Accepted
1652        | C2NodeResponse::ShuttingDown => false,
1653    }
1654}
1655
1656fn c2_node_event_is_terminal_frame(event: &C2NodeEvent) -> bool {
1657    match event {
1658        C2NodeEvent::TerminalFrame { .. } => true,
1659        C2NodeEvent::HarnessMcpReadCall { .. }
1660        | C2NodeEvent::Control { .. }
1661        | C2NodeEvent::SessionRecordHistorySummarized { .. }
1662        | C2NodeEvent::ControllerChanged { .. }
1663        | C2NodeEvent::WorkspaceAdded { .. }
1664        | C2NodeEvent::WorkspaceRemoved { .. }
1665        | C2NodeEvent::SessionRecordUpserted { .. }
1666        | C2NodeEvent::SessionRecordRemoved { .. }
1667        | C2NodeEvent::ManagedWorktreeUpserted { .. }
1668        | C2NodeEvent::ManagedWorktreeRemoved { .. }
1669        | C2NodeEvent::AgentStream { .. }
1670        | C2NodeEvent::ResyncRequired { .. } => false,
1671    }
1672}
1673
1674fn c2_node_response_has_agent_stream_event(response: &C2NodeResponse) -> bool {
1675    match response {
1676        C2NodeResponse::Resync { events, .. } => events
1677            .iter()
1678            .any(|event| c2_node_event_is_agent_stream(&event.event)),
1679        C2NodeResponse::Snapshot { .. }
1680        | C2NodeResponse::Armed { .. }
1681        | C2NodeResponse::Spawned { .. }
1682        | C2NodeResponse::Activated { .. }
1683        | C2NodeResponse::Aborted { .. }
1684        | C2NodeResponse::ReplyChunkAccepted { .. }
1685        | C2NodeResponse::CallRejected { .. }
1686        | C2NodeResponse::DeliveryStageBegun { .. }
1687        | C2NodeResponse::DeliveryBlobChunkAccepted { .. }
1688        | C2NodeResponse::DeliveryCommitted { .. }
1689        | C2NodeResponse::DeliveryStageAborted { .. }
1690        | C2NodeResponse::WorkspaceInspected { .. }
1691        | C2NodeResponse::HostDirectoriesBrowsed { .. }
1692        | C2NodeResponse::WorkspaceFileRead { .. }
1693        | C2NodeResponse::WorkspaceFileWritten { .. }
1694        | C2NodeResponse::WorkspaceFileCreated { .. }
1695        | C2NodeResponse::WorkspaceDirectoryCreated { .. }
1696        | C2NodeResponse::GitHistoryRead { .. }
1697        | C2NodeResponse::GitDiffRead { .. }
1698        | C2NodeResponse::Controller { .. }
1699        | C2NodeResponse::SpawnAccepted { .. }
1700        | C2NodeResponse::SpawnSpecAccepted { .. }
1701        | C2NodeResponse::ManagedWorktreeSpawnAccepted { .. }
1702        | C2NodeResponse::ManagedWorktreeCleanup { .. }
1703        | C2NodeResponse::SessionRecordUpdated { .. }
1704        | C2NodeResponse::ProviderSessionIndexed { .. }
1705        | C2NodeResponse::NativeSessionIndexed { .. }
1706        | C2NodeResponse::SessionRecordResumed { .. }
1707        | C2NodeResponse::SessionRecordForgotten { .. }
1708        | C2NodeResponse::NativeSessionsCataloged { .. }
1709        | C2NodeResponse::NativeSessionsPaged { .. }
1710        | C2NodeResponse::NativeSessionPreviewed { .. }
1711        | C2NodeResponse::SessionRecordPreviewed { .. }
1712        | C2NodeResponse::HistoryDiscovered { .. }
1713        | C2NodeResponse::HistoryLoaded { .. }
1714        | C2NodeResponse::ContextPackForSessionRecordExported { .. }
1715        | C2NodeResponse::ContextPackExported { .. }
1716        | C2NodeResponse::ContextPackForgotten { .. }
1717        | C2NodeResponse::DurableContextPackResolved { .. }
1718        | C2NodeResponse::ContextPackBytesRead { .. }
1719        | C2NodeResponse::WorkspaceRegistered { .. }
1720        | C2NodeResponse::StandaloneWorkspaceCreated { .. }
1721        | C2NodeResponse::WorkspaceUnregistered { .. }
1722        | C2NodeResponse::WorktreeCreated { .. }
1723        | C2NodeResponse::WorktreeRemoved { .. }
1724        | C2NodeResponse::Accepted
1725        | C2NodeResponse::ShuttingDown => false,
1726    }
1727}
1728
1729fn c2_node_event_is_agent_stream(event: &C2NodeEvent) -> bool {
1730    match event {
1731        C2NodeEvent::AgentStream { .. } => true,
1732        C2NodeEvent::HarnessMcpReadCall { .. }
1733        | C2NodeEvent::Control { .. }
1734        | C2NodeEvent::SessionRecordHistorySummarized { .. }
1735        | C2NodeEvent::ControllerChanged { .. }
1736        | C2NodeEvent::WorkspaceAdded { .. }
1737        | C2NodeEvent::WorkspaceRemoved { .. }
1738        | C2NodeEvent::SessionRecordUpserted { .. }
1739        | C2NodeEvent::SessionRecordRemoved { .. }
1740        | C2NodeEvent::ManagedWorktreeUpserted { .. }
1741        | C2NodeEvent::ManagedWorktreeRemoved { .. }
1742        | C2NodeEvent::TerminalFrame { .. }
1743        | C2NodeEvent::ResyncRequired { .. } => false,
1744    }
1745}
1746
1747/// Coarse label for a dropped/tearing-down `C2NodeEvent` -- used only by
1748/// `control_owner`'s event-delivery warns, mirroring `hatchery-harness-
1749/// service`'s own `agent_stream_event_kind_label` (same "name every drop"
1750/// reasoning): a log line that fires once per backpressure episode still
1751/// needs to say WHAT kind of event was in flight when it fired.
1752fn c2_node_event_kind_label(event: &C2NodeEvent) -> &'static str {
1753    match event {
1754        C2NodeEvent::HarnessMcpReadCall { .. } => "harness-mcp-read-call",
1755        C2NodeEvent::Control { .. } => "control",
1756        C2NodeEvent::SessionRecordHistorySummarized { .. } => "session-record-history-summarized",
1757        C2NodeEvent::TerminalFrame { .. } => "terminal-frame",
1758        C2NodeEvent::AgentStream { .. } => "agent-stream",
1759        C2NodeEvent::ControllerChanged { .. } => "controller-changed",
1760        C2NodeEvent::WorkspaceAdded { .. } => "workspace-added",
1761        C2NodeEvent::WorkspaceRemoved { .. } => "workspace-removed",
1762        C2NodeEvent::SessionRecordUpserted { .. } => "session-record-upserted",
1763        C2NodeEvent::SessionRecordRemoved { .. } => "session-record-removed",
1764        C2NodeEvent::ManagedWorktreeUpserted { .. } => "managed-worktree-upserted",
1765        C2NodeEvent::ManagedWorktreeRemoved { .. } => "managed-worktree-removed",
1766        C2NodeEvent::ResyncRequired { .. } => "resync-required",
1767    }
1768}
1769
1770/// Whether THIS full-channel drop should be logged: `true` exactly once per
1771/// contiguous backlog episode (the first drop since `logged` was last
1772/// reset), flipping `*logged` so every later drop in the same episode
1773/// returns `false` -- see `reset_channel_full_drop_episode` for the
1774/// counterpart that ends an episode. Split out as a pure function, used by
1775/// both `control_owner`'s `harness_mcp` and terminal-frame delivery arms, so
1776/// the "once per episode, not per event" contract is directly testable
1777/// without needing a tracing subscriber to observe log call counts.
1778fn should_log_channel_full_drop(logged: &mut bool) -> bool {
1779    if *logged {
1780        return false;
1781    }
1782    *logged = true;
1783    true
1784}
1785
1786/// Ends a full-channel drop episode: a send that succeeds again means the
1787/// backlog cleared, so the next drop (if any) is a fresh episode and earns
1788/// its own log line.
1789fn reset_channel_full_drop_episode(logged: &mut bool) {
1790    *logged = false;
1791}
1792
1793fn c2_node_response_has_unix_bytes(response: &C2NodeResponse) -> bool {
1794    match response {
1795        C2NodeResponse::Snapshot { snapshot, .. } => node_snapshot_has_unix_bytes(snapshot),
1796        C2NodeResponse::Resync { snapshot, events, .. } => {
1797            node_snapshot_has_unix_bytes(snapshot)
1798                || events
1799                    .iter()
1800                    .any(|envelope| c2_node_event_has_unix_bytes(&envelope.event))
1801        }
1802        C2NodeResponse::WorkspaceInspected { inspection } => inspection
1803            .git
1804            .worktrees
1805            .iter()
1806            .any(|worktree| worktree.path.as_unix_bytes().is_some()),
1807        C2NodeResponse::HostDirectoriesBrowsed { listing } => {
1808            listing.directory.as_ref().is_some_and(|path| path.as_unix_bytes().is_some())
1809                || listing.parent.as_ref().is_some_and(|path| path.as_unix_bytes().is_some())
1810                || listing.entries.iter().any(|entry| entry.path.as_unix_bytes().is_some())
1811                || listing.next_after.as_ref().is_some_and(|path| path.as_unix_bytes().is_some())
1812        }
1813        C2NodeResponse::WorkspaceFileRead { .. }
1814        | C2NodeResponse::WorkspaceFileWritten { .. }
1815        | C2NodeResponse::WorkspaceFileCreated { .. }
1816        | C2NodeResponse::WorkspaceDirectoryCreated { .. }
1817        | C2NodeResponse::GitHistoryRead { .. }
1818        | C2NodeResponse::GitDiffRead { .. }
1819        | C2NodeResponse::DeliveryStageBegun { .. }
1820        | C2NodeResponse::DeliveryBlobChunkAccepted { .. }
1821        | C2NodeResponse::DeliveryCommitted { .. }
1822        | C2NodeResponse::DeliveryStageAborted { .. } => false,
1823        C2NodeResponse::WorkspaceRegistered { workspace }
1824        | C2NodeResponse::StandaloneWorkspaceCreated { workspace } => {
1825            workspace.canonical_root.as_unix_bytes().is_some()
1826        }
1827        C2NodeResponse::WorktreeCreated { worktree, workspace } => {
1828            worktree.path.as_unix_bytes().is_some()
1829                || workspace.canonical_root.as_unix_bytes().is_some()
1830        }
1831        C2NodeResponse::WorktreeRemoved { target_root, .. } => {
1832            target_root.as_unix_bytes().is_some()
1833        }
1834        C2NodeResponse::Armed { .. }
1835        | C2NodeResponse::Spawned { .. }
1836        | C2NodeResponse::Activated { .. }
1837        | C2NodeResponse::Aborted { .. }
1838        | C2NodeResponse::ReplyChunkAccepted { .. }
1839        | C2NodeResponse::CallRejected { .. }
1840        | C2NodeResponse::Controller { .. }
1841        | C2NodeResponse::SpawnAccepted { .. }
1842        | C2NodeResponse::SpawnSpecAccepted { .. }
1843        | C2NodeResponse::ManagedWorktreeSpawnAccepted { .. }
1844        | C2NodeResponse::ManagedWorktreeCleanup { .. }
1845        | C2NodeResponse::SessionRecordUpdated { .. }
1846        | C2NodeResponse::ProviderSessionIndexed { .. }
1847        | C2NodeResponse::NativeSessionIndexed { .. }
1848        | C2NodeResponse::SessionRecordResumed { .. }
1849        | C2NodeResponse::SessionRecordForgotten { .. }
1850        | C2NodeResponse::NativeSessionsCataloged { .. }
1851        | C2NodeResponse::NativeSessionsPaged { .. }
1852        | C2NodeResponse::NativeSessionPreviewed { .. }
1853        | C2NodeResponse::SessionRecordPreviewed { .. }
1854        | C2NodeResponse::HistoryDiscovered { .. }
1855        | C2NodeResponse::HistoryLoaded { .. }
1856        | C2NodeResponse::ContextPackForSessionRecordExported { .. }
1857        | C2NodeResponse::ContextPackExported { .. }
1858        | C2NodeResponse::ContextPackForgotten { .. }
1859        | C2NodeResponse::DurableContextPackResolved { .. }
1860        | C2NodeResponse::ContextPackBytesRead { .. }
1861        | C2NodeResponse::WorkspaceUnregistered { .. }
1862        | C2NodeResponse::Accepted
1863        | C2NodeResponse::ShuttingDown => false,
1864    }
1865}
1866
1867fn routed_response_has_unix_repository_path(response: &RoutedNodeResponse) -> bool {
1868    response
1869        .response
1870        .as_ref()
1871        .is_ok_and(c2_node_response_has_unix_repository_path)
1872}
1873
1874fn c2_node_response_has_unix_repository_path(response: &C2NodeResponse) -> bool {
1875    match response {
1876        C2NodeResponse::WorkspaceInspected { inspection } => {
1877            inspection.entries.iter().any(|entry| {
1878                entry.relative_path.as_unix_bytes().is_some()
1879            }) || inspection.git.status.iter().any(|entry| {
1880                entry.path.as_unix_bytes().is_some()
1881                    || entry.previous_path.as_ref().is_some_and(|path| {
1882                        path.as_unix_bytes().is_some()
1883                    })
1884            })
1885        }
1886        C2NodeResponse::WorkspaceFileRead { file }
1887        | C2NodeResponse::WorkspaceFileWritten { file }
1888        | C2NodeResponse::WorkspaceFileCreated { file } => file.path.as_unix_bytes().is_some(),
1889        C2NodeResponse::WorkspaceDirectoryCreated { entry, .. } => {
1890            entry.relative_path.as_unix_bytes().is_some()
1891        }
1892        C2NodeResponse::GitDiffRead { diff, .. } => diff
1893            .path
1894            .as_ref()
1895            .is_some_and(|path| path.as_unix_bytes().is_some()),
1896        C2NodeResponse::DurableContextPackResolved { .. }
1897        | C2NodeResponse::ContextPackBytesRead { .. } => false,
1898        C2NodeResponse::Snapshot { .. }
1899        | C2NodeResponse::Resync { .. }
1900        | C2NodeResponse::Armed { .. }
1901        | C2NodeResponse::Spawned { .. }
1902        | C2NodeResponse::Activated { .. }
1903        | C2NodeResponse::Aborted { .. }
1904        | C2NodeResponse::ReplyChunkAccepted { .. }
1905        | C2NodeResponse::CallRejected { .. }
1906        | C2NodeResponse::DeliveryStageBegun { .. }
1907        | C2NodeResponse::DeliveryBlobChunkAccepted { .. }
1908        | C2NodeResponse::DeliveryCommitted { .. }
1909        | C2NodeResponse::DeliveryStageAborted { .. }
1910        | C2NodeResponse::HostDirectoriesBrowsed { .. }
1911        | C2NodeResponse::GitHistoryRead { .. }
1912        | C2NodeResponse::Controller { .. }
1913        | C2NodeResponse::SpawnAccepted { .. }
1914        | C2NodeResponse::SpawnSpecAccepted { .. }
1915        | C2NodeResponse::ManagedWorktreeSpawnAccepted { .. }
1916        | C2NodeResponse::ManagedWorktreeCleanup { .. }
1917        | C2NodeResponse::SessionRecordUpdated { .. }
1918        | C2NodeResponse::ProviderSessionIndexed { .. }
1919        | C2NodeResponse::NativeSessionIndexed { .. }
1920        | C2NodeResponse::SessionRecordResumed { .. }
1921        | C2NodeResponse::SessionRecordForgotten { .. }
1922        | C2NodeResponse::NativeSessionsCataloged { .. }
1923        | C2NodeResponse::NativeSessionsPaged { .. }
1924        | C2NodeResponse::NativeSessionPreviewed { .. }
1925        | C2NodeResponse::SessionRecordPreviewed { .. }
1926        | C2NodeResponse::HistoryDiscovered { .. }
1927        | C2NodeResponse::HistoryLoaded { .. }
1928        | C2NodeResponse::ContextPackForSessionRecordExported { .. }
1929        | C2NodeResponse::ContextPackExported { .. }
1930        | C2NodeResponse::ContextPackForgotten { .. }
1931        | C2NodeResponse::WorkspaceRegistered { .. }
1932        | C2NodeResponse::StandaloneWorkspaceCreated { .. }
1933        | C2NodeResponse::WorkspaceUnregistered { .. }
1934        | C2NodeResponse::WorktreeCreated { .. }
1935        | C2NodeResponse::WorktreeRemoved { .. }
1936        | C2NodeResponse::Accepted
1937        | C2NodeResponse::ShuttingDown => false,
1938    }
1939}
1940
1941fn routed_response_requires_workspace_file_read(response: &RoutedNodeResponse) -> bool {
1942    response.response.as_ref().is_ok_and(|response| {
1943        matches!(response, C2NodeResponse::WorkspaceFileRead { .. })
1944    })
1945}
1946
1947fn routed_response_requires_workspace_file_write(response: &RoutedNodeResponse) -> bool {
1948    response.response.as_ref().is_ok_and(|response| {
1949        matches!(response, C2NodeResponse::WorkspaceFileWritten { .. })
1950    })
1951}
1952
1953fn routed_response_requires_workspace_entry_create(response: &RoutedNodeResponse) -> bool {
1954    response.response.as_ref().is_ok_and(|response| {
1955        response.requires_workspace_entry_create_capability()
1956    })
1957}
1958
1959fn routed_response_requires_git_read(response: &RoutedNodeResponse) -> bool {
1960    response.response.as_ref().is_ok_and(|response| {
1961        matches!(response, C2NodeResponse::GitHistoryRead { .. } | C2NodeResponse::GitDiffRead { .. })
1962    })
1963}
1964
1965fn routed_response_requires_host_directory_browse(response: &RoutedNodeResponse) -> bool {
1966    match &response.response {
1967        Ok(response) => response.requires_host_directory_browse_capability(),
1968        Err(failure) => failure.requires_host_directory_browse_capability(),
1969    }
1970}
1971
1972fn routed_response_requires_standalone_workspace_lifecycle(
1973    response: &RoutedNodeResponse,
1974) -> bool {
1975    matches!(
1976        &response.response,
1977        Ok(C2NodeResponse::StandaloneWorkspaceCreated { .. })
1978    )
1979}
1980
1981fn routed_response_requires_provider_session_reference_index(
1982    response: &RoutedNodeResponse,
1983) -> bool {
1984    matches!(
1985        &response.response,
1986        Ok(C2NodeResponse::ProviderSessionIndexed { .. })
1987    )
1988}
1989
1990fn routed_response_requires_spawn_spec(response: &RoutedNodeResponse) -> bool {
1991    response.response.as_ref().is_ok_and(|response| {
1992        matches!(response,
1993            C2NodeResponse::SpawnSpecAccepted { .. }
1994            | C2NodeResponse::ManagedWorktreeSpawnAccepted { .. }
1995        )
1996    })
1997}
1998
1999fn routed_response_requires_spawn_profile_revision(response: &RoutedNodeResponse) -> bool {
2000    response.response.as_ref().is_ok_and(|response| {
2001        matches!(response,
2002            C2NodeResponse::SpawnSpecAccepted { .. }
2003            | C2NodeResponse::Spawned { .. }
2004            | C2NodeResponse::ManagedWorktreeSpawnAccepted { .. }
2005        )
2006    })
2007}
2008
2009fn routed_response_requires_worktree_selection(response: &RoutedNodeResponse) -> bool {
2010    response.response.as_ref().is_ok_and(|response| {
2011        matches!(response, C2NodeResponse::SpawnSpecAccepted { receipt }
2012            if receipt.target.worktree_id.is_some())
2013            || c2_node_response_has_managed_worktree(response)
2014    })
2015}
2016
2017fn routed_response_requires_managed_worktree(response: &RoutedNodeResponse) -> bool {
2018    response
2019        .response
2020        .as_ref()
2021        .is_ok_and(c2_node_response_has_managed_worktree)
2022}
2023
2024fn c2_node_response_has_managed_worktree(response: &C2NodeResponse) -> bool {
2025    match response {
2026        C2NodeResponse::Snapshot { snapshot, .. } => c2_snapshot_has_managed_worktree(snapshot),
2027        C2NodeResponse::Resync { snapshot, events, .. } => {
2028            c2_snapshot_has_managed_worktree(snapshot)
2029                || events
2030                    .iter()
2031                    .any(|event| c2_node_event_is_managed_worktree(&event.event))
2032        }
2033        C2NodeResponse::WorkspaceRegistered { workspace }
2034        | C2NodeResponse::StandaloneWorkspaceCreated { workspace }
2035        | C2NodeResponse::WorktreeCreated { workspace, .. } => {
2036            c2_workspace_has_managed_worktree_metadata(workspace)
2037        }
2038        C2NodeResponse::ManagedWorktreeSpawnAccepted { .. }
2039        | C2NodeResponse::ManagedWorktreeCleanup { .. } => true,
2040        _ => false,
2041    }
2042}
2043
2044fn c2_node_event_is_managed_worktree(event: &C2NodeEvent) -> bool {
2045    match event {
2046        C2NodeEvent::WorkspaceAdded { workspace } => {
2047            c2_workspace_has_managed_worktree_metadata(workspace)
2048        }
2049        C2NodeEvent::ManagedWorktreeUpserted { .. }
2050        | C2NodeEvent::ManagedWorktreeRemoved { .. } => true,
2051        _ => false,
2052    }
2053}
2054
2055fn c2_workspace_has_managed_worktree_metadata(
2056    workspace: &hatchery_core::C2WorkspaceSnapshot,
2057) -> bool {
2058    workspace.worktree_service_mode.is_some()
2059        || workspace.managed_worktree_profiles.is_some()
2060}
2061
2062fn c2_snapshot_has_managed_worktree(
2063    snapshot: &hatchery_core::C2NodeSnapshot,
2064) -> bool {
2065    !snapshot.managed_worktrees.is_empty()
2066        || snapshot
2067            .workspaces
2068            .iter()
2069            .any(c2_workspace_has_managed_worktree_metadata)
2070}
2071
2072fn node_snapshot_has_unix_bytes(snapshot: &hatchery_core::C2NodeSnapshot) -> bool {
2073    snapshot
2074        .workspaces
2075        .iter()
2076        .any(|workspace| workspace.canonical_root.as_unix_bytes().is_some())
2077}
2078
2079fn routed_event_has_unix_bytes(event: &RoutedNodeEvent) -> bool {
2080    c2_node_event_has_unix_bytes(&event.event)
2081}
2082
2083fn c2_node_event_has_unix_bytes(event: &C2NodeEvent) -> bool {
2084    match event {
2085        C2NodeEvent::WorkspaceAdded { workspace } => {
2086            workspace.canonical_root.as_unix_bytes().is_some()
2087        }
2088        C2NodeEvent::HarnessMcpReadCall { .. }
2089        | C2NodeEvent::Control { .. }
2090        | C2NodeEvent::SessionRecordHistorySummarized { .. }
2091        | C2NodeEvent::TerminalFrame { .. }
2092        | C2NodeEvent::ControllerChanged { .. }
2093        | C2NodeEvent::WorkspaceRemoved { .. }
2094        | C2NodeEvent::SessionRecordUpserted { .. }
2095        | C2NodeEvent::SessionRecordRemoved { .. }
2096        | C2NodeEvent::ManagedWorktreeUpserted { .. }
2097        | C2NodeEvent::ManagedWorktreeRemoved { .. }
2098        | C2NodeEvent::AgentStream { .. }
2099        | C2NodeEvent::ResyncRequired { .. } => false,
2100    }
2101}
2102
2103fn provider_text_is_legacy(value: &str) -> bool {
2104    hatchery_core::AgentId::new(value)
2105        .is_ok_and(|provider| provider_id_is_legacy(&provider))
2106}
2107
2108fn status_has_open_provider_id(status: &hatchery_core::StatusResponse) -> bool {
2109    status.nodes.values().filter_map(|node| node.inventory.as_ref()).any(|inventory| {
2110        inventory.enabled_providers.iter().any(|provider| !provider_id_is_legacy(provider))
2111            || inventory.provider_runtime_statuses.iter()
2112                .any(|runtime| !provider_id_is_legacy(runtime.provider()))
2113            || inventory.provider_contracts.iter()
2114                .any(|contract| !provider_id_is_legacy(&contract.provider))
2115            || inventory.provider_adapter_contracts.iter()
2116                .any(|contract| !provider_id_is_legacy(&contract.provider))
2117            || inventory.workspaces.values().any(|workspace| {
2118                workspace.sessions.iter()
2119                    .any(|session| !provider_text_is_legacy(&session.agent_id))
2120            })
2121            || inventory.managed_sessions.iter()
2122                .any(|record| !provider_id_is_legacy(&record.provider))
2123    })
2124}
2125
2126fn status_has_managed_worktree(status: &hatchery_core::StatusResponse) -> bool {
2127    status.nodes.values().any(|node| {
2128        node.inventory
2129            .as_ref()
2130            .is_some_and(|inventory| {
2131                !inventory.managed_worktrees.is_empty()
2132                    || inventory.workspaces.values().any(|workspace| {
2133                        workspace.worktree_service_mode.is_some()
2134                            || workspace.managed_worktree_profiles.is_some()
2135                    })
2136            })
2137    })
2138}
2139
2140fn status_has_child_environment_profile(
2141    status: &hatchery_core::StatusResponse,
2142) -> bool {
2143    status.nodes.values().any(|node| {
2144        node.inventory.as_ref().is_some_and(|inventory| {
2145            inventory.managed_sessions.iter().any(|record| {
2146                record.environment_profile.is_some()
2147            })
2148        })
2149    })
2150}
2151
2152fn status_has_session_bundle_materialization(
2153    status: &hatchery_core::StatusResponse,
2154) -> bool {
2155    status.nodes.values().any(|node| {
2156        node.inventory.as_ref().is_some_and(|inventory| {
2157            inventory.managed_sessions.iter().any(|record| record.bundle.is_some())
2158        })
2159    })
2160}
2161
2162fn topology_has_open_provider_id(topology: &C2Topology) -> bool {
2163    topology.nodes.iter().any(|node| {
2164        node.provider_contracts.iter()
2165            .any(|contract| !provider_id_is_legacy(&contract.provider))
2166            || node.provider_adapter_contracts.iter()
2167                .any(|contract| !provider_id_is_legacy(&contract.provider))
2168            || node.provider_runtime_statuses.iter()
2169                .any(|runtime| !provider_id_is_legacy(runtime.provider()))
2170    })
2171}
2172
2173fn workspace_has_open_provider_id(
2174    workspace: &hatchery_core::C2WorkspaceSnapshot,
2175) -> bool {
2176    workspace.sessions.iter()
2177        .any(|session| !provider_id_is_legacy(&session.agent_id))
2178}
2179
2180fn snapshot_has_open_provider_id(
2181    snapshot: &hatchery_core::C2NodeSnapshot,
2182) -> bool {
2183    snapshot.enabled_providers.iter().any(|provider| !provider_id_is_legacy(provider))
2184        || snapshot.provider_runtime_statuses.iter()
2185            .any(|runtime| !provider_id_is_legacy(runtime.provider()))
2186        || snapshot.workspaces.iter().any(workspace_has_open_provider_id)
2187        || snapshot.session_records.iter()
2188            .any(c2_managed_record_has_open_provider_id)
2189}
2190
2191fn context_receipt_has_open_provider_id(
2192    context: &gate4agent_node_protocol::ResolvedContextPackReceipt,
2193) -> bool {
2194    !provider_id_is_legacy(&context.lineage.source_provider)
2195}
2196
2197fn c2_managed_record_has_open_provider_id(
2198    record: &hatchery_core::C2ManagedSessionRecord,
2199) -> bool {
2200    !provider_id_is_legacy(&record.provider)
2201        || record
2202            .context
2203            .as_ref()
2204            .is_some_and(context_receipt_has_open_provider_id)
2205}
2206
2207fn c2_event_has_open_provider_id(event: &C2NodeEvent) -> bool {
2208    match event {
2209        C2NodeEvent::WorkspaceAdded { workspace } => workspace_has_open_provider_id(workspace),
2210        C2NodeEvent::SessionRecordUpserted { record } => {
2211            c2_managed_record_has_open_provider_id(record)
2212        }
2213        C2NodeEvent::HarnessMcpReadCall { .. }
2214        | C2NodeEvent::Control { .. }
2215        | C2NodeEvent::SessionRecordHistorySummarized { .. }
2216        | C2NodeEvent::TerminalFrame { .. }
2217        | C2NodeEvent::ControllerChanged { .. }
2218        | C2NodeEvent::WorkspaceRemoved { .. }
2219        | C2NodeEvent::SessionRecordRemoved { .. }
2220        | C2NodeEvent::ManagedWorktreeUpserted { .. }
2221        | C2NodeEvent::ManagedWorktreeRemoved { .. }
2222        | C2NodeEvent::AgentStream { .. }
2223        | C2NodeEvent::ResyncRequired { .. } => false,
2224    }
2225}
2226
2227fn routed_event_has_open_provider_id(event: &RoutedNodeEvent) -> bool {
2228    c2_event_has_open_provider_id(&event.event)
2229}
2230
2231fn c2_node_response_has_open_provider_id(response: &C2NodeResponse) -> bool {
2232    match response {
2233        C2NodeResponse::Snapshot { snapshot, .. } => snapshot_has_open_provider_id(snapshot),
2234        C2NodeResponse::Resync { snapshot, events, .. } => {
2235            snapshot_has_open_provider_id(snapshot)
2236                || events.iter().any(|event| c2_event_has_open_provider_id(&event.event))
2237        }
2238        C2NodeResponse::SessionRecordUpdated { record }
2239        | C2NodeResponse::ProviderSessionIndexed { record }
2240        | C2NodeResponse::NativeSessionIndexed { record, .. }
2241        | C2NodeResponse::SessionRecordResumed { record, .. } => {
2242            c2_managed_record_has_open_provider_id(record)
2243        }
2244        C2NodeResponse::SpawnSpecAccepted { receipt }
2245        | C2NodeResponse::Spawned { receipt, .. } => {
2246            !provider_id_is_legacy(&receipt.provider)
2247                || receipt
2248                    .context
2249                    .as_ref()
2250                    .is_some_and(context_receipt_has_open_provider_id)
2251        }
2252        C2NodeResponse::ManagedWorktreeSpawnAccepted { receipt } => {
2253            !provider_id_is_legacy(&receipt.spawn.provider)
2254                || receipt
2255                    .spawn
2256                    .context
2257                    .as_ref()
2258                    .is_some_and(context_receipt_has_open_provider_id)
2259        }
2260        C2NodeResponse::ContextPackExported { context }
2261        | C2NodeResponse::ContextPackForSessionRecordExported { context, .. }
2262        | C2NodeResponse::DurableContextPackResolved { context } => {
2263            context_receipt_has_open_provider_id(context)
2264        }
2265        C2NodeResponse::NativeSessionsCataloged { route, .. }
2266        | C2NodeResponse::NativeSessionsPaged { route, .. } => {
2267            !provider_id_is_legacy(&route.provider)
2268        }
2269        C2NodeResponse::NativeSessionPreviewed { selection, .. } => {
2270            !provider_id_is_legacy(&selection.route.provider)
2271        }
2272        C2NodeResponse::WorkspaceRegistered { workspace }
2273        | C2NodeResponse::StandaloneWorkspaceCreated { workspace }
2274        | C2NodeResponse::WorktreeCreated { workspace, .. } => {
2275            workspace_has_open_provider_id(workspace)
2276        }
2277        C2NodeResponse::Armed { .. }
2278        | C2NodeResponse::Activated { .. }
2279        | C2NodeResponse::Aborted { .. }
2280        | C2NodeResponse::ReplyChunkAccepted { .. }
2281        | C2NodeResponse::CallRejected { .. }
2282        | C2NodeResponse::WorkspaceInspected { .. }
2283        | C2NodeResponse::DeliveryStageBegun { .. }
2284        | C2NodeResponse::DeliveryBlobChunkAccepted { .. }
2285        | C2NodeResponse::DeliveryCommitted { .. }
2286        | C2NodeResponse::DeliveryStageAborted { .. }
2287        | C2NodeResponse::HostDirectoriesBrowsed { .. }
2288        | C2NodeResponse::WorkspaceFileRead { .. }
2289        | C2NodeResponse::WorkspaceFileWritten { .. }
2290        | C2NodeResponse::WorkspaceFileCreated { .. }
2291        | C2NodeResponse::WorkspaceDirectoryCreated { .. }
2292        | C2NodeResponse::GitHistoryRead { .. }
2293        | C2NodeResponse::GitDiffRead { .. }
2294        | C2NodeResponse::Controller { .. }
2295        | C2NodeResponse::SpawnAccepted { .. }
2296        | C2NodeResponse::ManagedWorktreeCleanup { .. }
2297        | C2NodeResponse::SessionRecordForgotten { .. }
2298        | C2NodeResponse::SessionRecordPreviewed { .. }
2299        | C2NodeResponse::HistoryDiscovered { .. }
2300        | C2NodeResponse::HistoryLoaded { .. }
2301        | C2NodeResponse::ContextPackForgotten { .. }
2302        | C2NodeResponse::ContextPackBytesRead { .. }
2303        | C2NodeResponse::WorkspaceUnregistered { .. }
2304        | C2NodeResponse::WorktreeRemoved { .. }
2305        | C2NodeResponse::Accepted
2306        | C2NodeResponse::ShuttingDown => false,
2307    }
2308}
2309
2310fn routed_response_has_open_provider_id(response: &RoutedNodeResponse) -> bool {
2311    response.response.as_ref().is_ok_and(c2_node_response_has_open_provider_id)
2312}
2313
2314async fn read_server_frame<S>(
2315    pipe: &mut S,
2316    limit: usize,
2317) -> Result<C2ServerFrame, C2ControlError>
2318where
2319    S: tokio::io::AsyncRead + Unpin,
2320{
2321    Ok(read_json_frame_limited_body_timeout(pipe, limit, FRAME_BODY_DEADLINE).await?)
2322}
2323
2324async fn control_reader<R>(mut reader: R, owner: mpsc::Sender<OwnerInput>)
2325where
2326    R: tokio::io::AsyncRead + Unpin,
2327{
2328    loop {
2329        match read_json_frame_limited_body_timeout(
2330            &mut reader,
2331            MAX_C2_SERVER_FRAME_BYTES,
2332            FRAME_BODY_DEADLINE,
2333        ).await {
2334            Ok(frame) => if owner.send(OwnerInput::Frame(frame)).await.is_err() { return; },
2335            Err(_) => { let _ = owner.send(OwnerInput::Closed).await; return; }
2336        }
2337    }
2338}
2339
2340async fn control_writer<W>(
2341    mut writer: W,
2342    mut frames: mpsc::Receiver<C2ClientFrame>,
2343    owner: mpsc::Sender<OwnerInput>,
2344) where
2345    W: tokio::io::AsyncWrite + Unpin,
2346{
2347    while let Some(frame) = frames.recv().await {
2348        if !matches!(timeout(FRAME_BODY_DEADLINE, write_json_frame_limited(
2349            &mut writer,
2350            &frame,
2351            MAX_C2_CLIENT_FRAME_BYTES,
2352        )).await, Ok(Ok(()))) {
2353            break;
2354        }
2355    }
2356    let _ = owner.send(OwnerInput::Closed).await;
2357}
2358
2359async fn control_owner<E>(
2360    mut commands: mpsc::Receiver<ControlCommand>,
2361    mut browser_commands: mpsc::Receiver<BrowserControlCommand>,
2362    events: E,
2363    topology: watch::Sender<Arc<C2Topology>>,
2364    writer: mpsc::Sender<C2ClientFrame>,
2365    mut incoming: mpsc::Receiver<OwnerInput>,
2366    path_capabilities: NegotiatedPathCapabilities,
2367) where
2368    E: Into<EventDelivery>,
2369{
2370    let events = events.into();
2371    let mut next_request_id = 1_u64;
2372    let mut pending = BTreeMap::new();
2373    let mut harness_mcp_receiver_gone = false;
2374    // Set the moment a `harness_mcp`/`regular` (terminal-frame) delivery
2375    // first drops an event for a full channel, cleared the moment a send
2376    // succeeds again -- so a sustained burst of drops during one backlog
2377    // episode logs exactly once (at the episode's start) instead of once
2378    // per dropped event, mirroring `harness_mcp_receiver_gone`'s own
2379    // one-shot-per-episode discipline for the channel-closed case right
2380    // next to it.
2381    let mut harness_mcp_channel_full_logged = false;
2382    let mut terminal_frame_channel_full_logged = false;
2383    let mut regular_event_channel_full_logged = false;
2384    let mut loss_reason = C2ConnectionLossReason::Shutdown;
2385    loop {
2386        tokio::select! {
2387            browser_cmd = browser_commands.recv() => {
2388                let Some(browser_cmd) = browser_cmd else { break; };
2389                let frame = C2ClientFrame::Browser(browser_cmd.cmd);
2390                if writer.send(frame).await.is_err() {
2391                    let _ = browser_cmd.reply.send(Err(C2ControlError::Closed));
2392                    loss_reason = C2ConnectionLossReason::PipeClosed;
2393                    break;
2394                }
2395                let _ = browser_cmd.reply.send(Ok(()));
2396            }
2397            command = commands.recv() => {
2398                let Some(command) = command else { break; };
2399                if let Err(error) = reject_unnegotiated_outbound_path(
2400                    &command.request,
2401                    path_capabilities,
2402                ) {
2403                    let _ = command.reply.send(Err(error));
2404                    continue;
2405                }
2406                let request_id = C2RequestId(next_request_id);
2407                let Some(next) = next_request_id.checked_add(1) else {
2408                    let _ = command.reply.send(Err(C2ControlError::RequestIdExhausted));
2409                    break;
2410                };
2411                next_request_id = next;
2412                let expected_request = command.request.clone();
2413                let frame = C2ClientFrame::Request(C2RequestEnvelope {
2414                    request_id,
2415                    request: RoutedNodeRequest {
2416                        route: command.route.clone(),
2417                        request: command.request,
2418                    },
2419                    correlation_id: command.correlation_id,
2420                });
2421                pending.insert(
2422                    request_id,
2423                    (command.route, expected_request, command.reply),
2424                );
2425                if writer.send(frame).await.is_err() {
2426                    loss_reason = C2ConnectionLossReason::PipeClosed;
2427                    break;
2428                }
2429            }
2430            input = incoming.recv() => {
2431                match input {
2432                    Some(OwnerInput::Frame(C2ServerFrame::Reply(reply))) => {
2433                        loss_reason = C2ConnectionLossReason::Protocol;
2434                        let Some((expected_route, expected_request, waiter)) =
2435                            pending.remove(&reply.request_id)
2436                        else {
2437                            break;
2438                        };
2439                        if reply.result.as_ref().is_ok_and(|routed| {
2440                            // The all-zero expected incarnation is the operator
2441                            // wildcard: accept whatever live incarnation the node
2442                            // reports (used by HQ spawns that are robust to node
2443                            // restarts). Otherwise the incarnation must match.
2444                            let wildcard = expected_route.expected_incarnation_id
2445                                == hatchery_core::NodeIncarnationId::from_bytes([0; 16]);
2446                            routed.node_id != expected_route.node_id
2447                                || (!wildcard
2448                                    && routed.incarnation_id
2449                                        != expected_route.expected_incarnation_id)
2450                        }) {
2451                            let _ = waiter.send(Err(C2ControlError::Protocol(
2452                                "C2 reply route or node incarnation does not match the request"
2453                                    .to_owned(),
2454                            )));
2455                            break;
2456                        }
2457                        if let Err(message) = validate_provider_session_index_response(
2458                            &expected_request,
2459                            &reply.result,
2460                        ) {
2461                            let _ = waiter.send(Err(C2ControlError::Protocol(
2462                                message.to_owned(),
2463                            )));
2464                            break;
2465                        }
2466                        if let Err(message) = validate_session_record_context_export_response(
2467                            &expected_request,
2468                            &reply.result,
2469                        ) {
2470                            let _ = waiter.send(Err(C2ControlError::Protocol(
2471                                message.to_owned(),
2472                            )));
2473                            break;
2474                        }
2475                        if let Err(message) = validate_native_session_response(
2476                            &expected_request,
2477                            &reply.result,
2478                        ) {
2479                            let _ = waiter.send(Err(C2ControlError::Protocol(
2480                                message.to_owned(),
2481                            )));
2482                            break;
2483                        }
2484                        if let Err(message) = validate_workspace_content_response(
2485                            &expected_request,
2486                            &reply.result,
2487                        ) {
2488                            let _ = waiter.send(Err(C2ControlError::Protocol(
2489                                message.to_owned(),
2490                            )));
2491                            break;
2492                        }
2493                        if let Err(message) = validate_session_task_response(
2494                            &expected_request,
2495                            &reply.result,
2496                        ) {
2497                            let _ = waiter.send(Err(C2ControlError::Protocol(
2498                                message.to_owned(),
2499                            )));
2500                            break;
2501                        }
2502                        if let Err(message) = validate_managed_worktree_spawn_v2_response(
2503                            &expected_request,
2504                            &reply.result,
2505                        ) {
2506                            let _ = waiter.send(Err(C2ControlError::Protocol(
2507                                message.to_owned(),
2508                            )));
2509                            break;
2510                        }
2511                        if let Err(message) = validate_harness_mcp_response(
2512                            &expected_request,
2513                            &reply.result,
2514                        ) {
2515                            let _ = waiter.send(Err(C2ControlError::Protocol(
2516                                message.to_owned(),
2517                            )));
2518                            break;
2519                        }
2520                        if !path_capabilities.terminal_frame_events
2521                            && reply
2522                                .result
2523                                .as_ref()
2524                                .is_ok_and(routed_response_has_terminal_frame_event)
2525                        {
2526                            let _ = waiter.send(Err(C2ControlError::Protocol(
2527                                TERMINAL_FRAME_EVENTS_NOT_NEGOTIATED.to_owned(),
2528                            )));
2529                            break;
2530                        }
2531                        if !path_capabilities.agent_stream_events
2532                            && reply
2533                                .result
2534                                .as_ref()
2535                                .is_ok_and(routed_response_has_agent_stream_event)
2536                        {
2537                            let _ = waiter.send(Err(C2ControlError::Protocol(
2538                                AGENT_STREAM_EVENTS_NOT_NEGOTIATED.to_owned(),
2539                            )));
2540                            break;
2541                        }
2542                        if !path_capabilities.agent_progress_snapshot
2543                            && reply
2544                                .result
2545                                .as_ref()
2546                                .is_ok_and(routed_response_has_agent_progress)
2547                        {
2548                            let _ = waiter.send(Err(C2ControlError::Protocol(
2549                                "agent progress snapshots require negotiated C2 capability"
2550                                    .to_owned(),
2551                            )));
2552                            break;
2553                        }
2554                        if !path_capabilities.provider_ids_open
2555                            && reply
2556                                .result
2557                                .as_ref()
2558                                .is_ok_and(routed_response_has_open_provider_id)
2559                        {
2560                            let _ = waiter.send(Err(C2ControlError::Protocol(
2561                                OPEN_PROVIDER_ID_NOT_NEGOTIATED.to_owned(),
2562                            )));
2563                            break;
2564                        }
2565                        if !path_capabilities.opaque_host_paths
2566                            && reply
2567                                .result
2568                                .as_ref()
2569                                .is_ok_and(routed_response_has_unix_bytes)
2570                        {
2571                            let _ = waiter.send(Err(C2ControlError::Protocol(
2572                                OPAQUE_UNIX_PATH_NOT_NEGOTIATED.to_owned(),
2573                            )));
2574                            break;
2575                        }
2576                        if !path_capabilities.repository_paths
2577                            && reply
2578                                .result
2579                                .as_ref()
2580                                .is_ok_and(routed_response_has_unix_repository_path)
2581                        {
2582                            let _ = waiter.send(Err(C2ControlError::Protocol(
2583                                REPOSITORY_PATH_NOT_NEGOTIATED.to_owned(),
2584                            )));
2585                            break;
2586                        }
2587                        if !path_capabilities.workspace_file_read
2588                            && reply
2589                                .result
2590                                .as_ref()
2591                                .is_ok_and(routed_response_requires_workspace_file_read)
2592                        {
2593                            let _ = waiter.send(Err(C2ControlError::Protocol(
2594                                WORKSPACE_FILE_READ_NOT_NEGOTIATED.to_owned(),
2595                            )));
2596                            break;
2597                        }
2598                        if !path_capabilities.workspace_file_write
2599                            && reply
2600                                .result
2601                                .as_ref()
2602                                .is_ok_and(routed_response_requires_workspace_file_write)
2603                        {
2604                            let _ = waiter.send(Err(C2ControlError::Protocol(
2605                                "workspace file writes require negotiated C2 capability".to_owned(),
2606                            )));
2607                            break;
2608                        }
2609                        if !path_capabilities.workspace_entry_create
2610                            && reply
2611                                .result
2612                                .as_ref()
2613                                .is_ok_and(routed_response_requires_workspace_entry_create)
2614                        {
2615                            let _ = waiter.send(Err(C2ControlError::Protocol(
2616                                WORKSPACE_ENTRY_CREATE_NOT_NEGOTIATED.to_owned(),
2617                            )));
2618                            break;
2619                        }
2620                        if !path_capabilities.git_read
2621                            && reply
2622                                .result
2623                                .as_ref()
2624                                .is_ok_and(routed_response_requires_git_read)
2625                        {
2626                            let _ = waiter.send(Err(C2ControlError::Protocol(
2627                                "git reads require negotiated C2 capability".to_owned(),
2628                            )));
2629                            break;
2630                        }
2631                        if !path_capabilities.host_directory_browse
2632                            && reply
2633                                .result
2634                                .as_ref()
2635                                .is_ok_and(routed_response_requires_host_directory_browse)
2636                        {
2637                            let _ = waiter.send(Err(C2ControlError::Protocol(
2638                                HOST_DIRECTORY_BROWSE_NOT_NEGOTIATED.to_owned(),
2639                            )));
2640                            break;
2641                        }
2642                        if !path_capabilities.standalone_workspace_lifecycle
2643                            && reply.result.as_ref().is_ok_and(
2644                                routed_response_requires_standalone_workspace_lifecycle,
2645                            )
2646                        {
2647                            let _ = waiter.send(Err(C2ControlError::Protocol(
2648                                STANDALONE_WORKSPACE_LIFECYCLE_NOT_NEGOTIATED.to_owned(),
2649                            )));
2650                            break;
2651                        }
2652                        if !path_capabilities.provider_session_reference_index
2653                            && matches!(
2654                                expected_request,
2655                                NodeRequest::IndexProviderSession { .. }
2656                            )
2657                            && reply.result.as_ref().is_ok_and(
2658                                routed_response_requires_provider_session_reference_index,
2659                            )
2660                        {
2661                            let _ = waiter.send(Err(C2ControlError::Protocol(
2662                                PROVIDER_SESSION_REFERENCE_INDEX_NOT_NEGOTIATED.to_owned(),
2663                            )));
2664                            break;
2665                        }
2666                        if !path_capabilities.spawn_spec_defaults_overrides
2667                            && reply
2668                                .result
2669                                .as_ref()
2670                                .is_ok_and(routed_response_requires_spawn_spec)
2671                        {
2672                            let _ = waiter.send(Err(C2ControlError::Protocol(
2673                                SPAWN_SPEC_NOT_NEGOTIATED.to_owned(),
2674                            )));
2675                            break;
2676                        }
2677                        if !path_capabilities.spawn_profile_revision
2678                            && reply
2679                                .result
2680                                .as_ref()
2681                                .is_ok_and(routed_response_requires_spawn_profile_revision)
2682                        {
2683                            let _ = waiter.send(Err(C2ControlError::Protocol(
2684                                SPAWN_PROFILE_REVISION_NOT_NEGOTIATED.to_owned(),
2685                            )));
2686                            break;
2687                        }
2688                        if !path_capabilities.worktree_selection
2689                            && reply
2690                                .result
2691                                .as_ref()
2692                                .is_ok_and(routed_response_requires_worktree_selection)
2693                        {
2694                            let _ = waiter.send(Err(C2ControlError::Protocol(
2695                                WORKTREE_SELECTION_NOT_NEGOTIATED.to_owned(),
2696                            )));
2697                            break;
2698                        }
2699                        if !path_capabilities.managed_worktree_lifecycle
2700                            && reply
2701                                .result
2702                                .as_ref()
2703                                .is_ok_and(routed_response_requires_managed_worktree)
2704                        {
2705                            let _ = waiter.send(Err(C2ControlError::Protocol(
2706                                MANAGED_WORKTREE_LIFECYCLE_NOT_NEGOTIATED.to_owned(),
2707                            )));
2708                            break;
2709                        }
2710                        if !path_capabilities.child_environment_profile
2711                            && reply.result.as_ref().is_ok_and(|routed| {
2712                                routed.response.as_ref().is_ok_and(
2713                                    C2NodeResponse::requires_child_environment_profile_capability,
2714                                )
2715                            })
2716                        {
2717                            let _ = waiter.send(Err(C2ControlError::Protocol(
2718                                CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED.to_owned(),
2719                            )));
2720                            break;
2721                        }
2722                        if !path_capabilities.session_bundle_materialization
2723                            && reply.result.as_ref().is_ok_and(|routed| {
2724                                routed.response.as_ref().is_ok_and(
2725                                    C2NodeResponse::requires_session_bundle_materialization_capability,
2726                                )
2727                            })
2728                        {
2729                            let _ = waiter.send(Err(C2ControlError::Protocol(
2730                                SESSION_BUNDLE_MATERIALIZATION_NOT_NEGOTIATED.to_owned(),
2731                            )));
2732                            break;
2733                        }
2734                        if !path_capabilities.history_context_pack
2735                            && reply
2736                                .result
2737                                .as_ref()
2738                                .is_ok_and(routed_response_requires_history_context_pack)
2739                        {
2740                            let _ = waiter.send(Err(C2ControlError::Protocol(
2741                                HISTORY_CONTEXT_PACK_NOT_NEGOTIATED.to_owned(),
2742                            )));
2743                            break;
2744                        }
2745                        if !path_capabilities.session_record_context_export
2746                            && reply.result.as_ref().is_ok_and(|routed| {
2747                                routed.response.as_ref().is_ok_and(
2748                                    C2NodeResponse::requires_session_record_context_export_capability,
2749                                )
2750                            })
2751                        {
2752                            let _ = waiter.send(Err(C2ControlError::Protocol(
2753                                SESSION_RECORD_CONTEXT_EXPORT_NOT_NEGOTIATED.to_owned(),
2754                            )));
2755                            break;
2756                        }
2757                        if !path_capabilities.control_detail
2758                            && reply.result.as_ref().is_ok_and(|routed| {
2759                                routed.response.as_ref().is_ok_and(
2760                                    C2NodeResponse::requires_control_detail_capability,
2761                                )
2762                            })
2763                        {
2764                            let _ = waiter.send(Err(C2ControlError::Protocol(
2765                                CONTROL_DETAIL_NOT_NEGOTIATED.to_owned(),
2766                            )));
2767                            break;
2768                        }
2769                        if !path_capabilities.session_task_correlation
2770                            && reply.result.as_ref().is_ok_and(|routed| {
2771                                routed.response.as_ref().is_ok_and(
2772                                    C2NodeResponse::requires_session_task_correlation_capability,
2773                                )
2774                            })
2775                        {
2776                            let _ = waiter.send(Err(C2ControlError::Protocol(
2777                                SESSION_TASK_CORRELATION_NOT_NEGOTIATED.to_owned(),
2778                            )));
2779                            break;
2780                        }
2781                        if !path_capabilities.native_session_catalog
2782                            && reply
2783                                .result
2784                                .as_ref()
2785                                .is_ok_and(routed_response_requires_native_session_catalog)
2786                        {
2787                            let _ = waiter.send(Err(C2ControlError::Protocol(
2788                                "native session catalog capability was not negotiated".to_owned(),
2789                            )));
2790                            break;
2791                        }
2792                        if !path_capabilities.native_session_catalog_paging
2793                            && reply
2794                                .result
2795                                .as_ref()
2796                                .is_ok_and(routed_response_requires_native_session_catalog_paging)
2797                        {
2798                            let _ = waiter.send(Err(C2ControlError::Protocol(
2799                                "native session catalog paging capability was not negotiated".to_owned(),
2800                            )));
2801                            break;
2802                        }
2803                        if !path_capabilities.native_session_preview
2804                            && reply
2805                                .result
2806                                .as_ref()
2807                                .is_ok_and(routed_response_requires_native_session_preview)
2808                        {
2809                            let _ = waiter.send(Err(C2ControlError::Protocol(
2810                                "native session preview capability was not negotiated".to_owned(),
2811                            )));
2812                            break;
2813                        }
2814                        if !path_capabilities.native_session_index
2815                            && reply
2816                                .result
2817                                .as_ref()
2818                                .is_ok_and(routed_response_requires_native_session_index)
2819                        {
2820                            let _ = waiter.send(Err(C2ControlError::Protocol(
2821                                "native session index capability was not negotiated".to_owned(),
2822                            )));
2823                            break;
2824                        }
2825                        if !path_capabilities.harness_mcp_read_proxy
2826                            && reply
2827                                .result
2828                                .as_ref()
2829                                .is_ok_and(routed_response_requires_harness_mcp_proxy)
2830                        {
2831                            let _ = waiter.send(Err(C2ControlError::Protocol(
2832                                "harness MCP read proxy capability was not negotiated".to_owned(),
2833                            )));
2834                            break;
2835                        }
2836                        let _ = waiter.send(reply.result.map_err(C2ControlError::Relay));
2837                    }
2838                    Some(OwnerInput::Frame(C2ServerFrame::Event(event))) => {
2839                        loss_reason = C2ConnectionLossReason::Protocol;
2840                        if event.event.requires_harness_mcp_proxy_capability()
2841                            && (!path_capabilities.harness_mcp_read_proxy
2842                                || !event.event.harness_mcp_contract_is_valid_at(
2843                                    match current_unix_ms() { Ok(value) => value, Err(_) => break },
2844                                ))
2845                        {
2846                            break;
2847                        }
2848                        if !path_capabilities.terminal_frame_events
2849                            && c2_node_event_is_terminal_frame(&event.event)
2850                        {
2851                            tracing::warn!(
2852                                kind = c2_node_event_kind_label(&event.event),
2853                                sequence = event.cursor.sequence,
2854                                cursor = ?event.cursor,
2855                                reason = "terminal frame events capability not negotiated",
2856                                "dropping event and tearing down the C2 control connection",
2857                            );
2858                            break;
2859                        }
2860                        if !path_capabilities.agent_stream_events
2861                            && c2_node_event_is_agent_stream(&event.event)
2862                        {
2863                            tracing::warn!(
2864                                kind = c2_node_event_kind_label(&event.event),
2865                                sequence = event.cursor.sequence,
2866                                cursor = ?event.cursor,
2867                                reason = "agent stream events capability not negotiated",
2868                                "dropping event and tearing down the C2 control connection",
2869                            );
2870                            break;
2871                        }
2872                        if !path_capabilities.provider_ids_open
2873                            && routed_event_has_open_provider_id(&event)
2874                        {
2875                            break;
2876                        }
2877                        if !path_capabilities.opaque_host_paths
2878                            && routed_event_has_unix_bytes(&event)
2879                        {
2880                            break;
2881                        }
2882                        if !path_capabilities.managed_worktree_lifecycle
2883                            && c2_node_event_is_managed_worktree(&event.event)
2884                        {
2885                            break;
2886                        }
2887                        if !path_capabilities.worktree_selection
2888                            && c2_node_event_is_managed_worktree(&event.event)
2889                        {
2890                            break;
2891                        }
2892                        if !path_capabilities.child_environment_profile
2893                            && event
2894                                .event
2895                                .requires_child_environment_profile_capability()
2896                        {
2897                            break;
2898                        }
2899                        if !path_capabilities.session_bundle_materialization
2900                            && event
2901                                .event
2902                                .requires_session_bundle_materialization_capability()
2903                        {
2904                            break;
2905                        }
2906                        if !path_capabilities.history_context_pack
2907                            && event.event.requires_history_context_pack_capability()
2908                        {
2909                            break;
2910                        }
2911                        if !path_capabilities.session_task_correlation
2912                            && event.event.requires_session_task_correlation_capability()
2913                        {
2914                            break;
2915                        }
2916                        if !path_capabilities.control_detail
2917                            && event.event.requires_control_detail_capability()
2918                        {
2919                            break;
2920                        }
2921                        if event.event.requires_harness_mcp_proxy_capability() {
2922                            // A full or gone harness-MCP receiver must never
2923                            // tear down the whole C2 connection -- every
2924                            // other in-flight request (Arm, Activate,
2925                            // Abort, ...) would otherwise be answered
2926                            // `Closed` seconds later with no log line
2927                            // anywhere naming why.
2928                            let sequence = event.cursor.sequence;
2929                            let cursor = event.cursor;
2930                            match events.harness_mcp.try_send(event) {
2931                                Ok(()) => {
2932                                    reset_channel_full_drop_episode(&mut harness_mcp_channel_full_logged);
2933                                }
2934                                Err(mpsc::error::TrySendError::Full(_)) => {
2935                                    if should_log_channel_full_drop(&mut harness_mcp_channel_full_logged) {
2936                                        tracing::warn!(
2937                                            kind = "harness-mcp-read-call",
2938                                            sequence,
2939                                            cursor = ?cursor,
2940                                            reason = "harness_mcp event channel full",
2941                                            "dropped harness MCP event; C2 control connection stays open",
2942                                        );
2943                                    }
2944                                }
2945                                Err(mpsc::error::TrySendError::Closed(_)) => {
2946                                    if !harness_mcp_receiver_gone {
2947                                        harness_mcp_receiver_gone = true;
2948                                        tracing::warn!(
2949                                            kind = "harness-mcp-read-call",
2950                                            sequence,
2951                                            cursor = ?cursor,
2952                                            reason = "harness_mcp event receiver dropped",
2953                                            "no harness MCP event subscriber; further harness MCP events on this connection are dropped silently",
2954                                        );
2955                                    }
2956                                }
2957                            }
2958                        } else if c2_node_event_is_terminal_frame(&event.event) {
2959                            let kind = c2_node_event_kind_label(&event.event);
2960                            let sequence = event.cursor.sequence;
2961                            let cursor = event.cursor;
2962                            match events.regular.try_send(event) {
2963                                Ok(()) => {
2964                                    reset_channel_full_drop_episode(&mut terminal_frame_channel_full_logged);
2965                                }
2966                                Err(mpsc::error::TrySendError::Full(_)) => {
2967                                    if should_log_channel_full_drop(&mut terminal_frame_channel_full_logged) {
2968                                        tracing::warn!(
2969                                            kind,
2970                                            sequence,
2971                                            cursor = ?cursor,
2972                                            reason = "regular event channel full",
2973                                            "dropped terminal frame event; C2 control connection stays open",
2974                                        );
2975                                    }
2976                                }
2977                                Err(mpsc::error::TrySendError::Closed(_)) => {
2978                                    tracing::warn!(
2979                                        kind,
2980                                        sequence,
2981                                        cursor = ?cursor,
2982                                        reason = "regular event receiver dropped",
2983                                        "dropping event and tearing down the C2 control connection",
2984                                    );
2985                                    loss_reason = C2ConnectionLossReason::RegularEventBackpressure;
2986                                    break;
2987                                }
2988                            }
2989                        } else {
2990                            let kind = c2_node_event_kind_label(&event.event);
2991                            let sequence = event.cursor.sequence;
2992                            let cursor = event.cursor;
2993                            match events.regular.try_send(event) {
2994                                Ok(()) => {
2995                                    reset_channel_full_drop_episode(&mut regular_event_channel_full_logged);
2996                                }
2997                                Err(mpsc::error::TrySendError::Full(_)) => {
2998                                    if should_log_channel_full_drop(&mut regular_event_channel_full_logged) {
2999                                        tracing::warn!(
3000                                            kind,
3001                                            sequence,
3002                                            cursor = ?cursor,
3003                                            reason = "regular event channel full",
3004                                            "dropped regular event; C2 control connection stays open",
3005                                        );
3006                                    }
3007                                }
3008                                Err(mpsc::error::TrySendError::Closed(_)) => {
3009                                    tracing::warn!(
3010                                        kind,
3011                                        sequence,
3012                                        cursor = ?cursor,
3013                                        reason = "regular event receiver dropped",
3014                                        "dropping event and tearing down the C2 control connection",
3015                                    );
3016                                    loss_reason = C2ConnectionLossReason::RegularEventBackpressure;
3017                                    break;
3018                                }
3019                            }
3020                        }
3021                    }
3022                    Some(OwnerInput::Frame(C2ServerFrame::Browser(ev))) => {
3023                        loss_reason = C2ConnectionLossReason::Protocol;
3024                        match events.browser.try_send(ev) {
3025                            Ok(()) => {}
3026                            Err(mpsc::error::TrySendError::Full(_)) => {
3027                                tracing::warn!("dropped browser frame; browser event channel full");
3028                            }
3029                            Err(mpsc::error::TrySendError::Closed(_)) => {
3030                                loss_reason = C2ConnectionLossReason::RegularEventBackpressure;
3031                                break;
3032                            }
3033                        }
3034                    }
3035                    Some(OwnerInput::Frame(C2ServerFrame::Topology(next))) => {
3036                        loss_reason = C2ConnectionLossReason::Protocol;
3037                        if !path_capabilities.provider_ids_open
3038                            && topology_has_open_provider_id(&next)
3039                        {
3040                            break;
3041                        }
3042                        if topology.borrow().as_ref() != &next {
3043                            topology.send_replace(Arc::new(next));
3044                        }
3045                    }
3046                    Some(OwnerInput::Frame(C2ServerFrame::Challenge(_) | C2ServerFrame::Hello(_) | C2ServerFrame::Rejected(_))) => {
3047                        loss_reason = C2ConnectionLossReason::Protocol;
3048                        break;
3049                    }
3050                    Some(OwnerInput::Closed) | None => {
3051                        loss_reason = C2ConnectionLossReason::PipeClosed;
3052                        break;
3053                    }
3054                }
3055            }
3056        }
3057    }
3058    if !pending.is_empty() {
3059        tracing::warn!(
3060            reason = %loss_reason,
3061            pending = pending.len(),
3062            "C2 control connection lost: failing every request still pending",
3063        );
3064    }
3065    for (_, (_, _, waiter)) in pending {
3066        let _ = waiter.send(Err(C2ControlError::ConnectionLost { reason: loss_reason }));
3067    }
3068}
3069
3070pub(crate) fn c2_proof(
3071    token: &str,
3072    direction: C2AuthDirection,
3073    client_nonce: &[u8; C2_AUTH_NONCE_BYTES],
3074    server_nonce: &[u8; C2_AUTH_NONCE_BYTES],
3075    compatibility: Option<(&ClientCompatibilityOffer, &NegotiatedC2ControlCompatibility)>,
3076) -> Result<[u8; 32], C2ControlError> {
3077    let transcript = match compatibility {
3078        Some((offer, selected)) => c2_bound_auth_transcript(
3079            direction,
3080            client_nonce,
3081            server_nonce,
3082            offer,
3083            selected,
3084        ).map_err(|error| C2ControlError::Authentication(error.to_string()))?,
3085        None => c2_auth_transcript(direction, client_nonce, server_nonce),
3086    };
3087    local_hmac_sha256(token.as_bytes(), &transcript)
3088        .map_err(C2ControlError::Authentication)
3089}
3090
3091#[cfg(windows)]
3092fn validate_endpoint(endpoint: &str) -> Result<(), C2ControlError> {
3093    if !endpoint.starts_with(r"\\.\pipe\") || endpoint.len() <= r"\\.\pipe\".len() || endpoint.len() > 1024 {
3094        return Err(C2ControlError::InvalidEndpoint);
3095    }
3096    Ok(())
3097}
3098
3099#[cfg(unix)]
3100fn validate_endpoint(endpoint: &str) -> Result<(), C2ControlError> {
3101    use std::os::unix::ffi::OsStrExt;
3102    use std::path::Path;
3103
3104    let path = Path::new(endpoint);
3105    if endpoint.is_empty() || path.as_os_str().as_bytes().len() > 103 || !path.is_absolute()
3106        || path.file_name().is_none()
3107    {
3108        return Err(C2ControlError::InvalidEndpoint);
3109    }
3110    Ok(())
3111}
3112
3113/// Tunnel control target for HQ. Not a substitute for [`validate_endpoint`]:
3114/// a raw `host:port` string is still [`C2ControlError::InvalidEndpoint`] there.
3115fn validate_tunnel_control_target(address: std::net::SocketAddr) -> Result<(), C2ControlError> {
3116    if address.port() == 0 || address.ip().is_unspecified() || address.ip().is_multicast() {
3117        return Err(C2ControlError::InvalidTunnelControl);
3118    }
3119    Ok(())
3120}
3121
3122fn validate_token(token: &str) -> Result<(), C2ControlError> {
3123    if token.is_empty() || token.len() > 4096 || !token.bytes().all(|byte| matches!(byte, 0x21..=0x7e)) {
3124        return Err(C2ControlError::InvalidToken);
3125    }
3126    Ok(())
3127}
3128
3129#[derive(Debug, Error)]
3130pub enum C2ControlError {
3131    #[cfg_attr(windows, error("C2 control endpoint is not a bounded local named pipe"))]
3132    #[cfg_attr(unix, error("C2 control endpoint is not a bounded absolute local endpoint"))]
3133    InvalidEndpoint,
3134    #[error("C2 tunnel control address must be a concrete host and a nonzero port")]
3135    InvalidTunnelControl,
3136    #[error("C2 token must contain 1..=4096 visible ASCII bytes without whitespace")]
3137    InvalidToken,
3138    #[error("C2 control I/O failed: {0}")]
3139    Io(#[from] io::Error),
3140    #[error(transparent)]
3141    Frame(#[from] FrameError),
3142    #[error("C2 authentication timed out")]
3143    AuthenticationTimedOut,
3144    #[error("C2 authentication failed: {0}")]
3145    Authentication(String),
3146    #[error("C2 control protocol failed: {0}")]
3147    Protocol(String),
3148    #[error("build stamp mismatch: local={local} remote={remote}")]
3149    BuildStampMismatch { local: String, remote: String },
3150    #[error("C2 relay rejected request: {0:?}")]
3151    Relay(C2RelayFailure),
3152    #[error("C2 control connection closed")]
3153    Closed,
3154    /// The `control_owner` loop tore the connection down for a NAMED
3155    /// reason (as opposed to `Closed`, which also covers a local caller
3156    /// dropping its own handle/queue -- never emitted by `control_owner`
3157    /// itself). Every request pending at teardown gets this, once, with
3158    /// the reason logged alongside the pending count.
3159    #[error("C2 control connection lost: {reason}")]
3160    ConnectionLost { reason: C2ConnectionLossReason },
3161    #[error("C2 control request queue is full")]
3162    QueueFull,
3163    #[error("C2 request ID space exhausted")]
3164    RequestIdExhausted,
3165}
3166
3167/// Why `control_owner` tore its connection down, attached to every request
3168/// still pending at that moment (`C2ControlError::ConnectionLost`). Logged
3169/// once at the teardown point instead of being silently swallowed.
3170#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3171pub enum C2ConnectionLossReason {
3172    /// The reader or writer half of the physical pipe ended (I/O error,
3173    /// EOF, or a downstream send failure into the writer task).
3174    PipeClosed,
3175    /// The relay sent a frame or reply that violates the negotiated
3176    /// protocol: an unmatched request id, a route/incarnation mismatch, an
3177    /// unnegotiated-capability leak, an invalid topology frame, or a
3178    /// repeated handshake frame after authentication.
3179    Protocol,
3180    /// The `regular` event consumer is gone: its channel closed because
3181    /// the receiver was dropped. A merely full (but alive) `regular`
3182    /// channel never reaches this reason -- the offending event is
3183    /// dropped and the connection stays open instead.
3184    RegularEventBackpressure,
3185    /// The local caller side shut the connection down deliberately (every
3186    /// `C2ControlHandle` clone dropped) or the request id space was
3187    /// exhausted.
3188    Shutdown,
3189}
3190
3191impl std::fmt::Display for C2ConnectionLossReason {
3192    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3193        formatter.write_str(match self {
3194            Self::PipeClosed => "pipe closed",
3195            Self::Protocol => "protocol violation",
3196            Self::RegularEventBackpressure => "regular event consumer fell behind",
3197            Self::Shutdown => "local caller shut the connection down",
3198        })
3199    }
3200}
3201
3202#[cfg(all(test, windows))]
3203mod tests {
3204    use super::*;
3205
3206    #[test]
3207    fn harness_mcp_c2_client_capability_and_correlation_are_exact() {
3208        let reservation_id = gate4agent_node_protocol::HarnessMcpReservationId::new(
3209            format!("hmcpres_{}", "a".repeat(24)),
3210        ).unwrap();
3211        let activation_digest = gate4agent_node_protocol::HarnessMcpActivationDigest::new(
3212            format!("sha256:{}", "b".repeat(64)),
3213        ).unwrap();
3214        let request = NodeRequest::AbortHarnessMcpReservation {
3215            reservation_id: reservation_id.clone(),
3216            activation_digest: activation_digest.clone(),
3217        };
3218        assert!(reject_unnegotiated_outbound_path(
3219            &request,
3220            NegotiatedPathCapabilities::default(),
3221        ).is_err());
3222        let capabilities = NegotiatedPathCapabilities {
3223            harness_mcp_read_proxy: true,
3224            spawn_spec_defaults_overrides: true,
3225            ..NegotiatedPathCapabilities::default()
3226        };
3227        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_ok());
3228
3229        let routed = RoutedNodeResponse {
3230            node_id: gate4agent_node_protocol::NodeId::new("node-a").unwrap(),
3231            incarnation_id: gate4agent_node_protocol::NodeIncarnationId::from_bytes([1; 16]),
3232            response: Ok(C2NodeResponse::Aborted {
3233                reservation_id: reservation_id.clone(),
3234                activation_digest: activation_digest.clone(),
3235            }),
3236        };
3237        assert!(validate_harness_mcp_response(&request, &Ok(routed.clone())).is_ok());
3238        let mut mismatch = routed;
3239        mismatch.response = Ok(C2NodeResponse::Aborted {
3240            reservation_id: gate4agent_node_protocol::HarnessMcpReservationId::new(
3241                format!("hmcpres_{}", "c".repeat(24)),
3242            ).unwrap(),
3243            activation_digest,
3244        });
3245        assert!(validate_harness_mcp_response(&request, &Ok(mismatch)).is_err());
3246    }
3247
3248    #[test]
3249    fn acp_control_verbs_are_admitted_only_when_negotiated_and_refuse_by_own_name() {
3250        let session = gate4agent_node_protocol::SessionAddress {
3251            workspace_id: WorkspaceId::new("primary").unwrap(),
3252            session: gate4agent_node_protocol::SessionKey {
3253                instance_id: AgentInstanceId(3),
3254                generation: SessionGeneration(1),
3255            },
3256        };
3257        let requests = [
3258            NodeRequest::ResolveInteraction {
3259                session: session.clone(),
3260                correlation_id: "acp-correlation-1".to_owned(),
3261                response: ProviderInteractionResponse::ApproveOnce,
3262            },
3263            NodeRequest::SetSessionMode {
3264                session: session.clone(),
3265                mode_id: "plan".to_owned(),
3266            },
3267            NodeRequest::SetSessionConfigOption {
3268                session: session.clone(),
3269                option_id: "reasoning-effort".to_owned(),
3270                value_json: "\"high\"".to_owned(),
3271            },
3272            NodeRequest::SetSessionModel {
3273                session: session.clone(),
3274                model_id: "claude-opus".to_owned(),
3275            },
3276        ];
3277        for request in &requests {
3278            assert_eq!(
3279                request.required_capability(),
3280                Some(C2_ACP_CONTROL_CAPABILITY),
3281            );
3282            let refusal = reject_unnegotiated_outbound_path(
3283                request,
3284                NegotiatedPathCapabilities::default(),
3285            )
3286            .unwrap_err();
3287            assert!(
3288                matches!(
3289                    &refusal,
3290                    C2ControlError::Protocol(message)
3291                        if message == ACP_CONTROL_NOT_NEGOTIATED
3292                ),
3293                "unexpected refusal for {request:?}: {refusal:?}",
3294            );
3295            let capabilities = NegotiatedPathCapabilities {
3296                acp_control: true,
3297                ..NegotiatedPathCapabilities::default()
3298            };
3299            assert!(reject_unnegotiated_outbound_path(request, capabilities).is_ok());
3300        }
3301    }
3302
3303    #[test]
3304    fn unrecognized_required_capability_refuses_naming_itself_not_workspace_file_read() {
3305        // Every capability `NodeRequest::required_capability()` can return
3306        // today has a dedicated arm now, so this reaches straight for the
3307        // fallback that stands in for tomorrow's capability -- exactly the
3308        // shape of the defect that shipped: a capability string with no
3309        // dedicated arm yet must never borrow another capability's message.
3310        let message = unnegotiated_capability_refusal(Some("future-control-v9"));
3311        assert_ne!(message, WORKSPACE_FILE_READ_NOT_NEGOTIATED);
3312        assert!(
3313            message.contains("future-control-v9"),
3314            "refusal must name the unrecognised capability, got: {message}",
3315        );
3316
3317        // The one capability the fallback must never again be mistaken for.
3318        let workspace_file_read_message =
3319            unnegotiated_capability_refusal(Some(C2_WORKSPACE_FILE_READ_CAPABILITY));
3320        assert_eq!(workspace_file_read_message, WORKSPACE_FILE_READ_NOT_NEGOTIATED);
3321        assert_ne!(workspace_file_read_message, message);
3322    }
3323
3324    use hatchery_core::{
3325        ArchitectureId, C2GitSnapshot, C2WorkspaceInspection, C2WorkspaceSnapshot,
3326        C2ServerChallenge, HostDescriptor, HostDirectoryEntry, HostDirectoryListing,
3327        NodeCursor, NodeId, OpaqueHostPath,
3328        OperatingSystemId, PathEncoding, PathSemantics, PathStyle, RepositoryPath,
3329        ResolvedSpawnReceipt, SpawnDeadlineMs, SpawnFieldProvenance,
3330        SpawnIdempotencyKey, SpawnOverrides, SpawnProfileId, SpawnProfileRevision,
3331        SpawnPromptMetadata, SpawnRequiredCapabilities, SpawnResolutionProvenance,
3332        SpawnSpec, SpawnTarget, WorkspaceFileContent, WorkspaceFileRead,
3333        WorktreeServiceMode,
3334    };
3335    use gate4agent_node_protocol::{
3336        GitStatusEntry, NodeIncarnationId, SessionMode, WorkspaceEntry, WorkspaceEntryKind,
3337        WorkspaceId,
3338    };
3339    use gate4agent_types::{
3340        AgentInstanceId, ProviderInteractionResponse, ProviderSessionIdentity,
3341        ProviderSessionKey, PtyScreenState, SessionGeneration, TerminalFrame, TerminalSize,
3342    };
3343    use std::sync::atomic::{AtomicU64, Ordering};
3344    use std::time::{SystemTime, UNIX_EPOCH};
3345    use tokio::net::windows::named_pipe::ServerOptions;
3346
3347    fn unique_control_endpoint() -> String {
3348        static NEXT: AtomicU64 = AtomicU64::new(1);
3349        let now = SystemTime::now()
3350            .duration_since(UNIX_EPOCH)
3351            .unwrap()
3352            .as_nanos();
3353        format!(
3354            r"\\.\pipe\hatchery-c2-client-strict-{}-{now}-{}",
3355            std::process::id(),
3356            NEXT.fetch_add(1, Ordering::Relaxed),
3357        )
3358    }
3359
3360    fn routed_event(sequence: u64) -> RoutedNodeEvent {
3361        RoutedNodeEvent {
3362            node_id: NodeId::new("node-a").unwrap(),
3363            cursor: NodeCursor {
3364                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3365                sequence,
3366            },
3367            event: hatchery_core::C2NodeEvent::ResyncRequired {
3368                oldest_available_sequence: sequence,
3369            },
3370        }
3371    }
3372
3373    fn event(sequence: u64) -> OwnerInput {
3374        OwnerInput::Frame(C2ServerFrame::Event(routed_event(sequence)))
3375    }
3376
3377    fn ordinary_state_event(sequence: u64) -> OwnerInput {
3378        OwnerInput::Frame(C2ServerFrame::Event(RoutedNodeEvent {
3379            node_id: NodeId::new("node-a").unwrap(),
3380            cursor: NodeCursor {
3381                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3382                sequence,
3383            },
3384            event: C2NodeEvent::WorkspaceRemoved {
3385                workspace_id: WorkspaceId::new("retired").unwrap(),
3386            },
3387        }))
3388    }
3389
3390    fn terminal_event(sequence: u64) -> OwnerInput {
3391        OwnerInput::Frame(C2ServerFrame::Event(RoutedNodeEvent {
3392            node_id: NodeId::new("node-a").unwrap(),
3393            cursor: NodeCursor {
3394                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3395                sequence,
3396            },
3397            event: terminal_node_event(sequence),
3398        }))
3399    }
3400
3401    fn harness_mcp_event(call_index: usize, deadline_unix_ms: u64) -> OwnerInput {
3402        OwnerInput::Frame(C2ServerFrame::Event(RoutedNodeEvent {
3403            node_id: NodeId::new("node-a").unwrap(),
3404            cursor: NodeCursor {
3405                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3406                sequence: 41,
3407            },
3408            event: C2NodeEvent::HarnessMcpReadCall {
3409                reservation_id: gate4agent_node_protocol::HarnessMcpReservationId::new(
3410                    format!("hmcpres_{:024x}", 1),
3411                ).unwrap(),
3412                activation_digest: gate4agent_node_protocol::HarnessMcpActivationDigest::new(
3413                    format!("sha256:{}", "b".repeat(64)),
3414                ).unwrap(),
3415                record_id: gate4agent_node_protocol::SessionRecordId::new(
3416                    "session-001",
3417                ).unwrap(),
3418                session: gate4agent_node_protocol::SessionAddress {
3419                    workspace_id: WorkspaceId::new("primary").unwrap(),
3420                    session: gate4agent_node_protocol::SessionKey {
3421                        instance_id: AgentInstanceId(7),
3422                        generation: SessionGeneration(2),
3423                    },
3424                },
3425                call_id: gate4agent_node_protocol::HarnessMcpCallId::new(
3426                    format!("hmcpcall_{call_index:024x}"),
3427                ).unwrap(),
3428                request: gate4agent_node_protocol::HarnessMcpOpaquePayloadV1 {
3429                    content_type:
3430                        gate4agent_node_protocol::HarnessMcpContentTypeV1::HarnessReadRequestJsonV1,
3431                    body: br#"{"kind":"context-get"}"#.to_vec(),
3432                },
3433                deadline_unix_ms,
3434            },
3435        }))
3436    }
3437
3438    fn terminal_node_event(sequence: u64) -> C2NodeEvent {
3439        C2NodeEvent::TerminalFrame {
3440            address: gate4agent_node_protocol::SessionAddress {
3441                workspace_id: WorkspaceId::new("primary").unwrap(),
3442                session: gate4agent_node_protocol::SessionKey {
3443                    instance_id: AgentInstanceId(7),
3444                    generation: SessionGeneration(2),
3445                },
3446            },
3447            frame: TerminalFrame {
3448                sequence,
3449                size: TerminalSize { rows: 24, columns: 80 },
3450                cursor_row: 1,
3451                cursor_column: 2,
3452                contents: "ready".to_owned(),
3453                formatted: b"ready".to_vec(),
3454                scrollback_formatted: Vec::new(),
3455                alternate_screen: false,
3456                mouse_protocol_enabled: false,
3457                mouse_protocol_encoding: Default::default(),
3458                produced_at_unix_ms: 0,
3459                screen_state: PtyScreenState::default(),
3460                bracketed_paste: None,
3461            },
3462        }
3463    }
3464
3465    fn route() -> NodeRoute {
3466        NodeRoute {
3467            node_id: NodeId::new("node-a").unwrap(),
3468            expected_incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3469        }
3470    }
3471
3472    #[tokio::test]
3473    async fn linearized_start_request_is_bounded_closed_and_exactly_correlated() {
3474        let (commands, mut receiver) = mpsc::channel(1);
3475        let first = start_control_request(
3476            &commands,
3477            all_path_capabilities(),
3478            route(),
3479            NodeRequest::Snapshot,
3480        ).unwrap();
3481        let full = start_control_request(
3482            &commands,
3483            all_path_capabilities(),
3484            route(),
3485            NodeRequest::Snapshot,
3486        );
3487        assert!(matches!(full, Err(C2ControlError::QueueFull)));
3488
3489        let queued = receiver.recv().await.unwrap();
3490        assert_eq!(queued.route, route());
3491        assert!(matches!(queued.request, NodeRequest::Snapshot));
3492        let exact = RoutedNodeResponse {
3493            node_id: route().node_id,
3494            incarnation_id: route().expected_incarnation_id,
3495            response: Ok(C2NodeResponse::Accepted),
3496        };
3497        queued.reply.send(Ok(exact.clone())).unwrap();
3498        assert_eq!(first.finish().await.unwrap(), exact);
3499
3500        let (closed_commands, closed_receiver) = mpsc::channel(1);
3501        drop(closed_receiver);
3502        let closed = start_control_request(
3503            &closed_commands,
3504            all_path_capabilities(),
3505            route(),
3506            NodeRequest::Snapshot,
3507        );
3508        assert!(matches!(closed, Err(C2ControlError::Closed)));
3509    }
3510
3511    fn spawn_spec(node_id: &str) -> SpawnSpec {
3512        SpawnSpec {
3513            target: SpawnTarget {
3514                node_id: NodeId::new(node_id).unwrap(),
3515                workspace_id: WorkspaceId::new("primary").unwrap(),
3516                worktree_id: None,
3517            },
3518            profile_id: SpawnProfileId::new("default").unwrap(),
3519            expected_profile_revision:
3520                SpawnProfileRevision::new("default.r1").unwrap(),
3521            overrides: SpawnOverrides::default(),
3522            deadline_ms: SpawnDeadlineMs::new(5_000).unwrap(),
3523            idempotency_key: SpawnIdempotencyKey::new("spawn-1").unwrap(),
3524            required_capabilities: SpawnRequiredCapabilities::default(),
3525        }
3526    }
3527
3528    fn spawn_receipt() -> ResolvedSpawnReceipt {
3529        ResolvedSpawnReceipt {
3530            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3531            session: gate4agent_node_protocol::SessionAddress {
3532                workspace_id: WorkspaceId::new("primary").unwrap(),
3533                session: gate4agent_node_protocol::SessionKey {
3534                    instance_id: AgentInstanceId(7),
3535                    generation: SessionGeneration(2),
3536                },
3537            },
3538            target: spawn_spec("node-a").target,
3539            profile_id: SpawnProfileId::new("default").unwrap(),
3540            profile_revision: SpawnProfileRevision::new("r1").unwrap(),
3541            provider: hatchery_core::AgentId::new("claude").unwrap(),
3542            mode: SessionMode::Pty,
3543            terminal_size: TerminalSize { rows: 24, columns: 80 },
3544            prompt: SpawnPromptMetadata { present: false, byte_len: 0 },
3545            bundle_id: None,
3546            bundle: None,
3547            context_id: None,
3548            context: None,
3549            environment_profile: None,
3550            deadline_ms: SpawnDeadlineMs::new(5_000).unwrap(),
3551            idempotency_key: SpawnIdempotencyKey::new("spawn-1").unwrap(),
3552            required_capabilities: SpawnRequiredCapabilities::default(),
3553            provenance: SpawnResolutionProvenance {
3554                provider: SpawnFieldProvenance::Profile,
3555                mode: SpawnFieldProvenance::Profile,
3556                terminal_size: SpawnFieldProvenance::Profile,
3557                prompt: SpawnFieldProvenance::Profile,
3558                bundle_id: SpawnFieldProvenance::Profile,
3559                context_id: SpawnFieldProvenance::Profile,
3560                environment_profile_id: SpawnFieldProvenance::Profile,
3561            },
3562            harness_mcp_proxy: None,
3563        }
3564    }
3565
3566    fn no_path_capabilities() -> NegotiatedPathCapabilities {
3567        NegotiatedPathCapabilities {
3568            opaque_host_paths: false,
3569            repository_paths: false,
3570            workspace_file_read: false,
3571            host_directory_browse: false,
3572            standalone_workspace_lifecycle: false,
3573            provider_session_reference_index: false,
3574            provider_ids_open: false,
3575            spawn_spec_defaults_overrides: false,
3576            worktree_selection: false,
3577            managed_worktree_lifecycle: false,
3578            managed_worktree_spawn_v2: false,
3579            child_environment_profile: false,
3580            session_bundle_materialization: false,
3581            history_context_pack: false,
3582            native_session_catalog: false,
3583            native_session_catalog_paging: false,
3584            native_session_index: false,
3585            native_session_preview: false,
3586            terminal_frame_events: false,
3587            ..Default::default()
3588        }
3589    }
3590
3591    fn all_path_capabilities() -> NegotiatedPathCapabilities {
3592        NegotiatedPathCapabilities {
3593            opaque_host_paths: true,
3594            repository_paths: true,
3595            workspace_file_read: true,
3596            host_directory_browse: true,
3597            standalone_workspace_lifecycle: true,
3598            provider_session_reference_index: true,
3599            provider_ids_open: true,
3600            spawn_spec_defaults_overrides: true,
3601            spawn_profile_revision: true,
3602            worktree_selection: true,
3603            managed_worktree_lifecycle: true,
3604            managed_worktree_spawn_v2: true,
3605            child_environment_profile: true,
3606            session_bundle_materialization: true,
3607            history_context_pack: true,
3608            session_record_context_export: true,
3609            native_session_catalog: true,
3610            native_session_catalog_paging: true,
3611            native_session_index: true,
3612            native_session_preview: true,
3613            terminal_frame_events: true,
3614            agent_progress_snapshot: true,
3615            ..Default::default()
3616        }
3617    }
3618
3619    #[test]
3620    fn session_record_context_export_requires_both_capabilities_and_exact_echo() {
3621        let session = gate4agent_node_protocol::SessionAddress {
3622            workspace_id: WorkspaceId::new("primary").unwrap(),
3623            session: gate4agent_node_protocol::SessionKey {
3624                instance_id: AgentInstanceId(41),
3625                generation: SessionGeneration(3),
3626            },
3627        };
3628        let record_id = gate4agent_node_protocol::SessionRecordId::new(
3629            "record-context-41",
3630        )
3631        .unwrap();
3632        let request = NodeRequest::ExportContextPackForSessionRecord {
3633            record_id: record_id.clone(),
3634            session: session.clone(),
3635        };
3636        let mut capabilities = NegotiatedPathCapabilities {
3637            session_record_context_export: true,
3638            ..NegotiatedPathCapabilities::default()
3639        };
3640        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_err());
3641        capabilities.history_context_pack = true;
3642        capabilities.session_record_context_export = false;
3643        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_err());
3644        capabilities.session_record_context_export = true;
3645        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_ok());
3646
3647        let context = gate4agent_node_protocol::ResolvedContextPackReceipt {
3648            id: gate4agent_node_protocol::SpawnContextId::new("context-record-41").unwrap(),
3649            digest: gate4agent_node_protocol::SpawnContextDigest::new(format!(
3650                "sha256:{}",
3651                "a".repeat(64),
3652            ))
3653            .unwrap(),
3654            lineage: gate4agent_node_protocol::ContextPackLineageReceipt {
3655                source_node_id: NodeId::new("node-a").unwrap(),
3656                source_session: session.clone(),
3657                source_provider: gate4agent_types::AgentId::new("claude").unwrap(),
3658            },
3659            source_message_count: 2,
3660            retained_message_count: 2,
3661            byte_len: 64,
3662            truncated: false,
3663        };
3664        let response = Ok(RoutedNodeResponse {
3665            node_id: NodeId::new("node-a").unwrap(),
3666            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3667            response: Ok(C2NodeResponse::ContextPackForSessionRecordExported {
3668                record_id: record_id.clone(),
3669                session: session.clone(),
3670                context: context.clone(),
3671            }),
3672        });
3673        assert!(validate_session_record_context_export_response(&request, &response).is_ok());
3674        let mismatch = Ok(RoutedNodeResponse {
3675            node_id: NodeId::new("node-a").unwrap(),
3676            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3677            response: Ok(C2NodeResponse::ContextPackForSessionRecordExported {
3678                record_id: gate4agent_node_protocol::SessionRecordId::new("record-other")
3679                    .unwrap(),
3680                session,
3681                context,
3682            }),
3683        });
3684        assert!(validate_session_record_context_export_response(&request, &mismatch).is_err());
3685    }
3686
3687    #[test]
3688    fn native_session_response_correlation_is_fail_closed() {
3689        let node_id = NodeId::new("node-a").unwrap();
3690        let incarnation_id = NodeIncarnationId::from_bytes([7; 16]);
3691        let route = gate4agent_node_protocol::NativeSessionCatalogRoute::workspace(
3692            WorkspaceId::new("primary").unwrap(),
3693            gate4agent_types::AgentId::new("codex").unwrap(),
3694        );
3695        let selection = gate4agent_node_protocol::NativeSessionSelection {
3696            route: route.clone(),
3697            catalog_revision: 7,
3698            recent_cutoff_unix_ms: 70,
3699            selection_id: "selection-7".to_owned(),
3700        };
3701        let routed = |response| {
3702            Ok(RoutedNodeResponse {
3703                node_id: node_id.clone(),
3704                incarnation_id,
3705                response: Ok(response),
3706            })
3707        };
3708
3709        let catalog = NodeRequest::CatalogNativeSessions {
3710            route: route.clone(),
3711            limit: 10,
3712        };
3713        assert!(validate_native_session_response(
3714            &catalog,
3715            &routed(C2NodeResponse::NativeSessionsCataloged {
3716                route: route.clone(),
3717                entries: Vec::new(),
3718                summary: None,
3719            }),
3720        )
3721        .is_ok());
3722        let wrong_route = gate4agent_node_protocol::NativeSessionCatalogRoute::workspace(
3723            WorkspaceId::new("other").unwrap(),
3724            gate4agent_types::AgentId::new("codex").unwrap(),
3725        );
3726        assert!(validate_native_session_response(
3727            &catalog,
3728            &routed(C2NodeResponse::NativeSessionsCataloged {
3729                route: wrong_route,
3730                entries: Vec::new(),
3731                summary: None,
3732            }),
3733        )
3734        .is_err());
3735
3736        let page = NodeRequest::PageNativeSessions {
3737            route: route.clone(),
3738            window: gate4agent_node_protocol::NativeSessionCatalogWindow::Recent,
3739            catalog_revision: 7,
3740            recent_cutoff_unix_ms: 70,
3741            after_selection_id: None,
3742            limit: 10,
3743        };
3744        assert!(validate_native_session_response(
3745            &page,
3746            &routed(C2NodeResponse::NativeSessionsPaged {
3747                route: route.clone(),
3748                page: gate4agent_node_protocol::NativeSessionCatalogPage {
3749                    window: gate4agent_node_protocol::NativeSessionCatalogWindow::Recent,
3750                    revision: 8,
3751                    entries: Vec::new(),
3752                    next_after_selection_id: None,
3753                    remaining_count: 0,
3754                    has_more: false,
3755                },
3756            }),
3757        )
3758        .is_err());
3759
3760        let preview = NodeRequest::PreviewNativeSession {
3761            selection: selection.clone(),
3762            message_limit: 10,
3763        };
3764        let mut wrong_selection = selection.clone();
3765        wrong_selection.catalog_revision = 8;
3766        assert!(validate_native_session_response(
3767            &preview,
3768            &routed(C2NodeResponse::NativeSessionPreviewed {
3769                selection: wrong_selection,
3770                preview: gate4agent_node_protocol::SessionRecordPreview {
3771                    title: None,
3772                    modified_at_unix_ms: None,
3773                    model: None,
3774                    message_count: 0,
3775                    message_count_exact: true,
3776                    completed_turn_count: None,
3777                    total_tokens: None,
3778                    truncated: false,
3779                    messages: Vec::new(),
3780                },
3781            }),
3782        )
3783        .is_err());
3784
3785        let index = NodeRequest::IndexNativeSession {
3786            selection: selection.clone(),
3787            display_name: "Indexed".to_owned(),
3788        };
3789        let record = hatchery_core::C2ManagedSessionRecord {
3790            record_id: gate4agent_node_protocol::SessionRecordId::new("record-7").unwrap(),
3791            display_name: "Indexed".to_owned(),
3792            provider: gate4agent_types::AgentId::new("codex").unwrap(),
3793            mode: SessionMode::Pty,
3794            state: gate4agent_node_protocol::ManagedSessionState::Dormant,
3795            workspace_id: WorkspaceId::new("primary").unwrap(),
3796            active_session: None,
3797            environment_profile: None,
3798            bundle: None,
3799            context_id: None,
3800            context: None,
3801            exported_context: None,
3802            task_binding: None,
3803            provider_identity_present: true,
3804            created_at_unix_ms: 1,
3805            updated_at_unix_ms: 1,
3806        };
3807        assert!(validate_native_session_response(
3808            &index,
3809            &routed(C2NodeResponse::NativeSessionIndexed {
3810                selection: selection.clone(),
3811                record: record.clone(),
3812            }),
3813        )
3814        .is_ok());
3815        assert!(validate_native_session_response(
3816            &index,
3817            &routed(C2NodeResponse::ProviderSessionIndexed {
3818                record: record.clone(),
3819            }),
3820        )
3821        .is_err());
3822        let mut wrong_echo = selection.clone();
3823        wrong_echo.catalog_revision = 8;
3824        assert!(validate_native_session_response(
3825            &index,
3826            &routed(C2NodeResponse::NativeSessionIndexed {
3827                selection: wrong_echo,
3828                record: record.clone(),
3829            }),
3830        )
3831        .is_err());
3832        let mut wrong_provider = record.clone();
3833        wrong_provider.provider = gate4agent_types::AgentId::new("claude").unwrap();
3834        assert!(validate_native_session_response(
3835            &index,
3836            &routed(C2NodeResponse::NativeSessionIndexed {
3837                selection: selection.clone(),
3838                record: wrong_provider,
3839            }),
3840        )
3841        .is_err());
3842        let mut wrong_workspace = record.clone();
3843        wrong_workspace.workspace_id = WorkspaceId::new("other").unwrap();
3844        assert!(validate_native_session_response(
3845            &index,
3846            &routed(C2NodeResponse::NativeSessionIndexed {
3847                selection: selection.clone(),
3848                record: wrong_workspace,
3849            }),
3850        )
3851        .is_err());
3852
3853        let provider_index = NodeRequest::IndexProviderSession {
3854            workspace_id: WorkspaceId::new("primary").unwrap(),
3855            provider: gate4agent_types::AgentId::new("codex").unwrap(),
3856            identity: gate4agent_types::ProviderSessionIdentity {
3857                key: gate4agent_types::ProviderSessionKey::SessionId,
3858                id: "provider-session-7".to_owned(),
3859                transcript_path: None,
3860            },
3861            display_name: "Indexed".to_owned(),
3862        };
3863        assert!(validate_provider_session_index_response(
3864            &provider_index,
3865            &routed(C2NodeResponse::ProviderSessionIndexed {
3866                record: record.clone(),
3867            }),
3868        )
3869        .is_ok());
3870        assert!(validate_provider_session_index_response(
3871            &provider_index,
3872            &routed(C2NodeResponse::NativeSessionIndexed {
3873                selection,
3874                record: record.clone(),
3875            }),
3876        )
3877        .is_err());
3878        assert!(validate_provider_session_index_response(
3879            &catalog,
3880            &routed(C2NodeResponse::ProviderSessionIndexed { record }),
3881        )
3882        .is_err());
3883    }
3884
3885    fn reply(request_id: u64) -> OwnerInput {
3886        OwnerInput::Frame(C2ServerFrame::Reply(hatchery_core::C2ReplyEnvelope {
3887            request_id: C2RequestId(request_id),
3888            result: Ok(RoutedNodeResponse {
3889                node_id: NodeId::new("node-a").unwrap(),
3890                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3891                response: Ok(hatchery_core::C2NodeResponse::Accepted),
3892            }),
3893            correlation_id: None,
3894        }))
3895    }
3896
3897    fn terminal_resync_reply(request_id: u64, sequence: u64) -> OwnerInput {
3898        OwnerInput::Frame(C2ServerFrame::Reply(hatchery_core::C2ReplyEnvelope {
3899            request_id: C2RequestId(request_id),
3900            result: Ok(RoutedNodeResponse {
3901                node_id: NodeId::new("node-a").unwrap(),
3902                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3903                response: Ok(C2NodeResponse::Resync {
3904                    event_sequence: sequence,
3905                    oldest_available_sequence: 1,
3906                    snapshot: hatchery_core::C2NodeSnapshot {
3907                        node_id: NodeId::new("node-a").unwrap(),
3908                        enabled_providers: Vec::new(),
3909                        provider_runtime_statuses: Default::default(),
3910                        workspaces: Vec::new(),
3911                        session_records: Vec::new(),
3912                        agent_progress: Vec::new(),
3913                        managed_worktrees: Vec::new(),
3914                        launch_inventory: None,
3915
3916                    machine: None,},
3917                    events: vec![hatchery_core::C2NodeEventEnvelope {
3918                        sequence,
3919                        event: terminal_node_event(sequence),
3920                    }],
3921                }),
3922            }),
3923            correlation_id: None,
3924        }))
3925    }
3926
3927    fn unix_path() -> OpaqueHostPath {
3928        OpaqueHostPath::unix_bytes(vec![b'/', b's', b'r', b'v', b'/', 0xff]).unwrap()
3929    }
3930
3931    fn unix_path_reply(request_id: u64) -> OwnerInput {
3932        OwnerInput::Frame(C2ServerFrame::Reply(hatchery_core::C2ReplyEnvelope {
3933            request_id: C2RequestId(request_id),
3934            result: Ok(RoutedNodeResponse {
3935                node_id: NodeId::new("node-a").unwrap(),
3936                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3937                response: Ok(C2NodeResponse::WorktreeRemoved {
3938                    target_root: unix_path(),
3939                    workspace_id: None,
3940                }),
3941            }),
3942            correlation_id: None,
3943        }))
3944    }
3945
3946    fn repository_path(value: &str) -> RepositoryPath {
3947        RepositoryPath::utf8(value.to_owned()).unwrap()
3948    }
3949
3950    fn unix_repository_path(value: &[u8]) -> RepositoryPath {
3951        RepositoryPath::unix_bytes(value.to_vec()).unwrap()
3952    }
3953
3954    fn repository_inspection_response(
3955        entry_path: RepositoryPath,
3956        status_path: RepositoryPath,
3957        previous_path: Option<RepositoryPath>,
3958    ) -> C2NodeResponse {
3959        C2NodeResponse::WorkspaceInspected {
3960            inspection: C2WorkspaceInspection {
3961                workspace_id: WorkspaceId::new("foreign").unwrap(),
3962                entries: vec![WorkspaceEntry {
3963                    relative_path: entry_path,
3964                    kind: WorkspaceEntryKind::File,
3965                }],
3966                tree_truncated: false,
3967                git: C2GitSnapshot {
3968                    is_repository: true,
3969                    branch: Some("main".to_owned()),
3970                    status: vec![GitStatusEntry {
3971                        index_status: " ".to_owned(),
3972                        worktree_status: "M".to_owned(),
3973                        path: status_path,
3974                        previous_path,
3975                    }],
3976                    recent_commits: Vec::new(),
3977                    worktrees: Vec::new(),
3978                    managed_worktree: None,
3979                    truncated: false,
3980                    diagnostic_present: false,
3981                },
3982                truncation: None,
3983            },
3984        }
3985    }
3986
3987    fn unix_repository_path_reply(request_id: u64) -> OwnerInput {
3988        OwnerInput::Frame(C2ServerFrame::Reply(hatchery_core::C2ReplyEnvelope {
3989            request_id: C2RequestId(request_id),
3990            result: Ok(RoutedNodeResponse {
3991                node_id: NodeId::new("node-a").unwrap(),
3992                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3993                response: Ok(repository_inspection_response(
3994                    unix_repository_path(b"src/\xff"),
3995                    repository_path("src/main.rs"),
3996                    None,
3997                )),
3998            }),
3999            correlation_id: None,
4000        }))
4001    }
4002
4003    fn unix_path_event(sequence: u64) -> OwnerInput {
4004        OwnerInput::Frame(C2ServerFrame::Event(RoutedNodeEvent {
4005            node_id: NodeId::new("node-a").unwrap(),
4006            cursor: NodeCursor {
4007                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
4008                sequence,
4009            },
4010            event: C2NodeEvent::WorkspaceAdded {
4011                workspace: C2WorkspaceSnapshot {
4012                    workspace_id: WorkspaceId::new("foreign").unwrap(),
4013                    canonical_root: unix_path(),
4014                    sessions: Vec::new(),
4015                    worktree_service_mode: None,
4016                    managed_worktree_profiles: None,
4017                },
4018            },
4019        }))
4020    }
4021
4022    #[test]
4023    fn c2_control_client_offer_is_exact_v2_with_authenticated_opt_ins() {
4024        let offer = client_compatibility_offer().unwrap();
4025
4026        assert_eq!(offer.build_stamp, BUILD_STAMP);
4027        assert_eq!(
4028            offer.capabilities,
4029            vec![
4030                CapabilityId::new(C2_COMPATIBILITY_METADATA_CAPABILITY).unwrap(),
4031                CapabilityId::new(C2_OPAQUE_UNIX_PATH_CAPABILITY).unwrap(),
4032                CapabilityId::new(C2_REPOSITORY_PATH_CAPABILITY).unwrap(),
4033                CapabilityId::new(C2_WORKSPACE_FILE_READ_CAPABILITY).unwrap(),
4034                CapabilityId::new(C2_WORKSPACE_FILE_WRITE_CAPABILITY).unwrap(),
4035                CapabilityId::new(C2_WORKSPACE_ENTRY_CREATE_CAPABILITY).unwrap(),
4036                CapabilityId::new(C2_GIT_READ_CAPABILITY).unwrap(),
4037                CapabilityId::new(C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).unwrap(),
4038                CapabilityId::new(C2_PROVIDER_RUNTIME_STATUS_CAPABILITY).unwrap(),
4039                CapabilityId::new(C2_PROVIDER_ID_OPEN_CAPABILITY).unwrap(),
4040                CapabilityId::new(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap(),
4041                CapabilityId::new(C2_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap(),
4042                CapabilityId::new(C2_TERMINAL_FRAME_EVENTS_CAPABILITY).unwrap(),
4043                CapabilityId::new(C2_AGENT_STREAM_EVENTS_CAPABILITY).unwrap(),
4044                CapabilityId::new(C2_WORKTREE_SELECTION_CAPABILITY).unwrap(),
4045                CapabilityId::new(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY).unwrap(),
4046                CapabilityId::new(C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY).unwrap(),
4047                CapabilityId::new(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY).unwrap(),
4048                CapabilityId::new(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap(),
4049                CapabilityId::new(C2_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap(),
4050                CapabilityId::new(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY).unwrap(),
4051                CapabilityId::new(C2_NATIVE_SESSION_CATALOG_CAPABILITY).unwrap(),
4052                CapabilityId::new(C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY).unwrap(),
4053                CapabilityId::new(C2_NATIVE_SESSION_INDEX_CAPABILITY).unwrap(),
4054                CapabilityId::new(C2_NATIVE_SESSION_PREVIEW_CAPABILITY).unwrap(),
4055                CapabilityId::new(C2_HOST_DIRECTORY_BROWSE_CAPABILITY).unwrap(),
4056                CapabilityId::new(C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY).unwrap(),
4057                CapabilityId::new(C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY).unwrap(),
4058                CapabilityId::new(C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY).unwrap(),
4059                CapabilityId::new(C2_SESSION_TASK_CORRELATION_CAPABILITY).unwrap(),
4060                CapabilityId::new(C2_CONTROL_DETAIL_CAPABILITY).unwrap(),
4061                CapabilityId::new(C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY).unwrap(),
4062                CapabilityId::new(C2_HARNESS_MCP_READ_PROXY_CAPABILITY).unwrap(),
4063                CapabilityId::new(C2_ACP_CONTROL_CAPABILITY).unwrap(),
4064            ],
4065        );
4066        assert_eq!(offer.state_schema, None);
4067        assert!(matches!(
4068            validate_selected_compatibility(&offer, None),
4069            Err(C2ControlError::Protocol(_)),
4070        ));
4071    }
4072
4073    #[tokio::test]
4074    async fn host_directory_browse_preserves_route_and_requires_negotiated_capability() {
4075        let request = NodeRequest::BrowseHostDirectories {
4076            directory: Some(OpaqueHostPath::utf8(r"C:\Users".to_owned()).unwrap()),
4077            after: Some(OpaqueHostPath::utf8(r"C:\Users\Public".to_owned()).unwrap()),
4078        };
4079        let (commands_tx, commands_rx) = mpsc::channel(1);
4080        let (events_tx, _events_rx) = mpsc::channel(1);
4081        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4082        let (incoming_tx, incoming_rx) = mpsc::channel(1);
4083        let (topology_tx, _topology_rx) =
4084            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4085        let owner = tokio::spawn(control_owner(
4086            commands_rx,
4087            events_tx,
4088            topology_tx,
4089            writer_tx,
4090            incoming_rx,
4091            all_path_capabilities(),
4092        ));
4093        let (reply_tx, reply_rx) = oneshot::channel();
4094        commands_tx.send(ControlCommand {
4095            route: route(),
4096            request: request.clone(),
4097            reply: reply_tx,
4098            correlation_id: None,
4099        }).await.unwrap();
4100        let Some(C2ClientFrame::Request(envelope)) = writer_rx.recv().await else {
4101            panic!("host directory browse was not written");
4102        };
4103        assert_eq!(envelope.request.route, route());
4104        assert_eq!(envelope.request.request, request);
4105        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Reply(
4106            hatchery_core::C2ReplyEnvelope {
4107                request_id: envelope.request_id,
4108                result: Ok(RoutedNodeResponse {
4109                    node_id: route().node_id,
4110                    incarnation_id: route().expected_incarnation_id,
4111                    response: Ok(C2NodeResponse::HostDirectoriesBrowsed {
4112                        listing: HostDirectoryListing {
4113                            directory: None,
4114                            parent: None,
4115                            entries: vec![HostDirectoryEntry {
4116                                path: OpaqueHostPath::utf8(r"C:\Users".to_owned()).unwrap(),
4117                                display_name: "Users".to_owned(),
4118                                is_link: false,
4119                            }],
4120                            next_after: None,
4121                            incomplete: false,
4122                        },
4123                    }),
4124                }),
4125                correlation_id: None,
4126            },
4127        ))).await.unwrap();
4128        assert!(matches!(
4129            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
4130            Ok(RoutedNodeResponse {
4131                response: Ok(C2NodeResponse::HostDirectoriesBrowsed { .. }),
4132                ..
4133            })
4134        ));
4135        drop(commands_tx);
4136        drop(incoming_tx);
4137        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4138
4139        let (commands_tx, commands_rx) = mpsc::channel(1);
4140        let (events_tx, _events_rx) = mpsc::channel(1);
4141        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4142        let (incoming_tx, incoming_rx) = mpsc::channel(1);
4143        let (topology_tx, _topology_rx) =
4144            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4145        let owner = tokio::spawn(control_owner(
4146            commands_rx,
4147            events_tx,
4148            topology_tx,
4149            writer_tx,
4150            incoming_rx,
4151            no_path_capabilities(),
4152        ));
4153        let (reply_tx, reply_rx) = oneshot::channel();
4154        commands_tx.send(ControlCommand {
4155            route: route(),
4156            request: NodeRequest::Snapshot,
4157            reply: reply_tx,
4158            correlation_id: None,
4159        }).await.unwrap();
4160        let Some(C2ClientFrame::Request(envelope)) = writer_rx.recv().await else {
4161            panic!("snapshot request was not written");
4162        };
4163        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Reply(
4164            hatchery_core::C2ReplyEnvelope {
4165                request_id: envelope.request_id,
4166                result: Ok(RoutedNodeResponse {
4167                    node_id: route().node_id,
4168                    incarnation_id: route().expected_incarnation_id,
4169                    response: Ok(C2NodeResponse::HostDirectoriesBrowsed {
4170                        listing: HostDirectoryListing {
4171                            directory: None,
4172                            parent: None,
4173                            entries: Vec::new(),
4174                            next_after: None,
4175                            incomplete: false,
4176                        },
4177                    }),
4178                }),
4179                correlation_id: None,
4180            },
4181        ))).await.unwrap();
4182        assert!(matches!(
4183            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
4184            Err(C2ControlError::Protocol(ref message))
4185                if message == HOST_DIRECTORY_BROWSE_NOT_NEGOTIATED
4186        ));
4187        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4188
4189        let mut capabilities = all_path_capabilities();
4190        capabilities.host_directory_browse = false;
4191        assert!(matches!(
4192            reject_unnegotiated_outbound_path(
4193                &NodeRequest::BrowseHostDirectories {
4194                    directory: None,
4195                    after: None,
4196                },
4197                capabilities,
4198            ),
4199            Err(C2ControlError::Protocol(ref message))
4200                if message == HOST_DIRECTORY_BROWSE_NOT_NEGOTIATED
4201        ));
4202        assert!(node_request_has_unix_bytes(&NodeRequest::BrowseHostDirectories {
4203            directory: None,
4204            after: Some(OpaqueHostPath::unix_bytes(b"/srv/\xff".to_vec()).unwrap()),
4205        }));
4206    }
4207
4208    #[tokio::test]
4209    async fn standalone_workspace_roundtrip_preserves_route_snapshot_and_fails_closed() {
4210        let request = NodeRequest::CreateStandaloneWorkspace {
4211            workspace_id: WorkspaceId::new("standalone").unwrap(),
4212            root: OpaqueHostPath::utf8(r"C:\standalone".to_owned()).unwrap(),
4213            initial_branch: Some("main".to_owned()),
4214        };
4215        assert_eq!(control_request_deadline(&request), Duration::from_secs(245));
4216        let mut legacy = all_path_capabilities();
4217        legacy.standalone_workspace_lifecycle = false;
4218        assert!(matches!(
4219            reject_unnegotiated_outbound_path(&request, legacy),
4220            Err(C2ControlError::Protocol(ref message))
4221                if message == STANDALONE_WORKSPACE_LIFECYCLE_NOT_NEGOTIATED
4222        ));
4223
4224        let (commands_tx, commands_rx) = mpsc::channel(1);
4225        let (events_tx, _events_rx) = mpsc::channel(1);
4226        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4227        let (incoming_tx, incoming_rx) = mpsc::channel(1);
4228        let (topology_tx, _topology_rx) =
4229            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4230        let owner = tokio::spawn(control_owner(
4231            commands_rx,
4232            events_tx,
4233            topology_tx,
4234            writer_tx,
4235            incoming_rx,
4236            all_path_capabilities(),
4237        ));
4238        let (reply_tx, reply_rx) = oneshot::channel();
4239        commands_tx.send(ControlCommand {
4240            route: route(),
4241            request: request.clone(),
4242            reply: reply_tx,
4243            correlation_id: None,
4244        }).await.unwrap();
4245        let Some(C2ClientFrame::Request(envelope)) = writer_rx.recv().await else {
4246            panic!("standalone workspace request was not written");
4247        };
4248        assert_eq!(envelope.request.route, route());
4249        assert_eq!(envelope.request.request, request);
4250        let workspace = C2WorkspaceSnapshot {
4251            workspace_id: WorkspaceId::new("standalone").unwrap(),
4252            canonical_root: OpaqueHostPath::utf8(r"C:\standalone".to_owned()).unwrap(),
4253            sessions: Vec::new(),
4254            worktree_service_mode: Some(WorktreeServiceMode::Manual),
4255            managed_worktree_profiles: None,
4256        };
4257        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Reply(
4258            hatchery_core::C2ReplyEnvelope {
4259                request_id: envelope.request_id,
4260                result: Ok(RoutedNodeResponse {
4261                    node_id: route().node_id,
4262                    incarnation_id: route().expected_incarnation_id,
4263                    response: Ok(C2NodeResponse::StandaloneWorkspaceCreated {
4264                        workspace: workspace.clone(),
4265                    }),
4266                }),
4267                correlation_id: None,
4268            },
4269        ))).await.unwrap();
4270        let response = timeout(Duration::from_secs(1), reply_rx)
4271            .await.unwrap().unwrap().unwrap();
4272        assert_eq!(response.node_id, route().node_id);
4273        assert_eq!(response.incarnation_id, route().expected_incarnation_id);
4274        assert!(matches!(
4275            response.response,
4276            Ok(C2NodeResponse::StandaloneWorkspaceCreated {
4277                workspace: observed,
4278            }) if observed == workspace
4279        ));
4280        drop(commands_tx);
4281        drop(incoming_tx);
4282        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4283
4284        assert!(routed_response_requires_standalone_workspace_lifecycle(
4285            &RoutedNodeResponse {
4286                node_id: route().node_id,
4287                incarnation_id: route().expected_incarnation_id,
4288                response: Ok(C2NodeResponse::StandaloneWorkspaceCreated { workspace }),
4289            },
4290        ));
4291    }
4292
4293    #[tokio::test]
4294    async fn control_owner_fails_closed_on_unnegotiated_terminal_frame_event() {
4295        let (commands_tx, commands_rx) = mpsc::channel(1);
4296        let (events_tx, mut events_rx) = mpsc::channel(1);
4297        let (writer_tx, _writer_rx) = mpsc::channel(1);
4298        let (incoming_tx, incoming_rx) = mpsc::channel(1);
4299        let (topology_tx, _topology_rx) =
4300            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4301        let owner = tokio::spawn(control_owner(
4302            commands_rx,
4303            events_tx,
4304            topology_tx,
4305            writer_tx,
4306            incoming_rx,
4307            no_path_capabilities(),
4308        ));
4309
4310        incoming_tx.send(terminal_event(8)).await.unwrap();
4311        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4312
4313        assert!(commands_tx.is_closed());
4314        assert!(matches!(
4315            events_rx.try_recv(),
4316            Err(tokio::sync::mpsc::error::TryRecvError::Disconnected),
4317        ));
4318    }
4319
4320    #[tokio::test]
4321    async fn control_owner_rejects_unnegotiated_terminal_frame_resync_reply_and_closes() {
4322        let (commands_tx, commands_rx) = mpsc::channel(1);
4323        let (events_tx, mut events_rx) = mpsc::channel(1);
4324        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4325        let (incoming_tx, incoming_rx) = mpsc::channel(1);
4326        let (topology_tx, _topology_rx) =
4327            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4328        let owner = tokio::spawn(control_owner(
4329            commands_rx,
4330            events_tx,
4331            topology_tx,
4332            writer_tx,
4333            incoming_rx,
4334            no_path_capabilities(),
4335        ));
4336        let (reply_tx, reply_rx) = oneshot::channel();
4337        commands_tx.send(ControlCommand {
4338            route: route(),
4339            request: NodeRequest::Resync { after_sequence: 7 },
4340            reply: reply_tx,
4341            correlation_id: None,
4342        }).await.unwrap();
4343        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
4344
4345        incoming_tx.send(terminal_resync_reply(1, 8)).await.unwrap();
4346        assert!(matches!(
4347            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
4348            Err(C2ControlError::Protocol(ref message))
4349                if message == TERMINAL_FRAME_EVENTS_NOT_NEGOTIATED
4350        ));
4351        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4352
4353        assert!(commands_tx.is_closed());
4354        assert!(matches!(
4355            events_rx.try_recv(),
4356            Err(tokio::sync::mpsc::error::TryRecvError::Disconnected),
4357        ));
4358    }
4359
4360    #[tokio::test]
4361    async fn saturated_terminal_event_channel_does_not_block_following_reply() {
4362        let (commands_tx, commands_rx) = mpsc::channel(1);
4363        let (events_tx, mut events_rx) = mpsc::channel(1);
4364        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4365        let (incoming_tx, incoming_rx) = mpsc::channel(1);
4366        let (topology_tx, _topology_rx) =
4367            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4368        let owner = tokio::spawn(control_owner(
4369            commands_rx,
4370            events_tx,
4371            topology_tx,
4372            writer_tx,
4373            incoming_rx,
4374            all_path_capabilities(),
4375        ));
4376
4377        incoming_tx.send(terminal_event(8)).await.unwrap();
4378        incoming_tx.send(terminal_event(9)).await.unwrap();
4379        let (reply_tx, reply_rx) = oneshot::channel();
4380        commands_tx.send(ControlCommand {
4381            route: route(),
4382            request: NodeRequest::Snapshot,
4383            reply: reply_tx,
4384            correlation_id: None,
4385        }).await.unwrap();
4386        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
4387        incoming_tx.send(reply(1)).await.unwrap();
4388
4389        let response = timeout(Duration::from_secs(1), reply_rx)
4390            .await
4391            .unwrap()
4392            .unwrap()
4393            .unwrap();
4394        assert!(matches!(response.response, Ok(C2NodeResponse::Accepted)));
4395        assert!(matches!(
4396            events_rx.try_recv(),
4397            Ok(RoutedNodeEvent {
4398                event: C2NodeEvent::TerminalFrame { .. },
4399                ..
4400            })
4401        ));
4402
4403        drop(commands_tx);
4404        drop(incoming_tx);
4405        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4406    }
4407
4408    #[tokio::test]
4409    async fn harness_mcp_burst_uses_dedicated_128_event_capacity_without_cursor_change() {
4410        let (commands_tx, commands_rx) = mpsc::channel(1);
4411        let (events_tx, _events_rx) = mpsc::channel(EVENT_CAPACITY);
4412        let (harness_tx, mut harness_rx) = mpsc::channel(HARNESS_MCP_EVENT_CAPACITY);
4413        let (writer_tx, _writer_rx) = mpsc::channel(1);
4414        let (incoming_tx, incoming_rx) = mpsc::channel(HARNESS_MCP_EVENT_CAPACITY + 1);
4415        let (topology_tx, _topology_rx) =
4416            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4417        let mut capabilities = all_path_capabilities();
4418        capabilities.harness_mcp_read_proxy = true;
4419        let deadline_unix_ms = current_unix_ms().unwrap() + 3_000;
4420        for call_index in 0..=HARNESS_MCP_EVENT_CAPACITY {
4421            incoming_tx.send(harness_mcp_event(call_index, deadline_unix_ms)).await.unwrap();
4422        }
4423        // The overflow (129th) event must not tear the connection down --
4424        // it is dropped with a warning and the connection stays open. This
4425        // test ends the connection by closing the reader input instead, so
4426        // the 128-capacity assertion below still exercises a genuinely
4427        // full (not closed) harness-MCP receiver.
4428        drop(incoming_tx);
4429        let owner = tokio::spawn(control_owner(
4430            commands_rx,
4431            mpsc::channel(1).1,
4432            EventDelivery { regular: events_tx, harness_mcp: harness_tx, browser: mpsc::channel(1).0 },
4433            topology_tx,
4434            writer_tx,
4435            incoming_rx,
4436            capabilities,
4437        ));
4438
4439        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4440        assert!(commands_tx.is_closed());
4441        let mut delivered = Vec::new();
4442        while let Ok(event) = harness_rx.try_recv() {
4443            delivered.push(event);
4444        }
4445        assert_eq!(delivered.len(), HARNESS_MCP_EVENT_CAPACITY);
4446        assert!(delivered.iter().all(|event| {
4447            event.cursor.sequence == 41
4448                && matches!(event.event, C2NodeEvent::HarnessMcpReadCall { .. })
4449        }));
4450    }
4451
4452    /// Item 3's direct proof: a sustained full-channel backlog -- a "gap
4453    /// episode" of dropped events -- logs exactly once at its start, not
4454    /// once per dropped event, and a fresh episode after recovery earns its
4455    /// own log line again. `control_owner`'s `harness_mcp`/terminal-frame
4456    /// arms both gate their warn through this exact function.
4457    #[test]
4458    fn channel_full_drop_logs_once_per_gap_episode_then_resets() {
4459        let mut logged = false;
4460        assert!(
4461            should_log_channel_full_drop(&mut logged),
4462            "the first drop of a new episode must log",
4463        );
4464        assert!(
4465            !should_log_channel_full_drop(&mut logged),
4466            "a second drop in the same still-unreported episode must not log again",
4467        );
4468        assert!(
4469            !should_log_channel_full_drop(&mut logged),
4470            "nor a third, no matter how long the episode runs",
4471        );
4472
4473        reset_channel_full_drop_episode(&mut logged);
4474
4475        assert!(
4476            should_log_channel_full_drop(&mut logged),
4477            "a drop in a NEW episode, after the channel recovered, logs again",
4478        );
4479    }
4480
4481    #[tokio::test]
4482    async fn harness_mcp_full_channel_drops_the_event_but_a_pending_request_still_completes() {
4483        let (commands_tx, commands_rx) = mpsc::channel(1);
4484        let (events_tx, _events_rx) = mpsc::channel(EVENT_CAPACITY);
4485        // Capacity 1, never drained: the very first harness-MCP event
4486        // fills it, so the second one exercises the `Full` branch.
4487        let (harness_tx, _harness_rx) = mpsc::channel(1);
4488        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4489        let (incoming_tx, incoming_rx) = mpsc::channel(4);
4490        let (topology_tx, _topology_rx) =
4491            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4492        let mut capabilities = all_path_capabilities();
4493        capabilities.harness_mcp_read_proxy = true;
4494        let deadline_unix_ms = current_unix_ms().unwrap() + 3_000;
4495        let owner = tokio::spawn(control_owner(
4496            commands_rx,
4497            mpsc::channel(1).1,
4498            EventDelivery { regular: events_tx, harness_mcp: harness_tx, browser: mpsc::channel(1).0 },
4499            topology_tx,
4500            writer_tx,
4501            incoming_rx,
4502            capabilities,
4503        ));
4504
4505        incoming_tx.send(harness_mcp_event(1, deadline_unix_ms)).await.unwrap();
4506        incoming_tx.send(harness_mcp_event(2, deadline_unix_ms)).await.unwrap();
4507        tokio::task::yield_now().await;
4508
4509        // A request enqueued (and answered) AFTER the channel-full event
4510        // still gets served: the owner loop never broke.
4511        let (reply_tx, reply_rx) = oneshot::channel();
4512        commands_tx.send(ControlCommand {
4513            route: route(),
4514            request: NodeRequest::Snapshot,
4515            reply: reply_tx,
4516            correlation_id: None,
4517        }).await.unwrap();
4518        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
4519        incoming_tx.send(reply(1)).await.unwrap();
4520        assert!(matches!(
4521            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
4522            Ok(RoutedNodeResponse { response: Ok(C2NodeResponse::Accepted), .. })
4523        ));
4524
4525        drop(commands_tx);
4526        drop(incoming_tx);
4527        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4528    }
4529
4530    #[tokio::test]
4531    async fn harness_mcp_dropped_receiver_stops_that_class_but_a_pending_request_still_completes() {
4532        let (commands_tx, commands_rx) = mpsc::channel(1);
4533        let (events_tx, _events_rx) = mpsc::channel(EVENT_CAPACITY);
4534        let (harness_tx, harness_rx) = mpsc::channel(HARNESS_MCP_EVENT_CAPACITY);
4535        // Nobody ever subscribes to harness-MCP events on this connection.
4536        drop(harness_rx);
4537        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4538        let (incoming_tx, incoming_rx) = mpsc::channel(4);
4539        let (topology_tx, _topology_rx) =
4540            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4541        let mut capabilities = all_path_capabilities();
4542        capabilities.harness_mcp_read_proxy = true;
4543        let deadline_unix_ms = current_unix_ms().unwrap() + 3_000;
4544        let owner = tokio::spawn(control_owner(
4545            commands_rx,
4546            mpsc::channel(1).1,
4547            EventDelivery { regular: events_tx, harness_mcp: harness_tx, browser: mpsc::channel(1).0 },
4548            topology_tx,
4549            writer_tx,
4550            incoming_rx,
4551            capabilities,
4552        ));
4553
4554        incoming_tx.send(harness_mcp_event(1, deadline_unix_ms)).await.unwrap();
4555
4556        let (reply_tx, reply_rx) = oneshot::channel();
4557        commands_tx.send(ControlCommand {
4558            route: route(),
4559            request: NodeRequest::Snapshot,
4560            reply: reply_tx,
4561            correlation_id: None,
4562        }).await.unwrap();
4563        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
4564        incoming_tx.send(reply(1)).await.unwrap();
4565        assert!(matches!(
4566            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
4567            Ok(RoutedNodeResponse { response: Ok(C2NodeResponse::Accepted), .. })
4568        ));
4569
4570        drop(commands_tx);
4571        drop(incoming_tx);
4572        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4573    }
4574
4575    #[tokio::test]
4576    async fn genuine_pipe_loss_answers_every_pending_request_with_the_named_reason() {
4577        let (commands_tx, commands_rx) = mpsc::channel(4);
4578        let (events_tx, _events_rx) = mpsc::channel(EVENT_CAPACITY);
4579        let (writer_tx, mut writer_rx) = mpsc::channel(4);
4580        let (incoming_tx, incoming_rx) = mpsc::channel(4);
4581        let (topology_tx, _topology_rx) =
4582            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4583        let owner = tokio::spawn(control_owner(
4584            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, all_path_capabilities(),
4585        ));
4586
4587        let (reply_tx, reply_rx) = oneshot::channel();
4588        commands_tx.send(ControlCommand {
4589            route: route(),
4590            request: NodeRequest::Snapshot,
4591            reply: reply_tx,
4592            correlation_id: None,
4593        }).await.unwrap();
4594        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
4595
4596        // The reader half signals a genuine pipe loss -- no reply for the
4597        // pending Snapshot request will ever arrive.
4598        incoming_tx.send(OwnerInput::Closed).await.unwrap();
4599        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4600
4601        assert!(matches!(
4602            reply_rx.await.unwrap(),
4603            Err(C2ControlError::ConnectionLost { reason: C2ConnectionLossReason::PipeClosed }),
4604        ));
4605    }
4606
4607    #[tokio::test]
4608    async fn harness_mcp_mixed_pending_burst_preserves_durable_event_without_cursor_pollution_or_replay() {
4609        let (commands_tx, commands_rx) = mpsc::channel(1);
4610        let (events_tx, events_rx) = mpsc::channel(EVENT_CAPACITY);
4611        let (harness_tx, harness_rx) = mpsc::channel(HARNESS_MCP_EVENT_CAPACITY);
4612        let (writer_tx, _writer_rx) = mpsc::channel(1);
4613        let (incoming_tx, incoming_rx) = mpsc::channel(2);
4614        let (topology_tx, _topology_rx) =
4615            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4616        let mut capabilities = all_path_capabilities();
4617        capabilities.harness_mcp_read_proxy = true;
4618        let deadline_unix_ms = current_unix_ms().unwrap() + 3_000;
4619        incoming_tx.send(harness_mcp_event(1, deadline_unix_ms)).await.unwrap();
4620        incoming_tx.send(ordinary_state_event(42)).await.unwrap();
4621        let owner = tokio::spawn(control_owner(
4622            commands_rx,
4623            mpsc::channel(1).1,
4624            EventDelivery { regular: events_tx, harness_mcp: harness_tx, browser: mpsc::channel(1).0 },
4625            topology_tx,
4626            writer_tx,
4627            incoming_rx,
4628            capabilities,
4629        ));
4630        let mut receiver = C2EventReceiver {
4631            events: events_rx,
4632            harness_mcp_events: harness_rx,
4633        };
4634        timeout(Duration::from_secs(1), async {
4635            while receiver.events.len() != 1 || receiver.harness_mcp_events.len() != 1 {
4636                tokio::task::yield_now().await;
4637            }
4638        }).await.unwrap();
4639
4640        let transient = receiver.recv().await.unwrap();
4641        assert_eq!(transient.cursor.sequence, 41);
4642        assert!(matches!(transient.event, C2NodeEvent::HarnessMcpReadCall { .. }));
4643        let durable = receiver.recv().await.unwrap();
4644        assert_eq!(durable.cursor.sequence, 42);
4645        assert!(matches!(
4646            durable.event,
4647            C2NodeEvent::WorkspaceRemoved { ref workspace_id }
4648                if workspace_id.as_str() == "retired"
4649        ));
4650        assert!(matches!(
4651            receiver.harness_mcp_events.try_recv(),
4652            Err(mpsc::error::TryRecvError::Empty),
4653        ));
4654        assert!(matches!(
4655            receiver.events.try_recv(),
4656            Err(mpsc::error::TryRecvError::Empty),
4657        ));
4658
4659        drop(commands_tx);
4660        drop(incoming_tx);
4661        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4662    }
4663
4664    /// `split_mut` (the Stage 1 accessor the reconnect bridge's
4665    /// `pump_one_connection` needs to race both channels in one `select!`)
4666    /// must hand out two receivers that each drain only their own class,
4667    /// regardless of enqueue order -- proving the two channels are
4668    /// genuinely independent streams, not merely `recv()`'s internal
4669    /// biased-merge order.
4670    #[tokio::test]
4671    async fn split_mut_drains_each_channel_independently_of_enqueue_order() {
4672        let (commands_tx, commands_rx) = mpsc::channel(1);
4673        let (events_tx, events_rx) = mpsc::channel(EVENT_CAPACITY);
4674        let (harness_tx, harness_rx) = mpsc::channel(HARNESS_MCP_EVENT_CAPACITY);
4675        let (writer_tx, _writer_rx) = mpsc::channel(1);
4676        let (incoming_tx, incoming_rx) = mpsc::channel(2);
4677        let (topology_tx, _topology_rx) =
4678            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4679        let mut capabilities = all_path_capabilities();
4680        capabilities.harness_mcp_read_proxy = true;
4681        let deadline_unix_ms = current_unix_ms().unwrap() + 3_000;
4682        // The regular event is enqueued FIRST -- if the two split
4683        // receivers were secretly still merged, a naive FIFO reading of the
4684        // harness_mcp side first would see nothing ready and this test
4685        // would hang (caught by the outer `timeout`) or, worse, silently
4686        // return the regular event.
4687        incoming_tx.send(ordinary_state_event(42)).await.unwrap();
4688        incoming_tx.send(harness_mcp_event(1, deadline_unix_ms)).await.unwrap();
4689        let owner = tokio::spawn(control_owner(
4690            commands_rx,
4691            mpsc::channel(1).1,
4692            EventDelivery { regular: events_tx, harness_mcp: harness_tx, browser: mpsc::channel(1).0 },
4693            topology_tx,
4694            writer_tx,
4695            incoming_rx,
4696            capabilities,
4697        ));
4698        let mut receiver = C2EventReceiver {
4699            events: events_rx,
4700            harness_mcp_events: harness_rx,
4701        };
4702        timeout(Duration::from_secs(1), async {
4703            while receiver.events.len() != 1 || receiver.harness_mcp_events.len() != 1 {
4704                tokio::task::yield_now().await;
4705            }
4706        }).await.unwrap();
4707
4708        let (regular_events, harness_mcp_events, _browser_events) = receiver.split_mut();
4709
4710        let mcp_event = timeout(Duration::from_secs(1), harness_mcp_events.recv())
4711            .await
4712            .expect("the harness_mcp receiver must not block on the regular channel")
4713            .unwrap();
4714        assert!(matches!(mcp_event.event, C2NodeEvent::HarnessMcpReadCall { .. }));
4715
4716        let regular_event = timeout(Duration::from_secs(1), regular_events.recv())
4717            .await
4718            .expect("the regular receiver must not block on the harness_mcp channel")
4719            .unwrap();
4720        assert_eq!(regular_event.cursor.sequence, 42);
4721        assert!(matches!(
4722            regular_event.event,
4723            C2NodeEvent::WorkspaceRemoved { ref workspace_id }
4724                if workspace_id.as_str() == "retired"
4725        ));
4726
4727        drop(commands_tx);
4728        drop(incoming_tx);
4729        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4730    }
4731
4732    #[test]
4733    fn c2_repository_path_gate_covers_entries_status_and_previous_path() {
4734        let utf8 = || repository_path("src/main.rs");
4735        let tagged = || unix_repository_path(b"src/\xff");
4736        for response in [
4737            repository_inspection_response(tagged(), utf8(), None),
4738            repository_inspection_response(utf8(), tagged(), None),
4739            repository_inspection_response(utf8(), utf8(), Some(tagged())),
4740        ] {
4741            assert!(c2_node_response_has_unix_repository_path(&response));
4742        }
4743        assert!(!c2_node_response_has_unix_repository_path(
4744            &repository_inspection_response(utf8(), utf8(), Some(utf8())),
4745        ));
4746    }
4747
4748    #[test]
4749    fn c2_control_client_rejects_selection_outside_offer() {
4750        let offer = client_compatibility_offer().unwrap();
4751        let selected = NegotiatedC2ControlCompatibility {
4752            build_stamp: format!("{}-tampered", offer.build_stamp),
4753            capabilities: offer.capabilities.clone(),
4754            host: HostDescriptor {
4755                operating_system: OperatingSystemId::new("windows").unwrap(),
4756                architecture: ArchitectureId::new("x86_64").unwrap(),
4757            },
4758            path_semantics: PathSemantics {
4759                style: PathStyle::Windows,
4760                encoding: PathEncoding::Utf8,
4761            },
4762        };
4763
4764        assert!(matches!(
4765            validate_selected_compatibility(&offer, Some(&selected)),
4766            Err(C2ControlError::BuildStampMismatch { .. }),
4767        ));
4768    }
4769
4770    #[test]
4771    fn c2_control_client_requires_authenticated_compatibility_metadata() {
4772        let offer = client_compatibility_offer().unwrap();
4773        let selected = NegotiatedC2ControlCompatibility {
4774            build_stamp: offer.build_stamp.clone(),
4775            capabilities: vec![
4776                CapabilityId::new(C2_WORKSPACE_FILE_READ_CAPABILITY).unwrap(),
4777            ],
4778            host: HostDescriptor {
4779                operating_system: OperatingSystemId::new("windows").unwrap(),
4780                architecture: ArchitectureId::new("x86_64").unwrap(),
4781            },
4782            path_semantics: PathSemantics {
4783                style: PathStyle::Windows,
4784                encoding: PathEncoding::Utf8,
4785            },
4786        };
4787
4788        assert!(matches!(
4789            validate_selected_compatibility(&offer, Some(&selected)),
4790            Err(C2ControlError::Protocol(_)),
4791        ));
4792    }
4793
4794    #[tokio::test]
4795    async fn negotiating_c2_client_rejects_hmac_valid_legacy_challenge_before_authenticate() {
4796        let endpoint = unique_control_endpoint();
4797        let token = "strict-negotiation-token";
4798        let mut server = ServerOptions::new()
4799            .first_pipe_instance(true)
4800            .create(&endpoint)
4801            .unwrap();
4802        let server_task = tokio::spawn({
4803            let token = token.to_owned();
4804            async move {
4805                server.connect().await.unwrap();
4806                let frame = read_json_frame_limited_body_timeout::<_, C2ClientFrame>(
4807                    &mut server,
4808                    MAX_C2_AUTH_FRAME_BYTES,
4809                    Duration::from_secs(1),
4810                )
4811                .await
4812                .unwrap();
4813                let C2ClientFrame::Hello(hello) = frame else {
4814                    panic!("client did not send hello");
4815                };
4816                assert!(hello.compatibility.is_some());
4817                let server_nonce = [7; C2_AUTH_NONCE_BYTES];
4818                let server_proof = c2_proof(
4819                    &token,
4820                    C2AuthDirection::Server,
4821                    &hello.client_nonce,
4822                    &server_nonce,
4823                    None,
4824                )
4825                .unwrap();
4826                write_json_frame_limited(
4827                    &mut server,
4828                    &C2ServerFrame::Challenge(C2ServerChallenge {
4829                        build_stamp: BUILD_STAMP.to_owned(),
4830                        server_nonce,
4831                        server_proof,
4832                        compatibility: None,
4833                    }),
4834                    MAX_C2_AUTH_FRAME_BYTES,
4835                )
4836                .await
4837                .unwrap();
4838                timeout(
4839                    Duration::from_secs(1),
4840                    read_json_frame_limited_body_timeout::<_, C2ClientFrame>(
4841                        &mut server,
4842                        MAX_C2_AUTH_FRAME_BYTES,
4843                        Duration::from_secs(1),
4844                    ),
4845                )
4846                .await
4847            }
4848        });
4849
4850        let error = match connect_local(&endpoint, token).await {
4851            Ok(_) => panic!("negotiated client accepted a legacy challenge"),
4852            Err(error) => error,
4853        };
4854        assert!(matches!(
4855            error,
4856            C2ControlError::Protocol(ref message)
4857                if message.contains("omitted the required authenticated compatibility selection")
4858        ));
4859        match server_task.await.unwrap() {
4860            Ok(Ok(frame)) => panic!("client sent a post-challenge frame: {frame:?}"),
4861            Ok(Err(_)) | Err(_) => {}
4862        }
4863    }
4864
4865    #[test]
4866    fn c2_control_bound_proof_rejects_tampered_selection() {
4867        let offer = client_compatibility_offer().unwrap();
4868        let selected = NegotiatedC2ControlCompatibility {
4869            build_stamp: offer.build_stamp.clone(),
4870            capabilities: offer.capabilities.clone(),
4871            host: HostDescriptor {
4872                operating_system: OperatingSystemId::new("windows").unwrap(),
4873                architecture: ArchitectureId::new("x86_64").unwrap(),
4874            },
4875            path_semantics: PathSemantics {
4876                style: PathStyle::Windows,
4877                encoding: PathEncoding::Utf8,
4878            },
4879        };
4880        let expected = c2_proof(
4881            "test-token",
4882            C2AuthDirection::Server,
4883            &[1; C2_AUTH_NONCE_BYTES],
4884            &[2; C2_AUTH_NONCE_BYTES],
4885            Some((&offer, &selected)),
4886        ).unwrap();
4887        let mut tampered = selected;
4888        tampered.path_semantics.style = PathStyle::Posix;
4889        let received = c2_proof(
4890            "test-token",
4891            C2AuthDirection::Server,
4892            &[1; C2_AUTH_NONCE_BYTES],
4893            &[2; C2_AUTH_NONCE_BYTES],
4894            Some((&offer, &tampered)),
4895        ).unwrap();
4896
4897        assert!(!proofs_match(&received, &expected));
4898    }
4899
4900    #[tokio::test]
4901    async fn control_owner_rejects_unnegotiated_unix_path_before_write() {
4902        let (commands_tx, commands_rx) = mpsc::channel(1);
4903        let (events_tx, _events_rx) = mpsc::channel(1);
4904        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4905        let (_incoming_tx, incoming_rx) = mpsc::channel(1);
4906        let (topology_tx, _topology_rx) =
4907            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4908        let owner = tokio::spawn(control_owner(
4909            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
4910        ));
4911        let request = NodeRequest::RegisterWorkspace {
4912            workspace_id: WorkspaceId::new("foreign").unwrap(),
4913            root: unix_path(),
4914        };
4915        assert!(reject_unnegotiated_outbound_path(&request, all_path_capabilities()).is_ok());
4916        let (reply_tx, reply_rx) = oneshot::channel();
4917        commands_tx
4918            .send(ControlCommand { route: route(), request, reply: reply_tx,  correlation_id: None, })
4919            .await
4920            .unwrap();
4921
4922        assert!(matches!(
4923            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
4924            Err(C2ControlError::Protocol(ref message))
4925                if message == OPAQUE_UNIX_PATH_NOT_NEGOTIATED
4926        ));
4927        assert!(writer_rx.try_recv().is_err());
4928        drop(commands_tx);
4929        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4930    }
4931
4932    #[tokio::test]
4933    async fn control_owner_rejects_unnegotiated_open_provider_spawn_before_write() {
4934        let (commands_tx, commands_rx) = mpsc::channel(1);
4935        let (events_tx, _events_rx) = mpsc::channel(1);
4936        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4937        let (_incoming_tx, incoming_rx) = mpsc::channel(1);
4938        let (topology_tx, _topology_rx) =
4939            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4940        let owner = tokio::spawn(control_owner(
4941            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
4942        ));
4943        let (reply_tx, reply_rx) = oneshot::channel();
4944        commands_tx.send(ControlCommand {
4945            route: route(),
4946            request: NodeRequest::Spawn {
4947                workspace_id: WorkspaceId::new("primary").unwrap(),
4948                provider: hatchery_core::AgentId::new("third-party-agent").unwrap(),
4949                mode: SessionMode::Pty,
4950                terminal_size: TerminalSize { rows: 40, columns: 120 },
4951                initial_prompt: None,
4952            },
4953            reply: reply_tx,
4954            correlation_id: None,
4955        }).await.unwrap();
4956
4957        assert!(matches!(
4958            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
4959            Err(C2ControlError::Protocol(ref message))
4960                if message == OPEN_PROVIDER_ID_NOT_NEGOTIATED
4961        ));
4962        assert!(writer_rx.try_recv().is_err());
4963        drop(commands_tx);
4964        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4965    }
4966
4967    #[test]
4968    fn spawn_profile_revision_is_advertised_and_rejected_before_relay_on_all_spawn_paths() {
4969        let offer = client_compatibility_offer().unwrap();
4970        assert!(offer.capabilities.iter().any(|capability| {
4971            capability.as_str() == C2_SPAWN_PROFILE_REVISION_CAPABILITY
4972        }));
4973
4974        let spec = spawn_spec("node-a");
4975        let reservation_id = gate4agent_node_protocol::HarnessMcpReservationId::new(
4976            format!("hmcpres_{}", "a".repeat(24)),
4977        ).unwrap();
4978        let activation_digest = gate4agent_node_protocol::HarnessMcpActivationDigest::new(
4979            format!("sha256:{}", "b".repeat(64)),
4980        ).unwrap();
4981        let deadline_unix_ms = current_unix_ms().unwrap().saturating_add(60_000);
4982        let requests = [
4983            NodeRequest::SpawnSpec { spec: spec.clone() },
4984            NodeRequest::SpawnManagedWorktree {
4985                request: gate4agent_node_protocol::ManagedWorktreeSpawnRequest {
4986                    spawn_spec: spec.clone(),
4987                    worktree_profile_id:
4988                        gate4agent_node_protocol::WorktreeProfileId::new("review").unwrap(),
4989                },
4990            },
4991            NodeRequest::ArmHarnessMcpReservation {
4992                reservation_id: reservation_id.clone(),
4993                activation_digest: activation_digest.clone(),
4994                spawn_spec: spec.clone(),
4995                launch: gate4agent_node_protocol::HarnessMcpLaunchV1 {
4996                    server_name: "fixture-harness".to_owned(),
4997                    args: vec!["--session-proxy".to_owned()],
4998                    endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
4999                    token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
5000                    program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
5001                    trace: None,
5002                    scrub_env: Vec::new(),
5003                },
5004                expires_at_unix_ms: deadline_unix_ms,
5005            },
5006            NodeRequest::SpawnSpecWithHarnessMcp {
5007                reservation_id,
5008                activation_digest,
5009                spec,
5010                deadline_unix_ms,
5011            },
5012        ];
5013        let mut capabilities = all_path_capabilities();
5014        capabilities.harness_mcp_read_proxy = true;
5015        capabilities.spawn_profile_revision = false;
5016        for request in &requests {
5017            assert!(matches!(
5018                reject_unnegotiated_outbound_path(request, capabilities),
5019                Err(C2ControlError::Protocol(ref message))
5020                    if message == SPAWN_PROFILE_REVISION_NOT_NEGOTIATED
5021            ));
5022        }
5023
5024        let response = RoutedNodeResponse {
5025            node_id: NodeId::new("node-a").unwrap(),
5026            incarnation_id: gate4agent_node_protocol::NodeIncarnationId::from_bytes([1; 16]),
5027            response: Ok(C2NodeResponse::SpawnSpecAccepted {
5028                receipt: spawn_receipt(),
5029            }),
5030        };
5031        assert!(routed_response_requires_spawn_profile_revision(&response));
5032    }
5033
5034    #[tokio::test]
5035    async fn control_owner_rejects_unnegotiated_spawn_spec_before_write() {
5036        let (commands_tx, commands_rx) = mpsc::channel(1);
5037        let (events_tx, _events_rx) = mpsc::channel(1);
5038        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5039        let (_incoming_tx, incoming_rx) = mpsc::channel(1);
5040        let (topology_tx, _topology_rx) =
5041            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5042        let mut capabilities = all_path_capabilities();
5043        capabilities.spawn_spec_defaults_overrides = false;
5044        let owner = tokio::spawn(control_owner(
5045            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx,
5046            capabilities,
5047        ));
5048        let (reply_tx, reply_rx) = oneshot::channel();
5049        commands_tx.send(ControlCommand {
5050            route: route(),
5051            request: NodeRequest::SpawnSpec { spec: spawn_spec("node-a") },
5052            reply: reply_tx,
5053            correlation_id: None,
5054        }).await.unwrap();
5055
5056        assert!(matches!(
5057            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5058            Err(C2ControlError::Protocol(ref message))
5059                if message == SPAWN_SPEC_NOT_NEGOTIATED
5060        ));
5061        assert!(writer_rx.try_recv().is_err());
5062        drop(commands_tx);
5063        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5064    }
5065
5066    #[tokio::test]
5067    async fn control_owner_rejects_unnegotiated_spawn_spec_reply_and_closes() {
5068        let (commands_tx, commands_rx) = mpsc::channel(1);
5069        let (events_tx, _events_rx) = mpsc::channel(1);
5070        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5071        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5072        let (topology_tx, _topology_rx) =
5073            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5074        let mut capabilities = all_path_capabilities();
5075        capabilities.spawn_spec_defaults_overrides = false;
5076        let owner = tokio::spawn(control_owner(
5077            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, capabilities,
5078        ));
5079        let (reply_tx, reply_rx) = oneshot::channel();
5080        commands_tx.send(ControlCommand {
5081            route: route(),
5082            request: NodeRequest::Snapshot,
5083            reply: reply_tx,
5084            correlation_id: None,
5085        }).await.unwrap();
5086        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5087        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Reply(
5088            hatchery_core::C2ReplyEnvelope {
5089                request_id: C2RequestId(1),
5090                result: Ok(RoutedNodeResponse {
5091                    node_id: NodeId::new("node-a").unwrap(),
5092                    incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5093                    response: Ok(C2NodeResponse::SpawnSpecAccepted {
5094                        receipt: spawn_receipt(),
5095                    }),
5096                }),
5097                correlation_id: None,
5098            },
5099        ))).await.unwrap();
5100
5101        assert!(matches!(
5102            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5103            Err(C2ControlError::Protocol(ref message))
5104                if message == SPAWN_SPEC_NOT_NEGOTIATED
5105        ));
5106        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5107        assert!(commands_tx.send(ControlCommand {
5108            route: route(),
5109            request: NodeRequest::Snapshot,
5110            reply: oneshot::channel().0,
5111            correlation_id: None,
5112        }).await.is_err());
5113    }
5114
5115    #[tokio::test]
5116    async fn control_owner_conditionally_gates_worktree_spawn_before_write() {
5117        let (commands_tx, commands_rx) = mpsc::channel(2);
5118        let (events_tx, _events_rx) = mpsc::channel(1);
5119        let (writer_tx, mut writer_rx) = mpsc::channel(2);
5120        let (_incoming_tx, incoming_rx) = mpsc::channel(1);
5121        let (topology_tx, _topology_rx) =
5122            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5123        let mut capabilities = all_path_capabilities();
5124        capabilities.worktree_selection = false;
5125        let owner = tokio::spawn(control_owner(
5126            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, capabilities,
5127        ));
5128
5129        let (legacy_reply_tx, _legacy_reply_rx) = oneshot::channel();
5130        commands_tx.send(ControlCommand {
5131            route: route(),
5132            request: NodeRequest::SpawnSpec { spec: spawn_spec("node-a") },
5133            reply: legacy_reply_tx,
5134            correlation_id: None,
5135        }).await.unwrap();
5136        assert!(matches!(
5137            writer_rx.recv().await,
5138            Some(C2ClientFrame::Request(_)),
5139        ));
5140
5141        let mut spec = spawn_spec("node-a");
5142        spec.target.worktree_id = Some(WorkspaceId::new("review-tree").unwrap());
5143        let (reply_tx, reply_rx) = oneshot::channel();
5144        commands_tx.send(ControlCommand {
5145            route: route(),
5146            request: NodeRequest::SpawnSpec { spec },
5147            reply: reply_tx,
5148            correlation_id: None,
5149        }).await.unwrap();
5150        assert!(matches!(
5151            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5152            Err(C2ControlError::Protocol(ref message))
5153                if message == WORKTREE_SELECTION_NOT_NEGOTIATED
5154        ));
5155        assert!(writer_rx.try_recv().is_err());
5156        drop(commands_tx);
5157        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5158    }
5159
5160    #[tokio::test]
5161    async fn control_owner_rejects_unnegotiated_worktree_receipt_and_closes() {
5162        let (commands_tx, commands_rx) = mpsc::channel(1);
5163        let (events_tx, _events_rx) = mpsc::channel(1);
5164        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5165        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5166        let (topology_tx, _topology_rx) =
5167            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5168        let mut capabilities = all_path_capabilities();
5169        capabilities.worktree_selection = false;
5170        let owner = tokio::spawn(control_owner(
5171            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, capabilities,
5172        ));
5173        let (reply_tx, reply_rx) = oneshot::channel();
5174        commands_tx.send(ControlCommand {
5175            route: route(),
5176            request: NodeRequest::Snapshot,
5177            reply: reply_tx,
5178            correlation_id: None,
5179        }).await.unwrap();
5180        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5181        let mut receipt = spawn_receipt();
5182        receipt.target.worktree_id = Some(WorkspaceId::new("review-tree").unwrap());
5183        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Reply(
5184            hatchery_core::C2ReplyEnvelope {
5185                request_id: C2RequestId(1),
5186                result: Ok(RoutedNodeResponse {
5187                    node_id: NodeId::new("node-a").unwrap(),
5188                    incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5189                    response: Ok(C2NodeResponse::SpawnSpecAccepted { receipt }),
5190                }),
5191                correlation_id: None,
5192            },
5193        ))).await.unwrap();
5194        assert!(matches!(
5195            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5196            Err(C2ControlError::Protocol(ref message))
5197                if message == WORKTREE_SELECTION_NOT_NEGOTIATED
5198        ));
5199        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5200    }
5201
5202    #[tokio::test]
5203    async fn control_owner_rejects_unnegotiated_environment_receipt_and_closes() {
5204        let (commands_tx, commands_rx) = mpsc::channel(1);
5205        let (events_tx, _events_rx) = mpsc::channel(1);
5206        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5207        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5208        let (topology_tx, _topology_rx) =
5209            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5210        let mut capabilities = all_path_capabilities();
5211        capabilities.child_environment_profile = false;
5212        let owner = tokio::spawn(control_owner(
5213            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, capabilities,
5214        ));
5215        let (reply_tx, reply_rx) = oneshot::channel();
5216        commands_tx.send(ControlCommand {
5217            route: route(),
5218            request: NodeRequest::Snapshot,
5219            reply: reply_tx,
5220            correlation_id: None,
5221        }).await.unwrap();
5222        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5223        let mut receipt = spawn_receipt();
5224        receipt.environment_profile = Some(
5225            hatchery_core::ResolvedEnvironmentProfileReceipt {
5226                profile_id: hatchery_core::SpawnEnvironmentProfileId::new(
5227                    "local-default",
5228                )
5229                .unwrap(),
5230                profile_revision:
5231                    hatchery_core::SpawnEnvironmentProfileRevision::new(
5232                        "local-default.r1",
5233                    )
5234                    .unwrap(),
5235                    network_allowlist: None,
5236                    browser_profile_id: None,
5237            },
5238        );
5239        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Reply(
5240            hatchery_core::C2ReplyEnvelope {
5241                request_id: C2RequestId(1),
5242                result: Ok(RoutedNodeResponse {
5243                    node_id: NodeId::new("node-a").unwrap(),
5244                    incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5245                    response: Ok(C2NodeResponse::SpawnSpecAccepted { receipt }),
5246                }),
5247                correlation_id: None,
5248            },
5249        ))).await.unwrap();
5250        assert!(matches!(
5251            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5252            Err(C2ControlError::Protocol(ref message))
5253                if message == CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED
5254        ));
5255        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5256    }
5257
5258    #[tokio::test]
5259    async fn control_owner_rejects_unnegotiated_open_provider_reply_and_closes() {
5260        let (commands_tx, commands_rx) = mpsc::channel(1);
5261        let (events_tx, _events_rx) = mpsc::channel(1);
5262        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5263        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5264        let (topology_tx, _topology_rx) =
5265            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5266        let owner = tokio::spawn(control_owner(
5267            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5268        ));
5269        let (reply_tx, reply_rx) = oneshot::channel();
5270        commands_tx.send(ControlCommand {
5271            route: route(),
5272            request: NodeRequest::Snapshot,
5273            reply: reply_tx,
5274            correlation_id: None,
5275        }).await.unwrap();
5276        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5277        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Reply(
5278            hatchery_core::C2ReplyEnvelope {
5279                request_id: C2RequestId(1),
5280                result: Ok(RoutedNodeResponse {
5281                    node_id: NodeId::new("node-a").unwrap(),
5282                    incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5283                    response: Ok(C2NodeResponse::Snapshot {
5284                        event_sequence: 1,
5285                        controller: None,
5286                        snapshot: hatchery_core::C2NodeSnapshot {
5287                            node_id: NodeId::new("node-a").unwrap(),
5288                            enabled_providers: vec![
5289                                hatchery_core::AgentId::new("third-party-agent").unwrap(),
5290                            ],
5291                            provider_runtime_statuses: Default::default(),
5292                            workspaces: Vec::new(),
5293                            agent_progress: Vec::new(),
5294                            session_records: Vec::new(),
5295                            managed_worktrees: Vec::new(),
5296                            launch_inventory: None,
5297
5298                        machine: None,},
5299                    }),
5300                }),
5301                correlation_id: None,
5302            },
5303        ))).await.unwrap();
5304
5305        assert!(matches!(
5306            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5307            Err(C2ControlError::Protocol(ref message))
5308                if message == OPEN_PROVIDER_ID_NOT_NEGOTIATED
5309        ));
5310        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5311        assert!(commands_tx.is_closed());
5312    }
5313
5314    #[tokio::test]
5315    async fn control_owner_rejects_unnegotiated_workspace_file_read_before_write_and_stays_healthy() {
5316        let (commands_tx, commands_rx) = mpsc::channel(2);
5317        let (events_tx, _events_rx) = mpsc::channel(1);
5318        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5319        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5320        let (topology_tx, _topology_rx) =
5321            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5322        let owner = tokio::spawn(control_owner(
5323            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5324        ));
5325        let (read_tx, read_rx) = oneshot::channel();
5326        commands_tx.send(ControlCommand {
5327            route: route(),
5328            request: NodeRequest::ReadWorkspaceFile {
5329                workspace_id: WorkspaceId::new("primary").unwrap(),
5330                path: repository_path("src/lib.rs"),
5331            },
5332            reply: read_tx,
5333            correlation_id: None,
5334        }).await.unwrap();
5335
5336        assert!(matches!(
5337            timeout(Duration::from_secs(1), read_rx).await.unwrap().unwrap(),
5338            Err(C2ControlError::Protocol(ref message))
5339                if message == WORKSPACE_FILE_READ_NOT_NEGOTIATED
5340        ));
5341        assert!(writer_rx.try_recv().is_err());
5342
5343        let (snapshot_tx, snapshot_rx) = oneshot::channel();
5344        commands_tx.send(ControlCommand {
5345            route: route(),
5346            request: NodeRequest::Snapshot,
5347            reply: snapshot_tx,
5348            correlation_id: None,
5349        }).await.unwrap();
5350        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5351        incoming_tx.send(reply(1)).await.unwrap();
5352        assert!(matches!(
5353            timeout(Duration::from_secs(1), snapshot_rx).await.unwrap().unwrap(),
5354            Ok(RoutedNodeResponse { response: Ok(C2NodeResponse::Accepted), .. })
5355        ));
5356
5357        drop(commands_tx);
5358        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5359    }
5360
5361    #[tokio::test]
5362    async fn control_owner_rejects_unnegotiated_workspace_entry_create_before_write_and_stays_healthy() {
5363        let (commands_tx, commands_rx) = mpsc::channel(2);
5364        let (events_tx, _events_rx) = mpsc::channel(1);
5365        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5366        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5367        let (topology_tx, _topology_rx) =
5368            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5369        let owner = tokio::spawn(control_owner(
5370            commands_rx,
5371            events_tx,
5372            topology_tx,
5373            writer_tx,
5374            incoming_rx,
5375            no_path_capabilities(),
5376        ));
5377        let request = NodeRequest::CreateWorkspaceDirectory {
5378            workspace_id: WorkspaceId::new("primary").unwrap(),
5379            path: repository_path("src/new"),
5380        };
5381        assert_eq!(control_request_deadline(&request), Duration::from_secs(20));
5382        assert!(
5383            control_request_deadline(&request)
5384                > CONTROLLER_ACQUIRE_RELAY_DEADLINE
5385                    .saturating_add(WORKSPACE_ENTRY_CREATE_RELAY_DEADLINE),
5386        );
5387        let (create_tx, create_rx) = oneshot::channel();
5388        commands_tx
5389            .send(ControlCommand {
5390                route: route(),
5391                request,
5392                reply: create_tx,
5393                correlation_id: None,
5394            })
5395            .await
5396            .unwrap();
5397        assert!(matches!(
5398            timeout(Duration::from_secs(1), create_rx).await.unwrap().unwrap(),
5399            Err(C2ControlError::Protocol(ref message))
5400                if message == WORKSPACE_ENTRY_CREATE_NOT_NEGOTIATED
5401        ));
5402        assert!(writer_rx.try_recv().is_err());
5403
5404        let (snapshot_tx, snapshot_rx) = oneshot::channel();
5405        commands_tx
5406            .send(ControlCommand {
5407                route: route(),
5408                request: NodeRequest::Snapshot,
5409                reply: snapshot_tx,
5410                correlation_id: None,
5411            })
5412            .await
5413            .unwrap();
5414        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5415        incoming_tx.send(reply(1)).await.unwrap();
5416        assert!(matches!(
5417            timeout(Duration::from_secs(1), snapshot_rx).await.unwrap().unwrap(),
5418            Ok(RoutedNodeResponse {
5419                response: Ok(C2NodeResponse::Accepted),
5420                ..
5421            })
5422        ));
5423
5424        drop(commands_tx);
5425        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5426    }
5427
5428    #[test]
5429    fn workspace_entry_create_requires_both_create_and_repository_capabilities() {
5430        let request = NodeRequest::CreateWorkspaceFile {
5431            workspace_id: WorkspaceId::new("primary").unwrap(),
5432            path: unix_repository_path(b"src/\xff"),
5433        };
5434        let mut capabilities = all_path_capabilities();
5435        capabilities.workspace_entry_create = false;
5436        assert!(matches!(
5437            reject_unnegotiated_outbound_path(&request, capabilities),
5438            Err(C2ControlError::Protocol(ref message))
5439                if message == WORKSPACE_ENTRY_CREATE_NOT_NEGOTIATED
5440        ));
5441        capabilities.workspace_entry_create = true;
5442        capabilities.repository_paths = false;
5443        assert!(matches!(
5444            reject_unnegotiated_outbound_path(&request, capabilities),
5445            Err(C2ControlError::Protocol(ref message))
5446                if message == REPOSITORY_PATH_NOT_NEGOTIATED
5447        ));
5448        capabilities.repository_paths = true;
5449        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_ok());
5450    }
5451
5452    #[test]
5453    fn provider_session_reference_index_request_is_c2_capability_gated() {
5454        let request = NodeRequest::IndexProviderSession {
5455            workspace_id: WorkspaceId::new("primary").unwrap(),
5456            provider: hatchery_core::AgentId::new("claude").unwrap(),
5457            identity: ProviderSessionIdentity {
5458                key: ProviderSessionKey::SessionId,
5459                id: "provider-session-42".to_owned(),
5460                transcript_path: None,
5461            },
5462            display_name: "release shepherd".to_owned(),
5463        };
5464        let mut capabilities = all_path_capabilities();
5465        capabilities.provider_session_reference_index = false;
5466        assert!(matches!(
5467            reject_unnegotiated_outbound_path(&request, capabilities),
5468            Err(C2ControlError::Protocol(ref message))
5469                if message == PROVIDER_SESSION_REFERENCE_INDEX_NOT_NEGOTIATED
5470        ));
5471        assert!(reject_unnegotiated_outbound_path(
5472            &request,
5473            all_path_capabilities(),
5474        )
5475        .is_ok());
5476    }
5477
5478    #[test]
5479    fn tagged_workspace_file_read_requires_both_file_and_repository_capabilities() {
5480        let request = NodeRequest::ReadWorkspaceFile {
5481            workspace_id: WorkspaceId::new("primary").unwrap(),
5482            path: unix_repository_path(b"src/\xff"),
5483        };
5484        let missing_repository = NegotiatedPathCapabilities {
5485            opaque_host_paths: true,
5486            repository_paths: false,
5487            workspace_file_read: true,
5488            host_directory_browse: true,
5489            standalone_workspace_lifecycle: true,
5490            provider_session_reference_index: true,
5491            provider_ids_open: true,
5492            spawn_spec_defaults_overrides: true,
5493            worktree_selection: true,
5494            managed_worktree_lifecycle: true,
5495            child_environment_profile: true,
5496            session_bundle_materialization: true,
5497            history_context_pack: true,
5498            native_session_catalog: true,
5499            native_session_catalog_paging: true,
5500            native_session_index: true,
5501            native_session_preview: true,
5502            terminal_frame_events: true,
5503            ..Default::default()
5504        };
5505        assert!(matches!(
5506            reject_unnegotiated_outbound_path(&request, missing_repository),
5507            Err(C2ControlError::Protocol(ref message))
5508                if message == REPOSITORY_PATH_NOT_NEGOTIATED
5509        ));
5510        let missing_file_read = NegotiatedPathCapabilities {
5511            opaque_host_paths: true,
5512            repository_paths: true,
5513            workspace_file_read: false,
5514            host_directory_browse: true,
5515            standalone_workspace_lifecycle: true,
5516            provider_session_reference_index: true,
5517            provider_ids_open: true,
5518            spawn_spec_defaults_overrides: true,
5519            worktree_selection: true,
5520            managed_worktree_lifecycle: true,
5521            child_environment_profile: true,
5522            session_bundle_materialization: true,
5523            history_context_pack: true,
5524            native_session_catalog: true,
5525            native_session_catalog_paging: true,
5526            native_session_index: true,
5527            native_session_preview: true,
5528            terminal_frame_events: true,
5529            ..Default::default()
5530        };
5531        assert!(matches!(
5532            reject_unnegotiated_outbound_path(&request, missing_file_read),
5533            Err(C2ControlError::Protocol(ref message))
5534                if message == WORKSPACE_FILE_READ_NOT_NEGOTIATED
5535        ));
5536        assert!(reject_unnegotiated_outbound_path(
5537            &request,
5538            all_path_capabilities(),
5539        ).is_ok());
5540    }
5541
5542    #[test]
5543    fn workspace_file_read_response_is_gated_and_path_checked() {
5544        let response = RoutedNodeResponse {
5545            node_id: NodeId::new("node-a").unwrap(),
5546            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5547            response: Ok(C2NodeResponse::WorkspaceFileRead {
5548                file: WorkspaceFileRead {
5549                    workspace_id: WorkspaceId::new("primary").unwrap(),
5550                    path: unix_repository_path(b"src/\xff"),
5551                    content: WorkspaceFileContent::NonUtf8 { byte_len: 3 },
5552                    revision: None,
5553                },
5554            }),
5555        };
5556        assert!(routed_response_requires_workspace_file_read(&response));
5557        assert!(routed_response_has_unix_repository_path(&response));
5558        assert!(!routed_response_has_unix_bytes(&response));
5559    }
5560
5561    #[test]
5562    fn workspace_entry_create_response_correlation_rejects_content_and_kind_mismatches() {
5563        let workspace_id = WorkspaceId::new("primary").unwrap();
5564        let file_path = repository_path("src/new.rs");
5565        let request = NodeRequest::CreateWorkspaceFile {
5566            workspace_id: workspace_id.clone(),
5567            path: file_path.clone(),
5568        };
5569        let response = Ok(RoutedNodeResponse {
5570            node_id: NodeId::new("node-a").unwrap(),
5571            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5572            response: Ok(C2NodeResponse::WorkspaceFileCreated {
5573                file: WorkspaceFileRead {
5574                    workspace_id: workspace_id.clone(),
5575                    path: file_path,
5576                    content: WorkspaceFileContent::Utf8 {
5577                        text: String::new(),
5578                        byte_len: 0,
5579                    },
5580                    revision: Some(
5581                        gate4agent_node_protocol::WorkspaceFileRevision::new(
5582                            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
5583                                .to_owned(),
5584                        )
5585                        .unwrap(),
5586                    ),
5587                },
5588            }),
5589        });
5590        assert!(validate_workspace_content_response(&request, &response).is_ok());
5591
5592        let wrong_content = Ok(RoutedNodeResponse {
5593            node_id: NodeId::new("node-a").unwrap(),
5594            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5595            response: Ok(C2NodeResponse::WorkspaceFileCreated {
5596                file: WorkspaceFileRead {
5597                    workspace_id: workspace_id.clone(),
5598                    path: repository_path("src/new.rs"),
5599                    content: WorkspaceFileContent::Utf8 {
5600                        text: "unexpected".to_owned(),
5601                        byte_len: 10,
5602                    },
5603                    revision: None,
5604                },
5605            }),
5606        });
5607        assert!(validate_workspace_content_response(&request, &wrong_content).is_err());
5608
5609        let directory_path = repository_path("src/new");
5610        let directory_request = NodeRequest::CreateWorkspaceDirectory {
5611            workspace_id: workspace_id.clone(),
5612            path: directory_path.clone(),
5613        };
5614        let wrong_kind = Ok(RoutedNodeResponse {
5615            node_id: NodeId::new("node-a").unwrap(),
5616            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5617            response: Ok(C2NodeResponse::WorkspaceDirectoryCreated {
5618                workspace_id,
5619                entry: WorkspaceEntry {
5620                    relative_path: directory_path,
5621                    kind: WorkspaceEntryKind::File,
5622                },
5623            }),
5624        });
5625        assert!(validate_workspace_content_response(&directory_request, &wrong_kind).is_err());
5626    }
5627
5628    #[tokio::test]
5629    async fn control_owner_rejects_unnegotiated_unix_path_reply_and_closes() {
5630        let (commands_tx, commands_rx) = mpsc::channel(1);
5631        let (events_tx, _events_rx) = mpsc::channel(1);
5632        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5633        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5634        let (topology_tx, _topology_rx) =
5635            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5636        let owner = tokio::spawn(control_owner(
5637            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5638        ));
5639        let (reply_tx, reply_rx) = oneshot::channel();
5640        commands_tx
5641            .send(ControlCommand {
5642                route: route(),
5643                request: NodeRequest::Snapshot,
5644                reply: reply_tx,
5645                correlation_id: None,
5646            })
5647            .await
5648            .unwrap();
5649        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5650        incoming_tx.send(unix_path_reply(1)).await.unwrap();
5651
5652        assert!(matches!(
5653            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5654            Err(C2ControlError::Protocol(ref message))
5655                if message == OPAQUE_UNIX_PATH_NOT_NEGOTIATED
5656        ));
5657        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5658        assert!(commands_tx.is_closed());
5659    }
5660
5661    #[tokio::test]
5662    async fn control_owner_rejects_unnegotiated_tagged_repository_path_reply_and_closes() {
5663        let (commands_tx, commands_rx) = mpsc::channel(1);
5664        let (events_tx, _events_rx) = mpsc::channel(1);
5665        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5666        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5667        let (topology_tx, _topology_rx) =
5668            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5669        let owner = tokio::spawn(control_owner(
5670            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5671        ));
5672        let (reply_tx, reply_rx) = oneshot::channel();
5673        commands_tx
5674            .send(ControlCommand {
5675                route: route(),
5676                request: NodeRequest::InspectWorkspace {
5677                    workspace_id: WorkspaceId::new("foreign").unwrap(),
5678                },
5679                reply: reply_tx,
5680                correlation_id: None,
5681            })
5682            .await
5683            .unwrap();
5684        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5685        incoming_tx.send(unix_repository_path_reply(1)).await.unwrap();
5686
5687        assert!(matches!(
5688            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5689            Err(C2ControlError::Protocol(ref message))
5690                if message == REPOSITORY_PATH_NOT_NEGOTIATED
5691        ));
5692        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5693        assert!(commands_tx.is_closed());
5694    }
5695
5696    #[tokio::test]
5697    async fn control_owner_rejects_unnegotiated_unix_path_event_and_closes() {
5698        let (commands_tx, commands_rx) = mpsc::channel(1);
5699        let (events_tx, mut events_rx) = mpsc::channel(1);
5700        let (writer_tx, _writer_rx) = mpsc::channel(1);
5701        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5702        let (topology_tx, _topology_rx) =
5703            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5704        let owner = tokio::spawn(control_owner(
5705            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5706        ));
5707        incoming_tx.send(unix_path_event(1)).await.unwrap();
5708
5709        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5710        assert!(events_rx.try_recv().is_err());
5711        assert!(commands_tx.is_closed());
5712    }
5713
5714    #[tokio::test]
5715    async fn late_reply_after_timed_waiter_does_not_desynchronize_following_request() {
5716        let (commands_tx, commands_rx) = mpsc::channel(2);
5717        let (events_tx, _events_rx) = mpsc::channel(1);
5718        let (writer_tx, mut writer_rx) = mpsc::channel(2);
5719        let (incoming_tx, incoming_rx) = mpsc::channel(2);
5720        let (topology_tx, _topology_rx) = watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5721        let owner = tokio::spawn(control_owner(
5722            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5723        ));
5724
5725        let (expired_tx, expired_rx) = oneshot::channel();
5726        commands_tx.send(ControlCommand {
5727            route: route(),
5728            request: NodeRequest::Snapshot,
5729            reply: expired_tx,
5730            correlation_id: None,
5731        }).await.unwrap();
5732        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5733        drop(expired_rx);
5734        incoming_tx.send(reply(1)).await.unwrap();
5735
5736        let (live_tx, live_rx) = oneshot::channel();
5737        commands_tx.send(ControlCommand {
5738            route: route(),
5739            request: NodeRequest::Snapshot,
5740            reply: live_tx,
5741            correlation_id: None,
5742        }).await.unwrap();
5743        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5744        incoming_tx.send(reply(2)).await.unwrap();
5745        assert!(matches!(
5746            timeout(Duration::from_secs(1), live_rx).await.unwrap().unwrap(),
5747            Ok(RoutedNodeResponse { response: Ok(hatchery_core::C2NodeResponse::Accepted), .. })
5748        ));
5749
5750        drop(incoming_tx);
5751        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5752    }
5753
5754    #[tokio::test]
5755    async fn dropped_event_receiver_closes_control_owner() {
5756        let (commands_tx, commands_rx) = mpsc::channel(1);
5757        let (events_tx, events_rx) = mpsc::channel(EVENT_CAPACITY);
5758        drop(events_rx);
5759        let (writer_tx, _writer_rx) = mpsc::channel(1);
5760        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5761        let (topology_tx, _topology_rx) = watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5762        let owner = tokio::spawn(control_owner(
5763            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5764        ));
5765
5766        incoming_tx.send(event(1)).await.unwrap();
5767        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5768        assert!(commands_tx.is_closed());
5769    }
5770
5771    #[tokio::test]
5772    async fn full_regular_channel_drops_event_and_still_serves_following_reply() {
5773        let (commands_tx, commands_rx) = mpsc::channel(1);
5774        let (events_tx, _events_rx) = mpsc::channel(EVENT_CAPACITY);
5775        let mut seq = 1u64;
5776        while events_tx.try_send(routed_event(seq)).is_ok() {
5777            seq += 1;
5778        }
5779        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5780        let (incoming_tx, incoming_rx) = mpsc::channel(2);
5781        let (topology_tx, _topology_rx) = watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5782        let owner = tokio::spawn(control_owner(
5783            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5784        ));
5785
5786        let (reply_tx, reply_rx) = oneshot::channel();
5787        commands_tx.send(ControlCommand {
5788            route: route(),
5789            request: NodeRequest::Snapshot,
5790            reply: reply_tx,
5791            correlation_id: None,
5792        }).await.unwrap();
5793        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5794        // The `regular` channel is full but its receiver is alive -- this
5795        // event must be dropped, not tear the connection down.
5796        incoming_tx.send(event(3)).await.unwrap();
5797        incoming_tx.send(reply(1)).await.unwrap();
5798
5799        assert!(matches!(
5800            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5801            Ok(RoutedNodeResponse {
5802                response: Ok(hatchery_core::C2NodeResponse::Accepted),
5803                ..
5804            })
5805        ));
5806        assert!(!commands_tx.is_closed());
5807
5808        drop(incoming_tx);
5809        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5810    }
5811
5812    #[tokio::test]
5813    async fn active_event_consumer_drains_regular_burst_and_owner_serves_following_reply() {
5814        let burst_len = EVENT_CAPACITY + 2;
5815        let (commands_tx, commands_rx) = mpsc::channel(1);
5816        let (events_tx, mut events_rx) =
5817            mpsc::channel::<RoutedNodeEvent>(EVENT_CAPACITY);
5818        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5819        let (incoming_tx, incoming_rx) = mpsc::channel(burst_len + 1);
5820        let (topology_tx, _topology_rx) = watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5821
5822        for sequence in 1..=burst_len as u64 {
5823            incoming_tx.send(event(sequence)).await.unwrap();
5824        }
5825        let collector = tokio::spawn(async move {
5826            let mut sequences = Vec::with_capacity(burst_len);
5827            while sequences.len() < burst_len {
5828                let event = events_rx.recv().await.expect("event stream must stay open");
5829                sequences.push(event.cursor.sequence);
5830            }
5831            sequences
5832        });
5833        tokio::task::yield_now().await;
5834        let owner = tokio::spawn(control_owner(
5835            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5836        ));
5837
5838        let sequences = timeout(Duration::from_secs(1), collector)
5839            .await
5840            .unwrap()
5841            .unwrap();
5842        assert_eq!(sequences, (1..=burst_len as u64).collect::<Vec<_>>());
5843
5844        let (reply_tx, reply_rx) = oneshot::channel();
5845        commands_tx.send(ControlCommand {
5846            route: route(),
5847            request: NodeRequest::Snapshot,
5848            reply: reply_tx,
5849            correlation_id: None,
5850        }).await.unwrap();
5851        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5852        incoming_tx.send(reply(1)).await.unwrap();
5853        assert!(matches!(
5854            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5855            Ok(RoutedNodeResponse {
5856                response: Ok(C2NodeResponse::Accepted),
5857                ..
5858            })
5859        ));
5860
5861        drop(incoming_tx);
5862        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5863    }
5864
5865    #[tokio::test]
5866    async fn c2_client_topology_replaces_old_incarnation_status() {
5867        use hatchery_core::{C2TopologyNode, NodeTransportState};
5868
5869        let (commands_tx, commands_rx) = mpsc::channel(1);
5870        let (events_tx, mut events_rx) = mpsc::channel(1);
5871        let (writer_tx, _writer_rx) = mpsc::channel(1);
5872        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5873        let old_runtime_status = hatchery_core::ProviderRuntimeStatuses::new([
5874            hatchery_core::ProviderRuntimeStatus::raw_passthrough(
5875                hatchery_core::AgentId::new("claude").unwrap(),
5876                Some(hatchery_core::ProviderRuntimeVersion::new("1.0.0").unwrap()),
5877            ),
5878        ])
5879        .unwrap();
5880        let offline = Arc::new(C2Topology { nodes: vec![C2TopologyNode {
5881            node_id: NodeId::new("node-a").unwrap(),
5882            endpoint: r"\\.\pipe\node-a".to_owned(),
5883            relay_route: hatchery_core::C2RelayRoute::LocalIpc,
5884            transport: NodeTransportState::Offline,
5885            current_incarnation_id: Some(NodeIncarnationId::from_bytes([8; 16])),
5886            machine: None,
5887            provider_contracts: Vec::new(),
5888            provider_adapter_contracts: Vec::new(),
5889            provider_runtime_statuses: old_runtime_status,
5890        }] });
5891        let (topology_tx, mut topology_rx) = watch::channel(offline);
5892        let owner = tokio::spawn(control_owner(
5893            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5894        ));
5895        let incarnation_id = NodeIncarnationId::from_bytes([9; 16]);
5896        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Topology(C2Topology {
5897            nodes: vec![C2TopologyNode {
5898                node_id: NodeId::new("node-a").unwrap(),
5899                endpoint: r"\\.\pipe\node-a".to_owned(),
5900                relay_route: hatchery_core::C2RelayRoute::LocalIpc,
5901                transport: NodeTransportState::Online,
5902                current_incarnation_id: Some(incarnation_id),
5903                machine: None,
5904                provider_contracts: Vec::new(),
5905                provider_adapter_contracts: Vec::new(),
5906                provider_runtime_statuses: hatchery_core::ProviderRuntimeStatuses::new([
5907                    hatchery_core::ProviderRuntimeStatus::raw_passthrough(
5908                        hatchery_core::AgentId::new("codex").unwrap(),
5909                        Some(hatchery_core::ProviderRuntimeVersion::new("2.0.0").unwrap()),
5910                    ),
5911                ])
5912                .unwrap(),
5913            }],
5914        }))).await.unwrap();
5915
5916        timeout(Duration::from_secs(1), topology_rx.changed()).await.unwrap().unwrap();
5917        assert_eq!(topology_rx.borrow().nodes[0].current_incarnation_id, Some(incarnation_id));
5918        let topology = topology_rx.borrow().clone();
5919        let statuses = &topology.nodes[0].provider_runtime_statuses;
5920        assert_eq!(statuses.as_slice().len(), 1);
5921        assert_eq!(
5922            statuses.as_slice()[0].provider(),
5923            &hatchery_core::AgentId::new("codex").unwrap(),
5924        );
5925        assert_eq!(statuses.as_slice()[0].version().unwrap().as_str(), "2.0.0");
5926        assert!(events_rx.try_recv().is_err());
5927        drop(incoming_tx);
5928        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5929        assert!(commands_tx.is_closed());
5930    }
5931
5932    #[test]
5933    fn child_environment_profile_set_and_inherit_require_capability_before_write() {
5934        let mut explicit = spawn_spec("node-a");
5935        explicit.overrides.environment_profile_id =
5936            gate4agent_node_protocol::SpawnOverride::Set {
5937                value: hatchery_core::SpawnEnvironmentProfileId::new(
5938                    "local-default",
5939                )
5940                .unwrap(),
5941            };
5942        let mut capabilities = all_path_capabilities();
5943        capabilities.child_environment_profile = false;
5944        assert!(matches!(
5945            reject_unnegotiated_outbound_path(
5946                &NodeRequest::SpawnSpec { spec: explicit },
5947                capabilities,
5948            ),
5949            Err(C2ControlError::Protocol(ref message))
5950                if message == CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED
5951        ));
5952
5953        let inherited = spawn_spec("node-a");
5954        assert!(matches!(
5955            reject_unnegotiated_outbound_path(
5956                &NodeRequest::SpawnSpec {
5957                    spec: inherited.clone(),
5958                },
5959                capabilities,
5960            ),
5961            Err(C2ControlError::Protocol(ref message))
5962                if message == CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED
5963        ));
5964        let managed = NodeRequest::SpawnManagedWorktree {
5965            request: hatchery_core::ManagedWorktreeSpawnRequest {
5966                spawn_spec: inherited,
5967                worktree_profile_id:
5968                    hatchery_core::WorktreeProfileId::new("review").unwrap(),
5969            },
5970        };
5971        assert!(matches!(
5972            reject_unnegotiated_outbound_path(&managed, capabilities),
5973            Err(C2ControlError::Protocol(ref message))
5974                if message == CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED
5975        ));
5976
5977        let mut cleared = spawn_spec("node-a");
5978        cleared.overrides.environment_profile_id =
5979            gate4agent_node_protocol::SpawnOverride::Clear;
5980        assert!(reject_unnegotiated_outbound_path(
5981            &NodeRequest::SpawnSpec { spec: cleared },
5982            capabilities,
5983        )
5984        .is_ok());
5985    }
5986
5987    #[test]
5988    fn session_bundle_inherit_and_set_require_capability_but_clear_does_not() {
5989        let mut capabilities = all_path_capabilities();
5990        capabilities.session_bundle_materialization = false;
5991
5992        let inherited = spawn_spec("node-a");
5993        assert!(matches!(
5994            reject_unnegotiated_outbound_path(
5995                &NodeRequest::SpawnSpec {
5996                    spec: inherited.clone(),
5997                },
5998                capabilities,
5999            ),
6000            Err(C2ControlError::Protocol(ref message))
6001                if message == SESSION_BUNDLE_MATERIALIZATION_NOT_NEGOTIATED
6002        ));
6003
6004        let mut explicit = inherited.clone();
6005        explicit.overrides.bundle_id = gate4agent_node_protocol::SpawnOverride::Set {
6006            value: hatchery_core::SpawnBundleId::new("review-bundle").unwrap(),
6007        };
6008        assert!(matches!(
6009            reject_unnegotiated_outbound_path(
6010                &NodeRequest::SpawnSpec { spec: explicit },
6011                capabilities,
6012            ),
6013            Err(C2ControlError::Protocol(ref message))
6014                if message == SESSION_BUNDLE_MATERIALIZATION_NOT_NEGOTIATED
6015        ));
6016
6017        let mut cleared = inherited;
6018        cleared.overrides.bundle_id = gate4agent_node_protocol::SpawnOverride::Clear;
6019        assert!(reject_unnegotiated_outbound_path(
6020            &NodeRequest::SpawnSpec { spec: cleared },
6021            capabilities,
6022        )
6023        .is_ok());
6024    }
6025
6026    #[test]
6027    fn managed_worktree_partial_capabilities_fail_closed() {
6028        let request = NodeRequest::SpawnManagedWorktree {
6029            request: hatchery_core::ManagedWorktreeSpawnRequest {
6030                spawn_spec: spawn_spec("node-a"),
6031                worktree_profile_id:
6032                    hatchery_core::WorktreeProfileId::new("review").unwrap(),
6033            },
6034        };
6035
6036        let mut capabilities = all_path_capabilities();
6037        capabilities.managed_worktree_lifecycle = false;
6038        assert!(matches!(
6039            reject_unnegotiated_outbound_path(&request, capabilities),
6040            Err(C2ControlError::Protocol(ref message))
6041                if message == MANAGED_WORKTREE_LIFECYCLE_NOT_NEGOTIATED
6042        ));
6043
6044        capabilities.managed_worktree_lifecycle = true;
6045        capabilities.worktree_selection = false;
6046        assert!(matches!(
6047            reject_unnegotiated_outbound_path(&request, capabilities),
6048            Err(C2ControlError::Protocol(ref message))
6049                if message == WORKTREE_SELECTION_NOT_NEGOTIATED
6050        ));
6051
6052        capabilities.worktree_selection = true;
6053        capabilities.spawn_spec_defaults_overrides = false;
6054        assert!(matches!(
6055            reject_unnegotiated_outbound_path(&request, capabilities),
6056            Err(C2ControlError::Protocol(ref message))
6057                if message == SPAWN_SPEC_NOT_NEGOTIATED
6058        ));
6059
6060        let cleanup = NodeRequest::CleanupManagedWorktree {
6061            lease_id: hatchery_core::ManagedWorktreeLeaseId::new("lease-a").unwrap(),
6062        };
6063        capabilities.spawn_spec_defaults_overrides = false;
6064        capabilities.worktree_selection = true;
6065        assert!(reject_unnegotiated_outbound_path(&cleanup, capabilities).is_ok());
6066        capabilities.worktree_selection = false;
6067        assert!(reject_unnegotiated_outbound_path(&cleanup, capabilities).is_err());
6068
6069        let event = C2NodeEvent::ManagedWorktreeRemoved {
6070            lease_id: hatchery_core::ManagedWorktreeLeaseId::new("lease-a").unwrap(),
6071        };
6072        assert!(c2_node_event_is_managed_worktree(&event));
6073
6074        let workspace = C2WorkspaceSnapshot {
6075            workspace_id: WorkspaceId::new("repo").unwrap(),
6076            canonical_root: OpaqueHostPath::utf8(r"C:\repo".to_owned()).unwrap(),
6077            sessions: Vec::new(),
6078            worktree_service_mode: Some(WorktreeServiceMode::Manual),
6079            managed_worktree_profiles: None,
6080        };
6081        assert!(c2_workspace_has_managed_worktree_metadata(&workspace));
6082        assert!(routed_response_requires_managed_worktree(&RoutedNodeResponse {
6083            node_id: NodeId::new("node-a").unwrap(),
6084            incarnation_id: NodeIncarnationId::from_bytes([1; 16]),
6085            response: Ok(C2NodeResponse::WorkspaceRegistered {
6086                workspace: workspace.clone(),
6087            }),
6088        }));
6089        assert!(c2_node_event_is_managed_worktree(
6090            &C2NodeEvent::WorkspaceAdded { workspace },
6091        ));
6092
6093        let legacy = C2WorkspaceSnapshot {
6094            workspace_id: WorkspaceId::new("legacy").unwrap(),
6095            canonical_root: OpaqueHostPath::utf8(r"C:\legacy".to_owned()).unwrap(),
6096            sessions: Vec::new(),
6097            worktree_service_mode: None,
6098            managed_worktree_profiles: None,
6099        };
6100        assert!(!c2_workspace_has_managed_worktree_metadata(&legacy));
6101        let profile_only = C2WorkspaceSnapshot {
6102            managed_worktree_profiles: Some(
6103                hatchery_core::WorktreeProfileInventory {
6104                    profiles: Vec::new(),
6105                },
6106            ),
6107            ..legacy
6108        };
6109        assert!(c2_workspace_has_managed_worktree_metadata(&profile_only));
6110    }
6111
6112    #[test]
6113    fn managed_worktree_v2_is_capability_gated_and_receipt_correlated() {
6114        let offer = client_compatibility_offer().unwrap();
6115        assert!(offer.capabilities.iter().any(|capability| {
6116            capability.as_str() == C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY
6117        }));
6118        let request = NodeRequest::SpawnManagedWorktreeV2 {
6119            request: hatchery_core::ManagedWorktreeSpawnRequestV2 {
6120                spawn_spec: spawn_spec("node-a"),
6121                worktree_profile_id:
6122                    hatchery_core::WorktreeProfileId::new("review").unwrap(),
6123                expected_profile_revision:
6124                    hatchery_core::WorktreeProfileRevision::new("wr1").unwrap(),
6125            },
6126        };
6127        let mut capabilities = all_path_capabilities();
6128        capabilities.managed_worktree_spawn_v2 = false;
6129        assert!(matches!(
6130            reject_unnegotiated_outbound_path(&request, capabilities),
6131            Err(C2ControlError::Protocol(ref message))
6132                if message == MANAGED_WORKTREE_SPAWN_V2_NOT_NEGOTIATED
6133        ));
6134
6135        let managed_workspace_id = WorkspaceId::new("managed-v2").unwrap();
6136        let mut spawn = spawn_receipt();
6137        spawn.profile_revision = SpawnProfileRevision::new("default.r1").unwrap();
6138        spawn.target.worktree_id = Some(managed_workspace_id.clone());
6139        spawn.session.workspace_id = managed_workspace_id.clone();
6140        let receipt = hatchery_core::ManagedWorktreeSpawnReceipt {
6141            spawn,
6142            lease: hatchery_core::ManagedWorktreeLeaseSnapshot {
6143                lease_id:
6144                    hatchery_core::ManagedWorktreeLeaseId::new("lease-v2").unwrap(),
6145                source_workspace_id: WorkspaceId::new("primary").unwrap(),
6146                workspace_id: managed_workspace_id,
6147                profile_id:
6148                    hatchery_core::WorktreeProfileId::new("review").unwrap(),
6149                profile_revision:
6150                    hatchery_core::WorktreeProfileRevision::new("wr1").unwrap(),
6151                retention: hatchery_core::ManagedWorktreeRetention::RemoveWhenReleased,
6152                state: hatchery_core::ManagedWorktreeLeaseState::InUse,
6153                active_session_count: 1,
6154                managed_record_count: 1,
6155                cleanup_failure: None,
6156                created_at_unix_ms: 1,
6157                updated_at_unix_ms: 2,
6158            },
6159        };
6160        let routed = RoutedNodeResponse {
6161            node_id: NodeId::new("node-a").unwrap(),
6162            incarnation_id: receipt.spawn.incarnation_id,
6163            response: Ok(C2NodeResponse::ManagedWorktreeSpawnAccepted {
6164                receipt: receipt.clone(),
6165            }),
6166        };
6167        assert!(validate_managed_worktree_spawn_v2_response(
6168            &request,
6169            &Ok(routed),
6170        )
6171        .is_ok());
6172
6173        let mut mismatched = receipt;
6174        mismatched.lease.profile_revision =
6175            hatchery_core::WorktreeProfileRevision::new("wr2").unwrap();
6176        let routed = RoutedNodeResponse {
6177            node_id: NodeId::new("node-a").unwrap(),
6178            incarnation_id: mismatched.spawn.incarnation_id,
6179            response: Ok(C2NodeResponse::ManagedWorktreeSpawnAccepted {
6180                receipt: mismatched,
6181            }),
6182        };
6183        assert!(validate_managed_worktree_spawn_v2_response(
6184            &request,
6185            &Ok(routed),
6186        )
6187        .is_err());
6188    }
6189
6190    #[test]
6191    fn history_context_pack_requests_and_failures_are_capability_bound() {
6192        let request = NodeRequest::DiscoverHistory {
6193            session: spawn_receipt().session,
6194            limit: 1,
6195        };
6196        let mut capabilities = all_path_capabilities();
6197        capabilities.history_context_pack = false;
6198        assert!(matches!(
6199            reject_unnegotiated_outbound_path(&request, capabilities),
6200            Err(C2ControlError::Protocol(ref message))
6201                if message == HISTORY_CONTEXT_PACK_NOT_NEGOTIATED
6202        ));
6203        capabilities.history_context_pack = true;
6204        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_ok());
6205
6206        let invalid = NodeRequest::DiscoverHistory {
6207            session: spawn_receipt().session,
6208            limit: 0,
6209        };
6210        assert!(matches!(
6211            reject_unnegotiated_outbound_path(&invalid, capabilities),
6212            Err(C2ControlError::Protocol(ref message))
6213                if message == INVALID_HISTORY_CONTEXT_PACK_REQUEST
6214        ));
6215
6216        capabilities.provider_ids_open = false;
6217        let forget = NodeRequest::ForgetContextPack {
6218            context_id: gate4agent_node_protocol::SpawnContextId::new("context-a").unwrap(),
6219        };
6220        assert!(matches!(
6221            reject_unnegotiated_outbound_path(&forget, capabilities),
6222            Err(C2ControlError::Protocol(ref message))
6223                if message == OPEN_PROVIDER_ID_NOT_NEGOTIATED
6224        ));
6225        capabilities.provider_ids_open = true;
6226        assert!(reject_unnegotiated_outbound_path(&forget, capabilities).is_ok());
6227
6228        let failure = RoutedNodeResponse {
6229            node_id: NodeId::new("node-a").unwrap(),
6230            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
6231            response: Err(hatchery_core::C2NodeFailure {
6232                code: gate4agent_node_protocol::NodeFailureCode::UnknownContextPack,
6233                message: "context pack unavailable".to_owned(),
6234            }),
6235        };
6236        assert!(routed_response_requires_history_context_pack(&failure));
6237        let response = RoutedNodeResponse {
6238            node_id: NodeId::new("node-a").unwrap(),
6239            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
6240            response: Ok(C2NodeResponse::HistoryLoaded {
6241                session: spawn_receipt().session,
6242                session_id: "session-1".to_owned(),
6243                message_count: 2,
6244                completed_turn_count: None,
6245            }),
6246        };
6247        assert!(routed_response_requires_history_context_pack(&response));
6248    }
6249
6250    #[test]
6251    fn native_session_catalog_request_is_capability_bound_and_read_only() {
6252        let route = gate4agent_node_protocol::NativeSessionCatalogRoute::workspace(
6253            gate4agent_node_protocol::WorkspaceId::new("primary").unwrap(),
6254            hatchery_core::AgentId::new("codex").unwrap(),
6255        );
6256        let request = NodeRequest::CatalogNativeSessions {
6257            route: route.clone(),
6258            limit: 8,
6259        };
6260        let mut capabilities = all_path_capabilities();
6261        capabilities.native_session_catalog = false;
6262        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_err());
6263        capabilities.native_session_catalog = true;
6264        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_ok());
6265        assert_eq!(control_request_deadline(&request), Duration::from_secs(40));
6266
6267        let page = NodeRequest::PageNativeSessions {
6268            route,
6269            window: hatchery_core::NativeSessionCatalogWindow::Older,
6270            catalog_revision: 7,
6271            recent_cutoff_unix_ms: 8,
6272            after_selection_id: Some("hist_selection_1".to_owned()),
6273            limit: 8,
6274        };
6275        capabilities.native_session_catalog_paging = false;
6276        assert!(reject_unnegotiated_outbound_path(&page, capabilities).is_err());
6277        capabilities.native_session_catalog_paging = true;
6278        assert!(reject_unnegotiated_outbound_path(&page, capabilities).is_ok());
6279        assert_eq!(control_request_deadline(&page), Duration::from_secs(40));
6280    }
6281
6282    #[tokio::test]
6283    async fn control_owner_rejects_reply_route_or_incarnation_mismatch() {
6284        let (commands_tx, commands_rx) = mpsc::channel(1);
6285        let (events_tx, _events_rx) = mpsc::channel(1);
6286        let (writer_tx, mut writer_rx) = mpsc::channel(1);
6287        let (incoming_tx, incoming_rx) = mpsc::channel(1);
6288        let (topology_tx, _topology_rx) =
6289            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
6290        let owner = tokio::spawn(control_owner(
6291            commands_rx,
6292            events_tx,
6293            topology_tx,
6294            writer_tx,
6295            incoming_rx,
6296            all_path_capabilities(),
6297        ));
6298        let (reply_tx, reply_rx) = oneshot::channel();
6299        commands_tx
6300            .send(ControlCommand {
6301                route: route(),
6302                request: NodeRequest::Snapshot,
6303                reply: reply_tx,
6304                correlation_id: None,
6305            })
6306            .await
6307            .unwrap();
6308        let C2ClientFrame::Request(request) = writer_rx.recv().await.unwrap() else {
6309            panic!("owner did not emit a request");
6310        };
6311        incoming_tx
6312            .send(OwnerInput::Frame(C2ServerFrame::Reply(
6313                hatchery_core::C2ReplyEnvelope {
6314                    request_id: request.request_id,
6315                    result: Ok(RoutedNodeResponse {
6316                        node_id: request.request.route.node_id,
6317                        incarnation_id: NodeIncarnationId::from_bytes([8; 16]),
6318                        response: Ok(C2NodeResponse::Accepted),
6319                    }),
6320                    correlation_id: None,
6321                },
6322            )))
6323            .await
6324            .unwrap();
6325        assert!(matches!(
6326            reply_rx.await.unwrap(),
6327            Err(C2ControlError::Protocol(ref message))
6328                if message.contains("route or node incarnation")
6329        ));
6330        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
6331    }
6332}
6333
6334#[cfg(all(test, unix))]
6335mod unix_endpoint_tests {
6336    use super::*;
6337
6338    #[test]
6339    fn unix_endpoint_remains_valid() {
6340        assert!(validate_endpoint("/tmp/gate4agent-c2.sock").is_ok());
6341    }
6342
6343    #[test]
6344    fn raw_public_tcp_endpoint_is_still_rejected_by_the_unix_validator() {
6345        for endpoint in ["203.0.113.8:51820", "10.88.1.2:27441", "tcp://10.88.1.2:27441"] {
6346            assert!(
6347                matches!(validate_endpoint(endpoint), Err(C2ControlError::InvalidEndpoint)),
6348                "{endpoint} must stay InvalidEndpoint",
6349            );
6350        }
6351    }
6352}