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