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