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