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