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