1use std::collections::{BTreeMap, HashMap, VecDeque};
9use std::io::Write;
10use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
11use std::sync::{Arc, Weak};
12use std::time::Duration;
13
14use scc::HashMap as SccHashMap;
15use serde::Deserialize;
16use serde_json::{Map, Value};
17use tokio::sync::{broadcast, watch};
18use tokio::task::JoinHandle;
19
20use agentos_protocol::generated::v1::{
21 AcpCallback, AcpCallbackResponse, AcpEvent, AcpHostRequestCallbackResponse,
22};
23use agentos_protocol::ACP_EXTENSION_NAMESPACE;
24use agentos_sidecar_client::wire;
25use agentos_vm_config as vm_config;
26
27use crate::config::{
28 resolve_host_functions, AgentOsConfig, AgentOsLimits, MountConfig, ResolvedHostFunction,
29 ResolvedHostFunctions, RootFilesystemConfig, RootFilesystemKind,
30 RootFilesystemMode as ConfigRootFilesystemMode, RootLowerInput, SidecarJsBridgeCall,
31 SidecarJsBridgeCallback, TimerScheduleDriver,
32};
33use crate::cron::CronManager;
34use crate::error::ClientError;
35use crate::process::SYNTHETIC_PID_BASE;
36use crate::session::{
37 AgentExitEvent, AgentRestartOutcome, DurableSessionEventEntry, EphemeralSessionEventEntry,
38 SessionStreamEntry, SessionUpdate,
39};
40use crate::sidecar::{AgentOsSidecar, AgentOsSidecarPlacement, AgentOsSidecarVmLease};
41use crate::transport::{SidecarProcess, WireSidecarCallback};
42use agentos_sidecar_client::TransportError;
43
44use once_cell::sync::OnceCell;
45
46pub(crate) struct ProcessEntry {
52 pub command: String,
53 pub args: Vec<String>,
54 #[allow(dead_code)]
55 pub stdout_tx: broadcast::Sender<Vec<u8>>,
56 #[allow(dead_code)]
57 pub stderr_tx: broadcast::Sender<Vec<u8>>,
58 pub output_tx: broadcast::Sender<crate::process::ProcessOutput>,
59 pub exit_tx: watch::Sender<Option<i32>>,
61 pub process_id: String,
63 pub kernel_pid: watch::Sender<Option<u32>>,
67 pub output_tasks: Vec<JoinHandle<()>>,
71 pub started_at: i64,
73}
74
75pub(crate) struct ShellEntry {
81 pub pid: u32,
82 pub data_tx: broadcast::Sender<Vec<u8>>,
83 pub stderr_tx: broadcast::Sender<Vec<u8>>,
84 pub process_id: String,
86 pub spawned_tx: watch::Sender<bool>,
91 pub exit_tx: watch::Sender<Option<i32>>,
94}
95
96pub(crate) struct AcpTerminalEntry {
98 pub exit_task: JoinHandle<()>,
99}
100
101pub(crate) struct HostAcpTerminalOutput {
104 pub buffer: String,
106 pub truncated: bool,
107 pub output_byte_limit: usize,
110}
111
112pub(crate) struct HostAcpTerminal {
116 pub shell_id: String,
119 pub output: Arc<parking_lot::Mutex<HostAcpTerminalOutput>>,
121 pub exit_rx: watch::Receiver<Option<i32>>,
123}
124
125#[derive(Debug, Clone)]
135pub struct PackageDescriptor {
136 pub path: String,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct ProjectedAgent {
141 pub id: String,
142 pub acp_entrypoint: String,
143 pub adapter_entrypoint: String,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
147pub struct SoftwareInfo {
148 #[serde(rename = "packageName")]
149 pub package_name: String,
150 pub commands: Vec<String>,
151}
152
153#[derive(Clone)]
155pub struct AgentOs {
156 inner: Arc<AgentOsInner>,
157}
158
159pub(crate) struct AgentOsInner {
160 pub(crate) transport: Arc<SidecarProcess>,
162 pub(crate) connection_id: String,
163 pub(crate) session_id: String,
164 pub(crate) vm_id: String,
165 pub(crate) projected_commands: parking_lot::Mutex<BTreeMap<String, String>>,
167 pub(crate) projected_agents: parking_lot::Mutex<Vec<ProjectedAgent>>,
169
170 pub(crate) process_registry_lock: parking_lot::Mutex<()>,
172 pub(crate) processes: SccHashMap<u32, ProcessEntry>,
173 pub(crate) process_counter: AtomicU64,
177 pub(crate) synthetic_pid_counter: AtomicU64,
180 pub(crate) observed_process_time_lock: parking_lot::Mutex<()>,
181 pub(crate) observed_process_start_times: SccHashMap<String, f64>,
185 pub(crate) observed_process_exit_times: SccHashMap<String, f64>,
188
189 pub(crate) shells: SccHashMap<String, ShellEntry>,
191 pub(crate) shell_counter: AtomicU64,
192 pub(crate) pending_shell_exits: SccHashMap<u64, JoinHandle<()>>,
193 pub(crate) closed_shell_exit_codes: parking_lot::Mutex<VecDeque<(String, i32)>>,
197 pub(crate) acp_terminals: SccHashMap<String, AcpTerminalEntry>,
198 pub(crate) acp_terminal_count: AtomicUsize,
199 pub(crate) acp_terminal_lifecycle_lock: tokio::sync::Mutex<()>,
200 pub(crate) host_acp_terminals: SccHashMap<String, HostAcpTerminal>,
203 pub(crate) host_acp_terminal_counter: AtomicU64,
205
206 pub(crate) durable_session_event_tx: broadcast::Sender<crate::session::SessionStreamEntry>,
208 pub(crate) durable_agent_exit_tx: broadcast::Sender<crate::session::AgentExitEvent>,
209
210 pub(crate) cron: Arc<CronManager>,
212
213 pub(crate) config: Arc<AgentOsConfig>,
215 pub(crate) host_functions: Vec<ResolvedHostFunctions>,
217 pub(crate) sidecar: Arc<AgentOsSidecar>,
218 pub(crate) sidecar_lease: parking_lot::Mutex<Option<AgentOsSidecarVmLease>>,
219 pub(crate) dynamic_mounts: parking_lot::Mutex<Vec<wire::MountDescriptor>>,
220 pub(crate) disposed: AtomicBool,
221 pub(crate) acp_event_pump: parking_lot::Mutex<Option<JoinHandle<()>>>,
225}
226
227impl AgentOs {
228 pub async fn create(options: AgentOsConfig) -> Result<AgentOs, ClientError> {
232 let config = Arc::new(options);
233
234 let sidecar = match &config.sidecar {
238 Some(crate::config::AgentOsSidecarConfig::Explicit { handle }) => handle.clone(),
239 Some(crate::config::AgentOsSidecarConfig::Shared { pool }) => {
240 AgentOs::get_shared_sidecar(pool.clone(), config.sidecar_binary_path.clone())
241 .await?
242 }
243 None => AgentOs::get_shared_sidecar(None, config.sidecar_binary_path.clone()).await?,
244 };
245 let (transport, connection_id, _) = sidecar.ensure_connection().await?;
246
247 let session = match transport
249 .request_wire(
250 wire_connection_ownership(&connection_id),
251 wire::RequestPayload::OpenSessionRequest(wire::OpenSessionRequest {
252 placement: sidecar_wire_placement(&sidecar),
253 metadata: HashMap::new(),
254 }),
255 )
256 .await?
257 {
258 wire::ResponsePayload::SessionOpenedResponse(opened) => opened,
259 wire::ResponsePayload::RejectedResponse(rejected) => {
260 return Err(rejected_to_error(rejected));
261 }
262 wire::ResponsePayload::AuthenticatedResponse(_)
263 | wire::ResponsePayload::VmCreatedResponse(_)
264 | wire::ResponsePayload::VmDisposedResponse(_)
265 | wire::ResponsePayload::RootFilesystemBootstrappedResponse(_)
266 | wire::ResponsePayload::VmConfiguredResponse(_)
267 | wire::ResponsePayload::HostCallbacksRegisteredResponse(_)
268 | wire::ResponsePayload::LayerCreatedResponse(_)
269 | wire::ResponsePayload::LayerSealedResponse(_)
270 | wire::ResponsePayload::SnapshotImportedResponse(_)
271 | wire::ResponsePayload::SnapshotExportedResponse(_)
272 | wire::ResponsePayload::OverlayCreatedResponse(_)
273 | wire::ResponsePayload::GuestFilesystemResultResponse(_)
274 | wire::ResponsePayload::RootFilesystemSnapshotResponse(_)
275 | wire::ResponsePayload::ProcessStartedResponse(_)
276 | wire::ResponsePayload::StdinWrittenResponse(_)
277 | wire::ResponsePayload::PtyResizedResponse(_)
278 | wire::ResponsePayload::StdinClosedResponse(_)
279 | wire::ResponsePayload::ProcessKilledResponse(_)
280 | wire::ResponsePayload::ProcessSnapshotResponse(_)
281 | wire::ResponsePayload::ListenerSnapshotResponse(_)
282 | wire::ResponsePayload::BoundUdpSnapshotResponse(_)
283 | wire::ResponsePayload::SignalStateResponse(_)
284 | wire::ResponsePayload::ZombieTimerCountResponse(_)
285 | wire::ResponsePayload::FilesystemResultResponse(_)
286 | wire::ResponsePayload::PermissionDecisionResponse(_)
287 | wire::ResponsePayload::PersistenceStateResponse(_)
288 | wire::ResponsePayload::PersistenceFlushedResponse(_)
289 | wire::ResponsePayload::VmFetchResponse(_)
290 | wire::ResponsePayload::ExtEnvelope(_)
291 | wire::ResponsePayload::GuestKernelResultResponse(_)
292 | wire::ResponsePayload::ResourceSnapshotResponse(_)
293 | wire::ResponsePayload::PackageLinkedResponse(_)
294 | wire::ResponsePayload::ProvidedCommandsResponse(_)
295 | wire::ResponsePayload::ListMountsResponse(_)
296 | wire::ResponsePayload::ExecutionAcceptedResponse(_)
297 | wire::ResponsePayload::ExecutionCompletedResponse(_)
298 | wire::ResponsePayload::ExecutionEvaluationResponse(_)
299 | wire::ResponsePayload::TypeScriptCheckResponse(_)
300 | wire::ResponsePayload::ExecutionDescriptorResponse(_)
301 | wire::ResponsePayload::ExecutionListResponse(_)
302 | wire::ResponsePayload::ExecutionDeletedResponse(_)
303 | wire::ResponsePayload::ExecutionIoResponse(_)
304 | wire::ResponsePayload::ExecutionOutputPageResponse(_) => {
305 return Err(ClientError::Sidecar(
306 "unexpected open_session response".to_string(),
307 ));
308 }
309 };
310 let session_id = session.session_id;
311
312 let mut events = transport.subscribe_wire_events();
314 let permissions = permissions_policy(&config);
315 let create_vm_config = serialize_create_vm_config_for_sidecar(&config)?;
316 if let Some(callback) = config.sidecar_js_bridge_callback.clone() {
317 let _ = session_js_bridge_callbacks()
318 .insert(sidecar_session_key(&connection_id, &session_id), callback);
319 transport.register_wire_callback("js_bridge_call", js_bridge_call_callback());
320 }
321
322 let vm = match transport
324 .request_wire(
325 wire_session_ownership(&connection_id, &session_id),
326 wire::RequestPayload::CreateVmRequest(wire::CreateVmRequest {
327 runtime: wire::GuestRuntimeKind::JavaScript,
328 config: serde_json::to_string(&create_vm_config).map_err(|error| {
329 ClientError::Sidecar(format!(
330 "failed to serialize create VM config: {error}"
331 ))
332 })?,
333 }),
334 )
335 .await?
336 {
337 wire::ResponsePayload::VmCreatedResponse(created) => created,
338 wire::ResponsePayload::RejectedResponse(rejected) => {
339 return Err(rejected_to_error(rejected));
340 }
341 wire::ResponsePayload::AuthenticatedResponse(_)
342 | wire::ResponsePayload::SessionOpenedResponse(_)
343 | wire::ResponsePayload::VmDisposedResponse(_)
344 | wire::ResponsePayload::RootFilesystemBootstrappedResponse(_)
345 | wire::ResponsePayload::VmConfiguredResponse(_)
346 | wire::ResponsePayload::HostCallbacksRegisteredResponse(_)
347 | wire::ResponsePayload::LayerCreatedResponse(_)
348 | wire::ResponsePayload::LayerSealedResponse(_)
349 | wire::ResponsePayload::SnapshotImportedResponse(_)
350 | wire::ResponsePayload::SnapshotExportedResponse(_)
351 | wire::ResponsePayload::OverlayCreatedResponse(_)
352 | wire::ResponsePayload::GuestFilesystemResultResponse(_)
353 | wire::ResponsePayload::RootFilesystemSnapshotResponse(_)
354 | wire::ResponsePayload::ProcessStartedResponse(_)
355 | wire::ResponsePayload::StdinWrittenResponse(_)
356 | wire::ResponsePayload::PtyResizedResponse(_)
357 | wire::ResponsePayload::StdinClosedResponse(_)
358 | wire::ResponsePayload::ProcessKilledResponse(_)
359 | wire::ResponsePayload::ProcessSnapshotResponse(_)
360 | wire::ResponsePayload::ListenerSnapshotResponse(_)
361 | wire::ResponsePayload::BoundUdpSnapshotResponse(_)
362 | wire::ResponsePayload::SignalStateResponse(_)
363 | wire::ResponsePayload::ZombieTimerCountResponse(_)
364 | wire::ResponsePayload::FilesystemResultResponse(_)
365 | wire::ResponsePayload::PermissionDecisionResponse(_)
366 | wire::ResponsePayload::PersistenceStateResponse(_)
367 | wire::ResponsePayload::PersistenceFlushedResponse(_)
368 | wire::ResponsePayload::VmFetchResponse(_)
369 | wire::ResponsePayload::ExtEnvelope(_)
370 | wire::ResponsePayload::GuestKernelResultResponse(_)
371 | wire::ResponsePayload::ResourceSnapshotResponse(_)
372 | wire::ResponsePayload::PackageLinkedResponse(_)
373 | wire::ResponsePayload::ProvidedCommandsResponse(_)
374 | wire::ResponsePayload::ListMountsResponse(_)
375 | wire::ResponsePayload::ExecutionAcceptedResponse(_)
376 | wire::ResponsePayload::ExecutionCompletedResponse(_)
377 | wire::ResponsePayload::ExecutionEvaluationResponse(_)
378 | wire::ResponsePayload::TypeScriptCheckResponse(_)
379 | wire::ResponsePayload::ExecutionDescriptorResponse(_)
380 | wire::ResponsePayload::ExecutionListResponse(_)
381 | wire::ResponsePayload::ExecutionDeletedResponse(_)
382 | wire::ResponsePayload::ExecutionIoResponse(_)
383 | wire::ResponsePayload::ExecutionOutputPageResponse(_) => {
384 return Err(ClientError::Sidecar(
385 "unexpected create_vm response".to_string(),
386 ));
387 }
388 };
389 let vm_id = vm.vm_id;
390
391 wait_for_vm_ready(&mut events, &vm_id, crate::VM_READY_TIMEOUT_MS).await?;
393
394 let packages = build_package_descriptors(&config);
397
398 let mounts = serialize_mounts(&config)?;
400 let configured_mounts = mounts.clone();
401
402 let (projected_commands, projected_agents) = match transport
406 .request_wire(
407 wire_vm_ownership(&connection_id, &session_id, &vm_id),
408 wire::RequestPayload::ConfigureVmRequest(wire::ConfigureVmRequest {
409 mounts,
410 software: Vec::new(),
413 permissions,
414 module_access_cwd: None,
418 instructions: config.additional_instructions.clone().into_iter().collect(),
419 projected_modules: Vec::new(),
420 command_permissions: HashMap::new(),
421 loopback_exempt_ports: config.loopback_exempt_ports.clone(),
422 packages,
423 packages_mount_at: config.packages_mount_at.clone().unwrap_or_default(),
424 bootstrap_commands: Vec::new(),
425 host_function_shim_commands: Vec::new(),
426 }),
427 )
428 .await?
429 {
430 wire::ResponsePayload::VmConfiguredResponse(configured) => (
431 configured
432 .projected_commands
433 .into_iter()
434 .map(|command| (command.name, command.guest_path))
435 .collect(),
436 projected_agents_from_wire(configured.agents),
437 ),
438 wire::ResponsePayload::RejectedResponse(rejected) => {
439 return Err(rejected_to_error(rejected));
440 }
441 wire::ResponsePayload::AuthenticatedResponse(_)
442 | wire::ResponsePayload::SessionOpenedResponse(_)
443 | wire::ResponsePayload::VmCreatedResponse(_)
444 | wire::ResponsePayload::VmDisposedResponse(_)
445 | wire::ResponsePayload::RootFilesystemBootstrappedResponse(_)
446 | wire::ResponsePayload::HostCallbacksRegisteredResponse(_)
447 | wire::ResponsePayload::LayerCreatedResponse(_)
448 | wire::ResponsePayload::LayerSealedResponse(_)
449 | wire::ResponsePayload::SnapshotImportedResponse(_)
450 | wire::ResponsePayload::SnapshotExportedResponse(_)
451 | wire::ResponsePayload::OverlayCreatedResponse(_)
452 | wire::ResponsePayload::GuestFilesystemResultResponse(_)
453 | wire::ResponsePayload::RootFilesystemSnapshotResponse(_)
454 | wire::ResponsePayload::ProcessStartedResponse(_)
455 | wire::ResponsePayload::StdinWrittenResponse(_)
456 | wire::ResponsePayload::PtyResizedResponse(_)
457 | wire::ResponsePayload::StdinClosedResponse(_)
458 | wire::ResponsePayload::ProcessKilledResponse(_)
459 | wire::ResponsePayload::ProcessSnapshotResponse(_)
460 | wire::ResponsePayload::ListenerSnapshotResponse(_)
461 | wire::ResponsePayload::BoundUdpSnapshotResponse(_)
462 | wire::ResponsePayload::SignalStateResponse(_)
463 | wire::ResponsePayload::ZombieTimerCountResponse(_)
464 | wire::ResponsePayload::FilesystemResultResponse(_)
465 | wire::ResponsePayload::PermissionDecisionResponse(_)
466 | wire::ResponsePayload::PersistenceStateResponse(_)
467 | wire::ResponsePayload::PersistenceFlushedResponse(_)
468 | wire::ResponsePayload::VmFetchResponse(_)
469 | wire::ResponsePayload::ExtEnvelope(_)
470 | wire::ResponsePayload::GuestKernelResultResponse(_)
471 | wire::ResponsePayload::ResourceSnapshotResponse(_)
472 | wire::ResponsePayload::PackageLinkedResponse(_)
473 | wire::ResponsePayload::ProvidedCommandsResponse(_)
474 | wire::ResponsePayload::ListMountsResponse(_)
475 | wire::ResponsePayload::ExecutionAcceptedResponse(_)
476 | wire::ResponsePayload::ExecutionCompletedResponse(_)
477 | wire::ResponsePayload::ExecutionEvaluationResponse(_)
478 | wire::ResponsePayload::TypeScriptCheckResponse(_)
479 | wire::ResponsePayload::ExecutionDescriptorResponse(_)
480 | wire::ResponsePayload::ExecutionListResponse(_)
481 | wire::ResponsePayload::ExecutionDeletedResponse(_)
482 | wire::ResponsePayload::ExecutionIoResponse(_)
483 | wire::ResponsePayload::ExecutionOutputPageResponse(_) => {
484 return Err(ClientError::Sidecar(
485 "unexpected configure_vm response".to_string(),
486 ));
487 }
488 };
489
490 let resolved_host_functions =
494 resolve_host_functions(&config.host_functions).map_err(ClientError::InvalidConfig)?;
495 if !resolved_host_functions.is_empty() {
496 let mut host_function_map: HashMap<String, ResolvedHostFunction> = HashMap::new();
497 for collection in &resolved_host_functions {
498 let mut host_functions = HashMap::new();
499 for host_function in &collection.functions {
500 host_functions.insert(
501 host_function.name.clone(),
502 wire::RegisteredHostCallbackDefinition {
503 description: host_function.description.clone(),
504 input_schema: json_utf8(
505 &host_function.input_schema,
506 "host callback input schema",
507 )?,
508 timeout_ms: host_function.timeout_ms,
509 examples: Vec::new(),
510 },
511 );
512 host_function_map.insert(
513 format!("{}:{}", collection.name, host_function.name),
514 host_function.clone(),
515 );
516 }
517 match transport
518 .request_wire(
519 wire_vm_ownership(&connection_id, &session_id, &vm_id),
520 wire::RequestPayload::RegisterHostCallbacksRequest(
521 wire::RegisterHostCallbacksRequest {
522 name: collection.name.clone(),
523 description: String::new(),
524 command_aliases: vec![format!("agentos-{}", collection.name)],
525 registry_command_aliases: vec![String::from("agentos")],
526 callbacks: host_functions,
527 },
528 ),
529 )
530 .await?
531 {
532 wire::ResponsePayload::HostCallbacksRegisteredResponse(_) => {}
533 wire::ResponsePayload::RejectedResponse(rejected) => {
534 return Err(rejected_to_error(rejected));
535 }
536 wire::ResponsePayload::AuthenticatedResponse(_)
537 | wire::ResponsePayload::SessionOpenedResponse(_)
538 | wire::ResponsePayload::VmCreatedResponse(_)
539 | wire::ResponsePayload::VmDisposedResponse(_)
540 | wire::ResponsePayload::RootFilesystemBootstrappedResponse(_)
541 | wire::ResponsePayload::VmConfiguredResponse(_)
542 | wire::ResponsePayload::LayerCreatedResponse(_)
543 | wire::ResponsePayload::LayerSealedResponse(_)
544 | wire::ResponsePayload::SnapshotImportedResponse(_)
545 | wire::ResponsePayload::SnapshotExportedResponse(_)
546 | wire::ResponsePayload::OverlayCreatedResponse(_)
547 | wire::ResponsePayload::GuestFilesystemResultResponse(_)
548 | wire::ResponsePayload::RootFilesystemSnapshotResponse(_)
549 | wire::ResponsePayload::ProcessStartedResponse(_)
550 | wire::ResponsePayload::StdinWrittenResponse(_)
551 | wire::ResponsePayload::PtyResizedResponse(_)
552 | wire::ResponsePayload::StdinClosedResponse(_)
553 | wire::ResponsePayload::ProcessKilledResponse(_)
554 | wire::ResponsePayload::ProcessSnapshotResponse(_)
555 | wire::ResponsePayload::ListenerSnapshotResponse(_)
556 | wire::ResponsePayload::BoundUdpSnapshotResponse(_)
557 | wire::ResponsePayload::SignalStateResponse(_)
558 | wire::ResponsePayload::ZombieTimerCountResponse(_)
559 | wire::ResponsePayload::FilesystemResultResponse(_)
560 | wire::ResponsePayload::PermissionDecisionResponse(_)
561 | wire::ResponsePayload::PersistenceStateResponse(_)
562 | wire::ResponsePayload::PersistenceFlushedResponse(_)
563 | wire::ResponsePayload::VmFetchResponse(_)
564 | wire::ResponsePayload::ExtEnvelope(_)
565 | wire::ResponsePayload::GuestKernelResultResponse(_)
566 | wire::ResponsePayload::ResourceSnapshotResponse(_)
567 | wire::ResponsePayload::PackageLinkedResponse(_)
568 | wire::ResponsePayload::ProvidedCommandsResponse(_)
569 | wire::ResponsePayload::ListMountsResponse(_)
570 | wire::ResponsePayload::ExecutionAcceptedResponse(_)
571 | wire::ResponsePayload::ExecutionCompletedResponse(_)
572 | wire::ResponsePayload::ExecutionEvaluationResponse(_)
573 | wire::ResponsePayload::TypeScriptCheckResponse(_)
574 | wire::ResponsePayload::ExecutionDescriptorResponse(_)
575 | wire::ResponsePayload::ExecutionListResponse(_)
576 | wire::ResponsePayload::ExecutionDeletedResponse(_)
577 | wire::ResponsePayload::ExecutionIoResponse(_)
578 | wire::ResponsePayload::ExecutionOutputPageResponse(_) => {
579 return Err(ClientError::Sidecar(
580 "unexpected register_host_callbacks response".to_string(),
581 ));
582 }
583 }
584 }
585 let _ = vm_host_functions().insert(
586 vm_id.clone(),
587 Arc::new(VmHostFunctionRegistry {
588 host_functions: resolved_host_functions.clone(),
589 host_function_map,
590 }),
591 );
592 transport.register_wire_callback("host_callback", host_callback_callback());
593 }
594
595 sidecar.active_vm_count.fetch_add(1, Ordering::SeqCst);
597 let lease = AgentOsSidecarVmLease {
598 sidecar: sidecar.clone(),
599 };
600
601 let driver = config
602 .schedule_driver
603 .clone()
604 .unwrap_or_else(|| Arc::new(TimerScheduleDriver::new()));
605 let cron = Arc::new(CronManager::new(driver));
606
607 let inner = AgentOsInner {
608 transport,
609 connection_id,
610 session_id,
611 vm_id,
612 projected_commands: parking_lot::Mutex::new(projected_commands),
613 projected_agents: parking_lot::Mutex::new(projected_agents),
614 process_registry_lock: parking_lot::Mutex::new(()),
615 processes: SccHashMap::new(),
616 process_counter: AtomicU64::new(1),
617 synthetic_pid_counter: AtomicU64::new(SYNTHETIC_PID_BASE),
618 observed_process_time_lock: parking_lot::Mutex::new(()),
619 observed_process_start_times: SccHashMap::new(),
620 observed_process_exit_times: SccHashMap::new(),
621 shells: SccHashMap::new(),
622 shell_counter: AtomicU64::new(0),
623 pending_shell_exits: SccHashMap::new(),
624 closed_shell_exit_codes: parking_lot::Mutex::new(VecDeque::new()),
625 acp_terminals: SccHashMap::new(),
626 acp_terminal_count: AtomicUsize::new(0),
627 acp_terminal_lifecycle_lock: tokio::sync::Mutex::new(()),
628 host_acp_terminals: SccHashMap::new(),
629 host_acp_terminal_counter: AtomicU64::new(0),
630 durable_session_event_tx: broadcast::channel(1024).0,
631 durable_agent_exit_tx: broadcast::channel(64).0,
632 cron,
633 config,
634 host_functions: resolved_host_functions,
635 sidecar,
636 sidecar_lease: parking_lot::Mutex::new(Some(lease)),
637 dynamic_mounts: parking_lot::Mutex::new(configured_mounts),
638 disposed: AtomicBool::new(false),
639 acp_event_pump: parking_lot::Mutex::new(None),
640 };
641
642 let client = AgentOs {
643 inner: Arc::new(inner),
644 };
645 let _ = vm_acp_routers().insert(client.inner.vm_id.clone(), Arc::downgrade(&client.inner));
649 client
650 .inner
651 .transport
652 .register_wire_callback("ext", acp_host_callback());
653 spawn_acp_event_pump(&client);
654 Ok(client)
655 }
656
657 pub async fn link_software(&self, descriptor: PackageDescriptor) -> Result<(), ClientError> {
673 let inner = self.inner();
674 let response = self
675 .transport()
676 .request_wire(
677 wire_vm_ownership(&inner.connection_id, &inner.session_id, &inner.vm_id),
678 wire::RequestPayload::LinkPackageRequest(wire::LinkPackageRequest {
679 package: wire::PackageDescriptor {
682 path: descriptor.path,
683 },
684 }),
685 )
686 .await?;
687 match response {
688 wire::ResponsePayload::PackageLinkedResponse(linked) => {
689 let mut guard = inner.projected_commands.lock();
690 for command in linked.projected_commands {
691 guard.insert(command.name, command.guest_path);
692 }
693 register_projected_agents(
694 &inner.projected_agents,
695 projected_agents_from_wire(linked.agents),
696 );
697 Ok(())
698 }
699 wire::ResponsePayload::RejectedResponse(rejected) => Err(rejected_to_error(rejected)),
700 other => Err(ClientError::Sidecar(format!(
701 "unexpected link_package response: {other:?}"
702 ))),
703 }
704 }
705
706 pub async fn list_software(&self) -> Result<Vec<SoftwareInfo>, ClientError> {
707 let inner = self.inner();
708 let response = self
709 .transport()
710 .request_wire(
711 wire_vm_ownership(&inner.connection_id, &inner.session_id, &inner.vm_id),
712 wire::RequestPayload::ProvidedCommandsRequest,
713 )
714 .await?;
715 match response {
716 wire::ResponsePayload::ProvidedCommandsResponse(provided) => Ok(provided
717 .packages
718 .into_iter()
719 .map(|package| SoftwareInfo {
720 package_name: package.package_name,
721 commands: package.commands,
722 })
723 .collect()),
724 wire::ResponsePayload::RejectedResponse(rejected) => Err(rejected_to_error(rejected)),
725 other => Err(ClientError::Sidecar(format!(
726 "unexpected list_software response: {other:?}"
727 ))),
728 }
729 }
730
731 pub async fn shutdown(&self) -> Result<(), ClientError> {
732 if self.inner.disposed.swap(true, Ordering::SeqCst) {
734 return Ok(());
735 }
736
737 self.inner.cron.dispose();
742
743 abort_tracked_task(&self.inner.acp_event_pump);
749 crate::process::drain_process_output_tasks(&self.inner.processes);
750
751 let mut exit_tasks = Vec::new();
754 self.inner.pending_shell_exits.retain(|_, task| {
755 exit_tasks.push(std::mem::replace(task, tokio::spawn(async {})));
756 false
757 });
758
759 {
760 let _terminal_lifecycle_guard = self.inner.acp_terminal_lifecycle_lock.lock().await;
761 let mut terminal_entries = Vec::new();
762 self.inner.acp_terminals.retain(|process_id, entry| {
763 terminal_entries.push((
764 process_id.clone(),
765 std::mem::replace(&mut entry.exit_task, tokio::spawn(async {})),
766 ));
767 false
768 });
769 self.inner.acp_terminal_count.store(0, Ordering::SeqCst);
770 for (process_id, _) in &terminal_entries {
771 let transport = self.transport().clone();
772 let ownership = wire::OwnershipScope::VmOwnership(wire::VmOwnership {
773 connection_id: self.inner.connection_id.clone(),
774 session_id: self.inner.session_id.clone(),
775 vm_id: self.inner.vm_id.clone(),
776 });
777 let process_id = process_id.clone();
778 exit_tasks.push(tokio::spawn(async move {
779 let _ = transport
780 .request_wire(
781 ownership,
782 wire::RequestPayload::KillProcessRequest(wire::KillProcessRequest {
783 process_id,
784 signal: String::from("SIGTERM"),
785 }),
786 )
787 .await;
788 }));
789 }
790 for (_, task) in terminal_entries {
791 exit_tasks.push(task);
792 }
793 }
794
795 let mut host_terminal_shells = Vec::new();
799 self.inner.host_acp_terminals.retain(|_, terminal| {
800 host_terminal_shells.push(terminal.shell_id.clone());
801 false
802 });
803 for shell_id in host_terminal_shells {
804 let _ = self.close_shell(&shell_id);
805 }
806
807 if !exit_tasks.is_empty() {
808 let mut drain_tasks = exit_tasks;
809 if tokio::time::timeout(
810 Duration::from_millis(crate::SHELL_DISPOSE_TIMEOUT_MS),
811 futures::future::join_all(drain_tasks.iter_mut()),
812 )
813 .await
814 .is_err()
815 {
816 for task in drain_tasks {
817 task.abort();
818 }
819 }
820 }
821
822 let lease = self.inner.sidecar_lease.lock().take();
826 let _ = self
827 .transport()
828 .request_wire(
829 wire::OwnershipScope::VmOwnership(wire::VmOwnership {
830 connection_id: self.inner.connection_id.clone(),
831 session_id: self.inner.session_id.clone(),
832 vm_id: self.inner.vm_id.clone(),
833 }),
834 wire::RequestPayload::DisposeVmRequest(wire::DisposeVmRequest {
835 reason: wire::DisposeReason::Requested,
836 }),
837 )
838 .await;
839 let _ = vm_host_functions().remove(&self.inner.vm_id);
840 let _ = vm_acp_routers().remove(&self.inner.vm_id);
841 let _ = session_js_bridge_callbacks().remove(&sidecar_session_key(
842 &self.inner.connection_id,
843 &self.inner.session_id,
844 ));
845 let sidecar = self.inner.sidecar.clone();
846 if let Some(lease) = lease {
847 lease.dispose().await?;
848 }
849 if sidecar.active_vm_count.load(Ordering::SeqCst) == 0 {
850 sidecar.kill_connection().await;
851 let _ = sidecar.dispose().await;
852 }
853
854 Ok(())
855 }
856
857 pub(crate) fn inner(&self) -> &AgentOsInner {
860 &self.inner
861 }
862
863 pub(crate) fn transport(&self) -> &Arc<SidecarProcess> {
864 &self.inner.transport
865 }
866
867 pub(crate) fn connection_id(&self) -> &str {
868 &self.inner.connection_id
869 }
870
871 pub(crate) fn wire_session_id(&self) -> &str {
872 &self.inner.session_id
873 }
874
875 pub(crate) fn vm_id(&self) -> &str {
876 &self.inner.vm_id
877 }
878
879 pub(crate) fn config(&self) -> &Arc<AgentOsConfig> {
880 &self.inner.config
881 }
882
883 pub(crate) fn host_functions(&self) -> &[ResolvedHostFunctions] {
884 &self.inner.host_functions
885 }
886
887 pub(crate) fn cron(&self) -> &Arc<CronManager> {
888 &self.inner.cron
889 }
890
891 pub fn sidecar(&self) -> Arc<AgentOsSidecar> {
894 self.inner.sidecar.clone()
895 }
896
897 pub fn projected_agents(&self) -> Vec<ProjectedAgent> {
898 self.inner.projected_agents.lock().clone()
899 }
900}
901
902fn abort_tracked_task(slot: &parking_lot::Mutex<Option<JoinHandle<()>>>) {
905 if let Some(handle) = slot.lock().take() {
906 handle.abort();
907 }
908}
909
910fn spawn_acp_event_pump(client: &AgentOs) {
911 let mut events = client.transport().subscribe_wire_events();
912 let inner = Arc::downgrade(&client.inner);
913 let handle = tokio::spawn(async move {
914 loop {
915 match events.recv().await {
916 Ok((ownership, wire::EventPayload::ExtEnvelope(envelope))) => {
917 let Some(inner) = inner.upgrade() else {
918 break;
919 };
920 if inner.disposed.load(Ordering::SeqCst) {
921 break;
922 }
923 if wire_ownership_vm_id(&ownership) != Some(inner.vm_id.as_str()) {
924 continue;
925 }
926 if let Err(error) = deliver_acp_ext_event(&inner, envelope) {
927 tracing::warn!(?error, "failed to deliver acp extension event");
928 }
929 }
930 Ok((
931 _,
932 wire::EventPayload::VmLifecycleEvent(_)
933 | wire::EventPayload::ProcessOutputEvent(_)
934 | wire::EventPayload::ProcessExitedEvent(_)
935 | wire::EventPayload::ExecutionOutputEvent(_)
936 | wire::EventPayload::ExecutionCompletedEvent(_)
937 | wire::EventPayload::StructuredEvent(_),
938 )) => {}
939 Err(broadcast::error::RecvError::Lagged(_)) => {}
940 Err(broadcast::error::RecvError::Closed) => break,
941 }
942 }
943 });
944 *client.inner.acp_event_pump.lock() = Some(handle);
945}
946
947fn deliver_acp_ext_event(
948 inner: &AgentOsInner,
949 envelope: wire::ExtEnvelope,
950) -> Result<(), ClientError> {
951 if envelope.namespace != ACP_EXTENSION_NAMESPACE {
952 return Ok(());
953 }
954 let event: AcpEvent = serde_bare::from_slice(&envelope.payload)
955 .map_err(|error| ClientError::Sidecar(format!("invalid ACP event: {error}")))?;
956 match event {
957 AcpEvent::AcpDurableSessionEvent(event) => {
958 let durable_event = crate::session::decode_durable_event(event.event)
959 .map_err(|error| ClientError::Sidecar(error.to_string()))?;
960 let _ = inner
961 .durable_session_event_tx
962 .send(SessionStreamEntry::Durable(DurableSessionEventEntry {
963 durability: crate::session::DurableEventKind::Durable,
964 session_id: event.session_id.clone(),
965 sequence: event.sequence,
966 timestamp: event.timestamp,
967 event: durable_event.clone(),
968 }));
969 Ok(())
970 }
971 AcpEvent::AcpEphemeralSessionUpdateEvent(event) => {
972 let update: SessionUpdate = serde_json::from_str(&event.update).map_err(|error| {
973 ClientError::Sidecar(format!("invalid ephemeral ACP session update: {error}"))
974 })?;
975 let session_event = match update {
976 SessionUpdate::AgentMessageChunk(chunk) => {
977 crate::session::EphemeralSessionEvent::AgentMessageChunk(chunk)
978 }
979 SessionUpdate::AgentThoughtChunk(chunk) => {
980 crate::session::EphemeralSessionEvent::AgentThoughtChunk(chunk)
981 }
982 _ => {
983 return Err(ClientError::Sidecar(String::from(
984 "ephemeral ACP event must be an agent message or thought chunk",
985 )))
986 }
987 };
988 let _ = inner
989 .durable_session_event_tx
990 .send(SessionStreamEntry::Ephemeral(EphemeralSessionEventEntry {
991 durability: crate::session::EphemeralEventKind::Ephemeral,
992 session_id: event.session_id,
993 after_sequence: event.after_sequence,
994 event: session_event,
995 }));
996 Ok(())
997 }
998 AcpEvent::AcpSessionEvent(event) => {
999 tracing::warn!(
1000 session_id = event.session_id,
1001 "ignored legacy live-session event; durable session events use typed envelopes"
1002 );
1003 Ok(())
1004 }
1005 AcpEvent::AcpAgentStderrEvent(event) => {
1006 let mut stderr = std::io::stderr().lock();
1007 if let Err(error) = stderr.write_all(&event.chunk).and_then(|_| stderr.flush()) {
1008 tracing::warn!(?error, "failed to write acp stderr event");
1009 }
1010 Ok(())
1011 }
1012 AcpEvent::AcpAgentExitedEvent(event) => {
1013 tracing::warn!(
1014 session_id = event.session_id,
1015 agent_type = event.agent_type,
1016 process_id = event.process_id,
1017 exit_code = ?event.exit_code,
1018 restart = event.restart,
1019 restart_count = event.restart_count,
1020 max_restarts = event.max_restarts,
1021 "acp agent adapter exited unexpectedly"
1022 );
1023 let _ = inner.durable_agent_exit_tx.send(AgentExitEvent {
1024 session_id: event.session_id,
1025 agent_type: event.agent_type,
1026 process_id: event.process_id,
1027 pid: event.pid,
1028 exit_code: event.exit_code,
1029 restart: AgentRestartOutcome::NotAttempted,
1030 restart_count: event.restart_count,
1031 max_restarts: event.max_restarts,
1032 });
1033 Ok(())
1034 }
1035 }
1036}
1037
1038fn sidecar_wire_placement(sidecar: &AgentOsSidecar) -> wire::SidecarPlacement {
1040 match &sidecar.placement {
1041 AgentOsSidecarPlacement::Shared { pool } => {
1042 wire::SidecarPlacement::SidecarPlacementShared(wire::SidecarPlacementShared {
1043 pool: pool.clone(),
1044 })
1045 }
1046 AgentOsSidecarPlacement::Explicit { sidecar_id } => {
1047 wire::SidecarPlacement::SidecarPlacementExplicit(wire::SidecarPlacementExplicit {
1048 sidecar_id: sidecar_id.clone(),
1049 })
1050 }
1051 }
1052}
1053
1054fn wire_connection_ownership(connection_id: &str) -> wire::OwnershipScope {
1055 wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
1056 connection_id: connection_id.to_string(),
1057 })
1058}
1059
1060fn wire_session_ownership(connection_id: &str, session_id: &str) -> wire::OwnershipScope {
1061 wire::OwnershipScope::SessionOwnership(wire::SessionOwnership {
1062 connection_id: connection_id.to_string(),
1063 session_id: session_id.to_string(),
1064 })
1065}
1066
1067fn wire_vm_ownership(connection_id: &str, session_id: &str, vm_id: &str) -> wire::OwnershipScope {
1068 wire::OwnershipScope::VmOwnership(wire::VmOwnership {
1069 connection_id: connection_id.to_string(),
1070 session_id: session_id.to_string(),
1071 vm_id: vm_id.to_string(),
1072 })
1073}
1074
1075fn serialize_create_vm_config_for_sidecar(
1076 config: &AgentOsConfig,
1077) -> Result<vm_config::CreateVmConfig, ClientError> {
1078 let (root_filesystem, native_root) =
1079 serialize_root_filesystem_config_for_sidecar(&config.root_filesystem)?;
1080 Ok(vm_config::CreateVmConfig {
1081 database: config.database.clone(),
1082 cwd: None,
1083 env: BTreeMap::new(),
1084 user: config.user.clone(),
1085 root_filesystem,
1086 permissions: permissions_policy_config(config),
1087 limits: serialize_limits_config_for_sidecar(config.limits.as_ref())?,
1088 dns: None,
1089 native_root,
1090 listen: None,
1091 loopback_exempt_ports: config.loopback_exempt_ports.clone(),
1092 js_runtime: config.allowed_node_builtins.as_ref().map(|allowed| {
1098 vm_config::JsRuntimeConfig {
1099 platform: vm_config::JsRuntimePlatform::default(),
1100 module_resolution: vm_config::JsModuleResolution::default(),
1101 allowed_builtins: Some(allowed.clone()),
1102 high_resolution_time: None,
1103 }
1104 }),
1105 bootstrap_commands: Some(vec![
1106 String::from("node"),
1107 String::from("npm"),
1108 String::from("npx"),
1109 String::from("python"),
1110 String::from("python3"),
1111 ]),
1112 })
1113}
1114
1115fn serialize_root_filesystem_config_for_sidecar(
1116 config: &RootFilesystemConfig,
1117) -> Result<
1118 (
1119 vm_config::RootFilesystemConfig,
1120 Option<vm_config::NativeRootFilesystemConfig>,
1121 ),
1122 ClientError,
1123> {
1124 let mode = match config.mode.unwrap_or(ConfigRootFilesystemMode::Ephemeral) {
1125 ConfigRootFilesystemMode::Ephemeral => vm_config::RootFilesystemMode::Ephemeral,
1126 ConfigRootFilesystemMode::ReadOnly => vm_config::RootFilesystemMode::ReadOnly,
1127 };
1128 match config.kind {
1129 RootFilesystemKind::Overlay => {
1130 if config.native_plugin.is_some() {
1131 return Err(ClientError::Sidecar(
1132 "rootFilesystem.nativePlugin requires type \"native\"".to_string(),
1133 ));
1134 }
1135 let lowers = config
1136 .lowers
1137 .iter()
1138 .map(serialize_root_lower_config_for_sidecar)
1139 .collect::<Result<Vec<_>, _>>()?;
1140 Ok((
1141 vm_config::RootFilesystemConfig {
1142 mode,
1143 disable_default_base_layer: config.disable_default_base_layer,
1144 lowers,
1145 bootstrap_entries: Vec::new(),
1146 },
1147 None,
1148 ))
1149 }
1150 RootFilesystemKind::Native => {
1151 if !config.lowers.is_empty() {
1152 return Err(ClientError::Sidecar(
1153 "native root filesystems do not support rootFilesystem.lowers".to_string(),
1154 ));
1155 }
1156 let plugin = config.native_plugin.as_ref().ok_or_else(|| {
1157 ClientError::Sidecar(
1158 "rootFilesystem.nativePlugin is required for type \"native\"".to_string(),
1159 )
1160 })?;
1161 Ok((
1162 vm_config::RootFilesystemConfig {
1163 mode,
1164 disable_default_base_layer: config.disable_default_base_layer,
1165 lowers: Vec::new(),
1166 bootstrap_entries: Vec::new(),
1167 },
1168 Some(vm_config::NativeRootFilesystemConfig {
1169 plugin: vm_config::MountPluginDescriptor {
1170 id: plugin.id.clone(),
1171 config: plugin
1172 .config
1173 .clone()
1174 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
1175 },
1176 read_only: config.mode == Some(ConfigRootFilesystemMode::ReadOnly),
1177 }),
1178 ))
1179 }
1180 }
1181}
1182
1183fn serialize_root_lower_config_for_sidecar(
1184 lower: &RootLowerInput,
1185) -> Result<vm_config::RootFilesystemLowerDescriptor, ClientError> {
1186 match lower {
1187 RootLowerInput::BundledBaseFilesystem => {
1188 Ok(vm_config::RootFilesystemLowerDescriptor::BundledBaseFilesystem)
1189 }
1190 RootLowerInput::SnapshotExport(snapshot) => {
1191 let entries = snapshot
1192 .source
1193 .filesystem
1194 .entries
1195 .iter()
1196 .map(serialize_filesystem_entry_config_for_sidecar)
1197 .collect::<Result<Vec<_>, _>>()?;
1198 Ok(vm_config::RootFilesystemLowerDescriptor::Snapshot { entries })
1199 }
1200 }
1201}
1202
1203fn serialize_filesystem_entry_config_for_sidecar(
1204 entry: &crate::fs::FilesystemEntry,
1205) -> Result<vm_config::RootFilesystemEntry, ClientError> {
1206 let mode = u32::from_str_radix(entry.mode.trim_start_matches("0o"), 8).map_err(|error| {
1207 ClientError::Sidecar(format!(
1208 "invalid root filesystem mode {} for {}: {error}",
1209 entry.mode, entry.path
1210 ))
1211 })?;
1212 let kind = match entry.entry_type {
1213 crate::fs::DirEntryType::File => vm_config::RootFilesystemEntryKind::File,
1214 crate::fs::DirEntryType::Directory => vm_config::RootFilesystemEntryKind::Directory,
1215 crate::fs::DirEntryType::Symlink => vm_config::RootFilesystemEntryKind::Symlink,
1216 };
1217 let encoding = entry.encoding.map(|encoding| match encoding {
1218 crate::fs::FilesystemEntryEncoding::Utf8 => vm_config::RootFilesystemEntryEncoding::Utf8,
1219 crate::fs::FilesystemEntryEncoding::Base64 => {
1220 vm_config::RootFilesystemEntryEncoding::Base64
1221 }
1222 });
1223
1224 Ok(vm_config::RootFilesystemEntry {
1225 path: entry.path.clone(),
1226 kind,
1227 mode: Some(mode),
1228 uid: Some(entry.uid),
1229 gid: Some(entry.gid),
1230 content: entry.content.clone(),
1231 encoding,
1232 target: entry.target.clone(),
1233 executable: entry.entry_type == crate::fs::DirEntryType::File && (mode & 0o111) != 0,
1234 })
1235}
1236
1237fn serialize_limits_config_for_sidecar(
1238 limits: Option<&AgentOsLimits>,
1239) -> Result<Option<vm_config::VmLimitsConfig>, ClientError> {
1240 let Some(limits) = limits else {
1241 return Ok(None);
1242 };
1243 let value = serde_json::to_value(limits).map_err(|error| {
1244 ClientError::Sidecar(format!("failed to serialize VM limits config: {error}"))
1245 })?;
1246 serde_json::from_value(value).map(Some).map_err(|error| {
1247 ClientError::Sidecar(format!("failed to encode VM limits config: {error}"))
1248 })
1249}
1250
1251fn permissions_policy_config(config: &AgentOsConfig) -> Option<vm_config::PermissionsPolicy> {
1254 let permissions = config.permissions.as_ref()?;
1255 Some(vm_config::PermissionsPolicy {
1256 fs: permissions.fs.as_ref().map(serialize_fs_permissions_config),
1257 network: permissions
1258 .network
1259 .as_ref()
1260 .map(serialize_pattern_permissions_config),
1261 child_process: permissions
1262 .child_process
1263 .as_ref()
1264 .map(serialize_pattern_permissions_config),
1265 process: permissions
1266 .process
1267 .as_ref()
1268 .map(serialize_pattern_permissions_config),
1269 env: permissions
1270 .env
1271 .as_ref()
1272 .map(serialize_pattern_permissions_config),
1273 host_function: permissions
1274 .host_function
1275 .as_ref()
1276 .map(serialize_pattern_permissions_config),
1277 })
1278}
1279
1280fn serialize_fs_permissions_config(
1281 permissions: &crate::config::FsPermissions,
1282) -> vm_config::FsPermissionScope {
1283 match permissions {
1284 crate::config::FsPermissions::Mode(mode) => {
1285 vm_config::FsPermissionScope::Mode(serialize_permission_mode_config(*mode))
1286 }
1287 crate::config::FsPermissions::Rules(rules) => {
1288 vm_config::FsPermissionScope::Rules(vm_config::FsPermissionRuleSet {
1289 default: rules.default.map(serialize_permission_mode_config),
1290 rules: rules
1291 .rules
1292 .iter()
1293 .map(|rule| vm_config::FsPermissionRule {
1294 mode: serialize_permission_mode_config(rule.mode),
1295 operations: operation_wildcard_if_omitted(&rule.operations),
1296 paths: resource_wildcard_if_omitted(&rule.paths),
1297 })
1298 .collect(),
1299 })
1300 }
1301 }
1302}
1303
1304fn serialize_pattern_permissions_config(
1305 permissions: &crate::config::PatternPermissions,
1306) -> vm_config::PatternPermissionScope {
1307 match permissions {
1308 crate::config::PatternPermissions::Mode(mode) => {
1309 vm_config::PatternPermissionScope::Mode(serialize_permission_mode_config(*mode))
1310 }
1311 crate::config::PatternPermissions::Rules(rules) => {
1312 vm_config::PatternPermissionScope::Rules(vm_config::PatternPermissionRuleSet {
1313 default: rules.default.map(serialize_permission_mode_config),
1314 rules: rules
1315 .rules
1316 .iter()
1317 .map(|rule| vm_config::PatternPermissionRule {
1318 mode: serialize_permission_mode_config(rule.mode),
1319 operations: operation_wildcard_if_omitted(&rule.operations),
1320 patterns: resource_wildcard_if_omitted(&rule.patterns),
1321 })
1322 .collect(),
1323 })
1324 }
1325 }
1326}
1327
1328fn serialize_permission_mode_config(
1329 mode: crate::config::PermissionMode,
1330) -> vm_config::PermissionMode {
1331 match mode {
1332 crate::config::PermissionMode::Allow => vm_config::PermissionMode::Allow,
1333 crate::config::PermissionMode::Deny => vm_config::PermissionMode::Deny,
1334 }
1335}
1336
1337async fn wait_for_vm_ready(
1339 events: &mut broadcast::Receiver<(wire::OwnershipScope, wire::EventPayload)>,
1340 vm_id: &str,
1341 timeout_ms: u64,
1342) -> Result<(), ClientError> {
1343 let wait = async {
1344 loop {
1345 match events.recv().await {
1346 Ok((ownership, payload)) => match payload {
1347 wire::EventPayload::VmLifecycleEvent(event) => {
1348 if matches!(event.state, wire::VmLifecycleState::Ready)
1349 && wire_ownership_vm_id(&ownership) == Some(vm_id)
1350 {
1351 return Ok(());
1352 }
1353 }
1354 wire::EventPayload::ProcessOutputEvent(_)
1355 | wire::EventPayload::ProcessExitedEvent(_)
1356 | wire::EventPayload::ExecutionOutputEvent(_)
1357 | wire::EventPayload::ExecutionCompletedEvent(_)
1358 | wire::EventPayload::StructuredEvent(_)
1359 | wire::EventPayload::ExtEnvelope(_) => {}
1360 },
1361 Err(broadcast::error::RecvError::Lagged(_)) => {}
1362 Err(broadcast::error::RecvError::Closed) => {
1363 return Err(ClientError::Sidecar(
1364 "sidecar transport closed before the VM became ready".to_string(),
1365 ));
1366 }
1367 }
1368 }
1369 };
1370 tokio::time::timeout(Duration::from_millis(timeout_ms), wait)
1371 .await
1372 .map_err(|_| {
1373 ClientError::Sidecar("timed out waiting for the VM to become ready".to_string())
1374 })?
1375}
1376
1377static VM_HOST_FUNCTIONS: OnceCell<SccHashMap<String, Arc<VmHostFunctionRegistry>>> =
1380 OnceCell::new();
1381
1382#[derive(Clone)]
1383struct VmHostFunctionRegistry {
1384 host_functions: Vec<ResolvedHostFunctions>,
1385 host_function_map: HashMap<String, ResolvedHostFunction>,
1386}
1387
1388fn vm_host_functions() -> &'static SccHashMap<String, Arc<VmHostFunctionRegistry>> {
1389 VM_HOST_FUNCTIONS.get_or_init(SccHashMap::new)
1390}
1391
1392static VM_ACP_ROUTERS: OnceCell<SccHashMap<String, Weak<AgentOsInner>>> = OnceCell::new();
1396
1397fn vm_acp_routers() -> &'static SccHashMap<String, Weak<AgentOsInner>> {
1398 VM_ACP_ROUTERS.get_or_init(SccHashMap::new)
1399}
1400
1401static SESSION_JS_BRIDGE_CALLBACKS: OnceCell<SccHashMap<String, SidecarJsBridgeCallback>> =
1406 OnceCell::new();
1407
1408fn session_js_bridge_callbacks() -> &'static SccHashMap<String, SidecarJsBridgeCallback> {
1409 SESSION_JS_BRIDGE_CALLBACKS.get_or_init(SccHashMap::new)
1410}
1411
1412fn sidecar_session_key(connection_id: &str, session_id: &str) -> String {
1413 format!("{connection_id}\0{session_id}")
1414}
1415
1416fn wire_ownership_session_key(ownership: &wire::OwnershipScope) -> Option<String> {
1417 match ownership {
1418 wire::OwnershipScope::SessionOwnership(ownership) => Some(sidecar_session_key(
1419 &ownership.connection_id,
1420 &ownership.session_id,
1421 )),
1422 wire::OwnershipScope::VmOwnership(ownership) => Some(sidecar_session_key(
1423 &ownership.connection_id,
1424 &ownership.session_id,
1425 )),
1426 wire::OwnershipScope::ConnectionOwnership(_) => None,
1427 }
1428}
1429
1430fn js_bridge_call_callback() -> WireSidecarCallback {
1431 Arc::new(|payload, ownership| {
1432 Box::pin(async move {
1433 let request = match payload {
1434 wire::SidecarRequestPayload::JsBridgeCallRequest(request) => request,
1435 wire::SidecarRequestPayload::HostCallbackRequest(_) => {
1436 return Ok(wire::SidecarResponsePayload::JsBridgeResultResponse(
1437 wire::JsBridgeResultResponse {
1438 call_id: "unknown".to_string(),
1439 result: None,
1440 error: Some(
1441 "js-bridge callback received a host callback request".to_string(),
1442 ),
1443 },
1444 ));
1445 }
1446 wire::SidecarRequestPayload::ExtEnvelope(_) => {
1447 return Ok(wire::SidecarResponsePayload::JsBridgeResultResponse(
1448 wire::JsBridgeResultResponse {
1449 call_id: "unknown".to_string(),
1450 result: None,
1451 error: Some(
1452 "js-bridge callback received an extension request".to_string(),
1453 ),
1454 },
1455 ));
1456 }
1457 };
1458 Ok(wire::SidecarResponsePayload::JsBridgeResultResponse(
1459 run_js_bridge_callback(&ownership, request).await,
1460 ))
1461 })
1462 })
1463}
1464
1465async fn run_js_bridge_callback(
1466 ownership: &wire::OwnershipScope,
1467 request: wire::JsBridgeCallRequest,
1468) -> wire::JsBridgeResultResponse {
1469 let call_id = request.call_id;
1470 let args = match serde_json::from_str::<Value>(&request.args) {
1471 Ok(args) => args,
1472 Err(error) => {
1473 return wire::JsBridgeResultResponse {
1474 call_id,
1475 result: None,
1476 error: Some(format!("Invalid js_bridge args: {error}")),
1477 };
1478 }
1479 };
1480 let callback = wire_ownership_session_key(ownership)
1481 .and_then(|key| session_js_bridge_callbacks().read(&key, |_, callback| callback.clone()));
1482 let Some(callback) = callback else {
1483 return wire::JsBridgeResultResponse {
1484 call_id,
1485 result: None,
1486 error: Some("No js_bridge callback registered for sidecar session".to_string()),
1487 };
1488 };
1489
1490 let call = SidecarJsBridgeCall {
1491 call_id: call_id.clone(),
1492 mount_id: request.mount_id,
1493 operation: request.operation,
1494 args,
1495 };
1496 match callback(call).await {
1497 Ok(result) => match result {
1498 Some(value) => match serde_json::to_string(&value) {
1499 Ok(result) => wire::JsBridgeResultResponse {
1500 call_id,
1501 result: Some(result),
1502 error: None,
1503 },
1504 Err(error) => wire::JsBridgeResultResponse {
1505 call_id,
1506 result: None,
1507 error: Some(format!("Invalid js_bridge result: {error}")),
1508 },
1509 },
1510 None => wire::JsBridgeResultResponse {
1511 call_id,
1512 result: None,
1513 error: None,
1514 },
1515 },
1516 Err(error) => wire::JsBridgeResultResponse {
1517 call_id,
1518 result: None,
1519 error: Some(error),
1520 },
1521 }
1522}
1523
1524fn acp_host_callback() -> WireSidecarCallback {
1528 Arc::new(|payload, ownership| {
1529 Box::pin(async move {
1530 match payload {
1531 wire::SidecarRequestPayload::ExtEnvelope(envelope) => {
1532 handle_acp_ext_callback(envelope, &ownership)
1533 .await
1534 .map_err(|error| TransportError::Sidecar(error.to_string()))
1535 }
1536 wire::SidecarRequestPayload::HostCallbackRequest(_)
1537 | wire::SidecarRequestPayload::JsBridgeCallRequest(_) => Ok(
1538 wire::SidecarResponsePayload::ExtEnvelope(wire::ExtEnvelope {
1539 namespace: ACP_EXTENSION_NAMESPACE.to_string(),
1540 payload: b"ACP callback received a non-extension request".to_vec(),
1541 }),
1542 ),
1543 }
1544 })
1545 })
1546}
1547
1548async fn handle_acp_ext_callback(
1549 envelope: wire::ExtEnvelope,
1550 ownership: &wire::OwnershipScope,
1551) -> Result<wire::SidecarResponsePayload, ClientError> {
1552 if envelope.namespace != ACP_EXTENSION_NAMESPACE {
1553 return Ok(wire::SidecarResponsePayload::ExtEnvelope(
1554 wire::ExtEnvelope {
1555 namespace: envelope.namespace,
1556 payload: b"unknown extension namespace".to_vec(),
1557 },
1558 ));
1559 }
1560 let callback: AcpCallback = serde_bare::from_slice(&envelope.payload)
1561 .map_err(|error| ClientError::Sidecar(format!("invalid ACP callback: {error}")))?;
1562 let response = match callback {
1563 AcpCallback::AcpHostRequestCallback(callback) => {
1564 let response = dispatch_acp_host_request(ownership, &callback.request).await;
1565 AcpCallbackResponse::AcpHostRequestCallbackResponse(AcpHostRequestCallbackResponse {
1566 response: Some(response),
1567 })
1568 }
1569 };
1570 let payload = serde_bare::to_vec(&response).map_err(|error| {
1571 ClientError::Sidecar(format!("failed to encode ACP callback response: {error}"))
1572 })?;
1573 Ok(wire::SidecarResponsePayload::ExtEnvelope(
1574 wire::ExtEnvelope {
1575 namespace: ACP_EXTENSION_NAMESPACE.to_string(),
1576 payload,
1577 },
1578 ))
1579}
1580
1581const ACP_TERMINAL_DEFAULT_OUTPUT_BYTE_LIMIT: usize = 1_048_576;
1588
1589struct AcpDispatchError {
1591 code: i64,
1592 message: String,
1593 data: Option<Value>,
1594}
1595
1596impl AcpDispatchError {
1597 fn new(code: i64, message: impl Into<String>) -> Self {
1598 Self {
1599 code,
1600 message: message.into(),
1601 data: None,
1602 }
1603 }
1604
1605 fn with_data(code: i64, message: impl Into<String>, data: Value) -> Self {
1606 Self {
1607 code,
1608 message: message.into(),
1609 data: Some(data),
1610 }
1611 }
1612}
1613
1614impl From<ClientError> for AcpDispatchError {
1615 fn from(error: ClientError) -> Self {
1616 match error {
1617 ClientError::Kernel { code, message } => {
1620 AcpDispatchError::with_data(-32603, message, serde_json::json!({ "code": code }))
1621 }
1622 other => AcpDispatchError::new(-32603, other.to_string()),
1623 }
1624 }
1625}
1626
1627impl From<anyhow::Error> for AcpDispatchError {
1628 fn from(error: anyhow::Error) -> Self {
1629 match error.downcast::<ClientError>() {
1632 Ok(client_error) => client_error.into(),
1633 Err(error) => AcpDispatchError::new(-32603, error.to_string()),
1634 }
1635 }
1636}
1637
1638async fn dispatch_acp_host_request(ownership: &wire::OwnershipScope, request: &str) -> String {
1642 let parsed = serde_json::from_str::<Value>(request);
1643 let (id, method, params_value) = match parsed {
1644 Ok(value) => {
1645 let id = value.get("id").cloned().unwrap_or(Value::Null);
1646 let method = value
1647 .get("method")
1648 .and_then(Value::as_str)
1649 .map(str::to_string);
1650 (id, method, value.get("params").cloned())
1651 }
1652 Err(error) => {
1653 return acp_error_response(Value::Null, -32700, &format!("Parse error: {error}"), None);
1654 }
1655 };
1656
1657 let Some(method) = method else {
1658 return acp_error_response(id, -32600, "Invalid Request: missing method", None);
1659 };
1660
1661 match handle_acp_host_request(ownership, &method, params_value).await {
1662 Ok(result) => serde_json::to_string(&serde_json::json!({
1663 "jsonrpc": "2.0",
1664 "id": id,
1665 "result": result,
1666 }))
1667 .unwrap_or_else(|error| acp_error_response(Value::Null, -32603, &error.to_string(), None)),
1668 Err(error) => acp_error_response(id, error.code, &error.message, error.data),
1669 }
1670}
1671
1672fn acp_error_response(id: Value, code: i64, message: &str, data: Option<Value>) -> String {
1673 let mut error = serde_json::json!({
1674 "code": code,
1675 "message": message,
1676 });
1677 if let Some(data) = data {
1678 if let Some(map) = error.as_object_mut() {
1679 map.insert("data".to_string(), data);
1680 }
1681 }
1682 serde_json::to_string(&serde_json::json!({
1683 "jsonrpc": "2.0",
1684 "id": id,
1685 "error": error,
1686 }))
1687 .unwrap_or_else(|_| {
1688 String::from(r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"failed to encode error response"}}"#)
1689 })
1690}
1691
1692fn resolve_acp_agent(ownership: &wire::OwnershipScope) -> Result<AgentOs, AcpDispatchError> {
1694 let vm_id = wire_ownership_vm_id(ownership).unwrap_or("");
1695 let inner = vm_acp_routers()
1696 .read(vm_id, |_, weak| weak.clone())
1697 .and_then(|weak| weak.upgrade());
1698 inner
1699 .map(|inner| AgentOs { inner })
1700 .ok_or_else(|| AcpDispatchError::new(-32603, "VM is no longer available"))
1701}
1702
1703async fn handle_acp_host_request(
1706 ownership: &wire::OwnershipScope,
1707 method: &str,
1708 params_value: Option<Value>,
1709) -> Result<Value, AcpDispatchError> {
1710 let params = acp_params(method, params_value)?;
1711 match method {
1712 "fs/read" | "fs/read_text_file" => {
1713 let agent = resolve_acp_agent(ownership)?;
1714 handle_acp_read_file(&agent, ¶ms).await
1715 }
1716 "fs/write" | "fs/write_text_file" => {
1717 let agent = resolve_acp_agent(ownership)?;
1718 handle_acp_write_file(&agent, ¶ms).await
1719 }
1720 "fs/readDir" | "fs/read_dir" => {
1721 let agent = resolve_acp_agent(ownership)?;
1722 handle_acp_read_dir(&agent, ¶ms).await
1723 }
1724 "terminal/create" => {
1725 let agent = resolve_acp_agent(ownership)?;
1726 handle_acp_create_terminal(&agent, ¶ms)
1727 }
1728 "terminal/write" => {
1729 let agent = resolve_acp_agent(ownership)?;
1730 handle_acp_write_terminal(&agent, ¶ms)
1731 }
1732 "terminal/output" | "terminal/read" => {
1733 let agent = resolve_acp_agent(ownership)?;
1734 handle_acp_read_terminal(&agent, ¶ms)
1735 }
1736 "terminal/wait_for_exit" | "terminal/waitForExit" => {
1737 let agent = resolve_acp_agent(ownership)?;
1738 handle_acp_wait_for_terminal_exit(&agent, ¶ms).await
1739 }
1740 "terminal/kill" => {
1741 let agent = resolve_acp_agent(ownership)?;
1742 handle_acp_kill_terminal(&agent, ¶ms)
1743 }
1744 "terminal/release" | "terminal/close" => {
1745 let agent = resolve_acp_agent(ownership)?;
1746 handle_acp_release_terminal(&agent, ¶ms)
1747 }
1748 "terminal/resize" => {
1749 let agent = resolve_acp_agent(ownership)?;
1750 handle_acp_resize_terminal(&agent, ¶ms)
1751 }
1752 other => Err(AcpDispatchError::with_data(
1753 -32601,
1754 format!("Method not found: {other}"),
1755 serde_json::json!({ "method": other }),
1756 )),
1757 }
1758}
1759
1760fn acp_params(
1763 method: &str,
1764 params_value: Option<Value>,
1765) -> Result<Map<String, Value>, AcpDispatchError> {
1766 match params_value {
1767 None | Some(Value::Null) => Ok(Map::new()),
1768 Some(Value::Object(map)) => Ok(map),
1769 Some(_) => Err(AcpDispatchError::new(
1770 -32602,
1771 format!("{method} requires object params"),
1772 )),
1773 }
1774}
1775
1776fn require_acp_string(
1777 params: &Map<String, Value>,
1778 name: &str,
1779 method: &str,
1780) -> Result<String, AcpDispatchError> {
1781 match params.get(name).and_then(Value::as_str) {
1782 Some(value) => Ok(value.to_string()),
1783 None => Err(AcpDispatchError::new(
1784 -32602,
1785 format!("{method} requires a string {name}"),
1786 )),
1787 }
1788}
1789
1790fn optional_acp_string(
1791 params: &Map<String, Value>,
1792 name: &str,
1793 method: &str,
1794) -> Result<Option<String>, AcpDispatchError> {
1795 match params.get(name) {
1796 None | Some(Value::Null) => Ok(None),
1797 Some(Value::String(value)) => Ok(Some(value.clone())),
1798 Some(_) => Err(AcpDispatchError::new(
1799 -32602,
1800 format!("{method} requires {name} to be a string when provided"),
1801 )),
1802 }
1803}
1804
1805fn optional_acp_number(
1806 params: &Map<String, Value>,
1807 name: &str,
1808 method: &str,
1809) -> Result<Option<f64>, AcpDispatchError> {
1810 match params.get(name) {
1811 None | Some(Value::Null) => Ok(None),
1812 Some(value) => match value.as_f64() {
1813 Some(number) if number.is_finite() => Ok(Some(number)),
1814 _ => Err(AcpDispatchError::new(
1815 -32602,
1816 format!("{method} requires {name} to be a number when provided"),
1817 )),
1818 },
1819 }
1820}
1821
1822fn optional_acp_string_array(
1823 params: &Map<String, Value>,
1824 name: &str,
1825 method: &str,
1826) -> Result<Option<Vec<String>>, AcpDispatchError> {
1827 match params.get(name) {
1828 None | Some(Value::Null) => Ok(None),
1829 Some(Value::Array(items)) => {
1830 let mut out = Vec::with_capacity(items.len());
1831 for item in items {
1832 match item.as_str() {
1833 Some(value) => out.push(value.to_string()),
1834 None => {
1835 return Err(AcpDispatchError::new(
1836 -32602,
1837 format!(
1838 "{method} requires {name} to be an array of strings when provided"
1839 ),
1840 ))
1841 }
1842 }
1843 }
1844 Ok(Some(out))
1845 }
1846 Some(_) => Err(AcpDispatchError::new(
1847 -32602,
1848 format!("{method} requires {name} to be an array of strings when provided"),
1849 )),
1850 }
1851}
1852
1853fn optional_acp_env(
1856 params: &Map<String, Value>,
1857 name: &str,
1858 method: &str,
1859) -> Result<Option<BTreeMap<String, String>>, AcpDispatchError> {
1860 match params.get(name) {
1861 None | Some(Value::Null) => Ok(None),
1862 Some(Value::Array(items)) => {
1863 let mut env = BTreeMap::new();
1864 for entry in items {
1865 let Some(record) = entry.as_object() else {
1866 return Err(AcpDispatchError::new(
1867 -32602,
1868 format!("{method} requires {name} entries to be {{ name, value }} objects"),
1869 ));
1870 };
1871 match (
1872 record.get("name").and_then(Value::as_str),
1873 record.get("value").and_then(Value::as_str),
1874 ) {
1875 (Some(key), Some(value)) => {
1876 env.insert(key.to_string(), value.to_string());
1877 }
1878 _ => {
1879 return Err(AcpDispatchError::new(
1880 -32602,
1881 format!(
1882 "{method} requires {name} entries to be {{ name, value }} objects"
1883 ),
1884 ))
1885 }
1886 }
1887 }
1888 Ok(Some(env))
1889 }
1890 Some(Value::Object(map)) => {
1891 let mut env = BTreeMap::new();
1892 for (key, value) in map {
1893 match value.as_str() {
1894 Some(value) => {
1895 env.insert(key.clone(), value.to_string());
1896 }
1897 None => {
1898 return Err(AcpDispatchError::new(
1899 -32602,
1900 format!("{method} requires {name} values to be strings"),
1901 ))
1902 }
1903 }
1904 }
1905 Ok(Some(env))
1906 }
1907 Some(_) => Err(AcpDispatchError::new(
1908 -32602,
1909 format!("{method} requires {name} to be an object or name/value array"),
1910 )),
1911 }
1912}
1913
1914async fn handle_acp_read_file(
1917 agent: &AgentOs,
1918 params: &Map<String, Value>,
1919) -> Result<Value, AcpDispatchError> {
1920 let method = "fs/read";
1921 let path = require_acp_string(params, "path", method)?;
1922 let line = optional_acp_number(params, "line", method)?;
1923 let limit = optional_acp_number(params, "limit", method)?;
1924 let encoding = optional_acp_string(params, "encoding", method)?;
1925 let bytes = agent.read_file(&path).await?;
1926 if encoding.as_deref() == Some("base64") {
1927 use base64::engine::general_purpose::STANDARD as BASE64;
1928 use base64::Engine as _;
1929 return Ok(serde_json::json!({ "content": BASE64.encode(&bytes) }));
1930 }
1931 let text = String::from_utf8_lossy(&bytes).into_owned();
1932 if line.is_none() && limit.is_none() {
1933 return Ok(serde_json::json!({ "content": text }));
1934 }
1935 let start_line = line.map(|n| n.trunc() as i64).unwrap_or(1).max(1);
1936 let lines: Vec<&str> = text.split('\n').collect();
1937 let start_index = (start_line - 1).max(0) as usize;
1938 let selected: Vec<&str> = match limit {
1939 None => lines.into_iter().skip(start_index).collect(),
1940 Some(limit) => {
1941 let limit = limit.trunc().max(0.0) as usize;
1942 lines.into_iter().skip(start_index).take(limit).collect()
1943 }
1944 };
1945 Ok(serde_json::json!({ "content": selected.join("\n") }))
1946}
1947
1948async fn handle_acp_write_file(
1949 agent: &AgentOs,
1950 params: &Map<String, Value>,
1951) -> Result<Value, AcpDispatchError> {
1952 let method = "fs/write";
1953 let path = require_acp_string(params, "path", method)?;
1954 let content = require_acp_string(params, "content", method)?;
1955 let encoding = optional_acp_string(params, "encoding", method)?;
1956 if encoding.as_deref() == Some("base64") {
1957 use base64::engine::general_purpose::STANDARD as BASE64;
1958 use base64::Engine as _;
1959 let decoded = BASE64.decode(content.as_bytes()).map_err(|error| {
1960 AcpDispatchError::new(
1961 -32602,
1962 format!("{method} content is not valid base64: {error}"),
1963 )
1964 })?;
1965 agent.write_file(&path, decoded).await?;
1966 } else {
1967 agent.write_file(&path, content).await?;
1968 }
1969 Ok(Value::Null)
1970}
1971
1972async fn handle_acp_read_dir(
1973 agent: &AgentOs,
1974 params: &Map<String, Value>,
1975) -> Result<Value, AcpDispatchError> {
1976 let method = "fs/readDir";
1977 let path = require_acp_string(params, "path", method)?;
1978 let entries = agent.acp_read_dir_with_types(&path).await?;
1979 let mapped: Vec<Value> = entries
1980 .into_iter()
1981 .map(|entry| {
1982 let child_path = if path == "/" {
1983 format!("/{}", entry.name)
1984 } else {
1985 format!("{path}/{}", entry.name)
1986 };
1987 let entry_type = if entry.is_symbolic_link {
1988 "symlink"
1989 } else if entry.is_directory {
1990 "directory"
1991 } else {
1992 "file"
1993 };
1994 serde_json::json!({
1995 "name": entry.name,
1996 "path": child_path,
1997 "type": entry_type,
1998 })
1999 })
2000 .collect();
2001 Ok(serde_json::json!({ "entries": mapped }))
2002}
2003
2004fn require_acp_terminal_id(
2007 params: &Map<String, Value>,
2008 method: &str,
2009) -> Result<String, AcpDispatchError> {
2010 require_acp_string(params, "terminalId", method)
2011}
2012
2013fn handle_acp_create_terminal(
2014 agent: &AgentOs,
2015 params: &Map<String, Value>,
2016) -> Result<Value, AcpDispatchError> {
2017 let method = "terminal/create";
2018 let command = require_acp_string(params, "command", method)?;
2019 let args = optional_acp_string_array(params, "args", method)?;
2020 let env = optional_acp_env(params, "env", method)?;
2021 let cwd = optional_acp_string(params, "cwd", method)?;
2022 let cols = optional_acp_number(params, "cols", method)?;
2023 let rows = optional_acp_number(params, "rows", method)?;
2024 let output_byte_limit = optional_acp_number(params, "outputByteLimit", method)?
2025 .map(|n| n.trunc().max(0.0) as usize)
2026 .unwrap_or(ACP_TERMINAL_DEFAULT_OUTPUT_BYTE_LIMIT);
2027
2028 let counter = agent
2029 .inner()
2030 .host_acp_terminal_counter
2031 .fetch_add(1, Ordering::SeqCst)
2032 + 1;
2033 let terminal_id = format!("acp-terminal-{counter}");
2034
2035 let output = Arc::new(parking_lot::Mutex::new(HostAcpTerminalOutput {
2036 buffer: String::new(),
2037 truncated: false,
2038 output_byte_limit,
2039 }));
2040 let (exit_tx, exit_rx) = watch::channel::<Option<i32>>(None);
2041
2042 let mut shell_options = crate::shell::OpenShellOptions {
2045 command: Some(command),
2046 cwd,
2047 ..Default::default()
2048 };
2049 if let Some(args) = args {
2050 shell_options.args = args;
2051 }
2052 if let Some(env) = env {
2053 shell_options.env = env;
2054 }
2055 if let Some(cols) = cols {
2056 shell_options.cols = Some(cols.trunc() as u16);
2057 }
2058 if let Some(rows) = rows {
2059 shell_options.rows = Some(rows.trunc() as u16);
2060 }
2061 let buffer_sink = output.clone();
2064 let handle = agent
2065 .acp_open_terminal(shell_options, exit_tx, move |data: &[u8]| {
2066 append_acp_terminal_output(&buffer_sink, data);
2067 })
2068 .map_err(|error| AcpDispatchError::new(-32603, error.to_string()))?;
2069 let shell_id = handle.shell_id.clone();
2070
2071 let entry = HostAcpTerminal {
2072 shell_id,
2073 output,
2074 exit_rx,
2075 };
2076 if agent
2077 .inner()
2078 .host_acp_terminals
2079 .insert(terminal_id.clone(), entry)
2080 .is_err()
2081 {
2082 return Err(AcpDispatchError::new(
2083 -32603,
2084 format!("ACP terminal id collision: {terminal_id}"),
2085 ));
2086 }
2087
2088 Ok(serde_json::json!({ "terminalId": terminal_id }))
2089}
2090
2091fn append_acp_terminal_output(
2092 output: &Arc<parking_lot::Mutex<HostAcpTerminalOutput>>,
2093 data: &[u8],
2094) {
2095 let chunk = String::from_utf8_lossy(data);
2096 if chunk.is_empty() {
2097 return;
2098 }
2099 let mut state = output.lock();
2100 state.buffer.push_str(&chunk);
2101 let limit = state.output_byte_limit;
2102 if state.buffer.len() > limit {
2103 let overflow = state.buffer.len() - limit;
2106 let mut cut = overflow;
2107 while cut < state.buffer.len() && !state.buffer.is_char_boundary(cut) {
2108 cut += 1;
2109 }
2110 state.buffer = state.buffer.split_off(cut);
2111 state.truncated = true;
2112 }
2113}
2114
2115fn handle_acp_write_terminal(
2116 agent: &AgentOs,
2117 params: &Map<String, Value>,
2118) -> Result<Value, AcpDispatchError> {
2119 let method = "terminal/write";
2120 let terminal_id = require_acp_terminal_id(params, method)?;
2121 let shell_id = acp_terminal_shell_id(agent, &terminal_id)?;
2122 let data = require_acp_string(params, "data", method)?;
2123 let encoding = optional_acp_string(params, "encoding", method)?;
2124 let input = if encoding.as_deref() == Some("base64") {
2125 use base64::engine::general_purpose::STANDARD as BASE64;
2126 use base64::Engine as _;
2127 let decoded = BASE64.decode(data.as_bytes()).map_err(|error| {
2128 AcpDispatchError::new(
2129 -32602,
2130 format!("{method} data is not valid base64: {error}"),
2131 )
2132 })?;
2133 crate::process::StdinInput::Bytes(decoded)
2134 } else {
2135 crate::process::StdinInput::Text(data)
2136 };
2137 agent
2138 .write_shell(&shell_id, input)
2139 .map_err(|error| AcpDispatchError::new(-32603, error.to_string()))?;
2140 Ok(Value::Null)
2141}
2142
2143fn handle_acp_read_terminal(
2144 agent: &AgentOs,
2145 params: &Map<String, Value>,
2146) -> Result<Value, AcpDispatchError> {
2147 let method = "terminal/output";
2148 let terminal_id = require_acp_terminal_id(params, method)?;
2149 agent
2150 .inner()
2151 .host_acp_terminals
2152 .read(&terminal_id, |_, terminal| {
2153 let (output, truncated) = {
2154 let state = terminal.output.lock();
2155 (state.buffer.clone(), state.truncated)
2156 };
2157 let mut result = serde_json::json!({
2158 "output": output,
2159 "truncated": truncated,
2160 });
2161 if let Some(exit_code) = *terminal.exit_rx.borrow() {
2162 if let Some(map) = result.as_object_mut() {
2163 map.insert(
2164 "exitStatus".to_string(),
2165 serde_json::json!({ "exitCode": exit_code, "signal": Value::Null }),
2166 );
2167 }
2168 }
2169 result
2170 })
2171 .ok_or_else(|| {
2172 AcpDispatchError::new(-32602, format!("ACP terminal not found: {terminal_id}"))
2173 })
2174}
2175
2176async fn handle_acp_wait_for_terminal_exit(
2177 agent: &AgentOs,
2178 params: &Map<String, Value>,
2179) -> Result<Value, AcpDispatchError> {
2180 let method = "terminal/wait_for_exit";
2181 let terminal_id = require_acp_terminal_id(params, method)?;
2182 let mut exit_rx = agent
2183 .inner()
2184 .host_acp_terminals
2185 .read(&terminal_id, |_, terminal| terminal.exit_rx.clone())
2186 .ok_or_else(|| {
2187 AcpDispatchError::new(-32602, format!("ACP terminal not found: {terminal_id}"))
2188 })?;
2189 let exit_code = loop {
2190 if let Some(code) = *exit_rx.borrow() {
2191 break code;
2192 }
2193 if exit_rx.changed().await.is_err() {
2194 break exit_rx.borrow().unwrap_or(1);
2198 }
2199 };
2200 Ok(serde_json::json!({ "exitCode": exit_code, "signal": Value::Null }))
2201}
2202
2203fn handle_acp_kill_terminal(
2204 agent: &AgentOs,
2205 params: &Map<String, Value>,
2206) -> Result<Value, AcpDispatchError> {
2207 let method = "terminal/kill";
2208 let terminal_id = require_acp_terminal_id(params, method)?;
2209 let shell_id = acp_terminal_shell_id(agent, &terminal_id)?;
2210 agent
2215 .acp_kill_terminal_shell(&shell_id)
2216 .map_err(|error| AcpDispatchError::new(-32603, error.to_string()))?;
2217 Ok(Value::Null)
2218}
2219
2220fn handle_acp_release_terminal(
2221 agent: &AgentOs,
2222 params: &Map<String, Value>,
2223) -> Result<Value, AcpDispatchError> {
2224 let method = "terminal/release";
2225 let terminal_id = require_acp_terminal_id(params, method)?;
2226 let Some((_, terminal)) = agent.inner().host_acp_terminals.remove(&terminal_id) else {
2227 return Err(AcpDispatchError::new(
2228 -32602,
2229 format!("ACP terminal not found: {terminal_id}"),
2230 ));
2231 };
2232 if terminal.exit_rx.borrow().is_none() {
2234 let _ = agent.acp_kill_terminal_shell(&terminal.shell_id);
2235 }
2236 let _ = agent.close_shell(&terminal.shell_id);
2238 Ok(Value::Null)
2239}
2240
2241fn handle_acp_resize_terminal(
2242 agent: &AgentOs,
2243 params: &Map<String, Value>,
2244) -> Result<Value, AcpDispatchError> {
2245 let method = "terminal/resize";
2246 let terminal_id = require_acp_terminal_id(params, method)?;
2247 let shell_id = acp_terminal_shell_id(agent, &terminal_id)?;
2248 let cols = optional_acp_number(params, "cols", method)?;
2249 let rows = optional_acp_number(params, "rows", method)?;
2250 let (Some(cols), Some(rows)) = (cols, rows) else {
2251 return Err(AcpDispatchError::new(
2252 -32602,
2253 format!("{method} requires numeric cols and rows"),
2254 ));
2255 };
2256 agent
2257 .resize_shell(&shell_id, cols.trunc() as u16, rows.trunc() as u16)
2258 .map_err(|error| AcpDispatchError::new(-32603, error.to_string()))?;
2259 Ok(Value::Null)
2260}
2261
2262fn acp_terminal_shell_id(agent: &AgentOs, terminal_id: &str) -> Result<String, AcpDispatchError> {
2264 agent
2265 .inner()
2266 .host_acp_terminals
2267 .read(terminal_id, |_, terminal| terminal.shell_id.clone())
2268 .ok_or_else(|| {
2269 AcpDispatchError::new(-32602, format!("ACP terminal not found: {terminal_id}"))
2270 })
2271}
2272
2273fn host_callback_callback() -> WireSidecarCallback {
2275 Arc::new(|payload, ownership| {
2276 Box::pin(async move {
2277 let request = match payload {
2278 wire::SidecarRequestPayload::HostCallbackRequest(request) => request,
2279 wire::SidecarRequestPayload::JsBridgeCallRequest(_) => {
2280 return Ok(wire::SidecarResponsePayload::HostCallbackResultResponse(
2281 wire::HostCallbackResultResponse {
2282 invocation_id: "unknown".to_string(),
2283 result: None,
2284 error: Some(
2285 "host callback received a non-host-function request".to_string(),
2286 ),
2287 },
2288 ));
2289 }
2290 wire::SidecarRequestPayload::ExtEnvelope(envelope) => {
2291 return Ok(wire::SidecarResponsePayload::ExtEnvelope(
2292 wire::ExtEnvelope {
2293 namespace: envelope.namespace,
2294 payload: b"host-callback received an extension request".to_vec(),
2295 },
2296 ));
2297 }
2298 };
2299 Ok(wire::SidecarResponsePayload::HostCallbackResultResponse(
2300 run_host_callback(&ownership, request).await,
2301 ))
2302 })
2303 })
2304}
2305
2306async fn run_host_callback(
2309 ownership: &wire::OwnershipScope,
2310 request: wire::HostCallbackRequest,
2311) -> wire::HostCallbackResultResponse {
2312 let input = match serde_json::from_str::<Value>(&request.input) {
2313 Ok(input) => input,
2314 Err(error) => {
2315 return wire::HostCallbackResultResponse {
2316 invocation_id: request.invocation_id,
2317 result: None,
2318 error: Some(format!("Invalid host callback input: {error}")),
2319 };
2320 }
2321 };
2322 let vm_id = wire_ownership_vm_id(ownership).unwrap_or("");
2323 let registry = vm_host_functions().read(vm_id, |_, registry| registry.clone());
2324 let Some(registry) = registry else {
2325 return wire::HostCallbackResultResponse {
2326 invocation_id: request.invocation_id,
2327 result: None,
2328 error: Some(format!(
2329 "Unknown host function \"{}\"",
2330 request.callback_key
2331 )),
2332 };
2333 };
2334
2335 if let Some(command) = parse_host_command_callback_input(&input) {
2336 return match run_host_command_callback(ownership, registry.as_ref(), command).await {
2337 Ok(value) => match host_callback_json_result(value) {
2338 Ok(result) => wire::HostCallbackResultResponse {
2339 invocation_id: request.invocation_id,
2340 result: Some(result),
2341 error: None,
2342 },
2343 Err(error) => wire::HostCallbackResultResponse {
2344 invocation_id: request.invocation_id,
2345 result: None,
2346 error: Some(error),
2347 },
2348 },
2349 Err(error) => wire::HostCallbackResultResponse {
2350 invocation_id: request.invocation_id,
2351 result: None,
2352 error: Some(error),
2353 },
2354 };
2355 }
2356
2357 let host_function = registry
2358 .host_function_map
2359 .get(&request.callback_key)
2360 .cloned();
2361 let Some(host_function) = host_function else {
2362 return wire::HostCallbackResultResponse {
2363 invocation_id: request.invocation_id,
2364 result: None,
2365 error: Some(format!(
2366 "Unknown host function \"{}\"",
2367 request.callback_key
2368 )),
2369 };
2370 };
2371 let timeout = Duration::from_millis(request.timeout_ms.max(1));
2372 match tokio::time::timeout(timeout, (host_function.execute)(input)).await {
2373 Ok(Ok(value)) => match host_callback_json_result(value) {
2374 Ok(result) => wire::HostCallbackResultResponse {
2375 invocation_id: request.invocation_id,
2376 result: Some(result),
2377 error: None,
2378 },
2379 Err(error) => wire::HostCallbackResultResponse {
2380 invocation_id: request.invocation_id,
2381 result: None,
2382 error: Some(error),
2383 },
2384 },
2385 Ok(Err(error)) => wire::HostCallbackResultResponse {
2386 invocation_id: request.invocation_id,
2387 result: None,
2388 error: Some(error),
2389 },
2390 Err(_) => wire::HostCallbackResultResponse {
2391 invocation_id: request.invocation_id,
2392 result: None,
2393 error: Some(format!(
2394 "Host function \"{}\" timed out after {}ms",
2395 request.callback_key, request.timeout_ms
2396 )),
2397 },
2398 }
2399}
2400
2401#[derive(Debug, Deserialize)]
2402struct HostCommandCallbackInput {
2403 #[serde(rename = "type")]
2404 kind: String,
2405 command: String,
2406 #[serde(default)]
2407 args: Vec<String>,
2408 cwd: String,
2409}
2410
2411fn parse_host_command_callback_input(input: &Value) -> Option<HostCommandCallbackInput> {
2412 let command = serde_json::from_value::<HostCommandCallbackInput>(input.clone()).ok()?;
2413 if command.kind == "command" {
2414 Some(command)
2415 } else {
2416 None
2417 }
2418}
2419
2420async fn run_host_command_callback(
2421 ownership: &wire::OwnershipScope,
2422 registry: &VmHostFunctionRegistry,
2423 command: HostCommandCallbackInput,
2424) -> Result<Value, String> {
2425 if command.command == "agentos" {
2426 return handle_agentos_registry_command(ownership, registry, &command).await;
2427 }
2428 let Some(collection) = registry
2429 .host_functions
2430 .iter()
2431 .find(|collection| format!("agentos-{}", collection.name) == command.command)
2432 else {
2433 return Err(format!(
2434 "Unknown host callback command \"{}\"",
2435 command.command
2436 ));
2437 };
2438 handle_agentos_host_function_command(ownership, registry, &command, collection).await
2439}
2440
2441async fn handle_agentos_registry_command(
2442 ownership: &wire::OwnershipScope,
2443 registry: &VmHostFunctionRegistry,
2444 command: &HostCommandCallbackInput,
2445) -> Result<Value, String> {
2446 let Some(subcommand) = command.args.first() else {
2447 return Ok(json_object([(
2448 "usage",
2449 Value::String(String::from(
2450 "agentos <command>: list-host-functions [collection], <collection> --help, or <collection> <function> ...",
2451 )),
2452 )]));
2453 };
2454 if is_help_flag(subcommand) {
2455 return Ok(json_object([(
2456 "usage",
2457 Value::String(String::from(
2458 "agentos <command>: list-host-functions [collection], <collection> --help, or <collection> <function> ...",
2459 )),
2460 )]));
2461 }
2462 if subcommand == "list-host-functions" || subcommand == "list-bindings" {
2463 return match command.args.get(1) {
2464 Some(collection_name) => {
2465 describe_host_functions_payload(®istry.host_functions, collection_name)
2466 }
2467 None => Ok(list_host_functions_payload(®istry.host_functions)),
2468 };
2469 }
2470
2471 let Some(collection) = registry
2472 .host_functions
2473 .iter()
2474 .find(|collection| collection.name == *subcommand)
2475 else {
2476 return Err(format!(
2477 "No host function collection \"{subcommand}\". Available: {}",
2478 host_functions_names(®istry.host_functions)
2479 ));
2480 };
2481
2482 let Some(host_function_name) = command.args.get(1) else {
2483 return describe_host_functions_payload(®istry.host_functions, subcommand);
2484 };
2485 if is_help_flag(host_function_name) {
2486 return describe_host_functions_payload(®istry.host_functions, subcommand);
2487 }
2488 if command.args.get(2).is_some_and(|value| is_help_flag(value)) {
2489 return describe_host_function_payload(collection, host_function_name);
2490 }
2491 invoke_host_function(
2492 ownership,
2493 registry,
2494 collection,
2495 host_function_name,
2496 command.args.get(2..).unwrap_or_default(),
2497 &command.cwd,
2498 )
2499 .await
2500}
2501
2502async fn handle_agentos_host_function_command(
2503 ownership: &wire::OwnershipScope,
2504 registry: &VmHostFunctionRegistry,
2505 command: &HostCommandCallbackInput,
2506 collection: &ResolvedHostFunctions,
2507) -> Result<Value, String> {
2508 let Some(host_function_name) = command.args.first() else {
2509 return describe_host_functions_payload(®istry.host_functions, &collection.name);
2510 };
2511 if is_help_flag(host_function_name) {
2512 return describe_host_functions_payload(®istry.host_functions, &collection.name);
2513 }
2514 if command.args.get(1).is_some_and(|value| is_help_flag(value)) {
2515 return describe_host_function_payload(collection, host_function_name);
2516 }
2517 invoke_host_function(
2518 ownership,
2519 registry,
2520 collection,
2521 host_function_name,
2522 command.args.get(1..).unwrap_or_default(),
2523 &command.cwd,
2524 )
2525 .await
2526}
2527
2528async fn invoke_host_function(
2529 ownership: &wire::OwnershipScope,
2530 registry: &VmHostFunctionRegistry,
2531 collection: &ResolvedHostFunctions,
2532 host_function_name: &str,
2533 args: &[String],
2534 cwd: &str,
2535) -> Result<Value, String> {
2536 let callback_key = format!("{}:{host_function_name}", collection.name);
2537 let Some(host_function) = registry.host_function_map.get(&callback_key).cloned() else {
2538 return Err(format!(
2539 "No host function \"{host_function_name}\" in collection \"{}\". Available: {}",
2540 collection.name,
2541 host_function_names(collection)
2542 ));
2543 };
2544
2545 let input = parse_host_function_input(ownership, &host_function, args, cwd).await?;
2549 validate_host_function_input(&host_function.input_schema, &input)
2550 .map_err(|error| error.to_string())?;
2551
2552 let timeout = Duration::from_millis(host_function.timeout_ms.unwrap_or(30_000).max(1));
2553 match tokio::time::timeout(timeout, (host_function.execute)(input)).await {
2554 Ok(Ok(value)) => Ok(value),
2555 Ok(Err(error)) => Err(error),
2556 Err(_) => Err(format!(
2557 "Host function \"{callback_key}\" timed out after {}ms",
2558 host_function.timeout_ms.unwrap_or(30_000)
2559 )),
2560 }
2561}
2562
2563async fn parse_host_function_input(
2564 ownership: &wire::OwnershipScope,
2565 host_function: &ResolvedHostFunction,
2566 args: &[String],
2567 cwd: &str,
2568) -> Result<Value, String> {
2569 if args.first().is_some_and(|arg| arg == "--json") {
2570 let value = args
2571 .get(1)
2572 .ok_or_else(|| String::from("Flag --json requires a value"))?;
2573 return serde_json::from_str(value)
2574 .map_err(|error| format!("Invalid JSON for --json: {error}"));
2575 }
2576
2577 if args.first().is_some_and(|arg| arg == "--json-file") {
2578 let path = args
2579 .get(1)
2580 .ok_or_else(|| String::from("Flag --json-file requires a value"))?;
2581 let guest_path = normalize_guest_path(if path.starts_with('/') {
2582 path.clone()
2583 } else {
2584 format!("{cwd}/{path}")
2585 });
2586 let vm_id = wire_ownership_vm_id(ownership).unwrap_or("");
2587 let inner = vm_acp_routers()
2588 .read(vm_id, |_, weak| weak.clone())
2589 .and_then(|weak| weak.upgrade())
2590 .ok_or_else(|| String::from("Invalid JSON file: VM is no longer available"))?;
2591 let bytes = AgentOs { inner }
2592 .read_file(&guest_path)
2593 .await
2594 .map_err(|error| format!("Invalid JSON file: {error}"))?;
2595 let text =
2596 String::from_utf8(bytes).map_err(|error| format!("Invalid JSON file: {error}"))?;
2597 return serde_json::from_str(&text).map_err(|error| format!("Invalid JSON file: {error}"));
2598 }
2599
2600 parse_host_function_argv(&host_function.input_schema, args)
2601}
2602
2603fn host_callback_json_result(value: Value) -> Result<String, String> {
2604 serde_json::to_string(&value).map_err(|error| format!("Invalid host callback result: {error}"))
2605}
2606
2607fn parse_host_function_argv(schema: &Value, argv: &[String]) -> Result<Value, String> {
2608 let properties = schema
2609 .get("properties")
2610 .and_then(Value::as_object)
2611 .cloned()
2612 .unwrap_or_default();
2613 let required = schema
2614 .get("required")
2615 .and_then(Value::as_array)
2616 .map(|items| {
2617 items
2618 .iter()
2619 .filter_map(Value::as_str)
2620 .map(str::to_owned)
2621 .collect::<std::collections::BTreeSet<_>>()
2622 })
2623 .unwrap_or_default();
2624
2625 let mut flag_to_field = BTreeMap::new();
2626 for (field_name, field_schema) in &properties {
2627 flag_to_field.insert(
2628 camel_to_kebab(field_name),
2629 (field_name.clone(), field_schema.clone()),
2630 );
2631 }
2632
2633 let mut input = Map::new();
2634 let mut index = 0;
2635 while index < argv.len() {
2636 let arg = &argv[index];
2637 if !arg.starts_with("--") {
2638 return Err(format!("Unexpected positional argument: \"{arg}\""));
2639 }
2640
2641 let raw_flag = &arg[2..];
2642 let (flag_name, negated) = raw_flag
2643 .strip_prefix("no-")
2644 .map(|name| (name, true))
2645 .unwrap_or((raw_flag, false));
2646 let Some((field_name, field_schema)) = flag_to_field.get(flag_name) else {
2647 return Err(format!("Unknown flag: --{raw_flag}"));
2648 };
2649 let field_type = json_schema_type(field_schema);
2650
2651 if negated {
2652 if field_type != Some("boolean") {
2653 return Err(format!("Unknown flag: --{raw_flag}"));
2654 }
2655 input.insert(field_name.clone(), Value::Bool(false));
2656 index += 1;
2657 continue;
2658 }
2659
2660 match field_type {
2661 Some("boolean") => {
2662 input.insert(field_name.clone(), Value::Bool(true));
2663 index += 1;
2664 }
2665 Some("number") | Some("integer") => {
2666 let value = argv
2667 .get(index + 1)
2668 .ok_or_else(|| format!("Flag --{raw_flag} requires a value"))?;
2669 let number = value
2670 .parse::<f64>()
2671 .map_err(|_| format!("Flag --{raw_flag} expects a number, got \"{value}\""))?;
2672 let number = serde_json::Number::from_f64(number).ok_or_else(|| {
2673 format!("Flag --{raw_flag} expects a finite number, got \"{value}\"")
2674 })?;
2675 input.insert(field_name.clone(), Value::Number(number));
2676 index += 2;
2677 }
2678 Some("array") => {
2679 let value = argv
2680 .get(index + 1)
2681 .ok_or_else(|| format!("Flag --{raw_flag} requires a value"))?;
2682 let item_type = field_schema.get("items").and_then(json_schema_type);
2683 let parsed_value = match item_type {
2684 Some("number") | Some("integer") => {
2685 let number = value.parse::<f64>().map_err(|_| {
2686 format!("Flag --{raw_flag} expects a number value, got \"{value}\"")
2687 })?;
2688 let number = serde_json::Number::from_f64(number).ok_or_else(|| {
2689 format!(
2690 "Flag --{raw_flag} expects a finite number value, got \"{value}\""
2691 )
2692 })?;
2693 Value::Number(number)
2694 }
2695 Some("boolean") => {
2696 let boolean = value.parse::<bool>().map_err(|_| {
2697 format!("Flag --{raw_flag} expects a boolean value, got \"{value}\"")
2698 })?;
2699 Value::Bool(boolean)
2700 }
2701 _ => Value::String(value.clone()),
2702 };
2703 input
2704 .entry(field_name.clone())
2705 .or_insert_with(|| Value::Array(Vec::new()))
2706 .as_array_mut()
2707 .expect("array field should always contain an array")
2708 .push(parsed_value);
2709 index += 2;
2710 }
2711 _ => {
2712 let value = argv
2713 .get(index + 1)
2714 .ok_or_else(|| format!("Flag --{raw_flag} requires a value"))?;
2715 input.insert(field_name.clone(), Value::String(value.clone()));
2716 index += 2;
2717 }
2718 }
2719 }
2720
2721 for field_name in required {
2722 if !input.contains_key(&field_name) {
2723 return Err(format!(
2724 "Missing required flag: --{}",
2725 camel_to_kebab(&field_name)
2726 ));
2727 }
2728 }
2729
2730 Ok(Value::Object(input))
2731}
2732
2733#[derive(Debug, Clone, PartialEq, Eq)]
2734struct HostFunctionInputSchemaViolation {
2735 path: String,
2736 expected: String,
2737 actual: String,
2738}
2739
2740impl HostFunctionInputSchemaViolation {
2741 fn new(
2742 path: impl Into<String>,
2743 expected: impl Into<String>,
2744 actual: impl Into<String>,
2745 ) -> Self {
2746 Self {
2747 path: path.into(),
2748 expected: expected.into(),
2749 actual: actual.into(),
2750 }
2751 }
2752}
2753
2754impl std::fmt::Display for HostFunctionInputSchemaViolation {
2755 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2756 write!(
2757 f,
2758 "HostFunctionInputSchemaViolation at {}: expected {}, got {}",
2759 self.path, self.expected, self.actual
2760 )
2761 }
2762}
2763
2764fn validate_host_function_input(
2765 schema: &Value,
2766 input: &Value,
2767) -> Result<(), HostFunctionInputSchemaViolation> {
2768 validate_host_function_input_at_path(schema, input, "$")
2769}
2770
2771fn validate_host_function_input_at_path(
2772 schema: &Value,
2773 input: &Value,
2774 path: &str,
2775) -> Result<(), HostFunctionInputSchemaViolation> {
2776 if schema.is_null() || schema.as_object().is_some_and(|object| object.is_empty()) {
2777 return Ok(());
2778 }
2779 if let Some(branches) = schema.get("anyOf").and_then(Value::as_array) {
2780 return validate_schema_branches(branches, input, path, "anyOf");
2781 }
2782 if let Some(branches) = schema.get("oneOf").and_then(Value::as_array) {
2783 return validate_schema_branches(branches, input, path, "oneOf");
2784 }
2785 if let Some(enum_values) = schema.get("enum").and_then(Value::as_array) {
2786 if enum_values.iter().any(|candidate| candidate == input) {
2787 return Ok(());
2788 }
2789 return Err(HostFunctionInputSchemaViolation::new(
2790 path,
2791 format!(
2792 "one of {}",
2793 enum_values
2794 .iter()
2795 .map(compact_json)
2796 .collect::<Vec<_>>()
2797 .join(", ")
2798 ),
2799 describe_value(input),
2800 ));
2801 }
2802 if let Some(expected) = schema.get("const") {
2803 if expected == input {
2804 return Ok(());
2805 }
2806 return Err(HostFunctionInputSchemaViolation::new(
2807 path,
2808 format!("constant {}", compact_json(expected)),
2809 describe_value(input),
2810 ));
2811 }
2812
2813 match schema.get("type") {
2814 Some(Value::String(expected_type)) => {
2815 validate_typed_host_function_input(schema, input, path, expected_type)
2816 }
2817 Some(Value::Array(expected_types)) => {
2818 let mut first_error = None;
2819 for expected_type in expected_types.iter().filter_map(Value::as_str) {
2820 match validate_typed_host_function_input(schema, input, path, expected_type) {
2821 Ok(()) => return Ok(()),
2822 Err(error) if first_error.is_none() => first_error = Some(error),
2823 Err(_) => {}
2824 }
2825 }
2826 Err(first_error.unwrap_or_else(|| {
2827 HostFunctionInputSchemaViolation::new(
2828 path,
2829 describe_expected(schema),
2830 describe_value(input),
2831 )
2832 }))
2833 }
2834 Some(_) => Ok(()),
2835 None if has_object_keywords(schema) => {
2836 validate_typed_host_function_input(schema, input, path, "object")
2837 }
2838 None => Ok(()),
2839 }
2840}
2841
2842fn validate_schema_branches(
2843 branches: &[Value],
2844 input: &Value,
2845 path: &str,
2846 keyword: &str,
2847) -> Result<(), HostFunctionInputSchemaViolation> {
2848 let mut first_error = None;
2849 for branch in branches {
2850 match validate_host_function_input_at_path(branch, input, path) {
2851 Ok(()) => return Ok(()),
2852 Err(error) if first_error.is_none() => first_error = Some(error),
2853 Err(_) => {}
2854 }
2855 }
2856 Err(first_error.unwrap_or_else(|| {
2857 HostFunctionInputSchemaViolation::new(
2858 path,
2859 format!(
2860 "{keyword} branch ({})",
2861 branches
2862 .iter()
2863 .map(describe_expected)
2864 .collect::<Vec<_>>()
2865 .join(" | ")
2866 ),
2867 describe_value(input),
2868 )
2869 }))
2870}
2871
2872fn validate_typed_host_function_input(
2873 schema: &Value,
2874 input: &Value,
2875 path: &str,
2876 expected_type: &str,
2877) -> Result<(), HostFunctionInputSchemaViolation> {
2878 match expected_type {
2879 "null" if input.is_null() => Ok(()),
2880 "null" => Err(type_violation(path, expected_type, input)),
2881 "boolean" if input.is_boolean() => Ok(()),
2882 "boolean" => Err(type_violation(path, expected_type, input)),
2883 "string" => validate_string_host_function_input(schema, input, path),
2884 "number" => validate_number_host_function_input(schema, input, path, false),
2885 "integer" => validate_number_host_function_input(schema, input, path, true),
2886 "array" => validate_array_host_function_input(schema, input, path),
2887 "object" => validate_object_host_function_input(schema, input, path),
2888 _ => Ok(()),
2889 }
2890}
2891
2892fn validate_string_host_function_input(
2893 schema: &Value,
2894 input: &Value,
2895 path: &str,
2896) -> Result<(), HostFunctionInputSchemaViolation> {
2897 let Some(value) = input.as_str() else {
2898 return Err(type_violation(path, "string", input));
2899 };
2900 if let Some(min_length) = schema.get("minLength").and_then(Value::as_u64) {
2901 if value.chars().count() < min_length as usize {
2902 return Err(HostFunctionInputSchemaViolation::new(
2903 path,
2904 format!("string with minLength {min_length}"),
2905 format!("string length {}", value.chars().count()),
2906 ));
2907 }
2908 }
2909 if let Some(max_length) = schema.get("maxLength").and_then(Value::as_u64) {
2910 if value.chars().count() > max_length as usize {
2911 return Err(HostFunctionInputSchemaViolation::new(
2912 path,
2913 format!("string with maxLength {max_length}"),
2914 format!("string length {}", value.chars().count()),
2915 ));
2916 }
2917 }
2918 Ok(())
2919}
2920
2921fn validate_number_host_function_input(
2922 schema: &Value,
2923 input: &Value,
2924 path: &str,
2925 expect_integer: bool,
2926) -> Result<(), HostFunctionInputSchemaViolation> {
2927 let Some(number) = input.as_f64() else {
2928 return Err(type_violation(
2929 path,
2930 if expect_integer { "integer" } else { "number" },
2931 input,
2932 ));
2933 };
2934 if expect_integer && number.fract() != 0.0 {
2935 return Err(type_violation(path, "integer", input));
2936 }
2937 if let Some(minimum) = schema.get("minimum").and_then(Value::as_f64) {
2938 if number < minimum {
2939 return Err(HostFunctionInputSchemaViolation::new(
2940 path,
2941 format!(
2942 "{} >= {}",
2943 if expect_integer { "integer" } else { "number" },
2944 minimum
2945 ),
2946 compact_json(input),
2947 ));
2948 }
2949 }
2950 if let Some(minimum) = schema.get("exclusiveMinimum").and_then(Value::as_f64) {
2951 if number <= minimum {
2952 return Err(HostFunctionInputSchemaViolation::new(
2953 path,
2954 format!(
2955 "{} > {}",
2956 if expect_integer { "integer" } else { "number" },
2957 minimum
2958 ),
2959 compact_json(input),
2960 ));
2961 }
2962 }
2963 if let Some(maximum) = schema.get("maximum").and_then(Value::as_f64) {
2964 if number > maximum {
2965 return Err(HostFunctionInputSchemaViolation::new(
2966 path,
2967 format!(
2968 "{} <= {}",
2969 if expect_integer { "integer" } else { "number" },
2970 maximum
2971 ),
2972 compact_json(input),
2973 ));
2974 }
2975 }
2976 if let Some(maximum) = schema.get("exclusiveMaximum").and_then(Value::as_f64) {
2977 if number >= maximum {
2978 return Err(HostFunctionInputSchemaViolation::new(
2979 path,
2980 format!(
2981 "{} < {}",
2982 if expect_integer { "integer" } else { "number" },
2983 maximum
2984 ),
2985 compact_json(input),
2986 ));
2987 }
2988 }
2989 Ok(())
2990}
2991
2992fn validate_array_host_function_input(
2993 schema: &Value,
2994 input: &Value,
2995 path: &str,
2996) -> Result<(), HostFunctionInputSchemaViolation> {
2997 let Some(items) = input.as_array() else {
2998 return Err(type_violation(path, "array", input));
2999 };
3000 if let Some(min_items) = schema.get("minItems").and_then(Value::as_u64) {
3001 if items.len() < min_items as usize {
3002 return Err(HostFunctionInputSchemaViolation::new(
3003 path,
3004 format!("array with minItems {min_items}"),
3005 format!("array length {}", items.len()),
3006 ));
3007 }
3008 }
3009 if let Some(max_items) = schema.get("maxItems").and_then(Value::as_u64) {
3010 if items.len() > max_items as usize {
3011 return Err(HostFunctionInputSchemaViolation::new(
3012 path,
3013 format!("array with maxItems {max_items}"),
3014 format!("array length {}", items.len()),
3015 ));
3016 }
3017 }
3018 if let Some(item_schema) = schema.get("items") {
3019 for (index, item) in items.iter().enumerate() {
3020 validate_host_function_input_at_path(item_schema, item, &format!("{path}[{index}]"))?;
3021 }
3022 }
3023 Ok(())
3024}
3025
3026fn validate_object_host_function_input(
3027 schema: &Value,
3028 input: &Value,
3029 path: &str,
3030) -> Result<(), HostFunctionInputSchemaViolation> {
3031 let Some(object) = input.as_object() else {
3032 return Err(type_violation(path, "object", input));
3033 };
3034 let properties = schema
3035 .get("properties")
3036 .and_then(Value::as_object)
3037 .cloned()
3038 .unwrap_or_default();
3039 let required = schema
3040 .get("required")
3041 .and_then(Value::as_array)
3042 .cloned()
3043 .unwrap_or_default();
3044 for field in required.iter().filter_map(Value::as_str) {
3045 if !object.contains_key(field) {
3046 let field_path = format!("{path}.{field}");
3047 let expected = properties
3048 .get(field)
3049 .map(describe_expected)
3050 .unwrap_or_else(|| String::from("required value"));
3051 return Err(HostFunctionInputSchemaViolation::new(
3052 field_path,
3053 expected,
3054 "missing value",
3055 ));
3056 }
3057 }
3058 for (field, value) in object {
3059 let field_path = format!("{path}.{field}");
3060 if let Some(field_schema) = properties.get(field) {
3061 validate_host_function_input_at_path(field_schema, value, &field_path)?;
3062 continue;
3063 }
3064 match schema.get("additionalProperties") {
3065 Some(Value::Bool(false)) => {
3066 return Err(HostFunctionInputSchemaViolation::new(
3067 field_path,
3068 "no additional properties",
3069 describe_value(value),
3070 ));
3071 }
3072 Some(additional_schema) => {
3073 validate_host_function_input_at_path(additional_schema, value, &field_path)?;
3074 }
3075 None => {}
3076 }
3077 }
3078 Ok(())
3079}
3080
3081fn has_object_keywords(schema: &Value) -> bool {
3082 schema.get("properties").is_some()
3083 || schema.get("required").is_some()
3084 || schema.get("additionalProperties").is_some()
3085}
3086
3087fn type_violation(path: &str, expected: &str, input: &Value) -> HostFunctionInputSchemaViolation {
3088 HostFunctionInputSchemaViolation::new(path, expected, describe_value(input))
3089}
3090
3091fn describe_expected(schema: &Value) -> String {
3092 if let Some(enum_values) = schema.get("enum").and_then(Value::as_array) {
3093 return format!(
3094 "one of {}",
3095 enum_values
3096 .iter()
3097 .map(compact_json)
3098 .collect::<Vec<_>>()
3099 .join(", ")
3100 );
3101 }
3102 if let Some(expected) = schema.get("const") {
3103 return format!("constant {}", compact_json(expected));
3104 }
3105 match schema.get("type") {
3106 Some(Value::String(expected_type)) => expected_type.clone(),
3107 Some(Value::Array(expected_types)) => expected_types
3108 .iter()
3109 .filter_map(Value::as_str)
3110 .collect::<Vec<_>>()
3111 .join(" | "),
3112 _ if has_object_keywords(schema) => String::from("object"),
3113 _ => String::from("value"),
3114 }
3115}
3116
3117fn describe_value(value: &Value) -> String {
3118 match value {
3119 Value::Null => String::from("null"),
3120 Value::Bool(_) => String::from("boolean"),
3121 Value::Number(number) => {
3122 let is_integer = number.as_i64().is_some()
3123 || number.as_u64().is_some()
3124 || number.as_f64().is_some_and(|float| float.fract() == 0.0);
3125 if is_integer {
3126 String::from("integer")
3127 } else {
3128 String::from("number")
3129 }
3130 }
3131 Value::String(_) => String::from("string"),
3132 Value::Array(_) => String::from("array"),
3133 Value::Object(_) => String::from("object"),
3134 }
3135}
3136
3137fn compact_json(value: &Value) -> String {
3138 serde_json::to_string(value).unwrap_or_else(|_| String::from("<invalid json>"))
3139}
3140
3141fn list_host_functions_payload(host_functions: &[ResolvedHostFunctions]) -> Value {
3142 Value::Object(Map::from_iter([(
3143 String::from("hostFunctions"),
3144 Value::Array(
3145 host_functions
3146 .iter()
3147 .map(|collection| {
3148 json_object([
3149 ("name", Value::String(collection.name.clone())),
3150 (
3151 "functions",
3152 Value::Array(
3153 collection
3154 .functions
3155 .iter()
3156 .map(|host_function| Value::String(host_function.name.clone()))
3157 .collect(),
3158 ),
3159 ),
3160 ])
3161 })
3162 .collect(),
3163 ),
3164 )]))
3165}
3166
3167fn describe_host_functions_payload(
3168 host_functions: &[ResolvedHostFunctions],
3169 collection_name: &str,
3170) -> Result<Value, String> {
3171 let Some(collection) = host_functions
3172 .iter()
3173 .find(|collection| collection.name == collection_name)
3174 else {
3175 return Err(format!(
3176 "No host function collection \"{collection_name}\". Available: {}",
3177 host_functions_names(host_functions)
3178 ));
3179 };
3180 Ok(json_object([
3181 ("name", Value::String(collection.name.clone())),
3182 (
3183 "functions",
3184 Value::Object(Map::from_iter(collection.functions.iter().map(
3185 |host_function| {
3186 (
3187 host_function.name.clone(),
3188 json_object([
3189 (
3190 "description",
3191 Value::String(host_function.description.clone()),
3192 ),
3193 (
3194 "flags",
3195 Value::Array(describe_host_function_flags(
3196 &host_function.input_schema,
3197 )),
3198 ),
3199 ]),
3200 )
3201 },
3202 ))),
3203 ),
3204 ]))
3205}
3206
3207fn describe_host_function_payload(
3208 collection: &ResolvedHostFunctions,
3209 host_function_name: &str,
3210) -> Result<Value, String> {
3211 let Some(host_function) = collection
3212 .functions
3213 .iter()
3214 .find(|host_function| host_function.name == host_function_name)
3215 else {
3216 return Err(format!(
3217 "No host function \"{host_function_name}\" in collection \"{}\". Available: {}",
3218 collection.name,
3219 host_function_names(collection)
3220 ));
3221 };
3222 Ok(json_object([
3223 ("collection", Value::String(collection.name.clone())),
3224 ("function", Value::String(host_function_name.to_string())),
3225 (
3226 "description",
3227 Value::String(host_function.description.clone()),
3228 ),
3229 (
3230 "flags",
3231 Value::Array(describe_host_function_flags(&host_function.input_schema)),
3232 ),
3233 ("examples", Value::Array(Vec::new())),
3234 ]))
3235}
3236
3237fn describe_host_function_flags(schema: &Value) -> Vec<Value> {
3238 let properties = schema
3239 .get("properties")
3240 .and_then(Value::as_object)
3241 .cloned()
3242 .unwrap_or_default();
3243 let required = schema
3244 .get("required")
3245 .and_then(Value::as_array)
3246 .map(|items| {
3247 items
3248 .iter()
3249 .filter_map(Value::as_str)
3250 .map(str::to_owned)
3251 .collect::<std::collections::BTreeSet<_>>()
3252 })
3253 .unwrap_or_default();
3254 properties
3255 .into_iter()
3256 .map(|(field_name, field_schema)| {
3257 json_object([
3258 (
3259 "name",
3260 Value::String(format!("--{}", camel_to_kebab(&field_name))),
3261 ),
3262 (
3263 "type",
3264 Value::String(describe_host_function_flag_type(&field_schema)),
3265 ),
3266 ("required", Value::Bool(required.contains(&field_name))),
3267 ])
3268 })
3269 .collect()
3270}
3271
3272fn describe_host_function_flag_type(schema: &Value) -> String {
3273 match json_schema_type(schema) {
3274 Some("array") => {
3275 let item_type = schema
3276 .get("items")
3277 .and_then(json_schema_type)
3278 .unwrap_or("string");
3279 format!("{item_type}[]")
3280 }
3281 Some("string") => schema
3282 .get("enum")
3283 .and_then(Value::as_array)
3284 .map(|values| values.iter().filter_map(Value::as_str).collect::<Vec<_>>())
3285 .filter(|values| !values.is_empty())
3286 .map(|values| values.join("|"))
3287 .unwrap_or_else(|| String::from("string")),
3288 Some(other) => other.to_string(),
3289 None => String::from("string"),
3290 }
3291}
3292
3293fn host_functions_names(host_functions: &[ResolvedHostFunctions]) -> String {
3294 host_functions
3295 .iter()
3296 .map(|collection| collection.name.clone())
3297 .collect::<Vec<_>>()
3298 .join(", ")
3299}
3300
3301fn host_function_names(collection: &ResolvedHostFunctions) -> String {
3302 collection
3303 .functions
3304 .iter()
3305 .map(|host_function| host_function.name.clone())
3306 .collect::<Vec<_>>()
3307 .join(", ")
3308}
3309
3310fn is_help_flag(value: &str) -> bool {
3311 matches!(value, "--help" | "-h")
3312}
3313
3314fn json_schema_type(schema: &Value) -> Option<&str> {
3315 schema.get("type").and_then(Value::as_str)
3316}
3317
3318fn camel_to_kebab(value: &str) -> String {
3319 let mut output = String::new();
3320 for (index, ch) in value.chars().enumerate() {
3321 if ch.is_ascii_uppercase() && index > 0 {
3322 output.push('-');
3323 }
3324 output.push(ch.to_ascii_lowercase());
3325 }
3326 output
3327}
3328
3329fn normalize_guest_path(path: String) -> String {
3330 let absolute = path.starts_with('/');
3331 let mut parts = Vec::new();
3332 for part in path.split('/') {
3333 match part {
3334 "" | "." => {}
3335 ".." => {
3336 parts.pop();
3337 }
3338 _ => parts.push(part),
3339 }
3340 }
3341 let normalized = parts.join("/");
3342 if absolute {
3343 format!("/{normalized}")
3344 } else {
3345 normalized
3346 }
3347}
3348
3349fn json_object<const N: usize>(entries: [(&str, Value); N]) -> Value {
3350 Value::Object(Map::from_iter(
3351 entries
3352 .into_iter()
3353 .map(|(key, value)| (key.to_string(), value)),
3354 ))
3355}
3356
3357pub(crate) fn build_package_descriptors(config: &AgentOsConfig) -> Vec<wire::PackageDescriptor> {
3360 config
3361 .packages
3362 .iter()
3363 .map(|package| wire::PackageDescriptor {
3364 path: package.path.clone(),
3365 })
3366 .collect()
3367}
3368
3369fn projected_agents_from_wire(agents: Vec<wire::AgentosProjectedAgent>) -> Vec<ProjectedAgent> {
3370 agents
3371 .into_iter()
3372 .map(|agent| ProjectedAgent {
3373 id: agent.id,
3374 acp_entrypoint: agent.acp_entrypoint,
3375 adapter_entrypoint: agent.adapter_entrypoint,
3376 })
3377 .collect()
3378}
3379
3380fn register_projected_agents(
3381 projected_agents: &parking_lot::Mutex<Vec<ProjectedAgent>>,
3382 agents: Vec<ProjectedAgent>,
3383) {
3384 let mut guard = projected_agents.lock();
3385 for agent in agents {
3386 guard.retain(|existing| existing.id != agent.id);
3387 guard.push(agent);
3388 }
3389}
3390
3391pub(crate) fn serialize_mounts(
3392 config: &AgentOsConfig,
3393) -> Result<Vec<wire::MountDescriptor>, ClientError> {
3394 config
3395 .mounts
3396 .iter()
3397 .map(|mount| match mount {
3398 MountConfig::Native {
3399 path,
3400 plugin,
3401 guest_source,
3402 guest_fstype,
3403 read_only,
3404 } => {
3405 let plugin_config = plugin
3406 .config
3407 .clone()
3408 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
3409 Ok(wire::MountDescriptor {
3410 guest_path: path.clone(),
3411 guest_source: guest_source.clone().unwrap_or_else(|| plugin.id.clone()),
3412 guest_fstype: guest_fstype.clone().unwrap_or_else(|| plugin.id.clone()),
3413 read_only: *read_only,
3414 plugin: wire::MountPluginDescriptor {
3415 id: plugin.id.clone(),
3416 config: json_utf8(&plugin_config, "native mount plugin config")?,
3417 },
3418 })
3419 }
3420 MountConfig::Plain { .. } => Err(ClientError::Sidecar(
3421 "plain mounts cannot be configured during Rust client VM creation".to_string(),
3422 )),
3423 MountConfig::Overlay { .. } => Err(ClientError::Sidecar(
3424 "overlay mounts cannot be configured during Rust client VM creation".to_string(),
3425 )),
3426 })
3427 .collect()
3428}
3429
3430pub(crate) fn permissions_policy(config: &AgentOsConfig) -> Option<wire::PermissionsPolicy> {
3433 let permissions = config.permissions.as_ref()?;
3434 Some(wire::PermissionsPolicy {
3435 fs: permissions.fs.as_ref().map(serialize_fs_permissions),
3436 network: permissions
3437 .network
3438 .as_ref()
3439 .map(serialize_pattern_permissions),
3440 child_process: permissions
3441 .child_process
3442 .as_ref()
3443 .map(serialize_pattern_permissions),
3444 process: permissions
3445 .process
3446 .as_ref()
3447 .map(serialize_pattern_permissions),
3448 env: permissions.env.as_ref().map(serialize_pattern_permissions),
3449 host_function: permissions
3450 .host_function
3451 .as_ref()
3452 .map(serialize_pattern_permissions),
3453 })
3454}
3455
3456fn serialize_fs_permissions(permissions: &crate::config::FsPermissions) -> wire::FsPermissionScope {
3457 match permissions {
3458 crate::config::FsPermissions::Mode(mode) => {
3459 wire::FsPermissionScope::PermissionMode(serialize_permission_mode(*mode))
3460 }
3461 crate::config::FsPermissions::Rules(rules) => {
3462 wire::FsPermissionScope::FsPermissionRuleSet(wire::FsPermissionRuleSet {
3463 default: rules.default.map(serialize_permission_mode),
3464 rules: rules
3465 .rules
3466 .iter()
3467 .map(|rule| wire::FsPermissionRule {
3468 mode: serialize_permission_mode(rule.mode),
3469 operations: operation_wildcard_if_omitted(&rule.operations),
3470 paths: resource_wildcard_if_omitted(&rule.paths),
3471 })
3472 .collect(),
3473 })
3474 }
3475 }
3476}
3477
3478fn serialize_pattern_permissions(
3479 permissions: &crate::config::PatternPermissions,
3480) -> wire::PatternPermissionScope {
3481 match permissions {
3482 crate::config::PatternPermissions::Mode(mode) => {
3483 wire::PatternPermissionScope::PermissionMode(serialize_permission_mode(*mode))
3484 }
3485 crate::config::PatternPermissions::Rules(rules) => {
3486 wire::PatternPermissionScope::PatternPermissionRuleSet(wire::PatternPermissionRuleSet {
3487 default: rules.default.map(serialize_permission_mode),
3488 rules: rules
3489 .rules
3490 .iter()
3491 .map(|rule| wire::PatternPermissionRule {
3492 mode: serialize_permission_mode(rule.mode),
3493 operations: operation_wildcard_if_omitted(&rule.operations),
3494 patterns: resource_wildcard_if_omitted(&rule.patterns),
3495 })
3496 .collect(),
3497 })
3498 }
3499 }
3500}
3501
3502fn serialize_permission_mode(mode: crate::config::PermissionMode) -> wire::PermissionMode {
3503 match mode {
3504 crate::config::PermissionMode::Allow => wire::PermissionMode::Allow,
3505 crate::config::PermissionMode::Deny => wire::PermissionMode::Deny,
3506 }
3507}
3508
3509fn json_utf8(value: &serde_json::Value, context: &str) -> Result<String, ClientError> {
3510 serde_json::to_string(value)
3511 .map_err(|error| ClientError::Sidecar(format!("failed to serialize {context}: {error}")))
3512}
3513
3514fn operation_wildcard_if_omitted(values: &Option<Vec<String>>) -> Vec<String> {
3515 values.clone().unwrap_or_else(|| vec!["*".to_string()])
3516}
3517
3518fn resource_wildcard_if_omitted(values: &Option<Vec<String>>) -> Vec<String> {
3519 values.clone().unwrap_or_else(|| vec!["**".to_string()])
3520}
3521
3522fn wire_ownership_vm_id(ownership: &wire::OwnershipScope) -> Option<&str> {
3524 match ownership {
3525 wire::OwnershipScope::VmOwnership(ownership) => Some(ownership.vm_id.as_str()),
3526 wire::OwnershipScope::ConnectionOwnership(_)
3527 | wire::OwnershipScope::SessionOwnership(_) => None,
3528 }
3529}
3530
3531fn rejected_to_error(rejected: wire::RejectedResponse) -> ClientError {
3533 ClientError::Kernel {
3534 code: rejected.code,
3535 message: rejected.message,
3536 }
3537}
3538
3539#[cfg(test)]
3540mod tests {
3541 use super::{
3542 abort_tracked_task, permissions_policy, serialize_create_vm_config_for_sidecar,
3543 serialize_root_filesystem_config_for_sidecar, JoinHandle,
3544 };
3545 use crate::config::{
3546 AgentOsConfig, AgentOsLimits, FsPermissionRule, FsPermissions, HostFunctionLimits,
3547 HttpLimits, JsRuntimeLimits, MountPlugin, PatternPermissions, PermissionMode, Permissions,
3548 PythonLimits, ResourceLimits, RootFilesystemConfig, RootFilesystemKind, RootFilesystemMode,
3549 RootLowerInput, RulePermissions, WasmLimits,
3550 };
3551 use crate::fs::{
3552 DirEntryType, FilesystemEntry, FilesystemEntryEncoding, FilesystemSnapshotEntries,
3553 FilesystemSnapshotExport, RootSnapshotExport, SnapshotExportKind,
3554 };
3555 use agentos_sidecar_client::wire::{
3556 FsPermissionScope, PatternPermissionScope, PermissionMode as WirePermissionMode,
3557 };
3558 use agentos_vm_config::{
3559 RootFilesystemEntryKind, RootFilesystemLowerDescriptor,
3560 RootFilesystemMode as ConfigRootFilesystemMode,
3561 };
3562
3563 #[tokio::test]
3575 async fn abort_tracked_task_aborts_and_clears_the_handle() {
3576 let slot: parking_lot::Mutex<Option<JoinHandle<()>>> = parking_lot::Mutex::new(None);
3578 assert!(
3579 slot.lock().is_none(),
3580 "pump slot starts empty like AgentOsInner"
3581 );
3582
3583 let task = tokio::spawn(async {
3584 loop {
3585 tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
3586 }
3587 });
3588 let abort_handle = task.abort_handle();
3589 *slot.lock() = Some(task);
3591 assert!(
3592 slot.lock().is_some(),
3593 "spawning the pump must populate the tracked handle"
3594 );
3595
3596 assert!(!abort_handle.is_finished(), "pump task should start alive");
3597
3598 abort_tracked_task(&slot);
3599
3600 assert!(
3601 slot.lock().is_none(),
3602 "tracked handle must be taken on abort"
3603 );
3604
3605 for _ in 0..100 {
3607 if abort_handle.is_finished() {
3608 break;
3609 }
3610 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3611 }
3612 assert!(
3613 abort_handle.is_finished(),
3614 "pump task must be aborted on shutdown"
3615 );
3616 }
3617
3618 #[test]
3619 fn permissions_policy_is_omitted_when_unset() {
3620 assert_eq!(permissions_policy(&AgentOsConfig::default()), None);
3622 }
3623
3624 #[test]
3625 fn permissions_policy_sends_only_configured_scopes() {
3626 let policy = permissions_policy(&AgentOsConfig {
3627 permissions: Some(Permissions {
3628 network: Some(PatternPermissions::Mode(PermissionMode::Deny)),
3629 ..Default::default()
3630 }),
3631 ..Default::default()
3632 })
3633 .expect("configured policy");
3634
3635 assert_eq!(
3636 policy.network,
3637 Some(PatternPermissionScope::PermissionMode(
3638 WirePermissionMode::Deny
3639 ))
3640 );
3641 assert_eq!(policy.fs, None);
3642 assert_eq!(policy.child_process, None);
3643 assert_eq!(policy.host_function, None);
3644 }
3645
3646 #[test]
3647 fn permissions_policy_expands_omitted_rule_fields_to_domain_wildcards() {
3648 let policy = permissions_policy(&AgentOsConfig {
3649 permissions: Some(Permissions {
3650 fs: Some(FsPermissions::Rules(RulePermissions {
3651 default: Some(PermissionMode::Deny),
3652 rules: vec![FsPermissionRule {
3653 mode: PermissionMode::Allow,
3654 operations: None,
3655 paths: Some(vec!["/workspace/**".to_string()]),
3656 }],
3657 })),
3658 ..Default::default()
3659 }),
3660 ..Default::default()
3661 })
3662 .expect("configured policy");
3663
3664 let Some(FsPermissionScope::FsPermissionRuleSet(rules)) = policy.fs else {
3665 panic!("expected fs rule set");
3666 };
3667 assert_eq!(rules.default, Some(WirePermissionMode::Deny));
3668 assert_eq!(rules.rules[0].operations, vec!["*"]);
3669 assert_eq!(rules.rules[0].paths, vec!["/workspace/**"]);
3670
3671 let policy = permissions_policy(&AgentOsConfig {
3672 permissions: Some(Permissions {
3673 network: Some(PatternPermissions::Rules(RulePermissions {
3674 default: Some(PermissionMode::Allow),
3675 rules: vec![crate::config::PatternPermissionRule {
3676 mode: PermissionMode::Deny,
3677 operations: None,
3678 patterns: None,
3679 }],
3680 })),
3681 ..Default::default()
3682 }),
3683 ..Default::default()
3684 })
3685 .expect("configured policy");
3686
3687 let Some(PatternPermissionScope::PatternPermissionRuleSet(rules)) = policy.network else {
3688 panic!("expected network rule set");
3689 };
3690 assert_eq!(rules.default, Some(WirePermissionMode::Allow));
3691 assert_eq!(rules.rules[0].operations, vec!["*"]);
3692 assert_eq!(rules.rules[0].patterns, vec!["**"]);
3693 }
3694
3695 #[test]
3696 fn root_filesystem_serializer_preserves_configured_descriptor() {
3697 let (descriptor, native_root) =
3698 serialize_root_filesystem_config_for_sidecar(&RootFilesystemConfig {
3699 mode: Some(RootFilesystemMode::ReadOnly),
3700 disable_default_base_layer: true,
3701 lowers: vec![
3702 RootLowerInput::BundledBaseFilesystem,
3703 RootLowerInput::SnapshotExport(RootSnapshotExport {
3704 kind: SnapshotExportKind::SnapshotExport,
3705 source: FilesystemSnapshotExport {
3706 format: "agentos-filesystem-snapshot-v1".to_string(),
3707 filesystem: FilesystemSnapshotEntries {
3708 entries: vec![
3709 FilesystemEntry {
3710 path: "/bin/run".to_string(),
3711 entry_type: DirEntryType::File,
3712 mode: "0755".to_string(),
3713 uid: 1000,
3714 gid: 1000,
3715 content: Some("#!/bin/sh".to_string()),
3716 encoding: Some(FilesystemEntryEncoding::Utf8),
3717 target: None,
3718 },
3719 FilesystemEntry {
3720 path: "/link".to_string(),
3721 entry_type: DirEntryType::Symlink,
3722 mode: "0777".to_string(),
3723 uid: 0,
3724 gid: 0,
3725 content: None,
3726 encoding: None,
3727 target: Some("/bin/run".to_string()),
3728 },
3729 ],
3730 },
3731 },
3732 }),
3733 ],
3734 ..Default::default()
3735 })
3736 .expect("serialize root filesystem");
3737
3738 assert!(native_root.is_none());
3739 assert_eq!(descriptor.mode, ConfigRootFilesystemMode::ReadOnly);
3740 assert!(descriptor.disable_default_base_layer);
3741 assert_eq!(descriptor.bootstrap_entries, Vec::new());
3742 assert!(matches!(
3743 descriptor.lowers[0],
3744 RootFilesystemLowerDescriptor::BundledBaseFilesystem
3745 ));
3746
3747 let RootFilesystemLowerDescriptor::Snapshot { entries } = &descriptor.lowers[1] else {
3748 panic!("expected snapshot lower");
3749 };
3750 assert_eq!(entries[0].path, "/bin/run");
3751 assert_eq!(entries[0].kind, RootFilesystemEntryKind::File);
3752 assert_eq!(entries[0].mode, Some(0o755));
3753 assert!(entries[0].executable);
3754 assert_eq!(entries[1].kind, RootFilesystemEntryKind::Symlink);
3755 assert_eq!(entries[1].target.as_deref(), Some("/bin/run"));
3756 }
3757
3758 #[test]
3759 fn create_vm_config_preserves_native_root_config() {
3760 let config = serialize_create_vm_config_for_sidecar(&AgentOsConfig {
3761 root_filesystem: RootFilesystemConfig {
3762 kind: RootFilesystemKind::Native,
3763 mode: Some(RootFilesystemMode::ReadOnly),
3764 native_plugin: Some(MountPlugin {
3765 id: "sqlite_vfs".to_string(),
3766 config: Some(serde_json::json!({
3767 "databasePath": "/tmp/agentos-root.sqlite"
3768 })),
3769 }),
3770 ..Default::default()
3771 },
3772 ..Default::default()
3773 })
3774 .expect("serialize create VM config");
3775 let native_root = config.native_root.expect("native root config");
3776
3777 assert_eq!(native_root.plugin.id, "sqlite_vfs");
3778 assert_eq!(
3779 native_root.plugin.config,
3780 serde_json::json!({ "databasePath": "/tmp/agentos-root.sqlite" })
3781 );
3782 assert!(native_root.read_only);
3783 }
3784
3785 #[test]
3786 fn create_vm_config_preserves_typed_limits() {
3787 let config = serialize_create_vm_config_for_sidecar(&AgentOsConfig {
3788 limits: Some(AgentOsLimits {
3789 resources: Some(ResourceLimits {
3790 max_processes: Some(7),
3791 max_filesystem_bytes: Some(4096),
3792 ..Default::default()
3793 }),
3794 http: Some(HttpLimits {
3795 max_fetch_response_bytes: Some(1024),
3796 }),
3797 host_functions: Some(HostFunctionLimits {
3798 default_timeout_ms: Some(500),
3799 max_registered_functions_per_vm: Some(12),
3800 ..Default::default()
3801 }),
3802 js_runtime: Some(JsRuntimeLimits {
3803 v8_heap_limit_mb: Some(64),
3804 sync_rpc_wait_timeout_ms: Some(2_000),
3805 cpu_time_limit_ms: Some(30_000),
3806 wall_clock_limit_ms: Some(0),
3807 import_cache_materialize_timeout_ms: Some(30_000),
3808 ..Default::default()
3809 }),
3810 python: Some(PythonLimits {
3811 max_old_space_mb: Some(256),
3812 ..Default::default()
3813 }),
3814 wasm: Some(WasmLimits {
3815 prewarm_timeout_ms: Some(30_000),
3816 runner_heap_limit_mb: Some(2_048),
3817 runner_cpu_time_limit_ms: Some(60_000),
3818 ..Default::default()
3819 }),
3820 ..Default::default()
3821 }),
3822 ..Default::default()
3823 })
3824 .expect("serialize create VM config");
3825 let limits = config.limits.expect("limits config");
3826
3827 let resources = limits.resources.expect("resource limits");
3828 assert_eq!(resources.max_processes, Some(7));
3829 assert_eq!(resources.max_filesystem_bytes, Some(4096));
3830 assert_eq!(
3831 limits.http.expect("http limits").max_fetch_response_bytes,
3832 Some(1024)
3833 );
3834 assert_eq!(
3835 limits
3836 .host_functions
3837 .as_ref()
3838 .expect("host_function limits")
3839 .default_timeout_ms,
3840 Some(500)
3841 );
3842 assert_eq!(
3843 limits
3844 .host_functions
3845 .expect("host_function limits")
3846 .max_registered_functions_per_vm,
3847 Some(12)
3848 );
3849 assert_eq!(
3850 limits
3851 .js_runtime
3852 .as_ref()
3853 .expect("js runtime limits")
3854 .v8_heap_limit_mb,
3855 Some(64)
3856 );
3857 let js_runtime = limits.js_runtime.expect("js runtime limits");
3858 assert_eq!(js_runtime.sync_rpc_wait_timeout_ms, Some(2_000));
3859 assert_eq!(js_runtime.cpu_time_limit_ms, Some(30_000));
3860 assert_eq!(js_runtime.wall_clock_limit_ms, Some(0));
3861 assert_eq!(js_runtime.import_cache_materialize_timeout_ms, Some(30_000));
3862 assert_eq!(
3863 limits.python.expect("python limits").max_old_space_mb,
3864 Some(256)
3865 );
3866 let wasm = limits.wasm.expect("wasm limits");
3867 assert_eq!(wasm.prewarm_timeout_ms, Some(30_000));
3868 assert_eq!(wasm.runner_heap_limit_mb, Some(2_048));
3869 assert_eq!(wasm.runner_cpu_time_limit_ms, Some(60_000));
3870 }
3871}