1use std::collections::HashMap;
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use tokio::sync::Mutex;
8
9use super::lifecycle::{GuardianLifecycle, GuardianLimits};
10use super::protocol::{
11 read_json_frame, write_json_frame, CancelActionParams, ConfigureSessionParams,
12 DaemonProcessRole, HelloResult, JournalReadParams, PruneParams, RpcError, RpcRequest,
13 RpcResponse, RunActionParams, SessionInfo, SessionParams,
14 CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY, COMPACT_ACTION_OUTPUT_CAPABILITY,
15 GENERATION_BOUND_ACTIONS_CAPABILITY, PROTOCOL_MAJOR, TYPED_ACTION_RESULT_CAPABILITY,
16};
17use crate::errors::{Result, WinxError};
18use crate::types::normalize_thread_id;
19
20pub struct ControlServer {
23 listener: crate::daemon::transport::DaemonListener,
24 socket_path: PathBuf,
25 lifecycle: Arc<GuardianLifecycle>,
26 guardian_capabilities: Arc<Mutex<HashMap<PathBuf, CachedGuardian>>>,
27 guardian_negotiation_gates: Arc<Mutex<HashMap<PathBuf, GuardianNegotiationGate>>>,
28 epoch: String,
29}
30
31#[derive(Clone)]
32struct CachedGuardian {
33 hello: HelloResult,
34 last_seen: Instant,
35 socket_identity: Option<SocketIdentity>,
36}
37
38#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39struct SocketIdentity {
40 device: u64,
41 inode: u64,
42 ctime_seconds: i64,
43 ctime_nanoseconds: i64,
44 mtime_seconds: i64,
45 mtime_nanoseconds: i64,
46}
47
48struct GuardianNegotiationGate {
49 gate: Arc<Mutex<()>>,
50 last_seen: Instant,
51}
52
53const MAX_GUARDIAN_NEGOTIATIONS: usize = 256;
54const GUARDIAN_NEGOTIATION_TTL: Duration = Duration::from_secs(5 * 60);
55
56impl ControlServer {
57 pub async fn bind(socket_path: impl AsRef<Path>) -> Result<Self> {
58 let socket_path = socket_path.as_ref().to_path_buf();
59 let parent = socket_path.parent().ok_or_else(|| {
60 WinxError::ConfigurationError("daemon socket must have a parent directory".to_string())
61 })?;
62 tokio::fs::create_dir_all(parent).await?;
63 crate::daemon::transport::restrict_to_owner(parent, 0o700).await?;
64 let guardian_dir = parent.join("guardians");
65 tokio::fs::create_dir_all(&guardian_dir).await?;
66 crate::daemon::transport::restrict_to_owner(&guardian_dir, 0o700).await?;
67
68 let listener = crate::daemon::transport::DaemonListener::bind(&socket_path).await.map_err(
71 |error| {
72 WinxError::ConfigurationError(format!(
73 "cannot claim daemon socket {}: {error}",
74 socket_path.display()
75 ))
76 },
77 )?;
78 crate::daemon::transport::restrict_to_owner(&socket_path, 0o600).await?;
79 let lifecycle = Arc::new(GuardianLifecycle::new(
80 guardian_dir,
81 guardian_binary()?,
82 GuardianLimits::from_env()?,
83 ));
84
85 Ok(Self {
86 listener,
87 socket_path,
88 lifecycle,
89 guardian_capabilities: Arc::new(Mutex::new(HashMap::new())),
90 guardian_negotiation_gates: Arc::new(Mutex::new(HashMap::new())),
91 epoch: format!("{:016x}", rand::random::<u64>()),
92 })
93 }
94
95 pub async fn serve(mut self) -> Result<()> {
96 crate::daemon::exit::install("winxd", &self.socket_path, &self.epoch);
97 self.lifecycle.clone().spawn_sweeper();
98 loop {
99 let stream = match self.listener.accept().await {
100 Ok(stream) => stream,
101 Err(error) => {
102 crate::daemon::exit::record_current(
103 &crate::daemon::exit::ExitReason::ListenerFailure(error.to_string()),
104 );
105 return Err(error.into());
106 }
107 };
108 if !crate::daemon::transport::peer_is_same_user(&stream)? {
109 tracing::warn!("Rejected winxd control connection from a different uid");
110 continue;
111 }
112 let lifecycle = self.lifecycle.clone();
113 let guardian_capabilities = self.guardian_capabilities.clone();
114 let guardian_negotiation_gates = self.guardian_negotiation_gates.clone();
115 let epoch = self.epoch.clone();
116 tokio::spawn(async move {
117 if let Err(error) = serve_connection(
118 stream,
119 lifecycle,
120 guardian_capabilities,
121 guardian_negotiation_gates,
122 epoch,
123 )
124 .await
125 {
126 tracing::debug!("winxd control client disconnected: {error}");
127 }
128 });
129 }
130 }
131}
132
133impl Drop for ControlServer {
134 fn drop(&mut self) {
135 let _ = std::fs::remove_file(&self.socket_path);
136 }
137}
138
139async fn serve_connection(
140 mut stream: crate::daemon::transport::DaemonStream,
141 lifecycle: Arc<GuardianLifecycle>,
142 guardian_capabilities: Arc<Mutex<HashMap<PathBuf, CachedGuardian>>>,
143 guardian_negotiation_gates: Arc<Mutex<HashMap<PathBuf, GuardianNegotiationGate>>>,
144 epoch: String,
145) -> Result<()> {
146 loop {
147 let request: RpcRequest = match read_json_frame(&mut stream).await {
148 Ok(request) => request,
149 Err(error) if error.kind() == ErrorKind::UnexpectedEof => return Ok(()),
150 Err(error) => return Err(error.into()),
151 };
152 let response = dispatch(
153 request,
154 &lifecycle,
155 &guardian_capabilities,
156 &guardian_negotiation_gates,
157 &epoch,
158 )
159 .await;
160 write_json_frame(&mut stream, &response).await?;
161 }
162}
163
164#[allow(clippy::too_many_lines)]
165async fn dispatch(
166 mut request: RpcRequest,
167 lifecycle: &GuardianLifecycle,
168 guardian_capabilities: &Mutex<HashMap<PathBuf, CachedGuardian>>,
169 guardian_negotiation_gates: &Mutex<HashMap<PathBuf, GuardianNegotiationGate>>,
170 epoch: &str,
171) -> RpcResponse {
172 if request.jsonrpc != "2.0" {
173 return rpc_error(request.id, -32600, "JSON-RPC version must be 2.0");
174 }
175 if request.method == "winx.hello" {
176 return rpc_result(
177 request.id,
178 &HelloResult::current(
179 DaemonProcessRole::Control,
180 vec![
181 "per_session_guardians".to_string(),
182 "planned_control_restart".to_string(),
183 "guardian_quota".to_string(),
184 "guardian_idle_ttl".to_string(),
185 "guardian_activity_clock".to_string(),
186 "unused_session_ttl".to_string(),
187 "quota_reclaims_unused".to_string(),
188 "session.configure".to_string(),
189 "session.negotiate".to_string(),
190 "shell.run_action".to_string(),
191 TYPED_ACTION_RESULT_CAPABILITY.to_string(),
192 COMPACT_ACTION_OUTPUT_CAPABILITY.to_string(),
193 GENERATION_BOUND_ACTIONS_CAPABILITY.to_string(),
194 CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY.to_string(),
195 super::protocol::AGENT_WORKFLOWS_CAPABILITY.to_string(),
196 "session.list".to_string(),
197 "session.read_output".to_string(),
198 "session.kill".to_string(),
199 "session.prune".to_string(),
200 "multi_consumer_cursors".to_string(),
201 "idempotency".to_string(),
202 crate::daemon::protocol::PROCESS_SHUTDOWN_CAPABILITY.to_string(),
203 ],
204 epoch,
205 ),
206 );
207 }
208 if request.method == "winx.shutdown" {
209 crate::daemon::server::schedule_process_exit("winxd");
212 return rpc_result(request.id, &serde_json::json!({ "shutting_down": true }));
213 }
214
215 if request.method == "session.list" {
216 return match list_sessions(lifecycle.guardian_dir()).await {
217 Ok(sessions) => rpc_result(request.id, &sessions),
218 Err(error) => rpc_error(request.id, -32000, &error.to_string()),
219 };
220 }
221
222 if request.method == "session.prune" {
223 let params = match decode::<PruneParams>(request.params) {
224 Ok(params) => params,
225 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
226 };
227 return match lifecycle.prune(params.idle_seconds).await {
228 Ok(result) => {
229 let mut cache = guardian_capabilities.lock().await;
230 for thread_id in &result.removed_thread_ids {
231 cache.remove(&lifecycle.socket_for(thread_id));
232 }
233 rpc_result(request.id, &result)
234 }
235 Err(error) => rpc_error(request.id, -32003, &error.to_string()),
236 };
237 }
238
239 let thread_id = match request_thread_id(&request) {
240 Ok(thread_id) if !thread_id.is_empty() => thread_id,
241 Ok(_) => return rpc_error(request.id, -32602, "explicit thread_id is required"),
242 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
243 };
244 let guardian_socket = lifecycle.socket_for(&thread_id);
245 if request.method == "session.negotiate" {
246 return match negotiated_guardian(
247 lifecycle,
248 guardian_capabilities,
249 guardian_negotiation_gates,
250 &thread_id,
251 &guardian_socket,
252 true,
253 )
254 .await
255 {
256 Ok(hello) => rpc_result(request.id, &hello),
257 Err(error) => rpc_error(request.id, -32001, &error.to_string()),
258 };
259 }
260 if request.method == "session.kill" {
261 match tokio::fs::try_exists(&guardian_socket).await {
262 Ok(true) => {}
263 Ok(false) => return rpc_result(request.id, &false),
264 Err(error) => return rpc_error(request.id, -32002, &error.to_string()),
265 }
266 }
267
268 let create_if_missing =
269 matches!(request.method.as_str(), "session.configure" | "shell.run_action");
270 let mut hello = match negotiated_guardian(
271 lifecycle,
272 guardian_capabilities,
273 guardian_negotiation_gates,
274 &thread_id,
275 &guardian_socket,
276 create_if_missing,
277 )
278 .await
279 {
280 Ok(hello) => hello,
281 Err(error) => return rpc_error(request.id, -32001, &error.to_string()),
282 };
283 let live_precondition = match request_requires_live_guardian(&request) {
284 Ok(required) => required,
285 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
286 };
287 if !live_precondition {
288 if let Err(error) = normalize_guardian_request(&mut request, &hello) {
289 return rpc_error(request.id, -32602, &error.to_string());
290 }
291 }
292 let kill_after = request.method == "session.kill";
293 let request_id = request.id;
294 let response = if live_precondition {
295 match relay(&guardian_socket, request.clone()).await {
296 Ok((response, effective)) => {
297 hello = effective.clone();
298 cache_guardian(guardian_capabilities, guardian_socket.clone(), effective).await;
299 response
300 }
301 Err(error) => {
302 guardian_capabilities.lock().await.remove(&guardian_socket);
303 return rpc_error(request_id, -32602, &error.to_string());
304 }
305 }
306 } else {
307 match relay_negotiated(&guardian_socket, request.clone(), &hello).await {
308 Ok(result) => result,
309 Err(first_error) => {
310 if request.method == "session.release_temporary" {
311 return rpc_error(request_id, -32002, &format!("temporary release response unavailable; inspect the exact targets before retrying: {first_error}"));
312 }
313 guardian_capabilities.lock().await.remove(&guardian_socket);
314 let refreshed = match negotiated_guardian(
315 lifecycle,
316 guardian_capabilities,
317 guardian_negotiation_gates,
318 &thread_id,
319 &guardian_socket,
320 create_if_missing,
321 )
322 .await
323 {
324 Ok(hello) => hello,
325 Err(error) => {
326 return rpc_error(
327 request_id,
328 -32002,
329 &format!("{first_error}; guardian renegotiation failed: {error}"),
330 )
331 }
332 };
333 if request.method == "shell.run_action"
334 && refreshed.daemon_epoch != hello.daemon_epoch
335 {
336 return rpc_error(
337 request_id,
338 -32002,
339 &format!(
340 "{first_error}; guardian epoch changed, so winxd will not retry an ambiguously delivered shell action"
341 ),
342 );
343 }
344 if let Err(error) = normalize_guardian_request(&mut request, &refreshed) {
345 return rpc_error(request_id, -32602, &error.to_string());
346 }
347 match relay_negotiated(&guardian_socket, request, &refreshed).await {
348 Ok(result) => {
349 hello = refreshed;
350 result
351 }
352 Err(error) => return rpc_error(request_id, -32002, &error.to_string()),
353 }
354 }
355 }
356 };
357
358 if kill_after && response.error.is_none() {
359 guardian_capabilities.lock().await.remove(&guardian_socket);
360 if let Err(error) = lifecycle.finish_kill(&guardian_socket, hello.daemon_pid).await {
361 return rpc_error(
362 request_id,
363 -32003,
364 &format!("session state was removed, but guardian cleanup failed: {error}"),
365 );
366 }
367 } else {
368 lifecycle.note_activity(&guardian_socket, &thread_id, hello.daemon_pid).await;
369 }
370 response
371}
372
373fn request_thread_id(request: &RpcRequest) -> Result<String> {
374 let thread_id = match request.method.as_str() {
375 "session.configure" => {
376 decode::<ConfigureSessionParams>(request.params.clone())?.snapshot.chat_id
377 }
378 "shell.run_action" => decode::<RunActionParams>(request.params.clone())?.command.thread_id,
379 "shell.cancel_action" => decode::<CancelActionParams>(request.params.clone())?.thread_id,
380 "session.release_temporary" => {
381 decode::<crate::utils::agent_temp::release::TemporaryReleaseRequest>(
382 request.params.clone(),
383 )?
384 .thread_id
385 }
386 "session.read_output" => decode::<JournalReadParams>(request.params.clone())?.thread_id,
387 "session.negotiate" | "session.info" | "session.kill" | "session.interrupt" => {
388 decode::<SessionParams>(request.params.clone())?.thread_id
389 }
390 _ => {
391 return Err(WinxError::InvalidInput(format!(
392 "unsupported control method {}",
393 request.method
394 )))
395 }
396 };
397 Ok(normalize_thread_id(&thread_id))
398}
399
400async fn negotiated_guardian(
401 lifecycle: &GuardianLifecycle,
402 cache: &Mutex<HashMap<PathBuf, CachedGuardian>>,
403 gates: &Mutex<HashMap<PathBuf, GuardianNegotiationGate>>,
404 thread_id: &str,
405 socket: &Path,
406 create_if_missing: bool,
407) -> Result<HelloResult> {
408 if let Some(hello) = cached_guardian(cache, socket).await {
409 return Ok(hello);
410 }
411 let negotiation_gate = guardian_negotiation_gate(gates, socket).await?;
412 let _single_flight = negotiation_gate.lock().await;
413 if let Some(hello) = cached_guardian(cache, socket).await {
414 return Ok(hello);
415 }
416 if !create_if_missing && !tokio::fs::try_exists(socket).await? {
417 return Err(WinxError::ShellInitializationError(format!(
418 "guardian at {} is unavailable",
419 socket.display()
420 )));
421 }
422 let hello = lifecycle.ensure_guardian(thread_id, socket).await?;
423 cache_guardian(cache, socket.to_path_buf(), hello.clone()).await;
424 Ok(hello)
425}
426
427async fn cached_guardian(
428 cache: &Mutex<HashMap<PathBuf, CachedGuardian>>,
429 socket: &Path,
430) -> Option<HelloResult> {
431 let socket_identity = socket_identity(socket).await;
432 let now = Instant::now();
433 let mut cache = cache.lock().await;
434 prune_guardian_cache(&mut cache, now);
435 if let Some(entry) = cache
436 .get_mut(socket)
437 .filter(|entry| socket_identity.is_some() && entry.socket_identity == socket_identity)
438 {
439 entry.last_seen = now;
440 return Some(entry.hello.clone());
441 }
442 cache.remove(socket);
443 None
444}
445
446fn prune_guardian_cache(cache: &mut HashMap<PathBuf, CachedGuardian>, now: Instant) {
447 cache.retain(|_, entry| now.duration_since(entry.last_seen) <= GUARDIAN_NEGOTIATION_TTL);
448}
449
450async fn guardian_negotiation_gate(
451 gates: &Mutex<HashMap<PathBuf, GuardianNegotiationGate>>,
452 socket: &Path,
453) -> Result<Arc<Mutex<()>>> {
454 let now = Instant::now();
455 let mut gates = gates.lock().await;
456 gates.retain(|_, entry| {
457 now.duration_since(entry.last_seen) <= GUARDIAN_NEGOTIATION_TTL
458 || Arc::strong_count(&entry.gate) > 1
459 });
460 if let Some(entry) = gates.get_mut(socket) {
461 entry.last_seen = now;
462 return Ok(Arc::clone(&entry.gate));
463 }
464 if gates.len() >= MAX_GUARDIAN_NEGOTIATIONS {
465 let oldest = gates
466 .iter()
467 .filter(|(_, entry)| Arc::strong_count(&entry.gate) == 1)
468 .min_by_key(|(_, entry)| entry.last_seen)
469 .map(|(path, _)| path.clone());
470 if let Some(oldest) = oldest {
471 gates.remove(&oldest);
472 }
473 }
474 if gates.len() >= MAX_GUARDIAN_NEGOTIATIONS {
475 return Err(WinxError::ResourceAllocationError {
476 message: format!(
477 "all {MAX_GUARDIAN_NEGOTIATIONS} guardian negotiation slots are active"
478 ),
479 });
480 }
481 let gate = Arc::new(Mutex::new(()));
482 gates.insert(
483 socket.to_path_buf(),
484 GuardianNegotiationGate { gate: Arc::clone(&gate), last_seen: now },
485 );
486 Ok(gate)
487}
488
489async fn cache_guardian(
490 cache: &Mutex<HashMap<PathBuf, CachedGuardian>>,
491 socket: PathBuf,
492 hello: HelloResult,
493) {
494 let socket_identity = socket_identity(&socket).await;
498 let now = Instant::now();
499 let mut cache = cache.lock().await;
500 prune_guardian_cache(&mut cache, now);
501 if !cache.contains_key(&socket) && cache.len() >= MAX_GUARDIAN_NEGOTIATIONS {
502 if let Some(oldest) =
503 cache.iter().min_by_key(|(_, entry)| entry.last_seen).map(|(path, _)| path.clone())
504 {
505 cache.remove(&oldest);
506 }
507 }
508 cache.insert(socket, CachedGuardian { hello, last_seen: now, socket_identity });
509}
510
511async fn socket_identity(socket: &Path) -> Option<SocketIdentity> {
515 let metadata = tokio::fs::metadata(socket).await.ok()?;
516 #[cfg(unix)]
517 {
518 use std::os::unix::fs::MetadataExt as _;
519 Some(SocketIdentity {
520 device: metadata.dev(),
521 inode: metadata.ino(),
522 ctime_seconds: metadata.ctime(),
523 ctime_nanoseconds: metadata.ctime_nsec(),
524 mtime_seconds: metadata.mtime(),
525 mtime_nanoseconds: metadata.mtime_nsec(),
526 })
527 }
528 #[cfg(not(unix))]
529 {
530 use std::os::windows::fs::MetadataExt as _;
531 let modified = metadata
532 .modified()
533 .ok()
534 .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok());
535 Some(SocketIdentity {
536 device: 0,
537 inode: metadata.file_size(),
538 ctime_seconds: i64::try_from(metadata.creation_time()).unwrap_or(i64::MAX),
539 ctime_nanoseconds: 0,
540 mtime_seconds: modified
541 .map_or(0, |duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)),
542 mtime_nanoseconds: modified.map_or(0, |duration| i64::from(duration.subsec_nanos())),
543 })
544 }
545}
546
547fn normalize_temporary_release_request(request: &RpcRequest, hello: &HelloResult) -> Result<()> {
548 if request.method != "session.release_temporary" {
549 return Ok(());
550 }
551 let params = decode::<crate::utils::agent_temp::release::TemporaryReleaseRequest>(
552 request.params.clone(),
553 )?;
554 let capable = hello
555 .capabilities
556 .iter()
557 .any(|candidate| candidate == super::protocol::TEMPORARY_RELEASE_CAPABILITY);
558 if !capable || params.expected_guardian_epoch.as_deref() != Some(hello.daemon_epoch.as_str()) {
559 return Err(WinxError::InvalidInput("temporary release requires the same capable guardian; no retry against a replacement is allowed".into()));
560 }
561 Ok(())
562}
563
564fn normalize_guardian_request(request: &mut RpcRequest, hello: &HelloResult) -> Result<()> {
565 normalize_temporary_release_request(request, hello)?;
566 let has = |capability: &str| hello.capabilities.iter().any(|candidate| candidate == capability);
567 if request.method == "shell.run_action" {
568 let mut params = decode::<RunActionParams>(request.params.clone())?;
569 if matches!(
570 ¶ms.command.action_json,
571 crate::types::BashCommandAction::Inspect { .. }
572 | crate::types::BashCommandAction::Prompt { .. }
573 ) && !has(super::protocol::AGENT_WORKFLOWS_CAPABILITY)
574 {
575 return Err(WinxError::InvalidInput(
576 "the effective guardian does not support native agent workflows; no input was sent"
577 .to_string(),
578 ));
579 }
580 if !has(TYPED_ACTION_RESULT_CAPABILITY) {
581 return Err(WinxError::ConfigurationError(format!(
582 "guardian pid {} does not advertise {TYPED_ACTION_RESULT_CAPABILITY}; terminate this durable session and initialize it again before running another BashCommand",
583 hello.daemon_pid
584 )));
585 }
586 if params
587 .options
588 .expected_guardian_epoch
589 .as_deref()
590 .is_some_and(|expected| expected != hello.daemon_epoch)
591 || params
592 .options
593 .expected_execution
594 .as_ref()
595 .is_some_and(|expected| expected.guardian_epoch != hello.daemon_epoch)
596 {
597 return Err(WinxError::InvalidInput(
598 "shell action precondition refers to a different effective guardian epoch"
599 .to_string(),
600 ));
601 }
602 if (params.options.expected_generation.is_some()
603 || params.options.expected_execution.is_some()
604 || params.options.require_generation_binding)
605 && !has(GENERATION_BOUND_ACTIONS_CAPABILITY)
606 {
607 return Err(WinxError::InvalidInput(
608 "the running guardian does not support generation-bound shell actions".to_string(),
609 ));
610 }
611 if params.options.cancellation_key.is_some()
612 && !has(CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY)
613 {
614 return Err(WinxError::InvalidInput(
615 "the running guardian does not support cancellable action reservations".to_string(),
616 ));
617 }
618 if !has(COMPACT_ACTION_OUTPUT_CAPABILITY) {
619 params.options.compact_output = false;
620 }
621 request.params = serde_json::to_value(params)
622 .map_err(|error| WinxError::SerializationError(error.to_string()))?;
623 } else if request.method == "session.interrupt" {
624 let params = decode::<SessionParams>(request.params.clone())?;
625 if params
626 .expected_guardian_epoch
627 .as_deref()
628 .is_some_and(|expected| expected != hello.daemon_epoch)
629 || params
630 .expected_execution
631 .as_ref()
632 .is_some_and(|expected| expected.guardian_epoch != hello.daemon_epoch)
633 {
634 return Err(WinxError::InvalidInput(
635 "interrupt precondition refers to a different effective guardian epoch".to_string(),
636 ));
637 }
638 if (params.expected_generation.is_some() || params.expected_execution.is_some())
639 && !has(GENERATION_BOUND_ACTIONS_CAPABILITY)
640 {
641 return Err(WinxError::InvalidInput(
642 "the running guardian does not support generation-bound interruption".to_string(),
643 ));
644 }
645 } else if request.method == "shell.cancel_action" {
646 let params = decode::<CancelActionParams>(request.params.clone())?;
647 if !has(CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY) {
648 return Err(WinxError::InvalidInput(
649 "the running guardian does not support cancellable action reservations".to_string(),
650 ));
651 }
652 if params
653 .expected_guardian_epoch
654 .as_deref()
655 .is_some_and(|expected| expected != hello.daemon_epoch)
656 {
657 return Err(WinxError::InvalidInput(
658 "cancel precondition refers to a different effective guardian epoch".to_string(),
659 ));
660 }
661 }
662 Ok(())
663}
664
665fn request_requires_live_guardian(request: &RpcRequest) -> Result<bool> {
666 match request.method.as_str() {
667 "shell.run_action" => {
668 let params = decode::<RunActionParams>(request.params.clone())?;
669 Ok(params.options.require_generation_binding
670 || params.options.expected_generation.is_some()
671 || params.options.expected_execution.is_some()
672 || params.options.expected_guardian_epoch.is_some()
673 || params.options.cancellation_key.is_some())
674 }
675 "session.interrupt" => {
676 let params = decode::<SessionParams>(request.params.clone())?;
677 Ok(params.expected_generation.is_some()
678 || params.expected_execution.is_some()
679 || params.expected_guardian_epoch.is_some())
680 }
681 "shell.cancel_action" | "session.release_temporary" => Ok(true),
682 _ => Ok(false),
683 }
684}
685
686async fn relay(socket: &Path, mut request: RpcRequest) -> Result<(RpcResponse, HelloResult)> {
687 let mut stream = crate::daemon::transport::connect(socket).await.map_err(|error| {
688 WinxError::ShellInitializationError(format!(
689 "guardian at {} is unavailable: {error}",
690 socket.display()
691 ))
692 })?;
693 let hello_id = rand::random::<u64>();
694 write_json_frame(
695 &mut stream,
696 &RpcRequest {
697 jsonrpc: "2.0".to_string(),
698 id: hello_id,
699 method: "winx.hello".to_string(),
700 params: serde_json::json!({ "protocol_major": PROTOCOL_MAJOR }),
701 },
702 )
703 .await?;
704 let hello_response: RpcResponse = read_json_frame(&mut stream).await?;
705 let hello: HelloResult = decode(hello_response.result.ok_or_else(|| {
706 WinxError::ConfigurationError("guardian handshake omitted result".to_string())
707 })?)?;
708 if hello.protocol_major != PROTOCOL_MAJOR {
709 return Err(WinxError::ConfigurationError(format!(
710 "guardian protocol major {} is incompatible with control {}",
711 hello.protocol_major, PROTOCOL_MAJOR
712 )));
713 }
714 if request.method == "shell.run_action"
715 && !hello.capabilities.iter().any(|capability| capability == TYPED_ACTION_RESULT_CAPABILITY)
716 {
717 return Err(WinxError::ConfigurationError(format!(
718 "guardian pid {} does not advertise {TYPED_ACTION_RESULT_CAPABILITY}; terminate this durable session and initialize it again before running another BashCommand",
719 hello.daemon_pid
720 )));
721 }
722 normalize_guardian_request(&mut request, &hello)?;
723 write_json_frame(&mut stream, &request).await?;
724 let response: RpcResponse = read_json_frame(&mut stream).await?;
725 Ok((response, hello))
726}
727
728async fn relay_negotiated(
729 socket: &Path,
730 request: RpcRequest,
731 hello: &HelloResult,
732) -> Result<RpcResponse> {
733 let mut stream = crate::daemon::transport::connect(socket).await.map_err(|error| {
734 WinxError::ShellInitializationError(format!(
735 "guardian at {} is unavailable: {error}",
736 socket.display()
737 ))
738 })?;
739 write_json_frame(&mut stream, &request).await?;
740 let response: RpcResponse = read_json_frame(&mut stream).await?;
741 if response.id != request.id || response.jsonrpc != "2.0" {
742 return Err(WinxError::ParseError(format!(
743 "guardian pid {} returned a mismatched JSON-RPC response",
744 hello.daemon_pid
745 )));
746 }
747 Ok(response)
748}
749
750async fn list_sessions(guardian_dir: &Path) -> Result<Vec<SessionInfo>> {
751 let mut sessions = Vec::new();
752 let mut entries = tokio::fs::read_dir(guardian_dir).await?;
753 while let Some(entry) = entries.next_entry().await? {
754 let path = entry.path();
755 if path.extension().and_then(|extension| extension.to_str()) != Some("sock") {
756 continue;
757 }
758 let request = RpcRequest {
759 jsonrpc: "2.0".to_string(),
760 id: rand::random::<u64>(),
761 method: "session.list".to_string(),
762 params: serde_json::json!({}),
763 };
764 let Ok((response, hello)) = relay(&path, request).await else { continue };
765 let Some(result) = response.result else { continue };
766 if let Ok(mut guardian_sessions) = serde_json::from_value::<Vec<SessionInfo>>(result) {
767 for session in &mut guardian_sessions {
768 session.runtime = Some(hello.clone());
769 }
770 sessions.append(&mut guardian_sessions);
771 }
772 }
773 sessions.sort_by(|left, right| left.thread_id.cmp(&right.thread_id));
774 Ok(sessions)
775}
776
777fn decode<T: serde::de::DeserializeOwned>(value: serde_json::Value) -> Result<T> {
778 serde_json::from_value(value)
779 .map_err(|error| WinxError::DeserializationError(error.to_string()))
780}
781
782fn guardian_binary() -> Result<PathBuf> {
783 if let Some(path) = std::env::var_os("WINX_GUARDIAN_BIN") {
784 return Ok(PathBuf::from(path));
785 }
786 let executable = std::env::current_exe()?;
787 let sibling = executable.with_file_name(if cfg!(windows) {
788 "winx-guardian.exe"
789 } else {
790 "winx-guardian"
791 });
792 if sibling.is_file() {
793 Ok(sibling)
794 } else {
795 Err(WinxError::ConfigurationError(format!(
796 "winx-guardian not found beside {} (set WINX_GUARDIAN_BIN)",
797 executable.display()
798 )))
799 }
800}
801
802fn rpc_result<T: serde::Serialize>(id: u64, result: &T) -> RpcResponse {
803 match serde_json::to_value(result) {
804 Ok(result) => {
805 RpcResponse { jsonrpc: "2.0".to_string(), id, result: Some(result), error: None }
806 }
807 Err(error) => rpc_error(id, -32603, &error.to_string()),
808 }
809}
810
811fn rpc_error(id: u64, code: i32, message: &str) -> RpcResponse {
812 RpcResponse {
813 jsonrpc: "2.0".to_string(),
814 id,
815 result: None,
816 error: Some(RpcError { code, message: message.to_string() }),
817 }
818}
819
820#[cfg(test)]
821mod tests {
822 #![allow(clippy::expect_used)]
823
824 use super::*;
825 use crate::runtime::ShellActionOptions;
826 use crate::state::bash_state::BashState;
827 use crate::types::BashCommand;
828 #[cfg(unix)]
829 use tokio::net::UnixListener;
830
831 fn guardian_1_4() -> HelloResult {
832 HelloResult {
833 protocol_major: PROTOCOL_MAJOR,
834 protocol_minor: 4,
835 capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
836 max_frame_bytes: crate::daemon::protocol::MAX_FRAME_BYTES,
837 daemon_epoch: "guardian-14".to_string(),
838 daemon_pid: 14,
839 process_role: None,
840 build: None,
841 }
842 }
843
844 #[test]
845 fn guardian_1_4_normalization_preserves_adapter_wait_policy_semantics() {
846 let command: BashCommand = serde_json::from_value(serde_json::json!({
847 "command": "printf compatible",
848 "wait_policy": "until_complete",
849 "thread_id": "guardian14"
850 }))
851 .expect("adapter command");
852 let mut request = RpcRequest {
853 jsonrpc: "2.0".to_string(),
854 id: 14,
855 method: "shell.run_action".to_string(),
856 params: serde_json::to_value(RunActionParams {
857 snapshot: BashState::new().snapshot(),
858 command,
859 request_key: "request14".to_string(),
860 consumer_id: "consumer14".to_string(),
861 options: ShellActionOptions {
862 compact_output: true,
863 ..ShellActionOptions::default()
864 },
865 })
866 .expect("run params"),
867 };
868
869 normalize_guardian_request(&mut request, &guardian_1_4()).expect("normalization");
870 let wire = request.params.as_object().expect("params object");
871 assert!(wire.get("options").is_none());
872 assert!(wire["command"].get("wait_policy").is_none());
873 }
874
875 #[test]
876 fn guardian_1_4_rejects_generation_bound_action_before_relay() {
877 let command: BashCommand = serde_json::from_value(serde_json::json!({
878 "command": "printf compatible",
879 "thread_id": "guardian14"
880 }))
881 .expect("adapter command");
882 let mut request = RpcRequest {
883 jsonrpc: "2.0".to_string(),
884 id: 15,
885 method: "shell.run_action".to_string(),
886 params: serde_json::to_value(RunActionParams {
887 snapshot: BashState::new().snapshot(),
888 command,
889 request_key: "request15".to_string(),
890 consumer_id: "consumer15".to_string(),
891 options: ShellActionOptions {
892 expected_generation: Some(7),
893 ..ShellActionOptions::default()
894 },
895 })
896 .expect("run params"),
897 };
898 assert!(matches!(
899 normalize_guardian_request(&mut request, &guardian_1_4()),
900 Err(WinxError::InvalidInput(_))
901 ));
902 }
903
904 #[test]
905 fn native_workflow_actions_require_the_effective_guardian_capability_before_relay() {
906 for action in [
907 serde_json::json!({ "type": "inspect", "bg_command_id": "one" }),
908 serde_json::json!({ "type": "prompt", "bg_command_id": "one", "prompt_id": "once", "text": "no input on old guardian" }),
909 ] {
910 let command: BashCommand = serde_json::from_value(serde_json::json!({
911 "action_json": action, "thread_id": "guardian14",
912 }))
913 .expect("native action");
914 let mut request = RpcRequest {
915 jsonrpc: "2.0".to_string(),
916 id: 16,
917 method: "shell.run_action".to_string(),
918 params: serde_json::to_value(RunActionParams {
919 snapshot: BashState::new().snapshot(),
920 command,
921 request_key: "native16".to_string(),
922 consumer_id: "consumer16".to_string(),
923 options: ShellActionOptions::default(),
924 })
925 .expect("native run params"),
926 };
927 let before = request.params.clone();
928 let error = normalize_guardian_request(&mut request, &guardian_1_4())
929 .expect_err("old guardian cannot accept native workflow actions");
930 assert!(error.to_string().contains("does not support native agent workflows"));
931 assert_eq!(
932 request.params, before,
933 "unsupported actions never reach normalization/relay"
934 );
935 let mut supported = guardian_1_4();
936 supported
937 .capabilities
938 .push(super::super::protocol::AGENT_WORKFLOWS_CAPABILITY.to_string());
939 normalize_guardian_request(&mut request, &supported)
940 .expect("capability authorizes native action");
941 }
942 }
943
944 #[cfg(unix)] #[tokio::test]
946 async fn stale_1_5_precondition_sends_zero_action_frames_to_1_4_guardian() {
947 let temp = tempfile::tempdir().expect("temporary directory");
948 let socket = temp.path().join("guardian.sock");
949 let listener = UnixListener::bind(&socket).expect("mock guardian");
950 let guardian = tokio::spawn(async move {
951 let (mut stream, _) = listener.accept().await.expect("control connection");
952 let hello: RpcRequest = read_json_frame(&mut stream).await.expect("hello frame");
953 assert_eq!(hello.method, "winx.hello");
954 write_json_frame(&mut stream, &rpc_result(hello.id, &guardian_1_4()))
955 .await
956 .expect("hello response");
957 tokio::time::timeout(
958 Duration::from_millis(100),
959 read_json_frame::<_, RpcRequest>(&mut stream),
960 )
961 .await
962 .is_ok_and(|frame| frame.is_ok())
963 });
964 let command: BashCommand = serde_json::from_value(serde_json::json!({
965 "command": "touch must-not-run",
966 "thread_id": "stale-guardian"
967 }))
968 .expect("command");
969 let request = RpcRequest {
970 jsonrpc: "2.0".to_string(),
971 id: 16,
972 method: "shell.run_action".to_string(),
973 params: serde_json::to_value(RunActionParams {
974 snapshot: BashState::new().snapshot(),
975 command,
976 request_key: "stale-request".to_string(),
977 consumer_id: "stale-consumer".to_string(),
978 options: ShellActionOptions {
979 require_generation_binding: true,
980 expected_guardian_epoch: Some("guardian-15".to_string()),
981 ..ShellActionOptions::default()
982 },
983 })
984 .expect("params"),
985 };
986
987 assert!(relay(&socket, request).await.is_err());
988 assert!(!guardian.await.expect("guardian task"));
989 }
990
991 #[tokio::test]
992 async fn guardian_negotiation_cache_is_bounded_under_churn() {
993 let cache = Mutex::new(HashMap::new());
994 for index in 0..(MAX_GUARDIAN_NEGOTIATIONS + 50) {
995 let mut hello = guardian_1_4();
996 hello.daemon_epoch = format!("epoch-{index}");
997 cache_guardian(&cache, PathBuf::from(format!("guardian-{index}.sock")), hello).await;
998 }
999 assert_eq!(cache.lock().await.len(), MAX_GUARDIAN_NEGOTIATIONS);
1000 }
1001
1002 #[tokio::test]
1003 async fn concurrent_cold_guardian_negotiation_uses_one_gate() {
1004 let gates = Mutex::new(HashMap::new());
1005 let socket = PathBuf::from("same-guardian.sock");
1006 let (first, second) = tokio::join!(
1007 guardian_negotiation_gate(&gates, &socket),
1008 guardian_negotiation_gate(&gates, &socket)
1009 );
1010 assert!(Arc::ptr_eq(
1011 &first.expect("first negotiation gate"),
1012 &second.expect("concurrent negotiation gate")
1013 ));
1014 assert_eq!(gates.lock().await.len(), 1);
1015 }
1016
1017 #[tokio::test]
1018 async fn guardian_negotiation_gates_apply_backpressure_without_evicting_active_slots() {
1019 let gates = Mutex::new(HashMap::new());
1020 let mut active = Vec::with_capacity(MAX_GUARDIAN_NEGOTIATIONS);
1021 for index in 0..MAX_GUARDIAN_NEGOTIATIONS {
1022 active.push(
1023 guardian_negotiation_gate(
1024 &gates,
1025 &PathBuf::from(format!("active-guardian-{index}.sock")),
1026 )
1027 .await
1028 .expect("active gate"),
1029 );
1030 }
1031
1032 let overflow =
1033 guardian_negotiation_gate(&gates, &PathBuf::from("overflow-guardian.sock")).await;
1034 assert!(matches!(overflow, Err(WinxError::ResourceAllocationError { .. })));
1035 assert_eq!(gates.lock().await.len(), MAX_GUARDIAN_NEGOTIATIONS);
1036
1037 drop(active.pop());
1038 guardian_negotiation_gate(&gates, &PathBuf::from("replacement-guardian.sock"))
1039 .await
1040 .expect("inactive slot can be replaced");
1041 assert_eq!(gates.lock().await.len(), MAX_GUARDIAN_NEGOTIATIONS);
1042 }
1043
1044 #[cfg(unix)] #[tokio::test]
1046 async fn recreated_guardian_socket_invalidates_negotiation_cache() {
1047 let temp = tempfile::tempdir().expect("temporary directory");
1048 let socket = temp.path().join("guardian.sock");
1049 let first_listener = UnixListener::bind(&socket).expect("first guardian socket");
1050 let cache = Mutex::new(HashMap::new());
1051 let hello = guardian_1_4();
1052 cache_guardian(&cache, socket.clone(), hello.clone()).await;
1053
1054 assert_eq!(
1055 cached_guardian(&cache, &socket).await.expect("cached first guardian").daemon_epoch,
1056 hello.daemon_epoch
1057 );
1058
1059 drop(first_listener);
1060 tokio::fs::remove_file(&socket).await.expect("remove first socket");
1061 let _second_listener = UnixListener::bind(&socket).expect("recreated guardian socket");
1062
1063 assert!(cached_guardian(&cache, &socket).await.is_none());
1064 assert!(cache.lock().await.is_empty());
1065 }
1066}