Skip to main content

agentos_client/
agent_os.rs

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