1use 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
36pub(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 pub exit_tx: watch::Sender<crate::process::ProcessOutcome>,
51 pub process_id: String,
53 pub kernel_pid: watch::Sender<Option<u32>>,
57 pub output_tasks: Vec<JoinHandle<()>>,
61 pub retain_output: bool,
63 pub execution_id: Option<String>,
65 pub execution_generation: Option<u64>,
66 pub started_at: i64,
68}
69
70pub(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 pub process_id: String,
82 pub spawned_tx: watch::Sender<Option<Result<(), ClientError>>>,
87 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
100pub(crate) struct TerminalEntry {
102 pub exit_task: JoinHandle<()>,
103}
104
105#[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#[derive(Clone)]
128pub struct AgentOs {
129 inner: Arc<AgentOsInner>,
130}
131
132pub(crate) struct AgentOsInner {
133 pub(crate) transport: Arc<SidecarProcess>,
135 pub(crate) connection_id: String,
136 pub(crate) session_id: String,
137 pub(crate) vm_id: String,
138 pub(crate) projected_commands: parking_lot::Mutex<BTreeMap<String, String>>,
140 pub(crate) vm_configuration_operation: tokio::sync::Mutex<()>,
143 pub(crate) installed_software:
144 parking_lot::Mutex<BTreeMap<String, crate::software::InstalledSoftware>>,
145
146 pub(crate) process_registry_lock: parking_lot::Mutex<()>,
148 pub(crate) pending_process_registrations: AtomicUsize,
151 pub(crate) processes: SccHashMap<u32, ProcessEntry>,
152 pub(crate) process_counter: AtomicU64,
156 pub(crate) synthetic_pid_counter: AtomicU64,
159 pub(crate) observed_process_time_lock: parking_lot::Mutex<()>,
160 pub(crate) observed_process_start_times: SccHashMap<String, f64>,
164 pub(crate) observed_process_exit_times: SccHashMap<String, f64>,
167
168 pub(crate) shells: SccHashMap<String, ShellEntry>,
170 pub(crate) shell_counter: AtomicU64,
171 pub(crate) pending_shell_exits: SccHashMap<u64, JoinHandle<()>>,
172 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 pub(crate) cron: Arc<CronManager>,
182
183 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
193struct 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 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 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 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 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 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 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 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 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 wait_for_vm_ready(&mut events, &vm_id, crate::VM_READY_TIMEOUT_MS).await?;
474
475 let packages = build_package_descriptors(&config);
478
479 let mounts = serialize_mounts(&config)?;
481 let configured_mounts = mounts.clone();
482
483 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 software: Vec::new(),
494 permissions: None,
498 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 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 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 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 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 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 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 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 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 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 self.inner.cron.dispose();
1015
1016 crate::process::drain_process_output_tasks(&self.inner.processes);
1019
1020 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 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 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 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 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
1208pub(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 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
1527async 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
1567static 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
1582static 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
1591static 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
1602static 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
1612fn 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
1796fn 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
1833async 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(®istry.host_functions, collection_name)
2006 }
2007 None => Ok(list_host_functions_payload(®istry.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(®istry.host_functions)
2019 ));
2020 };
2021
2022 let Some(host_function_name) = command.args.get(1) else {
2023 return describe_host_functions_payload(®istry.host_functions, subcommand);
2024 };
2025 if is_help_flag(host_function_name) {
2026 return describe_host_functions_payload(®istry.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(®istry.host_functions, &collection.name);
2050 };
2051 if is_help_flag(host_function_name) {
2052 return describe_host_functions_payload(®istry.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 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
2896pub(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
2960fn 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
3002fn 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 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}