Skip to main content

winx_code_agent/daemon/
control.rs

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