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