Skip to main content

agentos_client/
sidecar.rs

1//! `AgentOsSidecar` (public transport handle) + placement/description + the process-global shared
2//! pool + internal lease accounting.
3//!
4//! Ported from `packages/core/src/agent-os.ts` (`AgentOsSidecar`). The shared-sidecar pool is a
5//! process-global map (default pool `"default"`).
6
7#[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU8, Ordering};
10use std::sync::Arc;
11
12use once_cell::sync::OnceCell;
13use scc::HashMap as SccHashMap;
14use serde::Serialize;
15use uuid::Uuid;
16
17use agentos_sidecar_client::wire;
18
19use crate::agent_os::AgentOs;
20use crate::error::ClientError;
21use crate::transport::SidecarProcess;
22
23/// Maximum shared sidecar pool entries retained process-wide.
24const SHARED_SIDECAR_POOL_LIMIT: usize = 1024;
25
26/// Env var that overrides the Agent OS wrapper sidecar binary path.
27const AGENTOS_SIDECAR_BIN_ENV: &str = "AGENTOS_SIDECAR_BIN";
28
29static SHARED_SIDECAR_PACKAGE_CACHE: OnceCell<crate::software::ProcessPackageCacheOptions> =
30    OnceCell::new();
31
32/// Configure the package cache of child sidecars before opening any shared
33/// connection. The hosted worker uses this once at process startup.
34#[cfg(feature = "sidecar-internals")]
35pub fn configure_shared_sidecar_package_cache(
36    options: crate::software::ProcessPackageCacheOptions,
37) -> Result<(), ClientError> {
38    options.validate()?;
39    if let Some(existing) = SHARED_SIDECAR_PACKAGE_CACHE.get() {
40        if existing == &options {
41            return Ok(());
42        }
43        return Err(ClientError::PackageCacheConfiguration(
44            "shared sidecar package cache is already configured differently".into(),
45        ));
46    }
47    match SHARED_SIDECAR_PACKAGE_CACHE.set(options.clone()) {
48        Ok(()) => Ok(()),
49        Err(_) => configure_shared_sidecar_package_cache(options),
50    }
51}
52
53fn sidecar_spawn_args() -> Result<Vec<String>, ClientError> {
54    let Some(options) = SHARED_SIDECAR_PACKAGE_CACHE.get() else {
55        return Ok(Vec::new());
56    };
57    let mut args = vec!["sidecar".to_owned()];
58    if let Some(path) = &options.root {
59        let path = path.to_str().ok_or_else(|| {
60            ClientError::PackageCacheConfiguration(
61                "package cache directory is not valid UTF-8 for sidecar startup".into(),
62            )
63        })?;
64        args.extend(["--package-cache-dir".into(), path.into()]);
65    }
66    for (name, value) in [
67        ("--package-cache-min-free-bytes", options.min_free_bytes),
68        ("--package-cache-max-bytes", options.max_bytes),
69        ("--package-cache-max-entries", options.max_entries as u64),
70        (
71            "--package-cache-max-concurrent-acquisitions",
72            options.max_concurrent_acquisitions as u64,
73        ),
74        (
75            "--package-cache-max-pending-acquisitions",
76            options.max_pending_acquisitions as u64,
77        ),
78        (
79            "--package-cache-acquisition-timeout-ms",
80            options.acquisition_timeout_ms,
81        ),
82        (
83            "--package-cache-max-source-entries",
84            options.max_source_entries as u64,
85        ),
86        ("--package-cache-source-ttl-ms", options.source_ttl_ms),
87    ] {
88        args.extend([name.into(), value.to_string()]);
89    }
90    Ok(args)
91}
92
93/// The lazily-established shared sidecar process + authenticated connection. Multiple VMs in the same
94/// (shared) sidecar reuse this single process/connection, each opening its own session + VM on it.
95pub(crate) struct SharedConnection {
96    pub(crate) transport: Arc<SidecarProcess>,
97    pub(crate) connection_id: String,
98    #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
99    package_session_id: Option<String>,
100    authenticated: bool,
101}
102
103/// Sidecar lifecycle state, encoded as a `u8` for `AtomicU8`.
104///
105/// Parity: TypeScript `describe()` returns a JSON-serializable description whose `state` is exactly
106/// `"ready" | "disposing" | "disposed"`. The `#[serde(rename_all = "lowercase")]` attribute and the
107/// matching [`SidecarState::as_str`] reproduce that wire string so [`AgentOsSidecarDescription`]
108/// serializes to the same JSON shape.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
110#[serde(rename_all = "lowercase")]
111pub enum SidecarState {
112    Ready,
113    Disposing,
114    Disposed,
115}
116
117impl SidecarState {
118    /// The TypeScript wire string for this state (`"ready" | "disposing" | "disposed"`).
119    pub const fn as_str(self) -> &'static str {
120        match self {
121            SidecarState::Ready => "ready",
122            SidecarState::Disposing => "disposing",
123            SidecarState::Disposed => "disposed",
124        }
125    }
126
127    pub(crate) const fn as_u8(self) -> u8 {
128        match self {
129            SidecarState::Ready => 0,
130            SidecarState::Disposing => 1,
131            SidecarState::Disposed => 2,
132        }
133    }
134
135    pub(crate) const fn from_u8(value: u8) -> Self {
136        match value {
137            0 => SidecarState::Ready,
138            1 => SidecarState::Disposing,
139            2 => SidecarState::Disposed,
140            // Any other bit pattern is unreachable; the field is only written via `as_u8`.
141            _ => SidecarState::Disposed,
142        }
143    }
144}
145
146/// Where a sidecar lives.
147///
148/// Parity: TypeScript `AgentOsSidecarPlacement` is `{ kind: "shared"; pool?: string }` or
149/// `{ kind: "explicit"; sidecarId: string }`. The serde `tag`/`rename` attributes reproduce that
150/// JSON shape, including omitting `pool` when it is `None` (matching the `...(pool ? { pool } : {})`
151/// spread in `getSharedAgentOsSidecarInternal`).
152#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
153#[serde(tag = "kind", rename_all = "lowercase")]
154pub enum AgentOsSidecarPlacement {
155    Shared {
156        #[serde(skip_serializing_if = "Option::is_none")]
157        pool: Option<String>,
158    },
159    Explicit {
160        #[serde(rename = "sidecarId")]
161        sidecar_id: String,
162    },
163}
164
165/// A sync, deep-clone snapshot of a sidecar's state.
166///
167/// Parity: serializes to the TypeScript `AgentOsSidecarDescription` JSON shape
168/// (`{ sidecarId, placement, state, activeVmCount }`).
169#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170#[serde(rename_all = "camelCase")]
171pub struct AgentOsSidecarDescription {
172    pub sidecar_id: String,
173    pub placement: AgentOsSidecarPlacement,
174    pub state: SidecarState,
175    pub active_vm_count: u32,
176}
177
178/// Public transport handle for a (possibly shared) native sidecar process hosting VMs.
179pub struct AgentOsSidecar {
180    pub(crate) sidecar_id: String,
181    pub(crate) placement: AgentOsSidecarPlacement,
182    pub(crate) shared_pool: Option<String>,
183    pub(crate) state: AtomicU8,
184    pub(crate) active_vm_count: AtomicU32,
185    pub(crate) active_package_sessions: AtomicU32,
186    lifecycle: tokio::sync::Mutex<()>,
187    /// Absolute path to the `agentos-native-sidecar` binary, threaded from `AgentOsConfig` when present.
188    /// Otherwise `ensure_connection` resolves the Agent OS env fallback and passes an explicit path
189    /// to the generic transport.
190    pub(crate) sidecar_binary_path: Option<String>,
191    /// The shared sidecar process + authenticated connection, established on the first VM `create`
192    /// against this sidecar and reused by every subsequent VM in the same (shared) sidecar.
193    pub(crate) connection: tokio::sync::Mutex<Option<SharedConnection>>,
194    /// The transport in `connection`, readable without waiting for that lock, so a health check
195    /// sees a lost process even while another caller holds the connection.
196    live_transport: parking_lot::Mutex<Option<Arc<SidecarProcess>>>,
197}
198
199impl AgentOsSidecar {
200    /// Construct a sidecar handle.
201    pub(crate) fn new(
202        sidecar_id: impl Into<String>,
203        placement: AgentOsSidecarPlacement,
204        shared_pool: Option<String>,
205        sidecar_binary_path: Option<String>,
206    ) -> Self {
207        Self {
208            sidecar_id: sidecar_id.into(),
209            placement,
210            shared_pool,
211            state: AtomicU8::new(SidecarState::Ready.as_u8()),
212            active_vm_count: AtomicU32::new(0),
213            active_package_sessions: AtomicU32::new(0),
214            lifecycle: tokio::sync::Mutex::new(()),
215            sidecar_binary_path,
216            connection: tokio::sync::Mutex::new(None),
217            live_transport: parking_lot::Mutex::new(None),
218        }
219    }
220
221    /// Get (or lazily establish) the shared sidecar process + authenticated connection. The first
222    /// caller spawns the native sidecar child and runs the `Authenticate` handshake; subsequent
223    /// callers reuse the same transport + connection id. This is what makes a shared sidecar host
224    /// multiple VMs in one process.
225    pub(crate) async fn ensure_connection(
226        &self,
227    ) -> Result<(Arc<SidecarProcess>, String, usize), ClientError> {
228        let mut guard = self.connection.lock().await;
229        if self.state.load(Ordering::SeqCst) != SidecarState::Ready.as_u8() {
230            return Err(ClientError::Sidecar(
231                "sidecar is disposing or disposed".into(),
232            ));
233        }
234        if let Some(existing) = guard.as_ref() {
235            if existing.transport.is_disconnected() {
236                return Err(ClientError::SidecarExited(
237                    "its VMs are gone; create a new VM".into(),
238                ));
239            }
240            if !existing.authenticated {
241                return Err(ClientError::Sidecar(
242                    "sidecar authentication did not complete; dispose this sidecar before retrying"
243                        .into(),
244                ));
245            }
246            let max_frame = existing.transport.max_frame_bytes();
247            return Ok((
248                existing.transport.clone(),
249                existing.connection_id.clone(),
250                max_frame,
251            ));
252        }
253
254        let transport = SidecarProcess::spawn_with_args(
255            Some(self.resolved_sidecar_binary_path()),
256            sidecar_spawn_args()?,
257        )
258        .await?;
259        // Retain ownership before the authentication await, so errors or
260        // cancellation cannot orphan a child that holds the cache directory.
261        *self.live_transport.lock() = Some(transport.clone());
262        *guard = Some(SharedConnection {
263            transport: transport.clone(),
264            connection_id: String::new(),
265            #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
266            package_session_id: None,
267            authenticated: false,
268        });
269        let authed = match transport
270            .request_wire(
271                wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
272                    connection_id: "client-hint".to_string(),
273                }),
274                wire::RequestPayload::AuthenticateRequest(wire::AuthenticateRequest {
275                    client_name: "agentos-client".to_string(),
276                    auth_token: "agentos-client".to_string(),
277                    protocol_version: wire::PROTOCOL_VERSION,
278                    bridge_version: agentos_bridge::bridge_contract().version,
279                }),
280            )
281            .await?
282        {
283            wire::ResponsePayload::AuthenticatedResponse(authed) => authed,
284            wire::ResponsePayload::RejectedResponse(rejected) => {
285                return Err(ClientError::Kernel {
286                    code: rejected.code,
287                    message: rejected.message,
288                });
289            }
290            _ => {
291                return Err(ClientError::Sidecar(
292                    "unexpected authenticate response".to_string(),
293                ));
294            }
295        };
296        let max_frame = authed.max_frame_bytes as usize;
297        transport.set_max_frame_bytes(max_frame);
298        transport.set_max_write_files_entries(authed.max_write_files_entries as usize);
299
300        *guard = Some(SharedConnection {
301            transport: transport.clone(),
302            connection_id: authed.connection_id.clone(),
303            #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
304            package_session_id: None,
305            authenticated: true,
306        });
307        Ok((transport, authed.connection_id, max_frame))
308    }
309
310    /// Kill the shared sidecar child process if a connection was established. Used when the last VM
311    /// on a shared sidecar shuts down, so the sidecar process does not leak (process-global pool
312    /// entries are never dropped, so `kill_on_drop` alone would not fire at process exit).
313    pub(crate) async fn kill_connection(&self) -> Result<(), ClientError> {
314        let mut connection = self.connection.lock().await;
315        if let Some(connection) = connection.as_ref() {
316            connection.transport.terminate_child().await?;
317        }
318        // Keep the transport reachable if termination fails or is cancelled.
319        connection.take();
320        self.live_transport.lock().take();
321        Ok(())
322    }
323
324    pub(crate) async fn acquire_vm_lease(
325        self: &Arc<Self>,
326    ) -> Result<AgentOsSidecarVmLease, ClientError> {
327        self.acquire_lease(false).await
328    }
329
330    async fn acquire_lease(
331        self: &Arc<Self>,
332        package: bool,
333    ) -> Result<AgentOsSidecarVmLease, ClientError> {
334        let _lifecycle = self.lifecycle.lock().await;
335        if self.process_lost() {
336            return Err(ClientError::SidecarExited(
337                "its VMs are gone; create a new VM".into(),
338            ));
339        }
340        if self.current_state() != SidecarState::Ready {
341            return Err(ClientError::Sidecar(
342                "sidecar is disposing or disposed".into(),
343            ));
344        }
345        let count = if package {
346            &self.active_package_sessions
347        } else {
348            &self.active_vm_count
349        };
350        count
351            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
352                count.checked_add(1)
353            })
354            .map_err(|_| {
355                ClientError::Sidecar("limit_exceeded: sidecar lease count overflow".into())
356            })?;
357        Ok(AgentOsSidecarVmLease {
358            sidecar: self.clone(),
359            package,
360            released: AtomicBool::new(false),
361        })
362    }
363
364    pub(crate) async fn dispose_if_unused(&self) -> Result<(), ClientError> {
365        let _lifecycle = self.lifecycle.lock().await;
366        if self.active_vm_count.load(Ordering::SeqCst) == 0
367            && self.active_package_sessions.load(Ordering::SeqCst) == 0
368        {
369            self.dispose_locked().await?;
370        }
371        Ok(())
372    }
373
374    /// Open a sidecar-owned package acquisition session before any VM exists.
375    #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
376    pub async fn open_package_session(
377        self: &Arc<Self>,
378    ) -> Result<AgentOsPackageSession, ClientError> {
379        // Reserve before the first I/O await: last-VM shutdown must not kill
380        // the child while this session is still opening.
381        let lease = self.acquire_lease(true).await?;
382        let result = async {
383            let (transport, connection_id, _) = self.ensure_connection().await?;
384            let mut connection = self.connection.lock().await;
385            let current = connection.as_mut().ok_or_else(|| {
386                ClientError::Sidecar(
387                    "sidecar connection closed while opening package session".into(),
388                )
389            })?;
390            // The wire has no CloseSession operation. Reuse one acquisition
391            // session per connection so repeated open/close does not exhaust the
392            // bounded sidecar session table while sibling VMs remain alive.
393            let session_id = if let Some(session_id) = &current.package_session_id {
394                session_id.clone()
395            } else {
396                let response = transport
397                    .request_wire(
398                        wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
399                            connection_id: connection_id.clone(),
400                        }),
401                        wire::RequestPayload::OpenSessionRequest(wire::OpenSessionRequest {
402                            placement: crate::agent_os::sidecar_wire_placement(self),
403                            metadata: HashMap::new(),
404                        }),
405                    )
406                    .await?;
407                let wire::ResponsePayload::SessionOpenedResponse(opened) = response else {
408                    return Err(match response {
409                        wire::ResponsePayload::RejectedResponse(rejected) => {
410                            ClientError::from_rejection(rejected)
411                        }
412                        other => ClientError::Sidecar(format!(
413                            "unexpected package session response: {other:?}"
414                        )),
415                    });
416                };
417                current.package_session_id = Some(opened.session_id.clone());
418                opened.session_id
419            };
420            drop(connection);
421            Ok((transport, connection_id, session_id))
422        }
423        .await;
424        let (transport, connection_id, session_id) = match result {
425            Ok(opened) => opened,
426            Err(error) => {
427                lease.release();
428                if let Err(cleanup_error) = self.dispose_if_unused().await {
429                    tracing::error!(%cleanup_error, "failed to stop sidecar after package session open failed");
430                }
431                return Err(error);
432            }
433        };
434        Ok(AgentOsPackageSession {
435            sidecar: self.clone(),
436            transport,
437            connection_id,
438            session_id,
439            closed: AtomicBool::new(false),
440            lease,
441        })
442    }
443
444    fn resolved_sidecar_binary_path(&self) -> String {
445        self.sidecar_binary_path
446            .clone()
447            .or_else(|| std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok())
448            .unwrap_or_else(|| "agentos-native-sidecar".to_string())
449    }
450
451    /// Snapshot the sidecar's current state. SYNC.
452    ///
453    /// Parity: TypeScript `describe()` returns a deep clone of the internal description so callers
454    /// cannot mutate sidecar state through the returned value. The Rust struct derives `Clone`, so
455    /// constructing a fresh [`AgentOsSidecarDescription`] from the current atomics produces the same
456    /// snapshot semantics.
457    pub fn describe(&self) -> AgentOsSidecarDescription {
458        AgentOsSidecarDescription {
459            sidecar_id: self.sidecar_id.clone(),
460            placement: self.placement.clone(),
461            state: self.current_state(),
462            active_vm_count: self.active_vm_count.load(Ordering::SeqCst),
463        }
464    }
465
466    /// The lifecycle state, reported as `Disposed` once the sidecar process is gone: its VMs died
467    /// with it, and it can never host another VM. A shared pool then spawns a fresh sidecar.
468    fn current_state(&self) -> SidecarState {
469        let state = SidecarState::from_u8(self.state.load(Ordering::SeqCst));
470        if state == SidecarState::Ready && self.process_lost() {
471            SidecarState::Disposed
472        } else {
473            state
474        }
475    }
476
477    fn process_lost(&self) -> bool {
478        self.live_transport
479            .lock()
480            .as_ref()
481            .is_some_and(|transport| transport.is_disconnected())
482    }
483
484    /// Dispose the sidecar. Idempotent; disposes active leases and aggregates errors.
485    ///
486    /// Parity with TypeScript `AgentOsSidecar.dispose()`:
487    /// 1. If already `disposed`, return immediately (idempotent).
488    /// 2. Transition to `disposing`.
489    /// 3. Dispose every active lease, collecting (not short-circuiting on) errors.
490    /// 4. Reset `active_vm_count` to 0 and transition to `disposed`.
491    /// 5. If this sidecar is the cached shared sidecar for its pool, remove it from the pool.
492    /// 6. If any lease disposal failed, return an aggregated error.
493    pub async fn dispose(&self) -> Result<(), ClientError> {
494        let _lifecycle = self.lifecycle.lock().await;
495        self.dispose_locked().await
496    }
497
498    async fn dispose_locked(&self) -> Result<(), ClientError> {
499        if SidecarState::from_u8(self.state.load(Ordering::SeqCst)) == SidecarState::Disposed {
500            return Ok(());
501        }
502
503        self.state
504            .store(SidecarState::Disposing.as_u8(), Ordering::SeqCst);
505
506        // Explicit sidecar disposal owns all its VMs. Reap the process before
507        // reporting disposal, even if a failed VM still retained a lease.
508        // On cancellation/error, remain Disposing and retain the connection
509        // so a later call can finish instead of reporting false success.
510        self.kill_connection().await?;
511        self.active_vm_count.store(0, Ordering::SeqCst);
512        self.active_package_sessions.store(0, Ordering::SeqCst);
513        self.state
514            .store(SidecarState::Disposed.as_u8(), Ordering::SeqCst);
515
516        if let Some(pool) = self.shared_pool.as_deref() {
517            // Only remove the cached entry if it still points at this exact sidecar instance.
518            let self_ptr = self as *const AgentOsSidecar;
519            let _ = shared_sidecars()
520                .remove_if(pool, |cached| std::ptr::eq(Arc::as_ptr(cached), self_ptr));
521        }
522
523        Ok(())
524    }
525}
526
527/// A process-scoped sidecar session for package acquisition without a VM.
528#[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
529pub struct AgentOsPackageSession {
530    sidecar: Arc<AgentOsSidecar>,
531    transport: Arc<SidecarProcess>,
532    connection_id: String,
533    session_id: String,
534    closed: AtomicBool,
535    lease: AgentOsSidecarVmLease,
536}
537
538#[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
539impl AgentOsPackageSession {
540    pub async fn cache_stats(
541        &self,
542    ) -> Result<crate::software::ProcessPackageCacheStats, ClientError> {
543        if self.closed.load(Ordering::SeqCst) {
544            return Err(ClientError::Sidecar("package session is closed".into()));
545        }
546        let response = self
547            .transport
548            .request_wire(
549                wire::OwnershipScope::SessionOwnership(wire::SessionOwnership {
550                    connection_id: self.connection_id.clone(),
551                    session_id: self.session_id.clone(),
552                }),
553                wire::RequestPayload::GetPackageCacheStatsRequest,
554            )
555            .await?;
556        match response {
557            wire::ResponsePayload::PackageCacheStatsResponse(stats) => {
558                let count = |value: u64| usize::try_from(value).unwrap_or(usize::MAX);
559                Ok(crate::software::ProcessPackageCacheStats {
560                    entries: count(stats.entries),
561                    source_entries: count(stats.source_entries),
562                    bytes: stats.bytes,
563                    pinned_entries: count(stats.pinned_entries),
564                    pending_acquisitions: count(stats.pending_acquisitions),
565                    hits: stats.hits,
566                    misses: stats.misses,
567                    coalesced_waiters: stats.coalesced_waiters,
568                    acquisitions: stats.acquisitions,
569                    evictions: stats.evictions,
570                    capacity_failures: stats.capacity_failures,
571                    cancelled_acquisitions: stats.cancelled_acquisitions,
572                })
573            }
574            wire::ResponsePayload::RejectedResponse(rejected) => {
575                Err(ClientError::from_rejection(rejected))
576            }
577            other => Err(ClientError::Sidecar(format!(
578                "unexpected package cache stats response: {other:?}"
579            ))),
580        }
581    }
582
583    pub async fn acquire(
584        &self,
585        source: crate::software::PackageSource,
586        advisory: bool,
587        timeout_ms: Option<u64>,
588        options: Option<&crate::software::PackageResolverOptions>,
589    ) -> Result<crate::software::InstalledSoftware, ClientError> {
590        if self.closed.load(Ordering::SeqCst) {
591            return Err(ClientError::Sidecar("package session is closed".into()));
592        }
593        let source = match source {
594            crate::software::PackageSource::Url {
595                url,
596                expected_digest,
597            } => wire::PackageAcquisitionSource::PackageUrlSource(wire::PackageUrlSource {
598                url,
599                expected_digest,
600            }),
601            crate::software::PackageSource::Path {
602                path,
603                expected_digest,
604            } => wire::PackageAcquisitionSource::PackagePathSource(wire::PackagePathSource {
605                path,
606                expected_digest,
607            }),
608        };
609        let response = self
610            .transport
611            .request_wire(
612                wire::OwnershipScope::SessionOwnership(wire::SessionOwnership {
613                    connection_id: self.connection_id.clone(),
614                    session_id: self.session_id.clone(),
615                }),
616                wire::RequestPayload::AcquirePackageRequest(wire::AcquirePackageRequest {
617                    source,
618                    advisory,
619                    timeout_ms,
620                    max_package_bytes: options.map(|options| options.max_package_bytes),
621                    download_timeout_ms: options.map(|options| options.download_timeout_ms),
622                    connect_timeout_ms: options.map(|options| options.connect_timeout_ms),
623                    max_redirects: options.map(|options| options.max_redirects as u32),
624                    allow_insecure_local_http: options
625                        .is_some_and(|options| options.allow_insecure_local_http),
626                }),
627            )
628            .await?;
629        match response {
630            wire::ResponsePayload::PackageAcquiredResponse(package) => {
631                Ok(crate::software::InstalledSoftware {
632                    package_id: package.package_id,
633                    digest: package.digest,
634                    size_bytes: package.size,
635                    package_name: package.package_name,
636                    version: package.version,
637                    commands: package.commands,
638                })
639            }
640            wire::ResponsePayload::RejectedResponse(rejected) => {
641                Err(ClientError::from_rejection(rejected))
642            }
643            other => Err(ClientError::Sidecar(format!(
644                "unexpected acquire_package response: {other:?}"
645            ))),
646        }
647    }
648
649    pub async fn close(&self) -> Result<(), ClientError> {
650        self.closed.store(true, Ordering::SeqCst);
651        self.lease.release();
652        // Retry unfinished disposal even when an earlier close was cancelled.
653        self.sidecar.dispose_if_unused().await
654    }
655
656    #[cfg(feature = "service-internals")]
657    pub(crate) async fn shutdown_worker(&self) -> Result<(), ClientError> {
658        self.closed.store(true, Ordering::SeqCst);
659        self.lease.release();
660        self.sidecar.dispose().await
661    }
662}
663
664/// A lease over a VM; released on `AgentOs` dispose.
665pub(crate) struct AgentOsSidecarVmLease {
666    pub(crate) sidecar: Arc<AgentOsSidecar>,
667    package: bool,
668    released: AtomicBool,
669}
670
671impl AgentOsSidecarVmLease {
672    /// An initialization error has no VM handle left to retry disposal. Keep
673    /// that ownership counted until the explicit worker owner stops the child.
674    pub(crate) fn retain_until_worker_shutdown(self) {
675        self.released.store(true, Ordering::SeqCst);
676    }
677
678    /// Release the lease.
679    ///
680    /// Parity with the TypeScript lease `dispose()`: it is idempotent, removes itself from the
681    /// owning sidecar's active-lease set, recomputes `activeVmCount`, and disposes the underlying
682    /// session transport client. Consuming `self` here gives the idempotence for free (the lease
683    /// cannot be disposed twice). The active-vm count is decremented (saturating at 0) to mirror
684    /// `state.description.activeVmCount = state.activeLeases.size`.
685    ///
686    pub(crate) async fn dispose(self) -> Result<(), ClientError> {
687        self.release();
688        Ok(())
689    }
690
691    fn release(&self) -> bool {
692        if self.released.swap(true, Ordering::SeqCst) {
693            return false;
694        }
695        let count = if self.package {
696            &self.sidecar.active_package_sessions
697        } else {
698            &self.sidecar.active_vm_count
699        };
700        count
701            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
702                Some(count.saturating_sub(1))
703            })
704            .expect("saturating lease decrement");
705        true
706    }
707}
708
709impl Drop for AgentOsSidecarVmLease {
710    fn drop(&mut self) {
711        if self.release()
712            && self.sidecar.state.load(Ordering::SeqCst) == SidecarState::Ready.as_u8()
713        {
714            tracing::warn!(sidecar_id = %self.sidecar.sidecar_id, "sidecar lease dropped without explicit cleanup; ownership released, call sidecar.dispose() to confirm child teardown");
715        }
716    }
717}
718
719/// Process-global shared-sidecar pool, keyed by pool name (default `"default"`).
720static SHARED_SIDECARS: OnceCell<SccHashMap<String, Arc<AgentOsSidecar>>> = OnceCell::new();
721static SHARED_SIDECAR_POOL_LOCK: OnceCell<parking_lot::Mutex<()>> = OnceCell::new();
722
723/// Access (initializing on first use) the process-global shared-sidecar pool.
724pub(crate) fn shared_sidecars() -> &'static SccHashMap<String, Arc<AgentOsSidecar>> {
725    SHARED_SIDECARS.get_or_init(SccHashMap::new)
726}
727
728fn shared_sidecar_pool_lock() -> &'static parking_lot::Mutex<()> {
729    SHARED_SIDECAR_POOL_LOCK.get_or_init(parking_lot::Mutex::default)
730}
731
732fn shared_sidecar_pool_len(cache: &SccHashMap<String, Arc<AgentOsSidecar>>) -> usize {
733    let mut len = 0;
734    cache.scan(|_, _| {
735        len += 1;
736    });
737    len
738}
739
740fn prune_disposed_shared_sidecars(cache: &SccHashMap<String, Arc<AgentOsSidecar>>) {
741    let mut disposed_pools = Vec::new();
742    cache.scan(|pool, sidecar| {
743        if sidecar.describe().state == SidecarState::Disposed {
744            disposed_pools.push(pool.clone());
745        }
746    });
747    for pool in disposed_pools {
748        let _ = cache.remove_if(&pool, |sidecar| {
749            sidecar.describe().state == SidecarState::Disposed
750        });
751    }
752}
753
754#[cfg(test)]
755fn ensure_shared_sidecar_pool_capacity(
756    cache: &SccHashMap<String, Arc<AgentOsSidecar>>,
757) -> Result<(), ClientError> {
758    if shared_sidecar_pool_len(cache) >= SHARED_SIDECAR_POOL_LIMIT {
759        return Err(shared_sidecar_pool_limit_error());
760    }
761    Ok(())
762}
763
764fn shared_sidecar_pool_limit_error() -> ClientError {
765    ClientError::Sidecar(format!(
766        "shared sidecar pool limit exceeded: at most {SHARED_SIDECAR_POOL_LIMIT} pools can be cached"
767    ))
768}
769
770impl AgentOs {
771    /// Create an explicit sidecar handle. `sidecar_id` defaults to `agentos-native-sidecar-<uuid>`.
772    ///
773    /// Parity with TypeScript `createAgentOsSidecarInternal`: the explicit handle carries an
774    /// `Explicit` placement whose `sidecar_id` echoes the resolved id and has no shared pool.
775    pub async fn create_sidecar(
776        sidecar_id: Option<String>,
777    ) -> Result<Arc<AgentOsSidecar>, ClientError> {
778        let sidecar_id =
779            sidecar_id.unwrap_or_else(|| format!("agentos-native-sidecar-{}", Uuid::new_v4()));
780        let placement = AgentOsSidecarPlacement::Explicit {
781            sidecar_id: sidecar_id.clone(),
782        };
783        Ok(Arc::new(AgentOsSidecar::new(
784            sidecar_id, placement, None, None,
785        )))
786    }
787
788    /// Get (or create) a pooled shared sidecar. Pool defaults to `"default"`. Uses the process-global
789    /// cache.
790    ///
791    /// Parity with TypeScript `getSharedAgentOsSidecarInternal`: return the cached sidecar for the
792    /// pool when it exists and is not disposed; otherwise build a fresh handle
793    /// (`agentos-shared-sidecar:<pool>`, `Shared` placement) and cache it. Because the cache is a
794    /// process-global concurrent map rather than a synchronously-checked `Map`, the insert is done
795    /// atomically with `entry`/`insert` so two racing callers converge on a single live handle.
796    pub async fn get_shared_sidecar(
797        pool: Option<String>,
798        sidecar_binary_path: Option<String>,
799    ) -> Result<Arc<AgentOsSidecar>, ClientError> {
800        let pool = pool.unwrap_or_else(|| "default".to_string());
801        let cache = shared_sidecars();
802        let _guard = shared_sidecar_pool_lock().lock();
803
804        // Fast path: reuse a cached, non-disposed sidecar for this pool.
805        if let Some(existing) = cache.read(&pool, |_, sidecar| sidecar.clone()) {
806            if existing.describe().state != SidecarState::Disposed {
807                return Ok(existing);
808            }
809        }
810        prune_disposed_shared_sidecars(cache);
811
812        // Parity: TypeScript builds placement `{ kind: "shared", ...(pool ? { pool } : {}) }`, so an
813        // empty-string pool (a non-nullish value that survives `?? "default"`) is OMITTED from the
814        // placement. The `sharedPool` field used for cache cleanup still carries the raw pool value.
815        let placement_pool = if pool.is_empty() {
816            None
817        } else {
818            Some(pool.clone())
819        };
820        let sidecar = Arc::new(AgentOsSidecar::new(
821            format!("agentos-shared-sidecar:{pool}"),
822            AgentOsSidecarPlacement::Shared {
823                pool: placement_pool,
824            },
825            Some(pool.clone()),
826            sidecar_binary_path,
827        ));
828
829        // Insert atomically, replacing a stale (disposed) entry but yielding to a live one that a
830        // concurrent caller may have just installed.
831        let cache_len = shared_sidecar_pool_len(cache);
832        match cache.entry(pool) {
833            scc::hash_map::Entry::Occupied(mut occupied) => {
834                if occupied.get().describe().state == SidecarState::Disposed {
835                    *occupied.get_mut() = sidecar.clone();
836                    Ok(sidecar)
837                } else {
838                    Ok(occupied.get().clone())
839                }
840            }
841            scc::hash_map::Entry::Vacant(vacant) => {
842                if cache_len >= SHARED_SIDECAR_POOL_LIMIT {
843                    return Err(shared_sidecar_pool_limit_error());
844                }
845                vacant.insert_entry(sidecar.clone());
846                Ok(sidecar)
847            }
848        }
849    }
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855    use std::sync::Mutex;
856
857    static ENV_LOCK: Mutex<()> = Mutex::new(());
858
859    #[tokio::test]
860    async fn opening_leases_prevent_last_vm_from_disposing_the_child() {
861        let sidecar = shared("opening-leases", SidecarState::Ready);
862        let vm = sidecar.acquire_vm_lease().await.unwrap();
863        let package = sidecar.acquire_lease(true).await.unwrap();
864        vm.dispose().await.unwrap();
865        sidecar.dispose_if_unused().await.unwrap();
866        assert_eq!(sidecar.describe().state, SidecarState::Ready);
867        assert_eq!(sidecar.active_package_sessions.load(Ordering::SeqCst), 1);
868        package.release();
869        sidecar.dispose_if_unused().await.unwrap();
870        assert_eq!(sidecar.describe().state, SidecarState::Disposed);
871        assert!(sidecar.acquire_vm_lease().await.is_err());
872        assert!(sidecar.ensure_connection().await.is_err());
873        drop(package);
874        assert_eq!(sidecar.active_package_sessions.load(Ordering::SeqCst), 0);
875    }
876
877    #[tokio::test]
878    async fn unconfirmed_vm_cleanup_keeps_ownership_until_force_disposal() {
879        let sidecar = shared("unconfirmed-cleanup", SidecarState::Ready);
880        sidecar
881            .acquire_vm_lease()
882            .await
883            .unwrap()
884            .retain_until_worker_shutdown();
885        sidecar.dispose_if_unused().await.unwrap();
886        assert_eq!(sidecar.describe().active_vm_count, 1);
887        assert_eq!(sidecar.describe().state, SidecarState::Ready);
888        sidecar.dispose().await.unwrap();
889        assert_eq!(sidecar.describe().active_vm_count, 0);
890        assert_eq!(sidecar.describe().state, SidecarState::Disposed);
891    }
892
893    #[tokio::test]
894    async fn failed_package_session_open_releases_its_reserved_lease() {
895        let sidecar = Arc::new(AgentOsSidecar::new(
896            "missing-binary",
897            AgentOsSidecarPlacement::Explicit {
898                sidecar_id: "missing-binary".into(),
899            },
900            None,
901            Some("/agentos-missing-test-binary".into()),
902        ));
903        assert!(sidecar.open_package_session().await.is_err());
904        assert_eq!(sidecar.active_package_sessions.load(Ordering::SeqCst), 0);
905        assert_eq!(sidecar.describe().state, SidecarState::Disposed);
906    }
907
908    #[tokio::test]
909    async fn failed_vm_creation_releases_its_reserved_lease() {
910        let sidecar = Arc::new(AgentOsSidecar::new(
911            "missing-vm-binary",
912            AgentOsSidecarPlacement::Explicit {
913                sidecar_id: "missing-vm-binary".into(),
914            },
915            None,
916            Some("/agentos-missing-test-binary".into()),
917        ));
918        let result = AgentOs::create(crate::AgentOsConfig {
919            sidecar: Some(crate::AgentOsSidecarConfig::Explicit {
920                handle: sidecar.clone(),
921            }),
922            ..Default::default()
923        })
924        .await;
925        assert!(result.is_err());
926        assert_eq!(sidecar.describe().active_vm_count, 0);
927        assert_eq!(sidecar.describe().state, SidecarState::Disposed);
928    }
929
930    fn shared(pool: &str, state: SidecarState) -> Arc<AgentOsSidecar> {
931        let sidecar = Arc::new(AgentOsSidecar::new(
932            format!("agentos-shared-sidecar:{pool}"),
933            AgentOsSidecarPlacement::Shared {
934                pool: Some(pool.to_string()),
935            },
936            Some(pool.to_string()),
937            None,
938        ));
939        sidecar.state.store(state.as_u8(), Ordering::SeqCst);
940        sidecar
941    }
942
943    #[test]
944    fn sidecar_binary_path_prefers_explicit_wrapper_path() {
945        let _guard = ENV_LOCK.lock().expect("env lock");
946        let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
947        std::env::set_var(AGENTOS_SIDECAR_BIN_ENV, "/tmp/from-env");
948        let sidecar = AgentOsSidecar::new(
949            "explicit-test",
950            AgentOsSidecarPlacement::Explicit {
951                sidecar_id: "explicit-test".to_string(),
952            },
953            None,
954            Some("/tmp/from-config".to_string()),
955        );
956
957        assert_eq!(sidecar.resolved_sidecar_binary_path(), "/tmp/from-config");
958
959        restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
960    }
961
962    #[test]
963    fn sidecar_binary_path_uses_agent_os_env_fallback() {
964        let _guard = ENV_LOCK.lock().expect("env lock");
965        let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
966        std::env::set_var(AGENTOS_SIDECAR_BIN_ENV, "/tmp/agentos-native-sidecar");
967        let sidecar = shared("env-test", SidecarState::Ready);
968
969        assert_eq!(
970            sidecar.resolved_sidecar_binary_path(),
971            "/tmp/agentos-native-sidecar"
972        );
973
974        restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
975    }
976
977    #[test]
978    fn sidecar_binary_path_defaults_to_agent_os_wrapper() {
979        let _guard = ENV_LOCK.lock().expect("env lock");
980        let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
981        std::env::remove_var(AGENTOS_SIDECAR_BIN_ENV);
982        let sidecar = shared("default-test", SidecarState::Ready);
983
984        assert_eq!(
985            sidecar.resolved_sidecar_binary_path(),
986            "agentos-native-sidecar"
987        );
988
989        restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
990    }
991
992    fn restore_env(key: &str, value: Option<String>) {
993        match value {
994            Some(value) => std::env::set_var(key, value),
995            None => std::env::remove_var(key),
996        }
997    }
998
999    #[test]
1000    fn prune_disposed_shared_sidecars_keeps_live_entries() {
1001        let cache = SccHashMap::new();
1002        let _ = cache.insert("live".to_string(), shared("live", SidecarState::Ready));
1003        let _ = cache.insert(
1004            "disposed".to_string(),
1005            shared("disposed", SidecarState::Disposed),
1006        );
1007
1008        prune_disposed_shared_sidecars(&cache);
1009
1010        assert_eq!(shared_sidecar_pool_len(&cache), 1);
1011        assert!(cache.read("live", |_, _| ()).is_some());
1012        assert!(cache.read("disposed", |_, _| ()).is_none());
1013    }
1014
1015    #[test]
1016    fn shared_sidecar_pool_capacity_rejects_full_live_cache() {
1017        let cache = SccHashMap::new();
1018        for index in 0..SHARED_SIDECAR_POOL_LIMIT {
1019            let pool = format!("pool-{index}");
1020            let _ = cache.insert(pool.clone(), shared(&pool, SidecarState::Ready));
1021        }
1022
1023        let error =
1024            ensure_shared_sidecar_pool_capacity(&cache).expect_err("full cache should reject");
1025
1026        assert!(
1027            error
1028                .to_string()
1029                .contains("shared sidecar pool limit exceeded"),
1030            "unexpected error: {error}"
1031        );
1032    }
1033
1034    #[test]
1035    fn shared_sidecar_pool_capacity_allows_after_pruning_disposed_entries() {
1036        let cache = SccHashMap::new();
1037        for index in 0..SHARED_SIDECAR_POOL_LIMIT {
1038            let pool = format!("pool-{index}");
1039            let state = if index == 0 {
1040                SidecarState::Disposed
1041            } else {
1042                SidecarState::Ready
1043            };
1044            let _ = cache.insert(pool.clone(), shared(&pool, state));
1045        }
1046
1047        prune_disposed_shared_sidecars(&cache);
1048
1049        ensure_shared_sidecar_pool_capacity(&cache).expect("pruned cache should admit one entry");
1050        assert_eq!(
1051            shared_sidecar_pool_len(&cache),
1052            SHARED_SIDECAR_POOL_LIMIT - 1
1053        );
1054    }
1055
1056    #[tokio::test]
1057    async fn get_shared_sidecar_inserts_vacant_pool_without_reentrant_scan() {
1058        let pool = format!("unit-{}", Uuid::new_v4());
1059        let sidecar = AgentOs::get_shared_sidecar(Some(pool.clone()), None)
1060            .await
1061            .expect("shared sidecar");
1062
1063        assert_eq!(sidecar.shared_pool.as_deref(), Some(pool.as_str()));
1064
1065        sidecar.dispose().await.expect("dispose shared sidecar");
1066    }
1067
1068    #[test]
1069    fn dispose_removes_only_same_shared_sidecar_instance() {
1070        let pool = format!("dispose-race-{}", Uuid::new_v4());
1071        let old = shared(&pool, SidecarState::Ready);
1072        let replacement = shared(&pool, SidecarState::Ready);
1073        let cache = shared_sidecars();
1074        let _guard = shared_sidecar_pool_lock().lock();
1075
1076        let _ = cache.insert(pool.clone(), replacement.clone());
1077        old.state
1078            .store(SidecarState::Disposing.as_u8(), Ordering::SeqCst);
1079        old.active_vm_count.store(0, Ordering::SeqCst);
1080        old.state
1081            .store(SidecarState::Disposed.as_u8(), Ordering::SeqCst);
1082        let old_ptr = Arc::as_ptr(&old);
1083        let _ = cache.remove_if(&pool, |cached| std::ptr::eq(Arc::as_ptr(cached), old_ptr));
1084
1085        let cached = cache
1086            .read(&pool, |_, cached| cached.clone())
1087            .expect("replacement should remain cached");
1088        assert!(Arc::ptr_eq(&cached, &replacement));
1089
1090        let replacement_ptr = Arc::as_ptr(&replacement);
1091        let _ = cache.remove_if(&pool, |cached| {
1092            std::ptr::eq(Arc::as_ptr(cached), replacement_ptr)
1093        });
1094    }
1095}