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