Skip to main content

winx_code_agent/daemon/
control.rs

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
20/// Stable control plane. Each logical session is owned by a separate guardian
21/// process, so restarting this process does not close any PTY master.
22pub 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        // The listener claims the path atomically; a concurrent daemon start
69        // fails here instead of stealing a live socket.
70        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        // Planned control restart: guardians are detached processes and keep
210        // their PTYs; only the control plane exits.
211        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    // Read filesystem identity before taking the global cache mutex. Besides
495    // avoiding I/O under the lock, this makes an unlinked/recreated guardian
496    // socket invalidate the old epoch immediately instead of waiting for TTL.
497    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
511/// Identity of the socket artifact, used to notice a guardian that was
512/// recreated behind the same path. Unix reads the inode; Windows uses the
513/// marker file's creation time and size, which change on every re-bind.
514async 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            &params.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)] // mocks a guardian with a raw UnixListener
945    #[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)] // mocks a guardian with a raw UnixListener
1045    #[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}