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