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