Skip to main content

agentos_client/
agent_os.rs

1//! The `AgentOs` struct (all fields from ADR-001 §3), the `create` builder, and the `shutdown`
2//! (dispose) teardown.
3//!
4//! `AgentOs` is `Arc`-cloneable; all interior state lives behind concurrent maps / atomics /
5//! channels so `&self` methods never need an outer lock. Module files add only `impl AgentOs` blocks
6//! and never introduce new struct fields.
7
8use std::collections::{BTreeMap, HashMap, VecDeque};
9use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
10use std::sync::{Arc, Weak};
11use std::time::Duration;
12
13use scc::HashMap as SccHashMap;
14use serde::Deserialize;
15use serde_json::{Map, Value};
16use tokio::sync::{broadcast, watch};
17use tokio::task::JoinHandle;
18
19use agentos_sidecar_client::{wire, TransportError};
20use agentos_vm_config as vm_config;
21
22use crate::config::{
23    resolve_host_functions, AgentOsConfig, AgentOsLimits, MountConfig, ResolvedHostFunction,
24    ResolvedHostFunctions, RootFilesystemConfig, RootFilesystemKind,
25    RootFilesystemMode as ConfigRootFilesystemMode, RootLowerInput, SidecarJsBridgeCall,
26    SidecarJsBridgeCallback, SidecarSqliteCallback, TimerScheduleDriver,
27};
28use crate::cron::CronManager;
29use crate::error::ClientError;
30use crate::process::SYNTHETIC_PID_BASE;
31use crate::sidecar::{AgentOsSidecar, AgentOsSidecarPlacement, AgentOsSidecarVmLease};
32use crate::transport::{SidecarProcess, WireSidecarCallback};
33
34use once_cell::sync::OnceCell;
35
36// ---------------------------------------------------------------------------
37// Registry entries
38// ---------------------------------------------------------------------------
39
40/// An SDK-spawned process (TS `_processes` value). Keyed by user-facing pid.
41pub(crate) struct ProcessEntry {
42    pub command: String,
43    pub args: Vec<String>,
44    #[allow(dead_code)]
45    pub stdout_tx: broadcast::Sender<Vec<u8>>,
46    #[allow(dead_code)]
47    pub stderr_tx: broadcast::Sender<Vec<u8>>,
48    pub output_tx: broadcast::Sender<crate::process::ProcessOutput>,
49    /// A failed observation is distinct from a confirmed guest exit.
50    pub exit_tx: watch::Sender<crate::process::ProcessOutcome>,
51    /// The sidecar-side process id used on the wire.
52    pub process_id: String,
53    /// The kernel pid returned by the `Execute` response, seeded once the spawn lands. The TS native
54    /// path builds `displayPidByKernelPid` from this so `all_processes`/`process_tree` report the
55    /// public spawn pid (the map key) for the spawned root, not the raw kernel pid.
56    pub kernel_pid: watch::Sender<Option<u32>>,
57    /// Handles for the per-process output-callback tasks seeded at spawn (`on_stdout`/`on_stderr`).
58    /// The entry retains its own `stdout_tx`/`stderr_tx` clones for late subscribers, so these tasks
59    /// never observe the broadcast `Closed`; `shutdown` aborts them when draining the registry.
60    pub output_tasks: Vec<JoinHandle<()>>,
61    /// Whether the sidecar owns a bounded output replay for this process.
62    pub retain_output: bool,
63    /// First-class language executions use the semantic execution replay route.
64    pub execution_id: Option<String>,
65    pub execution_generation: Option<u64>,
66    /// Epoch milliseconds captured when `spawn` registered this process (TS `Date.now()`).
67    pub started_at: i64,
68}
69
70/// A PTY-backed shell (TS `_shells` value). Keyed by synthetic `shell-N` id.
71///
72/// `data_tx` carries stdout and stderr in their original wire order for terminal renderers.
73/// `stderr_tx` is an optional channel-specific diagnostic tap backing the `on_stderr` option and
74/// `on_shell_stderr`; terminal consumers must not render both streams or stderr would be duplicated.
75pub(crate) struct ShellEntry {
76    pub pid: u32,
77    pub data_tx: broadcast::Sender<Vec<u8>>,
78    pub stderr_tx: broadcast::Sender<Vec<u8>>,
79    pub event_tx: broadcast::Sender<crate::shell::TerminalOutputEvent>,
80    /// The sidecar-side process id used on the wire.
81    pub process_id: String,
82    /// Spawn-readiness gate. Pending until the background `Execute` request is
83    /// acked. TS `openShell` is fully synchronous so `writeShell` always addresses a live spawn; the
84    /// Rust wire spawn is async, so `write_shell`/`close_shell` await this gate before issuing their
85    /// wire request to preserve the deterministic ordering and avoid dropping early input.
86    pub spawned_tx: watch::Sender<Option<Result<(), ClientError>>>,
87    /// Exit-code channel backing `wait_shell` (TS `ShellHandle.wait`). Seeded `None`; the background
88    /// event loop publishes a confirmed exit code or a typed observation failure.
89    pub exit_tx: watch::Sender<Option<Result<i32, ClientError>>>,
90}
91
92#[derive(Clone)]
93pub(crate) struct ClosedShellEntry {
94    pub shell_id: String,
95    pub process_id: String,
96    pub pid: u32,
97    pub result: Result<i32, ClientError>,
98}
99
100/// A connected terminal process and its output fan-out task.
101pub(crate) struct TerminalEntry {
102    pub exit_task: JoinHandle<()>,
103}
104
105// ---------------------------------------------------------------------------
106// AgentOs
107// ---------------------------------------------------------------------------
108
109/// A self-contained agentOS package to link into a running VM via
110/// [`AgentOs::link_software`]. `path` is normally the packed `.aospkg` file;
111/// a directory is accepted only for local transition fixtures. The descriptor
112/// is forwarded to the sidecar, which owns the `/opt/agentos` projection and
113/// reads package metadata from the packed vbare manifest.
114#[derive(Debug, Clone)]
115pub struct PackageDescriptor {
116    pub path: String,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
120pub struct SoftwareInfo {
121    #[serde(rename = "packageName")]
122    pub package_name: String,
123    pub commands: Vec<String>,
124}
125
126/// The high-level client. Cheaply cloneable via `Arc`.
127#[derive(Clone)]
128pub struct AgentOs {
129    inner: Arc<AgentOsInner>,
130}
131
132pub(crate) struct AgentOsInner {
133    // Transport / connection / VM handle.
134    pub(crate) transport: Arc<SidecarProcess>,
135    pub(crate) connection_id: String,
136    pub(crate) session_id: String,
137    pub(crate) vm_id: String,
138    /// Projected command names and guest entrypoints reported by the sidecar.
139    pub(crate) projected_commands: parking_lot::Mutex<BTreeMap<String, String>>,
140    /// Admits one client-originated package or mount mutation without queuing
141    /// unbounded waiters. Concurrent callers receive a retryable error.
142    pub(crate) vm_configuration_operation: tokio::sync::Mutex<()>,
143    pub(crate) installed_software:
144        parking_lot::Mutex<BTreeMap<String, crate::software::InstalledSoftware>>,
145
146    // Process registries.
147    pub(crate) process_registry_lock: parking_lot::Mutex<()>,
148    /// Slots reserved by asynchronous language spawns that have reached client
149    /// admission but have not published their [`ProcessEntry`] yet.
150    pub(crate) pending_process_registrations: AtomicUsize,
151    pub(crate) processes: SccHashMap<u32, ProcessEntry>,
152    /// Wire `process_id` allocator for `exec` (the kernel-process view). Distinct from the
153    /// spawn synthetic-pid space so an `exec` call never perturbs the observable `spawn` pid sequence
154    /// (TS `nextSyntheticPid` is advanced only by `spawn`, never by `exec`).
155    pub(crate) process_counter: AtomicU64,
156    /// Synthetic display-pid allocator for `spawn` (TS `nextSyntheticPid`, seeded at
157    /// [`crate::process::SYNTHETIC_PID_BASE`]). The first spawned process gets `SYNTHETIC_PID_BASE`.
158    pub(crate) synthetic_pid_counter: AtomicU64,
159    pub(crate) observed_process_time_lock: parking_lot::Mutex<()>,
160    /// First-observed start time (epoch ms) per `"<process_id>:<kernel_pid>"`, mirroring TS
161    /// `observedProcessStartTimes`. A process keeps the timestamp first seen in `all_processes` across
162    /// later calls instead of advancing on every snapshot.
163    pub(crate) observed_process_start_times: SccHashMap<String, f64>,
164    /// First-observed exit time (epoch ms) per SDK-spawned wire `process_id`, mirroring TS
165    /// `tracked.exitTime` (set once when the process is first seen exited).
166    pub(crate) observed_process_exit_times: SccHashMap<String, f64>,
167
168    // Shell registries.
169    pub(crate) shells: SccHashMap<String, ShellEntry>,
170    pub(crate) shell_counter: AtomicU64,
171    pub(crate) pending_shell_exits: SccHashMap<u64, JoinHandle<()>>,
172    /// Bounded ordered map (cap [`crate::CLOSED_SHELL_EXIT_CODE_RETENTION_LIMIT`]) of exited shells'
173    /// exit codes, so `wait_shell` issued after the shell already exited (entry dropped from
174    /// `shells`) still resolves with the recorded code — mirrors the TS `_closedShellIds` retention.
175    pub(crate) closed_shells: parking_lot::Mutex<VecDeque<ClosedShellEntry>>,
176    pub(crate) terminals: SccHashMap<String, TerminalEntry>,
177    pub(crate) terminal_count: AtomicUsize,
178    pub(crate) terminal_lifecycle_lock: tokio::sync::Mutex<()>,
179
180    // Cron.
181    pub(crate) cron: Arc<CronManager>,
182
183    // Config / lifecycle.
184    pub(crate) config: Arc<AgentOsConfig>,
185    pub(crate) sidecar: Arc<AgentOsSidecar>,
186    pub(crate) sidecar_lease: parking_lot::Mutex<Option<AgentOsSidecarVmLease>>,
187    pub(crate) dynamic_mounts: parking_lot::Mutex<Vec<wire::MountDescriptor>>,
188    pub(crate) disposed: AtomicBool,
189    shutdown_result: tokio::sync::Mutex<Option<Result<(), ClientError>>>,
190    vm_disposed: AtomicBool,
191}
192
193/// Owns VM creation across caller cancellation. Dropping `AgentOs::create` may
194/// stop waiting, but it must not cancel a request after the sidecar may have
195/// allocated a VM with no returned identity available to the caller.
196struct AgentOsCreationTask {
197    task: Option<tokio::task::JoinHandle<Result<AgentOs, ClientError>>>,
198}
199
200impl Drop for AgentOsCreationTask {
201    fn drop(&mut self) {
202        let Some(task) = self.task.take() else {
203            return;
204        };
205        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
206            tracing::error!(
207                "VM creation waiter was dropped outside a Tokio runtime; process shutdown must reap the sidecar"
208            );
209            return;
210        };
211        runtime.spawn(async move {
212            match task.await {
213                Ok(Ok(vm)) => {
214                    if let Err(error) = vm.shutdown().await {
215                        tracing::error!(%error, "failed to dispose VM created after its caller was cancelled");
216                    }
217                }
218                Ok(Err(error)) => {
219                    tracing::debug!(%error, "cancelled VM creation finished with an error");
220                }
221                Err(error) => {
222                    tracing::error!(%error, "cancelled VM creation task failed");
223                }
224            }
225        });
226    }
227}
228
229impl AgentOs {
230    /// The sole public VM entry point. Processes software, spawns/authenticates the sidecar, creates
231    /// the VM, waits for ready (10s), configures it, takes a lease, and constructs the cron manager
232    /// (default [`crate::config::TimerScheduleDriver`]).
233    pub async fn create(options: AgentOsConfig) -> Result<AgentOs, ClientError> {
234        let mut creation = AgentOsCreationTask {
235            task: Some(tokio::spawn(Self::create_owned(options))),
236        };
237        let result = creation
238            .task
239            .as_mut()
240            .expect("creation task is present")
241            .await;
242        // No cancellation point exists between observing the joined result and
243        // disarming the guard, so a successful VM has exactly one owner.
244        creation.task.take();
245        match result {
246            Ok(result) => result,
247            Err(error) => Err(ClientError::Sidecar(format!(
248                "VM creation task failed: {error}"
249            ))),
250        }
251    }
252
253    async fn create_owned(mut options: AgentOsConfig) -> Result<AgentOs, ClientError> {
254        // Resolve registry names before allocating a sidecar, so an unknown
255        // package or release fails without booting a VM. The packages are
256        // downloaded and verified by the sidecar once the VM exists.
257        let mut boot_sources = Vec::with_capacity(options.software_sources.len());
258        for source in std::mem::take(&mut options.software_sources) {
259            let source = crate::software_registry::resolve_software_source(
260                source,
261                options.software_registry.as_ref(),
262            )
263            .await?;
264            if !boot_sources.contains(&source) {
265                boot_sources.push(source);
266            }
267        }
268        let client = Self::create_vm(options).await?;
269        // A failed install disposes the VM instead of returning one without the
270        // requested software.
271        for source in boot_sources {
272            if let Err(error) = client.install_software(source).await {
273                if let Err(shutdown_error) = client.shutdown().await {
274                    tracing::error!(%shutdown_error, "failed to dispose VM after create-time software install failed");
275                }
276                return Err(error);
277            }
278        }
279        Ok(client)
280    }
281
282    async fn create_vm(options: AgentOsConfig) -> Result<AgentOs, ClientError> {
283        let config = Arc::new(options);
284
285        // 1. Resolve the sidecar handle (shared "default" pool unless configured otherwise) and
286        //    establish/reuse its shared process + authenticated connection. A shared sidecar hosts
287        //    multiple VMs in one process, each opening its own session + VM below.
288        let sidecar = match &config.sidecar {
289            Some(crate::config::AgentOsSidecarConfig::Explicit { handle }) => handle.clone(),
290            Some(crate::config::AgentOsSidecarConfig::Shared { pool }) => {
291                AgentOs::get_shared_sidecar(pool.clone(), config.sidecar_binary_path.clone())
292                    .await?
293            }
294            None => AgentOs::get_shared_sidecar(None, config.sidecar_binary_path.clone()).await?,
295        };
296        let mut lease = Some(sidecar.acquire_vm_lease().await?);
297        let mut created_vm = None;
298        let mut created_session_key = None;
299        let result = async {
300            let (transport, connection_id, _) = sidecar.ensure_connection().await?;
301
302            // 2. Open a session for this VM (connection scope) on the shared connection.
303            let session = match transport
304                .request_wire(
305                    wire_connection_ownership(&connection_id),
306                    wire::RequestPayload::OpenSessionRequest(wire::OpenSessionRequest {
307                        placement: sidecar_wire_placement(&sidecar),
308                        metadata: HashMap::new(),
309                    }),
310                )
311                .await?
312            {
313                wire::ResponsePayload::SessionOpenedResponse(opened) => opened,
314                wire::ResponsePayload::RejectedResponse(rejected) => {
315                    return Err(rejected_to_error(rejected));
316                }
317                wire::ResponsePayload::AuthenticatedResponse(_)
318                | wire::ResponsePayload::VmCreatedResponse(_)
319                | wire::ResponsePayload::VmDisposedResponse(_)
320                | wire::ResponsePayload::VmConfigComparedResponse(_)
321                | wire::ResponsePayload::RootFilesystemBootstrappedResponse(_)
322                | wire::ResponsePayload::VmConfiguredResponse(_)
323                | wire::ResponsePayload::HostCallbacksRegisteredResponse(_)
324                | wire::ResponsePayload::LayerCreatedResponse(_)
325                | wire::ResponsePayload::LayerSealedResponse(_)
326                | wire::ResponsePayload::SnapshotImportedResponse(_)
327                | wire::ResponsePayload::SnapshotExportedResponse(_)
328                | wire::ResponsePayload::OverlayCreatedResponse(_)
329                | wire::ResponsePayload::GuestFilesystemResultResponse(_)
330                | wire::ResponsePayload::RootFilesystemSnapshotResponse(_)
331                | wire::ResponsePayload::ProcessStartedResponse(_)
332                | wire::ResponsePayload::StdinWrittenResponse(_)
333                | wire::ResponsePayload::PtyResizedResponse(_)
334                | wire::ResponsePayload::StdinClosedResponse(_)
335                | wire::ResponsePayload::ProcessKilledResponse(_)
336                | wire::ResponsePayload::ProcessSnapshotResponse(_)
337                | wire::ResponsePayload::ListenerSnapshotResponse(_)
338                | wire::ResponsePayload::BoundUdpSnapshotResponse(_)
339                | wire::ResponsePayload::SignalStateResponse(_)
340                | wire::ResponsePayload::ZombieTimerCountResponse(_)
341                | wire::ResponsePayload::FilesystemResultResponse(_)
342                | wire::ResponsePayload::PermissionDecisionResponse(_)
343                | wire::ResponsePayload::PersistenceStateResponse(_)
344                | wire::ResponsePayload::PersistenceFlushedResponse(_)
345                | wire::ResponsePayload::VmFetchResponse(_)
346                | wire::ResponsePayload::ExtEnvelope(_)
347                | wire::ResponsePayload::GuestKernelResultResponse(_)
348                | wire::ResponsePayload::ResourceSnapshotResponse(_)
349                | wire::ResponsePayload::PackageLinkedResponse(_)
350                | wire::ResponsePayload::PackageUnlinkedResponse(_)
351                | wire::ResponsePayload::PackageAcquiredResponse(_)
352                | wire::ResponsePayload::PackageInstalledResponse(_)
353                | wire::ResponsePayload::PackageCacheStatsResponse(_)
354                | wire::ResponsePayload::ProvidedCommandsResponse(_)
355                | wire::ResponsePayload::ListMountsResponse(_)
356                | wire::ResponsePayload::ExecutionAcceptedResponse(_)
357                | wire::ResponsePayload::ExecutionCompletedResponse(_)
358                | wire::ResponsePayload::ExecutionEvaluationResponse(_)
359                | wire::ResponsePayload::TypeScriptCheckResponse(_)
360                | wire::ResponsePayload::ExecutionDescriptorResponse(_)
361                | wire::ResponsePayload::ExecutionListResponse(_)
362                | wire::ResponsePayload::ExecutionDeletedResponse(_)
363                | wire::ResponsePayload::ExecutionIoResponse(_)
364                | wire::ResponsePayload::ExecutionOutputPageResponse(_)
365                | wire::ResponsePayload::ProcessOutputPageResponse(_) => {
366                    return Err(ClientError::Sidecar(
367                        "unexpected open_session response".to_string(),
368                    ));
369                }
370            };
371            let session_id = session.session_id;
372            let transport_id = transport.instance_id();
373            let session_key = sidecar_session_key(transport_id, &connection_id, &session_id);
374            created_session_key = Some(session_key.clone());
375
376            // 3. Subscribe to events BEFORE CreateVm so the `ready` lifecycle event cannot be missed.
377            let mut events = transport.subscribe_wire_events();
378            let create_vm_config = serialize_create_vm_config_for_sidecar(&config)?;
379            if let Some(callback) = config.sidecar_js_bridge_callback.clone() {
380                let _ = session_js_bridge_callbacks().insert(session_key.clone(), callback);
381                transport.register_wire_callback(
382                    "js_bridge_call",
383                    js_bridge_call_callback(transport_id),
384                );
385            }
386            if let Some(callback) = config.sidecar_sqlite_callback.clone() {
387                let _ = session_sqlite_callbacks().insert(session_key.clone(), callback);
388                transport.register_wire_callback("ext", sqlite_callback_callback(transport_id));
389            }
390
391            // 4. Create the VM (session scope).
392            let vm = match transport
393                .request_wire(
394                    wire_session_ownership(&connection_id, &session_id),
395                    wire::RequestPayload::CreateVmRequest(wire::CreateVmRequest {
396                        runtime: wire::GuestRuntimeKind::JavaScript,
397                        config: serde_json::to_string(&create_vm_config).map_err(|error| {
398                            ClientError::Sidecar(format!(
399                                "failed to serialize create VM config: {error}"
400                            ))
401                        })?,
402                    }),
403                )
404                .await?
405            {
406                wire::ResponsePayload::VmCreatedResponse(created) => created,
407                wire::ResponsePayload::RejectedResponse(rejected) => {
408                    return Err(rejected_to_error(rejected));
409                }
410                wire::ResponsePayload::AuthenticatedResponse(_)
411                | wire::ResponsePayload::SessionOpenedResponse(_)
412                | wire::ResponsePayload::VmDisposedResponse(_)
413                | wire::ResponsePayload::VmConfigComparedResponse(_)
414                | wire::ResponsePayload::RootFilesystemBootstrappedResponse(_)
415                | wire::ResponsePayload::VmConfiguredResponse(_)
416                | wire::ResponsePayload::HostCallbacksRegisteredResponse(_)
417                | wire::ResponsePayload::LayerCreatedResponse(_)
418                | wire::ResponsePayload::LayerSealedResponse(_)
419                | wire::ResponsePayload::SnapshotImportedResponse(_)
420                | wire::ResponsePayload::SnapshotExportedResponse(_)
421                | wire::ResponsePayload::OverlayCreatedResponse(_)
422                | wire::ResponsePayload::GuestFilesystemResultResponse(_)
423                | wire::ResponsePayload::RootFilesystemSnapshotResponse(_)
424                | wire::ResponsePayload::ProcessStartedResponse(_)
425                | wire::ResponsePayload::StdinWrittenResponse(_)
426                | wire::ResponsePayload::PtyResizedResponse(_)
427                | wire::ResponsePayload::StdinClosedResponse(_)
428                | wire::ResponsePayload::ProcessKilledResponse(_)
429                | wire::ResponsePayload::ProcessSnapshotResponse(_)
430                | wire::ResponsePayload::ListenerSnapshotResponse(_)
431                | wire::ResponsePayload::BoundUdpSnapshotResponse(_)
432                | wire::ResponsePayload::SignalStateResponse(_)
433                | wire::ResponsePayload::ZombieTimerCountResponse(_)
434                | wire::ResponsePayload::FilesystemResultResponse(_)
435                | wire::ResponsePayload::PermissionDecisionResponse(_)
436                | wire::ResponsePayload::PersistenceStateResponse(_)
437                | wire::ResponsePayload::PersistenceFlushedResponse(_)
438                | wire::ResponsePayload::VmFetchResponse(_)
439                | wire::ResponsePayload::ExtEnvelope(_)
440                | wire::ResponsePayload::GuestKernelResultResponse(_)
441                | wire::ResponsePayload::ResourceSnapshotResponse(_)
442                | wire::ResponsePayload::PackageLinkedResponse(_)
443                | wire::ResponsePayload::PackageUnlinkedResponse(_)
444                | wire::ResponsePayload::PackageAcquiredResponse(_)
445                | wire::ResponsePayload::PackageInstalledResponse(_)
446                | wire::ResponsePayload::PackageCacheStatsResponse(_)
447                | wire::ResponsePayload::ProvidedCommandsResponse(_)
448                | wire::ResponsePayload::ListMountsResponse(_)
449                | wire::ResponsePayload::ExecutionAcceptedResponse(_)
450                | wire::ResponsePayload::ExecutionCompletedResponse(_)
451                | wire::ResponsePayload::ExecutionEvaluationResponse(_)
452                | wire::ResponsePayload::TypeScriptCheckResponse(_)
453                | wire::ResponsePayload::ExecutionDescriptorResponse(_)
454                | wire::ResponsePayload::ExecutionListResponse(_)
455                | wire::ResponsePayload::ExecutionDeletedResponse(_)
456                | wire::ResponsePayload::ExecutionIoResponse(_)
457                | wire::ResponsePayload::ExecutionOutputPageResponse(_)
458                | wire::ResponsePayload::ProcessOutputPageResponse(_) => {
459                    return Err(ClientError::Sidecar(
460                        "unexpected create_vm response".to_string(),
461                    ));
462                }
463            };
464            let vm_id = vm.vm_id;
465            created_vm = Some((
466                transport.clone(),
467                connection_id.clone(),
468                session_id.clone(),
469                vm_id.clone(),
470            ));
471
472            // 5. Wait for the VM to reach `ready` (bounded by VM_READY_TIMEOUT_MS).
473            wait_for_vm_ready(&mut events, &vm_id, crate::VM_READY_TIMEOUT_MS).await?;
474
475            // Forward packages to the sidecar. The sidecar owns manifest parsing and
476            // command discovery for the `/opt/agentos` projection.
477            let packages = build_package_descriptors(&config);
478
479            // Native plugin mounts configured on the client.
480            let mounts = serialize_mounts(&config)?;
481            let configured_mounts = mounts.clone();
482
483            // 6. Configure the VM (vm scope). The sidecar owns the `/opt/agentos` package
484            // projection: it builds the staging dir + registers the read-only host_dir
485            // mount itself from the forwarded `packages`.
486            let projected_commands = match transport
487                .request_wire(
488                    wire_vm_ownership(&connection_id, &session_id, &vm_id),
489                    wire::RequestPayload::ConfigureVmRequest(wire::ConfigureVmRequest {
490                        mounts,
491                        // The legacy `software`/SoftwareDescriptor provisioning path is
492                        // retired: all boot software is projected via `packages`.
493                        software: Vec::new(),
494                        // CreateVm already resolved the selected defaults profile
495                        // and any explicit overrides. Preserve that sidecar-owned
496                        // policy while configuring mounts and packages.
497                        permissions: None,
498                        // Client-side `moduleAccessCwd` was removed in favor of an
499                        // explicit `nodeModulesMount(...)` entry in `mounts`; the
500                        // agentos wire field is left unset.
501                        module_access_cwd: None,
502                        instructions: Vec::new(),
503                        projected_modules: Vec::new(),
504                        command_permissions: HashMap::new(),
505                        loopback_exempt_ports: config.loopback_exempt_ports.clone(),
506                        packages,
507                        packages_mount_at: config.packages_mount_at.clone().unwrap_or_default(),
508                        bootstrap_commands: Vec::new(),
509                        host_function_shim_commands: Vec::new(),
510                    }),
511                )
512                .await?
513            {
514                wire::ResponsePayload::VmConfiguredResponse(configured) => configured
515                    .projected_commands
516                    .into_iter()
517                    .map(|command| (command.name, command.guest_path))
518                    .collect(),
519                wire::ResponsePayload::RejectedResponse(rejected) => {
520                    return Err(rejected_to_error(rejected));
521                }
522                wire::ResponsePayload::AuthenticatedResponse(_)
523                | wire::ResponsePayload::SessionOpenedResponse(_)
524                | wire::ResponsePayload::VmCreatedResponse(_)
525                | wire::ResponsePayload::VmDisposedResponse(_)
526                | wire::ResponsePayload::VmConfigComparedResponse(_)
527                | wire::ResponsePayload::RootFilesystemBootstrappedResponse(_)
528                | wire::ResponsePayload::HostCallbacksRegisteredResponse(_)
529                | wire::ResponsePayload::LayerCreatedResponse(_)
530                | wire::ResponsePayload::LayerSealedResponse(_)
531                | wire::ResponsePayload::SnapshotImportedResponse(_)
532                | wire::ResponsePayload::SnapshotExportedResponse(_)
533                | wire::ResponsePayload::OverlayCreatedResponse(_)
534                | wire::ResponsePayload::GuestFilesystemResultResponse(_)
535                | wire::ResponsePayload::RootFilesystemSnapshotResponse(_)
536                | wire::ResponsePayload::ProcessStartedResponse(_)
537                | wire::ResponsePayload::StdinWrittenResponse(_)
538                | wire::ResponsePayload::PtyResizedResponse(_)
539                | wire::ResponsePayload::StdinClosedResponse(_)
540                | wire::ResponsePayload::ProcessKilledResponse(_)
541                | wire::ResponsePayload::ProcessSnapshotResponse(_)
542                | wire::ResponsePayload::ListenerSnapshotResponse(_)
543                | wire::ResponsePayload::BoundUdpSnapshotResponse(_)
544                | wire::ResponsePayload::SignalStateResponse(_)
545                | wire::ResponsePayload::ZombieTimerCountResponse(_)
546                | wire::ResponsePayload::FilesystemResultResponse(_)
547                | wire::ResponsePayload::PermissionDecisionResponse(_)
548                | wire::ResponsePayload::PersistenceStateResponse(_)
549                | wire::ResponsePayload::PersistenceFlushedResponse(_)
550                | wire::ResponsePayload::VmFetchResponse(_)
551                | wire::ResponsePayload::ExtEnvelope(_)
552                | wire::ResponsePayload::GuestKernelResultResponse(_)
553                | wire::ResponsePayload::ResourceSnapshotResponse(_)
554                | wire::ResponsePayload::PackageLinkedResponse(_)
555                | wire::ResponsePayload::PackageUnlinkedResponse(_)
556                | wire::ResponsePayload::PackageAcquiredResponse(_)
557                | wire::ResponsePayload::PackageInstalledResponse(_)
558                | wire::ResponsePayload::PackageCacheStatsResponse(_)
559                | wire::ResponsePayload::ProvidedCommandsResponse(_)
560                | wire::ResponsePayload::ListMountsResponse(_)
561                | wire::ResponsePayload::ExecutionAcceptedResponse(_)
562                | wire::ResponsePayload::ExecutionCompletedResponse(_)
563                | wire::ResponsePayload::ExecutionEvaluationResponse(_)
564                | wire::ResponsePayload::TypeScriptCheckResponse(_)
565                | wire::ResponsePayload::ExecutionDescriptorResponse(_)
566                | wire::ResponsePayload::ExecutionListResponse(_)
567                | wire::ResponsePayload::ExecutionDeletedResponse(_)
568                | wire::ResponsePayload::ExecutionIoResponse(_)
569                | wire::ResponsePayload::ExecutionOutputPageResponse(_)
570                | wire::ResponsePayload::ProcessOutputPageResponse(_) => {
571                    return Err(ClientError::Sidecar(
572                        "unexpected configure_vm response".to_string(),
573                    ));
574                }
575            };
576
577            // 6b. Register host-function collections (if any): forward each definition via `register_host_callbacks`,
578            //     record the host execute callbacks in the per-VM registry, and install the shared
579            //     host-callback that routes guest host-function calls back to the host by VM.
580            let resolved_host_functions = resolve_host_functions(&config.host_functions)
581                .map_err(ClientError::InvalidConfig)?;
582            if !resolved_host_functions.is_empty() {
583                let mut host_function_map: HashMap<String, ResolvedHostFunction> = HashMap::new();
584                for collection in &resolved_host_functions {
585                    let mut host_functions = HashMap::new();
586                    for host_function in &collection.functions {
587                        host_functions.insert(
588                            host_function.name.clone(),
589                            wire::RegisteredHostCallbackDefinition {
590                                description: host_function.description.clone(),
591                                input_schema: json_utf8(
592                                    &host_function.input_schema,
593                                    "host callback input schema",
594                                )?,
595                                timeout_ms: host_function.timeout_ms,
596                                examples: Vec::new(),
597                            },
598                        );
599                        host_function_map.insert(
600                            format!("{}:{}", collection.name, host_function.name),
601                            host_function.clone(),
602                        );
603                    }
604                    match transport
605                        .request_wire(
606                            wire_vm_ownership(&connection_id, &session_id, &vm_id),
607                            wire::RequestPayload::RegisterHostCallbacksRequest(
608                                wire::RegisterHostCallbacksRequest {
609                                    name: collection.name.clone(),
610                                    description: String::new(),
611                                    command_aliases: vec![format!("agentos-{}", collection.name)],
612                                    registry_command_aliases: vec![String::from("agentos")],
613                                    callbacks: host_functions,
614                                },
615                            ),
616                        )
617                        .await?
618                    {
619                        wire::ResponsePayload::HostCallbacksRegisteredResponse(_) => {}
620                        wire::ResponsePayload::RejectedResponse(rejected) => {
621                            return Err(rejected_to_error(rejected));
622                        }
623                        wire::ResponsePayload::AuthenticatedResponse(_)
624                        | wire::ResponsePayload::SessionOpenedResponse(_)
625                        | wire::ResponsePayload::VmCreatedResponse(_)
626                        | wire::ResponsePayload::VmDisposedResponse(_)
627                        | wire::ResponsePayload::VmConfigComparedResponse(_)
628                        | wire::ResponsePayload::RootFilesystemBootstrappedResponse(_)
629                        | wire::ResponsePayload::VmConfiguredResponse(_)
630                        | wire::ResponsePayload::LayerCreatedResponse(_)
631                        | wire::ResponsePayload::LayerSealedResponse(_)
632                        | wire::ResponsePayload::SnapshotImportedResponse(_)
633                        | wire::ResponsePayload::SnapshotExportedResponse(_)
634                        | wire::ResponsePayload::OverlayCreatedResponse(_)
635                        | wire::ResponsePayload::GuestFilesystemResultResponse(_)
636                        | wire::ResponsePayload::RootFilesystemSnapshotResponse(_)
637                        | wire::ResponsePayload::ProcessStartedResponse(_)
638                        | wire::ResponsePayload::StdinWrittenResponse(_)
639                        | wire::ResponsePayload::PtyResizedResponse(_)
640                        | wire::ResponsePayload::StdinClosedResponse(_)
641                        | wire::ResponsePayload::ProcessKilledResponse(_)
642                        | wire::ResponsePayload::ProcessSnapshotResponse(_)
643                        | wire::ResponsePayload::ListenerSnapshotResponse(_)
644                        | wire::ResponsePayload::BoundUdpSnapshotResponse(_)
645                        | wire::ResponsePayload::SignalStateResponse(_)
646                        | wire::ResponsePayload::ZombieTimerCountResponse(_)
647                        | wire::ResponsePayload::FilesystemResultResponse(_)
648                        | wire::ResponsePayload::PermissionDecisionResponse(_)
649                        | wire::ResponsePayload::PersistenceStateResponse(_)
650                        | wire::ResponsePayload::PersistenceFlushedResponse(_)
651                        | wire::ResponsePayload::VmFetchResponse(_)
652                        | wire::ResponsePayload::ExtEnvelope(_)
653                        | wire::ResponsePayload::GuestKernelResultResponse(_)
654                        | wire::ResponsePayload::ResourceSnapshotResponse(_)
655                        | wire::ResponsePayload::PackageLinkedResponse(_)
656                        | wire::ResponsePayload::PackageUnlinkedResponse(_)
657                        | wire::ResponsePayload::PackageAcquiredResponse(_)
658                        | wire::ResponsePayload::PackageInstalledResponse(_)
659                        | wire::ResponsePayload::PackageCacheStatsResponse(_)
660                        | wire::ResponsePayload::ProvidedCommandsResponse(_)
661                        | wire::ResponsePayload::ListMountsResponse(_)
662                        | wire::ResponsePayload::ExecutionAcceptedResponse(_)
663                        | wire::ResponsePayload::ExecutionCompletedResponse(_)
664                        | wire::ResponsePayload::ExecutionEvaluationResponse(_)
665                        | wire::ResponsePayload::TypeScriptCheckResponse(_)
666                        | wire::ResponsePayload::ExecutionDescriptorResponse(_)
667                        | wire::ResponsePayload::ExecutionListResponse(_)
668                        | wire::ResponsePayload::ExecutionDeletedResponse(_)
669                        | wire::ResponsePayload::ExecutionIoResponse(_)
670                        | wire::ResponsePayload::ExecutionOutputPageResponse(_)
671                        | wire::ResponsePayload::ProcessOutputPageResponse(_) => {
672                            return Err(ClientError::Sidecar(
673                                "unexpected register_host_callbacks response".to_string(),
674                            ));
675                        }
676                    }
677                }
678                let _ = vm_host_functions().insert(
679                    vm_route_key(transport_id, &vm_id),
680                    Arc::new(VmHostFunctionRegistry {
681                        host_functions: resolved_host_functions.clone(),
682                        host_function_map,
683                    }),
684                );
685                transport
686                    .register_wire_callback("host_callback", host_callback_callback(transport_id));
687            }
688
689            // 7. Lease this VM on the (possibly shared) sidecar, build cron, and assemble the client.
690            let driver = config
691                .schedule_driver
692                .clone()
693                .unwrap_or_else(|| Arc::new(TimerScheduleDriver::new()));
694            let cron = Arc::new(CronManager::new(driver));
695
696            let inner = AgentOsInner {
697                transport,
698                connection_id,
699                session_id,
700                vm_id,
701                projected_commands: parking_lot::Mutex::new(projected_commands),
702                vm_configuration_operation: tokio::sync::Mutex::new(()),
703                installed_software: parking_lot::Mutex::new(BTreeMap::new()),
704                process_registry_lock: parking_lot::Mutex::new(()),
705                pending_process_registrations: AtomicUsize::new(0),
706                processes: SccHashMap::new(),
707                process_counter: AtomicU64::new(1),
708                synthetic_pid_counter: AtomicU64::new(SYNTHETIC_PID_BASE),
709                observed_process_time_lock: parking_lot::Mutex::new(()),
710                observed_process_start_times: SccHashMap::new(),
711                observed_process_exit_times: SccHashMap::new(),
712                shells: SccHashMap::new(),
713                shell_counter: AtomicU64::new(0),
714                pending_shell_exits: SccHashMap::new(),
715                closed_shells: parking_lot::Mutex::new(VecDeque::new()),
716                terminals: SccHashMap::new(),
717                terminal_count: AtomicUsize::new(0),
718                terminal_lifecycle_lock: tokio::sync::Mutex::new(()),
719                cron,
720                config,
721                sidecar: sidecar.clone(),
722                sidecar_lease: parking_lot::Mutex::new(lease.take()),
723                dynamic_mounts: parking_lot::Mutex::new(configured_mounts),
724                disposed: AtomicBool::new(false),
725                shutdown_result: tokio::sync::Mutex::new(None),
726                vm_disposed: AtomicBool::new(false),
727            };
728
729            let client = AgentOs {
730                inner: Arc::new(inner),
731            };
732            // Host functions can read JSON arguments from the guest filesystem. Keep
733            // a weak VM route for that trusted Core-only callback without exposing
734            // any product-specific orchestration protocol.
735            let _ = vm_clients().insert(
736                vm_route_key(client.inner.transport.instance_id(), &client.inner.vm_id),
737                Arc::downgrade(&client.inner),
738            );
739            Ok(client)
740        }
741        .await;
742        if result.is_err() {
743            let mut cleanup_confirmed = true;
744            if let Some((transport, connection_id, session_id, vm_id)) = created_vm {
745                let cleanup = transport
746                    .request_wire(
747                        wire_vm_ownership(&connection_id, &session_id, &vm_id),
748                        wire::RequestPayload::DisposeVmRequest(wire::DisposeVmRequest {
749                            reason: wire::DisposeReason::Requested,
750                        }),
751                    )
752                    .await
753                    .map_err(ClientError::from)
754                    .and_then(|response| vm_dispose_response(&vm_id, response));
755                if let Err(error) = cleanup {
756                    cleanup_confirmed = false;
757                    tracing::error!(%vm_id, %error, "failed to dispose partially initialized VM; retaining sidecar ownership until explicit worker shutdown");
758                }
759                let route = vm_route_key(transport.instance_id(), &vm_id);
760                vm_host_functions().remove(&route);
761                vm_clients().remove(&route);
762            }
763            if let Some(key) = created_session_key {
764                session_js_bridge_callbacks().remove(&key);
765                session_sqlite_callbacks().remove(&key);
766            }
767            if let Some(lease) = lease {
768                if cleanup_confirmed {
769                    lease.dispose().await?;
770                } else {
771                    lease.retain_until_worker_shutdown();
772                }
773            }
774            if let Err(error) = sidecar.dispose_if_unused().await {
775                tracing::error!(%error, "failed to stop sidecar after VM creation failed");
776            }
777        }
778        result
779    }
780
781    /// Dispose the VM (= TS `dispose`). Teardown order:
782    /// 1. cron dispose
783    /// 2. kill all shells + snapshot pending exits
784    /// 3. kill all connected terminals
785    /// 4. drain tracked shell-exit tasks (two-phase, bounded by
786    ///    [`crate::SHELL_DISPOSE_TIMEOUT_MS`])
787    /// 5. release the lease (or tear down the transport)
788    ///
789    /// Idempotent (guarded by `disposed`).
790    /// Dynamically link a software package into the RUNNING VM (parity with the
791    /// TS client's `linkSoftware`). Forwarded to the sidecar, which owns the
792    /// `/opt/agentos` projection and appends the package to its live staging dir,
793    /// so the package's commands appear under `/opt/agentos/bin` (on `$PATH`)
794    /// immediately with no reboot. Errors if a command name is already linked.
795    pub async fn link_software(&self, descriptor: PackageDescriptor) -> Result<(), ClientError> {
796        let vm_configuration = try_vm_mutation(&self.inner.vm_configuration_operation)?;
797        let package_id = format!("path:{}", descriptor.path);
798        self.link_software_path(&descriptor.path, &package_id, &vm_configuration)
799            .await
800    }
801
802    /// Resolve, verify, pin, and project one exact software artifact inside the
803    /// sidecar. The client sends only the source, never a resolved host path.
804    ///
805    /// A [`crate::SoftwareSource::Registry`] source resolves on this host to the
806    /// release manifest's URL and digest, so the sidecar verifies the bytes
807    /// exactly as for an explicit URL source.
808    pub async fn install_software(
809        &self,
810        source: impl Into<crate::software_registry::SoftwareSource>,
811    ) -> Result<crate::software::InstalledSoftware, ClientError> {
812        let _vm_configuration = try_vm_mutation(&self.inner.vm_configuration_operation)?;
813        let source = crate::software_registry::resolve_software_source(
814            source.into(),
815            self.inner.config.software_registry.as_ref(),
816        )
817        .await?;
818        let source = match source {
819            crate::software::PackageSource::Url {
820                url,
821                expected_digest,
822            } => wire::PackageAcquisitionSource::PackageUrlSource(wire::PackageUrlSource {
823                url,
824                expected_digest,
825            }),
826            crate::software::PackageSource::Path {
827                path,
828                expected_digest,
829            } => wire::PackageAcquisitionSource::PackagePathSource(wire::PackagePathSource {
830                path,
831                expected_digest,
832            }),
833        };
834        let options = self.inner.config.package_resolver.as_ref();
835        let response = self
836            .transport()
837            .request_wire(
838                wire_vm_ownership(
839                    &self.inner.connection_id,
840                    &self.inner.session_id,
841                    &self.inner.vm_id,
842                ),
843                wire::RequestPayload::InstallPackageRequest(wire::InstallPackageRequest {
844                    acquisition: wire::AcquirePackageRequest {
845                        source,
846                        advisory: false,
847                        timeout_ms: None,
848                        max_package_bytes: options.map(|options| options.max_package_bytes),
849                        download_timeout_ms: options.map(|options| options.download_timeout_ms),
850                        connect_timeout_ms: options.map(|options| options.connect_timeout_ms),
851                        max_redirects: options.map(|options| options.max_redirects as u32),
852                        allow_insecure_local_http: options
853                            .is_some_and(|options| options.allow_insecure_local_http),
854                    },
855                }),
856            )
857            .await?;
858        match response {
859            wire::ResponsePayload::PackageInstalledResponse(installed) => {
860                let package = installed.package;
861                let info = crate::software::InstalledSoftware {
862                    package_id: package.package_id,
863                    digest: package.digest,
864                    size_bytes: package.size,
865                    package_name: package.package_name,
866                    version: package.version,
867                    commands: package.commands,
868                };
869                let mut projected = self.inner.projected_commands.lock();
870                for command in installed.projected_commands {
871                    projected.insert(command.name, command.guest_path);
872                }
873                self.inner
874                    .installed_software
875                    .lock()
876                    .insert(info.package_id.clone(), info.clone());
877                Ok(info)
878            }
879            wire::ResponsePayload::RejectedResponse(rejected) => {
880                Err(ClientError::from_rejection(rejected))
881            }
882            other => Err(ClientError::Sidecar(format!(
883                "unexpected install_package response: {other:?}"
884            ))),
885        }
886    }
887
888    /// Remove one exact package identity from the live VM. Missing ids are a
889    /// typed error so actor desired state cannot silently drift from Core.
890    pub async fn uninstall_software(
891        &self,
892        package_id: &str,
893    ) -> Result<crate::software::InstalledSoftware, ClientError> {
894        let _vm_configuration = try_vm_mutation(&self.inner.vm_configuration_operation)?;
895        let installed = self
896            .inner
897            .installed_software
898            .lock()
899            .get(package_id)
900            .cloned()
901            .ok_or_else(|| ClientError::SoftwareNotFound(package_id.to_owned()))?;
902        let inner = self.inner();
903        let response = self
904            .transport()
905            .request_wire(
906                wire_vm_ownership(&inner.connection_id, &inner.session_id, &inner.vm_id),
907                wire::RequestPayload::UnlinkPackageRequest(wire::UnlinkPackageRequest {
908                    package_id: package_id.to_owned(),
909                }),
910            )
911            .await?;
912        match response {
913            wire::ResponsePayload::PackageUnlinkedResponse(unlinked) => {
914                let mut commands = inner.projected_commands.lock();
915                for command in unlinked.removed_commands {
916                    commands.remove(&command);
917                }
918                inner.installed_software.lock().remove(package_id);
919                Ok(installed)
920            }
921            wire::ResponsePayload::RejectedResponse(rejected) => Err(rejected_to_error(rejected)),
922            other => Err(ClientError::Sidecar(format!(
923                "unexpected unlink_package response: {other:?}"
924            ))),
925        }
926    }
927
928    /// Content-addressed packages installed through [`Self::install_software`].
929    /// The legacy package-name view remains available through
930    /// [`Self::list_software`] for trusted path configuration.
931    pub fn installed_software(&self) -> Vec<crate::software::InstalledSoftware> {
932        self.inner
933            .installed_software
934            .lock()
935            .values()
936            .cloned()
937            .collect()
938    }
939
940    async fn link_software_path(
941        &self,
942        path: &str,
943        package_id: &str,
944        _vm_configuration: &tokio::sync::MutexGuard<'_, ()>,
945    ) -> Result<(), ClientError> {
946        let inner = self.inner();
947        let response = self
948            .transport()
949            .request_wire(
950                wire_vm_ownership(&inner.connection_id, &inner.session_id, &inner.vm_id),
951                wire::RequestPayload::LinkPackageRequest(wire::LinkPackageRequest {
952                    // The wire `PackageDescriptor` carries the packed package
953                    // `path`; the sidecar reads metadata from that payload.
954                    package: wire::PackageDescriptor {
955                        path: path.to_owned(),
956                    },
957                    package_id: package_id.to_owned(),
958                }),
959            )
960            .await?;
961        match response {
962            wire::ResponsePayload::PackageLinkedResponse(linked) => {
963                let mut guard = inner.projected_commands.lock();
964                for command in linked.projected_commands {
965                    guard.insert(command.name, command.guest_path);
966                }
967                Ok(())
968            }
969            wire::ResponsePayload::RejectedResponse(rejected) => Err(rejected_to_error(rejected)),
970            other => Err(ClientError::Sidecar(format!(
971                "unexpected link_package response: {other:?}"
972            ))),
973        }
974    }
975
976    pub async fn list_software(&self) -> Result<Vec<SoftwareInfo>, ClientError> {
977        let inner = self.inner();
978        let response = self
979            .transport()
980            .request_wire(
981                wire_vm_ownership(&inner.connection_id, &inner.session_id, &inner.vm_id),
982                wire::RequestPayload::ProvidedCommandsRequest,
983            )
984            .await?;
985        match response {
986            wire::ResponsePayload::ProvidedCommandsResponse(provided) => Ok(provided
987                .packages
988                .into_iter()
989                .map(|package| SoftwareInfo {
990                    package_name: package.package_name,
991                    commands: package.commands,
992                })
993                .collect()),
994            wire::ResponsePayload::RejectedResponse(rejected) => Err(rejected_to_error(rejected)),
995            other => Err(ClientError::Sidecar(format!(
996                "unexpected list_software response: {other:?}"
997            ))),
998        }
999    }
1000
1001    pub async fn shutdown(&self) -> Result<(), ClientError> {
1002        // Serialize callers and retain only completed outcomes. Cancellation
1003        // must allow retry, not turn an unfinished DisposeVm into success.
1004        let mut shutdown_result = self.inner.shutdown_result.lock().await;
1005        if let Some(result) = shutdown_result.as_ref() {
1006            return result.clone();
1007        }
1008        self.inner.disposed.store(true, Ordering::SeqCst);
1009
1010        // The `/opt/agentos` projection staging dir is owned + cleaned up by the
1011        // sidecar on VM dispose, so the client no longer removes it here.
1012
1013        // 1. Cron dispose (cancel armed timers + tear down the driver).
1014        self.inner.cron.dispose();
1015
1016        // Drain the SDK-spawned process registry. Per-process output tasks await
1017        // a broadcast `Closed` that retained sender clones otherwise prevent.
1018        crate::process::drain_process_output_tasks(&self.inner.processes);
1019
1020        // 2-5. Best-effort drain tracked shell and terminal tasks before the VM is disposed, bounded
1021        //      by SHELL_DISPOSE_TIMEOUT_MS so late output cannot race a closed transport.
1022        let mut exit_tasks = Vec::new();
1023        self.inner.pending_shell_exits.retain(|_, task| {
1024            exit_tasks.push(std::mem::replace(task, tokio::spawn(async {})));
1025            false
1026        });
1027
1028        {
1029            let _terminal_lifecycle_guard = self.inner.terminal_lifecycle_lock.lock().await;
1030            let mut terminal_entries = Vec::new();
1031            self.inner.terminals.retain(|process_id, entry| {
1032                terminal_entries.push((
1033                    process_id.clone(),
1034                    std::mem::replace(&mut entry.exit_task, tokio::spawn(async {})),
1035                ));
1036                false
1037            });
1038            self.inner.terminal_count.store(0, Ordering::SeqCst);
1039            for (process_id, _) in &terminal_entries {
1040                let transport = self.transport().clone();
1041                let ownership = wire::OwnershipScope::VmOwnership(wire::VmOwnership {
1042                    connection_id: self.inner.connection_id.clone(),
1043                    session_id: self.inner.session_id.clone(),
1044                    vm_id: self.inner.vm_id.clone(),
1045                });
1046                let process_id = process_id.clone();
1047                exit_tasks.push(tokio::spawn(async move {
1048                    let _ = transport
1049                        .request_wire(
1050                            ownership,
1051                            wire::RequestPayload::KillProcessRequest(wire::KillProcessRequest {
1052                                process_id,
1053                                signal: String::from("SIGTERM"),
1054                            }),
1055                        )
1056                        .await;
1057                }));
1058            }
1059            for (_, task) in terminal_entries {
1060                exit_tasks.push(task);
1061            }
1062        }
1063
1064        if !exit_tasks.is_empty() {
1065            let mut drain_tasks = exit_tasks;
1066            if tokio::time::timeout(
1067                Duration::from_millis(crate::SHELL_DISPOSE_TIMEOUT_MS),
1068                futures::future::join_all(drain_tasks.iter_mut()),
1069            )
1070            .await
1071            .is_err()
1072            {
1073                for task in drain_tasks {
1074                    task.abort();
1075                }
1076            }
1077        }
1078
1079        // 6-7. Release this VM and its lease. Preserve disposal failure while
1080        // completing secondary cleanup. The transport is shared across
1081        //      VMs on the same sidecar, so it is only torn down when this was the last VM (matching
1082        //      the TS lease/shared-sidecar lifecycle); otherwise sibling VMs keep using it.
1083        // A VM dies with its sidecar process, so a lost transport leaves nothing to dispose.
1084        let mut disposal_result = if self.inner.vm_disposed.load(Ordering::SeqCst)
1085            || self.transport().is_disconnected()
1086        {
1087            Ok(())
1088        } else {
1089            self.transport()
1090                .request_wire(
1091                    wire::OwnershipScope::VmOwnership(wire::VmOwnership {
1092                        connection_id: self.inner.connection_id.clone(),
1093                        session_id: self.inner.session_id.clone(),
1094                        vm_id: self.inner.vm_id.clone(),
1095                    }),
1096                    wire::RequestPayload::DisposeVmRequest(wire::DisposeVmRequest {
1097                        reason: wire::DisposeReason::Requested,
1098                    }),
1099                )
1100                .await
1101                .map_err(ClientError::from)
1102                .and_then(|response| vm_dispose_response(&self.inner.vm_id, response))
1103        };
1104        if disposal_result.is_ok() {
1105            self.inner.vm_disposed.store(true, Ordering::SeqCst);
1106            // A failed DisposeVm may need host SQLite callbacks on retry.
1107            // Unregister routes only after the sidecar confirms VM disposal.
1108            let transport_id = self.inner.transport.instance_id();
1109            let vm_route = vm_route_key(transport_id, &self.inner.vm_id);
1110            let session_key = sidecar_session_key(
1111                transport_id,
1112                &self.inner.connection_id,
1113                &self.inner.session_id,
1114            );
1115            let _ = vm_host_functions().remove(&vm_route);
1116            let _ = vm_clients().remove(&vm_route);
1117            let _ = session_js_bridge_callbacks().remove(&session_key);
1118            let _ = session_sqlite_callbacks().remove(&session_key);
1119        }
1120        let sidecar = self.inner.sidecar.clone();
1121        let lease = if disposal_result.is_ok() {
1122            self.inner.sidecar_lease.lock().take()
1123        } else {
1124            None
1125        };
1126        if let Some(lease) = lease {
1127            retain_shutdown_failure(&mut disposal_result, lease.dispose().await);
1128        }
1129        retain_shutdown_failure(&mut disposal_result, sidecar.dispose_if_unused().await);
1130        if disposal_result.is_ok() {
1131            *shutdown_result = Some(Ok(()));
1132        }
1133        disposal_result
1134    }
1135
1136    // --- internal accessors used by sibling impl blocks ---
1137
1138    pub(crate) fn inner(&self) -> &AgentOsInner {
1139        &self.inner
1140    }
1141
1142    pub(crate) fn transport(&self) -> &Arc<SidecarProcess> {
1143        &self.inner.transport
1144    }
1145
1146    pub(crate) fn connection_id(&self) -> &str {
1147        &self.inner.connection_id
1148    }
1149
1150    pub(crate) fn wire_session_id(&self) -> &str {
1151        &self.inner.session_id
1152    }
1153
1154    pub(crate) fn vm_id(&self) -> &str {
1155        &self.inner.vm_id
1156    }
1157
1158    pub(crate) fn cron(&self) -> &Arc<CronManager> {
1159        &self.inner.cron
1160    }
1161
1162    /// The (possibly shared) sidecar handle backing this VM. Public for parity with TS
1163    /// `AgentOs.sidecar` (e.g. `describe()` reports `active_vm_count` across VMs sharing a pool).
1164    pub fn sidecar(&self) -> Arc<AgentOsSidecar> {
1165        self.inner.sidecar.clone()
1166    }
1167
1168    #[cfg(feature = "service-internals")]
1169    pub(crate) async fn vm_config_equivalent(
1170        &self,
1171        before: &AgentOsConfig,
1172        after: &AgentOsConfig,
1173        before_restart_identity: Vec<String>,
1174        after_restart_identity: Vec<String>,
1175    ) -> Result<bool, ClientError> {
1176        let before_mounts = serialize_mounts(before)?;
1177        let after_mounts = serialize_mounts(after)?;
1178        let before = serialize_create_vm_config_for_sidecar(before)?;
1179        let after = serialize_create_vm_config_for_sidecar(after)?;
1180        let response = self
1181            .transport()
1182            .request_wire(
1183                wire_session_ownership(self.connection_id(), self.wire_session_id()),
1184                wire::RequestPayload::CompareVmConfigRequest(wire::CompareVmConfigRequest {
1185                    before: serde_json::to_string(&before).map_err(|error| {
1186                        ClientError::Sidecar(format!("serialize before VM config: {error}"))
1187                    })?,
1188                    after: serde_json::to_string(&after).map_err(|error| {
1189                        ClientError::Sidecar(format!("serialize after VM config: {error}"))
1190                    })?,
1191                    before_mounts,
1192                    after_mounts,
1193                    before_restart_identity,
1194                    after_restart_identity,
1195                }),
1196            )
1197            .await?;
1198        match response {
1199            wire::ResponsePayload::VmConfigComparedResponse(result) => Ok(result.equivalent),
1200            wire::ResponsePayload::RejectedResponse(rejected) => Err(rejected_to_error(rejected)),
1201            _ => Err(ClientError::Sidecar(String::from(
1202                "unexpected compare VM config response",
1203            ))),
1204        }
1205    }
1206}
1207
1208/// Convert a sidecar's client-side placement into the wire `SidecarPlacement` for OpenSession.
1209pub(crate) fn sidecar_wire_placement(sidecar: &AgentOsSidecar) -> wire::SidecarPlacement {
1210    match &sidecar.placement {
1211        AgentOsSidecarPlacement::Shared { pool } => {
1212            wire::SidecarPlacement::SidecarPlacementShared(wire::SidecarPlacementShared {
1213                pool: pool.clone(),
1214            })
1215        }
1216        AgentOsSidecarPlacement::Explicit { sidecar_id } => {
1217            wire::SidecarPlacement::SidecarPlacementExplicit(wire::SidecarPlacementExplicit {
1218                sidecar_id: sidecar_id.clone(),
1219            })
1220        }
1221    }
1222}
1223
1224fn wire_connection_ownership(connection_id: &str) -> wire::OwnershipScope {
1225    wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
1226        connection_id: connection_id.to_string(),
1227    })
1228}
1229
1230fn wire_session_ownership(connection_id: &str, session_id: &str) -> wire::OwnershipScope {
1231    wire::OwnershipScope::SessionOwnership(wire::SessionOwnership {
1232        connection_id: connection_id.to_string(),
1233        session_id: session_id.to_string(),
1234    })
1235}
1236
1237fn wire_vm_ownership(connection_id: &str, session_id: &str, vm_id: &str) -> wire::OwnershipScope {
1238    wire::OwnershipScope::VmOwnership(wire::VmOwnership {
1239        connection_id: connection_id.to_string(),
1240        session_id: session_id.to_string(),
1241        vm_id: vm_id.to_string(),
1242    })
1243}
1244
1245fn serialize_create_vm_config_for_sidecar(
1246    config: &AgentOsConfig,
1247) -> Result<vm_config::CreateVmConfig, ClientError> {
1248    let (root_filesystem, native_root) =
1249        serialize_root_filesystem_config_for_sidecar(&config.root_filesystem)?;
1250    let mut create = vm_config::CreateVmConfig {
1251        defaults_profile: Some(vm_config::VmDefaultsProfile::AgentOs),
1252        database: config.database.clone(),
1253        cwd: None,
1254        env: config.environment.clone(),
1255        user: config.user.clone(),
1256        root_filesystem,
1257        permissions: permissions_policy_config(config),
1258        limits: serialize_limits_config_for_sidecar(config.limits.as_ref())?,
1259        dns: None,
1260        native_root,
1261        listen: None,
1262        loopback_exempt_ports: config.loopback_exempt_ports.clone(),
1263        // 0.3: the Node builtin allow-list moved from ConfigureVmRequest to
1264        // VM creation. `None` => engine default allow-list; `Some([..])` =>
1265        // exactly those (`Some([])` denies all). Platform/module-resolution
1266        // keep their engine defaults (full Node emulation), matching prior
1267        // behavior where Agent OS only ever constrained the builtin allow-list.
1268        js_runtime: (config.allowed_node_builtins.is_some()
1269            || config.high_resolution_time.is_some())
1270        .then(|| vm_config::JsRuntimeConfig {
1271            platform: vm_config::JsRuntimePlatform::default(),
1272            module_resolution: vm_config::JsModuleResolution::default(),
1273            allowed_builtins: config.allowed_node_builtins.clone(),
1274            high_resolution_time: config.high_resolution_time,
1275        }),
1276        bootstrap_commands: None,
1277    };
1278    create
1279        .normalize()
1280        .map_err(|error| ClientError::Sidecar(format!("invalid VM config: {error}")))?;
1281    Ok(create)
1282}
1283
1284pub(crate) fn validate_config(config: &AgentOsConfig) -> Result<(), ClientError> {
1285    if matches!(
1286        config.database,
1287        Some(vm_config::VmSqliteDescriptor::HostCallback { .. })
1288    ) && config.sidecar_sqlite_callback.is_none()
1289    {
1290        return Err(ClientError::Sidecar(String::from(
1291            "database type host_callback requires sidecar_sqlite_callback",
1292        )));
1293    }
1294    if let Some(options) = &config.package_resolver {
1295        options.validate()?;
1296    }
1297    let create = serialize_create_vm_config_for_sidecar(config)?;
1298    create
1299        .validate(wire::DEFAULT_MAX_FRAME_BYTES)
1300        .map_err(|error| ClientError::Sidecar(format!("invalid VM config: {error}")))?;
1301    serialize_mounts(config)?;
1302    Ok(())
1303}
1304
1305fn serialize_root_filesystem_config_for_sidecar(
1306    config: &RootFilesystemConfig,
1307) -> Result<
1308    (
1309        vm_config::RootFilesystemConfig,
1310        Option<vm_config::NativeRootFilesystemConfig>,
1311    ),
1312    ClientError,
1313> {
1314    let mode = match config.mode.unwrap_or(ConfigRootFilesystemMode::Ephemeral) {
1315        ConfigRootFilesystemMode::Ephemeral => vm_config::RootFilesystemMode::Ephemeral,
1316        ConfigRootFilesystemMode::ReadOnly => vm_config::RootFilesystemMode::ReadOnly,
1317    };
1318    match config.kind {
1319        RootFilesystemKind::Overlay => {
1320            if config.native_plugin.is_some() {
1321                return Err(ClientError::Sidecar(
1322                    "rootFilesystem.nativePlugin requires type \"native\"".to_string(),
1323                ));
1324            }
1325            let lowers = config
1326                .lowers
1327                .iter()
1328                .map(serialize_root_lower_config_for_sidecar)
1329                .collect::<Result<Vec<_>, _>>()?;
1330            Ok((
1331                vm_config::RootFilesystemConfig {
1332                    mode,
1333                    disable_default_base_layer: config.disable_default_base_layer,
1334                    lowers,
1335                    bootstrap_entries: Vec::new(),
1336                },
1337                None,
1338            ))
1339        }
1340        RootFilesystemKind::Native => {
1341            if !config.lowers.is_empty() {
1342                return Err(ClientError::Sidecar(
1343                    "native root filesystems do not support rootFilesystem.lowers".to_string(),
1344                ));
1345            }
1346            let plugin = config.native_plugin.as_ref().ok_or_else(|| {
1347                ClientError::Sidecar(
1348                    "rootFilesystem.nativePlugin is required for type \"native\"".to_string(),
1349                )
1350            })?;
1351            Ok((
1352                vm_config::RootFilesystemConfig {
1353                    mode,
1354                    disable_default_base_layer: config.disable_default_base_layer,
1355                    lowers: Vec::new(),
1356                    bootstrap_entries: Vec::new(),
1357                },
1358                Some(vm_config::NativeRootFilesystemConfig {
1359                    plugin: vm_config::MountPluginDescriptor {
1360                        id: plugin.id.clone(),
1361                        config: plugin
1362                            .config
1363                            .clone()
1364                            .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
1365                    },
1366                    read_only: config.mode == Some(ConfigRootFilesystemMode::ReadOnly),
1367                }),
1368            ))
1369        }
1370    }
1371}
1372
1373fn serialize_root_lower_config_for_sidecar(
1374    lower: &RootLowerInput,
1375) -> Result<vm_config::RootFilesystemLowerDescriptor, ClientError> {
1376    match lower {
1377        RootLowerInput::BundledBaseFilesystem => {
1378            Ok(vm_config::RootFilesystemLowerDescriptor::BundledBaseFilesystem)
1379        }
1380        RootLowerInput::SnapshotExport(snapshot) => {
1381            let entries = snapshot
1382                .source
1383                .filesystem
1384                .entries
1385                .iter()
1386                .map(serialize_filesystem_entry_config_for_sidecar)
1387                .collect::<Result<Vec<_>, _>>()?;
1388            Ok(vm_config::RootFilesystemLowerDescriptor::Snapshot { entries })
1389        }
1390    }
1391}
1392
1393fn serialize_filesystem_entry_config_for_sidecar(
1394    entry: &crate::fs::FilesystemEntry,
1395) -> Result<vm_config::RootFilesystemEntry, ClientError> {
1396    let mode = u32::from_str_radix(entry.mode.trim_start_matches("0o"), 8).map_err(|error| {
1397        ClientError::Sidecar(format!(
1398            "invalid root filesystem mode {} for {}: {error}",
1399            entry.mode, entry.path
1400        ))
1401    })?;
1402    let kind = match entry.entry_type {
1403        crate::fs::DirEntryType::File => vm_config::RootFilesystemEntryKind::File,
1404        crate::fs::DirEntryType::Directory => vm_config::RootFilesystemEntryKind::Directory,
1405        crate::fs::DirEntryType::Symlink => vm_config::RootFilesystemEntryKind::Symlink,
1406    };
1407    let encoding = entry.encoding.map(|encoding| match encoding {
1408        crate::fs::FilesystemEntryEncoding::Utf8 => vm_config::RootFilesystemEntryEncoding::Utf8,
1409        crate::fs::FilesystemEntryEncoding::Base64 => {
1410            vm_config::RootFilesystemEntryEncoding::Base64
1411        }
1412    });
1413
1414    Ok(vm_config::RootFilesystemEntry {
1415        path: entry.path.clone(),
1416        kind,
1417        mode: Some(mode),
1418        uid: Some(entry.uid),
1419        gid: Some(entry.gid),
1420        content: entry.content.clone(),
1421        encoding,
1422        target: entry.target.clone(),
1423        executable: entry.entry_type == crate::fs::DirEntryType::File && (mode & 0o111) != 0,
1424    })
1425}
1426
1427fn serialize_limits_config_for_sidecar(
1428    limits: Option<&AgentOsLimits>,
1429) -> Result<Option<vm_config::VmLimitsConfig>, ClientError> {
1430    let Some(limits) = limits else {
1431        return Ok(None);
1432    };
1433    let value = serde_json::to_value(limits).map_err(|error| {
1434        ClientError::Sidecar(format!("failed to serialize VM limits config: {error}"))
1435    })?;
1436    serde_json::from_value(value).map(Some).map_err(|error| {
1437        ClientError::Sidecar(format!("failed to encode VM limits config: {error}"))
1438    })
1439}
1440
1441fn permissions_policy_config(config: &AgentOsConfig) -> Option<vm_config::PermissionsPolicy> {
1442    config
1443        .permissions
1444        .as_ref()
1445        .map(|permissions| vm_config::PermissionsPolicy {
1446            fs: permissions.fs.as_ref().map(serialize_fs_permissions_config),
1447            network: permissions
1448                .network
1449                .as_ref()
1450                .map(serialize_pattern_permissions_config),
1451            child_process: permissions
1452                .child_process
1453                .as_ref()
1454                .map(serialize_pattern_permissions_config),
1455            process: permissions
1456                .process
1457                .as_ref()
1458                .map(serialize_pattern_permissions_config),
1459            env: permissions
1460                .env
1461                .as_ref()
1462                .map(serialize_pattern_permissions_config),
1463            host_function: permissions
1464                .host_function
1465                .as_ref()
1466                .map(serialize_pattern_permissions_config),
1467        })
1468}
1469
1470fn serialize_fs_permissions_config(
1471    permissions: &crate::config::FsPermissions,
1472) -> vm_config::FsPermissionScope {
1473    match permissions {
1474        crate::config::FsPermissions::Mode(mode) => {
1475            vm_config::FsPermissionScope::Mode(serialize_permission_mode_config(*mode))
1476        }
1477        crate::config::FsPermissions::Rules(rules) => {
1478            vm_config::FsPermissionScope::Rules(vm_config::FsPermissionRuleSet {
1479                default: rules.default.map(serialize_permission_mode_config),
1480                rules: rules
1481                    .rules
1482                    .iter()
1483                    .map(|rule| vm_config::FsPermissionRule {
1484                        mode: serialize_permission_mode_config(rule.mode),
1485                        operations: operation_wildcard_if_omitted(&rule.operations),
1486                        paths: resource_wildcard_if_omitted(&rule.paths),
1487                    })
1488                    .collect(),
1489            })
1490        }
1491    }
1492}
1493
1494fn serialize_pattern_permissions_config(
1495    permissions: &crate::config::PatternPermissions,
1496) -> vm_config::PatternPermissionScope {
1497    match permissions {
1498        crate::config::PatternPermissions::Mode(mode) => {
1499            vm_config::PatternPermissionScope::Mode(serialize_permission_mode_config(*mode))
1500        }
1501        crate::config::PatternPermissions::Rules(rules) => {
1502            vm_config::PatternPermissionScope::Rules(vm_config::PatternPermissionRuleSet {
1503                default: rules.default.map(serialize_permission_mode_config),
1504                rules: rules
1505                    .rules
1506                    .iter()
1507                    .map(|rule| vm_config::PatternPermissionRule {
1508                        mode: serialize_permission_mode_config(rule.mode),
1509                        operations: operation_wildcard_if_omitted(&rule.operations),
1510                        patterns: resource_wildcard_if_omitted(&rule.patterns),
1511                    })
1512                    .collect(),
1513            })
1514        }
1515    }
1516}
1517
1518fn serialize_permission_mode_config(
1519    mode: crate::config::PermissionMode,
1520) -> vm_config::PermissionMode {
1521    match mode {
1522        crate::config::PermissionMode::Allow => vm_config::PermissionMode::Allow,
1523        crate::config::PermissionMode::Deny => vm_config::PermissionMode::Deny,
1524    }
1525}
1526
1527/// Await the `ready` VM lifecycle event for `vm_id`, bounded by `timeout_ms`.
1528async fn wait_for_vm_ready(
1529    events: &mut broadcast::Receiver<(wire::OwnershipScope, wire::EventPayload)>,
1530    vm_id: &str,
1531    timeout_ms: u64,
1532) -> Result<(), ClientError> {
1533    let wait = async {
1534        loop {
1535            match events.recv().await {
1536                Ok((ownership, payload)) => match payload {
1537                    wire::EventPayload::VmLifecycleEvent(event) => {
1538                        if matches!(event.state, wire::VmLifecycleState::Ready)
1539                            && wire_ownership_vm_id(&ownership) == Some(vm_id)
1540                        {
1541                            return Ok(());
1542                        }
1543                    }
1544                    wire::EventPayload::ProcessOutputEvent(_)
1545                    | wire::EventPayload::ProcessExitedEvent(_)
1546                    | wire::EventPayload::ExecutionOutputEvent(_)
1547                    | wire::EventPayload::ExecutionCompletedEvent(_)
1548                    | wire::EventPayload::StructuredEvent(_)
1549                    | wire::EventPayload::ExtEnvelope(_) => {}
1550                },
1551                Err(broadcast::error::RecvError::Lagged(_)) => {}
1552                Err(broadcast::error::RecvError::Closed) => {
1553                    return Err(ClientError::SidecarExited(
1554                        "the sidecar exited before the VM became ready".to_string(),
1555                    ));
1556                }
1557            }
1558        }
1559    };
1560    tokio::time::timeout(Duration::from_millis(timeout_ms), wait)
1561        .await
1562        .map_err(|_| {
1563            ClientError::Sidecar("timed out waiting for the VM to become ready".to_string())
1564        })?
1565}
1566
1567/// Process-global per-VM host_function registry. The shared transport's single host callback routes to
1568/// the right VM's host_functions by frame ownership.
1569static VM_HOST_FUNCTIONS: OnceCell<SccHashMap<String, Arc<VmHostFunctionRegistry>>> =
1570    OnceCell::new();
1571
1572#[derive(Clone)]
1573struct VmHostFunctionRegistry {
1574    host_functions: Vec<ResolvedHostFunctions>,
1575    host_function_map: HashMap<String, ResolvedHostFunction>,
1576}
1577
1578fn vm_host_functions() -> &'static SccHashMap<String, Arc<VmHostFunctionRegistry>> {
1579    VM_HOST_FUNCTIONS.get_or_init(SccHashMap::new)
1580}
1581
1582/// Process-global map of VM id to client state used by trusted host functions
1583/// that read JSON arguments from the guest filesystem. `Weak` prevents the
1584/// registry from extending VM lifetime.
1585static VM_CLIENTS: OnceCell<SccHashMap<String, Weak<AgentOsInner>>> = OnceCell::new();
1586
1587fn vm_clients() -> &'static SccHashMap<String, Weak<AgentOsInner>> {
1588    VM_CLIENTS.get_or_init(SccHashMap::new)
1589}
1590
1591/// Process-global map of sidecar session -> Rust-host js_bridge callback.
1592///
1593/// Native root plugins can issue callbacks while `CreateVm` is still in flight, before the client
1594/// knows the generated VM id. Session ownership is already known by then and stays stable for the VM.
1595static SESSION_JS_BRIDGE_CALLBACKS: OnceCell<SccHashMap<String, SidecarJsBridgeCallback>> =
1596    OnceCell::new();
1597
1598fn session_js_bridge_callbacks() -> &'static SccHashMap<String, SidecarJsBridgeCallback> {
1599    SESSION_JS_BRIDGE_CALLBACKS.get_or_init(SccHashMap::new)
1600}
1601
1602/// Process-global map of sidecar session to the trusted VM SQLite callback.
1603/// Registration happens before CreateVm because filesystem/Core migrations run
1604/// while the VM is being constructed.
1605static SESSION_SQLITE_CALLBACKS: OnceCell<SccHashMap<String, SidecarSqliteCallback>> =
1606    OnceCell::new();
1607
1608fn session_sqlite_callbacks() -> &'static SccHashMap<String, SidecarSqliteCallback> {
1609    SESSION_SQLITE_CALLBACKS.get_or_init(SccHashMap::new)
1610}
1611
1612// Sidecar processes number connections, sessions, and VMs independently, so the
1613// process-global route maps key on the transport as well as the sidecar's ids.
1614fn vm_route_key(transport_id: u64, vm_id: &str) -> String {
1615    format!("{transport_id}\0{vm_id}")
1616}
1617
1618fn sidecar_session_key(transport_id: u64, connection_id: &str, session_id: &str) -> String {
1619    format!("{transport_id}\0{connection_id}\0{session_id}")
1620}
1621
1622fn wire_ownership_session_key(
1623    transport_id: u64,
1624    ownership: &wire::OwnershipScope,
1625) -> Option<String> {
1626    match ownership {
1627        wire::OwnershipScope::SessionOwnership(ownership) => Some(sidecar_session_key(
1628            transport_id,
1629            &ownership.connection_id,
1630            &ownership.session_id,
1631        )),
1632        wire::OwnershipScope::VmOwnership(ownership) => Some(sidecar_session_key(
1633            transport_id,
1634            &ownership.connection_id,
1635            &ownership.session_id,
1636        )),
1637        wire::OwnershipScope::ConnectionOwnership(_) => None,
1638    }
1639}
1640
1641fn js_bridge_call_callback(transport_id: u64) -> WireSidecarCallback {
1642    Arc::new(move |payload, ownership| {
1643        Box::pin(async move {
1644            let request = match payload {
1645                wire::SidecarRequestPayload::JsBridgeCallRequest(request) => request,
1646                wire::SidecarRequestPayload::HostCallbackRequest(_) => {
1647                    return Ok(wire::SidecarResponsePayload::JsBridgeResultResponse(
1648                        wire::JsBridgeResultResponse {
1649                            call_id: "unknown".to_string(),
1650                            result: None,
1651                            error: Some(
1652                                "js-bridge callback received a host callback request".to_string(),
1653                            ),
1654                        },
1655                    ));
1656                }
1657                wire::SidecarRequestPayload::ExtEnvelope(_) => {
1658                    return Ok(wire::SidecarResponsePayload::JsBridgeResultResponse(
1659                        wire::JsBridgeResultResponse {
1660                            call_id: "unknown".to_string(),
1661                            result: None,
1662                            error: Some(
1663                                "js-bridge callback received an extension request".to_string(),
1664                            ),
1665                        },
1666                    ));
1667                }
1668            };
1669            Ok(wire::SidecarResponsePayload::JsBridgeResultResponse(
1670                run_js_bridge_callback(transport_id, &ownership, request).await,
1671            ))
1672        })
1673    })
1674}
1675
1676fn sqlite_callback_callback(transport_id: u64) -> WireSidecarCallback {
1677    Arc::new(move |payload, ownership| {
1678        Box::pin(async move {
1679            let envelope = match payload {
1680                wire::SidecarRequestPayload::ExtEnvelope(envelope) => envelope,
1681                _ => {
1682                    return Err(TransportError::Sidecar(String::from(
1683                        "SQLite callback received a non-extension request",
1684                    )))
1685                }
1686            };
1687            if envelope.namespace != vm_config::VM_SQLITE_CALLBACK_NAMESPACE {
1688                return Ok(wire::SidecarResponsePayload::ExtEnvelope(
1689                    wire::ExtEnvelope {
1690                        namespace: envelope.namespace,
1691                        payload: serde_json::to_vec(&vm_config::VmSqliteCallbackResponse::Error {
1692                            message: String::from("unsupported sidecar extension namespace"),
1693                        })
1694                        .expect("serialize SQLite extension error"),
1695                    },
1696                ));
1697            }
1698            let response = match serde_json::from_slice::<vm_config::VmSqliteCallbackRequest>(
1699                &envelope.payload,
1700            ) {
1701                Ok(request) => {
1702                    let callback =
1703                        wire_ownership_session_key(transport_id, &ownership).and_then(|key| {
1704                            session_sqlite_callbacks().read(&key, |_, callback| callback.clone())
1705                        });
1706                    match callback {
1707                        Some(callback) => match callback(request).await {
1708                            Ok(response) => response,
1709                            Err(message) => vm_config::VmSqliteCallbackResponse::Error { message },
1710                        },
1711                        None => vm_config::VmSqliteCallbackResponse::Error {
1712                            message: String::from(
1713                                "no SQLite callback registered for sidecar session",
1714                            ),
1715                        },
1716                    }
1717                }
1718                Err(error) => vm_config::VmSqliteCallbackResponse::Error {
1719                    message: format!("invalid SQLite callback request: {error}"),
1720                },
1721            };
1722            Ok(wire::SidecarResponsePayload::ExtEnvelope(
1723                wire::ExtEnvelope {
1724                    namespace: vm_config::VM_SQLITE_CALLBACK_NAMESPACE.to_owned(),
1725                    payload: serde_json::to_vec(&response).map_err(|error| {
1726                        TransportError::Sidecar(format!(
1727                            "serialize SQLite callback response: {error}"
1728                        ))
1729                    })?,
1730                },
1731            ))
1732        })
1733    })
1734}
1735
1736async fn run_js_bridge_callback(
1737    transport_id: u64,
1738    ownership: &wire::OwnershipScope,
1739    request: wire::JsBridgeCallRequest,
1740) -> wire::JsBridgeResultResponse {
1741    let call_id = request.call_id;
1742    let args = match serde_json::from_str::<Value>(&request.args) {
1743        Ok(args) => args,
1744        Err(error) => {
1745            return wire::JsBridgeResultResponse {
1746                call_id,
1747                result: None,
1748                error: Some(format!("Invalid js_bridge args: {error}")),
1749            };
1750        }
1751    };
1752    let callback = wire_ownership_session_key(transport_id, ownership)
1753        .and_then(|key| session_js_bridge_callbacks().read(&key, |_, callback| callback.clone()));
1754    let Some(callback) = callback else {
1755        return wire::JsBridgeResultResponse {
1756            call_id,
1757            result: None,
1758            error: Some("No js_bridge callback registered for sidecar session".to_string()),
1759        };
1760    };
1761
1762    let call = SidecarJsBridgeCall {
1763        call_id: call_id.clone(),
1764        mount_id: request.mount_id,
1765        operation: request.operation,
1766        args,
1767    };
1768    match callback(call).await {
1769        Ok(result) => match result {
1770            Some(value) => match serde_json::to_string(&value) {
1771                Ok(result) => wire::JsBridgeResultResponse {
1772                    call_id,
1773                    result: Some(result),
1774                    error: None,
1775                },
1776                Err(error) => wire::JsBridgeResultResponse {
1777                    call_id,
1778                    result: None,
1779                    error: Some(format!("Invalid js_bridge result: {error}")),
1780                },
1781            },
1782            None => wire::JsBridgeResultResponse {
1783                call_id,
1784                result: None,
1785                error: None,
1786            },
1787        },
1788        Err(error) => wire::JsBridgeResultResponse {
1789            call_id,
1790            result: None,
1791            error: Some(error),
1792        },
1793    }
1794}
1795
1796/// The transport callback that answers guest host-function invocations by running the matching host function.
1797fn host_callback_callback(transport_id: u64) -> WireSidecarCallback {
1798    Arc::new(move |payload, ownership| {
1799        Box::pin(async move {
1800            let request = match payload {
1801                wire::SidecarRequestPayload::HostCallbackRequest(request) => request,
1802                wire::SidecarRequestPayload::JsBridgeCallRequest(_) => {
1803                    return Ok(wire::SidecarResponsePayload::HostCallbackResultResponse(
1804                        wire::HostCallbackResultResponse {
1805                            invocation_id: "unknown".to_string(),
1806                            result: None,
1807                            error: Some(
1808                                "host callback received a non-host-function request".to_string(),
1809                            ),
1810                        },
1811                    ));
1812                }
1813                wire::SidecarRequestPayload::ExtEnvelope(envelope) => {
1814                    return Ok(wire::SidecarResponsePayload::ExtEnvelope(
1815                        wire::ExtEnvelope {
1816                            namespace: envelope.namespace,
1817                            payload: b"host-callback received an extension request".to_vec(),
1818                        },
1819                    ));
1820                }
1821            };
1822            Ok(wire::SidecarResponsePayload::HostCallbackResultResponse(
1823                run_host_callback(
1824                    &vm_route_key(transport_id, wire_ownership_vm_id(&ownership).unwrap_or("")),
1825                    request,
1826                )
1827                .await,
1828            ))
1829        })
1830    })
1831}
1832
1833/// Run one host-function invocation against the per-VM host-function registry, honoring the timeout. Mirrors
1834/// TS `handleHostCallback` (unknown function, timeout, and error shapes).
1835async fn run_host_callback(
1836    vm_route: &str,
1837    request: wire::HostCallbackRequest,
1838) -> wire::HostCallbackResultResponse {
1839    let input = match serde_json::from_str::<Value>(&request.input) {
1840        Ok(input) => input,
1841        Err(error) => {
1842            return wire::HostCallbackResultResponse {
1843                invocation_id: request.invocation_id,
1844                result: None,
1845                error: Some(format!("Invalid host callback input: {error}")),
1846            };
1847        }
1848    };
1849    let registry = vm_host_functions().read(vm_route, |_, registry| registry.clone());
1850    let Some(registry) = registry else {
1851        return wire::HostCallbackResultResponse {
1852            invocation_id: request.invocation_id,
1853            result: None,
1854            error: Some(format!(
1855                "Unknown host function \"{}\"",
1856                request.callback_key
1857            )),
1858        };
1859    };
1860
1861    if request.callback_key == "agentos" {
1862        let Some(command) = parse_host_command_callback_input(&input) else {
1863            return wire::HostCallbackResultResponse {
1864                invocation_id: request.invocation_id,
1865                result: None,
1866                error: Some(String::from("Invalid registry callback for \"agentos\"")),
1867            };
1868        };
1869        if command.command != request.callback_key {
1870            return wire::HostCallbackResultResponse {
1871                invocation_id: request.invocation_id,
1872                result: None,
1873                error: Some(String::from("Invalid registry callback for \"agentos\"")),
1874            };
1875        }
1876        return match run_host_command_callback(vm_route, registry.as_ref(), command).await {
1877            Ok(value) => match host_callback_json_result(value) {
1878                Ok(result) => wire::HostCallbackResultResponse {
1879                    invocation_id: request.invocation_id,
1880                    result: Some(result),
1881                    error: None,
1882                },
1883                Err(error) => wire::HostCallbackResultResponse {
1884                    invocation_id: request.invocation_id,
1885                    result: None,
1886                    error: Some(error),
1887                },
1888            },
1889            Err(error) => wire::HostCallbackResultResponse {
1890                invocation_id: request.invocation_id,
1891                result: None,
1892                error: Some(error),
1893            },
1894        };
1895    }
1896
1897    let host_function = registry
1898        .host_function_map
1899        .get(&request.callback_key)
1900        .cloned();
1901    let Some(host_function) = host_function else {
1902        return wire::HostCallbackResultResponse {
1903            invocation_id: request.invocation_id,
1904            result: None,
1905            error: Some(format!(
1906                "Unknown host function \"{}\"",
1907                request.callback_key
1908            )),
1909        };
1910    };
1911    let timeout = Duration::from_millis(request.timeout_ms.max(1));
1912    match tokio::time::timeout(timeout, (host_function.execute)(input)).await {
1913        Ok(Ok(value)) => match host_callback_json_result(value) {
1914            Ok(result) => wire::HostCallbackResultResponse {
1915                invocation_id: request.invocation_id,
1916                result: Some(result),
1917                error: None,
1918            },
1919            Err(error) => wire::HostCallbackResultResponse {
1920                invocation_id: request.invocation_id,
1921                result: None,
1922                error: Some(error),
1923            },
1924        },
1925        Ok(Err(error)) => wire::HostCallbackResultResponse {
1926            invocation_id: request.invocation_id,
1927            result: None,
1928            error: Some(error),
1929        },
1930        Err(_) => wire::HostCallbackResultResponse {
1931            invocation_id: request.invocation_id,
1932            result: None,
1933            error: Some(format!(
1934                "Host function \"{}\" timed out after {}ms",
1935                request.callback_key, request.timeout_ms
1936            )),
1937        },
1938    }
1939}
1940
1941#[derive(Debug, Deserialize)]
1942struct HostCommandCallbackInput {
1943    #[serde(rename = "type")]
1944    kind: String,
1945    command: String,
1946    #[serde(default)]
1947    args: Vec<String>,
1948    cwd: String,
1949}
1950
1951fn parse_host_command_callback_input(input: &Value) -> Option<HostCommandCallbackInput> {
1952    let command = serde_json::from_value::<HostCommandCallbackInput>(input.clone()).ok()?;
1953    if command.kind == "command" {
1954        Some(command)
1955    } else {
1956        None
1957    }
1958}
1959
1960async fn run_host_command_callback(
1961    vm_route: &str,
1962    registry: &VmHostFunctionRegistry,
1963    command: HostCommandCallbackInput,
1964) -> Result<Value, String> {
1965    if command.command == "agentos" {
1966        return handle_agentos_registry_command(vm_route, registry, &command).await;
1967    }
1968    let Some(collection) = registry
1969        .host_functions
1970        .iter()
1971        .find(|collection| format!("agentos-{}", collection.name) == command.command)
1972    else {
1973        return Err(format!(
1974            "Unknown host callback command \"{}\"",
1975            command.command
1976        ));
1977    };
1978    handle_agentos_host_function_command(vm_route, registry, &command, collection).await
1979}
1980
1981async fn handle_agentos_registry_command(
1982    vm_route: &str,
1983    registry: &VmHostFunctionRegistry,
1984    command: &HostCommandCallbackInput,
1985) -> Result<Value, String> {
1986    let Some(subcommand) = command.args.first() else {
1987        return Ok(json_object([(
1988            "usage",
1989            Value::String(String::from(
1990                "agentos <command>: list-host-functions [collection], <collection> --help, or <collection> <function> ...",
1991            )),
1992        )]));
1993    };
1994    if is_help_flag(subcommand) {
1995        return Ok(json_object([(
1996            "usage",
1997            Value::String(String::from(
1998                "agentos <command>: list-host-functions [collection], <collection> --help, or <collection> <function> ...",
1999            )),
2000        )]));
2001    }
2002    if subcommand == "list-host-functions" || subcommand == "list-bindings" {
2003        return match command.args.get(1) {
2004            Some(collection_name) => {
2005                describe_host_functions_payload(&registry.host_functions, collection_name)
2006            }
2007            None => Ok(list_host_functions_payload(&registry.host_functions)),
2008        };
2009    }
2010
2011    let Some(collection) = registry
2012        .host_functions
2013        .iter()
2014        .find(|collection| collection.name == *subcommand)
2015    else {
2016        return Err(format!(
2017            "No host function collection \"{subcommand}\". Available: {}",
2018            host_functions_names(&registry.host_functions)
2019        ));
2020    };
2021
2022    let Some(host_function_name) = command.args.get(1) else {
2023        return describe_host_functions_payload(&registry.host_functions, subcommand);
2024    };
2025    if is_help_flag(host_function_name) {
2026        return describe_host_functions_payload(&registry.host_functions, subcommand);
2027    }
2028    if command.args.get(2).is_some_and(|value| is_help_flag(value)) {
2029        return describe_host_function_payload(collection, host_function_name);
2030    }
2031    invoke_host_function(
2032        vm_route,
2033        registry,
2034        collection,
2035        host_function_name,
2036        command.args.get(2..).unwrap_or_default(),
2037        &command.cwd,
2038    )
2039    .await
2040}
2041
2042async fn handle_agentos_host_function_command(
2043    vm_route: &str,
2044    registry: &VmHostFunctionRegistry,
2045    command: &HostCommandCallbackInput,
2046    collection: &ResolvedHostFunctions,
2047) -> Result<Value, String> {
2048    let Some(host_function_name) = command.args.first() else {
2049        return describe_host_functions_payload(&registry.host_functions, &collection.name);
2050    };
2051    if is_help_flag(host_function_name) {
2052        return describe_host_functions_payload(&registry.host_functions, &collection.name);
2053    }
2054    if command.args.get(1).is_some_and(|value| is_help_flag(value)) {
2055        return describe_host_function_payload(collection, host_function_name);
2056    }
2057    invoke_host_function(
2058        vm_route,
2059        registry,
2060        collection,
2061        host_function_name,
2062        command.args.get(1..).unwrap_or_default(),
2063        &command.cwd,
2064    )
2065    .await
2066}
2067
2068async fn invoke_host_function(
2069    vm_route: &str,
2070    registry: &VmHostFunctionRegistry,
2071    collection: &ResolvedHostFunctions,
2072    host_function_name: &str,
2073    args: &[String],
2074    cwd: &str,
2075) -> Result<Value, String> {
2076    let callback_key = format!("{}:{host_function_name}", collection.name);
2077    let Some(host_function) = registry.host_function_map.get(&callback_key).cloned() else {
2078        return Err(format!(
2079            "No host function \"{host_function_name}\" in collection \"{}\". Available: {}",
2080            collection.name,
2081            host_function_names(collection)
2082        ));
2083    };
2084
2085    // The sidecar checks the `hostFunction` permission scope before it forwards a
2086    // host function call here, for both the collection command and the registry command.
2087
2088    let input = parse_host_function_input(vm_route, &host_function, args, cwd).await?;
2089    validate_host_function_input(&host_function.input_schema, &input)
2090        .map_err(|error| error.to_string())?;
2091
2092    let timeout = Duration::from_millis(host_function.timeout_ms.unwrap_or(30_000).max(1));
2093    match tokio::time::timeout(timeout, (host_function.execute)(input)).await {
2094        Ok(Ok(value)) => Ok(value),
2095        Ok(Err(error)) => Err(error),
2096        Err(_) => Err(format!(
2097            "Host function \"{callback_key}\" timed out after {}ms",
2098            host_function.timeout_ms.unwrap_or(30_000)
2099        )),
2100    }
2101}
2102
2103async fn parse_host_function_input(
2104    vm_route: &str,
2105    host_function: &ResolvedHostFunction,
2106    args: &[String],
2107    cwd: &str,
2108) -> Result<Value, String> {
2109    if args.first().is_some_and(|arg| arg == "--json") {
2110        let value = args
2111            .get(1)
2112            .ok_or_else(|| String::from("Flag --json requires a value"))?;
2113        return serde_json::from_str(value)
2114            .map_err(|error| format!("Invalid JSON for --json: {error}"));
2115    }
2116
2117    if args.first().is_some_and(|arg| arg == "--json-file") {
2118        let path = args
2119            .get(1)
2120            .ok_or_else(|| String::from("Flag --json-file requires a value"))?;
2121        let guest_path = normalize_guest_path(if path.starts_with('/') {
2122            path.clone()
2123        } else {
2124            format!("{cwd}/{path}")
2125        });
2126        let inner = vm_clients()
2127            .read(vm_route, |_, weak| weak.clone())
2128            .and_then(|weak| weak.upgrade())
2129            .ok_or_else(|| String::from("Invalid JSON file: VM is no longer available"))?;
2130        let bytes = AgentOs { inner }
2131            .read_file(&guest_path)
2132            .await
2133            .map_err(|error| format!("Invalid JSON file: {error}"))?;
2134        let text =
2135            String::from_utf8(bytes).map_err(|error| format!("Invalid JSON file: {error}"))?;
2136        return serde_json::from_str(&text).map_err(|error| format!("Invalid JSON file: {error}"));
2137    }
2138
2139    parse_host_function_argv(&host_function.input_schema, args)
2140}
2141
2142fn host_callback_json_result(value: Value) -> Result<String, String> {
2143    serde_json::to_string(&value).map_err(|error| format!("Invalid host callback result: {error}"))
2144}
2145
2146fn parse_host_function_argv(schema: &Value, argv: &[String]) -> Result<Value, String> {
2147    let properties = schema
2148        .get("properties")
2149        .and_then(Value::as_object)
2150        .cloned()
2151        .unwrap_or_default();
2152    let required = schema
2153        .get("required")
2154        .and_then(Value::as_array)
2155        .map(|items| {
2156            items
2157                .iter()
2158                .filter_map(Value::as_str)
2159                .map(str::to_owned)
2160                .collect::<std::collections::BTreeSet<_>>()
2161        })
2162        .unwrap_or_default();
2163
2164    let mut flag_to_field = BTreeMap::new();
2165    for (field_name, field_schema) in &properties {
2166        flag_to_field.insert(
2167            camel_to_kebab(field_name),
2168            (field_name.clone(), field_schema.clone()),
2169        );
2170    }
2171
2172    let mut input = Map::new();
2173    let mut index = 0;
2174    while index < argv.len() {
2175        let arg = &argv[index];
2176        if !arg.starts_with("--") {
2177            return Err(format!("Unexpected positional argument: \"{arg}\""));
2178        }
2179
2180        let raw_flag = &arg[2..];
2181        let (flag_name, negated) = raw_flag
2182            .strip_prefix("no-")
2183            .map(|name| (name, true))
2184            .unwrap_or((raw_flag, false));
2185        let Some((field_name, field_schema)) = flag_to_field.get(flag_name) else {
2186            return Err(format!("Unknown flag: --{raw_flag}"));
2187        };
2188        let field_type = json_schema_type(field_schema);
2189
2190        if negated {
2191            if field_type != Some("boolean") {
2192                return Err(format!("Unknown flag: --{raw_flag}"));
2193            }
2194            input.insert(field_name.clone(), Value::Bool(false));
2195            index += 1;
2196            continue;
2197        }
2198
2199        match field_type {
2200            Some("boolean") => {
2201                input.insert(field_name.clone(), Value::Bool(true));
2202                index += 1;
2203            }
2204            Some("number") | Some("integer") => {
2205                let value = argv
2206                    .get(index + 1)
2207                    .ok_or_else(|| format!("Flag --{raw_flag} requires a value"))?;
2208                let number = value
2209                    .parse::<f64>()
2210                    .map_err(|_| format!("Flag --{raw_flag} expects a number, got \"{value}\""))?;
2211                let number = serde_json::Number::from_f64(number).ok_or_else(|| {
2212                    format!("Flag --{raw_flag} expects a finite number, got \"{value}\"")
2213                })?;
2214                input.insert(field_name.clone(), Value::Number(number));
2215                index += 2;
2216            }
2217            Some("array") => {
2218                let value = argv
2219                    .get(index + 1)
2220                    .ok_or_else(|| format!("Flag --{raw_flag} requires a value"))?;
2221                let item_type = field_schema.get("items").and_then(json_schema_type);
2222                let parsed_value = match item_type {
2223                    Some("number") | Some("integer") => {
2224                        let number = value.parse::<f64>().map_err(|_| {
2225                            format!("Flag --{raw_flag} expects a number value, got \"{value}\"")
2226                        })?;
2227                        let number = serde_json::Number::from_f64(number).ok_or_else(|| {
2228                            format!(
2229                                "Flag --{raw_flag} expects a finite number value, got \"{value}\""
2230                            )
2231                        })?;
2232                        Value::Number(number)
2233                    }
2234                    Some("boolean") => {
2235                        let boolean = value.parse::<bool>().map_err(|_| {
2236                            format!("Flag --{raw_flag} expects a boolean value, got \"{value}\"")
2237                        })?;
2238                        Value::Bool(boolean)
2239                    }
2240                    _ => Value::String(value.clone()),
2241                };
2242                input
2243                    .entry(field_name.clone())
2244                    .or_insert_with(|| Value::Array(Vec::new()))
2245                    .as_array_mut()
2246                    .expect("array field should always contain an array")
2247                    .push(parsed_value);
2248                index += 2;
2249            }
2250            _ => {
2251                let value = argv
2252                    .get(index + 1)
2253                    .ok_or_else(|| format!("Flag --{raw_flag} requires a value"))?;
2254                input.insert(field_name.clone(), Value::String(value.clone()));
2255                index += 2;
2256            }
2257        }
2258    }
2259
2260    for field_name in required {
2261        if !input.contains_key(&field_name) {
2262            return Err(format!(
2263                "Missing required flag: --{}",
2264                camel_to_kebab(&field_name)
2265            ));
2266        }
2267    }
2268
2269    Ok(Value::Object(input))
2270}
2271
2272#[derive(Debug, Clone, PartialEq, Eq)]
2273struct HostFunctionInputSchemaViolation {
2274    path: String,
2275    expected: String,
2276    actual: String,
2277}
2278
2279impl HostFunctionInputSchemaViolation {
2280    fn new(
2281        path: impl Into<String>,
2282        expected: impl Into<String>,
2283        actual: impl Into<String>,
2284    ) -> Self {
2285        Self {
2286            path: path.into(),
2287            expected: expected.into(),
2288            actual: actual.into(),
2289        }
2290    }
2291}
2292
2293impl std::fmt::Display for HostFunctionInputSchemaViolation {
2294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2295        write!(
2296            f,
2297            "HostFunctionInputSchemaViolation at {}: expected {}, got {}",
2298            self.path, self.expected, self.actual
2299        )
2300    }
2301}
2302
2303fn validate_host_function_input(
2304    schema: &Value,
2305    input: &Value,
2306) -> Result<(), HostFunctionInputSchemaViolation> {
2307    validate_host_function_input_at_path(schema, input, "$")
2308}
2309
2310fn validate_host_function_input_at_path(
2311    schema: &Value,
2312    input: &Value,
2313    path: &str,
2314) -> Result<(), HostFunctionInputSchemaViolation> {
2315    if schema.is_null() || schema.as_object().is_some_and(|object| object.is_empty()) {
2316        return Ok(());
2317    }
2318    if let Some(branches) = schema.get("anyOf").and_then(Value::as_array) {
2319        return validate_schema_branches(branches, input, path, "anyOf");
2320    }
2321    if let Some(branches) = schema.get("oneOf").and_then(Value::as_array) {
2322        return validate_schema_branches(branches, input, path, "oneOf");
2323    }
2324    if let Some(enum_values) = schema.get("enum").and_then(Value::as_array) {
2325        if enum_values.iter().any(|candidate| candidate == input) {
2326            return Ok(());
2327        }
2328        return Err(HostFunctionInputSchemaViolation::new(
2329            path,
2330            format!(
2331                "one of {}",
2332                enum_values
2333                    .iter()
2334                    .map(compact_json)
2335                    .collect::<Vec<_>>()
2336                    .join(", ")
2337            ),
2338            describe_value(input),
2339        ));
2340    }
2341    if let Some(expected) = schema.get("const") {
2342        if expected == input {
2343            return Ok(());
2344        }
2345        return Err(HostFunctionInputSchemaViolation::new(
2346            path,
2347            format!("constant {}", compact_json(expected)),
2348            describe_value(input),
2349        ));
2350    }
2351
2352    match schema.get("type") {
2353        Some(Value::String(expected_type)) => {
2354            validate_typed_host_function_input(schema, input, path, expected_type)
2355        }
2356        Some(Value::Array(expected_types)) => {
2357            let mut first_error = None;
2358            for expected_type in expected_types.iter().filter_map(Value::as_str) {
2359                match validate_typed_host_function_input(schema, input, path, expected_type) {
2360                    Ok(()) => return Ok(()),
2361                    Err(error) if first_error.is_none() => first_error = Some(error),
2362                    Err(_) => {}
2363                }
2364            }
2365            Err(first_error.unwrap_or_else(|| {
2366                HostFunctionInputSchemaViolation::new(
2367                    path,
2368                    describe_expected(schema),
2369                    describe_value(input),
2370                )
2371            }))
2372        }
2373        Some(_) => Ok(()),
2374        None if has_object_keywords(schema) => {
2375            validate_typed_host_function_input(schema, input, path, "object")
2376        }
2377        None => Ok(()),
2378    }
2379}
2380
2381fn validate_schema_branches(
2382    branches: &[Value],
2383    input: &Value,
2384    path: &str,
2385    keyword: &str,
2386) -> Result<(), HostFunctionInputSchemaViolation> {
2387    let mut first_error = None;
2388    for branch in branches {
2389        match validate_host_function_input_at_path(branch, input, path) {
2390            Ok(()) => return Ok(()),
2391            Err(error) if first_error.is_none() => first_error = Some(error),
2392            Err(_) => {}
2393        }
2394    }
2395    Err(first_error.unwrap_or_else(|| {
2396        HostFunctionInputSchemaViolation::new(
2397            path,
2398            format!(
2399                "{keyword} branch ({})",
2400                branches
2401                    .iter()
2402                    .map(describe_expected)
2403                    .collect::<Vec<_>>()
2404                    .join(" | ")
2405            ),
2406            describe_value(input),
2407        )
2408    }))
2409}
2410
2411fn validate_typed_host_function_input(
2412    schema: &Value,
2413    input: &Value,
2414    path: &str,
2415    expected_type: &str,
2416) -> Result<(), HostFunctionInputSchemaViolation> {
2417    match expected_type {
2418        "null" if input.is_null() => Ok(()),
2419        "null" => Err(type_violation(path, expected_type, input)),
2420        "boolean" if input.is_boolean() => Ok(()),
2421        "boolean" => Err(type_violation(path, expected_type, input)),
2422        "string" => validate_string_host_function_input(schema, input, path),
2423        "number" => validate_number_host_function_input(schema, input, path, false),
2424        "integer" => validate_number_host_function_input(schema, input, path, true),
2425        "array" => validate_array_host_function_input(schema, input, path),
2426        "object" => validate_object_host_function_input(schema, input, path),
2427        _ => Ok(()),
2428    }
2429}
2430
2431fn validate_string_host_function_input(
2432    schema: &Value,
2433    input: &Value,
2434    path: &str,
2435) -> Result<(), HostFunctionInputSchemaViolation> {
2436    let Some(value) = input.as_str() else {
2437        return Err(type_violation(path, "string", input));
2438    };
2439    if let Some(min_length) = schema.get("minLength").and_then(Value::as_u64) {
2440        if value.chars().count() < min_length as usize {
2441            return Err(HostFunctionInputSchemaViolation::new(
2442                path,
2443                format!("string with minLength {min_length}"),
2444                format!("string length {}", value.chars().count()),
2445            ));
2446        }
2447    }
2448    if let Some(max_length) = schema.get("maxLength").and_then(Value::as_u64) {
2449        if value.chars().count() > max_length as usize {
2450            return Err(HostFunctionInputSchemaViolation::new(
2451                path,
2452                format!("string with maxLength {max_length}"),
2453                format!("string length {}", value.chars().count()),
2454            ));
2455        }
2456    }
2457    Ok(())
2458}
2459
2460fn validate_number_host_function_input(
2461    schema: &Value,
2462    input: &Value,
2463    path: &str,
2464    expect_integer: bool,
2465) -> Result<(), HostFunctionInputSchemaViolation> {
2466    let Some(number) = input.as_f64() else {
2467        return Err(type_violation(
2468            path,
2469            if expect_integer { "integer" } else { "number" },
2470            input,
2471        ));
2472    };
2473    if expect_integer && number.fract() != 0.0 {
2474        return Err(type_violation(path, "integer", input));
2475    }
2476    if let Some(minimum) = schema.get("minimum").and_then(Value::as_f64) {
2477        if number < minimum {
2478            return Err(HostFunctionInputSchemaViolation::new(
2479                path,
2480                format!(
2481                    "{} >= {}",
2482                    if expect_integer { "integer" } else { "number" },
2483                    minimum
2484                ),
2485                compact_json(input),
2486            ));
2487        }
2488    }
2489    if let Some(minimum) = schema.get("exclusiveMinimum").and_then(Value::as_f64) {
2490        if number <= minimum {
2491            return Err(HostFunctionInputSchemaViolation::new(
2492                path,
2493                format!(
2494                    "{} > {}",
2495                    if expect_integer { "integer" } else { "number" },
2496                    minimum
2497                ),
2498                compact_json(input),
2499            ));
2500        }
2501    }
2502    if let Some(maximum) = schema.get("maximum").and_then(Value::as_f64) {
2503        if number > maximum {
2504            return Err(HostFunctionInputSchemaViolation::new(
2505                path,
2506                format!(
2507                    "{} <= {}",
2508                    if expect_integer { "integer" } else { "number" },
2509                    maximum
2510                ),
2511                compact_json(input),
2512            ));
2513        }
2514    }
2515    if let Some(maximum) = schema.get("exclusiveMaximum").and_then(Value::as_f64) {
2516        if number >= maximum {
2517            return Err(HostFunctionInputSchemaViolation::new(
2518                path,
2519                format!(
2520                    "{} < {}",
2521                    if expect_integer { "integer" } else { "number" },
2522                    maximum
2523                ),
2524                compact_json(input),
2525            ));
2526        }
2527    }
2528    Ok(())
2529}
2530
2531fn validate_array_host_function_input(
2532    schema: &Value,
2533    input: &Value,
2534    path: &str,
2535) -> Result<(), HostFunctionInputSchemaViolation> {
2536    let Some(items) = input.as_array() else {
2537        return Err(type_violation(path, "array", input));
2538    };
2539    if let Some(min_items) = schema.get("minItems").and_then(Value::as_u64) {
2540        if items.len() < min_items as usize {
2541            return Err(HostFunctionInputSchemaViolation::new(
2542                path,
2543                format!("array with minItems {min_items}"),
2544                format!("array length {}", items.len()),
2545            ));
2546        }
2547    }
2548    if let Some(max_items) = schema.get("maxItems").and_then(Value::as_u64) {
2549        if items.len() > max_items as usize {
2550            return Err(HostFunctionInputSchemaViolation::new(
2551                path,
2552                format!("array with maxItems {max_items}"),
2553                format!("array length {}", items.len()),
2554            ));
2555        }
2556    }
2557    if let Some(item_schema) = schema.get("items") {
2558        for (index, item) in items.iter().enumerate() {
2559            validate_host_function_input_at_path(item_schema, item, &format!("{path}[{index}]"))?;
2560        }
2561    }
2562    Ok(())
2563}
2564
2565fn validate_object_host_function_input(
2566    schema: &Value,
2567    input: &Value,
2568    path: &str,
2569) -> Result<(), HostFunctionInputSchemaViolation> {
2570    let Some(object) = input.as_object() else {
2571        return Err(type_violation(path, "object", input));
2572    };
2573    let properties = schema
2574        .get("properties")
2575        .and_then(Value::as_object)
2576        .cloned()
2577        .unwrap_or_default();
2578    let required = schema
2579        .get("required")
2580        .and_then(Value::as_array)
2581        .cloned()
2582        .unwrap_or_default();
2583    for field in required.iter().filter_map(Value::as_str) {
2584        if !object.contains_key(field) {
2585            let field_path = format!("{path}.{field}");
2586            let expected = properties
2587                .get(field)
2588                .map(describe_expected)
2589                .unwrap_or_else(|| String::from("required value"));
2590            return Err(HostFunctionInputSchemaViolation::new(
2591                field_path,
2592                expected,
2593                "missing value",
2594            ));
2595        }
2596    }
2597    for (field, value) in object {
2598        let field_path = format!("{path}.{field}");
2599        if let Some(field_schema) = properties.get(field) {
2600            validate_host_function_input_at_path(field_schema, value, &field_path)?;
2601            continue;
2602        }
2603        match schema.get("additionalProperties") {
2604            Some(Value::Bool(false)) => {
2605                return Err(HostFunctionInputSchemaViolation::new(
2606                    field_path,
2607                    "no additional properties",
2608                    describe_value(value),
2609                ));
2610            }
2611            Some(additional_schema) => {
2612                validate_host_function_input_at_path(additional_schema, value, &field_path)?;
2613            }
2614            None => {}
2615        }
2616    }
2617    Ok(())
2618}
2619
2620fn has_object_keywords(schema: &Value) -> bool {
2621    schema.get("properties").is_some()
2622        || schema.get("required").is_some()
2623        || schema.get("additionalProperties").is_some()
2624}
2625
2626fn type_violation(path: &str, expected: &str, input: &Value) -> HostFunctionInputSchemaViolation {
2627    HostFunctionInputSchemaViolation::new(path, expected, describe_value(input))
2628}
2629
2630fn describe_expected(schema: &Value) -> String {
2631    if let Some(enum_values) = schema.get("enum").and_then(Value::as_array) {
2632        return format!(
2633            "one of {}",
2634            enum_values
2635                .iter()
2636                .map(compact_json)
2637                .collect::<Vec<_>>()
2638                .join(", ")
2639        );
2640    }
2641    if let Some(expected) = schema.get("const") {
2642        return format!("constant {}", compact_json(expected));
2643    }
2644    match schema.get("type") {
2645        Some(Value::String(expected_type)) => expected_type.clone(),
2646        Some(Value::Array(expected_types)) => expected_types
2647            .iter()
2648            .filter_map(Value::as_str)
2649            .collect::<Vec<_>>()
2650            .join(" | "),
2651        _ if has_object_keywords(schema) => String::from("object"),
2652        _ => String::from("value"),
2653    }
2654}
2655
2656fn describe_value(value: &Value) -> String {
2657    match value {
2658        Value::Null => String::from("null"),
2659        Value::Bool(_) => String::from("boolean"),
2660        Value::Number(number) => {
2661            let is_integer = number.as_i64().is_some()
2662                || number.as_u64().is_some()
2663                || number.as_f64().is_some_and(|float| float.fract() == 0.0);
2664            if is_integer {
2665                String::from("integer")
2666            } else {
2667                String::from("number")
2668            }
2669        }
2670        Value::String(_) => String::from("string"),
2671        Value::Array(_) => String::from("array"),
2672        Value::Object(_) => String::from("object"),
2673    }
2674}
2675
2676fn compact_json(value: &Value) -> String {
2677    serde_json::to_string(value).unwrap_or_else(|_| String::from("<invalid json>"))
2678}
2679
2680fn list_host_functions_payload(host_functions: &[ResolvedHostFunctions]) -> Value {
2681    Value::Object(Map::from_iter([(
2682        String::from("hostFunctions"),
2683        Value::Array(
2684            host_functions
2685                .iter()
2686                .map(|collection| {
2687                    json_object([
2688                        ("name", Value::String(collection.name.clone())),
2689                        (
2690                            "functions",
2691                            Value::Array(
2692                                collection
2693                                    .functions
2694                                    .iter()
2695                                    .map(|host_function| Value::String(host_function.name.clone()))
2696                                    .collect(),
2697                            ),
2698                        ),
2699                    ])
2700                })
2701                .collect(),
2702        ),
2703    )]))
2704}
2705
2706fn describe_host_functions_payload(
2707    host_functions: &[ResolvedHostFunctions],
2708    collection_name: &str,
2709) -> Result<Value, String> {
2710    let Some(collection) = host_functions
2711        .iter()
2712        .find(|collection| collection.name == collection_name)
2713    else {
2714        return Err(format!(
2715            "No host function collection \"{collection_name}\". Available: {}",
2716            host_functions_names(host_functions)
2717        ));
2718    };
2719    Ok(json_object([
2720        ("name", Value::String(collection.name.clone())),
2721        (
2722            "functions",
2723            Value::Object(Map::from_iter(collection.functions.iter().map(
2724                |host_function| {
2725                    (
2726                        host_function.name.clone(),
2727                        json_object([
2728                            (
2729                                "description",
2730                                Value::String(host_function.description.clone()),
2731                            ),
2732                            (
2733                                "flags",
2734                                Value::Array(describe_host_function_flags(
2735                                    &host_function.input_schema,
2736                                )),
2737                            ),
2738                        ]),
2739                    )
2740                },
2741            ))),
2742        ),
2743    ]))
2744}
2745
2746fn describe_host_function_payload(
2747    collection: &ResolvedHostFunctions,
2748    host_function_name: &str,
2749) -> Result<Value, String> {
2750    let Some(host_function) = collection
2751        .functions
2752        .iter()
2753        .find(|host_function| host_function.name == host_function_name)
2754    else {
2755        return Err(format!(
2756            "No host function \"{host_function_name}\" in collection \"{}\". Available: {}",
2757            collection.name,
2758            host_function_names(collection)
2759        ));
2760    };
2761    Ok(json_object([
2762        ("collection", Value::String(collection.name.clone())),
2763        ("function", Value::String(host_function_name.to_string())),
2764        (
2765            "description",
2766            Value::String(host_function.description.clone()),
2767        ),
2768        (
2769            "flags",
2770            Value::Array(describe_host_function_flags(&host_function.input_schema)),
2771        ),
2772        ("examples", Value::Array(Vec::new())),
2773    ]))
2774}
2775
2776fn describe_host_function_flags(schema: &Value) -> Vec<Value> {
2777    let properties = schema
2778        .get("properties")
2779        .and_then(Value::as_object)
2780        .cloned()
2781        .unwrap_or_default();
2782    let required = schema
2783        .get("required")
2784        .and_then(Value::as_array)
2785        .map(|items| {
2786            items
2787                .iter()
2788                .filter_map(Value::as_str)
2789                .map(str::to_owned)
2790                .collect::<std::collections::BTreeSet<_>>()
2791        })
2792        .unwrap_or_default();
2793    properties
2794        .into_iter()
2795        .map(|(field_name, field_schema)| {
2796            json_object([
2797                (
2798                    "name",
2799                    Value::String(format!("--{}", camel_to_kebab(&field_name))),
2800                ),
2801                (
2802                    "type",
2803                    Value::String(describe_host_function_flag_type(&field_schema)),
2804                ),
2805                ("required", Value::Bool(required.contains(&field_name))),
2806            ])
2807        })
2808        .collect()
2809}
2810
2811fn describe_host_function_flag_type(schema: &Value) -> String {
2812    match json_schema_type(schema) {
2813        Some("array") => {
2814            let item_type = schema
2815                .get("items")
2816                .and_then(json_schema_type)
2817                .unwrap_or("string");
2818            format!("{item_type}[]")
2819        }
2820        Some("string") => schema
2821            .get("enum")
2822            .and_then(Value::as_array)
2823            .map(|values| values.iter().filter_map(Value::as_str).collect::<Vec<_>>())
2824            .filter(|values| !values.is_empty())
2825            .map(|values| values.join("|"))
2826            .unwrap_or_else(|| String::from("string")),
2827        Some(other) => other.to_string(),
2828        None => String::from("string"),
2829    }
2830}
2831
2832fn host_functions_names(host_functions: &[ResolvedHostFunctions]) -> String {
2833    host_functions
2834        .iter()
2835        .map(|collection| collection.name.clone())
2836        .collect::<Vec<_>>()
2837        .join(", ")
2838}
2839
2840fn host_function_names(collection: &ResolvedHostFunctions) -> String {
2841    collection
2842        .functions
2843        .iter()
2844        .map(|host_function| host_function.name.clone())
2845        .collect::<Vec<_>>()
2846        .join(", ")
2847}
2848
2849fn is_help_flag(value: &str) -> bool {
2850    matches!(value, "--help" | "-h")
2851}
2852
2853fn json_schema_type(schema: &Value) -> Option<&str> {
2854    schema.get("type").and_then(Value::as_str)
2855}
2856
2857fn camel_to_kebab(value: &str) -> String {
2858    let mut output = String::new();
2859    for (index, ch) in value.chars().enumerate() {
2860        if ch.is_ascii_uppercase() && index > 0 {
2861            output.push('-');
2862        }
2863        output.push(ch.to_ascii_lowercase());
2864    }
2865    output
2866}
2867
2868fn normalize_guest_path(path: String) -> String {
2869    let absolute = path.starts_with('/');
2870    let mut parts = Vec::new();
2871    for part in path.split('/') {
2872        match part {
2873            "" | "." => {}
2874            ".." => {
2875                parts.pop();
2876            }
2877            _ => parts.push(part),
2878        }
2879    }
2880    let normalized = parts.join("/");
2881    if absolute {
2882        format!("/{normalized}")
2883    } else {
2884        normalized
2885    }
2886}
2887
2888fn json_object<const N: usize>(entries: [(&str, Value); N]) -> Value {
2889    Value::Object(Map::from_iter(
2890        entries
2891            .into_iter()
2892            .map(|(key, value)| (key.to_string(), value)),
2893    ))
2894}
2895
2896/// Build the wire [`wire::PackageDescriptor`]s for the `/opt/agentos` projection.
2897/// The sidecar reads package metadata from the forwarded package path.
2898pub(crate) fn build_package_descriptors(config: &AgentOsConfig) -> Vec<wire::PackageDescriptor> {
2899    config
2900        .packages
2901        .iter()
2902        .map(|package| wire::PackageDescriptor {
2903            path: package.path.clone(),
2904        })
2905        .collect()
2906}
2907
2908pub(crate) fn serialize_mounts(
2909    config: &AgentOsConfig,
2910) -> Result<Vec<wire::MountDescriptor>, ClientError> {
2911    config
2912        .mounts
2913        .iter()
2914        .map(|mount| match mount {
2915            MountConfig::Native {
2916                path,
2917                plugin,
2918                guest_source,
2919                guest_fstype,
2920                read_only,
2921            } => {
2922                let plugin_config = plugin
2923                    .config
2924                    .clone()
2925                    .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
2926                Ok(wire::MountDescriptor {
2927                    guest_path: path.clone(),
2928                    guest_source: guest_source.clone().unwrap_or_else(|| plugin.id.clone()),
2929                    guest_fstype: guest_fstype.clone().unwrap_or_else(|| plugin.id.clone()),
2930                    read_only: *read_only,
2931                    plugin: wire::MountPluginDescriptor {
2932                        id: plugin.id.clone(),
2933                        config: json_utf8(&plugin_config, "native mount plugin config")?,
2934                    },
2935                })
2936            }
2937            MountConfig::Plain { .. } => Err(ClientError::Sidecar(
2938                "plain mounts cannot be configured during Rust client VM creation".to_string(),
2939            )),
2940            MountConfig::Overlay { .. } => Err(ClientError::Sidecar(
2941                "overlay mounts cannot be configured during Rust client VM creation".to_string(),
2942            )),
2943        })
2944        .collect()
2945}
2946
2947fn json_utf8(value: &serde_json::Value, context: &str) -> Result<String, ClientError> {
2948    serde_json::to_string(value)
2949        .map_err(|error| ClientError::Sidecar(format!("failed to serialize {context}: {error}")))
2950}
2951
2952fn operation_wildcard_if_omitted(values: &Option<Vec<String>>) -> Vec<String> {
2953    values.clone().unwrap_or_else(|| vec!["*".to_string()])
2954}
2955
2956fn resource_wildcard_if_omitted(values: &Option<Vec<String>>) -> Vec<String> {
2957    values.clone().unwrap_or_else(|| vec!["**".to_string()])
2958}
2959
2960/// Extract the `vm_id` from a generated ownership scope, if it is VM-scoped.
2961fn wire_ownership_vm_id(ownership: &wire::OwnershipScope) -> Option<&str> {
2962    match ownership {
2963        wire::OwnershipScope::VmOwnership(ownership) => Some(ownership.vm_id.as_str()),
2964        wire::OwnershipScope::ConnectionOwnership(_)
2965        | wire::OwnershipScope::SessionOwnership(_) => None,
2966    }
2967}
2968
2969fn vm_dispose_response(vm_id: &str, response: wire::ResponsePayload) -> Result<(), ClientError> {
2970    match response {
2971        wire::ResponsePayload::VmDisposedResponse(disposed) if disposed.vm_id == vm_id => Ok(()),
2972        wire::ResponsePayload::RejectedResponse(rejected) => Err(rejected_to_error(rejected)),
2973        other => Err(ClientError::Sidecar(format!(
2974            "unexpected dispose_vm response: {other:?}"
2975        ))),
2976    }
2977}
2978
2979fn retain_shutdown_failure(result: &mut Result<(), ClientError>, cleanup: Result<(), ClientError>) {
2980    if let Err(error) = cleanup {
2981        eprintln!("agentOS secondary shutdown cleanup failed: {error}");
2982        if result.is_ok() {
2983            *result = Err(error);
2984        }
2985    }
2986}
2987
2988fn concurrent_vm_mutation_error() -> ClientError {
2989    ClientError::Sidecar(String::from(
2990        "invalid_state: another VM mount or software mutation is already in progress; wait for it to finish before retrying",
2991    ))
2992}
2993
2994fn try_vm_mutation(
2995    operation: &tokio::sync::Mutex<()>,
2996) -> Result<tokio::sync::MutexGuard<'_, ()>, ClientError> {
2997    operation
2998        .try_lock()
2999        .map_err(|_| concurrent_vm_mutation_error())
3000}
3001
3002/// Preserve typed sidecar error codes and structured admission/deadline fields.
3003fn rejected_to_error(rejected: wire::RejectedResponse) -> ClientError {
3004    ClientError::from_rejection(rejected)
3005}
3006
3007#[cfg(test)]
3008mod tests {
3009    use std::collections::{BTreeMap, HashMap};
3010    use std::sync::{Arc, Mutex};
3011
3012    use super::{
3013        run_host_callback, serialize_create_vm_config_for_sidecar,
3014        serialize_root_filesystem_config_for_sidecar, try_vm_mutation, vm_host_functions,
3015        AgentOsCreationTask, VmHostFunctionRegistry,
3016    };
3017    use crate::config::{
3018        AgentOsConfig, AgentOsLimits, FsPermissionRule, FsPermissions, HostFunctionLimits,
3019        HttpLimits, JsRuntimeLimits, MountPlugin, PatternPermissions, PermissionMode, Permissions,
3020        PythonLimits, ResolvedHostFunction, ResolvedHostFunctions, ResourceLimits,
3021        RootFilesystemConfig, RootFilesystemKind, RootFilesystemMode, RootLowerInput,
3022        RulePermissions, WasmLimits,
3023    };
3024    use crate::fs::{
3025        DirEntryType, FilesystemEntry, FilesystemEntryEncoding, FilesystemSnapshotEntries,
3026        FilesystemSnapshotExport, RootSnapshotExport, SnapshotExportKind,
3027    };
3028    use agentos_vm_config::{
3029        FsPermissionScope, PatternPermissionScope, PermissionMode as ConfigPermissionMode,
3030        RootFilesystemEntryKind, RootFilesystemLowerDescriptor,
3031        RootFilesystemMode as ConfigRootFilesystemMode, VmDefaultsProfile,
3032    };
3033    use serde_json::json;
3034
3035    #[tokio::test]
3036    async fn command_shaped_input_cannot_redirect_an_authorized_host_callback() {
3037        let vm_id = format!("host-callback-test-{}", uuid::Uuid::new_v4());
3038        let calls = Arc::new(Mutex::new(Vec::new()));
3039        let allowed_calls = Arc::clone(&calls);
3040        let danger_calls = Arc::clone(&calls);
3041        let allowed = ResolvedHostFunction {
3042            name: String::from("allowed"),
3043            description: String::new(),
3044            input_schema: json!({ "type": "object" }),
3045            timeout_ms: None,
3046            execute: Arc::new(move |_| {
3047                allowed_calls.lock().expect("calls lock").push("allowed");
3048                Box::pin(async { Ok(json!("allowed")) })
3049            }),
3050        };
3051        let danger = ResolvedHostFunction {
3052            name: String::from("danger"),
3053            description: String::new(),
3054            input_schema: json!({ "type": "object" }),
3055            timeout_ms: None,
3056            execute: Arc::new(move |_| {
3057                danger_calls.lock().expect("calls lock").push("danger");
3058                Box::pin(async { Ok(json!("danger")) })
3059            }),
3060        };
3061        let host_function_map = HashMap::from([
3062            (String::from("math:allowed"), allowed.clone()),
3063            (String::from("math:danger"), danger.clone()),
3064        ]);
3065        let _ = vm_host_functions().insert(
3066            vm_id.clone(),
3067            Arc::new(VmHostFunctionRegistry {
3068                host_functions: vec![ResolvedHostFunctions {
3069                    name: String::from("math"),
3070                    functions: vec![allowed, danger],
3071                }],
3072                host_function_map,
3073            }),
3074        );
3075
3076        let response = run_host_callback(
3077            &vm_id,
3078            super::wire::HostCallbackRequest {
3079                invocation_id: String::from("invocation"),
3080                callback_key: String::from("math:allowed"),
3081                input: json!({
3082                    "type": "command",
3083                    "command": "agentos-math",
3084                    "args": ["danger"],
3085                    "cwd": "/workspace"
3086                })
3087                .to_string(),
3088                timeout_ms: 1_000,
3089            },
3090        )
3091        .await;
3092        vm_host_functions().remove(&vm_id);
3093
3094        assert_eq!(response.error, None);
3095        assert_eq!(response.result.as_deref(), Some("\"allowed\""));
3096        assert_eq!(*calls.lock().expect("calls lock"), vec!["allowed"]);
3097    }
3098
3099    #[tokio::test]
3100    async fn cancelled_creation_waiter_keeps_the_owned_task_running() {
3101        let (release, wait_for_release) = tokio::sync::oneshot::channel();
3102        let (completed, wait_for_completion) = tokio::sync::oneshot::channel();
3103        let guard = AgentOsCreationTask {
3104            task: Some(tokio::spawn(async move {
3105                let _ = wait_for_release.await;
3106                let _ = completed.send(());
3107                Err(crate::ClientError::Sidecar(String::from(
3108                    "injected creation failure",
3109                )))
3110            })),
3111        };
3112
3113        drop(guard);
3114        release.send(()).unwrap();
3115        tokio::time::timeout(std::time::Duration::from_secs(1), wait_for_completion)
3116            .await
3117            .expect("detached creation task remains owned")
3118            .expect("creation task reports completion");
3119    }
3120
3121    #[tokio::test]
3122    async fn concurrent_vm_mutations_fail_without_queueing() {
3123        let operation = tokio::sync::Mutex::new(());
3124        let active = try_vm_mutation(&operation).expect("first mutation is admitted");
3125        let error = try_vm_mutation(&operation).expect_err("second mutation is rejected");
3126        assert!(error.to_string().contains("invalid_state"));
3127        drop(active);
3128        assert!(try_vm_mutation(&operation).is_ok());
3129    }
3130
3131    #[test]
3132    fn vm_dispose_preserves_timeout_details_and_original_error_after_cleanup() {
3133        use super::{retain_shutdown_failure, vm_dispose_response, wire, ClientError};
3134
3135        let mut result = vm_dispose_response(
3136            "vm-timeout",
3137            wire::ResponsePayload::RejectedResponse(wire::RejectedResponse {
3138                code: "timeout".into(),
3139                message: "SQLite close is unconfirmed; raise limits.reactor.shutdownDeadlineMs"
3140                    .into(),
3141                limit_name: Some("reactor.shutdownDeadlineMs".into()),
3142                configured_limit: Some(5_000),
3143                current_usage: None,
3144                requested: None,
3145                unit: Some("milliseconds".into()),
3146                scope: Some("vm".into()),
3147                vm_id: Some("vm-timeout".into()),
3148                session_generation: None,
3149                capability_id: None,
3150                operation: Some("vm.dispose".into()),
3151                configuration_path: Some("limits.reactor.shutdownDeadlineMs".into()),
3152                retryable: Some(false),
3153                errno: Some("ETIMEDOUT".into()),
3154            }),
3155        );
3156        retain_shutdown_failure(
3157            &mut result,
3158            Err(ClientError::Sidecar("secondary cleanup".into())),
3159        );
3160        let ClientError::OperationTimedOut { message, details } = result.unwrap_err() else {
3161            panic!("expected original typed timeout");
3162        };
3163        assert!(message.contains("SQLite close is unconfirmed"));
3164        assert_eq!(
3165            details.limit_name.as_deref(),
3166            Some("reactor.shutdownDeadlineMs")
3167        );
3168        assert_eq!(details.configured_limit, Some(5_000));
3169        assert_eq!(details.unit.as_deref(), Some("milliseconds"));
3170        assert_eq!(details.vm_id.as_deref(), Some("vm-timeout"));
3171        assert_eq!(details.operation.as_deref(), Some("vm.dispose"));
3172        assert_eq!(
3173            details.configuration_path.as_deref(),
3174            Some("limits.reactor.shutdownDeadlineMs")
3175        );
3176        assert_eq!(details.retryable, Some(false));
3177        assert_eq!(details.errno.as_deref(), Some("ETIMEDOUT"));
3178
3179        assert!(vm_dispose_response(
3180            "vm-ok",
3181            wire::ResponsePayload::VmDisposedResponse(wire::VmDisposedResponse {
3182                vm_id: "vm-ok".into()
3183            })
3184        )
3185        .is_ok());
3186        assert!(vm_dispose_response(
3187            "vm-ok",
3188            wire::ResponsePayload::VmDisposedResponse(wire::VmDisposedResponse {
3189                vm_id: "other-vm".into()
3190            })
3191        )
3192        .is_err());
3193        let mut result = Ok(());
3194        retain_shutdown_failure(&mut result, Err(ClientError::Sidecar("cleanup".into())));
3195        assert!(result.is_err());
3196    }
3197
3198    #[test]
3199    fn create_vm_omits_permissions_so_sidecar_applies_product_defaults() {
3200        let config = serialize_create_vm_config_for_sidecar(&AgentOsConfig::default()).unwrap();
3201        assert_eq!(config.defaults_profile, Some(VmDefaultsProfile::AgentOs));
3202        assert_eq!(config.permissions, None);
3203    }
3204
3205    #[test]
3206    fn create_vm_sends_only_explicit_permission_overrides() {
3207        let config = serialize_create_vm_config_for_sidecar(&AgentOsConfig {
3208            permissions: Some(Permissions {
3209                network: Some(PatternPermissions::Mode(PermissionMode::Deny)),
3210                ..Default::default()
3211            }),
3212            ..Default::default()
3213        })
3214        .unwrap();
3215        let policy = config.permissions.expect("explicit policy");
3216        assert_eq!(
3217            policy.network,
3218            Some(PatternPermissionScope::Mode(ConfigPermissionMode::Deny))
3219        );
3220        assert_eq!(policy.child_process, None);
3221    }
3222
3223    #[test]
3224    fn create_vm_expands_omitted_rule_fields_to_domain_wildcards() {
3225        let config = serialize_create_vm_config_for_sidecar(&AgentOsConfig {
3226            permissions: Some(Permissions {
3227                fs: Some(FsPermissions::Rules(RulePermissions {
3228                    default: Some(PermissionMode::Deny),
3229                    rules: vec![FsPermissionRule {
3230                        mode: PermissionMode::Allow,
3231                        operations: None,
3232                        paths: Some(vec!["/workspace/**".to_string()]),
3233                    }],
3234                })),
3235                ..Default::default()
3236            }),
3237            ..Default::default()
3238        })
3239        .unwrap();
3240        let policy = config.permissions.expect("explicit fs policy");
3241        let Some(FsPermissionScope::Rules(rules)) = policy.fs else {
3242            panic!("expected fs rule set");
3243        };
3244        assert_eq!(rules.default, Some(ConfigPermissionMode::Deny));
3245        assert_eq!(rules.rules[0].operations, vec!["*"]);
3246        assert_eq!(rules.rules[0].paths, vec!["/workspace/**"]);
3247
3248        let config = serialize_create_vm_config_for_sidecar(&AgentOsConfig {
3249            permissions: Some(Permissions {
3250                network: Some(PatternPermissions::Rules(RulePermissions {
3251                    default: Some(PermissionMode::Allow),
3252                    rules: vec![crate::config::PatternPermissionRule {
3253                        mode: PermissionMode::Deny,
3254                        operations: None,
3255                        patterns: None,
3256                    }],
3257                })),
3258                ..Default::default()
3259            }),
3260            ..Default::default()
3261        })
3262        .unwrap();
3263        let policy = config.permissions.expect("explicit network policy");
3264        let Some(PatternPermissionScope::Rules(rules)) = policy.network else {
3265            panic!("expected network rule set");
3266        };
3267        assert_eq!(rules.default, Some(ConfigPermissionMode::Allow));
3268        assert_eq!(rules.rules[0].operations, vec!["*"]);
3269        assert_eq!(rules.rules[0].patterns, vec!["**"]);
3270    }
3271
3272    #[test]
3273    fn root_filesystem_serializer_preserves_configured_descriptor() {
3274        let (descriptor, native_root) =
3275            serialize_root_filesystem_config_for_sidecar(&RootFilesystemConfig {
3276                mode: Some(RootFilesystemMode::ReadOnly),
3277                disable_default_base_layer: true,
3278                lowers: vec![
3279                    RootLowerInput::BundledBaseFilesystem,
3280                    RootLowerInput::SnapshotExport(RootSnapshotExport {
3281                        kind: SnapshotExportKind::SnapshotExport,
3282                        source: FilesystemSnapshotExport {
3283                            format: "agentos-filesystem-snapshot-v1".to_string(),
3284                            filesystem: FilesystemSnapshotEntries {
3285                                entries: vec![
3286                                    FilesystemEntry {
3287                                        path: "/bin/run".to_string(),
3288                                        entry_type: DirEntryType::File,
3289                                        mode: "0755".to_string(),
3290                                        uid: 1000,
3291                                        gid: 1000,
3292                                        content: Some("#!/bin/sh".to_string()),
3293                                        encoding: Some(FilesystemEntryEncoding::Utf8),
3294                                        target: None,
3295                                    },
3296                                    FilesystemEntry {
3297                                        path: "/link".to_string(),
3298                                        entry_type: DirEntryType::Symlink,
3299                                        mode: "0777".to_string(),
3300                                        uid: 0,
3301                                        gid: 0,
3302                                        content: None,
3303                                        encoding: None,
3304                                        target: Some("/bin/run".to_string()),
3305                                    },
3306                                ],
3307                            },
3308                        },
3309                    }),
3310                ],
3311                ..Default::default()
3312            })
3313            .expect("serialize root filesystem");
3314
3315        assert!(native_root.is_none());
3316        assert_eq!(descriptor.mode, ConfigRootFilesystemMode::ReadOnly);
3317        assert!(descriptor.disable_default_base_layer);
3318        assert_eq!(descriptor.bootstrap_entries, Vec::new());
3319        assert!(matches!(
3320            descriptor.lowers[0],
3321            RootFilesystemLowerDescriptor::BundledBaseFilesystem
3322        ));
3323
3324        let RootFilesystemLowerDescriptor::Snapshot { entries } = &descriptor.lowers[1] else {
3325            panic!("expected snapshot lower");
3326        };
3327        assert_eq!(entries[0].path, "/bin/run");
3328        assert_eq!(entries[0].kind, RootFilesystemEntryKind::File);
3329        assert_eq!(entries[0].mode, Some(0o755));
3330        assert!(entries[0].executable);
3331        assert_eq!(entries[1].kind, RootFilesystemEntryKind::Symlink);
3332        assert_eq!(entries[1].target.as_deref(), Some("/bin/run"));
3333    }
3334
3335    #[test]
3336    fn create_vm_config_preserves_native_root_config() {
3337        let config = serialize_create_vm_config_for_sidecar(&AgentOsConfig {
3338            root_filesystem: RootFilesystemConfig {
3339                kind: RootFilesystemKind::Native,
3340                mode: Some(RootFilesystemMode::ReadOnly),
3341                native_plugin: Some(MountPlugin {
3342                    id: "sqlite_vfs".to_string(),
3343                    config: Some(serde_json::json!({
3344                        "databasePath": "/tmp/agentos-root.sqlite"
3345                    })),
3346                }),
3347                ..Default::default()
3348            },
3349            ..Default::default()
3350        })
3351        .expect("serialize create VM config");
3352        let native_root = config.native_root.expect("native root config");
3353
3354        assert_eq!(native_root.plugin.id, "sqlite_vfs");
3355        assert_eq!(
3356            native_root.plugin.config,
3357            serde_json::json!({ "databasePath": "/tmp/agentos-root.sqlite" })
3358        );
3359        assert!(native_root.read_only);
3360    }
3361
3362    #[test]
3363    fn create_vm_config_preserves_environment_and_timer_policy() {
3364        let environment = BTreeMap::from([
3365            (String::from("EMPTY"), String::new()),
3366            (String::from("PATH"), String::from("/opt/agentos/bin")),
3367        ]);
3368        let config = serialize_create_vm_config_for_sidecar(&AgentOsConfig {
3369            environment: Some(environment.clone()),
3370            high_resolution_time: Some(false),
3371            ..Default::default()
3372        })
3373        .expect("serialize create VM config");
3374
3375        assert_eq!(config.env, Some(environment));
3376        let js_runtime = config.js_runtime.expect("explicit timer policy");
3377        assert_eq!(js_runtime.allowed_builtins, None);
3378        assert_eq!(js_runtime.high_resolution_time, Some(false));
3379
3380        let empty = serialize_create_vm_config_for_sidecar(&AgentOsConfig {
3381            environment: Some(BTreeMap::new()),
3382            ..Default::default()
3383        })
3384        .expect("serialize empty environment");
3385        assert_eq!(empty.env, Some(BTreeMap::new()));
3386
3387        let defaulted = serialize_create_vm_config_for_sidecar(&AgentOsConfig::default())
3388            .expect("serialize default environment");
3389        assert_eq!(defaulted.env, None);
3390    }
3391
3392    #[test]
3393    fn create_vm_config_preserves_typed_limits() {
3394        let config = serialize_create_vm_config_for_sidecar(&AgentOsConfig {
3395            limits: Some(AgentOsLimits {
3396                agentos_packages: Some(crate::config::AgentOsPackageLimits {
3397                    max_mounts: Some(8192),
3398                }),
3399                resources: Some(ResourceLimits {
3400                    max_processes: Some(7),
3401                    max_filesystem_bytes: Some(4096),
3402                    ..Default::default()
3403                }),
3404                http: Some(HttpLimits {
3405                    max_fetch_response_bytes: Some(1024),
3406                }),
3407                tls: Some(crate::TlsLimits {
3408                    max_buffered_bytes: Some(2048),
3409                }),
3410                execution: Some(crate::ExecutionLimits {
3411                    completed_ttl_ms: Some(60_000),
3412                    max_completed_executions: Some(128),
3413                    live_execution_warning_threshold: Some(32),
3414                }),
3415                host_functions: Some(HostFunctionLimits {
3416                    default_timeout_ms: Some(500),
3417                    max_registered_functions_per_vm: Some(12),
3418                    ..Default::default()
3419                }),
3420                js_runtime: Some(JsRuntimeLimits {
3421                    v8_heap_limit_mb: Some(64),
3422                    sync_rpc_wait_timeout_ms: Some(2_000),
3423                    cpu_time_limit_ms: Some(30_000),
3424                    wall_clock_limit_ms: Some(0),
3425                    import_cache_materialize_timeout_ms: Some(30_000),
3426                    ..Default::default()
3427                }),
3428                python: Some(PythonLimits {
3429                    max_old_space_mb: Some(256),
3430                    ..Default::default()
3431                }),
3432                wasm: Some(WasmLimits {
3433                    prewarm_timeout_ms: Some(30_000),
3434                    runner_heap_limit_mb: Some(2_048),
3435                    runner_cpu_time_limit_ms: Some(60_000),
3436                    ..Default::default()
3437                }),
3438                ..Default::default()
3439            }),
3440            ..Default::default()
3441        })
3442        .expect("serialize create VM config");
3443        let limits = config.limits.expect("limits config");
3444
3445        assert_eq!(
3446            limits.tls.expect("TLS limits").max_buffered_bytes,
3447            Some(2048)
3448        );
3449        let execution = limits.execution.expect("execution limits");
3450        assert_eq!(execution.completed_ttl_ms, Some(60_000));
3451        assert_eq!(execution.max_completed_executions, Some(128));
3452        assert_eq!(execution.live_execution_warning_threshold, Some(32));
3453        assert_eq!(
3454            limits.agentos_packages.expect("package limits").max_mounts,
3455            Some(8192)
3456        );
3457        let resources = limits.resources.expect("resource limits");
3458        assert_eq!(resources.max_processes, Some(7));
3459        assert_eq!(resources.max_filesystem_bytes, Some(4096));
3460        assert_eq!(
3461            limits.http.expect("http limits").max_fetch_response_bytes,
3462            Some(1024)
3463        );
3464        assert_eq!(
3465            limits
3466                .host_functions
3467                .as_ref()
3468                .expect("host_function limits")
3469                .default_timeout_ms,
3470            Some(500)
3471        );
3472        assert_eq!(
3473            limits
3474                .host_functions
3475                .expect("host_function limits")
3476                .max_registered_functions_per_vm,
3477            Some(12)
3478        );
3479        assert_eq!(
3480            limits
3481                .js_runtime
3482                .as_ref()
3483                .expect("js runtime limits")
3484                .v8_heap_limit_mb,
3485            Some(64)
3486        );
3487        let js_runtime = limits.js_runtime.expect("js runtime limits");
3488        assert_eq!(js_runtime.sync_rpc_wait_timeout_ms, Some(2_000));
3489        assert_eq!(js_runtime.cpu_time_limit_ms, Some(30_000));
3490        assert_eq!(js_runtime.wall_clock_limit_ms, Some(0));
3491        assert_eq!(js_runtime.import_cache_materialize_timeout_ms, Some(30_000));
3492        assert_eq!(
3493            limits.python.expect("python limits").max_old_space_mb,
3494            Some(256)
3495        );
3496        let wasm = limits.wasm.expect("wasm limits");
3497        assert_eq!(wasm.prewarm_timeout_ms, Some(30_000));
3498        assert_eq!(wasm.runner_heap_limit_mb, Some(2_048));
3499        assert_eq!(wasm.runner_cpu_time_limit_ms, Some(60_000));
3500    }
3501
3502    #[test]
3503    fn tls_execution_limit_validation_stays_in_shared_vm_config() {
3504        let defaulted = serialize_create_vm_config_for_sidecar(&AgentOsConfig::default()).unwrap();
3505        assert!(defaulted.limits.is_none());
3506        let limits = AgentOsLimits {
3507            tls: Some(crate::TlsLimits::default()),
3508            execution: Some(crate::ExecutionLimits::default()),
3509            ..Default::default()
3510        };
3511        let config = AgentOsConfig {
3512            limits: Some(limits),
3513            ..Default::default()
3514        };
3515        super::validate_config(&config).unwrap();
3516        let empty = serialize_create_vm_config_for_sidecar(&config)
3517            .unwrap()
3518            .limits
3519            .unwrap();
3520        assert_eq!(empty.tls.unwrap().max_buffered_bytes, None);
3521        assert_eq!(
3522            empty.execution.unwrap(),
3523            agentos_vm_config::ExecutionLimitsConfig::default()
3524        );
3525
3526        for (group, field) in [
3527            ("tls", "maxBufferedBytes"),
3528            ("execution", "completedTtlMs"),
3529            ("execution", "maxCompletedExecutions"),
3530            ("execution", "liveExecutionWarningThreshold"),
3531        ] {
3532            for value in [0, u64::MAX] {
3533                let mut limits: AgentOsLimits =
3534                    serde_json::from_value(serde_json::json!({group: {field: value}})).unwrap();
3535                // Shared preflight compares explicit parent/child overrides;
3536                // it does not materialize sidecar-owned default buffer limits.
3537                limits.resources = Some(ResourceLimits {
3538                    max_socket_buffered_bytes: Some(4096),
3539                    ..Default::default()
3540                });
3541                let config = AgentOsConfig {
3542                    limits: Some(limits),
3543                    ..Default::default()
3544                };
3545                let error = super::validate_config(&config).unwrap_err().to_string();
3546                assert!(
3547                    error.contains(&format!("limits.{group}.{field}")),
3548                    "{error}"
3549                );
3550            }
3551        }
3552    }
3553}