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